chore: reset tracked db file

This commit is contained in:
null 2026-05-30 17:27:15 -05:00
parent 5449427b86
commit 35d0cbf8be
14 changed files with 1289 additions and 84 deletions

View File

@ -59,10 +59,6 @@ Show users what's coming: "You'll have $X left before the 15th", "Upcoming bills
--- ---
### 🟡 Recurring Payment Rules — MEDIUM ### 🟡 Recurring Payment Rules — MEDIUM
**Priority:** MEDIUM **Priority:** MEDIUM
**Added:** 2026-05-16 by Ripley (from _null's prioritized roadmap) **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 ## 🔵 LOW
### 🔵 Payment Method Tracking and Summary — LOW ### 🔵 Payment Method Tracking and Summary — LOW
**Priority:** LOW **Priority:** LOW
**Added:** 2026-05-11 by Ripley **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 ## 💭 NICE TO HAVE

View File

@ -1,18 +1,23 @@
# Bill Tracker — Changelog # Bill Tracker — Changelog
## v0.34.3 ## v0.34.1
### 🚀 Features ### 🚀 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. - **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.
### 🔧 Changed - **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.
- **Bump**`0.34.2``0.34.3` - **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`.
## v0.34.2 - **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 125%).
### 🧹 Roadmap Audit ### 🧹 Roadmap Audit
@ -22,22 +27,10 @@
- Updated status: Keyboard Navigation/Shortcuts → partial (Esc + Cmd+K done, arrow-key grid not) - Updated status: Keyboard Navigation/Shortcuts → partial (Esc + Cmd+K done, arrow-key grid not)
- Confirmed not implemented: Projected Cash Flow, Recurring Payment Rules (partial), Calendar Agenda, Filtered Exports, Payment Method Tracking, Unit Tests, Bill Grouping, Form State Management — all remain in FUTURE.md - Confirmed not implemented: Projected Cash Flow, Recurring Payment Rules (partial), Calendar Agenda, Filtered Exports, Payment Method Tracking, Unit Tests, Bill Grouping, Form State Management — all remain in FUTURE.md
### 🔧 Changed ### 🛠 Internal
- **Bump**`0.34.1``0.34.2` - **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.
---
## 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 125%).
### 🔧 Changed ### 🔧 Changed

View File

@ -191,6 +191,7 @@ export const api = {
confirmTransactionMatch: (transactionId, billId) => post('/matches/confirm', { transaction_id: transactionId, bill_id: billId }), 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 }), matchRecommendationToBill: (transactionIds, billId, merchant) => post('/subscriptions/recommendations/match-bill', { transaction_ids: transactionIds, bill_id: billId, merchant }),
subscriptionRecommendations: () => get('/subscriptions/recommendations'), subscriptionRecommendations: () => get('/subscriptions/recommendations'),
subscriptionTransactionMatches: (params = {}) => get(`/subscriptions/transaction-matches${queryString(params)}`),
updateSubscription: (id, data) => _fetch('PATCH', `/subscriptions/${id}`, data), updateSubscription: (id, data) => _fetch('PATCH', `/subscriptions/${id}`, data),
createSubscriptionFromRecommendation: (data) => post('/subscriptions/recommendations/create', data), createSubscriptionFromRecommendation: (data) => post('/subscriptions/recommendations/create', data),
declineRecommendation: (declineKey) => post('/subscriptions/recommendations/decline', { decline_key: declineKey }), declineRecommendation: (declineKey) => post('/subscriptions/recommendations/decline', { decline_key: declineKey }),
@ -206,11 +207,19 @@ export const api = {
restorePayment: (id) => post(`/payments/${id}/restore`), restorePayment: (id) => post(`/payments/${id}/restore`),
// Snowball // Snowball
snowball: () => get('/snowball'), snowball: () => get('/snowball'),
snowballSettings: () => get('/snowball/settings'), snowballSettings: () => get('/snowball/settings'),
saveSnowballSettings: (data) => _fetch('PATCH', '/snowball/settings', data), saveSnowballSettings: (data) => _fetch('PATCH', '/snowball/settings', data),
saveSnowballOrder: (items) => _fetch('PATCH', '/snowball/order', items), saveSnowballOrder: (items) => _fetch('PATCH', '/snowball/order', items),
snowballProjection: () => get('/snowball/projection'), 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
categories: () => get('/categories'), categories: () => get('/categories'),

View File

@ -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 (
<span className={cn('rounded-full px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide', map[status] ?? map.abandoned)}>
{labels[status] ?? status}
</span>
);
}
function MethodBadge({ method }) {
const map = { snowball: 'Snowball', avalanche: 'Avalanche', custom: 'Custom' };
return (
<span className="rounded-full px-2 py-0.5 text-[10px] font-medium bg-muted/60 text-muted-foreground">
{map[method] ?? method}
</span>
);
}
// Expanded plan detail
function PlanDetail({ plan }) {
const snapshot = plan.plan_snapshot ?? {};
const debts = snapshot.debts ?? [];
return (
<div className="px-4 pb-4 space-y-3">
{/* Summary stats */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2 pt-1">
{snapshot.projected_months && (
<div className="rounded-lg bg-muted/30 px-3 py-2 text-center">
<p className="text-[10px] text-muted-foreground uppercase tracking-wide">Projected payoff</p>
<p className="text-xs font-mono font-semibold mt-0.5">{snapshot.projected_payoff_date ?? '—'}</p>
</div>
)}
{snapshot.interest_saved > 0 && (
<div className="rounded-lg bg-emerald-500/8 border border-emerald-400/15 px-3 py-2 text-center">
<p className="text-[10px] text-muted-foreground uppercase tracking-wide">Interest saved</p>
<p className="text-xs font-mono font-semibold text-emerald-600 dark:text-emerald-400 mt-0.5">{fmt(snapshot.interest_saved)}</p>
</div>
)}
{snapshot.minimum_only_months && (
<div className="rounded-lg bg-muted/30 px-3 py-2 text-center">
<p className="text-[10px] text-muted-foreground uppercase tracking-wide">Minimum-only months</p>
<p className="text-xs font-mono font-semibold mt-0.5">{snapshot.minimum_only_months}</p>
</div>
)}
{plan.extra_payment > 0 && (
<div className="rounded-lg bg-muted/30 px-3 py-2 text-center">
<p className="text-[10px] text-muted-foreground uppercase tracking-wide">Extra/mo</p>
<p className="text-xs font-mono font-semibold mt-0.5">{fmtFull(plan.extra_payment)}</p>
</div>
)}
</div>
{/* Per-debt snapshot table */}
{debts.length > 0 && (
<div className="rounded-lg border border-border/50 overflow-hidden">
<table className="w-full text-xs">
<thead>
<tr className="bg-muted/30 border-b border-border/50">
<th className="text-left px-3 py-2 font-semibold text-muted-foreground uppercase tracking-wide text-[10px]">Debt</th>
<th className="text-right px-3 py-2 font-semibold text-muted-foreground uppercase tracking-wide text-[10px]">Starting balance</th>
<th className="text-right px-3 py-2 font-semibold text-muted-foreground uppercase tracking-wide text-[10px] hidden sm:table-cell">Projected payoff</th>
<th className="text-right px-3 py-2 font-semibold text-muted-foreground uppercase tracking-wide text-[10px] hidden sm:table-cell">Projected interest</th>
<th className="text-right px-3 py-2 font-semibold text-muted-foreground uppercase tracking-wide text-[10px]">Current balance</th>
</tr>
</thead>
<tbody className="divide-y divide-border/30">
{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 (
<tr key={d.bill_id} className="hover:bg-muted/10 transition-colors">
<td className="px-3 py-2 font-medium">{d.name}</td>
<td className="px-3 py-2 text-right font-mono tabular-nums">{fmtFull(d.starting_balance)}</td>
<td className="px-3 py-2 text-right font-mono hidden sm:table-cell">{d.projected_payoff_date ?? '—'}</td>
<td className="px-3 py-2 text-right font-mono text-rose-500 hidden sm:table-cell">
{d.projected_total_interest != null ? fmtFull(d.projected_total_interest) : '—'}
</td>
<td className="px-3 py-2 text-right font-mono tabular-nums">
{isPaidOff ? (
<span className="text-emerald-600 dark:text-emerald-400 font-semibold">Paid off </span>
) : curBal != null ? (
fmtFull(curBal)
) : (
<span className="text-muted-foreground italic">removed</span>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
{plan.notes && (
<p className="text-xs text-muted-foreground italic">{plan.notes}</p>
)}
</div>
);
}
// Single plan row
function PlanRow({ plan }) {
const [expanded, setExpanded] = useState(false);
const snapshot = plan.plan_snapshot ?? {};
const debtCount = (snapshot.debts ?? []).length;
return (
<Collapsible open={expanded} onOpenChange={setExpanded}>
<CollapsibleTrigger asChild>
<button type="button" className="w-full text-left hover:bg-muted/20 transition-colors px-4 py-3 flex items-center gap-3">
<StatusBadge status={plan.status} />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-medium">{plan.name}</span>
<MethodBadge method={plan.method} />
</div>
<p className="text-[11px] text-muted-foreground mt-0.5">
{dateRange(plan)}
{debtCount > 0 && ` · ${debtCount} debt${debtCount !== 1 ? 's' : ''}`}
{snapshot.interest_saved > 0 && ` · ${fmt(snapshot.interest_saved)} interest saved`}
</p>
</div>
<ChevronDown className={cn('h-4 w-4 text-muted-foreground transition-transform shrink-0', expanded && 'rotate-180')} />
</button>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="border-t border-border/30">
<PlanDetail plan={plan} />
</div>
</CollapsibleContent>
</Collapsible>
);
}
// 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 (
<div className="mt-6">
<Collapsible open={open} onOpenChange={setOpen}>
<div className="rounded-xl border border-border/60 overflow-hidden">
<CollapsibleTrigger asChild>
<button type="button" className="w-full text-left px-4 py-3 flex items-center gap-3 bg-muted/10 hover:bg-muted/20 transition-colors">
<History className="h-4 w-4 text-muted-foreground shrink-0" />
<span className="text-xs font-bold uppercase tracking-widest text-muted-foreground flex-1">
Plan History · {historical.length} plan{historical.length !== 1 ? 's' : ''}
</span>
<ChevronDown className={cn('h-4 w-4 text-muted-foreground transition-transform', open && 'rotate-180')} />
</button>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="divide-y divide-border/40">
{historical.map(plan => (
<PlanRow key={plan.id} plan={plan} />
))}
</div>
</CollapsibleContent>
</div>
</Collapsible>
</div>
);
}

View File

@ -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 (
<span className={cn('rounded-full px-2 py-0.5 text-[10px] font-semibold', cls)}>
{label}
</span>
);
}
// 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 (
<div className="flex items-center gap-3 px-4 py-2.5 text-sm">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="font-medium truncate">{debt.name}</span>
<OnTrackPill status={trackStatus} />
</div>
<div className="flex items-center gap-2">
<div className="flex-1 h-2 rounded-full bg-muted/50 overflow-hidden">
<div
className="h-full rounded-full bg-emerald-500 transition-all duration-500"
style={{ width: `${pct}%` }}
/>
</div>
<span className="text-[11px] font-mono text-muted-foreground tabular-nums w-8 text-right">{pct}%</span>
</div>
</div>
<div className="shrink-0 text-right">
{debt.current_balance !== null ? (
<>
<p className="text-xs font-mono font-semibold tabular-nums">{fmt(curBal)}</p>
{startBal > 0 && <p className="text-[10px] text-muted-foreground">{fmt(startBal)} start</p>}
</>
) : (
<p className="text-xs text-muted-foreground italic">removed</p>
)}
</div>
{snapshotDebt?.projected_payoff_date && (
<div className="shrink-0 text-right hidden sm:block">
<p className="text-[10px] text-muted-foreground">proj.</p>
<p className="text-xs font-mono">{snapshotDebt.projected_payoff_date.slice(0, 7)}</p>
</div>
)}
</div>
);
}
// 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 (
<>
<Collapsible open={open} onOpenChange={setOpen}>
<div className="mb-4 rounded-xl border border-emerald-400/25 bg-emerald-500/[0.05] dark:bg-emerald-400/[0.04] shadow-sm overflow-hidden">
{/* Header */}
<CollapsibleTrigger asChild>
<button type="button" className="w-full text-left">
<div className="flex items-center gap-3 px-4 py-3">
{/* Status dot + name */}
<div className="flex items-center gap-2 min-w-0 flex-1">
{isActive ? (
<span className="relative flex h-2.5 w-2.5 shrink-0">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75" />
<span className="relative inline-flex rounded-full h-2.5 w-2.5 bg-emerald-500" />
</span>
) : (
<Pause className="h-3 w-3 text-amber-500 shrink-0" />
)}
<span className="text-sm font-semibold truncate">{plan.name}</span>
<span className={cn(
'hidden sm:inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide',
isActive ? 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-400' : 'bg-amber-500/15 text-amber-600 dark:text-amber-400',
)}>
{isActive ? 'Active' : 'Paused'}
</span>
<span className="text-[11px] text-muted-foreground shrink-0">
Started {dateLabel(plan.started_at)} · {months(monthsElapsed)} in
</span>
</div>
{/* Overall progress bar */}
<div className="hidden md:flex items-center gap-2 w-36 shrink-0">
<div className="flex-1 h-1.5 rounded-full bg-muted/40 overflow-hidden">
<div className="h-full rounded-full bg-emerald-500 transition-all duration-500" style={{ width: `${overallPct}%` }} />
</div>
<span className="text-xs font-mono text-muted-foreground">{overallPct}%</span>
</div>
{/* Actions */}
<div className="flex items-center gap-1 shrink-0" onClick={e => e.stopPropagation()}>
{isActive && (
<>
<Button size="sm" variant="ghost" className="h-7 px-2 text-xs gap-1" onClick={onPause}>
<Pause className="h-3 w-3" /> Pause
</Button>
<Button size="sm" variant="outline" className="h-7 px-2 text-xs gap-1 border-emerald-400/40 text-emerald-600 hover:bg-emerald-500/10 dark:text-emerald-400" onClick={() => confirm('complete', 'Mark plan as complete?', 'This will record your plan as successfully completed.', onComplete)}>
<CheckCircle2 className="h-3 w-3" /> Complete
</Button>
</>
)}
{isPaused && (
<>
<Button size="sm" variant="outline" className="h-7 px-2 text-xs gap-1 border-emerald-400/40 text-emerald-600 hover:bg-emerald-500/10 dark:text-emerald-400" onClick={onResume}>
<Play className="h-3 w-3" /> Resume
</Button>
<Button size="sm" variant="ghost" className="h-7 px-2 text-xs gap-1 text-destructive hover:bg-destructive/10" onClick={() => confirm('abandon', 'Abandon this plan?', 'This plan will be moved to history. Your debt data stays unchanged.', onAbandon)}>
<X className="h-3 w-3" /> Abandon
</Button>
</>
)}
<Button size="sm" variant="ghost" className="h-7 px-2 text-xs gap-1 text-muted-foreground" onClick={() => confirm('new', 'Start a new plan?', 'Your current plan will be abandoned and moved to history. Your debt data stays unchanged.', onNewPlan)}>
<Zap className="h-3 w-3" /> New Plan
</Button>
<ChevronDown className={cn('h-4 w-4 text-muted-foreground transition-transform', open && 'rotate-180')} />
</div>
</div>
</button>
</CollapsibleTrigger>
{/* Collapsible body — per-debt rows */}
<CollapsibleContent>
<div className="border-t border-emerald-400/15 divide-y divide-border/30">
{currentDebts.length === 0 ? (
<p className="px-4 py-3 text-sm text-muted-foreground">No debt data in this plan.</p>
) : (
currentDebts.map(debt => (
<DebtProgressRow
key={debt.bill_id}
debt={debt}
snapshotDebt={snapshotMap[debt.bill_id]}
monthsElapsed={monthsElapsed}
/>
))
)}
{/* Summary row */}
{totalStart > 0 && (
<div className="px-4 py-2.5 flex items-center justify-between bg-muted/20">
<span className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
Total progress
</span>
<div className="flex items-center gap-4">
<span className="text-xs text-muted-foreground">
{fmt(totalPaid)} paid of {fmt(totalStart)}
</span>
{snapshot.interest_saved > 0 && (
<span className="text-xs text-emerald-600 dark:text-emerald-400 font-medium">
{fmt(snapshot.interest_saved)} interest saved vs minimum
</span>
)}
</div>
</div>
)}
</div>
</CollapsibleContent>
</div>
</Collapsible>
{/* Confirmation dialog */}
<AlertDialog open={!!confirmDialog} onOpenChange={open => { if (!open) setConfirmDialog(null); }}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{confirmDialog?.title}</AlertDialogTitle>
<AlertDialogDescription>{confirmDialog?.description}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={() => setConfirmDialog(null)}>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={() => { confirmDialog?.onConfirm(); setConfirmDialog(null); }}>
Confirm
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}

View File

@ -10,6 +10,8 @@ import { Skeleton } from '@/components/ui/Skeleton';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import BillModal from '@/components/BillModal'; import BillModal from '@/components/BillModal';
import { makeBillDraft } from '@/lib/billDrafts'; import { makeBillDraft } from '@/lib/billDrafts';
import PlanStatusBanner from '@/components/snowball/PlanStatusBanner';
import PlanHistoryPanel from '@/components/snowball/PlanHistoryPanel';
// formatters // formatters
function fmt(val) { function fmt(val) {
@ -442,6 +444,10 @@ export default function SnowballPage() {
const [editingBalance, setEditingBalance] = useState({ billId: null, value: '' }); 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 } = const { draggingIdx, draggingFromIdx, onPointerDown, onPointerMove, onPointerUp } =
useSortable(bills, setBills, setDirty); useSortable(bills, setBills, setDirty);
@ -474,7 +480,12 @@ export default function SnowballPage() {
} finally { setLoading(false); } } 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 // auto-arrange
const handleAutoArrange = () => { const handleAutoArrange = () => {
@ -604,6 +615,58 @@ export default function SnowballPage() {
? { snowball: liveSnowball, avalanche: projection?.avalanche } ? { snowball: liveSnowball, avalanche: projection?.avalanche }
: projection; : 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 // stats
const totalBalance = bills.reduce((s, b) => s + (b.current_balance || 0), 0); const totalBalance = bills.reduce((s, b) => s + (b.current_balance || 0), 0);
const totalMinPayment = bills.reduce((s, b) => s + (b.minimum_payment || 0), 0); const totalMinPayment = bills.reduce((s, b) => s + (b.minimum_payment || 0), 0);
@ -688,6 +751,18 @@ export default function SnowballPage() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Active plan banner */}
{activePlan && (
<PlanStatusBanner
plan={activePlan}
onPause={handlePausePlan}
onResume={handleResumePlan}
onComplete={handleCompletePlan}
onAbandon={handleAbandonPlan}
onNewPlan={handleStartPlan}
/>
)}
{/* Header */} {/* Header */}
<div> <div>
<h1 className="text-2xl font-semibold tracking-tight flex items-center gap-2"> <h1 className="text-2xl font-semibold tracking-tight flex items-center gap-2">
@ -763,14 +838,24 @@ export default function SnowballPage() {
)} )}
{bills.length > 0 && ( {bills.length > 0 && (
<ReadinessStrip <div className="space-y-3">
items={readinessItems} <ReadinessStrip
readyCount={readinessReadyCount} items={readinessItems}
totalCount={readinessItems.length} readyCount={readinessReadyCount}
allReady={readinessAllReady} totalCount={readinessItems.length}
onToggle={handleReadinessToggle} allReady={readinessAllReady}
disabled={savingSettings} onToggle={handleReadinessToggle}
/> disabled={savingSettings}
/>
{!activePlan && readinessReadyCount >= 3 && (
<div className="flex justify-end">
<Button size="sm" onClick={handleStartPlan} disabled={startingPlan} className="gap-1.5">
<Zap className="h-3.5 w-3.5" />
{startingPlan ? 'Starting…' : 'Start Snowball Plan'}
</Button>
</div>
)}
</div>
)} )}
{bills.length > 0 && (customOrderDrift || missingMinCount > 0) && ( {bills.length > 0 && (customOrderDrift || missingMinCount > 0) && (
@ -1017,6 +1102,9 @@ export default function SnowballPage() {
</div> </div>
)} )}
{/* Plan history */}
<PlanHistoryPanel plans={allPlans} />
{/* Edit modal */} {/* Edit modal */}
{editBill && ( {editBill && (
<BillModal <BillModal

View File

@ -1,9 +1,10 @@
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { import {
Bell, Bell,
CalendarDays, CalendarDays,
CheckCircle2, CheckCircle2,
CheckCircle,
Cloud, Cloud,
Link2, Link2,
Loader2, Loader2,
@ -11,6 +12,7 @@ import {
Plus, Plus,
RefreshCw, RefreshCw,
Repeat, Repeat,
Search,
Sparkles, Sparkles,
X, X,
} from 'lucide-react'; } from 'lucide-react';
@ -192,6 +194,48 @@ function BillPickerDialog({ open, onClose, recommendation, bills, onConfirm, bus
); );
} }
function TxResultRow({ tx, onTrack }) {
const dollars = (Math.abs(tx.amount) / 100).toFixed(2);
const label = tx.payee || tx.description || tx.memo || '—';
const account = tx.account_name || tx.data_source_name || null;
const isMatched = tx.match_status === 'matched';
const catalogMatch = tx.catalog_match;
return (
<div className="flex items-center gap-3 px-4 py-2.5 border-b border-border/30 last:border-0 hover:bg-muted/20 transition-colors">
<div className="flex-1 min-w-0">
<div className="flex flex-wrap items-center gap-1.5">
<span className="text-sm font-medium truncate max-w-[200px]">{label}</span>
{isMatched ? (
<Badge className="shrink-0 border-emerald-400/25 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 gap-1 text-[10px]">
<CheckCircle className="h-3 w-3" />
{tx.matched_bill_name || 'Matched'}
</Badge>
) : (
<Badge variant="outline" className="shrink-0 text-[10px] text-muted-foreground">Unmatched</Badge>
)}
{catalogMatch && (
<Badge variant="outline" className="shrink-0 border-primary/25 bg-primary/10 text-primary text-[10px]">
Known: {catalogMatch.name}
</Badge>
)}
</div>
<p className="text-[11px] text-muted-foreground mt-0.5">
{tx.posted_date}{account ? ` · ${account}` : ''}
{catalogMatch ? ` · ${TYPE_LABELS[catalogMatch.subscription_type] || 'Other'}` : ''}
</p>
</div>
<span className="font-mono text-sm font-semibold tabular-nums shrink-0">${dollars}</span>
{!isMatched && (
<Button size="sm" variant="outline" onClick={() => onTrack(tx)}
className="shrink-0 h-7 px-2 text-xs gap-1">
<Plus className="h-3 w-3" /> Track
</Button>
)}
</div>
);
}
function RecommendationCard({ recommendation, categoryId, onAccept, onDecline, onMatch, busy }) { function RecommendationCard({ recommendation, categoryId, onAccept, onDecline, onMatch, busy }) {
return ( return (
<div className="rounded-lg border border-primary/20 bg-primary/[0.05] p-4"> <div className="rounded-lg border border-primary/20 bg-primary/[0.05] p-4">
@ -270,7 +314,13 @@ export default function SubscriptionsPage() {
const [recommendationsLoading, setRecommendationsLoading] = useState(true); const [recommendationsLoading, setRecommendationsLoading] = useState(true);
const [busyId, setBusyId] = useState(null); const [busyId, setBusyId] = useState(null);
const [modal, setModal] = 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 subscriptionCategoryId = useMemo(() => {
const match = categories.find(category => /subscrip/i.test(category.name)); 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(() => {}); api.bills().then(b => setBills(Array.isArray(b) ? b : [])).catch(() => {});
}, [load, loadRecommendations]); }, [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() { async function refreshAll() {
await Promise.all([load(), loadRecommendations()]); 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 summary = data.summary || {};
const subscriptions = data.subscriptions || []; const subscriptions = data.subscriptions || [];
const active = subscriptions.filter(item => item.active); const active = subscriptions.filter(item => item.active);
const paused = 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 ( return (
<div className="mx-auto w-full max-w-6xl space-y-6"> <div className="mx-auto w-full max-w-6xl space-y-6">
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between"> <div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
@ -460,22 +560,55 @@ export default function SubscriptionsPage() {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Sparkles className="h-4 w-4 text-primary" /> <Sparkles className="h-4 w-4 text-primary" />
<CardTitle className="text-base">Recommendations</CardTitle> <CardTitle className="text-base">Recommendations</CardTitle>
{!recommendationsLoading && highConfidenceRecs.length > 0 && (
<span className="ml-auto text-[11px] font-medium text-muted-foreground tabular-nums">
{filteredRecs.length} of {highConfidenceRecs.length}
</span>
)}
</div> </div>
<CardDescription>Recurring unmatched bank charges that look like subscriptions.</CardDescription> <CardDescription>Recurring charges from your accounts with 90%+ confidence.</CardDescription>
{!recommendationsLoading && highConfidenceRecs.length > 0 && (
<div className="relative mt-2">
<Search className="absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground pointer-events-none" />
<Input
type="search"
placeholder="Search recommendations…"
value={recSearch}
onChange={e => setRecSearch(e.target.value)}
className="pl-8 h-8 text-sm"
/>
</div>
)}
</CardHeader> </CardHeader>
<CardContent className="space-y-3"> <CardContent className="space-y-3">
{recommendationsLoading ? ( {recommendationsLoading ? (
<div className="rounded-lg border border-border/60 bg-muted/20 px-4 py-10 text-center text-sm text-muted-foreground"> <div className="rounded-lg border border-border/60 bg-muted/20 px-4 py-10 text-center text-sm text-muted-foreground">
Scanning transactions... Scanning transactions
</div> </div>
) : recommendations.length === 0 ? ( ) : highConfidenceRecs.length === 0 ? (
<div className="rounded-lg border border-dashed border-border/70 px-4 py-10 text-center"> <div className="rounded-lg border border-dashed border-border/70 px-4 py-10 text-center">
<Bell className="mx-auto h-7 w-7 text-muted-foreground" /> <Bell className="mx-auto h-7 w-7 text-muted-foreground" />
<p className="mt-3 text-sm font-medium">No recommendations right now.</p> <p className="mt-3 text-sm font-medium">No high-confidence recommendations.</p>
<p className="mt-1 text-sm text-muted-foreground">Sync SimpleFIN after a few recurring charges appear.</p> <p className="mt-1 text-sm text-muted-foreground">
{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.'}
</p>
</div>
) : filteredRecs.length === 0 ? (
<div className="rounded-lg border border-dashed border-border/70 px-4 py-8 text-center">
<Search className="mx-auto h-6 w-6 text-muted-foreground" />
<p className="mt-3 text-sm font-medium">No matches for "{recSearch}"</p>
<button
type="button"
className="mt-1 text-xs text-primary hover:underline"
onClick={() => setRecSearch('')}
>
Clear search
</button>
</div> </div>
) : ( ) : (
recommendations.map(recommendation => ( filteredRecs.map(recommendation => (
<RecommendationCard <RecommendationCard
key={recommendation.id} key={recommendation.id}
recommendation={recommendation} recommendation={recommendation}
@ -491,6 +624,59 @@ export default function SubscriptionsPage() {
</Card> </Card>
</div> </div>
{/* Transaction search */}
<Card className="overflow-hidden">
<CardHeader className="px-4 pb-3 sm:px-6">
<div className="flex items-center gap-2">
<Search className="h-4 w-4 text-primary" />
<CardTitle className="text-base">Search Bank Transactions</CardTitle>
</div>
<CardDescription>
Search all account charges matched and unmatched to find subscriptions the algorithm may have missed.
</CardDescription>
<div className="relative mt-2">
<Search className="absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground pointer-events-none" />
<Input
type="search"
placeholder="Search by merchant, description, or payee…"
value={txQuery}
onChange={e => setTxQuery(e.target.value)}
className="pl-8 h-9 text-sm"
autoComplete="off"
/>
</div>
</CardHeader>
{(txQuery.trim() || txSearching) && (
<CardContent className="p-0">
{txSearching ? (
<div className="flex items-center justify-center gap-2 py-8 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
Searching transactions
</div>
) : txResults.length === 0 ? (
<div className="px-4 py-8 text-center">
<Search className="mx-auto h-7 w-7 text-muted-foreground" />
<p className="mt-3 text-sm font-medium">No transactions found for "{txQuery}"</p>
<p className="mt-1 text-xs text-muted-foreground">Try a different merchant name or description.</p>
</div>
) : (
<div>
<div className="px-4 py-2 border-b border-border/40 bg-muted/20">
<p className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
{txResults.length} result{txResults.length !== 1 ? 's' : ''}
{txResults.length === 50 ? ' (showing first 50)' : ''}
</p>
</div>
{txResults.map(tx => (
<TxResultRow key={tx.id} tx={tx} onTrack={openFromTransaction} />
))}
</div>
)}
</CardContent>
)}
</Card>
{modal && ( {modal && (
<BillModal <BillModal
key={modal.bill?.id ? `subscription-edit-${modal.bill.id}` : 'subscription-new'} key={modal.bill?.id ? `subscription-edit-${modal.bill.id}` : 'subscription-new'}

View File

@ -2447,6 +2447,31 @@ function runMigrations() {
if (!cols.includes('sort_order')) db.exec('ALTER TABLE bills ADD COLUMN sort_order INTEGER'); if (!cols.includes('sort_order')) db.exec('ALTER TABLE bills ADD COLUMN sort_order INTEGER');
db.exec('CREATE INDEX IF NOT EXISTS idx_bills_user_sort ON bills(user_id, active, sort_order, due_day)'); db.exec('CREATE INDEX IF NOT EXISTS idx_bills_user_sort ON bills(user_id, active, sort_order, due_day)');
} }
},
{
version: 'v0.73',
description: 'add snowball_plans table for plan lifecycle + history',
dependsOn: ['v0.72'],
run: function() {
db.exec(`
CREATE TABLE IF NOT EXISTS snowball_plans (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL DEFAULT 'Snowball Plan',
method TEXT NOT NULL DEFAULT 'snowball',
status TEXT NOT NULL DEFAULT 'active',
started_at TEXT NOT NULL DEFAULT (datetime('now')),
paused_at TEXT,
completed_at TEXT,
extra_payment REAL NOT NULL DEFAULT 0,
plan_snapshot TEXT NOT NULL,
notes TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)
`);
db.exec('CREATE INDEX IF NOT EXISTS idx_snowball_plans_user ON snowball_plans(user_id, status, created_at)');
}
} }
]; ];

View File

@ -185,7 +185,6 @@ CREATE TABLE IF NOT EXISTS notifications (
CREATE INDEX IF NOT EXISTS idx_notifications_lookup ON notifications(bill_id, user_id, year, month); CREATE INDEX IF NOT EXISTS idx_notifications_lookup ON notifications(bill_id, user_id, year, month);
CREATE INDEX IF NOT EXISTS idx_bills_active ON bills(active); CREATE INDEX IF NOT EXISTS idx_bills_active ON bills(active);
CREATE INDEX IF NOT EXISTS idx_bills_user_sort ON bills(user_id, active, sort_order, due_day);
CREATE INDEX IF NOT EXISTS idx_payments_bill_id ON payments(bill_id); CREATE INDEX IF NOT EXISTS idx_payments_bill_id ON payments(bill_id);
CREATE INDEX IF NOT EXISTS idx_payments_paid_date ON payments(paid_date); CREATE INDEX IF NOT EXISTS idx_payments_paid_date ON payments(paid_date);
CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id); CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id);
@ -273,3 +272,22 @@ CREATE TABLE IF NOT EXISTS advisory_bill_like_overrides (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
term TEXT NOT NULL UNIQUE term TEXT NOT NULL UNIQUE
); );
CREATE TABLE IF NOT EXISTS snowball_plans (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL DEFAULT 'Snowball Plan',
method TEXT NOT NULL DEFAULT 'snowball',
status TEXT NOT NULL DEFAULT 'active',
started_at TEXT NOT NULL DEFAULT (datetime('now')),
paused_at TEXT,
completed_at TEXT,
extra_payment REAL NOT NULL DEFAULT 0,
plan_snapshot TEXT NOT NULL,
notes TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_snowball_plans_user
ON snowball_plans(user_id, status, created_at);

View File

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

View File

@ -228,4 +228,236 @@ router.patch('/order', (req, res) => {
res.json({ success: true }); 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; module.exports = router;

View File

@ -9,6 +9,7 @@ const {
getSubscriptionRecommendations, getSubscriptionRecommendations,
getSubscriptionSummary, getSubscriptionSummary,
getSubscriptions, getSubscriptions,
searchSubscriptionTransactions,
} = require('../services/subscriptionService'); } = require('../services/subscriptionService');
const { addMerchantRule, applyMerchantRules } = require('../services/billMerchantRuleService'); 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) => { router.post('/recommendations/decline', (req, res) => {
const { decline_key } = req.body || {}; const { decline_key } = req.body || {};
if (!decline_key || typeof decline_key !== 'string' || decline_key.length > 200) { if (!decline_key || typeof decline_key !== 'string' || decline_key.length > 200) {

View File

@ -42,12 +42,51 @@ const TYPE_KEYWORDS = [
function loadCatalog(db) { function loadCatalog(db) {
try { 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 { } catch {
return []; 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) { function normalizeCatalogName(value) {
return String(value || '') return String(value || '')
.toLowerCase() .toLowerCase()
@ -62,17 +101,30 @@ function normalizeCatalogName(value) {
function lookupCatalog(catalog, merchantText) { function lookupCatalog(catalog, merchantText) {
if (!catalog.length || !merchantText) return null; if (!catalog.length || !merchantText) return null;
let best = null; let best = null;
let bestLen = 0; let bestScore = 0;
const merchantCompact = compactCatalogKey(merchantText);
for (const entry of catalog) { for (const entry of catalog) {
const nameKey = normalizeCatalogName(entry.name); const nameKey = normalizeCatalogName(entry.name);
const domainKey = entry.domain ? entry.domain.replace(/\./g, ' ') : ''; const nameCompact = compactCatalogKey(entry.name);
if (nameKey.length >= 3 && merchantText.includes(nameKey) && nameKey.length > bestLen) { const nameScore = 1000 + nameKey.length;
if (
nameKey.length >= 3
&& (merchantText.includes(nameKey) || (nameCompact.length >= 5 && merchantCompact.includes(nameCompact)))
&& nameScore > bestScore
) {
best = entry; best = entry;
bestLen = nameKey.length; bestScore = nameScore;
} }
if (domainKey.length >= 4 && merchantText.includes(domainKey) && domainKey.length > bestLen) { for (const domainKey of catalogDomainKeys(entry)) {
best = entry; const domainCompact = domainKey.replace(/\s+/g, '');
bestLen = domainKey.length; const domainScore = 500 + domainKey.length;
if (
(merchantText.includes(domainKey) || (domainCompact.length >= 5 && merchantCompact.includes(domainCompact)))
&& domainScore > bestScore
) {
best = entry;
bestScore = domainScore;
}
} }
} }
return best; return best;
@ -98,15 +150,30 @@ function titleCase(value) {
.join(' '); .join(' ');
} }
function inferType(merchantText, catalogEntry) { function inferType(merchantText, catalogEntry, catalogTypeMap = null) {
if (catalogEntry?.subscription_type) return catalogEntry.subscription_type; if (catalogEntry?.subscription_type) return catalogEntry.subscription_type;
const haystack = normalizeMerchant(merchantText); 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) { for (const [type, words] of TYPE_KEYWORDS) {
if (words.some(word => haystack.includes(word))) return type; if (words.some(word => haystack.includes(word))) return type;
} }
return 'other'; 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) { function monthlyEquivalent(amount, cycleType, billingCycle) {
const key = String(cycleType || billingCycle || 'monthly').toLowerCase(); const key = String(cycleType || billingCycle || 'monthly').toLowerCase();
const fallback = String(billingCycle || '').toLowerCase() === 'quarterly' const fallback = String(billingCycle || '').toLowerCase() === 'quarterly'
@ -223,6 +290,7 @@ function declineRecommendation(db, userId, declineKey) {
function getSubscriptionRecommendations(db, userId) { function getSubscriptionRecommendations(db, userId) {
const catalog = loadCatalog(db); const catalog = loadCatalog(db);
const catalogTypeMap = buildCatalogTypeMap(catalog);
const existingNames = existingBillNames(db, userId); const existingNames = existingBillNames(db, userId);
const declined = getDeclinedKeys(db, userId); const declined = getDeclinedKeys(db, userId);
@ -286,7 +354,7 @@ function getSubscriptionRecommendations(db, userId) {
if (catalogEntry && sorted.length === 1) { if (catalogEntry && sorted.length === 1) {
recommendations.push(buildRecommendation({ recommendations.push(buildRecommendation({
merchant, catalogEntry, sorted, averageAmount, maxDelta, last, 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; continue;
} }
@ -321,7 +389,7 @@ function getSubscriptionRecommendations(db, userId) {
const tier = catalogEntry ? 'confirmed' : 'pattern'; const tier = catalogEntry ? 'confirmed' : 'pattern';
recommendations.push(buildRecommendation({ recommendations.push(buildRecommendation({
merchant, catalogEntry, sorted, averageAmount, maxDelta, last, 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); 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 name = catalogEntry ? catalogEntry.name : titleCase(merchant);
const subscriptionType = inferType(merchant, catalogEntry); const subscriptionType = inferType(merchant, catalogEntry, catalogTypeMap);
const reasons = []; const reasons = [];
if (catalogEntry) reasons.push(`Matches known service: ${catalogEntry.name}`); if (catalogEntry) reasons.push(`Matches known service: ${catalogEntry.name}`);
@ -362,7 +430,7 @@ function buildRecommendation({ merchant, catalogEntry, sorted, averageAmount, ma
occurrence_count: sorted.length, occurrence_count: sorted.length,
confidence, confidence,
tier, 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), transaction_ids: sorted.map(item => item.id),
merchant, merchant,
decline_key: declineKey, 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 = {}) { function createSubscriptionFromRecommendation(db, userId, payload = {}) {
const seenDate = payload.last_seen_date || new Date().toISOString().slice(0, 10); const seenDate = payload.last_seen_date || new Date().toISOString().slice(0, 10);
const source = payload.catalog_match const source = payload.catalog_match
@ -450,4 +564,5 @@ module.exports = {
loadCatalog, loadCatalog,
monthlyEquivalent, monthlyEquivalent,
normalizeMerchant, normalizeMerchant,
searchSubscriptionTransactions,
}; };

View File

@ -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');
});