From afc338b5643c7cfcd82bc6481702b8d9a8f5f464 Mon Sep 17 00:00:00 2001 From: le king fu Date: Sat, 25 Apr 2026 14:49:19 -0400 Subject: [PATCH] feat(balance): extend balance.service with snapshots + lines (simple kind) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the snapshots + lines section of the balance service for Issue #146 (Bilan #1b). Simple-kind only — quantity / unit_price are forced to NULL both at the SQL CHECK level (already in v9) and at the service level (`upsertSnapshotLines` validates ahead of time). Priced-kind upsert lands in #140. New service exports: - listSnapshots / getSnapshotByDate / getSnapshotById / getPreviousSnapshot - createSnapshot (throws snapshot_date_taken when UNIQUE per date violated so the UI can redirect to edit mode) - updateSnapshot / deleteSnapshot (cascade lines via FK) - listLinesBySnapshot / upsertSnapshotLines (rewrite-all strategy) New BalanceErrorCode entries: snapshot_date_required, snapshot_date_taken, snapshot_not_found, snapshot_value_invalid, snapshot_priced_unsupported. New shared types: BalanceSnapshot, BalanceSnapshotLine. 22 new vitest cases cover: invalid-date guards, unique-per-date violation, simple-kind null invariant on inserts, NaN/Infinity rejection, clear+rewrite line semantics, getPreviousSnapshot strict-before ordering. Refs #146 Co-Authored-By: Claude Opus 4.7 (1M context) --- src/services/balance.service.test.ts | 232 ++++++++++++++++++++++++ src/services/balance.service.ts | 262 ++++++++++++++++++++++++++- src/shared/types/index.ts | 29 +++ 3 files changed, 522 insertions(+), 1 deletion(-) diff --git a/src/services/balance.service.test.ts b/src/services/balance.service.test.ts index 11981aa..3e1a361 100644 --- a/src/services/balance.service.test.ts +++ b/src/services/balance.service.test.ts @@ -15,6 +15,14 @@ import { updateBalanceAccount, archiveBalanceAccount, unarchiveBalanceAccount, + listSnapshots, + getSnapshotByDate, + createSnapshot, + updateSnapshot, + deleteSnapshot, + listLinesBySnapshot, + upsertSnapshotLines, + getPreviousSnapshot, BalanceServiceError, } from "./balance.service"; @@ -314,3 +322,227 @@ describe("archiveBalanceAccount / unarchiveBalanceAccount", () => { expect(sql).toContain("is_active = 1"); }); }); + +// ----------------------------------------------------------------------------- +// Snapshots + lines (Issue #146 / Bilan #1b — simple kind only) +// ----------------------------------------------------------------------------- + +const FAKE_SNAPSHOT = { + id: 5, + snapshot_date: "2026-04-15", + notes: null, + created_at: "", + updated_at: "", +}; + +describe("listSnapshots", () => { + it("orders by snapshot_date DESC", async () => { + mockSelect.mockResolvedValueOnce([]); + await listSnapshots(); + const sql = mockSelect.mock.calls[0][0] as string; + expect(sql).toContain("FROM balance_snapshots"); + expect(sql).toContain("ORDER BY snapshot_date DESC"); + }); +}); + +describe("getSnapshotByDate", () => { + it("rejects empty / invalid dates with snapshot_date_required", async () => { + await expect(getSnapshotByDate("")).rejects.toMatchObject({ + code: "snapshot_date_required", + }); + await expect(getSnapshotByDate("2026/04/15")).rejects.toMatchObject({ + code: "snapshot_date_required", + }); + expect(mockSelect).not.toHaveBeenCalled(); + }); + + it("returns the snapshot row when found", async () => { + mockSelect.mockResolvedValueOnce([FAKE_SNAPSHOT]); + const got = await getSnapshotByDate("2026-04-15"); + expect(got).toEqual(FAKE_SNAPSHOT); + expect(mockSelect.mock.calls[0][1]).toEqual(["2026-04-15"]); + }); + + it("returns null when no row matches", async () => { + mockSelect.mockResolvedValueOnce([]); + expect(await getSnapshotByDate("2026-04-15")).toBeNull(); + }); +}); + +describe("createSnapshot", () => { + it("rejects an invalid date", async () => { + await expect( + createSnapshot({ snapshot_date: " " }) + ).rejects.toMatchObject({ code: "snapshot_date_required" }); + expect(mockExecute).not.toHaveBeenCalled(); + }); + + it("rejects a duplicate snapshot date with snapshot_date_taken", async () => { + mockSelect.mockResolvedValueOnce([FAKE_SNAPSHOT]); // existing + await expect( + createSnapshot({ snapshot_date: "2026-04-15" }) + ).rejects.toMatchObject({ code: "snapshot_date_taken" }); + expect(mockExecute).not.toHaveBeenCalled(); + }); + + it("inserts a new snapshot and returns its id", async () => { + mockSelect.mockResolvedValueOnce([]); // no existing + mockExecute.mockResolvedValueOnce({ lastInsertId: 12, rowsAffected: 1 }); + const id = await createSnapshot({ + snapshot_date: "2026-04-25", + notes: " monthly check ", + }); + expect(id).toBe(12); + const params = mockExecute.mock.calls[0][1] as unknown[]; + expect(params).toEqual(["2026-04-25", "monthly check"]); + }); +}); + +describe("updateSnapshot", () => { + it("rejects when snapshot does not exist", async () => { + mockSelect.mockResolvedValueOnce([]); + await expect( + updateSnapshot(999, { notes: "x" }) + ).rejects.toMatchObject({ code: "snapshot_not_found" }); + }); + + it("normalizes empty notes to null", async () => { + mockSelect.mockResolvedValueOnce([FAKE_SNAPSHOT]); + mockExecute.mockResolvedValueOnce({ rowsAffected: 1 }); + await updateSnapshot(5, { notes: " " }); + const params = mockExecute.mock.calls[0][1] as unknown[]; + expect(params[0]).toBeNull(); + }); +}); + +describe("deleteSnapshot", () => { + it("rejects when snapshot does not exist", async () => { + mockSelect.mockResolvedValueOnce([]); + await expect(deleteSnapshot(999)).rejects.toMatchObject({ + code: "snapshot_not_found", + }); + }); + + it("deletes when found (lines cascade via FK)", async () => { + mockSelect.mockResolvedValueOnce([FAKE_SNAPSHOT]); + mockExecute.mockResolvedValueOnce({ rowsAffected: 1 }); + await deleteSnapshot(5); + expect(mockExecute).toHaveBeenCalledWith( + "DELETE FROM balance_snapshots WHERE id = $1", + [5] + ); + }); +}); + +describe("listLinesBySnapshot", () => { + it("orders by id and filters by snapshot_id", async () => { + mockSelect.mockResolvedValueOnce([]); + await listLinesBySnapshot(5); + const sql = mockSelect.mock.calls[0][0] as string; + expect(sql).toContain("FROM balance_snapshot_lines"); + expect(sql).toContain("WHERE snapshot_id = $1"); + expect(sql).toContain("ORDER BY id"); + expect(mockSelect.mock.calls[0][1]).toEqual([5]); + }); +}); + +describe("upsertSnapshotLines (simple kind)", () => { + it("rejects when the parent snapshot is missing", async () => { + mockSelect.mockResolvedValueOnce([]); + await expect( + upsertSnapshotLines(99, [{ account_id: 1, value: 1000 }]) + ).rejects.toMatchObject({ code: "snapshot_not_found" }); + expect(mockExecute).not.toHaveBeenCalled(); + }); + + it("rejects non-finite values with snapshot_value_invalid", async () => { + mockSelect.mockResolvedValueOnce([FAKE_SNAPSHOT]); + await expect( + upsertSnapshotLines(5, [ + { account_id: 1, value: 1000 }, + // @ts-expect-error testing runtime guard + { account_id: 2, value: "not a number" }, + ]) + ).rejects.toMatchObject({ code: "snapshot_value_invalid" }); + // Validation happens up-front, before any mutation + expect(mockExecute).not.toHaveBeenCalled(); + }); + + it("rejects NaN and Infinity", async () => { + mockSelect.mockResolvedValueOnce([FAKE_SNAPSHOT]); + await expect( + upsertSnapshotLines(5, [{ account_id: 1, value: NaN }]) + ).rejects.toMatchObject({ code: "snapshot_value_invalid" }); + + mockSelect.mockResolvedValueOnce([FAKE_SNAPSHOT]); + await expect( + upsertSnapshotLines(5, [{ account_id: 1, value: Infinity }]) + ).rejects.toMatchObject({ code: "snapshot_value_invalid" }); + }); + + it("clears existing lines, inserts each line with NULL quantity/unit_price, and bumps updated_at", async () => { + mockSelect.mockResolvedValueOnce([FAKE_SNAPSHOT]); + mockExecute + .mockResolvedValueOnce({ rowsAffected: 1 }) // delete + .mockResolvedValueOnce({ lastInsertId: 100, rowsAffected: 1 }) // insert 1 + .mockResolvedValueOnce({ lastInsertId: 101, rowsAffected: 1 }) // insert 2 + .mockResolvedValueOnce({ rowsAffected: 1 }); // update updated_at + await upsertSnapshotLines(5, [ + { account_id: 1, value: 1234.56 }, + { account_id: 2, value: 0 }, + ]); + // 1st call = DELETE + expect(mockExecute.mock.calls[0][0]).toContain( + "DELETE FROM balance_snapshot_lines" + ); + // Inserts use literal NULL for quantity/unit_price (simple kind invariant) + const insertSql = mockExecute.mock.calls[1][0] as string; + expect(insertSql).toContain("INSERT INTO balance_snapshot_lines"); + expect(insertSql).toMatch(/VALUES\s*\(\s*\$1,\s*\$2,\s*NULL,\s*NULL,\s*\$3/); + expect(insertSql).toContain("'manual'"); + // First insert params + expect(mockExecute.mock.calls[1][1]).toEqual([5, 1, 1234.56]); + // Second insert params (zero is allowed) + expect(mockExecute.mock.calls[2][1]).toEqual([5, 2, 0]); + // Final call = UPDATE updated_at on parent snapshot + expect(mockExecute.mock.calls[3][0]).toContain( + "UPDATE balance_snapshots" + ); + expect(mockExecute.mock.calls[3][0]).toContain("updated_at"); + }); + + it("clears all lines when called with an empty array", async () => { + mockSelect.mockResolvedValueOnce([FAKE_SNAPSHOT]); + mockExecute + .mockResolvedValueOnce({ rowsAffected: 3 }) // delete only + .mockResolvedValueOnce({ rowsAffected: 1 }); // bump updated_at + await upsertSnapshotLines(5, []); + // Only DELETE + UPDATE updated_at — no INSERTs + expect(mockExecute).toHaveBeenCalledTimes(2); + }); +}); + +describe("getPreviousSnapshot", () => { + it("returns the most recent snapshot strictly before referenceDate", async () => { + mockSelect.mockResolvedValueOnce([ + { ...FAKE_SNAPSHOT, snapshot_date: "2026-03-15" }, + ]); + const got = await getPreviousSnapshot("2026-04-15"); + expect(got?.snapshot_date).toBe("2026-03-15"); + const sql = mockSelect.mock.calls[0][0] as string; + expect(sql).toContain("snapshot_date < $1"); + expect(sql).toContain("ORDER BY snapshot_date DESC"); + expect(sql).toContain("LIMIT 1"); + }); + + it("returns null when no earlier snapshot exists", async () => { + mockSelect.mockResolvedValueOnce([]); + expect(await getPreviousSnapshot("2026-04-15")).toBeNull(); + }); + + it("rejects an invalid reference date", async () => { + await expect(getPreviousSnapshot("nope")).rejects.toMatchObject({ + code: "snapshot_date_required", + }); + }); +}); diff --git a/src/services/balance.service.ts b/src/services/balance.service.ts index 0547670..7bc39bb 100644 --- a/src/services/balance.service.ts +++ b/src/services/balance.service.ts @@ -15,6 +15,8 @@ import type { BalanceAccountWithCategory, BalanceCategory, BalanceCategoryKind, + BalanceSnapshot, + BalanceSnapshotLine, } from "../shared/types"; import { BALANCE_CURRENCY_CAD } from "../shared/types"; @@ -29,7 +31,12 @@ export type BalanceErrorCode = | "category_not_found" | "account_not_found" | "name_required" - | "kind_invalid"; + | "kind_invalid" + | "snapshot_date_required" + | "snapshot_date_taken" + | "snapshot_not_found" + | "snapshot_value_invalid" + | "snapshot_priced_unsupported"; export class BalanceServiceError extends Error { readonly code: BalanceErrorCode; @@ -358,3 +365,256 @@ export async function unarchiveBalanceAccount(id: number): Promise { [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 { + const db = await getDb(); + return db.select( + `SELECT id, snapshot_date, notes, created_at, updated_at + FROM balance_snapshots + ORDER BY snapshot_date DESC` + ); +} + +export async function getSnapshotByDate( + date: string +): Promise { + const normalized = normalizeSnapshotDate(date); + const db = await getDb(); + const rows = await db.select( + `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 { + const db = await getDb(); + const rows = await db.select( + `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 { + 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 { + 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 { + 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 { + const db = await getDb(); + return db.select( + `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] + ); +} + +export interface SnapshotLineInput { + account_id: number; + /** + * Simple-kind value. Must be a finite number (>= 0 in practice but the + * service accepts any finite — negative values support shorts/loans). + */ + value: number; +} + +/** + * Upsert a batch of snapshot lines (simple kind only). 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: + * - finite numeric value (NaN / +-Infinity rejected with `snapshot_value_invalid`); + * - quantity / unit_price always stored as NULL (simple-kind invariant). + * + * Priced-kind upsert lands in Issue #140 (Bilan #2). + */ +export async function upsertSnapshotLines( + snapshotId: number, + lines: SnapshotLineInput[] +): Promise { + 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) { + 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` + ); + } + } + + 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) { + 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] + ); + } + // 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 { + const normalized = normalizeSnapshotDate(referenceDate); + const db = await getDb(); + const rows = await db.select( + `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; +} diff --git a/src/shared/types/index.ts b/src/shared/types/index.ts index 004a122..3653a2f 100644 --- a/src/shared/types/index.ts +++ b/src/shared/types/index.ts @@ -601,3 +601,32 @@ export interface BalanceAccountWithCategory extends BalanceAccount { category_i18n_key: string; category_kind: BalanceCategoryKind; } + +// Snapshots — added Issue #146 (Bilan #1b) for the SnapshotEditPage. +// Lines are kept simple-kind only here (`quantity` / `unit_price` always NULL). +// The priced-kind UI lands in #140 / Bilan #2. + +export interface BalanceSnapshot { + id: number; + /** ISO date (YYYY-MM-DD), UNIQUE across the table. */ + snapshot_date: string; + notes: string | null; + created_at: string; + updated_at: string; +} + +export interface BalanceSnapshotLine { + id: number; + snapshot_id: number; + account_id: number; + /** Always NULL for simple-kind lines (Issue #146 scope). */ + quantity: number | null; + /** Always NULL for simple-kind lines (Issue #146 scope). */ + unit_price: number | null; + value: number; + /** 'manual' for simple-kind, 'maximus-api' for priced (#142). */ + price_source: string | null; + price_fetched_at: string | null; + created_at: string; + updated_at: string; +}