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
@@ -0,0 +1,122 @@
import { MantineProvider } from "@mantine/core";
import { 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 { configureAuthClient } from "@/shared/api";
import { createTestQueryClient } from "@/test/query";
import { server } from "@/test/server";
import type { BankCard } from "../api/queries";
import { BankCardForm } from "./BankCardForm";
const card: BankCard = {
id: "a",
name: "Основная",
bankName: "Банк",
lastFour: "1234",
openingBalance: { currency: "RUB", value: "100" },
balance: { currency: "RUB", value: "50" },
openingDate: "2026-01-01",
archivedAt: null,
version: 1,
};
function setup(value: BankCard) {
configureAuthClient({
accessToken: () => "test",
refresh: async () => false,
clear: () => {},
});
server.use(http.get("*/api/v1/bank-cards/a", () => HttpResponse.json(value)));
const close = vi.fn();
render(
<QueryClientProvider client={createTestQueryClient()}>
<MantineProvider env="test">
<BankCardForm card={value} onClose={close} />
</MantineProvider>
</QueryClientProvider>,
);
return close;
}
describe("BankCardForm", () => {
it("clears optional metadata on archive while omitting protected financial fields", async () => {
const close = setup({ ...card, archivedAt: "2026-09-01" });
expect(screen.getByLabelText(/Начальный остаток/)).toHaveAttribute(
"readonly",
);
expect(screen.getByLabelText(/Дата начала/)).toHaveAttribute("readonly");
fireEvent.change(screen.getByLabelText(/Банк/), { target: { value: "" } });
fireEvent.change(screen.getByLabelText(/Последние четыре/), {
target: { value: "" },
});
let body: unknown;
server.use(
http.patch("*/api/v1/bank-cards/a", async ({ request }) => {
body = await request.json();
return HttpResponse.json(card);
}),
);
fireEvent.click(screen.getByRole("button", { name: "Сохранить" }));
await waitFor(() => expect(close).toHaveBeenCalled());
expect(body).toEqual({
version: 1,
name: "Основная",
bankName: null,
lastFour: null,
});
});
it("retains an invalid opening date draft and explains conflict with saved history", async () => {
const close = setup(card);
fireEvent.change(screen.getByLabelText(/Дата начала/), {
target: { value: "2026-08-01" },
});
server.use(
http.patch("*/api/v1/bank-cards/a", () =>
HttpResponse.json(
{ code: "BANK_OPENING_DATE_CONFLICT" },
{ status: 400 },
),
),
);
fireEvent.click(screen.getByRole("button", { name: "Сохранить" }));
await screen.findByText(
"Дата начала противоречит сохранённой истории карты.",
);
expect(screen.getByLabelText(/Дата начала/)).toHaveValue("2026-08-01");
expect(close).not.toHaveBeenCalled();
});
it("keeps card edit version until explicit refresh adoption", async () => {
const close = setup(card);
let version = 1;
const bodies: Record<string, unknown>[] = [];
server.use(
http.get("*/api/v1/bank-cards/a", () =>
HttpResponse.json({ ...card, version }),
),
http.patch("*/api/v1/bank-cards/a", async ({ request }) => {
const body = (await request.json()) as Record<string, unknown>;
bodies.push(body);
version = 2;
return body.version === 1
? HttpResponse.json(
{ code: "BANK_VERSION_CONFLICT" },
{ status: 409 },
)
: HttpResponse.json(card);
}),
);
fireEvent.change(screen.getByLabelText(/Название карты/), {
target: { value: "Мой черновик" },
});
fireEvent.click(screen.getByRole("button", { name: "Сохранить" }));
await screen.findByText(/Запись уже изменена/);
fireEvent.click(screen.getByRole("button", { name: "Перечитать запись" }));
const adopt = await screen.findByRole("button", {
name: "Использовать актуальную версию 2",
});
expect(screen.getByLabelText(/Название карты/)).toHaveValue("Мой черновик");
fireEvent.click(adopt);
fireEvent.click(screen.getByRole("button", { name: "Сохранить" }));
await waitFor(() => expect(close).toHaveBeenCalled());
expect(bodies.map((b) => b.version)).toEqual([1, 2]);
});
});