refactor(server): migrate trackerService to TypeScript (.cts)

The tracker aggregation core (getTracker, safe-to-spend, bank tracking,
overdue count). Aggregation-heavy so money helpers are plain-required (any)
to avoid Dollars|null arithmetic friction, matching analyticsService.

Behavior-preserving. Verified: typecheck:server 0, check:server 0, suite 226/226.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
null 2026-07-06 11:16:14 -05:00
parent 0db5a77adc
commit 310eb07b9b
7 changed files with 91 additions and 155 deletions

View File

@ -1,6 +1,6 @@
const express = require('express'); const express = require('express');
const router = express.Router(); const router = express.Router();
const { getTracker, getUpcomingBills, getOverdueCount } = require('../services/trackerService'); const { getTracker, getUpcomingBills, getOverdueCount } = require('../services/trackerService.cts');
const { standardizeError } = require('../middleware/errorFormatter'); const { standardizeError } = require('../middleware/errorFormatter');
// GET /api/tracker/overdue-count — lightweight count for sidebar badge // GET /api/tracker/overdue-count — lightweight count for sidebar badge

View File

@ -1,5 +1,7 @@
'use strict'; 'use strict';
import type { Db } from '../types/db';
const { getDb } = require('../db/database'); const { getDb } = require('../db/database');
const { buildTrackerRow, getCycleRange, resolveDueDate, isPaidStatus } = require('./statusService.cts'); const { buildTrackerRow, getCycleRange, resolveDueDate, isPaidStatus } = require('./statusService.cts');
const { getUserSettings } = require('./userSettings.cts'); const { getUserSettings } = require('./userSettings.cts');
@ -8,22 +10,24 @@ const { computeAmountSuggestionsBatch } = require('./amountSuggestionService.cts
const { accountingActiveSql } = require('./paymentAccountingService.cts'); const { accountingActiveSql } = require('./paymentAccountingService.cts');
const { normalizeMerchant } = require('./subscriptionService'); const { normalizeMerchant } = require('./subscriptionService');
const { localDateString } = require('../utils/dates.mts'); const { localDateString } = require('../utils/dates.mts');
// Aggregation-heavy: money results flow through many .filter/.sort/arith, so a
// plain require (→ any) avoids Dollars|null friction (cf. analyticsService).
const { sumMoney, roundMoney, fromCents } = require('../utils/money.mts'); const { sumMoney, roundMoney, fromCents } = require('../utils/money.mts');
type Row = Record<string, any>;
const DEFAULT_PENDING_DAYS = 3; const DEFAULT_PENDING_DAYS = 3;
// Word-boundary match — same semantics as billMerchantRuleService.merchantMatches. // Word-boundary match — same semantics as billMerchantRuleService.merchantMatches.
function txMerchantMatches(txNorm, ruleMerchant) { function txMerchantMatches(txNorm: any, ruleMerchant: any): boolean {
if (!txNorm || !ruleMerchant) return false; if (!txNorm || !ruleMerchant) return false;
if (txNorm === ruleMerchant) return true; if (txNorm === ruleMerchant) return true;
const esc = s => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const esc = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const wb = s => new RegExp(`(^|\\s)${esc(s)}(\\s|$)`); const wb = (s: string) => new RegExp(`(^|\\s)${esc(s)}(\\s|$)`);
return wb(ruleMerchant).test(txNorm) || wb(txNorm).test(ruleMerchant); return wb(ruleMerchant).test(txNorm) || wb(txNorm).test(ruleMerchant);
} }
// For bills that have merchant rules, count how many of that user's pending bank function fetchBankPendingCounts(db: Db, userId: number, billIds: any[]): Record<string, number> {
// transactions match each bill. Only bills with at least one rule are checked.
function fetchBankPendingCounts(db, userId, billIds) {
if (billIds.length === 0) return {}; if (billIds.length === 0) return {};
const ph = billIds.map(() => '?').join(','); const ph = billIds.map(() => '?').join(',');
const rules = db.prepare(` const rules = db.prepare(`
@ -45,18 +49,18 @@ function fetchBankPendingCounts(db, userId, billIds) {
`).all(userId); `).all(userId);
if (pendingTxs.length === 0) return {}; if (pendingTxs.length === 0) return {};
const rulesByBill = {}; const rulesByBill: Record<string, string[]> = {};
for (const rule of rules) { for (const rule of rules) {
if (!rulesByBill[rule.bill_id]) rulesByBill[rule.bill_id] = []; if (!rulesByBill[rule.bill_id]) rulesByBill[rule.bill_id] = [];
rulesByBill[rule.bill_id].push(rule.merchant); rulesByBill[rule.bill_id].push(rule.merchant);
} }
const counts = {}; const counts: Record<string, number> = {};
for (const tx of pendingTxs) { for (const tx of pendingTxs) {
const txNorm = normalizeMerchant(tx.payee || tx.description || tx.memo || ''); const txNorm = normalizeMerchant(tx.payee || tx.description || tx.memo || '');
if (!txNorm) continue; if (!txNorm) continue;
for (const [billId, merchants] of Object.entries(rulesByBill)) { for (const [billId, merchants] of Object.entries(rulesByBill)) {
if (merchants.some(m => txMerchantMatches(txNorm, m))) { if (merchants.some((m: string) => txMerchantMatches(txNorm, m))) {
counts[billId] = (counts[billId] || 0) + 1; counts[billId] = (counts[billId] || 0) + 1;
} }
} }
@ -64,11 +68,7 @@ function fetchBankPendingCounts(db, userId, billIds) {
return counts; return counts;
} }
// `gatedUnpaidThisMonth` (dollars) is the occurrence-gated unpaid total the function buildBankTracking(db: Db, userId: number, year: number, month: number, gatedUnpaidThisMonth: any = null): any {
// caller already computed from the tracker rows (which honor resolveDueDate).
// When provided we use it instead of the ungated SQL below, so annual/quarterly/
// off-month bills don't inflate `unpaid_this_month` → the bank `remaining`.
function buildBankTracking(db, userId, year, month, gatedUnpaidThisMonth = null) {
try { try {
const settings = getUserSettings(userId); const settings = getUserSettings(userId);
if (settings.bank_tracking_enabled !== 'true') return { enabled: false }; if (settings.bank_tracking_enabled !== 'true') return { enabled: false };
@ -85,9 +85,6 @@ function buildBankTracking(db, userId, year, month, gatedUnpaidThisMonth = null)
const pendingDays = parseInt(settings.bank_tracking_pending_days, 10); const pendingDays = parseInt(settings.bank_tracking_pending_days, 10);
const days = Number.isInteger(pendingDays) && pendingDays >= 0 ? pendingDays : DEFAULT_PENDING_DAYS; const days = Number.isInteger(pendingDays) && pendingDays >= 0 ? pendingDays : DEFAULT_PENDING_DAYS;
// Only count manually-entered payments as pending — bank-synced payments
// (provider_sync) are already reflected in the live bank balance, so
// including them would double-deduct.
const pendingRow = days > 0 const pendingRow = days > 0
? db.prepare(` ? db.prepare(`
SELECT COALESCE(SUM(p.amount), 0) AS pending_total SELECT COALESCE(SUM(p.amount), 0) AS pending_total
@ -100,9 +97,6 @@ function buildBankTracking(db, userId, year, month, gatedUnpaidThisMonth = null)
`).get(userId, days) `).get(userId, days)
: { pending_total: 0 }; : { pending_total: 0 };
// Fallback (only when the caller didn't pass a gated total, e.g. a direct
// call): the ungated SQL sum. This overcounts off-month bills — prefer the
// gated total from getTracker's rows.
let unpaid; let unpaid;
if (gatedUnpaidThisMonth != null) { if (gatedUnpaidThisMonth != null) {
unpaid = roundMoney(gatedUnpaidThisMonth); unpaid = roundMoney(gatedUnpaidThisMonth);
@ -145,13 +139,13 @@ function buildBankTracking(db, userId, year, month, gatedUnpaidThisMonth = null)
remaining: roundMoney(effective - unpaid), remaining: roundMoney(effective - unpaid),
last_updated: account.updated_at, last_updated: account.updated_at,
}; };
} catch (err) { } catch (err: any) {
console.error('[buildBankTracking] Error computing bank tracking data:', err.message); console.error('[buildBankTracking] Error computing bank tracking data:', err.message);
return { enabled: false }; return { enabled: false };
} }
} }
function validateTrackerMonth(query = {}, now = new Date()) { function validateTrackerMonth(query: any = {}, now: Date = new Date()): any {
const year = parseInt(query.year || now.getFullYear(), 10); const year = parseInt(query.year || now.getFullYear(), 10);
const month = parseInt(query.month || now.getMonth() + 1, 10); const month = parseInt(query.month || now.getMonth() + 1, 10);
@ -164,14 +158,14 @@ function validateTrackerMonth(query = {}, now = new Date()) {
return { year, month }; return { year, month };
} }
function previousMonthFor(year, month) { function previousMonthFor(year: number, month: number) {
return { return {
year: month === 1 ? year - 1 : year, year: month === 1 ? year - 1 : year,
month: month === 1 ? 12 : month - 1, month: month === 1 ? 12 : month - 1,
}; };
} }
function monthOffset(year, month, offset) { function monthOffset(year: number, month: number, offset: number) {
let y = year; let y = year;
let m = month + offset; let m = month + offset;
while (m <= 0) { m += 12; y -= 1; } while (m <= 0) { m += 12; y -= 1; }
@ -179,12 +173,12 @@ function monthOffset(year, month, offset) {
return { year: y, month: m }; return { year: y, month: m };
} }
const FETCH_BILLS_ORDER = { const FETCH_BILLS_ORDER: Record<string, string> = {
due_day: 'CASE WHEN b.sort_order IS NULL THEN 1 ELSE 0 END, b.sort_order ASC, b.due_day ASC, b.name ASC', due_day: 'CASE WHEN b.sort_order IS NULL THEN 1 ELSE 0 END, b.sort_order ASC, b.due_day ASC, b.name ASC',
id: 'b.id ASC', id: 'b.id ASC',
}; };
function fetchActiveBills(db, userId, orderKey = 'due_day') { function fetchActiveBills(db: Db, userId: number, orderKey = 'due_day'): any[] {
const orderBy = FETCH_BILLS_ORDER[orderKey] ?? FETCH_BILLS_ORDER.due_day; const orderBy = FETCH_BILLS_ORDER[orderKey] ?? FETCH_BILLS_ORDER.due_day;
return db.prepare(` return db.prepare(`
SELECT b.*, c.name AS category_name, SELECT b.*, c.name AS category_name,
@ -199,7 +193,7 @@ function fetchActiveBills(db, userId, orderKey = 'due_day') {
`).all(userId); `).all(userId);
} }
function fetchMonthlyStates(db, billIds, year, month) { function fetchMonthlyStates(db: Db, billIds: any[], year: number, month: number): Record<string, any> {
if (billIds.length === 0) return {}; if (billIds.length === 0) return {};
const placeholders = billIds.map(() => '?').join(','); const placeholders = billIds.map(() => '?').join(',');
const rows = db.prepare(` const rows = db.prepare(`
@ -207,10 +201,10 @@ function fetchMonthlyStates(db, billIds, year, month) {
FROM monthly_bill_state FROM monthly_bill_state
WHERE bill_id IN (${placeholders}) AND year = ? AND month = ? WHERE bill_id IN (${placeholders}) AND year = ? AND month = ?
`).all(...billIds, year, month); `).all(...billIds, year, month);
return Object.fromEntries(rows.map(row => [row.bill_id, row])); return Object.fromEntries(rows.map((row: Row) => [row.bill_id, row]));
} }
function fetchPaymentsForBillCycle(db, bill, year, month) { function fetchPaymentsForBillCycle(db: Db, bill: Row, year: number, month: number): any[] {
const range = getCycleRange(year, month, bill); const range = getCycleRange(year, month, bill);
if (!range) return []; if (!range) return [];
return db.prepare(` return db.prepare(`
@ -223,14 +217,10 @@ function fetchPaymentsForBillCycle(db, bill, year, month) {
`).all(bill.id, range.start, range.end); `).all(bill.id, range.start, range.end);
} }
// Batched form of fetchPaymentsForBillCycle: one query for every bill's cycle function fetchPaymentsForBillsInMonth(db: Db, bills: any[], year: number, month: number): Map<any, any> {
// payments (grouped in JS by each bill's own range) instead of one query per const ranges = new Map<any, any>(); // billId → { start, end } (only bills due this month)
// bill. Returns Map<billId, payments[]> with each list still ordered paid_date let minStart: any = null;
// DESC — identical to calling fetchPaymentsForBillCycle per bill. let maxEnd: any = null;
function fetchPaymentsForBillsInMonth(db, bills, year, month) {
const ranges = new Map(); // billId → { start, end } (only bills due this month)
let minStart = null;
let maxEnd = null;
for (const bill of bills) { for (const bill of bills) {
const range = getCycleRange(year, month, bill); const range = getCycleRange(year, month, bill);
if (!range) continue; if (!range) continue;
@ -239,7 +229,7 @@ function fetchPaymentsForBillsInMonth(db, bills, year, month) {
if (maxEnd === null || range.end > maxEnd) maxEnd = range.end; if (maxEnd === null || range.end > maxEnd) maxEnd = range.end;
} }
const byBill = new Map(); const byBill = new Map<any, any>();
if (minStart === null) return byBill; if (minStart === null) return byBill;
const ids = [...ranges.keys()]; const ids = [...ranges.keys()];
@ -263,7 +253,7 @@ function fetchPaymentsForBillsInMonth(db, bills, year, month) {
return byBill; return byBill;
} }
function fetchPreviousMonthPaid(db, billIds, range) { function fetchPreviousMonthPaid(db: Db, billIds: any[], range: any): Record<string, any> {
if (billIds.length === 0) return {}; if (billIds.length === 0) return {};
const placeholders = billIds.map(() => '?').join(','); const placeholders = billIds.map(() => '?').join(',');
const rows = db.prepare(` const rows = db.prepare(`
@ -274,20 +264,20 @@ function fetchPreviousMonthPaid(db, billIds, range) {
AND ${accountingActiveSql()} AND ${accountingActiveSql()}
GROUP BY bill_id GROUP BY bill_id
`).all(...billIds, range.start, range.end); `).all(...billIds, range.start, range.end);
return Object.fromEntries(rows.map(row => [row.bill_id, row.total_paid])); return Object.fromEntries(rows.map((row: Row) => [row.bill_id, row.total_paid]));
} }
function fetchDismissedSuggestions(db, userId, billIds, year, month) { function fetchDismissedSuggestions(db: Db, userId: number, billIds: any[], year: number, month: number): Set<any> {
if (billIds.length === 0) return new Set(); if (billIds.length === 0) return new Set();
const rows = db.prepare(` const rows = db.prepare(`
SELECT bill_id SELECT bill_id
FROM autopay_suggestion_dismissals FROM autopay_suggestion_dismissals
WHERE user_id = ? AND year = ? AND month = ? WHERE user_id = ? AND year = ? AND month = ?
`).all(userId, year, month); `).all(userId, year, month);
return new Set(rows.map(row => row.bill_id)); return new Set(rows.map((row: Row) => row.bill_id));
} }
function fetchSparklines(db, billIds) { function fetchSparklines(db: Db, billIds: any[]): Record<string, any> {
if (billIds.length === 0) return {}; if (billIds.length === 0) return {};
const ph = billIds.map(() => '?').join(','); const ph = billIds.map(() => '?').join(',');
const rows = db.prepare(` const rows = db.prepare(`
@ -299,7 +289,7 @@ function fetchSparklines(db, billIds) {
GROUP BY bill_id, month_str GROUP BY bill_id, month_str
ORDER BY bill_id, month_str ORDER BY bill_id, month_str
`).all(...billIds); `).all(...billIds);
const out = {}; const out: Record<string, any> = {};
for (const r of rows) { for (const r of rows) {
if (!out[r.bill_id]) out[r.bill_id] = []; if (!out[r.bill_id]) out[r.bill_id] = [];
out[r.bill_id].push(fromCents(r.total)); out[r.bill_id].push(fromCents(r.total));
@ -307,7 +297,7 @@ function fetchSparklines(db, billIds) {
return out; return out;
} }
function fetchAutopayStats(db, billIds) { function fetchAutopayStats(db: Db, billIds: any[]): Record<string, any> {
if (billIds.length === 0) return {}; if (billIds.length === 0) return {};
const ph = billIds.map(() => '?').join(','); const ph = billIds.map(() => '?').join(',');
const rows = db.prepare(` const rows = db.prepare(`
@ -325,7 +315,7 @@ function fetchAutopayStats(db, billIds) {
AND p.paid_date >= date('now', '-12 months') AND p.paid_date >= date('now', '-12 months')
GROUP BY p.bill_id GROUP BY p.bill_id
`).all(...billIds); `).all(...billIds);
return Object.fromEntries(rows.map(r => [r.bill_id, { return Object.fromEntries(rows.map((r: Row) => [r.bill_id, {
total: r.total, total: r.total,
failures: r.failures || 0, failures: r.failures || 0,
last_failure_date: r.last_failure_date || null, last_failure_date: r.last_failure_date || null,
@ -333,65 +323,53 @@ function fetchAutopayStats(db, billIds) {
}])); }]));
} }
function rowDueAmount(row) { function rowDueAmount(row: Row): number {
const amount = Number(row.actual_amount ?? row.expected_amount); const amount = Number(row.actual_amount ?? row.expected_amount);
return Number.isFinite(amount) ? amount : 0; return Number.isFinite(amount) ? amount : 0;
} }
function rowPaidTowardDue(row) { function rowPaidTowardDue(row: Row): number {
const cappedPaid = Number(row.paid_toward_due); const cappedPaid = Number(row.paid_toward_due);
if (Number.isFinite(cappedPaid)) return cappedPaid; if (Number.isFinite(cappedPaid)) return cappedPaid;
return Math.min(Number(row.total_paid) || 0, rowDueAmount(row)); return Math.min(Number(row.total_paid) || 0, rowDueAmount(row));
} }
function rowOutstanding(row) { function rowOutstanding(row: Row): number {
return Math.max(Number(row.balance) || 0, 0); return Math.max(Number(row.balance) || 0, 0);
} }
/** /**
* Safe-to-spend projection for the current 1st/15th pay period. * Safe-to-spend projection for the current 1st/15th pay period. Pure (no DB).
*
* Pure function (no DB) so it is unit-testable: takes serialized tracker rows
* (dollar-denominated, post buildTrackerRow) plus the cash available for the
* period, and answers: "after every bill still due before the next payday is
* covered including overdue carry-over what is left to spend?"
*
* The next payday is the next bucket boundary (the 15th, or the 1st of the
* following month), matching the app's semi-monthly bucket model.
*/ */
function buildSafeToSpend({ activeRows, available, todayStr, year, month, dayOfMonth }) { function buildSafeToSpend({ activeRows, available, todayStr, year, month, dayOfMonth }: { activeRows: any[]; available: any; todayStr: string; year: number; month: number; dayOfMonth: number }): any {
const nextPayday = dayOfMonth < 15 const nextPayday = dayOfMonth < 15
? `${year}-${String(month).padStart(2, '0')}-15` ? `${year}-${String(month).padStart(2, '0')}-15`
: (month === 12 ? `${year + 1}-01-01` : `${year}-${String(month + 1).padStart(2, '0')}-01`); : (month === 12 ? `${year + 1}-01-01` : `${year}-${String(month + 1).padStart(2, '0')}-01`);
// Both strings are YYYY-MM-DD → Date.parse treats them as UTC midnight,
// so the difference is an exact whole number of days.
const daysUntilPayday = Math.max(0, Math.round((Date.parse(nextPayday) - Date.parse(todayStr)) / 86400000)); const daysUntilPayday = Math.max(0, Math.round((Date.parse(nextPayday) - Date.parse(todayStr)) / 86400000));
const stillDueRows = activeRows const stillDueRows = activeRows
.filter(r => !isPaidStatus(r.status)) .filter((r: Row) => !isPaidStatus(r.status))
.filter(r => rowOutstanding(r) > 0) .filter((r: Row) => rowOutstanding(r) > 0)
.filter(r => r.due_date < nextPayday) .filter((r: Row) => r.due_date < nextPayday)
.sort((a, b) => a.due_date.localeCompare(b.due_date) || String(a.name).localeCompare(String(b.name))); .sort((a: Row, b: Row) => a.due_date.localeCompare(b.due_date) || String(a.name).localeCompare(String(b.name)));
const stillDueTotal = sumMoney(stillDueRows, rowOutstanding); const stillDueTotal = sumMoney(stillDueRows, rowOutstanding);
const safeToSpend = roundMoney(available - stillDueTotal); const safeToSpend = roundMoney(available - stillDueTotal);
// Daily projection from today to payday: balance steps down as bills hit. const byDate = new Map<any, any>();
// Overdue bills land on "today" — they need to be paid now, not in the past.
const byDate = new Map();
for (const r of stillDueRows) { for (const r of stillDueRows) {
const date = r.due_date > todayStr ? r.due_date : todayStr; const date = r.due_date > todayStr ? r.due_date : todayStr;
if (!byDate.has(date)) byDate.set(date, []); if (!byDate.has(date)) byDate.set(date, []);
byDate.get(date).push({ id: r.id, name: r.name, amount: rowOutstanding(r) }); byDate.get(date).push({ id: r.id, name: r.name, amount: rowOutstanding(r) });
} }
const timeline = []; const timeline: any[] = [];
let running = roundMoney(available); let running = roundMoney(available);
if (!byDate.has(todayStr)) timeline.push({ date: todayStr, balance: running, bills: [] }); if (!byDate.has(todayStr)) timeline.push({ date: todayStr, balance: running, bills: [] });
for (const date of [...byDate.keys()].sort()) { for (const date of [...byDate.keys()].sort()) {
const bills = byDate.get(date); const bills = byDate.get(date);
running = roundMoney(running - sumMoney(bills, b => b.amount)); running = roundMoney(running - sumMoney(bills, (b: Row) => b.amount));
timeline.push({ date, balance: running, bills }); timeline.push({ date, balance: running, bills });
} }
if (timeline.length === 0 || timeline[timeline.length - 1].date !== nextPayday) { if (timeline.length === 0 || timeline[timeline.length - 1].date !== nextPayday) {
@ -405,7 +383,7 @@ function buildSafeToSpend({ activeRows, available, todayStr, year, month, dayOfM
safe_to_spend: safeToSpend, safe_to_spend: safeToSpend,
still_due_total: stillDueTotal, still_due_total: stillDueTotal,
still_due_count: stillDueRows.length, still_due_count: stillDueRows.length,
upcoming: stillDueRows.slice(0, 8).map(r => ({ upcoming: stillDueRows.slice(0, 8).map((r: Row) => ({
id: r.id, id: r.id,
name: r.name, name: r.name,
due_date: r.due_date, due_date: r.due_date,
@ -416,7 +394,7 @@ function buildSafeToSpend({ activeRows, available, todayStr, year, month, dayOfM
}; };
} }
function applyAutopaySuggestions(db, bill, payments, mbs, year, month, todayStr, dismissedSuggestions) { function applyAutopaySuggestions(db: Db, bill: Row, payments: any[], mbs: any, year: number, month: number, todayStr: string, dismissedSuggestions: Set<any>): any {
const dueDate = resolveDueDate(bill, year, month); const dueDate = resolveDueDate(bill, year, month);
if (!dueDate) return null; if (!dueDate) return null;
@ -452,9 +430,6 @@ function applyAutopaySuggestions(db, bill, payments, mbs, year, month, todayStr,
} }
const balCalc = computeBalanceDelta(bill, suggestedAmount); const balCalc = computeBalanceDelta(bill, suggestedAmount);
// Atomic: the auto-mark payment INSERT and its balance update must both land
// or neither. This runs on a GET /tracker, so a mid-way failure would
// otherwise leave a payment without its balance adjustment (or vice versa).
const insertAutoPayment = db.transaction(() => { const insertAutoPayment = db.transaction(() => {
const r = db.prepare(` const r = db.prepare(`
INSERT INTO payments (bill_id, amount, paid_date, method, notes, balance_delta, interest_delta, payment_source) INSERT INTO payments (bill_id, amount, paid_date, method, notes, balance_delta, interest_delta, payment_source)
@ -492,7 +467,7 @@ function applyAutopaySuggestions(db, bill, payments, mbs, year, month, todayStr,
}; };
} }
function buildThreeMonthTrend(db, userId, year, month, end, currentMonthPaid) { function buildThreeMonthTrend(db: Db, userId: number, year: number, month: number, end: any, currentMonthPaid: any): any {
const threeMonthsAgo = monthOffset(year, month, -2); const threeMonthsAgo = monthOffset(year, month, -2);
const threeMonthStart = getCycleRange(threeMonthsAgo.year, threeMonthsAgo.month).start; const threeMonthStart = getCycleRange(threeMonthsAgo.year, threeMonthsAgo.month).start;
const rows = db.prepare(` const rows = db.prepare(`
@ -505,12 +480,12 @@ function buildThreeMonthTrend(db, userId, year, month, end, currentMonthPaid) {
GROUP BY strftime('%Y-%m', p.paid_date) GROUP BY strftime('%Y-%m', p.paid_date)
`).all(userId, threeMonthStart, end); `).all(userId, threeMonthStart, end);
const monthlyPaymentsMap = new Map(); const monthlyPaymentsMap = new Map<any, any>();
rows.forEach(payment => { rows.forEach((payment: Row) => {
monthlyPaymentsMap.set(payment.month_key, payment.total_paid); monthlyPaymentsMap.set(payment.month_key, payment.total_paid);
}); });
const months = []; const months: any[] = [];
for (let i = 2; i >= 0; i--) { for (let i = 2; i >= 0; i--) {
const date = new Date(year, month - 1 - i); const date = new Date(year, month - 1 - i);
const monthKey = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`; const monthKey = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`;
@ -522,7 +497,7 @@ function buildThreeMonthTrend(db, userId, year, month, end, currentMonthPaid) {
}); });
} }
const threeMonthAvg = sumMoney(months, m => m.payment) / 3; const threeMonthAvg = sumMoney(months, (m: Row) => m.payment) / 3;
let percentChange = 0; let percentChange = 0;
let direction = 'flat'; let direction = 'flat';
if (threeMonthAvg > 0) { if (threeMonthAvg > 0) {
@ -539,11 +514,11 @@ function buildThreeMonthTrend(db, userId, year, month, end, currentMonthPaid) {
}; };
} }
function getTracker(userId, query = {}, now = new Date()) { function getTracker(userId: number, query: any = {}, now: Date = new Date()): any {
const parsed = validateTrackerMonth(query, now); const parsed = validateTrackerMonth(query, now);
if (parsed.error) return parsed; if (parsed.error) return parsed;
const db = getDb(); const db: Db = getDb();
const { year, month } = parsed; const { year, month } = parsed;
const todayStr = localDateString(now); const todayStr = localDateString(now);
const userSettings = getUserSettings(userId); const userSettings = getUserSettings(userId);
@ -553,18 +528,16 @@ function getTracker(userId, query = {}, now = new Date()) {
const prevMonthRange = getCycleRange(previousMonth.year, previousMonth.month); const prevMonthRange = getCycleRange(previousMonth.year, previousMonth.month);
const bills = fetchActiveBills(db, userId); const bills = fetchActiveBills(db, userId);
const billIds = bills.map(bill => bill.id); const billIds = bills.map((bill: Row) => bill.id);
const monthlyStates = fetchMonthlyStates(db, billIds, year, month); const monthlyStates = fetchMonthlyStates(db, billIds, year, month);
const prevMonthPayments = fetchPreviousMonthPaid(db, billIds, prevMonthRange); const prevMonthPayments = fetchPreviousMonthPaid(db, billIds, prevMonthRange);
const dismissedSuggestions = fetchDismissedSuggestions(db, userId, billIds, year, month); const dismissedSuggestions = fetchDismissedSuggestions(db, userId, billIds, year, month);
const sparklines = fetchSparklines(db, billIds); const sparklines = fetchSparklines(db, billIds);
const autopayStatsMap = fetchAutopayStats(db, billIds); const autopayStatsMap = fetchAutopayStats(db, billIds);
// Batched to avoid an N+1 across bills (was ~23 queries × N bills every load):
// one query for all cycle payments, two for all amount suggestions.
const paymentsByBill = fetchPaymentsForBillsInMonth(db, bills, year, month); const paymentsByBill = fetchPaymentsForBillsInMonth(db, bills, year, month);
const amountSuggestions = computeAmountSuggestionsBatch(db, billIds, year, month); const amountSuggestions = computeAmountSuggestionsBatch(db, billIds, year, month);
const rows = bills.map(bill => { const rows = bills.map((bill: Row) => {
bill.sparkline = sparklines[bill.id] ?? null; bill.sparkline = sparklines[bill.id] ?? null;
bill.autopay_stats = autopayStatsMap[bill.id] ?? null; bill.autopay_stats = autopayStatsMap[bill.id] ?? null;
if (!resolveDueDate(bill, year, month)) return null; if (!resolveDueDate(bill, year, month)) return null;
@ -572,14 +545,7 @@ function getTracker(userId, query = {}, now = new Date()) {
const payments = paymentsByBill.get(bill.id) || []; const payments = paymentsByBill.get(bill.id) || [];
const mbs = monthlyStates[bill.id]; const mbs = monthlyStates[bill.id];
const autopaySuggestion = applyAutopaySuggestions( const autopaySuggestion = applyAutopaySuggestions(
db, db, bill, payments, mbs, year, month, todayStr, dismissedSuggestions,
bill,
payments,
mbs,
year,
month,
todayStr,
dismissedSuggestions,
); );
const billForStatus = mbs?.actual_amount != null const billForStatus = mbs?.actual_amount != null
@ -599,7 +565,7 @@ function getTracker(userId, query = {}, now = new Date()) {
return row; return row;
}).filter(Boolean); }).filter(Boolean);
const activeRows = rows.filter(r => !r.is_skipped); const activeRows = rows.filter((r: Row) => !r.is_skipped);
const startingAmounts = db.prepare(` const startingAmounts = db.prepare(`
SELECT COALESCE(first_amount, 0) AS first_amount, SELECT COALESCE(first_amount, 0) AS first_amount,
COALESCE(fifteenth_amount, 0) AS fifteenth_amount, COALESCE(fifteenth_amount, 0) AS fifteenth_amount,
@ -618,7 +584,7 @@ function getTracker(userId, query = {}, now = new Date()) {
const dayOfMonth = now.getDate(); const dayOfMonth = now.getDate();
const activeRemainingPeriod = dayOfMonth < 15 ? '1st' : '15th'; const activeRemainingPeriod = dayOfMonth < 15 ? '1st' : '15th';
const periodRows = activeRows.filter(r => r.bucket === activeRemainingPeriod); const periodRows = activeRows.filter((r: Row) => r.bucket === activeRemainingPeriod);
const periodPaidTowardDue = sumMoney(periodRows, rowPaidTowardDue); const periodPaidTowardDue = sumMoney(periodRows, rowPaidTowardDue);
const periodOutstandingBalance = sumMoney(periodRows, rowOutstanding); const periodOutstandingBalance = sumMoney(periodRows, rowOutstanding);
const periodStartingAmount = activeRemainingPeriod === '1st' const periodStartingAmount = activeRemainingPeriod === '1st'
@ -626,11 +592,7 @@ function getTracker(userId, query = {}, now = new Date()) {
: (startingAmounts?.fifteenth_amount || 0); : (startingAmounts?.fifteenth_amount || 0);
const periodLabel = activeRemainingPeriod === '1st' ? '1st balance' : '15th balance'; const periodLabel = activeRemainingPeriod === '1st' ? '1st balance' : '15th balance';
// Occurrence-gated unpaid total for this month: the still-owed amount across const activeMonthUnpaid = sumMoney(activeRows, (r: Row) => Math.max(rowDueAmount(r) - rowPaidTowardDue(r), 0));
// bills actually due this month (activeRows already honor resolveDueDate),
// netting partial payments. Feeds the bank card so its unpaid/remaining agree
// with the rows instead of over-counting annual/off-month bills.
const activeMonthUnpaid = sumMoney(activeRows, r => Math.max(rowDueAmount(r) - rowPaidTowardDue(r), 0));
const bankTracking = buildBankTracking(db, userId, year, month, activeMonthUnpaid); const bankTracking = buildBankTracking(db, userId, year, month, activeMonthUnpaid);
const bankPendingCounts = bankTracking.enabled ? fetchBankPendingCounts(db, userId, billIds) : {}; const bankPendingCounts = bankTracking.enabled ? fetchBankPendingCounts(db, userId, billIds) : {};
const totalStarting = bankTracking.enabled const totalStarting = bankTracking.enabled
@ -638,47 +600,34 @@ function getTracker(userId, query = {}, now = new Date()) {
: (startingAmounts?.combined_amount || 0); : (startingAmounts?.combined_amount || 0);
const hasStartingAmounts = bankTracking.enabled || !!startingAmounts; const hasStartingAmounts = bankTracking.enabled || !!startingAmounts;
// ── Cash flow projection ───────────────────────────────────────────────────
// "Projected" means starting minus ALL bills due — paid or not.
// This tells the user what they'll have left after everything clears,
// not just what remains after what they've already paid.
const lastDayOfMonth = new Date(year, month, 0).getDate(); const lastDayOfMonth = new Date(year, month, 0).getDate();
const periodEndDay = activeRemainingPeriod === '1st' ? 14 : lastDayOfMonth; const periodEndDay = activeRemainingPeriod === '1st' ? 14 : lastDayOfMonth;
const periodEndLabel = `${['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'][month - 1]} ${periodEndDay}`; const periodEndLabel = `${['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'][month - 1]} ${periodEndDay}`;
const activeTotalPaid = sumMoney(activeRows, r => r.total_paid); const activeTotalPaid = sumMoney(activeRows, (r: Row) => r.total_paid);
const activePaidTowardDue = sumMoney(activeRows, rowPaidTowardDue); const activePaidTowardDue = sumMoney(activeRows, rowPaidTowardDue);
const activeTotalExpected = sumMoney(activeRows, rowDueAmount); const activeTotalExpected = sumMoney(activeRows, rowDueAmount);
const activeOutstandingBalance = sumMoney(activeRows, rowOutstanding); const activeOutstandingBalance = sumMoney(activeRows, rowOutstanding);
const periodBillsTotal = sumMoney(periodRows, rowDueAmount); const periodBillsTotal = sumMoney(periodRows, rowDueAmount);
const periodPaidCount = periodRows.filter(r => isPaidStatus(r.status)).length; const periodPaidCount = periodRows.filter((r: Row) => isPaidStatus(r.status)).length;
const periodTotalCount = periodRows.length; const periodTotalCount = periodRows.length;
// When bank tracking is on use the effective balance as the period starting point
const periodCashStart = bankTracking.enabled const periodCashStart = bankTracking.enabled
? bankTracking.effective_balance ? bankTracking.effective_balance
: periodStartingAmount; : periodStartingAmount;
const periodProjected = roundMoney(periodCashStart - periodBillsTotal); const periodProjected = roundMoney(periodCashStart - periodBillsTotal);
const monthBillsTotal = activeTotalExpected; const monthBillsTotal = activeTotalExpected;
const monthPaidCount = activeRows.filter(r => isPaidStatus(r.status)).length; const monthPaidCount = activeRows.filter((r: Row) => isPaidStatus(r.status)).length;
const monthTotalCount = activeRows.length; const monthTotalCount = activeRows.length;
const monthProjected = roundMoney(totalStarting - monthBillsTotal); const monthProjected = roundMoney(totalStarting - monthBillsTotal);
// Safe to spend: cash on hand for this period after already-made payments
// (bank mode: effective balance already nets them out), minus everything
// still due before the next payday.
const availableNow = bankTracking.enabled const availableNow = bankTracking.enabled
? bankTracking.effective_balance ? bankTracking.effective_balance
: roundMoney(periodStartingAmount - periodPaidTowardDue); : roundMoney(periodStartingAmount - periodPaidTowardDue);
const safeToSpend = buildSafeToSpend({ const safeToSpend = buildSafeToSpend({
activeRows, activeRows, available: availableNow, todayStr, year, month, dayOfMonth,
available: availableNow,
todayStr,
year,
month,
dayOfMonth,
}); });
const cashflow = { const cashflow = {
@ -701,9 +650,9 @@ function getTracker(userId, query = {}, now = new Date()) {
month_projected: monthProjected, month_projected: monthProjected,
}; };
const totalOverdue = sumMoney( const totalOverdue = sumMoney(
rows.filter(r => !r.is_skipped && (r.status === 'late' || r.status === 'missed')), rows.filter((r: Row) => !r.is_skipped && (r.status === 'late' || r.status === 'missed')),
r => r.balance); (r: Row) => r.balance);
const previousMonthTotal = sumMoney(activeRows, r => r.previous_month_paid); const previousMonthTotal = sumMoney(activeRows, (r: Row) => r.previous_month_paid);
return { return {
year, year,
@ -715,11 +664,6 @@ function getTracker(userId, query = {}, now = new Date()) {
has_starting_amounts: hasStartingAmounts, has_starting_amounts: hasStartingAmounts,
total_paid: activeTotalPaid, total_paid: activeTotalPaid,
paid_toward_due: activePaidTowardDue, paid_toward_due: activePaidTowardDue,
// In bank mode the effective bank balance is a single pool (no per-period
// split), so both remaining figures use the bank card's own remaining
// (effective_balance gated unpaid) — keeping the summary consistent with
// safe-to-spend and the bank card instead of showing a different number
// derived from manual starting amounts.
remaining: roundMoney(bankTracking.enabled remaining: roundMoney(bankTracking.enabled
? bankTracking.remaining ? bankTracking.remaining
: (hasStartingAmounts ? periodStartingAmount - periodPaidTowardDue : periodOutstandingBalance)), : (hasStartingAmounts ? periodStartingAmount - periodPaidTowardDue : periodOutstandingBalance)),
@ -732,23 +676,19 @@ function getTracker(userId, query = {}, now = new Date()) {
? `${periodLabel}: ${periodStartingAmount.toFixed(2)} starting minus ${periodPaidTowardDue.toFixed(2)} paid toward due` ? `${periodLabel}: ${periodStartingAmount.toFixed(2)} starting minus ${periodPaidTowardDue.toFixed(2)} paid toward due`
: `${periodLabel}: unpaid bills due in this period`, : `${periodLabel}: unpaid bills due in this period`,
overdue: totalOverdue, overdue: totalOverdue,
count_paid: activeRows.filter(r => r.status === 'paid').length, count_paid: activeRows.filter((r: Row) => r.status === 'paid').length,
count_upcoming: activeRows.filter(r => r.status === 'upcoming' || r.status === 'due_soon').length, count_upcoming: activeRows.filter((r: Row) => r.status === 'upcoming' || r.status === 'due_soon').length,
count_late: activeRows.filter(r => r.status === 'late' || r.status === 'missed').length, count_late: activeRows.filter((r: Row) => r.status === 'late' || r.status === 'missed').length,
count_autodraft: activeRows.filter(r => r.status === 'autodraft').length, count_autodraft: activeRows.filter((r: Row) => r.status === 'autodraft').length,
previous_month_total: previousMonthTotal, previous_month_total: previousMonthTotal,
trend: buildThreeMonthTrend(db, userId, year, month, end, activeTotalPaid), trend: buildThreeMonthTrend(db, userId, year, month, end, activeTotalPaid),
}, },
bank_tracking: bankTracking, bank_tracking: bankTracking,
cashflow, cashflow,
rows: bankTracking.enabled rows: bankTracking.enabled
? rows.map(r => { ? rows.map((r: Row) => {
const bank_pending_count = bankPendingCounts[r.id] || 0; const bank_pending_count = bankPendingCounts[r.id] || 0;
// Only flag manually-entered payments as pending-cleared — bank-synced
// or bank-matched payments are already settled so they don't need the badge.
const isManualPayment = r.payment_source !== 'provider_sync' && r.payment_source !== 'transaction_match'; const isManualPayment = r.payment_source !== 'provider_sync' && r.payment_source !== 'transaction_match';
// Amber "Pending" only makes sense for bills linked to the bank — for unlinked bills
// there's no bank clearing to wait for, so they should just show "Paid".
const isBankLinked = !!(r.has_merchant_rule || r.has_linked_transactions); const isBankLinked = !!(r.has_merchant_rule || r.has_linked_transactions);
if (r.status === 'paid' && r.last_paid_date && isManualPayment && isBankLinked) { if (r.status === 'paid' && r.last_paid_date && isManualPayment && isBankLinked) {
const cutoff = new Date(); const cutoff = new Date();
@ -762,8 +702,8 @@ function getTracker(userId, query = {}, now = new Date()) {
}; };
} }
function getUpcomingBills(userId, query = {}, now = new Date()) { function getUpcomingBills(userId: number, query: any = {}, now: Date = new Date()): any {
const db = getDb(); const db: Db = getDb();
const days = Math.max(1, Math.min(parseInt(query.days || '30', 10) || 30, 365)); const days = Math.max(1, Math.min(parseInt(query.days || '30', 10) || 30, 365));
const todayStr = localDateString(now); const todayStr = localDateString(now);
const userSettings = getUserSettings(userId); const userSettings = getUserSettings(userId);
@ -773,8 +713,8 @@ function getUpcomingBills(userId, query = {}, now = new Date()) {
const cutoff = new Date(now); const cutoff = new Date(now);
cutoff.setDate(cutoff.getDate() + days); cutoff.setDate(cutoff.getDate() + days);
const cutoffStr = localDateString(cutoff); const cutoffStr = localDateString(cutoff);
const upcoming = []; const upcoming: any[] = [];
const seen = new Set(); const seen = new Set<any>();
const monthCount = (cutoff.getFullYear() - now.getFullYear()) * 12 const monthCount = (cutoff.getFullYear() - now.getFullYear()) * 12
+ (cutoff.getMonth() - now.getMonth()) + 1; + (cutoff.getMonth() - now.getMonth()) + 1;
@ -792,10 +732,7 @@ function getUpcomingBills(userId, query = {}, now = new Date()) {
const row = buildTrackerRow( const row = buildTrackerRow(
bill, bill,
fetchPaymentsForBillCycle(db, bill, target.year, target.month), fetchPaymentsForBillCycle(db, bill, target.year, target.month),
target.year, target.year, target.month, todayStr, rowOptions,
target.month,
todayStr,
rowOptions,
); );
if (!row || row.status === 'paid') continue; if (!row || row.status === 'paid') continue;
@ -806,17 +743,17 @@ function getUpcomingBills(userId, query = {}, now = new Date()) {
due_date: dueDate, due_date: dueDate,
expected_amount: row.expected_amount, expected_amount: row.expected_amount,
status: row.status, status: row.status,
days_until_due: Math.floor((new Date(`${dueDate}T00:00:00`) - new Date(`${todayStr}T00:00:00`)) / 86400000), days_until_due: Math.floor((new Date(`${dueDate}T00:00:00`).getTime() - new Date(`${todayStr}T00:00:00`).getTime()) / 86400000),
}); });
} }
} }
upcoming.sort((a, b) => a.due_date.localeCompare(b.due_date)); upcoming.sort((a: Row, b: Row) => a.due_date.localeCompare(b.due_date));
return { days, today: todayStr, upcoming }; return { days, today: todayStr, upcoming };
} }
function getOverdueCount(userId, now = new Date()) { function getOverdueCount(userId: number, now: Date = new Date()): any {
const db = getDb(); const db: Db = getDb();
const todayStr = localDateString(now); const todayStr = localDateString(now);
const year = now.getFullYear(); const year = now.getFullYear();
const month = now.getMonth() + 1; const month = now.getMonth() + 1;
@ -842,14 +779,13 @@ function getOverdueCount(userId, now = new Date()) {
`).all(year, month, rangeStart, rangeEnd, userId); `).all(year, month, rangeStart, rangeEnd, userId);
let count = 0; let count = 0;
const overdueNames = []; const overdueNames: any[] = [];
for (const bill of bills) { for (const bill of bills) {
if (bill.is_skipped) continue; if (bill.is_skipped) continue;
if (bill.snoozed_until && bill.snoozed_until > todayStr) continue; if (bill.snoozed_until && bill.snoozed_until > todayStr) continue;
if (bill.autopay_enabled && bill.autodraft_status === 'assumed_paid') continue; if (bill.autopay_enabled && bill.autodraft_status === 'assumed_paid') continue;
const dueDate = resolveDueDate(bill, year, month); const dueDate = resolveDueDate(bill, year, month);
// Use >= so bills due TODAY are not counted as overdue — only strictly past dates
if (!dueDate || dueDate >= todayStr) continue; if (!dueDate || dueDate >= todayStr) continue;
const threshold = bill.actual_amount != null ? bill.actual_amount : bill.expected_amount; const threshold = bill.actual_amount != null ? bill.actual_amount : bill.expected_amount;

View File

@ -8,7 +8,7 @@ const dbPath = path.join(os.tmpdir(), `bill-tracker-reorder-test-${process.pid}.
process.env.DB_PATH = dbPath; process.env.DB_PATH = dbPath;
const { getDb, closeDb } = require('../db/database'); const { getDb, closeDb } = require('../db/database');
const { getTracker } = require('../services/trackerService'); const { getTracker } = require('../services/trackerService.cts');
function createUser(db, suffix) { function createUser(db, suffix) {
return db.prepare(` return db.prepare(`

View File

@ -18,7 +18,7 @@ const dbPath = path.join(os.tmpdir(), `bill-tracker-recon-${process.pid}.sqlite`
process.env.DB_PATH = dbPath; process.env.DB_PATH = dbPath;
const { getDb, closeDb } = require('../db/database'); const { getDb, closeDb } = require('../db/database');
const { getTracker } = require('../services/trackerService'); const { getTracker } = require('../services/trackerService.cts');
const { getAnalyticsSummary } = require('../services/analyticsService.cts'); const { getAnalyticsSummary } = require('../services/analyticsService.cts');
const { resolveDueDate } = require('../services/statusService.cts'); const { resolveDueDate } = require('../services/statusService.cts');
const summaryRouter = require('../routes/summary'); const summaryRouter = require('../routes/summary');

View File

@ -1,7 +1,7 @@
const test = require('node:test'); const test = require('node:test');
const assert = require('node:assert/strict'); const assert = require('node:assert/strict');
const { buildSafeToSpend } = require('../services/trackerService'); const { buildSafeToSpend } = require('../services/trackerService.cts');
function row(overrides = {}) { function row(overrides = {}) {
return { return {

View File

@ -15,7 +15,7 @@ const dbPath = path.join(os.tmpdir(), `bill-tracker-trackersvc-${process.pid}.sq
process.env.DB_PATH = dbPath; process.env.DB_PATH = dbPath;
const { getDb, closeDb } = require('../db/database'); const { getDb, closeDb } = require('../db/database');
const { getTracker, getOverdueCount } = require('../services/trackerService'); const { getTracker, getOverdueCount } = require('../services/trackerService.cts');
// Fixed "now" so occurrence gating + statuses are deterministic (mid-June 2026). // Fixed "now" so occurrence gating + statuses are deterministic (mid-June 2026).
const JUNE_20 = new Date(2026, 5, 20, 12, 0, 0); const JUNE_20 = new Date(2026, 5, 20, 12, 0, 0);

View File

@ -9,7 +9,7 @@ process.env.DB_PATH = dbPath;
const { getDb, closeDb } = require('../db/database'); const { getDb, closeDb } = require('../db/database');
const { ensureManualDataSource } = require('../services/transactionService.cts'); const { ensureManualDataSource } = require('../services/transactionService.cts');
const { getTracker } = require('../services/trackerService'); const { getTracker } = require('../services/trackerService.cts');
const { const {
listMatchSuggestions, listMatchSuggestions,
rejectMatchSuggestion, rejectMatchSuggestion,