Skip to content
Merged
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
171 changes: 171 additions & 0 deletions __tests__/financial_report_exporter_split_sum.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
});
89 changes: 89 additions & 0 deletions src/utils/financial_report_exporter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -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 =
Expand Down Expand Up @@ -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<string | number | bigint>,
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.
Expand Down Expand Up @@ -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
);
}
}
}
}

Expand Down Expand Up @@ -1660,6 +1744,11 @@ export function exportFinancialReport(
}
}

const splitCheck = reconcileExporterEntrySplits(params);
if (!splitCheck.ok) {
return splitCheck;
}

const lines = ["category,amount,currency"];
let rowCount = 0;

Expand Down
Loading