'use strict'; const { getDb } = require('../db/database'); const { getCycleRange, resolveDueDate } = require('./statusService'); const { decorateTransaction } = require('./transactionService'); function suggestionError(status, message, code, field = null) { const err = new Error(message); err.status = status; err.code = code; err.field = field; return err; } function normalizeId(value, field) { const id = typeof value === 'number' ? value : Number(value); if (!Number.isSafeInteger(id) || id <= 0) { throw suggestionError(400, `${field} must be a positive integer`, 'VALIDATION_ERROR', field); } return id; } function suggestionId(transactionId, billId) { return `${transactionId}:${billId}`; } function parseSuggestionId(id) { const match = /^(\d+):(\d+)$/.exec(String(id || '').trim()); if (!match) { throw suggestionError(400, 'Suggestion id must be transactionId:billId', 'VALIDATION_ERROR', 'id'); } return { transactionId: normalizeId(match[1], 'transaction_id'), billId: normalizeId(match[2], 'bill_id'), }; } function textKey(value) { return String(value || '') .toLowerCase() .replace(/[^a-z0-9]+/g, ' ') .trim(); } function transactionDate(transaction) { const date = transaction.posted_date || String(transaction.transacted_at || '').slice(0, 10); return /^\d{4}-\d{2}-\d{2}$/.test(date) ? date : null; } function dateParts(date) { const [year, month] = String(date).split('-').map(Number); return { year, month }; } function diffDays(a, b) { const left = new Date(`${a}T00:00:00Z`).getTime(); const right = new Date(`${b}T00:00:00Z`).getTime(); if (!Number.isFinite(left) || !Number.isFinite(right)) return null; return Math.abs(Math.round((left - right) / 86400000)); } function amountDollars(transaction) { const cents = Number(transaction.amount); return Number.isFinite(cents) ? Math.abs(cents) / 100 : 0; } function addAmountScore(score, reasons, transaction, bill) { const txAmount = amountDollars(transaction); const expected = Number(bill.expected_amount) || 0; if (txAmount <= 0 || expected <= 0) return score; const delta = Math.abs(txAmount - expected); const pct = delta / expected; if (delta <= 0.01) { reasons.push('amount matches'); return score + 40; } if (delta <= 1) { reasons.push('amount within $1'); return score + 32; } if (delta <= 5 || pct <= 0.05) { reasons.push('amount close to bill'); return score + 22; } if (pct <= 0.15) { reasons.push('amount within 15%'); return score + 12; } return score; } function addDateScore(score, reasons, transaction, bill) { const postedDate = transactionDate(transaction); if (!postedDate) return score; const { year, month } = dateParts(postedDate); const dueDate = resolveDueDate(bill, year, month); if (!dueDate) return score; const distance = diffDays(postedDate, dueDate); if (distance === null) return score; if (distance <= 1) { reasons.push('date within 1 day'); return score + 25; } if (distance <= 3) { reasons.push(`date within ${distance} days`); return score + 20; } if (distance <= 7) { reasons.push('date within 7 days'); return score + 12; } return score; } // Word-boundary comparison — same logic as billMerchantRuleService.merchantMatches() function wordBoundaryIncludes(a, b) { if (!a || !b) return false; if (a === b) return true; try { const esc = s => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const wb = s => new RegExp(`(^|\\s)${esc(s)}(\\s|$)`); return wb(b).test(a) || wb(a).test(b); } catch { return false; } } function addNameScore(score, reasons, transaction, bill) { const billName = textKey(bill.name); if (!billName) return score; const payee = textKey(transaction.payee); const description = textKey(transaction.description); const memo = textKey(transaction.memo); if (payee && wordBoundaryIncludes(payee, billName)) { reasons.push('payee contains bill name'); score += 22; } if (description && wordBoundaryIncludes(description, billName)) { reasons.push('description contains bill name'); score += 18; } if (memo && wordBoundaryIncludes(memo, billName)) { reasons.push('memo contains bill name'); score += 8; } return score; } function addPriorMatchScore(score, reasons, transaction, bill, priorMatchKeys) { const payee = textKey(transaction.payee); const description = textKey(transaction.description); if ( (payee && priorMatchKeys.has(`${bill.id}:payee:${payee}`)) || (description && priorMatchKeys.has(`${bill.id}:description:${description}`)) ) { reasons.push('prior match for this bill'); return score + 12; } return score; } function hasPaymentInTransactionCycle(db, bill, transaction) { const postedDate = transactionDate(transaction); if (!postedDate) return false; const { year, month } = dateParts(postedDate); const range = getCycleRange(year, month, bill); if (!range) return false; return !!db.prepare(` SELECT 1 FROM payments WHERE bill_id = ? AND paid_date BETWEEN ? AND ? AND deleted_at IS NULL LIMIT 1 `).get(bill.id, range.start, range.end); } function loadCandidateTransactions(db, userId, transactionId = null) { const params = [userId]; const where = [ 't.user_id = ?', 't.ignored = 0', "t.match_status = 'unmatched'", 't.pending = 0', // don't match/pay against unsettled charges — they can change or vanish '(t.account_id IS NULL OR fa.id IS NULL OR fa.monitored = 1)', ]; if (transactionId) { where.push('t.id = ?'); params.push(transactionId); } return db.prepare(` SELECT t.id, t.user_id, t.data_source_id, t.account_id, t.provider_transaction_id, t.source_type, t.transaction_type, t.posted_date, t.transacted_at, t.amount, t.currency, t.description, t.payee, t.memo, t.category, t.matched_bill_id, t.match_status, t.ignored, t.created_at, t.updated_at, ds.type AS data_source_type, ds.provider AS data_source_provider, ds.name AS data_source_name, ds.status AS data_source_status, fa.name AS account_name, fa.org_name AS account_org_name, fa.account_type AS account_type, b.name AS matched_bill_name FROM transactions t LEFT JOIN data_sources ds ON ds.id = t.data_source_id AND ds.user_id = t.user_id LEFT JOIN financial_accounts fa ON fa.id = t.account_id AND fa.user_id = t.user_id LEFT JOIN bills b ON b.id = t.matched_bill_id AND b.user_id = t.user_id AND b.deleted_at IS NULL WHERE ${where.join(' AND ')} ORDER BY COALESCE(t.posted_date, substr(t.transacted_at, 1, 10), t.created_at) DESC, t.id DESC LIMIT 100 `).all(...params).map(decorateTransaction); } function loadBills(db, userId) { return db.prepare(` SELECT b.*, c.name AS category_name FROM bills b LEFT JOIN categories c ON c.id = b.category_id AND c.deleted_at IS NULL WHERE b.user_id = ? AND b.deleted_at IS NULL AND b.active = 1 ORDER BY b.name COLLATE NOCASE ASC `).all(userId); } function loadRejections(db, userId) { const rows = db.prepare(` SELECT transaction_id, bill_id FROM match_suggestion_rejections WHERE user_id = ? AND rejected_at > datetime('now', '-90 days') `).all(userId); return new Set(rows.map(row => suggestionId(row.transaction_id, row.bill_id))); } function loadPriorMatchKeys(db, userId) { const rows = db.prepare(` SELECT matched_bill_id, payee, description FROM transactions WHERE user_id = ? AND matched_bill_id IS NOT NULL AND match_status = 'matched' AND ignored = 0 `).all(userId); const keys = new Set(); for (const row of rows) { const payee = textKey(row.payee); const description = textKey(row.description); if (payee) keys.add(`${row.matched_bill_id}:payee:${payee}`); if (description) keys.add(`${row.matched_bill_id}:description:${description}`); } return keys; } function scoreSuggestion(transaction, bill, priorMatchKeys) { const reasons = []; let score = 0; score = addAmountScore(score, reasons, transaction, bill); score = addDateScore(score, reasons, transaction, bill); score = addNameScore(score, reasons, transaction, bill); score = addPriorMatchScore(score, reasons, transaction, bill, priorMatchKeys); return { score: Math.min(score, 100), reasons }; } function listMatchSuggestions(userId, options = {}) { const db = getDb(); const rawTransactionId = options.transactionId ?? options.transaction_id; const transactionId = rawTransactionId ? normalizeId(rawTransactionId, 'transaction_id') : null; const limit = Math.max(1, Math.min(Number.parseInt(options.limit || '50', 10) || 50, 100)); const transactions = loadCandidateTransactions(db, userId, transactionId); const bills = loadBills(db, userId); const rejections = loadRejections(db, userId); const priorMatchKeys = loadPriorMatchKeys(db, userId); const suggestions = []; for (const transaction of transactions) { for (const bill of bills) { const id = suggestionId(transaction.id, bill.id); if (rejections.has(id)) continue; if (hasPaymentInTransactionCycle(db, bill, transaction)) continue; const scored = scoreSuggestion(transaction, bill, priorMatchKeys); if (scored.score < 20) continue; suggestions.push({ id, transactionId: transaction.id, billId: bill.id, score: scored.score, reasons: scored.reasons, transaction, bill: { id: bill.id, name: bill.name, expected_amount: bill.expected_amount, due_day: bill.due_day, category_name: bill.category_name || null, }, }); } } return suggestions .sort((a, b) => b.score - a.score || a.bill.name.localeCompare(b.bill.name)) .slice(0, limit); } function rejectMatchSuggestion(userId, id) { const db = getDb(); const parsed = parseSuggestionId(id); const transaction = db.prepare('SELECT id FROM transactions WHERE id = ? AND user_id = ?').get(parsed.transactionId, userId); if (!transaction) { throw suggestionError(404, 'Transaction not found', 'NOT_FOUND', 'transaction_id'); } const bill = db.prepare('SELECT id FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL').get(parsed.billId, userId); if (!bill) { throw suggestionError(404, 'Bill not found', 'NOT_FOUND', 'bill_id'); } db.prepare(` INSERT INTO match_suggestion_rejections (user_id, transaction_id, bill_id, rejected_at) VALUES (?, ?, ?, datetime('now')) ON CONFLICT(user_id, transaction_id, bill_id) DO UPDATE SET rejected_at = excluded.rejected_at `).run(userId, parsed.transactionId, parsed.billId); return { success: true, id: suggestionId(parsed.transactionId, parsed.billId), transactionId: parsed.transactionId, billId: parsed.billId, rejected: true, }; } // Auto-apply high-confidence suggestions (score >= 80) for a user. // Called by the background sync worker after each successful source sync. // Score of 80+ requires at minimum exact amount + date proximity + name signal, // so false-positive risk is low. function autoMatchForUser(userId) { const { matchTransactionToBill } = require('./transactionMatchService'); const suggestions = listMatchSuggestions(userId, { limit: 50 }); let matched = 0; for (const s of suggestions) { if (s.score < 80) break; // sorted descending — safe to stop early try { matchTransactionToBill(userId, s.transactionId, s.billId); matched++; } catch { // Already matched, ignored, bill deleted, or date missing — skip silently } } return matched; } module.exports = { autoMatchForUser, listMatchSuggestions, parseSuggestionId, rejectMatchSuggestion, suggestionId, };