'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, };