Replace planned budget data with actual transaction totals for the previous year column in the budget table. Add getActualTotalsForYear helper to budgetService. Ref #34 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
431 lines
13 KiB
TypeScript
431 lines
13 KiB
TypeScript
import { getDb } from "./db";
|
|
import type {
|
|
Category,
|
|
BudgetEntry,
|
|
BudgetTemplate,
|
|
BudgetTemplateEntry,
|
|
BudgetVsActualRow,
|
|
} from "../shared/types";
|
|
|
|
function computeMonthDateRange(year: number, month: number) {
|
|
const dateFrom = `${year}-${String(month).padStart(2, "0")}-01`;
|
|
const lastDay = new Date(year, month, 0).getDate();
|
|
const dateTo = `${year}-${String(month).padStart(2, "0")}-${String(lastDay).padStart(2, "0")}`;
|
|
return { dateFrom, dateTo };
|
|
}
|
|
|
|
export async function getActiveCategories(): Promise<Category[]> {
|
|
const db = await getDb();
|
|
return db.select<Category[]>(
|
|
"SELECT * FROM categories WHERE is_active = 1 AND is_inputable = 1 ORDER BY sort_order, name"
|
|
);
|
|
}
|
|
|
|
export async function getAllActiveCategories(): Promise<Category[]> {
|
|
const db = await getDb();
|
|
return db.select<Category[]>(
|
|
"SELECT * FROM categories WHERE is_active = 1 ORDER BY sort_order, name"
|
|
);
|
|
}
|
|
|
|
export async function getBudgetEntriesForMonth(
|
|
year: number,
|
|
month: number
|
|
): Promise<BudgetEntry[]> {
|
|
const db = await getDb();
|
|
return db.select<BudgetEntry[]>(
|
|
"SELECT * FROM budget_entries WHERE year = $1 AND month = $2",
|
|
[year, month]
|
|
);
|
|
}
|
|
|
|
export async function upsertBudgetEntry(
|
|
categoryId: number,
|
|
year: number,
|
|
month: number,
|
|
amount: number,
|
|
notes?: string
|
|
): Promise<void> {
|
|
const db = await getDb();
|
|
await db.execute(
|
|
`INSERT INTO budget_entries (category_id, year, month, amount, notes)
|
|
VALUES ($1, $2, $3, $4, $5)
|
|
ON CONFLICT(category_id, year, month) DO UPDATE SET amount = $4, notes = $5, updated_at = CURRENT_TIMESTAMP`,
|
|
[categoryId, year, month, amount, notes || null]
|
|
);
|
|
}
|
|
|
|
export async function deleteBudgetEntry(
|
|
categoryId: number,
|
|
year: number,
|
|
month: number
|
|
): Promise<void> {
|
|
const db = await getDb();
|
|
await db.execute(
|
|
"DELETE FROM budget_entries WHERE category_id = $1 AND year = $2 AND month = $3",
|
|
[categoryId, year, month]
|
|
);
|
|
}
|
|
|
|
export async function getActualsByCategory(
|
|
year: number,
|
|
month: number
|
|
): Promise<Array<{ category_id: number | null; actual: number }>> {
|
|
const db = await getDb();
|
|
const { dateFrom, dateTo } = computeMonthDateRange(year, month);
|
|
return db.select<Array<{ category_id: number | null; actual: number }>>(
|
|
`SELECT category_id, COALESCE(SUM(amount), 0) AS actual
|
|
FROM transactions
|
|
WHERE date BETWEEN $1 AND $2
|
|
GROUP BY category_id`,
|
|
[dateFrom, dateTo]
|
|
);
|
|
}
|
|
|
|
export async function getBudgetEntriesForYear(
|
|
year: number
|
|
): Promise<BudgetEntry[]> {
|
|
const db = await getDb();
|
|
return db.select<BudgetEntry[]>(
|
|
"SELECT * FROM budget_entries WHERE year = $1",
|
|
[year]
|
|
);
|
|
}
|
|
|
|
export async function upsertBudgetEntriesForYear(
|
|
categoryId: number,
|
|
year: number,
|
|
amounts: number[]
|
|
): Promise<void> {
|
|
const db = await getDb();
|
|
for (let m = 0; m < 12; m++) {
|
|
const month = m + 1;
|
|
const amount = amounts[m] ?? 0;
|
|
if (amount === 0) {
|
|
await db.execute(
|
|
"DELETE FROM budget_entries WHERE category_id = $1 AND year = $2 AND month = $3",
|
|
[categoryId, year, month]
|
|
);
|
|
} else {
|
|
await db.execute(
|
|
`INSERT INTO budget_entries (category_id, year, month, amount)
|
|
VALUES ($1, $2, $3, $4)
|
|
ON CONFLICT(category_id, year, month) DO UPDATE SET amount = $4, updated_at = CURRENT_TIMESTAMP`,
|
|
[categoryId, year, month, amount]
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Templates
|
|
|
|
export async function getAllTemplates(): Promise<BudgetTemplate[]> {
|
|
const db = await getDb();
|
|
return db.select<BudgetTemplate[]>(
|
|
"SELECT * FROM budget_templates ORDER BY name"
|
|
);
|
|
}
|
|
|
|
export async function getTemplateEntries(
|
|
templateId: number
|
|
): Promise<BudgetTemplateEntry[]> {
|
|
const db = await getDb();
|
|
return db.select<BudgetTemplateEntry[]>(
|
|
"SELECT * FROM budget_template_entries WHERE template_id = $1",
|
|
[templateId]
|
|
);
|
|
}
|
|
|
|
export async function saveAsTemplate(
|
|
name: string,
|
|
description: string | undefined,
|
|
entries: Array<{ category_id: number; amount: number }>
|
|
): Promise<number> {
|
|
const db = await getDb();
|
|
const result = await db.execute(
|
|
"INSERT INTO budget_templates (name, description) VALUES ($1, $2)",
|
|
[name, description || null]
|
|
);
|
|
const templateId = result.lastInsertId as number;
|
|
|
|
for (const entry of entries) {
|
|
await db.execute(
|
|
"INSERT INTO budget_template_entries (template_id, category_id, amount) VALUES ($1, $2, $3)",
|
|
[templateId, entry.category_id, entry.amount]
|
|
);
|
|
}
|
|
|
|
return templateId;
|
|
}
|
|
|
|
export async function applyTemplate(
|
|
templateId: number,
|
|
year: number,
|
|
month: number
|
|
): Promise<void> {
|
|
const entries = await getTemplateEntries(templateId);
|
|
for (const entry of entries) {
|
|
await upsertBudgetEntry(entry.category_id, year, month, entry.amount);
|
|
}
|
|
}
|
|
|
|
export async function deleteTemplate(templateId: number): Promise<void> {
|
|
const db = await getDb();
|
|
await db.execute(
|
|
"DELETE FROM budget_template_entries WHERE template_id = $1",
|
|
[templateId]
|
|
);
|
|
await db.execute("DELETE FROM budget_templates WHERE id = $1", [templateId]);
|
|
}
|
|
|
|
// --- Actuals helpers ---
|
|
|
|
export async function getActualTotalsForYear(
|
|
year: number
|
|
): Promise<Array<{ category_id: number | null; actual: number }>> {
|
|
const dateFrom = `${year}-01-01`;
|
|
const dateTo = `${year}-12-31`;
|
|
return getActualsByCategoryRange(dateFrom, dateTo);
|
|
}
|
|
|
|
// --- Budget vs Actual ---
|
|
|
|
async function getActualsByCategoryRange(
|
|
dateFrom: string,
|
|
dateTo: string
|
|
): Promise<Array<{ category_id: number | null; actual: number }>> {
|
|
const db = await getDb();
|
|
return db.select<Array<{ category_id: number | null; actual: number }>>(
|
|
`SELECT category_id, COALESCE(SUM(amount), 0) AS actual
|
|
FROM transactions
|
|
WHERE date BETWEEN $1 AND $2
|
|
GROUP BY category_id`,
|
|
[dateFrom, dateTo]
|
|
);
|
|
}
|
|
|
|
const TYPE_ORDER: Record<string, number> = { expense: 0, income: 1, transfer: 2 };
|
|
|
|
export async function getBudgetVsActualData(
|
|
year: number,
|
|
month: number
|
|
): Promise<BudgetVsActualRow[]> {
|
|
// Date ranges
|
|
const { dateFrom: monthFrom, dateTo: monthTo } = computeMonthDateRange(year, month);
|
|
const ytdFrom = `${year}-01-01`;
|
|
const ytdTo = monthTo;
|
|
|
|
// Fetch all data in parallel
|
|
const [allCategories, yearEntries, monthActuals, ytdActuals] = await Promise.all([
|
|
getAllActiveCategories(),
|
|
getBudgetEntriesForYear(year),
|
|
getActualsByCategoryRange(monthFrom, monthTo),
|
|
getActualsByCategoryRange(ytdFrom, ytdTo),
|
|
]);
|
|
|
|
// Build maps
|
|
const entryMap = new Map<number, Map<number, number>>();
|
|
for (const e of yearEntries) {
|
|
if (!entryMap.has(e.category_id)) entryMap.set(e.category_id, new Map());
|
|
entryMap.get(e.category_id)!.set(e.month, e.amount);
|
|
}
|
|
|
|
const monthActualMap = new Map<number, number>();
|
|
for (const a of monthActuals) {
|
|
if (a.category_id != null) monthActualMap.set(a.category_id, a.actual);
|
|
}
|
|
|
|
const ytdActualMap = new Map<number, number>();
|
|
for (const a of ytdActuals) {
|
|
if (a.category_id != null) ytdActualMap.set(a.category_id, a.actual);
|
|
}
|
|
|
|
// Index categories
|
|
const childrenByParent = new Map<number, Category[]>();
|
|
for (const cat of allCategories) {
|
|
if (cat.parent_id) {
|
|
if (!childrenByParent.has(cat.parent_id)) childrenByParent.set(cat.parent_id, []);
|
|
childrenByParent.get(cat.parent_id)!.push(cat);
|
|
}
|
|
}
|
|
|
|
// Sign multiplier: budget stored positive, expenses displayed negative
|
|
const signFor = (type: string) => (type === "expense" ? -1 : 1);
|
|
|
|
// Compute leaf row values
|
|
function buildLeaf(cat: Category, parentId: number | null, depth: number): BudgetVsActualRow {
|
|
const sign = signFor(cat.type);
|
|
const monthMap = entryMap.get(cat.id);
|
|
const rawMonthBudget = monthMap?.get(month) ?? 0;
|
|
const monthBudget = rawMonthBudget * sign;
|
|
|
|
let rawYtdBudget = 0;
|
|
for (let m = 1; m <= month; m++) {
|
|
rawYtdBudget += monthMap?.get(m) ?? 0;
|
|
}
|
|
const ytdBudget = rawYtdBudget * sign;
|
|
|
|
const monthActual = monthActualMap.get(cat.id) ?? 0;
|
|
const ytdActual = ytdActualMap.get(cat.id) ?? 0;
|
|
|
|
const monthVariation = monthActual - monthBudget;
|
|
const ytdVariation = ytdActual - ytdBudget;
|
|
|
|
return {
|
|
category_id: cat.id,
|
|
category_name: cat.name,
|
|
category_color: cat.color || "#9ca3af",
|
|
category_type: cat.type,
|
|
parent_id: parentId,
|
|
is_parent: false,
|
|
depth,
|
|
monthActual,
|
|
monthBudget,
|
|
monthVariation,
|
|
monthVariationPct: monthBudget !== 0 ? monthVariation / Math.abs(monthBudget) : null,
|
|
ytdActual,
|
|
ytdBudget,
|
|
ytdVariation,
|
|
ytdVariationPct: ytdBudget !== 0 ? ytdVariation / Math.abs(ytdBudget) : null,
|
|
};
|
|
}
|
|
|
|
function buildSubtotal(cat: Category, childRows: BudgetVsActualRow[], parentId: number | null, depth: number): BudgetVsActualRow {
|
|
const row: BudgetVsActualRow = {
|
|
category_id: cat.id,
|
|
category_name: cat.name,
|
|
category_color: cat.color || "#9ca3af",
|
|
category_type: cat.type,
|
|
parent_id: parentId,
|
|
is_parent: true,
|
|
depth,
|
|
monthActual: 0,
|
|
monthBudget: 0,
|
|
monthVariation: 0,
|
|
monthVariationPct: null,
|
|
ytdActual: 0,
|
|
ytdBudget: 0,
|
|
ytdVariation: 0,
|
|
ytdVariationPct: null,
|
|
};
|
|
for (const cr of childRows) {
|
|
row.monthActual += cr.monthActual;
|
|
row.monthBudget += cr.monthBudget;
|
|
row.monthVariation += cr.monthVariation;
|
|
row.ytdActual += cr.ytdActual;
|
|
row.ytdBudget += cr.ytdBudget;
|
|
row.ytdVariation += cr.ytdVariation;
|
|
}
|
|
row.monthVariationPct =
|
|
row.monthBudget !== 0 ? row.monthVariation / Math.abs(row.monthBudget) : null;
|
|
row.ytdVariationPct =
|
|
row.ytdBudget !== 0 ? row.ytdVariation / Math.abs(row.ytdBudget) : null;
|
|
return row;
|
|
}
|
|
|
|
function isRowAllZero(r: BudgetVsActualRow): boolean {
|
|
return (
|
|
r.monthActual === 0 &&
|
|
r.monthBudget === 0 &&
|
|
r.ytdActual === 0 &&
|
|
r.ytdBudget === 0
|
|
);
|
|
}
|
|
|
|
// Build rows for a sub-group (recursive, supports arbitrary depth)
|
|
function buildSubGroup(cat: Category, groupParentId: number, depth: number): BudgetVsActualRow[] {
|
|
const subChildren = childrenByParent.get(cat.id) || [];
|
|
const hasSubChildren = subChildren.some(
|
|
(c) => c.is_inputable || (childrenByParent.get(c.id) || []).length > 0
|
|
);
|
|
|
|
if (!hasSubChildren && cat.is_inputable) {
|
|
const leaf = buildLeaf(cat, groupParentId, depth);
|
|
return isRowAllZero(leaf) ? [] : [leaf];
|
|
}
|
|
if (!hasSubChildren) return [];
|
|
|
|
const childRows: BudgetVsActualRow[] = [];
|
|
if (cat.is_inputable) {
|
|
const direct = buildLeaf(cat, cat.id, depth + 1);
|
|
direct.category_name = `${cat.name} (direct)`;
|
|
if (!isRowAllZero(direct)) childRows.push(direct);
|
|
}
|
|
const sortedSubChildren = [...subChildren].sort((a, b) => a.name.localeCompare(b.name));
|
|
for (const child of sortedSubChildren) {
|
|
const grandchildren = childrenByParent.get(child.id) || [];
|
|
if (grandchildren.length > 0) {
|
|
const subRows = buildSubGroup(child, cat.id, depth + 1);
|
|
childRows.push(...subRows);
|
|
} else if (child.is_inputable) {
|
|
const leaf = buildLeaf(child, cat.id, depth + 1);
|
|
if (!isRowAllZero(leaf)) childRows.push(leaf);
|
|
}
|
|
}
|
|
if (childRows.length === 0) return [];
|
|
|
|
const leafRows = childRows.filter((r) => !r.is_parent);
|
|
const subtotal = buildSubtotal(cat, leafRows, groupParentId, depth);
|
|
return [subtotal, ...childRows];
|
|
}
|
|
|
|
const rows: BudgetVsActualRow[] = [];
|
|
const topLevel = allCategories.filter((c) => !c.parent_id);
|
|
|
|
for (const cat of topLevel) {
|
|
const children = childrenByParent.get(cat.id) || [];
|
|
const hasChildren = children.some(
|
|
(c) => c.is_inputable || (childrenByParent.get(c.id) || []).length > 0
|
|
);
|
|
|
|
if (!hasChildren && cat.is_inputable) {
|
|
// Standalone leaf at level 0
|
|
const leaf = buildLeaf(cat, null, 0);
|
|
if (!isRowAllZero(leaf)) rows.push(leaf);
|
|
} else if (hasChildren) {
|
|
const allChildRows: BudgetVsActualRow[] = [];
|
|
|
|
// Direct transactions on the parent itself
|
|
if (cat.is_inputable) {
|
|
const direct = buildLeaf(cat, cat.id, 1);
|
|
direct.category_name = `${cat.name} (direct)`;
|
|
if (!isRowAllZero(direct)) allChildRows.push(direct);
|
|
}
|
|
|
|
// Process children in alphabetical order
|
|
const sortedChildren = [...children].sort((a, b) => a.name.localeCompare(b.name));
|
|
for (const child of sortedChildren) {
|
|
const grandchildren = childrenByParent.get(child.id) || [];
|
|
if (grandchildren.length > 0) {
|
|
const subRows = buildSubGroup(child, cat.id, 1);
|
|
allChildRows.push(...subRows);
|
|
} else if (child.is_inputable) {
|
|
const leaf = buildLeaf(child, cat.id, 1);
|
|
if (!isRowAllZero(leaf)) allChildRows.push(leaf);
|
|
}
|
|
}
|
|
|
|
if (allChildRows.length === 0) continue;
|
|
|
|
// Collect only leaf rows for parent subtotal (avoid double-counting)
|
|
const leafRows = allChildRows.filter((r) => !r.is_parent);
|
|
const parent = buildSubtotal(cat, leafRows, null, 0);
|
|
|
|
rows.push(parent);
|
|
rows.push(...allChildRows);
|
|
}
|
|
}
|
|
|
|
// Sort by type only, preserving tree order within groups (already built correctly)
|
|
const rowOrder = new Map<BudgetVsActualRow, number>();
|
|
rows.forEach((r, i) => rowOrder.set(r, i));
|
|
|
|
rows.sort((a, b) => {
|
|
const typeA = TYPE_ORDER[a.category_type] ?? 9;
|
|
const typeB = TYPE_ORDER[b.category_type] ?? 9;
|
|
if (typeA !== typeB) return typeA - typeB;
|
|
return rowOrder.get(a)! - rowOrder.get(b)!;
|
|
});
|
|
|
|
return rows;
|
|
}
|