Skip to content
Open
Show file tree
Hide file tree
Changes from 6 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.0] (17/02/2026)
Added `create-test` command support for account templates (fetches template data, period data, and custom data).

Expand Down
39 changes: 39 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,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")
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, 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")
Expand Down
13 changes: 13 additions & 0 deletions lib/api/sfApi.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
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 getWorkflows(firmId, companyId, periodId) {
const instance = AxiosFactory.createInstance("firm", firmId);
try {
Expand Down Expand Up @@ -761,6 +773,7 @@ module.exports = {
getCompanyCustom,
getPeriodCustom,
getAllPeriodCustom,
updateReconciliationCustom,
getWorkflows,
getWorkflowInformation,
findReconciliationInWorkflow,
Expand Down
131 changes: 131 additions & 0 deletions lib/utils/textPropertyUtils.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
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 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, merge: true });
Comment thread
Benjvandam marked this conversation as resolved.
Outdated

if (!parsed || !parsed[testName]) continue;

const testData = parsed[testName];
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 (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 };
Loading