From 7a58d69c708ad81c88297e4a76d2a7ea964e3792 Mon Sep 17 00:00:00 2001
From: null
Date: Thu, 28 May 2026 22:54:07 -0500
Subject: [PATCH] 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
---
client/App.jsx | 2 +
client/api.js | 6 +
client/components/BillModal.jsx | 70 +++++
client/components/layout/Sidebar.jsx | 2 +
client/pages/SubscriptionsPage.jsx | 369 +++++++++++++++++++++++++++
db/database.js | 48 +++-
db/schema.sql | 5 +
package.json | 2 +-
routes/bills.js | 6 +
routes/subscriptions.js | 96 +++++++
server.js | 1 +
services/billsService.js | 40 ++-
services/subscriptionService.js | 274 ++++++++++++++++++++
13 files changed, 916 insertions(+), 5 deletions(-)
create mode 100644 client/pages/SubscriptionsPage.jsx
create mode 100644 routes/subscriptions.js
create mode 100644 services/subscriptionService.js
diff --git a/client/App.jsx b/client/App.jsx
index 640f01e..639fb05 100644
--- a/client/App.jsx
+++ b/client/App.jsx
@@ -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() {
}>} />
}>} />
}>} />
+ }>} />
}>} />
}>} />
}>} />
diff --git a/client/api.js b/client/api.js
index ad51051..d92a427 100644
--- a/client/api.js
+++ b/client/api.js
@@ -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),
diff --git a/client/components/BillModal.jsx b/client/components/BillModal.jsx
index 3b2e1f8..a2e2425 100644
--- a/client/components/BillModal.jsx
+++ b/client/components/BillModal.jsx
@@ -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
+ {/* Subscription Details */}
+
+
+
+ {isSubscription && (
+
+
+
+
+
+
+
+
setReminderDaysBefore(e.target.value)}
+ />
+
0-30 days before renewal.
+
+
+ )}
+
+
{/* Debt / Snowball Details — collapsible */}
+ );
+}
+
+function SubscriptionRow({ item, onEdit, onToggle }) {
+ return (
+
+
+
+
+
+ {TYPE_LABELS[item.subscription_type] || 'Other'}
+
+ {!item.active && (
+
+ Paused
+
+ )}
+
+
+ {item.category_name || 'Uncategorized'}
+ Due {fmtDate(item.next_due_date)}
+ {cycleLabel(item)}
+ {item.reminder_days_before ?? 3}d reminder
+
+
+
+
+
+
Per cycle
+
{fmt(item.expected_amount)}
+
+
+
Monthly
+
{fmt(item.monthly_equivalent)}
+
+
+
+
+
+
+
+
+ );
+}
+
+function RecommendationCard({ recommendation, categoryId, onAccept, busy }) {
+ return (
+
+
+
+
+
{recommendation.name}
+
+ {recommendation.confidence}% match
+
+
+
+ {TYPE_LABELS[recommendation.subscription_type] || 'Other'} · {recommendation.occurrence_count} charges · last seen {fmtDate(recommendation.last_seen_date)}
+
+
+ {recommendation.reasons?.map(reason => (
+
+ {reason}
+
+ ))}
+
+
+
+
{fmt(recommendation.expected_amount)}
+
+ {fmt(recommendation.monthly_equivalent)} / mo
+
+
+
+
+
+ );
+}
+
+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 (
+
+
+
+
+ Recurring Services
+
+
Subscriptions
+
+ Track manual subscriptions and review recurring SimpleFIN charges.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Tracked Subscriptions
+ Subscriptions are bills with recurring-service metadata.
+
+
+ {loading ? (
+
+ {Array.from({ length: 4 }).map((_, index) => (
+
+ ))}
+
+ ) : subscriptions.length === 0 ? (
+
+
+
No subscriptions tracked yet.
+
Add one manually or accept a SimpleFIN recommendation.
+
+ ) : (
+ <>
+ {active.map(item => (
+ setModal({ bill })} onToggle={toggleSubscription} />
+ ))}
+ {paused.map(item => (
+ setModal({ bill })} onToggle={toggleSubscription} />
+ ))}
+ >
+ )}
+
+
+
+
+
+
+
+ SimpleFIN Recommendations
+
+ Recurring unmatched bank charges that look like subscriptions.
+
+
+ {recommendationsLoading ? (
+
+ Scanning transactions...
+
+ ) : recommendations.length === 0 ? (
+
+
+
No recommendations right now.
+
Sync SimpleFIN after a few recurring charges appear.
+
+ ) : (
+ recommendations.map(recommendation => (
+
+ ))
+ )}
+
+
+
+
+ {modal && (
+
setModal(null)}
+ onSave={async () => {
+ setModal(null);
+ await refreshAll();
+ }}
+ />
+ )}
+
+ );
+}
diff --git a/db/database.js b/db/database.js
index d6a6809..a077fdc 100644
--- a/db/database.js
+++ b/db/database.js
@@ -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']
diff --git a/db/schema.sql b/db/schema.sql
index 834c65a..1fb6eda 100644
--- a/db/schema.sql
+++ b/db/schema.sql
@@ -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')),
diff --git a/package.json b/package.json
index 6e8ee73..0543c52 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "bill-tracker",
- "version": "0.29.3",
+ "version": "0.30.0",
"description": "Monthly bill tracking system",
"main": "server.js",
"scripts": {
diff --git a/routes/bills.js b/routes/bills.js
index b41d515..0d19aa3 100644
--- a/routes/bills.js
+++ b/routes/bills.js
@@ -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,
);
diff --git a/routes/subscriptions.js b/routes/subscriptions.js
new file mode 100644
index 0000000..f45a9b1
--- /dev/null
+++ b/routes/subscriptions.js
@@ -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;
diff --git a/server.js b/server.js
index c5f0fe2..e4053fe 100644
--- a/server.js
+++ b/server.js
@@ -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'));
diff --git a/services/billsService.js b/services/billsService.js
index 5efb25f..35a7c12 100644
--- a/services/billsService.js
+++ b/services/billsService.js
@@ -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: {
diff --git a/services/subscriptionService.js b/services/subscriptionService.js
new file mode 100644
index 0000000..6a8198e
--- /dev/null
+++ b/services/subscriptionService.js
@@ -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,
+};