2026-05-03 19:51:57 -05:00
const express = require ( 'express' ) ;
2026-05-09 13:03:36 -05:00
const { standardizeError } = require ( '../middleware/errorFormatter' ) ;
2026-05-03 19:51:57 -05:00
const router = require ( 'express' ) . Router ( ) ;
const { getDb } = require ( '../db/database' ) ;
const LIVE = 'deleted_at IS NULL' ; // filter for non-deleted payments
// GET /api/payments?bill_id=&year=&month=
router . get ( '/' , ( req , res ) => {
const db = getDb ( ) ;
const { bill _id , year , month } = req . query ;
// Validate year/month when provided
if ( ( year || month ) && ! ( year && month ) ) {
2026-05-09 13:03:36 -05:00
return res . status ( 400 ) . json ( standardizeError ( 'Both year and month are required when filtering by date' , 'VALIDATION_ERROR' , 'year' ) ) ;
2026-05-03 19:51:57 -05:00
}
let y , m ;
if ( year && month ) {
y = parseInt ( year , 10 ) ;
m = parseInt ( month , 10 ) ;
if ( ! Number . isInteger ( 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 ( ! Number . isInteger ( 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
}
}
let query = ` SELECT p.* FROM payments p JOIN bills b ON b.id = p.bill_id WHERE p. ${ LIVE } AND b.user_id = ? ` ;
const params = [ req . user . id ] ;
if ( bill _id ) { query += ' AND p.bill_id = ?' ; params . push ( parseInt ( bill _id , 10 ) ) ; }
if ( y && m ) {
const yStr = String ( y ) ;
const mStr = String ( m ) . padStart ( 2 , '0' ) ;
const daysInMonth = new Date ( y , m , 0 ) . getDate ( ) ;
const endDay = String ( daysInMonth ) . padStart ( 2 , '0' ) ;
query += ' AND p.paid_date BETWEEN ? AND ?' ;
params . push ( ` ${ yStr } - ${ mStr } -01 ` , ` ${ yStr } - ${ mStr } - ${ endDay } ` ) ;
}
query += ' ORDER BY p.paid_date DESC' ;
res . json ( db . prepare ( query ) . all ( ... params ) ) ;
} ) ;
// GET /api/payments/:id
router . get ( '/:id' , ( req , res ) => {
const db = getDb ( ) ;
const payment = db . prepare ( ` SELECT p.* FROM payments p JOIN bills b ON b.id = p.bill_id WHERE p.id = ? AND p. ${ LIVE } AND b.user_id = ? ` ) . get ( req . params . id , req . user . id ) ;
2026-05-09 13:03:36 -05:00
if ( ! payment ) return res . status ( 404 ) . json ( standardizeError ( 'Payment not found' , 'NOT_FOUND' , 'id' ) ) ;
2026-05-03 19:51:57 -05:00
res . json ( payment ) ;
} ) ;
// POST /api/payments — create single payment
router . post ( '/' , ( req , res ) => {
const db = getDb ( ) ;
const { bill _id , amount , paid _date , method , notes } = req . body ;
if ( ! bill _id || amount == null || ! paid _date )
2026-05-09 13:03:36 -05:00
return res . status ( 400 ) . json ( standardizeError ( 'bill_id, amount, and paid_date are required' , 'VALIDATION_ERROR' , 'bill_id' ) ) ;
2026-05-03 19:51:57 -05:00
const parsedAmount = parseFloat ( amount ) ;
if ( isNaN ( parsedAmount ) || parsedAmount <= 0 )
2026-05-09 13:03:36 -05:00
return res . status ( 400 ) . json ( standardizeError ( 'amount must be a positive number' , 'VALIDATION_ERROR' , 'amount' ) ) ;
2026-05-03 19:51:57 -05:00
if ( ! db . prepare ( 'SELECT id FROM bills WHERE id = ? AND user_id = ?' ) . get ( bill _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 result = db . prepare (
'INSERT INTO payments (bill_id, amount, paid_date, method, notes) VALUES (?, ?, ?, ?, ?)'
) . run ( bill _id , parsedAmount , paid _date , method || null , notes || null ) ;
res . status ( 201 ) . json ( db . prepare ( 'SELECT * FROM payments WHERE id = ?' ) . get ( result . lastInsertRowid ) ) ;
} ) ;
// POST /api/payments/quick — pay a bill (expected amount, today)
router . post ( '/quick' , ( req , res ) => {
const db = getDb ( ) ;
const { bill _id , amount , paid _date , method , notes } = req . body ;
2026-05-09 13:03:36 -05:00
if ( ! bill _id ) return res . status ( 400 ) . json ( standardizeError ( 'bill_id is required' , 'VALIDATION_ERROR' , 'bill_id' ) ) ;
2026-05-03 19:51:57 -05:00
const bill = db . prepare ( 'SELECT * FROM bills WHERE id = ? AND user_id = ?' ) . get ( bill _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 payAmount = amount != null ? parseFloat ( amount ) : bill . expected _amount ;
if ( isNaN ( payAmount ) || payAmount <= 0 )
2026-05-09 13:03:36 -05:00
return res . status ( 400 ) . json ( standardizeError ( 'amount must be a positive number' , 'VALIDATION_ERROR' , 'amount' ) ) ;
2026-05-03 19:51:57 -05:00
const payDate = paid _date || new Date ( ) . toISOString ( ) . slice ( 0 , 10 ) ;
const result = db . prepare (
'INSERT INTO payments (bill_id, amount, paid_date, method, notes) VALUES (?, ?, ?, ?, ?)'
) . run ( bill _id , payAmount , payDate , method || null , notes || null ) ;
if ( bill . autopay _enabled ) {
db . prepare ( "UPDATE bills SET autodraft_status='confirmed', updated_at=datetime('now') WHERE id=?" ) . run ( bill _id ) ;
}
res . status ( 201 ) . json ( db . prepare ( 'SELECT * FROM payments WHERE id = ?' ) . get ( result . lastInsertRowid ) ) ;
} ) ;
// POST /api/payments/bulk — record multiple payments in one request
2026-05-09 23:41:28 -05:00
// Bulk payment creation endpoint
// Validation rules:
// - Request body must contain a `payments` array
// - Maximum 50 items per request
// - Each item requires: bill_id (integer), paid_date (valid date), amount (number >= 0)
// - Duplicate payments (same bill_id + paid_date + amount) are skipped, not created
// - Returns { created: [...], skipped: [...], errors: [...] }
2026-05-03 19:51:57 -05:00
router . post ( '/bulk' , ( req , res ) => {
const db = getDb ( ) ;
2026-05-09 23:41:28 -05:00
const { payments } = req . body ;
2026-05-03 19:51:57 -05:00
2026-05-09 23:41:28 -05:00
// Validate request body has payments array
if ( ! payments || ! Array . isArray ( payments ) )
return res . status ( 400 ) . json ( standardizeError ( 'Request body must contain a `payments` array' , 'VALIDATION_ERROR' , 'payments' ) ) ;
// Validate max items per request (50)
if ( payments . length > 50 )
return res . status ( 400 ) . json ( standardizeError ( 'Maximum 50 items allowed per request' , 'VALIDATION_ERROR' , 'payments' ) ) ;
// Validate each payment item
for ( let i = 0 ; i < payments . length ; i ++ ) {
const item = payments [ i ] ;
if ( ! item . bill _id || item . amount == null || ! item . paid _date ) {
return res . status ( 400 ) . json ( standardizeError ( ` Payment at index ${ i } : bill_id, amount, and paid_date are required ` , 'VALIDATION_ERROR' , ` payments[ ${ i } ] ` ) ) ;
}
// Validate bill_id is an integer (check original input to prevent parseInt coercion)
const billIdStr = String ( item . bill _id ) . trim ( ) ;
const billIdInt = parseInt ( billIdStr , 10 ) ;
if ( ! /^\d+$/ . test ( billIdStr ) || ! Number . isInteger ( billIdInt ) ) {
return res . status ( 400 ) . json ( standardizeError ( ` Payment at index ${ i } : bill_id must be an integer ` , 'VALIDATION_ERROR' , ` payments[ ${ i } ].bill_id ` ) ) ;
}
// Validate paid_date is a valid date string
const dateRegex = /^\d{4}-\d{2}-\d{2}$/ ;
if ( ! dateRegex . test ( item . paid _date ) ) {
return res . status ( 400 ) . json ( standardizeError ( ` Payment at index ${ i } : paid_date must be a valid date in YYYY-MM-DD format ` , 'VALIDATION_ERROR' , ` payments[ ${ i } ].paid_date ` ) ) ;
}
// Validate amount is a finite number >= 0 (reject Infinity/NaN)
const parsedAmt = parseFloat ( item . amount ) ;
if ( isNaN ( parsedAmt ) || parsedAmt < 0 || ! isFinite ( parsedAmt ) ) {
return res . status ( 400 ) . json ( standardizeError ( ` Payment at index ${ i } : amount must be a number >= 0 ` , 'VALIDATION_ERROR' , ` payments[ ${ i } ].amount ` ) ) ;
}
}
2026-05-03 19:51:57 -05:00
const insert = db . prepare (
'INSERT INTO payments (bill_id, amount, paid_date, method, notes) VALUES (?, ?, ?, ?, ?)'
) ;
2026-05-09 23:41:28 -05:00
// Prepare statement for duplicate checking
const duplicateCheckStmt = db . prepare (
` SELECT 1 FROM payments p
JOIN bills b ON b . id = p . bill _id
WHERE b . user _id = ?
AND p . bill _id = ?
AND p . paid _date = ?
AND p . amount = ?
AND p . $ { LIVE } `
) ;
2026-05-03 19:51:57 -05:00
const created = [ ] ;
2026-05-09 23:41:28 -05:00
const skipped = [ ] ;
2026-05-03 19:51:57 -05:00
const errors = [ ] ;
const runBulk = db . transaction ( ( ) => {
2026-05-09 23:41:28 -05:00
for ( const item of payments ) {
const bill _id = parseInt ( String ( item . bill _id ) . trim ( ) , 10 ) ;
const parsedAmt = parseFloat ( item . amount ) ;
const { paid _date , method , notes } = item ;
// Check for duplicates using composite key (bill_id + paid_date + amount)
const isDuplicate = duplicateCheckStmt . get ( req . user . id , bill _id , paid _date , parsedAmt ) ;
if ( isDuplicate ) {
skipped . push ( { bill _id , paid _date , amount : parsedAmt } ) ;
2026-05-03 19:51:57 -05:00
continue ;
}
2026-05-09 23:41:28 -05:00
2026-05-03 19:51:57 -05:00
if ( ! db . prepare ( 'SELECT id FROM bills WHERE id = ? AND user_id = ?' ) . get ( bill _id , req . user . id ) ) {
errors . push ( { item , error : ` Bill ${ bill _id } not found ` } ) ;
continue ;
}
2026-05-09 23:41:28 -05:00
2026-05-03 19:51:57 -05:00
const r = insert . run ( bill _id , parsedAmt , paid _date , method || null , notes || null ) ;
created . push ( db . prepare ( 'SELECT * FROM payments WHERE id = ?' ) . get ( r . lastInsertRowid ) ) ;
}
} ) ;
runBulk ( ) ;
2026-05-09 23:41:28 -05:00
res . status ( 201 ) . json ( { created , skipped , errors } ) ;
2026-05-03 19:51:57 -05:00
} ) ;
// PUT /api/payments/:id
router . put ( '/:id' , ( req , res ) => {
const db = getDb ( ) ;
const existing = db . prepare ( ` SELECT p.* FROM payments p JOIN bills b ON b.id = p.bill_id WHERE p.id = ? AND p. ${ LIVE } AND b.user_id = ? ` ) . get ( req . params . id , req . user . id ) ;
2026-05-09 13:03:36 -05:00
if ( ! existing ) return res . status ( 404 ) . json ( standardizeError ( 'Payment not found' , 'NOT_FOUND' , 'id' ) ) ;
2026-05-03 19:51:57 -05:00
const { amount , paid _date , method , notes } = req . body ;
db . prepare ( `
UPDATE payments SET
amount = ? , paid _date = ? , method = ? , notes = ? ,
updated _at = datetime ( 'now' )
WHERE id = ?
` ).run(
amount != null ? parseFloat ( amount ) : existing . amount ,
paid _date ? ? existing . paid _date ,
method !== undefined ? ( method || null ) : existing . method ,
notes !== undefined ? ( notes || null ) : existing . notes ,
req . params . id ,
) ;
res . json ( db . prepare ( 'SELECT * FROM payments WHERE id = ?' ) . get ( req . params . id ) ) ;
} ) ;
// DELETE /api/payments/:id — soft delete (sets deleted_at)
router . delete ( '/:id' , ( req , res ) => {
const db = getDb ( ) ;
const payment = db . prepare ( ` SELECT p.id FROM payments p JOIN bills b ON b.id = p.bill_id WHERE p.id = ? AND p. ${ LIVE } AND b.user_id = ? ` ) . get ( req . params . id , req . user . id ) ;
2026-05-09 13:03:36 -05:00
if ( ! payment ) return res . status ( 404 ) . json ( standardizeError ( 'Payment not found' , 'NOT_FOUND' , 'id' ) ) ;
2026-05-03 19:51:57 -05:00
db . prepare ( "UPDATE payments SET deleted_at = datetime('now') WHERE id = ?" ) . run ( req . params . id ) ;
res . json ( { success : true } ) ;
} ) ;
// POST /api/payments/:id/restore — undo soft delete
router . post ( '/:id/restore' , ( req , res ) => {
const db = getDb ( ) ;
const payment = db . prepare ( 'SELECT p.id FROM payments p JOIN bills b ON b.id = p.bill_id WHERE p.id = ? AND p.deleted_at IS NOT NULL AND b.user_id = ?' ) . get ( req . params . id , req . user . id ) ;
2026-05-09 13:03:36 -05:00
if ( ! payment ) return res . status ( 404 ) . json ( standardizeError ( 'Deleted payment not found' , 'NOT_FOUND' , 'id' ) ) ;
2026-05-03 19:51:57 -05:00
db . prepare ( 'UPDATE payments SET deleted_at = NULL WHERE id = ?' ) . run ( req . params . id ) ;
res . json ( db . prepare ( 'SELECT * FROM payments WHERE id = ?' ) . get ( req . params . id ) ) ;
} ) ;
module . exports = router ;