204 lines
7.7 KiB
JavaScript
204 lines
7.7 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) {
|
|
let rules;
|
|
try {
|
|
rules = db.prepare(`
|
|
SELECT bmr.bill_id, bmr.merchant, b.name AS bill_name
|
|
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
|
|
|
|
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}`);
|
|
}
|
|
}
|
|
})();
|
|
} catch (err) {
|
|
console.error('[applyMerchantRules] Transaction failed, no payments recorded:', err.message);
|
|
return { matched: 0, matched_bills: [] };
|
|
}
|
|
|
|
return { matched, matched_bills: [...matchedBills.values()] };
|
|
}
|
|
|
|
// 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 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 added = 0;
|
|
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++;
|
|
}
|
|
}
|
|
})();
|
|
} catch (err) {
|
|
console.error('[syncBillPaymentsFromSimplefin] Transaction failed, no payments recorded:', err.message);
|
|
return { added: 0 };
|
|
}
|
|
|
|
return { added };
|
|
}
|
|
|
|
module.exports = { addMerchantRule, applyMerchantRules, syncBillPaymentsFromSimplefin };
|