From a15ff056b31ab09f25b84e1c6b83df96e2766309 Mon Sep 17 00:00:00 2001 From: null Date: Thu, 2 Jul 2026 21:19:35 -0500 Subject: [PATCH] fix(qa): Summary excludes bills not due in the month (reconciles with Tracker) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- HISTORY.md | 1 + docs/QA_PLAN.md | 6 +++--- e2e/api.probe.spec.js | 17 +++++++++++++++++ routes/summary.js | 11 +++++++++-- 4 files changed, 30 insertions(+), 5 deletions(-) diff --git a/HISTORY.md b/HISTORY.md index c79320f..c725efe 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -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) diff --git a/docs/QA_PLAN.md b/docs/QA_PLAN.md index 0dd7e44..21d7120 100644 --- a/docs/QA_PLAN.md +++ b/docs/QA_PLAN.md @@ -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): diff --git a/e2e/api.probe.spec.js b/e2e/api.probe.spec.js index d75df1a..0a926bd 100644 --- a/e2e/api.probe.spec.js +++ b/e2e/api.probe.spec.js @@ -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 diff --git a/routes/summary.js b/routes/summary.js index 481ea84..6ada2c2 100644 --- a/routes/summary.js +++ b/routes/summary.js @@ -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();