import { useParams } from "react-router"; import type { BrokerageOperation } from "@/entities/broker-operation"; import type { Portfolio } from "@/entities/portfolio"; import { useBrokerageOperations } from "@/entities/broker-operation"; import { type BrokerageAccount, useBrokerageAccounts, } from "@/entities/brokerage-account"; import { type BrokerageEventsPreviewState, useBrokerageEvents, } from "@/entities/brokerage-event"; import { type IncomeSummary, useIncomeSummary } from "@/entities/income"; import { formatShare, usePortfolio } from "@/entities/portfolio"; import { useStocks } from "@/entities/stock"; import { useScreenSync } from "@/features/tbank-connection"; import { apiErrorCode } from "@/shared/api"; import { formatMoney, instrumentLogoUrl } from "@/shared/lib"; const RECENT_EVENTS_LIMIT = 5; type AccountPageContent = { account?: BrokerageAccount; accountId?: string; incomeSummary: AccountPageIncomeSummary; partial?: boolean; recentEvents: AccountPageRecentEvents; recentOperations: AccountPageRecentOperations; refreshError: boolean; refreshing: boolean; retry: () => Promise | undefined; sourceError?: boolean; }; type AccountPageEmptyViewModel = AccountPageContent & { state: "empty"; }; type AccountPageIncomeSummary = | { lastSuccessfulAt?: null | string; refreshError?: boolean; stale?: boolean; state: "loaded"; summary: IncomeSummary; to: string; } | { retry: () => Promise | undefined; state: "error"; to: string } | { state: "empty" | "loading" | "partial"; to: string; }; type AccountPageLoadedViewModel = AccountPageContent & { portfolio: Portfolio; positionSections: AccountPagePositionSection[]; state: "loaded"; }; type AccountPagePosition = Portfolio["positions"][number] & { instrumentTo?: string; logoSrc: null | string; returnText: null | string; returnTone?: AccountPageReturnTone; shareText: null | string; }; type AccountPagePositionGroup = { key: string; positions: AccountPagePosition[]; }; type AccountPagePositionSection = { groups: AccountPagePositionGroup[]; kind: PositionSectionKind; }; type AccountPageRecentEvents = BrokerageEventsPreviewState; type AccountPageRecentOperations = | { items: BrokerageOperation[]; lastSuccessfulAt?: null | string; refreshError?: boolean; stale?: boolean; state: "loaded" | "partial"; to: string; } | { state: "empty" | "error" | "loading"; to: string; }; type AccountPageReturnTone = "negative" | "positive"; type AccountPageViewModel = | AccountPageEmptyViewModel | AccountPageLoadedViewModel | { code?: string; retry: () => Promise | undefined; state: "error" } | { state: "loading" }; type PositionSectionKind = "bonds" | "other" | "stocks"; function groupPositions( positions: AccountPagePosition[], ): AccountPagePositionGroup[] { const groups = new Map(); for (const [index, position] of positions.entries()) { const identity = positionIdentity(position); const rowKey = JSON.stringify(["row", position.account?.id, index]); const key = identity && position.account ? JSON.stringify([position.type, ...identity]) : rowKey; const group = groups.get(key); if ( group && group.positions.every( item => item.account?.id !== position.account?.id, ) ) { group.positions.push(position); } else { const uniqueKey = group ? rowKey : key; groups.set(uniqueKey, { key: uniqueKey, positions: [position] }); } } // eslint-disable-next-line unicorn/prefer-iterator-to-array -- Production targets ES2023; Iterator helpers require a newer browser baseline. return [...groups.values()]; } function positionIdentity(position: AccountPagePosition) { if (position.instrumentUid?.trim()) return ["uid", position.instrumentUid.trim()]; if (position.figi?.trim()) return ["figi", position.figi.trim()]; return position.isin?.trim() ? ["isin", position.isin.trim()] : undefined; } function toAccountPagePositionSections( portfolio: Portfolio, instrumentDetails: Map = new Map(), ): AccountPagePositionSection[] { const denominators = new Map(); for (const position of portfolio.positions) { if (!position.currentValue) continue; const currency = position.currentValue.currency; denominators.set( currency, (denominators.get(currency) ?? 0) + Number(position.currentValue.value), ); } const stocks = toSection( "stocks", portfolio, denominators, instrumentDetails, ); const bonds = toSection("bonds", portfolio, denominators, instrumentDetails); const other = toSection("other", portfolio, denominators, instrumentDetails); return [stocks, bonds, other].filter(section => section.groups.length > 0); } function toIncomeSummary( accountId: string | undefined, income: ReturnType, ): AccountPageIncomeSummary { const to = `/accounts/${accountId}/income`; if (!income.data && !income.isError && income.meta?.sync.state !== "failed") return { state: "loading", to }; if (income.meta?.availability === "partial") return { state: "partial", to }; if (!income.data && (income.isError || income.meta?.sync.state === "failed")) return { retry: () => income.refetch(), state: "error", to }; if (!income.data?.totalsByCurrency.length) return { state: "empty", to }; return { lastSuccessfulAt: income.meta?.lastSuccessfulAt, refreshError: income.isError || income.meta?.sync.state === "failed", stale: income.meta?.freshness === "stale", state: "loaded", summary: income.data, to, }; } function toPosition( position: Portfolio["positions"][number], denominators: Map, kind: PositionSectionKind, instrumentSlug?: string, ): AccountPagePosition { const isExpanded = kind !== "other"; const returnValue = position.absoluteReturn ? Number(position.absoluteReturn.value) : null; // eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. let returnTone: AccountPageReturnTone | undefined; if (returnValue !== null && returnValue !== 0) returnTone = returnValue > 0 ? "positive" : "negative"; const returnPrefix = isExpanded && returnValue !== null && returnValue > 0 ? "+" : ""; return { ...position, instrumentTo: (kind === "stocks" || kind === "bonds") && position.instrumentUid ? `/${kind}/${encodeURIComponent(instrumentSlug ?? position.instrumentUid)}` : undefined, logoSrc: isExpanded ? instrumentLogoUrl(position.logoName) : null, // eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. returnText: position.absoluteReturn ? `${returnPrefix}${formatMoney(position.absoluteReturn)}` : null, // eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. returnTone, shareText: position.currentValue?.currency ? formatShare(position.currentValue, { currency: position.currentValue.currency, value: String(denominators.get(position.currentValue.currency) ?? 0), }) : null, // eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. }; } function toRecentEvents( accountId: string | undefined, events: ReturnType, ): AccountPageRecentEvents { const to = `/accounts/${accountId}/events`; if (!events.data && !events.isError && events.meta?.sync.state !== "failed") return { state: "loading", to }; if (events.meta?.availability === "partial") return { items: events.data?.events.slice(0, RECENT_EVENTS_LIMIT) ?? [], state: "partial", to, }; if (!events.data && (events.isError || events.meta?.sync.state === "failed")) return { state: "error", to }; const items = events.data?.events.slice(0, RECENT_EVENTS_LIMIT) ?? []; if (items.length === 0) return { state: "empty", to }; return { items, lastSuccessfulAt: events.meta?.lastSuccessfulAt, refreshError: events.isError || events.meta?.sync.state === "failed", stale: events.meta?.freshness === "stale", state: "loaded", to, }; } function toRecentOperations( accountId: string | undefined, operations: ReturnType, ): AccountPageRecentOperations { const to = `/accounts/${accountId}/operations`; if ( !operations.data && !operations.isError && operations.meta?.sync.state !== "failed" ) return { state: "loading", to }; if (operations.meta?.availability === "partial") return { items: operations.data?.items ?? [], state: "partial", to }; if ( !operations.data && (operations.isError || operations.meta?.sync.state === "failed") ) return { state: "error", to }; if (!operations.data?.items.length) return { state: "empty", to }; return { items: operations.data.items, lastSuccessfulAt: operations.meta?.lastSuccessfulAt, refreshError: operations.isError || operations.meta?.sync.state === "failed", stale: operations.meta?.freshness === "stale", state: "loaded", to, }; } function toSection( kind: PositionSectionKind, portfolio: Portfolio, denominators: Map, instrumentDetails: Map, ): AccountPagePositionSection { return { groups: groupPositions( portfolio.positions .filter((position) => { if (kind === "stocks") return position.type === "STOCK"; return kind === "bonds" ? position.type === "BOND" : position.type !== "STOCK" && position.type !== "BOND" && position.type !== "CURRENCY"; }) .map(position => toPosition( position, denominators, kind, instrumentDetails.get(position.instrumentUid ?? "")?.slug, ), ), ), kind, }; } function useAccountPageViewModel(scopeId?: string): AccountPageViewModel { const { accountId: routeAccountId } = useParams(); const accountId = scopeId ?? routeAccountId; const portfolio = usePortfolio(accountId); const stocks = useStocks( portfolio.data?.positions.flatMap(position => position.type === "STOCK" && position.instrumentUid ? [position.instrumentUid] : [], ) ?? [], ); const instrumentDetails = stocks; const operations = useBrokerageOperations(accountId, { limit: 5, }); const events = useBrokerageEvents(accountId); const income = useIncomeSummary(accountId); const accounts = useBrokerageAccounts(); const sync = useScreenSync({ accountId, screen: "account" }, [ accounts, portfolio, operations, events, income, ]); if (!portfolio.data) { if (portfolio.isError || portfolio.meta?.sync.state === "failed") return { code: apiErrorCode(portfolio.error), retry: sync.retry, state: "error" as const, }; return { state: "loading" as const }; } const account = accounts.data?.find(item => item.id === accountId); const recentOperations = toRecentOperations(accountId, operations); const recentEvents = toRecentEvents(accountId, events); const incomeSummary = toIncomeSummary(accountId, income); const refresh = { partial: portfolio.meta?.availability === "partial", refreshError: portfolio.isError || sync.transportError || sync.sourceError, refreshing: portfolio.isFetching || sync.refreshing, retry: sync.retry, sourceError: sync.sourceError, }; if ( portfolio.meta?.availability === "complete" && portfolio.data.positions.length === 0 && portfolio.data.cashBalances.length === 0 ) return { account, accountId, incomeSummary, recentEvents, recentOperations, state: "empty" as const, ...refresh, }; return { account, accountId, incomeSummary, portfolio: portfolio.data, positionSections: toAccountPagePositionSections( portfolio.data, instrumentDetails, ), recentEvents, recentOperations, state: "loaded" as const, ...refresh, }; } export { type AccountPageIncomeSummary, type AccountPagePosition, type AccountPagePositionGroup, type AccountPagePositionSection, type AccountPageRecentEvents, type AccountPageRecentOperations, toAccountPagePositionSections, useAccountPageViewModel, };