feat(balance): extend balance.service with snapshots + lines (simple kind)

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) <noreply@anthropic.com>
This commit is contained in:
le king fu 2026-04-25 14:49:19 -04:00
parent 4c71eaca2d
commit afc338b564
3 changed files with 522 additions and 1 deletions

View file

@ -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",
});
});
});

View file

@ -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<void> {
[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]
);
}
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<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) {
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<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;
}

View file

@ -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;
}