278 lines
11 KiB
JavaScript
278 lines
11 KiB
JavaScript
'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);
|
|
// Apply this rule to all existing matching transactions
|
|
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);
|
|
applySpendingCategoryRules(db, userId, normalized);
|
|
}
|
|
|
|
function deleteSpendingCategoryRule(db, userId, ruleId) {
|
|
db.prepare('DELETE FROM spending_category_rules WHERE id=? AND user_id=?').run(ruleId, userId);
|
|
}
|
|
|
|
module.exports = {
|
|
getSpendingSummary,
|
|
getSpendingTransactions,
|
|
categorizeTransaction,
|
|
applySpendingCategoryRules,
|
|
getSpendingBudgets,
|
|
setSpendingBudget,
|
|
getSpendingCategoryRules,
|
|
addSpendingCategoryRule,
|
|
deleteSpendingCategoryRule,
|
|
};
|