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>
This commit is contained in:
parent
a6787adef0
commit
58d3c86336
3 changed files with 722 additions and 0 deletions
316
src/services/balance.service.test.ts
Normal file
316
src/services/balance.service.test.ts
Normal file
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
360
src/services/balance.service.ts
Normal file
360
src/services/balance.service.ts
Normal file
|
|
@ -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<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]
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue