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 ,
} = 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-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' ;
const bills = db . prepare ( `
SELECT b . * , c . name AS category _name ,
CASE WHEN EXISTS (
SELECT 1 FROM bill _history _ranges WHERE bill _id = b . id
) THEN 1 ELSE 0 END AS has _history _ranges
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-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' }
ORDER BY b . due _day ASC , b . name ASC
` ).all(req.user.id);
res . json ( 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 ) ) ;
} ) ;
// ── 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
const { year , month , actual _amount , notes , is _skipped } = req . body ;
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
}
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 ;
db . prepare ( `
INSERT INTO monthly _bill _state ( bill _id , year , month , actual _amount , notes , is _skipped , updated _at )
VALUES ( ? , ? , ? , ? , ? , ? , datetime ( 'now' ) )
ON CONFLICT ( bill _id , year , month ) DO UPDATE SET
actual _amount = excluded . actual _amount ,
notes = excluded . notes ,
is _skipped = excluded . is _skipped ,
updated _at = datetime ( 'now' )
` ).run(billId, y, m, amt, noteVal, skipVal);
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 ,
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
) THEN 1 ELSE 0 END AS has _history _ranges
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-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-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-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-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-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 (
{ amount , paid _date : paidDate } ,
{ 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-05-14 02:11:54 -05:00
'INSERT INTO payments (bill_id, amount, paid_date, method, notes, balance_delta) VALUES (?, ?, ?, ?, ?, ?)'
2026-05-15 22:45:38 -05:00
) . run ( billId , payment . amount , payment . paid _date , method , notes , balCalc ? . balance _delta ? ? null ) ;
2026-05-14 02:11:54 -05:00
if ( balCalc ) {
db . prepare ( "UPDATE bills SET current_balance = ?, updated_at = datetime('now') WHERE id = ?" )
. run ( balCalc . new _balance , billId ) ;
}
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-05-03 19:51:57 -05:00
module . exports = router ;