/** * Task 1771 — the ledger census finding. * * Four of the ledger's failure modes emit no event of their own: a write that * was narrated but never landed, a payment with no parent invoice, a stored * balance property that has drifted, and a duplicate payment created by a * non-deterministic paymentId. None of them can be caught by logging an * action, because no action occurs. This reconcile is the substitute, and it * is called from two places: the `ledger-reconcile` tool (on demand) and the * standing interval in the UI server. Both share this function so the two * forms cannot report different things. * * Pure: no I/O, and `now` is a parameter rather than a Date.now() call. */ /** The index names schema.cypher declares for the ledger. Their absence from a * live database means schema.cypher was edited but seed-neo4j.sh never re-ran * on that install, which emits nothing on its own. Defined here because both * the on-demand tool and the standing census check the same list; two copies * would let them disagree about whether the schema is applied. */ export const LEDGER_INDEX_NAMES: readonly string[] = [ "invoice_account_confirmation_unique", "cash_entry_account_entryid_unique", "cash_entry_account", "invoice_line_account", "invoice_payment_account", "credit_account", ] export interface PaymentKey { invoiceId: string paymentId: string amount: number date: string } export interface LedgerCensusRows { invoices: number payments: number orphanPayments: number storedBalanceProps: number paymentKeys: readonly PaymentKey[] declaredNotLive: readonly string[] /** Count of `[graph-write] reject` lines from the ledger's MCP logs in the * trailing 24 hours. Without it the census cannot tell a healthy empty * ledger from one whose write path is wholly dead: both print * `invoices=0 payments=0`. Task 1788, after exactly that went unseen for a * day. */ writeRejects24h: number } export interface LedgerCensusFinding { invoices: number payments: number orphanPayments: number storedBalanceProps: number suspectDuplicates: number declaredNotLive: readonly string[] writeRejects24h: number alarm: boolean } /** * Counts payments that share (invoiceId, amount, date) across two or more * distinct paymentId values. For a group of n distinct ids the contribution is * n-1, because exactly one member of the group is the legitimate payment. * * Grouping on distinct ids, not on row count, is what keeps a genuine retry * quiet: a retry that MERGEs onto the same deterministic paymentId appears * twice in the rows but once in the id set, and is not a duplicate. */ function countSuspectDuplicates(keys: readonly PaymentKey[]): number { const groups = new Map>() for (const k of keys) { const composite = `${k.invoiceId}|${k.amount}|${k.date}` const ids = groups.get(composite) ?? new Set() ids.add(k.paymentId) groups.set(composite, ids) } let total = 0 for (const ids of groups.values()) total += Math.max(0, ids.size - 1) return total } export function reconcileLedger(rows: LedgerCensusRows, _now: number): LedgerCensusFinding { const suspectDuplicates = countSuspectDuplicates(rows.paymentKeys) const alarm = rows.orphanPayments > 0 || rows.storedBalanceProps > 0 || suspectDuplicates > 0 || rows.declaredNotLive.length > 0 || rows.writeRejects24h > 0 return { invoices: rows.invoices, payments: rows.payments, orphanPayments: rows.orphanPayments, storedBalanceProps: rows.storedBalanceProps, suspectDuplicates, declaredNotLive: rows.declaredNotLive, writeRejects24h: rows.writeRejects24h, alarm, } } /** * The one `[ledger-census]` line format. Shared by the on-demand tool and the * standing interval so a log search finds both, and so a new counter cannot be * added to one emission and missed by the other. */ export function formatCensusLine(f: LedgerCensusFinding): string { return ( `[ledger-census] invoices=${f.invoices} payments=${f.payments} ` + `orphanPayments=${f.orphanPayments} storedBalanceProps=${f.storedBalanceProps} ` + `suspectDuplicates=${f.suspectDuplicates} ` + `declaredNotLive=${f.declaredNotLive.length === 0 ? "none" : f.declaredNotLive.join(",")} ` + `writeRejects24h=${f.writeRejects24h}` ) }