// balance.service.ts — domain service for the Bilan (balance sheet) feature. // // Scope at Issue #138 (Bilan #1a): CRUD for `balance_categories` and // `balance_accounts` only. Snapshots, snapshot lines, transfers and price // fetching ship in subsequent issues (#1b / #2 / #4 / #5). // // CRUD goes through `getDb()` + tauri-plugin-sql directly — the project // convention for database operations. Tauri commands are reserved for // filesystem / OAuth / license / profile work and the future Modified Dietz // + price-fetch work in Issue #142. import { getDb } from "./db"; import type { BalanceAccount, BalanceAccountWithCategory, BalanceCategory, BalanceCategoryKind, BalanceSnapshot, BalanceSnapshotLine, } from "../shared/types"; import { BALANCE_CURRENCY_CAD } from "../shared/types"; // ----------------------------------------------------------------------------- // Errors — typed so the UI can show distinct i18n messages. // ----------------------------------------------------------------------------- export type BalanceErrorCode = | "currency_unsupported" | "category_seed_protected" | "category_has_accounts" | "category_not_found" | "account_not_found" | "name_required" | "kind_invalid" | "snapshot_date_required" | "snapshot_date_taken" | "snapshot_not_found" | "snapshot_value_invalid" | "snapshot_priced_unsupported" | "snapshot_priced_quantity_required" | "snapshot_priced_unit_price_required" | "snapshot_priced_value_mismatch" | "snapshot_simple_must_be_scalar"; export class BalanceServiceError extends Error { readonly code: BalanceErrorCode; constructor(code: BalanceErrorCode, message: string) { super(message); this.name = "BalanceServiceError"; this.code = code; } } // ----------------------------------------------------------------------------- // Categories // ----------------------------------------------------------------------------- export async function listBalanceCategories(): Promise { const db = await getDb(); return db.select( `SELECT id, key, i18n_key, kind, sort_order, is_active, is_seed FROM balance_categories ORDER BY sort_order, key` ); } export async function getBalanceCategory( id: number ): Promise { const db = await getDb(); const rows = await db.select( `SELECT id, key, i18n_key, kind, sort_order, is_active, is_seed FROM balance_categories WHERE id = $1`, [id] ); return rows[0] ?? null; } export interface CreateBalanceCategoryInput { key: string; i18n_key: string; kind: BalanceCategoryKind; sort_order?: number; } /** * Create a user-defined balance category. The seed categories are created by * Migration v9 — never call this for seeded keys (UNIQUE will reject the * insert anyway). * * Note (Issue #138): the AccountsPage UI restricts user-created categories to * `kind = 'simple'`. The service still accepts both because the priced UI * lands in Issue #140. */ export async function createBalanceCategory( input: CreateBalanceCategoryInput ): Promise { if (!input.key || input.key.trim().length === 0) { throw new BalanceServiceError("name_required", "Category key is required"); } if (input.kind !== "simple" && input.kind !== "priced") { throw new BalanceServiceError("kind_invalid", "Invalid category kind"); } const db = await getDb(); const result = await db.execute( `INSERT INTO balance_categories (key, i18n_key, kind, sort_order, is_active, is_seed) VALUES ($1, $2, $3, $4, 1, 0)`, [ input.key.trim(), input.i18n_key.trim(), input.kind, input.sort_order ?? 0, ] ); return result.lastInsertId as number; } export interface UpdateBalanceCategoryInput { i18n_key?: string; sort_order?: number; is_active?: boolean; } /** * Rename / re-order / toggle active state of a category. Seeded categories * are renamable. Changing `kind` is intentionally not supported (would * invalidate existing snapshot lines). */ export async function updateBalanceCategory( id: number, input: UpdateBalanceCategoryInput ): Promise { const existing = await getBalanceCategory(id); if (!existing) { throw new BalanceServiceError( "category_not_found", `Category ${id} not found` ); } const db = await getDb(); const i18n = input.i18n_key !== undefined ? input.i18n_key : existing.i18n_key; const sortOrder = input.sort_order !== undefined ? input.sort_order : existing.sort_order; const isActive = input.is_active !== undefined ? (input.is_active ? 1 : 0) : existing.is_active ? 1 : 0; await db.execute( `UPDATE balance_categories SET i18n_key = $1, sort_order = $2, is_active = $3 WHERE id = $4`, [i18n, sortOrder, isActive, id] ); } /** * Delete a user-created category. Refuses to delete: * - seeded categories (`is_seed = 1`) — UI must disable the button; * - categories with linked accounts — FK RESTRICT would also reject, but * we pre-check to surface a clean i18n message. */ export async function deleteBalanceCategory(id: number): Promise { const existing = await getBalanceCategory(id); if (!existing) { throw new BalanceServiceError( "category_not_found", `Category ${id} not found` ); } if (existing.is_seed) { throw new BalanceServiceError( "category_seed_protected", "Seeded categories cannot be deleted" ); } const db = await getDb(); const linked = await db.select>( `SELECT COUNT(*) AS count FROM balance_accounts WHERE balance_category_id = $1`, [id] ); if ((linked[0]?.count ?? 0) > 0) { throw new BalanceServiceError( "category_has_accounts", "Cannot delete a category with linked accounts" ); } await db.execute("DELETE FROM balance_categories WHERE id = $1", [id]); } // ----------------------------------------------------------------------------- // Accounts // ----------------------------------------------------------------------------- export async function listBalanceAccounts(options?: { includeArchived?: boolean; }): Promise { const includeArchived = options?.includeArchived ?? false; const db = await getDb(); const where = includeArchived ? "" : "WHERE a.is_active = 1 AND a.archived_at IS NULL"; return db.select( `SELECT a.id, a.balance_category_id, a.name, a.symbol, a.currency, a.notes, a.is_active, a.archived_at, a.created_at, a.updated_at, c.key AS category_key, c.i18n_key AS category_i18n_key, c.kind AS category_kind FROM balance_accounts a INNER JOIN balance_categories c ON c.id = a.balance_category_id ${where} ORDER BY c.sort_order, a.name` ); } export async function getBalanceAccount( id: number ): Promise { const db = await getDb(); const rows = await db.select( `SELECT id, balance_category_id, name, symbol, currency, notes, is_active, archived_at, created_at, updated_at FROM balance_accounts WHERE id = $1`, [id] ); return rows[0] ?? null; } export interface CreateBalanceAccountInput { balance_category_id: number; name: string; symbol?: string | null; /** Defaults to 'CAD'. MVP rejects any other value. */ currency?: string; notes?: string | null; } /** * Create an account. Currency must be 'CAD' at the MVP — the SQL CHECK * would reject anything else, but we pre-check to surface a clean i18n * message instead of a raw SQL error. */ export async function createBalanceAccount( input: CreateBalanceAccountInput ): Promise { if (!input.name || input.name.trim().length === 0) { throw new BalanceServiceError("name_required", "Account name is required"); } const currency = input.currency ?? BALANCE_CURRENCY_CAD; if (currency !== BALANCE_CURRENCY_CAD) { throw new BalanceServiceError( "currency_unsupported", "Only CAD is supported at the MVP" ); } const cat = await getBalanceCategory(input.balance_category_id); if (!cat) { throw new BalanceServiceError( "category_not_found", "Linked balance category not found" ); } const db = await getDb(); const result = await db.execute( `INSERT INTO balance_accounts (balance_category_id, name, symbol, currency, notes, is_active) VALUES ($1, $2, $3, $4, $5, 1)`, [ input.balance_category_id, input.name.trim(), input.symbol ? input.symbol.trim() : null, currency, input.notes ? input.notes.trim() : null, ] ); return result.lastInsertId as number; } export interface UpdateBalanceAccountInput { balance_category_id?: number; name?: string; symbol?: string | null; notes?: string | null; is_active?: boolean; } export async function updateBalanceAccount( id: number, input: UpdateBalanceAccountInput ): Promise { const existing = await getBalanceAccount(id); if (!existing) { throw new BalanceServiceError( "account_not_found", `Account ${id} not found` ); } const name = input.name !== undefined ? input.name.trim() : existing.name; if (!name) { throw new BalanceServiceError("name_required", "Account name is required"); } const categoryId = input.balance_category_id !== undefined ? input.balance_category_id : existing.balance_category_id; const symbol = input.symbol !== undefined ? input.symbol === null ? null : input.symbol.trim() || null : existing.symbol; const notes = input.notes !== undefined ? input.notes === null ? null : input.notes.trim() || null : existing.notes; const isActive = input.is_active !== undefined ? input.is_active ? 1 : 0 : existing.is_active ? 1 : 0; const db = await getDb(); await db.execute( `UPDATE balance_accounts SET balance_category_id = $1, name = $2, symbol = $3, notes = $4, is_active = $5, updated_at = CURRENT_TIMESTAMP WHERE id = $6`, [categoryId, name, symbol, notes, isActive, id] ); } /** * Soft-delete an account: stamp `archived_at` and set `is_active = 0`. * Archived accounts are hidden from new snapshots but kept in the historic * snapshot lines (which is why we never hard-delete here). */ export async function archiveBalanceAccount(id: number): Promise { const existing = await getBalanceAccount(id); if (!existing) { throw new BalanceServiceError( "account_not_found", `Account ${id} not found` ); } const db = await getDb(); await db.execute( `UPDATE balance_accounts SET archived_at = CURRENT_TIMESTAMP, is_active = 0, updated_at = CURRENT_TIMESTAMP WHERE id = $1`, [id] ); } export async function unarchiveBalanceAccount(id: number): Promise { const existing = await getBalanceAccount(id); if (!existing) { throw new BalanceServiceError( "account_not_found", `Account ${id} not found` ); } const db = await getDb(); await db.execute( `UPDATE balance_accounts SET archived_at = NULL, is_active = 1, updated_at = CURRENT_TIMESTAMP WHERE id = $1`, [id] ); } // ----------------------------------------------------------------------------- // Snapshots + lines (Issue #146 / Bilan #1b — simple kind only) // ----------------------------------------------------------------------------- // // At Issue #146 the UI surfaces *only* simple-kind input: every line has // `quantity = NULL` and `unit_price = NULL`. The SQL CHECK on // `balance_snapshot_lines` already enforces the kind invariant, but // `upsertSnapshotLines` re-validates ahead of time so a typed // BalanceServiceError surfaces a clean i18n message instead of a raw SQL // error. Priced-kind upsert lands in Issue #140 (Bilan #2). const ISO_DATE_REGEX = /^\d{4}-\d{2}-\d{2}$/; function normalizeSnapshotDate(date: string): string { const trimmed = (date ?? "").trim(); if (!trimmed) { throw new BalanceServiceError( "snapshot_date_required", "Snapshot date is required" ); } if (!ISO_DATE_REGEX.test(trimmed)) { throw new BalanceServiceError( "snapshot_date_required", "Snapshot date must be in ISO YYYY-MM-DD format" ); } return trimmed; } export async function listSnapshots(): Promise { const db = await getDb(); return db.select( `SELECT id, snapshot_date, notes, created_at, updated_at FROM balance_snapshots ORDER BY snapshot_date DESC` ); } export async function getSnapshotByDate( date: string ): Promise { const normalized = normalizeSnapshotDate(date); const db = await getDb(); const rows = await db.select( `SELECT id, snapshot_date, notes, created_at, updated_at FROM balance_snapshots WHERE snapshot_date = $1`, [normalized] ); return rows[0] ?? null; } export async function getSnapshotById( id: number ): Promise { const db = await getDb(); const rows = await db.select( `SELECT id, snapshot_date, notes, created_at, updated_at FROM balance_snapshots WHERE id = $1`, [id] ); return rows[0] ?? null; } export interface CreateSnapshotInput { snapshot_date: string; notes?: string | null; } /** * Create a snapshot row. Throws `snapshot_date_taken` if a snapshot already * exists at the same date so the UI can redirect to edit mode (UNIQUE * constraint on `snapshot_date` would surface a raw SQL error otherwise). */ export async function createSnapshot( input: CreateSnapshotInput ): Promise { const date = normalizeSnapshotDate(input.snapshot_date); const existing = await getSnapshotByDate(date); if (existing) { throw new BalanceServiceError( "snapshot_date_taken", `A snapshot already exists at ${date}` ); } const db = await getDb(); const result = await db.execute( `INSERT INTO balance_snapshots (snapshot_date, notes) VALUES ($1, $2)`, [date, input.notes ? input.notes.trim() || null : null] ); return result.lastInsertId as number; } export interface UpdateSnapshotInput { notes?: string | null; } /** * Update snapshot metadata (notes only). Snapshot date is immutable once * saved — to change the date the user deletes the snapshot and creates a * new one (the UI exposes this as a constraint, not a feature). */ export async function updateSnapshot( id: number, input: UpdateSnapshotInput ): Promise { const existing = await getSnapshotById(id); if (!existing) { throw new BalanceServiceError( "snapshot_not_found", `Snapshot ${id} not found` ); } const notes = input.notes !== undefined ? input.notes === null ? null : input.notes.trim() || null : existing.notes; const db = await getDb(); await db.execute( `UPDATE balance_snapshots SET notes = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2`, [notes, id] ); } /** * Delete a snapshot. ON DELETE CASCADE on `balance_snapshot_lines` * .snapshot_id removes the lines too. The UI must double-confirm * (re-typing the snapshot date) before invoking this. */ export async function deleteSnapshot(id: number): Promise { const existing = await getSnapshotById(id); if (!existing) { throw new BalanceServiceError( "snapshot_not_found", `Snapshot ${id} not found` ); } const db = await getDb(); await db.execute("DELETE FROM balance_snapshots WHERE id = $1", [id]); } export async function listLinesBySnapshot( snapshotId: number ): Promise { const db = await getDb(); return db.select( `SELECT id, snapshot_id, account_id, quantity, unit_price, value, price_source, price_fetched_at, created_at, updated_at FROM balance_snapshot_lines WHERE snapshot_id = $1 ORDER BY id`, [snapshotId] ); } /** * Tolerance ε used by the priced-kind invariant `value === quantity * unit_price`. * * Floating-point multiplication of decimal user input is lossy * (`12.34 * 1.07 === 13.2038000000000002`), and the UI displays `value` * rounded to 2 decimals while keeping quantity / unit_price at full * precision. ε = 0.01 (one cent on the dollar) is generous enough to * absorb that drift but tight enough to catch obvious mistakes (off by * 10×). See decisions-log.md / Issue #140. */ export const PRICED_VALUE_TOLERANCE = 0.01; export interface SnapshotLineInput { account_id: number; /** * Snapshot value at this date. For priced lines this should match * `quantity * unit_price` within `PRICED_VALUE_TOLERANCE`; the service * validates the relation ahead of the SQL CHECK and surfaces a typed * `snapshot_priced_value_mismatch` error otherwise. */ value: number; /** * Category kind of the underlying account. Defaults to 'simple' to * preserve the #146 callers that don't pass it. Priced lines must * provide both `quantity` and `unit_price`. */ account_kind?: BalanceCategoryKind; /** Required for priced lines, must be NULL for simple. */ quantity?: number | null; /** Required for priced lines, must be NULL for simple. */ unit_price?: number | null; } /** * Pure helper that validates a snapshot line against its account's * category kind. Exposed for unit tests and used by `upsertSnapshotLines` * before any DB mutation happens. * * Rules: * - simple kind → quantity AND unit_price must be NULL/undefined; value * must be a finite number. * - priced kind → quantity AND unit_price must be finite numbers; value * must equal quantity × unit_price within * `PRICED_VALUE_TOLERANCE`. * * @throws `BalanceServiceError` with a typed code on the first failure. */ export function validateLineKindInvariants( line: SnapshotLineInput, accountKind: BalanceCategoryKind = line.account_kind ?? "simple" ): void { if (typeof line.value !== "number" || !Number.isFinite(line.value)) { throw new BalanceServiceError( "snapshot_value_invalid", `Line for account ${line.account_id}: value must be a finite number` ); } if (accountKind === "simple") { // Simple-kind: quantity / unit_price must be absent (NULL or undefined). if (line.quantity !== undefined && line.quantity !== null) { throw new BalanceServiceError( "snapshot_simple_must_be_scalar", `Line for account ${line.account_id}: simple-kind line must not carry quantity` ); } if (line.unit_price !== undefined && line.unit_price !== null) { throw new BalanceServiceError( "snapshot_simple_must_be_scalar", `Line for account ${line.account_id}: simple-kind line must not carry unit_price` ); } return; } // Priced-kind: both fields required and finite. if ( line.quantity === undefined || line.quantity === null || typeof line.quantity !== "number" || !Number.isFinite(line.quantity) ) { throw new BalanceServiceError( "snapshot_priced_quantity_required", `Line for account ${line.account_id}: quantity is required for priced accounts` ); } if ( line.unit_price === undefined || line.unit_price === null || typeof line.unit_price !== "number" || !Number.isFinite(line.unit_price) ) { throw new BalanceServiceError( "snapshot_priced_unit_price_required", `Line for account ${line.account_id}: unit_price is required for priced accounts` ); } const expected = line.quantity * line.unit_price; if (Math.abs(expected - line.value) > PRICED_VALUE_TOLERANCE) { throw new BalanceServiceError( "snapshot_priced_value_mismatch", `Line for account ${line.account_id}: value ${line.value} does not match quantity × unit_price (${expected})` ); } } /** * Upsert a batch of snapshot lines. Each input row is inserted or * replaced atomically per account; lines for accounts not present in * `lines` are removed from the snapshot. This makes the editor strictly * state-driven — what the user sees is exactly what gets saved. * * Validation enforced ahead of time so the SQL CHECK never fires * (`validateLineKindInvariants`): * - simple kind → quantity / unit_price must be NULL; value must be finite. * - priced kind → quantity / unit_price must be finite, and * `value === quantity * unit_price` within * `PRICED_VALUE_TOLERANCE`. * * The default `account_kind = 'simple'` preserves the #146 calling * convention — callers that pre-classify their lines (which the priced * editor in #140 must do) pass `account_kind: 'priced'` explicitly. */ export async function upsertSnapshotLines( snapshotId: number, lines: SnapshotLineInput[] ): Promise { const snapshot = await getSnapshotById(snapshotId); if (!snapshot) { throw new BalanceServiceError( "snapshot_not_found", `Snapshot ${snapshotId} not found` ); } // Validate every input up-front before mutating anything. for (const line of lines) { validateLineKindInvariants(line); } const db = await getDb(); // Strategy: clear and rewrite. Snapshot lines are small (one per active // account, typically < 20) so the simplicity outweighs the diff-tracking // savings. CASCADE guarantees consistency on partial failures. await db.execute( "DELETE FROM balance_snapshot_lines WHERE snapshot_id = $1", [snapshotId] ); for (const line of lines) { const kind = line.account_kind ?? "simple"; if (kind === "simple") { await db.execute( `INSERT INTO balance_snapshot_lines (snapshot_id, account_id, quantity, unit_price, value, price_source) VALUES ($1, $2, NULL, NULL, $3, 'manual')`, [snapshotId, line.account_id, line.value] ); } else { await db.execute( `INSERT INTO balance_snapshot_lines (snapshot_id, account_id, quantity, unit_price, value, price_source) VALUES ($1, $2, $3, $4, $5, 'manual')`, [ snapshotId, line.account_id, line.quantity, line.unit_price, line.value, ] ); } } // Bump the parent snapshot's updated_at so list views can sort by recency. await db.execute( `UPDATE balance_snapshots SET updated_at = CURRENT_TIMESTAMP WHERE id = $1`, [snapshotId] ); } /** * Convenience helper used by the "Prefill from previous snapshot" button. * Returns the snapshot whose `snapshot_date` is strictly earlier than * `referenceDate`, or `null` if none exists. */ export async function getPreviousSnapshot( referenceDate: string ): Promise { const normalized = normalizeSnapshotDate(referenceDate); const db = await getDb(); const rows = await db.select( `SELECT id, snapshot_date, notes, created_at, updated_at FROM balance_snapshots WHERE snapshot_date < $1 ORDER BY snapshot_date DESC LIMIT 1`, [normalized] ); return rows[0] ?? null; } // ----------------------------------------------------------------------------- // Time-series aggregators (Issue #141 / Bilan #3) — used by BalancePage. // ----------------------------------------------------------------------------- /** * Optional [from, to] range filter expressed in ISO `YYYY-MM-DD` format. * Both endpoints are inclusive. `from` and `to` may each be omitted to leave * that side unbounded. */ export interface SnapshotDateRange { from?: string; to?: string; } /** Aggregated total at a given snapshot date. */ export interface SnapshotTotalPoint { snapshot_date: string; total: number; } function buildDateRangeClause( range: SnapshotDateRange | undefined, baseAlias: string ): { clause: string; params: unknown[] } { if (!range || (!range.from && !range.to)) { return { clause: "", params: [] }; } const parts: string[] = []; const params: unknown[] = []; if (range.from) { const from = normalizeSnapshotDate(range.from); parts.push(`${baseAlias}.snapshot_date >= $${params.length + 1}`); params.push(from); } if (range.to) { const to = normalizeSnapshotDate(range.to); parts.push(`${baseAlias}.snapshot_date <= $${params.length + 1}`); params.push(to); } return { clause: `WHERE ${parts.join(" AND ")}`, params }; } /** * Returns the aggregated total value of every snapshot, sorted by date ASC. * Used by the line variant of the evolution chart on `/balance`. * * The aggregation is `SUM(value) GROUP BY snapshot_date` — every account * contributing to the snapshot is summed in. Snapshots with no lines * collapse to a `total = 0` row (preserved so the chart shows continuity). */ export async function getSnapshotTotalsByDate( range?: SnapshotDateRange ): Promise { const { clause, params } = buildDateRangeClause(range, "s"); const db = await getDb(); return db.select( `SELECT s.snapshot_date AS snapshot_date, COALESCE(SUM(l.value), 0) AS total FROM balance_snapshots s LEFT JOIN balance_snapshot_lines l ON l.snapshot_id = s.id ${clause} GROUP BY s.snapshot_date ORDER BY s.snapshot_date ASC`, params ); } /** Per-snapshot breakdown by category. */ export interface SnapshotCategoryBreakdownPoint { snapshot_date: string; byCategory: Record; } interface RawCategoryBreakdownRow { snapshot_date: string; category_key: string; total: number; } /** * Returns per-snapshot totals broken down by `balance_categories.key`, * sorted by date ASC. Used by the stacked-area variant of the evolution * chart. Categories with no value at a given date are omitted from the * `byCategory` map (chart consumers should treat absent keys as zero). * * Lines whose joined account points to no category are skipped — that * shouldn't happen given FK RESTRICT but the JOIN is defensive. */ export async function getSnapshotTotalsByCategoryAndDate( range?: SnapshotDateRange ): Promise { const { clause, params } = buildDateRangeClause(range, "s"); const db = await getDb(); const rows = await db.select( `SELECT s.snapshot_date AS snapshot_date, c.key AS category_key, COALESCE(SUM(l.value), 0) AS total FROM balance_snapshots s INNER JOIN balance_snapshot_lines l ON l.snapshot_id = s.id INNER JOIN balance_accounts a ON a.id = l.account_id INNER JOIN balance_categories c ON c.id = a.balance_category_id ${clause} GROUP BY s.snapshot_date, c.key ORDER BY s.snapshot_date ASC, c.key ASC`, params ); // Bucket rows by snapshot_date — many rows per date, one per category. const out: SnapshotCategoryBreakdownPoint[] = []; let current: SnapshotCategoryBreakdownPoint | null = null; for (const r of rows) { if (!current || current.snapshot_date !== r.snapshot_date) { current = { snapshot_date: r.snapshot_date, byCategory: {} }; out.push(current); } current.byCategory[r.category_key] = r.total; } return out; } /** Latest-snapshot value per active account (Issue #141). */ export interface AccountLatestSnapshot { account_id: number; account_name: string; symbol: string | null; balance_category_id: number; category_key: string; category_i18n_key: string; category_kind: BalanceCategoryKind; /** Date of the snapshot whose value is reported, or null if no snapshot exists. */ latest_snapshot_date: string | null; /** Value at that snapshot, or null if the account has no snapshot lines. */ latest_value: number | null; } /** * Returns one row per active (non-archived) account with the value of its * most-recent snapshot line. Accounts with no snapshot rows yet still * appear, with `latest_value = null`. Used by the accounts table on * `/balance` (#141) and as a building block for the period Δ% column. * * Implementation: a correlated subquery picks the line with the largest * `s.snapshot_date` for each account — SQLite handles this fine on the * indexed `balance_snapshots.snapshot_date` and `balance_snapshot_lines.account_id`. */ export async function getAccountsLatestSnapshot(): Promise< AccountLatestSnapshot[] > { const db = await getDb(); return db.select( `SELECT a.id AS account_id, a.name AS account_name, a.symbol AS symbol, a.balance_category_id AS balance_category_id, c.key AS category_key, c.i18n_key AS category_i18n_key, c.kind AS category_kind, (SELECT s.snapshot_date FROM balance_snapshot_lines l JOIN balance_snapshots s ON s.id = l.snapshot_id WHERE l.account_id = a.id ORDER BY s.snapshot_date DESC LIMIT 1) AS latest_snapshot_date, (SELECT l.value FROM balance_snapshot_lines l JOIN balance_snapshots s ON s.id = l.snapshot_id WHERE l.account_id = a.id ORDER BY s.snapshot_date DESC LIMIT 1) AS latest_value FROM balance_accounts a INNER JOIN balance_categories c ON c.id = a.balance_category_id WHERE a.is_active = 1 AND a.archived_at IS NULL ORDER BY c.sort_order, a.name` ); } /** * Returns the value at the earliest snapshot for each account whose * `snapshot_date` is `>= range.from` (and `<= range.to` when set), so the * accounts table can compute a per-account Δ% over the selected period. * * Returns one row per account with a snapshot in range. Accounts without * any snapshot in the period are omitted — callers default their Δ% to * `null` (rendered as "—"). */ export interface AccountPeriodAnchor { account_id: number; anchor_snapshot_date: string; anchor_value: number; } export async function getAccountsPeriodAnchor( range: SnapshotDateRange ): Promise { // For each account, find the earliest snapshot_date >= range.from (and // <= range.to when set), then read that line's value. const params: unknown[] = []; const conditions: string[] = []; if (range.from) { conditions.push(`s.snapshot_date >= $${params.length + 1}`); params.push(normalizeSnapshotDate(range.from)); } if (range.to) { conditions.push(`s.snapshot_date <= $${params.length + 1}`); params.push(normalizeSnapshotDate(range.to)); } const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : ""; const db = await getDb(); return db.select( `SELECT l.account_id AS account_id, MIN(s.snapshot_date) AS anchor_snapshot_date, (SELECT l2.value FROM balance_snapshot_lines l2 JOIN balance_snapshots s2 ON s2.id = l2.snapshot_id WHERE l2.account_id = l.account_id AND s2.snapshot_date = MIN(s.snapshot_date) LIMIT 1) AS anchor_value FROM balance_snapshot_lines l JOIN balance_snapshots s ON s.id = l.snapshot_id ${where} GROUP BY l.account_id`, params ); }