feat(income): add account income analytics

This commit is contained in:
2026-08-28 01:25:52 +03:00
parent 71a214d7ad
commit 9364568202
18 changed files with 1746 additions and 7 deletions
@@ -0,0 +1,234 @@
import { MantineProvider } from "@mantine/core";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import { HttpResponse, http } from "msw";
import type { FC, PropsWithChildren } from "react";
import { MemoryRouter, Route, Routes } from "react-router";
import { afterEach, describe, expect, it, vi } from "vitest";
import { configureAuthClient } from "@/shared/api";
import { server } from "@/test/server";
import { AccountIncomePage } from "./AccountIncomePage";
const analytics = {
accountId: "account-1",
period: { from: "2026-08-01", to: "2026-08-27" },
calculatedAt: "2026-08-27T00:00:00Z",
totalsByCurrency: [summary("RUB", "10", "2")],
previous: {
period: { from: "2026-07-01", to: "2026-07-31" },
totalsByCurrency: [comparison("RUB")],
},
yearComparison: null,
bucketGranularity: "day",
buckets: [
{
period: { from: "2026-08-01", to: "2026-08-01" },
totalsByCurrency: [summary("RUB", "10", "2")],
},
],
instruments: [
{
key: "figi:FIGI:RUB",
figi: "FIGI",
instrumentUid: null,
ticker: "BOND",
instrumentType: "bond",
displayName: "BOND",
coupon: money("RUB", "10"),
dividend: money("RUB", "2"),
total: money("RUB", "12"),
receiptCount: 1,
},
],
entries: [entry()],
};
function money(currency: string, value: string) {
return { currency, value };
}
function summary(currency: string, coupon: string, dividend: string) {
return {
currency,
coupon: money(currency, coupon),
dividend: money(currency, dividend),
total: money(currency, String(Number(coupon) + Number(dividend))),
};
}
function comparison(currency: string) {
return {
currency,
selected: summary(currency, "10", "2"),
compared: summary(currency, "1", "0"),
difference: summary(currency, "9", "2"),
};
}
function entry() {
return {
id: "coupon-1",
occurredAt: "2026-08-01T12:00:00Z",
type: "OPERATION_TYPE_COUPON",
category: "income",
description: "Купон",
figi: "FIGI",
instrumentUid: null,
ticker: "BOND",
instrumentType: "bond",
logoName: null,
payment: money("RUB", "10"),
quantity: null,
price: null,
accruedInterest: null,
commission: null,
};
}
function renderPage() {
configureAuthClient({
accessToken: () => "token",
refresh: async () => true,
clear: vi.fn(),
});
const Wrapper: FC<PropsWithChildren> = (props) => (
<MantineProvider>
<QueryClientProvider client={new QueryClient()}>
{props.children}
</QueryClientProvider>
</MantineProvider>
);
return render(
<Wrapper>
<MemoryRouter
initialEntries={[
"/accounts/account-1/income?from=2026-08-01&to=2026-08-27",
]}
>
<Routes>
<Route
path="/accounts/:accountId/income"
element={<AccountIncomePage />}
/>
<Route path="/accounts/:accountId/events" element={<h1>Events</h1>} />
</Routes>
</MemoryRouter>
</Wrapper>,
);
}
describe("AccountIncomePage", () => {
afterEach(() => {
cleanup();
configureAuthClient(null);
});
it("renders realized analytics, drill-down, and the independent forecast", async () => {
server.use(
http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () =>
HttpResponse.json(analytics),
),
http.get("*/api/v1/brokerage-accounts/account-1/events", () =>
HttpResponse.json({
accountId: "account-1",
calculatedAt: "2026-08-27T00:00:00Z",
incomeTotalsByCurrency: [money("RUB", "3")],
events: [
{
type: "coupon",
date: "2026-08-28",
dateKind: "event",
figi: "FIGI",
instrumentUid: null,
ticker: "BOND",
displayName: "BOND",
projectedAmount: money("RUB", "3"),
},
],
}),
),
);
renderPage();
await waitFor(() =>
expect(
screen.getByRole("heading", { name: "Пассивный доход" }),
).toBeInTheDocument(),
);
expect(
screen.getByText("Налоговые операции не включены в этот результат."),
).toBeInTheDocument();
expect(screen.getByText("Ближайший прогноз")).toBeInTheDocument();
expect(screen.getByText("BOND · 12 ₽ · 1")).toBeInTheDocument();
expect(
screen.getByText(
"Выбранный период: Купоны 10 ₽ · Дивиденды 2 ₽ · Итого 12 ₽",
),
).toBeInTheDocument();
expect(
screen.getByText("Разница: Купоны 9 ₽ · Дивиденды 2 ₽ · Итого 11 ₽"),
).toBeInTheDocument();
});
it("renders an empty trend bucket as zero money", async () => {
server.use(
http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () =>
HttpResponse.json({
...analytics,
buckets: [
...analytics.buckets,
{
period: { from: "2026-08-02", to: "2026-08-02" },
totalsByCurrency: [],
},
],
}),
),
);
renderPage();
await waitFor(() =>
expect(
screen.getByText("2026-08-02 — 2026-08-02: Купоны 0 ₽, Дивиденды 0 ₽"),
).toBeInTheDocument(),
);
});
it("renders signed coupon and dividend segments on opposite baseline sides", async () => {
server.use(
http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () =>
HttpResponse.json({
...analytics,
buckets: [
{
period: { from: "2026-08-01", to: "2026-08-01" },
totalsByCurrency: [summary("RUB", "-10", "2")],
},
],
}),
),
);
renderPage();
await waitFor(() => {
expect(
screen.getByTestId("trend-negative-coupon-2026-08-01"),
).toBeInTheDocument();
expect(
screen.getByTestId("trend-positive-dividend-2026-08-01"),
).toBeInTheDocument();
});
});
it("shows a recoverable realized error", async () => {
server.use(
http.get(
"*/api/v1/brokerage-accounts/account-1/income-analytics",
() => new HttpResponse(null, { status: 503 }),
),
);
renderPage();
await waitFor(() =>
expect(
screen.getByRole("button", { name: "Повторить" }),
).toBeInTheDocument(),
);
});
});