BillTracker/services/matchSuggestionService.js

370 lines
12 KiB
JavaScript
Raw Normal View History

'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,
};