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>
360 lines
11 KiB
TypeScript
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]
|
|
);
|
|
}
|