110 lines
5.9 KiB
JavaScript
110 lines
5.9 KiB
JavaScript
'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);
|
|
});
|