398 lines
16 KiB
JavaScript
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,
|
|
};
|