BillTracker/services/transactionMatchService.cts

325 lines
11 KiB
TypeScript
Raw Normal View History

'use strict';
import type { Db } from '../types/db';
const { getDb } = require('../db/database');
const { computeBalanceDelta, applyBalanceDelta } = require('./billsService.cts');
const {
applyBankPaymentAsSourceOfTruth,
reactivatePaymentsOverriddenBy,
} = require('./paymentAccountingService.cts');
const {
decorateTransaction,
getTransactionForUser,
} = require('./transactionService.cts');
const { serializePayment } = require('./paymentValidation.cts');
const { markMatched, markUnmatched, markIgnored } = require('./transactionMatchState.cts');
type Row = Record<string, any>;
const MATCH_PAYMENT_SOURCE = 'transaction_match';
const MATCH_PAYMENT_METHOD = 'transaction_match';
function matchError(status: number, message: string, code: string, field: string | null = null): Error {
const err = new Error(message) as any;
err.status = status;
err.code = code;
err.field = field;
return err;
}
function normalizeId(value: unknown, field: string): number {
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: Db, userId: number, transactionId: unknown): Row {
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: Db, userId: number, billId: unknown): Row {
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: Row): string {
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: Row): number {
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)); // tx.amount and payments.amount are both cents
}
function getActivePaymentForTransaction(db: Db, userId: number, transactionId: number): Row | undefined {
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: Db, userId: number, paymentId: number | bigint | null): Row | null {
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: Db, payment: Row): void {
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, Number(bill.current_balance) - Number(payment.balance_delta)); // cents, exact integer arithmetic
// 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: Db, bill: Row, amount: number): { balance_delta: any; interest_delta: any } {
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: Db, paymentId: number | bigint, deltas: { balance_delta: any; interest_delta: any }): void {
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: Row, bill: Row): string {
const label = transaction.payee || transaction.description || `transaction ${transaction.id}`;
return `Matched transaction to ${bill.name}: ${label}`.slice(0, 500);
}
function createOrUpdateMatchPayment(db: Db, userId: number, transaction: Row, bill: Row): number | bigint {
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: Db, userId: number, transactionId: number): Row | null {
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 { ...serializePayment(existingPayment), deleted: true };
}
db.prepare(`
UPDATE payments
SET transaction_id = NULL, updated_at = datetime('now')
WHERE id = ?
`).run(existingPayment.id);
return { ...serializePayment(existingPayment), unlinked: true };
}
function responseForTransaction(db: Db, userId: number, transactionId: number, paymentId: number | bigint | null = null, extra: Row = {}) {
return {
success: true,
transaction: decorateTransaction(getTransactionForUser(db, userId, transactionId)),
payment: serializePayment(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: number, transactionId: unknown, billId: unknown, opts: { learnMerchant?: boolean } = {}) {
const db: 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);
markMatched(db, userId, transaction.id, bill.id, { resetIgnored: true });
if (opts.learnMerchant) {
const { learnMerchantRuleFromMatch } = require('./billMerchantRuleService.cts');
learnMerchantRuleFromMatch(db, userId, bill.id, transaction);
}
return responseForTransaction(db, userId, transaction.id, paymentId);
});
return tx();
}
function unmatchTransaction(userId: number, transactionId: unknown) {
const db: Db = getDb();
const tx = db.transaction(() => {
const transaction = getOwnedTransaction(db, userId, transactionId);
const removedPayment = unlinkPaymentForTransaction(db, userId, transaction.id);
markUnmatched(db, userId, transaction.id, { resetIgnored: true });
return responseForTransaction(db, userId, transaction.id, null, { removed_payment: removedPayment });
});
return tx();
}
function ignoreTransaction(userId: number, transactionId: unknown) {
const db: Db = getDb();
const tx = db.transaction(() => {
const transaction = getOwnedTransaction(db, userId, transactionId);
const removedPayment = unlinkPaymentForTransaction(db, userId, transaction.id);
markIgnored(db, userId, transaction.id);
return responseForTransaction(db, userId, transaction.id, null, { removed_payment: removedPayment });
});
return tx();
}
function unignoreTransaction(userId: number, transactionId: unknown) {
const db: Db = getDb();
const tx = db.transaction(() => {
const transaction = getOwnedTransaction(db, userId, transactionId);
markUnmatched(db, userId, transaction.id, { resetIgnored: true });
return responseForTransaction(db, userId, transaction.id);
});
return tx();
}
module.exports = {
MATCH_PAYMENT_METHOD,
MATCH_PAYMENT_SOURCE,
ignoreTransaction,
matchTransactionToBill,
unignoreTransaction,
unmatchTransaction,
};