BillTracker/tests/trackerService.test.js

115 lines
5.6 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('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']);
});