BillTracker/services/paymentAccountingService.js

160 lines
5.2 KiB
JavaScript
Raw Normal View History

'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, Math.round((Number(bill.current_balance) - Number(payment.balance_delta)) * 100) / 100);
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,
};