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,
};