Simpl-Resultat/src/services/balance.service.ts
le king fu 396310aa74 feat(balance): add timeseries aggregator helpers + tests
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>
2026-04-25 16:06:23 -04:00

957 lines
31 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// balance.service.ts — domain service for the Bilan (balance sheet) feature.
//
// Scope at Issue #138 (Bilan #1a): CRUD for `balance_categories` and
// `balance_accounts` only. Snapshots, snapshot lines, transfers and price
// fetching ship in subsequent issues (#1b / #2 / #4 / #5).
//
// CRUD goes through `getDb()` + tauri-plugin-sql directly — the project
// convention for database operations. Tauri commands are reserved for
// filesystem / OAuth / license / profile work and the future Modified Dietz
// + price-fetch work in Issue #142.
import { 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
);
}