import { getDb } from "./db"; import { getBudgetVsActualData } from "./budgetService"; import type { MonthlyTrendItem, CategoryBreakdownItem, CategoryOverTimeData, CategoryOverTimeItem, HighlightsData, HighlightMover, CategoryDelta, CategoryZoomData, CategoryZoomChild, CategoryZoomEvolutionPoint, MonthBalance, RecentTransaction, CartesSnapshot, CartesKpi, CartesSparklinePoint, CartesTopMover, CartesMonthFlow, CartesBudgetAdherence, CartesBudgetWorstOverrun, CartesSeasonality, CartesSeasonalityYear, CartesKpiPeriodMode, } from "../shared/types"; export async function getMonthlyTrends( dateFrom?: string, dateTo?: string, sourceId?: number, ): Promise { const db = await getDb(); const whereClauses: string[] = []; const params: unknown[] = []; let paramIndex = 1; if (dateFrom) { whereClauses.push(`date >= $${paramIndex}`); params.push(dateFrom); paramIndex++; } if (dateTo) { whereClauses.push(`date <= $${paramIndex}`); params.push(dateTo); paramIndex++; } if (sourceId != null) { whereClauses.push(`source_id = $${paramIndex}`); params.push(sourceId); paramIndex++; } const whereSQL = whereClauses.length > 0 ? `WHERE ${whereClauses.join(" AND ")}` : ""; return db.select( `SELECT strftime('%Y-%m', date) AS month, COALESCE(SUM(CASE WHEN amount > 0 THEN amount ELSE 0 END), 0) AS income, ABS(COALESCE(SUM(CASE WHEN amount < 0 THEN amount ELSE 0 END), 0)) AS expenses FROM transactions ${whereSQL} GROUP BY month ORDER BY month ASC`, params ); } export async function getCategoryOverTime( dateFrom?: string, dateTo?: string, topN: number = 50, sourceId?: number, typeFilter?: "expense" | "income" | "transfer", ): Promise { const db = await getDb(); const whereClauses: string[] = []; const params: unknown[] = []; let paramIndex = 1; if (typeFilter) { whereClauses.push(`COALESCE(c.type, 'expense') = $${paramIndex}`); params.push(typeFilter); paramIndex++; } if (dateFrom) { whereClauses.push(`t.date >= $${paramIndex}`); params.push(dateFrom); paramIndex++; } if (dateTo) { whereClauses.push(`t.date <= $${paramIndex}`); params.push(dateTo); paramIndex++; } if (sourceId != null) { whereClauses.push(`t.source_id = $${paramIndex}`); params.push(sourceId); paramIndex++; } const whereSQL = whereClauses.length > 0 ? `WHERE ${whereClauses.join(" AND ")}` : ""; // Get top N categories by total spend const topCategories = await db.select( `SELECT t.category_id, COALESCE(c.name, 'Uncategorized') AS category_name, COALESCE(c.color, '#9ca3af') AS category_color, ABS(SUM(t.amount)) AS total FROM transactions t LEFT JOIN categories c ON t.category_id = c.id ${whereSQL} GROUP BY t.category_id ORDER BY total DESC LIMIT $${paramIndex}`, [...params, topN] ); const topCategoryIds = new Set(topCategories.map((c) => c.category_id)); const colors: Record = {}; const categoryIds: Record = {}; for (const cat of topCategories) { colors[cat.category_name] = cat.category_color; categoryIds[cat.category_name] = cat.category_id; } // Get monthly breakdown for all categories const monthlyRows = await db.select< Array<{ month: string; category_id: number | null; category_name: string; total: number; }> >( `SELECT strftime('%Y-%m', t.date) AS month, t.category_id, COALESCE(c.name, 'Uncategorized') AS category_name, ABS(SUM(t.amount)) AS total FROM transactions t LEFT JOIN categories c ON t.category_id = c.id ${whereSQL} GROUP BY month, t.category_id ORDER BY month ASC`, params ); // Build pivot data const monthMap = new Map(); let hasOther = false; for (const row of monthlyRows) { if (!monthMap.has(row.month)) { monthMap.set(row.month, { month: row.month }); } const item = monthMap.get(row.month)!; if (topCategoryIds.has(row.category_id)) { item[row.category_name] = ((item[row.category_name] as number) || 0) + row.total; } else { item["Other"] = ((item["Other"] as number) || 0) + row.total; hasOther = true; } } if (hasOther) { colors["Other"] = "#9ca3af"; } const categories = topCategories.map((c) => c.category_name); if (hasOther) { categories.push("Other"); } return { categories, data: Array.from(monthMap.values()), colors, categoryIds, }; } // --- Highlights (Issue #71) --- /** * Shifts a YYYY-MM-DD date string by `months` months and returns the first day * of the resulting month as YYYY-MM-01. Used to compute the start of the * 12-month sparkline window relative to the reference date. */ function shiftMonthStart(refIso: string, months: number): string { const [y, m] = refIso.split("-").map(Number); const d = new Date(Date.UTC(y, m - 1 + months, 1)); const yy = d.getUTCFullYear(); const mm = String(d.getUTCMonth() + 1).padStart(2, "0"); return `${yy}-${mm}-01`; } function shiftDate(refIso: string, days: number): string { const [y, m, d] = refIso.split("-").map(Number); const dt = new Date(Date.UTC(y, m - 1, d + days)); const yy = dt.getUTCFullYear(); const mm = String(dt.getUTCMonth() + 1).padStart(2, "0"); const dd = String(dt.getUTCDate()).padStart(2, "0"); return `${yy}-${mm}-${dd}`; } function todayIso(today: Date): string { const y = today.getFullYear(); const m = String(today.getMonth() + 1).padStart(2, "0"); const d = String(today.getDate()).padStart(2, "0"); return `${y}-${m}-${d}`; } /** * Returns the dashboard "highlights" snapshot for the reports hub: * - monthly tile — net balance of the REFERENCE month * - YTD tile — net balance from Jan 1st of `ytdYear` through today * - 12-month series — last 12 months ending at the reference month * - top movers — reference month vs the month immediately before it * - top transactions — biggest absolute amounts in the last `windowDays` * days ending today * * `today` is an optional injection point so unit tests can pin both the YTD * window and the "recent transactions" window without depending on wall-clock * time. All SQL is parameterised. */ export async function getHighlights( referenceYear: number, referenceMonth: number, ytdYear: number, windowDays: number = 30, topMoversLimit: number = 5, topTransactionsLimit: number = 10, today: Date = new Date(), ): Promise { const db = await getDb(); const currentMonth = `${referenceYear}-${String(referenceMonth).padStart(2, "0")}`; // YYYY-MM const { start: currentMonthStart, end: currentMonthEnd } = monthBoundaries( referenceYear, referenceMonth, ); const prev = previousMonth(referenceYear, referenceMonth); const { start: previousMonthStart, end: previousMonthEnd } = monthBoundaries( prev.year, prev.month, ); // Sparkline anchor = reference month; shift 11 back for a 12-month series. const sparklineStart = shiftMonthStart(`${currentMonthStart}`, -11); // YTD window: Jan 1 of `ytdYear` → today. The reference month does not // move this window — the YTD tile is pinned to the current civil year. const ytdStart = `${ytdYear}-01-01`; const todayStr = todayIso(today); // Guard: if the user picks a reference month in the future (unlikely) or if // `today` is clamped below Jan 1 (fixtures), cap the YTD end at `ytdStart` // so SQL never inverts its bounds. const ytdEnd = todayStr >= ytdStart ? todayStr : ytdStart; // Recent-transactions window ends today (not at the reference month). const recentWindowStart = shiftDate(todayStr, -(windowDays - 1)); // 1. Net balance for the reference month const currentBalanceRows = await db.select>( `SELECT COALESCE(SUM(amount), 0) AS net FROM transactions WHERE date >= $1 AND date <= $2`, [currentMonthStart, currentMonthEnd], ); const netBalanceCurrent = Number(currentBalanceRows[0]?.net ?? 0); // 2. YTD balance — independent of the reference month. const ytdRows = await db.select>( `SELECT COALESCE(SUM(amount), 0) AS net FROM transactions WHERE date >= $1 AND date <= $2`, [ytdStart, ytdEnd], ); const netBalanceYtd = Number(ytdRows[0]?.net ?? 0); // 3. 12-month sparkline series, ending at the reference month. const seriesRows = await db.select>( `SELECT strftime('%Y-%m', date) AS month, COALESCE(SUM(amount), 0) AS net FROM transactions WHERE date >= $1 AND date <= $2 GROUP BY month ORDER BY month ASC`, [sparklineStart, currentMonthEnd], ); const seriesMap = new Map(seriesRows.map((r) => [r.month, Number(r.net ?? 0)])); const monthlyBalanceSeries: MonthBalance[] = []; for (let i = 11; i >= 0; i--) { const monthKey = shiftMonthStart(currentMonthStart, -i).slice(0, 7); monthlyBalanceSeries.push({ month: monthKey, netBalance: seriesMap.get(monthKey) ?? 0 }); } // 4. Top movers — expense-side only (amount < 0), compare reference month // vs the immediately-previous month. const moversRows = await db.select< Array<{ category_id: number | null; category_name: string; category_color: string; current_total: number | null; previous_total: number | null; }> >( `SELECT t.category_id, COALESCE(c.name, 'Uncategorized') AS category_name, COALESCE(c.color, '#9ca3af') AS category_color, COALESCE(SUM(CASE WHEN t.date >= $1 AND t.date <= $2 THEN ABS(t.amount) ELSE 0 END), 0) AS current_total, COALESCE(SUM(CASE WHEN t.date >= $3 AND t.date <= $4 THEN ABS(t.amount) ELSE 0 END), 0) AS previous_total FROM transactions t LEFT JOIN categories c ON t.category_id = c.id WHERE t.amount < 0 AND ( (t.date >= $1 AND t.date <= $2) OR (t.date >= $3 AND t.date <= $4) ) GROUP BY t.category_id, category_name, category_color ORDER BY ABS(current_total - previous_total) DESC LIMIT $5`, [currentMonthStart, currentMonthEnd, previousMonthStart, previousMonthEnd, topMoversLimit], ); const topMovers: HighlightMover[] = moversRows.map((r) => { const current = Number(r.current_total ?? 0); const previous = Number(r.previous_total ?? 0); const deltaAbs = current - previous; const deltaPct = previous === 0 ? null : (deltaAbs / previous) * 100; // Highlights only exposes a monthly view, so the cumulative block mirrors // the monthly values — keeps the CategoryDelta shape uniform for // downstream consumers that never read the cumulative block. return { categoryId: r.category_id, categoryName: r.category_name, categoryColor: r.category_color, previousAmount: previous, currentAmount: current, deltaAbs, deltaPct, cumulativePreviousAmount: previous, cumulativeCurrentAmount: current, cumulativeDeltaAbs: deltaAbs, cumulativeDeltaPct: deltaPct, }; }); // 5. Top transactions within the recent window ending today. const recentRows = await db.select( `SELECT t.id, t.date, t.description, t.amount, c.name AS category_name, c.color AS category_color FROM transactions t LEFT JOIN categories c ON t.category_id = c.id WHERE t.date >= $1 AND t.date <= $2 ORDER BY ABS(t.amount) DESC LIMIT $3`, [recentWindowStart, todayStr, topTransactionsLimit], ); return { currentMonth, netBalanceCurrent, netBalanceYtd, monthlyBalanceSeries, topMovers, topTransactions: recentRows, }; } // --- Compare (Issue #73) --- interface RawDeltaRow { category_id: number | null; category_name: string; category_color: string; month_current_total: number | null; month_previous_total: number | null; cumulative_current_total: number | null; cumulative_previous_total: number | null; } function rowsToDeltas(rows: RawDeltaRow[]): CategoryDelta[] { return rows.map((r) => { const monthCurrent = Number(r.month_current_total ?? 0); const monthPrevious = Number(r.month_previous_total ?? 0); const monthDeltaAbs = monthCurrent - monthPrevious; const monthDeltaPct = monthPrevious === 0 ? null : (monthDeltaAbs / monthPrevious) * 100; const cumCurrent = Number(r.cumulative_current_total ?? 0); const cumPrevious = Number(r.cumulative_previous_total ?? 0); const cumDeltaAbs = cumCurrent - cumPrevious; const cumDeltaPct = cumPrevious === 0 ? null : (cumDeltaAbs / cumPrevious) * 100; return { categoryId: r.category_id, categoryName: r.category_name, categoryColor: r.category_color, // Monthly block (primary — also the legacy field set). previousAmount: monthPrevious, currentAmount: monthCurrent, deltaAbs: monthDeltaAbs, deltaPct: monthDeltaPct, // Cumulative YTD block. cumulativePreviousAmount: cumPrevious, cumulativeCurrentAmount: cumCurrent, cumulativeDeltaAbs: cumDeltaAbs, cumulativeDeltaPct: cumDeltaPct, }; }); } function monthBoundaries(year: number, month: number): { start: string; end: string } { const mm = String(month).padStart(2, "0"); const endDate = new Date(Date.UTC(year, month, 0)); const dd = String(endDate.getUTCDate()).padStart(2, "0"); return { start: `${year}-${mm}-01`, end: `${year}-${mm}-${dd}` }; } // --- Compare hierarchy (Issue #247) --- /** * Minimal category metadata for building the compare tree. Fetched WITHOUT an * `is_active` filter so soft-deleted categories (is_active = 0) that still carry * historic transactions keep their place in the hierarchy — matching the raw * LEFT JOIN behavior of COMPARE_DELTA_SQL (no regression on the leaves shown). */ interface CompareCatMeta { id: number; name: string; color: string | null; type: "expense" | "income" | "transfer" | null; parent_id: number | null; } const COMPARE_CATEGORIES_SQL = `SELECT id, name, color, type, parent_id FROM categories`; const COMPARE_TYPE_ORDER: Record = { expense: 0, income: 1, transfer: 2 }; /** Depth cap mirroring CATEGORY_TREE_CTE — guards a cyclic parent_id chain. */ const MAX_TREE_DEPTH = 5; /** * Turns the flat per-category deltas returned by COMPARE_DELTA_SQL into a * parent/child tree with subtotal (`is_parent`) rows, mirroring the hierarchy of * getBudgetVsActualData. * * The #243 transfer netting is untouched: leaf values are exactly what the SQL * returned, and a subtotal is the arithmetic sum of its descendant leaves — so a * group of balanced (netted-to-zero) transfers subtotals to ~0, i.e. the group's * net. Driven by `leaves` (the categories that actually had transactions in the * window) rather than the full category list, so netted-to-zero transfers and * soft-deleted categories with history are preserved and no empty rows appear. * * Exported for unit testing. */ export function buildCompareTree( leaves: CategoryDelta[], categories: CompareCatMeta[], ): CategoryDelta[] { const catById = new Map(); for (const c of categories) catById.set(c.id, c); // Flat delta lookup by category id. Rows whose category id is null // (Uncategorized) or absent from the table (hard-deleted) become orphans: // depth-0 leaves preserved in their incoming order. const deltaByCat = new Map(); const orphans: CategoryDelta[] = []; for (const d of leaves) { if (d.categoryId != null && catById.has(d.categoryId)) { deltaByCat.set(d.categoryId, d); } else { orphans.push(d); } } // "Relevant" = every category that has a delta plus all of its ancestors. const relevant = new Set(); for (const id of deltaByCat.keys()) { let cur: number | null | undefined = id; let guard = 0; while (cur != null && guard <= MAX_TREE_DEPTH) { if (relevant.has(cur)) break; relevant.add(cur); cur = catById.get(cur)?.parent_id ?? null; guard++; } } // Adjacency among relevant categories only, preserving DB order. const childrenByParent = new Map(); for (const c of categories) { if (c.parent_id != null && relevant.has(c.id) && relevant.has(c.parent_id)) { let arr = childrenByParent.get(c.parent_id); if (!arr) childrenByParent.set(c.parent_id, (arr = [])); arr.push(c); } } const typeOf = (c: CompareCatMeta): "expense" | "income" | "transfer" => c.type ?? "expense"; const leafRow = ( cat: CompareCatMeta, parentId: number | null, depth: number, ): CategoryDelta => ({ ...deltaByCat.get(cat.id)!, parent_id: parentId, is_parent: false, depth, category_type: typeOf(cat), }); const subtotalRow = ( cat: CompareCatMeta, descendantLeaves: CategoryDelta[], parentId: number | null, depth: number, ): CategoryDelta => { let previousAmount = 0; let currentAmount = 0; let cumulativePreviousAmount = 0; let cumulativeCurrentAmount = 0; for (const l of descendantLeaves) { previousAmount += l.previousAmount; currentAmount += l.currentAmount; cumulativePreviousAmount += l.cumulativePreviousAmount; cumulativeCurrentAmount += l.cumulativeCurrentAmount; } const deltaAbs = currentAmount - previousAmount; const cumulativeDeltaAbs = cumulativeCurrentAmount - cumulativePreviousAmount; return { categoryId: cat.id, categoryName: cat.name, categoryColor: cat.color ?? "#9ca3af", previousAmount, currentAmount, deltaAbs, // Match rowsToDeltas' leaf formula (signed denominator) so a single-child // parent shows the same % as its child. deltaPct: previousAmount !== 0 ? (deltaAbs / previousAmount) * 100 : null, cumulativePreviousAmount, cumulativeCurrentAmount, cumulativeDeltaAbs, cumulativeDeltaPct: cumulativePreviousAmount !== 0 ? (cumulativeDeltaAbs / cumulativePreviousAmount) * 100 : null, parent_id: parentId, is_parent: true, depth, category_type: typeOf(cat), }; }; interface Block { rows: CategoryDelta[]; sortKey: number; // |monthly delta| of the block head — orders siblings } // Builds a node's block: a pure leaf, or a subtotal followed by its // (recursively built) child blocks, siblings ordered by |monthly delta| desc. const buildNode = (cat: CompareCatMeta, depth: number): Block | null => { // Stop descending past the depth cap so a corrupted parent_id cycle can // never recurse forever — deeper nodes collapse to leaves (real category // trees are ≤ 3 levels). const children = depth >= MAX_TREE_DEPTH ? [] : (childrenByParent.get(cat.id) ?? []); const hasDirect = deltaByCat.has(cat.id); if (children.length === 0) { if (!hasDirect) return null; const leaf = leafRow(cat, cat.parent_id ?? null, depth); return { rows: [leaf], sortKey: Math.abs(leaf.deltaAbs) }; } const childBlocks: Block[] = []; // A category with both children AND its own transactions surfaces the direct // spend as a "(direct)" leaf so the subtotal stays the sum of its visible // rows (mirrors getBudgetVsActualData). Rare: a parent auto-loses // is_inputable when a child is added. if (hasDirect) { const direct = leafRow(cat, cat.id, depth + 1); childBlocks.push({ rows: [{ ...direct, categoryName: `${cat.name} (direct)` }], sortKey: Math.abs(direct.deltaAbs), }); } for (const child of children) { const b = buildNode(child, depth + 1); if (b) childBlocks.push(b); } childBlocks.sort((a, b) => b.sortKey - a.sortKey); const childRows = childBlocks.flatMap((b) => b.rows); const descendantLeaves = childRows.filter((r) => !r.is_parent); const subtotal = subtotalRow(cat, descendantLeaves, cat.parent_id ?? null, depth); return { rows: [subtotal, ...childRows], sortKey: Math.abs(subtotal.deltaAbs) }; }; // Roots = relevant categories with no relevant parent. Ordered by magnitude. const rootBlocks: Block[] = []; for (const c of categories) { if (!relevant.has(c.id)) continue; if (c.parent_id != null && relevant.has(c.parent_id)) continue; const b = buildNode(c, 0); if (b) rootBlocks.push(b); } rootBlocks.sort((a, b) => b.sortKey - a.sortKey); const rows = rootBlocks.flatMap((b) => b.rows); // Orphans (Uncategorized + hard-deleted) appended in their incoming order. for (const d of orphans) { rows.push({ ...d, parent_id: null, is_parent: false, depth: 0, category_type: d.category_type ?? "expense" }); } // Stable sort by type so sections (expense → income → transfer) are // contiguous; magnitude/tree order within a type is preserved via the index. const order = new Map(); rows.forEach((r, i) => order.set(r, i)); rows.sort((a, b) => { const ta = COMPARE_TYPE_ORDER[a.category_type ?? "expense"] ?? 9; const tb = COMPARE_TYPE_ORDER[b.category_type ?? "expense"] ?? 9; if (ta !== tb) return ta - tb; return order.get(a)! - order.get(b)!; }); return rows; } function previousMonth(year: number, month: number): { year: number; month: number } { if (month === 1) return { year: year - 1, month: 12 }; return { year, month: month - 1 }; } /** * Shared aggregation for the real-vs-real compare (both MoM and YoY drive the * exact same query — only the eight date bounds differ). Four date buckets * ($1..$8): monthly current/previous and cumulative current/previous. * * Per-category sign convention (Issue #243): * - `transfer` categories NET via a signed SUM(t.amount), so a balanced * debit/credit pair (e.g. "Paiement CC": -500 out, +500 in) cancels to ~0. * A transfer is a money move, not spending, and must not inflate the * expense figures. * - Every other type keeps the expense-report behavior: only outflows * (amount < 0) count, summed as positive magnitudes via ABS. * * The WHERE is broadened with `OR COALESCE(c.type, 'expense') = 'transfer'` so * transfer *credits* survive the `amount < 0` expense filter and can actually * cancel their debits — without it the credit leg would be dropped and the * category could never net to zero. For non-transfer rows the WHERE still * admits only outflows, so their totals are byte-identical to the previous * behavior (no revenues surfaced, no zero-rows for pure-income categories). * Uncategorized rows (LEFT JOIN → c.type NULL) default to 'expense'. */ const COMPARE_DELTA_SQL = `SELECT t.category_id, COALESCE(c.name, 'Uncategorized') AS category_name, COALESCE(c.color, '#9ca3af') AS category_color, COALESCE(SUM(CASE WHEN t.date >= $1 AND t.date <= $2 THEN CASE WHEN COALESCE(c.type, 'expense') = 'transfer' THEN t.amount ELSE ABS(t.amount) END ELSE 0 END), 0) AS month_current_total, COALESCE(SUM(CASE WHEN t.date >= $3 AND t.date <= $4 THEN CASE WHEN COALESCE(c.type, 'expense') = 'transfer' THEN t.amount ELSE ABS(t.amount) END ELSE 0 END), 0) AS month_previous_total, COALESCE(SUM(CASE WHEN t.date >= $5 AND t.date <= $6 THEN CASE WHEN COALESCE(c.type, 'expense') = 'transfer' THEN t.amount ELSE ABS(t.amount) END ELSE 0 END), 0) AS cumulative_current_total, COALESCE(SUM(CASE WHEN t.date >= $7 AND t.date <= $8 THEN CASE WHEN COALESCE(c.type, 'expense') = 'transfer' THEN t.amount ELSE ABS(t.amount) END ELSE 0 END), 0) AS cumulative_previous_total FROM transactions t LEFT JOIN categories c ON t.category_id = c.id WHERE (t.amount < 0 OR COALESCE(c.type, 'expense') = 'transfer') AND ( (t.date >= $1 AND t.date <= $2) OR (t.date >= $3 AND t.date <= $4) OR (t.date >= $5 AND t.date <= $6) OR (t.date >= $7 AND t.date <= $8) ) GROUP BY t.category_id, category_name, category_color ORDER BY ABS(month_current_total - month_previous_total) DESC`; /** * Month-over-month expense delta by category. Returns both a monthly view * (reference month vs immediately-previous month) and a cumulative YTD view * (Jan→refMonth of refYear vs Jan→prevMonth of refYear — i.e. "cumulative * progress through end of last month" vs "cumulative progress through end of * this month"). All SQL parameterised. */ export async function getCompareMonthOverMonth( year: number, month: number, ): Promise { const db = await getDb(); const { start: curStart, end: curEnd } = monthBoundaries(year, month); const prev = previousMonth(year, month); const { start: prevStart, end: prevEnd } = monthBoundaries(prev.year, prev.month); // Cumulative window: from Jan 1st of the reference year up to the end of // the month we care about. For MoM, the "previous" cumulative window ends // at the end of the previous month within the SAME year — so Jan only has // no cumulative-previous (empty window). We still bound the previous-year // handling: when month === 1 the previous month is in year - 1, so the // cumulative-previous window sits entirely in year - 1 (Jan → Dec). const cumCurrentStart = `${year}-01-01`; const cumCurrentEnd = curEnd; const cumPreviousStart = `${prev.year}-01-01`; const cumPreviousEnd = prevEnd; // Delta select stays first so its params/SQL remain `mock.calls[0]`; the // category metadata (for the hierarchy) is fetched alongside. `?? []` guards // under-specified mocks — db.select never returns undefined in production. const [rows, cats] = await Promise.all([ db.select(COMPARE_DELTA_SQL, [ curStart, curEnd, prevStart, prevEnd, cumCurrentStart, cumCurrentEnd, cumPreviousStart, cumPreviousEnd, ]), db.select(COMPARE_CATEGORIES_SQL), ]); return buildCompareTree(rowsToDeltas(rows), cats ?? []); } /** * Year-over-year expense delta by category. Returns both a monthly view * (reference month vs same month of the previous year) and a cumulative YTD * view (Jan→refMonth of refYear vs Jan→refMonth of refYear - 1). Uses the * reference year's December as the "current month" when no explicit * reference month is provided; callers typically pass the user's chosen * reference month. All SQL parameterised. */ export async function getCompareYearOverYear( year: number, month: number = 12, ): Promise { const db = await getDb(); const { start: curMonthStart, end: curMonthEnd } = monthBoundaries(year, month); const { start: prevMonthStart, end: prevMonthEnd } = monthBoundaries(year - 1, month); const cumCurrentStart = `${year}-01-01`; const cumCurrentEnd = curMonthEnd; const cumPreviousStart = `${year - 1}-01-01`; const cumPreviousEnd = prevMonthEnd; // See getCompareMonthOverMonth: delta select first, categories alongside. const [rows, cats] = await Promise.all([ db.select(COMPARE_DELTA_SQL, [ curMonthStart, curMonthEnd, prevMonthStart, prevMonthEnd, cumCurrentStart, cumCurrentEnd, cumPreviousStart, cumPreviousEnd, ]), db.select(COMPARE_CATEGORIES_SQL), ]); return buildCompareTree(rowsToDeltas(rows), cats ?? []); } // --- Category zoom (Issue #74) --- /** * Recursive CTE fragment bounded to `depth < 5` so a corrupted `parent_id` * loop (A → B → A) can never spin forever. The depth budget is intentionally * low: real category trees rarely exceed 3 levels. */ const CATEGORY_TREE_CTE = ` WITH RECURSIVE cat_tree(id, depth) AS ( SELECT id, 0 FROM categories WHERE id = $1 UNION ALL SELECT c.id, ct.depth + 1 FROM categories c JOIN cat_tree ct ON c.parent_id = ct.id WHERE ct.depth < 5 ) `; /** * Returns the zoom-on-category report for the given category id. * * When `includeSubcategories` is true the recursive CTE walks down the * category tree (capped at 5 levels) and aggregates matching rows. When * false, only direct transactions on `categoryId` are considered. Every * query is parameterised; the category id is never interpolated. */ export async function getCategoryZoom( categoryId: number, dateFrom: string, dateTo: string, includeSubcategories: boolean = true, ): Promise { const db = await getDb(); const categoryFilter = includeSubcategories ? `${CATEGORY_TREE_CTE} SELECT t.id, t.date, t.description, t.amount, t.category_id FROM transactions t WHERE t.category_id IN (SELECT id FROM cat_tree) AND t.date >= $2 AND t.date <= $3` : `SELECT t.id, t.date, t.description, t.amount, t.category_id FROM transactions t WHERE t.category_id = $1 AND t.date >= $2 AND t.date <= $3`; // 1. Transactions (with join for display) const txRows = await db.select( `${includeSubcategories ? CATEGORY_TREE_CTE : ""} SELECT t.id, t.date, t.description, t.amount, c.name AS category_name, c.color AS category_color FROM transactions t LEFT JOIN categories c ON t.category_id = c.id WHERE ${includeSubcategories ? "t.category_id IN (SELECT id FROM cat_tree)" : "t.category_id = $1"} AND t.date >= $2 AND t.date <= $3 ORDER BY ABS(t.amount) DESC LIMIT 500`, [categoryId, dateFrom, dateTo], ); // 2. Rollup total (sum of absolute amounts of matching rows) const totalRows = await db.select>( `${categoryFilter.replace("SELECT t.id, t.date, t.description, t.amount, t.category_id", "SELECT COALESCE(SUM(ABS(t.amount)), 0) AS rollup")}`, [categoryId, dateFrom, dateTo], ); const rollupTotal = Number(totalRows[0]?.rollup ?? 0); // 3. Breakdown by direct child (only meaningful when includeSubcategories is true) const byChild: CategoryZoomChild[] = []; if (includeSubcategories) { const childRows = await db.select< Array<{ child_id: number; child_name: string; child_color: string; total: number | null }> >( `SELECT child.id AS child_id, child.name AS child_name, COALESCE(child.color, '#9ca3af') AS child_color, COALESCE(SUM(ABS(t.amount)), 0) AS total FROM categories child LEFT JOIN transactions t ON t.category_id = child.id AND t.date >= $2 AND t.date <= $3 WHERE child.parent_id = $1 GROUP BY child.id, child.name, child.color ORDER BY total DESC`, [categoryId, dateFrom, dateTo], ); for (const r of childRows) { byChild.push({ categoryId: r.child_id, categoryName: r.child_name, categoryColor: r.child_color, total: Number(r.total ?? 0), }); } } // 4. Monthly evolution across the window const evolutionRows = await db.select>( `${includeSubcategories ? CATEGORY_TREE_CTE : ""} SELECT strftime('%Y-%m', t.date) AS month, COALESCE(SUM(ABS(t.amount)), 0) AS total FROM transactions t WHERE ${includeSubcategories ? "t.category_id IN (SELECT id FROM cat_tree)" : "t.category_id = $1"} AND t.date >= $2 AND t.date <= $3 GROUP BY month ORDER BY month ASC`, [categoryId, dateFrom, dateTo], ); const monthlyEvolution: CategoryZoomEvolutionPoint[] = evolutionRows.map((r) => ({ month: r.month, total: Number(r.total ?? 0), })); return { rollupTotal, byChild, monthlyEvolution, transactions: txRows, }; } // --- Cartes dashboard (Issue #97) --- /** * Signed month shift. Exported for unit tests. * shiftMonth(2026, 1, -1) -> { year: 2025, month: 12 } * shiftMonth(2026, 4, -24) -> { year: 2024, month: 4 } */ export function shiftMonth( year: number, month: number, offset: number, ): { year: number; month: number } { const total = year * 12 + (month - 1) + offset; return { year: Math.floor(total / 12), month: (total % 12) + 1, }; } function monthKey(year: number, month: number): string { return `${year}-${String(month).padStart(2, "0")}`; } function extractDelta( current: number | null, previous: number | null, ): { abs: number | null; pct: number | null } { if (current === null || previous === null) return { abs: null, pct: null }; const abs = current - previous; const pct = previous === 0 ? null : (abs / previous) * 100; return { abs, pct }; } function buildKpi( sparkline: CartesSparklinePoint[], current: number | null, previousMonth: number | null, previousYear: number | null, ): CartesKpi { const mom = extractDelta(current, previousMonth); const yoy = extractDelta(current, previousYear); return { current, previousMonth, previousYear, deltaMoMAbs: mom.abs, deltaMoMPct: mom.pct, deltaYoYAbs: yoy.abs, deltaYoYPct: yoy.pct, sparkline, }; } interface RawMonthFlow { month: string; income: number | null; expenses: number | null; } async function fetchMonthlyFlows( dateFrom: string, dateTo: string, ): Promise { const db = await getDb(); return db.select( `SELECT strftime('%Y-%m', date) AS month, COALESCE(SUM(CASE WHEN amount > 0 THEN amount ELSE 0 END), 0) AS income, ABS(COALESCE(SUM(CASE WHEN amount < 0 THEN amount ELSE 0 END), 0)) AS expenses FROM transactions WHERE date >= $1 AND date <= $2 GROUP BY month ORDER BY month ASC`, [dateFrom, dateTo], ); } interface RawSeasonalityRow { year: number; amount: number | null; } async function fetchSeasonality( month: number, yearFrom: number, yearTo: number, ): Promise { const db = await getDb(); const mm = String(month).padStart(2, "0"); return db.select( `SELECT CAST(strftime('%Y', date) AS INTEGER) AS year, ABS(COALESCE(SUM(CASE WHEN amount < 0 THEN amount ELSE 0 END), 0)) AS amount FROM transactions WHERE strftime('%m', date) = $1 AND CAST(strftime('%Y', date) AS INTEGER) >= $2 AND CAST(strftime('%Y', date) AS INTEGER) <= $3 GROUP BY year ORDER BY year DESC`, [mm, yearFrom, yearTo], ); } /** * Cartes dashboard snapshot. Single entry point that returns every widget's * data for the Cartes report, computed against a reference (year, month). * * Layout (all concurrent): * 1. 25-month expense/income series (covers ref, MoM, YoY, 12-month flow, * 13-month sparklines without any extra round trips). * 2. Month-over-month category deltas for top movers (existing service). * 3. Year-over-year category deltas to seed the savings-rate YoY lookup * via the monthly series instead of re-querying. * 4. Budget vs actual for the reference month. * 5. Seasonality: same calendar month across the two prior years. */ export async function getCartesSnapshot( referenceYear: number, referenceMonth: number, mode: CartesKpiPeriodMode = "month", ): Promise { // Date window: 25 months back from the reference to cover YoY + a 13-month // sparkline. Start = 24 months before ref = (ref - 24 months) = month offset -24. const windowStart = shiftMonth(referenceYear, referenceMonth, -24); const { start: windowStartIso } = monthBoundaries(windowStart.year, windowStart.month); const { end: refEnd } = monthBoundaries(referenceYear, referenceMonth); // Seasonality range: previous 2 years for the same calendar month. const [seasonalityRows, flowRows, momRows, budgetRows] = await Promise.all([ fetchSeasonality(referenceMonth, referenceYear - 2, referenceYear - 1), fetchMonthlyFlows(windowStartIso, refEnd), getCompareMonthOverMonth(referenceYear, referenceMonth), getBudgetVsActualData(referenceYear, referenceMonth), ]); // Index the flow rows by month for O(1) lookup, then fill missing months // with zeroes so downstream consumers get a contiguous series. const flowByMonth = new Map(); for (const r of flowRows) { flowByMonth.set(r.month, { income: Number(r.income ?? 0), expenses: Number(r.expenses ?? 0), }); } const buildSeries = (count: number): CartesMonthFlow[] => { const series: CartesMonthFlow[] = []; for (let i = count - 1; i >= 0; i--) { const { year: y, month: m } = shiftMonth(referenceYear, referenceMonth, -i); const key = monthKey(y, m); const row = flowByMonth.get(key); const income = row?.income ?? 0; const expenses = row?.expenses ?? 0; series.push({ month: key, income, expenses, net: income - expenses }); } return series; }; // 13-month sparklines for each KPI (reference month + 12 prior). const sparkSeries = buildSeries(13); const incomeSpark: CartesSparklinePoint[] = sparkSeries.map((p) => ({ month: p.month, value: p.income, })); const expensesSpark: CartesSparklinePoint[] = sparkSeries.map((p) => ({ month: p.month, value: p.expenses, })); const netSpark: CartesSparklinePoint[] = sparkSeries.map((p) => ({ month: p.month, value: p.net, })); const savingsSpark: CartesSparklinePoint[] = sparkSeries.map((p) => ({ month: p.month, value: p.income > 0 ? (p.net / p.income) * 100 : 0, })); // Compute MoM / YoY values directly from `flowByMonth` (which preserves the // "missing" distinction). The sparkline fills gaps with zero for display, // but deltas must remain null when the comparison month has no data. const refKey = monthKey(referenceYear, referenceMonth); const momMeta = shiftMonth(referenceYear, referenceMonth, -1); const momKey = monthKey(momMeta.year, momMeta.month); const yoyMeta = { year: referenceYear - 1, month: referenceMonth }; const yoyKey = monthKey(yoyMeta.year, yoyMeta.month); // Monthly aggregates — the "month" mode default behavior. const refRow = flowByMonth.get(refKey); const monthRefIncome = refRow?.income ?? 0; const monthRefExpenses = refRow?.expenses ?? 0; const monthRefNet = monthRefIncome - monthRefExpenses; // Savings rate is undefined when income is zero — expose as null rather than // rendering a misleading "0 %" in the UI. const monthRefSavings = monthRefIncome > 0 ? (monthRefNet / monthRefIncome) * 100 : null; const momRow = flowByMonth.get(momKey); const monthMomIncome = momRow ? momRow.income : null; const monthMomExpenses = momRow ? momRow.expenses : null; const monthMomNet = momRow ? momRow.income - momRow.expenses : null; const monthMomSavings = momRow && momRow.income > 0 ? ((momRow.income - momRow.expenses) / momRow.income) * 100 : null; const yoyRow = flowByMonth.get(yoyKey); const monthYoyIncome = yoyRow ? yoyRow.income : null; const monthYoyExpenses = yoyRow ? yoyRow.expenses : null; const monthYoyNet = yoyRow ? yoyRow.income - yoyRow.expenses : null; const monthYoySavings = yoyRow && yoyRow.income > 0 ? ((yoyRow.income - yoyRow.expenses) / yoyRow.income) * 100 : null; // YTD aggregates — sum monthly flows from January of a given year up to (and // including) `upToMonth`. Reuses the already-fetched `flowByMonth` map, so // no additional SQL round trip is required. Missing months contribute zero // to the sum (unlike monthly deltas which preserve the null distinction). const sumYtd = (year: number, upToMonth: number): { income: number; expenses: number } => { let income = 0; let expenses = 0; for (let m = 1; m <= upToMonth; m++) { const row = flowByMonth.get(monthKey(year, m)); if (row) { income += row.income; expenses += row.expenses; } } return { income, expenses }; }; // Current YTD: Jan→refMonth of refYear. const ytdCurrent = sumYtd(referenceYear, referenceMonth); const ytdRefIncome = ytdCurrent.income; const ytdRefExpenses = ytdCurrent.expenses; const ytdRefNet = ytdRefIncome - ytdRefExpenses; const ytdRefSavings = ytdRefIncome > 0 ? (ytdRefNet / ytdRefIncome) * 100 : null; // YTD MoM: previous YTD window is Jan→(refMonth-1) of refYear. When the // reference month is January, there is no prior window inside the same // year, so MoM deltas must be null (not zero). let ytdMomIncome: number | null = null; let ytdMomExpenses: number | null = null; let ytdMomNet: number | null = null; let ytdMomSavings: number | null = null; if (referenceMonth > 1) { const prev = sumYtd(referenceYear, referenceMonth - 1); ytdMomIncome = prev.income; ytdMomExpenses = prev.expenses; ytdMomNet = prev.income - prev.expenses; ytdMomSavings = prev.income > 0 ? ((prev.income - prev.expenses) / prev.income) * 100 : null; } // YTD YoY: same window (Jan→refMonth) of the previous calendar year. const ytdPrevYear = sumYtd(referenceYear - 1, referenceMonth); const ytdYoyIncome = ytdPrevYear.income; const ytdYoyExpenses = ytdPrevYear.expenses; const ytdYoyNet = ytdPrevYear.income - ytdPrevYear.expenses; const ytdYoySavings = ytdPrevYear.income > 0 ? (ytdYoyNet / ytdPrevYear.income) * 100 : null; // Select the KPI aggregate set based on the requested mode. Sparklines are // always the 13-month monthly series regardless of mode — the toggle only // affects the "current" value and its MoM/YoY comparisons. const isYtd = mode === "ytd"; const refIncome = isYtd ? ytdRefIncome : monthRefIncome; const refExpenses = isYtd ? ytdRefExpenses : monthRefExpenses; const refNet = isYtd ? ytdRefNet : monthRefNet; const refSavings = isYtd ? ytdRefSavings : monthRefSavings; const kpiMomIncome = isYtd ? ytdMomIncome : monthMomIncome; const kpiMomExpenses = isYtd ? ytdMomExpenses : monthMomExpenses; const kpiMomNet = isYtd ? ytdMomNet : monthMomNet; const kpiMomSavings = isYtd ? ytdMomSavings : monthMomSavings; const kpiYoyIncome = isYtd ? ytdYoyIncome : monthYoyIncome; const kpiYoyExpenses = isYtd ? ytdYoyExpenses : monthYoyExpenses; const kpiYoyNet = isYtd ? ytdYoyNet : monthYoyNet; const kpiYoySavings = isYtd ? ytdYoySavings : monthYoySavings; const incomeKpi = buildKpi(incomeSpark, refIncome, kpiMomIncome, kpiYoyIncome); const expensesKpi = buildKpi(expensesSpark, refExpenses, kpiMomExpenses, kpiYoyExpenses); const netKpi = buildKpi(netSpark, refNet, kpiMomNet, kpiYoyNet); const savingsKpi = buildKpi(savingsSpark, refSavings, kpiMomSavings, kpiYoySavings); // 12-month income vs expenses series for the overlay chart. const flow12Months = buildSeries(12); // Top movers: biggest MoM increases / decreases. `momRows` now carries the // compare hierarchy (Issue #247) — skip the subtotal (`is_parent`) rows so a // parent group can't double-count against its own leaves. The surviving leaves // are byte-identical to the previous flat output; the sort/slice below is // unchanged. `momRows` are sorted by absolute delta already; filter out // near-zero noise and split by sign. const significantMovers = momRows.filter( (r) => !r.is_parent && r.deltaAbs !== 0 && (r.previousAmount > 0 || r.currentAmount > 0), ); // Project the richer CategoryDelta shape down to the narrower CartesTopMover // shape so the Cartes dashboard keeps its stable contract regardless of how // many views (monthly / cumulative) the Compare service exposes. const toCartesMover = (r: CategoryDelta): CartesTopMover => ({ categoryId: r.categoryId, categoryName: r.categoryName, categoryColor: r.categoryColor, previousAmount: r.previousAmount, currentAmount: r.currentAmount, deltaAbs: r.deltaAbs, deltaPct: r.deltaPct, }); const topMoversUp: CartesTopMover[] = significantMovers .filter((r) => r.deltaAbs > 0) .sort((a, b) => b.deltaAbs - a.deltaAbs) .slice(0, 5) .map(toCartesMover); const topMoversDown: CartesTopMover[] = significantMovers .filter((r) => r.deltaAbs < 0) .sort((a, b) => a.deltaAbs - b.deltaAbs) .slice(0, 5) .map(toCartesMover); // Budget adherence — only expense categories with a non-zero budget count. // Both `monthActual` and `monthBudget` are signed (expense categories are // stored with a negative sign by `budgetService`), so all comparisons and // deltas on the adherence card must go through `Math.abs` — otherwise the // `> 0` filter rejects every expense row and the card shows "no budgeted // categories this month" even when budgets exist. const budgetedExpenseRows = budgetRows.filter( (r) => r.category_type === "expense" && Math.abs(r.monthBudget) > 0 && !r.is_parent, ); const budgetsInTarget = budgetedExpenseRows.filter( (r) => Math.abs(r.monthActual) <= Math.abs(r.monthBudget), ).length; const overruns: CartesBudgetWorstOverrun[] = budgetedExpenseRows .map((r) => { const actual = Math.abs(r.monthActual); const budget = Math.abs(r.monthBudget); const overrunAbs = actual - budget; const overrunPct = budget > 0 ? (overrunAbs / budget) * 100 : null; return { categoryId: r.category_id, categoryName: r.category_name, categoryColor: r.category_color, budget, actual, overrunAbs, overrunPct, }; }) .filter((r) => r.overrunAbs > 0) .sort((a, b) => b.overrunAbs - a.overrunAbs) .slice(0, 3); const budgetAdherence: CartesBudgetAdherence = { categoriesInTarget: budgetsInTarget, categoriesTotal: budgetedExpenseRows.length, worstOverruns: overruns, }; // Seasonality — average of the same calendar month across the previous // two years. If no data, average stays null. const historicalYears: CartesSeasonalityYear[] = seasonalityRows.map((r) => ({ year: Number(r.year), amount: Number(r.amount ?? 0), })); const historicalAverage = historicalYears.length ? historicalYears.reduce((sum, r) => sum + r.amount, 0) / historicalYears.length : null; // Seasonality compares the reference month's expenses to the same calendar // month in previous years — it is intentionally insensitive to the KPI // period toggle, so always use the monthly expenses (not the YTD figure). const referenceAmount = monthRefExpenses; const deviationPct = historicalAverage !== null && historicalAverage > 0 ? ((referenceAmount - historicalAverage) / historicalAverage) * 100 : null; const seasonality: CartesSeasonality = { referenceAmount, historicalYears, historicalAverage, deviationPct, }; return { referenceYear, referenceMonth, kpis: { income: incomeKpi, expenses: expensesKpi, net: netKpi, savingsRate: savingsKpi, }, flow12Months, topMoversUp, topMoversDown, budgetAdherence, seasonality, }; }