2026-05-28 22:54:07 -05:00
const express = require ( 'express' ) ;
const router = express . Router ( ) ;
const { getDb , ensureUserDefaultCategories } = require ( '../db/database' ) ;
const { standardizeError } = require ( '../middleware/errorFormatter' ) ;
const {
createSubscriptionFromRecommendation ,
2026-05-29 02:51:30 -05:00
declineRecommendation ,
2026-05-28 22:54:07 -05:00
decorateSubscription ,
getSubscriptionRecommendations ,
getSubscriptionSummary ,
getSubscriptions ,
2026-06-06 20:02:13 -05:00
monthlyEquivalent ,
2026-05-30 17:27:15 -05:00
searchSubscriptionTransactions ,
2026-05-28 22:54:07 -05:00
} = require ( '../services/subscriptionService' ) ;
2026-05-29 03:38:48 -05:00
const { addMerchantRule , applyMerchantRules } = require ( '../services/billMerchantRuleService' ) ;
2026-05-28 22:54:07 -05:00
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 ) ,
} ) ;
} ) ;
2026-05-29 02:51:30 -05:00
2026-05-30 17:27:15 -05:00
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' ) ) ;
}
} ) ;
2026-05-29 02:51:30 -05:00
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' ) ) ;
}
} ) ;
2026-05-28 22:54:07 -05:00
2026-05-29 03:38:48 -05:00
// 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' ) ) ;
2026-05-29 04:19:20 -05:00
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 ( `
2026-05-29 03:38:48 -05:00
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'
` );
2026-05-29 04:19:20 -05:00
const insertPayment = db . prepare ( `
INSERT OR IGNORE INTO payments ( bill _id , amount , paid _date , payment _source , transaction _id )
VALUES ( ? , ? , ? , 'auto_match' , ? )
` );
2026-05-29 03:38:48 -05:00
let matchedCount = 0 ;
db . transaction ( ( ) => {
2026-05-29 04:19:20 -05:00
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 ) ;
2026-05-29 03:38:48 -05:00
}
} ) ( ) ;
// 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 } ) ;
} ) ;
2026-05-28 22:54:07 -05:00
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 || { } ) ;
2026-05-29 03:38:48 -05:00
// 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 ) ;
2026-05-28 22:54:07 -05:00
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 ) ) ;
}
} ) ;
2026-06-06 20:02:13 -05:00
// ── 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' ) ) ;
}
} ) ;
2026-05-28 22:54:07 -05:00
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 ;