feat: Payoff Custom mode, Summary reordering, unifed billing schedule, SimpleFIN + backup fixes (batch v0.34.1.3)

This commit is contained in:
null 2026-05-30 21:20:51 -05:00
parent c23cae1107
commit 90cfed035b
38 changed files with 1294 additions and 526 deletions

View File

@ -1,28 +1,59 @@
# Bill Tracker — Changelog # Bill Tracker — Changelog
## v0.34.1.2 ## v0.34.1.3
### 🚀 Features ### 🚀 Features
- **Reordering across management pages** — Bills, Subscriptions, Categories, and Snowball now expose tracker-style drag/up/down controls. Bill-backed pages persist through `sort_order`; Categories adds its own persisted `sort_order` API. - **Reordering across management pages** — Bills, Subscriptions, Categories, and Snowball now expose tracker-style drag/up/down controls. Bill-backed pages persist through `sort_order`; Categories adds its own persisted `sort_order` API.
- **Snowball readiness warning** — Clicking "Start Snowball Plan" when any readiness checklist items are still incomplete now shows an AlertDialog listing the pending items and asking for confirmation before proceeding.
- **Payoff Simulator — all bills load** — Simulator now loads from `api.bills()` (all active bills) instead of the snowball-only endpoint, so any bill with a `current_balance` appears in the dropdown regardless of category.
- **Payoff Simulator — Custom mode** — Added a "Custom — not in Bill Tracker" option to the dropdown. Selecting it reveals Name (optional) and Balance (required) inputs, letting users simulate any loan or debt without creating a bill. Apply-to-budget and minimum-payment UI are hidden in custom mode.
- **Payoff Simulator — print** — Added a Print button (top-right of page header) that triggers `window.print()`. Print styles isolate the simulator region, hide interactive controls, and inject a summary line showing the simulated parameters.
- **Summary bill ordering** — Summary expenses now use the persisted bill order and support tracker-style drag/up/down reordering, while hiding reorder controls from printed/PDF summaries.
- **Unified bill schedule editing** — Edit Bill now uses one canonical "Billing Schedule" field instead of separate Billing Cycle and Cycle Type controls.
### 🔧 Changed ### 🔧 Changed
- **Bump**`0.34.1.1``0.34.1.2` - **Bump**`0.34.1.2``0.34.1.3`
- **Payoff Simulator — subscriptions excluded** — Added explicit `!is_subscription` guard to the bill filter so subscription bills never appear in the payoff dropdown even if a balance is accidentally set on one.
- **SimpleFIN sync window corrected** — Hard limit updated from 90 → 45 days (actual SimpleFIN Bridge cap). Initial seed and backfill use 44 days (1-day buffer). Routine sync default remains 30 days. The admin `sync_days` setting was previously stored but never read — it now correctly drives routine auto-sync and manual "Sync Now" lookback.
- **Backups status badge fixed** — System Status page Backups card previously showed "Enabled" (green) whenever `backup_enabled` was true, even with no schedule configured. Badge now reflects the scheduler state: "Scheduled" (green) when automatic backups are active, "Manual Only" (amber) when enabled but unscheduled, "Disabled" (amber) when off. Added Schedule and Next Backup rows to the card.
- **SimpleFIN admin card — sync explainer** — "Transaction history" field replaced with two clearly labelled blocks: Initial connect & backfill (fixed 44 days, read-only) and Routine sync lookback (editable, 145 days, default 30). Amber warning appears when the routine value reaches the 45-day limit. Persistent info note keeps the hard limit visible at all times.
- **SimpleFIN account monitoring** — Turning off tracking for an account now prevents new transaction ingestion for that account and excludes existing transactions from matching, merchant-rule sync, and subscription recommendation/search flows.
- **Snowball extra payment focus** — The extra monthly budget input now uses a brighter, professional focus panel with the live monthly amount called out.
- **Snowball drag behavior** — Snowball custom ordering now uses the same native drag/drop pattern and visual feedback as the Tracker page.
- **Scheduled backup retention** — The database backup scheduler now starts with the server only when enabled in Admin settings and prunes only scheduled backups, keeping the configured default of 2 without deleting manual, imported, or pre-restore backups.
- **Billing schedule migration** — Added migration v0.76 to backfill legacy billing-cycle values into `cycle_type`, normalize `cycle_day`, and derive the legacy `billing_cycle` value from the canonical schedule.
--- ---
## v0.34.1.1 ## v0.34.1
### 🚀 Features ### 🚀 Features
- **Persistent tracker bill ordering** — Added `sort_order` on bills, `PUT /api/bills/reorder`, and tracker drag/up/down controls so bill order can be changed and remembered. - **Persistent tracker bill ordering** — Added `sort_order` on bills, `PUT /api/bills/reorder`, and tracker drag/up/down controls so bill order can be changed and remembered.
- **Bill archive endpoint** — Added `PUT /api/bills/:id/archived` to hide or restore bills without deleting them. - **Bill archive endpoint** — Added `PUT /api/bills/:id/archived` to hide or restore bills without deleting them.
- **Subscription catalog matching** — Subscription recommendations now use the DB-backed `subscription_catalog` as a strong matching signal alongside the existing recurrence algorithm. Known services can surface as high-confidence recommendations, with catalog name/type/website carried into the Track flow. - **Subscription catalog matching** — Subscription recommendations now use the DB-backed `subscription_catalog` as a strong matching signal alongside the existing recurrence algorithm. Known services can surface as high-confidence recommendations, with catalog name/type/website carried into the Track flow.
- **Claude.ai catalog seed** — Updated the known subscription catalog so Claude.ai/Anthropic transaction descriptors match the Claude Pro subscription entry. - **Claude.ai catalog seed** — Updated the known subscription catalog so Claude.ai/Anthropic transaction descriptors match the Claude Pro subscription entry.
- **Subscription transaction match search** — Added `/api/subscriptions/transaction-matches` for the Subscriptions page. Bank transaction search now annotates known catalog hits, shows "Known: service" badges, and pre-fills new subscriptions from catalog metadata when available. - **Subscription transaction match search** — Added `/api/subscriptions/transaction-matches` for the Subscriptions page. Bank transaction search now annotates known catalog hits, shows "Known: service" badges, and pre-fills new subscriptions from catalog metadata when available.
- **Payoff Simulator page** — New `/payoff` route in sidebar. Select any debt from a dropdown; inputs auto-populate from bill rate, minimum, and expected amount (all editable). Live-updating custom SVG chart with 3 tracks: slate dashed (min-only), indigo dashed (snowball plan), amber solid (simulation). Stats cards show interest saved vs minimum, time saved, and total paid breakdown. "Apply to budget" pushes sim payment back to bill's expected amount with undo support. - **Payoff Simulator page** — New `/payoff` route in sidebar. Select any debt from a dropdown; inputs auto-populate from bill rate, minimum, and expected amount (all editable). Live-updating custom SVG chart with 3 tracks: slate dashed (min-only), indigo dashed (snowball plan), amber solid (simulation). Stats cards show interest saved vs minimum, time saved, and total paid breakdown. "Apply to budget" pushes sim payment back to bill's expected amount with undo support.
- **Snowball plan lifecycle** — Snowball page now supports committing to a plan. "Start Snowball Plan" button appears once ≥3 readiness items are checked. Active plan shows a collapsible emerald banner with pulsing status dot, per-debt progress bars, and on-track/ahead/behind indicators computed from the plan's initial snapshot vs. current balances. Actions: Pause · Resume · Complete · Abandon · New Plan (with AlertDialog confirmation). - **Snowball plan lifecycle** — Snowball page now supports committing to a plan. "Start Snowball Plan" button appears once ≥3 readiness items are checked. Active plan shows a collapsible emerald banner with pulsing status dot, per-debt progress bars, and on-track/ahead/behind indicators computed from the plan's initial snapshot vs. current balances. Actions: Pause · Resume · Complete · Abandon · New Plan (with AlertDialog confirmation).
- **Snowball plan history** — Collapsible history panel at the bottom of the Snowball page lists all past plans (completed, abandoned, paused) with status badges, date ranges, and expandable debt snapshot tables showing starting balance, projected payoff, projected interest, and current balance with "Paid off ✓" on cleared debts. - **Snowball plan history** — Collapsible history panel at the bottom of the Snowball page lists all past plans (completed, abandoned, paused) with status badges, date ranges, and expandable debt snapshot tables showing starting balance, projected payoff, projected interest, and current balance with "Paid off ✓" on cleared debts.
- **`snowball_plans` table** — Migration v0.73 adds persistent plan storage: status, method, extra_payment, started/paused/completed timestamps, and a JSON plan_snapshot of the initial projection and per-debt starting balances. 8 new API endpoints under `/api/snowball/plans`. - **`snowball_plans` table** — Migration v0.73 adds persistent plan storage: status, method, extra_payment, started/paused/completed timestamps, and a JSON plan_snapshot of the initial projection and per-debt starting balances. 8 new API endpoints under `/api/snowball/plans`.
- **Price Change Insights panel** — Tracker page now shows a collapsible amber panel when recurring bills have been paid at a different amount than expected for 2+ consecutive months. Per-bill "Update to $X.XX" action (with undo toast) and "Dismiss" (hidden for 30 days). TrendingUp/TrendingDown icons and teal palette for decreases. - **Price Change Insights panel** — Tracker page now shows a collapsible amber panel when recurring bills have been paid at a different amount than expected for 2+ consecutive months. Per-bill "Update to $X.XX" action (with undo toast) and "Dismiss" (hidden for 30 days). TrendingUp/TrendingDown icons and teal palette for decreases.
@ -40,7 +71,7 @@
- Updated status: Keyboard Navigation/Shortcuts → partial (Esc + Cmd+K done, arrow-key grid not) - Updated status: Keyboard Navigation/Shortcuts → partial (Esc + Cmd+K done, arrow-key grid not)
- Confirmed not implemented: Projected Cash Flow, Recurring Payment Rules (partial), Calendar Agenda, Filtered Exports, Payment Method Tracking, Unit Tests, Bill Grouping, Form State Management — all remain in FUTURE.md - Confirmed not implemented: Projected Cash Flow, Recurring Payment Rules (partial), Calendar Agenda, Filtered Exports, Payment Method Tracking, Unit Tests, Bill Grouping, Form State Management — all remain in FUTURE.md
### 🛠 Internal ### 🛠 Internal
- **Migration hardening** — Made late snooze/drift migrations idempotent for fresh databases. - **Migration hardening** — Made late snooze/drift migrations idempotent for fresh databases.
- **Subscription matching tests** — Added coverage for known catalog recommendations and catalog-annotated subscription transaction search. - **Subscription matching tests** — Added coverage for known catalog recommendations and catalog-annotated subscription transaction search.

View File

@ -16,6 +16,12 @@ import {
} from '@/components/ui/select'; } from '@/components/ui/select';
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 {
BILLING_SCHEDULE_OPTIONS,
billingCycleForSchedule,
defaultCycleDayForSchedule,
scheduleValue,
} from '@/lib/billingSchedule';
function getOrdinalSuffix(day) { function getOrdinalSuffix(day) {
if (day > 3 && day < 21) return 'th'; if (day > 3 && day < 21) return 'th';
@ -27,10 +33,6 @@ function getOrdinalSuffix(day) {
} }
} }
function defaultCycleDayFor(type) {
return type === 'weekly' || type === 'biweekly' ? 'monday' : '1';
}
// Radix Select crashes on empty string value // Radix Select crashes on empty string value
const CAT_NONE = 'none'; const CAT_NONE = 'none';
const PAYMENT_METHODS = [ const PAYMENT_METHODS = [
@ -121,9 +123,9 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa
const [dueDay, setDueDay] = useState(String(sourceBill?.due_day || '')); const [dueDay, setDueDay] = useState(String(sourceBill?.due_day || ''));
const [expectedAmount, setExpected] = useState(String(sourceBill?.expected_amount || '')); const [expectedAmount, setExpected] = useState(String(sourceBill?.expected_amount || ''));
const [interestRate, setInterestRate] = useState(sourceBill?.interest_rate == null ? '' : String(sourceBill.interest_rate)); const [interestRate, setInterestRate] = useState(sourceBill?.interest_rate == null ? '' : String(sourceBill.interest_rate));
const [billingCycle, setCycle] = useState(sourceBill?.billing_cycle || 'monthly'); const initialCycleType = scheduleValue(sourceBill || {});
const [cycleType, setCycleType] = useState(sourceBill?.cycle_type || 'monthly'); const [cycleType, setCycleType] = useState(initialCycleType);
const [cycleDay, setCycleDay] = useState(sourceBill?.cycle_day || '1'); const [cycleDay, setCycleDay] = useState(sourceBill?.cycle_day || defaultCycleDayForSchedule(initialCycleType));
const [autopay, setAutopay] = useState(!!sourceBill?.autopay_enabled); const [autopay, setAutopay] = useState(!!sourceBill?.autopay_enabled);
const [autodraftStatus, setAutodraftStatus] = useState(sourceBill?.autodraft_status || (sourceBill?.autopay_enabled ? 'assumed_paid' : 'none')); const [autodraftStatus, setAutodraftStatus] = useState(sourceBill?.autodraft_status || (sourceBill?.autopay_enabled ? 'assumed_paid' : 'none'));
const [autoMarkPaid, setAutoMarkPaid] = useState(!!sourceBill?.auto_mark_paid); const [autoMarkPaid, setAutoMarkPaid] = useState(!!sourceBill?.auto_mark_paid);
@ -296,7 +298,7 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa
const handleCycleTypeChange = (value) => { const handleCycleTypeChange = (value) => {
setCycleType(value); setCycleType(value);
setCycleDay(defaultCycleDayFor(value)); setCycleDay(defaultCycleDayForSchedule(value));
}; };
function resetPaymentForm() { function resetPaymentForm() {
@ -439,7 +441,7 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa
override_due_date: sourceBill?.override_due_date, override_due_date: sourceBill?.override_due_date,
expected_amount: parseFloat(expectedAmount) || 0, expected_amount: parseFloat(expectedAmount) || 0,
interest_rate: parsedInterestRate, interest_rate: parsedInterestRate,
billing_cycle: billingCycle, billing_cycle: billingCycleForSchedule(cycleType),
cycle_type: cycleType, cycle_type: cycleType,
cycle_day: cycleDay, cycle_day: cycleDay,
autopay_enabled: autopay, autopay_enabled: autopay,
@ -578,35 +580,17 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa
)} )}
</div> </div>
{/* Billing Cycle */} {/* Billing Schedule */}
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label className="text-xs uppercase tracking-wider text-muted-foreground">Billing Cycle</Label> <Label className="text-xs uppercase tracking-wider text-muted-foreground">Billing Schedule</Label>
<Select value={billingCycle} onValueChange={setCycle}>
<SelectTrigger className={cn(inp, 'w-full')}>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="monthly">Monthly</SelectItem>
<SelectItem value="quarterly">Quarterly</SelectItem>
<SelectItem value="annually">Annually</SelectItem>
<SelectItem value="irregular">Irregular</SelectItem>
</SelectContent>
</Select>
</div>
{/* Cycle Type */}
<div className="space-y-1.5">
<Label className="text-xs uppercase tracking-wider text-muted-foreground">Cycle Type</Label>
<Select value={cycleType} onValueChange={handleCycleTypeChange}> <Select value={cycleType} onValueChange={handleCycleTypeChange}>
<SelectTrigger className={cn(inp, 'w-full')}> <SelectTrigger className={cn(inp, 'w-full')}>
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="monthly">Monthly</SelectItem> {BILLING_SCHEDULE_OPTIONS.map(([value, label]) => (
<SelectItem value="weekly">Weekly</SelectItem> <SelectItem key={value} value={value}>{label}</SelectItem>
<SelectItem value="biweekly">Biweekly</SelectItem> ))}
<SelectItem value="quarterly">Quarterly</SelectItem>
<SelectItem value="annual">Annual</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>

View File

@ -1,5 +1,6 @@
import { ArrowDown, ArrowUp, Copy, GripVertical, PenLine, EyeOff, Eye, Clock, Trash2 } from 'lucide-react'; import { ArrowDown, ArrowUp, Copy, GripVertical, PenLine, EyeOff, Eye, Clock, Trash2 } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { scheduleLabel } from '@/lib/billingSchedule';
import { MobileBillRow } from '@/components/MobileBillRow'; import { MobileBillRow } from '@/components/MobileBillRow';
function ordinal(n) { function ordinal(n) {
@ -130,7 +131,7 @@ function BillCard({ bill, prefs = ALL_ON, onEdit, onToggle, onDelete, onHistory,
{/* Meta row */} {/* Meta row */}
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs font-medium text-muted-foreground"> <div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs font-medium text-muted-foreground">
{prefs.showCycle && <span className="capitalize">{bill.billing_cycle || 'monthly'}</span>} {prefs.showCycle && <span>{scheduleLabel(bill)}</span>}
{prefs.showCycle && prefs.showDueDay && <span className="text-border">·</span>} {prefs.showCycle && prefs.showDueDay && <span className="text-border">·</span>}

View File

@ -7,6 +7,7 @@ import {
import { toast } from 'sonner'; import { toast } from 'sonner';
import { api } from '@/api'; import { api } from '@/api';
import { cn, fmt } from '@/lib/utils'; import { cn, fmt } from '@/lib/utils';
import { scheduleLabel, scheduleValue } from '@/lib/billingSchedule';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'; import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
@ -68,7 +69,8 @@ function billSearchText(bill) {
bill.name, bill.name,
bill.category_name, bill.category_name,
bill.notes, bill.notes,
bill.billing_cycle, scheduleValue(bill),
scheduleLabel(bill),
bill.bucket, bill.bucket,
bill.website, bill.website,
amountSearchText( amountSearchText(

View File

@ -2,6 +2,7 @@ import React, { useMemo } from 'react';
import { ArrowDown, ArrowUp, Copy, GripVertical, History } from 'lucide-react'; import { ArrowDown, ArrowUp, Copy, GripVertical, History } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { scheduleLabel } from '@/lib/billingSchedule';
function hasHistoricalVisibility(bill) { function hasHistoricalVisibility(bill) {
const visibility = bill.history_visibility; const visibility = bill.history_visibility;
@ -137,7 +138,7 @@ export const MobileBillRow = React.memo(function MobileBillRow({ bill, onEdit, o
</div> </div>
<div> <div>
<p className="uppercase tracking-normal text-muted-foreground">Cycle</p> <p className="uppercase tracking-normal text-muted-foreground">Cycle</p>
<p className="mt-0.5 text-sm capitalize text-foreground">{bill.billing_cycle || 'monthly'}</p> <p className="mt-0.5 text-sm text-foreground">{scheduleLabel(bill)}</p>
</div> </div>
</div> </div>

View File

@ -21,7 +21,7 @@ const DEFAULT_SETTINGS = {
enabled: false, enabled: false,
frequency: 'daily', frequency: 'daily',
time: '02:00', time: '02:00',
retention_count: 14, retention_count: 2,
last_run_at: null, last_run_at: null,
next_run_at: null, next_run_at: null,
last_error: null, last_error: null,
@ -142,7 +142,7 @@ export default function BackupManagementCard() {
enabled: !!settings.enabled, enabled: !!settings.enabled,
frequency: settings.frequency, frequency: settings.frequency,
time: settings.time, time: settings.time,
retention_count: parseInt(settings.retention_count, 10) || 14, retention_count: parseInt(settings.retention_count, 10) || 2,
}); });
setSettings({ ...DEFAULT_SETTINGS, ...saved }); setSettings({ ...DEFAULT_SETTINGS, ...saved });
toast.success('Backup schedule saved.'); toast.success('Backup schedule saved.');

View File

@ -1,4 +1,5 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { AlertTriangle, Info } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { api } from '@/api'; import { api } from '@/api';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@ -52,8 +53,8 @@ export default function BankSyncAdminCard() {
toast.error('Sync interval must be between 0.5 and 168 hours.'); toast.error('Sync interval must be between 0.5 and 168 hours.');
return; return;
} }
if (!Number.isFinite(days) || days < 1 || days > 90) { if (!Number.isFinite(days) || days < 1 || days > 45) {
toast.error('Transaction history must be between 1 and 90 days — SimpleFIN Bridge does not support longer windows.'); toast.error('Routine sync lookback must be 145 days. SimpleFIN Bridge enforces a 45-day hard limit — values above 45 return errors.');
return; return;
} }
setSaving(true); setSaving(true);
@ -145,25 +146,72 @@ export default function BankSyncAdminCard() {
</div> </div>
</div> </div>
{/* Transaction history lookback */} {/* Sync window — two-mode explainer */}
<div className="flex items-start justify-between gap-4"> <div className="space-y-3">
<div> <div>
<p className="text-sm font-medium">Transaction history</p> <p className="text-sm font-medium">Sync lookback windows</p>
<p className="text-xs text-muted-foreground mt-0.5"> <p className="text-xs text-muted-foreground mt-0.5">
How far back to fetch transactions. Maximum 90 days this is a hard limit imposed by SimpleFIN Bridge and cannot be exceeded. SimpleFIN uses two different windows depending on sync type.
</p> </p>
</div> </div>
<div className="flex items-center gap-2 shrink-0">
<Input {/* Initial / backfill — read-only */}
type="number" <div className="rounded-lg border border-border/50 bg-muted/20 px-4 py-3 space-y-1">
min="1" <div className="flex items-center justify-between">
max="90" <p className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">
step="1" Initial connect &amp; backfill
value={syncDays} </p>
onChange={e => setSyncDays(Math.min(90, Math.max(1, parseInt(e.target.value, 10) || 90)))} <span className="font-mono text-sm font-bold">44 days</span>
className="w-20 text-sm text-right" </div>
/> <p className="text-xs text-muted-foreground">
<span className="text-sm text-muted-foreground">days</span> The first sync (and any manual backfill) always fetches the maximum 44 days of history
to build a complete transaction picture. This is fixed SimpleFIN Bridge enforces a
strict <strong>45-day hard limit</strong> and will return an error for any request beyond it.
</p>
</div>
{/* Routine sync — editable */}
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<p className="text-sm font-medium">Routine sync lookback</p>
<p className="text-xs text-muted-foreground mt-0.5">
How far back each auto-sync and manual "Sync Now" looks after the initial connect.
Recommended: <strong>730 days</strong>. Setting this near 45 increases request size
and duplicate-skip work with no benefit once history is established.
</p>
</div>
<div className="flex items-center gap-2 shrink-0">
<Input
type="number"
min="1"
max="45"
step="1"
value={syncDays}
onChange={e => setSyncDays(Math.min(45, Math.max(1, parseInt(e.target.value, 10) || 30)))}
className="w-20 text-sm text-right"
/>
<span className="text-sm text-muted-foreground">days</span>
</div>
</div>
{/* Amber warning at the SimpleFIN limit */}
{parseInt(syncDays, 10) >= 45 && (
<div className="flex items-start gap-2 rounded-lg border border-amber-500/30 bg-amber-500/8 px-3 py-2.5">
<AlertTriangle className="h-3.5 w-3.5 text-amber-500 shrink-0 mt-0.5" />
<p className="text-xs text-amber-600 dark:text-amber-400">
45 days is SimpleFIN Bridge&apos;s maximum. Requests at this limit may occasionally
fail due to request latency 30 days or less is recommended for reliable routine syncs.
</p>
</div>
)}
{/* Always-visible hard-limit note */}
<div className="flex items-start gap-2 text-xs text-muted-foreground">
<Info className="h-3.5 w-3.5 shrink-0 mt-0.5" />
<span>
SimpleFIN Bridge enforces a <strong>45-day maximum</strong> on all requests.
Any value above 45 will cause sync errors for all users.
</span>
</div> </div>
</div> </div>

View File

@ -231,6 +231,7 @@
.summary-screen-header, .summary-screen-header,
.summary-controls, .summary-controls,
.summary-actions, .summary-actions,
.summary-reorder-controls,
.summary-edit-actions, .summary-edit-actions,
.summary-income-form { .summary-income-form {
display: none !important; display: none !important;
@ -283,6 +284,10 @@
padding-left: 0 !important; padding-left: 0 !important;
} }
.summary-expense-row {
grid-template-columns: minmax(0, 1fr) 7.5rem 5.5rem !important;
}
.analytics-chart-grid { .analytics-chart-grid {
display: block !important; display: block !important;
} }

View File

@ -1,3 +1,5 @@
import { billingCycleForSchedule, scheduleValue } from './billingSchedule';
function categoryForTemplate(template, categories = []) { function categoryForTemplate(template, categories = []) {
const keywords = template?.categoryKeywords || []; const keywords = template?.categoryKeywords || [];
const match = categories.find(category => { const match = categories.find(category => {
@ -27,8 +29,8 @@ export function makeBillDraft(source, { copy = false, template = null, categorie
category_id: categoryIdOrFallback(data.category_id, template, categories), category_id: categoryIdOrFallback(data.category_id, template, categories),
due_day: data.due_day || 1, due_day: data.due_day || 1,
expected_amount: data.expected_amount ?? 0, expected_amount: data.expected_amount ?? 0,
billing_cycle: data.billing_cycle || 'monthly', billing_cycle: billingCycleForSchedule(scheduleValue(data)),
cycle_type: data.cycle_type || 'monthly', cycle_type: scheduleValue(data),
cycle_day: String(data.cycle_day || '1'), cycle_day: String(data.cycle_day || '1'),
autopay_enabled: !!data.autopay_enabled, autopay_enabled: !!data.autopay_enabled,
autodraft_status: data.autodraft_status || (data.autopay_enabled ? 'assumed_paid' : 'none'), autodraft_status: data.autodraft_status || (data.autopay_enabled ? 'assumed_paid' : 'none'),

View File

@ -0,0 +1,53 @@
export const BILLING_SCHEDULE_OPTIONS = [
['monthly', 'Monthly'],
['weekly', 'Weekly'],
['biweekly', 'Biweekly'],
['quarterly', 'Quarterly'],
['annual', 'Annual'],
];
const LABELS = Object.fromEntries(BILLING_SCHEDULE_OPTIONS);
export function scheduleFromBillingCycle(billingCycle) {
const value = String(billingCycle || '').toLowerCase();
if (value === 'quarterly') return 'quarterly';
if (value === 'annually' || value === 'annual') return 'annual';
return 'monthly';
}
export function normalizeSchedule(value, fallback = 'monthly') {
if (!value) return fallback;
const normalized = String(value).toLowerCase();
return LABELS[normalized] ? normalized : fallback;
}
export function scheduleValue(bill = {}) {
const cycleType = normalizeSchedule(bill.cycle_type, '');
const billingCycle = String(bill.billing_cycle || '').toLowerCase();
if (cycleType === 'monthly' && ['quarterly', 'annually', 'annual'].includes(billingCycle)) {
return scheduleFromBillingCycle(billingCycle);
}
return cycleType || scheduleFromBillingCycle(billingCycle);
}
export function scheduleLabel(valueOrBill) {
const value = valueOrBill && typeof valueOrBill === 'object'
? scheduleValue(valueOrBill)
: normalizeSchedule(valueOrBill);
return LABELS[value] || 'Monthly';
}
export function billingCycleForSchedule(schedule) {
const value = normalizeSchedule(schedule);
if (value === 'quarterly') return 'quarterly';
if (value === 'annual') return 'annually';
if (value === 'weekly' || value === 'biweekly') return 'irregular';
return 'monthly';
}
export function defaultCycleDayForSchedule(schedule) {
const value = normalizeSchedule(schedule);
return value === 'weekly' || value === 'biweekly' ? 'monday' : '1';
}

View File

@ -22,6 +22,7 @@ import { moveInArray, movedItemId, reorderPayload } from '@/lib/reorder';
import BillsTableInner from '@/components/BillsTableInner'; import BillsTableInner from '@/components/BillsTableInner';
import BillModal from '@/components/BillModal'; import BillModal from '@/components/BillModal';
import { makeBillDraft } from '@/lib/billDrafts'; import { makeBillDraft } from '@/lib/billDrafts';
import { scheduleLabel, scheduleValue } from '@/lib/billingSchedule';
const VISIBILITY_OPTIONS = [ const VISIBILITY_OPTIONS = [
{ value: 'default', label: 'Default', description: 'Use the app default behavior for inactive bill history.' }, { value: 'default', label: 'Default', description: 'Use the app default behavior for inactive bill history.' },
@ -196,7 +197,7 @@ const PREFS_LABELS = [
['showCategory', 'Category'], ['showCategory', 'Category'],
['showDueDay', 'Due day'], ['showDueDay', 'Due day'],
['showAmount', 'Amount'], ['showAmount', 'Amount'],
['showCycle', 'Billing cycle'], ['showCycle', 'Billing schedule'],
['showApr', 'APR'], ['showApr', 'APR'],
['showBalance', 'Balance'], ['showBalance', 'Balance'],
['showMinPayment', 'Min payment'], ['showMinPayment', 'Min payment'],
@ -732,7 +733,7 @@ export default function BillsPage() {
} }
const cycleOptions = useMemo(() => ( const cycleOptions = useMemo(() => (
Array.from(new Set(bills.map(b => b.billing_cycle || 'monthly'))).sort() Array.from(new Set(bills.map(scheduleValue))).sort()
), [bills]); ), [bills]);
const filteredBills = useMemo(() => { const filteredBills = useMemo(() => {
@ -740,7 +741,7 @@ export default function BillsPage() {
return bills.filter(bill => { return bills.filter(bill => {
if (filters.inactive && bill.active) return false; if (filters.inactive && bill.active) return false;
if (filters.category !== FILTER_ALL && String(bill.category_id ?? '') !== filters.category) return false; if (filters.category !== FILTER_ALL && String(bill.category_id ?? '') !== filters.category) return false;
if (filters.cycle !== FILTER_ALL && String(bill.billing_cycle || 'monthly') !== filters.cycle) return false; if (filters.cycle !== FILTER_ALL && scheduleValue(bill) !== filters.cycle) return false;
if (filters.autopay && !bill.autopay_enabled) return false; if (filters.autopay && !bill.autopay_enabled) return false;
if (filters.debt && !billIsDebt(bill)) return false; if (filters.debt && !billIsDebt(bill)) return false;
if (filters.firstBucket && !filters.fifteenthBucket && bill.bucket !== '1st') return false; if (filters.firstBucket && !filters.fifteenthBucket && bill.bucket !== '1st') return false;
@ -751,7 +752,8 @@ export default function BillsPage() {
bill.name, bill.name,
bill.category_name, bill.category_name,
bill.notes, bill.notes,
bill.billing_cycle, scheduleValue(bill),
scheduleLabel(bill),
bill.bucket, bill.bucket,
amountSearchText(bill.expected_amount, bill.current_balance, bill.minimum_payment, bill.interest_rate), amountSearchText(bill.expected_amount, bill.current_balance, bill.minimum_payment, bill.interest_rate),
].filter(Boolean).join(' ').toLowerCase(); ].filter(Boolean).join(' ').toLowerCase();
@ -910,12 +912,12 @@ export default function BillsPage() {
</Select> </Select>
<Select value={filters.cycle} onValueChange={value => setFilterValue('cycle', value)}> <Select value={filters.cycle} onValueChange={value => setFilterValue('cycle', value)}>
<SelectTrigger className="h-10 capitalize"> <SelectTrigger className="h-10 capitalize">
<SelectValue placeholder="Billing cycle" /> <SelectValue placeholder="Billing schedule" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value={FILTER_ALL}>All cycles</SelectItem> <SelectItem value={FILTER_ALL}>All cycles</SelectItem>
{cycleOptions.map(cycle => ( {cycleOptions.map(cycle => (
<SelectItem key={cycle} value={cycle}>{cycle.replace(/_/g, ' ')}</SelectItem> <SelectItem key={cycle} value={cycle}>{scheduleLabel(cycle)}</SelectItem>
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>

View File

@ -1,16 +1,38 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { AlertCircle, ArrowRight, Calculator, RefreshCw, RotateCcw, TrendingDown } from 'lucide-react'; import { AlertCircle, ArrowRight, Calculator, Printer, RefreshCw, RotateCcw, TrendingDown } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { api } from '@/api'; import { api } from '@/api';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Select, SelectContent, SelectGroup, SelectItem, SelectLabel,
SelectSeparator, SelectTrigger, SelectValue,
} from '@/components/ui/select'; } from '@/components/ui/select';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import PayoffChart from '@/components/snowball/PayoffChart'; import PayoffChart from '@/components/snowball/PayoffChart';
// Print isolation
const PRINT_STYLES = `
@media print {
* { visibility: hidden !important; }
#payoff-print-area,
#payoff-print-area * { visibility: visible !important; }
#payoff-print-area {
position: absolute !important;
top: 0 !important; left: 0 !important; right: 0 !important;
width: 100% !important;
padding: 24px !important;
margin: 0 !important;
background: #fff !important;
color: #111 !important;
}
#payoff-print-area .no-print { display: none !important; }
#payoff-print-area .print-only { display: block !important; }
}
`;
// Helpers // Helpers
function fmt(v) { function fmt(v) {
@ -56,7 +78,7 @@ function numMonths(track) {
return `${y} yr ${m} mo`; return `${y} yr ${m} mo`;
} }
// Stat card // Sub-components
function StatCard({ label, value, sub, color = 'amber' }) { function StatCard({ label, value, sub, color = 'amber' }) {
const colors = { const colors = {
@ -73,8 +95,6 @@ function StatCard({ label, value, sub, color = 'amber' }) {
); );
} }
// Input row
function InputRow({ label, hint, children }) { function InputRow({ label, hint, children }) {
return ( return (
<div className="space-y-1.5"> <div className="space-y-1.5">
@ -89,16 +109,15 @@ function InputRow({ label, hint, children }) {
); );
} }
// Empty states
function EmptyDebts() { function EmptyDebts() {
return ( return (
<div className="flex flex-col items-center justify-center rounded-xl border border-dashed border-border/60 bg-muted/10 px-6 py-16 text-center"> <div className="flex flex-col items-center justify-center rounded-xl border border-dashed border-border/60 bg-muted/10 px-6 py-16 text-center">
<TrendingDown className="h-10 w-10 text-muted-foreground/40 mb-4" /> <TrendingDown className="h-10 w-10 text-muted-foreground/40 mb-4" />
<p className="text-sm font-medium text-foreground">No debts with a balance found</p> <p className="text-sm font-medium text-foreground">No bills with a balance found</p>
<p className="text-xs text-muted-foreground mt-1"> <p className="text-xs text-muted-foreground mt-1">
Add a current balance to your bills on the{' '} Add a current balance to your bills on the{' '}
<a href="/snowball" className="underline text-primary hover:opacity-80">Snowball page</a>. <a href="/snowball" className="underline text-primary hover:opacity-80">Snowball page</a>,
or use the <strong>Custom</strong> option in the dropdown above.
</p> </p>
</div> </div>
); );
@ -109,7 +128,9 @@ function NoSelection() {
<div className="flex flex-col items-center justify-center rounded-xl border border-dashed border-border/60 bg-muted/10 px-6 py-16 text-center"> <div className="flex flex-col items-center justify-center rounded-xl border border-dashed border-border/60 bg-muted/10 px-6 py-16 text-center">
<Calculator className="h-10 w-10 text-muted-foreground/40 mb-4" /> <Calculator className="h-10 w-10 text-muted-foreground/40 mb-4" />
<p className="text-sm font-medium text-foreground">Select a loan or debt to begin</p> <p className="text-sm font-medium text-foreground">Select a loan or debt to begin</p>
<p className="text-xs text-muted-foreground mt-1">Choose from the dropdown above to run your simulation.</p> <p className="text-xs text-muted-foreground mt-1">
Choose from the dropdown above, or select <strong>Custom</strong> to simulate any loan.
</p>
</div> </div>
); );
} }
@ -121,24 +142,33 @@ export default function PayoffPage() {
const [extraPayment, setExtraPayment] = useState(0); const [extraPayment, setExtraPayment] = useState(0);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState(null); const [loadError, setLoadError] = useState(null);
const [selectedId, setSelectedId] = useState(null); const [selectedId, setSelectedId] = useState(null); // number | 'custom' | null
// Per-simulation state (reset when bill changes) // Custom mode inputs
const [customName, setCustomName] = useState('');
const [customBalance, setCustomBalance] = useState('');
// Per-simulation inputs (reset when selection changes)
const [simPayment, setSimPayment] = useState(''); const [simPayment, setSimPayment] = useState('');
const [simRate, setSimRate] = useState(''); const [simRate, setSimRate] = useState('');
const [oneTimeExtra, setOneTimeExtra] = useState(''); const [oneTimeExtra, setOneTimeExtra] = useState('');
const [applying, setApplying] = useState(false); const [applying, setApplying] = useState(false);
const isCustom = selectedId === 'custom';
const loadData = useCallback(() => { const loadData = useCallback(() => {
setLoading(true); setLoading(true);
setLoadError(null); setLoadError(null);
Promise.all([api.snowball(), api.snowballSettings()]) // Use api.bills() so ALL active bills with a balance appear (not just debt categories)
.then(([billData, settings]) => { Promise.all([api.bills(), api.snowballSettings()])
const debtBills = (billData || []).filter(b => (b.current_balance ?? 0) > 0); .then(([allBills, settings]) => {
setBills(debtBills); const withBalance = (allBills || [])
.filter(b => (b.current_balance ?? 0) > 0 && !b.is_subscription)
.sort((a, b) => a.name.localeCompare(b.name));
setBills(withBalance);
setExtraPayment(Number(settings?.extra_payment) || 0); setExtraPayment(Number(settings?.extra_payment) || 0);
if (debtBills.length > 0 && !selectedId) { if (withBalance.length > 0 && !selectedId) {
setSelectedId(debtBills[0].id); setSelectedId(withBalance[0].id);
} }
}) })
.catch(err => setLoadError(err.message || 'Failed to load data')) .catch(err => setLoadError(err.message || 'Failed to load data'))
@ -147,53 +177,65 @@ export default function PayoffPage() {
useEffect(() => { loadData(); }, [loadData]); useEffect(() => { loadData(); }, [loadData]);
const bill = useMemo(() => bills.find(b => b.id === selectedId) ?? null, [bills, selectedId]); const bill = useMemo(
const isAttack = bills[0]?.id === selectedId; () => (isCustom ? null : bills.find(b => b.id === selectedId) ?? null),
[bills, selectedId, isCustom],
);
// Reset sim inputs whenever the selected bill changes const isAttack = !isCustom && bills[0]?.id === selectedId;
// Reset sim inputs whenever selection changes
useEffect(() => { useEffect(() => {
if (isCustom) {
setSimPayment('');
setSimRate('0');
setOneTimeExtra('');
return;
}
if (!bill) return; if (!bill) return;
setSimPayment(String(Math.max(bill.minimum_payment ?? 0, bill.expected_amount ?? 0))); setSimPayment(String(Math.max(bill.minimum_payment ?? 0, bill.expected_amount ?? 0)));
setSimRate(String(bill.interest_rate ?? 0)); setSimRate(String(bill.interest_rate ?? 0));
setOneTimeExtra(''); setOneTimeExtra('');
}, [bill?.id]); // eslint-disable-line react-hooks/exhaustive-deps }, [selectedId]); // eslint-disable-line react-hooks/exhaustive-deps
// Derived simulation tracks // Derived numeric values
const simPaymentN = Math.max(0, Number(simPayment) || 0); const simPaymentN = Math.max(0, Number(simPayment) || 0);
const simRateN = Math.max(0, Number(simRate) || 0); const simRateN = Math.max(0, Number(simRate) || 0);
const oneTimeExtraN = Math.max(0, Number(oneTimeExtra) || 0); const oneTimeExtraN = Math.max(0, Number(oneTimeExtra) || 0);
const minPayment = bill?.minimum_payment ?? 0; const minPayment = bill?.minimum_payment ?? 0;
const activeBalance = isCustom ? (parseFloat(customBalance) || 0) : (bill?.current_balance ?? 0);
const activeName = isCustom ? (customName.trim() || 'Custom Loan') : (bill?.name ?? '');
const { minTrack, currentTrack, simTrack } = useMemo(() => { const { minTrack, currentTrack, simTrack } = useMemo(() => {
if (!bill) return { minTrack: [], currentTrack: [], simTrack: [] }; if (!activeBalance) return { minTrack: [], currentTrack: [], simTrack: [] };
const b = bill.current_balance; const min = !isCustom && minPayment > 0 ? minPayment : 0.01;
const min = minPayment > 0 ? minPayment : 0.01; const currentPmt = !isCustom && isAttack ? min + extraPayment : min;
const currentPmt = isAttack ? min + extraPayment : min;
return { return {
minTrack: buildPayoffSchedule(b, simRateN, min), minTrack: isCustom ? [] : buildPayoffSchedule(activeBalance, simRateN, min),
currentTrack: buildPayoffSchedule(b, simRateN, currentPmt), currentTrack: isCustom ? [] : buildPayoffSchedule(activeBalance, simRateN, currentPmt),
simTrack: buildPayoffSchedule(b, simRateN, simPaymentN, oneTimeExtraN), simTrack: buildPayoffSchedule(activeBalance, simRateN, simPaymentN, oneTimeExtraN),
}; };
}, [bill, simRateN, simPaymentN, oneTimeExtraN, minPayment, isAttack, extraPayment]); }, [activeBalance, isCustom, simRateN, simPaymentN, oneTimeExtraN, minPayment, isAttack, extraPayment]);
const minInterest = useMemo(() => minTrack.reduce((s, m) => s + m.interest, 0), [minTrack]); const minInterest = useMemo(() => minTrack.reduce((s, m) => s + m.interest, 0), [minTrack]);
const simInterest = useMemo(() => simTrack.reduce((s, m) => s + m.interest, 0), [simTrack]); const simInterest = useMemo(() => simTrack.reduce((s, m) => s + m.interest, 0), [simTrack]);
const interestSavings = Math.max(0, minInterest - simInterest); const interestSavings = Math.max(0, minInterest - simInterest);
const timeSavings = Math.max(0, minTrack.length - simTrack.length); const timeSavings = Math.max(0, minTrack.length - simTrack.length);
const simTotalPaid = simInterest + (bill?.current_balance ?? 0); const simTotalPaid = simInterest + activeBalance;
const simPayoffLabel = payoffLabel(simTrack); const simPayoffLabel = payoffLabel(simTrack);
const minPayoffLabel = payoffLabel(minTrack); const minPayoffLabel = payoffLabel(minTrack);
const simDuration = numMonths(simTrack); const simDuration = numMonths(simTrack);
const paymentBelowMin = simPaymentN > 0 && simPaymentN < minPayment && minPayment > 0; const paymentBelowMin = !isCustom && simPaymentN > 0 && simPaymentN < minPayment && minPayment > 0;
const paymentTooLow = bill && simPaymentN > 0 && simTrack.length === 0; const paymentTooLow = activeBalance > 0 && simPaymentN > 0 && simTrack.length === 0;
const customNeedsBalance = isCustom && !customBalance;
const defaultSimPayment = bill const defaultSimPayment = bill
? String(Math.max(bill.minimum_payment ?? 0, bill.expected_amount ?? 0)) ? String(Math.max(bill.minimum_payment ?? 0, bill.expected_amount ?? 0))
: ''; : '';
const defaultRate = bill ? String(bill.interest_rate ?? 0) : ''; const defaultRate = bill ? String(bill.interest_rate ?? 0) : '';
const isDirty = simPayment !== defaultSimPayment || simRate !== defaultRate || oneTimeExtra !== ''; const isDirty = !isCustom && (simPayment !== defaultSimPayment || simRate !== defaultRate || oneTimeExtra !== '');
const handleReset = () => { const handleReset = () => {
if (!bill) return; if (!bill) return;
@ -202,6 +244,8 @@ export default function PayoffPage() {
setOneTimeExtra(''); setOneTimeExtra('');
}; };
const handlePrint = () => window.print();
const handleApply = async () => { const handleApply = async () => {
if (!bill || applying) return; if (!bill || applying) return;
setApplying(true); setApplying(true);
@ -225,6 +269,10 @@ export default function PayoffPage() {
} }
}; };
const handleSelectChange = (val) => {
setSelectedId(val === 'custom' ? 'custom' : Number(val));
};
// Render // Render
if (loading) { if (loading) {
@ -234,9 +282,7 @@ export default function PayoffPage() {
<div className="h-4 w-96 rounded bg-muted/50" /> <div className="h-4 w-96 rounded bg-muted/50" />
<div className="grid grid-cols-1 lg:grid-cols-[360px_1fr] gap-6"> <div className="grid grid-cols-1 lg:grid-cols-[360px_1fr] gap-6">
<div className="space-y-4"> <div className="space-y-4">
{[1, 2, 3, 4].map(i => ( {[1, 2, 3, 4].map(i => <div key={i} className="h-16 rounded-xl bg-muted/40" />)}
<div key={i} className="h-16 rounded-xl bg-muted/40" />
))}
</div> </div>
<div className="h-96 rounded-xl bg-muted/40" /> <div className="h-96 rounded-xl bg-muted/40" />
</div> </div>
@ -257,236 +303,360 @@ export default function PayoffPage() {
); );
} }
const showResults = (isCustom && activeBalance > 0 && simTrack.length > 0) ||
(!isCustom && bill && simTrack.length > 0);
return ( return (
<div> <>
<style>{PRINT_STYLES}</style>
{/* Page header */} <div id="payoff-print-area">
<div className="mb-6 flex items-start justify-between gap-4">
<div> {/* ── Print-only summary header (hidden on screen) ── */}
<h1 className="text-2xl font-bold tracking-tight">Payoff Simulator</h1> <div className="print-only" style={{ display: 'none' }}>
<p className="text-sm text-muted-foreground mt-0.5"> <h2 style={{ fontSize: '18px', fontWeight: 700, marginBottom: '4px' }}>
Explore how extra payments reduce interest and shorten your payoff timeline. Payoff Simulator {activeName || '—'}
</p> </h2>
{activeBalance > 0 && (
<p style={{ fontSize: '12px', color: '#555', marginBottom: '12px' }}>
Balance: {fmt(activeBalance)}
{simRateN > 0 && ` · Rate: ${simRateN}%`}
{simPaymentN > 0 && ` · Payment: ${fmt(simPaymentN)}/mo`}
{oneTimeExtraN > 0 && ` · One-time extra: ${fmt(oneTimeExtraN)}`}
</p>
)}
</div> </div>
{isDirty && (
<Button size="sm" variant="ghost" onClick={handleReset} className="gap-1.5 text-xs shrink-0 mt-1">
<RotateCcw className="h-3 w-3" /> Reset
</Button>
)}
</div>
{/* Bill selector */} {/* ── Page header ── */}
<div className="mb-6"> <div className="mb-6 flex items-start justify-between gap-4 no-print">
{bills.length === 0 ? ( <div>
<EmptyDebts /> <h1 className="text-2xl font-bold tracking-tight">Payoff Simulator</h1>
) : ( <p className="text-sm text-muted-foreground mt-0.5">
<Select value={selectedId ? String(selectedId) : ''} onValueChange={v => setSelectedId(Number(v))}> Explore how extra payments reduce interest and shorten your payoff timeline.
<SelectTrigger className="w-72"> </p>
</div>
<div className="flex items-center gap-2 shrink-0 mt-1">
{isDirty && (
<Button size="sm" variant="ghost" onClick={handleReset} className="gap-1.5 text-xs">
<RotateCcw className="h-3 w-3" /> Reset
</Button>
)}
<Button
size="sm"
variant="outline"
onClick={handlePrint}
className="gap-1.5 text-xs"
title="Print-friendly view"
>
<Printer className="h-3.5 w-3.5" /> Print
</Button>
</div>
</div>
{/* ── Bill/debt selector ── */}
<div className="mb-6 no-print">
<Select
value={selectedId != null ? String(selectedId) : ''}
onValueChange={handleSelectChange}
>
<SelectTrigger className="w-80">
<SelectValue placeholder="Select a loan or debt…" /> <SelectValue placeholder="Select a loan or debt…" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{bills.map(b => ( {bills.length > 0 && (
<SelectItem key={b.id} value={String(b.id)}> <SelectGroup>
<span className="font-medium">{b.name}</span> <SelectLabel>Your Bills</SelectLabel>
{b.current_balance ? ( {bills.map(b => (
<span className="ml-2 text-muted-foreground font-mono text-xs"> <SelectItem key={b.id} value={String(b.id)}>
{fmt(b.current_balance)} <span className="font-medium">{b.name}</span>
</span> <span className="ml-2 text-muted-foreground font-mono text-xs">
) : null} {fmt(b.current_balance)}
</span>
</SelectItem>
))}
</SelectGroup>
)}
{bills.length > 0 && <SelectSeparator />}
<SelectGroup>
<SelectLabel>Manual Entry</SelectLabel>
<SelectItem value="custom">
Custom not in Bill Tracker
</SelectItem> </SelectItem>
))} </SelectGroup>
</SelectContent> </SelectContent>
</Select> </Select>
)}
</div>
{/* Main content: left panel + right panel */} {bills.length === 0 && !isCustom && (
{!bill ? ( <p className="mt-3 text-xs text-muted-foreground">
bills.length > 0 ? <NoSelection /> : null No bills with a current balance found.{' '}
) : ( <button
<div className="grid grid-cols-1 lg:grid-cols-[340px_1fr] gap-6 items-start"> type="button"
className="underline text-primary hover:opacity-80"
onClick={() => setSelectedId('custom')}
>
Use Custom instead
</button>
</p>
)}
</div>
{/* ── Left panel ── */} {/* ── Empty / no-selection states ── */}
<div className="table-surface p-5 space-y-5"> {!isCustom && !bill && bills.length === 0 && <EmptyDebts />}
{!isCustom && !bill && bills.length > 0 && <NoSelection />}
{/* ── Main content ── */}
{(isCustom || bill) && (
<div className="grid grid-cols-1 lg:grid-cols-[340px_1fr] gap-6 items-start">
{/* ── Left panel ── */}
<div className="table-surface p-5 space-y-5">
{/* Custom mode: Name + Balance inputs */}
{isCustom && (
<>
<InputRow label="Loan / Debt Name" hint="Optional">
<Input
value={customName}
onChange={e => setCustomName(e.target.value)}
placeholder="e.g. Car Loan, Mortgage…"
className="no-print"
/>
<p className="print-only hidden text-sm font-semibold">{customName || 'Custom Loan'}</p>
</InputRow>
<InputRow label="Current Balance" hint="Required">
<div className="flex items-center gap-2 no-print">
<span className="text-sm text-muted-foreground shrink-0">$</span>
<Input
type="number" min="0" step="100"
value={customBalance}
onChange={e => setCustomBalance(e.target.value)}
className="font-mono"
placeholder="0.00"
autoFocus
/>
</div>
<p className="print-only hidden font-mono text-sm font-semibold">{fmt(activeBalance)}</p>
{customNeedsBalance && (
<p className="text-[11px] text-amber-500 mt-1 flex items-center gap-1 no-print">
<AlertCircle className="h-3 w-3 shrink-0" />
Balance is required to run the simulation
</p>
)}
</InputRow>
</>
)}
{/* Bill mode: Required minimum display */}
{!isCustom && (
<div className="rounded-lg bg-muted/30 px-4 py-3 flex items-center justify-between">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
Required Minimum
</span>
<span className="font-mono text-lg font-bold tabular-nums">
{minPayment > 0
? fmt(minPayment)
: <span className="text-muted-foreground text-sm">Not set</span>}
</span>
</div>
)}
{!isCustom && minPayment <= 0 && (
<p className="text-xs text-amber-600 dark:text-amber-400 flex items-center gap-1.5 no-print">
<AlertCircle className="h-3.5 w-3.5 shrink-0" />
Set a minimum payment on the Snowball page for best results.
</p>
)}
{/* Interest rate */}
<InputRow label="Interest Rate" hint="Override to test scenarios">
<div className="flex items-center gap-2">
<Input
type="number" min="0" max="99" step="0.01"
value={simRate}
onChange={e => setSimRate(e.target.value)}
className="font-mono no-print"
placeholder="0.00"
/>
<span className="text-sm text-muted-foreground shrink-0">%</span>
<span className="print-only hidden font-mono text-sm">{simRateN}%</span>
</div>
</InputRow>
{/* Monthly payment */}
<InputRow label="Monthly Payment">
<div className="no-print">
<Input
type="number" min="0" step="1"
value={simPayment}
onChange={e => setSimPayment(e.target.value)}
className="font-mono"
placeholder="0.00"
/>
{paymentBelowMin && (
<p className="text-[11px] text-amber-600 dark:text-amber-400 flex items-center gap-1 mt-1">
<AlertCircle className="h-3 w-3 shrink-0" />
Below minimum payment of {fmt(minPayment)}
</p>
)}
{paymentTooLow && !paymentBelowMin && (
<p className="text-[11px] text-destructive flex items-center gap-1 mt-1">
<AlertCircle className="h-3 w-3 shrink-0" />
Payment too low to overcome interest
</p>
)}
{!isCustom && simPaymentN > 0 && simPaymentN !== (bill?.expected_amount ?? 0) && !paymentTooLow && (
<button
type="button"
onClick={handleApply}
disabled={applying}
className="mt-1.5 text-[11px] text-primary hover:underline flex items-center gap-1 disabled:opacity-50"
>
<ArrowRight className="h-3 w-3" />
{applying ? 'Applying…' : `Apply ${fmt(simPaymentN)}/mo to my budget`}
</button>
)}
</div>
<p className="print-only hidden font-mono text-sm font-semibold">{fmt(simPaymentN)}/mo</p>
</InputRow>
{/* One-time extra */}
<InputRow label="One-time Extra This Month" hint="Optional lump sum">
<div className="flex items-center gap-1 no-print">
<Input
type="number" min="0" step="100"
value={oneTimeExtra}
onChange={e => setOneTimeExtra(e.target.value)}
className="font-mono"
placeholder="0.00"
/>
<div className="flex flex-col gap-0.5">
<button
type="button"
className="rounded px-1.5 py-0.5 text-xs text-muted-foreground hover:bg-muted transition-colors"
onClick={() => setOneTimeExtra(String(Math.max(0, oneTimeExtraN + 100)))}
></button>
<button
type="button"
className="rounded px-1.5 py-0.5 text-xs text-muted-foreground hover:bg-muted transition-colors"
onClick={() => setOneTimeExtra(String(Math.max(0, oneTimeExtraN - 100)))}
></button>
</div>
</div>
{oneTimeExtraN > 0 && (
<p className="print-only hidden text-sm">{fmt(oneTimeExtraN)}</p>
)}
</InputRow>
{/* Divider */}
<div className="border-t border-border/50" />
{/* Payoff date summary */}
<div className="space-y-2">
{simPayoffLabel ? (
<div className="flex items-baseline justify-between">
<span className="text-xs text-muted-foreground uppercase tracking-wider font-semibold">Payoff</span>
<div className="text-right">
<span className="text-xl font-bold font-mono text-amber-500 dark:text-amber-400">
{simPayoffLabel}
</span>
{simDuration && (
<p className="text-[11px] text-muted-foreground">{simDuration}</p>
)}
</div>
</div>
) : (
<p className="text-xs text-muted-foreground text-center">
{customNeedsBalance
? 'Enter a balance to see payoff date'
: 'Enter a payment to see payoff date'}
</p>
)}
{!isCustom && minPayoffLabel && simPayoffLabel && minPayoffLabel !== simPayoffLabel && (
<div className="flex items-baseline justify-between">
<span className="text-[11px] text-muted-foreground">Minimum only</span>
<span className="text-[11px] text-muted-foreground font-mono line-through">
{minPayoffLabel}
</span>
</div>
)}
</div>
{/* Required minimum */}
<div className="rounded-lg bg-muted/30 px-4 py-3 flex items-center justify-between">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
Required Minimum
</span>
<span className="font-mono text-lg font-bold tabular-nums">
{minPayment > 0 ? fmt(minPayment) : <span className="text-muted-foreground text-sm">Not set</span>}
</span>
</div> </div>
{minPayment <= 0 && ( {/* ── Right panel ── */}
<p className="text-xs text-amber-600 dark:text-amber-400 flex items-center gap-1.5"> <div className="space-y-4">
<AlertCircle className="h-3.5 w-3.5 shrink-0" />
Set a minimum payment on the Snowball page for best results.
</p>
)}
{/* Interest rate */} {/* Chart */}
<InputRow label="Interest Rate" hint="Override to test scenarios"> {simTrack.length > 0 ? (
<div className="flex items-center gap-2"> <PayoffChart
<Input minTrack={minTrack}
type="number" min="0" max="99" step="0.01" currentTrack={currentTrack}
value={simRate} simTrack={simTrack}
onChange={e => setSimRate(e.target.value)} startBalance={activeBalance}
className="font-mono"
placeholder="0.00"
/> />
<span className="text-sm text-muted-foreground shrink-0">%</span> ) : (
</div> <div className="flex items-center justify-center rounded-xl border border-dashed border-border/60 bg-muted/10 h-[300px] text-sm text-muted-foreground">
</InputRow> {customNeedsBalance
? 'Enter a balance and payment to see the chart'
{/* Monthly payment */} : simPaymentN <= 0
<InputRow label="Monthly Payment"> ? 'Enter a monthly payment to see the chart'
<Input : 'Payment too low to pay off this debt'}
type="number" min="0" step="1"
value={simPayment}
onChange={e => setSimPayment(e.target.value)}
className="font-mono"
placeholder="0.00"
/>
{paymentBelowMin && (
<p className="text-[11px] text-amber-600 dark:text-amber-400 flex items-center gap-1 mt-1">
<AlertCircle className="h-3 w-3 shrink-0" />
Below minimum payment of {fmt(minPayment)}
</p>
)}
{paymentTooLow && !paymentBelowMin && (
<p className="text-[11px] text-destructive flex items-center gap-1 mt-1">
<AlertCircle className="h-3 w-3 shrink-0" />
Payment too low to overcome interest
</p>
)}
{simPaymentN > 0 && simPaymentN !== (bill?.expected_amount ?? 0) && !paymentTooLow && (
<button
type="button"
onClick={handleApply}
disabled={applying}
className="mt-1.5 text-[11px] text-primary hover:underline flex items-center gap-1 disabled:opacity-50"
>
<ArrowRight className="h-3 w-3" />
{applying ? 'Applying…' : `Apply ${fmt(simPaymentN)}/mo to my budget`}
</button>
)}
</InputRow>
{/* One-time extra */}
<InputRow label="One-time Extra This Month" hint="Optional lump sum">
<div className="flex items-center gap-1">
<Input
type="number" min="0" step="100"
value={oneTimeExtra}
onChange={e => setOneTimeExtra(e.target.value)}
className="font-mono"
placeholder="0.00"
/>
<div className="flex flex-col gap-0.5">
<button
type="button"
className="rounded px-1.5 py-0.5 text-xs text-muted-foreground hover:bg-muted transition-colors"
onClick={() => setOneTimeExtra(String(Math.max(0, oneTimeExtraN + 100)))}
></button>
<button
type="button"
className="rounded px-1.5 py-0.5 text-xs text-muted-foreground hover:bg-muted transition-colors"
onClick={() => setOneTimeExtra(String(Math.max(0, oneTimeExtraN - 100)))}
></button>
</div> </div>
</div> )}
</InputRow>
{/* Divider */} {/* Stats row */}
<div className="border-t border-border/50" /> {showResults && (
<>
{/* Payoff date summary */} <div className={cn('grid gap-3', !isCustom && interestSavings >= 0 ? 'grid-cols-2' : 'grid-cols-1')}>
<div className="space-y-2"> {!isCustom && (
{simPayoffLabel ? ( <StatCard
<div className="flex items-baseline justify-between"> label="Interest Savings"
<span className="text-xs text-muted-foreground uppercase tracking-wider font-semibold">Payoff</span> value={fmtShort(interestSavings)}
<div className="text-right"> sub="vs minimum only"
<span className="text-xl font-bold font-mono text-amber-500 dark:text-amber-400"> color={interestSavings > 0 ? 'teal' : 'slate'}
{simPayoffLabel} />
</span> )}
{simDuration && ( <StatCard
<p className="text-[11px] text-muted-foreground">{simDuration}</p> label="Time Savings"
value={timeSavings > 0 ? `${timeSavings} mo` : (isCustom ? numMonths(simTrack) ?? '—' : '—')}
sub={isCustom ? 'to pay off' : (timeSavings > 0 ? 'months sooner' : 'same timeline')}
color={timeSavings > 0 ? 'amber' : (isCustom ? 'amber' : 'slate')}
/>
{isCustom && (
<StatCard
label="Total Interest"
value={fmtShort(simInterest)}
sub="at this payment"
color={simInterest > 0 ? 'teal' : 'slate'}
/>
)} )}
</div> </div>
</div>
) : ( {/* Breakdown */}
<p className="text-xs text-muted-foreground text-center">Enter a payment to see payoff date</p> <div className="table-surface divide-y divide-border/50">
<div className="px-5 py-3 flex items-center justify-between">
<span className="text-sm text-muted-foreground">Balance today</span>
<span className="font-mono text-sm font-semibold">{fmt(activeBalance)}</span>
</div>
<div className="px-5 py-3 flex items-center justify-between">
<span className="text-sm text-muted-foreground">Total interest</span>
<span className="font-mono text-sm font-semibold text-rose-500">{fmt(simInterest)}</span>
</div>
<div className="px-5 py-3 flex items-center justify-between">
<span className="text-sm font-medium">Total paid</span>
<span className="font-mono text-sm font-bold">{fmt(simTotalPaid)}</span>
</div>
</div>
</>
)} )}
{minPayoffLabel && simPayoffLabel && minPayoffLabel !== simPayoffLabel && (
<div className="flex items-baseline justify-between">
<span className="text-[11px] text-muted-foreground">Minimum only</span>
<span className="text-[11px] text-muted-foreground font-mono line-through">{minPayoffLabel}</span>
</div>
)}
</div> </div>
</div> </div>
)}
{/* ── Right panel ── */} </div>{/* /payoff-print-area */}
<div className="space-y-4"> </>
{/* Chart */}
{simTrack.length > 0 ? (
<PayoffChart
minTrack={minTrack}
currentTrack={currentTrack}
simTrack={simTrack}
startBalance={bill.current_balance}
/>
) : (
<div className="flex items-center justify-center rounded-xl border border-dashed border-border/60 bg-muted/10 h-[300px] text-sm text-muted-foreground">
{simPaymentN <= 0 ? 'Enter a monthly payment to see the chart' : 'Payment too low to pay off this debt'}
</div>
)}
{/* Stats row */}
{simTrack.length > 0 && (
<>
<div className="grid grid-cols-2 gap-3">
<StatCard
label="Interest Savings"
value={fmtShort(interestSavings)}
sub="vs minimum only"
color={interestSavings > 0 ? 'teal' : 'slate'}
/>
<StatCard
label="Time Savings"
value={timeSavings > 0 ? `${timeSavings} mo` : '—'}
sub={timeSavings > 0 ? 'months sooner' : 'same timeline'}
color={timeSavings > 0 ? 'amber' : 'slate'}
/>
</div>
{/* Breakdown */}
<div className="table-surface divide-y divide-border/50">
<div className="px-5 py-3 flex items-center justify-between">
<span className="text-sm text-muted-foreground">Balance today</span>
<span className="font-mono text-sm font-semibold">{fmt(bill.current_balance)}</span>
</div>
<div className="px-5 py-3 flex items-center justify-between">
<span className="text-sm text-muted-foreground">Total interest</span>
<span className="font-mono text-sm font-semibold text-rose-500">{fmt(simInterest)}</span>
</div>
<div className="px-5 py-3 flex items-center justify-between">
<span className="text-sm font-medium">Total paid</span>
<span className="font-mono text-sm font-bold">{fmt(simTotalPaid)}</span>
</div>
</div>
</>
)}
</div>
</div>
)}
</div>
); );
} }

View File

@ -13,6 +13,7 @@ import BillModal from '@/components/BillModal';
import { makeBillDraft } from '@/lib/billDrafts'; import { makeBillDraft } from '@/lib/billDrafts';
import PlanStatusBanner from '@/components/snowball/PlanStatusBanner'; import PlanStatusBanner from '@/components/snowball/PlanStatusBanner';
import PlanHistoryPanel from '@/components/snowball/PlanHistoryPanel'; import PlanHistoryPanel from '@/components/snowball/PlanHistoryPanel';
import * as AlertDialog from '@radix-ui/react-alert-dialog';
// formatters // formatters
function fmt(val) { function fmt(val) {
@ -326,103 +327,6 @@ function ReadinessStrip({ items, readyCount, totalCount, allReady, onToggle, dis
); );
} }
// Pointer-based drag-and-drop hook (works on touch + mouse)
function useSortable(items, setItems, setDirty) {
const [draggingIdx, setDraggingIdx] = useState(null);
const [draggingFromIdx, setDraggingFromIdx] = useState(null);
// Refs that live through the entire drag gesture
const state = useRef({
fromIdx: null, // card index where the drag started
currentIdx: null, // card index currently under the pointer
startY: 0,
itemHeight: 0,
containerEl: null,
});
const indexFromPointer = useCallback((clientX, clientY) => {
const direct = document.elementFromPoint(clientX, clientY)?.closest?.('[data-card-index]');
if (direct?.dataset?.cardIndex != null) {
const idx = Number(direct.dataset.cardIndex);
if (Number.isInteger(idx)) return idx;
}
const cards = [...(state.current.containerEl?.querySelectorAll('[data-card-index]') || [])];
if (cards.length === 0) return state.current.currentIdx;
let nearestIdx = state.current.currentIdx;
let nearestDistance = Infinity;
for (const card of cards) {
const rect = card.getBoundingClientRect();
const centerY = rect.top + rect.height / 2;
const distance = Math.abs(clientY - centerY);
if (distance < nearestDistance) {
nearestDistance = distance;
nearestIdx = Number(card.dataset.cardIndex);
}
}
return Number.isInteger(nearestIdx) ? nearestIdx : state.current.currentIdx;
}, []);
const onPointerDown = useCallback((e, index) => {
// Only trigger on the grip handle (data-grip attr)
if (!e.target.closest('[data-grip]')) return;
// Ignore right-click
if (e.button !== undefined && e.button !== 0) return;
const card = e.target.closest('[data-card]');
const list = card?.parentElement;
const rect = card?.getBoundingClientRect();
// Capture on the container so pointermove/pointerup are dispatched
// directly to the element that owns those React handlers avoids
// relying on bubbling from the grip through React's delegation chain.
list?.setPointerCapture(e.pointerId);
state.current = {
fromIdx: index,
currentIdx: index,
startY: e.clientY,
itemHeight: rect?.height ?? 80,
containerEl: list ?? null,
};
setDraggingIdx(index);
setDraggingFromIdx(index);
}, []);
const onPointerMove = useCallback((e) => {
if (state.current.fromIdx === null) return;
const { containerEl, currentIdx } = state.current;
if (!containerEl) return;
const newIdx = Math.max(0, Math.min(items.length - 1, indexFromPointer(e.clientX, e.clientY)));
if (newIdx !== currentIdx) {
state.current.currentIdx = newIdx;
setDraggingIdx(newIdx); // visual feedback on where card will land
}
}, [indexFromPointer, items.length]);
const onPointerUp = useCallback((e) => {
const { fromIdx, currentIdx } = state.current;
state.current.fromIdx = null;
state.current.currentIdx = null;
setDraggingIdx(null);
setDraggingFromIdx(null);
if (fromIdx === null || currentIdx === null || fromIdx === currentIdx) return;
setItems(prev => {
const next = [...prev];
const [moved] = next.splice(fromIdx, 1);
next.splice(currentIdx, 0, moved);
return next;
});
setDirty(true);
}, [setItems, setDirty]);
return { draggingIdx, draggingFromIdx, onPointerDown, onPointerMove, onPointerUp };
}
// Page // Page
export default function SnowballPage() { export default function SnowballPage() {
const [bills, setBills] = useState([]); const [bills, setBills] = useState([]);
@ -445,12 +349,12 @@ export default function SnowballPage() {
const [editingBalance, setEditingBalance] = useState({ billId: null, value: '' }); const [editingBalance, setEditingBalance] = useState({ billId: null, value: '' });
const [activePlan, setActivePlan] = useState(null); const [activePlan, setActivePlan] = useState(null);
const [allPlans, setAllPlans] = useState([]); const [allPlans, setAllPlans] = useState([]);
const [startingPlan, setStartingPlan] = useState(false); const [startingPlan, setStartingPlan] = useState(false);
const [readinessWarnOpen, setReadinessWarnOpen] = useState(false);
const { draggingIdx, draggingFromIdx, onPointerDown, onPointerMove, onPointerUp } = const [draggingId, setDraggingId] = useState(null);
useSortable(bills, setBills, setDirty); const [dropTargetId, setDropTargetId] = useState(null);
// loading // loading
const loadProjection = useCallback(async () => { const loadProjection = useCallback(async () => {
@ -501,6 +405,40 @@ export default function SnowballPage() {
setDirty(true); setDirty(true);
}; };
const dragPropsFor = (bill, index) => {
if (ramseyMode || saving) return { draggable: false };
return {
draggable: true,
isDragging: draggingId === bill.id,
isDropTarget: dropTargetId === bill.id && draggingId !== bill.id,
onDragStart: (event) => {
setDraggingId(bill.id);
event.dataTransfer.effectAllowed = 'move';
event.dataTransfer.setData('text/plain', String(bill.id));
},
onDragEnter: () => {
if (draggingId && draggingId !== bill.id) setDropTargetId(bill.id);
},
onDragOver: (event) => {
event.preventDefault();
event.dataTransfer.dropEffect = 'move';
if (draggingId && draggingId !== bill.id) setDropTargetId(bill.id);
},
onDrop: (event) => {
event.preventDefault();
const sourceId = Number(event.dataTransfer.getData('text/plain') || draggingId);
const fromIndex = bills.findIndex(item => item.id === sourceId);
if (fromIndex >= 0) moveDebt(fromIndex, index);
setDraggingId(null);
setDropTargetId(null);
},
onDragEnd: () => {
setDraggingId(null);
setDropTargetId(null);
},
};
};
// save order // save order
const handleSaveOrder = async () => { const handleSaveOrder = async () => {
setSaving(true); setSaving(true);
@ -674,6 +612,11 @@ export default function SnowballPage() {
} catch (err) { toast.error(err.message || 'Failed to abandon plan'); } } catch (err) { toast.error(err.message || 'Failed to abandon plan'); }
}; };
const handleStartPlanClick = () => {
if (readinessAllReady) { handleStartPlan(); }
else { setReadinessWarnOpen(true); }
};
// stats // stats
const totalBalance = bills.reduce((s, b) => s + (b.current_balance || 0), 0); const totalBalance = bills.reduce((s, b) => s + (b.current_balance || 0), 0);
const totalMinPayment = bills.reduce((s, b) => s + (b.minimum_payment || 0), 0); const totalMinPayment = bills.reduce((s, b) => s + (b.minimum_payment || 0), 0);
@ -812,16 +755,27 @@ export default function SnowballPage() {
</p> </p>
</div> </div>
</div> </div>
<div className="space-y-1"> <div className="surface-elevated min-h-[58px] rounded-xl border border-primary/25 bg-primary/[0.04] px-4 py-3 shadow-sm shadow-primary/5">
<Label className="text-[10px] uppercase tracking-wider text-muted-foreground"> <div className="flex items-center justify-between gap-4">
Extra monthly budget ($) <div>
</Label> <Label htmlFor="extra-payment" className="flex items-center gap-1.5 text-[10px] font-semibold uppercase tracking-wider text-primary">
<Zap className="h-3.5 w-3.5" />
Extra applied monthly
</Label>
<p className="mt-0.5 text-[10px] text-muted-foreground">Added to the current target debt.</p>
</div>
<div className="text-right">
<p className="tracker-number text-base font-bold text-primary">{extraAmt > 0 ? fmt(extraAmt) : '$0'}</p>
<p className="text-[10px] text-muted-foreground">per month</p>
</div>
</div>
<Input <Input
id="extra-payment"
type="number" min="0" step="1" placeholder="0.00" type="number" min="0" step="1" placeholder="0.00"
value={extraPayment} value={extraPayment}
onChange={e => setExtraPayment(e.target.value)} onChange={e => setExtraPayment(e.target.value)}
onBlur={handleSaveExtraPayment} onBlur={handleSaveExtraPayment}
className={cn(inp, 'w-32')} className={cn(inp, 'mt-2 w-full border-primary/25 bg-background/75')}
disabled={savingSettings} disabled={savingSettings}
/> />
</div> </div>
@ -856,7 +810,7 @@ export default function SnowballPage() {
/> />
{!activePlan && readinessReadyCount >= 3 && ( {!activePlan && readinessReadyCount >= 3 && (
<div className="flex justify-end"> <div className="flex justify-end">
<Button size="sm" onClick={handleStartPlan} disabled={startingPlan} className="gap-1.5"> <Button size="sm" onClick={handleStartPlanClick} disabled={startingPlan} className="gap-1.5">
<Zap className="h-3.5 w-3.5" /> <Zap className="h-3.5 w-3.5" />
{startingPlan ? 'Starting…' : 'Start Snowball Plan'} {startingPlan ? 'Starting…' : 'Start Snowball Plan'}
</Button> </Button>
@ -898,19 +852,12 @@ export default function SnowballPage() {
{bills.length > 0 && ( {bills.length > 0 && (
<div className="grid gap-6 lg:grid-cols-[1fr_340px]"> <div className="grid gap-6 lg:grid-cols-[1fr_340px]">
{/* Cards list — pointer events on the whole list so moves are tracked even outside a card */} {/* Cards list */}
<div <div className="space-y-2">
className="space-y-2"
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerUp}
>
{bills.map((bill, index) => { {bills.map((bill, index) => {
const isAttack = index === 0; const isAttack = index === 0;
const isEditingBal = editingBalance.billId === bill.id; const isEditingBal = editingBalance.billId === bill.id;
const isDragging = draggingFromIdx !== null; const dragProps = dragPropsFor(bill, index);
const isDragSource = draggingFromIdx === index;
const isLandTarget = isDragging && !isDragSource && draggingIdx === index;
// Pull this debt's payoff info from the live projection (attack card only) // Pull this debt's payoff info from the live projection (attack card only)
const attackProjection = isAttack const attackProjection = isAttack
@ -920,17 +867,17 @@ export default function SnowballPage() {
return ( return (
<div <div
key={bill.id} key={bill.id}
data-card draggable={dragProps?.draggable}
data-card-index={index} onDragStart={dragProps?.onDragStart}
onDragEnter={dragProps?.onDragEnter}
onDragOver={dragProps?.onDragOver}
onDragEnd={dragProps?.onDragEnd}
onDrop={dragProps?.onDrop}
className={cn( className={cn(
'surface-elevated rounded-xl border select-none touch-none', 'surface-elevated rounded-xl border select-none transition-colors duration-150',
// Only animate when not in a drag gesture instant feedback on grab
!isDragging && 'transition-all duration-150',
isAttack ? 'border-emerald-500/30 bg-emerald-950/5' : 'border-border/40', isAttack ? 'border-emerald-500/30 bg-emerald-950/5' : 'border-border/40',
// Card being actively dragged lifted look dragProps?.isDragging && 'opacity-45',
isDragSource && 'scale-105 shadow-2xl ring-2 ring-primary/60 opacity-75 relative z-10', dragProps?.isDropTarget && 'ring-2 ring-inset ring-primary/35',
// Where the card will land slot highlight
isLandTarget && 'ring-2 ring-primary/40 scale-[0.97] opacity-50',
)} )}
> >
<div className="flex items-stretch"> <div className="flex items-stretch">
@ -938,13 +885,11 @@ export default function SnowballPage() {
{/* Grip */} {/* Grip */}
<div className="flex items-center gap-0.5 px-3 transition-colors touch-none shrink-0"> <div className="flex items-center gap-0.5 px-3 transition-colors touch-none shrink-0">
<div <div
data-grip
onPointerDown={e => { if (!ramseyMode) onPointerDown(e, index); }}
className={cn( className={cn(
'transition-colors', 'transition-colors',
ramseyMode ramseyMode
? 'text-muted-foreground/10 cursor-not-allowed' ? 'text-muted-foreground/10 cursor-not-allowed'
: 'text-muted-foreground/35 hover:text-muted-foreground/70 cursor-grab active:cursor-grabbing', : 'text-muted-foreground/55 hover:text-muted-foreground/80 cursor-grab active:cursor-grabbing',
)} )}
aria-label={ramseyMode ? 'Ramsey Mode controls order' : 'Drag to reorder'} aria-label={ramseyMode ? 'Ramsey Mode controls order' : 'Drag to reorder'}
> >
@ -1136,6 +1081,50 @@ export default function SnowballPage() {
{/* Plan history */} {/* Plan history */}
<PlanHistoryPanel plans={allPlans} /> <PlanHistoryPanel plans={allPlans} />
{/* Readiness warning dialog */}
<AlertDialog.Root open={readinessWarnOpen} onOpenChange={setReadinessWarnOpen}>
<AlertDialog.Portal>
<AlertDialog.Overlay className="fixed inset-0 z-50 bg-black/60 backdrop-blur-sm" />
<AlertDialog.Content className="fixed left-1/2 top-1/2 z-50 -translate-x-1/2 -translate-y-1/2 w-full max-w-md rounded-xl border border-border bg-popover p-6 shadow-2xl space-y-4 focus:outline-none">
<div className="flex items-center gap-2">
<AlertTriangle className="h-4 w-4 text-amber-400 shrink-0" />
<AlertDialog.Title className="text-base font-semibold">
Checklist not complete
</AlertDialog.Title>
</div>
<AlertDialog.Description asChild>
<div className="text-sm text-muted-foreground space-y-3">
<p>The following readiness items are still pending:</p>
<ul className="space-y-1.5">
{readinessItems.filter(i => !i.ready).map(item => (
<li key={item.id} className="flex items-center gap-2 text-amber-400 text-xs">
<AlertTriangle className="h-3.5 w-3.5 shrink-0" />
{item.label}
</li>
))}
</ul>
<p>Starting now may affect your plan&apos;s accuracy. You can still proceed.</p>
</div>
</AlertDialog.Description>
<div className="flex gap-2 justify-end pt-1">
<AlertDialog.Cancel asChild>
<Button variant="outline" size="sm">Go back</Button>
</AlertDialog.Cancel>
<AlertDialog.Action asChild>
<Button
size="sm"
className="gap-1.5"
onClick={() => { setReadinessWarnOpen(false); handleStartPlan(); }}
>
<Zap className="h-3.5 w-3.5" />
Start anyway
</Button>
</AlertDialog.Action>
</div>
</AlertDialog.Content>
</AlertDialog.Portal>
</AlertDialog.Root>
{/* Edit modal */} {/* Edit modal */}
{editBill && ( {editBill && (
<BillModal <BillModal

View File

@ -280,7 +280,16 @@ export default function StatusPage() {
const dbOk = db.connected ?? (db.status === 'connected') ?? true; const dbOk = db.connected ?? (db.status === 'connected') ?? true;
const workerOk = !worker.enabled ? null : worker.last_error ? false : true; const workerOk = !worker.enabled ? null : worker.last_error ? false : true;
const notificationsConfigured = notifications.configured ?? notifications.smtp_configured ?? null; const notificationsConfigured = notifications.configured ?? notifications.smtp_configured ?? null;
const backupsEnabled = backups.enabled ?? null; const backupsEnabled = backups.enabled ?? null;
const backupsScheduled = backups.scheduled_enabled ?? null;
const backupsStatus = backupsEnabled === null ? 'Pending'
: !backupsEnabled ? 'Disabled'
: backupsScheduled ? 'Scheduled'
: 'Manual Only';
const backupsTone = backupsEnabled === null ? 'muted'
: !backupsEnabled ? 'warn'
: backupsScheduled ? 'good'
: 'warn';
const recentErrors = Array.isArray(errors) ? errors : []; const recentErrors = Array.isArray(errors) ? errors : [];
const bankSyncEnabled = bankSync.enabled ?? false; const bankSyncEnabled = bankSync.enabled ?? false;
@ -428,10 +437,19 @@ export default function StatusPage() {
</StatusCard> </StatusCard>
<StatusCard title="Backups" icon={HardDrive} <StatusCard title="Backups" icon={HardDrive}
status={backupsEnabled === null ? 'Pending' : backupsEnabled ? 'Enabled' : 'Disabled'} status={backupsStatus}
tone={backupsEnabled === null ? 'muted' : backupsEnabled ? 'good' : 'warn'} tone={backupsTone}
> >
<StatRow label="Schedule"
value={
backupsEnabled === null ? null
: !backupsEnabled ? 'Disabled'
: backupsScheduled ? `${backups.scheduled_frequency ?? ''} ${backups.scheduled_time ?? ''}`.trim() || 'Enabled'
: 'Not scheduled'
}
/>
<StatRow label="Last Backup" value={formatDateTime(backups.last_backup_at)} /> <StatRow label="Last Backup" value={formatDateTime(backups.last_backup_at)} />
<StatRow label="Next Backup" value={formatDateTime(backups.next_backup_at)} />
<StatRow label="Count" value={backups.count ?? backups.backup_count} /> <StatRow label="Count" value={backups.count ?? backups.backup_count} />
<StatRow label="Retention" value={backups.keep_count ?? backups.retention_count} /> <StatRow label="Retention" value={backups.keep_count ?? backups.retention_count} />
<StatRow label="Last Error" value={backups.last_error} last /> <StatRow label="Last Error" value={backups.last_error} last />

View File

@ -21,6 +21,7 @@ import {
} from 'lucide-react'; } from 'lucide-react';
import { api } from '@/api'; import { api } from '@/api';
import { cn, fmt, fmtDate } from '@/lib/utils'; import { cn, fmt, fmtDate } from '@/lib/utils';
import { scheduleLabel } from '@/lib/billingSchedule';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
@ -49,8 +50,7 @@ const TYPE_LABELS = {
}; };
function cycleLabel(item) { function cycleLabel(item) {
const cycle = item.cycle_type || item.billing_cycle || 'monthly'; return scheduleLabel(item);
return cycle === 'annual' || cycle === 'annually' ? 'yearly' : cycle;
} }
function StatCard({ icon: Icon, label, value, hint }) { function StatCard({ icon: Icon, label, value, hint }) {

View File

@ -1,11 +1,14 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { import {
ArrowDown,
ArrowUp,
CalendarDays, CalendarDays,
CheckCircle2, CheckCircle2,
ChevronLeft, ChevronLeft,
ChevronRight, ChevronRight,
Edit3, Edit3,
GripVertical,
Loader2, Loader2,
Minus, Minus,
Printer, Printer,
@ -17,6 +20,7 @@ import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { cn, fmt } from '@/lib/utils'; import { cn, fmt } from '@/lib/utils';
import { moveInArray, reorderPayload } from '@/lib/reorder';
const MONTHS = [ const MONTHS = [
'January', 'January',
@ -115,9 +119,53 @@ function SummaryChart({ rows = [] }) {
); );
} }
function ExpenseRow({ expense }) { function ExpenseRow({ expense, moveControls, dragProps }) {
return ( return (
<div className="grid gap-2 border-b border-border/60 px-1 py-3 last:border-0 sm:grid-cols-[minmax(0,1fr)_7.5rem_5.5rem] sm:items-center"> <div
draggable={dragProps?.draggable}
onDragStart={dragProps?.onDragStart}
onDragEnter={dragProps?.onDragEnter}
onDragOver={dragProps?.onDragOver}
onDragEnd={dragProps?.onDragEnd}
onDrop={dragProps?.onDrop}
className={cn(
'summary-expense-row group grid gap-2 border-b border-border/60 px-1 py-3 last:border-0 sm:grid-cols-[auto_minmax(0,1fr)_7.5rem_5.5rem] sm:items-center',
dragProps?.isDragging && 'opacity-45',
dragProps?.isDropTarget && 'ring-2 ring-inset ring-primary/35',
)}
>
<div className="summary-reorder-controls flex shrink-0 items-center gap-0.5 self-start sm:self-center">
<GripVertical
className={cn(
'h-4 w-4 text-muted-foreground/55',
moveControls?.enabled && 'cursor-grab group-active:cursor-grabbing',
!moveControls?.enabled && 'opacity-30',
)}
aria-hidden="true"
/>
<div className="flex flex-col">
<button
type="button"
onClick={moveControls?.onMoveUp}
disabled={!moveControls?.enabled || !moveControls?.canMoveUp || moveControls?.moving}
className="rounded text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:pointer-events-none disabled:opacity-25"
title="Move expense up"
aria-label={`Move ${expense.name} up`}
>
<ArrowUp className="h-3 w-3" />
</button>
<button
type="button"
onClick={moveControls?.onMoveDown}
disabled={!moveControls?.enabled || !moveControls?.canMoveDown || moveControls?.moving}
className="rounded text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:pointer-events-none disabled:opacity-25"
title="Move expense down"
aria-label={`Move ${expense.name} down`}
>
<ArrowDown className="h-3 w-3" />
</button>
</div>
</div>
<div className="min-w-0"> <div className="min-w-0">
<div className="truncate text-sm font-semibold text-foreground">{expense.name}</div> <div className="truncate text-sm font-semibold text-foreground">{expense.name}</div>
<div className="mt-1 flex flex-wrap items-center gap-2 text-xs text-muted-foreground"> <div className="mt-1 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
@ -144,6 +192,9 @@ export default function SummaryPage() {
const [startingFifteenth, setStartingFifteenth] = useState('0'); const [startingFifteenth, setStartingFifteenth] = useState('0');
const [startingOther, setStartingOther] = useState('0'); const [startingOther, setStartingOther] = useState('0');
const [editingStarting, setEditingStarting] = useState(false); const [editingStarting, setEditingStarting] = useState(false);
const [draggingId, setDraggingId] = useState(null);
const [dropTargetId, setDropTargetId] = useState(null);
const [movingBillId, setMovingBillId] = useState(null);
const loadSummary = useCallback(async () => { const loadSummary = useCallback(async () => {
setLoading(true); setLoading(true);
@ -155,6 +206,9 @@ export default function SummaryPage() {
setStartingFifteenth(String(result.starting_amounts?.fifteenth_amount ?? 0)); setStartingFifteenth(String(result.starting_amounts?.fifteenth_amount ?? 0));
setStartingOther(String(result.starting_amounts?.other_amount ?? 0)); setStartingOther(String(result.starting_amounts?.other_amount ?? 0));
setEditingStarting(false); setEditingStarting(false);
setDraggingId(null);
setDropTargetId(null);
setMovingBillId(null);
} catch (err) { } catch (err) {
setError(err.message || 'Summary could not be loaded.'); setError(err.message || 'Summary could not be loaded.');
toast.error(err.message || 'Summary could not be loaded.'); toast.error(err.message || 'Summary could not be loaded.');
@ -170,6 +224,7 @@ export default function SummaryPage() {
const summary = data?.summary || {}; const summary = data?.summary || {};
const expenses = data?.expenses || []; const expenses = data?.expenses || [];
const starting = data?.starting_amounts || {}; const starting = data?.starting_amounts || {};
const reorderEnabled = !loading && !error && expenses.length > 1;
const generatedLabel = useMemo(() => { const generatedLabel = useMemo(() => {
if (!data?.generated_at) return ''; if (!data?.generated_at) return '';
@ -211,6 +266,72 @@ export default function SummaryPage() {
setSelected(selectedFromToday()); setSelected(selectedFromToday());
} }
async function persistExpenseOrder(nextExpenses, movedId) {
setData(prev => prev ? { ...prev, expenses: nextExpenses } : prev);
setMovingBillId(movedId);
try {
await api.reorderBills(reorderPayload(nextExpenses.map(expense => ({ id: expense.bill_id }))));
toast.success('Summary order saved');
await loadSummary();
} catch (err) {
toast.error(err.message || 'Failed to save summary order.');
await loadSummary();
} finally {
setMovingBillId(null);
}
}
function reorderExpenses(fromIndex, toIndex) {
if (!reorderEnabled || fromIndex === toIndex) return;
const nextExpenses = moveInArray(expenses, fromIndex, toIndex);
persistExpenseOrder(nextExpenses, expenses[fromIndex]?.bill_id || null);
}
function moveControlsFor(expense, index) {
return {
enabled: reorderEnabled,
moving: movingBillId === expense.bill_id,
canMoveUp: index > 0,
canMoveDown: index < expenses.length - 1,
onMoveUp: () => reorderExpenses(index, index - 1),
onMoveDown: () => reorderExpenses(index, index + 1),
};
}
function dragPropsFor(expense, index) {
if (!reorderEnabled) return { draggable: false };
return {
draggable: true,
isDragging: draggingId === expense.bill_id,
isDropTarget: dropTargetId === expense.bill_id && draggingId !== expense.bill_id,
onDragStart: (event) => {
setDraggingId(expense.bill_id);
event.dataTransfer.effectAllowed = 'move';
event.dataTransfer.setData('text/plain', String(expense.bill_id));
},
onDragEnter: () => {
if (draggingId && draggingId !== expense.bill_id) setDropTargetId(expense.bill_id);
},
onDragOver: (event) => {
event.preventDefault();
event.dataTransfer.dropEffect = 'move';
if (draggingId && draggingId !== expense.bill_id) setDropTargetId(expense.bill_id);
},
onDrop: (event) => {
event.preventDefault();
const sourceId = Number(event.dataTransfer.getData('text/plain') || draggingId);
const fromIndex = expenses.findIndex(item => item.bill_id === sourceId);
if (fromIndex >= 0) reorderExpenses(fromIndex, index);
setDraggingId(null);
setDropTargetId(null);
},
onDragEnd: () => {
setDraggingId(null);
setDropTargetId(null);
},
};
}
return ( return (
<div className="summary-page mx-auto max-w-3xl space-y-5"> <div className="summary-page mx-auto max-w-3xl space-y-5">
<div className="summary-print-meta hidden"> <div className="summary-print-meta hidden">
@ -387,8 +508,13 @@ export default function SummaryPage() {
</div> </div>
) : ( ) : (
<div className="rounded-2xl border border-border/60 bg-background/70 px-3"> <div className="rounded-2xl border border-border/60 bg-background/70 px-3">
{expenses.map(expense => ( {expenses.map((expense, index) => (
<ExpenseRow key={expense.bill_id} expense={expense} /> <ExpenseRow
key={expense.bill_id}
expense={expense}
moveControls={moveControlsFor(expense, index)}
dragProps={dragPropsFor(expense, index)}
/>
))} ))}
</div> </div>
)} )}

View File

@ -6,6 +6,7 @@ import { api } from '@/api.js';
import { useTracker, useDriftReport } from '@/hooks/useQueries'; import { useTracker, useDriftReport } from '@/hooks/useQueries';
import BillModal from '@/components/BillModal'; import BillModal from '@/components/BillModal';
import { makeBillDraft } from '@/lib/billDrafts'; import { makeBillDraft } from '@/lib/billDrafts';
import { scheduleLabel, scheduleValue } from '@/lib/billingSchedule';
import { cn, fmt, fmtDate, todayStr } from '@/lib/utils'; import { cn, fmt, fmtDate, todayStr } from '@/lib/utils';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
@ -2055,14 +2056,14 @@ export default function TrackerPage() {
return Array.from(map, ([id, name]) => ({ id, name })).sort((a, b) => a.name.localeCompare(b.name)); return Array.from(map, ([id, name]) => ({ id, name })).sort((a, b) => a.name.localeCompare(b.name));
}, [rows]); }, [rows]);
const cycleOptions = useMemo(() => ( const cycleOptions = useMemo(() => (
Array.from(new Set(rows.map(row => row.billing_cycle || 'monthly'))).sort() Array.from(new Set(rows.map(scheduleValue))).sort()
), [rows]); ), [rows]);
const filteredRows = useMemo(() => { const filteredRows = useMemo(() => {
const q = search.trim().toLowerCase(); const q = search.trim().toLowerCase();
return rows.filter(row => { return rows.filter(row => {
const effectiveStatus = rowEffectiveStatus(row); const effectiveStatus = rowEffectiveStatus(row);
if (filters.category !== FILTER_ALL && String(row.category_id ?? '') !== filters.category) return false; if (filters.category !== FILTER_ALL && String(row.category_id ?? '') !== filters.category) return false;
if (filters.cycle !== FILTER_ALL && String(row.billing_cycle || 'monthly') !== filters.cycle) return false; if (filters.cycle !== FILTER_ALL && scheduleValue(row) !== filters.cycle) return false;
if (filters.autopay && !row.autopay_enabled) return false; if (filters.autopay && !row.autopay_enabled) return false;
if (filters.debt && !rowIsDebt(row)) return false; if (filters.debt && !rowIsDebt(row)) return false;
if (filters.unpaid && (row.is_skipped || rowIsPaid(row))) return false; if (filters.unpaid && (row.is_skipped || rowIsPaid(row))) return false;
@ -2076,7 +2077,8 @@ export default function TrackerPage() {
row.category_name, row.category_name,
row.notes, row.notes,
row.monthly_notes, row.monthly_notes,
row.billing_cycle, scheduleValue(row),
scheduleLabel(row),
row.bucket, row.bucket,
row.status, row.status,
amountSearchText(row.expected_amount, row.actual_amount, row.total_paid, row.balance, row.current_balance, row.minimum_payment), amountSearchText(row.expected_amount, row.actual_amount, row.total_paid, row.balance, row.current_balance, row.minimum_payment),
@ -2182,12 +2184,12 @@ export default function TrackerPage() {
</Select> </Select>
<Select value={filters.cycle} onValueChange={value => setFilterValue('cycle', value)}> <Select value={filters.cycle} onValueChange={value => setFilterValue('cycle', value)}>
<SelectTrigger className="h-10 capitalize"> <SelectTrigger className="h-10 capitalize">
<SelectValue placeholder="Billing cycle" /> <SelectValue placeholder="Billing schedule" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value={FILTER_ALL}>All cycles</SelectItem> <SelectItem value={FILTER_ALL}>All cycles</SelectItem>
{cycleOptions.map(cycle => ( {cycleOptions.map(cycle => (
<SelectItem key={cycle} value={cycle}>{cycle.replace(/_/g, ' ')}</SelectItem> <SelectItem key={cycle} value={cycle}>{scheduleLabel(cycle)}</SelectItem>
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>

View File

@ -549,6 +549,7 @@ function ensureTransactionFoundationSchema(database = db) {
currency TEXT, currency TEXT,
balance INTEGER, balance INTEGER,
available_balance INTEGER, available_balance INTEGER,
monitored INTEGER NOT NULL DEFAULT 1,
raw_data TEXT, raw_data TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
@ -2510,6 +2511,56 @@ function runMigrations() {
if (!cols.includes('sort_order')) db.exec('ALTER TABLE categories ADD COLUMN sort_order INTEGER'); if (!cols.includes('sort_order')) db.exec('ALTER TABLE categories ADD COLUMN sort_order INTEGER');
db.exec('CREATE INDEX IF NOT EXISTS idx_categories_user_sort ON categories(user_id, sort_order, name)'); db.exec('CREATE INDEX IF NOT EXISTS idx_categories_user_sort ON categories(user_id, sort_order, name)');
} }
},
{
version: 'v0.76',
description: 'bills: canonical billing schedule cleanup',
dependsOn: ['v0.75'],
run: function() {
const cols = db.prepare('PRAGMA table_info(bills)').all().map(c => c.name);
if (!cols.includes('cycle_type') || !cols.includes('cycle_day') || !cols.includes('billing_cycle')) return;
db.exec(`
UPDATE bills
SET cycle_type = CASE
WHEN LOWER(COALESCE(cycle_type, '')) IN ('', 'monthly')
AND LOWER(COALESCE(billing_cycle, '')) = 'quarterly'
THEN 'quarterly'
WHEN LOWER(COALESCE(cycle_type, '')) IN ('', 'monthly')
AND LOWER(COALESCE(billing_cycle, '')) IN ('annually', 'annual')
THEN 'annual'
WHEN LOWER(COALESCE(cycle_type, '')) IN ('monthly', 'weekly', 'biweekly', 'quarterly', 'annual')
THEN LOWER(cycle_type)
ELSE 'monthly'
END;
UPDATE bills
SET cycle_day = CASE
WHEN cycle_type IN ('weekly', 'biweekly')
AND LOWER(COALESCE(cycle_day, '')) IN ('monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday')
THEN LOWER(cycle_day)
WHEN cycle_type IN ('weekly', 'biweekly')
THEN 'monday'
WHEN cycle_type IN ('quarterly', 'annual')
AND CAST(cycle_day AS INTEGER) BETWEEN 1 AND 12
THEN CAST(CAST(cycle_day AS INTEGER) AS TEXT)
WHEN cycle_type IN ('quarterly', 'annual')
THEN '1'
WHEN cycle_type = 'monthly'
AND CAST(cycle_day AS INTEGER) BETWEEN 1 AND 31
THEN CAST(CAST(cycle_day AS INTEGER) AS TEXT)
ELSE CAST(CASE WHEN due_day BETWEEN 1 AND 31 THEN due_day ELSE 1 END AS TEXT)
END;
UPDATE bills
SET billing_cycle = CASE
WHEN cycle_type = 'quarterly' THEN 'quarterly'
WHEN cycle_type = 'annual' THEN 'annually'
WHEN cycle_type IN ('weekly', 'biweekly') THEN 'irregular'
ELSE 'monthly'
END;
`);
}
} }
]; ];
@ -2956,6 +3007,18 @@ const ROLLBACK_SQL_MAP = {
'ALTER TABLE categories DROP COLUMN sort_order', 'ALTER TABLE categories DROP COLUMN sort_order',
] ]
}, },
'v0.76': {
description: 'bills: canonical billing schedule cleanup',
sql: [
`UPDATE bills
SET billing_cycle = CASE
WHEN cycle_type = 'quarterly' THEN 'quarterly'
WHEN cycle_type = 'annual' THEN 'annually'
WHEN cycle_type IN ('weekly', 'biweekly') THEN 'irregular'
ELSE 'monthly'
END`,
]
},
'v0.51': { 'v0.51': {
description: 'bills: snowball_exempt column', description: 'bills: snowball_exempt column',
sql: ['ALTER TABLE bills DROP COLUMN snowball_exempt'] sql: ['ALTER TABLE bills DROP COLUMN snowball_exempt']

View File

@ -22,6 +22,8 @@ CREATE TABLE IF NOT EXISTS bills (
expected_amount REAL NOT NULL DEFAULT 0, expected_amount REAL NOT NULL DEFAULT 0,
interest_rate REAL CHECK(interest_rate IS NULL OR (interest_rate >= 0 AND interest_rate <= 100)), interest_rate REAL CHECK(interest_rate IS NULL OR (interest_rate >= 0 AND interest_rate <= 100)),
billing_cycle TEXT DEFAULT 'monthly' CHECK(billing_cycle IN ('monthly', 'quarterly', 'annually', 'irregular')), billing_cycle TEXT DEFAULT 'monthly' CHECK(billing_cycle IN ('monthly', 'quarterly', 'annually', 'irregular')),
cycle_type TEXT NOT NULL DEFAULT 'monthly' CHECK(cycle_type IN ('monthly', 'weekly', 'biweekly', 'quarterly', 'annual')),
cycle_day TEXT,
autopay_enabled INTEGER NOT NULL DEFAULT 0, autopay_enabled INTEGER NOT NULL DEFAULT 0,
autodraft_status TEXT NOT NULL DEFAULT 'none' CHECK(autodraft_status IN ('none', 'pending', 'assumed_paid', 'confirmed')), autodraft_status TEXT NOT NULL DEFAULT 'none' CHECK(autodraft_status IN ('none', 'pending', 'assumed_paid', 'confirmed')),
auto_mark_paid INTEGER NOT NULL DEFAULT 0, auto_mark_paid INTEGER NOT NULL DEFAULT 0,
@ -118,6 +120,7 @@ CREATE TABLE IF NOT EXISTS financial_accounts (
currency TEXT, currency TEXT,
balance INTEGER, balance INTEGER,
available_balance INTEGER, available_balance INTEGER,
monitored INTEGER NOT NULL DEFAULT 1,
raw_data TEXT, raw_data TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,

View File

@ -1,6 +1,6 @@
{ {
"name": "bill-tracker", "name": "bill-tracker",
"version": "0.34.1.2", "version": "0.34.1.3",
"description": "Monthly bill tracking system", "description": "Monthly bill tracking system",
"main": "server.js", "main": "server.js",
"scripts": { "scripts": {

View File

@ -129,7 +129,7 @@ router.get('/:sourceId/accounts', (req, res) => {
const result = accounts.map(acc => ({ const result = accounts.map(acc => ({
...acc, ...acc,
monitored: acc.monitored === 1, monitored: acc.monitored === 1,
transactions: txStmt.all(acc.id, req.user.id), transactions: acc.monitored === 1 ? txStmt.all(acc.id, req.user.id) : [],
})); }));
res.json(result); res.json(result);

View File

@ -201,7 +201,7 @@ router.get('/', async (req, res) => {
const overdueCount = db.prepare(` const overdueCount = db.prepare(`
SELECT COUNT(*) AS n FROM bills b SELECT COUNT(*) AS n FROM bills b
WHERE b.active = 1 WHERE b.active = 1
AND b.billing_cycle = 'monthly' AND COALESCE(NULLIF(b.cycle_type, ''), 'monthly') = 'monthly'
AND CAST(b.due_day AS INTEGER) < ? AND CAST(b.due_day AS INTEGER) < ?
AND NOT EXISTS ( AND NOT EXISTS (
SELECT 1 FROM payments p SELECT 1 FROM payments p

View File

@ -147,12 +147,16 @@ function buildSummary(db, userId, year, month) {
b.due_day, b.due_day,
c.name AS category_name, c.name AS category_name,
m.actual_amount, m.actual_amount,
m.is_skipped m.is_skipped,
b.sort_order
FROM bills b FROM bills b
LEFT JOIN categories c ON c.id = b.category_id AND c.user_id = b.user_id AND c.deleted_at IS NULL LEFT JOIN categories c ON c.id = b.category_id AND c.user_id = b.user_id AND c.deleted_at IS NULL
LEFT JOIN monthly_bill_state m ON m.bill_id = b.id AND m.year = ? AND m.month = ? LEFT JOIN monthly_bill_state m ON m.bill_id = b.id AND m.year = ? AND m.month = ?
WHERE b.user_id = ? AND b.active = 1 AND b.deleted_at IS NULL WHERE b.user_id = ? AND b.active = 1 AND b.deleted_at IS NULL
ORDER BY b.due_day ASC, b.name ASC ORDER BY CASE WHEN b.sort_order IS NULL THEN 1 ELSE 0 END,
b.sort_order ASC,
b.due_day ASC,
b.name ASC
`).all(year, month, userId); `).all(year, month, userId);
const billIds = billRows.map(row => row.bill_id); const billIds = billRows.map(row => row.bill_id);

View File

@ -101,7 +101,7 @@ function hasOwn(obj, field) {
function getOwnedAccount(db, userId, accountId) { function getOwnedAccount(db, userId, accountId) {
if (accountId == null) return null; if (accountId == null) return null;
return db.prepare('SELECT * FROM financial_accounts WHERE id = ? AND user_id = ?').get(accountId, userId); return db.prepare('SELECT * FROM financial_accounts WHERE id = ? AND user_id = ? AND monitored = 1').get(accountId, userId);
} }
function getOwnedBill(db, userId, billId) { function getOwnedBill(db, userId, billId) {
@ -277,7 +277,10 @@ router.get('/', (req, res) => {
const page = parseLimitOffset(req.query); const page = parseLimitOffset(req.query);
if (page.error) return res.status(400).json(page.error); if (page.error) return res.status(400).json(page.error);
const where = ['t.user_id = ?']; const where = [
't.user_id = ?',
'(t.account_id IS NULL OR fa.id IS NULL OR fa.monitored = 1)',
];
const params = [req.user.id]; const params = [req.user.id];
const matchStatusFilter = req.query.match_status ? String(req.query.match_status).trim() : ''; const matchStatusFilter = req.query.match_status ? String(req.query.match_status).trim() : '';

View File

@ -239,6 +239,16 @@ async function main() {
console.error('[bankSync] Failed to start auto-sync worker:', err.message); console.error('[bankSync] Failed to start auto-sync worker:', err.message);
} }
// Start scheduled database backups only when enabled in Admin settings.
try {
const backupScheduler = require('./services/backupScheduler');
if (backupScheduler.getScheduleStatus().enabled) {
backupScheduler.start();
}
} catch (err) {
console.error('[backupScheduler] Failed to start scheduled backup worker:', err.message);
}
// Start daily worker (autopay marking, notifications, session pruning, cleanup) // Start daily worker (autopay marking, notifications, session pruning, cleanup)
try { try {
require('./workers/dailyWorker').start(); require('./workers/dailyWorker').start();

View File

@ -2,7 +2,7 @@ const crypto = require('crypto');
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
const Database = require('better-sqlite3'); const Database = require('better-sqlite3');
const { closeDb, getDb, getDbPath, getSetting } = require('../db/database'); const { closeDb, getDb, getDbPath } = require('../db/database');
const BACKUP_DIR = path.resolve( const BACKUP_DIR = path.resolve(
process.env.BACKUP_PATH || path.join(path.dirname(getDbPath()), '..', 'backups') process.env.BACKUP_PATH || path.join(path.dirname(getDbPath()), '..', 'backups')
@ -166,10 +166,7 @@ async function createBackup(prefix = 'bill-tracker-backup') {
validateSqliteDatabase(tempPath); validateSqliteDatabase(tempPath);
fs.renameSync(tempPath, finalPath); fs.renameSync(tempPath, finalPath);
fs.chmodSync(finalPath, 0o600); fs.chmodSync(finalPath, 0o600);
const meta = metadataForFile(finalPath); return metadataForFile(finalPath);
const retentionCount = parseInt(getSetting('backup_schedule_retention_count') || '2', 10);
applyRetention(retentionCount);
return meta;
} catch (err) { } catch (err) {
try { if (fs.existsSync(tempPath)) fs.unlinkSync(tempPath); } catch {} try { if (fs.existsSync(tempPath)) fs.unlinkSync(tempPath); } catch {}
cleanupSqliteSidecars(tempPath); cleanupSqliteSidecars(tempPath);
@ -242,12 +239,17 @@ function deleteBackup(id) {
return { deleted: true, id: backup.metadata.id, deleted_at: new Date().toISOString() }; return { deleted: true, id: backup.metadata.id, deleted_at: new Date().toISOString() };
} }
function applyRetention(retentionCount) { function applyRetention(retentionCount, options = {}) {
const keep = parseInt(retentionCount, 10); const keep = parseInt(retentionCount, 10);
if (!Number.isInteger(keep) || keep < 1) return { deleted: [] }; if (!Number.isInteger(keep) || keep < 1) return { deleted: [] };
// listBackups() is already sorted newest-first; delete everything beyond `keep` const type = options.type || null;
const toDelete = listBackups().slice(keep); const backups = type
? listBackups().filter(backup => backup.type === type)
: listBackups();
// listBackups() is already sorted newest-first; delete everything beyond `keep`.
const toDelete = backups.slice(keep);
const deleted = []; const deleted = [];
for (const backup of toDelete) { for (const backup of toDelete) {
@ -261,8 +263,9 @@ function applyRetention(retentionCount) {
return { deleted }; return { deleted };
} }
// Keep old name as an alias so the scheduler import still works. function applyScheduledRetention(retentionCount) {
const applyScheduledRetention = applyRetention; return applyRetention(retentionCount, { type: 'scheduled' });
}
async function restoreBackup(id) { async function restoreBackup(id) {
const source = getBackupFile(id); const source = getBackupFile(id);

View File

@ -2,10 +2,10 @@
const { getSetting, setSetting } = require('../db/database'); const { getSetting, setSetting } = require('../db/database');
const SYNC_DAYS_MAX = 90; // SimpleFIN Bridge advertised limit const SYNC_DAYS_MAX = 45; // SimpleFIN Bridge hard limit — requests beyond this return an error
const SYNC_DAYS_EFFECTIVE = 89; // 1-day buffer to avoid bridge-side capping due to request latency const SYNC_DAYS_EFFECTIVE = 44; // 1-day buffer so request latency never tips over the hard limit
const SYNC_DAYS_DEFAULT = 90; const SYNC_DAYS_DEFAULT = 30; // routine sync window (initial seed always uses SYNC_DAYS_EFFECTIVE)
const SYNC_INTERVAL_DEFAULT = 4; // hours const SYNC_INTERVAL_DEFAULT = 4; // hours
function getBankSyncConfig() { function getBankSyncConfig() {
const dbValue = getSetting('bank_sync_enabled'); const dbValue = getSetting('bank_sync_enabled');

View File

@ -12,8 +12,8 @@ const { getBankSyncConfig } = require('./bankSyncConfigService');
const { decorateDataSource } = require('./transactionService'); const { decorateDataSource } = require('./transactionService');
const { applyMerchantRules } = require('./billMerchantRuleService'); const { applyMerchantRules } = require('./billMerchantRuleService');
const SEED_SYNC_DAYS = 89; // Initial connect / explicit backfill (SimpleFIN Bridge ~90-day cap) const SEED_SYNC_DAYS = 44; // Initial connect / explicit backfill (SimpleFIN Bridge 45-day cap, 1-day buffer)
const ROUTINE_SYNC_DAYS = 30; // Auto-sync and manual "Sync Now" const ROUTINE_SYNC_DAYS = 30; // Fallback if admin config is missing
function sinceEpochDays(days) { function sinceEpochDays(days) {
return Math.floor((Date.now() - days * 86400 * 1000) / 1000); return Math.floor((Date.now() - days * 86400 * 1000) / 1000);
@ -26,7 +26,7 @@ function safeErrorMessage(err) {
// Upsert a single financial account, return the local row. // Upsert a single financial account, return the local row.
function upsertAccount(db, accountRow) { function upsertAccount(db, accountRow) {
const existing = db.prepare(` const existing = db.prepare(`
SELECT id FROM financial_accounts SELECT id, monitored FROM financial_accounts
WHERE data_source_id = ? AND provider_account_id = ? AND user_id = ? WHERE data_source_id = ? AND provider_account_id = ? AND user_id = ?
`).get(accountRow.data_source_id, accountRow.provider_account_id, accountRow.user_id); `).get(accountRow.data_source_id, accountRow.provider_account_id, accountRow.user_id);
@ -41,7 +41,7 @@ function upsertAccount(db, accountRow) {
accountRow.balance, accountRow.available_balance, accountRow.raw_data, accountRow.balance, accountRow.available_balance, accountRow.raw_data,
existing.id, existing.id,
); );
return existing.id; return existing;
} }
const result = db.prepare(` const result = db.prepare(`
@ -54,7 +54,7 @@ function upsertAccount(db, accountRow) {
accountRow.name, accountRow.org_name, accountRow.account_type, accountRow.name, accountRow.org_name, accountRow.account_type,
accountRow.currency, accountRow.balance, accountRow.available_balance, accountRow.raw_data, accountRow.currency, accountRow.balance, accountRow.available_balance, accountRow.raw_data,
); );
return result.lastInsertRowid; return { id: result.lastInsertRowid, monitored: 1 };
} }
// Insert a transaction, ignoring duplicates (unique index on data_source_id + provider_transaction_id). // Insert a transaction, ignoring duplicates (unique index on data_source_id + provider_transaction_id).
@ -81,9 +81,13 @@ function insertTransactionIfNew(db, txRow) {
} }
async function runSync(db, userId, dataSource, { days } = {}) { async function runSync(db, userId, dataSource, { days } = {}) {
const accessUrl = decryptSecret(dataSource.encrypted_secret); const accessUrl = decryptSecret(dataSource.encrypted_secret);
const isFirstSync = !dataSource.last_sync_at; const isFirstSync = !dataSource.last_sync_at;
const syncDays = days ?? (isFirstSync ? SEED_SYNC_DAYS : ROUTINE_SYNC_DAYS); // Explicit `days` param (e.g. backfill) takes precedence.
// Initial seed always uses the full SEED_SYNC_DAYS window regardless of admin config.
// Routine syncs use the admin-configured sync_days (default 30); falls back to ROUTINE_SYNC_DAYS.
const config = getBankSyncConfig();
const syncDays = days ?? (isFirstSync ? SEED_SYNC_DAYS : (config.sync_days || ROUTINE_SYNC_DAYS));
const since = sinceEpochDays(syncDays); const since = sinceEpochDays(syncDays);
const raw = await fetchAccountsAndTransactions(accessUrl, since); const raw = await fetchAccountsAndTransactions(accessUrl, since);
@ -99,12 +103,14 @@ async function runSync(db, userId, dataSource, { days } = {}) {
for (const rawAccount of accounts) { for (const rawAccount of accounts) {
const accountRow = normalizeAccount(rawAccount, dataSource.id, userId); const accountRow = normalizeAccount(rawAccount, dataSource.id, userId);
const localAccId = upsertAccount(db, accountRow); const localAccount = upsertAccount(db, accountRow);
accountsUpserted += 1; accountsUpserted += 1;
if (localAccount.monitored === 0) continue;
for (const rawTx of (rawAccount.transactions || [])) { for (const rawTx of (rawAccount.transactions || [])) {
const txRow = normalizeTransaction( const txRow = normalizeTransaction(
rawTx, localAccId, dataSource.id, userId, dataSource.id, rawAccount.id, rawTx, localAccount.id, dataSource.id, userId, dataSource.id, rawAccount.id,
); );
const outcome = insertTransactionIfNew(db, txRow); const outcome = insertTransactionIfNew(db, txRow);
if (outcome === 'inserted') transactionsNew += 1; if (outcome === 'inserted') transactionsNew += 1;

View File

@ -36,12 +36,14 @@ function applyMerchantRules(db, userId) {
if (rules.length === 0) return { matched: 0 }; if (rules.length === 0) return { matched: 0 };
const txRows = db.prepare(` const txRows = db.prepare(`
SELECT id, amount, payee, description, memo, posted_date, transacted_at SELECT t.id, t.amount, t.payee, t.description, t.memo, t.posted_date, t.transacted_at
FROM transactions FROM transactions t
WHERE user_id = ? LEFT JOIN financial_accounts fa ON fa.id = t.account_id AND fa.user_id = t.user_id
AND match_status = 'unmatched' WHERE t.user_id = ?
AND ignored = 0 AND t.match_status = 'unmatched'
AND amount < 0 AND t.ignored = 0
AND t.amount < 0
AND (t.account_id IS NULL OR fa.id IS NULL OR fa.monitored = 1)
`).all(userId); `).all(userId);
if (txRows.length === 0) return { matched: 0 }; if (txRows.length === 0) return { matched: 0 };
@ -112,12 +114,14 @@ function syncBillPaymentsFromSimplefin(db, userId, billId) {
} }
const txRows = db.prepare(` const txRows = db.prepare(`
SELECT id, amount, payee, description, memo, posted_date, transacted_at SELECT t.id, t.amount, t.payee, t.description, t.memo, t.posted_date, t.transacted_at
FROM transactions FROM transactions t
WHERE user_id = ? LEFT JOIN financial_accounts fa ON fa.id = t.account_id AND fa.user_id = t.user_id
AND match_status = 'unmatched' WHERE t.user_id = ?
AND ignored = 0 AND t.match_status = 'unmatched'
AND amount < 0 AND t.ignored = 0
AND t.amount < 0
AND (t.account_id IS NULL OR fa.id IS NULL OR fa.monitored = 1)
`).all(userId); `).all(userId);
if (txRows.length === 0) return { added: 0 }; if (txRows.length === 0) return { added: 0 };

View File

@ -222,6 +222,21 @@ function getValidCycleTypes() {
return ['monthly', 'weekly', 'biweekly', 'quarterly', 'annual']; return ['monthly', 'weekly', 'biweekly', 'quarterly', 'annual'];
} }
function cycleTypeFromBillingCycle(billingCycle) {
const value = String(billingCycle || '').toLowerCase();
if (value === 'quarterly') return 'quarterly';
if (value === 'annually' || value === 'annual') return 'annual';
return 'monthly';
}
function billingCycleForCycleType(cycleType) {
const value = String(cycleType || '').toLowerCase();
if (value === 'quarterly') return 'quarterly';
if (value === 'annual') return 'annually';
if (value === 'weekly' || value === 'biweekly') return 'irregular';
return 'monthly';
}
/** /**
* Validates and normalizes bill data for creation/update. * Validates and normalizes bill data for creation/update.
* Returns an object with normalized values and any validation errors. * Returns an object with normalized values and any validation errors.
@ -271,9 +286,6 @@ function validateBillData(data, existingBill = null) {
normalized.interest_rate = existingBill?.interest_rate ?? null; normalized.interest_rate = existingBill?.interest_rate ?? null;
} }
// billing_cycle
normalized.billing_cycle = data.billing_cycle !== undefined ? (data.billing_cycle || 'monthly') : (existingBill?.billing_cycle || 'monthly');
// autopay_enabled // autopay_enabled
normalized.autopay_enabled = data.autopay_enabled !== undefined ? (data.autopay_enabled ? 1 : 0) : (existingBill?.autopay_enabled || 0); normalized.autopay_enabled = data.autopay_enabled !== undefined ? (data.autopay_enabled ? 1 : 0) : (existingBill?.autopay_enabled || 0);
@ -308,15 +320,20 @@ function validateBillData(data, existingBill = null) {
} }
normalized.history_visibility = nextVisibility; normalized.history_visibility = nextVisibility;
// cycle_type and cycle_day // cycle_type is canonical. billing_cycle is derived for legacy display/import/export compatibility.
let nextCycleType = (data.cycle_type !== undefined ? data.cycle_type : existingBill?.cycle_type) || 'monthly'; const submittedCycleType = data.cycle_type !== undefined
? data.cycle_type
: undefined;
const fallbackCycleType = existingBill?.cycle_type
|| cycleTypeFromBillingCycle(data.billing_cycle ?? existingBill?.billing_cycle);
let nextCycleType = submittedCycleType ?? fallbackCycleType ?? 'monthly';
let nextCycleDay = existingBill?.cycle_day || getDefaultCycleDay(nextCycleType); let nextCycleDay = existingBill?.cycle_day || getDefaultCycleDay(nextCycleType);
if (data.cycle_type !== undefined) { if (submittedCycleType !== undefined) {
if (!validCycleTypes.includes(data.cycle_type)) { if (!validCycleTypes.includes(submittedCycleType)) {
errors.push({ field: 'cycle_type', message: `cycle_type must be one of: ${validCycleTypes.join(', ')}` }); errors.push({ field: 'cycle_type', message: `cycle_type must be one of: ${validCycleTypes.join(', ')}` });
} else { } else {
nextCycleType = data.cycle_type; nextCycleType = submittedCycleType;
} }
} }
@ -332,6 +349,7 @@ function validateBillData(data, existingBill = null) {
} }
normalized.cycle_type = nextCycleType; normalized.cycle_type = nextCycleType;
normalized.cycle_day = nextCycleDay; normalized.cycle_day = nextCycleDay;
normalized.billing_cycle = billingCycleForCycleType(nextCycleType);
// Calculate bucket based on due_day // Calculate bucket based on due_day
normalized.bucket = normalized.due_day <= 14 ? '1st' : '15th'; normalized.bucket = normalized.due_day <= 14 ? '1st' : '15th';
@ -466,7 +484,9 @@ module.exports = {
VALID_VISIBILITY, VALID_VISIBILITY,
TEMPLATE_FIELDS, TEMPLATE_FIELDS,
auditBillsForUser, auditBillsForUser,
billingCycleForCycleType,
categoryBelongsToUser, categoryBelongsToUser,
cycleTypeFromBillingCycle,
getValidCycleTypes, getValidCycleTypes,
getDefaultCycleDay, getDefaultCycleDay,
insertBill, insertBill,

View File

@ -1537,9 +1537,9 @@ function applyOneDecision(db, userId, decision, previewRow, sessionData, allowOv
const autopay = decision.autopay_enabled ?? (previewRow?.detected_labels?.includes('autopay') ? 1 : 0); const autopay = decision.autopay_enabled ?? (previewRow?.detected_labels?.includes('autopay') ? 1 : 0);
const ins = db.prepare(` const ins = db.prepare(`
INSERT INTO bills (user_id, name, category_id, due_day, bucket, expected_amount, billing_cycle, autopay_enabled, active) INSERT INTO bills (user_id, name, category_id, due_day, bucket, expected_amount, billing_cycle, cycle_type, cycle_day, autopay_enabled, active)
VALUES (?, ?, ?, ?, ?, ?, 'monthly', ?, 1) VALUES (?, ?, ?, ?, ?, ?, 'monthly', 'monthly', ?, ?, 1)
`).run(userId, name, categoryId, dueDay, dueDay <= 14 ? '1st' : '15th', expectedAmount, autopay); `).run(userId, name, categoryId, dueDay, dueDay <= 14 ? '1st' : '15th', expectedAmount, String(dueDay), autopay);
const newBillId = ins.lastInsertRowid; const newBillId = ins.lastInsertRowid;
summary.created++; summary.created++;

View File

@ -1,6 +1,6 @@
'use strict'; 'use strict';
const { insertBill, validateBillData } = require('./billsService'); const { billingCycleForCycleType, insertBill, validateBillData } = require('./billsService');
const SUBSCRIPTION_TYPES = [ const SUBSCRIPTION_TYPES = [
'streaming', 'software', 'cloud', 'music', 'news', 'streaming', 'software', 'cloud', 'music', 'news',
@ -265,13 +265,6 @@ function dollarsFromTransactionAmount(amount) {
return Math.round((Math.abs(Number(amount || 0)) / 100) * 100) / 100; return Math.round((Math.abs(Number(amount || 0)) / 100) * 100) / 100;
} }
function billingCycleForCycleType(cycleType) {
if (cycleType === 'quarterly') return 'quarterly';
if (cycleType === 'annual') return 'annually';
if (cycleType === 'monthly') return 'monthly';
return 'irregular';
}
// ── Decline store ───────────────────────────────────────────────────────────── // ── Decline store ─────────────────────────────────────────────────────────────
function getDeclinedKeys(db, userId) { function getDeclinedKeys(db, userId) {
@ -307,10 +300,12 @@ function getSubscriptionRecommendations(db, userId) {
ds.name AS data_source_name ds.name AS data_source_name
FROM transactions t FROM transactions t
LEFT JOIN data_sources ds ON ds.id = t.data_source_id AND ds.user_id = t.user_id LEFT JOIN data_sources ds ON ds.id = t.data_source_id AND ds.user_id = t.user_id
LEFT JOIN financial_accounts fa ON fa.id = t.account_id AND fa.user_id = t.user_id
WHERE t.user_id = ? WHERE t.user_id = ?
AND t.ignored = 0 AND t.ignored = 0
AND t.match_status = 'unmatched' AND t.match_status = 'unmatched'
AND t.amount < 0 AND t.amount < 0
AND (t.account_id IS NULL OR fa.id IS NULL OR fa.monitored = 1)
AND COALESCE(t.posted_date, substr(t.transacted_at, 1, 10)) >= date('now', '-420 days') AND COALESCE(t.posted_date, substr(t.transacted_at, 1, 10)) >= date('now', '-420 days')
ORDER BY tx_date ASC ORDER BY tx_date ASC
`).all(userId); `).all(userId);
@ -469,6 +464,7 @@ function searchSubscriptionTransactions(db, userId, query = {}) {
WHERE t.user_id = ? WHERE t.user_id = ?
AND t.ignored = 0 AND t.ignored = 0
AND t.amount < 0 AND t.amount < 0
AND (t.account_id IS NULL OR fa.id IS NULL OR fa.monitored = 1)
AND (t.description LIKE ? OR t.payee LIKE ? OR t.memo LIKE ? OR t.category LIKE ?) AND (t.description LIKE ? OR t.payee LIKE ? OR t.memo LIKE ? OR t.category LIKE ?)
ORDER BY ORDER BY
CASE WHEN t.match_status = 'unmatched' THEN 0 ELSE 1 END, CASE WHEN t.match_status = 'unmatched' THEN 0 ELSE 1 END,

View File

@ -88,6 +88,7 @@ function getTransactionForUser(db, userId, id) {
LEFT JOIN financial_accounts fa ON fa.id = t.account_id AND fa.user_id = t.user_id LEFT JOIN financial_accounts fa ON fa.id = t.account_id AND fa.user_id = t.user_id
LEFT JOIN bills b ON b.id = t.matched_bill_id AND b.user_id = t.user_id AND b.deleted_at IS NULL LEFT JOIN bills b ON b.id = t.matched_bill_id AND b.user_id = t.user_id AND b.deleted_at IS NULL
WHERE t.id = ? AND t.user_id = ? WHERE t.id = ? AND t.user_id = ?
AND (t.account_id IS NULL OR fa.id IS NULL OR fa.monitored = 1)
`).get(id, userId); `).get(id, userId);
} }

View File

@ -6,6 +6,7 @@ const os = require('os');
const path = require('path'); const path = require('path');
const Database = require('better-sqlite3'); const Database = require('better-sqlite3');
const { getDb } = require('../db/database'); const { getDb } = require('../db/database');
const { billingCycleForCycleType, cycleTypeFromBillingCycle } = require('./billsService');
const MAX_SQLITE_BYTES = 50 * 1024 * 1024; const MAX_SQLITE_BYTES = 50 * 1024 * 1024;
const SESSION_TTL_HOURS = 24; const SESSION_TTL_HOURS = 24;
@ -135,6 +136,12 @@ function sanitizeBill(row) {
const dueDay = toInt(row.due_day); const dueDay = toInt(row.due_day);
if (!name || dueDay < 1 || dueDay > 31) return null; if (!name || dueDay < 1 || dueDay > 31) return null;
const interestRate = toNumber(row.interest_rate, null); const interestRate = toNumber(row.interest_rate, null);
const cycleType = row.cycle_type
? String(row.cycle_type).trim().toLowerCase()
: cycleTypeFromBillingCycle(row.billing_cycle);
const normalizedCycleType = ['monthly', 'weekly', 'biweekly', 'quarterly', 'annual'].includes(cycleType)
? cycleType
: 'monthly';
return { return {
old_id: toInt(row.id), old_id: toInt(row.id),
name, name,
@ -144,7 +151,9 @@ function sanitizeBill(row) {
bucket: dueDay <= 14 ? '1st' : '15th', bucket: dueDay <= 14 ? '1st' : '15th',
expected_amount: Math.max(0, toNumber(row.expected_amount, 0) ?? 0), expected_amount: Math.max(0, toNumber(row.expected_amount, 0) ?? 0),
interest_rate: interestRate == null || interestRate < 0 || interestRate > 100 ? null : interestRate, interest_rate: interestRate == null || interestRate < 0 || interestRate > 100 ? null : interestRate,
billing_cycle: VALID_BILLING_CYCLES.has(row.billing_cycle) ? row.billing_cycle : 'monthly', billing_cycle: VALID_BILLING_CYCLES.has(row.billing_cycle) ? row.billing_cycle : billingCycleForCycleType(normalizedCycleType),
cycle_type: normalizedCycleType,
cycle_day: cleanText(row.cycle_day, 32),
autopay_enabled: toInt(row.autopay_enabled, 0) ? 1 : 0, autopay_enabled: toInt(row.autopay_enabled, 0) ? 1 : 0,
autodraft_status: VALID_AUTODRAFT.has(row.autodraft_status) ? row.autodraft_status : 'none', autodraft_status: VALID_AUTODRAFT.has(row.autodraft_status) ? row.autodraft_status : 'none',
website: cleanText(row.website, 500), website: cleanText(row.website, 500),
@ -453,9 +462,9 @@ function ensureBill(db, userId, bill, categoryId, summary, details) {
const result = db.prepare(` const result = db.prepare(`
INSERT INTO bills INSERT INTO bills
(user_id, name, category_id, due_day, override_due_date, bucket, expected_amount, interest_rate, (user_id, name, category_id, due_day, override_due_date, bucket, expected_amount, interest_rate,
billing_cycle, autopay_enabled, autodraft_status, website, username, account_info, has_2fa, billing_cycle, cycle_type, cycle_day, autopay_enabled, autodraft_status, website, username, account_info, has_2fa,
active, notes) active, notes)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run( `).run(
userId, userId,
bill.name, bill.name,
@ -465,7 +474,9 @@ function ensureBill(db, userId, bill, categoryId, summary, details) {
bill.bucket, bill.bucket,
bill.expected_amount, bill.expected_amount,
bill.interest_rate, bill.interest_rate,
bill.billing_cycle, billingCycleForCycleType(bill.cycle_type),
bill.cycle_type,
bill.cycle_day || null,
bill.autopay_enabled, bill.autopay_enabled,
bill.autodraft_status, bill.autodraft_status,
bill.website, bill.website,

View File

@ -22,12 +22,14 @@ const {
deleteBackup, deleteBackup,
listBackups, listBackups,
applyRetention, applyRetention,
applyScheduledRetention,
importBackupBuffer, importBackupBuffer,
checksumFile, checksumFile,
} = require('../services/backupService'); } = require('../services/backupService');
const { const {
validateScheduleSettings, validateScheduleSettings,
computeNextRun, computeNextRun,
runScheduledBackupNow,
} = require('../services/backupScheduler'); } = require('../services/backupScheduler');
const { const {
pruneOrphanedBackupPartials, pruneOrphanedBackupPartials,
@ -74,9 +76,6 @@ test('deleteBackup removes the file', async () => {
}); });
test('applyRetention keeps only the requested number of backups', async () => { test('applyRetention keeps only the requested number of backups', async () => {
// Raise retention so createBackup's own internal pruning doesn't interfere.
setSetting('backup_schedule_retention_count', '100');
for (const b of listBackups()) { try { deleteBackup(b.id); } catch {} } for (const b of listBackups()) { try { deleteBackup(b.id); } catch {} }
await createBackup('bill-tracker-backup'); await createBackup('bill-tracker-backup');
@ -91,6 +90,23 @@ test('applyRetention keeps only the requested number of backups', async () => {
setSetting('backup_schedule_retention_count', '2'); setSetting('backup_schedule_retention_count', '2');
}); });
test('applyScheduledRetention only prunes scheduled backups', async () => {
for (const b of listBackups()) { try { deleteBackup(b.id); } catch {} }
const manual = await createBackup('bill-tracker-backup');
await createBackup('scheduled-backup');
await createBackup('scheduled-backup');
await createBackup('scheduled-backup');
const { deleted } = applyScheduledRetention(2);
const backups = listBackups();
const scheduled = backups.filter(backup => backup.type === 'scheduled');
assert.equal(deleted.length, 1);
assert.equal(scheduled.length, 2);
assert.ok(backups.some(backup => backup.id === manual.id), 'manual backup was not pruned');
});
test('importBackupBuffer accepts a valid SQLite buffer', async () => { test('importBackupBuffer accepts a valid SQLite buffer', async () => {
// Create a real backup, then re-import its bytes // Create a real backup, then re-import its bytes
const src = await createBackup('bill-tracker-backup'); const src = await createBackup('bill-tracker-backup');
@ -183,6 +199,19 @@ test('computeNextRun advances to next day when time has already passed today', (
assert.ok(next.getTime() - from.getTime() > 12 * 60 * 60 * 1000); assert.ok(next.getTime() - from.getTime() > 12 * 60 * 60 * 1000);
}); });
test('runScheduledBackupNow keeps only the configured number of scheduled backups', async () => {
for (const b of listBackups()) { try { deleteBackup(b.id); } catch {} }
setSetting('backup_schedule_retention_count', '2');
await runScheduledBackupNow();
await runScheduledBackupNow();
await runScheduledBackupNow();
const scheduled = listBackups().filter(backup => backup.type === 'scheduled');
assert.equal(scheduled.length, 2);
});
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
// cleanupService — pruneOrphanedBackupPartials // cleanupService — pruneOrphanedBackupPartials
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────

View File

@ -0,0 +1,96 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const dbPath = path.join(os.tmpdir(), `bill-tracker-bank-sync-test-${process.pid}.sqlite`);
process.env.DB_PATH = dbPath;
const { getDb, closeDb } = require('../db/database');
const { encryptSecret } = require('../services/encryptionService');
const { syncDataSource } = require('../services/bankSyncService');
function createUser(db, suffix) {
return db.prepare(`
INSERT INTO users (username, password_hash, role, active, email, created_at, updated_at)
VALUES (?, 'x', 'user', 1, ?, datetime('now'), datetime('now'))
`).run(`bank-sync-${suffix}`, `bank-sync-${suffix}@local`).lastInsertRowid;
}
function createSource(db, userId) {
return db.prepare(`
INSERT INTO data_sources (user_id, type, provider, name, status, encrypted_secret)
VALUES (?, 'provider_sync', 'simplefin', 'SimpleFIN', 'active', ?)
`).run(userId, encryptSecret('https://user:pass@example.com/simplefin')).lastInsertRowid;
}
function createAccount(db, userId, dataSourceId, providerAccountId, monitored) {
return db.prepare(`
INSERT INTO financial_accounts
(user_id, data_source_id, provider_account_id, name, currency, monitored)
VALUES (?, ?, ?, ?, 'USD', ?)
`).run(userId, dataSourceId, providerAccountId, providerAccountId, monitored ? 1 : 0).lastInsertRowid;
}
test.after(() => {
closeDb();
for (const suffix of ['', '-wal', '-shm']) {
fs.rmSync(`${dbPath}${suffix}`, { force: true });
}
});
test('SimpleFIN sync skips storing transactions for unmonitored accounts', async () => {
const db = getDb();
const userId = createUser(db, 'skip-unmonitored');
const dataSourceId = createSource(db, userId);
const mutedAccountId = createAccount(db, userId, dataSourceId, 'muted-account', false);
const originalFetch = global.fetch;
global.fetch = async () => ({
ok: true,
status: 200,
json: async () => ({
accounts: [
{
id: 'muted-account',
name: 'Muted Account',
currency: 'USD',
balance: '100.00',
transactions: [
{ id: 'muted-tx-1', amount: '-12.34', posted: 1772323200, description: 'Muted charge' },
],
},
{
id: 'tracked-account',
name: 'Tracked Account',
currency: 'USD',
balance: '200.00',
transactions: [
{ id: 'tracked-tx-1', amount: '-56.78', posted: 1772323200, description: 'Tracked charge' },
],
},
],
}),
});
try {
const result = await syncDataSource(db, userId, dataSourceId);
assert.equal(result.transactionsNew, 1);
} finally {
global.fetch = originalFetch;
}
const mutedTransactions = db.prepare('SELECT COUNT(*) AS count FROM transactions WHERE account_id = ?').get(mutedAccountId).count;
assert.equal(mutedTransactions, 0);
const trackedAccount = db.prepare(`
SELECT id, monitored
FROM financial_accounts
WHERE user_id = ? AND data_source_id = ? AND provider_account_id = 'tracked-account'
`).get(userId, dataSourceId);
assert.equal(trackedAccount.monitored, 1);
const trackedTransactions = db.prepare('SELECT COUNT(*) AS count FROM transactions WHERE account_id = ?').get(trackedAccount.id).count;
assert.equal(trackedTransactions, 1);
});

View File

@ -0,0 +1,54 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const {
billingCycleForCycleType,
cycleTypeFromBillingCycle,
validateBillData,
} = require('../services/billsService');
test('billing schedule helpers map canonical cycle_type to legacy billing_cycle', () => {
assert.equal(billingCycleForCycleType('monthly'), 'monthly');
assert.equal(billingCycleForCycleType('quarterly'), 'quarterly');
assert.equal(billingCycleForCycleType('annual'), 'annually');
assert.equal(billingCycleForCycleType('weekly'), 'irregular');
assert.equal(billingCycleForCycleType('biweekly'), 'irregular');
});
test('billing schedule helpers recover legacy billing_cycle values', () => {
assert.equal(cycleTypeFromBillingCycle('monthly'), 'monthly');
assert.equal(cycleTypeFromBillingCycle('quarterly'), 'quarterly');
assert.equal(cycleTypeFromBillingCycle('annually'), 'annual');
assert.equal(cycleTypeFromBillingCycle('irregular'), 'monthly');
});
test('validateBillData derives billing_cycle from cycle_type', () => {
const { errors, normalized } = validateBillData({
name: 'Gym',
due_day: 12,
expected_amount: 25,
billing_cycle: 'monthly',
cycle_type: 'biweekly',
cycle_day: 'friday',
});
assert.deepEqual(errors, []);
assert.equal(normalized.cycle_type, 'biweekly');
assert.equal(normalized.cycle_day, 'friday');
assert.equal(normalized.billing_cycle, 'irregular');
});
test('validateBillData uses legacy billing_cycle when cycle_type is absent', () => {
const { errors, normalized } = validateBillData({
name: 'Insurance',
due_day: 20,
expected_amount: 100,
billing_cycle: 'annually',
});
assert.deepEqual(errors, []);
assert.equal(normalized.cycle_type, 'annual');
assert.equal(normalized.billing_cycle, 'annually');
});

View File

@ -23,10 +23,11 @@ function createUser(db, suffix) {
function createTransaction(db, userId, overrides = {}) { function createTransaction(db, userId, overrides = {}) {
return db.prepare(` return db.prepare(`
INSERT INTO transactions INSERT INTO transactions
(user_id, source_type, posted_date, amount, currency, description, payee, match_status, ignored) (user_id, source_type, account_id, posted_date, amount, currency, description, payee, match_status, ignored)
VALUES (?, 'manual', ?, ?, 'USD', ?, ?, 'unmatched', 0) VALUES (?, 'manual', ?, ?, ?, 'USD', ?, ?, 'unmatched', 0)
`).run( `).run(
userId, userId,
overrides.account_id || null,
overrides.posted_date || new Date().toISOString().slice(0, 10), overrides.posted_date || new Date().toISOString().slice(0, 10),
overrides.amount ?? -1599, overrides.amount ?? -1599,
overrides.description || 'NETFLIX.COM', overrides.description || 'NETFLIX.COM',
@ -34,6 +35,19 @@ function createTransaction(db, userId, overrides = {}) {
).lastInsertRowid; ).lastInsertRowid;
} }
function createAccount(db, userId, monitored = true) {
const sourceId = db.prepare(`
INSERT INTO data_sources (user_id, type, provider, name, status)
VALUES (?, 'provider_sync', 'simplefin', 'SimpleFIN', 'active')
`).run(userId).lastInsertRowid;
return db.prepare(`
INSERT INTO financial_accounts
(user_id, data_source_id, provider_account_id, name, currency, monitored)
VALUES (?, ?, ?, 'Checking', 'USD', ?)
`).run(userId, sourceId, `acct-${sourceId}`, monitored ? 1 : 0).lastInsertRowid;
}
test.after(() => { test.after(() => {
closeDb(); closeDb();
for (const suffix of ['', '-wal', '-shm']) { for (const suffix of ['', '-wal', '-shm']) {
@ -89,3 +103,20 @@ test('Claude.ai catalog seed matches Anthropic transaction descriptors', () => {
assert.equal(match.catalog_match.name, 'Claude.ai'); assert.equal(match.catalog_match.name, 'Claude.ai');
assert.equal(match.catalog_match.subscription_type, 'software'); assert.equal(match.catalog_match.subscription_type, 'software');
}); });
test('subscription recommendations and search ignore unmonitored SimpleFIN accounts', () => {
const db = getDb();
const userId = createUser(db, 'unmonitored');
const accountId = createAccount(db, userId, false);
const transactionId = createTransaction(db, userId, {
account_id: accountId,
description: 'NETFLIX.COM 866-579-7172',
payee: 'NETFLIX.COM',
});
const recommendations = getSubscriptionRecommendations(db, userId);
assert.equal(recommendations.some(item => item.catalog_match?.name === 'Netflix'), false);
const matches = searchSubscriptionTransactions(db, userId, { q: 'netflix', limit: 10 });
assert.equal(matches.some(item => item.id === transactionId), false);
});