Simpl-Resultat/src/services/balance.service.ts
le king fu 582cf4012d feat(balance): securities service + transactional detailed snapshot save (#212)
Service layer for detailed (per-security) balance accounts:

- findOrCreateSecurity (UPSERT on normalized UPPER(TRIM) symbol, callable
  in-txn via an executor), listSecurities, getSecurity, updateSecurity.
- saveSnapshotAtomic / upsertSnapshotLines: a detailed account (line carrying
  a holdings array) writes its aggregated line (value = rounded-cent SUM,
  qty/price NULL) AND its holdings in the SAME BEGIN/COMMIT; the line id is
  captured, existing holdings DELETEd, each security find-or-created and each
  holding INSERTed. A holding-insert failure rolls the whole save back. Simple
  / legacy-priced scalar path is unchanged. upsertSnapshotLines is now wrapped
  in an explicit transaction for the same atomicity.
- validateDetailedSnapshot: detailed+holdings => line qty/price NULL and
  value === rounded-cent SUM(holdings) compared EXACTLY (no float tolerance);
  detailed without holdings => pre-pivot aggregated tolerated.
  validateLineKindInvariants stays byte-for-byte for the scalar path.
- roundToCent helper; detailed path uses per-holding cent rounding then exact
  comparison to avoid N-holding rounding accumulation (decision 2026-06-03).
- Service backstop in updateBalanceAccount: detailed->simple rejected with a
  typed error (account_kind_detailed_has_holdings) when holdings exist; adds
  kind/detailed_since to the account input + SELECT.
- getHoldingsForLatestSnapshot (prefill; excludes quantity-0 positions),
  listHoldingsBySnapshotLine (drill-down).
- computeUnrealizedGain: per-holding and aggregated value - book_cost and %;
  book_cost = 0 OR NULL => gain % null (no divide-by-zero); NULL book_cost
  excluded from the aggregate and flagged.

Existing aggregators (getSnapshotTotalsBy*) and computeAccountReturn untouched.
Unit tests for every new function incl. casing dedup, N>=20 holdings rounding,
book_cost=0/NULL, detailed->simple guard, atomic save + rollback. Existing
upsertSnapshotLines/updateBalanceAccount tests updated for the BEGIN/COMMIT
wrapping and the kind/detailed_since columns.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 13:16:03 -04:00

2639 lines
92 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,
BalanceAccountKind,
BalanceAccountTransferWithTransaction,
BalanceAccountWithCategory,
BalanceAssetType,
BalanceCategory,
BalanceCategoryKind,
BalanceSecurity,
BalanceSnapshot,
BalanceSnapshotHolding,
BalanceSnapshotHoldingWithSecurity,
BalanceSnapshotLine,
BalanceTransferDirection,
BalanceVehicleType,
} from "../shared/types";
import { BALANCE_CURRENCY_CAD, BALANCE_VEHICLE_TYPES } 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"
| "vehicle_type_invalid"
| "snapshot_date_required"
| "snapshot_date_taken"
| "snapshot_date_exists"
| "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 #212 — securities + detailed snapshot (holdings)
| "security_symbol_required"
| "security_asset_type_invalid"
| "security_not_found"
| "snapshot_detailed_must_be_aggregate"
| "snapshot_detailed_value_mismatch"
| "snapshot_holding_invalid"
| "account_kind_invalid"
| "account_kind_detailed_has_holdings"
// 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(options?: {
includeInactive?: boolean;
}): Promise<BalanceCategory[]> {
// Default `true` keeps the data-layer change (#202) behavior-neutral: the
// existing callers see every category. The dropdown-side filtering of
// deactivated ex-envelope seeds (tfsa/rrsp after v13) is threaded by the UI
// issue (#203) via `includeInactive: false`.
const includeInactive = options?.includeInactive ?? true;
const db = await getDb();
const where = includeInactive ? "" : "WHERE is_active = 1";
return db.select<BalanceCategory[]>(
`SELECT id, key, i18n_key, kind, sort_order, is_active, is_seed, asset_type,
custom_label
FROM balance_categories
${where}
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,
custom_label
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;
/**
* Optional user-facing label override (migration v12). Empty/blank is
* normalized to NULL so the renderer falls back to `t(i18n_key)`.
*/
custom_label?: string | 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);
// Coalesce undefined → null so we never bind `undefined` to SQL on insert.
const customLabel = normalizeCustomLabel(input.custom_label) ?? null;
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, custom_label)
VALUES ($1, $2, $3, $4, 1, 0, $5, $6)`,
[
input.key.trim(),
input.i18n_key.trim(),
input.kind,
input.sort_order ?? 0,
assetType,
customLabel,
]
);
return result.lastInsertId as number;
}
/**
* Normalize a free-text label to `string | null`: trims whitespace and maps
* an empty result to NULL so the renderer falls back to `t(i18n_key)`.
* `undefined` input is preserved as `undefined` so callers can detect
* "field not provided" vs "explicitly cleared" in update flows.
*/
function normalizeCustomLabel(
raw: string | null | undefined
): string | null | undefined {
if (raw === undefined) return undefined;
if (raw === null) return null;
const trimmed = raw.trim();
return trimmed.length > 0 ? trimmed : null;
}
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;
/**
* User-facing label override (migration v12). Pass a string to set/rename,
* `null` (or blank) to clear and fall back to `t(i18n_key)`. Omit to leave
* unchanged. This is the supported way to rename a category — it never
* touches `i18n_key` (fixes bug I).
*/
custom_label?: string | 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;
const normalizedLabel = normalizeCustomLabel(input.custom_label);
const customLabel =
normalizedLabel !== undefined ? normalizedLabel : existing.custom_label ?? null;
await db.execute(
`UPDATE balance_categories
SET i18n_key = $1, sort_order = $2, is_active = $3, asset_type = $4,
custom_label = $5
WHERE id = $6`,
[i18n, sortOrder, isActive, assetType, customLabel, 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.vehicle_type,
a.kind, a.detailed_since,
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,
c.custom_label AS category_custom_label
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, vehicle_type, kind, detailed_since,
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;
/**
* Fiscal envelope / tax shelter (migration v12). Optional/nullable — NOT an
* automobile type. Validated against the enum; an out-of-enum value throws
* `vehicle_type_invalid`.
*/
vehicle_type?: BalanceVehicleType | null;
}
/**
* The six recognized fiscal envelopes. Re-uses the shared/types const so the
* service validation and the account-form dropdown (Issue #203) share one
* source of truth. Kept in sync with the SQL CHECK.
*/
const VEHICLE_TYPES = BALANCE_VEHICLE_TYPES;
/**
* Validate an optional `vehicle_type` against the fiscal-envelope enum.
* Mirrors `normalizeAssetTypeForKind`. NULL/undefined are allowed (the
* envelope is optional); any non-enum string throws `vehicle_type_invalid`.
* Returns `null` for absent input, the validated value otherwise.
*/
function normalizeVehicleType(
raw: BalanceVehicleType | null | undefined
): BalanceVehicleType | null {
if (raw === null || raw === undefined) {
return null;
}
if (!VEHICLE_TYPES.includes(raw)) {
throw new BalanceServiceError(
"vehicle_type_invalid",
`vehicle_type must be one of ${VEHICLE_TYPES.join(", ")}`
);
}
return raw;
}
/**
* 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 vehicleType = normalizeVehicleType(input.vehicle_type);
const db = await getDb();
const result = await db.execute(
`INSERT INTO balance_accounts (balance_category_id, name, symbol, currency, notes, is_active, vehicle_type)
VALUES ($1, $2, $3, $4, $5, 1, $6)`,
[
input.balance_category_id,
input.name.trim(),
input.symbol ? input.symbol.trim() : null,
currency,
input.notes ? input.notes.trim() : null,
vehicleType,
]
);
return result.lastInsertId as number;
}
export interface UpdateBalanceAccountInput {
balance_category_id?: number;
name?: string;
symbol?: string | null;
notes?: string | null;
is_active?: boolean;
/**
* Fiscal envelope / tax shelter (migration v12). Pass a value to set, `null`
* to clear, omit to leave unchanged. Validated against the enum. NOT an
* automobile type.
*/
vehicle_type?: BalanceVehicleType | null;
/**
* Entry mode (migration v15). 'simple' = one denormalized value, 'detailed' =
* per-security holdings. Pass to change, omit to leave unchanged. Switching
* `detailed → simple` is rejected with `account_kind_detailed_has_holdings`
* when the account already has holdings recorded — UI gating alone is
* insufficient (Issue #212 service backstop).
*/
kind?: BalanceAccountKind;
/**
* Authoritative pivot date (ISO YYYY-MM-DD) from which detailed entry is
* expected (migration v15). Pass a value to set, `null` to clear, omit to
* leave unchanged.
*/
detailed_since?: string | null;
}
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;
// Read-and-rewrite: when the caller omits vehicle_type, preserve the
// existing value so a full UPDATE never silently wipes the envelope.
const vehicleType =
input.vehicle_type !== undefined
? normalizeVehicleType(input.vehicle_type)
: existing.vehicle_type ?? null;
// Entry mode (migration v15). Validate against the enum; preserve when
// omitted. The detailed → simple downgrade is the dangerous transition —
// gated below.
const kind: BalanceAccountKind =
input.kind !== undefined ? input.kind : existing.kind;
if (kind !== "simple" && kind !== "detailed") {
throw new BalanceServiceError(
"account_kind_invalid",
`account kind must be 'simple' or 'detailed', got ${kind}`
);
}
const detailedSince =
input.detailed_since !== undefined
? input.detailed_since === null
? null
: normalizeSnapshotDate(input.detailed_since)
: existing.detailed_since ?? null;
// Service backstop (spec finding 🟢 TECHNIQUE): block a detailed → simple
// flip when holdings exist, otherwise they'd be orphaned (the line keeps a
// total value while the per-title rows dangle). The UI disables this too,
// but a direct service call must not bypass the invariant.
if (existing.kind === "detailed" && kind === "simple") {
const db0 = await getDb();
const held = await db0.select<Array<{ n: number }>>(
`SELECT COUNT(*) AS n
FROM balance_snapshot_holdings h
JOIN balance_snapshot_lines l ON l.id = h.snapshot_line_id
WHERE l.account_id = $1`,
[id]
);
if ((held[0]?.n ?? 0) > 0) {
throw new BalanceServiceError(
"account_kind_detailed_has_holdings",
`Account ${id} has detailed holdings and cannot be switched back to simple`
);
}
}
const db = await getDb();
await db.execute(
`UPDATE balance_accounts
SET balance_category_id = $1, name = $2, symbol = $3, notes = $4,
is_active = $5, vehicle_type = $6, kind = $7, detailed_since = $8,
updated_at = CURRENT_TIMESTAMP
WHERE id = $9`,
[categoryId, name, symbol, notes, isActive, vehicleType, kind, detailedSince, 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]
);
}
// -----------------------------------------------------------------------------
// Starter accounts (Issue #179 / Bilan onboarding)
// -----------------------------------------------------------------------------
//
// The 4 starter accounts proposed to existing profiles via StarterAccountsModal.
// New profiles get the same 4 directly via consolidated_schema.sql, so the
// names/categories MUST stay in sync between the two sources.
export interface StarterDef {
/**
* Stable identifier used by the modal checkbox state. Kept as cash/tfsa/
* rrsp/other for UI continuity even though the CELI/REER starters now
* attach to the `other` asset class (Bilan axe véhicule, Étape 1).
*/
key: "cash" | "tfsa" | "rrsp" | "other";
/** Default account name (FR — matches consolidated_schema seed). */
name: string;
/** i18n key for the user-facing label in the modal. */
i18nKey: string;
/**
* balance_categories.key (asset class) this starter attaches to. After the
* envelope/asset-class split, CELI and REER both map to `other`.
*/
categoryKey: "cash" | "other";
/**
* Fiscal envelope stamped on the created account (migration v12). NULL for
* the plain chequing / non-registered starters.
*/
vehicleType: BalanceVehicleType | null;
}
export const STARTER_ACCOUNTS: StarterDef[] = [
{
key: "cash",
name: "Compte chèque",
i18nKey: "balance.starters.items.cash",
categoryKey: "cash",
vehicleType: null,
},
{
key: "tfsa",
name: "CELI",
i18nKey: "balance.starters.items.tfsa",
categoryKey: "other",
vehicleType: "tfsa",
},
{
key: "rrsp",
name: "REER",
i18nKey: "balance.starters.items.rrsp",
categoryKey: "other",
vehicleType: "rrsp",
},
{
key: "other",
name: "Compte non-enregistré",
i18nKey: "balance.starters.items.other",
categoryKey: "other",
vehicleType: null,
},
];
/**
* Returns the set of starter keys whose proposed (name, category) already
* exists as an account on the active profile. Comparison is case-insensitive
* and trim-tolerant on the name. Used by StarterAccountsModal to disable the
* matching checkbox + render a "Déjà présent" tooltip.
*/
export async function getStarterCollisions(): Promise<Set<string>> {
const db = await getDb();
// After the envelope/asset-class split (Étape 1) the CELI/REER/non-registered
// starters all live in the `other` class and are told apart only by name, so
// the collision check matches on (categoryKey, name). The category filter is
// the union of the starters' asset classes: cash + other.
const rows = await db.select<
{ key: string; account_name: string }[]
>(
`SELECT c.key AS key, a.name AS account_name
FROM balance_accounts a
INNER JOIN balance_categories c ON c.id = a.balance_category_id
WHERE c.key IN ('cash','other')
AND a.archived_at IS NULL`
);
const collisions = new Set<string>();
for (const starter of STARTER_ACCOUNTS) {
const wanted = starter.name.trim().toLowerCase();
const hit = rows.some(
(r) =>
r.key === starter.categoryKey &&
r.account_name.trim().toLowerCase() === wanted
);
if (hit) collisions.add(starter.key);
}
return collisions;
}
/**
* Insert the selected starter accounts atomically. Resolves each starter's
* `category_id` from the seeded `balance_categories.key`. Wraps the inserts
* in BEGIN/COMMIT — on any failure ROLLBACK is issued and the original error
* is re-thrown. Returns the inserted account ids in input order.
*
* Callers SHOULD pre-filter `selectedKeys` against `getStarterCollisions()`
* to keep the UI honest, but each iteration ALSO re-checks for an existing
* (name, category) account inside the transaction and skips silently on a
* hit — a defense-in-depth guard since the table has no UNIQUE constraint
* on (name, balance_category_id). Returned ids exclude any skipped starter.
*/
export async function proposeStarterAccounts(
selectedKeys: string[]
): Promise<number[]> {
const wanted = STARTER_ACCOUNTS.filter((s) => selectedKeys.includes(s.key));
if (wanted.length === 0) return [];
const db = await getDb();
let inTxn = false;
const inserted: number[] = [];
try {
await db.execute("BEGIN");
inTxn = true;
for (const starter of wanted) {
// Resolve category id by key. Seeded keys are guaranteed to exist on
// a freshly migrated profile (Migration v9), so we surface a clean
// error if somehow missing rather than letting the FK fire. We require
// `is_active = 1` so a deactivated ex-envelope seed (tfsa/rrsp after v13)
// is never picked up — the starters now resolve to the active `other`
// class and carry the envelope in vehicle_type instead.
const catRows = await db.select<{ id: number }[]>(
`SELECT id FROM balance_categories WHERE key = $1 AND is_active = 1`,
[starter.categoryKey]
);
if (catRows.length === 0) {
throw new BalanceServiceError(
"category_not_found",
`Seeded category '${starter.categoryKey}' missing — expected v9 schema`
);
}
// Defense-in-depth: re-check collision in-txn before INSERT so we
// never create a silent duplicate even if the upstream pre-filter
// raced or was bypassed (S3 from PR #185 review).
const existing = await db.select<{ count: number }[]>(
`SELECT COUNT(*) AS count FROM balance_accounts
WHERE name = $1 AND balance_category_id = $2
AND archived_at IS NULL`,
[starter.name, catRows[0].id]
);
if ((existing[0]?.count ?? 0) > 0) {
continue;
}
const result = await db.execute(
`INSERT INTO balance_accounts (balance_category_id, name, currency, is_active, vehicle_type)
VALUES ($1, $2, 'CAD', 1, $3)`,
[catRows[0].id, starter.name, starter.vehicleType]
);
inserted.push(result.lastInsertId as number);
}
await db.execute("COMMIT");
inTxn = false;
return inserted;
} catch (e) {
if (inTxn) {
try {
await db.execute("ROLLBACK");
} catch {
// Preserve original error.
}
}
throw e;
}
}
// -----------------------------------------------------------------------------
// 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]
);
}
// -----------------------------------------------------------------------------
// Securities catalogue (Issue #212 / Bilan détail par titre — #3)
// -----------------------------------------------------------------------------
//
// `balance_securities` is a shared catalogue keyed by a NORMALIZED symbol
// (UPPER(TRIM(...))). The SQL column is already `UNIQUE COLLATE NOCASE` (v14),
// but we normalize in TS too so the stored value is canonical and the dedup is
// deterministic regardless of the caller's casing/whitespace.
/** Minimal executor surface shared by the top-level db handle and a txn. */
interface SqlExecutor {
select<T>(query: string, bindValues?: unknown[]): Promise<T>;
execute(
query: string,
bindValues?: unknown[]
): Promise<{ lastInsertId?: number; rowsAffected?: number }>;
}
/**
* Canonical symbol form: UPPER(TRIM(...)). Matches the v14/v16 SQL
* (`UPPER(TRIM(a.symbol))`) so a TS-created security and a migration-created
* one collapse to the same `balance_securities` row.
*/
export function normalizeSecuritySymbol(symbol: string): string {
return symbol.trim().toUpperCase();
}
export interface FindOrCreateSecurityInput {
symbol: string;
/** Defaults to 'CAD'. */
currency?: string;
/** 'stock' | 'crypto' — required; routes the price-fetch flow. */
asset_type: BalanceAssetType;
/** Optional human-readable name. */
name?: string | null;
}
const ASSET_TYPES: readonly BalanceAssetType[] = ["stock", "crypto"];
/**
* UPSERT a security by NORMALIZED symbol, returning its row. Idempotent: a
* second call with the same symbol (any casing) returns the existing row,
* updating `name`/`asset_type`/`currency` only when the caller passes new
* values. Callable inside an open transaction by threading `exec` (so a
* brand-new symbol can create its security then its holding atomically inside
* `saveSnapshotAtomic`); omit `exec` to use the top-level db handle.
*
* @throws `security_symbol_required` on empty symbol,
* `security_asset_type_invalid` on a non-enum asset_type.
*/
export async function findOrCreateSecurity(
input: FindOrCreateSecurityInput,
exec?: SqlExecutor
): Promise<BalanceSecurity> {
const symbol = normalizeSecuritySymbol(input.symbol ?? "");
if (symbol.length === 0) {
throw new BalanceServiceError(
"security_symbol_required",
"Security symbol is required"
);
}
if (!ASSET_TYPES.includes(input.asset_type)) {
throw new BalanceServiceError(
"security_asset_type_invalid",
`asset_type must be one of ${ASSET_TYPES.join(", ")}`
);
}
const currency = input.currency ?? BALANCE_CURRENCY_CAD;
const name = input.name ? input.name.trim() || null : null;
const db: SqlExecutor = exec ?? (await getDb());
// ON CONFLICT(symbol) DO UPDATE keeps the row's metadata fresh (e.g. a name
// arriving on a later save) while preserving its id. COALESCE keeps an
// existing name when the new payload omits one. The column is UNIQUE COLLATE
// NOCASE so the conflict target is the normalized symbol.
await db.execute(
`INSERT INTO balance_securities (symbol, name, currency, asset_type)
VALUES ($1, $2, $3, $4)
ON CONFLICT(symbol) DO UPDATE SET
name = COALESCE($2, balance_securities.name),
currency = $3,
asset_type = $4,
updated_at = CURRENT_TIMESTAMP`,
[symbol, name, currency, input.asset_type]
);
const rows = await db.select<BalanceSecurity[]>(
`SELECT id, symbol, name, currency, asset_type, created_at, updated_at
FROM balance_securities
WHERE symbol = $1`,
[symbol]
);
if (rows.length === 0) {
// Should be unreachable — the UPSERT just guaranteed the row exists.
throw new BalanceServiceError(
"security_not_found",
`Security ${symbol} not found after upsert`
);
}
return rows[0];
}
/** List every security in the catalogue, ordered by symbol. */
export async function listSecurities(): Promise<BalanceSecurity[]> {
const db = await getDb();
return db.select<BalanceSecurity[]>(
`SELECT id, symbol, name, currency, asset_type, created_at, updated_at
FROM balance_securities
ORDER BY symbol ASC`
);
}
/** Fetch one security by id, or `null` when absent. */
export async function getSecurity(id: number): Promise<BalanceSecurity | null> {
const db = await getDb();
const rows = await db.select<BalanceSecurity[]>(
`SELECT id, symbol, name, currency, asset_type, created_at, updated_at
FROM balance_securities
WHERE id = $1`,
[id]
);
return rows[0] ?? null;
}
export interface UpdateSecurityInput {
/** New normalized symbol; omit to leave unchanged. */
symbol?: string;
name?: string | null;
currency?: string;
asset_type?: BalanceAssetType;
}
/**
* Update a security's metadata. Symbol is re-normalized when provided.
* @throws `security_not_found`, `security_symbol_required`,
* `security_asset_type_invalid`.
*/
export async function updateSecurity(
id: number,
input: UpdateSecurityInput
): Promise<void> {
const existing = await getSecurity(id);
if (!existing) {
throw new BalanceServiceError(
"security_not_found",
`Security ${id} not found`
);
}
let symbol = existing.symbol;
if (input.symbol !== undefined) {
symbol = normalizeSecuritySymbol(input.symbol);
if (symbol.length === 0) {
throw new BalanceServiceError(
"security_symbol_required",
"Security symbol is required"
);
}
}
let assetType = existing.asset_type;
if (input.asset_type !== undefined) {
if (!ASSET_TYPES.includes(input.asset_type)) {
throw new BalanceServiceError(
"security_asset_type_invalid",
`asset_type must be one of ${ASSET_TYPES.join(", ")}`
);
}
assetType = input.asset_type;
}
const name =
input.name !== undefined
? input.name === null
? null
: input.name.trim() || null
: existing.name;
const currency = input.currency ?? existing.currency;
const db = await getDb();
await db.execute(
`UPDATE balance_securities
SET symbol = $1, name = $2, currency = $3, asset_type = $4,
updated_at = CURRENT_TIMESTAMP
WHERE id = $5`,
[symbol, name, currency, assetType, id]
);
}
/**
* 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;
/**
* Round a monetary amount to the cent. The detailed-snapshot invariant rounds
* every holding's value to the cent and compares the SUM **exactly** to the
* stored aggregated line value — no float tolerance (decision 2026-06-03). This
* sidesteps the rounding-accumulation problem that a single absolute ε would
* hit once N holdings are summed (see Issue #212 spec finding 🟡 SECURITE).
*/
export function roundToCent(value: number): number {
return Math.round(value * 100) / 100;
}
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;
/**
* Per-security breakdown for a `detailed` account (Issue #212). When this
* field is **present** (a defined array, even empty), the line is treated as
* detailed: the aggregated row stores the rounded-cent SUM of the holdings
* with `quantity`/`unit_price` NULL, and each holding is written to
* `balance_snapshot_holdings` in the same transaction. An EMPTY array models a
* detailed account at/just after its pivot with no positions yet entered
* (pre-pivot aggregated rows are produced WITHOUT this field — they take the
* simple path). `undefined` ⇒ not a detailed line (simple or legacy priced
* scalar path, untouched).
*/
holdings?: SnapshotHoldingInput[];
}
/**
* One position inside a `detailed` account's snapshot line (Issue #212).
* References its security by NORMALIZED symbol (+ asset_type/currency) so
* `findOrCreateSecurity` can resolve-or-create it inside the save transaction.
* Downstream #213/#214 (editor reducer + multi-title UI) produce this shape.
*/
export interface SnapshotHoldingInput {
/** Security symbol; normalized (UPPER/TRIM) by the service. */
symbol: string;
/** Asset class — required for find-or-create. */
asset_type: BalanceAssetType;
/** Defaults to 'CAD'. */
currency?: string;
/** Optional human-readable security name. */
security_name?: string | null;
quantity: number;
unit_price: number;
/** Position value (= quantity × unit_price); re-rounded to the cent server-side. */
value: number;
/** Acquisition cost basis for the unrealized-gain column; optional. */
book_cost?: number | null;
/** 'manual' | 'maximus-api'; defaults to 'manual'. */
price_source?: string | null;
price_fetched_at?: string | 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})`
);
}
}
/**
* A line is "detailed" when it carries a `holdings` array (even empty). The
* presence of the field — not the account's stored kind — drives the save
* path, so a detailed account can still write a pre-pivot aggregated row by
* simply omitting `holdings`.
*/
export function isDetailedLine(line: SnapshotLineInput): boolean {
return line.holdings !== undefined;
}
/**
* Validate a `detailed` line and its holdings (Issue #212). Companion to
* `validateLineKindInvariants` (which stays UNCHANGED for the simple/priced
* scalar path). Called ahead of any DB mutation.
*
* Rules (detail by security):
* - WITH holdings ⇒ the aggregated line MUST carry `quantity`/`unit_price`
* NULL (the total has no single qty/price) AND `line.value` MUST equal the
* rounded-cent SUM of the holdings' rounded-cent values, compared EXACTLY
* (no float tolerance — decision 2026-06-03). Each holding must have finite
* quantity/unit_price/value, a normalizable symbol and a valid asset_type.
* - WITHOUT holdings (empty array) ⇒ pre-pivot / no-position-yet aggregated
* row is tolerated: `value` only needs to be finite (validated by the
* caller's `validateLineKindInvariants` simple pass; here it's a no-op).
*
* @throws `BalanceServiceError` with a typed code on the first failure.
*/
export function validateDetailedSnapshot(line: SnapshotLineInput): void {
const holdings = line.holdings ?? [];
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`
);
}
// Pre-pivot / empty: an aggregated total with no per-title breakdown. The
// line value stands on its own; nothing further to assert.
if (holdings.length === 0) {
return;
}
// The aggregated row must not carry a scalar qty/price — those live per
// holding. Allow NULL/undefined only.
if (line.quantity !== undefined && line.quantity !== null) {
throw new BalanceServiceError(
"snapshot_detailed_must_be_aggregate",
`Line for account ${line.account_id}: detailed line must not carry a scalar quantity`
);
}
if (line.unit_price !== undefined && line.unit_price !== null) {
throw new BalanceServiceError(
"snapshot_detailed_must_be_aggregate",
`Line for account ${line.account_id}: detailed line must not carry a scalar unit_price`
);
}
let centSum = 0;
for (const h of holdings) {
if (typeof h.symbol !== "string" || normalizeSecuritySymbol(h.symbol) === "") {
throw new BalanceServiceError(
"snapshot_holding_invalid",
`Line for account ${line.account_id}: holding symbol is required`
);
}
if (!ASSET_TYPES.includes(h.asset_type)) {
throw new BalanceServiceError(
"snapshot_holding_invalid",
`Line for account ${line.account_id}: holding ${h.symbol} has invalid asset_type`
);
}
if (typeof h.quantity !== "number" || !Number.isFinite(h.quantity)) {
throw new BalanceServiceError(
"snapshot_holding_invalid",
`Line for account ${line.account_id}: holding ${h.symbol} quantity must be finite`
);
}
if (typeof h.unit_price !== "number" || !Number.isFinite(h.unit_price)) {
throw new BalanceServiceError(
"snapshot_holding_invalid",
`Line for account ${line.account_id}: holding ${h.symbol} unit_price must be finite`
);
}
if (typeof h.value !== "number" || !Number.isFinite(h.value)) {
throw new BalanceServiceError(
"snapshot_holding_invalid",
`Line for account ${line.account_id}: holding ${h.symbol} value must be finite`
);
}
centSum += roundToCent(h.value);
}
// Compare the rounded-cent SUM exactly against the rounded-cent line total.
// Both sides are rounded so the equality is on whole cents — no ε.
const expected = roundToCent(centSum);
if (roundToCent(line.value) !== expected) {
throw new BalanceServiceError(
"snapshot_detailed_value_mismatch",
`Line for account ${line.account_id}: value ${line.value} does not match rounded-cent SUM of holdings (${expected})`
);
}
}
/**
* Validate every input line ahead of any DB mutation. Detailed lines (those
* carrying a `holdings` field) go through `validateDetailedSnapshot`; all
* others keep the unchanged `validateLineKindInvariants` scalar pass. Shared by
* `upsertSnapshotLines` and `saveSnapshotAtomic` so the two save entry points
* never diverge.
*/
function validateAllLines(lines: SnapshotLineInput[]): void {
for (const line of lines) {
if (isDetailedLine(line)) {
validateDetailedSnapshot(line);
} else {
validateLineKindInvariants(line);
}
}
}
/**
* Insert one snapshot line and, for a detailed line, rewrite its holdings in
* the SAME executor (transaction). Returns the inserted line id.
*
* Detailed path: the aggregated row stores the rounded-cent SUM of the
* holdings (qty/price NULL); after capturing its `lastInsertId`, existing
* holdings for that line are DELETEd (defensive — a fresh line has none), then
* each holding's security is found-or-created and the holding INSERTed. The
* server-side total is recomputed from the rounded-cent SUM so the line's
* `value` is authoritative regardless of the caller's arithmetic.
*
* Simple / legacy-priced scalar path: unchanged — one INSERT, no holdings.
*/
async function insertSnapshotLineWithHoldings(
exec: SqlExecutor,
snapshotId: number,
line: SnapshotLineInput
): Promise<number> {
if (isDetailedLine(line)) {
const holdings = line.holdings ?? [];
// Recompute the aggregated total server-side from the rounded-cent SUM —
// the stored line value is the source of truth for the aggregators.
const total = roundToCent(
holdings.reduce((acc, h) => acc + roundToCent(h.value), 0)
);
const lineRes = await exec.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, total]
);
const lineId = lineRes.lastInsertId as number;
// Defensive clear — a freshly inserted line can't have holdings yet, but
// this keeps the helper safe if it's ever reused on an existing line.
await exec.execute(
`DELETE FROM balance_snapshot_holdings WHERE snapshot_line_id = $1`,
[lineId]
);
for (const h of holdings) {
const security = await findOrCreateSecurity(
{
symbol: h.symbol,
asset_type: h.asset_type,
currency: h.currency,
name: h.security_name,
},
exec
);
await exec.execute(
`INSERT INTO balance_snapshot_holdings
(snapshot_line_id, security_id, quantity, unit_price, value,
book_cost, price_source, price_fetched_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
[
lineId,
security.id,
h.quantity,
h.unit_price,
roundToCent(h.value),
h.book_cost ?? null,
h.price_source ?? "manual",
h.price_fetched_at ?? null,
]
);
}
return lineId;
}
// Simple / legacy priced scalar line — unchanged behaviour.
const kind = line.account_kind ?? "simple";
if (kind === "simple") {
const res = await exec.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]
);
return res.lastInsertId as number;
}
const res = await exec.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]
);
return res.lastInsertId as number;
}
/**
* 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 (detailed lines go
// through validateDetailedSnapshot, scalar lines through the unchanged pass).
validateAllLines(lines);
const db = await getDb();
// Strategy: clear and rewrite, wrapped in an explicit transaction so the
// line + holdings writes commit together. Snapshot lines are small (one per
// active account, typically < 20). CASCADE on snapshot_line_id wipes the old
// holdings when their parent line is DELETEd here, so a detailed account's
// per-title rows never outlive their line.
let inTxn = false;
try {
await db.execute("BEGIN");
inTxn = true;
await db.execute(
"DELETE FROM balance_snapshot_lines WHERE snapshot_id = $1",
[snapshotId]
);
for (const line of lines) {
await insertSnapshotLineWithHoldings(db, snapshotId, line);
}
// 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]
);
await db.execute("COMMIT");
inTxn = false;
} catch (e) {
if (inTxn) {
try {
await db.execute("ROLLBACK");
} catch {
// Defensive: preserve the original error over a rollback failure.
}
}
throw e;
}
}
/**
* Atomic snapshot save (#176). Wraps `INSERT INTO balance_snapshots` and
* the line writes in a single explicit BEGIN/COMMIT transaction so a
* failure during line validation or insertion never leaves an orphan
* snapshot row behind (which used to wedge subsequent saves at the same
* date through the `snapshot_date_taken` UNIQUE constraint).
*
* Caller contract:
* - All `lines` MUST already be validated by the caller — this function
* does NOT translate string inputs to numbers; it expects the same
* `SnapshotLineInput` shape that `upsertSnapshotLines` accepts.
* - The caller passes `existingSnapshotId` for edit-mode (no INSERT
* happens, only the line rewrite). For new-mode pass `null` and a
* `snapshot_date`; this function handles both cases inside the same
* transaction.
*
* On any error, ROLLBACK is issued and the original error is re-thrown.
* If ROLLBACK itself fails (e.g. transaction never opened), that error is
* swallowed and the original is preserved — the caller never sees a
* misleading rollback error.
*
* Edit-mode date move (Issue #200): pass `moveToDate` with the snapshot's
* NEW date when the user changed it in edit mode. The date move + line
* rewrite happen in the same transaction, so a collision on `moveToDate`
* (another snapshot already there) rolls the whole save back and surfaces
* `snapshot_date_exists`. Passing `moveToDate` in new mode is ignored — the
* date is taken from `snapshot_date` on INSERT there.
*/
export async function saveSnapshotAtomic(input: {
existingSnapshotId: number | null;
snapshot_date: string;
notes?: string | null;
lines: SnapshotLineInput[];
/** New date for an edit-mode move; omit when the date is unchanged. */
moveToDate?: string | null;
}): Promise<{ snapshotId: number }> {
// Validate every line ahead of time so the transaction never opens for
// a doomed save. Detailed lines go through validateDetailedSnapshot; scalar
// lines keep the unchanged validateLineKindInvariants pass.
validateAllLines(input.lines);
const db = await getDb();
let inTxn = false;
try {
await db.execute("BEGIN");
inTxn = true;
let snapshotId: number;
if (input.existingSnapshotId !== null) {
snapshotId = input.existingSnapshotId;
// Edit-mode date move (#200): if a new date was requested, re-check the
// UNIQUE constraint in-txn and update `snapshot_date`. Done before the
// line rewrite so a collision rolls the entire save back.
if (input.moveToDate != null) {
const moveTo = normalizeSnapshotDate(input.moveToDate);
const clash = await db.select<Array<{ id: number }>>(
`SELECT id FROM balance_snapshots
WHERE snapshot_date = $1 AND id <> $2`,
[moveTo, snapshotId]
);
if (clash.length > 0) {
throw new BalanceServiceError(
"snapshot_date_exists",
`Another snapshot already exists at ${moveTo}`
);
}
await db.execute(
`UPDATE balance_snapshots
SET snapshot_date = $1, updated_at = CURRENT_TIMESTAMP
WHERE id = $2`,
[moveTo, snapshotId]
);
}
} else {
const date = normalizeSnapshotDate(input.snapshot_date);
// Date collision check inside the transaction so a concurrent
// insert can't sneak between the SELECT and the INSERT.
const dup = await db.select<Array<{ id: number }>>(
`SELECT id FROM balance_snapshots WHERE snapshot_date = $1`,
[date]
);
if (dup.length > 0) {
throw new BalanceServiceError(
"snapshot_date_taken",
`A snapshot already exists at ${date}`
);
}
const insRes = await db.execute(
`INSERT INTO balance_snapshots (snapshot_date, notes)
VALUES ($1, $2)`,
[date, input.notes ? input.notes.trim() || null : null]
);
snapshotId = insRes.lastInsertId as number;
}
// Rewrite-all strategy (matches `upsertSnapshotLines`): clear
// existing lines, then re-insert every line + its holdings. Cheap
// because snapshot line counts are small. A detailed line writes its
// aggregated row and its holdings via the shared helper, all inside this
// same BEGIN/COMMIT — a holding INSERT failure rolls the whole save back.
await db.execute(
"DELETE FROM balance_snapshot_lines WHERE snapshot_id = $1",
[snapshotId]
);
for (const line of input.lines) {
await insertSnapshotLineWithHoldings(db, snapshotId, line);
}
await db.execute(
`UPDATE balance_snapshots
SET updated_at = CURRENT_TIMESTAMP
WHERE id = $1`,
[snapshotId]
);
await db.execute("COMMIT");
inTxn = false;
return { snapshotId };
} catch (e) {
if (inTxn) {
try {
await db.execute("ROLLBACK");
} catch {
// Defensive: if ROLLBACK fails we still want the caller to see
// the original error, not the rollback error.
}
}
throw e;
}
}
/**
* 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;
}
/** Sentinel bucket key for accounts with no fiscal envelope (NULL vehicle_type). */
export const VEHICLE_NONE_BUCKET = "none";
/** Per-snapshot breakdown by fiscal envelope (`vehicle_type`). */
export interface SnapshotVehicleBreakdownPoint {
snapshot_date: string;
/** Keyed by vehicle_type code, with the `'none'` bucket for NULL envelopes. */
byVehicle: Record<string, number>;
}
interface RawVehicleBreakdownRow {
snapshot_date: string;
vehicle_key: string;
total: number;
}
/**
* Returns per-snapshot totals broken down by `balance_accounts.vehicle_type`,
* sorted by date ASC. Mirror of `getSnapshotTotalsByCategoryAndDate` for the
* "par enveloppe" axis of the stacked-area chart (Issue #204 / Étape 1).
*
* ⚠️ `vehicle_type` is NULLABLE — accounts with no envelope are grouped under
* a single `'none'` bucket via `COALESCE(a.vehicle_type, 'none')`, never a SQL
* NULL key (which would collapse to a `null` object key on the JS side). The
* `'none'` bucket is labelled with `balance.vehicle.none` in the UI.
*
* Vehicles with no value at a given date are omitted from the `byVehicle` map
* (chart consumers treat absent keys as zero).
*/
export async function getSnapshotTotalsByVehicleAndDate(
range?: SnapshotDateRange
): Promise<SnapshotVehicleBreakdownPoint[]> {
const { clause, params } = buildDateRangeClause(range, "s");
const db = await getDb();
const rows = await db.select<RawVehicleBreakdownRow[]>(
`SELECT s.snapshot_date AS snapshot_date,
COALESCE(a.vehicle_type, 'none') AS vehicle_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
${clause}
GROUP BY s.snapshot_date, COALESCE(a.vehicle_type, 'none')
ORDER BY s.snapshot_date ASC, vehicle_key ASC`,
params
);
// Bucket rows by snapshot_date — many rows per date, one per vehicle.
const out: SnapshotVehicleBreakdownPoint[] = [];
let current: SnapshotVehicleBreakdownPoint | null = null;
for (const r of rows) {
if (!current || current.snapshot_date !== r.snapshot_date) {
current = { snapshot_date: r.snapshot_date, byVehicle: {} };
out.push(current);
}
current.byVehicle[r.vehicle_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;
/** Mirror of `balance_categories.custom_label` — drives renderCategoryLabel. */
category_custom_label?: string | null;
/**
* Fiscal envelope of the account (`vehicle_type`), or NULL when none.
* Surfaced for the "par enveloppe" axis groupings (Issue #204).
*/
vehicle_type?: BalanceVehicleType | null;
/** 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,
c.custom_label AS category_custom_label,
a.vehicle_type AS vehicle_type,
(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.
//
// We use a ROW_NUMBER() window function partitioned by account_id and
// ordered by snapshot_date ASC, then keep only rn = 1 per account. This
// avoids the previous "MIN(s.snapshot_date) inside a scalar subquery
// WHERE" pattern, which SQLite rejects with "misuse of aggregate function
// MIN()" (issue #175).
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 account_id,
snapshot_date AS anchor_snapshot_date,
value AS anchor_value
FROM (
SELECT l.account_id AS account_id,
s.snapshot_date AS snapshot_date,
l.value AS value,
ROW_NUMBER() OVER (
PARTITION BY l.account_id
ORDER BY s.snapshot_date ASC
) AS rn
FROM balance_snapshot_lines l
JOIN balance_snapshots s ON s.id = l.snapshot_id
${where}
)
WHERE rn = 1`,
params
);
}
// -----------------------------------------------------------------------------
// Holdings reads + unrealized gain (Issue #212 / Bilan détail par titre — #3)
// -----------------------------------------------------------------------------
/**
* Drill-down read: every holding of a single snapshot line, joined with its
* security for display (symbol / name / asset_type). Ordered by symbol.
*/
export async function listHoldingsBySnapshotLine(
lineId: number
): Promise<BalanceSnapshotHoldingWithSecurity[]> {
const db = await getDb();
return db.select<BalanceSnapshotHoldingWithSecurity[]>(
`SELECT h.id, h.snapshot_line_id, h.security_id, h.quantity, h.unit_price,
h.value, h.book_cost, h.price_source, h.price_fetched_at,
h.created_at, h.updated_at,
s.symbol AS security_symbol, s.name AS security_name,
s.asset_type AS security_asset_type
FROM balance_snapshot_holdings h
JOIN balance_securities s ON s.id = h.security_id
WHERE h.snapshot_line_id = $1
ORDER BY s.symbol ASC`,
[lineId]
);
}
/**
* Holdings of an account's LATEST snapshot, one per security, joined with the
* security for display. Used to PREFILL the next snapshot's editor (carry the
* titles + quantities + book_cost forward; the price gets re-fetched).
*
* Titles with quantity 0 are EXCLUDED — a position fully sold at the last
* snapshot shouldn't be re-offered (Issue #212 acceptance). A title sold then
* re-bought reappears because its latest non-zero holding wins.
*
* "Latest" is resolved per account by the max `snapshot_date` among that
* account's lines that actually carry holdings.
*/
export async function getHoldingsForLatestSnapshot(
accountId: number
): Promise<BalanceSnapshotHoldingWithSecurity[]> {
const db = await getDb();
return db.select<BalanceSnapshotHoldingWithSecurity[]>(
`SELECT h.id, h.snapshot_line_id, h.security_id, h.quantity, h.unit_price,
h.value, h.book_cost, h.price_source, h.price_fetched_at,
h.created_at, h.updated_at,
s.symbol AS security_symbol, s.name AS security_name,
s.asset_type AS security_asset_type
FROM balance_snapshot_holdings h
JOIN balance_securities s ON s.id = h.security_id
JOIN balance_snapshot_lines l ON l.id = h.snapshot_line_id
WHERE l.account_id = $1
AND l.snapshot_id = (
SELECT l2.snapshot_id
FROM balance_snapshot_lines l2
JOIN balance_snapshots s2 ON s2.id = l2.snapshot_id
JOIN balance_snapshot_holdings h2 ON h2.snapshot_line_id = l2.id
WHERE l2.account_id = $1
ORDER BY s2.snapshot_date DESC
LIMIT 1
)
AND h.quantity <> 0
ORDER BY s.symbol ASC`,
[accountId]
);
}
/** Per-holding unrealized gain row. */
export interface HoldingUnrealizedGain {
security_id: number;
symbol: string;
value: number;
book_cost: number | null;
/** `value - book_cost`, or null when book_cost is unknown (NULL). */
gain: number | null;
/**
* `(value - book_cost) / book_cost`, or null ("N/A") when book_cost is NULL
* or 0 — never a divide-by-zero. The UI renders null as the i18n "N/A".
*/
gain_pct: number | null;
}
/** Account-level aggregated unrealized gain across its latest holdings. */
export interface AccountUnrealizedGain {
/** SUM(value) across the holdings considered. */
total_value: number;
/** SUM(book_cost) across holdings WITH a known book_cost (NULLs excluded). */
total_book_cost: number;
/** total_value total_book_cost across the known-book_cost holdings only. */
total_gain: number;
/** total_gain / total_book_cost, or null when total_book_cost is 0. */
total_gain_pct: number | null;
/**
* True when at least one holding has a NULL book_cost (excluded from the
* aggregate) — the UI flags the % as partial so it isn't read as exhaustive.
*/
has_unknown_book_cost: boolean;
/** The per-holding breakdown the aggregate was computed from. */
holdings: HoldingUnrealizedGain[];
}
/**
* Compute the unrealized gain (`value book_cost`) per holding and aggregated,
* in value and percent (Issue #212). Pure over the holdings it's given, so it's
* trivially unit-testable; pass holdings from `getHoldingsForLatestSnapshot`
* (latest snapshot) or `listHoldingsBySnapshotLine` (a specific snapshot).
*
* Guards:
* - per holding: `book_cost = 0` OR `book_cost = NULL` ⇒ `gain_pct = null`
* ("N/A") so we never divide by zero. A NULL book_cost also yields
* `gain = null` (we don't know the cost basis), whereas `book_cost = 0`
* yields `gain = value` (cost basis is genuinely zero).
* - aggregate: NULL-book_cost holdings are EXCLUDED from `total_book_cost`
* and `total_gain`, and flagged via `has_unknown_book_cost` so the % isn't
* silently understated. `total_gain_pct` is null when no known book_cost
* contributes (sum is 0).
*/
export function computeUnrealizedGain(
holdings: Array<
Pick<BalanceSnapshotHolding, "security_id" | "value" | "book_cost"> & {
symbol?: string;
}
>
): AccountUnrealizedGain {
const rows: HoldingUnrealizedGain[] = [];
let totalValue = 0;
let totalBookCost = 0;
let totalGainKnown = 0;
let hasUnknown = false;
for (const h of holdings) {
const value = h.value;
totalValue += value;
const bc = h.book_cost;
let gain: number | null;
let gainPct: number | null;
if (bc === null || bc === undefined) {
// Unknown cost basis — no gain figure, excluded from the aggregate.
gain = null;
gainPct = null;
hasUnknown = true;
} else {
gain = value - bc;
totalBookCost += bc;
totalGainKnown += gain;
// book_cost = 0 is a real basis (gain = value) but % is undefined.
gainPct = bc === 0 ? null : (value - bc) / bc;
}
rows.push({
security_id: h.security_id,
symbol: (h as { symbol?: string }).symbol ?? "",
value,
book_cost: bc ?? null,
gain,
gain_pct: gainPct,
});
}
return {
total_value: totalValue,
total_book_cost: totalBookCost,
total_gain: totalGainKnown,
total_gain_pct: totalBookCost === 0 ? null : totalGainKnown / totalBookCost,
has_unknown_book_cost: hasUnknown,
holdings: rows,
};
}
// -----------------------------------------------------------------------------
// 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();
},
};