'use strict'; import type { Db } from '../types/db'; const { getDb } = require('../db/database.cts'); 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; 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, };