2026-05-16 21:36:04 -05:00
const test = require ( 'node:test' ) ;
const assert = require ( 'node:assert/strict' ) ;
const fs = require ( 'node:fs' ) ;
const os = require ( 'node:os' ) ;
const path = require ( 'node:path' ) ;
const dbPath = path . join ( os . tmpdir ( ) , ` bill-tracker-transaction-match-test- ${ process . pid } .sqlite ` ) ;
process . env . DB _PATH = dbPath ;
const { getDb , closeDb } = require ( '../db/database' ) ;
const { ensureManualDataSource } = require ( '../services/transactionService' ) ;
const { getTracker } = require ( '../services/trackerService' ) ;
const {
listMatchSuggestions ,
rejectMatchSuggestion ,
suggestionId ,
} = require ( '../services/matchSuggestionService' ) ;
const {
ignoreTransaction ,
matchTransactionToBill ,
unignoreTransaction ,
unmatchTransaction ,
} = require ( '../services/transactionMatchService' ) ;
function createUser ( db , suffix ) {
return db . prepare ( `
INSERT INTO users ( username , password _hash , role , active , email , created _at , updated _at )
VALUES ( ? , 'x' , 'user' , 1 , ? , datetime ( 'now' ) , datetime ( 'now' ) )
` ).run( ` match - user - $ { suffix } ` , ` match - user - $ { suffix } @ local ` ).lastInsertRowid;
}
function createBill ( db , userId , name = 'City Water' ) {
return db . prepare ( `
INSERT INTO bills ( user _id , name , due _day , expected _amount )
VALUES ( ? , ? , 16 , 85 )
` ).run(userId, name).lastInsertRowid;
}
function createTransaction ( db , userId , overrides = { } ) {
const source = ensureManualDataSource ( db , userId ) ;
return db . prepare ( `
INSERT INTO transactions
( user _id , data _source _id , source _type , posted _date , amount , currency ,
description , payee , match _status , ignored )
VALUES ( ? , ? , 'manual' , ? , ? , 'USD' , ? , ? , 'unmatched' , 0 )
` ).run(
userId ,
source . id ,
overrides . posted _date || '2026-05-16' ,
overrides . amount ? ? - 8500 ,
overrides . description || 'Water bill payment' ,
overrides . payee || 'City Water' ,
) . lastInsertRowid ;
}
function activePaymentsForTransaction ( db , transactionId ) {
return db . prepare ( `
SELECT *
FROM payments
WHERE transaction _id = ? AND deleted _at IS NULL
ORDER BY id
` ).all(transactionId);
}
function createManualPayment ( db , billId , overrides = { } ) {
return db . prepare ( `
INSERT INTO payments ( bill _id , amount , paid _date , method , payment _source , notes )
VALUES ( ? , ? , ? , ? , ? , ? )
` ).run(
billId ,
overrides . amount ? ? 85 ,
overrides . paid _date || '2026-05-16' ,
overrides . method || 'manual' ,
overrides . payment _source || 'manual' ,
overrides . notes || 'Manual payment' ,
) . lastInsertRowid ;
}
function trackerRow ( userId , billId , today = '2026-05-20' ) {
const tracker = getTracker ( userId , { year : 2026 , month : 5 } , new Date ( ` ${ today } T12:00:00Z ` ) ) ;
assert . equal ( tracker . error , undefined ) ;
const row = tracker . rows . find ( item => item . id === billId ) ;
assert . ok ( row , 'tracker row should exist' ) ;
return row ;
}
function callBillsRoute ( routePath , { userId , params = { } , query = { } } ) {
const billsRouter = require ( '../routes/bills' ) ;
const layer = billsRouter . stack . find ( item => item . route ? . path === routePath && item . route . methods . get ) ;
assert . ok ( layer , ` route ${ routePath } should exist ` ) ;
const handler = layer . route . stack [ 0 ] . handle ;
return new Promise ( ( resolve , reject ) => {
const req = {
params ,
query ,
user : { id : userId , role : 'user' } ,
} ;
const res = {
statusCode : 200 ,
status ( code ) {
this . statusCode = code ;
return this ;
} ,
json ( data ) {
resolve ( { status : this . statusCode , data } ) ;
} ,
} ;
try {
handler ( req , res ) ;
} catch ( err ) {
reject ( err ) ;
}
} ) ;
}
function callPaymentsRoute ( routePath , method , { userId , params = { } , query = { } , body = { } } ) {
const paymentsRouter = require ( '../routes/payments' ) ;
const layer = paymentsRouter . stack . find ( item => item . route ? . path === routePath && item . route . methods [ method ] ) ;
assert . ok ( layer , ` route ${ method . toUpperCase ( ) } ${ routePath } should exist ` ) ;
const handler = layer . route . stack [ 0 ] . handle ;
return new Promise ( ( resolve , reject ) => {
const req = {
body ,
params ,
query ,
user : { id : userId , role : 'user' } ,
} ;
const res = {
statusCode : 200 ,
status ( code ) {
this . statusCode = code ;
return this ;
} ,
json ( data ) {
resolve ( { status : this . statusCode , data } ) ;
} ,
} ;
try {
handler ( req , res ) ;
} catch ( err ) {
reject ( err ) ;
}
} ) ;
}
test . after ( ( ) => {
closeDb ( ) ;
for ( const suffix of [ '' , '-wal' , '-shm' ] ) {
fs . rmSync ( ` ${ dbPath } ${ suffix } ` , { force : true } ) ;
}
} ) ;
test ( 'matching a transaction creates one active transaction_match payment and unmatch removes it' , ( ) => {
const db = getDb ( ) ;
const userId = createUser ( db , 'basic' ) ;
const billId = createBill ( db , userId ) ;
const transactionId = createTransaction ( db , userId ) ;
const matched = matchTransactionToBill ( userId , transactionId , billId ) ;
assert . equal ( matched . transaction . id , transactionId ) ;
assert . equal ( matched . transaction . matched _bill _id , billId ) ;
assert . equal ( matched . transaction . match _status , 'matched' ) ;
assert . equal ( matched . transaction . ignored , 0 ) ;
assert . equal ( matched . payment . bill _id , billId ) ;
assert . equal ( matched . payment . amount , 85 ) ;
assert . equal ( matched . payment . paid _date , '2026-05-16' ) ;
assert . equal ( matched . payment . method , 'transaction_match' ) ;
assert . equal ( matched . payment . payment _source , 'transaction_match' ) ;
assert . equal ( matched . payment . transaction _id , transactionId ) ;
const matchedAgain = matchTransactionToBill ( userId , transactionId , billId ) ;
assert . equal ( matchedAgain . payment . id , matched . payment . id ) ;
assert . equal ( activePaymentsForTransaction ( db , transactionId ) . length , 1 ) ;
const unmatched = unmatchTransaction ( userId , transactionId ) ;
assert . equal ( unmatched . transaction . matched _bill _id , null ) ;
assert . equal ( unmatched . transaction . match _status , 'unmatched' ) ;
assert . equal ( unmatched . transaction . ignored , 0 ) ;
assert . equal ( activePaymentsForTransaction ( db , transactionId ) . length , 0 ) ;
const deletedPayment = db . prepare ( 'SELECT deleted_at FROM payments WHERE id = ?' ) . get ( matched . payment . id ) ;
assert . ok ( deletedPayment . deleted _at ) ;
} ) ;
test ( 'ignoring a matched transaction removes the match payment and blocks rematching until unignored' , ( ) => {
const db = getDb ( ) ;
const userId = createUser ( db , 'ignore' ) ;
const billId = createBill ( db , userId , 'Internet' ) ;
const transactionId = createTransaction ( db , userId , {
description : 'Internet payment' ,
payee : 'Fiber Co' ,
amount : - 6500 ,
} ) ;
matchTransactionToBill ( userId , transactionId , billId ) ;
const ignored = ignoreTransaction ( userId , transactionId ) ;
assert . equal ( ignored . transaction . match _status , 'ignored' ) ;
assert . equal ( ignored . transaction . ignored , 1 ) ;
assert . equal ( ignored . transaction . matched _bill _id , null ) ;
assert . equal ( activePaymentsForTransaction ( db , transactionId ) . length , 0 ) ;
assert . throws (
( ) => matchTransactionToBill ( userId , transactionId , billId ) ,
/Ignored transactions must be unignored before matching/ ,
) ;
const unignored = unignoreTransaction ( userId , transactionId ) ;
assert . equal ( unignored . transaction . match _status , 'unmatched' ) ;
assert . equal ( unignored . transaction . ignored , 0 ) ;
const rematched = matchTransactionToBill ( userId , transactionId , billId ) ;
assert . equal ( rematched . transaction . match _status , 'matched' ) ;
assert . equal ( activePaymentsForTransaction ( db , transactionId ) . length , 1 ) ;
} ) ;
test ( 'transaction match payments cannot be edited, deleted, or restored through payment routes' , async ( ) => {
const db = getDb ( ) ;
const userId = createUser ( db , 'payment-route-lock' ) ;
const billId = createBill ( db , userId , 'Water' ) ;
const transactionId = createTransaction ( db , userId ) ;
const matched = matchTransactionToBill ( userId , transactionId , billId ) ;
const updateRes = await callPaymentsRoute ( '/:id' , 'put' , {
userId ,
params : { id : String ( matched . payment . id ) } ,
body : {
amount : 1 ,
paid _date : '2026-05-17' ,
method : 'manual' ,
payment _source : 'manual' ,
} ,
} ) ;
assert . equal ( updateRes . status , 409 ) ;
let payment = db . prepare ( 'SELECT amount, paid_date, method, payment_source, transaction_id, deleted_at FROM payments WHERE id = ?' ) . get ( matched . payment . id ) ;
assert . equal ( payment . amount , 85 ) ;
assert . equal ( payment . paid _date , '2026-05-16' ) ;
assert . equal ( payment . method , 'transaction_match' ) ;
assert . equal ( payment . payment _source , 'transaction_match' ) ;
assert . equal ( payment . transaction _id , transactionId ) ;
assert . equal ( payment . deleted _at , null ) ;
const deleteRes = await callPaymentsRoute ( '/:id' , 'delete' , {
userId ,
params : { id : String ( matched . payment . id ) } ,
} ) ;
assert . equal ( deleteRes . status , 409 ) ;
assert . equal ( activePaymentsForTransaction ( db , transactionId ) . length , 1 ) ;
assert . equal ( db . prepare ( 'SELECT match_status FROM transactions WHERE id = ?' ) . get ( transactionId ) . match _status , 'matched' ) ;
unmatchTransaction ( userId , transactionId ) ;
payment = db . prepare ( 'SELECT deleted_at FROM payments WHERE id = ?' ) . get ( matched . payment . id ) ;
assert . ok ( payment . deleted _at ) ;
const restoreRes = await callPaymentsRoute ( '/:id/restore' , 'post' , {
userId ,
params : { id : String ( matched . payment . id ) } ,
} ) ;
assert . equal ( restoreRes . status , 409 ) ;
assert . equal ( activePaymentsForTransaction ( db , transactionId ) . length , 0 ) ;
assert . equal ( db . prepare ( 'SELECT match_status, matched_bill_id FROM transactions WHERE id = ?' ) . get ( transactionId ) . match _status , 'unmatched' ) ;
} ) ;
2026-05-16 21:41:13 -05:00
test ( 'generic payment routes cannot create transaction_match payments' , async ( ) => {
const db = getDb ( ) ;
const userId = createUser ( db , 'payment-source-lock' ) ;
const billId = createBill ( db , userId , 'Water' ) ;
const createRes = await callPaymentsRoute ( '/' , 'post' , {
userId ,
body : {
bill _id : billId ,
amount : 85 ,
paid _date : '2026-05-16' ,
payment _source : 'transaction_match' ,
} ,
} ) ;
assert . equal ( createRes . status , 400 ) ;
const quickRes = await callPaymentsRoute ( '/quick' , 'post' , {
userId ,
body : {
bill _id : billId ,
payment _source : 'transaction_match' ,
} ,
} ) ;
assert . equal ( quickRes . status , 400 ) ;
const bulkRes = await callPaymentsRoute ( '/bulk' , 'post' , {
userId ,
body : {
payments : [ {
bill _id : billId ,
amount : 85 ,
paid _date : '2026-05-16' ,
payment _source : 'transaction_match' ,
} ] ,
} ,
} ) ;
assert . equal ( bulkRes . status , 400 ) ;
assert . equal ( db . prepare ( 'SELECT COUNT(*) AS n FROM payments WHERE bill_id = ?' ) . get ( billId ) . n , 0 ) ;
} ) ;
2026-05-16 21:36:04 -05:00
test ( 'matching marks the tracker row paid and unmatching recalculates it as unpaid' , ( ) => {
const db = getDb ( ) ;
const userId = createUser ( db , 'tracker' ) ;
const billId = createBill ( db , userId , 'Electric' ) ;
const transactionId = createTransaction ( db , userId , {
description : 'Electric bill payment' ,
payee : 'Electric Utility' ,
} ) ;
assert . notEqual ( trackerRow ( userId , billId ) . status , 'paid' ) ;
matchTransactionToBill ( userId , transactionId , billId ) ;
const paidRow = trackerRow ( userId , billId ) ;
assert . equal ( paidRow . status , 'paid' ) ;
assert . equal ( paidRow . has _payment , true ) ;
assert . equal ( paidRow . payments [ 0 ] . transaction _id , transactionId ) ;
unmatchTransaction ( userId , transactionId ) ;
const unpaidRow = trackerRow ( userId , billId ) ;
assert . notEqual ( unpaidRow . status , 'paid' ) ;
assert . equal ( unpaidRow . has _payment , false ) ;
assert . equal ( unpaidRow . total _paid , 0 ) ;
} ) ;
test ( 'ignoring a transaction does not change bill status or manual payments' , ( ) => {
const db = getDb ( ) ;
const userId = createUser ( db , 'ignore-status' ) ;
const billId = createBill ( db , userId , 'Phone' ) ;
const transactionId = createTransaction ( db , userId , {
description : 'Phone store charge' ,
payee : 'Phone Store' ,
amount : - 8500 ,
} ) ;
const manualPaymentId = createManualPayment ( db , billId ) ;
const before = trackerRow ( userId , billId ) ;
assert . equal ( before . status , 'paid' ) ;
assert . equal ( before . payments . some ( payment => payment . id === manualPaymentId ) , true ) ;
ignoreTransaction ( userId , transactionId ) ;
const after = trackerRow ( userId , billId ) ;
assert . equal ( after . status , 'paid' ) ;
assert . equal ( after . total _paid , before . total _paid ) ;
assert . deepEqual ( activePaymentsForTransaction ( db , transactionId ) , [ ] ) ;
assert . equal ( after . payments . some ( payment => payment . id === manualPaymentId ) , true ) ;
} ) ;
test ( 'match suggestions are read-only and rejections do not touch payments or transactions' , ( ) => {
const db = getDb ( ) ;
const userId = createUser ( db , 'suggestions' ) ;
const billId = createBill ( db , userId ) ;
const transactionId = createTransaction ( db , userId ) ;
const beforeTransaction = db . prepare ( 'SELECT matched_bill_id, match_status, ignored FROM transactions WHERE id = ?' ) . get ( transactionId ) ;
const beforePaymentCount = db . prepare ( 'SELECT COUNT(*) AS n FROM payments WHERE bill_id = ? AND deleted_at IS NULL' ) . get ( billId ) . n ;
const suggestions = listMatchSuggestions ( userId , { transactionId } ) ;
const match = suggestions . find ( item => item . transactionId === transactionId && item . billId === billId ) ;
assert . ok ( match , 'expected a suggestion for the matching bill' ) ;
assert . equal ( match . score > 0 , true ) ;
assert . ok ( match . reasons . length > 0 ) ;
const afterSuggestTransaction = db . prepare ( 'SELECT matched_bill_id, match_status, ignored FROM transactions WHERE id = ?' ) . get ( transactionId ) ;
const afterSuggestPaymentCount = db . prepare ( 'SELECT COUNT(*) AS n FROM payments WHERE bill_id = ? AND deleted_at IS NULL' ) . get ( billId ) . n ;
assert . deepEqual ( afterSuggestTransaction , beforeTransaction ) ;
assert . equal ( afterSuggestPaymentCount , beforePaymentCount ) ;
const rejected = rejectMatchSuggestion ( userId , suggestionId ( transactionId , billId ) ) ;
assert . equal ( rejected . rejected , true ) ;
const afterRejectTransaction = db . prepare ( 'SELECT matched_bill_id, match_status, ignored FROM transactions WHERE id = ?' ) . get ( transactionId ) ;
const afterRejectPaymentCount = db . prepare ( 'SELECT COUNT(*) AS n FROM payments WHERE bill_id = ? AND deleted_at IS NULL' ) . get ( billId ) . n ;
assert . deepEqual ( afterRejectTransaction , beforeTransaction ) ;
assert . equal ( afterRejectPaymentCount , beforePaymentCount ) ;
assert . equal ( listMatchSuggestions ( userId , { transactionId } ) . some ( item => item . id === rejected . id ) , false ) ;
} ) ;
test ( 'manual payment history remains visible and suppresses duplicate suggestions for the same cycle' , async ( ) => {
const db = getDb ( ) ;
const userId = createUser ( db , 'manual-history' ) ;
const billId = createBill ( db , userId , 'Internet' ) ;
const manualPaymentId = createManualPayment ( db , billId , {
amount : 65 ,
notes : 'Paid from checking' ,
} ) ;
const transactionId = createTransaction ( db , userId , {
amount : - 6500 ,
description : 'Internet bill' ,
payee : 'Internet' ,
} ) ;
assert . equal (
listMatchSuggestions ( userId , { transactionId } ) . some ( item => item . billId === billId ) ,
false ,
) ;
const matched = matchTransactionToBill ( userId , transactionId , billId ) ;
const paymentsRes = await callBillsRoute ( '/:id/payments' , {
userId ,
params : { id : String ( billId ) } ,
query : { limit : '100' } ,
} ) ;
assert . equal ( paymentsRes . status , 200 ) ;
assert . equal ( paymentsRes . data . payments . some ( payment => payment . id === manualPaymentId && payment . payment _source === 'manual' ) , true ) ;
assert . equal ( paymentsRes . data . payments . some ( payment => payment . id === matched . payment . id && payment . payment _source === 'transaction_match' ) , true ) ;
const transactionsRes = await callBillsRoute ( '/:id/transactions' , {
userId ,
params : { id : String ( billId ) } ,
} ) ;
assert . equal ( transactionsRes . status , 200 ) ;
assert . equal ( transactionsRes . data . transactions . length , 1 ) ;
assert . equal ( transactionsRes . data . transactions [ 0 ] . id , transactionId ) ;
assert . equal ( transactionsRes . data . transactions [ 0 ] . linked _payment . id , matched . payment . id ) ;
} ) ;
2026-05-16 21:41:13 -05:00
2026-06-06 18:30:21 -05:00
test ( 'manual match learns a merchant rule; generic descriptors and background auto-match do not' , ( ) => {
const db = getDb ( ) ;
const userId = createUser ( db , 'learn' ) ;
const rulesFor = ( billId ) =>
db . prepare ( 'SELECT merchant FROM bill_merchant_rules WHERE user_id = ? AND bill_id = ? ORDER BY merchant' )
. all ( userId , billId ) . map ( r => r . merchant ) ;
// 1. Specific payee + explicit user confirmation → a normalized rule is learned.
const waterBill = createBill ( db , userId , 'Sparta Water' ) ;
const waterTx = createTransaction ( db , userId , {
payee : 'SPARTA WATER ASS UTILITIES' ,
description : 'SPARTA WATER ASS UTILITIES' ,
amount : - 8500 ,
} ) ;
matchTransactionToBill ( userId , waterTx , waterBill , { learnMerchant : true } ) ;
assert . deepEqual ( rulesFor ( waterBill ) , [ 'sparta water ass utilities' ] ,
'specific payee should be learned as a normalized merchant rule' ) ;
// 2. Generic-only descriptor → nothing is learned (would match too much).
const genBill = createBill ( db , userId , 'Some Transfer' ) ;
const genTx = createTransaction ( db , userId , {
payee : 'ACH Payment' ,
description : 'ACH PAYMENT' ,
amount : - 5000 ,
} ) ;
matchTransactionToBill ( userId , genTx , genBill , { learnMerchant : true } ) ;
assert . deepEqual ( rulesFor ( genBill ) , [ ] ,
'generic-only descriptor must not become an auto-match rule' ) ;
// 3. Background auto-match (no opts.learnMerchant) → never creates rules,
// so a wrong auto-match can't compound into a permanent rule.
const autoBill = createBill ( db , userId , 'Spotify' ) ;
const autoTx = createTransaction ( db , userId , {
payee : 'SPOTIFY USA' ,
description : 'SPOTIFY USA' ,
amount : - 1199 ,
} ) ;
matchTransactionToBill ( userId , autoTx , autoBill ) ;
assert . deepEqual ( rulesFor ( autoBill ) , [ ] ,
'background auto-match must not create merchant rules' ) ;
} ) ;
test ( 'applyMerchantRules skips ambiguous matches (rules for >1 bill) but still applies unambiguous ones' , ( ) => {
const { applyMerchantRules , addMerchantRule } = require ( '../services/billMerchantRuleService' ) ;
const db = getDb ( ) ;
const userId = createUser ( db , 'ambiguous' ) ;
// Two distinct bills both carry an "amazon" rule — a charge from AMAZON is ambiguous.
const billA = createBill ( db , userId , 'Amazon Card A' ) ;
const billB = createBill ( db , userId , 'Amazon Card B' ) ;
addMerchantRule ( db , userId , billA , 'amazon' ) ;
addMerchantRule ( db , userId , billB , 'amazon' ) ;
const ambiguousTx = createTransaction ( db , userId , {
payee : 'AMAZON' , description : 'AMAZON.COM' , amount : - 2500 ,
} ) ;
applyMerchantRules ( db , userId ) ;
const ambiguousRow = db . prepare ( 'SELECT match_status, matched_bill_id FROM transactions WHERE id = ?' ) . get ( ambiguousTx ) ;
assert . equal ( ambiguousRow . match _status , 'unmatched' , 'ambiguous match must be left for manual review' ) ;
assert . equal ( ambiguousRow . matched _bill _id , null ) ;
assert . equal (
db . prepare ( 'SELECT COUNT(*) AS n FROM payments WHERE transaction_id = ?' ) . get ( ambiguousTx ) . n ,
0 ,
'no payment should be created for an ambiguous match' ,
) ;
// A unique rule still auto-matches as before.
const spotifyBill = createBill ( db , userId , 'Spotify' ) ;
addMerchantRule ( db , userId , spotifyBill , 'spotify' ) ;
const spotifyTx = createTransaction ( db , userId , {
payee : 'SPOTIFY USA' , description : 'SPOTIFY USA' , amount : - 1199 ,
} ) ;
applyMerchantRules ( db , userId ) ;
const spotifyRow = db . prepare ( 'SELECT match_status, matched_bill_id FROM transactions WHERE id = ?' ) . get ( spotifyTx ) ;
assert . equal ( spotifyRow . match _status , 'matched' , 'unambiguous merchant rule should still auto-match' ) ;
assert . equal ( spotifyRow . matched _bill _id , spotifyBill ) ;
} ) ;
2026-05-16 21:41:13 -05:00
test ( 'bill linked transactions require an active linked payment' , async ( ) => {
const db = getDb ( ) ;
const userId = createUser ( db , 'orphan-link' ) ;
const billId = createBill ( db , userId , 'Orphaned' ) ;
const transactionId = createTransaction ( db , userId ) ;
db . prepare ( `
UPDATE transactions
SET matched _bill _id = ? , match _status = 'matched' , ignored = 0
WHERE id = ? AND user _id = ?
` ).run(billId, transactionId, userId);
const transactionsRes = await callBillsRoute ( '/:id/transactions' , {
userId ,
params : { id : String ( billId ) } ,
} ) ;
assert . equal ( transactionsRes . status , 200 ) ;
assert . equal ( transactionsRes . data . transactions . length , 0 ) ;
} ) ;