diff --git a/src/components/balance/BalanceAccountsTable.tsx b/src/components/balance/BalanceAccountsTable.tsx index 4991b1a..ef93e42 100644 --- a/src/components/balance/BalanceAccountsTable.tsx +++ b/src/components/balance/BalanceAccountsTable.tsx @@ -18,7 +18,8 @@ // that opens `LinkTransfersModal` (the modal handles its own state; this // component just bubbles up the request via `onLinkTransfers`). -import { useEffect, useMemo, useState } from "react"; +import { Fragment, useEffect, useMemo, useState } from "react"; +import type { ReactNode } from "react"; import { useTranslation } from "react-i18next"; import { Archive, @@ -31,6 +32,8 @@ import { import type { AccountLatestSnapshot, AccountPeriodAnchor, + AccountUnrealizedGain, + LatentGainRollup, } from "../../services/balance.service"; import { computeAccountReturn } from "../../services/balance.service"; import { @@ -89,6 +92,17 @@ interface BalanceAccountsTableProps { * (avoids triggering computation against the unix epoch). */ sinceCreationDate?: string | null; + /** + * Per-account unrealized (latent) gain keyed by `account_id`, prefetched by + * `useBalanceOverview` from each detailed account's latest holdings (Issue + * #216). Drives the latent-gain column + the per-security drill-down. Only + * detailed accounts with holdings appear; absent ⇒ no figure / no drill-down. + */ + latentGainByAccount?: Record; + /** Latent gain rolled up by asset class / envelope, for the summary block (#216). */ + latentGainRollup?: LatentGainRollup; + /** vehicle_type code (incl. 'none') → translated label, for the envelope rollup (#216). */ + vehicleLabels?: Record; } /** @@ -103,6 +117,9 @@ export default function BalanceAccountsTable({ onArchiveAccount, onLinkTransfers, sinceCreationDate, + latentGainByAccount = {}, + latentGainRollup, + vehicleLabels = {}, }: BalanceAccountsTableProps) { const { t, i18n } = useTranslation(); const fmt = cadFormatter(i18n.language === "fr" ? "fr-CA" : "en-CA"); @@ -116,6 +133,20 @@ export default function BalanceAccountsTable({ const [openMenuFor, setOpenMenuFor] = useState(null); + // Per-security drill-down (Issue #216): which detailed account rows are + // expanded. A Set keyed by account_id; toggled by the row's chevron. The + // holdings themselves are already prefetched in `latentGainByAccount`, so + // expanding is a pure render — no extra DB round-trip. + const [expandedFor, setExpandedFor] = useState>(new Set()); + const toggleExpanded = (accountId: number) => { + setExpandedFor((prev) => { + const next = new Set(prev); + if (next.has(accountId)) next.delete(accountId); + else next.add(accountId); + return next; + }); + }; + // Progressive disclosure of the 4 return columns (Issue #204). Collapsed by // default; the persisted choice is read once on mount. We start `false` so // the columns never flash open before the preference resolves. @@ -280,6 +311,58 @@ export default function BalanceAccountsTable({ ); } + /** + * Latent (unrealized) gain cell — value + % (Issue #216). `gain`/`gainPct` + * null ⇒ the shared service guard already decided "N/A" (book_cost NULL or 0); + * render the i18n N/A string, never a divide-by-zero. `partial` flags an + * aggregate where some holdings had an unknown book_cost (% understated). + */ + function renderLatentGainCell( + gain: number | null, + gainPct: number | null, + partial = false + ) { + if (gain === null) { + return ( + + {t("balance.latentGain.na")} + + ); + } + const positive = gain >= 0; + return ( + + + {positive ? "+" : ""} + {fmt.format(gain)} + + {gainPct !== null && ( + + ({positive ? "+" : ""} + {(gainPct * 100).toFixed(2)}%) + + )} + {partial && ( + + )} + + ); + } + + // The latent-gain column only earns its place when at least one detailed + // account has a computed gain — otherwise we keep the table narrow. + const hasLatentGain = accounts.some( + (a) => latentGainByAccount[a.account_id] !== undefined + ); + return (
@@ -317,6 +400,14 @@ export default function BalanceAccountsTable({ {t("balance.overview.periodDelta")} + {hasLatentGain && ( + + {t("balance.latentGain.column")} + + )} {showReturns && ( <> @@ -348,13 +439,44 @@ export default function BalanceAccountsTable({ 100 : null; const accReturns = returns[acc.account_id] ?? {}; + const lg = latentGainByAccount[acc.account_id]; + // A detailed account is drillable once its latest holdings are + // loaded. Simple accounts (no holdings) never expand. + const drillable = !!lg && lg.holdings.length > 0; + const isExpanded = drillable && expandedFor.has(acc.account_id); + // Number of columns a drill-down sub-row must span. + const colSpan = + 5 + (hasLatentGain ? 1 : 0) + (showReturns ? 4 : 0); return ( + - {acc.account_name} + + {drillable ? ( + + ) : ( + + )} + {acc.account_name} + {acc.symbol ? ( ({acc.symbol}) @@ -383,6 +505,17 @@ export default function BalanceAccountsTable({ "—" )} + {hasLatentGain && ( + + {lg + ? renderLatentGainCell( + lg.total_gain, + lg.total_gain_pct, + lg.has_unknown_book_cost + ) + : "—"} + + )} {showReturns && ( <> @@ -458,11 +591,147 @@ export default function BalanceAccountsTable({ )} + {isExpanded && ( + + +
+ + + + + + + + + + {lg!.holdings.map((h) => ( + + + + + + ))} + +
+ {t("balance.latentGain.drilldown.security")} + + {t("balance.latentGain.drilldown.value")} + + {t("balance.latentGain.drilldown.gain")} +
+ {h.symbol} + + {fmt.format(h.value)} + + {renderLatentGainCell(h.gain, h.gain_pct)} +
+
+ + + )} +
); })}
+ {latentGainRollup && + (latentGainRollup.byClass.length > 0 || + latentGainRollup.byVehicle.length > 0) && ( + + )} +
+ ); +} + +// ----------------------------------------------------------------------------- +// LatentGainSummary — aggregated latent gain by asset class / envelope (#216). +// Reuses the accounts surface (no new chart): a compact two-column block of +// rollup figures. Asset-class labels resolve from the accounts payload (same +// renderCategoryLabel path the table uses); envelope labels come from the +// shared `vehicleLabels` map BalancePage already builds. +// ----------------------------------------------------------------------------- + +interface LatentGainSummaryProps { + rollup: LatentGainRollup; + accounts: AccountLatestSnapshot[]; + vehicleLabels: Record; + fmt: Intl.NumberFormat; + renderGain: ( + gain: number | null, + gainPct: number | null, + partial?: boolean + ) => ReactNode; +} + +function LatentGainSummary({ + rollup, + accounts, + vehicleLabels, + renderGain, +}: LatentGainSummaryProps) { + const { t } = useTranslation(); + + // category_key → translated asset-class label, from the accounts payload. + const classLabels = useMemo(() => { + const m: Record = {}; + for (const a of accounts) { + if (!m[a.category_key]) { + m[a.category_key] = renderCategoryLabelFromAccount(a, t); + } + } + return m; + }, [accounts, t]); + + const section = ( + title: string, + buckets: LatentGainRollup["byClass"], + labelFor: (key: string) => string + ) => ( +
+

+ {title} +

+ + + {buckets.map((b) => ( + + + + + ))} + +
+ {labelFor(b.group_key)} + + {renderGain(b.total_gain, b.total_gain_pct, b.has_unknown_book_cost)} +
+
+ ); + + return ( +
+

+ {t("balance.latentGain.summary.title")} +

+
+ {section( + t("balance.latentGain.summary.byClass"), + rollup.byClass, + (key) => classLabels[key] ?? key + )} + {section( + t("balance.latentGain.summary.byVehicle"), + rollup.byVehicle, + (key) => vehicleLabels[key] ?? key + )} +
); } diff --git a/src/components/balance/BalanceOverviewCard.tsx b/src/components/balance/BalanceOverviewCard.tsx index 2ee60ad..8a33fcb 100644 --- a/src/components/balance/BalanceOverviewCard.tsx +++ b/src/components/balance/BalanceOverviewCard.tsx @@ -12,7 +12,10 @@ import { useMemo } from "react"; import { useTranslation } from "react-i18next"; import { Plus, TrendingUp, TrendingDown, AlertTriangle } from "lucide-react"; import { Link } from "react-router-dom"; -import type { SnapshotTotalPoint } from "../../services/balance.service"; +import type { + LatentGainRollup, + SnapshotTotalPoint, +} from "../../services/balance.service"; const STALENESS_DAYS = 60; const cadFormatter = (value: number) => @@ -25,9 +28,18 @@ const cadFormatter = (value: number) => interface BalanceOverviewCardProps { /** The full evolution series for the active period (latest at the end). */ totals: SnapshotTotalPoint[]; + /** + * Aggregated latent gain across all detailed accounts (Issue #216). Shown as + * a total figure alongside the net worth. Absent / no detailed account ⇒ the + * latent-gain line is hidden (no zero-noise for users without securities). + */ + latentGainRollup?: LatentGainRollup; } -export default function BalanceOverviewCard({ totals }: BalanceOverviewCardProps) { +export default function BalanceOverviewCard({ + totals, + latentGainRollup, +}: BalanceOverviewCardProps) { const { t, i18n } = useTranslation(); const summary = useMemo(() => { @@ -58,6 +70,19 @@ export default function BalanceOverviewCard({ totals }: BalanceOverviewCardProps day: "numeric", }); + // Total latent gain across detailed accounts. Only render when at least one + // detailed account contributed a bucket — otherwise the line is hidden. + const latent = useMemo(() => { + if ( + !latentGainRollup || + (latentGainRollup.byClass.length === 0 && + latentGainRollup.byVehicle.length === 0) + ) { + return null; + } + return latentGainRollup.grandTotal; + }, [latentGainRollup]); + return (
@@ -75,6 +100,36 @@ export default function BalanceOverviewCard({ totals }: BalanceOverviewCardProps date: formatDate(summary.latest.snapshot_date), })}

+ {latent && ( +

+ + {t("balance.latentGain.totalLabel")} + + = 0 + ? "text-[var(--positive)]" + : "text-[var(--negative)]" + }`} + > + {latent.total_gain >= 0 ? "+" : ""} + {cadFormatter(latent.total_gain)} + {latent.total_gain_pct !== null && ( + + ({latent.total_gain >= 0 ? "+" : ""} + {(latent.total_gain_pct * 100).toFixed(2)}%) + + )} + + {latent.has_unknown_book_cost && ( + + )} +

+ )} ) : (

diff --git a/src/hooks/useBalanceOverview.ts b/src/hooks/useBalanceOverview.ts index 756ca02..f324102 100644 --- a/src/hooks/useBalanceOverview.ts +++ b/src/hooks/useBalanceOverview.ts @@ -21,11 +21,15 @@ import { getSnapshotTotalsByVehicleAndDate, getAccountsLatestSnapshot, getAccountsPeriodAnchor, + getAccountLatentGainByLine, + rollupLatentGain, type SnapshotTotalPoint, type SnapshotCategoryBreakdownPoint, type SnapshotVehicleBreakdownPoint, type AccountLatestSnapshot, type AccountPeriodAnchor, + type AccountUnrealizedGain, + type LatentGainRollup, type SnapshotDateRange, } from "../services/balance.service"; @@ -44,10 +48,31 @@ interface State { evolutionByVehicle: SnapshotVehicleBreakdownPoint[]; accountsLatest: AccountLatestSnapshot[]; accountsPeriodAnchor: AccountPeriodAnchor[]; + /** + * Per-account unrealized (latent) gain keyed by `account_id`, computed from + * each detailed account's LATEST snapshot-line holdings (Issue #216). Only + * detailed accounts with holdings appear; simple accounts are absent. + */ + latentGainByAccount: Record; + /** Latent gain rolled up by asset class, by envelope, and grand total (#216). */ + latentGainRollup: LatentGainRollup; isLoading: boolean; error: string | null; } +/** Empty rollup — initial state and the no-detailed-account case. */ +const EMPTY_ROLLUP: LatentGainRollup = { + byClass: [], + byVehicle: [], + grandTotal: { + total_value: 0, + total_book_cost: 0, + total_gain: 0, + total_gain_pct: null, + has_unknown_book_cost: false, + }, +}; + type Action = | { type: "SET_PERIOD"; payload: BalancePeriod } | { type: "SET_CHART_MODE"; payload: BalanceChartMode } @@ -61,6 +86,8 @@ type Action = evolutionByVehicle: SnapshotVehicleBreakdownPoint[]; accountsLatest: AccountLatestSnapshot[]; accountsPeriodAnchor: AccountPeriodAnchor[]; + latentGainByAccount: Record; + latentGainRollup: LatentGainRollup; }; } | { type: "LOAD_ERROR"; payload: string }; @@ -75,6 +102,8 @@ function initialState(): State { evolutionByVehicle: [], accountsLatest: [], accountsPeriodAnchor: [], + latentGainByAccount: {}, + latentGainRollup: EMPTY_ROLLUP, isLoading: false, error: null, }; @@ -154,6 +183,41 @@ export function useBalanceOverview(): UseBalanceOverviewResult { getAccountsLatestSnapshot(), getAccountsPeriodAnchor(range), ]); + + // Latent gain (Issue #216): only detailed accounts that actually carry a + // latest snapshot line can have holdings. Fetch each in parallel, fold the + // holdings through the shared unrealized-gain guard, then roll up by asset + // class / envelope. Per-account fetch failures are isolated (the account + // simply has no latent-gain figure). Simple accounts are skipped entirely. + const detailed = latest.filter( + (a) => a.kind === "detailed" && a.latest_snapshot_line_id != null + ); + const latentEntries = await Promise.all( + detailed.map(async (a) => { + try { + const gain = await getAccountLatentGainByLine( + a.latest_snapshot_line_id as number + ); + return { account: a, gain }; + } catch { + return null; + } + }) + ); + const latentGainByAccount: Record = {}; + for (const e of latentEntries) { + if (e) latentGainByAccount[e.account.account_id] = e.gain; + } + const latentGainRollup = rollupLatentGain( + latentEntries + .filter((e): e is NonNullable => e !== null) + .map((e) => ({ + category_key: e.account.category_key, + vehicle_type: e.account.vehicle_type, + gain: e.gain, + })) + ); + dispatch({ type: "LOAD_SUCCESS", payload: { @@ -162,6 +226,8 @@ export function useBalanceOverview(): UseBalanceOverviewResult { evolutionByVehicle: byVehicle, accountsLatest: latest, accountsPeriodAnchor: anchors, + latentGainByAccount, + latentGainRollup, }, }); } catch (err) { diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 40c7de3..7a4976a 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -1839,6 +1839,25 @@ "hide": "Hide returns" } }, + "latentGain": { + "column": "Unrealized gain", + "tooltip": "Latent gain on a detailed account: current value minus cost basis (book cost), in dollars and percent. Shown “—” when the cost basis is unknown.", + "totalLabel": "Unrealized gain", + "na": "—", + "partial": "Partial: some positions have no recorded cost basis and are excluded from the percentage.", + "drilldown": { + "expand": "Show securities", + "collapse": "Hide securities", + "security": "Security", + "value": "Value", + "gain": "Unrealized gain" + }, + "summary": { + "title": "Unrealized gain breakdown", + "byClass": "By asset class", + "byVehicle": "By envelope" + } + }, "vehicle": { "none": "No envelope" }, diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 54d7683..07a9860 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -1839,6 +1839,25 @@ "hide": "Masquer les rendements" } }, + "latentGain": { + "column": "Gain latent", + "tooltip": "Gain latent d'un compte détaillé : valeur actuelle moins le coût d'acquisition, en dollars et en pourcentage. Affiché « — » quand le coût d'acquisition est inconnu.", + "totalLabel": "Gain latent", + "na": "—", + "partial": "Partiel : certaines positions n'ont pas de coût d'acquisition saisi et sont exclues du pourcentage.", + "drilldown": { + "expand": "Afficher les titres", + "collapse": "Masquer les titres", + "security": "Titre", + "value": "Valeur", + "gain": "Gain latent" + }, + "summary": { + "title": "Répartition du gain latent", + "byClass": "Par classe d'actif", + "byVehicle": "Par enveloppe" + } + }, "vehicle": { "none": "Sans enveloppe" }, diff --git a/src/pages/BalancePage.tsx b/src/pages/BalancePage.tsx index ba3e085..838d366 100644 --- a/src/pages/BalancePage.tsx +++ b/src/pages/BalancePage.tsx @@ -215,7 +215,10 @@ export default function BalancePage() { return (

- +
{/* Period selector */} @@ -316,6 +319,9 @@ export default function BalancePage() { accounts={state.accountsLatest} periodAnchor={state.accountsPeriodAnchor} sinceCreationDate={earliestSnapshotDate} + latentGainByAccount={state.latentGainByAccount} + latentGainRollup={state.latentGainRollup} + vehicleLabels={vehicleLabels} onArchiveAccount={(acc) => handleArchiveAccount(acc.account_id)} onLinkTransfers={(acc) => setLinkTarget(acc)} /> diff --git a/src/services/balance.service.test.ts b/src/services/balance.service.test.ts index c9d55d9..eeb9115 100644 --- a/src/services/balance.service.test.ts +++ b/src/services/balance.service.test.ts @@ -47,6 +47,9 @@ import { listHoldingsBySnapshotLine, getHoldingsForLatestSnapshot, computeUnrealizedGain, + getAccountLatentGainByLine, + rollupLatentGain, + type AccountUnrealizedGain, PRICED_VALUE_TOLERANCE, BalanceServiceError, getSnapshotTotalsByDate, @@ -1703,6 +1706,9 @@ describe("getAccountsLatestSnapshot", () => { // LEFT JOIN-equivalent: scalar subquery so accounts with no lines still surface. expect(sql).toContain("ORDER BY s.snapshot_date DESC"); expect(sql).toContain("LIMIT 1"); + // #216: account kind + latest line id surfaced for the drill-down. + expect(sql).toContain("a.kind AS kind"); + expect(sql).toContain("AS latest_snapshot_line_id"); }); }); @@ -2791,3 +2797,120 @@ describe("computeUnrealizedGain", () => { expect(r.holdings).toEqual([]); }); }); + +describe("getAccountLatentGainByLine", () => { + it("reads the line's holdings then folds them through computeUnrealizedGain", async () => { + mockSelect.mockResolvedValueOnce([ + { + security_id: 1, + value: 120, + book_cost: 100, + security_symbol: "AAPL", + }, + { + security_id: 2, + value: 300, + book_cost: null, + security_symbol: "MSFT", + }, + ]); + const r = await getAccountLatentGainByLine(42); + // It reads holdings of the supplied line id. + expect(mockSelect.mock.calls[0][1]).toEqual([42]); + // Aggregate mirrors computeUnrealizedGain (NULL book_cost excluded, flagged). + expect(r.total_value).toBe(420); + expect(r.total_gain).toBe(20); + expect(r.has_unknown_book_cost).toBe(true); + // Symbol from the security join is carried onto the per-holding rows. + expect(r.holdings[0].symbol).toBe("AAPL"); + }); +}); + +describe("rollupLatentGain", () => { + // A small AccountUnrealizedGain factory keeps the cases readable. + const g = ( + over: Partial + ): AccountUnrealizedGain => ({ + total_value: 0, + total_book_cost: 0, + total_gain: 0, + total_gain_pct: null, + has_unknown_book_cost: false, + holdings: [], + ...over, + }); + + it("groups by asset class and by envelope, and sums a grand total", () => { + const r = rollupLatentGain([ + { + category_key: "stock", + vehicle_type: "tfsa", + gain: g({ total_value: 120, total_book_cost: 100, total_gain: 20 }), + }, + { + category_key: "stock", + vehicle_type: "rrsp", + gain: g({ total_value: 80, total_book_cost: 100, total_gain: -20 }), + }, + { + category_key: "crypto", + vehicle_type: "tfsa", + gain: g({ total_value: 50, total_book_cost: 40, total_gain: 10 }), + }, + ]); + + // Asset-class buckets: stock merges the two accounts, crypto stands alone. + const stock = r.byClass.find((b) => b.group_key === "stock")!; + expect(stock.total_value).toBe(200); + expect(stock.total_gain).toBe(0); + expect(stock.total_gain_pct).toBe(0); // 0 / 200 + const crypto = r.byClass.find((b) => b.group_key === "crypto")!; + expect(crypto.total_gain_pct).toBeCloseTo(0.25, 5); // 10 / 40 + + // Envelope buckets: tfsa merges stock+crypto, rrsp stands alone. + const tfsa = r.byVehicle.find((b) => b.group_key === "tfsa")!; + expect(tfsa.total_value).toBe(170); + expect(tfsa.total_gain).toBe(30); + expect(tfsa.total_gain_pct).toBeCloseTo(30 / 140, 5); + + // Grand total across every account. + expect(r.grandTotal.total_value).toBe(250); + expect(r.grandTotal.total_gain).toBe(10); + expect(r.grandTotal.total_gain_pct).toBeCloseTo(10 / 240, 5); + expect(r.grandTotal.has_unknown_book_cost).toBe(false); + }); + + it("buckets a NULL vehicle_type under the 'none' envelope key", () => { + const r = rollupLatentGain([ + { + category_key: "other", + vehicle_type: null, + gain: g({ total_value: 10, total_book_cost: 10, total_gain: 0 }), + }, + ]); + expect(r.byVehicle.map((b) => b.group_key)).toEqual(["none"]); + }); + + it("a bucket with no known book_cost yields a null % and flags unknown", () => { + const r = rollupLatentGain([ + { + category_key: "stock", + vehicle_type: "tfsa", + // All book_cost unknown ⇒ total_book_cost 0, gain 0, flagged. + gain: g({ total_value: 500, has_unknown_book_cost: true }), + }, + ]); + expect(r.byClass[0].total_gain_pct).toBeNull(); + expect(r.byClass[0].has_unknown_book_cost).toBe(true); + expect(r.grandTotal.total_gain_pct).toBeNull(); + expect(r.grandTotal.has_unknown_book_cost).toBe(true); + }); + + it("empty input ⇒ empty buckets and a zeroed grand total", () => { + const r = rollupLatentGain([]); + expect(r.byClass).toEqual([]); + expect(r.byVehicle).toEqual([]); + expect(r.grandTotal.total_value).toBe(0); + expect(r.grandTotal.total_gain_pct).toBeNull(); + }); +}); diff --git a/src/services/balance.service.ts b/src/services/balance.service.ts index acfacb0..bb11cc9 100644 --- a/src/services/balance.service.ts +++ b/src/services/balance.service.ts @@ -1874,6 +1874,13 @@ export interface AccountLatestSnapshot { category_kind: BalanceCategoryKind; /** Mirror of `balance_categories.custom_label` — drives renderCategoryLabel. */ category_custom_label?: string | null; + /** + * Entry mode of the account itself (`balance_accounts.kind`, migration v15). + * 'detailed' accounts can drill down to per-security holdings; 'simple' carry + * a single value. Surfaced so the accounts table knows which rows expand + * (Issue #216). Authoritative over `category_kind` for dispatch. + */ + kind: BalanceAccountKind; /** * Fiscal envelope of the account (`vehicle_type`), or NULL when none. * Surfaced for the "par enveloppe" axis groupings (Issue #204). @@ -1883,6 +1890,13 @@ export interface AccountLatestSnapshot { latest_snapshot_date: string | null; /** Value at that snapshot, or null if the account has no snapshot lines. */ latest_value: number | null; + /** + * Id of the snapshot LINE backing `latest_value` (the row in + * `balance_snapshot_lines`), or null if the account has no snapshot lines. + * Lets the per-security drill-down read holdings via + * `listHoldingsBySnapshotLine` without a second resolve (Issue #216). + */ + latest_snapshot_line_id: number | null; } /** @@ -1908,6 +1922,7 @@ export async function getAccountsLatestSnapshot(): Promise< c.i18n_key AS category_i18n_key, c.kind AS category_kind, c.custom_label AS category_custom_label, + a.kind AS kind, a.vehicle_type AS vehicle_type, (SELECT s.snapshot_date FROM balance_snapshot_lines l @@ -1920,7 +1935,13 @@ export async function getAccountsLatestSnapshot(): Promise< JOIN balance_snapshots s ON s.id = l.snapshot_id WHERE l.account_id = a.id ORDER BY s.snapshot_date DESC - LIMIT 1) AS latest_value + LIMIT 1) AS latest_value, + (SELECT l.id + FROM balance_snapshot_lines l + JOIN balance_snapshots s ON s.id = l.snapshot_id + WHERE l.account_id = a.id + ORDER BY s.snapshot_date DESC + LIMIT 1) AS latest_snapshot_line_id FROM balance_accounts a INNER JOIN balance_categories c ON c.id = a.balance_category_id WHERE a.is_active = 1 AND a.archived_at IS NULL @@ -2155,6 +2176,150 @@ export function computeUnrealizedGain( }; } +/** + * Latent gain of a single detailed account, resolved from its latest snapshot + * line. Reads that line's holdings (joined with their security for display) and + * folds them through `computeUnrealizedGain` — so the N/A / book_cost guards are + * shared with the per-holding path (Issue #216). Returns the per-holding + * breakdown plus the account-level aggregate; `lineId` is the + * `latest_snapshot_line_id` carried by `AccountLatestSnapshot`. + */ +export async function getAccountLatentGainByLine( + lineId: number +): Promise { + const holdings = await listHoldingsBySnapshotLine(lineId); + return computeUnrealizedGain( + holdings.map((h) => ({ + security_id: h.security_id, + value: h.value, + book_cost: h.book_cost, + symbol: h.security_symbol, + })) + ); +} + +/** A single bucket of the latent-gain rollup (one asset class or one envelope). */ +export interface LatentGainBucket { + /** + * Grouping key: the category `key` for asset-class buckets, or the + * `vehicle_type` code (with `VEHICLE_NONE_BUCKET` for NULL) for envelope + * buckets. Lets the UI resolve a translated label without re-querying. + */ + group_key: string; + /** SUM(value) across the detailed accounts in this bucket. */ + total_value: number; + /** SUM(value − book_cost) over holdings WITH a known book_cost. */ + total_gain: number; + /** total_gain / SUM(book_cost), or null when no known book_cost contributes. */ + total_gain_pct: number | null; + /** True when ≥1 holding in the bucket has a NULL book_cost (excluded). */ + has_unknown_book_cost: boolean; +} + +/** Per-account input row for `rollupLatentGain` (decouples the SQL from the math). */ +export interface AccountLatentGainInput { + category_key: string; + vehicle_type?: BalanceVehicleType | null; + gain: AccountUnrealizedGain; +} + +/** Output of `rollupLatentGain`: aggregates by asset class, by envelope, grand total. */ +export interface LatentGainRollup { + byClass: LatentGainBucket[]; + byVehicle: LatentGainBucket[]; + /** Grand total across every detailed account (the BalanceOverviewCard figure). */ + grandTotal: { + total_value: number; + total_book_cost: number; + total_gain: number; + total_gain_pct: number | null; + has_unknown_book_cost: boolean; + }; +} + +/** + * Pure rollup of per-account latent gains into asset-class buckets, envelope + * buckets, and a grand total (Issue #216). No DB access — feed it the per-account + * `AccountUnrealizedGain`s (from `getAccountLatentGainByLine`) tagged with their + * `category_key` and `vehicle_type`, so it's trivially unit-testable. + * + * Accumulates `total_book_cost` per bucket internally to derive a sound `%` + * (gain / known-book_cost), mirroring `computeUnrealizedGain`'s aggregate guard: + * a bucket with no known book_cost yields `total_gain_pct = null`. NULL-book_cost + * holdings are already excluded from each account's `total_book_cost`/`total_gain` + * and surfaced via `has_unknown_book_cost`, which is OR-ed up per bucket. + * Buckets are emitted in first-seen order (callers sort for display). + */ +export function rollupLatentGain( + accounts: AccountLatentGainInput[] +): LatentGainRollup { + interface Acc { + group_key: string; + total_value: number; + total_book_cost: number; + total_gain: number; + has_unknown_book_cost: boolean; + } + const classMap = new Map(); + const vehicleMap = new Map(); + const grand: Acc = { + group_key: "", + total_value: 0, + total_book_cost: 0, + total_gain: 0, + has_unknown_book_cost: false, + }; + + const fold = (map: Map, key: string, g: AccountUnrealizedGain) => { + let bucket = map.get(key); + if (!bucket) { + bucket = { + group_key: key, + total_value: 0, + total_book_cost: 0, + total_gain: 0, + has_unknown_book_cost: false, + }; + map.set(key, bucket); + } + bucket.total_value += g.total_value; + bucket.total_book_cost += g.total_book_cost; + bucket.total_gain += g.total_gain; + bucket.has_unknown_book_cost ||= g.has_unknown_book_cost; + }; + + for (const a of accounts) { + const vehKey = a.vehicle_type ?? VEHICLE_NONE_BUCKET; + fold(classMap, a.category_key, a.gain); + fold(vehicleMap, vehKey, a.gain); + grand.total_value += a.gain.total_value; + grand.total_book_cost += a.gain.total_book_cost; + grand.total_gain += a.gain.total_gain; + grand.has_unknown_book_cost ||= a.gain.has_unknown_book_cost; + } + + const toBucket = (b: Acc): LatentGainBucket => ({ + group_key: b.group_key, + total_value: b.total_value, + total_gain: b.total_gain, + total_gain_pct: b.total_book_cost === 0 ? null : b.total_gain / b.total_book_cost, + has_unknown_book_cost: b.has_unknown_book_cost, + }); + + return { + byClass: [...classMap.values()].map(toBucket), + byVehicle: [...vehicleMap.values()].map(toBucket), + grandTotal: { + total_value: grand.total_value, + total_book_cost: grand.total_book_cost, + total_gain: grand.total_gain, + total_gain_pct: + grand.total_book_cost === 0 ? null : grand.total_gain / grand.total_book_cost, + has_unknown_book_cost: grand.has_unknown_book_cost, + }, + }; +} + // ----------------------------------------------------------------------------- // Returns + transfers (Issue #142 / Bilan #4) // -----------------------------------------------------------------------------