Compare commits
2 commits
c4edfb0a35
...
1a4cab2e9b
| Author | SHA1 | Date | |
|---|---|---|---|
| 1a4cab2e9b | |||
|
|
76ddad66c9 |
8 changed files with 729 additions and 7 deletions
|
|
@ -18,7 +18,8 @@
|
||||||
// that opens `LinkTransfersModal` (the modal handles its own state; this
|
// that opens `LinkTransfersModal` (the modal handles its own state; this
|
||||||
// component just bubbles up the request via `onLinkTransfers`).
|
// 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 { useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
Archive,
|
Archive,
|
||||||
|
|
@ -31,6 +32,8 @@ import {
|
||||||
import type {
|
import type {
|
||||||
AccountLatestSnapshot,
|
AccountLatestSnapshot,
|
||||||
AccountPeriodAnchor,
|
AccountPeriodAnchor,
|
||||||
|
AccountUnrealizedGain,
|
||||||
|
LatentGainRollup,
|
||||||
} from "../../services/balance.service";
|
} from "../../services/balance.service";
|
||||||
import { computeAccountReturn } from "../../services/balance.service";
|
import { computeAccountReturn } from "../../services/balance.service";
|
||||||
import {
|
import {
|
||||||
|
|
@ -89,6 +92,17 @@ interface BalanceAccountsTableProps {
|
||||||
* (avoids triggering computation against the unix epoch).
|
* (avoids triggering computation against the unix epoch).
|
||||||
*/
|
*/
|
||||||
sinceCreationDate?: string | null;
|
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,
|
onArchiveAccount,
|
||||||
onLinkTransfers,
|
onLinkTransfers,
|
||||||
sinceCreationDate,
|
sinceCreationDate,
|
||||||
|
latentGainByAccount = {},
|
||||||
|
latentGainRollup,
|
||||||
|
vehicleLabels = {},
|
||||||
}: BalanceAccountsTableProps) {
|
}: BalanceAccountsTableProps) {
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const fmt = cadFormatter(i18n.language === "fr" ? "fr-CA" : "en-CA");
|
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);
|
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
|
// Progressive disclosure of the 4 return columns (Issue #204). Collapsed by
|
||||||
// default; the persisted choice is read once on mount. We start `false` so
|
// default; the persisted choice is read once on mount. We start `false` so
|
||||||
// the columns never flash open before the preference resolves.
|
// 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 (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
|
|
@ -317,6 +400,14 @@ export default function BalanceAccountsTable({
|
||||||
<th className="text-right px-4 py-3 font-medium">
|
<th className="text-right px-4 py-3 font-medium">
|
||||||
{t("balance.overview.periodDelta")}
|
{t("balance.overview.periodDelta")}
|
||||||
</th>
|
</th>
|
||||||
|
{hasLatentGain && (
|
||||||
|
<th
|
||||||
|
className="text-right px-4 py-3 font-medium"
|
||||||
|
title={t("balance.latentGain.tooltip")}
|
||||||
|
>
|
||||||
|
{t("balance.latentGain.column")}
|
||||||
|
</th>
|
||||||
|
)}
|
||||||
{showReturns && (
|
{showReturns && (
|
||||||
<>
|
<>
|
||||||
<th className="text-right px-4 py-3 font-medium" title={t("balance.accountsTable.return3mTooltip")}>
|
<th className="text-right px-4 py-3 font-medium" title={t("balance.accountsTable.return3mTooltip")}>
|
||||||
|
|
@ -348,13 +439,44 @@ export default function BalanceAccountsTable({
|
||||||
100
|
100
|
||||||
: null;
|
: null;
|
||||||
const accReturns = returns[acc.account_id] ?? {};
|
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 (
|
return (
|
||||||
|
<Fragment key={acc.account_id}>
|
||||||
<tr
|
<tr
|
||||||
key={acc.account_id}
|
|
||||||
className="border-t border-[var(--border)] hover:bg-[var(--muted)]/10"
|
className="border-t border-[var(--border)] hover:bg-[var(--muted)]/10"
|
||||||
>
|
>
|
||||||
<td className="px-4 py-3 font-medium">
|
<td className="px-4 py-3 font-medium">
|
||||||
{acc.account_name}
|
<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 ? (
|
{acc.symbol ? (
|
||||||
<span className="ml-2 text-xs text-[var(--muted-foreground)]">
|
<span className="ml-2 text-xs text-[var(--muted-foreground)]">
|
||||||
({acc.symbol})
|
({acc.symbol})
|
||||||
|
|
@ -383,6 +505,17 @@ export default function BalanceAccountsTable({
|
||||||
"—"
|
"—"
|
||||||
)}
|
)}
|
||||||
</td>
|
</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 && (
|
{showReturns && (
|
||||||
<>
|
<>
|
||||||
<td className="px-4 py-3 text-right tabular-nums">
|
<td className="px-4 py-3 text-right tabular-nums">
|
||||||
|
|
@ -458,11 +591,147 @@ export default function BalanceAccountsTable({
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</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>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,10 @@ import { useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Plus, TrendingUp, TrendingDown, AlertTriangle } from "lucide-react";
|
import { Plus, TrendingUp, TrendingDown, AlertTriangle } from "lucide-react";
|
||||||
import { Link } from "react-router-dom";
|
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 STALENESS_DAYS = 60;
|
||||||
const cadFormatter = (value: number) =>
|
const cadFormatter = (value: number) =>
|
||||||
|
|
@ -25,9 +28,18 @@ const cadFormatter = (value: number) =>
|
||||||
interface BalanceOverviewCardProps {
|
interface BalanceOverviewCardProps {
|
||||||
/** The full evolution series for the active period (latest at the end). */
|
/** The full evolution series for the active period (latest at the end). */
|
||||||
totals: SnapshotTotalPoint[];
|
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 { t, i18n } = useTranslation();
|
||||||
|
|
||||||
const summary = useMemo(() => {
|
const summary = useMemo(() => {
|
||||||
|
|
@ -58,6 +70,19 @@ export default function BalanceOverviewCard({ totals }: BalanceOverviewCardProps
|
||||||
day: "numeric",
|
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 (
|
return (
|
||||||
<div className="bg-[var(--card)] rounded-xl border border-[var(--border)] p-6">
|
<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">
|
<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),
|
date: formatDate(summary.latest.snapshot_date),
|
||||||
})}
|
})}
|
||||||
</p>
|
</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">
|
<p className="text-sm text-[var(--muted-foreground)] mt-2">
|
||||||
|
|
|
||||||
|
|
@ -21,11 +21,15 @@ import {
|
||||||
getSnapshotTotalsByVehicleAndDate,
|
getSnapshotTotalsByVehicleAndDate,
|
||||||
getAccountsLatestSnapshot,
|
getAccountsLatestSnapshot,
|
||||||
getAccountsPeriodAnchor,
|
getAccountsPeriodAnchor,
|
||||||
|
getAccountLatentGainByLine,
|
||||||
|
rollupLatentGain,
|
||||||
type SnapshotTotalPoint,
|
type SnapshotTotalPoint,
|
||||||
type SnapshotCategoryBreakdownPoint,
|
type SnapshotCategoryBreakdownPoint,
|
||||||
type SnapshotVehicleBreakdownPoint,
|
type SnapshotVehicleBreakdownPoint,
|
||||||
type AccountLatestSnapshot,
|
type AccountLatestSnapshot,
|
||||||
type AccountPeriodAnchor,
|
type AccountPeriodAnchor,
|
||||||
|
type AccountUnrealizedGain,
|
||||||
|
type LatentGainRollup,
|
||||||
type SnapshotDateRange,
|
type SnapshotDateRange,
|
||||||
} from "../services/balance.service";
|
} from "../services/balance.service";
|
||||||
|
|
||||||
|
|
@ -44,10 +48,31 @@ interface State {
|
||||||
evolutionByVehicle: SnapshotVehicleBreakdownPoint[];
|
evolutionByVehicle: SnapshotVehicleBreakdownPoint[];
|
||||||
accountsLatest: AccountLatestSnapshot[];
|
accountsLatest: AccountLatestSnapshot[];
|
||||||
accountsPeriodAnchor: AccountPeriodAnchor[];
|
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;
|
isLoading: boolean;
|
||||||
error: string | null;
|
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 Action =
|
||||||
| { type: "SET_PERIOD"; payload: BalancePeriod }
|
| { type: "SET_PERIOD"; payload: BalancePeriod }
|
||||||
| { type: "SET_CHART_MODE"; payload: BalanceChartMode }
|
| { type: "SET_CHART_MODE"; payload: BalanceChartMode }
|
||||||
|
|
@ -61,6 +86,8 @@ type Action =
|
||||||
evolutionByVehicle: SnapshotVehicleBreakdownPoint[];
|
evolutionByVehicle: SnapshotVehicleBreakdownPoint[];
|
||||||
accountsLatest: AccountLatestSnapshot[];
|
accountsLatest: AccountLatestSnapshot[];
|
||||||
accountsPeriodAnchor: AccountPeriodAnchor[];
|
accountsPeriodAnchor: AccountPeriodAnchor[];
|
||||||
|
latentGainByAccount: Record<number, AccountUnrealizedGain>;
|
||||||
|
latentGainRollup: LatentGainRollup;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
| { type: "LOAD_ERROR"; payload: string };
|
| { type: "LOAD_ERROR"; payload: string };
|
||||||
|
|
@ -75,6 +102,8 @@ function initialState(): State {
|
||||||
evolutionByVehicle: [],
|
evolutionByVehicle: [],
|
||||||
accountsLatest: [],
|
accountsLatest: [],
|
||||||
accountsPeriodAnchor: [],
|
accountsPeriodAnchor: [],
|
||||||
|
latentGainByAccount: {},
|
||||||
|
latentGainRollup: EMPTY_ROLLUP,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
error: null,
|
error: null,
|
||||||
};
|
};
|
||||||
|
|
@ -154,6 +183,41 @@ export function useBalanceOverview(): UseBalanceOverviewResult {
|
||||||
getAccountsLatestSnapshot(),
|
getAccountsLatestSnapshot(),
|
||||||
getAccountsPeriodAnchor(range),
|
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({
|
dispatch({
|
||||||
type: "LOAD_SUCCESS",
|
type: "LOAD_SUCCESS",
|
||||||
payload: {
|
payload: {
|
||||||
|
|
@ -162,6 +226,8 @@ export function useBalanceOverview(): UseBalanceOverviewResult {
|
||||||
evolutionByVehicle: byVehicle,
|
evolutionByVehicle: byVehicle,
|
||||||
accountsLatest: latest,
|
accountsLatest: latest,
|
||||||
accountsPeriodAnchor: anchors,
|
accountsPeriodAnchor: anchors,
|
||||||
|
latentGainByAccount,
|
||||||
|
latentGainRollup,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
|
||||||
|
|
@ -1839,6 +1839,25 @@
|
||||||
"hide": "Hide returns"
|
"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": {
|
"vehicle": {
|
||||||
"none": "No envelope"
|
"none": "No envelope"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1839,6 +1839,25 @@
|
||||||
"hide": "Masquer les rendements"
|
"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": {
|
"vehicle": {
|
||||||
"none": "Sans enveloppe"
|
"none": "Sans enveloppe"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -215,7 +215,10 @@ export default function BalancePage() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<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">
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
||||||
{/* Period selector */}
|
{/* Period selector */}
|
||||||
|
|
@ -316,6 +319,9 @@ export default function BalancePage() {
|
||||||
accounts={state.accountsLatest}
|
accounts={state.accountsLatest}
|
||||||
periodAnchor={state.accountsPeriodAnchor}
|
periodAnchor={state.accountsPeriodAnchor}
|
||||||
sinceCreationDate={earliestSnapshotDate}
|
sinceCreationDate={earliestSnapshotDate}
|
||||||
|
latentGainByAccount={state.latentGainByAccount}
|
||||||
|
latentGainRollup={state.latentGainRollup}
|
||||||
|
vehicleLabels={vehicleLabels}
|
||||||
onArchiveAccount={(acc) => handleArchiveAccount(acc.account_id)}
|
onArchiveAccount={(acc) => handleArchiveAccount(acc.account_id)}
|
||||||
onLinkTransfers={(acc) => setLinkTarget(acc)}
|
onLinkTransfers={(acc) => setLinkTarget(acc)}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,9 @@ import {
|
||||||
listHoldingsBySnapshotLine,
|
listHoldingsBySnapshotLine,
|
||||||
getHoldingsForLatestSnapshot,
|
getHoldingsForLatestSnapshot,
|
||||||
computeUnrealizedGain,
|
computeUnrealizedGain,
|
||||||
|
getAccountLatentGainByLine,
|
||||||
|
rollupLatentGain,
|
||||||
|
type AccountUnrealizedGain,
|
||||||
PRICED_VALUE_TOLERANCE,
|
PRICED_VALUE_TOLERANCE,
|
||||||
BalanceServiceError,
|
BalanceServiceError,
|
||||||
getSnapshotTotalsByDate,
|
getSnapshotTotalsByDate,
|
||||||
|
|
@ -1703,6 +1706,9 @@ describe("getAccountsLatestSnapshot", () => {
|
||||||
// LEFT JOIN-equivalent: scalar subquery so accounts with no lines still surface.
|
// LEFT JOIN-equivalent: scalar subquery so accounts with no lines still surface.
|
||||||
expect(sql).toContain("ORDER BY s.snapshot_date DESC");
|
expect(sql).toContain("ORDER BY s.snapshot_date DESC");
|
||||||
expect(sql).toContain("LIMIT 1");
|
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([]);
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -1874,6 +1874,13 @@ export interface AccountLatestSnapshot {
|
||||||
category_kind: BalanceCategoryKind;
|
category_kind: BalanceCategoryKind;
|
||||||
/** Mirror of `balance_categories.custom_label` — drives renderCategoryLabel. */
|
/** Mirror of `balance_categories.custom_label` — drives renderCategoryLabel. */
|
||||||
category_custom_label?: string | null;
|
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.
|
* Fiscal envelope of the account (`vehicle_type`), or NULL when none.
|
||||||
* Surfaced for the "par enveloppe" axis groupings (Issue #204).
|
* Surfaced for the "par enveloppe" axis groupings (Issue #204).
|
||||||
|
|
@ -1883,6 +1890,13 @@ export interface AccountLatestSnapshot {
|
||||||
latest_snapshot_date: string | null;
|
latest_snapshot_date: string | null;
|
||||||
/** Value at that snapshot, or null if the account has no snapshot lines. */
|
/** Value at that snapshot, or null if the account has no snapshot lines. */
|
||||||
latest_value: number | null;
|
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.i18n_key AS category_i18n_key,
|
||||||
c.kind AS category_kind,
|
c.kind AS category_kind,
|
||||||
c.custom_label AS category_custom_label,
|
c.custom_label AS category_custom_label,
|
||||||
|
a.kind AS kind,
|
||||||
a.vehicle_type AS vehicle_type,
|
a.vehicle_type AS vehicle_type,
|
||||||
(SELECT s.snapshot_date
|
(SELECT s.snapshot_date
|
||||||
FROM balance_snapshot_lines l
|
FROM balance_snapshot_lines l
|
||||||
|
|
@ -1920,7 +1935,13 @@ export async function getAccountsLatestSnapshot(): Promise<
|
||||||
JOIN balance_snapshots s ON s.id = l.snapshot_id
|
JOIN balance_snapshots s ON s.id = l.snapshot_id
|
||||||
WHERE l.account_id = a.id
|
WHERE l.account_id = a.id
|
||||||
ORDER BY s.snapshot_date DESC
|
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
|
FROM balance_accounts a
|
||||||
INNER JOIN balance_categories c ON c.id = a.balance_category_id
|
INNER JOIN balance_categories c ON c.id = a.balance_category_id
|
||||||
WHERE a.is_active = 1 AND a.archived_at IS NULL
|
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)
|
// Returns + transfers (Issue #142 / Bilan #4)
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue