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' ) ;
2026-05-14 02:11:54 -05:00
const { computeBalanceDelta } = require ( '../services/billsService' ) ;
2026-05-15 22:45:38 -05:00
const { validatePaymentInput } = require ( '../services/paymentValidation' ) ;
2026-05-03 19:51:57 -05:00
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 ;
2026-05-15 22:45:38 -05:00
const validation = validatePaymentInput ( { bill _id , amount , paid _date } ) ;
if ( validation . error ) {
return res . status ( 400 ) . json ( standardizeError ( validation . error , 'VALIDATION_ERROR' , validation . field ) ) ;
}
const payment = validation . normalized ;
2026-05-03 19:51:57 -05:00
2026-05-15 22:45:38 -05:00
if ( ! db . prepare ( 'SELECT id FROM bills WHERE id = ? AND user_id = ?' ) . get ( payment . 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 (?, ?, ?, ?, ?)'
2026-05-15 22:45:38 -05:00
) . run ( payment . bill _id , payment . amount , payment . paid _date , method || null , notes || null ) ;
2026-05-03 19:51:57 -05:00
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-15 22:45:38 -05:00
const billValidation = validatePaymentInput ( { bill _id } , { requireAmount : false , requirePaidDate : false } ) ;
if ( billValidation . error ) {
return res . status ( 400 ) . json ( standardizeError ( billValidation . error , 'VALIDATION_ERROR' , billValidation . field ) ) ;
}
2026-05-03 19:51:57 -05:00
2026-05-15 22:45:38 -05:00
const bill = db . prepare ( 'SELECT * FROM bills WHERE id = ? AND user_id = ?' ) . get ( billValidation . normalized . 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
2026-05-15 22:45:38 -05:00
const paymentValidation = validatePaymentInput (
{
amount : amount != null ? amount : bill . expected _amount ,
paid _date : paid _date || new Date ( ) . toISOString ( ) . slice ( 0 , 10 ) ,
} ,
{ requireBillId : false } ,
) ;
if ( paymentValidation . error ) {
return res . status ( 400 ) . json ( standardizeError ( paymentValidation . error , 'VALIDATION_ERROR' , paymentValidation . field ) ) ;
}
const payAmount = paymentValidation . normalized . amount ;
const payDate = paymentValidation . normalized . paid _date ;
2026-05-03 19:51:57 -05:00
2026-05-14 02:11:54 -05:00
const balCalc = computeBalanceDelta ( bill , payAmount ) ;
2026-05-03 19:51:57 -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 ( bill . id , payAmount , payDate , method || null , notes || null , 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 = ?" )
2026-05-15 22:45:38 -05:00
. run ( balCalc . new _balance , bill . id ) ;
2026-05-14 02:11:54 -05:00
}
2026-05-03 19:51:57 -05:00
if ( bill . autopay _enabled ) {
2026-05-15 22:45:38 -05:00
db . prepare ( "UPDATE bills SET autodraft_status='confirmed', updated_at=datetime('now') WHERE id=?" ) . run ( bill . id ) ;
2026-05-03 19:51:57 -05:00
}
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
2026-05-15 22:45:38 -05:00
// - Each item requires: bill_id (integer), paid_date (valid date), amount (positive number)
2026-05-09 23:41:28 -05:00
// - 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 ] ;
2026-05-15 22:45:38 -05:00
const validation = validatePaymentInput ( item , { fieldPrefix : ` payments[ ${ i } ]. ` } ) ;
if ( validation . error ) {
return res . status ( 400 ) . json ( standardizeError ( ` Payment at index ${ i } : ${ validation . error } ` , 'VALIDATION_ERROR' , validation . field ) ) ;
2026-05-09 23:41:28 -05:00
}
}
2026-05-03 19:51:57 -05:00
const insert = 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-03 19:51:57 -05:00
) ;
2026-05-14 02:11:54 -05:00
const getBillForBalance = db . prepare ( 'SELECT current_balance, interest_rate FROM bills WHERE id = ? AND user_id = ?' ) ;
const applyBalance = db . prepare ( "UPDATE bills SET current_balance = ?, updated_at = datetime('now') WHERE id = ?" ) ;
2026-05-03 19:51:57 -05:00
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 ) {
2026-05-15 22:45:38 -05:00
const payment = validatePaymentInput ( item ) . normalized ;
const { bill _id , amount : parsedAmt , paid _date } = payment ;
const { method , notes } = item ;
2026-05-09 23:41:28 -05:00
// 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-14 02:11:54 -05:00
const billRow = getBillForBalance . get ( bill _id , req . user . id ) ;
if ( ! billRow ) {
2026-05-03 19:51:57 -05:00
errors . push ( { item , error : ` Bill ${ bill _id } not found ` } ) ;
continue ;
}
2026-05-14 02:11:54 -05:00
const balCalc = computeBalanceDelta ( billRow , parsedAmt ) ;
const r = insert . run ( bill _id , parsedAmt , paid _date , method || null , notes || null , balCalc ? . balance _delta ? ? null ) ;
if ( balCalc ) applyBalance . run ( balCalc . new _balance , bill _id ) ;
2026-05-03 19:51:57 -05:00
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 ;
2026-05-15 22:45:38 -05:00
const validation = validatePaymentInput (
{ amount , paid _date } ,
{ requireBillId : false , requireAmount : false , requirePaidDate : false } ,
) ;
if ( validation . error ) {
return res . status ( 400 ) . json ( standardizeError ( validation . error , 'VALIDATION_ERROR' , validation . field ) ) ;
}
2026-05-03 19:51:57 -05:00
db . prepare ( `
UPDATE payments SET
amount = ? , paid _date = ? , method = ? , notes = ? ,
updated _at = datetime ( 'now' )
WHERE id = ?
` ).run(
2026-05-15 22:45:38 -05:00
validation . normalized . amount ? ? existing . amount ,
validation . normalized . paid _date ? ? existing . paid _date ,
2026-05-03 19:51:57 -05:00
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 ( ) ;
2026-05-14 02:11:54 -05:00
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-14 02:11:54 -05:00
// Reverse any balance delta that was stored when this payment was created
if ( payment . balance _delta != null ) {
const bill = db . prepare ( 'SELECT current_balance FROM bills WHERE id = ?' ) . get ( payment . bill _id ) ;
if ( bill ? . current _balance != null ) {
const restored = Math . max ( 0 , Math . round ( ( bill . current _balance - payment . balance _delta ) * 100 ) / 100 ) ;
db . prepare ( "UPDATE bills SET current_balance = ?, updated_at = datetime('now') WHERE id = ?" ) . run ( restored , payment . bill _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 ( ) ;
2026-05-14 02:11:54 -05:00
const payment = db . prepare ( 'SELECT p.* 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-14 02:11:54 -05:00
// Re-apply the balance delta (undo the reversal done on delete)
if ( payment . balance _delta != null ) {
const bill = db . prepare ( 'SELECT current_balance FROM bills WHERE id = ?' ) . get ( payment . bill _id ) ;
if ( bill ? . current _balance != null ) {
const reapplied = Math . max ( 0 , Math . round ( ( bill . current _balance + payment . balance _delta ) * 100 ) / 100 ) ;
db . prepare ( "UPDATE bills SET current_balance = ?, updated_at = datetime('now') WHERE id = ?" ) . run ( reapplied , payment . bill _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 ;