useSnapshotEditor.save now validates all simple/priced lines in-memory before any DB write, then delegates to a new saveSnapshotAtomic helper that wraps INSERT snapshot + INSERT lines in an explicit BEGIN/COMMIT transaction (ROLLBACK on catch). Pattern matches categorizationService. Migration v11 cleans existing orphan snapshots in profiles that hit the old race; new orphans are no longer possible thanks to the transaction. Resolves #176
1625 lines
54 KiB
TypeScript
1625 lines
54 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,
|
||
BalanceAccountTransferWithTransaction,
|
||
BalanceAccountWithCategory,
|
||
BalanceAssetType,
|
||
BalanceCategory,
|
||
BalanceCategoryKind,
|
||
BalanceSnapshot,
|
||
BalanceSnapshotLine,
|
||
BalanceTransferDirection,
|
||
} from "../shared/types";
|
||
import { BALANCE_CURRENCY_CAD } from "../shared/types";
|
||
|
||
// -----------------------------------------------------------------------------
|
||
// Errors — typed so the UI can show distinct i18n messages.
|
||
// -----------------------------------------------------------------------------
|
||
|
||
export type BalanceErrorCode =
|
||
| "currency_unsupported"
|
||
| "category_seed_protected"
|
||
| "category_has_accounts"
|
||
| "category_not_found"
|
||
| "account_not_found"
|
||
| "name_required"
|
||
| "kind_invalid"
|
||
| "asset_type_required"
|
||
| "asset_type_invalid"
|
||
| "snapshot_date_required"
|
||
| "snapshot_date_taken"
|
||
| "snapshot_not_found"
|
||
| "snapshot_value_invalid"
|
||
| "snapshot_priced_unsupported"
|
||
| "snapshot_priced_quantity_required"
|
||
| "snapshot_priced_unit_price_required"
|
||
| "snapshot_priced_value_mismatch"
|
||
| "snapshot_simple_must_be_scalar"
|
||
// Issue #142 — transfers + returns
|
||
| "transfer_direction_invalid"
|
||
| "transfer_already_linked"
|
||
| "transfer_not_linked"
|
||
| "transfer_active_profile_unknown"
|
||
| "transaction_linked_to_balance_account";
|
||
|
||
export class BalanceServiceError extends Error {
|
||
readonly code: BalanceErrorCode;
|
||
constructor(code: BalanceErrorCode, message: string) {
|
||
super(message);
|
||
this.name = "BalanceServiceError";
|
||
this.code = code;
|
||
}
|
||
}
|
||
|
||
// -----------------------------------------------------------------------------
|
||
// Categories
|
||
// -----------------------------------------------------------------------------
|
||
|
||
export async function listBalanceCategories(): Promise<BalanceCategory[]> {
|
||
const db = await getDb();
|
||
return db.select<BalanceCategory[]>(
|
||
`SELECT id, key, i18n_key, kind, sort_order, is_active, is_seed, asset_type
|
||
FROM balance_categories
|
||
ORDER BY sort_order, key`
|
||
);
|
||
}
|
||
|
||
export async function getBalanceCategory(
|
||
id: number
|
||
): Promise<BalanceCategory | null> {
|
||
const db = await getDb();
|
||
const rows = await db.select<BalanceCategory[]>(
|
||
`SELECT id, key, i18n_key, kind, sort_order, is_active, is_seed, asset_type
|
||
FROM balance_categories
|
||
WHERE id = $1`,
|
||
[id]
|
||
);
|
||
return rows[0] ?? null;
|
||
}
|
||
|
||
export interface CreateBalanceCategoryInput {
|
||
key: string;
|
||
i18n_key: string;
|
||
kind: BalanceCategoryKind;
|
||
sort_order?: number;
|
||
/**
|
||
* Required when `kind === 'priced'` (Issue #169). Drives PriceFetchControl
|
||
* provider routing (best-effort Yahoo for stocks, exchange APIs for crypto).
|
||
* For `kind === 'simple'`, the service forces this to NULL regardless of
|
||
* the input value.
|
||
*/
|
||
asset_type?: BalanceAssetType | null;
|
||
}
|
||
|
||
/**
|
||
* Create a user-defined balance category. The seed categories are created by
|
||
* Migration v9 — never call this for seeded keys (UNIQUE will reject the
|
||
* insert anyway).
|
||
*/
|
||
export async function createBalanceCategory(
|
||
input: CreateBalanceCategoryInput
|
||
): Promise<number> {
|
||
if (!input.key || input.key.trim().length === 0) {
|
||
throw new BalanceServiceError("name_required", "Category key is required");
|
||
}
|
||
if (input.kind !== "simple" && input.kind !== "priced") {
|
||
throw new BalanceServiceError("kind_invalid", "Invalid category kind");
|
||
}
|
||
const assetType = normalizeAssetTypeForKind(input.kind, input.asset_type);
|
||
const db = await getDb();
|
||
const result = await db.execute(
|
||
`INSERT INTO balance_categories (key, i18n_key, kind, sort_order, is_active, is_seed, asset_type)
|
||
VALUES ($1, $2, $3, $4, 1, 0, $5)`,
|
||
[
|
||
input.key.trim(),
|
||
input.i18n_key.trim(),
|
||
input.kind,
|
||
input.sort_order ?? 0,
|
||
assetType,
|
||
]
|
||
);
|
||
return result.lastInsertId as number;
|
||
}
|
||
|
||
export interface UpdateBalanceCategoryInput {
|
||
i18n_key?: string;
|
||
sort_order?: number;
|
||
is_active?: boolean;
|
||
/**
|
||
* Allows backfilling `asset_type` on legacy priced categories created
|
||
* before migration v10. The service rejects an explicit `null` when the
|
||
* existing kind is priced (would unset a required field).
|
||
*/
|
||
asset_type?: BalanceAssetType | null;
|
||
}
|
||
|
||
/**
|
||
* Rename / re-order / toggle active state of a category. Seeded categories
|
||
* are renamable. Changing `kind` is intentionally not supported (would
|
||
* invalidate existing snapshot lines).
|
||
*/
|
||
export async function updateBalanceCategory(
|
||
id: number,
|
||
input: UpdateBalanceCategoryInput
|
||
): Promise<void> {
|
||
const existing = await getBalanceCategory(id);
|
||
if (!existing) {
|
||
throw new BalanceServiceError(
|
||
"category_not_found",
|
||
`Category ${id} not found`
|
||
);
|
||
}
|
||
const db = await getDb();
|
||
const i18n = input.i18n_key !== undefined ? input.i18n_key : existing.i18n_key;
|
||
const sortOrder =
|
||
input.sort_order !== undefined ? input.sort_order : existing.sort_order;
|
||
const isActive =
|
||
input.is_active !== undefined ? (input.is_active ? 1 : 0) : existing.is_active ? 1 : 0;
|
||
const assetType =
|
||
input.asset_type !== undefined
|
||
? normalizeAssetTypeForKind(existing.kind, input.asset_type)
|
||
: existing.asset_type;
|
||
await db.execute(
|
||
`UPDATE balance_categories
|
||
SET i18n_key = $1, sort_order = $2, is_active = $3, asset_type = $4
|
||
WHERE id = $5`,
|
||
[i18n, sortOrder, isActive, assetType, id]
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Coerce/validate `asset_type` against `kind`:
|
||
* - simple → always NULL (input is ignored).
|
||
* - priced → required, must be 'stock' or 'crypto'.
|
||
*/
|
||
function normalizeAssetTypeForKind(
|
||
kind: BalanceCategoryKind,
|
||
raw: BalanceAssetType | null | undefined
|
||
): BalanceAssetType | null {
|
||
if (kind === "simple") {
|
||
return null;
|
||
}
|
||
if (raw === null || raw === undefined) {
|
||
throw new BalanceServiceError(
|
||
"asset_type_required",
|
||
"asset_type is required for priced categories"
|
||
);
|
||
}
|
||
if (raw !== "stock" && raw !== "crypto") {
|
||
throw new BalanceServiceError(
|
||
"asset_type_invalid",
|
||
"asset_type must be 'stock' or 'crypto'"
|
||
);
|
||
}
|
||
return raw;
|
||
}
|
||
|
||
/**
|
||
* Delete a user-created category. Refuses to delete:
|
||
* - seeded categories (`is_seed = 1`) — UI must disable the button;
|
||
* - categories with linked accounts — FK RESTRICT would also reject, but
|
||
* we pre-check to surface a clean i18n message.
|
||
*/
|
||
export async function deleteBalanceCategory(id: number): Promise<void> {
|
||
const existing = await getBalanceCategory(id);
|
||
if (!existing) {
|
||
throw new BalanceServiceError(
|
||
"category_not_found",
|
||
`Category ${id} not found`
|
||
);
|
||
}
|
||
if (existing.is_seed) {
|
||
throw new BalanceServiceError(
|
||
"category_seed_protected",
|
||
"Seeded categories cannot be deleted"
|
||
);
|
||
}
|
||
const db = await getDb();
|
||
const linked = await db.select<Array<{ count: number }>>(
|
||
`SELECT COUNT(*) AS count FROM balance_accounts WHERE balance_category_id = $1`,
|
||
[id]
|
||
);
|
||
if ((linked[0]?.count ?? 0) > 0) {
|
||
throw new BalanceServiceError(
|
||
"category_has_accounts",
|
||
"Cannot delete a category with linked accounts"
|
||
);
|
||
}
|
||
await db.execute("DELETE FROM balance_categories WHERE id = $1", [id]);
|
||
}
|
||
|
||
// -----------------------------------------------------------------------------
|
||
// Accounts
|
||
// -----------------------------------------------------------------------------
|
||
|
||
export async function listBalanceAccounts(options?: {
|
||
includeArchived?: boolean;
|
||
}): Promise<BalanceAccountWithCategory[]> {
|
||
const includeArchived = options?.includeArchived ?? false;
|
||
const db = await getDb();
|
||
const where = includeArchived
|
||
? ""
|
||
: "WHERE a.is_active = 1 AND a.archived_at IS NULL";
|
||
return db.select<BalanceAccountWithCategory[]>(
|
||
`SELECT a.id, a.balance_category_id, a.name, a.symbol, a.currency,
|
||
a.notes, a.is_active, a.archived_at, a.created_at, a.updated_at,
|
||
c.key AS category_key, c.i18n_key AS category_i18n_key,
|
||
c.kind AS category_kind, c.asset_type AS category_asset_type
|
||
FROM balance_accounts a
|
||
INNER JOIN balance_categories c ON c.id = a.balance_category_id
|
||
${where}
|
||
ORDER BY c.sort_order, a.name`
|
||
);
|
||
}
|
||
|
||
export async function getBalanceAccount(
|
||
id: number
|
||
): Promise<BalanceAccount | null> {
|
||
const db = await getDb();
|
||
const rows = await db.select<BalanceAccount[]>(
|
||
`SELECT id, balance_category_id, name, symbol, currency, notes,
|
||
is_active, archived_at, created_at, updated_at
|
||
FROM balance_accounts
|
||
WHERE id = $1`,
|
||
[id]
|
||
);
|
||
return rows[0] ?? null;
|
||
}
|
||
|
||
export interface CreateBalanceAccountInput {
|
||
balance_category_id: number;
|
||
name: string;
|
||
symbol?: string | null;
|
||
/** Defaults to 'CAD'. MVP rejects any other value. */
|
||
currency?: string;
|
||
notes?: string | null;
|
||
}
|
||
|
||
/**
|
||
* Create an account. Currency must be 'CAD' at the MVP — the SQL CHECK
|
||
* would reject anything else, but we pre-check to surface a clean i18n
|
||
* message instead of a raw SQL error.
|
||
*/
|
||
export async function createBalanceAccount(
|
||
input: CreateBalanceAccountInput
|
||
): Promise<number> {
|
||
if (!input.name || input.name.trim().length === 0) {
|
||
throw new BalanceServiceError("name_required", "Account name is required");
|
||
}
|
||
const currency = input.currency ?? BALANCE_CURRENCY_CAD;
|
||
if (currency !== BALANCE_CURRENCY_CAD) {
|
||
throw new BalanceServiceError(
|
||
"currency_unsupported",
|
||
"Only CAD is supported at the MVP"
|
||
);
|
||
}
|
||
const cat = await getBalanceCategory(input.balance_category_id);
|
||
if (!cat) {
|
||
throw new BalanceServiceError(
|
||
"category_not_found",
|
||
"Linked balance category not found"
|
||
);
|
||
}
|
||
const db = await getDb();
|
||
const result = await db.execute(
|
||
`INSERT INTO balance_accounts (balance_category_id, name, symbol, currency, notes, is_active)
|
||
VALUES ($1, $2, $3, $4, $5, 1)`,
|
||
[
|
||
input.balance_category_id,
|
||
input.name.trim(),
|
||
input.symbol ? input.symbol.trim() : null,
|
||
currency,
|
||
input.notes ? input.notes.trim() : null,
|
||
]
|
||
);
|
||
return result.lastInsertId as number;
|
||
}
|
||
|
||
export interface UpdateBalanceAccountInput {
|
||
balance_category_id?: number;
|
||
name?: string;
|
||
symbol?: string | null;
|
||
notes?: string | null;
|
||
is_active?: boolean;
|
||
}
|
||
|
||
export async function updateBalanceAccount(
|
||
id: number,
|
||
input: UpdateBalanceAccountInput
|
||
): Promise<void> {
|
||
const existing = await getBalanceAccount(id);
|
||
if (!existing) {
|
||
throw new BalanceServiceError(
|
||
"account_not_found",
|
||
`Account ${id} not found`
|
||
);
|
||
}
|
||
const name = input.name !== undefined ? input.name.trim() : existing.name;
|
||
if (!name) {
|
||
throw new BalanceServiceError("name_required", "Account name is required");
|
||
}
|
||
const categoryId =
|
||
input.balance_category_id !== undefined
|
||
? input.balance_category_id
|
||
: existing.balance_category_id;
|
||
const symbol =
|
||
input.symbol !== undefined
|
||
? input.symbol === null
|
||
? null
|
||
: input.symbol.trim() || null
|
||
: existing.symbol;
|
||
const notes =
|
||
input.notes !== undefined
|
||
? input.notes === null
|
||
? null
|
||
: input.notes.trim() || null
|
||
: existing.notes;
|
||
const isActive =
|
||
input.is_active !== undefined
|
||
? input.is_active
|
||
? 1
|
||
: 0
|
||
: existing.is_active
|
||
? 1
|
||
: 0;
|
||
const db = await getDb();
|
||
await db.execute(
|
||
`UPDATE balance_accounts
|
||
SET balance_category_id = $1, name = $2, symbol = $3, notes = $4,
|
||
is_active = $5, updated_at = CURRENT_TIMESTAMP
|
||
WHERE id = $6`,
|
||
[categoryId, name, symbol, notes, isActive, id]
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Soft-delete an account: stamp `archived_at` and set `is_active = 0`.
|
||
* Archived accounts are hidden from new snapshots but kept in the historic
|
||
* snapshot lines (which is why we never hard-delete here).
|
||
*/
|
||
export async function archiveBalanceAccount(id: number): Promise<void> {
|
||
const existing = await getBalanceAccount(id);
|
||
if (!existing) {
|
||
throw new BalanceServiceError(
|
||
"account_not_found",
|
||
`Account ${id} not found`
|
||
);
|
||
}
|
||
const db = await getDb();
|
||
await db.execute(
|
||
`UPDATE balance_accounts
|
||
SET archived_at = CURRENT_TIMESTAMP, is_active = 0,
|
||
updated_at = CURRENT_TIMESTAMP
|
||
WHERE id = $1`,
|
||
[id]
|
||
);
|
||
}
|
||
|
||
export async function unarchiveBalanceAccount(id: number): Promise<void> {
|
||
const existing = await getBalanceAccount(id);
|
||
if (!existing) {
|
||
throw new BalanceServiceError(
|
||
"account_not_found",
|
||
`Account ${id} not found`
|
||
);
|
||
}
|
||
const db = await getDb();
|
||
await db.execute(
|
||
`UPDATE balance_accounts
|
||
SET archived_at = NULL, is_active = 1,
|
||
updated_at = CURRENT_TIMESTAMP
|
||
WHERE id = $1`,
|
||
[id]
|
||
);
|
||
}
|
||
|
||
// -----------------------------------------------------------------------------
|
||
// Snapshots + lines (Issue #146 / Bilan #1b — simple kind only)
|
||
// -----------------------------------------------------------------------------
|
||
//
|
||
// At Issue #146 the UI surfaces *only* simple-kind input: every line has
|
||
// `quantity = NULL` and `unit_price = NULL`. The SQL CHECK on
|
||
// `balance_snapshot_lines` already enforces the kind invariant, but
|
||
// `upsertSnapshotLines` re-validates ahead of time so a typed
|
||
// BalanceServiceError surfaces a clean i18n message instead of a raw SQL
|
||
// error. Priced-kind upsert lands in Issue #140 (Bilan #2).
|
||
|
||
const ISO_DATE_REGEX = /^\d{4}-\d{2}-\d{2}$/;
|
||
|
||
function normalizeSnapshotDate(date: string): string {
|
||
const trimmed = (date ?? "").trim();
|
||
if (!trimmed) {
|
||
throw new BalanceServiceError(
|
||
"snapshot_date_required",
|
||
"Snapshot date is required"
|
||
);
|
||
}
|
||
if (!ISO_DATE_REGEX.test(trimmed)) {
|
||
throw new BalanceServiceError(
|
||
"snapshot_date_required",
|
||
"Snapshot date must be in ISO YYYY-MM-DD format"
|
||
);
|
||
}
|
||
return trimmed;
|
||
}
|
||
|
||
export async function listSnapshots(): Promise<BalanceSnapshot[]> {
|
||
const db = await getDb();
|
||
return db.select<BalanceSnapshot[]>(
|
||
`SELECT id, snapshot_date, notes, created_at, updated_at
|
||
FROM balance_snapshots
|
||
ORDER BY snapshot_date DESC`
|
||
);
|
||
}
|
||
|
||
export async function getSnapshotByDate(
|
||
date: string
|
||
): Promise<BalanceSnapshot | null> {
|
||
const normalized = normalizeSnapshotDate(date);
|
||
const db = await getDb();
|
||
const rows = await db.select<BalanceSnapshot[]>(
|
||
`SELECT id, snapshot_date, notes, created_at, updated_at
|
||
FROM balance_snapshots
|
||
WHERE snapshot_date = $1`,
|
||
[normalized]
|
||
);
|
||
return rows[0] ?? null;
|
||
}
|
||
|
||
export async function getSnapshotById(
|
||
id: number
|
||
): Promise<BalanceSnapshot | null> {
|
||
const db = await getDb();
|
||
const rows = await db.select<BalanceSnapshot[]>(
|
||
`SELECT id, snapshot_date, notes, created_at, updated_at
|
||
FROM balance_snapshots
|
||
WHERE id = $1`,
|
||
[id]
|
||
);
|
||
return rows[0] ?? null;
|
||
}
|
||
|
||
export interface CreateSnapshotInput {
|
||
snapshot_date: string;
|
||
notes?: string | null;
|
||
}
|
||
|
||
/**
|
||
* Create a snapshot row. Throws `snapshot_date_taken` if a snapshot already
|
||
* exists at the same date so the UI can redirect to edit mode (UNIQUE
|
||
* constraint on `snapshot_date` would surface a raw SQL error otherwise).
|
||
*/
|
||
export async function createSnapshot(
|
||
input: CreateSnapshotInput
|
||
): Promise<number> {
|
||
const date = normalizeSnapshotDate(input.snapshot_date);
|
||
const existing = await getSnapshotByDate(date);
|
||
if (existing) {
|
||
throw new BalanceServiceError(
|
||
"snapshot_date_taken",
|
||
`A snapshot already exists at ${date}`
|
||
);
|
||
}
|
||
const db = await getDb();
|
||
const result = await db.execute(
|
||
`INSERT INTO balance_snapshots (snapshot_date, notes)
|
||
VALUES ($1, $2)`,
|
||
[date, input.notes ? input.notes.trim() || null : null]
|
||
);
|
||
return result.lastInsertId as number;
|
||
}
|
||
|
||
export interface UpdateSnapshotInput {
|
||
notes?: string | null;
|
||
}
|
||
|
||
/**
|
||
* Update snapshot metadata (notes only). Snapshot date is immutable once
|
||
* saved — to change the date the user deletes the snapshot and creates a
|
||
* new one (the UI exposes this as a constraint, not a feature).
|
||
*/
|
||
export async function updateSnapshot(
|
||
id: number,
|
||
input: UpdateSnapshotInput
|
||
): Promise<void> {
|
||
const existing = await getSnapshotById(id);
|
||
if (!existing) {
|
||
throw new BalanceServiceError(
|
||
"snapshot_not_found",
|
||
`Snapshot ${id} not found`
|
||
);
|
||
}
|
||
const notes =
|
||
input.notes !== undefined
|
||
? input.notes === null
|
||
? null
|
||
: input.notes.trim() || null
|
||
: existing.notes;
|
||
const db = await getDb();
|
||
await db.execute(
|
||
`UPDATE balance_snapshots
|
||
SET notes = $1, updated_at = CURRENT_TIMESTAMP
|
||
WHERE id = $2`,
|
||
[notes, id]
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Delete a snapshot. ON DELETE CASCADE on `balance_snapshot_lines`
|
||
* .snapshot_id removes the lines too. The UI must double-confirm
|
||
* (re-typing the snapshot date) before invoking this.
|
||
*/
|
||
export async function deleteSnapshot(id: number): Promise<void> {
|
||
const existing = await getSnapshotById(id);
|
||
if (!existing) {
|
||
throw new BalanceServiceError(
|
||
"snapshot_not_found",
|
||
`Snapshot ${id} not found`
|
||
);
|
||
}
|
||
const db = await getDb();
|
||
await db.execute("DELETE FROM balance_snapshots WHERE id = $1", [id]);
|
||
}
|
||
|
||
export async function listLinesBySnapshot(
|
||
snapshotId: number
|
||
): Promise<BalanceSnapshotLine[]> {
|
||
const db = await getDb();
|
||
return db.select<BalanceSnapshotLine[]>(
|
||
`SELECT id, snapshot_id, account_id, quantity, unit_price, value,
|
||
price_source, price_fetched_at, created_at, updated_at
|
||
FROM balance_snapshot_lines
|
||
WHERE snapshot_id = $1
|
||
ORDER BY id`,
|
||
[snapshotId]
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Tolerance ε used by the priced-kind invariant `value === quantity * unit_price`.
|
||
*
|
||
* Floating-point multiplication of decimal user input is lossy
|
||
* (`12.34 * 1.07 === 13.2038000000000002`), and the UI displays `value`
|
||
* rounded to 2 decimals while keeping quantity / unit_price at full
|
||
* precision. ε = 0.01 (one cent on the dollar) is generous enough to
|
||
* absorb that drift but tight enough to catch obvious mistakes (off by
|
||
* 10×). See decisions-log.md / Issue #140.
|
||
*/
|
||
export const PRICED_VALUE_TOLERANCE = 0.01;
|
||
|
||
export interface SnapshotLineInput {
|
||
account_id: number;
|
||
/**
|
||
* Snapshot value at this date. For priced lines this should match
|
||
* `quantity * unit_price` within `PRICED_VALUE_TOLERANCE`; the service
|
||
* validates the relation ahead of the SQL CHECK and surfaces a typed
|
||
* `snapshot_priced_value_mismatch` error otherwise.
|
||
*/
|
||
value: number;
|
||
/**
|
||
* Category kind of the underlying account. Defaults to 'simple' to
|
||
* preserve the #146 callers that don't pass it. Priced lines must
|
||
* provide both `quantity` and `unit_price`.
|
||
*/
|
||
account_kind?: BalanceCategoryKind;
|
||
/** Required for priced lines, must be NULL for simple. */
|
||
quantity?: number | null;
|
||
/** Required for priced lines, must be NULL for simple. */
|
||
unit_price?: number | null;
|
||
}
|
||
|
||
/**
|
||
* Pure helper that validates a snapshot line against its account's
|
||
* category kind. Exposed for unit tests and used by `upsertSnapshotLines`
|
||
* before any DB mutation happens.
|
||
*
|
||
* Rules:
|
||
* - simple kind → quantity AND unit_price must be NULL/undefined; value
|
||
* must be a finite number.
|
||
* - priced kind → quantity AND unit_price must be finite numbers; value
|
||
* must equal quantity × unit_price within
|
||
* `PRICED_VALUE_TOLERANCE`.
|
||
*
|
||
* @throws `BalanceServiceError` with a typed code on the first failure.
|
||
*/
|
||
export function validateLineKindInvariants(
|
||
line: SnapshotLineInput,
|
||
accountKind: BalanceCategoryKind = line.account_kind ?? "simple"
|
||
): void {
|
||
if (typeof line.value !== "number" || !Number.isFinite(line.value)) {
|
||
throw new BalanceServiceError(
|
||
"snapshot_value_invalid",
|
||
`Line for account ${line.account_id}: value must be a finite number`
|
||
);
|
||
}
|
||
if (accountKind === "simple") {
|
||
// Simple-kind: quantity / unit_price must be absent (NULL or undefined).
|
||
if (line.quantity !== undefined && line.quantity !== null) {
|
||
throw new BalanceServiceError(
|
||
"snapshot_simple_must_be_scalar",
|
||
`Line for account ${line.account_id}: simple-kind line must not carry quantity`
|
||
);
|
||
}
|
||
if (line.unit_price !== undefined && line.unit_price !== null) {
|
||
throw new BalanceServiceError(
|
||
"snapshot_simple_must_be_scalar",
|
||
`Line for account ${line.account_id}: simple-kind line must not carry unit_price`
|
||
);
|
||
}
|
||
return;
|
||
}
|
||
// Priced-kind: both fields required and finite.
|
||
if (
|
||
line.quantity === undefined ||
|
||
line.quantity === null ||
|
||
typeof line.quantity !== "number" ||
|
||
!Number.isFinite(line.quantity)
|
||
) {
|
||
throw new BalanceServiceError(
|
||
"snapshot_priced_quantity_required",
|
||
`Line for account ${line.account_id}: quantity is required for priced accounts`
|
||
);
|
||
}
|
||
if (
|
||
line.unit_price === undefined ||
|
||
line.unit_price === null ||
|
||
typeof line.unit_price !== "number" ||
|
||
!Number.isFinite(line.unit_price)
|
||
) {
|
||
throw new BalanceServiceError(
|
||
"snapshot_priced_unit_price_required",
|
||
`Line for account ${line.account_id}: unit_price is required for priced accounts`
|
||
);
|
||
}
|
||
const expected = line.quantity * line.unit_price;
|
||
if (Math.abs(expected - line.value) > PRICED_VALUE_TOLERANCE) {
|
||
throw new BalanceServiceError(
|
||
"snapshot_priced_value_mismatch",
|
||
`Line for account ${line.account_id}: value ${line.value} does not match quantity × unit_price (${expected})`
|
||
);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Upsert a batch of snapshot lines. Each input row is inserted or
|
||
* replaced atomically per account; lines for accounts not present in
|
||
* `lines` are removed from the snapshot. This makes the editor strictly
|
||
* state-driven — what the user sees is exactly what gets saved.
|
||
*
|
||
* Validation enforced ahead of time so the SQL CHECK never fires
|
||
* (`validateLineKindInvariants`):
|
||
* - simple kind → quantity / unit_price must be NULL; value must be finite.
|
||
* - priced kind → quantity / unit_price must be finite, and
|
||
* `value === quantity * unit_price` within
|
||
* `PRICED_VALUE_TOLERANCE`.
|
||
*
|
||
* The default `account_kind = 'simple'` preserves the #146 calling
|
||
* convention — callers that pre-classify their lines (which the priced
|
||
* editor in #140 must do) pass `account_kind: 'priced'` explicitly.
|
||
*/
|
||
export async function upsertSnapshotLines(
|
||
snapshotId: number,
|
||
lines: SnapshotLineInput[]
|
||
): Promise<void> {
|
||
const snapshot = await getSnapshotById(snapshotId);
|
||
if (!snapshot) {
|
||
throw new BalanceServiceError(
|
||
"snapshot_not_found",
|
||
`Snapshot ${snapshotId} not found`
|
||
);
|
||
}
|
||
// Validate every input up-front before mutating anything.
|
||
for (const line of lines) {
|
||
validateLineKindInvariants(line);
|
||
}
|
||
|
||
const db = await getDb();
|
||
// Strategy: clear and rewrite. Snapshot lines are small (one per active
|
||
// account, typically < 20) so the simplicity outweighs the diff-tracking
|
||
// savings. CASCADE guarantees consistency on partial failures.
|
||
await db.execute(
|
||
"DELETE FROM balance_snapshot_lines WHERE snapshot_id = $1",
|
||
[snapshotId]
|
||
);
|
||
for (const line of lines) {
|
||
const kind = line.account_kind ?? "simple";
|
||
if (kind === "simple") {
|
||
await db.execute(
|
||
`INSERT INTO balance_snapshot_lines
|
||
(snapshot_id, account_id, quantity, unit_price, value, price_source)
|
||
VALUES ($1, $2, NULL, NULL, $3, 'manual')`,
|
||
[snapshotId, line.account_id, line.value]
|
||
);
|
||
} else {
|
||
await db.execute(
|
||
`INSERT INTO balance_snapshot_lines
|
||
(snapshot_id, account_id, quantity, unit_price, value, price_source)
|
||
VALUES ($1, $2, $3, $4, $5, 'manual')`,
|
||
[
|
||
snapshotId,
|
||
line.account_id,
|
||
line.quantity,
|
||
line.unit_price,
|
||
line.value,
|
||
]
|
||
);
|
||
}
|
||
}
|
||
// Bump the parent snapshot's updated_at so list views can sort by recency.
|
||
await db.execute(
|
||
`UPDATE balance_snapshots
|
||
SET updated_at = CURRENT_TIMESTAMP
|
||
WHERE id = $1`,
|
||
[snapshotId]
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 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.
|
||
*/
|
||
export async function saveSnapshotAtomic(input: {
|
||
existingSnapshotId: number | null;
|
||
snapshot_date: string;
|
||
notes?: string | null;
|
||
lines: SnapshotLineInput[];
|
||
}): Promise<{ snapshotId: number }> {
|
||
// Validate every line ahead of time so the transaction never opens for
|
||
// a doomed save. Mirrors `upsertSnapshotLines` invariants.
|
||
for (const line of input.lines) {
|
||
validateLineKindInvariants(line);
|
||
}
|
||
|
||
const db = await getDb();
|
||
let inTxn = false;
|
||
try {
|
||
await db.execute("BEGIN");
|
||
inTxn = true;
|
||
|
||
let snapshotId: number;
|
||
if (input.existingSnapshotId !== null) {
|
||
snapshotId = input.existingSnapshotId;
|
||
} 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. Cheap because snapshot
|
||
// line counts are small.
|
||
await db.execute(
|
||
"DELETE FROM balance_snapshot_lines WHERE snapshot_id = $1",
|
||
[snapshotId]
|
||
);
|
||
for (const line of input.lines) {
|
||
const kind = line.account_kind ?? "simple";
|
||
if (kind === "simple") {
|
||
await db.execute(
|
||
`INSERT INTO balance_snapshot_lines
|
||
(snapshot_id, account_id, quantity, unit_price, value, price_source)
|
||
VALUES ($1, $2, NULL, NULL, $3, 'manual')`,
|
||
[snapshotId, line.account_id, line.value]
|
||
);
|
||
} else {
|
||
await db.execute(
|
||
`INSERT INTO balance_snapshot_lines
|
||
(snapshot_id, account_id, quantity, unit_price, value, price_source)
|
||
VALUES ($1, $2, $3, $4, $5, 'manual')`,
|
||
[
|
||
snapshotId,
|
||
line.account_id,
|
||
line.quantity,
|
||
line.unit_price,
|
||
line.value,
|
||
]
|
||
);
|
||
}
|
||
}
|
||
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;
|
||
}
|
||
|
||
/** Latest-snapshot value per active account (Issue #141). */
|
||
export interface AccountLatestSnapshot {
|
||
account_id: number;
|
||
account_name: string;
|
||
symbol: string | null;
|
||
balance_category_id: number;
|
||
category_key: string;
|
||
category_i18n_key: string;
|
||
category_kind: BalanceCategoryKind;
|
||
/** Date of the snapshot whose value is reported, or null if no snapshot exists. */
|
||
latest_snapshot_date: string | null;
|
||
/** Value at that snapshot, or null if the account has no snapshot lines. */
|
||
latest_value: number | null;
|
||
}
|
||
|
||
/**
|
||
* Returns one row per active (non-archived) account with the value of its
|
||
* most-recent snapshot line. Accounts with no snapshot rows yet still
|
||
* appear, with `latest_value = null`. Used by the accounts table on
|
||
* `/balance` (#141) and as a building block for the period Δ% column.
|
||
*
|
||
* Implementation: a correlated subquery picks the line with the largest
|
||
* `s.snapshot_date` for each account — SQLite handles this fine on the
|
||
* indexed `balance_snapshots.snapshot_date` and `balance_snapshot_lines.account_id`.
|
||
*/
|
||
export async function getAccountsLatestSnapshot(): Promise<
|
||
AccountLatestSnapshot[]
|
||
> {
|
||
const db = await getDb();
|
||
return db.select<AccountLatestSnapshot[]>(
|
||
`SELECT a.id AS account_id,
|
||
a.name AS account_name,
|
||
a.symbol AS symbol,
|
||
a.balance_category_id AS balance_category_id,
|
||
c.key AS category_key,
|
||
c.i18n_key AS category_i18n_key,
|
||
c.kind AS category_kind,
|
||
(SELECT s.snapshot_date
|
||
FROM balance_snapshot_lines l
|
||
JOIN balance_snapshots s ON s.id = l.snapshot_id
|
||
WHERE l.account_id = a.id
|
||
ORDER BY s.snapshot_date DESC
|
||
LIMIT 1) AS latest_snapshot_date,
|
||
(SELECT l.value
|
||
FROM balance_snapshot_lines l
|
||
JOIN balance_snapshots s ON s.id = l.snapshot_id
|
||
WHERE l.account_id = a.id
|
||
ORDER BY s.snapshot_date DESC
|
||
LIMIT 1) AS latest_value
|
||
FROM balance_accounts a
|
||
INNER JOIN balance_categories c ON c.id = a.balance_category_id
|
||
WHERE a.is_active = 1 AND a.archived_at IS NULL
|
||
ORDER BY c.sort_order, a.name`
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Returns the value at the earliest snapshot for each account whose
|
||
* `snapshot_date` is `>= range.from` (and `<= range.to` when set), so the
|
||
* accounts table can compute a per-account Δ% over the selected period.
|
||
*
|
||
* Returns one row per account with a snapshot in range. Accounts without
|
||
* any snapshot in the period are omitted — callers default their Δ% to
|
||
* `null` (rendered as "—").
|
||
*/
|
||
export interface AccountPeriodAnchor {
|
||
account_id: number;
|
||
anchor_snapshot_date: string;
|
||
anchor_value: number;
|
||
}
|
||
|
||
export async function getAccountsPeriodAnchor(
|
||
range: SnapshotDateRange
|
||
): Promise<AccountPeriodAnchor[]> {
|
||
// For each account, find the earliest snapshot_date >= range.from (and
|
||
// <= range.to when set), then read that line's value.
|
||
//
|
||
// 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
|
||
);
|
||
}
|
||
|
||
// -----------------------------------------------------------------------------
|
||
// 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();
|
||
},
|
||
};
|
||
|