BillTracker/services/statusService.js

290 lines
9.6 KiB
JavaScript
Raw Normal View History

2026-05-03 19:51:57 -05:00
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
2026-05-15 22:45:38 -05:00
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');
}
const { fromCents } = require('../utils/money');
const { serializePayment } = require('./paymentValidation');
2026-05-16 20:26:09 -05:00
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;
}
2026-05-03 19:51:57 -05:00
/**
* 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.
2026-05-03 19:51:57 -05:00
*/
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));
}
2026-05-16 20:26:09 -05:00
const day = clampDay(year, month, bill.due_day);
return dateString(year, month, day);
2026-05-03 19:51:57 -05:00
}
/**
* Auto-assigns bucket from due_day: 114 '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));
2026-05-03 19:51:57 -05:00
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);
}
2026-05-03 19:51:57 -05:00
/**
* Returns status for a bill given its payments and due date.
*
* Statuses:
2026-05-11 16:04:21 -05:00
* paid has a non-deleted payment in this billing cycle
* OR total paid >= expected_amount (fully settled)
2026-05-03 19:51:57 -05:00
* 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
*/
2026-05-15 22:45:38 -05:00
function calculateStatus(bill, payments, dueDate, today, options = {}) {
if (!dueDate) return 'inactive_cycle';
2026-05-15 22:45:38 -05:00
const gracePeriodDays = resolveGracePeriodDays(options.gracePeriodDays);
2026-05-11 16:04:21 -05:00
const safePayments = Array.isArray(payments) ? payments : [];
2026-05-16 20:26:09 -05:00
const expectedAmount = Number(bill.expected_amount) || 0;
const totalPaid = safePayments.reduce((sum, p) => sum + (Number(p.amount) || 0), 0);
2026-05-03 19:51:57 -05:00
2026-05-16 20:26:09 -05:00
if (totalPaid >= expectedAmount) return 'paid';
2026-05-03 19:51:57 -05:00
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.
*/
2026-05-15 22:45:38 -05:00
function buildTrackerRow(bill, payments, year, month, todayStr, options = {}) {
2026-05-03 19:51:57 -05:00
const dueDate = resolveDueDate(bill, year, month);
if (!dueDate) return null;
2026-05-03 19:51:57 -05:00
const bucket = resolveBucket(bill);
2026-05-11 16:04:21 -05:00
const safePayments = Array.isArray(payments) ? payments : [];
2026-05-15 22:45:38 -05:00
const status = calculateStatus(bill, safePayments, dueDate, todayStr, options);
2026-05-16 20:26:09 -05:00
const expectedAmount = Number(bill.expected_amount) || 0;
const totalPaid = safePayments.reduce((sum, p) => sum + (Number(p.amount) || 0), 0);
2026-05-11 16:04:21 -05:00
const hasPayment = safePayments.length > 0;
const isSettled = status === 'paid' || status === 'autodraft';
const paidTowardDue = Math.min(totalPaid, expectedAmount);
const overpaidAmount = Math.max(totalPaid - expectedAmount, 0);
2026-05-16 20:26:09 -05:00
const rawBalance = expectedAmount - totalPaid;
2026-05-11 16:04:21 -05:00
const balance = isSettled ? 0 : Math.max(rawBalance, 0);
const lastPayment = hasPayment
? [...safePayments].sort((a, b) => b.paid_date.localeCompare(a.paid_date))[0]
2026-05-03 19:51:57 -05:00
: 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: fromCents(expectedAmount),
2026-05-09 13:03:36 -05:00
notes: bill.notes || null, // Bill-level notes (always available)
total_paid: fromCents(totalPaid),
paid_toward_due: fromCents(paidTowardDue),
overpaid_amount: fromCents(overpaidAmount),
balance: fromCents(balance),
2026-05-11 16:04:21 -05:00
has_payment: hasPayment,
is_settled: isSettled,
2026-05-03 19:51:57 -05:00
last_paid_date: lastPayment ? lastPayment.paid_date : null,
last_payment_amount: lastPayment ? fromCents(lastPayment.amount) : null,
2026-05-03 19:51:57 -05:00
status,
autopay_enabled: !!bill.autopay_enabled,
autodraft_status: bill.autodraft_status,
2026-05-16 15:38:28 -05:00
auto_mark_paid: !!bill.auto_mark_paid,
2026-05-03 19:51:57 -05:00
billing_cycle: bill.billing_cycle,
cycle_type: normalizeCycleType(bill),
cycle_day: bill.cycle_day,
current_balance: bill.current_balance != null ? fromCents(bill.current_balance) : null,
minimum_payment: bill.minimum_payment != null ? fromCents(bill.minimum_payment) : null,
2026-05-16 15:38:28 -05:00
interest_rate: bill.interest_rate ?? null,
2026-06-07 00:24:43 -05:00
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.map(serializePayment),
2026-05-03 19:51:57 -05:00
};
}
module.exports = {
buildTrackerRow,
calculateStatus,
getCalendarMonthRange,
getCycleRange,
normalizeCycleType,
resolveBucket,
resolveDueDate,
resolveGracePeriodDays,
};