fix(tracker): bank pending counts, overdue center cleanup, and payment source labels
- Add bank_pending_count to tracker rows showing pending bank transaction matches for bills with merchant rules - Remove snoozed-only state from OverdueCommandCenter (always show when overdue rows exist) - Display 'Synced' label for transaction-matched payments in BillModal - Prioritize 'Pending' badge over StatusBadge when bank has pending matches - Exclude bank-synced and transaction-matched payments from pending_cleared (batch 0.37.3)
This commit is contained in:
parent
626459322f
commit
fab4945d50
|
|
@ -1155,7 +1155,7 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-0.5 truncate text-xs text-muted-foreground">
|
<p className="mt-0.5 truncate text-xs text-muted-foreground">
|
||||||
{fmtDate(payment.paid_date)} · {payment.method || 'manual'}
|
{fmtDate(payment.paid_date)} · {payment.method || (payment.payment_source === 'transaction_match' ? 'Synced' : 'manual')}
|
||||||
</p>
|
</p>
|
||||||
{payment.notes && (
|
{payment.notes && (
|
||||||
<p className="mt-0.5 truncate text-xs text-muted-foreground/80">{payment.notes}</p>
|
<p className="mt-0.5 truncate text-xs text-muted-foreground/80">{payment.notes}</p>
|
||||||
|
|
|
||||||
|
|
@ -157,7 +157,7 @@ export default function OverdueCommandCenter({ rows, year, month, refresh, onPay
|
||||||
r.snoozed_until && r.snoozed_until > todayStr
|
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 totalOverdue = overdueRows.reduce((sum, r) => {
|
||||||
const threshold = r.actual_amount ?? r.expected_amount;
|
const threshold = r.actual_amount ?? r.expected_amount;
|
||||||
|
|
@ -203,24 +203,18 @@ export default function OverdueCommandCenter({ rows, year, month, refresh, onPay
|
||||||
|
|
||||||
{/* Bill rows */}
|
{/* Bill rows */}
|
||||||
<CollapsibleContent>
|
<CollapsibleContent>
|
||||||
{overdueRows.length > 0 ? (
|
<div className="divide-y divide-border/40 px-4 pb-2">
|
||||||
<div className="divide-y divide-border/40 px-4 pb-2">
|
{overdueRows.map(row => (
|
||||||
{overdueRows.map(row => (
|
<OverdueRow
|
||||||
<OverdueRow
|
key={row.id}
|
||||||
key={row.id}
|
row={row}
|
||||||
row={row}
|
year={year}
|
||||||
year={year}
|
month={month}
|
||||||
month={month}
|
onPayNow={onPayNow}
|
||||||
onPayNow={onPayNow}
|
onRefresh={refresh}
|
||||||
onRefresh={refresh}
|
/>
|
||||||
/>
|
))}
|
||||||
))}
|
</div>
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<p className="px-4 pb-3 text-xs text-muted-foreground">
|
|
||||||
All overdue bills are snoozed.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</CollapsibleContent>
|
</CollapsibleContent>
|
||||||
</div>
|
</div>
|
||||||
</Collapsible>
|
</Collapsible>
|
||||||
|
|
|
||||||
|
|
@ -595,22 +595,30 @@ export function TrackerRow({ row, year, month, refresh, index, onEditBill, moveC
|
||||||
{showColumn('status') && (
|
{showColumn('status') && (
|
||||||
<TableCell className="w-[9%] py-2.5">
|
<TableCell className="w-[9%] py-2.5">
|
||||||
<div className="flex flex-col items-center gap-1">
|
<div className="flex flex-col items-center gap-1">
|
||||||
<StatusBadge
|
{row.bank_pending_count > 0 ? (
|
||||||
status={effectiveStatus}
|
<span
|
||||||
clickable
|
className="inline-flex items-center rounded-full border border-emerald-400/40 bg-emerald-500/10 px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-wide text-emerald-600 dark:text-emerald-400"
|
||||||
onClick={() => {
|
title={`Bank has ${row.bank_pending_count} pending charge${row.bank_pending_count > 1 ? 's' : ''} matching this bill`}
|
||||||
if (effectiveStatus === 'skipped') return;
|
>
|
||||||
handleTogglePaid();
|
Pending
|
||||||
}}
|
</span>
|
||||||
loading={loading}
|
) : row.pending_cleared ? (
|
||||||
/>
|
|
||||||
{row.pending_cleared && (
|
|
||||||
<span
|
<span
|
||||||
className="inline-flex items-center rounded-full border border-amber-400/40 bg-amber-500/10 px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-wide text-amber-600 dark:text-amber-400"
|
className="inline-flex items-center rounded-full border border-amber-400/40 bg-amber-500/10 px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-wide text-amber-600 dark:text-amber-400"
|
||||||
title="Paid in tracker but may not have cleared your bank account yet"
|
title="Paid in tracker but may not have cleared your bank account yet"
|
||||||
>
|
>
|
||||||
Pending
|
Pending
|
||||||
</span>
|
</span>
|
||||||
|
) : (
|
||||||
|
<StatusBadge
|
||||||
|
status={effectiveStatus}
|
||||||
|
clickable
|
||||||
|
onClick={() => {
|
||||||
|
if (effectiveStatus === 'skipped') return;
|
||||||
|
handleTogglePaid();
|
||||||
|
}}
|
||||||
|
loading={loading}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|
|
||||||
|
|
@ -6,9 +6,62 @@ const { getUserSettings } = require('./userSettings');
|
||||||
const { computeBalanceDelta, applyBalanceDelta } = require('./billsService');
|
const { computeBalanceDelta, applyBalanceDelta } = require('./billsService');
|
||||||
const { computeAmountSuggestion } = require('./amountSuggestionService');
|
const { computeAmountSuggestion } = require('./amountSuggestionService');
|
||||||
const { accountingActiveSql } = require('./paymentAccountingService');
|
const { accountingActiveSql } = require('./paymentAccountingService');
|
||||||
|
const { normalizeMerchant } = require('./subscriptionService');
|
||||||
|
|
||||||
const DEFAULT_PENDING_DAYS = 3;
|
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) {
|
function buildBankTracking(db, userId, year, month) {
|
||||||
try {
|
try {
|
||||||
const settings = getUserSettings(userId);
|
const settings = getUserSettings(userId);
|
||||||
|
|
@ -432,6 +485,7 @@ function getTracker(userId, query = {}, now = new Date()) {
|
||||||
const periodLabel = activeRemainingPeriod === '1st' ? '1st balance' : '15th balance';
|
const periodLabel = activeRemainingPeriod === '1st' ? '1st balance' : '15th balance';
|
||||||
|
|
||||||
const bankTracking = buildBankTracking(db, userId, year, month);
|
const bankTracking = buildBankTracking(db, userId, year, month);
|
||||||
|
const bankPendingCounts = bankTracking.enabled ? fetchBankPendingCounts(db, userId, billIds) : {};
|
||||||
const totalStarting = bankTracking.enabled
|
const totalStarting = bankTracking.enabled
|
||||||
? bankTracking.effective_balance
|
? bankTracking.effective_balance
|
||||||
: (startingAmounts?.combined_amount || 0);
|
: (startingAmounts?.combined_amount || 0);
|
||||||
|
|
@ -517,15 +571,17 @@ function getTracker(userId, query = {}, now = new Date()) {
|
||||||
cashflow,
|
cashflow,
|
||||||
rows: bankTracking.enabled
|
rows: bankTracking.enabled
|
||||||
? rows.map(r => {
|
? rows.map(r => {
|
||||||
|
const bank_pending_count = bankPendingCounts[r.id] || 0;
|
||||||
// Only flag manually-entered payments as pending-cleared — bank-synced
|
// Only flag manually-entered payments as pending-cleared — bank-synced
|
||||||
// payments are already in the balance so they don't need the badge.
|
// or bank-matched payments are already settled so they don't need the badge.
|
||||||
if (r.status === 'paid' && r.last_paid_date && r.payment_source !== 'provider_sync') {
|
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();
|
const cutoff = new Date();
|
||||||
cutoff.setDate(cutoff.getDate() - bankTracking.pending_days);
|
cutoff.setDate(cutoff.getDate() - bankTracking.pending_days);
|
||||||
const paidAt = new Date(r.last_paid_date);
|
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,
|
: rows,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue