fix(qa): Summary excludes bills not due in the month (reconciles with Tracker)

- routes/summary: filter the expense list by resolveDueDate so annual and
  off-month quarterly bills no longer inflate the monthly total / "monthly
  result" — the Summary now agrees with the Tracker for the same month (QA-B5-01)
- add a Tracker<->Summary reconciliation guard in e2e/api.probe.spec.js
- docs: archive QA-B5-01; track QA-B5-02 (SimpleFIN unpaid_this_month residual)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
null 2026-07-02 21:19:35 -05:00
parent 72d06aa2d8
commit a15ff056b3
4 changed files with 30 additions and 5 deletions

View File

@ -3,6 +3,7 @@
### 🐛 QA Fixes
- **[Summary] Non-monthly bills were counted in every month** — the Summary page listed annual (and off-month quarterly) bills as expenses for months they weren't due, so its monthly expense total and "monthly result" over-stated the obligation and disagreed with the Tracker (e.g. a yearly insurance bill inflated every month). `routes/summary.js` now filters bills by `resolveDueDate` — the same occurrence gate the Tracker uses. Guarded by a Tracker↔Summary reconciliation check in `e2e/api.probe.spec.js`. Residual tracked as **QA-B5-02**: the SimpleFIN bank-tracking `unpaid_this_month` metric uses a SQL query that needs the same filter. (was QA-B5-01)
- **[Data] Seed Demo Data amounts were 100× too small** — `scripts/seedDemoData.js` inserted demo dollar amounts straight into `bills.expected_amount` / `current_balance` / `minimum_payment`, which became integer-cents columns in the v1.03 migration, so a seeded "$85" bill showed as $0.85 (a whole demo month totalled ~$32 instead of ~$3,200). Now wraps demo values in `toCents()` before insert. Regression guard added in `e2e/api.probe.spec.js`. (was QA-B9-01)
- **[Bills] `expected_amount` was unvalidated** — POST/PUT `/api/bills` accepted negative amounts, non-numeric input (silently coerced to $0), and absurd values (`1e15` → cents past `Number.MAX_SAFE_INTEGER`). `validateBillData` (`services/billsService.js`) now rejects non-numeric / negative / out-of-range with a structured `VALIDATION_ERROR`, accepting `0`…`$100,000,000`. Regression assertions in `e2e/api.probe.spec.js`. (was QA-B13-01)
- **[UI] Negative amounts rendered as "$-50.00"** — client `fmt()` (`client/lib/utils.js`) and server `formatUSD()` (`utils/money.js`) placed the minus sign after the currency symbol; now render the conventional "-$50.00". Test added in `client/lib/utils.test.js`. (was QA-B6-01)

View File

@ -92,7 +92,7 @@ before cross-cutting; regression last). Update **Status** and **Findings** every
| B2 | Tracker (core) | `/` buckets, pay/skip/notes/overrides, balance cards, overdue, ledger, drift | seeded + adversarial | ⬜ | 0 / 0 |
| B3 | Bills & schedules | `/bills` CRUD, custom schedules, reorder, merchant rules, historical import | adversarial | ⬜ | 0 / 0 |
| B4 | Subscriptions & Categories | `/subscriptions`, catalog, `/categories`, groups, reorder | seeded | ⬜ | 0 / 0 |
| B5 | Reporting reconciliation | `/summary`, `/calendar`, `/analytics`, `/health` cross-check totals | seeded + large | ⬜ | 0 / 0 |
| B5 | Reporting reconciliation | `/summary`, `/calendar`, `/analytics`, `/health` cross-check totals | seeded + large | 🔄 | 1 / 1 |
| B6 | Spending | `/spending` YNAB view, averages, cover-overspending, safe-to-spend | seeded + edge months | 🔄 | 0 / 1 |
| B7 | Debt planning (math) | `/snowball`, `/payoff` APR/amortization vs hand-calc | edge (APR=0, $0 debt) | 🔄 | 0 / 2 |
| B8 | Banking & bank sync | `/bank-transactions`, SimpleFIN sync, matching, merchant/store, advisory filter | seeded txns | ⬜ | 0 / 0 |
@ -115,7 +115,7 @@ until you get a clean cycle.
| Cycle | Started | Build / commit | Findings logged | Fixed / archived | Result |
|-------|---------|----------------|-----------------|------------------|--------|
| 1 | 2026-07-02 | `bdbf231`→`98c8fab` (dev) | 9 | **9 → all fixed & archived** (B9-01, B13-01, B6-01, B7-01, B7-02, B14-01, B14-02, B14-03, B0-01) | 🔁 all findings fixed — **0 open**; re-run required for a clean pass. Probed B0/B1/B3/B4/B6/B7/B8/B9/B13/B14. Solid: auth-isolation, CSRF, payment/date validation, recurrence (quarterly/annual gating, Feb-31, leap year), transaction matching/dedup, subscription+spending math, XSS. Fixed: seed 100× cents (S2), bill-amount validation, both money-rounding/format bugs, all a11y (8/8 axe), bundle split, unused-dep + dead-code removal. |
| 1 | 2026-07-02 | `bdbf231`→`72d06aa` (dev) | 11 | **10 fixed & archived** (B9-01, B13-01, B6-01, B7-01, B7-02, B14-01/02/03, B0-01, **B5-01**) | 🔁 re-run required. B5 reconciliation (added post seed-fix) caught **QA-B5-01 (S2)**: Summary counted annual/off-month-quarterly bills every month → fixed via `resolveDueDate` gate; Tracker↔Summary now reconcile. Open: **1** (QA-B5-02, S3 — SimpleFIN bank-tracking `unpaid` SQL residual, needs SimpleFIN env). Probed B0/B1/B3/B4/B5/B6/B7/B8/B9/B13/B14; solid: auth/isolation, CSRF, payment/date validation, recurrence, matching/dedup, subscription+spending math, XSS. |
**Result key:** 🔄 in progress · 🔁 findings fixed, re-run required · ✅ clean (zero findings — QA complete)
@ -134,7 +134,7 @@ fixing. Keep only **Open / Fixing / Fixed** rows here. Once a finding is
| ID | Sev | Area (`file:line`) | Summary | Status | Notes / repro |
|----|-----|--------------------|---------|--------|---------------|
| _(none — all Cycle 1 findings fixed & archived to `HISTORY.md` v0.41.0)_ | | | | | |
| QA-B5-02 | S3 | `routes/summary.js:53` (`buildBankTracking`) | SimpleFIN `unpaid_this_month` SQL sums all active bills (no occurrence gate) — same root as QA-B5-01, secondary/SimpleFIN-only path | 🔴 Open | needs SimpleFIN test env to verify a fix |
**Finding template** (paste a new row above; keep the full write-up here until archived):

View File

@ -86,6 +86,23 @@ test('CSRF — a state-changing request without a token is rejected (B13)', asyn
expect.soft(res.status(), 'missing CSRF token must be rejected (403)').toBe(403);
});
test('reconcile — Tracker and Summary report the same month obligation (B5)', async ({ request }) => {
const now = new Date();
const q = `?year=${now.getFullYear()}&month=${now.getMonth() + 1}`;
const tracker = await (await request.get(`/api/tracker${q}`)).json();
const summary = await (await request.get(`/api/summary${q}`)).json();
const trackerExpected = tracker?.summary?.total_expected ?? 0;
// Summary lists each bill; sum its display_amount for the same month.
const summaryExpected = (summary?.expenses ?? []).reduce(
(s, e) => s + (Number(e.display_amount ?? e.expected_amount) || 0),
0,
);
const round = (n) => Math.round(n * 100) / 100;
console.log(`[recon] tracker.total_expected=$${round(trackerExpected)} summary.sum=$${round(summaryExpected)}`);
expect.soft(round(summaryExpected), 'Summary bill total must reconcile with Tracker (B5)').toBe(round(trackerExpected));
});
test('seed demo data stores amounts in the correct unit — cents, not dollars (B9)', async ({ request }) => {
// QA-B9-01: POST /api/user/seed-demo-data must produce realistic amounts. The
// seed inserts dollars into the integer-cents expected_amount column (regression

View File

@ -1,7 +1,7 @@
const express = require('express');
const router = express.Router();
const { getDb } = require('../db/database');
const { getCycleRange } = require('../services/statusService');
const { getCycleRange, resolveDueDate } = require('../services/statusService');
const { getUserSettings } = require('../services/userSettings');
const { accountingActiveSql } = require('../services/paymentAccountingService');
const { toCents, fromCents } = require('../utils/money');
@ -244,6 +244,9 @@ function buildSummary(db, userId, year, month) {
b.name,
b.expected_amount,
b.due_day,
b.billing_cycle,
b.cycle_type,
b.cycle_day,
c.name AS category_name,
m.actual_amount,
m.is_skipped,
@ -262,7 +265,11 @@ function buildSummary(db, userId, year, month) {
b.sort_order ASC,
b.due_day ASC,
b.name ASC
`).all(year, month, userId);
`).all(year, month, userId)
// QA-B5-01: only bills that actually occur in this month (matches the Tracker's
// resolveDueDate gating) — otherwise annual / off-month quarterly bills inflate
// the monthly expense list and total, disagreeing with the Tracker.
.filter(row => resolveDueDate(row, year, month));
const billIds = billRows.map(row => row.bill_id);
const paymentMap = new Map();