Compare commits
No commits in common. "1a4cab2e9b82cca6c5143adf2aa497df567ab707" and "c4edfb0a35b7b772486a7a3bb74c650127e06e4a" have entirely different histories.
1a4cab2e9b
...
c4edfb0a35
8 changed files with 7 additions and 729 deletions
|
|
@ -18,8 +18,7 @@
|
||||||
// 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 { Fragment, useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import type { ReactNode } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
Archive,
|
Archive,
|
||||||
|
|
@ -32,8 +31,6 @@ 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 {
|
||||||
|
|
@ -92,17 +89,6 @@ 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>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -117,9 +103,6 @@ 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");
|
||||||
|
|
@ -133,20 +116,6 @@ 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.
|
||||||
|
|
@ -311,58 +280,6 @@ 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">
|
||||||
|
|
@ -400,14 +317,6 @@ 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")}>
|
||||||
|
|
@ -439,44 +348,13 @@ 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">
|
||||||
<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}
|
{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})
|
||||||
|
|
@ -505,17 +383,6 @@ 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">
|
||||||
|
|
@ -591,147 +458,11 @@ 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,10 +12,7 @@ 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 {
|
import type { SnapshotTotalPoint } from "../../services/balance.service";
|
||||||
LatentGainRollup,
|
|
||||||
SnapshotTotalPoint,
|
|
||||||
} from "../../services/balance.service";
|
|
||||||
|
|
||||||
const STALENESS_DAYS = 60;
|
const STALENESS_DAYS = 60;
|
||||||
const cadFormatter = (value: number) =>
|
const cadFormatter = (value: number) =>
|
||||||
|
|
@ -28,18 +25,9 @@ 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({
|
export default function BalanceOverviewCard({ totals }: BalanceOverviewCardProps) {
|
||||||
totals,
|
|
||||||
latentGainRollup,
|
|
||||||
}: BalanceOverviewCardProps) {
|
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
|
|
||||||
const summary = useMemo(() => {
|
const summary = useMemo(() => {
|
||||||
|
|
@ -70,19 +58,6 @@ export default function BalanceOverviewCard({
|
||||||
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">
|
||||||
|
|
@ -100,36 +75,6 @@ export default function BalanceOverviewCard({
|
||||||
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,15 +21,11 @@ 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";
|
||||||
|
|
||||||
|
|
@ -48,31 +44,10 @@ 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 }
|
||||||
|
|
@ -86,8 +61,6 @@ 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 };
|
||||||
|
|
@ -102,8 +75,6 @@ function initialState(): State {
|
||||||
evolutionByVehicle: [],
|
evolutionByVehicle: [],
|
||||||
accountsLatest: [],
|
accountsLatest: [],
|
||||||
accountsPeriodAnchor: [],
|
accountsPeriodAnchor: [],
|
||||||
latentGainByAccount: {},
|
|
||||||
latentGainRollup: EMPTY_ROLLUP,
|
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
error: null,
|
error: null,
|
||||||
};
|
};
|
||||||
|
|
@ -183,41 +154,6 @@ 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: {
|
||||||
|
|
@ -226,8 +162,6 @@ export function useBalanceOverview(): UseBalanceOverviewResult {
|
||||||
evolutionByVehicle: byVehicle,
|
evolutionByVehicle: byVehicle,
|
||||||
accountsLatest: latest,
|
accountsLatest: latest,
|
||||||
accountsPeriodAnchor: anchors,
|
accountsPeriodAnchor: anchors,
|
||||||
latentGainByAccount,
|
|
||||||
latentGainRollup,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
|
||||||
|
|
@ -1839,25 +1839,6 @@
|
||||||
"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,25 +1839,6 @@
|
||||||
"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,10 +215,7 @@ export default function BalancePage() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<BalanceOverviewCard
|
<BalanceOverviewCard totals={state.evolutionTotals} />
|
||||||
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 */}
|
||||||
|
|
@ -319,9 +316,6 @@ 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,9 +47,6 @@ import {
|
||||||
listHoldingsBySnapshotLine,
|
listHoldingsBySnapshotLine,
|
||||||
getHoldingsForLatestSnapshot,
|
getHoldingsForLatestSnapshot,
|
||||||
computeUnrealizedGain,
|
computeUnrealizedGain,
|
||||||
getAccountLatentGainByLine,
|
|
||||||
rollupLatentGain,
|
|
||||||
type AccountUnrealizedGain,
|
|
||||||
PRICED_VALUE_TOLERANCE,
|
PRICED_VALUE_TOLERANCE,
|
||||||
BalanceServiceError,
|
BalanceServiceError,
|
||||||
getSnapshotTotalsByDate,
|
getSnapshotTotalsByDate,
|
||||||
|
|
@ -1706,9 +1703,6 @@ 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");
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -2797,120 +2791,3 @@ 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,13 +1874,6 @@ 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).
|
||||||
|
|
@ -1890,13 +1883,6 @@ 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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -1922,7 +1908,6 @@ 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
|
||||||
|
|
@ -1935,13 +1920,7 @@ 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
|
||||||
|
|
@ -2176,150 +2155,6 @@ 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