'use strict'; import type { Db } from '../types/db'; const { computeBalanceDelta, applyBalanceDelta } = require('./billsService.cts'); const { getCycleRange } = require('./statusService.cts'); // Dynamic better-sqlite3 rows / payloads — shape varies by query, callers read // individual columns. Typed loosely on purpose (incremental migration). type Row = Record; 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: string | null = null): string { const prefix = alias ? `${alias}.` : ''; return `COALESCE(${prefix}accounting_excluded, 0) = 0`; } function isBankBackedPayment(payment: Row = {}): boolean { return BANK_PAYMENT_SOURCES.has(payment.payment_source) || payment.transaction_id != null; } function appendNote(existing: unknown, line: string): string { 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: unknown): { year: number; month: number } | null { 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: Row, paidDate: unknown): any { const ym = paymentMonth(paidDate); if (!ym) return null; return getCycleRange(ym.year, ym.month, bill); } function reversePaymentBalance(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 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: Db, billId: number, amount: any, ): { balance_delta: any; interest_delta: any } { 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: Db, bill: Row, bankPayment: Row, ): { overridden: number } { 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: Db, bankPaymentId: number): { reactivated: number } { 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: Db, bill: Row, bankPayment: Row, ): { balance_delta: any; interest_delta: any } { 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, };