diff --git a/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.test.tsx b/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.test.tsx index c1e8038..bcd6ce8 100644 --- a/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.test.tsx +++ b/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.test.tsx @@ -26,6 +26,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import type { IncomeAnalytics } from "@/entities/income"; import { configureAuthClient } from "@/shared/api"; +import { formatSnapshotTime } from "@/shared/lib"; import { createTestQueryClient } from "@/test/query"; import { resource } from "@/test/resource"; import { server } from "@/test/server"; @@ -403,6 +404,35 @@ describe("AccountIncomePage", () => { expect(screen.queryByText(/Сравнение с/)).not.toBeInTheDocument(); }); + it.each([ + ["full", analytics], + ["empty", { ...analytics, buckets: [], entries: [], instruments: [], totalsByCurrency: [] }], + ])("shows the server source time and last attempt for a %s result", async (_name, data) => { + server.use( + http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () => + HttpResponse.json( + resource(data, { + freshness: "stale", + lastAttempt: { + finishedAt: "2026-08-27T01:00:00Z", + problems: [], + startedAt: "2026-08-27T00:59:00Z", + state: "failed", + }, + lastSuccessfulAt: "2026-08-26T12:00:00Z", + }), + ), + ), + ); + renderPage(); + expect(await screen.findByText(/^Данные на /)).toHaveTextContent( + formatSnapshotTime("2026-08-26T12:00:00Z"), + ); + expect(screen.getByText("Обновление не удалось")).toBeInTheDocument(); + expect(screen.getByText("Данные требуют обновления")).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Повторить" })).not.toBeInTheDocument(); + }); + it("shows a quiet realized error", async () => { server.use( http.get( diff --git a/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.vm.ts b/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.vm.ts index 4c1d679..f22540d 100644 --- a/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.vm.ts +++ b/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.vm.ts @@ -3,6 +3,8 @@ import { useEffect, useMemo, useState } from "react"; import { useForm } from "react-hook-form"; import { useParams, useSearchParams } from "react-router"; +import type { InvestmentResourceMeta } from "@/shared/api"; + import { type IncomeAnalytics, useIncomeAnalytics } from "@/entities/income"; import { useScreenSync } from "@/features/tbank-connection"; import { isValidPeriod, moscowDate, useInitialAvailability } from "@/shared/lib"; @@ -30,6 +32,7 @@ type AccountIncomePageViewModel onReset: () => void; onSubmit: (values: IncomeForm) => void; partial?: boolean; + source?: InvestmentResourceMeta; state: "loaded"; totalVisible: boolean; } @@ -116,6 +119,7 @@ const useAccountIncomePageViewModel = (): AccountIncomePageViewModel => { onReset, onSubmit, partial: analytics.meta?.completeness === "incomplete", + source: analytics.meta, state: "loaded", totalVisible: analytics.meta?.completeness === "complete", }; 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 33fbcb8..b2996e7 100644 --- a/src/pages/account-income/ui/AccountIncomePage/components/AccountIncomePageLoaded.view.tsx +++ b/src/pages/account-income/ui/AccountIncomePage/components/AccountIncomePageLoaded.view.tsx @@ -9,6 +9,8 @@ import { StateView } from "@wealthledger/ui"; import { FormProvider } from "react-hook-form"; import { useTranslation } from "react-i18next"; +import { SourceSyncStatus } from "@/shared/ui"; + import type { AccountIncomePageViewModel } from "../AccountIncomePage.vm"; import classes from "../AccountIncomePage.module.css"; @@ -49,6 +51,12 @@ const AccountIncomePageLoaded: FC = (props) => { /> + + {t("income.taxBoundary")} diff --git a/src/pages/account/ui/AccountPage/AccountPage.test.tsx b/src/pages/account/ui/AccountPage/AccountPage.test.tsx index 296a08a..37cbacf 100644 --- a/src/pages/account/ui/AccountPage/AccountPage.test.tsx +++ b/src/pages/account/ui/AccountPage/AccountPage.test.tsx @@ -18,6 +18,7 @@ import type { BrokerageEvent } from "@/entities/brokerage-event"; import { type Portfolio, portfolioQueryOptions } from "@/entities/portfolio"; import { configureAuthClient } from "@/shared/api"; +import { formatSnapshotTime } from "@/shared/lib"; import { createTestQueryClient } from "@/test/query"; import { resource } from "@/test/resource"; import { server } from "@/test/server"; @@ -231,6 +232,37 @@ describe("AccountPage states", () => { .toHaveAccessibleDescription(/Акция: 100 ₽, 100 %/); }); + it("shows server source time and attempt for an empty account", async () => { + server.use( + http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () => + HttpResponse.json(resource( + { + ...portfolio, + cashBalances: [], + isStale: false, + positions: [], + totalsByCurrency: [], + }, + { + lastAttempt: { + finishedAt: "2026-08-14T01:00:00Z", + problems: [], + startedAt: "2026-08-14T00:59:00Z", + state: "failed", + }, + lastSuccessfulAt: "2026-08-13T12:00:00Z", + }, + )), + ), + ); + renderAccountRoute(createTestQueryClient()); + expect(await screen.findByText(/^Данные на /)).toHaveTextContent( + formatSnapshotTime("2026-08-13T12:00:00Z"), + ); + expect(screen.getByText("Обновление не удалось")).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Повторить" })).not.toBeInTheDocument(); + }); + it("expands and resets other when switching slice", () => { const positions = [400, 250, 150, 100, 50, 30, 20].map( (value, index) => position({ @@ -874,7 +906,6 @@ describe("AccountPage states", () => { ).toBeInTheDocument(); expect(screen.queryByText(/T-Bank временно недоступен/)) .not.toBeInTheDocument(); - expect(screen.queryByText(/Данные получены/)).not.toBeInTheDocument(); expect(screen.getByRole("radio", { name: "Акции" })).toBeInTheDocument(); expect( screen.getByRole("radio", { name: "Облигации" }), @@ -1276,7 +1307,7 @@ describe("AccountPage states", () => { }); expect(structure).toHaveTextContent("10 ₽"); - expect(screen.queryByText(/Данные получены/)).not.toBeInTheDocument(); + expect(screen.getByText(/^Данные на /)).toBeInTheDocument(); void queryClient.invalidateQueries({ queryKey: portfolioQueryOptions("account-1").queryKey, }); @@ -1320,7 +1351,7 @@ describe("AccountPage states", () => { screen.getAllByRole("definition").map(node => node.textContent), ).toEqual(["-0,5 $"]), ); - expect(screen.queryByText(/Данные получены/)).not.toBeInTheDocument(); + expect(screen.getByText(/^Данные на /)).toHaveTextContent("Данные на"); expect(structure).not.toHaveTextContent("10 ₽"); expect(structure).toHaveTextContent("Нет положительной стоимости"); expect( @@ -1361,7 +1392,7 @@ describe("AccountPage states", () => { expect( screen.getAllByRole("definition").map(node => node.textContent), ).toEqual(["1 234,5 ₽"]); - expect(screen.queryByText(/Данные получены/)).not.toBeInTheDocument(); + expect(screen.getByText(/^Данные на /)).toBeInTheDocument(); expect( screen.queryByRole("status", { name: "Загрузка" }), ).not.toBeInTheDocument(); diff --git a/src/pages/account/ui/AccountPage/AccountPage.tsx b/src/pages/account/ui/AccountPage/AccountPage.tsx index e742f7f..f9809bc 100644 --- a/src/pages/account/ui/AccountPage/AccountPage.tsx +++ b/src/pages/account/ui/AccountPage/AccountPage.tsx @@ -18,6 +18,7 @@ const AccountPage: FC = () => { incomeSummary={viewModel.incomeSummary} recentEvents={viewModel.recentEvents} recentOperations={viewModel.recentOperations} + source={viewModel.source} /> ); diff --git a/src/pages/account/ui/AccountPage/AccountPage.vm.ts b/src/pages/account/ui/AccountPage/AccountPage.vm.ts index 5ecf856..622071a 100644 --- a/src/pages/account/ui/AccountPage/AccountPage.vm.ts +++ b/src/pages/account/ui/AccountPage/AccountPage.vm.ts @@ -16,7 +16,7 @@ import { import { useIncomeSummary } from "@/entities/income"; import { usePortfolio } from "@/entities/portfolio"; import { useScreenSync } from "@/features/tbank-connection"; -import { ApiErrorCode, apiErrorCode, isApiErrorCode } from "@/shared/api"; +import { apiErrorCode, isBrokerageAccessLoss } from "@/shared/api"; import { useInitialAvailability } from "@/shared/lib"; import { @@ -47,6 +47,7 @@ type AccountPageContent = { partial?: boolean; recentEvents: AccountPageRecentEvents; recentOperations: AccountPageRecentOperations; + source?: InvestmentResourceMeta; }; type AccountPageEmptyViewModel = AccountPageContent & { @@ -62,7 +63,6 @@ type AccountPageLoadedViewModel = AccountPageContent & { portfolio: Portfolio; positionSections: AccountPagePositionSection[]; slice: Slice; - source?: InvestmentResourceMeta; state: "loaded"; tab: string; unrealizedReturns: Portfolio["totalsByCurrency"]; @@ -74,12 +74,6 @@ type AccountPageViewModel | { code?: string; state: "error" } | { state: "loading" }; -function isPortfolioAccessFailure(code?: string) { - return isApiErrorCode(code, ApiErrorCode.InvalidTbankToken) - || isApiErrorCode(code, ApiErrorCode.NotFound) - || isApiErrorCode(code, ApiErrorCode.TbankNotConnected); -} - function useAccountPageViewModel(): AccountPageViewModel { const { accountId } = useParams(); const { t } = useTranslation(); @@ -119,7 +113,7 @@ function useAccountPageViewModel(): AccountPageViewModel { ]); const code = apiErrorCode(portfolio.error); - const isAccessFailure = isPortfolioAccessFailure(code); + const isAccessFailure = isBrokerageAccessLoss(code); const availability = useInitialAvailability({ hasContent: !!portfolio.data, @@ -152,6 +146,7 @@ function useAccountPageViewModel(): AccountPageViewModel { incomeSummary, recentEvents, recentOperations, + source: portfolio.meta, state: "empty" as const, }; diff --git a/src/pages/account/ui/AccountPage/components/AccountPageEmpty.view.tsx b/src/pages/account/ui/AccountPage/components/AccountPageEmpty.view.tsx index 5e336a9..510c88c 100644 --- a/src/pages/account/ui/AccountPage/components/AccountPageEmpty.view.tsx +++ b/src/pages/account/ui/AccountPage/components/AccountPageEmpty.view.tsx @@ -5,8 +5,10 @@ import { StateView } from "@wealthledger/ui"; import { useTranslation } from "react-i18next"; import type { BrokerageAccount } from "@/entities/brokerage-account"; +import type { InvestmentResourceMeta } from "@/shared/api"; import { BrokerageEventsPreview } from "@/entities/brokerage-event"; +import { SourceSyncStatus } from "@/shared/ui"; import { PortfolioStructure } from "@/widgets/portfolio-structure"; import type { @@ -25,6 +27,7 @@ type AccountPageEmptyProps = { incomeSummary?: AccountPageIncomeSummary; recentEvents: AccountPageRecentEvents; recentOperations: AccountPageRecentOperations; + source?: InvestmentResourceMeta; }; const AccountPageEmpty: FC = (props) => { @@ -40,6 +43,12 @@ const AccountPageEmpty: FC = (props) => { + + isApiErrorCode(code, permanent)); + return isBrokerageAccessLoss(code) + || isApiErrorCode(code, ApiErrorCode.Unauthorized); } function isTransientReadFailure( diff --git a/src/shared/api/index.ts b/src/shared/api/index.ts index faea056..bbd54da 100644 --- a/src/shared/api/index.ts +++ b/src/shared/api/index.ts @@ -7,7 +7,12 @@ export { configureAuthClient, } from "./client"; export type { paths } from "./generated/schema"; -export { ApiErrorCode, apiErrorCode, isApiErrorCode } from "./problem-details"; +export { + ApiErrorCode, + apiErrorCode, + isApiErrorCode, + isBrokerageAccessLoss, +} from "./problem-details"; export { type InvestmentResourceMeta, diff --git a/src/shared/api/problem-details.test.ts b/src/shared/api/problem-details.test.ts index 0c594e3..afc9c8e 100644 --- a/src/shared/api/problem-details.test.ts +++ b/src/shared/api/problem-details.test.ts @@ -1,6 +1,11 @@ import { expect, it } from "vitest"; -import { ApiErrorCode, apiErrorCode, isApiErrorCode } from "./problem-details"; +import { + ApiErrorCode, + apiErrorCode, + isApiErrorCode, + isBrokerageAccessLoss, +} from "./problem-details"; it("matches known codes and preserves unknown wire codes for fallback handling", () => { expect( @@ -16,3 +21,10 @@ it("matches known codes and preserves unknown wire codes for fallback handling", expect(apiErrorCode(null)).toBeUndefined(); expect(apiErrorCode({})).toBeUndefined(); }); + +it("separates brokerage access loss from UNAUTHORIZED, temporary and unknown codes", () => { + for (const code of ["INVALID_TBANK_TOKEN", "NOT_FOUND", "TBANK_NOT_CONNECTED"]) + expect(isBrokerageAccessLoss(code)).toBe(true); + for (const code of ["UNAUTHORIZED", "TBANK_UNAVAILABLE", "FUTURE_PROVIDER_ERROR", undefined]) + expect(isBrokerageAccessLoss(code)).toBe(false); +}); diff --git a/src/shared/api/problem-details.ts b/src/shared/api/problem-details.ts index bcea7d5..8d10712 100644 --- a/src/shared/api/problem-details.ts +++ b/src/shared/api/problem-details.ts @@ -41,4 +41,14 @@ function isApiErrorCode( return value === expected; } -export { ApiErrorCode, apiErrorCode, isApiErrorCode }; +// Confirmed loss of access to the brokerage data. UNAUTHORIZED is a session +// failure with its own refresh/sign-out path, so it is deliberately excluded. +function isBrokerageAccessLoss(code?: string): boolean { + return [ + ApiErrorCode.InvalidTbankToken, + ApiErrorCode.NotFound, + ApiErrorCode.TbankNotConnected, + ].some(loss => isApiErrorCode(code, loss)); +} + +export { ApiErrorCode, apiErrorCode, isApiErrorCode, isBrokerageAccessLoss }; diff --git a/src/shared/api/resource-query.ts b/src/shared/api/resource-query.ts index ca6ad06..46e77e7 100644 --- a/src/shared/api/resource-query.ts +++ b/src/shared/api/resource-query.ts @@ -10,7 +10,7 @@ import type { components, paths } from "./generated/schema"; import { useAuthenticatedQuery } from "./authenticated-query"; import { getAuthenticatedSessionSnapshot } from "./client"; -import { ApiErrorCode, apiErrorCode, isApiErrorCode } from "./problem-details"; +import { apiErrorCode, isBrokerageAccessLoss } from "./problem-details"; type InvestmentResourceMeta = components["schemas"]["InvestmentResourceMetaDto"]; @@ -39,15 +39,7 @@ function invalidateResourceAccess( group: string, error: unknown, ) { - const errorCode = apiErrorCode(error); - if ( - [ - ApiErrorCode.InvalidTbankToken, - ApiErrorCode.NotFound, - ApiErrorCode.TbankNotConnected, - ].every(code => !isApiErrorCode(errorCode, code)) - ) - return; + if (!isBrokerageAccessLoss(apiErrorCode(error))) return; const affected = { predicate: (cached: { meta?: Record }) =>