BillTracker/tests/transactionMatchService.tes...

444 lines
16 KiB
JavaScript
Raw Normal View History

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