Simpl-Resultat/src/services/balance.service.ts
le king fu a45e5c3cd0 feat(balance): add LinkTransfersModal + return columns in accounts table
Issue #142 / Bilan #4 — UI for transfer linking + per-account returns.

- New `LinkTransfersModal.tsx`: portal modal with date-range / category /
  free-text filters, multi-select with auto-proposed direction (`in` for
  negative bank amounts, `out` for positive — flippable per row).
  Submits via sequential `linkTransfer` calls; reports per-row failures
  inline (most common case: `transfer_already_linked` on a re-submit).
- `BalanceAccountsTable.tsx`: 4 new columns rendered side-by-side —
  3M / 1A / Since-inception (Modified Dietz via `compute_account_return`)
  + Unadjusted (`(V_end - V_start) / V_start`). Returns load lazily
  after mount via `Promise.all` over (account × horizon); per-cell
  failure leaves the slot at "—" without blocking the rest of the
  table. The actions menu gains a *Link transfers* item that bubbles
  the request up to the parent page. New props:
  `sinceCreationDate` (anchors the since-inception horizon) and
  `onLinkTransfers` (modal opener).
- `BalancePage.tsx`: hosts the new modal, loads the categories list
  once on mount for the filter dropdown, fetches the union of
  `listAccountTransfers` per account so the chart can render markers,
  and threads the earliest snapshot date down to the table. Reload
  is triggered after the modal reports at least one successful link.
- `balance.service.ts`: dropped the unused `BalanceAccountTransfer`
  import to satisfy `tsc --noUnusedLocals`.

`npm run build` clean. `npm test` → 429 passed. Manual sanity check:
the table renders "…" placeholders during the per-row return load,
then resolves to either a percentage or a "—" with the partial
tooltip when the underlying snapshot endpoint is missing.

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

1216 lines
40 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,
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"
| "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
FROM balance_categories
ORDER BY sort_order, key`
);
}
export async function getBalanceCategory(
id: number
): Promise<BalanceCategory | null> {
const db = await getDb();
const rows = await db.select<BalanceCategory[]>(
`SELECT id, key, i18n_key, kind, sort_order, is_active, is_seed
FROM balance_categories
WHERE id = $1`,
[id]
);
return rows[0] ?? null;
}
export interface CreateBalanceCategoryInput {
key: string;
i18n_key: string;
kind: BalanceCategoryKind;
sort_order?: number;
}
/**
* Create a user-defined balance category. The seed categories are created by
* Migration v9 — never call this for seeded keys (UNIQUE will reject the
* insert anyway).
*
* Note (Issue #138): the AccountsPage UI restricts user-created categories to
* `kind = 'simple'`. The service still accepts both because the priced UI
* lands in Issue #140.
*/
export async function createBalanceCategory(
input: CreateBalanceCategoryInput
): Promise<number> {
if (!input.key || input.key.trim().length === 0) {
throw new BalanceServiceError("name_required", "Category key is required");
}
if (input.kind !== "simple" && input.kind !== "priced") {
throw new BalanceServiceError("kind_invalid", "Invalid category kind");
}
const db = await getDb();
const result = await db.execute(
`INSERT INTO balance_categories (key, i18n_key, kind, sort_order, is_active, is_seed)
VALUES ($1, $2, $3, $4, 1, 0)`,
[
input.key.trim(),
input.i18n_key.trim(),
input.kind,
input.sort_order ?? 0,
]
);
return result.lastInsertId as number;
}
export interface UpdateBalanceCategoryInput {
i18n_key?: string;
sort_order?: number;
is_active?: boolean;
}
/**
* Rename / re-order / toggle active state of a category. Seeded categories
* are renamable. Changing `kind` is intentionally not supported (would
* invalidate existing snapshot lines).
*/
export async function updateBalanceCategory(
id: number,
input: UpdateBalanceCategoryInput
): Promise<void> {
const existing = await getBalanceCategory(id);
if (!existing) {
throw new BalanceServiceError(
"category_not_found",
`Category ${id} not found`
);
}
const db = await getDb();
const i18n = input.i18n_key !== undefined ? input.i18n_key : existing.i18n_key;
const sortOrder =
input.sort_order !== undefined ? input.sort_order : existing.sort_order;
const isActive =
input.is_active !== undefined ? (input.is_active ? 1 : 0) : existing.is_active ? 1 : 0;
await db.execute(
`UPDATE balance_categories
SET i18n_key = $1, sort_order = $2, is_active = $3
WHERE id = $4`,
[i18n, sortOrder, isActive, id]
);
}
/**
* Delete a user-created category. Refuses to delete:
* - seeded categories (`is_seed = 1`) — UI must disable the button;
* - categories with linked accounts — FK RESTRICT would also reject, but
* we pre-check to surface a clean i18n message.
*/
export async function deleteBalanceCategory(id: number): Promise<void> {
const existing = await getBalanceCategory(id);
if (!existing) {
throw new BalanceServiceError(
"category_not_found",
`Category ${id} not found`
);
}
if (existing.is_seed) {
throw new BalanceServiceError(
"category_seed_protected",
"Seeded categories cannot be deleted"
);
}
const db = await getDb();
const linked = await db.select<Array<{ count: number }>>(
`SELECT COUNT(*) AS count FROM balance_accounts WHERE balance_category_id = $1`,
[id]
);
if ((linked[0]?.count ?? 0) > 0) {
throw new BalanceServiceError(
"category_has_accounts",
"Cannot delete a category with linked accounts"
);
}
await db.execute("DELETE FROM balance_categories WHERE id = $1", [id]);
}
// -----------------------------------------------------------------------------
// Accounts
// -----------------------------------------------------------------------------
export async function listBalanceAccounts(options?: {
includeArchived?: boolean;
}): Promise<BalanceAccountWithCategory[]> {
const includeArchived = options?.includeArchived ?? false;
const db = await getDb();
const where = includeArchived
? ""
: "WHERE a.is_active = 1 AND a.archived_at IS NULL";
return db.select<BalanceAccountWithCategory[]>(
`SELECT a.id, a.balance_category_id, a.name, a.symbol, a.currency,
a.notes, a.is_active, a.archived_at, a.created_at, a.updated_at,
c.key AS category_key, c.i18n_key AS category_i18n_key, c.kind AS category_kind
FROM balance_accounts a
INNER JOIN balance_categories c ON c.id = a.balance_category_id
${where}
ORDER BY c.sort_order, a.name`
);
}
export async function getBalanceAccount(
id: number
): Promise<BalanceAccount | null> {
const db = await getDb();
const rows = await db.select<BalanceAccount[]>(
`SELECT id, balance_category_id, name, symbol, currency, notes,
is_active, archived_at, created_at, updated_at
FROM balance_accounts
WHERE id = $1`,
[id]
);
return rows[0] ?? null;
}
export interface CreateBalanceAccountInput {
balance_category_id: number;
name: string;
symbol?: string | null;
/** Defaults to 'CAD'. MVP rejects any other value. */
currency?: string;
notes?: string | null;
}
/**
* Create an account. Currency must be 'CAD' at the MVP — the SQL CHECK
* would reject anything else, but we pre-check to surface a clean i18n
* message instead of a raw SQL error.
*/
export async function createBalanceAccount(
input: CreateBalanceAccountInput
): Promise<number> {
if (!input.name || input.name.trim().length === 0) {
throw new BalanceServiceError("name_required", "Account name is required");
}
const currency = input.currency ?? BALANCE_CURRENCY_CAD;
if (currency !== BALANCE_CURRENCY_CAD) {
throw new BalanceServiceError(
"currency_unsupported",
"Only CAD is supported at the MVP"
);
}
const cat = await getBalanceCategory(input.balance_category_id);
if (!cat) {
throw new BalanceServiceError(
"category_not_found",
"Linked balance category not found"
);
}
const db = await getDb();
const result = await db.execute(
`INSERT INTO balance_accounts (balance_category_id, name, symbol, currency, notes, is_active)
VALUES ($1, $2, $3, $4, $5, 1)`,
[
input.balance_category_id,
input.name.trim(),
input.symbol ? input.symbol.trim() : null,
currency,
input.notes ? input.notes.trim() : null,
]
);
return result.lastInsertId as number;
}
export interface UpdateBalanceAccountInput {
balance_category_id?: number;
name?: string;
symbol?: string | null;
notes?: string | null;
is_active?: boolean;
}
export async function updateBalanceAccount(
id: number,
input: UpdateBalanceAccountInput
): Promise<void> {
const existing = await getBalanceAccount(id);
if (!existing) {
throw new BalanceServiceError(
"account_not_found",
`Account ${id} not found`
);
}
const name = input.name !== undefined ? input.name.trim() : existing.name;
if (!name) {
throw new BalanceServiceError("name_required", "Account name is required");
}
const categoryId =
input.balance_category_id !== undefined
? input.balance_category_id
: existing.balance_category_id;
const symbol =
input.symbol !== undefined
? input.symbol === null
? null
: input.symbol.trim() || null
: existing.symbol;
const notes =
input.notes !== undefined
? input.notes === null
? null
: input.notes.trim() || null
: existing.notes;
const isActive =
input.is_active !== undefined
? input.is_active
? 1
: 0
: existing.is_active
? 1
: 0;
const db = await getDb();
await db.execute(
`UPDATE balance_accounts
SET balance_category_id = $1, name = $2, symbol = $3, notes = $4,
is_active = $5, updated_at = CURRENT_TIMESTAMP
WHERE id = $6`,
[categoryId, name, symbol, notes, isActive, id]
);
}
/**
* Soft-delete an account: stamp `archived_at` and set `is_active = 0`.
* Archived accounts are hidden from new snapshots but kept in the historic
* snapshot lines (which is why we never hard-delete here).
*/
export async function archiveBalanceAccount(id: number): Promise<void> {
const existing = await getBalanceAccount(id);
if (!existing) {
throw new BalanceServiceError(
"account_not_found",
`Account ${id} not found`
);
}
const db = await getDb();
await db.execute(
`UPDATE balance_accounts
SET archived_at = CURRENT_TIMESTAMP, is_active = 0,
updated_at = CURRENT_TIMESTAMP
WHERE id = $1`,
[id]
);
}
export async function unarchiveBalanceAccount(id: number): Promise<void> {
const existing = await getBalanceAccount(id);
if (!existing) {
throw new BalanceServiceError(
"account_not_found",
`Account ${id} not found`
);
}
const db = await getDb();
await db.execute(
`UPDATE balance_accounts
SET archived_at = NULL, is_active = 1,
updated_at = CURRENT_TIMESTAMP
WHERE id = $1`,
[id]
);
}
// -----------------------------------------------------------------------------
// 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);
}