'use strict'; 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. // Prevents "suno" matching "sunoco", "prime" matching "prime video", etc. function merchantMatches(txMerchant, ruleMerchant) { if (!txMerchant || !ruleMerchant) return false; if (txMerchant === ruleMerchant) return true; const esc = s => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const wordBoundary = s => new RegExp(`(^|\\s)${esc(s)}(\\s|$)`); return wordBoundary(ruleMerchant).test(txMerchant) || wordBoundary(txMerchant).test(ruleMerchant); } // Detects when a payment posted just after month end but the bill was due in the // prior month. Grace window: up to `graceDays` days into the new month. // Module-scoped so both applyMerchantRules and syncBillPaymentsFromSimplefin can use it. function lateAttributionCandidate(paidDateStr, dueDayOfMonth, graceDays = 5) { const paid = new Date(paidDateStr + 'T00:00:00'); const dayOfMonth = paid.getDate(); if (dayOfMonth > graceDays) 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 } // Persist a merchant→bill rule so future synced transactions auto-match. function addMerchantRule(db, userId, billId, merchant) { const normalized = normalizeMerchant(merchant); if (!normalized || normalized.length < 3) return; try { db.prepare(` INSERT INTO bill_merchant_rules (user_id, bill_id, merchant) VALUES (?, ?, ?) ON CONFLICT(user_id, bill_id, merchant) DO NOTHING `).run(userId, billId, normalized); } catch { // Table may not exist yet on legacy DBs — safe to skip } } // Scan all unmatched negative transactions for this user, apply any stored // merchant rules, create payments, and mark the transactions matched. // Returns { matched: number }. function applyMerchantRules(db, userId) { let rules; try { rules = db.prepare(` SELECT bmr.bill_id, bmr.merchant, bmr.auto_attribute_late, 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 = ? `).all(userId); } catch { return { matched: 0 }; } if (rules.length === 0) return { matched: 0, matched_bills: [] }; // Longer (more specific) rules win when multiple could match the same transaction rules.sort((a, b) => b.merchant.length - a.merchant.length); let txRows; try { txRows = db.prepare(` SELECT t.id, t.amount, t.payee, t.description, t.memo, t.posted_date, t.transacted_at FROM transactions t LEFT JOIN financial_accounts fa ON fa.id = t.account_id AND fa.user_id = t.user_id WHERE t.user_id = ? AND t.match_status = 'unmatched' AND t.ignored = 0 AND t.amount < 0 AND (t.account_id IS NULL OR fa.id IS NULL OR fa.monitored = 1) `).all(userId); } catch (err) { console.error('[applyMerchantRules] Failed to fetch transactions:', err.message); return { matched: 0, matched_bills: [] }; } 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) VALUES (?, ?, ?, 'provider_sync', ?, ?) `); const updateBalance = db.prepare("UPDATE bills SET current_balance = ?, updated_at = datetime('now') WHERE id = ?"); const updateTx = db.prepare(` UPDATE transactions SET matched_bill_id = ?, match_status = 'matched', updated_at = datetime('now') WHERE id = ? AND user_id = ? AND match_status = 'unmatched' `); let matched = 0; const matchedBills = new Map(); // bill_id → bill_name for the summary const lateAttributions = []; // payments that crossed a month boundary try { db.transaction(() => { for (const tx of txRows) { const txMerchant = normalizeMerchant(tx.payee || tx.description || tx.memo || ''); if (!txMerchant) continue; const rule = rules.find(r => merchantMatches(txMerchant, r.merchant) ); if (!rule) continue; const paidDate = tx.posted_date || (tx.transacted_at ? String(tx.transacted_at).slice(0, 10) : null); if (!paidDate) continue; const amount = Math.round(Math.abs(tx.amount)) / 100; const bill = getBill.get(rule.bill_id); const balCalc = bill ? computeBalanceDelta(bill, amount) : null; const result = insertPayment.run(rule.bill_id, amount, paidDate, tx.id, balCalc?.balance_delta ?? null); if (result.changes > 0) { if (balCalc) updateBalance.run(balCalc.new_balance, rule.bill_id); 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 effectiveGrace = Math.max(5, globalGraceDays); const suggestedDate = lateAttributionCandidate(paidDate, rule.due_day, effectiveGrace); if (suggestedDate) { 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 { 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: [], late_attributions: [] }; } return { matched, matched_bills: [...matchedBills.values()], late_attributions: lateAttributions }; } // Sync all unmatched SimpleFIN transactions for a single bill using its stored // merchant rules. If no rule exists yet but the bill has a detected merchant in // its notes, the rule is created on the fly. // Returns { added: number }. function syncBillPaymentsFromSimplefin(db, userId, billId) { // Load rules for this specific bill let rules; try { rules = db.prepare(` SELECT merchant FROM bill_merchant_rules WHERE user_id = ? AND bill_id = ? ORDER BY LENGTH(merchant) DESC `).all(userId, billId).map(r => r.merchant); } catch { return { added: 0 }; } // Fallback: extract merchant from notes "Detected from recurring merchant: X" if (rules.length === 0) { try { const bill = db.prepare('SELECT notes FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL').get(billId, userId); const match = bill?.notes?.match(/Detected from recurring merchant:\s*(.+)/i); if (match) { const extracted = normalizeMerchant(match[1].trim()); if (extracted && extracted.length >= 3) { addMerchantRule(db, userId, billId, extracted); rules = [extracted]; } } } catch (err) { console.error('[syncBillPaymentsFromSimplefin] Failed to read bill notes:', err.message); } if (rules.length === 0) return { added: 0 }; } let txRows; try { txRows = db.prepare(` SELECT t.id, t.amount, t.payee, t.description, t.memo, t.posted_date, t.transacted_at FROM transactions t LEFT JOIN financial_accounts fa ON fa.id = t.account_id AND fa.user_id = t.user_id WHERE t.user_id = ? AND t.match_status = 'unmatched' AND t.ignored = 0 AND t.amount < 0 AND (t.account_id IS NULL OR fa.id IS NULL OR fa.monitored = 1) `).all(userId); } catch (err) { console.error('[syncBillPaymentsFromSimplefin] Failed to fetch transactions:', err.message); return { added: 0 }; } 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) VALUES (?, ?, ?, 'provider_sync', ?, ?) `); const updateBalance = db.prepare("UPDATE bills SET current_balance = ?, updated_at = datetime('now') WHERE id = ?"); const updateTx = db.prepare(` UPDATE transactions 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) { const txMerchant = normalizeMerchant(tx.payee || tx.description || tx.memo || ''); if (!txMerchant) continue; const matches = rules.some(r => merchantMatches(txMerchant, r)); if (!matches) continue; const paidDate = tx.posted_date || (tx.transacted_at ? String(tx.transacted_at).slice(0, 10) : null); if (!paidDate) continue; const amount = Math.round(Math.abs(tx.amount)) / 100; const bill = getBill.get(billId); const balCalc = bill ? computeBalanceDelta(bill, amount) : null; const result = insertPayment.run(billId, amount, paidDate, tx.id, balCalc?.balance_delta ?? null); if (result.changes > 0) { 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 globalDays2 = (() => { try { return parseInt(getUserSettings(userId).bank_late_attribution_days, 10) || 0; } catch { return 0; } })(); const effectiveGrace2 = Math.max(5, globalDays2); const suggestedDate = billMeta ? lateAttributionCandidate(paidDate, billMeta.due_day, effectiveGrace2) : null; if (suggestedDate) { const dayOfMonth = new Date(paidDate + 'T00:00:00').getDate(); const globalDays = globalDays2; 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, }); } } } } } })(); } catch (err) { console.error('[syncBillPaymentsFromSimplefin] Transaction failed, no payments recorded:', err.message); return { added: 0, late_attributions: [] }; } return { added, late_attributions: lateAttributions }; } module.exports = { addMerchantRule, applyMerchantRules, syncBillPaymentsFromSimplefin, merchantMatches };