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