'use strict'; import type { Db } from '../types/db'; const { getDb } = require('../db/database.cts'); const { log } = require('../utils/logger.cts'); const { buildTrackerRow, getCycleRange, resolveDueDate, isPaidStatus, } = require('./statusService.cts'); const { getUserSettings } = require('./userSettings.cts'); const { computeBalanceDelta, applyBalanceDelta } = require('./billsService.cts'); const { computeAmountSuggestionsBatch } = require('./amountSuggestionService.cts'); const { accountingActiveSql } = require('./paymentAccountingService.cts'); const { normalizeMerchant } = require('./subscriptionService.cts'); 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: any, ruleMerchant: any): boolean { if (!txNorm || !ruleMerchant) return false; if (txNorm === ruleMerchant) return true; 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); } function fetchBankPendingCounts(db: Db, userId: number, billIds: any[]): Record { if (billIds.length === 0) return {}; const ph = billIds.map(() => '?').join(','); const rules = db .prepare( ` SELECT bill_id, merchant FROM bill_merchant_rules WHERE user_id = ? AND bill_id IN (${ph}) `, ) .all(userId, ...billIds); if (rules.length === 0) return {}; const pendingTxs = db .prepare( ` SELECT t.payee, t.description, t.memo FROM transactions t LEFT JOIN financial_accounts fa ON fa.id = t.account_id WHERE t.user_id = ? AND t.pending = 1 AND t.amount < 0 AND t.ignored = 0 AND (t.account_id IS NULL OR fa.id IS NULL OR fa.monitored = 1) `, ) .all(userId); if (pendingTxs.length === 0) return {}; 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: 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: string) => txMerchantMatches(txNorm, m))) { counts[billId] = (counts[billId] || 0) + 1; } } } return counts; } 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 }; const accountId = parseInt(settings.bank_tracking_account_id, 10); if (!Number.isInteger(accountId) || accountId < 1) return { enabled: false }; const account = db .prepare( ` SELECT id, name, org_name, balance, available_balance, updated_at FROM financial_accounts WHERE id = ? AND user_id = ? `, ) .get(accountId, userId); if (!account || account.balance === null) return { enabled: false }; const pendingDays = parseInt(settings.bank_tracking_pending_days, 10); const days = Number.isInteger(pendingDays) && pendingDays >= 0 ? pendingDays : DEFAULT_PENDING_DAYS; const pendingRow = days > 0 ? db .prepare( ` SELECT COALESCE(SUM(p.amount), 0) AS pending_total FROM payments p JOIN bills b ON b.id = p.bill_id WHERE b.user_id = ? AND p.paid_date >= date('now', '-' || ? || ' days') AND p.paid_date <= date('now') AND b.deleted_at IS NULL AND ${accountingActiveSql('p')} AND (p.payment_source IS NULL OR p.payment_source NOT IN ('provider_sync', 'transaction_match', 'auto_match')) `, ) .get(userId, days) : { pending_total: 0 }; let unpaid; if (gatedUnpaidThisMonth != null) { unpaid = roundMoney(gatedUnpaidThisMonth); } else { const { start, end } = getCycleRange(year, month); const unpaidRow = db .prepare( ` SELECT COALESCE(SUM( CASE WHEN m.actual_amount IS NOT NULL THEN m.actual_amount ELSE COALESCE(b.expected_amount, 0) END ), 0) AS unpaid_total FROM bills b LEFT JOIN monthly_bill_state m ON m.bill_id = b.id AND m.year = ? AND m.month = ? LEFT JOIN ( SELECT bill_id, SUM(amount) AS paid_sum FROM payments WHERE paid_date BETWEEN ? AND ? AND deleted_at IS NULL AND ${accountingActiveSql()} GROUP BY bill_id ) pay ON pay.bill_id = b.id WHERE b.user_id = ? AND b.active = 1 AND b.deleted_at IS NULL AND COALESCE(m.is_skipped, 0) = 0 AND COALESCE(pay.paid_sum, 0) = 0 `, ) .get(year, month, start, end, userId); unpaid = fromCents(unpaidRow.unpaid_total); } const balance = fromCents(account.balance); const pending = fromCents(pendingRow.pending_total); const effective = roundMoney(balance - pending); return { enabled: true, account_id: account.id, account_name: account.name, org_name: account.org_name, balance, pending_payments: pending, pending_days: days, effective_balance: effective, unpaid_this_month: unpaid, remaining: roundMoney(effective - unpaid), last_updated: account.updated_at, }; } catch (err: any) { log.error('[buildBankTracking] Error computing bank tracking data:', err.message); return { enabled: false }; } } 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); if (Number.isNaN(year) || year < 2000 || year > 2100) { return { error: 'year must be a 4-digit integer between 2000 and 2100', status: 400 }; } if (Number.isNaN(month) || month < 1 || month > 12) { return { error: 'month must be an integer between 1 and 12', status: 400 }; } return { year, month }; } function previousMonthFor(year: number, month: number) { return { year: month === 1 ? year - 1 : year, month: month === 1 ? 12 : month - 1, }; } function monthOffset(year: number, month: number, offset: number) { let y = year; let m = month + offset; while (m <= 0) { m += 12; y -= 1; } while (m > 12) { m -= 12; y += 1; } return { year: y, month: m }; } 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: 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, CASE WHEN mr.bill_id IS NOT NULL THEN 1 ELSE 0 END AS has_merchant_rule, CASE WHEN lt.matched_bill_id IS NOT NULL THEN 1 ELSE 0 END AS has_linked_transactions FROM bills b LEFT JOIN categories c ON b.category_id = c.id AND c.deleted_at IS NULL LEFT JOIN (SELECT DISTINCT bill_id FROM bill_merchant_rules) mr ON mr.bill_id = b.id LEFT JOIN (SELECT DISTINCT matched_bill_id FROM transactions WHERE match_status = 'matched') lt ON lt.matched_bill_id = b.id WHERE b.active = 1 AND b.user_id = ? AND b.deleted_at IS NULL ORDER BY ${orderBy} `, ) .all(userId); } 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( ` SELECT bill_id, actual_amount, notes, is_skipped, snoozed_until FROM monthly_bill_state WHERE bill_id IN (${placeholders}) AND year = ? AND month = ? `, ) .all(...billIds, year, month); return Object.fromEntries(rows.map((row: Row) => [row.bill_id, row])); } function fetchPaymentsForBillCycle(db: Db, bill: Row, year: number, month: number): any[] { const range = getCycleRange(year, month, bill); if (!range) return []; return db .prepare( ` SELECT bill_id, id, amount, paid_date, method, notes, payment_source, transaction_id, created_at, updated_at FROM payments WHERE bill_id = ? AND paid_date BETWEEN ? AND ? AND deleted_at IS NULL AND ${accountingActiveSql()} ORDER BY paid_date DESC `, ) .all(bill.id, range.start, range.end); } 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; ranges.set(bill.id, range); if (minStart === null || range.start < minStart) minStart = range.start; if (maxEnd === null || range.end > maxEnd) maxEnd = range.end; } const byBill = new Map(); if (minStart === null) return byBill; const ids = [...ranges.keys()]; const placeholders = ids.map(() => '?').join(','); const rows = db .prepare( ` SELECT bill_id, id, amount, paid_date, method, notes, payment_source, transaction_id, created_at, updated_at FROM payments WHERE bill_id IN (${placeholders}) AND paid_date BETWEEN ? AND ? AND deleted_at IS NULL AND ${accountingActiveSql()} ORDER BY paid_date DESC `, ) .all(...ids, minStart, maxEnd); for (const row of rows) { const range = ranges.get(row.bill_id); // The union window may be wider than this bill's own cycle — filter to it. if (row.paid_date < range.start || row.paid_date > range.end) continue; if (!byBill.has(row.bill_id)) byBill.set(row.bill_id, []); byBill.get(row.bill_id).push(row); } return byBill; } function fetchPreviousMonthPaid(db: Db, billIds: any[], range: any): Record { if (billIds.length === 0) return {}; const placeholders = billIds.map(() => '?').join(','); const rows = db .prepare( ` SELECT bill_id, SUM(amount) as total_paid FROM payments WHERE bill_id IN (${placeholders}) AND paid_date BETWEEN ? AND ? AND deleted_at IS NULL AND ${accountingActiveSql()} GROUP BY bill_id `, ) .all(...billIds, range.start, range.end); return Object.fromEntries(rows.map((row: Row) => [row.bill_id, row.total_paid])); } 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) => row.bill_id)); } function fetchSparklines(db: Db, billIds: any[]): Record { if (billIds.length === 0) return {}; const ph = billIds.map(() => '?').join(','); const rows = db .prepare( ` SELECT bill_id, substr(paid_date, 1, 7) AS month_str, SUM(amount) AS total FROM payments WHERE bill_id IN (${ph}) AND paid_date >= date('now', '-6 months') AND deleted_at IS NULL AND accounting_excluded = 0 GROUP BY bill_id, month_str ORDER BY bill_id, month_str `, ) .all(...billIds); 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)); } return out; } function fetchAutopayStats(db: Db, billIds: any[]): Record { if (billIds.length === 0) return {}; const ph = billIds.map(() => '?').join(','); const rows = db .prepare( ` SELECT p.bill_id, COUNT(*) AS total, SUM(p.autopay_failure) AS failures, MAX(CASE WHEN p.autopay_failure = 1 THEN p.paid_date END) AS last_failure_date, MAX(CASE WHEN p.autopay_failure = 1 THEN p.notes END) AS last_failure_notes FROM payments p JOIN bills b ON b.id = p.bill_id WHERE p.bill_id IN (${ph}) AND b.autopay_enabled = 1 AND p.deleted_at IS NULL AND p.paid_date >= date('now', '-12 months') GROUP BY p.bill_id `, ) .all(...billIds); 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, last_failure_notes: r.last_failure_notes || null, }, ]), ); } function rowDueAmount(row: Row): number { const amount = Number(row.actual_amount ?? row.expected_amount); return Number.isFinite(amount) ? amount : 0; } 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: Row): number { return Math.max(Number(row.balance) || 0, 0); } /** * Safe-to-spend projection for the current 1st/15th pay period. Pure (no DB). */ 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`; const daysUntilPayday = Math.max( 0, Math.round((Date.parse(nextPayday) - Date.parse(todayStr)) / 86400000), ); const stillDueRows = activeRows .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); 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: 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: Row) => b.amount)); timeline.push({ date, balance: running, bills }); } if (timeline.length === 0 || timeline[timeline.length - 1].date !== nextPayday) { timeline.push({ date: nextPayday, balance: running, bills: [], payday: true }); } return { next_payday: nextPayday, days_until_payday: daysUntilPayday, available: roundMoney(available), safe_to_spend: safeToSpend, still_due_total: stillDueTotal, still_due_count: stillDueRows.length, upcoming: stillDueRows.slice(0, 8).map((r: Row) => ({ id: r.id, name: r.name, due_date: r.due_date, amount: rowOutstanding(r), status: r.status, })), timeline, }; } 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; const suggestedAmount = Number(mbs?.actual_amount ?? bill.expected_amount); const hasSuggestedAmount = Number.isFinite(suggestedAmount) && suggestedAmount > 0; const isEligible = !!( bill.autopay_enabled && bill.autodraft_status === 'assumed_paid' && hasSuggestedAmount && dueDate <= todayStr && !mbs?.is_skipped && payments.length === 0 ); if (!isEligible) return null; if (bill.auto_mark_paid) { const range = getCycleRange(year, month, bill); const existingPayment = db .prepare( ` SELECT bill_id, id, amount, paid_date, method, notes, payment_source, transaction_id, created_at, updated_at FROM payments WHERE bill_id = ? AND deleted_at IS NULL AND ${accountingActiveSql()} AND paid_date BETWEEN ? AND ? ORDER BY paid_date DESC LIMIT 1 `, ) .get(bill.id, range.start, range.end); if (existingPayment) { payments.push(existingPayment); return null; } const balCalc = computeBalanceDelta(bill, suggestedAmount); const insertAutoPayment = db.transaction(() => { const r = db .prepare( ` INSERT INTO payments (bill_id, amount, paid_date, method, notes, balance_delta, interest_delta, payment_source) VALUES (?, ?, ?, ?, ?, ?, ?, ?) `, ) .run( bill.id, suggestedAmount, dueDate, 'autopay', 'Auto-marked paid on due date', balCalc?.balance_delta ?? null, balCalc?.interest_delta ?? null, 'manual', ); if (balCalc) applyBalanceDelta(db, bill.id, balCalc); return r.lastInsertRowid; }); const paymentId = insertAutoPayment(); if (balCalc) bill.current_balance = balCalc.new_balance; payments.push( db .prepare( ` SELECT bill_id, id, amount, paid_date, method, notes, payment_source, transaction_id, created_at, updated_at FROM payments WHERE id = ? `, ) .get(paymentId), ); return null; } if (dismissedSuggestions.has(bill.id)) return null; return { bill_id: bill.id, amount: fromCents(suggestedAmount), paid_date: dueDate, method: 'autopay', }; } 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( ` SELECT SUM(p.amount) as total_paid, strftime('%Y-%m', p.paid_date) as month_key FROM payments p JOIN bills b ON p.bill_id = b.id WHERE b.user_id = ? AND b.deleted_at IS NULL AND p.paid_date BETWEEN ? AND ? AND p.deleted_at IS NULL AND ${accountingActiveSql('p')} GROUP BY strftime('%Y-%m', p.paid_date) `, ) .all(userId, threeMonthStart, end); const monthlyPaymentsMap = new Map(); rows.forEach((payment: Row) => { monthlyPaymentsMap.set(payment.month_key, payment.total_paid); }); 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')}`; months.push({ year: date.getFullYear(), month: date.getMonth() + 1, key: monthKey, payment: fromCents(monthlyPaymentsMap.get(monthKey) || 0), }); } const threeMonthAvg = sumMoney(months, (m: Row) => m.payment) / 3; let percentChange = 0; let direction = 'flat'; if (threeMonthAvg > 0) { percentChange = ((currentMonthPaid - threeMonthAvg) / threeMonthAvg) * 100; if (percentChange > 2) direction = 'up'; else if (percentChange < -2) direction = 'down'; } return { three_month_avg: parseFloat(threeMonthAvg.toFixed(2)), current_month_paid: parseFloat(currentMonthPaid.toFixed(2)), percent_change: parseFloat(percentChange.toFixed(1)), direction, }; } function getTracker(userId: number, query: any = {}, now: Date = new Date()): any { const parsed = validateTrackerMonth(query, now); if (parsed.error) return parsed; const db: Db = getDb(); const { year, month } = parsed; const todayStr = localDateString(now); const userSettings = getUserSettings(userId); const rowOptions = { gracePeriodDays: userSettings.grace_period_days, dueSoonDays: userSettings.due_soon_days, }; const { start, end } = getCycleRange(year, month); const previousMonth = previousMonthFor(year, month); const prevMonthRange = getCycleRange(previousMonth.year, previousMonth.month); const bills = fetchActiveBills(db, userId); 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); const paymentsByBill = fetchPaymentsForBillsInMonth(db, bills, year, month); const amountSuggestions = computeAmountSuggestionsBatch(db, billIds, year, month); 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; const payments = paymentsByBill.get(bill.id) || []; const mbs = monthlyStates[bill.id]; const autopaySuggestion = applyAutopaySuggestions( db, bill, payments, mbs, year, month, todayStr, dismissedSuggestions, ); const billForStatus = mbs?.actual_amount != null ? { ...bill, expected_amount: mbs.actual_amount } : bill; const row = buildTrackerRow(billForStatus, payments, year, month, todayStr, rowOptions); if (!row) return null; row.expected_amount = fromCents(bill.expected_amount); row.actual_amount = mbs?.actual_amount != null ? fromCents(mbs.actual_amount) : null; row.monthly_notes = mbs?.notes ?? null; row.is_skipped = !!mbs?.is_skipped; row.snoozed_until = mbs?.snoozed_until ?? null; if (autopaySuggestion) row.autopay_suggestion = autopaySuggestion; row.previous_month_paid = fromCents(prevMonthPayments[bill.id] || 0); row.amount_suggestion = amountSuggestions.get(bill.id) ?? null; return row; }) .filter(Boolean); 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, COALESCE(other_amount, 0) AS other_amount, COALESCE(first_amount, 0) + COALESCE(fifteenth_amount, 0) + COALESCE(other_amount, 0) AS combined_amount FROM monthly_starting_amounts WHERE user_id = ? AND year = ? AND month = ? `, ) .get(userId, year, month); if (startingAmounts) { startingAmounts.first_amount = fromCents(startingAmounts.first_amount); startingAmounts.fifteenth_amount = fromCents(startingAmounts.fifteenth_amount); startingAmounts.other_amount = fromCents(startingAmounts.other_amount); startingAmounts.combined_amount = fromCents(startingAmounts.combined_amount); } const dayOfMonth = now.getDate(); const activeRemainingPeriod = dayOfMonth < 15 ? '1st' : '15th'; const periodRows = activeRows.filter((r: Row) => r.bucket === activeRemainingPeriod); const periodPaidTowardDue = sumMoney(periodRows, rowPaidTowardDue); const periodOutstandingBalance = sumMoney(periodRows, rowOutstanding); const periodStartingAmount = activeRemainingPeriod === '1st' ? startingAmounts?.first_amount || 0 : startingAmounts?.fifteenth_amount || 0; const periodLabel = activeRemainingPeriod === '1st' ? '1st balance' : '15th balance'; 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 ? bankTracking.effective_balance : startingAmounts?.combined_amount || 0; const hasStartingAmounts = bankTracking.enabled || !!startingAmounts; 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: 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: Row) => isPaidStatus(r.status)).length; const periodTotalCount = periodRows.length; const periodCashStart = bankTracking.enabled ? bankTracking.effective_balance : periodStartingAmount; const periodProjected = roundMoney(periodCashStart - periodBillsTotal); const monthBillsTotal = activeTotalExpected; const monthPaidCount = activeRows.filter((r: Row) => isPaidStatus(r.status)).length; const monthTotalCount = activeRows.length; const monthProjected = roundMoney(totalStarting - monthBillsTotal); const availableNow = bankTracking.enabled ? bankTracking.effective_balance : roundMoney(periodStartingAmount - periodPaidTowardDue); const safeToSpend = buildSafeToSpend({ activeRows, available: availableNow, todayStr, year, month, dayOfMonth, }); const cashflow = { ...safeToSpend, has_data: hasStartingAmounts, uses_bank_balance: bankTracking.enabled, period: activeRemainingPeriod, period_end_label: periodEndLabel, period_starting: periodCashStart, period_bills_total: periodBillsTotal, period_paid: periodPaidTowardDue, period_paid_count: periodPaidCount, period_total_count: periodTotalCount, period_projected: periodProjected, month_starting: totalStarting, month_bills_total: monthBillsTotal, month_paid: roundMoney(activePaidTowardDue), month_paid_count: monthPaidCount, month_total_count: monthTotalCount, month_projected: monthProjected, }; const totalOverdue = sumMoney( 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, month, today: todayStr, summary: { total_expected: activeTotalExpected, total_starting: totalStarting, has_starting_amounts: hasStartingAmounts, total_paid: activeTotalPaid, paid_toward_due: activePaidTowardDue, remaining: roundMoney( bankTracking.enabled ? bankTracking.remaining : hasStartingAmounts ? periodStartingAmount - periodPaidTowardDue : periodOutstandingBalance, ), total_remaining: roundMoney( bankTracking.enabled ? bankTracking.remaining : hasStartingAmounts ? totalStarting - activePaidTowardDue : activeOutstandingBalance, ), remaining_period: activeRemainingPeriod, remaining_label: periodLabel, remaining_hint: hasStartingAmounts ? `${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: 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: Row) => { const bank_pending_count = bankPendingCounts[r.id] || 0; const isManualPayment = r.payment_source !== 'provider_sync' && r.payment_source !== 'transaction_match'; const isBankLinked = !!(r.has_merchant_rule || r.has_linked_transactions); if (r.status === 'paid' && r.last_paid_date && isManualPayment && isBankLinked) { const cutoff = new Date(); cutoff.setDate(cutoff.getDate() - bankTracking.pending_days); const paidAt = new Date(r.last_paid_date); return { ...r, pending_cleared: paidAt >= cutoff, bank_pending_count }; } return { ...r, pending_cleared: false, bank_pending_count }; }) : rows, }; } 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); const rowOptions = { gracePeriodDays: userSettings.grace_period_days, dueSoonDays: userSettings.due_soon_days, }; const bills = fetchActiveBills(db, userId, 'id'); const cutoff = new Date(now); cutoff.setDate(cutoff.getDate() + days); const cutoffStr = localDateString(cutoff); const upcoming: any[] = []; const seen = new Set(); const monthCount = (cutoff.getFullYear() - now.getFullYear()) * 12 + (cutoff.getMonth() - now.getMonth()) + 1; for (let offset = 0; offset < monthCount; offset += 1) { const target = monthOffset(now.getFullYear(), now.getMonth() + 1, offset); for (const bill of bills) { const dueDate = resolveDueDate(bill, target.year, target.month); if (!dueDate || dueDate < todayStr || dueDate > cutoffStr) continue; const key = `${bill.id}:${dueDate}`; if (seen.has(key)) continue; seen.add(key); const row = buildTrackerRow( bill, fetchPaymentsForBillCycle(db, bill, target.year, target.month), target.year, target.month, todayStr, rowOptions, ); if (!row || row.status === 'paid') continue; upcoming.push({ id: bill.id, name: bill.name, category_name: bill.category_name, due_date: dueDate, expected_amount: row.expected_amount, status: row.status, days_until_due: Math.floor( (new Date(`${dueDate}T00:00:00`).getTime() - new Date(`${todayStr}T00:00:00`).getTime()) / 86400000, ), }); } } upcoming.sort((a: Row, b: Row) => a.due_date.localeCompare(b.due_date)); return { days, today: todayStr, upcoming }; } 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; const monthStr = String(month).padStart(2, '0'); const rangeStart = `${year}-${monthStr}-01`; const lastDay = new Date(year, month, 0).getDate(); const rangeEnd = `${year}-${monthStr}-${String(lastDay).padStart(2, '0')}`; const bills = db .prepare( ` SELECT b.id, b.name, b.due_day, b.override_due_date, b.expected_amount, b.billing_cycle, b.cycle_type, b.cycle_day, b.autopay_enabled, b.autodraft_status, mbs.actual_amount, mbs.is_skipped, mbs.snoozed_until, COALESCE(SUM(p.amount), 0) AS total_paid FROM bills b LEFT JOIN monthly_bill_state mbs ON mbs.bill_id = b.id AND mbs.year = ? AND mbs.month = ? LEFT JOIN payments p ON p.bill_id = b.id AND p.paid_date BETWEEN ? AND ? AND p.deleted_at IS NULL AND ${accountingActiveSql('p')} WHERE b.user_id = ? AND b.active = 1 AND b.deleted_at IS NULL GROUP BY b.id `, ) .all(year, month, rangeStart, rangeEnd, userId); let count = 0; 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); if (!dueDate || dueDate >= todayStr) continue; const threshold = bill.actual_amount != null ? bill.actual_amount : bill.expected_amount; if (threshold > 0 && bill.total_paid >= threshold) continue; count++; overdueNames.push(bill.name); } return { count, names: overdueNames, month, year, today: todayStr }; } module.exports = { getTracker, getUpcomingBills, validateTrackerMonth, getOverdueCount, buildSafeToSpend, };