'use strict'; const { normalizeMerchant } = require('./subscriptionService'); // Spending = unmatched outflows (amount < 0) that haven't been ignored. // Bill-matched transactions are excluded so there's no double-counting. const SPENDING_WHERE = ` t.amount < 0 AND t.ignored = 0 AND t.match_status != 'matched' AND t.user_id = ? `; function monthRange(year, month) { const start = `${year}-${String(month).padStart(2, '0')}-01`; const end = new Date(year, month, 0).toISOString().slice(0, 10); // last day return { start, end }; } function cents(raw) { return Math.abs(Number(raw)) / 100; } // ── Summary ────────────────────────────────────────────────────────────────── function getSpendingSummary(db, userId, year, month) { const { start, end } = monthRange(year, month); // Spending by category const rows = db.prepare(` SELECT c.id AS category_id, c.name AS category_name, SUM(ABS(t.amount)) AS total_cents, COUNT(t.id) AS tx_count FROM transactions t LEFT JOIN categories c ON c.id = t.spending_category_id AND c.deleted_at IS NULL WHERE ${SPENDING_WHERE} AND (t.posted_date BETWEEN ? AND ? OR (t.posted_date IS NULL AND t.transacted_at BETWEEN ? AND ?)) GROUP BY c.id ORDER BY total_cents DESC `).all(userId, start, end, start + 'T00:00:00', end + 'T23:59:59'); const budgets = db.prepare(` SELECT category_id, amount FROM spending_budgets WHERE user_id = ? AND year = ? AND month = ? `).all(userId, year, month); const budgetMap = new Map(budgets.map(b => [b.category_id, b.amount])); let totalCents = 0; const byCategory = rows.map(r => { totalCents += r.total_cents; return { category_id: r.category_id, category_name: r.category_name ?? '(Uncategorized)', amount: r.total_cents / 100, tx_count: r.tx_count, budget: r.category_id ? (budgetMap.get(r.category_id) ?? null) : null, }; }); // Attach pct_of_total byCategory.forEach(c => { c.pct_of_total = totalCents > 0 ? Math.round(c.amount / (totalCents / 100) * 100) / 100 : 0; }); const uncatRow = byCategory.find(c => !c.category_id); const uncategorized_amount = uncatRow?.amount ?? 0; const uncategorized_count = uncatRow?.tx_count ?? 0; // Income (positive unmatched transactions this month) const incomeRow = db.prepare(` SELECT COALESCE(SUM(t.amount), 0) AS total FROM transactions t WHERE t.user_id = ? AND t.ignored = 0 AND t.amount > 0 AND t.match_status != 'matched' AND (t.posted_date BETWEEN ? AND ? OR (t.posted_date IS NULL AND t.transacted_at BETWEEN ? AND ?)) `).get(userId, start, end, start + 'T00:00:00', end + 'T23:59:59'); return { year, month, total_spending: totalCents / 100, uncategorized_amount, uncategorized_count, by_category: byCategory, income: (incomeRow?.total ?? 0) / 100, }; } // ── Transactions ───────────────────────────────────────────────────────────── function getSpendingTransactions(db, userId, year, month, { categoryId = undefined, uncategorizedOnly = false, page = 1, limit = 50, } = {}) { const { start, end } = monthRange(year, month); const offset = (Math.max(1, page) - 1) * limit; let filter = ''; const params = [userId, start, end, start + 'T00:00:00', end + 'T23:59:59']; if (uncategorizedOnly) { filter = 'AND t.spending_category_id IS NULL'; } else if (categoryId !== undefined) { if (categoryId === null) { filter = 'AND t.spending_category_id IS NULL'; } else { filter = 'AND t.spending_category_id = ?'; params.push(categoryId); } } const rows = db.prepare(` SELECT t.id, t.amount, t.payee, t.description, t.memo, t.posted_date, t.transacted_at, t.spending_category_id, c.name AS category_name FROM transactions t LEFT JOIN categories c ON c.id = t.spending_category_id AND c.deleted_at IS NULL WHERE ${SPENDING_WHERE} ${filter} AND (t.posted_date BETWEEN ? AND ? OR (t.posted_date IS NULL AND t.transacted_at BETWEEN ? AND ?)) ORDER BY COALESCE(t.posted_date, DATE(t.transacted_at)) DESC, t.id DESC LIMIT ? OFFSET ? `).all(...params, limit, offset); const total = db.prepare(` SELECT COUNT(*) AS n FROM transactions t WHERE ${SPENDING_WHERE} ${filter} AND (t.posted_date BETWEEN ? AND ? OR (t.posted_date IS NULL AND t.transacted_at BETWEEN ? AND ?)) `).get(...params).n; return { transactions: rows.map(r => ({ id: r.id, amount: cents(r.amount), payee: r.payee || r.description || r.memo || '(Unknown)', date: r.posted_date || (r.transacted_at ? String(r.transacted_at).slice(0, 10) : null), spending_category_id: r.spending_category_id, spending_category_name: r.category_name ?? null, })), total, page, pages: Math.ceil(total / limit), }; } // ── Categorize ─────────────────────────────────────────────────────────────── function categorizeTransaction(db, userId, txId, categoryId, saveMerchantRule = false) { const tx = db.prepare('SELECT * FROM transactions WHERE id = ? AND user_id = ?').get(txId, userId); if (!tx) throw Object.assign(new Error('Transaction not found'), { status: 404 }); db.prepare("UPDATE transactions SET spending_category_id=?, updated_at=datetime('now') WHERE id=? AND user_id=?") .run(categoryId ?? null, txId, userId); if (saveMerchantRule && categoryId) { const merchant = normalizeMerchant(tx.payee || tx.description || tx.memo || ''); if (merchant && merchant.length >= 3) { try { db.prepare(` INSERT INTO spending_category_rules (user_id, category_id, merchant) VALUES (?, ?, ?) ON CONFLICT(user_id, merchant) DO UPDATE SET category_id=excluded.category_id `).run(userId, categoryId, merchant); // Auto-mark this category as spending-enabled db.prepare("UPDATE categories SET spending_enabled=1, updated_at=datetime('now') WHERE id=? AND user_id=?") .run(categoryId, userId); applySpendingCategoryRules(db, userId, merchant); } catch { /* safe to ignore */ } } } } // ── Auto-categorization ────────────────────────────────────────────────────── function applySpendingCategoryRules(db, userId, onlyMerchant = null) { let rules; try { const q = onlyMerchant ? db.prepare('SELECT merchant, category_id FROM spending_category_rules WHERE user_id=? AND merchant=?') .all(userId, onlyMerchant) : db.prepare('SELECT merchant, category_id FROM spending_category_rules WHERE user_id=?') .all(userId); rules = q; } catch { return 0; } if (!rules.length) return 0; let applied = 0; const update = db.prepare("UPDATE transactions SET spending_category_id=?, updated_at=datetime('now') WHERE id=? AND user_id=?"); const txRows = db.prepare(` SELECT id, payee, description, memo FROM transactions WHERE user_id=? AND amount<0 AND ignored=0 AND match_status!='matched' AND spending_category_id IS NULL `).all(userId); db.transaction(() => { for (const tx of txRows) { const merchant = normalizeMerchant(tx.payee || tx.description || tx.memo || ''); if (!merchant) continue; const rule = rules.find(r => merchantMatches(merchant, r.merchant)); if (rule) { update.run(rule.category_id, tx.id, userId); applied++; } } })(); return applied; } function merchantMatches(txMerchant, ruleMerchant) { if (!txMerchant || !ruleMerchant) return false; if (txMerchant === ruleMerchant) return true; const esc = s => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const wb = s => new RegExp(`(^|\\s)${esc(s)}(\\s|$)`); return wb(ruleMerchant).test(txMerchant) || wb(txMerchant).test(ruleMerchant); } // ── Budgets ────────────────────────────────────────────────────────────────── function getSpendingBudgets(db, userId, year, month) { return db.prepare(` SELECT sb.category_id, sb.amount, c.name AS category_name FROM spending_budgets sb JOIN categories c ON c.id = sb.category_id AND c.deleted_at IS NULL WHERE sb.user_id=? AND sb.year=? AND sb.month=? `).all(userId, year, month); } function setSpendingBudget(db, userId, categoryId, year, month, amount) { if (amount === null || amount === undefined) { db.prepare('DELETE FROM spending_budgets WHERE user_id=? AND category_id=? AND year=? AND month=?') .run(userId, categoryId, year, month); } else { db.prepare(` INSERT INTO spending_budgets (user_id, category_id, year, month, amount, updated_at) VALUES (?, ?, ?, ?, ?, datetime('now')) ON CONFLICT(user_id, category_id, year, month) DO UPDATE SET amount=excluded.amount, updated_at=datetime('now') `).run(userId, categoryId, year, month, Number(amount)); } } // ── Category rules ─────────────────────────────────────────────────────────── function getSpendingCategoryRules(db, userId) { return db.prepare(` SELECT r.id, r.merchant, r.category_id, c.name AS category_name FROM spending_category_rules r JOIN categories c ON c.id = r.category_id AND c.deleted_at IS NULL WHERE r.user_id=? ORDER BY r.merchant ASC `).all(userId); } function addSpendingCategoryRule(db, userId, categoryId, merchant) { const normalized = normalizeMerchant(merchant); if (!normalized || normalized.length < 2) throw Object.assign(new Error('Merchant name too short'), { status: 400 }); db.prepare(` INSERT INTO spending_category_rules (user_id, category_id, merchant) VALUES (?, ?, ?) ON CONFLICT(user_id, merchant) DO UPDATE SET category_id=excluded.category_id `).run(userId, categoryId, normalized); // Auto-mark this category as spending-enabled so it shows in the picker try { db.prepare("UPDATE categories SET spending_enabled=1, updated_at=datetime('now') WHERE id=? AND user_id=?") .run(categoryId, userId); } catch { /* non-critical */ } applySpendingCategoryRules(db, userId, normalized); } function deleteSpendingCategoryRule(db, userId, ruleId) { db.prepare('DELETE FROM spending_category_rules WHERE id=? AND user_id=?').run(ruleId, userId); } // ── Income ─────────────────────────────────────────────────────────────────── function getIncomeTransactions(db, userId, year, month, { page = 1, limit = 50 } = {}) { const { start, end } = monthRange(year, month); const offset = (Math.max(1, page) - 1) * limit; try { const rows = db.prepare(` SELECT id, amount, payee, description, memo, posted_date, transacted_at FROM transactions WHERE user_id = ? AND amount > 0 AND ignored = 0 AND match_status != 'matched' AND (posted_date BETWEEN ? AND ? OR (posted_date IS NULL AND transacted_at BETWEEN ? AND ?)) ORDER BY COALESCE(posted_date, DATE(transacted_at)) DESC, id DESC LIMIT ? OFFSET ? `).all(userId, start, end, start + 'T00:00:00', end + 'T23:59:59', limit, offset); const total = db.prepare(` SELECT COUNT(*) AS n FROM transactions WHERE user_id = ? AND amount > 0 AND ignored = 0 AND match_status != 'matched' AND (posted_date BETWEEN ? AND ? OR (posted_date IS NULL AND transacted_at BETWEEN ? AND ?)) `).get(userId, start, end, start + 'T00:00:00', end + 'T23:59:59').n; return { transactions: rows.map(r => ({ id: r.id, amount: Math.abs(Number(r.amount)) / 100, payee: r.payee || r.description || r.memo || '(Unknown)', date: r.posted_date || (r.transacted_at ? String(r.transacted_at).slice(0, 10) : null), })), total, page, pages: Math.ceil(total / limit), }; } catch (err) { console.error('[getIncomeTransactions]', err.message); return { transactions: [], total: 0, page: 1, pages: 1 }; } } module.exports = { getSpendingSummary, getSpendingTransactions, categorizeTransaction, applySpendingCategoryRules, getSpendingBudgets, setSpendingBudget, getSpendingCategoryRules, addSpendingCategoryRule, deleteSpendingCategoryRule, getIncomeTransactions, };