Simpl-Resultat/src/services/budgetService.ts
medic-bot 66d0cd85ff fix: address reviewer feedback (#23)
- Extract reorderRows into shared utility (src/utils/reorderRows.ts) to
  deduplicate identical function in BudgetTable and BudgetVsActualTable
- Restore alphabetical sorting of children in budgetService.ts
- Fix styling for intermediate parent rows at depth 2+ (was only handling
  depth 0-1)
- Reduce pie chart size (height 220->180, radii reduced) and padding to
  give more space to the table

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 21:02:24 -04:00

421 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]);
}
// --- 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;
}