BillTracker/services/billMerchantRuleService.js

253 lines
10 KiB
JavaScript

'use strict';
const { normalizeMerchant } = require('./subscriptionService');
const { computeBalanceDelta } = require('./billsService');
// 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
}
}
// 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) {
// Detects when a payment posted just after month end but the bill was due in the prior month.
// Grace window: up to LATE_ATTR_DAYS days into the new month.
const LATE_ATTR_DAYS = 5;
function lateAttributionCandidate(paidDateStr, dueDayOfMonth) {
const paid = new Date(paidDateStr + 'T00:00:00');
const dayOfMonth = paid.getDate();
if (dayOfMonth > LATE_ATTR_DAYS) return null;
const prevMonthLastDay = new Date(paid.getFullYear(), paid.getMonth(), 0);
if (dueDayOfMonth > prevMonthLastDay.getDate()) return null;
return prevMonthLastDay.toISOString().slice(0, 10); // suggested prior-month date
}
let rules;
try {
rules = db.prepare(`
SELECT bmr.bill_id, bmr.merchant, 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: [] };
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.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: [] };
const getBill = db.prepare('SELECT current_balance, interest_rate 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, balance_delta)
VALUES (?, ?, ?, 'provider_sync', ?, ?)
`);
const updateBalance = db.prepare("UPDATE bills SET current_balance = ?, updated_at = datetime('now') WHERE id = ?");
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;
const rule = rules.find(r =>
txMerchant.includes(r.merchant) || r.merchant.includes(txMerchant)
);
if (!rule) 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(rule.bill_id);
const balCalc = bill ? computeBalanceDelta(bill, amount) : null;
const result = insertPayment.run(rule.bill_id, amount, paidDate, tx.id, balCalc?.balance_delta ?? null);
if (result.changes > 0) {
if (balCalc) updateBalance.run(balCalc.new_balance, 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 suggestedDate = lateAttributionCandidate(paidDate, rule.due_day);
if (suggestedDate) {
// Fetch the payment id just inserted
const inserted = db.prepare(
'SELECT id FROM payments WHERE transaction_id = ? AND bill_id = ? AND deleted_at IS NULL'
).get(tx.id, rule.bill_id);
if (inserted) {
lateAttributions.push({
payment_id: inserted.id,
bill_name: rule.bill_name || `Bill #${rule.bill_id}`,
original_date: paidDate,
suggested_date: suggestedDate,
amount,
});
}
}
}
}
})();
} 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 = ?
`).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.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 current_balance, interest_rate 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, balance_delta)
VALUES (?, ?, ?, 'provider_sync', ?, ?)
`);
const updateBalance = db.prepare("UPDATE bills SET current_balance = ?, updated_at = datetime('now') WHERE id = ?");
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 id 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 => txMerchant.includes(r) || r.includes(txMerchant));
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 balCalc = bill ? computeBalanceDelta(bill, amount) : null;
const result = insertPayment.run(billId, amount, paidDate, tx.id, balCalc?.balance_delta ?? null);
if (result.changes > 0) {
if (balCalc) updateBalance.run(balCalc.new_balance, billId);
updateTx.run(billId, tx.id, userId);
added++;
// Check for late attribution (payment just crossed month boundary)
const suggestedDate = billMeta ? lateAttributionCandidate(paidDate, billMeta.due_day) : null;
if (suggestedDate) {
const inserted = getPaymentId.get(tx.id, billId);
if (inserted) {
lateAttributions.push({
payment_id: inserted.id,
bill_name: billMeta.name || `Bill #${billId}`,
original_date: paidDate,
suggested_date: suggestedDate,
amount,
});
}
}
}
}
})();
} 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 };