// 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 { invoke } from "@tauri-apps/api/core"; import { getDb } from "./db"; import { loadProfiles } from "./profileService"; import type { AccountReturn, BalanceAccount, BalanceAccountTransferWithTransaction, BalanceAccountWithCategory, BalanceAssetType, BalanceCategory, BalanceCategoryKind, BalanceSnapshot, BalanceSnapshotLine, BalanceTransferDirection, } 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" | "asset_type_required" | "asset_type_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" // Issue #142 — transfers + returns | "transfer_direction_invalid" | "transfer_already_linked" | "transfer_not_linked" | "transfer_active_profile_unknown" | "transaction_linked_to_balance_account"; 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, asset_type 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, asset_type FROM balance_categories WHERE id = $1`, [id] ); return rows[0] ?? null; } export interface CreateBalanceCategoryInput { key: string; i18n_key: string; kind: BalanceCategoryKind; sort_order?: number; /** * Required when `kind === 'priced'` (Issue #169). Drives PriceFetchControl * provider routing (best-effort Yahoo for stocks, exchange APIs for crypto). * For `kind === 'simple'`, the service forces this to NULL regardless of * the input value. */ asset_type?: BalanceAssetType | null; } /** * 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). */ 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 assetType = normalizeAssetTypeForKind(input.kind, input.asset_type); const db = await getDb(); const result = await db.execute( `INSERT INTO balance_categories (key, i18n_key, kind, sort_order, is_active, is_seed, asset_type) VALUES ($1, $2, $3, $4, 1, 0, $5)`, [ input.key.trim(), input.i18n_key.trim(), input.kind, input.sort_order ?? 0, assetType, ] ); return result.lastInsertId as number; } export interface UpdateBalanceCategoryInput { i18n_key?: string; sort_order?: number; is_active?: boolean; /** * Allows backfilling `asset_type` on legacy priced categories created * before migration v10. The service rejects an explicit `null` when the * existing kind is priced (would unset a required field). */ asset_type?: BalanceAssetType | null; } /** * 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; const assetType = input.asset_type !== undefined ? normalizeAssetTypeForKind(existing.kind, input.asset_type) : existing.asset_type; await db.execute( `UPDATE balance_categories SET i18n_key = $1, sort_order = $2, is_active = $3, asset_type = $4 WHERE id = $5`, [i18n, sortOrder, isActive, assetType, id] ); } /** * Coerce/validate `asset_type` against `kind`: * - simple → always NULL (input is ignored). * - priced → required, must be 'stock' or 'crypto'. */ function normalizeAssetTypeForKind( kind: BalanceCategoryKind, raw: BalanceAssetType | null | undefined ): BalanceAssetType | null { if (kind === "simple") { return null; } if (raw === null || raw === undefined) { throw new BalanceServiceError( "asset_type_required", "asset_type is required for priced categories" ); } if (raw !== "stock" && raw !== "crypto") { throw new BalanceServiceError( "asset_type_invalid", "asset_type must be 'stock' or 'crypto'" ); } return raw; } /** * 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, c.asset_type AS category_asset_type 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 ); } // ----------------------------------------------------------------------------- // Returns + transfers (Issue #142 / Bilan #4) // ----------------------------------------------------------------------------- // // Two distinct surface areas: // // (1) `computeAccountReturn` — Modified Dietz return for one account over a // period. Lives on the Rust side (`compute_account_return` Tauri command) // because it needs to JOIN snapshots + transfers + transactions and // apply day-precision weighting in a single short-lived connection. The // TS shim resolves the active profile's `db_filename` from `loadProfiles` // and forwards it to the command. // // (2) Transfer linking helpers — `linkTransfer`, `unlinkTransfer`, // `listAccountTransfers`. Plain CRUD on `balance_account_transfers` via // `getDb()`, same pattern as the rest of this file. /** * Compute the Modified Dietz return for `accountId` over the period * `[periodStart, periodEnd]` (both ISO `YYYY-MM-DD`). Returns the typed * `AccountReturn` shape — see `src/shared/types/index.ts`. * * Resolves the active profile's `db_filename` from `loadProfiles()` so the * caller doesn't have to thread it through every screen. Throws * `transfer_active_profile_unknown` if no active profile is set (should be * impossible in normal app flow, but the service guards it anyway). */ export async function computeAccountReturn( accountId: number, periodStart: string, periodEnd: string ): Promise { const startNorm = normalizeSnapshotDate(periodStart); const endNorm = normalizeSnapshotDate(periodEnd); const config = await loadProfiles(); const profile = config.profiles.find( (p) => p.id === config.active_profile_id ); if (!profile) { throw new BalanceServiceError( "transfer_active_profile_unknown", "No active profile is set" ); } return invoke("compute_account_return", { dbFilename: profile.db_filename, accountId, periodStart: startNorm, periodEnd: endNorm, }); } function normalizeDirection( direction: BalanceTransferDirection ): BalanceTransferDirection { if (direction !== "in" && direction !== "out") { throw new BalanceServiceError( "transfer_direction_invalid", `Invalid transfer direction: ${direction}` ); } return direction; } /** * Suggested direction for an unlinked transaction based on its signed amount. * Pure helper so the `LinkTransfersModal` UI can pre-fill the direction * column without round-tripping. Convention: in this codebase, expense * transactions are stored with negative amounts (money leaving the bank). * From the *balance account's* perspective: * - negative bank amount = money left the bank → arrived at the balance * account = `in` * - positive bank amount = money entered the bank = the balance account * gave it back = `out` */ export function suggestTransferDirection( transactionAmount: number ): BalanceTransferDirection { return transactionAmount < 0 ? "in" : "out"; } /** * Link a transaction to a balance account with the given direction. * Throws `transfer_already_linked` if the (transaction, account) pair is * already in the table (UNIQUE constraint). */ export async function linkTransfer( accountId: number, transactionId: number, direction: BalanceTransferDirection, notes?: string | null ): Promise { const dir = normalizeDirection(direction); const trimmedNotes = notes ? notes.trim() || null : null; const db = await getDb(); // Guard duplicate link with a SELECT — keeps the error typed instead of a // raw "UNIQUE constraint failed" string. const existing = await db.select<{ id: number }[]>( `SELECT id FROM balance_account_transfers WHERE account_id = $1 AND transaction_id = $2`, [accountId, transactionId] ); if (existing.length > 0) { throw new BalanceServiceError( "transfer_already_linked", `Transaction ${transactionId} is already linked to account ${accountId}` ); } const result = await db.execute( `INSERT INTO balance_account_transfers (account_id, transaction_id, direction, notes) VALUES ($1, $2, $3, $4)`, [accountId, transactionId, dir, trimmedNotes] ); return result.lastInsertId as number; } /** * Unlink a transaction from an account. Throws `transfer_not_linked` if the * pair isn't in the table — keeps callers from silently no-op'ing on a stale * UI state. */ export async function unlinkTransfer( accountId: number, transactionId: number ): Promise { const db = await getDb(); const result = await db.execute( `DELETE FROM balance_account_transfers WHERE account_id = $1 AND transaction_id = $2`, [accountId, transactionId] ); if (result.rowsAffected === 0) { throw new BalanceServiceError( "transfer_not_linked", `No transfer linked transaction ${transactionId} to account ${accountId}` ); } } /** * List every linked transfer for `accountId`, joined with the transaction * table for date/description/amount. Optional `dateRange` (ISO YYYY-MM-DD, * inclusive both sides) filters by `transactions.date`. */ export async function listAccountTransfers( accountId: number, dateRange?: { from?: string; to?: string } ): Promise { const params: unknown[] = [accountId]; const conditions: string[] = ["bat.account_id = $1"]; if (dateRange?.from) { conditions.push(`t.date >= $${params.length + 1}`); params.push(normalizeSnapshotDate(dateRange.from)); } if (dateRange?.to) { conditions.push(`t.date <= $${params.length + 1}`); params.push(normalizeSnapshotDate(dateRange.to)); } const where = `WHERE ${conditions.join(" AND ")}`; const db = await getDb(); return db.select( `SELECT bat.id AS id, bat.account_id AS account_id, bat.transaction_id AS transaction_id, bat.direction AS direction, bat.notes AS notes, bat.created_at AS created_at, t.date AS transaction_date, t.description AS transaction_description, t.amount AS transaction_amount, a.name AS account_name FROM balance_account_transfers bat JOIN transactions t ON t.id = bat.transaction_id JOIN balance_accounts a ON a.id = bat.account_id ${where} ORDER BY t.date DESC, bat.id DESC`, params ); } /** * Returns the set of `transaction_id`s currently linked to ANY balance * account. Used by the transactions table to render the transfer icon * without an N+1 query — the caller receives the full set once per render * and does an in-memory `.has(id)` lookup. Cheap on real-world scales * (typically < 1000 linked transfers per profile). */ export async function listLinkedTransactionIds(): Promise> { const db = await getDb(); const rows = await db.select<{ transaction_id: number }[]>( `SELECT DISTINCT transaction_id FROM balance_account_transfers` ); return new Set(rows.map((r) => r.transaction_id)); } /** * Returns transfer info keyed by `transaction_id` for tooltip rendering in * the transactions table. Each transaction maps to an array because a * single transaction *could* be linked to several accounts in principle * (the UNIQUE is on the pair, not on transaction alone). */ export interface LinkedTransferTooltipRow { transaction_id: number; account_id: number; account_name: string; direction: BalanceTransferDirection; } export async function listAllLinkedTransfersForTooltip(): Promise< Map > { const db = await getDb(); const rows = await db.select( `SELECT bat.transaction_id AS transaction_id, bat.account_id AS account_id, a.name AS account_name, bat.direction AS direction FROM balance_account_transfers bat JOIN balance_accounts a ON a.id = bat.account_id ORDER BY bat.transaction_id` ); const map = new Map(); for (const r of rows) { const list = map.get(r.transaction_id) ?? []; list.push(r); map.set(r.transaction_id, list); } return map; } /** * Detect whether the SQL error returned by `tauri-plugin-sql` is a FK * RESTRICT violation from `balance_account_transfers.transaction_id`. The * plugin surfaces the SQLite error message verbatim, so we match on the * string. Used by `transactionService.deleteTransaction` to surface a * clean i18n error instead of leaking the raw SQL. */ export function isLinkedTransactionFkError(error: unknown): boolean { const msg = error instanceof Error ? error.message : String(error ?? ""); // SQLite FK error messages look like: // "FOREIGN KEY constraint failed" // or // "code: 787, message: FOREIGN KEY constraint failed" // Both contain the canonical "FOREIGN KEY constraint failed" substring. return /FOREIGN KEY constraint failed/i.test(msg); } // ----------------------------------------------------------------------------- // Prices — fetch_price Tauri command wrapper (Issue #156 / Bilan #5) // ----------------------------------------------------------------------------- // // Wraps `invoke('fetch_price', { symbol, date })` with: // - Local rate-limit (1 request / 2s via module-level timestamp) // - In-flight deduplication (same symbol+date → one request, multiple awaiters) // - Exponential backoff on 5xx-class errors (2/4/8s, max 3 retries) // - No retry on 4xx errors or rate_limit (429-class) // - Hard 100-request session cap (successful fetches only) // // The Rust command `fetch_price` (implemented in issue #155) rejects with a // JSON string serialized from the Rust error enum: // {"code":"auth"} | {"code":"rate_limit","retry_after_s":42} | ... // // Annexe B i18n mapping (keys live in the i18n PR #160): // auth → balance.priceFetching.errors.authFailed // premium_required → balance.priceFetching.errors.premiumRequired // symbol_not_found → balance.priceFetching.errors.symbolNotFound // rate_limit → balance.priceFetching.errors.rateLimit // provider_unavailable → balance.priceFetching.errors.serverUnavailable // network → balance.priceFetching.errors.serverUnavailable // internal → balance.priceFetching.errors.serverUnavailable // session_cap_reached → balance.priceFetching.errors.sessionCapReached export type PriceErrorCode = | "auth" | "premium_required" | "symbol_not_found" | "rate_limit" | "provider_unavailable" | "network" | "internal" | "session_cap_reached"; export type PriceError = | { code: "rate_limit"; retry_after_s: number; i18nKey: string } | { code: Exclude; i18nKey: string }; export interface PriceSuccess { ok: true; symbol: string; date: string; price: number; currency: string; source: string; cached: boolean; actual_date?: string | null; fetched_at: string; } export type PriceResult = | PriceSuccess | { ok: false; error: PriceError }; /** Raw shape returned by the Rust `fetch_price` command on success. */ interface RawPriceResponse { symbol: string; date: string; price: number; currency: string; source: string; cached: boolean; actual_date?: string | null; fetched_at: string; } // i18n key map for non-rate_limit error codes. const PRICE_ERROR_I18N_MAP: Record, string> = { auth: "balance.priceFetching.errors.authFailed", premium_required: "balance.priceFetching.errors.premiumRequired", symbol_not_found: "balance.priceFetching.errors.symbolNotFound", provider_unavailable: "balance.priceFetching.errors.serverUnavailable", network: "balance.priceFetching.errors.serverUnavailable", internal: "balance.priceFetching.errors.serverUnavailable", session_cap_reached: "balance.priceFetching.errors.sessionCapReached", }; /** Codes that map to no-retry behaviour (4xx-class or session cap). */ const NO_RETRY_CODES = new Set([ "auth", "premium_required", "symbol_not_found", "rate_limit", "session_cap_reached", ]); /** * Parse the string-serialized Rust error into a typed `PriceError`. * `invoke` rejects with the value of `Result::Err(String)`, which the Rust * side serialises via serde_json (see issue #155 worker decision). */ function parseRustError(e: unknown): PriceError { if (typeof e === "string") { try { const j = JSON.parse(e) as Record; if (j && typeof j.code === "string") { const code = j.code; if (code === "rate_limit") { const retry_after_s = typeof j.retry_after_s === "number" ? j.retry_after_s : 0; return { code: "rate_limit", retry_after_s, i18nKey: "balance.priceFetching.errors.rateLimit", }; } if (code in PRICE_ERROR_I18N_MAP) { const typedCode = code as Exclude; return { code: typedCode, i18nKey: PRICE_ERROR_I18N_MAP[typedCode] }; } } } catch { // Fall through to default below. } } return { code: "internal", i18nKey: PRICE_ERROR_I18N_MAP.internal, }; } // Module-level state — resets only when the JS module is re-imported // (i.e. on app process restart). Tests reset via `prices.__resetForTests()`. let _lastFiredAt = 0; let _sessionCount = 0; const SESSION_CAP = 100; const MIN_INTERVAL_MS = 2000; const _inFlight = new Map>(); /** Enforce the 1-request-per-2s local rate limit. */ async function _enforceRateLimit(): Promise { const now = Date.now(); const wait = Math.max(0, _lastFiredAt + MIN_INTERVAL_MS - now); if (wait > 0) { await new Promise((r) => setTimeout(r, wait)); } _lastFiredAt = Date.now(); } /** Single attempt: rate-limit, then invoke once. */ async function _doFetchOnce( symbol: string, date: string ): Promise { await _enforceRateLimit(); try { const raw = await invoke("fetch_price", { symbol, date }); return { ok: true, ...raw }; } catch (e) { return { ok: false, error: parseRustError(e) }; } } /** Wrap _doFetchOnce with exponential backoff on retryable errors (5xx-class). */ async function _withRetries( symbol: string, date: string ): Promise { const delays = [2000, 4000, 8000]; let lastResult: PriceResult | null = null; for (let attempt = 0; attempt <= 3; attempt++) { const r = await _doFetchOnce(symbol, date); if (r.ok) return r; lastResult = r; const code = r.error.code; if (NO_RETRY_CODES.has(code)) { // 4xx-class: return immediately, no retry. return r; } // 5xx-class (provider_unavailable, network, internal): retry with backoff. if (attempt < 3) { await new Promise((r) => setTimeout(r, delays[attempt])); } } // Should never reach here, but satisfy TypeScript. return lastResult!; } /** * `prices` namespace — entry point for the UI. * * All outgoing requests are rate-limited (1/2s), deduplicated in-flight, and * wrapped with exponential backoff on 5xx-class errors. A hard session cap of * 100 successful fetches guards against runaway loops. */ export const prices = { /** * Fetch the price for `symbol` at `date` (ISO YYYY-MM-DD). * * Decision (MEDIUM): the 100-session cap is checked BEFORE rate-limit and * dedup. Successful fetches increment the counter; failures do NOT consume * the budget — a 4xx auth error costs nothing, and a user who hits a bad * symbol shouldn't have their session budget drained. */ async fetchPrice(symbol: string, date: string): Promise { if (_sessionCount >= SESSION_CAP) { return { ok: false, error: { code: "session_cap_reached", i18nKey: PRICE_ERROR_I18N_MAP.session_cap_reached, }, }; } const key = `${symbol}|${date}`; const existing = _inFlight.get(key); if (existing) return existing; const promise = (async () => { try { const result = await _withRetries(symbol, date); if (result.ok) _sessionCount++; return result; } finally { _inFlight.delete(key); } })(); _inFlight.set(key, promise); return promise; }, /** * Reset module-level state between tests. * Call in `beforeEach` to isolate rate-limit, session count, and in-flight map. */ __resetForTests(): void { _lastFiredAt = 0; _sessionCount = 0; _inFlight.clear(); }, };