feat: Payoff Custom mode, Summary reordering, unifed billing schedule, SimpleFIN + backup fixes (batch v0.34.1.3)
This commit is contained in:
parent
c23cae1107
commit
90cfed035b
39
HISTORY.md
39
HISTORY.md
|
|
@ -1,28 +1,59 @@
|
|||
# Bill Tracker — Changelog
|
||||
|
||||
## v0.34.1.2
|
||||
## v0.34.1.3
|
||||
|
||||
### 🚀 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.
|
||||
|
||||
- **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
|
||||
|
||||
- **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, 1–45 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
|
||||
|
||||
- **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.
|
||||
|
||||
- **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.
|
||||
|
||||
- **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.
|
||||
- **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_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.
|
||||
|
|
@ -40,7 +71,7 @@
|
|||
- 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
|
||||
|
||||
### 🛠 Internal
|
||||
### 🛠 Internal
|
||||
|
||||
- **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.
|
||||
|
|
|
|||
|
|
@ -16,6 +16,12 @@ import {
|
|||
} from '@/components/ui/select';
|
||||
import { api } from '@/api';
|
||||
import { cn, fmt, fmtDate, todayStr } from '@/lib/utils';
|
||||
import {
|
||||
BILLING_SCHEDULE_OPTIONS,
|
||||
billingCycleForSchedule,
|
||||
defaultCycleDayForSchedule,
|
||||
scheduleValue,
|
||||
} from '@/lib/billingSchedule';
|
||||
|
||||
function getOrdinalSuffix(day) {
|
||||
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
|
||||
const CAT_NONE = 'none';
|
||||
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 [expectedAmount, setExpected] = useState(String(sourceBill?.expected_amount || ''));
|
||||
const [interestRate, setInterestRate] = useState(sourceBill?.interest_rate == null ? '' : String(sourceBill.interest_rate));
|
||||
const [billingCycle, setCycle] = useState(sourceBill?.billing_cycle || 'monthly');
|
||||
const [cycleType, setCycleType] = useState(sourceBill?.cycle_type || 'monthly');
|
||||
const [cycleDay, setCycleDay] = useState(sourceBill?.cycle_day || '1');
|
||||
const initialCycleType = scheduleValue(sourceBill || {});
|
||||
const [cycleType, setCycleType] = useState(initialCycleType);
|
||||
const [cycleDay, setCycleDay] = useState(sourceBill?.cycle_day || defaultCycleDayForSchedule(initialCycleType));
|
||||
const [autopay, setAutopay] = useState(!!sourceBill?.autopay_enabled);
|
||||
const [autodraftStatus, setAutodraftStatus] = useState(sourceBill?.autodraft_status || (sourceBill?.autopay_enabled ? 'assumed_paid' : 'none'));
|
||||
const [autoMarkPaid, setAutoMarkPaid] = useState(!!sourceBill?.auto_mark_paid);
|
||||
|
|
@ -296,7 +298,7 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa
|
|||
|
||||
const handleCycleTypeChange = (value) => {
|
||||
setCycleType(value);
|
||||
setCycleDay(defaultCycleDayFor(value));
|
||||
setCycleDay(defaultCycleDayForSchedule(value));
|
||||
};
|
||||
|
||||
function resetPaymentForm() {
|
||||
|
|
@ -439,7 +441,7 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa
|
|||
override_due_date: sourceBill?.override_due_date,
|
||||
expected_amount: parseFloat(expectedAmount) || 0,
|
||||
interest_rate: parsedInterestRate,
|
||||
billing_cycle: billingCycle,
|
||||
billing_cycle: billingCycleForSchedule(cycleType),
|
||||
cycle_type: cycleType,
|
||||
cycle_day: cycleDay,
|
||||
autopay_enabled: autopay,
|
||||
|
|
@ -578,35 +580,17 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa
|
|||
)}
|
||||
</div>
|
||||
|
||||
{/* Billing Cycle */}
|
||||
{/* Billing Schedule */}
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs uppercase tracking-wider text-muted-foreground">Billing Cycle</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>
|
||||
<Label className="text-xs uppercase tracking-wider text-muted-foreground">Billing Schedule</Label>
|
||||
<Select value={cycleType} onValueChange={handleCycleTypeChange}>
|
||||
<SelectTrigger className={cn(inp, 'w-full')}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="monthly">Monthly</SelectItem>
|
||||
<SelectItem value="weekly">Weekly</SelectItem>
|
||||
<SelectItem value="biweekly">Biweekly</SelectItem>
|
||||
<SelectItem value="quarterly">Quarterly</SelectItem>
|
||||
<SelectItem value="annual">Annual</SelectItem>
|
||||
{BILLING_SCHEDULE_OPTIONS.map(([value, label]) => (
|
||||
<SelectItem key={value} value={value}>{label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { ArrowDown, ArrowUp, Copy, GripVertical, PenLine, EyeOff, Eye, Clock, Trash2 } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { scheduleLabel } from '@/lib/billingSchedule';
|
||||
import { MobileBillRow } from '@/components/MobileBillRow';
|
||||
|
||||
function ordinal(n) {
|
||||
|
|
@ -130,7 +131,7 @@ function BillCard({ bill, prefs = ALL_ON, onEdit, onToggle, onDelete, onHistory,
|
|||
|
||||
{/* Meta row */}
|
||||
<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>}
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import {
|
|||
import { toast } from 'sonner';
|
||||
import { api } from '@/api';
|
||||
import { cn, fmt } from '@/lib/utils';
|
||||
import { scheduleLabel, scheduleValue } from '@/lib/billingSchedule';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
|
|
@ -68,7 +69,8 @@ function billSearchText(bill) {
|
|||
bill.name,
|
||||
bill.category_name,
|
||||
bill.notes,
|
||||
bill.billing_cycle,
|
||||
scheduleValue(bill),
|
||||
scheduleLabel(bill),
|
||||
bill.bucket,
|
||||
bill.website,
|
||||
amountSearchText(
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import React, { useMemo } from 'react';
|
|||
import { ArrowDown, ArrowUp, Copy, GripVertical, History } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { scheduleLabel } from '@/lib/billingSchedule';
|
||||
|
||||
function hasHistoricalVisibility(bill) {
|
||||
const visibility = bill.history_visibility;
|
||||
|
|
@ -137,7 +138,7 @@ export const MobileBillRow = React.memo(function MobileBillRow({ bill, onEdit, o
|
|||
</div>
|
||||
<div>
|
||||
<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>
|
||||
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ const DEFAULT_SETTINGS = {
|
|||
enabled: false,
|
||||
frequency: 'daily',
|
||||
time: '02:00',
|
||||
retention_count: 14,
|
||||
retention_count: 2,
|
||||
last_run_at: null,
|
||||
next_run_at: null,
|
||||
last_error: null,
|
||||
|
|
@ -142,7 +142,7 @@ export default function BackupManagementCard() {
|
|||
enabled: !!settings.enabled,
|
||||
frequency: settings.frequency,
|
||||
time: settings.time,
|
||||
retention_count: parseInt(settings.retention_count, 10) || 14,
|
||||
retention_count: parseInt(settings.retention_count, 10) || 2,
|
||||
});
|
||||
setSettings({ ...DEFAULT_SETTINGS, ...saved });
|
||||
toast.success('Backup schedule saved.');
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import { AlertTriangle, Info } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { api } from '@/api';
|
||||
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.');
|
||||
return;
|
||||
}
|
||||
if (!Number.isFinite(days) || days < 1 || days > 90) {
|
||||
toast.error('Transaction history must be between 1 and 90 days — SimpleFIN Bridge does not support longer windows.');
|
||||
if (!Number.isFinite(days) || days < 1 || days > 45) {
|
||||
toast.error('Routine sync lookback must be 1–45 days. SimpleFIN Bridge enforces a 45-day hard limit — values above 45 return errors.');
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
|
|
@ -145,25 +146,72 @@ export default function BankSyncAdminCard() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* Transaction history lookback */}
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
{/* Sync window — two-mode explainer */}
|
||||
<div className="space-y-3">
|
||||
<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">
|
||||
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>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
max="90"
|
||||
step="1"
|
||||
value={syncDays}
|
||||
onChange={e => setSyncDays(Math.min(90, Math.max(1, parseInt(e.target.value, 10) || 90)))}
|
||||
className="w-20 text-sm text-right"
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">days</span>
|
||||
|
||||
{/* Initial / backfill — read-only */}
|
||||
<div className="rounded-lg border border-border/50 bg-muted/20 px-4 py-3 space-y-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">
|
||||
Initial connect & backfill
|
||||
</p>
|
||||
<span className="font-mono text-sm font-bold">44 days</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
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>7–30 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'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>
|
||||
|
||||
|
|
|
|||
|
|
@ -231,6 +231,7 @@
|
|||
.summary-screen-header,
|
||||
.summary-controls,
|
||||
.summary-actions,
|
||||
.summary-reorder-controls,
|
||||
.summary-edit-actions,
|
||||
.summary-income-form {
|
||||
display: none !important;
|
||||
|
|
@ -283,6 +284,10 @@
|
|||
padding-left: 0 !important;
|
||||
}
|
||||
|
||||
.summary-expense-row {
|
||||
grid-template-columns: minmax(0, 1fr) 7.5rem 5.5rem !important;
|
||||
}
|
||||
|
||||
.analytics-chart-grid {
|
||||
display: block !important;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import { billingCycleForSchedule, scheduleValue } from './billingSchedule';
|
||||
|
||||
function categoryForTemplate(template, categories = []) {
|
||||
const keywords = template?.categoryKeywords || [];
|
||||
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),
|
||||
due_day: data.due_day || 1,
|
||||
expected_amount: data.expected_amount ?? 0,
|
||||
billing_cycle: data.billing_cycle || 'monthly',
|
||||
cycle_type: data.cycle_type || 'monthly',
|
||||
billing_cycle: billingCycleForSchedule(scheduleValue(data)),
|
||||
cycle_type: scheduleValue(data),
|
||||
cycle_day: String(data.cycle_day || '1'),
|
||||
autopay_enabled: !!data.autopay_enabled,
|
||||
autodraft_status: data.autodraft_status || (data.autopay_enabled ? 'assumed_paid' : 'none'),
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
|
|
@ -22,6 +22,7 @@ import { moveInArray, movedItemId, reorderPayload } from '@/lib/reorder';
|
|||
import BillsTableInner from '@/components/BillsTableInner';
|
||||
import BillModal from '@/components/BillModal';
|
||||
import { makeBillDraft } from '@/lib/billDrafts';
|
||||
import { scheduleLabel, scheduleValue } from '@/lib/billingSchedule';
|
||||
|
||||
const VISIBILITY_OPTIONS = [
|
||||
{ value: 'default', label: 'Default', description: 'Use the app default behavior for inactive bill history.' },
|
||||
|
|
@ -196,7 +197,7 @@ const PREFS_LABELS = [
|
|||
['showCategory', 'Category'],
|
||||
['showDueDay', 'Due day'],
|
||||
['showAmount', 'Amount'],
|
||||
['showCycle', 'Billing cycle'],
|
||||
['showCycle', 'Billing schedule'],
|
||||
['showApr', 'APR'],
|
||||
['showBalance', 'Balance'],
|
||||
['showMinPayment', 'Min payment'],
|
||||
|
|
@ -732,7 +733,7 @@ export default function BillsPage() {
|
|||
}
|
||||
|
||||
const cycleOptions = useMemo(() => (
|
||||
Array.from(new Set(bills.map(b => b.billing_cycle || 'monthly'))).sort()
|
||||
Array.from(new Set(bills.map(scheduleValue))).sort()
|
||||
), [bills]);
|
||||
|
||||
const filteredBills = useMemo(() => {
|
||||
|
|
@ -740,7 +741,7 @@ export default function BillsPage() {
|
|||
return bills.filter(bill => {
|
||||
if (filters.inactive && bill.active) 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.debt && !billIsDebt(bill)) return false;
|
||||
if (filters.firstBucket && !filters.fifteenthBucket && bill.bucket !== '1st') return false;
|
||||
|
|
@ -751,7 +752,8 @@ export default function BillsPage() {
|
|||
bill.name,
|
||||
bill.category_name,
|
||||
bill.notes,
|
||||
bill.billing_cycle,
|
||||
scheduleValue(bill),
|
||||
scheduleLabel(bill),
|
||||
bill.bucket,
|
||||
amountSearchText(bill.expected_amount, bill.current_balance, bill.minimum_payment, bill.interest_rate),
|
||||
].filter(Boolean).join(' ').toLowerCase();
|
||||
|
|
@ -910,12 +912,12 @@ export default function BillsPage() {
|
|||
</Select>
|
||||
<Select value={filters.cycle} onValueChange={value => setFilterValue('cycle', value)}>
|
||||
<SelectTrigger className="h-10 capitalize">
|
||||
<SelectValue placeholder="Billing cycle" />
|
||||
<SelectValue placeholder="Billing schedule" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={FILTER_ALL}>All cycles</SelectItem>
|
||||
{cycleOptions.map(cycle => (
|
||||
<SelectItem key={cycle} value={cycle}>{cycle.replace(/_/g, ' ')}</SelectItem>
|
||||
<SelectItem key={cycle} value={cycle}>{scheduleLabel(cycle)}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
|
|
|||
|
|
@ -1,16 +1,38 @@
|
|||
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 { api } from '@/api';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||
Select, SelectContent, SelectGroup, SelectItem, SelectLabel,
|
||||
SelectSeparator, SelectTrigger, SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { cn } from '@/lib/utils';
|
||||
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 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function fmt(v) {
|
||||
|
|
@ -56,7 +78,7 @@ function numMonths(track) {
|
|||
return `${y} yr ${m} mo`;
|
||||
}
|
||||
|
||||
// ─── Stat card ────────────────────────────────────────────────────────────────
|
||||
// ─── Sub-components ───────────────────────────────────────────────────────────
|
||||
|
||||
function StatCard({ label, value, sub, color = 'amber' }) {
|
||||
const colors = {
|
||||
|
|
@ -73,8 +95,6 @@ function StatCard({ label, value, sub, color = 'amber' }) {
|
|||
);
|
||||
}
|
||||
|
||||
// ─── Input row ────────────────────────────────────────────────────────────────
|
||||
|
||||
function InputRow({ label, hint, children }) {
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
|
|
@ -89,16 +109,15 @@ function InputRow({ label, hint, children }) {
|
|||
);
|
||||
}
|
||||
|
||||
// ─── Empty states ─────────────────────────────────────────────────────────────
|
||||
|
||||
function EmptyDebts() {
|
||||
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">
|
||||
<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">
|
||||
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>
|
||||
</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">
|
||||
<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-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>
|
||||
);
|
||||
}
|
||||
|
|
@ -121,24 +142,33 @@ export default function PayoffPage() {
|
|||
const [extraPayment, setExtraPayment] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
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 [simRate, setSimRate] = useState('');
|
||||
const [oneTimeExtra, setOneTimeExtra] = useState('');
|
||||
const [applying, setApplying] = useState(false);
|
||||
|
||||
const isCustom = selectedId === 'custom';
|
||||
|
||||
const loadData = useCallback(() => {
|
||||
setLoading(true);
|
||||
setLoadError(null);
|
||||
Promise.all([api.snowball(), api.snowballSettings()])
|
||||
.then(([billData, settings]) => {
|
||||
const debtBills = (billData || []).filter(b => (b.current_balance ?? 0) > 0);
|
||||
setBills(debtBills);
|
||||
// Use api.bills() so ALL active bills with a balance appear (not just debt categories)
|
||||
Promise.all([api.bills(), api.snowballSettings()])
|
||||
.then(([allBills, settings]) => {
|
||||
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);
|
||||
if (debtBills.length > 0 && !selectedId) {
|
||||
setSelectedId(debtBills[0].id);
|
||||
if (withBalance.length > 0 && !selectedId) {
|
||||
setSelectedId(withBalance[0].id);
|
||||
}
|
||||
})
|
||||
.catch(err => setLoadError(err.message || 'Failed to load data'))
|
||||
|
|
@ -147,53 +177,65 @@ export default function PayoffPage() {
|
|||
|
||||
useEffect(() => { loadData(); }, [loadData]);
|
||||
|
||||
const bill = useMemo(() => bills.find(b => b.id === selectedId) ?? null, [bills, selectedId]);
|
||||
const isAttack = bills[0]?.id === selectedId;
|
||||
const bill = useMemo(
|
||||
() => (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(() => {
|
||||
if (isCustom) {
|
||||
setSimPayment('');
|
||||
setSimRate('0');
|
||||
setOneTimeExtra('');
|
||||
return;
|
||||
}
|
||||
if (!bill) return;
|
||||
setSimPayment(String(Math.max(bill.minimum_payment ?? 0, bill.expected_amount ?? 0)));
|
||||
setSimRate(String(bill.interest_rate ?? 0));
|
||||
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 simRateN = Math.max(0, Number(simRate) || 0);
|
||||
const oneTimeExtraN = Math.max(0, Number(oneTimeExtra) || 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(() => {
|
||||
if (!bill) return { minTrack: [], currentTrack: [], simTrack: [] };
|
||||
const b = bill.current_balance;
|
||||
const min = minPayment > 0 ? minPayment : 0.01;
|
||||
const currentPmt = isAttack ? min + extraPayment : min;
|
||||
if (!activeBalance) return { minTrack: [], currentTrack: [], simTrack: [] };
|
||||
const min = !isCustom && minPayment > 0 ? minPayment : 0.01;
|
||||
const currentPmt = !isCustom && isAttack ? min + extraPayment : min;
|
||||
return {
|
||||
minTrack: buildPayoffSchedule(b, simRateN, min),
|
||||
currentTrack: buildPayoffSchedule(b, simRateN, currentPmt),
|
||||
simTrack: buildPayoffSchedule(b, simRateN, simPaymentN, oneTimeExtraN),
|
||||
minTrack: isCustom ? [] : buildPayoffSchedule(activeBalance, simRateN, min),
|
||||
currentTrack: isCustom ? [] : buildPayoffSchedule(activeBalance, simRateN, currentPmt),
|
||||
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 simInterest = useMemo(() => simTrack.reduce((s, m) => s + m.interest, 0), [simTrack]);
|
||||
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 interestSavings = Math.max(0, minInterest - simInterest);
|
||||
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 minPayoffLabel = payoffLabel(minTrack);
|
||||
const simDuration = numMonths(simTrack);
|
||||
|
||||
const paymentBelowMin = simPaymentN > 0 && simPaymentN < minPayment && minPayment > 0;
|
||||
const paymentTooLow = bill && simPaymentN > 0 && simTrack.length === 0;
|
||||
const paymentBelowMin = !isCustom && simPaymentN > 0 && simPaymentN < minPayment && minPayment > 0;
|
||||
const paymentTooLow = activeBalance > 0 && simPaymentN > 0 && simTrack.length === 0;
|
||||
const customNeedsBalance = isCustom && !customBalance;
|
||||
|
||||
const defaultSimPayment = bill
|
||||
? String(Math.max(bill.minimum_payment ?? 0, bill.expected_amount ?? 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 = () => {
|
||||
if (!bill) return;
|
||||
|
|
@ -202,6 +244,8 @@ export default function PayoffPage() {
|
|||
setOneTimeExtra('');
|
||||
};
|
||||
|
||||
const handlePrint = () => window.print();
|
||||
|
||||
const handleApply = async () => {
|
||||
if (!bill || applying) return;
|
||||
setApplying(true);
|
||||
|
|
@ -225,6 +269,10 @@ export default function PayoffPage() {
|
|||
}
|
||||
};
|
||||
|
||||
const handleSelectChange = (val) => {
|
||||
setSelectedId(val === 'custom' ? 'custom' : Number(val));
|
||||
};
|
||||
|
||||
// ── Render ──────────────────────────────────────────────────────────────────
|
||||
|
||||
if (loading) {
|
||||
|
|
@ -234,9 +282,7 @@ export default function PayoffPage() {
|
|||
<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="space-y-4">
|
||||
{[1, 2, 3, 4].map(i => (
|
||||
<div key={i} className="h-16 rounded-xl bg-muted/40" />
|
||||
))}
|
||||
{[1, 2, 3, 4].map(i => <div key={i} className="h-16 rounded-xl bg-muted/40" />)}
|
||||
</div>
|
||||
<div className="h-96 rounded-xl bg-muted/40" />
|
||||
</div>
|
||||
|
|
@ -257,236 +303,360 @@ export default function PayoffPage() {
|
|||
);
|
||||
}
|
||||
|
||||
const showResults = (isCustom && activeBalance > 0 && simTrack.length > 0) ||
|
||||
(!isCustom && bill && simTrack.length > 0);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<>
|
||||
<style>{PRINT_STYLES}</style>
|
||||
|
||||
{/* Page header */}
|
||||
<div className="mb-6 flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Payoff Simulator</h1>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">
|
||||
Explore how extra payments reduce interest and shorten your payoff timeline.
|
||||
</p>
|
||||
<div id="payoff-print-area">
|
||||
|
||||
{/* ── Print-only summary header (hidden on screen) ── */}
|
||||
<div className="print-only" style={{ display: 'none' }}>
|
||||
<h2 style={{ fontSize: '18px', fontWeight: 700, marginBottom: '4px' }}>
|
||||
Payoff Simulator — {activeName || '—'}
|
||||
</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>
|
||||
{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 */}
|
||||
<div className="mb-6">
|
||||
{bills.length === 0 ? (
|
||||
<EmptyDebts />
|
||||
) : (
|
||||
<Select value={selectedId ? String(selectedId) : ''} onValueChange={v => setSelectedId(Number(v))}>
|
||||
<SelectTrigger className="w-72">
|
||||
{/* ── Page header ── */}
|
||||
<div className="mb-6 flex items-start justify-between gap-4 no-print">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Payoff Simulator</h1>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">
|
||||
Explore how extra payments reduce interest and shorten your payoff timeline.
|
||||
</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…" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{bills.map(b => (
|
||||
<SelectItem key={b.id} value={String(b.id)}>
|
||||
<span className="font-medium">{b.name}</span>
|
||||
{b.current_balance ? (
|
||||
<span className="ml-2 text-muted-foreground font-mono text-xs">
|
||||
{fmt(b.current_balance)}
|
||||
</span>
|
||||
) : null}
|
||||
{bills.length > 0 && (
|
||||
<SelectGroup>
|
||||
<SelectLabel>Your Bills</SelectLabel>
|
||||
{bills.map(b => (
|
||||
<SelectItem key={b.id} value={String(b.id)}>
|
||||
<span className="font-medium">{b.name}</span>
|
||||
<span className="ml-2 text-muted-foreground font-mono text-xs">
|
||||
{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>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Main content: left panel + right panel */}
|
||||
{!bill ? (
|
||||
bills.length > 0 ? <NoSelection /> : null
|
||||
) : (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-[340px_1fr] gap-6 items-start">
|
||||
{bills.length === 0 && !isCustom && (
|
||||
<p className="mt-3 text-xs text-muted-foreground">
|
||||
No bills with a current balance found.{' '}
|
||||
<button
|
||||
type="button"
|
||||
className="underline text-primary hover:opacity-80"
|
||||
onClick={() => setSelectedId('custom')}
|
||||
>
|
||||
Use Custom instead
|
||||
</button>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Left panel ── */}
|
||||
<div className="table-surface p-5 space-y-5">
|
||||
{/* ── Empty / no-selection states ── */}
|
||||
{!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>
|
||||
|
||||
{minPayment <= 0 && (
|
||||
<p className="text-xs text-amber-600 dark:text-amber-400 flex items-center gap-1.5">
|
||||
<AlertCircle className="h-3.5 w-3.5 shrink-0" />
|
||||
Set a minimum payment on the Snowball page for best results.
|
||||
</p>
|
||||
)}
|
||||
{/* ── Right panel ── */}
|
||||
<div className="space-y-4">
|
||||
|
||||
{/* 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"
|
||||
placeholder="0.00"
|
||||
{/* Chart */}
|
||||
{simTrack.length > 0 ? (
|
||||
<PayoffChart
|
||||
minTrack={minTrack}
|
||||
currentTrack={currentTrack}
|
||||
simTrack={simTrack}
|
||||
startBalance={activeBalance}
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground shrink-0">%</span>
|
||||
</div>
|
||||
</InputRow>
|
||||
|
||||
{/* Monthly payment */}
|
||||
<InputRow label="Monthly Payment">
|
||||
<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>
|
||||
)}
|
||||
{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 className="flex items-center justify-center rounded-xl border border-dashed border-border/60 bg-muted/10 h-[300px] text-sm text-muted-foreground">
|
||||
{customNeedsBalance
|
||||
? 'Enter a balance and payment to see the chart'
|
||||
: simPaymentN <= 0
|
||||
? 'Enter a monthly payment to see the chart'
|
||||
: 'Payment too low to pay off this debt'}
|
||||
</div>
|
||||
</div>
|
||||
</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>
|
||||
{/* Stats row */}
|
||||
{showResults && (
|
||||
<>
|
||||
<div className={cn('grid gap-3', !isCustom && interestSavings >= 0 ? 'grid-cols-2' : 'grid-cols-1')}>
|
||||
{!isCustom && (
|
||||
<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` : (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>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground text-center">Enter a payment to see payoff date</p>
|
||||
|
||||
{/* 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(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>
|
||||
)}
|
||||
|
||||
{/* ── Right panel ── */}
|
||||
<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>
|
||||
</div>{/* /payoff-print-area */}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import BillModal from '@/components/BillModal';
|
|||
import { makeBillDraft } from '@/lib/billDrafts';
|
||||
import PlanStatusBanner from '@/components/snowball/PlanStatusBanner';
|
||||
import PlanHistoryPanel from '@/components/snowball/PlanHistoryPanel';
|
||||
import * as AlertDialog from '@radix-ui/react-alert-dialog';
|
||||
|
||||
// ── formatters ────────────────────────────────────────────────────────────────
|
||||
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 ──────────────────────────────────────────────────────────────────────
|
||||
export default function SnowballPage() {
|
||||
const [bills, setBills] = useState([]);
|
||||
|
|
@ -445,12 +349,12 @@ export default function SnowballPage() {
|
|||
|
||||
const [editingBalance, setEditingBalance] = useState({ billId: null, value: '' });
|
||||
|
||||
const [activePlan, setActivePlan] = useState(null);
|
||||
const [allPlans, setAllPlans] = useState([]);
|
||||
const [startingPlan, setStartingPlan] = useState(false);
|
||||
|
||||
const { draggingIdx, draggingFromIdx, onPointerDown, onPointerMove, onPointerUp } =
|
||||
useSortable(bills, setBills, setDirty);
|
||||
const [activePlan, setActivePlan] = useState(null);
|
||||
const [allPlans, setAllPlans] = useState([]);
|
||||
const [startingPlan, setStartingPlan] = useState(false);
|
||||
const [readinessWarnOpen, setReadinessWarnOpen] = useState(false);
|
||||
const [draggingId, setDraggingId] = useState(null);
|
||||
const [dropTargetId, setDropTargetId] = useState(null);
|
||||
|
||||
// ── loading ───────────────────────────────────────────────────────────────
|
||||
const loadProjection = useCallback(async () => {
|
||||
|
|
@ -501,6 +405,40 @@ export default function SnowballPage() {
|
|||
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 ────────────────────────────────────────────────────────────
|
||||
const handleSaveOrder = async () => {
|
||||
setSaving(true);
|
||||
|
|
@ -674,6 +612,11 @@ export default function SnowballPage() {
|
|||
} catch (err) { toast.error(err.message || 'Failed to abandon plan'); }
|
||||
};
|
||||
|
||||
const handleStartPlanClick = () => {
|
||||
if (readinessAllReady) { handleStartPlan(); }
|
||||
else { setReadinessWarnOpen(true); }
|
||||
};
|
||||
|
||||
// ── stats ─────────────────────────────────────────────────────────────────
|
||||
const totalBalance = bills.reduce((s, b) => s + (b.current_balance || 0), 0);
|
||||
const totalMinPayment = bills.reduce((s, b) => s + (b.minimum_payment || 0), 0);
|
||||
|
|
@ -812,16 +755,27 @@ export default function SnowballPage() {
|
|||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px] uppercase tracking-wider text-muted-foreground">
|
||||
Extra monthly budget ($)
|
||||
</Label>
|
||||
<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">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<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
|
||||
id="extra-payment"
|
||||
type="number" min="0" step="1" placeholder="0.00"
|
||||
value={extraPayment}
|
||||
onChange={e => setExtraPayment(e.target.value)}
|
||||
onBlur={handleSaveExtraPayment}
|
||||
className={cn(inp, 'w-32')}
|
||||
className={cn(inp, 'mt-2 w-full border-primary/25 bg-background/75')}
|
||||
disabled={savingSettings}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -856,7 +810,7 @@ export default function SnowballPage() {
|
|||
/>
|
||||
{!activePlan && readinessReadyCount >= 3 && (
|
||||
<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" />
|
||||
{startingPlan ? 'Starting…' : 'Start Snowball Plan'}
|
||||
</Button>
|
||||
|
|
@ -898,19 +852,12 @@ export default function SnowballPage() {
|
|||
{bills.length > 0 && (
|
||||
<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 */}
|
||||
<div
|
||||
className="space-y-2"
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
onPointerCancel={onPointerUp}
|
||||
>
|
||||
{/* Cards list */}
|
||||
<div className="space-y-2">
|
||||
{bills.map((bill, index) => {
|
||||
const isAttack = index === 0;
|
||||
const isEditingBal = editingBalance.billId === bill.id;
|
||||
const isDragging = draggingFromIdx !== null;
|
||||
const isDragSource = draggingFromIdx === index;
|
||||
const isLandTarget = isDragging && !isDragSource && draggingIdx === index;
|
||||
const dragProps = dragPropsFor(bill, index);
|
||||
|
||||
// Pull this debt's payoff info from the live projection (attack card only)
|
||||
const attackProjection = isAttack
|
||||
|
|
@ -920,17 +867,17 @@ export default function SnowballPage() {
|
|||
return (
|
||||
<div
|
||||
key={bill.id}
|
||||
data-card
|
||||
data-card-index={index}
|
||||
draggable={dragProps?.draggable}
|
||||
onDragStart={dragProps?.onDragStart}
|
||||
onDragEnter={dragProps?.onDragEnter}
|
||||
onDragOver={dragProps?.onDragOver}
|
||||
onDragEnd={dragProps?.onDragEnd}
|
||||
onDrop={dragProps?.onDrop}
|
||||
className={cn(
|
||||
'surface-elevated rounded-xl border select-none touch-none',
|
||||
// Only animate when not in a drag gesture — instant feedback on grab
|
||||
!isDragging && 'transition-all duration-150',
|
||||
'surface-elevated rounded-xl border select-none transition-colors duration-150',
|
||||
isAttack ? 'border-emerald-500/30 bg-emerald-950/5' : 'border-border/40',
|
||||
// Card being actively dragged — lifted look
|
||||
isDragSource && 'scale-105 shadow-2xl ring-2 ring-primary/60 opacity-75 relative z-10',
|
||||
// Where the card will land — slot highlight
|
||||
isLandTarget && 'ring-2 ring-primary/40 scale-[0.97] opacity-50',
|
||||
dragProps?.isDragging && 'opacity-45',
|
||||
dragProps?.isDropTarget && 'ring-2 ring-inset ring-primary/35',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-stretch">
|
||||
|
|
@ -938,13 +885,11 @@ export default function SnowballPage() {
|
|||
{/* Grip */}
|
||||
<div className="flex items-center gap-0.5 px-3 transition-colors touch-none shrink-0">
|
||||
<div
|
||||
data-grip
|
||||
onPointerDown={e => { if (!ramseyMode) onPointerDown(e, index); }}
|
||||
className={cn(
|
||||
'transition-colors',
|
||||
ramseyMode
|
||||
? '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'}
|
||||
>
|
||||
|
|
@ -1136,6 +1081,50 @@ export default function SnowballPage() {
|
|||
{/* Plan history */}
|
||||
<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'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 */}
|
||||
{editBill && (
|
||||
<BillModal
|
||||
|
|
|
|||
|
|
@ -280,7 +280,16 @@ export default function StatusPage() {
|
|||
const dbOk = db.connected ?? (db.status === 'connected') ?? true;
|
||||
const workerOk = !worker.enabled ? null : worker.last_error ? false : true;
|
||||
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 bankSyncEnabled = bankSync.enabled ?? false;
|
||||
|
|
@ -428,10 +437,19 @@ export default function StatusPage() {
|
|||
</StatusCard>
|
||||
|
||||
<StatusCard title="Backups" icon={HardDrive}
|
||||
status={backupsEnabled === null ? 'Pending' : backupsEnabled ? 'Enabled' : 'Disabled'}
|
||||
tone={backupsEnabled === null ? 'muted' : backupsEnabled ? 'good' : 'warn'}
|
||||
status={backupsStatus}
|
||||
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="Next Backup" value={formatDateTime(backups.next_backup_at)} />
|
||||
<StatRow label="Count" value={backups.count ?? backups.backup_count} />
|
||||
<StatRow label="Retention" value={backups.keep_count ?? backups.retention_count} />
|
||||
<StatRow label="Last Error" value={backups.last_error} last />
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import {
|
|||
} from 'lucide-react';
|
||||
import { api } from '@/api';
|
||||
import { cn, fmt, fmtDate } from '@/lib/utils';
|
||||
import { scheduleLabel } from '@/lib/billingSchedule';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
|
|
@ -49,8 +50,7 @@ const TYPE_LABELS = {
|
|||
};
|
||||
|
||||
function cycleLabel(item) {
|
||||
const cycle = item.cycle_type || item.billing_cycle || 'monthly';
|
||||
return cycle === 'annual' || cycle === 'annually' ? 'yearly' : cycle;
|
||||
return scheduleLabel(item);
|
||||
}
|
||||
|
||||
function StatCard({ icon: Icon, label, value, hint }) {
|
||||
|
|
|
|||
|
|
@ -1,11 +1,14 @@
|
|||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
ArrowDown,
|
||||
ArrowUp,
|
||||
CalendarDays,
|
||||
CheckCircle2,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Edit3,
|
||||
GripVertical,
|
||||
Loader2,
|
||||
Minus,
|
||||
Printer,
|
||||
|
|
@ -17,6 +20,7 @@ import { Button } from '@/components/ui/button';
|
|||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { cn, fmt } from '@/lib/utils';
|
||||
import { moveInArray, reorderPayload } from '@/lib/reorder';
|
||||
|
||||
const MONTHS = [
|
||||
'January',
|
||||
|
|
@ -115,9 +119,53 @@ function SummaryChart({ rows = [] }) {
|
|||
);
|
||||
}
|
||||
|
||||
function ExpenseRow({ expense }) {
|
||||
function ExpenseRow({ expense, moveControls, dragProps }) {
|
||||
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="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">
|
||||
|
|
@ -144,6 +192,9 @@ export default function SummaryPage() {
|
|||
const [startingFifteenth, setStartingFifteenth] = useState('0');
|
||||
const [startingOther, setStartingOther] = useState('0');
|
||||
const [editingStarting, setEditingStarting] = useState(false);
|
||||
const [draggingId, setDraggingId] = useState(null);
|
||||
const [dropTargetId, setDropTargetId] = useState(null);
|
||||
const [movingBillId, setMovingBillId] = useState(null);
|
||||
|
||||
const loadSummary = useCallback(async () => {
|
||||
setLoading(true);
|
||||
|
|
@ -155,6 +206,9 @@ export default function SummaryPage() {
|
|||
setStartingFifteenth(String(result.starting_amounts?.fifteenth_amount ?? 0));
|
||||
setStartingOther(String(result.starting_amounts?.other_amount ?? 0));
|
||||
setEditingStarting(false);
|
||||
setDraggingId(null);
|
||||
setDropTargetId(null);
|
||||
setMovingBillId(null);
|
||||
} catch (err) {
|
||||
setError(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 expenses = data?.expenses || [];
|
||||
const starting = data?.starting_amounts || {};
|
||||
const reorderEnabled = !loading && !error && expenses.length > 1;
|
||||
|
||||
const generatedLabel = useMemo(() => {
|
||||
if (!data?.generated_at) return '';
|
||||
|
|
@ -211,6 +266,72 @@ export default function SummaryPage() {
|
|||
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 (
|
||||
<div className="summary-page mx-auto max-w-3xl space-y-5">
|
||||
<div className="summary-print-meta hidden">
|
||||
|
|
@ -387,8 +508,13 @@ export default function SummaryPage() {
|
|||
</div>
|
||||
) : (
|
||||
<div className="rounded-2xl border border-border/60 bg-background/70 px-3">
|
||||
{expenses.map(expense => (
|
||||
<ExpenseRow key={expense.bill_id} expense={expense} />
|
||||
{expenses.map((expense, index) => (
|
||||
<ExpenseRow
|
||||
key={expense.bill_id}
|
||||
expense={expense}
|
||||
moveControls={moveControlsFor(expense, index)}
|
||||
dragProps={dragPropsFor(expense, index)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { api } from '@/api.js';
|
|||
import { useTracker, useDriftReport } from '@/hooks/useQueries';
|
||||
import BillModal from '@/components/BillModal';
|
||||
import { makeBillDraft } from '@/lib/billDrafts';
|
||||
import { scheduleLabel, scheduleValue } from '@/lib/billingSchedule';
|
||||
import { cn, fmt, fmtDate, todayStr } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
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));
|
||||
}, [rows]);
|
||||
const cycleOptions = useMemo(() => (
|
||||
Array.from(new Set(rows.map(row => row.billing_cycle || 'monthly'))).sort()
|
||||
Array.from(new Set(rows.map(scheduleValue))).sort()
|
||||
), [rows]);
|
||||
const filteredRows = useMemo(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
return rows.filter(row => {
|
||||
const effectiveStatus = rowEffectiveStatus(row);
|
||||
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.debt && !rowIsDebt(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.notes,
|
||||
row.monthly_notes,
|
||||
row.billing_cycle,
|
||||
scheduleValue(row),
|
||||
scheduleLabel(row),
|
||||
row.bucket,
|
||||
row.status,
|
||||
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 value={filters.cycle} onValueChange={value => setFilterValue('cycle', value)}>
|
||||
<SelectTrigger className="h-10 capitalize">
|
||||
<SelectValue placeholder="Billing cycle" />
|
||||
<SelectValue placeholder="Billing schedule" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={FILTER_ALL}>All cycles</SelectItem>
|
||||
{cycleOptions.map(cycle => (
|
||||
<SelectItem key={cycle} value={cycle}>{cycle.replace(/_/g, ' ')}</SelectItem>
|
||||
<SelectItem key={cycle} value={cycle}>{scheduleLabel(cycle)}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
|
|
|||
|
|
@ -549,6 +549,7 @@ function ensureTransactionFoundationSchema(database = db) {
|
|||
currency TEXT,
|
||||
balance INTEGER,
|
||||
available_balance INTEGER,
|
||||
monitored INTEGER NOT NULL DEFAULT 1,
|
||||
raw_data TEXT,
|
||||
created_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');
|
||||
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',
|
||||
]
|
||||
},
|
||||
'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': {
|
||||
description: 'bills: snowball_exempt column',
|
||||
sql: ['ALTER TABLE bills DROP COLUMN snowball_exempt']
|
||||
|
|
|
|||
|
|
@ -22,6 +22,8 @@ CREATE TABLE IF NOT EXISTS bills (
|
|||
expected_amount REAL NOT NULL DEFAULT 0,
|
||||
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')),
|
||||
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,
|
||||
autodraft_status TEXT NOT NULL DEFAULT 'none' CHECK(autodraft_status IN ('none', 'pending', 'assumed_paid', 'confirmed')),
|
||||
auto_mark_paid INTEGER NOT NULL DEFAULT 0,
|
||||
|
|
@ -118,6 +120,7 @@ CREATE TABLE IF NOT EXISTS financial_accounts (
|
|||
currency TEXT,
|
||||
balance INTEGER,
|
||||
available_balance INTEGER,
|
||||
monitored INTEGER NOT NULL DEFAULT 1,
|
||||
raw_data TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "bill-tracker",
|
||||
"version": "0.34.1.2",
|
||||
"version": "0.34.1.3",
|
||||
"description": "Monthly bill tracking system",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
|
|
|
|||
|
|
@ -129,7 +129,7 @@ router.get('/:sourceId/accounts', (req, res) => {
|
|||
const result = accounts.map(acc => ({
|
||||
...acc,
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -201,7 +201,7 @@ router.get('/', async (req, res) => {
|
|||
const overdueCount = db.prepare(`
|
||||
SELECT COUNT(*) AS n FROM bills b
|
||||
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 NOT EXISTS (
|
||||
SELECT 1 FROM payments p
|
||||
|
|
|
|||
|
|
@ -147,12 +147,16 @@ function buildSummary(db, userId, year, month) {
|
|||
b.due_day,
|
||||
c.name AS category_name,
|
||||
m.actual_amount,
|
||||
m.is_skipped
|
||||
m.is_skipped,
|
||||
b.sort_order
|
||||
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 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
|
||||
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);
|
||||
|
||||
const billIds = billRows.map(row => row.bill_id);
|
||||
|
|
|
|||
|
|
@ -101,7 +101,7 @@ function hasOwn(obj, field) {
|
|||
|
||||
function getOwnedAccount(db, userId, accountId) {
|
||||
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) {
|
||||
|
|
@ -277,7 +277,10 @@ router.get('/', (req, res) => {
|
|||
const page = parseLimitOffset(req.query);
|
||||
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 matchStatusFilter = req.query.match_status ? String(req.query.match_status).trim() : '';
|
||||
|
|
|
|||
10
server.js
10
server.js
|
|
@ -239,6 +239,16 @@ async function main() {
|
|||
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)
|
||||
try {
|
||||
require('./workers/dailyWorker').start();
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ const crypto = require('crypto');
|
|||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
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(
|
||||
process.env.BACKUP_PATH || path.join(path.dirname(getDbPath()), '..', 'backups')
|
||||
|
|
@ -166,10 +166,7 @@ async function createBackup(prefix = 'bill-tracker-backup') {
|
|||
validateSqliteDatabase(tempPath);
|
||||
fs.renameSync(tempPath, finalPath);
|
||||
fs.chmodSync(finalPath, 0o600);
|
||||
const meta = metadataForFile(finalPath);
|
||||
const retentionCount = parseInt(getSetting('backup_schedule_retention_count') || '2', 10);
|
||||
applyRetention(retentionCount);
|
||||
return meta;
|
||||
return metadataForFile(finalPath);
|
||||
} catch (err) {
|
||||
try { if (fs.existsSync(tempPath)) fs.unlinkSync(tempPath); } catch {}
|
||||
cleanupSqliteSidecars(tempPath);
|
||||
|
|
@ -242,12 +239,17 @@ function deleteBackup(id) {
|
|||
return { deleted: true, id: backup.metadata.id, deleted_at: new Date().toISOString() };
|
||||
}
|
||||
|
||||
function applyRetention(retentionCount) {
|
||||
function applyRetention(retentionCount, options = {}) {
|
||||
const keep = parseInt(retentionCount, 10);
|
||||
if (!Number.isInteger(keep) || keep < 1) return { deleted: [] };
|
||||
|
||||
// listBackups() is already sorted newest-first; delete everything beyond `keep`
|
||||
const toDelete = listBackups().slice(keep);
|
||||
const type = options.type || null;
|
||||
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 = [];
|
||||
|
||||
for (const backup of toDelete) {
|
||||
|
|
@ -261,8 +263,9 @@ function applyRetention(retentionCount) {
|
|||
return { deleted };
|
||||
}
|
||||
|
||||
// Keep old name as an alias so the scheduler import still works.
|
||||
const applyScheduledRetention = applyRetention;
|
||||
function applyScheduledRetention(retentionCount) {
|
||||
return applyRetention(retentionCount, { type: 'scheduled' });
|
||||
}
|
||||
|
||||
async function restoreBackup(id) {
|
||||
const source = getBackupFile(id);
|
||||
|
|
|
|||
|
|
@ -2,10 +2,10 @@
|
|||
|
||||
const { getSetting, setSetting } = require('../db/database');
|
||||
|
||||
const SYNC_DAYS_MAX = 90; // SimpleFIN Bridge advertised limit
|
||||
const SYNC_DAYS_EFFECTIVE = 89; // 1-day buffer to avoid bridge-side capping due to request latency
|
||||
const SYNC_DAYS_DEFAULT = 90;
|
||||
const SYNC_INTERVAL_DEFAULT = 4; // hours
|
||||
const SYNC_DAYS_MAX = 45; // SimpleFIN Bridge hard limit — requests beyond this return an error
|
||||
const SYNC_DAYS_EFFECTIVE = 44; // 1-day buffer so request latency never tips over the hard limit
|
||||
const SYNC_DAYS_DEFAULT = 30; // routine sync window (initial seed always uses SYNC_DAYS_EFFECTIVE)
|
||||
const SYNC_INTERVAL_DEFAULT = 4; // hours
|
||||
|
||||
function getBankSyncConfig() {
|
||||
const dbValue = getSetting('bank_sync_enabled');
|
||||
|
|
|
|||
|
|
@ -12,8 +12,8 @@ const { getBankSyncConfig } = require('./bankSyncConfigService');
|
|||
const { decorateDataSource } = require('./transactionService');
|
||||
const { applyMerchantRules } = require('./billMerchantRuleService');
|
||||
|
||||
const SEED_SYNC_DAYS = 89; // Initial connect / explicit backfill (SimpleFIN Bridge ~90-day cap)
|
||||
const ROUTINE_SYNC_DAYS = 30; // Auto-sync and manual "Sync Now"
|
||||
const SEED_SYNC_DAYS = 44; // Initial connect / explicit backfill (SimpleFIN Bridge 45-day cap, 1-day buffer)
|
||||
const ROUTINE_SYNC_DAYS = 30; // Fallback if admin config is missing
|
||||
|
||||
function sinceEpochDays(days) {
|
||||
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.
|
||||
function upsertAccount(db, accountRow) {
|
||||
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 = ?
|
||||
`).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,
|
||||
existing.id,
|
||||
);
|
||||
return existing.id;
|
||||
return existing;
|
||||
}
|
||||
|
||||
const result = db.prepare(`
|
||||
|
|
@ -54,7 +54,7 @@ function upsertAccount(db, accountRow) {
|
|||
accountRow.name, accountRow.org_name, accountRow.account_type,
|
||||
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).
|
||||
|
|
@ -81,9 +81,13 @@ function insertTransactionIfNew(db, txRow) {
|
|||
}
|
||||
|
||||
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 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 raw = await fetchAccountsAndTransactions(accessUrl, since);
|
||||
|
|
@ -99,12 +103,14 @@ async function runSync(db, userId, dataSource, { days } = {}) {
|
|||
|
||||
for (const rawAccount of accounts) {
|
||||
const accountRow = normalizeAccount(rawAccount, dataSource.id, userId);
|
||||
const localAccId = upsertAccount(db, accountRow);
|
||||
const localAccount = upsertAccount(db, accountRow);
|
||||
accountsUpserted += 1;
|
||||
|
||||
if (localAccount.monitored === 0) continue;
|
||||
|
||||
for (const rawTx of (rawAccount.transactions || [])) {
|
||||
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);
|
||||
if (outcome === 'inserted') transactionsNew += 1;
|
||||
|
|
|
|||
|
|
@ -36,12 +36,14 @@ function applyMerchantRules(db, userId) {
|
|||
if (rules.length === 0) return { matched: 0 };
|
||||
|
||||
const txRows = db.prepare(`
|
||||
SELECT id, amount, payee, description, memo, posted_date, transacted_at
|
||||
FROM transactions
|
||||
WHERE user_id = ?
|
||||
AND match_status = 'unmatched'
|
||||
AND ignored = 0
|
||||
AND amount < 0
|
||||
SELECT t.id, t.amount, t.payee, t.description, t.memo, t.posted_date, t.transacted_at
|
||||
FROM transactions t
|
||||
LEFT JOIN financial_accounts fa ON fa.id = t.account_id AND fa.user_id = t.user_id
|
||||
WHERE t.user_id = ?
|
||||
AND t.match_status = 'unmatched'
|
||||
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);
|
||||
|
||||
if (txRows.length === 0) return { matched: 0 };
|
||||
|
|
@ -112,12 +114,14 @@ function syncBillPaymentsFromSimplefin(db, userId, billId) {
|
|||
}
|
||||
|
||||
const txRows = db.prepare(`
|
||||
SELECT id, amount, payee, description, memo, posted_date, transacted_at
|
||||
FROM transactions
|
||||
WHERE user_id = ?
|
||||
AND match_status = 'unmatched'
|
||||
AND ignored = 0
|
||||
AND amount < 0
|
||||
SELECT t.id, t.amount, t.payee, t.description, t.memo, t.posted_date, t.transacted_at
|
||||
FROM transactions t
|
||||
LEFT JOIN financial_accounts fa ON fa.id = t.account_id AND fa.user_id = t.user_id
|
||||
WHERE t.user_id = ?
|
||||
AND t.match_status = 'unmatched'
|
||||
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);
|
||||
|
||||
if (txRows.length === 0) return { added: 0 };
|
||||
|
|
|
|||
|
|
@ -222,6 +222,21 @@ function getValidCycleTypes() {
|
|||
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.
|
||||
* 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;
|
||||
}
|
||||
|
||||
// billing_cycle
|
||||
normalized.billing_cycle = data.billing_cycle !== undefined ? (data.billing_cycle || 'monthly') : (existingBill?.billing_cycle || 'monthly');
|
||||
|
||||
// autopay_enabled
|
||||
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;
|
||||
|
||||
// cycle_type and cycle_day
|
||||
let nextCycleType = (data.cycle_type !== undefined ? data.cycle_type : existingBill?.cycle_type) || 'monthly';
|
||||
// cycle_type is canonical. billing_cycle is derived for legacy display/import/export compatibility.
|
||||
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);
|
||||
|
||||
if (data.cycle_type !== undefined) {
|
||||
if (!validCycleTypes.includes(data.cycle_type)) {
|
||||
if (submittedCycleType !== undefined) {
|
||||
if (!validCycleTypes.includes(submittedCycleType)) {
|
||||
errors.push({ field: 'cycle_type', message: `cycle_type must be one of: ${validCycleTypes.join(', ')}` });
|
||||
} else {
|
||||
nextCycleType = data.cycle_type;
|
||||
nextCycleType = submittedCycleType;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -332,6 +349,7 @@ function validateBillData(data, existingBill = null) {
|
|||
}
|
||||
normalized.cycle_type = nextCycleType;
|
||||
normalized.cycle_day = nextCycleDay;
|
||||
normalized.billing_cycle = billingCycleForCycleType(nextCycleType);
|
||||
|
||||
// Calculate bucket based on due_day
|
||||
normalized.bucket = normalized.due_day <= 14 ? '1st' : '15th';
|
||||
|
|
@ -466,7 +484,9 @@ module.exports = {
|
|||
VALID_VISIBILITY,
|
||||
TEMPLATE_FIELDS,
|
||||
auditBillsForUser,
|
||||
billingCycleForCycleType,
|
||||
categoryBelongsToUser,
|
||||
cycleTypeFromBillingCycle,
|
||||
getValidCycleTypes,
|
||||
getDefaultCycleDay,
|
||||
insertBill,
|
||||
|
|
|
|||
|
|
@ -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 ins = db.prepare(`
|
||||
INSERT INTO bills (user_id, name, category_id, due_day, bucket, expected_amount, billing_cycle, autopay_enabled, active)
|
||||
VALUES (?, ?, ?, ?, ?, ?, 'monthly', ?, 1)
|
||||
`).run(userId, name, categoryId, dueDay, dueDay <= 14 ? '1st' : '15th', expectedAmount, autopay);
|
||||
INSERT INTO bills (user_id, name, category_id, due_day, bucket, expected_amount, billing_cycle, cycle_type, cycle_day, autopay_enabled, active)
|
||||
VALUES (?, ?, ?, ?, ?, ?, 'monthly', 'monthly', ?, ?, 1)
|
||||
`).run(userId, name, categoryId, dueDay, dueDay <= 14 ? '1st' : '15th', expectedAmount, String(dueDay), autopay);
|
||||
|
||||
const newBillId = ins.lastInsertRowid;
|
||||
summary.created++;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
'use strict';
|
||||
|
||||
const { insertBill, validateBillData } = require('./billsService');
|
||||
const { billingCycleForCycleType, insertBill, validateBillData } = require('./billsService');
|
||||
|
||||
const SUBSCRIPTION_TYPES = [
|
||||
'streaming', 'software', 'cloud', 'music', 'news',
|
||||
|
|
@ -265,13 +265,6 @@ function dollarsFromTransactionAmount(amount) {
|
|||
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 ─────────────────────────────────────────────────────────────
|
||||
|
||||
function getDeclinedKeys(db, userId) {
|
||||
|
|
@ -307,10 +300,12 @@ function getSubscriptionRecommendations(db, userId) {
|
|||
ds.name AS data_source_name
|
||||
FROM transactions t
|
||||
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 = ?
|
||||
AND t.ignored = 0
|
||||
AND t.match_status = 'unmatched'
|
||||
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')
|
||||
ORDER BY tx_date ASC
|
||||
`).all(userId);
|
||||
|
|
@ -469,6 +464,7 @@ function searchSubscriptionTransactions(db, userId, query = {}) {
|
|||
WHERE t.user_id = ?
|
||||
AND t.ignored = 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 ?)
|
||||
ORDER BY
|
||||
CASE WHEN t.match_status = 'unmatched' THEN 0 ELSE 1 END,
|
||||
|
|
|
|||
|
|
@ -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 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 = ?
|
||||
AND (t.account_id IS NULL OR fa.id IS NULL OR fa.monitored = 1)
|
||||
`).get(id, userId);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ const os = require('os');
|
|||
const path = require('path');
|
||||
const Database = require('better-sqlite3');
|
||||
const { getDb } = require('../db/database');
|
||||
const { billingCycleForCycleType, cycleTypeFromBillingCycle } = require('./billsService');
|
||||
|
||||
const MAX_SQLITE_BYTES = 50 * 1024 * 1024;
|
||||
const SESSION_TTL_HOURS = 24;
|
||||
|
|
@ -135,6 +136,12 @@ function sanitizeBill(row) {
|
|||
const dueDay = toInt(row.due_day);
|
||||
if (!name || dueDay < 1 || dueDay > 31) return 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 {
|
||||
old_id: toInt(row.id),
|
||||
name,
|
||||
|
|
@ -144,7 +151,9 @@ function sanitizeBill(row) {
|
|||
bucket: dueDay <= 14 ? '1st' : '15th',
|
||||
expected_amount: Math.max(0, toNumber(row.expected_amount, 0) ?? 0),
|
||||
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,
|
||||
autodraft_status: VALID_AUTODRAFT.has(row.autodraft_status) ? row.autodraft_status : 'none',
|
||||
website: cleanText(row.website, 500),
|
||||
|
|
@ -453,9 +462,9 @@ function ensureBill(db, userId, bill, categoryId, summary, details) {
|
|||
const result = db.prepare(`
|
||||
INSERT INTO bills
|
||||
(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)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
userId,
|
||||
bill.name,
|
||||
|
|
@ -465,7 +474,9 @@ function ensureBill(db, userId, bill, categoryId, summary, details) {
|
|||
bill.bucket,
|
||||
bill.expected_amount,
|
||||
bill.interest_rate,
|
||||
bill.billing_cycle,
|
||||
billingCycleForCycleType(bill.cycle_type),
|
||||
bill.cycle_type,
|
||||
bill.cycle_day || null,
|
||||
bill.autopay_enabled,
|
||||
bill.autodraft_status,
|
||||
bill.website,
|
||||
|
|
|
|||
|
|
@ -22,12 +22,14 @@ const {
|
|||
deleteBackup,
|
||||
listBackups,
|
||||
applyRetention,
|
||||
applyScheduledRetention,
|
||||
importBackupBuffer,
|
||||
checksumFile,
|
||||
} = require('../services/backupService');
|
||||
const {
|
||||
validateScheduleSettings,
|
||||
computeNextRun,
|
||||
runScheduledBackupNow,
|
||||
} = require('../services/backupScheduler');
|
||||
const {
|
||||
pruneOrphanedBackupPartials,
|
||||
|
|
@ -74,9 +76,6 @@ test('deleteBackup removes the file', 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 {} }
|
||||
|
||||
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');
|
||||
});
|
||||
|
||||
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 () => {
|
||||
// Create a real backup, then re-import its bytes
|
||||
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);
|
||||
});
|
||||
|
||||
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
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
@ -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');
|
||||
});
|
||||
|
|
@ -23,10 +23,11 @@ function createUser(db, suffix) {
|
|||
function createTransaction(db, userId, overrides = {}) {
|
||||
return db.prepare(`
|
||||
INSERT INTO transactions
|
||||
(user_id, source_type, posted_date, amount, currency, description, payee, match_status, ignored)
|
||||
VALUES (?, 'manual', ?, ?, 'USD', ?, ?, 'unmatched', 0)
|
||||
(user_id, source_type, account_id, posted_date, amount, currency, description, payee, match_status, ignored)
|
||||
VALUES (?, 'manual', ?, ?, ?, 'USD', ?, ?, 'unmatched', 0)
|
||||
`).run(
|
||||
userId,
|
||||
overrides.account_id || null,
|
||||
overrides.posted_date || new Date().toISOString().slice(0, 10),
|
||||
overrides.amount ?? -1599,
|
||||
overrides.description || 'NETFLIX.COM',
|
||||
|
|
@ -34,6 +35,19 @@ function createTransaction(db, userId, overrides = {}) {
|
|||
).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(() => {
|
||||
closeDb();
|
||||
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.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);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue