2026-06-07 01:05:48 -05:00
|
|
|
'use strict';
|
|
|
|
|
|
|
|
|
|
const { computeBalanceDelta, applyBalanceDelta } = require('./billsService');
|
|
|
|
|
const { getCycleRange } = require('./statusService');
|
2026-06-10 20:14:13 -05:00
|
|
|
const { roundMoney } = require('../utils/money');
|
2026-06-07 01:05:48 -05:00
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
|
2026-06-10 20:14:13 -05:00
|
|
|
const restored = Math.max(0, roundMoney(Number(bill.current_balance) - Number(payment.balance_delta)));
|
2026-06-07 01:05:48 -05:00
|
|
|
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,
|
|
|
|
|
};
|