feat(balance): schema migration v9 + service skeleton + AccountsPage (#138) #147
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;
|
incomeTotal: number;
|
||||||
expenseTotal: 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