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:
parent
a15ff056b3
commit
1bd282f47b
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue