const express = require('express'); const router = express.Router(); const { getDb, ensureUserDefaultCategories } = require('../db/database'); const { standardizeError } = require('../middleware/errorFormatter'); const { fromCents } = require('../utils/money'); const { createSubscriptionFromRecommendation, declineRecommendation, decorateSubscription, getSubscriptionRecommendations, getSubscriptionSummary, getSubscriptions, monthlyEquivalent, recordSubscriptionFeedback, searchSubscriptionTransactions, } = require('../services/subscriptionService'); const { addMerchantRule, applyMerchantRules } = require('../services/billMerchantRuleService'); 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.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) { return res.status(400).json(standardizeError('decline_key is required', 'VALIDATION_ERROR', 'decline_key')); } try { const db = getDb(); declineRecommendation(db, req.user.id, decline_key); recordSubscriptionFeedback(db, req.user.id, { action: 'decline', catalog_id: req.body?.catalog_id, merchant: req.body?.merchant, confidence: req.body?.confidence, metadata: { decline_key }, }); res.json({ ok: true }); } catch (err) { res.status(500).json(standardizeError(err.message || 'Failed to decline recommendation', 'DECLINE_ERROR')); } }); // POST /api/subscriptions/recommendations/match-bill // Link an existing bill to all transactions in a recommendation (no new bill created). router.post('/recommendations/match-bill', (req, res) => { const billId = parseInt(req.body?.bill_id, 10); const rawIds = Array.isArray(req.body?.transaction_ids) ? req.body.transaction_ids : []; const txIds = rawIds.map(id => parseInt(id, 10)).filter(n => Number.isInteger(n) && n > 0).slice(0, 50); if (!Number.isInteger(billId) || billId < 1) { return res.status(400).json(standardizeError('bill_id is required', 'VALIDATION_ERROR', 'bill_id')); } if (txIds.length === 0) { return res.status(400).json(standardizeError('transaction_ids must be a non-empty array', 'VALIDATION_ERROR', 'transaction_ids')); } const db = getDb(); const bill = db.prepare('SELECT id, name, catalog_id FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL').get(billId, req.user.id); if (!bill) return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id')); const catalogId = parseInt(req.body?.catalog_id, 10); const catalogEntry = Number.isInteger(catalogId) && catalogId > 0 ? db.prepare('SELECT id FROM subscription_catalog WHERE id = ?').get(catalogId) : null; const placeholders = txIds.map(() => '?').join(','); const txRows = db.prepare(` SELECT id, amount, posted_date, transacted_at FROM transactions WHERE user_id = ? AND id IN (${placeholders}) AND ignored = 0 AND match_status != 'matched' `).all(req.user.id, ...txIds); const updateTx = db.prepare(` UPDATE transactions SET matched_bill_id = ?, match_status = 'matched', updated_at = CURRENT_TIMESTAMP WHERE id = ? AND user_id = ? AND ignored = 0 AND match_status != 'matched' `); const insertPayment = db.prepare(` INSERT OR IGNORE INTO payments (bill_id, amount, paid_date, payment_source, transaction_id) VALUES (?, ?, ?, 'auto_match', ?) `); let matchedCount = 0; db.transaction(() => { for (const tx of txRows) { const paidDate = tx.posted_date || (tx.transacted_at ? String(tx.transacted_at).slice(0, 10) : null); const amount = Math.round(Math.abs(tx.amount)); // tx.amount and payments.amount are both cents matchedCount += updateTx.run(billId, tx.id, req.user.id).changes; if (paidDate) insertPayment.run(billId, amount, paidDate, tx.id); } })(); // Store merchant rule for ongoing auto-matching on future syncs const merchant = typeof req.body?.merchant === 'string' ? req.body.merchant.trim() : ''; if (merchant) addMerchantRule(db, req.user.id, billId, merchant); if (catalogEntry && !bill.catalog_id) { try { db.prepare('UPDATE bills SET catalog_id = ?, updated_at = datetime(\'now\') WHERE id = ? AND user_id = ?') .run(catalogEntry.id, billId, req.user.id); } catch { /* pre-v0.96 */ } } recordSubscriptionFeedback(db, req.user.id, { action: 'link_existing_bill', catalog_id: catalogEntry?.id, bill_id: billId, merchant, confidence: req.body?.confidence, metadata: { transaction_ids: txIds }, }); // Apply rules immediately to catch any unmatched transactions beyond the explicit list const { matched: autoMatched } = applyMerchantRules(db, req.user.id); res.json({ ok: true, matched_count: matchedCount + autoMatched, bill_name: bill.name }); }); 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 || {}); // Store merchant rule so future SimpleFIN transactions auto-match this bill if (req.body?.merchant) addMerchantRule(db, req.user.id, created.id, req.body.merchant); recordSubscriptionFeedback(db, req.user.id, { action: 'accept_track_new', catalog_id: req.body?.catalog_match?.id, bill_id: created.id, merchant: req.body?.merchant, confidence: req.body?.confidence, metadata: { transaction_ids: req.body?.transaction_ids || [] }, }); 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)); } }); // ── Catalog browser ─────────────────────────────────────────────────────────── router.get('/catalog', (req, res) => { const db = getDb(); try { const catalogEntries = db.prepare(` SELECT id, rank, name, category, subcategory, subscription_type, website, starting_monthly_usd, starting_annual_usd FROM subscription_catalog ORDER BY rank ASC `).all(); if (!catalogEntries.length) return res.json({ catalog: [] }); // User's subscription bills that are linked to a catalog entry const matchedBills = db.prepare(` SELECT b.id, b.name, b.expected_amount, b.active, b.catalog_id, b.cycle_type, b.billing_cycle FROM bills b WHERE b.user_id = ? AND b.is_subscription = 1 AND b.deleted_at IS NULL AND b.catalog_id IS NOT NULL `).all(req.user.id); const billByCatalogId = new Map(matchedBills.map(b => [b.catalog_id, b])); // User's custom descriptors let userDescriptors = []; try { userDescriptors = db.prepare( 'SELECT id, catalog_id, descriptor FROM user_catalog_descriptors WHERE user_id = ?' ).all(req.user.id); } catch { /* pre-v0.96 */ } const userDescsByCatalogId = new Map(); for (const d of userDescriptors) { if (!userDescsByCatalogId.has(d.catalog_id)) userDescsByCatalogId.set(d.catalog_id, []); userDescsByCatalogId.get(d.catalog_id).push({ id: d.id, descriptor: d.descriptor }); } const catalog = catalogEntries.map(entry => { const bill = billByCatalogId.get(entry.id) ?? null; return { ...entry, matched_bill: bill ? { id: bill.id, name: bill.name, expected_amount: fromCents(bill.expected_amount), active: !!bill.active, monthly_equivalent: monthlyEquivalent(fromCents(bill.expected_amount), bill.cycle_type, bill.billing_cycle), } : null, user_descriptors: userDescsByCatalogId.get(entry.id) ?? [], }; }); res.json({ catalog }); } catch (err) { res.status(500).json(standardizeError(err.message || 'Failed to load catalog', 'CATALOG_ERROR')); } }); // Update which catalog entry a bill is linked to (or unlink with null) router.put('/:id/catalog-link', (req, res) => { const db = getDb(); const billId = parseInt(req.params.id, 10); if (!Number.isInteger(billId) || billId < 1) { return res.status(400).json(standardizeError('Invalid bill ID', 'VALIDATION_ERROR')); } const bill = db.prepare( 'SELECT id, name, catalog_id FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL' ).get(billId, req.user.id); if (!bill) return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND')); const rawCatalogId = req.body?.catalog_id; if (rawCatalogId === null || rawCatalogId === undefined) { db.prepare("UPDATE bills SET catalog_id = NULL, updated_at = datetime('now') WHERE id = ? AND user_id = ?") .run(billId, req.user.id); recordSubscriptionFeedback(db, req.user.id, { action: 'catalog_unlink', catalog_id: bill.catalog_id, bill_id: billId, merchant: bill.name, }); return res.json({ ok: true, catalog_id: null }); } const catalogId = parseInt(rawCatalogId, 10); if (!Number.isInteger(catalogId) || catalogId < 1) { return res.status(400).json(standardizeError('catalog_id must be a positive integer or null', 'VALIDATION_ERROR', 'catalog_id')); } const catalogEntry = db.prepare('SELECT id FROM subscription_catalog WHERE id = ?').get(catalogId); if (!catalogEntry) return res.status(404).json(standardizeError('Catalog entry not found', 'NOT_FOUND', 'catalog_id')); db.prepare("UPDATE bills SET catalog_id = ?, updated_at = datetime('now') WHERE id = ? AND user_id = ?") .run(catalogId, billId, req.user.id); recordSubscriptionFeedback(db, req.user.id, { action: 'catalog_relink', catalog_id: catalogId, bill_id: billId, merchant: bill.name, metadata: { previous_catalog_id: bill.catalog_id || null }, }); res.json({ ok: true, catalog_id: catalogId }); }); // Add a custom bank descriptor for a catalog entry (per-user) router.post('/catalog/:catalogId/descriptors', (req, res) => { const db = getDb(); const catalogId = parseInt(req.params.catalogId, 10); if (!Number.isInteger(catalogId) || catalogId < 1) { return res.status(400).json(standardizeError('Invalid catalog ID', 'VALIDATION_ERROR')); } const catalogEntry = db.prepare('SELECT id FROM subscription_catalog WHERE id = ?').get(catalogId); if (!catalogEntry) return res.status(404).json(standardizeError('Catalog entry not found', 'NOT_FOUND')); const descriptor = String(req.body?.descriptor ?? '').trim(); if (!descriptor) { return res.status(400).json(standardizeError('descriptor is required', 'VALIDATION_ERROR', 'descriptor')); } if (descriptor.length > 100) { return res.status(400).json(standardizeError('descriptor must be 100 characters or less', 'VALIDATION_ERROR', 'descriptor')); } try { // Check for case-insensitive duplicate const exists = db.prepare( 'SELECT id FROM user_catalog_descriptors WHERE user_id = ? AND catalog_id = ? AND LOWER(descriptor) = LOWER(?)' ).get(req.user.id, catalogId, descriptor); if (exists) { return res.status(409).json(standardizeError('Descriptor already exists for this service', 'DUPLICATE_ERROR', 'descriptor')); } const result = db.prepare( 'INSERT INTO user_catalog_descriptors (user_id, catalog_id, descriptor) VALUES (?, ?, ?)' ).run(req.user.id, catalogId, descriptor); recordSubscriptionFeedback(db, req.user.id, { action: 'add_descriptor', catalog_id: catalogId, merchant: descriptor, descriptor, }); res.status(201).json({ id: result.lastInsertRowid, descriptor, catalog_id: catalogId }); } catch (err) { res.status(500).json(standardizeError(err.message || 'Failed to add descriptor', 'DESCRIPTOR_ADD_ERROR')); } }); // Delete a user-added catalog descriptor router.delete('/catalog/descriptors/:id', (req, res) => { const db = getDb(); const descriptorId = parseInt(req.params.id, 10); if (!Number.isInteger(descriptorId) || descriptorId < 1) { return res.status(400).json(standardizeError('Invalid descriptor ID', 'VALIDATION_ERROR')); } try { const existing = db.prepare( 'SELECT catalog_id, descriptor FROM user_catalog_descriptors WHERE id = ? AND user_id = ?' ).get(descriptorId, req.user.id); const result = db.prepare( 'DELETE FROM user_catalog_descriptors WHERE id = ? AND user_id = ?' ).run(descriptorId, req.user.id); if (result.changes === 0) { return res.status(404).json(standardizeError('Descriptor not found', 'NOT_FOUND')); } if (existing) { recordSubscriptionFeedback(db, req.user.id, { action: 'delete_descriptor', catalog_id: existing.catalog_id, merchant: existing.descriptor, descriptor: existing.descriptor, }); } res.json({ ok: true }); } catch (err) { res.status(500).json(standardizeError(err.message || 'Failed to delete descriptor', 'DESCRIPTOR_DELETE_ERROR')); } }); 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;