2026-05-03 19:51:57 -05:00
const express = require ( 'express' ) ;
const router = express . Router ( ) ;
const { getDb , ensureUserDefaultCategories } = require ( '../db/database' ) ;
2026-05-16 15:38:28 -05:00
const {
auditBillsForUser ,
categoryBelongsToUser ,
insertBill ,
parseTemplateData ,
sanitizeTemplateData ,
validateBillData ,
computeBalanceDelta ,
2026-06-06 16:34:20 -05:00
applyBalanceDelta ,
2026-05-16 15:38:28 -05:00
} = require ( '../services/billsService' ) ;
2026-05-15 00:03:32 -05:00
const { amortizationSchedule , debtAprSnapshot } = require ( '../services/aprService' ) ;
2026-05-09 13:03:36 -05:00
const { standardizeError } = require ( '../middleware/errorFormatter' ) ;
2026-05-15 22:45:38 -05:00
const { validatePaymentInput } = require ( '../services/paymentValidation' ) ;
2026-06-04 02:20:30 -05:00
const { addMerchantRule , syncBillPaymentsFromSimplefin , merchantMatches } = require ( '../services/billMerchantRuleService' ) ;
2026-06-03 21:21:38 -05:00
const { normalizeMerchant } = require ( '../services/subscriptionService' ) ;
2026-05-16 21:36:04 -05:00
const { decorateTransaction } = require ( '../services/transactionService' ) ;
2026-05-03 19:51:57 -05:00
// ── GET /api/bills ────────────────────────────────────────────────────────────
router . get ( '/' , ( req , res ) => {
const db = getDb ( ) ;
ensureUserDefaultCategories ( req . user . id ) ;
const includeInactive = req . query . inactive === 'true' ;
2026-06-03 22:25:30 -05:00
// LEFT JOIN on a pre-grouped subquery is one query instead of N+1 correlated EXISTS lookups.
2026-05-03 19:51:57 -05:00
const bills = db . prepare ( `
SELECT b . * , c . name AS category _name ,
2026-06-03 22:25:30 -05:00
CASE WHEN hr . bill _id IS NOT NULL THEN 1 ELSE 0 END AS has _history _ranges
2026-05-03 19:51:57 -05:00
FROM bills b
2026-05-16 10:34:32 -05:00
LEFT JOIN categories c ON b . category _id = c . id AND c . deleted _at IS NULL
2026-06-03 22:25:30 -05:00
LEFT JOIN ( SELECT DISTINCT bill _id FROM bill _history _ranges ) hr ON hr . bill _id = b . id
2026-05-03 19:51:57 -05:00
WHERE b . user _id = ?
2026-05-16 10:34:32 -05:00
AND b . deleted _at IS NULL
2026-05-03 19:51:57 -05:00
$ { includeInactive ? '' : 'AND b.active = 1' }
2026-05-30 16:13:37 -05:00
ORDER BY CASE WHEN b . sort _order IS NULL THEN 1 ELSE 0 END , b . sort _order ASC , b . due _day ASC , b . name ASC
2026-05-03 19:51:57 -05:00
` ).all(req.user.id);
res . json ( bills ) ;
2026-05-16 10:56:56 -05:00
} ) ;
2026-05-30 16:13:37 -05:00
// ── PUT /api/bills/reorder ───────────────────────────────────────────────────
router . put ( '/reorder' , ( req , res ) => {
const db = getDb ( ) ;
const entries = Object . entries ( req . body || { } ) . map ( ( [ billId , sortOrder ] ) => ( {
billId : Number ( billId ) ,
sortOrder : Number ( sortOrder ) ,
} ) ) ;
if ( entries . length === 0 ) {
return res . status ( 400 ) . json ( standardizeError ( 'At least one bill order is required' , 'VALIDATION_ERROR' , 'reorder' ) ) ;
}
const invalid = entries . find ( ( { billId , sortOrder } ) => (
! Number . isInteger ( billId ) || billId <= 0 || ! Number . isInteger ( sortOrder ) || sortOrder < 0
) ) ;
if ( invalid ) {
return res . status ( 400 ) . json ( standardizeError ( 'Reorder payload must map bill ids to non-negative integer positions' , 'VALIDATION_ERROR' , 'reorder' ) ) ;
}
const ids = entries . map ( item => item . billId ) ;
const placeholders = ids . map ( ( ) => '?' ) . join ( ',' ) ;
const owned = db . prepare ( `
SELECT id
FROM bills
WHERE user _id = ? AND deleted _at IS NULL AND id IN ( $ { placeholders } )
` ).all(req.user.id, ...ids);
if ( owned . length !== ids . length ) {
return res . status ( 404 ) . json ( standardizeError ( 'One or more bills were not found' , 'NOT_FOUND' , 'bill_id' ) ) ;
}
const update = db . prepare ( "UPDATE bills SET sort_order = ?, updated_at = datetime('now') WHERE id = ? AND user_id = ?" ) ;
const applyOrder = db . transaction ( ( items ) => {
for ( const item of items ) update . run ( item . sortOrder , item . billId , req . user . id ) ;
} ) ;
applyOrder ( entries ) ;
const bills = db . prepare ( `
SELECT b . * , c . name AS category _name
FROM bills b
LEFT JOIN categories c ON b . category _id = c . id AND c . deleted _at IS NULL
WHERE b . user _id = ? AND b . deleted _at IS NULL AND b . active = 1
ORDER BY CASE WHEN b . sort _order IS NULL THEN 1 ELSE 0 END , b . sort _order ASC , b . due _day ASC , b . name ASC
` ).all(req.user.id);
res . json ( { success : true , bills } ) ;
} ) ;
2026-05-16 10:56:56 -05:00
// ── GET /api/bills/audit?inactive=true ───────────────────────────────────────
router . get ( '/audit' , ( req , res ) => {
const db = getDb ( ) ;
ensureUserDefaultCategories ( req . user . id ) ;
const includeInactive = req . query . inactive === 'true' ;
2026-05-16 15:38:28 -05:00
res . json ( auditBillsForUser ( db , req . user . id , includeInactive ) ) ;
} ) ;
2026-05-30 14:33:55 -05:00
// ── GET /api/bills/drift-report ──────────────────────────────────────────────
router . get ( '/drift-report' , ( req , res ) => {
const { getDriftReport } = require ( '../services/driftService' ) ;
try {
res . json ( getDriftReport ( req . user . id ) ) ;
} catch ( err ) {
res . status ( 500 ) . json ( { error : 'Failed to compute drift report' } ) ;
}
} ) ;
2026-06-04 20:45:11 -05:00
// GET /api/bills/merchant-rules — all rules for this user across all bills
router . get ( '/merchant-rules' , ( req , res ) => {
try {
const rules = getDb ( ) . prepare ( `
SELECT bmr . id , bmr . merchant , bmr . auto _attribute _late , bmr . created _at ,
b . id AS bill _id , b . name AS bill _name
FROM bill _merchant _rules bmr
JOIN bills b ON b . id = bmr . bill _id AND b . deleted _at IS NULL
WHERE bmr . user _id = ?
ORDER BY b . name COLLATE NOCASE ASC , LENGTH ( bmr . merchant ) DESC , bmr . merchant ASC
` ).all(req.user.id);
res . json ( { rules } ) ;
} catch ( err ) {
console . error ( '[bills/merchant-rules GET]' , err . message ) ;
res . status ( 500 ) . json ( { error : 'Failed to load merchant rules' } ) ;
}
} ) ;
2026-05-30 14:33:55 -05:00
// ── POST /api/bills/:id/snooze-drift ─────────────────────────────────────────
// Registered early (before /:id) but path has suffix so no conflict
router . post ( '/:id/snooze-drift' , ( req , res ) => {
const db = getDb ( ) ;
const id = parseInt ( req . params . id , 10 ) ;
if ( ! Number . isInteger ( id ) || id <= 0 ) return res . status ( 400 ) . json ( { error : 'Invalid id' } ) ;
const bill = db . prepare ( 'SELECT id, user_id FROM bills WHERE id = ? AND deleted_at IS NULL' ) . get ( id ) ;
if ( ! bill || bill . user _id !== req . user . id ) return res . status ( 404 ) . json ( { error : 'Not found' } ) ;
const until = new Date ( ) ;
until . setDate ( until . getDate ( ) + 30 ) ;
const untilStr = until . toISOString ( ) . slice ( 0 , 10 ) ;
db . prepare ( 'UPDATE bills SET drift_snoozed_until = ? WHERE id = ?' ) . run ( untilStr , id ) ;
res . json ( { ok : true , drift _snoozed _until : untilStr } ) ;
} ) ;
2026-05-16 15:38:28 -05:00
// ── GET /api/bills/templates ─────────────────────────────────────────────────
router . get ( '/templates' , ( req , res ) => {
const db = getDb ( ) ;
const rows = db . prepare ( `
SELECT id , name , data , created _at , updated _at
FROM bill _templates
WHERE user _id = ?
ORDER BY name COLLATE NOCASE ASC
2026-05-16 10:56:56 -05:00
` ).all(req.user.id);
2026-05-16 15:38:28 -05:00
res . json ( rows . map ( row => ( {
... row ,
data : parseTemplateData ( row . data ) ,
} ) ) ) ;
} ) ;
2026-05-16 10:56:56 -05:00
2026-05-16 15:38:28 -05:00
// ── POST /api/bills/templates ────────────────────────────────────────────────
router . post ( '/templates' , ( req , res ) => {
const db = getDb ( ) ;
const name = String ( req . body . name || '' ) . trim ( ) ;
if ( name . length < 2 ) {
return res . status ( 400 ) . json ( standardizeError ( 'Template name must be at least 2 characters' , 'VALIDATION_ERROR' , 'name' ) ) ;
}
2026-05-16 10:56:56 -05:00
2026-05-16 15:38:28 -05:00
const data = sanitizeTemplateData ( req . body . data || { } ) ;
if ( Object . keys ( data ) . length === 0 ) {
return res . status ( 400 ) . json ( standardizeError ( 'Template data is required' , 'VALIDATION_ERROR' , 'data' ) ) ;
}
const validation = validateBillData ( data ) ;
if ( validation . errors . length > 0 ) {
const firstError = validation . errors [ 0 ] ;
return res . status ( 400 ) . json ( standardizeError ( firstError . message , 'VALIDATION_ERROR' , ` data. ${ firstError . field } ` ) ) ;
}
if ( ! categoryBelongsToUser ( db , validation . normalized . category _id , req . user . id ) ) {
return res . status ( 400 ) . json ( standardizeError ( 'category_id is invalid for this user' , 'VALIDATION_ERROR' , 'data.category_id' ) ) ;
}
const normalizedData = sanitizeTemplateData ( validation . normalized ) ;
2026-05-16 10:56:56 -05:00
2026-05-16 15:38:28 -05:00
const result = db . prepare ( `
INSERT INTO bill _templates ( user _id , name , data , updated _at )
VALUES ( ? , ? , ? , datetime ( 'now' ) )
ON CONFLICT ( user _id , name ) DO UPDATE SET
data = excluded . data ,
updated _at = datetime ( 'now' )
` ).run(req.user.id, name, JSON.stringify(normalizedData));
const template = db . prepare ( `
SELECT id , name , data , created _at , updated _at
FROM bill _templates
WHERE user _id = ? AND name = ?
` ).get(req.user.id, name);
res . status ( result . changes > 0 ? 201 : 200 ) . json ( {
... template ,
data : parseTemplateData ( template . data ) ,
2026-05-16 10:56:56 -05:00
} ) ;
2026-05-03 19:51:57 -05:00
} ) ;
2026-05-16 15:38:28 -05:00
// ── DELETE /api/bills/templates/:templateId ──────────────────────────────────
router . delete ( '/templates/:templateId' , ( req , res ) => {
const db = getDb ( ) ;
const templateId = parseInt ( req . params . templateId , 10 ) ;
if ( ! Number . isInteger ( templateId ) ) {
return res . status ( 400 ) . json ( standardizeError ( 'template_id must be an integer' , 'VALIDATION_ERROR' , 'template_id' ) ) ;
}
const result = db . prepare ( 'DELETE FROM bill_templates WHERE id = ? AND user_id = ?' ) . run ( templateId , req . user . id ) ;
if ( result . changes === 0 ) return res . status ( 404 ) . json ( standardizeError ( 'Template not found' , 'NOT_FOUND' , 'template_id' ) ) ;
res . json ( { success : true } ) ;
} ) ;
// ── POST /api/bills/:id/duplicate ────────────────────────────────────────────
router . post ( '/:id/duplicate' , ( req , res ) => {
const db = getDb ( ) ;
const body = req . body || { } ;
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 source = db . prepare ( 'SELECT * FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL' ) . get ( billId , req . user . id ) ;
if ( ! source ) return res . status ( 404 ) . json ( standardizeError ( 'Bill not found' , 'NOT_FOUND' , 'bill_id' ) ) ;
const draft = {
... sanitizeTemplateData ( source ) ,
... sanitizeTemplateData ( body ) ,
name : String ( body . name || ` ${ source . name } (Copy) ` ) . trim ( ) ,
} ;
const validation = validateBillData ( draft ) ;
if ( validation . errors . length > 0 ) {
const firstError = validation . errors [ 0 ] ;
return res . status ( 400 ) . json ( standardizeError ( firstError . message , 'VALIDATION_ERROR' , firstError . field ) ) ;
}
const { normalized } = validation ;
if ( ! categoryBelongsToUser ( db , normalized . category _id , req . user . id ) ) {
return res . status ( 400 ) . json ( standardizeError ( 'category_id is invalid for this user' , 'VALIDATION_ERROR' , 'category_id' ) ) ;
}
res . status ( 201 ) . json ( insertBill ( db , req . user . id , normalized ) ) ;
} ) ;
2026-05-03 19:51:57 -05:00
// ── GET /api/bills/:id/monthly-state?year=&month= ─────────────────────────────
router . get ( '/:id/monthly-state' , ( req , res ) => {
const db = getDb ( ) ;
const billId = parseInt ( req . params . id , 10 ) ;
2026-05-16 10:34:32 -05:00
if ( ! db . prepare ( 'SELECT id FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL' ) . get ( billId , req . user . id ) )
2026-05-09 13:03:36 -05:00
return res . status ( 404 ) . json ( standardizeError ( 'Bill not found' , 'NOT_FOUND' , 'bill_id' ) ) ;
2026-05-03 19:51:57 -05:00
const year = parseInt ( req . query . year , 10 ) ;
const month = parseInt ( req . query . month , 10 ) ;
if ( isNaN ( year ) || year < 2000 || year > 2100 )
2026-05-09 13:03:36 -05:00
return res . status ( 400 ) . json ( standardizeError ( 'year must be a 4-digit integer between 2000 and 2100' , 'VALIDATION_ERROR' , 'year' ) ) ;
2026-05-03 19:51:57 -05:00
if ( isNaN ( month ) || month < 1 || month > 12 )
2026-05-09 13:03:36 -05:00
return res . status ( 400 ) . json ( standardizeError ( 'month must be an integer between 1 and 12' , 'VALIDATION_ERROR' , 'month' ) ) ;
2026-05-03 19:51:57 -05:00
const mbs = db . prepare (
'SELECT actual_amount, notes, is_skipped FROM monthly_bill_state WHERE bill_id=? AND year=? AND month=?'
) . get ( billId , year , month ) ;
res . json ( {
bill _id : billId ,
year ,
month ,
actual _amount : mbs ? . actual _amount ? ? null ,
notes : mbs ? . notes ? ? null ,
is _skipped : ! ! ( mbs ? . is _skipped ) ,
} ) ;
} ) ;
// ── PUT /api/bills/:id/monthly-state ──────────────────────────────────────────
router . put ( '/:id/monthly-state' , ( req , res ) => {
const db = getDb ( ) ;
const billId = parseInt ( req . params . id , 10 ) ;
2026-05-16 10:34:32 -05:00
if ( ! db . prepare ( 'SELECT id FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL' ) . get ( billId , req . user . id ) )
2026-05-09 13:03:36 -05:00
return res . status ( 404 ) . json ( standardizeError ( 'Bill not found' , 'NOT_FOUND' , 'bill_id' ) ) ;
2026-05-03 19:51:57 -05:00
2026-05-30 13:19:09 -05:00
const { year , month , actual _amount , notes , is _skipped , snoozed _until } = req . body ;
2026-05-03 19:51:57 -05:00
const y = parseInt ( year , 10 ) ;
const m = parseInt ( month , 10 ) ;
if ( isNaN ( y ) || y < 2000 || y > 2100 )
2026-05-09 13:03:36 -05:00
return res . status ( 400 ) . json ( standardizeError ( 'year must be a 4-digit integer between 2000 and 2100' , 'VALIDATION_ERROR' , 'year' ) ) ;
2026-05-03 19:51:57 -05:00
if ( isNaN ( m ) || m < 1 || m > 12 )
2026-05-09 13:03:36 -05:00
return res . status ( 400 ) . json ( standardizeError ( 'month must be an integer between 1 and 12' , 'VALIDATION_ERROR' , 'month' ) ) ;
2026-05-03 19:51:57 -05:00
if ( actual _amount !== undefined && actual _amount !== null ) {
const amt = parseFloat ( actual _amount ) ;
if ( isNaN ( amt ) || amt < 0 )
2026-05-09 13:03:36 -05:00
return res . status ( 400 ) . json ( standardizeError ( 'actual_amount must be a non-negative number or null' , 'VALIDATION_ERROR' , 'actual_amount' ) ) ;
2026-05-03 19:51:57 -05:00
}
2026-05-30 13:19:09 -05:00
if ( snoozed _until !== undefined && snoozed _until !== null ) {
if ( ! /^\d{4}-\d{2}-\d{2}$/ . test ( snoozed _until ) )
return res . status ( 400 ) . json ( standardizeError ( 'snoozed_until must be an ISO date string (YYYY-MM-DD) or null' , 'VALIDATION_ERROR' , 'snoozed_until' ) ) ;
}
const amt = actual _amount !== undefined ? ( actual _amount === null ? null : parseFloat ( actual _amount ) ) : null ;
const noteVal = notes !== undefined ? ( notes || null ) : null ;
const skipVal = is _skipped !== undefined ? ( is _skipped ? 1 : 0 ) : 0 ;
const snoozeVal = snoozed _until !== undefined ? ( snoozed _until || null ) : null ;
2026-05-03 19:51:57 -05:00
db . prepare ( `
2026-05-30 13:19:09 -05:00
INSERT INTO monthly _bill _state ( bill _id , year , month , actual _amount , notes , is _skipped , snoozed _until , updated _at )
VALUES ( ? , ? , ? , ? , ? , ? , ? , datetime ( 'now' ) )
2026-05-03 19:51:57 -05:00
ON CONFLICT ( bill _id , year , month ) DO UPDATE SET
actual _amount = excluded . actual _amount ,
notes = excluded . notes ,
is _skipped = excluded . is _skipped ,
2026-05-30 13:19:09 -05:00
snoozed _until = excluded . snoozed _until ,
2026-05-03 19:51:57 -05:00
updated _at = datetime ( 'now' )
2026-05-30 13:19:09 -05:00
` ).run(billId, y, m, amt, noteVal, skipVal, snoozeVal);
2026-05-03 19:51:57 -05:00
const saved = db . prepare (
'SELECT * FROM monthly_bill_state WHERE bill_id=? AND year=? AND month=?'
) . get ( billId , y , m ) ;
res . json ( {
bill _id : saved . bill _id ,
year : saved . year ,
month : saved . month ,
actual _amount : saved . actual _amount ,
notes : saved . notes ,
is _skipped : ! ! saved . is _skipped ,
2026-05-30 13:19:09 -05:00
snoozed _until : saved . snoozed _until ? ? null ,
2026-05-03 19:51:57 -05:00
created _at : saved . created _at ,
updated _at : saved . updated _at ,
} ) ;
} ) ;
// ── GET /api/bills/:id ────────────────────────────────────────────────────────
router . get ( '/:id' , ( req , res ) => {
const db = getDb ( ) ;
const bill = db . prepare ( `
SELECT b . * , c . name AS category _name ,
CASE WHEN EXISTS (
SELECT 1 FROM bill _history _ranges WHERE bill _id = b . id
2026-05-29 04:19:20 -05:00
) THEN 1 ELSE 0 END AS has _history _ranges ,
CASE WHEN EXISTS (
SELECT 1 FROM bill _merchant _rules WHERE bill _id = b . id AND user _id = b . user _id
) THEN 1 ELSE 0 END AS has _merchant _rule
2026-05-03 19:51:57 -05:00
FROM bills b
2026-05-16 10:34:32 -05:00
LEFT JOIN categories c ON b . category _id = c . id AND c . deleted _at IS NULL
WHERE b . id = ? AND b . user _id = ? AND b . deleted _at IS NULL
2026-05-03 19:51:57 -05:00
` ).get(req.params.id, req.user.id);
2026-05-09 13:03:36 -05:00
if ( ! bill ) return res . status ( 404 ) . json ( standardizeError ( 'Bill not found' , 'NOT_FOUND' , 'bill_id' ) ) ;
2026-05-03 19:51:57 -05:00
res . json ( bill ) ;
} ) ;
// ── POST /api/bills ───────────────────────────────────────────────────────────
router . post ( '/' , ( req , res ) => {
const db = getDb ( ) ;
2026-05-16 15:38:28 -05:00
const body = req . body || { } ;
let payload = body ;
if ( body . source _bill _id !== undefined && body . source _bill _id !== null && body . source _bill _id !== '' ) {
const sourceBillId = parseInt ( body . source _bill _id , 10 ) ;
if ( ! Number . isInteger ( sourceBillId ) ) {
return res . status ( 400 ) . json ( standardizeError ( 'source_bill_id must be an integer' , 'VALIDATION_ERROR' , 'source_bill_id' ) ) ;
}
const source = db . prepare ( 'SELECT * FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL' ) . get ( sourceBillId , req . user . id ) ;
if ( ! source ) return res . status ( 404 ) . json ( standardizeError ( 'Source bill not found' , 'NOT_FOUND' , 'source_bill_id' ) ) ;
payload = {
... sanitizeTemplateData ( source ) ,
... sanitizeTemplateData ( body ) ,
name : String ( body . name || ` ${ source . name } (Copy) ` ) . trim ( ) ,
} ;
}
2026-05-03 19:51:57 -05:00
2026-05-11 12:12:31 -05:00
// Validate and normalize bill data
2026-05-16 15:38:28 -05:00
const validation = validateBillData ( payload ) ;
2026-05-11 12:12:31 -05:00
if ( validation . errors . length > 0 ) {
const firstError = validation . errors [ 0 ] ;
return res . status ( 400 ) . json ( standardizeError ( firstError . message , 'VALIDATION_ERROR' , firstError . field ) ) ;
2026-05-03 19:51:57 -05:00
}
2026-05-11 12:12:31 -05:00
const { normalized } = validation ;
2026-05-03 19:51:57 -05:00
2026-05-11 12:12:31 -05:00
// Validate category_id exists for this user
2026-05-16 15:38:28 -05:00
if ( ! categoryBelongsToUser ( db , normalized . category _id , req . user . id ) ) {
2026-05-09 13:03:36 -05:00
return res . status ( 400 ) . json ( standardizeError ( 'category_id is invalid for this user' , 'VALIDATION_ERROR' , 'category_id' ) ) ;
2026-05-03 19:51:57 -05:00
}
2026-05-16 15:38:28 -05:00
res . status ( 201 ) . json ( insertBill ( db , req . user . id , normalized ) ) ;
2026-05-03 19:51:57 -05:00
} ) ;
// ── PUT /api/bills/:id ────────────────────────────────────────────────────────
router . put ( '/:id' , ( req , res ) => {
const db = getDb ( ) ;
2026-05-16 10:34:32 -05:00
const existing = db . prepare ( 'SELECT * FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL' ) . get ( req . params . id , req . user . id ) ;
2026-05-09 13:03:36 -05:00
if ( ! existing ) return res . status ( 404 ) . json ( standardizeError ( 'Bill not found' , 'NOT_FOUND' , 'bill_id' ) ) ;
2026-05-03 19:51:57 -05:00
2026-05-11 12:12:31 -05:00
// Validate and normalize bill data
const validation = validateBillData ( req . body , existing ) ;
if ( validation . errors . length > 0 ) {
const firstError = validation . errors [ 0 ] ;
return res . status ( 400 ) . json ( standardizeError ( firstError . message , 'VALIDATION_ERROR' , firstError . field ) ) ;
2026-05-03 19:51:57 -05:00
}
2026-05-11 12:12:31 -05:00
const { normalized } = validation ;
2026-05-03 19:51:57 -05:00
2026-05-11 12:12:31 -05:00
// Validate category_id exists for this user if changed
2026-05-16 15:38:28 -05:00
if ( ! categoryBelongsToUser ( db , normalized . category _id , req . user . id ) ) {
2026-05-11 12:12:31 -05:00
return res . status ( 400 ) . json ( standardizeError ( 'category_id is invalid for this user' , 'VALIDATION_ERROR' , 'category_id' ) ) ;
2026-05-10 00:39:11 -05:00
}
2026-05-03 19:51:57 -05:00
db . prepare ( `
UPDATE bills SET
name = ? , category _id = ? , due _day = ? , override _due _date = ? , bucket = ? ,
2026-05-16 15:38:28 -05:00
expected _amount = ? , interest _rate = ? , billing _cycle = ? , autopay _enabled = ? , autodraft _status = ? , auto _mark _paid = ? ,
2026-05-03 19:51:57 -05:00
website = ? , username = ? , account _info = ? , has _2fa = ? , notes = ? , active = ? ,
2026-05-10 00:39:11 -05:00
history _visibility = ? , cycle _type = ? , cycle _day = ? ,
2026-05-14 03:00:01 -05:00
current _balance = ? , minimum _payment = ? , snowball _order = ? , snowball _include = ? , snowball _exempt = ? ,
2026-05-28 22:54:07 -05:00
is _subscription = ? , subscription _type = ? , reminder _days _before = ? , subscription _source = ? , subscription _detected _at = ? ,
2026-05-03 19:51:57 -05:00
updated _at = datetime ( 'now' )
WHERE id = ? AND user _id = ?
` ).run(
2026-05-11 12:12:31 -05:00
normalized . name ,
normalized . category _id ,
normalized . due _day ,
normalized . override _due _date ,
normalized . bucket ,
normalized . expected _amount ,
normalized . interest _rate ,
normalized . billing _cycle ,
normalized . autopay _enabled ,
normalized . autodraft _status ,
2026-05-16 15:38:28 -05:00
normalized . auto _mark _paid ,
2026-05-11 12:12:31 -05:00
normalized . website ,
normalized . username ,
normalized . account _info ,
normalized . has _2fa ,
normalized . notes ,
normalized . active ,
normalized . history _visibility ,
normalized . cycle _type ,
normalized . cycle _day ,
2026-05-14 02:11:54 -05:00
normalized . current _balance ,
normalized . minimum _payment ,
normalized . snowball _order ,
normalized . snowball _include ,
2026-05-14 03:00:01 -05:00
normalized . snowball _exempt ,
2026-05-28 22:54:07 -05:00
normalized . is _subscription ,
normalized . subscription _type ,
normalized . reminder _days _before ,
normalized . subscription _source ,
normalized . subscription _detected _at ,
2026-05-03 19:51:57 -05:00
req . params . id ,
req . user . id ,
) ;
2026-05-16 10:34:32 -05:00
const updated = db . prepare ( 'SELECT * FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL' ) . get ( req . params . id , req . user . id ) ;
2026-05-03 19:51:57 -05:00
res . json ( updated ) ;
} ) ;
2026-05-30 16:13:37 -05:00
// ── PUT /api/bills/:id/archived ──────────────────────────────────────────────
router . put ( '/:id/archived' , ( req , res ) => {
const db = getDb ( ) ;
const id = parseInt ( req . params . id , 10 ) ;
if ( ! Number . isInteger ( id ) || id <= 0 ) {
return res . status ( 400 ) . json ( standardizeError ( 'Invalid id' , 'VALIDATION_ERROR' , 'bill_id' ) ) ;
}
const bill = db . prepare ( 'SELECT id FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL' ) . get ( id , req . user . id ) ;
if ( ! bill ) return res . status ( 404 ) . json ( standardizeError ( 'Bill not found' , 'NOT_FOUND' , 'bill_id' ) ) ;
const archived = ! ! req . body ? . archived ;
db . prepare ( "UPDATE bills SET active = ?, updated_at = datetime('now') WHERE id = ? AND user_id = ?" )
. run ( archived ? 0 : 1 , id , req . user . id ) ;
const updated = db . prepare ( 'SELECT * FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL' ) . get ( id , req . user . id ) ;
res . json ( { ... updated , archived : ! updated . active } ) ;
} ) ;
2026-05-16 10:34:32 -05:00
// ── DELETE /api/bills/:id — soft delete for 30-day recovery ───────────────────
2026-05-03 19:51:57 -05:00
router . delete ( '/:id' , ( req , res ) => {
const db = getDb ( ) ;
2026-05-16 10:34:32 -05:00
const bill = db . prepare ( 'SELECT id, name FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL' ) . get ( req . params . id , req . user . id ) ;
2026-05-09 13:03:36 -05:00
if ( ! bill ) return res . status ( 404 ) . json ( standardizeError ( 'Bill not found' , 'NOT_FOUND' , 'bill_id' ) ) ;
2026-05-03 19:51:57 -05:00
2026-05-16 10:34:32 -05:00
db . prepare ( "UPDATE bills SET deleted_at = datetime('now'), active = 0, updated_at = datetime('now') WHERE id = ? AND user_id = ?" )
. run ( req . params . id , req . user . id ) ;
2026-05-03 19:51:57 -05:00
res . json ( {
success : true ,
deleted _bill _id : bill . id ,
deleted _bill _name : bill . name ,
2026-05-16 10:34:32 -05:00
recoverable _until _days : 30 ,
2026-05-03 19:51:57 -05:00
} ) ;
} ) ;
2026-05-16 10:34:32 -05:00
// POST /api/bills/:id/restore — undo bill soft delete
router . post ( '/:id/restore' , ( req , res ) => {
const db = getDb ( ) ;
const bill = db . prepare ( 'SELECT id, name FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NOT NULL' ) . get ( req . params . id , req . user . id ) ;
if ( ! bill ) return res . status ( 404 ) . json ( standardizeError ( 'Deleted bill not found' , 'NOT_FOUND' , 'bill_id' ) ) ;
db . prepare ( "UPDATE bills SET deleted_at = NULL, active = 1, updated_at = datetime('now') WHERE id = ? AND user_id = ?" )
. run ( req . params . id , req . user . id ) ;
res . json ( db . prepare ( 'SELECT * FROM bills WHERE id = ? AND user_id = ?' ) . get ( req . params . id , req . user . id ) ) ;
} ) ;
2026-05-29 04:19:20 -05:00
// POST /api/bills/:id/sync-simplefin-payments
// Scan unmatched SimpleFIN transactions for this bill's merchant rules and
// backfill any missing payments.
router . post ( '/:id/sync-simplefin-payments' , ( req , res ) => {
const billId = parseInt ( req . params . id , 10 ) ;
if ( ! Number . isInteger ( billId ) ) return res . status ( 400 ) . json ( standardizeError ( 'Invalid bill id' , 'VALIDATION_ERROR' ) ) ;
const db = getDb ( ) ;
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' ) ) ;
try {
const result = syncBillPaymentsFromSimplefin ( db , req . user . id , billId ) ;
res . json ( result ) ;
} catch ( err ) {
res . status ( 500 ) . json ( standardizeError ( err . message || 'Sync failed' , 'SYNC_ERROR' ) ) ;
}
} ) ;
2026-05-03 19:51:57 -05:00
// ── GET /api/bills/:id/payments?page=1&limit=20 ───────────────────────────────
router . get ( '/:id/payments' , ( req , res ) => {
const db = getDb ( ) ;
2026-05-16 10:34:32 -05:00
const bill = db . prepare ( 'SELECT id, name FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL' ) . get ( req . params . id , req . user . id ) ;
2026-05-09 13:03:36 -05:00
if ( ! bill ) return res . status ( 404 ) . json ( standardizeError ( 'Bill not found' , 'NOT_FOUND' , 'bill_id' ) ) ;
2026-05-03 19:51:57 -05:00
const limit = Math . min ( parseInt ( req . query . limit || '20' , 10 ) , 100 ) ;
const page = Math . max ( parseInt ( req . query . page || '1' , 10 ) , 1 ) ;
const offset = ( page - 1 ) * limit ;
const total = db . prepare (
'SELECT COUNT(*) AS n FROM payments WHERE bill_id = ? AND deleted_at IS NULL'
) . get ( req . params . id ) . n ;
const items = db . prepare (
'SELECT * FROM payments WHERE bill_id = ? AND deleted_at IS NULL ORDER BY paid_date DESC LIMIT ? OFFSET ?'
) . all ( req . params . id , limit , offset ) ;
res . json ( {
bill _id : parseInt ( req . params . id , 10 ) ,
bill _name : bill . name ,
total ,
page ,
limit ,
pages : Math . ceil ( total / limit ) ,
payments : items ,
} ) ;
} ) ;
2026-05-16 21:36:04 -05:00
// ── GET /api/bills/:id/transactions ──────────────────────────────────────────
router . get ( '/:id/transactions' , ( 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 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 rows = db . prepare ( `
SELECT
t . id , t . user _id , t . data _source _id , t . account _id , t . provider _transaction _id ,
t . source _type , t . transaction _type , t . posted _date , t . transacted _at , t . amount ,
t . currency , t . description , t . payee , t . memo , t . category , t . matched _bill _id ,
t . match _status , t . ignored , t . created _at , t . updated _at ,
ds . type AS data _source _type , ds . provider AS data _source _provider ,
ds . name AS data _source _name , ds . status AS data _source _status ,
fa . name AS account _name , fa . org _name AS account _org _name ,
fa . account _type AS account _type ,
b . name AS matched _bill _name ,
p . id AS linked _payment _id ,
p . amount AS linked _payment _amount ,
p . paid _date AS linked _payment _date ,
p . payment _source AS linked _payment _source ,
p . method AS linked _payment _method
FROM transactions t
LEFT JOIN data _sources ds ON ds . id = t . data _source _id AND ds . user _id = t . user _id
LEFT JOIN financial _accounts fa ON fa . id = t . account _id AND fa . user _id = t . user _id
LEFT JOIN bills b ON b . id = t . matched _bill _id AND b . user _id = t . user _id AND b . deleted _at IS NULL
2026-05-16 21:41:13 -05:00
JOIN payments p ON p . transaction _id = t . id AND p . bill _id = ? AND p . deleted _at IS NULL
2026-05-16 21:36:04 -05:00
WHERE t . user _id = ?
AND t . matched _bill _id = ?
AND t . match _status = 'matched'
AND t . ignored = 0
ORDER BY COALESCE ( t . posted _date , substr ( t . transacted _at , 1 , 10 ) , t . created _at ) DESC , t . id DESC
` ).all(billId, req.user.id, billId);
const transactions = rows . map ( row => decorateTransaction ( {
... row ,
linked _payment : row . linked _payment _id ? {
id : row . linked _payment _id ,
amount : row . linked _payment _amount ,
paid _date : row . linked _payment _date ,
payment _source : row . linked _payment _source ,
method : row . linked _payment _method ,
} : null ,
} ) ) ;
res . json ( {
bill _id : billId ,
bill _name : bill . name ,
total : transactions . length ,
transactions ,
} ) ;
} ) ;
2026-05-09 13:03:36 -05:00
// ── POST /api/bills/:id/toggle-paid — toggle Paid/Unpaid status ──────────────
router . post ( '/:id/toggle-paid' , ( req , res ) => {
const db = getDb ( ) ;
const billId = parseInt ( req . params . id , 10 ) ;
2026-05-10 15:25:47 -05:00
// Get bill - always scope to the requesting user
2026-05-16 15:38:28 -05:00
const bill = db . prepare ( 'SELECT id, expected_amount, user_id, due_day, current_balance, interest_rate, autopay_enabled FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL' ) . get ( billId , req . user . id ) ;
2026-05-09 13:03:36 -05:00
if ( ! bill ) return res . status ( 404 ) . json ( standardizeError ( 'Bill not found' , 'NOT_FOUND' , 'bill_id' ) ) ;
2026-05-11 11:56:49 -05:00
// Scope to year/month if provided
const year = req . body . year !== undefined ? parseInt ( req . body . year , 10 ) : null ;
const month = req . body . month !== undefined ? parseInt ( req . body . month , 10 ) : null ;
2026-05-15 22:45:38 -05:00
if ( ( year === null ) !== ( month === null ) ) {
return res . status ( 400 ) . json ( standardizeError ( 'year and month must both be provided or both omitted' , 'VALIDATION_ERROR' , 'year' ) ) ;
}
if ( year !== null && ( Number . isNaN ( year ) || year < 2000 || year > 2100 ) ) {
return res . status ( 400 ) . json ( standardizeError ( 'year must be a 4-digit integer between 2000 and 2100' , 'VALIDATION_ERROR' , 'year' ) ) ;
}
if ( month !== null && ( Number . isNaN ( month ) || month < 1 || month > 12 ) ) {
return res . status ( 400 ) . json ( standardizeError ( 'month must be an integer between 1 and 12' , 'VALIDATION_ERROR' , 'month' ) ) ;
}
2026-05-11 11:56:49 -05:00
let currentPayment ;
if ( year !== null && month !== null ) {
currentPayment = db . prepare (
'SELECT * FROM payments WHERE bill_id = ? AND deleted_at IS NULL AND strftime(\'%Y\', paid_date) = ? AND strftime(\'%m\', paid_date) = ? ORDER BY paid_date DESC LIMIT 1'
) . get ( billId , String ( year ) , String ( month ) . padStart ( 2 , '0' ) ) ;
} else {
currentPayment = db . prepare (
'SELECT * FROM payments WHERE bill_id = ? AND deleted_at IS NULL ORDER BY paid_date DESC LIMIT 1'
) . get ( billId ) ;
}
2026-05-09 13:03:36 -05:00
// If paid (has payment), remove it → Unpaid
if ( currentPayment ) {
2026-05-14 02:11:54 -05:00
// Reverse any balance delta that was applied when this payment was created
if ( currentPayment . balance _delta != null ) {
const freshBill = db . prepare ( 'SELECT current_balance FROM bills WHERE id = ?' ) . get ( billId ) ;
if ( freshBill ? . current _balance != null ) {
const restored = Math . max ( 0 , Math . round ( ( freshBill . current _balance - currentPayment . balance _delta ) * 100 ) / 100 ) ;
db . prepare ( "UPDATE bills SET current_balance = ?, updated_at = datetime('now') WHERE id = ?" ) . run ( restored , billId ) ;
}
}
2026-05-09 13:03:36 -05:00
db . prepare ( "UPDATE payments SET deleted_at = datetime('now') WHERE id = ?" ) . run ( currentPayment . id ) ;
res . json ( {
success : true ,
isPaid : false ,
action : 'removed_payment' ,
paymentId : currentPayment . id ,
} ) ;
return ;
}
// If unpaid, create payment → Paid
// Use expected_amount if no amount provided
2026-05-15 22:45:38 -05:00
const amount = req . body . amount !== undefined ? req . body . amount : bill . expected _amount ;
2026-05-11 11:56:49 -05:00
// Determine paid_date
let paidDate = req . body . paid _date ;
if ( ! paidDate && year !== null && month !== null ) {
// Calculate paid_date from bill's due_day clamped to the month's days
const daysInMonth = new Date ( year , month , 0 ) . getDate ( ) ;
const day = Math . min ( Math . max ( Number ( bill . due _day ) , 1 ) , daysInMonth ) ;
paidDate = ` ${ year } - ${ String ( month ) . padStart ( 2 , '0' ) } - ${ String ( day ) . padStart ( 2 , '0' ) } ` ;
} else if ( ! paidDate ) {
paidDate = new Date ( ) . toISOString ( ) . slice ( 0 , 10 ) ;
}
2026-05-09 13:03:36 -05:00
const method = req . body . method || null ;
const notes = req . body . notes || null ;
2026-05-15 22:45:38 -05:00
const paymentValidation = validatePaymentInput (
2026-05-16 20:26:09 -05:00
{ amount , paid _date : paidDate , payment _source : req . body . payment _source ? ? 'manual' } ,
2026-05-15 22:45:38 -05:00
{ requireBillId : false } ,
) ;
if ( paymentValidation . error ) {
return res . status ( 400 ) . json ( standardizeError ( paymentValidation . error , 'VALIDATION_ERROR' , paymentValidation . field ) ) ;
2026-05-09 13:03:36 -05:00
}
2026-05-15 22:45:38 -05:00
const payment = paymentValidation . normalized ;
2026-05-09 13:03:36 -05:00
2026-05-14 02:11:54 -05:00
// Compute balance delta for debt bills before inserting
2026-05-15 22:45:38 -05:00
const balCalc = computeBalanceDelta ( bill , payment . amount ) ;
2026-05-14 02:11:54 -05:00
2026-05-09 13:03:36 -05:00
const result = db . prepare (
2026-06-06 16:34:20 -05:00
'INSERT INTO payments (bill_id, amount, paid_date, method, notes, balance_delta, interest_delta, payment_source) VALUES (?, ?, ?, ?, ?, ?, ?, ?)'
) . run ( billId , payment . amount , payment . paid _date , method , notes , balCalc ? . balance _delta ? ? null , balCalc ? . interest _delta ? ? null , payment . payment _source ) ;
2026-05-14 02:11:54 -05:00
2026-06-06 16:34:20 -05:00
applyBalanceDelta ( db , billId , balCalc ) ;
2026-05-09 13:03:36 -05:00
res . status ( 201 ) . json ( {
success : true ,
isPaid : true ,
action : 'created_payment' ,
payment : db . prepare ( 'SELECT * FROM payments WHERE id = ?' ) . get ( result . lastInsertRowid ) ,
} ) ;
} ) ;
2026-05-03 19:51:57 -05:00
// ── GET /api/bills/:id/history-ranges ────────────────────────────────────────
router . get ( '/:id/history-ranges' , ( req , res ) => {
const db = getDb ( ) ;
2026-05-16 10:34:32 -05:00
if ( ! db . prepare ( 'SELECT id FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL' ) . get ( req . params . id , req . user . id ) )
2026-05-09 13:03:36 -05:00
return res . status ( 404 ) . json ( standardizeError ( 'Bill not found' , 'NOT_FOUND' , 'bill_id' ) ) ;
2026-05-03 19:51:57 -05:00
const ranges = db . prepare (
'SELECT * FROM bill_history_ranges WHERE bill_id = ? ORDER BY start_year ASC, start_month ASC'
) . all ( req . params . id ) ;
2026-05-16 10:34:32 -05:00
const bill = db . prepare ( 'SELECT history_visibility FROM bills WHERE id = ? AND deleted_at IS NULL' ) . get ( req . params . id ) ;
2026-05-03 19:51:57 -05:00
res . json ( { bill _id : parseInt ( req . params . id , 10 ) , history _visibility : bill . history _visibility , ranges } ) ;
} ) ;
// ── POST /api/bills/:id/history-ranges ───────────────────────────────────────
router . post ( '/:id/history-ranges' , ( req , res ) => {
const db = getDb ( ) ;
2026-05-16 10:34:32 -05:00
if ( ! db . prepare ( 'SELECT id FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL' ) . get ( req . params . id , req . user . id ) )
2026-05-09 13:03:36 -05:00
return res . status ( 404 ) . json ( standardizeError ( 'Bill not found' , 'NOT_FOUND' , 'bill_id' ) ) ;
2026-05-03 19:51:57 -05:00
const { start _year , start _month , end _year , end _month , label } = req . body ;
const sy = parseInt ( start _year , 10 ) ;
const sm = parseInt ( start _month , 10 ) ;
if ( isNaN ( sy ) || sy < 2000 || sy > 2100 )
2026-05-09 13:03:36 -05:00
return res . status ( 400 ) . json ( standardizeError ( 'start_year must be between 2000 and 2100' , 'VALIDATION_ERROR' , 'start_year' ) ) ;
2026-05-03 19:51:57 -05:00
if ( isNaN ( sm ) || sm < 1 || sm > 12 )
2026-05-09 13:03:36 -05:00
return res . status ( 400 ) . json ( standardizeError ( 'start_month must be between 1 and 12' , 'VALIDATION_ERROR' , 'start_month' ) ) ;
2026-05-03 19:51:57 -05:00
let ey = null , em = null ;
if ( end _year != null ) {
ey = parseInt ( end _year , 10 ) ;
if ( isNaN ( ey ) || ey < 2000 || ey > 2100 )
2026-05-09 13:03:36 -05:00
return res . status ( 400 ) . json ( standardizeError ( 'end_year must be between 2000 and 2100' , 'VALIDATION_ERROR' , 'end_year' ) ) ;
2026-05-03 19:51:57 -05:00
}
if ( end _month != null ) {
em = parseInt ( end _month , 10 ) ;
if ( isNaN ( em ) || em < 1 || em > 12 )
2026-05-09 13:03:36 -05:00
return res . status ( 400 ) . json ( standardizeError ( 'end_month must be between 1 and 12' , 'VALIDATION_ERROR' , 'end_month' ) ) ;
2026-05-03 19:51:57 -05:00
}
if ( ( ey == null ) !== ( em == null ) ) {
2026-05-09 13:03:36 -05:00
return res . status ( 400 ) . json ( standardizeError ( 'end_year and end_month must both be provided or both omitted' , 'VALIDATION_ERROR' , 'end_year' ) ) ;
2026-05-03 19:51:57 -05:00
}
if ( ey != null ) {
const startVal = sy * 12 + sm ;
const endVal = ey * 12 + em ;
if ( endVal < startVal )
2026-05-09 13:03:36 -05:00
return res . status ( 400 ) . json ( standardizeError ( 'end date must be on or after start date' , 'VALIDATION_ERROR' , 'end_year' ) ) ;
2026-05-03 19:51:57 -05:00
}
const result = db . prepare ( `
INSERT INTO bill _history _ranges ( bill _id , start _year , start _month , end _year , end _month , label )
VALUES ( ? , ? , ? , ? , ? , ? )
` ).run(req.params.id, sy, sm, ey, em, label || null);
const created = db . prepare ( 'SELECT * FROM bill_history_ranges WHERE id = ?' ) . get ( result . lastInsertRowid ) ;
res . status ( 201 ) . json ( created ) ;
} ) ;
// ── PUT /api/bills/:id/history-ranges/:rangeId ───────────────────────────────
router . put ( '/:id/history-ranges/:rangeId' , ( req , res ) => {
const db = getDb ( ) ;
2026-05-16 10:34:32 -05:00
if ( ! db . prepare ( 'SELECT id FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL' ) . get ( req . params . id , req . user . id ) )
2026-05-09 13:03:36 -05:00
return res . status ( 404 ) . json ( standardizeError ( 'Bill not found' , 'NOT_FOUND' , 'bill_id' ) ) ;
2026-05-03 19:51:57 -05:00
const range = db . prepare ( 'SELECT * FROM bill_history_ranges WHERE id = ? AND bill_id = ?' )
. get ( req . params . rangeId , req . params . id ) ;
2026-05-09 13:03:36 -05:00
if ( ! range ) return res . status ( 404 ) . json ( standardizeError ( 'History range not found' , 'NOT_FOUND' , 'rangeId' ) ) ;
2026-05-03 19:51:57 -05:00
const { start _year , start _month , end _year , end _month , label } = req . body ;
const sy = start _year != null ? parseInt ( start _year , 10 ) : range . start _year ;
const sm = start _month != null ? parseInt ( start _month , 10 ) : range . start _month ;
if ( isNaN ( sy ) || sy < 2000 || sy > 2100 )
2026-05-09 13:03:36 -05:00
return res . status ( 400 ) . json ( standardizeError ( 'start_year must be between 2000 and 2100' , 'VALIDATION_ERROR' , 'start_year' ) ) ;
2026-05-03 19:51:57 -05:00
if ( isNaN ( sm ) || sm < 1 || sm > 12 )
2026-05-09 13:03:36 -05:00
return res . status ( 400 ) . json ( standardizeError ( 'start_month must be between 1 and 12' , 'VALIDATION_ERROR' , 'start_month' ) ) ;
2026-05-03 19:51:57 -05:00
let ey = range . end _year ;
let em = range . end _month ;
if ( end _year !== undefined ) ey = end _year != null ? parseInt ( end _year , 10 ) : null ;
if ( end _month !== undefined ) em = end _month != null ? parseInt ( end _month , 10 ) : null ;
if ( ey != null && ( isNaN ( ey ) || ey < 2000 || ey > 2100 ) )
2026-05-09 13:03:36 -05:00
return res . status ( 400 ) . json ( standardizeError ( 'end_year must be between 2000 and 2100' , 'VALIDATION_ERROR' , 'end_year' ) ) ;
2026-05-03 19:51:57 -05:00
if ( em != null && ( isNaN ( em ) || em < 1 || em > 12 ) )
2026-05-09 13:03:36 -05:00
return res . status ( 400 ) . json ( standardizeError ( 'end_month must be between 1 and 12' , 'VALIDATION_ERROR' , 'end_month' ) ) ;
2026-05-03 19:51:57 -05:00
if ( ( ey == null ) !== ( em == null ) )
2026-05-09 13:03:36 -05:00
return res . status ( 400 ) . json ( standardizeError ( 'end_year and end_month must both be provided or both omitted' , 'VALIDATION_ERROR' , 'end_year' ) ) ;
2026-05-03 19:51:57 -05:00
if ( ey != null && ( ey * 12 + em ) < ( sy * 12 + sm ) )
2026-05-09 13:03:36 -05:00
return res . status ( 400 ) . json ( standardizeError ( 'end date must be on or after start date' , 'VALIDATION_ERROR' , 'end_year' ) ) ;
2026-05-03 19:51:57 -05:00
db . prepare ( `
UPDATE bill _history _ranges
SET start _year = ? , start _month = ? , end _year = ? , end _month = ? , label = ? ,
updated _at = datetime ( 'now' )
WHERE id = ? AND bill _id = ?
` ).run(sy, sm, ey, em, label !== undefined ? (label || null) : range.label, req.params.rangeId, req.params.id);
const updated = db . prepare ( 'SELECT * FROM bill_history_ranges WHERE id = ?' ) . get ( req . params . rangeId ) ;
res . json ( updated ) ;
} ) ;
// ── DELETE /api/bills/:id/history-ranges/:rangeId ────────────────────────────
router . delete ( '/:id/history-ranges/:rangeId' , ( req , res ) => {
const db = getDb ( ) ;
2026-05-16 10:34:32 -05:00
if ( ! db . prepare ( 'SELECT id FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL' ) . get ( req . params . id , req . user . id ) )
2026-05-09 13:03:36 -05:00
return res . status ( 404 ) . json ( standardizeError ( 'Bill not found' , 'NOT_FOUND' , 'bill_id' ) ) ;
2026-05-03 19:51:57 -05:00
const range = db . prepare ( 'SELECT id FROM bill_history_ranges WHERE id = ? AND bill_id = ?' )
. get ( req . params . rangeId , req . params . id ) ;
2026-05-09 13:03:36 -05:00
if ( ! range ) return res . status ( 404 ) . json ( standardizeError ( 'History range not found' , 'NOT_FOUND' , 'rangeId' ) ) ;
2026-05-03 19:51:57 -05:00
db . prepare ( 'DELETE FROM bill_history_ranges WHERE id = ? AND bill_id = ?' )
. run ( req . params . rangeId , req . params . id ) ;
res . json ( { success : true } ) ;
} ) ;
2026-05-15 00:03:32 -05:00
// ── GET /api/bills/:id/amortization — full month-by-month schedule for a debt bill ──
router . get ( '/:id/amortization' , ( req , res ) => {
const db = getDb ( ) ;
const billId = parseInt ( req . params . id , 10 ) ;
2026-05-16 10:34:32 -05:00
const bill = db . prepare ( 'SELECT * FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL' ) . get ( billId , req . user . id ) ;
2026-05-15 00:03:32 -05:00
if ( ! bill ) return res . status ( 404 ) . json ( standardizeError ( 'Bill not found' , 'NOT_FOUND' , 'bill_id' ) ) ;
const balance = Number ( bill . current _balance ) ;
const apr = Number ( bill . interest _rate ) || 0 ;
const minPmt = Number ( bill . minimum _payment ) || 0 ;
// Optional override: ?payment=X lets callers model "what if I pay more?"
let payment = minPmt ;
if ( req . query . payment !== undefined ) {
const qp = parseFloat ( req . query . payment ) ;
if ( ! Number . isFinite ( qp ) || qp <= 0 ) {
return res . status ( 400 ) . json ( standardizeError ( 'payment must be a positive number' , 'VALIDATION_ERROR' , 'payment' ) ) ;
}
payment = qp ;
}
// Optional ?max_months=N (default 360, hard cap 600)
let maxMonths = 360 ;
if ( req . query . max _months !== undefined ) {
const qm = parseInt ( req . query . max _months , 10 ) ;
if ( Number . isInteger ( qm ) && qm > 0 ) maxMonths = Math . min ( qm , 600 ) ;
}
if ( ! Number . isFinite ( balance ) || balance <= 0 ) {
return res . json ( { bill _id : billId , schedule : [ ] , apr _snapshot : null , error : 'No current balance set' } ) ;
}
const schedule = amortizationSchedule ( balance , apr , payment , maxMonths ) ;
const apr _snapshot = debtAprSnapshot ( bill ) ;
const total _interest = schedule . reduce ( ( s , r ) => s + r . interest , 0 ) ;
res . json ( {
bill _id : billId ,
balance ,
apr ,
payment ,
schedule ,
summary : {
months : schedule . length ,
total _interest : Math . round ( total _interest * 100 ) / 100 ,
total _paid : Math . round ( ( schedule . reduce ( ( s , r ) => s + r . payment , 0 ) ) * 100 ) / 100 ,
capped : schedule . length >= maxMonths && schedule [ schedule . length - 1 ] ? . balance > 0 ,
} ,
apr _snapshot ,
} ) ;
} ) ;
2026-05-14 19:33:23 -05:00
// ── PATCH /api/bills/:id/snowball — update only snowball_include / snowball_exempt ──
router . patch ( '/:id/snowball' , ( req , res ) => {
const db = getDb ( ) ;
const billId = parseInt ( req . params . id , 10 ) ;
2026-05-16 10:34:32 -05:00
if ( ! db . prepare ( 'SELECT id FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL' ) . get ( billId , req . user . id ) ) {
2026-05-14 19:33:23 -05:00
return res . status ( 404 ) . json ( standardizeError ( 'Bill not found' , 'NOT_FOUND' , 'bill_id' ) ) ;
}
const include = req . body . snowball _include !== undefined ? ( req . body . snowball _include ? 1 : 0 ) : undefined ;
const exempt = req . body . snowball _exempt !== undefined ? ( req . body . snowball _exempt ? 1 : 0 ) : undefined ;
const parts = [ ] ;
const vals = [ ] ;
if ( include !== undefined ) { parts . push ( 'snowball_include = ?' ) ; vals . push ( include ) ; }
if ( exempt !== undefined ) { parts . push ( 'snowball_exempt = ?' ) ; vals . push ( exempt ) ; }
if ( parts . length === 0 ) return res . status ( 400 ) . json ( standardizeError ( 'Nothing to update' , 'VALIDATION_ERROR' ) ) ;
parts . push ( "updated_at = datetime('now')" ) ;
db . prepare ( ` UPDATE bills SET ${ parts . join ( ', ' ) } WHERE id = ? AND user_id = ? ` ) . run ( ... vals , billId , req . user . id ) ;
res . json ( { id : billId , snowball _include : include , snowball _exempt : exempt } ) ;
} ) ;
2026-05-14 02:11:54 -05:00
// ── PATCH /api/bills/:id/balance — lightweight balance-only update ────────────
router . patch ( '/:id/balance' , ( req , res ) => {
const db = getDb ( ) ;
const billId = parseInt ( req . params . id , 10 ) ;
2026-05-16 10:34:32 -05:00
if ( ! db . prepare ( 'SELECT id FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL' ) . get ( billId , req . user . id ) ) {
2026-05-14 02:11:54 -05:00
return res . status ( 404 ) . json ( standardizeError ( 'Bill not found' , 'NOT_FOUND' , 'bill_id' ) ) ;
}
const raw = req . body . current _balance ;
let val = null ;
if ( raw !== null && raw !== '' && raw !== undefined ) {
val = parseFloat ( raw ) ;
if ( ! Number . isFinite ( val ) || val < 0 ) {
return res . status ( 400 ) . json ( standardizeError ( 'current_balance must be a non-negative number' , 'VALIDATION_ERROR' , 'current_balance' ) ) ;
}
val = Math . round ( val * 100 ) / 100 ;
}
db . prepare ( "UPDATE bills SET current_balance = ?, updated_at = datetime('now') WHERE id = ?" ) . run ( val , billId ) ;
res . json ( { id : billId , current _balance : val } ) ;
} ) ;
2026-06-03 21:21:38 -05:00
// ── Merchant rule helpers ─────────────────────────────────────────────────────
function requireBill ( db , billId , userId ) {
return db . prepare ( 'SELECT id, name FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL' ) . get ( billId , userId ) ;
}
// Count unmatched transactions that would match a normalized merchant string.
function previewMatchCount ( db , userId , normalized ) {
if ( ! normalized || normalized . length < 2 ) return 0 ;
const txRows = db . prepare ( `
SELECT t . payee , t . description , t . memo
FROM transactions t
LEFT JOIN financial _accounts fa ON fa . id = t . account _id AND fa . user _id = t . user _id
WHERE t . user _id = ?
AND t . match _status = 'unmatched'
AND t . ignored = 0
AND t . amount < 0
AND ( t . account _id IS NULL OR fa . id IS NULL OR fa . monitored = 1 )
` ).all(userId);
return txRows . filter ( tx => {
const txMerchant = normalizeMerchant ( tx . payee || tx . description || tx . memo || '' ) ;
2026-06-04 02:20:30 -05:00
return txMerchant && merchantMatches ( txMerchant , normalized ) ;
2026-06-03 21:21:38 -05:00
} ) . length ;
}
// Find bills (other than this one) that already claim this merchant.
function findConflicts ( db , userId , billId , normalized ) {
return db . prepare ( `
SELECT b . id , b . name
FROM bill _merchant _rules bmr
JOIN bills b ON b . id = bmr . bill _id AND b . user _id = bmr . user _id AND b . deleted _at IS NULL
WHERE bmr . user _id = ? AND bmr . merchant = ? AND bmr . bill _id != ?
` ).all(userId, normalized, billId);
}
// ── GET /api/bills/:id/merchant-rules ────────────────────────────────────────
router . get ( '/:id/merchant-rules' , ( 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' ) ) ;
if ( ! requireBill ( db , billId , req . user . id ) )
return res . status ( 404 ) . json ( standardizeError ( 'Bill not found' , 'NOT_FOUND' ) ) ;
const rules = db . prepare ( `
2026-06-04 02:36:36 -05:00
SELECT id , merchant , auto _attribute _late , created _at FROM bill _merchant _rules
2026-06-03 21:21:38 -05:00
WHERE user _id = ? AND bill _id = ?
ORDER BY created _at ASC
` ).all(req.user.id, billId);
// Suggest recent unmatched transactions as quick-pick options
const suggestions = db . prepare ( `
SELECT t . id , t . payee , t . description , t . memo , t . amount , t . posted _date , t . transacted _at
FROM transactions t
LEFT JOIN financial _accounts fa ON fa . id = t . account _id AND fa . user _id = t . user _id
WHERE t . user _id = ?
AND t . match _status = 'unmatched'
AND t . ignored = 0
AND t . amount < 0
AND ( t . account _id IS NULL OR fa . id IS NULL OR fa . monitored = 1 )
ORDER BY COALESCE ( t . posted _date , substr ( t . transacted _at , 1 , 10 ) ) DESC
LIMIT 30
` ).all(req.user.id).map(tx => {
const raw = tx . payee || tx . description || tx . memo || '' ;
const normalized = normalizeMerchant ( raw ) ;
return { id : tx . id , label : raw . trim ( ) , normalized , amount : tx . amount ,
date : tx . posted _date || String ( tx . transacted _at || '' ) . slice ( 0 , 10 ) } ;
} ) . filter ( s => s . normalized . length >= 2 ) ;
res . json ( { rules , suggestions } ) ;
} ) ;
// ── GET /api/bills/:id/merchant-rules/preview ─────────────────────────────────
router . get ( '/:id/merchant-rules/preview' , ( 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' ) ) ;
if ( ! requireBill ( db , billId , req . user . id ) )
return res . status ( 404 ) . json ( standardizeError ( 'Bill not found' , 'NOT_FOUND' ) ) ;
const raw = String ( req . query . merchant || '' ) . trim ( ) ;
const normalized = normalizeMerchant ( raw ) ;
if ( ! normalized || normalized . length < 2 )
return res . json ( { match _count : 0 , conflicts : [ ] , normalized : '' } ) ;
const match _count = previewMatchCount ( db , req . user . id , normalized ) ;
const conflicts = findConflicts ( db , req . user . id , billId , normalized ) ;
res . json ( { match _count , conflicts , normalized } ) ;
} ) ;
// ── POST /api/bills/:id/merchant-rules ───────────────────────────────────────
router . post ( '/:id/merchant-rules' , ( 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' ) ) ;
if ( ! requireBill ( db , billId , req . user . id ) )
return res . status ( 404 ) . json ( standardizeError ( 'Bill not found' , 'NOT_FOUND' ) ) ;
const raw = String ( req . body ? . merchant || '' ) . trim ( ) ;
const normalized = normalizeMerchant ( raw ) ;
if ( ! normalized || normalized . length < 2 )
return res . status ( 400 ) . json ( standardizeError ( 'merchant must be at least 2 characters after normalisation' , 'VALIDATION_ERROR' , 'merchant' ) ) ;
const conflicts = findConflicts ( db , req . user . id , billId , normalized ) ;
try {
db . prepare ( `
INSERT INTO bill _merchant _rules ( user _id , bill _id , merchant )
VALUES ( ? , ? , ? )
ON CONFLICT ( user _id , bill _id , merchant ) DO NOTHING
` ).run(req.user.id, billId, normalized);
} catch ( err ) {
return res . status ( 500 ) . json ( standardizeError ( 'Failed to save rule' , 'DB_ERROR' ) ) ;
}
// Retroactively apply the new rule to existing unmatched transactions
const { added } = syncBillPaymentsFromSimplefin ( db , req . user . id , billId ) ;
const rule = db . prepare ( 'SELECT id, merchant, created_at FROM bill_merchant_rules WHERE user_id = ? AND bill_id = ? AND merchant = ?' )
. get ( req . user . id , billId , normalized ) ;
res . status ( 201 ) . json ( { rule , retroactive _matches : added , conflicts } ) ;
} ) ;
// ── DELETE /api/bills/:id/merchant-rules/:ruleId ──────────────────────────────
2026-06-04 02:05:15 -05:00
// ── GET /api/bills/:id/merchant-rules/candidates ─────────────────────────────
// All transactions matching this bill's merchant rules — any match_status.
// Each item includes the current status so the user knows what will happen.
router . get ( '/:id/merchant-rules/candidates' , ( 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 = requireBill ( db , billId , req . user . id ) ;
if ( ! bill ) return res . status ( 404 ) . json ( standardizeError ( 'Bill not found' , 'NOT_FOUND' ) ) ;
const rules = db . prepare (
'SELECT merchant FROM bill_merchant_rules WHERE user_id = ? AND bill_id = ?'
) . all ( req . user . id , billId ) . map ( r => r . merchant ) ;
if ( rules . length === 0 ) return res . json ( { candidates : [ ] } ) ;
// Fetch all negative transactions for this user — any match status
let txRows ;
try {
txRows = db . prepare ( `
SELECT t . id , t . amount , t . payee , t . description , t . memo ,
t . posted _date , t . transacted _at , t . match _status ,
t . matched _bill _id ,
b . name AS matched _bill _name
FROM transactions t
LEFT JOIN bills b ON b . id = t . matched _bill _id AND b . user _id = t . user _id AND b . deleted _at IS NULL
WHERE t . user _id = ? AND t . amount < 0 AND t . ignored = 0
ORDER BY COALESCE ( t . posted _date , substr ( t . transacted _at , 1 , 10 ) ) DESC
LIMIT 500
` ).all(req.user.id);
} catch {
return res . json ( { candidates : [ ] } ) ;
}
// Existing payments for this bill keyed by transaction_id
const existingPayments = new Set (
db . prepare ( 'SELECT transaction_id FROM payments WHERE bill_id = ? AND transaction_id IS NOT NULL AND deleted_at IS NULL' )
. all ( billId ) . map ( r => r . transaction _id )
) ;
const candidates = [ ] ;
for ( const tx of txRows ) {
const txMerchant = normalizeMerchant ( tx . payee || tx . description || tx . memo || '' ) ;
if ( ! txMerchant ) continue ;
2026-06-04 02:20:30 -05:00
const matches = rules . some ( r => merchantMatches ( txMerchant , r ) ) ;
2026-06-04 02:05:15 -05:00
if ( ! matches ) continue ;
const paidDate = tx . posted _date || ( tx . transacted _at ? String ( tx . transacted _at ) . slice ( 0 , 10 ) : null ) ;
if ( ! paidDate ) continue ;
let status ;
if ( existingPayments . has ( tx . id ) ) {
status = 'payment_exists' ;
} else if ( tx . match _status === 'matched' && tx . matched _bill _id === billId ) {
status = 'matched_this_bill' ;
} else if ( tx . match _status === 'matched' && tx . matched _bill _id !== billId ) {
status = 'matched_other_bill' ;
} else {
status = 'unmatched' ;
}
candidates . push ( {
id : tx . id ,
payee : tx . payee || tx . description || '(no description)' ,
amount : Math . round ( Math . abs ( tx . amount ) ) / 100 ,
paid _date : paidDate ,
status ,
matched _bill _name : tx . matched _bill _name || null ,
} ) ;
}
res . json ( { candidates } ) ;
} ) ;
// ── POST /api/bills/:id/merchant-rules/import-historical ──────────────────────
// Import a specific list of transaction IDs as payments for this bill.
router . post ( '/:id/merchant-rules/import-historical' , ( 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 = requireBill ( db , billId , req . user . id ) ;
if ( ! bill ) return res . status ( 404 ) . json ( standardizeError ( 'Bill not found' , 'NOT_FOUND' ) ) ;
const ids = req . body ? . transaction _ids ;
if ( ! Array . isArray ( ids ) || ids . length === 0 )
return res . status ( 400 ) . json ( standardizeError ( 'transaction_ids must be a non-empty array' , 'VALIDATION_ERROR' ) ) ;
const validIds = ids . filter ( id => Number . isInteger ( id ) && id > 0 ) ;
if ( validIds . length === 0 )
return res . status ( 400 ) . json ( standardizeError ( 'No valid transaction ids provided' , 'VALIDATION_ERROR' ) ) ;
2026-06-06 16:34:20 -05:00
const getBill = db . prepare ( 'SELECT current_balance, interest_rate, interest_accrued_month FROM bills WHERE id = ? AND deleted_at IS NULL' ) ;
2026-06-04 02:05:15 -05:00
const getTx = db . prepare ( 'SELECT * FROM transactions WHERE id = ? AND user_id = ? AND amount < 0' ) ;
const insertPayment = db . prepare ( `
2026-06-06 16:34:20 -05:00
INSERT OR IGNORE INTO payments ( bill _id , amount , paid _date , payment _source , transaction _id , balance _delta , interest _delta )
VALUES ( ? , ? , ? , 'provider_sync' , ? , ? , ? )
2026-06-04 02:05:15 -05:00
` );
const updateTx = db . prepare ( `
UPDATE transactions SET matched _bill _id = ? , match _status = 'matched' , updated _at = datetime ( 'now' ) WHERE id = ?
` );
let imported = 0 ;
const lateAttributions = [ ] ;
try {
db . transaction ( ( ) => {
for ( const txId of validIds ) {
const tx = getTx . get ( txId , req . user . id ) ;
if ( ! tx ) continue ;
const paidDate = tx . posted _date || ( tx . transacted _at ? String ( tx . transacted _at ) . slice ( 0 , 10 ) : null ) ;
if ( ! paidDate ) continue ;
const amount = Math . round ( Math . abs ( tx . amount ) ) / 100 ;
const billRow = getBill . get ( billId ) ;
const balCalc = billRow ? computeBalanceDelta ( billRow , amount ) : null ;
2026-06-06 16:34:20 -05:00
const result = insertPayment . run ( billId , amount , paidDate , txId , balCalc ? . balance _delta ? ? null , balCalc ? . interest _delta ? ? null ) ;
2026-06-04 02:05:15 -05:00
if ( result . changes > 0 ) {
2026-06-06 16:34:20 -05:00
applyBalanceDelta ( db , billId , balCalc ) ;
2026-06-04 02:05:15 -05:00
updateTx . run ( billId , txId ) ;
imported ++ ;
// Check for late attribution
const { normalizeMerchant : nm , ... _ } = { normalizeMerchant } ;
const rules2 = db . prepare ( 'SELECT due_day FROM bills WHERE id = ?' ) . get ( billId ) ;
if ( rules2 ? . due _day ) {
const { lateAttributionCandidate } = require ( '../services/billMerchantRuleService' ) ;
// inline check
const paid = new Date ( paidDate + 'T00:00:00' ) ;
const dom = paid . getDate ( ) ;
if ( dom <= 5 ) {
const prevEnd = new Date ( paid . getFullYear ( ) , paid . getMonth ( ) , 0 ) ;
if ( rules2 . due _day <= prevEnd . getDate ( ) ) {
const suggested = prevEnd . toISOString ( ) . slice ( 0 , 10 ) ;
const inserted = db . prepare ( 'SELECT id FROM payments WHERE transaction_id = ? AND bill_id = ? AND deleted_at IS NULL' ) . get ( txId , billId ) ;
if ( inserted ) {
lateAttributions . push ( { payment _id : inserted . id , bill _name : bill . name , original _date : paidDate , suggested _date : suggested , amount } ) ;
}
}
}
}
}
}
} ) ( ) ;
} catch ( err ) {
console . error ( '[import-historical] Transaction failed:' , err . message ) ;
return res . status ( 500 ) . json ( standardizeError ( 'Import failed' , 'DB_ERROR' ) ) ;
}
res . json ( { imported , late _attributions : lateAttributions } ) ;
} ) ;
2026-06-04 02:36:36 -05:00
// PATCH /api/bills/:id/merchant-rules/:ruleId/auto-attribute
// Toggle the auto_attribute_late flag for a single merchant rule.
router . patch ( '/:id/merchant-rules/:ruleId/auto-attribute' , ( req , res ) => {
const db = getDb ( ) ;
const billId = parseInt ( req . params . id , 10 ) ;
const ruleId = parseInt ( req . params . ruleId , 10 ) ;
if ( ! Number . isInteger ( billId ) || billId < 1 || ! Number . isInteger ( ruleId ) || ruleId < 1 )
return res . status ( 400 ) . json ( standardizeError ( 'Invalid id' , 'VALIDATION_ERROR' ) ) ;
if ( ! requireBill ( db , billId , req . user . id ) )
return res . status ( 404 ) . json ( standardizeError ( 'Bill not found' , 'NOT_FOUND' ) ) ;
const enabled = req . body ? . enabled ? 1 : 0 ;
const changes = db . prepare (
"UPDATE bill_merchant_rules SET auto_attribute_late = ? WHERE id = ? AND user_id = ? AND bill_id = ?"
) . run ( enabled , ruleId , req . user . id , billId ) . changes ;
if ( changes === 0 ) return res . status ( 404 ) . json ( standardizeError ( 'Rule not found' , 'NOT_FOUND' ) ) ;
res . json ( { id : ruleId , auto _attribute _late : enabled === 1 } ) ;
} ) ;
2026-06-03 21:21:38 -05:00
router . delete ( '/:id/merchant-rules/:ruleId' , ( req , res ) => {
const db = getDb ( ) ;
const billId = parseInt ( req . params . id , 10 ) ;
const ruleId = parseInt ( req . params . ruleId , 10 ) ;
if ( ! Number . isInteger ( billId ) || billId < 1 || ! Number . isInteger ( ruleId ) || ruleId < 1 )
return res . status ( 400 ) . json ( standardizeError ( 'Invalid id' , 'VALIDATION_ERROR' ) ) ;
if ( ! requireBill ( db , billId , req . user . id ) )
return res . status ( 404 ) . json ( standardizeError ( 'Bill not found' , 'NOT_FOUND' ) ) ;
const changes = db . prepare ( 'DELETE FROM bill_merchant_rules WHERE id = ? AND user_id = ? AND bill_id = ?' )
. run ( ruleId , req . user . id , billId ) . changes ;
if ( changes === 0 )
return res . status ( 404 ) . json ( standardizeError ( 'Rule not found' , 'NOT_FOUND' ) ) ;
res . json ( { success : true } ) ;
} ) ;
2026-05-03 19:51:57 -05:00
module . exports = router ;