diff --git a/services/statusService.js b/services/statusService.js index 7b43d14..41518c9 100644 --- a/services/statusService.js +++ b/services/statusService.js @@ -1,19 +1,134 @@ 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 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 parseCycleDayOfMonth(bill, fallback = bill?.due_day || 1) { + const parsed = parseInt(bill?.cycle_day, 10); + if (Number.isInteger(parsed) && parsed >= 1 && parsed <= 31) 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. - * Bills use a recurring day-of-month template field. Legacy override_due_date - * values are intentionally ignored by the current product behavior. + * 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 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')}`; + 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, parseCycleDayOfMonth(bill)); + return dateString(year, month, day); } /** @@ -28,13 +143,43 @@ function resolveBucket(bill) { * 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')}`; +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. * @@ -48,6 +193,8 @@ function getCycleRange(year, month) { * 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 totalPaid = safePayments.reduce((sum, p) => sum + p.amount, 0); @@ -73,6 +220,8 @@ function calculateStatus(bill, payments, dueDate, today, options = {}) { */ 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); @@ -106,6 +255,8 @@ function buildTrackerRow(bill, payments, year, month, todayStr, options = {}) { 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, @@ -113,4 +264,13 @@ function buildTrackerRow(bill, payments, year, month, todayStr, options = {}) { }; } -module.exports = { resolveDueDate, resolveBucket, getCycleRange, calculateStatus, buildTrackerRow, resolveGracePeriodDays }; +module.exports = { + buildTrackerRow, + calculateStatus, + getCalendarMonthRange, + getCycleRange, + normalizeCycleType, + resolveBucket, + resolveDueDate, + resolveGracePeriodDays, +};