Skip to content
Open
Show file tree
Hide file tree
Changes from all 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.4] (06/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>`. The YAML file read is the one referenced by the template's `config.json` `test` key (same as `run-test`); pass `--file <exact-file-name>` to read another file, e.g. a year variant like `*_TY25_liquid_test.yml`. When the test name exists in several templates the command lists them and asks for `--handle` instead of guessing. Also supports `--dry-run` to preview the payload and `--yes` to skip the confirmation prompt.

## [1.56.3] (03/07/2026)
Improve `run-test --status` output for CI: surface the underlying error message when a run ends in `test_error`/`internal_error` (previously reported as a bare `FAILED` with no reason), and suppress the progress spinner when stdout is not a TTY (it flooded CI logs with hundreds of "Running tests.." frames).

Expand Down
156 changes: 156 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,160 @@ 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("--file <file-name>", "Specify the exact YAML file name (inside the template's tests/ folder) to read the test from, instead of the test file referenced by the template's config.json (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.");
process.exitCode = 1;
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, options.file);
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);
}
const { period, error: periodError } = textPropertyUtils.findPeriodByKey(periodsArray, periodKey);
if (!period) {
// Skipping still seeds the rest of the scenario, but the partial seed must
// be detectable: flag it as a failure, consistent with the
// reconciliation/account not-found paths.
consola.error(`${periodError} — skipping (marked as failure)`);
process.exitCode = 1;
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)...`);
// A throw (network error, unexpected lookup failure) must count as a
// per-item failure, not abort the batch mid-way with no summary.
let response;
try {
response = await update.apply();
} catch (error) {
hadFailures = true;
consola.error(`${update.level}: failed (${error.message})`);
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`);
}
}
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
89 changes: 88 additions & 1 deletion 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,60 @@ async function getAllPeriodCustom(firmId, companyId, periodId) {
return items;
}

// The period/reconciliation/account `/custom` endpoints accept a BULK
// `{ properties: [...] }` body (verified live); the company `/custom` endpoint
// does NOT — it returns 400 unless each custom is POSTed as a single
// `{ namespace, key, value }` object, hence the per-property loop there.
// All four use batchResponseErrorHandler so one failed write (e.g. 422) is
// counted as a failure by the caller instead of aborting the whole batch.
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) {
return apiUtils.batchResponseErrorHandler(error);
}
}

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) {
results.push(apiUtils.batchResponseErrorHandler(error));
}
}
return results;
}

async function updatePeriodCustom(firmId, companyId, periodId, properties) {
const instance = AxiosFactory.createInstance("firm", firmId);
try {
const response = await instance.post(`/companies/${companyId}/periods/${periodId}/custom`, { properties });
apiUtils.responseSuccessHandler(response);
return response;
} catch (error) {
return apiUtils.batchResponseErrorHandler(error);
}
}

async function updateAccountCustom(firmId, companyId, periodId, accountId, properties) {
const instance = AxiosFactory.createInstance("firm", firmId);
try {
const response = await instance.post(`/companies/${companyId}/periods/${periodId}/accounts/${accountId}/custom`, { properties });
apiUtils.responseSuccessHandler(response);
return response;
} catch (error) {
return apiUtils.batchResponseErrorHandler(error);
}
}

async function getWorkflows(firmId, companyId, periodId) {
const instance = AxiosFactory.createInstance("firm", firmId);
try {
Expand Down Expand Up @@ -616,7 +698,7 @@ async function findReconciliationInWorkflows(firmId, reconciliationHandle, compa
// Get data from all workflows
const responseWorkflows = await getWorkflows(firmId, companyId, periodId);
// Check in each workflow
for (const workflow of responseWorkflows.data) {
for (const workflow of responseWorkflows?.data ?? []) {
const reconciliationInformation = await findReconciliationInWorkflow(firmId, reconciliationHandle, companyId, periodId, workflow.id);
// Found
if (reconciliationInformation) {
Expand Down Expand Up @@ -756,11 +838,16 @@ module.exports = {
createTestRun,
createPreviewRun,
getPeriods,
getAllPeriods,
findPeriod,
getCompanyDrop,
getCompanyCustom,
getPeriodCustom,
getAllPeriodCustom,
updateReconciliationCustom,
updateCompanyCustom,
updatePeriodCustom,
updateAccountCustom,
getWorkflows,
getWorkflowInformation,
findReconciliationInWorkflow,
Expand Down
16 changes: 16 additions & 0 deletions lib/utils/apiUtils.js
Original file line number Diff line number Diff line change
Expand Up @@ -59,9 +59,25 @@ async function responseErrorHandler(error) {
throw error;
}

// Like responseErrorHandler, but NEVER terminates the process — for batch flows
// (e.g. the update*Custom writes behind `update-text-properties`) where a single
// failed write (422/403/...) must not abort the remaining updates. Logs the error
// and returns the error response so callers can count it as a failure.
function batchResponseErrorHandler(error) {
if (error?.response) {
const { status, statusText, data, config } = error.response;
consola.debug(`Response Status: ${status} (${statusText}) - method: ${config?.method} - url: ${config?.url}`);
consola.error(`Response Error (${status}): ${JSON.stringify(data?.error ?? data)}`);
return error.response;
}
// No HTTP response (network-level error): not a per-item failure, so rethrow.
throw error;
}

module.exports = {
checkAuthorizePartners,
checkRequiredEnvVariables,
responseSuccessHandler,
responseErrorHandler,
batchResponseErrorHandler,
};
Loading