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-16 20:26:09 -05:00
const { getCycleRange , resolveDueDate } = require ( '../services/statusService' ) ;
2026-05-03 19:51:57 -05:00
const LIVE = 'deleted_at IS NULL' ; // filter for non-deleted payments
2026-05-16 21:36:04 -05:00
const TRANSACTION _MATCH _SOURCE = 'transaction_match' ;
function isTransactionLinkedPayment ( payment ) {
return payment ? . payment _source === TRANSACTION _MATCH _SOURCE || payment ? . transaction _id != null ;
}
function rejectTransactionLinkedPayment ( res ) {
return res . status ( 409 ) . json ( standardizeError (
'Transaction-linked payments must be changed through transaction match controls' ,
'TRANSACTION_PAYMENT_LOCKED' ,
'transaction_id' ,
) ) ;
}
2026-05-03 19:51:57 -05:00
2026-05-16 15:38:28 -05:00
function parseYearMonth ( body ) {
const year = parseInt ( body . year , 10 ) ;
const month = parseInt ( body . month , 10 ) ;
if ( ! Number . isInteger ( year ) || year < 2000 || year > 2100 ) {
return { error : standardizeError ( 'year must be a 4-digit integer between 2000 and 2100' , 'VALIDATION_ERROR' , 'year' ) } ;
}
if ( ! Number . isInteger ( month ) || month < 1 || month > 12 ) {
return { error : standardizeError ( 'month must be an integer between 1 and 12' , 'VALIDATION_ERROR' , 'month' ) } ;
}
return { year , month } ;
}
function getAutopaySuggestionContext ( db , userId , billId , year , month ) {
const bill = db . prepare ( `
SELECT *
FROM bills
WHERE id = ? AND user _id = ? AND deleted _at IS NULL
` ).get(billId, userId);
if ( ! bill ) return { error : standardizeError ( 'Bill not found' , 'NOT_FOUND' , 'bill_id' ) , status : 404 } ;
if ( ! bill . autopay _enabled || bill . autodraft _status !== 'assumed_paid' ) {
return { error : standardizeError ( 'Bill is not eligible for autopay suggestions' , 'VALIDATION_ERROR' , 'bill_id' ) , status : 400 } ;
}
const state = db . prepare ( `
SELECT actual _amount , is _skipped
FROM monthly _bill _state
WHERE bill _id = ? AND year = ? AND month = ?
` ).get(bill.id, year, month);
if ( state ? . is _skipped ) {
return { error : standardizeError ( 'Skipped bills cannot be suggested for payment' , 'VALIDATION_ERROR' , 'bill_id' ) , status : 400 } ;
}
const dueDate = resolveDueDate ( bill , year , month ) ;
2026-05-16 20:26:09 -05:00
if ( ! dueDate ) {
return { error : standardizeError ( 'Bill does not occur in the selected month' , 'VALIDATION_ERROR' , 'month' ) , status : 400 } ;
}
2026-05-16 15:38:28 -05:00
const amount = state ? . actual _amount ? ? bill . expected _amount ;
return { bill , dueDate , amount } ;
}
2026-05-03 19:51:57 -05:00
// 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
}
}
2026-05-16 10:34:32 -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 = ? AND b.deleted_at IS NULL ` ;
2026-05-03 19:51:57 -05:00
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 ( ) ;
2026-05-16 10:34:32 -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 = ? AND b.deleted_at IS NULL ` ) . 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 ( ) ;
2026-05-16 20:26:09 -05:00
const { bill _id , amount , paid _date , method , notes , payment _source } = req . body ;
2026-05-03 19:51:57 -05:00
2026-05-16 20:26:09 -05:00
const validation = validatePaymentInput ( { bill _id , amount , paid _date , payment _source : payment _source ? ? 'manual' } ) ;
2026-05-15 22:45:38 -05:00
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-16 15:38:28 -05:00
const bill = db . prepare ( 'SELECT * FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL' ) . get ( payment . bill _id , req . user . id ) ;
if ( ! bill )
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-16 15:38:28 -05:00
const balCalc = computeBalanceDelta ( bill , payment . amount ) ;
2026-05-03 19:51:57 -05:00
const result = db . prepare (
2026-05-16 20:26:09 -05:00
'INSERT INTO payments (bill_id, amount, paid_date, method, notes, balance_delta, payment_source) VALUES (?, ?, ?, ?, ?, ?, ?)'
) . run ( payment . bill _id , payment . amount , payment . paid _date , method || null , notes || null , balCalc ? . balance _delta ? ? null , payment . payment _source ) ;
2026-05-16 15:38:28 -05:00
if ( balCalc ) {
db . prepare ( "UPDATE bills SET current_balance = ?, updated_at = datetime('now') WHERE id = ?" )
. run ( balCalc . new _balance , 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/quick — pay a bill (expected amount, today)
router . post ( '/quick' , ( req , res ) => {
const db = getDb ( ) ;
2026-05-16 20:26:09 -05:00
const { bill _id , amount , paid _date , method , notes , payment _source } = req . body ;
2026-05-03 19:51:57 -05:00
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-16 10:34:32 -05:00
const bill = db . prepare ( 'SELECT * FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL' ) . 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 ) ,
2026-05-16 20:26:09 -05:00
payment _source : 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 ) ) ;
}
const payAmount = paymentValidation . normalized . amount ;
const payDate = paymentValidation . normalized . paid _date ;
2026-05-16 20:26:09 -05:00
const paySource = paymentValidation . normalized . payment _source ;
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-16 20:26:09 -05:00
'INSERT INTO payments (bill_id, amount, paid_date, method, notes, balance_delta, payment_source) VALUES (?, ?, ?, ?, ?, ?, ?)'
) . run ( bill . id , payAmount , payDate , method || null , notes || null , balCalc ? . balance _delta ? ? null , paySource ) ;
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
2026-05-16 15:38:28 -05:00
res . status ( 201 ) . json ( db . prepare ( 'SELECT * FROM payments WHERE id = ?' ) . get ( result . lastInsertRowid ) ) ;
} ) ;
// POST /api/payments/autopay-suggestions/:billId/confirm
router . post ( '/autopay-suggestions/:billId/confirm' , ( req , res ) => {
const db = getDb ( ) ;
const ym = parseYearMonth ( req . body ) ;
if ( ym . error ) return res . status ( 400 ) . json ( ym . error ) ;
const billId = parseInt ( req . params . billId , 10 ) ;
if ( ! Number . isInteger ( billId ) ) {
return res . status ( 400 ) . json ( standardizeError ( 'bill_id must be an integer' , 'VALIDATION_ERROR' , 'bill_id' ) ) ;
2026-05-03 19:51:57 -05:00
}
2026-05-16 15:38:28 -05:00
const context = getAutopaySuggestionContext ( db , req . user . id , billId , ym . year , ym . month ) ;
if ( context . error ) return res . status ( context . status ) . json ( context . error ) ;
const { bill , dueDate , amount } = context ;
if ( dueDate > new Date ( ) . toISOString ( ) . slice ( 0 , 10 ) ) {
return res . status ( 400 ) . json ( standardizeError ( 'Autopay suggestion is not due yet' , 'VALIDATION_ERROR' , 'paid_date' ) ) ;
}
const paymentValidation = validatePaymentInput (
{ amount , paid _date : dueDate } ,
{ requireBillId : false } ,
) ;
if ( paymentValidation . error ) {
return res . status ( 400 ) . json ( standardizeError ( paymentValidation . error , 'VALIDATION_ERROR' , paymentValidation . field ) ) ;
}
const suggestedPayment = paymentValidation . normalized ;
2026-05-16 20:26:09 -05:00
const suggestionRange = getCycleRange ( ym . year , ym . month , bill ) ;
2026-05-16 15:38:28 -05:00
const existing = db . prepare ( `
SELECT p . *
FROM payments p
JOIN bills b ON b . id = p . bill _id
WHERE p . bill _id = ?
AND b . user _id = ?
AND p . deleted _at IS NULL
2026-05-16 20:26:09 -05:00
AND p . paid _date BETWEEN ? AND ?
2026-05-16 15:38:28 -05:00
ORDER BY p . paid _date DESC
LIMIT 1
2026-05-16 20:26:09 -05:00
` ).get(bill.id, req.user.id, suggestionRange.start, suggestionRange.end);
2026-05-16 15:38:28 -05:00
if ( existing ) {
db . prepare ( 'DELETE FROM autopay_suggestion_dismissals WHERE user_id = ? AND bill_id = ? AND year = ? AND month = ?' )
. run ( req . user . id , bill . id , ym . year , ym . month ) ;
return res . json ( { created : false , payment : existing } ) ;
}
const balCalc = computeBalanceDelta ( bill , suggestedPayment . amount ) ;
const result = db . prepare ( `
2026-05-16 20:26:09 -05:00
INSERT INTO payments ( bill _id , amount , paid _date , method , notes , balance _delta , payment _source )
VALUES ( ? , ? , ? , ? , ? , ? , ? )
2026-05-16 15:38:28 -05:00
` ).run(
bill . id ,
suggestedPayment . amount ,
suggestedPayment . paid _date ,
'autopay' ,
'Confirmed autopay suggestion' ,
balCalc ? . balance _delta ? ? null ,
2026-05-16 20:26:09 -05:00
'manual' ,
2026-05-16 15:38:28 -05:00
) ;
if ( balCalc ) {
db . prepare ( "UPDATE bills SET current_balance = ?, updated_at=datetime('now') WHERE id=?" )
. run ( balCalc . new _balance , bill . id ) ;
}
db . prepare ( 'DELETE FROM autopay_suggestion_dismissals WHERE user_id = ? AND bill_id = ? AND year = ? AND month = ?' )
. run ( req . user . id , bill . id , ym . year , ym . month ) ;
res . status ( 201 ) . json ( { created : true , payment : db . prepare ( 'SELECT * FROM payments WHERE id = ?' ) . get ( result . lastInsertRowid ) } ) ;
} ) ;
// POST /api/payments/autopay-suggestions/:billId/dismiss
router . post ( '/autopay-suggestions/:billId/dismiss' , ( req , res ) => {
const db = getDb ( ) ;
const ym = parseYearMonth ( req . body ) ;
if ( ym . error ) return res . status ( 400 ) . json ( ym . error ) ;
const billId = parseInt ( req . params . billId , 10 ) ;
if ( ! Number . isInteger ( billId ) ) {
return res . status ( 400 ) . json ( standardizeError ( 'bill_id must be an integer' , 'VALIDATION_ERROR' , 'bill_id' ) ) ;
}
if ( ! db . prepare ( 'SELECT id FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL' ) . get ( billId , req . user . id ) ) {
return res . status ( 404 ) . json ( standardizeError ( 'Bill not found' , 'NOT_FOUND' , 'bill_id' ) ) ;
}
db . prepare ( `
INSERT INTO autopay _suggestion _dismissals ( user _id , bill _id , year , month , dismissed _at )
VALUES ( ? , ? , ? , ? , datetime ( 'now' ) )
ON CONFLICT ( user _id , bill _id , year , month )
DO UPDATE SET dismissed _at = datetime ( 'now' )
` ).run(req.user.id, billId, ym.year, ym.month);
res . json ( { success : true } ) ;
2026-05-03 19:51:57 -05:00
} ) ;
// 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-16 20:26:09 -05:00
const validation = validatePaymentInput (
{ ... item , payment _source : item . payment _source ? ? 'manual' } ,
{ fieldPrefix : ` payments[ ${ i } ]. ` } ,
) ;
2026-05-15 22:45:38 -05:00
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-16 20:26:09 -05:00
'INSERT INTO payments (bill_id, amount, paid_date, method, notes, balance_delta, payment_source) VALUES (?, ?, ?, ?, ?, ?, ?)'
2026-05-03 19:51:57 -05:00
) ;
2026-05-16 10:34:32 -05:00
const getBillForBalance = db . prepare ( 'SELECT current_balance, interest_rate FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL' ) ;
2026-05-14 02:11:54 -05:00
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 = ?
2026-05-16 10:34:32 -05:00
AND b . deleted _at IS NULL
2026-05-09 23:41:28 -05:00
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-16 20:26:09 -05:00
const payment = validatePaymentInput ( { ... item , payment _source : item . payment _source ? ? 'manual' } ) . normalized ;
const { bill _id , amount : parsedAmt , paid _date , payment _source } = payment ;
2026-05-15 22:45:38 -05:00
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 ) ;
2026-05-16 20:26:09 -05:00
const r = insert . run ( bill _id , parsedAmt , paid _date , method || null , notes || null , balCalc ? . balance _delta ? ? null , payment _source ) ;
2026-05-14 02:11:54 -05:00
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 ( ) ;
2026-05-16 10:34:32 -05:00
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 = ? AND b.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 ( 'Payment not found' , 'NOT_FOUND' , 'id' ) ) ;
2026-05-16 21:36:04 -05:00
if ( isTransactionLinkedPayment ( existing ) ) return rejectTransactionLinkedPayment ( res ) ;
2026-05-03 19:51:57 -05:00
2026-05-16 20:26:09 -05:00
const { amount , paid _date , method , notes , payment _source } = req . body ;
2026-05-15 22:45:38 -05:00
const validation = validatePaymentInput (
2026-05-16 20:26:09 -05:00
{ amount , paid _date , payment _source } ,
2026-05-15 22:45:38 -05:00
{ 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
2026-05-16 15:38:28 -05:00
const nextAmount = validation . normalized . amount ? ? existing . amount ;
const nextPaidDate = validation . normalized . paid _date ? ? existing . paid _date ;
2026-05-16 20:26:09 -05:00
const nextPaymentSource = validation . normalized . payment _source ? ? existing . payment _source ? ? 'manual' ;
2026-05-16 15:38:28 -05:00
let nextBalanceDelta = existing . balance _delta ;
const bill = db . prepare ( 'SELECT * FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL' ) . get ( existing . bill _id , req . user . id ) ;
if ( bill ) {
let restoredBalance = bill . current _balance ;
if ( existing . balance _delta != null && bill . current _balance != null ) {
restoredBalance = Math . max ( 0 , Math . round ( ( bill . current _balance - existing . balance _delta ) * 100 ) / 100 ) ;
}
const balCalc = computeBalanceDelta ( { ... bill , current _balance : restoredBalance } , nextAmount ) ;
nextBalanceDelta = balCalc ? . balance _delta ? ? null ;
if ( balCalc ) {
db . prepare ( "UPDATE bills SET current_balance = ?, updated_at = datetime('now') WHERE id = ?" )
. run ( balCalc . new _balance , existing . bill _id ) ;
} else if ( existing . balance _delta != null && restoredBalance != null ) {
db . prepare ( "UPDATE bills SET current_balance = ?, updated_at = datetime('now') WHERE id = ?" )
. run ( restoredBalance , existing . bill _id ) ;
}
}
2026-05-03 19:51:57 -05:00
db . prepare ( `
UPDATE payments SET
2026-05-16 20:26:09 -05:00
amount = ? , paid _date = ? , method = ? , notes = ? , balance _delta = ? , payment _source = ? ,
2026-05-03 19:51:57 -05:00
updated _at = datetime ( 'now' )
WHERE id = ?
` ).run(
2026-05-16 15:38:28 -05:00
nextAmount ,
nextPaidDate ,
2026-05-03 19:51:57 -05:00
method !== undefined ? ( method || null ) : existing . method ,
notes !== undefined ? ( notes || null ) : existing . notes ,
2026-05-16 15:38:28 -05:00
nextBalanceDelta ,
2026-05-16 20:26:09 -05:00
nextPaymentSource ,
2026-05-03 19:51:57 -05:00
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-16 10:34:32 -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 = ? AND b.deleted_at IS NULL ` ) . 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-16 21:36:04 -05:00
if ( isTransactionLinkedPayment ( payment ) ) return rejectTransactionLinkedPayment ( res ) ;
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-16 10:34:32 -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 = ? AND b.deleted_at IS NULL' ) . 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-16 21:36:04 -05:00
if ( isTransactionLinkedPayment ( payment ) ) return rejectTransactionLinkedPayment ( res ) ;
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 ;