485 lines
16 KiB
TypeScript
485 lines
16 KiB
TypeScript
// @ts-nocheck — route controller converted to .cts; handler req/res typed, full type-check deferred (thin glue over typed services).
|
|
import type { Req, Res, Next } from '../types/http';
|
|
const express = require('express');
|
|
const router = express.Router();
|
|
const { getDb, ensureUserDefaultCategories } = require('../db/database.cts');
|
|
const {
|
|
ApiError,
|
|
ValidationError,
|
|
NotFoundError,
|
|
ConflictError,
|
|
} = require('../utils/apiError.cts');
|
|
const { fromCents } = require('../utils/money.mts');
|
|
const {
|
|
createSubscriptionFromRecommendation,
|
|
declineRecommendation,
|
|
decorateSubscription,
|
|
getSubscriptionRecommendations,
|
|
getSubscriptionSummary,
|
|
getSubscriptions,
|
|
monthlyEquivalent,
|
|
recordSubscriptionFeedback,
|
|
searchSubscriptionTransactions,
|
|
} = require('../services/subscriptionService.cts');
|
|
const { addMerchantRule, applyMerchantRules } = require('../services/billMerchantRuleService.cts');
|
|
|
|
router.get('/', (req: Req, res: 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: Req, res: Res) => {
|
|
const db = getDb();
|
|
res.json({
|
|
recommendations: getSubscriptionRecommendations(db, req.user.id),
|
|
});
|
|
});
|
|
|
|
router.get('/transaction-matches', (req: Req, res: Res) => {
|
|
res.json({
|
|
transactions: searchSubscriptionTransactions(getDb(), req.user.id, req.query),
|
|
});
|
|
});
|
|
|
|
router.post('/recommendations/decline', (req: Req, res: Res) => {
|
|
const { decline_key } = req.body || {};
|
|
if (!decline_key || typeof decline_key !== 'string' || decline_key.length > 200) {
|
|
throw ValidationError('decline_key is required', 'decline_key');
|
|
}
|
|
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 });
|
|
});
|
|
|
|
// 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: Req, res: 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) {
|
|
throw ValidationError('bill_id is required', 'bill_id');
|
|
}
|
|
if (txIds.length === 0) {
|
|
throw ValidationError('transaction_ids must be a non-empty array', '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) throw NotFoundError('Bill 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: Req, res: 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) {
|
|
throw ValidationError('category_id is invalid for this user', '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) {
|
|
// Service validation errors are user-facing at 4xx (parity with the old
|
|
// behavior: no-status service throws surfaced at 400, never as masked 5xx).
|
|
throw new ApiError(
|
|
err.status ? 'VALIDATION_ERROR' : 'SUBSCRIPTION_CREATE_ERROR',
|
|
err.message || 'Could not create subscription',
|
|
err.status || 400,
|
|
{ field: err.field || null },
|
|
);
|
|
}
|
|
});
|
|
|
|
// ── Catalog browser ───────────────────────────────────────────────────────────
|
|
|
|
router.get('/catalog', (req: Req, res: Res) => {
|
|
const db = getDb();
|
|
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 });
|
|
});
|
|
|
|
// Update which catalog entry a bill is linked to (or unlink with null)
|
|
router.put('/:id/catalog-link', (req: Req, res: Res) => {
|
|
const db = getDb();
|
|
const billId = parseInt(req.params.id, 10);
|
|
if (!Number.isInteger(billId) || billId < 1) {
|
|
throw ValidationError('Invalid bill ID');
|
|
}
|
|
|
|
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) throw NotFoundError('Bill 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) {
|
|
throw ValidationError('catalog_id must be a positive integer or null', 'catalog_id');
|
|
}
|
|
const catalogEntry = db
|
|
.prepare('SELECT id FROM subscription_catalog WHERE id = ?')
|
|
.get(catalogId);
|
|
if (!catalogEntry) throw NotFoundError('Catalog entry 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: Req, res: Res) => {
|
|
const db = getDb();
|
|
const catalogId = parseInt(req.params.catalogId, 10);
|
|
if (!Number.isInteger(catalogId) || catalogId < 1) {
|
|
throw ValidationError('Invalid catalog ID');
|
|
}
|
|
|
|
const catalogEntry = db
|
|
.prepare('SELECT id FROM subscription_catalog WHERE id = ?')
|
|
.get(catalogId);
|
|
if (!catalogEntry) throw NotFoundError('Catalog entry not found');
|
|
|
|
const descriptor = String(req.body?.descriptor ?? '').trim();
|
|
if (!descriptor) {
|
|
throw ValidationError('descriptor is required', 'descriptor');
|
|
}
|
|
if (descriptor.length > 100) {
|
|
throw ValidationError('descriptor must be 100 characters or less', 'descriptor');
|
|
}
|
|
|
|
// 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) {
|
|
throw ConflictError(
|
|
'Descriptor already exists for this service',
|
|
'descriptor',
|
|
'DUPLICATE_ERROR',
|
|
);
|
|
}
|
|
|
|
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 });
|
|
});
|
|
|
|
// Delete a user-added catalog descriptor
|
|
router.delete('/catalog/descriptors/:id', (req: Req, res: Res) => {
|
|
const db = getDb();
|
|
const descriptorId = parseInt(req.params.id, 10);
|
|
if (!Number.isInteger(descriptorId) || descriptorId < 1) {
|
|
throw ValidationError('Invalid descriptor ID');
|
|
}
|
|
|
|
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) {
|
|
throw NotFoundError('Descriptor 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 });
|
|
});
|
|
|
|
router.patch('/:id', (req: Req, res: Res) => {
|
|
const db = getDb();
|
|
const billId = parseInt(req.params.id, 10);
|
|
if (!Number.isInteger(billId)) {
|
|
throw ValidationError('bill_id must be an integer', '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) throw NotFoundError('Bill 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
|
|
) {
|
|
throw ValidationError('reminder_days_before must be between 0 and 30', '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;
|