Skip to content
Open
Show file tree
Hide file tree
Changes from 13 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
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,4 @@ node_modules
./tmp
.cursor
/tmp
/tmp-*
/tmp-*
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.56.3] (02/07/2026)
Added `update-text-properties` command. It uploads custom text properties from a Liquid Test YAML file to a company file at company, period, reconciliation and account levels for the entries referenced in the test scenario. Usage: `silverfin update-text-properties -u <url> -t <test-name>`. Supports `--handle` for faster YAML file lookup, `--dry-run` to preview the payload, and `--yes` to skip the confirmation prompt.

## [1.56.2] (25/06/2026)
Send the staging HTTP Basic Auth header on firm OAuth token requests only when the staging gateway actually requires it (detected via a one-time `WWW-Authenticate: Basic` probe). Fixes `silverfin authorize` and token refresh failing with "unknown client" on stagings that have HTTP basic auth disabled.

Expand Down
141 changes: 141 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,145 @@ 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 company file. Pushes custom data at the company, period, reconciliation and account levels for every entry referenced in the test scenario (not only the reconciliation in the URL). The URL identifies the target firm/company."
)
.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)
.option("-y, --yes", "Skip the confirmation prompt before 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.
if (!firmId || !companyId) {
consola.error("Could not determine the firm and company from the URL. Double-check the Silverfin URL and try again.");
return;
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 all periods once (paginated) 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) {
periodsArray = await SF.getAllPeriods(firmId, companyId);

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: SF.getAllPeriods(firmId, companyId) isn't wrapped in try/catch here — a network error escapes every per-update failure handler in this loop and aborts the whole command, discarding the hadFailures accounting already collected for prior updates (company-level, and any earlier periods). Convergent finding from 2 independent finders.

if (!periodsArray) {
  try {
    periodsArray = await SF.getAllPeriods(firmId, companyId);
  } catch (error) {
    consola.error(`Could not fetch periods: ${error.message}`);
    process.exitCode = 1;
    return;
  }
}

}
const period = periodsArray.find((p) => p.fiscal_year?.end_date === periodKey);
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;
}

// Summarise what will be written and where, then confirm (unless --yes)
const firmName = firmCredentials.getFirmName(firmId);
consola.warn(`About to write custom data to company ${companyId} on firm ${firmName ? `${firmName} (${firmId})` : firmId}:`);
for (const update of updates) {
consola.warn(` • ${update.level}: ${update.properties.length} properties`);
}
if (!options.yes) {
cliUtils.promptConfirmation();
}

// 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
93 changes: 93 additions & 0 deletions lib/api/sfApi.js
Original file line number Diff line number Diff line change
Expand Up @@ -505,6 +505,34 @@ async function getPeriods(firmId, companyId, page = 1) {
}
}

async function getAllPeriods(firmId, companyId) {
const fetchPage = async (page) => {
const response = await getPeriods(firmId, companyId, page);
if (!response || !response.data) {
return [];
}

return response.data || [];
};

const items = [];
let page = 1;
let hasMore = true;

while (hasMore && page <= MAX_PAGES) {
const pageData = await fetchPage(page);
items.push(...pageData);
hasMore = pageData.length === PER_PAGE;
page++;
}

if (page > MAX_PAGES) {
consola.warn(`Reached maximum page limit (${MAX_PAGES}) while fetching company periods. There might be more data available.`);
}

return items;
}

function findPeriod(periodId, periodsArray) {
return periodsArray.find((period) => period.id == periodId);
}
Expand Down Expand Up @@ -572,6 +600,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 @@ -756,11 +844,16 @@ module.exports = {
createTestRun,
createPreviewRun,
getPeriods,
getAllPeriods,
findPeriod,
getCompanyDrop,
getCompanyCustom,
getPeriodCustom,
getAllPeriodCustom,
updateReconciliationCustom,
updateCompanyCustom,
updatePeriodCustom,
updateAccountCustom,
getWorkflows,
getWorkflowInformation,
findReconciliationInWorkflow,
Expand Down
Loading