BillTracker/services/statusService.js

290 lines
9.4 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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');
}
const { roundMoney, sumMoney } = require('../utils/money');
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: 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));
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 = sumMoney(safePayments, p => p.amount);
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 = sumMoney(safePayments, p => p.amount);
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,
};