feat: add manual bank card screens

Add cards, operations, refunds, categories and monthly summaries with Mantine date controls.

Derive banking types from runtime OpenAPI. Cover recovery and session isolation.
This commit is contained in:
2026-09-12 18:34:41 +03:00
parent 427ca23a43
commit ff4e88edbe
50 changed files with 7795 additions and 10 deletions
+458
View File
@@ -0,0 +1,458 @@
import { MantineProvider } from "@mantine/core";
import { QueryClientProvider } from "@tanstack/react-query";
import {
fireEvent,
render,
screen,
waitFor,
within,
} from "@testing-library/react";
import { HttpResponse, http } from "msw";
import { describe, expect, it, vi } from "vitest";
import type { BankCard, BankOperation } from "@/entities/bank-card";
import { configureAuthClient } from "@/shared/api";
import { createTestQueryClient } from "@/test/query";
import { server } from "@/test/server";
import { OperationForm } from "./OperationForm";
const money = (value: string) => ({ currency: "RUB", value });
const card: BankCard = {
id: "a",
name: "Повседневная",
bankName: null,
lastFour: null,
balance: money("0"),
openingBalance: money("0"),
openingDate: "2026-01-01",
archivedAt: null,
version: 1,
};
function setup(operation?: BankOperation) {
configureAuthClient({
accessToken: () => "test",
refresh: async () => false,
clear: () => {},
});
server.use(
http.get("*/api/v1/bank-cards/categories", () =>
HttpResponse.json([
{ id: "default", name: "Остальное", isDefault: true, version: 2 },
]),
),
);
const close = vi.fn();
render(
<QueryClientProvider client={createTestQueryClient()}>
<MantineProvider env="test">
<OperationForm
operation={operation}
card={card}
cards={[
card,
{ ...card, id: "b", name: "Запасная", openingDate: "2026-08-01" },
{
...card,
id: "archived",
name: "Архивная",
archivedAt: "2026-01-02",
},
]}
onClose={close}
/>
</MantineProvider>
</QueryClientProvider>,
);
return close;
}
async function select(label: string, value: string | RegExp) {
fireEvent.click(screen.getByRole("combobox", { name: new RegExp(label) }));
fireEvent.click(await screen.findByRole("option", { name: value }));
}
describe("OperationForm", () => {
it("uses renamed default category and exact maximum positive money despite zero balance", async () => {
const close = setup();
await waitFor(() =>
expect(screen.getByRole("combobox", { name: /Категория/ })).toHaveValue(
"Остальное",
),
);
let body: unknown;
server.use(
http.post("*/api/v1/bank-cards/operations", async ({ request }) => {
body = await request.json();
return HttpResponse.json({}, { status: 201 });
}),
);
fireEvent.change(screen.getByLabelText(/Сумма/), {
target: { value: "92233720368547758,07" },
});
fireEvent.click(screen.getByRole("button", { name: "Сохранить" }));
await waitFor(() => expect(close).toHaveBeenCalled());
expect(body).toMatchObject({
categoryId: "default",
amount: money("92233720368547758.07"),
});
});
it("checks both transfer opening dates, excludes archive and reuses intent after a lost response", async () => {
setup();
await select("Вид операции", "Перевод");
await select("Карта-получатель", "Запасная");
fireEvent.change(screen.getByLabelText(/Сумма/), {
target: { value: "0" },
});
fireEvent.change(screen.getByLabelText(/Дата операции/), {
target: { value: "2026-07-31" },
});
const bodies: unknown[] = [];
server.use(
http.post("*/api/v1/bank-cards/operations", async ({ request }) => {
bodies.push(await request.json());
return bodies.length === 1
? HttpResponse.error()
: HttpResponse.json({}, { status: 201 });
}),
);
fireEvent.click(screen.getByRole("button", { name: "Сохранить" }));
expect(
await screen.findByText("Сумма операции должна быть больше нуля."),
).toBeInTheDocument();
expect(screen.getByText(/Дата не может быть раньше/)).toBeInTheDocument();
expect(bodies).toHaveLength(0);
fireEvent.change(screen.getByLabelText(/Сумма/), {
target: { value: "150,01" },
});
fireEvent.change(screen.getByLabelText(/Дата операции/), {
target: { value: "2026-08-02" },
});
fireEvent.click(screen.getByRole("button", { name: "Сохранить" }));
await screen.findByText(/Не удалось подтвердить сохранение/);
expect(screen.getByLabelText(/Сумма/)).toHaveValue("150,01");
fireEvent.click(screen.getByRole("button", { name: "Сохранить" }));
await waitFor(() => expect(bodies).toHaveLength(2));
expect(bodies[0]).toEqual(bodies[1]);
expect(bodies[0]).toMatchObject({
kind: "transfer",
cardId: "a",
targetCardId: "b",
amount: money("150.01"),
});
});
it("topup omits categories and preserves entered description", async () => {
setup();
await select("Вид операции", "Пополнение");
expect(
screen.queryByRole("combobox", { name: /Категория/ }),
).not.toBeInTheDocument();
let body: unknown;
server.use(
http.post("*/api/v1/bank-cards/operations", async ({ request }) => {
body = await request.json();
return HttpResponse.json({}, { status: 201 });
}),
);
fireEvent.change(screen.getByLabelText(/Сумма/), {
target: { value: "0.10" },
});
fireEvent.change(screen.getByLabelText(/Описание/), {
target: { value: "Внесение" },
});
fireEvent.click(screen.getByRole("button", { name: "Сохранить" }));
await waitFor(() => expect(body).toBeDefined());
expect(body).toMatchObject({
kind: "topup",
amount: money("0.10"),
description: "Внесение",
});
expect(body).not.toHaveProperty("categoryId");
});
it("finds a previous-month purchase across pages, checks remaining refund and retains the complete-refund intent", async () => {
const purchase: BankOperation = {
id: "purchase",
kind: "expense",
cardId: "a",
cardName: card.name,
targetCardId: null,
targetCardName: null,
categoryId: "default",
categoryName: "Остальное",
originalExpenseId: null,
amount: money("500"),
availableRefund: money("500"),
date: "2026-07-01",
description: "Прошлая покупка",
version: 1,
createdAt: "2026-07-01T00:00:00Z",
};
server.use(
http.get("*/api/v1/bank-cards/a/expenses", ({ request }) => {
return HttpResponse.json({
items:
new URL(request.url).searchParams.get("page") === "2"
? [purchase]
: [],
page: 1,
total: 1,
hasNext: new URL(request.url).searchParams.get("page") !== "2",
});
}),
http.get("*/api/v1/bank-cards/operations/purchase", () =>
HttpResponse.json(purchase),
),
);
const close = setup();
await select("Вид операции", "Возврат");
await waitFor(() =>
expect(screen.getByRole("button", { name: "Далее" })).toBeEnabled(),
);
fireEvent.click(screen.getByRole("button", { name: "Далее" }));
await waitFor(() =>
expect(screen.getByRole("button", { name: "Назад" })).toBeEnabled(),
);
await select("Исходная покупка", /Прошлая покупка/);
await screen.findByText("Доступно к возврату: 500 ₽");
fireEvent.change(screen.getByLabelText(/Сумма/), {
target: { value: "500.01" },
});
fireEvent.click(screen.getByRole("button", { name: "Сохранить" }));
await screen.findByText("Сумма превышает доступный возврат.");
const bodies: unknown[] = [];
server.use(
http.post("*/api/v1/bank-cards/operations", async ({ request }) => {
bodies.push(await request.json());
purchase.availableRefund = money("0");
return bodies.length === 1
? HttpResponse.error()
: HttpResponse.json({}, { status: 201 });
}),
);
fireEvent.change(screen.getByLabelText(/Сумма/), {
target: { value: "500" },
});
fireEvent.change(screen.getByLabelText(/Дата операции/), {
target: { value: "2026-08-01" },
});
fireEvent.click(screen.getByRole("button", { name: "Сохранить" }));
await screen.findByText(/Не удалось подтвердить сохранение/);
fireEvent.click(screen.getByRole("button", { name: "Перечитать запись" }));
await screen.findByText("Доступно к возврату: 0 ₽");
fireEvent.click(screen.getByRole("button", { name: "Сохранить" }));
await waitFor(() => expect(close).toHaveBeenCalled());
expect(bodies[0]).toEqual(bodies[1]);
expect(bodies[0]).toMatchObject({
kind: "refund",
originalExpenseId: "purchase",
date: "2026-08-01",
});
});
it("edits a full refund with its own amount excluded from the limit and immutable purchase", async () => {
const refund: BankOperation = {
id: "refund",
kind: "refund",
cardId: "a",
cardName: card.name,
targetCardId: null,
targetCardName: null,
categoryId: "default",
categoryName: "Остальное",
originalExpenseId: "purchase",
amount: money("500"),
availableRefund: money("500"),
date: "2026-08-01",
description: "",
version: 2,
createdAt: "2026-08-01T00:00:00Z",
};
const purchase = {
...refund,
id: "purchase",
kind: "expense",
date: "2026-07-01",
availableRefund: money("0"),
};
server.use(
http.get("*/api/v1/bank-cards/a/expenses", () =>
HttpResponse.json({
items: [purchase],
page: 1,
total: 1,
hasNext: false,
}),
),
http.get("*/api/v1/bank-cards/operations/purchase", () =>
HttpResponse.json(purchase),
),
http.get("*/api/v1/bank-cards/operations/refund", () =>
HttpResponse.json(refund),
),
);
const close = setup(refund);
await screen.findByText("Доступно к возврату: 500 ₽");
expect(
screen.getByRole("combobox", { name: /Исходная покупка/ }),
).toBeDisabled();
expect(
screen.getByRole("combobox", { name: /Вид операции/ }),
).toBeDisabled();
fireEvent.change(screen.getByLabelText(/Описание/), {
target: { value: "Полностью возвращено" },
});
let body: unknown;
server.use(
http.patch(
"*/api/v1/bank-cards/operations/refund",
async ({ request }) => {
body = await request.json();
return HttpResponse.json(refund);
},
),
);
await waitFor(() =>
expect(screen.getByRole("button", { name: "Сохранить" })).toBeEnabled(),
);
fireEvent.click(screen.getByRole("button", { name: "Сохранить" }));
await waitFor(() => expect(close).toHaveBeenCalled());
expect(body).toMatchObject({
version: 2,
amount: money("500"),
originalExpenseId: "purchase",
description: "Полностью возвращено",
});
});
it("keeps edit draft and stale version until explicit adoption after conflict", async () => {
const operation: BankOperation = {
id: "expense",
kind: "expense",
cardId: "a",
cardName: card.name,
targetCardId: null,
targetCardName: null,
categoryId: "default",
categoryName: "Остальное",
originalExpenseId: null,
amount: money("100"),
availableRefund: money("100"),
date: "2026-08-01",
description: "Первое",
version: 1,
createdAt: "2026-08-01T00:00:00Z",
};
let readVersion = 1;
const bodies: Record<string, unknown>[] = [];
server.use(
http.get("*/api/v1/bank-cards/operations/expense", () =>
HttpResponse.json({
...operation,
version: readVersion,
description: readVersion === 1 ? "Первое" : "Другое исправление",
}),
),
http.patch(
"*/api/v1/bank-cards/operations/expense",
async ({ request }) => {
const body = (await request.json()) as Record<string, unknown>;
bodies.push(body);
readVersion = 2;
return body.version === 1
? HttpResponse.json(
{ code: "BANK_VERSION_CONFLICT" },
{ status: 409 },
)
: HttpResponse.json({ ...operation, ...body });
},
),
);
const close = setup(operation);
fireEvent.change(screen.getByLabelText(/Описание/), {
target: { value: "Мой черновик" },
});
fireEvent.change(screen.getByLabelText(/Сумма/), {
target: { value: "150" },
});
await waitFor(() =>
expect(screen.getByRole("button", { name: "Сохранить" })).toBeEnabled(),
);
fireEvent.click(screen.getByRole("button", { name: "Сохранить" }));
await screen.findByText(/Запись уже изменена/);
expect(screen.getByLabelText(/Описание/)).toHaveValue("Мой черновик");
fireEvent.click(screen.getByRole("button", { name: "Перечитать запись" }));
await screen.findByRole("button", {
name: "Использовать актуальную версию 2",
});
expect(screen.getByLabelText(/Сумма/)).toHaveValue("150");
fireEvent.click(screen.getByRole("button", { name: "Сохранить" }));
await waitFor(() => expect(bodies).toHaveLength(2));
expect(bodies[1].version).toBe(1);
fireEvent.click(
screen.getByRole("button", { name: "Использовать актуальную версию 2" }),
);
fireEvent.click(screen.getByRole("button", { name: "Сохранить" }));
await waitFor(() => expect(close).toHaveBeenCalled());
expect(bodies[2]).toMatchObject({
version: 2,
description: "Мой черновик",
amount: money("150"),
});
});
it("creates a category inside expense without losing operation fields", async () => {
setup();
fireEvent.change(screen.getByLabelText(/Сумма/), {
target: { value: "001.20" },
});
fireEvent.change(screen.getByLabelText(/Описание/), {
target: { value: "Черновик расхода" },
});
fireEvent.click(screen.getByRole("button", { name: "Добавить категорию" }));
const dialog = screen.getByRole("dialog", { name: "Категории" });
fireEvent.change(within(dialog).getByLabelText(/Название категории/), {
target: { value: " кафе " },
});
let created = false;
server.use(
http.post("*/api/v1/bank-cards/categories", () => {
created = true;
return HttpResponse.json(
{ id: "new", name: "кафе", version: 1, isDefault: false },
{ status: 201 },
);
}),
http.get("*/api/v1/bank-cards/categories", () =>
HttpResponse.json([
{ id: "default", name: "Остальное", isDefault: true, version: 2 },
...(created
? [{ id: "new", name: "кафе", isDefault: false, version: 1 }]
: []),
]),
),
);
fireEvent.click(
within(dialog).getByRole("button", { name: "Добавить категорию" }),
);
await waitFor(() =>
expect(
screen.queryByRole("dialog", { name: "Категории" }),
).not.toBeInTheDocument(),
);
expect(screen.getByLabelText(/Сумма/)).toHaveValue("001.20");
expect(screen.getByLabelText(/Описание/)).toHaveValue("Черновик расхода");
await waitFor(() =>
expect(screen.getByRole("combobox", { name: /Категория/ })).toHaveValue(
"кафе",
),
);
let body: unknown;
server.use(
http.post("*/api/v1/bank-cards/operations", async ({ request }) => {
body = await request.json();
return HttpResponse.json({}, { status: 201 });
}),
);
fireEvent.click(screen.getByRole("button", { name: "Сохранить" }));
await waitFor(() => expect(body).toBeDefined());
expect(body).toMatchObject({
categoryId: "new",
description: "Черновик расхода",
amount: money("1.20"),
});
});
});