From 4e5f564fa1d583013fb3e7febdb3baa9737446b4 Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Sat, 26 Sep 2026 21:22:49 +0300 Subject: [PATCH] feat: show persisted investment data and sync state Consume the provided resource-envelope API snapshot. Preserve financial snapshots during refresh, observe synchronization without importing, and fence late responses across account and connection changes. --- src/app/routes/AppRoutes/AllAccounts.test.tsx | 119 +- .../DocumentTitle/DocumentTitle.test.tsx | 5 +- src/entities/bond/api/queries.ts | 24 +- .../api/useBrokerageOperations.ts | 19 +- .../api/useBrokerageAccounts.test.tsx | 37 +- .../api/useBrokerageAccounts.ts | 14 +- .../brokerage-account/model/display.ts | 21 +- .../BrokerageAccountList.stories.tsx | 41 + .../BrokerageAccountList.test.tsx | 40 + .../BrokerageAccountList.tsx | 25 +- .../api/useBrokerageEvents.test.tsx | 37 +- .../brokerage-event/api/useBrokerageEvents.ts | 16 +- .../BrokerageEventsPreview.tsx | 30 +- src/entities/income/api/useIncome.ts | 22 +- .../portfolio/api/resource-lifecycle.test.tsx | 144 ++ .../portfolio/api/usePortfolio.test.tsx | 20 +- src/entities/portfolio/api/usePortfolio.ts | 14 +- src/entities/stock/api/queries.ts | 26 +- src/features/auth/model/auth.test.tsx | 3 +- src/features/auth/model/auth.tsx | 2 +- .../instrument-details/model/section.test.ts | 59 + .../instrument-details/model/section.ts | 41 +- .../instrument-details/model/types.ts | 10 +- .../ui/InstrumentChart.view.tsx | 8 +- .../ui/InstrumentEvents.view.tsx | 6 +- .../ui/InstrumentSection.view.tsx | 31 +- src/features/tbank-connection/index.ts | 6 + .../model/connection-boundary.ts | 16 +- .../model/screen-sync.test.tsx | 268 +++ .../tbank-connection/model/screen-sync.ts | 148 ++ .../tbank-connection/model/sync.test.tsx | 114 ++ src/features/tbank-connection/model/sync.ts | 24 + .../AccountEventsPage.filters.test.tsx | 68 +- .../AccountEventsPage.test.tsx | 28 +- .../AccountEventsPage/AccountEventsPage.vm.ts | 26 +- .../AccountEventsPageLoaded.view.tsx | 26 +- .../AccountIncomePage.test.tsx | 183 +- .../AccountIncomePage/AccountIncomePage.vm.ts | 111 +- .../AccountIncomePageLoaded.view.tsx | 36 +- .../components/IncomeComparisons.view.tsx | 21 +- .../AccountOperationsPage.test.tsx | 110 +- .../AccountOperationsPage.vm.ts | 23 +- .../AccountOperationsPageLoaded.view.tsx | 28 +- .../ui/AccountPage/AccountPage.test.tsx | 139 +- .../account/ui/AccountPage/AccountPage.tsx | 3 + .../ui/AccountPage/AccountPage.vm.test.tsx | 195 +- .../account/ui/AccountPage/AccountPage.vm.ts | 113 +- .../components/AccountPageEmpty.view.tsx | 2 + .../components/AccountPageLoaded.view.tsx | 19 +- .../components/IncomeSummary.view.tsx | 35 +- .../PortfolioRefreshState.stories.tsx | 5 + .../PortfolioRefreshState.view.tsx | 23 +- .../components/RecentOperations.view.tsx | 23 +- .../ui/AccountsPage/AccountsPage.test.tsx | 43 +- .../accounts/ui/AccountsPage/AccountsPage.tsx | 4 + .../ui/AccountsPage/AccountsPage.vm.ts | 32 +- .../components/AccountsPageLoading.view.tsx | 2 +- .../components/AccountsRefreshState.view.tsx | 33 +- src/pages/bond/ui/BondPage/BondPage.test.tsx | 249 ++- src/pages/bond/ui/BondPage/BondPage.view.tsx | 17 +- src/pages/bond/ui/BondPage/BondPage.vm.ts | 34 +- .../bonds/ui/BondsPage/BondsPage.test.tsx | 39 +- .../bonds/ui/BondsPage/BondsPage.view.tsx | 16 +- src/pages/bonds/ui/BondsPage/BondsPage.vm.ts | 15 +- .../stock/ui/StockPage/StockPage.test.tsx | 197 +- .../stock/ui/StockPage/StockPage.view.tsx | 14 +- src/pages/stock/ui/StockPage/StockPage.vm.ts | 34 +- .../StockPage/components/StockEvents.view.tsx | 4 +- .../stocks/ui/StocksPage/StocksPage.test.tsx | 35 +- .../stocks/ui/StocksPage/StocksPage.view.tsx | 16 +- .../stocks/ui/StocksPage/StocksPage.vm.ts | 15 +- src/shared/api/authenticated-query.test.tsx | 21 +- src/shared/api/client.test.ts | 7 +- src/shared/api/client.ts | 48 +- src/shared/api/generated/schema.d.ts | 738 +++++--- src/shared/api/index.ts | 13 +- src/shared/api/resource-query.ts | 150 ++ src/shared/api/spec/openapi.json | 1652 +++++++++++++---- src/shared/i18n/locales/ru.ts | 13 + src/test/handlers.ts | 65 +- src/test/resource.ts | 21 + 81 files changed, 4714 insertions(+), 1420 deletions(-) create mode 100644 src/entities/portfolio/api/resource-lifecycle.test.tsx create mode 100644 src/features/instrument-details/model/section.test.ts create mode 100644 src/features/tbank-connection/model/screen-sync.test.tsx create mode 100644 src/features/tbank-connection/model/screen-sync.ts create mode 100644 src/features/tbank-connection/model/sync.test.tsx create mode 100644 src/features/tbank-connection/model/sync.ts create mode 100644 src/shared/api/resource-query.ts create mode 100644 src/test/resource.ts diff --git a/src/app/routes/AppRoutes/AllAccounts.test.tsx b/src/app/routes/AppRoutes/AllAccounts.test.tsx index 5ef6949..f493d0a 100644 --- a/src/app/routes/AppRoutes/AllAccounts.test.tsx +++ b/src/app/routes/AppRoutes/AllAccounts.test.tsx @@ -16,6 +16,7 @@ import type { IncomeAnalytics } from "@/entities/income"; import type { Portfolio } from "@/entities/portfolio"; import { AuthProvider } from "@/features/auth"; import { createTestQueryClient } from "@/test/query"; +import { resource } from "@/test/resource"; import { server } from "@/test/server"; import { AppRoutes } from "./AppRoutes"; @@ -132,50 +133,56 @@ beforeEach(() => { ), http.get("*/api/v1/brokerage-accounts", () => HttpResponse.json( - accounts.map((account) => ({ - ...account, - type: "BROKERAGE", - openedOn: null, - portfolioSummary: null, - })), + resource( + accounts.map((account) => ({ + ...account, + type: "BROKERAGE", + openedOn: null, + portfolioSummary: null, + })), + ), ), ), http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () => - HttpResponse.json(portfolio), + HttpResponse.json(resource(portfolio)), ), http.get( "*/api/v1/brokerage-accounts/:accountId/operations", ({ params }) => - HttpResponse.json({ - accountId: params.accountId, - items, - hasNext: false, - nextCursor: null, - observedAt: receipt.occurredAt, - }), + HttpResponse.json( + resource({ + accountId: params.accountId, + items, + hasNext: false, + nextCursor: null, + observedAt: receipt.occurredAt, + }), + ), ), http.get("*/api/v1/brokerage-accounts/:accountId/income-analytics", () => - HttpResponse.json(analytics), + HttpResponse.json(resource(analytics)), ), http.get("*/api/v1/brokerage-accounts/:accountId/events", () => - HttpResponse.json({ - accountId: "all", - calculatedAt: receipt.occurredAt, - incomeTotalsByCurrency: [], - events: accounts.map((account) => ({ - account, - type: "coupon", - date: "2026-09-15", - dateKind: "event", - figi: null, - instrumentUid: null, - ticker: "BOND", - displayName: "Облигация", - isin: null, - logoName: null, - projectedAmount: { currency: "RUB", value: "2" }, - })), - }), + HttpResponse.json( + resource({ + accountId: "all", + calculatedAt: receipt.occurredAt, + incomeTotalsByCurrency: [], + events: accounts.map((account) => ({ + account, + type: "coupon", + date: "2026-09-15", + dateKind: "event", + figi: null, + instrumentUid: null, + ticker: "BOND", + displayName: "Облигация", + isin: null, + logoName: null, + projectedAmount: { currency: "RUB", value: "2" }, + })), + }), + ), ), ); }); @@ -224,20 +231,22 @@ describe("All accounts routes", () => { ({ request, params }) => { const url = new URL(request.url); requests.push(url); - return HttpResponse.json({ - accountId: params.accountId, - items: [ - { - ...receipt, - description: url.searchParams.has("cursor") - ? "Вторая страница" - : "Первая страница", - }, - ], - hasNext: !url.searchParams.has("cursor"), - nextCursor: url.searchParams.has("cursor") ? null : "next", - observedAt: receipt.occurredAt, - }); + return HttpResponse.json( + resource({ + accountId: params.accountId, + items: [ + { + ...receipt, + description: url.searchParams.has("cursor") + ? "Вторая страница" + : "Первая страница", + }, + ], + hasNext: !url.searchParams.has("cursor"), + nextCursor: url.searchParams.has("cursor") ? null : "next", + observedAt: receipt.occurredAt, + }), + ); }, ), ); @@ -274,13 +283,15 @@ describe("All accounts routes", () => { { code: "INVALID_OPERATIONS_CURSOR" }, { status: 400 }, ) - : HttpResponse.json({ - accountId: "all", - items, - hasNext: true, - nextCursor: "expired", - observedAt: receipt.occurredAt, - }), + : HttpResponse.json( + resource({ + accountId: "all", + items, + hasNext: true, + nextCursor: "expired", + observedAt: receipt.occurredAt, + }), + ), ), ); renderPage("/accounts/all/operations?from=2026-08-01&to=2026-08-31"); diff --git a/src/app/routes/DocumentTitle/DocumentTitle.test.tsx b/src/app/routes/DocumentTitle/DocumentTitle.test.tsx index 8d5579e..353cb03 100644 --- a/src/app/routes/DocumentTitle/DocumentTitle.test.tsx +++ b/src/app/routes/DocumentTitle/DocumentTitle.test.tsx @@ -12,6 +12,7 @@ import { MemoryRouter, useNavigate } from "react-router"; import { beforeEach, describe, expect, it } from "vitest"; import { AuthProvider } from "@/features/auth"; import { createTestQueryClient } from "@/test/query"; +import { resource } from "@/test/resource"; import { server } from "@/test/server"; import { ProtectedDocumentTitle } from "./DocumentTitle"; @@ -62,7 +63,7 @@ describe("document titles", () => { server.use( http.get("*/api/v1/brokerage-accounts", async () => { await accountsReady; - return HttpResponse.json([{ id: "a", name: "ИИС" }]); + return HttpResponse.json(resource([{ id: "a", name: "ИИС" }])); }), ); renderTitle("/accounts/a/income?from=2026-01-01&to=2026-08-31"); @@ -85,7 +86,7 @@ describe("document titles", () => { async (kind, id, fallback, name) => { server.use( http.get(`*/api/v1/${kind}/by-ticker/:ticker`, () => - HttpResponse.json({ name }), + HttpResponse.json(resource({ name })), ), ); renderTitle(`/${kind}/${id}`); diff --git a/src/entities/bond/api/queries.ts b/src/entities/bond/api/queries.ts index 4c21dc5..d69b753 100644 --- a/src/entities/bond/api/queries.ts +++ b/src/entities/bond/api/queries.ts @@ -1,8 +1,4 @@ -import { - apiQueryClient, - type paths, - useAuthenticatedQuery, -} from "@/shared/api"; +import { apiQueryClient, type paths, useResourceQuery } from "@/shared/api"; type BondPeriod = NonNullable< NonNullable< @@ -13,7 +9,7 @@ type BondPeriod = NonNullable< const BOND_STALE_TIME_MS = 30_000; function useBondSearch(query: string) { - return useAuthenticatedQuery({ + return useResourceQuery({ ...apiQueryClient.queryOptions("get", "/api/v1/bonds", { params: { query: { query } }, }), @@ -36,11 +32,11 @@ function useBond(identifier: string) { identifier, ); - const byUid = useAuthenticatedQuery({ + const byUid = useResourceQuery({ ...bondQueryOptions(identifier), enabled: Boolean(identifier) && isUid, }); - const byTicker = useAuthenticatedQuery({ + const byTicker = useResourceQuery({ ...apiQueryClient.queryOptions("get", "/api/v1/bonds/by-ticker/{ticker}", { params: { path: { ticker: identifier } }, }), @@ -50,7 +46,7 @@ function useBond(identifier: string) { return isUid ? byUid : byTicker; } function useBondMetrics(uid: string) { - return useAuthenticatedQuery({ + return useResourceQuery({ ...apiQueryClient.queryOptions("get", "/api/v1/bonds/{uid}/metrics", { params: { path: { uid } }, }), @@ -59,7 +55,7 @@ function useBondMetrics(uid: string) { }); } function useBondQuote(uid: string) { - return useAuthenticatedQuery({ + return useResourceQuery({ ...apiQueryClient.queryOptions("get", "/api/v1/bonds/{uid}/quote", { params: { path: { uid } }, }), @@ -68,7 +64,7 @@ function useBondQuote(uid: string) { }); } function useBondCandles(uid: string, period: BondPeriod) { - return useAuthenticatedQuery({ + return useResourceQuery({ ...apiQueryClient.queryOptions("get", "/api/v1/bonds/{uid}/candles", { params: { path: { uid }, query: { period } }, }), @@ -77,7 +73,8 @@ function useBondCandles(uid: string, period: BondPeriod) { }); } function useBondPosition(uid: string) { - return useAuthenticatedQuery({ + return useResourceQuery({ + meta: { accessGroup: "brokerage" }, ...apiQueryClient.queryOptions("get", "/api/v1/bonds/{uid}/position", { params: { path: { uid } }, }), @@ -86,7 +83,8 @@ function useBondPosition(uid: string) { }); } function useBondCoupons(uid: string) { - return useAuthenticatedQuery({ + return useResourceQuery({ + meta: { accessGroup: "brokerage" }, ...apiQueryClient.queryOptions("get", "/api/v1/bonds/{uid}/coupons", { params: { path: { uid } }, }), diff --git a/src/entities/broker-operation/api/useBrokerageOperations.ts b/src/entities/broker-operation/api/useBrokerageOperations.ts index 7df4e63..5fc15ca 100644 --- a/src/entities/broker-operation/api/useBrokerageOperations.ts +++ b/src/entities/broker-operation/api/useBrokerageOperations.ts @@ -1,16 +1,14 @@ -import { - apiQueryClient, - type paths, - useAuthenticatedQuery, -} from "@/shared/api"; +import { apiQueryClient, type paths, useResourceQuery } from "@/shared/api"; type OperationsEndpoint = paths["/api/v1/brokerage-accounts/{accountId}/operations"]["get"]; -type BrokerageOperation = - OperationsEndpoint["responses"][200]["content"]["application/json"]["items"][number]; -type BrokerageOperationsQuery = NonNullable< - OperationsEndpoint["parameters"]["query"] +type BrokerageOperation = NonNullable< + OperationsEndpoint["responses"][200]["content"]["application/json"]["data"] +>["items"][number]; +type BrokerageOperationsQuery = Omit< + NonNullable, + "sync" >; type OperationCategory = BrokerageOperation["category"]; @@ -56,7 +54,8 @@ function useBrokerageOperations( accountId: string | undefined, query: BrokerageOperationsQuery = {}, ) { - return useAuthenticatedQuery({ + return useResourceQuery({ + meta: { accessGroup: "brokerage" }, ...brokerageOperationsQueryOptions(accountId, query), enabled: Boolean(accountId), staleTime: 30_000, diff --git a/src/entities/brokerage-account/api/useBrokerageAccounts.test.tsx b/src/entities/brokerage-account/api/useBrokerageAccounts.test.tsx index 047d1f5..795a5c7 100644 --- a/src/entities/brokerage-account/api/useBrokerageAccounts.test.tsx +++ b/src/entities/brokerage-account/api/useBrokerageAccounts.test.tsx @@ -3,6 +3,7 @@ import { HttpResponse, http } from "msw"; import { describe, expect, it, vi } from "vitest"; import { configureAuthClient } from "@/shared/api"; import { createQueryWrapper } from "@/test/query"; +import { resource } from "@/test/resource"; import { server } from "@/test/server"; import { useBrokerageAccounts } from "./useBrokerageAccounts"; @@ -15,14 +16,16 @@ describe("useBrokerageAccounts", () => { }); server.use( http.get("*/api/v1/brokerage-accounts", () => - HttpResponse.json([ - { - id: "account-1", - name: "Основной", - type: "BROKERAGE", - openedOn: "2024-08-13", - }, - ]), + HttpResponse.json( + resource([ + { + id: "account-1", + name: "Основной", + type: "BROKERAGE", + openedOn: "2024-08-13", + }, + ]), + ), ), ); @@ -52,14 +55,16 @@ describe("useBrokerageAccounts", () => { http.get("*/api/v1/brokerage-accounts", () => { calls += 1; return calls === 1 - ? HttpResponse.json([ - { - id: "account-1", - name: "Основной", - type: "BROKERAGE", - openedOn: "2024-08-13", - }, - ]) + ? HttpResponse.json( + resource([ + { + id: "account-1", + name: "Основной", + type: "BROKERAGE", + openedOn: "2024-08-13", + }, + ]), + ) : HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 }); }), ); diff --git a/src/entities/brokerage-account/api/useBrokerageAccounts.ts b/src/entities/brokerage-account/api/useBrokerageAccounts.ts index 83ac0cd..7ae6cec 100644 --- a/src/entities/brokerage-account/api/useBrokerageAccounts.ts +++ b/src/entities/brokerage-account/api/useBrokerageAccounts.ts @@ -1,19 +1,17 @@ -import { - apiQueryClient, - type paths, - useAuthenticatedQuery, -} from "@/shared/api"; +import { apiQueryClient, type paths, useResourceQuery } from "@/shared/api"; const brokerageAccountsQueryOptions = () => apiQueryClient.queryOptions("get", "/api/v1/brokerage-accounts"); const brokerageAccountsQueryKey = brokerageAccountsQueryOptions().queryKey; -type BrokerageAccount = - paths["/api/v1/brokerage-accounts"]["get"]["responses"][200]["content"]["application/json"][number]; +type BrokerageAccount = NonNullable< + paths["/api/v1/brokerage-accounts"]["get"]["responses"][200]["content"]["application/json"]["data"] +>[number]; function useBrokerageAccounts() { - return useAuthenticatedQuery({ + return useResourceQuery({ + meta: { accessGroup: "brokerage" }, ...brokerageAccountsQueryOptions(), staleTime: 60_000, }); diff --git a/src/entities/brokerage-account/model/display.ts b/src/entities/brokerage-account/model/display.ts index b92e039..21982a3 100644 --- a/src/entities/brokerage-account/model/display.ts +++ b/src/entities/brokerage-account/model/display.ts @@ -1,10 +1,18 @@ -import type { BrokerageAccount } from "../api/useBrokerageAccounts"; +import type { + BrokerageAccount, + useBrokerageAccounts, +} from "../api/useBrokerageAccounts"; type Money = NonNullable< BrokerageAccount["portfolioSummary"] >["totalsByCurrency"][number]; type BrokerageAccountCard = { + source?: NonNullable< + ReturnType["meta"] + >["dependencies"] extends (infer T)[] | undefined + ? T + : never; account: BrokerageAccount; positionCounts: { stocks: number; bonds: number } | null; summary: @@ -19,11 +27,19 @@ function accountTypeKey(type: BrokerageAccount["type"]) { function brokerageAccountCards( accounts: BrokerageAccount[], + dependencies: NonNullable< + ReturnType["meta"] + >["dependencies"] = [], ): BrokerageAccountCard[] { return accounts.map((account) => { + const source = dependencies.find( + (item) => + item.resourceKey === `portfolio/${encodeURIComponent(account.id)}`, + ); const summary = account.portfolioSummary; if (!summary) return { + source, account, positionCounts: null, summary: { state: "unavailable" }, @@ -33,8 +49,9 @@ function brokerageAccountCards( bonds: summary.bondPositionCount, }; if (!summary.totalsByCurrency.length) - return { account, positionCounts, summary: { state: "empty" } }; + return { source, account, positionCounts, summary: { state: "empty" } }; return { + source, account, positionCounts, summary: { diff --git a/src/entities/brokerage-account/ui/BrokerageAccountList/BrokerageAccountList.stories.tsx b/src/entities/brokerage-account/ui/BrokerageAccountList/BrokerageAccountList.stories.tsx index 3d20caa..050a2c9 100644 --- a/src/entities/brokerage-account/ui/BrokerageAccountList/BrokerageAccountList.stories.tsx +++ b/src/entities/brokerage-account/ui/BrokerageAccountList/BrokerageAccountList.stories.tsx @@ -185,3 +185,44 @@ export const LargeValues: Story = { ]), }, }; + +export const IndependentFreshness: Story = { + name: "Независимая свежесть и ошибка сводок", + args: { + accounts: brokerageAccountCards( + [ + syntheticAccount, + { + ...syntheticAccount, + id: "waiting", + name: "Новый счёт", + portfolioSummary: null, + }, + ], + [ + { + resourceKey: "portfolio/storybook-account", + dataRevision: "1", + stateVersion: "failed", + availability: "complete", + freshness: "stale", + lastSuccessfulAt: "2026-09-24T10:30:00Z", + sync: { + id: "failed", + state: "failed", + errorCode: "TBANK_UNAVAILABLE", + }, + }, + { + resourceKey: "portfolio/waiting", + dataRevision: "0", + stateVersion: "waiting", + availability: "missing", + freshness: "unknown", + lastSuccessfulAt: null, + sync: { id: "waiting", state: "running", errorCode: null }, + }, + ], + ), + }, +}; diff --git a/src/entities/brokerage-account/ui/BrokerageAccountList/BrokerageAccountList.test.tsx b/src/entities/brokerage-account/ui/BrokerageAccountList/BrokerageAccountList.test.tsx index b4f6828..ef5aa02 100644 --- a/src/entities/brokerage-account/ui/BrokerageAccountList/BrokerageAccountList.test.tsx +++ b/src/entities/brokerage-account/ui/BrokerageAccountList/BrokerageAccountList.test.tsx @@ -2,6 +2,7 @@ import { MantineProvider } from "@mantine/core"; import { render, screen, within } from "@testing-library/react"; import { MemoryRouter } from "react-router"; import { describe, expect, it } from "vitest"; +import { resource } from "@/test/resource"; import { brokerageAccountCards } from "../../model/display"; import { BrokerageAccountList } from "./BrokerageAccountList"; @@ -84,3 +85,42 @@ describe("BrokerageAccountList", () => { ).not.toBeInTheDocument(); }); }); + +it("shows portfolio summary freshness independently for each account", () => { + const accounts = brokerageAccountCards( + ["fresh", "stale"].map((id) => ({ + id, + name: id, + type: "BROKERAGE" as const, + openedOn: null, + portfolioSummary: { + stockPositionCount: 1, + bondPositionCount: 0, + totalsByCurrency: [{ currency: "RUB", value: "10" }], + }, + })), + [ + resource(null, { resourceKey: "portfolio/fresh", freshness: "fresh" }) + .meta, + resource(null, { resourceKey: "portfolio/stale", freshness: "stale" }) + .meta, + ], + ); + render( + + + + + , + ); + expect( + within(screen.getByRole("link", { name: /stale/ })).getByText( + "Данные требуют обновления", + ), + ).toBeVisible(); + expect( + within(screen.getByRole("link", { name: /fresh/ })).queryByText( + "Данные требуют обновления", + ), + ).not.toBeInTheDocument(); +}); diff --git a/src/entities/brokerage-account/ui/BrokerageAccountList/BrokerageAccountList.tsx b/src/entities/brokerage-account/ui/BrokerageAccountList/BrokerageAccountList.tsx index ccc9b9b..6519be2 100644 --- a/src/entities/brokerage-account/ui/BrokerageAccountList/BrokerageAccountList.tsx +++ b/src/entities/brokerage-account/ui/BrokerageAccountList/BrokerageAccountList.tsx @@ -2,7 +2,7 @@ import { Card, Group, SimpleGrid, Stack, Text } from "@mantine/core"; import type { FC } from "react"; import { useTranslation } from "react-i18next"; import { Link } from "react-router"; -import { formatDate, formatMoney } from "@/shared/lib"; +import { formatDate, formatMoney, formatSnapshotTime } from "@/shared/lib"; import type { BrokerageAccountCard } from "../../model/display"; import { accountTypeKey } from "../../model/display"; import classes from "./BrokerageAccountList.module.css"; @@ -52,8 +52,8 @@ const BrokerageAccountList: FC = (props) => { const { t } = useTranslation(); return ( - - {accounts.map(({ account, summary, positionCounts }) => ( + + {accounts.map(({ account, summary, positionCounts, source }) => ( = (props) => { {t("accounts.currentValue")} + {source?.lastSuccessfulAt ? ( + + {t("investment.sourceTime", { + date: formatSnapshotTime(source.lastSuccessfulAt), + })} + + ) : null} + {source?.sync.state === "running" || + source?.sync.state === "queued" ? ( + + {t("accounts.refreshing")} + + ) : null} + {source?.sync.state === "failed" ? ( + {t("accounts.refreshError")} + ) : null} + {source?.freshness === "stale" ? ( + {t("investment.stale")} + ) : null} {positionCounts ? (
diff --git a/src/entities/brokerage-event/api/useBrokerageEvents.test.tsx b/src/entities/brokerage-event/api/useBrokerageEvents.test.tsx index 895b014..3208167 100644 --- a/src/entities/brokerage-event/api/useBrokerageEvents.test.tsx +++ b/src/entities/brokerage-event/api/useBrokerageEvents.test.tsx @@ -3,6 +3,7 @@ import { HttpResponse, http } from "msw"; import { describe, expect, it, vi } from "vitest"; import { configureAuthClient } from "@/shared/api"; import { createQueryWrapper } from "@/test/query"; +import { resource } from "@/test/resource"; import { server } from "@/test/server"; import { useBrokerageEvents } from "./useBrokerageEvents"; @@ -31,23 +32,25 @@ describe("useBrokerageEvents", () => { }); server.use( http.get("*/api/v1/brokerage-accounts/account-1/events", () => - HttpResponse.json({ - accountId: "account-1", - calculatedAt: "2026-08-14T00:00:00Z", - events: [ - { - type: "dividend", - date: "2026-09-01", - dateKind: "registry", - figi: "figi-1", - instrumentUid: null, - ticker: "SBER", - displayName: "Сбер", - logoName: "sber3.png", - projectedAmount: { currency: "RUB", value: "12.34" }, - }, - ], - }), + HttpResponse.json( + resource({ + accountId: "account-1", + calculatedAt: "2026-08-14T00:00:00Z", + events: [ + { + type: "dividend", + date: "2026-09-01", + dateKind: "registry", + figi: "figi-1", + instrumentUid: null, + ticker: "SBER", + displayName: "Сбер", + logoName: "sber3.png", + projectedAmount: { currency: "RUB", value: "12.34" }, + }, + ], + }), + ), ), ); diff --git a/src/entities/brokerage-event/api/useBrokerageEvents.ts b/src/entities/brokerage-event/api/useBrokerageEvents.ts index 7970606..7a2a304 100644 --- a/src/entities/brokerage-event/api/useBrokerageEvents.ts +++ b/src/entities/brokerage-event/api/useBrokerageEvents.ts @@ -1,19 +1,17 @@ -import { - apiQueryClient, - type paths, - useAuthenticatedQuery, -} from "@/shared/api"; +import { apiQueryClient, type paths, useResourceQuery } from "@/shared/api"; type EventsEndpoint = paths["/api/v1/brokerage-accounts/{accountId}/events"]["get"]; -type BrokerageEvent = - EventsEndpoint["responses"][200]["content"]["application/json"]["events"][number]; +type BrokerageEvent = NonNullable< + EventsEndpoint["responses"][200]["content"]["application/json"]["data"] +>["events"][number]; function useBrokerageEvents( accountId: string | undefined, - query: EventsEndpoint["parameters"]["query"] = {}, + query: Omit, "sync"> = {}, ) { - return useAuthenticatedQuery({ + return useResourceQuery({ + meta: { accessGroup: "brokerage" }, ...apiQueryClient.queryOptions( "get", "/api/v1/brokerage-accounts/{accountId}/events", diff --git a/src/entities/brokerage-event/ui/BrokerageEventsPreview/BrokerageEventsPreview.tsx b/src/entities/brokerage-event/ui/BrokerageEventsPreview/BrokerageEventsPreview.tsx index e3b2ffa..b6623f3 100644 --- a/src/entities/brokerage-event/ui/BrokerageEventsPreview/BrokerageEventsPreview.tsx +++ b/src/entities/brokerage-event/ui/BrokerageEventsPreview/BrokerageEventsPreview.tsx @@ -3,6 +3,7 @@ import { LoadingSkeleton } from "@wealthledger/ui"; import type { FC } from "react"; import { useTranslation } from "react-i18next"; import { Link } from "react-router"; +import { formatSnapshotTime } from "@/shared/lib"; import type { BrokerageEvent } from "../../api/useBrokerageEvents"; import { BrokerageEventCard } from "../BrokerageEventCard"; @@ -12,7 +13,14 @@ type BrokerageEventsPreviewProps = { type BrokerageEventsPreviewState = | { state: "loading" | "error" | "empty"; to: string } - | { state: "loaded"; to: string; items: BrokerageEvent[] }; + | { + state: "loaded" | "partial"; + to: string; + lastSuccessfulAt?: string | null; + stale?: boolean; + refreshError?: boolean; + items: BrokerageEvent[]; + }; const BrokerageEventsPreview: FC = (props) => { const { events } = props; @@ -20,6 +28,21 @@ const BrokerageEventsPreview: FC = (props) => { return ( + {events.state === "loaded" ? ( + <> + {events.lastSuccessfulAt ? ( + + {t("investment.sourceTime", { + date: formatSnapshotTime(events.lastSuccessfulAt), + })} + + ) : null} + {events.stale ? {t("investment.stale")} : null} + {events.refreshError ? ( + {t("accounts.refreshError")} + ) : null} + + ) : null} {events.state === "loading" ? ( {[0, 1, 2].map((index) => ( @@ -33,7 +56,10 @@ const BrokerageEventsPreview: FC = (props) => { {events.state === "empty" ? ( {t("events.recentEmpty")} ) : null} - {events.state === "loaded" + {events.state === "partial" ? ( + {t("investment.partial")} + ) : null} + {events.state === "loaded" || events.state === "partial" ? events.items.map((event) => ( ; +type IncomeAnalytics = NonNullable< + paths["/api/v1/brokerage-accounts/{accountId}/income-analytics"]["get"]["responses"][200]["content"]["application/json"]["data"] +>; function useIncomeSummary(accountId: string | undefined) { - return useAuthenticatedQuery({ + return useResourceQuery({ + meta: { accessGroup: "brokerage" }, ...apiQueryClient.queryOptions( "get", "/api/v1/brokerage-accounts/{accountId}/income-summary", @@ -25,7 +24,8 @@ function useIncomeAnalytics( accountId: string | undefined, period: { from: string; to: string }, ) { - return useAuthenticatedQuery({ + return useResourceQuery({ + meta: { accessGroup: "brokerage" }, ...apiQueryClient.queryOptions( "get", "/api/v1/brokerage-accounts/{accountId}/income-analytics", diff --git a/src/entities/portfolio/api/resource-lifecycle.test.tsx b/src/entities/portfolio/api/resource-lifecycle.test.tsx new file mode 100644 index 0000000..6bf9d4f --- /dev/null +++ b/src/entities/portfolio/api/resource-lifecycle.test.tsx @@ -0,0 +1,144 @@ +import { act, renderHook, waitFor } from "@testing-library/react"; +import { HttpResponse, http } from "msw"; +import { describe, expect, it, vi } from "vitest"; +import { apiClient, configureAuthClient } from "@/shared/api"; +import { createQueryWrapper } from "@/test/query"; +import { resource } from "@/test/resource"; +import { server } from "@/test/server"; +import { + type Portfolio, + portfolioQueryOptions, + usePortfolio, +} from "./usePortfolio"; + +const snapshot: Portfolio = { + positions: [], + totalsByCurrency: [{ currency: "RUB", value: "123.000000001" }], + cashBalances: [], + structure: { currencies: [], unvaluedCategories: [] }, + lastSuccessfulAt: "2026-08-14T00:00:00Z", + isStale: false, +}; +function auth() { + configureAuthClient({ + accessToken: () => "synthetic", + refresh: async () => true, + clear: vi.fn(), + }); +} + +describe("persistent resource query lifecycle", () => { + it("observes into the canonical cache and rejects an old notified state before accepting the current one", async () => { + auth(); + const modes: (string | null)[] = []; + 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, { + stateVersion: observations > 1 ? "new" : "old", + }), + ); + }, + ), + ); + const { wrapper, queryClient } = createQueryWrapper(); + const { result } = renderHook(() => usePortfolio("a"), { wrapper }); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + await act(() => result.current.observe("new")); + await waitFor(() => expect(result.current.meta?.stateVersion).toBe("new")); + expect(modes).toEqual([null, "observe", "observe"]); + expect(queryClient.getQueryCache().getAll()).toHaveLength(1); + expect( + queryClient.getQueryData(portfolioQueryOptions("a").queryKey), + ).toMatchObject({ meta: { stateVersion: "new" }, data: snapshot }); + }); + it("does not convert missing or failed source data to empty financial success", async () => { + auth(); + server.use( + http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () => + HttpResponse.json( + resource(null, { + sync: { + id: "work", + state: "failed", + errorCode: "TBANK_UNAVAILABLE", + }, + }), + ), + ), + ); + const { result } = renderHook(() => usePortfolio("a"), { + wrapper: createQueryWrapper().wrapper, + }); + await waitFor(() => expect(result.current.meta?.sync.state).toBe("failed")); + expect(result.current.data).toBeUndefined(); + expect(result.current.isSuccess).toBe(false); + expect(result.current.meta?.availability).toBe("missing"); + }); + it("rejects a late ordinary response when an observe response for that resource arrived first", async () => { + auth(); + let release!: () => void; + const blocked = new Promise((resolve) => { + release = resolve; + }); + server.use( + http.get( + "*/api/v1/brokerage-accounts/:accountId/portfolio", + async ({ request }) => { + if (!new URL(request.url).searchParams.has("sync")) await blocked; + return HttpResponse.json(resource(snapshot)); + }, + ), + ); + const old = apiClient.GET( + "/api/v1/brokerage-accounts/{accountId}/portfolio", + { params: { path: { accountId: "a" } } }, + ); + const rejected = expect(old).rejects.toMatchObject({ name: "AbortError" }); + await apiClient.GET("/api/v1/brokerage-accounts/{accountId}/portfolio", { + params: { path: { accountId: "a" }, query: { sync: "observe" } }, + }); + release(); + await rejected; + }); + it("clears private snapshots on confirmed access loss and fences another pending success", async () => { + auth(); + let denied = false; + let release!: () => void; + const blocked = new Promise((resolve) => { + release = resolve; + }); + server.use( + http.get( + "*/api/v1/brokerage-accounts/:accountId/portfolio", + async ({ params }) => { + if (params.accountId === "b") await blocked; + if (params.accountId === "a" && denied) + return HttpResponse.json({ code: "NOT_FOUND" }, { status: 404 }); + return HttpResponse.json(resource(snapshot)); + }, + ), + ); + const { wrapper } = createQueryWrapper(); + const { result } = renderHook( + () => ({ a: usePortfolio("a"), b: usePortfolio("b") }), + { wrapper }, + ); + await waitFor(() => expect(result.current.a.data).toEqual(snapshot)); + denied = true; + await act(() => result.current.a.refetch()); + await waitFor(() => expect(result.current.a.data).toBeUndefined()); + await act(async () => { + release(); + await blocked; + }); + expect(result.current.b.data).toBeUndefined(); + expect(result.current.a.isError).toBe(true); + }); +}); diff --git a/src/entities/portfolio/api/usePortfolio.test.tsx b/src/entities/portfolio/api/usePortfolio.test.tsx index c91233d..efb6e4c 100644 --- a/src/entities/portfolio/api/usePortfolio.test.tsx +++ b/src/entities/portfolio/api/usePortfolio.test.tsx @@ -1,9 +1,9 @@ import { renderHook, waitFor } from "@testing-library/react"; import { HttpResponse, http } from "msw"; import { describe, expect, it, vi } from "vitest"; - import { configureAuthClient } from "@/shared/api"; import { createQueryWrapper } from "@/test/query"; +import { resource } from "@/test/resource"; import { server } from "@/test/server"; import { usePortfolio } from "./usePortfolio"; @@ -33,14 +33,16 @@ describe("usePortfolio", () => { http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () => { calls += 1; return calls === 1 - ? HttpResponse.json({ - lastSuccessfulAt: "2026-08-14T00:00:00Z", - isStale: false, - totalsByCurrency: [], - positions: [], - structure: { currencies: [], unvaluedCategories: [] }, - cashBalances: [], - }) + ? HttpResponse.json( + resource({ + lastSuccessfulAt: "2026-08-14T00:00:00Z", + isStale: false, + totalsByCurrency: [], + positions: [], + structure: { currencies: [], unvaluedCategories: [] }, + cashBalances: [], + }), + ) : HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 }); }), ); diff --git a/src/entities/portfolio/api/usePortfolio.ts b/src/entities/portfolio/api/usePortfolio.ts index 9326702..0514463 100644 --- a/src/entities/portfolio/api/usePortfolio.ts +++ b/src/entities/portfolio/api/usePortfolio.ts @@ -1,15 +1,12 @@ -import { - apiQueryClient, - type paths, - useAuthenticatedQuery, -} from "@/shared/api"; +import { apiQueryClient, type paths, useResourceQuery } from "@/shared/api"; const portfolioQueryPolicy = { staleTime: 30_000, } as const; -type Portfolio = - paths["/api/v1/brokerage-accounts/{accountId}/portfolio"]["get"]["responses"][200]["content"]["application/json"]; +type Portfolio = NonNullable< + paths["/api/v1/brokerage-accounts/{accountId}/portfolio"]["get"]["responses"][200]["content"]["application/json"]["data"] +>; function portfolioQueryOptions(accountId: string | undefined) { return apiQueryClient.queryOptions( @@ -20,7 +17,8 @@ function portfolioQueryOptions(accountId: string | undefined) { } function usePortfolio(accountId: string | undefined) { - return useAuthenticatedQuery({ + return useResourceQuery({ + meta: { accessGroup: "brokerage" }, ...portfolioQueryOptions(accountId), enabled: Boolean(accountId), ...portfolioQueryPolicy, diff --git a/src/entities/stock/api/queries.ts b/src/entities/stock/api/queries.ts index 29967c0..26081ab 100644 --- a/src/entities/stock/api/queries.ts +++ b/src/entities/stock/api/queries.ts @@ -1,8 +1,9 @@ import { apiQueryClient, type paths, + unwrapResourceResult, useAuthenticatedQueries, - useAuthenticatedQuery, + useResourceQuery, } from "@/shared/api"; type StockPeriod = NonNullable< @@ -14,7 +15,7 @@ type StockPeriod = NonNullable< const STOCK_STALE_TIME_MS = 30_000; function useStockSearch(query: string) { - return useAuthenticatedQuery({ + return useResourceQuery({ ...apiQueryClient.queryOptions("get", "/api/v1/stocks", { params: { query: { query } }, }), @@ -35,7 +36,10 @@ function useStocks(uids: string[]) { const uniqueUids = [...new Set(uids)]; const queries = useAuthenticatedQueries(uniqueUids.map(stockQueryOptions)); return new Map( - queries.map((query, index) => [uniqueUids[index], query.data]), + queries.map((query, index) => [ + uniqueUids[index], + unwrapResourceResult(query).data, + ]), ); } @@ -45,11 +49,11 @@ function useStock(identifier: string) { identifier, ); - const byUid = useAuthenticatedQuery({ + const byUid = useResourceQuery({ ...stockQueryOptions(identifier), enabled: Boolean(identifier) && isUid, }); - const byTicker = useAuthenticatedQuery({ + const byTicker = useResourceQuery({ ...apiQueryClient.queryOptions("get", "/api/v1/stocks/by-ticker/{ticker}", { params: { path: { ticker: identifier } }, }), @@ -59,7 +63,7 @@ function useStock(identifier: string) { return isUid ? byUid : byTicker; } function useStockQuote(uid: string) { - return useAuthenticatedQuery({ + return useResourceQuery({ ...apiQueryClient.queryOptions("get", "/api/v1/stocks/{uid}/quote", { params: { path: { uid } }, }), @@ -68,7 +72,7 @@ function useStockQuote(uid: string) { }); } function useStockCandles(uid: string, period: StockPeriod) { - return useAuthenticatedQuery({ + return useResourceQuery({ ...apiQueryClient.queryOptions("get", "/api/v1/stocks/{uid}/candles", { params: { path: { uid }, query: { period } }, }), @@ -77,7 +81,8 @@ function useStockCandles(uid: string, period: StockPeriod) { }); } function useStockPosition(uid: string) { - return useAuthenticatedQuery({ + return useResourceQuery({ + meta: { accessGroup: "brokerage" }, ...apiQueryClient.queryOptions("get", "/api/v1/stocks/{uid}/position", { params: { path: { uid } }, }), @@ -86,7 +91,7 @@ function useStockPosition(uid: string) { }); } function useStockCompany(uid: string) { - return useAuthenticatedQuery({ + return useResourceQuery({ ...apiQueryClient.queryOptions("get", "/api/v1/stocks/{uid}/company", { params: { path: { uid } }, }), @@ -95,7 +100,8 @@ function useStockCompany(uid: string) { }); } function useStockDividends(uid: string) { - return useAuthenticatedQuery({ + return useResourceQuery({ + meta: { accessGroup: "brokerage" }, ...apiQueryClient.queryOptions("get", "/api/v1/stocks/{uid}/dividends", { params: { path: { uid } }, }), diff --git a/src/features/auth/model/auth.test.tsx b/src/features/auth/model/auth.test.tsx index e349958..972d790 100644 --- a/src/features/auth/model/auth.test.tsx +++ b/src/features/auth/model/auth.test.tsx @@ -5,6 +5,7 @@ import type { FC, PropsWithChildren } from "react"; import { describe, expect, it } from "vitest"; import { apiClient, apiQueryClient, useAuthenticatedQuery } from "@/shared/api"; import { createTestQueryClient } from "@/test/query"; +import { resource } from "@/test/resource"; import { server } from "@/test/server"; import { AuthProvider, useAuth } from "./auth"; @@ -24,7 +25,7 @@ describe("AuthProvider", () => { ), http.get("*/api/v1/brokerage-accounts", ({ request }) => { authorization = request.headers.get("Authorization"); - return HttpResponse.json([]); + return HttpResponse.json(resource([])); }), ); const queryClient = createTestQueryClient(); diff --git a/src/features/auth/model/auth.tsx b/src/features/auth/model/auth.tsx index 16d13ca..3830b6d 100644 --- a/src/features/auth/model/auth.tsx +++ b/src/features/auth/model/auth.tsx @@ -143,8 +143,8 @@ const AuthProvider: FC = (props) => { refresh, clear: clearSession, }); - return () => configureAuthClient(null); }); + useEffect(() => () => configureAuthClient(null), []); return ( { + it.each(["missing", "partial", "complete"] as const)( + "keeps %s separate from successful transport", + (availability) => { + const data = availability === "missing" ? undefined : { rows: [] }; + const state = section({ + data, + meta: resource(data, { availability }).meta, + isError: false, + isFetching: false, + refetch: vi.fn(), + }); + expect(state.loading).toBe(availability === "missing"); + expect(state.partial).toBe(availability === "partial"); + expect(state.hasData).toBe(availability !== "missing"); + }, + ); + it("preserves a full fresh source and timestamp when force fails, while offering scoped retry", () => { + const retry = vi.fn(); + const refetch = vi.fn(); + const meta = resource([], { + sync: { id: "work", state: "failed", errorCode: "TBANK_UNAVAILABLE" }, + }).meta; + const state = section( + { data: [], meta, isError: false, isFetching: false, refetch }, + retry, + ); + expect(state).toMatchObject({ + hasData: true, + loading: false, + error: true, + refreshing: false, + lastSuccessfulAt: meta.lastSuccessfulAt, + }); + state.retry(); + expect(retry).toHaveBeenCalledOnce(); + expect(refetch).not.toHaveBeenCalled(); + }); + it("shows source failure without a false loading or empty result", () => { + const state = section({ + data: undefined, + meta: resource(null, { + sync: { id: "work", state: "failed", errorCode: "TBANK_UNAVAILABLE" }, + }).meta, + isError: false, + isFetching: false, + refetch: vi.fn(), + }); + expect(state).toMatchObject({ + hasData: false, + loading: false, + error: true, + }); + }); +}); diff --git a/src/features/instrument-details/model/section.ts b/src/features/instrument-details/model/section.ts index 972d7e6..109acb9 100644 --- a/src/features/instrument-details/model/section.ts +++ b/src/features/instrument-details/model/section.ts @@ -1,23 +1,44 @@ +import type { paths } from "@/shared/api"; + +type Meta = + paths["/api/v1/tbank-sync/{id}"]["get"]["responses"][200]["content"]["application/json"]["resources"][number]; type SectionState = { + stale?: boolean; + sourceError?: boolean; + partial?: boolean; + lastSuccessfulAt?: string | null; hasData: boolean; loading: boolean; error: boolean; refreshing: boolean; retry: () => void; }; -function section(query: { - data: unknown; - isError: boolean; - isFetching: boolean; - refetch: () => unknown; -}): SectionState { +function section( + query: { + data: unknown; + meta?: Meta; + isError: boolean; + isFetching: boolean; + refetch: () => unknown; + }, + retry?: () => unknown, +): SectionState { + const error = query.isError || query.meta?.sync.state === "failed"; return { + stale: query.meta?.freshness === "stale", + sourceError: query.meta?.sync.state === "failed", + partial: query.meta?.availability === "partial", + lastSuccessfulAt: query.meta?.lastSuccessfulAt, hasData: Boolean(query.data), - loading: !query.data && !query.isError, - error: query.isError, - refreshing: Boolean(query.data) && query.isFetching, + loading: !query.data && !error, + error, + refreshing: + Boolean(query.data) && + (query.isFetching || + query.meta?.sync.state === "running" || + query.meta?.sync.state === "queued"), retry: () => { - void query.refetch(); + void (retry ?? query.refetch)(); }, }; } diff --git a/src/features/instrument-details/model/types.ts b/src/features/instrument-details/model/types.ts index ccfa9b1..f3dbd2d 100644 --- a/src/features/instrument-details/model/types.ts +++ b/src/features/instrument-details/model/types.ts @@ -2,10 +2,12 @@ import type { BrokerageOperation } from "@/entities/broker-operation"; import type { paths } from "@/shared/api"; import type { SectionState } from "./section"; -type Position = - paths["/api/v1/stocks/{uid}/position"]["get"]["responses"][200]["content"]["application/json"]; -type Candles = - paths["/api/v1/stocks/{uid}/candles"]["get"]["responses"][200]["content"]["application/json"]; +type Position = NonNullable< + paths["/api/v1/stocks/{uid}/position"]["get"]["responses"][200]["content"]["application/json"]["data"] +>; +type Candles = NonNullable< + paths["/api/v1/stocks/{uid}/candles"]["get"]["responses"][200]["content"]["application/json"]["data"] +>; type ChartRow = Candles["candles"][number] & { date: string; price: number }; type InstrumentPositionVm = { position?: Position; diff --git a/src/features/instrument-details/ui/InstrumentChart.view.tsx b/src/features/instrument-details/ui/InstrumentChart.view.tsx index 0e1a7e0..100050e 100644 --- a/src/features/instrument-details/ui/InstrumentChart.view.tsx +++ b/src/features/instrument-details/ui/InstrumentChart.view.tsx @@ -177,7 +177,13 @@ const InstrumentChart: FC = (props) => { ) : ( - {t("stock.noCandles")} + + {t( + vm.candlesState.partial + ? "investment.partial" + : "stock.noCandles", + )} + ) ) : null} diff --git a/src/features/instrument-details/ui/InstrumentEvents.view.tsx b/src/features/instrument-details/ui/InstrumentEvents.view.tsx index 5d7f873..53195c0 100644 --- a/src/features/instrument-details/ui/InstrumentEvents.view.tsx +++ b/src/features/instrument-details/ui/InstrumentEvents.view.tsx @@ -60,7 +60,11 @@ const InstrumentEvents: FC = (props) => { ) : ( - {t("stock.noOperations")} + {t( + vm.operationsState.partial + ? "investment.partial" + : "stock.noOperations", + )} )} diff --git a/src/features/instrument-details/ui/InstrumentSection.view.tsx b/src/features/instrument-details/ui/InstrumentSection.view.tsx index 5534f1e..ff8fea3 100644 --- a/src/features/instrument-details/ui/InstrumentSection.view.tsx +++ b/src/features/instrument-details/ui/InstrumentSection.view.tsx @@ -11,6 +11,7 @@ import { import { LoadingSkeleton } from "@wealthledger/ui"; import type { FC, PropsWithChildren, ReactNode } from "react"; import { useTranslation } from "react-i18next"; +import { formatSnapshotTime } from "@/shared/lib"; import type { SectionState } from "../model/section"; import classes from "./InstrumentDetails.module.css"; @@ -38,20 +39,40 @@ const InstrumentSection: FC = (props) => { {props.controls} + {props.state.stale ? ( + {t("investment.stale")} + ) : null} + {props.state.partial ? ( + {t("investment.partial")} + ) : null} + {props.state.lastSuccessfulAt ? ( + + {t("investment.sourceTime", { + date: formatSnapshotTime(props.state.lastSuccessfulAt), + })} + + ) : null} {props.state.refreshing ? ( {t("accounts.refreshing")} ) : null} {props.state.error ? ( - + - {props.state.hasData - ? t("accounts.refreshError") - : t("stock.sectionError")} + {props.state.sourceError + ? t("investment.syncFailed") + : props.state.hasData + ? t("accounts.refreshError") + : t("stock.sectionError")} - diff --git a/src/features/tbank-connection/index.ts b/src/features/tbank-connection/index.ts index ed97bf1..5651582 100644 --- a/src/features/tbank-connection/index.ts +++ b/src/features/tbank-connection/index.ts @@ -1 +1,7 @@ +export { useScreenSync } from "./model/screen-sync"; +export { + type InvestmentScreen, + useTbankSync, + useTbankSyncStatus, +} from "./model/sync"; export { TbankConnectionSection } from "./ui/TbankConnectionSection/TbankConnectionSection"; diff --git a/src/features/tbank-connection/model/connection-boundary.ts b/src/features/tbank-connection/model/connection-boundary.ts index 440864b..14670ba 100644 --- a/src/features/tbank-connection/model/connection-boundary.ts +++ b/src/features/tbank-connection/model/connection-boundary.ts @@ -1,5 +1,9 @@ import type { QueryClient } from "@tanstack/react-query"; -import { apiQueryClient, type paths } from "@/shared/api"; +import { + apiQueryClient, + invalidateApiRequests, + type paths, +} from "@/shared/api"; type TbankConnectionStatus = paths["/api/v1/tbank-connection"]["get"]["responses"][200]["content"]["application/json"]; @@ -15,6 +19,13 @@ function commitTbankConnectionBoundary( queryClient: QueryClient, status: TbankConnectionStatus, ) { + invalidateApiRequests( + (path) => + path.startsWith("/api/v1/brokerage-accounts") || + path.startsWith("/api/v1/stocks") || + path.startsWith("/api/v1/bonds") || + path.startsWith("/api/v1/tbank-sync"), + ); queryClient.setQueryData(tbankConnectionQueryKey, status); void queryClient.resetQueries({ predicate: ({ queryKey }) => { @@ -22,7 +33,8 @@ function commitTbankConnectionBoundary( return ( method === "get" && typeof path === "string" && - (path === "/api/v1/bonds" || + (path.startsWith("/api/v1/tbank-sync") || + path === "/api/v1/bonds" || path.startsWith("/api/v1/bonds/") || path === "/api/v1/stocks" || path.startsWith("/api/v1/stocks/") || diff --git a/src/features/tbank-connection/model/screen-sync.test.tsx b/src/features/tbank-connection/model/screen-sync.test.tsx new file mode 100644 index 0000000..434ab27 --- /dev/null +++ b/src/features/tbank-connection/model/screen-sync.test.tsx @@ -0,0 +1,268 @@ +import { act, renderHook, waitFor } from "@testing-library/react"; +import { HttpResponse, http } from "msw"; +import { describe, expect, it, vi } from "vitest"; +import { useBrokerageAccounts } from "@/entities/brokerage-account"; +import { configureAuthClient } from "@/shared/api"; +import { createQueryWrapper } from "@/test/query"; +import { resource } from "@/test/resource"; +import { server } from "@/test/server"; +import { useScreenSync } from "../index"; + +function auth() { + configureAuthClient({ + accessToken: () => "synthetic", + refresh: async () => true, + clear: vi.fn(), + }); +} +describe("page-owned screen synchronization", () => { + it("posts only its scope once and observes completion without importing again", async () => { + auth(); + const modes: (string | null)[] = []; + const bodies: unknown[] = []; + let completed = false; + let polls = 0; + const meta = () => + resource([], { stateVersion: completed ? "2" : "1" }).meta; + server.use( + http.get("*/api/v1/brokerage-accounts", ({ request }) => { + modes.push(new URL(request.url).searchParams.get("sync")); + return HttpResponse.json(resource([], meta())); + }), + http.post("*/api/v1/tbank-sync", async ({ request }) => { + bodies.push(await request.json()); + completed = true; + return HttpResponse.json( + { + id: "screen", + scope: { screen: "accounts" }, + now: "2026-09-25T00:00:00Z", + }, + { status: 202 }, + ); + }), + http.get("*/api/v1/tbank-sync/:id", () => { + polls++; + return HttpResponse.json({ + id: "screen", + scope: { screen: "accounts" }, + now: "2026-09-25T00:00:00Z", + state: "succeeded", + errorCode: null, + progress: { total: 1, completed: 1, discovering: false }, + resources: [meta()], + }); + }), + ); + const { result, unmount } = renderHook( + () => { + const accounts = useBrokerageAccounts(); + return { + accounts, + sync: useScreenSync({ screen: "accounts" }, [accounts]), + }; + }, + { wrapper: createQueryWrapper().wrapper }, + ); + await waitFor(() => expect(result.current.accounts.data).toEqual([])); + await act(async () => { + await Promise.all([ + result.current.sync.refresh(), + result.current.sync.refresh(), + ]); + }); + await waitFor(() => + expect(result.current.accounts.meta?.stateVersion).toBe("2"), + ); + expect(bodies).toEqual([{ screen: "accounts" }]); + expect(modes).toEqual([null, "observe"]); + vi.useFakeTimers(); + try { + await act(() => vi.advanceTimersByTimeAsync(120000)); + expect(polls).toBe(1); + expect(modes).toEqual([null, "observe"]); + unmount(); + await vi.advanceTimersByTimeAsync(120000); + expect(polls).toBe(1); + } finally { + vi.useRealTimers(); + } + }); + it.each(["leave", "logout"])( + "publishes completed empty coverage while history runs, then stops on %s", + async (boundary) => { + vi.useFakeTimers({ toFake: ["Date"] }); + try { + auth(); + let completed = false; + let polls = 0; + const modes: (string | null)[] = []; + const snapshot = () => + resource(completed ? [] : null, { + stateVersion: completed ? "covered" : "initial", + sync: { id: "history", state: "running", errorCode: null }, + }); + server.use( + http.get("*/api/v1/brokerage-accounts", ({ request }) => { + modes.push(new URL(request.url).searchParams.get("sync")); + return HttpResponse.json(snapshot()); + }), + http.get("*/api/v1/tbank-sync/:id", () => { + polls++; + return HttpResponse.json({ + id: "history", + scope: null, + now: "2026-09-25T00:00:00Z", + state: "running", + errorCode: null, + progress: { + total: 2, + completed: completed ? 1 : 0, + discovering: true, + }, + resources: [snapshot().meta], + }); + }), + ); + const { result, unmount } = renderHook( + () => { + const accounts = useBrokerageAccounts(); + return { + accounts, + sync: useScreenSync({ screen: "accounts" }, [accounts]), + }; + }, + { wrapper: createQueryWrapper().wrapper }, + ); + await waitFor(() => + expect(result.current.accounts.meta?.sync.id).toBe("history"), + ); + await waitFor(() => expect(polls).toBeGreaterThan(0)); + expect(result.current.sync.refreshing).toBe(true); + expect(result.current.accounts.data).toBeUndefined(); + completed = true; + vi.setSystemTime(Date.now() + 61000); + await waitFor(() => expect(result.current.accounts.data).toEqual([]), { + timeout: 3000, + }); + expect(result.current.sync.refreshing).toBe(true); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 1100)); + }); + expect(modes).toEqual([null, "observe"]); + if (boundary === "leave") unmount(); + else await act(() => configureAuthClient(null)); + const stopped = polls; + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 1100)); + }); + expect(polls).toBe(stopped); + } finally { + vi.useRealTimers(); + } + }, + ); + + it("clears private snapshots when status confirms access loss", async () => { + auth(); + server.use( + http.get("*/api/v1/brokerage-accounts", () => + HttpResponse.json( + resource([], { + sync: { id: "revoked", state: "running", errorCode: null }, + }), + ), + ), + http.get("*/api/v1/tbank-sync/:id", () => + HttpResponse.json({ code: "INVALID_TBANK_TOKEN" }, { status: 400 }), + ), + ); + const { result } = renderHook( + () => { + const accounts = useBrokerageAccounts(); + return { + accounts, + sync: useScreenSync({ screen: "accounts" }, [accounts]), + }; + }, + { wrapper: createQueryWrapper().wrapper }, + ); + await waitFor(() => expect(result.current.accounts.isError).toBe(true)); + expect(result.current.accounts.data).toBeUndefined(); + }); + it("observes a projection when only its dependency reports new coverage", async () => { + auth(); + let observed = false; + const modes: (string | null)[] = []; + const dependency = resource([], { + resourceKey: "operations", + stateVersion: observed ? "covered" : "initial", + sync: { + id: "dependency", + state: observed ? "idle" : "running", + errorCode: null, + }, + }).meta; + server.use( + http.get("*/api/v1/brokerage-accounts", ({ request }) => { + const mode = new URL(request.url).searchParams.get("sync"); + modes.push(mode); + if (mode) observed = true; + return HttpResponse.json( + resource(observed ? [] : null, { + resourceKey: "projection", + dependencies: [ + { ...dependency, stateVersion: observed ? "covered" : "initial" }, + ], + }), + ); + }), + http.get("*/api/v1/tbank-sync/:id", () => + HttpResponse.json({ + id: "dependency", + scope: null, + now: "2026-09-25T00:00:00Z", + state: "succeeded", + errorCode: null, + progress: { total: 1, completed: 1, discovering: false }, + resources: [{ ...dependency, stateVersion: "covered" }], + }), + ), + ); + const { result } = renderHook( + () => { + const accounts = useBrokerageAccounts(); + useScreenSync({ screen: "accounts" }, [accounts]); + return accounts; + }, + { wrapper: createQueryWrapper().wrapper }, + ); + await waitFor(() => expect(result.current.data).toEqual([])); + expect(modes).toEqual([null, "observe"]); + }); + + it("preserves a fresh snapshot and timestamp after a failed force", async () => { + auth(); + const saved = resource([], { lastSuccessfulAt: "2026-09-25T01:00:00Z" }); + server.use( + http.get("*/api/v1/brokerage-accounts", () => HttpResponse.json(saved)), + http.post("*/api/v1/tbank-sync", () => + HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 }), + ), + ); + const { result } = renderHook( + () => { + const accounts = useBrokerageAccounts(); + return { + accounts, + sync: useScreenSync({ screen: "accounts" }, [accounts]), + }; + }, + { wrapper: createQueryWrapper().wrapper }, + ); + await waitFor(() => expect(result.current.accounts.data).toEqual([])); + await act(() => result.current.sync.refresh()); + await waitFor(() => expect(result.current.sync.transportError).toBe(true)); + expect(result.current.accounts.meta).toEqual(saved.meta); + }); +}); diff --git a/src/features/tbank-connection/model/screen-sync.ts b/src/features/tbank-connection/model/screen-sync.ts new file mode 100644 index 0000000..b57481b --- /dev/null +++ b/src/features/tbank-connection/model/screen-sync.ts @@ -0,0 +1,148 @@ +import { useQueryClient } from "@tanstack/react-query"; +import { useEffect, useRef, useState } from "react"; +import { + apiErrorCode, + apiQueryClient, + invalidateResourceAccess, + type paths, + useAuthenticatedQueries, +} from "@/shared/api"; +import { type InvestmentScreen, useTbankSync } from "./sync"; + +type ResourceMeta = + paths["/api/v1/tbank-sync/{id}"]["get"]["responses"][200]["content"]["application/json"]["resources"][number]; +type ScreenResource = { + meta?: ResourceMeta; + observe: (version?: string) => Promise; +}; +const STATUS_INTERVAL_MS = 1000; + +function useScreenSync(scope: InvestmentScreen, resources: ScreenResource[]) { + const mutation = useTbankSync(); + const client = useQueryClient(); + const denied = useRef(new Set()); + const scopeKey = JSON.stringify(scope); + const [requested, setRequested] = useState<{ + scope: string; + id: string; + } | null>(null); + const requestInFlight = useRef(null); + const currentScope = useRef(scopeKey); + currentScope.current = scopeKey; + const currentMutation = JSON.stringify(mutation.variables?.body) === scopeKey; + const observed = useRef(new Map()); + const ids = [ + ...new Set([ + ...(requested?.scope === scopeKey ? [requested.id] : []), + ...resources + .flatMap(({ meta }) => + meta ? [meta, ...(meta.dependencies ?? [])] : [], + ) + .flatMap((meta) => + meta?.sync.id && + (meta.sync.state === "running" || meta.sync.state === "queued") + ? [meta.sync.id] + : [], + ), + ]), + ]; + const statuses = useAuthenticatedQueries( + ids.map((id) => ({ + ...apiQueryClient.queryOptions("get", "/api/v1/tbank-sync/{id}", { + params: { path: { id } }, + }), + retry: false, + refetchOnMount: false, + refetchOnWindowFocus: false, + refetchInterval: (query: { + state: { error: unknown; data?: { state: string } }; + }) => + query.state.error || + (query.state.data && query.state.data.state !== "running") + ? false + : STATUS_INTERVAL_MS, + })), + ); + useEffect(() => { + for (const status of statuses) { + if (status.error && !denied.current.has(status.error)) { + denied.current.add(status.error); + if (apiErrorCode(status.error) !== "NOT_FOUND") + invalidateResourceAccess(client, "brokerage", status.error); + } + } + const latest = new Map(); + for (const status of [...statuses].sort( + (a, b) => a.dataUpdatedAt - b.dataUpdatedAt, + )) + for (const meta of status.data?.resources ?? []) { + latest.set(meta.resourceKey, meta); + for (const dependency of meta.dependencies ?? []) + latest.set(dependency.resourceKey, dependency); + } + for (const resource of resources) { + if (!resource.meta) continue; + const changed = [ + resource.meta, + ...(resource.meta.dependencies ?? []), + ].flatMap((current) => { + const notified = latest.get(current.resourceKey); + return notified && notified.stateVersion !== current.stateVersion + ? [notified] + : []; + }); + if (!changed.length) continue; + const notification = JSON.stringify( + changed.map((meta) => [meta.resourceKey, meta.stateVersion]), + ); + if (observed.current.get(resource.meta.resourceKey) === notification) + continue; + observed.current.set(resource.meta.resourceKey, notification); + const own = changed.find( + (meta) => meta.resourceKey === resource.meta?.resourceKey, + ); + void resource.observe(own?.stateVersion).catch(() => undefined); + } + }, [client, statuses, resources]); + const refreshing = + (currentMutation && mutation.isPending) || + statuses.some( + (status) => !status.isError && status.data?.state === "running", + ) || + resources.some(({ meta }) => { + const status = statuses[ids.indexOf(meta?.sync.id ?? "")]; + return ( + !status?.isError && + (!status?.data || status.data.state === "running") && + (meta?.sync.state === "running" || meta?.sync.state === "queued") + ); + }); + const refresh = async () => { + if (requestInFlight.current === scopeKey || refreshing) return; + requestInFlight.current = scopeKey; + try { + const accepted = await mutation.mutateAsync({ body: scope }); + if ( + currentScope.current === scopeKey && + requestInFlight.current === scopeKey + ) + setRequested({ scope: scopeKey, id: accepted.id }); + } catch { + /* The mutation retains the transport error for presentation. */ + } finally { + if (requestInFlight.current === scopeKey) requestInFlight.current = null; + } + }; + return { + refresh, + refreshing, + transportError: + (currentMutation && mutation.isError) || + statuses.some((status) => status.isError), + sourceError: + statuses.some((status) => status.data?.state === "failed") || + resources.some(({ meta }) => meta?.sync.state === "failed"), + }; +} + +export { useScreenSync }; diff --git a/src/features/tbank-connection/model/sync.test.tsx b/src/features/tbank-connection/model/sync.test.tsx new file mode 100644 index 0000000..425e36b --- /dev/null +++ b/src/features/tbank-connection/model/sync.test.tsx @@ -0,0 +1,114 @@ +import { act, renderHook, waitFor } from "@testing-library/react"; +import { HttpResponse, http } from "msw"; +import { describe, expect, it, vi } from "vitest"; +import { apiQueryClient, configureAuthClient } from "@/shared/api"; +import { createQueryWrapper } from "@/test/query"; +import { server } from "@/test/server"; +import { + type InvestmentScreen, + useTbankSync, + useTbankSyncStatus, +} from "../index"; +import { commitTbankConnectionBoundary } from "./connection-boundary"; + +describe("sync connection and session boundaries", () => { + it.each(["connection", "session"])( + "discards pending status and mutation after %s changes", + async (boundary) => { + let generation = 0; + configureAuthClient({ + generation: () => generation, + accessToken: () => "synthetic", + refresh: async () => true, + clear: vi.fn(), + }); + let release!: () => void; + const blocked = new Promise((resolve) => { + release = resolve; + }); + const accepted = { + id: "work", + scope: { screen: "accounts" }, + now: "2026-09-25T12:00:00Z", + }; + let started = 0; + let changed = false; + server.use( + http.post("*/api/v1/tbank-sync", async () => { + started += 1; + await blocked; + return HttpResponse.json(accepted, { status: 202 }); + }), + http.get("*/api/v1/tbank-sync/:id", async () => { + const now = changed ? "2026-09-25T12:01:00Z" : accepted.now; + started += 1; + await blocked; + return HttpResponse.json({ + ...accepted, + now, + state: "succeeded", + errorCode: null, + progress: { total: 1, completed: 1, discovering: false }, + resources: [], + }); + }), + ); + const { wrapper, queryClient } = createQueryWrapper(); + const published: string[] = []; + const unsubscribe = queryClient.getQueryCache().subscribe((event) => { + if (event.query.queryKey[1] === "/api/v1/tbank-sync/{id}") { + const data = event.query.state.data as { now?: string } | undefined; + if (data?.now) published.push(data.now); + } + }); + const { result } = renderHook( + () => ({ + mutation: useTbankSync(), + status: useTbankSyncStatus("work"), + }), + { wrapper }, + ); + const screen: InvestmentScreen = { screen: "accounts" }; + let mutation!: Promise; + act(() => { + mutation = result.current.mutation.mutateAsync({ body: screen }); + }); + const discarded = expect(mutation).rejects.toMatchObject({ + name: "AbortError", + }); + await waitFor(() => expect(started).toBe(2)); + await act(async () => { + changed = true; + if (boundary === "connection") + commitTbankConnectionBoundary(queryClient, { + connected: false, + connectedAt: null, + }); + else { + generation += 1; + queryClient.clear(); + } + release(); + await blocked; + }); + await discarded; + expect(result.current.mutation.data).toBeUndefined(); + const key = apiQueryClient.queryOptions( + "get", + "/api/v1/tbank-sync/{id}", + { params: { path: { id: "work" } } }, + ).queryKey; + // A new request may start after a successful connection boundary; an old result cannot win it. + if (boundary === "session") + expect(queryClient.getQueryData(key)).toBeUndefined(); + else + await waitFor(() => + expect(queryClient.getQueryData(key)).toMatchObject({ + now: "2026-09-25T12:01:00Z", + }), + ); + expect(published).not.toContain(accepted.now); + unsubscribe(); + }, + ); +}); diff --git a/src/features/tbank-connection/model/sync.ts b/src/features/tbank-connection/model/sync.ts new file mode 100644 index 0000000..594c5e3 --- /dev/null +++ b/src/features/tbank-connection/model/sync.ts @@ -0,0 +1,24 @@ +import { + apiQueryClient, + type paths, + useAuthenticatedQuery, +} from "@/shared/api"; + +type InvestmentScreen = + paths["/api/v1/tbank-sync"]["post"]["requestBody"]["content"]["application/json"]; +function useTbankSync() { + return apiQueryClient.useMutation("post", "/api/v1/tbank-sync", { + retry: false, + }); +} +function useTbankSyncStatus(id: string | null) { + return useAuthenticatedQuery({ + ...apiQueryClient.queryOptions("get", "/api/v1/tbank-sync/{id}", { + params: { path: { id: id ?? "" } }, + }), + enabled: Boolean(id), + retry: false, + }); +} + +export { type InvestmentScreen, useTbankSync, useTbankSyncStatus }; diff --git a/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.filters.test.tsx b/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.filters.test.tsx index b402669..f405623 100644 --- a/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.filters.test.tsx +++ b/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.filters.test.tsx @@ -1,6 +1,12 @@ import { MantineProvider } from "@mantine/core"; import { QueryClientProvider } from "@tanstack/react-query"; -import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { + fireEvent, + render, + renderHook, + screen, + waitFor, +} from "@testing-library/react"; import { HttpResponse, http } from "msw"; import type { FC } from "react"; import { @@ -12,9 +18,11 @@ import { } from "react-router"; import { describe, expect, it, vi } from "vitest"; import { configureAuthClient } from "@/shared/api"; -import { createTestQueryClient } from "@/test/query"; +import { createQueryWrapper, createTestQueryClient } from "@/test/query"; +import { resource } from "@/test/resource"; import { server } from "@/test/server"; import { AccountEventsPage } from "./AccountEventsPage"; +import { useAccountEventsPageViewModel } from "./AccountEventsPage.vm"; const Navigation: FC = () => { const location = useLocation(); @@ -44,12 +52,14 @@ describe("Upcoming event date filters", () => { "*/api/v1/brokerage-accounts/:accountId/events", ({ request }) => { searches.push(new URL(request.url).search); - return HttpResponse.json({ - accountId, - calculatedAt: "2099-01-01T00:00:00Z", - events: [], - incomeTotalsByCurrency: [], - }); + return HttpResponse.json( + resource({ + accountId, + calculatedAt: "2099-01-01T00:00:00Z", + events: [], + incomeTotalsByCurrency: [], + }), + ); }, ), ); @@ -110,3 +120,45 @@ describe("Upcoming event date filters", () => { }, ); }); + +it("retains the previous complete forecast and timestamp when its source refresh fails", async () => { + configureAuthClient({ + accessToken: () => "synthetic", + refresh: async () => true, + clear: vi.fn(), + }); + const events = [ + { + date: "2026-10-01", + displayName: "Synthetic coupon", + type: "coupon", + projectedAmount: { currency: "RUB", value: "123.45" }, + }, + ]; + const snapshot = resource( + { events, incomeTotalsByCurrency: [{ currency: "RUB", value: "123.45" }] }, + { sync: { id: "failed", state: "failed", errorCode: "TBANK_UNAVAILABLE" } }, + ); + server.use( + http.get("*/api/v1/brokerage-accounts/:accountId/events", () => + HttpResponse.json(snapshot), + ), + ); + const { wrapper: QueryWrapper } = createQueryWrapper(); + const { result } = renderHook(() => useAccountEventsPageViewModel("all"), { + wrapper: ({ children }) => ( + + {children} + + ), + }); + await waitFor(() => + expect(result.current).toMatchObject({ + state: "loaded", + events, + sourceError: true, + refreshError: true, + lastSuccessfulAt: snapshot.meta.lastSuccessfulAt, + }), + ); +}); diff --git a/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.test.tsx b/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.test.tsx index d4a94ba..5312127 100644 --- a/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.test.tsx +++ b/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.test.tsx @@ -5,6 +5,15 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { useBrokerageEvents } from "@/entities/brokerage-event"; import { AccountEventsPage } from "./AccountEventsPage"; +const { refresh } = vi.hoisted(() => ({ refresh: vi.fn() })); +vi.mock("@/features/tbank-connection", () => ({ + useScreenSync: () => ({ + refresh, + refreshing: false, + sourceError: false, + transportError: false, + }), +})); vi.mock("@/entities/brokerage-event", async (importOriginal) => ({ ...(await importOriginal()), useBrokerageEvents: vi.fn(), @@ -24,12 +33,17 @@ function renderPage() { describe("AccountEventsPage", () => { beforeEach(() => { + refresh.mockClear(); query.mockReturnValue({ data: { accountId: "account", calculatedAt: "2026-08-14T00:00:00Z", events: [], }, + meta: { + availability: "complete", + sync: { state: "idle", id: null, errorCode: null }, + }, isError: false, isLoading: false, refetch: vi.fn(), @@ -55,6 +69,10 @@ describe("AccountEventsPage", () => { it("keeps the loading fallback when no result or account is available", () => { query.mockReturnValue({ data: undefined, + meta: { + availability: "complete", + sync: { state: "idle", id: null, errorCode: null }, + }, isError: false, isLoading: false, refetch: vi.fn(), @@ -99,6 +117,10 @@ describe("AccountEventsPage", () => { }, ], }, + meta: { + availability: "complete", + sync: { state: "idle", id: null, errorCode: null }, + }, isError: false, isLoading: false, refetch: vi.fn(), @@ -145,12 +167,16 @@ describe("AccountEventsPage", () => { , ); screen.getByRole("button", { name: "Повторить" }).click(); - expect(refetch).toHaveBeenCalledOnce(); + expect(refresh).toHaveBeenCalledOnce(); }); it("shows a structural busy state before events are available", () => { query.mockReturnValue({ data: undefined, + meta: { + availability: "complete", + sync: { state: "idle", id: null, errorCode: null }, + }, isError: false, isLoading: true, refetch: vi.fn(), diff --git a/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.vm.ts b/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.vm.ts index 9737804..1cd4d37 100644 --- a/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.vm.ts +++ b/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.vm.ts @@ -4,6 +4,7 @@ import { type BrokerageEvent, useBrokerageEvents, } from "@/entities/brokerage-event"; +import { useScreenSync } from "@/features/tbank-connection"; type AccountEventsPageViewModel = | { state: "loading" } @@ -11,6 +12,9 @@ type AccountEventsPageViewModel = | { state: "loaded"; events: BrokerageEvent[]; + sourceError?: boolean; + lastSuccessfulAt?: string | null; + partial?: boolean; refreshing: boolean; refreshError: boolean; retry: () => void; @@ -41,22 +45,34 @@ function useAccountEventsPageViewModel( ...(appliedFrom ? { from: appliedFrom } : {}), ...(appliedTo ? { to: appliedTo } : {}), }); + const sync = useScreenSync( + { + screen: "events", + accountId, + ...(appliedFrom ? { from: appliedFrom } : {}), + ...(appliedTo ? { to: appliedTo } : {}), + }, + [events], + ); if (events.isLoading) return { state: "loading" }; - if (events.isError && !events.data) + if ((events.isError || events.meta?.sync.state === "failed") && !events.data) return { state: "error", retry: () => { - void events.refetch(); + void sync.refresh(); }, }; if (!events.data) return { state: "loading" }; return { state: "loaded", events: events.data.events, - refreshing: events.isFetching, - refreshError: events.isError, + sourceError: sync.sourceError, + lastSuccessfulAt: events.meta?.lastSuccessfulAt, + partial: events.meta?.availability !== "complete", + refreshing: events.isFetching || sync.refreshing, + refreshError: events.isError || sync.sourceError || sync.transportError, retry: () => { - void events.refetch(); + void sync.refresh(); }, draftFrom, draftTo, diff --git a/src/pages/account-events/ui/AccountEventsPage/components/AccountEventsPageLoaded.view.tsx b/src/pages/account-events/ui/AccountEventsPage/components/AccountEventsPageLoaded.view.tsx index 518706c..5d82878 100644 --- a/src/pages/account-events/ui/AccountEventsPage/components/AccountEventsPageLoaded.view.tsx +++ b/src/pages/account-events/ui/AccountEventsPage/components/AccountEventsPageLoaded.view.tsx @@ -11,6 +11,7 @@ import { DatePickerInput } from "@mantine/dates"; import type { FC } from "react"; import { useTranslation } from "react-i18next"; import { BrokerageEventCard } from "@/entities/brokerage-event"; +import { formatSnapshotTime } from "@/shared/lib"; import type { AccountEventsPageViewModel } from "../AccountEventsPage.vm"; import { AccountEventsPageEmpty } from "./AccountEventsPageEmpty.view"; @@ -23,13 +24,30 @@ const AccountEventsPageLoaded: FC = (props) => { const { t } = useTranslation(); return ( + {props.lastSuccessfulAt ? ( + + {t("investment.sourceTime", { + date: formatSnapshotTime(props.lastSuccessfulAt), + })} + + ) : null} {t("events.title")} + + {props.partial ? ( + {t("investment.partial")} + ) : null} {props.refreshing ? ( ) : null} {props.refreshError ? ( - - {t("accounts.refreshError")} + + {t( + props.sourceError + ? "investment.syncFailed" + : "accounts.refreshError", + )} @@ -80,9 +98,9 @@ const AccountEventsPageLoaded: FC = (props) => { /> ))} - ) : ( + ) : !props.partial ? ( - )} + ) : null} ); }; diff --git a/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.test.tsx b/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.test.tsx index 9f4465c..51056cc 100644 --- a/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.test.tsx +++ b/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.test.tsx @@ -1,3 +1,4 @@ +import { resource } from "@/test/resource"; import "dayjs/locale/ru"; import { MantineProvider } from "@mantine/core"; import { DatesProvider } from "@mantine/dates"; @@ -154,7 +155,7 @@ describe("AccountIncomePage", () => { "*/api/v1/brokerage-accounts/:accountId/income-analytics", ({ request }) => { requested.push(new URL(request.url).search); - return HttpResponse.json(analytics); + return HttpResponse.json(resource(analytics)); }, ), ); @@ -236,7 +237,7 @@ describe("AccountIncomePage", () => { }; server.use( http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () => - HttpResponse.json(response), + HttpResponse.json(resource(response)), ), ); renderPage(); @@ -284,7 +285,7 @@ describe("AccountIncomePage", () => { ])("shows a local error for %s", async (_name, response) => { server.use( http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () => - HttpResponse.json(response), + HttpResponse.json(resource(response)), ), ); renderPage(); @@ -299,13 +300,15 @@ describe("AccountIncomePage", () => { it("accepts an empty analytics response", async () => { server.use( http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () => - HttpResponse.json({ - ...analytics, - totalsByCurrency: [], - buckets: [], - instruments: [], - entries: [], - }), + HttpResponse.json( + resource({ + ...analytics, + totalsByCurrency: [], + buckets: [], + instruments: [], + entries: [], + }), + ), ), ); renderPage(); @@ -347,7 +350,7 @@ describe("AccountIncomePage", () => { }; server.use( http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () => - HttpResponse.json(response), + HttpResponse.json(resource(response)), ), ); renderPage(); @@ -369,13 +372,15 @@ describe("AccountIncomePage", () => { const eventsRequest = vi.fn(() => new HttpResponse(null, { status: 503 })); server.use( http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () => - HttpResponse.json({ - ...analytics, - yearComparison: { - period: { from: "2025-08-01", to: "2025-08-27" }, - totalsByCurrency: [comparison("RUB")], - }, - }), + HttpResponse.json( + resource({ + ...analytics, + yearComparison: { + period: { from: "2025-08-01", to: "2025-08-27" }, + totalsByCurrency: [comparison("RUB")], + }, + }), + ), ), http.get("*/api/v1/brokerage-accounts/account-1/events", eventsRequest), ); @@ -392,13 +397,16 @@ describe("AccountIncomePage", () => { expect(screen.queryByText("Динамика")).not.toBeInTheDocument(); expect(eventsRequest).not.toHaveBeenCalled(); expect(screen.getByRole("region", { name: "Выплаты" })).toBeInTheDocument(); - const selected = screen.getByRole("region", { + const year = screen.getByRole("region", { name: "Годом ранее" }); + const selected = within(year).getByRole("region", { name: "01.08.2026 – 27.08.2026", }); expect( screen.getByRole("region", { name: "01.08.2025 – 27.08.2025" }), ).toBeInTheDocument(); - expect(screen.queryByText(/Предыдущий период/)).not.toBeInTheDocument(); + expect( + screen.getByRole("heading", { name: "Предыдущий период" }), + ).toBeVisible(); expect(screen.queryByText("Выбранный период")).not.toBeInTheDocument(); expect(screen.queryByText("Сравниваемый период")).not.toBeInTheDocument(); expect( @@ -409,7 +417,7 @@ describe("AccountIncomePage", () => { for (const amount of ["10 ₽", "2 ₽", "12 ₽"]) { expect(within(selected).getByText(amount)).toBeInTheDocument(); } - const difference = screen.getByRole("region", { name: "Разница" }); + const difference = within(year).getByRole("region", { name: "Разница" }); for (const amount of ["+9 ₽", "+2 ₽", "+11 ₽"]) { expect(within(difference).getByText(amount)).toBeInTheDocument(); } @@ -425,7 +433,18 @@ describe("AccountIncomePage", () => { it("shows the unavailable year state without falling back to the previous period", async () => { server.use( http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () => - HttpResponse.json(analytics), + HttpResponse.json( + resource(analytics, { + dependencies: [ + resource(analytics).meta, + resource(analytics).meta, + { + ...resource(null).meta, + unavailableReason: "CALENDAR_OR_ACCOUNT_HISTORY", + }, + ], + }), + ), ), ); renderPage(); @@ -433,9 +452,11 @@ describe("AccountIncomePage", () => { await screen.findByText("Сравнение с годом ранее недоступно."), ).toBeInTheDocument(); expect( - screen.queryByRole("region", { name: "Разница" }), + screen.queryByRole("region", { name: "Годом ранее" }), ).not.toBeInTheDocument(); - expect(screen.queryByText(/Предыдущий период/)).not.toBeInTheDocument(); + expect( + screen.getByRole("region", { name: "Предыдущий период" }), + ).toBeVisible(); }); it("shows a recoverable realized error", async () => { @@ -537,13 +558,15 @@ function respondWithRequestedPeriod(requests: string[]) { ({ request }) => { const url = new URL(request.url); requests.push(url.search); - return HttpResponse.json({ - ...analytics, - period: { - from: url.searchParams.get("from"), - to: url.searchParams.get("to"), - }, - }); + return HttpResponse.json( + resource({ + ...analytics, + period: { + from: url.searchParams.get("from"), + to: url.searchParams.get("to"), + }, + }), + ); }, ), ); @@ -669,7 +692,32 @@ describe("income period and snapshot boundary", () => { await screen.findByText(/Показан последний загруженный результат/), ).toBeInTheDocument(); expect(screen.getByRole("region", { name: "Выплаты" })).toBeVisible(); - respondWithRequestedPeriod([]); + server.use( + http.post("*/api/v1/tbank-sync", () => + HttpResponse.json( + { + id: "income", + scope: { screen: "income", accountId: "account-1" }, + now: "2026-09-25T00:00:00Z", + }, + { status: 202 }, + ), + ), + http.get("*/api/v1/tbank-sync/:id", () => + HttpResponse.json({ + id: "income", + scope: null, + now: "2026-09-25T00:00:00Z", + state: "succeeded", + errorCode: null, + progress: { total: 1, completed: 1, discovering: false }, + resources: [resource(null, { stateVersion: "2" }).meta], + }), + ), + http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () => + HttpResponse.json(resource(analytics, { stateVersion: "2" })), + ), + ); fireEvent.click(screen.getByRole("button", { name: "Повторить" })); await waitFor(() => expect( @@ -694,10 +742,12 @@ describe("income period and snapshot boundary", () => { "*/api/v1/brokerage-accounts/account-1/income-analytics", async () => { await pending; - return HttpResponse.json({ - ...analytics, - period: { from: "2026-08-02", to: "2026-08-27" }, - }); + return HttpResponse.json( + resource({ + ...analytics, + period: { from: "2026-08-02", to: "2026-08-27" }, + }), + ); }, ), ); @@ -778,3 +828,64 @@ describe("income period and snapshot boundary", () => { expect(screen.getAllByText("Купон")).toHaveLength(6); }); }); + +it("shows selected income before comparison coverage without inventing a zero baseline", async () => { + server.use( + http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () => + HttpResponse.json( + resource({ ...analytics, previous: null, yearComparison: null }), + ), + ), + ); + renderPage(); + expect(await screen.findByRole("region", { name: "Выплаты" })).toBeVisible(); + expect( + screen.queryByRole("region", { name: "Год назад" }), + ).not.toBeInTheDocument(); + expect(screen.getAllByText("12 ₽").length).toBeGreaterThan(0); + expect( + screen.getAllByText("Период сравнения ещё не загружен полностью."), + ).toHaveLength(2); + expect( + screen.queryByText("Сравнение с годом ранее недоступно."), + ).not.toBeInTheDocument(); + expect( + screen.queryByRole("region", { name: "Разница" }), + ).not.toBeInTheDocument(); + expect(screen.getByLabelText("Период")).toBeVisible(); +}); + +it("keeps complete previous comparison while year source failed without inventing its amounts", async () => { + server.use( + http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () => + HttpResponse.json( + resource(analytics, { + dependencies: [ + resource(analytics).meta, + resource(analytics).meta, + { + ...resource(null).meta, + sync: { + id: null, + state: "failed", + errorCode: "TBANK_UNAVAILABLE", + }, + }, + ], + }), + ), + ), + ); + renderPage(); + expect( + await screen.findByRole("region", { name: "Предыдущий период" }), + ).toBeVisible(); + expect( + screen.getByText( + "Не удалось загрузить период сравнения. Повторите обновление.", + ), + ).toBeVisible(); + expect( + screen.queryByRole("region", { name: "Годом ранее" }), + ).not.toBeInTheDocument(); +}); diff --git a/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.vm.ts b/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.vm.ts index 48533af..81bab28 100644 --- a/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.vm.ts +++ b/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.vm.ts @@ -4,6 +4,7 @@ import { useForm } from "react-hook-form"; import { useParams, useSearchParams } from "react-router"; import { z } from "zod"; import { type IncomeAnalytics, useIncomeAnalytics } from "@/entities/income"; +import { useScreenSync } from "@/features/tbank-connection"; import { validationMessage } from "@/shared/i18n"; import { formatDate } from "@/shared/lib"; import { @@ -17,7 +18,16 @@ import { const ISO_MONTH_LENGTH = 7; +type IncomeMeta = ReturnType["meta"]; +type ComparisonNotice = + | "comparisonPending" + | "comparisonFailed" + | "yearUnavailable"; + type IncomeDisplayModel = { + previousComparison: IncomeDisplayModel["comparison"]; + previousNotice: ComparisonNotice; + yearNotice: ComparisonNotice; entries: { key: string; operation: IncomeAnalytics["entries"][number] }[]; comparison: { selectedPeriod: string; @@ -38,6 +48,9 @@ type AccountIncomePageViewModel = analytics: IncomeAnalytics; display: IncomeDisplayModel; formRevision: number; + sourceError?: boolean; + lastSuccessfulAt?: string | null; + partial?: boolean; refreshing: boolean; refreshError: boolean; retry: () => void; @@ -90,6 +103,10 @@ const useAccountIncomePageViewModel = ( }); const [formRevision, setFormRevision] = useState(0); const analytics = useIncomeAnalytics(accountId, period); + const sync = useScreenSync( + { screen: "income", accountId, from: period.from, to: period.to }, + [analytics], + ); useEffect(() => { if (!validPeriod(requested)) setSearchParams(period, { replace: true }); @@ -112,21 +129,30 @@ const useAccountIncomePageViewModel = ( setSearchParams(next, { replace: true }); }; if (analytics.isLoading || !analytics.data) { - if (analytics.isError) - return { state: "error", retry: () => analytics.refetch(), period }; + if (analytics.isError || analytics.meta?.sync.state === "failed") + return { state: "error", retry: sync.refresh, period }; return { state: "loading" }; } - const display = toIncomeDisplayModel(analytics.data); + const display = toIncomeDisplayModel(analytics.data, analytics.meta); if (!display) - return { state: "error", retry: () => analytics.refetch(), period }; + return { + state: "error", + retry: () => { + void analytics.observe(); + }, + period, + }; return { state: "loaded", analytics: analytics.data, display, formRevision, - refreshing: analytics.isFetching, - refreshError: analytics.isError, - retry: () => analytics.refetch(), + sourceError: sync.sourceError, + lastSuccessfulAt: analytics.meta?.lastSuccessfulAt, + partial: analytics.meta?.availability !== "complete", + refreshing: analytics.isFetching || sync.refreshing, + refreshError: analytics.isError || sync.sourceError || sync.transportError, + retry: sync.refresh, form, onSubmit, onReset, @@ -157,6 +183,7 @@ function validPeriod(period: IncomePeriod): period is IncomePeriod { function toIncomeDisplayModel( analytics: IncomeAnalytics, + meta?: IncomeMeta, ): IncomeDisplayModel | null { const instruments = new Map< string, @@ -209,33 +236,53 @@ function toIncomeDisplayModel( if (instrument.includedCount !== instrument.receiptCount) return null; } + // The income endpoint returns selected, previous and year dependencies in order. + const [, previous, year] = meta?.dependencies ?? []; return { entries, - comparison: analytics.yearComparison - ? { - selectedPeriod: formatComparisonPeriod(analytics.period), - comparedPeriod: formatComparisonPeriod( - analytics.yearComparison.period, - ), - values: analytics.yearComparison.totalsByCurrency.map((value) => ({ - ...value, - percentages: { - total: formatIncomeChange( - value.difference.total.value, - value.compared.total.value, - ), - coupon: formatIncomeChange( - value.difference.coupon.value, - value.compared.coupon.value, - ), - dividend: formatIncomeChange( - value.difference.dividend.value, - value.compared.dividend.value, - ), - }, - })), - } - : null, + previousComparison: comparisonDisplay(analytics.period, analytics.previous), + comparison: comparisonDisplay(analytics.period, analytics.yearComparison), + previousNotice: comparisonNotice(previous), + yearNotice: comparisonNotice(year), + }; +} + +function comparisonNotice( + dependency: + | NonNullable["dependencies"]>[number] + | undefined, +): ComparisonNotice { + if (dependency?.unavailableReason) return "yearUnavailable"; + return dependency?.sync.state === "failed" + ? "comparisonFailed" + : "comparisonPending"; +} + +function comparisonDisplay( + period: IncomePeriod, + comparison: IncomeAnalytics["yearComparison"], +): IncomeDisplayModel["comparison"] { + if (!comparison) return null; + return { + selectedPeriod: formatComparisonPeriod(period), + comparedPeriod: formatComparisonPeriod(comparison.period), + values: comparison.totalsByCurrency.map((value) => ({ + ...value, + percentages: { + total: formatIncomeChange( + value.difference.total.value, + value.compared.total.value, + ), + coupon: formatIncomeChange( + value.difference.coupon.value, + value.compared.coupon.value, + ), + dividend: formatIncomeChange( + value.difference.dividend.value, + value.compared.dividend.value, + ), + }, + })), }; } diff --git a/src/pages/account-income/ui/AccountIncomePage/components/AccountIncomePageLoaded.view.tsx b/src/pages/account-income/ui/AccountIncomePage/components/AccountIncomePageLoaded.view.tsx index c3b1121..9d2da9f 100644 --- a/src/pages/account-income/ui/AccountIncomePage/components/AccountIncomePageLoaded.view.tsx +++ b/src/pages/account-income/ui/AccountIncomePage/components/AccountIncomePageLoaded.view.tsx @@ -11,6 +11,7 @@ import { StateView } from "@wealthledger/ui"; import type { FC } from "react"; import { FormProvider } from "react-hook-form"; import { useTranslation } from "react-i18next"; +import { formatSnapshotTime } from "@/shared/lib"; import classes from "../AccountIncomePage.module.css"; import type { AccountIncomePageViewModel } from "../AccountIncomePage.vm"; import { IncomeComparisons } from "./IncomeComparisons.view"; @@ -34,7 +35,17 @@ const AccountIncomePageLoaded: FC = (props) => { const { t } = useTranslation(); return ( + {props.lastSuccessfulAt ? ( + + {t("investment.sourceTime", { + date: formatSnapshotTime(props.lastSuccessfulAt), + })} + + ) : null} {t("income.title")} + = (props) => { ) : null} {refreshError ? ( - + - {t("income.refreshError")} + + {t( + props.sourceError + ? "investment.syncFailed" + : "income.refreshError", + )} + ) : null} - {analytics.totalsByCurrency.length ? ( + {props.partial ? ( + {t("investment.partial")} + ) : analytics.totalsByCurrency.length ? ( ) : ( = (props) => { /> )} {t("income.taxBoundary")} - + + ); diff --git a/src/pages/account-income/ui/AccountIncomePage/components/IncomeComparisons.view.tsx b/src/pages/account-income/ui/AccountIncomePage/components/IncomeComparisons.view.tsx index a44473e..8e32d4e 100644 --- a/src/pages/account-income/ui/AccountIncomePage/components/IncomeComparisons.view.tsx +++ b/src/pages/account-income/ui/AccountIncomePage/components/IncomeComparisons.view.tsx @@ -5,8 +5,14 @@ import { formatMoney } from "@/shared/lib"; import classes from "../AccountIncomePage.module.css"; import type { IncomeDisplayModel } from "../AccountIncomePage.vm"; -type IncomeComparisonsProps = { comparison: IncomeDisplayModel["comparison"] }; -type ComparisonProps = NonNullable; +type IncomeComparisonsProps = { + comparison: IncomeDisplayModel["comparison"]; + title: string; + notice: string; +}; +type ComparisonProps = NonNullable & { + title: string; +}; type ComparisonValuesProps = { kind: "selected" | "compared" | "difference"; label: string; @@ -15,16 +21,15 @@ type ComparisonValuesProps = { }; const IncomeComparisons: FC = (props) => { - const { t } = useTranslation(); return ( - {t("income.yearAgo")} + {props.title} {props.comparison ? ( - + ) : ( - - {t("income.yearUnavailable")} + + {props.notice} )} @@ -35,7 +40,7 @@ const IncomeComparisons: FC = (props) => { const Comparison: FC = (props) => { const { t } = useTranslation(); return ( -
+
{props.values.map((value) => ( - + { ({ request }) => { const url = new URL(request.url); searches.push(url.search); - return HttpResponse.json({ - accountId: "account-1", - items: [operation], - nextCursor: url.searchParams.get("cursor") ? null : "next-1", - hasNext: !url.searchParams.get("cursor"), - observedAt: "2026-08-14T00:00:00Z", - }); + return HttpResponse.json( + resource({ + accountId: "account-1", + items: [operation], + nextCursor: url.searchParams.get("cursor") ? null : "next-1", + hasNext: !url.searchParams.get("cursor"), + observedAt: "2026-08-14T00:00:00Z", + }), + ); }, ), ); @@ -229,13 +234,15 @@ describe("AccountOperationsPage", () => { ({ request }) => { const url = new URL(request.url); searches.push(url.search); - return HttpResponse.json({ - accountId: "account-1", - items: [operation], - nextCursor: null, - hasNext: false, - observedAt: "2026-08-14T00:00:00Z", - }); + return HttpResponse.json( + resource({ + accountId: "account-1", + items: [operation], + nextCursor: null, + hasNext: false, + observedAt: "2026-08-14T00:00:00Z", + }), + ); }, ), ); @@ -262,13 +269,15 @@ describe("AccountOperationsPage", () => { ({ request }) => { const url = new URL(request.url); searches.push(url.search); - return HttpResponse.json({ - accountId: "account-1", - items: [operation], - nextCursor: null, - hasNext: false, - observedAt: "2026-08-14T00:00:00Z", - }); + return HttpResponse.json( + resource({ + accountId: "account-1", + items: [operation], + nextCursor: null, + hasNext: false, + observedAt: "2026-08-14T00:00:00Z", + }), + ); }, ), ); @@ -301,3 +310,58 @@ describe("AccountOperationsPage", () => { ).toBeInTheDocument(); }); }); + +it("does not label incomplete empty operations as a confirmed empty period", async () => { + configureTestAuth(); + server.use( + http.get("*/api/v1/brokerage-accounts/:accountId/operations", () => + HttpResponse.json( + resource( + { + accountId: "account-1", + items: [], + hasNext: false, + nextCursor: null, + }, + { availability: "partial" }, + ), + ), + ), + ); + renderPage(); + expect(await screen.findByText("Период загружен не полностью")).toBeVisible(); + expect(screen.queryByText("Операций нет")).not.toBeInTheDocument(); +}); + +it("recovers an invalid cursor at the first page without clearing applied filters", async () => { + configureTestAuth(); + const searches: URLSearchParams[] = []; + server.use( + http.get( + "*/api/v1/brokerage-accounts/:accountId/operations", + ({ request }) => { + const params = new URL(request.url).searchParams; + searches.push(params); + if (params.has("cursor")) + return HttpResponse.json( + { code: "INVALID_OPERATIONS_CURSOR" }, + { status: 400 }, + ); + return HttpResponse.json( + resource({ + items: [operation], + hasNext: true, + nextCursor: "old-cursor", + }), + ); + }, + ), + ); + renderPage("?from=2026-08-01&to=2026-08-14&categories=trade"); + fireEvent.click(await screen.findByRole("button", { name: "Далее" })); + fireEvent.click(await screen.findByRole("button", { name: "Повторить" })); + await waitFor(() => expect(searches.length).toBe(3)); + expect(searches[2].get("cursor")).toBeNull(); + for (const key of ["from", "to", "categories"]) + expect(searches[2].get(key)).toBe(searches[0].get(key)); +}); diff --git a/src/pages/account-operations/ui/AccountOperationsPage/AccountOperationsPage.vm.ts b/src/pages/account-operations/ui/AccountOperationsPage/AccountOperationsPage.vm.ts index 41341d3..c168899 100644 --- a/src/pages/account-operations/ui/AccountOperationsPage/AccountOperationsPage.vm.ts +++ b/src/pages/account-operations/ui/AccountOperationsPage/AccountOperationsPage.vm.ts @@ -9,6 +9,7 @@ import { useBrokerageOperations, } from "@/entities/broker-operation"; +import { useScreenSync } from "@/features/tbank-connection"; import { apiErrorCode } from "@/shared/api"; type AccountOperationsPageViewModel = @@ -16,6 +17,10 @@ type AccountOperationsPageViewModel = | { state: "error"; retry: () => void } | { state: "loaded"; + sourceError?: boolean; + lastSuccessfulAt?: string | null; + partial?: boolean; + historyLoading?: boolean; refreshing?: boolean; refreshError?: boolean; retry?: () => void; @@ -82,6 +87,10 @@ const useAccountOperationsPageVm = ( ...firstPageQuery, ...(cursor ? { cursor } : {}), }); + const sync = useScreenSync( + { screen: "operations", accountId, ...firstPageQuery }, + [operations], + ); const resetQueryKey = brokerageOperationsQueryOptions(accountId, { limit: 100, }).queryKey; @@ -95,12 +104,12 @@ const useAccountOperationsPageVm = ( exact: true, }); resetCursors(); - } else void operations.refetch(); + } else void sync.refresh(); }; if (operations.isLoading) return { state: "loading" }; if ( - operations.isError && + (operations.isError || operations.meta?.sync.state === "failed") && (!operations.data || apiErrorCode(operations.error) === "INVALID_OPERATIONS_CURSOR") ) @@ -115,8 +124,14 @@ const useAccountOperationsPageVm = ( return { state: "loaded", - refreshing: operations.isFetching, - refreshError: operations.isError, + sourceError: sync.sourceError, + lastSuccessfulAt: operations.meta?.lastSuccessfulAt, + partial: operations.meta?.availability !== "complete", + historyLoading: + operations.meta?.coverage?.historyState === "loading" || + operations.meta?.coverage?.historyState === "partial", + refreshing: operations.isFetching || sync.refreshing, + refreshError: operations.isError || sync.sourceError || sync.transportError, retry, draftFrom, draftTo, diff --git a/src/pages/account-operations/ui/AccountOperationsPage/components/AccountOperationsPageLoaded.view.tsx b/src/pages/account-operations/ui/AccountOperationsPage/components/AccountOperationsPageLoaded.view.tsx index 019d6ba..077f6ac 100644 --- a/src/pages/account-operations/ui/AccountOperationsPage/components/AccountOperationsPageLoaded.view.tsx +++ b/src/pages/account-operations/ui/AccountOperationsPage/components/AccountOperationsPageLoaded.view.tsx @@ -5,6 +5,7 @@ import { MultiSelect, Progress, Stack, + Text, Title, } from "@mantine/core"; import { DatePickerInput } from "@mantine/dates"; @@ -16,6 +17,7 @@ import { type OperationCategory, operationCategories, } from "@/entities/broker-operation"; +import { formatSnapshotTime } from "@/shared/lib"; import type { AccountOperationsPageViewModel } from "../AccountOperationsPage.vm"; type AccountOperationsPageLoadedProps = Extract< @@ -59,13 +61,31 @@ const AccountOperationsPageLoaded: FC = ( return ( + {props.lastSuccessfulAt ? ( + + {t("investment.sourceTime", { + date: formatSnapshotTime(props.lastSuccessfulAt), + })} + + ) : null} {t("ledger.title")} + + {props.partial ? ( + {t("investment.partial")} + ) : null} + {props.historyLoading ? {t("investment.history")} : null} {props.refreshing ? ( ) : null} {props.refreshError ? ( - - {t("accounts.refreshError")} + + {t( + props.sourceError + ? "investment.syncFailed" + : "accounts.refreshError", + )} @@ -120,12 +140,12 @@ const AccountOperationsPageLoaded: FC = ( operation={operation} /> )) - ) : ( + ) : !props.partial ? ( - )} + ) : null} {hasNext || hasPrevious ? ( {refreshing ? ( @@ -24,12 +32,15 @@ const PortfolioRefreshState: FC = (props) => { ) : null} {refreshError ? ( - + - {t("portfolio.refreshError")} - + + {t( + props.sourceError + ? "investment.syncFailed" + : "portfolio.refreshError", + )} + ) : null} diff --git a/src/pages/account/ui/AccountPage/components/RecentOperations.view.tsx b/src/pages/account/ui/AccountPage/components/RecentOperations.view.tsx index 4b4c69a..be267dc 100644 --- a/src/pages/account/ui/AccountPage/components/RecentOperations.view.tsx +++ b/src/pages/account/ui/AccountPage/components/RecentOperations.view.tsx @@ -4,6 +4,7 @@ import type { FC } from "react"; import { useTranslation } from "react-i18next"; import { Link } from "react-router"; import { LedgerEntryRow } from "@/entities/broker-operation"; +import { formatSnapshotTime } from "@/shared/lib"; import type { AccountPageRecentOperations } from "../AccountPage.vm"; type RecentOperationsProps = { @@ -16,6 +17,23 @@ const RecentOperations: FC = (props) => { return ( + {operations.state === "loaded" ? ( + <> + {operations.lastSuccessfulAt ? ( + + {t("investment.sourceTime", { + date: formatSnapshotTime(operations.lastSuccessfulAt), + })} + + ) : null} + {operations.stale ? ( + {t("investment.stale")} + ) : null} + {operations.refreshError ? ( + {t("accounts.refreshError")} + ) : null} + + ) : null} {operations.state === "loading" ? ( {[0, 1, 2].map((index) => ( @@ -29,7 +47,10 @@ const RecentOperations: FC = (props) => { {operations.state === "empty" ? ( {t("ledger.recentEmpty")} ) : null} - {operations.state === "loaded" + {operations.state === "partial" ? ( + {t("investment.partial")} + ) : null} + {operations.state === "loaded" || operations.state === "partial" ? operations.items.map((operation) => ( { server.use( http.get("*/api/v1/brokerage-accounts", () => { requestCount += 1; - if (requestCount === 1) return HttpResponse.json([account]); + if (requestCount === 1) return HttpResponse.json(resource([account])); return new Promise((resolve) => { resolveRefresh = resolve; }); @@ -100,21 +101,31 @@ describe("AccountsPage", () => { screen.getByRole("status", { name: "Обновляем данные" }), ).toBeInTheDocument(); - resolveRefresh?.(HttpResponse.json([account])); + resolveRefresh?.(HttpResponse.json(resource([account]))); }); it("keeps the cached account list and offers retry after a refresh error", async () => { + const requests: unknown[] = []; + server.use( + http.post("*/api/v1/tbank-sync", async ({ request }) => { + requests.push(await request.json()); + return HttpResponse.json( + { code: "TBANK_UNAVAILABLE" }, + { status: 503 }, + ); + }), + ); let requestCount = 0; server.use( http.get("*/api/v1/brokerage-accounts", () => { requestCount += 1; - if (requestCount === 1) return HttpResponse.json([account]); + if (requestCount === 1) return HttpResponse.json(resource([account])); if (requestCount === 2) return HttpResponse.json( { code: "TBANK_UNAVAILABLE" }, { status: 503 }, ); - return HttpResponse.json([account]); + return HttpResponse.json(resource([account])); }), ); const queryClient = createTestQueryClient(); @@ -132,7 +143,8 @@ describe("AccountsPage", () => { expect(screen.getByText(account.name)).toBeInTheDocument(); screen.getByRole("button", { name: "Повторить" }).click(); - await waitFor(() => expect(requestCount).toBe(3)); + await waitFor(() => expect(requests).toEqual([{ screen: "accounts" }])); + expect(requestCount).toBe(2); }); it("keeps a cached empty account list visible after a refresh error", async () => { @@ -140,7 +152,7 @@ describe("AccountsPage", () => { server.use( http.get("*/api/v1/brokerage-accounts", () => { requestCount += 1; - if (requestCount === 1) return HttpResponse.json([]); + if (requestCount === 1) return HttpResponse.json(resource([])); return HttpResponse.json( { code: "TBANK_UNAVAILABLE" }, { status: 503 }, @@ -163,3 +175,22 @@ describe("AccountsPage", () => { expect(screen.getByText("Нет инвестиционных счетов")).toBeInTheDocument(); }); }); + +it("keeps unknown membership loading instead of declaring no accounts", async () => { + server.use( + http.get("*/api/v1/brokerage-accounts", () => + HttpResponse.json(resource(null)), + ), + ); + const client = createTestQueryClient(); + renderPage(client); + await waitFor(() => + expect(client.getQueryData(brokerageAccountsQueryKey)).toMatchObject({ + data: null, + }), + ); + expect( + screen.queryByText("Нет инвестиционных счетов"), + ).not.toBeInTheDocument(); + expect(screen.getByRole("status")).toBeVisible(); +}); diff --git a/src/pages/accounts/ui/AccountsPage/AccountsPage.tsx b/src/pages/accounts/ui/AccountsPage/AccountsPage.tsx index 29e2294..224e80e 100644 --- a/src/pages/accounts/ui/AccountsPage/AccountsPage.tsx +++ b/src/pages/accounts/ui/AccountsPage/AccountsPage.tsx @@ -12,7 +12,9 @@ const AccountsPage: FC = () => { if (viewModel.state === "empty") return ( @@ -20,7 +22,9 @@ const AccountsPage: FC = () => { return ( diff --git a/src/pages/accounts/ui/AccountsPage/AccountsPage.vm.ts b/src/pages/accounts/ui/AccountsPage/AccountsPage.vm.ts index 88ebe77..a6629a7 100644 --- a/src/pages/accounts/ui/AccountsPage/AccountsPage.vm.ts +++ b/src/pages/accounts/ui/AccountsPage/AccountsPage.vm.ts @@ -2,32 +2,42 @@ import { brokerageAccountCards, useBrokerageAccounts, } from "@/entities/brokerage-account"; +import { useScreenSync } from "@/features/tbank-connection"; import { apiErrorCode } from "@/shared/api"; function useAccountsPageViewModel() { const accounts = useBrokerageAccounts(); - if (!accounts.data) { - if (accounts.isFetching) return { state: "loading" as const }; - if (accounts.isError) + const sync = useScreenSync({ screen: "accounts" }, [accounts]); + if ( + !accounts.data || + (accounts.meta?.availability === "partial" && !accounts.data.length) + ) { + if (!accounts.isError && accounts.meta?.sync.state !== "failed") + return { state: "loading" as const }; + if (accounts.isError || accounts.meta?.sync.state === "failed") return { state: "error" as const, code: apiErrorCode(accounts.error), - retry: () => accounts.refetch(), + retry: sync.refresh, }; } if (!accounts.data?.length) return { state: "empty" as const, - refreshing: accounts.isFetching, - refreshError: accounts.isError, - retry: () => accounts.refetch(), + lastSuccessfulAt: accounts.meta?.lastSuccessfulAt, + refreshing: accounts.isFetching || sync.refreshing, + sourceError: sync.sourceError, + refreshError: accounts.isError || sync.transportError || sync.sourceError, + retry: sync.refresh, }; return { state: "loaded" as const, - accounts: brokerageAccountCards(accounts.data), - refreshing: accounts.isFetching, - refreshError: accounts.isError, - retry: () => accounts.refetch(), + accounts: brokerageAccountCards(accounts.data, accounts.meta?.dependencies), + lastSuccessfulAt: accounts.meta?.lastSuccessfulAt, + refreshing: accounts.isFetching || sync.refreshing, + sourceError: sync.sourceError, + refreshError: accounts.isError || sync.transportError || sync.sourceError, + retry: sync.refresh, }; } diff --git a/src/pages/accounts/ui/AccountsPage/components/AccountsPageLoading.view.tsx b/src/pages/accounts/ui/AccountsPage/components/AccountsPageLoading.view.tsx index da72c3b..029b466 100644 --- a/src/pages/accounts/ui/AccountsPage/components/AccountsPageLoading.view.tsx +++ b/src/pages/accounts/ui/AccountsPage/components/AccountsPageLoading.view.tsx @@ -9,7 +9,7 @@ const AccountsPageLoading: FC = () => { - @@ -80,8 +77,8 @@ const BondPageView: FC = (props) => { ) : null} - {vm.bondState.error || vm.quoteState.error ? ( - + {vm.refreshError || vm.bondState.error || vm.quoteState.error ? ( + {t(vm.quote ? "accounts.refreshError" : "stock.sectionError")} ) : null} @@ -322,7 +319,9 @@ const BondPageView: FC = (props) => { {vm.coupons ? ( {vm.coupons.estimateUnavailable || vm.coupons.positionIsStale ? ( - {t("stock.estimateUnavailable")} + + {t("stock.estimateUnavailable")} + ) : null} {vm.upcoming.length ? ( vm.upcoming.map((coupon) => ( diff --git a/src/pages/bond/ui/BondPage/BondPage.vm.ts b/src/pages/bond/ui/BondPage/BondPage.vm.ts index 9819885..2ba6f75 100644 --- a/src/pages/bond/ui/BondPage/BondPage.vm.ts +++ b/src/pages/bond/ui/BondPage/BondPage.vm.ts @@ -16,6 +16,7 @@ import { useBrokerageOperations, } from "@/entities/broker-operation"; import { section } from "@/features/instrument-details"; +import { useScreenSync } from "@/features/tbank-connection"; import { apiErrorCode } from "@/shared/api"; import { formatDate, instrumentLogoUrl } from "@/shared/lib"; @@ -58,6 +59,17 @@ function useBondPageVm() { ...firstPage, cursor: cursors.at(-1), }); + const sync = useScreenSync( + { + screen: "bond", + instrumentUid: uid, + candlePeriod: period, + accountId: "all", + categories: categories[filter], + limit: 20, + }, + [bond, quote, candles, position, metrics, coupons, operations], + ); const setParam = (key: string, value: string) => { const next = new URLSearchParams(params); next.set(key, value); @@ -70,7 +82,7 @@ function useBondPageVm() { queryKey: brokerageOperationsQueryOptions("all", firstPage).queryKey, exact: true, }); - } else void operations.refetch(); + } else void sync.refresh(); }; const chartData = candles.data?.candles.map((candle) => ({ @@ -79,31 +91,34 @@ function useBondPageVm() { price: Number(candle.close), })) ?? []; return { + refresh: sync.refresh, + refreshing: sync.refreshing, + refreshError: sync.transportError || sync.sourceError, canonicalTo: bond.data?.slug && bond.data.slug !== bondId ? `/bonds/${encodeURIComponent(bond.data.slug)}${params.size ? `?${params.toString()}` : ""}` : null, bond: bond.data, - bondState: section(bond), + bondState: section(bond, uid ? sync.refresh : () => bond.refetch()), code: apiErrorCode(bond.error), logoSrc: instrumentLogoUrl(bond.data?.logoName), metrics: metrics.data, - metricsState: section(metrics), + metricsState: section(metrics, sync.refresh), quote: quote.data, - quoteState: section(quote), + quoteState: section(quote, sync.refresh), candles: candles.data, chartData, priceRows: chartData.toReversed(), - candlesState: section(candles), + candlesState: section(candles, sync.refresh), period, setPeriod: (value: string) => setParam("period", value), averageLine: null, hasIncompleteCandle: candles.data?.candles.some((candle) => !candle.isComplete) ?? false, position: position.data, - positionState: section(position), + positionState: section(position, sync.refresh), coupons: coupons.data, - couponsState: section(coupons), + couponsState: section(coupons, sync.refresh), upcoming: coupons.data?.coupons .filter((event) => event.timing === "upcoming") @@ -121,7 +136,10 @@ function useBondPageVm() { ), ) ?? [], operations: operations.data, - operationsState: { ...section(operations), retry: retryOperations }, + operationsState: { + ...section(operations, sync.refresh), + retry: retryOperations, + }, eventRows: operations.data?.items.map((item) => ({ ...item, diff --git a/src/pages/bonds/ui/BondsPage/BondsPage.test.tsx b/src/pages/bonds/ui/BondsPage/BondsPage.test.tsx index 3242caf..6cfe1b9 100644 --- a/src/pages/bonds/ui/BondsPage/BondsPage.test.tsx +++ b/src/pages/bonds/ui/BondsPage/BondsPage.test.tsx @@ -6,6 +6,7 @@ import { MemoryRouter } from "react-router"; import { describe, expect, it, vi } from "vitest"; import { configureAuthClient } from "@/shared/api"; import { createTestQueryClient } from "@/test/query"; +import { resource } from "@/test/resource"; import { server } from "@/test/server"; import { BondsPage } from "./BondsPage"; @@ -15,24 +16,26 @@ describe("bond search", () => { server.use( http.get("*/api/v1/bonds", ({ request }) => { query(new URL(request.url).searchParams.get("query")); - return HttpResponse.json([ - { - name: "ОФЗ", - ticker: "SU26238RMFS4", - instrumentUid: "unique-bond", - slug: "SU26238RMFS4", - logoName: "sber.png", - classCode: "TQOB", - isin: "RU0009029540", - realExchange: "REAL_EXCHANGE_MOEX", - shareType: "SHARE_TYPE_COMMON", - apiTradeAvailable: true, - pricePercent: "98.125", - maturityDate: "2030-05-15", - perpetual: false, - priceAt: "2026-09-10T10:00:00Z", - }, - ]); + return HttpResponse.json( + resource([ + { + name: "ОФЗ", + ticker: "SU26238RMFS4", + instrumentUid: "unique-bond", + slug: "SU26238RMFS4", + logoName: "sber.png", + classCode: "TQOB", + isin: "RU0009029540", + realExchange: "REAL_EXCHANGE_MOEX", + shareType: "SHARE_TYPE_COMMON", + apiTradeAvailable: true, + pricePercent: "98.125", + maturityDate: "2030-05-15", + perpetual: false, + priceAt: "2026-09-10T10:00:00Z", + }, + ]), + ); }), ); configureAuthClient({ diff --git a/src/pages/bonds/ui/BondsPage/BondsPage.view.tsx b/src/pages/bonds/ui/BondsPage/BondsPage.view.tsx index e8e9ab3..43caa06 100644 --- a/src/pages/bonds/ui/BondsPage/BondsPage.view.tsx +++ b/src/pages/bonds/ui/BondsPage/BondsPage.view.tsx @@ -37,6 +37,16 @@ const BondsPageView: FC = (props) => { {t("bond.searchSubtitle")} + + {vm.partial ? ( + {t("investment.partial")} + ) : null} = (props) => { {t("stock.searchHint")} ) : null} {vm.error ? ( - + {t( @@ -166,7 +176,9 @@ const BondsPageView: FC = (props) => { ))} ) : ( - {t("bond.noSearchResults")} + + {t(vm.partial ? "investment.partial" : "bond.noSearchResults")} + ) ) : null} diff --git a/src/pages/bonds/ui/BondsPage/BondsPage.vm.ts b/src/pages/bonds/ui/BondsPage/BondsPage.vm.ts index 55f5b2f..2d21c59 100644 --- a/src/pages/bonds/ui/BondsPage/BondsPage.vm.ts +++ b/src/pages/bonds/ui/BondsPage/BondsPage.vm.ts @@ -1,6 +1,7 @@ import { useDebouncedValue } from "@mantine/hooks"; import { useSearchParams } from "react-router"; import { useBondSearch } from "@/entities/bond"; +import { useScreenSync } from "@/features/tbank-connection"; import { apiErrorCode } from "@/shared/api"; import { instrumentLogoUrl } from "@/shared/lib"; @@ -11,15 +12,23 @@ function useBondsPageVm() { const query = params.get("q") ?? ""; const [debounced] = useDebouncedValue(query.trim(), SEARCH_DEBOUNCE_MS); const result = useBondSearch(debounced); + const sync = useScreenSync({ screen: "bonds", query: debounced }, [result]); const waiting = query.trim() !== debounced; return { query, setQuery: (q: string) => setParams(q ? { q } : {}, { replace: true }), - loading: query.trim().length >= 2 && (waiting || result.isLoading), - error: result.isError, + loading: + query.trim().length >= 2 && + (waiting || + (!result.data && + !result.isError && + result.meta?.sync.state !== "failed")), + error: result.isError || sync.sourceError || sync.transportError, + refreshing: sync.refreshing, + partial: result.meta?.availability === "partial", code: apiErrorCode(result.error), retry: () => { - void result.refetch(); + void sync.refresh(); }, ready: debounced.length >= 2 && !waiting && Boolean(result.data), items: diff --git a/src/pages/stock/ui/StockPage/StockPage.test.tsx b/src/pages/stock/ui/StockPage/StockPage.test.tsx index 10248f2..4a493e3 100644 --- a/src/pages/stock/ui/StockPage/StockPage.test.tsx +++ b/src/pages/stock/ui/StockPage/StockPage.test.tsx @@ -6,6 +6,7 @@ import { MemoryRouter, Route, Routes, useLocation } from "react-router"; import { describe, expect, it, vi } from "vitest"; import { configureAuthClient } from "@/shared/api"; import { createTestQueryClient } from "@/test/query"; +import { resource } from "@/test/resource"; import { server } from "@/test/server"; import { StockPage } from "./StockPage"; @@ -80,100 +81,112 @@ function setup( http.get("*/api/v1/stocks/:uid", () => options.failStock ? HttpResponse.json({ code: "NOT_FOUND" }, { status: 404 }) - : HttpResponse.json(resolved), + : HttpResponse.json(resource(resolved)), ), http.get("*/api/v1/stocks/by-ticker/:ticker", ({ request }) => { requests.push(new URL(request.url)); return options.ambiguousTicker ? HttpResponse.json({ code: "STOCK_TICKER_AMBIGUOUS" }, { status: 409 }) - : HttpResponse.json(resolved); + : HttpResponse.json(resource(resolved)); }), http.get("*/api/v1/stocks/:uid/:section", ({ params, request }) => { requests.push(new URL(request.url)); if (params.section === "quote") return failQuote ? HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 }) - : HttpResponse.json(quote); + : HttpResponse.json(resource(quote)); if (params.section === "position") return HttpResponse.json( - options.noPosition - ? { ...position, quantity: "0", accounts: [] } - : position, + resource( + options.noPosition + ? { ...position, quantity: "0", accounts: [] } + : position, + ), ); if (params.section === "candles") - return HttpResponse.json({ - candles: [], - currency: "RUB", - historyFrom: null, - observedAt: quote.observedAt, - }); + return HttpResponse.json( + resource({ + candles: [], + currency: "RUB", + historyFrom: null, + observedAt: quote.observedAt, + }), + ); if (params.section === "company") return options.failCompany ? HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 }) - : HttpResponse.json({ - description: "Описание компании", - metrics: [ - { - key: "peRatioTtm", - value: 5.6, - unit: "ratio", - currency: null, - period: "TTM", - }, - ], - unavailable: [], - observedAt: quote.observedAt, - }); - return HttpResponse.json({ - dividends: [ - { - perShare: money("32.88"), - recordDate: "2026-10-13", - lastBuyDate: "2026-10-12", - paymentDate: "2026-10-27", - timing: "upcoming", - estimatedAmount: options.noPosition ? null : money("328.8"), - }, - { - perShare: money("20"), - recordDate: "2025-10-13", - paymentDate: null, - lastBuyDate: null, - timing: "past", - estimatedAmount: null, - }, - ], - from: "2021-09-10", - to: "2027-09-10", - observedAt: quote.observedAt, - positionAt: null, - estimateUnavailable: false, - positionIsStale: false, - }); + : HttpResponse.json( + resource({ + description: "Описание компании", + metrics: [ + { + key: "peRatioTtm", + value: 5.6, + unit: "ratio", + currency: null, + period: "TTM", + }, + ], + unavailable: [], + observedAt: quote.observedAt, + }), + ); + return HttpResponse.json( + resource({ + dividends: [ + { + perShare: money("32.88"), + recordDate: "2026-10-13", + lastBuyDate: "2026-10-12", + paymentDate: "2026-10-27", + timing: "upcoming", + estimatedAmount: options.noPosition ? null : money("328.8"), + }, + { + perShare: money("20"), + recordDate: "2025-10-13", + paymentDate: null, + lastBuyDate: null, + timing: "past", + estimatedAmount: null, + }, + ], + from: "2021-09-10", + to: "2027-09-10", + observedAt: quote.observedAt, + positionAt: null, + estimateUnavailable: false, + positionIsStale: false, + }), + ); }), http.get("*/api/v1/brokerage-accounts/all/operations", ({ request }) => { const url = new URL(request.url); requests.push(url); const next = url.searchParams.has("cursor"); - return HttpResponse.json({ - accountId: "all", - observedAt: quote.observedAt, - hasNext: !next, - nextCursor: next ? null : "next", - items: [ - { - id: next ? "sold" : "dividend", - occurredAt: next ? "2022-01-02T10:00:00Z" : "2025-01-02T10:00:00Z", - type: next ? "OPERATION_TYPE_SELL" : "OPERATION_TYPE_DIVIDEND", - category: next ? "trade" : "income", - description: "", - account: { id: "main", name: "Основной" }, - payment: money("200"), - quantity: next ? "2" : null, - price: next ? money("100") : null, - }, - ], - }); + return HttpResponse.json( + resource({ + accountId: "all", + observedAt: quote.observedAt, + hasNext: !next, + nextCursor: next ? null : "next", + items: [ + { + id: next ? "sold" : "dividend", + occurredAt: next + ? "2022-01-02T10:00:00Z" + : "2025-01-02T10:00:00Z", + type: next ? "OPERATION_TYPE_SELL" : "OPERATION_TYPE_DIVIDEND", + category: next ? "trade" : "income", + description: "", + account: { id: "main", name: "Основной" }, + payment: money("200"), + quantity: next ? "2" : null, + price: next ? money("100") : null, + }, + ], + }), + ); }), ); configureAuthClient({ @@ -271,9 +284,11 @@ describe("stock page", () => { screen.queryByRole("heading", { name: "Моя позиция" }), ).not.toBeInTheDocument(); expect(screen.getByRole("heading", { name: "График цены" })).toBeVisible(); - expect(await screen.findByRole("alert")).toHaveTextContent( - "Не удалось загрузить этот раздел", - ); + expect( + (await screen.findByText(/Не удалось загрузить этот раздел/)).closest( + '[role="status"]', + ), + ).toHaveAttribute("aria-live", "polite"); }); it("sends instrument-filtered continuation, resets it on filter changes and changes chart period", async () => { const { requests } = setup(); @@ -308,14 +323,38 @@ describe("stock page", () => { ); }); it("keeps the last quote on a refresh failure", async () => { - const controls = setup(); + const scopes: unknown[] = []; + server.use( + http.post("*/api/v1/tbank-sync", async ({ request }) => { + scopes.push(await request.json()); + return HttpResponse.json( + { code: "TBANK_UNAVAILABLE" }, + { status: 503 }, + ); + }), + ); + const controls = setup({ + path: `/stocks/${uid}?period=year&events=trades`, + }); await screen.findByText("300 ₽"); controls.failQuote(); - fireEvent.click(screen.getByRole("button", { name: "Обновить цену" })); - expect(await screen.findByRole("alert")).toHaveTextContent( - "последние полученные", - ); + fireEvent.click(screen.getByRole("button", { name: "Обновить" })); + expect( + (await screen.findByText(/последние полученные/)).closest( + '[role="status"]', + ), + ).toHaveAttribute("aria-live", "polite"); expect(screen.getByText("300 ₽")).toBeVisible(); + expect(scopes).toEqual([ + { + screen: "stock", + instrumentUid: uid, + accountId: "all", + candlePeriod: "year", + categories: ["trade"], + limit: 20, + }, + ]); }); it("offers recovery for an unavailable direct link", async () => { setup({ failStock: true }); diff --git a/src/pages/stock/ui/StockPage/StockPage.view.tsx b/src/pages/stock/ui/StockPage/StockPage.view.tsx index 8ee10fc..83ea318 100644 --- a/src/pages/stock/ui/StockPage/StockPage.view.tsx +++ b/src/pages/stock/ui/StockPage/StockPage.view.tsx @@ -63,12 +63,10 @@ const StockPageView: FC = (props) => { {vm.company?.description ? ( @@ -76,8 +74,8 @@ const StockPageView: FC = (props) => { {vm.company.description} ) : null} - {vm.stockState.error || vm.quoteState.error ? ( - + {vm.refreshError || vm.stockState.error || vm.quoteState.error ? ( + {t(vm.quote ? "accounts.refreshError" : "stock.sectionError")} ) : null} @@ -164,7 +162,7 @@ const StockPageView: FC = (props) => { {vm.company ? ( {vm.company.unavailable.length ? ( - + {t("stock.partialCompany")} + {vm.partial ? ( + {t("investment.partial")} + ) : null} = (props) => { {t("stock.searchHint")} ) : null} {vm.error ? ( - + {t( @@ -142,7 +152,9 @@ const StocksPageView: FC = (props) => { ))} ) : ( - {t("stock.noSearchResults")} + + {t(vm.partial ? "investment.partial" : "stock.noSearchResults")} + ) ) : null} diff --git a/src/pages/stocks/ui/StocksPage/StocksPage.vm.ts b/src/pages/stocks/ui/StocksPage/StocksPage.vm.ts index 0982818..c2d7d97 100644 --- a/src/pages/stocks/ui/StocksPage/StocksPage.vm.ts +++ b/src/pages/stocks/ui/StocksPage/StocksPage.vm.ts @@ -1,6 +1,7 @@ import { useDebouncedValue } from "@mantine/hooks"; import { useSearchParams } from "react-router"; import { useStockSearch } from "@/entities/stock"; +import { useScreenSync } from "@/features/tbank-connection"; import { apiErrorCode } from "@/shared/api"; import { instrumentLogoUrl } from "@/shared/lib"; @@ -11,15 +12,23 @@ function useStocksPageVm() { const query = params.get("q") ?? ""; const [debounced] = useDebouncedValue(query.trim(), SEARCH_DEBOUNCE_MS); const result = useStockSearch(debounced); + const sync = useScreenSync({ screen: "stocks", query: debounced }, [result]); const waiting = query.trim() !== debounced; return { query, setQuery: (q: string) => setParams(q ? { q } : {}, { replace: true }), - loading: query.trim().length >= 2 && (waiting || result.isLoading), - error: result.isError, + loading: + query.trim().length >= 2 && + (waiting || + (!result.data && + !result.isError && + result.meta?.sync.state !== "failed")), + error: result.isError || sync.sourceError || sync.transportError, + refreshing: sync.refreshing, + partial: result.meta?.availability === "partial", code: apiErrorCode(result.error), retry: () => { - void result.refetch(); + void sync.refresh(); }, ready: debounced.length >= 2 && !waiting && Boolean(result.data), items: diff --git a/src/shared/api/authenticated-query.test.tsx b/src/shared/api/authenticated-query.test.tsx index 0fa58af..8f82429 100644 --- a/src/shared/api/authenticated-query.test.tsx +++ b/src/shared/api/authenticated-query.test.tsx @@ -2,6 +2,7 @@ import { renderHook, waitFor } from "@testing-library/react"; import { HttpResponse, http } from "msw"; import { describe, expect, it, vi } from "vitest"; import { createQueryWrapper } from "@/test/query"; +import { resource } from "@/test/resource"; import { server } from "@/test/server"; import { useAuthenticatedQueries, @@ -17,14 +18,16 @@ describe("useAuthenticatedQuery", () => { server.use( http.get("*/api/v1/brokerage-accounts", () => { requests(); - return HttpResponse.json([ - { - id: "account-1", - name: "Основной", - type: "BROKERAGE", - openedOn: "2024-08-13", - }, - ]); + return HttpResponse.json( + resource([ + { + id: "account-1", + name: "Основной", + type: "BROKERAGE", + openedOn: "2024-08-13", + }, + ]), + ); }), ); @@ -53,7 +56,7 @@ describe("useAuthenticatedQuery", () => { await waitFor(() => expect(result.current.isSuccess).toBe(true)); expect(requests).toHaveBeenCalledOnce(); - expect(result.current.data).toEqual([ + expect(result.current.data?.data).toEqual([ { id: "account-1", name: "Основной", diff --git a/src/shared/api/client.test.ts b/src/shared/api/client.test.ts index e679c1a..fd0802e 100644 --- a/src/shared/api/client.test.ts +++ b/src/shared/api/client.test.ts @@ -1,5 +1,6 @@ import { HttpResponse, http } from "msw"; import { describe, expect, it, vi } from "vitest"; +import { resource } from "@/test/resource"; import { server } from "@/test/server"; import { apiClient, configureAuthClient } from "./client"; @@ -16,14 +17,14 @@ describe("apiClient authentication", () => { authorization.push(request.headers.get("Authorization") ?? ""); return authorization.length === 1 ? HttpResponse.json({ code: "UNAUTHORIZED" }, { status: 401 }) - : HttpResponse.json([]); + : HttpResponse.json(resource([])); }), ); configureAuthClient({ accessToken: () => token, refresh, clear: vi.fn() }); const { data } = await apiClient.GET("/api/v1/brokerage-accounts"); - expect(data).toEqual([]); + expect(data?.data).toEqual([]); expect(refresh).toHaveBeenCalledOnce(); expect(authorization).toEqual(["Bearer expired", "Bearer fresh"]); }); @@ -76,7 +77,7 @@ describe("apiClient authentication", () => { if (++calls === 1) return HttpResponse.json({ code: "UNAUTHORIZED" }, { status: 401 }); await pending; - return HttpResponse.json([{ id: "previous-user-account" }]); + return HttpResponse.json(resource([{ id: "previous-user-account" }])); }), ); configureAuthClient({ diff --git a/src/shared/api/client.ts b/src/shared/api/client.ts index d73ea56..d1bc1e4 100644 --- a/src/shared/api/client.ts +++ b/src/shared/api/client.ts @@ -14,6 +14,13 @@ type AuthHandlers = { clear: () => void; }; +const inFlight = new Set<{ pathname: string; invalidated: boolean }>(); +const latestReads = new Map(); +function invalidateApiRequests(matches: (pathname: string) => boolean) { + for (const request of inFlight) + if (matches(request.pathname)) request.invalidated = true; +} + let auth: AuthHandlers | null = null; let hasAuthenticatedSession = false; const authSessionListeners = new Set<() => void>(); @@ -25,6 +32,7 @@ function setAuthenticatedSession(next: boolean) { } function configureAuthClient(handlers: AuthHandlers | null) { + if (!handlers) invalidateApiRequests(() => true); auth = handlers; setAuthenticatedSession(Boolean(auth?.accessToken())); } @@ -39,12 +47,47 @@ function subscribeAuthenticatedSession(listener: () => void) { } async function authenticatedFetch(request: Request): Promise { + const url = new URL(request.url); + url.searchParams.delete("sync"); + url.searchParams.sort(); + const key = `${request.method} ${url}`; + const ticket = { pathname: url.pathname, invalidated: false }; + const read = + request.method === "GET" + ? (latestReads.get(key) ?? { sequence: 0, pending: 0 }) + : null; + if (read) { + read.sequence += 1; + read.pending += 1; + latestReads.set(key, read); + } + const sequence = read?.sequence; + inFlight.add(ticket); + try { + return await fetchCurrent( + request, + () => !ticket.invalidated && (!read || sequence === read.sequence), + ); + } finally { + inFlight.delete(ticket); + if (read && --read.pending === 0) latestReads.delete(key); + } +} + +async function fetchCurrent( + request: Request, + isLatest: () => boolean, +): Promise { const isAuthRequest = new URL(request.url).pathname.startsWith( "/api/v1/auth/", ); const session = auth; const generation = session?.generation?.(); - const isCurrent = () => generation === auth?.generation?.(); + const isCurrent = () => + (session?.generation + ? generation === session.generation() && + generation === auth?.generation?.() + : session === auth) && isLatest(); const token = session?.accessToken(); const withToken = (source: Request, value: string) => { const headers = new Headers(source.headers); @@ -55,6 +98,7 @@ async function authenticatedFetch(request: Request): Promise { token && !isAuthRequest ? withToken(request, token) : request; const retryRequest = authorized.clone(); const response = await fetch(authorized); + await response.clone().arrayBuffer(); if (!isAuthRequest && !isCurrent()) throw new DOMException("Session changed", "AbortError"); if ( @@ -78,6 +122,7 @@ async function authenticatedFetch(request: Request): Promise { const refreshedToken = auth.accessToken(); if (!refreshedToken) return response; const retried = await fetch(withToken(retryRequest, refreshedToken)); + await retried.clone().arrayBuffer(); if (!isCurrent()) throw new DOMException("Session changed", "AbortError"); return retried; } @@ -95,5 +140,6 @@ export { apiQueryClient, configureAuthClient, getAuthenticatedSessionSnapshot, + invalidateApiRequests, subscribeAuthenticatedSession, }; diff --git a/src/shared/api/generated/schema.d.ts b/src/shared/api/generated/schema.d.ts index 3a17015..8ecf943 100644 --- a/src/shared/api/generated/schema.d.ts +++ b/src/shared/api/generated/schema.d.ts @@ -676,14 +676,42 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/tbank-sync": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["TbankSyncController_request"]; + delete?: never; + options?: never; + head?: never; + patch?: 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; export interface components { schemas: { - ValidationProblemDto: { - pointer: string; - detail: string; - }; ProblemDetailsDto: { /** @example 401 */ status: number; @@ -697,7 +725,7 @@ export interface components { * } * ] */ - errors?: components["schemas"]["ValidationProblemDto"][]; + errors?: string[]; }; CredentialsDto: { /** @example user@example.com */ @@ -866,6 +894,63 @@ export interface components { openedOn: string | null; portfolioSummary: components["schemas"]["PortfolioSummaryDto"] | null; }; + InvestmentRangeDto: { + accountId?: string; + /** Format: date-time */ + from: string; + /** Format: date-time */ + to: string; + }; + InvestmentCoverageDto: { + requested: components["schemas"]["InvestmentRangeDto"][]; + completeRanges: components["schemas"]["InvestmentRangeDto"][]; + missingRanges: components["schemas"]["InvestmentRangeDto"][]; + /** Format: date-time */ + asOf: string | null; + /** @enum {string} */ + historyBoundary: "known" | "unknown"; + /** @enum {string} */ + historyState: "missing" | "loading" | "partial" | "complete" | "failed"; + }; + InvestmentSyncDto: { + id: string | null; + /** @enum {string} */ + state: "idle" | "queued" | "running" | "failed"; + errorCode: string | null; + }; + InvestmentDependencyDto: { + dataRevision: string; + stateVersion: string; + unavailableReason?: string; + coverage?: components["schemas"]["InvestmentCoverageDto"]; + resourceKey: string; + /** @enum {string} */ + availability: "missing" | "partial" | "complete"; + /** @enum {string} */ + freshness: "unknown" | "fresh" | "stale"; + /** Format: date-time */ + lastSuccessfulAt: string | null; + sync: components["schemas"]["InvestmentSyncDto"]; + }; + InvestmentResourceMetaDto: { + dataRevision: string; + stateVersion: string; + unavailableReason?: string; + coverage?: components["schemas"]["InvestmentCoverageDto"]; + resourceKey: string; + /** @enum {string} */ + availability: "missing" | "partial" | "complete"; + /** @enum {string} */ + freshness: "unknown" | "fresh" | "stale"; + /** Format: date-time */ + lastSuccessfulAt: string | null; + sync: components["schemas"]["InvestmentSyncDto"]; + dependencies?: components["schemas"]["InvestmentDependencyDto"][]; + }; + BrokerageAccountsResourceDto: { + data: components["schemas"]["BrokerageAccountDto"][] | null; + meta: components["schemas"]["InvestmentResourceMetaDto"]; + }; AccountReferenceDto: { id: string; name: string; @@ -919,6 +1004,10 @@ export interface components { cashBalances: components["schemas"]["MoneyDto"][]; structure: components["schemas"]["PortfolioStructureDto"]; }; + PortfolioResourceDto: { + data: components["schemas"]["PortfolioDto"] | null; + meta: components["schemas"]["InvestmentResourceMetaDto"]; + }; BrokerageOperationDto: { account?: components["schemas"]["AccountReferenceDto"]; id: string; @@ -947,6 +1036,10 @@ export interface components { /** Format: date-time */ observedAt: string; }; + BrokerageOperationsResourceDto: { + data: components["schemas"]["BrokerageOperationsPageDto"] | null; + meta: components["schemas"]["InvestmentResourceMetaDto"]; + }; IncomePeriodDto: { /** Format: date */ from: string; @@ -966,6 +1059,10 @@ export interface components { /** Format: date-time */ calculatedAt: string; }; + IncomeSummaryResourceDto: { + data: components["schemas"]["IncomeSummaryDto"] | null; + meta: components["schemas"]["InvestmentResourceMetaDto"]; + }; IncomeComparisonCurrencyDto: { currency: string; selected: components["schemas"]["IncomeCurrencySummaryDto"]; @@ -1019,7 +1116,7 @@ export interface components { totalsByCurrency: components["schemas"]["IncomeCurrencySummaryDto"][]; /** Format: date-time */ calculatedAt: string; - previous: components["schemas"]["IncomeComparisonDto"]; + previous: components["schemas"]["IncomeComparisonDto"] | null; yearComparison: components["schemas"]["IncomeComparisonDto"] | null; /** @enum {string} */ bucketGranularity: "day" | "week" | "month"; @@ -1027,6 +1124,10 @@ export interface components { instruments: components["schemas"]["IncomeInstrumentDto"][]; entries: components["schemas"]["IncomeAnalyticsEntryDto"][]; }; + IncomeAnalyticsResourceDto: { + data: components["schemas"]["IncomeAnalyticsDto"] | null; + meta: components["schemas"]["InvestmentResourceMetaDto"]; + }; UpcomingEventDto: { account?: components["schemas"]["AccountReferenceDto"]; /** @enum {string} */ @@ -1050,6 +1151,10 @@ export interface components { events: components["schemas"]["UpcomingEventDto"][]; incomeTotalsByCurrency: components["schemas"]["MoneyDto"][]; }; + UpcomingEventsResourceDto: { + data: components["schemas"]["UpcomingEventsDto"] | null; + meta: components["schemas"]["InvestmentResourceMetaDto"]; + }; StockSearchResultDto: { /** Format: uuid */ instrumentUid: string; @@ -1072,6 +1177,10 @@ export interface components { /** Format: date-time */ priceAt: string | null; }; + StockSearchResourceDto: { + data: components["schemas"]["StockSearchResultDto"][] | null; + meta: components["schemas"]["InvestmentResourceMetaDto"]; + }; StockDto: { /** Format: uuid */ instrumentUid: string; @@ -1091,6 +1200,10 @@ export interface components { /** Format: date-time */ historyFrom: string | null; }; + StockResourceDto: { + data: components["schemas"]["StockDto"] | null; + meta: components["schemas"]["InvestmentResourceMetaDto"]; + }; StockQuoteDto: { price: components["schemas"]["MoneyDto"] | null; /** Format: date-time */ @@ -1105,6 +1218,10 @@ export interface components { /** Format: date-time */ observedAt: string; }; + StockQuoteResourceDto: { + data: components["schemas"]["StockQuoteDto"] | null; + meta: components["schemas"]["InvestmentResourceMetaDto"]; + }; StockCandleDto: { /** Format: date-time */ time: string; @@ -1119,6 +1236,10 @@ export interface components { /** Format: date-time */ observedAt: string; }; + StockCandlesResourceDto: { + data: components["schemas"]["StockCandlesDto"] | null; + meta: components["schemas"]["InvestmentResourceMetaDto"]; + }; StockPositionRowDto: { instrumentUid?: string | null; account?: components["schemas"]["AccountReferenceDto"]; @@ -1146,6 +1267,10 @@ export interface components { /** Format: date-time */ observedAt: string; }; + StockPositionResourceDto: { + data: components["schemas"]["StockPositionDto"] | null; + meta: components["schemas"]["InvestmentResourceMetaDto"]; + }; StockMetricDto: { key: string; value: number; @@ -1162,6 +1287,10 @@ export interface components { /** Format: date-time */ observedAt: string; }; + StockCompanyResourceDto: { + data: components["schemas"]["StockCompanyDto"] | null; + meta: components["schemas"]["InvestmentResourceMetaDto"]; + }; StockDividendDto: { perShare: components["schemas"]["MoneyDto"] | null; /** Format: date */ @@ -1187,6 +1316,10 @@ export interface components { estimateUnavailable: boolean; positionIsStale: boolean; }; + StockDividendsResourceDto: { + data: components["schemas"]["StockDividendsDto"] | null; + meta: components["schemas"]["InvestmentResourceMetaDto"]; + }; BondCreditRatingDto: { /** @enum {string} */ agency: "NCR"; @@ -1227,6 +1360,10 @@ export interface components { /** Format: date-time */ observedAt: string; }; + BondMetricsResourceDto: { + data: components["schemas"]["BondMetricsDto"] | null; + meta: components["schemas"]["InvestmentResourceMetaDto"]; + }; BondSearchResultDto: { /** Format: uuid */ instrumentUid: string; @@ -1261,6 +1398,10 @@ export interface components { /** Format: date-time */ priceAt: string | null; }; + BondSearchResourceDto: { + data: components["schemas"]["BondSearchResultDto"][] | null; + meta: components["schemas"]["InvestmentResourceMetaDto"]; + }; BondDto: { /** Format: uuid */ instrumentUid: string; @@ -1291,6 +1432,10 @@ export interface components { amortization: boolean | null; subordinated: boolean | null; }; + BondResourceDto: { + data: components["schemas"]["BondDto"] | null; + meta: components["schemas"]["InvestmentResourceMetaDto"]; + }; BondQuoteDto: { pricePercent: string | null; /** @description Indicative clean value at the current nominal; no FX conversion. */ @@ -1307,6 +1452,10 @@ export interface components { /** Format: date-time */ observedAt: string; }; + BondQuoteResourceDto: { + data: components["schemas"]["BondQuoteDto"] | null; + meta: components["schemas"]["InvestmentResourceMetaDto"]; + }; BondCandlesDto: { candles: components["schemas"]["StockCandleDto"][]; currency: string | null; @@ -1315,6 +1464,10 @@ export interface components { /** Format: date-time */ observedAt: string; }; + BondCandlesResourceDto: { + data: components["schemas"]["BondCandlesDto"] | null; + meta: components["schemas"]["InvestmentResourceMetaDto"]; + }; BondPositionDto: { quantity: string; currentValue: components["schemas"]["MoneyDto"] | null; @@ -1326,6 +1479,10 @@ export interface components { /** Format: date-time */ observedAt: string; }; + BondPositionResourceDto: { + data: components["schemas"]["BondPositionDto"] | null; + meta: components["schemas"]["InvestmentResourceMetaDto"]; + }; BondCouponDto: { number: string | null; paymentDate: string | null; @@ -1346,6 +1503,10 @@ export interface components { estimateUnavailable: boolean; positionIsStale: boolean; }; + BondCouponsResourceDto: { + data: components["schemas"]["BondCouponsDto"] | null; + meta: components["schemas"]["InvestmentResourceMetaDto"]; + }; TbankConnectionStatusDto: { connected: boolean; /** Format: date-time */ @@ -1354,6 +1515,53 @@ export interface components { PutTbankConnectionDto: { token: string; }; + SyncCandleRangeDto: { + /** Format: date-time */ + from: string; + /** Format: date-time */ + to: string; + }; + InvestmentScreenDto: { + /** @enum {string} */ + screen: "accounts" | "account" | "operations" | "income" | "events" | "stocks" | "bonds" | "stock" | "bond"; + accountId?: string; + /** Format: date-time */ + from?: string; + /** Format: date-time */ + to?: 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; + }; + TbankSyncAcceptedDto: { + /** Format: uuid */ + id: string; + scope: components["schemas"]["InvestmentScreenDto"]; + /** Format: date-time */ + now: string; + }; + TbankSyncProgressDto: { + completed: number; + total: number; + discovering: boolean; + }; + TbankSyncStatusDto: { + /** Format: uuid */ + id: string; + scope: components["schemas"]["InvestmentScreenDto"] | null; + /** Format: date-time */ + now: string; + /** @enum {string} */ + state: "running" | "succeeded" | "failed" | "cancelled"; + errorCode: string | null; + progress: components["schemas"]["TbankSyncProgressDto"]; + resources: components["schemas"]["InvestmentResourceMetaDto"][]; + }; }; responses: never; parameters: never; @@ -1411,15 +1619,6 @@ export interface operations { "application/problem+json": components["schemas"]["ProblemDetailsDto"]; }; }; - /** @description Email already registered */ - 409: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["ProblemDetailsDto"]; - }; - }; /** @description Internal server error */ 500: { headers: { @@ -2910,7 +3109,9 @@ export interface operations { }; BrokerageAccountsController_list: { parameters: { - query?: never; + query?: { + sync?: "observe"; + }; header?: never; path?: never; cookie?: never; @@ -2922,7 +3123,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["BrokerageAccountDto"][]; + "application/json": components["schemas"]["BrokerageAccountsResourceDto"]; }; }; /** @description Invalid request */ @@ -2952,15 +3153,6 @@ export interface operations { "application/problem+json": components["schemas"]["ProblemDetailsDto"]; }; }; - /** @description T-Bank connection required */ - 409: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["ProblemDetailsDto"]; - }; - }; /** @description Internal server error */ 500: { headers: { @@ -2983,7 +3175,9 @@ export interface operations { }; PortfolioController_all: { parameters: { - query?: never; + query?: { + sync?: "observe"; + }; header?: never; path?: never; cookie?: never; @@ -2995,7 +3189,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["PortfolioDto"]; + "application/json": components["schemas"]["PortfolioResourceDto"]; }; }; /** @description Invalid request */ @@ -3025,15 +3219,6 @@ export interface operations { "application/problem+json": components["schemas"]["ProblemDetailsDto"]; }; }; - /** @description T-Bank connection required */ - 409: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["ProblemDetailsDto"]; - }; - }; /** @description Internal server error */ 500: { headers: { @@ -3056,7 +3241,9 @@ export interface operations { }; PortfolioController_get: { parameters: { - query?: never; + query?: { + sync?: "observe"; + }; header?: never; path: { accountId: string; @@ -3070,7 +3257,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["PortfolioDto"]; + "application/json": components["schemas"]["PortfolioResourceDto"]; }; }; /** @description Invalid request */ @@ -3100,15 +3287,6 @@ export interface operations { "application/problem+json": components["schemas"]["ProblemDetailsDto"]; }; }; - /** @description T-Bank connection required */ - 409: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["ProblemDetailsDto"]; - }; - }; /** @description Internal server error */ 500: { headers: { @@ -3132,6 +3310,7 @@ export interface operations { BrokerageOperationsController_all: { parameters: { query?: { + sync?: "observe"; /** @description Instrument UID. Without from, includes history since the earliest available account opening. */ instrumentUid?: string; from?: string; @@ -3151,7 +3330,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["BrokerageOperationsPageDto"]; + "application/json": components["schemas"]["BrokerageOperationsResourceDto"]; }; }; /** @description Invalid request */ @@ -3181,15 +3360,6 @@ export interface operations { "application/problem+json": components["schemas"]["ProblemDetailsDto"]; }; }; - /** @description T-Bank connection required */ - 409: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["ProblemDetailsDto"]; - }; - }; /** @description Internal server error */ 500: { headers: { @@ -3213,6 +3383,7 @@ export interface operations { BrokerageOperationsController_get: { parameters: { query?: { + sync?: "observe"; /** @description Instrument UID. Without from, includes history since the earliest available account opening. */ instrumentUid?: string; from?: string; @@ -3234,7 +3405,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["BrokerageOperationsPageDto"]; + "application/json": components["schemas"]["BrokerageOperationsResourceDto"]; }; }; /** @description Invalid request */ @@ -3264,15 +3435,6 @@ export interface operations { "application/problem+json": components["schemas"]["ProblemDetailsDto"]; }; }; - /** @description T-Bank connection required */ - 409: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["ProblemDetailsDto"]; - }; - }; /** @description Internal server error */ 500: { headers: { @@ -3295,7 +3457,9 @@ export interface operations { }; IncomeAnalyticsController_allSummary: { parameters: { - query?: never; + query?: { + sync?: "observe"; + }; header?: never; path?: never; cookie?: never; @@ -3307,7 +3471,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["IncomeSummaryDto"]; + "application/json": components["schemas"]["IncomeSummaryResourceDto"]; }; }; /** @description Invalid request */ @@ -3337,15 +3501,6 @@ export interface operations { "application/problem+json": components["schemas"]["ProblemDetailsDto"]; }; }; - /** @description T-Bank connection required */ - 409: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["ProblemDetailsDto"]; - }; - }; /** @description Internal server error */ 500: { headers: { @@ -3369,6 +3524,7 @@ export interface operations { IncomeAnalyticsController_allAnalytics: { parameters: { query?: { + sync?: "observe"; from?: string; to?: string; }; @@ -3383,7 +3539,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["IncomeAnalyticsDto"]; + "application/json": components["schemas"]["IncomeAnalyticsResourceDto"]; }; }; /** @description Invalid request */ @@ -3413,15 +3569,6 @@ export interface operations { "application/problem+json": components["schemas"]["ProblemDetailsDto"]; }; }; - /** @description T-Bank connection required */ - 409: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["ProblemDetailsDto"]; - }; - }; /** @description Internal server error */ 500: { headers: { @@ -3444,7 +3591,9 @@ export interface operations { }; IncomeAnalyticsController_summary: { parameters: { - query?: never; + query?: { + sync?: "observe"; + }; header?: never; path: { accountId: string; @@ -3458,7 +3607,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["IncomeSummaryDto"]; + "application/json": components["schemas"]["IncomeSummaryResourceDto"]; }; }; /** @description Invalid request */ @@ -3488,15 +3637,6 @@ export interface operations { "application/problem+json": components["schemas"]["ProblemDetailsDto"]; }; }; - /** @description T-Bank connection required */ - 409: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["ProblemDetailsDto"]; - }; - }; /** @description Internal server error */ 500: { headers: { @@ -3520,6 +3660,7 @@ export interface operations { IncomeAnalyticsController_analytics: { parameters: { query?: { + sync?: "observe"; from?: string; to?: string; }; @@ -3536,7 +3677,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["IncomeAnalyticsDto"]; + "application/json": components["schemas"]["IncomeAnalyticsResourceDto"]; }; }; /** @description Invalid request */ @@ -3566,15 +3707,6 @@ export interface operations { "application/problem+json": components["schemas"]["ProblemDetailsDto"]; }; }; - /** @description T-Bank connection required */ - 409: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["ProblemDetailsDto"]; - }; - }; /** @description Internal server error */ 500: { headers: { @@ -3598,6 +3730,7 @@ export interface operations { UpcomingEventsController_all: { parameters: { query?: { + sync?: "observe"; /** @description Inclusive start date (YYYY-MM-DD). Defaults to today in UTC. */ from?: string; /** @description Inclusive end date (YYYY-MM-DD). Defaults to 30 days after from. */ @@ -3614,7 +3747,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["UpcomingEventsDto"]; + "application/json": components["schemas"]["UpcomingEventsResourceDto"]; }; }; /** @description Invalid request */ @@ -3644,15 +3777,6 @@ export interface operations { "application/problem+json": components["schemas"]["ProblemDetailsDto"]; }; }; - /** @description T-Bank connection required */ - 409: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["ProblemDetailsDto"]; - }; - }; /** @description Internal server error */ 500: { headers: { @@ -3676,6 +3800,7 @@ export interface operations { UpcomingEventsController_get: { parameters: { query?: { + sync?: "observe"; /** @description Inclusive start date (YYYY-MM-DD). Defaults to today in UTC. */ from?: string; /** @description Inclusive end date (YYYY-MM-DD). Defaults to 30 days after from. */ @@ -3694,7 +3819,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["UpcomingEventsDto"]; + "application/json": components["schemas"]["UpcomingEventsResourceDto"]; }; }; /** @description Invalid request */ @@ -3724,15 +3849,6 @@ export interface operations { "application/problem+json": components["schemas"]["ProblemDetailsDto"]; }; }; - /** @description T-Bank connection required */ - 409: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["ProblemDetailsDto"]; - }; - }; /** @description Internal server error */ 500: { headers: { @@ -3756,6 +3872,7 @@ export interface operations { StockController_search: { parameters: { query: { + sync?: "observe"; query: string; }; header?: never; @@ -3769,7 +3886,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["StockSearchResultDto"][]; + "application/json": components["schemas"]["StockSearchResourceDto"]; }; }; /** @description Invalid request */ @@ -3799,15 +3916,6 @@ export interface operations { "application/problem+json": components["schemas"]["ProblemDetailsDto"]; }; }; - /** @description T-Bank connection required */ - 409: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["ProblemDetailsDto"]; - }; - }; /** @description Internal server error */ 500: { headers: { @@ -3830,7 +3938,9 @@ export interface operations { }; StockController_getByTicker: { parameters: { - query?: never; + query?: { + sync?: "observe"; + }; header?: never; path: { ticker: string; @@ -3844,7 +3954,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["StockDto"]; + "application/json": components["schemas"]["StockResourceDto"]; }; }; /** @description Invalid request */ @@ -3874,15 +3984,6 @@ export interface operations { "application/problem+json": components["schemas"]["ProblemDetailsDto"]; }; }; - /** @description T-Bank connection required */ - 409: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["ProblemDetailsDto"]; - }; - }; /** @description Internal server error */ 500: { headers: { @@ -3905,7 +4006,9 @@ export interface operations { }; StockController_get: { parameters: { - query?: never; + query?: { + sync?: "observe"; + }; header?: never; path: { uid: string; @@ -3919,7 +4022,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["StockDto"]; + "application/json": components["schemas"]["StockResourceDto"]; }; }; /** @description Invalid request */ @@ -3949,15 +4052,6 @@ export interface operations { "application/problem+json": components["schemas"]["ProblemDetailsDto"]; }; }; - /** @description T-Bank connection required */ - 409: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["ProblemDetailsDto"]; - }; - }; /** @description Internal server error */ 500: { headers: { @@ -3980,7 +4074,9 @@ export interface operations { }; StockController_quote: { parameters: { - query?: never; + query?: { + sync?: "observe"; + }; header?: never; path: { uid: string; @@ -3994,7 +4090,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["StockQuoteDto"]; + "application/json": components["schemas"]["StockQuoteResourceDto"]; }; }; /** @description Invalid request */ @@ -4024,15 +4120,6 @@ export interface operations { "application/problem+json": components["schemas"]["ProblemDetailsDto"]; }; }; - /** @description T-Bank connection required */ - 409: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["ProblemDetailsDto"]; - }; - }; /** @description Internal server error */ 500: { headers: { @@ -4056,6 +4143,7 @@ export interface operations { StockController_candles: { parameters: { query?: { + sync?: "observe"; period?: "month" | "halfYear" | "year" | "all"; }; header?: never; @@ -4071,7 +4159,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["StockCandlesDto"]; + "application/json": components["schemas"]["StockCandlesResourceDto"]; }; }; /** @description Invalid request */ @@ -4101,15 +4189,6 @@ export interface operations { "application/problem+json": components["schemas"]["ProblemDetailsDto"]; }; }; - /** @description T-Bank connection required */ - 409: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["ProblemDetailsDto"]; - }; - }; /** @description Internal server error */ 500: { headers: { @@ -4132,7 +4211,9 @@ export interface operations { }; StockController_position: { parameters: { - query?: never; + query?: { + sync?: "observe"; + }; header?: never; path: { uid: string; @@ -4146,7 +4227,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["StockPositionDto"]; + "application/json": components["schemas"]["StockPositionResourceDto"]; }; }; /** @description Invalid request */ @@ -4176,15 +4257,6 @@ export interface operations { "application/problem+json": components["schemas"]["ProblemDetailsDto"]; }; }; - /** @description T-Bank connection required */ - 409: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["ProblemDetailsDto"]; - }; - }; /** @description Internal server error */ 500: { headers: { @@ -4207,7 +4279,9 @@ export interface operations { }; StockController_company: { parameters: { - query?: never; + query?: { + sync?: "observe"; + }; header?: never; path: { uid: string; @@ -4221,7 +4295,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["StockCompanyDto"]; + "application/json": components["schemas"]["StockCompanyResourceDto"]; }; }; /** @description Invalid request */ @@ -4251,15 +4325,6 @@ export interface operations { "application/problem+json": components["schemas"]["ProblemDetailsDto"]; }; }; - /** @description T-Bank connection required */ - 409: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["ProblemDetailsDto"]; - }; - }; /** @description Internal server error */ 500: { headers: { @@ -4282,7 +4347,9 @@ export interface operations { }; StockController_dividends: { parameters: { - query?: never; + query?: { + sync?: "observe"; + }; header?: never; path: { uid: string; @@ -4296,7 +4363,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["StockDividendsDto"]; + "application/json": components["schemas"]["StockDividendsResourceDto"]; }; }; /** @description Invalid request */ @@ -4326,15 +4393,6 @@ export interface operations { "application/problem+json": components["schemas"]["ProblemDetailsDto"]; }; }; - /** @description T-Bank connection required */ - 409: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["ProblemDetailsDto"]; - }; - }; /** @description Internal server error */ 500: { headers: { @@ -4357,7 +4415,9 @@ export interface operations { }; BondController_metrics: { parameters: { - query?: never; + query?: { + sync?: "observe"; + }; header?: never; path: { uid: string; @@ -4371,7 +4431,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["BondMetricsDto"]; + "application/json": components["schemas"]["BondMetricsResourceDto"]; }; }; /** @description Invalid request */ @@ -4401,15 +4461,6 @@ export interface operations { "application/problem+json": components["schemas"]["ProblemDetailsDto"]; }; }; - /** @description T-Bank connection required */ - 409: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["ProblemDetailsDto"]; - }; - }; /** @description Internal server error */ 500: { headers: { @@ -4433,6 +4484,7 @@ export interface operations { BondController_search: { parameters: { query: { + sync?: "observe"; query: string; }; header?: never; @@ -4446,7 +4498,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["BondSearchResultDto"][]; + "application/json": components["schemas"]["BondSearchResourceDto"]; }; }; /** @description Invalid request */ @@ -4476,15 +4528,6 @@ export interface operations { "application/problem+json": components["schemas"]["ProblemDetailsDto"]; }; }; - /** @description T-Bank connection required */ - 409: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["ProblemDetailsDto"]; - }; - }; /** @description Internal server error */ 500: { headers: { @@ -4507,7 +4550,9 @@ export interface operations { }; BondController_getByTicker: { parameters: { - query?: never; + query?: { + sync?: "observe"; + }; header?: never; path: { ticker: string; @@ -4521,7 +4566,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["BondDto"]; + "application/json": components["schemas"]["BondResourceDto"]; }; }; /** @description Invalid request */ @@ -4551,15 +4596,6 @@ export interface operations { "application/problem+json": components["schemas"]["ProblemDetailsDto"]; }; }; - /** @description T-Bank connection required */ - 409: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["ProblemDetailsDto"]; - }; - }; /** @description Internal server error */ 500: { headers: { @@ -4582,7 +4618,9 @@ export interface operations { }; BondController_get: { parameters: { - query?: never; + query?: { + sync?: "observe"; + }; header?: never; path: { uid: string; @@ -4596,7 +4634,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["BondDto"]; + "application/json": components["schemas"]["BondResourceDto"]; }; }; /** @description Invalid request */ @@ -4626,15 +4664,6 @@ export interface operations { "application/problem+json": components["schemas"]["ProblemDetailsDto"]; }; }; - /** @description T-Bank connection required */ - 409: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["ProblemDetailsDto"]; - }; - }; /** @description Internal server error */ 500: { headers: { @@ -4657,7 +4686,9 @@ export interface operations { }; BondController_quote: { parameters: { - query?: never; + query?: { + sync?: "observe"; + }; header?: never; path: { uid: string; @@ -4671,7 +4702,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["BondQuoteDto"]; + "application/json": components["schemas"]["BondQuoteResourceDto"]; }; }; /** @description Invalid request */ @@ -4701,15 +4732,6 @@ export interface operations { "application/problem+json": components["schemas"]["ProblemDetailsDto"]; }; }; - /** @description T-Bank connection required */ - 409: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["ProblemDetailsDto"]; - }; - }; /** @description Internal server error */ 500: { headers: { @@ -4733,6 +4755,7 @@ export interface operations { BondController_candles: { parameters: { query?: { + sync?: "observe"; period?: "month" | "halfYear" | "year" | "all"; }; header?: never; @@ -4748,7 +4771,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["BondCandlesDto"]; + "application/json": components["schemas"]["BondCandlesResourceDto"]; }; }; /** @description Invalid request */ @@ -4778,15 +4801,6 @@ export interface operations { "application/problem+json": components["schemas"]["ProblemDetailsDto"]; }; }; - /** @description T-Bank connection required */ - 409: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["ProblemDetailsDto"]; - }; - }; /** @description Internal server error */ 500: { headers: { @@ -4809,7 +4823,9 @@ export interface operations { }; BondController_position: { parameters: { - query?: never; + query?: { + sync?: "observe"; + }; header?: never; path: { uid: string; @@ -4823,7 +4839,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["BondPositionDto"]; + "application/json": components["schemas"]["BondPositionResourceDto"]; }; }; /** @description Invalid request */ @@ -4853,15 +4869,6 @@ export interface operations { "application/problem+json": components["schemas"]["ProblemDetailsDto"]; }; }; - /** @description T-Bank connection required */ - 409: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["ProblemDetailsDto"]; - }; - }; /** @description Internal server error */ 500: { headers: { @@ -4884,7 +4891,9 @@ export interface operations { }; BondController_coupons: { parameters: { - query?: never; + query?: { + sync?: "observe"; + }; header?: never; path: { uid: string; @@ -4898,7 +4907,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["BondCouponsDto"]; + "application/json": components["schemas"]["BondCouponsResourceDto"]; }; }; /** @description Invalid request */ @@ -4928,15 +4937,6 @@ export interface operations { "application/problem+json": components["schemas"]["ProblemDetailsDto"]; }; }; - /** @description T-Bank connection required */ - 409: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["ProblemDetailsDto"]; - }; - }; /** @description Internal server error */ 500: { headers: { @@ -5151,4 +5151,138 @@ export interface operations { }; }; }; + TbankSyncController_request: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["InvestmentScreenDto"]; + }; + }; + responses: { + 202: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["TbankSyncAcceptedDto"]; + }; + }; + /** @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"]; + }; + }; + }; + }; + 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"]; + }; + }; + }; + }; } diff --git a/src/shared/api/index.ts b/src/shared/api/index.ts index d8dc2e8..ba485e8 100644 --- a/src/shared/api/index.ts +++ b/src/shared/api/index.ts @@ -2,6 +2,17 @@ export { useAuthenticatedQueries, useAuthenticatedQuery, } from "./authenticated-query"; -export { apiClient, apiQueryClient, configureAuthClient } from "./client"; +export { + apiClient, + apiQueryClient, + configureAuthClient, + invalidateApiRequests, +} from "./client"; export type { paths } from "./generated/schema"; export { apiErrorCode } from "./problem-details"; + +export { + invalidateResourceAccess, + unwrapResourceResult, + useResourceQuery, +} from "./resource-query"; diff --git a/src/shared/api/resource-query.ts b/src/shared/api/resource-query.ts new file mode 100644 index 0000000..3c8616c --- /dev/null +++ b/src/shared/api/resource-query.ts @@ -0,0 +1,150 @@ +import type { QueryClient } from "@tanstack/react-query"; +import { + type QueryFunctionContext, + type QueryKey, + type UseQueryOptions, + type UseQueryResult, + useQueryClient, +} from "@tanstack/react-query"; +import { useEffect } from "react"; +import { useAuthenticatedQuery } from "./authenticated-query"; +import type { components } from "./generated/schema"; +import { apiErrorCode } from "./problem-details"; + +const accessEpochs = new WeakMap>(); + +type Resource = { + data: unknown; + meta: components["schemas"]["InvestmentResourceMetaDto"]; +}; +function unwrapResourceResult( + result: UseQueryResult, +) { + const missing = result.isSuccess && result.data.data === null; + return { + ...result, + transport: { + status: result.status, + fetchStatus: result.fetchStatus, + error: result.error, + }, + data: result.data?.data ?? undefined, + meta: result.data?.meta, + isPending: result.isPending || missing, + isSuccess: result.isSuccess && !missing, + status: missing ? "pending" : result.status, + } as UseQueryResult, E> & { + meta?: T["meta"]; + transport: { + status: UseQueryResult["status"]; + fetchStatus: UseQueryResult["fetchStatus"]; + error: E | null; + }; + }; +} + +function invalidateResourceAccess( + client: QueryClient, + group: string, + error: unknown, +) { + if ( + ![ + "NOT_FOUND", + "FORBIDDEN", + "TBANK_NOT_CONNECTED", + "INVALID_TBANK_TOKEN", + ].includes(apiErrorCode(error) ?? "") + ) + return; + const epochs = accessEpochs.get(client) ?? new Map(); + accessEpochs.set(client, epochs); + epochs.set(group, (epochs.get(group) ?? 0) + 1); + const affected = { + predicate: (cached: { meta?: Record }) => + cached.meta?.accessGroup === group, + }; + void client.cancelQueries(affected); + for (const cached of client.getQueryCache().findAll(affected)) + cached.setState({ + data: undefined, + dataUpdatedAt: 0, + status: "error", + error: error as Error, + fetchStatus: "idle", + }); +} + +// Observation changes transport mode, never the generated cache identity. +function useResourceQuery( + options: UseQueryOptions, +) { + const client = useQueryClient(); + const group = + typeof options.meta?.accessGroup === "string" + ? options.meta.accessGroup + : undefined; + let epochs = accessEpochs.get(client); + if (!epochs) { + epochs = new Map(); + accessEpochs.set(client, epochs); + } + const groupEpochs = epochs; + const queryFn = options.queryFn; + const guarded = + typeof queryFn === "function" + ? async (context: QueryFunctionContext) => { + const epoch = group ? (groupEpochs.get(group) ?? 0) : 0; + const result = await queryFn(context); + if (group && epoch !== (groupEpochs.get(group) ?? 0)) + throw new DOMException("Access changed", "AbortError"); + return result; + } + : queryFn; + const query = useAuthenticatedQuery({ ...options, queryFn: guarded }); + useEffect(() => { + if (group) invalidateResourceAccess(client, group, query.error); + }, [client, group, query.error]); + const observe = async (expectedStateVersion?: string) => { + const queryFn = guarded; + if (typeof queryFn !== "function") return; + await client.cancelQueries({ queryKey: options.queryKey, exact: true }); + return client.fetchQuery({ + ...options, + staleTime: 0, + retry: false, + queryFn: async (context: QueryFunctionContext) => { + const [method, path, input] = context.queryKey; + const init = input as + | { params?: { query?: Record } } + | undefined; + const queryKey = [ + method, + path, + { + ...init, + params: { + ...init?.params, + query: { ...init?.params?.query, sync: "observe" }, + }, + }, + ] as unknown as K; + const response = await queryFn({ ...context, queryKey }); + if ( + expectedStateVersion && + response.meta.stateVersion !== expectedStateVersion + ) { + // One more observational read; never turn a stale notification into an import. + const current = await queryFn({ ...context, queryKey }); + if (current.meta.stateVersion !== expectedStateVersion) + throw new DOMException("Resource changed", "AbortError"); + return current; + } + return response; + }, + }); + }; + return { ...unwrapResourceResult(query), observe }; +} + +export { invalidateResourceAccess, unwrapResourceResult, useResourceQuery }; diff --git a/src/shared/api/spec/openapi.json b/src/shared/api/spec/openapi.json index 410d0b6..3b3c25e 100644 --- a/src/shared/api/spec/openapi.json +++ b/src/shared/api/spec/openapi.json @@ -56,16 +56,6 @@ } } }, - "409": { - "description": "Email already registered", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetailsDto" - } - } - } - }, "500": { "description": "Internal server error", "content": { @@ -1986,17 +1976,26 @@ "/api/v1/brokerage-accounts": { "get": { "operationId": "BrokerageAccountsController_list", - "parameters": [], + "parameters": [ + { + "name": "sync", + "required": false, + "in": "query", + "schema": { + "type": "string", + "enum": [ + "observe" + ] + } + } + ], "responses": { "200": { "description": "", "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/BrokerageAccountDto" - } + "$ref": "#/components/schemas/BrokerageAccountsResourceDto" } } } @@ -2031,16 +2030,6 @@ } } }, - "409": { - "description": "T-Bank connection required", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetailsDto" - } - } - } - }, "500": { "description": "Internal server error", "content": { @@ -2075,14 +2064,26 @@ "/api/v1/brokerage-accounts/all/portfolio": { "get": { "operationId": "PortfolioController_all", - "parameters": [], + "parameters": [ + { + "name": "sync", + "required": false, + "in": "query", + "schema": { + "type": "string", + "enum": [ + "observe" + ] + } + } + ], "responses": { "200": { "description": "", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PortfolioDto" + "$ref": "#/components/schemas/PortfolioResourceDto" } } } @@ -2117,16 +2118,6 @@ } } }, - "409": { - "description": "T-Bank connection required", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetailsDto" - } - } - } - }, "500": { "description": "Internal server error", "content": { @@ -2169,6 +2160,17 @@ "schema": { "type": "string" } + }, + { + "name": "sync", + "required": false, + "in": "query", + "schema": { + "type": "string", + "enum": [ + "observe" + ] + } } ], "responses": { @@ -2177,7 +2179,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PortfolioDto" + "$ref": "#/components/schemas/PortfolioResourceDto" } } } @@ -2212,16 +2214,6 @@ } } }, - "409": { - "description": "T-Bank connection required", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetailsDto" - } - } - } - }, "500": { "description": "Internal server error", "content": { @@ -2257,6 +2249,17 @@ "get": { "operationId": "BrokerageOperationsController_all", "parameters": [ + { + "name": "sync", + "required": false, + "in": "query", + "schema": { + "type": "string", + "enum": [ + "observe" + ] + } + }, { "name": "instrumentUid", "required": false, @@ -2330,7 +2333,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/BrokerageOperationsPageDto" + "$ref": "#/components/schemas/BrokerageOperationsResourceDto" } } } @@ -2365,16 +2368,6 @@ } } }, - "409": { - "description": "T-Bank connection required", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetailsDto" - } - } - } - }, "500": { "description": "Internal server error", "content": { @@ -2418,6 +2411,17 @@ "type": "string" } }, + { + "name": "sync", + "required": false, + "in": "query", + "schema": { + "type": "string", + "enum": [ + "observe" + ] + } + }, { "name": "instrumentUid", "required": false, @@ -2491,7 +2495,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/BrokerageOperationsPageDto" + "$ref": "#/components/schemas/BrokerageOperationsResourceDto" } } } @@ -2526,16 +2530,6 @@ } } }, - "409": { - "description": "T-Bank connection required", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetailsDto" - } - } - } - }, "500": { "description": "Internal server error", "content": { @@ -2570,14 +2564,26 @@ "/api/v1/brokerage-accounts/all/income-summary": { "get": { "operationId": "IncomeAnalyticsController_allSummary", - "parameters": [], + "parameters": [ + { + "name": "sync", + "required": false, + "in": "query", + "schema": { + "type": "string", + "enum": [ + "observe" + ] + } + } + ], "responses": { "200": { "description": "", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/IncomeSummaryDto" + "$ref": "#/components/schemas/IncomeSummaryResourceDto" } } } @@ -2612,16 +2618,6 @@ } } }, - "409": { - "description": "T-Bank connection required", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetailsDto" - } - } - } - }, "500": { "description": "Internal server error", "content": { @@ -2657,6 +2653,17 @@ "get": { "operationId": "IncomeAnalyticsController_allAnalytics", "parameters": [ + { + "name": "sync", + "required": false, + "in": "query", + "schema": { + "type": "string", + "enum": [ + "observe" + ] + } + }, { "name": "from", "required": false, @@ -2682,7 +2689,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/IncomeAnalyticsDto" + "$ref": "#/components/schemas/IncomeAnalyticsResourceDto" } } } @@ -2717,16 +2724,6 @@ } } }, - "409": { - "description": "T-Bank connection required", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetailsDto" - } - } - } - }, "500": { "description": "Internal server error", "content": { @@ -2769,6 +2766,17 @@ "schema": { "type": "string" } + }, + { + "name": "sync", + "required": false, + "in": "query", + "schema": { + "type": "string", + "enum": [ + "observe" + ] + } } ], "responses": { @@ -2777,7 +2785,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/IncomeSummaryDto" + "$ref": "#/components/schemas/IncomeSummaryResourceDto" } } } @@ -2812,16 +2820,6 @@ } } }, - "409": { - "description": "T-Bank connection required", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetailsDto" - } - } - } - }, "500": { "description": "Internal server error", "content": { @@ -2865,6 +2863,17 @@ "type": "string" } }, + { + "name": "sync", + "required": false, + "in": "query", + "schema": { + "type": "string", + "enum": [ + "observe" + ] + } + }, { "name": "from", "required": false, @@ -2890,7 +2899,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/IncomeAnalyticsDto" + "$ref": "#/components/schemas/IncomeAnalyticsResourceDto" } } } @@ -2925,16 +2934,6 @@ } } }, - "409": { - "description": "T-Bank connection required", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetailsDto" - } - } - } - }, "500": { "description": "Internal server error", "content": { @@ -2970,6 +2969,17 @@ "get": { "operationId": "UpcomingEventsController_all", "parameters": [ + { + "name": "sync", + "required": false, + "in": "query", + "schema": { + "type": "string", + "enum": [ + "observe" + ] + } + }, { "name": "from", "required": false, @@ -2997,7 +3007,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UpcomingEventsDto" + "$ref": "#/components/schemas/UpcomingEventsResourceDto" } } } @@ -3032,16 +3042,6 @@ } } }, - "409": { - "description": "T-Bank connection required", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetailsDto" - } - } - } - }, "500": { "description": "Internal server error", "content": { @@ -3085,6 +3085,17 @@ "type": "string" } }, + { + "name": "sync", + "required": false, + "in": "query", + "schema": { + "type": "string", + "enum": [ + "observe" + ] + } + }, { "name": "from", "required": false, @@ -3112,7 +3123,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UpcomingEventsDto" + "$ref": "#/components/schemas/UpcomingEventsResourceDto" } } } @@ -3147,16 +3158,6 @@ } } }, - "409": { - "description": "T-Bank connection required", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetailsDto" - } - } - } - }, "500": { "description": "Internal server error", "content": { @@ -3192,6 +3193,17 @@ "get": { "operationId": "StockController_search", "parameters": [ + { + "name": "sync", + "required": false, + "in": "query", + "schema": { + "type": "string", + "enum": [ + "observe" + ] + } + }, { "name": "query", "required": true, @@ -3209,10 +3221,7 @@ "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/StockSearchResultDto" - } + "$ref": "#/components/schemas/StockSearchResourceDto" } } } @@ -3247,16 +3256,6 @@ } } }, - "409": { - "description": "T-Bank connection required", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetailsDto" - } - } - } - }, "500": { "description": "Internal server error", "content": { @@ -3302,6 +3301,17 @@ "pattern": "^[A-Za-z0-9][A-Za-z0-9._@+-]*$", "type": "string" } + }, + { + "name": "sync", + "required": false, + "in": "query", + "schema": { + "type": "string", + "enum": [ + "observe" + ] + } } ], "responses": { @@ -3310,7 +3320,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/StockDto" + "$ref": "#/components/schemas/StockResourceDto" } } } @@ -3345,16 +3355,6 @@ } } }, - "409": { - "description": "T-Bank connection required", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetailsDto" - } - } - } - }, "500": { "description": "Internal server error", "content": { @@ -3397,6 +3397,17 @@ "schema": { "type": "string" } + }, + { + "name": "sync", + "required": false, + "in": "query", + "schema": { + "type": "string", + "enum": [ + "observe" + ] + } } ], "responses": { @@ -3405,7 +3416,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/StockDto" + "$ref": "#/components/schemas/StockResourceDto" } } } @@ -3440,16 +3451,6 @@ } } }, - "409": { - "description": "T-Bank connection required", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetailsDto" - } - } - } - }, "500": { "description": "Internal server error", "content": { @@ -3492,6 +3493,17 @@ "schema": { "type": "string" } + }, + { + "name": "sync", + "required": false, + "in": "query", + "schema": { + "type": "string", + "enum": [ + "observe" + ] + } } ], "responses": { @@ -3500,7 +3512,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/StockQuoteDto" + "$ref": "#/components/schemas/StockQuoteResourceDto" } } } @@ -3535,16 +3547,6 @@ } } }, - "409": { - "description": "T-Bank connection required", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetailsDto" - } - } - } - }, "500": { "description": "Internal server error", "content": { @@ -3588,6 +3590,17 @@ "type": "string" } }, + { + "name": "sync", + "required": false, + "in": "query", + "schema": { + "type": "string", + "enum": [ + "observe" + ] + } + }, { "name": "period", "required": false, @@ -3610,7 +3623,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/StockCandlesDto" + "$ref": "#/components/schemas/StockCandlesResourceDto" } } } @@ -3645,16 +3658,6 @@ } } }, - "409": { - "description": "T-Bank connection required", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetailsDto" - } - } - } - }, "500": { "description": "Internal server error", "content": { @@ -3697,6 +3700,17 @@ "schema": { "type": "string" } + }, + { + "name": "sync", + "required": false, + "in": "query", + "schema": { + "type": "string", + "enum": [ + "observe" + ] + } } ], "responses": { @@ -3705,7 +3719,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/StockPositionDto" + "$ref": "#/components/schemas/StockPositionResourceDto" } } } @@ -3740,16 +3754,6 @@ } } }, - "409": { - "description": "T-Bank connection required", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetailsDto" - } - } - } - }, "500": { "description": "Internal server error", "content": { @@ -3792,6 +3796,17 @@ "schema": { "type": "string" } + }, + { + "name": "sync", + "required": false, + "in": "query", + "schema": { + "type": "string", + "enum": [ + "observe" + ] + } } ], "responses": { @@ -3800,7 +3815,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/StockCompanyDto" + "$ref": "#/components/schemas/StockCompanyResourceDto" } } } @@ -3835,16 +3850,6 @@ } } }, - "409": { - "description": "T-Bank connection required", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetailsDto" - } - } - } - }, "500": { "description": "Internal server error", "content": { @@ -3887,6 +3892,17 @@ "schema": { "type": "string" } + }, + { + "name": "sync", + "required": false, + "in": "query", + "schema": { + "type": "string", + "enum": [ + "observe" + ] + } } ], "responses": { @@ -3895,7 +3911,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/StockDividendsDto" + "$ref": "#/components/schemas/StockDividendsResourceDto" } } } @@ -3930,16 +3946,6 @@ } } }, - "409": { - "description": "T-Bank connection required", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetailsDto" - } - } - } - }, "500": { "description": "Internal server error", "content": { @@ -3982,6 +3988,17 @@ "schema": { "type": "string" } + }, + { + "name": "sync", + "required": false, + "in": "query", + "schema": { + "type": "string", + "enum": [ + "observe" + ] + } } ], "responses": { @@ -3990,7 +4007,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/BondMetricsDto" + "$ref": "#/components/schemas/BondMetricsResourceDto" } } } @@ -4025,16 +4042,6 @@ } } }, - "409": { - "description": "T-Bank connection required", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetailsDto" - } - } - } - }, "500": { "description": "Internal server error", "content": { @@ -4070,6 +4077,17 @@ "get": { "operationId": "BondController_search", "parameters": [ + { + "name": "sync", + "required": false, + "in": "query", + "schema": { + "type": "string", + "enum": [ + "observe" + ] + } + }, { "name": "query", "required": true, @@ -4087,10 +4105,7 @@ "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/BondSearchResultDto" - } + "$ref": "#/components/schemas/BondSearchResourceDto" } } } @@ -4125,16 +4140,6 @@ } } }, - "409": { - "description": "T-Bank connection required", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetailsDto" - } - } - } - }, "500": { "description": "Internal server error", "content": { @@ -4180,6 +4185,17 @@ "pattern": "^[A-Za-z0-9][A-Za-z0-9._@+-]*$", "type": "string" } + }, + { + "name": "sync", + "required": false, + "in": "query", + "schema": { + "type": "string", + "enum": [ + "observe" + ] + } } ], "responses": { @@ -4188,7 +4204,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/BondDto" + "$ref": "#/components/schemas/BondResourceDto" } } } @@ -4223,16 +4239,6 @@ } } }, - "409": { - "description": "T-Bank connection required", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetailsDto" - } - } - } - }, "500": { "description": "Internal server error", "content": { @@ -4275,6 +4281,17 @@ "schema": { "type": "string" } + }, + { + "name": "sync", + "required": false, + "in": "query", + "schema": { + "type": "string", + "enum": [ + "observe" + ] + } } ], "responses": { @@ -4283,7 +4300,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/BondDto" + "$ref": "#/components/schemas/BondResourceDto" } } } @@ -4318,16 +4335,6 @@ } } }, - "409": { - "description": "T-Bank connection required", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetailsDto" - } - } - } - }, "500": { "description": "Internal server error", "content": { @@ -4370,6 +4377,17 @@ "schema": { "type": "string" } + }, + { + "name": "sync", + "required": false, + "in": "query", + "schema": { + "type": "string", + "enum": [ + "observe" + ] + } } ], "responses": { @@ -4378,7 +4396,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/BondQuoteDto" + "$ref": "#/components/schemas/BondQuoteResourceDto" } } } @@ -4413,16 +4431,6 @@ } } }, - "409": { - "description": "T-Bank connection required", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetailsDto" - } - } - } - }, "500": { "description": "Internal server error", "content": { @@ -4466,6 +4474,17 @@ "type": "string" } }, + { + "name": "sync", + "required": false, + "in": "query", + "schema": { + "type": "string", + "enum": [ + "observe" + ] + } + }, { "name": "period", "required": false, @@ -4488,7 +4507,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/BondCandlesDto" + "$ref": "#/components/schemas/BondCandlesResourceDto" } } } @@ -4523,16 +4542,6 @@ } } }, - "409": { - "description": "T-Bank connection required", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetailsDto" - } - } - } - }, "500": { "description": "Internal server error", "content": { @@ -4575,6 +4584,17 @@ "schema": { "type": "string" } + }, + { + "name": "sync", + "required": false, + "in": "query", + "schema": { + "type": "string", + "enum": [ + "observe" + ] + } } ], "responses": { @@ -4583,7 +4603,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/BondPositionDto" + "$ref": "#/components/schemas/BondPositionResourceDto" } } } @@ -4618,16 +4638,6 @@ } } }, - "409": { - "description": "T-Bank connection required", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetailsDto" - } - } - } - }, "500": { "description": "Internal server error", "content": { @@ -4670,6 +4680,17 @@ "schema": { "type": "string" } + }, + { + "name": "sync", + "required": false, + "in": "query", + "schema": { + "type": "string", + "enum": [ + "observe" + ] + } } ], "responses": { @@ -4678,7 +4699,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/BondCouponsDto" + "$ref": "#/components/schemas/BondCouponsResourceDto" } } } @@ -4713,16 +4734,6 @@ } } }, - "409": { - "description": "T-Bank connection required", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetailsDto" - } - } - } - }, "500": { "description": "Internal server error", "content": { @@ -4980,6 +4991,177 @@ "tbank-connection" ] } + }, + "/api/v1/tbank-sync": { + "post": { + "operationId": "TbankSyncController_request", + "parameters": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvestmentScreenDto" + } + } + } + }, + "responses": { + "202": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TbankSyncAcceptedDto" + } + } + } + }, + "400": { + "description": "Invalid request", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + }, + "401": { + "description": "Authentication required", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + }, + "404": { + "description": "Resource not found", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + }, + "503": { + "description": "Upstream service unavailable", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + } + }, + "security": [ + { + "bearer": [] + } + ], + "tags": [ + "tbank-sync" + ] + } + }, + "/api/v1/tbank-sync/{id}": { + "get": { + "operationId": "TbankSyncController_status", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TbankSyncStatusDto" + } + } + } + }, + "400": { + "description": "Invalid request", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + }, + "401": { + "description": "Authentication required", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + }, + "404": { + "description": "Resource not found", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + }, + "503": { + "description": "Upstream service unavailable", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + } + }, + "security": [ + { + "bearer": [] + } + ], + "tags": [ + "tbank-sync" + ] + } } }, "info": { @@ -4992,21 +5174,6 @@ "servers": [], "components": { "schemas": { - "ValidationProblemDto": { - "type": "object", - "properties": { - "pointer": { - "type": "string" - }, - "detail": { - "type": "string" - } - }, - "required": [ - "pointer", - "detail" - ] - }, "ProblemDetailsDto": { "type": "object", "properties": { @@ -5027,7 +5194,7 @@ ], "type": "array", "items": { - "$ref": "#/components/schemas/ValidationProblemDto" + "type": "string" } } }, @@ -5668,6 +5835,237 @@ "portfolioSummary" ] }, + "InvestmentRangeDto": { + "type": "object", + "properties": { + "accountId": { + "type": "string" + }, + "from": { + "type": "string", + "format": "date-time" + }, + "to": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "from", + "to" + ] + }, + "InvestmentCoverageDto": { + "type": "object", + "properties": { + "requested": { + "type": "array", + "items": { + "$ref": "#/components/schemas/InvestmentRangeDto" + } + }, + "completeRanges": { + "type": "array", + "items": { + "$ref": "#/components/schemas/InvestmentRangeDto" + } + }, + "missingRanges": { + "type": "array", + "items": { + "$ref": "#/components/schemas/InvestmentRangeDto" + } + }, + "asOf": { + "type": "string", + "nullable": true, + "format": "date-time" + }, + "historyBoundary": { + "type": "string", + "enum": [ + "known", + "unknown" + ] + }, + "historyState": { + "type": "string", + "enum": [ + "missing", + "loading", + "partial", + "complete", + "failed" + ] + } + }, + "required": [ + "requested", + "completeRanges", + "missingRanges", + "asOf", + "historyBoundary", + "historyState" + ] + }, + "InvestmentSyncDto": { + "type": "object", + "properties": { + "id": { + "type": "string", + "nullable": true + }, + "state": { + "type": "string", + "enum": [ + "idle", + "queued", + "running", + "failed" + ] + }, + "errorCode": { + "type": "string", + "nullable": true + } + }, + "required": [ + "id", + "state", + "errorCode" + ] + }, + "InvestmentDependencyDto": { + "type": "object", + "properties": { + "dataRevision": { + "type": "string" + }, + "stateVersion": { + "type": "string" + }, + "unavailableReason": { + "type": "string" + }, + "coverage": { + "$ref": "#/components/schemas/InvestmentCoverageDto" + }, + "resourceKey": { + "type": "string" + }, + "availability": { + "type": "string", + "enum": [ + "missing", + "partial", + "complete" + ] + }, + "freshness": { + "type": "string", + "enum": [ + "unknown", + "fresh", + "stale" + ] + }, + "lastSuccessfulAt": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "sync": { + "$ref": "#/components/schemas/InvestmentSyncDto" + } + }, + "required": [ + "dataRevision", + "stateVersion", + "resourceKey", + "availability", + "freshness", + "lastSuccessfulAt", + "sync" + ] + }, + "InvestmentResourceMetaDto": { + "type": "object", + "properties": { + "dataRevision": { + "type": "string" + }, + "stateVersion": { + "type": "string" + }, + "unavailableReason": { + "type": "string" + }, + "coverage": { + "$ref": "#/components/schemas/InvestmentCoverageDto" + }, + "resourceKey": { + "type": "string" + }, + "availability": { + "type": "string", + "enum": [ + "missing", + "partial", + "complete" + ] + }, + "freshness": { + "type": "string", + "enum": [ + "unknown", + "fresh", + "stale" + ] + }, + "lastSuccessfulAt": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "sync": { + "$ref": "#/components/schemas/InvestmentSyncDto" + }, + "dependencies": { + "type": "array", + "items": { + "$ref": "#/components/schemas/InvestmentDependencyDto" + } + } + }, + "required": [ + "dataRevision", + "stateVersion", + "resourceKey", + "availability", + "freshness", + "lastSuccessfulAt", + "sync" + ] + }, + "BrokerageAccountsResourceDto": { + "type": "object", + "properties": { + "data": { + "nullable": true, + "type": "array", + "items": { + "$ref": "#/components/schemas/BrokerageAccountDto" + } + }, + "meta": { + "$ref": "#/components/schemas/InvestmentResourceMetaDto" + } + }, + "required": [ + "data", + "meta" + ] + }, "AccountReferenceDto": { "type": "object", "properties": { @@ -5909,6 +6307,27 @@ "structure" ] }, + "PortfolioResourceDto": { + "type": "object", + "properties": { + "data": { + "nullable": true, + "type": "object", + "allOf": [ + { + "$ref": "#/components/schemas/PortfolioDto" + } + ] + }, + "meta": { + "$ref": "#/components/schemas/InvestmentResourceMetaDto" + } + }, + "required": [ + "data", + "meta" + ] + }, "BrokerageOperationDto": { "type": "object", "properties": { @@ -6050,6 +6469,27 @@ "observedAt" ] }, + "BrokerageOperationsResourceDto": { + "type": "object", + "properties": { + "data": { + "nullable": true, + "type": "object", + "allOf": [ + { + "$ref": "#/components/schemas/BrokerageOperationsPageDto" + } + ] + }, + "meta": { + "$ref": "#/components/schemas/InvestmentResourceMetaDto" + } + }, + "required": [ + "data", + "meta" + ] + }, "IncomePeriodDto": { "type": "object", "properties": { @@ -6117,6 +6557,27 @@ "calculatedAt" ] }, + "IncomeSummaryResourceDto": { + "type": "object", + "properties": { + "data": { + "nullable": true, + "type": "object", + "allOf": [ + { + "$ref": "#/components/schemas/IncomeSummaryDto" + } + ] + }, + "meta": { + "$ref": "#/components/schemas/InvestmentResourceMetaDto" + } + }, + "required": [ + "data", + "meta" + ] + }, "IncomeComparisonCurrencyDto": { "type": "object", "properties": { @@ -6360,7 +6821,13 @@ "format": "date-time" }, "previous": { - "$ref": "#/components/schemas/IncomeComparisonDto" + "nullable": true, + "type": "object", + "allOf": [ + { + "$ref": "#/components/schemas/IncomeComparisonDto" + } + ] }, "yearComparison": { "nullable": true, @@ -6411,6 +6878,27 @@ "entries" ] }, + "IncomeAnalyticsResourceDto": { + "type": "object", + "properties": { + "data": { + "nullable": true, + "type": "object", + "allOf": [ + { + "$ref": "#/components/schemas/IncomeAnalyticsDto" + } + ] + }, + "meta": { + "$ref": "#/components/schemas/InvestmentResourceMetaDto" + } + }, + "required": [ + "data", + "meta" + ] + }, "UpcomingEventDto": { "type": "object", "properties": { @@ -6512,6 +7000,27 @@ "incomeTotalsByCurrency" ] }, + "UpcomingEventsResourceDto": { + "type": "object", + "properties": { + "data": { + "nullable": true, + "type": "object", + "allOf": [ + { + "$ref": "#/components/schemas/UpcomingEventsDto" + } + ] + }, + "meta": { + "$ref": "#/components/schemas/InvestmentResourceMetaDto" + } + }, + "required": [ + "data", + "meta" + ] + }, "StockSearchResultDto": { "type": "object", "properties": { @@ -6605,6 +7114,25 @@ "priceAt" ] }, + "StockSearchResourceDto": { + "type": "object", + "properties": { + "data": { + "nullable": true, + "type": "array", + "items": { + "$ref": "#/components/schemas/StockSearchResultDto" + } + }, + "meta": { + "$ref": "#/components/schemas/InvestmentResourceMetaDto" + } + }, + "required": [ + "data", + "meta" + ] + }, "StockDto": { "type": "object", "properties": { @@ -6682,6 +7210,27 @@ "historyFrom" ] }, + "StockResourceDto": { + "type": "object", + "properties": { + "data": { + "nullable": true, + "type": "object", + "allOf": [ + { + "$ref": "#/components/schemas/StockDto" + } + ] + }, + "meta": { + "$ref": "#/components/schemas/InvestmentResourceMetaDto" + } + }, + "required": [ + "data", + "meta" + ] + }, "StockQuoteDto": { "type": "object", "properties": { @@ -6753,6 +7302,27 @@ "observedAt" ] }, + "StockQuoteResourceDto": { + "type": "object", + "properties": { + "data": { + "nullable": true, + "type": "object", + "allOf": [ + { + "$ref": "#/components/schemas/StockQuoteDto" + } + ] + }, + "meta": { + "$ref": "#/components/schemas/InvestmentResourceMetaDto" + } + }, + "required": [ + "data", + "meta" + ] + }, "StockCandleDto": { "type": "object", "properties": { @@ -6803,6 +7373,27 @@ "observedAt" ] }, + "StockCandlesResourceDto": { + "type": "object", + "properties": { + "data": { + "nullable": true, + "type": "object", + "allOf": [ + { + "$ref": "#/components/schemas/StockCandlesDto" + } + ] + }, + "meta": { + "$ref": "#/components/schemas/InvestmentResourceMetaDto" + } + }, + "required": [ + "data", + "meta" + ] + }, "StockPositionRowDto": { "type": "object", "properties": { @@ -6957,6 +7548,27 @@ "observedAt" ] }, + "StockPositionResourceDto": { + "type": "object", + "properties": { + "data": { + "nullable": true, + "type": "object", + "allOf": [ + { + "$ref": "#/components/schemas/StockPositionDto" + } + ] + }, + "meta": { + "$ref": "#/components/schemas/InvestmentResourceMetaDto" + } + }, + "required": [ + "data", + "meta" + ] + }, "StockMetricDto": { "type": "object", "properties": { @@ -7026,6 +7638,27 @@ "observedAt" ] }, + "StockCompanyResourceDto": { + "type": "object", + "properties": { + "data": { + "nullable": true, + "type": "object", + "allOf": [ + { + "$ref": "#/components/schemas/StockCompanyDto" + } + ] + }, + "meta": { + "$ref": "#/components/schemas/InvestmentResourceMetaDto" + } + }, + "required": [ + "data", + "meta" + ] + }, "StockDividendDto": { "type": "object", "properties": { @@ -7123,6 +7756,27 @@ "positionIsStale" ] }, + "StockDividendsResourceDto": { + "type": "object", + "properties": { + "data": { + "nullable": true, + "type": "object", + "allOf": [ + { + "$ref": "#/components/schemas/StockDividendsDto" + } + ] + }, + "meta": { + "$ref": "#/components/schemas/InvestmentResourceMetaDto" + } + }, + "required": [ + "data", + "meta" + ] + }, "BondCreditRatingDto": { "type": "object", "properties": { @@ -7274,6 +7928,27 @@ "observedAt" ] }, + "BondMetricsResourceDto": { + "type": "object", + "properties": { + "data": { + "nullable": true, + "type": "object", + "allOf": [ + { + "$ref": "#/components/schemas/BondMetricsDto" + } + ] + }, + "meta": { + "$ref": "#/components/schemas/InvestmentResourceMetaDto" + } + }, + "required": [ + "data", + "meta" + ] + }, "BondSearchResultDto": { "type": "object", "properties": { @@ -7433,6 +8108,25 @@ "priceAt" ] }, + "BondSearchResourceDto": { + "type": "object", + "properties": { + "data": { + "nullable": true, + "type": "array", + "items": { + "$ref": "#/components/schemas/BondSearchResultDto" + } + }, + "meta": { + "$ref": "#/components/schemas/InvestmentResourceMetaDto" + } + }, + "required": [ + "data", + "meta" + ] + }, "BondDto": { "type": "object", "properties": { @@ -7580,6 +8274,27 @@ "subordinated" ] }, + "BondResourceDto": { + "type": "object", + "properties": { + "data": { + "nullable": true, + "type": "object", + "allOf": [ + { + "$ref": "#/components/schemas/BondDto" + } + ] + }, + "meta": { + "$ref": "#/components/schemas/InvestmentResourceMetaDto" + } + }, + "required": [ + "data", + "meta" + ] + }, "BondQuoteDto": { "type": "object", "properties": { @@ -7657,6 +8372,27 @@ "observedAt" ] }, + "BondQuoteResourceDto": { + "type": "object", + "properties": { + "data": { + "nullable": true, + "type": "object", + "allOf": [ + { + "$ref": "#/components/schemas/BondQuoteDto" + } + ] + }, + "meta": { + "$ref": "#/components/schemas/InvestmentResourceMetaDto" + } + }, + "required": [ + "data", + "meta" + ] + }, "BondCandlesDto": { "type": "object", "properties": { @@ -7687,6 +8423,27 @@ "observedAt" ] }, + "BondCandlesResourceDto": { + "type": "object", + "properties": { + "data": { + "nullable": true, + "type": "object", + "allOf": [ + { + "$ref": "#/components/schemas/BondCandlesDto" + } + ] + }, + "meta": { + "$ref": "#/components/schemas/InvestmentResourceMetaDto" + } + }, + "required": [ + "data", + "meta" + ] + }, "BondPositionDto": { "type": "object", "properties": { @@ -7749,6 +8506,27 @@ "observedAt" ] }, + "BondPositionResourceDto": { + "type": "object", + "properties": { + "data": { + "nullable": true, + "type": "object", + "allOf": [ + { + "$ref": "#/components/schemas/BondPositionDto" + } + ] + }, + "meta": { + "$ref": "#/components/schemas/InvestmentResourceMetaDto" + } + }, + "required": [ + "data", + "meta" + ] + }, "BondCouponDto": { "type": "object", "properties": { @@ -7849,6 +8627,27 @@ "positionIsStale" ] }, + "BondCouponsResourceDto": { + "type": "object", + "properties": { + "data": { + "nullable": true, + "type": "object", + "allOf": [ + { + "$ref": "#/components/schemas/BondCouponsDto" + } + ] + }, + "meta": { + "$ref": "#/components/schemas/InvestmentResourceMetaDto" + } + }, + "required": [ + "data", + "meta" + ] + }, "TbankConnectionStatusDto": { "type": "object", "properties": { @@ -7877,6 +8676,187 @@ "required": [ "token" ] + }, + "SyncCandleRangeDto": { + "type": "object", + "properties": { + "from": { + "type": "string", + "format": "date-time" + }, + "to": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "from", + "to" + ] + }, + "InvestmentScreenDto": { + "type": "object", + "properties": { + "screen": { + "type": "string", + "enum": [ + "accounts", + "account", + "operations", + "income", + "events", + "stocks", + "bonds", + "stock", + "bond" + ] + }, + "accountId": { + "type": "string" + }, + "from": { + "type": "string", + "format": "date-time" + }, + "to": { + "type": "string", + "format": "date-time" + }, + "instrumentUid": { + "type": "string", + "format": "uuid" + }, + "query": { + "type": "string" + }, + "candlePeriod": { + "type": "string", + "enum": [ + "month", + "halfYear", + "year", + "all" + ] + }, + "candleRange": { + "$ref": "#/components/schemas/SyncCandleRangeDto" + }, + "categories": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "trade", + "income", + "tax", + "fee", + "transfer", + "other" + ] + } + }, + "limit": { + "type": "number", + "minimum": 1, + "maximum": 1000 + } + }, + "required": [ + "screen" + ] + }, + "TbankSyncAcceptedDto": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "scope": { + "$ref": "#/components/schemas/InvestmentScreenDto" + }, + "now": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "id", + "scope", + "now" + ] + }, + "TbankSyncProgressDto": { + "type": "object", + "properties": { + "completed": { + "type": "number" + }, + "total": { + "type": "number" + }, + "discovering": { + "type": "boolean" + } + }, + "required": [ + "completed", + "total", + "discovering" + ] + }, + "TbankSyncStatusDto": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "scope": { + "nullable": true, + "type": "object", + "allOf": [ + { + "$ref": "#/components/schemas/InvestmentScreenDto" + } + ] + }, + "now": { + "type": "string", + "format": "date-time" + }, + "state": { + "type": "string", + "enum": [ + "running", + "succeeded", + "failed", + "cancelled" + ] + }, + "errorCode": { + "type": "string", + "nullable": true + }, + "progress": { + "$ref": "#/components/schemas/TbankSyncProgressDto" + }, + "resources": { + "type": "array", + "items": { + "$ref": "#/components/schemas/InvestmentResourceMetaDto" + } + } + }, + "required": [ + "id", + "scope", + "now", + "state", + "errorCode", + "progress", + "resources" + ] } } } diff --git a/src/shared/i18n/locales/ru.ts b/src/shared/i18n/locales/ru.ts index b46010d..ef1eb68 100644 --- a/src/shared/i18n/locales/ru.ts +++ b/src/shared/i18n/locales/ru.ts @@ -1,5 +1,14 @@ const ru = { translation: { + investment: { + refresh: "Обновить", + stale: "Данные требуют обновления", + syncFailed: + "Источник не удалось обновить. Сохранённые данные не изменены.", + partial: "Период загружен не полностью", + history: "История догружается", + sourceTime: "Данные на {{date}}", + }, appName: "WealthLedger", navigation: { cards: "Карты", @@ -623,6 +632,10 @@ const ru = { taxBoundary: "Налоговые операции не включены в этот результат.", comparisonEmpty: "За эти периоды купоны и дивиденды не найдены.", yearAgo: "Годом ранее", + previousPeriod: "Предыдущий период", + comparisonPending: "Период сравнения ещё не загружен полностью.", + comparisonFailed: + "Не удалось загрузить период сравнения. Повторите обновление.", yearUnavailable: "Сравнение с годом ранее недоступно.", difference: "Разница", total: "Итого", diff --git a/src/test/handlers.ts b/src/test/handlers.ts index 22db84a..ef65772 100644 --- a/src/test/handlers.ts +++ b/src/test/handlers.ts @@ -1,4 +1,5 @@ import { HttpResponse, http } from "msw"; +import { resource } from "@/test/resource"; const authentication = { accessToken: "test-token", @@ -26,40 +27,50 @@ const handlers = [ "*/api/v1/tbank-connection", () => new HttpResponse(null, { status: 204 }), ), - http.get("*/api/v1/brokerage-accounts", () => HttpResponse.json([])), + http.get("*/api/v1/brokerage-accounts", () => + HttpResponse.json(resource([])), + ), http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () => - HttpResponse.json({ - lastSuccessfulAt: "2026-08-14T00:00:00Z", - isStale: false, - totalsByCurrency: [], - positions: [], - structure: { currencies: [], unvaluedCategories: [] }, - cashBalances: [], - }), + HttpResponse.json( + resource({ + lastSuccessfulAt: "2026-08-14T00:00:00Z", + isStale: false, + totalsByCurrency: [], + positions: [], + structure: { currencies: [], unvaluedCategories: [] }, + cashBalances: [], + }), + ), ), http.get("*/api/v1/brokerage-accounts/:accountId/operations", () => - HttpResponse.json({ - accountId: "account-1", - items: [], - nextCursor: null, - hasNext: false, - observedAt: "2026-08-14T00:00:00Z", - }), + HttpResponse.json( + resource({ + accountId: "account-1", + items: [], + nextCursor: null, + hasNext: false, + observedAt: "2026-08-14T00:00:00Z", + }), + ), ), http.get("*/api/v1/brokerage-accounts/:accountId/events", () => - HttpResponse.json({ - accountId: "account-1", - calculatedAt: "2026-08-14T00:00:00Z", - events: [], - }), + HttpResponse.json( + resource({ + accountId: "account-1", + calculatedAt: "2026-08-14T00:00:00Z", + events: [], + }), + ), ), http.get("*/api/v1/brokerage-accounts/:accountId/income-summary", () => - HttpResponse.json({ - accountId: "account-1", - period: { from: "2026-01-01", to: "2026-08-14" }, - totalsByCurrency: [], - calculatedAt: "2026-08-14T00:00:00Z", - }), + HttpResponse.json( + resource({ + accountId: "account-1", + period: { from: "2026-01-01", to: "2026-08-14" }, + totalsByCurrency: [], + calculatedAt: "2026-08-14T00:00:00Z", + }), + ), ), ]; diff --git a/src/test/resource.ts b/src/test/resource.ts new file mode 100644 index 0000000..2ee795a --- /dev/null +++ b/src/test/resource.ts @@ -0,0 +1,21 @@ +import type { paths } from "@/shared/api"; + +type Meta = + paths["/api/v1/brokerage-accounts"]["get"]["responses"][200]["content"]["application/json"]["meta"]; +function resource(data: T, meta: Partial = {}) { + return { + data, + meta: { + resourceKey: "fixture", + dataRevision: "1", + stateVersion: "1", + availability: data === null ? "missing" : "complete", + freshness: "fresh", + lastSuccessfulAt: "2026-08-14T00:00:00Z", + sync: { id: null, state: "idle", errorCode: null }, + ...meta, + } satisfies Meta, + }; +} + +export { resource }; -- 2.54.0