feat: implement cycle_type logic in statusService (weekly/biweekly/quarterly/annual)
This commit is contained in:
parent
b124e48ebc
commit
0c628212a0
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in New Issue