'use strict'; import type { Db } from '../types/db'; const { normalizeMerchant } = require('./subscriptionService.cts'); const { localDateString } = require('../utils/dates.mts') as typeof import('../utils/dates.mts'); const { toCents, fromCents } = require('../utils/money.mts') as typeof import('../utils/money.mts'); // 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 = ? `; interface SpendingCatRow { category_id: number | string | null; category_name: string; group_id: number | null; group_name: string | null; amount: number; tx_count: number; budget: number | null; avg_3mo: number | null; pct_of_total?: number; } interface SpendingTxOptions { categoryId?: number | null; uncategorizedOnly?: boolean; page?: number; limit?: number; } interface IncomeOptions { page?: number; limit?: number; includeIgnored?: boolean; } function monthRange(year: number, month: number): { start: string; end: string } { const start = `${year}-${String(month).padStart(2, '0')}-01`; const end = localDateString(new Date(year, month, 0)); // last day return { start, end }; } function shiftMonth(year: number, month: number, delta: number): { year: number; month: number } { let m = month + delta, y = year; while (m < 1) { m += 12; y--; } while (m > 12) { m -= 12; y++; } return { year: y, month: m }; } function cents(raw: unknown): number { return Math.abs(Number(raw)) / 100; } // ── Summary ────────────────────────────────────────────────────────────────── function getSpendingSummary(db: Db, userId: number, year: number, month: number) { const { start, end } = monthRange(year, month); const dateRangeParams = [start, end, start + 'T00:00:00', end + 'T23:59:59']; const DATE_RANGE_SQL = '(t.posted_date BETWEEN ? AND ? OR (t.posted_date IS NULL AND t.transacted_at BETWEEN ? AND ?))'; // Every spending-enabled category, even with $0 activity this month (YNAB-style: budgeted // categories are always visible, not just ones with transactions). const categoryRows = db .prepare( ` SELECT c.id AS category_id, c.name AS category_name, c.group_id AS group_id, cg.name AS group_name, COALESCE(SUM(ABS(t.amount)), 0) AS total_cents, COUNT(t.id) AS tx_count FROM categories c LEFT JOIN category_groups cg ON cg.id = c.group_id LEFT JOIN transactions t ON t.spending_category_id = c.id AND ${SPENDING_WHERE} AND ${DATE_RANGE_SQL} WHERE c.user_id = ? AND c.deleted_at IS NULL AND c.spending_enabled = 1 GROUP BY c.id ORDER BY CASE WHEN c.sort_order IS NULL THEN 1 ELSE 0 END, c.sort_order ASC, c.name COLLATE NOCASE ASC `, ) .all(userId, ...dateRangeParams, userId); // Uncategorized outflows const uncatRow = db .prepare( ` SELECT COALESCE(SUM(ABS(t.amount)), 0) AS total_cents, COUNT(t.id) AS tx_count FROM transactions t WHERE ${SPENDING_WHERE} AND t.spending_category_id IS NULL AND ${DATE_RANGE_SQL} `, ) .get(userId, ...dateRangeParams); // Outflows categorized to a category that isn't (or is no longer) spending-enabled — // bundled into a single "Other categories" row so totals stay accurate without polluting // the spending-enabled category list. const otherRow = db .prepare( ` SELECT COALESCE(SUM(ABS(t.amount)), 0) AS total_cents, COUNT(t.id) AS tx_count FROM transactions t LEFT JOIN categories c ON c.id = t.spending_category_id WHERE ${SPENDING_WHERE} AND ${DATE_RANGE_SQL} AND t.spending_category_id IS NOT NULL AND (c.id IS NULL OR c.deleted_at IS NOT NULL OR c.spending_enabled = 0) `, ) .get(userId, ...dateRangeParams); 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: any) => [b.category_id, b.amount])); // 3-month spending average per category (the 3 calendar months before the requested one) const prev1 = monthRange(shiftMonth(year, month, -1).year, shiftMonth(year, month, -1).month); const prev3 = monthRange(shiftMonth(year, month, -3).year, shiftMonth(year, month, -3).month); const avgRows = db .prepare( ` SELECT t.spending_category_id AS category_id, COALESCE(SUM(ABS(t.amount)), 0) AS total_cents FROM transactions t WHERE ${SPENDING_WHERE} AND t.spending_category_id IS NOT NULL AND (t.posted_date BETWEEN ? AND ? OR (t.posted_date IS NULL AND t.transacted_at BETWEEN ? AND ?)) GROUP BY t.spending_category_id `, ) .all(userId, prev3.start, prev1.end, prev3.start + 'T00:00:00', prev1.end + 'T23:59:59'); const avgMap = new Map(avgRows.map((r: any) => [r.category_id, r.total_cents / 3])); let totalCents = 0; const byCategory: SpendingCatRow[] = categoryRows.map((r: any) => { totalCents += r.total_cents; return { category_id: r.category_id, category_name: r.category_name, group_id: r.group_id ?? null, group_name: r.group_name ?? null, amount: r.total_cents / 100, tx_count: r.tx_count, budget: fromCents(budgetMap.get(r.category_id) ?? null), avg_3mo: fromCents(avgMap.get(r.category_id) ?? 0), }; }); if (uncatRow.tx_count > 0) { totalCents += uncatRow.total_cents; byCategory.push({ category_id: null, category_name: '(Uncategorized)', group_id: null, group_name: null, amount: uncatRow.total_cents / 100, tx_count: uncatRow.tx_count, budget: null, avg_3mo: null, }); } if (otherRow.tx_count > 0) { totalCents += otherRow.total_cents; byCategory.push({ category_id: 'other', category_name: 'Other categories', group_id: null, group_name: null, amount: otherRow.total_cents / 100, tx_count: otherRow.tx_count, budget: null, avg_3mo: null, }); } // Attach pct_of_total byCategory.forEach((c) => { c.pct_of_total = totalCents > 0 ? Math.round((c.amount / (totalCents / 100)) * 100) / 100 : 0; }); const uncategorized_amount = uncatRow.total_cents / 100; const uncategorized_count = uncatRow.tx_count; // 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'); const categoryGroups = db .prepare( ` SELECT id, name, sort_order FROM category_groups WHERE user_id = ? ORDER BY sort_order ASC, name COLLATE NOCASE ASC `, ) .all(userId); return { year, month, total_spending: totalCents / 100, uncategorized_amount, uncategorized_count, by_category: byCategory, category_groups: categoryGroups, income: (incomeRow?.total ?? 0) / 100, }; } // ── Transactions ───────────────────────────────────────────────────────────── function getSpendingTransactions( db: Db, userId: number, year: number, month: number, { categoryId = undefined, uncategorizedOnly = false, page = 1, limit = 50, }: SpendingTxOptions = {}, ) { const { start, end } = monthRange(year, month); const offset = (Math.max(1, page) - 1) * limit; let filter = ''; const params: any[] = [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: any) => ({ 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: Db, userId: number, txId: number, categoryId: number | null, saveMerchantRule = false, ): void { 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: Db, userId: number, onlyMerchant: string | null = null, ): number { let rules: any[]; 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: string, ruleMerchant: string): boolean { if (!txMerchant || !ruleMerchant) return false; if (txMerchant === ruleMerchant) return true; const esc = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const wb = (s: string) => new RegExp(`(^|\\s)${esc(s)}(\\s|$)`); return wb(ruleMerchant).test(txMerchant) || wb(txMerchant).test(ruleMerchant); } // ── Budgets ────────────────────────────────────────────────────────────────── function getSpendingBudgets(db: Db, userId: number, year: number, month: number) { const rows = 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); return rows.map((r: any) => ({ ...r, amount: fromCents(r.amount) })); } function setSpendingBudget( db: Db, userId: number, categoryId: number, year: number, month: number, amount: number | string | null, ): void { 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, toCents(amount)); } } // ── Category rules ─────────────────────────────────────────────────────────── function getSpendingCategoryRules(db: Db, userId: number) { 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: Db, userId: number, categoryId: number, merchant: string, ): void { 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: Db, userId: number, ruleId: number): void { db.prepare('DELETE FROM spending_category_rules WHERE id=? AND user_id=?').run(ruleId, userId); } // ── Income ─────────────────────────────────────────────────────────────────── function getIncomeTransactions( db: Db, userId: number, year: number, month: number, { page = 1, limit = 50, includeIgnored = false }: IncomeOptions = {}, ) { const { start, end } = monthRange(year, month); const offset = (Math.max(1, page) - 1) * limit; const ignoredFilter = includeIgnored ? '' : 'AND ignored = 0'; try { const rows = db .prepare( ` SELECT id, amount, payee, description, memo, posted_date, transacted_at, ignored FROM transactions WHERE user_id = ? AND amount > 0 ${ignoredFilter} AND match_status != 'matched' AND (posted_date BETWEEN ? AND ? OR (posted_date IS NULL AND transacted_at BETWEEN ? AND ?)) ORDER BY ignored ASC, 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 ${ignoredFilter} 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: any) => ({ 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), ignored: r.ignored === 1, })), total, page, pages: Math.ceil(total / limit), }; } catch (err: any) { 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, };