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 { const db = await getDb(); return db.select( "SELECT * FROM categories WHERE is_active = 1 AND is_inputable = 1 ORDER BY sort_order, name" ); } export async function getAllActiveCategories(): Promise { const db = await getDb(); return db.select( "SELECT * FROM categories WHERE is_active = 1 ORDER BY sort_order, name" ); } export async function getBudgetEntriesForMonth( year: number, month: number ): Promise { const db = await getDb(); return db.select( "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 { 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 { 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> { const db = await getDb(); const { dateFrom, dateTo } = computeMonthDateRange(year, month); return db.select>( `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 { const db = await getDb(); return db.select( "SELECT * FROM budget_entries WHERE year = $1", [year] ); } export async function upsertBudgetEntriesForYear( categoryId: number, year: number, amounts: number[] ): Promise { 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 { const db = await getDb(); return db.select( "SELECT * FROM budget_templates ORDER BY name" ); } export async function getTemplateEntries( templateId: number ): Promise { const db = await getDb(); return db.select( "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 { 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 { 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 { 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> { 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> { const db = await getDb(); return db.select>( `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 = { expense: 0, income: 1, transfer: 2 }; export async function getBudgetVsActualData( year: number, month: number ): Promise { // 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>(); 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(); for (const a of monthActuals) { if (a.category_id != null) monthActualMap.set(a.category_id, a.actual); } const ytdActualMap = new Map(); for (const a of ytdActuals) { if (a.category_id != null) ytdActualMap.set(a.category_id, a.actual); } // Index categories const childrenByParent = new Map(); 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(); 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; }