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-06-06 16:34:20 -05:00
const { computeBalanceDelta , applyBalanceDelta } = require ( '../services/billsService' ) ;
2026-06-11 20:12:31 -05:00
const { validatePaymentInput , serializePayment } = require ( '../services/paymentValidation' ) ;
2026-05-16 20:26:09 -05:00
const { getCycleRange , resolveDueDate } = require ( '../services/statusService' ) ;
refactor(match): one canonical writer for transaction match state (IMP-CODE-03)
match_status, matched_bill_id and ignored must move together, but they were
updated by copy-pasted inline UPDATEs across six routes/services — exactly how
they drift apart (QA-B5-04 left match_status='matched' with a NULL bill).
Add services/transactionMatchState.js (markMatched / markUnmatched / markIgnored,
each ownership-scoped, returning rows changed) and route the six single-
transaction transitions through it: matchTransactionToBill, unmatchTransaction,
ignoreTransaction, unignoreTransaction (transactionMatchService), the match/
unmatch handlers (routes/matches), and unmatch-on-payment-delete (routes/
transactions, routes/payments).
Guarded bulk auto-match sweeps (subscription tracking, merchant-rule matching,
historical import) and the retention purge intentionally keep their own queries
— their WHERE clauses carry idempotency guards (AND match_status='unmatched')
the simple helper must not silently drop.
Test: tests/transactionMatchState.test.js (transitions + ownership scoping).
transactionMatchService/subscriptionService regression suites still pass;
server 122 pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 13:02:10 -05:00
const { markUnmatched } = require ( '../services/transactionMatchState' ) ;
2026-06-07 01:05:48 -05:00
const {
markProvisionalManualPaymentsOverridden ,
reactivatePaymentsOverriddenBy ,
} = require ( '../services/paymentAccountingService' ) ;
2026-06-10 19:42:51 -05:00
const { todayLocal } = require ( '../utils/dates' ) ;
2026-06-11 20:12:31 -05:00
const { fromCents } = require ( '../utils/money' ) ;
2026-05-03 19:51:57 -05:00
2026-06-03 22:28:46 -05:00
// SQL_NOT_DELETED is a compile-time constant SQL fragment, never user-supplied.
// It cannot be a bind parameter (SQL fragments are not parameterisable — only
// values are). Interpolating a hardcoded constant like this is safe by
// construction; do NOT replace this pattern with dynamic/user-controlled input.
const SQL _NOT _DELETED = 'deleted_at IS NULL' ;
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-06-11 20:12:31 -05:00
const amount = fromCents ( state ? . actual _amount ? ? bill . expected _amount ) ;
2026-05-16 15:38:28 -05:00
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-06-03 22:28:46 -05:00
let query = ` SELECT p.* FROM payments p JOIN bills b ON b.id = p.bill_id WHERE p. ${ SQL _NOT _DELETED } 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' ;
2026-06-11 20:12:31 -05:00
res . json ( db . prepare ( query ) . all ( ... params ) . map ( serializePayment ) ) ;
2026-05-03 19:51:57 -05:00
} ) ;
2026-06-06 17:34:09 -05:00
// GET /api/payments/recent-auto — provider_sync payments with a linked tx, last 7 days
router . get ( '/recent-auto' , ( req , res ) => {
const db = getDb ( ) ;
const rows = db . prepare ( `
SELECT p . id , p . bill _id , p . amount , p . paid _date , p . payment _source ,
p . transaction _id , p . balance _delta , p . interest _delta , p . created _at ,
b . name AS bill _name ,
t . payee , t . description , t . amount AS tx _cents , t . posted _date
FROM payments p
JOIN bills b ON b . id = p . bill _id
LEFT JOIN transactions t ON t . id = p . transaction _id
WHERE b . user _id = ?
AND p . payment _source = 'provider_sync'
AND p . transaction _id IS NOT NULL
AND p . deleted _at IS NULL
AND b . deleted _at IS NULL
AND p . created _at >= datetime ( 'now' , '-7 days' )
ORDER BY p . created _at DESC
LIMIT 50
` ).all(req.user.id);
2026-06-11 20:12:31 -05:00
res . json ( rows . map ( serializePayment ) ) ;
2026-06-06 17:34:09 -05:00
} ) ;
2026-05-03 19:51:57 -05:00
// GET /api/payments/:id
router . get ( '/:id' , ( req , res ) => {
const db = getDb ( ) ;
2026-06-03 22:28:46 -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. ${ SQL _NOT _DELETED } 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-06-11 20:12:31 -05:00
res . json ( serializePayment ( payment ) ) ;
2026-05-03 19:51:57 -05:00
} ) ;
2026-06-06 17:34:09 -05:00
// POST /api/payments/:id/undo-auto — reverse a provider_sync auto-match
router . post ( '/:id/undo-auto' , ( 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 . $ { SQL _NOT _DELETED } AND b . user _id = ? AND b . deleted _at IS NULL
` ).get(req.params.id, req.user.id);
if ( ! payment ) return res . status ( 404 ) . json ( standardizeError ( 'Payment not found' , 'NOT_FOUND' , 'id' ) ) ;
if ( payment . payment _source !== 'provider_sync' ) {
return res . status ( 409 ) . json ( standardizeError ( 'Only provider_sync payments can be undone here' , 'NOT_AUTO_MATCH' ) ) ;
}
if ( ! payment . transaction _id ) {
return res . status ( 409 ) . json ( standardizeError ( 'Payment has no linked transaction' , 'NO_TRANSACTION' ) ) ;
}
try {
db . transaction ( ( ) => {
// Restore balance (same logic as DELETE /:id)
2026-06-07 01:05:48 -05:00
if ( ! payment . accounting _excluded && payment . balance _delta != null ) {
2026-06-06 17:34:09 -05:00
const bill = db . prepare ( 'SELECT current_balance FROM bills WHERE id = ?' ) . get ( payment . bill _id ) ;
if ( bill ? . current _balance != null ) {
2026-06-11 20:12:31 -05:00
const restored = Math . max ( 0 , Number ( bill . current _balance ) - Number ( payment . balance _delta ) ) ;
2026-06-06 17:34:09 -05:00
db . prepare ( `
UPDATE bills
SET current _balance = ? ,
interest _accrued _month = CASE WHEN ? THEN NULL ELSE interest _accrued _month END ,
updated _at = datetime ( 'now' )
WHERE id = ?
` ).run(restored, payment.interest_delta != null ? 1 : 0, payment.bill_id);
}
}
2026-06-07 01:05:48 -05:00
reactivatePaymentsOverriddenBy ( db , payment . id ) ;
2026-06-06 17:34:09 -05:00
db . prepare ( "UPDATE payments SET deleted_at = datetime('now') WHERE id = ?" ) . run ( payment . id ) ;
refactor(match): one canonical writer for transaction match state (IMP-CODE-03)
match_status, matched_bill_id and ignored must move together, but they were
updated by copy-pasted inline UPDATEs across six routes/services — exactly how
they drift apart (QA-B5-04 left match_status='matched' with a NULL bill).
Add services/transactionMatchState.js (markMatched / markUnmatched / markIgnored,
each ownership-scoped, returning rows changed) and route the six single-
transaction transitions through it: matchTransactionToBill, unmatchTransaction,
ignoreTransaction, unignoreTransaction (transactionMatchService), the match/
unmatch handlers (routes/matches), and unmatch-on-payment-delete (routes/
transactions, routes/payments).
Guarded bulk auto-match sweeps (subscription tracking, merchant-rule matching,
historical import) and the retention purge intentionally keep their own queries
— their WHERE clauses carry idempotency guards (AND match_status='unmatched')
the simple helper must not silently drop.
Test: tests/transactionMatchState.test.js (transitions + ownership scoping).
transactionMatchService/subscriptionService regression suites still pass;
server 122 pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 13:02:10 -05:00
markUnmatched ( db , req . user . id , payment . transaction _id ) ;
2026-06-06 17:34:09 -05:00
} ) ( ) ;
res . json ( { success : true } ) ;
} catch ( err ) {
console . error ( '[payments] undo-auto error:' , err . message ) ;
res . status ( 500 ) . json ( standardizeError ( 'Failed to undo auto-match' , 'SERVER_ERROR' ) ) ;
}
} ) ;
2026-05-03 19:51:57 -05:00
// POST /api/payments — create single payment
router . post ( '/' , ( req , res ) => {
const db = getDb ( ) ;
2026-06-07 14:49:39 -05:00
const { bill _id , amount , paid _date , method , notes , payment _source , autopay _failure } = 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-06-07 14:49:39 -05:00
const failureFlag = autopay _failure ? 1 : 0 ;
2026-05-03 19:51:57 -05:00
const result = db . prepare (
2026-06-07 14:49:39 -05:00
'INSERT INTO payments (bill_id, amount, paid_date, method, notes, balance_delta, interest_delta, payment_source, autopay_failure) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)'
) . run ( payment . bill _id , payment . amount , payment . paid _date , method || null , notes || null , balCalc ? . balance _delta ? ? null , balCalc ? . interest _delta ? ? null , payment . payment _source , failureFlag ) ;
2026-05-16 15:38:28 -05:00
2026-06-06 16:34:20 -05:00
applyBalanceDelta ( db , bill . id , balCalc ) ;
2026-05-03 19:51:57 -05:00
2026-06-11 20:12:31 -05:00
res . status ( 201 ) . json ( serializePayment ( db . prepare ( 'SELECT * FROM payments WHERE id = ?' ) . get ( result . lastInsertRowid ) ) ) ;
2026-05-03 19:51:57 -05:00
} ) ;
// 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 (
{
2026-06-11 20:12:31 -05:00
amount : amount != null ? amount : fromCents ( bill . expected _amount ) ,
2026-06-10 19:42:51 -05:00
paid _date : paid _date || todayLocal ( ) ,
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-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 ( bill . id , payAmount , payDate , method || null , notes || null , balCalc ? . balance _delta ? ? null , balCalc ? . interest _delta ? ? null , paySource ) ;
2026-05-14 02:11:54 -05:00
2026-06-06 16:34:20 -05:00
applyBalanceDelta ( db , bill . id , balCalc ) ;
2026-05-03 19:51:57 -05:00
2026-06-11 20:12:31 -05:00
res . status ( 201 ) . json ( serializePayment ( db . prepare ( 'SELECT * FROM payments WHERE id = ?' ) . get ( result . lastInsertRowid ) ) ) ;
2026-05-16 15:38:28 -05:00
} ) ;
// 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 ;
2026-06-10 19:42:51 -05:00
if ( dueDate > todayLocal ( ) ) {
2026-05-16 15:38:28 -05:00
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 ) ;
2026-06-11 20:12:31 -05:00
return res . json ( { created : false , payment : serializePayment ( existing ) } ) ;
2026-05-16 15:38:28 -05:00
}
const balCalc = computeBalanceDelta ( bill , suggestedPayment . amount ) ;
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 ( ? , ? , ? , ? , ? , ? , ? , ? )
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-06-06 16:34:20 -05:00
balCalc ? . interest _delta ? ? null ,
2026-05-16 20:26:09 -05:00
'manual' ,
2026-05-16 15:38:28 -05:00
) ;
2026-06-06 16:34:20 -05:00
applyBalanceDelta ( db , bill . id , balCalc ) ;
2026-05-16 15:38:28 -05:00
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 ) ;
2026-06-11 20:12:31 -05:00
res . status ( 201 ) . json ( { created : true , payment : serializePayment ( db . prepare ( 'SELECT * FROM payments WHERE id = ?' ) . get ( result . lastInsertRowid ) ) } ) ;
2026-05-16 15:38:28 -05:00
} ) ;
// 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-06-06 16:34:20 -05:00
'INSERT INTO payments (bill_id, amount, paid_date, method, notes, balance_delta, interest_delta, payment_source) VALUES (?, ?, ?, ?, ?, ?, ?, ?)'
2026-05-03 19:51:57 -05:00
) ;
2026-06-06 16:34:20 -05:00
const getBillForBalance = db . prepare ( 'SELECT current_balance, interest_rate, interest_accrued_month FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL' ) ;
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 = ?
2026-06-03 22:28:46 -05:00
AND p . $ { SQL _NOT _DELETED } `
2026-05-09 23:41:28 -05:00
) ;
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 ) {
2026-06-11 20:12:31 -05:00
skipped . push ( { bill _id , paid _date , amount : fromCents ( 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-06-06 16:34:20 -05:00
const r = insert . run ( bill _id , parsedAmt , paid _date , method || null , notes || null , balCalc ? . balance _delta ? ? null , balCalc ? . interest _delta ? ? null , payment _source ) ;
applyBalanceDelta ( db , bill _id , balCalc ) ;
2026-05-14 02:11:54 -05:00
2026-06-11 20:12:31 -05:00
created . push ( serializePayment ( db . prepare ( 'SELECT * FROM payments WHERE id = ?' ) . get ( r . lastInsertRowid ) ) ) ;
2026-05-03 19:51:57 -05:00
}
} ) ;
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-06-03 22:28:46 -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. ${ SQL _NOT _DELETED } 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 ) ;
2026-06-06 16:34:20 -05:00
let nextInterestDelta = existing . interest _delta ? ? null ;
2026-05-16 15:38:28 -05:00
if ( bill ) {
2026-06-06 16:34:20 -05:00
// Reverse only the *payment* portion of the stored delta (not the interest component)
// so that interest already charged this month is not double-counted. For legacy rows
// where interest_delta is NULL, fall back to reversing the full delta as before.
const interestPortion = existing . interest _delta ? ? 0 ;
const paymentPortion = existing . balance _delta != null ? existing . balance _delta - interestPortion : null ;
2026-05-16 15:38:28 -05:00
let restoredBalance = bill . current _balance ;
2026-06-06 16:34:20 -05:00
if ( paymentPortion != null && bill . current _balance != null ) {
2026-06-11 20:12:31 -05:00
restoredBalance = Math . max ( 0 , bill . current _balance - paymentPortion ) ;
2026-05-16 15:38:28 -05:00
}
2026-06-06 16:34:20 -05:00
// interest_accrued_month is still set to this month (if interest was charged) so
// computeBalanceDelta will skip interest when the payment is within the same month,
// and charge a fresh month of interest if editing into a new calendar month.
2026-05-16 15:38:28 -05:00
const balCalc = computeBalanceDelta ( { ... bill , current _balance : restoredBalance } , nextAmount ) ;
2026-06-06 16:34:20 -05:00
nextBalanceDelta = balCalc ? . balance _delta ? ? null ;
nextInterestDelta = balCalc ? . interest _delta ? ? null ;
2026-05-16 15:38:28 -05:00
if ( balCalc ) {
2026-06-06 16:34:20 -05:00
applyBalanceDelta ( db , existing . bill _id , balCalc ) ;
} else if ( paymentPortion != null && restoredBalance != null ) {
2026-05-16 15:38:28 -05:00
db . prepare ( "UPDATE bills SET current_balance = ?, updated_at = datetime('now') WHERE id = ?" )
. run ( restoredBalance , existing . bill _id ) ;
}
}
2026-06-07 14:49:39 -05:00
const { autopay _failure } = req . body ;
const nextAutopayFailure = autopay _failure !== undefined ? ( autopay _failure ? 1 : 0 ) : existing . autopay _failure ;
2026-05-03 19:51:57 -05:00
db . prepare ( `
UPDATE payments SET
2026-06-06 16:34:20 -05:00
amount = ? , paid _date = ? , method = ? , notes = ? , balance _delta = ? , interest _delta = ? ,
2026-06-07 14:49:39 -05:00
payment _source = ? , autopay _failure = ? , updated _at = datetime ( 'now' )
2026-05-03 19:51:57 -05:00
WHERE id = ?
2026-05-31 15:06:10 -05:00
AND bill _id IN ( SELECT id FROM bills WHERE user _id = ? AND deleted _at IS NULL )
2026-05-03 19:51:57 -05:00
` ).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-06-06 16:34:20 -05:00
nextInterestDelta ,
2026-05-16 20:26:09 -05:00
nextPaymentSource ,
2026-06-07 14:49:39 -05:00
nextAutopayFailure ,
2026-05-03 19:51:57 -05:00
req . params . id ,
2026-05-31 15:06:10 -05:00
req . user . id ,
2026-05-03 19:51:57 -05:00
) ;
2026-06-11 20:12:31 -05:00
res . json ( serializePayment ( db . prepare ( ` SELECT p.* FROM payments p JOIN bills b ON b.id = p.bill_id WHERE p.id = ? AND p. ${ SQL _NOT _DELETED } AND b.user_id = ? AND b.deleted_at IS NULL ` ) . get ( req . params . id , req . user . id ) ) ) ;
2026-05-03 19:51:57 -05:00
} ) ;
// DELETE /api/payments/:id — soft delete (sets deleted_at)
router . delete ( '/:id' , ( req , res ) => {
const db = getDb ( ) ;
2026-06-03 22:28:46 -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. ${ SQL _NOT _DELETED } 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
2026-06-06 16:34:20 -05:00
// Reverse any balance delta that was stored when this payment was created.
// If this payment was the one that charged interest this month, clear
// interest_accrued_month so the next payment can re-accrue correctly.
2026-06-07 01:05:48 -05:00
if ( ! payment . accounting _excluded && payment . balance _delta != null ) {
2026-05-31 15:06:10 -05:00
const bill = db . prepare ( 'SELECT current_balance FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL' ) . get ( payment . bill _id , req . user . id ) ;
2026-05-14 02:11:54 -05:00
if ( bill ? . current _balance != null ) {
2026-06-11 20:12:31 -05:00
const restored = Math . max ( 0 , bill . current _balance - payment . balance _delta ) ;
2026-06-06 16:34:20 -05:00
db . prepare ( `
UPDATE bills
SET current _balance = ? ,
interest _accrued _month = CASE WHEN ? THEN NULL ELSE interest _accrued _month END ,
updated _at = datetime ( 'now' )
WHERE id = ?
` ).run(restored, payment.interest_delta != null ? 1 : 0, payment.bill_id);
2026-05-14 02:11:54 -05:00
}
}
2026-05-31 15:06:10 -05:00
db . prepare ( "UPDATE payments SET deleted_at = datetime('now') WHERE id = ? AND bill_id IN (SELECT id FROM bills WHERE user_id = ? AND deleted_at IS NULL)" ) . run ( req . params . id , req . user . id ) ;
2026-05-03 19:51:57 -05:00
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
2026-06-06 16:34:20 -05:00
// Re-apply the balance delta (undo the reversal done on delete).
// If this payment originally charged interest, restore interest_accrued_month
// to the month of the payment so future same-month payments skip interest.
2026-06-07 01:05:48 -05:00
if ( ! payment . accounting _excluded && payment . balance _delta != null ) {
2026-05-31 15:06:10 -05:00
const bill = db . prepare ( 'SELECT current_balance FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL' ) . get ( payment . bill _id , req . user . id ) ;
2026-05-14 02:11:54 -05:00
if ( bill ? . current _balance != null ) {
2026-06-11 20:12:31 -05:00
const reapplied = Math . max ( 0 , bill . current _balance + payment . balance _delta ) ;
2026-06-06 16:34:20 -05:00
const interestMonth = payment . interest _delta != null ? ( payment . paid _date ? . slice ( 0 , 7 ) ? ? null ) : null ;
db . prepare ( `
UPDATE bills
SET current _balance = ? ,
interest _accrued _month = CASE WHEN ? IS NOT NULL THEN ? ELSE interest _accrued _month END ,
updated _at = datetime ( 'now' )
WHERE id = ?
` ).run(reapplied, interestMonth, interestMonth, payment.bill_id);
2026-05-14 02:11:54 -05:00
}
}
2026-05-31 15:06:10 -05:00
db . prepare ( 'UPDATE payments SET deleted_at = NULL WHERE id = ? AND bill_id IN (SELECT id FROM bills WHERE user_id = ? AND deleted_at IS NULL)' ) . run ( req . params . id , req . user . id ) ;
2026-06-11 20:12:31 -05:00
res . json ( serializePayment ( db . prepare ( ` SELECT p.* FROM payments p JOIN bills b ON b.id = p.bill_id WHERE p.id = ? AND p. ${ SQL _NOT _DELETED } AND b.user_id = ? AND b.deleted_at IS NULL ` ) . get ( req . params . id , req . user . id ) ) ) ;
2026-05-03 19:51:57 -05:00
} ) ;
2026-06-04 00:06:16 -05:00
// PATCH /api/payments/:id/attribute-to-month
// Changes only the paid_date of a provider_sync payment to move it into the
// correct billing period when it posted just after month end.
// Does not touch the amount or balance_delta.
router . patch ( '/:id/attribute-to-month' , ( req , res ) => {
const db = getDb ( ) ;
const paymentId = parseInt ( req . params . id , 10 ) ;
if ( ! Number . isInteger ( paymentId ) || paymentId < 1 ) {
return res . status ( 400 ) . json ( standardizeError ( 'Invalid payment id' , 'VALIDATION_ERROR' ) ) ;
}
const { paid _date } = req . body ;
if ( ! paid _date || ! /^\d{4}-\d{2}-\d{2}$/ . test ( paid _date ) ) {
return res . status ( 400 ) . json ( standardizeError ( 'paid_date must be YYYY-MM-DD' , 'VALIDATION_ERROR' , 'paid_date' ) ) ;
}
// Validate it is a real calendar date
2026-06-10 19:42:51 -05:00
const newDate = new Date ( paid _date + 'T00:00:00Z' ) ;
2026-06-04 00:06:16 -05:00
if ( isNaN ( newDate . getTime ( ) ) || newDate . toISOString ( ) . slice ( 0 , 10 ) !== paid _date ) {
return res . status ( 400 ) . json ( standardizeError ( 'paid_date is not a valid calendar date' , 'VALIDATION_ERROR' , 'paid_date' ) ) ;
}
try {
const payment = db . prepare ( `
SELECT p . * FROM payments p
JOIN bills b ON b . id = p . bill _id
WHERE p . id = ? AND p . $ { SQL _NOT _DELETED } AND b . user _id = ? AND b . deleted _at IS NULL
` ).get(paymentId, req.user.id);
if ( ! payment ) return res . status ( 404 ) . json ( standardizeError ( 'Payment not found' , 'NOT_FOUND' ) ) ;
// Only allow date-only reclassification for provider_sync payments
if ( payment . payment _source !== 'provider_sync' && payment . payment _source !== 'auto_match' ) {
return res . status ( 409 ) . json ( standardizeError (
'Only bank-synced payments can be reclassified to a different month' ,
'RECLASSIFY_ONLY_SYNC' ,
) ) ;
}
// Sanity check: new date must be in the month immediately before the original date
const orig = new Date ( payment . paid _date + 'T00:00:00' ) ;
const origYM = orig . getFullYear ( ) * 12 + orig . getMonth ( ) ;
const newYM = newDate . getFullYear ( ) * 12 + newDate . getMonth ( ) ;
if ( newYM !== origYM - 1 ) {
return res . status ( 400 ) . json ( standardizeError (
'The new paid_date must be in the month immediately before the original payment date' ,
'VALIDATION_ERROR' , 'paid_date' ,
) ) ;
}
2026-06-07 01:05:48 -05:00
db . transaction ( ( ) => {
db . prepare ( "UPDATE payments SET paid_date = ?, updated_at = datetime('now') WHERE id = ?" )
. run ( paid _date , paymentId ) ;
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 ) markProvisionalManualPaymentsOverridden ( db , bill , { ... payment , paid _date } ) ;
} ) ( ) ;
2026-06-04 00:06:16 -05:00
2026-06-11 20:12:31 -05:00
res . json ( serializePayment ( db . prepare ( ` SELECT p.* FROM payments p JOIN bills b ON b.id = p.bill_id WHERE p.id = ? AND p. ${ SQL _NOT _DELETED } AND b.user_id = ? AND b.deleted_at IS NULL ` ) . get ( paymentId , req . user . id ) ) ) ;
2026-06-04 00:06:16 -05:00
} catch ( err ) {
console . error ( '[payments] attribute-to-month error:' , err . message ) ;
res . status ( 500 ) . json ( standardizeError ( 'Failed to reclassify payment date' , 'DB_ERROR' ) ) ;
}
} ) ;
2026-05-03 19:51:57 -05:00
module . exports = router ;