90 lines
4.7 KiB
JavaScript
90 lines
4.7 KiB
JavaScript
|
|
'use strict';
|
||
|
|
|
||
|
|
// X2 — the payment-accounting source-of-truth layer (manual vs. bank) had no
|
||
|
|
// dedicated tests. Covers the bank-backed predicate, the accounting-active SQL
|
||
|
|
// fragment, and the core money-integrity invariant: when a bank payment lands it
|
||
|
|
// overrides a provisional manual payment (so it isn't double-counted) and the
|
||
|
|
// manual one counts again — with the balance restored/re-applied — if the bank
|
||
|
|
// override is later removed.
|
||
|
|
const test = require('node:test');
|
||
|
|
const assert = require('node:assert/strict');
|
||
|
|
const os = require('node:os');
|
||
|
|
const path = require('node:path');
|
||
|
|
const fs = require('node:fs');
|
||
|
|
|
||
|
|
const dbPath = path.join(os.tmpdir(), `bill-tracker-payacct-${process.pid}.sqlite`);
|
||
|
|
process.env.DB_PATH = dbPath;
|
||
|
|
|
||
|
|
const { getDb, closeDb } = require('../db/database');
|
||
|
|
const {
|
||
|
|
isBankBackedPayment,
|
||
|
|
accountingActiveSql,
|
||
|
|
markProvisionalManualPaymentsOverridden,
|
||
|
|
reactivatePaymentsOverriddenBy,
|
||
|
|
} = require('../services/paymentAccountingService');
|
||
|
|
|
||
|
|
test('isBankBackedPayment: bank sources or a transaction_id count as bank-backed', () => {
|
||
|
|
assert.equal(isBankBackedPayment({ payment_source: 'provider_sync' }), true);
|
||
|
|
assert.equal(isBankBackedPayment({ payment_source: 'transaction_match' }), true);
|
||
|
|
assert.equal(isBankBackedPayment({ payment_source: 'auto_match' }), true);
|
||
|
|
assert.equal(isBankBackedPayment({ payment_source: 'manual', transaction_id: 42 }), true);
|
||
|
|
assert.equal(isBankBackedPayment({ payment_source: 'manual' }), false);
|
||
|
|
assert.equal(isBankBackedPayment({}), false);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('accountingActiveSql fragment, with and without a table alias', () => {
|
||
|
|
assert.equal(accountingActiveSql(), 'COALESCE(accounting_excluded, 0) = 0');
|
||
|
|
assert.equal(accountingActiveSql('p'), 'COALESCE(p.accounting_excluded, 0) = 0');
|
||
|
|
});
|
||
|
|
|
||
|
|
test('bank payment overrides a provisional manual payment, then reactivates on removal', () => {
|
||
|
|
const db = getDb();
|
||
|
|
const userId = db.prepare("INSERT INTO users (username, password_hash, role, active) VALUES ('pa-user','x','user',1)").run().lastInsertRowid;
|
||
|
|
// $500 balance; a manual $100 payment already dropped it to $400.
|
||
|
|
const billId = db.prepare(
|
||
|
|
"INSERT INTO bills (user_id, name, due_day, billing_cycle, cycle_type, expected_amount, current_balance, interest_rate, active) VALUES (?, 'Card', 10, 'monthly', 'monthly', 10000, 40000, 0, 1)",
|
||
|
|
).run(userId).lastInsertRowid;
|
||
|
|
const bill = db.prepare('SELECT * FROM bills WHERE id = ?').get(billId);
|
||
|
|
|
||
|
|
const manualId = db.prepare(
|
||
|
|
"INSERT INTO payments (bill_id, amount, paid_date, method, payment_source, balance_delta) VALUES (?, 10000, '2026-06-10', 'manual', 'manual', -10000)",
|
||
|
|
).run(billId).lastInsertRowid;
|
||
|
|
// Bank payment for the same bill/cycle (a matched SimpleFIN transaction).
|
||
|
|
const bankId = db.prepare(
|
||
|
|
"INSERT INTO payments (bill_id, amount, paid_date, method, payment_source, transaction_id) VALUES (?, 10000, '2026-06-12', 'bank', 'transaction_match', 777)",
|
||
|
|
).run(billId).lastInsertRowid;
|
||
|
|
const bankPayment = db.prepare('SELECT * FROM payments WHERE id = ?').get(bankId);
|
||
|
|
|
||
|
|
const activeCount = () => db.prepare(
|
||
|
|
`SELECT COUNT(*) c FROM payments WHERE bill_id = ? AND deleted_at IS NULL AND ${accountingActiveSql()}`,
|
||
|
|
).get(billId).c;
|
||
|
|
|
||
|
|
// Both currently count — the double-count we must resolve.
|
||
|
|
assert.equal(activeCount(), 2);
|
||
|
|
|
||
|
|
const { overridden } = markProvisionalManualPaymentsOverridden(db, bill, bankPayment);
|
||
|
|
assert.equal(overridden, 1, 'the one provisional manual payment is overridden');
|
||
|
|
|
||
|
|
const manualAfter = db.prepare('SELECT * FROM payments WHERE id = ?').get(manualId);
|
||
|
|
assert.equal(manualAfter.accounting_excluded, 1);
|
||
|
|
assert.equal(manualAfter.overridden_by_payment_id, bankId);
|
||
|
|
assert.equal(manualAfter.balance_delta, null, 'overridden payment no longer holds a balance delta');
|
||
|
|
assert.equal(activeCount(), 1, 'only the bank payment counts now — no double count');
|
||
|
|
// Its balance effect was reversed: $400 → $500.
|
||
|
|
assert.equal(db.prepare('SELECT current_balance FROM bills WHERE id = ?').get(billId).current_balance, 50000);
|
||
|
|
|
||
|
|
// Bank override removed → the manual payment counts again and re-applies its balance.
|
||
|
|
const { reactivated } = reactivatePaymentsOverriddenBy(db, bankId);
|
||
|
|
assert.equal(reactivated, 1);
|
||
|
|
const manualReactivated = db.prepare('SELECT * FROM payments WHERE id = ?').get(manualId);
|
||
|
|
assert.equal(manualReactivated.accounting_excluded, 0);
|
||
|
|
assert.equal(manualReactivated.overridden_by_payment_id, null);
|
||
|
|
assert.equal(manualReactivated.balance_delta, -10000, 'balance delta re-applied');
|
||
|
|
assert.equal(db.prepare('SELECT current_balance FROM bills WHERE id = ?').get(billId).current_balance, 40000, '$500 → $400 again');
|
||
|
|
});
|
||
|
|
|
||
|
|
test.after(() => {
|
||
|
|
closeDb();
|
||
|
|
for (const s of ['', '-wal', '-shm']) { try { fs.rmSync(dbPath + s); } catch {} }
|
||
|
|
});
|