BillTracker/services/statusService.js

111 lines
4.1 KiB
JavaScript
Raw Permalink 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');
/**
* 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.
*/
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')}`;
}
/**
* 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 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')}`;
return { start, end };
}
/**
* 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) {
const gracePeriodDays = parseInt(getSetting('grace_period_days') || '5', 10);
const safePayments = Array.isArray(payments) ? payments : [];
const totalPaid = safePayments.reduce((sum, p) => sum + p.amount, 0);
// A recorded payment is the user's confirmation that this cycle is handled.
// Expected amounts are estimates, so a lower actual payment must not leave a Pay
// button visible and invite duplicate payments.
if (safePayments.length > 0 || totalPaid >= bill.expected_amount) 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) {
const dueDate = resolveDueDate(bill, year, month);
const bucket = resolveBucket(bill);
const safePayments = Array.isArray(payments) ? payments : [];
const status = calculateStatus(bill, safePayments, dueDate, todayStr);
const totalPaid = safePayments.reduce((sum, p) => sum + p.amount, 0);
const hasPayment = safePayments.length > 0;
const isSettled = status === 'paid' || status === 'autodraft';
const rawBalance = bill.expected_amount - 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: bill.expected_amount,
notes: bill.notes || null, // Bill-level notes (always available)
total_paid: totalPaid,
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,
billing_cycle: bill.billing_cycle,
payments: safePayments,
};
}
module.exports = { resolveDueDate, resolveBucket, getCycleRange, calculateStatus, buildTrackerRow };