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 <noreply@anthropic.com>
This commit is contained in:
null 2026-07-02 21:23:37 -05:00
parent a15ff056b3
commit 1bd282f47b
4 changed files with 24 additions and 3 deletions

View File

@ -3,7 +3,7 @@
### 🐛 QA Fixes ### 🐛 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) - **[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) - **[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) - **[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

@ -115,7 +115,7 @@ until you get a clean cycle.
| Cycle | Started | Build / commit | Findings logged | Fixed / archived | Result | | 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) **Result key:** 🔄 in progress · 🔁 findings fixed, re-run required · ✅ clean (zero findings — QA complete)

View File

@ -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)); 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 }) => { 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 // 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 // seed inserts dollars into the integer-cents expected_amount column (regression

View File

@ -3,6 +3,7 @@
const { getDb } = require('../db/database'); const { getDb } = require('../db/database');
const { accountingActiveSql } = require('./paymentAccountingService'); const { accountingActiveSql } = require('./paymentAccountingService');
const { sumMoney, fromCents } = require('../utils/money'); const { sumMoney, fromCents } = require('../utils/money');
const { resolveDueDate } = require('./statusService');
function parseInteger(value, fallback) { function parseInteger(value, fallback) {
if (value === undefined || value === null || value === '') return fallback; if (value === undefined || value === null || value === '') return fallback;
@ -134,6 +135,7 @@ function getAnalyticsSummary(userId, query = {}) {
const bills = db.prepare(` const bills = db.prepare(`
SELECT b.id, b.name, b.category_id, b.expected_amount, b.active, b.created_at, 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 c.name AS category_name
FROM bills b 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 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); }).filter(row => row.total > 0);
const expected_vs_actual = rangeMonths.map(m => { const expected_vs_actual = rangeMonths.map(m => {
const [mYear, mMonth] = m.key.split('-').map(Number);
let expected = 0; let expected = 0;
let actual = 0; let actual = 0;
let skipped_count = 0; let skipped_count = 0;
@ -197,7 +200,10 @@ function getAnalyticsSummary(userId, query = {}) {
if (!skipped || parsed.includeSkipped) { if (!skipped || parsed.includeSkipped) {
actual += paymentByBillMonth.get(`${bill.id}:${m.key}`) || 0; 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; expected += state?.actual_amount ?? bill.expected_amount ?? 0;
} }
} }