fix(account): show source metadata on empty account and income (WealthLedger/frontend#55) #133

Merged
codex-bot merged 1 commits from fix/55-investment-source-metadata into develop 2026-11-03 06:44:14 +03:00
12 changed files with 131 additions and 33 deletions
@@ -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(
@@ -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",
};
@@ -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<LoadedProps> = (props) => {
/>
</FormProvider>
<SourceSyncStatus
lastAttempt={props.source?.lastAttempt}
lastSuccessfulAt={props.source?.lastSuccessfulAt}
stale={props.source?.freshness === "stale"}
/>
<AccountIncomePageLoadedSummary {...props} />
<Alert color="blue">{t("income.taxBoundary")}</Alert>
@@ -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();
@@ -18,6 +18,7 @@ const AccountPage: FC = () => {
incomeSummary={viewModel.incomeSummary}
recentEvents={viewModel.recentEvents}
recentOperations={viewModel.recentOperations}
source={viewModel.source}
/>
);
@@ -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,
};
@@ -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<AccountPageEmptyProps> = (props) => {
@@ -40,6 +43,12 @@ const AccountPageEmpty: FC<AccountPageEmptyProps> = (props) => {
<Stack>
<AccountHeading account={props.account} accountId={props.accountId} />
<SourceSyncStatus
lastAttempt={props.source?.lastAttempt}
lastSuccessfulAt={props.source?.lastSuccessfulAt}
stale={props.source?.freshness === "stale"}
/>
<StateView text={text} title={t("portfolio.emptyTitle")} />
<PortfolioStructure
+8 -7
View File
@@ -3,7 +3,12 @@ import createQueryClient from "openapi-react-query";
import type { paths } from "./generated/schema";
import { ApiErrorCode, apiErrorCode, isApiErrorCode } from "./problem-details";
import {
ApiErrorCode,
apiErrorCode,
isApiErrorCode,
isBrokerageAccessLoss,
} from "./problem-details";
const HTTP_UNAUTHORIZED = 401;
const HTTP_REQUEST_TIMEOUT = 408;
@@ -165,12 +170,8 @@ function isNetworkFailure(error: unknown): boolean {
}
function isPermanentCode(code?: string): boolean {
return [
ApiErrorCode.InvalidTbankToken,
ApiErrorCode.NotFound,
ApiErrorCode.TbankNotConnected,
ApiErrorCode.Unauthorized,
].some(permanent => isApiErrorCode(code, permanent));
return isBrokerageAccessLoss(code)
|| isApiErrorCode(code, ApiErrorCode.Unauthorized);
}
function isTransientReadFailure(
+6 -1
View File
@@ -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,
+13 -1
View File
@@ -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);
});
+11 -1
View File
@@ -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 };
+2 -10
View File
@@ -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<string, unknown> }) =>