diff --git a/CHANGELOG.md b/CHANGELOG.md index b682167..725f8de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 `. Supports `-o, --output ` 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 [--full]`. Supports `-o, --output `. + ## [1.56.1] (08/06/2026) Increase the waiting time for the test runs to avoid timeout errors. diff --git a/bin/cli.js b/bin/cli.js index 6a27627..6cfb956 100755 --- a/bin/cli.js +++ b/bin/cli.js @@ -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"); @@ -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 ", "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 ", "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") diff --git a/lib/dataCapture.js b/lib/dataCapture.js new file mode 100644 index 0000000..9868a9d --- /dev/null +++ b/lib/dataCapture.js @@ -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} + */ +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 }; diff --git a/lib/liquidTestGenerator.js b/lib/liquidTestGenerator.js index 1703969..6de095a 100644 --- a/lib/liquidTestGenerator.js +++ b/lib/liquidTestGenerator.js @@ -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; @@ -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, }; diff --git a/tests/lib/dataCapture.test.js b/tests/lib/dataCapture.test.js new file mode 100644 index 0000000..68865ea --- /dev/null +++ b/tests/lib/dataCapture.test.js @@ -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" }, + }); + }); + }); +});