2026-07-03 18:10:28 -05:00
|
|
|
|
'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);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-07-03 18:21:28 -05:00
|
|
|
|
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/);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-07-03 18:10:28 -05:00
|
|
|
|
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']);
|
|
|
|
|
|
});
|