Compare commits
10 Commits
836dbdb9ae
...
995f635d35
| Author | SHA1 | Date |
|---|---|---|
|
|
995f635d35 | |
|
|
d92cc38116 | |
|
|
e9c5e4d1d3 | |
|
|
9f4b53d37a | |
|
|
73631ab812 | |
|
|
c91c97ef41 | |
|
|
10e159352a | |
|
|
ad1f5bebf6 | |
|
|
4a38cc8614 | |
|
|
d689ff6e68 |
22
HISTORY.md
22
HISTORY.md
|
|
@ -1,6 +1,28 @@
|
|||
# Bill Tracker — Changelog
|
||||
## v0.41.0
|
||||
|
||||
### 🧪 Money-service test coverage
|
||||
|
||||
- **[Tests] Covered two untested money-critical services** — the manual-vs-bank payment source-of-truth (`paymentAccountingService`) and the analytics month-window math (`analyticsService`) had no dedicated tests. Added `tests/paymentAccountingService.test.js` (bank-backed predicate, the accounting-active SQL fragment, and the core invariant: a bank payment overrides a provisional manual payment so it isn't double-counted, restores the balance, and the manual payment counts again with its balance re-applied when the override is removed) and `tests/analyticsService.test.js` (month key/label/addMonths/monthEndDate/buildMonths with year-boundary + leap-year edges, and `validateSummaryQuery` defaults/range errors). (Tracker X2)
|
||||
|
||||
### ⚡ Tracker performance
|
||||
|
||||
- **[Tracker] Killed the getTracker N+1 (was ~2–3 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 70–450 queries for a 35-bill account on every Tracker load. Now one query fetches all bills' cycle payments (grouped in JS by each bill's own range) and two queries compute all amount suggestions (`computeAmountSuggestionsBatch`), replacing the per-bill loops. Behavior-preserving — `tests/amountSuggestionService.test.js` pins the batched suggestion to be byte-identical to the per-bill function, and the `trackerService` tests still pass unchanged. (Tracker P1)
|
||||
|
||||
### 🧹 Tracker cleanup
|
||||
|
||||
- **[Tracker] Consolidated the "paid or autodraft = done" check + tidied a few spots** — the settled-status test (`status === 'paid' || status === 'autodraft'`) was copy-pasted across the server and client; added a single `isPaidStatus(status)` (+ `PAID_STATUSES`) to `services/statusService.js` and a matching `isPaidStatus` to `client/lib/trackerUtils.js`, and routed the unambiguous call sites through it (`trackerService`, `StatusBadge`, `CalendarPage`, and `rowIsPaid`) — the intentionally paid-*only* counts (`count_paid`, `count_autodraft`) are left distinct. Also replaced two inline `Math.max(r.balance || 0, 0)` sums in `getTracker` with the existing `rowOutstanding` helper, and gave the Tracker's display-settings load a quiet toast on failure instead of a silent swallow. Behavior-preserving; full server + client suites green. (Tracker T5)
|
||||
|
||||
### 🐛 Tracker & bill-modal hardening
|
||||
|
||||
- **[Bill modal] Correctness + error/toast + validator cleanup** — several small fixes in `BillModal`: `handleBlur` used positional guessing that defaulted every unmapped field to `interestRate`'s value (latent, masked by inline validators) — now takes the field value explicitly; the three copy-pasted money validators collapsed into one shared `validateNonNegativeMoney(val, label)` in `client/lib/money.js` (the expected-amount message also went from "positive number" to "non-negative", since 0 is allowed); the save action's duplicate due-day/interest-rate re-validation (which re-checked with toasts what `validateForm` already field-validated) was removed; the save/deactivate/verify-autopay `toast.error(err.message)` calls got fallbacks so a missing message never shows "undefined"; and the save toasts now name the bill ("Rent added" / "Rent updated"). Tests: `client/lib/money.test.js` covers the shared validator. (Tracker BM1)
|
||||
- **[Tracker] Route error handling + autopay write atomicity** — `routes/tracker.js` had no try/catch and returned a plain `{error}` shape unlike the rest of the API; its three GET handlers now wrap in try/catch and return `standardizeError` (`{error, message, field, code}`), including the invalid-month validation path. Separately, `applyAutopaySuggestions` (which runs on a `GET /tracker`) inserted the auto-mark-paid payment and applied the balance delta as two un-transactional steps — a mid-way failure could leave a payment without its balance adjustment. Wrapped the INSERT + `applyBalanceDelta` in a single `db.transaction`. Covered by new cases in `tests/trackerService.test.js` (auto-mark creates one payment + drops the balance, idempotent on re-load; route returns the standardized error). (Tracker T2)
|
||||
- **[Tracker] Sidebar overdue badge (and drift/bills) went stale after row actions** — the app never called `queryClient.invalidateQueries`; a Tracker mutation only `refetch()`'d the single tracker query passed down as `refresh`. So after paying/skipping/editing a bill, the sidebar **overdue badge** (`['overdue-count']`, 2-minute staleTime) kept its old number for up to two minutes — you could clear your last overdue bill and still see "3" — and the drift report / bills list didn't update either. Added a `useInvalidateTrackerData()` hook (invalidates `tracker` + `overdue-count` + `drift-report` + `bills`) and wired it in place of the bare `refetch` handed to the rows, `BillModal.onSave`, bank-sync, reorder, and the payment/late-attribution handlers, so the whole shell updates live. (Tracker X3)
|
||||
- **[Notifications] "Reminder days before" was a dead setting — the notifier ignored it** — every bill has a `reminder_days_before` column (default 3) and the bill modal exposed a "Reminder Days" control for subscriptions, but `services/notificationService.js` used a hard-coded schedule (early reminder always at exactly 3 days out) and never read the column. A user who set "remind me 7 days before" still only got the fixed 3-day/1-day/today reminders. The early reminder now fires at the bill's own `reminder_days_before` lead (only when ≥ 2 days, so it never collides with the 1-day/same-day reminders), and the email subject + body say "due in N days" using that value. The lead-time selection was pulled into a pure, exported `reminderTypeFor(bill, diffDays)` so it's unit-tested directly (`tests/notificationLeadTime.test.js`) — default 3 stays backwards-compatible. The **"Reminder Days Before" control now shows for every bill** (not just subscriptions), and saving a non-subscription bill no longer clobbers the column back to 3. (Tracker BM3)
|
||||
- **[Bill modal/SimpleFIN] Importing bank payments didn't refresh the payment list or the Tracker** — the two flows in the bill modal that *create* payments — **Sync** (`syncBillSimplefinPayments`) and a **merchant-rule historical import** (`onRulesChanged` → `importHistoricalPayments`) — only reloaded the linked-transactions list, unlike the unmatch handlers which correctly reload payments *and* linked transactions *and* call `onSave`. So after importing, say, 3 payments from bank history, the modal's Payment History stayed stale and the Tracker row behind it kept showing "due/overdue" even though the bill was now covered — until you closed and reopened. Both paths now `await Promise.all([loadPayments(), loadLinkedTransactions()])` then `onSave?.()`, matching the unmatch pattern, so imported payments appear immediately and the Tracker updates live. (The SimpleFIN *search/preview/candidate* flow was already correct.) (Tracker BM4)
|
||||
- **[Tracker/SimpleFIN] Bank card's "unpaid this month" and "remaining" over-counted off-month bills** — `buildBankTracking` (`services/trackerService.js`) summed `expected_amount` for *all* active unpaid bills via SQL with no occurrence gate, so an annual or off-month quarterly bill inflated `unpaid_this_month` (and therefore the bank `remaining`) even though the Tracker rows beside it correctly excluded it — the same class of bug as QA-B5-02, still live on the bank path. `getTracker` now derives the unpaid total from the already-gated rows (via `resolveDueDate`), netting partial payments, and passes it into `buildBankTracking`. Also made `summary.remaining` / `total_remaining` use the bank card's own remaining when bank tracking is on (they previously used manual starting-amount math even in bank mode, disagreeing with safe-to-spend), and switched a stray `balance / 100` to `fromCents`. New test file `tests/trackerService.test.js` covers the gating fix, summary totals, the bank-mode remaining agreement, cents↔dollars integrity, and `getOverdueCount` gating — the dense Tracker aggregation had no dedicated tests before. (Tracker T1)
|
||||
- **[Payments] Quick-pay could create duplicate payments and double-drop the balance** — `POST /api/payments/quick` (the one-click "pay" behind every Tracker row) had **no duplicate guard** and its INSERT + balance update weren't atomic, unlike `POST /api/payments/bulk`. A double-click, a retry, or two open tabs made a *second* payment for the same bill/date/amount and applied the balance drop twice; a failure between the INSERT and the balance write left a payment with no balance adjustment. Quick-pay now checks the same `bill_id + paid_date + amount` composite key (returning the existing payment idempotently, HTTP 200) and wraps the INSERT + `applyBalanceDelta` in a single `db.transaction`. A different amount on the same day is still a legitimate new payment. Test: `tests/paymentsQuickRoute.test.js`. (Tracker X1)
|
||||
|
||||
### ✨ Data page overhaul
|
||||
|
||||
- **[Data] Redesigned the Data page (modern, goal-oriented, 2026)** — replaced the dense tabbed/collapsible-card layout with a settings-style **two-pane** shell: a sticky goal-nav (Bank sync · Transactions · Import · Export & backups) beside one active pane, collapsing to a segmented control on mobile. Adds a **connection hero** with five distinct states (loading / server-disabled / error+retry / not-connected / connected±needs-attention) so a network blip is never mistaken for "not connected" and a server without SimpleFIN never shows a dead Connect button; **`?section=` deep-linking** (URL source of truth, back-button friendly, migrates the old tab key); plain-language titles + icons; a live **"N to review"** badge and **health dot** on the nav; at-a-glance transaction count + "syncs automatically" reassurance; **lazy-loaded** import panes; reduced-motion-aware transitions; and command-palette section links. `/data` is now covered by the authed axe sweep (zero critical/serious). Shell only — every section component (and the SimpleFIN sync buttons) is reused unchanged. `SectionCard` chrome modernized (icon chip, sentence-case titles).
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useActionState, useEffect, useState } from 'react';
|
||||
import { ChevronDown, Copy, Layers, Link2, Link2Off, Loader2, Pencil, Plus, RefreshCw, Trash2 } from 'lucide-react';
|
||||
import { formatCentsUSD } from '@/lib/money';
|
||||
import { formatCentsUSD, validateNonNegativeMoney } from '@/lib/money';
|
||||
import { toast } from 'sonner';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
|
|
@ -248,12 +248,10 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa
|
|||
return '';
|
||||
};
|
||||
|
||||
const validateExpectedAmount = (val) => {
|
||||
if (val === '' || val === null) return '';
|
||||
const num = parseFloat(val);
|
||||
if (isNaN(num) || num < 0) return 'Amount must be a positive number';
|
||||
return '';
|
||||
};
|
||||
// Money fields share one non-negative validator (blank allowed, 0 allowed).
|
||||
const validateExpectedAmount = (val) => validateNonNegativeMoney(val, 'Amount');
|
||||
const validateCurrentBalance = (val) => validateNonNegativeMoney(val, 'Balance');
|
||||
const validateMinimumPayment = (val) => validateNonNegativeMoney(val, 'Min payment');
|
||||
|
||||
const validateInterestRate = (val) => {
|
||||
if (val === '' || val === null) return '';
|
||||
|
|
@ -263,20 +261,6 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa
|
|||
return '';
|
||||
};
|
||||
|
||||
const validateCurrentBalance = (val) => {
|
||||
if (val === '' || val === null) return '';
|
||||
const num = parseFloat(val);
|
||||
if (isNaN(num) || num < 0) return 'Balance must be a non-negative number';
|
||||
return '';
|
||||
};
|
||||
|
||||
const validateMinimumPayment = (val) => {
|
||||
if (val === '' || val === null) return '';
|
||||
const num = parseFloat(val);
|
||||
if (isNaN(num) || num < 0) return 'Min payment must be a non-negative number';
|
||||
return '';
|
||||
};
|
||||
|
||||
const validateForm = () => {
|
||||
const newErrors = {
|
||||
name: validateName(name),
|
||||
|
|
@ -290,13 +274,11 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa
|
|||
return Object.values(newErrors).every(err => err === '');
|
||||
};
|
||||
|
||||
const handleBlur = (field, validator) => {
|
||||
setErrors(prev => ({ ...prev, [field]: validator(
|
||||
field === 'name' ? name :
|
||||
field === 'dueDay' ? dueDay :
|
||||
field === 'expectedAmount' ? expectedAmount :
|
||||
interestRate
|
||||
)}));
|
||||
// Value passed explicitly so this never falls through to the wrong field's
|
||||
// state (the old positional guessing defaulted every unmapped field to
|
||||
// interestRate).
|
||||
const handleBlur = (field, value, validator) => {
|
||||
setErrors(prev => ({ ...prev, [field]: validator(value) }));
|
||||
};
|
||||
|
||||
const handleCategoryChange = (val) => {
|
||||
|
|
@ -325,7 +307,7 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa
|
|||
setLocalVerifiedAt(new Date(res.autopay_verified_at));
|
||||
toast.success('Autopay marked as verified.');
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
toast.error(err.message || 'Failed to verify autopay.');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -530,18 +512,11 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa
|
|||
return;
|
||||
}
|
||||
|
||||
// validateForm() already enforced due-day (1–31) and interest-rate (0–100)
|
||||
// ranges and blocked the save with field errors, so these are just parses.
|
||||
const parsedDueDay = Number(dueDay);
|
||||
if (!Number.isInteger(parsedDueDay) || parsedDueDay < 1 || parsedDueDay > 31) {
|
||||
toast.error('Due day must be a whole number from 1 to 31.');
|
||||
return;
|
||||
}
|
||||
|
||||
const trimmedInterestRate = interestRate.trim();
|
||||
const parsedInterestRate = trimmedInterestRate === '' ? null : Number(trimmedInterestRate);
|
||||
if (parsedInterestRate !== null && (!Number.isFinite(parsedInterestRate) || parsedInterestRate < 0 || parsedInterestRate > 100)) {
|
||||
toast.error('Interest rate must be blank or a number from 0 to 100.');
|
||||
return;
|
||||
}
|
||||
|
||||
const data = {
|
||||
source_bill_id: sourceBill?.source_bill_id,
|
||||
|
|
@ -559,7 +534,7 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa
|
|||
auto_mark_paid: canAutoMarkPaid && autoMarkPaid,
|
||||
is_subscription: isSubscription,
|
||||
subscription_type: isSubscription ? subscriptionType : null,
|
||||
reminder_days_before: isSubscription ? parseInt(reminderDaysBefore || '3', 10) : 3,
|
||||
reminder_days_before: parseInt(reminderDaysBefore || '3', 10),
|
||||
subscription_source: sourceBill?.subscription_source || 'manual',
|
||||
subscription_detected_at: sourceBill?.subscription_detected_at,
|
||||
has_2fa: has2fa,
|
||||
|
|
@ -582,10 +557,10 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa
|
|||
} else {
|
||||
savedBill = await api.createBill(data);
|
||||
}
|
||||
toast.success('Bill added');
|
||||
toast.success(`${data.name} added`);
|
||||
} else {
|
||||
savedBill = await api.updateBill(bill.id, data);
|
||||
toast.success('Bill updated');
|
||||
toast.success(`${data.name} updated`);
|
||||
}
|
||||
if (saveTemplate) {
|
||||
const safeTemplateName = templateName.trim() || data.name;
|
||||
|
|
@ -595,7 +570,7 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa
|
|||
onSave(savedBill);
|
||||
setDialogOpen(false);
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
toast.error(err.message || 'Failed to save bill.');
|
||||
}
|
||||
}, null);
|
||||
|
||||
|
|
@ -609,7 +584,7 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa
|
|||
onSave?.();
|
||||
setDialogOpen(false);
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
toast.error(err.message || 'Failed to update bill.');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -638,7 +613,7 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa
|
|||
setName(e.target.value);
|
||||
setTimeout(() => setErrors(prev => ({ ...prev, name: validateName(e.target.value) })), 300);
|
||||
}}
|
||||
onBlur={() => handleBlur('name', validateName)}
|
||||
onBlur={() => handleBlur('name', name, validateName)}
|
||||
required
|
||||
/>
|
||||
{errors.name && (
|
||||
|
|
@ -673,7 +648,7 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa
|
|||
setDueDay(e.target.value);
|
||||
setTimeout(() => setErrors(prev => ({ ...prev, dueDay: validateDueDay(e.target.value) })), 300);
|
||||
}}
|
||||
onBlur={() => handleBlur('dueDay', validateDueDay)}
|
||||
onBlur={() => handleBlur('dueDay', dueDay, validateDueDay)}
|
||||
/>
|
||||
{errors.dueDay && (
|
||||
<span className="text-[10px] text-red-500 font-medium">{errors.dueDay}</span>
|
||||
|
|
@ -694,7 +669,7 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa
|
|||
setExpected(e.target.value);
|
||||
setTimeout(() => setErrors(prev => ({ ...prev, expectedAmount: validateExpectedAmount(e.target.value) })), 300);
|
||||
}}
|
||||
onBlur={() => handleBlur('expectedAmount', validateExpectedAmount)}
|
||||
onBlur={() => handleBlur('expectedAmount', expectedAmount, validateExpectedAmount)}
|
||||
/>
|
||||
{errors.expectedAmount && (
|
||||
<span className="text-[10px] text-red-500 font-medium">{errors.expectedAmount}</span>
|
||||
|
|
@ -788,8 +763,8 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa
|
|||
</label>
|
||||
|
||||
{isSubscription && (
|
||||
<div className="mt-3 grid gap-3 border-t border-border/40 pt-3 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<div className="mt-3 border-t border-border/40 pt-3">
|
||||
<div className="space-y-1.5 sm:max-w-[50%]">
|
||||
<Label className="text-xs uppercase tracking-wider text-muted-foreground">Subscription Type</Label>
|
||||
<Select value={subscriptionType} onValueChange={setSubscriptionType}>
|
||||
<SelectTrigger className={cn(inp, 'w-full')}>
|
||||
|
|
@ -802,23 +777,30 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa
|
|||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs uppercase tracking-wider text-muted-foreground">Reminder Days</Label>
|
||||
<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 */}
|
||||
</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 */}
|
||||
<div className="col-span-2">
|
||||
<div className="flex items-center gap-3">
|
||||
|
|
@ -860,7 +842,7 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa
|
|||
setInterestRate(e.target.value);
|
||||
setTimeout(() => setErrors(prev => ({ ...prev, interestRate: validateInterestRate(e.target.value) })), 300);
|
||||
}}
|
||||
onBlur={() => handleBlur('interestRate', validateInterestRate)}
|
||||
onBlur={() => handleBlur('interestRate', interestRate, validateInterestRate)}
|
||||
/>
|
||||
{errors.interestRate && (
|
||||
<span className="text-[10px] text-red-500 font-medium">{errors.interestRate}</span>
|
||||
|
|
@ -879,7 +861,7 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa
|
|||
setCurrentBalance(e.target.value);
|
||||
setTimeout(() => setErrors(prev => ({ ...prev, currentBalance: validateCurrentBalance(e.target.value) })), 300);
|
||||
}}
|
||||
onBlur={() => setErrors(prev => ({ ...prev, currentBalance: validateCurrentBalance(currentBalance) }))}
|
||||
onBlur={() => handleBlur('currentBalance', currentBalance, validateCurrentBalance)}
|
||||
/>
|
||||
{errors.currentBalance && (
|
||||
<span className="text-[10px] text-red-500 font-medium">{errors.currentBalance}</span>
|
||||
|
|
@ -898,7 +880,7 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa
|
|||
setMinimumPayment(e.target.value);
|
||||
setTimeout(() => setErrors(prev => ({ ...prev, minimumPayment: validateMinimumPayment(e.target.value) })), 300);
|
||||
}}
|
||||
onBlur={() => setErrors(prev => ({ ...prev, minimumPayment: validateMinimumPayment(minimumPayment) }))}
|
||||
onBlur={() => handleBlur('minimumPayment', minimumPayment, validateMinimumPayment)}
|
||||
/>
|
||||
{errors.minimumPayment && (
|
||||
<span className="text-[10px] text-red-500 font-medium">{errors.minimumPayment}</span>
|
||||
|
|
@ -1211,7 +1193,11 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa
|
|||
const result = await api.syncBillSimplefinPayments(sourceBill.id);
|
||||
if (result.added > 0) {
|
||||
toast.success(`${result.added} payment${result.added !== 1 ? 's' : ''} imported from bank history.`);
|
||||
loadLinkedTransactions?.();
|
||||
// Imported payments must refresh the payment list AND the
|
||||
// Tracker behind the modal (the row may now be covered) —
|
||||
// same as the unmatch handlers.
|
||||
await Promise.all([loadPayments(), loadLinkedTransactions()]);
|
||||
onSave?.();
|
||||
} else {
|
||||
toast.info('No new matching transactions found.');
|
||||
}
|
||||
|
|
@ -1238,9 +1224,13 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa
|
|||
<BillMerchantRules
|
||||
billId={sourceBill?.id}
|
||||
billName={sourceBill?.name}
|
||||
onRulesChanged={() => {
|
||||
onRulesChanged={async () => {
|
||||
setLocalHasRules(true);
|
||||
loadLinkedTransactions?.();
|
||||
// A historical import (fired after adding a rule) creates
|
||||
// payments, so refresh the payment list AND the Tracker too —
|
||||
// not just the linked transactions.
|
||||
await Promise.all([loadPayments(), loadLinkedTransactions()]);
|
||||
onSave?.();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import React, { useMemo } from 'react';
|
||||
import { Loader2, AlertCircle } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { STATUS_META } from '@/lib/trackerUtils';
|
||||
import { STATUS_META, isPaidStatus } from '@/lib/trackerUtils';
|
||||
|
||||
export const StatusBadge = React.memo(function StatusBadge({ status, clickable, onClick, loading }) {
|
||||
const meta = useMemo(() => STATUS_META[status] || STATUS_META.upcoming, [status]);
|
||||
|
|
@ -26,7 +26,7 @@ export const StatusBadge = React.memo(function StatusBadge({ status, clickable,
|
|||
loading && 'opacity-60 cursor-wait',
|
||||
meta.cls,
|
||||
)}
|
||||
title={canClick ? (status === 'paid' || status === 'autodraft' ? 'Click to mark unpaid' : 'Click to mark paid') : undefined}
|
||||
title={canClick ? (isPaidStatus(status) ? 'Click to mark unpaid' : 'Click to mark paid') : undefined}
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useCallback } from 'react';
|
||||
import { api } from '@/api';
|
||||
|
||||
// Custom hook for fetching tracker data
|
||||
|
|
@ -50,3 +51,19 @@ export function useDriftReport() {
|
|||
refetchOnWindowFocus: false,
|
||||
});
|
||||
}
|
||||
|
||||
// A single invalidation for every query a bill mutation can affect. Previously a
|
||||
// row action only refetch()'d the one tracker query passed down as `refresh`, so
|
||||
// the sidebar overdue badge (['overdue-count'], 2-min staleTime), the drift
|
||||
// report and the bills list stayed stale after paying/skipping/editing a bill —
|
||||
// e.g. clearing your last overdue bill still showed "3" on the badge for minutes.
|
||||
// Hand this to rows / BillModal.onSave / payment + sync handlers so the whole
|
||||
// shell updates live. Returns a stable callback safe to use as an effect dep.
|
||||
export function useInvalidateTrackerData() {
|
||||
const queryClient = useQueryClient();
|
||||
return useCallback(() => {
|
||||
for (const key of [['tracker'], ['overdue-count'], ['drift-report'], ['bills']]) {
|
||||
queryClient.invalidateQueries({ queryKey: key });
|
||||
}
|
||||
}, [queryClient]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -60,3 +60,18 @@ export function formatCentsUSD(cents, { signed = false, dash = false, currency =
|
|||
if (signed) return (c < 0 ? '-' : '+') + body;
|
||||
return (c < 0 ? '-' : '') + body;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared form validator for a non-negative money field (dollars). Blank is
|
||||
* allowed (returns ''); otherwise the value must parse to a number ≥ 0. Returns
|
||||
* '' when valid, or an error string labelled for the field. Zero is allowed —
|
||||
* these are non-negative, not strictly-positive, amounts.
|
||||
* @param {number|string|null} val
|
||||
* @param {string} [label] — field name for the message (e.g. "Amount", "Balance")
|
||||
*/
|
||||
export function validateNonNegativeMoney(val, label = 'Amount') {
|
||||
if (val === '' || val === null || val === undefined) return '';
|
||||
const num = parseFloat(val);
|
||||
if (isNaN(num) || num < 0) return `${label} must be a non-negative number`;
|
||||
return '';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,21 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { formatUSD, formatUSDWhole, formatCentsUSD } from './money';
|
||||
import { formatUSD, formatUSDWhole, formatCentsUSD, validateNonNegativeMoney } from './money';
|
||||
|
||||
describe('validateNonNegativeMoney', () => {
|
||||
it('allows blank and zero (non-negative, not strictly positive)', () => {
|
||||
expect(validateNonNegativeMoney('')).toBe('');
|
||||
expect(validateNonNegativeMoney(null)).toBe('');
|
||||
expect(validateNonNegativeMoney(undefined)).toBe('');
|
||||
expect(validateNonNegativeMoney('0')).toBe('');
|
||||
expect(validateNonNegativeMoney('12.50')).toBe('');
|
||||
});
|
||||
|
||||
it('rejects negatives and non-numeric with a labelled message', () => {
|
||||
expect(validateNonNegativeMoney('-1', 'Amount')).toBe('Amount must be a non-negative number');
|
||||
expect(validateNonNegativeMoney('abc', 'Balance')).toBe('Balance must be a non-negative number');
|
||||
expect(validateNonNegativeMoney('-0.01', 'Min payment')).toBe('Min payment must be a non-negative number');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatUSD (dollars in)', () => {
|
||||
it('formats to two decimals with grouping', () => {
|
||||
|
|
|
|||
|
|
@ -86,10 +86,17 @@ export function rowEffectiveStatus(row) {
|
|||
return (isPaidByThreshold && row.status !== 'paid' && row.status !== 'autodraft') ? 'paid' : row.status;
|
||||
}
|
||||
|
||||
// A bill's month is "settled" when its status is paid or autodraft. Mirrors the
|
||||
// server's statusService.isPaidStatus — the single low-level check the various
|
||||
// row/badge/calendar components share.
|
||||
export function isPaidStatus(status) {
|
||||
return status === 'paid' || status === 'autodraft';
|
||||
}
|
||||
|
||||
export function rowIsPaid(row) {
|
||||
const status = rowEffectiveStatus(row);
|
||||
if (row.autopay_suggestion && status === 'autodraft') return false;
|
||||
return status === 'paid' || status === 'autodraft';
|
||||
return isPaidStatus(status);
|
||||
}
|
||||
|
||||
export function rowIsDebt(row) {
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import {
|
|||
import { toast } from 'sonner';
|
||||
import { api } from '@/api';
|
||||
import { cn, fmt, fmtDate, todayStr } from '@/lib/utils';
|
||||
import { isPaidStatus } from '@/lib/trackerUtils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||
|
|
@ -45,7 +46,7 @@ function displayStatus(status) {
|
|||
}
|
||||
|
||||
function statusTone(status) {
|
||||
if (status === 'paid' || status === 'autodraft') return 'border-emerald-500/30 bg-emerald-500/15 text-emerald-700 dark:text-emerald-300';
|
||||
if (isPaidStatus(status)) return 'border-emerald-500/30 bg-emerald-500/15 text-emerald-700 dark:text-emerald-300';
|
||||
if (status === 'skipped') return 'border-border bg-muted/80 text-muted-foreground';
|
||||
if (status === 'late') return 'border-orange-400/60 bg-orange-500/25 text-orange-800 shadow-sm shadow-orange-950/10 dark:text-orange-100';
|
||||
if (status === 'missed') return 'border-rose-400/60 bg-rose-500/30 text-rose-800 shadow-sm shadow-rose-950/10 dark:text-rose-100';
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { useSearchParams } from 'react-router-dom';
|
|||
import { ChevronLeft, ChevronRight, AlertCircle, CheckCircle2, Plus, Search, RefreshCw, Landmark, ArrowUpToLine, ArrowUp, ArrowDown, BellOff, EyeOff, Settings2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { api } from '@/api.js';
|
||||
import { useTracker, useDriftReport } from '@/hooks/useQueries';
|
||||
import { useTracker, useDriftReport, useInvalidateTrackerData } from '@/hooks/useQueries';
|
||||
import { useSearchPanelPreference } from '@/hooks/useSearchPanelPreference';
|
||||
import BillModal from '@/components/BillModal';
|
||||
import { makeBillDraft } from '@/lib/billDrafts';
|
||||
|
|
@ -262,7 +262,11 @@ export default function TrackerPage() {
|
|||
|
||||
// Use React Query for data fetching
|
||||
const { data, isLoading: loading, isError, error, refetch, dataUpdatedAt } = useTracker(year, month);
|
||||
const { data: driftData, refetch: refetchDrift } = useDriftReport();
|
||||
const { data: driftData } = useDriftReport();
|
||||
// Live cross-query refresh: a bill mutation invalidates the tracker AND the
|
||||
// overdue badge / drift report / bills list so the whole shell updates, not
|
||||
// just the one tracker query. Use this everywhere a row/payment mutation lands.
|
||||
const invalidateData = useInvalidateTrackerData();
|
||||
const driftedIds = useMemo(() => new Set((driftData?.bills ?? []).map(b => b.id)), [driftData]);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -280,7 +284,7 @@ export default function TrackerPage() {
|
|||
useEffect(() => {
|
||||
api.settings()
|
||||
.then(settings => setTrackerSettings(prev => ({ ...prev, ...settings })))
|
||||
.catch(() => {});
|
||||
.catch(() => toast.error("Couldn't load tracker display settings — showing defaults."));
|
||||
}, []);
|
||||
|
||||
// Listen for late-attribution events fired by BillModal's single-bill sync
|
||||
|
|
@ -329,7 +333,7 @@ export default function TrackerPage() {
|
|||
// Surface late-attribution prompts (payments that just crossed a month boundary)
|
||||
if (attributions.length > 0) setLateAttributions(attributions);
|
||||
|
||||
refetch();
|
||||
invalidateData();
|
||||
} catch (err) {
|
||||
toast.error(err.message || 'Bank sync failed');
|
||||
} finally {
|
||||
|
|
@ -564,7 +568,7 @@ export default function TrackerPage() {
|
|||
try {
|
||||
await api.reorderBills(payload);
|
||||
toast.success('Bill order saved');
|
||||
refetch();
|
||||
invalidateData();
|
||||
} catch (err) {
|
||||
setOrderedRows(null);
|
||||
toast.error(err.message || 'Failed to save bill order');
|
||||
|
|
@ -862,7 +866,7 @@ export default function TrackerPage() {
|
|||
rows={rows}
|
||||
year={year}
|
||||
month={month}
|
||||
refresh={refetch}
|
||||
refresh={invalidateData}
|
||||
onPayNow={(row) => setCommandCenterPayRow(row)}
|
||||
/>
|
||||
)}
|
||||
|
|
@ -881,7 +885,7 @@ export default function TrackerPage() {
|
|||
{!isError && !loading && showDriftInsights && (driftData?.bills?.length ?? 0) > 0 && (
|
||||
<DriftInsightPanel
|
||||
driftBills={driftData.bills}
|
||||
refresh={() => { refetch(); refetchDrift(); }}
|
||||
refresh={invalidateData}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
|
@ -953,8 +957,8 @@ export default function TrackerPage() {
|
|||
)}
|
||||
{!isError && (first.length > 0 || second.length > 0) && (
|
||||
<div className="space-y-5">
|
||||
{first.length > 0 && <Bucket label="1st – 14th" rows={first} year={year} month={month} refresh={refetch} onEditBill={handleOpenEditBill} loading={loading} reorderEnabled={reorderEnabled} movingBillId={movingBillId} sortKey={sortKey} sortDir={sortDir} onSort={handleSortHeader} onReorderRows={(next) => handleReorderBucket('1st', next)} driftedIds={driftedIds} visibleColumns={visibleTableColumns} />}
|
||||
{second.length > 0 && <Bucket label="15th – 31st" rows={second} year={year} month={month} refresh={refetch} onEditBill={handleOpenEditBill} loading={loading} reorderEnabled={reorderEnabled} movingBillId={movingBillId} sortKey={sortKey} sortDir={sortDir} onSort={handleSortHeader} onReorderRows={(next) => handleReorderBucket('15th', next)} driftedIds={driftedIds} visibleColumns={visibleTableColumns} />}
|
||||
{first.length > 0 && <Bucket label="1st – 14th" rows={first} year={year} month={month} refresh={invalidateData} onEditBill={handleOpenEditBill} loading={loading} reorderEnabled={reorderEnabled} movingBillId={movingBillId} sortKey={sortKey} sortDir={sortDir} onSort={handleSortHeader} onReorderRows={(next) => handleReorderBucket('1st', next)} driftedIds={driftedIds} visibleColumns={visibleTableColumns} />}
|
||||
{second.length > 0 && <Bucket label="15th – 31st" rows={second} year={year} month={month} refresh={invalidateData} onEditBill={handleOpenEditBill} loading={loading} reorderEnabled={reorderEnabled} movingBillId={movingBillId} sortKey={sortKey} sortDir={sortDir} onSort={handleSortHeader} onReorderRows={(next) => handleReorderBucket('15th', next)} driftedIds={driftedIds} visibleColumns={visibleTableColumns} />}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
@ -966,7 +970,7 @@ export default function TrackerPage() {
|
|||
initialBill={editBillData.initialBill}
|
||||
categories={editBillData.categories}
|
||||
onClose={() => setEditBillData(null)}
|
||||
onSave={() => refetch()}
|
||||
onSave={() => invalidateData()}
|
||||
onDuplicate={bill => setEditBillData({
|
||||
bill: null,
|
||||
initialBill: makeBillDraft(bill, { copy: true, categories: editBillData.categories }),
|
||||
|
|
@ -981,7 +985,7 @@ export default function TrackerPage() {
|
|||
onClose={() => setEditStartingOpen(false)}
|
||||
year={year}
|
||||
month={month}
|
||||
onSave={() => { setEditStartingOpen(false); refetch(); }}
|
||||
onSave={() => { setEditStartingOpen(false); invalidateData(); }}
|
||||
/>
|
||||
|
||||
{/* Income breakdown modal — opens when clicking the bank balance card */}
|
||||
|
|
@ -1008,7 +1012,7 @@ export default function TrackerPage() {
|
|||
const month = new Date(attr.suggested_date + 'T00:00:00').toLocaleDateString('en-US', { month: 'long' });
|
||||
toast.success(`${attr.bill_name} payment moved to ${month}`);
|
||||
setLateAttributions(prev => prev.slice(1)); // dismiss only on success
|
||||
refetch();
|
||||
invalidateData();
|
||||
} catch (err) {
|
||||
toast.error(err.message || 'Failed to reclassify payment — try again');
|
||||
// keep the attribution in queue so user can retry
|
||||
|
|
@ -1029,7 +1033,7 @@ export default function TrackerPage() {
|
|||
threshold={commandCenterPayRow.actual_amount ?? commandCenterPayRow.expected_amount}
|
||||
defaultPaymentDate={paymentDateForTrackerMonth(year, month, commandCenterPayRow.due_day)}
|
||||
onClose={() => setCommandCenterPayRow(null)}
|
||||
onSaved={() => { setCommandCenterPayRow(null); refetch(); }}
|
||||
onSaved={() => { setCommandCenterPayRow(null); invalidateData(); }}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -241,15 +241,32 @@ router.post('/quick', (req, res) => {
|
|||
const payDate = paymentValidation.normalized.paid_date;
|
||||
const paySource = paymentValidation.normalized.payment_source;
|
||||
|
||||
// Guard against a double-clicked / retried "pay" (or two tabs) creating a
|
||||
// duplicate payment. Mirrors the bulk route's composite-key check
|
||||
// (bill_id + paid_date + amount). If one already exists, return it idempotently.
|
||||
const existingDuplicate = db.prepare(
|
||||
`SELECT p.* FROM payments p
|
||||
WHERE p.bill_id = ? AND p.paid_date = ? AND p.amount = ? AND p.${SQL_NOT_DELETED}
|
||||
ORDER BY p.id DESC LIMIT 1`
|
||||
).get(bill.id, payDate, payAmount);
|
||||
if (existingDuplicate) {
|
||||
return res.status(200).json(serializePayment(existingDuplicate));
|
||||
}
|
||||
|
||||
const balCalc = computeBalanceDelta(bill, payAmount);
|
||||
|
||||
const result = db.prepare(
|
||||
'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);
|
||||
// Atomic: the INSERT and the balance update must both land or neither, so a
|
||||
// mid-way failure never leaves a payment without its balance adjustment.
|
||||
const insertQuickPayment = db.transaction(() => {
|
||||
const r = db.prepare(
|
||||
'INSERT INTO payments (bill_id, amount, paid_date, method, notes, balance_delta, interest_delta, payment_source) VALUES (?, ?, ?, ?, ?, ?, ?, ?)'
|
||||
).run(bill.id, payAmount, payDate, method || null, notes || null, balCalc?.balance_delta ?? null, balCalc?.interest_delta ?? null, paySource);
|
||||
applyBalanceDelta(db, bill.id, balCalc);
|
||||
return r.lastInsertRowid;
|
||||
});
|
||||
const paymentId = insertQuickPayment();
|
||||
|
||||
applyBalanceDelta(db, bill.id, balCalc);
|
||||
|
||||
res.status(201).json(serializePayment(db.prepare('SELECT * FROM payments WHERE id = ?').get(result.lastInsertRowid)));
|
||||
res.status(201).json(serializePayment(db.prepare('SELECT * FROM payments WHERE id = ?').get(paymentId)));
|
||||
});
|
||||
|
||||
// POST /api/payments/autopay-suggestions/:billId/confirm
|
||||
|
|
|
|||
|
|
@ -1,22 +1,40 @@
|
|||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { getTracker, getUpcomingBills, getOverdueCount } = require('../services/trackerService');
|
||||
const { standardizeError } = require('../middleware/errorFormatter');
|
||||
|
||||
// GET /api/tracker/overdue-count — lightweight count for sidebar badge
|
||||
router.get('/overdue-count', (req, res) => {
|
||||
res.json(getOverdueCount(req.user.id));
|
||||
try {
|
||||
res.json(getOverdueCount(req.user.id));
|
||||
} catch (err) {
|
||||
console.error('[tracker/overdue-count]', err.message);
|
||||
res.status(500).json(standardizeError('Failed to load overdue count', 'INTERNAL_ERROR'));
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/tracker?year=2026&month=5
|
||||
router.get('/', (req, res) => {
|
||||
const result = getTracker(req.user.id, req.query);
|
||||
if (result.error) return res.status(result.status || 400).json({ error: result.error });
|
||||
res.json(result);
|
||||
try {
|
||||
const result = getTracker(req.user.id, req.query);
|
||||
if (result.error) {
|
||||
return res.status(result.status || 400).json(standardizeError(result.error, 'VALIDATION_ERROR'));
|
||||
}
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
console.error('[tracker]', err.message);
|
||||
res.status(500).json(standardizeError('Failed to load tracker data', 'INTERNAL_ERROR'));
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/tracker/upcoming?days=30
|
||||
router.get('/upcoming', (req, res) => {
|
||||
res.json(getUpcomingBills(req.user.id, req.query));
|
||||
try {
|
||||
res.json(getUpcomingBills(req.user.id, req.query));
|
||||
} catch (err) {
|
||||
console.error('[tracker/upcoming]', err.message);
|
||||
res.status(500).json(standardizeError('Failed to load upcoming bills', 'INTERNAL_ERROR'));
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
|
|
|||
|
|
@ -3,6 +3,35 @@
|
|||
const { accountingActiveSql } = require('./paymentAccountingService');
|
||||
const { fromCents } = require('../utils/money');
|
||||
|
||||
// The 6 calendar months immediately preceding (year, month), newest first, each
|
||||
// as { y, m, key: 'YYYY-MM' }.
|
||||
function priorMonths(year, month, count = 6) {
|
||||
const list = [];
|
||||
let y = year;
|
||||
let m = month;
|
||||
for (let i = 0; i < count; i++) {
|
||||
m -= 1;
|
||||
if (m === 0) { m = 12; y -= 1; }
|
||||
list.push({ y, m, key: `${y}-${String(m).padStart(2, '0')}` });
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
// Rolling median of a bill's monthly amounts → a suggestion object (or null).
|
||||
function medianSuggestion(amounts) {
|
||||
if (amounts.length === 0) return null;
|
||||
const sorted = [...amounts].sort((a, b) => a - b);
|
||||
const mid = Math.floor(sorted.length / 2);
|
||||
const median = sorted.length % 2 === 0
|
||||
? (sorted[mid - 1] + sorted[mid]) / 2
|
||||
: sorted[mid];
|
||||
return {
|
||||
suggestion: fromCents(Math.round(median)),
|
||||
months_used: amounts.length,
|
||||
confidence: amounts.length >= 3 ? 'high' : 'low',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes a suggested expected amount for a bill based on the rolling median
|
||||
* of the last 6 months of actual data. Prefers monthly_bill_state.actual_amount
|
||||
|
|
@ -10,13 +39,8 @@ const { fromCents } = require('../utils/money');
|
|||
*/
|
||||
function computeAmountSuggestion(db, billId, year, month) {
|
||||
const amounts = [];
|
||||
let y = year;
|
||||
let m = month;
|
||||
|
||||
for (let i = 0; i < 6; i++) {
|
||||
m -= 1;
|
||||
if (m === 0) { m = 12; y -= 1; }
|
||||
|
||||
for (const { y, m } of priorMonths(year, month, 6)) {
|
||||
const mbs = db.prepare(
|
||||
'SELECT actual_amount FROM monthly_bill_state WHERE bill_id = ? AND year = ? AND month = ?'
|
||||
).get(billId, y, m);
|
||||
|
|
@ -39,19 +63,75 @@ function computeAmountSuggestion(db, billId, year, month) {
|
|||
if (result.total > 0) amounts.push(result.total);
|
||||
}
|
||||
|
||||
if (amounts.length === 0) return null;
|
||||
|
||||
const sorted = [...amounts].sort((a, b) => a - b);
|
||||
const mid = Math.floor(sorted.length / 2);
|
||||
const median = sorted.length % 2 === 0
|
||||
? (sorted[mid - 1] + sorted[mid]) / 2
|
||||
: sorted[mid];
|
||||
|
||||
return {
|
||||
suggestion: fromCents(Math.round(median)),
|
||||
months_used: amounts.length,
|
||||
confidence: amounts.length >= 3 ? 'high' : 'low',
|
||||
};
|
||||
return medianSuggestion(amounts);
|
||||
}
|
||||
|
||||
module.exports = { computeAmountSuggestion };
|
||||
/**
|
||||
* Batched form of computeAmountSuggestion for many bills at once: two queries
|
||||
* total (monthly_bill_state + grouped payment sums) instead of up to 12 per
|
||||
* bill. Returns a Map<billId, suggestion|null>. Behavior-identical to calling
|
||||
* computeAmountSuggestion per bill (guarded by amountSuggestionService.test.js).
|
||||
*/
|
||||
function computeAmountSuggestionsBatch(db, billIds, year, month) {
|
||||
const result = new Map();
|
||||
if (!billIds || billIds.length === 0) return result;
|
||||
|
||||
const months = priorMonths(year, month, 6);
|
||||
const monthKeys = new Set(months.map(mm => mm.key));
|
||||
const minKey = months[months.length - 1].key; // earliest (month − 6)
|
||||
const maxKey = months[0].key; // latest (month − 1)
|
||||
// Broad window that fully spans the 6 months; the monthKeys filter below is
|
||||
// the exact gate, so a '-01'…'-31' string window is safe for ISO dates.
|
||||
const windowStart = `${minKey}-01`;
|
||||
const windowEnd = `${maxKey}-31`;
|
||||
|
||||
const placeholders = billIds.map(() => '?').join(',');
|
||||
|
||||
// User-corrected monthly amounts.
|
||||
const mbsMap = new Map(); // billId → { 'YYYY-MM': actual_amount }
|
||||
const mbsRows = db.prepare(`
|
||||
SELECT bill_id, year, month, actual_amount
|
||||
FROM monthly_bill_state
|
||||
WHERE bill_id IN (${placeholders})
|
||||
`).all(...billIds);
|
||||
for (const r of mbsRows) {
|
||||
if (r.actual_amount == null) continue;
|
||||
const key = `${r.year}-${String(r.month).padStart(2, '0')}`;
|
||||
if (!monthKeys.has(key)) continue;
|
||||
if (!mbsMap.has(r.bill_id)) mbsMap.set(r.bill_id, {});
|
||||
mbsMap.get(r.bill_id)[key] = r.actual_amount;
|
||||
}
|
||||
|
||||
// Payment sums grouped by bill + month.
|
||||
const payMap = new Map(); // billId → { 'YYYY-MM': total }
|
||||
const payRows = db.prepare(`
|
||||
SELECT bill_id, strftime('%Y-%m', paid_date) AS ym, COALESCE(SUM(amount), 0) AS total
|
||||
FROM payments
|
||||
WHERE bill_id IN (${placeholders})
|
||||
AND deleted_at IS NULL
|
||||
AND ${accountingActiveSql()}
|
||||
AND paid_date BETWEEN ? AND ?
|
||||
GROUP BY bill_id, ym
|
||||
`).all(...billIds, windowStart, windowEnd);
|
||||
for (const r of payRows) {
|
||||
if (!monthKeys.has(r.ym)) continue;
|
||||
if (!payMap.has(r.bill_id)) payMap.set(r.bill_id, {});
|
||||
payMap.get(r.bill_id)[r.ym] = r.total;
|
||||
}
|
||||
|
||||
for (const billId of billIds) {
|
||||
const amounts = [];
|
||||
const mbsFor = mbsMap.get(billId) || {};
|
||||
const payFor = payMap.get(billId) || {};
|
||||
for (const { key } of months) {
|
||||
if (mbsFor[key] != null) { amounts.push(mbsFor[key]); continue; }
|
||||
const total = payFor[key] || 0;
|
||||
if (total > 0) amounts.push(total);
|
||||
}
|
||||
result.set(billId, medianSuggestion(amounts));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
module.exports = { computeAmountSuggestion, computeAmountSuggestionsBatch };
|
||||
|
|
|
|||
|
|
@ -141,8 +141,32 @@ function senderAddress() {
|
|||
|
||||
// ── Email templates ───────────────────────────────────────────────────────────
|
||||
|
||||
// Per-bill reminder lead time (days before due) for the early "due soon"
|
||||
// reminder. Every bill has a reminder_days_before column (default 3); honor it
|
||||
// so a user who asks for "7 days before" actually gets a 7-day reminder.
|
||||
function leadDaysOf(bill) {
|
||||
return Number.isInteger(bill?.reminder_days_before) ? bill.reminder_days_before : 3;
|
||||
}
|
||||
|
||||
// Which reminder type (if any) applies to a bill that is `diffDays` calendar
|
||||
// days from its due date. Pure + exported so the lead-time logic is unit-testable
|
||||
// without invoking the full runner (which uses the real clock + real senders).
|
||||
// The early reminder fires at the bill's own lead time, and only when that lead
|
||||
// is ≥ 2 so it never collides with the 1-day or same-day reminders.
|
||||
function reminderTypeFor(bill, diffDays) {
|
||||
const leadDays = leadDaysOf(bill);
|
||||
if (diffDays >= 2 && diffDays === leadDays) return 'due_3d';
|
||||
if (diffDays === 1) return 'due_1d';
|
||||
if (diffDays === 0) return 'due_today';
|
||||
if (diffDays < 0) return 'overdue';
|
||||
return null;
|
||||
}
|
||||
|
||||
const TYPE_META = {
|
||||
due_3d: { subject: (b) => `Reminder: ${b.name} due in 3 days`, urgency: 'upcoming' },
|
||||
// The `due_3d` key is the early reminder; its lead time is the bill's own
|
||||
// reminder_days_before (only fires when that is ≥ 2 — a 1-day lead is covered
|
||||
// by due_1d and a 0-day lead by due_today).
|
||||
due_3d: { subject: (b) => `Reminder: ${b.name} due in ${leadDaysOf(b)} days`, urgency: 'upcoming' },
|
||||
due_1d: { subject: (b) => `Reminder: ${b.name} due tomorrow`, urgency: 'soon' },
|
||||
due_today: { subject: (b) => `Due today: ${b.name}`, urgency: 'today' },
|
||||
overdue: { subject: (b) => `Overdue: ${b.name} hasn't been paid`, urgency: 'overdue' },
|
||||
|
|
@ -169,7 +193,7 @@ function buildEmailHtml(bill, type, dueDate) {
|
|||
// these message strings (previously raw — an XSS vector via the bill name).
|
||||
const name = esc(bill.name);
|
||||
const messages = {
|
||||
due_3d: `<strong>${name}</strong> is due in 3 days.`,
|
||||
due_3d: `<strong>${name}</strong> is due in ${leadDaysOf(bill)} days.`,
|
||||
due_1d: `<strong>${name}</strong> is due <strong>tomorrow</strong>.`,
|
||||
due_today: `<strong>${name}</strong> is due <strong>today</strong>.`,
|
||||
overdue: `<strong>${name}</strong> was due on ${fmt(dueDate)} and has not been marked as paid.`,
|
||||
|
|
@ -359,12 +383,9 @@ async function runNotifications() {
|
|||
const dueDay = new Date(due.getFullYear(), due.getMonth(), due.getDate());
|
||||
const diffDays = Math.round((dueDay - todayDate) / 86400000);
|
||||
|
||||
// Determine which type applies today
|
||||
let type = null;
|
||||
if (diffDays === 3) type = 'due_3d';
|
||||
else if (diffDays === 1) type = 'due_1d';
|
||||
else if (diffDays === 0) type = 'due_today';
|
||||
else if (diffDays < 0) type = 'overdue';
|
||||
// Determine which type applies today. The early reminder fires at the bill's
|
||||
// own lead time (reminder_days_before, default 3) rather than a fixed 3 days.
|
||||
const type = reminderTypeFor(bill, diffDays);
|
||||
|
||||
if (!type) continue;
|
||||
|
||||
|
|
@ -555,3 +576,6 @@ module.exports = { runNotifications, runDriftNotifications, sendTestEmail, creat
|
|||
// before it, leaving `_push` undefined → "Send test push" always 500'd).
|
||||
module.exports._push = { sendNtfy, sendGotify, sendDiscord, sendTelegram, sendTestPush, sendPushToUser, encryptSecret };
|
||||
module.exports._email = { buildEmailHtml, esc };
|
||||
// Reminder lead-time logic, exposed for unit tests (the full runner uses the
|
||||
// real clock + real senders, so the pure type selection is tested in isolation).
|
||||
module.exports._reminders = { reminderTypeFor, leadDaysOf, TYPE_META };
|
||||
|
|
|
|||
|
|
@ -1,5 +1,13 @@
|
|||
const { getSetting } = require('../db/database');
|
||||
|
||||
// A bill's month is "settled" when its status is paid or autodraft (assumed paid
|
||||
// via autopay). Single source of truth so the ~scattered inline
|
||||
// `status === 'paid' || status === 'autodraft'` checks don't drift.
|
||||
const PAID_STATUSES = Object.freeze(['paid', 'autodraft']);
|
||||
function isPaidStatus(status) {
|
||||
return status === 'paid' || status === 'autodraft';
|
||||
}
|
||||
|
||||
const WEEKDAY_INDEX = {
|
||||
sunday: 0,
|
||||
monday: 1,
|
||||
|
|
@ -226,7 +234,7 @@ function buildTrackerRow(bill, payments, year, month, todayStr, options = {}) {
|
|||
const expectedAmount = Number(bill.expected_amount) || 0;
|
||||
const totalPaid = safePayments.reduce((sum, p) => sum + (Number(p.amount) || 0), 0);
|
||||
const hasPayment = safePayments.length > 0;
|
||||
const isSettled = status === 'paid' || status === 'autodraft';
|
||||
const isSettled = isPaidStatus(status);
|
||||
const paidTowardDue = Math.min(totalPaid, expectedAmount);
|
||||
const overpaidAmount = Math.max(totalPaid - expectedAmount, 0);
|
||||
const rawBalance = expectedAmount - totalPaid;
|
||||
|
|
@ -282,7 +290,9 @@ module.exports = {
|
|||
calculateStatus,
|
||||
getCalendarMonthRange,
|
||||
getCycleRange,
|
||||
isPaidStatus,
|
||||
normalizeCycleType,
|
||||
PAID_STATUSES,
|
||||
resolveBucket,
|
||||
resolveDueDate,
|
||||
resolveGracePeriodDays,
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
'use strict';
|
||||
|
||||
const { getDb } = require('../db/database');
|
||||
const { buildTrackerRow, getCycleRange, resolveDueDate } = require('./statusService');
|
||||
const { buildTrackerRow, getCycleRange, resolveDueDate, isPaidStatus } = require('./statusService');
|
||||
const { getUserSettings } = require('./userSettings');
|
||||
const { computeBalanceDelta, applyBalanceDelta } = require('./billsService');
|
||||
const { computeAmountSuggestion } = require('./amountSuggestionService');
|
||||
const { computeAmountSuggestionsBatch } = require('./amountSuggestionService');
|
||||
const { accountingActiveSql } = require('./paymentAccountingService');
|
||||
const { normalizeMerchant } = require('./subscriptionService');
|
||||
const { localDateString } = require('../utils/dates');
|
||||
|
|
@ -64,7 +64,11 @@ function fetchBankPendingCounts(db, userId, billIds) {
|
|||
return counts;
|
||||
}
|
||||
|
||||
function buildBankTracking(db, userId, year, month) {
|
||||
// `gatedUnpaidThisMonth` (dollars) is the occurrence-gated unpaid total the
|
||||
// caller already computed from the tracker rows (which honor resolveDueDate).
|
||||
// When provided we use it instead of the ungated SQL below, so annual/quarterly/
|
||||
// off-month bills don't inflate `unpaid_this_month` → the bank `remaining`.
|
||||
function buildBankTracking(db, userId, year, month, gatedUnpaidThisMonth = null) {
|
||||
try {
|
||||
const settings = getUserSettings(userId);
|
||||
if (settings.bank_tracking_enabled !== 'true') return { enabled: false };
|
||||
|
|
@ -96,29 +100,37 @@ function buildBankTracking(db, userId, year, month) {
|
|||
`).get(userId, days)
|
||||
: { pending_total: 0 };
|
||||
|
||||
const { start, end } = getCycleRange(year, month);
|
||||
const unpaidRow = db.prepare(`
|
||||
SELECT COALESCE(SUM(
|
||||
CASE WHEN m.actual_amount IS NOT NULL THEN m.actual_amount
|
||||
ELSE COALESCE(b.expected_amount, 0) END
|
||||
), 0) AS unpaid_total
|
||||
FROM bills b
|
||||
LEFT JOIN monthly_bill_state m ON m.bill_id = b.id AND m.year = ? AND m.month = ?
|
||||
LEFT JOIN (
|
||||
SELECT bill_id, SUM(amount) AS paid_sum FROM payments
|
||||
WHERE paid_date BETWEEN ? AND ?
|
||||
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);
|
||||
// Fallback (only when the caller didn't pass a gated total, e.g. a direct
|
||||
// call): the ungated SQL sum. This overcounts off-month bills — prefer the
|
||||
// gated total from getTracker's rows.
|
||||
let unpaid;
|
||||
if (gatedUnpaidThisMonth != null) {
|
||||
unpaid = roundMoney(gatedUnpaidThisMonth);
|
||||
} else {
|
||||
const { start, end } = getCycleRange(year, month);
|
||||
const unpaidRow = db.prepare(`
|
||||
SELECT COALESCE(SUM(
|
||||
CASE WHEN m.actual_amount IS NOT NULL THEN m.actual_amount
|
||||
ELSE COALESCE(b.expected_amount, 0) END
|
||||
), 0) AS unpaid_total
|
||||
FROM bills b
|
||||
LEFT JOIN monthly_bill_state m ON m.bill_id = b.id AND m.year = ? AND m.month = ?
|
||||
LEFT JOIN (
|
||||
SELECT bill_id, SUM(amount) AS paid_sum FROM payments
|
||||
WHERE paid_date BETWEEN ? AND ?
|
||||
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 = roundMoney(account.balance / 100);
|
||||
const balance = fromCents(account.balance);
|
||||
const pending = fromCents(pendingRow.pending_total);
|
||||
const effective = roundMoney(balance - pending);
|
||||
const unpaid = fromCents(unpaidRow.unpaid_total);
|
||||
|
||||
return {
|
||||
enabled: true,
|
||||
|
|
@ -211,6 +223,46 @@ function fetchPaymentsForBillCycle(db, bill, year, month) {
|
|||
`).all(bill.id, range.start, range.end);
|
||||
}
|
||||
|
||||
// Batched form of fetchPaymentsForBillCycle: one query for every bill's cycle
|
||||
// payments (grouped in JS by each bill's own range) instead of one query per
|
||||
// bill. Returns Map<billId, payments[]> with each list still ordered paid_date
|
||||
// DESC — identical to calling fetchPaymentsForBillCycle per bill.
|
||||
function fetchPaymentsForBillsInMonth(db, bills, year, month) {
|
||||
const ranges = new Map(); // billId → { start, end } (only bills due this month)
|
||||
let minStart = null;
|
||||
let maxEnd = null;
|
||||
for (const bill of bills) {
|
||||
const range = getCycleRange(year, month, bill);
|
||||
if (!range) continue;
|
||||
ranges.set(bill.id, range);
|
||||
if (minStart === null || range.start < minStart) minStart = range.start;
|
||||
if (maxEnd === null || range.end > maxEnd) maxEnd = range.end;
|
||||
}
|
||||
|
||||
const byBill = new Map();
|
||||
if (minStart === null) return byBill;
|
||||
|
||||
const ids = [...ranges.keys()];
|
||||
const placeholders = ids.map(() => '?').join(',');
|
||||
const rows = db.prepare(`
|
||||
SELECT bill_id, id, amount, paid_date, method, notes, payment_source, transaction_id, created_at, updated_at
|
||||
FROM payments
|
||||
WHERE bill_id IN (${placeholders}) AND paid_date BETWEEN ? AND ?
|
||||
AND deleted_at IS NULL
|
||||
AND ${accountingActiveSql()}
|
||||
ORDER BY paid_date DESC
|
||||
`).all(...ids, minStart, maxEnd);
|
||||
|
||||
for (const row of rows) {
|
||||
const range = ranges.get(row.bill_id);
|
||||
// The union window may be wider than this bill's own cycle — filter to it.
|
||||
if (row.paid_date < range.start || row.paid_date > range.end) continue;
|
||||
if (!byBill.has(row.bill_id)) byBill.set(row.bill_id, []);
|
||||
byBill.get(row.bill_id).push(row);
|
||||
}
|
||||
return byBill;
|
||||
}
|
||||
|
||||
function fetchPreviousMonthPaid(db, billIds, range) {
|
||||
if (billIds.length === 0) return {};
|
||||
const placeholders = billIds.map(() => '?').join(',');
|
||||
|
|
@ -317,7 +369,7 @@ function buildSafeToSpend({ activeRows, available, todayStr, year, month, dayOfM
|
|||
const daysUntilPayday = Math.max(0, Math.round((Date.parse(nextPayday) - Date.parse(todayStr)) / 86400000));
|
||||
|
||||
const stillDueRows = activeRows
|
||||
.filter(r => !['paid', 'autodraft'].includes(r.status))
|
||||
.filter(r => !isPaidStatus(r.status))
|
||||
.filter(r => rowOutstanding(r) > 0)
|
||||
.filter(r => r.due_date < nextPayday)
|
||||
.sort((a, b) => a.due_date.localeCompare(b.due_date) || String(a.name).localeCompare(String(b.name)));
|
||||
|
|
@ -400,29 +452,34 @@ function applyAutopaySuggestions(db, bill, payments, mbs, year, month, todayStr,
|
|||
}
|
||||
|
||||
const balCalc = computeBalanceDelta(bill, suggestedAmount);
|
||||
const result = db.prepare(`
|
||||
INSERT INTO payments (bill_id, amount, paid_date, method, notes, balance_delta, interest_delta, payment_source)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
bill.id,
|
||||
suggestedAmount,
|
||||
dueDate,
|
||||
'autopay',
|
||||
'Auto-marked paid on due date',
|
||||
balCalc?.balance_delta ?? null,
|
||||
balCalc?.interest_delta ?? null,
|
||||
'manual',
|
||||
);
|
||||
// Atomic: the auto-mark payment INSERT and its balance update must both land
|
||||
// or neither. This runs on a GET /tracker, so a mid-way failure would
|
||||
// otherwise leave a payment without its balance adjustment (or vice versa).
|
||||
const insertAutoPayment = db.transaction(() => {
|
||||
const r = db.prepare(`
|
||||
INSERT INTO payments (bill_id, amount, paid_date, method, notes, balance_delta, interest_delta, payment_source)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
bill.id,
|
||||
suggestedAmount,
|
||||
dueDate,
|
||||
'autopay',
|
||||
'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) {
|
||||
applyBalanceDelta(db, bill.id, balCalc);
|
||||
bill.current_balance = balCalc.new_balance;
|
||||
}
|
||||
if (balCalc) bill.current_balance = balCalc.new_balance;
|
||||
payments.push(db.prepare(`
|
||||
SELECT bill_id, id, amount, paid_date, method, notes, payment_source, transaction_id, created_at, updated_at
|
||||
FROM payments
|
||||
WHERE id = ?
|
||||
`).get(result.lastInsertRowid));
|
||||
`).get(paymentId));
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
@ -502,13 +559,17 @@ function getTracker(userId, query = {}, now = new Date()) {
|
|||
const dismissedSuggestions = fetchDismissedSuggestions(db, userId, billIds, year, month);
|
||||
const sparklines = fetchSparklines(db, billIds);
|
||||
const autopayStatsMap = fetchAutopayStats(db, billIds);
|
||||
// Batched to avoid an N+1 across bills (was ~2–3 queries × N bills every load):
|
||||
// one query for all cycle payments, two for all amount suggestions.
|
||||
const paymentsByBill = fetchPaymentsForBillsInMonth(db, bills, year, month);
|
||||
const amountSuggestions = computeAmountSuggestionsBatch(db, billIds, year, month);
|
||||
|
||||
const rows = bills.map(bill => {
|
||||
bill.sparkline = sparklines[bill.id] ?? null;
|
||||
bill.autopay_stats = autopayStatsMap[bill.id] ?? null;
|
||||
if (!resolveDueDate(bill, year, month)) return null;
|
||||
|
||||
const payments = fetchPaymentsForBillCycle(db, bill, year, month);
|
||||
const payments = paymentsByBill.get(bill.id) || [];
|
||||
const mbs = monthlyStates[bill.id];
|
||||
const autopaySuggestion = applyAutopaySuggestions(
|
||||
db,
|
||||
|
|
@ -534,7 +595,7 @@ function getTracker(userId, query = {}, now = new Date()) {
|
|||
row.snoozed_until = mbs?.snoozed_until ?? null;
|
||||
if (autopaySuggestion) row.autopay_suggestion = autopaySuggestion;
|
||||
row.previous_month_paid = fromCents(prevMonthPayments[bill.id] || 0);
|
||||
row.amount_suggestion = computeAmountSuggestion(db, bill.id, year, month);
|
||||
row.amount_suggestion = amountSuggestions.get(bill.id) ?? null;
|
||||
return row;
|
||||
}).filter(Boolean);
|
||||
|
||||
|
|
@ -559,13 +620,18 @@ function getTracker(userId, query = {}, now = new Date()) {
|
|||
const activeRemainingPeriod = dayOfMonth < 15 ? '1st' : '15th';
|
||||
const periodRows = activeRows.filter(r => r.bucket === activeRemainingPeriod);
|
||||
const periodPaidTowardDue = sumMoney(periodRows, rowPaidTowardDue);
|
||||
const periodOutstandingBalance = sumMoney(periodRows, r => Math.max(r.balance || 0, 0));
|
||||
const periodOutstandingBalance = sumMoney(periodRows, rowOutstanding);
|
||||
const periodStartingAmount = activeRemainingPeriod === '1st'
|
||||
? (startingAmounts?.first_amount || 0)
|
||||
: (startingAmounts?.fifteenth_amount || 0);
|
||||
const periodLabel = activeRemainingPeriod === '1st' ? '1st balance' : '15th balance';
|
||||
|
||||
const bankTracking = buildBankTracking(db, userId, year, month);
|
||||
// Occurrence-gated unpaid total for this month: the still-owed amount across
|
||||
// bills actually due this month (activeRows already honor resolveDueDate),
|
||||
// netting partial payments. Feeds the bank card so its unpaid/remaining agree
|
||||
// with the rows instead of over-counting annual/off-month bills.
|
||||
const activeMonthUnpaid = sumMoney(activeRows, r => Math.max(rowDueAmount(r) - rowPaidTowardDue(r), 0));
|
||||
const bankTracking = buildBankTracking(db, userId, year, month, activeMonthUnpaid);
|
||||
const bankPendingCounts = bankTracking.enabled ? fetchBankPendingCounts(db, userId, billIds) : {};
|
||||
const totalStarting = bankTracking.enabled
|
||||
? bankTracking.effective_balance
|
||||
|
|
@ -583,10 +649,10 @@ function getTracker(userId, query = {}, now = new Date()) {
|
|||
const activeTotalPaid = sumMoney(activeRows, r => r.total_paid);
|
||||
const activePaidTowardDue = sumMoney(activeRows, rowPaidTowardDue);
|
||||
const activeTotalExpected = sumMoney(activeRows, rowDueAmount);
|
||||
const activeOutstandingBalance = sumMoney(activeRows, r => Math.max(r.balance || 0, 0));
|
||||
const activeOutstandingBalance = sumMoney(activeRows, rowOutstanding);
|
||||
|
||||
const periodBillsTotal = sumMoney(periodRows, rowDueAmount);
|
||||
const periodPaidCount = periodRows.filter(r => ['paid', 'autodraft'].includes(r.status)).length;
|
||||
const periodPaidCount = periodRows.filter(r => isPaidStatus(r.status)).length;
|
||||
const periodTotalCount = periodRows.length;
|
||||
|
||||
// When bank tracking is on use the effective balance as the period starting point
|
||||
|
|
@ -596,7 +662,7 @@ function getTracker(userId, query = {}, now = new Date()) {
|
|||
const periodProjected = roundMoney(periodCashStart - periodBillsTotal);
|
||||
|
||||
const monthBillsTotal = activeTotalExpected;
|
||||
const monthPaidCount = activeRows.filter(r => ['paid', 'autodraft'].includes(r.status)).length;
|
||||
const monthPaidCount = activeRows.filter(r => isPaidStatus(r.status)).length;
|
||||
const monthTotalCount = activeRows.length;
|
||||
const monthProjected = roundMoney(totalStarting - monthBillsTotal);
|
||||
|
||||
|
|
@ -649,8 +715,17 @@ function getTracker(userId, query = {}, now = new Date()) {
|
|||
has_starting_amounts: hasStartingAmounts,
|
||||
total_paid: activeTotalPaid,
|
||||
paid_toward_due: activePaidTowardDue,
|
||||
remaining: roundMoney(hasStartingAmounts ? periodStartingAmount - periodPaidTowardDue : periodOutstandingBalance),
|
||||
total_remaining: roundMoney(hasStartingAmounts ? totalStarting - activePaidTowardDue : activeOutstandingBalance),
|
||||
// In bank mode the effective bank balance is a single pool (no per-period
|
||||
// split), so both remaining figures use the bank card's own remaining
|
||||
// (effective_balance − gated unpaid) — keeping the summary consistent with
|
||||
// safe-to-spend and the bank card instead of showing a different number
|
||||
// derived from manual starting amounts.
|
||||
remaining: roundMoney(bankTracking.enabled
|
||||
? bankTracking.remaining
|
||||
: (hasStartingAmounts ? periodStartingAmount - periodPaidTowardDue : periodOutstandingBalance)),
|
||||
total_remaining: roundMoney(bankTracking.enabled
|
||||
? bankTracking.remaining
|
||||
: (hasStartingAmounts ? totalStarting - activePaidTowardDue : activeOutstandingBalance)),
|
||||
remaining_period: activeRemainingPeriod,
|
||||
remaining_label: periodLabel,
|
||||
remaining_hint: hasStartingAmounts
|
||||
|
|
|
|||
|
|
@ -0,0 +1,74 @@
|
|||
'use strict';
|
||||
|
||||
// P1 — computeAmountSuggestionsBatch replaces up to 12 per-bill queries with two
|
||||
// batched queries in getTracker. This pins that the batched form is byte-identical
|
||||
// to calling the single-bill computeAmountSuggestion per bill (the behavior we
|
||||
// rely on for the N+1 fix to be safe).
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const fs = require('node:fs');
|
||||
|
||||
const dbPath = path.join(os.tmpdir(), `bill-tracker-amountsug-${process.pid}.sqlite`);
|
||||
process.env.DB_PATH = dbPath;
|
||||
|
||||
const { getDb, closeDb } = require('../db/database');
|
||||
const { computeAmountSuggestion, computeAmountSuggestionsBatch } = require('../services/amountSuggestionService');
|
||||
|
||||
let db, userId, bills;
|
||||
test.before(() => {
|
||||
db = getDb();
|
||||
userId = db.prepare("INSERT INTO users (username, password_hash, role, active) VALUES ('as-user','x','user',1)").run().lastInsertRowid;
|
||||
const insBill = db.prepare("INSERT INTO bills (user_id, name, due_day, expected_amount, active) VALUES (?, ?, 1, 10000, 1)");
|
||||
bills = {
|
||||
payments: insBill.run(userId, 'Payments only').lastInsertRowid, // suggestion from payment sums
|
||||
mbs: insBill.run(userId, 'Corrected').lastInsertRowid, // user-corrected actual_amount wins
|
||||
mixed: insBill.run(userId, 'Mixed').lastInsertRowid, // some months mbs, some payments
|
||||
empty: insBill.run(userId, 'No history').lastInsertRowid, // → null suggestion
|
||||
};
|
||||
|
||||
const pay = db.prepare("INSERT INTO payments (bill_id, amount, paid_date, payment_source) VALUES (?, ?, ?, 'manual')");
|
||||
// For query month = 2026-06: prior months are May, Apr, Mar, Feb, Jan 2026, Dec 2025.
|
||||
pay.run(bills.payments, 10000, '2026-05-15');
|
||||
pay.run(bills.payments, 12000, '2026-04-15');
|
||||
pay.run(bills.payments, 11000, '2026-03-15');
|
||||
// Two payments in one month should sum.
|
||||
pay.run(bills.mixed, 3000, '2026-05-10');
|
||||
pay.run(bills.mixed, 2000, '2026-05-20'); // May total 5000
|
||||
pay.run(bills.mixed, 9000, '2026-04-10');
|
||||
|
||||
const mbs = db.prepare("INSERT INTO monthly_bill_state (bill_id, year, month, actual_amount) VALUES (?, ?, ?, ?)");
|
||||
mbs.run(bills.mbs, 2026, 5, 8000);
|
||||
mbs.run(bills.mbs, 2026, 4, 8500);
|
||||
mbs.run(bills.mbs, 2026, 3, 7500);
|
||||
// Mixed: March has a corrected amount that should win over any payment sum.
|
||||
mbs.run(bills.mixed, 2026, 3, 4200);
|
||||
});
|
||||
test.after(() => {
|
||||
closeDb();
|
||||
for (const s of ['', '-wal', '-shm']) { try { fs.rmSync(dbPath + s); } catch {} }
|
||||
});
|
||||
|
||||
test('batch output equals per-bill computeAmountSuggestion for every bill', () => {
|
||||
const ids = Object.values(bills);
|
||||
const batch = computeAmountSuggestionsBatch(db, ids, 2026, 6);
|
||||
for (const id of ids) {
|
||||
const single = computeAmountSuggestion(db, id, 2026, 6);
|
||||
assert.deepEqual(batch.get(id), single, `bill ${id} batch matches single`);
|
||||
}
|
||||
});
|
||||
|
||||
test('empty bill list returns an empty map; no-history bill → null', () => {
|
||||
assert.equal(computeAmountSuggestionsBatch(db, [], 2026, 6).size, 0);
|
||||
const batch = computeAmountSuggestionsBatch(db, [bills.empty], 2026, 6);
|
||||
assert.equal(batch.get(bills.empty), null);
|
||||
});
|
||||
|
||||
test('corrected monthly amount wins over payment sums (median of mbs values)', () => {
|
||||
const s = computeAmountSuggestion(db, bills.mbs, 2026, 6);
|
||||
// median of [8000, 8500, 7500] = 8000 → $80
|
||||
assert.equal(s.suggestion, 80);
|
||||
assert.equal(s.months_used, 3);
|
||||
assert.equal(s.confidence, 'high');
|
||||
});
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
'use strict';
|
||||
|
||||
// X2 — analyticsService's month-window math (which drives the expected-vs-actual
|
||||
// reconciliation, the QA-B5-03 area) and query validation had no unit tests.
|
||||
// These pin the pure helpers with hand-calculated examples, including the
|
||||
// year-boundary and leap-year edges that off-by-one bugs hide in.
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const {
|
||||
monthKey, monthLabel, addMonths, monthEndDate, buildMonths, validateSummaryQuery,
|
||||
} = require('../services/analyticsService');
|
||||
|
||||
test('monthKey zero-pads the month', () => {
|
||||
assert.equal(monthKey(2026, 6), '2026-06');
|
||||
assert.equal(monthKey(2026, 12), '2026-12');
|
||||
});
|
||||
|
||||
test('monthLabel is a UTC short-month + 2-digit year (no TZ drift)', () => {
|
||||
assert.equal(monthLabel(2026, 1), 'Jan 26');
|
||||
assert.equal(monthLabel(2026, 6), 'Jun 26');
|
||||
assert.equal(monthLabel(2025, 12), 'Dec 25');
|
||||
});
|
||||
|
||||
test('addMonths crosses year boundaries both ways', () => {
|
||||
assert.deepEqual(addMonths(2026, 1, -1), { year: 2025, month: 12 });
|
||||
assert.deepEqual(addMonths(2026, 12, 1), { year: 2027, month: 1 });
|
||||
assert.deepEqual(addMonths(2026, 6, 0), { year: 2026, month: 6 });
|
||||
assert.deepEqual(addMonths(2026, 3, -6), { year: 2025, month: 9 });
|
||||
});
|
||||
|
||||
test('monthEndDate handles 30/31-day months and leap Februaries', () => {
|
||||
assert.equal(monthEndDate(2026, 6), '2026-06-30');
|
||||
assert.equal(monthEndDate(2026, 7), '2026-07-31');
|
||||
assert.equal(monthEndDate(2026, 2), '2026-02-28'); // not a leap year
|
||||
assert.equal(monthEndDate(2024, 2), '2024-02-29'); // leap year
|
||||
});
|
||||
|
||||
test('buildMonths yields `count` contiguous months ending at (endYear, endMonth)', () => {
|
||||
const months = buildMonths(2026, 3, 3); // Jan, Feb, Mar 2026
|
||||
assert.equal(months.length, 3);
|
||||
assert.deepEqual(months.map(m => m.key), ['2026-01', '2026-02', '2026-03']);
|
||||
assert.equal(months[0].start, '2026-01-01');
|
||||
assert.equal(months[2].end, '2026-03-31');
|
||||
assert.equal(months[2].label, 'Mar 26');
|
||||
});
|
||||
|
||||
test('buildMonths spanning a year boundary', () => {
|
||||
const months = buildMonths(2026, 1, 3); // Nov 25, Dec 25, Jan 26
|
||||
assert.deepEqual(months.map(m => m.key), ['2025-11', '2025-12', '2026-01']);
|
||||
});
|
||||
|
||||
test('validateSummaryQuery: defaults, parsing, and range errors', () => {
|
||||
const ok = validateSummaryQuery({ year: '2026', month: '6', months: '12' });
|
||||
assert.equal(ok.error, undefined);
|
||||
assert.equal(ok.year, 2026);
|
||||
assert.equal(ok.month, 6);
|
||||
assert.equal(ok.months, 12);
|
||||
assert.equal(ok.includeSkipped, true, 'skipped included unless explicitly false');
|
||||
|
||||
assert.match(validateSummaryQuery({ month: '13' }, new Date('2026-06-15')).error, /month/);
|
||||
assert.match(validateSummaryQuery({ months: '37' }, new Date('2026-06-15')).error, /months/);
|
||||
assert.match(validateSummaryQuery({ year: '1999' }, new Date('2026-06-15')).error, /year/);
|
||||
assert.match(validateSummaryQuery({ category_id: '-1' }, new Date('2026-06-15')).error, /category_id/);
|
||||
|
||||
// include_skipped=false flips the flag
|
||||
assert.equal(validateSummaryQuery({ include_skipped: 'false' }, new Date('2026-06-15')).includeSkipped, false);
|
||||
});
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
'use strict';
|
||||
|
||||
// BM3 — the reminder notifier used a hard-coded 3-day early reminder and never
|
||||
// read the bill's reminder_days_before, so the "Reminder days before" control
|
||||
// was a no-op. These tests pin the now-honored per-bill lead time (pure logic,
|
||||
// no clock/senders) and the dynamic email wording.
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const { _reminders, _email } = require('../services/notificationService');
|
||||
const { reminderTypeFor, leadDaysOf, TYPE_META } = _reminders;
|
||||
|
||||
test('leadDaysOf defaults to 3 and honors an integer reminder_days_before', () => {
|
||||
assert.equal(leadDaysOf({}), 3);
|
||||
assert.equal(leadDaysOf({ reminder_days_before: null }), 3);
|
||||
assert.equal(leadDaysOf({ reminder_days_before: 7 }), 7);
|
||||
assert.equal(leadDaysOf({ reminder_days_before: 0 }), 0);
|
||||
});
|
||||
|
||||
test('a 7-day lead fires the early reminder at 7 days out, NOT at 3', () => {
|
||||
const bill = { reminder_days_before: 7 };
|
||||
assert.equal(reminderTypeFor(bill, 7), 'due_3d', 'early reminder at the chosen lead');
|
||||
assert.equal(reminderTypeFor(bill, 3), null, 'no reminder at the old fixed 3 days');
|
||||
});
|
||||
|
||||
test('default (no reminder_days_before) still fires at 3 days — backwards compatible', () => {
|
||||
assert.equal(reminderTypeFor({}, 3), 'due_3d');
|
||||
assert.equal(reminderTypeFor({}, 5), null);
|
||||
});
|
||||
|
||||
test('1-day and same-day reminders are unaffected and never double up with a small lead', () => {
|
||||
// lead of 1 → the early reminder is suppressed (due_1d covers it)
|
||||
assert.equal(reminderTypeFor({ reminder_days_before: 1 }, 1), 'due_1d');
|
||||
// lead of 0 → due_today covers it
|
||||
assert.equal(reminderTypeFor({ reminder_days_before: 0 }, 0), 'due_today');
|
||||
// generic day/overdue selection
|
||||
assert.equal(reminderTypeFor({ reminder_days_before: 5 }, 1), 'due_1d');
|
||||
assert.equal(reminderTypeFor({ reminder_days_before: 5 }, 0), 'due_today');
|
||||
assert.equal(reminderTypeFor({ reminder_days_before: 5 }, -2), 'overdue');
|
||||
assert.equal(reminderTypeFor({ reminder_days_before: 5 }, 4), null);
|
||||
});
|
||||
|
||||
test('email subject + body reflect the actual lead days', () => {
|
||||
const bill = { name: 'Insurance', expected_amount: 12000, reminder_days_before: 7 };
|
||||
assert.match(TYPE_META.due_3d.subject(bill), /due in 7 days/);
|
||||
const html = _email.buildEmailHtml(bill, 'due_3d', '2026-07-10');
|
||||
assert.match(html, /is due in 7 days/);
|
||||
});
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
'use strict';
|
||||
|
||||
// X2 — the payment-accounting source-of-truth layer (manual vs. bank) had no
|
||||
// dedicated tests. Covers the bank-backed predicate, the accounting-active SQL
|
||||
// fragment, and the core money-integrity invariant: when a bank payment lands it
|
||||
// overrides a provisional manual payment (so it isn't double-counted) and the
|
||||
// manual one counts again — with the balance restored/re-applied — if the bank
|
||||
// override is later removed.
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const fs = require('node:fs');
|
||||
|
||||
const dbPath = path.join(os.tmpdir(), `bill-tracker-payacct-${process.pid}.sqlite`);
|
||||
process.env.DB_PATH = dbPath;
|
||||
|
||||
const { getDb, closeDb } = require('../db/database');
|
||||
const {
|
||||
isBankBackedPayment,
|
||||
accountingActiveSql,
|
||||
markProvisionalManualPaymentsOverridden,
|
||||
reactivatePaymentsOverriddenBy,
|
||||
} = require('../services/paymentAccountingService');
|
||||
|
||||
test('isBankBackedPayment: bank sources or a transaction_id count as bank-backed', () => {
|
||||
assert.equal(isBankBackedPayment({ payment_source: 'provider_sync' }), true);
|
||||
assert.equal(isBankBackedPayment({ payment_source: 'transaction_match' }), true);
|
||||
assert.equal(isBankBackedPayment({ payment_source: 'auto_match' }), true);
|
||||
assert.equal(isBankBackedPayment({ payment_source: 'manual', transaction_id: 42 }), true);
|
||||
assert.equal(isBankBackedPayment({ payment_source: 'manual' }), false);
|
||||
assert.equal(isBankBackedPayment({}), false);
|
||||
});
|
||||
|
||||
test('accountingActiveSql fragment, with and without a table alias', () => {
|
||||
assert.equal(accountingActiveSql(), 'COALESCE(accounting_excluded, 0) = 0');
|
||||
assert.equal(accountingActiveSql('p'), 'COALESCE(p.accounting_excluded, 0) = 0');
|
||||
});
|
||||
|
||||
test('bank payment overrides a provisional manual payment, then reactivates on removal', () => {
|
||||
const db = getDb();
|
||||
const userId = db.prepare("INSERT INTO users (username, password_hash, role, active) VALUES ('pa-user','x','user',1)").run().lastInsertRowid;
|
||||
// $500 balance; a manual $100 payment already dropped it to $400.
|
||||
const billId = db.prepare(
|
||||
"INSERT INTO bills (user_id, name, due_day, billing_cycle, cycle_type, expected_amount, current_balance, interest_rate, active) VALUES (?, 'Card', 10, 'monthly', 'monthly', 10000, 40000, 0, 1)",
|
||||
).run(userId).lastInsertRowid;
|
||||
const bill = db.prepare('SELECT * FROM bills WHERE id = ?').get(billId);
|
||||
|
||||
const manualId = db.prepare(
|
||||
"INSERT INTO payments (bill_id, amount, paid_date, method, payment_source, balance_delta) VALUES (?, 10000, '2026-06-10', 'manual', 'manual', -10000)",
|
||||
).run(billId).lastInsertRowid;
|
||||
// Bank payment for the same bill/cycle (a matched SimpleFIN transaction).
|
||||
const bankId = db.prepare(
|
||||
"INSERT INTO payments (bill_id, amount, paid_date, method, payment_source, transaction_id) VALUES (?, 10000, '2026-06-12', 'bank', 'transaction_match', 777)",
|
||||
).run(billId).lastInsertRowid;
|
||||
const bankPayment = db.prepare('SELECT * FROM payments WHERE id = ?').get(bankId);
|
||||
|
||||
const activeCount = () => db.prepare(
|
||||
`SELECT COUNT(*) c FROM payments WHERE bill_id = ? AND deleted_at IS NULL AND ${accountingActiveSql()}`,
|
||||
).get(billId).c;
|
||||
|
||||
// Both currently count — the double-count we must resolve.
|
||||
assert.equal(activeCount(), 2);
|
||||
|
||||
const { overridden } = markProvisionalManualPaymentsOverridden(db, bill, bankPayment);
|
||||
assert.equal(overridden, 1, 'the one provisional manual payment is overridden');
|
||||
|
||||
const manualAfter = db.prepare('SELECT * FROM payments WHERE id = ?').get(manualId);
|
||||
assert.equal(manualAfter.accounting_excluded, 1);
|
||||
assert.equal(manualAfter.overridden_by_payment_id, bankId);
|
||||
assert.equal(manualAfter.balance_delta, null, 'overridden payment no longer holds a balance delta');
|
||||
assert.equal(activeCount(), 1, 'only the bank payment counts now — no double count');
|
||||
// Its balance effect was reversed: $400 → $500.
|
||||
assert.equal(db.prepare('SELECT current_balance FROM bills WHERE id = ?').get(billId).current_balance, 50000);
|
||||
|
||||
// Bank override removed → the manual payment counts again and re-applies its balance.
|
||||
const { reactivated } = reactivatePaymentsOverriddenBy(db, bankId);
|
||||
assert.equal(reactivated, 1);
|
||||
const manualReactivated = db.prepare('SELECT * FROM payments WHERE id = ?').get(manualId);
|
||||
assert.equal(manualReactivated.accounting_excluded, 0);
|
||||
assert.equal(manualReactivated.overridden_by_payment_id, null);
|
||||
assert.equal(manualReactivated.balance_delta, -10000, 'balance delta re-applied');
|
||||
assert.equal(db.prepare('SELECT current_balance FROM bills WHERE id = ?').get(billId).current_balance, 40000, '$500 → $400 again');
|
||||
});
|
||||
|
||||
test.after(() => {
|
||||
closeDb();
|
||||
for (const s of ['', '-wal', '-shm']) { try { fs.rmSync(dbPath + s); } catch {} }
|
||||
});
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
'use strict';
|
||||
|
||||
// X1 — POST /payments/quick must be idempotent under a double-clicked / retried
|
||||
// "pay" and must apply the balance delta atomically with the INSERT. Before the
|
||||
// fix, a second identical request created a duplicate payment AND double-applied
|
||||
// the balance drop.
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const fs = require('node:fs');
|
||||
|
||||
const dbPath = path.join(os.tmpdir(), `bill-tracker-quickpay-${process.pid}.sqlite`);
|
||||
process.env.DB_PATH = dbPath;
|
||||
|
||||
const { getDb, closeDb } = require('../db/database');
|
||||
const router = require('../routes/payments');
|
||||
|
||||
function handler(method, routePath) {
|
||||
const layer = router.stack.find(l => l.route?.path === routePath && l.route.methods[method]);
|
||||
assert.ok(layer, `${method.toUpperCase()} ${routePath} exists`);
|
||||
return layer.route.stack[layer.route.stack.length - 1].handle;
|
||||
}
|
||||
function call(method, routePath, { userId, params = {}, body = {} } = {}) {
|
||||
const h = handler(method, routePath);
|
||||
return new Promise((resolve) => {
|
||||
const req = { params, body, query: {}, user: { id: userId, role: 'user' } };
|
||||
const res = {
|
||||
statusCode: 200,
|
||||
status(c) { this.statusCode = c; return this; },
|
||||
json(d) { resolve({ status: this.statusCode, data: d }); },
|
||||
};
|
||||
h(req, res);
|
||||
});
|
||||
}
|
||||
|
||||
let userId, billId;
|
||||
test.before(() => {
|
||||
const db = getDb();
|
||||
userId = db.prepare("INSERT INTO users (username, password_hash, role, active) VALUES ('qp-user','x','user',1)").run().lastInsertRowid;
|
||||
// $100 expected, $1,000 balance so we can observe the balance drop.
|
||||
billId = db.prepare(
|
||||
"INSERT INTO bills (user_id, name, due_day, expected_amount, current_balance, minimum_payment, interest_rate, active) VALUES (?, 'Card', 1, 10000, 100000, 5000, 0, 1)",
|
||||
).run(userId).lastInsertRowid;
|
||||
});
|
||||
test.after(() => {
|
||||
closeDb();
|
||||
for (const s of ['', '-wal', '-shm']) { try { fs.unlinkSync(dbPath + s); } catch {} }
|
||||
});
|
||||
|
||||
test('first quick pay creates the payment (201) and drops the balance once', async () => {
|
||||
const { status, data } = await call('post', '/quick', {
|
||||
userId, body: { bill_id: billId, amount: 100, paid_date: '2026-07-01' },
|
||||
});
|
||||
assert.equal(status, 201);
|
||||
assert.equal(data.amount, 100, 'payment amount in dollars');
|
||||
|
||||
const db = getDb();
|
||||
const count = db.prepare('SELECT COUNT(*) c FROM payments WHERE bill_id = ? AND deleted_at IS NULL').get(billId).c;
|
||||
assert.equal(count, 1);
|
||||
const bal = db.prepare('SELECT current_balance FROM bills WHERE id = ?').get(billId).current_balance;
|
||||
assert.equal(bal, 90000, '1000.00 − 100.00 = 900.00 (cents)');
|
||||
});
|
||||
|
||||
test('a duplicate quick pay (same bill+date+amount) is idempotent — no second row, balance unchanged', async () => {
|
||||
const { status, data } = await call('post', '/quick', {
|
||||
userId, body: { bill_id: billId, amount: 100, paid_date: '2026-07-01' },
|
||||
});
|
||||
assert.equal(status, 200, 'returns the existing payment, not a new 201');
|
||||
assert.equal(data.amount, 100);
|
||||
|
||||
const db = getDb();
|
||||
const count = db.prepare('SELECT COUNT(*) c FROM payments WHERE bill_id = ? AND deleted_at IS NULL').get(billId).c;
|
||||
assert.equal(count, 1, 'still exactly one payment');
|
||||
const bal = db.prepare('SELECT current_balance FROM bills WHERE id = ?').get(billId).current_balance;
|
||||
assert.equal(bal, 90000, 'balance NOT dropped a second time');
|
||||
});
|
||||
|
||||
test('a different amount on the same day is a legitimate new payment', async () => {
|
||||
const { status } = await call('post', '/quick', {
|
||||
userId, body: { bill_id: billId, amount: 50, paid_date: '2026-07-01' },
|
||||
});
|
||||
assert.equal(status, 201);
|
||||
const db = getDb();
|
||||
const count = db.prepare('SELECT COUNT(*) c FROM payments WHERE bill_id = ? AND deleted_at IS NULL').get(billId).c;
|
||||
assert.equal(count, 2);
|
||||
const bal = db.prepare('SELECT current_balance FROM bills WHERE id = ?').get(billId).current_balance;
|
||||
assert.equal(bal, 85000, '900.00 − 50.00 = 850.00 (cents)');
|
||||
});
|
||||
|
|
@ -0,0 +1,158 @@
|
|||
'use strict';
|
||||
|
||||
// T1 — the Tracker's core aggregation (getTracker summary + bank tracking +
|
||||
// getOverdueCount) had no dedicated tests despite being the densest money-math
|
||||
// in the app. Covers the occurrence-gating fix (annual/off-month bills must not
|
||||
// inflate the bank card's unpaid/remaining), summary totals, the bank-mode
|
||||
// remaining agreement, cents↔dollars integrity, and overdue gating.
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const fs = require('node:fs');
|
||||
|
||||
const dbPath = path.join(os.tmpdir(), `bill-tracker-trackersvc-${process.pid}.sqlite`);
|
||||
process.env.DB_PATH = dbPath;
|
||||
|
||||
const { getDb, closeDb } = require('../db/database');
|
||||
const { getTracker, getOverdueCount } = require('../services/trackerService');
|
||||
|
||||
// Fixed "now" so occurrence gating + statuses are deterministic (mid-June 2026).
|
||||
const JUNE_20 = new Date(2026, 5, 20, 12, 0, 0);
|
||||
|
||||
let db;
|
||||
function mkUser(name) {
|
||||
return db.prepare(
|
||||
"INSERT INTO users (username, password_hash, role, active) VALUES (?, 'x', 'user', 1)",
|
||||
).run(name).lastInsertRowid;
|
||||
}
|
||||
const insertBill = () => db.prepare(`
|
||||
INSERT INTO bills (user_id, name, due_day, billing_cycle, cycle_type, cycle_day, expected_amount, active)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, 1)
|
||||
`);
|
||||
function enableBank(userId, balanceCents) {
|
||||
const acctId = db.prepare(`
|
||||
INSERT INTO financial_accounts (user_id, name, org_name, account_type, balance, available_balance, monitored)
|
||||
VALUES (?, 'Checking', 'Test Bank', 'checking', ?, ?, 1)
|
||||
`).run(userId, balanceCents, balanceCents).lastInsertRowid;
|
||||
const setSetting = db.prepare('INSERT INTO user_settings (user_id, key, value) VALUES (?, ?, ?)');
|
||||
setSetting.run(userId, 'bank_tracking_enabled', 'true');
|
||||
setSetting.run(userId, 'bank_tracking_account_id', String(acctId));
|
||||
// Pending window 0 so no recent-manual-payment guessing affects the balance.
|
||||
setSetting.run(userId, 'bank_tracking_pending_days', '0');
|
||||
}
|
||||
|
||||
test.before(() => { db = getDb(); });
|
||||
test.after(() => {
|
||||
closeDb();
|
||||
for (const s of ['', '-wal', '-shm']) { try { fs.rmSync(dbPath + s); } catch {} }
|
||||
});
|
||||
|
||||
test('bank mode: annual/off-month bill does NOT inflate unpaid_this_month or remaining (gating fix)', () => {
|
||||
const userId = mkUser('t1-gating');
|
||||
const ins = insertBill();
|
||||
ins.run(userId, 'Monthly', 15, 'monthly', 'monthly', null, 10000); // $100 due every month
|
||||
ins.run(userId, 'Annual (Jan)', 1, 'annually', 'annual', '1', 50000); // $500, not due in June
|
||||
enableBank(userId, 200000); // $2,000 balance
|
||||
|
||||
const t = getTracker(userId, { year: 2026, month: 6 }, JUNE_20);
|
||||
|
||||
assert.equal(t.bank_tracking.enabled, true);
|
||||
// Only the $100 monthly bill is due in June; the $500 annual bill is excluded.
|
||||
assert.equal(t.bank_tracking.unpaid_this_month, 100, 'gated: $100, not $600');
|
||||
assert.equal(t.bank_tracking.balance, 2000, 'balance in dollars (fromCents)');
|
||||
assert.equal(t.bank_tracking.remaining, 1900, '2000 − 100 (not 2000 − 600)');
|
||||
// Fix #2: the summary remaining figures agree with the bank card.
|
||||
assert.equal(t.summary.remaining, 1900);
|
||||
assert.equal(t.summary.total_remaining, 1900);
|
||||
});
|
||||
|
||||
test('summary totals: gated expected, total_paid, paid_toward_due (cents→dollars)', () => {
|
||||
const userId = mkUser('t1-summary');
|
||||
const ins = insertBill();
|
||||
const billA = ins.run(userId, 'A due 15', 15, 'monthly', 'monthly', null, 10000).lastInsertRowid; // $100
|
||||
const billB = ins.run(userId, 'B due 1', 1, 'monthly', 'monthly', null, 20000).lastInsertRowid; // $200
|
||||
ins.run(userId, 'Annual (Jan)', 1, 'annually', 'annual', '1', 90000); // $900, excluded in June
|
||||
|
||||
const pay = db.prepare(
|
||||
"INSERT INTO payments (bill_id, amount, paid_date, payment_source) VALUES (?, ?, ?, 'manual')",
|
||||
);
|
||||
pay.run(billA, 10000, '2026-06-15'); // pay A in full ($100)
|
||||
pay.run(billB, 5000, '2026-06-10'); // partial toward B ($50)
|
||||
|
||||
const t = getTracker(userId, { year: 2026, month: 6 }, JUNE_20);
|
||||
|
||||
assert.equal(t.summary.total_expected, 300, 'only June bills: 100 + 200 (annual excluded)');
|
||||
assert.equal(t.summary.total_paid, 150, '100 + 50');
|
||||
assert.equal(t.summary.paid_toward_due, 150, 'both payments are ≤ their due amount');
|
||||
assert.equal(t.rows.filter(r => r.name === 'Annual (Jan)').length, 0, 'annual bill absent from rows');
|
||||
});
|
||||
|
||||
test('summary.remaining falls back to outstanding balance when no bank + no starting amounts', () => {
|
||||
const userId = mkUser('t1-nobank');
|
||||
insertBill().run(userId, 'Solo due 15', 15, 'monthly', 'monthly', null, 10000); // $100, unpaid
|
||||
const t = getTracker(userId, { year: 2026, month: 6 }, JUNE_20);
|
||||
assert.equal(t.bank_tracking.enabled, false);
|
||||
assert.equal(t.summary.has_starting_amounts, false);
|
||||
// No bank / no starting amounts → remaining is the outstanding still-owed for
|
||||
// the current period (June 20 → "15th" period); the $100 bill is unpaid.
|
||||
assert.equal(t.summary.remaining, 100);
|
||||
});
|
||||
|
||||
test('auto_mark_paid bill creates an autopay payment and drops the balance atomically', () => {
|
||||
const userId = mkUser('t1-autopay');
|
||||
// Eligible: autopay on, assumed_paid, due June 1 (past on June 20), auto_mark_paid.
|
||||
db.prepare(`
|
||||
INSERT INTO bills (user_id, name, due_day, billing_cycle, cycle_type, cycle_day,
|
||||
expected_amount, current_balance, autopay_enabled, autodraft_status, auto_mark_paid, active)
|
||||
VALUES (?, 'Autodraft', 1, 'monthly', 'monthly', NULL, 10000, 50000, 1, 'assumed_paid', 1, 1)
|
||||
`).run(userId);
|
||||
|
||||
const t = getTracker(userId, { year: 2026, month: 6 }, JUNE_20);
|
||||
|
||||
const bill = db.prepare("SELECT id, current_balance FROM bills WHERE user_id = ?").get(userId);
|
||||
const pays = db.prepare("SELECT amount, method, payment_source FROM payments WHERE bill_id = ? AND deleted_at IS NULL").all(bill.id);
|
||||
assert.equal(pays.length, 1, 'exactly one auto-mark payment created');
|
||||
assert.equal(pays[0].amount, 10000, '$100 in cents');
|
||||
assert.equal(pays[0].method, 'autopay');
|
||||
assert.equal(bill.current_balance, 40000, '500.00 − 100.00 = 400.00 (balance applied)');
|
||||
// The row should read as done (paid/autodraft), and running again must not double-charge.
|
||||
const t2 = getTracker(userId, { year: 2026, month: 6 }, JUNE_20);
|
||||
const paysAfter = db.prepare("SELECT COUNT(*) c FROM payments WHERE bill_id = ? AND deleted_at IS NULL").get(bill.id).c;
|
||||
assert.equal(paysAfter, 1, 'second load does not create a duplicate autopay payment');
|
||||
assert.ok(t.summary.count_paid + t.summary.count_autodraft >= 1);
|
||||
assert.ok(t2);
|
||||
});
|
||||
|
||||
test('GET /tracker returns a standardized error for an invalid month', async () => {
|
||||
const router = require('../routes/tracker');
|
||||
const layer = router.stack.find(l => l.route?.path === '/' && l.route.methods.get);
|
||||
const handler = layer.route.stack[layer.route.stack.length - 1].handle;
|
||||
const userId = mkUser('t1-route');
|
||||
const result = await new Promise((resolve) => {
|
||||
const req = { query: { year: '2026', month: '13' }, user: { id: userId, role: 'user' } };
|
||||
const res = {
|
||||
statusCode: 200,
|
||||
status(c) { this.statusCode = c; return this; },
|
||||
json(d) { resolve({ status: this.statusCode, data: d }); },
|
||||
};
|
||||
handler(req, res);
|
||||
});
|
||||
assert.equal(result.status, 400);
|
||||
assert.equal(result.data.code, 'VALIDATION_ERROR', 'standardized shape, not a plain {error}');
|
||||
assert.match(result.data.message, /month/);
|
||||
});
|
||||
|
||||
test('getOverdueCount gates by occurrence and honors paid/skip', () => {
|
||||
const userId = mkUser('t1-overdue');
|
||||
const ins = insertBill();
|
||||
ins.run(userId, 'Overdue monthly', 1, 'monthly', 'monthly', null, 10000); // due June 1, unpaid → overdue on June 20
|
||||
ins.run(userId, 'Annual (Jan)', 1, 'annually', 'annual', '1', 50000); // not due in June → NOT overdue
|
||||
const paidBill = ins.run(userId, 'Paid monthly', 1, 'monthly', 'monthly', null, 10000).lastInsertRowid;
|
||||
db.prepare("INSERT INTO payments (bill_id, amount, paid_date, payment_source) VALUES (?, 10000, '2026-06-02', 'manual')")
|
||||
.run(paidBill);
|
||||
|
||||
const { count, names } = getOverdueCount(userId, JUNE_20);
|
||||
assert.equal(count, 1, 'only the unpaid monthly bill due June 1 is overdue');
|
||||
assert.deepEqual(names, ['Overdue monthly']);
|
||||
});
|
||||
Loading…
Reference in New Issue