BillTracker/services/transactionMatchService.js

348 lines
11 KiB
JavaScript

'use strict';
const { getDb } = require('../db/database');
const { computeBalanceDelta, applyBalanceDelta } = require('./billsService');
const {
applyBankPaymentAsSourceOfTruth,
reactivatePaymentsOverriddenBy,
} = require('./paymentAccountingService');
const {
decorateTransaction,
getTransactionForUser,
} = require('./transactionService');
const { roundMoney } = require('../utils/money');
const MATCH_PAYMENT_SOURCE = 'transaction_match';
const MATCH_PAYMENT_METHOD = 'transaction_match';
function matchError(status, message, code, field = null) {
const err = new Error(message);
err.status = status;
err.code = code;
err.field = field;
return err;
}
function normalizeId(value, field) {
const id = typeof value === 'number' ? value : Number(value);
if (!Number.isSafeInteger(id) || id <= 0) {
throw matchError(400, `${field} must be a positive integer`, 'VALIDATION_ERROR', field);
}
return id;
}
function getOwnedTransaction(db, userId, transactionId) {
const id = normalizeId(transactionId, 'transaction_id');
const transaction = getTransactionForUser(db, userId, id);
if (!transaction) {
throw matchError(404, 'Transaction not found', 'NOT_FOUND', 'transaction_id');
}
return transaction;
}
function getOwnedBill(db, userId, billId) {
const id = normalizeId(billId, 'bill_id');
const bill = db.prepare(`
SELECT *
FROM bills
WHERE id = ? AND user_id = ? AND deleted_at IS NULL
`).get(id, userId);
if (!bill) {
throw matchError(404, 'Bill not found', 'NOT_FOUND', 'bill_id');
}
return bill;
}
function paymentDateForTransaction(transaction) {
const date = transaction.posted_date || String(transaction.transacted_at || '').slice(0, 10);
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
throw matchError(
400,
'Transaction must have a posted date before it can be matched to a bill',
'VALIDATION_ERROR',
'posted_date',
);
}
return date;
}
function paymentAmountForTransaction(transaction) {
const cents = Number(transaction.amount);
if (!Number.isSafeInteger(cents) || cents === 0) {
throw matchError(
400,
'Transaction amount must be a non-zero integer number of cents',
'VALIDATION_ERROR',
'amount',
);
}
return Math.round(Math.abs(cents)) / 100;
}
function getActivePaymentForTransaction(db, userId, transactionId) {
return db.prepare(`
SELECT p.*
FROM payments p
JOIN bills b ON b.id = p.bill_id
WHERE p.transaction_id = ?
AND p.deleted_at IS NULL
AND b.user_id = ?
AND b.deleted_at IS NULL
ORDER BY p.id ASC
LIMIT 1
`).get(transactionId, userId);
}
function getPaymentForResponse(db, userId, paymentId) {
if (!paymentId) return null;
return db.prepare(`
SELECT p.*
FROM payments p
JOIN bills b ON b.id = p.bill_id
WHERE p.id = ?
AND b.user_id = ?
`).get(paymentId, userId) || null;
}
function restorePaymentBalance(db, payment) {
if (!payment || payment.balance_delta == null) return;
const bill = db.prepare('SELECT id, current_balance FROM bills WHERE id = ?').get(payment.bill_id);
if (bill?.current_balance == null) return;
const restored = Math.max(0, roundMoney(Number(bill.current_balance) - Number(payment.balance_delta)));
// Clear interest_accrued_month when reversing a payment that charged interest,
// so the re-applied payment can accrue interest fresh.
db.prepare(`
UPDATE bills
SET current_balance = ?,
interest_accrued_month = CASE WHEN ? THEN NULL ELSE interest_accrued_month END,
updated_at = datetime('now')
WHERE id = ?
`).run(restored, payment.interest_delta != null ? 1 : 0, bill.id);
}
function applyPaymentBalance(db, bill, amount) {
const freshBill = db.prepare('SELECT * FROM bills WHERE id = ?').get(bill.id) || bill;
const balCalc = computeBalanceDelta(freshBill, amount);
applyBalanceDelta(db, bill.id, balCalc);
return { balance_delta: balCalc?.balance_delta ?? null, interest_delta: balCalc?.interest_delta ?? null };
}
function updatePaymentBalanceDeltas(db, paymentId, deltas) {
db.prepare(`
UPDATE payments
SET balance_delta = ?,
interest_delta = ?,
updated_at = datetime('now')
WHERE id = ?
`).run(deltas.balance_delta, deltas.interest_delta, paymentId);
}
function buildMatchPaymentNotes(transaction, bill) {
const label = transaction.payee || transaction.description || `transaction ${transaction.id}`;
return `Matched transaction to ${bill.name}: ${label}`.slice(0, 500);
}
function createOrUpdateMatchPayment(db, userId, transaction, bill) {
const amount = paymentAmountForTransaction(transaction);
const paidDate = paymentDateForTransaction(transaction);
const notes = buildMatchPaymentNotes(transaction, bill);
const existingPayment = getActivePaymentForTransaction(db, userId, transaction.id);
if (existingPayment && existingPayment.payment_source !== MATCH_PAYMENT_SOURCE) {
throw matchError(
409,
'Transaction is already linked to a non-matching payment. Unlink that payment before matching this transaction.',
'TRANSACTION_PAYMENT_ALREADY_LINKED',
'transaction_id',
);
}
if (existingPayment) {
restorePaymentBalance(db, existingPayment);
db.prepare(`
UPDATE payments
SET bill_id = ?,
amount = ?,
paid_date = ?,
method = ?,
notes = ?,
balance_delta = NULL,
interest_delta = NULL,
payment_source = ?,
updated_at = datetime('now')
WHERE id = ?
`).run(
bill.id,
amount,
paidDate,
MATCH_PAYMENT_METHOD,
notes,
MATCH_PAYMENT_SOURCE,
existingPayment.id,
);
const updatedPayment = db.prepare('SELECT * FROM payments WHERE id = ?').get(existingPayment.id);
const deltas = applyBankPaymentAsSourceOfTruth(db, bill, updatedPayment);
updatePaymentBalanceDeltas(db, existingPayment.id, deltas);
return existingPayment.id;
}
const result = db.prepare(`
INSERT INTO payments
(bill_id, amount, paid_date, method, notes, balance_delta, interest_delta, payment_source, transaction_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
bill.id,
amount,
paidDate,
MATCH_PAYMENT_METHOD,
notes,
null,
null,
MATCH_PAYMENT_SOURCE,
transaction.id,
);
const insertedPayment = db.prepare('SELECT * FROM payments WHERE id = ?').get(result.lastInsertRowid);
const deltas = applyBankPaymentAsSourceOfTruth(db, bill, insertedPayment);
updatePaymentBalanceDeltas(db, result.lastInsertRowid, deltas);
return result.lastInsertRowid;
}
function unlinkPaymentForTransaction(db, userId, transactionId) {
const existingPayment = getActivePaymentForTransaction(db, userId, transactionId);
if (!existingPayment) return null;
if (existingPayment.payment_source === MATCH_PAYMENT_SOURCE) {
restorePaymentBalance(db, existingPayment);
reactivatePaymentsOverriddenBy(db, existingPayment.id);
db.prepare(`
UPDATE payments
SET deleted_at = datetime('now'), updated_at = datetime('now')
WHERE id = ?
`).run(existingPayment.id);
return { ...existingPayment, deleted: true };
}
db.prepare(`
UPDATE payments
SET transaction_id = NULL, updated_at = datetime('now')
WHERE id = ?
`).run(existingPayment.id);
return { ...existingPayment, unlinked: true };
}
function responseForTransaction(db, userId, transactionId, paymentId = null, extra = {}) {
return {
success: true,
transaction: decorateTransaction(getTransactionForUser(db, userId, transactionId)),
payment: getPaymentForResponse(db, userId, paymentId),
...extra,
};
}
// opts.learnMerchant — when true (explicit user confirmation), remember a
// merchant→bill rule so future synced transactions from the same merchant
// auto-match. Left false for background auto-matching to avoid compounding
// a wrong auto-match into a permanent rule.
function matchTransactionToBill(userId, transactionId, billId, opts = {}) {
const db = getDb();
const tx = db.transaction(() => {
const transaction = getOwnedTransaction(db, userId, transactionId);
if (transaction.ignored || transaction.match_status === 'ignored') {
throw matchError(400, 'Ignored transactions must be unignored before matching', 'TRANSACTION_IGNORED', 'transaction_id');
}
const bill = getOwnedBill(db, userId, billId);
const paymentId = createOrUpdateMatchPayment(db, userId, transaction, bill);
db.prepare(`
UPDATE transactions
SET matched_bill_id = ?,
match_status = 'matched',
ignored = 0,
updated_at = datetime('now')
WHERE id = ? AND user_id = ?
`).run(bill.id, transaction.id, userId);
if (opts.learnMerchant) {
const { learnMerchantRuleFromMatch } = require('./billMerchantRuleService');
learnMerchantRuleFromMatch(db, userId, bill.id, transaction);
}
return responseForTransaction(db, userId, transaction.id, paymentId);
});
return tx();
}
function unmatchTransaction(userId, transactionId) {
const db = getDb();
const tx = db.transaction(() => {
const transaction = getOwnedTransaction(db, userId, transactionId);
const removedPayment = unlinkPaymentForTransaction(db, userId, transaction.id);
db.prepare(`
UPDATE transactions
SET matched_bill_id = NULL,
match_status = 'unmatched',
ignored = 0,
updated_at = datetime('now')
WHERE id = ? AND user_id = ?
`).run(transaction.id, userId);
return responseForTransaction(db, userId, transaction.id, null, { removed_payment: removedPayment });
});
return tx();
}
function ignoreTransaction(userId, transactionId) {
const db = getDb();
const tx = db.transaction(() => {
const transaction = getOwnedTransaction(db, userId, transactionId);
const removedPayment = unlinkPaymentForTransaction(db, userId, transaction.id);
db.prepare(`
UPDATE transactions
SET ignored = 1,
match_status = 'ignored',
matched_bill_id = NULL,
updated_at = datetime('now')
WHERE id = ? AND user_id = ?
`).run(transaction.id, userId);
return responseForTransaction(db, userId, transaction.id, null, { removed_payment: removedPayment });
});
return tx();
}
function unignoreTransaction(userId, transactionId) {
const db = getDb();
const tx = db.transaction(() => {
const transaction = getOwnedTransaction(db, userId, transactionId);
db.prepare(`
UPDATE transactions
SET ignored = 0,
match_status = 'unmatched',
matched_bill_id = NULL,
updated_at = datetime('now')
WHERE id = ? AND user_id = ?
`).run(transaction.id, userId);
return responseForTransaction(db, userId, transaction.id);
});
return tx();
}
module.exports = {
MATCH_PAYMENT_METHOD,
MATCH_PAYMENT_SOURCE,
ignoreTransaction,
matchTransactionToBill,
unignoreTransaction,
unmatchTransaction,
};