BillTracker/routes/subscriptions.js

334 lines
14 KiB
JavaScript

const express = require('express');
const router = express.Router();
const { getDb, ensureUserDefaultCategories } = require('../db/database');
const { standardizeError } = require('../middleware/errorFormatter');
const {
createSubscriptionFromRecommendation,
declineRecommendation,
decorateSubscription,
getSubscriptionRecommendations,
getSubscriptionSummary,
getSubscriptions,
monthlyEquivalent,
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 {
declineRecommendation(getDb(), req.user.id, 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 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 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)) / 100;
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);
// 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);
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: bill.expected_amount,
active: !!bill.active,
monthly_equivalent: monthlyEquivalent(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 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);
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);
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);
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 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'));
}
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;