const { getSetting } = require('../db/database'); const WEEKDAY_INDEX = { sunday: 0, monday: 1, tuesday: 2, wednesday: 3, thursday: 4, friday: 5, saturday: 6, }; const BIWEEKLY_ANCHOR = new Date(Date.UTC(2000, 0, 3)); // Monday function resolveGracePeriodDays(value) { const parsed = parseInt(value ?? getSetting('grace_period_days') ?? '5', 10); return Number.isInteger(parsed) && parsed >= 0 ? parsed : 5; } function pad(value) { return String(value).padStart(2, '0'); } function roundMoney(value) { return Math.round((Number(value) || 0) * 100) / 100; } function dateString(year, month, day) { return `${year}-${pad(month)}-${pad(day)}`; } function dateFromString(value) { const [year, month, day] = String(value).split('-').map(Number); return new Date(Date.UTC(year, month - 1, day)); } function addDays(date, days) { const next = new Date(date); next.setUTCDate(next.getUTCDate() + days); return next; } function toDateString(date) { return dateString(date.getUTCFullYear(), date.getUTCMonth() + 1, date.getUTCDate()); } function daysInMonth(year, month) { return new Date(Date.UTC(year, month, 0)).getUTCDate(); } function clampDay(year, month, day) { const parsed = parseInt(day, 10); const safeDay = Number.isInteger(parsed) ? parsed : 1; return Math.min(Math.max(safeDay, 1), daysInMonth(year, month)); } function normalizeCycleType(bill = {}) { const value = String(bill.cycle_type || bill.billing_cycle || 'monthly').toLowerCase(); if (value === 'annually') return 'annual'; if (['monthly', 'weekly', 'biweekly', 'quarterly', 'annual'].includes(value)) return value; return 'monthly'; } function parseCycleMonth(value, fallback = 1) { const parsed = parseInt(value, 10); if (Number.isInteger(parsed) && parsed >= 1 && parsed <= 12) return parsed; return fallback; } function parseWeekday(value, fallback = 'monday') { const normalized = String(value || fallback).trim().toLowerCase(); return WEEKDAY_INDEX[normalized] ?? WEEKDAY_INDEX[fallback]; } function firstWeekdayInMonth(year, month, weekdayIndex) { const first = new Date(Date.UTC(year, month - 1, 1)); const offset = (weekdayIndex - first.getUTCDay() + 7) % 7; return addDays(first, offset); } function firstBiweeklyDateInMonth(year, month, weekdayIndex) { const start = new Date(Date.UTC(year, month - 1, 1)); const end = new Date(Date.UTC(year, month, 0)); const weekdayAnchor = addDays(BIWEEKLY_ANCHOR, (weekdayIndex - BIWEEKLY_ANCHOR.getUTCDay() + 7) % 7); let cursor = firstWeekdayInMonth(year, month, weekdayIndex); while (cursor <= end) { const diffDays = Math.round((cursor - weekdayAnchor) / 86400000); if (diffDays % 14 === 0) return cursor; cursor = addDays(cursor, 7); } return null; } function monthMatchesQuarterlyCycle(month, cycleStartMonth) { return ((month - cycleStartMonth) % 3 + 3) % 3 === 0; } /** * Resolves the due date for a bill in a given year/month. * Returns null when the bill's cycle does not occur in the requested month. * Legacy override_due_date values are intentionally ignored by the current * product behavior. */ function resolveDueDate(bill, year, month) { const cycleType = normalizeCycleType(bill); if (cycleType === 'weekly') { return toDateString(firstWeekdayInMonth(year, month, parseWeekday(bill.cycle_day))); } if (cycleType === 'biweekly') { const due = firstBiweeklyDateInMonth(year, month, parseWeekday(bill.cycle_day)); return due ? toDateString(due) : null; } if (cycleType === 'quarterly') { if (!monthMatchesQuarterlyCycle(month, parseCycleMonth(bill.cycle_day, 1))) return null; return dateString(year, month, clampDay(year, month, bill.due_day)); } if (cycleType === 'annual') { if (month !== parseCycleMonth(bill.cycle_day, 1)) return null; return dateString(year, month, clampDay(year, month, bill.due_day)); } const day = clampDay(year, month, bill.due_day); return dateString(year, month, day); } /** * 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 getCalendarMonthRange(year, month) { const start = dateString(year, month, 1); const end = dateString(year, month, daysInMonth(year, month)); return { start, end }; } /** * Computes the payment cycle start/end for a bill in a given month. * Without a bill argument this preserves the historical calendar-month range. */ function getCycleRange(year, month, bill = null) { if (!bill) return getCalendarMonthRange(year, month); const dueDate = resolveDueDate(bill, year, month); if (!dueDate) return null; const cycleType = normalizeCycleType(bill); if (cycleType === 'weekly') { const start = dateFromString(dueDate); return { start: dueDate, end: toDateString(addDays(start, 6)) }; } if (cycleType === 'biweekly') { const start = dateFromString(dueDate); return { start: dueDate, end: toDateString(addDays(start, 13)) }; } if (cycleType === 'quarterly') { const startMonth = month; const endDate = new Date(Date.UTC(year, startMonth - 1 + 3, 0)); return { start: dateString(year, startMonth, 1), end: toDateString(endDate) }; } if (cycleType === 'annual') { return { start: dateString(year, 1, 1), end: dateString(year, 12, 31) }; } return getCalendarMonthRange(year, month); } /** * 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, options = {}) { if (!dueDate) return 'inactive_cycle'; const gracePeriodDays = resolveGracePeriodDays(options.gracePeriodDays); const safePayments = Array.isArray(payments) ? payments : []; const expectedAmount = Number(bill.expected_amount) || 0; const totalPaid = roundMoney(safePayments.reduce((sum, p) => sum + (Number(p.amount) || 0), 0)); if (totalPaid >= expectedAmount) 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, options = {}) { const dueDate = resolveDueDate(bill, year, month); if (!dueDate) return null; const bucket = resolveBucket(bill); const safePayments = Array.isArray(payments) ? payments : []; const status = calculateStatus(bill, safePayments, dueDate, todayStr, options); const expectedAmount = Number(bill.expected_amount) || 0; const totalPaid = roundMoney(safePayments.reduce((sum, p) => sum + (Number(p.amount) || 0), 0)); const hasPayment = safePayments.length > 0; const isSettled = status === 'paid' || status === 'autodraft'; const paidTowardDue = roundMoney(Math.min(totalPaid, expectedAmount)); const overpaidAmount = roundMoney(Math.max(totalPaid - expectedAmount, 0)); const rawBalance = expectedAmount - 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: expectedAmount, notes: bill.notes || null, // Bill-level notes (always available) total_paid: totalPaid, paid_toward_due: paidTowardDue, overpaid_amount: overpaidAmount, 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, auto_mark_paid: !!bill.auto_mark_paid, billing_cycle: bill.billing_cycle, cycle_type: normalizeCycleType(bill), cycle_day: bill.cycle_day, current_balance: bill.current_balance ?? null, minimum_payment: bill.minimum_payment ?? null, interest_rate: bill.interest_rate ?? null, is_subscription: !!bill.is_subscription, has_2fa: !!bill.has_2fa, has_merchant_rule: !!bill.has_merchant_rule, has_linked_transactions: !!bill.has_linked_transactions, website: bill.website || null, payments: safePayments, }; } module.exports = { buildTrackerRow, calculateStatus, getCalendarMonthRange, getCycleRange, normalizeCycleType, resolveBucket, resolveDueDate, resolveGracePeriodDays, roundMoney, };