Compare commits

..

2 commits

Author SHA1 Message Date
205e1ded54 Merge pull request 'feat(balance): securities service + detailed snapshot save (#212)' (#221) from issue-212-service-securities into main 2026-06-10 01:07:48 +00:00
le king fu
582cf4012d feat(balance): securities service + transactional detailed snapshot save (#212)
Service layer for detailed (per-security) balance accounts:

- findOrCreateSecurity (UPSERT on normalized UPPER(TRIM) symbol, callable
  in-txn via an executor), listSecurities, getSecurity, updateSecurity.
- saveSnapshotAtomic / upsertSnapshotLines: a detailed account (line carrying
  a holdings array) writes its aggregated line (value = rounded-cent SUM,
  qty/price NULL) AND its holdings in the SAME BEGIN/COMMIT; the line id is
  captured, existing holdings DELETEd, each security find-or-created and each
  holding INSERTed. A holding-insert failure rolls the whole save back. Simple
  / legacy-priced scalar path is unchanged. upsertSnapshotLines is now wrapped
  in an explicit transaction for the same atomicity.
- validateDetailedSnapshot: detailed+holdings => line qty/price NULL and
  value === rounded-cent SUM(holdings) compared EXACTLY (no float tolerance);
  detailed without holdings => pre-pivot aggregated tolerated.
  validateLineKindInvariants stays byte-for-byte for the scalar path.
- roundToCent helper; detailed path uses per-holding cent rounding then exact
  comparison to avoid N-holding rounding accumulation (decision 2026-06-03).
- Service backstop in updateBalanceAccount: detailed->simple rejected with a
  typed error (account_kind_detailed_has_holdings) when holdings exist; adds
  kind/detailed_since to the account input + SELECT.
- getHoldingsForLatestSnapshot (prefill; excludes quantity-0 positions),
  listHoldingsBySnapshotLine (drill-down).
- computeUnrealizedGain: per-holding and aggregated value - book_cost and %;
  book_cost = 0 OR NULL => gain % null (no divide-by-zero); NULL book_cost
  excluded from the aggregate and flagged.

Existing aggregators (getSnapshotTotalsBy*) and computeAccountReturn untouched.
Unit tests for every new function incl. casing dedup, N>=20 holdings rounding,
book_cost=0/NULL, detailed->simple guard, atomic save + rollback. Existing
upsertSnapshotLines/updateBalanceAccount tests updated for the BEGIN/COMMIT
wrapping and the kind/detailed_since columns.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 13:16:03 -04:00
2 changed files with 1324 additions and 93 deletions

View file

@ -36,6 +36,17 @@ import {
saveSnapshotAtomic, saveSnapshotAtomic,
getPreviousSnapshot, getPreviousSnapshot,
validateLineKindInvariants, validateLineKindInvariants,
validateDetailedSnapshot,
isDetailedLine,
normalizeSecuritySymbol,
roundToCent,
findOrCreateSecurity,
listSecurities,
getSecurity,
updateSecurity,
listHoldingsBySnapshotLine,
getHoldingsForLatestSnapshot,
computeUnrealizedGain,
PRICED_VALUE_TOLERANCE, PRICED_VALUE_TOLERANCE,
BalanceServiceError, BalanceServiceError,
getSnapshotTotalsByDate, getSnapshotTotalsByDate,
@ -609,6 +620,8 @@ describe("balance accounts — vehicle_type (#202)", () => {
is_active: 1, is_active: 1,
archived_at: null, archived_at: null,
vehicle_type: "tfsa", vehicle_type: "tfsa",
kind: "simple",
detailed_since: null,
created_at: "", created_at: "",
updated_at: "", updated_at: "",
}, },
@ -633,6 +646,8 @@ describe("balance accounts — vehicle_type (#202)", () => {
is_active: 1, is_active: 1,
archived_at: null, archived_at: null,
vehicle_type: null, vehicle_type: null,
kind: "simple",
detailed_since: null,
created_at: "", created_at: "",
updated_at: "", updated_at: "",
}, },
@ -655,6 +670,8 @@ describe("balance accounts — vehicle_type (#202)", () => {
is_active: 1, is_active: 1,
archived_at: null, archived_at: null,
vehicle_type: "tfsa", vehicle_type: "tfsa",
kind: "simple",
detailed_since: null,
created_at: "", created_at: "",
updated_at: "", updated_at: "",
}, },
@ -722,6 +739,8 @@ describe("updateBalanceAccount", () => {
notes: null, notes: null,
is_active: 1, is_active: 1,
archived_at: null, archived_at: null,
kind: "simple",
detailed_since: null,
created_at: "", created_at: "",
updated_at: "", updated_at: "",
}, },
@ -938,43 +957,52 @@ describe("upsertSnapshotLines (simple kind)", () => {
it("clears existing lines, inserts each line with NULL quantity/unit_price, and bumps updated_at", async () => { it("clears existing lines, inserts each line with NULL quantity/unit_price, and bumps updated_at", async () => {
mockSelect.mockResolvedValueOnce([FAKE_SNAPSHOT]); mockSelect.mockResolvedValueOnce([FAKE_SNAPSHOT]);
// #212: the rewrite is now wrapped in BEGIN/COMMIT for holdings atomicity.
mockExecute mockExecute
.mockResolvedValueOnce({ rowsAffected: 0 }) // BEGIN
.mockResolvedValueOnce({ rowsAffected: 1 }) // delete .mockResolvedValueOnce({ rowsAffected: 1 }) // delete
.mockResolvedValueOnce({ lastInsertId: 100, rowsAffected: 1 }) // insert 1 .mockResolvedValueOnce({ lastInsertId: 100, rowsAffected: 1 }) // insert 1
.mockResolvedValueOnce({ lastInsertId: 101, rowsAffected: 1 }) // insert 2 .mockResolvedValueOnce({ lastInsertId: 101, rowsAffected: 1 }) // insert 2
.mockResolvedValueOnce({ rowsAffected: 1 }); // update updated_at .mockResolvedValueOnce({ rowsAffected: 1 }) // update updated_at
.mockResolvedValueOnce({ rowsAffected: 0 }); // COMMIT
await upsertSnapshotLines(5, [ await upsertSnapshotLines(5, [
{ account_id: 1, value: 1234.56 }, { account_id: 1, value: 1234.56 },
{ account_id: 2, value: 0 }, { account_id: 2, value: 0 },
]); ]);
// 1st call = DELETE // 1st call = BEGIN, 2nd = DELETE
expect(mockExecute.mock.calls[0][0]).toContain( expect(mockExecute.mock.calls[0][0]).toBe("BEGIN");
expect(mockExecute.mock.calls[1][0]).toContain(
"DELETE FROM balance_snapshot_lines" "DELETE FROM balance_snapshot_lines"
); );
// Inserts use literal NULL for quantity/unit_price (simple kind invariant) // Inserts use literal NULL for quantity/unit_price (simple kind invariant)
const insertSql = mockExecute.mock.calls[1][0] as string; const insertSql = mockExecute.mock.calls[2][0] as string;
expect(insertSql).toContain("INSERT INTO balance_snapshot_lines"); 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).toMatch(/VALUES\s*\(\s*\$1,\s*\$2,\s*NULL,\s*NULL,\s*\$3/);
expect(insertSql).toContain("'manual'"); expect(insertSql).toContain("'manual'");
// First insert params // First insert params
expect(mockExecute.mock.calls[1][1]).toEqual([5, 1, 1234.56]); expect(mockExecute.mock.calls[2][1]).toEqual([5, 1, 1234.56]);
// Second insert params (zero is allowed) // Second insert params (zero is allowed)
expect(mockExecute.mock.calls[2][1]).toEqual([5, 2, 0]); expect(mockExecute.mock.calls[3][1]).toEqual([5, 2, 0]);
// Final call = UPDATE updated_at on parent snapshot // UPDATE updated_at on parent snapshot, then COMMIT last.
expect(mockExecute.mock.calls[3][0]).toContain( expect(mockExecute.mock.calls[4][0]).toContain("UPDATE balance_snapshots");
"UPDATE balance_snapshots" expect(mockExecute.mock.calls[4][0]).toContain("updated_at");
expect(mockExecute.mock.calls[mockExecute.mock.calls.length - 1][0]).toBe(
"COMMIT"
); );
expect(mockExecute.mock.calls[3][0]).toContain("updated_at");
}); });
it("clears all lines when called with an empty array", async () => { it("clears all lines when called with an empty array", async () => {
mockSelect.mockResolvedValueOnce([FAKE_SNAPSHOT]); mockSelect.mockResolvedValueOnce([FAKE_SNAPSHOT]);
mockExecute mockExecute
.mockResolvedValueOnce({ rowsAffected: 0 }) // BEGIN
.mockResolvedValueOnce({ rowsAffected: 3 }) // delete only .mockResolvedValueOnce({ rowsAffected: 3 }) // delete only
.mockResolvedValueOnce({ rowsAffected: 1 }); // bump updated_at .mockResolvedValueOnce({ rowsAffected: 1 }) // bump updated_at
.mockResolvedValueOnce({ rowsAffected: 0 }); // COMMIT
await upsertSnapshotLines(5, []); await upsertSnapshotLines(5, []);
// Only DELETE + UPDATE updated_at — no INSERTs // BEGIN + DELETE + UPDATE updated_at + COMMIT — no INSERTs
expect(mockExecute).toHaveBeenCalledTimes(2); expect(mockExecute).toHaveBeenCalledTimes(4);
expect(mockExecute.mock.calls[0][0]).toBe("BEGIN");
expect(mockExecute.mock.calls[3][0]).toBe("COMMIT");
}); });
}); });
@ -1207,9 +1235,11 @@ describe("upsertSnapshotLines — priced kind", () => {
it("inserts a priced line with quantity + unit_price + value", async () => { it("inserts a priced line with quantity + unit_price + value", async () => {
mockSelect.mockResolvedValueOnce([FAKE_SNAPSHOT]); mockSelect.mockResolvedValueOnce([FAKE_SNAPSHOT]);
mockExecute mockExecute
.mockResolvedValueOnce({ rowsAffected: 0 }) // BEGIN
.mockResolvedValueOnce({ rowsAffected: 1 }) // delete .mockResolvedValueOnce({ rowsAffected: 1 }) // delete
.mockResolvedValueOnce({ lastInsertId: 100, rowsAffected: 1 }) // insert .mockResolvedValueOnce({ lastInsertId: 100, rowsAffected: 1 }) // insert
.mockResolvedValueOnce({ rowsAffected: 1 }); // bump updated_at .mockResolvedValueOnce({ rowsAffected: 1 }) // bump updated_at
.mockResolvedValueOnce({ rowsAffected: 0 }); // COMMIT
await upsertSnapshotLines(5, [ await upsertSnapshotLines(5, [
{ {
account_id: 7, account_id: 7,
@ -1219,20 +1249,22 @@ describe("upsertSnapshotLines — priced kind", () => {
value: 255, value: 255,
}, },
]); ]);
const insertSql = mockExecute.mock.calls[1][0] as string; const insertSql = mockExecute.mock.calls[2][0] as string;
expect(insertSql).toContain("INSERT INTO balance_snapshot_lines"); expect(insertSql).toContain("INSERT INTO balance_snapshot_lines");
// Priced inserts use parameter placeholders for qty/price (not literal NULLs) // Priced inserts use parameter placeholders for qty/price (not literal NULLs)
expect(insertSql).toMatch(/VALUES\s*\(\s*\$1,\s*\$2,\s*\$3,\s*\$4,\s*\$5/); expect(insertSql).toMatch(/VALUES\s*\(\s*\$1,\s*\$2,\s*\$3,\s*\$4,\s*\$5/);
expect(mockExecute.mock.calls[1][1]).toEqual([5, 7, 10, 25.5, 255]); expect(mockExecute.mock.calls[2][1]).toEqual([5, 7, 10, 25.5, 255]);
}); });
it("supports a mix of simple + priced lines in the same batch", async () => { it("supports a mix of simple + priced lines in the same batch", async () => {
mockSelect.mockResolvedValueOnce([FAKE_SNAPSHOT]); mockSelect.mockResolvedValueOnce([FAKE_SNAPSHOT]);
mockExecute mockExecute
.mockResolvedValueOnce({ rowsAffected: 0 }) // BEGIN
.mockResolvedValueOnce({ rowsAffected: 1 }) // delete .mockResolvedValueOnce({ rowsAffected: 1 }) // delete
.mockResolvedValueOnce({ lastInsertId: 100, rowsAffected: 1 }) // insert simple .mockResolvedValueOnce({ lastInsertId: 100, rowsAffected: 1 }) // insert simple
.mockResolvedValueOnce({ lastInsertId: 101, rowsAffected: 1 }) // insert priced .mockResolvedValueOnce({ lastInsertId: 101, rowsAffected: 1 }) // insert priced
.mockResolvedValueOnce({ rowsAffected: 1 }); // bump updated_at .mockResolvedValueOnce({ rowsAffected: 1 }) // bump updated_at
.mockResolvedValueOnce({ rowsAffected: 0 }); // COMMIT
await upsertSnapshotLines(5, [ await upsertSnapshotLines(5, [
{ account_id: 1, value: 1000 }, { account_id: 1, value: 1000 },
{ {
@ -1244,15 +1276,15 @@ describe("upsertSnapshotLines — priced kind", () => {
}, },
]); ]);
// Simple insert uses literal NULLs for qty/price // Simple insert uses literal NULLs for qty/price
expect(mockExecute.mock.calls[1][0] as string).toMatch( expect(mockExecute.mock.calls[2][0] as string).toMatch(
/VALUES\s*\(\s*\$1,\s*\$2,\s*NULL,\s*NULL,\s*\$3/ /VALUES\s*\(\s*\$1,\s*\$2,\s*NULL,\s*NULL,\s*\$3/
); );
expect(mockExecute.mock.calls[1][1]).toEqual([5, 1, 1000]); expect(mockExecute.mock.calls[2][1]).toEqual([5, 1, 1000]);
// Priced insert uses placeholders // Priced insert uses placeholders
expect(mockExecute.mock.calls[2][0] as string).toMatch( expect(mockExecute.mock.calls[3][0] as string).toMatch(
/VALUES\s*\(\s*\$1,\s*\$2,\s*\$3,\s*\$4,\s*\$5/ /VALUES\s*\(\s*\$1,\s*\$2,\s*\$3,\s*\$4,\s*\$5/
); );
expect(mockExecute.mock.calls[2][1]).toEqual([5, 7, 10, 50, 500]); expect(mockExecute.mock.calls[3][1]).toEqual([5, 7, 10, 50, 500]);
}); });
}); });
@ -2206,3 +2238,556 @@ describe("balance.service.prices", () => {
expect(invoke).not.toHaveBeenCalled(); expect(invoke).not.toHaveBeenCalled();
}); });
}); });
// =============================================================================
// Détail par titre (Issue #212) — securities CRUD, detailed save, holdings,
// unrealized gain, detailed→simple guard.
// =============================================================================
describe("normalizeSecuritySymbol", () => {
it("uppercases and trims", () => {
expect(normalizeSecuritySymbol(" aapl ")).toBe("AAPL");
expect(normalizeSecuritySymbol("btc")).toBe("BTC");
expect(normalizeSecuritySymbol("VeQt.TO")).toBe("VEQT.TO");
});
});
describe("roundToCent", () => {
it("rounds to two decimals (half-up at the cent)", () => {
expect(roundToCent(13.2038)).toBe(13.2);
expect(roundToCent(0.005)).toBe(0.01);
expect(roundToCent(100)).toBe(100);
expect(roundToCent(2.675)).toBeCloseTo(2.68, 5);
});
});
describe("findOrCreateSecurity", () => {
it("rejects an empty/whitespace symbol", async () => {
await expect(
findOrCreateSecurity({ symbol: " ", asset_type: "stock" })
).rejects.toMatchObject({ code: "security_symbol_required" });
expect(mockExecute).not.toHaveBeenCalled();
});
it("rejects an invalid asset_type", async () => {
await expect(
// @ts-expect-error runtime guard
findOrCreateSecurity({ symbol: "AAPL", asset_type: "bond" })
).rejects.toMatchObject({ code: "security_asset_type_invalid" });
expect(mockExecute).not.toHaveBeenCalled();
});
it("UPSERTs on the NORMALIZED symbol and returns the row (casing-dedup)", async () => {
mockExecute.mockResolvedValueOnce({ rowsAffected: 1 }); // upsert
mockSelect.mockResolvedValueOnce([
{
id: 3,
symbol: "AAPL",
name: null,
currency: "CAD",
asset_type: "stock",
created_at: "",
updated_at: "",
},
]);
const sec = await findOrCreateSecurity({
symbol: " aapl ",
asset_type: "stock",
});
expect(sec.id).toBe(3);
// The INSERT and the lookup both use the normalized symbol.
expect(mockExecute.mock.calls[0][1]?.[0]).toBe("AAPL");
expect(mockExecute.mock.calls[0][0]).toContain("ON CONFLICT(symbol) DO UPDATE");
expect(mockSelect.mock.calls[0][1]).toEqual(["AAPL"]);
});
it("a second call with different casing resolves the SAME row (dedup)", async () => {
// First create.
mockExecute.mockResolvedValueOnce({ rowsAffected: 1 });
mockSelect.mockResolvedValueOnce([
{ id: 9, symbol: "BTC", name: null, currency: "CAD", asset_type: "crypto", created_at: "", updated_at: "" },
]);
const a = await findOrCreateSecurity({ symbol: "btc", asset_type: "crypto" });
// Second call, different casing — the normalized conflict target returns id 9.
mockExecute.mockResolvedValueOnce({ rowsAffected: 1 });
mockSelect.mockResolvedValueOnce([
{ id: 9, symbol: "BTC", name: null, currency: "CAD", asset_type: "crypto", created_at: "", updated_at: "" },
]);
const b = await findOrCreateSecurity({ symbol: " BtC ", asset_type: "crypto" });
expect(a.id).toBe(b.id);
expect(b.id).toBe(9);
});
it("threads an executor when given (in-txn create)", async () => {
const execSelect = vi.fn().mockResolvedValueOnce([
{ id: 1, symbol: "VEQT.TO", name: null, currency: "CAD", asset_type: "stock", created_at: "", updated_at: "" },
]);
const execExecute = vi.fn().mockResolvedValueOnce({ rowsAffected: 1 });
const exec = { select: execSelect, execute: execExecute };
await findOrCreateSecurity({ symbol: "veqt.to", asset_type: "stock" }, exec);
// The provided executor was used, NOT the top-level db handle.
expect(execExecute).toHaveBeenCalledTimes(1);
expect(mockExecute).not.toHaveBeenCalled();
expect(mockSelect).not.toHaveBeenCalled();
});
});
describe("listSecurities / getSecurity / updateSecurity", () => {
it("listSecurities orders by symbol", async () => {
mockSelect.mockResolvedValueOnce([]);
await listSecurities();
expect(mockSelect.mock.calls[0][0]).toContain("FROM balance_securities");
expect(mockSelect.mock.calls[0][0]).toContain("ORDER BY symbol ASC");
});
it("getSecurity returns null when absent", async () => {
mockSelect.mockResolvedValueOnce([]);
expect(await getSecurity(404)).toBeNull();
});
it("updateSecurity rejects on missing id", async () => {
mockSelect.mockResolvedValueOnce([]); // getSecurity → none
await expect(updateSecurity(7, { name: "x" })).rejects.toMatchObject({
code: "security_not_found",
});
expect(mockExecute).not.toHaveBeenCalled();
});
it("updateSecurity re-normalizes symbol and validates asset_type", async () => {
mockSelect.mockResolvedValueOnce([
{ id: 7, symbol: "AAPL", name: "Apple", currency: "CAD", asset_type: "stock", created_at: "", updated_at: "" },
]);
mockExecute.mockResolvedValueOnce({ rowsAffected: 1 });
await updateSecurity(7, { symbol: " msft ", asset_type: "stock" });
const params = mockExecute.mock.calls[0][1] as unknown[];
expect(params[0]).toBe("MSFT"); // normalized
});
it("updateSecurity rejects an invalid asset_type", async () => {
mockSelect.mockResolvedValueOnce([
{ id: 7, symbol: "AAPL", name: null, currency: "CAD", asset_type: "stock", created_at: "", updated_at: "" },
]);
await expect(
// @ts-expect-error runtime guard
updateSecurity(7, { asset_type: "etf" })
).rejects.toMatchObject({ code: "security_asset_type_invalid" });
expect(mockExecute).not.toHaveBeenCalled();
});
});
describe("isDetailedLine", () => {
it("is true iff a holdings array is present (even empty)", () => {
expect(isDetailedLine({ account_id: 1, value: 0, holdings: [] })).toBe(true);
expect(
isDetailedLine({
account_id: 1,
value: 10,
holdings: [
{ symbol: "AAPL", asset_type: "stock", quantity: 1, unit_price: 10, value: 10 },
],
})
).toBe(true);
expect(isDetailedLine({ account_id: 1, value: 10 })).toBe(false);
});
});
describe("validateDetailedSnapshot", () => {
const h = (value: number, over: Partial<{ symbol: string; quantity: number; unit_price: number }> = {}) => ({
symbol: over.symbol ?? "AAPL",
asset_type: "stock" as const,
quantity: over.quantity ?? 1,
unit_price: over.unit_price ?? value,
value,
});
it("accepts a detailed line whose value = rounded-cent SUM of holdings", () => {
expect(() =>
validateDetailedSnapshot({
account_id: 7,
value: 300,
holdings: [h(100, { symbol: "AAPL" }), h(200, { symbol: "MSFT" })],
})
).not.toThrow();
});
it("tolerates a detailed account with NO holdings (pre-pivot aggregated)", () => {
expect(() =>
validateDetailedSnapshot({ account_id: 7, value: 5000, holdings: [] })
).not.toThrow();
});
it("rejects a detailed line that carries a scalar quantity/unit_price", () => {
expect(() =>
validateDetailedSnapshot({
account_id: 7,
value: 100,
quantity: 1,
holdings: [h(100)],
})
).toThrow(BalanceServiceError);
let scalarErr: unknown;
try {
validateDetailedSnapshot({
account_id: 7,
value: 100,
unit_price: 100,
holdings: [h(100)],
});
} catch (e) {
scalarErr = e;
}
expect((scalarErr as BalanceServiceError).code).toBe(
"snapshot_detailed_must_be_aggregate"
);
});
it("rejects when line value ≠ rounded-cent SUM", () => {
let caught: unknown;
try {
validateDetailedSnapshot({
account_id: 7,
value: 305, // should be 300
holdings: [h(100), h(200, { symbol: "MSFT" })],
});
} catch (e) {
caught = e;
}
expect((caught as BalanceServiceError).code).toBe(
"snapshot_detailed_value_mismatch"
);
});
it("rejects a holding with an empty symbol or bad asset_type or non-finite numbers", () => {
expect(() =>
validateDetailedSnapshot({
account_id: 7,
value: 10,
holdings: [{ symbol: " ", asset_type: "stock", quantity: 1, unit_price: 10, value: 10 }],
})
).toThrowError(/snapshot_holding_invalid|required/);
expect(() =>
validateDetailedSnapshot({
account_id: 7,
value: 10,
// @ts-expect-error runtime guard
holdings: [{ symbol: "AAPL", asset_type: "bond", quantity: 1, unit_price: 10, value: 10 }],
})
).toThrow(BalanceServiceError);
expect(() =>
validateDetailedSnapshot({
account_id: 7,
value: 10,
holdings: [{ symbol: "AAPL", asset_type: "stock", quantity: NaN, unit_price: 10, value: 10 }],
})
).toThrow(BalanceServiceError);
});
it("accepts N≥20 holdings — per-holding rounding never accumulates past a cent", () => {
// 21 positions each at 0.005 → roundToCent ⇒ 0.01 each ⇒ exact SUM 0.21.
// A naive Σ(raw)=0.105 with an absolute ε=0.01 would mis-handle this;
// rounding each first makes the comparison exact on whole cents.
const holdings = Array.from({ length: 21 }, (_, i) => ({
symbol: `S${i}`,
asset_type: "stock" as const,
quantity: 1,
unit_price: 0.005,
value: 0.005,
}));
expect(() =>
validateDetailedSnapshot({ account_id: 7, value: 0.21, holdings })
).not.toThrow();
// Off-by-a-cent must still be rejected exactly.
let offByCent: unknown;
try {
validateDetailedSnapshot({ account_id: 7, value: 0.22, holdings });
} catch (e) {
offByCent = e;
}
expect((offByCent as BalanceServiceError).code).toBe(
"snapshot_detailed_value_mismatch"
);
});
});
describe("saveSnapshotAtomic — detailed account (holdings)", () => {
it("writes the aggregated line + securities + holdings inside ONE BEGIN/COMMIT", async () => {
mockSelect
.mockResolvedValueOnce([]) // dup-check: free date
// findOrCreateSecurity AAPL lookup, then MSFT lookup
.mockResolvedValueOnce([
{ id: 11, symbol: "AAPL", name: null, currency: "CAD", asset_type: "stock", created_at: "", updated_at: "" },
])
.mockResolvedValueOnce([
{ id: 12, symbol: "MSFT", name: null, currency: "CAD", asset_type: "stock", created_at: "", updated_at: "" },
]);
mockExecute
.mockResolvedValueOnce({ rowsAffected: 0 }) // BEGIN
.mockResolvedValueOnce({ lastInsertId: 42, rowsAffected: 1 }) // INSERT snapshot
.mockResolvedValueOnce({ rowsAffected: 0 }) // DELETE lines
.mockResolvedValueOnce({ lastInsertId: 500, rowsAffected: 1 }) // INSERT aggregated line
.mockResolvedValueOnce({ rowsAffected: 0 }) // DELETE holdings for line 500
.mockResolvedValueOnce({ rowsAffected: 1 }) // UPSERT security AAPL
.mockResolvedValueOnce({ rowsAffected: 1 }) // INSERT holding AAPL
.mockResolvedValueOnce({ rowsAffected: 1 }) // UPSERT security MSFT
.mockResolvedValueOnce({ rowsAffected: 1 }) // INSERT holding MSFT
.mockResolvedValueOnce({ rowsAffected: 1 }) // UPDATE updated_at
.mockResolvedValueOnce({ rowsAffected: 0 }); // COMMIT
const res = await saveSnapshotAtomic({
existingSnapshotId: null,
snapshot_date: "2026-05-30",
lines: [
{
account_id: 7,
value: 300,
holdings: [
{ symbol: "aapl", asset_type: "stock", quantity: 2, unit_price: 50, value: 100, book_cost: 80 },
{ symbol: "msft", asset_type: "stock", quantity: 1, unit_price: 200, value: 200, book_cost: 150 },
],
},
],
});
expect(res.snapshotId).toBe(42);
const calls = mockExecute.mock.calls.map((c: unknown[]) => String(c[0]));
// The aggregated line is inserted with NULL qty/price and the SUM value.
const aggInsertIdx = calls.findIndex((s: string) => s.includes("INSERT INTO balance_snapshot_lines"));
expect(mockExecute.mock.calls[aggInsertIdx][1]).toEqual([42, 7, 300]);
// Holdings are deleted then inserted referencing the captured line id (500).
expect(calls).toContain("DELETE FROM balance_snapshot_holdings WHERE snapshot_line_id = $1");
const holdingInsert = mockExecute.mock.calls.find((c: unknown[]) =>
String(c[0]).includes("INSERT INTO balance_snapshot_holdings")
);
expect(holdingInsert?.[1]?.[0]).toBe(500); // snapshot_line_id
// Begins once, commits last, never rolls back.
expect(mockExecute.mock.calls[0][0]).toBe("BEGIN");
expect(mockExecute.mock.calls[mockExecute.mock.calls.length - 1][0]).toBe("COMMIT");
expect(calls).not.toContain("ROLLBACK");
});
it("ROLLS BACK the whole save when a holding INSERT fails (no partial line/holdings)", async () => {
mockSelect
.mockResolvedValueOnce([]) // dup-check
.mockResolvedValueOnce([
{ id: 11, symbol: "AAPL", name: null, currency: "CAD", asset_type: "stock", created_at: "", updated_at: "" },
]); // security AAPL lookup
mockExecute
.mockResolvedValueOnce({ rowsAffected: 0 }) // BEGIN
.mockResolvedValueOnce({ lastInsertId: 42, rowsAffected: 1 }) // INSERT snapshot
.mockResolvedValueOnce({ rowsAffected: 0 }) // DELETE lines
.mockResolvedValueOnce({ lastInsertId: 500, rowsAffected: 1 }) // INSERT aggregated line
.mockResolvedValueOnce({ rowsAffected: 0 }) // DELETE holdings
.mockResolvedValueOnce({ rowsAffected: 1 }) // UPSERT security AAPL
.mockRejectedValueOnce(new Error("holding FK violation")) // INSERT holding FAILS
.mockResolvedValueOnce({ rowsAffected: 0 }); // ROLLBACK
await expect(
saveSnapshotAtomic({
existingSnapshotId: null,
snapshot_date: "2026-05-30",
lines: [
{
account_id: 7,
value: 100,
holdings: [
{ symbol: "AAPL", asset_type: "stock", quantity: 2, unit_price: 50, value: 100 },
],
},
],
})
).rejects.toThrow("holding FK violation");
// ROLLBACK was the last call, no COMMIT happened.
expect(mockExecute.mock.calls[mockExecute.mock.calls.length - 1][0]).toBe("ROLLBACK");
expect(
mockExecute.mock.calls.some((c: unknown[]) => c[0] === "COMMIT")
).toBe(false);
});
it("recomputes the aggregated value server-side from the rounded-cent SUM", async () => {
// Caller passes value=0.21 with 21×0.005 holdings; the line must store 0.21.
mockSelect.mockResolvedValueOnce([]); // dup-check
for (let i = 0; i < 21; i++) {
mockSelect.mockResolvedValueOnce([
{ id: 100 + i, symbol: `S${i}`, name: null, currency: "CAD", asset_type: "stock", created_at: "", updated_at: "" },
]);
}
// BEGIN, INSERT snapshot, DELETE lines, INSERT line, DELETE holdings,
// then 21×(upsert security + insert holding), UPDATE, COMMIT.
mockExecute
.mockResolvedValueOnce({ rowsAffected: 0 }) // BEGIN
.mockResolvedValueOnce({ lastInsertId: 42, rowsAffected: 1 }) // INSERT snapshot
.mockResolvedValueOnce({ rowsAffected: 0 }) // DELETE lines
.mockResolvedValueOnce({ lastInsertId: 500, rowsAffected: 1 }) // INSERT line
.mockResolvedValueOnce({ rowsAffected: 0 }); // DELETE holdings
for (let i = 0; i < 21; i++) {
mockExecute
.mockResolvedValueOnce({ rowsAffected: 1 }) // upsert security
.mockResolvedValueOnce({ rowsAffected: 1 }); // insert holding
}
mockExecute
.mockResolvedValueOnce({ rowsAffected: 1 }) // UPDATE updated_at
.mockResolvedValueOnce({ rowsAffected: 0 }); // COMMIT
const holdings = Array.from({ length: 21 }, (_, i) => ({
symbol: `S${i}`,
asset_type: "stock" as const,
quantity: 1,
unit_price: 0.005,
value: 0.005,
}));
await saveSnapshotAtomic({
existingSnapshotId: null,
snapshot_date: "2026-05-30",
lines: [{ account_id: 7, value: 0.21, holdings }],
});
const lineInsert = mockExecute.mock.calls.find((c: unknown[]) =>
String(c[0]).includes("INSERT INTO balance_snapshot_lines")
);
// 21 × roundToCent(0.005)=0.01 ⇒ 0.21 stored.
expect(lineInsert?.[1]).toEqual([42, 7, 0.21]);
});
});
describe("updateBalanceAccount — detailed→simple guard (#212)", () => {
const detailedAccount = {
id: 7,
balance_category_id: 1,
name: "Courtage",
symbol: null,
currency: "CAD",
notes: null,
is_active: 1,
archived_at: null,
vehicle_type: null,
kind: "detailed",
detailed_since: "2026-05-01",
created_at: "",
updated_at: "",
};
it("rejects detailed→simple when holdings exist (typed error, no UPDATE)", async () => {
mockSelect
.mockResolvedValueOnce([detailedAccount]) // getBalanceAccount
.mockResolvedValueOnce([{ n: 3 }]); // holdings count > 0
await expect(
updateBalanceAccount(7, { kind: "simple" })
).rejects.toMatchObject({ code: "account_kind_detailed_has_holdings" });
// Guard fires before the UPDATE.
expect(
mockExecute.mock.calls.some((c: unknown[]) =>
String(c[0]).includes("UPDATE balance_accounts")
)
).toBe(false);
});
it("ALLOWS detailed→simple when no holdings exist yet", async () => {
mockSelect
.mockResolvedValueOnce([detailedAccount]) // getBalanceAccount
.mockResolvedValueOnce([{ n: 0 }]); // no holdings
mockExecute.mockResolvedValueOnce({ rowsAffected: 1 }); // UPDATE
await updateBalanceAccount(7, { kind: "simple" });
const sql = mockExecute.mock.calls[0][0] as string;
expect(sql).toContain("kind = $7");
const params = mockExecute.mock.calls[0][1] as unknown[];
expect(params[6]).toBe("simple"); // kind
});
it("does NOT run the holdings check on simple→detailed (only the dangerous direction is gated)", async () => {
const simpleAccount = { ...detailedAccount, kind: "simple", detailed_since: null };
mockSelect.mockResolvedValueOnce([simpleAccount]); // getBalanceAccount only
mockExecute.mockResolvedValueOnce({ rowsAffected: 1 }); // UPDATE
await updateBalanceAccount(7, { kind: "detailed", detailed_since: "2026-06-01" });
// Exactly one SELECT (the account read) — no holdings count query.
expect(mockSelect).toHaveBeenCalledTimes(1);
const params = mockExecute.mock.calls[0][1] as unknown[];
expect(params[6]).toBe("detailed");
expect(params[7]).toBe("2026-06-01"); // detailed_since normalized
});
it("rejects an invalid kind value", async () => {
mockSelect.mockResolvedValueOnce([{ ...detailedAccount, kind: "simple" }]);
await expect(
// @ts-expect-error runtime guard
updateBalanceAccount(7, { kind: "weird" })
).rejects.toMatchObject({ code: "account_kind_invalid" });
});
});
describe("listHoldingsBySnapshotLine / getHoldingsForLatestSnapshot", () => {
it("listHoldingsBySnapshotLine joins the security and filters by line", async () => {
mockSelect.mockResolvedValueOnce([]);
await listHoldingsBySnapshotLine(500);
const sql = mockSelect.mock.calls[0][0] as string;
expect(sql).toContain("FROM balance_snapshot_holdings h");
expect(sql).toContain("JOIN balance_securities s");
expect(sql).toContain("WHERE h.snapshot_line_id = $1");
expect(mockSelect.mock.calls[0][1]).toEqual([500]);
});
it("getHoldingsForLatestSnapshot EXCLUDES quantity-0 positions", async () => {
mockSelect.mockResolvedValueOnce([]);
await getHoldingsForLatestSnapshot(7);
const sql = mockSelect.mock.calls[0][0] as string;
// Latest-snapshot subquery + the qty<>0 filter for the prefill.
expect(sql).toContain("h.quantity <> 0");
expect(sql).toContain("ORDER BY s2.snapshot_date DESC");
expect(mockSelect.mock.calls[0][1]).toEqual([7]);
});
});
describe("computeUnrealizedGain", () => {
it("computes per-holding gain and % from book_cost", () => {
const r = computeUnrealizedGain([
{ security_id: 1, value: 120, book_cost: 100, symbol: "AAPL" },
{ security_id: 2, value: 80, book_cost: 100, symbol: "MSFT" },
]);
expect(r.total_value).toBe(200);
expect(r.total_book_cost).toBe(200);
expect(r.total_gain).toBe(0);
expect(r.total_gain_pct).toBe(0);
expect(r.holdings[0].gain).toBe(20);
expect(r.holdings[0].gain_pct).toBeCloseTo(0.2, 5);
expect(r.holdings[1].gain).toBe(-20);
expect(r.holdings[1].gain_pct).toBeCloseTo(-0.2, 5);
expect(r.has_unknown_book_cost).toBe(false);
});
it("book_cost = 0 ⇒ gain = value but gain_pct = null (N/A, no divide-by-zero)", () => {
const r = computeUnrealizedGain([
{ security_id: 1, value: 50, book_cost: 0 },
]);
expect(r.holdings[0].gain).toBe(50);
expect(r.holdings[0].gain_pct).toBeNull();
// book_cost 0 contributes to the (zero) aggregate denominator ⇒ total % null.
expect(r.total_book_cost).toBe(0);
expect(r.total_gain_pct).toBeNull();
expect(r.has_unknown_book_cost).toBe(false);
});
it("NULL book_cost ⇒ gain & % null, EXCLUDED from aggregate, flagged", () => {
const r = computeUnrealizedGain([
{ security_id: 1, value: 120, book_cost: 100 },
{ security_id: 2, value: 300, book_cost: null },
]);
// The NULL-book_cost holding's value still counts toward total_value…
expect(r.total_value).toBe(420);
// …but NOT toward book_cost / gain aggregates.
expect(r.total_book_cost).toBe(100);
expect(r.total_gain).toBe(20);
expect(r.total_gain_pct).toBeCloseTo(0.2, 5);
expect(r.holdings[1].gain).toBeNull();
expect(r.holdings[1].gain_pct).toBeNull();
expect(r.has_unknown_book_cost).toBe(true);
});
it("empty holdings ⇒ zeros and null %", () => {
const r = computeUnrealizedGain([]);
expect(r.total_value).toBe(0);
expect(r.total_book_cost).toBe(0);
expect(r.total_gain).toBe(0);
expect(r.total_gain_pct).toBeNull();
expect(r.has_unknown_book_cost).toBe(false);
expect(r.holdings).toEqual([]);
});
});

View file

@ -15,12 +15,16 @@ import { loadProfiles } from "./profileService";
import type { import type {
AccountReturn, AccountReturn,
BalanceAccount, BalanceAccount,
BalanceAccountKind,
BalanceAccountTransferWithTransaction, BalanceAccountTransferWithTransaction,
BalanceAccountWithCategory, BalanceAccountWithCategory,
BalanceAssetType, BalanceAssetType,
BalanceCategory, BalanceCategory,
BalanceCategoryKind, BalanceCategoryKind,
BalanceSecurity,
BalanceSnapshot, BalanceSnapshot,
BalanceSnapshotHolding,
BalanceSnapshotHoldingWithSecurity,
BalanceSnapshotLine, BalanceSnapshotLine,
BalanceTransferDirection, BalanceTransferDirection,
BalanceVehicleType, BalanceVehicleType,
@ -52,6 +56,15 @@ export type BalanceErrorCode =
| "snapshot_priced_unit_price_required" | "snapshot_priced_unit_price_required"
| "snapshot_priced_value_mismatch" | "snapshot_priced_value_mismatch"
| "snapshot_simple_must_be_scalar" | "snapshot_simple_must_be_scalar"
// Issue #212 — securities + detailed snapshot (holdings)
| "security_symbol_required"
| "security_asset_type_invalid"
| "security_not_found"
| "snapshot_detailed_must_be_aggregate"
| "snapshot_detailed_value_mismatch"
| "snapshot_holding_invalid"
| "account_kind_invalid"
| "account_kind_detailed_has_holdings"
// Issue #142 — transfers + returns // Issue #142 — transfers + returns
| "transfer_direction_invalid" | "transfer_direction_invalid"
| "transfer_already_linked" | "transfer_already_linked"
@ -305,6 +318,7 @@ export async function listBalanceAccounts(options?: {
return db.select<BalanceAccountWithCategory[]>( return db.select<BalanceAccountWithCategory[]>(
`SELECT a.id, a.balance_category_id, a.name, a.symbol, a.currency, `SELECT a.id, a.balance_category_id, a.name, a.symbol, a.currency,
a.notes, a.is_active, a.archived_at, a.vehicle_type, a.notes, a.is_active, a.archived_at, a.vehicle_type,
a.kind, a.detailed_since,
a.created_at, a.updated_at, a.created_at, a.updated_at,
c.key AS category_key, c.i18n_key AS category_i18n_key, c.key AS category_key, c.i18n_key AS category_i18n_key,
c.kind AS category_kind, c.asset_type AS category_asset_type, c.kind AS category_kind, c.asset_type AS category_asset_type,
@ -322,7 +336,8 @@ export async function getBalanceAccount(
const db = await getDb(); const db = await getDb();
const rows = await db.select<BalanceAccount[]>( const rows = await db.select<BalanceAccount[]>(
`SELECT id, balance_category_id, name, symbol, currency, notes, `SELECT id, balance_category_id, name, symbol, currency, notes,
is_active, archived_at, vehicle_type, created_at, updated_at is_active, archived_at, vehicle_type, kind, detailed_since,
created_at, updated_at
FROM balance_accounts FROM balance_accounts
WHERE id = $1`, WHERE id = $1`,
[id] [id]
@ -427,6 +442,20 @@ export interface UpdateBalanceAccountInput {
* automobile type. * automobile type.
*/ */
vehicle_type?: BalanceVehicleType | null; vehicle_type?: BalanceVehicleType | null;
/**
* Entry mode (migration v15). 'simple' = one denormalized value, 'detailed' =
* per-security holdings. Pass to change, omit to leave unchanged. Switching
* `detailed → simple` is rejected with `account_kind_detailed_has_holdings`
* when the account already has holdings recorded UI gating alone is
* insufficient (Issue #212 service backstop).
*/
kind?: BalanceAccountKind;
/**
* Authoritative pivot date (ISO YYYY-MM-DD) from which detailed entry is
* expected (migration v15). Pass a value to set, `null` to clear, omit to
* leave unchanged.
*/
detailed_since?: string | null;
} }
export async function updateBalanceAccount( export async function updateBalanceAccount(
@ -474,13 +503,51 @@ export async function updateBalanceAccount(
input.vehicle_type !== undefined input.vehicle_type !== undefined
? normalizeVehicleType(input.vehicle_type) ? normalizeVehicleType(input.vehicle_type)
: existing.vehicle_type ?? null; : existing.vehicle_type ?? null;
// Entry mode (migration v15). Validate against the enum; preserve when
// omitted. The detailed → simple downgrade is the dangerous transition —
// gated below.
const kind: BalanceAccountKind =
input.kind !== undefined ? input.kind : existing.kind;
if (kind !== "simple" && kind !== "detailed") {
throw new BalanceServiceError(
"account_kind_invalid",
`account kind must be 'simple' or 'detailed', got ${kind}`
);
}
const detailedSince =
input.detailed_since !== undefined
? input.detailed_since === null
? null
: normalizeSnapshotDate(input.detailed_since)
: existing.detailed_since ?? null;
// Service backstop (spec finding 🟢 TECHNIQUE): block a detailed → simple
// flip when holdings exist, otherwise they'd be orphaned (the line keeps a
// total value while the per-title rows dangle). The UI disables this too,
// but a direct service call must not bypass the invariant.
if (existing.kind === "detailed" && kind === "simple") {
const db0 = await getDb();
const held = await db0.select<Array<{ n: number }>>(
`SELECT COUNT(*) AS n
FROM balance_snapshot_holdings h
JOIN balance_snapshot_lines l ON l.id = h.snapshot_line_id
WHERE l.account_id = $1`,
[id]
);
if ((held[0]?.n ?? 0) > 0) {
throw new BalanceServiceError(
"account_kind_detailed_has_holdings",
`Account ${id} has detailed holdings and cannot be switched back to simple`
);
}
}
const db = await getDb(); const db = await getDb();
await db.execute( await db.execute(
`UPDATE balance_accounts `UPDATE balance_accounts
SET balance_category_id = $1, name = $2, symbol = $3, notes = $4, SET balance_category_id = $1, name = $2, symbol = $3, notes = $4,
is_active = $5, vehicle_type = $6, updated_at = CURRENT_TIMESTAMP is_active = $5, vehicle_type = $6, kind = $7, detailed_since = $8,
WHERE id = $7`, updated_at = CURRENT_TIMESTAMP
[categoryId, name, symbol, notes, isActive, vehicleType, id] WHERE id = $9`,
[categoryId, name, symbol, notes, isActive, vehicleType, kind, detailedSince, id]
); );
} }
@ -857,6 +924,189 @@ export async function listLinesBySnapshot(
); );
} }
// -----------------------------------------------------------------------------
// Securities catalogue (Issue #212 / Bilan détail par titre — #3)
// -----------------------------------------------------------------------------
//
// `balance_securities` is a shared catalogue keyed by a NORMALIZED symbol
// (UPPER(TRIM(...))). The SQL column is already `UNIQUE COLLATE NOCASE` (v14),
// but we normalize in TS too so the stored value is canonical and the dedup is
// deterministic regardless of the caller's casing/whitespace.
/** Minimal executor surface shared by the top-level db handle and a txn. */
interface SqlExecutor {
select<T>(query: string, bindValues?: unknown[]): Promise<T>;
execute(
query: string,
bindValues?: unknown[]
): Promise<{ lastInsertId?: number; rowsAffected?: number }>;
}
/**
* Canonical symbol form: UPPER(TRIM(...)). Matches the v14/v16 SQL
* (`UPPER(TRIM(a.symbol))`) so a TS-created security and a migration-created
* one collapse to the same `balance_securities` row.
*/
export function normalizeSecuritySymbol(symbol: string): string {
return symbol.trim().toUpperCase();
}
export interface FindOrCreateSecurityInput {
symbol: string;
/** Defaults to 'CAD'. */
currency?: string;
/** 'stock' | 'crypto' — required; routes the price-fetch flow. */
asset_type: BalanceAssetType;
/** Optional human-readable name. */
name?: string | null;
}
const ASSET_TYPES: readonly BalanceAssetType[] = ["stock", "crypto"];
/**
* UPSERT a security by NORMALIZED symbol, returning its row. Idempotent: a
* second call with the same symbol (any casing) returns the existing row,
* updating `name`/`asset_type`/`currency` only when the caller passes new
* values. Callable inside an open transaction by threading `exec` (so a
* brand-new symbol can create its security then its holding atomically inside
* `saveSnapshotAtomic`); omit `exec` to use the top-level db handle.
*
* @throws `security_symbol_required` on empty symbol,
* `security_asset_type_invalid` on a non-enum asset_type.
*/
export async function findOrCreateSecurity(
input: FindOrCreateSecurityInput,
exec?: SqlExecutor
): Promise<BalanceSecurity> {
const symbol = normalizeSecuritySymbol(input.symbol ?? "");
if (symbol.length === 0) {
throw new BalanceServiceError(
"security_symbol_required",
"Security symbol is required"
);
}
if (!ASSET_TYPES.includes(input.asset_type)) {
throw new BalanceServiceError(
"security_asset_type_invalid",
`asset_type must be one of ${ASSET_TYPES.join(", ")}`
);
}
const currency = input.currency ?? BALANCE_CURRENCY_CAD;
const name = input.name ? input.name.trim() || null : null;
const db: SqlExecutor = exec ?? (await getDb());
// ON CONFLICT(symbol) DO UPDATE keeps the row's metadata fresh (e.g. a name
// arriving on a later save) while preserving its id. COALESCE keeps an
// existing name when the new payload omits one. The column is UNIQUE COLLATE
// NOCASE so the conflict target is the normalized symbol.
await db.execute(
`INSERT INTO balance_securities (symbol, name, currency, asset_type)
VALUES ($1, $2, $3, $4)
ON CONFLICT(symbol) DO UPDATE SET
name = COALESCE($2, balance_securities.name),
currency = $3,
asset_type = $4,
updated_at = CURRENT_TIMESTAMP`,
[symbol, name, currency, input.asset_type]
);
const rows = await db.select<BalanceSecurity[]>(
`SELECT id, symbol, name, currency, asset_type, created_at, updated_at
FROM balance_securities
WHERE symbol = $1`,
[symbol]
);
if (rows.length === 0) {
// Should be unreachable — the UPSERT just guaranteed the row exists.
throw new BalanceServiceError(
"security_not_found",
`Security ${symbol} not found after upsert`
);
}
return rows[0];
}
/** List every security in the catalogue, ordered by symbol. */
export async function listSecurities(): Promise<BalanceSecurity[]> {
const db = await getDb();
return db.select<BalanceSecurity[]>(
`SELECT id, symbol, name, currency, asset_type, created_at, updated_at
FROM balance_securities
ORDER BY symbol ASC`
);
}
/** Fetch one security by id, or `null` when absent. */
export async function getSecurity(id: number): Promise<BalanceSecurity | null> {
const db = await getDb();
const rows = await db.select<BalanceSecurity[]>(
`SELECT id, symbol, name, currency, asset_type, created_at, updated_at
FROM balance_securities
WHERE id = $1`,
[id]
);
return rows[0] ?? null;
}
export interface UpdateSecurityInput {
/** New normalized symbol; omit to leave unchanged. */
symbol?: string;
name?: string | null;
currency?: string;
asset_type?: BalanceAssetType;
}
/**
* Update a security's metadata. Symbol is re-normalized when provided.
* @throws `security_not_found`, `security_symbol_required`,
* `security_asset_type_invalid`.
*/
export async function updateSecurity(
id: number,
input: UpdateSecurityInput
): Promise<void> {
const existing = await getSecurity(id);
if (!existing) {
throw new BalanceServiceError(
"security_not_found",
`Security ${id} not found`
);
}
let symbol = existing.symbol;
if (input.symbol !== undefined) {
symbol = normalizeSecuritySymbol(input.symbol);
if (symbol.length === 0) {
throw new BalanceServiceError(
"security_symbol_required",
"Security symbol is required"
);
}
}
let assetType = existing.asset_type;
if (input.asset_type !== undefined) {
if (!ASSET_TYPES.includes(input.asset_type)) {
throw new BalanceServiceError(
"security_asset_type_invalid",
`asset_type must be one of ${ASSET_TYPES.join(", ")}`
);
}
assetType = input.asset_type;
}
const name =
input.name !== undefined
? input.name === null
? null
: input.name.trim() || null
: existing.name;
const currency = input.currency ?? existing.currency;
const db = await getDb();
await db.execute(
`UPDATE balance_securities
SET symbol = $1, name = $2, currency = $3, asset_type = $4,
updated_at = CURRENT_TIMESTAMP
WHERE id = $5`,
[symbol, name, currency, assetType, id]
);
}
/** /**
* Tolerance ε used by the priced-kind invariant `value === quantity * unit_price`. * Tolerance ε used by the priced-kind invariant `value === quantity * unit_price`.
* *
@ -869,6 +1119,17 @@ export async function listLinesBySnapshot(
*/ */
export const PRICED_VALUE_TOLERANCE = 0.01; export const PRICED_VALUE_TOLERANCE = 0.01;
/**
* Round a monetary amount to the cent. The detailed-snapshot invariant rounds
* every holding's value to the cent and compares the SUM **exactly** to the
* stored aggregated line value no float tolerance (decision 2026-06-03). This
* sidesteps the rounding-accumulation problem that a single absolute ε would
* hit once N holdings are summed (see Issue #212 spec finding 🟡 SECURITE).
*/
export function roundToCent(value: number): number {
return Math.round(value * 100) / 100;
}
export interface SnapshotLineInput { export interface SnapshotLineInput {
account_id: number; account_id: number;
/** /**
@ -888,6 +1149,44 @@ export interface SnapshotLineInput {
quantity?: number | null; quantity?: number | null;
/** Required for priced lines, must be NULL for simple. */ /** Required for priced lines, must be NULL for simple. */
unit_price?: number | null; unit_price?: number | null;
/**
* Per-security breakdown for a `detailed` account (Issue #212). When this
* field is **present** (a defined array, even empty), the line is treated as
* detailed: the aggregated row stores the rounded-cent SUM of the holdings
* with `quantity`/`unit_price` NULL, and each holding is written to
* `balance_snapshot_holdings` in the same transaction. An EMPTY array models a
* detailed account at/just after its pivot with no positions yet entered
* (pre-pivot aggregated rows are produced WITHOUT this field they take the
* simple path). `undefined` not a detailed line (simple or legacy priced
* scalar path, untouched).
*/
holdings?: SnapshotHoldingInput[];
}
/**
* One position inside a `detailed` account's snapshot line (Issue #212).
* References its security by NORMALIZED symbol (+ asset_type/currency) so
* `findOrCreateSecurity` can resolve-or-create it inside the save transaction.
* Downstream #213/#214 (editor reducer + multi-title UI) produce this shape.
*/
export interface SnapshotHoldingInput {
/** Security symbol; normalized (UPPER/TRIM) by the service. */
symbol: string;
/** Asset class — required for find-or-create. */
asset_type: BalanceAssetType;
/** Defaults to 'CAD'. */
currency?: string;
/** Optional human-readable security name. */
security_name?: string | null;
quantity: number;
unit_price: number;
/** Position value (= quantity × unit_price); re-rounded to the cent server-side. */
value: number;
/** Acquisition cost basis for the unrealized-gain column; optional. */
book_cost?: number | null;
/** 'manual' | 'maximus-api'; defaults to 'manual'. */
price_source?: string | null;
price_fetched_at?: string | null;
} }
/** /**
@ -962,6 +1261,209 @@ export function validateLineKindInvariants(
} }
} }
/**
* A line is "detailed" when it carries a `holdings` array (even empty). The
* presence of the field not the account's stored kind drives the save
* path, so a detailed account can still write a pre-pivot aggregated row by
* simply omitting `holdings`.
*/
export function isDetailedLine(line: SnapshotLineInput): boolean {
return line.holdings !== undefined;
}
/**
* Validate a `detailed` line and its holdings (Issue #212). Companion to
* `validateLineKindInvariants` (which stays UNCHANGED for the simple/priced
* scalar path). Called ahead of any DB mutation.
*
* Rules (detail by security):
* - WITH holdings the aggregated line MUST carry `quantity`/`unit_price`
* NULL (the total has no single qty/price) AND `line.value` MUST equal the
* rounded-cent SUM of the holdings' rounded-cent values, compared EXACTLY
* (no float tolerance decision 2026-06-03). Each holding must have finite
* quantity/unit_price/value, a normalizable symbol and a valid asset_type.
* - WITHOUT holdings (empty array) pre-pivot / no-position-yet aggregated
* row is tolerated: `value` only needs to be finite (validated by the
* caller's `validateLineKindInvariants` simple pass; here it's a no-op).
*
* @throws `BalanceServiceError` with a typed code on the first failure.
*/
export function validateDetailedSnapshot(line: SnapshotLineInput): void {
const holdings = line.holdings ?? [];
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`
);
}
// Pre-pivot / empty: an aggregated total with no per-title breakdown. The
// line value stands on its own; nothing further to assert.
if (holdings.length === 0) {
return;
}
// The aggregated row must not carry a scalar qty/price — those live per
// holding. Allow NULL/undefined only.
if (line.quantity !== undefined && line.quantity !== null) {
throw new BalanceServiceError(
"snapshot_detailed_must_be_aggregate",
`Line for account ${line.account_id}: detailed line must not carry a scalar quantity`
);
}
if (line.unit_price !== undefined && line.unit_price !== null) {
throw new BalanceServiceError(
"snapshot_detailed_must_be_aggregate",
`Line for account ${line.account_id}: detailed line must not carry a scalar unit_price`
);
}
let centSum = 0;
for (const h of holdings) {
if (typeof h.symbol !== "string" || normalizeSecuritySymbol(h.symbol) === "") {
throw new BalanceServiceError(
"snapshot_holding_invalid",
`Line for account ${line.account_id}: holding symbol is required`
);
}
if (!ASSET_TYPES.includes(h.asset_type)) {
throw new BalanceServiceError(
"snapshot_holding_invalid",
`Line for account ${line.account_id}: holding ${h.symbol} has invalid asset_type`
);
}
if (typeof h.quantity !== "number" || !Number.isFinite(h.quantity)) {
throw new BalanceServiceError(
"snapshot_holding_invalid",
`Line for account ${line.account_id}: holding ${h.symbol} quantity must be finite`
);
}
if (typeof h.unit_price !== "number" || !Number.isFinite(h.unit_price)) {
throw new BalanceServiceError(
"snapshot_holding_invalid",
`Line for account ${line.account_id}: holding ${h.symbol} unit_price must be finite`
);
}
if (typeof h.value !== "number" || !Number.isFinite(h.value)) {
throw new BalanceServiceError(
"snapshot_holding_invalid",
`Line for account ${line.account_id}: holding ${h.symbol} value must be finite`
);
}
centSum += roundToCent(h.value);
}
// Compare the rounded-cent SUM exactly against the rounded-cent line total.
// Both sides are rounded so the equality is on whole cents — no ε.
const expected = roundToCent(centSum);
if (roundToCent(line.value) !== expected) {
throw new BalanceServiceError(
"snapshot_detailed_value_mismatch",
`Line for account ${line.account_id}: value ${line.value} does not match rounded-cent SUM of holdings (${expected})`
);
}
}
/**
* Validate every input line ahead of any DB mutation. Detailed lines (those
* carrying a `holdings` field) go through `validateDetailedSnapshot`; all
* others keep the unchanged `validateLineKindInvariants` scalar pass. Shared by
* `upsertSnapshotLines` and `saveSnapshotAtomic` so the two save entry points
* never diverge.
*/
function validateAllLines(lines: SnapshotLineInput[]): void {
for (const line of lines) {
if (isDetailedLine(line)) {
validateDetailedSnapshot(line);
} else {
validateLineKindInvariants(line);
}
}
}
/**
* Insert one snapshot line and, for a detailed line, rewrite its holdings in
* the SAME executor (transaction). Returns the inserted line id.
*
* Detailed path: the aggregated row stores the rounded-cent SUM of the
* holdings (qty/price NULL); after capturing its `lastInsertId`, existing
* holdings for that line are DELETEd (defensive a fresh line has none), then
* each holding's security is found-or-created and the holding INSERTed. The
* server-side total is recomputed from the rounded-cent SUM so the line's
* `value` is authoritative regardless of the caller's arithmetic.
*
* Simple / legacy-priced scalar path: unchanged one INSERT, no holdings.
*/
async function insertSnapshotLineWithHoldings(
exec: SqlExecutor,
snapshotId: number,
line: SnapshotLineInput
): Promise<number> {
if (isDetailedLine(line)) {
const holdings = line.holdings ?? [];
// Recompute the aggregated total server-side from the rounded-cent SUM —
// the stored line value is the source of truth for the aggregators.
const total = roundToCent(
holdings.reduce((acc, h) => acc + roundToCent(h.value), 0)
);
const lineRes = await exec.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, total]
);
const lineId = lineRes.lastInsertId as number;
// Defensive clear — a freshly inserted line can't have holdings yet, but
// this keeps the helper safe if it's ever reused on an existing line.
await exec.execute(
`DELETE FROM balance_snapshot_holdings WHERE snapshot_line_id = $1`,
[lineId]
);
for (const h of holdings) {
const security = await findOrCreateSecurity(
{
symbol: h.symbol,
asset_type: h.asset_type,
currency: h.currency,
name: h.security_name,
},
exec
);
await exec.execute(
`INSERT INTO balance_snapshot_holdings
(snapshot_line_id, security_id, quantity, unit_price, value,
book_cost, price_source, price_fetched_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
[
lineId,
security.id,
h.quantity,
h.unit_price,
roundToCent(h.value),
h.book_cost ?? null,
h.price_source ?? "manual",
h.price_fetched_at ?? null,
]
);
}
return lineId;
}
// Simple / legacy priced scalar line — unchanged behaviour.
const kind = line.account_kind ?? "simple";
if (kind === "simple") {
const res = await exec.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]
);
return res.lastInsertId as number;
}
const res = await exec.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]
);
return res.lastInsertId as number;
}
/** /**
* Upsert a batch of snapshot lines. Each input row is inserted or * Upsert a batch of snapshot lines. Each input row is inserted or
* replaced atomically per account; lines for accounts not present in * replaced atomically per account; lines for accounts not present in
@ -990,42 +1492,26 @@ export async function upsertSnapshotLines(
`Snapshot ${snapshotId} not found` `Snapshot ${snapshotId} not found`
); );
} }
// Validate every input up-front before mutating anything. // Validate every input up-front before mutating anything (detailed lines go
for (const line of lines) { // through validateDetailedSnapshot, scalar lines through the unchanged pass).
validateLineKindInvariants(line); validateAllLines(lines);
}
const db = await getDb(); const db = await getDb();
// Strategy: clear and rewrite. Snapshot lines are small (one per active // Strategy: clear and rewrite, wrapped in an explicit transaction so the
// account, typically < 20) so the simplicity outweighs the diff-tracking // line + holdings writes commit together. Snapshot lines are small (one per
// savings. CASCADE guarantees consistency on partial failures. // active account, typically < 20). CASCADE on snapshot_line_id wipes the old
// holdings when their parent line is DELETEd here, so a detailed account's
// per-title rows never outlive their line.
let inTxn = false;
try {
await db.execute("BEGIN");
inTxn = true;
await db.execute( await db.execute(
"DELETE FROM balance_snapshot_lines WHERE snapshot_id = $1", "DELETE FROM balance_snapshot_lines WHERE snapshot_id = $1",
[snapshotId] [snapshotId]
); );
for (const line of lines) { for (const line of lines) {
const kind = line.account_kind ?? "simple"; await insertSnapshotLineWithHoldings(db, snapshotId, line);
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. // Bump the parent snapshot's updated_at so list views can sort by recency.
await db.execute( await db.execute(
@ -1034,6 +1520,18 @@ export async function upsertSnapshotLines(
WHERE id = $1`, WHERE id = $1`,
[snapshotId] [snapshotId]
); );
await db.execute("COMMIT");
inTxn = false;
} catch (e) {
if (inTxn) {
try {
await db.execute("ROLLBACK");
} catch {
// Defensive: preserve the original error over a rollback failure.
}
}
throw e;
}
} }
/** /**
@ -1073,10 +1571,9 @@ export async function saveSnapshotAtomic(input: {
moveToDate?: string | null; moveToDate?: string | null;
}): Promise<{ snapshotId: number }> { }): Promise<{ snapshotId: number }> {
// Validate every line ahead of time so the transaction never opens for // Validate every line ahead of time so the transaction never opens for
// a doomed save. Mirrors `upsertSnapshotLines` invariants. // a doomed save. Detailed lines go through validateDetailedSnapshot; scalar
for (const line of input.lines) { // lines keep the unchanged validateLineKindInvariants pass.
validateLineKindInvariants(line); validateAllLines(input.lines);
}
const db = await getDb(); const db = await getDb();
let inTxn = false; let inTxn = false;
@ -1133,35 +1630,16 @@ export async function saveSnapshotAtomic(input: {
} }
// Rewrite-all strategy (matches `upsertSnapshotLines`): clear // Rewrite-all strategy (matches `upsertSnapshotLines`): clear
// existing lines, then re-insert every line. Cheap because snapshot // existing lines, then re-insert every line + its holdings. Cheap
// line counts are small. // because snapshot line counts are small. A detailed line writes its
// aggregated row and its holdings via the shared helper, all inside this
// same BEGIN/COMMIT — a holding INSERT failure rolls the whole save back.
await db.execute( await db.execute(
"DELETE FROM balance_snapshot_lines WHERE snapshot_id = $1", "DELETE FROM balance_snapshot_lines WHERE snapshot_id = $1",
[snapshotId] [snapshotId]
); );
for (const line of input.lines) { for (const line of input.lines) {
const kind = line.account_kind ?? "simple"; await insertSnapshotLineWithHoldings(db, snapshotId, line);
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,
]
);
}
} }
await db.execute( await db.execute(
`UPDATE balance_snapshots `UPDATE balance_snapshots
@ -1509,6 +1987,174 @@ export async function getAccountsPeriodAnchor(
); );
} }
// -----------------------------------------------------------------------------
// Holdings reads + unrealized gain (Issue #212 / Bilan détail par titre — #3)
// -----------------------------------------------------------------------------
/**
* Drill-down read: every holding of a single snapshot line, joined with its
* security for display (symbol / name / asset_type). Ordered by symbol.
*/
export async function listHoldingsBySnapshotLine(
lineId: number
): Promise<BalanceSnapshotHoldingWithSecurity[]> {
const db = await getDb();
return db.select<BalanceSnapshotHoldingWithSecurity[]>(
`SELECT h.id, h.snapshot_line_id, h.security_id, h.quantity, h.unit_price,
h.value, h.book_cost, h.price_source, h.price_fetched_at,
h.created_at, h.updated_at,
s.symbol AS security_symbol, s.name AS security_name,
s.asset_type AS security_asset_type
FROM balance_snapshot_holdings h
JOIN balance_securities s ON s.id = h.security_id
WHERE h.snapshot_line_id = $1
ORDER BY s.symbol ASC`,
[lineId]
);
}
/**
* Holdings of an account's LATEST snapshot, one per security, joined with the
* security for display. Used to PREFILL the next snapshot's editor (carry the
* titles + quantities + book_cost forward; the price gets re-fetched).
*
* Titles with quantity 0 are EXCLUDED a position fully sold at the last
* snapshot shouldn't be re-offered (Issue #212 acceptance). A title sold then
* re-bought reappears because its latest non-zero holding wins.
*
* "Latest" is resolved per account by the max `snapshot_date` among that
* account's lines that actually carry holdings.
*/
export async function getHoldingsForLatestSnapshot(
accountId: number
): Promise<BalanceSnapshotHoldingWithSecurity[]> {
const db = await getDb();
return db.select<BalanceSnapshotHoldingWithSecurity[]>(
`SELECT h.id, h.snapshot_line_id, h.security_id, h.quantity, h.unit_price,
h.value, h.book_cost, h.price_source, h.price_fetched_at,
h.created_at, h.updated_at,
s.symbol AS security_symbol, s.name AS security_name,
s.asset_type AS security_asset_type
FROM balance_snapshot_holdings h
JOIN balance_securities s ON s.id = h.security_id
JOIN balance_snapshot_lines l ON l.id = h.snapshot_line_id
WHERE l.account_id = $1
AND l.snapshot_id = (
SELECT l2.snapshot_id
FROM balance_snapshot_lines l2
JOIN balance_snapshots s2 ON s2.id = l2.snapshot_id
JOIN balance_snapshot_holdings h2 ON h2.snapshot_line_id = l2.id
WHERE l2.account_id = $1
ORDER BY s2.snapshot_date DESC
LIMIT 1
)
AND h.quantity <> 0
ORDER BY s.symbol ASC`,
[accountId]
);
}
/** Per-holding unrealized gain row. */
export interface HoldingUnrealizedGain {
security_id: number;
symbol: string;
value: number;
book_cost: number | null;
/** `value - book_cost`, or null when book_cost is unknown (NULL). */
gain: number | null;
/**
* `(value - book_cost) / book_cost`, or null ("N/A") when book_cost is NULL
* or 0 never a divide-by-zero. The UI renders null as the i18n "N/A".
*/
gain_pct: number | null;
}
/** Account-level aggregated unrealized gain across its latest holdings. */
export interface AccountUnrealizedGain {
/** SUM(value) across the holdings considered. */
total_value: number;
/** SUM(book_cost) across holdings WITH a known book_cost (NULLs excluded). */
total_book_cost: number;
/** total_value total_book_cost across the known-book_cost holdings only. */
total_gain: number;
/** total_gain / total_book_cost, or null when total_book_cost is 0. */
total_gain_pct: number | null;
/**
* True when at least one holding has a NULL book_cost (excluded from the
* aggregate) the UI flags the % as partial so it isn't read as exhaustive.
*/
has_unknown_book_cost: boolean;
/** The per-holding breakdown the aggregate was computed from. */
holdings: HoldingUnrealizedGain[];
}
/**
* Compute the unrealized gain (`value book_cost`) per holding and aggregated,
* in value and percent (Issue #212). Pure over the holdings it's given, so it's
* trivially unit-testable; pass holdings from `getHoldingsForLatestSnapshot`
* (latest snapshot) or `listHoldingsBySnapshotLine` (a specific snapshot).
*
* Guards:
* - per holding: `book_cost = 0` OR `book_cost = NULL` `gain_pct = null`
* ("N/A") so we never divide by zero. A NULL book_cost also yields
* `gain = null` (we don't know the cost basis), whereas `book_cost = 0`
* yields `gain = value` (cost basis is genuinely zero).
* - aggregate: NULL-book_cost holdings are EXCLUDED from `total_book_cost`
* and `total_gain`, and flagged via `has_unknown_book_cost` so the % isn't
* silently understated. `total_gain_pct` is null when no known book_cost
* contributes (sum is 0).
*/
export function computeUnrealizedGain(
holdings: Array<
Pick<BalanceSnapshotHolding, "security_id" | "value" | "book_cost"> & {
symbol?: string;
}
>
): AccountUnrealizedGain {
const rows: HoldingUnrealizedGain[] = [];
let totalValue = 0;
let totalBookCost = 0;
let totalGainKnown = 0;
let hasUnknown = false;
for (const h of holdings) {
const value = h.value;
totalValue += value;
const bc = h.book_cost;
let gain: number | null;
let gainPct: number | null;
if (bc === null || bc === undefined) {
// Unknown cost basis — no gain figure, excluded from the aggregate.
gain = null;
gainPct = null;
hasUnknown = true;
} else {
gain = value - bc;
totalBookCost += bc;
totalGainKnown += gain;
// book_cost = 0 is a real basis (gain = value) but % is undefined.
gainPct = bc === 0 ? null : (value - bc) / bc;
}
rows.push({
security_id: h.security_id,
symbol: (h as { symbol?: string }).symbol ?? "",
value,
book_cost: bc ?? null,
gain,
gain_pct: gainPct,
});
}
return {
total_value: totalValue,
total_book_cost: totalBookCost,
total_gain: totalGainKnown,
total_gain_pct: totalBookCost === 0 ? null : totalGainKnown / totalBookCost,
has_unknown_book_cost: hasUnknown,
holdings: rows,
};
}
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
// Returns + transfers (Issue #142 / Bilan #4) // Returns + transfers (Issue #142 / Bilan #4)
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------