BillTracker/tests/trackerService.test.js

159 lines
8.0 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use strict';
// T1 — the Tracker's core aggregation (getTracker summary + bank tracking +
// getOverdueCount) had no dedicated tests despite being the densest money-math
// in the app. Covers the occurrence-gating fix (annual/off-month bills must not
// inflate the bank card's unpaid/remaining), summary totals, the bank-mode
// remaining agreement, cents↔dollars integrity, and overdue gating.
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-trackersvc-${process.pid}.sqlite`);
process.env.DB_PATH = dbPath;
const { getDb, closeDb } = require('../db/database');
const { getTracker, getOverdueCount } = require('../services/trackerService');
// Fixed "now" so occurrence gating + statuses are deterministic (mid-June 2026).
const JUNE_20 = new Date(2026, 5, 20, 12, 0, 0);
let db;
function mkUser(name) {
return db.prepare(
"INSERT INTO users (username, password_hash, role, active) VALUES (?, 'x', 'user', 1)",
).run(name).lastInsertRowid;
}
const insertBill = () => db.prepare(`
INSERT INTO bills (user_id, name, due_day, billing_cycle, cycle_type, cycle_day, expected_amount, active)
VALUES (?, ?, ?, ?, ?, ?, ?, 1)
`);
function enableBank(userId, balanceCents) {
const acctId = db.prepare(`
INSERT INTO financial_accounts (user_id, name, org_name, account_type, balance, available_balance, monitored)
VALUES (?, 'Checking', 'Test Bank', 'checking', ?, ?, 1)
`).run(userId, balanceCents, balanceCents).lastInsertRowid;
const setSetting = db.prepare('INSERT INTO user_settings (user_id, key, value) VALUES (?, ?, ?)');
setSetting.run(userId, 'bank_tracking_enabled', 'true');
setSetting.run(userId, 'bank_tracking_account_id', String(acctId));
// Pending window 0 so no recent-manual-payment guessing affects the balance.
setSetting.run(userId, 'bank_tracking_pending_days', '0');
}
test.before(() => { db = getDb(); });
test.after(() => {
closeDb();
for (const s of ['', '-wal', '-shm']) { try { fs.rmSync(dbPath + s); } catch {} }
});
test('bank mode: annual/off-month bill does NOT inflate unpaid_this_month or remaining (gating fix)', () => {
const userId = mkUser('t1-gating');
const ins = insertBill();
ins.run(userId, 'Monthly', 15, 'monthly', 'monthly', null, 10000); // $100 due every month
ins.run(userId, 'Annual (Jan)', 1, 'annually', 'annual', '1', 50000); // $500, not due in June
enableBank(userId, 200000); // $2,000 balance
const t = getTracker(userId, { year: 2026, month: 6 }, JUNE_20);
assert.equal(t.bank_tracking.enabled, true);
// Only the $100 monthly bill is due in June; the $500 annual bill is excluded.
assert.equal(t.bank_tracking.unpaid_this_month, 100, 'gated: $100, not $600');
assert.equal(t.bank_tracking.balance, 2000, 'balance in dollars (fromCents)');
assert.equal(t.bank_tracking.remaining, 1900, '2000 100 (not 2000 600)');
// Fix #2: the summary remaining figures agree with the bank card.
assert.equal(t.summary.remaining, 1900);
assert.equal(t.summary.total_remaining, 1900);
});
test('summary totals: gated expected, total_paid, paid_toward_due (cents→dollars)', () => {
const userId = mkUser('t1-summary');
const ins = insertBill();
const billA = ins.run(userId, 'A due 15', 15, 'monthly', 'monthly', null, 10000).lastInsertRowid; // $100
const billB = ins.run(userId, 'B due 1', 1, 'monthly', 'monthly', null, 20000).lastInsertRowid; // $200
ins.run(userId, 'Annual (Jan)', 1, 'annually', 'annual', '1', 90000); // $900, excluded in June
const pay = db.prepare(
"INSERT INTO payments (bill_id, amount, paid_date, payment_source) VALUES (?, ?, ?, 'manual')",
);
pay.run(billA, 10000, '2026-06-15'); // pay A in full ($100)
pay.run(billB, 5000, '2026-06-10'); // partial toward B ($50)
const t = getTracker(userId, { year: 2026, month: 6 }, JUNE_20);
assert.equal(t.summary.total_expected, 300, 'only June bills: 100 + 200 (annual excluded)');
assert.equal(t.summary.total_paid, 150, '100 + 50');
assert.equal(t.summary.paid_toward_due, 150, 'both payments are ≤ their due amount');
assert.equal(t.rows.filter(r => r.name === 'Annual (Jan)').length, 0, 'annual bill absent from rows');
});
test('summary.remaining falls back to outstanding balance when no bank + no starting amounts', () => {
const userId = mkUser('t1-nobank');
insertBill().run(userId, 'Solo due 15', 15, 'monthly', 'monthly', null, 10000); // $100, unpaid
const t = getTracker(userId, { year: 2026, month: 6 }, JUNE_20);
assert.equal(t.bank_tracking.enabled, false);
assert.equal(t.summary.has_starting_amounts, false);
// No bank / no starting amounts → remaining is the outstanding still-owed for
// the current period (June 20 → "15th" period); the $100 bill is unpaid.
assert.equal(t.summary.remaining, 100);
});
test('auto_mark_paid bill creates an autopay payment and drops the balance atomically', () => {
const userId = mkUser('t1-autopay');
// Eligible: autopay on, assumed_paid, due June 1 (past on June 20), auto_mark_paid.
db.prepare(`
INSERT INTO bills (user_id, name, due_day, billing_cycle, cycle_type, cycle_day,
expected_amount, current_balance, autopay_enabled, autodraft_status, auto_mark_paid, active)
VALUES (?, 'Autodraft', 1, 'monthly', 'monthly', NULL, 10000, 50000, 1, 'assumed_paid', 1, 1)
`).run(userId);
const t = getTracker(userId, { year: 2026, month: 6 }, JUNE_20);
const bill = db.prepare("SELECT id, current_balance FROM bills WHERE user_id = ?").get(userId);
const pays = db.prepare("SELECT amount, method, payment_source FROM payments WHERE bill_id = ? AND deleted_at IS NULL").all(bill.id);
assert.equal(pays.length, 1, 'exactly one auto-mark payment created');
assert.equal(pays[0].amount, 10000, '$100 in cents');
assert.equal(pays[0].method, 'autopay');
assert.equal(bill.current_balance, 40000, '500.00 100.00 = 400.00 (balance applied)');
// The row should read as done (paid/autodraft), and running again must not double-charge.
const t2 = getTracker(userId, { year: 2026, month: 6 }, JUNE_20);
const paysAfter = db.prepare("SELECT COUNT(*) c FROM payments WHERE bill_id = ? AND deleted_at IS NULL").get(bill.id).c;
assert.equal(paysAfter, 1, 'second load does not create a duplicate autopay payment');
assert.ok(t.summary.count_paid + t.summary.count_autodraft >= 1);
assert.ok(t2);
});
test('GET /tracker returns a standardized error for an invalid month', async () => {
const router = require('../routes/tracker');
const layer = router.stack.find(l => l.route?.path === '/' && l.route.methods.get);
const handler = layer.route.stack[layer.route.stack.length - 1].handle;
const userId = mkUser('t1-route');
const result = await new Promise((resolve) => {
const req = { query: { year: '2026', month: '13' }, user: { id: userId, role: 'user' } };
const res = {
statusCode: 200,
status(c) { this.statusCode = c; return this; },
json(d) { resolve({ status: this.statusCode, data: d }); },
};
handler(req, res);
});
assert.equal(result.status, 400);
assert.equal(result.data.code, 'VALIDATION_ERROR', 'standardized shape, not a plain {error}');
assert.match(result.data.message, /month/);
});
test('getOverdueCount gates by occurrence and honors paid/skip', () => {
const userId = mkUser('t1-overdue');
const ins = insertBill();
ins.run(userId, 'Overdue monthly', 1, 'monthly', 'monthly', null, 10000); // due June 1, unpaid → overdue on June 20
ins.run(userId, 'Annual (Jan)', 1, 'annually', 'annual', '1', 50000); // not due in June → NOT overdue
const paidBill = ins.run(userId, 'Paid monthly', 1, 'monthly', 'monthly', null, 10000).lastInsertRowid;
db.prepare("INSERT INTO payments (bill_id, amount, paid_date, payment_source) VALUES (?, 10000, '2026-06-02', 'manual')")
.run(paidBill);
const { count, names } = getOverdueCount(userId, JUNE_20);
assert.equal(count, 1, 'only the unpaid monthly bill due June 1 is overdue');
assert.deepEqual(names, ['Overdue monthly']);
});