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>
2639 lines
92 KiB
TypeScript
2639 lines
92 KiB
TypeScript
// 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();
|
||
},
|
||
};
|
||
|