'use strict'; const { normalizeMerchant } = require('./subscriptionService'); const { getUserSettings } = require('./userSettings'); const { applyBankPaymentAsSourceOfTruth } = require('./paymentAccountingService'); // 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 } } // Generic descriptor tokens that must never become a merchant rule on their own — // a rule like "ach" or "atm" would word-match far too many unrelated transactions. // (normalizeMerchant already strips pos/debit/card/payment/purchase/recurring/online; // this catches the residual generic tokens it leaves behind.) const GENERIC_MERCHANT_TOKENS = new Set([ 'ach', 'atm', 'transfer', 'xfer', 'pmt', 'withdrawal', 'deposit', 'check', 'fee', 'bill', 'autopay', 'draft', 'credit', 'bank', 'visa', 'mastercard', 'amex', 'web', 'mobile', 'pending', 'transaction', ]); // Derive a specific, reusable merchant string from a confirmed transaction match. // Returns null when the text is too generic to be a safe auto-match rule. function learnableMerchantFromTransaction(transaction) { if (!transaction) return null; const raw = transaction.payee || transaction.description || ''; const norm = normalizeMerchant(raw); if (!norm || norm.length < 4) return null; const tokens = norm.split(' ').filter(Boolean); // Require at least one meaningful (non-generic, length >= 3) token. const meaningful = tokens.filter(t => t.length >= 3 && !GENERIC_MERCHANT_TOKENS.has(t)); if (meaningful.length === 0) return null; return norm; } // Learn a merchant→bill rule from an explicit user confirmation so future synced // transactions from the same merchant auto-match. Best-effort and idempotent — // never throws, returns the merchant it stored (or null when nothing was learned). function learnMerchantRuleFromMatch(db, userId, billId, transaction) { try { const merchant = learnableMerchantFromTransaction(transaction); if (!merchant) return null; addMerchantRule(db, userId, billId, merchant); return merchant; } catch { return null; } } // 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.pending = 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 * 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) VALUES (?, ?, ?, 'provider_sync', ?) `); 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; // All rules that match this transaction (rules is pre-sorted most-specific // first by merchant length). If the most-specific tier maps to more than one // distinct bill — e.g. two bills both named "Amazon" with a matching rule — // the match is ambiguous, so skip auto-attribution and leave it for manual // review rather than silently guessing the wrong bill. const matchingRules = rules.filter(r => merchantMatches(txMerchant, r.merchant)); if (matchingRules.length === 0) continue; const topLen = matchingRules[0].merchant.length; const topBills = new Set( matchingRules.filter(r => r.merchant.length === topLen).map(r => r.bill_id) ); if (topBills.size > 1) continue; const rule = matchingRules[0]; 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 result = insertPayment.run(rule.bill_id, amount, paidDate, tx.id); if (result.changes > 0) { const inserted = db.prepare('SELECT * FROM payments WHERE transaction_id = ? AND bill_id = ? AND deleted_at IS NULL').get(tx.id, 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); if (inserted) inserted.paid_date = suggestedDate; } else { const insertedForPrompt = db.prepare( 'SELECT id FROM payments WHERE transaction_id = ? AND bill_id = ? AND deleted_at IS NULL' ).get(tx.id, rule.bill_id); if (insertedForPrompt) { lateAttributions.push({ payment_id: insertedForPrompt.id, bill_name: rule.bill_name || `Bill #${rule.bill_id}`, original_date: paidDate, suggested_date: suggestedDate, amount, }); } } } if (bill && inserted) applyBankPaymentAsSourceOfTruth(db, bill, inserted); } } })(); } 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.pending = 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 * 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) VALUES (?, ?, ?, 'provider_sync', ?) `); 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 * 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 result = insertPayment.run(billId, amount, paidDate, tx.id); if (result.changes > 0) { const inserted = getPaymentId.get(tx.id, 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); if (inserted) inserted.paid_date = suggestedDate; } else { const insertedForPrompt = getPaymentId.get(tx.id, billId); if (insertedForPrompt) { lateAttributions.push({ payment_id: insertedForPrompt.id, bill_name: billMeta.name || `Bill #${billId}`, original_date: paidDate, suggested_date: suggestedDate, amount, }); } } } if (bill && inserted) applyBankPaymentAsSourceOfTruth(db, bill, inserted); } } })(); } 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, learnMerchantRuleFromMatch, learnableMerchantFromTransaction };