diff --git a/src/services/balance.service.test.ts b/src/services/balance.service.test.ts index ecefc19..c9d55d9 100644 --- a/src/services/balance.service.test.ts +++ b/src/services/balance.service.test.ts @@ -36,6 +36,17 @@ import { saveSnapshotAtomic, getPreviousSnapshot, validateLineKindInvariants, + validateDetailedSnapshot, + isDetailedLine, + normalizeSecuritySymbol, + roundToCent, + findOrCreateSecurity, + listSecurities, + getSecurity, + updateSecurity, + listHoldingsBySnapshotLine, + getHoldingsForLatestSnapshot, + computeUnrealizedGain, PRICED_VALUE_TOLERANCE, BalanceServiceError, getSnapshotTotalsByDate, @@ -609,6 +620,8 @@ describe("balance accounts — vehicle_type (#202)", () => { is_active: 1, archived_at: null, vehicle_type: "tfsa", + kind: "simple", + detailed_since: null, created_at: "", updated_at: "", }, @@ -633,6 +646,8 @@ describe("balance accounts — vehicle_type (#202)", () => { is_active: 1, archived_at: null, vehicle_type: null, + kind: "simple", + detailed_since: null, created_at: "", updated_at: "", }, @@ -655,6 +670,8 @@ describe("balance accounts — vehicle_type (#202)", () => { is_active: 1, archived_at: null, vehicle_type: "tfsa", + kind: "simple", + detailed_since: null, created_at: "", updated_at: "", }, @@ -722,6 +739,8 @@ describe("updateBalanceAccount", () => { notes: null, is_active: 1, archived_at: null, + kind: "simple", + detailed_since: null, created_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 () => { 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: 1 }) // update updated_at + .mockResolvedValueOnce({ rowsAffected: 0 }); // COMMIT await upsertSnapshotLines(5, [ { account_id: 1, value: 1234.56 }, { account_id: 2, value: 0 }, ]); - // 1st call = DELETE - expect(mockExecute.mock.calls[0][0]).toContain( + // 1st call = BEGIN, 2nd = DELETE + expect(mockExecute.mock.calls[0][0]).toBe("BEGIN"); + expect(mockExecute.mock.calls[1][0]).toContain( "DELETE FROM balance_snapshot_lines" ); // Inserts use literal NULL for quantity/unit_price (simple kind invariant) - const insertSql = mockExecute.mock.calls[1][0] as string; + const insertSql = mockExecute.mock.calls[2][0] as string; expect(insertSql).toContain("INSERT INTO balance_snapshot_lines"); expect(insertSql).toMatch(/VALUES\s*\(\s*\$1,\s*\$2,\s*NULL,\s*NULL,\s*\$3/); expect(insertSql).toContain("'manual'"); // First insert params - expect(mockExecute.mock.calls[1][1]).toEqual([5, 1, 1234.56]); + expect(mockExecute.mock.calls[2][1]).toEqual([5, 1, 1234.56]); // Second insert params (zero is allowed) - expect(mockExecute.mock.calls[2][1]).toEqual([5, 2, 0]); - // Final call = UPDATE updated_at on parent snapshot - expect(mockExecute.mock.calls[3][0]).toContain( - "UPDATE balance_snapshots" + expect(mockExecute.mock.calls[3][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[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: 1 }) // bump updated_at + .mockResolvedValueOnce({ rowsAffected: 0 }); // COMMIT await upsertSnapshotLines(5, []); - // Only DELETE + UPDATE updated_at — no INSERTs - expect(mockExecute).toHaveBeenCalledTimes(2); + // 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"); }); }); @@ -1207,9 +1235,11 @@ 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: 1 }) // bump updated_at + .mockResolvedValueOnce({ rowsAffected: 0 }); // COMMIT await upsertSnapshotLines(5, [ { account_id: 7, @@ -1219,20 +1249,22 @@ describe("upsertSnapshotLines — priced kind", () => { 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"); // 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[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 () => { 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: 1 }) // bump updated_at + .mockResolvedValueOnce({ rowsAffected: 0 }); // COMMIT await upsertSnapshotLines(5, [ { account_id: 1, value: 1000 }, { @@ -1244,15 +1276,15 @@ describe("upsertSnapshotLines — priced kind", () => { }, ]); // 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/ ); - expect(mockExecute.mock.calls[1][1]).toEqual([5, 1, 1000]); + expect(mockExecute.mock.calls[2][1]).toEqual([5, 1, 1000]); // 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/ ); - 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(); }); }); + +// ============================================================================= +// 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([]); + }); +}); diff --git a/src/services/balance.service.ts b/src/services/balance.service.ts index 6688254..acfacb0 100644 --- a/src/services/balance.service.ts +++ b/src/services/balance.service.ts @@ -15,12 +15,16 @@ import { loadProfiles } from "./profileService"; import type { AccountReturn, BalanceAccount, + BalanceAccountKind, BalanceAccountTransferWithTransaction, BalanceAccountWithCategory, BalanceAssetType, BalanceCategory, BalanceCategoryKind, + BalanceSecurity, BalanceSnapshot, + BalanceSnapshotHolding, + BalanceSnapshotHoldingWithSecurity, BalanceSnapshotLine, BalanceTransferDirection, BalanceVehicleType, @@ -52,6 +56,15 @@ 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" @@ -305,6 +318,7 @@ export async function listBalanceAccounts(options?: { return db.select( `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, @@ -322,7 +336,8 @@ export async function getBalanceAccount( const db = await getDb(); const rows = await db.select( `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 WHERE id = $1`, [id] @@ -427,6 +442,20 @@ 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( @@ -474,13 +503,51 @@ 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>( + `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, updated_at = CURRENT_TIMESTAMP - WHERE id = $7`, - [categoryId, name, symbol, notes, isActive, vehicleType, id] + 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] ); } @@ -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(query: string, bindValues?: unknown[]): Promise; + 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 { + 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( + `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 { + const db = await getDb(); + return db.select( + `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 { + const db = await getDb(); + const rows = await db.select( + `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 { + 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`. * @@ -869,6 +1119,17 @@ export async function listLinesBySnapshot( */ 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; /** @@ -888,6 +1149,44 @@ 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; } /** @@ -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 { + 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 @@ -990,50 +1492,46 @@ export async function upsertSnapshotLines( `Snapshot ${snapshotId} not found` ); } - // Validate every input up-front before mutating anything. - for (const line of lines) { - validateLineKindInvariants(line); - } + // Validate every input up-front before mutating anything (detailed lines go + // through validateDetailedSnapshot, scalar lines through the unchanged pass). + validateAllLines(lines); const db = await getDb(); - // Strategy: clear and rewrite. Snapshot lines are small (one per active - // account, typically < 20) so the simplicity outweighs the diff-tracking - // savings. CASCADE guarantees consistency on partial failures. - await db.execute( - "DELETE FROM balance_snapshot_lines WHERE snapshot_id = $1", - [snapshotId] - ); - for (const line of lines) { - 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, - ] - ); + // 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); } + // 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] - ); } /** @@ -1073,10 +1571,9 @@ 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. Mirrors `upsertSnapshotLines` invariants. - for (const line of input.lines) { - validateLineKindInvariants(line); - } + // a doomed save. Detailed lines go through validateDetailedSnapshot; scalar + // lines keep the unchanged validateLineKindInvariants pass. + validateAllLines(input.lines); const db = await getDb(); let inTxn = false; @@ -1133,35 +1630,16 @@ export async function saveSnapshotAtomic(input: { } // Rewrite-all strategy (matches `upsertSnapshotLines`): clear - // existing lines, then re-insert every line. Cheap because snapshot - // line counts are small. + // 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. await db.execute( "DELETE FROM balance_snapshot_lines WHERE snapshot_id = $1", [snapshotId] ); for (const line of input.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, - ] - ); - } + await insertSnapshotLineWithHoldings(db, snapshotId, line); } await db.execute( `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 { + const db = await getDb(); + return db.select( + `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 { + const db = await getDb(); + return db.select( + `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 & { + 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) // -----------------------------------------------------------------------------