'use strict'; const { getDb } = require('../db/database'); const { buildTrackerRow, getCycleRange, resolveDueDate } = require('./statusService'); const { getUserSettings } = require('./userSettings'); const { computeBalanceDelta } = require('./billsService'); const { computeAmountSuggestion } = require('./amountSuggestionService'); function validateTrackerMonth(query = {}, now = new Date()) { 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, month) { return { year: month === 1 ? year - 1 : year, month: month === 1 ? 12 : month - 1, }; } function monthOffset(year, month, offset) { 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 }; } function fetchActiveBills(db, userId, orderBy = 'b.due_day ASC, b.name ASC') { return db.prepare(` SELECT b.*, c.name AS category_name FROM bills b LEFT JOIN categories c ON b.category_id = c.id AND c.deleted_at IS NULL WHERE b.active = 1 AND b.user_id = ? AND b.deleted_at IS NULL ORDER BY ${orderBy} `).all(userId); } function fetchMonthlyStates(db, billIds, year, month) { if (billIds.length === 0) return {}; const placeholders = billIds.map(() => '?').join(','); const rows = db.prepare(` SELECT bill_id, actual_amount, notes, is_skipped 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])); } function fetchPaymentsForBillCycle(db, bill, year, month) { 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 ORDER BY paid_date DESC `).all(bill.id, range.start, range.end); } function fetchPreviousMonthPaid(db, billIds, range) { 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 GROUP BY bill_id `).all(...billIds, range.start, range.end); return Object.fromEntries(rows.map(row => [row.bill_id, row.total_paid])); } function fetchDismissedSuggestions(db, userId, billIds, year, month) { 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)); } function rowDueAmount(row) { const amount = Number(row.actual_amount ?? row.expected_amount); return Number.isFinite(amount) ? amount : 0; } function rowPaidTowardDue(row) { const cappedPaid = Number(row.paid_toward_due); if (Number.isFinite(cappedPaid)) return cappedPaid; return Math.min(Number(row.total_paid) || 0, rowDueAmount(row)); } function applyAutopaySuggestions(db, bill, payments, mbs, year, month, todayStr, dismissedSuggestions) { 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 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 result = db.prepare(` INSERT INTO payments (bill_id, amount, paid_date, method, notes, balance_delta, payment_source) VALUES (?, ?, ?, ?, ?, ?, ?) `).run( bill.id, suggestedAmount, dueDate, 'autopay', 'Auto-marked paid on due date', balCalc?.balance_delta ?? null, 'manual', ); if (balCalc) { db.prepare("UPDATE bills SET current_balance = ?, updated_at=datetime('now') WHERE id=?") .run(balCalc.new_balance, bill.id); 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(result.lastInsertRowid)); return null; } if (dismissedSuggestions.has(bill.id)) return null; return { bill_id: bill.id, amount: suggestedAmount, paid_date: dueDate, method: 'autopay', }; } function buildThreeMonthTrend(db, userId, year, month, end, currentMonthPaid) { 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 GROUP BY strftime('%Y-%m', p.paid_date) `).all(userId, threeMonthStart, end); const monthlyPaymentsMap = new Map(); rows.forEach(payment => { monthlyPaymentsMap.set(payment.month_key, payment.total_paid); }); const months = []; 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: parseFloat(monthlyPaymentsMap.get(monthKey) || 0), }); } const threeMonthAvg = months.reduce((sum, m) => sum + m.payment, 0) / 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, query = {}, now = new Date()) { const parsed = validateTrackerMonth(query, now); if (parsed.error) return parsed; const db = getDb(); const { year, month } = parsed; const todayStr = now.toISOString().slice(0, 10); const userSettings = getUserSettings(userId); const rowOptions = { gracePeriodDays: userSettings.grace_period_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 => bill.id); const monthlyStates = fetchMonthlyStates(db, billIds, year, month); const prevMonthPayments = fetchPreviousMonthPaid(db, billIds, prevMonthRange); const dismissedSuggestions = fetchDismissedSuggestions(db, userId, billIds, year, month); const rows = bills.map(bill => { if (!resolveDueDate(bill, year, month)) return null; const payments = fetchPaymentsForBillCycle(db, bill, year, month); 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 = bill.expected_amount; row.actual_amount = mbs?.actual_amount ?? null; row.monthly_notes = mbs?.notes ?? null; row.is_skipped = !!(mbs?.is_skipped); if (autopaySuggestion) row.autopay_suggestion = autopaySuggestion; row.previous_month_paid = prevMonthPayments[bill.id] || 0; row.amount_suggestion = computeAmountSuggestion(db, bill.id, year, month); return row; }).filter(Boolean); const activeRows = rows.filter(r => !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); const dayOfMonth = now.getDate(); const activeRemainingPeriod = dayOfMonth < 15 ? '1st' : '15th'; const periodRows = activeRows.filter(r => r.bucket === activeRemainingPeriod); const periodPaidTowardDue = periodRows.reduce((s, r) => s + rowPaidTowardDue(r), 0); const periodOutstandingBalance = periodRows.reduce((s, r) => s + Math.max(r.balance || 0, 0), 0); const periodStartingAmount = activeRemainingPeriod === '1st' ? (startingAmounts?.first_amount || 0) : (startingAmounts?.fifteenth_amount || 0); const periodLabel = activeRemainingPeriod === '1st' ? '1st balance' : '15th balance'; const totalStarting = startingAmounts?.combined_amount || 0; const hasStartingAmounts = !!startingAmounts; const activeTotalPaid = activeRows.reduce((s, r) => s + r.total_paid, 0); const activePaidTowardDue = activeRows.reduce((s, r) => s + rowPaidTowardDue(r), 0); const activeTotalExpected = activeRows.reduce((s, r) => s + rowDueAmount(r), 0); const activeOutstandingBalance = activeRows.reduce((s, r) => s + Math.max(r.balance || 0, 0), 0); const totalOverdue = rows .filter(r => !r.is_skipped && (r.status === 'late' || r.status === 'missed')) .reduce((s, r) => s + r.balance, 0); const previousMonthTotal = activeRows.reduce((s, r) => s + r.previous_month_paid, 0); return { year, month, today: todayStr, summary: { total_expected: activeTotalExpected, total_starting: totalStarting, has_starting_amounts: hasStartingAmounts, total_paid: activeTotalPaid, paid_toward_due: activePaidTowardDue, remaining: hasStartingAmounts ? periodStartingAmount - periodPaidTowardDue : periodOutstandingBalance, total_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 => 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, previous_month_total: previousMonthTotal, trend: buildThreeMonthTrend(db, userId, year, month, end, activeTotalPaid), }, rows, }; } function getUpcomingBills(userId, query = {}, now = new Date()) { const db = getDb(); const days = Math.max(1, Math.min(parseInt(query.days || '30', 10) || 30, 365)); const todayStr = now.toISOString().slice(0, 10); const userSettings = getUserSettings(userId); const rowOptions = { gracePeriodDays: userSettings.grace_period_days }; const bills = fetchActiveBills(db, userId, 'b.id ASC'); const cutoff = new Date(now); cutoff.setDate(cutoff.getDate() + days); const cutoffStr = cutoff.toISOString().slice(0, 10); const upcoming = []; 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: bill.expected_amount, status: row.status, days_until_due: Math.floor((new Date(`${dueDate}T00:00:00`) - new Date(`${todayStr}T00:00:00`)) / 86400000), }); } } upcoming.sort((a, b) => a.due_date.localeCompare(b.due_date)); return { days, today: todayStr, upcoming }; } module.exports = { getTracker, getUpcomingBills, validateTrackerMonth, };