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 ,
} = 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
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' ) ) ;
const update = 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'
` );
let matchedCount = 0 ;
db . transaction ( ( ) => {
for ( const id of txIds ) {
matchedCount += update . run ( billId , id , req . user . id ) . changes ;
}
} ) ( ) ;
// 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 ) ) ;
}
} ) ;
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 ;