diff --git a/HISTORY.md b/HISTORY.md index 1956168..d2d158e 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -10,6 +10,12 @@ - **Migration version sync assertion** — `_runMigrationVersions` module-level variable is now populated by `runMigrations()` before its loop runs. `reconcileLegacyMigrations()` — which runs after `runMigrations()` on legacy-DB upgrade paths — compares its own version array against the stored list and throws a descriptive error if any version appears in one array but not the other. Catches drift between the two migration arrays at startup rather than silently misconfiguring a legacy schema. +- **Late-attribution coverage extended to single-bill sync and BillModal** — `syncBillPaymentsFromSimplefin` (called by the Sync button in BillModal's Bank Matching Rules section) now runs the same late-attribution detection as the full sync. When a single-bill sync finds a payment that crossed a month boundary, it returns `late_attributions` in its response. The BillModal sync handler dispatches a `tracker:late-attributions` DOM event; TrackerPage listens and appends those to the existing queue, so the attribution dialog appears regardless of whether the sync came from the tracker header button or from inside a BillModal. Late attributions from both sources are processed in the same queue. + +- **`BillMerchantRules` preview shows error state instead of silently failing** — The debounced preview API call previously swallowed all errors with `.catch(() => {})`, causing the preview badge to simply disappear on network or server failure. Added `previewError` state: on failure a red "Error" chip appears in the input, and typing again clears it. + +- **Late-attribution prompt for bank-synced payments that just missed month end** — When `applyMerchantRules` auto-matches a transaction and the payment's `posted_date` falls within 5 days into a new month while the bill's `due_day` was in the prior month, the payment is flagged as a late-attribution candidate. After "Sync Bank" completes on the TrackerPage, a dialog appears for each candidate: "AT&T payment of $332.97 posted June 1 — should it count for May?" The user can accept (moves `paid_date` to the last day of the prior month so the tracker shows it as paid that month) or dismiss (keeps the original date). Multiple candidates are queued and shown one at a time. The date-only reclassification goes through a new `PATCH /api/payments/:id/attribute-to-month` endpoint that is specifically allowed for `provider_sync` payments (the existing PUT endpoint rejects transaction-linked payments). Amount and bank link are never changed. + - **Encryption key fully app-managed — no env var required** — `TOKEN_ENCRYPTION_KEY` environment variable support removed entirely. The auto-generated DB key (`_auto_encryption_key` in the settings table) is now the primary mechanism, not a fallback. The `[security]` warning that fired on every startup when no env var was set is gone. On first startup a 48-byte cryptographically random key is generated and persisted to the database; subsequent restarts reuse it. All existing encrypted data (SMTP password, OIDC secret, SimpleFIN tokens, push notification tokens) continues to decrypt correctly. - **TrackerPage crash fixed — `activeTotalExpected` temporal dead zone** — The `cashflow` block added in an earlier change referenced `activeTotalExpected` and `activePaidTowardDue` before their `const` declarations. JavaScript's temporal dead zone caused `Cannot access 'activeTotalExpected' before initialization` on every tracker load. Fixed by moving the four `active*` declarations above the cashflow block that depends on them. diff --git a/client/api.js b/client/api.js index 608fe1c..b323496 100644 --- a/client/api.js +++ b/client/api.js @@ -350,7 +350,8 @@ export const api = { dataSourceAccounts: (sourceId) => get(`/data-sources/${sourceId}/accounts`), setAccountMonitored: (sourceId, accountId, monitored) => put(`/data-sources/${sourceId}/accounts/${accountId}`, { monitored }), allFinancialAccounts: () => get('/data-sources/accounts/all'), - syncAllSources: () => post('/data-sources/sync-all', {}), + syncAllSources: () => post('/data-sources/sync-all', {}), + attributePaymentToMonth: (id, paid_date) => _fetch('PATCH', `/payments/${id}/attribute-to-month`, { paid_date }), // Admin — bank sync feature flag bankSyncConfig: () => get('/admin/bank-sync-config'), diff --git a/client/components/BillMerchantRules.jsx b/client/components/BillMerchantRules.jsx index dae85d2..0269015 100644 --- a/client/components/BillMerchantRules.jsx +++ b/client/components/BillMerchantRules.jsx @@ -59,8 +59,9 @@ function ConflictWarning({ conflicts }) { ); } -function PreviewBadge({ count, loading }) { +function PreviewBadge({ count, loading, error }) { if (loading) return ; + if (error) return Error; if (count === null) return null; return ( { if (cancelled) return; setPreviewCount(data.match_count); setConflicts(data.conflicts || []); }) - .catch(() => {}) + .catch(() => { + if (!cancelled) setPreviewError(true); + }) .finally(() => { if (!cancelled) setPreviewLoading(false); }); return () => { cancelled = true; }; }, [debouncedInput, billId]); @@ -234,7 +239,7 @@ export default function BillMerchantRules({ billId, onRulesChanged }) { { setInput(e.target.value); setShowSuggestions(true); setRetroFeedback(null); }} + onChange={e => { setInput(e.target.value); setShowSuggestions(true); setRetroFeedback(null); setPreviewError(false); }} onFocus={() => setShowSuggestions(true)} onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); handleAdd(); } @@ -245,7 +250,7 @@ export default function BillMerchantRules({ billId, onRulesChanged }) { disabled={adding} />
- +
+ + + + + ); +} + export default function TrackerPage() { const [searchParams, setSearchParams] = useSearchParams(); const now = new Date(); @@ -61,8 +99,10 @@ export default function TrackerPage() { }, [setSearchParams]); // Edit Bill modal: { bill, categories } when open, null when closed - const [bankSyncStatus, setBankSyncStatus] = useState(null); - const [bankSyncing, setBankSyncing] = useState(false); + const [bankSyncStatus, setBankSyncStatus] = useState(null); + const [bankSyncing, setBankSyncing] = useState(false); + const [lateAttributions, setLateAttributions] = useState([]); // pending month-attribution prompts + const [attrBusy, setAttrBusy] = useState(null); // payment_id being resolved const [pinUpcoming, setPinUpcoming] = useState(() => localStorage.getItem('tracker_pin_upcoming') === 'true'); const [editBillData, setEditBillData] = useState(null); // Edit Starting Amounts modal: true when open, false when closed @@ -89,6 +129,18 @@ export default function TrackerPage() { .catch(() => setBankSyncStatus(null)); }, []); + // Listen for late-attribution events fired by BillModal's single-bill sync + useEffect(() => { + function handler(e) { + const attrs = e.detail?.attributions; + if (Array.isArray(attrs) && attrs.length > 0) { + setLateAttributions(prev => [...prev, ...attrs]); + } + } + window.addEventListener('tracker:late-attributions', handler); + return () => window.removeEventListener('tracker:late-attributions', handler); + }, []); + function navigate(delta) { let nm = month + delta; let ny = year; @@ -104,6 +156,8 @@ export default function TrackerPage() { const matched = result.auto_matched ?? 0; const newTx = result.transactions_new ?? 0; const billNames = result.matched_bills ?? []; + const attributions = result.late_attributions ?? []; + if (matched > 0 && billNames.length > 0) { toast.success( `Synced — ${billNames.join(', ')} ✓` + @@ -117,6 +171,10 @@ export default function TrackerPage() { } else { toast.success('Synced — no new transactions'); } + + // Surface late-attribution prompts (payments that just crossed a month boundary) + if (attributions.length > 0) setLateAttributions(attributions); + refetch(); } catch (err) { toast.error(err.message || 'Bank sync failed'); @@ -567,6 +625,31 @@ export default function TrackerPage() { onSave={() => { setEditStartingOpen(false); refetch(); }} /> + {/* Late-attribution dialog — fires after sync when a payment just crossed a month boundary */} + {lateAttributions.length > 0 && ( + { + setAttrBusy(attr.payment_id); + try { + await api.attributePaymentToMonth(attr.payment_id, attr.suggested_date); + const month = new Date(attr.suggested_date + 'T00:00:00').toLocaleDateString('en-US', { month: 'long' }); + toast.success(`${attr.bill_name} payment moved to ${month}`); + setLateAttributions(prev => prev.slice(1)); // dismiss only on success + refetch(); + } catch (err) { + toast.error(err.message || 'Failed to reclassify payment — try again'); + // keep the attribution in queue so user can retry + } finally { + setAttrBusy(null); + } + }} + onDismiss={() => setLateAttributions(prev => prev.slice(1))} + /> + )} + {/* PaymentLedgerDialog opened via Overdue Command Center "Pay Now" */} {commandCenterPayRow && ( { return res.status(404).json(standardizeError('No SimpleFIN connections found', 'NOT_FOUND')); } - let accountsUpserted = 0; - let transactionsNew = 0; - let transactionsSkip = 0; - let autoMatched = 0; - const matchedBillSet = new Set(); // dedupe across multiple sources + let accountsUpserted = 0; + let transactionsNew = 0; + let transactionsSkip = 0; + let autoMatched = 0; + const matchedBillSet = new Set(); + const lateAttrAll = []; const errors = []; for (const source of sources) { @@ -260,17 +261,19 @@ router.post('/sync-all', async (req, res) => { transactionsSkip += result.transactionsSkip ?? 0; autoMatched += result.autoMatched ?? 0; for (const name of result.matched_bills ?? []) matchedBillSet.add(name); + for (const attr of result.late_attributions ?? []) lateAttrAll.push(attr); } catch (err) { errors.push(sanitizeErrorMessage(err?.message || 'Sync failed')); } } res.json({ - accounts_upserted: accountsUpserted, - transactions_new: transactionsNew, - transactions_skip: transactionsSkip, - auto_matched: autoMatched, - matched_bills: [...matchedBillSet], + accounts_upserted: accountsUpserted, + transactions_new: transactionsNew, + transactions_skip: transactionsSkip, + auto_matched: autoMatched, + matched_bills: [...matchedBillSet], + late_attributions: lateAttrAll, errors, }); } catch (err) { diff --git a/routes/payments.js b/routes/payments.js index 250f6be..b610306 100644 --- a/routes/payments.js +++ b/routes/payments.js @@ -460,4 +460,63 @@ router.post('/:id/restore', (req, res) => { res.json(db.prepare(`SELECT p.* FROM payments p JOIN bills b ON b.id = p.bill_id WHERE p.id = ? AND p.${SQL_NOT_DELETED} AND b.user_id = ? AND b.deleted_at IS NULL`).get(req.params.id, req.user.id)); }); +// PATCH /api/payments/:id/attribute-to-month +// Changes only the paid_date of a provider_sync payment to move it into the +// correct billing period when it posted just after month end. +// Does not touch the amount or balance_delta. +router.patch('/:id/attribute-to-month', (req, res) => { + const db = getDb(); + const paymentId = parseInt(req.params.id, 10); + if (!Number.isInteger(paymentId) || paymentId < 1) { + return res.status(400).json(standardizeError('Invalid payment id', 'VALIDATION_ERROR')); + } + + const { paid_date } = req.body; + if (!paid_date || !/^\d{4}-\d{2}-\d{2}$/.test(paid_date)) { + return res.status(400).json(standardizeError('paid_date must be YYYY-MM-DD', 'VALIDATION_ERROR', 'paid_date')); + } + // Validate it is a real calendar date + const newDate = new Date(paid_date + 'T00:00:00'); + if (isNaN(newDate.getTime()) || newDate.toISOString().slice(0, 10) !== paid_date) { + return res.status(400).json(standardizeError('paid_date is not a valid calendar date', 'VALIDATION_ERROR', 'paid_date')); + } + + try { + const payment = db.prepare(` + SELECT p.* FROM payments p + JOIN bills b ON b.id = p.bill_id + WHERE p.id = ? AND p.${SQL_NOT_DELETED} AND b.user_id = ? AND b.deleted_at IS NULL + `).get(paymentId, req.user.id); + + if (!payment) return res.status(404).json(standardizeError('Payment not found', 'NOT_FOUND')); + + // Only allow date-only reclassification for provider_sync payments + if (payment.payment_source !== 'provider_sync' && payment.payment_source !== 'auto_match') { + return res.status(409).json(standardizeError( + 'Only bank-synced payments can be reclassified to a different month', + 'RECLASSIFY_ONLY_SYNC', + )); + } + + // Sanity check: new date must be in the month immediately before the original date + const orig = new Date(payment.paid_date + 'T00:00:00'); + const origYM = orig.getFullYear() * 12 + orig.getMonth(); + const newYM = newDate.getFullYear() * 12 + newDate.getMonth(); + if (newYM !== origYM - 1) { + return res.status(400).json(standardizeError( + 'The new paid_date must be in the month immediately before the original payment date', + 'VALIDATION_ERROR', 'paid_date', + )); + } + + db.prepare("UPDATE payments SET paid_date = ?, updated_at = datetime('now') WHERE id = ?") + .run(paid_date, paymentId); + + res.json(db.prepare(`SELECT p.* FROM payments p JOIN bills b ON b.id = p.bill_id WHERE p.id = ? AND p.${SQL_NOT_DELETED} AND b.user_id = ? AND b.deleted_at IS NULL`).get(paymentId, req.user.id)); + } catch (err) { + console.error('[payments] attribute-to-month error:', err.message); + res.status(500).json(standardizeError('Failed to reclassify payment date', 'DB_ERROR')); + } +}); + module.exports = router; diff --git a/services/bankSyncService.js b/services/bankSyncService.js index 98badd6..f89050c 100644 --- a/services/bankSyncService.js +++ b/services/bankSyncService.js @@ -130,9 +130,9 @@ async function runSync(db, userId, dataSource, { days } = {}) { `).run(partialError, dataSource.id, userId); // Apply any stored merchant→bill rules to newly synced transactions - const { matched: autoMatched, matched_bills: matchedBills } = applyMerchantRules(db, userId); + const { matched: autoMatched, matched_bills: matchedBills, late_attributions: lateAttributions } = applyMerchantRules(db, userId); - return { accountsUpserted, transactionsNew, transactionsSkip, autoMatched, matched_bills: matchedBills || [], errlist: raw._errlistSummary || null }; + return { accountsUpserted, transactionsNew, transactionsSkip, autoMatched, matched_bills: matchedBills || [], late_attributions: lateAttributions || [], errlist: raw._errlistSummary || null }; } // ─── Public API ─────────────────────────────────────────────────────────────── diff --git a/services/billMerchantRuleService.js b/services/billMerchantRuleService.js index d1bc6ef..f1dcbc0 100644 --- a/services/billMerchantRuleService.js +++ b/services/billMerchantRuleService.js @@ -22,10 +22,22 @@ function addMerchantRule(db, userId, billId, merchant) { // merchant rules, create payments, and mark the transactions matched. // Returns { matched: number }. function applyMerchantRules(db, userId) { + // Detects when a payment posted just after month end but the bill was due in the prior month. + // Grace window: up to LATE_ATTR_DAYS days into the new month. + const LATE_ATTR_DAYS = 5; + function lateAttributionCandidate(paidDateStr, dueDayOfMonth) { + const paid = new Date(paidDateStr + 'T00:00:00'); + const dayOfMonth = paid.getDate(); + if (dayOfMonth > LATE_ATTR_DAYS) return null; + const prevMonthLastDay = new Date(paid.getFullYear(), paid.getMonth(), 0); + if (dueDayOfMonth > prevMonthLastDay.getDate()) return null; + return prevMonthLastDay.toISOString().slice(0, 10); // suggested prior-month date + } + let rules; try { rules = db.prepare(` - SELECT bmr.bill_id, bmr.merchant, b.name AS bill_name + SELECT bmr.bill_id, bmr.merchant, b.name AS bill_name, b.due_day FROM bill_merchant_rules bmr JOIN bills b ON b.id = bmr.bill_id AND b.user_id = bmr.user_id AND b.deleted_at IS NULL WHERE bmr.user_id = ? @@ -68,7 +80,8 @@ function applyMerchantRules(db, userId) { `); let matched = 0; - const matchedBills = new Map(); // bill_id → bill_name for the summary + const matchedBills = new Map(); // bill_id → bill_name for the summary + const lateAttributions = []; // payments that crossed a month boundary try { db.transaction(() => { @@ -94,15 +107,33 @@ function applyMerchantRules(db, userId) { updateTx.run(rule.bill_id, tx.id, userId); matched++; matchedBills.set(rule.bill_id, rule.bill_name || `Bill #${rule.bill_id}`); + + // Check if this payment just missed the previous month's window + const suggestedDate = lateAttributionCandidate(paidDate, rule.due_day); + if (suggestedDate) { + // Fetch the payment id just inserted + const inserted = db.prepare( + 'SELECT id FROM payments WHERE transaction_id = ? AND bill_id = ? AND deleted_at IS NULL' + ).get(tx.id, rule.bill_id); + if (inserted) { + lateAttributions.push({ + payment_id: inserted.id, + bill_name: rule.bill_name || `Bill #${rule.bill_id}`, + original_date: paidDate, + suggested_date: suggestedDate, + amount, + }); + } + } } } })(); } catch (err) { console.error('[applyMerchantRules] Transaction failed, no payments recorded:', err.message); - return { matched: 0, matched_bills: [] }; + return { matched: 0, matched_bills: [], late_attributions: [] }; } - return { matched, matched_bills: [...matchedBills.values()] }; + return { matched, matched_bills: [...matchedBills.values()], late_attributions: lateAttributions }; } // Sync all unmatched SimpleFIN transactions for a single bill using its stored @@ -158,6 +189,7 @@ function syncBillPaymentsFromSimplefin(db, userId, billId) { if (txRows.length === 0) return { added: 0 }; + const billMeta = db.prepare('SELECT name, due_day, current_balance, interest_rate FROM bills WHERE id = ? AND deleted_at IS NULL').get(billId); 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) @@ -169,8 +201,10 @@ function syncBillPaymentsFromSimplefin(db, userId, billId) { SET matched_bill_id = ?, match_status = 'matched', updated_at = datetime('now') WHERE id = ? AND user_id = ? AND match_status = 'unmatched' `); + const getPaymentId = db.prepare('SELECT id FROM payments WHERE transaction_id = ? AND bill_id = ? AND deleted_at IS NULL'); let added = 0; + const lateAttributions = []; try { db.transaction(() => { for (const tx of txRows) { @@ -189,15 +223,30 @@ function syncBillPaymentsFromSimplefin(db, userId, billId) { if (balCalc) updateBalance.run(balCalc.new_balance, billId); updateTx.run(billId, tx.id, userId); added++; + + // 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, + }); + } + } } } })(); } catch (err) { console.error('[syncBillPaymentsFromSimplefin] Transaction failed, no payments recorded:', err.message); - return { added: 0 }; + return { added: 0, late_attributions: [] }; } - return { added }; + return { added, late_attributions: lateAttributions }; } module.exports = { addMerchantRule, applyMerchantRules, syncBillPaymentsFromSimplefin };