345 lines
15 KiB
JavaScript
345 lines
15 KiB
JavaScript
'use strict';
|
|
|
|
const { normalizeMerchant } = require('./subscriptionService');
|
|
const { getUserSettings } = require('./userSettings');
|
|
const { applyBankPaymentAsSourceOfTruth } = require('./paymentAccountingService');
|
|
const { localDateString } = require('../utils/dates');
|
|
|
|
// 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);
|
|
}
|
|
|
|
// Detects when a payment posted just after month end but the bill was due in the
|
|
// prior month. Grace window: up to `graceDays` days into the new month.
|
|
// Module-scoped so both applyMerchantRules and syncBillPaymentsFromSimplefin can use it.
|
|
function lateAttributionCandidate(paidDateStr, dueDayOfMonth, graceDays = 5) {
|
|
const paid = new Date(paidDateStr + 'T00:00:00');
|
|
const dayOfMonth = paid.getDate();
|
|
if (dayOfMonth > graceDays) return null;
|
|
const prevMonthLastDay = new Date(paid.getFullYear(), paid.getMonth(), 0);
|
|
if (dueDayOfMonth > prevMonthLastDay.getDate()) return null;
|
|
return localDateString(prevMonthLastDay); // suggested prior-month date
|
|
}
|
|
|
|
// Persist a merchant→bill rule so future synced transactions auto-match.
|
|
function addMerchantRule(db, userId, billId, merchant) {
|
|
const normalized = normalizeMerchant(merchant);
|
|
if (!normalized || normalized.length < 3) return;
|
|
try {
|
|
db.prepare(`
|
|
INSERT INTO bill_merchant_rules (user_id, bill_id, merchant)
|
|
VALUES (?, ?, ?)
|
|
ON CONFLICT(user_id, bill_id, merchant) DO NOTHING
|
|
`).run(userId, billId, normalized);
|
|
} catch {
|
|
// Table may not exist yet on legacy DBs — safe to skip
|
|
}
|
|
}
|
|
|
|
// Generic descriptor tokens that must never become a merchant rule on their own —
|
|
// a rule like "ach" or "atm" would word-match far too many unrelated transactions.
|
|
// (normalizeMerchant already strips pos/debit/card/payment/purchase/recurring/online;
|
|
// this catches the residual generic tokens it leaves behind.)
|
|
const GENERIC_MERCHANT_TOKENS = new Set([
|
|
'ach', 'atm', 'transfer', 'xfer', 'pmt', 'withdrawal', 'deposit', 'check',
|
|
'fee', 'bill', 'autopay', 'draft', 'credit', 'bank', 'visa', 'mastercard',
|
|
'amex', 'web', 'mobile', 'pending', 'transaction',
|
|
]);
|
|
|
|
// Derive a specific, reusable merchant string from a confirmed transaction match.
|
|
// Returns null when the text is too generic to be a safe auto-match rule.
|
|
function learnableMerchantFromTransaction(transaction) {
|
|
if (!transaction) return null;
|
|
const raw = transaction.payee || transaction.description || '';
|
|
const norm = normalizeMerchant(raw);
|
|
if (!norm || norm.length < 4) return null;
|
|
const tokens = norm.split(' ').filter(Boolean);
|
|
// Require at least one meaningful (non-generic, length >= 3) token.
|
|
const meaningful = tokens.filter(t => t.length >= 3 && !GENERIC_MERCHANT_TOKENS.has(t));
|
|
if (meaningful.length === 0) return null;
|
|
return norm;
|
|
}
|
|
|
|
// Learn a merchant→bill rule from an explicit user confirmation so future synced
|
|
// transactions from the same merchant auto-match. Best-effort and idempotent —
|
|
// never throws, returns the merchant it stored (or null when nothing was learned).
|
|
function learnMerchantRuleFromMatch(db, userId, billId, transaction) {
|
|
try {
|
|
const merchant = learnableMerchantFromTransaction(transaction);
|
|
if (!merchant) return null;
|
|
addMerchantRule(db, userId, billId, merchant);
|
|
return merchant;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// Scan all unmatched negative transactions for this user, apply any stored
|
|
// merchant rules, create payments, and mark the transactions matched.
|
|
// Returns { matched: number }.
|
|
function applyMerchantRules(db, userId) {
|
|
let rules;
|
|
try {
|
|
rules = db.prepare(`
|
|
SELECT bmr.bill_id, bmr.merchant, bmr.auto_attribute_late, b.name AS bill_name, b.due_day
|
|
FROM bill_merchant_rules bmr
|
|
JOIN bills b ON b.id = bmr.bill_id AND b.user_id = bmr.user_id AND b.deleted_at IS NULL
|
|
WHERE bmr.user_id = ?
|
|
`).all(userId);
|
|
} catch {
|
|
return { matched: 0 };
|
|
}
|
|
|
|
if (rules.length === 0) return { matched: 0, matched_bills: [] };
|
|
|
|
// Longer (more specific) rules win when multiple could match the same transaction
|
|
rules.sort((a, b) => b.merchant.length - a.merchant.length);
|
|
|
|
let txRows;
|
|
try {
|
|
txRows = db.prepare(`
|
|
SELECT t.id, t.amount, t.payee, t.description, t.memo, t.posted_date, t.transacted_at
|
|
FROM transactions t
|
|
LEFT JOIN financial_accounts fa ON fa.id = t.account_id AND fa.user_id = t.user_id
|
|
WHERE t.user_id = ?
|
|
AND t.match_status = 'unmatched'
|
|
AND t.ignored = 0
|
|
AND t.amount < 0
|
|
AND t.pending = 0
|
|
AND (t.account_id IS NULL OR fa.id IS NULL OR fa.monitored = 1)
|
|
`).all(userId);
|
|
} catch (err) {
|
|
console.error('[applyMerchantRules] Failed to fetch transactions:', err.message);
|
|
return { matched: 0, matched_bills: [] };
|
|
}
|
|
|
|
if (txRows.length === 0) return { matched: 0, matched_bills: [] };
|
|
|
|
// Global grace window — auto-apply late attribution for ALL bills within this many days
|
|
const userSettings = (() => { try { return getUserSettings(userId); } catch { return {}; } })();
|
|
const globalGraceDays = parseInt(userSettings.bank_late_attribution_days, 10) || 0;
|
|
|
|
const getBill = db.prepare('SELECT * FROM bills WHERE id = ? AND deleted_at IS NULL');
|
|
const insertPayment = db.prepare(`
|
|
INSERT OR IGNORE INTO payments (bill_id, amount, paid_date, payment_source, transaction_id)
|
|
VALUES (?, ?, ?, 'provider_sync', ?)
|
|
`);
|
|
const updateTx = db.prepare(`
|
|
UPDATE transactions
|
|
SET matched_bill_id = ?, match_status = 'matched', updated_at = datetime('now')
|
|
WHERE id = ? AND user_id = ? AND match_status = 'unmatched'
|
|
`);
|
|
|
|
let matched = 0;
|
|
const matchedBills = new Map(); // bill_id → bill_name for the summary
|
|
const lateAttributions = []; // payments that crossed a month boundary
|
|
|
|
try {
|
|
db.transaction(() => {
|
|
for (const tx of txRows) {
|
|
const txMerchant = normalizeMerchant(tx.payee || tx.description || tx.memo || '');
|
|
if (!txMerchant) continue;
|
|
|
|
// All rules that match this transaction (rules is pre-sorted most-specific
|
|
// first by merchant length). If the most-specific tier maps to more than one
|
|
// distinct bill — e.g. two bills both named "Amazon" with a matching rule —
|
|
// the match is ambiguous, so skip auto-attribution and leave it for manual
|
|
// review rather than silently guessing the wrong bill.
|
|
const matchingRules = rules.filter(r => merchantMatches(txMerchant, r.merchant));
|
|
if (matchingRules.length === 0) continue;
|
|
const topLen = matchingRules[0].merchant.length;
|
|
const topBills = new Set(
|
|
matchingRules.filter(r => r.merchant.length === topLen).map(r => r.bill_id)
|
|
);
|
|
if (topBills.size > 1) continue;
|
|
const rule = matchingRules[0];
|
|
|
|
const paidDate = tx.posted_date || (tx.transacted_at ? String(tx.transacted_at).slice(0, 10) : null);
|
|
if (!paidDate) continue;
|
|
|
|
const amount = Math.round(Math.abs(tx.amount)) / 100;
|
|
const bill = getBill.get(rule.bill_id);
|
|
|
|
const result = insertPayment.run(rule.bill_id, amount, paidDate, tx.id);
|
|
if (result.changes > 0) {
|
|
const inserted = db.prepare('SELECT * FROM payments WHERE transaction_id = ? AND bill_id = ? AND deleted_at IS NULL').get(tx.id, rule.bill_id);
|
|
updateTx.run(rule.bill_id, tx.id, userId);
|
|
matched++;
|
|
matchedBills.set(rule.bill_id, rule.bill_name || `Bill #${rule.bill_id}`);
|
|
|
|
// Check if this payment just missed the previous month's window
|
|
const effectiveGrace = Math.max(5, globalGraceDays);
|
|
const suggestedDate = lateAttributionCandidate(paidDate, rule.due_day, effectiveGrace);
|
|
if (suggestedDate) {
|
|
const dayOfMonth = new Date(paidDate + 'T00:00:00').getDate();
|
|
// Auto-apply if: per-rule toggle is on OR global grace window covers this day
|
|
const autoApply = rule.auto_attribute_late ||
|
|
(globalGraceDays > 0 && dayOfMonth <= globalGraceDays);
|
|
|
|
if (autoApply) {
|
|
db.prepare("UPDATE payments SET paid_date = ?, updated_at = datetime('now') WHERE transaction_id = ? AND bill_id = ? AND deleted_at IS NULL")
|
|
.run(suggestedDate, tx.id, rule.bill_id);
|
|
if (inserted) inserted.paid_date = suggestedDate;
|
|
} else {
|
|
const insertedForPrompt = db.prepare(
|
|
'SELECT id FROM payments WHERE transaction_id = ? AND bill_id = ? AND deleted_at IS NULL'
|
|
).get(tx.id, rule.bill_id);
|
|
if (insertedForPrompt) {
|
|
lateAttributions.push({
|
|
payment_id: insertedForPrompt.id,
|
|
bill_name: rule.bill_name || `Bill #${rule.bill_id}`,
|
|
original_date: paidDate,
|
|
suggested_date: suggestedDate,
|
|
amount,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
if (bill && inserted) applyBankPaymentAsSourceOfTruth(db, bill, inserted);
|
|
}
|
|
}
|
|
})();
|
|
} catch (err) {
|
|
console.error('[applyMerchantRules] Transaction failed, no payments recorded:', err.message);
|
|
return { matched: 0, matched_bills: [], late_attributions: [] };
|
|
}
|
|
|
|
return { matched, matched_bills: [...matchedBills.values()], late_attributions: lateAttributions };
|
|
}
|
|
|
|
// Sync all unmatched SimpleFIN transactions for a single bill using its stored
|
|
// merchant rules. If no rule exists yet but the bill has a detected merchant in
|
|
// its notes, the rule is created on the fly.
|
|
// Returns { added: number }.
|
|
function syncBillPaymentsFromSimplefin(db, userId, billId) {
|
|
// Load rules for this specific bill
|
|
let rules;
|
|
try {
|
|
rules = db.prepare(`
|
|
SELECT merchant FROM bill_merchant_rules
|
|
WHERE user_id = ? AND bill_id = ?
|
|
ORDER BY LENGTH(merchant) DESC
|
|
`).all(userId, billId).map(r => r.merchant);
|
|
} catch {
|
|
return { added: 0 };
|
|
}
|
|
|
|
// Fallback: extract merchant from notes "Detected from recurring merchant: X"
|
|
if (rules.length === 0) {
|
|
try {
|
|
const bill = db.prepare('SELECT notes FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL').get(billId, userId);
|
|
const match = bill?.notes?.match(/Detected from recurring merchant:\s*(.+)/i);
|
|
if (match) {
|
|
const extracted = normalizeMerchant(match[1].trim());
|
|
if (extracted && extracted.length >= 3) {
|
|
addMerchantRule(db, userId, billId, extracted);
|
|
rules = [extracted];
|
|
}
|
|
}
|
|
} catch (err) {
|
|
console.error('[syncBillPaymentsFromSimplefin] Failed to read bill notes:', err.message);
|
|
}
|
|
if (rules.length === 0) return { added: 0 };
|
|
}
|
|
|
|
let txRows;
|
|
try {
|
|
txRows = db.prepare(`
|
|
SELECT t.id, t.amount, t.payee, t.description, t.memo, t.posted_date, t.transacted_at
|
|
FROM transactions t
|
|
LEFT JOIN financial_accounts fa ON fa.id = t.account_id AND fa.user_id = t.user_id
|
|
WHERE t.user_id = ?
|
|
AND t.match_status = 'unmatched'
|
|
AND t.ignored = 0
|
|
AND t.amount < 0
|
|
AND t.pending = 0
|
|
AND (t.account_id IS NULL OR fa.id IS NULL OR fa.monitored = 1)
|
|
`).all(userId);
|
|
} catch (err) {
|
|
console.error('[syncBillPaymentsFromSimplefin] Failed to fetch transactions:', err.message);
|
|
return { added: 0 };
|
|
}
|
|
|
|
if (txRows.length === 0) return { added: 0 };
|
|
|
|
const billMeta = db.prepare('SELECT name, due_day, current_balance, interest_rate FROM bills WHERE id = ? AND deleted_at IS NULL').get(billId);
|
|
const getBill = db.prepare('SELECT * FROM bills WHERE id = ? AND deleted_at IS NULL');
|
|
const insertPayment = db.prepare(`
|
|
INSERT OR IGNORE INTO payments (bill_id, amount, paid_date, payment_source, transaction_id)
|
|
VALUES (?, ?, ?, 'provider_sync', ?)
|
|
`);
|
|
const updateTx = db.prepare(`
|
|
UPDATE transactions
|
|
SET matched_bill_id = ?, match_status = 'matched', updated_at = datetime('now')
|
|
WHERE id = ? AND user_id = ? AND match_status = 'unmatched'
|
|
`);
|
|
const getPaymentId = db.prepare('SELECT * FROM payments WHERE transaction_id = ? AND bill_id = ? AND deleted_at IS NULL');
|
|
|
|
let added = 0;
|
|
const lateAttributions = [];
|
|
try {
|
|
db.transaction(() => {
|
|
for (const tx of txRows) {
|
|
const txMerchant = normalizeMerchant(tx.payee || tx.description || tx.memo || '');
|
|
if (!txMerchant) continue;
|
|
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;
|
|
const amount = Math.round(Math.abs(tx.amount)) / 100;
|
|
const bill = getBill.get(billId);
|
|
|
|
const result = insertPayment.run(billId, amount, paidDate, tx.id);
|
|
if (result.changes > 0) {
|
|
const inserted = getPaymentId.get(tx.id, billId);
|
|
updateTx.run(billId, tx.id, userId);
|
|
added++;
|
|
|
|
// Check for late attribution (payment just crossed month boundary)
|
|
const globalDays2 = (() => { try { return parseInt(getUserSettings(userId).bank_late_attribution_days, 10) || 0; } catch { return 0; } })();
|
|
const effectiveGrace2 = Math.max(5, globalDays2);
|
|
const suggestedDate = billMeta ? lateAttributionCandidate(paidDate, billMeta.due_day, effectiveGrace2) : null;
|
|
if (suggestedDate) {
|
|
const dayOfMonth = new Date(paidDate + 'T00:00:00').getDate();
|
|
const globalDays = globalDays2;
|
|
const autoApply = (globalDays > 0 && dayOfMonth <= globalDays);
|
|
|
|
if (autoApply) {
|
|
db.prepare("UPDATE payments SET paid_date = ?, updated_at = datetime('now') WHERE transaction_id = ? AND bill_id = ? AND deleted_at IS NULL")
|
|
.run(suggestedDate, tx.id, billId);
|
|
if (inserted) inserted.paid_date = suggestedDate;
|
|
} else {
|
|
const insertedForPrompt = getPaymentId.get(tx.id, billId);
|
|
if (insertedForPrompt) {
|
|
lateAttributions.push({
|
|
payment_id: insertedForPrompt.id,
|
|
bill_name: billMeta.name || `Bill #${billId}`,
|
|
original_date: paidDate,
|
|
suggested_date: suggestedDate,
|
|
amount,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
if (bill && inserted) applyBankPaymentAsSourceOfTruth(db, bill, inserted);
|
|
}
|
|
}
|
|
})();
|
|
} catch (err) {
|
|
console.error('[syncBillPaymentsFromSimplefin] Transaction failed, no payments recorded:', err.message);
|
|
return { added: 0, late_attributions: [] };
|
|
}
|
|
|
|
return { added, late_attributions: lateAttributions };
|
|
}
|
|
|
|
module.exports = { addMerchantRule, applyMerchantRules, syncBillPaymentsFromSimplefin, merchantMatches, learnMerchantRuleFromMatch, learnableMerchantFromTransaction };
|