Skip to content
Closed
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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ All notable changes to this project will be documented in this file.
## [Unreleased]
Added `get-results` command. It fetches the computed results and custom data of a reconciliation or account in a live company file (identified by its Silverfin URL) and prints them as JSON. Usage: `silverfin get-results -u <url>`. Supports `-o, --output <file>` to write the JSON to a file instead of stdout.

Added `capture` command. It captures a live company file's data as JSON. By default it captures the template at the URL and its dependencies (scoped); `--full` captures company/period/reconciliation customs and results across all periods. Usage: `silverfin capture -u <url> [--full]`. Supports `-o, --output <file>`.

## [1.56.1] (08/06/2026)
Increase the waiting time for the test runs to avoid timeout errors.

Expand Down
24 changes: 24 additions & 0 deletions bin/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ const toolkit = require("../index");
const liquidTestGenerator = require("../lib/liquidTestGenerator");
const liquidTestRunner = require("../lib/liquidTestRunner");
const resultsReader = require("../lib/resultsReader");
const dataCapture = require("../lib/dataCapture");
const { ExportFileInstanceGenerator } = require("../lib/exportFileInstanceGenerator");
const stats = require("../lib/cli/stats");
const { Command, Option } = require("commander");
Expand Down Expand Up @@ -550,6 +551,29 @@ program
}
});

// CAPTURE — snapshot a live company file's data as JSON
program
.command("capture")
.description("Capture a live company file's data as JSON. Default: the template at the URL and its dependencies (scoped). Use --full to capture company/period/reconciliation customs and results across all periods")
.requiredOption("-u, --url <url>", "Specify the full Silverfin URL of the reconciliation/account in the company file (mandatory)")
.option("--full", "Capture the whole company file (all periods, workflows, reconciliations) instead of just the template's scope (optional)", false)
.option("-o, --output <file>", "Write the JSON to a file instead of stdout (optional)")
.action(async (options) => {
const data = await dataCapture.capture(options.url, { full: options.full });
if (!data) {
process.exitCode = 1;
return;
}
const json = JSON.stringify(data, null, 2);
if (options.output) {
const fs = require("fs");
fs.writeFileSync(options.output, json);
consola.success(`Wrote capture to ${options.output}`);
} else {
console.log(json);
}
});

// Check Liquid Test dependencies for a reconciliation template
program
.command("check-dependencies")
Expand Down
127 changes: 127 additions & 0 deletions lib/dataCapture.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
const SF = require("./api/sfApi");
const Utils = require("./utils/liquidTestUtils");
const liquidTestGenerator = require("./liquidTestGenerator");
const { consola } = require("consola");

const PER_PAGE = 200;
const MAX_PAGES = 50;

/**
* Capture a live company file's data as a plain object (no I/O).
*
* Two modes:
* - scoped (default): reuses the create-test gathering (`buildLiquidTest`) to
* capture exactly what the template at the URL references (+ dependencies).
* - full: fans out across the whole company file (company + every period +
* every workflow reconciliation) collecting customs and results.
*
* The Silverfin API allows max 1 in-flight call per company, so the full
* capture is intentionally sequential.
*
* @param {String} url Full Silverfin URL of the reconciliation/account
* @param {Object} [opts]
* @param {Boolean} [opts.full=false]
* @returns {Promise<Object|null>}
*/
async function capture(url, opts = {}) {
return opts.full ? captureFull(url) : captureScoped(url);
}

async function captureScoped(url) {
const built = await liquidTestGenerator.buildLiquidTest(url, "capture", true);
if (!built) {
return null;
}
const snapshot = (built.liquidTestObject && built.liquidTestObject.capture) || {};
return {
mode: "scoped",
handle: built.templateHandle,
templateType: built.templateType,
context: snapshot.context ?? null,
data: snapshot.data ?? null,
expectation: snapshot.expectation ?? null,
};
}

async function captureFull(url) {
const parameters = Utils.extractURL(url);
const { firmId, companyId } = parameters;

const out = { mode: "full", firmId, companyId, company: {}, periods: {} };

// Company-level drop + customs
const companyDrop = await SF.getCompanyDrop(firmId, companyId);
out.company.drop = companyDrop?.data ?? null;
const companyCustom = await SF.getCompanyCustom(firmId, companyId);
out.company.custom = companyCustom?.data ?? null;

// Every period
const periods = await fetchAllPeriods(firmId, companyId);
for (const period of periods) {
const periodId = period.id;
const key = period.fiscal_year?.end_date ? String(period.fiscal_year.end_date) : String(periodId);
const periodEntry = { periodId, custom: [], workflows: {} };

// Period-level customs (paginated internally)
periodEntry.custom = (await SF.getAllPeriodCustom(firmId, companyId, periodId)) || [];

// Workflows -> reconciliations (custom + results)
const workflowsResponse = await SF.getWorkflows(firmId, companyId, periodId);
const workflows = workflowsResponse?.data ?? [];
for (const workflow of workflows) {
const reconciliations = await fetchAllWorkflowReconciliations(firmId, companyId, periodId, workflow.id);
const reconciliationsOut = {};
for (const reconciliation of reconciliations) {
const customResponse = await SF.getReconciliationCustom("firm", firmId, companyId, periodId, reconciliation.id);
const resultsResponse = await SF.getReconciliationResults("firm", firmId, companyId, periodId, reconciliation.id);
reconciliationsOut[reconciliation.handle || reconciliation.id] = {
id: reconciliation.id,
custom: customResponse?.data ?? null,
results: resultsResponse?.data ?? null,
};
}
const workflowKey = workflow.name ? `${workflow.name} (${workflow.id})` : String(workflow.id);
periodEntry.workflows[workflowKey] = { id: workflow.id, reconciliations: reconciliationsOut };
}

out.periods[key] = periodEntry;
}

// Account-level customs are not included in --full: there is no list-accounts
// endpoint wired into the CLI. Use scoped capture (or get-results) for accounts.
consola.info("Full capture covers company/period/reconciliation customs + results. Account-level customs are only captured in scoped mode.");

return out;
}

async function fetchAllPeriods(firmId, companyId) {
const items = [];
let page = 1;
while (page <= MAX_PAGES) {
const response = await SF.getPeriods(firmId, companyId, page);
const data = response?.data ?? [];
items.push(...data);
if (data.length < PER_PAGE) {
break;
}
page++;
}
return items;
}

async function fetchAllWorkflowReconciliations(firmId, companyId, periodId, workflowId) {
const items = [];
let page = 1;
while (page <= MAX_PAGES) {
const response = await SF.getWorkflowInformation(firmId, companyId, periodId, workflowId, page);
const data = response?.data ?? [];
items.push(...data);
if (data.length < PER_PAGE) {
break;
}
page++;
}
return items;
}

module.exports = { capture, captureScoped, captureFull };
13 changes: 11 additions & 2 deletions lib/liquidTestGenerator.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ const { AccountTemplate } = require("./templates/accountTemplate");
const { SharedPart } = require("./templates/sharedPart");

// MainProcess
async function testGenerator(url, testName, reconciledStatus = true) {
async function buildLiquidTest(url, testName, reconciledStatus = true) {
// Get parameters from URL provided and determine template type
const parameters = Utils.extractURL(url);
const templateType = parameters.templateType;
Expand Down Expand Up @@ -313,10 +313,19 @@ async function testGenerator(url, testName, reconciledStatus = true) {
}
}

return { templateHandle, liquidTestObject, templateType };
}

async function testGenerator(url, testName, reconciledStatus = true) {
const built = await buildLiquidTest(url, testName, reconciledStatus);
if (!built) {
return;
}
// Save YAML
Utils.exportYAML(templateHandle, liquidTestObject, templateType);
Utils.exportYAML(built.templateHandle, built.liquidTestObject, built.templateType);
}

module.exports = {
testGenerator,
buildLiquidTest,
};
79 changes: 79 additions & 0 deletions tests/lib/dataCapture.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
jest.mock("../../lib/api/sfApi");
jest.mock("../../lib/utils/liquidTestUtils");
jest.mock("../../lib/liquidTestGenerator");
jest.mock("consola");

const SF = require("../../lib/api/sfApi");
const Utils = require("../../lib/utils/liquidTestUtils");
const liquidTestGenerator = require("../../lib/liquidTestGenerator");
const { capture } = require("../../lib/dataCapture");

describe("dataCapture.capture", () => {
beforeEach(() => {
jest.clearAllMocks();
});

describe("scoped (default)", () => {
it("reuses buildLiquidTest and returns the scoped snapshot", async () => {
liquidTestGenerator.buildLiquidTest.mockResolvedValue({
templateHandle: "my_handle",
templateType: "reconciliationText",
liquidTestObject: {
capture: {
context: { period: "2024-12-31" },
data: { periods: { "2024-12-31": {} } },
expectation: { results: { r: "1" } },
},
},
});

const result = await capture("https://live.getsilverfin.com/f/96/100/...", { full: false });

expect(liquidTestGenerator.buildLiquidTest).toHaveBeenCalledWith("https://live.getsilverfin.com/f/96/100/...", "capture", true);
expect(result).toEqual({
mode: "scoped",
handle: "my_handle",
templateType: "reconciliationText",
context: { period: "2024-12-31" },
data: { periods: { "2024-12-31": {} } },
expectation: { results: { r: "1" } },
});
});

it("returns null when the template could not be built", async () => {
liquidTestGenerator.buildLiquidTest.mockResolvedValue(undefined);
const result = await capture("https://live.getsilverfin.com/f/96/100/...");
expect(result).toBeNull();
});
});

describe("full", () => {
it("captures company + period + workflow reconciliation customs and results", async () => {
Utils.extractURL.mockReturnValue({ firmId: "96", companyId: "100" });
SF.getCompanyDrop.mockResolvedValue({ data: { name: "Co" } });
SF.getCompanyCustom.mockResolvedValue({ data: [{ namespace: "c", key: "k", value: 1 }] });
SF.getPeriods.mockResolvedValue({ data: [{ id: 200, fiscal_year: { end_date: "2024-12-31" } }] });
SF.getAllPeriodCustom.mockResolvedValue([{ namespace: "p", key: "k", value: 2 }]);
SF.getWorkflows.mockResolvedValue({ data: [{ id: 11, name: "WF" }] });
SF.getWorkflowInformation.mockResolvedValue({ data: [{ id: 300, handle: "recon_a" }] });
SF.getReconciliationCustom.mockResolvedValue({ data: [{ namespace: "r", key: "k", value: 3 }] });
SF.getReconciliationResults.mockResolvedValue({ data: { res: "9" } });

const result = await capture("https://live.getsilverfin.com/f/96/100/...", { full: true });

expect(result.mode).toBe("full");
expect(result.firmId).toBe("96");
expect(result.company.drop).toEqual({ name: "Co" });
expect(result.company.custom).toEqual([{ namespace: "c", key: "k", value: 1 }]);

const period = result.periods["2024-12-31"];
expect(period.periodId).toBe(200);
expect(period.custom).toEqual([{ namespace: "p", key: "k", value: 2 }]);
expect(period.workflows["WF (11)"].reconciliations.recon_a).toEqual({
id: 300,
custom: [{ namespace: "r", key: "k", value: 3 }],
results: { res: "9" },
});
});
});
});