Simpl-Resultat/src/services/balance.service.test.ts
le king fu dafdd4ce17 feat(balance): add returns + transfers section to balance.service
Issue #142 / Bilan #4 — TS bridge for the Modified Dietz command + plain
CRUD for transfer linking.

Types (`src/shared/types/index.ts`):
- `BalanceTransferDirection` ('in' | 'out')
- `BalanceAccountTransfer` (raw row) +
  `BalanceAccountTransferWithTransaction` (joined view)
- `AccountReturn` (mirrors the Rust struct, ready to receive the invoke
  payload as-is)

Service (`src/services/balance.service.ts`):
- `computeAccountReturn(accountId, periodStart, periodEnd)`: resolves the
  active profile's `db_filename` from `loadProfiles()` and calls the
  `compute_account_return` Tauri command.
- `linkTransfer(accountId, transactionId, direction, notes?)`: INSERT
  with duplicate guard (typed `transfer_already_linked` error instead of
  raw SQL UNIQUE failure).
- `unlinkTransfer(accountId, transactionId)`: DELETE with
  `transfer_not_linked` guard for stale-UI calls.
- `listAccountTransfers(accountId, dateRange?)`: joined SELECT for
  modal/list rendering.
- `listLinkedTransactionIds()`: returns a `Set<number>` for the
  transaction icon (one query, in-memory `.has()` lookups thereafter).
- `listAllLinkedTransfersForTooltip()`: returns
  `Map<transactionId, links[]>` for tooltip rendering.
- `suggestTransferDirection(amount)`: pure helper for the modal — maps
  negative bank amounts to 'in', positive to 'out'.
- `isLinkedTransactionFkError(error)`: detects the canonical SQLite "FK
  constraint failed" text so `transactionService.deleteTransaction` can
  surface a clear i18n message.
- 5 new error codes added to `BalanceErrorCode`.

Tests (`balance.service.test.ts`): 22 new vitest cases bringing the file
to 85 passed. Mocks `@tauri-apps/api/core` `invoke` and
`./profileService` `loadProfiles`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 16:27:16 -04:00

1206 lines
39 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("./db", () => ({
getDb: vi.fn(),
}));
vi.mock("@tauri-apps/api/core", () => ({
invoke: vi.fn(),
}));
vi.mock("./profileService", () => ({
loadProfiles: vi.fn(),
}));
import { getDb } from "./db";
import { invoke } from "@tauri-apps/api/core";
import { loadProfiles } from "./profileService";
import {
listBalanceCategories,
createBalanceCategory,
updateBalanceCategory,
deleteBalanceCategory,
listBalanceAccounts,
createBalanceAccount,
updateBalanceAccount,
archiveBalanceAccount,
unarchiveBalanceAccount,
listSnapshots,
getSnapshotByDate,
createSnapshot,
updateSnapshot,
deleteSnapshot,
listLinesBySnapshot,
upsertSnapshotLines,
getPreviousSnapshot,
validateLineKindInvariants,
PRICED_VALUE_TOLERANCE,
BalanceServiceError,
getSnapshotTotalsByDate,
getSnapshotTotalsByCategoryAndDate,
getAccountsLatestSnapshot,
getAccountsPeriodAnchor,
computeAccountReturn,
linkTransfer,
unlinkTransfer,
listAccountTransfers,
listLinkedTransactionIds,
listAllLinkedTransfersForTooltip,
isLinkedTransactionFkError,
suggestTransferDirection,
} 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");
});
});
// -----------------------------------------------------------------------------
// Snapshots + lines (Issue #146 / Bilan #1b — simple kind only)
// -----------------------------------------------------------------------------
const FAKE_SNAPSHOT = {
id: 5,
snapshot_date: "2026-04-15",
notes: null,
created_at: "",
updated_at: "",
};
describe("listSnapshots", () => {
it("orders by snapshot_date DESC", async () => {
mockSelect.mockResolvedValueOnce([]);
await listSnapshots();
const sql = mockSelect.mock.calls[0][0] as string;
expect(sql).toContain("FROM balance_snapshots");
expect(sql).toContain("ORDER BY snapshot_date DESC");
});
});
describe("getSnapshotByDate", () => {
it("rejects empty / invalid dates with snapshot_date_required", async () => {
await expect(getSnapshotByDate("")).rejects.toMatchObject({
code: "snapshot_date_required",
});
await expect(getSnapshotByDate("2026/04/15")).rejects.toMatchObject({
code: "snapshot_date_required",
});
expect(mockSelect).not.toHaveBeenCalled();
});
it("returns the snapshot row when found", async () => {
mockSelect.mockResolvedValueOnce([FAKE_SNAPSHOT]);
const got = await getSnapshotByDate("2026-04-15");
expect(got).toEqual(FAKE_SNAPSHOT);
expect(mockSelect.mock.calls[0][1]).toEqual(["2026-04-15"]);
});
it("returns null when no row matches", async () => {
mockSelect.mockResolvedValueOnce([]);
expect(await getSnapshotByDate("2026-04-15")).toBeNull();
});
});
describe("createSnapshot", () => {
it("rejects an invalid date", async () => {
await expect(
createSnapshot({ snapshot_date: " " })
).rejects.toMatchObject({ code: "snapshot_date_required" });
expect(mockExecute).not.toHaveBeenCalled();
});
it("rejects a duplicate snapshot date with snapshot_date_taken", async () => {
mockSelect.mockResolvedValueOnce([FAKE_SNAPSHOT]); // existing
await expect(
createSnapshot({ snapshot_date: "2026-04-15" })
).rejects.toMatchObject({ code: "snapshot_date_taken" });
expect(mockExecute).not.toHaveBeenCalled();
});
it("inserts a new snapshot and returns its id", async () => {
mockSelect.mockResolvedValueOnce([]); // no existing
mockExecute.mockResolvedValueOnce({ lastInsertId: 12, rowsAffected: 1 });
const id = await createSnapshot({
snapshot_date: "2026-04-25",
notes: " monthly check ",
});
expect(id).toBe(12);
const params = mockExecute.mock.calls[0][1] as unknown[];
expect(params).toEqual(["2026-04-25", "monthly check"]);
});
});
describe("updateSnapshot", () => {
it("rejects when snapshot does not exist", async () => {
mockSelect.mockResolvedValueOnce([]);
await expect(
updateSnapshot(999, { notes: "x" })
).rejects.toMatchObject({ code: "snapshot_not_found" });
});
it("normalizes empty notes to null", async () => {
mockSelect.mockResolvedValueOnce([FAKE_SNAPSHOT]);
mockExecute.mockResolvedValueOnce({ rowsAffected: 1 });
await updateSnapshot(5, { notes: " " });
const params = mockExecute.mock.calls[0][1] as unknown[];
expect(params[0]).toBeNull();
});
});
describe("deleteSnapshot", () => {
it("rejects when snapshot does not exist", async () => {
mockSelect.mockResolvedValueOnce([]);
await expect(deleteSnapshot(999)).rejects.toMatchObject({
code: "snapshot_not_found",
});
});
it("deletes when found (lines cascade via FK)", async () => {
mockSelect.mockResolvedValueOnce([FAKE_SNAPSHOT]);
mockExecute.mockResolvedValueOnce({ rowsAffected: 1 });
await deleteSnapshot(5);
expect(mockExecute).toHaveBeenCalledWith(
"DELETE FROM balance_snapshots WHERE id = $1",
[5]
);
});
});
describe("listLinesBySnapshot", () => {
it("orders by id and filters by snapshot_id", async () => {
mockSelect.mockResolvedValueOnce([]);
await listLinesBySnapshot(5);
const sql = mockSelect.mock.calls[0][0] as string;
expect(sql).toContain("FROM balance_snapshot_lines");
expect(sql).toContain("WHERE snapshot_id = $1");
expect(sql).toContain("ORDER BY id");
expect(mockSelect.mock.calls[0][1]).toEqual([5]);
});
});
describe("upsertSnapshotLines (simple kind)", () => {
it("rejects when the parent snapshot is missing", async () => {
mockSelect.mockResolvedValueOnce([]);
await expect(
upsertSnapshotLines(99, [{ account_id: 1, value: 1000 }])
).rejects.toMatchObject({ code: "snapshot_not_found" });
expect(mockExecute).not.toHaveBeenCalled();
});
it("rejects non-finite values with snapshot_value_invalid", async () => {
mockSelect.mockResolvedValueOnce([FAKE_SNAPSHOT]);
await expect(
upsertSnapshotLines(5, [
{ account_id: 1, value: 1000 },
// @ts-expect-error testing runtime guard
{ account_id: 2, value: "not a number" },
])
).rejects.toMatchObject({ code: "snapshot_value_invalid" });
// Validation happens up-front, before any mutation
expect(mockExecute).not.toHaveBeenCalled();
});
it("rejects NaN and Infinity", async () => {
mockSelect.mockResolvedValueOnce([FAKE_SNAPSHOT]);
await expect(
upsertSnapshotLines(5, [{ account_id: 1, value: NaN }])
).rejects.toMatchObject({ code: "snapshot_value_invalid" });
mockSelect.mockResolvedValueOnce([FAKE_SNAPSHOT]);
await expect(
upsertSnapshotLines(5, [{ account_id: 1, value: Infinity }])
).rejects.toMatchObject({ code: "snapshot_value_invalid" });
});
it("clears existing lines, inserts each line with NULL quantity/unit_price, and bumps updated_at", async () => {
mockSelect.mockResolvedValueOnce([FAKE_SNAPSHOT]);
mockExecute
.mockResolvedValueOnce({ rowsAffected: 1 }) // delete
.mockResolvedValueOnce({ lastInsertId: 100, rowsAffected: 1 }) // insert 1
.mockResolvedValueOnce({ lastInsertId: 101, rowsAffected: 1 }) // insert 2
.mockResolvedValueOnce({ rowsAffected: 1 }); // update updated_at
await upsertSnapshotLines(5, [
{ account_id: 1, value: 1234.56 },
{ account_id: 2, value: 0 },
]);
// 1st call = DELETE
expect(mockExecute.mock.calls[0][0]).toContain(
"DELETE FROM balance_snapshot_lines"
);
// Inserts use literal NULL for quantity/unit_price (simple kind invariant)
const insertSql = mockExecute.mock.calls[1][0] as string;
expect(insertSql).toContain("INSERT INTO balance_snapshot_lines");
expect(insertSql).toMatch(/VALUES\s*\(\s*\$1,\s*\$2,\s*NULL,\s*NULL,\s*\$3/);
expect(insertSql).toContain("'manual'");
// First insert params
expect(mockExecute.mock.calls[1][1]).toEqual([5, 1, 1234.56]);
// Second insert params (zero is allowed)
expect(mockExecute.mock.calls[2][1]).toEqual([5, 2, 0]);
// Final call = UPDATE updated_at on parent snapshot
expect(mockExecute.mock.calls[3][0]).toContain(
"UPDATE balance_snapshots"
);
expect(mockExecute.mock.calls[3][0]).toContain("updated_at");
});
it("clears all lines when called with an empty array", async () => {
mockSelect.mockResolvedValueOnce([FAKE_SNAPSHOT]);
mockExecute
.mockResolvedValueOnce({ rowsAffected: 3 }) // delete only
.mockResolvedValueOnce({ rowsAffected: 1 }); // bump updated_at
await upsertSnapshotLines(5, []);
// Only DELETE + UPDATE updated_at — no INSERTs
expect(mockExecute).toHaveBeenCalledTimes(2);
});
});
describe("getPreviousSnapshot", () => {
it("returns the most recent snapshot strictly before referenceDate", async () => {
mockSelect.mockResolvedValueOnce([
{ ...FAKE_SNAPSHOT, snapshot_date: "2026-03-15" },
]);
const got = await getPreviousSnapshot("2026-04-15");
expect(got?.snapshot_date).toBe("2026-03-15");
const sql = mockSelect.mock.calls[0][0] as string;
expect(sql).toContain("snapshot_date < $1");
expect(sql).toContain("ORDER BY snapshot_date DESC");
expect(sql).toContain("LIMIT 1");
});
it("returns null when no earlier snapshot exists", async () => {
mockSelect.mockResolvedValueOnce([]);
expect(await getPreviousSnapshot("2026-04-15")).toBeNull();
});
it("rejects an invalid reference date", async () => {
await expect(getPreviousSnapshot("nope")).rejects.toMatchObject({
code: "snapshot_date_required",
});
});
});
// -----------------------------------------------------------------------------
// Priced-kind validation (Issue #140 / Bilan #2)
// -----------------------------------------------------------------------------
describe("validateLineKindInvariants — simple kind", () => {
it("accepts a clean simple line", () => {
expect(() =>
validateLineKindInvariants({ account_id: 1, value: 1234.56 })
).not.toThrow();
});
it("accepts simple kind with explicit account_kind = simple", () => {
expect(() =>
validateLineKindInvariants({
account_id: 1,
value: 0,
account_kind: "simple",
})
).not.toThrow();
});
it("rejects a simple line carrying a quantity", () => {
expect(() =>
validateLineKindInvariants({
account_id: 1,
value: 100,
account_kind: "simple",
quantity: 10,
})
).toThrowError(BalanceServiceError);
});
it("rejects a simple line carrying a unit_price", () => {
expect(() =>
validateLineKindInvariants({
account_id: 1,
value: 100,
account_kind: "simple",
unit_price: 10,
})
).toThrowError(BalanceServiceError);
});
it("rejects a non-finite value", () => {
expect(() =>
validateLineKindInvariants({ account_id: 1, value: NaN })
).toThrowError(BalanceServiceError);
expect(() =>
validateLineKindInvariants({ account_id: 1, value: Infinity })
).toThrowError(BalanceServiceError);
});
});
describe("validateLineKindInvariants — priced kind", () => {
const baseInput = {
account_id: 7,
account_kind: "priced" as const,
};
it("accepts a clean priced line where value === qty * price", () => {
expect(() =>
validateLineKindInvariants({
...baseInput,
quantity: 10,
unit_price: 25.5,
value: 255,
})
).not.toThrow();
});
it("rejects a priced line missing the quantity", () => {
expect(() =>
validateLineKindInvariants({
...baseInput,
quantity: null,
unit_price: 25.5,
value: 255,
})
).toMatchObject; // sanity, real assertion below
expect(() =>
validateLineKindInvariants({
...baseInput,
quantity: null,
unit_price: 25.5,
value: 255,
})
).toThrowError(BalanceServiceError);
try {
validateLineKindInvariants({
...baseInput,
quantity: null,
unit_price: 25.5,
value: 255,
});
} catch (e) {
expect((e as BalanceServiceError).code).toBe(
"snapshot_priced_quantity_required"
);
}
});
it("rejects a priced line missing the unit_price", () => {
try {
validateLineKindInvariants({
...baseInput,
quantity: 10,
unit_price: null,
value: 255,
});
} catch (e) {
expect((e as BalanceServiceError).code).toBe(
"snapshot_priced_unit_price_required"
);
return;
}
throw new Error("expected throw");
});
it("rejects a priced line where value disagrees with qty × price", () => {
try {
validateLineKindInvariants({
...baseInput,
quantity: 10,
unit_price: 25.5,
// off by way more than tolerance — 255.0 expected, 999 saved
value: 999,
});
} catch (e) {
expect((e as BalanceServiceError).code).toBe(
"snapshot_priced_value_mismatch"
);
return;
}
throw new Error("expected throw");
});
it("accepts a priced line within the tolerance ε", () => {
// 12.34 × 1.07 = 13.2038 in math, but JS gives 13.2038000000000002.
// The drift is well within ε = 0.01.
const qty = 12.34;
const price = 1.07;
expect(() =>
validateLineKindInvariants({
...baseInput,
quantity: qty,
unit_price: price,
value: 13.2038,
})
).not.toThrow();
});
it("rejects a priced line just outside the tolerance ε", () => {
// expected = 100, threshold ε = 0.01 → 100.011 fails, 100.005 passes.
expect(() =>
validateLineKindInvariants({
...baseInput,
quantity: 10,
unit_price: 10,
value: 100 + PRICED_VALUE_TOLERANCE * 1.5,
})
).toThrowError(BalanceServiceError);
expect(() =>
validateLineKindInvariants({
...baseInput,
quantity: 10,
unit_price: 10,
value: 100 + PRICED_VALUE_TOLERANCE * 0.5,
})
).not.toThrow();
});
it("rejects priced when quantity is non-finite", () => {
expect(() =>
validateLineKindInvariants({
...baseInput,
quantity: NaN,
unit_price: 10,
value: 100,
})
).toThrowError(BalanceServiceError);
});
});
describe("upsertSnapshotLines — priced kind", () => {
it("rejects a priced line where qty × price drifts beyond ε", async () => {
mockSelect.mockResolvedValueOnce([FAKE_SNAPSHOT]);
await expect(
upsertSnapshotLines(5, [
{
account_id: 7,
account_kind: "priced",
quantity: 10,
unit_price: 25,
value: 999, // wrong on purpose
},
])
).rejects.toMatchObject({ code: "snapshot_priced_value_mismatch" });
// No DB mutation when validation fails up-front.
expect(mockExecute).not.toHaveBeenCalled();
});
it("inserts a priced line with quantity + unit_price + value", async () => {
mockSelect.mockResolvedValueOnce([FAKE_SNAPSHOT]);
mockExecute
.mockResolvedValueOnce({ rowsAffected: 1 }) // delete
.mockResolvedValueOnce({ lastInsertId: 100, rowsAffected: 1 }) // insert
.mockResolvedValueOnce({ rowsAffected: 1 }); // bump updated_at
await upsertSnapshotLines(5, [
{
account_id: 7,
account_kind: "priced",
quantity: 10,
unit_price: 25.5,
value: 255,
},
]);
const insertSql = mockExecute.mock.calls[1][0] as string;
expect(insertSql).toContain("INSERT INTO balance_snapshot_lines");
// Priced inserts use parameter placeholders for qty/price (not literal NULLs)
expect(insertSql).toMatch(/VALUES\s*\(\s*\$1,\s*\$2,\s*\$3,\s*\$4,\s*\$5/);
expect(mockExecute.mock.calls[1][1]).toEqual([5, 7, 10, 25.5, 255]);
});
it("supports a mix of simple + priced lines in the same batch", async () => {
mockSelect.mockResolvedValueOnce([FAKE_SNAPSHOT]);
mockExecute
.mockResolvedValueOnce({ rowsAffected: 1 }) // delete
.mockResolvedValueOnce({ lastInsertId: 100, rowsAffected: 1 }) // insert simple
.mockResolvedValueOnce({ lastInsertId: 101, rowsAffected: 1 }) // insert priced
.mockResolvedValueOnce({ rowsAffected: 1 }); // bump updated_at
await upsertSnapshotLines(5, [
{ account_id: 1, value: 1000 },
{
account_id: 7,
account_kind: "priced",
quantity: 10,
unit_price: 50,
value: 500,
},
]);
// Simple insert uses literal NULLs for qty/price
expect(mockExecute.mock.calls[1][0] as string).toMatch(
/VALUES\s*\(\s*\$1,\s*\$2,\s*NULL,\s*NULL,\s*\$3/
);
expect(mockExecute.mock.calls[1][1]).toEqual([5, 1, 1000]);
// Priced insert uses placeholders
expect(mockExecute.mock.calls[2][0] as string).toMatch(
/VALUES\s*\(\s*\$1,\s*\$2,\s*\$3,\s*\$4,\s*\$5/
);
expect(mockExecute.mock.calls[2][1]).toEqual([5, 7, 10, 50, 500]);
});
});
// -----------------------------------------------------------------------------
// Time-series aggregators (Issue #141 / Bilan #3)
// -----------------------------------------------------------------------------
describe("getSnapshotTotalsByDate", () => {
it("returns an empty array on an empty DB", async () => {
mockSelect.mockResolvedValueOnce([]);
expect(await getSnapshotTotalsByDate()).toEqual([]);
});
it("aggregates SUM(value) and orders ASC by snapshot_date", async () => {
mockSelect.mockResolvedValueOnce([
{ snapshot_date: "2026-01-31", total: 1000 },
{ snapshot_date: "2026-02-28", total: 1100 },
{ snapshot_date: "2026-03-31", total: 1250 },
]);
const out = await getSnapshotTotalsByDate();
expect(out).toEqual([
{ snapshot_date: "2026-01-31", total: 1000 },
{ snapshot_date: "2026-02-28", total: 1100 },
{ snapshot_date: "2026-03-31", total: 1250 },
]);
const sql = mockSelect.mock.calls[0][0] as string;
expect(sql).toContain("FROM balance_snapshots");
expect(sql).toContain("LEFT JOIN balance_snapshot_lines");
expect(sql).toContain("GROUP BY s.snapshot_date");
expect(sql).toContain("ORDER BY s.snapshot_date ASC");
// Empty range → no WHERE clause + no params
expect(sql).not.toContain("WHERE");
expect(mockSelect.mock.calls[0][1]).toEqual([]);
});
it("applies an inclusive [from, to] date range filter", async () => {
mockSelect.mockResolvedValueOnce([]);
await getSnapshotTotalsByDate({ from: "2026-01-01", to: "2026-03-31" });
const sql = mockSelect.mock.calls[0][0] as string;
expect(sql).toContain("WHERE");
expect(sql).toContain("s.snapshot_date >=");
expect(sql).toContain("s.snapshot_date <=");
expect(mockSelect.mock.calls[0][1]).toEqual(["2026-01-01", "2026-03-31"]);
});
it("supports an open-ended `from` only", async () => {
mockSelect.mockResolvedValueOnce([]);
await getSnapshotTotalsByDate({ from: "2026-01-01" });
const sql = mockSelect.mock.calls[0][0] as string;
expect(sql).toContain("s.snapshot_date >=");
expect(sql).not.toContain("s.snapshot_date <=");
expect(mockSelect.mock.calls[0][1]).toEqual(["2026-01-01"]);
});
});
describe("getSnapshotTotalsByCategoryAndDate", () => {
it("returns [] on empty DB", async () => {
mockSelect.mockResolvedValueOnce([]);
expect(await getSnapshotTotalsByCategoryAndDate()).toEqual([]);
});
it("buckets multiple category rows under the same snapshot_date", async () => {
mockSelect.mockResolvedValueOnce([
{ snapshot_date: "2026-01-31", category_key: "cash", total: 500 },
{ snapshot_date: "2026-01-31", category_key: "tfsa", total: 1500 },
{ snapshot_date: "2026-02-28", category_key: "cash", total: 700 },
{ snapshot_date: "2026-02-28", category_key: "tfsa", total: 1700 },
]);
const out = await getSnapshotTotalsByCategoryAndDate();
expect(out).toEqual([
{
snapshot_date: "2026-01-31",
byCategory: { cash: 500, tfsa: 1500 },
},
{
snapshot_date: "2026-02-28",
byCategory: { cash: 700, tfsa: 1700 },
},
]);
const sql = mockSelect.mock.calls[0][0] as string;
expect(sql).toContain("INNER JOIN balance_snapshot_lines");
expect(sql).toContain("INNER JOIN balance_accounts");
expect(sql).toContain("INNER JOIN balance_categories");
expect(sql).toContain("GROUP BY s.snapshot_date, c.key");
});
it("applies date range params when supplied", async () => {
mockSelect.mockResolvedValueOnce([]);
await getSnapshotTotalsByCategoryAndDate({
from: "2026-01-01",
to: "2026-12-31",
});
const sql = mockSelect.mock.calls[0][0] as string;
expect(sql).toContain("WHERE");
expect(mockSelect.mock.calls[0][1]).toEqual(["2026-01-01", "2026-12-31"]);
});
});
describe("getAccountsLatestSnapshot", () => {
it("returns [] when there are no active accounts", async () => {
mockSelect.mockResolvedValueOnce([]);
expect(await getAccountsLatestSnapshot()).toEqual([]);
});
it("returns one row per active account joined with category metadata", async () => {
mockSelect.mockResolvedValueOnce([
{
account_id: 1,
account_name: "BMO chequing",
symbol: null,
balance_category_id: 10,
category_key: "cash",
category_i18n_key: "balance.category.cash",
category_kind: "simple",
latest_snapshot_date: "2026-03-31",
latest_value: 1234.56,
},
{
account_id: 2,
account_name: "Wealthsimple TFSA",
symbol: null,
balance_category_id: 11,
category_key: "tfsa",
category_i18n_key: "balance.category.tfsa",
category_kind: "simple",
latest_snapshot_date: null,
latest_value: null,
},
]);
const out = await getAccountsLatestSnapshot();
expect(out).toHaveLength(2);
expect(out[0].latest_value).toBe(1234.56);
expect(out[1].latest_value).toBeNull();
const sql = mockSelect.mock.calls[0][0] as string;
// Filter: only active, non-archived accounts.
expect(sql).toContain("a.is_active = 1");
expect(sql).toContain("a.archived_at IS NULL");
// LEFT JOIN-equivalent: scalar subquery so accounts with no lines still surface.
expect(sql).toContain("ORDER BY s.snapshot_date DESC");
expect(sql).toContain("LIMIT 1");
});
});
describe("getAccountsPeriodAnchor", () => {
it("queries with a from-only filter", async () => {
mockSelect.mockResolvedValueOnce([
{ account_id: 1, anchor_snapshot_date: "2026-01-31", anchor_value: 1000 },
]);
const rows = await getAccountsPeriodAnchor({ from: "2026-01-01" });
expect(rows).toHaveLength(1);
expect(rows[0].anchor_value).toBe(1000);
const sql = mockSelect.mock.calls[0][0] as string;
expect(sql).toContain("MIN(s.snapshot_date)");
expect(sql).toContain("GROUP BY l.account_id");
expect(mockSelect.mock.calls[0][1]).toEqual(["2026-01-01"]);
});
it("queries with both from and to", async () => {
mockSelect.mockResolvedValueOnce([]);
await getAccountsPeriodAnchor({ from: "2026-01-01", to: "2026-12-31" });
expect(mockSelect.mock.calls[0][1]).toEqual(["2026-01-01", "2026-12-31"]);
});
it("works with an empty range (open-ended)", async () => {
mockSelect.mockResolvedValueOnce([]);
await getAccountsPeriodAnchor({});
const sql = mockSelect.mock.calls[0][0] as string;
// No WHERE clause when neither bound is set.
expect(sql).not.toMatch(/WHERE\s+s\.snapshot_date/);
});
});
// -----------------------------------------------------------------------------
// Returns + transfers (Issue #142)
// -----------------------------------------------------------------------------
describe("computeAccountReturn", () => {
beforeEach(() => {
vi.mocked(loadProfiles).mockReset();
vi.mocked(invoke).mockReset();
});
it("invokes the Tauri command with the active profile's db_filename", async () => {
vi.mocked(loadProfiles).mockResolvedValueOnce({
active_profile_id: "p1",
profiles: [
{
id: "p1",
name: "Max",
color: "#fff",
pin_hash: null,
db_filename: "max.db",
created_at: "0",
},
],
});
const fakeReturn = {
value_start: 1000,
value_end: 1100,
net_contributions: 0,
return_pct: 0.1,
annualized_pct: 0.42,
is_partial: false,
has_no_transfers_warning: true,
};
vi.mocked(invoke).mockResolvedValueOnce(fakeReturn);
const out = await computeAccountReturn(7, "2026-01-01", "2026-04-01");
expect(out).toEqual(fakeReturn);
expect(invoke).toHaveBeenCalledWith("compute_account_return", {
dbFilename: "max.db",
accountId: 7,
periodStart: "2026-01-01",
periodEnd: "2026-04-01",
});
});
it("rejects malformed period dates before invoking the command", async () => {
vi.mocked(loadProfiles).mockResolvedValueOnce({
active_profile_id: "p1",
profiles: [
{
id: "p1",
name: "Max",
color: "#fff",
pin_hash: null,
db_filename: "max.db",
created_at: "0",
},
],
});
await expect(
computeAccountReturn(1, "not-a-date", "2026-04-01")
).rejects.toBeInstanceOf(BalanceServiceError);
expect(invoke).not.toHaveBeenCalled();
});
it("throws transfer_active_profile_unknown when no active profile resolves", async () => {
vi.mocked(loadProfiles).mockResolvedValueOnce({
active_profile_id: "missing",
profiles: [],
});
await expect(
computeAccountReturn(1, "2026-01-01", "2026-04-01")
).rejects.toMatchObject({ code: "transfer_active_profile_unknown" });
expect(invoke).not.toHaveBeenCalled();
});
});
describe("suggestTransferDirection", () => {
it("maps negative bank amounts to 'in' (money left bank → arrived in account)", () => {
expect(suggestTransferDirection(-100)).toBe("in");
});
it("maps positive bank amounts to 'out' (money came back from account)", () => {
expect(suggestTransferDirection(50)).toBe("out");
});
it("treats zero as 'out' as a deterministic fallback", () => {
expect(suggestTransferDirection(0)).toBe("out");
});
});
describe("linkTransfer", () => {
it("rejects an invalid direction without touching the DB", async () => {
await expect(
// @ts-expect-error testing runtime guard
linkTransfer(1, 2, "sideways")
).rejects.toBeInstanceOf(BalanceServiceError);
expect(mockExecute).not.toHaveBeenCalled();
});
it("guards against duplicate links with a typed error", async () => {
mockSelect.mockResolvedValueOnce([{ id: 5 }]);
await expect(linkTransfer(1, 2, "in")).rejects.toMatchObject({
code: "transfer_already_linked",
});
expect(mockExecute).not.toHaveBeenCalled();
});
it("inserts and returns the new transfer id", async () => {
mockSelect.mockResolvedValueOnce([]);
mockExecute.mockResolvedValueOnce({ lastInsertId: 99, rowsAffected: 1 });
const id = await linkTransfer(1, 2, "out", " manual ");
expect(id).toBe(99);
const sql = mockExecute.mock.calls[0][0] as string;
expect(sql).toContain("INSERT INTO balance_account_transfers");
expect(mockExecute.mock.calls[0][1]).toEqual([1, 2, "out", "manual"]);
});
it("normalizes empty notes to null", async () => {
mockSelect.mockResolvedValueOnce([]);
mockExecute.mockResolvedValueOnce({ lastInsertId: 1, rowsAffected: 1 });
await linkTransfer(1, 2, "in", " ");
expect(mockExecute.mock.calls[0][1][3]).toBeNull();
});
});
describe("unlinkTransfer", () => {
it("throws transfer_not_linked when no row was deleted", async () => {
mockExecute.mockResolvedValueOnce({ lastInsertId: 0, rowsAffected: 0 });
await expect(unlinkTransfer(1, 2)).rejects.toMatchObject({
code: "transfer_not_linked",
});
});
it("succeeds when one row is deleted", async () => {
mockExecute.mockResolvedValueOnce({ lastInsertId: 0, rowsAffected: 1 });
await expect(unlinkTransfer(1, 2)).resolves.toBeUndefined();
expect(mockExecute.mock.calls[0][1]).toEqual([1, 2]);
});
});
describe("listAccountTransfers", () => {
it("filters by account_id only when no date range is supplied", async () => {
mockSelect.mockResolvedValueOnce([]);
await listAccountTransfers(7);
const sql = mockSelect.mock.calls[0][0] as string;
expect(sql).toContain("FROM balance_account_transfers bat");
expect(sql).toContain("JOIN transactions t");
expect(sql).toContain("JOIN balance_accounts a");
expect(sql).toContain("WHERE bat.account_id = $1");
expect(sql).not.toContain("t.date >=");
expect(mockSelect.mock.calls[0][1]).toEqual([7]);
});
it("appends inclusive date bounds when supplied", async () => {
mockSelect.mockResolvedValueOnce([]);
await listAccountTransfers(7, { from: "2026-01-01", to: "2026-04-01" });
const sql = mockSelect.mock.calls[0][0] as string;
expect(sql).toContain("t.date >=");
expect(sql).toContain("t.date <=");
expect(mockSelect.mock.calls[0][1]).toEqual([7, "2026-01-01", "2026-04-01"]);
});
});
describe("listLinkedTransactionIds", () => {
it("returns a Set of transaction ids", async () => {
mockSelect.mockResolvedValueOnce([
{ transaction_id: 5 },
{ transaction_id: 12 },
]);
const ids = await listLinkedTransactionIds();
expect(ids).toBeInstanceOf(Set);
expect(ids.has(5)).toBe(true);
expect(ids.has(12)).toBe(true);
expect(ids.size).toBe(2);
});
});
describe("listAllLinkedTransfersForTooltip", () => {
it("groups multiple links per transaction id", async () => {
mockSelect.mockResolvedValueOnce([
{ transaction_id: 1, account_id: 10, account_name: "TFSA", direction: "in" },
{ transaction_id: 1, account_id: 20, account_name: "RRSP", direction: "out" },
{ transaction_id: 2, account_id: 10, account_name: "TFSA", direction: "in" },
]);
const map = await listAllLinkedTransfersForTooltip();
expect(map.get(1)).toHaveLength(2);
expect(map.get(2)).toHaveLength(1);
expect(map.get(1)?.[0].account_name).toBe("TFSA");
});
});
describe("isLinkedTransactionFkError", () => {
it("matches the canonical SQLite FK error text", () => {
expect(
isLinkedTransactionFkError(new Error("FOREIGN KEY constraint failed"))
).toBe(true);
});
it("matches the wrapped tauri-plugin-sql variant", () => {
expect(
isLinkedTransactionFkError(
new Error("code: 787, message: FOREIGN KEY constraint failed")
)
).toBe(true);
});
it("does not match unrelated errors", () => {
expect(isLinkedTransactionFkError(new Error("something else"))).toBe(false);
expect(isLinkedTransactionFkError(undefined)).toBe(false);
});
});