292 lines
9.5 KiB
JavaScript
292 lines
9.5 KiB
JavaScript
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,
|
||
autopay_verified_at: bill.autopay_verified_at ?? null,
|
||
inactive_reason: bill.inactive_reason ?? null,
|
||
inactivated_at: bill.inactivated_at ?? null,
|
||
sparkline: bill.sparkline ?? null,
|
||
autopay_stats: bill.autopay_stats ?? null,
|
||
payments: safePayments,
|
||
};
|
||
}
|
||
|
||
module.exports = {
|
||
buildTrackerRow,
|
||
calculateStatus,
|
||
getCalendarMonthRange,
|
||
getCycleRange,
|
||
normalizeCycleType,
|
||
resolveBucket,
|
||
resolveDueDate,
|
||
resolveGracePeriodDays,
|
||
roundMoney,
|
||
};
|