fix(folio): use sum-of-added-rows in balanceMatchesAddedTxn

The old predicate (every row's signed amount equals delta) silently passed
the double-submit bug because two same-amount rows each match the delta in
isolation. Switching to the sum check (addedSum === delta) catches both the
double-submit case and any future multi-row append whose total drifts from
the balance change.
This commit is contained in:
pj committed 2026-05-31 15:49:51 +05:30
1 parent 35fce05b65
commit b88b6c239d
1 file changed
+14 -19
+14 -19
View File
@@ -12,17 +12,13 @@ import {
whenRoute, whenRoute,
} from "@sanderling/spec"; } from "@sanderling/spec";
import { defaultActions } from "@sanderling/spec/defaults"; import { defaultActions } from "@sanderling/spec/defaults";
import { balanceMatchesAddedSum, type LedgerRow } from "./predicates";
interface Account { interface Account {
name: string; name: string;
balance: number; balance: number;
} }
interface LedgerRow {
key: string;
signed: number;
}
// Parses formatCents output like "$5.00", "-$1,234.56", "+$0.50" back to integer cents. // Parses formatCents output like "$5.00", "-$1,234.56", "+$0.50" back to integer cents.
function parseDollarCents(text: string | undefined): number { function parseDollarCents(text: string | undefined): number {
if (!text) return 0; if (!text) return 0;
@@ -93,22 +89,21 @@ const newAccountBalanceIsZero = always(
}) })
); );
// Property 2: when new ledger rows appear, the ledger balance delta equals // Property 2: when new ledger rows appear, the balance delta equals the SUM
// the sum of the new rows' signed amounts. A double-submit lands two rows // of the new rows' signed amounts. Single-row and multi-row sums both must
// whose individual amounts cannot both equal the aggregate delta, so each // match; a double-submit lands two rows whose total is twice the balance
// such row fires the property. // change and trips the sum check.
const balanceMatchesAddedTxn = always( const balanceMatchesAddedTxn = always(
now(() => route.current === "ledger").implies( now(() => route.current === "ledger").implies(
next(() => { next(() =>
const prev = ledgerRows.previous ?? []; balanceMatchesAddedSum(
const curr = ledgerRows.current; ledgerRows.previous ?? [],
const prevKeys = new Set(prev.map(r => r.key)); ledgerRows.current,
const added = curr.filter(r => !prevKeys.has(r.key)); ledgerBalance.previous ?? 0,
if (added.length === 0) return true; ledgerBalance.current,
const delta = ledgerBalance.current - (ledgerBalance.previous ?? 0); ),
return added.every(r => r.signed === delta); ),
}) ),
)
); );
const DEMO_EMAIL = "[email protected]"; const DEMO_EMAIL = "[email protected]";