Merge pull request 'feat(balance): securities service + detailed snapshot save (#212)' (#221) from issue-212-service-securities into main
This commit is contained in:
commit
205e1ded54
2 changed files with 1324 additions and 93 deletions
|
|
@ -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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue