Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 4 additions & 1 deletion 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.53.0] (28/01/2026)
Added `create-test` command support for account templates (fetches template data, period data, and custom data).

## [1.52.0] (12/01/2026)
This update improves test execution performance when running tests with status checks across multiple template handles.
Tests are now run in parallel for multiple handles when using the `--status` flag, significantly reducing the overall execution time. Previously, tests with status checks for multiple handles would run sequentially, but now they leverage parallel processing for better efficiency. This change only affects the `silverfin run-test` command when both multiple handles and the status flag are used together.
Expand Down Expand Up @@ -69,4 +72,4 @@ For example: `silverfin update-reconciliation --id 12345`
- Add tests for the exportFile class

## [1.38.0] (04/07/2025)
- Added a changelog.md file and logic to display the changes when updating to latest version
- Added a changelog.md file and logic to display the changes when updating to latest version
55 changes: 55 additions & 0 deletions lib/api/sfApi.js
Original file line number Diff line number Diff line change
Expand Up @@ -404,6 +404,30 @@ async function removeSharedPartFromAccountTemplate(type, envId, sharedPartId, ac
}
}

async function getAccountTemplateCustom(type, envId, companyId, periodId, accountTemplateId, page = 1) {
const instance = AxiosFactory.createInstance(type, envId);
try {
const response = await instance.get(`/companies/${companyId}/periods/${periodId}/accounts/${accountTemplateId}/custom`, { params: { page: page, per_page: 200 } });
apiUtils.responseSuccessHandler(response);
return response;
} catch (error) {
const response = await apiUtils.responseErrorHandler(error);
return response;
}
}

async function getAccountTemplateResults(type, envId, companyId, periodId, accountTemplateId) {
const instance = AxiosFactory.createInstance(type, envId);
try {
const response = await instance.get(`/companies/${companyId}/periods/${periodId}/accounts/${accountTemplateId}/results`);
apiUtils.responseSuccessHandler(response);
return response;
} catch (error) {
const response = await apiUtils.responseErrorHandler(error);
return response;
}
}

async function createTestRun(firmId, attributes, templateType) {
const instance = AxiosFactory.createInstance("firm", firmId);
let response;
Expand Down Expand Up @@ -615,6 +639,34 @@ async function getAccountDetails(firmId, companyId, periodId, accountId) {
}
}

async function findAccountByNumber(firmId, companyId, periodId, accountNumber, page = 1) {
const instance = AxiosFactory.createInstance("firm", firmId);
try {
const response = await instance.get(`companies/${companyId}/periods/${periodId}/accounts`, {
params: { page: page },
});

const accounts = response.data;

// No data - end of pagination
if (accounts.length === 0) {
return null;
}

// Look for the account in this page
const account = accounts.find((acc) => acc.account.number === accountNumber);
if (account) {
return account;
}

// Not found in this page, try next page
return findAccountByNumber(firmId, companyId, periodId, accountNumber, page + 1);
} catch (error) {
const response = await apiUtils.responseErrorHandler(error);
return response;
}
}

// Liquid Linter
// attributes should be JSON
async function verifyLiquid(firmId, attributes) {
Expand Down Expand Up @@ -698,6 +750,8 @@ module.exports = {
findAccountTemplateByName,
addSharedPartToAccountTemplate,
removeSharedPartFromAccountTemplate,
getAccountTemplateCustom,
getAccountTemplateResults,
readTestRun,
createTestRun,
createPreviewRun,
Expand All @@ -712,6 +766,7 @@ module.exports = {
findReconciliationInWorkflow,
findReconciliationInWorkflows,
getAccountDetails,
findAccountByNumber,
verifyLiquid,
getFirmDetails,
createExportFileInstance,
Expand Down
280 changes: 176 additions & 104 deletions lib/liquidTestGenerator.js

Large diffs are not rendered by default.

76 changes: 55 additions & 21 deletions lib/utils/liquidTestUtils.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,71 +4,105 @@ const fsUtils = require("./fsUtils");
const { consola } = require("consola");

// Create base Liquid Test object
function createBaseLiquidTest(testName) {
return {
function createBaseLiquidTest(testName, templateType = "reconciliationText") {
const baseStructure = {
[testName]: {
context: {
period: "#Replace with period",
},
data: {
periods: {
replace_period_name: {
reconciliations: {},
reconciliations: {}, // Only for reconciliation texts
},
},
},
expectation: {
reconciled: "#Replace with reconciled status",
results: {},
rollforward: {},
},
},
};

// Add current_account for account templates
if (templateType === "accountTemplate") {
baseStructure[testName].context.current_account = "#Replace with current account";
delete baseStructure[testName].data.periods.replace_period_name.reconciliations; // Remove reconciliations
}

return baseStructure;
}

// Provide a link to reconciliation in Silverfin
// Extract firm id, company id, period id, reconciliation id
// Provide a link to reconciliation or account template in Silverfin
// Extract template type, firm id, company id, period id, template id
function extractURL(url) {
try {
const parts = url.split("?")[0].split("/f/")[1].split("/");
let type;
let idType, templateType;
if (parts.indexOf("reconciliation_texts") !== -1) {
type = "reconciliationId";
idType = "reconciliationId";
templateType = "reconciliationText";
} else if (parts.indexOf("account_entry") !== -1) {
type = "accountId";
idType = "accountId";
templateType = "accountTemplate";
} else {
consola.error("Not possible to identify if it's a reconciliation text or account entry.");
process.exit(1);
}
return {
templateType,
firmId: parts[0],
companyId: parts[1],
ledgerId: parts[3],
workflowId: parts[5],
[type]: parts[7],
[idType]: parts[7],
};
} catch (err) {
consola.error("The URL provided is not correct. Double check it and run the command again.");
process.exit(1);
}
}

function generateFileName(handle, counter = 0) {
function generateFileName(handle, templateType, counter = 0) {
let fileName = `${handle}_liquid_test.yml`;
if (counter != 0) {
fileName = `${handle}_${counter}_liquid_test.yml`;
}
const filePath = `./reconciliation_texts/${handle}/tests/${fileName}`;
let filePath;
switch (templateType) {
case "reconciliationText":
filePath = `./reconciliation_texts/${handle}/tests/${fileName}`;
break;
case "accountTemplate":
filePath = `./account_templates/${handle}/tests/${fileName}`;
break;
default:
consola.error("Invalid template type");
process.exit(1);
}
if (fs.existsSync(filePath)) {
return generateFileName(handle, counter + 1);
return generateFileName(handle, templateType, counter + 1);
}
return filePath;
}

// Create YAML
function exportYAML(handle, liquidTestObject) {
fsUtils.createFolder(`./reconciliation_texts`);
fsUtils.createTemplateFolders("reconciliationText", handle, true);
const filePath = generateFileName(handle);
function exportYAML(handle, liquidTestObject, templateType) {
switch (templateType) {
case "reconciliationText":
fsUtils.createFolder(`./reconciliation_texts`);
fsUtils.createTemplateFolders("reconciliationText", handle, true);
break;
case "accountTemplate":
fsUtils.createFolder(`./account_templates`);
fsUtils.createTemplateFolders("accountTemplate", handle, true);
break;
default:
consola.error("Invalid template type");
process.exit(1);
}
const filePath = generateFileName(handle, templateType);
fs.writeFile(
filePath,
YAML.stringify(liquidTestObject, {
Expand Down Expand Up @@ -138,20 +172,20 @@ function processCustom(customArray) {
}

// Company Drop used
function getCompanyDependencies(reconcilationObject, reconciliationHandle) {
function getCompanyDependencies(templateCode, templateHandle) {
const reCompanySearch = RegExp(/company\.\w+(?:\.\w+\.\w+)?/g); // company.foo or company.custom.foo.bar

// No main part ?
if (!reconcilationObject || !reconcilationObject.text) {
consola.warn(`Reconciliation "${reconciliationHandle}": no liquid code found`);
if (!templateCode || !templateCode.text) {
consola.warn(`Template "${templateHandle}": no liquid code found`);
return { standardDropElements: [], customDropElements: [] };
}

// Main Part
let companyFound = reconcilationObject.text.match(reCompanySearch) || [];
let companyFound = templateCode.text.match(reCompanySearch) || [];

// Parts
for (const part of reconcilationObject.text_parts) {
for (const part of templateCode.text_parts) {
const companyPart = part.content.match(reCompanySearch) || [];
if (companyPart) {
companyFound = companyFound.concat(companyPart);
Expand Down
Loading