From b40066838c8ae37cd542d1eb50c083cb41e59f53 Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Wed, 11 Nov 2026 08:22:44 +0300 Subject: [PATCH] refactor!: support RUB only (WealthLedger/main#55) Adopt RUB-only API contract; remove currency cards, selectors, screener currency filter and multi-currency branches. BREAKING CHANGE: requires the RUB-only backend contract. --- src/app/routes/AppRoutes/AllAccounts.test.tsx | 21 ++- .../broker-operation/ui/OperationGroup.tsx | 2 +- .../brokerage-account/model/display.ts | 17 +- .../BrokerageAccountList.stories.tsx | 86 +++------- .../BrokerageAccountList.test.tsx | 93 ++++------- .../BrokerageAccountList.tsx | 104 ++++-------- .../ui/BrokerageEventGroup.test.tsx | 6 +- src/entities/portfolio/model/display.ts | 2 +- .../ui/InstrumentChart/InstrumentChart.vm.ts | 3 +- .../InvestmentRefreshState.stories.tsx | 2 +- .../AccountEventsPage.filters.test.tsx | 4 +- .../AccountEventsPage.retry.test.tsx | 2 +- .../AccountIncomePage.test.tsx | 30 ++-- .../AccountIncomePageLoaded.view.tsx | 11 +- .../components/IncomeSummary.view.tsx | 18 +-- src/pages/account/model/concentration.test.ts | 28 ++-- src/pages/account/model/concentration.ts | 26 +-- src/pages/account/model/performance.ts | 1 - src/pages/account/model/positions.fixtures.ts | 11 +- src/pages/account/model/positions.test.ts | 29 ++-- src/pages/account/model/positions.ts | 34 ++-- src/pages/account/model/previews.ts | 2 +- .../ui/AccountPage/AccountPage.test.tsx | 149 +++++++----------- .../account/ui/AccountPage/AccountPage.tsx | 2 - .../ui/AccountPage/AccountPage.vm.test.tsx | 8 +- .../account/ui/AccountPage/AccountPage.vm.ts | 23 +-- .../components/AccountPageEmpty.view.tsx | 7 +- .../components/AccountPageLoaded.view.tsx | 45 ++++-- .../Concentration/Concentration.view.tsx | 40 ++--- .../CurrencyCards/CurrencyCards.test.tsx | 77 --------- .../CurrencyCards/CurrencyCards.view.tsx | 74 --------- .../components/IncomeSummary.view.tsx | 16 +- .../Performance/Performance.stories.tsx | 2 +- .../Performance/PerformanceTab.test.tsx | 4 +- .../PortfolioStructure.stories.tsx | 95 ++++------- .../PortfolioStructure.test.tsx | 103 +++++------- .../PortfolioStructure.view.tsx | 20 +-- .../PortfolioStructure.vm.ts | 29 +--- .../ui/AccountsPage/AccountsPage.test.tsx | 38 +++-- .../components/AccountsPageLoaded.view.tsx | 56 ++----- .../stock/ui/StockPage/StockPage.test.tsx | 33 +--- .../components/StockCompany.view.tsx | 11 +- .../stocks/ui/StocksPage/StocksPage.view.tsx | 5 - .../stocks/ui/StocksPage/StocksPage.vm.ts | 3 - src/shared/api/generated/schema.d.ts | 40 ++--- src/shared/api/resource-query.test.tsx | 10 +- src/shared/api/spec/openapi.json | 141 +++++------------ src/shared/i18n/locales/ru.ts | 21 +-- src/shared/lib/decimal.test.ts | 13 +- src/shared/lib/decimal.ts | 28 ++-- src/shared/lib/display.test.ts | 6 +- src/shared/lib/display.ts | 25 +-- src/shared/lib/total-text.ts | 8 +- src/shared/ui/EntryGroup/EntryGroup.tsx | 12 +- src/test/handlers.ts | 17 +- 55 files changed, 540 insertions(+), 1153 deletions(-) delete mode 100644 src/pages/account/ui/AccountPage/components/CurrencyCards/CurrencyCards.test.tsx delete mode 100644 src/pages/account/ui/AccountPage/components/CurrencyCards/CurrencyCards.view.tsx diff --git a/src/app/routes/AppRoutes/AllAccounts.test.tsx b/src/app/routes/AppRoutes/AllAccounts.test.tsx index 65c3258..645363b 100644 --- a/src/app/routes/AppRoutes/AllAccounts.test.tsx +++ b/src/app/routes/AppRoutes/AllAccounts.test.tsx @@ -55,12 +55,12 @@ const position: Portfolio["positions"][number] }; const portfolio: Portfolio = { - cashBalances: [], + cash: { currency: "RUB", value: "0" }, isStale: false, lastSuccessfulAt: "2026-08-15T12:00:00Z", positions: accounts.map(account => ({ ...position, account })), - structure: { currencies: [], unvaluedCategories: [] }, - totalsByCurrency: [{ currency: "RUB", value: "1200" }], + structure: { categories: [], hasNegativeValues: false, totalValue: "0", unvaluedCategories: [] }, + totals: { currency: "RUB", value: "1200" }, }; const receipt: BrokerageOperation @@ -106,14 +106,11 @@ const analytics: IncomeAnalytics }, ], period: { from: "2026-08-01", to: "2026-08-31" }, - totalsByCurrency: [ - { - coupon: { currency: "RUB", value: "2" }, - currency: "RUB", - dividend: { currency: "RUB", value: "0" }, - total: { currency: "RUB", value: "2" }, - }, - ], + total: { + coupon: { currency: "RUB", value: "2" }, + dividend: { currency: "RUB", value: "0" }, + total: { currency: "RUB", value: "2" }, + }, }; const CurrentLocation: FC = () => { @@ -216,7 +213,7 @@ beforeEach(() => { type: "coupon", }), ), - incomeTotalsByCurrency: [], + incomeTotal: { currency: "RUB", value: "0" }, window: { from: "2026-09-01", to: "2026-10-01" }, }), ), diff --git a/src/entities/broker-operation/ui/OperationGroup.tsx b/src/entities/broker-operation/ui/OperationGroup.tsx index fa36ce1..d00b4f5 100644 --- a/src/entities/broker-operation/ui/OperationGroup.tsx +++ b/src/entities/broker-operation/ui/OperationGroup.tsx @@ -66,7 +66,7 @@ const OperationGroup: FC = (props) => { const totalsText = totalText(totals, true); - const totalTone = isCoupon && totals?.length && totals.every(total => !isZeroDecimal(total.value) && !total.value.startsWith("-")) + const totalTone = isCoupon && totals && !isZeroDecimal(totals.value) && !totals.value.startsWith("-") ? "positive" : "neutral"; diff --git a/src/entities/brokerage-account/model/display.ts b/src/entities/brokerage-account/model/display.ts index 08cb2d2..208062a 100644 --- a/src/entities/brokerage-account/model/display.ts +++ b/src/entities/brokerage-account/model/display.ts @@ -4,8 +4,6 @@ import type { BrokerageAccount, } from "../api/useBrokerageAccounts"; -const VISIBLE_CURRENCY_LIMIT = 2; - type BrokerageAccountCard = { account: BrokerageAccount; positionCounts: null | PositionCounts; @@ -13,18 +11,13 @@ type BrokerageAccountCard = { summary: | { portfolio: NonNullable; - remainingCurrencyCount: number; state: "available"; - totals: Money[]; + totals: NonNullable["totals"]; } | { state: "empty" } | { state: "unavailable" }; }; -type Money = NonNullable< - BrokerageAccount["portfolioSummary"] ->["totalsByCurrency"][number]; - type PositionCounts = { bonds: number; stocks: number; total: number }; function accountTypeKey(type: BrokerageAccount["type"]) { @@ -57,7 +50,7 @@ function brokerageAccountCards( total: summary.totalPositionCount, }; - if (summary.totalsByCurrency.length === 0) + if (Number(summary.totals.value) === 0) return { account, positionCounts, source, summary: { state: "empty" } }; return { @@ -66,12 +59,8 @@ function brokerageAccountCards( source, summary: { portfolio: summary, - remainingCurrencyCount: Math.max( - summary.totalsByCurrency.length - VISIBLE_CURRENCY_LIMIT, - 0, - ), state: "available", - totals: summary.totalsByCurrency.slice(0, VISIBLE_CURRENCY_LIMIT), + totals: summary.totals, }, }; }); diff --git a/src/entities/brokerage-account/ui/BrokerageAccountList/BrokerageAccountList.stories.tsx b/src/entities/brokerage-account/ui/BrokerageAccountList/BrokerageAccountList.stories.tsx index a7fc647..7b84f53 100644 --- a/src/entities/brokerage-account/ui/BrokerageAccountList/BrokerageAccountList.stories.tsx +++ b/src/entities/brokerage-account/ui/BrokerageAccountList/BrokerageAccountList.stories.tsx @@ -14,36 +14,29 @@ type PortfolioSummary = NonNullable; const rubReturn = { absolute: "136900", costBasis: "1103600.50", - currency: "RUB", partial: false, percent: "12.40", }; const portfolioSummary = { bondPositionCount: 12, - cashBalances: [ - { currency: "RUB", value: "82500.50" }, - { currency: "USD", value: "1500" }, - ], + cash: { currency: "RUB", value: "82500.50" }, fundPositionCount: 2, stockPositionCount: 5, structure: { - currencies: [{ - categories: [ - { sharePercent: "55.000000", type: "STOCK", value: "682500" }, - { sharePercent: "25.000000", type: "BOND", value: "310500" }, - { sharePercent: "10.000000", type: "OTHER", value: "124200" }, - { sharePercent: "10.000000", type: "CASH", value: "124200" }, - ], - currency: "RUB", - hasNegativeValues: false, - totalValue: "1241400", - }], + categories: [ + { sharePercent: "55.000000", type: "STOCK", value: "682500" }, + { sharePercent: "25.000000", type: "BOND", value: "310500" }, + { sharePercent: "10.000000", type: "OTHER", value: "124200" }, + { sharePercent: "10.000000", type: "CASH", value: "124200" }, + ], + hasNegativeValues: false, + totalValue: "1241400", unvaluedCategories: [], }, totalPositionCount: 19, - totalsByCurrency: [{ currency: "RUB", value: "1240500.50" }], - unrealizedReturnByCurrency: [rubReturn], + totals: { currency: "RUB", value: "1240500.50" }, + unrealizedReturn: rubReturn, } satisfies PortfolioSummary; function account( @@ -63,20 +56,25 @@ function account( const negativeSummary: PortfolioSummary = { ...portfolioSummary, - unrealizedReturnByCurrency: [{ + unrealizedReturn: { ...rubReturn, absolute: "-4200", percent: "-3.05", - }], + }, }; const emptySummary: PortfolioSummary = { ...portfolioSummary, - cashBalances: [], - structure: { currencies: [], unvaluedCategories: [] }, + cash: { currency: "RUB", value: "0" }, + structure: { + categories: [], + hasNegativeValues: false, + totalValue: "0", + unvaluedCategories: [], + }, totalPositionCount: 0, - totalsByCurrency: [], - unrealizedReturnByCurrency: [], + totals: { currency: "RUB", value: "0" }, + unrealizedReturn: rubReturn, }; const meta = { @@ -94,7 +92,7 @@ const meta = { docs: { description: { component: - "Карточки счетов: стоимость и доходность по валютам, деньги, структура и свежесть источника. Денежные и процентные значения остаются строками. Все примеры содержат вымышленные данные; ссылки работают только внутри MemoryRouter.", + "Карточки счетов: стоимость и доходность, деньги, структура и свежесть источника. Денежные и процентные значения остаются строками. Все примеры содержат вымышленные данные; ссылки работают только внутри MemoryRouter.", }, }, }, @@ -108,40 +106,6 @@ type Story = StoryObj; export const Normal: Story = {}; -export const MultiCurrency: Story = { - args: { - accounts: brokerageAccountCards([account("multi", "Мультивалютный", { - ...portfolioSummary, - cashBalances: [ - { currency: "RUB", value: "82500.50" }, - { currency: "USD", value: "1500" }, - ], - totalsByCurrency: [ - { currency: "RUB", value: "1240500.50" }, - { currency: "USD", value: "3200" }, - { currency: "EUR", value: "900" }, - ], - unrealizedReturnByCurrency: [ - ...portfolioSummary.unrealizedReturnByCurrency, - { - absolute: "20", - costBasis: "3180", - currency: "USD", - partial: false, - percent: "2.10", - }, - { - absolute: "0", - costBasis: "900", - currency: "EUR", - partial: false, - percent: null, - }, - ], - })]), - }, -}; - export const Negative: Story = { args: { accounts: brokerageAccountCards([ @@ -154,10 +118,10 @@ export const Partial: Story = { args: { accounts: brokerageAccountCards([account("partial", "Частичные данные", { ...portfolioSummary, - unrealizedReturnByCurrency: [{ + unrealizedReturn: { ...rubReturn, partial: true, - }], + }, })]), }, }; diff --git a/src/entities/brokerage-account/ui/BrokerageAccountList/BrokerageAccountList.test.tsx b/src/entities/brokerage-account/ui/BrokerageAccountList/BrokerageAccountList.test.tsx index 5657ce9..4367da4 100644 --- a/src/entities/brokerage-account/ui/BrokerageAccountList/BrokerageAccountList.test.tsx +++ b/src/entities/brokerage-account/ui/BrokerageAccountList/BrokerageAccountList.test.tsx @@ -14,55 +14,28 @@ type PortfolioSummary = NonNullable; const portfolioSummary = { bondPositionCount: 12, - cashBalances: [ - { currency: "RUB", value: "82500.5" }, - { currency: "USD", value: "20" }, - ], + cash: { currency: "RUB", value: "82500.5" }, fundPositionCount: 0, stockPositionCount: 5, structure: { - currencies: [{ - categories: [ - { sharePercent: "50.000000", type: "STOCK", value: "100" }, - { sharePercent: "30.000000", type: "BOND", value: "60" }, - { sharePercent: "0.000000", type: "OTHER", value: "0" }, - { sharePercent: "20.000000", type: "CASH", value: "40" }, - ], - currency: "RUB", - hasNegativeValues: false, - totalValue: "200", - }], + categories: [ + { sharePercent: "50.000000", type: "STOCK", value: "100" }, + { sharePercent: "30.000000", type: "BOND", value: "60" }, + { sharePercent: "0.000000", type: "OTHER", value: "0" }, + { sharePercent: "20.000000", type: "CASH", value: "40" }, + ], + hasNegativeValues: false, + totalValue: "200", unvaluedCategories: [], }, totalPositionCount: 17, - totalsByCurrency: [ - { currency: "RUB", value: "123456789012345678901.995" }, - { currency: "USD", value: "20" }, - { currency: "EUR", value: "5" }, - ], - unrealizedReturnByCurrency: [ - { - absolute: "136900", - costBasis: "1000000", - currency: "RUB", - partial: true, - percent: "12.40", - }, - { - absolute: "-4.2", - costBasis: "24.2", - currency: "USD", - partial: false, - percent: "-3.05", - }, - { - absolute: "0", - costBasis: "5", - currency: "EUR", - partial: false, - percent: null, - }, - ], + totals: { currency: "RUB", value: "123456789012345678901.995" }, + unrealizedReturn: { + absolute: "136900", + costBasis: "1000000", + partial: true, + percent: "12.40", + }, } satisfies PortfolioSummary; type BrokerageAccountCardInput = Parameters[0][ @@ -97,16 +70,19 @@ describe("BrokerageAccountList", () => { it("renders returns, cash and structure with precise totals", () => { const nullReturnSummary: PortfolioSummary = { ...portfolioSummary, - cashBalances: [], - structure: { currencies: [], unvaluedCategories: [] }, - totalsByCurrency: [{ currency: "EUR", value: "5" }], - unrealizedReturnByCurrency: [{ + structure: { + categories: [], + hasNegativeValues: false, + totalValue: "0", + unvaluedCategories: [], + }, + totals: { currency: "RUB", value: "5" }, + unrealizedReturn: { absolute: "0", costBasis: "5", - currency: "EUR", partial: false, percent: null, - }], + }, }; renderList(brokerageAccountCards([ @@ -118,19 +94,15 @@ describe("BrokerageAccountList", () => { expect(link).toHaveAttribute("href", "/accounts/account-1"); expect(within(link).getByText("123 456 789 012 345 678 902,00 ₽")) .toBeInTheDocument(); - expect(within(link).getByText("Еще 1 валюта")).toBeInTheDocument(); expect(within(link).getByText("+12,4 %")).toBeInTheDocument(); expect(within(link).getByText("+136 900 ₽")).toBeInTheDocument(); - expect(within(link).getByText("−3,1 %")).toBeInTheDocument(); - expect(within(link).getByText("−4,2 $")).toBeInTheDocument(); const nullReturnLink = screen.getByRole("link", { name: /Без доходности/ }); expect(within(nullReturnLink).getByText("—")).toBeInTheDocument(); expect(within(link).getByText("частично")).toBeInTheDocument(); expect(within(link).getByText("82 500,50 ₽")).toBeInTheDocument(); - expect(within(link).getAllByText("20,00 $")).not.toHaveLength(0); expect(within(link).getByText("17 позиций")).toBeInTheDocument(); expect(screen.getByRole("img", { - name: "Структура в RUB: Акции 50,0 %; Облигации 30,0 %; " + name: "Структура: Акции 50,0 %; Облигации 30,0 %; " + "Другие активы 0,0 %; Свободные деньги 20,0 %", })).toBeInTheDocument(); const stockLegend = within(link).getByText(/Акции 50,0 %/); @@ -140,17 +112,20 @@ describe("BrokerageAccountList", () => { expect(within(link).queryByText(/Другие активы 0,0 %/)) .not.toBeInTheDocument(); expect(link).toHaveTextContent("По данным брокера"); - expect(link).toHaveTextContent("Структура RUB: Акции 100"); + expect(link).toHaveTextContent("Структура: Акции 100"); }); it("preserves empty, unavailable and stale states", () => { const emptySummary: PortfolioSummary = { ...portfolioSummary, - cashBalances: [], - structure: { currencies: [], unvaluedCategories: [] }, + structure: { + categories: [], + hasNegativeValues: false, + totalValue: "0", + unvaluedCategories: [], + }, totalPositionCount: 0, - totalsByCurrency: [], - unrealizedReturnByCurrency: [], + totals: { currency: "RUB", value: "0" }, }; const cards = brokerageAccountCards([ diff --git a/src/entities/brokerage-account/ui/BrokerageAccountList/BrokerageAccountList.tsx b/src/entities/brokerage-account/ui/BrokerageAccountList/BrokerageAccountList.tsx index 719dea6..07ec3b7 100644 --- a/src/entities/brokerage-account/ui/BrokerageAccountList/BrokerageAccountList.tsx +++ b/src/entities/brokerage-account/ui/BrokerageAccountList/BrokerageAccountList.tsx @@ -34,10 +34,10 @@ type BrokerageAccountSummaryProps = { type PortfolioSummary = AvailableSummary["portfolio"]; type ReturnValueProps = { - item: PortfolioSummary["unrealizedReturnByCurrency"][number]; + item: PortfolioSummary["unrealizedReturn"]; }; -type StructureCurrency = PortfolioSummary["structure"]["currencies"][number]; +type StructureCurrency = PortfolioSummary["structure"]; function formatPercent(value: null | string) { if (value === null) return "—"; @@ -81,7 +81,7 @@ const ReturnValue: FC = (props) => { {formatMoney( - { currency: item.currency, value: item.absolute }, + { currency: "RUB", value: item.absolute }, { withSign: true }, )} @@ -119,7 +119,6 @@ const StructureBar: FC = (props) => { }).join("; "); const label = t("accounts.structure.label", { - currency: props.item.currency, value: description, }); @@ -197,50 +196,24 @@ const BrokerageAccountSummary: FC = (props) => { return ( - {summary.totals.map((total) => { - const item = summary.portfolio.unrealizedReturnByCurrency.find( - returnItem => returnItem.currency === total.currency, - ); - - return ( - - - {formatMoney(total, { withRoundedCents: true })} - - - {item && } - - ); - })} - - {!!summary.remainingCurrencyCount && ( - - {t("accounts.remainingCurrencies", { - count: summary.remainingCurrencyCount, - })} + + + {formatMoney(summary.totals, { withRoundedCents: true })} - )} + + + - {summary.portfolio.cashBalances.length > 0 && ( - - - {t("accounts.cash")} - + + + {t("accounts.cash")} + - - {summary.portfolio.cashBalances.map(cash => ( - - {formatMoney(cash, { withRoundedCents: true })} - - ))} - - - )} + + {formatMoney(summary.portfolio.cash, { withRoundedCents: true })} + + ); }; @@ -287,39 +260,31 @@ const AccountDescription: FC = (props) => { const { account, summary } = props.account; if (summary.state !== "available") return null; - const returns = summary.portfolio.unrealizedReturnByCurrency.map(item => - t("accounts.description.return", { - absolute: formatMoney( - { currency: item.currency, value: item.absolute }, - { withSign: true }, - ), - currency: item.currency, - percent: formatPercent(item.percent), - }), - ); + const returns = [t("accounts.description.return", { + absolute: formatMoney( + { currency: "RUB", value: summary.portfolio.unrealizedReturn.absolute }, + { withSign: true }, + ), + percent: formatPercent(summary.portfolio.unrealizedReturn.percent), + })]; - const structures = summary.portfolio.structure.currencies.map((item) => { - const categories = item.categories.map(category => + const structures = (() => { + const categories = summary.portfolio.structure.categories.map(category => t("accounts.description.structureCategory", { category: t(portfolioCategoryPresentation[category.type].label), value: formatMoney( - { currency: item.currency, value: category.value }, + { currency: "RUB", value: category.value }, { withRoundedCents: true }, ), }), ).join(", "); - return t("accounts.description.structure", { - currency: item.currency, - value: categories, - }); - }); + return t("accounts.description.structure", { value: categories }); + })(); - const hasPartialReturn = summary.portfolio.unrealizedReturnByCurrency.some( - item => item.partial, - ); + const hasPartialReturn = summary.portfolio.unrealizedReturn.partial; - const details = [...returns, ...structures].join(". "); + const details = [returns, structures].join(". "); return ( @@ -328,8 +293,7 @@ const AccountDescription: FC = (props) => { type: t(accountTypeKey(account.type)), })} {t("accounts.description.currentValue", { - value: summary.portfolio.totalsByCurrency - .map(total => formatMoney(total)).join(", "), + value: formatMoney(summary.portfolio.totals), })} {details ? `${details}. ` : ""} {hasPartialReturn ? t("accounts.brokerDescription") : ""} @@ -369,9 +333,7 @@ const BrokerageAccountList: FC = (props) => { {summary.state === "available" && ( - {summary.portfolio.structure.currencies.map(item => ( - - ))} + )} diff --git a/src/entities/brokerage-event/ui/BrokerageEventGroup.test.tsx b/src/entities/brokerage-event/ui/BrokerageEventGroup.test.tsx index c17a9bf..842a5fa 100644 --- a/src/entities/brokerage-event/ui/BrokerageEventGroup.test.tsx +++ b/src/entities/brokerage-event/ui/BrokerageEventGroup.test.tsx @@ -97,13 +97,13 @@ describe("BrokerageEventGroup", () => { expect(screen.getByText("0,00 ₽")).toBeInTheDocument(); }); - it("shows separate forecast totals per currency without conversion", () => { + it("sums forecast totals exactly", () => { renderGroup([ - event({ id: "broker", name: "Брокерский" }, "10", "USD"), + event({ id: "broker", name: "Брокерский" }, "10"), event({ id: "iis", name: "ИИС" }, "200"), ]); - expect(screen.getByRole("list", { name: /10 \$.*200 ₽/ })) + expect(screen.getByRole("list", { name: /210 ₽/ })) .toBeInTheDocument(); }); diff --git a/src/entities/portfolio/model/display.ts b/src/entities/portfolio/model/display.ts index 0b229ec..d950266 100644 --- a/src/entities/portfolio/model/display.ts +++ b/src/entities/portfolio/model/display.ts @@ -2,7 +2,7 @@ import type { Portfolio } from "../api/usePortfolio"; const PERCENT_SCALE = 100; -type Money = Portfolio["totalsByCurrency"][number]; +type Money = Portfolio["totals"]; function formatShare( numerator: Money | null, diff --git a/src/features/instrument-details/ui/InstrumentChart/InstrumentChart.vm.ts b/src/features/instrument-details/ui/InstrumentChart/InstrumentChart.vm.ts index 8cbc580..7a14209 100644 --- a/src/features/instrument-details/ui/InstrumentChart/InstrumentChart.vm.ts +++ b/src/features/instrument-details/ui/InstrumentChart/InstrumentChart.vm.ts @@ -19,7 +19,6 @@ function useInstrumentChartVm(props: InstrumentChartProps) { const average = vm.position?.averagePurchasePrice; const averageLine = average && props.priceUnit !== "percent" - && average.currency === vm.candles?.currency ? Number(average.value) : null; @@ -47,7 +46,7 @@ function useInstrumentChartVm(props: InstrumentChartProps) { currency: props.priceUnit === "percent" ? t("bond.percentOfNominal") - : (vm.candles?.currency ?? ""), + : "₽", }, ); diff --git a/src/features/tbank-connection/ui/InvestmentRefreshState/InvestmentRefreshState.stories.tsx b/src/features/tbank-connection/ui/InvestmentRefreshState/InvestmentRefreshState.stories.tsx index 651044d..4396ac4 100644 --- a/src/features/tbank-connection/ui/InvestmentRefreshState/InvestmentRefreshState.stories.tsx +++ b/src/features/tbank-connection/ui/InvestmentRefreshState/InvestmentRefreshState.stories.tsx @@ -23,7 +23,7 @@ const meta = { }, render: arguments_ => ( - 125 000,50 ₽ · 320 USD + 125 000,50 ₽ diff --git a/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.filters.test.tsx b/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.filters.test.tsx index 25566ce..32bcba5 100644 --- a/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.filters.test.tsx +++ b/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.filters.test.tsx @@ -263,7 +263,7 @@ describe("Upcoming event date filters", () => { accountId, calculatedAt: "2099-01-01T00:00:00Z", events: [], - incomeTotalsByCurrency: [], + incomeTotal: { currency: "RUB", value: "0" }, window: { from: "2099-01-01", to: "2099-01-31" }, }), ); @@ -353,7 +353,7 @@ it("retains the previous complete forecast when its source refresh fails", async ]; const snapshot = resource( - { events, incomeTotalsByCurrency: [{ currency: "RUB", value: "123.45" }] }, + { events, incomeTotal: { currency: "RUB", value: "123.45" } }, { lastAttempt: { finishedAt: "2026-08-14T00:01:00Z", diff --git a/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.retry.test.tsx b/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.retry.test.tsx index cb39af1..8dae089 100644 --- a/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.retry.test.tsx +++ b/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.retry.test.tsx @@ -33,7 +33,7 @@ const snapshot = resource({ type: "coupon", }, ], - incomeTotalsByCurrency: [{ currency: "RUB", value: "123.45" }], + incomeTotal: { currency: "RUB", value: "123.45" }, window: { from: "2026-08-14", to: "2026-09-13" }, }); diff --git a/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.test.tsx b/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.test.tsx index f0ae8d8..63951ed 100644 --- a/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.test.tsx +++ b/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.test.tsx @@ -49,7 +49,7 @@ const analytics buckets: [ { period: { from: "2026-08-01", to: "2026-08-01" }, - totalsByCurrency: [summary("RUB", "10", "2")], + total: summary("10", "2"), }, ], calculatedAt: "2026-08-27T00:00:00Z", @@ -77,7 +77,7 @@ const analytics }, ], period: { from: "2026-08-01", to: "2026-08-27" }, - totalsByCurrency: [summary("RUB", "10", "2")], + total: summary("10", "2"), } satisfies IncomeAnalytics & { instruments: [unknown] }; function entry(): IncomeAnalytics["entries"][number] { @@ -145,12 +145,11 @@ function renderPage( return { ...view, queryClient }; } -function summary(currency: string, coupon: string, dividend: string) { +function summary(coupon: string, dividend: string) { return { - coupon: money(currency, coupon), - currency, - dividend: money(currency, dividend), - total: money(currency, String(Number(coupon) + Number(dividend))), + coupon: money("RUB", coupon), + dividend: money("RUB", dividend), + total: money("RUB", String(Number(coupon) + Number(dividend))), }; } @@ -308,7 +307,7 @@ describe("AccountIncomePage", () => { buckets: [], entries: [], instruments: [], - totalsByCurrency: [], + total: summary("0", "0"), }), ), ), @@ -410,7 +409,7 @@ describe("AccountIncomePage", () => { it.each([ ["full", analytics], - ["empty", { ...analytics, buckets: [], entries: [], instruments: [], totalsByCurrency: [] }], + ["empty", { ...analytics, buckets: [], entries: [], instruments: [], total: summary("0", "0") }], ])("shows the server source time and last attempt for a %s result", async (_name, data) => { server.use( http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () => @@ -737,11 +736,11 @@ describe("income period and snapshot boundary", () => { const instruments = [ { ...analytics.instruments[0], - coupon: money("USD", "10"), - dividend: money("USD", "0"), - key: "usd", + coupon: money("RUB", "10"), + dividend: money("RUB", "0"), + key: "rub-positive", receiptCount: 1, - total: money("USD", "10"), + total: money("RUB", "10"), }, { ...analytics.instruments[0], @@ -770,7 +769,7 @@ describe("income period and snapshot boundary", () => { payment: instrument.total, })), instruments, - totalsByCurrency: [summary("RUB", "-5", "0"), summary("USD", "10", "0")], + total: summary("-5", "0"), }); render( @@ -782,10 +781,9 @@ describe("income period and snapshot boundary", () => { screen.getByRole("heading", { name: "Выплаты" }), ).toBeInTheDocument(); expect(screen.queryByText("RUB")).not.toBeInTheDocument(); - expect(screen.queryByText("USD")).not.toBeInTheDocument(); const cards = screen.getAllByRole("listitem"); expect(cards).toHaveLength(3); - expect(cards[0]).toHaveTextContent("+10 $"); + expect(cards[0]).toHaveTextContent("+10 ₽"); expect(cards[1]).toHaveTextContent("−2 ₽"); expect(cards[2]).toHaveTextContent("−3 ₽"); expect(screen.queryByText(/Итого:|Выплат:/)).not.toBeInTheDocument(); diff --git a/src/pages/account-income/ui/AccountIncomePage/components/AccountIncomePageLoaded.view.tsx b/src/pages/account-income/ui/AccountIncomePage/components/AccountIncomePageLoaded.view.tsx index 48c3a91..a567f34 100644 --- a/src/pages/account-income/ui/AccountIncomePage/components/AccountIncomePageLoaded.view.tsx +++ b/src/pages/account-income/ui/AccountIncomePage/components/AccountIncomePageLoaded.view.tsx @@ -5,7 +5,6 @@ import { Stack, Title, } from "@mantine/core"; -import { StateView } from "@wealthledger/ui"; import { FormProvider } from "react-hook-form"; import { useTranslation } from "react-i18next"; @@ -25,13 +24,9 @@ const AccountIncomePageLoadedSummary: FC = (props) => { const { analytics } = props; - if (props.partial) { - return {t("investment.partial")}; - } - - return analytics.totalsByCurrency.length > 0 - ? - : ; + return props.partial + ? {t("investment.partial")} + : ; }; const AccountIncomePageLoaded: FC = (props) => { diff --git a/src/pages/account-income/ui/AccountIncomePage/components/IncomeSummary.view.tsx b/src/pages/account-income/ui/AccountIncomePage/components/IncomeSummary.view.tsx index c00107e..d0f1f70 100644 --- a/src/pages/account-income/ui/AccountIncomePage/components/IncomeSummary.view.tsx +++ b/src/pages/account-income/ui/AccountIncomePage/components/IncomeSummary.view.tsx @@ -7,7 +7,7 @@ import type { IncomeAnalytics } from "@/entities/income"; import { formatMoney } from "@/shared/lib"; -type IncomeSummaryProps = { totals: IncomeAnalytics["totalsByCurrency"] }; +type IncomeSummaryProps = { totals: IncomeAnalytics["total"] }; const IncomeSummary: FC = (props) => { const { t } = useTranslation(); @@ -16,16 +16,14 @@ const IncomeSummary: FC = (props) => { {t("income.summaryTitle")} - {props.totals.map(total => ( - - {formatMoney(total.total)} + + {formatMoney(props.totals.total)} - - {t("income.coupon")}: {formatMoney(total.coupon)} ·{" "} - {t("income.dividend")}: {formatMoney(total.dividend)} - - - ))} + + {t("income.coupon")}: {formatMoney(props.totals.coupon)} ·{" "} + {t("income.dividend")}: {formatMoney(props.totals.dividend)} + + ); }; diff --git a/src/pages/account/model/concentration.test.ts b/src/pages/account/model/concentration.test.ts index 80fd3f0..4ead4b9 100644 --- a/src/pages/account/model/concentration.test.ts +++ b/src/pages/account/model/concentration.test.ts @@ -35,15 +35,13 @@ describe("account concentration", () => { const model = toConcentration(snapshot([ stock("A", "1"), stock("B", "15"), stock("C", "0"), stock("D", "-50"), stock("E", null), - stock("F", null, "USD"), stock("G", "-3", "USD"), - ]), "stocks", "RUB", labels); + ]), "stocks", labels); - expect(model.currencies).toEqual(["RUB", "USD"]); expect(model.total).toBe("16"); expect(model.items.map(item => item.share)).toEqual(["93,8 %", "6,3 %"]); - expect(model.unaccounted).toBe(3); - const negative = snapshot([stock("A", "-3", "USD")]); - expect(toConcentration(negative, "stocks", "USD", labels)) + expect(model.unaccounted).toBe(2); + const negative = snapshot([stock("A", "-3")]); + expect(toConcentration(negative, "stocks", labels)) .toMatchObject({ empty: true, unaccounted: 1 }); }); @@ -52,7 +50,7 @@ describe("account concentration", () => { stock("A", "400"), stock("B", "250"), stock("C", "150"), stock("D", "100"), stock("E", "50"), stock("F", "30"), stock("G", "20"), - ]), "stocks", "RUB", labels); + ]), "stocks", labels); expect(model.items.map(item => item.amount)).toEqual([ "400", "250", "150", "100", "50", @@ -71,7 +69,7 @@ describe("account concentration", () => { stock("Сбер", "100"), stock("X5", "100"), stock("VK", "100"), stock("D", "100"), stock("E", "100"), stock("F", "100"), - ]), "stocks", "RUB", labels); + ]), "stocks", labels); expect(model.items).toHaveLength(6); expect(model.other).toBeNull(); @@ -83,7 +81,7 @@ describe("account concentration", () => { stock("Large", "100000000"), stock("Small", "1"), ]); - expect(toConcentration(tiny, "stocks", "RUB", labels).items[1]?.share) + expect(toConcentration(tiny, "stocks", labels).items[1]?.share) .toBe("< 0,1 %"); }); @@ -103,9 +101,9 @@ describe("account concentration", () => { stock("D", "50", "RUB", { issuer: null, sector: null }), ]; - expect(toConcentration(snapshot(positions), "sectors", "RUB", labels) + expect(toConcentration(snapshot(positions), "sectors", labels) .items.map(item => item.amount)).toEqual(["500", "100", "50"]); - expect(toConcentration(snapshot(positions), "issuers", "RUB", labels) + expect(toConcentration(snapshot(positions), "issuers", labels) .items.map(item => [item.name, item.amount])).toEqual([ ["Сбербанк", "500"], ["", "50"], ]); @@ -123,7 +121,7 @@ describe("account concentration", () => { stock("D", "100", "RUB", { issuer: null, sector: "energy" }), ]); - expect(toConcentration(issuers, "issuers", "RUB", labels) + expect(toConcentration(issuers, "issuers", labels) .items.map(item => [item.label, item.amount])).toEqual([ ["Газпром", "300"], ["Сбер", "300"], ["Эмитент не указан", "300"], ]); @@ -140,7 +138,7 @@ describe("account concentration", () => { : "Энергетика", }; - expect(toConcentration(sectors, "sectors", "RUB", sectorLabels) + expect(toConcentration(sectors, "sectors", sectorLabels) .items.map(item => item.label)) .toEqual(["Информационные технологии", "Энергетика"]); }); @@ -158,7 +156,7 @@ describe("account concentration", () => { }), ]); - expect(toConcentration(data, "issuers", "RUB", labels).items + expect(toConcentration(data, "issuers", labels).items .map(item => [item.name, item.label, item.amount])).toEqual([ ["Альфа", "Альфа", "400"], ["Бета", "Бета", "400"], ]); @@ -169,7 +167,7 @@ describe("account concentration", () => { isin: "RU000A0JX0J2", ticker: "BOND", type: "BOND", })]); - expect(toConcentration(data, "bonds", "RUB", labels) + expect(toConcentration(data, "bonds", labels) .items[0]?.logo?.subtitle).toBe("RU000A0JX0J2"); }); }); diff --git a/src/pages/account/model/concentration.ts b/src/pages/account/model/concentration.ts index e9eb18a..0179fc9 100644 --- a/src/pages/account/model/concentration.ts +++ b/src/pages/account/model/concentration.ts @@ -9,7 +9,6 @@ import { } from "@/shared/lib"; type Concentration = { - currencies: string[]; empty: boolean; items: ConcentrationItem[]; other: ConcentrationOther | null; @@ -83,11 +82,9 @@ function addPosition( index: number, slice: Slice, labels: ConcentrationLabels, - selected: null | string, ): number { if (!isIncluded(slice, position.type)) return 0; if (!position.currentValue) return 1; - if (position.currentValue.currency !== selected) return 0; const value = decimalFromString(position.currentValue.value); if (value.value < 0n) return 1; if (value.value === 0n) return 0; @@ -135,16 +132,6 @@ function compareDecimal(a: Decimal, b: Decimal): number { return left < right ? -1 : 0; } -function concentrationCurrencies(portfolio: Portfolio): string[] { - return [...new Set(portfolio.positions - .filter(position => eligibleTypes.has(position.type)) - .filter(position => position.currentValue - && decimalFromString(position.currentValue.value).value !== 0n) - .map(position => position.currentValue?.currency) - .filter((value): value is string => !!value))] - .toSorted((a, b) => a.localeCompare(b)); -} - function groupIdentity( position: Position, slice: Slice, @@ -214,22 +201,13 @@ function share(part: Decimal, total: Decimal): string { function toConcentration( portfolio: Portfolio, slice: Slice, - currency: null | string, labels: ConcentrationLabels, ): Concentration { - const currencies = concentrationCurrencies(portfolio); - - const selected = currency && currencies.includes(currency) - ? currency - : currencies[0] ?? null; - const groups = new Map(); let unaccounted = 0; for (const [index, position] of portfolio.positions.entries()) { - unaccounted += addPosition( - groups, position, index, slice, labels, selected, - ); + unaccounted += addPosition(groups, position, index, slice, labels); } const ranked = Array.from(groups.values(), group => ({ @@ -278,7 +256,6 @@ function toConcentration( : null; return { - currencies, empty: !total, items: other ? entries.slice(0, TOP) : entries, other, @@ -289,7 +266,6 @@ function toConcentration( } export { - concentrationCurrencies, toConcentration, }; export type { diff --git a/src/pages/account/model/performance.ts b/src/pages/account/model/performance.ts index a2a39b9..d7482f0 100644 --- a/src/pages/account/model/performance.ts +++ b/src/pages/account/model/performance.ts @@ -15,7 +15,6 @@ const reasonLabels: Record = { HISTORY_START_UNKNOWN: "Начало истории счёта неизвестно", INDEX_HISTORY_UNAVAILABLE: "История индекса временно недоступна", NO_SOLUTION: "Расчёт доходности не сходится", - NON_RUB_ACTIVITY: "Есть операции или позиции не в рублях", OPERATIONS_HISTORY_INCOMPLETE: "История операций ещё не получена полностью", PORTFOLIO_VALUATION_INCOMPLETE: "Нет полной оценки портфеля", UNCONFIRMED_TRANSFER: "Перевод между счетами не удалось подтвердить", diff --git a/src/pages/account/model/positions.fixtures.ts b/src/pages/account/model/positions.fixtures.ts index ff4718e..0fc62eb 100644 --- a/src/pages/account/model/positions.fixtures.ts +++ b/src/pages/account/model/positions.fixtures.ts @@ -2,7 +2,7 @@ import type { Portfolio } from "@/entities/portfolio"; const portfolio = { - cashBalances: [{ currency: "USD", value: "1" }], + cash: { currency: "RUB", value: "1" }, isStale: true, lastSuccessfulAt: "2026-08-13T10:00:00Z", positions: [ @@ -49,8 +49,13 @@ const portfolio type: "FUND", }, ] satisfies [Portfolio["positions"][number], ...Portfolio["positions"]], - structure: { currencies: [], unvaluedCategories: [] }, - totalsByCurrency: [{ currency: "RUB", value: "150" }], + structure: { + categories: [], + hasNegativeValues: false, + totalValue: "0", + unvaluedCategories: [], + }, + totals: { currency: "RUB", value: "150" }, } satisfies Portfolio; const position: Portfolio["positions"][number] = { diff --git a/src/pages/account/model/positions.test.ts b/src/pages/account/model/positions.test.ts index e1986d8..f881cff 100644 --- a/src/pages/account/model/positions.test.ts +++ b/src/pages/account/model/positions.test.ts @@ -35,7 +35,7 @@ describe("account position sections", () => { const snapshot: Portfolio = { ...portfolio, - cashBalances: [{ currency: "RUB", value: "1000" }], + cash: { currency: "RUB", value: "1000" }, positions: [ ...portfolio.positions, { @@ -56,8 +56,8 @@ describe("account position sections", () => { }), ), ], - structure: { currencies: [], unvaluedCategories: [] }, - totalsByCurrency: [{ currency: "RUB", value: "1250" }], + structure: { categories: [], hasNegativeValues: false, totalValue: "0", unvaluedCategories: [] }, + totals: { currency: "RUB", value: "1250" }, }; const original = structuredClone(snapshot); @@ -193,13 +193,13 @@ describe("account position sections", () => { ).toHaveLength(2); }); - it("keeps currencies and purchase prices attached to their source positions", () => { + it("keeps values and purchase prices attached to their source positions", () => { const source = [ position, { ...second, - averagePurchasePrice: { currency: "USD", value: "0.99" }, - currentValue: { currency: "USD", value: "1.01" }, + averagePurchasePrice: { currency: "RUB", value: "0.99" }, + currentValue: { currency: "RUB", value: "1.01" }, }, ]; @@ -264,10 +264,6 @@ describe("account position returns", () => { { name: "zero quantity", overrides: { quantity: "0" } }, { name: "negative quantity", overrides: { quantity: "-1" } }, { name: "missing average price", overrides: { averagePurchasePrice: null } }, - { - name: "currency mismatch", - overrides: { averagePurchasePrice: { currency: "USD", value: "280" } }, - }, { name: "zero cost", overrides: { averagePurchasePrice: { currency: "RUB", value: "0" } } }, ])("returns null for $name", ({ overrides }) => { const result = sections([makePosition({ @@ -301,29 +297,24 @@ describe("account position returns", () => { expect(formatPositionReturnPercent(testCase.value)).toBe(testCase.expected); }); - it("sums complete unrealized returns by currency", () => { + it("sums complete unrealized returns", () => { const result = toUnrealizedReturns([ makePosition({ absoluteReturn: { currency: "RUB", value: "0.1" } }), makePosition({ absoluteReturn: { currency: "RUB", value: "0.2" } }), - makePosition({ absoluteReturn: { currency: "USD", value: "-2.5" } }), ]); - expect(result).toEqual([ - { currency: "RUB", value: "0.3" }, - { currency: "USD", value: "-2.5" }, - ]); + expect(result).toEqual({ currency: "RUB", value: "0.3" }); }); - it("omits only currencies with incomplete returns", () => { + it("returns null when any return is incomplete", () => { const result = toUnrealizedReturns([ makePosition({ absoluteReturn: { currency: "RUB", value: "10" } }), makePosition({ absoluteReturn: null, currentValue: { currency: "RUB", value: "100" }, }), - makePosition({ absoluteReturn: { currency: "USD", value: "2" } }), ]); - expect(result).toEqual([{ currency: "USD", value: "2" }]); + expect(result).toBeNull(); }); }); diff --git a/src/pages/account/model/positions.ts b/src/pages/account/model/positions.ts index 5019923..7478774 100644 --- a/src/pages/account/model/positions.ts +++ b/src/pages/account/model/positions.ts @@ -249,37 +249,23 @@ function toSection( function toUnrealizedReturns( positions: Portfolio["positions"], -): Money[] { - const totals = new Map(); +): Money | null { + const values: Decimal[] = []; for (const position of positions) { - const currencies = position.absoluteReturn - ? [position.absoluteReturn.currency] - : [...new Set([ - position.averagePurchasePrice?.currency, - position.currentValue?.currency, - ].filter((currency): currency is string => !!currency))]; + if (!position.absoluteReturn) { + // A position without any valuation has nothing to contribute. - for (const currency of currencies) { - const total = totals.get(currency) ?? { complete: true, values: [] }; + if (!position.averagePurchasePrice && !position.currentValue) + continue; - if (position.absoluteReturn) { - total.values.push(decimalFromString(position.absoluteReturn.value)); - } - else { - total.complete = false; - } - - totals.set(currency, total); + return null; } + + values.push(decimalFromString(position.absoluteReturn.value)); } - return [...totals].flatMap(([currency, total]) => total.complete - ? [{ currency, value: decimalToString(sumDecimals(total.values)) }] - : []); + return { currency: "RUB", value: decimalToString(sumDecimals(values)) }; } export { diff --git a/src/pages/account/model/previews.ts b/src/pages/account/model/previews.ts index 448379b..329f2e2 100644 --- a/src/pages/account/model/previews.ts +++ b/src/pages/account/model/previews.ts @@ -64,7 +64,7 @@ function toIncomeSummary( if (income.meta?.completeness === "incomplete") return { state: "partial", to }; if (!income.data && (income.isError || income.meta?.lastAttempt.state === "failed")) return { state: "error", to }; - if (!income.data?.totalsByCurrency.length) return { state: "empty", to }; + if (!income.data) return { state: "empty", to }; return { state: "loaded", diff --git a/src/pages/account/ui/AccountPage/AccountPage.test.tsx b/src/pages/account/ui/AccountPage/AccountPage.test.tsx index 5ce9057..8d61bc1 100644 --- a/src/pages/account/ui/AccountPage/AccountPage.test.tsx +++ b/src/pages/account/ui/AccountPage/AccountPage.test.tsx @@ -96,7 +96,7 @@ function renderPage(ui: React.ReactNode) { const portfolio = { - cashBalances: [{ currency: "USD", value: "0.5" }], + cash: { currency: "RUB", value: "0.5" }, isStale: true, lastSuccessfulAt: "2026-08-13T10:00:00Z", positions: [ @@ -129,8 +129,8 @@ const portfolio type: "BOND", }, ] satisfies [Portfolio["positions"][number], ...Portfolio["positions"]], - structure: { currencies: [], unvaluedCategories: [] }, - totalsByCurrency: [{ currency: "RUB", value: "1234.5" }], + structure: { categories: [], hasNegativeValues: false, totalValue: "0", unvaluedCategories: [] }, + totals: { currency: "RUB", value: "1234.5" }, } satisfies Portfolio; function position( @@ -201,8 +201,6 @@ const LoadedHarness = ( function loadedProps(nextPortfolio: Portfolio = portfolio) { return { concentration: null, - currency: null, - onCurrencyChange: vi.fn(), onSliceChange: vi.fn(), portfolio: nextPortfolio, positionSections: toAccountPagePositionSections(nextPortfolio), @@ -242,10 +240,10 @@ describe("AccountPage states", () => { HttpResponse.json(resource( { ...portfolio, - cashBalances: [], + cash: { currency: "RUB", value: "0" }, isStale: false, positions: [], - totalsByCurrency: [], + totals: { currency: "RUB", value: "0" }, }, { lastAttempt: { @@ -278,7 +276,7 @@ describe("AccountPage states", () => { const data = { ...portfolio, positions }; const model = toConcentration( - data, "stocks", "RUB", concentrationLabels(), + data, "stocks", concentrationLabels(), ); const props = loadedProps(data); @@ -287,7 +285,6 @@ describe("AccountPage states", () => { {...props} accountId="a" concentration={model} - currency="RUB" tab="concentration" />, ); @@ -326,10 +323,8 @@ describe("AccountPage states", () => { concentration={toConcentration( { ...portfolio, positions }, "stocks", - "RUB", concentrationLabels(), )} - currency="RUB" tab="concentration" />, ); @@ -354,9 +349,8 @@ describe("AccountPage states", () => { {...loadedProps(data)} accountId="a" concentration={toConcentration( - data, "stocks", "RUB", concentrationLabels(), + data, "stocks", concentrationLabels(), )} - currency="RUB" tab="concentration" />, ); @@ -370,9 +364,8 @@ describe("AccountPage states", () => { {...loadedProps(data)} accountId="a" concentration={toConcentration( - data, "issuers", "RUB", concentrationLabels(), + data, "issuers", concentrationLabels(), )} - currency="RUB" slice="issuers" tab="concentration" />, @@ -392,9 +385,8 @@ describe("AccountPage states", () => { {...loadedProps(data)} accountId="a" concentration={toConcentration( - data, "stocks", "RUB", concentrationLabels(), + data, "stocks", concentrationLabels(), )} - currency="RUB" tab="concentration" />, ); @@ -424,9 +416,8 @@ describe("AccountPage states", () => { {...loadedProps(data)} accountId="a" concentration={toConcentration( - data, "sectors", "RUB", concentrationLabels(), + data, "sectors", concentrationLabels(), )} - currency="RUB" slice="sectors" tab="concentration" />, @@ -457,9 +448,8 @@ describe("AccountPage states", () => { {...loadedProps(data)} accountId="a" concentration={toConcentration( - data, "stocks", "RUB", concentrationLabels(), + data, "stocks", concentrationLabels(), )} - currency="RUB" tab="concentration" />, ); @@ -484,9 +474,8 @@ describe("AccountPage states", () => { {...loadedProps(data)} accountId="a" concentration={toConcentration( - data, "stocks", "RUB", concentrationLabels(), + data, "stocks", concentrationLabels(), )} - currency="RUB" partial tab="concentration" />, @@ -496,16 +485,14 @@ describe("AccountPage states", () => { .toHaveLength(1); }); - it("keeps slice and currency while switching page tabs and resets on account change", async () => { + it("keeps the slice while switching page tabs and resets on account change", async () => { const multiCurrency = { ...portfolio, positions: - ["RUB", "USD"].flatMap(currency => - [400, 250, 150, 100, 50, 30, 20].map((value, index) => - position({ - currentValue: { currency, value: String(value) }, - displayName: `${currency} акция ${index + 1}`, - sector: "energy", type: "STOCK", - }), - ), + [400, 250, 150, 100, 50, 30, 20].map((value, index) => + position({ + currentValue: { currency: "RUB", value: String(value) }, + displayName: `Акция ${index + 1}`, + sector: "energy", type: "STOCK", + }), ) }; server.use( @@ -520,37 +507,27 @@ describe("AccountPage states", () => { fireEvent.click(screen.getByRole("button", { name: /Прочее/ })); expect(screen.getByRole("button", { name: /Прочее/ })) .toHaveAttribute("aria-expanded", "true"); - fireEvent.click(screen.getByRole("combobox", { name: "Валюта концентрации" })); - fireEvent.click(screen.getByRole("option", { name: "USD" })); - expect(screen.getByRole("button", { name: /Прочее/ })) - .toHaveAttribute("aria-expanded", "false"); fireEvent.click(screen.getByRole("button", { name: /Прочее/ })); fireEvent.click(screen.getByRole("radio", { name: "Секторы" })); fireEvent.click(screen.getByRole("radio", { name: "Акции" })); expect(screen.getByRole("button", { name: /Прочее/ })) .toHaveAttribute("aria-expanded", "false"); fireEvent.click(screen.getByRole("radio", { name: "Секторы" })); - expect(screen.getByRole("combobox", { name: "Валюта концентрации" })) - .toHaveValue("USD"); fireEvent.click(screen.getByRole("tab", { name: "Позиции" })); fireEvent.click(screen.getByRole("tab", { name: "Концентрация" })); expect(screen.getByRole("radio", { name: "Секторы" })) .toBeChecked(); - expect(screen.getByRole("combobox", { name: "Валюта концентрации" })) - .toHaveValue("USD"); fireEvent.click(screen.getByRole("button", { name: "Другой счёт" })); fireEvent.click(await screen.findByRole("tab", { name: "Концентрация" })); expect(screen.getByRole("radio", { name: "Акции" })).toBeChecked(); - expect(screen.getByRole("combobox", { name: "Валюта концентрации" })) - .toHaveValue("RUB"); }); - it("falls back to the first currency when the selected one disappears", () => { + it("includes all ruble positions in concentration", () => { const data = { ...portfolio, positions: [ position({ - currentValue: { currency: "USD", value: "200" }, + currentValue: { currency: "RUB", value: "200" }, }), position({ currentValue: { currency: "RUB", value: "100" }, @@ -558,12 +535,11 @@ describe("AccountPage states", () => { ] }; const model = toConcentration( - data, "stocks", "EUR", concentrationLabels(), + data, "stocks", concentrationLabels(), ); - expect(model.currencies).toEqual(["RUB", "USD"]); - expect(model.total).toBe("100"); - expect(model.items.map(item => item.amount)).toEqual(["100"]); + expect(model.total).toBe("300"); + expect(model.items.map(item => item.amount)).toEqual(["200", "100"]); }); it("shows the loaded portfolio source sync status", async () => { server.use( @@ -679,7 +655,7 @@ describe("AccountPage states", () => { ).toHaveTextContent("Данные о структуре портфеля пока недоступны."); expect( screen.getAllByRole("definition").map(node => node.textContent), - ).toEqual(["1 234,5 ₽"]); + ).toEqual(["1 234,50 ₽"]); }); it("keeps initial placeholders decorative without amounts or focus targets", () => { const { container } = renderPage(); @@ -722,11 +698,7 @@ describe("AccountPage states", () => { { type: "CURRENCY", }, ], - structure: { currencies: [], unvaluedCategories: [] }, + structure: { categories: [], hasNegativeValues: false, totalValue: "0", unvaluedCategories: [] }, })} />, ); @@ -750,14 +722,12 @@ describe("AccountPage states", () => { expect( balances.getAllByRole("definition").map(node => node.textContent), - ).toEqual(["234,500000000000000001 ₽", "-0,5 $", "3,00 XYZ"]); + ).toEqual(["234,50 ₽"]); expect( screen.getAllByRole("definition").map(node => node.textContent), ).toEqual([ - "1 234,5 ₽", - "234,500000000000000001 ₽", - "-0,5 $", - "3,00 XYZ", + "1 234,50 ₽", + "234,50 ₽", ]); expect(screen.queryByText("Торгуемая валюта")).not.toBeInTheDocument(); expect( @@ -774,7 +744,7 @@ describe("AccountPage states", () => { resource({ ...portfolio, positions: hasPositions ? portfolio.positions : [], - totalsByCurrency: [], + totals: { currency: "RUB", value: "0" }, }), ), ), @@ -782,20 +752,20 @@ describe("AccountPage states", () => { renderAccountRoute(createTestQueryClient()); await screen.findByRole("heading", { level: 1, name: "Портфель" }); fireEvent.click(screen.getByRole("tab", { name: "Деньги" })); - expect(screen.getByRole("definition")).toHaveTextContent("0,5 $"); + expect(screen.getByText("0,50 ₽")).toBeInTheDocument(); expect(screen.queryByText("Портфель пока пуст")).not.toBeInTheDocument(); }, ); it.each([ - { totalsByCurrency: [] }, - { totalsByCurrency: [{ currency: "RUB", value: "100" }] }, + { empty: true, totals: { currency: "RUB", value: "0" } }, + { empty: false, totals: { currency: "RUB", value: "100" } }, ])( - "hides the empty other section without emptying a currency-only portfolio (totals: $totalsByCurrency)", - async ({ totalsByCurrency }) => { + "hides the empty other section without emptying a currency-only portfolio (totals: $totals.value)", + async ({ empty, totals }) => { const snapshot: Portfolio = { ...portfolio, - cashBalances: [], + cash: { currency: "RUB", value: "0" }, positions: [ { ...portfolio.positions[0], @@ -804,8 +774,8 @@ describe("AccountPage states", () => { type: "CURRENCY", }, ], - structure: { currencies: [], unvaluedCategories: [] }, - totalsByCurrency, + structure: { categories: [], hasNegativeValues: false, totalValue: "0", unvaluedCategories: [] }, + totals, }; server.use( @@ -826,11 +796,11 @@ describe("AccountPage states", () => { expect( within( screen.getByRole("tabpanel", { name: "Деньги" }), - ).queryByRole("definition"), - ).not.toBeInTheDocument(); + ).getByRole("definition"), + ).toHaveTextContent("0,00 ₽"); expect( screen.queryAllByRole("definition").map(node => node.textContent), - ).toEqual(totalsByCurrency.length > 0 ? ["100 ₽"] : []); + ).toEqual([empty ? "0,00 ₽" : "100,00 ₽", "0,00 ₽"]); expect( queryClient.getQueryData(portfolioQueryOptions("account-1").queryKey), ).toEqual(resource(snapshot)); @@ -1147,7 +1117,7 @@ describe("AccountPage states", () => { date: "2026-09-11", dateKind: "registry", displayName: "Акция", - projectedAmount: { currency: "USD", value: "0" }, + projectedAmount: { currency: "RUB", value: "0" }, type: "dividend", }, { @@ -1191,7 +1161,7 @@ describe("AccountPage states", () => { expect( content.getByText("11.09.2026 · Дата реестра · RU000A0JX0J2"), ).toBeVisible(); - for (const amount of ["850,50 ₽", "0 $", "1 000 ₽", "Сумма неизвестна"]) + for (const amount of ["850,50 ₽", "0 ₽", "1 000 ₽", "Сумма неизвестна"]) expect(content.getByText(amount)).toBeVisible(); expect(content.getAllByText("Прогноз")).toHaveLength(4); expect(content.queryByText("Без движения денег")).not.toBeInTheDocument(); @@ -1277,7 +1247,7 @@ describe("AccountPage states", () => { return HttpResponse.json(resource( requests === 1 ? portfolio - : { ...portfolio, totalsByCurrency: [{ currency: "RUB", value: "999" }] }, + : { ...portfolio, totals: { currency: "RUB", value: "999" } }, { resourceKey: "portfolio" }, )); }), @@ -1301,14 +1271,14 @@ describe("AccountPage states", () => { expect(screen.getByText("Не удалось обновить данные")).toBeInTheDocument(); expect(screen.getByText("Вторая")).toBeInTheDocument(); - expect(screen.getByText("1 234,5 ₽")).toBeInTheDocument(); + expect(screen.getByText("1 234,50 ₽")).toBeInTheDocument(); // One cached read plus the three automatic attempts. expect(requests).toBe(4); isFailing = false; fireEvent.click(retry); - expect(await screen.findByText("999 ₽")).toBeInTheDocument(); + expect(await screen.findByText("999,00 ₽")).toBeInTheDocument(); expect(requests).toBe(5); expect(screen.queryByText("Не удалось обновить данные")) .not.toBeInTheDocument(); @@ -1370,14 +1340,9 @@ describe("AccountPage states", () => { = { ...portfolio, structure: { - currencies: [ - { - categories: [{ sharePercent: null, type: "BOND", value: "10" }], - currency: "RUB", - hasNegativeValues: false, - totalValue: "10", - }, - ], + categories: [{ sharePercent: null, type: "BOND", value: "10" }], + hasNegativeValues: false, + totalValue: "10", unvaluedCategories: [{ count: 1, type: "STOCK" }], }, }; @@ -1428,7 +1393,7 @@ describe("AccountPage states", () => { expect(screen.getByText("Вторая")).toBeInTheDocument(); expect( screen.getAllByRole("definition").map(node => node.textContent), - ).toEqual(["1 234,5 ₽"]); + ).toEqual(["1 234,50 ₽"]); expect(screen.queryByRole("button", { name: "Повторить" })) .not.toBeInTheDocument(); void queryClient.invalidateQueries({ @@ -1438,17 +1403,17 @@ describe("AccountPage states", () => { expect(structure).toHaveTextContent("10 ₽"); expect( screen.getAllByRole("definition").map(node => node.textContent), - ).toEqual(["1 234,5 ₽"]); + ).toEqual(["1 234,50 ₽"]); resolveRetry?.( HttpResponse.json( resource( { ...portfolio, - cashBalances: [{ currency: "EUR", value: "2.50" }], + cash: { currency: "RUB", value: "2.50" }, isStale: false, lastSuccessfulAt: "2026-08-14T11:30:00Z", - structure: { currencies: [], unvaluedCategories: [] }, - totalsByCurrency: [{ currency: "USD", value: "-0.5" }], + structure: { categories: [], hasNegativeValues: false, totalValue: "0", unvaluedCategories: [] }, + totals: { currency: "RUB", value: "-0.5" }, }, { dataRevision: "2", resourceKey: "portfolio" }, ), @@ -1457,7 +1422,7 @@ describe("AccountPage states", () => { await waitFor(() => expect( screen.getAllByRole("definition").map(node => node.textContent), - ).toEqual(["-0,5 $"]), + ).toEqual(["-0,50 ₽"]), ); expect(screen.getByText(/^Данные на /)).toHaveTextContent("Данные на"); expect(structure).not.toHaveTextContent("10 ₽"); @@ -1499,7 +1464,7 @@ describe("AccountPage states", () => { expect(screen.getByText("Вторая")).toBeInTheDocument(); expect( screen.getAllByRole("definition").map(node => node.textContent), - ).toEqual(["1 234,5 ₽"]); + ).toEqual(["1 234,50 ₽"]); expect(screen.getByText(/^Данные на /)).toBeInTheDocument(); expect( screen.queryByRole("status", { name: "Загрузка" }), diff --git a/src/pages/account/ui/AccountPage/AccountPage.tsx b/src/pages/account/ui/AccountPage/AccountPage.tsx index 0b74da4..83ee4e0 100644 --- a/src/pages/account/ui/AccountPage/AccountPage.tsx +++ b/src/pages/account/ui/AccountPage/AccountPage.tsx @@ -28,9 +28,7 @@ const AccountPage: FC = () => { account={vm.account} accountId={vm.accountId} concentration={vm.concentration} - currency={vm.currency} incomeSummary={vm.incomeSummary} - onCurrencyChange={vm.onCurrencyChange} onSliceChange={vm.onSliceChange} onTabChange={vm.onTabChange} partial={vm.partial} diff --git a/src/pages/account/ui/AccountPage/AccountPage.vm.test.tsx b/src/pages/account/ui/AccountPage/AccountPage.vm.test.tsx index 9915fae..5585a0b 100644 --- a/src/pages/account/ui/AccountPage/AccountPage.vm.test.tsx +++ b/src/pages/account/ui/AccountPage/AccountPage.vm.test.tsx @@ -187,7 +187,7 @@ it("keeps overview sections independent when their source data is incomplete", a http.get("*/api/v1/brokerage-accounts/:accountId/events", () => HttpResponse.json( resource( - { events: [], incomeTotalsByCurrency: [] }, + { events: [], incomeTotal: { currency: "RUB", value: "0" } }, { completeness: "incomplete" }, ), ), @@ -213,8 +213,8 @@ it("keeps overview sections loaded without exposing technical retry", async () = const sections: Record = { - "events": { events: [], incomeTotalsByCurrency: [] }, - "income-summary": { totalsByCurrency: [] }, + "events": { events: [], incomeTotal: { currency: "RUB", value: "0" } }, + "income-summary": { total: { coupon: { currency: "RUB", value: "0" }, dividend: { currency: "RUB", value: "0" }, total: { currency: "RUB", value: "0" } } }, "operations": { hasNext: false, items: [], nextCursor: null }, portfolio, }; @@ -238,7 +238,7 @@ it("keeps overview sections loaded without exposing technical retry", async () = await waitFor(() => expect(result.current).toMatchObject({ - incomeSummary: { state: "empty" }, + incomeSummary: { state: "loaded" }, recentEvents: { state: "empty" }, recentOperations: { state: "empty" }, state: "loaded", diff --git a/src/pages/account/ui/AccountPage/AccountPage.vm.ts b/src/pages/account/ui/AccountPage/AccountPage.vm.ts index 8f0106e..bbcfc57 100644 --- a/src/pages/account/ui/AccountPage/AccountPage.vm.ts +++ b/src/pages/account/ui/AccountPage/AccountPage.vm.ts @@ -21,7 +21,6 @@ import { useInitialAvailability } from "@/shared/lib"; import { type Concentration, - concentrationCurrencies, type ConcentrationLabels, type Slice, toConcentration, @@ -57,8 +56,6 @@ type AccountPageEmptyViewModel = AccountPageContent & { type AccountPageLoadedViewModel = AccountPageContent & { concentration: Concentration | null; - currency: null | string; - onCurrencyChange: (currency: null | string) => void; onSliceChange: (slice: Slice) => void; onTabChange: (tab: null | string) => void; portfolio: Portfolio; @@ -66,7 +63,9 @@ type AccountPageLoadedViewModel = AccountPageContent & { slice: Slice; state: "loaded"; tab: string; - unrealizedReturns: Portfolio["totalsByCurrency"]; + unrealizedReturns: + | null + | { currency: string; value: string }; }; type AccountPageReadRetry = ReturnType; @@ -83,15 +82,14 @@ function useAccountPageVm(): AccountPageViewModel { const [selection, setSelection] = useState<{ accountId?: string; - currency: null | string; slice: Slice; tab: string; - }>({ accountId, currency: null, slice: "stocks", tab: "positions" }); + }>({ accountId, slice: "stocks", tab: "positions" }); const current = selection.accountId === accountId ? selection : { - accountId, currency: null, slice: "stocks" as const, tab: "positions", + accountId, slice: "stocks" as const, tab: "positions", }; const updateSelection = (change: Partial) => @@ -144,7 +142,7 @@ function useAccountPageVm(): AccountPageViewModel { if ( portfolio.meta?.availability === "available" && portfolio.data.positions.length === 0 - && portfolio.data.cashBalances.length === 0 + && Number(portfolio.data.cash.value) === 0 ) return { account, @@ -158,11 +156,6 @@ function useAccountPageVm(): AccountPageViewModel { }; const isPartial = portfolio.meta?.completeness === "incomplete"; - const currencies = concentrationCurrencies(portfolio.data); - - const currency = current.currency && currencies.includes(current.currency) - ? current.currency - : currencies[0] ?? null; const labels: ConcentrationLabels = { missingIssuer: t("portfolio.concentration.missingIssuer"), @@ -173,16 +166,14 @@ function useAccountPageVm(): AccountPageViewModel { const concentration = accountId !== "all" && current.tab === "concentration" - ? toConcentration(portfolio.data, current.slice, currency, labels) + ? toConcentration(portfolio.data, current.slice, labels) : null; return { account, accountId, concentration, - currency, incomeSummary, - onCurrencyChange: value => updateSelection({ currency: value }), onSliceChange: slice => updateSelection({ slice }), onTabChange: tab => updateSelection({ tab: tab ?? "positions" }), partial: isPartial, diff --git a/src/pages/account/ui/AccountPage/components/AccountPageEmpty.view.tsx b/src/pages/account/ui/AccountPage/components/AccountPageEmpty.view.tsx index 41a688c..2f32145 100644 --- a/src/pages/account/ui/AccountPage/components/AccountPageEmpty.view.tsx +++ b/src/pages/account/ui/AccountPage/components/AccountPageEmpty.view.tsx @@ -55,7 +55,12 @@ const AccountPageEmpty: FC = (props) => { void; onSliceChange: (slice: Slice) => void; onTabChange: (tab: null | string) => void; partial?: boolean; @@ -53,7 +50,9 @@ type AccountPageLoadedProps = { slice: Slice; source?: InvestmentResourceMeta; tab: string; - unrealizedReturns: Portfolio["totalsByCurrency"]; + unrealizedReturns: + | null + | { currency: string; value: string }; }; const AccountPageLoaded: FC = (props) => { @@ -94,9 +93,23 @@ const AccountPageLoaded: FC = (props) => { ? tab !== "concentration" && ( {t("investment.partial")} ) - : } + : ( + +
+ {t("portfolio.total")} +
- {!props.partial && props.unrealizedReturns.length > 0 && ( +
+ + {formatMoney(props.portfolio.totals, { + withRoundedCents: true, + })} + +
+
+ )} + + {!props.partial && props.unrealizedReturns && ( = (props) => { {t("portfolio.unrealizedResult")} - {props.unrealizedReturns.map(total => ( - {formatMoney(total)} - ))} + {formatMoney(props.unrealizedReturns)} @@ -174,10 +185,8 @@ const AccountPageLoaded: FC = (props) => { {props.concentration && ( = (props) => { )} - + +
+ {t("portfolio.cashTitle")} +
+ +
+ + {formatMoney(props.portfolio.cash, { withRoundedCents: true })} + +
+
diff --git a/src/pages/account/ui/AccountPage/components/Concentration/Concentration.view.tsx b/src/pages/account/ui/AccountPage/components/Concentration/Concentration.view.tsx index 9f9783a..fe5b85e 100644 --- a/src/pages/account/ui/AccountPage/components/Concentration/Concentration.view.tsx +++ b/src/pages/account/ui/AccountPage/components/Concentration/Concentration.view.tsx @@ -1,6 +1,6 @@ import { DonutChart } from "@mantine/charts"; import { - Alert, Box, Group, Paper, SegmentedControl, Select, Stack, + Alert, Box, Group, Paper, SegmentedControl, Stack, Text, Title, } from "@mantine/core"; import { type FC, useId, useState } from "react"; @@ -18,9 +18,7 @@ import { PositionLogo } from "../PositionLogo.view"; import classes from "./Concentration.module.css"; type Props = { - currency: null | string; model: ConcentrationModel; - onCurrencyChange: (currency: null | string) => void; onSliceChange: (slice: Slice) => void; partial: boolean; slice: Slice; @@ -35,7 +33,6 @@ const sliceLabels = { type RowProps = { color: string; - currency: string; index: number; item: ConcentrationItem; logo?: ConcentrationItem["logo"]; @@ -50,8 +47,8 @@ const colors = [ const PERCENT = 100; const OTHER_START = 6; -function amountFor(item: ConcentrationItem, currency: string): string { - return formatMoney({ currency, value: item.amount }); +function amountFor(item: ConcentrationItem): string { + return formatMoney({ currency: "RUB", value: item.amount }); } const ConcentrationRow: FC = props => ( @@ -83,7 +80,7 @@ const ConcentrationRow: FC = props => ( - {amountFor(props.item, props.currency)} + {amountFor(props.item)} {props.item.share} @@ -131,7 +128,6 @@ const Concentration: FC = (props) => { const [expanded, setExpanded] = useState(false); const otherId = useId(); const chartId = useId(); - const currency = props.currency ?? ""; const rows = props.model.other ? [...props.model.items, props.model.other] @@ -147,10 +143,8 @@ const Concentration: FC = (props) => { props.model.top5Share && t("portfolio.concentration.top5", { share: props.model.top5Share, }), - props.model.total && formatMoney({ - currency, value: props.model.total, - }), - ...rows.map(item => `${item.label}: ${amountFor(item, currency)}, ${item.share}`), + props.model.total && formatMoney({ currency: "RUB", value: props.model.total }), + ...rows.map(item => `${item.label}: ${amountFor(item)}, ${item.share}`), ].filter(Boolean).join("; "); const slices = [ @@ -170,7 +164,7 @@ const Concentration: FC = (props) => { const pieProps = { isAnimationActive: false, label: rankLabel }; const tooltipRows = rows.map(item => ({ - amount: amountFor(item, currency), + amount: amountFor(item), label: item.label, share: item.share, })); @@ -178,7 +172,7 @@ const Concentration: FC = (props) => { const tooltipProps = { content: }; const totalText = props.model.total - ? formatMoney({ currency, value: props.model.total }) + ? formatMoney({ currency: "RUB", value: props.model.total }) : ""; return ( @@ -192,16 +186,6 @@ const Concentration: FC = (props) => { value={props.slice} /> - {props.model.currencies.length > 1 && ( - - )} @@ -110,11 +96,11 @@ const PortfolioStructureView: FC = (props) => { )} -
+
{props.chartData.length > 0 && ( { const { structure } = props; const { t } = useTranslation(); - const [currency, setCurrency] = useState(null); - - const selected - = structure?.currencies.find(item => item.currency === currency) - ?? structure?.currencies[0]; const isIncomplete = (structure?.unvaluedCategories.length ?? 0) > 0; - const rows = portfolioRows(selected, structure, t); + const rows = portfolioRows(structure, t); const chartData = rows.filter(row => row.value > 0); return { chartData, - currencies: structure?.currencies.map(item => item.currency) ?? [], - - currency: selected?.currency ?? null, incomplete: isIncomplete, - negative: selected?.hasNegativeValues ?? false, - onCurrencyChange: setCurrency, + negative: structure?.hasNegativeValues ?? false, rows, unavailable: !structure, unvalued: @@ -46,14 +36,12 @@ const usePortfolioStructureVm = (props: PortfolioStructureProps) => { }; function portfolioRows( - selected: - NonNullable["currencies"][number] | undefined, structure: PortfolioStructureProps["structure"], t: TFunction, ) { - if (!selected) return []; + if (!structure) return []; - return selected.categories.map((category) => { + return structure.categories.map((category) => { const presentation = portfolioCategoryPresentation[category.type]; const percentage @@ -61,16 +49,13 @@ function portfolioRows( ? undefined : Number(category.sharePercent); - const unknown = structure?.unvaluedCategories.some( + const isUnknown = structure.unvaluedCategories.some( item => item.type === category.type, ); - let amount = formatMoney({ - currency: selected.currency, - value: category.value, - }); + let amount = formatMoney({ currency: "RUB", value: category.value }); - if (unknown) { + if (isUnknown) { amount = category.value === "0" ? t("portfolio.structure.unvalued") diff --git a/src/pages/accounts/ui/AccountsPage/AccountsPage.test.tsx b/src/pages/accounts/ui/AccountsPage/AccountsPage.test.tsx index 67a7293..d410ef2 100644 --- a/src/pages/accounts/ui/AccountsPage/AccountsPage.test.tsx +++ b/src/pages/accounts/ui/AccountsPage/AccountsPage.test.tsx @@ -31,16 +31,26 @@ function configureTestAuth() { }); } -function portfolioSummary(totals: { currency: string; value: string }[]) { +function portfolioSummary(totals: { currency: string; value: string }) { return { bondPositionCount: 1, - cashBalances: [], + cash: { currency: "RUB", value: "0" }, fundPositionCount: 0, stockPositionCount: 0, - structure: { currencies: [], unvaluedCategories: [] }, + structure: { + categories: [], + hasNegativeValues: false, + totalValue: "0", + unvaluedCategories: [], + }, totalPositionCount: 1, - totalsByCurrency: totals, - unrealizedReturnByCurrency: [], + totals, + unrealizedReturn: { + absolute: "0", + costBasis: "0", + partial: false, + percent: null, + }, }; } @@ -59,23 +69,18 @@ function renderPage(queryClient: QueryClient) { } describe("AccountsPage", () => { - it("summarizes currencies separately and reports excluded accounts", async () => { + it("summarizes account totals and reports excluded accounts", async () => { server.use(http.get("*/api/v1/brokerage-accounts", () => HttpResponse.json(resource([ { ...account, - portfolioSummary: portfolioSummary([ - { currency: "RUB", value: "100.10" }, - { currency: "USD", value: "20" }, - ]), + portfolioSummary: portfolioSummary({ currency: "RUB", value: "100.10" }), }, { ...account, id: "account-2", name: "Второй", - portfolioSummary: portfolioSummary([ - { currency: "RUB", value: "50.20" }, - ]), + portfolioSummary: portfolioSummary({ currency: "RUB", value: "50.20" }), }, { ...account, @@ -88,9 +93,9 @@ describe("AccountsPage", () => { id: "account-4", name: "Только деньги", portfolioSummary: { - ...portfolioSummary([{ currency: "RUB", value: "25" }]), + ...portfolioSummary({ currency: "RUB", value: "25" }), bondPositionCount: 0, - cashBalances: [{ currency: "RUB", value: "25" }], + cash: { currency: "RUB", value: "25" }, totalPositionCount: 0, }, }, @@ -99,7 +104,7 @@ describe("AccountsPage", () => { id: "account-5", name: "Без сумм", portfolioSummary: { - ...portfolioSummary([]), + ...portfolioSummary({ currency: "RUB", value: "0" }), totalPositionCount: 0, }, }, @@ -110,7 +115,6 @@ describe("AccountsPage", () => { renderPage(client); expect(await screen.findByText("175,30 ₽")).toBeInTheDocument(); - expect(screen.getAllByText("20,00 $")).not.toHaveLength(0); expect(screen.queryByText("Ваши инвестиции по счетам")) .not.toBeInTheDocument(); expect(screen.getByText("5 счетов")).toBeInTheDocument(); diff --git a/src/pages/accounts/ui/AccountsPage/components/AccountsPageLoaded.view.tsx b/src/pages/accounts/ui/AccountsPage/components/AccountsPageLoaded.view.tsx index 6ef0deb..e1ddd69 100644 --- a/src/pages/accounts/ui/AccountsPage/components/AccountsPageLoaded.view.tsx +++ b/src/pages/accounts/ui/AccountsPage/components/AccountsPageLoaded.view.tsx @@ -25,34 +25,16 @@ type AccountsPageLoadedProps = { readRetry?: ReadRetryProps; }; -type Money = { currency: string; value: string }; - -function accountTotals(accounts: BrokerageAccountCard[]) { - const values = new Map(); - let excludedCount = 0; +function accountTotals(accounts: BrokerageAccountCard[]): null | string { + const values: string[] = []; for (const { summary } of accounts) { - if (summary.state === "empty") continue; + if (summary.state !== "available") continue; - if (summary.state === "unavailable") { - excludedCount += 1; - continue; - } - - for (const total of summary.portfolio.totalsByCurrency) { - values.set(total.currency, [ - ...values.get(total.currency) ?? [], - total.value, - ]); - } + values.push(summary.portfolio.totals.value); } - const totals: Money[] = Array.from(values, ([currency, amounts]) => ({ - currency, - value: sumDecimals(amounts), - })); - - return { excludedCount, totals }; + return values.length > 0 ? sumDecimals(values) : null; } function sumDecimals(values: string[]) { @@ -88,9 +70,11 @@ function sumDecimals(values: string[]) { const AccountsPageLoaded: FC = (props) => { const { t } = useTranslation(); - const { excludedCount, totals } = accountTotals(props.accounts); - const primaryTotal = totals.find(total => total.currency === "RUB") ?? totals[0]; - const otherTotals = totals.filter(total => total !== primaryTotal); + const total = accountTotals(props.accounts); + + const excludedCount = props.accounts.filter( + account => account.summary.state === "unavailable", + ).length; return ( @@ -101,25 +85,15 @@ const AccountsPageLoaded: FC = (props) => { {t("accounts.currentValue")} - {primaryTotal + {total === null ? ( - - {formatMoney(primaryTotal, { withRoundedCents: true })} - + {t("accounts.summaryNoTotals")} ) : ( - {t("accounts.summaryNoTotals")} - )} - - {otherTotals.length > 0 && ( - - {otherTotals.map(total => ( - - {formatMoney(total, { withRoundedCents: true })} + + {formatMoney({ currency: "RUB", value: total }, { withRoundedCents: true })} - ))} - - )} + )} diff --git a/src/pages/stock/ui/StockPage/StockPage.test.tsx b/src/pages/stock/ui/StockPage/StockPage.test.tsx index a64e6b3..138dcad 100644 --- a/src/pages/stock/ui/StockPage/StockPage.test.tsx +++ b/src/pages/stock/ui/StockPage/StockPage.test.tsx @@ -258,17 +258,10 @@ function setup( } describe("stock page", () => { - it("preserves unknown and unambiguous currencies with compact, ratio and percent formatting", async () => { + it("formats RUB money, ratio and percent metrics", async () => { setup( { metrics: [ - { - currency: null, - key: "marketCapitalization", - period: null, - unit: "money", - value: 1_234_567.89, - }, { currency: "RUB", key: "revenueTtm", @@ -276,20 +269,6 @@ describe("stock page", () => { unit: "money", value: 1_234_567.89, }, - { - currency: "USD", - key: "ebitdaTtm", - period: "TTM", - unit: "money", - value: 1_234_567.89, - }, - { - currency: "CAD", - key: "netIncomeTtm", - period: "TTM", - unit: "money", - value: 1_234_567.89, - }, { currency: null, key: "peRatioTtm", @@ -307,14 +286,8 @@ describe("stock page", () => { ], }, ); - expect(await screen.findByText("1,23 млн валюта неизвестна")).toBeVisible(); - for (const value of [ - "1,23 млн ₽", - "1,23 млн USD", - "1,23 млн CAD", - "5,68 ×", - "12,35 %", - ]) + expect(await screen.findByText("1,23 млн ₽")).toBeVisible(); + for (const value of ["5,68 ×", "12,35 %"]) expect(screen.getByText(value)).toBeVisible(); }); it("opens a direct ticker URL and uses its resolved UID for market and personal requests", async () => { diff --git a/src/pages/stock/ui/StockPage/components/StockCompany.view.tsx b/src/pages/stock/ui/StockPage/components/StockCompany.view.tsx index 500a725..4cc0666 100644 --- a/src/pages/stock/ui/StockPage/components/StockCompany.view.tsx +++ b/src/pages/stock/ui/StockPage/components/StockCompany.view.tsx @@ -37,7 +37,7 @@ const StockCompany: FC = (props) => { {props.company.metrics.map((metric) => { const label = [t(`stock.metrics.${metric.key}`), metric.period].filter(Boolean).join(" · "); const formatter = metric.unit === "money" ? compactMetricFormatter : metricFormatter; - const value = `${formatter.format(metric.value)} ${metricUnit(metric, t("stock.currencyUnknown"))}`; + const value = `${formatter.format(metric.value)} ${metricUnit(metric)}`; return ( @@ -89,14 +89,9 @@ const StockCompany: FC = (props) => { ); }; -function metricUnit( - metric: NonNullable["metrics"][number], - unknownCurrency: string, -) { +function metricUnit(metric: NonNullable["metrics"][number]) { if (metric.unit === "money") - return metric.currency === "RUB" - ? "₽" - : (metric.currency ?? unknownCurrency); + return "₽"; return metric.unit === "percent" ? "%" : "×"; } diff --git a/src/pages/stocks/ui/StocksPage/StocksPage.view.tsx b/src/pages/stocks/ui/StocksPage/StocksPage.view.tsx index d380ff9..ededbb7 100644 --- a/src/pages/stocks/ui/StocksPage/StocksPage.view.tsx +++ b/src/pages/stocks/ui/StocksPage/StocksPage.view.tsx @@ -69,7 +69,6 @@ const criteriaFields: CriteriaField[] = [ "SHARE_TYPE_PREFERRED", ], }, - { label: "currency", options: ["RUB", "USD", "EUR", "CNY", "HKD", "TRY"] }, { label: "realExchange", options: [ @@ -184,10 +183,6 @@ const StockCard: FC = (props) => { {exchange && {exchange}} - {item.currency && ( - {item.currency} - )} - {lot && {lot}} diff --git a/src/pages/stocks/ui/StocksPage/StocksPage.vm.ts b/src/pages/stocks/ui/StocksPage/StocksPage.vm.ts index 81f6986..3d67807 100644 --- a/src/pages/stocks/ui/StocksPage/StocksPage.vm.ts +++ b/src/pages/stocks/ui/StocksPage/StocksPage.vm.ts @@ -4,7 +4,6 @@ import { apiClient } from "@/shared/api"; type Criteria = { countryOfRisk: string; - currency: string; maxLot: string; query: string; realExchange: string; @@ -18,7 +17,6 @@ type StocksPageVm = ReturnType; const emptyCriteria: Criteria = { countryOfRisk: "", - currency: "", maxLot: "", query: "", realExchange: "", @@ -30,7 +28,6 @@ async function createSearch(criteria: Criteria) { const { data } = await apiClient.POST("/api/v1/stocks/screener/search", { body: { ...(criteria.countryOfRisk && { countryOfRisk: criteria.countryOfRisk }), - ...(criteria.currency && { currency: criteria.currency }), ...(criteria.maxLot && { maxLot: Number(criteria.maxLot) }), ...(criteria.query && { query: criteria.query }), ...(criteria.realExchange && { realExchange: criteria.realExchange }), diff --git a/src/shared/api/generated/schema.d.ts b/src/shared/api/generated/schema.d.ts index 0232dd1..93806b8 100644 --- a/src/shared/api/generated/schema.d.ts +++ b/src/shared/api/generated/schema.d.ts @@ -984,25 +984,21 @@ export interface components { /** @description Percentage including cash, rounded to six decimal places. Null for incomplete, negative or zero-total compositions. */ sharePercent: string | null; }; - PortfolioStructureCurrencyDto: { - currency: string; - totalValue: string; - /** @description Any negative constituent, even if netted positive. */ - hasNegativeValues: boolean; - categories: components["schemas"]["PortfolioStructureCategoryDto"][]; - }; PortfolioUnvaluedCategoryDto: { /** @enum {string} */ type: "STOCK" | "BOND" | "OTHER"; count: number; }; PortfolioStructureDto: { - currencies: components["schemas"]["PortfolioStructureCurrencyDto"][]; - /** @description Positions without current value and known valuation currency. Shares are unavailable across all currencies when nonempty. */ + /** @description Exact known total as a decimal string. */ + totalValue: string; + /** @description Any negative constituent, even if netted positive. */ + hasNegativeValues: boolean; + categories: components["schemas"]["PortfolioStructureCategoryDto"][]; + /** @description Positions without current value. Shares are unavailable when nonempty. */ unvaluedCategories: components["schemas"]["PortfolioUnvaluedCategoryDto"][]; }; UnrealizedReturnDto: { - currency: string; /** @example 12.345678901 */ absolute: string; /** @example 100.00 */ @@ -1012,10 +1008,10 @@ export interface components { partial: boolean; }; PortfolioSummaryDto: { - totalsByCurrency: components["schemas"]["MoneyDto"][]; - cashBalances: components["schemas"]["MoneyDto"][]; + totals: components["schemas"]["MoneyDto"]; + cash: components["schemas"]["MoneyDto"]; structure: components["schemas"]["PortfolioStructureDto"]; - unrealizedReturnByCurrency: components["schemas"]["UnrealizedReturnDto"][]; + unrealizedReturn: components["schemas"]["UnrealizedReturnDto"]; /** @description Number of non-zero stock positions, not the number of units. */ stockPositionCount: number; /** @description Number of non-zero bond positions, not the number of units. */ @@ -1145,9 +1141,9 @@ export interface components { /** Format: date-time */ lastSuccessfulAt: string; isStale: boolean; - totalsByCurrency: components["schemas"]["MoneyDto"][]; + totals: components["schemas"]["MoneyDto"]; positions: components["schemas"]["PortfolioPositionDto"][]; - cashBalances: components["schemas"]["MoneyDto"][]; + cash: components["schemas"]["MoneyDto"]; structure: components["schemas"]["PortfolioStructureDto"]; }; PortfolioResourceDto: { @@ -1162,7 +1158,7 @@ export interface components { days: number; }; /** @enum {string} */ - PerformanceUnavailableReason: "ACCOUNT_VALUATION_DATES_DIFFER" | "AMBIGUOUS_RETURN" | "HISTORY_START_UNKNOWN" | "INDEX_HISTORY_UNAVAILABLE" | "NON_RUB_ACTIVITY" | "NO_SOLUTION" | "OPERATIONS_HISTORY_INCOMPLETE" | "PORTFOLIO_VALUATION_INCOMPLETE" | "UNCONFIRMED_TRANSFER" | "UNVALUED_SECURITIES_TRANSFER"; + PerformanceUnavailableReason: "ACCOUNT_VALUATION_DATES_DIFFER" | "AMBIGUOUS_RETURN" | "HISTORY_START_UNKNOWN" | "INDEX_HISTORY_UNAVAILABLE" | "NO_SOLUTION" | "OPERATIONS_HISTORY_INCOMPLETE" | "PORTFOLIO_VALUATION_INCOMPLETE" | "UNCONFIRMED_TRANSFER" | "UNVALUED_SECURITIES_TRANSFER"; PerformanceReturnDto: { /** @description Decimal fraction: 0.151 = 15.1% */ value: string | null; @@ -1259,8 +1255,7 @@ export interface components { /** Format: date */ to: string; }; - IncomeCurrencySummaryDto: { - currency: string; + IncomeTotalDto: { coupon: components["schemas"]["MoneyDto"]; dividend: components["schemas"]["MoneyDto"]; total: components["schemas"]["MoneyDto"]; @@ -1268,7 +1263,7 @@ export interface components { IncomeSummaryDto: { accountId: string; period: components["schemas"]["IncomePeriodDto"]; - totalsByCurrency: components["schemas"]["IncomeCurrencySummaryDto"][]; + total: components["schemas"]["IncomeTotalDto"]; /** Format: date-time */ calculatedAt: string; }; @@ -1278,7 +1273,7 @@ export interface components { }; IncomeBucketDto: { period: components["schemas"]["IncomePeriodDto"]; - totalsByCurrency: components["schemas"]["IncomeCurrencySummaryDto"][]; + total: components["schemas"]["IncomeTotalDto"]; }; IncomeInstrumentDto: { key: string; @@ -1316,7 +1311,7 @@ export interface components { IncomeAnalyticsDto: { accountId: string; period: components["schemas"]["IncomePeriodDto"]; - totalsByCurrency: components["schemas"]["IncomeCurrencySummaryDto"][]; + total: components["schemas"]["IncomeTotalDto"]; /** Format: date-time */ calculatedAt: string; /** @enum {string} */ @@ -1392,7 +1387,7 @@ export interface components { /** @description Inclusive UTC window the saved forecast was calculated for. Unchanged by the requested from/to filter. */ window: components["schemas"]["UpcomingEventsWindowDto"]; events: components["schemas"]["UpcomingEventDto"][]; - incomeTotalsByCurrency: components["schemas"]["MoneyDto"][]; + incomeTotal: components["schemas"]["MoneyDto"]; }; UpcomingEventsResourceDto: { data: components["schemas"]["UpcomingEventsDto"] | null; @@ -1451,7 +1446,6 @@ export interface components { query?: string; sector?: string; shareType?: string; - currency?: string; realExchange?: string; countryOfRisk?: string; maxLot?: number; diff --git a/src/shared/api/resource-query.test.tsx b/src/shared/api/resource-query.test.tsx index fa2743b..54a5a17 100644 --- a/src/shared/api/resource-query.test.tsx +++ b/src/shared/api/resource-query.test.tsx @@ -20,12 +20,12 @@ function usePrivateResource(accountId: string) { } const snapshot: Portfolio = { - cashBalances: [], + cash: { currency: "RUB", value: "0" }, isStale: false, lastSuccessfulAt: "2026-08-14T00:00:00Z", positions: [], - structure: { currencies: [], unvaluedCategories: [] }, - totalsByCurrency: [{ currency: "RUB", value: "123.000000001" }], + structure: { categories: [], hasNegativeValues: false, totalValue: "0", unvaluedCategories: [] }, + totals: { currency: "RUB", value: "123.000000001" }, }; function auth() { @@ -94,8 +94,8 @@ describe("persistent resource query lifecycle", () => { isReady = true; const loaded = await result.current.refetch(); expect(loaded.data).toEqual(refreshed); - expect(loaded.data?.data?.totalsByCurrency).toEqual( - snapshot.totalsByCurrency, + expect(loaded.data?.data?.totals).toEqual( + snapshot.totals, ); }); await waitFor(() => expect(result.current.data).toEqual(snapshot)); diff --git a/src/shared/api/spec/openapi.json b/src/shared/api/spec/openapi.json index 9c24e25..ad30860 100644 --- a/src/shared/api/spec/openapi.json +++ b/src/shared/api/spec/openapi.json @@ -5871,33 +5871,6 @@ "sharePercent" ] }, - "PortfolioStructureCurrencyDto": { - "type": "object", - "properties": { - "currency": { - "type": "string" - }, - "totalValue": { - "type": "string" - }, - "hasNegativeValues": { - "type": "boolean", - "description": "Any negative constituent, even if netted positive." - }, - "categories": { - "type": "array", - "items": { - "$ref": "#/components/schemas/PortfolioStructureCategoryDto" - } - } - }, - "required": [ - "currency", - "totalValue", - "hasNegativeValues", - "categories" - ] - }, "PortfolioUnvaluedCategoryDto": { "type": "object", "properties": { @@ -5922,14 +5895,22 @@ "PortfolioStructureDto": { "type": "object", "properties": { - "currencies": { + "totalValue": { + "type": "string", + "description": "Exact known total as a decimal string." + }, + "hasNegativeValues": { + "type": "boolean", + "description": "Any negative constituent, even if netted positive." + }, + "categories": { "type": "array", "items": { - "$ref": "#/components/schemas/PortfolioStructureCurrencyDto" + "$ref": "#/components/schemas/PortfolioStructureCategoryDto" } }, "unvaluedCategories": { - "description": "Positions without current value and known valuation currency. Shares are unavailable across all currencies when nonempty.", + "description": "Positions without current value. Shares are unavailable when nonempty.", "type": "array", "items": { "$ref": "#/components/schemas/PortfolioUnvaluedCategoryDto" @@ -5937,16 +5918,15 @@ } }, "required": [ - "currencies", + "totalValue", + "hasNegativeValues", + "categories", "unvaluedCategories" ] }, "UnrealizedReturnDto": { "type": "object", "properties": { - "currency": { - "type": "string" - }, "absolute": { "type": "string", "example": "12.345678901" @@ -5965,7 +5945,6 @@ } }, "required": [ - "currency", "absolute", "costBasis", "percent", @@ -5975,26 +5954,17 @@ "PortfolioSummaryDto": { "type": "object", "properties": { - "totalsByCurrency": { - "type": "array", - "items": { - "$ref": "#/components/schemas/MoneyDto" - } + "totals": { + "$ref": "#/components/schemas/MoneyDto" }, - "cashBalances": { - "type": "array", - "items": { - "$ref": "#/components/schemas/MoneyDto" - } + "cash": { + "$ref": "#/components/schemas/MoneyDto" }, "structure": { "$ref": "#/components/schemas/PortfolioStructureDto" }, - "unrealizedReturnByCurrency": { - "type": "array", - "items": { - "$ref": "#/components/schemas/UnrealizedReturnDto" - } + "unrealizedReturn": { + "$ref": "#/components/schemas/UnrealizedReturnDto" }, "stockPositionCount": { "type": "integer", @@ -6018,10 +5988,10 @@ } }, "required": [ - "totalsByCurrency", - "cashBalances", + "totals", + "cash", "structure", - "unrealizedReturnByCurrency", + "unrealizedReturn", "stockPositionCount", "bondPositionCount", "fundPositionCount", @@ -6537,11 +6507,8 @@ "isStale": { "type": "boolean" }, - "totalsByCurrency": { - "type": "array", - "items": { - "$ref": "#/components/schemas/MoneyDto" - } + "totals": { + "$ref": "#/components/schemas/MoneyDto" }, "positions": { "type": "array", @@ -6549,11 +6516,8 @@ "$ref": "#/components/schemas/PortfolioPositionDto" } }, - "cashBalances": { - "type": "array", - "items": { - "$ref": "#/components/schemas/MoneyDto" - } + "cash": { + "$ref": "#/components/schemas/MoneyDto" }, "structure": { "$ref": "#/components/schemas/PortfolioStructureDto" @@ -6562,9 +6526,9 @@ "required": [ "lastSuccessfulAt", "isStale", - "totalsByCurrency", + "totals", "positions", - "cashBalances", + "cash", "structure" ] }, @@ -6617,7 +6581,6 @@ "AMBIGUOUS_RETURN", "HISTORY_START_UNKNOWN", "INDEX_HISTORY_UNAVAILABLE", - "NON_RUB_ACTIVITY", "NO_SOLUTION", "OPERATIONS_HISTORY_INCOMPLETE", "PORTFOLIO_VALUATION_INCOMPLETE", @@ -7060,12 +7023,9 @@ "to" ] }, - "IncomeCurrencySummaryDto": { + "IncomeTotalDto": { "type": "object", "properties": { - "currency": { - "type": "string" - }, "coupon": { "$ref": "#/components/schemas/MoneyDto" }, @@ -7077,7 +7037,6 @@ } }, "required": [ - "currency", "coupon", "dividend", "total" @@ -7092,11 +7051,8 @@ "period": { "$ref": "#/components/schemas/IncomePeriodDto" }, - "totalsByCurrency": { - "type": "array", - "items": { - "$ref": "#/components/schemas/IncomeCurrencySummaryDto" - } + "total": { + "$ref": "#/components/schemas/IncomeTotalDto" }, "calculatedAt": { "type": "string", @@ -7106,7 +7062,7 @@ "required": [ "accountId", "period", - "totalsByCurrency", + "total", "calculatedAt" ] }, @@ -7137,16 +7093,13 @@ "period": { "$ref": "#/components/schemas/IncomePeriodDto" }, - "totalsByCurrency": { - "type": "array", - "items": { - "$ref": "#/components/schemas/IncomeCurrencySummaryDto" - } + "total": { + "$ref": "#/components/schemas/IncomeTotalDto" } }, "required": [ "period", - "totalsByCurrency" + "total" ] }, "IncomeInstrumentDto": { @@ -7322,11 +7275,8 @@ "period": { "$ref": "#/components/schemas/IncomePeriodDto" }, - "totalsByCurrency": { - "type": "array", - "items": { - "$ref": "#/components/schemas/IncomeCurrencySummaryDto" - } + "total": { + "$ref": "#/components/schemas/IncomeTotalDto" }, "calculatedAt": { "type": "string", @@ -7362,7 +7312,7 @@ "required": [ "accountId", "period", - "totalsByCurrency", + "total", "calculatedAt", "bucketGranularity", "buckets", @@ -7634,11 +7584,8 @@ "$ref": "#/components/schemas/UpcomingEventDto" } }, - "incomeTotalsByCurrency": { - "type": "array", - "items": { - "$ref": "#/components/schemas/MoneyDto" - } + "incomeTotal": { + "$ref": "#/components/schemas/MoneyDto" } }, "required": [ @@ -7646,7 +7593,7 @@ "calculatedAt", "window", "events", - "incomeTotalsByCurrency" + "incomeTotal" ] }, "UpcomingEventsResourceDto": { @@ -7895,10 +7842,6 @@ "type": "string", "minLength": 1 }, - "currency": { - "type": "string", - "minLength": 1 - }, "realExchange": { "type": "string", "minLength": 1 diff --git a/src/shared/i18n/locales/ru.ts b/src/shared/i18n/locales/ru.ts index 1830e8d..9456123 100644 --- a/src/shared/i18n/locales/ru.ts +++ b/src/shared/i18n/locales/ru.ts @@ -14,9 +14,9 @@ const ru = { currentValue: "Текущая стоимость", description: { currentValue: "Текущая стоимость: {{value}}. ", - return: "Доходность {{currency}}: {{percent}}, {{absolute}}", + return: "Доходность: {{percent}}, {{absolute}}", start: "Счёт {{name}}, {{type}}. ", - structure: "Структура {{currency}}: {{value}}", + structure: "Структура: {{value}}", structureCategory: "{{category}} {{value}}", }, emptyText: @@ -39,17 +39,13 @@ const ru = { refreshError: "Не удалось обновить данные. Показаны последние полученные.", refreshing: "Обновляем данные", - remainingCurrencies_few: "Еще {{count}} валюты", - remainingCurrencies_many: "Еще {{count}} валют", - remainingCurrencies_one: "Еще {{count}} валюта", - remainingCurrencies_other: "Еще {{count}} валюты", retry: "Повторить", scope: "Счёт", stocks: "Акции", structure: { category: "{{category}} {{percent}} %", incomplete: "{{category}}: доля неизвестна", - label: "Структура в {{currency}}: {{value}}", + label: "Структура: {{value}}", }, summaryEmpty: "Нет текущих сумм", summaryExcluded_few: @@ -512,9 +508,9 @@ const ru = { bonds: "Облигации", byAccount: "Позиции по счетам", cashBalances: "Денежные остатки", + cashTitle: "Деньги", concentration: { chart: "Распределение позиций", - currency: "Валюта концентрации", empty: "В этом разрезе нет позиций с положительной оценкой.", incomplete: "Данные неполные", issuers: "Эмитенты", @@ -534,11 +530,6 @@ const ru = { }, connectionErrorText: "Настройте подключение, чтобы посмотреть портфель.", connectionErrorTitle: "Проверьте подключение T-Bank", - currencies: { - EUR: "Евро", - RUB: "Российский рубль", - USD: "Доллар США", - }, emptyText: "На счете нет позиций и доступных денежных остатков.", emptyTitle: "Портфель пока пуст", more: "Подробнее", @@ -598,7 +589,6 @@ const ru = { categories: "Категории активов", chartLabel: "Структура по типам активов. Суммы и доли — в списке рядом.", - currency: "Валюта структуры портфеля", empty: "Нет положительной стоимости для кольцевой диаграммы.", incomplete: "Оценка неполная: ниже показаны только известные суммы. Валюта оценки части позиций неизвестна, поэтому доли и диаграмма недоступны.", @@ -622,6 +612,7 @@ const ru = { positions: "Позиции", }, title: "Портфель", + total: "Текущая стоимость", unrealizedResult: "Нереализованный результат", unrealizedResultCaption: "по цене, без НКД и купонов", updatedAt: "Данные получены {{date}}", @@ -664,7 +655,6 @@ const ru = { complete: "Завершена", connect: "Для просмотра акций подключите T-Банк в настройках или обновите подключение.", - currencyUnknown: "валюта неизвестна", date: "Дата", dateUnknown: "Дата не указана", dividendHistory: "История дивидендов компании", @@ -752,7 +742,6 @@ const ru = { screener: { asOf: "Список получен {{date}}", countryOfRisk: "Страна риска", - currency: "Валюта расчётов", empty: "Подходящие акции не найдены.", error: "Не удалось получить результат скринера. Попробуйте найти снова.", maxLot: "Максимальный размер лота", diff --git a/src/shared/lib/decimal.test.ts b/src/shared/lib/decimal.test.ts index 3fcdfca..8b368ac 100644 --- a/src/shared/lib/decimal.test.ts +++ b/src/shared/lib/decimal.test.ts @@ -3,18 +3,13 @@ import { describe, expect, it } from "vitest"; import { sumMoney } from "./decimal"; describe("sumMoney", () => { - it("adds exact signed decimals independently per currency", () => { + it("adds exact signed decimals", () => { expect(sumMoney([ { currency: "RUB", value: "0.1" }, - { currency: "USD", value: "0.01" }, { currency: "RUB", value: "0.2" }, { currency: "RUB", value: "-1500000.45" }, - { currency: "USD", value: "-0.02" }, { currency: "RUB", value: "200" }, - ])).toEqual([ - { currency: "RUB", value: "-1499800.15" }, - { currency: "USD", value: "-0.01" }, - ]); + ])).toEqual({ currency: "RUB", value: "-1499800.15" }); }); it("preserves significant fractional digits and a compensated zero", () => { @@ -22,11 +17,11 @@ describe("sumMoney", () => { { currency: "RUB", value: "100.5" }, { currency: "RUB", value: "49.50" }, { currency: "RUB", value: "0.005" }, - ])).toEqual([{ currency: "RUB", value: "150.005" }]); + ])).toEqual({ currency: "RUB", value: "150.005" }); expect(sumMoney([ { currency: "RUB", value: "100" }, { currency: "RUB", value: "-100" }, - ])).toEqual([{ currency: "RUB", value: "0" }]); + ])).toEqual({ currency: "RUB", value: "0" }); }); it.each(["", ".", "1.2.3", "not-a-number"])( diff --git a/src/shared/lib/decimal.ts b/src/shared/lib/decimal.ts index 90473b4..f86c58d 100644 --- a/src/shared/lib/decimal.ts +++ b/src/shared/lib/decimal.ts @@ -46,24 +46,20 @@ function sumDecimals(decimals: readonly Decimal[]): Decimal { return { scale, value }; } -function sumMoney(amounts: readonly Money[]): Money[] | null { - const decimals = new Map(); +function sumMoney(amounts: readonly Money[]): Money | null { + try { + const currency = amounts[0]?.currency ?? "RUB"; - for (const amount of amounts) { - try { - const values = decimals.get(amount.currency) ?? []; - values.push(decimalFromString(amount.value)); - decimals.set(amount.currency, values); - } - catch { - return null; - } + return { + currency, + value: decimalToString(sumDecimals( + amounts.map(amount => decimalFromString(amount.value)), + )), + }; + } + catch { + return null; } - - return Array.from(decimals, ([currency, values]) => ({ - currency, - value: decimalToString(sumDecimals(values)), - })); } export { diff --git a/src/shared/lib/display.test.ts b/src/shared/lib/display.test.ts index 9658726..b16d914 100644 --- a/src/shared/lib/display.test.ts +++ b/src/shared/lib/display.test.ts @@ -43,7 +43,6 @@ describe("shared display helpers", () => { "-1 234 567,89 ₽", ); expect(formatMoney({ currency: "RUB", value: "10" })).toBe("10 ₽"); - expect(formatMoney({ currency: "USD", value: "0.5" })).toBe("0,5 $"); expect(formatMoney({ currency: "RUB", value: "0001234.50" })).toBe( "0 001 234,50 ₽", ); @@ -71,14 +70,11 @@ describe("shared display helpers", () => { )).toBe("0,00 ₽"); }); - it("rounds to the currency's minor units and drops the sign of a rounded zero", () => { + it("rounds to minor units and drops the sign of a rounded zero", () => { const rounded = { withRoundedCents: true }; expect(formatMoney({ currency: "RUB", value: "-0.004" }, rounded)).toBe("0,00 ₽"); expect(formatMoney({ currency: "RUB", value: "-0.005" }, rounded)).toBe("-0,01 ₽"); expect(formatMoney({ currency: "RUB", value: "999.995" }, rounded)).toBe("1 000,00 ₽"); - expect(formatMoney({ currency: "JPY", value: "1500.5" }, rounded)).toBe("1 501 JPY"); - expect(formatMoney({ currency: "KWD", value: "1.2345" }, rounded)).toBe("1,235 KWD"); - expect(formatMoney({ currency: "ZZZ", value: "1.234" }, rounded)).toBe("1,23 ZZZ"); }); it("builds the brand logo url from an upstream file name", () => { diff --git a/src/shared/lib/display.ts b/src/shared/lib/display.ts index 2fb8a0f..dd78a1f 100644 --- a/src/shared/lib/display.ts +++ b/src/shared/lib/display.ts @@ -1,7 +1,7 @@ import { decimalFromString } from "./decimal"; const DECIMAL_PART_COUNT = 2; -const DEFAULT_MINOR_UNITS = 2; +const MINOR_UNITS = 2; type FormatMoneyOptions = { withRoundedCents?: boolean; @@ -25,17 +25,6 @@ const dateFormatter = new Intl.DateTimeFormat("ru-RU", { year: "numeric", }); -// ISO 4217 minor units (JPY 0, KWD 3); unknown codes fall back to 2. -function currencyMinorUnits(currency: string): number { - try { - return new Intl.NumberFormat("en", { currency, style: "currency" }) - .resolvedOptions().maximumFractionDigits ?? DEFAULT_MINOR_UNITS; - } - catch { - return DEFAULT_MINOR_UNITS; - } -} - function formatDate(value: string): string { return dateFormatter.format(new Date(value)); } @@ -69,10 +58,8 @@ function formatMoney( ): string { if (!money) return "—"; - const minorUnits = currencyMinorUnits(money.currency); - const value = withRoundedCents - ? roundDecimal(money.value, minorUnits) + ? roundDecimal(money.value, MINOR_UNITS) : money.value; const [integerPart = "", fractionPart = ""] = value.split( @@ -81,18 +68,14 @@ function formatMoney( ); const normalizedFraction = withRoundedCents || withZeroCents - ? fractionPart.padEnd(minorUnits, "0") + ? fractionPart.padEnd(MINOR_UNITS, "0") : fractionPart; const decimal = normalizedFraction ? `${integerPart}.${normalizedFraction}` : integerPart; - let currency = money.currency; - if (currency === "RUB") currency = "₽"; - else if (currency === "USD") currency = "$"; - - const formatted = `${formatDecimal(decimal)} ${currency}`; + const formatted = `${formatDecimal(decimal)} ₽`; if (!withSign) return formatted; if (isZeroDecimal(value)) diff --git a/src/shared/lib/total-text.ts b/src/shared/lib/total-text.ts index 2cb0b09..124b4da 100644 --- a/src/shared/lib/total-text.ts +++ b/src/shared/lib/total-text.ts @@ -2,12 +2,8 @@ import { formatMoney } from "./display"; type Money = { currency: string; value: string }; -function totalText(totals: Money[] | null, isSigned: boolean): string { - return totals - ? totals.map(total => - formatMoney(total, { withSign: isSigned || undefined }), - ).join(", ") - : ""; +function totalText(total: Money | null, isSigned: boolean): string { + return total ? formatMoney(total, { withSign: isSigned || undefined }) : ""; } export { totalText }; diff --git a/src/shared/ui/EntryGroup/EntryGroup.tsx b/src/shared/ui/EntryGroup/EntryGroup.tsx index c1c9b93..d6f2f6b 100644 --- a/src/shared/ui/EntryGroup/EntryGroup.tsx +++ b/src/shared/ui/EntryGroup/EntryGroup.tsx @@ -24,7 +24,9 @@ type EntryGroupProps = PropsWithChildren<{ type EntryGroupTotalProps = { isSigned?: boolean; - totals: null | { currency: string; value: string }[]; + totals: + | null + | { currency: string; value: string }; }; const AccountRow: FC = (props) => { @@ -88,11 +90,9 @@ const EntryGroupTotal: FC = (props) => { return ( - {props.totals.map(total => ( - - {formatMoney(total, { withSign: isSigned || undefined })} - - ))} + + {formatMoney(props.totals, { withSign: isSigned || undefined })} + ); }; diff --git a/src/test/handlers.ts b/src/test/handlers.ts index 0b4606c..6923e74 100644 --- a/src/test/handlers.ts +++ b/src/test/handlers.ts @@ -37,12 +37,17 @@ const handlers = [ http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () => HttpResponse.json( resource({ - cashBalances: [], + cash: { currency: "RUB", value: "0" }, isStale: false, lastSuccessfulAt: "2026-08-14T00:00:00Z", positions: [], - structure: { currencies: [], unvaluedCategories: [] }, - totalsByCurrency: [], + structure: { + categories: [], + hasNegativeValues: false, + totalValue: "0", + unvaluedCategories: [], + }, + totals: { currency: "RUB", value: "0" }, }), ), ), @@ -76,7 +81,11 @@ const handlers = [ accountId: "account-1", calculatedAt: "2026-08-14T00:00:00Z", period: { from: "2026-01-01", to: "2026-08-14" }, - totalsByCurrency: [], + total: { + coupon: { currency: "RUB", value: "0" }, + dividend: { currency: "RUB", value: "0" }, + total: { currency: "RUB", value: "0" }, + }, }), ), ), -- 2.54.0