chore: reset tracked db file
This commit is contained in:
parent
5449427b86
commit
35d0cbf8be
21
FUTURE.md
21
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
|
||||
|
||||
|
|
|
|||
43
HISTORY.md
43
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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
|
|
@ -211,6 +212,14 @@ export const api = {
|
|||
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'),
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<div className="space-y-6">
|
||||
|
||||
{/* Active plan banner */}
|
||||
{activePlan && (
|
||||
<PlanStatusBanner
|
||||
plan={activePlan}
|
||||
onPause={handlePausePlan}
|
||||
onResume={handleResumePlan}
|
||||
onComplete={handleCompletePlan}
|
||||
onAbandon={handleAbandonPlan}
|
||||
onNewPlan={handleStartPlan}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight flex items-center gap-2">
|
||||
|
|
@ -763,6 +838,7 @@ export default function SnowballPage() {
|
|||
)}
|
||||
|
||||
{bills.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<ReadinessStrip
|
||||
items={readinessItems}
|
||||
readyCount={readinessReadyCount}
|
||||
|
|
@ -771,6 +847,15 @@ export default function SnowballPage() {
|
|||
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) && (
|
||||
|
|
@ -1017,6 +1102,9 @@ export default function SnowballPage() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Plan history */}
|
||||
<PlanHistoryPanel plans={allPlans} />
|
||||
|
||||
{/* Edit modal */}
|
||||
{editBill && (
|
||||
<BillModal
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
Bell,
|
||||
CalendarDays,
|
||||
CheckCircle2,
|
||||
CheckCircle,
|
||||
Cloud,
|
||||
Link2,
|
||||
Loader2,
|
||||
|
|
@ -11,6 +12,7 @@ import {
|
|||
Plus,
|
||||
RefreshCw,
|
||||
Repeat,
|
||||
Search,
|
||||
Sparkles,
|
||||
X,
|
||||
} 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 }) {
|
||||
return (
|
||||
<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 [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 (
|
||||
<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">
|
||||
|
|
@ -460,22 +560,55 @@ export default function SubscriptionsPage() {
|
|||
<div className="flex items-center gap-2">
|
||||
<Sparkles className="h-4 w-4 text-primary" />
|
||||
<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>
|
||||
<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>
|
||||
<CardContent className="space-y-3">
|
||||
{recommendationsLoading ? (
|
||||
<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>
|
||||
) : recommendations.length === 0 ? (
|
||||
) : highConfidenceRecs.length === 0 ? (
|
||||
<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" />
|
||||
<p className="mt-3 text-sm font-medium">No recommendations right now.</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">Sync SimpleFIN after a few recurring charges appear.</p>
|
||||
<p className="mt-3 text-sm font-medium">No high-confidence recommendations.</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>
|
||||
) : (
|
||||
recommendations.map(recommendation => (
|
||||
filteredRecs.map(recommendation => (
|
||||
<RecommendationCard
|
||||
key={recommendation.id}
|
||||
recommendation={recommendation}
|
||||
|
|
@ -491,6 +624,59 @@ export default function SubscriptionsPage() {
|
|||
</Card>
|
||||
</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 && (
|
||||
<BillModal
|
||||
key={modal.bill?.id ? `subscription-edit-${modal.bill.id}` : 'subscription-new'}
|
||||
|
|
|
|||
|
|
@ -2447,6 +2447,31 @@ function runMigrations() {
|
|||
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)');
|
||||
}
|
||||
},
|
||||
{
|
||||
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)');
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -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_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_paid_date ON payments(paid_date);
|
||||
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,
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "bill-tracker",
|
||||
"version": "0.34.3",
|
||||
"version": "0.34.1",
|
||||
"description": "Monthly bill tracking system",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
|
|
|
|||
|
|
@ -228,4 +228,236 @@ router.patch('/order', (req, res) => {
|
|||
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;
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
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;
|
||||
bestLen = domainKey.length;
|
||||
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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
Loading…
Reference in New Issue