Add four service helpers used by the upcoming `/balance` overview: - getSnapshotTotalsByDate(range?) — SUM(value) GROUP BY snapshot_date with an optional inclusive [from, to] range. LEFT JOIN preserves empty snapshots as zero rows so the chart shows continuity. - getSnapshotTotalsByCategoryAndDate(range?) — same aggregation broken down by balance_categories.key, returned as one row per snapshot date with a `byCategory` map. Powers the stacked-area variant. - getAccountsLatestSnapshot() — one row per active account with the value of its most-recent snapshot line (NULL when none exists). Filters archived accounts via WHERE is_active = 1 AND archived_at IS NULL, matches the listBalanceAccounts default. - getAccountsPeriodAnchor(range) — earliest snapshot_date >= from per account, with the value at that date — the anchor used to compute the per-account Δ% column on the accounts table. Tests cover empty DB, single/multi snapshot, archived exclusion via SQL inspection, date-range params (from-only, both bounds, open). Refs: #141 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
957 lines
31 KiB
TypeScript
957 lines
31 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 { getDb } from "./db";
|
||
import type {
|
||
BalanceAccount,
|
||
BalanceAccountWithCategory,
|
||
BalanceCategory,
|
||
BalanceCategoryKind,
|
||
BalanceSnapshot,
|
||
BalanceSnapshotLine,
|
||
} from "../shared/types";
|
||
import { BALANCE_CURRENCY_CAD } from "../shared/types";
|
||
|
||
// -----------------------------------------------------------------------------
|
||
// Errors — typed so the UI can show distinct i18n messages.
|
||
// -----------------------------------------------------------------------------
|
||
|
||
export type BalanceErrorCode =
|
||
| "currency_unsupported"
|
||
| "category_seed_protected"
|
||
| "category_has_accounts"
|
||
| "category_not_found"
|
||
| "account_not_found"
|
||
| "name_required"
|
||
| "kind_invalid"
|
||
| "snapshot_date_required"
|
||
| "snapshot_date_taken"
|
||
| "snapshot_not_found"
|
||
| "snapshot_value_invalid"
|
||
| "snapshot_priced_unsupported"
|
||
| "snapshot_priced_quantity_required"
|
||
| "snapshot_priced_unit_price_required"
|
||
| "snapshot_priced_value_mismatch"
|
||
| "snapshot_simple_must_be_scalar";
|
||
|
||
export class BalanceServiceError extends Error {
|
||
readonly code: BalanceErrorCode;
|
||
constructor(code: BalanceErrorCode, message: string) {
|
||
super(message);
|
||
this.name = "BalanceServiceError";
|
||
this.code = code;
|
||
}
|
||
}
|
||
|
||
// -----------------------------------------------------------------------------
|
||
// Categories
|
||
// -----------------------------------------------------------------------------
|
||
|
||
export async function listBalanceCategories(): Promise<BalanceCategory[]> {
|
||
const db = await getDb();
|
||
return db.select<BalanceCategory[]>(
|
||
`SELECT id, key, i18n_key, kind, sort_order, is_active, is_seed
|
||
FROM balance_categories
|
||
ORDER BY sort_order, key`
|
||
);
|
||
}
|
||
|
||
export async function getBalanceCategory(
|
||
id: number
|
||
): Promise<BalanceCategory | null> {
|
||
const db = await getDb();
|
||
const rows = await db.select<BalanceCategory[]>(
|
||
`SELECT id, key, i18n_key, kind, sort_order, is_active, is_seed
|
||
FROM balance_categories
|
||
WHERE id = $1`,
|
||
[id]
|
||
);
|
||
return rows[0] ?? null;
|
||
}
|
||
|
||
export interface CreateBalanceCategoryInput {
|
||
key: string;
|
||
i18n_key: string;
|
||
kind: BalanceCategoryKind;
|
||
sort_order?: number;
|
||
}
|
||
|
||
/**
|
||
* Create a user-defined balance category. The seed categories are created by
|
||
* Migration v9 — never call this for seeded keys (UNIQUE will reject the
|
||
* insert anyway).
|
||
*
|
||
* Note (Issue #138): the AccountsPage UI restricts user-created categories to
|
||
* `kind = 'simple'`. The service still accepts both because the priced UI
|
||
* lands in Issue #140.
|
||
*/
|
||
export async function createBalanceCategory(
|
||
input: CreateBalanceCategoryInput
|
||
): Promise<number> {
|
||
if (!input.key || input.key.trim().length === 0) {
|
||
throw new BalanceServiceError("name_required", "Category key is required");
|
||
}
|
||
if (input.kind !== "simple" && input.kind !== "priced") {
|
||
throw new BalanceServiceError("kind_invalid", "Invalid category kind");
|
||
}
|
||
const db = await getDb();
|
||
const result = await db.execute(
|
||
`INSERT INTO balance_categories (key, i18n_key, kind, sort_order, is_active, is_seed)
|
||
VALUES ($1, $2, $3, $4, 1, 0)`,
|
||
[
|
||
input.key.trim(),
|
||
input.i18n_key.trim(),
|
||
input.kind,
|
||
input.sort_order ?? 0,
|
||
]
|
||
);
|
||
return result.lastInsertId as number;
|
||
}
|
||
|
||
export interface UpdateBalanceCategoryInput {
|
||
i18n_key?: string;
|
||
sort_order?: number;
|
||
is_active?: boolean;
|
||
}
|
||
|
||
/**
|
||
* Rename / re-order / toggle active state of a category. Seeded categories
|
||
* are renamable. Changing `kind` is intentionally not supported (would
|
||
* invalidate existing snapshot lines).
|
||
*/
|
||
export async function updateBalanceCategory(
|
||
id: number,
|
||
input: UpdateBalanceCategoryInput
|
||
): Promise<void> {
|
||
const existing = await getBalanceCategory(id);
|
||
if (!existing) {
|
||
throw new BalanceServiceError(
|
||
"category_not_found",
|
||
`Category ${id} not found`
|
||
);
|
||
}
|
||
const db = await getDb();
|
||
const i18n = input.i18n_key !== undefined ? input.i18n_key : existing.i18n_key;
|
||
const sortOrder =
|
||
input.sort_order !== undefined ? input.sort_order : existing.sort_order;
|
||
const isActive =
|
||
input.is_active !== undefined ? (input.is_active ? 1 : 0) : existing.is_active ? 1 : 0;
|
||
await db.execute(
|
||
`UPDATE balance_categories
|
||
SET i18n_key = $1, sort_order = $2, is_active = $3
|
||
WHERE id = $4`,
|
||
[i18n, sortOrder, isActive, id]
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Delete a user-created category. Refuses to delete:
|
||
* - seeded categories (`is_seed = 1`) — UI must disable the button;
|
||
* - categories with linked accounts — FK RESTRICT would also reject, but
|
||
* we pre-check to surface a clean i18n message.
|
||
*/
|
||
export async function deleteBalanceCategory(id: number): Promise<void> {
|
||
const existing = await getBalanceCategory(id);
|
||
if (!existing) {
|
||
throw new BalanceServiceError(
|
||
"category_not_found",
|
||
`Category ${id} not found`
|
||
);
|
||
}
|
||
if (existing.is_seed) {
|
||
throw new BalanceServiceError(
|
||
"category_seed_protected",
|
||
"Seeded categories cannot be deleted"
|
||
);
|
||
}
|
||
const db = await getDb();
|
||
const linked = await db.select<Array<{ count: number }>>(
|
||
`SELECT COUNT(*) AS count FROM balance_accounts WHERE balance_category_id = $1`,
|
||
[id]
|
||
);
|
||
if ((linked[0]?.count ?? 0) > 0) {
|
||
throw new BalanceServiceError(
|
||
"category_has_accounts",
|
||
"Cannot delete a category with linked accounts"
|
||
);
|
||
}
|
||
await db.execute("DELETE FROM balance_categories WHERE id = $1", [id]);
|
||
}
|
||
|
||
// -----------------------------------------------------------------------------
|
||
// Accounts
|
||
// -----------------------------------------------------------------------------
|
||
|
||
export async function listBalanceAccounts(options?: {
|
||
includeArchived?: boolean;
|
||
}): Promise<BalanceAccountWithCategory[]> {
|
||
const includeArchived = options?.includeArchived ?? false;
|
||
const db = await getDb();
|
||
const where = includeArchived
|
||
? ""
|
||
: "WHERE a.is_active = 1 AND a.archived_at IS NULL";
|
||
return db.select<BalanceAccountWithCategory[]>(
|
||
`SELECT a.id, a.balance_category_id, a.name, a.symbol, a.currency,
|
||
a.notes, a.is_active, a.archived_at, a.created_at, a.updated_at,
|
||
c.key AS category_key, c.i18n_key AS category_i18n_key, c.kind AS category_kind
|
||
FROM balance_accounts a
|
||
INNER JOIN balance_categories c ON c.id = a.balance_category_id
|
||
${where}
|
||
ORDER BY c.sort_order, a.name`
|
||
);
|
||
}
|
||
|
||
export async function getBalanceAccount(
|
||
id: number
|
||
): Promise<BalanceAccount | null> {
|
||
const db = await getDb();
|
||
const rows = await db.select<BalanceAccount[]>(
|
||
`SELECT id, balance_category_id, name, symbol, currency, notes,
|
||
is_active, archived_at, created_at, updated_at
|
||
FROM balance_accounts
|
||
WHERE id = $1`,
|
||
[id]
|
||
);
|
||
return rows[0] ?? null;
|
||
}
|
||
|
||
export interface CreateBalanceAccountInput {
|
||
balance_category_id: number;
|
||
name: string;
|
||
symbol?: string | null;
|
||
/** Defaults to 'CAD'. MVP rejects any other value. */
|
||
currency?: string;
|
||
notes?: string | null;
|
||
}
|
||
|
||
/**
|
||
* Create an account. Currency must be 'CAD' at the MVP — the SQL CHECK
|
||
* would reject anything else, but we pre-check to surface a clean i18n
|
||
* message instead of a raw SQL error.
|
||
*/
|
||
export async function createBalanceAccount(
|
||
input: CreateBalanceAccountInput
|
||
): Promise<number> {
|
||
if (!input.name || input.name.trim().length === 0) {
|
||
throw new BalanceServiceError("name_required", "Account name is required");
|
||
}
|
||
const currency = input.currency ?? BALANCE_CURRENCY_CAD;
|
||
if (currency !== BALANCE_CURRENCY_CAD) {
|
||
throw new BalanceServiceError(
|
||
"currency_unsupported",
|
||
"Only CAD is supported at the MVP"
|
||
);
|
||
}
|
||
const cat = await getBalanceCategory(input.balance_category_id);
|
||
if (!cat) {
|
||
throw new BalanceServiceError(
|
||
"category_not_found",
|
||
"Linked balance category not found"
|
||
);
|
||
}
|
||
const db = await getDb();
|
||
const result = await db.execute(
|
||
`INSERT INTO balance_accounts (balance_category_id, name, symbol, currency, notes, is_active)
|
||
VALUES ($1, $2, $3, $4, $5, 1)`,
|
||
[
|
||
input.balance_category_id,
|
||
input.name.trim(),
|
||
input.symbol ? input.symbol.trim() : null,
|
||
currency,
|
||
input.notes ? input.notes.trim() : null,
|
||
]
|
||
);
|
||
return result.lastInsertId as number;
|
||
}
|
||
|
||
export interface UpdateBalanceAccountInput {
|
||
balance_category_id?: number;
|
||
name?: string;
|
||
symbol?: string | null;
|
||
notes?: string | null;
|
||
is_active?: boolean;
|
||
}
|
||
|
||
export async function updateBalanceAccount(
|
||
id: number,
|
||
input: UpdateBalanceAccountInput
|
||
): Promise<void> {
|
||
const existing = await getBalanceAccount(id);
|
||
if (!existing) {
|
||
throw new BalanceServiceError(
|
||
"account_not_found",
|
||
`Account ${id} not found`
|
||
);
|
||
}
|
||
const name = input.name !== undefined ? input.name.trim() : existing.name;
|
||
if (!name) {
|
||
throw new BalanceServiceError("name_required", "Account name is required");
|
||
}
|
||
const categoryId =
|
||
input.balance_category_id !== undefined
|
||
? input.balance_category_id
|
||
: existing.balance_category_id;
|
||
const symbol =
|
||
input.symbol !== undefined
|
||
? input.symbol === null
|
||
? null
|
||
: input.symbol.trim() || null
|
||
: existing.symbol;
|
||
const notes =
|
||
input.notes !== undefined
|
||
? input.notes === null
|
||
? null
|
||
: input.notes.trim() || null
|
||
: existing.notes;
|
||
const isActive =
|
||
input.is_active !== undefined
|
||
? input.is_active
|
||
? 1
|
||
: 0
|
||
: existing.is_active
|
||
? 1
|
||
: 0;
|
||
const db = await getDb();
|
||
await db.execute(
|
||
`UPDATE balance_accounts
|
||
SET balance_category_id = $1, name = $2, symbol = $3, notes = $4,
|
||
is_active = $5, updated_at = CURRENT_TIMESTAMP
|
||
WHERE id = $6`,
|
||
[categoryId, name, symbol, notes, isActive, id]
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Soft-delete an account: stamp `archived_at` and set `is_active = 0`.
|
||
* Archived accounts are hidden from new snapshots but kept in the historic
|
||
* snapshot lines (which is why we never hard-delete here).
|
||
*/
|
||
export async function archiveBalanceAccount(id: number): Promise<void> {
|
||
const existing = await getBalanceAccount(id);
|
||
if (!existing) {
|
||
throw new BalanceServiceError(
|
||
"account_not_found",
|
||
`Account ${id} not found`
|
||
);
|
||
}
|
||
const db = await getDb();
|
||
await db.execute(
|
||
`UPDATE balance_accounts
|
||
SET archived_at = CURRENT_TIMESTAMP, is_active = 0,
|
||
updated_at = CURRENT_TIMESTAMP
|
||
WHERE id = $1`,
|
||
[id]
|
||
);
|
||
}
|
||
|
||
export async function unarchiveBalanceAccount(id: number): Promise<void> {
|
||
const existing = await getBalanceAccount(id);
|
||
if (!existing) {
|
||
throw new BalanceServiceError(
|
||
"account_not_found",
|
||
`Account ${id} not found`
|
||
);
|
||
}
|
||
const db = await getDb();
|
||
await db.execute(
|
||
`UPDATE balance_accounts
|
||
SET archived_at = NULL, is_active = 1,
|
||
updated_at = CURRENT_TIMESTAMP
|
||
WHERE id = $1`,
|
||
[id]
|
||
);
|
||
}
|
||
|
||
// -----------------------------------------------------------------------------
|
||
// Snapshots + lines (Issue #146 / Bilan #1b — simple kind only)
|
||
// -----------------------------------------------------------------------------
|
||
//
|
||
// At Issue #146 the UI surfaces *only* simple-kind input: every line has
|
||
// `quantity = NULL` and `unit_price = NULL`. The SQL CHECK on
|
||
// `balance_snapshot_lines` already enforces the kind invariant, but
|
||
// `upsertSnapshotLines` re-validates ahead of time so a typed
|
||
// BalanceServiceError surfaces a clean i18n message instead of a raw SQL
|
||
// error. Priced-kind upsert lands in Issue #140 (Bilan #2).
|
||
|
||
const ISO_DATE_REGEX = /^\d{4}-\d{2}-\d{2}$/;
|
||
|
||
function normalizeSnapshotDate(date: string): string {
|
||
const trimmed = (date ?? "").trim();
|
||
if (!trimmed) {
|
||
throw new BalanceServiceError(
|
||
"snapshot_date_required",
|
||
"Snapshot date is required"
|
||
);
|
||
}
|
||
if (!ISO_DATE_REGEX.test(trimmed)) {
|
||
throw new BalanceServiceError(
|
||
"snapshot_date_required",
|
||
"Snapshot date must be in ISO YYYY-MM-DD format"
|
||
);
|
||
}
|
||
return trimmed;
|
||
}
|
||
|
||
export async function listSnapshots(): Promise<BalanceSnapshot[]> {
|
||
const db = await getDb();
|
||
return db.select<BalanceSnapshot[]>(
|
||
`SELECT id, snapshot_date, notes, created_at, updated_at
|
||
FROM balance_snapshots
|
||
ORDER BY snapshot_date DESC`
|
||
);
|
||
}
|
||
|
||
export async function getSnapshotByDate(
|
||
date: string
|
||
): Promise<BalanceSnapshot | null> {
|
||
const normalized = normalizeSnapshotDate(date);
|
||
const db = await getDb();
|
||
const rows = await db.select<BalanceSnapshot[]>(
|
||
`SELECT id, snapshot_date, notes, created_at, updated_at
|
||
FROM balance_snapshots
|
||
WHERE snapshot_date = $1`,
|
||
[normalized]
|
||
);
|
||
return rows[0] ?? null;
|
||
}
|
||
|
||
export async function getSnapshotById(
|
||
id: number
|
||
): Promise<BalanceSnapshot | null> {
|
||
const db = await getDb();
|
||
const rows = await db.select<BalanceSnapshot[]>(
|
||
`SELECT id, snapshot_date, notes, created_at, updated_at
|
||
FROM balance_snapshots
|
||
WHERE id = $1`,
|
||
[id]
|
||
);
|
||
return rows[0] ?? null;
|
||
}
|
||
|
||
export interface CreateSnapshotInput {
|
||
snapshot_date: string;
|
||
notes?: string | null;
|
||
}
|
||
|
||
/**
|
||
* Create a snapshot row. Throws `snapshot_date_taken` if a snapshot already
|
||
* exists at the same date so the UI can redirect to edit mode (UNIQUE
|
||
* constraint on `snapshot_date` would surface a raw SQL error otherwise).
|
||
*/
|
||
export async function createSnapshot(
|
||
input: CreateSnapshotInput
|
||
): Promise<number> {
|
||
const date = normalizeSnapshotDate(input.snapshot_date);
|
||
const existing = await getSnapshotByDate(date);
|
||
if (existing) {
|
||
throw new BalanceServiceError(
|
||
"snapshot_date_taken",
|
||
`A snapshot already exists at ${date}`
|
||
);
|
||
}
|
||
const db = await getDb();
|
||
const result = await db.execute(
|
||
`INSERT INTO balance_snapshots (snapshot_date, notes)
|
||
VALUES ($1, $2)`,
|
||
[date, input.notes ? input.notes.trim() || null : null]
|
||
);
|
||
return result.lastInsertId as number;
|
||
}
|
||
|
||
export interface UpdateSnapshotInput {
|
||
notes?: string | null;
|
||
}
|
||
|
||
/**
|
||
* Update snapshot metadata (notes only). Snapshot date is immutable once
|
||
* saved — to change the date the user deletes the snapshot and creates a
|
||
* new one (the UI exposes this as a constraint, not a feature).
|
||
*/
|
||
export async function updateSnapshot(
|
||
id: number,
|
||
input: UpdateSnapshotInput
|
||
): Promise<void> {
|
||
const existing = await getSnapshotById(id);
|
||
if (!existing) {
|
||
throw new BalanceServiceError(
|
||
"snapshot_not_found",
|
||
`Snapshot ${id} not found`
|
||
);
|
||
}
|
||
const notes =
|
||
input.notes !== undefined
|
||
? input.notes === null
|
||
? null
|
||
: input.notes.trim() || null
|
||
: existing.notes;
|
||
const db = await getDb();
|
||
await db.execute(
|
||
`UPDATE balance_snapshots
|
||
SET notes = $1, updated_at = CURRENT_TIMESTAMP
|
||
WHERE id = $2`,
|
||
[notes, id]
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Delete a snapshot. ON DELETE CASCADE on `balance_snapshot_lines`
|
||
* .snapshot_id removes the lines too. The UI must double-confirm
|
||
* (re-typing the snapshot date) before invoking this.
|
||
*/
|
||
export async function deleteSnapshot(id: number): Promise<void> {
|
||
const existing = await getSnapshotById(id);
|
||
if (!existing) {
|
||
throw new BalanceServiceError(
|
||
"snapshot_not_found",
|
||
`Snapshot ${id} not found`
|
||
);
|
||
}
|
||
const db = await getDb();
|
||
await db.execute("DELETE FROM balance_snapshots WHERE id = $1", [id]);
|
||
}
|
||
|
||
export async function listLinesBySnapshot(
|
||
snapshotId: number
|
||
): Promise<BalanceSnapshotLine[]> {
|
||
const db = await getDb();
|
||
return db.select<BalanceSnapshotLine[]>(
|
||
`SELECT id, snapshot_id, account_id, quantity, unit_price, value,
|
||
price_source, price_fetched_at, created_at, updated_at
|
||
FROM balance_snapshot_lines
|
||
WHERE snapshot_id = $1
|
||
ORDER BY id`,
|
||
[snapshotId]
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Tolerance ε used by the priced-kind invariant `value === quantity * unit_price`.
|
||
*
|
||
* Floating-point multiplication of decimal user input is lossy
|
||
* (`12.34 * 1.07 === 13.2038000000000002`), and the UI displays `value`
|
||
* rounded to 2 decimals while keeping quantity / unit_price at full
|
||
* precision. ε = 0.01 (one cent on the dollar) is generous enough to
|
||
* absorb that drift but tight enough to catch obvious mistakes (off by
|
||
* 10×). See decisions-log.md / Issue #140.
|
||
*/
|
||
export const PRICED_VALUE_TOLERANCE = 0.01;
|
||
|
||
export interface SnapshotLineInput {
|
||
account_id: number;
|
||
/**
|
||
* Snapshot value at this date. For priced lines this should match
|
||
* `quantity * unit_price` within `PRICED_VALUE_TOLERANCE`; the service
|
||
* validates the relation ahead of the SQL CHECK and surfaces a typed
|
||
* `snapshot_priced_value_mismatch` error otherwise.
|
||
*/
|
||
value: number;
|
||
/**
|
||
* Category kind of the underlying account. Defaults to 'simple' to
|
||
* preserve the #146 callers that don't pass it. Priced lines must
|
||
* provide both `quantity` and `unit_price`.
|
||
*/
|
||
account_kind?: BalanceCategoryKind;
|
||
/** Required for priced lines, must be NULL for simple. */
|
||
quantity?: number | null;
|
||
/** Required for priced lines, must be NULL for simple. */
|
||
unit_price?: number | null;
|
||
}
|
||
|
||
/**
|
||
* Pure helper that validates a snapshot line against its account's
|
||
* category kind. Exposed for unit tests and used by `upsertSnapshotLines`
|
||
* before any DB mutation happens.
|
||
*
|
||
* Rules:
|
||
* - simple kind → quantity AND unit_price must be NULL/undefined; value
|
||
* must be a finite number.
|
||
* - priced kind → quantity AND unit_price must be finite numbers; value
|
||
* must equal quantity × unit_price within
|
||
* `PRICED_VALUE_TOLERANCE`.
|
||
*
|
||
* @throws `BalanceServiceError` with a typed code on the first failure.
|
||
*/
|
||
export function validateLineKindInvariants(
|
||
line: SnapshotLineInput,
|
||
accountKind: BalanceCategoryKind = line.account_kind ?? "simple"
|
||
): void {
|
||
if (typeof line.value !== "number" || !Number.isFinite(line.value)) {
|
||
throw new BalanceServiceError(
|
||
"snapshot_value_invalid",
|
||
`Line for account ${line.account_id}: value must be a finite number`
|
||
);
|
||
}
|
||
if (accountKind === "simple") {
|
||
// Simple-kind: quantity / unit_price must be absent (NULL or undefined).
|
||
if (line.quantity !== undefined && line.quantity !== null) {
|
||
throw new BalanceServiceError(
|
||
"snapshot_simple_must_be_scalar",
|
||
`Line for account ${line.account_id}: simple-kind line must not carry quantity`
|
||
);
|
||
}
|
||
if (line.unit_price !== undefined && line.unit_price !== null) {
|
||
throw new BalanceServiceError(
|
||
"snapshot_simple_must_be_scalar",
|
||
`Line for account ${line.account_id}: simple-kind line must not carry unit_price`
|
||
);
|
||
}
|
||
return;
|
||
}
|
||
// Priced-kind: both fields required and finite.
|
||
if (
|
||
line.quantity === undefined ||
|
||
line.quantity === null ||
|
||
typeof line.quantity !== "number" ||
|
||
!Number.isFinite(line.quantity)
|
||
) {
|
||
throw new BalanceServiceError(
|
||
"snapshot_priced_quantity_required",
|
||
`Line for account ${line.account_id}: quantity is required for priced accounts`
|
||
);
|
||
}
|
||
if (
|
||
line.unit_price === undefined ||
|
||
line.unit_price === null ||
|
||
typeof line.unit_price !== "number" ||
|
||
!Number.isFinite(line.unit_price)
|
||
) {
|
||
throw new BalanceServiceError(
|
||
"snapshot_priced_unit_price_required",
|
||
`Line for account ${line.account_id}: unit_price is required for priced accounts`
|
||
);
|
||
}
|
||
const expected = line.quantity * line.unit_price;
|
||
if (Math.abs(expected - line.value) > PRICED_VALUE_TOLERANCE) {
|
||
throw new BalanceServiceError(
|
||
"snapshot_priced_value_mismatch",
|
||
`Line for account ${line.account_id}: value ${line.value} does not match quantity × unit_price (${expected})`
|
||
);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Upsert a batch of snapshot lines. Each input row is inserted or
|
||
* replaced atomically per account; lines for accounts not present in
|
||
* `lines` are removed from the snapshot. This makes the editor strictly
|
||
* state-driven — what the user sees is exactly what gets saved.
|
||
*
|
||
* Validation enforced ahead of time so the SQL CHECK never fires
|
||
* (`validateLineKindInvariants`):
|
||
* - simple kind → quantity / unit_price must be NULL; value must be finite.
|
||
* - priced kind → quantity / unit_price must be finite, and
|
||
* `value === quantity * unit_price` within
|
||
* `PRICED_VALUE_TOLERANCE`.
|
||
*
|
||
* The default `account_kind = 'simple'` preserves the #146 calling
|
||
* convention — callers that pre-classify their lines (which the priced
|
||
* editor in #140 must do) pass `account_kind: 'priced'` explicitly.
|
||
*/
|
||
export async function upsertSnapshotLines(
|
||
snapshotId: number,
|
||
lines: SnapshotLineInput[]
|
||
): Promise<void> {
|
||
const snapshot = await getSnapshotById(snapshotId);
|
||
if (!snapshot) {
|
||
throw new BalanceServiceError(
|
||
"snapshot_not_found",
|
||
`Snapshot ${snapshotId} not found`
|
||
);
|
||
}
|
||
// Validate every input up-front before mutating anything.
|
||
for (const line of lines) {
|
||
validateLineKindInvariants(line);
|
||
}
|
||
|
||
const db = await getDb();
|
||
// Strategy: clear and rewrite. Snapshot lines are small (one per active
|
||
// account, typically < 20) so the simplicity outweighs the diff-tracking
|
||
// savings. CASCADE guarantees consistency on partial failures.
|
||
await db.execute(
|
||
"DELETE FROM balance_snapshot_lines WHERE snapshot_id = $1",
|
||
[snapshotId]
|
||
);
|
||
for (const line of lines) {
|
||
const kind = line.account_kind ?? "simple";
|
||
if (kind === "simple") {
|
||
await db.execute(
|
||
`INSERT INTO balance_snapshot_lines
|
||
(snapshot_id, account_id, quantity, unit_price, value, price_source)
|
||
VALUES ($1, $2, NULL, NULL, $3, 'manual')`,
|
||
[snapshotId, line.account_id, line.value]
|
||
);
|
||
} else {
|
||
await db.execute(
|
||
`INSERT INTO balance_snapshot_lines
|
||
(snapshot_id, account_id, quantity, unit_price, value, price_source)
|
||
VALUES ($1, $2, $3, $4, $5, 'manual')`,
|
||
[
|
||
snapshotId,
|
||
line.account_id,
|
||
line.quantity,
|
||
line.unit_price,
|
||
line.value,
|
||
]
|
||
);
|
||
}
|
||
}
|
||
// Bump the parent snapshot's updated_at so list views can sort by recency.
|
||
await db.execute(
|
||
`UPDATE balance_snapshots
|
||
SET updated_at = CURRENT_TIMESTAMP
|
||
WHERE id = $1`,
|
||
[snapshotId]
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Convenience helper used by the "Prefill from previous snapshot" button.
|
||
* Returns the snapshot whose `snapshot_date` is strictly earlier than
|
||
* `referenceDate`, or `null` if none exists.
|
||
*/
|
||
export async function getPreviousSnapshot(
|
||
referenceDate: string
|
||
): Promise<BalanceSnapshot | null> {
|
||
const normalized = normalizeSnapshotDate(referenceDate);
|
||
const db = await getDb();
|
||
const rows = await db.select<BalanceSnapshot[]>(
|
||
`SELECT id, snapshot_date, notes, created_at, updated_at
|
||
FROM balance_snapshots
|
||
WHERE snapshot_date < $1
|
||
ORDER BY snapshot_date DESC
|
||
LIMIT 1`,
|
||
[normalized]
|
||
);
|
||
return rows[0] ?? null;
|
||
}
|
||
|
||
// -----------------------------------------------------------------------------
|
||
// Time-series aggregators (Issue #141 / Bilan #3) — used by BalancePage.
|
||
// -----------------------------------------------------------------------------
|
||
|
||
/**
|
||
* Optional [from, to] range filter expressed in ISO `YYYY-MM-DD` format.
|
||
* Both endpoints are inclusive. `from` and `to` may each be omitted to leave
|
||
* that side unbounded.
|
||
*/
|
||
export interface SnapshotDateRange {
|
||
from?: string;
|
||
to?: string;
|
||
}
|
||
|
||
/** Aggregated total at a given snapshot date. */
|
||
export interface SnapshotTotalPoint {
|
||
snapshot_date: string;
|
||
total: number;
|
||
}
|
||
|
||
function buildDateRangeClause(
|
||
range: SnapshotDateRange | undefined,
|
||
baseAlias: string
|
||
): { clause: string; params: unknown[] } {
|
||
if (!range || (!range.from && !range.to)) {
|
||
return { clause: "", params: [] };
|
||
}
|
||
const parts: string[] = [];
|
||
const params: unknown[] = [];
|
||
if (range.from) {
|
||
const from = normalizeSnapshotDate(range.from);
|
||
parts.push(`${baseAlias}.snapshot_date >= $${params.length + 1}`);
|
||
params.push(from);
|
||
}
|
||
if (range.to) {
|
||
const to = normalizeSnapshotDate(range.to);
|
||
parts.push(`${baseAlias}.snapshot_date <= $${params.length + 1}`);
|
||
params.push(to);
|
||
}
|
||
return { clause: `WHERE ${parts.join(" AND ")}`, params };
|
||
}
|
||
|
||
/**
|
||
* Returns the aggregated total value of every snapshot, sorted by date ASC.
|
||
* Used by the line variant of the evolution chart on `/balance`.
|
||
*
|
||
* The aggregation is `SUM(value) GROUP BY snapshot_date` — every account
|
||
* contributing to the snapshot is summed in. Snapshots with no lines
|
||
* collapse to a `total = 0` row (preserved so the chart shows continuity).
|
||
*/
|
||
export async function getSnapshotTotalsByDate(
|
||
range?: SnapshotDateRange
|
||
): Promise<SnapshotTotalPoint[]> {
|
||
const { clause, params } = buildDateRangeClause(range, "s");
|
||
const db = await getDb();
|
||
return db.select<SnapshotTotalPoint[]>(
|
||
`SELECT s.snapshot_date AS snapshot_date,
|
||
COALESCE(SUM(l.value), 0) AS total
|
||
FROM balance_snapshots s
|
||
LEFT JOIN balance_snapshot_lines l ON l.snapshot_id = s.id
|
||
${clause}
|
||
GROUP BY s.snapshot_date
|
||
ORDER BY s.snapshot_date ASC`,
|
||
params
|
||
);
|
||
}
|
||
|
||
/** Per-snapshot breakdown by category. */
|
||
export interface SnapshotCategoryBreakdownPoint {
|
||
snapshot_date: string;
|
||
byCategory: Record<string, number>;
|
||
}
|
||
|
||
interface RawCategoryBreakdownRow {
|
||
snapshot_date: string;
|
||
category_key: string;
|
||
total: number;
|
||
}
|
||
|
||
/**
|
||
* Returns per-snapshot totals broken down by `balance_categories.key`,
|
||
* sorted by date ASC. Used by the stacked-area variant of the evolution
|
||
* chart. Categories with no value at a given date are omitted from the
|
||
* `byCategory` map (chart consumers should treat absent keys as zero).
|
||
*
|
||
* Lines whose joined account points to no category are skipped — that
|
||
* shouldn't happen given FK RESTRICT but the JOIN is defensive.
|
||
*/
|
||
export async function getSnapshotTotalsByCategoryAndDate(
|
||
range?: SnapshotDateRange
|
||
): Promise<SnapshotCategoryBreakdownPoint[]> {
|
||
const { clause, params } = buildDateRangeClause(range, "s");
|
||
const db = await getDb();
|
||
const rows = await db.select<RawCategoryBreakdownRow[]>(
|
||
`SELECT s.snapshot_date AS snapshot_date,
|
||
c.key AS category_key,
|
||
COALESCE(SUM(l.value), 0) AS total
|
||
FROM balance_snapshots s
|
||
INNER JOIN balance_snapshot_lines l ON l.snapshot_id = s.id
|
||
INNER JOIN balance_accounts a ON a.id = l.account_id
|
||
INNER JOIN balance_categories c ON c.id = a.balance_category_id
|
||
${clause}
|
||
GROUP BY s.snapshot_date, c.key
|
||
ORDER BY s.snapshot_date ASC, c.key ASC`,
|
||
params
|
||
);
|
||
// Bucket rows by snapshot_date — many rows per date, one per category.
|
||
const out: SnapshotCategoryBreakdownPoint[] = [];
|
||
let current: SnapshotCategoryBreakdownPoint | null = null;
|
||
for (const r of rows) {
|
||
if (!current || current.snapshot_date !== r.snapshot_date) {
|
||
current = { snapshot_date: r.snapshot_date, byCategory: {} };
|
||
out.push(current);
|
||
}
|
||
current.byCategory[r.category_key] = r.total;
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/** Latest-snapshot value per active account (Issue #141). */
|
||
export interface AccountLatestSnapshot {
|
||
account_id: number;
|
||
account_name: string;
|
||
symbol: string | null;
|
||
balance_category_id: number;
|
||
category_key: string;
|
||
category_i18n_key: string;
|
||
category_kind: BalanceCategoryKind;
|
||
/** Date of the snapshot whose value is reported, or null if no snapshot exists. */
|
||
latest_snapshot_date: string | null;
|
||
/** Value at that snapshot, or null if the account has no snapshot lines. */
|
||
latest_value: number | null;
|
||
}
|
||
|
||
/**
|
||
* Returns one row per active (non-archived) account with the value of its
|
||
* most-recent snapshot line. Accounts with no snapshot rows yet still
|
||
* appear, with `latest_value = null`. Used by the accounts table on
|
||
* `/balance` (#141) and as a building block for the period Δ% column.
|
||
*
|
||
* Implementation: a correlated subquery picks the line with the largest
|
||
* `s.snapshot_date` for each account — SQLite handles this fine on the
|
||
* indexed `balance_snapshots.snapshot_date` and `balance_snapshot_lines.account_id`.
|
||
*/
|
||
export async function getAccountsLatestSnapshot(): Promise<
|
||
AccountLatestSnapshot[]
|
||
> {
|
||
const db = await getDb();
|
||
return db.select<AccountLatestSnapshot[]>(
|
||
`SELECT a.id AS account_id,
|
||
a.name AS account_name,
|
||
a.symbol AS symbol,
|
||
a.balance_category_id AS balance_category_id,
|
||
c.key AS category_key,
|
||
c.i18n_key AS category_i18n_key,
|
||
c.kind AS category_kind,
|
||
(SELECT s.snapshot_date
|
||
FROM balance_snapshot_lines l
|
||
JOIN balance_snapshots s ON s.id = l.snapshot_id
|
||
WHERE l.account_id = a.id
|
||
ORDER BY s.snapshot_date DESC
|
||
LIMIT 1) AS latest_snapshot_date,
|
||
(SELECT l.value
|
||
FROM balance_snapshot_lines l
|
||
JOIN balance_snapshots s ON s.id = l.snapshot_id
|
||
WHERE l.account_id = a.id
|
||
ORDER BY s.snapshot_date DESC
|
||
LIMIT 1) AS latest_value
|
||
FROM balance_accounts a
|
||
INNER JOIN balance_categories c ON c.id = a.balance_category_id
|
||
WHERE a.is_active = 1 AND a.archived_at IS NULL
|
||
ORDER BY c.sort_order, a.name`
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Returns the value at the earliest snapshot for each account whose
|
||
* `snapshot_date` is `>= range.from` (and `<= range.to` when set), so the
|
||
* accounts table can compute a per-account Δ% over the selected period.
|
||
*
|
||
* Returns one row per account with a snapshot in range. Accounts without
|
||
* any snapshot in the period are omitted — callers default their Δ% to
|
||
* `null` (rendered as "—").
|
||
*/
|
||
export interface AccountPeriodAnchor {
|
||
account_id: number;
|
||
anchor_snapshot_date: string;
|
||
anchor_value: number;
|
||
}
|
||
|
||
export async function getAccountsPeriodAnchor(
|
||
range: SnapshotDateRange
|
||
): Promise<AccountPeriodAnchor[]> {
|
||
// For each account, find the earliest snapshot_date >= range.from (and
|
||
// <= range.to when set), then read that line's value.
|
||
const params: unknown[] = [];
|
||
const conditions: string[] = [];
|
||
if (range.from) {
|
||
conditions.push(`s.snapshot_date >= $${params.length + 1}`);
|
||
params.push(normalizeSnapshotDate(range.from));
|
||
}
|
||
if (range.to) {
|
||
conditions.push(`s.snapshot_date <= $${params.length + 1}`);
|
||
params.push(normalizeSnapshotDate(range.to));
|
||
}
|
||
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
||
const db = await getDb();
|
||
return db.select<AccountPeriodAnchor[]>(
|
||
`SELECT l.account_id AS account_id,
|
||
MIN(s.snapshot_date) AS anchor_snapshot_date,
|
||
(SELECT l2.value
|
||
FROM balance_snapshot_lines l2
|
||
JOIN balance_snapshots s2 ON s2.id = l2.snapshot_id
|
||
WHERE l2.account_id = l.account_id
|
||
AND s2.snapshot_date = MIN(s.snapshot_date)
|
||
LIMIT 1) AS anchor_value
|
||
FROM balance_snapshot_lines l
|
||
JOIN balance_snapshots s ON s.id = l.snapshot_id
|
||
${where}
|
||
GROUP BY l.account_id`,
|
||
params
|
||
);
|
||
}
|