Compare commits

..

No commits in common. "995f635d35ae4bfefe9fae91d5828f7e190fb904" and "836dbdb9ae6a31486c19a251e062cc0137f0badf" have entirely different histories.

21 changed files with 176 additions and 998 deletions

View File

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

View File

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

View File

@ -1,5 +1,4 @@
import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { useCallback } from 'react';
import { api } from '@/api'; import { api } from '@/api';
// Custom hook for fetching tracker data // Custom hook for fetching tracker data
@ -51,19 +50,3 @@ export function useDriftReport() {
refetchOnWindowFocus: false, 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,18 +60,3 @@ export function formatCentsUSD(cents, { signed = false, dash = false, currency =
if (signed) return (c < 0 ? '-' : '+') + body; if (signed) return (c < 0 ? '-' : '+') + body;
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,21 +1,5 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { formatUSD, formatUSDWhole, formatCentsUSD, validateNonNegativeMoney } from './money'; import { formatUSD, formatUSDWhole, formatCentsUSD } 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)', () => { describe('formatUSD (dollars in)', () => {
it('formats to two decimals with grouping', () => { it('formats to two decimals with grouping', () => {

View File

@ -86,17 +86,10 @@ export function rowEffectiveStatus(row) {
return (isPaidByThreshold && row.status !== 'paid' && row.status !== 'autodraft') ? 'paid' : row.status; 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) { export function rowIsPaid(row) {
const status = rowEffectiveStatus(row); const status = rowEffectiveStatus(row);
if (row.autopay_suggestion && status === 'autodraft') return false; if (row.autopay_suggestion && status === 'autodraft') return false;
return isPaidStatus(status); return status === 'paid' || status === 'autodraft';
} }
export function rowIsDebt(row) { export function rowIsDebt(row) {

View File

@ -16,7 +16,6 @@ import {
import { toast } from 'sonner'; import { toast } from 'sonner';
import { api } from '@/api'; import { api } from '@/api';
import { cn, fmt, fmtDate, todayStr } from '@/lib/utils'; import { cn, fmt, fmtDate, todayStr } from '@/lib/utils';
import { isPaidStatus } from '@/lib/trackerUtils';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
@ -46,7 +45,7 @@ function displayStatus(status) {
} }
function statusTone(status) { function statusTone(status) {
if (isPaidStatus(status)) return 'border-emerald-500/30 bg-emerald-500/15 text-emerald-700 dark:text-emerald-300'; if (status === 'paid' || status === 'autodraft') 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 === '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 === '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'; 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 { ChevronLeft, ChevronRight, AlertCircle, CheckCircle2, Plus, Search, RefreshCw, Landmark, ArrowUpToLine, ArrowUp, ArrowDown, BellOff, EyeOff, Settings2 } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { api } from '@/api.js'; import { api } from '@/api.js';
import { useTracker, useDriftReport, useInvalidateTrackerData } from '@/hooks/useQueries'; import { useTracker, useDriftReport } from '@/hooks/useQueries';
import { useSearchPanelPreference } from '@/hooks/useSearchPanelPreference'; import { useSearchPanelPreference } from '@/hooks/useSearchPanelPreference';
import BillModal from '@/components/BillModal'; import BillModal from '@/components/BillModal';
import { makeBillDraft } from '@/lib/billDrafts'; import { makeBillDraft } from '@/lib/billDrafts';
@ -262,11 +262,7 @@ export default function TrackerPage() {
// Use React Query for data fetching // Use React Query for data fetching
const { data, isLoading: loading, isError, error, refetch, dataUpdatedAt } = useTracker(year, month); const { data, isLoading: loading, isError, error, refetch, dataUpdatedAt } = useTracker(year, month);
const { data: driftData } = useDriftReport(); const { data: driftData, refetch: refetchDrift } = 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]); const driftedIds = useMemo(() => new Set((driftData?.bills ?? []).map(b => b.id)), [driftData]);
useEffect(() => { useEffect(() => {
@ -284,7 +280,7 @@ export default function TrackerPage() {
useEffect(() => { useEffect(() => {
api.settings() api.settings()
.then(settings => setTrackerSettings(prev => ({ ...prev, ...settings }))) .then(settings => setTrackerSettings(prev => ({ ...prev, ...settings })))
.catch(() => toast.error("Couldn't load tracker display settings — showing defaults.")); .catch(() => {});
}, []); }, []);
// Listen for late-attribution events fired by BillModal's single-bill sync // Listen for late-attribution events fired by BillModal's single-bill sync
@ -333,7 +329,7 @@ export default function TrackerPage() {
// Surface late-attribution prompts (payments that just crossed a month boundary) // Surface late-attribution prompts (payments that just crossed a month boundary)
if (attributions.length > 0) setLateAttributions(attributions); if (attributions.length > 0) setLateAttributions(attributions);
invalidateData(); refetch();
} catch (err) { } catch (err) {
toast.error(err.message || 'Bank sync failed'); toast.error(err.message || 'Bank sync failed');
} finally { } finally {
@ -568,7 +564,7 @@ export default function TrackerPage() {
try { try {
await api.reorderBills(payload); await api.reorderBills(payload);
toast.success('Bill order saved'); toast.success('Bill order saved');
invalidateData(); refetch();
} catch (err) { } catch (err) {
setOrderedRows(null); setOrderedRows(null);
toast.error(err.message || 'Failed to save bill order'); toast.error(err.message || 'Failed to save bill order');
@ -866,7 +862,7 @@ export default function TrackerPage() {
rows={rows} rows={rows}
year={year} year={year}
month={month} month={month}
refresh={invalidateData} refresh={refetch}
onPayNow={(row) => setCommandCenterPayRow(row)} onPayNow={(row) => setCommandCenterPayRow(row)}
/> />
)} )}
@ -885,7 +881,7 @@ export default function TrackerPage() {
{!isError && !loading && showDriftInsights && (driftData?.bills?.length ?? 0) > 0 && ( {!isError && !loading && showDriftInsights && (driftData?.bills?.length ?? 0) > 0 && (
<DriftInsightPanel <DriftInsightPanel
driftBills={driftData.bills} driftBills={driftData.bills}
refresh={invalidateData} refresh={() => { refetch(); refetchDrift(); }}
/> />
)} )}
@ -957,8 +953,8 @@ export default function TrackerPage() {
)} )}
{!isError && (first.length > 0 || second.length > 0) && ( {!isError && (first.length > 0 || second.length > 0) && (
<div className="space-y-5"> <div className="space-y-5">
{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} />} {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={invalidateData} onEditBill={handleOpenEditBill} loading={loading} reorderEnabled={reorderEnabled} movingBillId={movingBillId} sortKey={sortKey} sortDir={sortDir} onSort={handleSortHeader} onReorderRows={(next) => handleReorderBucket('15th', 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} />}
</div> </div>
)} )}
@ -970,7 +966,7 @@ export default function TrackerPage() {
initialBill={editBillData.initialBill} initialBill={editBillData.initialBill}
categories={editBillData.categories} categories={editBillData.categories}
onClose={() => setEditBillData(null)} onClose={() => setEditBillData(null)}
onSave={() => invalidateData()} onSave={() => refetch()}
onDuplicate={bill => setEditBillData({ onDuplicate={bill => setEditBillData({
bill: null, bill: null,
initialBill: makeBillDraft(bill, { copy: true, categories: editBillData.categories }), initialBill: makeBillDraft(bill, { copy: true, categories: editBillData.categories }),
@ -985,7 +981,7 @@ export default function TrackerPage() {
onClose={() => setEditStartingOpen(false)} onClose={() => setEditStartingOpen(false)}
year={year} year={year}
month={month} month={month}
onSave={() => { setEditStartingOpen(false); invalidateData(); }} onSave={() => { setEditStartingOpen(false); refetch(); }}
/> />
{/* Income breakdown modal — opens when clicking the bank balance card */} {/* Income breakdown modal — opens when clicking the bank balance card */}
@ -1012,7 +1008,7 @@ export default function TrackerPage() {
const month = new Date(attr.suggested_date + 'T00:00:00').toLocaleDateString('en-US', { month: 'long' }); const month = new Date(attr.suggested_date + 'T00:00:00').toLocaleDateString('en-US', { month: 'long' });
toast.success(`${attr.bill_name} payment moved to ${month}`); toast.success(`${attr.bill_name} payment moved to ${month}`);
setLateAttributions(prev => prev.slice(1)); // dismiss only on success setLateAttributions(prev => prev.slice(1)); // dismiss only on success
invalidateData(); refetch();
} catch (err) { } catch (err) {
toast.error(err.message || 'Failed to reclassify payment — try again'); toast.error(err.message || 'Failed to reclassify payment — try again');
// keep the attribution in queue so user can retry // keep the attribution in queue so user can retry
@ -1033,7 +1029,7 @@ export default function TrackerPage() {
threshold={commandCenterPayRow.actual_amount ?? commandCenterPayRow.expected_amount} threshold={commandCenterPayRow.actual_amount ?? commandCenterPayRow.expected_amount}
defaultPaymentDate={paymentDateForTrackerMonth(year, month, commandCenterPayRow.due_day)} defaultPaymentDate={paymentDateForTrackerMonth(year, month, commandCenterPayRow.due_day)}
onClose={() => setCommandCenterPayRow(null)} onClose={() => setCommandCenterPayRow(null)}
onSaved={() => { setCommandCenterPayRow(null); invalidateData(); }} onSaved={() => { setCommandCenterPayRow(null); refetch(); }}
/> />
)} )}

View File

@ -241,32 +241,15 @@ router.post('/quick', (req, res) => {
const payDate = paymentValidation.normalized.paid_date; const payDate = paymentValidation.normalized.paid_date;
const paySource = paymentValidation.normalized.payment_source; 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 balCalc = computeBalanceDelta(bill, payAmount);
// Atomic: the INSERT and the balance update must both land or neither, so a const result = db.prepare(
// mid-way failure never leaves a payment without its balance adjustment. 'INSERT INTO payments (bill_id, amount, paid_date, method, notes, balance_delta, interest_delta, payment_source) VALUES (?, ?, ?, ?, ?, ?, ?, ?)'
const insertQuickPayment = db.transaction(() => { ).run(bill.id, payAmount, payDate, method || null, notes || null, balCalc?.balance_delta ?? null, balCalc?.interest_delta ?? null, paySource);
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(paymentId))); applyBalanceDelta(db, bill.id, balCalc);
res.status(201).json(serializePayment(db.prepare('SELECT * FROM payments WHERE id = ?').get(result.lastInsertRowid)));
}); });
// POST /api/payments/autopay-suggestions/:billId/confirm // POST /api/payments/autopay-suggestions/:billId/confirm

View File

@ -1,40 +1,22 @@
const express = require('express'); const express = require('express');
const router = express.Router(); const router = express.Router();
const { getTracker, getUpcomingBills, getOverdueCount } = require('../services/trackerService'); const { getTracker, getUpcomingBills, getOverdueCount } = require('../services/trackerService');
const { standardizeError } = require('../middleware/errorFormatter');
// GET /api/tracker/overdue-count — lightweight count for sidebar badge // GET /api/tracker/overdue-count — lightweight count for sidebar badge
router.get('/overdue-count', (req, res) => { router.get('/overdue-count', (req, res) => {
try { res.json(getOverdueCount(req.user.id));
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 // GET /api/tracker?year=2026&month=5
router.get('/', (req, res) => { router.get('/', (req, res) => {
try { const result = getTracker(req.user.id, req.query);
const result = getTracker(req.user.id, req.query); if (result.error) return res.status(result.status || 400).json({ error: result.error });
if (result.error) { res.json(result);
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 // GET /api/tracker/upcoming?days=30
router.get('/upcoming', (req, res) => { router.get('/upcoming', (req, res) => {
try { res.json(getUpcomingBills(req.user.id, req.query));
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; module.exports = router;

View File

@ -3,35 +3,6 @@
const { accountingActiveSql } = require('./paymentAccountingService'); const { accountingActiveSql } = require('./paymentAccountingService');
const { fromCents } = require('../utils/money'); 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 * 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 * of the last 6 months of actual data. Prefers monthly_bill_state.actual_amount
@ -39,8 +10,13 @@ function medianSuggestion(amounts) {
*/ */
function computeAmountSuggestion(db, billId, year, month) { function computeAmountSuggestion(db, billId, year, month) {
const amounts = []; 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( const mbs = db.prepare(
'SELECT actual_amount FROM monthly_bill_state WHERE bill_id = ? AND year = ? AND month = ?' 'SELECT actual_amount FROM monthly_bill_state WHERE bill_id = ? AND year = ? AND month = ?'
).get(billId, y, m); ).get(billId, y, m);
@ -63,75 +39,19 @@ function computeAmountSuggestion(db, billId, year, month) {
if (result.total > 0) amounts.push(result.total); if (result.total > 0) amounts.push(result.total);
} }
return 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',
};
} }
/** 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,32 +141,8 @@ function senderAddress() {
// ── Email templates ─────────────────────────────────────────────────────────── // ── 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 = { const TYPE_META = {
// The `due_3d` key is the early reminder; its lead time is the bill's own due_3d: { subject: (b) => `Reminder: ${b.name} due in 3 days`, urgency: 'upcoming' },
// 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_1d: { subject: (b) => `Reminder: ${b.name} due tomorrow`, urgency: 'soon' },
due_today: { subject: (b) => `Due today: ${b.name}`, urgency: 'today' }, due_today: { subject: (b) => `Due today: ${b.name}`, urgency: 'today' },
overdue: { subject: (b) => `Overdue: ${b.name} hasn't been paid`, urgency: 'overdue' }, overdue: { subject: (b) => `Overdue: ${b.name} hasn't been paid`, urgency: 'overdue' },
@ -193,7 +169,7 @@ function buildEmailHtml(bill, type, dueDate) {
// these message strings (previously raw — an XSS vector via the bill name). // these message strings (previously raw — an XSS vector via the bill name).
const name = esc(bill.name); const name = esc(bill.name);
const messages = { const messages = {
due_3d: `<strong>${name}</strong> is due in ${leadDaysOf(bill)} days.`, due_3d: `<strong>${name}</strong> is due in 3 days.`,
due_1d: `<strong>${name}</strong> is due <strong>tomorrow</strong>.`, due_1d: `<strong>${name}</strong> is due <strong>tomorrow</strong>.`,
due_today: `<strong>${name}</strong> is due <strong>today</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.`, overdue: `<strong>${name}</strong> was due on ${fmt(dueDate)} and has not been marked as paid.`,
@ -383,9 +359,12 @@ async function runNotifications() {
const dueDay = new Date(due.getFullYear(), due.getMonth(), due.getDate()); const dueDay = new Date(due.getFullYear(), due.getMonth(), due.getDate());
const diffDays = Math.round((dueDay - todayDate) / 86400000); const diffDays = Math.round((dueDay - todayDate) / 86400000);
// Determine which type applies today. The early reminder fires at the bill's // Determine which type applies today
// own lead time (reminder_days_before, default 3) rather than a fixed 3 days. let type = null;
const type = reminderTypeFor(bill, diffDays); 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';
if (!type) continue; if (!type) continue;
@ -576,6 +555,3 @@ module.exports = { runNotifications, runDriftNotifications, sendTestEmail, creat
// before it, leaving `_push` undefined → "Send test push" always 500'd). // before it, leaving `_push` undefined → "Send test push" always 500'd).
module.exports._push = { sendNtfy, sendGotify, sendDiscord, sendTelegram, sendTestPush, sendPushToUser, encryptSecret }; module.exports._push = { sendNtfy, sendGotify, sendDiscord, sendTelegram, sendTestPush, sendPushToUser, encryptSecret };
module.exports._email = { buildEmailHtml, esc }; 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,13 +1,5 @@
const { getSetting } = require('../db/database'); 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 = { const WEEKDAY_INDEX = {
sunday: 0, sunday: 0,
monday: 1, monday: 1,
@ -234,7 +226,7 @@ function buildTrackerRow(bill, payments, year, month, todayStr, options = {}) {
const expectedAmount = Number(bill.expected_amount) || 0; const expectedAmount = Number(bill.expected_amount) || 0;
const totalPaid = safePayments.reduce((sum, p) => sum + (Number(p.amount) || 0), 0); const totalPaid = safePayments.reduce((sum, p) => sum + (Number(p.amount) || 0), 0);
const hasPayment = safePayments.length > 0; const hasPayment = safePayments.length > 0;
const isSettled = isPaidStatus(status); const isSettled = status === 'paid' || status === 'autodraft';
const paidTowardDue = Math.min(totalPaid, expectedAmount); const paidTowardDue = Math.min(totalPaid, expectedAmount);
const overpaidAmount = Math.max(totalPaid - expectedAmount, 0); const overpaidAmount = Math.max(totalPaid - expectedAmount, 0);
const rawBalance = expectedAmount - totalPaid; const rawBalance = expectedAmount - totalPaid;
@ -290,9 +282,7 @@ module.exports = {
calculateStatus, calculateStatus,
getCalendarMonthRange, getCalendarMonthRange,
getCycleRange, getCycleRange,
isPaidStatus,
normalizeCycleType, normalizeCycleType,
PAID_STATUSES,
resolveBucket, resolveBucket,
resolveDueDate, resolveDueDate,
resolveGracePeriodDays, resolveGracePeriodDays,

View File

@ -1,10 +1,10 @@
'use strict'; 'use strict';
const { getDb } = require('../db/database'); const { getDb } = require('../db/database');
const { buildTrackerRow, getCycleRange, resolveDueDate, isPaidStatus } = require('./statusService'); const { buildTrackerRow, getCycleRange, resolveDueDate } = require('./statusService');
const { getUserSettings } = require('./userSettings'); const { getUserSettings } = require('./userSettings');
const { computeBalanceDelta, applyBalanceDelta } = require('./billsService'); const { computeBalanceDelta, applyBalanceDelta } = require('./billsService');
const { computeAmountSuggestionsBatch } = require('./amountSuggestionService'); const { computeAmountSuggestion } = require('./amountSuggestionService');
const { accountingActiveSql } = require('./paymentAccountingService'); const { accountingActiveSql } = require('./paymentAccountingService');
const { normalizeMerchant } = require('./subscriptionService'); const { normalizeMerchant } = require('./subscriptionService');
const { localDateString } = require('../utils/dates'); const { localDateString } = require('../utils/dates');
@ -64,11 +64,7 @@ function fetchBankPendingCounts(db, userId, billIds) {
return counts; return counts;
} }
// `gatedUnpaidThisMonth` (dollars) is the occurrence-gated unpaid total the function buildBankTracking(db, userId, year, month) {
// 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 { try {
const settings = getUserSettings(userId); const settings = getUserSettings(userId);
if (settings.bank_tracking_enabled !== 'true') return { enabled: false }; if (settings.bank_tracking_enabled !== 'true') return { enabled: false };
@ -100,37 +96,29 @@ function buildBankTracking(db, userId, year, month, gatedUnpaidThisMonth = null)
`).get(userId, days) `).get(userId, days)
: { pending_total: 0 }; : { pending_total: 0 };
// Fallback (only when the caller didn't pass a gated total, e.g. a direct const { start, end } = getCycleRange(year, month);
// call): the ungated SQL sum. This overcounts off-month bills — prefer the const unpaidRow = db.prepare(`
// gated total from getTracker's rows. SELECT COALESCE(SUM(
let unpaid; CASE WHEN m.actual_amount IS NOT NULL THEN m.actual_amount
if (gatedUnpaidThisMonth != null) { ELSE COALESCE(b.expected_amount, 0) END
unpaid = roundMoney(gatedUnpaidThisMonth); ), 0) AS unpaid_total
} else { FROM bills b
const { start, end } = getCycleRange(year, month); LEFT JOIN monthly_bill_state m ON m.bill_id = b.id AND m.year = ? AND m.month = ?
const unpaidRow = db.prepare(` LEFT JOIN (
SELECT COALESCE(SUM( SELECT bill_id, SUM(amount) AS paid_sum FROM payments
CASE WHEN m.actual_amount IS NOT NULL THEN m.actual_amount WHERE paid_date BETWEEN ? AND ?
ELSE COALESCE(b.expected_amount, 0) END AND deleted_at IS NULL
), 0) AS unpaid_total AND ${accountingActiveSql()}
FROM bills b GROUP BY bill_id
LEFT JOIN monthly_bill_state m ON m.bill_id = b.id AND m.year = ? AND m.month = ? ) pay ON pay.bill_id = b.id
LEFT JOIN ( WHERE b.user_id = ? AND b.active = 1 AND b.deleted_at IS NULL
SELECT bill_id, SUM(amount) AS paid_sum FROM payments AND COALESCE(m.is_skipped, 0) = 0 AND COALESCE(pay.paid_sum, 0) = 0
WHERE paid_date BETWEEN ? AND ? `).get(year, month, start, end, userId);
AND deleted_at IS NULL
AND ${accountingActiveSql()}
GROUP BY bill_id
) pay ON pay.bill_id = b.id
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 = fromCents(account.balance); const balance = roundMoney(account.balance / 100);
const pending = fromCents(pendingRow.pending_total); const pending = fromCents(pendingRow.pending_total);
const effective = roundMoney(balance - pending); const effective = roundMoney(balance - pending);
const unpaid = fromCents(unpaidRow.unpaid_total);
return { return {
enabled: true, enabled: true,
@ -223,46 +211,6 @@ function fetchPaymentsForBillCycle(db, bill, year, month) {
`).all(bill.id, range.start, range.end); `).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) { function fetchPreviousMonthPaid(db, billIds, range) {
if (billIds.length === 0) return {}; if (billIds.length === 0) return {};
const placeholders = billIds.map(() => '?').join(','); const placeholders = billIds.map(() => '?').join(',');
@ -369,7 +317,7 @@ function buildSafeToSpend({ activeRows, available, todayStr, year, month, dayOfM
const daysUntilPayday = Math.max(0, Math.round((Date.parse(nextPayday) - Date.parse(todayStr)) / 86400000)); const daysUntilPayday = Math.max(0, Math.round((Date.parse(nextPayday) - Date.parse(todayStr)) / 86400000));
const stillDueRows = activeRows const stillDueRows = activeRows
.filter(r => !isPaidStatus(r.status)) .filter(r => !['paid', 'autodraft'].includes(r.status))
.filter(r => rowOutstanding(r) > 0) .filter(r => rowOutstanding(r) > 0)
.filter(r => r.due_date < nextPayday) .filter(r => r.due_date < nextPayday)
.sort((a, b) => a.due_date.localeCompare(b.due_date) || String(a.name).localeCompare(String(b.name))); .sort((a, b) => a.due_date.localeCompare(b.due_date) || String(a.name).localeCompare(String(b.name)));
@ -452,34 +400,29 @@ function applyAutopaySuggestions(db, bill, payments, mbs, year, month, todayStr,
} }
const balCalc = computeBalanceDelta(bill, suggestedAmount); const balCalc = computeBalanceDelta(bill, suggestedAmount);
// Atomic: the auto-mark payment INSERT and its balance update must both land const result = db.prepare(`
// or neither. This runs on a GET /tracker, so a mid-way failure would INSERT INTO payments (bill_id, amount, paid_date, method, notes, balance_delta, interest_delta, payment_source)
// otherwise leave a payment without its balance adjustment (or vice versa). VALUES (?, ?, ?, ?, ?, ?, ?, ?)
const insertAutoPayment = db.transaction(() => { `).run(
const r = db.prepare(` bill.id,
INSERT INTO payments (bill_id, amount, paid_date, method, notes, balance_delta, interest_delta, payment_source) suggestedAmount,
VALUES (?, ?, ?, ?, ?, ?, ?, ?) dueDate,
`).run( 'autopay',
bill.id, 'Auto-marked paid on due date',
suggestedAmount, balCalc?.balance_delta ?? null,
dueDate, balCalc?.interest_delta ?? null,
'autopay', 'manual',
'Auto-marked paid on due date', );
balCalc?.balance_delta ?? null,
balCalc?.interest_delta ?? null,
'manual',
);
if (balCalc) applyBalanceDelta(db, bill.id, balCalc);
return r.lastInsertRowid;
});
const paymentId = insertAutoPayment();
if (balCalc) bill.current_balance = balCalc.new_balance; if (balCalc) {
applyBalanceDelta(db, bill.id, balCalc);
bill.current_balance = balCalc.new_balance;
}
payments.push(db.prepare(` payments.push(db.prepare(`
SELECT bill_id, id, amount, paid_date, method, notes, payment_source, transaction_id, created_at, updated_at SELECT bill_id, id, amount, paid_date, method, notes, payment_source, transaction_id, created_at, updated_at
FROM payments FROM payments
WHERE id = ? WHERE id = ?
`).get(paymentId)); `).get(result.lastInsertRowid));
return null; return null;
} }
@ -559,17 +502,13 @@ function getTracker(userId, query = {}, now = new Date()) {
const dismissedSuggestions = fetchDismissedSuggestions(db, userId, billIds, year, month); const dismissedSuggestions = fetchDismissedSuggestions(db, userId, billIds, year, month);
const sparklines = fetchSparklines(db, billIds); const sparklines = fetchSparklines(db, billIds);
const autopayStatsMap = fetchAutopayStats(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 => { const rows = bills.map(bill => {
bill.sparkline = sparklines[bill.id] ?? null; bill.sparkline = sparklines[bill.id] ?? null;
bill.autopay_stats = autopayStatsMap[bill.id] ?? null; bill.autopay_stats = autopayStatsMap[bill.id] ?? null;
if (!resolveDueDate(bill, year, month)) return null; if (!resolveDueDate(bill, year, month)) return null;
const payments = paymentsByBill.get(bill.id) || []; const payments = fetchPaymentsForBillCycle(db, bill, year, month);
const mbs = monthlyStates[bill.id]; const mbs = monthlyStates[bill.id];
const autopaySuggestion = applyAutopaySuggestions( const autopaySuggestion = applyAutopaySuggestions(
db, db,
@ -595,7 +534,7 @@ function getTracker(userId, query = {}, now = new Date()) {
row.snoozed_until = mbs?.snoozed_until ?? null; row.snoozed_until = mbs?.snoozed_until ?? null;
if (autopaySuggestion) row.autopay_suggestion = autopaySuggestion; if (autopaySuggestion) row.autopay_suggestion = autopaySuggestion;
row.previous_month_paid = fromCents(prevMonthPayments[bill.id] || 0); row.previous_month_paid = fromCents(prevMonthPayments[bill.id] || 0);
row.amount_suggestion = amountSuggestions.get(bill.id) ?? null; row.amount_suggestion = computeAmountSuggestion(db, bill.id, year, month);
return row; return row;
}).filter(Boolean); }).filter(Boolean);
@ -620,18 +559,13 @@ function getTracker(userId, query = {}, now = new Date()) {
const activeRemainingPeriod = dayOfMonth < 15 ? '1st' : '15th'; const activeRemainingPeriod = dayOfMonth < 15 ? '1st' : '15th';
const periodRows = activeRows.filter(r => r.bucket === activeRemainingPeriod); const periodRows = activeRows.filter(r => r.bucket === activeRemainingPeriod);
const periodPaidTowardDue = sumMoney(periodRows, rowPaidTowardDue); const periodPaidTowardDue = sumMoney(periodRows, rowPaidTowardDue);
const periodOutstandingBalance = sumMoney(periodRows, rowOutstanding); const periodOutstandingBalance = sumMoney(periodRows, r => Math.max(r.balance || 0, 0));
const periodStartingAmount = activeRemainingPeriod === '1st' const periodStartingAmount = activeRemainingPeriod === '1st'
? (startingAmounts?.first_amount || 0) ? (startingAmounts?.first_amount || 0)
: (startingAmounts?.fifteenth_amount || 0); : (startingAmounts?.fifteenth_amount || 0);
const periodLabel = activeRemainingPeriod === '1st' ? '1st balance' : '15th balance'; const periodLabel = activeRemainingPeriod === '1st' ? '1st balance' : '15th balance';
// Occurrence-gated unpaid total for this month: the still-owed amount across const bankTracking = buildBankTracking(db, userId, year, month);
// 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 bankPendingCounts = bankTracking.enabled ? fetchBankPendingCounts(db, userId, billIds) : {};
const totalStarting = bankTracking.enabled const totalStarting = bankTracking.enabled
? bankTracking.effective_balance ? bankTracking.effective_balance
@ -649,10 +583,10 @@ function getTracker(userId, query = {}, now = new Date()) {
const activeTotalPaid = sumMoney(activeRows, r => r.total_paid); const activeTotalPaid = sumMoney(activeRows, r => r.total_paid);
const activePaidTowardDue = sumMoney(activeRows, rowPaidTowardDue); const activePaidTowardDue = sumMoney(activeRows, rowPaidTowardDue);
const activeTotalExpected = sumMoney(activeRows, rowDueAmount); const activeTotalExpected = sumMoney(activeRows, rowDueAmount);
const activeOutstandingBalance = sumMoney(activeRows, rowOutstanding); const activeOutstandingBalance = sumMoney(activeRows, r => Math.max(r.balance || 0, 0));
const periodBillsTotal = sumMoney(periodRows, rowDueAmount); const periodBillsTotal = sumMoney(periodRows, rowDueAmount);
const periodPaidCount = periodRows.filter(r => isPaidStatus(r.status)).length; const periodPaidCount = periodRows.filter(r => ['paid', 'autodraft'].includes(r.status)).length;
const periodTotalCount = periodRows.length; const periodTotalCount = periodRows.length;
// When bank tracking is on use the effective balance as the period starting point // When bank tracking is on use the effective balance as the period starting point
@ -662,7 +596,7 @@ function getTracker(userId, query = {}, now = new Date()) {
const periodProjected = roundMoney(periodCashStart - periodBillsTotal); const periodProjected = roundMoney(periodCashStart - periodBillsTotal);
const monthBillsTotal = activeTotalExpected; const monthBillsTotal = activeTotalExpected;
const monthPaidCount = activeRows.filter(r => isPaidStatus(r.status)).length; const monthPaidCount = activeRows.filter(r => ['paid', 'autodraft'].includes(r.status)).length;
const monthTotalCount = activeRows.length; const monthTotalCount = activeRows.length;
const monthProjected = roundMoney(totalStarting - monthBillsTotal); const monthProjected = roundMoney(totalStarting - monthBillsTotal);
@ -715,17 +649,8 @@ function getTracker(userId, query = {}, now = new Date()) {
has_starting_amounts: hasStartingAmounts, has_starting_amounts: hasStartingAmounts,
total_paid: activeTotalPaid, total_paid: activeTotalPaid,
paid_toward_due: activePaidTowardDue, paid_toward_due: activePaidTowardDue,
// In bank mode the effective bank balance is a single pool (no per-period remaining: roundMoney(hasStartingAmounts ? periodStartingAmount - periodPaidTowardDue : periodOutstandingBalance),
// split), so both remaining figures use the bank card's own remaining total_remaining: roundMoney(hasStartingAmounts ? totalStarting - activePaidTowardDue : activeOutstandingBalance),
// (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_period: activeRemainingPeriod,
remaining_label: periodLabel, remaining_label: periodLabel,
remaining_hint: hasStartingAmounts remaining_hint: hasStartingAmounts

View File

@ -1,74 +0,0 @@
'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

@ -1,68 +0,0 @@
'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

@ -1,48 +0,0 @@
'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

@ -1,89 +0,0 @@
'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

@ -1,89 +0,0 @@
'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

@ -1,158 +0,0 @@
'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']);
});