379 lines
12 KiB
JavaScript
379 lines
12 KiB
JavaScript
'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.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) {
|
|
try {
|
|
const rows = db.prepare(`
|
|
SELECT transaction_id, bill_id
|
|
FROM match_suggestion_rejections
|
|
WHERE user_id = ?
|
|
AND created_at > datetime('now', '-90 days')
|
|
`).all(userId);
|
|
return new Set(rows.map(row => suggestionId(row.transaction_id, row.bill_id)));
|
|
} catch {
|
|
// Fall back to all rejections if created_at column doesn't exist yet
|
|
try {
|
|
const rows = db.prepare(`
|
|
SELECT transaction_id, bill_id FROM match_suggestion_rejections WHERE user_id = ?
|
|
`).all(userId);
|
|
return new Set(rows.map(row => suggestionId(row.transaction_id, row.bill_id)));
|
|
} catch { return new Set(); }
|
|
}
|
|
}
|
|
|
|
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,
|
|
};
|