diff --git a/routes/bills.js b/routes/bills.js index c0a48e5..0d063bd 100644 --- a/routes/bills.js +++ b/routes/bills.js @@ -13,7 +13,7 @@ const { const { amortizationSchedule, debtAprSnapshot } = require('../services/aprService'); const { standardizeError } = require('../middleware/errorFormatter'); const { validatePaymentInput } = require('../services/paymentValidation'); -const { addMerchantRule, syncBillPaymentsFromSimplefin } = require('../services/billMerchantRuleService'); +const { addMerchantRule, syncBillPaymentsFromSimplefin, merchantMatches } = require('../services/billMerchantRuleService'); const { normalizeMerchant } = require('../services/subscriptionService'); const { decorateTransaction } = require('../services/transactionService'); @@ -914,7 +914,7 @@ function previewMatchCount(db, userId, normalized) { `).all(userId); return txRows.filter(tx => { const txMerchant = normalizeMerchant(tx.payee || tx.description || tx.memo || ''); - return txMerchant && (txMerchant.includes(normalized) || normalized.includes(txMerchant)); + return txMerchant && merchantMatches(txMerchant, normalized); }).length; } @@ -1071,7 +1071,7 @@ router.get('/:id/merchant-rules/candidates', (req, res) => { for (const tx of txRows) { const txMerchant = normalizeMerchant(tx.payee || tx.description || tx.memo || ''); if (!txMerchant) continue; - const matches = rules.some(r => txMerchant.includes(r) || r.includes(txMerchant)); + 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); diff --git a/services/billMerchantRuleService.js b/services/billMerchantRuleService.js index f1dcbc0..8b544fc 100644 --- a/services/billMerchantRuleService.js +++ b/services/billMerchantRuleService.js @@ -3,6 +3,18 @@ const { normalizeMerchant } = require('./subscriptionService'); const { computeBalanceDelta } = require('./billsService'); +// 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); +} + // Persist a merchant→bill rule so future synced transactions auto-match. function addMerchantRule(db, userId, billId, merchant) { const normalized = normalizeMerchant(merchant); @@ -90,7 +102,7 @@ function applyMerchantRules(db, userId) { if (!txMerchant) continue; const rule = rules.find(r => - txMerchant.includes(r.merchant) || r.merchant.includes(txMerchant) + merchantMatches(txMerchant, r.merchant) ); if (!rule) continue; @@ -210,7 +222,7 @@ function syncBillPaymentsFromSimplefin(db, userId, billId) { for (const tx of txRows) { const txMerchant = normalizeMerchant(tx.payee || tx.description || tx.memo || ''); if (!txMerchant) continue; - const matches = rules.some(r => txMerchant.includes(r) || r.includes(txMerchant)); + 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; @@ -249,4 +261,4 @@ function syncBillPaymentsFromSimplefin(db, userId, billId) { return { added, late_attributions: lateAttributions }; } -module.exports = { addMerchantRule, applyMerchantRules, syncBillPaymentsFromSimplefin }; +module.exports = { addMerchantRule, applyMerchantRules, syncBillPaymentsFromSimplefin, merchantMatches };