'use strict'; const { categorizeTransaction } = require('./spendingService'); // Mirrors the pack's match_order_recommendation: regional descriptor variants // (most specific — tied to a city/county) are checked first, then online // billing descriptors (PAYPAL/STRIPE/APPLE.COM/BILL/etc.), then canonical // national merchants as the fallback. const ENTRY_KIND_ORDER = { regional_descriptor_variant: 0, online_billing_descriptor_variant: 1, canonical_merchant: 2, }; let _entries = null; function maxPatternLength(patterns) { return patterns.reduce((max, p) => Math.max(max, p.length), 0); } // Lazily load and pre-sort all merchant_store_matches rows. Cached for the // lifetime of the process — this is static reference data seeded once by the // v1.05 migration. function loadMerchantMatchEntries(db) { if (_entries) return _entries; const rows = db.prepare(` SELECT id, entry_kind, canonical_merchant_id, canonical_name, display_name, category, merchant_type, scope, priority, match_patterns, negative_patterns, locality_city, locality_state FROM merchant_store_matches `).all(); _entries = rows.map(row => ({ ...row, match_patterns: JSON.parse(row.match_patterns || '[]'), negative_patterns: JSON.parse(row.negative_patterns || '[]'), })); _entries.sort((a, b) => { const orderA = ENTRY_KIND_ORDER[a.entry_kind] ?? 99; const orderB = ENTRY_KIND_ORDER[b.entry_kind] ?? 99; if (orderA !== orderB) return orderA - orderB; if (b.priority !== a.priority) return b.priority - a.priority; return maxPatternLength(b.match_patterns) - maxPatternLength(a.match_patterns); }); return _entries; } // Apply the pack's normalization rules: uppercase, "&" -> "AND", strip // apostrophes/punctuation, collapse whitespace. match_patterns in the pack // are pre-normalized to this same shape (e.g. "THE CHILDREN S PLACE"). function normalizeForMatch(value) { return String(value || '') .toUpperCase() .replace(/&/g, ' AND ') .replace(/['’]/g, '') .replace(/[^A-Z0-9\s]/g, ' ') .replace(/\s+/g, ' ') .trim(); } // Find the best merchant/store match for a raw transaction description. // Returns { entry_id, canonical_name, display_name, category, scope, priority } or null. function findMerchantMatch(db, description) { const normalized = normalizeForMatch(description); if (!normalized) return null; const entries = loadMerchantMatchEntries(db); for (const entry of entries) { if (entry.negative_patterns.some(p => normalized.includes(p))) continue; if (entry.match_patterns.some(p => p && normalized.includes(p))) { return { entry_id: entry.id, canonical_name: entry.canonical_name, display_name: entry.display_name, category: entry.category, scope: entry.scope, priority: entry.priority, }; } } return null; } // Find-or-create a spending category by name for this user, matching the // COLLATE NOCASE convention used by ensureUserDefaultCategories (db/database.js). function findOrCreateCategory(db, userId, name) { const existing = db.prepare('SELECT id FROM categories WHERE user_id = ? AND name = ? COLLATE NOCASE') .get(userId, name); if (existing) return existing.id; const result = db.prepare('INSERT INTO categories (user_id, name) VALUES (?, ?)').run(userId, name); return result.lastInsertRowid; } // Scan the user's uncategorized outflows, apply merchant/store pack matches, // and categorize them (writing spending_category_rules so future syncs hit // the cheaper rule path instead of rescanning the full pack). // // With { dryRun: true }, computes the same matches without writing anything // (no categories created, no transactions updated) — used to preview an // auto-categorize run before applying it. // // Returns { updated: number, categories: [{ name, count }], changes: [{ transaction_id, display_name, category }] }. function applyMerchantStoreMatches(db, userId, { dryRun = false } = {}) { const txRows = db.prepare(` SELECT id, payee, description, memo FROM transactions WHERE user_id = ? AND amount < 0 AND ignored = 0 AND match_status != 'matched' AND spending_category_id IS NULL `).all(userId); if (txRows.length === 0) return { updated: 0, categories: [], changes: [] }; const categoryCounts = new Map(); // category name -> count const changes = []; let updated = 0; const apply = () => { for (const tx of txRows) { const match = findMerchantMatch(db, tx.payee || tx.description || tx.memo || ''); if (!match) continue; if (!dryRun) { const categoryId = findOrCreateCategory(db, userId, match.category); categorizeTransaction(db, userId, tx.id, categoryId, true); } updated++; changes.push({ transaction_id: tx.id, display_name: match.display_name, category: match.category }); categoryCounts.set(match.category, (categoryCounts.get(match.category) || 0) + 1); } }; if (dryRun) { apply(); } else { db.transaction(apply)(); } return { updated, categories: [...categoryCounts.entries()].map(([name, count]) => ({ name, count })), changes, }; } module.exports = { loadMerchantMatchEntries, normalizeForMatch, findMerchantMatch, findOrCreateCategory, applyMerchantStoreMatches, };