From 9e42148f6a9ad620b4fc9f6aef9ee2d80d73702b Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Sun, 6 Sep 2026 19:49:02 +0300 Subject: [PATCH] fix(frontend): complete audit remediation --- .../api/useBrokerageEvents.test.tsx | 16 ++ src/features/auth/model/auth.test.tsx | 23 +- .../ui/TbankConnectionSection.test.tsx | 7 +- .../ui/AccountEventsPage.test.tsx | 33 ++- .../account-events/ui/AccountEventsPage.tsx | 17 +- .../account-events/ui/AccountEventsPage.vm.ts | 23 ++ .../ui/AccountIncomePage.test.tsx | 225 +++++++++++++++++- .../account-income/ui/AccountIncomePage.vm.ts | 53 +++-- .../ui/components/IncomeBreakdown.view.tsx | 49 ++-- src/shared/api/generated/schema.d.ts | 22 +- src/shared/api/spec/openapi.json | 112 ++++++++- 11 files changed, 508 insertions(+), 72 deletions(-) create mode 100644 src/pages/account-events/ui/AccountEventsPage.vm.ts diff --git a/src/entities/brokerage-event/api/useBrokerageEvents.test.tsx b/src/entities/brokerage-event/api/useBrokerageEvents.test.tsx index 76f74cc..b222bd4 100644 --- a/src/entities/brokerage-event/api/useBrokerageEvents.test.tsx +++ b/src/entities/brokerage-event/api/useBrokerageEvents.test.tsx @@ -7,6 +7,22 @@ import { server } from "@/test/server"; import { useBrokerageEvents } from "./useBrokerageEvents"; describe("useBrokerageEvents", () => { + it("does not fetch events without an account ID", async () => { + configureAuthClient({ + accessToken: () => "token", + refresh: async () => true, + clear: vi.fn(), + }); + const requested = vi.fn(() => HttpResponse.json({ events: [] })); + server.use( + http.get("*/api/v1/brokerage-accounts/:accountId/events", requested), + ); + const { result } = renderHook(() => useBrokerageEvents(undefined), { + wrapper: createQueryWrapper().wrapper, + }); + await waitFor(() => expect(result.current.fetchStatus).toBe("idle")); + expect(requested).not.toHaveBeenCalled(); + }); it("loads future cash events through the authenticated query seam", async () => { configureAuthClient({ accessToken: () => "token", diff --git a/src/features/auth/model/auth.test.tsx b/src/features/auth/model/auth.test.tsx index 15d39e1..67233ed 100644 --- a/src/features/auth/model/auth.test.tsx +++ b/src/features/auth/model/auth.test.tsx @@ -1,9 +1,10 @@ -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { QueryClientProvider, useQueryClient } from "@tanstack/react-query"; import { act, renderHook, waitFor } from "@testing-library/react"; import { HttpResponse, http } from "msw"; import type { FC, PropsWithChildren } from "react"; import { describe, expect, it } from "vitest"; import { apiClient, apiQueryClient } from "@/shared/api"; +import { createTestQueryClient } from "@/test/query"; import { server } from "@/test/server"; import { AuthProvider, useAuth } from "./auth"; @@ -22,26 +23,36 @@ describe("AuthProvider", () => { return HttpResponse.json([]); }), ); + const queryClient = createTestQueryClient(); const wrapper: FC = (props) => { const { children } = props; return ( - + {children} ); }; - const { result } = renderHook(() => useAuth(), { wrapper }); + const { result, rerender } = renderHook( + () => ({ ...useAuth(), queryClient: useQueryClient() }), + { wrapper }, + ); await waitFor(() => expect(result.current.ready).toBe(true)); await apiClient.GET("/api/v1/brokerage-accounts"); expect(authorization).toBe("Bearer restored-token"); + queryClient.setQueryData(["rerender-proof"], { value: "preserved" }); + rerender(); + expect(result.current.queryClient).toBe(queryClient); + expect(result.current.queryClient.getQueryData(["rerender-proof"])).toEqual( + { value: "preserved" }, + ); }); it("settles unauthenticated and clears cached data when cold-start refresh has a transport error", async () => { server.use(http.post("*/api/v1/auth/refresh", () => HttpResponse.error())); - const queryClient = new QueryClient(); + const queryClient = createTestQueryClient(); queryClient.setQueryData(["cached-query"], { value: "stale" }); const wrapper: FC = (props) => { const { children } = props; @@ -77,7 +88,7 @@ describe("AuthProvider", () => { HttpResponse.json({ code: "UNAUTHORIZED" }, { status: 401 }), ), ); - const queryClient = new QueryClient(); + const queryClient = createTestQueryClient(); queryClient.setQueryData(["cached-query"], { value: "stale" }); const wrapper: FC = (props) => { const { children } = props; @@ -110,7 +121,7 @@ describe("AuthProvider", () => { }), ), ); - const queryClient = new QueryClient(); + const queryClient = createTestQueryClient(); const statusKey = apiQueryClient.queryOptions( "get", "/api/v1/tbank-connection", diff --git a/src/features/tbank-connection/ui/TbankConnectionSection.test.tsx b/src/features/tbank-connection/ui/TbankConnectionSection.test.tsx index 9712736..0234866 100644 --- a/src/features/tbank-connection/ui/TbankConnectionSection.test.tsx +++ b/src/features/tbank-connection/ui/TbankConnectionSection.test.tsx @@ -1,9 +1,10 @@ import { MantineProvider } from "@mantine/core"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { type QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { HttpResponse, http } from "msw"; import { describe, expect, it, vi } from "vitest"; import { apiQueryClient, configureAuthClient } from "@/shared/api"; +import { createTestQueryClient } from "@/test/query"; import { server } from "@/test/server"; import { TbankConnectionSection } from "./TbankConnectionSection"; @@ -42,9 +43,7 @@ function renderSection(client: QueryClient) { } function seededClient() { - const client = new QueryClient({ - defaultOptions: { queries: { retry: false } }, - }); + const client = createTestQueryClient(); client.setQueryData(brokerageAccountsQueryKey, [ { id: "account-1", diff --git a/src/pages/account-events/ui/AccountEventsPage.test.tsx b/src/pages/account-events/ui/AccountEventsPage.test.tsx index 4551e86..aab036d 100644 --- a/src/pages/account-events/ui/AccountEventsPage.test.tsx +++ b/src/pages/account-events/ui/AccountEventsPage.test.tsx @@ -1,6 +1,6 @@ import { MantineProvider } from "@mantine/core"; import { render, screen } from "@testing-library/react"; -import { MemoryRouter } from "react-router"; +import { MemoryRouter, Route, Routes } from "react-router"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { useBrokerageEvents } from "@/entities/brokerage-event"; import { AccountEventsPage } from "./AccountEventsPage"; @@ -35,6 +35,37 @@ describe("AccountEventsPage", () => { } as never); }); + it("passes the selected route account to the query", () => { + render( + + + + } + /> + + + , + ); + expect(query).toHaveBeenCalledWith("selected-account"); + }); + + it("keeps the loading fallback when no result or account is available", () => { + query.mockReturnValue({ + data: undefined, + isError: false, + isLoading: false, + refetch: vi.fn(), + } as never); + renderPage(); + expect(query).toHaveBeenCalledWith(undefined); + expect(screen.getByRole("status", { name: "Загрузка" })).toHaveAttribute( + "aria-busy", + "true", + ); + }); + it("renders events and marks an offer without an amount", () => { query.mockReturnValue({ data: { diff --git a/src/pages/account-events/ui/AccountEventsPage.tsx b/src/pages/account-events/ui/AccountEventsPage.tsx index abdbeb0..44a140b 100644 --- a/src/pages/account-events/ui/AccountEventsPage.tsx +++ b/src/pages/account-events/ui/AccountEventsPage.tsx @@ -1,20 +1,17 @@ import type { FC } from "react"; -import { useParams } from "react-router"; -import { useBrokerageEvents } from "@/entities/brokerage-event"; +import { useAccountEventsPageViewModel } from "./AccountEventsPage.vm"; import { AccountEventsPageEmpty } from "./components/AccountEventsPageEmpty.view"; import { AccountEventsPageError } from "./components/AccountEventsPageError.view"; import { AccountEventsPageLoaded } from "./components/AccountEventsPageLoaded.view"; import { AccountEventsPageLoading } from "./components/AccountEventsPageLoading.view"; const AccountEventsPage: FC = () => { - const { accountId } = useParams(); - const events = useBrokerageEvents(accountId); - if (events.isLoading) return ; - if (events.isError) - return events.refetch()} />; - if (!events.data) return ; - if (!events.data.events.length) return ; - return ; + const viewModel = useAccountEventsPageViewModel(); + if (viewModel.state === "loading") return ; + if (viewModel.state === "error") + return ; + if (viewModel.state === "empty") return ; + return ; }; export { AccountEventsPage }; diff --git a/src/pages/account-events/ui/AccountEventsPage.vm.ts b/src/pages/account-events/ui/AccountEventsPage.vm.ts new file mode 100644 index 0000000..aeb0ef4 --- /dev/null +++ b/src/pages/account-events/ui/AccountEventsPage.vm.ts @@ -0,0 +1,23 @@ +import { useParams } from "react-router"; +import { + type BrokerageEvent, + useBrokerageEvents, +} from "@/entities/brokerage-event"; + +type AccountEventsPageViewModel = + | { state: "loading" } + | { state: "empty" } + | { state: "error"; retry: () => void } + | { state: "loaded"; events: BrokerageEvent[] }; + +function useAccountEventsPageViewModel(): AccountEventsPageViewModel { + const { accountId } = useParams(); + const events = useBrokerageEvents(accountId); + if (events.isLoading) return { state: "loading" }; + if (events.isError) return { state: "error", retry: () => events.refetch() }; + if (!events.data) return { state: "loading" }; + if (!events.data.events.length) return { state: "empty" }; + return { state: "loaded", events: events.data.events }; +} + +export { useAccountEventsPageViewModel }; diff --git a/src/pages/account-income/ui/AccountIncomePage.test.tsx b/src/pages/account-income/ui/AccountIncomePage.test.tsx index d610875..fa6daec 100644 --- a/src/pages/account-income/ui/AccountIncomePage.test.tsx +++ b/src/pages/account-income/ui/AccountIncomePage.test.tsx @@ -1,5 +1,11 @@ import { MantineProvider } from "@mantine/core"; -import { render, screen, waitFor } from "@testing-library/react"; +import { + fireEvent, + render, + screen, + waitFor, + within, +} from "@testing-library/react"; import { HttpResponse, http } from "msw"; import { MemoryRouter, Route, Routes } from "react-router"; import { describe, expect, it, vi } from "vitest"; @@ -9,6 +15,7 @@ import { createQueryWrapper } from "@/test/query"; import { server } from "@/test/server"; import { AccountIncomePage } from "./AccountIncomePage"; import { isDate, toIncomeDisplayModel } from "./AccountIncomePage.vm"; +import { IncomeBreakdown } from "./components/IncomeBreakdown.view"; const analytics = { accountId: "account-1", @@ -29,7 +36,7 @@ const analytics = { ], instruments: [ { - key: "figi:FIGI:RUB", + key: "income-group-opaque-v2", figi: "FIGI", instrumentUid: null, ticker: "BOND", @@ -38,10 +45,18 @@ const analytics = { coupon: money("RUB", "10"), dividend: money("RUB", "2"), total: money("RUB", "12"), - receiptCount: 1, + receiptCount: 2, + }, + ], + entries: [ + entry(), + { + ...entry(), + id: "dividend-1", + type: "OPERATION_TYPE_DIVIDEND", + payment: money("RUB", "2"), }, ], - entries: [entry()], } satisfies IncomeAnalytics; function money(currency: string, value: string) { @@ -69,6 +84,7 @@ function comparison(currency: string) { function entry(): IncomeAnalytics["entries"][number] { return { id: "coupon-1", + instrumentKey: "income-group-opaque-v2", occurredAt: "2026-08-01T12:00:00Z", type: "OPERATION_TYPE_COUPON", category: "income", @@ -118,6 +134,201 @@ function renderPage() { } describe("AccountIncomePage", () => { + it("preserves every repeated-ID payment when an open drill-down updates", async () => { + const first = { ...entry(), id: "same-id", description: "Выплата A" }; + const second = { + ...entry(), + id: "same-id", + description: "Выплата B", + payment: money("RUB", "-2"), + }; + const third = { + ...entry(), + id: "other-id", + description: "Выплата C", + payment: money("RUB", "1"), + }; + function breakdown(entries: IncomeAnalytics["entries"], total: string) { + const display = toIncomeDisplayModel({ + ...analytics, + instruments: [ + { + ...analytics.instruments[0], + coupon: money("RUB", total), + dividend: money("RUB", "0"), + total: money("RUB", total), + receiptCount: entries.length, + }, + ], + entries, + }); + expect(display).not.toBeNull(); + return ( + + + + ); + } + const { rerender } = render(breakdown([first, second, third], "9")); + fireEvent.click(screen.getByRole("button", { name: "BOND · 9 ₽ · 3" })); + const panel = await screen.findByRole("region", { name: "BOND · 9 ₽ · 3" }); + const payments = () => + within(panel) + .getAllByText(/^Выплата [ABC]$/) + .map((element) => element.textContent); + expect(payments()).toEqual(["Выплата A", "Выплата B", "Выплата C"]); + + rerender(breakdown([third, first, second], "9")); + expect(payments()).toEqual(["Выплата C", "Выплата A", "Выплата B"]); + expect(within(panel).getByText("−2 ₽")).toBeInTheDocument(); + + rerender(breakdown([second, second, first], "6")); + expect(payments()).toEqual(["Выплата B", "Выплата B", "Выплата A"]); + expect(within(panel).getAllByText("−2 ₽")).toHaveLength(2); + + rerender(breakdown([second, first], "8")); + expect(payments()).toEqual(["Выплата B", "Выплата A"]); + expect( + screen.getByRole("button", { name: "BOND · 8 ₽ · 2" }), + ).toHaveAttribute("aria-expanded", "true"); + }); + + it.each([ + ["missing key", undefined], + ["empty key", ""], + ["whitespace key", " "], + ["non-string key", 12], + ["unknown key", "not-in-breakdown"], + ])("rejects %s and recovers through retry", async (_name, instrumentKey) => { + let response = { + ...analytics, + entries: [ + { ...analytics.entries[0], instrumentKey }, + analytics.entries[1], + ], + }; + server.use( + http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () => + HttpResponse.json(response), + ), + ); + renderPage(); + const retry = await screen.findByRole("button", { name: "Повторить" }); + expect(screen.queryByText("BOND · 12 ₽ · 2")).not.toBeInTheDocument(); + response = analytics; + fireEvent.click(retry); + expect(await screen.findByText("BOND · 12 ₽ · 2")).toBeInTheDocument(); + }); + + it.each([ + [ + "duplicate instrument keys", + { + ...analytics, + instruments: [...analytics.instruments, analytics.instruments[0]], + }, + ], + [ + "currency mismatch", + { + ...analytics, + entries: [ + { ...analytics.entries[0], payment: money("USD", "10") }, + analytics.entries[1], + ], + }, + ], + [ + "receipt count mismatch", + { + ...analytics, + instruments: [{ ...analytics.instruments[0], receiptCount: 3 }], + }, + ], + ["missing entries", { ...analytics, entries: [] }], + [ + "empty instrument key", + { ...analytics, instruments: [{ ...analytics.instruments[0], key: "" }] }, + ], + ])("shows a local error for %s", async (_name, response) => { + server.use( + http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () => + HttpResponse.json(response), + ), + ); + renderPage(); + expect( + await screen.findByRole("button", { name: "Повторить" }), + ).toBeInTheDocument(); + expect(screen.queryByText("BOND · 12 ₽ · 2")).not.toBeInTheDocument(); + }); + + 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: [], + }), + ), + ); + renderPage(); + expect( + await screen.findByRole("heading", { name: "Пассивный доход" }), + ).toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: "Повторить" }), + ).not.toBeInTheDocument(); + }); + + it("renders the signed drill-down using opaque membership rather than instrument identity", async () => { + const response = { + ...analytics, + instruments: [ + { + ...analytics.instruments[0], + key: "server-only-key", + total: money("RUB", "8"), + dividend: money("RUB", "-2"), + }, + ], + entries: [ + { + ...analytics.entries[0], + instrumentKey: "server-only-key", + figi: null, + ticker: null, + description: "Исходная выплата", + }, + { + ...analytics.entries[1], + instrumentKey: "server-only-key", + instrumentUid: "unrelated-uid", + payment: money("RUB", "-2"), + description: "Отрицательная корректировка", + }, + ], + }; + server.use( + http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () => + HttpResponse.json(response), + ), + ); + renderPage(); + fireEvent.click( + await screen.findByRole("button", { name: "BOND · 8 ₽ · 2" }), + ); + const panel = await screen.findByRole("region", { name: "BOND · 8 ₽ · 2" }); + expect(within(panel).getByText("Исходная выплата")).toBeInTheDocument(); + expect( + within(panel).getByText("Отрицательная корректировка"), + ).toBeInTheDocument(); + expect(within(panel).getByText("−2 ₽")).toBeInTheDocument(); + }); + it("rejects malformed calendar dates without throwing", () => { expect(isDate("2026-99-99")).toBe(false); expect(isDate("2026-02-29")).toBe(false); @@ -127,8 +338,8 @@ describe("AccountIncomePage", () => { it("groups income entries once in the view model", () => { const display = toIncomeDisplayModel(analytics); - expect(display.instruments[0].entries).toEqual([analytics.entries[0]]); - expect(display.trend[0].buckets[0].total).toEqual( + expect(display?.instruments[0].entries).toEqual(analytics.entries); + expect(display?.trend[0].buckets[0].total).toEqual( analytics.buckets[0].totalsByCurrency[0], ); }); @@ -168,7 +379,7 @@ describe("AccountIncomePage", () => { screen.getByText("Налоговые операции не включены в этот результат."), ).toBeInTheDocument(); expect(screen.getByText("Ближайший прогноз")).toBeInTheDocument(); - expect(screen.getByText("BOND · 12 ₽ · 1")).toBeInTheDocument(); + expect(screen.getByText("BOND · 12 ₽ · 2")).toBeInTheDocument(); expect( screen.getByText( "Выбранный период: Купоны 10 ₽ · Дивиденды 2 ₽ · Итого 12 ₽", diff --git a/src/pages/account-income/ui/AccountIncomePage.vm.ts b/src/pages/account-income/ui/AccountIncomePage.vm.ts index c9d1cad..abc7050 100644 --- a/src/pages/account-income/ui/AccountIncomePage.vm.ts +++ b/src/pages/account-income/ui/AccountIncomePage.vm.ts @@ -92,10 +92,13 @@ const useAccountIncomePageViewModel = (): AccountIncomePageViewModel => { return { state: "error", retry: () => analytics.refetch(), period }; return { state: "loading" }; } + const display = toIncomeDisplayModel(analytics.data); + if (!display) + return { state: "error", retry: () => analytics.refetch(), period }; return { state: "loaded", analytics: analytics.data, - display: toIncomeDisplayModel(analytics.data), + display, forecast: toForecast(events), form, onSubmit, @@ -149,14 +152,32 @@ function isDate(value: string): boolean { ); } -function toIncomeDisplayModel(analytics: IncomeAnalytics): IncomeDisplayModel { - const entriesByInstrument = new Map(); +function toIncomeDisplayModel( + analytics: IncomeAnalytics, +): IncomeDisplayModel | null { + const instruments = new Map< + string, + IncomeDisplayModel["instruments"][number] + >(); + for (const instrument of analytics.instruments) { + if ( + typeof instrument.key !== "string" || + !instrument.key.trim() || + instruments.has(instrument.key) + ) + return null; + instruments.set(instrument.key, { ...instrument, entries: [] }); + } for (const entry of analytics.entries) { - const key = incomeEntryKey(entry); - entriesByInstrument.set(key, [ - ...(entriesByInstrument.get(key) ?? []), - entry, - ]); + if (typeof entry.instrumentKey !== "string" || !entry.instrumentKey.trim()) + return null; + const instrument = instruments.get(entry.instrumentKey); + if (!instrument || entry.payment?.currency !== instrument.total.currency) + return null; + instrument.entries.push(entry); + } + for (const instrument of instruments.values()) { + if (instrument.entries.length !== instrument.receiptCount) return null; } return { @@ -169,24 +190,10 @@ function toIncomeDisplayModel(analytics: IncomeAnalytics): IncomeDisplayModel { zeroSummary(currency), })), })), - instruments: analytics.instruments.map((instrument) => ({ - ...instrument, - entries: entriesByInstrument.get(instrument.key) ?? [], - })), + instruments: [...instruments.values()], }; } -function incomeEntryKey(entry: IncomeAnalytics["entries"][number]): string { - const base = entry.instrumentUid - ? `uid:${entry.instrumentUid}` - : entry.figi - ? `figi:${entry.figi}` - : entry.ticker || entry.instrumentType - ? `ticker:${entry.ticker ?? ""}:${entry.instrumentType ?? ""}` - : `operation:${entry.id}`; - return `${base}:${entry.payment?.currency ?? ""}`; -} - function zeroSummary( currency: string, ): IncomeAnalytics["totalsByCurrency"][number] { diff --git a/src/pages/account-income/ui/components/IncomeBreakdown.view.tsx b/src/pages/account-income/ui/components/IncomeBreakdown.view.tsx index 0655079..907bcfb 100644 --- a/src/pages/account-income/ui/components/IncomeBreakdown.view.tsx +++ b/src/pages/account-income/ui/components/IncomeBreakdown.view.tsx @@ -16,25 +16,36 @@ const IncomeBreakdown: FC = (props) => { {t("income.instruments")} - {props.instruments.map((instrument) => ( - - - {instrument.displayName} · {formatMoney(instrument.total)} ·{" "} - {instrument.receiptCount} - - - - {t("income.coupon")}: {formatMoney(instrument.coupon)} ·{" "} - {t("income.dividend")}: {formatMoney(instrument.dividend)} - - - {instrument.entries.map((entry) => ( - - ))} - - - - ))} + {props.instruments.map((instrument) => { + const occurrences = new Map(); + return ( + + + {instrument.displayName} · {formatMoney(instrument.total)} ·{" "} + {instrument.receiptCount} + + + + {t("income.coupon")}: {formatMoney(instrument.coupon)} ·{" "} + {t("income.dividend")}: {formatMoney(instrument.dividend)} + + + {instrument.entries.map((entry) => { + // Distinguish repeated IDs and reset image fallback when its source changes. + const occurrence = occurrences.get(entry.id) ?? 0; + occurrences.set(entry.id, occurrence + 1); + const key = JSON.stringify([ + entry.id, + occurrence, + entry.logoName, + ]); + return ; + })} + + + + ); + })} ); diff --git a/src/shared/api/generated/schema.d.ts b/src/shared/api/generated/schema.d.ts index f6161cd..9b308cd 100644 --- a/src/shared/api/generated/schema.d.ts +++ b/src/shared/api/generated/schema.d.ts @@ -366,6 +366,26 @@ export interface components { total: components["schemas"]["MoneyDto"]; receiptCount: number; }; + IncomeAnalyticsEntryDto: { + id: string; + /** Format: date-time */ + occurredAt: string; + type: string; + /** @enum {string} */ + category: "trade" | "income" | "tax" | "fee" | "transfer" | "other"; + description: string; + figi: string | null; + instrumentUid: string | null; + ticker: string | null; + instrumentType: string | null; + logoName: string | null; + payment: components["schemas"]["MoneyDto"] | null; + quantity: string | null; + price: components["schemas"]["MoneyDto"] | null; + accruedInterest: components["schemas"]["MoneyDto"] | null; + commission: components["schemas"]["MoneyDto"] | null; + instrumentKey: string; + }; IncomeAnalyticsDto: { accountId: string; period: components["schemas"]["IncomePeriodDto"]; @@ -378,7 +398,7 @@ export interface components { bucketGranularity: "day" | "week" | "month"; buckets: components["schemas"]["IncomeBucketDto"][]; instruments: components["schemas"]["IncomeInstrumentDto"][]; - entries: components["schemas"]["BrokerageOperationDto"][]; + entries: components["schemas"]["IncomeAnalyticsEntryDto"][]; }; }; responses: never; diff --git a/src/shared/api/spec/openapi.json b/src/shared/api/spec/openapi.json index 49991ca..7ff95ad 100644 --- a/src/shared/api/spec/openapi.json +++ b/src/shared/api/spec/openapi.json @@ -1887,6 +1887,116 @@ "receiptCount" ] }, + "IncomeAnalyticsEntryDto": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "occurredAt": { + "type": "string", + "format": "date-time" + }, + "type": { + "type": "string" + }, + "category": { + "type": "string", + "enum": [ + "trade", + "income", + "tax", + "fee", + "transfer", + "other" + ] + }, + "description": { + "type": "string" + }, + "figi": { + "type": "string", + "nullable": true + }, + "instrumentUid": { + "type": "string", + "nullable": true + }, + "ticker": { + "type": "string", + "nullable": true + }, + "instrumentType": { + "type": "string", + "nullable": true + }, + "logoName": { + "type": "string", + "nullable": true + }, + "payment": { + "nullable": true, + "type": "object", + "allOf": [ + { + "$ref": "#/components/schemas/MoneyDto" + } + ] + }, + "quantity": { + "type": "string", + "nullable": true + }, + "price": { + "nullable": true, + "type": "object", + "allOf": [ + { + "$ref": "#/components/schemas/MoneyDto" + } + ] + }, + "accruedInterest": { + "nullable": true, + "type": "object", + "allOf": [ + { + "$ref": "#/components/schemas/MoneyDto" + } + ] + }, + "commission": { + "nullable": true, + "type": "object", + "allOf": [ + { + "$ref": "#/components/schemas/MoneyDto" + } + ] + }, + "instrumentKey": { + "type": "string" + } + }, + "required": [ + "id", + "occurredAt", + "type", + "category", + "description", + "figi", + "instrumentUid", + "ticker", + "instrumentType", + "logoName", + "payment", + "quantity", + "price", + "accruedInterest", + "commission", + "instrumentKey" + ] + }, "IncomeAnalyticsDto": { "type": "object", "properties": { @@ -1941,7 +2051,7 @@ "entries": { "type": "array", "items": { - "$ref": "#/components/schemas/BrokerageOperationDto" + "$ref": "#/components/schemas/IncomeAnalyticsEntryDto" } } },