BillTracker/tests/transactionMatchService.tes...

592 lines
23 KiB
JavaScript

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, 8500)
`).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 ?? 8500,
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, 8500);
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');
});
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);
});
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: 6500,
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);
});
test('bank-backed match overrides same-cycle manual tracker payment but keeps it in history', async () => {
const db = getDb();
const userId = createUser(db, 'bank-override');
const billId = createBill(db, userId, 'Internet Override');
const manualPaymentId = createManualPayment(db, billId, {
amount: 8500,
notes: 'Marked paid while waiting for bank clear',
});
const transactionId = createTransaction(db, userId, {
amount: -9000,
description: 'Internet Override',
payee: 'Internet Override',
});
const beforeMatch = trackerRow(userId, billId);
assert.equal(beforeMatch.total_paid, 85);
assert.equal(beforeMatch.status, 'paid');
const matched = matchTransactionToBill(userId, transactionId, billId);
const afterMatch = trackerRow(userId, billId);
assert.equal(afterMatch.total_paid, 90);
assert.equal(afterMatch.payments.length, 1);
assert.equal(afterMatch.payments[0].id, matched.payment.id);
assert.equal(afterMatch.payments.some(payment => payment.id === manualPaymentId), false);
const manual = db.prepare('SELECT * FROM payments WHERE id = ?').get(manualPaymentId);
assert.equal(manual.accounting_excluded, 1);
assert.equal(manual.exclusion_reason, 'overridden_by_bank');
assert.equal(manual.overridden_by_payment_id, matched.payment.id);
assert.match(manual.notes, /History only: overridden by bank payment/);
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.accounting_excluded === 1), true);
assert.equal(paymentsRes.data.payments.some(payment => payment.id === matched.payment.id && payment.accounting_excluded === 0), true);
});
test('unmatching a bank-backed payment reactivates the provisional manual payment', () => {
const db = getDb();
const userId = createUser(db, 'bank-override-unmatch');
const billId = createBill(db, userId, 'Internet Unmatch');
const manualPaymentId = createManualPayment(db, billId);
const transactionId = createTransaction(db, userId, {
description: 'Internet Unmatch',
payee: 'Internet Unmatch',
});
matchTransactionToBill(userId, transactionId, billId);
assert.equal(db.prepare('SELECT accounting_excluded FROM payments WHERE id = ?').get(manualPaymentId).accounting_excluded, 1);
unmatchTransaction(userId, transactionId);
const manual = db.prepare('SELECT accounting_excluded, overridden_by_payment_id, exclusion_reason, notes FROM payments WHERE id = ?').get(manualPaymentId);
assert.equal(manual.accounting_excluded, 0);
assert.equal(manual.overridden_by_payment_id, null);
assert.equal(manual.exclusion_reason, null);
assert.match(manual.notes, /manual payment counts again/i);
const row = trackerRow(userId, billId);
assert.equal(row.status, 'paid');
assert.equal(row.total_paid, 85);
assert.equal(row.payments.some(payment => payment.id === manualPaymentId), true);
});
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);
});
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);
});