BillTracker/services/trackerService.js

389 lines
14 KiB
JavaScript
Raw Normal View History

2026-05-16 15:38:28 -05:00
'use strict';
const { getDb } = require('../db/database');
const { buildTrackerRow, getCycleRange, resolveDueDate } = require('./statusService');
const { getUserSettings } = require('./userSettings');
const { computeBalanceDelta } = require('./billsService');
2026-05-28 02:09:49 -05:00
const { computeAmountSuggestion } = require('./amountSuggestionService');
2026-05-16 15:38:28 -05:00
function validateTrackerMonth(query = {}, now = new Date()) {
const year = parseInt(query.year || now.getFullYear(), 10);
const month = parseInt(query.month || now.getMonth() + 1, 10);
if (Number.isNaN(year) || year < 2000 || year > 2100) {
return { error: 'year must be a 4-digit integer between 2000 and 2100', status: 400 };
}
if (Number.isNaN(month) || month < 1 || month > 12) {
return { error: 'month must be an integer between 1 and 12', status: 400 };
}
return { year, month };
}
function previousMonthFor(year, month) {
return {
year: month === 1 ? year - 1 : year,
month: month === 1 ? 12 : month - 1,
};
}
function monthOffset(year, month, offset) {
let y = year;
let m = month + offset;
while (m <= 0) { m += 12; y -= 1; }
while (m > 12) { m -= 12; y += 1; }
return { year: y, month: m };
}
function fetchActiveBills(db, userId, orderBy = 'b.due_day ASC, b.name ASC') {
return db.prepare(`
SELECT b.*, c.name AS category_name
FROM bills b
LEFT JOIN categories c ON b.category_id = c.id AND c.deleted_at IS NULL
WHERE b.active = 1 AND b.user_id = ? AND b.deleted_at IS NULL
ORDER BY ${orderBy}
`).all(userId);
}
function fetchMonthlyStates(db, billIds, year, month) {
if (billIds.length === 0) return {};
const placeholders = billIds.map(() => '?').join(',');
const rows = db.prepare(`
SELECT bill_id, actual_amount, notes, is_skipped
FROM monthly_bill_state
WHERE bill_id IN (${placeholders}) AND year = ? AND month = ?
`).all(...billIds, year, month);
return Object.fromEntries(rows.map(row => [row.bill_id, row]));
}
2026-05-16 20:26:09 -05:00
function fetchPaymentsForBillCycle(db, bill, year, month) {
const range = getCycleRange(year, month, bill);
if (!range) return [];
return db.prepare(`
SELECT bill_id, id, amount, paid_date, method, notes, payment_source, transaction_id, created_at, updated_at
2026-05-16 15:38:28 -05:00
FROM payments
2026-05-16 20:26:09 -05:00
WHERE bill_id = ? AND paid_date BETWEEN ? AND ?
2026-05-16 15:38:28 -05:00
AND deleted_at IS NULL
ORDER BY paid_date DESC
2026-05-16 20:26:09 -05:00
`).all(bill.id, range.start, range.end);
2026-05-16 15:38:28 -05:00
}
function fetchPreviousMonthPaid(db, billIds, range) {
if (billIds.length === 0) return {};
const placeholders = billIds.map(() => '?').join(',');
const rows = db.prepare(`
SELECT bill_id, SUM(amount) as total_paid
FROM payments
WHERE bill_id IN (${placeholders}) AND paid_date BETWEEN ? AND ?
AND deleted_at IS NULL
GROUP BY bill_id
`).all(...billIds, range.start, range.end);
return Object.fromEntries(rows.map(row => [row.bill_id, row.total_paid]));
}
function fetchDismissedSuggestions(db, userId, billIds, year, month) {
if (billIds.length === 0) return new Set();
const rows = db.prepare(`
SELECT bill_id
FROM autopay_suggestion_dismissals
WHERE user_id = ? AND year = ? AND month = ?
`).all(userId, year, month);
return new Set(rows.map(row => row.bill_id));
}
2026-05-16 20:26:09 -05:00
function rowDueAmount(row) {
const amount = Number(row.actual_amount ?? row.expected_amount);
return Number.isFinite(amount) ? amount : 0;
}
function rowPaidTowardDue(row) {
const cappedPaid = Number(row.paid_toward_due);
if (Number.isFinite(cappedPaid)) return cappedPaid;
return Math.min(Number(row.total_paid) || 0, rowDueAmount(row));
}
2026-05-16 15:38:28 -05:00
function applyAutopaySuggestions(db, bill, payments, mbs, year, month, todayStr, dismissedSuggestions) {
const dueDate = resolveDueDate(bill, year, month);
2026-05-16 20:26:09 -05:00
if (!dueDate) return null;
2026-05-16 15:38:28 -05:00
const suggestedAmount = Number(mbs?.actual_amount ?? bill.expected_amount);
const hasSuggestedAmount = Number.isFinite(suggestedAmount) && suggestedAmount > 0;
const isEligible = !!(
bill.autopay_enabled &&
bill.autodraft_status === 'assumed_paid' &&
hasSuggestedAmount &&
dueDate <= todayStr &&
!mbs?.is_skipped &&
payments.length === 0
);
if (!isEligible) return null;
if (bill.auto_mark_paid) {
2026-05-16 20:26:09 -05:00
const range = getCycleRange(year, month, bill);
2026-05-16 15:38:28 -05:00
const existingPayment = db.prepare(`
2026-05-16 20:26:09 -05:00
SELECT bill_id, id, amount, paid_date, method, notes, payment_source, transaction_id, created_at, updated_at
2026-05-16 15:38:28 -05:00
FROM payments
WHERE bill_id = ?
AND deleted_at IS NULL
2026-05-16 20:26:09 -05:00
AND paid_date BETWEEN ? AND ?
2026-05-16 15:38:28 -05:00
ORDER BY paid_date DESC
LIMIT 1
2026-05-16 20:26:09 -05:00
`).get(bill.id, range.start, range.end);
2026-05-16 15:38:28 -05:00
if (existingPayment) {
payments.push(existingPayment);
return null;
}
const balCalc = computeBalanceDelta(bill, suggestedAmount);
const result = db.prepare(`
2026-05-16 20:26:09 -05:00
INSERT INTO payments (bill_id, amount, paid_date, method, notes, balance_delta, payment_source)
VALUES (?, ?, ?, ?, ?, ?, ?)
2026-05-16 15:38:28 -05:00
`).run(
bill.id,
suggestedAmount,
dueDate,
'autopay',
'Auto-marked paid on due date',
balCalc?.balance_delta ?? null,
2026-05-16 20:26:09 -05:00
'manual',
2026-05-16 15:38:28 -05:00
);
if (balCalc) {
db.prepare("UPDATE bills SET current_balance = ?, updated_at=datetime('now') WHERE id=?")
.run(balCalc.new_balance, bill.id);
bill.current_balance = balCalc.new_balance;
}
payments.push(db.prepare(`
2026-05-16 20:26:09 -05:00
SELECT bill_id, id, amount, paid_date, method, notes, payment_source, transaction_id, created_at, updated_at
2026-05-16 15:38:28 -05:00
FROM payments
WHERE id = ?
`).get(result.lastInsertRowid));
return null;
}
if (dismissedSuggestions.has(bill.id)) return null;
return {
bill_id: bill.id,
amount: suggestedAmount,
paid_date: dueDate,
method: 'autopay',
};
}
function buildThreeMonthTrend(db, userId, year, month, end, currentMonthPaid) {
const threeMonthsAgo = monthOffset(year, month, -2);
const threeMonthStart = getCycleRange(threeMonthsAgo.year, threeMonthsAgo.month).start;
const rows = db.prepare(`
SELECT SUM(p.amount) as total_paid, strftime('%Y-%m', p.paid_date) as month_key
FROM payments p
JOIN bills b ON p.bill_id = b.id
WHERE b.user_id = ? AND b.deleted_at IS NULL AND p.paid_date BETWEEN ? AND ?
AND p.deleted_at IS NULL
GROUP BY strftime('%Y-%m', p.paid_date)
`).all(userId, threeMonthStart, end);
const monthlyPaymentsMap = new Map();
rows.forEach(payment => {
monthlyPaymentsMap.set(payment.month_key, payment.total_paid);
});
const months = [];
for (let i = 2; i >= 0; i--) {
const date = new Date(year, month - 1 - i);
const monthKey = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`;
months.push({
year: date.getFullYear(),
month: date.getMonth() + 1,
key: monthKey,
payment: parseFloat(monthlyPaymentsMap.get(monthKey) || 0),
});
}
const threeMonthAvg = months.reduce((sum, m) => sum + m.payment, 0) / 3;
let percentChange = 0;
let direction = 'flat';
if (threeMonthAvg > 0) {
percentChange = ((currentMonthPaid - threeMonthAvg) / threeMonthAvg) * 100;
if (percentChange > 2) direction = 'up';
else if (percentChange < -2) direction = 'down';
}
return {
three_month_avg: parseFloat(threeMonthAvg.toFixed(2)),
current_month_paid: parseFloat(currentMonthPaid.toFixed(2)),
percent_change: parseFloat(percentChange.toFixed(1)),
direction,
};
}
function getTracker(userId, query = {}, now = new Date()) {
const parsed = validateTrackerMonth(query, now);
if (parsed.error) return parsed;
const db = getDb();
const { year, month } = parsed;
const todayStr = now.toISOString().slice(0, 10);
const userSettings = getUserSettings(userId);
const rowOptions = { gracePeriodDays: userSettings.grace_period_days };
const { start, end } = getCycleRange(year, month);
const previousMonth = previousMonthFor(year, month);
const prevMonthRange = getCycleRange(previousMonth.year, previousMonth.month);
const bills = fetchActiveBills(db, userId);
const billIds = bills.map(bill => bill.id);
const monthlyStates = fetchMonthlyStates(db, billIds, year, month);
const prevMonthPayments = fetchPreviousMonthPaid(db, billIds, prevMonthRange);
const dismissedSuggestions = fetchDismissedSuggestions(db, userId, billIds, year, month);
const rows = bills.map(bill => {
2026-05-16 20:26:09 -05:00
if (!resolveDueDate(bill, year, month)) return null;
const payments = fetchPaymentsForBillCycle(db, bill, year, month);
2026-05-16 15:38:28 -05:00
const mbs = monthlyStates[bill.id];
const autopaySuggestion = applyAutopaySuggestions(
db,
bill,
payments,
mbs,
year,
month,
todayStr,
dismissedSuggestions,
);
const billForStatus = mbs?.actual_amount != null
? { ...bill, expected_amount: mbs.actual_amount }
: bill;
const row = buildTrackerRow(billForStatus, payments, year, month, todayStr, rowOptions);
2026-05-16 20:26:09 -05:00
if (!row) return null;
2026-05-16 15:38:28 -05:00
row.expected_amount = bill.expected_amount;
row.actual_amount = mbs?.actual_amount ?? null;
row.monthly_notes = mbs?.notes ?? null;
row.is_skipped = !!(mbs?.is_skipped);
if (autopaySuggestion) row.autopay_suggestion = autopaySuggestion;
row.previous_month_paid = prevMonthPayments[bill.id] || 0;
2026-05-28 02:09:49 -05:00
row.amount_suggestion = computeAmountSuggestion(db, bill.id, year, month);
2026-05-16 15:38:28 -05:00
return row;
2026-05-16 20:26:09 -05:00
}).filter(Boolean);
2026-05-16 15:38:28 -05:00
const activeRows = rows.filter(r => !r.is_skipped);
const startingAmounts = db.prepare(`
SELECT COALESCE(first_amount, 0) AS first_amount,
COALESCE(fifteenth_amount, 0) AS fifteenth_amount,
COALESCE(other_amount, 0) AS other_amount,
COALESCE(first_amount, 0) + COALESCE(fifteenth_amount, 0) + COALESCE(other_amount, 0) AS combined_amount
FROM monthly_starting_amounts
WHERE user_id = ? AND year = ? AND month = ?
`).get(userId, year, month);
const dayOfMonth = now.getDate();
const activeRemainingPeriod = dayOfMonth < 15 ? '1st' : '15th';
const periodRows = activeRows.filter(r => r.bucket === activeRemainingPeriod);
2026-05-16 20:26:09 -05:00
const periodPaidTowardDue = periodRows.reduce((s, r) => s + rowPaidTowardDue(r), 0);
2026-05-16 15:38:28 -05:00
const periodOutstandingBalance = periodRows.reduce((s, r) => s + Math.max(r.balance || 0, 0), 0);
const periodStartingAmount = activeRemainingPeriod === '1st'
? (startingAmounts?.first_amount || 0)
: (startingAmounts?.fifteenth_amount || 0);
const periodLabel = activeRemainingPeriod === '1st' ? '1st balance' : '15th balance';
const totalStarting = startingAmounts?.combined_amount || 0;
const hasStartingAmounts = !!startingAmounts;
const activeTotalPaid = activeRows.reduce((s, r) => s + r.total_paid, 0);
2026-05-16 20:26:09 -05:00
const activePaidTowardDue = activeRows.reduce((s, r) => s + rowPaidTowardDue(r), 0);
const activeTotalExpected = activeRows.reduce((s, r) => s + rowDueAmount(r), 0);
2026-05-16 15:38:28 -05:00
const activeOutstandingBalance = activeRows.reduce((s, r) => s + Math.max(r.balance || 0, 0), 0);
const totalOverdue = rows
.filter(r => !r.is_skipped && (r.status === 'late' || r.status === 'missed'))
.reduce((s, r) => s + r.balance, 0);
const previousMonthTotal = activeRows.reduce((s, r) => s + r.previous_month_paid, 0);
return {
year,
month,
today: todayStr,
summary: {
total_expected: activeTotalExpected,
total_starting: totalStarting,
has_starting_amounts: hasStartingAmounts,
total_paid: activeTotalPaid,
2026-05-16 20:26:09 -05:00
paid_toward_due: activePaidTowardDue,
remaining: hasStartingAmounts ? periodStartingAmount - periodPaidTowardDue : periodOutstandingBalance,
total_remaining: hasStartingAmounts ? totalStarting - activePaidTowardDue : activeOutstandingBalance,
2026-05-16 15:38:28 -05:00
remaining_period: activeRemainingPeriod,
remaining_label: periodLabel,
remaining_hint: hasStartingAmounts
2026-05-16 20:26:09 -05:00
? `${periodLabel}: ${periodStartingAmount.toFixed(2)} starting minus ${periodPaidTowardDue.toFixed(2)} paid toward due`
2026-05-16 15:38:28 -05:00
: `${periodLabel}: unpaid bills due in this period`,
overdue: totalOverdue,
count_paid: activeRows.filter(r => r.status === 'paid').length,
count_upcoming: activeRows.filter(r => r.status === 'upcoming' || r.status === 'due_soon').length,
count_late: activeRows.filter(r => r.status === 'late' || r.status === 'missed').length,
count_autodraft: activeRows.filter(r => r.status === 'autodraft').length,
previous_month_total: previousMonthTotal,
trend: buildThreeMonthTrend(db, userId, year, month, end, activeTotalPaid),
},
rows,
};
}
function getUpcomingBills(userId, query = {}, now = new Date()) {
const db = getDb();
const days = Math.max(1, Math.min(parseInt(query.days || '30', 10) || 30, 365));
const todayStr = now.toISOString().slice(0, 10);
const userSettings = getUserSettings(userId);
const rowOptions = { gracePeriodDays: userSettings.grace_period_days };
const bills = fetchActiveBills(db, userId, 'b.id ASC');
const cutoff = new Date(now);
cutoff.setDate(cutoff.getDate() + days);
const cutoffStr = cutoff.toISOString().slice(0, 10);
const upcoming = [];
2026-05-16 20:26:09 -05:00
const seen = new Set();
const monthCount = (cutoff.getFullYear() - now.getFullYear()) * 12
+ (cutoff.getMonth() - now.getMonth()) + 1;
for (let offset = 0; offset < monthCount; offset += 1) {
const target = monthOffset(now.getFullYear(), now.getMonth() + 1, offset);
for (const bill of bills) {
const dueDate = resolveDueDate(bill, target.year, target.month);
if (!dueDate || dueDate < todayStr || dueDate > cutoffStr) continue;
const key = `${bill.id}:${dueDate}`;
if (seen.has(key)) continue;
seen.add(key);
const row = buildTrackerRow(
bill,
fetchPaymentsForBillCycle(db, bill, target.year, target.month),
target.year,
target.month,
todayStr,
rowOptions,
);
if (!row || row.status === 'paid') continue;
upcoming.push({
id: bill.id,
name: bill.name,
category_name: bill.category_name,
due_date: dueDate,
expected_amount: bill.expected_amount,
status: row.status,
days_until_due: Math.floor((new Date(`${dueDate}T00:00:00`) - new Date(`${todayStr}T00:00:00`)) / 86400000),
});
}
2026-05-16 15:38:28 -05:00
}
upcoming.sort((a, b) => a.due_date.localeCompare(b.due_date));
return { days, today: todayStr, upcoming };
}
module.exports = {
getTracker,
getUpcomingBills,
validateTrackerMonth,
};