feat: hybrid subscription tracker
Added subscription metadata to bills: is_subscription, type, reminder_days, source, detected_at Backend subscription API (routes/subscriptions.js) SimpleFIN recommendation logic (services/subscriptionService.js) New /subscriptions page (client/pages/SubscriptionsPage.jsx) Track-as-subscription controls in BillModal.jsx Navigation under Tracker menu Accepting a recommendation creates a subscription-backed bill + links detected transactions
This commit is contained in:
parent
22df64e5e7
commit
7a58d69c70
|
|
@ -30,6 +30,7 @@ const TrackerPage = lazy(() => import('@/pages/TrackerPage'));
|
|||
const CalendarPage = lazy(() => import('@/pages/CalendarPage'));
|
||||
const SummaryPage = lazy(() => import('@/pages/SummaryPage'));
|
||||
const BillsPage = lazy(() => import('@/pages/BillsPage'));
|
||||
const SubscriptionsPage = lazy(() => import('@/pages/SubscriptionsPage'));
|
||||
const CategoriesPage = lazy(() => import('@/pages/CategoriesPage'));
|
||||
const SettingsPage = lazy(() => import('@/pages/SettingsPage'));
|
||||
const StatusPage = lazy(() => import('@/pages/StatusPage'));
|
||||
|
|
@ -202,6 +203,7 @@ export default function App() {
|
|||
<Route path="calendar" element={<ErrorBoundary><Suspense fallback={<PageLoader />}><CalendarPage /></Suspense></ErrorBoundary>} />
|
||||
<Route path="summary" element={<ErrorBoundary><Suspense fallback={<PageLoader />}><SummaryPage /></Suspense></ErrorBoundary>} />
|
||||
<Route path="bills" element={<ErrorBoundary><Suspense fallback={<PageLoader />}><BillsPage /></Suspense></ErrorBoundary>} />
|
||||
<Route path="subscriptions" element={<ErrorBoundary><Suspense fallback={<PageLoader />}><SubscriptionsPage /></Suspense></ErrorBoundary>} />
|
||||
<Route path="categories" element={<ErrorBoundary><Suspense fallback={<PageLoader />}><CategoriesPage /></Suspense></ErrorBoundary>} />
|
||||
<Route path="health" element={<ErrorBoundary><Suspense fallback={<PageLoader />}><HealthPage /></Suspense></ErrorBoundary>} />
|
||||
<Route path="analytics" element={<ErrorBoundary><Suspense fallback={<PageLoader />}><AnalyticsPage /></Suspense></ErrorBoundary>} />
|
||||
|
|
|
|||
|
|
@ -179,6 +179,12 @@ export const api = {
|
|||
saveBillTemplate: (data) => post('/bills/templates', data),
|
||||
deleteBillTemplate: (id) => del(`/bills/templates/${id}`),
|
||||
|
||||
// Subscriptions
|
||||
subscriptions: () => get('/subscriptions'),
|
||||
subscriptionRecommendations: () => get('/subscriptions/recommendations'),
|
||||
updateSubscription: (id, data) => _fetch('PATCH', `/subscriptions/${id}`, data),
|
||||
createSubscriptionFromRecommendation: (data) => post('/subscriptions/recommendations/create', data),
|
||||
|
||||
// Payments
|
||||
quickPay: (data) => post('/payments/quick', data),
|
||||
confirmAutopaySuggestion: (billId, data) => post(`/payments/autopay-suggestions/${billId}/confirm`, data),
|
||||
|
|
|
|||
|
|
@ -44,6 +44,18 @@ const PAYMENT_METHODS = [
|
|||
|
||||
const DEBT_KEYWORDS = ['credit', 'loan', 'mortgage', 'housing', 'debt'];
|
||||
const SNOWBALL_KEYWORDS = ['credit', 'loan', 'debt'];
|
||||
const SUBSCRIPTION_TYPES = [
|
||||
['streaming', 'Streaming'],
|
||||
['software', 'Software'],
|
||||
['cloud', 'Cloud'],
|
||||
['music', 'Music'],
|
||||
['news', 'News'],
|
||||
['fitness', 'Fitness'],
|
||||
['gaming', 'Gaming'],
|
||||
['utilities', 'Utilities'],
|
||||
['insurance', 'Insurance'],
|
||||
['other', 'Other'],
|
||||
];
|
||||
|
||||
function fmtTransactionAmount(amount, currency = 'USD') {
|
||||
const cents = Number(amount || 0);
|
||||
|
|
@ -113,6 +125,9 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa
|
|||
const [autopay, setAutopay] = useState(!!sourceBill?.autopay_enabled);
|
||||
const [autodraftStatus, setAutodraftStatus] = useState(sourceBill?.autodraft_status || (sourceBill?.autopay_enabled ? 'assumed_paid' : 'none'));
|
||||
const [autoMarkPaid, setAutoMarkPaid] = useState(!!sourceBill?.auto_mark_paid);
|
||||
const [isSubscription, setIsSubscription] = useState(!!sourceBill?.is_subscription);
|
||||
const [subscriptionType, setSubscriptionType] = useState(sourceBill?.subscription_type || 'other');
|
||||
const [reminderDaysBefore, setReminderDaysBefore] = useState(String(sourceBill?.reminder_days_before ?? 3));
|
||||
const [has2fa, setHas2fa] = useState(!!sourceBill?.has_2fa);
|
||||
const [website, setWebsite] = useState(sourceBill?.website || '');
|
||||
const [username, setUsername] = useState(sourceBill?.username || '');
|
||||
|
|
@ -427,6 +442,11 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa
|
|||
autopay_enabled: autopay,
|
||||
autodraft_status: autopay ? autodraftStatus : 'none',
|
||||
auto_mark_paid: canAutoMarkPaid && autoMarkPaid,
|
||||
is_subscription: isSubscription,
|
||||
subscription_type: isSubscription ? subscriptionType : null,
|
||||
reminder_days_before: isSubscription ? parseInt(reminderDaysBefore || '3', 10) : 3,
|
||||
subscription_source: sourceBill?.subscription_source || 'manual',
|
||||
subscription_detected_at: sourceBill?.subscription_detected_at,
|
||||
has_2fa: has2fa,
|
||||
website: website || null,
|
||||
username: username || null,
|
||||
|
|
@ -639,6 +659,56 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa
|
|||
</p>
|
||||
</div>
|
||||
|
||||
{/* Subscription Details */}
|
||||
<div className="col-span-2 rounded-lg border border-border/60 bg-muted/20 p-3">
|
||||
<label className="flex items-start gap-2.5 cursor-pointer group">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSubscription}
|
||||
onChange={e => setIsSubscription(e.target.checked)}
|
||||
className="mt-0.5 h-4 w-4 rounded border-border accent-primary"
|
||||
/>
|
||||
<span>
|
||||
<span className="block text-sm font-medium text-muted-foreground group-hover:text-foreground transition-colors">
|
||||
Track as subscription
|
||||
</span>
|
||||
<span className="mt-0.5 block text-[10px] text-muted-foreground/70">
|
||||
Adds this bill to the subscription view with monthly equivalent cost and SimpleFIN recommendation matching.
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{isSubscription && (
|
||||
<div className="mt-3 grid gap-3 border-t border-border/40 pt-3 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs uppercase tracking-wider text-muted-foreground">Subscription Type</Label>
|
||||
<Select value={subscriptionType} onValueChange={setSubscriptionType}>
|
||||
<SelectTrigger className={cn(inp, 'w-full')}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SUBSCRIPTION_TYPES.map(([value, label]) => (
|
||||
<SelectItem key={value} value={value}>{label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs uppercase tracking-wider text-muted-foreground">Reminder Days</Label>
|
||||
<Input
|
||||
className={cn(inp, 'tracker-number')}
|
||||
type="number"
|
||||
min="0"
|
||||
max="30"
|
||||
value={reminderDaysBefore}
|
||||
onChange={e => setReminderDaysBefore(e.target.value)}
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground/70">0-30 days before renewal.</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Debt / Snowball Details — collapsible */}
|
||||
<div className="col-span-2">
|
||||
<button
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { NavLink, useLocation, useNavigate } from 'react-router-dom';
|
|||
import {
|
||||
Activity, BarChart3, CalendarDays, ChevronDown, ClipboardCheck, ClipboardList, Database, Info, LayoutGrid, LogOut, Map, Menu, Receipt,
|
||||
Search, Settings, ShieldCheck, Tag, TrendingDown, User, X,
|
||||
Repeat,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
|
|
@ -34,6 +35,7 @@ const trackerItems = [
|
|||
{ to: '/', icon: LayoutGrid, label: 'Overview', end: true },
|
||||
{ to: '/summary', icon: ClipboardList, label: 'Summary' },
|
||||
{ to: '/bills', icon: Receipt, label: 'Bills' },
|
||||
{ to: '/subscriptions', icon: Repeat, label: 'Subscriptions' },
|
||||
{ to: '/categories', icon: Tag, label: 'Categories' },
|
||||
{ to: '/health', icon: ClipboardCheck, label: 'Health' },
|
||||
{ to: '/snowball', icon: TrendingDown, label: 'Snowball' },
|
||||
|
|
|
|||
|
|
@ -0,0 +1,369 @@
|
|||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
Bell,
|
||||
CalendarDays,
|
||||
CheckCircle2,
|
||||
Cloud,
|
||||
Loader2,
|
||||
Pause,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Repeat,
|
||||
Sparkles,
|
||||
} from 'lucide-react';
|
||||
import { api } from '@/api';
|
||||
import { cn, fmt, fmtDate } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import BillModal from '@/components/BillModal';
|
||||
|
||||
const TYPE_LABELS = {
|
||||
streaming: 'Streaming',
|
||||
software: 'Software',
|
||||
cloud: 'Cloud',
|
||||
music: 'Music',
|
||||
news: 'News',
|
||||
fitness: 'Fitness',
|
||||
gaming: 'Gaming',
|
||||
utilities: 'Utilities',
|
||||
insurance: 'Insurance',
|
||||
other: 'Other',
|
||||
};
|
||||
|
||||
function cycleLabel(item) {
|
||||
const cycle = item.cycle_type || item.billing_cycle || 'monthly';
|
||||
return cycle === 'annual' || cycle === 'annually' ? 'yearly' : cycle;
|
||||
}
|
||||
|
||||
function StatCard({ icon: Icon, label, value, hint }) {
|
||||
return (
|
||||
<div className="rounded-lg border border-border/70 bg-card/80 p-4">
|
||||
<div className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-foreground/70">
|
||||
<Icon className="h-4 w-4 text-primary" />
|
||||
{label}
|
||||
</div>
|
||||
<p className="tracker-number mt-2 text-2xl font-bold tracking-tight text-foreground">{value}</p>
|
||||
{hint && <p className="mt-1 text-xs font-medium text-muted-foreground">{hint}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SubscriptionRow({ item, onEdit, onToggle }) {
|
||||
return (
|
||||
<div className={cn(
|
||||
'flex flex-col gap-3 border-b border-border/40 px-4 py-4 last:border-b-0 sm:flex-row sm:items-center',
|
||||
!item.active && 'opacity-60',
|
||||
)}>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onEdit(item)}
|
||||
className="truncate text-left text-[15px] font-semibold text-foreground hover:text-primary"
|
||||
>
|
||||
{item.name}
|
||||
</button>
|
||||
<Badge variant="outline" className="capitalize border-primary/25 bg-primary/10 text-primary">
|
||||
{TYPE_LABELS[item.subscription_type] || 'Other'}
|
||||
</Badge>
|
||||
{!item.active && (
|
||||
<Badge variant="outline" className="border-amber-500/25 bg-amber-500/10 text-amber-300">
|
||||
Paused
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs font-medium text-muted-foreground">
|
||||
<span>{item.category_name || 'Uncategorized'}</span>
|
||||
<span>Due {fmtDate(item.next_due_date)}</span>
|
||||
<span className="capitalize">{cycleLabel(item)}</span>
|
||||
<span>{item.reminder_days_before ?? 3}d reminder</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 sm:w-[260px]">
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Per cycle</p>
|
||||
<p className="tracker-number text-sm font-semibold text-foreground">{fmt(item.expected_amount)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Monthly</p>
|
||||
<p className="tracker-number text-sm font-semibold text-emerald-600 dark:text-emerald-300">{fmt(item.monthly_equivalent)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 sm:justify-end">
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => onEdit(item)}>
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={item.active ? 'text-amber-300 hover:bg-amber-500/10 hover:text-amber-200' : 'text-emerald-300 hover:bg-emerald-500/10 hover:text-emerald-200'}
|
||||
onClick={() => onToggle(item)}
|
||||
>
|
||||
{item.active ? 'Pause' : 'Resume'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RecommendationCard({ recommendation, categoryId, onAccept, busy }) {
|
||||
return (
|
||||
<div className="rounded-lg border border-primary/20 bg-primary/[0.05] p-4">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<p className="truncate text-sm font-semibold text-foreground">{recommendation.name}</p>
|
||||
<Badge variant="outline" className="border-primary/25 bg-primary/10 text-primary">
|
||||
{recommendation.confidence}% match
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="mt-1 text-xs font-medium text-muted-foreground">
|
||||
{TYPE_LABELS[recommendation.subscription_type] || 'Other'} · {recommendation.occurrence_count} charges · last seen {fmtDate(recommendation.last_seen_date)}
|
||||
</p>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{recommendation.reasons?.map(reason => (
|
||||
<span key={reason} className="rounded-md border border-border/60 bg-background/60 px-2 py-1 text-[11px] font-medium text-muted-foreground">
|
||||
{reason}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="shrink-0 text-left sm:text-right">
|
||||
<p className="tracker-number text-base font-semibold text-foreground">{fmt(recommendation.expected_amount)}</p>
|
||||
<p className="tracker-number text-xs font-semibold text-emerald-600 dark:text-emerald-300">
|
||||
{fmt(recommendation.monthly_equivalent)} / mo
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="mt-3 gap-2"
|
||||
disabled={busy}
|
||||
onClick={() => onAccept({ ...recommendation, category_id: categoryId })}
|
||||
>
|
||||
{busy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <CheckCircle2 className="h-3.5 w-3.5" />}
|
||||
Track
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SubscriptionsPage() {
|
||||
const [data, setData] = useState({ summary: {}, subscriptions: [] });
|
||||
const [recommendations, setRecommendations] = useState([]);
|
||||
const [categories, setCategories] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [recommendationsLoading, setRecommendationsLoading] = useState(true);
|
||||
const [busyId, setBusyId] = useState(null);
|
||||
const [modal, setModal] = useState(null);
|
||||
|
||||
const subscriptionCategoryId = useMemo(() => {
|
||||
const match = categories.find(category => /subscrip/i.test(category.name));
|
||||
return match?.id || null;
|
||||
}, [categories]);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [subscriptionData, categoryData] = await Promise.all([
|
||||
api.subscriptions(),
|
||||
api.categories(),
|
||||
]);
|
||||
setData(subscriptionData);
|
||||
setCategories(categoryData || []);
|
||||
} catch (err) {
|
||||
toast.error(err.message || 'Failed to load subscriptions.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadRecommendations = useCallback(async () => {
|
||||
setRecommendationsLoading(true);
|
||||
try {
|
||||
const result = await api.subscriptionRecommendations();
|
||||
setRecommendations(result.recommendations || []);
|
||||
} catch (err) {
|
||||
toast.error(err.message || 'Failed to scan subscription recommendations.');
|
||||
} finally {
|
||||
setRecommendationsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
loadRecommendations();
|
||||
}, [load, loadRecommendations]);
|
||||
|
||||
async function refreshAll() {
|
||||
await Promise.all([load(), loadRecommendations()]);
|
||||
}
|
||||
|
||||
async function toggleSubscription(item) {
|
||||
setBusyId(`toggle-${item.id}`);
|
||||
try {
|
||||
await api.updateSubscription(item.id, { active: !item.active });
|
||||
toast.success(item.active ? 'Subscription paused' : 'Subscription resumed');
|
||||
await load();
|
||||
} catch (err) {
|
||||
toast.error(err.message || 'Subscription could not be updated.');
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function acceptRecommendation(recommendation) {
|
||||
setBusyId(`rec-${recommendation.id}`);
|
||||
try {
|
||||
await api.createSubscriptionFromRecommendation(recommendation);
|
||||
toast.success(`${recommendation.name} is now tracked.`);
|
||||
await refreshAll();
|
||||
} catch (err) {
|
||||
toast.error(err.message || 'Could not create subscription.');
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
}
|
||||
|
||||
function openManualSubscription() {
|
||||
setModal({
|
||||
bill: null,
|
||||
initialBill: {
|
||||
name: '',
|
||||
category_id: subscriptionCategoryId,
|
||||
due_day: new Date().getDate(),
|
||||
expected_amount: '',
|
||||
billing_cycle: 'monthly',
|
||||
cycle_type: 'monthly',
|
||||
cycle_day: String(new Date().getDate()),
|
||||
is_subscription: 1,
|
||||
subscription_type: 'other',
|
||||
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);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div>
|
||||
<p className="mb-1 text-[11px] font-semibold uppercase tracking-[0.15em] text-muted-foreground">
|
||||
Recurring Services
|
||||
</p>
|
||||
<h1 className="text-3xl font-semibold tracking-tight">Subscriptions</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Track manual subscriptions and review recurring SimpleFIN charges.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button type="button" variant="outline" className="gap-2" onClick={refreshAll} disabled={loading || recommendationsLoading}>
|
||||
<RefreshCw className={cn('h-4 w-4', (loading || recommendationsLoading) && 'animate-spin')} />
|
||||
Refresh
|
||||
</Button>
|
||||
<Button type="button" className="gap-2" onClick={openManualSubscription}>
|
||||
<Plus className="h-4 w-4" />
|
||||
Add Subscription
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<StatCard icon={Repeat} label="Monthly" value={fmt(summary.monthly_total)} hint={`${summary.active_count || 0} active subscriptions`} />
|
||||
<StatCard icon={CalendarDays} label="Yearly" value={fmt(summary.yearly_total)} hint="Normalized yearly impact" />
|
||||
<StatCard icon={Pause} label="Paused" value={summary.paused_count || 0} hint="Kept but not active" />
|
||||
<StatCard icon={Cloud} label="Top Type" value={summary.top_type ? TYPE_LABELS[summary.top_type.type] || summary.top_type.type : '—'} hint={summary.top_type ? `${fmt(summary.top_type.monthly_total)} / mo` : 'No active subscriptions'} />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-5 xl:grid-cols-[minmax(0,1fr)_420px]">
|
||||
<Card className="overflow-hidden">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">Tracked Subscriptions</CardTitle>
|
||||
<CardDescription>Subscriptions are bills with recurring-service metadata.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
{loading ? (
|
||||
<div className="p-4">
|
||||
{Array.from({ length: 4 }).map((_, index) => (
|
||||
<div key={index} className="mb-2 h-20 animate-pulse rounded-lg bg-muted/40" />
|
||||
))}
|
||||
</div>
|
||||
) : subscriptions.length === 0 ? (
|
||||
<div className="px-4 py-14 text-center">
|
||||
<Repeat className="mx-auto h-8 w-8 text-muted-foreground" />
|
||||
<p className="mt-3 text-sm font-medium">No subscriptions tracked yet.</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">Add one manually or accept a SimpleFIN recommendation.</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{active.map(item => (
|
||||
<SubscriptionRow key={item.id} item={item} onEdit={bill => setModal({ bill })} onToggle={toggleSubscription} />
|
||||
))}
|
||||
{paused.map(item => (
|
||||
<SubscriptionRow key={item.id} item={item} onEdit={bill => setModal({ bill })} onToggle={toggleSubscription} />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="overflow-hidden">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles className="h-4 w-4 text-primary" />
|
||||
<CardTitle className="text-base">SimpleFIN Recommendations</CardTitle>
|
||||
</div>
|
||||
<CardDescription>Recurring unmatched bank charges that look like subscriptions.</CardDescription>
|
||||
</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...
|
||||
</div>
|
||||
) : recommendations.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>
|
||||
</div>
|
||||
) : (
|
||||
recommendations.map(recommendation => (
|
||||
<RecommendationCard
|
||||
key={recommendation.id}
|
||||
recommendation={recommendation}
|
||||
categoryId={subscriptionCategoryId}
|
||||
busy={busyId === `rec-${recommendation.id}`}
|
||||
onAccept={acceptRecommendation}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{modal && (
|
||||
<BillModal
|
||||
key={modal.bill?.id ? `subscription-edit-${modal.bill.id}` : 'subscription-new'}
|
||||
bill={modal.bill}
|
||||
initialBill={modal.initialBill}
|
||||
categories={categories}
|
||||
onClose={() => setModal(null)}
|
||||
onSave={async () => {
|
||||
setModal(null);
|
||||
await refreshAll();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -48,7 +48,8 @@ const COLUMN_WHITELIST = new Set([
|
|||
// bills table columns
|
||||
'history_visibility', 'interest_rate', 'user_id',
|
||||
'current_balance', 'minimum_payment', 'snowball_order', 'snowball_include',
|
||||
'snowball_exempt', 'deleted_at',
|
||||
'snowball_exempt', 'is_subscription', 'subscription_type', 'reminder_days_before',
|
||||
'subscription_source', 'subscription_detected_at', 'deleted_at',
|
||||
// sessions table columns
|
||||
'created_at',
|
||||
]);
|
||||
|
|
@ -1021,6 +1022,25 @@ function reconcileLegacyMigrations() {
|
|||
`);
|
||||
console.log('[migration] match suggestion rejections table ensured');
|
||||
}
|
||||
},
|
||||
{
|
||||
version: 'v0.63',
|
||||
description: 'bills: subscription metadata fields',
|
||||
check: function() {
|
||||
const cols = db.prepare('PRAGMA table_info(bills)').all().map(c => c.name);
|
||||
return ['is_subscription', 'subscription_type', 'reminder_days_before', 'subscription_source', 'subscription_detected_at']
|
||||
.every(col => cols.includes(col));
|
||||
},
|
||||
run: function() {
|
||||
const cols = db.prepare('PRAGMA table_info(bills)').all().map(c => c.name);
|
||||
if (!cols.includes('is_subscription')) db.exec('ALTER TABLE bills ADD COLUMN is_subscription INTEGER NOT NULL DEFAULT 0');
|
||||
if (!cols.includes('subscription_type')) db.exec('ALTER TABLE bills ADD COLUMN subscription_type TEXT');
|
||||
if (!cols.includes('reminder_days_before')) db.exec('ALTER TABLE bills ADD COLUMN reminder_days_before INTEGER NOT NULL DEFAULT 3');
|
||||
if (!cols.includes('subscription_source')) db.exec("ALTER TABLE bills ADD COLUMN subscription_source TEXT NOT NULL DEFAULT 'manual'");
|
||||
if (!cols.includes('subscription_detected_at')) db.exec('ALTER TABLE bills ADD COLUMN subscription_detected_at TEXT');
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_bills_user_subscription ON bills(user_id, is_subscription, active)');
|
||||
console.log('[migration] bills: subscription metadata columns added');
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
|
|
@ -1769,6 +1789,21 @@ function runMigrations() {
|
|||
`);
|
||||
console.log('[migration] match suggestion rejections table ensured');
|
||||
}
|
||||
},
|
||||
{
|
||||
version: 'v0.63',
|
||||
description: 'bills: subscription metadata fields',
|
||||
dependsOn: ['v0.62'],
|
||||
run: function() {
|
||||
const cols = db.prepare('PRAGMA table_info(bills)').all().map(c => c.name);
|
||||
if (!cols.includes('is_subscription')) db.exec('ALTER TABLE bills ADD COLUMN is_subscription INTEGER NOT NULL DEFAULT 0');
|
||||
if (!cols.includes('subscription_type')) db.exec('ALTER TABLE bills ADD COLUMN subscription_type TEXT');
|
||||
if (!cols.includes('reminder_days_before')) db.exec('ALTER TABLE bills ADD COLUMN reminder_days_before INTEGER NOT NULL DEFAULT 3');
|
||||
if (!cols.includes('subscription_source')) db.exec("ALTER TABLE bills ADD COLUMN subscription_source TEXT NOT NULL DEFAULT 'manual'");
|
||||
if (!cols.includes('subscription_detected_at')) db.exec('ALTER TABLE bills ADD COLUMN subscription_detected_at TEXT');
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_bills_user_subscription ON bills(user_id, is_subscription, active)');
|
||||
console.log('[migration] bills: subscription metadata columns added');
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
|
|
@ -2192,6 +2227,17 @@ const ROLLBACK_SQL_MAP = {
|
|||
'DROP TABLE IF EXISTS match_suggestion_rejections',
|
||||
]
|
||||
},
|
||||
'v0.63': {
|
||||
description: 'bills: subscription metadata fields',
|
||||
sql: [
|
||||
'DROP INDEX IF EXISTS idx_bills_user_subscription',
|
||||
'ALTER TABLE bills DROP COLUMN subscription_detected_at',
|
||||
'ALTER TABLE bills DROP COLUMN subscription_source',
|
||||
'ALTER TABLE bills DROP COLUMN reminder_days_before',
|
||||
'ALTER TABLE bills DROP COLUMN subscription_type',
|
||||
'ALTER TABLE bills DROP COLUMN is_subscription',
|
||||
]
|
||||
},
|
||||
'v0.51': {
|
||||
description: 'bills: snowball_exempt column',
|
||||
sql: ['ALTER TABLE bills DROP COLUMN snowball_exempt']
|
||||
|
|
|
|||
|
|
@ -34,6 +34,11 @@ CREATE TABLE IF NOT EXISTS bills (
|
|||
snowball_order INTEGER,
|
||||
snowball_include INTEGER NOT NULL DEFAULT 0,
|
||||
snowball_exempt INTEGER NOT NULL DEFAULT 0,
|
||||
is_subscription INTEGER NOT NULL DEFAULT 0,
|
||||
subscription_type TEXT,
|
||||
reminder_days_before INTEGER NOT NULL DEFAULT 3,
|
||||
subscription_source TEXT NOT NULL DEFAULT 'manual',
|
||||
subscription_detected_at TEXT,
|
||||
deleted_at TEXT,
|
||||
notes TEXT,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "bill-tracker",
|
||||
"version": "0.29.3",
|
||||
"version": "0.30.0",
|
||||
"description": "Monthly bill tracking system",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
|
|
|
|||
|
|
@ -302,6 +302,7 @@ router.put('/:id', (req, res) => {
|
|||
website = ?, username = ?, account_info = ?, has_2fa = ?, notes = ?, active = ?,
|
||||
history_visibility = ?, cycle_type = ?, cycle_day = ?,
|
||||
current_balance = ?, minimum_payment = ?, snowball_order = ?, snowball_include = ?, snowball_exempt = ?,
|
||||
is_subscription = ?, subscription_type = ?, reminder_days_before = ?, subscription_source = ?, subscription_detected_at = ?,
|
||||
updated_at = datetime('now')
|
||||
WHERE id = ? AND user_id = ?
|
||||
`).run(
|
||||
|
|
@ -330,6 +331,11 @@ router.put('/:id', (req, res) => {
|
|||
normalized.snowball_order,
|
||||
normalized.snowball_include,
|
||||
normalized.snowball_exempt,
|
||||
normalized.is_subscription,
|
||||
normalized.subscription_type,
|
||||
normalized.reminder_days_before,
|
||||
normalized.subscription_source,
|
||||
normalized.subscription_detected_at,
|
||||
req.params.id,
|
||||
req.user.id,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,96 @@
|
|||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { getDb, ensureUserDefaultCategories } = require('../db/database');
|
||||
const { standardizeError } = require('../middleware/errorFormatter');
|
||||
const {
|
||||
createSubscriptionFromRecommendation,
|
||||
decorateSubscription,
|
||||
getSubscriptionRecommendations,
|
||||
getSubscriptionSummary,
|
||||
getSubscriptions,
|
||||
} = require('../services/subscriptionService');
|
||||
|
||||
router.get('/', (req, res) => {
|
||||
const db = getDb();
|
||||
ensureUserDefaultCategories(req.user.id);
|
||||
const subscriptions = getSubscriptions(db, req.user.id);
|
||||
res.json({
|
||||
summary: getSubscriptionSummary(subscriptions),
|
||||
subscriptions,
|
||||
});
|
||||
});
|
||||
|
||||
router.get('/recommendations', (req, res) => {
|
||||
const db = getDb();
|
||||
res.json({
|
||||
recommendations: getSubscriptionRecommendations(db, req.user.id),
|
||||
});
|
||||
});
|
||||
|
||||
router.post('/recommendations/create', (req, res) => {
|
||||
const db = getDb();
|
||||
ensureUserDefaultCategories(req.user.id);
|
||||
if (req.body?.category_id) {
|
||||
const categoryId = parseInt(req.body.category_id, 10);
|
||||
const category = Number.isInteger(categoryId)
|
||||
? db.prepare('SELECT id FROM categories WHERE id = ? AND user_id = ? AND deleted_at IS NULL').get(categoryId, req.user.id)
|
||||
: null;
|
||||
if (!category) {
|
||||
return res.status(400).json(standardizeError('category_id is invalid for this user', 'VALIDATION_ERROR', 'category_id'));
|
||||
}
|
||||
}
|
||||
try {
|
||||
const created = createSubscriptionFromRecommendation(db, req.user.id, req.body || {});
|
||||
res.status(201).json(created);
|
||||
} catch (err) {
|
||||
res.status(err.status || 400).json(standardizeError(err.message || 'Could not create subscription', err.status ? 'VALIDATION_ERROR' : 'SUBSCRIPTION_CREATE_ERROR', err.field || null));
|
||||
}
|
||||
});
|
||||
|
||||
router.patch('/:id', (req, res) => {
|
||||
const db = getDb();
|
||||
const billId = parseInt(req.params.id, 10);
|
||||
if (!Number.isInteger(billId)) {
|
||||
return res.status(400).json(standardizeError('bill_id must be an integer', 'VALIDATION_ERROR', 'bill_id'));
|
||||
}
|
||||
|
||||
const existing = db.prepare('SELECT * FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL').get(billId, req.user.id);
|
||||
if (!existing) return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
|
||||
|
||||
const allowedTypes = new Set(['streaming', 'software', 'cloud', 'music', 'news', 'fitness', 'gaming', 'utilities', 'insurance', 'other']);
|
||||
const next = {
|
||||
is_subscription: req.body.is_subscription !== undefined ? (req.body.is_subscription ? 1 : 0) : existing.is_subscription,
|
||||
subscription_type: req.body.subscription_type !== undefined
|
||||
? (allowedTypes.has(req.body.subscription_type) ? req.body.subscription_type : 'other')
|
||||
: existing.subscription_type,
|
||||
reminder_days_before: req.body.reminder_days_before !== undefined
|
||||
? Number(req.body.reminder_days_before)
|
||||
: existing.reminder_days_before,
|
||||
active: req.body.active !== undefined ? (req.body.active ? 1 : 0) : existing.active,
|
||||
};
|
||||
|
||||
if (!Number.isInteger(next.reminder_days_before) || next.reminder_days_before < 0 || next.reminder_days_before > 30) {
|
||||
return res.status(400).json(standardizeError('reminder_days_before must be between 0 and 30', 'VALIDATION_ERROR', 'reminder_days_before'));
|
||||
}
|
||||
|
||||
db.prepare(`
|
||||
UPDATE bills
|
||||
SET is_subscription = ?,
|
||||
subscription_type = ?,
|
||||
reminder_days_before = ?,
|
||||
active = ?,
|
||||
updated_at = datetime('now')
|
||||
WHERE id = ? AND user_id = ?
|
||||
`).run(next.is_subscription, next.subscription_type, next.reminder_days_before, next.active, billId, req.user.id);
|
||||
|
||||
const updated = db.prepare(`
|
||||
SELECT b.*, c.name AS category_name
|
||||
FROM bills b
|
||||
LEFT JOIN categories c ON c.id = b.category_id AND c.user_id = b.user_id AND c.deleted_at IS NULL
|
||||
WHERE b.id = ? AND b.user_id = ?
|
||||
`).get(billId, req.user.id);
|
||||
|
||||
res.json(decorateSubscription(updated));
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
|
@ -82,6 +82,7 @@ app.use('/api/admin', csrfMiddleware, requireAuth, requireAdmin, adminAct
|
|||
|
||||
app.use('/api/tracker', csrfMiddleware, requireAuth, requireUser, require('./routes/tracker'));
|
||||
app.use('/api/bills', csrfMiddleware, requireAuth, requireUser, require('./routes/bills'));
|
||||
app.use('/api/subscriptions', csrfMiddleware, requireAuth, requireUser, require('./routes/subscriptions'));
|
||||
app.use('/api/payments', csrfMiddleware, requireAuth, requireUser, require('./routes/payments'));
|
||||
app.use('/api/data-sources', csrfMiddleware, requireAuth, requireUser, require('./routes/dataSources'));
|
||||
app.use('/api/transactions', csrfMiddleware, requireAuth, requireUser, require('./routes/transactions'));
|
||||
|
|
|
|||
|
|
@ -5,7 +5,8 @@ const TEMPLATE_FIELDS = [
|
|||
'interest_rate', 'billing_cycle', 'cycle_type', 'cycle_day', 'autopay_enabled',
|
||||
'autodraft_status', 'auto_mark_paid', 'website', 'username', 'account_info',
|
||||
'has_2fa', 'notes', 'current_balance', 'minimum_payment', 'snowball_order',
|
||||
'snowball_include', 'snowball_exempt', 'history_visibility',
|
||||
'snowball_include', 'snowball_exempt', 'history_visibility', 'is_subscription',
|
||||
'subscription_type', 'reminder_days_before', 'subscription_source', 'subscription_detected_at',
|
||||
];
|
||||
|
||||
function hasText(value) {
|
||||
|
|
@ -55,8 +56,9 @@ function insertBill(db, userId, normalized) {
|
|||
(user_id, name, category_id, due_day, override_due_date, bucket, expected_amount,
|
||||
interest_rate, billing_cycle, autopay_enabled, autodraft_status, auto_mark_paid, website, username,
|
||||
account_info, has_2fa, notes, history_visibility, active, cycle_type, cycle_day,
|
||||
current_balance, minimum_payment, snowball_order, snowball_include, snowball_exempt)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?, ?, ?)
|
||||
current_balance, minimum_payment, snowball_order, snowball_include, snowball_exempt,
|
||||
is_subscription, subscription_type, reminder_days_before, subscription_source, subscription_detected_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
userId,
|
||||
normalized.name,
|
||||
|
|
@ -83,6 +85,11 @@ function insertBill(db, userId, normalized) {
|
|||
normalized.snowball_order,
|
||||
normalized.snowball_include,
|
||||
normalized.snowball_exempt,
|
||||
normalized.is_subscription,
|
||||
normalized.subscription_type,
|
||||
normalized.reminder_days_before,
|
||||
normalized.subscription_source,
|
||||
normalized.subscription_detected_at,
|
||||
);
|
||||
|
||||
return db.prepare('SELECT * FROM bills WHERE id = ?').get(result.lastInsertRowid);
|
||||
|
|
@ -387,6 +394,33 @@ function validateBillData(data, existingBill = null) {
|
|||
? (data.snowball_exempt ? 1 : 0)
|
||||
: (existingBill?.snowball_exempt ?? 0);
|
||||
|
||||
normalized.is_subscription = data.is_subscription !== undefined
|
||||
? (data.is_subscription ? 1 : 0)
|
||||
: (existingBill?.is_subscription ?? 0);
|
||||
|
||||
normalized.subscription_type = data.subscription_type !== undefined
|
||||
? (data.subscription_type ? String(data.subscription_type).trim().slice(0, 64) : null)
|
||||
: (existingBill?.subscription_type ?? null);
|
||||
|
||||
if (data.reminder_days_before !== undefined) {
|
||||
const days = Number(data.reminder_days_before);
|
||||
if (!Number.isInteger(days) || days < 0 || days > 30) {
|
||||
errors.push({ field: 'reminder_days_before', message: 'reminder_days_before must be between 0 and 30' });
|
||||
} else {
|
||||
normalized.reminder_days_before = days;
|
||||
}
|
||||
} else {
|
||||
normalized.reminder_days_before = existingBill?.reminder_days_before ?? 3;
|
||||
}
|
||||
|
||||
normalized.subscription_source = data.subscription_source !== undefined
|
||||
? (data.subscription_source ? String(data.subscription_source).trim().slice(0, 32) : 'manual')
|
||||
: (existingBill?.subscription_source || 'manual');
|
||||
|
||||
normalized.subscription_detected_at = data.subscription_detected_at !== undefined
|
||||
? (data.subscription_detected_at || null)
|
||||
: (existingBill?.subscription_detected_at ?? null);
|
||||
|
||||
return {
|
||||
errors,
|
||||
normalized: {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,274 @@
|
|||
const { insertBill, validateBillData } = require('./billsService');
|
||||
|
||||
const SUBSCRIPTION_TYPES = ['streaming', 'software', 'cloud', 'music', 'news', 'fitness', 'gaming', 'utilities', 'insurance', 'other'];
|
||||
|
||||
const MONTHLY_FACTORS = {
|
||||
weekly: 52 / 12,
|
||||
biweekly: 26 / 12,
|
||||
monthly: 1,
|
||||
quarterly: 1 / 3,
|
||||
annual: 1 / 12,
|
||||
annually: 1 / 12,
|
||||
irregular: 1,
|
||||
};
|
||||
|
||||
const TYPE_KEYWORDS = [
|
||||
['streaming', ['netflix', 'hulu', 'disney', 'max', 'paramount', 'peacock', 'youtube tv', 'sling']],
|
||||
['music', ['spotify', 'apple music', 'tidal', 'pandora']],
|
||||
['software', ['adobe', 'microsoft', 'github', 'notion', 'linear', 'figma', 'canva', 'openai', 'chatgpt']],
|
||||
['cloud', ['dropbox', 'icloud', 'google storage', 'backblaze', 'aws', 'cloudflare']],
|
||||
['news', ['nyt', 'new york times', 'economist', 'athletic', 'washington post']],
|
||||
['fitness', ['peloton', 'planet fitness', 'gym', 'fitbit']],
|
||||
['gaming', ['xbox', 'playstation', 'steam', 'nintendo']],
|
||||
['utilities', ['verizon', 'at t', 'comcast', 'xfinity', 'spectrum', 'tmobile']],
|
||||
['insurance', ['insurance', 'geico', 'progressive', 'state farm', 'allstate']],
|
||||
];
|
||||
|
||||
function normalizeMerchant(value) {
|
||||
return String(value || '')
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9\s]/g, ' ')
|
||||
.replace(/\b(pos|debit|card|payment|purchase|recurring|online|inc|llc|co)\b/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function titleCase(value) {
|
||||
return String(value || 'Subscription')
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
function inferType(text) {
|
||||
const haystack = normalizeMerchant(text);
|
||||
for (const [type, words] of TYPE_KEYWORDS) {
|
||||
if (words.some(word => haystack.includes(word))) return type;
|
||||
}
|
||||
return 'other';
|
||||
}
|
||||
|
||||
function monthlyEquivalent(amount, cycleType, billingCycle) {
|
||||
const key = String(cycleType || billingCycle || 'monthly').toLowerCase();
|
||||
const fallback = String(billingCycle || '').toLowerCase() === 'quarterly'
|
||||
? 'quarterly'
|
||||
: String(billingCycle || '').toLowerCase() === 'annually'
|
||||
? 'annual'
|
||||
: key;
|
||||
const factor = MONTHLY_FACTORS[key] ?? MONTHLY_FACTORS[fallback] ?? 1;
|
||||
return Math.round(Number(amount || 0) * factor * 100) / 100;
|
||||
}
|
||||
|
||||
function nextDueDate(bill, now = new Date()) {
|
||||
const dueDay = Math.min(Math.max(Number(bill.due_day) || 1, 1), 31);
|
||||
const cycle = String(bill.cycle_type || bill.billing_cycle || 'monthly').toLowerCase();
|
||||
let date = new Date(now.getFullYear(), now.getMonth(), dueDay);
|
||||
if (date < new Date(now.getFullYear(), now.getMonth(), now.getDate())) {
|
||||
date = new Date(now.getFullYear(), now.getMonth() + 1, dueDay);
|
||||
}
|
||||
|
||||
if (cycle === 'quarterly' || cycle === 'annual') {
|
||||
const startMonth = Math.min(Math.max(Number(bill.cycle_day) || 1, 1), 12) - 1;
|
||||
const step = cycle === 'quarterly' ? 3 : 12;
|
||||
date = new Date(now.getFullYear(), startMonth, dueDay);
|
||||
while (date < new Date(now.getFullYear(), now.getMonth(), now.getDate())) {
|
||||
date = new Date(date.getFullYear(), date.getMonth() + step, dueDay);
|
||||
}
|
||||
}
|
||||
|
||||
return date.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function decorateSubscription(bill) {
|
||||
const monthly = monthlyEquivalent(bill.expected_amount, bill.cycle_type, bill.billing_cycle);
|
||||
return {
|
||||
...bill,
|
||||
is_subscription: !!bill.is_subscription,
|
||||
active: !!bill.active,
|
||||
monthly_equivalent: monthly,
|
||||
yearly_equivalent: Math.round(monthly * 12 * 100) / 100,
|
||||
next_due_date: nextDueDate(bill),
|
||||
subscription_type: bill.subscription_type || inferType(`${bill.name} ${bill.category_name || ''}`),
|
||||
};
|
||||
}
|
||||
|
||||
function getSubscriptions(db, userId) {
|
||||
return db.prepare(`
|
||||
SELECT b.*, c.name AS category_name
|
||||
FROM bills b
|
||||
LEFT JOIN categories c ON c.id = b.category_id AND c.user_id = b.user_id AND c.deleted_at IS NULL
|
||||
WHERE b.user_id = ?
|
||||
AND b.deleted_at IS NULL
|
||||
AND b.is_subscription = 1
|
||||
ORDER BY b.active DESC, b.due_day ASC, b.name COLLATE NOCASE ASC
|
||||
`).all(userId).map(decorateSubscription);
|
||||
}
|
||||
|
||||
function getSubscriptionSummary(subscriptions) {
|
||||
const active = subscriptions.filter(item => item.active);
|
||||
const monthlyTotal = active.reduce((sum, item) => sum + Number(item.monthly_equivalent || 0), 0);
|
||||
const typeTotals = new Map();
|
||||
for (const item of active) {
|
||||
const type = item.subscription_type || 'other';
|
||||
typeTotals.set(type, (typeTotals.get(type) || 0) + Number(item.monthly_equivalent || 0));
|
||||
}
|
||||
const topType = [...typeTotals.entries()].sort((a, b) => b[1] - a[1])[0] || null;
|
||||
return {
|
||||
active_count: active.length,
|
||||
paused_count: subscriptions.length - active.length,
|
||||
monthly_total: Math.round(monthlyTotal * 100) / 100,
|
||||
yearly_total: Math.round(monthlyTotal * 12 * 100) / 100,
|
||||
top_type: topType ? { type: topType[0], monthly_total: Math.round(topType[1] * 100) / 100 } : null,
|
||||
};
|
||||
}
|
||||
|
||||
function existingBillNames(db, userId) {
|
||||
return db.prepare('SELECT name FROM bills WHERE user_id = ? AND deleted_at IS NULL')
|
||||
.all(userId)
|
||||
.map(row => normalizeMerchant(row.name))
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function dollarsFromTransactionAmount(amount) {
|
||||
return Math.round((Math.abs(Number(amount || 0)) / 100) * 100) / 100;
|
||||
}
|
||||
|
||||
function billingCycleForCycleType(cycleType) {
|
||||
if (cycleType === 'quarterly') return 'quarterly';
|
||||
if (cycleType === 'annual') return 'annually';
|
||||
if (cycleType === 'monthly') return 'monthly';
|
||||
return 'irregular';
|
||||
}
|
||||
|
||||
function getSubscriptionRecommendations(db, userId) {
|
||||
const existingNames = existingBillNames(db, userId);
|
||||
const rows = db.prepare(`
|
||||
SELECT
|
||||
t.id, t.amount, t.currency, t.description, t.payee, t.memo, t.category,
|
||||
COALESCE(t.posted_date, substr(t.transacted_at, 1, 10)) AS tx_date,
|
||||
ds.provider AS data_source_provider,
|
||||
ds.name AS data_source_name
|
||||
FROM transactions t
|
||||
LEFT JOIN data_sources ds ON ds.id = t.data_source_id AND ds.user_id = t.user_id
|
||||
WHERE t.user_id = ?
|
||||
AND t.ignored = 0
|
||||
AND t.match_status = 'unmatched'
|
||||
AND t.amount < 0
|
||||
AND COALESCE(t.posted_date, substr(t.transacted_at, 1, 10)) >= date('now', '-420 days')
|
||||
AND (ds.provider = 'simplefin' OR t.source_type = 'provider_sync')
|
||||
ORDER BY tx_date ASC
|
||||
`).all(userId);
|
||||
|
||||
const groups = new Map();
|
||||
for (const tx of rows) {
|
||||
const merchant = normalizeMerchant(tx.payee || tx.description || tx.memo);
|
||||
if (!merchant || merchant.length < 3) continue;
|
||||
const amount = dollarsFromTransactionAmount(tx.amount);
|
||||
if (amount < 1) continue;
|
||||
const key = `${merchant}:${Math.round(amount)}`;
|
||||
const group = groups.get(key) || { merchant, amountBucket: Math.round(amount), items: [] };
|
||||
group.items.push({ ...tx, amount_dollars: amount });
|
||||
groups.set(key, group);
|
||||
}
|
||||
|
||||
const recommendations = [];
|
||||
for (const group of groups.values()) {
|
||||
if (group.items.length < 2) continue;
|
||||
if (existingNames.some(name => name.includes(group.merchant) || group.merchant.includes(name))) continue;
|
||||
|
||||
const sorted = group.items.filter(item => item.tx_date).sort((a, b) => String(a.tx_date).localeCompare(String(b.tx_date)));
|
||||
if (sorted.length < 2) continue;
|
||||
const gaps = [];
|
||||
for (let i = 1; i < sorted.length; i++) {
|
||||
gaps.push(Math.round((new Date(`${sorted[i].tx_date}T00:00:00`) - new Date(`${sorted[i - 1].tx_date}T00:00:00`)) / 86400000));
|
||||
}
|
||||
const avgGap = gaps.reduce((sum, gap) => sum + gap, 0) / gaps.length;
|
||||
const cycleType = avgGap >= 320 ? 'annual' : avgGap >= 75 ? 'quarterly' : avgGap >= 10 && avgGap <= 18 ? 'biweekly' : avgGap <= 9 ? 'weekly' : 'monthly';
|
||||
if (cycleType === 'monthly' && (avgGap < 24 || avgGap > 38)) continue;
|
||||
if (cycleType === 'quarterly' && (avgGap < 75 || avgGap > 105)) continue;
|
||||
|
||||
const averageAmount = sorted.reduce((sum, item) => sum + item.amount_dollars, 0) / sorted.length;
|
||||
const maxDelta = Math.max(...sorted.map(item => Math.abs(item.amount_dollars - averageAmount)));
|
||||
if (maxDelta > Math.max(3, averageAmount * 0.18)) continue;
|
||||
|
||||
const last = sorted[sorted.length - 1];
|
||||
recommendations.push({
|
||||
id: Buffer.from(`${group.merchant}:${group.amountBucket}:${last.tx_date}`).toString('base64url'),
|
||||
name: titleCase(group.merchant),
|
||||
subscription_type: inferType(group.merchant),
|
||||
expected_amount: Math.round(averageAmount * 100) / 100,
|
||||
monthly_equivalent: monthlyEquivalent(averageAmount, cycleType, cycleType),
|
||||
cycle_type: cycleType,
|
||||
billing_cycle: billingCycleForCycleType(cycleType),
|
||||
due_day: Number(String(last.tx_date).slice(8, 10)) || 1,
|
||||
last_seen_date: last.tx_date,
|
||||
occurrence_count: sorted.length,
|
||||
confidence: Math.min(96, 58 + sorted.length * 9 + (maxDelta <= 1 ? 10 : 0)),
|
||||
transaction_ids: sorted.map(item => item.id),
|
||||
merchant: group.merchant,
|
||||
source: last.data_source_name || 'SimpleFIN',
|
||||
reasons: [
|
||||
`${sorted.length} similar SimpleFIN charges`,
|
||||
`About ${Math.round(avgGap)} days apart`,
|
||||
`${last.currency || 'USD'} ${averageAmount.toFixed(2)} average charge`,
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
return recommendations.sort((a, b) => b.confidence - a.confidence || b.occurrence_count - a.occurrence_count).slice(0, 20);
|
||||
}
|
||||
|
||||
function createSubscriptionFromRecommendation(db, userId, payload = {}) {
|
||||
const seenDate = payload.last_seen_date || new Date().toISOString().slice(0, 10);
|
||||
const draft = {
|
||||
name: payload.name,
|
||||
category_id: payload.category_id || null,
|
||||
due_day: payload.due_day,
|
||||
expected_amount: payload.expected_amount,
|
||||
billing_cycle: billingCycleForCycleType(payload.cycle_type || 'monthly'),
|
||||
cycle_type: payload.cycle_type || 'monthly',
|
||||
cycle_day: payload.cycle_type === 'annual' || payload.cycle_type === 'quarterly'
|
||||
? String(new Date(`${seenDate}T00:00:00`).getMonth() + 1)
|
||||
: String(payload.due_day || 1),
|
||||
is_subscription: 1,
|
||||
subscription_type: SUBSCRIPTION_TYPES.includes(payload.subscription_type) ? payload.subscription_type : 'other',
|
||||
reminder_days_before: 3,
|
||||
subscription_source: 'simplefin_recommendation',
|
||||
subscription_detected_at: new Date().toISOString(),
|
||||
notes: payload.merchant ? `Detected from recurring SimpleFIN merchant: ${payload.merchant}` : null,
|
||||
};
|
||||
|
||||
const validation = validateBillData(draft);
|
||||
if (validation.errors.length > 0) {
|
||||
const err = new Error(validation.errors[0].message);
|
||||
err.field = validation.errors[0].field;
|
||||
err.status = 400;
|
||||
throw err;
|
||||
}
|
||||
|
||||
const created = insertBill(db, userId, validation.normalized);
|
||||
const ids = Array.isArray(payload.transaction_ids)
|
||||
? payload.transaction_ids.map(id => Number(id)).filter(Number.isInteger).slice(0, 50)
|
||||
: [];
|
||||
if (ids.length > 0) {
|
||||
const update = db.prepare(`
|
||||
UPDATE transactions
|
||||
SET matched_bill_id = ?, match_status = 'matched', updated_at = CURRENT_TIMESTAMP
|
||||
WHERE user_id = ? AND id = ? AND ignored = 0
|
||||
`);
|
||||
for (const id of ids) update.run(created.id, userId, id);
|
||||
}
|
||||
|
||||
return decorateSubscription(created);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
SUBSCRIPTION_TYPES,
|
||||
createSubscriptionFromRecommendation,
|
||||
decorateSubscription,
|
||||
getSubscriptionRecommendations,
|
||||
getSubscriptionSummary,
|
||||
getSubscriptions,
|
||||
monthlyEquivalent,
|
||||
};
|
||||
Loading…
Reference in New Issue