feat: implement cycle_type logic in statusService (weekly/biweekly/quarterly/annual)

This commit is contained in:
null 2026-05-16 15:42:54 -05:00
parent b124e48ebc
commit 0c628212a0
1 changed files with 170 additions and 10 deletions

View File

@ -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,
};