2026-05-16 15:38:28 -05:00
|
|
|
'use strict';
|
|
|
|
|
|
|
|
|
|
const { getDb } = require('../db/database');
|
2026-06-11 20:12:31 -05:00
|
|
|
const { buildTrackerRow, getCycleRange, resolveDueDate } = require('./statusService');
|
2026-05-16 15:38:28 -05:00
|
|
|
const { getUserSettings } = require('./userSettings');
|
2026-06-06 16:34:20 -05:00
|
|
|
const { computeBalanceDelta, applyBalanceDelta } = require('./billsService');
|
2026-05-28 02:09:49 -05:00
|
|
|
const { computeAmountSuggestion } = require('./amountSuggestionService');
|
2026-06-07 01:05:48 -05:00
|
|
|
const { accountingActiveSql } = require('./paymentAccountingService');
|
2026-06-08 16:05:31 -05:00
|
|
|
const { normalizeMerchant } = require('./subscriptionService');
|
2026-06-10 19:42:51 -05:00
|
|
|
const { localDateString } = require('../utils/dates');
|
2026-06-11 20:12:31 -05:00
|
|
|
const { sumMoney, roundMoney, fromCents } = require('../utils/money');
|
2026-05-16 15:38:28 -05:00
|
|
|
|
2026-06-03 21:09:26 -05:00
|
|
|
const DEFAULT_PENDING_DAYS = 3;
|
|
|
|
|
|
2026-06-08 16:05:31 -05:00
|
|
|
// 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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-03 21:09:26 -05:00
|
|
|
function buildBankTracking(db, userId, year, month) {
|
2026-06-04 02:48:32 -05:00
|
|
|
try {
|
2026-06-03 21:09:26 -05:00
|
|
|
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;
|
|
|
|
|
|
2026-06-04 01:27:00 -05:00
|
|
|
// 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.
|
2026-06-03 21:09:26 -05:00
|
|
|
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
|
2026-06-07 01:05:48 -05:00
|
|
|
AND ${accountingActiveSql('p')}
|
|
|
|
|
AND (p.payment_source IS NULL OR p.payment_source NOT IN ('provider_sync', 'transaction_match', 'auto_match'))
|
2026-06-03 21:09:26 -05:00
|
|
|
`).get(userId, days)
|
|
|
|
|
: { pending_total: 0 };
|
|
|
|
|
|
|
|
|
|
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
|
2026-06-07 01:05:48 -05:00
|
|
|
WHERE paid_date BETWEEN ? AND ?
|
|
|
|
|
AND deleted_at IS NULL
|
|
|
|
|
AND ${accountingActiveSql()}
|
|
|
|
|
GROUP BY bill_id
|
2026-06-03 21:09:26 -05:00
|
|
|
) 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);
|
|
|
|
|
|
|
|
|
|
const balance = roundMoney(account.balance / 100);
|
2026-06-11 20:12:31 -05:00
|
|
|
const pending = fromCents(pendingRow.pending_total);
|
2026-06-03 21:09:26 -05:00
|
|
|
const effective = roundMoney(balance - pending);
|
2026-06-11 20:12:31 -05:00
|
|
|
const unpaid = fromCents(unpaidRow.unpaid_total);
|
2026-06-03 21:09:26 -05:00
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
};
|
2026-06-04 02:48:32 -05:00
|
|
|
} catch (err) {
|
|
|
|
|
console.error('[buildBankTracking] Error computing bank tracking data:', err.message);
|
|
|
|
|
return { enabled: false };
|
|
|
|
|
}
|
2026-06-03 21:09:26 -05:00
|
|
|
}
|
|
|
|
|
|
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 };
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-28 03:43:50 -05:00
|
|
|
const FETCH_BILLS_ORDER = {
|
2026-05-30 16:13:37 -05:00
|
|
|
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',
|
2026-05-28 03:43:50 -05:00
|
|
|
id: 'b.id ASC',
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
function fetchActiveBills(db, userId, orderKey = 'due_day') {
|
|
|
|
|
const orderBy = FETCH_BILLS_ORDER[orderKey] ?? FETCH_BILLS_ORDER.due_day;
|
2026-05-16 15:38:28 -05:00
|
|
|
return db.prepare(`
|
2026-06-06 23:04:53 -05:00
|
|
|
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
|
2026-05-16 15:38:28 -05:00
|
|
|
FROM bills b
|
|
|
|
|
LEFT JOIN categories c ON b.category_id = c.id AND c.deleted_at IS NULL
|
2026-06-06 23:04:53 -05:00
|
|
|
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
|
2026-05-16 15:38:28 -05:00
|
|
|
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(`
|
2026-05-30 13:19:09 -05:00
|
|
|
SELECT bill_id, actual_amount, notes, is_skipped, snoozed_until
|
2026-05-16 15:38:28 -05:00
|
|
|
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
|
2026-06-07 01:05:48 -05:00
|
|
|
AND ${accountingActiveSql()}
|
2026-05-16 15:38:28 -05:00
|
|
|
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
|
2026-06-07 01:05:48 -05:00
|
|
|
AND ${accountingActiveSql()}
|
2026-05-16 15:38:28 -05:00
|
|
|
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-06-07 14:49:39 -05:00
|
|
|
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] = [];
|
2026-06-11 20:12:31 -05:00
|
|
|
out[r.bill_id].push(fromCents(r.total));
|
2026-06-07 14:49:39 -05:00
|
|
|
}
|
|
|
|
|
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,
|
|
|
|
|
}]));
|
|
|
|
|
}
|
|
|
|
|
|
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-06-07 01:05:48 -05:00
|
|
|
AND ${accountingActiveSql()}
|
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-06-06 16:34:20 -05:00
|
|
|
INSERT INTO payments (bill_id, amount, paid_date, method, notes, balance_delta, interest_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-06-06 16:34:20 -05:00
|
|
|
balCalc?.interest_delta ?? null,
|
2026-05-16 20:26:09 -05:00
|
|
|
'manual',
|
2026-05-16 15:38:28 -05:00
|
|
|
);
|
|
|
|
|
|
|
|
|
|
if (balCalc) {
|
2026-06-06 16:34:20 -05:00
|
|
|
applyBalanceDelta(db, bill.id, balCalc);
|
2026-05-16 15:38:28 -05:00
|
|
|
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,
|
2026-06-11 20:12:31 -05:00
|
|
|
amount: fromCents(suggestedAmount),
|
2026-05-16 15:38:28 -05:00
|
|
|
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
|
2026-06-07 01:05:48 -05:00
|
|
|
AND ${accountingActiveSql('p')}
|
2026-05-16 15:38:28 -05:00
|
|
|
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,
|
2026-06-11 20:12:31 -05:00
|
|
|
payment: fromCents(monthlyPaymentsMap.get(monthKey) || 0),
|
2026-05-16 15:38:28 -05:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-10 20:14:13 -05:00
|
|
|
const threeMonthAvg = sumMoney(months, m => m.payment) / 3;
|
2026-05-16 15:38:28 -05:00
|
|
|
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;
|
2026-06-10 19:42:51 -05:00
|
|
|
const todayStr = localDateString(now);
|
2026-05-16 15:38:28 -05:00
|
|
|
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);
|
2026-06-07 14:49:39 -05:00
|
|
|
const sparklines = fetchSparklines(db, billIds);
|
|
|
|
|
const autopayStatsMap = fetchAutopayStats(db, billIds);
|
2026-05-16 15:38:28 -05:00
|
|
|
|
|
|
|
|
const rows = bills.map(bill => {
|
2026-06-07 14:49:39 -05:00
|
|
|
bill.sparkline = sparklines[bill.id] ?? null;
|
|
|
|
|
bill.autopay_stats = autopayStatsMap[bill.id] ?? null;
|
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-06-11 20:12:31 -05:00
|
|
|
row.expected_amount = fromCents(bill.expected_amount);
|
|
|
|
|
row.actual_amount = mbs?.actual_amount != null ? fromCents(mbs.actual_amount) : null;
|
2026-05-16 15:38:28 -05:00
|
|
|
row.monthly_notes = mbs?.notes ?? null;
|
|
|
|
|
row.is_skipped = !!(mbs?.is_skipped);
|
2026-05-30 13:19:09 -05:00
|
|
|
row.snoozed_until = mbs?.snoozed_until ?? null;
|
2026-05-16 15:38:28 -05:00
|
|
|
if (autopaySuggestion) row.autopay_suggestion = autopaySuggestion;
|
2026-06-11 20:12:31 -05:00
|
|
|
row.previous_month_paid = fromCents(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);
|
|
|
|
|
|
2026-06-11 20:12:31 -05:00
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-16 15:38:28 -05:00
|
|
|
const dayOfMonth = now.getDate();
|
|
|
|
|
const activeRemainingPeriod = dayOfMonth < 15 ? '1st' : '15th';
|
|
|
|
|
const periodRows = activeRows.filter(r => r.bucket === activeRemainingPeriod);
|
2026-06-10 20:14:13 -05:00
|
|
|
const periodPaidTowardDue = sumMoney(periodRows, rowPaidTowardDue);
|
|
|
|
|
const periodOutstandingBalance = sumMoney(periodRows, r => Math.max(r.balance || 0, 0));
|
2026-05-16 15:38:28 -05:00
|
|
|
const periodStartingAmount = activeRemainingPeriod === '1st'
|
|
|
|
|
? (startingAmounts?.first_amount || 0)
|
|
|
|
|
: (startingAmounts?.fifteenth_amount || 0);
|
|
|
|
|
const periodLabel = activeRemainingPeriod === '1st' ? '1st balance' : '15th balance';
|
|
|
|
|
|
2026-06-03 21:09:26 -05:00
|
|
|
const bankTracking = buildBankTracking(db, userId, year, month);
|
2026-06-08 16:05:31 -05:00
|
|
|
const bankPendingCounts = bankTracking.enabled ? fetchBankPendingCounts(db, userId, billIds) : {};
|
2026-06-03 21:09:26 -05:00
|
|
|
const totalStarting = bankTracking.enabled
|
|
|
|
|
? bankTracking.effective_balance
|
|
|
|
|
: (startingAmounts?.combined_amount || 0);
|
|
|
|
|
const hasStartingAmounts = bankTracking.enabled || !!startingAmounts;
|
2026-06-03 21:30:02 -05:00
|
|
|
|
|
|
|
|
// ── 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}`;
|
|
|
|
|
|
2026-06-10 20:14:13 -05:00
|
|
|
const activeTotalPaid = sumMoney(activeRows, r => r.total_paid);
|
|
|
|
|
const activePaidTowardDue = sumMoney(activeRows, rowPaidTowardDue);
|
|
|
|
|
const activeTotalExpected = sumMoney(activeRows, rowDueAmount);
|
|
|
|
|
const activeOutstandingBalance = sumMoney(activeRows, r => Math.max(r.balance || 0, 0));
|
2026-06-03 23:08:20 -05:00
|
|
|
|
2026-06-10 20:14:13 -05:00
|
|
|
const periodBillsTotal = sumMoney(periodRows, rowDueAmount);
|
2026-06-03 21:30:02 -05:00
|
|
|
const periodPaidCount = periodRows.filter(r => ['paid', 'autodraft'].includes(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 => ['paid', 'autodraft'].includes(r.status)).length;
|
|
|
|
|
const monthTotalCount = activeRows.length;
|
|
|
|
|
const monthProjected = roundMoney(totalStarting - monthBillsTotal);
|
|
|
|
|
|
|
|
|
|
const cashflow = {
|
|
|
|
|
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,
|
|
|
|
|
};
|
2026-06-10 20:14:13 -05:00
|
|
|
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);
|
2026-05-16 15:38:28 -05:00
|
|
|
|
|
|
|
|
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,
|
2026-05-31 15:06:10 -05:00
|
|
|
remaining: roundMoney(hasStartingAmounts ? periodStartingAmount - periodPaidTowardDue : periodOutstandingBalance),
|
|
|
|
|
total_remaining: roundMoney(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),
|
|
|
|
|
},
|
2026-06-03 21:09:26 -05:00
|
|
|
bank_tracking: bankTracking,
|
2026-06-03 21:30:02 -05:00
|
|
|
cashflow,
|
2026-06-03 21:09:26 -05:00
|
|
|
rows: bankTracking.enabled
|
|
|
|
|
? rows.map(r => {
|
2026-06-08 16:05:31 -05:00
|
|
|
const bank_pending_count = bankPendingCounts[r.id] || 0;
|
2026-06-04 01:27:00 -05:00
|
|
|
// Only flag manually-entered payments as pending-cleared — bank-synced
|
2026-06-08 16:05:31 -05:00
|
|
|
// 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';
|
2026-06-08 16:33:48 -05:00
|
|
|
// 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) {
|
2026-06-03 21:09:26 -05:00
|
|
|
const cutoff = new Date();
|
|
|
|
|
cutoff.setDate(cutoff.getDate() - bankTracking.pending_days);
|
|
|
|
|
const paidAt = new Date(r.last_paid_date);
|
2026-06-08 16:05:31 -05:00
|
|
|
return { ...r, pending_cleared: paidAt >= cutoff, bank_pending_count };
|
2026-06-03 21:09:26 -05:00
|
|
|
}
|
2026-06-08 16:05:31 -05:00
|
|
|
return { ...r, pending_cleared: false, bank_pending_count };
|
2026-06-03 21:09:26 -05:00
|
|
|
})
|
|
|
|
|
: rows,
|
2026-05-16 15:38:28 -05:00
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getUpcomingBills(userId, query = {}, now = new Date()) {
|
|
|
|
|
const db = getDb();
|
|
|
|
|
const days = Math.max(1, Math.min(parseInt(query.days || '30', 10) || 30, 365));
|
2026-06-10 19:42:51 -05:00
|
|
|
const todayStr = localDateString(now);
|
2026-05-16 15:38:28 -05:00
|
|
|
const userSettings = getUserSettings(userId);
|
|
|
|
|
const rowOptions = { gracePeriodDays: userSettings.grace_period_days };
|
2026-05-28 03:43:50 -05:00
|
|
|
const bills = fetchActiveBills(db, userId, 'id');
|
2026-05-16 15:38:28 -05:00
|
|
|
|
|
|
|
|
const cutoff = new Date(now);
|
|
|
|
|
cutoff.setDate(cutoff.getDate() + days);
|
2026-06-10 19:42:51 -05:00
|
|
|
const cutoffStr = localDateString(cutoff);
|
2026-05-16 15:38:28 -05:00
|
|
|
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,
|
2026-06-11 20:12:31 -05:00
|
|
|
expected_amount: row.expected_amount,
|
2026-05-16 20:26:09 -05:00
|
|
|
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 };
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-30 13:19:09 -05:00
|
|
|
function getOverdueCount(userId, now = new Date()) {
|
|
|
|
|
const db = getDb();
|
2026-06-10 19:42:51 -05:00
|
|
|
const todayStr = localDateString(now);
|
2026-05-30 13:19:09 -05:00
|
|
|
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(`
|
2026-06-04 01:16:48 -05:00
|
|
|
SELECT b.id, b.name, b.due_day, b.override_due_date, b.expected_amount,
|
2026-05-30 13:19:09 -05:00
|
|
|
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
|
2026-06-07 01:05:48 -05:00
|
|
|
AND ${accountingActiveSql('p')}
|
2026-05-30 13:19:09 -05:00
|
|
|
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;
|
2026-06-04 01:00:29 -05:00
|
|
|
const overdueNames = [];
|
2026-05-30 13:19:09 -05:00
|
|
|
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);
|
2026-06-04 01:00:29 -05:00
|
|
|
// Use >= so bills due TODAY are not counted as overdue — only strictly past dates
|
|
|
|
|
if (!dueDate || dueDate >= todayStr) continue;
|
2026-05-30 13:19:09 -05:00
|
|
|
|
|
|
|
|
const threshold = bill.actual_amount != null ? bill.actual_amount : bill.expected_amount;
|
|
|
|
|
if (threshold > 0 && bill.total_paid >= threshold) continue;
|
|
|
|
|
|
|
|
|
|
count++;
|
2026-06-04 01:00:29 -05:00
|
|
|
overdueNames.push(bill.name);
|
2026-05-30 13:19:09 -05:00
|
|
|
}
|
|
|
|
|
|
2026-06-04 01:00:29 -05:00
|
|
|
return { count, names: overdueNames, month, year, today: todayStr };
|
2026-05-30 13:19:09 -05:00
|
|
|
}
|
|
|
|
|
|
2026-05-16 15:38:28 -05:00
|
|
|
module.exports = {
|
|
|
|
|
getTracker,
|
|
|
|
|
getUpcomingBills,
|
|
|
|
|
validateTrackerMonth,
|
2026-05-30 13:19:09 -05:00
|
|
|
getOverdueCount,
|
2026-05-16 15:38:28 -05:00
|
|
|
};
|