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
+384
View File
@@ -0,0 +1,384 @@
import { act, renderHook, waitFor } from "@testing-library/react";
import { HttpResponse, http } from "msw";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { apiClient, configureAuthClient } from "@/shared/api";
import { createQueryWrapper } from "@/test/query";
import { server } from "@/test/server";
import { useBankMutations } from "./mutations";
import {
type BankCard,
type CreateBankOperation,
useBankCard,
useBankCards,
useBankCategories,
useBankExpenses,
useBankOperation,
useBankOperations,
useBankSummary,
} from "./queries";
const card: BankCard = {
id: "a",
name: "Daily",
bankName: null,
lastFour: null,
openingBalance: { currency: "RUB", value: "100" },
openingDate: "2026-09-01",
balance: { currency: "RUB", value: "100" },
archivedAt: null,
version: 1,
};
const transfer: CreateBankOperation = {
id: "4e6caa65-dd52-4629-81e2-ce8a139a0612",
kind: "transfer",
cardId: "a",
targetCardId: "b",
amount: { currency: "RUB", value: "90071992547409.93" },
date: "2026-09-12",
};
function authenticate() {
configureAuthClient({
accessToken: () => "token",
refresh: async () => true,
clear: vi.fn(),
});
}
function readHandlers(counts: Map<string, number>, categoryName = "Food") {
return [
http.get("*/api/v1/bank-cards", ({ request }) => {
const key = new URL(request.url).pathname + new URL(request.url).search;
counts.set(key, (counts.get(key) ?? 0) + 1);
return HttpResponse.json([card]);
}),
http.get("*/api/v1/bank-cards/*", ({ request }) => {
const url = new URL(request.url);
const key = url.pathname + url.search;
counts.set(key, (counts.get(key) ?? 0) + 1);
if (url.pathname.endsWith("/categories"))
return HttpResponse.json([
{ id: "food", name: categoryName, isDefault: true, version: 1 },
]);
if (url.pathname.endsWith("/summary"))
return HttpResponse.json({
month: url.searchParams.get("month"),
cardId: url.searchParams.get("cardId"),
expenses: { currency: "RUB", value: "0" },
refunds: { currency: "RUB", value: "0" },
net: { currency: "RUB", value: "0" },
categories: [],
});
if (
url.pathname.endsWith("/operations") ||
url.pathname.endsWith("/expenses")
)
return HttpResponse.json({
items: [],
page: 1,
total: 0,
hasNext: false,
});
if (url.pathname.includes("/operations/"))
return HttpResponse.json({ ...transfer, version: 1 });
return HttpResponse.json({ ...card, id: url.pathname.split("/").at(-1) });
}),
];
}
function useReadModels() {
const cards = useBankCards(true);
const a = useBankCard("a");
const b = useBankCard("b");
const categories = useBankCategories();
const history = useBankOperations("a", {
month: "2026-09",
page: 2,
limit: 20,
});
const expenses = useBankExpenses("a", { page: 1 });
const september = useBankSummary("2026-09");
const august = useBankSummary("2026-08", "b");
const original = useBankOperation("original");
return {
reads: [
cards,
a,
b,
categories,
history,
expenses,
september,
august,
original,
],
mutations: useBankMutations(),
};
}
describe("banking API", () => {
beforeEach(authenticate);
it("uses session-aware banking endpoints and explicit month/page without T-Bank", async () => {
const counts = new Map<string, number>();
server.use(...readHandlers(counts));
const { result } = renderHook(useReadModels, {
wrapper: createQueryWrapper().wrapper,
});
await waitFor(() =>
expect(result.current.reads.every((read) => read.isSuccess)).toBe(true),
);
expect([...counts.keys()]).toContain(
"/api/v1/bank-cards?includeArchived=true",
);
expect([...counts.keys()]).toContain(
"/api/v1/bank-cards/a/operations?month=2026-09&page=2&limit=20",
);
expect([...counts.keys()]).toContain(
"/api/v1/bank-cards/summary?month=2026-08&cardId=b",
);
expect(counts.size).toBe(9);
});
it("replays the exact UUID and large Money string once across shared auth refresh", async () => {
let token = "expired";
const refresh = vi.fn(async () => {
token = "renewed";
return true;
});
configureAuthClient({ accessToken: () => token, refresh, clear: vi.fn() });
const bodies: unknown[] = [];
const headers: (string | null)[] = [];
server.use(
http.post("*/api/v1/bank-cards/operations", async ({ request }) => {
bodies.push(await request.json());
headers.push(request.headers.get("Authorization"));
return bodies.length === 1
? HttpResponse.json({ code: "UNAUTHORIZED" }, { status: 401 })
: HttpResponse.json({ ...transfer, version: 1 });
}),
);
const { result } = renderHook(useBankMutations, {
wrapper: createQueryWrapper().wrapper,
});
await act(() => result.current.createOperation.mutateAsync(transfer));
expect(bodies).toEqual([transfer, transfer]);
expect(headers).toEqual(["Bearer expired", "Bearer renewed"]);
expect(refresh).toHaveBeenCalledTimes(1);
});
it("does not automatically retry a network failure and preserves the form-supplied intent on repeat", async () => {
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({ ...transfer, version: 1 });
}),
);
const { result } = renderHook(useBankMutations, {
wrapper: createQueryWrapper().wrapper,
});
await act(async () => {
await expect(
result.current.createOperation.mutateAsync(transfer),
).rejects.toThrow();
});
expect(bodies).toEqual([transfer]);
await act(() => result.current.createOperation.mutateAsync(transfer));
expect(bodies).toEqual([transfer, transfer]);
});
it("keeps the confirmed balance while a mutation is pending or fails, and on refetch failure", async () => {
server.use(...readHandlers(new Map()));
let finish: () => void = () => {};
const pending = new Promise<void>((resolve) => {
finish = resolve;
});
server.use(
http.post("*/api/v1/bank-cards/operations", async () => {
await pending;
return HttpResponse.json({ code: "INTERNAL_ERROR" }, { status: 500 });
}),
);
const { result } = renderHook(
() => ({ query: useBankCard("a"), mutations: useBankMutations() }),
{ wrapper: createQueryWrapper().wrapper },
);
await waitFor(() => expect(result.current.query.isSuccess).toBe(true));
let saving: Promise<unknown>;
act(() => {
saving = result.current.mutations.createOperation
.mutateAsync(transfer)
.catch(() => undefined);
});
await waitFor(() =>
expect(result.current.mutations.createOperation.isPending).toBe(true),
);
expect(result.current.query.data?.balance.value).toBe("100");
await act(async () => {
finish();
await saving;
});
expect(result.current.query.data?.balance.value).toBe("100");
server.use(
http.get("*/api/v1/bank-cards/a", () =>
HttpResponse.json({ code: "INTERNAL_ERROR" }, { status: 500 }),
),
);
await act(() => result.current.query.refetch());
expect(result.current.query.isError).toBe(true);
expect(result.current.query.data?.balance.value).toBe("100");
});
it("refetches both cards, every month, categories, journal and source expenses after a transfer without invalidating brokerage", async () => {
const counts = new Map<string, number>();
server.use(
...readHandlers(counts),
http.post("*/api/v1/bank-cards/operations", () =>
HttpResponse.json({ ...transfer, version: 1 }),
),
);
const { wrapper, queryClient } = createQueryWrapper();
const brokerKey = ["get", "/api/v1/brokerage-accounts"];
queryClient.setQueryData(brokerKey, []);
const { result } = renderHook(useReadModels, { wrapper });
await waitFor(() =>
expect(result.current.reads.every((read) => read.isSuccess)).toBe(true),
);
await act(() =>
result.current.mutations.createOperation.mutateAsync(transfer),
);
expect([...counts.values()]).toEqual(Array(9).fill(2));
expect(queryClient.getQueryState(brokerKey)?.isInvalidated).toBe(false);
});
it.each([
"BANK_VERSION_CONFLICT",
"BANK_INTENT_CONFLICT",
"BANK_REFUND_LIMIT",
])("fetches current state after %s", async (code) => {
let reads = 0;
server.use(
http.get("*/api/v1/bank-cards/a", () =>
HttpResponse.json({ ...card, version: ++reads }),
),
http.patch("*/api/v1/bank-cards/a", () =>
HttpResponse.json({ code }, { status: 409 }),
),
);
const { result } = renderHook(
() => ({ card: useBankCard("a"), mutations: useBankMutations() }),
{ wrapper: createQueryWrapper().wrapper },
);
await waitFor(() => expect(result.current.card.data?.version).toBe(1));
await act(async () => {
await expect(
result.current.mutations.updateCard.mutateAsync({
id: "a",
body: { name: "changed", version: 1 },
}),
).rejects.toEqual({ code });
});
await waitFor(() => expect(result.current.card.data?.version).toBe(2));
});
it("sends edit/delete versions and refreshes category names without optimistic updates", async () => {
const requests: unknown[] = [];
let name = "Food";
server.use(
http.get("*/api/v1/bank-cards/categories", () =>
HttpResponse.json([{ id: "food", name, isDefault: true, version: 1 }]),
),
http.patch("*/api/v1/bank-cards/categories/food", async ({ request }) => {
const body = await request.json();
requests.push(body);
name = "Groceries";
return HttpResponse.json({
id: "food",
name,
isDefault: true,
version: 2,
});
}),
http.patch(
"*/api/v1/bank-cards/operations/original",
async ({ request }) => {
requests.push(await request.json());
return HttpResponse.json({ ...transfer, version: 2 });
},
),
http.delete("*/api/v1/bank-cards/operations/original", ({ request }) => {
requests.push(new URL(request.url).searchParams.get("version"));
return new HttpResponse(null, { status: 204 });
}),
);
const { result } = renderHook(
() => ({
categories: useBankCategories(),
mutations: useBankMutations(),
}),
{ wrapper: createQueryWrapper().wrapper },
);
await waitFor(() =>
expect(result.current.categories.data?.[0].name).toBe("Food"),
);
await act(() =>
result.current.mutations.updateCategory.mutateAsync({
id: "food",
body: { name: "Groceries", version: 1 },
}),
);
await waitFor(() =>
expect(result.current.categories.data?.[0].name).toBe("Groceries"),
);
await act(() =>
result.current.mutations.updateOperation.mutateAsync({
id: "original",
body: { description: "corrected", version: 1 },
}),
);
await act(() =>
result.current.mutations.deleteOperation.mutateAsync({
id: "original",
version: 2,
}),
);
expect(requests).toEqual([
{ name: "Groceries", version: 1 },
{ description: "corrected", version: 1 },
"2",
]);
});
it("disables reads after session removal and retains banking data across a T-Bank API mutation", async () => {
const counts = new Map<string, number>();
server.use(
...readHandlers(counts),
http.put("*/api/v1/tbank-connection", () =>
HttpResponse.json({
connected: true,
connectedAt: "2026-09-12T00:00:00Z",
}),
),
);
const { wrapper, queryClient } = createQueryWrapper();
const { result, rerender } = renderHook(() => useBankCard("a"), {
wrapper,
});
await waitFor(() => expect(result.current.data?.balance.value).toBe("100"));
await apiClient.PUT("/api/v1/tbank-connection", {
body: { token: "synthetic" },
});
expect(result.current.data?.balance.value).toBe("100");
act(() => {
configureAuthClient(null);
queryClient.clear();
});
rerender();
await waitFor(() => expect(result.current.fetchStatus).toBe("idle"));
expect(result.current.data).toBeUndefined();
expect(counts.get("/api/v1/bank-cards/a")).toBe(1);
});
});