7 Commits
118 changed files with 2602 additions and 3060 deletions
+7 -6
View File
@@ -8,7 +8,7 @@ Vitest — отдельную JSDOM конфигурацию. Точные versi
[package.json](../package.json), реализация — source of truth. [package.json](../package.json), реализация — source of truth.
```text ```text
app → pages → features → entities → shared app → pages → widgets → features → entities → shared
page container → page VM → entity/feature query → shared/api → backend /api/v1 page container → page VM → entity/feature query → shared/api → backend /api/v1
↓ ↓
props → state/section views props → state/section views
@@ -30,13 +30,14 @@ contract; frontend не читает его checkout для генерации
| --- | --- | | --- | --- |
| Bootstrap, глобальные providers, routes | `app` | | Bootstrap, глобальные providers, routes | `app` |
| Композиция маршрута и поведение, нужное только этому экрану | `pages/<page>` | | Композиция маршрута и поведение, нужное только этому экрану | `pages/<page>` |
| Крупная переиспользуемая композиция экрана | `widgets/<widget>` |
| Самостоятельная пользовательская возможность с собственным смыслом | `features/<capability>` | | Самостоятельная пользовательская возможность с собственным смыслом | `features/<capability>` |
| Представление/поведение бизнес-сущности | `entities/<entity>` | | Представление/поведение бизнес-сущности | `entities/<entity>` |
| Переиспользуемый код без WealthLedger domain meaning | `shared` | | Переиспользуемый код без WealthLedger domain meaning | `shared` |
Локальный FSD profile: `app → pages → features → entities → shared`. Локальный FSD profile: `app → pages → widgets → features → entities → shared`.
Слой `widgets` не используется: композиция экрана и компонент для одной page Widget собирает несколько нижних слоёв в самостоятельный UI-блок; компонент для
остаются у page. Form, mutation или click handler сами по себе не одной page остаётся у page. Form, mutation или click handler сами по себе не
требуют feature. Два callers не делают domain helper generic shared. требуют feature. Два callers не делают domain helper generic shared.
Slice именуется по ответственности, а не `helpers`, `hooks` или `types`. Slice именуется по ответственности, а не `helpers`, `hooks` или `types`.
@@ -48,12 +49,12 @@ Slice именуется по ответственности, а не `helpers`,
Cross-slice consumer использует `@/` и `index.ts` владельца, например Cross-slice consumer использует `@/` и `index.ts` владельца, например
`@/entities/portfolio`. Alias Vite и TypeScript согласован. Другой slice не `@/entities/portfolio`. Alias Vite и TypeScript согласован. Другой slice не
импортирует внутренний файл напрямую. Slices `pages`, `features`, `entities` не зависят от соседнего slice своего слоя; импортирует внутренний файл напрямую. Slices `pages`, `widgets`, `features`, `entities` не зависят от соседнего slice своего слоя;
композиция принадлежит вышележащему owner. Existing меж-slice исключения композиция принадлежит вышележащему owner. Existing меж-slice исключения
не являются разрешением добавлять новые. не являются разрешением добавлять новые.
Направление зависимостей, public imports и запрет соседних slices в Направление зависимостей, public imports и запрет соседних slices в
`pages`, `features`, `entities` проверяет `npm run deps`. Внутренние imports `pages`, `widgets`, `features`, `entities` проверяет `npm run deps`. Внутренние imports
одного slice и связи shared segments этот запрет не ограничивает. одного slice и связи shared segments этот запрет не ограничивает.
Knip проверяет использование exports, а не смысл placement. Knip проверяет использование exports, а не смысл placement.
-25
View File
@@ -1,30 +1,5 @@
# Changelog # Changelog
## [1.4.0](https://git.personal.ksv741.ru/WealthLedger/frontend/compare/v1.3.0...v1.4.0) (2026-11-06)
### Features
* **investments:** warn and offer retry after exhausted read retries (WealthLedger/frontend[#55](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/55)) ([#136](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/136)) ([8ce1e8e](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/8ce1e8e44486dcc23d4c074559ca2bb517880b60))
### Bug Fixes
* **account:** show source metadata on empty account and income (WealthLedger/frontend[#55](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/55)) ([#133](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/133)) ([9619dc9](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/9619dc979172dbc16391206e801ed67862d20116))
* align audit findings for Events, currency and auth (WealthLedger/frontend[#155](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/155)) ([668f2ef](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/668f2ef70e535a1222e7f600b1be4a31fefc6f8b))
### Refactoring
* **api:** consume bank operation oneOf contract (WealthLedger/frontend[#144](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/144)) ([#152](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/152)) ([2d070ce](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/2d070ce2b54bbee466ecf6cc18b437910f0c07f8))
* drop dead query helpers and simplify read deadline (WealthLedger/frontend[#141](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/141)) ([#149](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/149)) ([5077bd4](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/5077bd4f0baa4a59643e875485c2f08253cc198d))
* move portfolio structure into account page, drop widgets (WealthLedger/frontend[#139](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/139)) ([#140](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/140)) ([e2d143d](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/e2d143df46e650139e49b7c80a886008ede86d2a))
* normalize view models and share breadcrumbs and titles (WealthLedger/frontend[#143](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/143)) ([#153](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/153)) ([2db39f2](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/2db39f25d9478350ad56a1b2e2ca6496068257c5))
* replace repeated lazy page wrappers with lazyPage helper ([#131](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/131)) ([#132](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/132)) ([71fbe77](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/71fbe7774d3ed63fcb84bc644da997921bc049b2))
* share path-root query predicate (WealthLedger/frontend[#142](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/142)) ([#154](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/154)) ([cadba8d](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/cadba8d075daf9b20c0c9de97ad5bd8f5a3e74fc))
### Maintenance
* **api:** update OpenAPI snapshot with UpcomingEventsDto.window (WealthLedger/frontend[#134](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/134)) ([#135](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/135)) ([aaee959](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/aaee9591d7880193ee9b0c14339e8477264e4a94))
* **deps:** set Renovate release age, PR limit and update groups (WealthLedger/main[#6](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/6)) ([#137](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/137)) ([ad93725](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/ad93725a84615ec329631b71ed8db33337085740))
## [1.3.0](https://git.personal.ksv741.ru/WealthLedger/frontend/compare/v1.2.1...v1.3.0) (2026-11-01) ## [1.3.0](https://git.personal.ksv741.ru/WealthLedger/frontend/compare/v1.2.1...v1.3.0) (2026-11-01)
### Features ### Features
+11 -5
View File
@@ -29,17 +29,17 @@ module.exports = {
}, },
{ {
from: { from: {
path: "^src/(app|pages|features|entities|shared)/([^/]+)/", path: "^src/(app|pages|widgets|features|entities|shared)/([^/]+)/",
}, },
name: "slices-must-use-public-api", name: "slices-must-use-public-api",
severity: "error", severity: "error",
to: { to: {
path: String.raw`^src/(app|pages|features|entities|shared)/([^/]+)/(?!index\.ts$)`, path: String.raw`^src/(app|pages|widgets|features|entities|shared)/([^/]+)/(?!index\.ts$)`,
pathNot: "^src/$1/$2/", pathNot: "^src/$1/$2/",
}, },
}, },
{ {
from: { path: "^src/(pages|features|entities)/([^/]+)/" }, from: { path: "^src/(pages|widgets|features|entities)/([^/]+)/" },
name: "no-same-layer-cross-slice-imports", name: "no-same-layer-cross-slice-imports",
severity: "error", severity: "error",
to: { path: "^src/$1/", pathNot: "^src/$1/$2/" }, to: { path: "^src/$1/", pathNot: "^src/$1/$2/" },
@@ -48,18 +48,24 @@ module.exports = {
from: { path: "^src/shared/" }, from: { path: "^src/shared/" },
name: "shared-must-not-depend-on-layers", name: "shared-must-not-depend-on-layers",
severity: "error", severity: "error",
to: { path: "^src/(entities|features|pages|app)/" }, to: { path: "^src/(entities|features|widgets|pages|app)/" },
}, },
{ {
from: { path: "^src/entities/" }, from: { path: "^src/entities/" },
name: "entities-must-not-depend-on-higher-layers", name: "entities-must-not-depend-on-higher-layers",
severity: "error", severity: "error",
to: { path: "^src/(features|pages|app)/" }, to: { path: "^src/(features|widgets|pages|app)/" },
}, },
{ {
from: { path: "^src/features/" }, from: { path: "^src/features/" },
name: "features-must-not-depend-on-higher-layers", name: "features-must-not-depend-on-higher-layers",
severity: "error", severity: "error",
to: { path: "^src/(widgets|pages|app)/" },
},
{
from: { path: "^src/widgets/" },
name: "widgets-must-not-depend-on-higher-layers",
severity: "error",
to: { path: "^src/(pages|app)/" }, to: { path: "^src/(pages|app)/" },
}, },
{ {
-9
View File
@@ -17,21 +17,12 @@ http {
default $http_x_forwarded_proto; default $http_x_forwarded_proto;
'' $scheme; '' $scheme;
} }
# One shared budget: the external proxy's client-IP forwarding is not verified.
map "$request_method:$uri" $auth_admission_key {
default "";
POST:/api/v1/auth/login auth;
POST:/api/v1/auth/register auth;
}
limit_req_zone $auth_admission_key zone=auth_admission:1m rate=2r/s;
limit_req_status 429;
server { server {
listen 8080; listen 8080;
server_name _; server_name _;
root /usr/share/nginx/html; root /usr/share/nginx/html;
resolver 127.0.0.11 valid=5s ipv6=off; resolver 127.0.0.11 valid=5s ipv6=off;
location /api/ { location /api/ {
limit_req zone=auth_admission burst=5 nodelay;
set $backend http://backend:3000; set $backend http://backend:3000;
proxy_pass $backend$request_uri; proxy_pass $backend$request_uri;
proxy_set_header Host $http_host; proxy_set_header Host $http_host;
+1 -6
View File
@@ -25,11 +25,7 @@ const readability = {
) )
context.report({ messageId: "schema", node }); context.report({ messageId: "schema", node });
}, },
FunctionDeclaration(node) { FunctionDeclaration: checkParameters,
checkParameters(node);
if (node.id?.name?.endsWith?.("ViewModel"))
context.report({ messageId: "vmName", node: node.id });
},
FunctionExpression: checkParameters, FunctionExpression: checkParameters,
ImportDeclaration(node) { ImportDeclaration(node) {
if ( if (
@@ -99,7 +95,6 @@ const readability = {
schema: "Declare Zod schemas in a dedicated *.schema.ts file.", schema: "Declare Zod schemas in a dedicated *.schema.ts file.",
svg: "Own SVG markup in packages/icons, and import its public component.", svg: "Own SVG markup in packages/icons, and import its public component.",
union: "Put complex union members on separate lines.", union: "Put complex union members on separate lines.",
vmName: "Name custom hooks use*Vm, not use*ViewModel.",
}, },
schema: [], schema: [],
type: "suggestion", type: "suggestion",
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "wealth-ledger-frontend", "name": "wealth-ledger-frontend",
"version": "1.4.0", "version": "1.3.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "wealth-ledger-frontend", "name": "wealth-ledger-frontend",
"version": "1.4.0", "version": "1.3.0",
"workspaces": [ "workspaces": [
"packages/*" "packages/*"
], ],
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "wealth-ledger-frontend", "name": "wealth-ledger-frontend",
"private": true, "private": true,
"version": "1.4.0", "version": "1.3.0",
"type": "module", "type": "module",
"engines": { "engines": {
"node": "24.22.1" "node": "24.22.1"
+1 -16
View File
@@ -1,30 +1,15 @@
{ {
"$schema": "https://docs.renovatebot.com/renovate-schema.json", "$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": ["config:recommended", ":semanticCommitTypeAll(chore)", "security:minimumReleaseAgeNpm"], "extends": ["config:recommended", ":semanticCommitTypeAll(chore)"],
"timezone": "Europe/Moscow", "timezone": "Europe/Moscow",
"labels": ["dependencies"], "labels": ["dependencies"],
"dependencyDashboard": true, "dependencyDashboard": true,
"rangeStrategy": "pin", "rangeStrategy": "pin",
"prConcurrentLimit": 5,
"lockFileMaintenance": {
"enabled": true,
"schedule": ["before 4am on the first day of the month"]
},
"packageRules": [ "packageRules": [
{ {
"matchUpdateTypes": ["minor", "patch"], "matchUpdateTypes": ["minor", "patch"],
"groupName": "non-major dependencies" "groupName": "non-major dependencies"
}, },
{
"matchManagers": ["github-actions"],
"matchUpdateTypes": ["minor", "patch"],
"groupName": "non-major github actions"
},
{
"matchManagers": ["docker-compose", "dockerfile"],
"matchUpdateTypes": ["minor", "patch"],
"groupName": "non-major container images"
},
{ {
"matchUpdateTypes": ["major"], "matchUpdateTypes": ["major"],
"dependencyDashboardApproval": true "dependencyDashboardApproval": true
+16 -14
View File
@@ -55,12 +55,12 @@ const position: Portfolio["positions"][number]
}; };
const portfolio: Portfolio = { const portfolio: Portfolio = {
cash: { currency: "RUB", value: "0" }, cashBalances: [],
isStale: false, isStale: false,
lastSuccessfulAt: "2026-08-15T12:00:00Z", lastSuccessfulAt: "2026-08-15T12:00:00Z",
positions: accounts.map(account => ({ ...position, account })), positions: accounts.map(account => ({ ...position, account })),
structure: { categories: [], hasNegativeValues: false, totalValue: "0", unvaluedCategories: [] }, structure: { currencies: [], unvaluedCategories: [] },
totals: { currency: "RUB", value: "1200" }, totalsByCurrency: [{ currency: "RUB", value: "1200" }],
}; };
const receipt: BrokerageOperation const receipt: BrokerageOperation
@@ -106,11 +106,14 @@ const analytics: IncomeAnalytics
}, },
], ],
period: { from: "2026-08-01", to: "2026-08-31" }, period: { from: "2026-08-01", to: "2026-08-31" },
total: { totalsByCurrency: [
coupon: { currency: "RUB", value: "2" }, {
dividend: { currency: "RUB", value: "0" }, coupon: { currency: "RUB", value: "2" },
total: { currency: "RUB", value: "2" }, currency: "RUB",
}, dividend: { currency: "RUB", value: "0" },
total: { currency: "RUB", value: "2" },
},
],
}; };
const CurrentLocation: FC = () => { const CurrentLocation: FC = () => {
@@ -213,8 +216,7 @@ beforeEach(() => {
type: "coupon", type: "coupon",
}), }),
), ),
incomeTotal: { currency: "RUB", value: "0" }, incomeTotalsByCurrency: [],
window: { from: "2026-09-01", to: "2026-10-01" },
}), }),
), ),
), ),
@@ -224,9 +226,9 @@ beforeEach(() => {
describe("All accounts routes", () => { describe("All accounts routes", () => {
it.each([ it.each([
["/accounts/all", "Все счета"], ["/accounts/all", "Все счета"],
["/accounts/all/operations", "История операций"], ["/accounts/all/operations", "Журнал операций"],
["/accounts/all/income?from=2026-08-01&to=2026-08-31", "Пассивный доход"], ["/accounts/all/income?from=2026-08-01&to=2026-08-31", "Пассивный доход"],
["/accounts/all/events", "Предстоящие события"], ["/accounts/all/events", "Будущие события"],
])("opens %s directly", async (path, title) => { ])("opens %s directly", async (path, title) => {
renderPage(path); renderPage(path);
expect( expect(
@@ -308,9 +310,9 @@ describe("All accounts routes", () => {
); );
for (const [suffix, tab] of [ for (const [suffix, tab] of [
["operations", "История операций"], ["operations", "Операции"],
["income", "Доход"], ["income", "Доход"],
["events", "Предстоящие события"], ["events", "События"],
]) { ]) {
fireEvent.click(screen.getByRole("tab", { name: tab })); fireEvent.click(screen.getByRole("tab", { name: tab }));
expect(screen.getByRole("link", { name: "Подробнее" })) expect(screen.getByRole("link", { name: "Подробнее" }))
@@ -1,5 +1,6 @@
import type { FC } from "react"; import type { FC } from "react";
import { useDocumentTitle } from "@mantine/hooks";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useCurrentRoute } from "@/app/model"; import { useCurrentRoute } from "@/app/model";
@@ -7,12 +8,12 @@ import { useBankCard } from "@/entities/bank-card";
import { useBond } from "@/entities/bond"; import { useBond } from "@/entities/bond";
import { useBrokerageAccounts } from "@/entities/brokerage-account"; import { useBrokerageAccounts } from "@/entities/brokerage-account";
import { useStock } from "@/entities/stock"; import { useStock } from "@/entities/stock";
import { useAppDocumentTitle } from "@/shared/ui";
type DocumentTitleProps = { title: string }; type DocumentTitleProps = { title: string };
const DocumentTitle: FC<DocumentTitleProps> = (props): undefined => { const DocumentTitle: FC<DocumentTitleProps> = (props): undefined => {
useAppDocumentTitle(props.title); const { t } = useTranslation();
useDocumentTitle(`${props.title} — ${t("appName")}`);
}; };
type AccountDocumentTitleProps = { type AccountDocumentTitleProps = {
@@ -1,12 +1,12 @@
import type { FC, PropsWithChildren } from "react"; import type { FC, PropsWithChildren } from "react";
import { Button, Stack, Text, Title } from "@mantine/core"; import { Button, Stack, Text, Title } from "@mantine/core";
import { useDocumentTitle } from "@mantine/hooks";
import { Component } from "react"; import { Component } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Link, useLocation } from "react-router"; import { Link, useLocation } from "react-router";
import { useAuth } from "@/features/auth"; import { useAuth } from "@/features/auth";
import { useAppDocumentTitle } from "@/shared/ui";
type BoundaryProps = PropsWithChildren<{ type BoundaryProps = PropsWithChildren<{
authenticated: boolean; authenticated: boolean;
@@ -37,7 +37,7 @@ class Boundary extends Component<BoundaryProps, State> {
const RouteErrorFallback: FC<RouteErrorFallbackProps> = (props) => { const RouteErrorFallback: FC<RouteErrorFallbackProps> = (props) => {
const { authenticated } = props; const { authenticated } = props;
const { t } = useTranslation(); const { t } = useTranslation();
useAppDocumentTitle(t("routeError.title")); useDocumentTitle(`${t("routeError.title")} — ${t("appName")}`);
const to = authenticated ? "/accounts" : "/login"; const to = authenticated ? "/accounts" : "/login";
return ( return (
@@ -55,9 +55,9 @@ describe("AppBreadcrumbs", () => {
}); });
it.each([ it.each([
["operations", "История операций"], ["operations", "Операции"],
["OPERATIONS", "История операций"], ["OPERATIONS", "Операции"],
["events", "Предстоящие события"], ["events", "Будущие события"],
["income", "Пассивный доход"], ["income", "Пассивный доход"],
])("links to the account hierarchy from %s", (section, title) => { ])("links to the account hierarchy from %s", (section, title) => {
renderBreadcrumbs(`/accounts/account-1/${section}`, [ renderBreadcrumbs(`/accounts/account-1/${section}`, [
+7 -17
View File
@@ -7,8 +7,10 @@ import { useCurrentRoute } from "@/app/model";
import { AccountBreadcrumbs } from "./AccountBreadcrumbs"; import { AccountBreadcrumbs } from "./AccountBreadcrumbs";
import { AccountScopeSwitch } from "./AccountScopeSwitch/AccountScopeSwitch"; import { AccountScopeSwitch } from "./AccountScopeSwitch/AccountScopeSwitch";
import { BondBreadcrumbs } from "./BondBreadcrumbs";
import { BreadcrumbTrail } from "./BreadcrumbTrail"; import { BreadcrumbTrail } from "./BreadcrumbTrail";
import { EntityBreadcrumbs } from "./EntityBreadcrumbs"; import { CardBreadcrumbs } from "./CardBreadcrumbs";
import { StockBreadcrumbs } from "./StockBreadcrumbs";
const AppBreadcrumbs: FC = () => { const AppBreadcrumbs: FC = () => {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -24,16 +26,9 @@ const AppBreadcrumbs: FC = () => {
stock, stock,
} = useCurrentRoute(); } = useCurrentRoute();
if (bond) { if (bond) return <BondBreadcrumbs identifier={bond.params.bondId ?? ""} />;
return <EntityBreadcrumbs identifier={bond.params.bondId ?? ""} kind="bond" />; if (stock)
} return <StockBreadcrumbs identifier={stock.params.stockId ?? ""} />;
if (stock) {
return (
<EntityBreadcrumbs identifier={stock.params.stockId ?? ""} kind="stock" />
);
}
if (account && (section || !account.params.section)) if (account && (section || !account.params.section))
return ( return (
<Stack gap={0} mb="md"> <Stack gap={0} mb="md">
@@ -48,13 +43,8 @@ const AppBreadcrumbs: FC = () => {
/> />
</Stack> </Stack>
); );
if (card) return <CardBreadcrumbs identifier={card.params.cardId ?? ""} />;
if (card) {
return <EntityBreadcrumbs identifier={card.params.cardId ?? ""} kind="card" />;
}
if (cards) return <BreadcrumbTrail items={[{ label: t("bank.title") }]} />; if (cards) return <BreadcrumbTrail items={[{ label: t("bank.title") }]} />;
if (accounts) if (accounts)
return <BreadcrumbTrail items={[{ label: t("breadcrumbs.accounts") }]} />; return <BreadcrumbTrail items={[{ label: t("breadcrumbs.accounts") }]} />;
@@ -0,0 +1,25 @@
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { useBond } from "@/entities/bond";
import { BreadcrumbTrail } from "./BreadcrumbTrail";
type BondBreadcrumbsProps = { identifier: string };
const BondBreadcrumbs: FC<BondBreadcrumbsProps> = (props) => {
const { data } = useBond(props.identifier);
const { t } = useTranslation();
return (
<BreadcrumbTrail
items={[
{ label: t("bond.searchTitle"), to: "/bonds" },
{ label: data?.name ?? t("bond.instrument") },
]}
/>
);
};
export { BondBreadcrumbs };
@@ -39,4 +39,4 @@ const BreadcrumbTrail: FC<BreadcrumbTrailProps> = (props) => {
); );
}; };
export { BreadcrumbTrail, type Crumb }; export { BreadcrumbTrail };
@@ -0,0 +1,25 @@
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { useBankCard } from "@/entities/bank-card";
import { BreadcrumbTrail } from "./BreadcrumbTrail";
type CardBreadcrumbsProps = { identifier: string };
const CardBreadcrumbs: FC<CardBreadcrumbsProps> = (props) => {
const { data } = useBankCard(props.identifier);
const { t } = useTranslation();
return (
<BreadcrumbTrail
items={[
{ label: t("bank.title"), to: "/cards" },
{ label: data?.name ?? t("bank.card") },
]}
/>
);
};
export { CardBreadcrumbs };
@@ -1,48 +0,0 @@
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { useBankCard } from "@/entities/bank-card";
import { useBond } from "@/entities/bond";
import { useStock } from "@/entities/stock";
import type { Crumb } from "./BreadcrumbTrail";
import { BreadcrumbTrail } from "./BreadcrumbTrail";
type EntityBreadcrumbsProps = {
identifier: string;
kind:
| "bond"
| "card"
| "stock";
};
const EntityBreadcrumbs: FC<EntityBreadcrumbsProps> = (props) => {
const { identifier, kind } = props;
const { t } = useTranslation();
const bond = useBond(kind === "bond" ? identifier : "");
const card = useBankCard(kind === "card" ? identifier : "");
const stock = useStock(kind === "stock" ? identifier : "");
let parent: Crumb;
let name: string;
if (kind === "bond") {
parent = { label: t("bond.searchTitle"), to: "/bonds" };
name = bond.data?.name ?? t("bond.instrument");
}
else if (kind === "stock") {
parent = { label: t("stock.searchTitle"), to: "/stocks" };
name = stock.data?.name ?? t("stock.share");
}
else {
parent = { label: t("bank.title"), to: "/cards" };
name = card.data?.name ?? t("bank.card");
}
return <BreadcrumbTrail items={[parent, { label: name }]} />;
};
export { EntityBreadcrumbs };
@@ -0,0 +1,25 @@
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { useStock } from "@/entities/stock";
import { BreadcrumbTrail } from "./BreadcrumbTrail";
type StockBreadcrumbsProps = { identifier: string };
const StockBreadcrumbs: FC<StockBreadcrumbsProps> = (props) => {
const { data } = useStock(props.identifier);
const { t } = useTranslation();
return (
<BreadcrumbTrail
items={[
{ label: t("stock.searchTitle"), to: "/stocks" },
{ label: data?.name ?? t("stock.share") },
]}
/>
);
};
export { StockBreadcrumbs };
+10 -4
View File
@@ -5,7 +5,6 @@ import {
ApiErrorCode, ApiErrorCode,
apiErrorCode, apiErrorCode,
isApiErrorCode, isApiErrorCode,
isGetUnder,
} from "@/shared/api"; } from "@/shared/api";
import type { import type {
@@ -27,14 +26,21 @@ async function mutationData<T>(
return data; return data;
} }
const bankCardGetPredicate = isGetUnder(["/api/v1/bank-cards"]);
function useBankMutationOptions() { function useBankMutationOptions() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const invalidate = () => const invalidate = () =>
queryClient.invalidateQueries({ queryClient.invalidateQueries({
predicate: ({ queryKey }) => bankCardGetPredicate(queryKey), predicate: ({ queryKey }) => {
const [method, path] = queryKey;
return (
method === "get"
&& typeof path === "string"
&& (path === "/api/v1/bank-cards"
|| path.startsWith("/api/v1/bank-cards/"))
);
},
}); });
return { return {
@@ -49,10 +49,6 @@ function entryLabelKey(operation: BrokerageOperation): string {
return `ledger.category.${operation.category}`; return `ledger.category.${operation.category}`;
} }
function isCouponPayment(operation: BrokerageOperation): boolean {
return isOperationType(operation.type, OperationType.Coupon);
}
function isIncomePayment(operation: BrokerageOperation): boolean { function isIncomePayment(operation: BrokerageOperation): boolean {
return [OperationType.Coupon, OperationType.Dividend].some(type => return [OperationType.Coupon, OperationType.Dividend].some(type =>
isOperationType(operation.type, type), isOperationType(operation.type, type),
@@ -135,7 +131,6 @@ function paymentPresentation(
export { export {
brokerageOperationGroupKey, brokerageOperationGroupKey,
entryLabelKey, entryLabelKey,
isCouponPayment,
isIncomePayment, isIncomePayment,
isZeroDecimal, isZeroDecimal,
operationLabelKey, operationLabelKey,
@@ -55,27 +55,23 @@ function renderGroup(
} }
describe("OperationGroup", () => { describe("OperationGroup", () => {
it("shows the received coupon and positive totals in flat account rows", () => { it("shows the header, exact total and separate card rows per account", () => {
renderGroup([ renderGroup([
{ ...operation("1", { id: "broker", name: "Брокерский" }, "0.1"), description: "Выплата купонов СФО ВТБ РКС Эталон 04" }, operation("1", { id: "broker", name: "Брокерский" }, "0.1"),
operation("2", { id: "iis", name: "ИИС" }, "0.2"), operation("2", { id: "iis", name: "ИИС" }, "0.2"),
]); ]);
expect(screen.getByText("Выплата купонов СФО ВТБ РКС Эталон 04")).toBeInTheDocument(); expect(screen.getByText("Купон · BOND")).toBeInTheDocument();
expect(screen.getByText("+0,3 ₽")).toBeInTheDocument(); expect(screen.getByText("+0,3 ₽")).toBeInTheDocument();
const accounts = within(screen.getByRole("list", { const accounts = within(screen.getByRole("list", {
name: /Выплата купонов СФО ВТБ РКС Эталон 04, Исполнено 14\.08\.2026.*, \+0,3 ₽/, name: /Купон · BOND, Исполнено 14\.08\.2026.*, \+0,3 ₽/,
})); }));
expect(accounts.getByRole("listitem", { name: "Брокерский, +0,1 ₽" })) expect(accounts.getByRole("listitem", { name: "Брокерский, +0,1 ₽" }))
.toHaveTextContent("Купон"); .toHaveTextContent("Купон");
expect(accounts.getByText("+0,1 ₽")).toHaveClass(/positive/);
expect(accounts.getByRole("listitem", { name: "ИИС, +0,2 ₽" })) expect(accounts.getByRole("listitem", { name: "ИИС, +0,2 ₽" }))
.toHaveTextContent("Купон"); .toHaveTextContent("Купон");
expect(accounts.getByText("+0,2 ₽")).toHaveClass(/positive/);
expect(accounts.getByRole("listitem", { name: "ИИС, +0,2 ₽" }))
.not.toHaveClass("EntryGroup_accountCardRow");
expect(accounts.queryByText(/Исполнено/)).not.toBeInTheDocument(); expect(accounts.queryByText(/Исполнено/)).not.toBeInTheDocument();
expect(accounts.queryByRole("link")).not.toBeInTheDocument(); expect(accounts.queryByRole("link")).not.toBeInTheDocument();
}); });
@@ -87,10 +83,8 @@ describe("OperationGroup", () => {
]); ]);
expect(screen.getByRole("list", { expect(screen.getByRole("list", {
name: /Выплата купонов BOND, Исполнено .*Итог недоступен/, name: /Купон · BOND, Исполнено .*Итог недоступен/,
})).toBeInTheDocument(); })).toBeInTheDocument();
expect(screen.getByText("Итог недоступен")).toBeInTheDocument();
expect(screen.getByText("—")).not.toHaveClass(/positive/);
}); });
it.each(["0", "0.000"])( it.each(["0", "0.000"])(
@@ -120,7 +114,6 @@ describe("OperationGroup", () => {
operation("2", { id: "iis", name: "ИИС" }, "-100"), operation("2", { id: "iis", name: "ИИС" }, "-100"),
]); ]);
expect(screen.getByText("0 ₽")).toBeInTheDocument(); expect(screen.getByText("0 ₽")).toBeInTheDocument();
expect(screen.getByText("−100 ₽")).toHaveClass(/negative/);
}); });
it("renders repeated ids inside one group as separate rows", () => { it("renders repeated ids inside one group as separate rows", () => {
@@ -130,7 +123,7 @@ describe("OperationGroup", () => {
]); ]);
expect(screen.getByText("+30 ₽")).toBeInTheDocument(); expect(screen.getByText("+30 ₽")).toBeInTheDocument();
const accounts = within(screen.getByRole("list", { name: /Выплата купонов/ })); const accounts = within(screen.getByRole("list", { name: /Купон/ }));
expect(accounts.getAllByText("Брокерский")).toHaveLength(1); expect(accounts.getAllByText("Брокерский")).toHaveLength(1);
expect(accounts.getAllByText("ИИС")).toHaveLength(1); expect(accounts.getAllByText("ИИС")).toHaveLength(1);
expect(accounts.getAllByRole("listitem")).toHaveLength(2); expect(accounts.getAllByRole("listitem")).toHaveLength(2);
@@ -9,13 +9,12 @@ import {
sumMoney, sumMoney,
totalText, totalText,
} from "@/shared/lib"; } from "@/shared/lib";
import { AccountCardRow, AccountRow, EntryGroup, EntryGroupTotal } from "@/shared/ui"; import { AccountCardRow, EntryGroup, EntryGroupTotal } from "@/shared/ui";
import type { BrokerageOperation } from "../api/useBrokerageOperations"; import type { BrokerageOperation } from "../api/useBrokerageOperations";
import { import {
entryLabelKey, entryLabelKey,
isCouponPayment,
isZeroDecimal, isZeroDecimal,
operationLabelKey, operationLabelKey,
paymentPresentation, paymentPresentation,
@@ -38,8 +37,6 @@ const OperationGroup: FC<OperationGroupProps> = (props) => {
? entryLabelKey(first) ? entryLabelKey(first)
: operationLabelKey(first.type)); : operationLabelKey(first.type));
const isCoupon = isCouponPayment(first);
const instrument const instrument
= first.ticker ?? first.instrumentType ?? t("ledger.noInstrument"); = first.ticker ?? first.instrumentType ?? t("ledger.noInstrument");
@@ -55,21 +52,9 @@ const OperationGroup: FC<OperationGroupProps> = (props) => {
: null; : null;
const date = formatSnapshotDay(first.occurredAt); const date = formatSnapshotDay(first.occurredAt);
const description = first.description.trim(); const title = `${typeLabel} · ${instrument}`;
let title = `${typeLabel} · ${instrument}`;
if (isCoupon) {
title = description && description.toLocaleLowerCase() !== "купон"
? description
: t("ledger.couponPayment", { instrument });
}
const totalsText = totalText(totals, true); const totalsText = totalText(totals, true);
const totalTone = isCoupon && totals && !isZeroDecimal(totals.value) && !totals.value.startsWith("-")
? "positive"
: "neutral";
const accessibleName = [ const accessibleName = [
title, title,
t("payout.executedAt", { date }), t("payout.executedAt", { date }),
@@ -78,14 +63,13 @@ const OperationGroup: FC<OperationGroupProps> = (props) => {
return ( return (
<EntryGroup <EntryGroup
flat={isCoupon}
header={( header={(
<EntryCard <EntryCard
imageSrc={instrumentLogoUrl(first.logoName)} imageSrc={instrumentLogoUrl(first.logoName)}
initial={(first.ticker?.trim()[0] ?? "?").toUpperCase()} initial={(first.ticker?.trim()[0] ?? "?").toUpperCase()}
metadata={t("payout.executedAt", { date })} metadata={t("payout.executedAt", { date })}
title={title} title={title}
tone={totalTone} tone="neutral"
value={<EntryGroupTotal isSigned totals={totals} />} value={<EntryGroupTotal isSigned totals={totals} />}
valueLabel={typeLabel} valueLabel={typeLabel}
wrap wrap
@@ -96,15 +80,14 @@ const OperationGroup: FC<OperationGroupProps> = (props) => {
{props.entries.map(({ key, operation }) => { {props.entries.map(({ key, operation }) => {
const payment = paymentPresentation(operation); const payment = paymentPresentation(operation);
const accountRowProps = { return (
accountName: operation.account?.name ?? "—", <AccountCardRow
value: payment.amount, accountName={operation.account?.name ?? "—"}
valueLabel: t(payment.label), key={key}
}; value={payment.amount}
valueLabel={t(payment.label)}
return isCoupon />
? <AccountRow key={key} {...accountRowProps} tone={payment.tone} /> );
: <AccountCardRow key={key} {...accountRowProps} />;
})} })}
</EntryGroup> </EntryGroup>
); );
@@ -4,6 +4,8 @@ import type {
BrokerageAccount, BrokerageAccount,
} from "../api/useBrokerageAccounts"; } from "../api/useBrokerageAccounts";
const VISIBLE_CURRENCY_LIMIT = 2;
type BrokerageAccountCard = { type BrokerageAccountCard = {
account: BrokerageAccount; account: BrokerageAccount;
positionCounts: null | PositionCounts; positionCounts: null | PositionCounts;
@@ -11,13 +13,18 @@ type BrokerageAccountCard = {
summary: summary:
| { | {
portfolio: NonNullable<BrokerageAccount["portfolioSummary"]>; portfolio: NonNullable<BrokerageAccount["portfolioSummary"]>;
remainingCurrencyCount: number;
state: "available"; state: "available";
totals: NonNullable<BrokerageAccount["portfolioSummary"]>["totals"]; totals: Money[];
} }
| { state: "empty" } | { state: "empty" }
| { state: "unavailable" }; | { state: "unavailable" };
}; };
type Money = NonNullable<
BrokerageAccount["portfolioSummary"]
>["totalsByCurrency"][number];
type PositionCounts = { bonds: number; stocks: number; total: number }; type PositionCounts = { bonds: number; stocks: number; total: number };
function accountTypeKey(type: BrokerageAccount["type"]) { function accountTypeKey(type: BrokerageAccount["type"]) {
@@ -50,7 +57,7 @@ function brokerageAccountCards(
total: summary.totalPositionCount, total: summary.totalPositionCount,
}; };
if (Number(summary.totals.value) === 0) if (summary.totalsByCurrency.length === 0)
return { account, positionCounts, source, summary: { state: "empty" } }; return { account, positionCounts, source, summary: { state: "empty" } };
return { return {
@@ -59,8 +66,12 @@ function brokerageAccountCards(
source, source,
summary: { summary: {
portfolio: summary, portfolio: summary,
remainingCurrencyCount: Math.max(
summary.totalsByCurrency.length - VISIBLE_CURRENCY_LIMIT,
0,
),
state: "available", state: "available",
totals: summary.totals, totals: summary.totalsByCurrency.slice(0, VISIBLE_CURRENCY_LIMIT),
}, },
}; };
}); });
@@ -65,32 +65,18 @@
.summary { .summary {
font-variant-numeric: tabular-nums lining-nums; font-variant-numeric: tabular-nums lining-nums;
align-items: flex-end; white-space: nowrap;
}
.currencySummary {
min-width: 0;
} }
.total { .total {
color: var(--wl-color-text); color: var(--wl-color-text);
font-size: var(--mantine-font-size-lg); font-size: var(--mantine-font-size-lg);
line-height: var(--mantine-line-height-lg); line-height: var(--mantine-line-height-lg);
overflow-wrap: anywhere;
} }
.return, .return,
.cashValue { .cashValue {
font-variant-numeric: tabular-nums lining-nums; font-variant-numeric: tabular-nums lining-nums;
}
.return {
justify-content: flex-end;
min-width: 0;
overflow-wrap: anywhere;
}
.cashValue {
white-space: nowrap; white-space: nowrap;
} }
@@ -152,11 +138,7 @@
text-align: left; text-align: left;
} }
.summary { .balance > div {
align-items: flex-start;
}
.return {
justify-content: flex-start; justify-content: flex-start;
} }
} }
@@ -14,29 +14,36 @@ type PortfolioSummary = NonNullable<BrokerageAccount["portfolioSummary"]>;
const rubReturn = { const rubReturn = {
absolute: "136900", absolute: "136900",
costBasis: "1103600.50", costBasis: "1103600.50",
currency: "RUB",
partial: false, partial: false,
percent: "12.40", percent: "12.40",
}; };
const portfolioSummary = { const portfolioSummary = {
bondPositionCount: 12, bondPositionCount: 12,
cash: { currency: "RUB", value: "82500.50" }, cashBalances: [
{ currency: "RUB", value: "82500.50" },
{ currency: "USD", value: "1500" },
],
fundPositionCount: 2, fundPositionCount: 2,
stockPositionCount: 5, stockPositionCount: 5,
structure: { structure: {
categories: [ currencies: [{
{ sharePercent: "55.000000", type: "STOCK", value: "682500" }, categories: [
{ sharePercent: "25.000000", type: "BOND", value: "310500" }, { sharePercent: "55.000000", type: "STOCK", value: "682500" },
{ sharePercent: "10.000000", type: "OTHER", value: "124200" }, { sharePercent: "25.000000", type: "BOND", value: "310500" },
{ sharePercent: "10.000000", type: "CASH", value: "124200" }, { sharePercent: "10.000000", type: "OTHER", value: "124200" },
], { sharePercent: "10.000000", type: "CASH", value: "124200" },
hasNegativeValues: false, ],
totalValue: "1241400", currency: "RUB",
hasNegativeValues: false,
totalValue: "1241400",
}],
unvaluedCategories: [], unvaluedCategories: [],
}, },
totalPositionCount: 19, totalPositionCount: 19,
totals: { currency: "RUB", value: "1240500.50" }, totalsByCurrency: [{ currency: "RUB", value: "1240500.50" }],
unrealizedReturn: rubReturn, unrealizedReturnByCurrency: [rubReturn],
} satisfies PortfolioSummary; } satisfies PortfolioSummary;
function account( function account(
@@ -56,25 +63,20 @@ function account(
const negativeSummary: PortfolioSummary = { const negativeSummary: PortfolioSummary = {
...portfolioSummary, ...portfolioSummary,
unrealizedReturn: { unrealizedReturnByCurrency: [{
...rubReturn, ...rubReturn,
absolute: "-4200", absolute: "-4200",
percent: "-3.05", percent: "-3.05",
}, }],
}; };
const emptySummary: PortfolioSummary = { const emptySummary: PortfolioSummary = {
...portfolioSummary, ...portfolioSummary,
cash: { currency: "RUB", value: "0" }, cashBalances: [],
structure: { structure: { currencies: [], unvaluedCategories: [] },
categories: [],
hasNegativeValues: false,
totalValue: "0",
unvaluedCategories: [],
},
totalPositionCount: 0, totalPositionCount: 0,
totals: { currency: "RUB", value: "0" }, totalsByCurrency: [],
unrealizedReturn: rubReturn, unrealizedReturnByCurrency: [],
}; };
const meta = { const meta = {
@@ -92,7 +94,7 @@ const meta = {
docs: { docs: {
description: { description: {
component: component:
"Карточки счетов: стоимость и доходность, деньги, структура и свежесть источника. Денежные и процентные значения остаются строками. Все примеры содержат вымышленные данные; ссылки работают только внутри MemoryRouter.", "Карточки счетов: стоимость и доходность по валютам, деньги, структура и свежесть источника. Денежные и процентные значения остаются строками. Все примеры содержат вымышленные данные; ссылки работают только внутри MemoryRouter.",
}, },
}, },
}, },
@@ -106,6 +108,40 @@ type Story = StoryObj<typeof meta>;
export const Normal: Story = {}; 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 = { export const Negative: Story = {
args: { args: {
accounts: brokerageAccountCards([ accounts: brokerageAccountCards([
@@ -118,10 +154,10 @@ export const Partial: Story = {
args: { args: {
accounts: brokerageAccountCards([account("partial", "Частичные данные", { accounts: brokerageAccountCards([account("partial", "Частичные данные", {
...portfolioSummary, ...portfolioSummary,
unrealizedReturn: { unrealizedReturnByCurrency: [{
...rubReturn, ...rubReturn,
partial: true, partial: true,
}, }],
})]), })]),
}, },
}; };
@@ -14,28 +14,55 @@ type PortfolioSummary = NonNullable<BrokerageAccount["portfolioSummary"]>;
const portfolioSummary = { const portfolioSummary = {
bondPositionCount: 12, bondPositionCount: 12,
cash: { currency: "RUB", value: "82500.5" }, cashBalances: [
{ currency: "RUB", value: "82500.5" },
{ currency: "USD", value: "20" },
],
fundPositionCount: 0, fundPositionCount: 0,
stockPositionCount: 5, stockPositionCount: 5,
structure: { structure: {
categories: [ currencies: [{
{ sharePercent: "50.000000", type: "STOCK", value: "100" }, categories: [
{ sharePercent: "30.000000", type: "BOND", value: "60" }, { sharePercent: "50.000000", type: "STOCK", value: "100" },
{ sharePercent: "0.000000", type: "OTHER", value: "0" }, { sharePercent: "30.000000", type: "BOND", value: "60" },
{ sharePercent: "20.000000", type: "CASH", value: "40" }, { sharePercent: "0.000000", type: "OTHER", value: "0" },
], { sharePercent: "20.000000", type: "CASH", value: "40" },
hasNegativeValues: false, ],
totalValue: "200", currency: "RUB",
hasNegativeValues: false,
totalValue: "200",
}],
unvaluedCategories: [], unvaluedCategories: [],
}, },
totalPositionCount: 17, totalPositionCount: 17,
totals: { currency: "RUB", value: "123456789012345678901.995" }, totalsByCurrency: [
unrealizedReturn: { { currency: "RUB", value: "123456789012345678901.995" },
absolute: "136900", { currency: "USD", value: "20" },
costBasis: "1000000", { currency: "EUR", value: "5" },
partial: true, ],
percent: "12.40", 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,
},
],
} satisfies PortfolioSummary; } satisfies PortfolioSummary;
type BrokerageAccountCardInput = Parameters<typeof BrokerageAccountList>[0][ type BrokerageAccountCardInput = Parameters<typeof BrokerageAccountList>[0][
@@ -70,19 +97,16 @@ describe("BrokerageAccountList", () => {
it("renders returns, cash and structure with precise totals", () => { it("renders returns, cash and structure with precise totals", () => {
const nullReturnSummary: PortfolioSummary = { const nullReturnSummary: PortfolioSummary = {
...portfolioSummary, ...portfolioSummary,
structure: { cashBalances: [],
categories: [], structure: { currencies: [], unvaluedCategories: [] },
hasNegativeValues: false, totalsByCurrency: [{ currency: "EUR", value: "5" }],
totalValue: "0", unrealizedReturnByCurrency: [{
unvaluedCategories: [],
},
totals: { currency: "RUB", value: "5" },
unrealizedReturn: {
absolute: "0", absolute: "0",
costBasis: "5", costBasis: "5",
currency: "EUR",
partial: false, partial: false,
percent: null, percent: null,
}, }],
}; };
renderList(brokerageAccountCards([ renderList(brokerageAccountCards([
@@ -94,38 +118,38 @@ describe("BrokerageAccountList", () => {
expect(link).toHaveAttribute("href", "/accounts/account-1"); expect(link).toHaveAttribute("href", "/accounts/account-1");
expect(within(link).getByText("123 456 789 012 345 678 902,00 ₽")) expect(within(link).getByText("123 456 789 012 345 678 902,00 ₽"))
.toBeInTheDocument(); .toBeInTheDocument();
expect(within(link).getByText("+12,4 %")).toBeInTheDocument(); expect(within(link).getByText("Еще 1 валюта")).toBeInTheDocument();
expect(within(link).getByText("+12,40 %")).toBeInTheDocument();
expect(within(link).getByText("+136 900 ₽")).toBeInTheDocument(); expect(within(link).getByText("+136 900 ₽")).toBeInTheDocument();
expect(within(link).getByText("−3,05 %")).toBeInTheDocument();
expect(within(link).getByText("−4,2 USD")).toBeInTheDocument();
const nullReturnLink = screen.getByRole("link", { name: /Без доходности/ }); const nullReturnLink = screen.getByRole("link", { name: /Без доходности/ });
expect(within(nullReturnLink).getByText("—")).toBeInTheDocument(); expect(within(nullReturnLink).getByText("—")).toBeInTheDocument();
expect(within(link).getByText("частично")).toBeInTheDocument(); expect(within(link).getByText("частично")).toBeInTheDocument();
expect(within(link).getByText("82 500,50 ₽")).toBeInTheDocument(); expect(within(link).getByText("82 500,50 ₽")).toBeInTheDocument();
expect(within(link).getAllByText("20,00 USD")).not.toHaveLength(0);
expect(within(link).getByText("17 позиций")).toBeInTheDocument(); expect(within(link).getByText("17 позиций")).toBeInTheDocument();
expect(screen.getByRole("img", { expect(screen.getByRole("img", {
name: "Структура: Акции 50,0 %; Облигации 30,0 %; " name: "Структура в RUB: Акции 50 %; Облигации 30 %; "
+ "Другие активы 0,0 %; Свободные деньги 20,0 %", + "Другие активы 0 %; Свободные деньги 20 %",
})).toBeInTheDocument(); })).toBeInTheDocument();
const stockLegend = within(link).getByText(/Акции 50,0 %/); const stockLegend = within(link).getByText(/Акции 50 %/);
expect(stockLegend).toHaveTextContent("● Акции 50,0 %"); expect(stockLegend).toHaveTextContent("● Акции 50 %");
// eslint-disable-next-line testing-library/no-node-access -- The legend is aria-hidden, so only DOM traversal reaches its container. // eslint-disable-next-line testing-library/no-node-access -- The legend is aria-hidden, so only DOM traversal reaches its container.
expect(stockLegend.parentElement).toHaveAttribute("aria-hidden", "true"); expect(stockLegend.parentElement).toHaveAttribute("aria-hidden", "true");
expect(within(link).queryByText(/Другие активы 0,0 %/)) expect(within(link).queryByText(/Другие активы 0 %/)).not.toBeInTheDocument();
.not.toBeInTheDocument();
expect(link).toHaveTextContent("По данным брокера"); expect(link).toHaveTextContent("По данным брокера");
expect(link).toHaveTextContent("Структура: Акции 100"); expect(link).toHaveTextContent("Структура RUB: Акции 100");
}); });
it("preserves empty, unavailable and stale states", () => { it("preserves empty, unavailable and stale states", () => {
const emptySummary: PortfolioSummary = { const emptySummary: PortfolioSummary = {
...portfolioSummary, ...portfolioSummary,
structure: { cashBalances: [],
categories: [], structure: { currencies: [], unvaluedCategories: [] },
hasNegativeValues: false,
totalValue: "0",
unvaluedCategories: [],
},
totalPositionCount: 0, totalPositionCount: 0,
totals: { currency: "RUB", value: "0" }, totalsByCurrency: [],
unrealizedReturnByCurrency: [],
}; };
const cards = brokerageAccountCards([ const cards = brokerageAccountCards([
@@ -15,11 +15,6 @@ import classes from "./BrokerageAccountList.module.css";
const DECIMAL_PART_COUNT = 2; const DECIMAL_PART_COUNT = 2;
const STRUCTURE_CATEGORY_ORDER = ["STOCK", "BOND", "OTHER", "CASH"] as const; const STRUCTURE_CATEGORY_ORDER = ["STOCK", "BOND", "OTHER", "CASH"] as const;
const percentFormatter = new Intl.NumberFormat("ru-RU", {
maximumFractionDigits: 1,
minimumFractionDigits: 1,
});
type AvailableSummary = Extract< type AvailableSummary = Extract<
BrokerageAccountCard["summary"], BrokerageAccountCard["summary"],
{ state: "available" } { state: "available" }
@@ -34,23 +29,28 @@ type BrokerageAccountSummaryProps = {
type PortfolioSummary = AvailableSummary["portfolio"]; type PortfolioSummary = AvailableSummary["portfolio"];
type ReturnValueProps = { type ReturnValueProps = {
item: PortfolioSummary["unrealizedReturn"]; item: PortfolioSummary["unrealizedReturnByCurrency"][number];
}; };
type StructureCurrency = PortfolioSummary["structure"]; type StructureCurrency = PortfolioSummary["structure"]["currencies"][number];
function formatPercent(value: null | string) { function formatPercent(value: null | string) {
if (value === null) return "—"; if (value === null) return "—";
const rounded = percentFormatter.format(Math.abs(Number(value))); const formatted = `${value.replace(".", ",")}\u{A0}%`;
let sign = ""; if (isZero(value))
if (rounded !== "0,0") sign = value.startsWith("-") ? "−" : "+"; return value.startsWith("-") ? formatted.slice(1) : formatted;
return `${sign}${rounded}\u{A0}%`; return value.startsWith("-") ? `−${formatted.slice(1)}` : `+${formatted}`;
} }
function formatStructurePercent(value: string) { function formatStructurePercent(value: string) {
return percentFormatter.format(Number(value)); let trimmed = value;
while (trimmed.includes(".") && trimmed.endsWith("0"))
trimmed = trimmed.slice(0, -1);
if (trimmed.endsWith(".")) trimmed = trimmed.slice(0, -1);
return trimmed.replace(".", ",");
} }
function isZero(value: string) { function isZero(value: string) {
@@ -65,23 +65,27 @@ const ReturnValue: FC<ReturnValueProps> = (props) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { item } = props; const { item } = props;
const displayedPercent = formatPercent(item.percent); const isNegative = item.percent !== null
const isNegative = displayedPercent.startsWith("−"); && item.percent.startsWith("-")
const isPositive = displayedPercent.startsWith("+"); && !isZero(item.percent);
const isPositive = item.percent !== null
&& !item.percent.startsWith("-")
&& !isZero(item.percent);
let toneClass = classes.neutral; let toneClass = classes.neutral;
if (isNegative) toneClass = classes.negative; if (isNegative) toneClass = classes.negative;
else if (isPositive) toneClass = classes.positive; else if (isPositive) toneClass = classes.positive;
return ( return (
<Group className={classes.return} gap="xs" wrap="wrap"> <Group className={classes.return} gap="xs" wrap="nowrap">
<Text className={toneClass} fw={700} size="sm"> <Text className={toneClass} fw={700} size="sm">
{displayedPercent} {formatPercent(item.percent)}
</Text> </Text>
<Text className={toneClass} size="sm"> <Text className={toneClass} size="sm">
{formatMoney( {formatMoney(
{ currency: "RUB", value: item.absolute }, { currency: item.currency, value: item.absolute },
{ withSign: true }, { withSign: true },
)} )}
</Text> </Text>
@@ -119,6 +123,7 @@ const StructureBar: FC<StructureBarProps> = (props) => {
}).join("; "); }).join("; ");
const label = t("accounts.structure.label", { const label = t("accounts.structure.label", {
currency: props.item.currency,
value: description, value: description,
}); });
@@ -195,25 +200,51 @@ const BrokerageAccountSummary: FC<BrokerageAccountSummaryProps> = (props) => {
return ( return (
<Stack className={classes.balance} gap="xs"> <Stack className={classes.balance} gap="xs">
<Stack className={classes.summary} gap="sm"> <Group align="flex-start" gap="lg" justify="flex-end" wrap="nowrap">
<Stack className={classes.currencySummary} gap={2}> <Stack className={classes.summary} gap={4}>
<Text className={classes.total} fw={700}> {summary.totals.map(total => (
{formatMoney(summary.totals, { withRoundedCents: true })} <Text className={classes.total} fw={700} key={total.currency}>
{formatMoney(total, { withRoundedCents: true })}
</Text>
))}
{!!summary.remainingCurrencyCount && (
<Text c="dimmed" size="sm">
{t("accounts.remainingCurrencies", {
count: summary.remainingCurrencyCount,
})}
</Text>
)}
</Stack>
<Stack gap={4}>
{summary.totals.map((total) => {
const item = summary.portfolio.unrealizedReturnByCurrency.find(
returnItem => returnItem.currency === total.currency,
);
return item
? <ReturnValue item={item} key={total.currency} />
: null;
})}
</Stack>
</Group>
{summary.portfolio.cashBalances.length > 0 && (
<Group className={classes.cash} gap="xs" justify="flex-end">
<Text c="dimmed" size="xs">
{t("accounts.cash")}
</Text> </Text>
<ReturnValue item={summary.portfolio.unrealizedReturn} /> <Stack gap={0}>
</Stack> {summary.portfolio.cashBalances.map(cash => (
</Stack> <Text className={classes.cashValue} key={cash.currency} size="sm">
{formatMoney(cash, { withRoundedCents: true })}
<Group className={classes.cash} gap="xs" justify="flex-end"> </Text>
<Text c="dimmed" size="xs"> ))}
{t("accounts.cash")} </Stack>
</Text> </Group>
)}
<Text className={classes.cashValue} size="sm">
{formatMoney(summary.portfolio.cash, { withRoundedCents: true })}
</Text>
</Group>
</Stack> </Stack>
); );
}; };
@@ -260,31 +291,39 @@ const AccountDescription: FC<AccountDescriptionProps> = (props) => {
const { account, summary } = props.account; const { account, summary } = props.account;
if (summary.state !== "available") return null; if (summary.state !== "available") return null;
const returns = [t("accounts.description.return", { const returns = summary.portfolio.unrealizedReturnByCurrency.map(item =>
absolute: formatMoney( t("accounts.description.return", {
{ currency: "RUB", value: summary.portfolio.unrealizedReturn.absolute }, absolute: formatMoney(
{ withSign: true }, { currency: item.currency, value: item.absolute },
), { withSign: true },
percent: formatPercent(summary.portfolio.unrealizedReturn.percent), ),
})]; currency: item.currency,
percent: formatPercent(item.percent),
}),
);
const structures = (() => { const structures = summary.portfolio.structure.currencies.map((item) => {
const categories = summary.portfolio.structure.categories.map(category => const categories = item.categories.map(category =>
t("accounts.description.structureCategory", { t("accounts.description.structureCategory", {
category: t(portfolioCategoryPresentation[category.type].label), category: t(portfolioCategoryPresentation[category.type].label),
value: formatMoney( value: formatMoney(
{ currency: "RUB", value: category.value }, { currency: item.currency, value: category.value },
{ withRoundedCents: true }, { withRoundedCents: true },
), ),
}), }),
).join(", "); ).join(", ");
return t("accounts.description.structure", { value: categories }); return t("accounts.description.structure", {
})(); currency: item.currency,
value: categories,
});
});
const hasPartialReturn = summary.portfolio.unrealizedReturn.partial; const hasPartialReturn = summary.portfolio.unrealizedReturnByCurrency.some(
item => item.partial,
);
const details = [returns, structures].join(". "); const details = [...returns, ...structures].join(". ");
return ( return (
<VisuallyHidden> <VisuallyHidden>
@@ -293,7 +332,8 @@ const AccountDescription: FC<AccountDescriptionProps> = (props) => {
type: t(accountTypeKey(account.type)), type: t(accountTypeKey(account.type)),
})} })}
{t("accounts.description.currentValue", { {t("accounts.description.currentValue", {
value: formatMoney(summary.portfolio.totals), value: summary.portfolio.totalsByCurrency
.map(total => formatMoney(total)).join(", "),
})} })}
{details ? `${details}. ` : ""} {details ? `${details}. ` : ""}
{hasPartialReturn ? t("accounts.brokerDescription") : ""} {hasPartialReturn ? t("accounts.brokerDescription") : ""}
@@ -333,7 +373,9 @@ const BrokerageAccountList: FC<BrokerageAccountListProps> = (props) => {
{summary.state === "available" && ( {summary.state === "available" && (
<Stack className={classes.structure} gap={4}> <Stack className={classes.structure} gap={4}>
<StructureBar item={summary.portfolio.structure} /> {summary.portfolio.structure.currencies.map(item => (
<StructureBar item={item} key={item.currency} />
))}
</Stack> </Stack>
)} )}
@@ -97,13 +97,13 @@ describe("BrokerageEventGroup", () => {
expect(screen.getByText("0,00 ₽")).toBeInTheDocument(); expect(screen.getByText("0,00 ₽")).toBeInTheDocument();
}); });
it("sums forecast totals exactly", () => { it("shows separate forecast totals per currency without conversion", () => {
renderGroup([ renderGroup([
event({ id: "broker", name: "Брокерский" }, "10"), event({ id: "broker", name: "Брокерский" }, "10", "USD"),
event({ id: "iis", name: "ИИС" }, "200"), event({ id: "iis", name: "ИИС" }, "200"),
]); ]);
expect(screen.getByRole("list", { name: /210 ₽/ })) expect(screen.getByRole("list", { name: /10 USD.*200 ₽/ }))
.toBeInTheDocument(); .toBeInTheDocument();
}); });
@@ -1,20 +0,0 @@
import { apiQueryClient, type paths, useAuthenticatedQuery } from "@/shared/api";
type Performance = NonNullable<
paths["/api/v1/brokerage-accounts/{accountId}/performance"]["get"]["responses"][200]["content"]["application/json"]["data"]
>;
function usePerformance(accountId: string) {
return useAuthenticatedQuery({
meta: { accessGroup: "brokerage" },
...apiQueryClient.queryOptions(
"get",
"/api/v1/brokerage-accounts/{accountId}/performance",
{ params: { path: { accountId } } },
),
enabled: Boolean(accountId),
staleTime: 30_000,
});
}
export { type Performance, usePerformance };
-1
View File
@@ -1 +0,0 @@
export { type Performance, usePerformance } from "./api/usePerformance";
+1 -1
View File
@@ -2,7 +2,7 @@ import type { Portfolio } from "../api/usePortfolio";
const PERCENT_SCALE = 100; const PERCENT_SCALE = 100;
type Money = Portfolio["totals"]; type Money = Portfolio["totalsByCurrency"][number];
function formatShare( function formatShare(
numerator: Money | null, numerator: Money | null,
@@ -19,6 +19,7 @@ function useInstrumentChartVm(props: InstrumentChartProps) {
const average = vm.position?.averagePurchasePrice; const average = vm.position?.averagePurchasePrice;
const averageLine = average && props.priceUnit !== "percent" const averageLine = average && props.priceUnit !== "percent"
&& average.currency === vm.candles?.currency
? Number(average.value) ? Number(average.value)
: null; : null;
@@ -46,7 +47,7 @@ function useInstrumentChartVm(props: InstrumentChartProps) {
currency: currency:
props.priceUnit === "percent" props.priceUnit === "percent"
? t("bond.percentOfNominal") ? t("bond.percentOfNominal")
: "₽", : (vm.candles?.currency ?? ""),
}, },
); );
@@ -2,18 +2,19 @@ import type { QueryClient } from "@tanstack/react-query";
import { import {
apiQueryClient, apiQueryClient,
isGetUnder,
type paths, type paths,
} from "@/shared/api"; } from "@/shared/api";
type TbankConnectionStatus type TbankConnectionStatus
= paths["/api/v1/tbank-connection"]["get"]["responses"][200]["content"]["application/json"]; = paths["/api/v1/tbank-connection"]["get"]["responses"][200]["content"]["application/json"];
const investmentGetPredicate = isGetUnder([ function isInvestmentPath(path: string) {
"/api/v1/brokerage-accounts", return [
"/api/v1/stocks", "/api/v1/brokerage-accounts",
"/api/v1/bonds", "/api/v1/stocks",
]); "/api/v1/bonds",
].some(root => path === root || path.startsWith(`${root}/`));
}
const tbankConnectionQueryOptions = () => const tbankConnectionQueryOptions = () =>
apiQueryClient.queryOptions("get", "/api/v1/tbank-connection"); apiQueryClient.queryOptions("get", "/api/v1/tbank-connection");
@@ -26,7 +27,15 @@ function commitTbankConnectionBoundary(
) { ) {
queryClient.setQueryData(tbankConnectionQueryKey, status); queryClient.setQueryData(tbankConnectionQueryKey, status);
void queryClient.resetQueries({ void queryClient.resetQueries({
predicate: ({ queryKey }) => investmentGetPredicate(queryKey), predicate: ({ queryKey }) => {
const [method, path] = queryKey;
return (
method === "get"
&& typeof path === "string"
&& isInvestmentPath(path)
);
},
}); });
} }
@@ -10,9 +10,6 @@ import { server } from "@/test/server";
import { useScreenSync } from "../index"; import { useScreenSync } from "../index";
const TRANSPORT_ATTEMPTS = 3;
const TRANSPORT_RECOVERY_MS = 3000;
function auth() { function auth() {
configureAuthClient({ configureAuthClient({
accessToken: () => "synthetic", accessToken: () => "synthetic",
@@ -31,13 +28,11 @@ describe("read-only screen synchronization", () => {
gets += 1; gets += 1;
syncModes.push(new URL(request.url).searchParams.get("sync")); syncModes.push(new URL(request.url).searchParams.get("sync"));
// The transport retries a transient read twice (1 s, 2 s) on its own. return gets === 1
return gets <= TRANSPORT_ATTEMPTS
? HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 }) ? HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 })
: HttpResponse.json(resource([])); : HttpResponse.json(resource([]));
}), }),
); );
vi.useFakeTimers();
const { result } = renderHook( const { result } = renderHook(
() => { () => {
@@ -51,26 +46,12 @@ describe("read-only screen synchronization", () => {
{ wrapper: createQueryWrapper().wrapper }, { wrapper: createQueryWrapper().wrapper },
); );
try { await waitFor(() => expect(gets).toBe(1));
// Exhaust the transport back-off so the query settles in error. await act(async () => result.current.sync.retry());
await vi.waitFor(async () => { await waitFor(() => expect(result.current.accounts.data).toEqual([]));
await act(() => vi.advanceTimersByTimeAsync(TRANSPORT_RECOVERY_MS));
expect(result.current.sync.transportError).toBe(true);
});
expect(gets).toBe(TRANSPORT_ATTEMPTS);
await act(async () => result.current.sync.retry()); expect(gets).toBe(2);
await vi.waitFor(async () => { expect(syncModes).toEqual([null, null]);
await act(() => vi.advanceTimersByTimeAsync(0));
expect(result.current.accounts.data).toEqual([]);
});
}
finally {
vi.useRealTimers();
}
expect(gets).toBe(TRANSPORT_ATTEMPTS + 1);
expect(syncModes).toEqual(Array.from({ length: gets }, () => null));
}); });
it("refreshes the currently open resources together", async () => { it("refreshes the currently open resources together", async () => {
@@ -23,7 +23,7 @@ const meta = {
}, },
render: arguments_ => ( render: arguments_ => (
<Stack> <Stack>
<Text>125 000,50 ₽</Text> <Text>125 000,50 ₽ · 320 USD</Text>
<InvestmentRefreshState {...arguments_} /> <InvestmentRefreshState {...arguments_} />
</Stack> </Stack>
@@ -4,16 +4,16 @@ import {
TbankConnectionError, TbankConnectionError,
TbankConnectionLoading, TbankConnectionLoading,
} from "./TbankConnectionSection.view"; } from "./TbankConnectionSection.view";
import { useTbankConnectionSectionVm } from "./TbankConnectionSection.vm"; import { useTbankConnectionViewModel } from "./TbankConnectionSection.vm";
import { TbankConnectionSectionLoaded } from "./TbankConnectionSectionLoaded"; import { TbankConnectionSectionLoaded } from "./TbankConnectionSectionLoaded";
const TbankConnectionSection: FC = () => { const TbankConnectionSection: FC = () => {
const vm = useTbankConnectionSectionVm(); const viewModel = useTbankConnectionViewModel();
if (vm.state === "loading") return <TbankConnectionLoading />; if (viewModel.state === "loading") return <TbankConnectionLoading />;
return vm.state === "error" return viewModel.state === "error"
? <TbankConnectionError retry={vm.retry} /> ? <TbankConnectionError retry={viewModel.retry} />
: <TbankConnectionSectionLoaded {...vm} />; : <TbankConnectionSectionLoaded {...viewModel} />;
}; };
export { TbankConnectionSection }; export { TbankConnectionSection };
@@ -2,7 +2,7 @@ import { useAuthenticatedQuery } from "@/shared/api";
import { tbankConnectionQueryOptions } from "../../model/connection-boundary"; import { tbankConnectionQueryOptions } from "../../model/connection-boundary";
function useTbankConnectionSectionVm() { function useTbankConnectionViewModel() {
const status = useAuthenticatedQuery({ const status = useAuthenticatedQuery({
...tbankConnectionQueryOptions(), ...tbankConnectionQueryOptions(),
}); });
@@ -22,4 +22,4 @@ function useTbankConnectionSectionVm() {
}; };
} }
export { useTbankConnectionSectionVm }; export { useTbankConnectionViewModel };
@@ -26,7 +26,7 @@ import { resource } from "@/test/resource";
import { server } from "@/test/server"; import { server } from "@/test/server";
import { AccountEventsPage } from "./AccountEventsPage"; import { AccountEventsPage } from "./AccountEventsPage";
import { useAccountEventsPageVm } from "./AccountEventsPage.vm"; import { useAccountEventsPageViewModel } from "./AccountEventsPage.vm";
const Navigation: FC = () => { const Navigation: FC = () => {
const location = useLocation(); const location = useLocation();
@@ -100,7 +100,7 @@ describe("Upcoming event date filters", () => {
); );
const queryClient = createTestQueryClient(); const queryClient = createTestQueryClient();
const { result } = renderHook(() => useAccountEventsPageVm(), { const { result } = renderHook(() => useAccountEventsPageViewModel(), {
wrapper: ({ children }) => ( wrapper: ({ children }) => (
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={["?from=2099-02-01"]}> <MemoryRouter initialEntries={["?from=2099-02-01"]}>
@@ -146,7 +146,7 @@ describe("Upcoming event date filters", () => {
const queryClient = createTestQueryClient(); const queryClient = createTestQueryClient();
const { result } = renderHook( const { result } = renderHook(
() => useAccountEventsPageVm(), () => useAccountEventsPageViewModel(),
{ {
wrapper: ({ children }) => ( wrapper: ({ children }) => (
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
@@ -176,11 +176,7 @@ describe("Upcoming event date filters", () => {
({ request }) => { ({ request }) => {
searches.push(new URL(request.url).search); searches.push(new URL(request.url).search);
return HttpResponse.json(resource({ return HttpResponse.json(resource({ events: [] }));
calculatedAt: "2026-08-14T00:00:00Z",
events: [],
window: { from: "2026-08-14", to: "2026-09-13" },
}));
}, },
), ),
); );
@@ -227,11 +223,7 @@ describe("Upcoming event date filters", () => {
"*/api/v1/brokerage-accounts/:accountId/events", "*/api/v1/brokerage-accounts/:accountId/events",
({ request }) => ({ request }) =>
new URL(request.url).searchParams.get("from") === defaultFrom new URL(request.url).searchParams.get("from") === defaultFrom
? HttpResponse.json(resource({ ? HttpResponse.json(resource({ events: [] }))
calculatedAt: "2026-08-14T00:00:00Z",
events: [],
window: { from: "2026-08-14", to: "2026-09-13" },
}))
: HttpResponse.json({ code: "VALIDATION_ERROR" }, { status: 400 }), : HttpResponse.json({ code: "VALIDATION_ERROR" }, { status: 400 }),
), ),
); );
@@ -263,8 +255,7 @@ describe("Upcoming event date filters", () => {
accountId, accountId,
calculatedAt: "2099-01-01T00:00:00Z", calculatedAt: "2099-01-01T00:00:00Z",
events: [], events: [],
incomeTotal: { currency: "RUB", value: "0" }, incomeTotalsByCurrency: [],
window: { from: "2099-01-01", to: "2099-01-31" },
}), }),
); );
}, },
@@ -273,7 +264,6 @@ describe("Upcoming event date filters", () => {
renderEvents(accountId, ["?from=2026-02-01&to=2026-02-28"]); renderEvents(accountId, ["?from=2026-02-01&to=2026-02-28"]);
const from = await screen.findByLabelText("Период"); const from = await screen.findByLabelText("Период");
expect(from).toHaveTextContent("01.02.2026 – 28.02.2026"); expect(from).toHaveTextContent("01.02.2026 – 28.02.2026");
expect(screen.getByText(/Прогноз от .* на 01\.01\.2099 – 31\.01\.2099/)).toBeVisible();
expect(screen.getByRole("button", { name: "Применить" })).toBeDisabled(); expect(screen.getByRole("button", { name: "Применить" })).toBeDisabled();
expect(searches).toEqual(["?from=2026-02-01&to=2026-02-28"]); expect(searches).toEqual(["?from=2026-02-01&to=2026-02-28"]);
@@ -330,7 +320,7 @@ describe("Upcoming event date filters", () => {
expect(screen.getByLabelText("URL")).toBeEmptyDOMElement(); expect(screen.getByLabelText("URL")).toBeEmptyDOMElement();
expect(screen.getByRole("button", { name: "Применить" })).toBeDisabled(); expect(screen.getByRole("button", { name: "Применить" })).toBeDisabled();
expect( expect(
screen.getByText("На выбранный период предстоящих событий не найдено"), screen.getByText("За выбранный период событий не найдено"),
).toBeVisible(); ).toBeVisible();
}, },
); );
@@ -353,7 +343,7 @@ it("retains the previous complete forecast when its source refresh fails", async
]; ];
const snapshot = resource( const snapshot = resource(
{ events, incomeTotal: { currency: "RUB", value: "123.45" } }, { events, incomeTotalsByCurrency: [{ currency: "RUB", value: "123.45" }] },
{ {
lastAttempt: { lastAttempt: {
finishedAt: "2026-08-14T00:01:00Z", finishedAt: "2026-08-14T00:01:00Z",
@@ -371,7 +361,7 @@ it("retains the previous complete forecast when its source refresh fails", async
); );
const queryClient = createTestQueryClient(); const queryClient = createTestQueryClient();
const { result } = renderHook(() => useAccountEventsPageVm(), { const { result } = renderHook(() => useAccountEventsPageViewModel(), {
wrapper: ({ children }) => ( wrapper: ({ children }) => (
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={["/accounts/all/events"]}> <MemoryRouter initialEntries={["/accounts/all/events"]}>
@@ -17,8 +17,6 @@ const RETRIES_EXHAUSTED_MS = 6000;
const TEST_MS = 10_000; const TEST_MS = 10_000;
const snapshot = resource({ const snapshot = resource({
accountId: "account-1",
calculatedAt: "2026-08-14T00:00:00Z",
events: [ events: [
{ {
date: "2026-10-01", date: "2026-10-01",
@@ -33,8 +31,7 @@ const snapshot = resource({
type: "coupon", type: "coupon",
}, },
], ],
incomeTotal: { currency: "RUB", value: "123.45" }, incomeTotalsByCurrency: [{ currency: "RUB", value: "123.45" }],
window: { from: "2026-08-14", to: "2026-09-13" },
}); });
function renderEvents() { function renderEvents() {
@@ -103,13 +100,7 @@ describe("AccountEventsPage read retry", () => {
async () => { async () => {
let isFailing = false; let isFailing = false;
let requests = 0; let requests = 0;
let imports = 0;
server.use( server.use(
http.post("*/api/v1/tbank-sync", () => {
imports += 1;
return HttpResponse.json({}, { status: 202 });
}),
http.get("*/api/v1/brokerage-accounts/:accountId/events", () => { http.get("*/api/v1/brokerage-accounts/:accountId/events", () => {
requests += 1; requests += 1;
@@ -120,7 +111,6 @@ describe("AccountEventsPage read retry", () => {
); );
const { queryClient } = renderEvents(); const { queryClient } = renderEvents();
await screen.findByText("Купон · Synthetic coupon"); await screen.findByText("Купон · Synthetic coupon");
expect(screen.getByText(/Прогноз от .* на 14\.08\.2026 – 13\.09\.2026/)).toBeVisible();
expect(screen.queryByRole("button", { name: "Повторить" })) expect(screen.queryByRole("button", { name: "Повторить" }))
.not.toBeInTheDocument(); .not.toBeInTheDocument();
@@ -137,7 +127,6 @@ describe("AccountEventsPage read retry", () => {
expect(screen.getByText("Не удалось обновить данные")).toBeInTheDocument(); expect(screen.getByText("Не удалось обновить данные")).toBeInTheDocument();
expect(screen.getByText("Купон · Synthetic coupon")).toBeInTheDocument(); expect(screen.getByText("Купон · Synthetic coupon")).toBeInTheDocument();
expect(screen.getByText(/Прогноз от .* на 14\.08\.2026 – 13\.09\.2026/)).toBeVisible();
expect(requests).toBe(4); expect(requests).toBe(4);
isFailing = false; isFailing = false;
@@ -147,7 +136,6 @@ describe("AccountEventsPage read retry", () => {
expect(screen.queryByText("Не удалось обновить данные")) expect(screen.queryByText("Не удалось обновить данные"))
.not.toBeInTheDocument()); .not.toBeInTheDocument());
expect(requests).toBe(5); expect(requests).toBe(5);
expect(imports).toBe(0);
}, },
); );
@@ -47,7 +47,6 @@ describe("AccountEventsPage", () => {
accountId: "account", accountId: "account",
calculatedAt: "2026-08-14T00:00:00Z", calculatedAt: "2026-08-14T00:00:00Z",
events: [], events: [],
window: { from: "2026-08-14", to: "2026-09-13" },
}, },
isError: false, isError: false,
isLoading: false, isLoading: false,
@@ -137,7 +136,6 @@ describe("AccountEventsPage", () => {
type: "offer", type: "offer",
}, },
], ],
window: { from: "2026-08-14", to: "2026-09-13" },
}, },
isError: false, isError: false,
isLoading: false, isLoading: false,
@@ -174,7 +172,7 @@ describe("AccountEventsPage", () => {
it("renders empty and error states with retry", async () => { it("renders empty and error states with retry", async () => {
const { rerender } = renderPage(); const { rerender } = renderPage();
expect( expect(
screen.getByText("На выбранный период предстоящих событий не найдено"), screen.getByText("За выбранный период событий не найдено"),
).toBeInTheDocument(); ).toBeInTheDocument();
query.mockReturnValue({ query.mockReturnValue({
data: undefined, data: undefined,
@@ -239,7 +237,7 @@ describe("AccountEventsPage", () => {
"true", "true",
); );
expect( expect(
screen.queryByText("На выбранный период предстоящих событий не найдено"), screen.queryByText("За выбранный период событий не найдено"),
).not.toBeInTheDocument(); ).not.toBeInTheDocument();
}); });
@@ -278,7 +276,6 @@ describe("AccountEventsPage", () => {
accountId: "all", accountId: "all",
calculatedAt: "2026-08-14T00:00:00Z", calculatedAt: "2026-08-14T00:00:00Z",
events: [...events], events: [...events],
window: { from: "2026-08-14", to: "2026-09-13" },
}, },
isError: false, isError: false,
isLoading: false, isLoading: false,
@@ -1,17 +1,17 @@
import type { FC } from "react"; import type { FC } from "react";
import { useAccountEventsPageVm } from "./AccountEventsPage.vm"; import { useAccountEventsPageViewModel } from "./AccountEventsPage.vm";
import { AccountEventsPageError } from "./components/AccountEventsPageError.view"; import { AccountEventsPageError } from "./components/AccountEventsPageError.view";
import { AccountEventsPageLoaded } from "./components/AccountEventsPageLoaded.view"; import { AccountEventsPageLoaded } from "./components/AccountEventsPageLoaded.view";
import { AccountEventsPageLoading } from "./components/AccountEventsPageLoading.view"; import { AccountEventsPageLoading } from "./components/AccountEventsPageLoading.view";
const AccountEventsPage: FC = () => { const AccountEventsPage: FC = () => {
const vm = useAccountEventsPageVm(); const viewModel = useAccountEventsPageViewModel();
if (vm.state === "loading") return <AccountEventsPageLoading />; if (viewModel.state === "loading") return <AccountEventsPageLoading />;
return vm.state === "error" return viewModel.state === "error"
? <AccountEventsPageError {...vm} /> ? <AccountEventsPageError {...viewModel} />
: <AccountEventsPageLoaded {...vm} />; : <AccountEventsPageLoaded {...viewModel} />;
}; };
export { AccountEventsPage }; export { AccountEventsPage };
@@ -18,7 +18,6 @@ import {
type AccountEventsPageViewModel type AccountEventsPageViewModel
= | { = | {
allAccounts: boolean; allAccounts: boolean;
calculatedAt: string;
canApply: boolean; canApply: boolean;
draftPeriod: [null | string, null | string]; draftPeriod: [null | string, null | string];
events: BrokerageEvent[]; events: BrokerageEvent[];
@@ -32,7 +31,6 @@ type AccountEventsPageViewModel
source?: InvestmentResourceMeta; source?: InvestmentResourceMeta;
state: "loaded"; state: "loaded";
totalVisible: boolean; totalVisible: boolean;
window: { from: string; to: string };
} }
| { | {
onReset: () => void; onReset: () => void;
@@ -41,7 +39,7 @@ type AccountEventsPageViewModel
} }
| { state: "loading" }; | { state: "loading" };
function useAccountEventsPageVm(): AccountEventsPageViewModel { function useAccountEventsPageViewModel(): AccountEventsPageViewModel {
const { accountId = "" } = useParams(); const { accountId = "" } = useParams();
const { const {
@@ -112,7 +110,6 @@ function useAccountEventsPageVm(): AccountEventsPageViewModel {
return { return {
allAccounts: accountId === "all", allAccounts: accountId === "all",
calculatedAt: events.data.calculatedAt,
canApply: canApply:
(draftFrom !== appliedFrom || draftTo !== appliedTo) (draftFrom !== appliedFrom || draftTo !== appliedTo)
&& isValidPeriod(draftFrom, draftTo), && isValidPeriod(draftFrom, draftTo),
@@ -136,8 +133,7 @@ function useAccountEventsPageVm(): AccountEventsPageViewModel {
source: events.meta, source: events.meta,
state: "loaded", state: "loaded",
totalVisible: events.meta?.completeness === "complete", totalVisible: events.meta?.completeness === "complete",
window: events.data.window,
}; };
} }
export { type AccountEventsPageViewModel, useAccountEventsPageVm }; export { type AccountEventsPageViewModel, useAccountEventsPageViewModel };
@@ -17,7 +17,7 @@ import {
brokerageEventGroupKey, brokerageEventGroupKey,
brokerageEventKey, brokerageEventKey,
} from "@/entities/brokerage-event"; } from "@/entities/brokerage-event";
import { formatDate, formatSnapshotTime, groupItems } from "@/shared/lib"; import { groupItems } from "@/shared/lib";
import { ReadRetry, SourceSyncStatus } from "@/shared/ui"; import { ReadRetry, SourceSyncStatus } from "@/shared/ui";
import type { AccountEventsPageViewModel } from "../AccountEventsPage.vm"; import type { AccountEventsPageViewModel } from "../AccountEventsPage.vm";
@@ -89,14 +89,6 @@ const AccountEventsPageLoaded: FC<AccountEventsPageLoadedProps> = (props) => {
<Text c="dimmed">{t("events.hint")}</Text> <Text c="dimmed">{t("events.hint")}</Text>
<Text c="dimmed" size="sm">
{t("events.forecastWindow", {
date: formatSnapshotTime(props.calculatedAt),
from: formatDate(props.window.from),
to: formatDate(props.window.to),
})}
</Text>
<Group align="end"> <Group align="end">
<DatePickerInput <DatePickerInput
allowSingleDateInRange allowSingleDateInRange
@@ -34,7 +34,7 @@ import { server } from "@/test/server";
import { toIncomeDisplayModel } from "../../model/display"; import { toIncomeDisplayModel } from "../../model/display";
import { AccountIncomePage } from "./AccountIncomePage"; import { AccountIncomePage } from "./AccountIncomePage";
import { import {
useAccountIncomePageVm, useAccountIncomePageViewModel,
} from "./AccountIncomePage.vm"; } from "./AccountIncomePage.vm";
import { IncomePayments } from "./components/IncomePayments.view"; import { IncomePayments } from "./components/IncomePayments.view";
@@ -49,7 +49,7 @@ const analytics
buckets: [ buckets: [
{ {
period: { from: "2026-08-01", to: "2026-08-01" }, period: { from: "2026-08-01", to: "2026-08-01" },
total: summary("10", "2"), totalsByCurrency: [summary("RUB", "10", "2")],
}, },
], ],
calculatedAt: "2026-08-27T00:00:00Z", calculatedAt: "2026-08-27T00:00:00Z",
@@ -77,7 +77,7 @@ const analytics
}, },
], ],
period: { from: "2026-08-01", to: "2026-08-27" }, period: { from: "2026-08-01", to: "2026-08-27" },
total: summary("10", "2"), totalsByCurrency: [summary("RUB", "10", "2")],
} satisfies IncomeAnalytics & { instruments: [unknown] }; } satisfies IncomeAnalytics & { instruments: [unknown] };
function entry(): IncomeAnalytics["entries"][number] { function entry(): IncomeAnalytics["entries"][number] {
@@ -145,11 +145,12 @@ function renderPage(
return { ...view, queryClient }; return { ...view, queryClient };
} }
function summary(coupon: string, dividend: string) { function summary(currency: string, coupon: string, dividend: string) {
return { return {
coupon: money("RUB", coupon), coupon: money(currency, coupon),
dividend: money("RUB", dividend), currency,
total: money("RUB", String(Number(coupon) + Number(dividend))), dividend: money(currency, dividend),
total: money(currency, String(Number(coupon) + Number(dividend))),
}; };
} }
@@ -170,7 +171,7 @@ describe("AccountIncomePage", () => {
); );
const queryClient = createTestQueryClient(); const queryClient = createTestQueryClient();
const { result } = renderHook(() => useAccountIncomePageVm(), { const { result } = renderHook(() => useAccountIncomePageViewModel(), {
wrapper: ({ children }) => ( wrapper: ({ children }) => (
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={["?from=2024-02-29&to=2024-03-01"]}> <MemoryRouter initialEntries={["?from=2024-02-29&to=2024-03-01"]}>
@@ -307,7 +308,7 @@ describe("AccountIncomePage", () => {
buckets: [], buckets: [],
entries: [], entries: [],
instruments: [], instruments: [],
total: summary("0", "0"), totalsByCurrency: [],
}), }),
), ),
), ),
@@ -409,7 +410,7 @@ describe("AccountIncomePage", () => {
it.each([ it.each([
["full", analytics], ["full", analytics],
["empty", { ...analytics, buckets: [], entries: [], instruments: [], total: summary("0", "0") }], ["empty", { ...analytics, buckets: [], entries: [], instruments: [], totalsByCurrency: [] }],
])("shows the server source time and last attempt for a %s result", async (_name, data) => { ])("shows the server source time and last attempt for a %s result", async (_name, data) => {
server.use( server.use(
http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () => http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () =>
@@ -736,11 +737,11 @@ describe("income period and snapshot boundary", () => {
const instruments = [ const instruments = [
{ {
...analytics.instruments[0], ...analytics.instruments[0],
coupon: money("RUB", "10"), coupon: money("USD", "10"),
dividend: money("RUB", "0"), dividend: money("USD", "0"),
key: "rub-positive", key: "usd",
receiptCount: 1, receiptCount: 1,
total: money("RUB", "10"), total: money("USD", "10"),
}, },
{ {
...analytics.instruments[0], ...analytics.instruments[0],
@@ -769,7 +770,7 @@ describe("income period and snapshot boundary", () => {
payment: instrument.total, payment: instrument.total,
})), })),
instruments, instruments,
total: summary("-5", "0"), totalsByCurrency: [summary("RUB", "-5", "0"), summary("USD", "10", "0")],
}); });
render( render(
@@ -781,9 +782,10 @@ describe("income period and snapshot boundary", () => {
screen.getByRole("heading", { name: "Выплаты" }), screen.getByRole("heading", { name: "Выплаты" }),
).toBeInTheDocument(); ).toBeInTheDocument();
expect(screen.queryByText("RUB")).not.toBeInTheDocument(); expect(screen.queryByText("RUB")).not.toBeInTheDocument();
expect(screen.queryByText("USD")).not.toBeInTheDocument();
const cards = screen.getAllByRole("listitem"); const cards = screen.getAllByRole("listitem");
expect(cards).toHaveLength(3); expect(cards).toHaveLength(3);
expect(cards[0]).toHaveTextContent("+10 ₽"); expect(cards[0]).toHaveTextContent("+10 USD");
expect(cards[1]).toHaveTextContent("−2 ₽"); expect(cards[1]).toHaveTextContent("−2 ₽");
expect(cards[2]).toHaveTextContent("−3 ₽"); expect(cards[2]).toHaveTextContent("−3 ₽");
expect(screen.queryByText(/Итого:|Выплат:/)).not.toBeInTheDocument(); expect(screen.queryByText(/Итого:|Выплат:/)).not.toBeInTheDocument();
@@ -815,11 +817,11 @@ describe("income period and snapshot boundary", () => {
); );
renderPage(undefined, "all"); renderPage(undefined, "all");
expect(await screen.findByText("Выплата купонов BOND")).toBeInTheDocument(); expect(await screen.findByText("Купон · BOND")).toBeInTheDocument();
expect(screen.getByText("+30 ₽")).toBeInTheDocument(); expect(screen.getByText("+30 ₽")).toBeInTheDocument();
const accounts = within(screen.getByRole("list", { const accounts = within(screen.getByRole("list", {
name: /Выплата купонов BOND, Исполнено/, name: /Купон · BOND, Исполнено/,
})); }));
expect(accounts.getByText("Брокерский")).toBeInTheDocument(); expect(accounts.getByText("Брокерский")).toBeInTheDocument();
@@ -854,7 +856,7 @@ describe("income period and snapshot boundary", () => {
expect(await screen.findByText("+30 ₽")).toBeInTheDocument(); expect(await screen.findByText("+30 ₽")).toBeInTheDocument();
const accounts = within(screen.getByRole("list", { const accounts = within(screen.getByRole("list", {
name: /Выплата купонов BOND/, name: /Купон · BOND/,
})); }));
expect(accounts.getAllByRole("listitem")).toHaveLength(2); expect(accounts.getAllByRole("listitem")).toHaveLength(2);
@@ -887,7 +889,7 @@ describe("income period and snapshot boundary", () => {
); );
const view = renderPage(undefined, "all"); const view = renderPage(undefined, "all");
await screen.findByText("Выплата купонов BOND"); await screen.findByText("Купон · BOND");
expect(screen.queryByText("+30 ₽")).not.toBeInTheDocument(); expect(screen.queryByText("+30 ₽")).not.toBeInTheDocument();
view.unmount(); view.unmount();
@@ -1,17 +1,17 @@
import type { FC } from "react"; import type { FC } from "react";
import { useAccountIncomePageVm } from "./AccountIncomePage.vm"; import { useAccountIncomePageViewModel } from "./AccountIncomePage.vm";
import { AccountIncomePageError } from "./components/AccountIncomePageError.view"; import { AccountIncomePageError } from "./components/AccountIncomePageError.view";
import { AccountIncomePageLoaded } from "./components/AccountIncomePageLoaded.view"; import { AccountIncomePageLoaded } from "./components/AccountIncomePageLoaded.view";
import { AccountIncomePageLoading } from "./components/AccountIncomePageLoading.view"; import { AccountIncomePageLoading } from "./components/AccountIncomePageLoading.view";
const AccountIncomePage: FC = () => { const AccountIncomePage: FC = () => {
const vm = useAccountIncomePageVm(); const viewModel = useAccountIncomePageViewModel();
if (vm.state === "loading") return <AccountIncomePageLoading />; if (viewModel.state === "loading") return <AccountIncomePageLoading />;
return vm.state === "error" return viewModel.state === "error"
? <AccountIncomePageError {...vm} /> ? <AccountIncomePageError {...viewModel} />
: <AccountIncomePageLoaded {...vm} />; : <AccountIncomePageLoaded {...viewModel} />;
}; };
export { AccountIncomePage }; export { AccountIncomePage };
@@ -43,7 +43,7 @@ type AccountIncomePageViewModel
} }
| { state: "loading" }; | { state: "loading" };
const useAccountIncomePageVm = (): AccountIncomePageViewModel => { const useAccountIncomePageViewModel = (): AccountIncomePageViewModel => {
const { accountId = "" } = useParams(); const { accountId = "" } = useParams();
const [searchParameters, setSearchParameters] = useSearchParams(); const [searchParameters, setSearchParameters] = useSearchParams();
@@ -146,5 +146,5 @@ function isValidIncomePeriod(period: IncomePeriod): boolean {
export { export {
type AccountIncomePageViewModel, type AccountIncomePageViewModel,
useAccountIncomePageVm, useAccountIncomePageViewModel,
}; };
@@ -5,6 +5,7 @@ import {
Stack, Stack,
Title, Title,
} from "@mantine/core"; } from "@mantine/core";
import { StateView } from "@wealthledger/ui";
import { FormProvider } from "react-hook-form"; import { FormProvider } from "react-hook-form";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@@ -24,9 +25,13 @@ const AccountIncomePageLoadedSummary: FC<LoadedProps> = (props) => {
const { analytics } = props; const { analytics } = props;
return props.partial if (props.partial) {
? <Alert role="status">{t("investment.partial")}</Alert> return <Alert role="status">{t("investment.partial")}</Alert>;
: <IncomeSummary totals={analytics.total} />; }
return analytics.totalsByCurrency.length > 0
? <IncomeSummary totals={analytics.totalsByCurrency} />
: <StateView text={t("income.emptyText")} title={t("income.emptyTitle")} />;
}; };
const AccountIncomePageLoaded: FC<LoadedProps> = (props) => { const AccountIncomePageLoaded: FC<LoadedProps> = (props) => {
@@ -7,7 +7,7 @@ import type { IncomeAnalytics } from "@/entities/income";
import { formatMoney } from "@/shared/lib"; import { formatMoney } from "@/shared/lib";
type IncomeSummaryProps = { totals: IncomeAnalytics["total"] }; type IncomeSummaryProps = { totals: IncomeAnalytics["totalsByCurrency"] };
const IncomeSummary: FC<IncomeSummaryProps> = (props) => { const IncomeSummary: FC<IncomeSummaryProps> = (props) => {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -16,14 +16,16 @@ const IncomeSummary: FC<IncomeSummaryProps> = (props) => {
<Stack> <Stack>
<Title order={2}>{t("income.summaryTitle")}</Title> <Title order={2}>{t("income.summaryTitle")}</Title>
<Paper p="md" radius="md" withBorder> {props.totals.map(total => (
<Text fw={700}>{formatMoney(props.totals.total)}</Text> <Paper key={total.currency} p="md" radius="md" withBorder>
<Text fw={700}>{formatMoney(total.total)}</Text>
<Text c="dimmed" size="sm"> <Text c="dimmed" size="sm">
{t("income.coupon")}: {formatMoney(props.totals.coupon)} ·{" "} {t("income.coupon")}: {formatMoney(total.coupon)} ·{" "}
{t("income.dividend")}: {formatMoney(props.totals.dividend)} {t("income.dividend")}: {formatMoney(total.dividend)}
</Text> </Text>
</Paper> </Paper>
))}
</Stack> </Stack>
); );
}; };
@@ -440,7 +440,7 @@ describe("AccountOperationsPage", () => {
renderPage(); renderPage();
expect( expect(
await screen.findByRole("heading", { name: "История операций" }), await screen.findByRole("heading", { name: "Журнал операций" }),
).toBeInTheDocument(); ).toBeInTheDocument();
expect( expect(
screen.queryByRole("link", { name: "К счету" }), screen.queryByRole("link", { name: "К счету" }),
@@ -482,7 +482,7 @@ describe("AccountOperationsPage", () => {
renderPage(); renderPage();
expect( expect(
await screen.findByRole("heading", { name: "История операций" }), await screen.findByRole("heading", { name: "Журнал операций" }),
).toBeInTheDocument(); ).toBeInTheDocument();
await waitFor(() => expect(searches.length).toBeGreaterThan(0)); await waitFor(() => expect(searches.length).toBeGreaterThan(0));
const countBefore = searches.length; const countBefore = searches.length;
@@ -521,7 +521,7 @@ describe("AccountOperationsPage", () => {
renderPage(); renderPage();
expect( expect(
await screen.findByRole("heading", { name: "История операций" }), await screen.findByRole("heading", { name: "Журнал операций" }),
).toBeInTheDocument(); ).toBeInTheDocument();
fireEvent.click(screen.getByRole("combobox", { name: "Категории" })); fireEvent.click(screen.getByRole("combobox", { name: "Категории" }));
fireEvent.click(await screen.findByText("Налог")); fireEvent.click(await screen.findByText("Налог"));
@@ -579,7 +579,7 @@ describe("AccountOperationsPage", () => {
); );
renderPage(); renderPage();
expect( expect(
await screen.findByText("Не удалось загрузить историю операций", undefined, { await screen.findByText("Не удалось загрузить журнал", undefined, {
timeout: RETRIES_EXHAUSTED_MS, timeout: RETRIES_EXHAUSTED_MS,
}), }),
).toBeInTheDocument(); ).toBeInTheDocument();
@@ -787,10 +787,10 @@ describe("all-account operation groups", () => {
fireEvent.click(screen.getByRole("button", { name: "Далее" })); fireEvent.click(screen.getByRole("button", { name: "Далее" }));
const accounts = await screen.findByRole("list", { const accounts = await screen.findByRole("list", {
name: /Выплата купонов BOND, Исполнено/, name: /Купон · BOND, Исполнено/,
}); });
expect(screen.getByText("Выплата купонов BOND")).toBeInTheDocument(); expect(screen.getByText("Купон · BOND")).toBeInTheDocument();
expect(screen.getByText("+200 ₽")).toBeInTheDocument(); expect(screen.getByText("+200 ₽")).toBeInTheDocument();
expect(within(accounts).getAllByRole("listitem")).toHaveLength(2); expect(within(accounts).getAllByRole("listitem")).toHaveLength(2);
@@ -6,15 +6,15 @@ import { AccountOperationsPageLoaded } from "./components/AccountOperationsPageL
import { AccountOperationsPageLoading } from "./components/AccountOperationsPageLoading.view"; import { AccountOperationsPageLoading } from "./components/AccountOperationsPageLoading.view";
const AccountOperationsPage: FC = () => { const AccountOperationsPage: FC = () => {
const vm = useAccountOperationsPageVm(); const viewModel = useAccountOperationsPageVm();
if (vm.state === "loading") { if (viewModel.state === "loading") {
return <AccountOperationsPageLoading />; return <AccountOperationsPageLoading />;
} }
return vm.state === "error" return viewModel.state === "error"
? <AccountOperationsPageError {...vm} /> ? <AccountOperationsPageError {...viewModel} />
: <AccountOperationsPageLoaded {...vm} />; : <AccountOperationsPageLoaded {...viewModel} />;
}; };
export { AccountOperationsPage }; export { AccountOperationsPage };
+15 -13
View File
@@ -35,13 +35,15 @@ describe("account concentration", () => {
const model = toConcentration(snapshot([ const model = toConcentration(snapshot([
stock("A", "1"), stock("B", "15"), stock("A", "1"), stock("B", "15"),
stock("C", "0"), stock("D", "-50"), stock("E", null), stock("C", "0"), stock("D", "-50"), stock("E", null),
]), "stocks", labels); stock("F", null, "USD"), stock("G", "-3", "USD"),
]), "stocks", "RUB", labels);
expect(model.currencies).toEqual(["RUB", "USD"]);
expect(model.total).toBe("16"); expect(model.total).toBe("16");
expect(model.items.map(item => item.share)).toEqual(["93,8 %", "6,3 %"]); expect(model.items.map(item => item.share)).toEqual(["93,8 %", "6,3 %"]);
expect(model.unaccounted).toBe(2); expect(model.unaccounted).toBe(3);
const negative = snapshot([stock("A", "-3")]); const negative = snapshot([stock("A", "-3", "USD")]);
expect(toConcentration(negative, "stocks", labels)) expect(toConcentration(negative, "stocks", "USD", labels))
.toMatchObject({ empty: true, unaccounted: 1 }); .toMatchObject({ empty: true, unaccounted: 1 });
}); });
@@ -50,7 +52,7 @@ describe("account concentration", () => {
stock("A", "400"), stock("B", "250"), stock("C", "150"), stock("A", "400"), stock("B", "250"), stock("C", "150"),
stock("D", "100"), stock("E", "50"), stock("F", "30"), stock("D", "100"), stock("E", "50"), stock("F", "30"),
stock("G", "20"), stock("G", "20"),
]), "stocks", labels); ]), "stocks", "RUB", labels);
expect(model.items.map(item => item.amount)).toEqual([ expect(model.items.map(item => item.amount)).toEqual([
"400", "250", "150", "100", "50", "400", "250", "150", "100", "50",
@@ -69,7 +71,7 @@ describe("account concentration", () => {
stock("Сбер", "100"), stock("X5", "100"), stock("Сбер", "100"), stock("X5", "100"),
stock("VK", "100"), stock("D", "100"), stock("VK", "100"), stock("D", "100"),
stock("E", "100"), stock("F", "100"), stock("E", "100"), stock("F", "100"),
]), "stocks", labels); ]), "stocks", "RUB", labels);
expect(model.items).toHaveLength(6); expect(model.items).toHaveLength(6);
expect(model.other).toBeNull(); expect(model.other).toBeNull();
@@ -81,7 +83,7 @@ describe("account concentration", () => {
stock("Large", "100000000"), stock("Small", "1"), stock("Large", "100000000"), stock("Small", "1"),
]); ]);
expect(toConcentration(tiny, "stocks", labels).items[1]?.share) expect(toConcentration(tiny, "stocks", "RUB", labels).items[1]?.share)
.toBe("< 0,1 %"); .toBe("< 0,1 %");
}); });
@@ -101,9 +103,9 @@ describe("account concentration", () => {
stock("D", "50", "RUB", { issuer: null, sector: null }), stock("D", "50", "RUB", { issuer: null, sector: null }),
]; ];
expect(toConcentration(snapshot(positions), "sectors", labels) expect(toConcentration(snapshot(positions), "sectors", "RUB", labels)
.items.map(item => item.amount)).toEqual(["500", "100", "50"]); .items.map(item => item.amount)).toEqual(["500", "100", "50"]);
expect(toConcentration(snapshot(positions), "issuers", labels) expect(toConcentration(snapshot(positions), "issuers", "RUB", labels)
.items.map(item => [item.name, item.amount])).toEqual([ .items.map(item => [item.name, item.amount])).toEqual([
["Сбербанк", "500"], ["", "50"], ["Сбербанк", "500"], ["", "50"],
]); ]);
@@ -121,7 +123,7 @@ describe("account concentration", () => {
stock("D", "100", "RUB", { issuer: null, sector: "energy" }), stock("D", "100", "RUB", { issuer: null, sector: "energy" }),
]); ]);
expect(toConcentration(issuers, "issuers", labels) expect(toConcentration(issuers, "issuers", "RUB", labels)
.items.map(item => [item.label, item.amount])).toEqual([ .items.map(item => [item.label, item.amount])).toEqual([
["Газпром", "300"], ["Сбер", "300"], ["Эмитент не указан", "300"], ["Газпром", "300"], ["Сбер", "300"], ["Эмитент не указан", "300"],
]); ]);
@@ -138,7 +140,7 @@ describe("account concentration", () => {
: "Энергетика", : "Энергетика",
}; };
expect(toConcentration(sectors, "sectors", sectorLabels) expect(toConcentration(sectors, "sectors", "RUB", sectorLabels)
.items.map(item => item.label)) .items.map(item => item.label))
.toEqual(["Информационные технологии", "Энергетика"]); .toEqual(["Информационные технологии", "Энергетика"]);
}); });
@@ -156,7 +158,7 @@ describe("account concentration", () => {
}), }),
]); ]);
expect(toConcentration(data, "issuers", labels).items expect(toConcentration(data, "issuers", "RUB", labels).items
.map(item => [item.name, item.label, item.amount])).toEqual([ .map(item => [item.name, item.label, item.amount])).toEqual([
["Альфа", "Альфа", "400"], ["Бета", "Бета", "400"], ["Альфа", "Альфа", "400"], ["Бета", "Бета", "400"],
]); ]);
@@ -167,7 +169,7 @@ describe("account concentration", () => {
isin: "RU000A0JX0J2", ticker: "BOND", type: "BOND", isin: "RU000A0JX0J2", ticker: "BOND", type: "BOND",
})]); })]);
expect(toConcentration(data, "bonds", labels) expect(toConcentration(data, "bonds", "RUB", labels)
.items[0]?.logo?.subtitle).toBe("RU000A0JX0J2"); .items[0]?.logo?.subtitle).toBe("RU000A0JX0J2");
}); });
}); });
+25 -1
View File
@@ -9,6 +9,7 @@ import {
} from "@/shared/lib"; } from "@/shared/lib";
type Concentration = { type Concentration = {
currencies: string[];
empty: boolean; empty: boolean;
items: ConcentrationItem[]; items: ConcentrationItem[];
other: ConcentrationOther | null; other: ConcentrationOther | null;
@@ -82,9 +83,11 @@ function addPosition(
index: number, index: number,
slice: Slice, slice: Slice,
labels: ConcentrationLabels, labels: ConcentrationLabels,
selected: null | string,
): number { ): number {
if (!isIncluded(slice, position.type)) return 0; if (!isIncluded(slice, position.type)) return 0;
if (!position.currentValue) return 1; if (!position.currentValue) return 1;
if (position.currentValue.currency !== selected) return 0;
const value = decimalFromString(position.currentValue.value); const value = decimalFromString(position.currentValue.value);
if (value.value < 0n) return 1; if (value.value < 0n) return 1;
if (value.value === 0n) return 0; if (value.value === 0n) return 0;
@@ -132,6 +135,16 @@ function compareDecimal(a: Decimal, b: Decimal): number {
return left < right ? -1 : 0; 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( function groupIdentity(
position: Position, position: Position,
slice: Slice, slice: Slice,
@@ -201,13 +214,22 @@ function share(part: Decimal, total: Decimal): string {
function toConcentration( function toConcentration(
portfolio: Portfolio, portfolio: Portfolio,
slice: Slice, slice: Slice,
currency: null | string,
labels: ConcentrationLabels, labels: ConcentrationLabels,
): Concentration { ): Concentration {
const currencies = concentrationCurrencies(portfolio);
const selected = currency && currencies.includes(currency)
? currency
: currencies[0] ?? null;
const groups = new Map<string, Group>(); const groups = new Map<string, Group>();
let unaccounted = 0; let unaccounted = 0;
for (const [index, position] of portfolio.positions.entries()) { for (const [index, position] of portfolio.positions.entries()) {
unaccounted += addPosition(groups, position, index, slice, labels); unaccounted += addPosition(
groups, position, index, slice, labels, selected,
);
} }
const ranked = Array.from(groups.values(), group => ({ const ranked = Array.from(groups.values(), group => ({
@@ -256,6 +278,7 @@ function toConcentration(
: null; : null;
return { return {
currencies,
empty: !total, empty: !total,
items: other ? entries.slice(0, TOP) : entries, items: other ? entries.slice(0, TOP) : entries,
other, other,
@@ -266,6 +289,7 @@ function toConcentration(
} }
export { export {
concentrationCurrencies,
toConcentration, toConcentration,
}; };
export type { export type {
-104
View File
@@ -1,104 +0,0 @@
import type { Performance } from "@/entities/performance";
import { formatDate, formatMoney, formatPercentagePoints, formatSignedPercent } from "@/shared/lib";
type TextValue = { tone: Tone; value: string };
type Tone
= | "negative"
| "neutral"
| "positive";
const reasonLabels: Record<string, string> = {
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: "Бумаги переведены без денежной оценки",
};
const unknownReason = "Причину недоступности не удалось определить";
function performanceVm(data: Performance) {
return {
basis: data.return.basis,
comparison: data.outperformance
? {
money: signedMoney(data.outperformance.money),
points: valueText(
data.outperformance.percentagePoints,
formatPercentagePoints,
),
}
: null,
current: formatMoney(data.currentValue),
currentPoint: data.series.length > 0
? { date: formatDate(`${data.series.at(-1)?.date ?? data.period.to}T00:00:00Z`) }
: null,
days: data.period.days,
excluded: data.excludedAccounts.map(item => ({
id: item.accountId,
name: item.name,
reason: reasonText(item.reason),
})),
from: data.period.from,
indexReason: data.benchmark.unavailableReason
? reasonText(data.benchmark.unavailableReason)
: null,
indexReturn: valueText(data.benchmark.return.value, formatSignedPercent),
indexValue: data.benchmark.value ? formatMoney(data.benchmark.value) : "—",
invested: formatMoney(data.netInvested),
profit: signedMoney(data.profit),
returnReason: data.return.unavailableReason
? reasonText(data.return.unavailableReason)
: null,
returnValue: valueText(data.return.value, formatSignedPercent),
series: data.series.map(item => ({
benchmarkValue: Number(item.benchmarkValue),
date: item.date,
label: formatDate(`${item.date}T00:00:00Z`),
netInvested: Number(item.netInvested),
referenceValue: item.referenceValue === null
? null
: Number(item.referenceValue),
})),
to: data.period.to,
};
}
function reasonText(reason: string): string {
// eslint-disable-next-line security/detect-object-injection -- Lookup accepts only an API reason, with a safe fallback.
return reasonLabels[reason] ?? unknownReason;
}
function signedMoney(value: Performance["profit"]): TextValue {
return {
tone: tone(value.value),
value: formatMoney(value, { withSign: true }),
};
}
function tone(value: string): Tone {
const number = Number(value);
if (number > 0) return "positive";
return number < 0 ? "negative" : "neutral";
}
// Tone follows the displayed text, so "0,0 %" is never coloured.
function valueText(
value: null | string,
format: (value: string) => string,
): TextValue {
if (value === null) return { tone: "neutral", value: "—" };
const text = format(value);
if (text.startsWith("+")) return { tone: "positive", value: text };
return { tone: text.startsWith("−") ? "negative" : "neutral", value: text };
}
export { performanceVm, reasonText };
@@ -2,7 +2,7 @@ import type { Portfolio } from "@/entities/portfolio";
const portfolio const portfolio
= { = {
cash: { currency: "RUB", value: "1" }, cashBalances: [{ currency: "USD", value: "1" }],
isStale: true, isStale: true,
lastSuccessfulAt: "2026-08-13T10:00:00Z", lastSuccessfulAt: "2026-08-13T10:00:00Z",
positions: [ positions: [
@@ -49,13 +49,8 @@ const portfolio
type: "FUND", type: "FUND",
}, },
] satisfies [Portfolio["positions"][number], ...Portfolio["positions"]], ] satisfies [Portfolio["positions"][number], ...Portfolio["positions"]],
structure: { structure: { currencies: [], unvaluedCategories: [] },
categories: [], totalsByCurrency: [{ currency: "RUB", value: "150" }],
hasNegativeValues: false,
totalValue: "0",
unvaluedCategories: [],
},
totals: { currency: "RUB", value: "150" },
} satisfies Portfolio; } satisfies Portfolio;
const position: Portfolio["positions"][number] = { const position: Portfolio["positions"][number] = {
+23 -32
View File
@@ -23,7 +23,7 @@ function sections(positions: Portfolio["positions"]) {
} }
describe("account position sections", () => { describe("account position sections", () => {
it("excludes currency mirrors from display and share denominators", () => { it("excludes currency instruments only from display, preserving order and share denominators", () => {
const otherTypes = [ const otherTypes = [
"OPTION", "OPTION",
"OTHER", "OTHER",
@@ -35,7 +35,7 @@ describe("account position sections", () => {
const snapshot: Portfolio = { const snapshot: Portfolio = {
...portfolio, ...portfolio,
cash: { currency: "RUB", value: "1000" }, cashBalances: [{ currency: "RUB", value: "1000" }],
positions: [ positions: [
...portfolio.positions, ...portfolio.positions,
{ {
@@ -56,8 +56,8 @@ describe("account position sections", () => {
}), }),
), ),
], ],
structure: { categories: [], hasNegativeValues: false, totalValue: "0", unvaluedCategories: [] }, structure: { currencies: [], unvaluedCategories: [] },
totals: { currency: "RUB", value: "1250" }, totalsByCurrency: [{ currency: "RUB", value: "1250" }],
}; };
const original = structuredClone(snapshot); const original = structuredClone(snapshot);
@@ -73,30 +73,12 @@ describe("account position sections", () => {
"FIGI-FUND", "FIGI-FUND",
...otherTypes.map(type => `FIGI-${type}-NEXT`), ...otherTypes.map(type => `FIGI-${type}-NEXT`),
]); ]);
expect(sections[0]?.groups[0]?.positions[0].shareText).toBe("50 %"); expect(sections[0]?.groups[0]?.positions[0].shareText).toBe("40 %");
expect(sections[1]?.groups[0]?.positions[0].shareText).toBe("25 %"); expect(sections[1]?.groups[0]?.positions[0].shareText).toBe("20 %");
expect(sections[2]?.groups[1]?.positions[0].shareText).toBe("25 %"); expect(sections[2]?.groups[1]?.positions[0].shareText).toBe("20 %");
expect(snapshot).toEqual(original); expect(snapshot).toEqual(original);
}); });
it("keeps all-account positions from both accounts without counting mirrored cash", () => {
const result = sections([
makePosition({ account: { id: "a", name: "Основной" }, currentValue: { currency: "RUB", value: "100" } }),
makePosition({ account: { id: "a", name: "Основной" }, currentValue: { currency: "RUB", value: "50" }, type: "CURRENCY" }),
makePosition({ account: { id: "b", name: "ИИС" }, currentValue: { currency: "RUB", value: "100" } }),
]);
const visible = result.flatMap(section =>
section.groups.flatMap(group => group.positions));
expect(visible)
.toEqual(expect.arrayContaining([
expect.objectContaining({ account: { id: "a", name: "Основной" }, shareText: "50 %" }),
expect.objectContaining({ account: { id: "b", name: "ИИС" }, shareText: "50 %" }),
]));
expect(visible).toHaveLength(2);
});
it("prepares grouped position sections with derived display data", () => { it("prepares grouped position sections with derived display data", () => {
expect( expect(
toAccountPagePositionSections(portfolio).map(({ groups, kind }) => ({ toAccountPagePositionSections(portfolio).map(({ groups, kind }) => ({
@@ -193,13 +175,13 @@ describe("account position sections", () => {
).toHaveLength(2); ).toHaveLength(2);
}); });
it("keeps values and purchase prices attached to their source positions", () => { it("keeps currencies and purchase prices attached to their source positions", () => {
const source = [ const source = [
position, position,
{ {
...second, ...second,
averagePurchasePrice: { currency: "RUB", value: "0.99" }, averagePurchasePrice: { currency: "USD", value: "0.99" },
currentValue: { currency: "RUB", value: "1.01" }, currentValue: { currency: "USD", value: "1.01" },
}, },
]; ];
@@ -264,6 +246,10 @@ describe("account position returns", () => {
{ name: "zero quantity", overrides: { quantity: "0" } }, { name: "zero quantity", overrides: { quantity: "0" } },
{ name: "negative quantity", overrides: { quantity: "-1" } }, { name: "negative quantity", overrides: { quantity: "-1" } },
{ name: "missing average price", overrides: { averagePurchasePrice: null } }, { 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" } } }, { name: "zero cost", overrides: { averagePurchasePrice: { currency: "RUB", value: "0" } } },
])("returns null for $name", ({ overrides }) => { ])("returns null for $name", ({ overrides }) => {
const result = sections([makePosition({ const result = sections([makePosition({
@@ -297,24 +283,29 @@ describe("account position returns", () => {
expect(formatPositionReturnPercent(testCase.value)).toBe(testCase.expected); expect(formatPositionReturnPercent(testCase.value)).toBe(testCase.expected);
}); });
it("sums complete unrealized returns", () => { it("sums complete unrealized returns by currency", () => {
const result = toUnrealizedReturns([ const result = toUnrealizedReturns([
makePosition({ absoluteReturn: { currency: "RUB", value: "0.1" } }), makePosition({ absoluteReturn: { currency: "RUB", value: "0.1" } }),
makePosition({ absoluteReturn: { currency: "RUB", value: "0.2" } }), makePosition({ absoluteReturn: { currency: "RUB", value: "0.2" } }),
makePosition({ absoluteReturn: { currency: "USD", value: "-2.5" } }),
]); ]);
expect(result).toEqual({ currency: "RUB", value: "0.3" }); expect(result).toEqual([
{ currency: "RUB", value: "0.3" },
{ currency: "USD", value: "-2.5" },
]);
}); });
it("returns null when any return is incomplete", () => { it("omits only currencies with incomplete returns", () => {
const result = toUnrealizedReturns([ const result = toUnrealizedReturns([
makePosition({ absoluteReturn: { currency: "RUB", value: "10" } }), makePosition({ absoluteReturn: { currency: "RUB", value: "10" } }),
makePosition({ makePosition({
absoluteReturn: null, absoluteReturn: null,
currentValue: { currency: "RUB", value: "100" }, currentValue: { currency: "RUB", value: "100" },
}), }),
makePosition({ absoluteReturn: { currency: "USD", value: "2" } }),
]); ]);
expect(result).toBeNull(); expect(result).toEqual([{ currency: "USD", value: "2" }]);
}); });
}); });
+25 -11
View File
@@ -152,7 +152,7 @@ function toAccountPagePositionSections(
const denominators = new Map<string, number>(); const denominators = new Map<string, number>();
for (const position of portfolio.positions) { for (const position of portfolio.positions) {
if (position.type === "CURRENCY" || !position.currentValue) continue; if (!position.currentValue) continue;
const currency = position.currentValue.currency; const currency = position.currentValue.currency;
denominators.set( denominators.set(
currency, currency,
@@ -249,23 +249,37 @@ function toSection(
function toUnrealizedReturns( function toUnrealizedReturns(
positions: Portfolio["positions"], positions: Portfolio["positions"],
): Money | null { ): Money[] {
const values: Decimal[] = []; const totals = new Map<string, {
complete: boolean;
values: Decimal[];
}>();
for (const position of positions) { for (const position of positions) {
if (!position.absoluteReturn) { const currencies = position.absoluteReturn
// A position without any valuation has nothing to contribute. ? [position.absoluteReturn.currency]
: [...new Set([
position.averagePurchasePrice?.currency,
position.currentValue?.currency,
].filter((currency): currency is string => !!currency))];
if (!position.averagePurchasePrice && !position.currentValue) for (const currency of currencies) {
continue; const total = totals.get(currency) ?? { complete: true, values: [] };
return null; if (position.absoluteReturn) {
total.values.push(decimalFromString(position.absoluteReturn.value));
}
else {
total.complete = false;
}
totals.set(currency, total);
} }
values.push(decimalFromString(position.absoluteReturn.value));
} }
return { currency: "RUB", value: decimalToString(sumDecimals(values)) }; return [...totals].flatMap(([currency, total]) => total.complete
? [{ currency, value: decimalToString(sumDecimals(total.values)) }]
: []);
} }
export { export {
+1 -1
View File
@@ -64,7 +64,7 @@ function toIncomeSummary(
if (income.meta?.completeness === "incomplete") return { state: "partial", to }; if (income.meta?.completeness === "incomplete") return { state: "partial", to };
if (!income.data && (income.isError || income.meta?.lastAttempt.state === "failed")) if (!income.data && (income.isError || income.meta?.lastAttempt.state === "failed"))
return { state: "error", to }; return { state: "error", to };
if (!income.data) return { state: "empty", to }; if (!income.data?.totalsByCurrency.length) return { state: "empty", to };
return { return {
state: "loaded", state: "loaded",
@@ -96,7 +96,7 @@ function renderPage(ui: React.ReactNode) {
const portfolio const portfolio
= { = {
cash: { currency: "RUB", value: "0.5" }, cashBalances: [{ currency: "USD", value: "0.5" }],
isStale: true, isStale: true,
lastSuccessfulAt: "2026-08-13T10:00:00Z", lastSuccessfulAt: "2026-08-13T10:00:00Z",
positions: [ positions: [
@@ -129,8 +129,8 @@ const portfolio
type: "BOND", type: "BOND",
}, },
] satisfies [Portfolio["positions"][number], ...Portfolio["positions"]], ] satisfies [Portfolio["positions"][number], ...Portfolio["positions"]],
structure: { categories: [], hasNegativeValues: false, totalValue: "0", unvaluedCategories: [] }, structure: { currencies: [], unvaluedCategories: [] },
totals: { currency: "RUB", value: "1234.5" }, totalsByCurrency: [{ currency: "RUB", value: "1234.5" }],
} satisfies Portfolio; } satisfies Portfolio;
function position( function position(
@@ -201,6 +201,8 @@ const LoadedHarness = (
function loadedProps(nextPortfolio: Portfolio = portfolio) { function loadedProps(nextPortfolio: Portfolio = portfolio) {
return { return {
concentration: null, concentration: null,
currency: null,
onCurrencyChange: vi.fn(),
onSliceChange: vi.fn(), onSliceChange: vi.fn(),
portfolio: nextPortfolio, portfolio: nextPortfolio,
positionSections: toAccountPagePositionSections(nextPortfolio), positionSections: toAccountPagePositionSections(nextPortfolio),
@@ -240,10 +242,10 @@ describe("AccountPage states", () => {
HttpResponse.json(resource( HttpResponse.json(resource(
{ {
...portfolio, ...portfolio,
cash: { currency: "RUB", value: "0" }, cashBalances: [],
isStale: false, isStale: false,
positions: [], positions: [],
totals: { currency: "RUB", value: "0" }, totalsByCurrency: [],
}, },
{ {
lastAttempt: { lastAttempt: {
@@ -276,7 +278,7 @@ describe("AccountPage states", () => {
const data = { ...portfolio, positions }; const data = { ...portfolio, positions };
const model = toConcentration( const model = toConcentration(
data, "stocks", concentrationLabels(), data, "stocks", "RUB", concentrationLabels(),
); );
const props = loadedProps(data); const props = loadedProps(data);
@@ -285,6 +287,7 @@ describe("AccountPage states", () => {
{...props} {...props}
accountId="a" accountId="a"
concentration={model} concentration={model}
currency="RUB"
tab="concentration" tab="concentration"
/>, />,
); );
@@ -323,8 +326,10 @@ describe("AccountPage states", () => {
concentration={toConcentration( concentration={toConcentration(
{ ...portfolio, positions }, { ...portfolio, positions },
"stocks", "stocks",
"RUB",
concentrationLabels(), concentrationLabels(),
)} )}
currency="RUB"
tab="concentration" tab="concentration"
/>, />,
); );
@@ -349,8 +354,9 @@ describe("AccountPage states", () => {
{...loadedProps(data)} {...loadedProps(data)}
accountId="a" accountId="a"
concentration={toConcentration( concentration={toConcentration(
data, "stocks", concentrationLabels(), data, "stocks", "RUB", concentrationLabels(),
)} )}
currency="RUB"
tab="concentration" tab="concentration"
/>, />,
); );
@@ -364,8 +370,9 @@ describe("AccountPage states", () => {
{...loadedProps(data)} {...loadedProps(data)}
accountId="a" accountId="a"
concentration={toConcentration( concentration={toConcentration(
data, "issuers", concentrationLabels(), data, "issuers", "RUB", concentrationLabels(),
)} )}
currency="RUB"
slice="issuers" slice="issuers"
tab="concentration" tab="concentration"
/>, />,
@@ -385,8 +392,9 @@ describe("AccountPage states", () => {
{...loadedProps(data)} {...loadedProps(data)}
accountId="a" accountId="a"
concentration={toConcentration( concentration={toConcentration(
data, "stocks", concentrationLabels(), data, "stocks", "RUB", concentrationLabels(),
)} )}
currency="RUB"
tab="concentration" tab="concentration"
/>, />,
); );
@@ -416,8 +424,9 @@ describe("AccountPage states", () => {
{...loadedProps(data)} {...loadedProps(data)}
accountId="a" accountId="a"
concentration={toConcentration( concentration={toConcentration(
data, "sectors", concentrationLabels(), data, "sectors", "RUB", concentrationLabels(),
)} )}
currency="RUB"
slice="sectors" slice="sectors"
tab="concentration" tab="concentration"
/>, />,
@@ -448,8 +457,9 @@ describe("AccountPage states", () => {
{...loadedProps(data)} {...loadedProps(data)}
accountId="a" accountId="a"
concentration={toConcentration( concentration={toConcentration(
data, "stocks", concentrationLabels(), data, "stocks", "RUB", concentrationLabels(),
)} )}
currency="RUB"
tab="concentration" tab="concentration"
/>, />,
); );
@@ -474,8 +484,9 @@ describe("AccountPage states", () => {
{...loadedProps(data)} {...loadedProps(data)}
accountId="a" accountId="a"
concentration={toConcentration( concentration={toConcentration(
data, "stocks", concentrationLabels(), data, "stocks", "RUB", concentrationLabels(),
)} )}
currency="RUB"
partial partial
tab="concentration" tab="concentration"
/>, />,
@@ -485,14 +496,16 @@ describe("AccountPage states", () => {
.toHaveLength(1); .toHaveLength(1);
}); });
it("keeps the slice while switching page tabs and resets on account change", async () => { it("keeps slice and currency while switching page tabs and resets on account change", async () => {
const multiCurrency = { ...portfolio, positions: const multiCurrency = { ...portfolio, positions:
[400, 250, 150, 100, 50, 30, 20].map((value, index) => ["RUB", "USD"].flatMap(currency =>
position({ [400, 250, 150, 100, 50, 30, 20].map((value, index) =>
currentValue: { currency: "RUB", value: String(value) }, position({
displayName: `Акция ${index + 1}`, currentValue: { currency, value: String(value) },
sector: "energy", type: "STOCK", displayName: `${currency} акция ${index + 1}`,
}), sector: "energy", type: "STOCK",
}),
),
) }; ) };
server.use( server.use(
@@ -507,27 +520,37 @@ describe("AccountPage states", () => {
fireEvent.click(screen.getByRole("button", { name: /Прочее/ })); fireEvent.click(screen.getByRole("button", { name: /Прочее/ }));
expect(screen.getByRole("button", { name: /Прочее/ })) expect(screen.getByRole("button", { name: /Прочее/ }))
.toHaveAttribute("aria-expanded", "true"); .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("button", { name: /Прочее/ }));
fireEvent.click(screen.getByRole("radio", { name: "Секторы" })); fireEvent.click(screen.getByRole("radio", { name: "Секторы" }));
fireEvent.click(screen.getByRole("radio", { name: "Акции" })); fireEvent.click(screen.getByRole("radio", { name: "Акции" }));
expect(screen.getByRole("button", { name: /Прочее/ })) expect(screen.getByRole("button", { name: /Прочее/ }))
.toHaveAttribute("aria-expanded", "false"); .toHaveAttribute("aria-expanded", "false");
fireEvent.click(screen.getByRole("radio", { name: "Секторы" })); 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: "Позиции" }));
fireEvent.click(screen.getByRole("tab", { name: "Концентрация" })); fireEvent.click(screen.getByRole("tab", { name: "Концентрация" }));
expect(screen.getByRole("radio", { name: "Секторы" })) expect(screen.getByRole("radio", { name: "Секторы" }))
.toBeChecked(); .toBeChecked();
expect(screen.getByRole("combobox", { name: "Валюта концентрации" }))
.toHaveValue("USD");
fireEvent.click(screen.getByRole("button", { name: "Другой счёт" })); fireEvent.click(screen.getByRole("button", { name: "Другой счёт" }));
fireEvent.click(await screen.findByRole("tab", { name: "Концентрация" })); fireEvent.click(await screen.findByRole("tab", { name: "Концентрация" }));
expect(screen.getByRole("radio", { name: "Акции" })).toBeChecked(); expect(screen.getByRole("radio", { name: "Акции" })).toBeChecked();
expect(screen.getByRole("combobox", { name: "Валюта концентрации" }))
.toHaveValue("RUB");
}); });
it("includes all ruble positions in concentration", () => { it("falls back to the first currency when the selected one disappears", () => {
const data = { ...portfolio, positions: [ const data = { ...portfolio, positions: [
position({ position({
currentValue: { currency: "RUB", value: "200" }, currentValue: { currency: "USD", value: "200" },
}), }),
position({ position({
currentValue: { currency: "RUB", value: "100" }, currentValue: { currency: "RUB", value: "100" },
@@ -535,11 +558,12 @@ describe("AccountPage states", () => {
] }; ] };
const model = toConcentration( const model = toConcentration(
data, "stocks", concentrationLabels(), data, "stocks", "EUR", concentrationLabels(),
); );
expect(model.total).toBe("300"); expect(model.currencies).toEqual(["RUB", "USD"]);
expect(model.items.map(item => item.amount)).toEqual(["200", "100"]); expect(model.total).toBe("100");
expect(model.items.map(item => item.amount)).toEqual(["100"]);
}); });
it("shows the loaded portfolio source sync status", async () => { it("shows the loaded portfolio source sync status", async () => {
server.use( server.use(
@@ -655,7 +679,7 @@ describe("AccountPage states", () => {
).toHaveTextContent("Данные о структуре портфеля пока недоступны."); ).toHaveTextContent("Данные о структуре портфеля пока недоступны.");
expect( expect(
screen.getAllByRole("definition").map(node => node.textContent), screen.getAllByRole("definition").map(node => node.textContent),
).toEqual(["1 234,50 ₽"]); ).toEqual(["1 234,5 ₽"]);
}); });
it("keeps initial placeholders decorative without amounts or focus targets", () => { it("keeps initial placeholders decorative without amounts or focus targets", () => {
const { container } = renderPage(<AccountPageLoading />); const { container } = renderPage(<AccountPageLoading />);
@@ -698,7 +722,11 @@ describe("AccountPage states", () => {
<LoadedHarness <LoadedHarness
{...loadedProps({ {...loadedProps({
...portfolio, ...portfolio,
cash: { currency: "RUB", value: "234.500000000000000001" }, cashBalances: [
{ currency: "RUB", value: "234.500000000000000001" },
{ currency: "USD", value: "-0.5" },
{ currency: "XYZ", value: "3.00" },
],
positions: [ positions: [
...portfolio.positions, ...portfolio.positions,
{ {
@@ -709,7 +737,7 @@ describe("AccountPage states", () => {
type: "CURRENCY", type: "CURRENCY",
}, },
], ],
structure: { categories: [], hasNegativeValues: false, totalValue: "0", unvaluedCategories: [] }, structure: { currencies: [], unvaluedCategories: [] },
})} })}
/>, />,
); );
@@ -722,12 +750,14 @@ describe("AccountPage states", () => {
expect( expect(
balances.getAllByRole("definition").map(node => node.textContent), balances.getAllByRole("definition").map(node => node.textContent),
).toEqual(["234,50 ₽"]); ).toEqual(["234,500000000000000001 ₽", "-0,5 $", "3,00 XYZ"]);
expect( expect(
screen.getAllByRole("definition").map(node => node.textContent), screen.getAllByRole("definition").map(node => node.textContent),
).toEqual([ ).toEqual([
"1 234,50 ₽", "1 234,5 ₽",
"234,50 ₽", "234,500000000000000001 ₽",
"-0,5 $",
"3,00 XYZ",
]); ]);
expect(screen.queryByText("Торгуемая валюта")).not.toBeInTheDocument(); expect(screen.queryByText("Торгуемая валюта")).not.toBeInTheDocument();
expect( expect(
@@ -744,7 +774,7 @@ describe("AccountPage states", () => {
resource({ resource({
...portfolio, ...portfolio,
positions: hasPositions ? portfolio.positions : [], positions: hasPositions ? portfolio.positions : [],
totals: { currency: "RUB", value: "0" }, totalsByCurrency: [],
}), }),
), ),
), ),
@@ -752,20 +782,20 @@ describe("AccountPage states", () => {
renderAccountRoute(createTestQueryClient()); renderAccountRoute(createTestQueryClient());
await screen.findByRole("heading", { level: 1, name: "Портфель" }); await screen.findByRole("heading", { level: 1, name: "Портфель" });
fireEvent.click(screen.getByRole("tab", { name: "Деньги" })); fireEvent.click(screen.getByRole("tab", { name: "Деньги" }));
expect(screen.getByText("0,50 ₽")).toBeInTheDocument(); expect(screen.getByRole("definition")).toHaveTextContent("0,5 $");
expect(screen.queryByText("Портфель пока пуст")).not.toBeInTheDocument(); expect(screen.queryByText("Портфель пока пуст")).not.toBeInTheDocument();
}, },
); );
it.each([ it.each([
{ empty: true, totals: { currency: "RUB", value: "0" } }, { totalsByCurrency: [] },
{ empty: false, totals: { currency: "RUB", value: "100" } }, { totalsByCurrency: [{ currency: "RUB", value: "100" }] },
])( ])(
"hides the empty other section without emptying a currency-only portfolio (totals: $totals.value)", "hides the empty other section without emptying a currency-only portfolio (totals: $totalsByCurrency)",
async ({ empty, totals }) => { async ({ totalsByCurrency }) => {
const snapshot: Portfolio = { const snapshot: Portfolio = {
...portfolio, ...portfolio,
cash: { currency: "RUB", value: "0" }, cashBalances: [],
positions: [ positions: [
{ {
...portfolio.positions[0], ...portfolio.positions[0],
@@ -774,8 +804,8 @@ describe("AccountPage states", () => {
type: "CURRENCY", type: "CURRENCY",
}, },
], ],
structure: { categories: [], hasNegativeValues: false, totalValue: "0", unvaluedCategories: [] }, structure: { currencies: [], unvaluedCategories: [] },
totals, totalsByCurrency,
}; };
server.use( server.use(
@@ -796,11 +826,11 @@ describe("AccountPage states", () => {
expect( expect(
within( within(
screen.getByRole("tabpanel", { name: "Деньги" }), screen.getByRole("tabpanel", { name: "Деньги" }),
).getByRole("definition"), ).queryByRole("definition"),
).toHaveTextContent("0,00 ₽"); ).not.toBeInTheDocument();
expect( expect(
screen.queryAllByRole("definition").map(node => node.textContent), screen.queryAllByRole("definition").map(node => node.textContent),
).toEqual([empty ? "0,00 ₽" : "100,00 ₽", "0,00 ₽"]); ).toEqual(totalsByCurrency.length > 0 ? ["100 ₽"] : []);
expect( expect(
queryClient.getQueryData(portfolioQueryOptions("account-1").queryKey), queryClient.getQueryData(portfolioQueryOptions("account-1").queryKey),
).toEqual(resource(snapshot)); ).toEqual(resource(snapshot));
@@ -820,9 +850,9 @@ describe("AccountPage states", () => {
expect( expect(
screen.getByRole("heading", { name: "Портфель пока пуст" }), screen.getByRole("heading", { name: "Портфель пока пуст" }),
).toBeInTheDocument(); ).toBeInTheDocument();
expect(screen.getByRole("link", { name: "Открыть историю операций" })) expect(screen.getByRole("link", { name: "Открыть журнал" }))
.toBeInTheDocument(); .toBeInTheDocument();
expect(screen.getByRole("link", { name: "Открыть предстоящие события" })) expect(screen.getByRole("link", { name: "Открыть будущие события" }))
.toBeInTheDocument(); .toBeInTheDocument();
expect(screen.getByRole("link", { name: "Открыть пассивный доход" })) expect(screen.getByRole("link", { name: "Открыть пассивный доход" }))
.toBeInTheDocument(); .toBeInTheDocument();
@@ -863,10 +893,10 @@ describe("AccountPage states", () => {
}), }),
).toBeVisible(); ).toBeVisible();
expect( expect(
screen.getByRole("status", { name: "Загрузка предстоящих событий" }), screen.getByRole("status", { name: "Загрузка будущих событий" }),
).toBeVisible(); ).toBeVisible();
expect( expect(
screen.queryByText("На ближайшие 30 дней предстоящих событий не найдено."), screen.queryByText("На ближайшие 30 дней событий не найдено."),
).not.toBeInTheDocument(); ).not.toBeInTheDocument();
expect(screen.getByText("Загрузка дохода текущего месяца")).toBeVisible(); expect(screen.getByText("Загрузка дохода текущего месяца")).toBeVisible();
expect(screen.queryByText("Нет текущих сумм")).not.toBeInTheDocument(); expect(screen.queryByText("Нет текущих сумм")).not.toBeInTheDocument();
@@ -1046,7 +1076,7 @@ describe("AccountPage states", () => {
/>, />,
); );
fireEvent.click(screen.getByRole("tab", { name: "История операций" })); fireEvent.click(screen.getByRole("tab", { name: "Операции" }));
expect( expect(
screen.getByText("Пока недоступно"), screen.getByText("Пока недоступно"),
).toBeInTheDocument(); ).toBeInTheDocument();
@@ -1085,7 +1115,7 @@ describe("AccountPage states", () => {
/>, />,
); );
fireEvent.click(screen.getByRole("tab", { name: "История операций" })); fireEvent.click(screen.getByRole("tab", { name: "Операции" }));
expect(screen.getByText("Сделка")).toBeInTheDocument(); expect(screen.getByText("Сделка")).toBeInTheDocument();
expect(screen.getByText("−12 ₽")).toBeInTheDocument(); expect(screen.getByText("−12 ₽")).toBeInTheDocument();
}); });
@@ -1117,7 +1147,7 @@ describe("AccountPage states", () => {
date: "2026-09-11", date: "2026-09-11",
dateKind: "registry", dateKind: "registry",
displayName: "Акция", displayName: "Акция",
projectedAmount: { currency: "RUB", value: "0" }, projectedAmount: { currency: "USD", value: "0" },
type: "dividend", type: "dividend",
}, },
{ {
@@ -1139,8 +1169,8 @@ describe("AccountPage states", () => {
/>, />,
); );
fireEvent.click(screen.getByRole("tab", { name: "Предстоящие события" })); fireEvent.click(screen.getByRole("tab", { name: "События" }));
const section = screen.getByRole("tabpanel", { name: "Предстоящие события" }); const section = screen.getByRole("tabpanel", { name: "События" });
const content = within(section); const content = within(section);
const text = section.textContent ?? ""; const text = section.textContent ?? "";
@@ -1161,7 +1191,7 @@ describe("AccountPage states", () => {
expect( expect(
content.getByText("11.09.2026 · Дата реестра · RU000A0JX0J2"), content.getByText("11.09.2026 · Дата реестра · RU000A0JX0J2"),
).toBeVisible(); ).toBeVisible();
for (const amount of ["850,50 ₽", "0 ₽", "1 000 ₽", "Сумма неизвестна"]) for (const amount of ["850,50 ₽", "0 USD", "1 000 ₽", "Сумма неизвестна"])
expect(content.getByText(amount)).toBeVisible(); expect(content.getByText(amount)).toBeVisible();
expect(content.getAllByText("Прогноз")).toHaveLength(4); expect(content.getAllByText("Прогноз")).toHaveLength(4);
expect(content.queryByText("Без движения денег")).not.toBeInTheDocument(); expect(content.queryByText("Без движения денег")).not.toBeInTheDocument();
@@ -1181,15 +1211,15 @@ describe("AccountPage states", () => {
/>, />,
); );
fireEvent.click(screen.getByRole("tab", { name: "Предстоящие события" })); fireEvent.click(screen.getByRole("tab", { name: "События" }));
const content = within( const content = within(
screen.getByRole("tabpanel", { name: "Предстоящие события" }), screen.getByRole("tabpanel", { name: "События" }),
); );
if (state === "loading") { if (state === "loading") {
const skeleton = content.getByRole("status", { const skeleton = content.getByRole("status", {
name: "Загрузка предстоящих событий", name: "Загрузка будущих событий",
}); });
// eslint-disable-next-line vitest/no-conditional-expect -- The fixed table case selects the expected outcome; every case asserts its own branch. // eslint-disable-next-line vitest/no-conditional-expect -- The fixed table case selects the expected outcome; every case asserts its own branch.
@@ -1200,7 +1230,7 @@ describe("AccountPage states", () => {
else { else {
// eslint-disable-next-line vitest/no-conditional-expect -- The table case deterministically selects the empty state. // eslint-disable-next-line vitest/no-conditional-expect -- The table case deterministically selects the empty state.
expect( expect(
content.getByText("На ближайшие 30 дней предстоящих событий не найдено."), content.getByText("На ближайшие 30 дней событий не найдено."),
).toBeVisible(); ).toBeVisible();
} }
@@ -1221,7 +1251,7 @@ describe("AccountPage states", () => {
/>, />,
); );
fireEvent.click(screen.getByRole("tab", { name: "Предстоящие события" })); fireEvent.click(screen.getByRole("tab", { name: "События" }));
expect( expect(
screen.getByText("Данные появятся здесь, когда чтение восстановится."), screen.getByText("Данные появятся здесь, когда чтение восстановится."),
).toBeInTheDocument(); ).toBeInTheDocument();
@@ -1247,7 +1277,7 @@ describe("AccountPage states", () => {
return HttpResponse.json(resource( return HttpResponse.json(resource(
requests === 1 requests === 1
? portfolio ? portfolio
: { ...portfolio, totals: { currency: "RUB", value: "999" } }, : { ...portfolio, totalsByCurrency: [{ currency: "RUB", value: "999" }] },
{ resourceKey: "portfolio" }, { resourceKey: "portfolio" },
)); ));
}), }),
@@ -1271,14 +1301,14 @@ describe("AccountPage states", () => {
expect(screen.getByText("Не удалось обновить данные")).toBeInTheDocument(); expect(screen.getByText("Не удалось обновить данные")).toBeInTheDocument();
expect(screen.getByText("Вторая")).toBeInTheDocument(); expect(screen.getByText("Вторая")).toBeInTheDocument();
expect(screen.getByText("1 234,50 ₽")).toBeInTheDocument(); expect(screen.getByText("1 234,5 ₽")).toBeInTheDocument();
// One cached read plus the three automatic attempts. // One cached read plus the three automatic attempts.
expect(requests).toBe(4); expect(requests).toBe(4);
isFailing = false; isFailing = false;
fireEvent.click(retry); fireEvent.click(retry);
expect(await screen.findByText("999,00 ₽")).toBeInTheDocument(); expect(await screen.findByText("999 ₽")).toBeInTheDocument();
expect(requests).toBe(5); expect(requests).toBe(5);
expect(screen.queryByText("Не удалось обновить данные")) expect(screen.queryByText("Не удалось обновить данные"))
.not.toBeInTheDocument(); .not.toBeInTheDocument();
@@ -1340,9 +1370,14 @@ describe("AccountPage states", () => {
= { = {
...portfolio, ...portfolio,
structure: { structure: {
categories: [{ sharePercent: null, type: "BOND", value: "10" }], currencies: [
hasNegativeValues: false, {
totalValue: "10", categories: [{ sharePercent: null, type: "BOND", value: "10" }],
currency: "RUB",
hasNegativeValues: false,
totalValue: "10",
},
],
unvaluedCategories: [{ count: 1, type: "STOCK" }], unvaluedCategories: [{ count: 1, type: "STOCK" }],
}, },
}; };
@@ -1393,7 +1428,7 @@ describe("AccountPage states", () => {
expect(screen.getByText("Вторая")).toBeInTheDocument(); expect(screen.getByText("Вторая")).toBeInTheDocument();
expect( expect(
screen.getAllByRole("definition").map(node => node.textContent), screen.getAllByRole("definition").map(node => node.textContent),
).toEqual(["1 234,50 ₽"]); ).toEqual(["1 234,5 ₽"]);
expect(screen.queryByRole("button", { name: "Повторить" })) expect(screen.queryByRole("button", { name: "Повторить" }))
.not.toBeInTheDocument(); .not.toBeInTheDocument();
void queryClient.invalidateQueries({ void queryClient.invalidateQueries({
@@ -1403,17 +1438,17 @@ describe("AccountPage states", () => {
expect(structure).toHaveTextContent("10 ₽"); expect(structure).toHaveTextContent("10 ₽");
expect( expect(
screen.getAllByRole("definition").map(node => node.textContent), screen.getAllByRole("definition").map(node => node.textContent),
).toEqual(["1 234,50 ₽"]); ).toEqual(["1 234,5 ₽"]);
resolveRetry?.( resolveRetry?.(
HttpResponse.json( HttpResponse.json(
resource( resource(
{ {
...portfolio, ...portfolio,
cash: { currency: "RUB", value: "2.50" }, cashBalances: [{ currency: "EUR", value: "2.50" }],
isStale: false, isStale: false,
lastSuccessfulAt: "2026-08-14T11:30:00Z", lastSuccessfulAt: "2026-08-14T11:30:00Z",
structure: { categories: [], hasNegativeValues: false, totalValue: "0", unvaluedCategories: [] }, structure: { currencies: [], unvaluedCategories: [] },
totals: { currency: "RUB", value: "-0.5" }, totalsByCurrency: [{ currency: "USD", value: "-0.5" }],
}, },
{ dataRevision: "2", resourceKey: "portfolio" }, { dataRevision: "2", resourceKey: "portfolio" },
), ),
@@ -1422,7 +1457,7 @@ describe("AccountPage states", () => {
await waitFor(() => await waitFor(() =>
expect( expect(
screen.getAllByRole("definition").map(node => node.textContent), screen.getAllByRole("definition").map(node => node.textContent),
).toEqual(["-0,50 ₽"]), ).toEqual(["-0,5 $"]),
); );
expect(screen.getByText(/^Данные на /)).toHaveTextContent("Данные на"); expect(screen.getByText(/^Данные на /)).toHaveTextContent("Данные на");
expect(structure).not.toHaveTextContent("10 ₽"); expect(structure).not.toHaveTextContent("10 ₽");
@@ -1464,7 +1499,7 @@ describe("AccountPage states", () => {
expect(screen.getByText("Вторая")).toBeInTheDocument(); expect(screen.getByText("Вторая")).toBeInTheDocument();
expect( expect(
screen.getAllByRole("definition").map(node => node.textContent), screen.getAllByRole("definition").map(node => node.textContent),
).toEqual(["1 234,50 ₽"]); ).toEqual(["1 234,5 ₽"]);
expect(screen.getByText(/^Данные на /)).toBeInTheDocument(); expect(screen.getByText(/^Данные на /)).toBeInTheDocument();
expect( expect(
screen.queryByRole("status", { name: "Загрузка" }), screen.queryByRole("status", { name: "Загрузка" }),
@@ -1487,8 +1522,8 @@ describe("AccountPage states", () => {
.toBeVisible(); .toBeVisible();
expect(screen.queryByText("Вторая")).not.toBeInTheDocument(); expect(screen.queryByText("Вторая")).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("tab", { name: "История операций" })); fireEvent.click(screen.getByRole("tab", { name: "Операции" }));
expect(screen.getByText("Последних операций пока нет.")).toBeVisible(); expect(screen.getByText("Последних событий пока нет.")).toBeVisible();
}); });
it("links operation, event and income previews to their detail pages", () => { it("links operation, event and income previews to their detail pages", () => {
@@ -1503,8 +1538,8 @@ describe("AccountPage states", () => {
); );
for (const [tab, href] of [ for (const [tab, href] of [
["История операций", "/accounts/account-1/operations"], ["Операции", "/accounts/account-1/operations"],
["Предстоящие события", "/accounts/account-1/events"], ["События", "/accounts/account-1/events"],
["Доход", "/accounts/account-1/income"], ["Доход", "/accounts/account-1/income"],
]) { ]) {
fireEvent.click(screen.getByRole("tab", { name: tab })); fireEvent.click(screen.getByRole("tab", { name: tab }));
@@ -1620,7 +1655,7 @@ describe("AccountPage states", () => {
await screen.findByRole("heading", { level: 1, name: "Все счета" }); await screen.findByRole("heading", { level: 1, name: "Все счета" });
const operationGroup = screen.getByRole("list", { const operationGroup = screen.getByRole("list", {
name: /Выплата купонов BOND, Исполнено/, name: /Купон · BOND, Исполнено/,
}); });
expect(screen.getByText("+500 ₽")).toBeInTheDocument(); expect(screen.getByText("+500 ₽")).toBeInTheDocument();
@@ -1,46 +1,48 @@
import type { FC } from "react"; import type { FC } from "react";
import { useAccountPageVm } from "./AccountPage.vm"; import { useAccountPageViewModel } from "./AccountPage.vm";
import { AccountPageEmpty } from "./components/AccountPageEmpty.view"; import { AccountPageEmpty } from "./components/AccountPageEmpty.view";
import { AccountPageError } from "./components/AccountPageError.view"; import { AccountPageError } from "./components/AccountPageError.view";
import { AccountPageLoaded } from "./components/AccountPageLoaded.view"; import { AccountPageLoaded } from "./components/AccountPageLoaded.view";
import { AccountPageLoading } from "./components/AccountPageLoading/AccountPageLoading.view"; import { AccountPageLoading } from "./components/AccountPageLoading/AccountPageLoading.view";
const AccountPage: FC = () => { const AccountPage: FC = () => {
const vm = useAccountPageVm(); const viewModel = useAccountPageViewModel();
if (vm.state === "loading") return <AccountPageLoading />; if (viewModel.state === "loading") return <AccountPageLoading />;
if (vm.state === "error") return <AccountPageError {...vm} />; if (viewModel.state === "error") return <AccountPageError {...viewModel} />;
if (vm.state === "empty") if (viewModel.state === "empty")
return ( return (
<AccountPageEmpty <AccountPageEmpty
account={vm.account} account={viewModel.account}
accountId={vm.accountId} accountId={viewModel.accountId}
incomeSummary={vm.incomeSummary} incomeSummary={viewModel.incomeSummary}
readRetry={vm.readRetry} readRetry={viewModel.readRetry}
recentEvents={vm.recentEvents} recentEvents={viewModel.recentEvents}
recentOperations={vm.recentOperations} recentOperations={viewModel.recentOperations}
source={vm.source} source={viewModel.source}
/> />
); );
return ( return (
<AccountPageLoaded <AccountPageLoaded
account={vm.account} account={viewModel.account}
accountId={vm.accountId} accountId={viewModel.accountId}
concentration={vm.concentration} concentration={viewModel.concentration}
incomeSummary={vm.incomeSummary} currency={viewModel.currency}
onSliceChange={vm.onSliceChange} incomeSummary={viewModel.incomeSummary}
onTabChange={vm.onTabChange} onCurrencyChange={viewModel.onCurrencyChange}
partial={vm.partial} onSliceChange={viewModel.onSliceChange}
portfolio={vm.portfolio} onTabChange={viewModel.onTabChange}
positionSections={vm.positionSections} partial={viewModel.partial}
readRetry={vm.readRetry} portfolio={viewModel.portfolio}
recentEvents={vm.recentEvents} positionSections={viewModel.positionSections}
recentOperations={vm.recentOperations} readRetry={viewModel.readRetry}
slice={vm.slice} recentEvents={viewModel.recentEvents}
source={vm.source} recentOperations={viewModel.recentOperations}
tab={vm.tab} slice={viewModel.slice}
unrealizedReturns={vm.unrealizedReturns} source={viewModel.source}
tab={viewModel.tab}
unrealizedReturns={viewModel.unrealizedReturns}
/> />
); );
}; };
@@ -12,7 +12,7 @@ import { resource } from "@/test/resource";
import { server } from "@/test/server"; import { server } from "@/test/server";
import { portfolio } from "../../model/positions.fixtures"; import { portfolio } from "../../model/positions.fixtures";
import { useAccountPageVm } from "./AccountPage.vm"; import { useAccountPageViewModel } from "./AccountPage.vm";
function configureTestAuth() { function configureTestAuth() {
configureAuthClient({ configureAuthClient({
@@ -102,12 +102,12 @@ function mockLoadedAccountPage() {
); );
} }
describe("useAccountPageVm", () => { describe("useAccountPageViewModel", () => {
it("prepares recent operations and events state for presentation views", async () => { it("prepares recent operations and events state for presentation views", async () => {
configureTestAuth(); configureTestAuth();
mockLoadedAccountPage(); mockLoadedAccountPage();
const { result } = renderHook(() => useAccountPageVm(), { const { result } = renderHook(() => useAccountPageViewModel(), {
wrapper: createWrapper(), wrapper: createWrapper(),
}); });
@@ -154,7 +154,7 @@ describe("useAccountPageVm", () => {
), ),
); );
const { result } = renderHook(() => useAccountPageVm(), { const { result } = renderHook(() => useAccountPageViewModel(), {
wrapper: createWrapper(), wrapper: createWrapper(),
}); });
@@ -187,14 +187,14 @@ it("keeps overview sections independent when their source data is incomplete", a
http.get("*/api/v1/brokerage-accounts/:accountId/events", () => http.get("*/api/v1/brokerage-accounts/:accountId/events", () =>
HttpResponse.json( HttpResponse.json(
resource( resource(
{ events: [], incomeTotal: { currency: "RUB", value: "0" } }, { events: [], incomeTotalsByCurrency: [] },
{ completeness: "incomplete" }, { completeness: "incomplete" },
), ),
), ),
), ),
); );
const { result } = renderHook(() => useAccountPageVm(), { const { result } = renderHook(() => useAccountPageViewModel(), {
wrapper: createWrapper(), wrapper: createWrapper(),
}); });
@@ -213,8 +213,8 @@ it("keeps overview sections loaded without exposing technical retry", async () =
const sections: Record<string, unknown> const sections: Record<string, unknown>
= { = {
"events": { events: [], incomeTotal: { currency: "RUB", value: "0" } }, "events": { events: [], incomeTotalsByCurrency: [] },
"income-summary": { total: { coupon: { currency: "RUB", value: "0" }, dividend: { currency: "RUB", value: "0" }, total: { currency: "RUB", value: "0" } } }, "income-summary": { totalsByCurrency: [] },
"operations": { hasNext: false, items: [], nextCursor: null }, "operations": { hasNext: false, items: [], nextCursor: null },
portfolio, portfolio,
}; };
@@ -232,13 +232,13 @@ it("keeps overview sections loaded without exposing technical retry", async () =
), ),
); );
const { result } = renderHook(() => useAccountPageVm(), { const { result } = renderHook(() => useAccountPageViewModel(), {
wrapper: createWrapper(), wrapper: createWrapper(),
}); });
await waitFor(() => await waitFor(() =>
expect(result.current).toMatchObject({ expect(result.current).toMatchObject({
incomeSummary: { state: "loaded" }, incomeSummary: { state: "empty" },
recentEvents: { state: "empty" }, recentEvents: { state: "empty" },
recentOperations: { state: "empty" }, recentOperations: { state: "empty" },
state: "loaded", state: "loaded",
@@ -21,6 +21,7 @@ import { useInitialAvailability } from "@/shared/lib";
import { import {
type Concentration, type Concentration,
concentrationCurrencies,
type ConcentrationLabels, type ConcentrationLabels,
type Slice, type Slice,
toConcentration, toConcentration,
@@ -56,6 +57,8 @@ type AccountPageEmptyViewModel = AccountPageContent & {
type AccountPageLoadedViewModel = AccountPageContent & { type AccountPageLoadedViewModel = AccountPageContent & {
concentration: Concentration | null; concentration: Concentration | null;
currency: null | string;
onCurrencyChange: (currency: null | string) => void;
onSliceChange: (slice: Slice) => void; onSliceChange: (slice: Slice) => void;
onTabChange: (tab: null | string) => void; onTabChange: (tab: null | string) => void;
portfolio: Portfolio; portfolio: Portfolio;
@@ -63,9 +66,7 @@ type AccountPageLoadedViewModel = AccountPageContent & {
slice: Slice; slice: Slice;
state: "loaded"; state: "loaded";
tab: string; tab: string;
unrealizedReturns: unrealizedReturns: Portfolio["totalsByCurrency"];
| null
| { currency: string; value: string };
}; };
type AccountPageReadRetry = ReturnType<typeof readRetry>; type AccountPageReadRetry = ReturnType<typeof readRetry>;
@@ -76,20 +77,21 @@ type AccountPageViewModel
| { code?: string; readRetry: AccountPageReadRetry; state: "error" } | { code?: string; readRetry: AccountPageReadRetry; state: "error" }
| { state: "loading" }; | { state: "loading" };
function useAccountPageVm(): AccountPageViewModel { function useAccountPageViewModel(): AccountPageViewModel {
const { accountId } = useParams(); const { accountId } = useParams();
const { t } = useTranslation(); const { t } = useTranslation();
const [selection, setSelection] = useState<{ const [selection, setSelection] = useState<{
accountId?: string; accountId?: string;
currency: null | string;
slice: Slice; slice: Slice;
tab: string; tab: string;
}>({ accountId, slice: "stocks", tab: "positions" }); }>({ accountId, currency: null, slice: "stocks", tab: "positions" });
const current = selection.accountId === accountId const current = selection.accountId === accountId
? selection ? selection
: { : {
accountId, slice: "stocks" as const, tab: "positions", accountId, currency: null, slice: "stocks" as const, tab: "positions",
}; };
const updateSelection = (change: Partial<typeof selection>) => const updateSelection = (change: Partial<typeof selection>) =>
@@ -142,7 +144,7 @@ function useAccountPageVm(): AccountPageViewModel {
if ( if (
portfolio.meta?.availability === "available" portfolio.meta?.availability === "available"
&& portfolio.data.positions.length === 0 && portfolio.data.positions.length === 0
&& Number(portfolio.data.cash.value) === 0 && portfolio.data.cashBalances.length === 0
) )
return { return {
account, account,
@@ -156,6 +158,11 @@ function useAccountPageVm(): AccountPageViewModel {
}; };
const isPartial = portfolio.meta?.completeness === "incomplete"; 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 = { const labels: ConcentrationLabels = {
missingIssuer: t("portfolio.concentration.missingIssuer"), missingIssuer: t("portfolio.concentration.missingIssuer"),
@@ -166,14 +173,16 @@ function useAccountPageVm(): AccountPageViewModel {
const concentration = accountId !== "all" const concentration = accountId !== "all"
&& current.tab === "concentration" && current.tab === "concentration"
? toConcentration(portfolio.data, current.slice, labels) ? toConcentration(portfolio.data, current.slice, currency, labels)
: null; : null;
return { return {
account, account,
accountId, accountId,
concentration, concentration,
currency,
incomeSummary, incomeSummary,
onCurrencyChange: value => updateSelection({ currency: value }),
onSliceChange: slice => updateSelection({ slice }), onSliceChange: slice => updateSelection({ slice }),
onTabChange: tab => updateSelection({ tab: tab ?? "positions" }), onTabChange: tab => updateSelection({ tab: tab ?? "positions" }),
partial: isPartial, partial: isPartial,
@@ -190,4 +199,4 @@ function useAccountPageVm(): AccountPageViewModel {
}; };
} }
export { useAccountPageVm }; export { useAccountPageViewModel };
@@ -9,6 +9,7 @@ import type { InvestmentResourceMeta } from "@/shared/api";
import { BrokerageEventsPreview } from "@/entities/brokerage-event"; import { BrokerageEventsPreview } from "@/entities/brokerage-event";
import { ReadRetry, type ReadRetryProps, SourceSyncStatus } from "@/shared/ui"; import { ReadRetry, type ReadRetryProps, SourceSyncStatus } from "@/shared/ui";
import { PortfolioStructure } from "@/widgets/portfolio-structure";
import type { import type {
AccountPageIncomeSummary, AccountPageIncomeSummary,
@@ -18,7 +19,6 @@ import type {
import { AccountHeading } from "./AccountHeading.view"; import { AccountHeading } from "./AccountHeading.view";
import { IncomeSummary } from "./IncomeSummary.view"; import { IncomeSummary } from "./IncomeSummary.view";
import { PortfolioStructure } from "./PortfolioStructure/PortfolioStructure";
import { RecentOperations } from "./RecentOperations.view"; import { RecentOperations } from "./RecentOperations.view";
type AccountPageEmptyProps = { type AccountPageEmptyProps = {
@@ -55,12 +55,7 @@ const AccountPageEmpty: FC<AccountPageEmptyProps> = (props) => {
<StateView text={text} title={t("portfolio.emptyTitle")} /> <StateView text={text} title={t("portfolio.emptyTitle")} />
<PortfolioStructure <PortfolioStructure
structure={{ structure={{ currencies: [], unvaluedCategories: [] }}
categories: [],
hasNegativeValues: false,
totalValue: "0",
unvaluedCategories: [],
}}
/> />
<RecentOperations <RecentOperations
@@ -16,6 +16,7 @@ import type { InvestmentResourceMeta } from "@/shared/api";
import { BrokerageEventsPreview } from "@/entities/brokerage-event"; import { BrokerageEventsPreview } from "@/entities/brokerage-event";
import { formatMoney } from "@/shared/lib"; import { formatMoney } from "@/shared/lib";
import { ReadRetry, type ReadRetryProps, SourceSyncStatus } from "@/shared/ui"; import { ReadRetry, type ReadRetryProps, SourceSyncStatus } from "@/shared/ui";
import { PortfolioStructure } from "@/widgets/portfolio-structure";
import type { Concentration as ConcentrationModel, Slice } from "../../../model/concentration"; import type { Concentration as ConcentrationModel, Slice } from "../../../model/concentration";
import type { AccountPagePositionSection } from "../../../model/positions"; import type { AccountPagePositionSection } from "../../../model/positions";
@@ -28,9 +29,8 @@ import type {
import classes from "../AccountPage.module.css"; import classes from "../AccountPage.module.css";
import { AccountHeading } from "./AccountHeading.view"; import { AccountHeading } from "./AccountHeading.view";
import { Concentration } from "./Concentration/Concentration.view"; import { Concentration } from "./Concentration/Concentration.view";
import { CurrencyCards } from "./CurrencyCards/CurrencyCards.view";
import { IncomeSummary } from "./IncomeSummary.view"; import { IncomeSummary } from "./IncomeSummary.view";
import { PerformanceTab } from "./Performance/PerformanceTab";
import { PortfolioStructure } from "./PortfolioStructure/PortfolioStructure";
import { PositionSection } from "./PositionSection.view"; import { PositionSection } from "./PositionSection.view";
import { RecentOperations } from "./RecentOperations.view"; import { RecentOperations } from "./RecentOperations.view";
@@ -38,7 +38,9 @@ type AccountPageLoadedProps = {
account?: BrokerageAccount; account?: BrokerageAccount;
accountId?: string; accountId?: string;
concentration: ConcentrationModel | null; concentration: ConcentrationModel | null;
currency: null | string;
incomeSummary?: AccountPageIncomeSummary; incomeSummary?: AccountPageIncomeSummary;
onCurrencyChange: (currency: null | string) => void;
onSliceChange: (slice: Slice) => void; onSliceChange: (slice: Slice) => void;
onTabChange: (tab: null | string) => void; onTabChange: (tab: null | string) => void;
partial?: boolean; partial?: boolean;
@@ -50,9 +52,7 @@ type AccountPageLoadedProps = {
slice: Slice; slice: Slice;
source?: InvestmentResourceMeta; source?: InvestmentResourceMeta;
tab: string; tab: string;
unrealizedReturns: unrealizedReturns: Portfolio["totalsByCurrency"];
| null
| { currency: string; value: string };
}; };
const AccountPageLoaded: FC<AccountPageLoadedProps> = (props) => { const AccountPageLoaded: FC<AccountPageLoadedProps> = (props) => {
@@ -93,23 +93,9 @@ const AccountPageLoaded: FC<AccountPageLoadedProps> = (props) => {
? tab !== "concentration" && ( ? tab !== "concentration" && (
<Alert role="status">{t("investment.partial")}</Alert> <Alert role="status">{t("investment.partial")}</Alert>
) )
: ( : <CurrencyCards amounts={props.portfolio.totalsByCurrency} />}
<Stack component="dl" gap={4} m={0}>
<dt>
<Text fw={600}>{t("portfolio.total")}</Text>
</dt>
<dd style={{ margin: 0 }}> {!props.partial && props.unrealizedReturns.length > 0 && (
<Text className={classes.totalAmount}>
{formatMoney(props.portfolio.totals, {
withRoundedCents: true,
})}
</Text>
</dd>
</Stack>
)}
{!props.partial && props.unrealizedReturns && (
<Stack <Stack
aria-label={t("portfolio.unrealizedResult")} aria-label={t("portfolio.unrealizedResult")}
component="section" component="section"
@@ -118,7 +104,9 @@ const AccountPageLoaded: FC<AccountPageLoadedProps> = (props) => {
<Text fw={600}>{t("portfolio.unrealizedResult")}</Text> <Text fw={600}>{t("portfolio.unrealizedResult")}</Text>
<Group gap="md"> <Group gap="md">
<Text>{formatMoney(props.unrealizedReturns)}</Text> {props.unrealizedReturns.map(total => (
<Text key={total.currency}>{formatMoney(total)}</Text>
))}
</Group> </Group>
<Text c="dimmed" size="xs"> <Text c="dimmed" size="xs">
@@ -160,8 +148,6 @@ const AccountPageLoaded: FC<AccountPageLoadedProps> = (props) => {
<Tabs.Tab value="events">{t("portfolio.tabs.events")}</Tabs.Tab> <Tabs.Tab value="events">{t("portfolio.tabs.events")}</Tabs.Tab>
<Tabs.Tab value="income">{t("portfolio.tabs.income")}</Tabs.Tab> <Tabs.Tab value="income">{t("portfolio.tabs.income")}</Tabs.Tab>
<Tabs.Tab value="performance">{t("portfolio.tabs.performance")}</Tabs.Tab>
</Tabs.List> </Tabs.List>
<Tabs.Panel value="positions"> <Tabs.Panel value="positions">
@@ -185,8 +171,10 @@ const AccountPageLoaded: FC<AccountPageLoadedProps> = (props) => {
<Tabs.Panel value="concentration"> <Tabs.Panel value="concentration">
{props.concentration && ( {props.concentration && (
<Concentration <Concentration
key={props.slice} currency={props.currency}
key={`${props.slice}-${props.currency}`}
model={props.concentration} model={props.concentration}
onCurrencyChange={props.onCurrencyChange}
onSliceChange={props.onSliceChange} onSliceChange={props.onSliceChange}
partial={!!props.partial} partial={!!props.partial}
slice={props.slice} slice={props.slice}
@@ -196,17 +184,7 @@ const AccountPageLoaded: FC<AccountPageLoadedProps> = (props) => {
)} )}
<Tabs.Panel value="cash"> <Tabs.Panel value="cash">
<Stack component="dl" gap={4} m={0}> <CurrencyCards amounts={props.portfolio.cashBalances} />
<dt>
<Text fw={600}>{t("portfolio.cashTitle")}</Text>
</dt>
<dd style={{ margin: 0 }}>
<Text>
{formatMoney(props.portfolio.cash, { withRoundedCents: true })}
</Text>
</dd>
</Stack>
</Tabs.Panel> </Tabs.Panel>
<Tabs.Panel value="operations"> <Tabs.Panel value="operations">
@@ -233,10 +211,6 @@ const AccountPageLoaded: FC<AccountPageLoadedProps> = (props) => {
/> />
)} )}
</Tabs.Panel> </Tabs.Panel>
<Tabs.Panel value="performance">
{props.accountId && <PerformanceTab accountId={props.accountId} />}
</Tabs.Panel>
</Tabs> </Tabs>
</Stack> </Stack>
); );
@@ -1,6 +1,6 @@
import { DonutChart } from "@mantine/charts"; import { DonutChart } from "@mantine/charts";
import { import {
Alert, Box, Group, Paper, SegmentedControl, Stack, Alert, Box, Group, Paper, SegmentedControl, Select, Stack,
Text, Title, Text, Title,
} from "@mantine/core"; } from "@mantine/core";
import { type FC, useId, useState } from "react"; import { type FC, useId, useState } from "react";
@@ -18,7 +18,9 @@ import { PositionLogo } from "../PositionLogo.view";
import classes from "./Concentration.module.css"; import classes from "./Concentration.module.css";
type Props = { type Props = {
currency: null | string;
model: ConcentrationModel; model: ConcentrationModel;
onCurrencyChange: (currency: null | string) => void;
onSliceChange: (slice: Slice) => void; onSliceChange: (slice: Slice) => void;
partial: boolean; partial: boolean;
slice: Slice; slice: Slice;
@@ -33,6 +35,7 @@ const sliceLabels = {
type RowProps = { type RowProps = {
color: string; color: string;
currency: string;
index: number; index: number;
item: ConcentrationItem; item: ConcentrationItem;
logo?: ConcentrationItem["logo"]; logo?: ConcentrationItem["logo"];
@@ -47,8 +50,8 @@ const colors = [
const PERCENT = 100; const PERCENT = 100;
const OTHER_START = 6; const OTHER_START = 6;
function amountFor(item: ConcentrationItem): string { function amountFor(item: ConcentrationItem, currency: string): string {
return formatMoney({ currency: "RUB", value: item.amount }); return formatMoney({ currency, value: item.amount });
} }
const ConcentrationRow: FC<RowProps> = props => ( const ConcentrationRow: FC<RowProps> = props => (
@@ -80,7 +83,7 @@ const ConcentrationRow: FC<RowProps> = props => (
</span> </span>
<span className={classes.value}> <span className={classes.value}>
<strong>{amountFor(props.item)}</strong> <strong>{amountFor(props.item, props.currency)}</strong>
<Text c="dimmed" size="sm">{props.item.share}</Text> <Text c="dimmed" size="sm">{props.item.share}</Text>
</span> </span>
@@ -128,6 +131,7 @@ const Concentration: FC<Props> = (props) => {
const [expanded, setExpanded] = useState(false); const [expanded, setExpanded] = useState(false);
const otherId = useId(); const otherId = useId();
const chartId = useId(); const chartId = useId();
const currency = props.currency ?? "";
const rows = props.model.other const rows = props.model.other
? [...props.model.items, props.model.other] ? [...props.model.items, props.model.other]
@@ -143,8 +147,10 @@ const Concentration: FC<Props> = (props) => {
props.model.top5Share && t("portfolio.concentration.top5", { props.model.top5Share && t("portfolio.concentration.top5", {
share: props.model.top5Share, share: props.model.top5Share,
}), }),
props.model.total && formatMoney({ currency: "RUB", value: props.model.total }), props.model.total && formatMoney({
...rows.map(item => `${item.label}: ${amountFor(item)}, ${item.share}`), currency, value: props.model.total,
}),
...rows.map(item => `${item.label}: ${amountFor(item, currency)}, ${item.share}`),
].filter(Boolean).join("; "); ].filter(Boolean).join("; ");
const slices = [ const slices = [
@@ -164,7 +170,7 @@ const Concentration: FC<Props> = (props) => {
const pieProps = { isAnimationActive: false, label: rankLabel }; const pieProps = { isAnimationActive: false, label: rankLabel };
const tooltipRows = rows.map(item => ({ const tooltipRows = rows.map(item => ({
amount: amountFor(item), amount: amountFor(item, currency),
label: item.label, label: item.label,
share: item.share, share: item.share,
})); }));
@@ -172,7 +178,7 @@ const Concentration: FC<Props> = (props) => {
const tooltipProps = { content: <ChartTooltip rows={tooltipRows} /> }; const tooltipProps = { content: <ChartTooltip rows={tooltipRows} /> };
const totalText = props.model.total const totalText = props.model.total
? formatMoney({ currency: "RUB", value: props.model.total }) ? formatMoney({ currency, value: props.model.total })
: ""; : "";
return ( return (
@@ -186,6 +192,16 @@ const Concentration: FC<Props> = (props) => {
value={props.slice} value={props.slice}
/> />
{props.model.currencies.length > 1 && (
<Select
allowDeselect={false}
aria-label={t("portfolio.concentration.currency")}
data={props.model.currencies}
onChange={props.onCurrencyChange}
value={props.currency}
w={130}
/>
)}
</Group> </Group>
{props.partial && ( {props.partial && (
@@ -195,7 +211,9 @@ const Concentration: FC<Props> = (props) => {
{props.model.empty {props.model.empty
? ( ? (
<Stack gap="xs"> <Stack gap="xs">
<Text fw={600}>{t(sliceLabels[props.slice])}</Text> <Text fw={600}>{t(sliceLabels[props.slice])}
{props.currency && ` · ${props.currency}`}
</Text>
<Text>{t("portfolio.concentration.empty")}</Text> <Text>{t("portfolio.concentration.empty")}</Text>
</Stack> </Stack>
@@ -242,6 +260,7 @@ const Concentration: FC<Props> = (props) => {
{props.model.items.map((item, index) => ( {props.model.items.map((item, index) => (
<ConcentrationRow <ConcentrationRow
color={colors[index % colors.length] ?? "blue"} color={colors[index % colors.length] ?? "blue"}
currency={currency}
index={index + 1} index={index + 1}
item={item} item={item}
key={item.key} key={item.key}
@@ -266,7 +285,7 @@ const Concentration: FC<Props> = (props) => {
)} )}
</span> </span>
<span>{amountFor(props.model.other)} · { <span>{amountFor(props.model.other, currency)} · {
props.model.other.share props.model.other.share
} }
</span> </span>
@@ -282,6 +301,7 @@ const Concentration: FC<Props> = (props) => {
<ConcentrationRow <ConcentrationRow
color={colors[(index + OTHER_START) % colors.length] color={colors[(index + OTHER_START) % colors.length]
?? "blue"} ?? "blue"}
currency={currency}
index={index + OTHER_START} index={index + OTHER_START}
item={item} item={item}
key={item.key} key={item.key}
@@ -0,0 +1,77 @@
import { MantineProvider } from "@mantine/core";
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { CurrencyCards } from "./CurrencyCards.view";
describe("CurrencyCards currency presentation", () => {
it.each([0, 5])(
"renders only the %i supplied totals in upstream order",
(count) => {
const totals = [
{ currency: "EUR", value: "3.00" },
{ currency: "USD", value: "2.00" },
{ currency: "RUB", value: "1.00" },
{ currency: "CAD", value: "4.00" },
{ currency: "XYZ", value: "5.00" },
].slice(0, count);
const { container } = render(
<MantineProvider>
<CurrencyCards amounts={totals} />
</MantineProvider>,
);
expect(
screen.queryAllByRole("definition").map(node => node.textContent),
).toEqual(
["3,00 €", "2,00 $", "1,00 ₽", "4,00 CAD", "5,00 XYZ"].slice(0, count),
);
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access -- This assertion checks DOM structure, decoration or focus behavior that semantic queries cannot establish.
expect(container.querySelectorAll("dl")).toHaveLength(count);
expect(
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access -- This assertion checks DOM structure, decoration or focus behavior that semantic queries cannot establish.
container.querySelector("[tabindex], button, a, input"),
).toBeNull();
},
);
it.each([
["RUB", "125000.50", "125 000,50 ₽", "Российский рубль", "₽"],
["USD", "320.00", "320,00 $", "Доллар США", "$"],
["EUR", "9876.54321", "9 876,54321 €", "Евро", "€"],
[
"RUB",
"123456789012.345678901",
"123 456 789 012,345678901 ₽",
"Российский рубль",
"₽",
],
["USD", "-1234567.890123", "-1 234 567,890123 $", "Доллар США", "$"],
["EUR", "987654321.000001", "987 654 321,000001 €", "Евро", "€"],
["USD", "-0.5", "-0,5 $", "Доллар США", "$"],
["CAD", "20.00", "20,00 CAD", "CAD", "CAD"],
["XYZ", "1.000", "1,000 XYZ", "XYZ", "XYZ"],
])(
"preserves %s %s and its currency identity",
(currency, value, expected, name, symbol) => {
render(
<MantineProvider>
<CurrencyCards amounts={[{ currency, value }]} />
</MantineProvider>,
);
const term = screen.getByRole("term");
expect(term).toHaveTextContent(name);
// eslint-disable-next-line testing-library/no-node-access -- This assertion checks DOM structure, decoration or focus behavior that semantic queries cannot establish.
expect(term.querySelector("[aria-hidden=\"true\"]")).toHaveTextContent(
symbol,
);
expect(screen.getByRole("definition")).toHaveTextContent(expected);
// eslint-disable-next-line testing-library/no-node-access -- This assertion checks DOM structure, decoration or focus behavior that semantic queries cannot establish.
expect(term.parentElement).toBe(
// eslint-disable-next-line testing-library/no-node-access -- This assertion checks DOM structure, decoration or focus behavior that semantic queries cannot establish.
screen.getByRole("definition").parentElement,
);
},
);
});
@@ -0,0 +1,74 @@
import type { FC } from "react";
import { Paper, SimpleGrid, Text, VisuallyHidden } from "@mantine/core";
import { useTranslation } from "react-i18next";
import type { Portfolio } from "@/entities/portfolio";
import { formatDecimal } from "@/shared/lib";
import classes from "../../AccountPage.module.css";
const SIGN_PREFIX_LENGTH = 2;
type CurrencyCardsProps = {
amounts: Portfolio["totalsByCurrency"];
};
const CurrencyCards: FC<CurrencyCardsProps> = (props) => {
const { t } = useTranslation();
const { amounts } = props;
const currencies: Record<string, { name: string; symbol: string }> = {
EUR: { name: t("portfolio.currencies.EUR"), symbol: "€" },
RUB: { name: t("portfolio.currencies.RUB"), symbol: "₽" },
USD: { name: t("portfolio.currencies.USD"), symbol: "$" },
};
return (
<SimpleGrid cols={{ base: 1, md: 3, sm: 2 }} spacing={10}>
{amounts.map((money) => {
const amount = formatDecimal(money.value);
return (
<Paper
className={classes.total}
component="dl"
key={money.currency}
m={0}
withBorder
>
<Text className={classes.totalBadge} component="dt">
<span aria-hidden="true">
{currencies[money.currency]?.symbol ?? money.currency}
</span>
<VisuallyHidden>
{currencies[money.currency]?.name ?? money.currency}
</VisuallyHidden>
</Text>
<Text className={classes.totalAmount} component="dd" m={0}>
{amount.startsWith("-")
? (
<>
<span className={classes.totalLeadingDigits}>
{amount.slice(0, SIGN_PREFIX_LENGTH)}
</span>
{amount.slice(SIGN_PREFIX_LENGTH)}
</>
)
: (
amount
)}{" "}
{currencies[money.currency]?.symbol ?? money.currency}
</Text>
</Paper>
);
})}
</SimpleGrid>
);
};
export { CurrencyCards };
@@ -64,14 +64,16 @@ const IncomeSummary: FC<IncomeSummaryProps> = (props) => {
return ( return (
<Stack gap="xs"> <Stack gap="xs">
<Paper p="sm" withBorder> {props.summary.totalsByCurrency.map(total => (
<Text fw={700}>{formatMoney(props.summary.total.total)}</Text> <Paper key={total.currency} p="sm" withBorder>
<Text fw={700}>{formatMoney(total.total)}</Text>
<Text c="dimmed" size="sm"> <Text c="dimmed" size="sm">
{t("income.coupon")}: {formatMoney(props.summary.total.coupon)} ·{" "} {t("income.coupon")}: {formatMoney(total.coupon)} ·{" "}
{t("income.dividend")}: {formatMoney(props.summary.total.dividend)} {t("income.dividend")}: {formatMoney(total.dividend)}
</Text> </Text>
</Paper> </Paper>
))}
<Button component={Link} to={props.to} variant="subtle"> <Button component={Link} to={props.to} variant="subtle">
{props.moreLabel ?? t("income.open")} {props.moreLabel ?? t("income.open")}
@@ -1,3 +0,0 @@
.positive { color: var(--wl-color-positive); }
.negative { color: var(--wl-color-negative); }
.neutral { color: var(--mantine-color-dark-7); }
@@ -1,106 +0,0 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import type { Performance } from "@/entities/performance";
import { PerformanceView } from "./Performance.view";
const rub = (value: string) => ({ currency: "RUB", value });
const series = Array.from({ length: 24 }, (_, month) => {
const date = new Date(Date.UTC(2024, month, 1)).toISOString().slice(0, 10);
const invested = 300_000 + Math.floor(month / 6) * 150_000;
return {
benchmarkValue: String(Math.round(invested * (1 + month * 0.009))),
date,
netInvested: String(invested),
referenceValue: String(Math.round(invested * (1 + month * 0.004))),
};
});
const data: Performance = {
accountId: "account-1",
benchmark: {
asOf: "2025-12-01",
code: "MCFTRR",
return: { basis: "annual", unavailableReason: null, value: "0.108" },
unavailableReason: null,
value: rub("1387973"),
},
calculatedAt: "2025-12-01T00:00:00Z",
completeness: "complete",
currentValue: rub("1536400"),
excludedAccounts: [],
netInvested: rub("1060000"),
outperformance: { money: rub("148427"), percentagePoints: "0.043" },
period: { days: 700, from: "2024-01-01", to: "2025-12-01" },
profit: rub("476400"),
reference: {
asOf: "2025-12-01",
code: "IMOEX",
return: { basis: "annual", unavailableReason: null, value: "0.047" },
unavailableReason: null,
value: rub("1150000"),
},
return: { basis: "annual", unavailableReason: null, value: "0.151" },
series,
};
const meta = {
args: { data },
component: PerformanceView,
parameters: {
docs: {
description: {
component:
"Вкладка «Результат»: доходность, прибыль, те же деньги в индексе Мосбиржи и опережение. Синтетический серверный ответ без расчётов на клиенте.",
},
},
},
tags: ["autodocs"],
title: "Product/Performance",
} satisfies Meta<typeof PerformanceView>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Ahead: Story = {};
export const Behind: Story = {
args: {
data: {
...data,
currentValue: rub("1172400"),
outperformance: { money: rub("-215573"), percentagePoints: "-0.068" },
profit: rub("112400"),
return: { basis: "annual", unavailableReason: null, value: "0.04" },
},
},
};
export const ShortHistory: Story = {
args: {
data: {
...data,
benchmark: {
...data.benchmark,
return: { basis: "period", unavailableReason: null, value: "0.112" },
},
period: { days: 277, from: "2025-02-27", to: "2025-12-01" },
return: { basis: "period", unavailableReason: null, value: "0.074" },
},
},
};
export const Incomplete: Story = {
args: {
data: {
...data,
completeness: "incomplete",
excludedAccounts: [
{ accountId: "usd", name: "Валютный", reason: "PORTFOLIO_VALUATION_INCOMPLETE" },
],
},
},
};
@@ -1,185 +0,0 @@
import { LineChart } from "@mantine/charts";
import {
Accordion,
ActionIcon,
Alert,
Group,
Paper,
SimpleGrid,
Stack,
Text,
Title,
Tooltip,
} from "@mantine/core";
import { useTranslation } from "react-i18next";
import type { Performance } from "@/entities/performance";
import { formatChartValue, formatDate } from "@/shared/lib";
import { performanceVm } from "../../../../model/performance";
import classes from "./Performance.module.css";
const formatRub = (value: number) => `${formatChartValue(Math.round(value))} ₽`;
function PerformanceView({ data }: Readonly<{ data: Performance }>) {
const { t } = useTranslation();
const vm = performanceVm(data);
const basis = vm.basis === "annual"
? t("portfolio.performance.yearly")
: t("portfolio.performance.periodLabel", { days: vm.days });
const comparisonTone = vm.comparison?.points.tone ?? "neutral";
let comparisonTitle = t("portfolio.performance.marketEqual");
if (comparisonTone === "positive") comparisonTitle = t("portfolio.performance.marketAhead");
else if (comparisonTone === "negative") comparisonTitle = t("portfolio.performance.marketBehind");
const percentText = vm.returnValue.value;
const indexText = vm.indexReturn.value;
const currentLines = vm.currentPoint
? [{ color: "blue.6", label: t("portfolio.performance.current"), x: vm.currentPoint.date }]
: [];
const profitCaption = t("portfolio.performance.profitCaption", { current: vm.current, invested: vm.invested });
const chartLabel = [
`${t("portfolio.performance.invested")}: ${vm.invested}`,
`${t("portfolio.performance.benchmark")}: ${vm.indexValue}`,
`${t("portfolio.performance.current")}: ${vm.current}`,
`${t("portfolio.performance.returnLabel")}: ${percentText}`,
].join("; ");
return (
<Stack gap="md">
{vm.excluded.length > 0 && (
<Alert role="status" title={t("portfolio.performance.incomplete")}>
{vm.excluded.map(item => (
<Text key={item.id} size="sm">
{t("portfolio.performance.unavailableReason", item)}
</Text>
))}
</Alert>
)}
{vm.basis === "period" && (
<Alert role="status">
{t("portfolio.performance.periodNote", { days: vm.days })}
</Alert>
)}
<SimpleGrid cols={{ base: 1, lg: 4, sm: 2 }}>
<Paper component="dl" m={0} p="md" withBorder>
<Text component="dt" size="sm">{t("portfolio.performance.returnLabel")}</Text>
<Text className={classes[vm.returnValue.tone]} component="dd" fw={700} m={0} size="xl">
{percentText}
</Text>
<Text c="dimmed" size="xs">{vm.returnReason ?? basis}</Text>
</Paper>
<Paper component="dl" m={0} p="md" withBorder>
<Text component="dt" size="sm">{t("portfolio.performance.profit")}</Text>
<Text className={classes[vm.profit.tone]} component="dd" fw={700} m={0} size="xl">
{vm.profit.value}
</Text>
<Text c="dimmed" size="xs">{profitCaption}</Text>
</Paper>
<Paper component="dl" m={0} p="md" withBorder>
<Group gap="xs">
<Text component="dt" size="sm">{t("portfolio.performance.benchmark")}</Text>
<Tooltip
events={{ focus: true, hover: true, touch: true }}
label={t("portfolio.performance.benchmarkHint")}
multiline
w={280}
>
<ActionIcon
aria-label={t("portfolio.performance.benchmarkHint")}
size="xs"
variant="subtle"
>?
</ActionIcon>
</Tooltip>
</Group>
<Text component="dd" fw={700} m={0} size="xl">{indexText}</Text>
<Text c="dimmed" size="xs">
{vm.indexReason ?? `${basis} · ${vm.indexValue}`}
</Text>
</Paper>
<Paper component="dl" m={0} p="md" withBorder>
<Text component="dt" size="sm">
{comparisonTitle}
</Text>
<Text
className={classes[vm.comparison?.points.tone ?? "neutral"]}
component="dd"
fw={700}
m={0}
size="xl"
>
{vm.comparison?.points.value ?? "—"}
</Text>
<Text c="dimmed" size="xs">{vm.comparison?.money.value ?? vm.indexReason ?? vm.returnReason}</Text>
</Paper>
</SimpleGrid>
<Paper component="section" p="md" withBorder>
<Title mb="sm" order={2} size="h4">{t("portfolio.performance.chartTitle")}</Title>
{vm.series.length > 0
? (
<LineChart
aria-label={chartLabel}
curveType="linear"
data={vm.series}
dataKey="label"
gridAxis="y"
h={{ base: 220, sm: 300 }}
lineProps={{ isAnimationActive: false }}
referenceLines={currentLines}
series={[
{ color: "dark.4", label: t("portfolio.performance.invested"), name: "netInvested" },
{ color: "orange.6", label: t("portfolio.performance.benchmark"), name: "benchmarkValue" },
{ color: "gray.5", label: t("portfolio.performance.reference"), name: "referenceValue", strokeDasharray: "4 3" },
]}
tooltipAnimationDuration={0}
valueFormatter={formatRub}
yAxisProps={{ domain: [0, "auto"], width: 70 }}
/>
)
: <Text c="dimmed">{t("portfolio.performance.benchmarkUnavailable")}</Text>}
<Text c="dimmed" mt="sm" size="sm">
{t("portfolio.performance.missingHistory")}
</Text>
</Paper>
<Accordion variant="contained">
<Accordion.Item value="method">
<Accordion.Control>{t("portfolio.performance.calculation")}</Accordion.Control>
<Accordion.Panel>
<Text size="sm">
{t("portfolio.performance.datePeriod", { from: formatDate(`${vm.from}T00:00:00Z`), to: formatDate(`${vm.to}T00:00:00Z`) })}
</Text>
<Text mt="sm" size="sm">{t("portfolio.performance.method")}</Text>
</Accordion.Panel>
</Accordion.Item>
</Accordion>
</Stack>
);
}
export { PerformanceView };
@@ -1,193 +0,0 @@
import { MantineProvider } from "@mantine/core";
import { QueryClientProvider } from "@tanstack/react-query";
import { fireEvent, render, screen } from "@testing-library/react";
import { http, HttpResponse, type JsonBodyType } from "msw";
import { describe, expect, it, vi } from "vitest";
import type { Performance } from "@/entities/performance";
import { configureAuthClient } from "@/shared/api";
import { createTestQueryClient } from "@/test/query";
import { server } from "@/test/server";
import { PerformanceTab } from "./PerformanceTab";
const url = "*/api/v1/brokerage-accounts/:accountId/performance";
const rub = (value: string) => ({ currency: "RUB", value });
function performance(overrides: Partial<Performance> = {}): Performance {
return {
accountId: "account-1",
benchmark: {
asOf: "2025-12-31",
code: "MCFTRR",
return: { basis: "annual", unavailableReason: null, value: "0.048809" },
unavailableReason: null,
value: rub("110000"),
},
calculatedAt: "2026-01-01T00:00:00Z",
completeness: "complete",
currentValue: rub("121000"),
excludedAccounts: [],
netInvested: rub("100000"),
outperformance: { money: rub("11000"), percentagePoints: "0.051191" },
period: { days: 730, from: "2024-01-01", to: "2025-12-31" },
profit: rub("21000"),
reference: {
asOf: "2025-12-31",
code: "IMOEX",
return: { basis: "annual", unavailableReason: null, value: "0.02" },
unavailableReason: null,
value: rub("105000"),
},
return: { basis: "annual", unavailableReason: null, value: "0.1" },
series: [
{ benchmarkValue: "100000", date: "2024-01-01", netInvested: "100000", referenceValue: "100000" },
{ benchmarkValue: "110000", date: "2025-12-31", netInvested: "100000", referenceValue: "105000" },
],
...overrides,
};
}
function renderTab(body: JsonBodyType, status = 200) {
configureAuthClient({
accessToken: () => "test-token",
clear: vi.fn(),
refresh: () => Promise.resolve(true),
});
server.use(http.get(url, () => HttpResponse.json(body, { status })));
return render(
<QueryClientProvider client={createTestQueryClient()}>
<MantineProvider>
<PerformanceTab accountId="account-1" />
</MantineProvider>
</QueryClientProvider>,
);
}
const meta = { dependencies: [], unavailable: [] };
describe("PerformanceTab", () => {
it("shows return, profit, the same money in the index and outperformance with signs", async () => {
renderTab({ data: performance(), meta });
expect(await screen.findByText("+10,0 %")).toBeInTheDocument();
expect(screen.getByText("+21 000 ₽")).toBeInTheDocument();
expect(screen.getByText("+4,9 %")).toBeInTheDocument();
expect(screen.getByText("Опережение рынка")).toBeInTheDocument();
expect(screen.getByText("+5,1 п.п.")).toBeInTheDocument();
expect(screen.getByText("+11 000 ₽")).toBeInTheDocument();
// Index codes are allowed only inside "Как считали", not as captions.
expect(screen.getByText("Те же деньги в индексе Мосбиржи")).toBeInTheDocument();
expect(screen.queryByText("MCFTRR")).not.toBeInTheDocument();
});
it("labels lagging the market without colour-only meaning", async () => {
renderTab({
data: performance({
outperformance: { money: rub("-5000"), percentagePoints: "-0.02" },
}),
meta,
});
expect(await screen.findByText("Отставание от рынка")).toBeInTheDocument();
expect(screen.getByText("−2,0 п.п.")).toBeInTheDocument();
});
it("never says annual when history is shorter than a year", async () => {
renderTab({
data: performance({
benchmark: { ...performance().benchmark, return: { basis: "period", unavailableReason: null, value: "0.05" } },
period: { days: 200, from: "2025-06-14", to: "2025-12-31" },
return: { basis: "period", unavailableReason: null, value: "0.07" },
}),
meta,
});
expect(await screen.findByText(/проценты показаны за 200 дн\./)).toBeInTheDocument();
expect(screen.queryByText(/годовых/)).not.toBeInTheDocument();
});
it("lists excluded accounts when the all-accounts result is incomplete", async () => {
renderTab({
data: performance({
completeness: "incomplete",
excludedAccounts: [{ accountId: "usd", name: "Валютный", reason: "PORTFOLIO_VALUATION_INCOMPLETE" }],
}),
meta,
});
expect(await screen.findByText("Результат неполный")).toBeInTheDocument();
expect(screen.getByText("Валютный: Нет полной оценки портфеля")).toBeInTheDocument();
});
it("explains unavailability instead of showing zeros, including unknown reasons", async () => {
renderTab({
data: null,
meta: {
dependencies: [],
unavailable: [
{ accountId: "iis", name: "ИИС", reason: "HISTORY_START_UNKNOWN" },
{ accountId: "x", name: "Новый", reason: "SOMETHING_NEW" },
],
},
});
expect(await screen.findByText("Доходность недоступна")).toBeInTheDocument();
expect(screen.getByText("ИИС: Начало истории счёта неизвестно")).toBeInTheDocument();
expect(screen.getByText("Новый: Причину недоступности не удалось определить")).toBeInTheDocument();
expect(screen.queryByText(/0,0 %/)).not.toBeInTheDocument();
});
it("keeps profit and hides the comparison when the index is unavailable", async () => {
renderTab({
data: performance({
benchmark: {
asOf: null,
code: "MCFTRR",
return: { basis: "annual", unavailableReason: "INDEX_HISTORY_UNAVAILABLE", value: null },
unavailableReason: "INDEX_HISTORY_UNAVAILABLE",
value: null,
},
outperformance: null,
series: [],
}),
meta,
});
expect(await screen.findByText("+21 000 ₽")).toBeInTheDocument();
expect(screen.getByText("Сравнение с индексом сейчас недоступно")).toBeInTheDocument();
});
it("does not colour or call a rounded-to-zero difference outperformance", async () => {
renderTab({
data: performance({
outperformance: { money: rub("0"), percentagePoints: "-0.0004" },
}),
meta,
});
expect(await screen.findByText("Сравнение с рынком")).toBeInTheDocument();
expect(screen.getByText("0,0 п.п.")).toBeInTheDocument();
});
it("explains a pending sync when no account reasons are available", async () => {
renderTab({ data: null, meta });
expect(await screen.findByText(/ещё синхронизируются/)).toBeInTheDocument();
});
it("shows a local error with retry", async () => {
renderTab({ code: "INTERNAL_SERVER_ERROR", status: 500 }, 500);
expect(await screen.findByText("Не удалось загрузить результат инвестирования", {}, { timeout: 8000 })).toBeInTheDocument();
server.use(http.get(
url,
() => HttpResponse.json({ data: performance(), meta }),
));
fireEvent.click(screen.getByRole("button", { name: "Повторить" }));
expect(await screen.findByText("+10,0 %")).toBeInTheDocument();
}, 10_000);
});
@@ -1,66 +0,0 @@
import { Button, Paper, SimpleGrid, Skeleton, Stack, Text, Title } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { usePerformance } from "@/entities/performance";
import { reasonText } from "../../../../model/performance";
import { PerformanceView } from "./Performance.view";
function PerformanceTab({ accountId }: Readonly<{ accountId: string }>) {
const { t } = useTranslation();
const query = usePerformance(accountId);
if (query.isPending) return (
<Stack aria-label={t("portfolio.performance.returnLabel")} gap="md">
<SimpleGrid cols={{ base: 1, lg: 4, sm: 2 }}>
{["rate", "profit", "benchmark", "difference"].map(key => (
<Paper key={key} p="md" withBorder>
<Skeleton h={14} mb="sm" w="60%" />
<Skeleton h={28} w="80%" />
</Paper>
))}
</SimpleGrid>
<Skeleton h={220} />
</Stack>
);
const onRetry = () => {
void query.refetch();
};
if (query.isError) return (
<Paper p="lg" withBorder>
<Stack align="flex-start" gap="sm">
<Text>{t("portfolio.performance.error")}</Text>
<Button onClick={onRetry} variant="light">
{t("portfolio.performance.retry")}
</Button>
</Stack>
</Paper>
);
if (!query.data.data) return (
<Paper p="xl" withBorder>
<Title order={2} size="h4">{t("portfolio.performance.unavailable")}</Title>
{query.data.meta.unavailable.length === 0 && (
<Text c="dimmed" mt="sm">{t("portfolio.performance.syncing")}</Text>
)}
{query.data.meta.unavailable.map(item => (
<Text c="dimmed" key={item.accountId} mt="sm">
{t("portfolio.performance.unavailableReason", {
name: item.name,
reason: reasonText(item.reason),
})}
</Text>
))}
</Paper>
);
return <PerformanceView data={query.data.data} />;
}
export { PerformanceTab };
@@ -31,26 +31,16 @@ function configureTestAuth() {
}); });
} }
function portfolioSummary(totals: { currency: string; value: string }) { function portfolioSummary(totals: { currency: string; value: string }[]) {
return { return {
bondPositionCount: 1, bondPositionCount: 1,
cash: { currency: "RUB", value: "0" }, cashBalances: [],
fundPositionCount: 0, fundPositionCount: 0,
stockPositionCount: 0, stockPositionCount: 0,
structure: { structure: { currencies: [], unvaluedCategories: [] },
categories: [],
hasNegativeValues: false,
totalValue: "0",
unvaluedCategories: [],
},
totalPositionCount: 1, totalPositionCount: 1,
totals, totalsByCurrency: totals,
unrealizedReturn: { unrealizedReturnByCurrency: [],
absolute: "0",
costBasis: "0",
partial: false,
percent: null,
},
}; };
} }
@@ -69,18 +59,23 @@ function renderPage(queryClient: QueryClient) {
} }
describe("AccountsPage", () => { describe("AccountsPage", () => {
it("summarizes account totals and reports excluded accounts", async () => { it("summarizes currencies separately and reports excluded accounts", async () => {
server.use(http.get("*/api/v1/brokerage-accounts", () => server.use(http.get("*/api/v1/brokerage-accounts", () =>
HttpResponse.json(resource([ HttpResponse.json(resource([
{ {
...account, ...account,
portfolioSummary: portfolioSummary({ currency: "RUB", value: "100.10" }), portfolioSummary: portfolioSummary([
{ currency: "RUB", value: "100.10" },
{ currency: "USD", value: "20" },
]),
}, },
{ {
...account, ...account,
id: "account-2", id: "account-2",
name: "Второй", name: "Второй",
portfolioSummary: portfolioSummary({ currency: "RUB", value: "50.20" }), portfolioSummary: portfolioSummary([
{ currency: "RUB", value: "50.20" },
]),
}, },
{ {
...account, ...account,
@@ -93,9 +88,9 @@ describe("AccountsPage", () => {
id: "account-4", id: "account-4",
name: "Только деньги", name: "Только деньги",
portfolioSummary: { portfolioSummary: {
...portfolioSummary({ currency: "RUB", value: "25" }), ...portfolioSummary([{ currency: "RUB", value: "25" }]),
bondPositionCount: 0, bondPositionCount: 0,
cash: { currency: "RUB", value: "25" }, cashBalances: [{ currency: "RUB", value: "25" }],
totalPositionCount: 0, totalPositionCount: 0,
}, },
}, },
@@ -104,7 +99,7 @@ describe("AccountsPage", () => {
id: "account-5", id: "account-5",
name: "Без сумм", name: "Без сумм",
portfolioSummary: { portfolioSummary: {
...portfolioSummary({ currency: "RUB", value: "0" }), ...portfolioSummary([]),
totalPositionCount: 0, totalPositionCount: 0,
}, },
}, },
@@ -115,8 +110,7 @@ describe("AccountsPage", () => {
renderPage(client); renderPage(client);
expect(await screen.findByText("175,30 ₽")).toBeInTheDocument(); expect(await screen.findByText("175,30 ₽")).toBeInTheDocument();
expect(screen.queryByText("Ваши инвестиции по счетам")) expect(screen.getAllByText("20,00 USD")).not.toHaveLength(0);
.not.toBeInTheDocument();
expect(screen.getByText("5 счетов")).toBeInTheDocument(); expect(screen.getByText("5 счетов")).toBeInTheDocument();
expect(screen.getByText("Нет текущих сумм")).toBeInTheDocument(); expect(screen.getByText("Нет текущих сумм")).toBeInTheDocument();
expect(screen.getByText( expect(screen.getByText(
@@ -1,22 +1,22 @@
import type { FC } from "react"; import type { FC } from "react";
import { useAccountsPageVm } from "./AccountsPage.vm"; import { useAccountsPageViewModel } from "./AccountsPage.vm";
import { AccountsPageEmpty } from "./components/AccountsPageEmpty.view"; import { AccountsPageEmpty } from "./components/AccountsPageEmpty.view";
import { AccountsPageError } from "./components/AccountsPageError.view"; import { AccountsPageError } from "./components/AccountsPageError.view";
import { AccountsPageLoaded } from "./components/AccountsPageLoaded.view"; import { AccountsPageLoaded } from "./components/AccountsPageLoaded.view";
import { AccountsPageLoading } from "./components/AccountsPageLoading.view"; import { AccountsPageLoading } from "./components/AccountsPageLoading.view";
const AccountsPage: FC = () => { const AccountsPage: FC = () => {
const vm = useAccountsPageVm(); const viewModel = useAccountsPageViewModel();
if (vm.state === "loading") return <AccountsPageLoading />; if (viewModel.state === "loading") return <AccountsPageLoading />;
if (vm.state === "error") return <AccountsPageError {...vm} />; if (viewModel.state === "error") return <AccountsPageError {...viewModel} />;
if (vm.state === "empty") return <AccountsPageEmpty />; if (viewModel.state === "empty") return <AccountsPageEmpty />;
return ( return (
<AccountsPageLoaded <AccountsPageLoaded
accounts={vm.accounts} accounts={viewModel.accounts}
meta={vm.meta} meta={viewModel.meta}
readRetry={vm.readRetry} readRetry={viewModel.readRetry}
/> />
); );
}; };
@@ -30,7 +30,7 @@ function isAccountsAccessFailure(code?: string) {
|| isApiErrorCode(code, ApiErrorCode.TbankNotConnected); || isApiErrorCode(code, ApiErrorCode.TbankNotConnected);
} }
function useAccountsPageVm(): AccountsPageViewModel { function useAccountsPageViewModel(): AccountsPageViewModel {
const accounts = useBrokerageAccounts(); const accounts = useBrokerageAccounts();
useScreenSync({ screen: "accounts" }, [accounts]); useScreenSync({ screen: "accounts" }, [accounts]);
@@ -73,4 +73,4 @@ function useAccountsPageVm(): AccountsPageViewModel {
}; };
} }
export { useAccountsPageVm }; export { useAccountsPageViewModel };
@@ -1,21 +0,0 @@
.summary {
min-width: 0;
padding-bottom: var(--mantine-spacing-lg);
border-bottom: 1px solid var(--wl-color-border);
}
.primaryTotal {
font-size: clamp(1.75rem, 4vw, 2.25rem);
font-variant-numeric: tabular-nums;
line-height: 1.2;
overflow-wrap: anywhere;
}
.otherTotal {
font-variant-numeric: tabular-nums;
overflow-wrap: anywhere;
}
.meta {
padding-top: var(--mantine-spacing-xs);
}
@@ -1,6 +1,6 @@
import type { FC } from "react"; import type { FC } from "react";
import { Button, Group, Stack, Text, Title } from "@mantine/core"; import { Button, SimpleGrid, Stack, Text, Title } from "@mantine/core";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Link } from "react-router"; import { Link } from "react-router";
@@ -13,8 +13,6 @@ import {
import { formatMoney } from "@/shared/lib"; import { formatMoney } from "@/shared/lib";
import { ReadRetry, type ReadRetryProps, SourceSyncStatus } from "@/shared/ui"; import { ReadRetry, type ReadRetryProps, SourceSyncStatus } from "@/shared/ui";
import classes from "./AccountsPageLoaded.module.css";
const DECIMAL_BASE = 10n; const DECIMAL_BASE = 10n;
const DECIMAL_PART_COUNT = 2; const DECIMAL_PART_COUNT = 2;
const ZERO = 0n; const ZERO = 0n;
@@ -25,16 +23,34 @@ type AccountsPageLoadedProps = {
readRetry?: ReadRetryProps; readRetry?: ReadRetryProps;
}; };
function accountTotals(accounts: BrokerageAccountCard[]): null | string { type Money = { currency: string; value: string };
const values: string[] = [];
function accountTotals(accounts: BrokerageAccountCard[]) {
const values = new Map<string, string[]>();
let excludedCount = 0;
for (const { summary } of accounts) { for (const { summary } of accounts) {
if (summary.state !== "available") continue; if (summary.state === "empty") continue;
values.push(summary.portfolio.totals.value); if (summary.state === "unavailable") {
excludedCount += 1;
continue;
}
for (const total of summary.portfolio.totalsByCurrency) {
values.set(total.currency, [
...values.get(total.currency) ?? [],
total.value,
]);
}
} }
return values.length > 0 ? sumDecimals(values) : null; const totals: Money[] = Array.from(values, ([currency, amounts]) => ({
currency,
value: sumDecimals(amounts),
}));
return { excludedCount, totals };
} }
function sumDecimals(values: string[]) { function sumDecimals(values: string[]) {
@@ -70,48 +86,53 @@ function sumDecimals(values: string[]) {
const AccountsPageLoaded: FC<AccountsPageLoadedProps> = (props) => { const AccountsPageLoaded: FC<AccountsPageLoadedProps> = (props) => {
const { t } = useTranslation(); const { t } = useTranslation();
const total = accountTotals(props.accounts); const { excludedCount, totals } = accountTotals(props.accounts);
const excludedCount = props.accounts.filter(
account => account.summary.state === "unavailable",
).length;
return ( return (
<Stack gap="lg"> <Stack gap="lg">
<Title order={1}>{t("accounts.title")}</Title> <Stack gap={4}>
<Title order={1}>{t("accounts.title")}</Title>
<Stack className={classes.summary} gap="xs">
<Text c="dimmed" size="sm"> <Text c="dimmed" size="sm">
{t("accounts.currentValue")} {t("accounts.subtitle")}
</Text> </Text>
</Stack>
{total === null <Stack gap="xs">
? ( <SimpleGrid cols={{ base: 1, xs: 2 }} spacing="xs">
<Text c="dimmed">{t("accounts.summaryNoTotals")}</Text> <Stack gap={4}>
) <Text c="dimmed" size="sm">
: ( {t("accounts.currentValue")}
<Text className={classes.primaryTotal} fw={700}> </Text>
{formatMoney({ currency: "RUB", value: total }, { withRoundedCents: true })}
{totals.length > 0
? totals.map(total => (
<Text fw={700} key={total.currency} size="lg">
{formatMoney(total, { withRoundedCents: true })}
</Text>
))
: <Text c="dimmed">{t("accounts.summaryNoTotals")}</Text>}
</Stack>
<Stack gap={4}>
<Text fw={600}>{t("accounts.count", {
count: props.accounts.length,
})}
</Text>
{excludedCount > 0 && (
<Text c="dimmed" size="sm">
{t("accounts.summaryExcluded", { count: excludedCount })}
</Text> </Text>
)} )}
</Stack>
</SimpleGrid>
<Group className={classes.meta} gap="xs" wrap="wrap"> <SourceSyncStatus
<Text c="dimmed" size="xs"> lastAttempt={props.meta.lastAttempt}
{t("accounts.count", { count: props.accounts.length })} lastSuccessfulAt={props.meta.lastSuccessfulAt}
</Text> stale={props.meta.freshness === "stale"}
/>
<SourceSyncStatus
lastAttempt={props.meta.lastAttempt}
lastSuccessfulAt={props.meta.lastSuccessfulAt}
stale={props.meta.freshness === "stale"}
/>
</Group>
{excludedCount > 0 && (
<Text c="dimmed" size="sm">
{t("accounts.summaryExcluded", { count: excludedCount })}
</Text>
)}
{!!props.readRetry && <ReadRetry {...props.readRetry} />} {!!props.readRetry && <ReadRetry {...props.readRetry} />}
</Stack> </Stack>
+1 -1
View File
@@ -351,7 +351,7 @@ describe("bond page", () => {
screen.queryByRole("heading", { name: "Моя позиция" }), screen.queryByRole("heading", { name: "Моя позиция" }),
).not.toBeInTheDocument(); ).not.toBeInTheDocument();
expect( expect(
screen.getByRole("heading", { name: "История операций" }), screen.getByRole("heading", { name: "Моя история событий" }),
).toBeVisible(); ).toBeVisible();
}); });
it( it(
@@ -120,52 +120,11 @@ const useOperationFormVm = (props: OperationFormProps) => {
body: { ...body, version }, body: { ...body, version },
id: props.operation.id, id: props.operation.id,
}); });
else { else
const base = { await operationCreation.mutateAsync({
amount: body.amount,
cardId: values.cardId,
date: values.date,
description: values.description,
id: intent, id: intent,
}; ...body,
});
switch (values.kind) {
case "expense": {
await operationCreation.mutateAsync({
...base,
categoryId: values.categoryId || defaultCategoryId,
kind: values.kind,
});
break;
}
case "refund": {
await operationCreation.mutateAsync({
...base,
kind: values.kind,
originalExpenseId: values.originalExpenseId,
});
break;
}
case "topup": {
await operationCreation.mutateAsync({
...base,
kind: values.kind,
});
break;
}
case "transfer": {
await operationCreation.mutateAsync({
...base,
kind: values.kind,
targetCardId: values.targetCardId,
});
break;
}
}
}
notifications.show({ notifications.show({
id: "bank-operation-saved-" + actionId, id: "bank-operation-saved-" + actionId,
+2 -2
View File
@@ -1,13 +1,13 @@
import type { FC } from "react"; import type { FC } from "react";
import { useDocumentTitle } from "@mantine/hooks";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { AuthPageLayout, LoginForm } from "@/features/auth"; import { AuthPageLayout, LoginForm } from "@/features/auth";
import { useAppDocumentTitle } from "@/shared/ui";
const LoginPage: FC = () => { const LoginPage: FC = () => {
const { t } = useTranslation(); const { t } = useTranslation();
useAppDocumentTitle(t("auth.login")); useDocumentTitle(`${t("auth.login")} — ${t("appName")}`);
return ( return (
<AuthPageLayout title={t("auth.login")}> <AuthPageLayout title={t("auth.login")}>
+2 -3
View File
@@ -1,15 +1,14 @@
import type { FC } from "react"; import type { FC } from "react";
import { Button, Stack, Title } from "@mantine/core"; import { Button, Stack, Title } from "@mantine/core";
import { useDocumentTitle } from "@mantine/hooks";
import { StateView } from "@wealthledger/ui"; import { StateView } from "@wealthledger/ui";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Link } from "react-router"; import { Link } from "react-router";
import { useAppDocumentTitle } from "@/shared/ui";
const NotFoundPage: FC = () => { const NotFoundPage: FC = () => {
const { t } = useTranslation(); const { t } = useTranslation();
useAppDocumentTitle(t("notFound.title")); useDocumentTitle(`${t("notFound.title")} — ${t("appName")}`);
return ( return (
<Stack> <Stack>
+2 -2
View File
@@ -1,13 +1,13 @@
import type { FC } from "react"; import type { FC } from "react";
import { useDocumentTitle } from "@mantine/hooks";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { AuthPageLayout, RegisterForm } from "@/features/auth"; import { AuthPageLayout, RegisterForm } from "@/features/auth";
import { useAppDocumentTitle } from "@/shared/ui";
const RegisterPage: FC = () => { const RegisterPage: FC = () => {
const { t } = useTranslation(); const { t } = useTranslation();
useAppDocumentTitle(t("auth.register")); useDocumentTitle(`${t("auth.register")} — ${t("appName")}`);
return ( return (
<AuthPageLayout title={t("auth.register")}> <AuthPageLayout title={t("auth.register")}>
@@ -1,24 +1,13 @@
import type { FC } from "react"; import type { FC } from "react";
import { Button, Stack, Title } from "@mantine/core"; import { Button, Stack, Title } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { useAuth } from "@/features/auth";
import { TbankConnectionSection } from "@/features/tbank-connection"; import { TbankConnectionSection } from "@/features/tbank-connection";
const SettingsPage: FC = () => { import { useSettingsPageVm } from "./SettingsPage.vm";
const { t } = useTranslation();
const { logout } = useAuth();
// eslint-disable-next-line frontend/readability -- the page VM was removed as degenerate (frontend#143); a one-off error-swallowing logout is not a ViewModel. const SettingsPage: FC = () => {
const onLogout = async () => { const { onLogout, t } = useSettingsPageVm();
try {
await logout();
}
catch {
// The session is already cleared; the route guard owns navigation.
}
};
return ( return (
<Stack> <Stack>
@@ -0,0 +1,21 @@
import { useTranslation } from "react-i18next";
import { useAuth } from "@/features/auth";
function useSettingsPageVm() {
const { t } = useTranslation();
const { logout } = useAuth();
const onLogout = async () => {
try {
await logout();
}
catch {
// The session is already cleared; the route guard owns navigation.
}
};
return { onLogout, t };
}
export { useSettingsPageVm };
@@ -258,10 +258,17 @@ function setup(
} }
describe("stock page", () => { describe("stock page", () => {
it("formats RUB money, ratio and percent metrics", async () => { it("preserves unknown and unambiguous currencies with compact, ratio and percent formatting", async () => {
setup( setup(
{ {
metrics: [ metrics: [
{
currency: null,
key: "marketCapitalization",
period: null,
unit: "money",
value: 1_234_567.89,
},
{ {
currency: "RUB", currency: "RUB",
key: "revenueTtm", key: "revenueTtm",
@@ -269,6 +276,20 @@ describe("stock page", () => {
unit: "money", unit: "money",
value: 1_234_567.89, 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, currency: null,
key: "peRatioTtm", key: "peRatioTtm",
@@ -286,8 +307,14 @@ describe("stock page", () => {
], ],
}, },
); );
expect(await screen.findByText("1,23 млн ₽")).toBeVisible(); expect(await screen.findByText("1,23 млн валюта неизвестна")).toBeVisible();
for (const value of ["5,68 ×", "12,35 %"]) for (const value of [
"1,23 млн ₽",
"1,23 млн USD",
"1,23 млн CAD",
"5,68 ×",
"12,35 %",
])
expect(screen.getByText(value)).toBeVisible(); expect(screen.getByText(value)).toBeVisible();
}); });
it("opens a direct ticker URL and uses its resolved UID for market and personal requests", async () => { it("opens a direct ticker URL and uses its resolved UID for market and personal requests", async () => {
@@ -463,7 +490,7 @@ describe("stock page", () => {
fireEvent.click(screen.getByRole("button", { name: "Далее" })); fireEvent.click(screen.getByRole("button", { name: "Далее" }));
controls.recoverOperations(); controls.recoverOperations();
expect(screen.queryByRole("button", { name: "Повторить" })).not.toBeInTheDocument(); expect(screen.queryByRole("button", { name: "Повторить" })).not.toBeInTheDocument();
expect(screen.getByRole("heading", { name: "История операций" })).toBeVisible(); expect(screen.getByRole("heading", { name: "Моя история событий" })).toBeVisible();
}, },
); );
it("keeps the last quote without manual refresh", async () => { it("keeps the last quote without manual refresh", async () => {
@@ -37,7 +37,7 @@ const StockCompany: FC<StockCompanyProps> = (props) => {
{props.company.metrics.map((metric) => { {props.company.metrics.map((metric) => {
const label = [t(`stock.metrics.${metric.key}`), metric.period].filter(Boolean).join(" · "); const label = [t(`stock.metrics.${metric.key}`), metric.period].filter(Boolean).join(" · ");
const formatter = metric.unit === "money" ? compactMetricFormatter : metricFormatter; const formatter = metric.unit === "money" ? compactMetricFormatter : metricFormatter;
const value = `${formatter.format(metric.value)} ${metricUnit(metric)}`; const value = `${formatter.format(metric.value)} ${metricUnit(metric, t("stock.currencyUnknown"))}`;
return ( return (
<InstrumentMetric key={metric.key} label={label} value={value} /> <InstrumentMetric key={metric.key} label={label} value={value} />
@@ -89,9 +89,14 @@ const StockCompany: FC<StockCompanyProps> = (props) => {
); );
}; };
function metricUnit(metric: NonNullable<StockPageVm["company"]>["metrics"][number]) { function metricUnit(
metric: NonNullable<StockPageVm["company"]>["metrics"][number],
unknownCurrency: string,
) {
if (metric.unit === "money") if (metric.unit === "money")
return "₽"; return metric.currency === "RUB"
? "₽"
: (metric.currency ?? unknownCurrency);
return metric.unit === "percent" ? "%" : "×"; return metric.unit === "percent" ? "%" : "×";
} }
@@ -69,6 +69,7 @@ const criteriaFields: CriteriaField[] = [
"SHARE_TYPE_PREFERRED", "SHARE_TYPE_PREFERRED",
], ],
}, },
{ label: "currency", options: ["RUB", "USD", "EUR", "CNY", "HKD", "TRY"] },
{ {
label: "realExchange", label: "realExchange",
options: [ options: [
@@ -183,6 +184,10 @@ const StockCard: FC<StockCardProps> = (props) => {
<Group gap="xs"> <Group gap="xs">
{exchange && <Badge color="gray" variant="light">{exchange}</Badge>} {exchange && <Badge color="gray" variant="light">{exchange}</Badge>}
{item.currency && (
<Badge color="gray" variant="light">{item.currency}</Badge>
)}
{lot && <Text c="dimmed" size="xs">{lot}</Text>} {lot && <Text c="dimmed" size="xs">{lot}</Text>}
</Group> </Group>
@@ -4,6 +4,7 @@ import { apiClient } from "@/shared/api";
type Criteria = { type Criteria = {
countryOfRisk: string; countryOfRisk: string;
currency: string;
maxLot: string; maxLot: string;
query: string; query: string;
realExchange: string; realExchange: string;
@@ -17,6 +18,7 @@ type StocksPageVm = ReturnType<typeof useStocksPageVm>;
const emptyCriteria: Criteria = { const emptyCriteria: Criteria = {
countryOfRisk: "", countryOfRisk: "",
currency: "",
maxLot: "", maxLot: "",
query: "", query: "",
realExchange: "", realExchange: "",
@@ -28,6 +30,7 @@ async function createSearch(criteria: Criteria) {
const { data } = await apiClient.POST("/api/v1/stocks/screener/search", { const { data } = await apiClient.POST("/api/v1/stocks/screener/search", {
body: { body: {
...(criteria.countryOfRisk && { countryOfRisk: criteria.countryOfRisk }), ...(criteria.countryOfRisk && { countryOfRisk: criteria.countryOfRisk }),
...(criteria.currency && { currency: criteria.currency }),
...(criteria.maxLot && { maxLot: Number(criteria.maxLot) }), ...(criteria.maxLot && { maxLot: Number(criteria.maxLot) }),
...(criteria.query && { query: criteria.query }), ...(criteria.query && { query: criteria.query }),
...(criteria.realExchange && { realExchange: criteria.realExchange }), ...(criteria.realExchange && { realExchange: criteria.realExchange }),
+52 -40
View File
@@ -6,7 +6,10 @@ import { createQueryWrapper } from "@/test/query";
import { resource } from "@/test/resource"; import { resource } from "@/test/resource";
import { server } from "@/test/server"; import { server } from "@/test/server";
import { useAuthenticatedQuery } from "./authenticated-query"; import {
useAuthenticatedQueries,
useAuthenticatedQuery,
} from "./authenticated-query";
import { apiQueryClient, configureAuthClient } from "./client"; import { apiQueryClient, configureAuthClient } from "./client";
const useSingleQuery = () => const useSingleQuery = () =>
@@ -14,49 +17,58 @@ const useSingleQuery = () =>
apiQueryClient.queryOptions("get", "/api/v1/brokerage-accounts"), apiQueryClient.queryOptions("get", "/api/v1/brokerage-accounts"),
); );
const useMultipleQueries = () =>
useAuthenticatedQueries([
apiQueryClient.queryOptions("get", "/api/v1/brokerage-accounts"),
])[0];
describe("useAuthenticatedQuery", () => { describe("useAuthenticatedQuery", () => {
it("keeps protected queries idle without a session and loads after session configuration", async () => { it.each(["single", "multiple"])(
const requests = vi.fn(); "keeps %s protected queries idle without a session and loads after session configuration",
server.use( async (mode) => {
http.get("*/api/v1/brokerage-accounts", () => { const requests = vi.fn();
requests(); server.use(
http.get("*/api/v1/brokerage-accounts", () => {
requests();
return HttpResponse.json( return HttpResponse.json(
resource([ resource([
{ {
id: "account-1", id: "account-1",
name: "Основной", name: "Основной",
openedOn: "2024-08-13", openedOn: "2024-08-13",
type: "BROKERAGE", type: "BROKERAGE",
}, },
]), ]),
); );
}), }),
); );
const { result } = renderHook(useSingleQuery, { const { result } = renderHook(
wrapper: createQueryWrapper().wrapper, mode === "single" ? useSingleQuery : useMultipleQueries,
}); { wrapper: createQueryWrapper().wrapper },
);
await new Promise(resolve => setTimeout(resolve, 0)); await new Promise(resolve => setTimeout(resolve, 0));
expect(requests).not.toHaveBeenCalled(); expect(requests).not.toHaveBeenCalled();
expect(result.current?.fetchStatus).toBe("idle"); expect(result.current?.fetchStatus).toBe("idle");
configureAuthClient({ configureAuthClient({
accessToken: () => "token", accessToken: () => "token",
clear: vi.fn(), clear: vi.fn(),
refresh: () => Promise.resolve(true), refresh: () => Promise.resolve(true),
}); });
await waitFor(() => expect(result.current?.isSuccess).toBe(true)); await waitFor(() => expect(result.current?.isSuccess).toBe(true));
expect(requests).toHaveBeenCalledOnce(); expect(requests).toHaveBeenCalledOnce();
expect(result.current?.data?.data).toEqual([ expect(result.current?.data?.data).toEqual([
{ {
id: "account-1", id: "account-1",
name: "Основной", name: "Основной",
openedOn: "2024-08-13", openedOn: "2024-08-13",
type: "BROKERAGE", type: "BROKERAGE",
}, },
]); ]);
}); },
);
}); });
+21 -1
View File
@@ -1,5 +1,6 @@
import { import {
type QueryKey, type QueryKey,
useQueries,
useQuery, useQuery,
type UseQueryOptions, type UseQueryOptions,
type UseQueryResult, type UseQueryResult,
@@ -11,6 +12,25 @@ import {
subscribeAuthenticatedSession, subscribeAuthenticatedSession,
} from "./client"; } from "./client";
function useAuthenticatedQueries<TData, TError, TQueryKey extends QueryKey>(
queries: UseQueryOptions<TData, TError, TData, TQueryKey>[],
) {
const hasSession = useHasAuthenticatedSession();
return useQueries({
queries: queries.map(
(options): UseQueryOptions<TData, TError, TData, TQueryKey> => ({
...options,
enabled: query =>
hasSession
&& (typeof options.enabled === "function"
? options.enabled(query)
: (options.enabled ?? true)),
}),
),
});
}
function useAuthenticatedQuery< function useAuthenticatedQuery<
TQueryFunctionData = unknown, TQueryFunctionData = unknown,
TError = Error, TError = Error,
@@ -38,4 +58,4 @@ function useHasAuthenticatedSession() {
); );
} }
export { useAuthenticatedQuery }; export { useAuthenticatedQueries, useAuthenticatedQuery };
+28 -27
View File
@@ -208,50 +208,51 @@ function parseJson(body: string): unknown {
} }
async function readAttempt(request: Request): Promise<ReadAttempt> { async function readAttempt(request: Request): Promise<ReadAttempt> {
// Manual timeout (not AbortSignal.timeout) so fake-timer tests can advance it. const controller = new AbortController();
const timeout = new AbortController(); const failure: { reason?: "abort" | "timeout" } = {};
const timer = setTimeout(() => timeout.abort(), GET_DEADLINE_MS);
const signal = AbortSignal.any([request.signal, timeout.signal]); const { promise: deadline, reject: rejectDeadline }
= Promise.withResolvers<never>();
const timeout = setTimeout(() => {
failure.reason = "timeout";
controller.abort();
rejectDeadline(abortError());
}, GET_DEADLINE_MS);
const abort = () => {
failure.reason = "abort";
controller.abort();
rejectDeadline(abortError());
};
request.signal.addEventListener("abort", abort, { once: true });
const timedRequest = new Request(request, { signal: controller.signal });
try { try {
// MSW keeps a stalled body readable after abort, so keep the race. const response = await Promise.race([fetch(timedRequest), deadline]);
const response = await Promise.race([ const body = await Promise.race([response.text(), deadline]);
fetch(new Request(request, { signal })), const problem = parseJson(body);
rejectOnAbort(signal),
]);
const body = await Promise.race([response.text(), rejectOnAbort(signal)]);
return { return {
problem: parseJson(body), problem,
reason: "transient", reason: "transient",
response: new Response(body, response), response: new Response(body, response),
}; };
} }
catch (error) { catch (error) {
if (request.signal.aborted) return { reason: "abort" }; if (failure.reason === "abort") return { reason: "abort" };
// Timeout or network failure: retryable. if (failure.reason === "timeout" || isNetworkFailure(error))
if (timeout.signal.aborted || isNetworkFailure(error))
return { reason: "transient" }; return { reason: "transient" };
throw error; throw error;
} }
finally { finally {
clearTimeout(timer); clearTimeout(timeout);
request.signal.removeEventListener("abort", abort);
} }
} }
function rejectOnAbort(signal: AbortSignal): Promise<never> {
return new Promise((_resolve, reject) => {
// The signal is either already aborted or one of two parents firing once.
signal.addEventListener(
"abort",
() => reject(abortError()),
{ once: true },
);
});
}
function retryAfterMs(response: Response): number | undefined { function retryAfterMs(response: Response): number | undefined {
const header = response.headers.get("Retry-After"); const header = response.headers.get("Retry-After");
if (!header) return undefined; if (!header) return undefined;
+250 -329
View File
@@ -276,38 +276,6 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/api/v1/brokerage-accounts/all/performance": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["PerformanceController_all"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/brokerage-accounts/{accountId}/performance": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["PerformanceController_account"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/brokerage-accounts/all/operations": { "/api/v1/brokerage-accounts/all/operations": {
parameters: { parameters: {
query?: never; query?: never;
@@ -740,6 +708,22 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/api/v1/tbank-sync/{id}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["TbankSyncController_status"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
} }
export type webhooks = Record<string, never>; export type webhooks = Record<string, never>;
export interface components { export interface components {
@@ -791,77 +775,6 @@ export interface components {
*/ */
password: string; password: string;
}; };
RubMoneyInputDto: {
/** @enum {string} */
currency: "RUB";
value: string;
};
CreateExpenseOperationDto: {
/** Format: uuid */
id: string;
/** Format: uuid */
cardId: string;
amount: components["schemas"]["RubMoneyInputDto"];
/** Format: date */
date: string;
description?: string;
/**
* @description discriminator enum property added by openapi-typescript
* @enum {string}
*/
kind: "expense";
/** Format: uuid */
categoryId?: string;
};
CreateTopupOperationDto: {
/** Format: uuid */
id: string;
/** Format: uuid */
cardId: string;
amount: components["schemas"]["RubMoneyInputDto"];
/** Format: date */
date: string;
description?: string;
/**
* @description discriminator enum property added by openapi-typescript
* @enum {string}
*/
kind: "topup";
};
CreateTransferOperationDto: {
/** Format: uuid */
id: string;
/** Format: uuid */
cardId: string;
amount: components["schemas"]["RubMoneyInputDto"];
/** Format: date */
date: string;
description?: string;
/**
* @description discriminator enum property added by openapi-typescript
* @enum {string}
*/
kind: "transfer";
/** Format: uuid */
targetCardId: string;
};
CreateRefundOperationDto: {
/** Format: uuid */
id: string;
/** Format: uuid */
cardId: string;
amount: components["schemas"]["RubMoneyInputDto"];
/** Format: date */
date: string;
description?: string;
/**
* @description discriminator enum property added by openapi-typescript
* @enum {string}
*/
kind: "refund";
/** Format: uuid */
originalExpenseId: string;
};
MoneyDto: { MoneyDto: {
currency: string; currency: string;
value: string; value: string;
@@ -879,6 +792,11 @@ export interface components {
archivedAt: string | null; archivedAt: string | null;
version: number; version: number;
}; };
RubMoneyInputDto: {
/** @enum {string} */
currency: "RUB";
value: string;
};
CreateBankCardDto: { CreateBankCardDto: {
/** Format: uuid */ /** Format: uuid */
id: string; id: string;
@@ -923,6 +841,24 @@ export interface components {
net: components["schemas"]["MoneyDto"]; net: components["schemas"]["MoneyDto"];
categories: components["schemas"]["BankSummaryCategoryDto"][]; categories: components["schemas"]["BankSummaryCategoryDto"][];
}; };
CreateBankOperationDto: {
/** Format: uuid */
id: string;
/** @enum {string} */
kind: "expense" | "topup" | "transfer" | "refund";
/** Format: uuid */
cardId: string;
/** Format: uuid */
targetCardId?: string;
/** Format: uuid */
categoryId?: string;
/** Format: uuid */
originalExpenseId?: string;
amount: components["schemas"]["RubMoneyInputDto"];
/** Format: date */
date: string;
description?: string;
};
BankOperationDto: { BankOperationDto: {
id: string; id: string;
/** @enum {string} */ /** @enum {string} */
@@ -984,21 +920,25 @@ export interface components {
/** @description Percentage including cash, rounded to six decimal places. Null for incomplete, negative or zero-total compositions. */ /** @description Percentage including cash, rounded to six decimal places. Null for incomplete, negative or zero-total compositions. */
sharePercent: string | null; sharePercent: string | null;
}; };
PortfolioStructureCurrencyDto: {
currency: string;
totalValue: string;
/** @description Any negative constituent, even if netted positive. */
hasNegativeValues: boolean;
categories: components["schemas"]["PortfolioStructureCategoryDto"][];
};
PortfolioUnvaluedCategoryDto: { PortfolioUnvaluedCategoryDto: {
/** @enum {string} */ /** @enum {string} */
type: "STOCK" | "BOND" | "OTHER"; type: "STOCK" | "BOND" | "OTHER";
count: number; count: number;
}; };
PortfolioStructureDto: { PortfolioStructureDto: {
/** @description Exact known total as a decimal string. */ currencies: components["schemas"]["PortfolioStructureCurrencyDto"][];
totalValue: string; /** @description Positions without current value and known valuation currency. Shares are unavailable across all currencies when nonempty. */
/** @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"][]; unvaluedCategories: components["schemas"]["PortfolioUnvaluedCategoryDto"][];
}; };
UnrealizedReturnDto: { UnrealizedReturnDto: {
currency: string;
/** @example 12.345678901 */ /** @example 12.345678901 */
absolute: string; absolute: string;
/** @example 100.00 */ /** @example 100.00 */
@@ -1008,10 +948,10 @@ export interface components {
partial: boolean; partial: boolean;
}; };
PortfolioSummaryDto: { PortfolioSummaryDto: {
totals: components["schemas"]["MoneyDto"]; totalsByCurrency: components["schemas"]["MoneyDto"][];
cash: components["schemas"]["MoneyDto"]; cashBalances: components["schemas"]["MoneyDto"][];
structure: components["schemas"]["PortfolioStructureDto"]; structure: components["schemas"]["PortfolioStructureDto"];
unrealizedReturn: components["schemas"]["UnrealizedReturnDto"]; unrealizedReturnByCurrency: components["schemas"]["UnrealizedReturnDto"][];
/** @description Number of non-zero stock positions, not the number of units. */ /** @description Number of non-zero stock positions, not the number of units. */
stockPositionCount: number; stockPositionCount: number;
/** @description Number of non-zero bond positions, not the number of units. */ /** @description Number of non-zero bond positions, not the number of units. */
@@ -1141,82 +1081,15 @@ export interface components {
/** Format: date-time */ /** Format: date-time */
lastSuccessfulAt: string; lastSuccessfulAt: string;
isStale: boolean; isStale: boolean;
totals: components["schemas"]["MoneyDto"]; totalsByCurrency: components["schemas"]["MoneyDto"][];
positions: components["schemas"]["PortfolioPositionDto"][]; positions: components["schemas"]["PortfolioPositionDto"][];
cash: components["schemas"]["MoneyDto"]; cashBalances: components["schemas"]["MoneyDto"][];
structure: components["schemas"]["PortfolioStructureDto"]; structure: components["schemas"]["PortfolioStructureDto"];
}; };
PortfolioResourceDto: { PortfolioResourceDto: {
data: components["schemas"]["PortfolioDto"] | null; data: components["schemas"]["PortfolioDto"] | null;
meta: components["schemas"]["InvestmentResourceMetaDto"]; meta: components["schemas"]["InvestmentResourceMetaDto"];
}; };
PerformancePeriodDto: {
/** Format: date */
from: string;
/** Format: date */
to: string;
days: number;
};
/** @enum {string} */
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;
/** @enum {string} */
basis: "annual" | "period";
unavailableReason: components["schemas"]["PerformanceUnavailableReason"] | null;
};
PerformanceIndexDto: {
/** @enum {string} */
code: "MCFTRR" | "IMOEX";
value: components["schemas"]["MoneyDto"] | null;
return: components["schemas"]["PerformanceReturnDto"];
/** Format: date */
asOf: string | null;
unavailableReason: components["schemas"]["PerformanceUnavailableReason"] | null;
};
PerformanceOutperformanceDto: {
/** @description Difference between rates as decimal fractions */
percentagePoints: string;
money: components["schemas"]["MoneyDto"];
};
PerformanceSeriesPointDto: {
/** Format: date */
date: string;
netInvested: string;
benchmarkValue: string;
referenceValue: string | null;
};
PerformanceExcludedAccountDto: {
accountId: string;
name: string;
reason: components["schemas"]["PerformanceUnavailableReason"];
};
PerformanceDto: {
accountId: string;
period: components["schemas"]["PerformancePeriodDto"];
netInvested: components["schemas"]["MoneyDto"];
currentValue: components["schemas"]["MoneyDto"];
profit: components["schemas"]["MoneyDto"];
return: components["schemas"]["PerformanceReturnDto"];
benchmark: components["schemas"]["PerformanceIndexDto"];
reference: components["schemas"]["PerformanceIndexDto"];
outperformance: components["schemas"]["PerformanceOutperformanceDto"] | null;
series: components["schemas"]["PerformanceSeriesPointDto"][];
/** @enum {string} */
completeness: "complete" | "incomplete";
excludedAccounts: components["schemas"]["PerformanceExcludedAccountDto"][];
/** Format: date-time */
calculatedAt: string;
};
PerformanceMetaDto: {
dependencies: components["schemas"]["InvestmentDependencyDto"][];
unavailable: components["schemas"]["PerformanceExcludedAccountDto"][];
};
PerformanceResourceDto: {
data: components["schemas"]["PerformanceDto"] | null;
meta: components["schemas"]["PerformanceMetaDto"];
};
BrokerageOperationDto: { BrokerageOperationDto: {
account?: components["schemas"]["AccountReferenceDto"]; account?: components["schemas"]["AccountReferenceDto"];
id: string; id: string;
@@ -1255,7 +1128,8 @@ export interface components {
/** Format: date */ /** Format: date */
to: string; to: string;
}; };
IncomeTotalDto: { IncomeCurrencySummaryDto: {
currency: string;
coupon: components["schemas"]["MoneyDto"]; coupon: components["schemas"]["MoneyDto"];
dividend: components["schemas"]["MoneyDto"]; dividend: components["schemas"]["MoneyDto"];
total: components["schemas"]["MoneyDto"]; total: components["schemas"]["MoneyDto"];
@@ -1263,7 +1137,7 @@ export interface components {
IncomeSummaryDto: { IncomeSummaryDto: {
accountId: string; accountId: string;
period: components["schemas"]["IncomePeriodDto"]; period: components["schemas"]["IncomePeriodDto"];
total: components["schemas"]["IncomeTotalDto"]; totalsByCurrency: components["schemas"]["IncomeCurrencySummaryDto"][];
/** Format: date-time */ /** Format: date-time */
calculatedAt: string; calculatedAt: string;
}; };
@@ -1273,7 +1147,7 @@ export interface components {
}; };
IncomeBucketDto: { IncomeBucketDto: {
period: components["schemas"]["IncomePeriodDto"]; period: components["schemas"]["IncomePeriodDto"];
total: components["schemas"]["IncomeTotalDto"]; totalsByCurrency: components["schemas"]["IncomeCurrencySummaryDto"][];
}; };
IncomeInstrumentDto: { IncomeInstrumentDto: {
key: string; key: string;
@@ -1311,7 +1185,7 @@ export interface components {
IncomeAnalyticsDto: { IncomeAnalyticsDto: {
accountId: string; accountId: string;
period: components["schemas"]["IncomePeriodDto"]; period: components["schemas"]["IncomePeriodDto"];
total: components["schemas"]["IncomeTotalDto"]; totalsByCurrency: components["schemas"]["IncomeCurrencySummaryDto"][];
/** Format: date-time */ /** Format: date-time */
calculatedAt: string; calculatedAt: string;
/** @enum {string} */ /** @enum {string} */
@@ -1387,7 +1261,7 @@ export interface components {
/** @description Inclusive UTC window the saved forecast was calculated for. Unchanged by the requested from/to filter. */ /** @description Inclusive UTC window the saved forecast was calculated for. Unchanged by the requested from/to filter. */
window: components["schemas"]["UpcomingEventsWindowDto"]; window: components["schemas"]["UpcomingEventsWindowDto"];
events: components["schemas"]["UpcomingEventDto"][]; events: components["schemas"]["UpcomingEventDto"][];
incomeTotal: components["schemas"]["MoneyDto"]; incomeTotalsByCurrency: components["schemas"]["MoneyDto"][];
}; };
UpcomingEventsResourceDto: { UpcomingEventsResourceDto: {
data: components["schemas"]["UpcomingEventsDto"] | null; data: components["schemas"]["UpcomingEventsDto"] | null;
@@ -1446,6 +1320,7 @@ export interface components {
query?: string; query?: string;
sector?: string; sector?: string;
shareType?: string; shareType?: string;
currency?: string;
realExchange?: string; realExchange?: string;
countryOfRisk?: string; countryOfRisk?: string;
maxLot?: number; maxLot?: number;
@@ -1785,6 +1660,84 @@ export interface components {
PutTbankConnectionDto: { PutTbankConnectionDto: {
token: string; token: string;
}; };
EventsInvestmentScreenDto: {
accountId: string;
/**
* @description discriminator enum property added by openapi-typescript
* @enum {string}
*/
screen: "events";
/**
* Format: date
* @description Inclusive UTC calendar start date (YYYY-MM-DD). May be supplied independently; defaults to today in UTC.
*/
from?: string;
/**
* Format: date
* @description Inclusive UTC calendar end date (YYYY-MM-DD). May be supplied independently; defaults to 30 days after from. Timestamp queries use the start of the following UTC day as the exclusive end.
*/
to?: string;
};
IncomeInvestmentScreenDto: {
accountId: string;
/**
* @description discriminator enum property added by openapi-typescript
* @enum {string}
*/
screen: "income";
/**
* Format: date
* @description Inclusive start calendar date in Europe/Moscow (YYYY-MM-DD). Supply both from and to, or neither for the current Moscow month.
*/
from?: string;
/**
* Format: date
* @description Inclusive end calendar date in Europe/Moscow (YYYY-MM-DD). Supply both from and to, or neither. Timestamp queries use the start of the following Moscow day as the exclusive end, capped at now.
*/
to?: string;
};
SyncCandleRangeDto: {
/** Format: date-time */
from: string;
/** Format: date-time */
to: string;
};
TimestampInvestmentScreenDto: {
accountId?: string;
/** Format: uuid */
instrumentUid?: string;
query?: string;
/** @enum {string} */
candlePeriod?: "month" | "halfYear" | "year" | "all";
candleRange?: components["schemas"]["SyncCandleRangeDto"];
categories?: ("trade" | "income" | "tax" | "fee" | "transfer" | "other")[];
limit?: number;
/**
* @description discriminator enum property added by openapi-typescript
* @enum {string}
*/
screen: "accounts" | "account" | "operations" | "stocks" | "bonds" | "stock" | "bond";
/**
* Format: date-time
* @description Start instant for operations.
*/
from?: string;
/**
* Format: date-time
* @description End instant for operations.
*/
to?: string;
};
TbankSyncStatusDto: {
/** Format: uuid */
id: string;
scope: (components["schemas"]["IncomeInvestmentScreenDto"] | components["schemas"]["EventsInvestmentScreenDto"] | components["schemas"]["TimestampInvestmentScreenDto"]) | null;
/** Format: date-time */
now: string;
/** @enum {string} */
state: "running" | "succeeded" | "failed" | "cancelled";
errorCode: string | null;
};
}; };
responses: never; responses: never;
parameters: never; parameters: never;
@@ -2645,7 +2598,7 @@ export interface operations {
}; };
requestBody: { requestBody: {
content: { content: {
"application/json": components["schemas"]["CreateExpenseOperationDto"] | components["schemas"]["CreateTopupOperationDto"] | components["schemas"]["CreateTransferOperationDto"] | components["schemas"]["CreateRefundOperationDto"]; "application/json": components["schemas"]["CreateBankOperationDto"];
}; };
}; };
responses: { responses: {
@@ -3524,136 +3477,6 @@ export interface operations {
}; };
}; };
}; };
PerformanceController_all: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["PerformanceResourceDto"];
};
};
/** @description Invalid request */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
/** @description Authentication required */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
/** @description Resource not found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
/** @description Internal server error */
500: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
/** @description Upstream service unavailable */
503: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
};
};
PerformanceController_account: {
parameters: {
query?: never;
header?: never;
path: {
accountId: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["PerformanceResourceDto"];
};
};
/** @description Invalid request */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
/** @description Authentication required */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
/** @description Resource not found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
/** @description Internal server error */
500: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
/** @description Upstream service unavailable */
503: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
};
};
BrokerageOperationsController_all: { BrokerageOperationsController_all: {
parameters: { parameters: {
query?: { query?: {
@@ -3802,7 +3625,9 @@ export interface operations {
}; };
IncomeAnalyticsController_allSummary: { IncomeAnalyticsController_allSummary: {
parameters: { parameters: {
query?: never; query?: {
sync?: "observe";
};
header?: never; header?: never;
path?: never; path?: never;
cookie?: never; cookie?: never;
@@ -3867,6 +3692,7 @@ export interface operations {
IncomeAnalyticsController_allAnalytics: { IncomeAnalyticsController_allAnalytics: {
parameters: { parameters: {
query?: { query?: {
sync?: "observe";
/** @description Inclusive calendar date in Europe/Moscow. Supply both from/to or neither. */ /** @description Inclusive calendar date in Europe/Moscow. Supply both from/to or neither. */
from?: string; from?: string;
/** @description Inclusive calendar date in Europe/Moscow, no later than today. Supply both from/to or neither. */ /** @description Inclusive calendar date in Europe/Moscow, no later than today. Supply both from/to or neither. */
@@ -3935,7 +3761,9 @@ export interface operations {
}; };
IncomeAnalyticsController_summary: { IncomeAnalyticsController_summary: {
parameters: { parameters: {
query?: never; query?: {
sync?: "observe";
};
header?: never; header?: never;
path: { path: {
accountId: string; accountId: string;
@@ -4002,6 +3830,7 @@ export interface operations {
IncomeAnalyticsController_analytics: { IncomeAnalyticsController_analytics: {
parameters: { parameters: {
query?: { query?: {
sync?: "observe";
/** @description Inclusive calendar date in Europe/Moscow. Supply both from/to or neither. */ /** @description Inclusive calendar date in Europe/Moscow. Supply both from/to or neither. */
from?: string; from?: string;
/** @description Inclusive calendar date in Europe/Moscow, no later than today. Supply both from/to or neither. */ /** @description Inclusive calendar date in Europe/Moscow, no later than today. Supply both from/to or neither. */
@@ -4073,6 +3902,7 @@ export interface operations {
UpcomingEventsController_all: { UpcomingEventsController_all: {
parameters: { parameters: {
query?: { query?: {
sync?: "observe";
/** @description Inclusive UTC calendar start date (YYYY-MM-DD). May be supplied independently; defaults to today in UTC. */ /** @description Inclusive UTC calendar start date (YYYY-MM-DD). May be supplied independently; defaults to today in UTC. */
from?: string; from?: string;
/** @description Inclusive UTC calendar end date (YYYY-MM-DD). May be supplied independently; defaults to 30 days after from. Timestamp queries use the start of the following UTC day as the exclusive end. */ /** @description Inclusive UTC calendar end date (YYYY-MM-DD). May be supplied independently; defaults to 30 days after from. Timestamp queries use the start of the following UTC day as the exclusive end. */
@@ -4142,6 +3972,7 @@ export interface operations {
UpcomingEventsController_get: { UpcomingEventsController_get: {
parameters: { parameters: {
query?: { query?: {
sync?: "observe";
/** @description Inclusive UTC calendar start date (YYYY-MM-DD). May be supplied independently; defaults to today in UTC. */ /** @description Inclusive UTC calendar start date (YYYY-MM-DD). May be supplied independently; defaults to today in UTC. */
from?: string; from?: string;
/** @description Inclusive UTC calendar end date (YYYY-MM-DD). May be supplied independently; defaults to 30 days after from. Timestamp queries use the start of the following UTC day as the exclusive end. */ /** @description Inclusive UTC calendar end date (YYYY-MM-DD). May be supplied independently; defaults to 30 days after from. Timestamp queries use the start of the following UTC day as the exclusive end. */
@@ -4213,6 +4044,7 @@ export interface operations {
StockController_search: { StockController_search: {
parameters: { parameters: {
query: { query: {
sync?: "observe";
query: string; query: string;
}; };
header?: never; header?: never;
@@ -4278,7 +4110,9 @@ export interface operations {
}; };
StockController_getByTicker: { StockController_getByTicker: {
parameters: { parameters: {
query?: never; query?: {
sync?: "observe";
};
header?: never; header?: never;
path: { path: {
ticker: string; ticker: string;
@@ -4480,7 +4314,9 @@ export interface operations {
}; };
StockController_get: { StockController_get: {
parameters: { parameters: {
query?: never; query?: {
sync?: "observe";
};
header?: never; header?: never;
path: { path: {
uid: string; uid: string;
@@ -4546,7 +4382,9 @@ export interface operations {
}; };
StockController_quote: { StockController_quote: {
parameters: { parameters: {
query?: never; query?: {
sync?: "observe";
};
header?: never; header?: never;
path: { path: {
uid: string; uid: string;
@@ -4613,6 +4451,7 @@ export interface operations {
StockController_candles: { StockController_candles: {
parameters: { parameters: {
query?: { query?: {
sync?: "observe";
period?: "month" | "halfYear" | "year" | "all"; period?: "month" | "halfYear" | "year" | "all";
}; };
header?: never; header?: never;
@@ -4746,7 +4585,9 @@ export interface operations {
}; };
StockController_company: { StockController_company: {
parameters: { parameters: {
query?: never; query?: {
sync?: "observe";
};
header?: never; header?: never;
path: { path: {
uid: string; uid: string;
@@ -4812,7 +4653,9 @@ export interface operations {
}; };
StockController_dividends: { StockController_dividends: {
parameters: { parameters: {
query?: never; query?: {
sync?: "observe";
};
header?: never; header?: never;
path: { path: {
uid: string; uid: string;
@@ -4878,7 +4721,9 @@ export interface operations {
}; };
BondController_metrics: { BondController_metrics: {
parameters: { parameters: {
query?: never; query?: {
sync?: "observe";
};
header?: never; header?: never;
path: { path: {
uid: string; uid: string;
@@ -4945,6 +4790,7 @@ export interface operations {
BondController_search: { BondController_search: {
parameters: { parameters: {
query: { query: {
sync?: "observe";
query: string; query: string;
}; };
header?: never; header?: never;
@@ -5010,7 +4856,9 @@ export interface operations {
}; };
BondController_getByTicker: { BondController_getByTicker: {
parameters: { parameters: {
query?: never; query?: {
sync?: "observe";
};
header?: never; header?: never;
path: { path: {
ticker: string; ticker: string;
@@ -5076,7 +4924,9 @@ export interface operations {
}; };
BondController_get: { BondController_get: {
parameters: { parameters: {
query?: never; query?: {
sync?: "observe";
};
header?: never; header?: never;
path: { path: {
uid: string; uid: string;
@@ -5142,7 +4992,9 @@ export interface operations {
}; };
BondController_quote: { BondController_quote: {
parameters: { parameters: {
query?: never; query?: {
sync?: "observe";
};
header?: never; header?: never;
path: { path: {
uid: string; uid: string;
@@ -5209,6 +5061,7 @@ export interface operations {
BondController_candles: { BondController_candles: {
parameters: { parameters: {
query?: { query?: {
sync?: "observe";
period?: "month" | "halfYear" | "year" | "all"; period?: "month" | "halfYear" | "year" | "all";
}; };
header?: never; header?: never;
@@ -5342,7 +5195,9 @@ export interface operations {
}; };
BondController_coupons: { BondController_coupons: {
parameters: { parameters: {
query?: never; query?: {
sync?: "observe";
};
header?: never; header?: never;
path: { path: {
uid: string; uid: string;
@@ -5600,4 +5455,70 @@ export interface operations {
}; };
}; };
}; };
TbankSyncController_status: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["TbankSyncStatusDto"];
};
};
/** @description Invalid request */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
/** @description Authentication required */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
/** @description Resource not found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
/** @description Internal server error */
500: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
/** @description Upstream service unavailable */
503: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
};
};
} }
-1
View File
@@ -13,7 +13,6 @@ export {
isApiErrorCode, isApiErrorCode,
isBrokerageAccessLoss, isBrokerageAccessLoss,
} from "./problem-details"; } from "./problem-details";
export { isGetUnder } from "./query-predicate";
export { isReadFailureExhausted, readRetry } from "./read-failure"; export { isReadFailureExhausted, readRetry } from "./read-failure";
export { export {
-26
View File
@@ -1,26 +0,0 @@
import { describe, expect, it } from "vitest";
import { isGetUnder } from "./query-predicate";
describe("isGetUnder", () => {
it("matches the root itself and nested paths for get queries only", () => {
const predicate = isGetUnder(["/api/v1/bank-cards"]);
expect(predicate(["get", "/api/v1/bank-cards"])).toBe(true);
expect(predicate(["get", "/api/v1/bank-cards/{id}"])).toBe(true);
expect(predicate(["get", "/api/v1/bank-cards"])).toBe(true);
expect(predicate(["get", "/api/v1/bank-cardsx"])).toBe(false);
expect(predicate(["post", "/api/v1/bank-cards"])).toBe(false);
expect(predicate(["get", "/api/v1/other"])).toBe(false);
});
it("checks every root and tolerates init and non-string parts", () => {
const predicate = isGetUnder(["/api/v1/stocks", "/api/v1/bonds"]);
expect(predicate(["get", "/api/v1/stocks/{uid}/quote", { params: {} }]))
.toBe(true);
expect(predicate(["get", "/api/v1/bonds"])).toBe(true);
expect(predicate([undefined, "/api/v1/stocks"])).toBe(false);
expect(predicate(["get", undefined])).toBe(false);
});
});
-18
View File
@@ -1,18 +0,0 @@
import type { QueryKey } from "@tanstack/react-query";
// openapi-react-query shapes query keys as [method, path, init].
function isGetUnder(roots: readonly string[]) {
const predicate = (queryKey: QueryKey) => {
const [method, path] = queryKey as readonly [unknown, unknown];
return (
method === "get"
&& typeof path === "string"
&& roots.some(root => path === root || path.startsWith(`${root}/`))
);
};
return predicate;
}
export { isGetUnder };
+78 -5
View File
@@ -20,12 +20,12 @@ function usePrivateResource(accountId: string) {
} }
const snapshot: Portfolio = { const snapshot: Portfolio = {
cash: { currency: "RUB", value: "0" }, cashBalances: [],
isStale: false, isStale: false,
lastSuccessfulAt: "2026-08-14T00:00:00Z", lastSuccessfulAt: "2026-08-14T00:00:00Z",
positions: [], positions: [],
structure: { categories: [], hasNegativeValues: false, totalValue: "0", unvaluedCategories: [] }, structure: { currencies: [], unvaluedCategories: [] },
totals: { currency: "RUB", value: "123.000000001" }, totalsByCurrency: [{ currency: "RUB", value: "123.000000001" }],
}; };
function auth() { function auth() {
@@ -37,6 +37,38 @@ function auth() {
} }
describe("persistent resource query lifecycle", () => { describe("persistent resource query lifecycle", () => {
it("observes once into the canonical cache without another import", async () => {
auth();
const modes: (null | string)[] = [];
let observations = 0;
server.use(
http.get(
"*/api/v1/brokerage-accounts/:accountId/portfolio",
({ request }) => {
const mode = new URL(request.url).searchParams.get("sync");
modes.push(mode);
if (mode) observations += 1;
return HttpResponse.json(
resource(snapshot, {
dataRevision: observations > 0 ? "new" : "old",
}),
);
},
),
);
const { queryClient, wrapper } = createQueryWrapper();
const { result } = renderHook(() => usePrivateResource("a"), { wrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
await act(() => result.current.observe());
await waitFor(() => expect(result.current.meta?.dataRevision).toBe("new"));
expect(modes).toEqual([null, "observe"]);
expect(queryClient.getQueryCache().getAll()).toHaveLength(1);
expect(
queryClient.getQueryData(resourceOptions("a").queryKey),
).toMatchObject({ data: snapshot, meta: { dataRevision: "new" } });
});
it("keeps successful HTTP state distinct from missing or failed source data", async () => { it("keeps successful HTTP state distinct from missing or failed source data", async () => {
auth(); auth();
server.use( server.use(
@@ -94,8 +126,8 @@ describe("persistent resource query lifecycle", () => {
isReady = true; isReady = true;
const loaded = await result.current.refetch(); const loaded = await result.current.refetch();
expect(loaded.data).toEqual(refreshed); expect(loaded.data).toEqual(refreshed);
expect(loaded.data?.data?.totals).toEqual( expect(loaded.data?.data?.totalsByCurrency).toEqual(
snapshot.totals, snapshot.totalsByCurrency,
); );
}); });
await waitFor(() => expect(result.current.data).toEqual(snapshot)); await waitFor(() => expect(result.current.data).toEqual(snapshot));
@@ -104,6 +136,47 @@ describe("persistent resource query lifecycle", () => {
expect(result.current.isSuccess).toBe(true); expect(result.current.isSuccess).toBe(true);
expect(result.current.isPending).toBe(false); expect(result.current.isPending).toBe(false);
}); });
it("keeps the observed snapshot when an older query finishes later", async () => {
auth();
const { promise: blocked, resolve: release }
= Promise.withResolvers<void>();
let isStarted = false;
server.use(
http.get(
"*/api/v1/brokerage-accounts/:accountId/portfolio",
async ({ request }) => {
if (!new URL(request.url).searchParams.has("sync")) {
isStarted = true;
await blocked;
return HttpResponse.json(resource({ ...snapshot, isStale: true }));
}
return HttpResponse.json(resource(snapshot));
},
),
);
const { result } = renderHook(() => usePrivateResource("a"), {
wrapper: createQueryWrapper().wrapper,
});
await waitFor(() => expect(isStarted).toBe(true));
// Capture the observation promise before releasing the old handler; the
// final assertion must follow its settlement, not the handler release.
let observation: Promise<unknown> = Promise.resolve();
await act(async () => {
observation = result.current.observe() ?? Promise.resolve();
await observation;
});
await act(async () => {
release();
await observation;
});
expect(result.current.data).toEqual(snapshot);
});
it("clears private snapshots on confirmed access loss and fences another pending success", async () => { it("clears private snapshots on confirmed access loss and fences another pending success", async () => {
auth(); auth();
let isDenied = false; let isDenied = false;
+55 -4
View File
@@ -1,5 +1,6 @@
import { import {
type QueryClient, type QueryClient,
type QueryFunctionContext,
useQueryClient, useQueryClient,
type UseQueryOptions, type UseQueryOptions,
} from "@tanstack/react-query"; } from "@tanstack/react-query";
@@ -8,6 +9,7 @@ import { useEffect } from "react";
import type { components, paths } from "./generated/schema"; import type { components, paths } from "./generated/schema";
import { useAuthenticatedQuery } from "./authenticated-query"; import { useAuthenticatedQuery } from "./authenticated-query";
import { getAuthenticatedSessionSnapshot } from "./client";
import { apiErrorCode, isBrokerageAccessLoss } from "./problem-details"; import { apiErrorCode, isBrokerageAccessLoss } from "./problem-details";
type InvestmentResourceMeta = components["schemas"]["InvestmentResourceMetaDto"]; type InvestmentResourceMeta = components["schemas"]["InvestmentResourceMetaDto"];
@@ -29,7 +31,7 @@ type ResourcePath = {
type ResourceQueryKey = readonly [ type ResourceQueryKey = readonly [
"get", "get",
ResourcePath, ResourcePath,
{ params?: { path?: object; query?: object } }?, { params?: { path?: object; query?: { sync?: "observe" } } }?,
]; ];
function invalidateResourceAccess( function invalidateResourceAccess(
@@ -55,6 +57,22 @@ function invalidateResourceAccess(
}); });
} }
// Keep endpoint and parameters intact. The generated queryFn accepts the
// optional sync mode even for a cache key originally created without init.
function observationKey<K extends readonly unknown[]>(key: K): K {
const [method, path, init] = key as unknown as [
string,
string,
{ params?: { path?: object; query?: object } }?,
];
return [method, path, {
...init,
params: { ...init?.params, query: { ...init?.params?.query, sync: "observe" } },
}] as unknown as K;
}
// Observation changes transport mode, never the generated cache identity.
function useResourceQuery< function useResourceQuery<
T extends Resource, T extends Resource,
E = unknown, E = unknown,
@@ -74,17 +92,50 @@ function useResourceQuery<
if (group) invalidateResourceAccess(client, group, query.error); if (group) invalidateResourceAccess(client, group, query.error);
}, [client, group, query.error]); }, [client, group, query.error]);
const observe = async () => {
const queryFunction = options.queryFn;
if (
typeof queryFunction !== "function"
|| !query.isEnabled
|| !getAuthenticatedSessionSnapshot()
)
return;
// Cancellation is synchronous; start the replacement in the same boundary.
void client.cancelQueries({ exact: true, queryKey: options.queryKey });
return client.query({
...options,
queryFn: async (context: QueryFunctionContext<K>) => {
const queryKey = observationKey(context.queryKey);
return queryFunction({ ...context, queryKey });
},
retry: false,
// Keep the cache envelope independent of observer projection.
select: undefined,
staleTime: 0,
});
};
const data: T["data"] | undefined = query.data?.data; const data: T["data"] | undefined = query.data?.data;
const meta: T["meta"] | undefined = query.data?.meta; const meta: T["meta"] | undefined = query.data?.meta;
// Project resource fields; refetch still returns the transport envelope. // Project resource fields; refetch still returns the transport envelope.
// queryKey is kept for screen-sync cache bookkeeping.
return { return {
// eslint-disable-next-line @tanstack/query/no-rest-destructuring -- Single projection point; consumers already depend on the whole query state.
...query,
data: data ?? undefined, data: data ?? undefined,
error: query.error,
fetchStatus: query.fetchStatus,
isEnabled: query.isEnabled,
isError: query.isError,
isFetching: query.isFetching,
isLoading: query.isLoading,
isPending: query.isPending,
isSuccess: query.isSuccess,
meta, meta,
observe,
queryKey: options.queryKey, queryKey: options.queryKey,
refetch: query.refetch,
status: query.status,
sync: query.data && "sync" in query.data ? query.data.sync : undefined, sync: query.data && "sync" in query.data ? query.data.sync : undefined,
}; };
} }
File diff suppressed because it is too large Load Diff
+42 -65
View File
@@ -14,9 +14,9 @@ const ru = {
currentValue: "Текущая стоимость", currentValue: "Текущая стоимость",
description: { description: {
currentValue: "Текущая стоимость: {{value}}. ", currentValue: "Текущая стоимость: {{value}}. ",
return: "Доходность: {{percent}}, {{absolute}}", return: "Доходность {{currency}}: {{percent}}, {{absolute}}",
start: "Счёт {{name}}, {{type}}. ", start: "Счёт {{name}}, {{type}}. ",
structure: "Структура: {{value}}", structure: "Структура {{currency}}: {{value}}",
structureCategory: "{{category}} {{value}}", structureCategory: "{{category}} {{value}}",
}, },
emptyText: emptyText:
@@ -39,14 +39,19 @@ const ru = {
refreshError: refreshError:
"Не удалось обновить данные. Показаны последние полученные.", "Не удалось обновить данные. Показаны последние полученные.",
refreshing: "Обновляем данные", refreshing: "Обновляем данные",
remainingCurrencies_few: "Еще {{count}} валюты",
remainingCurrencies_many: "Еще {{count}} валют",
remainingCurrencies_one: "Еще {{count}} валюта",
remainingCurrencies_other: "Еще {{count}} валюты",
retry: "Повторить", retry: "Повторить",
scope: "Счёт", scope: "Счёт",
stocks: "Акции", stocks: "Акции",
structure: { structure: {
category: "{{category}} {{percent}} %", category: "{{category}} {{percent}} %",
incomplete: "{{category}}: доля неизвестна", incomplete: "{{category}}: доля неизвестна",
label: "Структура: {{value}}", label: "Структура в {{currency}}: {{value}}",
}, },
subtitle: "Ваши инвестиции по счетам",
summaryEmpty: "Нет текущих сумм", summaryEmpty: "Нет текущих сумм",
summaryExcluded_few: summaryExcluded_few:
"Ещё {{count}} счёта не включены: нет актуального портфеля", "Ещё {{count}} счёта не включены: нет актуального портфеля",
@@ -270,7 +275,7 @@ const ru = {
COUPON_TYPE_VARIABLE: "Переменный", COUPON_TYPE_VARIABLE: "Переменный",
}, },
couponWindow: couponWindow:
"Календарь выпуска с {{from}} по {{to}}. Полученные выплаты показаны в истории операций.", "Календарь выпуска с {{from}} по {{to}}. Личные зачисления показаны в истории событий.",
couponYield: "Текущая купонная доходность, годовых", couponYield: "Текущая купонная доходность, годовых",
couponYieldNote: couponYieldNote:
"Купонная доходность = ближайший купон × 365 / дней в купонном периоде / цена без НКД × 100 %. Это годовая оценка при сохранении размера купона и номинала, без налогов, комиссий и реинвестирования; для плавающего или переменного купона будущий доход изменится.", "Купонная доходность = ближайший купон × 365 / дней в купонном периоде / цена без НКД × 100 %. Это годовая оценка при сохранении размера купона и номинала, без налогов, комиссий и реинвестирования; для плавающего или переменного купона будущий доход изменится.",
@@ -339,10 +344,10 @@ const ru = {
breadcrumbs: { breadcrumbs: {
accountFallback: "Счет", accountFallback: "Счет",
accounts: "Брокерские счета", accounts: "Брокерские счета",
events: "Предстоящие события", events: "Будущие события",
income: "Пассивный доход", income: "Пассивный доход",
label: "Хлебные крошки", label: "Хлебные крошки",
operations: "История операций", operations: "Операции",
settings: "Настройки", settings: "Настройки",
}, },
entryGroup: { entryGroup: {
@@ -352,20 +357,19 @@ const ru = {
dateKind: { event: "Дата события", registry: "Дата реестра" }, dateKind: { event: "Дата события", registry: "Дата реестра" },
emptyText: emptyText:
"Доступные источники не сообщили о купонах, дивидендах, погашениях или офертах в этом периоде.", "Доступные источники не сообщили о купонах, дивидендах, погашениях или офертах в этом периоде.",
emptyTitle: "На выбранный период предстоящих событий не найдено", emptyTitle: "За выбранный период событий не найдено",
errorText: "Попробуйте загрузить предстоящие события ещё раз.", errorText: "Попробуйте запросить прогноз еще раз.",
errorTitle: "Не удалось загрузить предстоящие события", errorTitle: "Не удалось загрузить будущие события",
forecastWindow: "Прогноз от {{date}} на {{from}} – {{to}}",
hint: "Прогноз рассчитан по текущей позиции до налогов. Покрытие зависит от доступных данных MOEX.", hint: "Прогноз рассчитан по текущей позиции до налогов. Покрытие зависит от доступных данных MOEX.",
noAmount: "Сумма неизвестна", noAmount: "Сумма неизвестна",
open: "Открыть предстоящие события", open: "Открыть будущие события",
projectedAmount: "Ожидаемая сумма", projectedAmount: "Ожидаемая сумма",
recentEmpty: "На ближайшие 30 дней предстоящих событий не найдено.", recentEmpty: "На ближайшие 30 дней событий не найдено.",
recentError: "Не удалось загрузить предстоящие события.", recentError: "Не удалось загрузить будущие события.",
recentLoading: "Загрузка предстоящих событий", recentLoading: "Загрузка будущих событий",
recentTitle: "Предстоящие события", recentTitle: "Будущие события",
retry: "Повторить", retry: "Повторить",
title: "Предстоящие события", title: "Будущие события",
type: { type: {
coupon: "Купон", coupon: "Купон",
dividend: "Дивиденд", dividend: "Дивиденд",
@@ -439,7 +443,6 @@ const ru = {
transfer: "Перевод", transfer: "Перевод",
}, },
clearPeriod: "Очистить период", clearPeriod: "Очистить период",
couponPayment: "Выплата купонов {{instrument}}",
emptyText: "Измените период или фильтры.", emptyText: "Измените период или фильтры.",
emptyTitle: "Операций нет", emptyTitle: "Операций нет",
entryLabel: { entryLabel: {
@@ -448,14 +451,14 @@ const ru = {
dividend: "Дивиденд", dividend: "Дивиденд",
}, },
}, },
errorText: "Попробуйте загрузить историю операций ещё раз.", errorText: "Попробуйте запросить операции еще раз.",
errorTitle: "Не удалось загрузить историю операций", errorTitle: "Не удалось загрузить журнал",
from: "С", from: "С",
next: "Далее", next: "Далее",
noInstrument: "Без инструмента", noInstrument: "Без инструмента",
noMoney: "Без движения денег", noMoney: "Без движения денег",
occurredAt: "Исполнено {{date}}", occurredAt: "Исполнено {{date}}",
open: "Открыть историю операций", open: "Открыть журнал",
operationType: { operationType: {
buy: "Покупка", buy: "Покупка",
coupon: "Купон", coupon: "Купон",
@@ -472,13 +475,13 @@ const ru = {
previous: "Назад", previous: "Назад",
price: "Цена: {{value}}", price: "Цена: {{value}}",
quantity: "{{value}} шт.", quantity: "{{value}} шт.",
recentEmpty: "Последних операций пока нет.", recentEmpty: "Последних событий пока нет.",
recentError: "Не удалось загрузить последние операции.", recentError: "Не удалось загрузить последние события.",
recentLoading: "Загрузка последних операций", recentLoading: "Загрузка последних событий",
recentTitle: "Последние операции", recentTitle: "Последние события",
reset: "Сбросить", reset: "Сбросить",
retry: "Повторить", retry: "Повторить",
title: "История операций", title: "Журнал операций",
to: "По", to: "По",
}, },
navigation: { navigation: {
@@ -508,9 +511,9 @@ const ru = {
bonds: "Облигации", bonds: "Облигации",
byAccount: "Позиции по счетам", byAccount: "Позиции по счетам",
cashBalances: "Денежные остатки", cashBalances: "Денежные остатки",
cashTitle: "Деньги",
concentration: { concentration: {
chart: "Распределение позиций", chart: "Распределение позиций",
currency: "Валюта концентрации",
empty: "В этом разрезе нет позиций с положительной оценкой.", empty: "В этом разрезе нет позиций с положительной оценкой.",
incomplete: "Данные неполные", incomplete: "Данные неполные",
issuers: "Эмитенты", issuers: "Эмитенты",
@@ -530,42 +533,15 @@ const ru = {
}, },
connectionErrorText: "Настройте подключение, чтобы посмотреть портфель.", connectionErrorText: "Настройте подключение, чтобы посмотреть портфель.",
connectionErrorTitle: "Проверьте подключение T-Bank", connectionErrorTitle: "Проверьте подключение T-Bank",
currencies: {
EUR: "Евро",
RUB: "Российский рубль",
USD: "Доллар США",
},
emptyText: "На счете нет позиций и доступных денежных остатков.", emptyText: "На счете нет позиций и доступных денежных остатков.",
emptyTitle: "Портфель пока пуст", emptyTitle: "Портфель пока пуст",
more: "Подробнее", more: "Подробнее",
otherPositions: "Другие позиции", otherPositions: "Другие позиции",
performance: {
benchmark: "Те же деньги в индексе Мосбиржи",
benchmarkHint:
"Если бы каждое пополнение вкладывалось в индекс Мосбиржи с учётом дивидендов после налогов",
benchmarkUnavailable: "Сравнение с индексом сейчас недоступно",
calculation: "Как считали",
chartTitle: "Ваш портфель против индекса",
current: "Ваш портфель сейчас",
datePeriod: "С {{from}} по {{to}}",
error: "Не удалось загрузить результат инвестирования",
incomplete: "Результат неполный",
invested: "Вложено (нетто)",
marketAhead: "Опережение рынка",
marketBehind: "Отставание от рынка",
marketEqual: "Сравнение с рынком",
method:
"Доходность учитывает суммы и даты пополнений и выводов. Купоны, дивиденды, налоги и комиссии уже входят в текущую стоимость. Для сравнения те же потоки вложены в индекс полной доходности Мосбиржи с дивидендами после налогов (MCFTRR). Ценовой индекс IMOEX дивиденды не учитывает и показан только для справки. Источник: MOEX ISS. Это не налоговый расчёт.",
missingHistory:
"Линии прошлой стоимости вашего портфеля нет: такая история ещё не накоплена. Точка справа — текущая оценка.",
periodLabel: "за {{days}} дн.",
periodNote:
"История меньше года: проценты показаны за {{days}} дн. без пересчёта в годовые.",
profit: "Прибыль",
profitCaption: "{{current}} сейчас · вложено {{invested}}",
reference: "Индекс Мосбиржи без дивидендов",
retry: "Повторить",
returnLabel: "Доходность",
syncing: "Данные счетов ещё синхронизируются. Результат появится после загрузки.",
unavailable: "Доходность недоступна",
unavailableReason: "{{name}}: {{reason}}",
yearly: "годовых",
},
positionFilter: "Тип позиции", positionFilter: "Тип позиции",
positionKinds: { positionKinds: {
bonds: "Облигации", bonds: "Облигации",
@@ -589,6 +565,7 @@ const ru = {
categories: "Категории активов", categories: "Категории активов",
chartLabel: chartLabel:
"Структура по типам активов. Суммы и доли — в списке рядом.", "Структура по типам активов. Суммы и доли — в списке рядом.",
currency: "Валюта структуры портфеля",
empty: "Нет положительной стоимости для кольцевой диаграммы.", empty: "Нет положительной стоимости для кольцевой диаграммы.",
incomplete: incomplete:
"Оценка неполная: ниже показаны только известные суммы. Валюта оценки части позиций неизвестна, поэтому доли и диаграмма недоступны.", "Оценка неполная: ниже показаны только известные суммы. Валюта оценки части позиций неизвестна, поэтому доли и диаграмма недоступны.",
@@ -605,14 +582,12 @@ const ru = {
}, },
tabs: { tabs: {
cash: "Деньги", cash: "Деньги",
events: "Предстоящие события", events: "События",
income: "Доход", income: "Доход",
operations: "История операций", operations: "Операции",
performance: "Результат",
positions: "Позиции", positions: "Позиции",
}, },
title: "Портфель", title: "Портфель",
total: "Текущая стоимость",
unrealizedResult: "Нереализованный результат", unrealizedResult: "Нереализованный результат",
unrealizedResultCaption: "по цене, без НКД и купонов", unrealizedResultCaption: "по цене, без НКД и купонов",
updatedAt: "Данные получены {{date}}", updatedAt: "Данные получены {{date}}",
@@ -655,16 +630,17 @@ const ru = {
complete: "Завершена", complete: "Завершена",
connect: connect:
"Для просмотра акций подключите T-Банк в настройках или обновите подключение.", "Для просмотра акций подключите T-Банк в настройках или обновите подключение.",
currencyUnknown: "валюта неизвестна",
date: "Дата", date: "Дата",
dateUnknown: "Дата не указана", dateUnknown: "Дата не указана",
dividendHistory: "История дивидендов компании", dividendHistory: "История дивидендов компании",
dividendNote: dividendNote:
"Личная сумма — оценка по текущему количеству, не гарантия выплаты. Налоги не сопоставлены; дата выплаты компании может отличаться от дня зачисления на ваш счёт.", "Личная сумма — оценка по текущему количеству, не гарантия выплаты. Налоги не сопоставлены; дата выплаты компании может отличаться от дня зачисления на ваш счёт.",
dividendWindow: dividendWindow:
"События компании с {{from}} по {{to}}. Полученные выплаты показаны отдельно в истории операций.", "События компании с {{from}} по {{to}}. Личные зачисления показаны отдельно в истории событий.",
estimateUnavailable: estimateUnavailable:
"Данные позиции не удалось обновить. Личная оценка недоступна или рассчитана по сохранённой позиции.", "Данные позиции не удалось обновить. Личная оценка недоступна или рассчитана по сохранённой позиции.",
eventFilter: "Фильтр операций", eventFilter: "Фильтр событий",
eventPrice: "Цена: {{value}}", eventPrice: "Цена: {{value}}",
eventQuantity: "{{value}} шт.", eventQuantity: "{{value}} шт.",
exchanges: { exchanges: {
@@ -680,7 +656,7 @@ const ru = {
trades: "Сделки", trades: "Сделки",
}, },
forming: "Формируется", forming: "Формируется",
history: "История операций", history: "Моя история событий",
historyNote: historyNote:
"Операции по этой акции на доступных открытых счетах, включая периоды до текущего года. Дата сделки — дата операции брокера; количество — исполненная часть. История закрытых счетов не включена.", "Операции по этой акции на доступных открытых счетах, включая периоды до текущего года. Дата сделки — дата операции брокера; количество — исполненная часть. История закрытых счетов не включена.",
incomplete: "Последняя свеча ещё формируется: её цена может измениться.", incomplete: "Последняя свеча ещё формируется: её цена может измениться.",
@@ -742,6 +718,7 @@ const ru = {
screener: { screener: {
asOf: "Список получен {{date}}", asOf: "Список получен {{date}}",
countryOfRisk: "Страна риска", countryOfRisk: "Страна риска",
currency: "Валюта расчётов",
empty: "Подходящие акции не найдены.", empty: "Подходящие акции не найдены.",
error: "Не удалось получить результат скринера. Попробуйте найти снова.", error: "Не удалось получить результат скринера. Попробуйте найти снова.",
maxLot: "Максимальный размер лота", maxLot: "Максимальный размер лота",
+9 -4
View File
@@ -3,13 +3,18 @@ import { describe, expect, it } from "vitest";
import { sumMoney } from "./decimal"; import { sumMoney } from "./decimal";
describe("sumMoney", () => { describe("sumMoney", () => {
it("adds exact signed decimals", () => { it("adds exact signed decimals independently per currency", () => {
expect(sumMoney([ expect(sumMoney([
{ currency: "RUB", value: "0.1" }, { currency: "RUB", value: "0.1" },
{ currency: "USD", value: "0.01" },
{ currency: "RUB", value: "0.2" }, { currency: "RUB", value: "0.2" },
{ currency: "RUB", value: "-1500000.45" }, { currency: "RUB", value: "-1500000.45" },
{ currency: "USD", value: "-0.02" },
{ currency: "RUB", value: "200" }, { currency: "RUB", value: "200" },
])).toEqual({ currency: "RUB", value: "-1499800.15" }); ])).toEqual([
{ currency: "RUB", value: "-1499800.15" },
{ currency: "USD", value: "-0.01" },
]);
}); });
it("preserves significant fractional digits and a compensated zero", () => { it("preserves significant fractional digits and a compensated zero", () => {
@@ -17,11 +22,11 @@ describe("sumMoney", () => {
{ currency: "RUB", value: "100.5" }, { currency: "RUB", value: "100.5" },
{ currency: "RUB", value: "49.50" }, { currency: "RUB", value: "49.50" },
{ currency: "RUB", value: "0.005" }, { currency: "RUB", value: "0.005" },
])).toEqual({ currency: "RUB", value: "150.005" }); ])).toEqual([{ currency: "RUB", value: "150.005" }]);
expect(sumMoney([ expect(sumMoney([
{ currency: "RUB", value: "100" }, { currency: "RUB", value: "100" },
{ 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"])( it.each(["", ".", "1.2.3", "not-a-number"])(

Some files were not shown because too many files have changed in this diff Show More