From 0c86cf194a711a6e11d5c6619c7ae37013a9d33d Mon Sep 17 00:00:00 2001 From: Goodnessukaigwe Date: Tue, 29 Sep 2026 07:54:59 +0100 Subject: [PATCH] feat(financial_report_exporter): reject split totals that miss the base amount Reconcile entry allocations against the report amount with exact bigint arithmetic so under- and over-allocation cannot be exported as a valid spreadsheet. Co-authored-by: Cursor --- ...inancial_report_exporter_split_sum.test.ts | 171 ++++++++++++++++++ src/utils/financial_report_exporter.ts | 89 +++++++++ 2 files changed, 260 insertions(+) create mode 100644 __tests__/financial_report_exporter_split_sum.test.ts diff --git a/__tests__/financial_report_exporter_split_sum.test.ts b/__tests__/financial_report_exporter_split_sum.test.ts new file mode 100644 index 0000000..4c4ddfd --- /dev/null +++ b/__tests__/financial_report_exporter_split_sum.test.ts @@ -0,0 +1,171 @@ +import { + EXPORTER_PARAM_ERROR_CODES as ERROR_CODES, + assertFinancialReportSplitSum, + exportFinancialReport, + financial_report_exporter, + FinancialReportExporterErrorException, + validateFinancialReportExporterParams, +} from "../src/utils/financial_report_exporter.js"; + +describe("financial_report_exporter split-sum checks (#510)", () => { + describe("assertFinancialReportSplitSum", () => { + it("accepts allocations that match the base amount exactly", () => { + const result = assertFinancialReportSplitSum(["100", 250n, 150], "500"); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.value).toBe(500n); + } + }); + + it("sums multiple split entries with bigint-exact arithmetic", () => { + // 1_000_000 + 2_500_000 + 6_500_000 = 10_000_000 + const result = assertFinancialReportSplitSum( + [1_000_000n, "2500000", 6_500_000], + 10_000_000n + ); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.value).toBe(10_000_000n); + } + }); + + it("rejects under-allocation", () => { + const result = assertFinancialReportSplitSum(["10", "20"], "35"); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.code).toBe(ERROR_CODES.SUM_MISMATCH); + expect(result.error).toContain("30"); + expect(result.error).toContain("35"); + } + }); + + it("rejects over-allocation", () => { + const result = assertFinancialReportSplitSum(["20", "20"], "35"); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.code).toBe(ERROR_CODES.SUM_MISMATCH); + expect(result.error).toContain("40"); + expect(result.error).toContain("35"); + } + }); + + it("accepts a single zero split against a zero base amount", () => { + const result = assertFinancialReportSplitSum([0n], 0); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.value).toBe(0n); + } + }); + + it("rejects an empty splits array", () => { + const result = assertFinancialReportSplitSum([], 0); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.code).toBe(ERROR_CODES.INVALID_PARAMETER); + } + }); + }); + + describe("exportFinancialReport and financial_report_exporter", () => { + it("exports when entry amounts reconcile with the base amount", () => { + const res = exportFinancialReport({ + amount: 1000, + currency: "USDC", + entries: [ + { category: "operations", amount: 600, currency: "USDC" }, + { category: "escrow", amount: 400, currency: "USDC" }, + ], + }); + expect(res.ok).toBe(true); + if (res.ok) { + expect(res.rowCount).toBe(2); + expect(res.data).toContain("operations,600,USDC"); + expect(res.data).toContain("escrow,400,USDC"); + } + }); + + it("still exports a total row when no split entries are provided", () => { + const res = exportFinancialReport({ amount: 500, currency: "XLM" }); + expect(res.ok).toBe(true); + if (res.ok) { + expect(res.rowCount).toBe(1); + expect(res.data).toContain("total,500,XLM"); + } + }); + + it("still exports a total row for an empty entries array", () => { + const res = exportFinancialReport({ amount: 75, entries: [] }); + expect(res.ok).toBe(true); + if (res.ok) { + expect(res.data).toContain("total,75"); + } + }); + + it("does not produce a successful report when splits are under the base amount", () => { + const res = exportFinancialReport({ + amount: 1000, + entries: [ + { category: "a", amount: 100 }, + { category: "b", amount: 200 }, + ], + }); + expect(res.ok).toBe(false); + if (!res.ok) { + expect(res.code).toBe(ERROR_CODES.SUM_MISMATCH); + } + }); + + it("does not produce a successful report when splits exceed the base amount", () => { + const res = exportFinancialReport({ + amount: 100, + entries: [ + { amount: 60 }, + { amount: 50 }, + ], + }); + expect(res.ok).toBe(false); + if (!res.ok) { + expect(res.code).toBe(ERROR_CODES.SUM_MISMATCH); + } + }); + + it("rejects mismatched allocations in validateFinancialReportExporterParams", () => { + expect(() => { + validateFinancialReportExporterParams({ + amount: 100, + entries: [{ amount: 40 }, { amount: 40 }], + }); + }).toThrow(FinancialReportExporterErrorException); + + try { + validateFinancialReportExporterParams({ + amount: 100, + entries: [{ amount: 40 }, { amount: 40 }], + }); + } catch (err: unknown) { + expect(err).toBeInstanceOf(FinancialReportExporterErrorException); + if (err instanceof FinancialReportExporterErrorException) { + expect(err.code).toBe(ERROR_CODES.SUM_MISMATCH); + } + } + }); + + it("rejects mismatched allocations in async financial_report_exporter", async () => { + await expect( + financial_report_exporter({ + amount: 1000, + entries: [{ amount: 999 }], + }) + ).rejects.toThrow(FinancialReportExporterErrorException); + }); + + it("accepts matching allocations in async financial_report_exporter", async () => { + const res = await financial_report_exporter({ + amount: 9, + entries: [{ amount: 2 }, { amount: 3 }, { amount: 4 }], + }); + expect(res.ok).toBe(true); + expect(res.rowCount).toBe(3); + }); + }); +}); diff --git a/src/utils/financial_report_exporter.ts b/src/utils/financial_report_exporter.ts index d09b30b..b24f48d 100644 --- a/src/utils/financial_report_exporter.ts +++ b/src/utils/financial_report_exporter.ts @@ -1424,6 +1424,7 @@ export enum FinancialReportExporterError { OVERFLOW_EXCESSIVE_DIGITS = "OVERFLOW_EXCESSIVE_DIGITS", EMPTY_DATA = "EMPTY_DATA", INVALID_ROW = "INVALID_ROW", + SUM_MISMATCH = "FINANCIAL_REPORT_SUM_MISMATCH", } export const EXPORTER_PARAM_ERROR_CODES = { @@ -1434,6 +1435,7 @@ export const EXPORTER_PARAM_ERROR_CODES = { OVERFLOW_EXCESSIVE_DIGITS: "OVERFLOW_EXCESSIVE_DIGITS", EMPTY_DATA: "EMPTY_DATA", INVALID_ROW: "INVALID_ROW", + SUM_MISMATCH: "FINANCIAL_REPORT_SUM_MISMATCH", } as const; export type FinancialReportParamErrorCode = @@ -1469,6 +1471,76 @@ export interface FinancialReportExporterParams { entries?: FinancialReportEntry[]; } +export type SplitSumCheckResult = + | { ok: true; value: bigint } + | { ok: false; error: string; code: FinancialReportParamErrorCode }; + +/** + * Confirm that split/allocation amounts sum exactly to the report base amount. + * Comparison is bigint-exact; a mismatch is `SUM_MISMATCH`, never a silent export. + */ +export function assertFinancialReportSplitSum( + splits: Array, + expectedBase: string | number | bigint +): SplitSumCheckResult { + if (!Array.isArray(splits) || splits.length === 0) { + return { + ok: false, + error: "splits must be a non-empty array", + code: EXPORTER_PARAM_ERROR_CODES.INVALID_PARAMETER, + }; + } + + const baseCheck = validateFinancialAmount(expectedBase, "amount"); + if (!baseCheck.ok) { + return baseCheck; + } + + let total = 0n; + for (let i = 0; i < splits.length; i++) { + const splitCheck = validateFinancialAmount(splits[i], `splits[${i}]`); + if (!splitCheck.ok) { + return splitCheck; + } + const next = total + splitCheck.value; + if (digitCount(next.toString()) > MAX_SAFE_DIGITS) { + return { + ok: false, + error: `split total exceeds maximum of ${MAX_SAFE_DIGITS} digits`, + code: EXPORTER_PARAM_ERROR_CODES.EXCESSIVE_DIGITS, + }; + } + total = next; + } + + if (total !== baseCheck.value) { + return { + ok: false, + error: `split total (${total}) does not match base amount (${baseCheck.value})`, + code: EXPORTER_PARAM_ERROR_CODES.SUM_MISMATCH, + }; + } + + return { ok: true, value: total }; +} + +function reconcileExporterEntrySplits( + params: FinancialReportExporterParams +): SplitSumCheckResult | { ok: true; value: bigint; skipped: true } { + if (!params.entries || params.entries.length === 0) { + const baseCheck = validateFinancialAmount(params.amount, "amount"); + if (!baseCheck.ok) { + return baseCheck; + } + return { ok: true, value: baseCheck.value, skipped: true }; + } + + return assertFinancialReportSplitSum( + params.entries.map((entry) => entry.amount), + params.amount + ); +} + /** * Validate a non-negative numeric amount parameter. * Rejects negative amounts with error code NEGATIVE_PARAMETER / INVALID_AMOUNT. @@ -1603,6 +1675,18 @@ export function validateFinancialReportExporterParams( ); } } + if (params.entries.length > 0) { + const splitCheck = assertFinancialReportSplitSum( + params.entries.map((entry) => entry.amount), + params.amount + ); + if (!splitCheck.ok) { + throw new FinancialReportExporterErrorException( + splitCheck.code, + splitCheck.error + ); + } + } } } @@ -1660,6 +1744,11 @@ export function exportFinancialReport( } } + const splitCheck = reconcileExporterEntrySplits(params); + if (!splitCheck.ok) { + return splitCheck; + } + const lines = ["category,amount,currency"]; let rowCount = 0;