BillTracker/services/spendingService.js

398 lines
16 KiB
JavaScript

'use strict';
const { normalizeMerchant } = require('./subscriptionService');
const { localDateString } = require('../utils/dates');
const { toCents, fromCents } = require('../utils/money');
// 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 = localDateString(new Date(year, month, 0)); // last day
return { start, end };
}
function shiftMonth(year, month, delta) {
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) {
return Math.abs(Number(raw)) / 100;
}
// ── Summary ──────────────────────────────────────────────────────────────────
function getSpendingSummary(db, userId, year, month) {
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 => [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 => [r.category_id, r.total_cents / 3]));
let totalCents = 0;
const byCategory = categoryRows.map(r => {
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, 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) {
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 => ({ ...r, amount: fromCents(r.amount) }));
}
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, toCents(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, includeIgnored = false } = {}) {
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 => ({
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) {
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,
};