From 1bd282f47b185de8f86b05792bc04c743ec58d1b Mon Sep 17 00:00:00 2001 From: null Date: Thu, 2 Jul 2026 21:23:37 -0500 Subject: [PATCH] fix(qa): Analytics "expected" gates by occurrence (matches Tracker/Summary) - analyticsService: only add a bill's expected_amount in months it actually occurs (resolveDueDate), so annual / off-month quarterly bills no longer inflate the expected-vs-actual line every month (QA-B5-03, same root as B5-01) - add a Tracker<->Analytics reconciliation guard to e2e/api.probe.spec.js - docs: archive QA-B5-03; cycle log Co-Authored-By: Claude Opus 4.8 --- HISTORY.md | 2 +- docs/QA_PLAN.md | 2 +- e2e/api.probe.spec.js | 15 +++++++++++++++ services/analyticsService.js | 8 +++++++- 4 files changed, 24 insertions(+), 3 deletions(-) diff --git a/HISTORY.md b/HISTORY.md index c725efe..36189c5 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -3,7 +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) +- **[Summary/Analytics] Non-monthly bills were counted in every month** — the Summary expense list/total and the Analytics "expected vs actual" line both counted annual (and off-month quarterly) bills for months they weren't due, over-stating the obligation and disagreeing with the Tracker (e.g. a yearly insurance bill inflated every month). Both `routes/summary.js` and `services/analyticsService.js` now gate bills by `resolveDueDate` — the same occurrence check the Tracker uses. Guarded by Tracker↔Summary and Tracker↔Analytics reconciliation checks 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, QA-B5-03) - **[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 21d7120..612d317 100644 --- a/docs/QA_PLAN.md +++ b/docs/QA_PLAN.md @@ -115,7 +115,7 @@ until you get a clean cycle. | Cycle | Started | Build / commit | Findings logged | Fixed / archived | Result | |-------|---------|----------------|-----------------|------------------|--------| -| 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. | +| 1 | 2026-07-02 | `bdbf231`→`a15ff05`+ (dev) | 12 | **11 fixed & archived** (B9-01, B13-01, B6-01, B7-01, B7-02, B14-01/02/03, B0-01, **B5-01, B5-03**) | 🔁 re-run required. Post seed-fix reconciliation caught the **occurrence-gating family**: Summary (B5-01, S2) and Analytics (B5-03, S3) counted annual/off-month-quarterly bills every month → both fixed via `resolveDueDate`; Tracker↔Summary and Tracker↔Analytics now reconcile (guarded in the probe). 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) diff --git a/e2e/api.probe.spec.js b/e2e/api.probe.spec.js index 0a926bd..b60b711 100644 --- a/e2e/api.probe.spec.js +++ b/e2e/api.probe.spec.js @@ -103,6 +103,21 @@ test('reconcile — Tracker and Summary report the same month obligation (B5)', expect.soft(round(summaryExpected), 'Summary bill total must reconcile with Tracker (B5)').toBe(round(trackerExpected)); }); +test('reconcile — Analytics expected line gates by occurrence like the Tracker (B5)', async ({ request }) => { + const now = new Date(); + const y = now.getFullYear(); + const m = now.getMonth() + 1; + const tracker = await (await request.get(`/api/tracker?year=${y}&month=${m}`)).json(); + const analytics = await (await request.get(`/api/analytics/summary?year=${y}&month=${m}&months=12`)).json(); + + const key = `${y}-${String(m).padStart(2, '0')}`; + const row = (analytics?.expected_vs_actual ?? []).find((r) => r.month === key); + const trackerExpected = Math.round((tracker?.summary?.total_expected ?? 0) * 100) / 100; + const analyticsExpected = Math.round((row?.expected ?? 0) * 100) / 100; + console.log(`[recon] analytics.expected[${key}]=$${analyticsExpected} tracker.total_expected=$${trackerExpected}`); + expect.soft(analyticsExpected, 'Analytics expected must gate by occurrence (QA-B5-01 family)').toBe(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/services/analyticsService.js b/services/analyticsService.js index 3944e8c..8812ffc 100644 --- a/services/analyticsService.js +++ b/services/analyticsService.js @@ -3,6 +3,7 @@ const { getDb } = require('../db/database'); const { accountingActiveSql } = require('./paymentAccountingService'); const { sumMoney, fromCents } = require('../utils/money'); +const { resolveDueDate } = require('./statusService'); function parseInteger(value, fallback) { if (value === undefined || value === null || value === '') return fallback; @@ -134,6 +135,7 @@ function getAnalyticsSummary(userId, query = {}) { const bills = db.prepare(` SELECT b.id, b.name, b.category_id, b.expected_amount, b.active, b.created_at, + b.due_day, b.billing_cycle, b.cycle_type, b.cycle_day, c.name AS category_name FROM bills b LEFT JOIN categories c ON c.id = b.category_id AND c.user_id = b.user_id AND c.deleted_at IS NULL @@ -187,6 +189,7 @@ function getAnalyticsSummary(userId, query = {}) { }).filter(row => row.total > 0); const expected_vs_actual = rangeMonths.map(m => { + const [mYear, mMonth] = m.key.split('-').map(Number); let expected = 0; let actual = 0; let skipped_count = 0; @@ -197,7 +200,10 @@ function getAnalyticsSummary(userId, query = {}) { if (!skipped || parsed.includeSkipped) { actual += paymentByBillMonth.get(`${bill.id}:${m.key}`) || 0; } - if (!skipped) { + // QA-B5-01 family: only add "expected" in months the bill actually occurs, + // so annual / off-month quarterly bills don't inflate the expected line + // every month (matches the Tracker / Summary occurrence gate). + if (!skipped && resolveDueDate(bill, mYear, mMonth)) { expected += state?.actual_amount ?? bill.expected_amount ?? 0; } }