'use strict'; const { computeBalanceDelta, applyBalanceDelta } = require('./billsService'); const { getCycleRange } = require('./statusService'); const ACCOUNTING_ACTIVE_SQL = 'COALESCE(accounting_excluded, 0) = 0'; const BANK_PAYMENT_SOURCES = new Set(['provider_sync', 'transaction_match', 'auto_match']); const OVERRIDE_REASON = 'overridden_by_bank'; function accountingActiveSql(alias = null) { const prefix = alias ? `${alias}.` : ''; return `COALESCE(${prefix}accounting_excluded, 0) = 0`; } function isBankBackedPayment(payment = {}) { return BANK_PAYMENT_SOURCES.has(payment.payment_source) || payment.transaction_id != null; } function appendNote(existing, line) { const current = String(existing || '').trim(); if (current.includes(line)) return current || line; return current ? `${current}\n${line}`.slice(0, 500) : line.slice(0, 500); } function paymentMonth(paidDate) { const match = String(paidDate || '').match(/^(\d{4})-(\d{2})-\d{2}$/); if (!match) return null; return { year: Number(match[1]), month: Number(match[2]) }; } function cycleRangeForPayment(bill, paidDate) { const ym = paymentMonth(paidDate); if (!ym) return null; return getCycleRange(ym.year, ym.month, bill); } function reversePaymentBalance(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, Number(bill.current_balance) - Number(payment.balance_delta)); // cents, exact integer arithmetic 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 applyPaymentBalanceFromFreshBill(db, billId, amount) { const bill = db.prepare('SELECT * FROM bills WHERE id = ? AND deleted_at IS NULL').get(billId); if (!bill) return { balance_delta: null, interest_delta: null }; const balCalc = computeBalanceDelta(bill, amount); applyBalanceDelta(db, billId, balCalc); return { balance_delta: balCalc?.balance_delta ?? null, interest_delta: balCalc?.interest_delta ?? null, }; } function markProvisionalManualPaymentsOverridden(db, bill, bankPayment) { if (!bill || !bankPayment || !isBankBackedPayment(bankPayment)) return { overridden: 0 }; const range = cycleRangeForPayment(bill, bankPayment.paid_date); if (!range) return { overridden: 0 }; const provisionalPayments = db.prepare(` SELECT * FROM payments WHERE bill_id = ? AND id != ? AND payment_source = 'manual' AND transaction_id IS NULL AND deleted_at IS NULL AND ${ACCOUNTING_ACTIVE_SQL} AND paid_date BETWEEN ? AND ? ORDER BY paid_date DESC, id DESC `).all(bill.id, bankPayment.id, range.start, range.end); const note = `History only: overridden by bank payment #${bankPayment.id} on ${bankPayment.paid_date}.`; const update = db.prepare(` UPDATE payments SET accounting_excluded = 1, exclusion_reason = ?, excluded_at = datetime('now'), overridden_by_payment_id = ?, notes = ?, balance_delta = NULL, interest_delta = NULL, updated_at = datetime('now') WHERE id = ? `); for (const payment of provisionalPayments) { reversePaymentBalance(db, payment); update.run(OVERRIDE_REASON, bankPayment.id, appendNote(payment.notes, note), payment.id); } return { overridden: provisionalPayments.length }; } function reactivatePaymentsOverriddenBy(db, bankPaymentId) { const rows = db.prepare(` SELECT * FROM payments WHERE overridden_by_payment_id = ? AND accounting_excluded = 1 AND deleted_at IS NULL `).all(bankPaymentId); const update = db.prepare(` UPDATE payments SET accounting_excluded = 0, exclusion_reason = NULL, excluded_at = NULL, overridden_by_payment_id = NULL, balance_delta = ?, interest_delta = ?, notes = ?, updated_at = datetime('now') WHERE id = ? `); for (const payment of rows) { const deltas = applyPaymentBalanceFromFreshBill(db, payment.bill_id, payment.amount); update.run( deltas.balance_delta, deltas.interest_delta, appendNote(payment.notes, 'Bank override removed; this manual payment counts again.'), payment.id, ); } return { reactivated: rows.length }; } function applyBankPaymentAsSourceOfTruth(db, bill, bankPayment) { markProvisionalManualPaymentsOverridden(db, bill, bankPayment); const deltas = applyPaymentBalanceFromFreshBill(db, bill.id, bankPayment.amount); db.prepare(` UPDATE payments SET balance_delta = ?, interest_delta = ?, updated_at = datetime('now') WHERE id = ? `).run(deltas.balance_delta, deltas.interest_delta, bankPayment.id); return deltas; } module.exports = { ACCOUNTING_ACTIVE_SQL, OVERRIDE_REASON, accountingActiveSql, isBankBackedPayment, markProvisionalManualPaymentsOverridden, reactivatePaymentsOverriddenBy, applyBankPaymentAsSourceOfTruth, };