diff --git a/client/components/BillModal.jsx b/client/components/BillModal.jsx index 862f96f..4ad26ef 100644 --- a/client/components/BillModal.jsx +++ b/client/components/BillModal.jsx @@ -1155,7 +1155,7 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa )}

- {fmtDate(payment.paid_date)} · {payment.method || 'manual'} + {fmtDate(payment.paid_date)} · {payment.method || (payment.payment_source === 'transaction_match' ? 'Synced' : 'manual')}

{payment.notes && (

{payment.notes}

diff --git a/client/components/tracker/OverdueCommandCenter.jsx b/client/components/tracker/OverdueCommandCenter.jsx index bdcce75..0f850e3 100644 --- a/client/components/tracker/OverdueCommandCenter.jsx +++ b/client/components/tracker/OverdueCommandCenter.jsx @@ -157,7 +157,7 @@ export default function OverdueCommandCenter({ rows, year, month, refresh, onPay r.snoozed_until && r.snoozed_until > todayStr ); - if (overdueRows.length === 0 && snoozedRows.length === 0) return null; + if (overdueRows.length === 0) return null; const totalOverdue = overdueRows.reduce((sum, r) => { const threshold = r.actual_amount ?? r.expected_amount; @@ -203,24 +203,18 @@ export default function OverdueCommandCenter({ rows, year, month, refresh, onPay {/* Bill rows */} - {overdueRows.length > 0 ? ( -
- {overdueRows.map(row => ( - - ))} -
- ) : ( -

- All overdue bills are snoozed. -

- )} +
+ {overdueRows.map(row => ( + + ))} +
diff --git a/client/components/tracker/TrackerRow.jsx b/client/components/tracker/TrackerRow.jsx index 8cecf61..78d6279 100644 --- a/client/components/tracker/TrackerRow.jsx +++ b/client/components/tracker/TrackerRow.jsx @@ -595,22 +595,30 @@ export function TrackerRow({ row, year, month, refresh, index, onEditBill, moveC {showColumn('status') && (
- { - if (effectiveStatus === 'skipped') return; - handleTogglePaid(); - }} - loading={loading} - /> - {row.pending_cleared && ( + {row.bank_pending_count > 0 ? ( + 1 ? 's' : ''} matching this bill`} + > + Pending + + ) : row.pending_cleared ? ( Pending + ) : ( + { + if (effectiveStatus === 'skipped') return; + handleTogglePaid(); + }} + loading={loading} + /> )}
diff --git a/services/trackerService.js b/services/trackerService.js index ba98fed..41d230b 100644 --- a/services/trackerService.js +++ b/services/trackerService.js @@ -6,9 +6,62 @@ const { getUserSettings } = require('./userSettings'); const { computeBalanceDelta, applyBalanceDelta } = require('./billsService'); const { computeAmountSuggestion } = require('./amountSuggestionService'); const { accountingActiveSql } = require('./paymentAccountingService'); +const { normalizeMerchant } = require('./subscriptionService'); 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; +} + function buildBankTracking(db, userId, year, month) { try { const settings = getUserSettings(userId); @@ -432,6 +485,7 @@ function getTracker(userId, query = {}, now = new Date()) { const periodLabel = activeRemainingPeriod === '1st' ? '1st balance' : '15th balance'; const bankTracking = buildBankTracking(db, userId, year, month); + const bankPendingCounts = bankTracking.enabled ? fetchBankPendingCounts(db, userId, billIds) : {}; const totalStarting = bankTracking.enabled ? bankTracking.effective_balance : (startingAmounts?.combined_amount || 0); @@ -517,15 +571,17 @@ function getTracker(userId, query = {}, now = new Date()) { 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 - // payments are already in the balance so they don't need the badge. - if (r.status === 'paid' && r.last_paid_date && r.payment_source !== 'provider_sync') { + // 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'; + if (r.status === 'paid' && r.last_paid_date && isManualPayment) { 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 }; + return { ...r, pending_cleared: paidAt >= cutoff, bank_pending_count }; } - return { ...r, pending_cleared: false }; + return { ...r, pending_cleared: false, bank_pending_count }; }) : rows, };