diff --git a/src/pages/account/ui/AccountPage.test.tsx b/src/pages/account/ui/AccountPage.test.tsx
index 9f053b5..647683e 100644
--- a/src/pages/account/ui/AccountPage.test.tsx
+++ b/src/pages/account/ui/AccountPage.test.tsx
@@ -1,6 +1,12 @@
import { MantineProvider } from "@mantine/core";
import { type QueryClient, QueryClientProvider } from "@tanstack/react-query";
-import { fireEvent, render, screen, waitFor } from "@testing-library/react";
+import {
+ fireEvent,
+ render,
+ screen,
+ waitFor,
+ within,
+} from "@testing-library/react";
import { HttpResponse, http } from "msw";
import { MemoryRouter, Route, Routes } from "react-router";
import { describe, expect, it, vi } from "vitest";
@@ -145,11 +151,39 @@ describe("AccountPage states", () => {
},
);
- it("shows only supplied totals despite cash and positions with unknown value", () => {
- renderPage();
+ it("shows supplied cash separately without adding currency instruments or recalculating totals", () => {
+ renderPage(
+ ,
+ );
+ const balances = within(
+ screen.getByRole("region", { name: "Денежные остатки" }),
+ );
+ expect(
+ balances.getAllByRole("definition").map((node) => node.textContent),
+ ).toEqual(["234,500000000000000001 ₽", "-0,5 $", "3,00 XYZ"]);
expect(
screen.getAllByRole("definition").map((node) => node.textContent),
- ).toEqual(["1 234,5 ₽"]);
+ ).toEqual(["1 234,5 ₽", "234,500000000000000001 ₽", "-0,5 $", "3,00 XYZ"]);
+ expect(screen.queryByText("Торгуемая валюта")).not.toBeInTheDocument();
expect(
screen.queryByText(/полная оценка|общая стоимость/i),
).not.toBeInTheDocument();
@@ -169,11 +203,58 @@ describe("AccountPage states", () => {
);
renderAccountRoute(createTestQueryClient());
await screen.findByRole("heading", { name: "Портфель", level: 1 });
- expect(screen.queryByRole("definition")).not.toBeInTheDocument();
+ expect(screen.getByRole("definition")).toHaveTextContent("0,5 $");
expect(screen.queryByText("Портфель пока пуст")).not.toBeInTheDocument();
},
);
+ it.each([
+ { totalsByCurrency: [] },
+ { totalsByCurrency: [{ currency: "RUB", value: "100" }] },
+ ])(
+ "hides the empty other section without emptying a currency-only portfolio (totals: $totalsByCurrency)",
+ async ({ totalsByCurrency }) => {
+ const snapshot: Portfolio = {
+ ...portfolio,
+ totalsByCurrency,
+ positions: [
+ {
+ ...portfolio.positions[0],
+ displayName: "Торгуемая валюта",
+ type: "CURRENCY",
+ currentValue: { currency: "RUB", value: "100" },
+ },
+ ],
+ cashBalances: [],
+ };
+ server.use(
+ http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () =>
+ HttpResponse.json(snapshot),
+ ),
+ );
+ const queryClient = createTestQueryClient();
+ renderAccountRoute(queryClient);
+
+ await screen.findByRole("heading", { name: "Портфель", level: 1 });
+ expect(
+ screen.queryByRole("button", { name: "Другие позиции" }),
+ ).not.toBeInTheDocument();
+ expect(screen.queryByText("Торгуемая валюта")).not.toBeInTheDocument();
+ expect(screen.queryByText("Портфель пока пуст")).not.toBeInTheDocument();
+ expect(
+ within(
+ screen.getByRole("region", { name: "Денежные остатки" }),
+ ).queryByRole("definition"),
+ ).not.toBeInTheDocument();
+ expect(
+ screen.queryAllByRole("definition").map((node) => node.textContent),
+ ).toEqual(totalsByCurrency.length ? ["100 ₽"] : []);
+ expect(
+ queryClient.getQueryData(portfolioQueryOptions("account-1").queryKey),
+ ).toEqual(snapshot);
+ },
+ );
+
it("renders loading and empty states", () => {
renderPage();
expect(screen.getByRole("status")).toBeInTheDocument();
@@ -613,13 +694,17 @@ describe("AccountPage states", () => {
),
).toBeInTheDocument();
expect(screen.getByText("Вторая")).toBeInTheDocument();
- expect(screen.getByRole("definition")).toHaveTextContent("1 234,5 ₽");
+ expect(
+ screen.getAllByRole("definition").map((node) => node.textContent),
+ ).toEqual(["1 234,5 ₽", "0,5 $"]);
expect(screen.getByText(/Данные получены/).textContent).toBe(
snapshotText,
);
fireEvent.click(screen.getByRole("button", { name: "Повторить" }));
await waitFor(() => expect(requestCount).toBe(3));
- expect(screen.getByRole("definition")).toHaveTextContent("1 234,5 ₽");
+ expect(
+ screen.getAllByRole("definition").map((node) => node.textContent),
+ ).toEqual(["1 234,5 ₽", "0,5 $"]);
expect(screen.getByText(/Данные получены/).textContent).toBe(
snapshotText,
);
@@ -629,10 +714,13 @@ describe("AccountPage states", () => {
isStale: false,
lastSuccessfulAt: "2026-08-14T11:30:00Z",
totalsByCurrency: [{ currency: "USD", value: "-0.5" }],
+ cashBalances: [{ currency: "EUR", value: "2.50" }],
}),
);
await waitFor(() =>
- expect(screen.getByRole("definition")).toHaveTextContent("-0,5 $"),
+ expect(
+ screen.getAllByRole("definition").map((node) => node.textContent),
+ ).toEqual(["-0,5 $", "2,50 €"]),
);
expect(screen.getByText(/Данные получены/)).toHaveTextContent("11:30");
expect(
@@ -666,7 +754,9 @@ describe("AccountPage states", () => {
await waitFor(() => expect(requestCount).toBe(2));
expect(screen.getByText("Вторая")).toBeInTheDocument();
- expect(screen.getByRole("definition")).toHaveTextContent("1 234,5 ₽");
+ expect(
+ screen.getAllByRole("definition").map((node) => node.textContent),
+ ).toEqual(["1 234,5 ₽", "0,5 $"]);
expect(screen.getByText(/Данные получены/)).toHaveTextContent("10:00");
expect(
screen.queryByRole("status", { name: "Загрузка" }),
diff --git a/src/pages/account/ui/AccountPage.vm.test.tsx b/src/pages/account/ui/AccountPage.vm.test.tsx
index 0949a85..8a9acf3 100644
--- a/src/pages/account/ui/AccountPage.vm.test.tsx
+++ b/src/pages/account/ui/AccountPage.vm.test.tsx
@@ -8,7 +8,10 @@ import type { Portfolio } from "@/entities/portfolio";
import { configureAuthClient } from "@/shared/api";
import { createQueryWrapper } from "@/test/query";
import { server } from "@/test/server";
-import { useAccountPageViewModel } from "./AccountPage.vm";
+import {
+ toAccountPagePositionSections,
+ useAccountPageViewModel,
+} from "./AccountPage.vm";
const portfolio: Portfolio = {
lastSuccessfulAt: "2026-08-13T10:00:00Z",
@@ -130,6 +133,55 @@ function mockLoadedAccountPage() {
}
describe("useAccountPageViewModel", () => {
+ it("excludes currency instruments only from display, preserving order and share denominators", () => {
+ const otherTypes = [
+ "OPTION",
+ "OTHER",
+ "FUTURE",
+ "STRUCTURED_NOTE",
+ "DIGITAL_FINANCIAL_ASSET",
+ "FUND",
+ ] as const;
+ const snapshot: Portfolio = {
+ ...portfolio,
+ totalsByCurrency: [{ currency: "RUB", value: "1250" }],
+ cashBalances: [{ currency: "RUB", value: "1000" }],
+ positions: [
+ ...portfolio.positions,
+ {
+ ...portfolio.positions[0],
+ figi: "FIGI-CURRENCY",
+ type: "CURRENCY",
+ currentValue: { currency: "RUB", value: "50" },
+ },
+ ...otherTypes.map((type) => ({
+ ...portfolio.positions[2],
+ figi: `FIGI-${type}-NEXT`,
+ type,
+ currentValue:
+ type === "OPTION" ? { currency: "RUB", value: "50" } : null,
+ })),
+ ],
+ };
+ const original = structuredClone(snapshot);
+
+ const sections = toAccountPagePositionSections(snapshot);
+
+ expect(sections.map((section) => section.kind)).toEqual([
+ "stocks",
+ "bonds",
+ "other",
+ ]);
+ expect(sections[2].positions.map((position) => position.figi)).toEqual([
+ "FIGI-FUND",
+ ...otherTypes.map((type) => `FIGI-${type}-NEXT`),
+ ]);
+ expect(sections[0].positions[0].shareText).toBe("40 %");
+ expect(sections[1].positions[0].shareText).toBe("20 %");
+ expect(sections[2].positions[1].shareText).toBe("20 %");
+ expect(snapshot).toEqual(original);
+ });
+
it("prepares grouped position sections with derived display data", async () => {
configureTestAuth();
mockLoadedAccountPage();
diff --git a/src/pages/account/ui/AccountPage.vm.ts b/src/pages/account/ui/AccountPage.vm.ts
index 95d3fa1..6fc389d 100644
--- a/src/pages/account/ui/AccountPage.vm.ts
+++ b/src/pages/account/ui/AccountPage.vm.ts
@@ -202,7 +202,11 @@ function toSection(
.filter((position) => {
if (kind === "stocks") return position.type === "STOCK";
if (kind === "bonds") return position.type === "BOND";
- return position.type !== "STOCK" && position.type !== "BOND";
+ return (
+ position.type !== "STOCK" &&
+ position.type !== "BOND" &&
+ position.type !== "CURRENCY"
+ );
})
.map((position) => toPosition(position, denominators, kind)),
};
diff --git a/src/pages/account/ui/components/AccountPageLoaded.view.tsx b/src/pages/account/ui/components/AccountPageLoaded.view.tsx
index 41d8e36..76eb8d2 100644
--- a/src/pages/account/ui/components/AccountPageLoaded.view.tsx
+++ b/src/pages/account/ui/components/AccountPageLoaded.view.tsx
@@ -12,10 +12,9 @@ import type {
AccountPageRecentOperations,
} from "../AccountPage.vm";
import { AccountHeading } from "./AccountHeading.view";
-import { CashBalances } from "./CashBalances.view";
+import { CurrencyCards } from "./CurrencyCards.view";
import { IncomeSummary } from "./IncomeSummary.view";
import { PortfolioRefreshState } from "./PortfolioRefreshState.view";
-import { PortfolioTotals } from "./PortfolioTotals.view";
import { PositionSection } from "./PositionSection.view";
import { RecentEvents } from "./RecentEvents.view";
import { RecentOperations } from "./RecentOperations.view";
@@ -50,7 +49,7 @@ const AccountPageLoaded: FC = (props) => {
date: formatSnapshotTime(props.portfolio.lastSuccessfulAt),
})}
-
+
= (props) => {
{t("portfolio.cashBalances")}
-
+
diff --git a/src/pages/account/ui/components/CashBalances.view.tsx b/src/pages/account/ui/components/CashBalances.view.tsx
deleted file mode 100644
index 7b675c8..0000000
--- a/src/pages/account/ui/components/CashBalances.view.tsx
+++ /dev/null
@@ -1,26 +0,0 @@
-import { Paper, Text } from "@mantine/core";
-import type { FC } from "react";
-import type { Portfolio } from "@/entities/portfolio";
-import { formatMoney } from "@/shared/lib";
-import classes from "../AccountPage.module.css";
-
-type CashBalancesProps = {
- balances: Portfolio["cashBalances"];
-};
-
-const CashBalances: FC = (props) => {
- const { balances } = props;
-
- return (
- <>
- {balances.map((balance) => (
-
- {balance.currency}
- {formatMoney(balance)}
-
- ))}
- >
- );
-};
-
-export { CashBalances };
diff --git a/src/pages/account/ui/components/PortfolioTotals.test.tsx b/src/pages/account/ui/components/CurrencyCards.test.tsx
similarity index 90%
rename from src/pages/account/ui/components/PortfolioTotals.test.tsx
rename to src/pages/account/ui/components/CurrencyCards.test.tsx
index f66ca1a..1366e63 100644
--- a/src/pages/account/ui/components/PortfolioTotals.test.tsx
+++ b/src/pages/account/ui/components/CurrencyCards.test.tsx
@@ -1,9 +1,9 @@
import { MantineProvider } from "@mantine/core";
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
-import { PortfolioTotals } from "./PortfolioTotals.view";
+import { CurrencyCards } from "./CurrencyCards.view";
-describe("PortfolioTotals currency presentation", () => {
+describe("CurrencyCards currency presentation", () => {
it.each([0, 1, 3, 5])(
"renders only the %i supplied totals in upstream order",
(count) => {
@@ -16,7 +16,7 @@ describe("PortfolioTotals currency presentation", () => {
].slice(0, count);
const { container } = render(
-
+
,
);
expect(
@@ -52,7 +52,7 @@ describe("PortfolioTotals currency presentation", () => {
(currency, value, expected, name, symbol) => {
render(
-
+
,
);
const term = screen.getByRole("term");
@@ -70,8 +70,8 @@ describe("PortfolioTotals currency presentation", () => {
it("keeps USD and CAD as distinct supplied values", () => {
render(
- = (props) => {
- const { totals } = props;
+const CurrencyCards: FC = (props) => {
+ const { amounts } = props;
const { t } = useTranslation();
const currencies: Record = {
RUB: { symbol: "₽", name: t("portfolio.currencies.RUB") },
@@ -20,22 +20,22 @@ const PortfolioTotals: FC = (props) => {
return (
- {totals.map((total) => {
- const amount = formatDecimal(total.value);
+ {amounts.map((money) => {
+ const amount = formatDecimal(money.value);
return (
- {currencies[total.currency]?.symbol ?? total.currency}
+ {currencies[money.currency]?.symbol ?? money.currency}
- {currencies[total.currency]?.name ?? total.currency}
+ {currencies[money.currency]?.name ?? money.currency}
@@ -49,7 +49,7 @@ const PortfolioTotals: FC = (props) => {
) : (
amount
)}{" "}
- {currencies[total.currency]?.symbol ?? total.currency}
+ {currencies[money.currency]?.symbol ?? money.currency}
);
@@ -58,4 +58,4 @@ const PortfolioTotals: FC = (props) => {
);
};
-export { PortfolioTotals };
+export { CurrencyCards };
diff --git a/src/pages/account/ui/components/PortfolioRefreshState.stories.tsx b/src/pages/account/ui/components/PortfolioRefreshState.stories.tsx
index 7b259c2..5c7358b 100644
--- a/src/pages/account/ui/components/PortfolioRefreshState.stories.tsx
+++ b/src/pages/account/ui/components/PortfolioRefreshState.stories.tsx
@@ -1,8 +1,8 @@
import { Stack } from "@mantine/core";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { fn } from "storybook/test";
+import { CurrencyCards } from "./CurrencyCards.view";
import { PortfolioRefreshState } from "./PortfolioRefreshState.view";
-import { PortfolioTotals } from "./PortfolioTotals.view";
const meta = {
title: "Product/PortfolioRefreshState",
@@ -18,14 +18,14 @@ const meta = {
docs: {
description: {
component:
- "Обновление уже показанных данных портфеля. Реальные PortfolioTotals остаются видимыми вместе с PortfolioRefreshState при refetch и его ошибке. Суммы синтетические; это presentation Client Cached Snapshot, без QueryClient и запроса API. Retry регистрирует локальное действие в Actions и не доказывает восстановление API.",
+ "Обновление уже показанных данных портфеля. Реальные CurrencyCards остаются видимыми вместе с PortfolioRefreshState при refetch и его ошибке. Суммы синтетические; это presentation Client Cached Snapshot, без QueryClient и запроса API. Retry регистрирует локальное действие в Actions и не доказывает восстановление API.",
},
},
},
render: (args) => (
-