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'); }); 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: 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); }); 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); });