Compare commits

...

10 Commits

Author SHA1 Message Date
null 995f635d35 refactor(tracker): consolidate isPaidStatus + rowOutstanding + toast gap (T5)
Added a single isPaidStatus(status) (+ PAID_STATUSES) to statusService and a
matching client helper in trackerUtils, routing the unambiguous settled-status
checks through it (trackerService, StatusBadge, CalendarPage, rowIsPaid). The
intentionally paid-only counts stay distinct. Replaced two inline
Math.max(r.balance||0,0) with rowOutstanding, and gave the Tracker settings
load a quiet toast instead of a silent swallow. Behavior-preserving.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 18:36:30 -05:00
null d92cc38116 fix(bill-modal): correctness + toast fallbacks + validator consolidation (BM1)
- handleBlur now takes the field value explicitly (was positional guessing that
  fell through to interestRate for unmapped fields).
- Three copy-pasted money validators -> one shared validateNonNegativeMoney in
  client/lib/money.js; expected-amount copy 'positive' -> 'non-negative' (0 ok).
- Removed the save action's duplicate due-day/interest-rate re-validation
  (validateForm already covers it); kept the parses.
- Added err.message fallbacks to save/deactivate/verify-autopay toasts.
- Save toasts now name the bill.

Test: client/lib/money.test.js.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 18:32:25 -05:00
null e9c5e4d1d3 test(tracker): cover paymentAccounting + analytics money services (X2)
Added tests for two untested money-critical services:
- paymentAccountingService: bank-backed predicate, accounting-active SQL, and
  the manual-vs-bank override invariant (bank overrides provisional manual so
  it isn't double-counted; balance restored; manual reactivates + re-applies
  balance when the override is removed).
- analyticsService: month-window math (year-boundary + leap edges) and
  validateSummaryQuery defaults/range errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 18:28:12 -05:00
null 9f4b53d37a perf(tracker): batch getTracker per-bill queries (kill N+1) (P1)
getTracker ran a payments query per bill plus computeAmountSuggestion per bill
(up to 12 queries each: 6 months x 2) inside bills.map — ~70-450 queries for a
35-bill account per load. Now one query fetches all cycle payments (grouped in
JS per bill range) and two compute all amount suggestions
(computeAmountSuggestionsBatch). Behavior-preserving.

Test: tests/amountSuggestionService.test.js pins batch == per-bill output.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 18:25:55 -05:00
null 73631ab812 fix(tracker): route error handling + autopay write atomicity (T2)
routes/tracker.js had no try/catch and returned a plain {error} shape; the
three GET handlers now wrap in try/catch + standardizeError (including the
invalid-month path). applyAutopaySuggestions ran INSERT + applyBalanceDelta
as two un-transactional steps on a GET — wrapped both in one db.transaction.

Tests: autopay creates one payment + drops balance (idempotent), route
returns standardized error.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 18:21:28 -05:00
null c91c97ef41 feat(tracker): live cross-query invalidation for the app shell (X3)
The app never called invalidateQueries; a Tracker mutation only refetch()'d
the one tracker query. So the sidebar overdue badge (['overdue-count'],
2-min staleTime), drift report, and bills list stayed stale after pay/skip/
edit — you could clear your last overdue bill and still see '3' for minutes.
Added useInvalidateTrackerData() (tracker + overdue-count + drift-report +
bills) and wired it into rows, BillModal.onSave, bank-sync, reorder, and the
payment/late-attribution handlers.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 18:19:30 -05:00
null 10e159352a fix(notifications): honor per-bill reminder_days_before + expose for all bills (BM3)
The notifier used a hard-coded 3-day early reminder and never read
reminder_days_before, so the modal's 'Reminder Days' control was a no-op.
The early reminder now fires at the bill's own lead (>= 2 days so it never
collides with the 1-day/same-day reminders); email subject+body say 'due in
N days'. Lead-time selection extracted to a pure exported reminderTypeFor()
for unit testing. The Reminder Days control now shows for every bill and a
non-subscription save no longer clobbers the column to 3.

Test: tests/notificationLeadTime.test.js

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 18:16:10 -05:00
null ad1f5bebf6 fix(bill-modal): SimpleFIN import refreshes payments + Tracker live (BM4)
The Sync button and merchant-rule historical import both CREATE payments but
only reloaded linked transactions, so the modal's Payment History stayed stale
and the Tracker row behind the modal didn't update (kept showing due/overdue)
until close+reopen. Both now await Promise.all([loadPayments(),
loadLinkedTransactions()]) then onSave?.(), matching the unmatch handlers.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 18:11:28 -05:00
null 4a38cc8614 fix(tracker): gate bank card unpaid/remaining by occurrence + add trackerService tests (T1)
buildBankTracking summed expected_amount for all active unpaid bills with no
resolveDueDate gate, so annual/off-month bills inflated unpaid_this_month and
the bank remaining (same class as QA-B5-02, live on the bank path). getTracker
now derives the unpaid total from the already-gated rows (netting partials) and
passes it in. summary.remaining/total_remaining now use the bank card's own
remaining in bank mode (agreeing with safe-to-spend), and a stray balance/100
is now fromCents.

New tests/trackerService.test.js: gating fix, summary totals, bank-mode
remaining agreement, cents<->dollars, getOverdueCount gating.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 18:10:28 -05:00
null d689ff6e68 fix(tracker): quick-pay duplicate guard + atomic balance write (X1)
POST /payments/quick had no dedupe and non-atomic INSERT+applyBalanceDelta,
unlike /payments/bulk. A double-click/retry/two-tab pay created a second
payment and dropped the balance twice; a mid-way failure left a payment with
no balance adjustment. Now checks the bill_id+paid_date+amount composite key
(idempotent 200) and wraps the write in db.transaction.

Test: tests/paymentsQuickRoute.test.js

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 18:05:19 -05:00
21 changed files with 998 additions and 176 deletions

View File

@ -1,6 +1,28 @@
# Bill Tracker — Changelog
## v0.41.0
### 🧪 Money-service test coverage
- **[Tests] Covered two untested money-critical services** — the manual-vs-bank payment source-of-truth (`paymentAccountingService`) and the analytics month-window math (`analyticsService`) had no dedicated tests. Added `tests/paymentAccountingService.test.js` (bank-backed predicate, the accounting-active SQL fragment, and the core invariant: a bank payment overrides a provisional manual payment so it isn't double-counted, restores the balance, and the manual payment counts again with its balance re-applied when the override is removed) and `tests/analyticsService.test.js` (month key/label/addMonths/monthEndDate/buildMonths with year-boundary + leap-year edges, and `validateSummaryQuery` defaults/range errors). (Tracker X2)
### ⚡ Tracker performance
- **[Tracker] Killed the getTracker N+1 (was ~23 DB round-trips × N bills every home-page load)** — inside `bills.map`, `getTracker` ran a payments query per bill (`fetchPaymentsForBillCycle`) plus `computeAmountSuggestion` per bill, and the suggestion alone fired up to 12 queries per bill (6 months × 2) — roughly 70450 queries for a 35-bill account on every Tracker load. Now one query fetches all bills' cycle payments (grouped in JS by each bill's own range) and two queries compute all amount suggestions (`computeAmountSuggestionsBatch`), replacing the per-bill loops. Behavior-preserving — `tests/amountSuggestionService.test.js` pins the batched suggestion to be byte-identical to the per-bill function, and the `trackerService` tests still pass unchanged. (Tracker P1)
### 🧹 Tracker cleanup
- **[Tracker] Consolidated the "paid or autodraft = done" check + tidied a few spots** — the settled-status test (`status === 'paid' || status === 'autodraft'`) was copy-pasted across the server and client; added a single `isPaidStatus(status)` (+ `PAID_STATUSES`) to `services/statusService.js` and a matching `isPaidStatus` to `client/lib/trackerUtils.js`, and routed the unambiguous call sites through it (`trackerService`, `StatusBadge`, `CalendarPage`, and `rowIsPaid`) — the intentionally paid-*only* counts (`count_paid`, `count_autodraft`) are left distinct. Also replaced two inline `Math.max(r.balance || 0, 0)` sums in `getTracker` with the existing `rowOutstanding` helper, and gave the Tracker's display-settings load a quiet toast on failure instead of a silent swallow. Behavior-preserving; full server + client suites green. (Tracker T5)
### 🐛 Tracker & bill-modal hardening
- **[Bill modal] Correctness + error/toast + validator cleanup** — several small fixes in `BillModal`: `handleBlur` used positional guessing that defaulted every unmapped field to `interestRate`'s value (latent, masked by inline validators) — now takes the field value explicitly; the three copy-pasted money validators collapsed into one shared `validateNonNegativeMoney(val, label)` in `client/lib/money.js` (the expected-amount message also went from "positive number" to "non-negative", since 0 is allowed); the save action's duplicate due-day/interest-rate re-validation (which re-checked with toasts what `validateForm` already field-validated) was removed; the save/deactivate/verify-autopay `toast.error(err.message)` calls got fallbacks so a missing message never shows "undefined"; and the save toasts now name the bill ("Rent added" / "Rent updated"). Tests: `client/lib/money.test.js` covers the shared validator. (Tracker BM1)
- **[Tracker] Route error handling + autopay write atomicity** — `routes/tracker.js` had no try/catch and returned a plain `{error}` shape unlike the rest of the API; its three GET handlers now wrap in try/catch and return `standardizeError` (`{error, message, field, code}`), including the invalid-month validation path. Separately, `applyAutopaySuggestions` (which runs on a `GET /tracker`) inserted the auto-mark-paid payment and applied the balance delta as two un-transactional steps — a mid-way failure could leave a payment without its balance adjustment. Wrapped the INSERT + `applyBalanceDelta` in a single `db.transaction`. Covered by new cases in `tests/trackerService.test.js` (auto-mark creates one payment + drops the balance, idempotent on re-load; route returns the standardized error). (Tracker T2)
- **[Tracker] Sidebar overdue badge (and drift/bills) went stale after row actions** — the app never called `queryClient.invalidateQueries`; a Tracker mutation only `refetch()`'d the single tracker query passed down as `refresh`. So after paying/skipping/editing a bill, the sidebar **overdue badge** (`['overdue-count']`, 2-minute staleTime) kept its old number for up to two minutes — you could clear your last overdue bill and still see "3" — and the drift report / bills list didn't update either. Added a `useInvalidateTrackerData()` hook (invalidates `tracker` + `overdue-count` + `drift-report` + `bills`) and wired it in place of the bare `refetch` handed to the rows, `BillModal.onSave`, bank-sync, reorder, and the payment/late-attribution handlers, so the whole shell updates live. (Tracker X3)
- **[Notifications] "Reminder days before" was a dead setting — the notifier ignored it** — every bill has a `reminder_days_before` column (default 3) and the bill modal exposed a "Reminder Days" control for subscriptions, but `services/notificationService.js` used a hard-coded schedule (early reminder always at exactly 3 days out) and never read the column. A user who set "remind me 7 days before" still only got the fixed 3-day/1-day/today reminders. The early reminder now fires at the bill's own `reminder_days_before` lead (only when ≥ 2 days, so it never collides with the 1-day/same-day reminders), and the email subject + body say "due in N days" using that value. The lead-time selection was pulled into a pure, exported `reminderTypeFor(bill, diffDays)` so it's unit-tested directly (`tests/notificationLeadTime.test.js`) — default 3 stays backwards-compatible. The **"Reminder Days Before" control now shows for every bill** (not just subscriptions), and saving a non-subscription bill no longer clobbers the column back to 3. (Tracker BM3)
- **[Bill modal/SimpleFIN] Importing bank payments didn't refresh the payment list or the Tracker** — the two flows in the bill modal that *create* payments — **Sync** (`syncBillSimplefinPayments`) and a **merchant-rule historical import** (`onRulesChanged` → `importHistoricalPayments`) — only reloaded the linked-transactions list, unlike the unmatch handlers which correctly reload payments *and* linked transactions *and* call `onSave`. So after importing, say, 3 payments from bank history, the modal's Payment History stayed stale and the Tracker row behind it kept showing "due/overdue" even though the bill was now covered — until you closed and reopened. Both paths now `await Promise.all([loadPayments(), loadLinkedTransactions()])` then `onSave?.()`, matching the unmatch pattern, so imported payments appear immediately and the Tracker updates live. (The SimpleFIN *search/preview/candidate* flow was already correct.) (Tracker BM4)
- **[Tracker/SimpleFIN] Bank card's "unpaid this month" and "remaining" over-counted off-month bills** — `buildBankTracking` (`services/trackerService.js`) summed `expected_amount` for *all* active unpaid bills via SQL with no occurrence gate, so an annual or off-month quarterly bill inflated `unpaid_this_month` (and therefore the bank `remaining`) even though the Tracker rows beside it correctly excluded it — the same class of bug as QA-B5-02, still live on the bank path. `getTracker` now derives the unpaid total from the already-gated rows (via `resolveDueDate`), netting partial payments, and passes it into `buildBankTracking`. Also made `summary.remaining` / `total_remaining` use the bank card's own remaining when bank tracking is on (they previously used manual starting-amount math even in bank mode, disagreeing with safe-to-spend), and switched a stray `balance / 100` to `fromCents`. New test file `tests/trackerService.test.js` covers the gating fix, summary totals, the bank-mode remaining agreement, cents↔dollars integrity, and `getOverdueCount` gating — the dense Tracker aggregation had no dedicated tests before. (Tracker T1)
- **[Payments] Quick-pay could create duplicate payments and double-drop the balance** — `POST /api/payments/quick` (the one-click "pay" behind every Tracker row) had **no duplicate guard** and its INSERT + balance update weren't atomic, unlike `POST /api/payments/bulk`. A double-click, a retry, or two open tabs made a *second* payment for the same bill/date/amount and applied the balance drop twice; a failure between the INSERT and the balance write left a payment with no balance adjustment. Quick-pay now checks the same `bill_id + paid_date + amount` composite key (returning the existing payment idempotently, HTTP 200) and wraps the INSERT + `applyBalanceDelta` in a single `db.transaction`. A different amount on the same day is still a legitimate new payment. Test: `tests/paymentsQuickRoute.test.js`. (Tracker X1)
### ✨ Data page overhaul
- **[Data] Redesigned the Data page (modern, goal-oriented, 2026)** — replaced the dense tabbed/collapsible-card layout with a settings-style **two-pane** shell: a sticky goal-nav (Bank sync · Transactions · Import · Export & backups) beside one active pane, collapsing to a segmented control on mobile. Adds a **connection hero** with five distinct states (loading / server-disabled / error+retry / not-connected / connected±needs-attention) so a network blip is never mistaken for "not connected" and a server without SimpleFIN never shows a dead Connect button; **`?section=` deep-linking** (URL source of truth, back-button friendly, migrates the old tab key); plain-language titles + icons; a live **"N to review"** badge and **health dot** on the nav; at-a-glance transaction count + "syncs automatically" reassurance; **lazy-loaded** import panes; reduced-motion-aware transitions; and command-palette section links. `/data` is now covered by the authed axe sweep (zero critical/serious). Shell only — every section component (and the SimpleFIN sync buttons) is reused unchanged. `SectionCard` chrome modernized (icon chip, sentence-case titles).

View File

@ -1,6 +1,6 @@
import { useActionState, useEffect, useState } from 'react';
import { ChevronDown, Copy, Layers, Link2, Link2Off, Loader2, Pencil, Plus, RefreshCw, Trash2 } from 'lucide-react';
import { formatCentsUSD } from '@/lib/money';
import { formatCentsUSD, validateNonNegativeMoney } from '@/lib/money';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
@ -248,12 +248,10 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa
return '';
};
const validateExpectedAmount = (val) => {
if (val === '' || val === null) return '';
const num = parseFloat(val);
if (isNaN(num) || num < 0) return 'Amount must be a positive number';
return '';
};
// Money fields share one non-negative validator (blank allowed, 0 allowed).
const validateExpectedAmount = (val) => validateNonNegativeMoney(val, 'Amount');
const validateCurrentBalance = (val) => validateNonNegativeMoney(val, 'Balance');
const validateMinimumPayment = (val) => validateNonNegativeMoney(val, 'Min payment');
const validateInterestRate = (val) => {
if (val === '' || val === null) return '';
@ -263,20 +261,6 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa
return '';
};
const validateCurrentBalance = (val) => {
if (val === '' || val === null) return '';
const num = parseFloat(val);
if (isNaN(num) || num < 0) return 'Balance must be a non-negative number';
return '';
};
const validateMinimumPayment = (val) => {
if (val === '' || val === null) return '';
const num = parseFloat(val);
if (isNaN(num) || num < 0) return 'Min payment must be a non-negative number';
return '';
};
const validateForm = () => {
const newErrors = {
name: validateName(name),
@ -290,13 +274,11 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa
return Object.values(newErrors).every(err => err === '');
};
const handleBlur = (field, validator) => {
setErrors(prev => ({ ...prev, [field]: validator(
field === 'name' ? name :
field === 'dueDay' ? dueDay :
field === 'expectedAmount' ? expectedAmount :
interestRate
)}));
// Value passed explicitly so this never falls through to the wrong field's
// state (the old positional guessing defaulted every unmapped field to
// interestRate).
const handleBlur = (field, value, validator) => {
setErrors(prev => ({ ...prev, [field]: validator(value) }));
};
const handleCategoryChange = (val) => {
@ -325,7 +307,7 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa
setLocalVerifiedAt(new Date(res.autopay_verified_at));
toast.success('Autopay marked as verified.');
} catch (err) {
toast.error(err.message);
toast.error(err.message || 'Failed to verify autopay.');
}
}
@ -530,18 +512,11 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa
return;
}
// validateForm() already enforced due-day (131) and interest-rate (0100)
// ranges and blocked the save with field errors, so these are just parses.
const parsedDueDay = Number(dueDay);
if (!Number.isInteger(parsedDueDay) || parsedDueDay < 1 || parsedDueDay > 31) {
toast.error('Due day must be a whole number from 1 to 31.');
return;
}
const trimmedInterestRate = interestRate.trim();
const parsedInterestRate = trimmedInterestRate === '' ? null : Number(trimmedInterestRate);
if (parsedInterestRate !== null && (!Number.isFinite(parsedInterestRate) || parsedInterestRate < 0 || parsedInterestRate > 100)) {
toast.error('Interest rate must be blank or a number from 0 to 100.');
return;
}
const data = {
source_bill_id: sourceBill?.source_bill_id,
@ -559,7 +534,7 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa
auto_mark_paid: canAutoMarkPaid && autoMarkPaid,
is_subscription: isSubscription,
subscription_type: isSubscription ? subscriptionType : null,
reminder_days_before: isSubscription ? parseInt(reminderDaysBefore || '3', 10) : 3,
reminder_days_before: parseInt(reminderDaysBefore || '3', 10),
subscription_source: sourceBill?.subscription_source || 'manual',
subscription_detected_at: sourceBill?.subscription_detected_at,
has_2fa: has2fa,
@ -582,10 +557,10 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa
} else {
savedBill = await api.createBill(data);
}
toast.success('Bill added');
toast.success(`${data.name} added`);
} else {
savedBill = await api.updateBill(bill.id, data);
toast.success('Bill updated');
toast.success(`${data.name} updated`);
}
if (saveTemplate) {
const safeTemplateName = templateName.trim() || data.name;
@ -595,7 +570,7 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa
onSave(savedBill);
setDialogOpen(false);
} catch (err) {
toast.error(err.message);
toast.error(err.message || 'Failed to save bill.');
}
}, null);
@ -609,7 +584,7 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa
onSave?.();
setDialogOpen(false);
} catch (err) {
toast.error(err.message);
toast.error(err.message || 'Failed to update bill.');
}
}
@ -638,7 +613,7 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa
setName(e.target.value);
setTimeout(() => setErrors(prev => ({ ...prev, name: validateName(e.target.value) })), 300);
}}
onBlur={() => handleBlur('name', validateName)}
onBlur={() => handleBlur('name', name, validateName)}
required
/>
{errors.name && (
@ -673,7 +648,7 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa
setDueDay(e.target.value);
setTimeout(() => setErrors(prev => ({ ...prev, dueDay: validateDueDay(e.target.value) })), 300);
}}
onBlur={() => handleBlur('dueDay', validateDueDay)}
onBlur={() => handleBlur('dueDay', dueDay, validateDueDay)}
/>
{errors.dueDay && (
<span className="text-[10px] text-red-500 font-medium">{errors.dueDay}</span>
@ -694,7 +669,7 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa
setExpected(e.target.value);
setTimeout(() => setErrors(prev => ({ ...prev, expectedAmount: validateExpectedAmount(e.target.value) })), 300);
}}
onBlur={() => handleBlur('expectedAmount', validateExpectedAmount)}
onBlur={() => handleBlur('expectedAmount', expectedAmount, validateExpectedAmount)}
/>
{errors.expectedAmount && (
<span className="text-[10px] text-red-500 font-medium">{errors.expectedAmount}</span>
@ -788,8 +763,8 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa
</label>
{isSubscription && (
<div className="mt-3 grid gap-3 border-t border-border/40 pt-3 sm:grid-cols-2">
<div className="space-y-1.5">
<div className="mt-3 border-t border-border/40 pt-3">
<div className="space-y-1.5 sm:max-w-[50%]">
<Label className="text-xs uppercase tracking-wider text-muted-foreground">Subscription Type</Label>
<Select value={subscriptionType} onValueChange={setSubscriptionType}>
<SelectTrigger className={cn(inp, 'w-full')}>
@ -802,8 +777,16 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-xs uppercase tracking-wider text-muted-foreground">Reminder Days</Label>
{/* Bank sync button moved to the Transactions tab → Bank Matching Rules section */}
</div>
)}
</div>
{/* Reminder lead time applies to every bill (the notifier honors
reminder_days_before for the early "due soon" reminder). */}
<div className="col-span-2 rounded-lg border border-border/60 bg-muted/20 p-3">
<div className="space-y-1.5 sm:max-w-[50%]">
<Label className="text-xs uppercase tracking-wider text-muted-foreground">Reminder Days Before</Label>
<Input
className={cn(inp, 'tracker-number')}
type="number"
@ -812,11 +795,10 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa
value={reminderDaysBefore}
onChange={e => setReminderDaysBefore(e.target.value)}
/>
<p className="text-[10px] text-muted-foreground/70">0-30 days before renewal.</p>
<p className="text-[10px] text-muted-foreground/70">
Get an early reminder this many days before the due date (0-30). Also needs reminders enabled in Settings.
</p>
</div>
{/* Bank sync button moved to the Transactions tab → Bank Matching Rules section */}
</div>
)}
</div>
{/* Debt / Snowball Details — collapsible */}
@ -860,7 +842,7 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa
setInterestRate(e.target.value);
setTimeout(() => setErrors(prev => ({ ...prev, interestRate: validateInterestRate(e.target.value) })), 300);
}}
onBlur={() => handleBlur('interestRate', validateInterestRate)}
onBlur={() => handleBlur('interestRate', interestRate, validateInterestRate)}
/>
{errors.interestRate && (
<span className="text-[10px] text-red-500 font-medium">{errors.interestRate}</span>
@ -879,7 +861,7 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa
setCurrentBalance(e.target.value);
setTimeout(() => setErrors(prev => ({ ...prev, currentBalance: validateCurrentBalance(e.target.value) })), 300);
}}
onBlur={() => setErrors(prev => ({ ...prev, currentBalance: validateCurrentBalance(currentBalance) }))}
onBlur={() => handleBlur('currentBalance', currentBalance, validateCurrentBalance)}
/>
{errors.currentBalance && (
<span className="text-[10px] text-red-500 font-medium">{errors.currentBalance}</span>
@ -898,7 +880,7 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa
setMinimumPayment(e.target.value);
setTimeout(() => setErrors(prev => ({ ...prev, minimumPayment: validateMinimumPayment(e.target.value) })), 300);
}}
onBlur={() => setErrors(prev => ({ ...prev, minimumPayment: validateMinimumPayment(minimumPayment) }))}
onBlur={() => handleBlur('minimumPayment', minimumPayment, validateMinimumPayment)}
/>
{errors.minimumPayment && (
<span className="text-[10px] text-red-500 font-medium">{errors.minimumPayment}</span>
@ -1211,7 +1193,11 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa
const result = await api.syncBillSimplefinPayments(sourceBill.id);
if (result.added > 0) {
toast.success(`${result.added} payment${result.added !== 1 ? 's' : ''} imported from bank history.`);
loadLinkedTransactions?.();
// Imported payments must refresh the payment list AND the
// Tracker behind the modal (the row may now be covered)
// same as the unmatch handlers.
await Promise.all([loadPayments(), loadLinkedTransactions()]);
onSave?.();
} else {
toast.info('No new matching transactions found.');
}
@ -1238,9 +1224,13 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa
<BillMerchantRules
billId={sourceBill?.id}
billName={sourceBill?.name}
onRulesChanged={() => {
onRulesChanged={async () => {
setLocalHasRules(true);
loadLinkedTransactions?.();
// A historical import (fired after adding a rule) creates
// payments, so refresh the payment list AND the Tracker too
// not just the linked transactions.
await Promise.all([loadPayments(), loadLinkedTransactions()]);
onSave?.();
}}
/>
</div>

View File

@ -1,7 +1,7 @@
import React, { useMemo } from 'react';
import { Loader2, AlertCircle } from 'lucide-react';
import { cn } from '@/lib/utils';
import { STATUS_META } from '@/lib/trackerUtils';
import { STATUS_META, isPaidStatus } from '@/lib/trackerUtils';
export const StatusBadge = React.memo(function StatusBadge({ status, clickable, onClick, loading }) {
const meta = useMemo(() => STATUS_META[status] || STATUS_META.upcoming, [status]);
@ -26,7 +26,7 @@ export const StatusBadge = React.memo(function StatusBadge({ status, clickable,
loading && 'opacity-60 cursor-wait',
meta.cls,
)}
title={canClick ? (status === 'paid' || status === 'autodraft' ? 'Click to mark unpaid' : 'Click to mark paid') : undefined}
title={canClick ? (isPaidStatus(status) ? 'Click to mark unpaid' : 'Click to mark paid') : undefined}
>
{loading ? (
<>

View File

@ -1,4 +1,5 @@
import { useQuery } from '@tanstack/react-query';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useCallback } from 'react';
import { api } from '@/api';
// Custom hook for fetching tracker data
@ -50,3 +51,19 @@ export function useDriftReport() {
refetchOnWindowFocus: false,
});
}
// A single invalidation for every query a bill mutation can affect. Previously a
// row action only refetch()'d the one tracker query passed down as `refresh`, so
// the sidebar overdue badge (['overdue-count'], 2-min staleTime), the drift
// report and the bills list stayed stale after paying/skipping/editing a bill —
// e.g. clearing your last overdue bill still showed "3" on the badge for minutes.
// Hand this to rows / BillModal.onSave / payment + sync handlers so the whole
// shell updates live. Returns a stable callback safe to use as an effect dep.
export function useInvalidateTrackerData() {
const queryClient = useQueryClient();
return useCallback(() => {
for (const key of [['tracker'], ['overdue-count'], ['drift-report'], ['bills']]) {
queryClient.invalidateQueries({ queryKey: key });
}
}, [queryClient]);
}

View File

@ -60,3 +60,18 @@ export function formatCentsUSD(cents, { signed = false, dash = false, currency =
if (signed) return (c < 0 ? '-' : '+') + body;
return (c < 0 ? '-' : '') + body;
}
/**
* Shared form validator for a non-negative money field (dollars). Blank is
* allowed (returns ''); otherwise the value must parse to a number 0. Returns
* '' when valid, or an error string labelled for the field. Zero is allowed
* these are non-negative, not strictly-positive, amounts.
* @param {number|string|null} val
* @param {string} [label] field name for the message (e.g. "Amount", "Balance")
*/
export function validateNonNegativeMoney(val, label = 'Amount') {
if (val === '' || val === null || val === undefined) return '';
const num = parseFloat(val);
if (isNaN(num) || num < 0) return `${label} must be a non-negative number`;
return '';
}

View File

@ -1,5 +1,21 @@
import { describe, it, expect } from 'vitest';
import { formatUSD, formatUSDWhole, formatCentsUSD } from './money';
import { formatUSD, formatUSDWhole, formatCentsUSD, validateNonNegativeMoney } from './money';
describe('validateNonNegativeMoney', () => {
it('allows blank and zero (non-negative, not strictly positive)', () => {
expect(validateNonNegativeMoney('')).toBe('');
expect(validateNonNegativeMoney(null)).toBe('');
expect(validateNonNegativeMoney(undefined)).toBe('');
expect(validateNonNegativeMoney('0')).toBe('');
expect(validateNonNegativeMoney('12.50')).toBe('');
});
it('rejects negatives and non-numeric with a labelled message', () => {
expect(validateNonNegativeMoney('-1', 'Amount')).toBe('Amount must be a non-negative number');
expect(validateNonNegativeMoney('abc', 'Balance')).toBe('Balance must be a non-negative number');
expect(validateNonNegativeMoney('-0.01', 'Min payment')).toBe('Min payment must be a non-negative number');
});
});
describe('formatUSD (dollars in)', () => {
it('formats to two decimals with grouping', () => {

View File

@ -86,10 +86,17 @@ export function rowEffectiveStatus(row) {
return (isPaidByThreshold && row.status !== 'paid' && row.status !== 'autodraft') ? 'paid' : row.status;
}
// A bill's month is "settled" when its status is paid or autodraft. Mirrors the
// server's statusService.isPaidStatus — the single low-level check the various
// row/badge/calendar components share.
export function isPaidStatus(status) {
return status === 'paid' || status === 'autodraft';
}
export function rowIsPaid(row) {
const status = rowEffectiveStatus(row);
if (row.autopay_suggestion && status === 'autodraft') return false;
return status === 'paid' || status === 'autodraft';
return isPaidStatus(status);
}
export function rowIsDebt(row) {

View File

@ -16,6 +16,7 @@ import {
import { toast } from 'sonner';
import { api } from '@/api';
import { cn, fmt, fmtDate, todayStr } from '@/lib/utils';
import { isPaidStatus } from '@/lib/trackerUtils';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
@ -45,7 +46,7 @@ function displayStatus(status) {
}
function statusTone(status) {
if (status === 'paid' || status === 'autodraft') return 'border-emerald-500/30 bg-emerald-500/15 text-emerald-700 dark:text-emerald-300';
if (isPaidStatus(status)) return 'border-emerald-500/30 bg-emerald-500/15 text-emerald-700 dark:text-emerald-300';
if (status === 'skipped') return 'border-border bg-muted/80 text-muted-foreground';
if (status === 'late') return 'border-orange-400/60 bg-orange-500/25 text-orange-800 shadow-sm shadow-orange-950/10 dark:text-orange-100';
if (status === 'missed') return 'border-rose-400/60 bg-rose-500/30 text-rose-800 shadow-sm shadow-rose-950/10 dark:text-rose-100';

View File

@ -3,7 +3,7 @@ import { useSearchParams } from 'react-router-dom';
import { ChevronLeft, ChevronRight, AlertCircle, CheckCircle2, Plus, Search, RefreshCw, Landmark, ArrowUpToLine, ArrowUp, ArrowDown, BellOff, EyeOff, Settings2 } from 'lucide-react';
import { toast } from 'sonner';
import { api } from '@/api.js';
import { useTracker, useDriftReport } from '@/hooks/useQueries';
import { useTracker, useDriftReport, useInvalidateTrackerData } from '@/hooks/useQueries';
import { useSearchPanelPreference } from '@/hooks/useSearchPanelPreference';
import BillModal from '@/components/BillModal';
import { makeBillDraft } from '@/lib/billDrafts';
@ -262,7 +262,11 @@ export default function TrackerPage() {
// Use React Query for data fetching
const { data, isLoading: loading, isError, error, refetch, dataUpdatedAt } = useTracker(year, month);
const { data: driftData, refetch: refetchDrift } = useDriftReport();
const { data: driftData } = useDriftReport();
// Live cross-query refresh: a bill mutation invalidates the tracker AND the
// overdue badge / drift report / bills list so the whole shell updates, not
// just the one tracker query. Use this everywhere a row/payment mutation lands.
const invalidateData = useInvalidateTrackerData();
const driftedIds = useMemo(() => new Set((driftData?.bills ?? []).map(b => b.id)), [driftData]);
useEffect(() => {
@ -280,7 +284,7 @@ export default function TrackerPage() {
useEffect(() => {
api.settings()
.then(settings => setTrackerSettings(prev => ({ ...prev, ...settings })))
.catch(() => {});
.catch(() => toast.error("Couldn't load tracker display settings — showing defaults."));
}, []);
// Listen for late-attribution events fired by BillModal's single-bill sync
@ -329,7 +333,7 @@ export default function TrackerPage() {
// Surface late-attribution prompts (payments that just crossed a month boundary)
if (attributions.length > 0) setLateAttributions(attributions);
refetch();
invalidateData();
} catch (err) {
toast.error(err.message || 'Bank sync failed');
} finally {
@ -564,7 +568,7 @@ export default function TrackerPage() {
try {
await api.reorderBills(payload);
toast.success('Bill order saved');
refetch();
invalidateData();
} catch (err) {
setOrderedRows(null);
toast.error(err.message || 'Failed to save bill order');
@ -862,7 +866,7 @@ export default function TrackerPage() {
rows={rows}
year={year}
month={month}
refresh={refetch}
refresh={invalidateData}
onPayNow={(row) => setCommandCenterPayRow(row)}
/>
)}
@ -881,7 +885,7 @@ export default function TrackerPage() {
{!isError && !loading && showDriftInsights && (driftData?.bills?.length ?? 0) > 0 && (
<DriftInsightPanel
driftBills={driftData.bills}
refresh={() => { refetch(); refetchDrift(); }}
refresh={invalidateData}
/>
)}
@ -953,8 +957,8 @@ export default function TrackerPage() {
)}
{!isError && (first.length > 0 || second.length > 0) && (
<div className="space-y-5">
{first.length > 0 && <Bucket label="1st 14th" rows={first} year={year} month={month} refresh={refetch} onEditBill={handleOpenEditBill} loading={loading} reorderEnabled={reorderEnabled} movingBillId={movingBillId} sortKey={sortKey} sortDir={sortDir} onSort={handleSortHeader} onReorderRows={(next) => handleReorderBucket('1st', next)} driftedIds={driftedIds} visibleColumns={visibleTableColumns} />}
{second.length > 0 && <Bucket label="15th 31st" rows={second} year={year} month={month} refresh={refetch} onEditBill={handleOpenEditBill} loading={loading} reorderEnabled={reorderEnabled} movingBillId={movingBillId} sortKey={sortKey} sortDir={sortDir} onSort={handleSortHeader} onReorderRows={(next) => handleReorderBucket('15th', next)} driftedIds={driftedIds} visibleColumns={visibleTableColumns} />}
{first.length > 0 && <Bucket label="1st 14th" rows={first} year={year} month={month} refresh={invalidateData} onEditBill={handleOpenEditBill} loading={loading} reorderEnabled={reorderEnabled} movingBillId={movingBillId} sortKey={sortKey} sortDir={sortDir} onSort={handleSortHeader} onReorderRows={(next) => handleReorderBucket('1st', next)} driftedIds={driftedIds} visibleColumns={visibleTableColumns} />}
{second.length > 0 && <Bucket label="15th 31st" rows={second} year={year} month={month} refresh={invalidateData} onEditBill={handleOpenEditBill} loading={loading} reorderEnabled={reorderEnabled} movingBillId={movingBillId} sortKey={sortKey} sortDir={sortDir} onSort={handleSortHeader} onReorderRows={(next) => handleReorderBucket('15th', next)} driftedIds={driftedIds} visibleColumns={visibleTableColumns} />}
</div>
)}
@ -966,7 +970,7 @@ export default function TrackerPage() {
initialBill={editBillData.initialBill}
categories={editBillData.categories}
onClose={() => setEditBillData(null)}
onSave={() => refetch()}
onSave={() => invalidateData()}
onDuplicate={bill => setEditBillData({
bill: null,
initialBill: makeBillDraft(bill, { copy: true, categories: editBillData.categories }),
@ -981,7 +985,7 @@ export default function TrackerPage() {
onClose={() => setEditStartingOpen(false)}
year={year}
month={month}
onSave={() => { setEditStartingOpen(false); refetch(); }}
onSave={() => { setEditStartingOpen(false); invalidateData(); }}
/>
{/* Income breakdown modal — opens when clicking the bank balance card */}
@ -1008,7 +1012,7 @@ export default function TrackerPage() {
const month = new Date(attr.suggested_date + 'T00:00:00').toLocaleDateString('en-US', { month: 'long' });
toast.success(`${attr.bill_name} payment moved to ${month}`);
setLateAttributions(prev => prev.slice(1)); // dismiss only on success
refetch();
invalidateData();
} catch (err) {
toast.error(err.message || 'Failed to reclassify payment — try again');
// keep the attribution in queue so user can retry
@ -1029,7 +1033,7 @@ export default function TrackerPage() {
threshold={commandCenterPayRow.actual_amount ?? commandCenterPayRow.expected_amount}
defaultPaymentDate={paymentDateForTrackerMonth(year, month, commandCenterPayRow.due_day)}
onClose={() => setCommandCenterPayRow(null)}
onSaved={() => { setCommandCenterPayRow(null); refetch(); }}
onSaved={() => { setCommandCenterPayRow(null); invalidateData(); }}
/>
)}

View File

@ -241,15 +241,32 @@ router.post('/quick', (req, res) => {
const payDate = paymentValidation.normalized.paid_date;
const paySource = paymentValidation.normalized.payment_source;
// Guard against a double-clicked / retried "pay" (or two tabs) creating a
// duplicate payment. Mirrors the bulk route's composite-key check
// (bill_id + paid_date + amount). If one already exists, return it idempotently.
const existingDuplicate = db.prepare(
`SELECT p.* FROM payments p
WHERE p.bill_id = ? AND p.paid_date = ? AND p.amount = ? AND p.${SQL_NOT_DELETED}
ORDER BY p.id DESC LIMIT 1`
).get(bill.id, payDate, payAmount);
if (existingDuplicate) {
return res.status(200).json(serializePayment(existingDuplicate));
}
const balCalc = computeBalanceDelta(bill, payAmount);
const result = db.prepare(
// Atomic: the INSERT and the balance update must both land or neither, so a
// mid-way failure never leaves a payment without its balance adjustment.
const insertQuickPayment = db.transaction(() => {
const r = db.prepare(
'INSERT INTO payments (bill_id, amount, paid_date, method, notes, balance_delta, interest_delta, payment_source) VALUES (?, ?, ?, ?, ?, ?, ?, ?)'
).run(bill.id, payAmount, payDate, method || null, notes || null, balCalc?.balance_delta ?? null, balCalc?.interest_delta ?? null, paySource);
applyBalanceDelta(db, bill.id, balCalc);
return r.lastInsertRowid;
});
const paymentId = insertQuickPayment();
res.status(201).json(serializePayment(db.prepare('SELECT * FROM payments WHERE id = ?').get(result.lastInsertRowid)));
res.status(201).json(serializePayment(db.prepare('SELECT * FROM payments WHERE id = ?').get(paymentId)));
});
// POST /api/payments/autopay-suggestions/:billId/confirm

View File

@ -1,22 +1,40 @@
const express = require('express');
const router = express.Router();
const { getTracker, getUpcomingBills, getOverdueCount } = require('../services/trackerService');
const { standardizeError } = require('../middleware/errorFormatter');
// GET /api/tracker/overdue-count — lightweight count for sidebar badge
router.get('/overdue-count', (req, res) => {
try {
res.json(getOverdueCount(req.user.id));
} catch (err) {
console.error('[tracker/overdue-count]', err.message);
res.status(500).json(standardizeError('Failed to load overdue count', 'INTERNAL_ERROR'));
}
});
// GET /api/tracker?year=2026&month=5
router.get('/', (req, res) => {
try {
const result = getTracker(req.user.id, req.query);
if (result.error) return res.status(result.status || 400).json({ error: result.error });
if (result.error) {
return res.status(result.status || 400).json(standardizeError(result.error, 'VALIDATION_ERROR'));
}
res.json(result);
} catch (err) {
console.error('[tracker]', err.message);
res.status(500).json(standardizeError('Failed to load tracker data', 'INTERNAL_ERROR'));
}
});
// GET /api/tracker/upcoming?days=30
router.get('/upcoming', (req, res) => {
try {
res.json(getUpcomingBills(req.user.id, req.query));
} catch (err) {
console.error('[tracker/upcoming]', err.message);
res.status(500).json(standardizeError('Failed to load upcoming bills', 'INTERNAL_ERROR'));
}
});
module.exports = router;

View File

@ -3,6 +3,35 @@
const { accountingActiveSql } = require('./paymentAccountingService');
const { fromCents } = require('../utils/money');
// The 6 calendar months immediately preceding (year, month), newest first, each
// as { y, m, key: 'YYYY-MM' }.
function priorMonths(year, month, count = 6) {
const list = [];
let y = year;
let m = month;
for (let i = 0; i < count; i++) {
m -= 1;
if (m === 0) { m = 12; y -= 1; }
list.push({ y, m, key: `${y}-${String(m).padStart(2, '0')}` });
}
return list;
}
// Rolling median of a bill's monthly amounts → a suggestion object (or null).
function medianSuggestion(amounts) {
if (amounts.length === 0) return null;
const sorted = [...amounts].sort((a, b) => a - b);
const mid = Math.floor(sorted.length / 2);
const median = sorted.length % 2 === 0
? (sorted[mid - 1] + sorted[mid]) / 2
: sorted[mid];
return {
suggestion: fromCents(Math.round(median)),
months_used: amounts.length,
confidence: amounts.length >= 3 ? 'high' : 'low',
};
}
/**
* Computes a suggested expected amount for a bill based on the rolling median
* of the last 6 months of actual data. Prefers monthly_bill_state.actual_amount
@ -10,13 +39,8 @@ const { fromCents } = require('../utils/money');
*/
function computeAmountSuggestion(db, billId, year, month) {
const amounts = [];
let y = year;
let m = month;
for (let i = 0; i < 6; i++) {
m -= 1;
if (m === 0) { m = 12; y -= 1; }
for (const { y, m } of priorMonths(year, month, 6)) {
const mbs = db.prepare(
'SELECT actual_amount FROM monthly_bill_state WHERE bill_id = ? AND year = ? AND month = ?'
).get(billId, y, m);
@ -39,19 +63,75 @@ function computeAmountSuggestion(db, billId, year, month) {
if (result.total > 0) amounts.push(result.total);
}
if (amounts.length === 0) return null;
const sorted = [...amounts].sort((a, b) => a - b);
const mid = Math.floor(sorted.length / 2);
const median = sorted.length % 2 === 0
? (sorted[mid - 1] + sorted[mid]) / 2
: sorted[mid];
return {
suggestion: fromCents(Math.round(median)),
months_used: amounts.length,
confidence: amounts.length >= 3 ? 'high' : 'low',
};
return medianSuggestion(amounts);
}
module.exports = { computeAmountSuggestion };
/**
* Batched form of computeAmountSuggestion for many bills at once: two queries
* total (monthly_bill_state + grouped payment sums) instead of up to 12 per
* bill. Returns a Map<billId, suggestion|null>. Behavior-identical to calling
* computeAmountSuggestion per bill (guarded by amountSuggestionService.test.js).
*/
function computeAmountSuggestionsBatch(db, billIds, year, month) {
const result = new Map();
if (!billIds || billIds.length === 0) return result;
const months = priorMonths(year, month, 6);
const monthKeys = new Set(months.map(mm => mm.key));
const minKey = months[months.length - 1].key; // earliest (month 6)
const maxKey = months[0].key; // latest (month 1)
// Broad window that fully spans the 6 months; the monthKeys filter below is
// the exact gate, so a '-01'…'-31' string window is safe for ISO dates.
const windowStart = `${minKey}-01`;
const windowEnd = `${maxKey}-31`;
const placeholders = billIds.map(() => '?').join(',');
// User-corrected monthly amounts.
const mbsMap = new Map(); // billId → { 'YYYY-MM': actual_amount }
const mbsRows = db.prepare(`
SELECT bill_id, year, month, actual_amount
FROM monthly_bill_state
WHERE bill_id IN (${placeholders})
`).all(...billIds);
for (const r of mbsRows) {
if (r.actual_amount == null) continue;
const key = `${r.year}-${String(r.month).padStart(2, '0')}`;
if (!monthKeys.has(key)) continue;
if (!mbsMap.has(r.bill_id)) mbsMap.set(r.bill_id, {});
mbsMap.get(r.bill_id)[key] = r.actual_amount;
}
// Payment sums grouped by bill + month.
const payMap = new Map(); // billId → { 'YYYY-MM': total }
const payRows = db.prepare(`
SELECT bill_id, strftime('%Y-%m', paid_date) AS ym, COALESCE(SUM(amount), 0) AS total
FROM payments
WHERE bill_id IN (${placeholders})
AND deleted_at IS NULL
AND ${accountingActiveSql()}
AND paid_date BETWEEN ? AND ?
GROUP BY bill_id, ym
`).all(...billIds, windowStart, windowEnd);
for (const r of payRows) {
if (!monthKeys.has(r.ym)) continue;
if (!payMap.has(r.bill_id)) payMap.set(r.bill_id, {});
payMap.get(r.bill_id)[r.ym] = r.total;
}
for (const billId of billIds) {
const amounts = [];
const mbsFor = mbsMap.get(billId) || {};
const payFor = payMap.get(billId) || {};
for (const { key } of months) {
if (mbsFor[key] != null) { amounts.push(mbsFor[key]); continue; }
const total = payFor[key] || 0;
if (total > 0) amounts.push(total);
}
result.set(billId, medianSuggestion(amounts));
}
return result;
}
module.exports = { computeAmountSuggestion, computeAmountSuggestionsBatch };

View File

@ -141,8 +141,32 @@ function senderAddress() {
// ── Email templates ───────────────────────────────────────────────────────────
// Per-bill reminder lead time (days before due) for the early "due soon"
// reminder. Every bill has a reminder_days_before column (default 3); honor it
// so a user who asks for "7 days before" actually gets a 7-day reminder.
function leadDaysOf(bill) {
return Number.isInteger(bill?.reminder_days_before) ? bill.reminder_days_before : 3;
}
// Which reminder type (if any) applies to a bill that is `diffDays` calendar
// days from its due date. Pure + exported so the lead-time logic is unit-testable
// without invoking the full runner (which uses the real clock + real senders).
// The early reminder fires at the bill's own lead time, and only when that lead
// is ≥ 2 so it never collides with the 1-day or same-day reminders.
function reminderTypeFor(bill, diffDays) {
const leadDays = leadDaysOf(bill);
if (diffDays >= 2 && diffDays === leadDays) return 'due_3d';
if (diffDays === 1) return 'due_1d';
if (diffDays === 0) return 'due_today';
if (diffDays < 0) return 'overdue';
return null;
}
const TYPE_META = {
due_3d: { subject: (b) => `Reminder: ${b.name} due in 3 days`, urgency: 'upcoming' },
// The `due_3d` key is the early reminder; its lead time is the bill's own
// reminder_days_before (only fires when that is ≥ 2 — a 1-day lead is covered
// by due_1d and a 0-day lead by due_today).
due_3d: { subject: (b) => `Reminder: ${b.name} due in ${leadDaysOf(b)} days`, urgency: 'upcoming' },
due_1d: { subject: (b) => `Reminder: ${b.name} due tomorrow`, urgency: 'soon' },
due_today: { subject: (b) => `Due today: ${b.name}`, urgency: 'today' },
overdue: { subject: (b) => `Overdue: ${b.name} hasn't been paid`, urgency: 'overdue' },
@ -169,7 +193,7 @@ function buildEmailHtml(bill, type, dueDate) {
// these message strings (previously raw — an XSS vector via the bill name).
const name = esc(bill.name);
const messages = {
due_3d: `<strong>${name}</strong> is due in 3 days.`,
due_3d: `<strong>${name}</strong> is due in ${leadDaysOf(bill)} days.`,
due_1d: `<strong>${name}</strong> is due <strong>tomorrow</strong>.`,
due_today: `<strong>${name}</strong> is due <strong>today</strong>.`,
overdue: `<strong>${name}</strong> was due on ${fmt(dueDate)} and has not been marked as paid.`,
@ -359,12 +383,9 @@ async function runNotifications() {
const dueDay = new Date(due.getFullYear(), due.getMonth(), due.getDate());
const diffDays = Math.round((dueDay - todayDate) / 86400000);
// Determine which type applies today
let type = null;
if (diffDays === 3) type = 'due_3d';
else if (diffDays === 1) type = 'due_1d';
else if (diffDays === 0) type = 'due_today';
else if (diffDays < 0) type = 'overdue';
// Determine which type applies today. The early reminder fires at the bill's
// own lead time (reminder_days_before, default 3) rather than a fixed 3 days.
const type = reminderTypeFor(bill, diffDays);
if (!type) continue;
@ -555,3 +576,6 @@ module.exports = { runNotifications, runDriftNotifications, sendTestEmail, creat
// before it, leaving `_push` undefined → "Send test push" always 500'd).
module.exports._push = { sendNtfy, sendGotify, sendDiscord, sendTelegram, sendTestPush, sendPushToUser, encryptSecret };
module.exports._email = { buildEmailHtml, esc };
// Reminder lead-time logic, exposed for unit tests (the full runner uses the
// real clock + real senders, so the pure type selection is tested in isolation).
module.exports._reminders = { reminderTypeFor, leadDaysOf, TYPE_META };

View File

@ -1,5 +1,13 @@
const { getSetting } = require('../db/database');
// A bill's month is "settled" when its status is paid or autodraft (assumed paid
// via autopay). Single source of truth so the ~scattered inline
// `status === 'paid' || status === 'autodraft'` checks don't drift.
const PAID_STATUSES = Object.freeze(['paid', 'autodraft']);
function isPaidStatus(status) {
return status === 'paid' || status === 'autodraft';
}
const WEEKDAY_INDEX = {
sunday: 0,
monday: 1,
@ -226,7 +234,7 @@ function buildTrackerRow(bill, payments, year, month, todayStr, options = {}) {
const expectedAmount = Number(bill.expected_amount) || 0;
const totalPaid = safePayments.reduce((sum, p) => sum + (Number(p.amount) || 0), 0);
const hasPayment = safePayments.length > 0;
const isSettled = status === 'paid' || status === 'autodraft';
const isSettled = isPaidStatus(status);
const paidTowardDue = Math.min(totalPaid, expectedAmount);
const overpaidAmount = Math.max(totalPaid - expectedAmount, 0);
const rawBalance = expectedAmount - totalPaid;
@ -282,7 +290,9 @@ module.exports = {
calculateStatus,
getCalendarMonthRange,
getCycleRange,
isPaidStatus,
normalizeCycleType,
PAID_STATUSES,
resolveBucket,
resolveDueDate,
resolveGracePeriodDays,

View File

@ -1,10 +1,10 @@
'use strict';
const { getDb } = require('../db/database');
const { buildTrackerRow, getCycleRange, resolveDueDate } = require('./statusService');
const { buildTrackerRow, getCycleRange, resolveDueDate, isPaidStatus } = require('./statusService');
const { getUserSettings } = require('./userSettings');
const { computeBalanceDelta, applyBalanceDelta } = require('./billsService');
const { computeAmountSuggestion } = require('./amountSuggestionService');
const { computeAmountSuggestionsBatch } = require('./amountSuggestionService');
const { accountingActiveSql } = require('./paymentAccountingService');
const { normalizeMerchant } = require('./subscriptionService');
const { localDateString } = require('../utils/dates');
@ -64,7 +64,11 @@ function fetchBankPendingCounts(db, userId, billIds) {
return counts;
}
function buildBankTracking(db, userId, year, month) {
// `gatedUnpaidThisMonth` (dollars) is the occurrence-gated unpaid total the
// caller already computed from the tracker rows (which honor resolveDueDate).
// When provided we use it instead of the ungated SQL below, so annual/quarterly/
// off-month bills don't inflate `unpaid_this_month` → the bank `remaining`.
function buildBankTracking(db, userId, year, month, gatedUnpaidThisMonth = null) {
try {
const settings = getUserSettings(userId);
if (settings.bank_tracking_enabled !== 'true') return { enabled: false };
@ -96,6 +100,13 @@ function buildBankTracking(db, userId, year, month) {
`).get(userId, days)
: { pending_total: 0 };
// Fallback (only when the caller didn't pass a gated total, e.g. a direct
// call): the ungated SQL sum. This overcounts off-month bills — prefer the
// gated total from getTracker's rows.
let unpaid;
if (gatedUnpaidThisMonth != null) {
unpaid = roundMoney(gatedUnpaidThisMonth);
} else {
const { start, end } = getCycleRange(year, month);
const unpaidRow = db.prepare(`
SELECT COALESCE(SUM(
@ -114,11 +125,12 @@ function buildBankTracking(db, userId, year, month) {
WHERE b.user_id = ? AND b.active = 1 AND b.deleted_at IS NULL
AND COALESCE(m.is_skipped, 0) = 0 AND COALESCE(pay.paid_sum, 0) = 0
`).get(year, month, start, end, userId);
unpaid = fromCents(unpaidRow.unpaid_total);
}
const balance = roundMoney(account.balance / 100);
const balance = fromCents(account.balance);
const pending = fromCents(pendingRow.pending_total);
const effective = roundMoney(balance - pending);
const unpaid = fromCents(unpaidRow.unpaid_total);
return {
enabled: true,
@ -211,6 +223,46 @@ function fetchPaymentsForBillCycle(db, bill, year, month) {
`).all(bill.id, range.start, range.end);
}
// Batched form of fetchPaymentsForBillCycle: one query for every bill's cycle
// payments (grouped in JS by each bill's own range) instead of one query per
// bill. Returns Map<billId, payments[]> with each list still ordered paid_date
// DESC — identical to calling fetchPaymentsForBillCycle per bill.
function fetchPaymentsForBillsInMonth(db, bills, year, month) {
const ranges = new Map(); // billId → { start, end } (only bills due this month)
let minStart = null;
let maxEnd = null;
for (const bill of bills) {
const range = getCycleRange(year, month, bill);
if (!range) continue;
ranges.set(bill.id, range);
if (minStart === null || range.start < minStart) minStart = range.start;
if (maxEnd === null || range.end > maxEnd) maxEnd = range.end;
}
const byBill = new Map();
if (minStart === null) return byBill;
const ids = [...ranges.keys()];
const placeholders = ids.map(() => '?').join(',');
const rows = db.prepare(`
SELECT bill_id, id, amount, paid_date, method, notes, payment_source, transaction_id, created_at, updated_at
FROM payments
WHERE bill_id IN (${placeholders}) AND paid_date BETWEEN ? AND ?
AND deleted_at IS NULL
AND ${accountingActiveSql()}
ORDER BY paid_date DESC
`).all(...ids, minStart, maxEnd);
for (const row of rows) {
const range = ranges.get(row.bill_id);
// The union window may be wider than this bill's own cycle — filter to it.
if (row.paid_date < range.start || row.paid_date > range.end) continue;
if (!byBill.has(row.bill_id)) byBill.set(row.bill_id, []);
byBill.get(row.bill_id).push(row);
}
return byBill;
}
function fetchPreviousMonthPaid(db, billIds, range) {
if (billIds.length === 0) return {};
const placeholders = billIds.map(() => '?').join(',');
@ -317,7 +369,7 @@ function buildSafeToSpend({ activeRows, available, todayStr, year, month, dayOfM
const daysUntilPayday = Math.max(0, Math.round((Date.parse(nextPayday) - Date.parse(todayStr)) / 86400000));
const stillDueRows = activeRows
.filter(r => !['paid', 'autodraft'].includes(r.status))
.filter(r => !isPaidStatus(r.status))
.filter(r => rowOutstanding(r) > 0)
.filter(r => r.due_date < nextPayday)
.sort((a, b) => a.due_date.localeCompare(b.due_date) || String(a.name).localeCompare(String(b.name)));
@ -400,7 +452,11 @@ function applyAutopaySuggestions(db, bill, payments, mbs, year, month, todayStr,
}
const balCalc = computeBalanceDelta(bill, suggestedAmount);
const result = db.prepare(`
// Atomic: the auto-mark payment INSERT and its balance update must both land
// or neither. This runs on a GET /tracker, so a mid-way failure would
// otherwise leave a payment without its balance adjustment (or vice versa).
const insertAutoPayment = db.transaction(() => {
const r = db.prepare(`
INSERT INTO payments (bill_id, amount, paid_date, method, notes, balance_delta, interest_delta, payment_source)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`).run(
@ -413,16 +469,17 @@ function applyAutopaySuggestions(db, bill, payments, mbs, year, month, todayStr,
balCalc?.interest_delta ?? null,
'manual',
);
if (balCalc) applyBalanceDelta(db, bill.id, balCalc);
return r.lastInsertRowid;
});
const paymentId = insertAutoPayment();
if (balCalc) {
applyBalanceDelta(db, bill.id, balCalc);
bill.current_balance = balCalc.new_balance;
}
if (balCalc) bill.current_balance = balCalc.new_balance;
payments.push(db.prepare(`
SELECT bill_id, id, amount, paid_date, method, notes, payment_source, transaction_id, created_at, updated_at
FROM payments
WHERE id = ?
`).get(result.lastInsertRowid));
`).get(paymentId));
return null;
}
@ -502,13 +559,17 @@ function getTracker(userId, query = {}, now = new Date()) {
const dismissedSuggestions = fetchDismissedSuggestions(db, userId, billIds, year, month);
const sparklines = fetchSparklines(db, billIds);
const autopayStatsMap = fetchAutopayStats(db, billIds);
// Batched to avoid an N+1 across bills (was ~23 queries × N bills every load):
// one query for all cycle payments, two for all amount suggestions.
const paymentsByBill = fetchPaymentsForBillsInMonth(db, bills, year, month);
const amountSuggestions = computeAmountSuggestionsBatch(db, billIds, year, month);
const rows = bills.map(bill => {
bill.sparkline = sparklines[bill.id] ?? null;
bill.autopay_stats = autopayStatsMap[bill.id] ?? null;
if (!resolveDueDate(bill, year, month)) return null;
const payments = fetchPaymentsForBillCycle(db, bill, year, month);
const payments = paymentsByBill.get(bill.id) || [];
const mbs = monthlyStates[bill.id];
const autopaySuggestion = applyAutopaySuggestions(
db,
@ -534,7 +595,7 @@ function getTracker(userId, query = {}, now = new Date()) {
row.snoozed_until = mbs?.snoozed_until ?? null;
if (autopaySuggestion) row.autopay_suggestion = autopaySuggestion;
row.previous_month_paid = fromCents(prevMonthPayments[bill.id] || 0);
row.amount_suggestion = computeAmountSuggestion(db, bill.id, year, month);
row.amount_suggestion = amountSuggestions.get(bill.id) ?? null;
return row;
}).filter(Boolean);
@ -559,13 +620,18 @@ function getTracker(userId, query = {}, now = new Date()) {
const activeRemainingPeriod = dayOfMonth < 15 ? '1st' : '15th';
const periodRows = activeRows.filter(r => r.bucket === activeRemainingPeriod);
const periodPaidTowardDue = sumMoney(periodRows, rowPaidTowardDue);
const periodOutstandingBalance = sumMoney(periodRows, r => Math.max(r.balance || 0, 0));
const periodOutstandingBalance = sumMoney(periodRows, rowOutstanding);
const periodStartingAmount = activeRemainingPeriod === '1st'
? (startingAmounts?.first_amount || 0)
: (startingAmounts?.fifteenth_amount || 0);
const periodLabel = activeRemainingPeriod === '1st' ? '1st balance' : '15th balance';
const bankTracking = buildBankTracking(db, userId, year, month);
// Occurrence-gated unpaid total for this month: the still-owed amount across
// bills actually due this month (activeRows already honor resolveDueDate),
// netting partial payments. Feeds the bank card so its unpaid/remaining agree
// with the rows instead of over-counting annual/off-month bills.
const activeMonthUnpaid = sumMoney(activeRows, r => Math.max(rowDueAmount(r) - rowPaidTowardDue(r), 0));
const bankTracking = buildBankTracking(db, userId, year, month, activeMonthUnpaid);
const bankPendingCounts = bankTracking.enabled ? fetchBankPendingCounts(db, userId, billIds) : {};
const totalStarting = bankTracking.enabled
? bankTracking.effective_balance
@ -583,10 +649,10 @@ function getTracker(userId, query = {}, now = new Date()) {
const activeTotalPaid = sumMoney(activeRows, r => r.total_paid);
const activePaidTowardDue = sumMoney(activeRows, rowPaidTowardDue);
const activeTotalExpected = sumMoney(activeRows, rowDueAmount);
const activeOutstandingBalance = sumMoney(activeRows, r => Math.max(r.balance || 0, 0));
const activeOutstandingBalance = sumMoney(activeRows, rowOutstanding);
const periodBillsTotal = sumMoney(periodRows, rowDueAmount);
const periodPaidCount = periodRows.filter(r => ['paid', 'autodraft'].includes(r.status)).length;
const periodPaidCount = periodRows.filter(r => isPaidStatus(r.status)).length;
const periodTotalCount = periodRows.length;
// When bank tracking is on use the effective balance as the period starting point
@ -596,7 +662,7 @@ function getTracker(userId, query = {}, now = new Date()) {
const periodProjected = roundMoney(periodCashStart - periodBillsTotal);
const monthBillsTotal = activeTotalExpected;
const monthPaidCount = activeRows.filter(r => ['paid', 'autodraft'].includes(r.status)).length;
const monthPaidCount = activeRows.filter(r => isPaidStatus(r.status)).length;
const monthTotalCount = activeRows.length;
const monthProjected = roundMoney(totalStarting - monthBillsTotal);
@ -649,8 +715,17 @@ function getTracker(userId, query = {}, now = new Date()) {
has_starting_amounts: hasStartingAmounts,
total_paid: activeTotalPaid,
paid_toward_due: activePaidTowardDue,
remaining: roundMoney(hasStartingAmounts ? periodStartingAmount - periodPaidTowardDue : periodOutstandingBalance),
total_remaining: roundMoney(hasStartingAmounts ? totalStarting - activePaidTowardDue : activeOutstandingBalance),
// In bank mode the effective bank balance is a single pool (no per-period
// split), so both remaining figures use the bank card's own remaining
// (effective_balance gated unpaid) — keeping the summary consistent with
// safe-to-spend and the bank card instead of showing a different number
// derived from manual starting amounts.
remaining: roundMoney(bankTracking.enabled
? bankTracking.remaining
: (hasStartingAmounts ? periodStartingAmount - periodPaidTowardDue : periodOutstandingBalance)),
total_remaining: roundMoney(bankTracking.enabled
? bankTracking.remaining
: (hasStartingAmounts ? totalStarting - activePaidTowardDue : activeOutstandingBalance)),
remaining_period: activeRemainingPeriod,
remaining_label: periodLabel,
remaining_hint: hasStartingAmounts

View File

@ -0,0 +1,74 @@
'use strict';
// P1 — computeAmountSuggestionsBatch replaces up to 12 per-bill queries with two
// batched queries in getTracker. This pins that the batched form is byte-identical
// to calling the single-bill computeAmountSuggestion per bill (the behavior we
// rely on for the N+1 fix to be safe).
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-amountsug-${process.pid}.sqlite`);
process.env.DB_PATH = dbPath;
const { getDb, closeDb } = require('../db/database');
const { computeAmountSuggestion, computeAmountSuggestionsBatch } = require('../services/amountSuggestionService');
let db, userId, bills;
test.before(() => {
db = getDb();
userId = db.prepare("INSERT INTO users (username, password_hash, role, active) VALUES ('as-user','x','user',1)").run().lastInsertRowid;
const insBill = db.prepare("INSERT INTO bills (user_id, name, due_day, expected_amount, active) VALUES (?, ?, 1, 10000, 1)");
bills = {
payments: insBill.run(userId, 'Payments only').lastInsertRowid, // suggestion from payment sums
mbs: insBill.run(userId, 'Corrected').lastInsertRowid, // user-corrected actual_amount wins
mixed: insBill.run(userId, 'Mixed').lastInsertRowid, // some months mbs, some payments
empty: insBill.run(userId, 'No history').lastInsertRowid, // → null suggestion
};
const pay = db.prepare("INSERT INTO payments (bill_id, amount, paid_date, payment_source) VALUES (?, ?, ?, 'manual')");
// For query month = 2026-06: prior months are May, Apr, Mar, Feb, Jan 2026, Dec 2025.
pay.run(bills.payments, 10000, '2026-05-15');
pay.run(bills.payments, 12000, '2026-04-15');
pay.run(bills.payments, 11000, '2026-03-15');
// Two payments in one month should sum.
pay.run(bills.mixed, 3000, '2026-05-10');
pay.run(bills.mixed, 2000, '2026-05-20'); // May total 5000
pay.run(bills.mixed, 9000, '2026-04-10');
const mbs = db.prepare("INSERT INTO monthly_bill_state (bill_id, year, month, actual_amount) VALUES (?, ?, ?, ?)");
mbs.run(bills.mbs, 2026, 5, 8000);
mbs.run(bills.mbs, 2026, 4, 8500);
mbs.run(bills.mbs, 2026, 3, 7500);
// Mixed: March has a corrected amount that should win over any payment sum.
mbs.run(bills.mixed, 2026, 3, 4200);
});
test.after(() => {
closeDb();
for (const s of ['', '-wal', '-shm']) { try { fs.rmSync(dbPath + s); } catch {} }
});
test('batch output equals per-bill computeAmountSuggestion for every bill', () => {
const ids = Object.values(bills);
const batch = computeAmountSuggestionsBatch(db, ids, 2026, 6);
for (const id of ids) {
const single = computeAmountSuggestion(db, id, 2026, 6);
assert.deepEqual(batch.get(id), single, `bill ${id} batch matches single`);
}
});
test('empty bill list returns an empty map; no-history bill → null', () => {
assert.equal(computeAmountSuggestionsBatch(db, [], 2026, 6).size, 0);
const batch = computeAmountSuggestionsBatch(db, [bills.empty], 2026, 6);
assert.equal(batch.get(bills.empty), null);
});
test('corrected monthly amount wins over payment sums (median of mbs values)', () => {
const s = computeAmountSuggestion(db, bills.mbs, 2026, 6);
// median of [8000, 8500, 7500] = 8000 → $80
assert.equal(s.suggestion, 80);
assert.equal(s.months_used, 3);
assert.equal(s.confidence, 'high');
});

View File

@ -0,0 +1,68 @@
'use strict';
// X2 — analyticsService's month-window math (which drives the expected-vs-actual
// reconciliation, the QA-B5-03 area) and query validation had no unit tests.
// These pin the pure helpers with hand-calculated examples, including the
// year-boundary and leap-year edges that off-by-one bugs hide in.
const test = require('node:test');
const assert = require('node:assert/strict');
const {
monthKey, monthLabel, addMonths, monthEndDate, buildMonths, validateSummaryQuery,
} = require('../services/analyticsService');
test('monthKey zero-pads the month', () => {
assert.equal(monthKey(2026, 6), '2026-06');
assert.equal(monthKey(2026, 12), '2026-12');
});
test('monthLabel is a UTC short-month + 2-digit year (no TZ drift)', () => {
assert.equal(monthLabel(2026, 1), 'Jan 26');
assert.equal(monthLabel(2026, 6), 'Jun 26');
assert.equal(monthLabel(2025, 12), 'Dec 25');
});
test('addMonths crosses year boundaries both ways', () => {
assert.deepEqual(addMonths(2026, 1, -1), { year: 2025, month: 12 });
assert.deepEqual(addMonths(2026, 12, 1), { year: 2027, month: 1 });
assert.deepEqual(addMonths(2026, 6, 0), { year: 2026, month: 6 });
assert.deepEqual(addMonths(2026, 3, -6), { year: 2025, month: 9 });
});
test('monthEndDate handles 30/31-day months and leap Februaries', () => {
assert.equal(monthEndDate(2026, 6), '2026-06-30');
assert.equal(monthEndDate(2026, 7), '2026-07-31');
assert.equal(monthEndDate(2026, 2), '2026-02-28'); // not a leap year
assert.equal(monthEndDate(2024, 2), '2024-02-29'); // leap year
});
test('buildMonths yields `count` contiguous months ending at (endYear, endMonth)', () => {
const months = buildMonths(2026, 3, 3); // Jan, Feb, Mar 2026
assert.equal(months.length, 3);
assert.deepEqual(months.map(m => m.key), ['2026-01', '2026-02', '2026-03']);
assert.equal(months[0].start, '2026-01-01');
assert.equal(months[2].end, '2026-03-31');
assert.equal(months[2].label, 'Mar 26');
});
test('buildMonths spanning a year boundary', () => {
const months = buildMonths(2026, 1, 3); // Nov 25, Dec 25, Jan 26
assert.deepEqual(months.map(m => m.key), ['2025-11', '2025-12', '2026-01']);
});
test('validateSummaryQuery: defaults, parsing, and range errors', () => {
const ok = validateSummaryQuery({ year: '2026', month: '6', months: '12' });
assert.equal(ok.error, undefined);
assert.equal(ok.year, 2026);
assert.equal(ok.month, 6);
assert.equal(ok.months, 12);
assert.equal(ok.includeSkipped, true, 'skipped included unless explicitly false');
assert.match(validateSummaryQuery({ month: '13' }, new Date('2026-06-15')).error, /month/);
assert.match(validateSummaryQuery({ months: '37' }, new Date('2026-06-15')).error, /months/);
assert.match(validateSummaryQuery({ year: '1999' }, new Date('2026-06-15')).error, /year/);
assert.match(validateSummaryQuery({ category_id: '-1' }, new Date('2026-06-15')).error, /category_id/);
// include_skipped=false flips the flag
assert.equal(validateSummaryQuery({ include_skipped: 'false' }, new Date('2026-06-15')).includeSkipped, false);
});

View File

@ -0,0 +1,48 @@
'use strict';
// BM3 — the reminder notifier used a hard-coded 3-day early reminder and never
// read the bill's reminder_days_before, so the "Reminder days before" control
// was a no-op. These tests pin the now-honored per-bill lead time (pure logic,
// no clock/senders) and the dynamic email wording.
const test = require('node:test');
const assert = require('node:assert/strict');
const { _reminders, _email } = require('../services/notificationService');
const { reminderTypeFor, leadDaysOf, TYPE_META } = _reminders;
test('leadDaysOf defaults to 3 and honors an integer reminder_days_before', () => {
assert.equal(leadDaysOf({}), 3);
assert.equal(leadDaysOf({ reminder_days_before: null }), 3);
assert.equal(leadDaysOf({ reminder_days_before: 7 }), 7);
assert.equal(leadDaysOf({ reminder_days_before: 0 }), 0);
});
test('a 7-day lead fires the early reminder at 7 days out, NOT at 3', () => {
const bill = { reminder_days_before: 7 };
assert.equal(reminderTypeFor(bill, 7), 'due_3d', 'early reminder at the chosen lead');
assert.equal(reminderTypeFor(bill, 3), null, 'no reminder at the old fixed 3 days');
});
test('default (no reminder_days_before) still fires at 3 days — backwards compatible', () => {
assert.equal(reminderTypeFor({}, 3), 'due_3d');
assert.equal(reminderTypeFor({}, 5), null);
});
test('1-day and same-day reminders are unaffected and never double up with a small lead', () => {
// lead of 1 → the early reminder is suppressed (due_1d covers it)
assert.equal(reminderTypeFor({ reminder_days_before: 1 }, 1), 'due_1d');
// lead of 0 → due_today covers it
assert.equal(reminderTypeFor({ reminder_days_before: 0 }, 0), 'due_today');
// generic day/overdue selection
assert.equal(reminderTypeFor({ reminder_days_before: 5 }, 1), 'due_1d');
assert.equal(reminderTypeFor({ reminder_days_before: 5 }, 0), 'due_today');
assert.equal(reminderTypeFor({ reminder_days_before: 5 }, -2), 'overdue');
assert.equal(reminderTypeFor({ reminder_days_before: 5 }, 4), null);
});
test('email subject + body reflect the actual lead days', () => {
const bill = { name: 'Insurance', expected_amount: 12000, reminder_days_before: 7 };
assert.match(TYPE_META.due_3d.subject(bill), /due in 7 days/);
const html = _email.buildEmailHtml(bill, 'due_3d', '2026-07-10');
assert.match(html, /is due in 7 days/);
});

View File

@ -0,0 +1,89 @@
'use strict';
// X2 — the payment-accounting source-of-truth layer (manual vs. bank) had no
// dedicated tests. Covers the bank-backed predicate, the accounting-active SQL
// fragment, and the core money-integrity invariant: when a bank payment lands it
// overrides a provisional manual payment (so it isn't double-counted) and the
// manual one counts again — with the balance restored/re-applied — if the bank
// override is later removed.
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-payacct-${process.pid}.sqlite`);
process.env.DB_PATH = dbPath;
const { getDb, closeDb } = require('../db/database');
const {
isBankBackedPayment,
accountingActiveSql,
markProvisionalManualPaymentsOverridden,
reactivatePaymentsOverriddenBy,
} = require('../services/paymentAccountingService');
test('isBankBackedPayment: bank sources or a transaction_id count as bank-backed', () => {
assert.equal(isBankBackedPayment({ payment_source: 'provider_sync' }), true);
assert.equal(isBankBackedPayment({ payment_source: 'transaction_match' }), true);
assert.equal(isBankBackedPayment({ payment_source: 'auto_match' }), true);
assert.equal(isBankBackedPayment({ payment_source: 'manual', transaction_id: 42 }), true);
assert.equal(isBankBackedPayment({ payment_source: 'manual' }), false);
assert.equal(isBankBackedPayment({}), false);
});
test('accountingActiveSql fragment, with and without a table alias', () => {
assert.equal(accountingActiveSql(), 'COALESCE(accounting_excluded, 0) = 0');
assert.equal(accountingActiveSql('p'), 'COALESCE(p.accounting_excluded, 0) = 0');
});
test('bank payment overrides a provisional manual payment, then reactivates on removal', () => {
const db = getDb();
const userId = db.prepare("INSERT INTO users (username, password_hash, role, active) VALUES ('pa-user','x','user',1)").run().lastInsertRowid;
// $500 balance; a manual $100 payment already dropped it to $400.
const billId = db.prepare(
"INSERT INTO bills (user_id, name, due_day, billing_cycle, cycle_type, expected_amount, current_balance, interest_rate, active) VALUES (?, 'Card', 10, 'monthly', 'monthly', 10000, 40000, 0, 1)",
).run(userId).lastInsertRowid;
const bill = db.prepare('SELECT * FROM bills WHERE id = ?').get(billId);
const manualId = db.prepare(
"INSERT INTO payments (bill_id, amount, paid_date, method, payment_source, balance_delta) VALUES (?, 10000, '2026-06-10', 'manual', 'manual', -10000)",
).run(billId).lastInsertRowid;
// Bank payment for the same bill/cycle (a matched SimpleFIN transaction).
const bankId = db.prepare(
"INSERT INTO payments (bill_id, amount, paid_date, method, payment_source, transaction_id) VALUES (?, 10000, '2026-06-12', 'bank', 'transaction_match', 777)",
).run(billId).lastInsertRowid;
const bankPayment = db.prepare('SELECT * FROM payments WHERE id = ?').get(bankId);
const activeCount = () => db.prepare(
`SELECT COUNT(*) c FROM payments WHERE bill_id = ? AND deleted_at IS NULL AND ${accountingActiveSql()}`,
).get(billId).c;
// Both currently count — the double-count we must resolve.
assert.equal(activeCount(), 2);
const { overridden } = markProvisionalManualPaymentsOverridden(db, bill, bankPayment);
assert.equal(overridden, 1, 'the one provisional manual payment is overridden');
const manualAfter = db.prepare('SELECT * FROM payments WHERE id = ?').get(manualId);
assert.equal(manualAfter.accounting_excluded, 1);
assert.equal(manualAfter.overridden_by_payment_id, bankId);
assert.equal(manualAfter.balance_delta, null, 'overridden payment no longer holds a balance delta');
assert.equal(activeCount(), 1, 'only the bank payment counts now — no double count');
// Its balance effect was reversed: $400 → $500.
assert.equal(db.prepare('SELECT current_balance FROM bills WHERE id = ?').get(billId).current_balance, 50000);
// Bank override removed → the manual payment counts again and re-applies its balance.
const { reactivated } = reactivatePaymentsOverriddenBy(db, bankId);
assert.equal(reactivated, 1);
const manualReactivated = db.prepare('SELECT * FROM payments WHERE id = ?').get(manualId);
assert.equal(manualReactivated.accounting_excluded, 0);
assert.equal(manualReactivated.overridden_by_payment_id, null);
assert.equal(manualReactivated.balance_delta, -10000, 'balance delta re-applied');
assert.equal(db.prepare('SELECT current_balance FROM bills WHERE id = ?').get(billId).current_balance, 40000, '$500 → $400 again');
});
test.after(() => {
closeDb();
for (const s of ['', '-wal', '-shm']) { try { fs.rmSync(dbPath + s); } catch {} }
});

View File

@ -0,0 +1,89 @@
'use strict';
// X1 — POST /payments/quick must be idempotent under a double-clicked / retried
// "pay" and must apply the balance delta atomically with the INSERT. Before the
// fix, a second identical request created a duplicate payment AND double-applied
// the balance drop.
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-quickpay-${process.pid}.sqlite`);
process.env.DB_PATH = dbPath;
const { getDb, closeDb } = require('../db/database');
const router = require('../routes/payments');
function handler(method, routePath) {
const layer = router.stack.find(l => l.route?.path === routePath && l.route.methods[method]);
assert.ok(layer, `${method.toUpperCase()} ${routePath} exists`);
return layer.route.stack[layer.route.stack.length - 1].handle;
}
function call(method, routePath, { userId, params = {}, body = {} } = {}) {
const h = handler(method, routePath);
return new Promise((resolve) => {
const req = { params, body, query: {}, user: { id: userId, role: 'user' } };
const res = {
statusCode: 200,
status(c) { this.statusCode = c; return this; },
json(d) { resolve({ status: this.statusCode, data: d }); },
};
h(req, res);
});
}
let userId, billId;
test.before(() => {
const db = getDb();
userId = db.prepare("INSERT INTO users (username, password_hash, role, active) VALUES ('qp-user','x','user',1)").run().lastInsertRowid;
// $100 expected, $1,000 balance so we can observe the balance drop.
billId = db.prepare(
"INSERT INTO bills (user_id, name, due_day, expected_amount, current_balance, minimum_payment, interest_rate, active) VALUES (?, 'Card', 1, 10000, 100000, 5000, 0, 1)",
).run(userId).lastInsertRowid;
});
test.after(() => {
closeDb();
for (const s of ['', '-wal', '-shm']) { try { fs.unlinkSync(dbPath + s); } catch {} }
});
test('first quick pay creates the payment (201) and drops the balance once', async () => {
const { status, data } = await call('post', '/quick', {
userId, body: { bill_id: billId, amount: 100, paid_date: '2026-07-01' },
});
assert.equal(status, 201);
assert.equal(data.amount, 100, 'payment amount in dollars');
const db = getDb();
const count = db.prepare('SELECT COUNT(*) c FROM payments WHERE bill_id = ? AND deleted_at IS NULL').get(billId).c;
assert.equal(count, 1);
const bal = db.prepare('SELECT current_balance FROM bills WHERE id = ?').get(billId).current_balance;
assert.equal(bal, 90000, '1000.00 100.00 = 900.00 (cents)');
});
test('a duplicate quick pay (same bill+date+amount) is idempotent — no second row, balance unchanged', async () => {
const { status, data } = await call('post', '/quick', {
userId, body: { bill_id: billId, amount: 100, paid_date: '2026-07-01' },
});
assert.equal(status, 200, 'returns the existing payment, not a new 201');
assert.equal(data.amount, 100);
const db = getDb();
const count = db.prepare('SELECT COUNT(*) c FROM payments WHERE bill_id = ? AND deleted_at IS NULL').get(billId).c;
assert.equal(count, 1, 'still exactly one payment');
const bal = db.prepare('SELECT current_balance FROM bills WHERE id = ?').get(billId).current_balance;
assert.equal(bal, 90000, 'balance NOT dropped a second time');
});
test('a different amount on the same day is a legitimate new payment', async () => {
const { status } = await call('post', '/quick', {
userId, body: { bill_id: billId, amount: 50, paid_date: '2026-07-01' },
});
assert.equal(status, 201);
const db = getDb();
const count = db.prepare('SELECT COUNT(*) c FROM payments WHERE bill_id = ? AND deleted_at IS NULL').get(billId).c;
assert.equal(count, 2);
const bal = db.prepare('SELECT current_balance FROM bills WHERE id = ?').get(billId).current_balance;
assert.equal(bal, 85000, '900.00 50.00 = 850.00 (cents)');
});

View File

@ -0,0 +1,158 @@
'use strict';
// T1 — the Tracker's core aggregation (getTracker summary + bank tracking +
// getOverdueCount) had no dedicated tests despite being the densest money-math
// in the app. Covers the occurrence-gating fix (annual/off-month bills must not
// inflate the bank card's unpaid/remaining), summary totals, the bank-mode
// remaining agreement, cents↔dollars integrity, and overdue gating.
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-trackersvc-${process.pid}.sqlite`);
process.env.DB_PATH = dbPath;
const { getDb, closeDb } = require('../db/database');
const { getTracker, getOverdueCount } = require('../services/trackerService');
// Fixed "now" so occurrence gating + statuses are deterministic (mid-June 2026).
const JUNE_20 = new Date(2026, 5, 20, 12, 0, 0);
let db;
function mkUser(name) {
return db.prepare(
"INSERT INTO users (username, password_hash, role, active) VALUES (?, 'x', 'user', 1)",
).run(name).lastInsertRowid;
}
const insertBill = () => db.prepare(`
INSERT INTO bills (user_id, name, due_day, billing_cycle, cycle_type, cycle_day, expected_amount, active)
VALUES (?, ?, ?, ?, ?, ?, ?, 1)
`);
function enableBank(userId, balanceCents) {
const acctId = db.prepare(`
INSERT INTO financial_accounts (user_id, name, org_name, account_type, balance, available_balance, monitored)
VALUES (?, 'Checking', 'Test Bank', 'checking', ?, ?, 1)
`).run(userId, balanceCents, balanceCents).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));
// Pending window 0 so no recent-manual-payment guessing affects the balance.
setSetting.run(userId, 'bank_tracking_pending_days', '0');
}
test.before(() => { db = getDb(); });
test.after(() => {
closeDb();
for (const s of ['', '-wal', '-shm']) { try { fs.rmSync(dbPath + s); } catch {} }
});
test('bank mode: annual/off-month bill does NOT inflate unpaid_this_month or remaining (gating fix)', () => {
const userId = mkUser('t1-gating');
const ins = insertBill();
ins.run(userId, 'Monthly', 15, 'monthly', 'monthly', null, 10000); // $100 due every month
ins.run(userId, 'Annual (Jan)', 1, 'annually', 'annual', '1', 50000); // $500, not due in June
enableBank(userId, 200000); // $2,000 balance
const t = getTracker(userId, { year: 2026, month: 6 }, JUNE_20);
assert.equal(t.bank_tracking.enabled, true);
// Only the $100 monthly bill is due in June; the $500 annual bill is excluded.
assert.equal(t.bank_tracking.unpaid_this_month, 100, 'gated: $100, not $600');
assert.equal(t.bank_tracking.balance, 2000, 'balance in dollars (fromCents)');
assert.equal(t.bank_tracking.remaining, 1900, '2000 100 (not 2000 600)');
// Fix #2: the summary remaining figures agree with the bank card.
assert.equal(t.summary.remaining, 1900);
assert.equal(t.summary.total_remaining, 1900);
});
test('summary totals: gated expected, total_paid, paid_toward_due (cents→dollars)', () => {
const userId = mkUser('t1-summary');
const ins = insertBill();
const billA = ins.run(userId, 'A due 15', 15, 'monthly', 'monthly', null, 10000).lastInsertRowid; // $100
const billB = ins.run(userId, 'B due 1', 1, 'monthly', 'monthly', null, 20000).lastInsertRowid; // $200
ins.run(userId, 'Annual (Jan)', 1, 'annually', 'annual', '1', 90000); // $900, excluded in June
const pay = db.prepare(
"INSERT INTO payments (bill_id, amount, paid_date, payment_source) VALUES (?, ?, ?, 'manual')",
);
pay.run(billA, 10000, '2026-06-15'); // pay A in full ($100)
pay.run(billB, 5000, '2026-06-10'); // partial toward B ($50)
const t = getTracker(userId, { year: 2026, month: 6 }, JUNE_20);
assert.equal(t.summary.total_expected, 300, 'only June bills: 100 + 200 (annual excluded)');
assert.equal(t.summary.total_paid, 150, '100 + 50');
assert.equal(t.summary.paid_toward_due, 150, 'both payments are ≤ their due amount');
assert.equal(t.rows.filter(r => r.name === 'Annual (Jan)').length, 0, 'annual bill absent from rows');
});
test('summary.remaining falls back to outstanding balance when no bank + no starting amounts', () => {
const userId = mkUser('t1-nobank');
insertBill().run(userId, 'Solo due 15', 15, 'monthly', 'monthly', null, 10000); // $100, unpaid
const t = getTracker(userId, { year: 2026, month: 6 }, JUNE_20);
assert.equal(t.bank_tracking.enabled, false);
assert.equal(t.summary.has_starting_amounts, false);
// No bank / no starting amounts → remaining is the outstanding still-owed for
// the current period (June 20 → "15th" period); the $100 bill is unpaid.
assert.equal(t.summary.remaining, 100);
});
test('auto_mark_paid bill creates an autopay payment and drops the balance atomically', () => {
const userId = mkUser('t1-autopay');
// Eligible: autopay on, assumed_paid, due June 1 (past on June 20), auto_mark_paid.
db.prepare(`
INSERT INTO bills (user_id, name, due_day, billing_cycle, cycle_type, cycle_day,
expected_amount, current_balance, autopay_enabled, autodraft_status, auto_mark_paid, active)
VALUES (?, 'Autodraft', 1, 'monthly', 'monthly', NULL, 10000, 50000, 1, 'assumed_paid', 1, 1)
`).run(userId);
const t = getTracker(userId, { year: 2026, month: 6 }, JUNE_20);
const bill = db.prepare("SELECT id, current_balance FROM bills WHERE user_id = ?").get(userId);
const pays = db.prepare("SELECT amount, method, payment_source FROM payments WHERE bill_id = ? AND deleted_at IS NULL").all(bill.id);
assert.equal(pays.length, 1, 'exactly one auto-mark payment created');
assert.equal(pays[0].amount, 10000, '$100 in cents');
assert.equal(pays[0].method, 'autopay');
assert.equal(bill.current_balance, 40000, '500.00 100.00 = 400.00 (balance applied)');
// The row should read as done (paid/autodraft), and running again must not double-charge.
const t2 = getTracker(userId, { year: 2026, month: 6 }, JUNE_20);
const paysAfter = db.prepare("SELECT COUNT(*) c FROM payments WHERE bill_id = ? AND deleted_at IS NULL").get(bill.id).c;
assert.equal(paysAfter, 1, 'second load does not create a duplicate autopay payment');
assert.ok(t.summary.count_paid + t.summary.count_autodraft >= 1);
assert.ok(t2);
});
test('GET /tracker returns a standardized error for an invalid month', async () => {
const router = require('../routes/tracker');
const layer = router.stack.find(l => l.route?.path === '/' && l.route.methods.get);
const handler = layer.route.stack[layer.route.stack.length - 1].handle;
const userId = mkUser('t1-route');
const result = await new Promise((resolve) => {
const req = { query: { year: '2026', month: '13' }, user: { id: userId, role: 'user' } };
const res = {
statusCode: 200,
status(c) { this.statusCode = c; return this; },
json(d) { resolve({ status: this.statusCode, data: d }); },
};
handler(req, res);
});
assert.equal(result.status, 400);
assert.equal(result.data.code, 'VALIDATION_ERROR', 'standardized shape, not a plain {error}');
assert.match(result.data.message, /month/);
});
test('getOverdueCount gates by occurrence and honors paid/skip', () => {
const userId = mkUser('t1-overdue');
const ins = insertBill();
ins.run(userId, 'Overdue monthly', 1, 'monthly', 'monthly', null, 10000); // due June 1, unpaid → overdue on June 20
ins.run(userId, 'Annual (Jan)', 1, 'annually', 'annual', '1', 50000); // not due in June → NOT overdue
const paidBill = ins.run(userId, 'Paid monthly', 1, 'monthly', 'monthly', null, 10000).lastInsertRowid;
db.prepare("INSERT INTO payments (bill_id, amount, paid_date, payment_source) VALUES (?, 10000, '2026-06-02', 'manual')")
.run(paidBill);
const { count, names } = getOverdueCount(userId, JUNE_20);
assert.equal(count, 1, 'only the unpaid monthly bill due June 1 is overdue');
assert.deepEqual(names, ['Overdue monthly']);
});