From 35d0cbf8beed356e01c1a02ea425c4b494cd2396 Mon Sep 17 00:00:00 2001 From: null Date: Sat, 30 May 2026 17:27:15 -0500 Subject: [PATCH] chore: reset tracked db file --- FUTURE.md | 21 -- HISTORY.md | 43 ++- client/api.js | 19 +- .../components/snowball/PlanHistoryPanel.jsx | 205 +++++++++++++ .../components/snowball/PlanStatusBanner.jsx | 271 ++++++++++++++++++ client/pages/SnowballPage.jsx | 106 ++++++- client/pages/SubscriptionsPage.jsx | 202 ++++++++++++- db/database.js | 25 ++ db/schema.sql | 20 +- package.json | 2 +- routes/snowball.js | 232 +++++++++++++++ routes/subscriptions.js | 11 + services/subscriptionService.js | 143 ++++++++- tests/subscriptionService.test.js | 73 +++++ 14 files changed, 1289 insertions(+), 84 deletions(-) create mode 100644 client/components/snowball/PlanHistoryPanel.jsx create mode 100644 client/components/snowball/PlanStatusBanner.jsx create mode 100644 tests/subscriptionService.test.js diff --git a/FUTURE.md b/FUTURE.md index af087cb..5e2eed6 100644 --- a/FUTURE.md +++ b/FUTURE.md @@ -59,10 +59,6 @@ Show users what's coming: "You'll have $X left before the 15th", "Upcoming bills --- - - - - ### 🟑 Recurring Payment Rules β€” MEDIUM **Priority:** MEDIUM **Added:** 2026-05-16 by Ripley (from _null's prioritized roadmap) @@ -127,11 +123,8 @@ Export only utilities, debts, overdue, date range, tax-relevant categories. Curr --- - ## πŸ”΅ LOW - - ### πŸ”΅ Payment Method Tracking and Summary β€” LOW **Priority:** LOW **Added:** 2026-05-11 by Ripley @@ -178,20 +171,6 @@ Currently no unit tests exist for components or hooks. The only testing is funct --- -### πŸ”΅ Custom Bill Grouping Criteria -**Added:** 2026-05-30 by Codex -**Origin:** Split from "Missing Bill Grouping and Reorganization API" after persistent bill ordering was implemented. - -**Description:** -Bills can now be reordered and remembered on the tracker page, but users still cannot define custom tracker groupings beyond the existing due-date buckets. - -**Implementation Notes:** -- Add user-defined grouping settings for tracker sections -- Decide whether grouping is global or per-user/per-view -- Preserve manual `sort_order` inside each custom group -- Estimated effort: 3-5 hours - ---- ## πŸ’­ NICE TO HAVE diff --git a/HISTORY.md b/HISTORY.md index 67efdf0..b413e13 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -1,18 +1,23 @@ # Bill Tracker β€” Changelog -## v0.34.3 +## v0.34.1 ### πŸš€ Features -- **Payoff Simulator** β€” `/payoff` route with custom SVG chart. Select any debt, inputs auto-populate from bill rate/minimum/amount. Live-updating chart with 3 tracks: slate dashed (min-only), indigo dashed (snowball plan), amber solid (simulation). Stats cards show interest saved, time saved, total paid breakdown. "Apply to budget" pushes sim payment back to bill with undo support. - -### πŸ”§ Changed - -- **Bump** β€” `0.34.2` β†’ `0.34.3` - ---- - -## v0.34.2 +- **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. +- **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. +- **Drift detection service** β€” `driftService.getDriftReport()` computes a rolling median of the last 3 months of payments per bill and compares it against `expected_amount`. Flags when `|delta| β‰₯ $1 AND |drift%| β‰₯ threshold`. +- **Price-change email digest** β€” Daily worker now calls `runDriftNotifications()`, sending a single amber-styled digest email per user listing all bills with changed amounts (old β†’ new, Ξ”%). +- **Drift snooze persistence** β€” `drift_snoozed_until` column on `bills` (migration v0.71). `POST /api/bills/:id/snooze-drift` sets a 30-day snooze server-side. +- **"Notify on price changes" toggle** β€” New notification preference in ProfilePage, backed by `notify_amount_change` column on `users` (migration v0.71). +- **Price change sensitivity setting** β€” "Price change sensitivity" `%` input in SettingsPage Billing Behavior section. Stored as `drift_threshold_pct` in per-user settings (default 5%, range 1–25%). ### 🧹 Roadmap Audit @@ -22,22 +27,10 @@ - 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 -### πŸ”§ Changed + ### πŸ›  Internal -- **Bump** β€” `0.34.1` β†’ `0.34.2` - ---- - -## v0.34.1 - -### πŸš€ Features - -- **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. -- **Drift detection service** β€” `driftService.getDriftReport()` computes a rolling median of the last 3 months of payments per bill and compares it against `expected_amount`. Flags when `|delta| β‰₯ $1 AND |drift%| β‰₯ threshold`. -- **Price-change email digest** β€” Daily worker now calls `runDriftNotifications()`, sending a single amber-styled digest email per user listing all bills with changed amounts (old β†’ new, Ξ”%). -- **Drift snooze persistence** β€” `drift_snoozed_until` column on `bills` (migration v0.71). `POST /api/bills/:id/snooze-drift` sets a 30-day snooze server-side. -- **"Notify on price changes" toggle** β€” New notification preference in ProfilePage, backed by `notify_amount_change` column on `users` (migration v0.71). -- **Price change sensitivity setting** β€” "Price change sensitivity" `%` input in SettingsPage Billing Behavior section. Stored as `drift_threshold_pct` in per-user settings (default 5%, range 1–25%). +- **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. ### πŸ”§ Changed diff --git a/client/api.js b/client/api.js index c3876d6..3e57038 100644 --- a/client/api.js +++ b/client/api.js @@ -191,6 +191,7 @@ export const api = { confirmTransactionMatch: (transactionId, billId) => post('/matches/confirm', { transaction_id: transactionId, bill_id: billId }), matchRecommendationToBill: (transactionIds, billId, merchant) => post('/subscriptions/recommendations/match-bill', { transaction_ids: transactionIds, bill_id: billId, merchant }), subscriptionRecommendations: () => get('/subscriptions/recommendations'), + subscriptionTransactionMatches: (params = {}) => get(`/subscriptions/transaction-matches${queryString(params)}`), updateSubscription: (id, data) => _fetch('PATCH', `/subscriptions/${id}`, data), createSubscriptionFromRecommendation: (data) => post('/subscriptions/recommendations/create', data), declineRecommendation: (declineKey) => post('/subscriptions/recommendations/decline', { decline_key: declineKey }), @@ -206,11 +207,19 @@ export const api = { restorePayment: (id) => post(`/payments/${id}/restore`), // Snowball - snowball: () => get('/snowball'), - snowballSettings: () => get('/snowball/settings'), - saveSnowballSettings: (data) => _fetch('PATCH', '/snowball/settings', data), - saveSnowballOrder: (items) => _fetch('PATCH', '/snowball/order', items), - snowballProjection: () => get('/snowball/projection'), + snowball: () => get('/snowball'), + snowballSettings: () => get('/snowball/settings'), + saveSnowballSettings: (data) => _fetch('PATCH', '/snowball/settings', data), + saveSnowballOrder: (items) => _fetch('PATCH', '/snowball/order', items), + snowballProjection: () => get('/snowball/projection'), + snowballPlans: () => get('/snowball/plans'), + snowballActivePlan: () => get('/snowball/plans/active'), + startSnowballPlan: (data) => post('/snowball/plans', data), + updateSnowballPlan: (id, d) => _fetch('PATCH', `/snowball/plans/${id}`, d), + pauseSnowballPlan: (id) => post(`/snowball/plans/${id}/pause`, {}), + resumeSnowballPlan: (id) => post(`/snowball/plans/${id}/resume`, {}), + completeSnowballPlan: (id) => post(`/snowball/plans/${id}/complete`, {}), + abandonSnowballPlan: (id) => post(`/snowball/plans/${id}/abandon`, {}), // Categories categories: () => get('/categories'), diff --git a/client/components/snowball/PlanHistoryPanel.jsx b/client/components/snowball/PlanHistoryPanel.jsx new file mode 100644 index 0000000..2140cef --- /dev/null +++ b/client/components/snowball/PlanHistoryPanel.jsx @@ -0,0 +1,205 @@ +import React, { useState } from 'react'; +import { ChevronDown, History } from 'lucide-react'; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; +import { cn } from '@/lib/utils'; + +function fmt(v) { + return (Number(v) || 0).toLocaleString(undefined, { + style: 'currency', currency: 'USD', maximumFractionDigits: 0, + }); +} + +function fmtFull(v) { + return (Number(v) || 0).toLocaleString(undefined, { + style: 'currency', currency: 'USD', minimumFractionDigits: 2, maximumFractionDigits: 2, + }); +} + +function dateRange(plan) { + const start = plan.started_at + ? new Date(plan.started_at).toLocaleDateString(undefined, { month: 'short', year: 'numeric' }) + : 'β€”'; + const end = plan.completed_at || plan.paused_at + ? new Date(plan.completed_at || plan.paused_at).toLocaleDateString(undefined, { month: 'short', year: 'numeric' }) + : plan.status === 'abandoned' ? 'abandoned' : 'present'; + return `${start} – ${end}`; +} + +function StatusBadge({ status }) { + const map = { + active: 'bg-emerald-500/12 text-emerald-600 dark:text-emerald-400', + paused: 'bg-amber-500/12 text-amber-600 dark:text-amber-400', + completed: 'bg-indigo-500/12 text-indigo-600 dark:text-indigo-400', + abandoned: 'bg-rose-500/12 text-rose-600 dark:text-rose-400', + }; + const labels = { active: 'Active', paused: 'Paused', completed: 'Completed', abandoned: 'Abandoned' }; + return ( + + {labels[status] ?? status} + + ); +} + +function MethodBadge({ method }) { + const map = { snowball: 'Snowball', avalanche: 'Avalanche', custom: 'Custom' }; + return ( + + {map[method] ?? method} + + ); +} + +// ─── Expanded plan detail ───────────────────────────────────────────────────── + +function PlanDetail({ plan }) { + const snapshot = plan.plan_snapshot ?? {}; + const debts = snapshot.debts ?? []; + + return ( +
+ {/* Summary stats */} +
+ {snapshot.projected_months && ( +
+

Projected payoff

+

{snapshot.projected_payoff_date ?? 'β€”'}

+
+ )} + {snapshot.interest_saved > 0 && ( +
+

Interest saved

+

{fmt(snapshot.interest_saved)}

+
+ )} + {snapshot.minimum_only_months && ( +
+

Minimum-only months

+

{snapshot.minimum_only_months}

+
+ )} + {plan.extra_payment > 0 && ( +
+

Extra/mo

+

{fmtFull(plan.extra_payment)}

+
+ )} +
+ + {/* Per-debt snapshot table */} + {debts.length > 0 && ( +
+ + + + + + + + + + + + {debts.map(d => { + const current = plan.current_debts?.find(c => c.bill_id === d.bill_id); + const curBal = current?.current_balance; + const isPaidOff = curBal !== null && curBal !== undefined && curBal <= 0; + return ( + + + + + + + + ); + })} + +
DebtStarting balanceProjected payoffProjected interestCurrent balance
{d.name}{fmtFull(d.starting_balance)}{d.projected_payoff_date ?? 'β€”'} + {d.projected_total_interest != null ? fmtFull(d.projected_total_interest) : 'β€”'} + + {isPaidOff ? ( + Paid off βœ“ + ) : curBal != null ? ( + fmtFull(curBal) + ) : ( + removed + )} +
+
+ )} + + {plan.notes && ( +

{plan.notes}

+ )} +
+ ); +} + +// ─── Single plan row ────────────────────────────────────────────────────────── + +function PlanRow({ plan }) { + const [expanded, setExpanded] = useState(false); + const snapshot = plan.plan_snapshot ?? {}; + const debtCount = (snapshot.debts ?? []).length; + + return ( + + + + + +
+ +
+
+
+ ); +} + +// ─── PlanHistoryPanel ───────────────────────────────────────────────────────── + +export default function PlanHistoryPanel({ plans = [] }) { + const [open, setOpen] = useState(false); + + const historical = plans.filter(p => !['active', 'paused'].includes(p.status)); + if (historical.length === 0) return null; + + return ( +
+ +
+ + + + +
+ {historical.map(plan => ( + + ))} +
+
+
+
+
+ ); +} diff --git a/client/components/snowball/PlanStatusBanner.jsx b/client/components/snowball/PlanStatusBanner.jsx new file mode 100644 index 0000000..1daf146 --- /dev/null +++ b/client/components/snowball/PlanStatusBanner.jsx @@ -0,0 +1,271 @@ +import React, { useMemo, useState } from 'react'; +import { CheckCircle2, ChevronDown, Circle, Clock, Pause, Play, TrendingUp, X, Zap } from 'lucide-react'; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; +import { + AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, + AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, +} from '@/components/ui/alert-dialog'; +import { Button } from '@/components/ui/button'; +import { cn } from '@/lib/utils'; + +function fmt(v) { + return (Number(v) || 0).toLocaleString(undefined, { + style: 'currency', currency: 'USD', maximumFractionDigits: 0, + }); +} + +function dateLabel(iso) { + if (!iso) return 'β€”'; + return new Date(iso).toLocaleDateString(undefined, { month: 'short', year: 'numeric' }); +} + +function months(n) { + if (!n || n <= 0) return 'just started'; + const y = Math.floor(n / 12); + const m = n % 12; + if (y === 0) return `${m} mo`; + if (m === 0) return `${y} yr`; + return `${y} yr ${m} mo`; +} + +// ─── On-track indicator ─────────────────────────────────────────────────────── + +function computeOnTrack(debt, monthsElapsed) { + if (!debt.projected_payoff_month || debt.current_balance === null) return null; + if (debt.starting_balance <= 0) return null; + + const remaining = debt.projected_payoff_month - monthsElapsed; + if (remaining <= 0) return debt.current_balance <= 0 ? 'done' : 'behind'; + + const progressExpected = monthsElapsed / debt.projected_payoff_month; + const progressActual = debt.starting_balance > 0 + ? 1 - (debt.current_balance / debt.starting_balance) + : 0; + + const diff = progressActual - progressExpected; + if (diff > 0.05) return 'ahead'; + if (diff < -0.05) return 'behind'; + return 'on_track'; +} + +function OnTrackPill({ status }) { + if (!status) return null; + const map = { + ahead: { label: '↑ Ahead', cls: 'bg-teal-500/12 text-teal-600 dark:text-teal-400' }, + on_track: { label: 'β†’ On track', cls: 'bg-muted/60 text-muted-foreground' }, + behind: { label: '↓ Behind', cls: 'bg-amber-500/12 text-amber-600 dark:text-amber-400' }, + done: { label: 'βœ“ Paid off', cls: 'bg-emerald-500/12 text-emerald-600 dark:text-emerald-400' }, + }; + const { label, cls } = map[status] ?? map.on_track; + return ( + + {label} + + ); +} + +// ─── Per-debt progress row ──────────────────────────────────────────────────── + +function DebtProgressRow({ debt, snapshotDebt, monthsElapsed }) { + const startBal = debt.starting_balance ?? 0; + const curBal = debt.current_balance ?? startBal; + const pct = debt.progress_pct ?? 0; + const trackStatus = computeOnTrack({ ...debt, ...snapshotDebt }, monthsElapsed); + + return ( +
+
+
+ {debt.name} + +
+
+
+
+
+ {pct}% +
+
+
+ {debt.current_balance !== null ? ( + <> +

{fmt(curBal)}

+ {startBal > 0 &&

{fmt(startBal)} start

} + + ) : ( +

removed

+ )} +
+ {snapshotDebt?.projected_payoff_date && ( +
+

proj.

+

{snapshotDebt.projected_payoff_date.slice(0, 7)}

+
+ )} +
+ ); +} + +// ─── PlanStatusBanner ───────────────────────────────────────────────────────── + +export default function PlanStatusBanner({ plan, onPause, onResume, onComplete, onAbandon, onNewPlan }) { + const [open, setOpen] = useState(true); + const [confirmDialog, setConfirmDialog] = useState(null); + + const snapshot = plan?.plan_snapshot ?? {}; + const snapshotMap = useMemo(() => { + const m = {}; + (snapshot.debts ?? []).forEach(d => { m[d.bill_id] = d; }); + return m; + }, [snapshot.debts]); + + const currentDebts = plan?.current_debts ?? []; + const monthsElapsed = plan?.months_elapsed ?? 0; + + const totalStart = currentDebts.reduce((s, d) => s + (d.starting_balance ?? 0), 0); + const totalCur = currentDebts.reduce((s, d) => s + (d.current_balance ?? d.starting_balance ?? 0), 0); + const totalPaid = Math.max(0, totalStart - totalCur); + const overallPct = totalStart > 0 ? Math.min(100, Math.round(totalPaid / totalStart * 100)) : 0; + + const isActive = plan?.status === 'active'; + const isPaused = plan?.status === 'paused'; + + function confirm(action, title, description, onConfirm) { + setConfirmDialog({ title, description, onConfirm }); + } + + if (!plan) return null; + + return ( + <> + +
+ + {/* Header */} + + + + + )} + {isPaused && ( + <> + + + + )} + + +
+ +
+ + + + {/* Collapsible body β€” per-debt rows */} + +
+ {currentDebts.length === 0 ? ( +

No debt data in this plan.

+ ) : ( + currentDebts.map(debt => ( + + )) + )} + + {/* Summary row */} + {totalStart > 0 && ( +
+ + Total progress + +
+ + {fmt(totalPaid)} paid of {fmt(totalStart)} + + {snapshot.interest_saved > 0 && ( + + {fmt(snapshot.interest_saved)} interest saved vs minimum + + )} +
+
+ )} +
+
+ + + + + {/* Confirmation dialog */} + { if (!open) setConfirmDialog(null); }}> + + + {confirmDialog?.title} + {confirmDialog?.description} + + + setConfirmDialog(null)}>Cancel + { confirmDialog?.onConfirm(); setConfirmDialog(null); }}> + Confirm + + + + + + ); +} diff --git a/client/pages/SnowballPage.jsx b/client/pages/SnowballPage.jsx index fa08746..b5561df 100644 --- a/client/pages/SnowballPage.jsx +++ b/client/pages/SnowballPage.jsx @@ -10,6 +10,8 @@ import { Skeleton } from '@/components/ui/Skeleton'; import { cn } from '@/lib/utils'; import BillModal from '@/components/BillModal'; import { makeBillDraft } from '@/lib/billDrafts'; +import PlanStatusBanner from '@/components/snowball/PlanStatusBanner'; +import PlanHistoryPanel from '@/components/snowball/PlanHistoryPanel'; // ── formatters ──────────────────────────────────────────────────────────────── function fmt(val) { @@ -442,6 +444,10 @@ 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); @@ -474,7 +480,12 @@ export default function SnowballPage() { } finally { setLoading(false); } }, []); - useEffect(() => { Promise.all([load(), loadProjection()]); }, [load, loadProjection]); + const loadPlans = useCallback(() => { + api.snowballActivePlan().then(p => setActivePlan(p)).catch(() => setActivePlan(null)); + api.snowballPlans().then(d => setAllPlans(d?.plans ?? [])).catch(() => {}); + }, []); + + useEffect(() => { Promise.all([load(), loadProjection()]); loadPlans(); }, [load, loadProjection, loadPlans]); // ── auto-arrange ────────────────────────────────────────────────────────── const handleAutoArrange = () => { @@ -604,6 +615,58 @@ export default function SnowballPage() { ? { snowball: liveSnowball, avalanche: projection?.avalanche } : projection; + // ── plan lifecycle ──────────────────────────────────────────────────────── + const handleStartPlan = async () => { + setStartingPlan(true); + try { + const plan = await api.startSnowballPlan({ method: ramseyMode ? 'snowball' : 'custom' }); + setActivePlan(plan); + setAllPlans(prev => [plan, ...prev.filter(p => !['active', 'paused'].includes(p.status))]); + toast.success('Snowball plan started!'); + } catch (err) { toast.error(err.message || 'Failed to start plan'); } + finally { setStartingPlan(false); } + }; + + const handlePausePlan = async () => { + if (!activePlan) return; + try { + const updated = await api.pauseSnowballPlan(activePlan.id); + setActivePlan(updated); + setAllPlans(prev => prev.map(p => p.id === updated.id ? updated : p)); + toast.success('Plan paused'); + } catch (err) { toast.error(err.message || 'Failed to pause'); } + }; + + const handleResumePlan = async () => { + if (!activePlan) return; + try { + const updated = await api.resumeSnowballPlan(activePlan.id); + setActivePlan(updated); + setAllPlans(prev => prev.map(p => p.id === updated.id ? updated : p)); + toast.success('Plan resumed'); + } catch (err) { toast.error(err.message || 'Failed to resume'); } + }; + + const handleCompletePlan = async () => { + if (!activePlan) return; + try { + const updated = await api.completeSnowballPlan(activePlan.id); + setActivePlan(null); + setAllPlans(prev => prev.map(p => p.id === updated.id ? updated : p)); + toast.success('Plan marked as complete!'); + } catch (err) { toast.error(err.message || 'Failed to complete plan'); } + }; + + const handleAbandonPlan = async () => { + if (!activePlan) return; + try { + const updated = await api.abandonSnowballPlan(activePlan.id); + setActivePlan(null); + setAllPlans(prev => prev.map(p => p.id === updated.id ? updated : p)); + toast.success('Plan abandoned'); + } catch (err) { toast.error(err.message || 'Failed to abandon plan'); } + }; + // ── 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); @@ -688,6 +751,18 @@ export default function SnowballPage() { return (
+ {/* Active plan banner */} + {activePlan && ( + + )} + {/* Header */}

@@ -763,14 +838,24 @@ export default function SnowballPage() { )} {bills.length > 0 && ( - +
+ + {!activePlan && readinessReadyCount >= 3 && ( +
+ +
+ )} +
)} {bills.length > 0 && (customOrderDrift || missingMinCount > 0) && ( @@ -1017,6 +1102,9 @@ export default function SnowballPage() {

)} + {/* Plan history */} + + {/* Edit modal */} {editBill && ( +
+
+ {label} + {isMatched ? ( + + + {tx.matched_bill_name || 'Matched'} + + ) : ( + Unmatched + )} + {catalogMatch && ( + + Known: {catalogMatch.name} + + )} +
+

+ {tx.posted_date}{account ? ` Β· ${account}` : ''} + {catalogMatch ? ` Β· ${TYPE_LABELS[catalogMatch.subscription_type] || 'Other'}` : ''} +

+
+ ${dollars} + {!isMatched && ( + + )} +
+ ); +} + function RecommendationCard({ recommendation, categoryId, onAccept, onDecline, onMatch, busy }) { return (
@@ -270,7 +314,13 @@ export default function SubscriptionsPage() { const [recommendationsLoading, setRecommendationsLoading] = useState(true); const [busyId, setBusyId] = useState(null); const [modal, setModal] = useState(null); - const [matchTarget, setMatchTarget] = useState(null); // recommendation being linked + const [matchTarget, setMatchTarget] = useState(null); + const [recSearch, setRecSearch] = useState(''); + + const [txQuery, setTxQuery] = useState(''); + const [txResults, setTxResults] = useState([]); + const [txSearching, setTxSearching] = useState(false); + const txDebounce = useRef(null); const subscriptionCategoryId = useMemo(() => { const match = categories.find(category => /subscrip/i.test(category.name)); @@ -311,6 +361,21 @@ export default function SubscriptionsPage() { api.bills().then(b => setBills(Array.isArray(b) ? b : [])).catch(() => {}); }, [load, loadRecommendations]); + useEffect(() => { + clearTimeout(txDebounce.current); + const q = txQuery.trim(); + if (!q) { setTxResults([]); return; } + txDebounce.current = setTimeout(async () => { + setTxSearching(true); + try { + const result = await api.subscriptionTransactionMatches({ q, limit: 50 }); + setTxResults(Array.isArray(result) ? result : (result?.transactions ?? [])); + } catch { setTxResults([]); } + finally { setTxSearching(false); } + }, 300); + return () => clearTimeout(txDebounce.current); + }, [txQuery]); + async function refreshAll() { await Promise.all([load(), loadRecommendations()]); } @@ -387,11 +452,46 @@ export default function SubscriptionsPage() { }); } + function openFromTransaction(tx) { + const catalogMatch = tx.catalog_match; + const label = catalogMatch?.name || tx.payee || tx.description || tx.memo || ''; + const dollars = Math.abs(tx.amount ?? 0) / 100; + const rawDay = tx.posted_date ? new Date(tx.posted_date + 'T12:00:00').getDate() : NaN; + const dueDay = Number.isInteger(rawDay) && rawDay >= 1 && rawDay <= 31 ? rawDay : new Date().getDate(); + setModal({ + bill: null, + initialBill: { + name: label, + category_id: subscriptionCategoryId, + due_day: dueDay, + expected_amount: dollars > 0 ? dollars.toFixed(2) : '', + cycle_type: 'monthly', + is_subscription: 1, + subscription_type: catalogMatch?.subscription_type || 'other', + website: catalogMatch?.website || undefined, + notes: catalogMatch + ? `Matched known subscription catalog entry: ${catalogMatch.name}` + : undefined, + reminder_days_before: 3, + }, + }); + } + const summary = data.summary || {}; const subscriptions = data.subscriptions || []; const active = subscriptions.filter(item => item.active); const paused = subscriptions.filter(item => !item.active); + const MIN_CONFIDENCE = 90; + const highConfidenceRecs = useMemo( + () => recommendations.filter(r => (r.confidence ?? 0) >= MIN_CONFIDENCE), + [recommendations], + ); + const filteredRecs = useMemo(() => { + const q = recSearch.trim().toLowerCase(); + return q ? highConfidenceRecs.filter(r => r.name?.toLowerCase().includes(q)) : highConfidenceRecs; + }, [highConfidenceRecs, recSearch]); + return (
@@ -460,22 +560,55 @@ export default function SubscriptionsPage() {
Recommendations + {!recommendationsLoading && highConfidenceRecs.length > 0 && ( + + {filteredRecs.length} of {highConfidenceRecs.length} + + )}
- Recurring unmatched bank charges that look like subscriptions. + Recurring charges from your accounts with 90%+ confidence. + {!recommendationsLoading && highConfidenceRecs.length > 0 && ( +
+ + setRecSearch(e.target.value)} + className="pl-8 h-8 text-sm" + /> +
+ )} {recommendationsLoading ? (
- Scanning transactions... + Scanning transactions…
- ) : recommendations.length === 0 ? ( + ) : highConfidenceRecs.length === 0 ? (
-

No recommendations right now.

-

Sync SimpleFIN after a few recurring charges appear.

+

No high-confidence recommendations.

+

+ {recommendations.length > 0 + ? `${recommendations.length} low-confidence pattern${recommendations.length !== 1 ? 's' : ''} found β€” more account activity will improve accuracy.` + : 'Sync your accounts after a few recurring charges appear.'} +

+
+ ) : filteredRecs.length === 0 ? ( +
+ +

No matches for "{recSearch}"

+
) : ( - recommendations.map(recommendation => ( + filteredRecs.map(recommendation => (
+ {/* Transaction search */} + + +
+ + Search Bank Transactions +
+ + Search all account charges β€” matched and unmatched β€” to find subscriptions the algorithm may have missed. + +
+ + setTxQuery(e.target.value)} + className="pl-8 h-9 text-sm" + autoComplete="off" + /> +
+
+ + {(txQuery.trim() || txSearching) && ( + + {txSearching ? ( +
+ + Searching transactions… +
+ ) : txResults.length === 0 ? ( +
+ +

No transactions found for "{txQuery}"

+

Try a different merchant name or description.

+
+ ) : ( +
+
+

+ {txResults.length} result{txResults.length !== 1 ? 's' : ''} + {txResults.length === 50 ? ' (showing first 50)' : ''} +

+
+ {txResults.map(tx => ( + + ))} +
+ )} +
+ )} +
+ {modal && ( { res.json({ success: true }); }); +// ── Snowball Plan helpers ───────────────────────────────────────────────────── + +function enrichPlanWithProgress(db, plan) { + let snapshot; + try { snapshot = JSON.parse(plan.plan_snapshot); } catch { snapshot = null; } + + const currentDebts = (snapshot?.debts ?? []).map(d => { + const bill = db.prepare('SELECT current_balance, name, deleted_at FROM bills WHERE id = ?').get(d.bill_id); + const currentBalance = bill && !bill.deleted_at ? (bill.current_balance ?? null) : null; + const startingBalance = d.starting_balance ?? 0; + const progressPct = startingBalance > 0 && currentBalance !== null + ? Math.min(100, Math.max(0, Math.round((startingBalance - currentBalance) / startingBalance * 100))) + : null; + return { bill_id: d.bill_id, name: d.name, current_balance: currentBalance, starting_balance: startingBalance, progress_pct: progressPct, deleted: !!(bill?.deleted_at) }; + }); + + const startedMs = plan.started_at ? new Date(plan.started_at).getTime() : Date.now(); + const monthsElapsed = Math.floor((Date.now() - startedMs) / (1000 * 60 * 60 * 24 * 30)); + + return { ...plan, plan_snapshot: snapshot, months_elapsed: monthsElapsed, current_debts: currentDebts }; +} + +// POST /api/snowball/plans β€” start a new snowball plan +router.post('/plans', (req, res) => { + try { + const db = getDb(); + const userId = req.user.id; + const { name, method, notes } = req.body; + + const planName = (typeof name === 'string' && name.trim()) ? name.trim().slice(0, 100) : 'Snowball Plan'; + const planMethod = ['snowball', 'avalanche', 'custom'].includes(method) ? method : 'snowball'; + + const debts = getDebtBills(userId); + const activeDebts = debts.filter(b => (b.current_balance ?? 0) > 0); + if (activeDebts.length === 0) { + return res.status(400).json({ error: 'No debts with a balance found. Add a balance to at least one bill.' }); + } + + const user = db.prepare('SELECT snowball_extra_payment FROM users WHERE id = ?').get(userId); + const extra = user?.snowball_extra_payment ?? 0; + const now = new Date(); + + const snowball = planMethod === 'avalanche' ? calculateAvalanche(debts, extra, now) : calculateSnowball(debts, extra, now); + const minOnly = calculateMinimumOnly(debts, now); + const interestSaved = Math.max(0, Math.round(((minOnly.total_interest_paid ?? 0) - (snowball.total_interest_paid ?? 0)) * 100) / 100); + + const debtSnaps = debts.map((b, i) => { + const proj = snowball.debts?.find(d => d.id === b.id); + return { + bill_id: b.id, + name: b.name, + starting_balance: b.current_balance ?? 0, + minimum_payment: b.minimum_payment ?? 0, + interest_rate: b.interest_rate ?? 0, + projected_payoff_month: proj?.payoff_month ?? null, + projected_payoff_date: proj?.payoff_date ?? null, + projected_total_interest: proj?.total_interest ?? null, + order: i, + }; + }); + + const planSnapshot = JSON.stringify({ + projected_payoff_date: snowball.payoff_date ?? null, + projected_months: snowball.months_to_freedom ?? null, + projected_total_interest: snowball.total_interest_paid ?? null, + minimum_only_months: minOnly.months_to_freedom ?? null, + interest_saved: interestSaved, + debts: debtSnaps, + }); + + // Abandon any existing active/paused plan first + db.prepare(` + UPDATE snowball_plans SET status = 'abandoned', updated_at = datetime('now') + WHERE user_id = ? AND status IN ('active', 'paused') + `).run(userId); + + const result = db.prepare(` + INSERT INTO snowball_plans (user_id, name, method, status, extra_payment, plan_snapshot, notes, started_at, created_at, updated_at) + VALUES (?, ?, ?, 'active', ?, ?, ?, datetime('now'), datetime('now'), datetime('now')) + `).run(userId, planName, planMethod, extra, planSnapshot, notes || null); + + const plan = db.prepare('SELECT * FROM snowball_plans WHERE id = ?').get(result.lastInsertRowid); + res.status(201).json(enrichPlanWithProgress(db, plan)); + } catch (err) { + console.error('[snowball plans] POST error:', err.message); + res.status(500).json({ error: 'Failed to start plan' }); + } +}); + +// GET /api/snowball/plans β€” list all plans for user +router.get('/plans', (req, res) => { + try { + const db = getDb(); + const plans = db.prepare(` + SELECT * FROM snowball_plans WHERE user_id = ? ORDER BY created_at DESC + `).all(req.user.id); + res.json({ plans: plans.map(p => enrichPlanWithProgress(db, p)) }); + } catch (err) { + console.error('[snowball plans] GET /plans error:', err.message); + res.status(500).json({ error: 'Failed to load plans' }); + } +}); + +// GET /api/snowball/plans/active β€” return the active or paused plan (or null) +router.get('/plans/active', (req, res) => { + try { + const db = getDb(); + const plan = db.prepare(` + SELECT * FROM snowball_plans + WHERE user_id = ? AND status IN ('active', 'paused') + ORDER BY created_at DESC LIMIT 1 + `).get(req.user.id); + res.json(plan ? enrichPlanWithProgress(db, plan) : null); + } catch (err) { + console.error('[snowball plans] GET /plans/active error:', err.message); + res.status(500).json({ error: 'Failed to load active plan' }); + } +}); + +// PATCH /api/snowball/plans/:id β€” update name or notes +router.patch('/plans/:id', (req, res) => { + try { + const db = getDb(); + const id = parseInt(req.params.id, 10); + if (!Number.isInteger(id) || id <= 0) return res.status(400).json({ error: 'Invalid id' }); + const plan = db.prepare('SELECT * FROM snowball_plans WHERE id = ? AND user_id = ?').get(id, req.user.id); + if (!plan) return res.status(404).json({ error: 'Plan not found' }); + + const { name, notes } = req.body; + const newName = (typeof name === 'string' && name.trim()) ? name.trim().slice(0, 100) : plan.name; + const newNotes = notes !== undefined ? (notes || null) : plan.notes; + + db.prepare(` + UPDATE snowball_plans SET name = ?, notes = ?, updated_at = datetime('now') WHERE id = ? + `).run(newName, newNotes, id); + + const updated = db.prepare('SELECT * FROM snowball_plans WHERE id = ?').get(id); + res.json(enrichPlanWithProgress(db, updated)); + } catch (err) { + console.error('[snowball plans] PATCH error:', err.message); + res.status(500).json({ error: 'Failed to update plan' }); + } +}); + +// POST /api/snowball/plans/:id/pause +router.post('/plans/:id/pause', (req, res) => { + try { + const db = getDb(); + const id = parseInt(req.params.id, 10); + if (!Number.isInteger(id) || id <= 0) return res.status(400).json({ error: 'Invalid id' }); + const plan = db.prepare('SELECT * FROM snowball_plans WHERE id = ? AND user_id = ?').get(id, req.user.id); + if (!plan) return res.status(404).json({ error: 'Plan not found' }); + if (plan.status !== 'active') return res.status(400).json({ error: 'Only active plans can be paused' }); + + db.prepare(` + UPDATE snowball_plans SET status = 'paused', paused_at = datetime('now'), updated_at = datetime('now') WHERE id = ? + `).run(id); + + const updated = db.prepare('SELECT * FROM snowball_plans WHERE id = ?').get(id); + res.json(enrichPlanWithProgress(db, updated)); + } catch (err) { + console.error('[snowball plans] pause error:', err.message); + res.status(500).json({ error: 'Failed to pause plan' }); + } +}); + +// POST /api/snowball/plans/:id/resume +router.post('/plans/:id/resume', (req, res) => { + try { + const db = getDb(); + const id = parseInt(req.params.id, 10); + if (!Number.isInteger(id) || id <= 0) return res.status(400).json({ error: 'Invalid id' }); + const plan = db.prepare('SELECT * FROM snowball_plans WHERE id = ? AND user_id = ?').get(id, req.user.id); + if (!plan) return res.status(404).json({ error: 'Plan not found' }); + if (plan.status !== 'paused') return res.status(400).json({ error: 'Only paused plans can be resumed' }); + + db.prepare(` + UPDATE snowball_plans SET status = 'active', paused_at = NULL, updated_at = datetime('now') WHERE id = ? + `).run(id); + + const updated = db.prepare('SELECT * FROM snowball_plans WHERE id = ?').get(id); + res.json(enrichPlanWithProgress(db, updated)); + } catch (err) { + console.error('[snowball plans] resume error:', err.message); + res.status(500).json({ error: 'Failed to resume plan' }); + } +}); + +// POST /api/snowball/plans/:id/complete +router.post('/plans/:id/complete', (req, res) => { + try { + const db = getDb(); + const id = parseInt(req.params.id, 10); + if (!Number.isInteger(id) || id <= 0) return res.status(400).json({ error: 'Invalid id' }); + const plan = db.prepare('SELECT * FROM snowball_plans WHERE id = ? AND user_id = ?').get(id, req.user.id); + if (!plan) return res.status(404).json({ error: 'Plan not found' }); + if (!['active', 'paused'].includes(plan.status)) return res.status(400).json({ error: 'Only active or paused plans can be completed' }); + + db.prepare(` + UPDATE snowball_plans SET status = 'completed', completed_at = datetime('now'), updated_at = datetime('now') WHERE id = ? + `).run(id); + + const updated = db.prepare('SELECT * FROM snowball_plans WHERE id = ?').get(id); + res.json(enrichPlanWithProgress(db, updated)); + } catch (err) { + console.error('[snowball plans] complete error:', err.message); + res.status(500).json({ error: 'Failed to complete plan' }); + } +}); + +// POST /api/snowball/plans/:id/abandon +router.post('/plans/:id/abandon', (req, res) => { + try { + const db = getDb(); + const id = parseInt(req.params.id, 10); + if (!Number.isInteger(id) || id <= 0) return res.status(400).json({ error: 'Invalid id' }); + const plan = db.prepare('SELECT * FROM snowball_plans WHERE id = ? AND user_id = ?').get(id, req.user.id); + if (!plan) return res.status(404).json({ error: 'Plan not found' }); + if (!['active', 'paused'].includes(plan.status)) return res.status(400).json({ error: 'Only active or paused plans can be abandoned' }); + + db.prepare(` + UPDATE snowball_plans SET status = 'abandoned', updated_at = datetime('now') WHERE id = ? + `).run(id); + + const updated = db.prepare('SELECT * FROM snowball_plans WHERE id = ?').get(id); + res.json(enrichPlanWithProgress(db, updated)); + } catch (err) { + console.error('[snowball plans] abandon error:', err.message); + res.status(500).json({ error: 'Failed to abandon plan' }); + } +}); + module.exports = router; diff --git a/routes/subscriptions.js b/routes/subscriptions.js index bfe2201..825f9c0 100644 --- a/routes/subscriptions.js +++ b/routes/subscriptions.js @@ -9,6 +9,7 @@ const { getSubscriptionRecommendations, getSubscriptionSummary, getSubscriptions, + searchSubscriptionTransactions, } = require('../services/subscriptionService'); const { addMerchantRule, applyMerchantRules } = require('../services/billMerchantRuleService'); @@ -29,6 +30,16 @@ router.get('/recommendations', (req, res) => { }); }); +router.get('/transaction-matches', (req, res) => { + try { + res.json({ + transactions: searchSubscriptionTransactions(getDb(), req.user.id, req.query), + }); + } catch (err) { + res.status(500).json(standardizeError(err.message || 'Failed to search subscription transactions', 'SUBSCRIPTION_SEARCH_ERROR')); + } +}); + router.post('/recommendations/decline', (req, res) => { const { decline_key } = req.body || {}; if (!decline_key || typeof decline_key !== 'string' || decline_key.length > 200) { diff --git a/services/subscriptionService.js b/services/subscriptionService.js index 5d9d94a..cda4636 100644 --- a/services/subscriptionService.js +++ b/services/subscriptionService.js @@ -42,12 +42,51 @@ const TYPE_KEYWORDS = [ function loadCatalog(db) { try { - return db.prepare('SELECT id, rank, name, category, subscription_type, domain FROM subscription_catalog ORDER BY rank ASC').all(); + return db.prepare('SELECT id, rank, name, category, subscription_type, domain, website FROM subscription_catalog ORDER BY rank ASC').all(); } catch { return []; } } +// Build a normalized-name β†’ subscription_type map from the full catalog so +// inferType can use all 290 known services, not just the hardcoded keyword list. +function buildCatalogTypeMap(catalog) { + const map = new Map(); + for (const entry of catalog) { + if (!entry.subscription_type || entry.subscription_type === 'other') continue; + const key = normalizeCatalogName(entry.name); + if (key.length >= 3 && !map.has(key)) map.set(key, entry.subscription_type); + } + return map; +} + +function compactCatalogKey(value) { + return normalizeCatalogName(value).replace(/\s+/g, ''); +} + +function hostFromUrl(value) { + if (!value) return ''; + try { + return new URL(String(value).startsWith('http') ? String(value) : `https://${value}`).hostname; + } catch { + return String(value || ''); + } +} + +function catalogDomainKeys(entry) { + const keys = new Set(); + const candidates = [entry.domain, hostFromUrl(entry.website)].filter(Boolean); + for (const candidate of candidates) { + const host = String(candidate).toLowerCase().replace(/^www\./, '').replace(/\/.*$/, ''); + const labels = host.split('.').filter(Boolean); + if (labels.length >= 2) { + keys.add(labels.join(' ')); + keys.add(labels.slice(-2).join(' ')); + } + } + return [...keys].filter(key => key.length >= 4); +} + function normalizeCatalogName(value) { return String(value || '') .toLowerCase() @@ -62,17 +101,30 @@ function normalizeCatalogName(value) { function lookupCatalog(catalog, merchantText) { if (!catalog.length || !merchantText) return null; let best = null; - let bestLen = 0; + let bestScore = 0; + const merchantCompact = compactCatalogKey(merchantText); for (const entry of catalog) { const nameKey = normalizeCatalogName(entry.name); - const domainKey = entry.domain ? entry.domain.replace(/\./g, ' ') : ''; - if (nameKey.length >= 3 && merchantText.includes(nameKey) && nameKey.length > bestLen) { + const nameCompact = compactCatalogKey(entry.name); + const nameScore = 1000 + nameKey.length; + if ( + nameKey.length >= 3 + && (merchantText.includes(nameKey) || (nameCompact.length >= 5 && merchantCompact.includes(nameCompact))) + && nameScore > bestScore + ) { best = entry; - bestLen = nameKey.length; + bestScore = nameScore; } - if (domainKey.length >= 4 && merchantText.includes(domainKey) && domainKey.length > bestLen) { - best = entry; - bestLen = domainKey.length; + for (const domainKey of catalogDomainKeys(entry)) { + const domainCompact = domainKey.replace(/\s+/g, ''); + const domainScore = 500 + domainKey.length; + if ( + (merchantText.includes(domainKey) || (domainCompact.length >= 5 && merchantCompact.includes(domainCompact))) + && domainScore > bestScore + ) { + best = entry; + bestScore = domainScore; + } } } return best; @@ -98,15 +150,30 @@ function titleCase(value) { .join(' '); } -function inferType(merchantText, catalogEntry) { +function inferType(merchantText, catalogEntry, catalogTypeMap = null) { if (catalogEntry?.subscription_type) return catalogEntry.subscription_type; const haystack = normalizeMerchant(merchantText); + if (catalogTypeMap) { + for (const [nameKey, type] of catalogTypeMap.entries()) { + if (haystack.includes(nameKey)) return type; + } + } for (const [type, words] of TYPE_KEYWORDS) { if (words.some(word => haystack.includes(word))) return type; } return 'other'; } +function catalogMatchPayload(catalogEntry) { + return catalogEntry ? { + id: catalogEntry.id, + name: catalogEntry.name, + category: catalogEntry.category, + subscription_type: catalogEntry.subscription_type || 'other', + website: catalogEntry.website || null, + } : null; +} + function monthlyEquivalent(amount, cycleType, billingCycle) { const key = String(cycleType || billingCycle || 'monthly').toLowerCase(); const fallback = String(billingCycle || '').toLowerCase() === 'quarterly' @@ -223,6 +290,7 @@ function declineRecommendation(db, userId, declineKey) { function getSubscriptionRecommendations(db, userId) { const catalog = loadCatalog(db); + const catalogTypeMap = buildCatalogTypeMap(catalog); const existingNames = existingBillNames(db, userId); const declined = getDeclinedKeys(db, userId); @@ -286,7 +354,7 @@ function getSubscriptionRecommendations(db, userId) { if (catalogEntry && sorted.length === 1) { recommendations.push(buildRecommendation({ merchant, catalogEntry, sorted, averageAmount, maxDelta, last, - cycleType: 'monthly', avgGap: 30, confidence: 62, tier: 'possible', declineKey, + cycleType: 'monthly', avgGap: 30, confidence: 90, tier: 'known_service', declineKey, catalogTypeMap, })); continue; } @@ -321,7 +389,7 @@ function getSubscriptionRecommendations(db, userId) { const tier = catalogEntry ? 'confirmed' : 'pattern'; recommendations.push(buildRecommendation({ merchant, catalogEntry, sorted, averageAmount, maxDelta, last, - cycleType, avgGap, confidence, tier, declineKey, + cycleType, avgGap, confidence, tier, declineKey, catalogTypeMap, })); } @@ -339,9 +407,9 @@ function getSubscriptionRecommendations(db, userId) { return deduped.slice(0, 20); } -function buildRecommendation({ merchant, catalogEntry, sorted, averageAmount, maxDelta, last, cycleType, avgGap, confidence, tier, declineKey }) { +function buildRecommendation({ merchant, catalogEntry, sorted, averageAmount, maxDelta, last, cycleType, avgGap, confidence, tier, declineKey, catalogTypeMap }) { const name = catalogEntry ? catalogEntry.name : titleCase(merchant); - const subscriptionType = inferType(merchant, catalogEntry); + const subscriptionType = inferType(merchant, catalogEntry, catalogTypeMap); const reasons = []; if (catalogEntry) reasons.push(`Matches known service: ${catalogEntry.name}`); @@ -362,7 +430,7 @@ function buildRecommendation({ merchant, catalogEntry, sorted, averageAmount, ma occurrence_count: sorted.length, confidence, tier, - catalog_match: catalogEntry ? { id: catalogEntry.id, name: catalogEntry.name, category: catalogEntry.category } : null, + catalog_match: catalogMatchPayload(catalogEntry), transaction_ids: sorted.map(item => item.id), merchant, decline_key: declineKey, @@ -371,6 +439,52 @@ function buildRecommendation({ merchant, catalogEntry, sorted, averageAmount, ma }; } +function searchSubscriptionTransactions(db, userId, query = {}) { + const q = String(query.q || '').trim(); + if (q.length < 2) return []; + const limit = Math.max(1, Math.min(parseInt(query.limit || '50', 10) || 50, 100)); + const like = `%${q}%`; + const catalog = loadCatalog(db); + + const rows = db.prepare(` + SELECT + t.id, t.user_id, t.data_source_id, t.account_id, t.provider_transaction_id, + t.source_type, t.transaction_type, t.posted_date, t.transacted_at, t.amount, + t.currency, t.description, t.payee, t.memo, t.category, t.matched_bill_id, + t.match_status, t.ignored, t.created_at, t.updated_at, + ds.type AS data_source_type, ds.provider AS data_source_provider, + ds.name AS data_source_name, ds.status AS data_source_status, + fa.name AS account_name, fa.org_name AS account_org_name, + fa.account_type AS account_type, + b.name AS matched_bill_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 + 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.user_id = ? + AND t.ignored = 0 + AND t.amount < 0 + 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, + COALESCE(t.posted_date, substr(t.transacted_at, 1, 10), t.created_at) DESC, + t.id DESC + LIMIT ? + `).all(userId, like, like, like, like, limit); + + return rows.map(row => { + const merchant = normalizeMerchant(row.payee || row.description || row.memo); + const catalogEntry = lookupCatalog(catalog, merchant); + return { + ...row, + amount_dollars: dollarsFromTransactionAmount(row.amount), + merchant, + is_known_subscription: !!catalogEntry, + catalog_match: catalogMatchPayload(catalogEntry), + }; + }).sort((a, b) => Number(b.is_known_subscription) - Number(a.is_known_subscription)); +} + function createSubscriptionFromRecommendation(db, userId, payload = {}) { const seenDate = payload.last_seen_date || new Date().toISOString().slice(0, 10); const source = payload.catalog_match @@ -450,4 +564,5 @@ module.exports = { loadCatalog, monthlyEquivalent, normalizeMerchant, + searchSubscriptionTransactions, }; diff --git a/tests/subscriptionService.test.js b/tests/subscriptionService.test.js new file mode 100644 index 0000000..772fe20 --- /dev/null +++ b/tests/subscriptionService.test.js @@ -0,0 +1,73 @@ +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-subscription-service-test-${process.pid}.sqlite`); +process.env.DB_PATH = dbPath; + +const { getDb, closeDb } = require('../db/database'); +const { + getSubscriptionRecommendations, + searchSubscriptionTransactions, +} = require('../services/subscriptionService'); + +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(`subscription-user-${suffix}`, `subscription-user-${suffix}@local`).lastInsertRowid; +} + +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) + `).run( + userId, + overrides.posted_date || new Date().toISOString().slice(0, 10), + overrides.amount ?? -1599, + overrides.description || 'NETFLIX.COM', + overrides.payee || 'NETFLIX.COM', + ).lastInsertRowid; +} + +test.after(() => { + closeDb(); + for (const suffix of ['', '-wal', '-shm']) { + fs.rmSync(`${dbPath}${suffix}`, { force: true }); + } +}); + +test('known catalog services appear as high-confidence subscription recommendations', () => { + const db = getDb(); + const userId = createUser(db, 'recommendation'); + createTransaction(db, userId); + + const recommendations = getSubscriptionRecommendations(db, userId); + const netflix = recommendations.find(item => item.catalog_match?.name === 'Netflix'); + + assert.ok(netflix, 'Netflix catalog match should be recommended from one known charge'); + assert.equal(netflix.subscription_type, 'streaming'); + assert.equal(netflix.confidence >= 90, true); + assert.match(netflix.reasons.join(' '), /Matches known service: Netflix/); +}); + +test('subscription transaction search annotates known catalog matches', () => { + const db = getDb(); + const userId = createUser(db, 'search'); + const transactionId = createTransaction(db, userId, { + description: 'NETFLIX.COM 866-579-7172', + payee: 'NETFLIX.COM', + }); + + const matches = searchSubscriptionTransactions(db, userId, { q: 'netflix', limit: 10 }); + const match = matches.find(item => item.id === transactionId); + + assert.ok(match, 'transaction should be returned by subscription search'); + assert.equal(match.is_known_subscription, true); + assert.equal(match.catalog_match.name, 'Netflix'); + assert.equal(match.catalog_match.subscription_type, 'streaming'); +});