From 4c3a0e566f3e981406c8d50f31c856a8701d368f Mon Sep 17 00:00:00 2001 From: Benjamin Van Dam Date: Mon, 16 Mar 2026 20:12:12 +0100 Subject: [PATCH 1/9] Add helper function to post TPs --- lib/api/sfApi.js | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/lib/api/sfApi.js b/lib/api/sfApi.js index 6e54717..66aa975 100644 --- a/lib/api/sfApi.js +++ b/lib/api/sfApi.js @@ -572,6 +572,18 @@ async function getAllPeriodCustom(firmId, companyId, periodId) { return items; } +async function updateReconciliationCustom(firmId, companyId, periodId, reconciliationId, properties) { + const instance = AxiosFactory.createInstance("firm", firmId); + try { + const response = await instance.post(`/companies/${companyId}/periods/${periodId}/reconciliations/${reconciliationId}/custom`, { properties }); + apiUtils.responseSuccessHandler(response); + return response; + } catch (error) { + const response = await apiUtils.responseErrorHandler(error); + return response; + } +} + async function getWorkflows(firmId, companyId, periodId) { const instance = AxiosFactory.createInstance("firm", firmId); try { @@ -761,6 +773,7 @@ module.exports = { getCompanyCustom, getPeriodCustom, getAllPeriodCustom, + updateReconciliationCustom, getWorkflows, getWorkflowInformation, findReconciliationInWorkflow, From 0c5811d9fcd32c67284934b4d318e7efd517c899 Mon Sep 17 00:00:00 2001 From: Benjamin Van Dam Date: Mon, 16 Mar 2026 20:18:34 +0100 Subject: [PATCH 2/9] Add utils to parse YAML files and create POST payload --- lib/utils/textPropertyUtils.js | 130 +++++++++++++++++++++++++++++++++ 1 file changed, 130 insertions(+) create mode 100644 lib/utils/textPropertyUtils.js diff --git a/lib/utils/textPropertyUtils.js b/lib/utils/textPropertyUtils.js new file mode 100644 index 0000000..7fc0aeb --- /dev/null +++ b/lib/utils/textPropertyUtils.js @@ -0,0 +1,130 @@ +const fs = require("fs"); +const path = require("path"); +const yaml = require("yaml"); +const { consola } = require("consola"); +const fsUtils = require("./fsUtils"); + +/** + * Transform a YAML custom properties object into the Silverfin API format. + * Input: flat object with dot-notation keys (e.g. "namespace.key.subkey": value) + * Output: array of { namespace, key, value } objects + */ +function transformCustomToProperties(customData) { + const namespaceMap = new Map(); + + for (const [fullKey, value] of Object.entries(customData)) { + const keyParts = fullKey.split("."); + + if (keyParts.length < 2) { + consola.warn(`Skipping key "${fullKey}" — expected namespace.key format`); + continue; + } + + const namespace = keyParts[0]; + const key = keyParts[1]; + const namespaceKey = `${namespace}.${key}`; + + if (keyParts.length === 2) { + if (!namespaceMap.has(namespaceKey)) { + namespaceMap.set(namespaceKey, { namespace, key, value }); + } + } else { + if (!namespaceMap.has(namespaceKey)) { + namespaceMap.set(namespaceKey, { namespace, key, value: {} }); + } + const subKey = keyParts.slice(2).join("."); + namespaceMap.get(namespaceKey).value[subKey] = value; + } + } + + return Array.from(namespaceMap.values()); +} + +/** + * Find a test by name across YAML files in a template's tests/ folder. + * If handle is provided, only search that handle's folder. + * If not, scan all reconciliation_texts folders and use reconciliationId to disambiguate. + * Returns { custom, handle, periodKey } or exits with an error. + */ +function findTestData(testName, handle, reconciliationId, firmId) { + const templateType = "reconciliationText"; + const baseDir = path.join(process.cwd(), fsUtils.FOLDERS[templateType]); + + if (!fs.existsSync(baseDir)) { + consola.error(`Directory not found: ${fsUtils.FOLDERS[templateType]}`); + process.exit(1); + } + + const handleDirs = handle ? [handle] : fs.readdirSync(baseDir).filter((entry) => { + return fs.statSync(path.join(baseDir, entry)).isDirectory(); + }); + + const matches = []; + + for (const dir of handleDirs) { + const testsDir = path.join(baseDir, dir, "tests"); + if (!fs.existsSync(testsDir)) continue; + + const yamlFiles = fs.readdirSync(testsDir).filter((f) => f.endsWith(".yml")); + + for (const file of yamlFiles) { + const filePath = path.join(testsDir, file); + const content = fs.readFileSync(filePath, "utf-8"); + const parsed = yaml.parse(content, { maxAliasCount: 10000 }); + + if (!parsed || !parsed[testName]) continue; + + const testData = parsed[testName]; + const periods = testData?.data?.periods; + if (!periods) continue; + + const periodKey = Object.keys(periods)[0]; + const reconciliations = periods[periodKey]?.reconciliations; + if (!reconciliations) continue; + + for (const [reconHandle, reconData] of Object.entries(reconciliations)) { + if (reconData?.custom) { + matches.push({ + handle: dir, + reconHandle, + periodKey, + custom: reconData.custom, + file, + }); + } + } + } + } + + if (matches.length === 0) { + consola.error(`Test "${testName}" not found in any YAML file`); + process.exit(1); + } + + if (matches.length === 1) { + return matches[0]; + } + + // Multiple matches — disambiguate using reconciliationId from config.json + if (reconciliationId && firmId) { + for (const match of matches) { + try { + const config = fsUtils.readConfig(templateType, match.handle); + const templateId = config?.id?.[firmId]; + if (String(templateId) === String(reconciliationId)) { + return match; + } + } catch { + // config not found for this handle, skip + } + } + } + + consola.error( + `Test "${testName}" found in multiple templates: ${matches.map((m) => m.handle).join(", ")}. ` + + `Use --handle to specify which one.` + ); + process.exit(1); +} + +module.exports = { transformCustomToProperties, findTestData }; From d48e3282a202e990841bda1019499516b595a6fa Mon Sep 17 00:00:00 2001 From: Benjamin Van Dam Date: Mon, 16 Mar 2026 20:20:59 +0100 Subject: [PATCH 3/9] Add consola commands --- .gitignore | 3 ++- bin/cli.js | 39 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 1ef6b16..d3240e6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ node_modules .env .DS_Store -./tmp \ No newline at end of file +./tmp +.cursor \ No newline at end of file diff --git a/bin/cli.js b/bin/cli.js index 79858a1..2891bf5 100755 --- a/bin/cli.js +++ b/bin/cli.js @@ -19,6 +19,8 @@ const { runCommandChecks } = require("../lib/cli/utils"); const { CwdValidator } = require("../lib/cli/cwdValidator"); const { AutoCompletions } = require("../lib/cli/autoCompletions"); const fsUtils = require("../lib/utils/fsUtils"); +const textPropertyUtils = require("../lib/utils/textPropertyUtils"); +const liquidTestUtils = require("../lib/utils/liquidTestUtils"); const firmIdDefault = cliUtils.loadDefaultFirmId(); cliUtils.handleUncaughtErrors(); @@ -527,6 +529,43 @@ program liquidTestGenerator.testGenerator(options.url, testName, reconciledStatus); }); +// Update Text Properties from Liquid Test data +program + .command("update-text-properties") + .description("Upload custom text properties from a Liquid Test YAML file to a reconciliation in a company file") + .requiredOption("-u, --url ", "Specify the full Silverfin URL of the reconciliation in the company file (mandatory)") + .requiredOption("-t, --test ", "Specify the name of the test to use as data source (mandatory)") + .option("-h, --handle ", "Specify the reconciliation handle to narrow down the YAML file search (optional)") + .option("--dry-run", "Only transform and display the properties without uploading (optional)", false) + .action(async (options) => { + // Parse URL to extract IDs + const urlData = liquidTestUtils.extractURL(options.url); + const { firmId, companyId, ledgerId: periodId, reconciliationId } = urlData; + + // Find the test data in the YAML files + const testData = textPropertyUtils.findTestData(options.test, options.handle, reconciliationId, firmId); + consola.info(`Found test "${options.test}" in ${testData.handle}/tests/${testData.file}`); + + // Transform custom properties to API format + const properties = textPropertyUtils.transformCustomToProperties(testData.custom); + consola.info(`Transformed ${properties.length} properties`); + + if (options.dryRun) { + consola.info("Dry run — properties that would be uploaded:"); + consola.log(JSON.stringify(properties, null, 2)); + return; + } + + // Upload to Silverfin + const response = await SF.updateReconciliationCustom(firmId, companyId, periodId, reconciliationId, properties); + if (response && response.status >= 200 && response.status < 300) { + consola.success("Text properties updated successfully"); + } else { + consola.error("Failed to update text properties"); + process.exit(1); + } + }); + // Check Liquid Test dependencies for a reconciliation template program .command("check-dependencies") From 6fe5858f04b4cf164c868a724c28b0fc2fdebe06 Mon Sep 17 00:00:00 2001 From: Benjamin Van Dam Date: Mon, 16 Mar 2026 21:30:50 +0100 Subject: [PATCH 4/9] Fix to skip null period in textPropertyUtils --- lib/utils/textPropertyUtils.js | 27 ++++++++++++++------------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/lib/utils/textPropertyUtils.js b/lib/utils/textPropertyUtils.js index 7fc0aeb..6a2bb0c 100644 --- a/lib/utils/textPropertyUtils.js +++ b/lib/utils/textPropertyUtils.js @@ -78,19 +78,20 @@ function findTestData(testName, handle, reconciliationId, firmId) { const periods = testData?.data?.periods; if (!periods) continue; - const periodKey = Object.keys(periods)[0]; - const reconciliations = periods[periodKey]?.reconciliations; - if (!reconciliations) continue; - - for (const [reconHandle, reconData] of Object.entries(reconciliations)) { - if (reconData?.custom) { - matches.push({ - handle: dir, - reconHandle, - periodKey, - custom: reconData.custom, - file, - }); + for (const [periodKey, periodData] of Object.entries(periods)) { + const reconciliations = periodData?.reconciliations; + if (!reconciliations) continue; + + for (const [reconHandle, reconData] of Object.entries(reconciliations)) { + if (reconData?.custom) { + matches.push({ + handle: dir, + reconHandle, + periodKey, + custom: reconData.custom, + file, + }); + } } } } From b3b059e9911e1e326abbdceac18289e07e9a71c4 Mon Sep 17 00:00:00 2001 From: Benjamin Van Dam Date: Mon, 16 Mar 2026 21:34:05 +0100 Subject: [PATCH 5/9] Handle anchors and aliases --- lib/utils/textPropertyUtils.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/utils/textPropertyUtils.js b/lib/utils/textPropertyUtils.js index 6a2bb0c..3d924d7 100644 --- a/lib/utils/textPropertyUtils.js +++ b/lib/utils/textPropertyUtils.js @@ -70,7 +70,7 @@ function findTestData(testName, handle, reconciliationId, firmId) { for (const file of yamlFiles) { const filePath = path.join(testsDir, file); const content = fs.readFileSync(filePath, "utf-8"); - const parsed = yaml.parse(content, { maxAliasCount: 10000 }); + const parsed = yaml.parse(content, { maxAliasCount: 10000, merge: true }); if (!parsed || !parsed[testName]) continue; From 40984d89946d74ab5c736dc8c2242e64554ec841 Mon Sep 17 00:00:00 2001 From: Benjamin Van Dam Date: Mon, 16 Mar 2026 21:48:19 +0100 Subject: [PATCH 6/9] Update changelog --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 03f4286..ed773fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ All notable changes to this project will be documented in this file. +## [1.55.0] (16/03/2026) +Added `update-text-properties` command. It uploads custom text properties from a Liquid Test YAML file to a reconciliation in a company file. Usage: `silverfin update-text-properties -u -t `. Supports `--handle` for faster YAML file lookup and `--dry-run` to preview the payload without uploading. + ## [1.54.0] (17/02/2026) Added `create-test` command support for account templates (fetches template data, period data, and custom data). From 20fc0e0d27d114869eaebb3a1531a17b12d71500 Mon Sep 17 00:00:00 2001 From: Benjamin Van Dam Date: Mon, 23 Mar 2026 15:46:30 +0100 Subject: [PATCH 7/9] Support all custom property levels in update-text-properties Extract and push custom data from all 4 levels in liquid test YAML: company, period, reconciliation, and account. Company/period/account endpoints send one property per request. Reconciliation and account IDs are resolved via the API by handle/number. Co-Authored-By: Claude Opus 4.6 (1M context) --- bin/cli.js | 105 ++++++++++++++++++++++++++++----- lib/api/sfApi.js | 51 ++++++++++++++++ lib/utils/textPropertyUtils.js | 87 +++++++++++++-------------- 3 files changed, 184 insertions(+), 59 deletions(-) diff --git a/bin/cli.js b/bin/cli.js index 2891bf5..77603a7 100755 --- a/bin/cli.js +++ b/bin/cli.js @@ -540,29 +540,106 @@ program .action(async (options) => { // Parse URL to extract IDs const urlData = liquidTestUtils.extractURL(options.url); - const { firmId, companyId, ledgerId: periodId, reconciliationId } = urlData; + const { firmId, companyId } = urlData; // Find the test data in the YAML files - const testData = textPropertyUtils.findTestData(options.test, options.handle, reconciliationId, firmId); + const testData = textPropertyUtils.findTestData(options.test, options.handle); consola.info(`Found test "${options.test}" in ${testData.handle}/tests/${testData.file}`); - // Transform custom properties to API format - const properties = textPropertyUtils.transformCustomToProperties(testData.custom); - consola.info(`Transformed ${properties.length} properties`); + // Collect all updates to perform + const updates = []; + + // Company-level custom + if (testData.company?.custom) { + const properties = textPropertyUtils.transformCustomToProperties(testData.company.custom); + updates.push({ level: "company", properties, apply: () => SF.updateCompanyCustom(firmId, companyId, properties) }); + } + + // Fetch periods once if needed for resolving period dates + let periodsArray = null; + + for (const [periodKey, periodEntry] of Object.entries(testData.periods)) { + // Resolve period date to period ID + if (!periodsArray) { + const periodsResponse = await SF.getPeriods(firmId, companyId); + periodsArray = periodsResponse?.data || []; + } + const period = periodsArray.find((p) => p.fiscal_year.end_date === periodKey); + if (!period) { + consola.warn(`Period "${periodKey}" not found in company — skipping`); + continue; + } + const targetPeriodId = period.id; + + // Period-level custom + if (periodEntry.custom) { + const properties = textPropertyUtils.transformCustomToProperties(periodEntry.custom); + updates.push({ level: `period [${periodKey}]`, properties, apply: () => SF.updatePeriodCustom(firmId, companyId, targetPeriodId, properties) }); + } + + // Reconciliation-level custom + for (const [reconHandle, customData] of Object.entries(periodEntry.reconciliations)) { + const properties = textPropertyUtils.transformCustomToProperties(customData); + updates.push({ + level: `reconciliation [${reconHandle}] in ${periodKey}`, + properties, + apply: async () => { + const recon = await SF.findReconciliationInWorkflows(firmId, reconHandle, companyId, targetPeriodId); + if (!recon) { + consola.error(`Reconciliation "${reconHandle}" not found in any workflow for period ${periodKey}`); + return null; + } + return SF.updateReconciliationCustom(firmId, companyId, targetPeriodId, recon.id, properties); + }, + }); + } + + // Account-level custom + for (const [accountNumber, customData] of Object.entries(periodEntry.accounts)) { + const properties = textPropertyUtils.transformCustomToProperties(customData); + updates.push({ + level: `account [${accountNumber}] in ${periodKey}`, + properties, + apply: async () => { + const account = await SF.findAccountByNumber(firmId, companyId, targetPeriodId, accountNumber); + if (!account) { + consola.error(`Account "${accountNumber}" not found for period ${periodKey}`); + return null; + } + return SF.updateAccountCustom(firmId, companyId, targetPeriodId, account.account.id, properties); + }, + }); + } + } + + if (updates.length === 0) { + consola.warn("No custom properties found in this test"); + return; + } + + consola.info(`Found ${updates.length} custom update(s) to apply`); if (options.dryRun) { - consola.info("Dry run — properties that would be uploaded:"); - consola.log(JSON.stringify(properties, null, 2)); + for (const update of updates) { + consola.info(`[dry-run] ${update.level}: ${update.properties.length} properties`); + consola.log(JSON.stringify(update.properties, null, 2)); + } return; } - // Upload to Silverfin - const response = await SF.updateReconciliationCustom(firmId, companyId, periodId, reconciliationId, properties); - if (response && response.status >= 200 && response.status < 300) { - consola.success("Text properties updated successfully"); - } else { - consola.error("Failed to update text properties"); - process.exit(1); + // Apply all updates + for (const update of updates) { + consola.start(`Updating ${update.level} (${update.properties.length} properties)...`); + const response = await update.apply(); + if (!response) continue; + // Handle both single response (reconciliation) and array of responses (company/period/account) + const responses = Array.isArray(response) ? response : [response]; + const failed = responses.filter((r) => !r || r.status < 200 || r.status >= 300); + if (failed.length === 0) { + consola.success(`${update.level}: updated`); + } else { + consola.error(`${update.level}: ${failed.length}/${responses.length} failed`); + } } }); diff --git a/lib/api/sfApi.js b/lib/api/sfApi.js index 66aa975..0ef6d66 100644 --- a/lib/api/sfApi.js +++ b/lib/api/sfApi.js @@ -584,6 +584,54 @@ async function updateReconciliationCustom(firmId, companyId, periodId, reconcili } } +async function updateCompanyCustom(firmId, companyId, properties) { + const instance = AxiosFactory.createInstance("firm", firmId); + const results = []; + for (const prop of properties) { + try { + const response = await instance.post(`/companies/${companyId}/custom`, prop); + apiUtils.responseSuccessHandler(response); + results.push(response); + } catch (error) { + const response = await apiUtils.responseErrorHandler(error); + results.push(response); + } + } + return results; +} + +async function updatePeriodCustom(firmId, companyId, periodId, properties) { + const instance = AxiosFactory.createInstance("firm", firmId); + const results = []; + for (const prop of properties) { + try { + const response = await instance.post(`/companies/${companyId}/periods/${periodId}/custom`, prop); + apiUtils.responseSuccessHandler(response); + results.push(response); + } catch (error) { + const response = await apiUtils.responseErrorHandler(error); + results.push(response); + } + } + return results; +} + +async function updateAccountCustom(firmId, companyId, periodId, accountId, properties) { + const instance = AxiosFactory.createInstance("firm", firmId); + const results = []; + for (const prop of properties) { + try { + const response = await instance.post(`/companies/${companyId}/periods/${periodId}/accounts/${accountId}/custom`, prop); + apiUtils.responseSuccessHandler(response); + results.push(response); + } catch (error) { + const response = await apiUtils.responseErrorHandler(error); + results.push(response); + } + } + return results; +} + async function getWorkflows(firmId, companyId, periodId) { const instance = AxiosFactory.createInstance("firm", firmId); try { @@ -774,6 +822,9 @@ module.exports = { getPeriodCustom, getAllPeriodCustom, updateReconciliationCustom, + updateCompanyCustom, + updatePeriodCustom, + updateAccountCustom, getWorkflows, getWorkflowInformation, findReconciliationInWorkflow, diff --git a/lib/utils/textPropertyUtils.js b/lib/utils/textPropertyUtils.js index 3d924d7..f408797 100644 --- a/lib/utils/textPropertyUtils.js +++ b/lib/utils/textPropertyUtils.js @@ -43,10 +43,12 @@ function transformCustomToProperties(customData) { /** * Find a test by name across YAML files in a template's tests/ folder. * If handle is provided, only search that handle's folder. - * If not, scan all reconciliation_texts folders and use reconciliationId to disambiguate. - * Returns { custom, handle, periodKey } or exits with an error. + * If not, scan all reconciliation_texts folders. + * Extracts custom data from all 4 levels: company, period, reconciliation, account. + * Returns { file, handle, company, periods } where periods contains per-period custom, + * reconciliation custom, and account custom data. */ -function findTestData(testName, handle, reconciliationId, firmId) { +function findTestData(testName, handle) { const templateType = "reconciliationText"; const baseDir = path.join(process.cwd(), fsUtils.FOLDERS[templateType]); @@ -59,8 +61,6 @@ function findTestData(testName, handle, reconciliationId, firmId) { return fs.statSync(path.join(baseDir, entry)).isDirectory(); }); - const matches = []; - for (const dir of handleDirs) { const testsDir = path.join(baseDir, dir, "tests"); if (!fs.existsSync(testsDir)) continue; @@ -75,56 +75,53 @@ function findTestData(testName, handle, reconciliationId, firmId) { if (!parsed || !parsed[testName]) continue; const testData = parsed[testName]; + const result = { file, handle: dir, company: null, periods: {} }; + + // Company-level custom + if (testData?.data?.company?.custom) { + result.company = { custom: testData.data.company.custom }; + } + + // Period-level data const periods = testData?.data?.periods; - if (!periods) continue; - - for (const [periodKey, periodData] of Object.entries(periods)) { - const reconciliations = periodData?.reconciliations; - if (!reconciliations) continue; - - for (const [reconHandle, reconData] of Object.entries(reconciliations)) { - if (reconData?.custom) { - matches.push({ - handle: dir, - reconHandle, - periodKey, - custom: reconData.custom, - file, - }); + if (periods) { + for (const [periodKey, periodData] of Object.entries(periods)) { + if (!periodData) continue; + + const periodEntry = { custom: null, reconciliations: {}, accounts: {} }; + + // Period-level custom + if (periodData.custom) { + periodEntry.custom = periodData.custom; } - } - } - } - } - if (matches.length === 0) { - consola.error(`Test "${testName}" not found in any YAML file`); - process.exit(1); - } + // Reconciliation-level custom + if (periodData.reconciliations) { + for (const [reconHandle, reconData] of Object.entries(periodData.reconciliations)) { + if (reconData?.custom) { + periodEntry.reconciliations[reconHandle] = reconData.custom; + } + } + } - if (matches.length === 1) { - return matches[0]; - } + // Account-level custom + if (periodData.accounts) { + for (const [accountNumber, accountData] of Object.entries(periodData.accounts)) { + if (accountData?.custom) { + periodEntry.accounts[accountNumber] = accountData.custom; + } + } + } - // Multiple matches — disambiguate using reconciliationId from config.json - if (reconciliationId && firmId) { - for (const match of matches) { - try { - const config = fsUtils.readConfig(templateType, match.handle); - const templateId = config?.id?.[firmId]; - if (String(templateId) === String(reconciliationId)) { - return match; + result.periods[periodKey] = periodEntry; } - } catch { - // config not found for this handle, skip } + + return result; } } - consola.error( - `Test "${testName}" found in multiple templates: ${matches.map((m) => m.handle).join(", ")}. ` + - `Use --handle to specify which one.` - ); + consola.error(`Test "${testName}" not found in any YAML file`); process.exit(1); } From 0013ece7f256a633c901787ebed244de3939db55 Mon Sep 17 00:00:00 2001 From: Benjamin Van Dam Date: Mon, 23 Mar 2026 15:51:09 +0100 Subject: [PATCH 8/9] Bump version to 1.55.0 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 287b023..1f9d64c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "silverfin-cli", - "version": "1.54.0", + "version": "1.55.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "silverfin-cli", - "version": "1.54.0", + "version": "1.55.0", "license": "MIT", "dependencies": { "axios": "^1.6.2", diff --git a/package.json b/package.json index 71536fd..aa43672 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "silverfin-cli", - "version": "1.54.0", + "version": "1.55.0", "description": "Command line tool for Silverfin template development", "main": "index.js", "license": "MIT", From 32eb811dec19b0871f8d0c15e5784be15b9403ad Mon Sep 17 00:00:00 2001 From: Benjamin Van Dam Date: Mon, 23 Mar 2026 16:01:25 +0100 Subject: [PATCH 9/9] Fix account ID guard and exit code on update failures --- bin/cli.js | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/bin/cli.js b/bin/cli.js index 77603a7..0ec2cf1 100755 --- a/bin/cli.js +++ b/bin/cli.js @@ -602,11 +602,12 @@ program properties, apply: async () => { const account = await SF.findAccountByNumber(firmId, companyId, targetPeriodId, accountNumber); - if (!account) { - consola.error(`Account "${accountNumber}" not found for period ${periodKey}`); + const accountId = account?.account?.id; + if (!accountId) { + consola.error(`Account "${accountNumber}" could not be resolved for period ${periodKey}`); return null; } - return SF.updateAccountCustom(firmId, companyId, targetPeriodId, account.account.id, properties); + return SF.updateAccountCustom(firmId, companyId, targetPeriodId, accountId, properties); }, }); } @@ -628,19 +629,27 @@ program } // Apply all updates + let hadFailures = false; for (const update of updates) { consola.start(`Updating ${update.level} (${update.properties.length} properties)...`); const response = await update.apply(); - if (!response) continue; + if (!response) { + hadFailures = true; + continue; + } // Handle both single response (reconciliation) and array of responses (company/period/account) const responses = Array.isArray(response) ? response : [response]; const failed = responses.filter((r) => !r || r.status < 200 || r.status >= 300); if (failed.length === 0) { consola.success(`${update.level}: updated`); } else { + hadFailures = true; consola.error(`${update.level}: ${failed.length}/${responses.length} failed`); } } + if (hadFailures) { + process.exitCode = 1; + } }); // Check Liquid Test dependencies for a reconciliation template