diff --git a/HISTORY.md b/HISTORY.md index 0757777..97d5000 100644 --- a/HISTORY.md +++ b/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. diff --git a/client/components/BillModal.jsx b/client/components/BillModal.jsx index 3ec9bfa..02201c5 100644 --- a/client/components/BillModal.jsx +++ b/client/components/BillModal.jsx @@ -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 )} - {/* Billing Cycle */} + {/* Billing Schedule */}
- - -
- - {/* Cycle Type */} -
- +
diff --git a/client/components/BillsTableInner.jsx b/client/components/BillsTableInner.jsx index 1f0c671..13f7cad 100644 --- a/client/components/BillsTableInner.jsx +++ b/client/components/BillsTableInner.jsx @@ -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 */}
- {prefs.showCycle && {bill.billing_cycle || 'monthly'}} + {prefs.showCycle && {scheduleLabel(bill)}} {prefs.showCycle && prefs.showDueDay && Β·} diff --git a/client/components/CommandPalette.jsx b/client/components/CommandPalette.jsx index 29a63c9..490a4a0 100644 --- a/client/components/CommandPalette.jsx +++ b/client/components/CommandPalette.jsx @@ -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( diff --git a/client/components/MobileBillRow.jsx b/client/components/MobileBillRow.jsx index 96e8dc8..623f2cb 100644 --- a/client/components/MobileBillRow.jsx +++ b/client/components/MobileBillRow.jsx @@ -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

Cycle

-

{bill.billing_cycle || 'monthly'}

+

{scheduleLabel(bill)}

diff --git a/client/components/admin/BackupManagementCard.jsx b/client/components/admin/BackupManagementCard.jsx index fa31a6d..8747e38 100644 --- a/client/components/admin/BackupManagementCard.jsx +++ b/client/components/admin/BackupManagementCard.jsx @@ -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.'); diff --git a/client/components/admin/BankSyncAdminCard.jsx b/client/components/admin/BankSyncAdminCard.jsx index e0eb6e3..989c9f6 100644 --- a/client/components/admin/BankSyncAdminCard.jsx +++ b/client/components/admin/BankSyncAdminCard.jsx @@ -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() { - {/* Transaction history lookback */} -
+ {/* Sync window β€” two-mode explainer */} +
-

Transaction history

+

Sync lookback windows

- 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.

-
- setSyncDays(Math.min(90, Math.max(1, parseInt(e.target.value, 10) || 90)))} - className="w-20 text-sm text-right" - /> - days + + {/* Initial / backfill β€” read-only */} +
+
+

+ Initial connect & backfill +

+ 44 days +
+

+ 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 45-day hard limit and will return an error for any request beyond it. +

+
+ + {/* Routine sync β€” editable */} +
+
+

Routine sync lookback

+

+ How far back each auto-sync and manual "Sync Now" looks after the initial connect. + Recommended: 7–30 days. Setting this near 45 increases request size + and duplicate-skip work with no benefit once history is established. +

+
+
+ setSyncDays(Math.min(45, Math.max(1, parseInt(e.target.value, 10) || 30)))} + className="w-20 text-sm text-right" + /> + days +
+
+ + {/* Amber warning at the SimpleFIN limit */} + {parseInt(syncDays, 10) >= 45 && ( +
+ +

+ 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. +

+
+ )} + + {/* Always-visible hard-limit note */} +
+ + + SimpleFIN Bridge enforces a 45-day maximum on all requests. + Any value above 45 will cause sync errors for all users. +
diff --git a/client/index.css b/client/index.css index 2e04c39..5ad8eb1 100644 --- a/client/index.css +++ b/client/index.css @@ -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; } diff --git a/client/lib/billDrafts.js b/client/lib/billDrafts.js index eacd01d..b34e1d3 100644 --- a/client/lib/billDrafts.js +++ b/client/lib/billDrafts.js @@ -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'), diff --git a/client/lib/billingSchedule.js b/client/lib/billingSchedule.js new file mode 100644 index 0000000..ddfd052 --- /dev/null +++ b/client/lib/billingSchedule.js @@ -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'; +} diff --git a/client/pages/BillsPage.jsx b/client/pages/BillsPage.jsx index ca33185..2ae0de3 100644 --- a/client/pages/BillsPage.jsx +++ b/client/pages/BillsPage.jsx @@ -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() { diff --git a/client/pages/PayoffPage.jsx b/client/pages/PayoffPage.jsx index 8f4025d..d928223 100644 --- a/client/pages/PayoffPage.jsx +++ b/client/pages/PayoffPage.jsx @@ -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 (
@@ -89,16 +109,15 @@ function InputRow({ label, hint, children }) { ); } -// ─── Empty states ───────────────────────────────────────────────────────────── - function EmptyDebts() { return (
-

No debts with a balance found

+

No bills with a balance found

Add a current balance to your bills on the{' '} - Snowball page. + Snowball page, + or use the Custom option in the dropdown above.

); @@ -109,7 +128,9 @@ function NoSelection() {

Select a loan or debt to begin

-

Choose from the dropdown above to run your simulation.

+

+ Choose from the dropdown above, or select Custom to simulate any loan. +

); } @@ -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() {
- {[1, 2, 3, 4].map(i => ( -
- ))} + {[1, 2, 3, 4].map(i =>
)}
@@ -257,236 +303,360 @@ export default function PayoffPage() { ); } + const showResults = (isCustom && activeBalance > 0 && simTrack.length > 0) || + (!isCustom && bill && simTrack.length > 0); + return ( -
+ <> + - {/* Page header */} -
-
-

Payoff Simulator

-

- Explore how extra payments reduce interest and shorten your payoff timeline. -

+
+ + {/* ── Print-only summary header (hidden on screen) ── */} +
+

+ Payoff Simulator β€” {activeName || 'β€”'} +

+ {activeBalance > 0 && ( +

+ Balance: {fmt(activeBalance)} + {simRateN > 0 && ` Β· Rate: ${simRateN}%`} + {simPaymentN > 0 && ` Β· Payment: ${fmt(simPaymentN)}/mo`} + {oneTimeExtraN > 0 && ` Β· One-time extra: ${fmt(oneTimeExtraN)}`} +

+ )}
- {isDirty && ( - - )} -
- {/* Bill selector */} -
- {bills.length === 0 ? ( - - ) : ( - + - {bills.map(b => ( - - {b.name} - {b.current_balance ? ( - - {fmt(b.current_balance)} - - ) : null} + {bills.length > 0 && ( + + Your Bills + {bills.map(b => ( + + {b.name} + + {fmt(b.current_balance)} + + + ))} + + )} + {bills.length > 0 && } + + Manual Entry + + Custom β€” not in Bill Tracker - ))} + - )} -
- {/* Main content: left panel + right panel */} - {!bill ? ( - bills.length > 0 ? : null - ) : ( -
+ {bills.length === 0 && !isCustom && ( +

+ No bills with a current balance found.{' '} + +

+ )} +
- {/* ── Left panel ── */} -
+ {/* ── Empty / no-selection states ── */} + {!isCustom && !bill && bills.length === 0 && } + {!isCustom && !bill && bills.length > 0 && } + + {/* ── Main content ── */} + {(isCustom || bill) && ( +
+ + {/* ── Left panel ── */} +
+ + {/* Custom mode: Name + Balance inputs */} + {isCustom && ( + <> + + setCustomName(e.target.value)} + placeholder="e.g. Car Loan, Mortgage…" + className="no-print" + /> +

{customName || 'Custom Loan'}

+
+ + +
+ $ + setCustomBalance(e.target.value)} + className="font-mono" + placeholder="0.00" + autoFocus + /> +
+

{fmt(activeBalance)}

+ {customNeedsBalance && ( +

+ + Balance is required to run the simulation +

+ )} +
+ + )} + + {/* Bill mode: Required minimum display */} + {!isCustom && ( +
+ + Required Minimum + + + {minPayment > 0 + ? fmt(minPayment) + : Not set} + +
+ )} + + {!isCustom && minPayment <= 0 && ( +

+ + Set a minimum payment on the Snowball page for best results. +

+ )} + + {/* Interest rate */} + +
+ setSimRate(e.target.value)} + className="font-mono no-print" + placeholder="0.00" + /> + % + {simRateN}% +
+
+ + {/* Monthly payment */} + +
+ setSimPayment(e.target.value)} + className="font-mono" + placeholder="0.00" + /> + {paymentBelowMin && ( +

+ + Below minimum payment of {fmt(minPayment)} +

+ )} + {paymentTooLow && !paymentBelowMin && ( +

+ + Payment too low to overcome interest +

+ )} + {!isCustom && simPaymentN > 0 && simPaymentN !== (bill?.expected_amount ?? 0) && !paymentTooLow && ( + + )} +
+

{fmt(simPaymentN)}/mo

+
+ + {/* One-time extra */} + +
+ setOneTimeExtra(e.target.value)} + className="font-mono" + placeholder="0.00" + /> +
+ + +
+
+ {oneTimeExtraN > 0 && ( +

{fmt(oneTimeExtraN)}

+ )} +
+ + {/* Divider */} +
+ + {/* Payoff date summary */} +
+ {simPayoffLabel ? ( +
+ Payoff +
+ + {simPayoffLabel} + + {simDuration && ( +

{simDuration}

+ )} +
+
+ ) : ( +

+ {customNeedsBalance + ? 'Enter a balance to see payoff date' + : 'Enter a payment to see payoff date'} +

+ )} + + {!isCustom && minPayoffLabel && simPayoffLabel && minPayoffLabel !== simPayoffLabel && ( +
+ Minimum only + + {minPayoffLabel} + +
+ )} +
- {/* Required minimum */} -
- - Required Minimum - - - {minPayment > 0 ? fmt(minPayment) : Not set} -
- {minPayment <= 0 && ( -

- - Set a minimum payment on the Snowball page for best results. -

- )} + {/* ── Right panel ── */} +
- {/* Interest rate */} - -
- setSimRate(e.target.value)} - className="font-mono" - placeholder="0.00" + {/* Chart */} + {simTrack.length > 0 ? ( + - % -
-
- - {/* Monthly payment */} - - setSimPayment(e.target.value)} - className="font-mono" - placeholder="0.00" - /> - {paymentBelowMin && ( -

- - Below minimum payment of {fmt(minPayment)} -

- )} - {paymentTooLow && !paymentBelowMin && ( -

- - Payment too low to overcome interest -

- )} - {simPaymentN > 0 && simPaymentN !== (bill?.expected_amount ?? 0) && !paymentTooLow && ( - - )} -
- - {/* One-time extra */} - -
- setOneTimeExtra(e.target.value)} - className="font-mono" - placeholder="0.00" - /> -
- - + ) : ( +
+ {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'}
-
- + )} - {/* Divider */} -
- - {/* Payoff date summary */} -
- {simPayoffLabel ? ( -
- Payoff -
- - {simPayoffLabel} - - {simDuration && ( -

{simDuration}

+ {/* Stats row */} + {showResults && ( + <> +
= 0 ? 'grid-cols-2' : 'grid-cols-1')}> + {!isCustom && ( + 0 ? 'teal' : 'slate'} + /> + )} + 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 && ( + 0 ? 'teal' : 'slate'} + /> )}
-
- ) : ( -

Enter a payment to see payoff date

+ + {/* Breakdown */} +
+
+ Balance today + {fmt(activeBalance)} +
+
+ Total interest + {fmt(simInterest)} +
+
+ Total paid + {fmt(simTotalPaid)} +
+
+ )} - {minPayoffLabel && simPayoffLabel && minPayoffLabel !== simPayoffLabel && ( -
- Minimum only - {minPayoffLabel} -
- )}
-
+ )} - {/* ── Right panel ── */} -
- - {/* Chart */} - {simTrack.length > 0 ? ( - - ) : ( -
- {simPaymentN <= 0 ? 'Enter a monthly payment to see the chart' : 'Payment too low to pay off this debt'} -
- )} - - {/* Stats row */} - {simTrack.length > 0 && ( - <> -
- 0 ? 'teal' : 'slate'} - /> - 0 ? `${timeSavings} mo` : 'β€”'} - sub={timeSavings > 0 ? 'months sooner' : 'same timeline'} - color={timeSavings > 0 ? 'amber' : 'slate'} - /> -
- - {/* Breakdown */} -
-
- Balance today - {fmt(bill.current_balance)} -
-
- Total interest - {fmt(simInterest)} -
-
- Total paid - {fmt(simTotalPaid)} -
-
- - )} - -
-
- )} - -
+
{/* /payoff-print-area */} + ); } diff --git a/client/pages/SnowballPage.jsx b/client/pages/SnowballPage.jsx index 9b4cc1d..e0256cb 100644 --- a/client/pages/SnowballPage.jsx +++ b/client/pages/SnowballPage.jsx @@ -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() {

-
- +
+
+
+ +

Added to the current target debt.

+
+
+

{extraAmt > 0 ? fmt(extraAmt) : '$0'}

+

per month

+
+
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} />
@@ -856,7 +810,7 @@ export default function SnowballPage() { /> {!activePlan && readinessReadyCount >= 3 && (
- @@ -898,19 +852,12 @@ export default function SnowballPage() { {bills.length > 0 && (
- {/* Cards list β€” pointer events on the whole list so moves are tracked even outside a card */} -
+ {/* Cards list */} +
{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 (
@@ -938,13 +885,11 @@ export default function SnowballPage() { {/* Grip */}
{ 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 */} + {/* Readiness warning dialog */} + + + + +
+ + + Checklist not complete + +
+ +
+

The following readiness items are still pending:

+
    + {readinessItems.filter(i => !i.ready).map(item => ( +
  • + + {item.label} +
  • + ))} +
+

Starting now may affect your plan's accuracy. You can still proceed.

+
+
+
+ + + + + + +
+
+
+
+ {/* Edit modal */} {editBill && ( + + diff --git a/client/pages/SubscriptionsPage.jsx b/client/pages/SubscriptionsPage.jsx index 4241e08..fcd8311 100644 --- a/client/pages/SubscriptionsPage.jsx +++ b/client/pages/SubscriptionsPage.jsx @@ -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 }) { diff --git a/client/pages/SummaryPage.jsx b/client/pages/SummaryPage.jsx index 3006ef1..202d9a4 100644 --- a/client/pages/SummaryPage.jsx +++ b/client/pages/SummaryPage.jsx @@ -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 ( -
+
+
+
{expense.name}
@@ -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 (
@@ -387,8 +508,13 @@ export default function SummaryPage() {
) : (
- {expenses.map(expense => ( - + {expenses.map((expense, index) => ( + ))}
)} diff --git a/client/pages/TrackerPage.jsx b/client/pages/TrackerPage.jsx index 573a8be..25bb07b 100644 --- a/client/pages/TrackerPage.jsx +++ b/client/pages/TrackerPage.jsx @@ -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() { diff --git a/db/database.js b/db/database.js index fa22780..01919c9 100644 --- a/db/database.js +++ b/db/database.js @@ -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'] diff --git a/db/schema.sql b/db/schema.sql index 0ab1fcd..a5ab7f5 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -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, diff --git a/package.json b/package.json index 3a90019..1d85198 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/routes/dataSources.js b/routes/dataSources.js index 0546f2b..d638c31 100644 --- a/routes/dataSources.js +++ b/routes/dataSources.js @@ -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); diff --git a/routes/status.js b/routes/status.js index 6bd2fe4..1fda816 100644 --- a/routes/status.js +++ b/routes/status.js @@ -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 diff --git a/routes/summary.js b/routes/summary.js index 80940ef..b43d560 100644 --- a/routes/summary.js +++ b/routes/summary.js @@ -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); diff --git a/routes/transactions.js b/routes/transactions.js index 5775ca4..686017c 100644 --- a/routes/transactions.js +++ b/routes/transactions.js @@ -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() : ''; diff --git a/server.js b/server.js index 99ed709..8d25dbd 100644 --- a/server.js +++ b/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(); diff --git a/services/backupService.js b/services/backupService.js index e89de66..819e5b1 100644 --- a/services/backupService.js +++ b/services/backupService.js @@ -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); diff --git a/services/bankSyncConfigService.js b/services/bankSyncConfigService.js index c26315e..22a51fa 100644 --- a/services/bankSyncConfigService.js +++ b/services/bankSyncConfigService.js @@ -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'); diff --git a/services/bankSyncService.js b/services/bankSyncService.js index 47fc4f6..27c7b43 100644 --- a/services/bankSyncService.js +++ b/services/bankSyncService.js @@ -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; diff --git a/services/billMerchantRuleService.js b/services/billMerchantRuleService.js index 2d430f0..24d6719 100644 --- a/services/billMerchantRuleService.js +++ b/services/billMerchantRuleService.js @@ -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 }; diff --git a/services/billsService.js b/services/billsService.js index 35a7c12..c7cd30d 100644 --- a/services/billsService.js +++ b/services/billsService.js @@ -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, diff --git a/services/spreadsheetImportService.js b/services/spreadsheetImportService.js index 5824138..af822b5 100644 --- a/services/spreadsheetImportService.js +++ b/services/spreadsheetImportService.js @@ -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++; diff --git a/services/subscriptionService.js b/services/subscriptionService.js index 39f0acc..ee72481 100644 --- a/services/subscriptionService.js +++ b/services/subscriptionService.js @@ -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, diff --git a/services/transactionService.js b/services/transactionService.js index fbac885..93b18d2 100644 --- a/services/transactionService.js +++ b/services/transactionService.js @@ -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); } diff --git a/services/userDbImportService.js b/services/userDbImportService.js index d37353b..58b2713 100644 --- a/services/userDbImportService.js +++ b/services/userDbImportService.js @@ -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, diff --git a/tests/backupAndCleanup.test.js b/tests/backupAndCleanup.test.js index f0c409f..b8917ca 100644 --- a/tests/backupAndCleanup.test.js +++ b/tests/backupAndCleanup.test.js @@ -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 // ───────────────────────────────────────────────────────────────────────────── diff --git a/tests/bankSyncService.test.js b/tests/bankSyncService.test.js new file mode 100644 index 0000000..7946729 --- /dev/null +++ b/tests/bankSyncService.test.js @@ -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); +}); diff --git a/tests/billsService.test.js b/tests/billsService.test.js new file mode 100644 index 0000000..16f93d5 --- /dev/null +++ b/tests/billsService.test.js @@ -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'); +}); diff --git a/tests/subscriptionService.test.js b/tests/subscriptionService.test.js index a926dff..4598658 100644 --- a/tests/subscriptionService.test.js +++ b/tests/subscriptionService.test.js @@ -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); +});