Skip to content
Open
Show file tree
Hide file tree
Changes from 10 commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
4c3a0e5
Add helper function to post TPs
bensilverfin Mar 16, 2026
0c5811d
Add utils to parse YAML files and create POST payload
bensilverfin Mar 16, 2026
d48e328
Add consola commands
bensilverfin Mar 16, 2026
6fe5858
Fix to skip null period in textPropertyUtils
bensilverfin Mar 16, 2026
b3b059e
Handle anchors and aliases
bensilverfin Mar 16, 2026
40984d8
Update changelog
bensilverfin Mar 16, 2026
fd48970
Support all custom property levels in update-text-properties
bensilverfin Mar 23, 2026
bfc7a5c
Bump version to 1.55.0
bensilverfin Mar 23, 2026
06d247e
Fix account ID guard and exit code on update failures
bensilverfin Mar 23, 2026
ec00fec
Merge remote-tracking branch 'origin/main' into implement-reverse-test
bensilverfin Apr 22, 2026
41cc5cf
Merge origin/main (1.56.1) into implement-reverse-test
Benjvandam Jun 29, 2026
1e85a88
Address PR #249 review comments
Benjvandam Jun 29, 2026
37a92be
Merge origin/main (1.56.2) into implement-reverse-test and bump versi…
Benjvandam Jul 2, 2026
aeddf10
Address review: batch alignment, non-aborting batch errors, exit codes
Benjvandam Jul 3, 2026
2fe2422
Merge main and bump to 1.56.4 (1.56.3 taken by run-test --status fix)
Benjvandam Jul 6, 2026
309830e
Fix period resolution ambiguity and mid-batch aborts in update-text-p…
Benjvandam Jul 6, 2026
962ae39
Resolve update-text-properties test file via config.json, add --file …
Benjvandam Jul 6, 2026
374155f
Document config.json test file resolution and --file in changelog
Benjvandam Jul 6, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
node_modules
.env
.DS_Store
./tmp
./tmp
.cursor
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <url> -t <test-name>`. Supports `--handle` for faster YAML file lookup and `--dry-run` to preview the payload without uploading.

## [1.54.1] (14/04/2026)
Increase the waiting time for the test runs to avoid timeout errors.

Expand Down
125 changes: 125 additions & 0 deletions bin/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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")
Comment thread
Benjvandam marked this conversation as resolved.
Outdated
.requiredOption("-u, --url <url>", "Specify the full Silverfin URL of the reconciliation in the company file (mandatory)")
.requiredOption("-t, --test <test-name>", "Specify the name of the test to use as data source (mandatory)")
.option("-h, --handle <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;
Comment thread
Benjvandam marked this conversation as resolved.

// 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);
Comment thread
Benjvandam marked this conversation as resolved.
Outdated
periodsArray = periodsResponse?.data || [];
}
const period = periodsArray.find((p) => p.fiscal_year.end_date === periodKey);
Comment thread
Benjvandam marked this conversation as resolved.
Outdated
if (!period) {
consola.warn(`Period "${periodKey}" not found in company — skipping`);
Comment thread
Benjvandam marked this conversation as resolved.
Outdated
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();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 Major: await update.apply() isn't wrapped in a try/catch, so a thrown error from the reconciliation/account lookup helpers aborts the entire remaining batch — the same failure mode this PR just fixed for the write calls via batchResponseErrorHandler.

Concretely, apply() for reconciliation/account updates (lines 597-598, 614-615) calls SF.findReconciliationInWorkflows / SF.findAccountByNumber, which still resolve through the old apiUtils.responseErrorHandler:

  • On 403/422 it calls process.exit(1) directly — killing the whole command mid-batch.
  • On any other unhandled status (401, 5xx) it rethrows, which isn't caught anywhere in this loop, so it becomes an uncaught exception (errorUtils.uncaughtErrorsprocess.exit(1)).
  • If getWorkflows (called from findReconciliationInWorkflows) returns an error response instead of throwing (e.g. 404), responseWorkflows.data is undefined, and for (const workflow of responseWorkflows.data) throws a TypeError.

Any of these mid-batch turns what should be one failed update (tracked via hadFailures) into a hard crash that silently drops every remaining update in the run — undermining the batch-continuation goal this PR just implemented for the direct write path.

Suggested fix — wrap the resolve+apply step so lookup failures are treated the same as write failures:

for (const update of updates) {
  consola.start(`Updating ${update.level} (${update.properties.length} properties)...`);
  let response;
  try {
    response = await update.apply();
  } catch (error) {
    hadFailures = true;
    consola.error(`${update.level}: failed to resolve/update — ${error.message}`);
    continue;
  }
  if (!response) {
    hadFailures = true;
    continue;
  }
  ...
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — the apply loop now wraps update.apply() in a try/catch: a thrown error (network failure, lookup 500) is logged, counted as a failure (exit code 1) and the batch continues with the remaining updates. Also guarded findReconciliationInWorkflows against an undefined workflows response, so a handled 404 warns "not found" instead of throwing.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale: This was fixed. update.apply() is now wrapped in try/catch in the apply loop (confirmed at HEAD) — a thrown error is logged and counted via hadFailures. No action needed.

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`);
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
if (hadFailures) {
process.exitCode = 1;
}
});

// Check Liquid Test dependencies for a reconciliation template
program
.command("check-dependencies")
Expand Down
64 changes: 64 additions & 0 deletions lib/api/sfApi.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Comment thread
Benjvandam marked this conversation as resolved.
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);
Comment thread
Benjvandam marked this conversation as resolved.
Outdated
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 {
Expand Down Expand Up @@ -761,6 +821,10 @@ module.exports = {
getCompanyCustom,
getPeriodCustom,
getAllPeriodCustom,
updateReconciliationCustom,
updateCompanyCustom,
updatePeriodCustom,
updateAccountCustom,
getWorkflows,
getWorkflowInformation,
findReconciliationInWorkflow,
Expand Down
128 changes: 128 additions & 0 deletions lib/utils/textPropertyUtils.js
Original file line number Diff line number Diff line change
@@ -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) {
Comment thread
Benjvandam marked this conversation as resolved.
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;
Comment thread
Benjvandam marked this conversation as resolved.
}
Comment thread
michieldegezelle marked this conversation as resolved.
}

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 });
Comment thread
Benjvandam marked this conversation as resolved.
Outdated

if (!parsed || !parsed[testName]) continue;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

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 };
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "silverfin-cli",
"version": "1.54.1",
"version": "1.55.0",
"description": "Command line tool for Silverfin template development",
"main": "index.js",
"license": "MIT",
Expand Down
Loading