Compare commits
No commits in common. "205e1ded54d592b476fcd3807455f82dd24752f5" and "4e4e4bd0d25e3c4fa378303eaf63a4d1261c35ea" have entirely different histories.
205e1ded54
...
4e4e4bd0d2
2 changed files with 93 additions and 1324 deletions
|
|
@ -36,17 +36,6 @@ import {
|
|||
saveSnapshotAtomic,
|
||||
getPreviousSnapshot,
|
||||
validateLineKindInvariants,
|
||||
validateDetailedSnapshot,
|
||||
isDetailedLine,
|
||||
normalizeSecuritySymbol,
|
||||
roundToCent,
|
||||
findOrCreateSecurity,
|
||||
listSecurities,
|
||||
getSecurity,
|
||||
updateSecurity,
|
||||
listHoldingsBySnapshotLine,
|
||||
getHoldingsForLatestSnapshot,
|
||||
computeUnrealizedGain,
|
||||
PRICED_VALUE_TOLERANCE,
|
||||
BalanceServiceError,
|
||||
getSnapshotTotalsByDate,
|
||||
|
|
@ -620,8 +609,6 @@ describe("balance accounts — vehicle_type (#202)", () => {
|
|||
is_active: 1,
|
||||
archived_at: null,
|
||||
vehicle_type: "tfsa",
|
||||
kind: "simple",
|
||||
detailed_since: null,
|
||||
created_at: "",
|
||||
updated_at: "",
|
||||
},
|
||||
|
|
@ -646,8 +633,6 @@ describe("balance accounts — vehicle_type (#202)", () => {
|
|||
is_active: 1,
|
||||
archived_at: null,
|
||||
vehicle_type: null,
|
||||
kind: "simple",
|
||||
detailed_since: null,
|
||||
created_at: "",
|
||||
updated_at: "",
|
||||
},
|
||||
|
|
@ -670,8 +655,6 @@ describe("balance accounts — vehicle_type (#202)", () => {
|
|||
is_active: 1,
|
||||
archived_at: null,
|
||||
vehicle_type: "tfsa",
|
||||
kind: "simple",
|
||||
detailed_since: null,
|
||||
created_at: "",
|
||||
updated_at: "",
|
||||
},
|
||||
|
|
@ -739,8 +722,6 @@ describe("updateBalanceAccount", () => {
|
|||
notes: null,
|
||||
is_active: 1,
|
||||
archived_at: null,
|
||||
kind: "simple",
|
||||
detailed_since: null,
|
||||
created_at: "",
|
||||
updated_at: "",
|
||||
},
|
||||
|
|
@ -957,52 +938,43 @@ describe("upsertSnapshotLines (simple kind)", () => {
|
|||
|
||||
it("clears existing lines, inserts each line with NULL quantity/unit_price, and bumps updated_at", async () => {
|
||||
mockSelect.mockResolvedValueOnce([FAKE_SNAPSHOT]);
|
||||
// #212: the rewrite is now wrapped in BEGIN/COMMIT for holdings atomicity.
|
||||
mockExecute
|
||||
.mockResolvedValueOnce({ rowsAffected: 0 }) // BEGIN
|
||||
.mockResolvedValueOnce({ rowsAffected: 1 }) // delete
|
||||
.mockResolvedValueOnce({ lastInsertId: 100, rowsAffected: 1 }) // insert 1
|
||||
.mockResolvedValueOnce({ lastInsertId: 101, rowsAffected: 1 }) // insert 2
|
||||
.mockResolvedValueOnce({ rowsAffected: 1 }) // update updated_at
|
||||
.mockResolvedValueOnce({ rowsAffected: 0 }); // COMMIT
|
||||
.mockResolvedValueOnce({ rowsAffected: 1 }); // update updated_at
|
||||
await upsertSnapshotLines(5, [
|
||||
{ account_id: 1, value: 1234.56 },
|
||||
{ account_id: 2, value: 0 },
|
||||
]);
|
||||
// 1st call = BEGIN, 2nd = DELETE
|
||||
expect(mockExecute.mock.calls[0][0]).toBe("BEGIN");
|
||||
expect(mockExecute.mock.calls[1][0]).toContain(
|
||||
// 1st call = DELETE
|
||||
expect(mockExecute.mock.calls[0][0]).toContain(
|
||||
"DELETE FROM balance_snapshot_lines"
|
||||
);
|
||||
// Inserts use literal NULL for quantity/unit_price (simple kind invariant)
|
||||
const insertSql = mockExecute.mock.calls[2][0] as string;
|
||||
const insertSql = mockExecute.mock.calls[1][0] as string;
|
||||
expect(insertSql).toContain("INSERT INTO balance_snapshot_lines");
|
||||
expect(insertSql).toMatch(/VALUES\s*\(\s*\$1,\s*\$2,\s*NULL,\s*NULL,\s*\$3/);
|
||||
expect(insertSql).toContain("'manual'");
|
||||
// First insert params
|
||||
expect(mockExecute.mock.calls[2][1]).toEqual([5, 1, 1234.56]);
|
||||
expect(mockExecute.mock.calls[1][1]).toEqual([5, 1, 1234.56]);
|
||||
// Second insert params (zero is allowed)
|
||||
expect(mockExecute.mock.calls[3][1]).toEqual([5, 2, 0]);
|
||||
// UPDATE updated_at on parent snapshot, then COMMIT last.
|
||||
expect(mockExecute.mock.calls[4][0]).toContain("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[2][1]).toEqual([5, 2, 0]);
|
||||
// Final call = UPDATE updated_at on parent snapshot
|
||||
expect(mockExecute.mock.calls[3][0]).toContain(
|
||||
"UPDATE balance_snapshots"
|
||||
);
|
||||
expect(mockExecute.mock.calls[3][0]).toContain("updated_at");
|
||||
});
|
||||
|
||||
it("clears all lines when called with an empty array", async () => {
|
||||
mockSelect.mockResolvedValueOnce([FAKE_SNAPSHOT]);
|
||||
mockExecute
|
||||
.mockResolvedValueOnce({ rowsAffected: 0 }) // BEGIN
|
||||
.mockResolvedValueOnce({ rowsAffected: 3 }) // delete only
|
||||
.mockResolvedValueOnce({ rowsAffected: 1 }) // bump updated_at
|
||||
.mockResolvedValueOnce({ rowsAffected: 0 }); // COMMIT
|
||||
.mockResolvedValueOnce({ rowsAffected: 1 }); // bump updated_at
|
||||
await upsertSnapshotLines(5, []);
|
||||
// BEGIN + DELETE + UPDATE updated_at + COMMIT — no INSERTs
|
||||
expect(mockExecute).toHaveBeenCalledTimes(4);
|
||||
expect(mockExecute.mock.calls[0][0]).toBe("BEGIN");
|
||||
expect(mockExecute.mock.calls[3][0]).toBe("COMMIT");
|
||||
// Only DELETE + UPDATE updated_at — no INSERTs
|
||||
expect(mockExecute).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -1235,11 +1207,9 @@ describe("upsertSnapshotLines — priced kind", () => {
|
|||
it("inserts a priced line with quantity + unit_price + value", async () => {
|
||||
mockSelect.mockResolvedValueOnce([FAKE_SNAPSHOT]);
|
||||
mockExecute
|
||||
.mockResolvedValueOnce({ rowsAffected: 0 }) // BEGIN
|
||||
.mockResolvedValueOnce({ rowsAffected: 1 }) // delete
|
||||
.mockResolvedValueOnce({ lastInsertId: 100, rowsAffected: 1 }) // insert
|
||||
.mockResolvedValueOnce({ rowsAffected: 1 }) // bump updated_at
|
||||
.mockResolvedValueOnce({ rowsAffected: 0 }); // COMMIT
|
||||
.mockResolvedValueOnce({ rowsAffected: 1 }); // bump updated_at
|
||||
await upsertSnapshotLines(5, [
|
||||
{
|
||||
account_id: 7,
|
||||
|
|
@ -1249,22 +1219,20 @@ describe("upsertSnapshotLines — priced kind", () => {
|
|||
value: 255,
|
||||
},
|
||||
]);
|
||||
const insertSql = mockExecute.mock.calls[2][0] as string;
|
||||
const insertSql = mockExecute.mock.calls[1][0] as string;
|
||||
expect(insertSql).toContain("INSERT INTO balance_snapshot_lines");
|
||||
// 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(mockExecute.mock.calls[2][1]).toEqual([5, 7, 10, 25.5, 255]);
|
||||
expect(mockExecute.mock.calls[1][1]).toEqual([5, 7, 10, 25.5, 255]);
|
||||
});
|
||||
|
||||
it("supports a mix of simple + priced lines in the same batch", async () => {
|
||||
mockSelect.mockResolvedValueOnce([FAKE_SNAPSHOT]);
|
||||
mockExecute
|
||||
.mockResolvedValueOnce({ rowsAffected: 0 }) // BEGIN
|
||||
.mockResolvedValueOnce({ rowsAffected: 1 }) // delete
|
||||
.mockResolvedValueOnce({ lastInsertId: 100, rowsAffected: 1 }) // insert simple
|
||||
.mockResolvedValueOnce({ lastInsertId: 101, rowsAffected: 1 }) // insert priced
|
||||
.mockResolvedValueOnce({ rowsAffected: 1 }) // bump updated_at
|
||||
.mockResolvedValueOnce({ rowsAffected: 0 }); // COMMIT
|
||||
.mockResolvedValueOnce({ rowsAffected: 1 }); // bump updated_at
|
||||
await upsertSnapshotLines(5, [
|
||||
{ account_id: 1, value: 1000 },
|
||||
{
|
||||
|
|
@ -1276,15 +1244,15 @@ describe("upsertSnapshotLines — priced kind", () => {
|
|||
},
|
||||
]);
|
||||
// Simple insert uses literal NULLs for qty/price
|
||||
expect(mockExecute.mock.calls[2][0] as string).toMatch(
|
||||
expect(mockExecute.mock.calls[1][0] as string).toMatch(
|
||||
/VALUES\s*\(\s*\$1,\s*\$2,\s*NULL,\s*NULL,\s*\$3/
|
||||
);
|
||||
expect(mockExecute.mock.calls[2][1]).toEqual([5, 1, 1000]);
|
||||
expect(mockExecute.mock.calls[1][1]).toEqual([5, 1, 1000]);
|
||||
// Priced insert uses placeholders
|
||||
expect(mockExecute.mock.calls[3][0] as string).toMatch(
|
||||
expect(mockExecute.mock.calls[2][0] as string).toMatch(
|
||||
/VALUES\s*\(\s*\$1,\s*\$2,\s*\$3,\s*\$4,\s*\$5/
|
||||
);
|
||||
expect(mockExecute.mock.calls[3][1]).toEqual([5, 7, 10, 50, 500]);
|
||||
expect(mockExecute.mock.calls[2][1]).toEqual([5, 7, 10, 50, 500]);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -2238,556 +2206,3 @@ describe("balance.service.prices", () => {
|
|||
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,16 +15,12 @@ import { loadProfiles } from "./profileService";
|
|||
import type {
|
||||
AccountReturn,
|
||||
BalanceAccount,
|
||||
BalanceAccountKind,
|
||||
BalanceAccountTransferWithTransaction,
|
||||
BalanceAccountWithCategory,
|
||||
BalanceAssetType,
|
||||
BalanceCategory,
|
||||
BalanceCategoryKind,
|
||||
BalanceSecurity,
|
||||
BalanceSnapshot,
|
||||
BalanceSnapshotHolding,
|
||||
BalanceSnapshotHoldingWithSecurity,
|
||||
BalanceSnapshotLine,
|
||||
BalanceTransferDirection,
|
||||
BalanceVehicleType,
|
||||
|
|
@ -56,15 +52,6 @@ export type BalanceErrorCode =
|
|||
| "snapshot_priced_unit_price_required"
|
||||
| "snapshot_priced_value_mismatch"
|
||||
| "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
|
||||
| "transfer_direction_invalid"
|
||||
| "transfer_already_linked"
|
||||
|
|
@ -318,7 +305,6 @@ export async function listBalanceAccounts(options?: {
|
|||
return db.select<BalanceAccountWithCategory[]>(
|
||||
`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.kind, a.detailed_since,
|
||||
a.created_at, a.updated_at,
|
||||
c.key AS category_key, c.i18n_key AS category_i18n_key,
|
||||
c.kind AS category_kind, c.asset_type AS category_asset_type,
|
||||
|
|
@ -336,8 +322,7 @@ export async function getBalanceAccount(
|
|||
const db = await getDb();
|
||||
const rows = await db.select<BalanceAccount[]>(
|
||||
`SELECT id, balance_category_id, name, symbol, currency, notes,
|
||||
is_active, archived_at, vehicle_type, kind, detailed_since,
|
||||
created_at, updated_at
|
||||
is_active, archived_at, vehicle_type, created_at, updated_at
|
||||
FROM balance_accounts
|
||||
WHERE id = $1`,
|
||||
[id]
|
||||
|
|
@ -442,20 +427,6 @@ export interface UpdateBalanceAccountInput {
|
|||
* automobile type.
|
||||
*/
|
||||
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(
|
||||
|
|
@ -503,51 +474,13 @@ export async function updateBalanceAccount(
|
|||
input.vehicle_type !== undefined
|
||||
? normalizeVehicleType(input.vehicle_type)
|
||||
: 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();
|
||||
await db.execute(
|
||||
`UPDATE balance_accounts
|
||||
SET balance_category_id = $1, name = $2, symbol = $3, notes = $4,
|
||||
is_active = $5, vehicle_type = $6, kind = $7, detailed_since = $8,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $9`,
|
||||
[categoryId, name, symbol, notes, isActive, vehicleType, kind, detailedSince, id]
|
||||
is_active = $5, vehicle_type = $6, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $7`,
|
||||
[categoryId, name, symbol, notes, isActive, vehicleType, id]
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -924,189 +857,6 @@ 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`.
|
||||
*
|
||||
|
|
@ -1119,17 +869,6 @@ export async function updateSecurity(
|
|||
*/
|
||||
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 {
|
||||
account_id: number;
|
||||
/**
|
||||
|
|
@ -1149,44 +888,6 @@ export interface SnapshotLineInput {
|
|||
quantity?: number | null;
|
||||
/** Required for priced lines, must be NULL for simple. */
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -1261,209 +962,6 @@ 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
|
||||
* replaced atomically per account; lines for accounts not present in
|
||||
|
|
@ -1492,46 +990,50 @@ export async function upsertSnapshotLines(
|
|||
`Snapshot ${snapshotId} not found`
|
||||
);
|
||||
}
|
||||
// Validate every input up-front before mutating anything (detailed lines go
|
||||
// through validateDetailedSnapshot, scalar lines through the unchanged pass).
|
||||
validateAllLines(lines);
|
||||
// Validate every input up-front before mutating anything.
|
||||
for (const line of lines) {
|
||||
validateLineKindInvariants(line);
|
||||
}
|
||||
|
||||
const db = await getDb();
|
||||
// Strategy: clear and rewrite, wrapped in an explicit transaction so the
|
||||
// line + holdings writes commit together. Snapshot lines are small (one per
|
||||
// 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(
|
||||
"DELETE FROM balance_snapshot_lines WHERE snapshot_id = $1",
|
||||
[snapshotId]
|
||||
);
|
||||
for (const line of lines) {
|
||||
await insertSnapshotLineWithHoldings(db, snapshotId, line);
|
||||
// Strategy: clear and rewrite. Snapshot lines are small (one per active
|
||||
// account, typically < 20) so the simplicity outweighs the diff-tracking
|
||||
// savings. CASCADE guarantees consistency on partial failures.
|
||||
await db.execute(
|
||||
"DELETE FROM balance_snapshot_lines WHERE snapshot_id = $1",
|
||||
[snapshotId]
|
||||
);
|
||||
for (const line of lines) {
|
||||
const kind = line.account_kind ?? "simple";
|
||||
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.
|
||||
await db.execute(
|
||||
`UPDATE balance_snapshots
|
||||
SET updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $1`,
|
||||
[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;
|
||||
}
|
||||
// Bump the parent snapshot's updated_at so list views can sort by recency.
|
||||
await db.execute(
|
||||
`UPDATE balance_snapshots
|
||||
SET updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $1`,
|
||||
[snapshotId]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -1571,9 +1073,10 @@ export async function saveSnapshotAtomic(input: {
|
|||
moveToDate?: string | null;
|
||||
}): Promise<{ snapshotId: number }> {
|
||||
// Validate every line ahead of time so the transaction never opens for
|
||||
// a doomed save. Detailed lines go through validateDetailedSnapshot; scalar
|
||||
// lines keep the unchanged validateLineKindInvariants pass.
|
||||
validateAllLines(input.lines);
|
||||
// a doomed save. Mirrors `upsertSnapshotLines` invariants.
|
||||
for (const line of input.lines) {
|
||||
validateLineKindInvariants(line);
|
||||
}
|
||||
|
||||
const db = await getDb();
|
||||
let inTxn = false;
|
||||
|
|
@ -1630,16 +1133,35 @@ export async function saveSnapshotAtomic(input: {
|
|||
}
|
||||
|
||||
// Rewrite-all strategy (matches `upsertSnapshotLines`): clear
|
||||
// existing lines, then re-insert every line + its holdings. Cheap
|
||||
// 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.
|
||||
// existing lines, then re-insert every line. Cheap because snapshot
|
||||
// line counts are small.
|
||||
await db.execute(
|
||||
"DELETE FROM balance_snapshot_lines WHERE snapshot_id = $1",
|
||||
[snapshotId]
|
||||
);
|
||||
for (const line of input.lines) {
|
||||
await insertSnapshotLineWithHoldings(db, snapshotId, line);
|
||||
const kind = line.account_kind ?? "simple";
|
||||
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(
|
||||
`UPDATE balance_snapshots
|
||||
|
|
@ -1987,174 +1509,6 @@ 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)
|
||||
// -----------------------------------------------------------------------------
|
||||
|
|
|
|||
Loading…
Reference in a new issue