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:
parent
4c71eaca2d
commit
afc338b564
3 changed files with 522 additions and 1 deletions
|
|
@ -15,6 +15,14 @@ import {
|
||||||
updateBalanceAccount,
|
updateBalanceAccount,
|
||||||
archiveBalanceAccount,
|
archiveBalanceAccount,
|
||||||
unarchiveBalanceAccount,
|
unarchiveBalanceAccount,
|
||||||
|
listSnapshots,
|
||||||
|
getSnapshotByDate,
|
||||||
|
createSnapshot,
|
||||||
|
updateSnapshot,
|
||||||
|
deleteSnapshot,
|
||||||
|
listLinesBySnapshot,
|
||||||
|
upsertSnapshotLines,
|
||||||
|
getPreviousSnapshot,
|
||||||
BalanceServiceError,
|
BalanceServiceError,
|
||||||
} from "./balance.service";
|
} from "./balance.service";
|
||||||
|
|
||||||
|
|
@ -314,3 +322,227 @@ describe("archiveBalanceAccount / unarchiveBalanceAccount", () => {
|
||||||
expect(sql).toContain("is_active = 1");
|
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",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,8 @@ import type {
|
||||||
BalanceAccountWithCategory,
|
BalanceAccountWithCategory,
|
||||||
BalanceCategory,
|
BalanceCategory,
|
||||||
BalanceCategoryKind,
|
BalanceCategoryKind,
|
||||||
|
BalanceSnapshot,
|
||||||
|
BalanceSnapshotLine,
|
||||||
} from "../shared/types";
|
} from "../shared/types";
|
||||||
import { BALANCE_CURRENCY_CAD } from "../shared/types";
|
import { BALANCE_CURRENCY_CAD } from "../shared/types";
|
||||||
|
|
||||||
|
|
@ -29,7 +31,12 @@ export type BalanceErrorCode =
|
||||||
| "category_not_found"
|
| "category_not_found"
|
||||||
| "account_not_found"
|
| "account_not_found"
|
||||||
| "name_required"
|
| "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 {
|
export class BalanceServiceError extends Error {
|
||||||
readonly code: BalanceErrorCode;
|
readonly code: BalanceErrorCode;
|
||||||
|
|
@ -358,3 +365,256 @@ export async function unarchiveBalanceAccount(id: number): Promise<void> {
|
||||||
[id]
|
[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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -601,3 +601,32 @@ export interface BalanceAccountWithCategory extends BalanceAccount {
|
||||||
category_i18n_key: string;
|
category_i18n_key: string;
|
||||||
category_kind: BalanceCategoryKind;
|
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;
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue