Simpl-Resultat/src/services/balance.service.ts
le king fu 3963f552ae
All checks were successful
PR Check / rust (push) Successful in 23m42s
PR Check / frontend (push) Successful in 2m26s
PR Check / rust (pull_request) Successful in 22m55s
PR Check / frontend (pull_request) Successful in 2m24s
feat(balance): add asset_type column to balance_categories
Priced balance categories now carry an explicit `asset_type`
('stock' | 'crypto') so PriceFetchControl can route to the right
provider without symbol heuristics. ETH = Ethan Allen NYSE AND
Ethereum crypto are no longer ambiguous.

Migration v10 adds a nullable column and backfills the two seeded
priced categories (key='stock','crypto'). Legacy custom priced rows
stay NULL until the user edits the category — SnapshotLineRow hides
the price-fetch button when asset_type is NULL on a priced row, so
manual entry remains available.

Service-side validation rejects priced creation without asset_type
('asset_type_required') and rejects values outside ('stock','crypto')
('asset_type_invalid'). Simple kind coerces asset_type to NULL.

The CategoryVariant of AccountForm shows the selector only when
kind=priced, requires it on submit, and resets it on kind switch.
i18n keys added under balance.category.assetType.* (FR + EN).

Tests:
- 4 new Rust migration tests in lib.rs (column add, seed backfill,
  legacy row stays NULL, CHECK rejects 'gold')
- 6 new vitest cases on createBalanceCategory + listBalanceAccounts
  asserts c.asset_type AS category_asset_type in the join
- balance-flow integration test updated to pass asset_type='stock'

No new test for SnapshotLineRow render guard — project lacks
@testing-library/react + jsdom; the guard is one boolean expression
covered by manual QA per autopilot decisions in PR #167.

Fixes #169
2026-04-28 19:54:04 -04:00

1497 lines
49 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.

// 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 { invoke } from "@tauri-apps/api/core";
import { getDb } from "./db";
import { loadProfiles } from "./profileService";
import type {
AccountReturn,
BalanceAccount,
BalanceAccountTransferWithTransaction,
BalanceAccountWithCategory,
BalanceAssetType,
BalanceCategory,
BalanceCategoryKind,
BalanceSnapshot,
BalanceSnapshotLine,
BalanceTransferDirection,
} 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"
| "asset_type_required"
| "asset_type_invalid"
| "snapshot_date_required"
| "snapshot_date_taken"
| "snapshot_not_found"
| "snapshot_value_invalid"
| "snapshot_priced_unsupported"
| "snapshot_priced_quantity_required"
| "snapshot_priced_unit_price_required"
| "snapshot_priced_value_mismatch"
| "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 {
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, asset_type
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, asset_type
FROM balance_categories
WHERE id = $1`,
[id]
);
return rows[0] ?? null;
}
export interface CreateBalanceCategoryInput {
key: string;
i18n_key: string;
kind: BalanceCategoryKind;
sort_order?: number;
/**
* Required when `kind === 'priced'` (Issue #169). Drives PriceFetchControl
* provider routing (best-effort Yahoo for stocks, exchange APIs for crypto).
* For `kind === 'simple'`, the service forces this to NULL regardless of
* the input value.
*/
asset_type?: BalanceAssetType | null;
}
/**
* 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).
*/
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 assetType = normalizeAssetTypeForKind(input.kind, input.asset_type);
const db = await getDb();
const result = await db.execute(
`INSERT INTO balance_categories (key, i18n_key, kind, sort_order, is_active, is_seed, asset_type)
VALUES ($1, $2, $3, $4, 1, 0, $5)`,
[
input.key.trim(),
input.i18n_key.trim(),
input.kind,
input.sort_order ?? 0,
assetType,
]
);
return result.lastInsertId as number;
}
export interface UpdateBalanceCategoryInput {
i18n_key?: string;
sort_order?: number;
is_active?: boolean;
/**
* Allows backfilling `asset_type` on legacy priced categories created
* before migration v10. The service rejects an explicit `null` when the
* existing kind is priced (would unset a required field).
*/
asset_type?: BalanceAssetType | null;
}
/**
* 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;
const assetType =
input.asset_type !== undefined
? normalizeAssetTypeForKind(existing.kind, input.asset_type)
: existing.asset_type;
await db.execute(
`UPDATE balance_categories
SET i18n_key = $1, sort_order = $2, is_active = $3, asset_type = $4
WHERE id = $5`,
[i18n, sortOrder, isActive, assetType, id]
);
}
/**
* Coerce/validate `asset_type` against `kind`:
* - simple → always NULL (input is ignored).
* - priced → required, must be 'stock' or 'crypto'.
*/
function normalizeAssetTypeForKind(
kind: BalanceCategoryKind,
raw: BalanceAssetType | null | undefined
): BalanceAssetType | null {
if (kind === "simple") {
return null;
}
if (raw === null || raw === undefined) {
throw new BalanceServiceError(
"asset_type_required",
"asset_type is required for priced categories"
);
}
if (raw !== "stock" && raw !== "crypto") {
throw new BalanceServiceError(
"asset_type_invalid",
"asset_type must be 'stock' or 'crypto'"
);
}
return raw;
}
/**
* 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, c.asset_type AS category_asset_type
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]
);
}
// -----------------------------------------------------------------------------
// Snapshots + lines (Issue #146 / Bilan #1b — simple kind only)
// -----------------------------------------------------------------------------
//
// At Issue #146 the UI surfaces *only* simple-kind input: every line has
// `quantity = NULL` and `unit_price = NULL`. The SQL CHECK on
// `balance_snapshot_lines` already enforces the kind invariant, but
// `upsertSnapshotLines` re-validates ahead of time so a typed
// BalanceServiceError surfaces a clean i18n message instead of a raw SQL
// error. Priced-kind upsert lands in Issue #140 (Bilan #2).
const ISO_DATE_REGEX = /^\d{4}-\d{2}-\d{2}$/;
function normalizeSnapshotDate(date: string): string {
const trimmed = (date ?? "").trim();
if (!trimmed) {
throw new BalanceServiceError(
"snapshot_date_required",
"Snapshot date is required"
);
}
if (!ISO_DATE_REGEX.test(trimmed)) {
throw new BalanceServiceError(
"snapshot_date_required",
"Snapshot date must be in ISO YYYY-MM-DD format"
);
}
return trimmed;
}
export async function listSnapshots(): Promise<BalanceSnapshot[]> {
const db = await getDb();
return db.select<BalanceSnapshot[]>(
`SELECT id, snapshot_date, notes, created_at, updated_at
FROM balance_snapshots
ORDER BY snapshot_date DESC`
);
}
export async function getSnapshotByDate(
date: string
): Promise<BalanceSnapshot | null> {
const normalized = normalizeSnapshotDate(date);
const db = await getDb();
const rows = await db.select<BalanceSnapshot[]>(
`SELECT id, snapshot_date, notes, created_at, updated_at
FROM balance_snapshots
WHERE snapshot_date = $1`,
[normalized]
);
return rows[0] ?? null;
}
export async function getSnapshotById(
id: number
): Promise<BalanceSnapshot | null> {
const db = await getDb();
const rows = await db.select<BalanceSnapshot[]>(
`SELECT id, snapshot_date, notes, created_at, updated_at
FROM balance_snapshots
WHERE id = $1`,
[id]
);
return rows[0] ?? null;
}
export interface CreateSnapshotInput {
snapshot_date: string;
notes?: string | null;
}
/**
* Create a snapshot row. Throws `snapshot_date_taken` if a snapshot already
* exists at the same date so the UI can redirect to edit mode (UNIQUE
* constraint on `snapshot_date` would surface a raw SQL error otherwise).
*/
export async function createSnapshot(
input: CreateSnapshotInput
): Promise<number> {
const date = normalizeSnapshotDate(input.snapshot_date);
const existing = await getSnapshotByDate(date);
if (existing) {
throw new BalanceServiceError(
"snapshot_date_taken",
`A snapshot already exists at ${date}`
);
}
const db = await getDb();
const result = await db.execute(
`INSERT INTO balance_snapshots (snapshot_date, notes)
VALUES ($1, $2)`,
[date, input.notes ? input.notes.trim() || null : null]
);
return result.lastInsertId as number;
}
export interface UpdateSnapshotInput {
notes?: string | null;
}
/**
* Update snapshot metadata (notes only). Snapshot date is immutable once
* saved — to change the date the user deletes the snapshot and creates a
* new one (the UI exposes this as a constraint, not a feature).
*/
export async function updateSnapshot(
id: number,
input: UpdateSnapshotInput
): Promise<void> {
const existing = await getSnapshotById(id);
if (!existing) {
throw new BalanceServiceError(
"snapshot_not_found",
`Snapshot ${id} not found`
);
}
const notes =
input.notes !== undefined
? input.notes === null
? null
: input.notes.trim() || null
: existing.notes;
const db = await getDb();
await db.execute(
`UPDATE balance_snapshots
SET notes = $1, updated_at = CURRENT_TIMESTAMP
WHERE id = $2`,
[notes, id]
);
}
/**
* Delete a snapshot. ON DELETE CASCADE on `balance_snapshot_lines`
* .snapshot_id removes the lines too. The UI must double-confirm
* (re-typing the snapshot date) before invoking this.
*/
export async function deleteSnapshot(id: number): Promise<void> {
const existing = await getSnapshotById(id);
if (!existing) {
throw new BalanceServiceError(
"snapshot_not_found",
`Snapshot ${id} not found`
);
}
const db = await getDb();
await db.execute("DELETE FROM balance_snapshots WHERE id = $1", [id]);
}
export async function listLinesBySnapshot(
snapshotId: number
): Promise<BalanceSnapshotLine[]> {
const db = await getDb();
return db.select<BalanceSnapshotLine[]>(
`SELECT id, snapshot_id, account_id, quantity, unit_price, value,
price_source, price_fetched_at, created_at, updated_at
FROM balance_snapshot_lines
WHERE snapshot_id = $1
ORDER BY id`,
[snapshotId]
);
}
/**
* Tolerance ε used by the priced-kind invariant `value === quantity * unit_price`.
*
* Floating-point multiplication of decimal user input is lossy
* (`12.34 * 1.07 === 13.2038000000000002`), and the UI displays `value`
* rounded to 2 decimals while keeping quantity / unit_price at full
* precision. ε = 0.01 (one cent on the dollar) is generous enough to
* absorb that drift but tight enough to catch obvious mistakes (off by
* 10×). See decisions-log.md / Issue #140.
*/
export const PRICED_VALUE_TOLERANCE = 0.01;
export interface SnapshotLineInput {
account_id: number;
/**
* Snapshot value at this date. For priced lines this should match
* `quantity * unit_price` within `PRICED_VALUE_TOLERANCE`; the service
* validates the relation ahead of the SQL CHECK and surfaces a typed
* `snapshot_priced_value_mismatch` error otherwise.
*/
value: number;
/**
* Category kind of the underlying account. Defaults to 'simple' to
* preserve the #146 callers that don't pass it. Priced lines must
* provide both `quantity` and `unit_price`.
*/
account_kind?: BalanceCategoryKind;
/** Required for priced lines, must be NULL for simple. */
quantity?: number | null;
/** Required for priced lines, must be NULL for simple. */
unit_price?: number | null;
}
/**
* Pure helper that validates a snapshot line against its account's
* category kind. Exposed for unit tests and used by `upsertSnapshotLines`
* before any DB mutation happens.
*
* Rules:
* - simple kind → quantity AND unit_price must be NULL/undefined; value
* must be a finite number.
* - priced kind → quantity AND unit_price must be finite numbers; value
* must equal quantity × unit_price within
* `PRICED_VALUE_TOLERANCE`.
*
* @throws `BalanceServiceError` with a typed code on the first failure.
*/
export function validateLineKindInvariants(
line: SnapshotLineInput,
accountKind: BalanceCategoryKind = line.account_kind ?? "simple"
): void {
if (typeof line.value !== "number" || !Number.isFinite(line.value)) {
throw new BalanceServiceError(
"snapshot_value_invalid",
`Line for account ${line.account_id}: value must be a finite number`
);
}
if (accountKind === "simple") {
// Simple-kind: quantity / unit_price must be absent (NULL or undefined).
if (line.quantity !== undefined && line.quantity !== null) {
throw new BalanceServiceError(
"snapshot_simple_must_be_scalar",
`Line for account ${line.account_id}: simple-kind line must not carry quantity`
);
}
if (line.unit_price !== undefined && line.unit_price !== null) {
throw new BalanceServiceError(
"snapshot_simple_must_be_scalar",
`Line for account ${line.account_id}: simple-kind line must not carry unit_price`
);
}
return;
}
// Priced-kind: both fields required and finite.
if (
line.quantity === undefined ||
line.quantity === null ||
typeof line.quantity !== "number" ||
!Number.isFinite(line.quantity)
) {
throw new BalanceServiceError(
"snapshot_priced_quantity_required",
`Line for account ${line.account_id}: quantity is required for priced accounts`
);
}
if (
line.unit_price === undefined ||
line.unit_price === null ||
typeof line.unit_price !== "number" ||
!Number.isFinite(line.unit_price)
) {
throw new BalanceServiceError(
"snapshot_priced_unit_price_required",
`Line for account ${line.account_id}: unit_price is required for priced accounts`
);
}
const expected = line.quantity * line.unit_price;
if (Math.abs(expected - line.value) > PRICED_VALUE_TOLERANCE) {
throw new BalanceServiceError(
"snapshot_priced_value_mismatch",
`Line for account ${line.account_id}: value ${line.value} does not match quantity × unit_price (${expected})`
);
}
}
/**
* Upsert a batch of snapshot lines. Each input row is inserted or
* replaced atomically per account; lines for accounts not present in
* `lines` are removed from the snapshot. This makes the editor strictly
* state-driven — what the user sees is exactly what gets saved.
*
* Validation enforced ahead of time so the SQL CHECK never fires
* (`validateLineKindInvariants`):
* - simple kind → quantity / unit_price must be NULL; value must be finite.
* - priced kind → quantity / unit_price must be finite, and
* `value === quantity * unit_price` within
* `PRICED_VALUE_TOLERANCE`.
*
* The default `account_kind = 'simple'` preserves the #146 calling
* convention — callers that pre-classify their lines (which the priced
* editor in #140 must do) pass `account_kind: 'priced'` explicitly.
*/
export async function upsertSnapshotLines(
snapshotId: number,
lines: SnapshotLineInput[]
): Promise<void> {
const snapshot = await getSnapshotById(snapshotId);
if (!snapshot) {
throw new BalanceServiceError(
"snapshot_not_found",
`Snapshot ${snapshotId} not found`
);
}
// Validate every input up-front before mutating anything.
for (const line of lines) {
validateLineKindInvariants(line);
}
const db = await getDb();
// Strategy: clear and rewrite. Snapshot lines are small (one per active
// account, typically < 20) so the simplicity outweighs the diff-tracking
// savings. CASCADE guarantees consistency on partial failures.
await db.execute(
"DELETE FROM balance_snapshot_lines WHERE snapshot_id = $1",
[snapshotId]
);
for (const line of lines) {
const kind = line.account_kind ?? "simple";
if (kind === "simple") {
await db.execute(
`INSERT INTO balance_snapshot_lines
(snapshot_id, account_id, quantity, unit_price, value, price_source)
VALUES ($1, $2, NULL, NULL, $3, 'manual')`,
[snapshotId, line.account_id, line.value]
);
} else {
await db.execute(
`INSERT INTO balance_snapshot_lines
(snapshot_id, account_id, quantity, unit_price, value, price_source)
VALUES ($1, $2, $3, $4, $5, 'manual')`,
[
snapshotId,
line.account_id,
line.quantity,
line.unit_price,
line.value,
]
);
}
}
// Bump the parent snapshot's updated_at so list views can sort by recency.
await db.execute(
`UPDATE balance_snapshots
SET updated_at = CURRENT_TIMESTAMP
WHERE id = $1`,
[snapshotId]
);
}
/**
* Convenience helper used by the "Prefill from previous snapshot" button.
* Returns the snapshot whose `snapshot_date` is strictly earlier than
* `referenceDate`, or `null` if none exists.
*/
export async function getPreviousSnapshot(
referenceDate: string
): Promise<BalanceSnapshot | null> {
const normalized = normalizeSnapshotDate(referenceDate);
const db = await getDb();
const rows = await db.select<BalanceSnapshot[]>(
`SELECT id, snapshot_date, notes, created_at, updated_at
FROM balance_snapshots
WHERE snapshot_date < $1
ORDER BY snapshot_date DESC
LIMIT 1`,
[normalized]
);
return rows[0] ?? null;
}
// -----------------------------------------------------------------------------
// Time-series aggregators (Issue #141 / Bilan #3) — used by BalancePage.
// -----------------------------------------------------------------------------
/**
* Optional [from, to] range filter expressed in ISO `YYYY-MM-DD` format.
* Both endpoints are inclusive. `from` and `to` may each be omitted to leave
* that side unbounded.
*/
export interface SnapshotDateRange {
from?: string;
to?: string;
}
/** Aggregated total at a given snapshot date. */
export interface SnapshotTotalPoint {
snapshot_date: string;
total: number;
}
function buildDateRangeClause(
range: SnapshotDateRange | undefined,
baseAlias: string
): { clause: string; params: unknown[] } {
if (!range || (!range.from && !range.to)) {
return { clause: "", params: [] };
}
const parts: string[] = [];
const params: unknown[] = [];
if (range.from) {
const from = normalizeSnapshotDate(range.from);
parts.push(`${baseAlias}.snapshot_date >= $${params.length + 1}`);
params.push(from);
}
if (range.to) {
const to = normalizeSnapshotDate(range.to);
parts.push(`${baseAlias}.snapshot_date <= $${params.length + 1}`);
params.push(to);
}
return { clause: `WHERE ${parts.join(" AND ")}`, params };
}
/**
* Returns the aggregated total value of every snapshot, sorted by date ASC.
* Used by the line variant of the evolution chart on `/balance`.
*
* The aggregation is `SUM(value) GROUP BY snapshot_date` — every account
* contributing to the snapshot is summed in. Snapshots with no lines
* collapse to a `total = 0` row (preserved so the chart shows continuity).
*/
export async function getSnapshotTotalsByDate(
range?: SnapshotDateRange
): Promise<SnapshotTotalPoint[]> {
const { clause, params } = buildDateRangeClause(range, "s");
const db = await getDb();
return db.select<SnapshotTotalPoint[]>(
`SELECT s.snapshot_date AS snapshot_date,
COALESCE(SUM(l.value), 0) AS total
FROM balance_snapshots s
LEFT JOIN balance_snapshot_lines l ON l.snapshot_id = s.id
${clause}
GROUP BY s.snapshot_date
ORDER BY s.snapshot_date ASC`,
params
);
}
/** Per-snapshot breakdown by category. */
export interface SnapshotCategoryBreakdownPoint {
snapshot_date: string;
byCategory: Record<string, number>;
}
interface RawCategoryBreakdownRow {
snapshot_date: string;
category_key: string;
total: number;
}
/**
* Returns per-snapshot totals broken down by `balance_categories.key`,
* sorted by date ASC. Used by the stacked-area variant of the evolution
* chart. Categories with no value at a given date are omitted from the
* `byCategory` map (chart consumers should treat absent keys as zero).
*
* Lines whose joined account points to no category are skipped — that
* shouldn't happen given FK RESTRICT but the JOIN is defensive.
*/
export async function getSnapshotTotalsByCategoryAndDate(
range?: SnapshotDateRange
): Promise<SnapshotCategoryBreakdownPoint[]> {
const { clause, params } = buildDateRangeClause(range, "s");
const db = await getDb();
const rows = await db.select<RawCategoryBreakdownRow[]>(
`SELECT s.snapshot_date AS snapshot_date,
c.key AS category_key,
COALESCE(SUM(l.value), 0) AS total
FROM balance_snapshots s
INNER JOIN balance_snapshot_lines l ON l.snapshot_id = s.id
INNER JOIN balance_accounts a ON a.id = l.account_id
INNER JOIN balance_categories c ON c.id = a.balance_category_id
${clause}
GROUP BY s.snapshot_date, c.key
ORDER BY s.snapshot_date ASC, c.key ASC`,
params
);
// Bucket rows by snapshot_date — many rows per date, one per category.
const out: SnapshotCategoryBreakdownPoint[] = [];
let current: SnapshotCategoryBreakdownPoint | null = null;
for (const r of rows) {
if (!current || current.snapshot_date !== r.snapshot_date) {
current = { snapshot_date: r.snapshot_date, byCategory: {} };
out.push(current);
}
current.byCategory[r.category_key] = r.total;
}
return out;
}
/** Latest-snapshot value per active account (Issue #141). */
export interface AccountLatestSnapshot {
account_id: number;
account_name: string;
symbol: string | null;
balance_category_id: number;
category_key: string;
category_i18n_key: string;
category_kind: BalanceCategoryKind;
/** Date of the snapshot whose value is reported, or null if no snapshot exists. */
latest_snapshot_date: string | null;
/** Value at that snapshot, or null if the account has no snapshot lines. */
latest_value: number | null;
}
/**
* Returns one row per active (non-archived) account with the value of its
* most-recent snapshot line. Accounts with no snapshot rows yet still
* appear, with `latest_value = null`. Used by the accounts table on
* `/balance` (#141) and as a building block for the period Δ% column.
*
* Implementation: a correlated subquery picks the line with the largest
* `s.snapshot_date` for each account — SQLite handles this fine on the
* indexed `balance_snapshots.snapshot_date` and `balance_snapshot_lines.account_id`.
*/
export async function getAccountsLatestSnapshot(): Promise<
AccountLatestSnapshot[]
> {
const db = await getDb();
return db.select<AccountLatestSnapshot[]>(
`SELECT a.id AS account_id,
a.name AS account_name,
a.symbol AS symbol,
a.balance_category_id AS balance_category_id,
c.key AS category_key,
c.i18n_key AS category_i18n_key,
c.kind AS category_kind,
(SELECT s.snapshot_date
FROM balance_snapshot_lines l
JOIN balance_snapshots s ON s.id = l.snapshot_id
WHERE l.account_id = a.id
ORDER BY s.snapshot_date DESC
LIMIT 1) AS latest_snapshot_date,
(SELECT l.value
FROM balance_snapshot_lines l
JOIN balance_snapshots s ON s.id = l.snapshot_id
WHERE l.account_id = a.id
ORDER BY s.snapshot_date DESC
LIMIT 1) AS latest_value
FROM balance_accounts a
INNER JOIN balance_categories c ON c.id = a.balance_category_id
WHERE a.is_active = 1 AND a.archived_at IS NULL
ORDER BY c.sort_order, a.name`
);
}
/**
* Returns the value at the earliest snapshot for each account whose
* `snapshot_date` is `>= range.from` (and `<= range.to` when set), so the
* accounts table can compute a per-account Δ% over the selected period.
*
* Returns one row per account with a snapshot in range. Accounts without
* any snapshot in the period are omitted — callers default their Δ% to
* `null` (rendered as "—").
*/
export interface AccountPeriodAnchor {
account_id: number;
anchor_snapshot_date: string;
anchor_value: number;
}
export async function getAccountsPeriodAnchor(
range: SnapshotDateRange
): Promise<AccountPeriodAnchor[]> {
// For each account, find the earliest snapshot_date >= range.from (and
// <= range.to when set), then read that line's value.
const params: unknown[] = [];
const conditions: string[] = [];
if (range.from) {
conditions.push(`s.snapshot_date >= $${params.length + 1}`);
params.push(normalizeSnapshotDate(range.from));
}
if (range.to) {
conditions.push(`s.snapshot_date <= $${params.length + 1}`);
params.push(normalizeSnapshotDate(range.to));
}
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
const db = await getDb();
return db.select<AccountPeriodAnchor[]>(
`SELECT l.account_id AS account_id,
MIN(s.snapshot_date) AS anchor_snapshot_date,
(SELECT l2.value
FROM balance_snapshot_lines l2
JOIN balance_snapshots s2 ON s2.id = l2.snapshot_id
WHERE l2.account_id = l.account_id
AND s2.snapshot_date = MIN(s.snapshot_date)
LIMIT 1) AS anchor_value
FROM balance_snapshot_lines l
JOIN balance_snapshots s ON s.id = l.snapshot_id
${where}
GROUP BY l.account_id`,
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);
}
// -----------------------------------------------------------------------------
// Prices — fetch_price Tauri command wrapper (Issue #156 / Bilan #5)
// -----------------------------------------------------------------------------
//
// Wraps `invoke('fetch_price', { symbol, date })` with:
// - Local rate-limit (1 request / 2s via module-level timestamp)
// - In-flight deduplication (same symbol+date → one request, multiple awaiters)
// - Exponential backoff on 5xx-class errors (2/4/8s, max 3 retries)
// - No retry on 4xx errors or rate_limit (429-class)
// - Hard 100-request session cap (successful fetches only)
//
// The Rust command `fetch_price` (implemented in issue #155) rejects with a
// JSON string serialized from the Rust error enum:
// {"code":"auth"} | {"code":"rate_limit","retry_after_s":42} | ...
//
// Annexe B i18n mapping (keys live in the i18n PR #160):
// auth → balance.priceFetching.errors.authFailed
// premium_required → balance.priceFetching.errors.premiumRequired
// symbol_not_found → balance.priceFetching.errors.symbolNotFound
// rate_limit → balance.priceFetching.errors.rateLimit
// provider_unavailable → balance.priceFetching.errors.serverUnavailable
// network → balance.priceFetching.errors.serverUnavailable
// internal → balance.priceFetching.errors.serverUnavailable
// session_cap_reached → balance.priceFetching.errors.sessionCapReached
export type PriceErrorCode =
| "auth"
| "premium_required"
| "symbol_not_found"
| "rate_limit"
| "provider_unavailable"
| "network"
| "internal"
| "session_cap_reached";
export type PriceError =
| { code: "rate_limit"; retry_after_s: number; i18nKey: string }
| { code: Exclude<PriceErrorCode, "rate_limit">; i18nKey: string };
export interface PriceSuccess {
ok: true;
symbol: string;
date: string;
price: number;
currency: string;
source: string;
cached: boolean;
actual_date?: string | null;
fetched_at: string;
}
export type PriceResult =
| PriceSuccess
| { ok: false; error: PriceError };
/** Raw shape returned by the Rust `fetch_price` command on success. */
interface RawPriceResponse {
symbol: string;
date: string;
price: number;
currency: string;
source: string;
cached: boolean;
actual_date?: string | null;
fetched_at: string;
}
// i18n key map for non-rate_limit error codes.
const PRICE_ERROR_I18N_MAP: Record<Exclude<PriceErrorCode, "rate_limit">, string> = {
auth: "balance.priceFetching.errors.authFailed",
premium_required: "balance.priceFetching.errors.premiumRequired",
symbol_not_found: "balance.priceFetching.errors.symbolNotFound",
provider_unavailable: "balance.priceFetching.errors.serverUnavailable",
network: "balance.priceFetching.errors.serverUnavailable",
internal: "balance.priceFetching.errors.serverUnavailable",
session_cap_reached: "balance.priceFetching.errors.sessionCapReached",
};
/** Codes that map to no-retry behaviour (4xx-class or session cap). */
const NO_RETRY_CODES = new Set<PriceErrorCode>([
"auth",
"premium_required",
"symbol_not_found",
"rate_limit",
"session_cap_reached",
]);
/**
* Parse the string-serialized Rust error into a typed `PriceError`.
* `invoke` rejects with the value of `Result::Err(String)`, which the Rust
* side serialises via serde_json (see issue #155 worker decision).
*/
function parseRustError(e: unknown): PriceError {
if (typeof e === "string") {
try {
const j = JSON.parse(e) as Record<string, unknown>;
if (j && typeof j.code === "string") {
const code = j.code;
if (code === "rate_limit") {
const retry_after_s =
typeof j.retry_after_s === "number" ? j.retry_after_s : 0;
return {
code: "rate_limit",
retry_after_s,
i18nKey: "balance.priceFetching.errors.rateLimit",
};
}
if (code in PRICE_ERROR_I18N_MAP) {
const typedCode = code as Exclude<PriceErrorCode, "rate_limit">;
return { code: typedCode, i18nKey: PRICE_ERROR_I18N_MAP[typedCode] };
}
}
} catch {
// Fall through to default below.
}
}
return {
code: "internal",
i18nKey: PRICE_ERROR_I18N_MAP.internal,
};
}
// Module-level state — resets only when the JS module is re-imported
// (i.e. on app process restart). Tests reset via `prices.__resetForTests()`.
let _lastFiredAt = 0;
let _sessionCount = 0;
const SESSION_CAP = 100;
const MIN_INTERVAL_MS = 2000;
const _inFlight = new Map<string, Promise<PriceResult>>();
/** Enforce the 1-request-per-2s local rate limit. */
async function _enforceRateLimit(): Promise<void> {
const now = Date.now();
const wait = Math.max(0, _lastFiredAt + MIN_INTERVAL_MS - now);
if (wait > 0) {
await new Promise<void>((r) => setTimeout(r, wait));
}
_lastFiredAt = Date.now();
}
/** Single attempt: rate-limit, then invoke once. */
async function _doFetchOnce(
symbol: string,
date: string
): Promise<PriceResult> {
await _enforceRateLimit();
try {
const raw = await invoke<RawPriceResponse>("fetch_price", { symbol, date });
return { ok: true, ...raw };
} catch (e) {
return { ok: false, error: parseRustError(e) };
}
}
/** Wrap _doFetchOnce with exponential backoff on retryable errors (5xx-class). */
async function _withRetries(
symbol: string,
date: string
): Promise<PriceResult> {
const delays = [2000, 4000, 8000];
let lastResult: PriceResult | null = null;
for (let attempt = 0; attempt <= 3; attempt++) {
const r = await _doFetchOnce(symbol, date);
if (r.ok) return r;
lastResult = r;
const code = r.error.code;
if (NO_RETRY_CODES.has(code)) {
// 4xx-class: return immediately, no retry.
return r;
}
// 5xx-class (provider_unavailable, network, internal): retry with backoff.
if (attempt < 3) {
await new Promise<void>((r) => setTimeout(r, delays[attempt]));
}
}
// Should never reach here, but satisfy TypeScript.
return lastResult!;
}
/**
* `prices` namespace — entry point for the UI.
*
* All outgoing requests are rate-limited (1/2s), deduplicated in-flight, and
* wrapped with exponential backoff on 5xx-class errors. A hard session cap of
* 100 successful fetches guards against runaway loops.
*/
export const prices = {
/**
* Fetch the price for `symbol` at `date` (ISO YYYY-MM-DD).
*
* Decision (MEDIUM): the 100-session cap is checked BEFORE rate-limit and
* dedup. Successful fetches increment the counter; failures do NOT consume
* the budget — a 4xx auth error costs nothing, and a user who hits a bad
* symbol shouldn't have their session budget drained.
*/
async fetchPrice(symbol: string, date: string): Promise<PriceResult> {
if (_sessionCount >= SESSION_CAP) {
return {
ok: false,
error: {
code: "session_cap_reached",
i18nKey: PRICE_ERROR_I18N_MAP.session_cap_reached,
},
};
}
const key = `${symbol}|${date}`;
const existing = _inFlight.get(key);
if (existing) return existing;
const promise = (async () => {
try {
const result = await _withRetries(symbol, date);
if (result.ok) _sessionCount++;
return result;
} finally {
_inFlight.delete(key);
}
})();
_inFlight.set(key, promise);
return promise;
},
/**
* Reset module-level state between tests.
* Call in `beforeEach` to isolate rate-limit, session count, and in-flight map.
*/
__resetForTests(): void {
_lastFiredAt = 0;
_sessionCount = 0;
_inFlight.clear();
},
};