444 lines
16 KiB
JavaScript
444 lines
16 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, 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);
|
|
});
|