2026-05-03 19:51:57 -05:00
|
|
|
|
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: 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 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:
|
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
|
|
|
|
|
|
*/
|
|
|
|
|
|
function calculateStatus(bill, payments, dueDate, today) {
|
|
|
|
|
|
const gracePeriodDays = parseInt(getSetting('grace_period_days') || '5', 10);
|
2026-05-11 16:04:21 -05:00
|
|
|
|
const safePayments = Array.isArray(payments) ? payments : [];
|
|
|
|
|
|
const totalPaid = safePayments.reduce((sum, p) => sum + p.amount, 0);
|
2026-05-03 19:51:57 -05:00
|
|
|
|
|
2026-05-11 16:04:21 -05:00
|
|
|
|
// 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';
|
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.
|
|
|
|
|
|
*/
|
|
|
|
|
|
function buildTrackerRow(bill, payments, year, month, todayStr) {
|
|
|
|
|
|
const dueDate = resolveDueDate(bill, year, month);
|
|
|
|
|
|
const bucket = resolveBucket(bill);
|
2026-05-11 16:04:21 -05:00
|
|
|
|
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]
|
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: bill.expected_amount,
|
2026-05-09 13:03:36 -05:00
|
|
|
|
notes: bill.notes || null, // Bill-level notes (always available)
|
2026-05-03 19:51:57 -05:00
|
|
|
|
total_paid: totalPaid,
|
2026-05-11 16:04:21 -05:00
|
|
|
|
balance,
|
|
|
|
|
|
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 ? lastPayment.amount : null,
|
|
|
|
|
|
status,
|
|
|
|
|
|
autopay_enabled: !!bill.autopay_enabled,
|
|
|
|
|
|
autodraft_status: bill.autodraft_status,
|
|
|
|
|
|
billing_cycle: bill.billing_cycle,
|
2026-05-11 16:04:21 -05:00
|
|
|
|
payments: safePayments,
|
2026-05-03 19:51:57 -05:00
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
module.exports = { resolveDueDate, resolveBucket, getCycleRange, calculateStatus, buildTrackerRow };
|