diff --git a/client/components/data/BankSyncSection.jsx b/client/components/data/BankSyncSection.jsx index 868c52d..cfc4144 100644 --- a/client/components/data/BankSyncSection.jsx +++ b/client/components/data/BankSyncSection.jsx @@ -310,7 +310,8 @@ export default function BankSyncSection({ onConnectionChange, cardProps = {} }) // Bank tracking state const [btEnabled, setBtEnabled] = useState(false); const [btAccountId, setBtAccountId] = useState(''); - const [btPendingDays, setBtPendingDays] = useState(3); + const [btPendingDays, setBtPendingDays] = useState(3); + const [btLateGraceDays, setBtLateGraceDays] = useState(0); const [btAccounts, setBtAccounts] = useState([]); const [btSaving, setBtSaving] = useState(false); @@ -339,6 +340,7 @@ export default function BankSyncSection({ onConnectionChange, cardProps = {} }) setBtEnabled(settings.bank_tracking_enabled === 'true'); setBtAccountId(settings.bank_tracking_account_id || ''); setBtPendingDays(parseInt(settings.bank_tracking_pending_days, 10) || 3); + setBtLateGraceDays(parseInt(settings.bank_late_attribution_days, 10) || 0); setBtAccounts(Array.isArray(accounts) ? accounts : []); } catch { // non-fatal — bank tracking section just won't populate @@ -351,12 +353,14 @@ export default function BankSyncSection({ onConnectionChange, cardProps = {} }) const next = { bank_tracking_enabled: String(patch.enabled ?? btEnabled), bank_tracking_account_id: String(patch.accountId ?? btAccountId), - bank_tracking_pending_days: String(patch.pendingDays ?? btPendingDays), + bank_tracking_pending_days: String(patch.pendingDays ?? btPendingDays), + bank_late_attribution_days: String(patch.lateGraceDays ?? btLateGraceDays), }; await api.saveSettings(next); if (patch.enabled !== undefined) setBtEnabled(patch.enabled); if (patch.accountId !== undefined) setBtAccountId(patch.accountId); - if (patch.pendingDays !== undefined) setBtPendingDays(patch.pendingDays); + if (patch.pendingDays !== undefined) setBtPendingDays(patch.pendingDays); + if (patch.lateGraceDays !== undefined) setBtLateGraceDays(patch.lateGraceDays); toast.success('Bank tracking settings saved'); } catch (err) { toast.error(err.message || 'Failed to save bank tracking settings'); @@ -861,6 +865,38 @@ export default function BankSyncSection({ onConnectionChange, cardProps = {} }) + {/* Late payment grace window */} +
+ If a payment posts in the first N days of a new month but the bill was due in the prior month, + automatically count it for the prior month — no prompt needed. +
+ + {btLateGraceDays > 0 && ( ++ Any payment posting on the 1st–{btLateGraceDays}{btLateGraceDays === 1 ? 'st' : btLateGraceDays === 2 ? 'nd' : btLateGraceDays === 3 ? 'rd' : 'th'} will automatically count for the prior month if the bill was due then. +
+ )} +How it works: Your live bank balance is fetched every time your data syncs. Bills you've already marked paid are not double-counted — your bank balance reflects them. Only unpaid bills still due this month are subtracted.
diff --git a/services/billMerchantRuleService.js b/services/billMerchantRuleService.js index 2d1235d..3a59035 100644 --- a/services/billMerchantRuleService.js +++ b/services/billMerchantRuleService.js @@ -2,6 +2,7 @@ const { normalizeMerchant } = require('./subscriptionService'); const { computeBalanceDelta } = require('./billsService'); +const { getUserSettings } = require('./userSettings'); // Word-boundary merchant match — requires the rule to appear as complete word(s) // within the transaction string (or vice versa), not just as a substring. @@ -79,6 +80,10 @@ function applyMerchantRules(db, userId) { if (txRows.length === 0) return { matched: 0, matched_bills: [] }; + // Global grace window — auto-apply late attribution for ALL bills within this many days + const userSettings = (() => { try { return getUserSettings(userId); } catch { return {}; } })(); + const globalGraceDays = parseInt(userSettings.bank_late_attribution_days, 10) || 0; + const getBill = db.prepare('SELECT current_balance, interest_rate FROM bills WHERE id = ? AND deleted_at IS NULL'); const insertPayment = db.prepare(` INSERT OR IGNORE INTO payments (bill_id, amount, paid_date, payment_source, transaction_id, balance_delta) @@ -123,8 +128,12 @@ function applyMerchantRules(db, userId) { // Check if this payment just missed the previous month's window const suggestedDate = lateAttributionCandidate(paidDate, rule.due_day); if (suggestedDate) { - if (rule.auto_attribute_late) { - // Auto-apply without prompting — this bill always posts after month end + const dayOfMonth = new Date(paidDate + 'T00:00:00').getDate(); + // Auto-apply if: per-rule toggle is on OR global grace window covers this day + const autoApply = rule.auto_attribute_late || + (globalGraceDays > 0 && dayOfMonth <= globalGraceDays); + + if (autoApply) { db.prepare("UPDATE payments SET paid_date = ?, updated_at = datetime('now') WHERE transaction_id = ? AND bill_id = ? AND deleted_at IS NULL") .run(suggestedDate, tx.id, rule.bill_id); } else { @@ -244,15 +253,24 @@ function syncBillPaymentsFromSimplefin(db, userId, billId) { // Check for late attribution (payment just crossed month boundary) const suggestedDate = billMeta ? lateAttributionCandidate(paidDate, billMeta.due_day) : null; if (suggestedDate) { - const inserted = getPaymentId.get(tx.id, billId); - if (inserted) { - lateAttributions.push({ - payment_id: inserted.id, - bill_name: billMeta.name || `Bill #${billId}`, - original_date: paidDate, - suggested_date: suggestedDate, - amount, - }); + const dayOfMonth = new Date(paidDate + 'T00:00:00').getDate(); + const globalDays = (() => { try { return parseInt(getUserSettings(userId).bank_late_attribution_days, 10) || 0; } catch { return 0; } })(); + const autoApply = (globalDays > 0 && dayOfMonth <= globalDays); + + if (autoApply) { + db.prepare("UPDATE payments SET paid_date = ?, updated_at = datetime('now') WHERE transaction_id = ? AND bill_id = ? AND deleted_at IS NULL") + .run(suggestedDate, tx.id, billId); + } else { + const inserted = getPaymentId.get(tx.id, billId); + if (inserted) { + lateAttributions.push({ + payment_id: inserted.id, + bill_name: billMeta.name || `Bill #${billId}`, + original_date: paidDate, + suggested_date: suggestedDate, + amount, + }); + } } } } diff --git a/services/userSettings.js b/services/userSettings.js index ff4421e..56b4128 100644 --- a/services/userSettings.js +++ b/services/userSettings.js @@ -11,6 +11,7 @@ const USER_SETTING_KEYS = [ 'bank_tracking_enabled', 'bank_tracking_account_id', 'bank_tracking_pending_days', + 'bank_late_attribution_days', ]; function defaultUserSettings() {