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,
|
||||
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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue