Compare commits
12 Commits
127b69ffc2
...
2050e13407
| Author | SHA1 | Date |
|---|---|---|
|
|
2050e13407 | |
|
|
972daa9b07 | |
|
|
5fe1f6499b | |
|
|
18c7025f3a | |
|
|
ccf89e6df1 | |
|
|
ad474f1ac1 | |
|
|
3c1d000bab | |
|
|
819cfdfa9f | |
|
|
1bd282f47b | |
|
|
a15ff056b3 | |
|
|
72d06aa2d8 | |
|
|
98c8fab176 |
|
|
@ -3,10 +3,14 @@
|
||||||
|
|
||||||
### 🐛 QA Fixes
|
### 🐛 QA Fixes
|
||||||
|
|
||||||
|
- **[Notifications] "Send test push" was completely broken** — `services/notificationService.js` attached its `_push` export (the ntfy/Gotify/Discord/Telegram helpers) *before* the final `module.exports = {…}`, which clobbered it, so `require('…/notificationService')._push` was `undefined`. `routes/notifications.js` (`const { sendTestPush } = require(…)._push || {}`) therefore always hit `throw 'Push service not initialised'` → **`POST /api/notifications/test-push` always returned 500** for every user testing their push channel. Scheduled reminders were unaffected (they call `sendPushToUser` in-scope). Moved the `_push` assignment after the reassignment. Covered by `tests/notificationDelivery.test.js` (per-channel payloads, dispatch, error handling, and a check that the auth token never leaks into the message body). (was QA-B10-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`. The SimpleFIN bank-tracking `unpaid_this_month` metric had the same gap and is fixed the same way (fetch + JS `resolveDueDate` filter, since SQL can't call it), covered by `tests/summaryBankTracking.test.js`. (was QA-B5-01, QA-B5-02, 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)
|
||||||
- **[a11y] Icon-only controls and chart SVGs had no accessible name** — Radix Select filter/sort triggers (Tracker, Bills) and the Spending month-nav buttons rendered with no discernible text (screen readers announced a bare "button"); Analytics chart `<svg role="img">`s had no name; a Snowball drag-handle `<div>` used a prohibited `aria-label`. Added `aria-label`s to the triggers/buttons and a `label` prop to the Analytics `SvgFrame`, and switched the drag-handle to `title`. Clears axe **critical `button-name`** and **serious `svg-img-alt` / `aria-prohibited-attr`** across those pages; guarded by `e2e/a11y.authed.spec.js`. (was QA-B14-01, part of QA-B14-02)
|
- **[a11y] Icon-only controls and chart SVGs had no accessible name** — Radix Select filter/sort triggers (Tracker, Bills) and the Spending month-nav buttons rendered with no discernible text (screen readers announced a bare "button"); Analytics chart `<svg role="img">`s had no name; a Snowball drag-handle `<div>` used a prohibited `aria-label`. Added `aria-label`s to the triggers/buttons and a `label` prop to the Analytics `SvgFrame`, and switched the drag-handle to `title`. Clears axe **critical `button-name`** and **serious `svg-img-alt` / `aria-prohibited-attr`** across those pages; guarded by `e2e/a11y.authed.spec.js`. (was QA-B14-01, part of QA-B14-02)
|
||||||
|
- **[Money] `toCents` lost a cent on 3-decimal half values** — `toCents(1.005)` returned `100` ($1.00) instead of `101`, because `Math.round(n * 100)` inherits binary-float error (`1.005 * 100 === 100.4999…`). Now rounds off the shortest decimal string that round-trips the value (half away from zero); output is **identical** for every integer / ≤2-decimal / `"$1,234.56"` input, so no downstream behavior changes. Added `tests/money.test.js` (9 tests). (was QA-B7-01)
|
||||||
|
- **[a11y] Nested interactive controls in Categories & Snowball rows** — Categories rows were `role="button"` (and draggable) yet wrapped their own move/edit/delete buttons, and the Snowball plan-status header wrapped its action buttons *inside* the collapsible trigger button (axe **serious `nested-interactive`**). Restructured both: Categories now use a plain container with a dedicated chevron toggle button (click-anywhere still expands via mouse; keyboard/SR use the chevron); the Snowball header splits into a name/progress toggle, sibling action buttons, and a chevron toggle. Expand/collapse verified by `e2e/categories.spec.js`; all 8 authenticated pages now pass axe. (completes QA-B14-02)
|
||||||
|
|
||||||
### 🧹 QA Cleanup
|
### 🧹 QA Cleanup
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -145,11 +145,14 @@ export default function PlanStatusBanner({ plan, onPause, onResume, onComplete,
|
||||||
<div className="mb-4 rounded-xl border border-emerald-400/25 bg-emerald-500/[0.05] dark:bg-emerald-400/[0.04] shadow-sm overflow-hidden">
|
<div className="mb-4 rounded-xl border border-emerald-400/25 bg-emerald-500/[0.05] dark:bg-emerald-400/[0.04] shadow-sm overflow-hidden">
|
||||||
|
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<CollapsibleTrigger asChild>
|
{/* Header row. The name/progress area and the chevron are the collapsible
|
||||||
<button type="button" className="w-full text-left">
|
toggles; the action buttons are siblings (not nested inside a trigger
|
||||||
<div className="flex items-center gap-3 px-4 py-3">
|
button) so they don't trip axe nested-interactive (a11y QA-B14-02). */}
|
||||||
|
<div className="flex items-center gap-3 px-4 py-3">
|
||||||
|
|
||||||
{/* Status dot + name */}
|
{/* Status dot + name + progress — toggle */}
|
||||||
|
<CollapsibleTrigger asChild>
|
||||||
|
<button type="button" className="flex min-w-0 flex-1 items-center gap-3 text-left">
|
||||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||||
{isActive ? (
|
{isActive ? (
|
||||||
<span className="relative flex h-2.5 w-2.5 shrink-0">
|
<span className="relative flex h-2.5 w-2.5 shrink-0">
|
||||||
|
|
@ -178,38 +181,44 @@ export default function PlanStatusBanner({ plan, onPause, onResume, onComplete,
|
||||||
</div>
|
</div>
|
||||||
<span className="text-xs font-mono text-muted-foreground">{overallPct}%</span>
|
<span className="text-xs font-mono text-muted-foreground">{overallPct}%</span>
|
||||||
</div>
|
</div>
|
||||||
|
</button>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions — siblings of the triggers, not nested inside them */}
|
||||||
<div className="flex items-center gap-1 shrink-0" onClick={e => e.stopPropagation()}>
|
<div className="flex items-center gap-1 shrink-0">
|
||||||
{isActive && (
|
{isActive && (
|
||||||
<>
|
<>
|
||||||
<Button size="sm" variant="ghost" className="h-7 px-2 text-xs gap-1" onClick={onPause}>
|
<Button size="sm" variant="ghost" className="h-7 px-2 text-xs gap-1" onClick={onPause}>
|
||||||
<Pause className="h-3 w-3" /> Pause
|
<Pause className="h-3 w-3" /> Pause
|
||||||
</Button>
|
|
||||||
<Button size="sm" variant="outline" className="h-7 px-2 text-xs gap-1 border-emerald-400/40 text-emerald-600 hover:bg-emerald-500/10 dark:text-emerald-400" onClick={() => confirm('complete', 'Mark plan as complete?', 'This will record your plan as successfully completed.', onComplete)}>
|
|
||||||
<CheckCircle2 className="h-3 w-3" /> Complete
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{isPaused && (
|
|
||||||
<>
|
|
||||||
<Button size="sm" variant="outline" className="h-7 px-2 text-xs gap-1 border-emerald-400/40 text-emerald-600 hover:bg-emerald-500/10 dark:text-emerald-400" onClick={onResume}>
|
|
||||||
<Play className="h-3 w-3" /> Resume
|
|
||||||
</Button>
|
|
||||||
<Button size="sm" variant="ghost" className="h-7 px-2 text-xs gap-1 text-destructive hover:bg-destructive/10" onClick={() => confirm('abandon', 'Abandon this plan?', 'This plan will be moved to history. Your debt data stays unchanged.', onAbandon)}>
|
|
||||||
<X className="h-3 w-3" /> Abandon
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<Button size="sm" variant="ghost" className="h-7 px-2 text-xs gap-1 text-muted-foreground" onClick={() => confirm('new', 'Start a new plan?', 'Your current plan will be abandoned and moved to history. Your debt data stays unchanged.', onNewPlan)}>
|
|
||||||
<Zap className="h-3 w-3" /> New Plan
|
|
||||||
</Button>
|
</Button>
|
||||||
<ChevronDown className={cn('h-4 w-4 text-muted-foreground transition-transform', open && 'rotate-180')} />
|
<Button size="sm" variant="outline" className="h-7 px-2 text-xs gap-1 border-emerald-400/40 text-emerald-600 hover:bg-emerald-500/10 dark:text-emerald-400" onClick={() => confirm('complete', 'Mark plan as complete?', 'This will record your plan as successfully completed.', onComplete)}>
|
||||||
</div>
|
<CheckCircle2 className="h-3 w-3" /> Complete
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{isPaused && (
|
||||||
|
<>
|
||||||
|
<Button size="sm" variant="outline" className="h-7 px-2 text-xs gap-1 border-emerald-400/40 text-emerald-600 hover:bg-emerald-500/10 dark:text-emerald-400" onClick={onResume}>
|
||||||
|
<Play className="h-3 w-3" /> Resume
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="ghost" className="h-7 px-2 text-xs gap-1 text-destructive hover:bg-destructive/10" onClick={() => confirm('abandon', 'Abandon this plan?', 'This plan will be moved to history. Your debt data stays unchanged.', onAbandon)}>
|
||||||
|
<X className="h-3 w-3" /> Abandon
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<Button size="sm" variant="ghost" className="h-7 px-2 text-xs gap-1 text-muted-foreground" onClick={() => confirm('new', 'Start a new plan?', 'Your current plan will be abandoned and moved to history. Your debt data stays unchanged.', onNewPlan)}>
|
||||||
|
<Zap className="h-3 w-3" /> New Plan
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
{/* Chevron — also a toggle */}
|
||||||
</button>
|
<CollapsibleTrigger asChild>
|
||||||
</CollapsibleTrigger>
|
<button type="button" aria-label="Toggle plan details" className="shrink-0 rounded text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
|
||||||
|
<ChevronDown className={cn('h-4 w-4 transition-transform', open && 'rotate-180')} />
|
||||||
|
</button>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Collapsible body — per-debt rows */}
|
{/* Collapsible body — per-debt rows */}
|
||||||
<CollapsibleContent>
|
<CollapsibleContent>
|
||||||
|
|
|
||||||
|
|
@ -565,16 +565,16 @@ export default function CategoriesPage() {
|
||||||
dragProps?.isDropTarget && 'ring-2 ring-inset ring-primary/35',
|
dragProps?.isDropTarget && 'ring-2 ring-inset ring-primary/35',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
{/* Plain container (not role=button) so the nested action
|
||||||
|
buttons aren't interactive-in-interactive (a11y QA-B14-02).
|
||||||
|
Mouse click-anywhere still expands; keyboard/SR users use
|
||||||
|
the dedicated chevron toggle button below. */}
|
||||||
<div
|
<div
|
||||||
role="button"
|
|
||||||
tabIndex={0}
|
|
||||||
aria-expanded={isExpanded}
|
|
||||||
title={preview}
|
title={preview}
|
||||||
onClick={() => toggleCategory(cat.id)}
|
onClick={() => toggleCategory(cat.id)}
|
||||||
onKeyDown={event => onRowKeyDown(event, cat.id)}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
'group grid cursor-pointer gap-4 px-4 py-4 transition-colors sm:px-5 md:grid-cols-[minmax(0,1fr)_auto] md:items-center',
|
'group grid cursor-pointer gap-4 px-4 py-4 transition-colors sm:px-5 md:grid-cols-[minmax(0,1fr)_auto] md:items-center',
|
||||||
'hover:bg-muted/35 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset',
|
'hover:bg-muted/35',
|
||||||
isExpanded && 'bg-muted/25',
|
isExpanded && 'bg-muted/25',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|
@ -615,12 +615,20 @@ export default function CategoriesPage() {
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<ChevronDown
|
<button
|
||||||
className={cn(
|
type="button"
|
||||||
'mt-0.5 h-4 w-4 shrink-0 text-muted-foreground transition-transform',
|
onClick={event => { event.stopPropagation(); toggleCategory(cat.id); }}
|
||||||
isExpanded && 'rotate-180 text-foreground',
|
aria-expanded={isExpanded}
|
||||||
)}
|
aria-label={`${isExpanded ? 'Collapse' : 'Expand'} ${cat.name}`}
|
||||||
/>
|
className="mt-0.5 shrink-0 rounded text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
>
|
||||||
|
<ChevronDown
|
||||||
|
className={cn(
|
||||||
|
'h-4 w-4 transition-transform',
|
||||||
|
isExpanded && 'rotate-180 text-foreground',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# BillTracker — Master QA Plan (living document)
|
# BillTracker — Master QA Plan (living document)
|
||||||
|
|
||||||
**Version target:** v0.41.x · **Executor:** Claude (active) · **Last updated:** 2026-07-02
|
**Version target:** v0.41.x · **Executor:** Claude (active) · **Last updated:** 2026-07-02 (Cycle 1: 13 findings fixed & archived, 0 open — incl. a broken "Send test push")
|
||||||
|
|
||||||
This is a **living, operational** QA document, not a static spec. Claude runs it,
|
This is a **living, operational** QA document, not a static spec. Claude runs it,
|
||||||
in **batches**, actively hunting for bugs/errors/rough edges, **fixing** them, and
|
in **batches**, actively hunting for bugs/errors/rough edges, **fixing** them, and
|
||||||
|
|
@ -87,22 +87,22 @@ before cross-cutting; regression last). Update **Status** and **Findings** every
|
||||||
| # | Batch | Primary surface | Data state | Status | Open / Fixed |
|
| # | Batch | Primary surface | Data state | Status | Open / Fixed |
|
||||||
|---|-------|-----------------|-----------|--------|--------------|
|
|---|-------|-----------------|-----------|--------|--------------|
|
||||||
| B0 | Baseline, tooling & **coverage recon** | `npm run ci`/`check`, app boots, console clean, **re-scan routes/pages/API vs plan & update it**, **control census** | any | 🔄 | 0 / 1 |
|
| B0 | Baseline, tooling & **coverage recon** | `npm run ci`/`check`, app boots, console clean, **re-scan routes/pages/API vs plan & update it**, **control census** | any | 🔄 | 0 / 1 |
|
||||||
| B-UI | **Design-system primitives** | each `client/components/ui/*` × state matrix (default/hover/focus/active/disabled/loading/error/read-only) × light/dark × keyboard | any | ⬜ | 0 / 0 |
|
| B-UI | **Design-system primitives** | each `client/components/ui/*` × state matrix (default/hover/focus/active/disabled/loading/error/read-only) × light/dark × keyboard | any | 🔄 | 0 / 0 |
|
||||||
| B1 | Auth & authorization | login (pw/OIDC/TOTP/WebAuthn), roles, single-user, CSRF, data isolation | multi + single user | ⬜ | 0 / 0 |
|
| B1 | Auth & authorization | login (pw/OIDC/TOTP/WebAuthn), roles, single-user, CSRF, data isolation | multi + single user | ⬜ | 0 / 0 |
|
||||||
| B2 | Tracker (core) | `/` buckets, pay/skip/notes/overrides, balance cards, overdue, ledger, drift | seeded + adversarial | ⬜ | 0 / 0 |
|
| 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 |
|
| 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 |
|
| 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 | 🔄 | 0 / 3 |
|
||||||
| B6 | Spending | `/spending` YNAB view, averages, cover-overspending, safe-to-spend | seeded + edge months | 🔄 | 0 / 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) | 🔄 | 1 / 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 |
|
| B8 | Banking & bank sync | `/bank-transactions`, SimpleFIN sync, matching, merchant/store, advisory filter | seeded txns | ⬜ | 0 / 0 |
|
||||||
| B9 | Data lifecycle | `/data` import (XLSX/CSV/SQLite), export, ICS feed, backups round-trip | empty + seeded | 🔄 | 0 / 1 |
|
| B9 | Data lifecycle | `/data` import (XLSX/CSV/SQLite), export, ICS feed, backups round-trip | empty + seeded | 🔄 | 0 / 1 |
|
||||||
| B10 | Notifications & workers | email + ntfy/Gotify/Discord/Telegram, reminders, cron workers | seeded | ⬜ | 0 / 0 |
|
| B10 | Notifications & workers | email + ntfy/Gotify/Discord/Telegram, reminders, cron workers | seeded | 🔄 | 0 / 1 |
|
||||||
| B11 | Admin panel | users, login mode, auth methods, backups, cleanup, status, onboarding | admin | ⬜ | 0 / 0 |
|
| B11 | Admin panel | users, login mode, auth methods, backups, cleanup, status, onboarding | admin | 🔄 | 0 / 0 |
|
||||||
| B12 | Settings, Profile & global UI | `/settings`, `/profile`, static pages, command palette, sidebar/nav | any | ⬜ | 0 / 0 |
|
| B12 | Settings, Profile & global UI | `/settings`, `/profile`, static pages, command palette, sidebar/nav | any | 🔄 | 0 / 0 |
|
||||||
| B13 | API / backend direct | all `/api/*`: auth, CSRF, validation, rate limits, error shape, IDOR, cents | via HTTP client | 🔄 | 0 / 1 |
|
| B13 | API / backend direct | all `/api/*`: auth, CSRF, validation, rate limits, error shape, IDOR, cents | via HTTP client | 🔄 | 0 / 1 |
|
||||||
| B14 | Non-functional | a11y, performance, PWA/offline, XSS/secrets, timezone/DST | large + adversarial | 🔄 | 1 / 2 |
|
| B14 | Non-functional | a11y, performance, PWA/offline, XSS/secrets, timezone/DST | large + adversarial | 🔄 | 0 / 3 |
|
||||||
| B15 | Regression & sign-off | full smoke on **production build**, exit criteria | seeded | ⬜ | 0 / 0 |
|
| B15 | Regression & sign-off | full smoke on **production build**, exit criteria | seeded | 🔄 | 0 / 0 |
|
||||||
|
|
||||||
> After B15, if any batch is 🔁 or has open S1/S2, loop back. Then start a new
|
> After B15, if any batch is 🔁 or has open S1/S2, loop back. Then start a new
|
||||||
> cycle from B0 against the next build/version.
|
> cycle from B0 against the next build/version.
|
||||||
|
|
@ -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` (dev) | 9 (find pass ongoing) | 7 → HISTORY v0.41.0 (B9-01, B13-01, B6-01, B14-01, B14-03, B0-01, B7-02; + a11y svg/aria of B14-02) | 🔄 in progress — B0/B1/B3/B4/B6/B7/B8/B9/B13/B14 probed. Solid: auth-isolation, CSRF, payment/date validation, **recurrence (quarterly/annual gating, Feb-31 clamp, leap year — all correct)**, **transaction matching/dedup guards**, subscription+spending math, XSS. **Fixed: seed 100× cents (S2), bill-amount validation, negative-money format, a11y labels, vendor-bundle split, unused-dep removal, dead-code removal.** Open: 2 (B7-01 rounding S3 [float-inherent], B14-02 nested-interactive S3 [architectural]) |
|
| 1 | 2026-07-02 | `bdbf231`→(dev) | 13 | **13 → all fixed, verified & archived** (…, +B10-01 broken "Send test push") | ✅ **0 open.** Post seed-fix reconciliation caught the **occurrence-gating family** — Summary (S2), Analytics, and SimpleFIN bank-tracking all counted non-monthly bills every month; all fixed via `resolveDueDate` and guarded (probe reconciliation + `tests/summaryBankTracking.test.js`). 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, calendar gating. **A full re-run (B0→B15) is still required to declare the cycle clean per exit criteria.** |
|
||||||
|
|
||||||
**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)
|
||||||
|
|
||||||
|
|
@ -134,8 +134,7 @@ fixing. Keep only **Open / Fixing / Fixed** rows here. Once a finding is
|
||||||
|
|
||||||
| ID | Sev | Area (`file:line`) | Summary | Status | Notes / repro |
|
| ID | Sev | Area (`file:line`) | Summary | Status | Notes / repro |
|
||||||
|----|-----|--------------------|---------|--------|---------------|
|
|----|-----|--------------------|---------|--------|---------------|
|
||||||
| QA-B7-01 | S3 | `utils/money.js:29` | `toCents` mis-rounds fractional cents: `toCents(1.005)` → 100 (`$1.00`) not 101 | 🔴 Open | see write-up (deferred — float-inherent) |
|
| _(none — all Cycle 1 findings fixed, verified & archived to `HISTORY.md` v0.41.0)_ | | | | | |
|
||||||
| QA-B14-02 | S3 | `/categories` (8), `/snowball` (1) | axe **serious** `nested-interactive`: draggable/expandable rows are `role=button` yet contain nested buttons | 🔴 Open | see write-up (deferred — architectural) |
|
|
||||||
|
|
||||||
**Finding template** (paste a new row above; keep the full write-up here until archived):
|
**Finding template** (paste a new row above; keep the full write-up here until archived):
|
||||||
|
|
||||||
|
|
@ -156,44 +155,7 @@ Fix: (what changed, commit) — Verified by: (repro re-run + ci)
|
||||||
Log console errors, failed network requests, and unhandled rejections as findings
|
Log console errors, failed network requests, and unhandled rejections as findings
|
||||||
**even if the UI looks fine**.
|
**even if the UI looks fine**.
|
||||||
|
|
||||||
### Cycle 1 — logged write-ups
|
_All Cycle 1 write-ups have been archived to `HISTORY.md` v0.41.0 (see §3)._
|
||||||
|
|
||||||
```
|
|
||||||
ID: QA-B7-01
|
|
||||||
Severity: S3 (minor — wrong edge behavior in money core that advertises exactness)
|
|
||||||
Environment: server-side money math
|
|
||||||
Area: utils/money.js:29 (toCents → Math.round(n * 100))
|
|
||||||
Steps to reproduce:
|
|
||||||
1. toCents(1.005) → 100 (i.e. $1.00), not 101 ($1.01).
|
|
||||||
2. Round-trip fromCents(toCents(1.005)) → 1 (a cent silently lost).
|
|
||||||
Expected: "cent-exact" per the file's own docstring — 1.005 → 101.
|
|
||||||
Actual: float multiply (1.005*100 = 100.4999…) rounds down before Math.round.
|
|
||||||
Evidence: node probe. Other 3-decimal inputs also affected (values near .xx5).
|
|
||||||
Impact: bounded to sub-cent, and only when a 3+ decimal dollar value reaches the
|
|
||||||
boundary (proration/interest), so low severity — but it contradicts the exactness
|
|
||||||
guarantee and is the plan's named "fractional cents" adversarial case.
|
|
||||||
Fix (deferred): round on a string/scaled-integer basis, or add epsilon before round.
|
|
||||||
```
|
|
||||||
|
|
||||||
```
|
|
||||||
ID: QA-B14-02 (partially fixed — nested-interactive remains)
|
|
||||||
Severity: S3 (accessibility — WCAG 2 A/AA, axe "serious")
|
|
||||||
Environment: authenticated app; found/verified via e2e/a11y.authed.spec.js
|
|
||||||
FIXED this cycle (archived to HISTORY.md, was part of B14-02 + all of B14-01):
|
|
||||||
- button-name (critical) → Tracker/Bills Select triggers + Spending month-nav +
|
|
||||||
Analytics chart svgs now have aria-labels. All 4 pages pass.
|
|
||||||
- svg-img-alt → /analytics chart svgs got aria-labels (SvgFrame `label` prop). Pass.
|
|
||||||
- aria-prohibited-attr → /snowball drag-handle div aria-label → title. Pass.
|
|
||||||
REMAINING (deferred — architectural, needs manual drag/keyboard/SR verification):
|
|
||||||
- nested-interactive → /categories (8): each row is <div role="button" tabindex=0
|
|
||||||
aria-expanded> AND draggable AND contains nested edit/delete/drag buttons.
|
|
||||||
- nested-interactive → /snowball (1): a <button class="w-full text-left"
|
|
||||||
aria-controls aria-expanded> accordion trigger wraps other interactive controls.
|
|
||||||
Fix (deferred): don't make the whole row/trigger a button — use a dedicated
|
|
||||||
expand/collapse control and keep the row a plain container, so interactive
|
|
||||||
children aren't nested inside an interactive ancestor. Re-verify drag + keyboard
|
|
||||||
after. Guard: e2e/a11y.authed.spec.js (currently red on these two pages by design).
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -269,6 +231,7 @@ Manual passes prove a button works **once**; they don't stop it regressing next
|
||||||
| `npm run test:e2e` | run the E2E suite headless (boots the app via `webServer`) |
|
| `npm run test:e2e` | run the E2E suite headless (boots the app via `webServer`) |
|
||||||
| `npm run test:e2e:ui` | Playwright UI mode — watch/debug interactively |
|
| `npm run test:e2e:ui` | Playwright UI mode — watch/debug interactively |
|
||||||
| `npm run test:e2e:update` | re-baseline visual-regression screenshots (review the diff before committing) |
|
| `npm run test:e2e:update` | re-baseline visual-regression screenshots (review the diff before committing) |
|
||||||
|
| `npm run smoke:prod` | **B15 production-build smoke** — builds, boots `node server.js` (dist/), drives the real artifact so the split vendor chunks are validated at runtime |
|
||||||
|
|
||||||
- **Setup (one-time):** `npm install` then `npx playwright install chromium`. Config: `playwright.config.js`; specs in `e2e/`.
|
- **Setup (one-time):** `npm install` then `npx playwright install chromium`. Config: `playwright.config.js`; specs in `e2e/`.
|
||||||
- **Scope:** the suite is a **thin critical-path smoke**, not a replacement for the manual playbooks — it locks the happy paths (login → pay bill → skip → note → reconcile), the primitive state matrix, per-page axe scans, and page screenshots. Grow it whenever a manual pass finds a UI regression that a click-test could have caught.
|
- **Scope:** the suite is a **thin critical-path smoke**, not a replacement for the manual playbooks — it locks the happy paths (login → pay bill → skip → note → reconcile), the primitive state matrix, per-page axe scans, and page screenshots. Grow it whenever a manual pass finds a UI regression that a click-test could have caught.
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,23 @@ test('data-isolation — cannot touch another user\'s bill (B1/IDOR)', async ({
|
||||||
expect.soft(del.status(), 'DELETE user B bill must be blocked (>=400)').toBeGreaterThanOrEqual(400);
|
expect.soft(del.status(), 'DELETE user B bill must be blocked (>=400)').toBeGreaterThanOrEqual(400);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('authorization — a regular user is blocked from admin + status APIs (B1/B11)', async ({ request }) => {
|
||||||
|
const token = await csrf(request);
|
||||||
|
// Read endpoints → 403 (requireAdmin); user A is a regular 'user'.
|
||||||
|
for (const url of ['/api/admin/users', '/api/admin/has-users', '/api/status', '/api/about-admin']) {
|
||||||
|
const res = await request.get(url);
|
||||||
|
console.log(`[authz] GET ${url} -> ${res.status()}`);
|
||||||
|
expect.soft(res.status(), `${url} must be admin-only (403)`).toBe(403);
|
||||||
|
}
|
||||||
|
// Write endpoint → 403 too (not a silent success).
|
||||||
|
const create = await request.post('/api/admin/users', {
|
||||||
|
headers: { 'x-csrf-token': token },
|
||||||
|
data: { username: 'sneaky_admin', password: 'password123', role: 'admin' },
|
||||||
|
});
|
||||||
|
console.log(`[authz] POST /api/admin/users -> ${create.status()}`);
|
||||||
|
expect.soft(create.status(), 'creating a user as a non-admin must be 403').toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
test('bad / nonexistent id — structured, not a 500 (B13)', async ({ request }) => {
|
test('bad / nonexistent id — structured, not a 500 (B13)', async ({ request }) => {
|
||||||
for (const id of ['99999999', 'not-a-number', '0', '-1']) {
|
for (const id of ['99999999', 'not-a-number', '0', '-1']) {
|
||||||
const res = await request.get(`/api/bills/${id}`);
|
const res = await request.get(`/api/bills/${id}`);
|
||||||
|
|
@ -86,6 +103,38 @@ 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);
|
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('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
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,62 @@
|
||||||
|
// B-UI: functional behavior of shared primitives that axe/a11y can't assert —
|
||||||
|
// dialogs cancel without side effects, Selects open by mouse and keyboard,
|
||||||
|
// disabled controls stay inert. All checks are READ-ONLY (Esc/Cancel/reopen), so
|
||||||
|
// they're safe alongside the other UI specs in the parallel suite.
|
||||||
|
const { test, expect } = require('@playwright/test');
|
||||||
|
const { STORAGE_STATE } = require('./constants');
|
||||||
|
|
||||||
|
test.use({ storageState: STORAGE_STATE });
|
||||||
|
|
||||||
|
test('dialog: Add Bill opens, Esc closes it, and creates nothing (Cancel = no side effect)', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
const badges = page.locator('button[title="Click to mark paid"]:visible, button[title="Click to mark unpaid"]:visible');
|
||||||
|
await expect(badges.first()).toBeVisible(); // wait for bills to load before counting
|
||||||
|
const before = await badges.count();
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Add Bill' }).click();
|
||||||
|
const dialog = page.getByRole('dialog');
|
||||||
|
await expect(dialog).toBeVisible();
|
||||||
|
// Focus moved into the dialog (focus trap).
|
||||||
|
await expect(dialog.locator(':focus')).toHaveCount(1);
|
||||||
|
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
await expect(dialog).toBeHidden();
|
||||||
|
// No bill was created and the page is still functional.
|
||||||
|
await expect(page.getByRole('button', { name: 'Add Bill' })).toBeVisible();
|
||||||
|
await expect.poll(() => badges.count()).toBe(before);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('select: category filter opens by mouse and by keyboard and lists options', async ({ page, isMobile }) => {
|
||||||
|
test.skip(isMobile, 'filter panel layout differs on mobile');
|
||||||
|
await page.goto('/');
|
||||||
|
|
||||||
|
// Radix Select trigger exposes role="combobox".
|
||||||
|
const trigger = page.getByRole('combobox', { name: 'Filter by category' });
|
||||||
|
await expect(trigger).toBeVisible();
|
||||||
|
|
||||||
|
// Mouse: opens a listbox with the "All categories" option.
|
||||||
|
await trigger.click();
|
||||||
|
const listbox = page.getByRole('listbox');
|
||||||
|
await expect(listbox).toBeVisible();
|
||||||
|
await expect(page.getByRole('option', { name: 'All categories' })).toBeVisible();
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
await expect(listbox).toBeHidden();
|
||||||
|
|
||||||
|
// Keyboard: focus the trigger and open with Enter.
|
||||||
|
await trigger.focus();
|
||||||
|
await page.keyboard.press('Enter');
|
||||||
|
await expect(page.getByRole('listbox')).toBeVisible();
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('disabled control: sort-direction button is inert in Custom order', async ({ page, isMobile }) => {
|
||||||
|
test.skip(isMobile, 'filter panel layout differs on mobile');
|
||||||
|
await page.goto('/');
|
||||||
|
// Default sort is "Custom order", for which the asc/desc toggle is disabled.
|
||||||
|
const dir = page.getByRole('button', { name: 'Asc' });
|
||||||
|
await expect(dir).toBeVisible();
|
||||||
|
await expect(dir).toBeDisabled();
|
||||||
|
// Clicking a disabled control must be a no-op (force past the actionability guard).
|
||||||
|
await dir.click({ force: true });
|
||||||
|
await expect(dir).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
// Regression for the QA-B14-02 a11y refactor: category rows are now plain
|
||||||
|
// containers with a dedicated chevron toggle button (instead of role=button rows
|
||||||
|
// that nested action buttons). Verify expand/collapse still works both ways.
|
||||||
|
const { test, expect } = require('@playwright/test');
|
||||||
|
const { STORAGE_STATE } = require('./constants');
|
||||||
|
|
||||||
|
test.use({ storageState: STORAGE_STATE });
|
||||||
|
|
||||||
|
test('category expands via the chevron toggle and via row click (QA-B14-02)', async ({ page }) => {
|
||||||
|
await page.goto('/categories');
|
||||||
|
|
||||||
|
// Dedicated toggle button, collapsed initially.
|
||||||
|
const expandBtn = page.getByRole('button', { name: /^Expand / }).first();
|
||||||
|
await expect(expandBtn).toBeVisible();
|
||||||
|
await expect(expandBtn).toHaveAttribute('aria-expanded', 'false');
|
||||||
|
|
||||||
|
// Clicking it expands the row (button flips to "Collapse …").
|
||||||
|
await expandBtn.click();
|
||||||
|
await expect(page.getByRole('button', { name: /^Collapse / })).not.toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
@ -17,6 +17,7 @@
|
||||||
"test:e2e:ui": "node e2e/setup/prepare-db.js && playwright test --ui",
|
"test:e2e:ui": "node e2e/setup/prepare-db.js && playwright test --ui",
|
||||||
"test:e2e:update": "node e2e/setup/prepare-db.js && playwright test --project=chromium-desktop --project=chromium-mobile --update-snapshots",
|
"test:e2e:update": "node e2e/setup/prepare-db.js && playwright test --project=chromium-desktop --project=chromium-mobile --update-snapshots",
|
||||||
"test:e2e:probe": "node e2e/setup/prepare-db.js && playwright test --project=probe",
|
"test:e2e:probe": "node e2e/setup/prepare-db.js && playwright test --project=probe",
|
||||||
|
"smoke:prod": "bash scripts/prod-smoke.sh",
|
||||||
"ci": "npm run check:server && npm run test:all && npm run build",
|
"ci": "npm run check:server && npm run test:all && npm run build",
|
||||||
"start": "node server.js"
|
"start": "node server.js"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const { getDb } = require('../db/database');
|
const { getDb } = require('../db/database');
|
||||||
const { getCycleRange } = require('../services/statusService');
|
const { getCycleRange, resolveDueDate } = require('../services/statusService');
|
||||||
const { getUserSettings } = require('../services/userSettings');
|
const { getUserSettings } = require('../services/userSettings');
|
||||||
const { accountingActiveSql } = require('../services/paymentAccountingService');
|
const { accountingActiveSql } = require('../services/paymentAccountingService');
|
||||||
const { toCents, fromCents } = require('../utils/money');
|
const { toCents, fromCents } = require('../utils/money');
|
||||||
|
|
@ -48,15 +48,16 @@ function buildBankTrackingSummary(db, userId, year, month) {
|
||||||
`).get(userId, effectivePendingDays)
|
`).get(userId, effectivePendingDays)
|
||||||
: { pending_total: 0 };
|
: { pending_total: 0 };
|
||||||
|
|
||||||
// Unpaid bills remaining this month (not skipped, not yet paid)
|
// Unpaid bills remaining this month (not skipped, not yet paid), occurrence-gated
|
||||||
|
// in JS so annual / off-month quarterly bills don't inflate the total (QA-B5-02,
|
||||||
|
// same root as QA-B5-01 — SQL can't call resolveDueDate).
|
||||||
const { start, end } = getCycleRange(year, month);
|
const { start, end } = getCycleRange(year, month);
|
||||||
const unpaidRow = db.prepare(`
|
const unpaidCandidates = db.prepare(`
|
||||||
SELECT COALESCE(SUM(
|
SELECT b.due_day, b.billing_cycle, b.cycle_type, b.cycle_day,
|
||||||
CASE
|
CASE
|
||||||
WHEN m.actual_amount IS NOT NULL THEN m.actual_amount
|
WHEN m.actual_amount IS NOT NULL THEN m.actual_amount
|
||||||
ELSE COALESCE(b.expected_amount, 0)
|
ELSE COALESCE(b.expected_amount, 0)
|
||||||
END
|
END AS amount_cents
|
||||||
), 0) AS unpaid_total
|
|
||||||
FROM bills b
|
FROM bills b
|
||||||
LEFT JOIN monthly_bill_state m ON m.bill_id = b.id AND m.year = ? AND m.month = ?
|
LEFT JOIN monthly_bill_state m ON m.bill_id = b.id AND m.year = ? AND m.month = ?
|
||||||
LEFT JOIN (
|
LEFT JOIN (
|
||||||
|
|
@ -72,12 +73,15 @@ function buildBankTrackingSummary(db, userId, year, month) {
|
||||||
AND b.deleted_at IS NULL
|
AND b.deleted_at IS NULL
|
||||||
AND COALESCE(m.is_skipped, 0) = 0
|
AND COALESCE(m.is_skipped, 0) = 0
|
||||||
AND COALESCE(pay.paid_sum, 0) = 0
|
AND COALESCE(pay.paid_sum, 0) = 0
|
||||||
`).get(year, month, start, end, userId);
|
`).all(year, month, start, end, userId);
|
||||||
|
const unpaidTotalCents = unpaidCandidates
|
||||||
|
.filter(row => resolveDueDate(row, year, month))
|
||||||
|
.reduce((sum, row) => sum + (row.amount_cents || 0), 0);
|
||||||
|
|
||||||
const balanceDollars = money(account.balance / 100);
|
const balanceDollars = money(account.balance / 100);
|
||||||
const pendingDollars = fromCents(pendingRow.pending_total);
|
const pendingDollars = fromCents(pendingRow.pending_total);
|
||||||
const effectiveDollars = money(balanceDollars - pendingDollars);
|
const effectiveDollars = money(balanceDollars - pendingDollars);
|
||||||
const unpaidDollars = fromCents(unpaidRow.unpaid_total);
|
const unpaidDollars = fromCents(unpaidTotalCents);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
|
@ -244,6 +248,9 @@ function buildSummary(db, userId, year, month) {
|
||||||
b.name,
|
b.name,
|
||||||
b.expected_amount,
|
b.expected_amount,
|
||||||
b.due_day,
|
b.due_day,
|
||||||
|
b.billing_cycle,
|
||||||
|
b.cycle_type,
|
||||||
|
b.cycle_day,
|
||||||
c.name AS category_name,
|
c.name AS category_name,
|
||||||
m.actual_amount,
|
m.actual_amount,
|
||||||
m.is_skipped,
|
m.is_skipped,
|
||||||
|
|
@ -262,7 +269,11 @@ function buildSummary(db, userId, year, month) {
|
||||||
b.sort_order ASC,
|
b.sort_order ASC,
|
||||||
b.due_day ASC,
|
b.due_day ASC,
|
||||||
b.name 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 billIds = billRows.map(row => row.bill_id);
|
||||||
const paymentMap = new Map();
|
const paymentMap = new Map();
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,68 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Production-build smoke (QA_PLAN B15). Drives the REAL built artifact — `node
|
||||||
|
* server.js` serving dist/ — not the Vite dev server, so it validates that the
|
||||||
|
* split vendor chunks (QA-B0-01) actually load and the app boots in production.
|
||||||
|
*
|
||||||
|
* Usage: node scripts/prod-smoke.js (expects `npm run build` already run and a
|
||||||
|
* server already listening on PROD_SMOKE_URL — the shell wrapper handles both).
|
||||||
|
*/
|
||||||
|
const { chromium } = require('@playwright/test');
|
||||||
|
|
||||||
|
const URL = process.env.PROD_SMOKE_URL || 'http://localhost:3098';
|
||||||
|
const USER = process.env.E2E_USER || 'e2e_user';
|
||||||
|
const PASS = process.env.E2E_PASS || 'e2e_pass_1234';
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const browser = await chromium.launch();
|
||||||
|
const page = await browser.newPage();
|
||||||
|
const errors = [];
|
||||||
|
const failed = [];
|
||||||
|
page.on('console', (m) => { if (m.type() === 'error') errors.push(m.text()); });
|
||||||
|
page.on('pageerror', (e) => errors.push(String(e)));
|
||||||
|
page.on('requestfailed', (r) => failed.push(`${r.url()} ${r.failure()?.errorText || ''}`));
|
||||||
|
|
||||||
|
let ok = true;
|
||||||
|
const fail = (msg) => { ok = false; console.error(' ✖', msg); };
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. App shell + split chunks load; login page renders.
|
||||||
|
await page.goto(URL + '/login', { waitUntil: 'networkidle' });
|
||||||
|
const heading = page.getByRole('heading', { name: /sign in/i });
|
||||||
|
if (await heading.isVisible().catch(() => false)) console.log(' ✔ login page renders (vendor chunks loaded)');
|
||||||
|
else fail('login page did not render — a chunk may have failed to load');
|
||||||
|
|
||||||
|
// 2. Log in and reach the authenticated app (lazy route chunks load).
|
||||||
|
await page.locator('#username').fill(USER);
|
||||||
|
await page.locator('#password').fill(PASS);
|
||||||
|
await page.getByRole('button', { name: /sign in/i }).click();
|
||||||
|
await page.waitForURL((u) => !u.pathname.startsWith('/login'), { timeout: 15000 });
|
||||||
|
const gotIt = page.getByRole('button', { name: 'Got it' });
|
||||||
|
await gotIt.click({ timeout: 8000 }).catch(() => {});
|
||||||
|
if (await page.getByRole('button', { name: 'Add Bill' }).isVisible().catch(() => false)) {
|
||||||
|
console.log(' ✔ authenticated Tracker renders on the production build');
|
||||||
|
} else fail('authenticated Tracker did not render');
|
||||||
|
|
||||||
|
// 3. A couple of lazy-loaded routes render (their chunks resolve).
|
||||||
|
for (const path of ['/bills', '/analytics', '/spending']) {
|
||||||
|
await page.goto(URL + path, { waitUntil: 'networkidle' });
|
||||||
|
const empty = await page.locator('body').innerText().catch(() => '');
|
||||||
|
if (empty && empty.trim().length > 0) console.log(` ✔ ${path} rendered`);
|
||||||
|
else fail(`${path} rendered blank (lazy chunk failure?)`);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
fail('exception: ' + e.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
const chunkErrors = failed.filter((f) => /\.js|\.css|assets/.test(f));
|
||||||
|
if (chunkErrors.length) fail('failed asset requests:\n ' + chunkErrors.join('\n '));
|
||||||
|
// The pre-login page probes /api/auth/session and gets 401 by design (then shows
|
||||||
|
// login) — that's expected, not a defect. Everything else is a real error.
|
||||||
|
const benign = /session check failed|Not authenticated|auth\/session|status of 401/i;
|
||||||
|
const realErrors = errors.filter((e) => !benign.test(e));
|
||||||
|
if (realErrors.length) fail('console/page errors:\n ' + realErrors.slice(0, 5).join('\n '));
|
||||||
|
|
||||||
|
await browser.close();
|
||||||
|
console.log(ok ? '\nPROD SMOKE: PASS' : '\nPROD SMOKE: FAIL');
|
||||||
|
process.exit(ok ? 0 : 1);
|
||||||
|
})();
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# Production-build smoke (QA_PLAN B15): builds the app, boots `node server.js`
|
||||||
|
# serving dist/ against a scratch DB, and drives the real artifact with Playwright
|
||||||
|
# to confirm the split vendor chunks load and the app works in production.
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
|
PORT="${PROD_SMOKE_PORT:-3098}"
|
||||||
|
export PROD_SMOKE_URL="http://localhost:${PORT}"
|
||||||
|
|
||||||
|
echo "[prod-smoke] building…"
|
||||||
|
npm run build >/dev/null
|
||||||
|
|
||||||
|
echo "[prod-smoke] preparing scratch DB…"
|
||||||
|
node e2e/setup/prepare-db.js >/dev/null 2>&1
|
||||||
|
|
||||||
|
echo "[prod-smoke] starting production server on :${PORT}…"
|
||||||
|
DB_PATH="db/e2e.db" PORT="${PORT}" BIND_HOST=127.0.0.1 node server.js >/tmp/prod-smoke-server.log 2>&1 &
|
||||||
|
SRV=$!
|
||||||
|
trap 'kill "${SRV}" 2>/dev/null || true' EXIT
|
||||||
|
|
||||||
|
for _ in $(seq 1 40); do
|
||||||
|
curl -sf "http://localhost:${PORT}/api/version" >/dev/null 2>&1 && break
|
||||||
|
sleep 0.5
|
||||||
|
done
|
||||||
|
|
||||||
|
node scripts/prod-smoke.js
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -95,7 +95,8 @@ async function sendTestPush(user) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports._push = { sendNtfy, sendGotify, sendDiscord, sendTelegram, sendTestPush, sendPushToUser, encryptSecret };
|
// NOTE: the `_push` export is attached AFTER the final `module.exports = {…}`
|
||||||
|
// below — assigning it here would be clobbered by that reassignment (QA-B10-01).
|
||||||
|
|
||||||
// ── SMTP transport ────────────────────────────────────────────────────────────
|
// ── SMTP transport ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -546,3 +547,7 @@ async function runDriftNotifications() {
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { runNotifications, runDriftNotifications, sendTestEmail, createTransport };
|
module.exports = { runNotifications, runDriftNotifications, sendTestEmail, createTransport };
|
||||||
|
// Push helpers, exposed for the test-push route + tests. Assigned AFTER the line
|
||||||
|
// above so it isn't clobbered by the reassignment (QA-B10-01: previously set
|
||||||
|
// before it, leaving `_push` undefined → "Send test push" always 500'd).
|
||||||
|
module.exports._push = { sendNtfy, sendGotify, sendDiscord, sendTelegram, sendTestPush, sendPushToUser, encryptSecret };
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,73 @@
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const { test } = require('node:test');
|
||||||
|
const assert = require('node:assert');
|
||||||
|
const { toCents, fromCents, roundMoney, sumMoney, mulMoney, formatUSD, formatCentsUSD } = require('../utils/money');
|
||||||
|
|
||||||
|
test('toCents — unchanged for integer / ≤2-decimal / formatted inputs', () => {
|
||||||
|
assert.strictEqual(toCents(0), 0);
|
||||||
|
assert.strictEqual(toCents(85), 8500);
|
||||||
|
assert.strictEqual(toCents(19.99), 1999);
|
||||||
|
assert.strictEqual(toCents(100.001), 10000); // 3rd decimal <5 rounds down (10000.1)
|
||||||
|
assert.strictEqual(toCents('$1,234.56'), 123456);
|
||||||
|
assert.strictEqual(toCents('9,999,999.99'), 999999999);
|
||||||
|
assert.strictEqual(toCents(0.1), 10);
|
||||||
|
assert.strictEqual(toCents(0.1 + 0.2), 30); // 0.30000000000000004 → 30, not 30.000…
|
||||||
|
});
|
||||||
|
|
||||||
|
test('toCents — QA-B7-01: fractional half-cents round half away from zero (was buggy)', () => {
|
||||||
|
assert.strictEqual(toCents(1.005), 101); // was 100 with Math.round(n*100)
|
||||||
|
assert.strictEqual(toCents(2.675), 268); // was 267
|
||||||
|
assert.strictEqual(toCents(0.005), 1);
|
||||||
|
assert.strictEqual(toCents('1.005'), 101);
|
||||||
|
assert.strictEqual(toCents(1.004), 100);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('toCents — negatives and nullish/invalid', () => {
|
||||||
|
assert.strictEqual(toCents(-50), -5000);
|
||||||
|
assert.strictEqual(toCents('-12.34'), -1234);
|
||||||
|
assert.strictEqual(toCents(null), null);
|
||||||
|
assert.strictEqual(toCents(undefined), null);
|
||||||
|
assert.strictEqual(toCents(''), null);
|
||||||
|
assert.ok(Number.isNaN(toCents('abc')));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('toCents — round-trips through fromCents for money values', () => {
|
||||||
|
for (const v of [0, 85, 19.99, 1234.56, 0.01]) {
|
||||||
|
assert.strictEqual(fromCents(toCents(v)), v);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('fromCents', () => {
|
||||||
|
assert.strictEqual(fromCents(8500), 85);
|
||||||
|
assert.strictEqual(fromCents(null), null);
|
||||||
|
assert.strictEqual(fromCents(undefined), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sumMoney — cent-exact, no float drift', () => {
|
||||||
|
assert.strictEqual(sumMoney([0.1, 0.2]), 0.3);
|
||||||
|
assert.strictEqual(sumMoney([{ a: 1.11 }, { a: 2.22 }], (r) => r.a), 3.33);
|
||||||
|
assert.strictEqual(sumMoney([]), 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('mulMoney — rounds to the cent', () => {
|
||||||
|
assert.strictEqual(mulMoney(100, 0.1), 10);
|
||||||
|
assert.strictEqual(mulMoney(19.99, 2), 39.98);
|
||||||
|
assert.strictEqual(mulMoney('bad', 2), 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('roundMoney', () => {
|
||||||
|
assert.strictEqual(roundMoney(1.005), 1.01); // benefits from the toCents fix
|
||||||
|
assert.strictEqual(roundMoney(19.994), 19.99);
|
||||||
|
assert.strictEqual(roundMoney('abc'), 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('formatUSD / formatCentsUSD — negative sign before the symbol (QA-B6-01)', () => {
|
||||||
|
assert.strictEqual(formatUSD(50), '$50.00');
|
||||||
|
assert.strictEqual(formatUSD(-50), '-$50.00');
|
||||||
|
assert.strictEqual(formatUSD(-1234.5), '-$1,234.50');
|
||||||
|
assert.strictEqual(formatUSD(0), '$0.00');
|
||||||
|
assert.strictEqual(formatUSD(null), '$0.00');
|
||||||
|
assert.strictEqual(formatCentsUSD(123456), '$1,234.56');
|
||||||
|
assert.strictEqual(formatCentsUSD(-5000), '-$50.00');
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,108 @@
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// B10: push-notification DELIVERY — verifies the message-building + HTTP send path
|
||||||
|
// for each channel against a local sink (no external network), plus error handling,
|
||||||
|
// dispatch, and that the auth token never leaks into the message body.
|
||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const http = require('node:http');
|
||||||
|
|
||||||
|
const { _push } = require('../services/notificationService');
|
||||||
|
const { sendNtfy, sendGotify, sendDiscord, sendPushToUser } = _push;
|
||||||
|
|
||||||
|
// Local HTTP sink: records requests, status configurable.
|
||||||
|
function startSink() {
|
||||||
|
const requests = [];
|
||||||
|
let status = 200;
|
||||||
|
const server = http.createServer((req, res) => {
|
||||||
|
let body = '';
|
||||||
|
req.on('data', (c) => (body += c));
|
||||||
|
req.on('end', () => {
|
||||||
|
requests.push({ method: req.method, url: req.url, headers: req.headers, body });
|
||||||
|
res.statusCode = status;
|
||||||
|
res.end(status === 200 ? 'ok' : 'err');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
server.listen(0, '127.0.0.1', () => {
|
||||||
|
resolve({
|
||||||
|
url: `http://127.0.0.1:${server.address().port}`,
|
||||||
|
requests,
|
||||||
|
setStatus: (c) => { status = c; },
|
||||||
|
close: () => new Promise((r) => server.close(r)),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('ntfy delivery: posts body + Title header; token stays in Authorization, not the body', async () => {
|
||||||
|
const sink = await startSink();
|
||||||
|
try {
|
||||||
|
await sendNtfy(sink.url, 'secret-token-xyz', 'Bill due', 'Electric $85 due tomorrow', 'overdue');
|
||||||
|
assert.equal(sink.requests.length, 1);
|
||||||
|
const r = sink.requests[0];
|
||||||
|
assert.equal(r.method, 'POST');
|
||||||
|
assert.equal(r.body, 'Electric $85 due tomorrow');
|
||||||
|
assert.equal(r.headers['title'], 'Bill due');
|
||||||
|
assert.ok(r.headers['priority'], 'priority header present');
|
||||||
|
assert.equal(r.headers['authorization'], 'Bearer secret-token-xyz');
|
||||||
|
assert.ok(!r.body.includes('secret-token-xyz'), 'token must never appear in the message body');
|
||||||
|
} finally { await sink.close(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
test('gotify delivery: JSON {title, message, priority} to /message?token=', async () => {
|
||||||
|
const sink = await startSink();
|
||||||
|
try {
|
||||||
|
await sendGotify(sink.url, 'gtoken', 'Reminder', 'Rent due in 3 days', 'today');
|
||||||
|
const r = sink.requests[0];
|
||||||
|
assert.match(r.url, /\/message\?token=gtoken/);
|
||||||
|
const json = JSON.parse(r.body);
|
||||||
|
assert.equal(json.title, 'Reminder');
|
||||||
|
assert.equal(json.message, 'Rent due in 3 days');
|
||||||
|
assert.equal(json.priority, 7); // 'today'
|
||||||
|
} finally { await sink.close(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
test('discord delivery: webhook embed with title/description', async () => {
|
||||||
|
const sink = await startSink();
|
||||||
|
try {
|
||||||
|
await sendDiscord(sink.url, 'Overdue bill', 'Water bill is 2 days overdue', 'overdue');
|
||||||
|
const json = JSON.parse(sink.requests[0].body);
|
||||||
|
assert.equal(json.embeds[0].title, 'Overdue bill');
|
||||||
|
assert.equal(json.embeds[0].description, 'Water bill is 2 days overdue');
|
||||||
|
assert.ok(typeof json.embeds[0].color === 'number');
|
||||||
|
} finally { await sink.close(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dispatch: sendPushToUser routes to the configured channel', async () => {
|
||||||
|
const sink = await startSink();
|
||||||
|
try {
|
||||||
|
const user = { notify_push_enabled: 1, push_channel: 'ntfy', push_url: sink.url, push_token: '' };
|
||||||
|
await sendPushToUser(user, 'Title', 'Body', 'soon');
|
||||||
|
assert.equal(sink.requests.length, 1);
|
||||||
|
assert.equal(sink.requests[0].body, 'Body');
|
||||||
|
} finally { await sink.close(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dispatch: disabled/unconfigured user sends nothing (no throw)', async () => {
|
||||||
|
await assert.doesNotReject(sendPushToUser({ notify_push_enabled: 0 }, 'T', 'B', 'soon'));
|
||||||
|
await assert.doesNotReject(sendPushToUser({ notify_push_enabled: 1, push_channel: 'ntfy' }, 'T', 'B', 'soon'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('error handling: an unreachable/erroring channel throws a clear, channel-named error', async () => {
|
||||||
|
const sink = await startSink();
|
||||||
|
sink.setStatus(500);
|
||||||
|
try {
|
||||||
|
await assert.rejects(
|
||||||
|
sendNtfy(sink.url, '', 'T', 'B', 'soon'),
|
||||||
|
/ntfy returned 500/,
|
||||||
|
);
|
||||||
|
} finally { await sink.close(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dispatch: an unknown channel throws instead of silently doing nothing', async () => {
|
||||||
|
await assert.rejects(
|
||||||
|
sendPushToUser({ notify_push_enabled: 1, push_channel: 'carrier-pigeon', push_url: 'http://x' }, 'T', 'B', 'soon'),
|
||||||
|
/Unknown push channel: carrier-pigeon/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,69 @@
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// QA-B5-02 regression: the SimpleFIN bank-tracking `unpaid_this_month` metric must
|
||||||
|
// only count bills that actually occur in the requested month — annual / off-month
|
||||||
|
// quarterly bills should not inflate it (same occurrence gate as Tracker/Summary).
|
||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const os = require('node:os');
|
||||||
|
const path = require('node:path');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
|
||||||
|
const dbPath = path.join(os.tmpdir(), `bill-tracker-summary-bt-test-${process.pid}.sqlite`);
|
||||||
|
process.env.DB_PATH = dbPath;
|
||||||
|
|
||||||
|
const { getDb, closeDb } = require('../db/database');
|
||||||
|
|
||||||
|
function callSummary(userId, year, month) {
|
||||||
|
const router = require('../routes/summary');
|
||||||
|
const layer = router.stack.find(item => item.route?.path === '/' && item.route.methods.get);
|
||||||
|
assert.ok(layer, 'GET /api/summary route should exist');
|
||||||
|
const handler = layer.route.stack[0].handle;
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const req = { query: { year: String(year), month: String(month) }, user: { id: userId, role: 'user' } };
|
||||||
|
const res = {
|
||||||
|
statusCode: 200,
|
||||||
|
status(code) { this.statusCode = code; return this; },
|
||||||
|
json(data) { resolve({ status: this.statusCode, data }); },
|
||||||
|
};
|
||||||
|
handler(req, res);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('bank-tracking unpaid_this_month excludes bills not due this month (QA-B5-02)', async () => {
|
||||||
|
const db = getDb();
|
||||||
|
const userId = db.prepare(
|
||||||
|
"INSERT INTO users (username, password_hash, role, active) VALUES ('bt-user', 'x', 'user', 1)",
|
||||||
|
).run().lastInsertRowid;
|
||||||
|
|
||||||
|
const insertBill = db.prepare(`
|
||||||
|
INSERT INTO bills (user_id, name, due_day, billing_cycle, cycle_type, cycle_day, expected_amount, active)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, 1)
|
||||||
|
`);
|
||||||
|
// Due every June (query month): counts.
|
||||||
|
insertBill.run(userId, 'Monthly Bill', 15, 'monthly', 'monthly', null, 10000); // $100
|
||||||
|
// Annual, anchored to January: NOT due in June — must be excluded.
|
||||||
|
insertBill.run(userId, 'Annual Bill', 1, 'annually', 'annual', '1', 50000); // $500
|
||||||
|
|
||||||
|
// Enable bank tracking pointed at a financial account with a balance.
|
||||||
|
const acctId = db.prepare(`
|
||||||
|
INSERT INTO financial_accounts (user_id, name, org_name, account_type, balance, available_balance, monitored)
|
||||||
|
VALUES (?, 'Checking', 'Test Bank', 'checking', 200000, 200000, 1)
|
||||||
|
`).run(userId).lastInsertRowid;
|
||||||
|
const setSetting = db.prepare('INSERT INTO user_settings (user_id, key, value) VALUES (?, ?, ?)');
|
||||||
|
setSetting.run(userId, 'bank_tracking_enabled', 'true');
|
||||||
|
setSetting.run(userId, 'bank_tracking_account_id', String(acctId));
|
||||||
|
|
||||||
|
const { data } = await callSummary(userId, 2026, 6); // June 2026
|
||||||
|
|
||||||
|
assert.ok(data.bank_tracking, 'bank_tracking summary should be present when enabled');
|
||||||
|
// Only the $100 monthly bill is due in June; the $500 annual bill (Jan) is excluded.
|
||||||
|
assert.equal(data.bank_tracking.unpaid_this_month, 100);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.after(() => {
|
||||||
|
closeDb();
|
||||||
|
for (const suffix of ['', '-wal', '-shm']) {
|
||||||
|
try { fs.rmSync(dbPath + suffix); } catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,72 @@
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// B2/B5: the Summary must honour per-month monthly_bill_state modifiers — skipped
|
||||||
|
// bills excluded from the total, per-month amount overrides applied — alongside the
|
||||||
|
// occurrence gate added for QA-B5-01. Guards against regressions in either.
|
||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const os = require('node:os');
|
||||||
|
const path = require('node:path');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
|
||||||
|
const dbPath = path.join(os.tmpdir(), `bill-tracker-summary-skip-test-${process.pid}.sqlite`);
|
||||||
|
process.env.DB_PATH = dbPath;
|
||||||
|
|
||||||
|
const { getDb, closeDb } = require('../db/database');
|
||||||
|
|
||||||
|
function callSummary(userId, year, month) {
|
||||||
|
const router = require('../routes/summary');
|
||||||
|
const layer = router.stack.find(item => item.route?.path === '/' && item.route.methods.get);
|
||||||
|
const handler = layer.route.stack[0].handle;
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const req = { query: { year: String(year), month: String(month) }, user: { id: userId, role: 'user' } };
|
||||||
|
const res = {
|
||||||
|
statusCode: 200,
|
||||||
|
status(c) { this.statusCode = c; return this; },
|
||||||
|
json(data) { resolve({ status: this.statusCode, data }); },
|
||||||
|
};
|
||||||
|
handler(req, res);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('summary honours skip (exclude) and per-month amount override', async () => {
|
||||||
|
const db = getDb();
|
||||||
|
const userId = db.prepare(
|
||||||
|
"INSERT INTO users (username, password_hash, role, active) VALUES ('skip-user', 'x', 'user', 1)",
|
||||||
|
).run().lastInsertRowid;
|
||||||
|
|
||||||
|
const insertBill = db.prepare(`
|
||||||
|
INSERT INTO bills (user_id, name, due_day, billing_cycle, cycle_type, expected_amount, active)
|
||||||
|
VALUES (?, ?, ?, 'monthly', 'monthly', ?, 1)
|
||||||
|
`);
|
||||||
|
const a = insertBill.run(userId, 'Bill A', 5, 10000).lastInsertRowid; // $100
|
||||||
|
const b = insertBill.run(userId, 'Bill B', 10, 20000).lastInsertRowid; // $200, skipped
|
||||||
|
const c = insertBill.run(userId, 'Bill C', 15, 30000).lastInsertRowid; // $300, overridden to $50
|
||||||
|
|
||||||
|
const setState = db.prepare(
|
||||||
|
'INSERT INTO monthly_bill_state (bill_id, year, month, actual_amount, is_skipped) VALUES (?, 2026, 6, ?, ?)',
|
||||||
|
);
|
||||||
|
setState.run(b, null, 1); // skip B
|
||||||
|
setState.run(c, 5000, 0); // override C → $50
|
||||||
|
|
||||||
|
const { data } = await callSummary(userId, 2026, 6);
|
||||||
|
const find = (id) => data.expenses.find((e) => e.bill_id === id);
|
||||||
|
|
||||||
|
assert.equal(find(a).display_amount, 100, 'Bill A shows base amount');
|
||||||
|
assert.equal(find(b).is_skipped, true, 'Bill B is flagged skipped');
|
||||||
|
assert.equal(find(c).display_amount, 50, 'Bill C shows the per-month override');
|
||||||
|
assert.equal(find(c).actual_amount, 50, 'Bill C override surfaced as actual_amount');
|
||||||
|
|
||||||
|
// Counted total excludes the skipped bill and uses the override: 100 + 50 = 150.
|
||||||
|
const countedTotal = data.expenses
|
||||||
|
.filter((e) => !e.is_skipped)
|
||||||
|
.reduce((sum, e) => sum + e.display_amount, 0);
|
||||||
|
assert.equal(countedTotal, 150, 'monthly obligation excludes skipped, applies override');
|
||||||
|
});
|
||||||
|
|
||||||
|
test.after(() => {
|
||||||
|
closeDb();
|
||||||
|
for (const suffix of ['', '-wal', '-shm']) {
|
||||||
|
try { fs.rmSync(dbPath + suffix); } catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
@ -19,14 +19,29 @@
|
||||||
/**
|
/**
|
||||||
* Dollars (number or string like "$1,234.56") → integer cents.
|
* Dollars (number or string like "$1,234.56") → integer cents.
|
||||||
* null/undefined/'' → null. Unparseable input → NaN (caller validates).
|
* null/undefined/'' → null. Unparseable input → NaN (caller validates).
|
||||||
|
*
|
||||||
|
* Rounds off the decimal string (via the shortest round-trip representation for
|
||||||
|
* numbers) rather than `Math.round(n * 100)`, whose binary-float error rounds
|
||||||
|
* e.g. 1.005 down to 100 instead of 101 (QA-B7-01). Output is identical to the
|
||||||
|
* old helper for all integer / ≤2-decimal / "$1,234.56" inputs; only 3+-decimal
|
||||||
|
* half-cent values change, and they now round half away from zero.
|
||||||
*/
|
*/
|
||||||
function toCents(dollars) {
|
function toCents(dollars) {
|
||||||
if (dollars === null || dollars === undefined || dollars === '') return null;
|
if (dollars === null || dollars === undefined || dollars === '') return null;
|
||||||
const n = typeof dollars === 'string'
|
const cleaned = typeof dollars === 'string' ? dollars.replace(/[$,\s]/g, '') : dollars;
|
||||||
? Number(dollars.replace(/[$,\s]/g, ''))
|
const n = Number(cleaned);
|
||||||
: Number(dollars);
|
|
||||||
if (!Number.isFinite(n)) return NaN;
|
if (!Number.isFinite(n)) return NaN;
|
||||||
return Math.round(n * 100);
|
|
||||||
|
// The shortest decimal string that round-trips to this float (e.g. 1.005 for
|
||||||
|
// the number 1.005, not 1.00499999…). Scientific notation → float fallback.
|
||||||
|
const decimal = typeof cleaned === 'string' ? cleaned : n.toString();
|
||||||
|
if (/[eE]/.test(decimal)) return Math.round(n * 100);
|
||||||
|
|
||||||
|
const negative = decimal.trim().startsWith('-');
|
||||||
|
const [intPart, fracPart = ''] = decimal.replace('-', '').split('.');
|
||||||
|
const frac3 = (fracPart + '000').slice(0, 3);
|
||||||
|
const cents = Number(intPart || '0') * 100 + Number(frac3.slice(0, 2)) + (Number(frac3[2]) >= 5 ? 1 : 0);
|
||||||
|
return negative ? -cents : cents;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Integer cents → dollar number (for API payloads). null/undefined → null. */
|
/** Integer cents → dollar number (for API payloads). null/undefined → null. */
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue