Compare commits

..

2 commits

Author SHA1 Message Date
1a4cab2e9b Merge pull request 'feat(balance): per-security drill-down + latent gain (#216)' (#224) from issue-216-drilldown-gain into main 2026-06-10 01:07:56 +00:00
le king fu
76ddad66c9 feat(balance): per-security drill-down + latent gain in accounts table (#216)
Surface unrealized (latent) gain on the existing balance surfaces, no new
visualization (decision 2026-06-04).

Service:
- AccountLatestSnapshot gains account `kind` + `latest_snapshot_line_id`, so
  the table knows which rows are detailed and where their holdings live.
- getAccountLatentGainByLine(lineId) folds a line's holdings through the
  existing computeUnrealizedGain (shares the book_cost=0 / NULL -> N/A guard).
- rollupLatentGain(accounts): pure aggregation by asset class, by envelope
  (vehicle_type, 'none' bucket), and grand total; per-bucket % denominator is
  the known book_cost only (null when none), mirroring the aggregate guard.

Hook (useBalanceOverview):
- Prefetches per-detailed-account latent gain in parallel (failures isolated),
  exposes latentGainByAccount + latentGainRollup. Simple accounts skipped.

UI:
- BalanceAccountsTable: expandable detailed rows -> per-security value + latent
  gain %; a latent-gain column (shown only when a detailed account has one);
  a summary block aggregating latent gain by asset class / envelope.
- BalanceOverviewCard: total latent gain line (hidden without detailed accounts).
- N/A rendered (never divide-by-zero) when book_cost is NULL or 0; partial-%
  flag when some positions lack a cost basis.

Native non-CAD currency display de-scoped this round (untestable while all
securities are CAD). Modified Dietz return columns (#204) unchanged.

i18n: balance.latentGain.* in FR + EN. Focused unit tests for
getAccountLatentGainByLine and rollupLatentGain (grouping, 'none' envelope,
null-% / unknown book_cost, empty).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 13:49:57 -04:00
8 changed files with 729 additions and 7 deletions

View file

@ -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<number, AccountUnrealizedGain>;
/** 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<string, string>;
}
/**
@ -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<number | null>(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<Set<number>>(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 (
<span className="text-[var(--muted-foreground)]">
{t("balance.latentGain.na")}
</span>
);
}
const positive = gain >= 0;
return (
<span
className={`inline-flex items-center justify-end gap-1 ${
positive ? "text-[var(--positive)]" : "text-[var(--negative)]"
}`}
>
<span>
{positive ? "+" : ""}
{fmt.format(gain)}
</span>
{gainPct !== null && (
<span className="text-xs text-[var(--muted-foreground)]">
({positive ? "+" : ""}
{(gainPct * 100).toFixed(2)}%)
</span>
)}
{partial && (
<AlertTriangle
size={12}
className="text-amber-500"
aria-label={t("balance.latentGain.partial")}
/>
)}
</span>
);
}
// 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 (
<div className="space-y-2">
<div className="flex justify-end">
@ -317,6 +400,14 @@ export default function BalanceAccountsTable({
<th className="text-right px-4 py-3 font-medium">
{t("balance.overview.periodDelta")}
</th>
{hasLatentGain && (
<th
className="text-right px-4 py-3 font-medium"
title={t("balance.latentGain.tooltip")}
>
{t("balance.latentGain.column")}
</th>
)}
{showReturns && (
<>
<th className="text-right px-4 py-3 font-medium" title={t("balance.accountsTable.return3mTooltip")}>
@ -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 (
<Fragment key={acc.account_id}>
<tr
key={acc.account_id}
className="border-t border-[var(--border)] hover:bg-[var(--muted)]/10"
>
<td className="px-4 py-3 font-medium">
<span className="inline-flex items-center gap-1.5">
{drillable ? (
<button
type="button"
onClick={() => toggleExpanded(acc.account_id)}
aria-expanded={isExpanded}
aria-label={t(
isExpanded
? "balance.latentGain.drilldown.collapse"
: "balance.latentGain.drilldown.expand"
)}
className="p-0.5 -ml-1 rounded hover:bg-[var(--muted)]/40 text-[var(--muted-foreground)]"
>
{isExpanded ? (
<ChevronDown size={14} />
) : (
<ChevronRight size={14} />
)}
</button>
) : (
<span className="inline-block w-[14px]" aria-hidden />
)}
{acc.account_name}
</span>
{acc.symbol ? (
<span className="ml-2 text-xs text-[var(--muted-foreground)]">
({acc.symbol})
@ -383,6 +505,17 @@ export default function BalanceAccountsTable({
"—"
)}
</td>
{hasLatentGain && (
<td className="px-4 py-3 text-right tabular-nums">
{lg
? renderLatentGainCell(
lg.total_gain,
lg.total_gain_pct,
lg.has_unknown_book_cost
)
: "—"}
</td>
)}
{showReturns && (
<>
<td className="px-4 py-3 text-right tabular-nums">
@ -458,11 +591,147 @@ export default function BalanceAccountsTable({
)}
</td>
</tr>
{isExpanded && (
<tr className="bg-[var(--muted)]/5">
<td colSpan={colSpan} className="px-0 py-0">
<div className="px-4 py-2">
<table className="w-full text-xs">
<thead>
<tr className="text-[var(--muted-foreground)]">
<th className="text-left font-medium py-1 pl-6">
{t("balance.latentGain.drilldown.security")}
</th>
<th className="text-right font-medium py-1">
{t("balance.latentGain.drilldown.value")}
</th>
<th className="text-right font-medium py-1 pr-2">
{t("balance.latentGain.drilldown.gain")}
</th>
</tr>
</thead>
<tbody>
{lg!.holdings.map((h) => (
<tr key={h.security_id}>
<td className="text-left py-1 pl-6 font-medium">
{h.symbol}
</td>
<td className="text-right py-1 tabular-nums">
{fmt.format(h.value)}
</td>
<td className="text-right py-1 pr-2 tabular-nums">
{renderLatentGainCell(h.gain, h.gain_pct)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</td>
</tr>
)}
</Fragment>
);
})}
</tbody>
</table>
</div>
{latentGainRollup &&
(latentGainRollup.byClass.length > 0 ||
latentGainRollup.byVehicle.length > 0) && (
<LatentGainSummary
rollup={latentGainRollup}
accounts={accounts}
vehicleLabels={vehicleLabels}
fmt={fmt}
renderGain={renderLatentGainCell}
/>
)}
</div>
);
}
// -----------------------------------------------------------------------------
// 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<string, string>;
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<string, string> = {};
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
) => (
<div className="flex-1 min-w-[220px]">
<p className="text-xs font-semibold text-[var(--muted-foreground)] uppercase tracking-wide mb-1">
{title}
</p>
<table className="w-full text-sm">
<tbody>
{buckets.map((b) => (
<tr key={b.group_key} className="border-t border-[var(--border)]/60">
<td className="py-1.5 text-[var(--muted-foreground)]">
{labelFor(b.group_key)}
</td>
<td className="py-1.5 text-right tabular-nums">
{renderGain(b.total_gain, b.total_gain_pct, b.has_unknown_book_cost)}
</td>
</tr>
))}
</tbody>
</table>
</div>
);
return (
<div className="mt-3 bg-[var(--card)] border border-[var(--border)] rounded-xl p-4">
<h3 className="text-sm font-semibold mb-3">
{t("balance.latentGain.summary.title")}
</h3>
<div className="flex flex-col sm:flex-row gap-6">
{section(
t("balance.latentGain.summary.byClass"),
rollup.byClass,
(key) => classLabels[key] ?? key
)}
{section(
t("balance.latentGain.summary.byVehicle"),
rollup.byVehicle,
(key) => vehicleLabels[key] ?? key
)}
</div>
</div>
);
}

View file

@ -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 (
<div className="bg-[var(--card)] rounded-xl border border-[var(--border)] p-6">
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-4">
@ -75,6 +100,36 @@ export default function BalanceOverviewCard({ totals }: BalanceOverviewCardProps
date: formatDate(summary.latest.snapshot_date),
})}
</p>
{latent && (
<p className="text-sm mt-2 inline-flex items-center gap-1.5">
<span className="text-[var(--muted-foreground)]">
{t("balance.latentGain.totalLabel")}
</span>
<span
className={`font-medium ${
latent.total_gain >= 0
? "text-[var(--positive)]"
: "text-[var(--negative)]"
}`}
>
{latent.total_gain >= 0 ? "+" : ""}
{cadFormatter(latent.total_gain)}
{latent.total_gain_pct !== null && (
<span className="text-[var(--muted-foreground)] font-normal text-xs ml-1">
({latent.total_gain >= 0 ? "+" : ""}
{(latent.total_gain_pct * 100).toFixed(2)}%)
</span>
)}
</span>
{latent.has_unknown_book_cost && (
<AlertTriangle
size={12}
className="text-amber-500"
aria-label={t("balance.latentGain.partial")}
/>
)}
</p>
)}
</>
) : (
<p className="text-sm text-[var(--muted-foreground)] mt-2">

View file

@ -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<number, AccountUnrealizedGain>;
/** 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<number, AccountUnrealizedGain>;
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<number, AccountUnrealizedGain> = {};
for (const e of latentEntries) {
if (e) latentGainByAccount[e.account.account_id] = e.gain;
}
const latentGainRollup = rollupLatentGain(
latentEntries
.filter((e): e is NonNullable<typeof e> => 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) {

View file

@ -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"
},

View file

@ -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"
},

View file

@ -215,7 +215,10 @@ export default function BalancePage() {
return (
<div className="space-y-6">
<BalanceOverviewCard totals={state.evolutionTotals} />
<BalanceOverviewCard
totals={state.evolutionTotals}
latentGainRollup={state.latentGainRollup}
/>
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
{/* 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)}
/>

View file

@ -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>
): 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();
});
});

View file

@ -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<AccountUnrealizedGain> {
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<string, Acc>();
const vehicleMap = new Map<string, Acc>();
const grand: Acc = {
group_key: "",
total_value: 0,
total_book_cost: 0,
total_gain: 0,
has_unknown_book_cost: false,
};
const fold = (map: Map<string, Acc>, 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)
// -----------------------------------------------------------------------------