BillTracker/services/spendingService.js

325 lines
13 KiB
JavaScript
Raw Normal View History

'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,
};