const { getSetting } = require('../db/database'); /** * Resolves the due date for a bill in a given year/month. * Bills use a recurring day-of-month template field. Legacy override_due_date * values are intentionally ignored by the current product behavior. */ function resolveDueDate(bill, year, month) { const daysInMonth = new Date(year, month, 0).getDate(); const day = Math.min(bill.due_day, daysInMonth); return `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`; } /** * Auto-assigns bucket from due_day: 1–14 → '1st', 15+ → '15th' */ function resolveBucket(bill) { if (bill.bucket) return bill.bucket; return bill.due_day <= 14 ? '1st' : '15th'; } /** * Computes the payment cycle start/end for a bill in a given month. * For monthly bills the cycle is the calendar month. */ function getCycleRange(year, month) { const start = `${year}-${String(month).padStart(2, '0')}-01`; const daysInMonth = new Date(year, month, 0).getDate(); const end = `${year}-${String(month).padStart(2, '0')}-${String(daysInMonth).padStart(2, '0')}`; return { start, end }; } /** * Returns status for a bill given its payments and due date. * * Statuses: * paid — has a non-deleted payment in this billing cycle * — OR total paid >= expected_amount (fully settled) * autodraft — autopay_enabled and assumed_paid (no confirmed payment yet) * upcoming — due_date in the future * due_soon — due within 3 days * late — past due, within grace period * missed — past grace period, unpaid */ function calculateStatus(bill, payments, dueDate, today) { const gracePeriodDays = parseInt(getSetting('grace_period_days') || '5', 10); const safePayments = Array.isArray(payments) ? payments : []; const totalPaid = safePayments.reduce((sum, p) => sum + p.amount, 0); // A recorded payment is the user's confirmation that this cycle is handled. // Expected amounts are estimates, so a lower actual payment must not leave a Pay // button visible and invite duplicate payments. if (safePayments.length > 0 || totalPaid >= bill.expected_amount) return 'paid'; if (bill.autopay_enabled && bill.autodraft_status === 'assumed_paid') { return 'autodraft'; } const due = new Date(dueDate + 'T00:00:00'); const todayDate = new Date(today + 'T00:00:00'); const diffDays = Math.floor((due - todayDate) / 86400000); if (diffDays > 3) return 'upcoming'; if (diffDays >= 0) return 'due_soon'; if (Math.abs(diffDays) <= gracePeriodDays) return 'late'; return 'missed'; } /** * Builds a full tracker row for a bill in a given month. */ function buildTrackerRow(bill, payments, year, month, todayStr) { const dueDate = resolveDueDate(bill, year, month); const bucket = resolveBucket(bill); const safePayments = Array.isArray(payments) ? payments : []; const status = calculateStatus(bill, safePayments, dueDate, todayStr); const totalPaid = safePayments.reduce((sum, p) => sum + p.amount, 0); const hasPayment = safePayments.length > 0; const isSettled = status === 'paid' || status === 'autodraft'; const rawBalance = bill.expected_amount - totalPaid; const balance = isSettled ? 0 : Math.max(rawBalance, 0); const lastPayment = hasPayment ? [...safePayments].sort((a, b) => b.paid_date.localeCompare(a.paid_date))[0] : null; return { id: bill.id, name: bill.name, category_id: bill.category_id, category_name: bill.category_name || null, due_date: dueDate, due_day: bill.due_day, bucket, expected_amount: bill.expected_amount, notes: bill.notes || null, // Bill-level notes (always available) total_paid: totalPaid, balance, has_payment: hasPayment, is_settled: isSettled, last_paid_date: lastPayment ? lastPayment.paid_date : null, last_payment_amount: lastPayment ? lastPayment.amount : null, status, autopay_enabled: !!bill.autopay_enabled, autodraft_status: bill.autodraft_status, billing_cycle: bill.billing_cycle, payments: safePayments, }; } module.exports = { resolveDueDate, resolveBucket, getCycleRange, calculateStatus, buildTrackerRow };