BillTracker/services/trackerService.js

872 lines
34 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.

'use strict';
const { getDb } = require('../db/database');
const { buildTrackerRow, getCycleRange, resolveDueDate, isPaidStatus } = require('./statusService');
const { getUserSettings } = require('./userSettings');
const { computeBalanceDelta, applyBalanceDelta } = require('./billsService');
const { computeAmountSuggestionsBatch } = require('./amountSuggestionService');
const { accountingActiveSql } = require('./paymentAccountingService');
const { normalizeMerchant } = require('./subscriptionService');
const { localDateString } = require('../utils/dates');
const { sumMoney, roundMoney, fromCents } = require('../utils/money');
const DEFAULT_PENDING_DAYS = 3;
// Word-boundary match — same semantics as billMerchantRuleService.merchantMatches.
function txMerchantMatches(txNorm, ruleMerchant) {
if (!txNorm || !ruleMerchant) return false;
if (txNorm === ruleMerchant) return true;
const esc = s => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const wb = s => new RegExp(`(^|\\s)${esc(s)}(\\s|$)`);
return wb(ruleMerchant).test(txNorm) || wb(txNorm).test(ruleMerchant);
}
// For bills that have merchant rules, count how many of that user's pending bank
// transactions match each bill. Only bills with at least one rule are checked.
function fetchBankPendingCounts(db, userId, billIds) {
if (billIds.length === 0) return {};
const ph = billIds.map(() => '?').join(',');
const rules = db.prepare(`
SELECT bill_id, merchant
FROM bill_merchant_rules
WHERE user_id = ? AND bill_id IN (${ph})
`).all(userId, ...billIds);
if (rules.length === 0) return {};
const pendingTxs = db.prepare(`
SELECT t.payee, t.description, t.memo
FROM transactions t
LEFT JOIN financial_accounts fa ON fa.id = t.account_id
WHERE t.user_id = ?
AND t.pending = 1
AND t.amount < 0
AND t.ignored = 0
AND (t.account_id IS NULL OR fa.id IS NULL OR fa.monitored = 1)
`).all(userId);
if (pendingTxs.length === 0) return {};
const rulesByBill = {};
for (const rule of rules) {
if (!rulesByBill[rule.bill_id]) rulesByBill[rule.bill_id] = [];
rulesByBill[rule.bill_id].push(rule.merchant);
}
const counts = {};
for (const tx of pendingTxs) {
const txNorm = normalizeMerchant(tx.payee || tx.description || tx.memo || '');
if (!txNorm) continue;
for (const [billId, merchants] of Object.entries(rulesByBill)) {
if (merchants.some(m => txMerchantMatches(txNorm, m))) {
counts[billId] = (counts[billId] || 0) + 1;
}
}
}
return counts;
}
// `gatedUnpaidThisMonth` (dollars) is the occurrence-gated unpaid total the
// caller already computed from the tracker rows (which honor resolveDueDate).
// When provided we use it instead of the ungated SQL below, so annual/quarterly/
// off-month bills don't inflate `unpaid_this_month` → the bank `remaining`.
function buildBankTracking(db, userId, year, month, gatedUnpaidThisMonth = null) {
try {
const settings = getUserSettings(userId);
if (settings.bank_tracking_enabled !== 'true') return { enabled: false };
const accountId = parseInt(settings.bank_tracking_account_id, 10);
if (!Number.isInteger(accountId) || accountId < 1) return { enabled: false };
const account = db.prepare(`
SELECT id, name, org_name, balance, available_balance, updated_at
FROM financial_accounts WHERE id = ? AND user_id = ?
`).get(accountId, userId);
if (!account || account.balance === null) return { enabled: false };
const pendingDays = parseInt(settings.bank_tracking_pending_days, 10);
const days = Number.isInteger(pendingDays) && pendingDays >= 0 ? pendingDays : DEFAULT_PENDING_DAYS;
// Only count manually-entered payments as pending — bank-synced payments
// (provider_sync) are already reflected in the live bank balance, so
// including them would double-deduct.
const pendingRow = days > 0
? db.prepare(`
SELECT COALESCE(SUM(p.amount), 0) AS pending_total
FROM payments p JOIN bills b ON b.id = p.bill_id
WHERE b.user_id = ?
AND p.paid_date >= date('now', '-' || ? || ' days')
AND p.paid_date <= date('now') AND b.deleted_at IS NULL
AND ${accountingActiveSql('p')}
AND (p.payment_source IS NULL OR p.payment_source NOT IN ('provider_sync', 'transaction_match', 'auto_match'))
`).get(userId, days)
: { pending_total: 0 };
// Fallback (only when the caller didn't pass a gated total, e.g. a direct
// call): the ungated SQL sum. This overcounts off-month bills — prefer the
// gated total from getTracker's rows.
let unpaid;
if (gatedUnpaidThisMonth != null) {
unpaid = roundMoney(gatedUnpaidThisMonth);
} else {
const { start, end } = getCycleRange(year, month);
const unpaidRow = db.prepare(`
SELECT COALESCE(SUM(
CASE WHEN m.actual_amount IS NOT NULL THEN m.actual_amount
ELSE COALESCE(b.expected_amount, 0) END
), 0) AS unpaid_total
FROM bills b
LEFT JOIN monthly_bill_state m ON m.bill_id = b.id AND m.year = ? AND m.month = ?
LEFT JOIN (
SELECT bill_id, SUM(amount) AS paid_sum FROM payments
WHERE paid_date BETWEEN ? AND ?
AND deleted_at IS NULL
AND ${accountingActiveSql()}
GROUP BY bill_id
) pay ON pay.bill_id = b.id
WHERE b.user_id = ? AND b.active = 1 AND b.deleted_at IS NULL
AND COALESCE(m.is_skipped, 0) = 0 AND COALESCE(pay.paid_sum, 0) = 0
`).get(year, month, start, end, userId);
unpaid = fromCents(unpaidRow.unpaid_total);
}
const balance = fromCents(account.balance);
const pending = fromCents(pendingRow.pending_total);
const effective = roundMoney(balance - pending);
return {
enabled: true,
account_id: account.id,
account_name: account.name,
org_name: account.org_name,
balance,
pending_payments: pending,
pending_days: days,
effective_balance: effective,
unpaid_this_month: unpaid,
remaining: roundMoney(effective - unpaid),
last_updated: account.updated_at,
};
} catch (err) {
console.error('[buildBankTracking] Error computing bank tracking data:', err.message);
return { enabled: false };
}
}
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 };
}
const FETCH_BILLS_ORDER = {
due_day: 'CASE WHEN b.sort_order IS NULL THEN 1 ELSE 0 END, b.sort_order ASC, b.due_day ASC, b.name ASC',
id: 'b.id ASC',
};
function fetchActiveBills(db, userId, orderKey = 'due_day') {
const orderBy = FETCH_BILLS_ORDER[orderKey] ?? FETCH_BILLS_ORDER.due_day;
return db.prepare(`
SELECT b.*, c.name AS category_name,
CASE WHEN mr.bill_id IS NOT NULL THEN 1 ELSE 0 END AS has_merchant_rule,
CASE WHEN lt.matched_bill_id IS NOT NULL THEN 1 ELSE 0 END AS has_linked_transactions
FROM bills b
LEFT JOIN categories c ON b.category_id = c.id AND c.deleted_at IS NULL
LEFT JOIN (SELECT DISTINCT bill_id FROM bill_merchant_rules) mr ON mr.bill_id = b.id
LEFT JOIN (SELECT DISTINCT matched_bill_id FROM transactions WHERE match_status = 'matched') lt ON lt.matched_bill_id = b.id
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, snoozed_until
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]));
}
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
FROM payments
WHERE bill_id = ? AND paid_date BETWEEN ? AND ?
AND deleted_at IS NULL
AND ${accountingActiveSql()}
ORDER BY paid_date DESC
`).all(bill.id, range.start, range.end);
}
// Batched form of fetchPaymentsForBillCycle: one query for every bill's cycle
// payments (grouped in JS by each bill's own range) instead of one query per
// bill. Returns Map<billId, payments[]> with each list still ordered paid_date
// DESC — identical to calling fetchPaymentsForBillCycle per bill.
function fetchPaymentsForBillsInMonth(db, bills, year, month) {
const ranges = new Map(); // billId → { start, end } (only bills due this month)
let minStart = null;
let maxEnd = null;
for (const bill of bills) {
const range = getCycleRange(year, month, bill);
if (!range) continue;
ranges.set(bill.id, range);
if (minStart === null || range.start < minStart) minStart = range.start;
if (maxEnd === null || range.end > maxEnd) maxEnd = range.end;
}
const byBill = new Map();
if (minStart === null) return byBill;
const ids = [...ranges.keys()];
const placeholders = ids.map(() => '?').join(',');
const rows = db.prepare(`
SELECT bill_id, id, amount, paid_date, method, notes, payment_source, transaction_id, created_at, updated_at
FROM payments
WHERE bill_id IN (${placeholders}) AND paid_date BETWEEN ? AND ?
AND deleted_at IS NULL
AND ${accountingActiveSql()}
ORDER BY paid_date DESC
`).all(...ids, minStart, maxEnd);
for (const row of rows) {
const range = ranges.get(row.bill_id);
// The union window may be wider than this bill's own cycle — filter to it.
if (row.paid_date < range.start || row.paid_date > range.end) continue;
if (!byBill.has(row.bill_id)) byBill.set(row.bill_id, []);
byBill.get(row.bill_id).push(row);
}
return byBill;
}
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
AND ${accountingActiveSql()}
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));
}
function fetchSparklines(db, billIds) {
if (billIds.length === 0) return {};
const ph = billIds.map(() => '?').join(',');
const rows = db.prepare(`
SELECT bill_id, substr(paid_date, 1, 7) AS month_str, SUM(amount) AS total
FROM payments
WHERE bill_id IN (${ph})
AND paid_date >= date('now', '-6 months')
AND deleted_at IS NULL AND accounting_excluded = 0
GROUP BY bill_id, month_str
ORDER BY bill_id, month_str
`).all(...billIds);
const out = {};
for (const r of rows) {
if (!out[r.bill_id]) out[r.bill_id] = [];
out[r.bill_id].push(fromCents(r.total));
}
return out;
}
function fetchAutopayStats(db, billIds) {
if (billIds.length === 0) return {};
const ph = billIds.map(() => '?').join(',');
const rows = db.prepare(`
SELECT
p.bill_id,
COUNT(*) AS total,
SUM(p.autopay_failure) AS failures,
MAX(CASE WHEN p.autopay_failure = 1 THEN p.paid_date END) AS last_failure_date,
MAX(CASE WHEN p.autopay_failure = 1 THEN p.notes END) AS last_failure_notes
FROM payments p
JOIN bills b ON b.id = p.bill_id
WHERE p.bill_id IN (${ph})
AND b.autopay_enabled = 1
AND p.deleted_at IS NULL
AND p.paid_date >= date('now', '-12 months')
GROUP BY p.bill_id
`).all(...billIds);
return Object.fromEntries(rows.map(r => [r.bill_id, {
total: r.total,
failures: r.failures || 0,
last_failure_date: r.last_failure_date || null,
last_failure_notes: r.last_failure_notes || null,
}]));
}
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));
}
function rowOutstanding(row) {
return Math.max(Number(row.balance) || 0, 0);
}
/**
* Safe-to-spend projection for the current 1st/15th pay period.
*
* Pure function (no DB) so it is unit-testable: takes serialized tracker rows
* (dollar-denominated, post buildTrackerRow) plus the cash available for the
* period, and answers: "after every bill still due before the next payday is
* covered — including overdue carry-over — what is left to spend?"
*
* The next payday is the next bucket boundary (the 15th, or the 1st of the
* following month), matching the app's semi-monthly bucket model.
*/
function buildSafeToSpend({ activeRows, available, todayStr, year, month, dayOfMonth }) {
const nextPayday = dayOfMonth < 15
? `${year}-${String(month).padStart(2, '0')}-15`
: (month === 12 ? `${year + 1}-01-01` : `${year}-${String(month + 1).padStart(2, '0')}-01`);
// Both strings are YYYY-MM-DD → Date.parse treats them as UTC midnight,
// so the difference is an exact whole number of days.
const daysUntilPayday = Math.max(0, Math.round((Date.parse(nextPayday) - Date.parse(todayStr)) / 86400000));
const stillDueRows = activeRows
.filter(r => !isPaidStatus(r.status))
.filter(r => rowOutstanding(r) > 0)
.filter(r => r.due_date < nextPayday)
.sort((a, b) => a.due_date.localeCompare(b.due_date) || String(a.name).localeCompare(String(b.name)));
const stillDueTotal = sumMoney(stillDueRows, rowOutstanding);
const safeToSpend = roundMoney(available - stillDueTotal);
// Daily projection from today to payday: balance steps down as bills hit.
// Overdue bills land on "today" — they need to be paid now, not in the past.
const byDate = new Map();
for (const r of stillDueRows) {
const date = r.due_date > todayStr ? r.due_date : todayStr;
if (!byDate.has(date)) byDate.set(date, []);
byDate.get(date).push({ id: r.id, name: r.name, amount: rowOutstanding(r) });
}
const timeline = [];
let running = roundMoney(available);
if (!byDate.has(todayStr)) timeline.push({ date: todayStr, balance: running, bills: [] });
for (const date of [...byDate.keys()].sort()) {
const bills = byDate.get(date);
running = roundMoney(running - sumMoney(bills, b => b.amount));
timeline.push({ date, balance: running, bills });
}
if (timeline.length === 0 || timeline[timeline.length - 1].date !== nextPayday) {
timeline.push({ date: nextPayday, balance: running, bills: [], payday: true });
}
return {
next_payday: nextPayday,
days_until_payday: daysUntilPayday,
available: roundMoney(available),
safe_to_spend: safeToSpend,
still_due_total: stillDueTotal,
still_due_count: stillDueRows.length,
upcoming: stillDueRows.slice(0, 8).map(r => ({
id: r.id,
name: r.name,
due_date: r.due_date,
amount: rowOutstanding(r),
status: r.status,
})),
timeline,
};
}
function applyAutopaySuggestions(db, bill, payments, mbs, year, month, todayStr, dismissedSuggestions) {
const dueDate = resolveDueDate(bill, year, month);
if (!dueDate) return null;
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) {
const range = getCycleRange(year, month, bill);
const existingPayment = db.prepare(`
SELECT bill_id, id, amount, paid_date, method, notes, payment_source, transaction_id, created_at, updated_at
FROM payments
WHERE bill_id = ?
AND deleted_at IS NULL
AND ${accountingActiveSql()}
AND paid_date BETWEEN ? AND ?
ORDER BY paid_date DESC
LIMIT 1
`).get(bill.id, range.start, range.end);
if (existingPayment) {
payments.push(existingPayment);
return null;
}
const balCalc = computeBalanceDelta(bill, suggestedAmount);
// Atomic: the auto-mark payment INSERT and its balance update must both land
// or neither. This runs on a GET /tracker, so a mid-way failure would
// otherwise leave a payment without its balance adjustment (or vice versa).
const insertAutoPayment = db.transaction(() => {
const r = db.prepare(`
INSERT INTO payments (bill_id, amount, paid_date, method, notes, balance_delta, interest_delta, payment_source)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`).run(
bill.id,
suggestedAmount,
dueDate,
'autopay',
'Auto-marked paid on due date',
balCalc?.balance_delta ?? null,
balCalc?.interest_delta ?? null,
'manual',
);
if (balCalc) applyBalanceDelta(db, bill.id, balCalc);
return r.lastInsertRowid;
});
const paymentId = insertAutoPayment();
if (balCalc) bill.current_balance = balCalc.new_balance;
payments.push(db.prepare(`
SELECT bill_id, id, amount, paid_date, method, notes, payment_source, transaction_id, created_at, updated_at
FROM payments
WHERE id = ?
`).get(paymentId));
return null;
}
if (dismissedSuggestions.has(bill.id)) return null;
return {
bill_id: bill.id,
amount: fromCents(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
AND ${accountingActiveSql('p')}
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: fromCents(monthlyPaymentsMap.get(monthKey) || 0),
});
}
const threeMonthAvg = sumMoney(months, m => m.payment) / 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 = localDateString(now);
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 sparklines = fetchSparklines(db, billIds);
const autopayStatsMap = fetchAutopayStats(db, billIds);
// Batched to avoid an N+1 across bills (was ~23 queries × N bills every load):
// one query for all cycle payments, two for all amount suggestions.
const paymentsByBill = fetchPaymentsForBillsInMonth(db, bills, year, month);
const amountSuggestions = computeAmountSuggestionsBatch(db, billIds, year, month);
const rows = bills.map(bill => {
bill.sparkline = sparklines[bill.id] ?? null;
bill.autopay_stats = autopayStatsMap[bill.id] ?? null;
if (!resolveDueDate(bill, year, month)) return null;
const payments = paymentsByBill.get(bill.id) || [];
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);
if (!row) return null;
row.expected_amount = fromCents(bill.expected_amount);
row.actual_amount = mbs?.actual_amount != null ? fromCents(mbs.actual_amount) : null;
row.monthly_notes = mbs?.notes ?? null;
row.is_skipped = !!(mbs?.is_skipped);
row.snoozed_until = mbs?.snoozed_until ?? null;
if (autopaySuggestion) row.autopay_suggestion = autopaySuggestion;
row.previous_month_paid = fromCents(prevMonthPayments[bill.id] || 0);
row.amount_suggestion = amountSuggestions.get(bill.id) ?? null;
return row;
}).filter(Boolean);
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);
if (startingAmounts) {
startingAmounts.first_amount = fromCents(startingAmounts.first_amount);
startingAmounts.fifteenth_amount = fromCents(startingAmounts.fifteenth_amount);
startingAmounts.other_amount = fromCents(startingAmounts.other_amount);
startingAmounts.combined_amount = fromCents(startingAmounts.combined_amount);
}
const dayOfMonth = now.getDate();
const activeRemainingPeriod = dayOfMonth < 15 ? '1st' : '15th';
const periodRows = activeRows.filter(r => r.bucket === activeRemainingPeriod);
const periodPaidTowardDue = sumMoney(periodRows, rowPaidTowardDue);
const periodOutstandingBalance = sumMoney(periodRows, rowOutstanding);
const periodStartingAmount = activeRemainingPeriod === '1st'
? (startingAmounts?.first_amount || 0)
: (startingAmounts?.fifteenth_amount || 0);
const periodLabel = activeRemainingPeriod === '1st' ? '1st balance' : '15th balance';
// Occurrence-gated unpaid total for this month: the still-owed amount across
// bills actually due this month (activeRows already honor resolveDueDate),
// netting partial payments. Feeds the bank card so its unpaid/remaining agree
// with the rows instead of over-counting annual/off-month bills.
const activeMonthUnpaid = sumMoney(activeRows, r => Math.max(rowDueAmount(r) - rowPaidTowardDue(r), 0));
const bankTracking = buildBankTracking(db, userId, year, month, activeMonthUnpaid);
const bankPendingCounts = bankTracking.enabled ? fetchBankPendingCounts(db, userId, billIds) : {};
const totalStarting = bankTracking.enabled
? bankTracking.effective_balance
: (startingAmounts?.combined_amount || 0);
const hasStartingAmounts = bankTracking.enabled || !!startingAmounts;
// ── Cash flow projection ───────────────────────────────────────────────────
// "Projected" means starting minus ALL bills due — paid or not.
// This tells the user what they'll have left after everything clears,
// not just what remains after what they've already paid.
const lastDayOfMonth = new Date(year, month, 0).getDate();
const periodEndDay = activeRemainingPeriod === '1st' ? 14 : lastDayOfMonth;
const periodEndLabel = `${['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'][month - 1]} ${periodEndDay}`;
const activeTotalPaid = sumMoney(activeRows, r => r.total_paid);
const activePaidTowardDue = sumMoney(activeRows, rowPaidTowardDue);
const activeTotalExpected = sumMoney(activeRows, rowDueAmount);
const activeOutstandingBalance = sumMoney(activeRows, rowOutstanding);
const periodBillsTotal = sumMoney(periodRows, rowDueAmount);
const periodPaidCount = periodRows.filter(r => isPaidStatus(r.status)).length;
const periodTotalCount = periodRows.length;
// When bank tracking is on use the effective balance as the period starting point
const periodCashStart = bankTracking.enabled
? bankTracking.effective_balance
: periodStartingAmount;
const periodProjected = roundMoney(periodCashStart - periodBillsTotal);
const monthBillsTotal = activeTotalExpected;
const monthPaidCount = activeRows.filter(r => isPaidStatus(r.status)).length;
const monthTotalCount = activeRows.length;
const monthProjected = roundMoney(totalStarting - monthBillsTotal);
// Safe to spend: cash on hand for this period after already-made payments
// (bank mode: effective balance already nets them out), minus everything
// still due before the next payday.
const availableNow = bankTracking.enabled
? bankTracking.effective_balance
: roundMoney(periodStartingAmount - periodPaidTowardDue);
const safeToSpend = buildSafeToSpend({
activeRows,
available: availableNow,
todayStr,
year,
month,
dayOfMonth,
});
const cashflow = {
...safeToSpend,
has_data: hasStartingAmounts,
uses_bank_balance: bankTracking.enabled,
period: activeRemainingPeriod,
period_end_label: periodEndLabel,
period_starting: periodCashStart,
period_bills_total: periodBillsTotal,
period_paid: periodPaidTowardDue,
period_paid_count: periodPaidCount,
period_total_count: periodTotalCount,
period_projected: periodProjected,
month_starting: totalStarting,
month_bills_total: monthBillsTotal,
month_paid: roundMoney(activePaidTowardDue),
month_paid_count: monthPaidCount,
month_total_count: monthTotalCount,
month_projected: monthProjected,
};
const totalOverdue = sumMoney(
rows.filter(r => !r.is_skipped && (r.status === 'late' || r.status === 'missed')),
r => r.balance);
const previousMonthTotal = sumMoney(activeRows, r => r.previous_month_paid);
return {
year,
month,
today: todayStr,
summary: {
total_expected: activeTotalExpected,
total_starting: totalStarting,
has_starting_amounts: hasStartingAmounts,
total_paid: activeTotalPaid,
paid_toward_due: activePaidTowardDue,
// In bank mode the effective bank balance is a single pool (no per-period
// split), so both remaining figures use the bank card's own remaining
// (effective_balance gated unpaid) — keeping the summary consistent with
// safe-to-spend and the bank card instead of showing a different number
// derived from manual starting amounts.
remaining: roundMoney(bankTracking.enabled
? bankTracking.remaining
: (hasStartingAmounts ? periodStartingAmount - periodPaidTowardDue : periodOutstandingBalance)),
total_remaining: roundMoney(bankTracking.enabled
? bankTracking.remaining
: (hasStartingAmounts ? totalStarting - activePaidTowardDue : activeOutstandingBalance)),
remaining_period: activeRemainingPeriod,
remaining_label: periodLabel,
remaining_hint: hasStartingAmounts
? `${periodLabel}: ${periodStartingAmount.toFixed(2)} starting minus ${periodPaidTowardDue.toFixed(2)} paid toward due`
: `${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),
},
bank_tracking: bankTracking,
cashflow,
rows: bankTracking.enabled
? rows.map(r => {
const bank_pending_count = bankPendingCounts[r.id] || 0;
// Only flag manually-entered payments as pending-cleared — bank-synced
// or bank-matched payments are already settled so they don't need the badge.
const isManualPayment = r.payment_source !== 'provider_sync' && r.payment_source !== 'transaction_match';
// Amber "Pending" only makes sense for bills linked to the bank — for unlinked bills
// there's no bank clearing to wait for, so they should just show "Paid".
const isBankLinked = !!(r.has_merchant_rule || r.has_linked_transactions);
if (r.status === 'paid' && r.last_paid_date && isManualPayment && isBankLinked) {
const cutoff = new Date();
cutoff.setDate(cutoff.getDate() - bankTracking.pending_days);
const paidAt = new Date(r.last_paid_date);
return { ...r, pending_cleared: paidAt >= cutoff, bank_pending_count };
}
return { ...r, pending_cleared: false, bank_pending_count };
})
: 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 = localDateString(now);
const userSettings = getUserSettings(userId);
const rowOptions = { gracePeriodDays: userSettings.grace_period_days };
const bills = fetchActiveBills(db, userId, 'id');
const cutoff = new Date(now);
cutoff.setDate(cutoff.getDate() + days);
const cutoffStr = localDateString(cutoff);
const upcoming = [];
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: row.expected_amount,
status: row.status,
days_until_due: Math.floor((new Date(`${dueDate}T00:00:00`) - new Date(`${todayStr}T00:00:00`)) / 86400000),
});
}
}
upcoming.sort((a, b) => a.due_date.localeCompare(b.due_date));
return { days, today: todayStr, upcoming };
}
function getOverdueCount(userId, now = new Date()) {
const db = getDb();
const todayStr = localDateString(now);
const year = now.getFullYear();
const month = now.getMonth() + 1;
const monthStr = String(month).padStart(2, '0');
const rangeStart = `${year}-${monthStr}-01`;
const lastDay = new Date(year, month, 0).getDate();
const rangeEnd = `${year}-${monthStr}-${String(lastDay).padStart(2, '0')}`;
const bills = db.prepare(`
SELECT b.id, b.name, b.due_day, b.override_due_date, b.expected_amount,
b.billing_cycle, b.cycle_type, b.cycle_day,
b.autopay_enabled, b.autodraft_status,
mbs.actual_amount, mbs.is_skipped, mbs.snoozed_until,
COALESCE(SUM(p.amount), 0) AS total_paid
FROM bills b
LEFT JOIN monthly_bill_state mbs
ON mbs.bill_id = b.id AND mbs.year = ? AND mbs.month = ?
LEFT JOIN payments p
ON p.bill_id = b.id AND p.paid_date BETWEEN ? AND ? AND p.deleted_at IS NULL
AND ${accountingActiveSql('p')}
WHERE b.user_id = ? AND b.active = 1 AND b.deleted_at IS NULL
GROUP BY b.id
`).all(year, month, rangeStart, rangeEnd, userId);
let count = 0;
const overdueNames = [];
for (const bill of bills) {
if (bill.is_skipped) continue;
if (bill.snoozed_until && bill.snoozed_until > todayStr) continue;
if (bill.autopay_enabled && bill.autodraft_status === 'assumed_paid') continue;
const dueDate = resolveDueDate(bill, year, month);
// Use >= so bills due TODAY are not counted as overdue — only strictly past dates
if (!dueDate || dueDate >= todayStr) continue;
const threshold = bill.actual_amount != null ? bill.actual_amount : bill.expected_amount;
if (threshold > 0 && bill.total_paid >= threshold) continue;
count++;
overdueNames.push(bill.name);
}
return { count, names: overdueNames, month, year, today: todayStr };
}
module.exports = {
getTracker,
getUpcomingBills,
validateTrackerMonth,
getOverdueCount,
buildSafeToSpend,
};