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>
This commit is contained in:
le king fu 2026-04-25 16:27:16 -04:00
parent 23ff8466c0
commit dafdd4ce17
3 changed files with 545 additions and 1 deletions

View file

@ -4,7 +4,17 @@ vi.mock("./db", () => ({
getDb: vi.fn(), getDb: vi.fn(),
})); }));
vi.mock("@tauri-apps/api/core", () => ({
invoke: vi.fn(),
}));
vi.mock("./profileService", () => ({
loadProfiles: vi.fn(),
}));
import { getDb } from "./db"; import { getDb } from "./db";
import { invoke } from "@tauri-apps/api/core";
import { loadProfiles } from "./profileService";
import { import {
listBalanceCategories, listBalanceCategories,
createBalanceCategory, createBalanceCategory,
@ -30,6 +40,14 @@ import {
getSnapshotTotalsByCategoryAndDate, getSnapshotTotalsByCategoryAndDate,
getAccountsLatestSnapshot, getAccountsLatestSnapshot,
getAccountsPeriodAnchor, getAccountsPeriodAnchor,
computeAccountReturn,
linkTransfer,
unlinkTransfer,
listAccountTransfers,
listLinkedTransactionIds,
listAllLinkedTransfersForTooltip,
isLinkedTransactionFkError,
suggestTransferDirection,
} from "./balance.service"; } from "./balance.service";
const mockSelect = vi.fn(); const mockSelect = vi.fn();
@ -974,3 +992,215 @@ describe("getAccountsPeriodAnchor", () => {
expect(sql).not.toMatch(/WHERE\s+s\.snapshot_date/); 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);
});
});

View file

@ -9,14 +9,20 @@
// filesystem / OAuth / license / profile work and the future Modified Dietz // filesystem / OAuth / license / profile work and the future Modified Dietz
// + price-fetch work in Issue #142. // + price-fetch work in Issue #142.
import { invoke } from "@tauri-apps/api/core";
import { getDb } from "./db"; import { getDb } from "./db";
import { loadProfiles } from "./profileService";
import type { import type {
AccountReturn,
BalanceAccount, BalanceAccount,
BalanceAccountTransfer,
BalanceAccountTransferWithTransaction,
BalanceAccountWithCategory, BalanceAccountWithCategory,
BalanceCategory, BalanceCategory,
BalanceCategoryKind, BalanceCategoryKind,
BalanceSnapshot, BalanceSnapshot,
BalanceSnapshotLine, BalanceSnapshotLine,
BalanceTransferDirection,
} from "../shared/types"; } from "../shared/types";
import { BALANCE_CURRENCY_CAD } from "../shared/types"; import { BALANCE_CURRENCY_CAD } from "../shared/types";
@ -40,7 +46,13 @@ export type BalanceErrorCode =
| "snapshot_priced_quantity_required" | "snapshot_priced_quantity_required"
| "snapshot_priced_unit_price_required" | "snapshot_priced_unit_price_required"
| "snapshot_priced_value_mismatch" | "snapshot_priced_value_mismatch"
| "snapshot_simple_must_be_scalar"; | "snapshot_simple_must_be_scalar"
// Issue #142 — transfers + returns
| "transfer_direction_invalid"
| "transfer_already_linked"
| "transfer_not_linked"
| "transfer_active_profile_unknown"
| "transaction_linked_to_balance_account";
export class BalanceServiceError extends Error { export class BalanceServiceError extends Error {
readonly code: BalanceErrorCode; readonly code: BalanceErrorCode;
@ -955,3 +967,251 @@ export async function getAccountsPeriodAnchor(
params params
); );
} }
// -----------------------------------------------------------------------------
// Returns + transfers (Issue #142 / Bilan #4)
// -----------------------------------------------------------------------------
//
// Two distinct surface areas:
//
// (1) `computeAccountReturn` — Modified Dietz return for one account over a
// period. Lives on the Rust side (`compute_account_return` Tauri command)
// because it needs to JOIN snapshots + transfers + transactions and
// apply day-precision weighting in a single short-lived connection. The
// TS shim resolves the active profile's `db_filename` from `loadProfiles`
// and forwards it to the command.
//
// (2) Transfer linking helpers — `linkTransfer`, `unlinkTransfer`,
// `listAccountTransfers`. Plain CRUD on `balance_account_transfers` via
// `getDb()`, same pattern as the rest of this file.
/**
* Compute the Modified Dietz return for `accountId` over the period
* `[periodStart, periodEnd]` (both ISO `YYYY-MM-DD`). Returns the typed
* `AccountReturn` shape see `src/shared/types/index.ts`.
*
* Resolves the active profile's `db_filename` from `loadProfiles()` so the
* caller doesn't have to thread it through every screen. Throws
* `transfer_active_profile_unknown` if no active profile is set (should be
* impossible in normal app flow, but the service guards it anyway).
*/
export async function computeAccountReturn(
accountId: number,
periodStart: string,
periodEnd: string
): Promise<AccountReturn> {
const startNorm = normalizeSnapshotDate(periodStart);
const endNorm = normalizeSnapshotDate(periodEnd);
const config = await loadProfiles();
const profile = config.profiles.find(
(p) => p.id === config.active_profile_id
);
if (!profile) {
throw new BalanceServiceError(
"transfer_active_profile_unknown",
"No active profile is set"
);
}
return invoke<AccountReturn>("compute_account_return", {
dbFilename: profile.db_filename,
accountId,
periodStart: startNorm,
periodEnd: endNorm,
});
}
function normalizeDirection(
direction: BalanceTransferDirection
): BalanceTransferDirection {
if (direction !== "in" && direction !== "out") {
throw new BalanceServiceError(
"transfer_direction_invalid",
`Invalid transfer direction: ${direction}`
);
}
return direction;
}
/**
* Suggested direction for an unlinked transaction based on its signed amount.
* Pure helper so the `LinkTransfersModal` UI can pre-fill the direction
* column without round-tripping. Convention: in this codebase, expense
* transactions are stored with negative amounts (money leaving the bank).
* From the *balance account's* perspective:
* - negative bank amount = money left the bank arrived at the balance
* account = `in`
* - positive bank amount = money entered the bank = the balance account
* gave it back = `out`
*/
export function suggestTransferDirection(
transactionAmount: number
): BalanceTransferDirection {
return transactionAmount < 0 ? "in" : "out";
}
/**
* Link a transaction to a balance account with the given direction.
* Throws `transfer_already_linked` if the (transaction, account) pair is
* already in the table (UNIQUE constraint).
*/
export async function linkTransfer(
accountId: number,
transactionId: number,
direction: BalanceTransferDirection,
notes?: string | null
): Promise<number> {
const dir = normalizeDirection(direction);
const trimmedNotes = notes ? notes.trim() || null : null;
const db = await getDb();
// Guard duplicate link with a SELECT — keeps the error typed instead of a
// raw "UNIQUE constraint failed" string.
const existing = await db.select<{ id: number }[]>(
`SELECT id FROM balance_account_transfers
WHERE account_id = $1 AND transaction_id = $2`,
[accountId, transactionId]
);
if (existing.length > 0) {
throw new BalanceServiceError(
"transfer_already_linked",
`Transaction ${transactionId} is already linked to account ${accountId}`
);
}
const result = await db.execute(
`INSERT INTO balance_account_transfers (account_id, transaction_id, direction, notes)
VALUES ($1, $2, $3, $4)`,
[accountId, transactionId, dir, trimmedNotes]
);
return result.lastInsertId as number;
}
/**
* Unlink a transaction from an account. Throws `transfer_not_linked` if the
* pair isn't in the table — keeps callers from silently no-op'ing on a stale
* UI state.
*/
export async function unlinkTransfer(
accountId: number,
transactionId: number
): Promise<void> {
const db = await getDb();
const result = await db.execute(
`DELETE FROM balance_account_transfers
WHERE account_id = $1 AND transaction_id = $2`,
[accountId, transactionId]
);
if (result.rowsAffected === 0) {
throw new BalanceServiceError(
"transfer_not_linked",
`No transfer linked transaction ${transactionId} to account ${accountId}`
);
}
}
/**
* List every linked transfer for `accountId`, joined with the transaction
* table for date/description/amount. Optional `dateRange` (ISO YYYY-MM-DD,
* inclusive both sides) filters by `transactions.date`.
*/
export async function listAccountTransfers(
accountId: number,
dateRange?: { from?: string; to?: string }
): Promise<BalanceAccountTransferWithTransaction[]> {
const params: unknown[] = [accountId];
const conditions: string[] = ["bat.account_id = $1"];
if (dateRange?.from) {
conditions.push(`t.date >= $${params.length + 1}`);
params.push(normalizeSnapshotDate(dateRange.from));
}
if (dateRange?.to) {
conditions.push(`t.date <= $${params.length + 1}`);
params.push(normalizeSnapshotDate(dateRange.to));
}
const where = `WHERE ${conditions.join(" AND ")}`;
const db = await getDb();
return db.select<BalanceAccountTransferWithTransaction[]>(
`SELECT bat.id AS id,
bat.account_id AS account_id,
bat.transaction_id AS transaction_id,
bat.direction AS direction,
bat.notes AS notes,
bat.created_at AS created_at,
t.date AS transaction_date,
t.description AS transaction_description,
t.amount AS transaction_amount,
a.name AS account_name
FROM balance_account_transfers bat
JOIN transactions t ON t.id = bat.transaction_id
JOIN balance_accounts a ON a.id = bat.account_id
${where}
ORDER BY t.date DESC, bat.id DESC`,
params
);
}
/**
* Returns the set of `transaction_id`s currently linked to ANY balance
* account. Used by the transactions table to render the transfer icon
* without an N+1 query the caller receives the full set once per render
* and does an in-memory `.has(id)` lookup. Cheap on real-world scales
* (typically < 1000 linked transfers per profile).
*/
export async function listLinkedTransactionIds(): Promise<Set<number>> {
const db = await getDb();
const rows = await db.select<{ transaction_id: number }[]>(
`SELECT DISTINCT transaction_id FROM balance_account_transfers`
);
return new Set(rows.map((r) => r.transaction_id));
}
/**
* Returns transfer info keyed by `transaction_id` for tooltip rendering in
* the transactions table. Each transaction maps to an array because a
* single transaction *could* be linked to several accounts in principle
* (the UNIQUE is on the pair, not on transaction alone).
*/
export interface LinkedTransferTooltipRow {
transaction_id: number;
account_id: number;
account_name: string;
direction: BalanceTransferDirection;
}
export async function listAllLinkedTransfersForTooltip(): Promise<
Map<number, LinkedTransferTooltipRow[]>
> {
const db = await getDb();
const rows = await db.select<LinkedTransferTooltipRow[]>(
`SELECT bat.transaction_id AS transaction_id,
bat.account_id AS account_id,
a.name AS account_name,
bat.direction AS direction
FROM balance_account_transfers bat
JOIN balance_accounts a ON a.id = bat.account_id
ORDER BY bat.transaction_id`
);
const map = new Map<number, LinkedTransferTooltipRow[]>();
for (const r of rows) {
const list = map.get(r.transaction_id) ?? [];
list.push(r);
map.set(r.transaction_id, list);
}
return map;
}
/**
* Detect whether the SQL error returned by `tauri-plugin-sql` is a FK
* RESTRICT violation from `balance_account_transfers.transaction_id`. The
* plugin surfaces the SQLite error message verbatim, so we match on the
* string. Used by `transactionService.deleteTransaction` to surface a
* clean i18n error instead of leaking the raw SQL.
*/
export function isLinkedTransactionFkError(error: unknown): boolean {
const msg = error instanceof Error ? error.message : String(error ?? "");
// SQLite FK error messages look like:
// "FOREIGN KEY constraint failed"
// or
// "code: 787, message: FOREIGN KEY constraint failed"
// Both contain the canonical "FOREIGN KEY constraint failed" substring.
return /FOREIGN KEY constraint failed/i.test(msg);
}

View file

@ -630,3 +630,57 @@ export interface BalanceSnapshotLine {
created_at: string; created_at: string;
updated_at: string; updated_at: string;
} }
// Account transfers — added Issue #142 (Bilan #4). Links a transaction to a
// balance account so the Modified Dietz return calculator can separate
// contributions from gains. Direction follows the account's perspective:
// 'in' = capital added (deposit / buy)
// 'out' = capital removed (withdrawal / sell)
// `transaction_id` ON DELETE RESTRICT — preserves reproducibility of past
// returns, the UI must force the user to unlink before deleting the
// underlying transaction.
export type BalanceTransferDirection = "in" | "out";
export interface BalanceAccountTransfer {
id: number;
account_id: number;
transaction_id: number;
direction: BalanceTransferDirection;
notes: string | null;
created_at: string;
}
/** Joined view used by LinkTransfersModal + transaction icon lookup. */
export interface BalanceAccountTransferWithTransaction
extends BalanceAccountTransfer {
transaction_date: string;
transaction_description: string;
transaction_amount: number;
account_name: string;
}
/**
* Modified Dietz return for one account over a period.
* Mirrors the Rust struct in `src-tauri/src/commands/return_calculator.rs`.
*
* - `value_start` / `value_end`: latest snapshot value each endpoint, or
* null when no snapshot exists.
* - `net_contributions`: signed sum of cash flows in the period.
* - `return_pct`: Modified Dietz return (0.05 = +5%); null if either
* endpoint is missing or denominator is non-positive.
* - `annualized_pct`: `(1 + R)^(365/T) - 1`; null for zero-length periods
* or whenever `return_pct` is null.
* - `is_partial`: true when one endpoint snapshot is missing.
* - `has_no_transfers_warning`: true when no transfers were tagged in the
* period return collapses to simple `(V_end - V_start) / V_start` and
* the UI surfaces a warning so the user can verify nothing was forgotten.
*/
export interface AccountReturn {
value_start: number | null;
value_end: number | null;
net_contributions: number;
return_pct: number | null;
annualized_pct: number | null;
is_partial: boolean;
has_no_transfers_warning: boolean;
}