From 58d3c86336cea1bde5a77421924af2062b1b4155 Mon Sep 17 00:00:00 2001 From: le king fu Date: Sat, 25 Apr 2026 14:33:39 -0400 Subject: [PATCH] feat(balance): add balance.service CRUD section + tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the TypeScript service layer for the Bilan feature, scoped to Issue #138 (Bilan #1a) — categories + accounts CRUD only. Snapshots, snapshot lines, transfers and price-fetching land in subsequent issues. The service uses `getDb()` + tauri-plugin-sql directly per project convention (96 occurrences across 15 services). No new Tauri commands introduced — the only future Rust commands are `compute_account_return` (Issue #142) and `fetch_price` (Issue #144). API surface: - listBalanceCategories / getBalanceCategory / createBalanceCategory / updateBalanceCategory / deleteBalanceCategory (with seed + has-accounts guards) - listBalanceAccounts (excludes archived by default) / getBalanceAccount / createBalanceAccount (CAD-only at MVP) / updateBalanceAccount / archiveBalanceAccount / unarchiveBalanceAccount (soft delete) Typed errors via BalanceServiceError + BalanceErrorCode union so the UI can render distinct i18n messages. Domain types added under `src/shared/types/index.ts`: BalanceCategoryKind, BalanceCategory, BalanceAccount, BalanceAccountWithCategory, BALANCE_CURRENCY_CAD. 19 vitest cases cover: ordering, kind validation, seed protection, linked-account guard, currency rejection, missing-category lookup, soft delete + restore round-trip, symbol/notes normalization. Refs #138 Co-Authored-By: Claude Opus 4.7 (1M context) --- src/services/balance.service.test.ts | 316 +++++++++++++++++++++++ src/services/balance.service.ts | 360 +++++++++++++++++++++++++++ src/shared/types/index.ts | 46 ++++ 3 files changed, 722 insertions(+) create mode 100644 src/services/balance.service.test.ts create mode 100644 src/services/balance.service.ts diff --git a/src/services/balance.service.test.ts b/src/services/balance.service.test.ts new file mode 100644 index 0000000..11981aa --- /dev/null +++ b/src/services/balance.service.test.ts @@ -0,0 +1,316 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("./db", () => ({ + getDb: vi.fn(), +})); + +import { getDb } from "./db"; +import { + listBalanceCategories, + createBalanceCategory, + updateBalanceCategory, + deleteBalanceCategory, + listBalanceAccounts, + createBalanceAccount, + updateBalanceAccount, + archiveBalanceAccount, + unarchiveBalanceAccount, + BalanceServiceError, +} from "./balance.service"; + +const mockSelect = vi.fn(); +const mockExecute = vi.fn(); +const mockDb = { select: mockSelect, execute: mockExecute }; + +beforeEach(() => { + vi.mocked(getDb).mockResolvedValue(mockDb as never); + mockSelect.mockReset(); + mockExecute.mockReset(); +}); + +// ----------------------------------------------------------------------------- +// Categories +// ----------------------------------------------------------------------------- + +describe("listBalanceCategories", () => { + it("orders by sort_order then key", async () => { + mockSelect.mockResolvedValueOnce([]); + await listBalanceCategories(); + const sql = mockSelect.mock.calls[0][0] as string; + expect(sql).toContain("FROM balance_categories"); + expect(sql).toContain("ORDER BY sort_order, key"); + }); +}); + +describe("createBalanceCategory", () => { + it("rejects an empty key", async () => { + await expect( + createBalanceCategory({ key: " ", i18n_key: "x", kind: "simple" }) + ).rejects.toBeInstanceOf(BalanceServiceError); + expect(mockExecute).not.toHaveBeenCalled(); + }); + + it("rejects an invalid kind", async () => { + await expect( + createBalanceCategory({ + key: "custom", + i18n_key: "balance.category.custom", + // @ts-expect-error testing runtime guard + kind: "weird", + }) + ).rejects.toBeInstanceOf(BalanceServiceError); + expect(mockExecute).not.toHaveBeenCalled(); + }); + + it("inserts with is_seed = 0 and returns lastInsertId", async () => { + mockExecute.mockResolvedValueOnce({ lastInsertId: 42, rowsAffected: 1 }); + const id = await createBalanceCategory({ + key: "ferr", + i18n_key: "balance.category.ferr", + kind: "simple", + sort_order: 35, + }); + expect(id).toBe(42); + const sql = mockExecute.mock.calls[0][0] as string; + const params = mockExecute.mock.calls[0][1] as unknown[]; + expect(sql).toContain("INSERT INTO balance_categories"); + expect(sql).toContain("is_seed"); + expect(sql).toMatch(/0\)$/); // is_seed hardcoded to 0 + expect(params).toEqual(["ferr", "balance.category.ferr", "simple", 35]); + }); +}); + +describe("deleteBalanceCategory", () => { + it("refuses to delete a seeded category", async () => { + mockSelect.mockResolvedValueOnce([ + { + id: 1, + key: "cash", + i18n_key: "balance.category.cash", + kind: "simple", + sort_order: 10, + is_active: 1, + is_seed: 1, + }, + ]); + await expect(deleteBalanceCategory(1)).rejects.toMatchObject({ + code: "category_seed_protected", + }); + expect(mockExecute).not.toHaveBeenCalled(); + }); + + it("refuses to delete a category with linked accounts", async () => { + // 1st select = getBalanceCategory; 2nd select = COUNT(*) accounts linked + mockSelect + .mockResolvedValueOnce([ + { + id: 8, + key: "ferr", + i18n_key: "balance.category.ferr", + kind: "simple", + sort_order: 35, + is_active: 1, + is_seed: 0, + }, + ]) + .mockResolvedValueOnce([{ count: 2 }]); + await expect(deleteBalanceCategory(8)).rejects.toMatchObject({ + code: "category_has_accounts", + }); + expect(mockExecute).not.toHaveBeenCalled(); + }); + + it("deletes a user-created category with no linked accounts", async () => { + mockSelect + .mockResolvedValueOnce([ + { + id: 8, + key: "ferr", + i18n_key: "balance.category.ferr", + kind: "simple", + sort_order: 35, + is_active: 1, + is_seed: 0, + }, + ]) + .mockResolvedValueOnce([{ count: 0 }]); + mockExecute.mockResolvedValueOnce({ rowsAffected: 1 }); + await deleteBalanceCategory(8); + expect(mockExecute).toHaveBeenCalledWith( + "DELETE FROM balance_categories WHERE id = $1", + [8] + ); + }); +}); + +describe("updateBalanceCategory", () => { + it("renames a seeded category (allowed)", async () => { + mockSelect.mockResolvedValueOnce([ + { + id: 1, + key: "cash", + i18n_key: "balance.category.cash", + kind: "simple", + sort_order: 10, + is_active: 1, + is_seed: 1, + }, + ]); + mockExecute.mockResolvedValueOnce({ rowsAffected: 1 }); + await updateBalanceCategory(1, { i18n_key: "balance.category.cash_renamed" }); + const params = mockExecute.mock.calls[0][1] as unknown[]; + expect(params[0]).toBe("balance.category.cash_renamed"); + }); + + it("rejects update on missing category", async () => { + mockSelect.mockResolvedValueOnce([]); + await expect(updateBalanceCategory(999, { sort_order: 5 })).rejects.toMatchObject({ + code: "category_not_found", + }); + }); +}); + +// ----------------------------------------------------------------------------- +// Accounts +// ----------------------------------------------------------------------------- + +describe("listBalanceAccounts", () => { + it("excludes archived accounts by default", async () => { + mockSelect.mockResolvedValueOnce([]); + await listBalanceAccounts(); + const sql = mockSelect.mock.calls[0][0] as string; + expect(sql).toContain("a.is_active = 1"); + expect(sql).toContain("a.archived_at IS NULL"); + }); + + it("includes archived accounts when requested", async () => { + mockSelect.mockResolvedValueOnce([]); + await listBalanceAccounts({ includeArchived: true }); + const sql = mockSelect.mock.calls[0][0] as string; + expect(sql).not.toContain("archived_at IS NULL"); + }); +}); + +describe("createBalanceAccount", () => { + it("rejects empty name", async () => { + await expect( + createBalanceAccount({ balance_category_id: 1, name: " " }) + ).rejects.toMatchObject({ code: "name_required" }); + }); + + it("rejects non-CAD currency at the MVP", async () => { + await expect( + createBalanceAccount({ + balance_category_id: 1, + name: "USD account", + currency: "USD", + }) + ).rejects.toMatchObject({ code: "currency_unsupported" }); + expect(mockExecute).not.toHaveBeenCalled(); + }); + + it("rejects when the category does not exist", async () => { + mockSelect.mockResolvedValueOnce([]); // getBalanceCategory returns null + await expect( + createBalanceAccount({ balance_category_id: 999, name: "Mystery" }) + ).rejects.toMatchObject({ code: "category_not_found" }); + }); + + it("inserts with default CAD currency", async () => { + mockSelect.mockResolvedValueOnce([ + { + id: 1, + key: "cash", + i18n_key: "balance.category.cash", + kind: "simple", + sort_order: 10, + is_active: 1, + is_seed: 1, + }, + ]); + mockExecute.mockResolvedValueOnce({ lastInsertId: 7, rowsAffected: 1 }); + const id = await createBalanceAccount({ + balance_category_id: 1, + name: "Encaisse Wealthsimple", + }); + expect(id).toBe(7); + const params = mockExecute.mock.calls[0][1] as unknown[]; + expect(params).toEqual([1, "Encaisse Wealthsimple", null, "CAD", null]); + }); +}); + +describe("updateBalanceAccount", () => { + it("rejects when account does not exist", async () => { + mockSelect.mockResolvedValueOnce([]); + await expect(updateBalanceAccount(42, { name: "x" })).rejects.toMatchObject({ + code: "account_not_found", + }); + }); + + it("normalizes empty symbol to null", async () => { + mockSelect.mockResolvedValueOnce([ + { + id: 7, + balance_category_id: 1, + name: "Encaisse", + symbol: "OLD", + currency: "CAD", + notes: null, + is_active: 1, + archived_at: null, + created_at: "", + updated_at: "", + }, + ]); + mockExecute.mockResolvedValueOnce({ rowsAffected: 1 }); + await updateBalanceAccount(7, { symbol: " " }); + const params = mockExecute.mock.calls[0][1] as unknown[]; + expect(params[2]).toBeNull(); // symbol + }); +}); + +describe("archiveBalanceAccount / unarchiveBalanceAccount", () => { + it("archives an existing account", async () => { + mockSelect.mockResolvedValueOnce([ + { + id: 7, + balance_category_id: 1, + name: "Encaisse", + symbol: null, + currency: "CAD", + notes: null, + is_active: 1, + archived_at: null, + created_at: "", + updated_at: "", + }, + ]); + mockExecute.mockResolvedValueOnce({ rowsAffected: 1 }); + await archiveBalanceAccount(7); + const sql = mockExecute.mock.calls[0][0] as string; + expect(sql).toContain("archived_at = CURRENT_TIMESTAMP"); + expect(sql).toContain("is_active = 0"); + }); + + it("unarchives an existing account", async () => { + mockSelect.mockResolvedValueOnce([ + { + id: 7, + balance_category_id: 1, + name: "Encaisse", + symbol: null, + currency: "CAD", + notes: null, + is_active: 0, + archived_at: "2026-04-25 10:00:00", + created_at: "", + updated_at: "", + }, + ]); + mockExecute.mockResolvedValueOnce({ rowsAffected: 1 }); + await unarchiveBalanceAccount(7); + const sql = mockExecute.mock.calls[0][0] as string; + expect(sql).toContain("archived_at = NULL"); + expect(sql).toContain("is_active = 1"); + }); +}); diff --git a/src/services/balance.service.ts b/src/services/balance.service.ts new file mode 100644 index 0000000..0547670 --- /dev/null +++ b/src/services/balance.service.ts @@ -0,0 +1,360 @@ +// 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] + ); +} diff --git a/src/shared/types/index.ts b/src/shared/types/index.ts index c9dc052..004a122 100644 --- a/src/shared/types/index.ts +++ b/src/shared/types/index.ts @@ -555,3 +555,49 @@ export interface TransactionPageResult { incomeTotal: number; expenseTotal: number; } + +// --- Balance (Bilan) types --- +// Backed by migration v9 (see src-tauri/src/database/balance_schema.sql). +// MVP scope (Issue #138 / #1a): categories + accounts CRUD only. Snapshots, +// snapshot lines and transfers ship in subsequent issues (#1b / #2 / #4). + +export type BalanceCategoryKind = "simple" | "priced"; + +export const BALANCE_CURRENCY_CAD = "CAD"; + +export interface BalanceCategory { + id: number; + /** Stable lookup key (e.g. 'cash', 'tfsa', 'stock'). UNIQUE NOT NULL. */ + key: string; + /** Translation key into i18n locales (e.g. 'balance.category.cash'). */ + i18n_key: string; + /** simple = direct value entry; priced = quantity x unit_price. */ + kind: BalanceCategoryKind; + sort_order: number; + is_active: boolean; + /** True when seeded by Migration v9 — cannot be deleted, can be renamed. */ + is_seed: boolean; +} + +export interface BalanceAccount { + id: number; + balance_category_id: number; + name: string; + /** Symbol (e.g. 'AAPL', 'BTC-USD'); NULL for simple-kind accounts. */ + symbol: string | null; + /** ISO 4217. MVP: hardcoded 'CAD' (CHECK enforced server-side). */ + currency: string; + notes: string | null; + is_active: boolean; + /** Soft-delete timestamp; archived accounts hide from new snapshots. */ + archived_at: string | null; + created_at: string; + updated_at: string; +} + +/** Joined view used by AccountsPage tables. */ +export interface BalanceAccountWithCategory extends BalanceAccount { + category_key: string; + category_i18n_key: string; + category_kind: BalanceCategoryKind; +}