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