Simpl-Resultat/src/services/balance.service.ts
le king fu 58d3c86336 feat(balance): add balance.service CRUD section + tests
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) <noreply@anthropic.com>
2026-04-25 14:33:39 -04:00

360 lines
11 KiB
TypeScript

// 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<BalanceCategory[]> {
const db = await getDb();
return db.select<BalanceCategory[]>(
`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<BalanceCategory | null> {
const db = await getDb();
const rows = await db.select<BalanceCategory[]>(
`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<number> {
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<void> {
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<void> {
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<Array<{ count: number }>>(
`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<BalanceAccountWithCategory[]> {
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<BalanceAccountWithCategory[]>(
`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<BalanceAccount | null> {
const db = await getDb();
const rows = await db.select<BalanceAccount[]>(
`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<number> {
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<void> {
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<void> {
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<void> {
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]
);
}