diff --git a/routes/tracker.js b/routes/tracker.js index 2879c0e..36231a7 100644 --- a/routes/tracker.js +++ b/routes/tracker.js @@ -1,6 +1,6 @@ const express = require('express'); const router = express.Router(); -const { getTracker, getUpcomingBills, getOverdueCount } = require('../services/trackerService'); +const { getTracker, getUpcomingBills, getOverdueCount } = require('../services/trackerService.cts'); const { standardizeError } = require('../middleware/errorFormatter'); // GET /api/tracker/overdue-count — lightweight count for sidebar badge diff --git a/services/trackerService.js b/services/trackerService.cts similarity index 76% rename from services/trackerService.js rename to services/trackerService.cts index 34036ec..1c786f9 100644 --- a/services/trackerService.js +++ b/services/trackerService.cts @@ -1,5 +1,7 @@ 'use strict'; +import type { Db } from '../types/db'; + const { getDb } = require('../db/database'); const { buildTrackerRow, getCycleRange, resolveDueDate, isPaidStatus } = require('./statusService.cts'); const { getUserSettings } = require('./userSettings.cts'); @@ -8,22 +10,24 @@ const { computeAmountSuggestionsBatch } = require('./amountSuggestionService.cts const { accountingActiveSql } = require('./paymentAccountingService.cts'); const { normalizeMerchant } = require('./subscriptionService'); 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'); +type Row = Record; + const DEFAULT_PENDING_DAYS = 3; // 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 true; - const esc = s => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - const wb = s => new RegExp(`(^|\\s)${esc(s)}(\\s|$)`); + const esc = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const wb = (s: string) => new RegExp(`(^|\\s)${esc(s)}(\\s|$)`); return wb(ruleMerchant).test(txNorm) || wb(txNorm).test(ruleMerchant); } -// For bills that have merchant rules, count how many of that user's pending bank -// transactions match each bill. Only bills with at least one rule are checked. -function fetchBankPendingCounts(db, userId, billIds) { +function fetchBankPendingCounts(db: Db, userId: number, billIds: any[]): Record { if (billIds.length === 0) return {}; const ph = billIds.map(() => '?').join(','); const rules = db.prepare(` @@ -45,18 +49,18 @@ function fetchBankPendingCounts(db, userId, billIds) { `).all(userId); if (pendingTxs.length === 0) return {}; - const rulesByBill = {}; + const rulesByBill: Record = {}; for (const rule of rules) { if (!rulesByBill[rule.bill_id]) rulesByBill[rule.bill_id] = []; rulesByBill[rule.bill_id].push(rule.merchant); } - const counts = {}; + const counts: Record = {}; for (const tx of pendingTxs) { const txNorm = normalizeMerchant(tx.payee || tx.description || tx.memo || ''); if (!txNorm) continue; 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; } } @@ -64,11 +68,7 @@ function fetchBankPendingCounts(db, userId, billIds) { return counts; } -// `gatedUnpaidThisMonth` (dollars) is the occurrence-gated unpaid total the -// 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) { +function buildBankTracking(db: Db, userId: number, year: number, month: number, gatedUnpaidThisMonth: any = null): any { try { const settings = getUserSettings(userId); 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 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 ? db.prepare(` SELECT COALESCE(SUM(p.amount), 0) AS pending_total @@ -100,9 +97,6 @@ function buildBankTracking(db, userId, year, month, gatedUnpaidThisMonth = null) `).get(userId, days) : { 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; if (gatedUnpaidThisMonth != null) { unpaid = roundMoney(gatedUnpaidThisMonth); @@ -145,13 +139,13 @@ function buildBankTracking(db, userId, year, month, gatedUnpaidThisMonth = null) remaining: roundMoney(effective - unpaid), last_updated: account.updated_at, }; - } catch (err) { + } catch (err: any) { console.error('[buildBankTracking] Error computing bank tracking data:', err.message); 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 month = parseInt(query.month || now.getMonth() + 1, 10); @@ -164,14 +158,14 @@ function validateTrackerMonth(query = {}, now = new Date()) { return { year, month }; } -function previousMonthFor(year, month) { +function previousMonthFor(year: number, month: number) { return { year: month === 1 ? year - 1 : year, month: month === 1 ? 12 : month - 1, }; } -function monthOffset(year, month, offset) { +function monthOffset(year: number, month: number, offset: number) { let y = year; let m = month + offset; while (m <= 0) { m += 12; y -= 1; } @@ -179,12 +173,12 @@ function monthOffset(year, month, offset) { return { year: y, month: m }; } -const FETCH_BILLS_ORDER = { +const FETCH_BILLS_ORDER: Record = { 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', }; -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; return db.prepare(` SELECT b.*, c.name AS category_name, @@ -199,7 +193,7 @@ function fetchActiveBills(db, userId, orderKey = 'due_day') { `).all(userId); } -function fetchMonthlyStates(db, billIds, year, month) { +function fetchMonthlyStates(db: Db, billIds: any[], year: number, month: number): Record { if (billIds.length === 0) return {}; const placeholders = billIds.map(() => '?').join(','); const rows = db.prepare(` @@ -207,10 +201,10 @@ function fetchMonthlyStates(db, billIds, year, month) { FROM monthly_bill_state WHERE bill_id IN (${placeholders}) AND year = ? AND 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); if (!range) return []; return db.prepare(` @@ -223,14 +217,10 @@ function fetchPaymentsForBillCycle(db, bill, year, month) { `).all(bill.id, range.start, range.end); } -// Batched form of fetchPaymentsForBillCycle: one query for every bill's cycle -// payments (grouped in JS by each bill's own range) instead of one query per -// bill. Returns Map with each list still ordered paid_date -// DESC — identical to calling fetchPaymentsForBillCycle per bill. -function fetchPaymentsForBillsInMonth(db, bills, year, month) { - const ranges = new Map(); // billId → { start, end } (only bills due this month) - let minStart = null; - let maxEnd = null; +function fetchPaymentsForBillsInMonth(db: Db, bills: any[], year: number, month: number): Map { + const ranges = new Map(); // billId → { start, end } (only bills due this month) + let minStart: any = null; + let maxEnd: any = null; for (const bill of bills) { const range = getCycleRange(year, month, bill); if (!range) continue; @@ -239,7 +229,7 @@ function fetchPaymentsForBillsInMonth(db, bills, year, month) { if (maxEnd === null || range.end > maxEnd) maxEnd = range.end; } - const byBill = new Map(); + const byBill = new Map(); if (minStart === null) return byBill; const ids = [...ranges.keys()]; @@ -263,7 +253,7 @@ function fetchPaymentsForBillsInMonth(db, bills, year, month) { return byBill; } -function fetchPreviousMonthPaid(db, billIds, range) { +function fetchPreviousMonthPaid(db: Db, billIds: any[], range: any): Record { if (billIds.length === 0) return {}; const placeholders = billIds.map(() => '?').join(','); const rows = db.prepare(` @@ -274,20 +264,20 @@ function fetchPreviousMonthPaid(db, billIds, range) { AND ${accountingActiveSql()} GROUP BY bill_id `).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 { if (billIds.length === 0) return new Set(); const rows = db.prepare(` SELECT bill_id FROM autopay_suggestion_dismissals WHERE user_id = ? AND year = ? AND 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 { if (billIds.length === 0) return {}; const ph = billIds.map(() => '?').join(','); const rows = db.prepare(` @@ -299,7 +289,7 @@ function fetchSparklines(db, billIds) { GROUP BY bill_id, month_str ORDER BY bill_id, month_str `).all(...billIds); - const out = {}; + const out: Record = {}; for (const r of rows) { if (!out[r.bill_id]) out[r.bill_id] = []; out[r.bill_id].push(fromCents(r.total)); @@ -307,7 +297,7 @@ function fetchSparklines(db, billIds) { return out; } -function fetchAutopayStats(db, billIds) { +function fetchAutopayStats(db: Db, billIds: any[]): Record { if (billIds.length === 0) return {}; const ph = billIds.map(() => '?').join(','); const rows = db.prepare(` @@ -325,7 +315,7 @@ function fetchAutopayStats(db, billIds) { AND p.paid_date >= date('now', '-12 months') GROUP BY p.bill_id `).all(...billIds); - return Object.fromEntries(rows.map(r => [r.bill_id, { + return Object.fromEntries(rows.map((r: Row) => [r.bill_id, { total: r.total, failures: r.failures || 0, 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); return Number.isFinite(amount) ? amount : 0; } -function rowPaidTowardDue(row) { +function rowPaidTowardDue(row: Row): number { const cappedPaid = Number(row.paid_toward_due); if (Number.isFinite(cappedPaid)) return cappedPaid; 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); } /** - * Safe-to-spend projection for the current 1st/15th pay period. - * - * 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. + * Safe-to-spend projection for the current 1st/15th pay period. Pure (no DB). */ -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 ? `${year}-${String(month).padStart(2, '0')}-15` : (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 stillDueRows = activeRows - .filter(r => !isPaidStatus(r.status)) - .filter(r => rowOutstanding(r) > 0) - .filter(r => r.due_date < nextPayday) - .sort((a, b) => a.due_date.localeCompare(b.due_date) || String(a.name).localeCompare(String(b.name))); + .filter((r: Row) => !isPaidStatus(r.status)) + .filter((r: Row) => rowOutstanding(r) > 0) + .filter((r: Row) => r.due_date < nextPayday) + .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 safeToSpend = roundMoney(available - stillDueTotal); - // Daily projection from today to payday: balance steps down as bills hit. - // Overdue bills land on "today" — they need to be paid now, not in the past. - const byDate = new Map(); + const byDate = new Map(); for (const r of stillDueRows) { const date = r.due_date > todayStr ? r.due_date : todayStr; if (!byDate.has(date)) byDate.set(date, []); byDate.get(date).push({ id: r.id, name: r.name, amount: rowOutstanding(r) }); } - const timeline = []; + const timeline: any[] = []; let running = roundMoney(available); if (!byDate.has(todayStr)) timeline.push({ date: todayStr, balance: running, bills: [] }); for (const date of [...byDate.keys()].sort()) { 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 }); } 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, still_due_total: stillDueTotal, still_due_count: stillDueRows.length, - upcoming: stillDueRows.slice(0, 8).map(r => ({ + upcoming: stillDueRows.slice(0, 8).map((r: Row) => ({ id: r.id, name: r.name, 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 { const dueDate = resolveDueDate(bill, year, month); if (!dueDate) return null; @@ -452,9 +430,6 @@ function applyAutopaySuggestions(db, bill, payments, mbs, year, month, todayStr, } 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 r = db.prepare(` 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 threeMonthStart = getCycleRange(threeMonthsAgo.year, threeMonthsAgo.month).start; const rows = db.prepare(` @@ -505,12 +480,12 @@ function buildThreeMonthTrend(db, userId, year, month, end, currentMonthPaid) { GROUP BY strftime('%Y-%m', p.paid_date) `).all(userId, threeMonthStart, end); - const monthlyPaymentsMap = new Map(); - rows.forEach(payment => { + const monthlyPaymentsMap = new Map(); + rows.forEach((payment: Row) => { monthlyPaymentsMap.set(payment.month_key, payment.total_paid); }); - const months = []; + const months: any[] = []; for (let i = 2; i >= 0; i--) { const date = new Date(year, month - 1 - i); 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 direction = 'flat'; 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); if (parsed.error) return parsed; - const db = getDb(); + const db: Db = getDb(); const { year, month } = parsed; const todayStr = localDateString(now); const userSettings = getUserSettings(userId); @@ -553,18 +528,16 @@ function getTracker(userId, query = {}, now = new Date()) { const prevMonthRange = getCycleRange(previousMonth.year, previousMonth.month); 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 prevMonthPayments = fetchPreviousMonthPaid(db, billIds, prevMonthRange); const dismissedSuggestions = fetchDismissedSuggestions(db, userId, billIds, year, month); const sparklines = fetchSparklines(db, billIds); const autopayStatsMap = fetchAutopayStats(db, billIds); - // Batched to avoid an N+1 across bills (was ~2–3 queries × N bills every load): - // one query for all cycle payments, two for all amount suggestions. const paymentsByBill = fetchPaymentsForBillsInMonth(db, bills, 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.autopay_stats = autopayStatsMap[bill.id] ?? 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 mbs = monthlyStates[bill.id]; const autopaySuggestion = applyAutopaySuggestions( - db, - bill, - payments, - mbs, - year, - month, - todayStr, - dismissedSuggestions, + db, bill, payments, mbs, year, month, todayStr, dismissedSuggestions, ); const billForStatus = mbs?.actual_amount != null @@ -599,7 +565,7 @@ function getTracker(userId, query = {}, now = new Date()) { return row; }).filter(Boolean); - const activeRows = rows.filter(r => !r.is_skipped); + const activeRows = rows.filter((r: Row) => !r.is_skipped); const startingAmounts = db.prepare(` SELECT COALESCE(first_amount, 0) AS first_amount, COALESCE(fifteenth_amount, 0) AS fifteenth_amount, @@ -618,7 +584,7 @@ function getTracker(userId, query = {}, now = new Date()) { const dayOfMonth = now.getDate(); 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 periodOutstandingBalance = sumMoney(periodRows, rowOutstanding); const periodStartingAmount = activeRemainingPeriod === '1st' @@ -626,11 +592,7 @@ function getTracker(userId, query = {}, now = new Date()) { : (startingAmounts?.fifteenth_amount || 0); const periodLabel = activeRemainingPeriod === '1st' ? '1st balance' : '15th balance'; - // Occurrence-gated unpaid total for this month: the still-owed amount across - // 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 activeMonthUnpaid = sumMoney(activeRows, (r: Row) => Math.max(rowDueAmount(r) - rowPaidTowardDue(r), 0)); const bankTracking = buildBankTracking(db, userId, year, month, activeMonthUnpaid); const bankPendingCounts = bankTracking.enabled ? fetchBankPendingCounts(db, userId, billIds) : {}; const totalStarting = bankTracking.enabled @@ -638,47 +600,34 @@ function getTracker(userId, query = {}, now = new Date()) { : (startingAmounts?.combined_amount || 0); 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 periodEndDay = activeRemainingPeriod === '1st' ? 14 : lastDayOfMonth; 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 activeTotalExpected = sumMoney(activeRows, rowDueAmount); const activeOutstandingBalance = sumMoney(activeRows, rowOutstanding); 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; - // When bank tracking is on use the effective balance as the period starting point const periodCashStart = bankTracking.enabled ? bankTracking.effective_balance : periodStartingAmount; const periodProjected = roundMoney(periodCashStart - periodBillsTotal); 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 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 ? bankTracking.effective_balance : roundMoney(periodStartingAmount - periodPaidTowardDue); const safeToSpend = buildSafeToSpend({ - activeRows, - available: availableNow, - todayStr, - year, - month, - dayOfMonth, + activeRows, available: availableNow, todayStr, year, month, dayOfMonth, }); const cashflow = { @@ -701,9 +650,9 @@ function getTracker(userId, query = {}, now = new Date()) { month_projected: monthProjected, }; const totalOverdue = sumMoney( - rows.filter(r => !r.is_skipped && (r.status === 'late' || r.status === 'missed')), - r => r.balance); - const previousMonthTotal = sumMoney(activeRows, r => r.previous_month_paid); + rows.filter((r: Row) => !r.is_skipped && (r.status === 'late' || r.status === 'missed')), + (r: Row) => r.balance); + const previousMonthTotal = sumMoney(activeRows, (r: Row) => r.previous_month_paid); return { year, @@ -715,11 +664,6 @@ function getTracker(userId, query = {}, now = new Date()) { has_starting_amounts: hasStartingAmounts, total_paid: activeTotalPaid, 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 ? bankTracking.remaining : (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}: unpaid bills due in this period`, overdue: totalOverdue, - count_paid: activeRows.filter(r => r.status === 'paid').length, - count_upcoming: activeRows.filter(r => r.status === 'upcoming' || r.status === 'due_soon').length, - count_late: activeRows.filter(r => r.status === 'late' || r.status === 'missed').length, - count_autodraft: activeRows.filter(r => r.status === 'autodraft').length, + count_paid: activeRows.filter((r: Row) => r.status === 'paid').length, + count_upcoming: activeRows.filter((r: Row) => r.status === 'upcoming' || r.status === 'due_soon').length, + count_late: activeRows.filter((r: Row) => r.status === 'late' || r.status === 'missed').length, + count_autodraft: activeRows.filter((r: Row) => r.status === 'autodraft').length, previous_month_total: previousMonthTotal, trend: buildThreeMonthTrend(db, userId, year, month, end, activeTotalPaid), }, bank_tracking: bankTracking, cashflow, rows: bankTracking.enabled - ? rows.map(r => { + ? rows.map((r: Row) => { 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'; - // 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); if (r.status === 'paid' && r.last_paid_date && isManualPayment && isBankLinked) { const cutoff = new Date(); @@ -762,8 +702,8 @@ function getTracker(userId, query = {}, now = new Date()) { }; } -function getUpcomingBills(userId, query = {}, now = new Date()) { - const db = getDb(); +function getUpcomingBills(userId: number, query: any = {}, now: Date = new Date()): any { + const db: Db = getDb(); const days = Math.max(1, Math.min(parseInt(query.days || '30', 10) || 30, 365)); const todayStr = localDateString(now); const userSettings = getUserSettings(userId); @@ -773,8 +713,8 @@ function getUpcomingBills(userId, query = {}, now = new Date()) { const cutoff = new Date(now); cutoff.setDate(cutoff.getDate() + days); const cutoffStr = localDateString(cutoff); - const upcoming = []; - const seen = new Set(); + const upcoming: any[] = []; + const seen = new Set(); const monthCount = (cutoff.getFullYear() - now.getFullYear()) * 12 + (cutoff.getMonth() - now.getMonth()) + 1; @@ -792,10 +732,7 @@ function getUpcomingBills(userId, query = {}, now = new Date()) { const row = buildTrackerRow( bill, fetchPaymentsForBillCycle(db, bill, target.year, target.month), - target.year, - target.month, - todayStr, - rowOptions, + target.year, target.month, todayStr, rowOptions, ); if (!row || row.status === 'paid') continue; @@ -806,17 +743,17 @@ function getUpcomingBills(userId, query = {}, now = new Date()) { due_date: dueDate, expected_amount: row.expected_amount, 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 }; } -function getOverdueCount(userId, now = new Date()) { - const db = getDb(); +function getOverdueCount(userId: number, now: Date = new Date()): any { + const db: Db = getDb(); const todayStr = localDateString(now); const year = now.getFullYear(); const month = now.getMonth() + 1; @@ -842,14 +779,13 @@ function getOverdueCount(userId, now = new Date()) { `).all(year, month, rangeStart, rangeEnd, userId); let count = 0; - const overdueNames = []; + const overdueNames: any[] = []; for (const bill of bills) { if (bill.is_skipped) continue; if (bill.snoozed_until && bill.snoozed_until > todayStr) continue; if (bill.autopay_enabled && bill.autodraft_status === 'assumed_paid') continue; 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; const threshold = bill.actual_amount != null ? bill.actual_amount : bill.expected_amount; diff --git a/tests/billReorder.test.js b/tests/billReorder.test.js index 0b467ae..7992162 100644 --- a/tests/billReorder.test.js +++ b/tests/billReorder.test.js @@ -8,7 +8,7 @@ const dbPath = path.join(os.tmpdir(), `bill-tracker-reorder-test-${process.pid}. process.env.DB_PATH = dbPath; const { getDb, closeDb } = require('../db/database'); -const { getTracker } = require('../services/trackerService'); +const { getTracker } = require('../services/trackerService.cts'); function createUser(db, suffix) { return db.prepare(` diff --git a/tests/reconciliation.test.js b/tests/reconciliation.test.js index 23714bf..92cd635 100644 --- a/tests/reconciliation.test.js +++ b/tests/reconciliation.test.js @@ -18,7 +18,7 @@ const dbPath = path.join(os.tmpdir(), `bill-tracker-recon-${process.pid}.sqlite` process.env.DB_PATH = dbPath; const { getDb, closeDb } = require('../db/database'); -const { getTracker } = require('../services/trackerService'); +const { getTracker } = require('../services/trackerService.cts'); const { getAnalyticsSummary } = require('../services/analyticsService.cts'); const { resolveDueDate } = require('../services/statusService.cts'); const summaryRouter = require('../routes/summary'); diff --git a/tests/safeToSpend.test.js b/tests/safeToSpend.test.js index e40fc1f..60358dc 100644 --- a/tests/safeToSpend.test.js +++ b/tests/safeToSpend.test.js @@ -1,7 +1,7 @@ const test = require('node:test'); const assert = require('node:assert/strict'); -const { buildSafeToSpend } = require('../services/trackerService'); +const { buildSafeToSpend } = require('../services/trackerService.cts'); function row(overrides = {}) { return { diff --git a/tests/trackerService.test.js b/tests/trackerService.test.js index 5dcd637..8e7a4b8 100644 --- a/tests/trackerService.test.js +++ b/tests/trackerService.test.js @@ -15,7 +15,7 @@ const dbPath = path.join(os.tmpdir(), `bill-tracker-trackersvc-${process.pid}.sq process.env.DB_PATH = dbPath; 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). const JUNE_20 = new Date(2026, 5, 20, 12, 0, 0); diff --git a/tests/transactionMatchService.test.js b/tests/transactionMatchService.test.js index f07f36b..5834a7b 100644 --- a/tests/transactionMatchService.test.js +++ b/tests/transactionMatchService.test.js @@ -9,7 +9,7 @@ process.env.DB_PATH = dbPath; const { getDb, closeDb } = require('../db/database'); const { ensureManualDataSource } = require('../services/transactionService.cts'); -const { getTracker } = require('../services/trackerService'); +const { getTracker } = require('../services/trackerService.cts'); const { listMatchSuggestions, rejectMatchSuggestion,