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/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). diff --git a/bin/cli.js b/bin/cli.js index 79858a1..0ec2cf1 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,129 @@ 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 } = urlData; + + // Find the test data in the YAML files + const testData = textPropertyUtils.findTestData(options.test, options.handle); + consola.info(`Found test "${options.test}" in ${testData.handle}/tests/${testData.file}`); + + // 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); + 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, accountId, 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) { + for (const update of updates) { + consola.info(`[dry-run] ${update.level}: ${update.properties.length} properties`); + consola.log(JSON.stringify(update.properties, null, 2)); + } + return; + } + + // 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) { + 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 program .command("check-dependencies") diff --git a/lib/api/sfApi.js b/lib/api/sfApi.js index 6e54717..0ef6d66 100644 --- a/lib/api/sfApi.js +++ b/lib/api/sfApi.js @@ -572,6 +572,66 @@ 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 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 { @@ -761,6 +821,10 @@ module.exports = { getCompanyCustom, getPeriodCustom, getAllPeriodCustom, + updateReconciliationCustom, + updateCompanyCustom, + updatePeriodCustom, + updateAccountCustom, getWorkflows, getWorkflowInformation, findReconciliationInWorkflow, diff --git a/lib/utils/textPropertyUtils.js b/lib/utils/textPropertyUtils.js new file mode 100644 index 0000000..f408797 --- /dev/null +++ b/lib/utils/textPropertyUtils.js @@ -0,0 +1,128 @@ +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. + * 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) { + 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(); + }); + + 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, merge: true }); + + 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) { + 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; + } + + // Reconciliation-level custom + if (periodData.reconciliations) { + for (const [reconHandle, reconData] of Object.entries(periodData.reconciliations)) { + if (reconData?.custom) { + periodEntry.reconciliations[reconHandle] = reconData.custom; + } + } + } + + // Account-level custom + if (periodData.accounts) { + for (const [accountNumber, accountData] of Object.entries(periodData.accounts)) { + if (accountData?.custom) { + periodEntry.accounts[accountNumber] = accountData.custom; + } + } + } + + result.periods[periodKey] = periodEntry; + } + } + + return result; + } + } + + consola.error(`Test "${testName}" not found in any YAML file`); + process.exit(1); +} + +module.exports = { transformCustomToProperties, findTestData }; 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",