From c223f6240823cfe5a1a9d893dc46b45698ed636d Mon Sep 17 00:00:00 2001 From: null Date: Sun, 5 Jul 2026 11:55:52 -0500 Subject: [PATCH] test(money): cross-surface reconciliation harness (Track B) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pins that Tracker, Analytics, Bills, and Summary agree on a month's expected/paid totals — the 'same number, different truth' bug class (QA-B5/B9). Hand-seeds an off-month annual bill, an occurring quarterly bill, and monthly bills w/ full+partial payments; asserts in integer cents that Analytics.expected / Bills-gated-sum / Summary.expense_total all == Tracker.total_expected, Analytics.actual == Tracker.total_paid, and the off-month annual inflates no surface. All reconcile today; the harness makes future gating drift fail loudly. Suite 193 green. Co-Authored-By: Claude Opus 4.8 --- HISTORY.md | 4 ++ tests/reconciliation.test.js | 109 +++++++++++++++++++++++++++++++++++ 2 files changed, 113 insertions(+) create mode 100644 tests/reconciliation.test.js diff --git a/HISTORY.md b/HISTORY.md index 78e23e4..46c23f1 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -1,6 +1,10 @@ # Bill Tracker — Changelog ## v0.41.0 +### 🧮 Cross-surface money reconciliation harness (Track B) — pins "same number, same truth" + +- **[Money integrity] Added `tests/reconciliation.test.js` — the totals every surface shows for a month must agree.** The app has repeatedly shipped "same number, different truth" drift (QA-B5/B9) where the Tracker, Summary, Analytics, Bills, and bank ledger each gate a bill's monthly occurrence slightly differently — so an annual/off-month bill inflates one surface's "expected" but not another's. The harness hand-seeds the exact shapes that historically broke gating (a $900 **off-month annual** bill, a **quarterly** bill that *does* fall in the month, plus plain monthly bills with a full + a partial payment) and pins the invariants, **comparing in integer cents** (never post-`fromCents` floats, which drift on rounding): `Analytics.expected` == `Tracker.total_expected`, `Σ resolveDueDate-gated Bills` == `Tracker.total_expected`, `Summary.expense_total` (via the real `GET /summary` handler) == `Tracker.total_expected`, `Analytics.actual` == `Tracker.total_paid`, and — the linchpin — the off-month annual bill inflates **no** surface. All four money surfaces reconcile today (the QA-B5 fixes hold); the harness makes a future regression fail loudly. (Bank-ledger occurrence gating is already pinned by `trackerService.test.js`.) Full suite 193 green. + ### 💰 Payment atomicity audit (Track A) — every balance mutation is now transactional - **[Money integrity] Wrapped the four remaining non-atomic payment paths in `db.transaction()`.** Quick-pay and bulk-pay were already atomic (X1), but the audit found the INSERT/UPDATE + `applyBalanceDelta` pair split across two un-transactional statements on **manual `POST /payments`**, **autopay-suggestion confirm**, **`DELETE` (unpay)**, and **`POST /:id/restore`** — plus **`PUT` (edit)**, where the bill-balance write and the payment `UPDATE` weren't atomic. A crash/constraint failure between the two statements could leave a payment without its balance adjustment (or a balance reversed without the row deleted). Each is now a single transaction: the row write and the balance mutation land together or neither. Behavior-preserving — `tests/paymentsRoute.test.js` pins the create → delete → restore → edit balance round-trip and the double-restore no-op. diff --git a/tests/reconciliation.test.js b/tests/reconciliation.test.js new file mode 100644 index 0000000..190e5fa --- /dev/null +++ b/tests/reconciliation.test.js @@ -0,0 +1,109 @@ +'use strict'; + +// Track B — cross-surface money reconciliation. The Tracker, Analytics, and the +// raw Bills list must agree on the same month's expected/paid totals — the app +// has repeatedly had "same number, different truth" drift (QA-B5/B9) where one +// surface gates a bill's occurrence differently from another. This harness seeds +// the exact shapes that historically broke gating (an off-month annual bill, a +// quarterly bill that DOES occur, plus plain monthly bills) and pins the +// invariants that must hold, comparing in INTEGER CENTS (never post-fromCents +// floats, which drift on rounding). +const test = require('node:test'); +const assert = require('node:assert/strict'); +const os = require('node:os'); +const path = require('node:path'); +const fs = require('node:fs'); + +const dbPath = path.join(os.tmpdir(), `bill-tracker-recon-${process.pid}.sqlite`); +process.env.DB_PATH = dbPath; + +const { getDb, closeDb } = require('../db/database'); +const { getTracker } = require('../services/trackerService'); +const { getAnalyticsSummary } = require('../services/analyticsService'); +const { resolveDueDate } = require('../services/statusService'); +const summaryRouter = require('../routes/summary'); + +// Fixed "now" so occurrence gating is deterministic: June 20, 2026. +const NOW = new Date(2026, 5, 20, 12, 0, 0); +const YEAR = 2026, MONTH = 6, KEY = '2026-06'; +const cents = (dollars) => Math.round(dollars * 100); + +let db, userId; +test.before(() => { + db = getDb(); + userId = db.prepare("INSERT INTO users (username, password_hash, role, active) VALUES ('recon','x','user',1)").run().lastInsertRowid; + const ins = db.prepare(` + INSERT INTO bills (user_id, name, due_day, billing_cycle, cycle_type, cycle_day, expected_amount, active) + VALUES (?, ?, ?, ?, ?, ?, ?, 1) + `); + const monthlyA = ins.run(userId, 'Monthly A', 15, 'monthly', 'monthly', null, 10000).lastInsertRowid; // $100, occurs + const monthlyB = ins.run(userId, 'Monthly B', 1, 'monthly', 'monthly', null, 20000).lastInsertRowid; // $200, occurs + ins.run(userId, 'Quarterly (Mar anchor)', 10, 'quarterly', 'quarterly', '3', 30000); // $300, Mar/Jun/Sep/Dec → occurs in June + ins.run(userId, 'Annual (Jan)', 1, 'annually', 'annual', '1', 90000); // $900, NOT due in June + + const pay = db.prepare("INSERT INTO payments (bill_id, amount, paid_date, payment_source) VALUES (?, ?, ?, 'manual')"); + pay.run(monthlyA, 10000, '2026-06-15'); // pay A in full ($100) + pay.run(monthlyB, 5000, '2026-06-10'); // partial toward B ($50) +}); +test.after(() => { + closeDb(); + for (const s of ['', '-wal', '-shm']) { try { fs.rmSync(dbPath + s); } catch {} } +}); + +function analyticsMonthRow() { + const summary = getAnalyticsSummary(userId, { year: YEAR, month: MONTH, months: 12 }); + return (summary.expected_vs_actual || []).find(r => r.month === KEY) || { expected: 0, actual: 0 }; +} +function billsGatedExpectedCents() { + // The raw Bills surface: sum the occurrence-gated active bills' expected_amount. + const bills = db.prepare('SELECT * FROM bills WHERE user_id = ? AND active = 1 AND deleted_at IS NULL').all(userId); + return bills + .filter(b => resolveDueDate(b, YEAR, MONTH)) + .reduce((sum, b) => sum + (b.expected_amount || 0), 0); +} +function summaryExpenseTotalCents() { + // The Summary surface: invoke GET /summary and read its gated expense_total. + const layer = summaryRouter.stack.find(l => l.route?.path === '/' && l.route.methods.get); + const handle = layer.route.stack[layer.route.stack.length - 1].handle; + let captured; + const req = { query: { year: String(YEAR), month: String(MONTH) }, user: { id: userId } }; + const res = { status() { return this; }, json(d) { captured = d; } }; + handle(req, res); + return cents(captured.summary.expense_total); +} + +test('Analytics.expected reconciles with Tracker.total_expected for the month (integer cents)', () => { + const tracker = getTracker(userId, { year: YEAR, month: MONTH }, NOW); + const analytics = analyticsMonthRow(); + // Gated June bills: $100 + $200 + $300 (quarterly) = $600; the $900 annual is excluded. + assert.equal(cents(tracker.summary.total_expected), 60000, 'tracker total_expected gated to $600'); + assert.equal(cents(analytics.expected), cents(tracker.summary.total_expected), 'analytics.expected == tracker.total_expected'); +}); + +test('Bills gated sum reconciles with Tracker.total_expected (integer cents)', () => { + const tracker = getTracker(userId, { year: YEAR, month: MONTH }, NOW); + assert.equal(billsGatedExpectedCents(), cents(tracker.summary.total_expected), 'Σ resolveDueDate-gated bills == tracker total_expected'); +}); + +test('Summary.expense_total reconciles with Tracker.total_expected for the month (integer cents)', () => { + const tracker = getTracker(userId, { year: YEAR, month: MONTH }, NOW); + assert.equal(summaryExpenseTotalCents(), cents(tracker.summary.total_expected), 'summary expense_total == tracker total_expected'); +}); + +test('Analytics.actual reconciles with Tracker.total_paid for the month (integer cents)', () => { + const tracker = getTracker(userId, { year: YEAR, month: MONTH }, NOW); + const analytics = analyticsMonthRow(); + // Payments in June: $100 (A) + $50 (B partial) = $150. + assert.equal(cents(tracker.summary.total_paid), 15000, 'tracker total_paid = $150'); + assert.equal(cents(analytics.actual), cents(tracker.summary.total_paid), 'analytics.actual == tracker.total_paid'); +}); + +test('the off-month annual bill inflates NO surface (gating is consistent everywhere)', () => { + const tracker = getTracker(userId, { year: YEAR, month: MONTH }, NOW); + const analytics = analyticsMonthRow(); + assert.equal(tracker.rows.filter(r => r.name === 'Annual (Jan)').length, 0, 'annual bill absent from tracker rows'); + // If the annual $900 leaked into any surface, the totals would be $1,500 not $600. + assert.equal(cents(tracker.summary.total_expected), 60000); + assert.equal(cents(analytics.expected), 60000); + assert.equal(billsGatedExpectedCents(), 60000); +});