diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index aac21cb..b5bfa08 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -4,6 +4,7 @@ import "@mantine/dates/styles.css"; import "dayjs/locale/ru"; import "../src/shared/i18n"; import { MantineProvider } from "@mantine/core"; +import { DatesProvider } from "@mantine/dates"; import type { Preview } from "@storybook/react-vite"; import { cssVariablesResolver, theme } from "@/shared/theme"; @@ -33,7 +34,9 @@ const preview: Preview = { forceColorScheme="light" colorSchemeManager={colorSchemeManager} > - + + + ), ], diff --git a/src/app/routes/AppRoutes.test.tsx b/src/app/routes/AppRoutes.test.tsx index 3760312..84fcedb 100644 --- a/src/app/routes/AppRoutes.test.tsx +++ b/src/app/routes/AppRoutes.test.tsx @@ -37,6 +37,7 @@ describe("AppRoutes", () => { ["/accounts/demo", "Вход"], ["/accounts/demo/operations", "Вход"], ["/settings", "Вход"], + ["/cards?month=2026-08", "Вход"], ["/unknown", "Страница не найдена"], ])("renders %s with its page title", async (path, title) => { renderRoutes(path); @@ -94,6 +95,41 @@ describe("AppRoutes", () => { }); }); + it("returns after login to cards with the selected month without T-Bank", async () => { + server.use( + http.get("*/api/v1/bank-cards/categories", () => HttpResponse.json([])), + http.post("*/api/v1/auth/login", () => + HttpResponse.json({ + accessToken: "test", + user: { id: "user-1", email: "user@example.com" }, + }), + ), + http.get("*/api/v1/bank-cards", () => HttpResponse.json([])), + http.get("*/api/v1/bank-cards/summary", () => + HttpResponse.json({ + month: "2026-08", + cardId: null, + expenses: { currency: "RUB", value: "0" }, + refunds: { currency: "RUB", value: "0" }, + net: { currency: "RUB", value: "0" }, + categories: [], + }), + ), + ); + renderRoutes("/cards?month=2026-08"); + await screen.findByRole("heading", { name: "Вход" }); + fireEvent.change(screen.getByLabelText("Email"), { + target: { value: "user@example.com" }, + }); + fireEvent.change(screen.getByLabelText("Пароль"), { + target: { value: "a secure password" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Вход" })); + await screen.findByRole("heading", { name: "Добавьте первую карту" }); + expect(screen.getByLabelText("Месяц")).toHaveTextContent("August 2026"); + expect(document.title).toBe("Карты — WealthLedger"); + }); + it("lazy-loads the protected shell for an authenticated accounts route", async () => { server.use( http.post("*/api/v1/auth/refresh", () => diff --git a/src/app/routes/AppRoutes.tsx b/src/app/routes/AppRoutes.tsx index 44c156e..a6c5bc9 100644 --- a/src/app/routes/AppRoutes.tsx +++ b/src/app/routes/AppRoutes.tsx @@ -4,6 +4,14 @@ import { Navigate, Route, Routes } from "react-router"; import { LazyPage } from "./RouteFallback"; import { Protected, Public } from "./RouteGuards"; +const CardPage = lazy(async () => { + const { CardPage } = await import("@/pages/card"); + return { default: CardPage }; +}); +const CardsPage = lazy(async () => { + const { CardsPage } = await import("@/pages/cards"); + return { default: CardsPage }; +}); const BondPage = lazy(async () => { const { BondPage } = await import("@/pages/bond"); return { default: BondPage }; @@ -93,6 +101,22 @@ const AppRoutes: FC = () => ( } > + + + + } + /> + + + + } + /> = (props) => { return ; }; +const CardDocumentTitle: FC = (props) => { + const { t } = useTranslation(); + const { data } = useBankCard(props.identifier); + return ; +}; + const ProtectedDocumentTitle: FC = () => { const { t } = useTranslation(); const account = useMatch("/accounts/:accountId/:section?"); const bond = useMatch("/bonds/:bondId"); const stock = useMatch("/stocks/:stockId"); + const card = useMatch("/cards/:cardId"); + const cards = useMatch("/cards"); const bonds = useMatch("/bonds"); const stocks = useMatch("/stocks"); const settings = useMatch("/settings"); + if (card) return ; if (account) return ( { return ( ); diff --git a/src/app/routes/RouteGuards.tsx b/src/app/routes/RouteGuards.tsx index 3fe0f8d..4f34f8c 100644 --- a/src/app/routes/RouteGuards.tsx +++ b/src/app/routes/RouteGuards.tsx @@ -22,8 +22,16 @@ const Protected: FC = (props) => { const Public: FC = (props) => { const { children } = props; const { user, ready } = useAuth(); + const location = useLocation(); + const requested = (location.state as { returnTo?: string } | null)?.returnTo; + const returnTo = + typeof requested === "string" && + requested.startsWith("/") && + !requested.startsWith("//") + ? requested + : "/accounts"; if (!ready) return ; - return user ? : children; + return user ? : children; }; export { Protected, Public }; diff --git a/src/app/ui/AppBreadcrumbs.tsx b/src/app/ui/AppBreadcrumbs.tsx index f9c8464..995c0ba 100644 --- a/src/app/ui/AppBreadcrumbs.tsx +++ b/src/app/ui/AppBreadcrumbs.tsx @@ -6,6 +6,7 @@ import { AccountBreadcrumbs } from "./AccountBreadcrumbs"; import { AccountScopeSwitch } from "./AccountScopeSwitch"; import { BondBreadcrumbs } from "./BondBreadcrumbs"; import { BreadcrumbTrail } from "./BreadcrumbTrail"; +import { CardBreadcrumbs } from "./CardBreadcrumbs"; import { StockBreadcrumbs } from "./StockBreadcrumbs"; const AppBreadcrumbs: FC = () => { @@ -17,6 +18,8 @@ const AppBreadcrumbs: FC = () => { const events = useMatch({ path: "/accounts/:accountId/events", end: true }); const income = useMatch({ path: "/accounts/:accountId/income", end: true }); const account = useMatch({ path: "/accounts/:accountId", end: true }); + const card = useMatch("/cards/:cardId"); + const cards = useMatch("/cards"); const accounts = useMatch({ path: "/accounts", end: true }); const settings = useMatch({ path: "/settings", end: true }); @@ -71,6 +74,8 @@ const AppBreadcrumbs: FC = () => { ); + if (card) return ; + if (cards) return ; if (accounts) return ; if (settings) diff --git a/src/app/ui/CardBreadcrumbs.tsx b/src/app/ui/CardBreadcrumbs.tsx new file mode 100644 index 0000000..9ea035d --- /dev/null +++ b/src/app/ui/CardBreadcrumbs.tsx @@ -0,0 +1,20 @@ +import type { FC } from "react"; +import { useTranslation } from "react-i18next"; +import { useBankCard } from "@/entities/bank-card"; +import { BreadcrumbTrail } from "./BreadcrumbTrail"; + +type CardBreadcrumbsProps = { identifier: string }; +const CardBreadcrumbs: FC = (props) => { + const { data } = useBankCard(props.identifier); + const { t } = useTranslation(); + return ( + + ); +}; + +export { CardBreadcrumbs }; diff --git a/src/entities/bank-card/api/banking.test.tsx b/src/entities/bank-card/api/banking.test.tsx new file mode 100644 index 0000000..0a180b7 --- /dev/null +++ b/src/entities/bank-card/api/banking.test.tsx @@ -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, 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(); + 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((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; + 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(); + 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(); + 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); + }); +}); diff --git a/src/entities/bank-card/api/mutations.ts b/src/entities/bank-card/api/mutations.ts new file mode 100644 index 0000000..910320d --- /dev/null +++ b/src/entities/bank-card/api/mutations.ts @@ -0,0 +1,111 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { apiClient, apiErrorCode } from "@/shared/api"; +import type { + CreateBankCard, + CreateBankOperation, + CreateExpenseCategory, + UpdateBankCard, + UpdateBankOperation, + UpdateExpenseCategory, +} from "./queries"; + +async function mutationData( + request: Promise<{ data?: T; error?: unknown }>, +) { + const { data, error } = await request; + if (error) throw error; + return data; +} + +function useBankMutations() { + const queryClient = useQueryClient(); + const invalidate = () => + queryClient.invalidateQueries({ + predicate: ({ queryKey: [method, path] }) => + method === "get" && + typeof path === "string" && + (path === "/api/v1/bank-cards" || + path.startsWith("/api/v1/bank-cards/")), + }); + const options = { + retry: false, + onSuccess: invalidate, + onError: (error: unknown) => { + if ( + [ + "BANK_VERSION_CONFLICT", + "BANK_WRITE_CONFLICT", + "BANK_INTENT_CONFLICT", + "BANK_REFUND_LIMIT", + "BANK_DEPENDENT_REFUNDS", + ].includes(apiErrorCode(error) ?? "") + ) + return invalidate(); + }, + } as const; + const createCard = useMutation({ + ...options, + mutationFn: (body: CreateBankCard) => + mutationData(apiClient.POST("/api/v1/bank-cards", { body })), + }); + const updateCard = useMutation({ + ...options, + mutationFn: ({ id, body }: { id: string; body: UpdateBankCard }) => + mutationData( + apiClient.PATCH("/api/v1/bank-cards/{id}", { + params: { path: { id } }, + body, + }), + ), + }); + const createOperation = useMutation({ + ...options, + mutationFn: (body: CreateBankOperation) => + mutationData(apiClient.POST("/api/v1/bank-cards/operations", { body })), + }); + const updateOperation = useMutation({ + ...options, + mutationFn: ({ id, body }: { id: string; body: UpdateBankOperation }) => + mutationData( + apiClient.PATCH("/api/v1/bank-cards/operations/{id}", { + params: { path: { id } }, + body, + }), + ), + }); + const deleteOperation = useMutation({ + ...options, + mutationFn: ({ id, version }: { id: string; version: number }) => + mutationData( + apiClient.DELETE("/api/v1/bank-cards/operations/{id}", { + params: { path: { id }, query: { version } }, + }), + ), + }); + const createCategory = useMutation({ + ...options, + mutationFn: (body: CreateExpenseCategory) => + mutationData(apiClient.POST("/api/v1/bank-cards/categories", { body })), + }); + const updateCategory = useMutation({ + ...options, + mutationFn: ({ id, body }: { id: string; body: UpdateExpenseCategory }) => + mutationData( + apiClient.PATCH("/api/v1/bank-cards/categories/{id}", { + params: { path: { id } }, + body, + }), + ), + }); + return { + createCard, + updateCard, + createOperation, + updateOperation, + deleteOperation, + createCategory, + updateCategory, + }; +} + +export { useBankMutations }; diff --git a/src/entities/bank-card/api/queries.ts b/src/entities/bank-card/api/queries.ts new file mode 100644 index 0000000..d083f79 --- /dev/null +++ b/src/entities/bank-card/api/queries.ts @@ -0,0 +1,117 @@ +import { + apiQueryClient, + type paths, + useAuthenticatedQuery, +} from "@/shared/api"; + +type BankCard = + paths["/api/v1/bank-cards/{id}"]["get"]["responses"][200]["content"]["application/json"]; +type BankOperation = + paths["/api/v1/bank-cards/operations/{id}"]["get"]["responses"][200]["content"]["application/json"]; +type BankSummary = + paths["/api/v1/bank-cards/summary"]["get"]["responses"][200]["content"]["application/json"]; +type ExpenseCategory = + paths["/api/v1/bank-cards/categories"]["get"]["responses"][200]["content"]["application/json"][number]; +type CreateBankCard = + paths["/api/v1/bank-cards"]["post"]["requestBody"]["content"]["application/json"]; +type UpdateBankCard = + paths["/api/v1/bank-cards/{id}"]["patch"]["requestBody"]["content"]["application/json"]; +type CreateBankOperation = + paths["/api/v1/bank-cards/operations"]["post"]["requestBody"]["content"]["application/json"]; +type UpdateBankOperation = + paths["/api/v1/bank-cards/operations/{id}"]["patch"]["requestBody"]["content"]["application/json"]; +type CreateExpenseCategory = + paths["/api/v1/bank-cards/categories"]["post"]["requestBody"]["content"]["application/json"]; +type UpdateExpenseCategory = + paths["/api/v1/bank-cards/categories/{id}"]["patch"]["requestBody"]["content"]["application/json"]; + +function useBankCards(includeArchived = false) { + return useAuthenticatedQuery( + apiQueryClient.queryOptions("get", "/api/v1/bank-cards", { + params: { + query: { includeArchived: includeArchived ? "true" : "false" }, + }, + }), + ); +} + +function useBankCard(id: string | undefined) { + return useAuthenticatedQuery({ + ...apiQueryClient.queryOptions("get", "/api/v1/bank-cards/{id}", { + params: { path: { id: id ?? "" } }, + }), + enabled: Boolean(id), + }); +} + +function useBankCategories() { + return useAuthenticatedQuery( + apiQueryClient.queryOptions("get", "/api/v1/bank-cards/categories"), + ); +} + +function useBankOperations( + id: string | undefined, + query: { month: string; page: number; limit?: number }, +) { + return useAuthenticatedQuery({ + ...apiQueryClient.queryOptions( + "get", + "/api/v1/bank-cards/{id}/operations", + { params: { path: { id: id ?? "" }, query } }, + ), + enabled: Boolean(id && query.month), + }); +} + +function useBankExpenses( + id: string | undefined, + query: { page: number; limit?: number }, +) { + return useAuthenticatedQuery({ + ...apiQueryClient.queryOptions("get", "/api/v1/bank-cards/{id}/expenses", { + params: { path: { id: id ?? "" }, query }, + }), + enabled: Boolean(id), + }); +} + +function useBankSummary(month: string, cardId?: string) { + return useAuthenticatedQuery({ + ...apiQueryClient.queryOptions("get", "/api/v1/bank-cards/summary", { + params: { query: { month, cardId } }, + }), + enabled: Boolean(month), + }); +} + +function useBankOperation(id: string | undefined) { + return useAuthenticatedQuery({ + ...apiQueryClient.queryOptions( + "get", + "/api/v1/bank-cards/operations/{id}", + { params: { path: { id: id ?? "" } } }, + ), + enabled: Boolean(id), + }); +} + +export { + type BankCard, + type BankOperation, + type BankSummary, + type CreateBankCard, + type CreateBankOperation, + type CreateExpenseCategory, + type ExpenseCategory, + type UpdateBankCard, + type UpdateBankOperation, + type UpdateExpenseCategory, + useBankCard, + useBankCards, + useBankCategories, + useBankExpenses, + useBankOperation, + useBankOperations, + useBankSummary, +}; diff --git a/src/entities/bank-card/index.ts b/src/entities/bank-card/index.ts new file mode 100644 index 0000000..cfb514d --- /dev/null +++ b/src/entities/bank-card/index.ts @@ -0,0 +1,25 @@ +export { useBankMutations } from "./api/mutations"; +export { + type BankCard, + type BankOperation, + useBankCard, + useBankCards, + useBankCategories, + useBankExpenses, + useBankOperation, + useBankOperations, + useBankSummary, +} from "./api/queries"; +export { + bankDateSchema, + bankMoneyValue, + bankMonth, + bankToday, + moneyCents, + positiveMoneySchema, +} from "./model/form-validation"; +export { BankCardForm } from "./ui/BankCardForm"; +export { BankFeedback } from "./ui/BankFeedback"; +export { BankSummaryView } from "./ui/BankSummary.view"; +export { CategoryManager } from "./ui/CategoryManager"; +export { useBankFormFocus } from "./ui/useBankFormFocus"; diff --git a/src/entities/bank-card/model/form-validation.test.ts b/src/entities/bank-card/model/form-validation.test.ts new file mode 100644 index 0000000..225e278 --- /dev/null +++ b/src/entities/bank-card/model/form-validation.test.ts @@ -0,0 +1,56 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + bankCardSchema, + bankDateSchema, + bankMoneyValue, + bankMonth, + bankToday, + positiveMoneySchema, +} from "./form-validation"; + +afterEach(() => vi.useRealTimers()); +describe("bank form input", () => { + it("keeps input bounds exact without rounding and canonicalizes leading zeros", () => { + expect(positiveMoneySchema.safeParse("92233720368547758.07").success).toBe( + true, + ); + for (const invalid of [ + "92233720368547758.08", + "1.001", + "0", + "-1", + "abc", + "1e3", + ]) { + expect(positiveMoneySchema.safeParse(invalid).success).toBe(false); + } + expect(bankMoneyValue(" -00010,20 ")).toBe("-10.20"); + const values = { + name: "A", + bankName: "", + lastFour: "", + openingBalance: "-92233720368547758.08", + openingDate: "2026-01-01", + }; + expect(bankCardSchema.safeParse(values).success).toBe(true); + expect( + bankCardSchema.safeParse({ + ...values, + openingBalance: "-92233720368547758.09", + }).success, + ).toBe(false); + expect( + bankCardSchema.safeParse({ ...values, lastFour: "123" }).success, + ).toBe(false); + }); + it("uses Moscow date and refuses nonexistent or future calendar days", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-08-31T21:30:00Z")); + expect(bankToday()).toBe("2026-09-01"); + expect(bankMonth(null)).toBe("2026-09"); + expect(bankMonth("2026-13")).toBe("2026-09"); + expect(bankDateSchema.safeParse("2026-09-01").success).toBe(true); + expect(bankDateSchema.safeParse("2026-09-02").success).toBe(false); + expect(bankDateSchema.safeParse("2026-02-30").success).toBe(false); + }); +}); diff --git a/src/entities/bank-card/model/form-validation.ts b/src/entities/bank-card/model/form-validation.ts new file mode 100644 index 0000000..490866f --- /dev/null +++ b/src/entities/bank-card/model/form-validation.ts @@ -0,0 +1,85 @@ +import { z } from "zod"; +import { validationMessage } from "@/shared/i18n"; + +function bankToday() { + return new Intl.DateTimeFormat("en-CA", { + timeZone: "Europe/Moscow", + year: "numeric", + month: "2-digit", + day: "2-digit", + }).format(new Date()); +} +function bankMonth(value: string | null) { + return value && /^\d{4}-(0[1-9]|1[0-2])$/.test(value) + ? value + : bankToday().slice(0, 7); +} +function bankMoneyValue(value: string) { + return value + .trim() + .replace(",", ".") + .replace(/^(-?)0+(?=\d)/, "$1"); +} +function moneyCents(value: string): bigint | null { + const normalized = value.trim().replace(",", "."); + if (!/^-?\d+(\.\d{1,2})?$/.test(normalized)) return null; + const [whole, fraction = ""] = normalized.replace("-", "").split("."); + return ( + BigInt(`${whole}${fraction.padEnd(2, "0")}`) * + (normalized.startsWith("-") ? -1n : 1n) + ); +} +const moneySchema = z.string().refine((value) => { + const cents = moneyCents(value); + return ( + cents !== null && + cents >= -9223372036854775808n && + cents <= 9223372036854775807n + ); +}, validationMessage("bank.validation.money")); +const positiveMoneySchema = moneySchema.refine((value) => { + const cents = moneyCents(value); + return cents === null || cents > 0n; +}, validationMessage("bank.validation.positive")); +const bankDateSchema = z + .string() + .refine( + (value) => + /^\d{4}-\d{2}-\d{2}$/.test(value) && + value >= "0001-01-01" && + !Number.isNaN(Date.parse(`${value}T00:00:00Z`)) && + new Date(`${value}T00:00:00Z`).toISOString().slice(0, 10) === value && + value <= bankToday(), + validationMessage("bank.validation.date"), + ); +const bankCardSchema = z.object({ + name: z + .string() + .trim() + .min(1, validationMessage("bank.validation.name")(undefined)) + .max(100, validationMessage("bank.validation.name")(undefined)), + bankName: z + .string() + .trim() + .max(100, validationMessage("bank.validation.bankName")(undefined)), + lastFour: z + .string() + .refine( + (value) => !value || /^\d{4}$/.test(value), + validationMessage("bank.validation.lastFour"), + ), + openingBalance: moneySchema, + openingDate: bankDateSchema, +}); +type BankCardValues = z.infer; + +export { + type BankCardValues, + bankCardSchema, + bankDateSchema, + bankMoneyValue, + bankMonth, + bankToday, + moneyCents, + positiveMoneySchema, +}; diff --git a/src/entities/bank-card/ui/BankCardForm.stories.tsx b/src/entities/bank-card/ui/BankCardForm.stories.tsx new file mode 100644 index 0000000..a5580c7 --- /dev/null +++ b/src/entities/bank-card/ui/BankCardForm.stories.tsx @@ -0,0 +1,44 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { FormProvider, useForm } from "react-hook-form"; +import type { BankCardValues } from "../model/form-validation"; +import { BankCardFields } from "./BankCardForm.view"; + +const meta = { + title: "Product/BankCards/CardFields", + component: BankCardFields, + tags: ["autodocs"], + args: { archived: false }, + decorators: [ + (Story) => { + const methods = useForm({ + defaultValues: { + name: "Учебная повседневная карта", + bankName: "Вымышленный банк", + lastFour: "1234", + openingBalance: "-92233720368547758.08", + openingDate: "2026-08-01", + }, + }); + return ( + + + + ); + }, + ], + parameters: { + docs: { + description: { + component: + "Поля ручной дебетовой карты на вымышленных данных. Сохранения и обращения к API нет. Архив оставляет описательные сведения доступными, а финансовые поля — только для чтения.", + }, + }, + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; +export const Active: Story = { name: "Независимая карта в рублях" }; +export const Archived: Story = { + name: "Финансовые поля архивной карты", + args: { archived: true }, +}; diff --git a/src/entities/bank-card/ui/BankCardForm.test.tsx b/src/entities/bank-card/ui/BankCardForm.test.tsx new file mode 100644 index 0000000..14ed802 --- /dev/null +++ b/src/entities/bank-card/ui/BankCardForm.test.tsx @@ -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( + + + + + , + ); + 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[] = []; + 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; + 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]); + }); +}); diff --git a/src/entities/bank-card/ui/BankCardForm.tsx b/src/entities/bank-card/ui/BankCardForm.tsx new file mode 100644 index 0000000..1c45e8e --- /dev/null +++ b/src/entities/bank-card/ui/BankCardForm.tsx @@ -0,0 +1,144 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import { Button, Group, Modal, Stack, Text } from "@mantine/core"; +import { type FC, useState } from "react"; +import { FormProvider, useForm } from "react-hook-form"; +import { useTranslation } from "react-i18next"; +import { useBankMutations } from "../api/mutations"; +import { type BankCard, useBankCard } from "../api/queries"; +import { + type BankCardValues, + bankCardSchema, + bankMoneyValue, + bankToday, +} from "../model/form-validation"; +import { BankCardFields } from "./BankCardForm.view"; +import { BankFeedback } from "./BankFeedback"; +import classes from "./BankForms.module.css"; +import { useBankFormFocus } from "./useBankFormFocus"; + +type BankCardFormProps = { card?: BankCard; onClose: () => void }; +const BankCardForm: FC = (props) => { + useBankFormFocus(); + const { t } = useTranslation(); + const [intent] = useState(() => crypto.randomUUID()); + const [version, setVersion] = useState(props.card?.version ?? 1); + const [error, setError] = useState(); + const [latest, setLatest] = useState(); + const fresh = useBankCard(props.card?.id); + const mutation = useBankMutations(); + const methods = useForm({ + resolver: zodResolver(bankCardSchema), + defaultValues: { + name: props.card?.name ?? "", + bankName: props.card?.bankName ?? "", + lastFour: props.card?.lastFour ?? "", + openingBalance: props.card?.openingBalance.value ?? "0", + openingDate: props.card?.openingDate ?? bankToday(), + }, + }); + const pending = + methods.formState.isSubmitting || (fresh.isFetching && Boolean(error)); + return ( + !pending && props.onClose()} + title={t(props.card ? "bank.editCard" : "bank.addCard")} + closeButtonProps={{ "aria-label": t("bank.close"), disabled: pending }} + closeOnClickOutside={!pending} + closeOnEscape={!pending} + > + + { + setError(undefined); + try { + const metadata = { + name: values.name, + bankName: values.bankName || null, + lastFour: values.lastFour || null, + }; + const financial = { + openingBalance: { + currency: "RUB" as const, + value: bankMoneyValue(values.openingBalance), + }, + openingDate: values.openingDate, + }; + if (props.card) + await mutation.updateCard.mutateAsync({ + id: props.card.id, + body: { + ...metadata, + ...(!props.card.archivedAt ? financial : {}), + version, + }, + }); + else + await mutation.createCard.mutateAsync({ + id: intent, + ...metadata, + ...financial, + }); + props.onClose(); + } catch (failure) { + setError(failure); + } + })} + > + + + + {error ? ( + <> + + {props.card && ( + { + const response = await fresh.refetch(); + if (response.data && !response.error) + setLatest(response.data); + else setError(response.error); + }} + > + {t("bank.reloadRecord")} + + )} + > + ) : null} + {latest && ( + <> + + {t("bank.versionHint", { + details: `${latest.name} · ${latest.openingBalance.value} RUB · ${latest.openingDate}`, + })} + + { + setVersion(latest.version); + setLatest(undefined); + setError(undefined); + }} + > + {t("bank.adoptVersion", { version: latest.version })} + + > + )} + + + {t("bank.save")} + + + {t("bank.cancel")} + + + + + + + + ); +}; + +export { BankCardForm }; diff --git a/src/entities/bank-card/ui/BankCardForm.view.tsx b/src/entities/bank-card/ui/BankCardForm.view.tsx new file mode 100644 index 0000000..e2be3c4 --- /dev/null +++ b/src/entities/bank-card/ui/BankCardForm.view.tsx @@ -0,0 +1,87 @@ +import { Stack, Text, TextInput } from "@mantine/core"; +import { DateInput } from "@mantine/dates"; +import type { FC } from "react"; +import { Controller, useFormContext } from "react-hook-form"; +import { useTranslation } from "react-i18next"; +import { + type BankCardValues, + bankDateSchema, + bankToday, +} from "../model/form-validation"; + +type BankCardFieldsProps = { archived: boolean }; +const BankCardFields: FC = (props) => { + const { + control, + register, + formState: { errors }, + } = useFormContext(); + const { t } = useTranslation(); + return ( + + + + + + ( + + bankDateSchema.safeParse(value).success ? value : null + } + fixOnBlur={false} + onInput={(event) => field.onChange(event.currentTarget.value)} + onChange={(value) => field.onChange(value ?? "")} + onBlur={(event) => { + field.onChange(event.currentTarget.value); + field.onBlur(); + }} + maxDate={bankToday()} + popoverProps={{ + withinPortal: false, + middlewares: { shift: { crossAxis: true } }, + }} + readOnly={props.archived} + error={errors.openingDate?.message} + /> + )} + /> + + {t("bank.openingHint")} + + {props.archived && {t("bank.archiveHint")}} + + ); +}; + +export { BankCardFields }; diff --git a/src/entities/bank-card/ui/BankFeedback.tsx b/src/entities/bank-card/ui/BankFeedback.tsx new file mode 100644 index 0000000..0b2ef22 --- /dev/null +++ b/src/entities/bank-card/ui/BankFeedback.tsx @@ -0,0 +1,37 @@ +import { Alert, Button, Group, Text } from "@mantine/core"; +import type { FC } from "react"; +import { useTranslation } from "react-i18next"; +import { apiErrorCode } from "@/shared/api"; + +type BankFeedbackProps = { + error: unknown; + retry?: () => void; + cached?: boolean; + loadingError?: boolean; +}; +const BankFeedback: FC = (props) => { + const { t } = useTranslation(); + const code = apiErrorCode(props.error); + return ( + + + + {props.cached + ? t("bank.refreshError") + : t(`bank.errors.${code}` as "bank.errors.BANK_INVALID_INPUT", { + defaultValue: t( + props.loadingError ? "bank.error" : "bank.requestError", + ), + })} + + {props.retry && ( + + {t("bank.retry")} + + )} + + + ); +}; + +export { BankFeedback }; diff --git a/src/entities/bank-card/ui/BankForms.module.css b/src/entities/bank-card/ui/BankForms.module.css new file mode 100644 index 0000000..1f24583 --- /dev/null +++ b/src/entities/bank-card/ui/BankForms.module.css @@ -0,0 +1,6 @@ +.fieldset { + border: 0; + padding: 0; + margin: 0; + min-width: 0; +} diff --git a/src/entities/bank-card/ui/BankSummary.stories.tsx b/src/entities/bank-card/ui/BankSummary.stories.tsx new file mode 100644 index 0000000..3b9387e --- /dev/null +++ b/src/entities/bank-card/ui/BankSummary.stories.tsx @@ -0,0 +1,102 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { BankSummaryView } from "./BankSummary.view"; + +const money = (value: string) => ({ currency: "RUB", value }); +const meta = { + title: "Product/BankCards/Summary", + component: BankSummaryView, + tags: ["autodocs"], + parameters: { + docs: { + description: { + component: + "Месячные расходы ручного учёта. Вымышленные суммы, без API и сохранения. Возвраты учитываются отдельно; отрицательный итог не превращается в процентную диаграмму.", + }, + }, + }, + args: { + loading: false, + error: false, + fetching: false, + retry: () => {}, + data: { + month: "2026-09", + cardId: null, + expenses: money("8500"), + refunds: money("1000"), + net: money("7500"), + categories: [ + { + categoryId: "food", + categoryName: "Продукты", + expenses: money("8500"), + refunds: money("1000"), + net: money("7500"), + }, + ], + }, + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; +export const Loaded: Story = { name: "Расходы и возвраты" }; +export const RefundMonth: Story = { + name: "Возврат покупки прошлого месяца", + args: { + data: { + month: "2026-09", + cardId: null, + expenses: money("0"), + refunds: money("600"), + net: money("-600"), + categories: [ + { + categoryId: "shopping", + categoryName: + "Покупки с длинным пользовательским названием категории", + expenses: money("0"), + refunds: money("600"), + net: money("-600"), + }, + ], + }, + }, +}; +export const Empty: Story = { + name: "Известные нули", + args: { + data: { + month: "2026-09", + cardId: null, + expenses: money("0"), + refunds: money("0"), + net: money("0"), + categories: [], + }, + }, +}; +export const Loading: Story = { + name: "Начальная загрузка", + args: { data: undefined, loading: true }, +}; +export const Failed: Story = { + name: "Ошибка без данных", + args: { data: undefined, error: true }, +}; +export const CachedError: Story = { + name: "Сохранённые суммы при ошибке обновления", + args: { error: true }, +}; +export const LongAmounts: Story = { + name: "Точный итог длиннее предела отдельного ввода", + args: { + data: { + month: "2026-09", + cardId: null, + expenses: money("184467440737095516.14"), + refunds: money("0"), + net: money("184467440737095516.14"), + categories: [], + }, + }, +}; diff --git a/src/entities/bank-card/ui/BankSummary.view.tsx b/src/entities/bank-card/ui/BankSummary.view.tsx new file mode 100644 index 0000000..93f788b --- /dev/null +++ b/src/entities/bank-card/ui/BankSummary.view.tsx @@ -0,0 +1,90 @@ +import { + Button, + Group, + Paper, + SimpleGrid, + Skeleton, + Stack, + Text, + Title, +} from "@mantine/core"; +import type { FC } from "react"; +import { useTranslation } from "react-i18next"; +import { formatMoney } from "@/shared/lib"; +import { LoadingSkeleton } from "@/shared/ui"; +import type { BankSummary } from "../api/queries"; + +type BankSummaryViewProps = { + data?: BankSummary; + loading: boolean; + error: boolean; + fetching: boolean; + retry: () => void; + all?: boolean; +}; +const BankSummaryView: FC = (props) => { + const { t } = useTranslation(); + return ( + + + + {t(props.all ? "bank.allSummary" : "bank.summary")} + + {props.data ? ( + <> + + {(["expenses", "refunds", "net"] as const).map((key) => ( + + {t(`bank.${key}`)} + + {formatMoney(props.data?.[key] ?? null)} + + + ))} + + + {t("bank.summaryHint")} + + {props.data.categories.length ? ( + props.data.categories.map((category) => ( + + {category.categoryName} + + {(["expenses", "refunds", "net"] as const).map((key) => ( + + {t(`bank.${key}`)}: {formatMoney(category[key])} + + ))} + + + )) + ) : ( + {t("bank.noExpenses")} + )} + > + ) : props.loading ? ( + + + + + + + + ) : null} + {props.error && ( + + {t(props.data ? "bank.refreshError" : "bank.error")} + + {t("bank.retry")} + + + )} + {props.data && props.fetching && ( + {t("bank.refreshing")} + )} + + + ); +}; + +export { BankSummaryView }; diff --git a/src/entities/bank-card/ui/CategoryManager.test.tsx b/src/entities/bank-card/ui/CategoryManager.test.tsx new file mode 100644 index 0000000..4179ef3 --- /dev/null +++ b/src/entities/bank-card/ui/CategoryManager.test.tsx @@ -0,0 +1,74 @@ +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 } from "vitest"; +import { configureAuthClient } from "@/shared/api"; +import { createTestQueryClient } from "@/test/query"; +import { server } from "@/test/server"; +import { CategoryManager } from "./CategoryManager"; + +describe("CategoryManager", () => { + it("rejects duplicate case-insensitive and too long names then renames default identity", async () => { + configureAuthClient({ + accessToken: () => "test", + refresh: async () => false, + clear: () => {}, + }); + let category = { + id: "default", + name: "Другое", + isDefault: true, + version: 1, + }; + let creates = 0; + let body: unknown; + server.use( + http.get("*/api/v1/bank-cards/categories", () => + HttpResponse.json([category]), + ), + http.post("*/api/v1/bank-cards/categories", () => { + creates++; + return HttpResponse.json({}); + }), + http.patch( + "*/api/v1/bank-cards/categories/default", + async ({ request }) => { + body = await request.json(); + category = { ...category, name: "Остальное", version: 2 }; + return HttpResponse.json(category); + }, + ), + ); + render( + + + {}} /> + + , + ); + await screen.findByText("Другое"); + fireEvent.change(screen.getByLabelText(/Название категории/), { + target: { value: " другое " }, + }); + fireEvent.click(screen.getByRole("button", { name: "Добавить категорию" })); + await screen.findByText("Категория с таким названием уже есть."); + fireEvent.change(screen.getByLabelText(/Название категории/), { + target: { value: "a".repeat(81) }, + }); + fireEvent.click(screen.getByRole("button", { name: "Добавить категорию" })); + await screen.findByText("Введите название от 1 до 80 символов."); + expect(creates).toBe(0); + fireEvent.click(screen.getByRole("button", { name: "Переименовать" })); + fireEvent.input(screen.getByLabelText(/Название категории/), { + target: { value: "Остальное" }, + }); + fireEvent.blur(screen.getByLabelText(/Название категории/)); + fireEvent.click(screen.getByRole("button", { name: "Сохранить" })); + await screen.findByText("Остальное"); + await waitFor(() => + expect(body).toEqual({ name: "Остальное", version: 1 }), + ); + expect(screen.getByText("По умолчанию")).toBeInTheDocument(); + }); +}); diff --git a/src/entities/bank-card/ui/CategoryManager.tsx b/src/entities/bank-card/ui/CategoryManager.tsx new file mode 100644 index 0000000..2801f18 --- /dev/null +++ b/src/entities/bank-card/ui/CategoryManager.tsx @@ -0,0 +1,215 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import { + Badge, + Button, + Group, + Modal, + Stack, + Text, + TextInput, +} from "@mantine/core"; +import { type FC, useState } from "react"; +import { + Controller, + FormProvider, + useForm, + useFormContext, +} from "react-hook-form"; +import { useTranslation } from "react-i18next"; +import { z } from "zod"; +import { validationMessage } from "@/shared/i18n"; +import { useBankMutations } from "../api/mutations"; +import { type ExpenseCategory, useBankCategories } from "../api/queries"; +import { BankFeedback } from "./BankFeedback"; +import classes from "./BankForms.module.css"; +import { useBankFormFocus } from "./useBankFormFocus"; + +type CategoryManagerProps = { + onClose: () => void; + onCreated?: (id: string) => void; +}; +type CategoryValues = { name: string }; +const CategoryFields: FC = () => { + const { t } = useTranslation(); + const { + control, + formState: { errors }, + } = useFormContext(); + return ( + ( + + )} + /> + ); +}; +const CategoryManager: FC = (props) => { + useBankFormFocus(); + const { t } = useTranslation(); + const categories = useBankCategories(); + const mutation = useBankMutations(); + const [editing, setEditing] = useState(); + const [error, setError] = useState(); + const [latest, setLatest] = useState(); + const methods = useForm({ + resolver: zodResolver( + z.object({ + name: z + .string() + .trim() + .refine( + (value) => value.length > 0 && value.length <= 80, + validationMessage("bank.validation.category"), + ) + .refine( + (value) => + !categories.data?.some( + (c) => + c.id !== editing?.id && + c.name.toLocaleLowerCase("ru-RU") === + value.toLocaleLowerCase("ru-RU"), + ), + validationMessage("bank.validation.duplicate"), + ), + }), + ), + defaultValues: { name: "" }, + }); + const pending = methods.formState.isSubmitting; + return ( + !pending && props.onClose()} + title={t("bank.categories")} + closeOnClickOutside={!pending} + closeOnEscape={!pending} + closeButtonProps={{ "aria-label": t("bank.close"), disabled: pending }} + > + + { + setError(undefined); + try { + if (editing) + await mutation.updateCategory.mutateAsync({ + id: editing.id, + body: { name: values.name, version: editing.version }, + }); + else { + const result = await mutation.createCategory.mutateAsync({ + name: values.name, + }); + if (result) props.onCreated?.(result.id); + } + setEditing(undefined); + methods.reset({ name: "" }); + } catch (failure) { + setError(failure); + } + })} + > + + + {categories.data?.map((category) => ( + + + {category.name} + {category.isDefault && ( + {t("bank.defaultCategory")} + )} + + { + setEditing(category); + methods.reset({ name: category.name }); + setError(undefined); + setLatest(undefined); + }} + > + {t("bank.rename")} + + + ))} + {categories.isLoading && ( + {t("bank.loading")} + )} + {categories.isError && ( + void categories.refetch()} + /> + )} + + {error ? ( + <> + + {editing && ( + { + const response = await categories.refetch(); + if (response.error) setError(response.error); + else + setLatest( + response.data?.find((c) => c.id === editing.id), + ); + }} + > + {t("bank.reloadRecord")} + + )} + > + ) : null} + {latest && ( + <> + {t("bank.versionHint", { details: latest.name })} + { + setEditing(latest); + setLatest(undefined); + setError(undefined); + }} + > + {t("bank.adoptVersion", { version: latest.version })} + + > + )} + + + {t(editing ? "bank.save" : "bank.addCategory")} + + {editing && ( + { + setEditing(undefined); + methods.reset({ name: "" }); + setError(undefined); + }} + > + {t("bank.cancel")} + + )} + + {t("bank.close")} + + + + + + + + ); +}; + +export { CategoryManager }; diff --git a/src/entities/bank-card/ui/useBankFormFocus.ts b/src/entities/bank-card/ui/useBankFormFocus.ts new file mode 100644 index 0000000..85a0845 --- /dev/null +++ b/src/entities/bank-card/ui/useBankFormFocus.ts @@ -0,0 +1,16 @@ +import { useEffect, useState } from "react"; + +function useBankFormFocus() { + const [trigger] = useState(() => document.activeElement); + useEffect( + () => () => { + queueMicrotask(() => { + if (trigger instanceof HTMLElement && trigger.isConnected) + trigger.focus(); + }); + }, + [trigger], + ); +} + +export { useBankFormFocus }; diff --git a/src/features/auth/model/auth.test.tsx b/src/features/auth/model/auth.test.tsx index 67233ed..ecc997d 100644 --- a/src/features/auth/model/auth.test.tsx +++ b/src/features/auth/model/auth.test.tsx @@ -8,6 +8,10 @@ import { createTestQueryClient } from "@/test/query"; import { server } from "@/test/server"; import { AuthProvider, useAuth } from "./auth"; +const bankCardsKey = apiQueryClient.queryOptions("get", "/api/v1/bank-cards", { + params: { query: { includeArchived: "false" } }, +}).queryKey; + describe("AuthProvider", () => { it("authorizes requests after restoring the initial session", async () => { let authorization: string | null = null; @@ -54,6 +58,7 @@ describe("AuthProvider", () => { server.use(http.post("*/api/v1/auth/refresh", () => HttpResponse.error())); const queryClient = createTestQueryClient(); queryClient.setQueryData(["cached-query"], { value: "stale" }); + queryClient.setQueryData(bankCardsKey, [{ id: "bank-card-1" }]); const wrapper: FC = (props) => { const { children } = props; @@ -70,6 +75,7 @@ describe("AuthProvider", () => { expect(result.current.user).toBeNull(); expect(result.current.accessToken).toBeNull(); expect(queryClient.getQueryData(["cached-query"])).toBeUndefined(); + expect(queryClient.getQueryData(bankCardsKey)).toBeUndefined(); }); it("keeps the original protected 401 when serialized refresh has a transport error", async () => { @@ -90,6 +96,7 @@ describe("AuthProvider", () => { ); const queryClient = createTestQueryClient(); queryClient.setQueryData(["cached-query"], { value: "stale" }); + queryClient.setQueryData(bankCardsKey, [{ id: "bank-card-1" }]); const wrapper: FC = (props) => { const { children } = props; @@ -110,6 +117,7 @@ describe("AuthProvider", () => { await waitFor(() => expect(result.current.user).toBeNull()); expect(result.current.accessToken).toBeNull(); expect(queryClient.getQueryData(["cached-query"])).toBeUndefined(); + expect(queryClient.getQueryData(bankCardsKey)).toBeUndefined(); }); it("clears connection status and financial cache independently on logout", async () => { @@ -136,6 +144,7 @@ describe("AuthProvider", () => { connectedAt: "2026-08-14T00:00:00Z", }); queryClient.setQueryData(portfolioKey, { source: "current-connection" }); + queryClient.setQueryData(bankCardsKey, [{ id: "bank-card-1" }]); const wrapper: FC = (props) => { const { children } = props; return ( @@ -147,11 +156,15 @@ describe("AuthProvider", () => { const { result } = renderHook(() => useAuth(), { wrapper }); await waitFor(() => expect(result.current.accessToken).toBe("test-token")); + expect(queryClient.getQueryData(bankCardsKey)).toEqual([ + { id: "bank-card-1" }, + ]); await act(() => result.current.logout()); expect(result.current.user).toBeNull(); expect(result.current.accessToken).toBeNull(); expect(queryClient.getQueryData(statusKey)).toBeUndefined(); expect(queryClient.getQueryData(portfolioKey)).toBeUndefined(); + expect(queryClient.getQueryData(bankCardsKey)).toBeUndefined(); }); }); diff --git a/src/features/tbank-connection/ui/TbankConnectionSection.test.tsx b/src/features/tbank-connection/ui/TbankConnectionSection.test.tsx index 71c4f3c..be680e1 100644 --- a/src/features/tbank-connection/ui/TbankConnectionSection.test.tsx +++ b/src/features/tbank-connection/ui/TbankConnectionSection.test.tsx @@ -32,6 +32,13 @@ const bondQueryKey = apiQueryClient.queryOptions( "/api/v1/bonds/{uid}/position", { params: { path: { uid: "e6123145-9665-43e0-8413-cd61b8aa9b13" } } }, ).queryKey; +const bankCardsQueryKey = apiQueryClient.queryOptions( + "get", + "/api/v1/bank-cards", + { + params: { query: { includeArchived: "false" } }, + }, +).queryKey; const unrelatedQueryKey = apiQueryClient.queryOptions( "get", "/api/v1/auth/me", @@ -66,6 +73,7 @@ function seededClient() { client.setQueryData(stockQueryKey, { source: "previous-connection" }); client.setQueryData(bondQueryKey, { source: "previous-connection" }); client.setQueryData(unrelatedQueryKey, { id: "user-1" }); + client.setQueryData(bankCardsQueryKey, [{ id: "bank-card-1" }]); return client; } @@ -86,6 +94,10 @@ describe("TbankConnectionSection", () => { ).toBeInTheDocument(), ); expect(client.getQueryData(brokerageAccountsQueryKey)).toBeUndefined(); + expect(client.getQueryData(bankCardsQueryKey)).toEqual([ + { id: "bank-card-1" }, + ]); + expect(client.getQueryState(bankCardsQueryKey)?.isInvalidated).toBe(false); expect(client.getQueryData(portfolioQueryKey)).toBeUndefined(); expect(client.getQueryData(stockQueryKey)).toBeUndefined(); expect(client.getQueryData(bondQueryKey)).toBeUndefined(); @@ -114,6 +126,10 @@ describe("TbankConnectionSection", () => { expect(client.getQueryData(portfolioQueryKey)).toBeUndefined(), ); expect(client.getQueryData(brokerageAccountsQueryKey)).toBeUndefined(); + expect(client.getQueryData(bankCardsQueryKey)).toEqual([ + { id: "bank-card-1" }, + ]); + expect(client.getQueryState(bankCardsQueryKey)?.isInvalidated).toBe(false); }); it("commits disconnected status and resets financial queries after deleting", async () => { @@ -139,6 +155,10 @@ describe("TbankConnectionSection", () => { ).toBeInTheDocument(), ); expect(client.getQueryData(brokerageAccountsQueryKey)).toBeUndefined(); + expect(client.getQueryData(bankCardsQueryKey)).toEqual([ + { id: "bank-card-1" }, + ]); + expect(client.getQueryState(bankCardsQueryKey)?.isInvalidated).toBe(false); expect(client.getQueryData(portfolioQueryKey)).toBeUndefined(); expect(client.getQueryData(stockQueryKey)).toBeUndefined(); expect(client.getQueryData(bondQueryKey)).toBeUndefined(); diff --git a/src/pages/card/index.ts b/src/pages/card/index.ts new file mode 100644 index 0000000..7d3a361 --- /dev/null +++ b/src/pages/card/index.ts @@ -0,0 +1 @@ +export { CardPage } from "./ui/CardPage"; diff --git a/src/pages/card/ui/CardPage.module.css b/src/pages/card/ui/CardPage.module.css new file mode 100644 index 0000000..0484ac2 --- /dev/null +++ b/src/pages/card/ui/CardPage.module.css @@ -0,0 +1,19 @@ +.wrap { + overflow-wrap: anywhere; + min-width: 0; +} +.operation { + min-width: 0; +} +.operation p { + white-space: normal; + overflow-wrap: anywhere; +} +.operation > div > div { + min-width: 0; +} +@media (min-width: 36em) { + .operation > div:first-child { + grid-template-columns: auto minmax(0, 1fr) minmax(0, 40%); + } +} diff --git a/src/pages/card/ui/CardPage.test.tsx b/src/pages/card/ui/CardPage.test.tsx new file mode 100644 index 0000000..b01d5ef --- /dev/null +++ b/src/pages/card/ui/CardPage.test.tsx @@ -0,0 +1,231 @@ +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 { MemoryRouter, Route, Routes } from "react-router"; +import { describe, expect, it } from "vitest"; +import { configureAuthClient } from "@/shared/api"; +import { createTestQueryClient } from "@/test/query"; +import { server } from "@/test/server"; +import { CardPage } from "./CardPage"; + +const money = (value: string) => ({ currency: "RUB", value }); +const card = { + id: "a", + name: "Повседневная", + bankName: null, + lastFour: null, + balance: money("10000"), + openingBalance: money("10000"), + openingDate: "2026-01-01", + archivedAt: null, + version: 1, +}; +const operation = { + id: "transfer", + kind: "transfer", + cardId: "a", + cardName: "Повседневная", + targetCardId: "b", + targetCardName: "Запасная", + categoryId: null, + categoryName: null, + originalExpenseId: null, + amount: money("50.01"), + availableRefund: null, + date: "2026-08-03", + description: null, + version: 1, + createdAt: "2026-08-03T00:00:00Z", +}; +function setup( + options: { + missing?: boolean; + summaryError?: boolean; + cardsError?: boolean; + } = {}, +) { + configureAuthClient({ + accessToken: () => "test", + refresh: async () => false, + clear: () => {}, + }); + const requests: string[] = []; + server.use( + http.get("*/api/v1/bank-cards/a", () => + options.missing + ? HttpResponse.json({ code: "BANK_NOT_FOUND" }, { status: 404 }) + : HttpResponse.json(card), + ), + http.get("*/api/v1/bank-cards", () => + options.cardsError + ? HttpResponse.error() + : HttpResponse.json([card, { ...card, id: "b", name: "Запасная" }]), + ), + http.get("*/api/v1/bank-cards/a/operations", ({ request }) => { + requests.push(new URL(request.url).search); + return HttpResponse.json({ + items: [operation], + page: 1, + total: 2, + hasNext: true, + }); + }), + http.get("*/api/v1/bank-cards/summary", () => + options.summaryError + ? HttpResponse.error() + : HttpResponse.json({ + month: "2026-08", + cardId: "a", + expenses: money("0"), + refunds: money("50"), + net: money("-50"), + categories: [], + }), + ), + ); + const client = createTestQueryClient(); + render( + + + + + } /> + + + + , + ); + return { requests, client }; +} +describe("CardPage", () => { + it("shares month with history, pages the journal and keeps current balance", async () => { + const { requests } = setup(); + await screen.findByRole("heading", { name: "Повседневная" }); + expect( + await screen.findByText("Перевод на «Запасная»"), + ).toBeInTheDocument(); + expect(screen.getByText("−50,01 ₽")).toBeInTheDocument(); + expect(screen.getByText("-50 ₽")).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Далее" })); + await waitFor(() => + expect(requests.some((v) => v.includes("page=2"))).toBe(true), + ); + fireEvent.click(screen.getByLabelText("Месяц")); + fireEvent.click(await screen.findByRole("button", { name: "Jul" })); + await waitFor(() => + expect( + requests.some( + (v) => v.includes("month=2026-07") && v.includes("page=1"), + ), + ).toBe(true), + ); + expect(screen.getByText(/10\s000 ₽/)).toBeInTheDocument(); + }); + it("keeps card and journal when summary fails", async () => { + setup({ summaryError: true }); + await screen.findByRole("heading", { name: "Повседневная" }); + await screen.findByRole("alert"); + expect( + await screen.findByText("Перевод на «Запасная»"), + ).toBeInTheDocument(); + expect(screen.queryByText("0 ₽")).not.toBeInTheDocument(); + }); + it("keeps cached card and history after refetch failure", async () => { + const { client } = setup(); + await screen.findByText("Перевод на «Запасная»"); + server.use( + http.get("*/api/v1/bank-cards/a", () => HttpResponse.error()), + http.get("*/api/v1/bank-cards/a/operations", () => HttpResponse.error()), + ); + await client.invalidateQueries(); + await screen.findAllByRole("alert"); + expect( + screen.getByRole("heading", { name: "Повседневная" }), + ).toBeInTheDocument(); + expect(screen.getByText("Перевод на «Запасная»")).toBeInTheDocument(); + }); + it("handles inaccessible card neutrally", async () => { + setup({ missing: true }); + await screen.findByRole("heading", { name: "Карта не найдена" }); + expect(screen.getByRole("link", { name: "К картам" })).toHaveAttribute( + "href", + "/cards", + ); + expect(screen.queryByText(/10\s000 ₽/)).not.toBeInTheDocument(); + }); + it("archives without deleting history and restores financial actions", async () => { + setup(); + await screen.findByText("Перевод на «Запасная»"); + let current = card; + const bodies: unknown[] = []; + server.use( + http.get("*/api/v1/bank-cards/a", () => HttpResponse.json(current)), + http.get("*/api/v1/bank-cards", () => + HttpResponse.json([current, { ...card, id: "b", name: "Запасная" }]), + ), + http.patch("*/api/v1/bank-cards/a", async ({ request }) => { + const body = (await request.json()) as { + version: number; + archived: boolean; + }; + bodies.push(body); + current = { + ...current, + version: current.version + 1, + archivedAt: body.archived ? "2026-09-01" : null, + } as typeof card; + return HttpResponse.json(current); + }), + ); + fireEvent.click(screen.getByRole("button", { name: "Убрать в архив" })); + await screen.findByRole("button", { name: "Вернуть из архива" }); + expect( + screen.getByRole("button", { name: "Добавить операцию" }), + ).toBeDisabled(); + expect(screen.getByRole("button", { name: "Изменить" })).toBeDisabled(); + expect(screen.getByText("Перевод на «Запасная»")).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Вернуть из архива" })); + await screen.findByRole("button", { name: "Убрать в архив" }); + expect( + screen.getByRole("button", { name: "Добавить операцию" }), + ).toBeEnabled(); + expect(bodies).toEqual([ + { version: 1, archived: true }, + { version: 2, archived: false }, + ]); + }); + it("recovers the prerequisite card list while preserving the visible balance and journal", async () => { + setup({ cardsError: true }); + await screen.findByText("Перевод на «Запасная»"); + expect(await screen.findByRole("alert")).toHaveTextContent( + "Не удалось загрузить данные карт.", + ); + expect( + screen.getByRole("button", { name: "Добавить операцию" }), + ).toBeDisabled(); + server.use( + http.get("*/api/v1/bank-cards", () => + HttpResponse.json([card, { ...card, id: "b", name: "Запасная" }]), + ), + ); + fireEvent.click( + within(screen.getByRole("alert")).getByRole("button", { + name: "Повторить", + }), + ); + await waitFor(() => + expect( + screen.getByRole("button", { name: "Добавить операцию" }), + ).toBeEnabled(), + ); + expect(screen.getByText("Перевод на «Запасная»")).toBeInTheDocument(); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + }); +}); diff --git a/src/pages/card/ui/CardPage.tsx b/src/pages/card/ui/CardPage.tsx new file mode 100644 index 0000000..64d95b2 --- /dev/null +++ b/src/pages/card/ui/CardPage.tsx @@ -0,0 +1,37 @@ +import type { FC } from "react"; +import { BankCardForm } from "@/entities/bank-card"; +import { CardPageView } from "./CardPage.view"; +import { useCardPageVm } from "./CardPage.vm"; +import { DeleteOperation } from "./DeleteOperation"; +import { OperationForm } from "./OperationForm"; + +const CardPage: FC = () => { + const vm = useCardPageVm(); + return ( + <> + + {vm.editingCard && vm.card.data && ( + vm.setEditingCard(false)} + /> + )} + {vm.operation && vm.card.data && vm.cards.data && ( + vm.setOperation(null)} + /> + )} + {vm.deleting && ( + vm.setDeleting(null)} + /> + )} + > + ); +}; + +export { CardPage }; diff --git a/src/pages/card/ui/CardPage.view.tsx b/src/pages/card/ui/CardPage.view.tsx new file mode 100644 index 0000000..f56ddd1 --- /dev/null +++ b/src/pages/card/ui/CardPage.view.tsx @@ -0,0 +1,226 @@ +import { + Anchor, + Badge, + Button, + Group, + Paper, + Skeleton, + Stack, + Text, + Title, +} from "@mantine/core"; +import { MonthPickerInput } from "@mantine/dates"; +import type { FC } from "react"; +import { useTranslation } from "react-i18next"; +import { Link } from "react-router"; +import { BankFeedback, BankSummaryView } from "@/entities/bank-card"; +import { formatMoney } from "@/shared/lib"; +import { EntryCard, LoadingSkeleton, StateView } from "@/shared/ui"; +import classes from "./CardPage.module.css"; +import type { CardPageVm } from "./CardPage.vm"; + +type CardPageViewProps = { vm: CardPageVm }; +const CardPageView: FC = (props) => { + const { vm } = props; + const { t } = useTranslation(); + const card = vm.card.data; + if (!card) { + if (vm.card.isLoading) + return ( + + + + + + + + + ); + return ( + + + {t("bank.back")} + + {!vm.notFound && ( + void vm.card.refetch()}> + {t("bank.retry")} + + )} + + } + /> + ); + } + return ( + + + + + + {card.name} + + {card.archivedAt && ( + {t("bank.archived")} + )} + + + {card.bankName} + {card.lastFour ? ` · •• ${card.lastFour}` : ""} + + + {formatMoney(card.balance)} + + {t("bank.manualBalance")} + + vm.setOperation("new")} + > + {t("bank.addOperation")} + + vm.setEditingCard(true)}> + {t("bank.editCard")} + + void vm.toggleArchive()} + > + {t(card.archivedAt ? "bank.restore" : "bank.archive")} + + + {vm.archiveError ? ( + void vm.card.refetch()} + /> + ) : null} + {card.archivedAt && {t("bank.archiveHint")}} + + + {vm.card.isError && ( + + {t("bank.refreshError")} + void vm.card.refetch()}> + {t("bank.retry")} + + + )} + {vm.card.isFetching && {t("bank.refreshing")}} + {vm.cards.isError && ( + void vm.cards.refetch()} + /> + )} + { + if (value) vm.setMonth(value.slice(0, 7)); + }} + popoverProps={{ middlewares: { shift: { crossAxis: true } } }} + /> + void vm.summary.refetch()} + /> + {t("bank.operations")} + {vm.rows ? ( + vm.rows.length ? ( + vm.rows.map((row) => ( + + + {t("bank.card")} + + ) : null + } + /> + + vm.setOperation(row.item)} + > + {t("bank.edit")} + + vm.setDeleting(row.item)} + > + {t("bank.delete")} + + + + )) + ) : ( + {t("bank.noOperations")} + ) + ) : vm.history.isLoading ? ( + + + + + + + ) : null} + {vm.history.isError && ( + + {t(vm.rows ? "bank.refreshError" : "bank.error")} + void vm.history.refetch()}> + {t("bank.retry")} + + + )} + {vm.history.data && ( + + vm.setPage(vm.page - 1)} + > + {t("bank.previous")} + + {t("bank.page", { page: vm.page })} + vm.setPage(vm.page + 1)} + > + {t("bank.next")} + + + )} + + ); +}; + +export { CardPageView }; diff --git a/src/pages/card/ui/CardPage.vm.ts b/src/pages/card/ui/CardPage.vm.ts new file mode 100644 index 0000000..b654330 --- /dev/null +++ b/src/pages/card/ui/CardPage.vm.ts @@ -0,0 +1,103 @@ +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useParams, useSearchParams } from "react-router"; +import { + type BankOperation, + bankMonth, + useBankCard, + useBankCards, + useBankMutations, + useBankOperations, + useBankSummary, +} from "@/entities/bank-card"; +import { apiErrorCode } from "@/shared/api"; +import { formatMoney } from "@/shared/lib"; + +function useCardPageVm() { + const { cardId } = useParams(); + const [params, setParams] = useSearchParams(); + const { t } = useTranslation(); + const month = bankMonth(params.get("month")); + const rawPage = Number(params.get("page") ?? 1); + const page = Number.isSafeInteger(rawPage) && rawPage > 0 ? rawPage : 1; + const card = useBankCard(cardId); + const { updateCard } = useBankMutations(); + const [archiveError, setArchiveError] = useState(); + const cards = useBankCards(true); + const history = useBankOperations(cardId, { month, page }); + const summary = useBankSummary(month, cardId); + const [editingCard, setEditingCard] = useState(false); + const [operation, setOperation] = useState( + null, + ); + const [deleting, setDeleting] = useState(null); + const rows = history.data?.items.map((item) => { + const outgoing = + item.kind === "expense" || + (item.kind === "transfer" && item.cardId === cardId); + return { + item, + title: + item.kind === "transfer" + ? t(outgoing ? "bank.transferTo" : "bank.transferFrom", { + name: outgoing ? item.targetCardName : item.cardName, + }) + : t(`bank.kinds.${item.kind}`), + metadata: [item.date, item.categoryName, item.description] + .filter(Boolean) + .join(" · "), + value: `${outgoing ? "−" : "+"}${formatMoney(item.amount)}`, + tone: outgoing ? ("negative" as const) : ("positive" as const), + otherCard: + item.kind === "transfer" + ? outgoing + ? item.targetCardId + : item.cardId + : null, + editable: + Boolean(cards.data) && + !cards.data?.some( + (c) => + (c.id === item.cardId || c.id === item.targetCardId) && + c.archivedAt, + ), + }; + }); + return { + card, + cards, + archiveError, + archivePending: updateCard.isPending, + toggleArchive: async () => { + if (!card.data) return; + setArchiveError(undefined); + try { + await updateCard.mutateAsync({ + id: card.data.id, + body: { version: card.data.version, archived: !card.data.archivedAt }, + }); + } catch (failure) { + setArchiveError(failure); + } + }, + history, + summary, + month, + page, + rows, + editingCard, + setEditingCard, + operation, + setOperation, + deleting, + setDeleting, + notFound: apiErrorCode(card.error) === "BANK_NOT_FOUND", + setMonth: (value: string) => { + if (value) setParams({ month: bankMonth(value) }); + }, + setPage: (value: number) => setParams({ month, page: String(value) }), + }; +} +type CardPageVm = ReturnType; + +export { type CardPageVm, useCardPageVm }; diff --git a/src/pages/card/ui/DeleteOperation.test.tsx b/src/pages/card/ui/DeleteOperation.test.tsx new file mode 100644 index 0000000..914bb96 --- /dev/null +++ b/src/pages/card/ui/DeleteOperation.test.tsx @@ -0,0 +1,76 @@ +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 type { BankOperation } from "@/entities/bank-card"; +import { configureAuthClient } from "@/shared/api"; +import { createTestQueryClient } from "@/test/query"; +import { server } from "@/test/server"; +import { DeleteOperation } from "./DeleteOperation"; + +const transfer: BankOperation = { + id: "transfer", + kind: "transfer", + cardId: "a", + cardName: "Основная", + targetCardId: "b", + targetCardName: "Запасная", + categoryId: null, + categoryName: null, + originalExpenseId: null, + amount: { currency: "RUB", value: "100" }, + availableRefund: null, + date: "2026-08-01", + description: null, + version: 1, + createdAt: "2026-08-01T00:00:00Z", +}; +describe("DeleteOperation", () => { + it("names both sides and requires a deliberate fresh version after conflict", async () => { + configureAuthClient({ + accessToken: () => "test", + refresh: async () => false, + clear: () => {}, + }); + const versions: string[] = []; + server.use( + http.get("*/api/v1/bank-cards/operations/transfer", () => + HttpResponse.json({ ...transfer, version: 2 }), + ), + http.delete("*/api/v1/bank-cards/operations/transfer", ({ request }) => { + const version = new URL(request.url).searchParams.get("version") ?? ""; + versions.push(version); + return version === "1" + ? HttpResponse.json( + { code: "BANK_VERSION_CONFLICT" }, + { status: 409 }, + ) + : new HttpResponse(null, { status: 204 }); + }), + ); + const close = vi.fn(); + render( + + + + + , + ); + expect(screen.getByText(/«Основная» → «Запасная»/)).toHaveTextContent( + "Обе стороны перевода будут удалены.", + ); + fireEvent.click(screen.getByRole("button", { name: "Удалить" })); + await screen.findByText(/Запись уже изменена/); + expect(close).not.toHaveBeenCalled(); + fireEvent.click(screen.getByRole("button", { name: "Перечитать запись" })); + fireEvent.click( + await screen.findByRole("button", { + name: "Использовать актуальную версию 2", + }), + ); + fireEvent.click(screen.getByRole("button", { name: "Удалить" })); + await waitFor(() => expect(close).toHaveBeenCalled()); + expect(versions).toEqual(["1", "2"]); + }); +}); diff --git a/src/pages/card/ui/DeleteOperation.tsx b/src/pages/card/ui/DeleteOperation.tsx new file mode 100644 index 0000000..d880f74 --- /dev/null +++ b/src/pages/card/ui/DeleteOperation.tsx @@ -0,0 +1,111 @@ +import { Button, Group, Modal, Stack, Text } from "@mantine/core"; +import { type FC, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + BankFeedback, + type BankOperation, + useBankFormFocus, + useBankMutations, + useBankOperation, +} from "@/entities/bank-card"; +import { formatMoney } from "@/shared/lib"; + +type DeleteOperationProps = { operation: BankOperation; onClose: () => void }; +const DeleteOperation: FC = (props) => { + useBankFormFocus(); + const { t } = useTranslation(); + const [record, setRecord] = useState(props.operation); + const [latest, setLatest] = useState(); + const [error, setError] = useState(); + const query = useBankOperation(record.id); + const { deleteOperation } = useBankMutations(); + const pending = + deleteOperation.isPending || (query.isFetching && Boolean(error)); + return ( + !pending && props.onClose()} + title={t("bank.deleteTitle")} + closeOnEscape={!pending} + closeOnClickOutside={!pending} + closeButtonProps={{ "aria-label": t("bank.close"), disabled: pending }} + > + + + {record.kind === "transfer" + ? t("bank.deleteTransfer", { + amount: formatMoney(record.amount), + source: record.cardName, + target: record.targetCardName, + }) + : t("bank.deleteHint", { + kind: t(`bank.kinds.${record.kind}`), + amount: formatMoney(record.amount), + card: record.cardName, + })} + + {error ? ( + <> + + { + const result = await query.refetch(); + if (result.data && !result.error) setLatest(result.data); + else setError(result.error); + }} + > + {t("bank.reloadRecord")} + + > + ) : null} + {latest && ( + <> + + {t("bank.versionHint", { + details: `${latest.date} · ${formatMoney(latest.amount)} · ${latest.description ?? ""}`, + })} + + { + setRecord(latest); + setLatest(undefined); + setError(undefined); + }} + > + {t("bank.adoptVersion", { version: latest.version })} + + > + )} + + { + setError(undefined); + try { + await deleteOperation.mutateAsync({ + id: record.id, + version: record.version, + }); + props.onClose(); + } catch (failure) { + setError(failure); + } + }} + > + {t("bank.delete")} + + + {t("bank.cancel")} + + + + + ); +}; + +export { DeleteOperation }; diff --git a/src/pages/card/ui/OperationForm.module.css b/src/pages/card/ui/OperationForm.module.css new file mode 100644 index 0000000..fef62f4 --- /dev/null +++ b/src/pages/card/ui/OperationForm.module.css @@ -0,0 +1,6 @@ +.fieldset { + border: 0; + margin: 0; + padding: 0; + min-width: 0; +} diff --git a/src/pages/card/ui/OperationForm.test.tsx b/src/pages/card/ui/OperationForm.test.tsx new file mode 100644 index 0000000..9f319fc --- /dev/null +++ b/src/pages/card/ui/OperationForm.test.tsx @@ -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( + + + + + , + ); + 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[] = []; + 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; + 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"), + }); + }); +}); diff --git a/src/pages/card/ui/OperationForm.tsx b/src/pages/card/ui/OperationForm.tsx new file mode 100644 index 0000000..0e2ceef --- /dev/null +++ b/src/pages/card/ui/OperationForm.tsx @@ -0,0 +1,417 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import { Button, Group, Modal, Stack, Text } from "@mantine/core"; +import { type FC, useState } from "react"; +import { FormProvider, useForm } from "react-hook-form"; +import { useTranslation } from "react-i18next"; +import { z } from "zod"; +import { + type BankCard, + BankFeedback, + type BankOperation, + bankDateSchema, + bankMoneyValue, + bankToday, + CategoryManager, + moneyCents, + positiveMoneySchema, + useBankCategories, + useBankExpenses, + useBankFormFocus, + useBankMutations, + useBankOperation, +} from "@/entities/bank-card"; +import { validationMessage } from "@/shared/i18n"; +import { formatMoney } from "@/shared/lib"; +import classes from "./OperationForm.module.css"; +import type { OperationValues } from "./OperationForm.types"; +import { OperationFields, RefundFields } from "./OperationForm.view"; + +const operationSchema = z.object({ + kind: z.enum(["expense", "topup", "transfer", "refund"]), + cardId: z.string(), + targetCardId: z.string(), + categoryId: z.string(), + originalExpenseId: z.string(), + amount: positiveMoneySchema, + date: bankDateSchema, + description: z + .string() + .max(500, validationMessage("bank.validation.description")(undefined)), +}); + +type OperationFormProps = { + card: BankCard; + cards: BankCard[]; + operation?: BankOperation; + onClose: () => void; +}; +const OperationForm: FC = (props) => { + useBankFormFocus(); + const { t } = useTranslation(); + const [categoriesOpen, setCategoriesOpen] = useState(false); + const [version, setVersion] = useState(props.operation?.version ?? 1); + const [latest, setLatest] = useState(); + const [sourcePage, setSourcePage] = useState(1); + const [sourceId, setSourceId] = useState( + props.operation?.originalExpenseId ?? "", + ); + const sourceQuery = useBankOperation(sourceId || undefined); + const currentQuery = useBankOperation(props.operation?.id); + const [refundLimit, setRefundLimit] = useState( + props.operation?.availableRefund, + ); + const [intent] = useState(() => crypto.randomUUID()); + const [submittedValues, setSubmittedValues] = useState(); + const [error, setError] = useState(); + const categories = useBankCategories(); + const mutation = useBankMutations(); + const methods = useForm({ + resolver: zodResolver( + operationSchema.superRefine((values, ctx) => { + // Repeated intent reaches server idempotency even after its source changed. + if ( + submittedValues && + JSON.stringify(values) === JSON.stringify(submittedValues) + ) + return; + const source = props.cards.find((c) => c.id === values.cardId); + const target = props.cards.find((c) => c.id === values.targetCardId); + if (!source || source.archivedAt) + ctx.addIssue({ + code: "custom", + path: ["cardId"], + ...validationMessage("bank.validation.required")(undefined), + }); + if ( + values.kind === "transfer" && + (!target || target.archivedAt || target.id === values.cardId) + ) + ctx.addIssue({ + code: "custom", + path: ["targetCardId"], + ...validationMessage("bank.validation.destination")(undefined), + }); + if ( + (source && values.date < source.openingDate) || + (values.kind === "transfer" && + target && + values.date < target.openingDate) + ) + ctx.addIssue({ + code: "custom", + path: ["date"], + ...validationMessage("bank.validation.start")(undefined), + }); + if (values.kind === "refund") { + const expense = sourceQuery.data; + if (!expense || expense.cardId !== values.cardId) + ctx.addIssue({ + code: "custom", + path: ["originalExpenseId"], + ...validationMessage("bank.validation.required")(undefined), + }); + if (expense && values.date < expense.date) + ctx.addIssue({ + code: "custom", + path: ["date"], + ...validationMessage("bank.validation.start")(undefined), + }); + const limit = props.operation + ? refundLimit + : expense?.availableRefund; + const amount = moneyCents(values.amount); + if ( + limit && + amount !== null && + amount > (moneyCents(limit.value) ?? 0n) + ) + ctx.addIssue({ + code: "custom", + path: ["amount"], + ...validationMessage("bank.validation.refund")(undefined), + }); + } + if ( + values.kind === "expense" && + !categories.data?.some( + (c) => + c.id === + (values.categoryId || + categories.data?.find((c) => c.isDefault)?.id), + ) + ) + ctx.addIssue({ + code: "custom", + path: ["categoryId"], + ...validationMessage("bank.validation.required")(undefined), + }); + }), + ), + defaultValues: { + kind: props.operation?.kind ?? "expense", + cardId: props.operation?.cardId ?? props.card.id, + targetCardId: props.operation?.targetCardId ?? "", + categoryId: props.operation?.categoryId ?? "", + originalExpenseId: props.operation?.originalExpenseId ?? "", + amount: props.operation?.amount.value ?? "", + date: props.operation?.date ?? bankToday(), + description: props.operation?.description ?? "", + }, + }); + const kind = methods.watch("kind"); + const expenses = useBankExpenses( + kind === "refund" ? props.card.id : undefined, + { page: sourcePage }, + ); + const source = methods.watch("cardId"); + const pending = methods.formState.isSubmitting; + const options = props.cards + .filter((c) => !c.archivedAt) + .map((c) => ({ value: c.id, label: c.name })); + return ( + <> + !pending && !categoriesOpen && props.onClose()} + title={t(props.operation ? "bank.editOperation" : "bank.addOperation")} + closeOnEscape={!pending && !categoriesOpen} + closeOnClickOutside={!pending && !categoriesOpen} + closeButtonProps={{ "aria-label": t("bank.close"), disabled: pending }} + > + + { + setSubmittedValues(values); + setError(undefined); + try { + const body = { + kind: values.kind, + cardId: values.cardId, + ...(values.kind === "transfer" + ? { targetCardId: values.targetCardId } + : {}), + ...(values.kind === "expense" + ? { + categoryId: + values.categoryId || + categories.data?.find((c) => c.isDefault)?.id, + } + : {}), + ...(values.kind === "refund" + ? { originalExpenseId: values.originalExpenseId } + : {}), + amount: { + currency: "RUB" as const, + value: bankMoneyValue(values.amount), + }, + date: values.date, + description: values.description, + }; + if (props.operation) + await mutation.updateOperation.mutateAsync({ + id: props.operation.id, + body: { ...body, version }, + }); + else + await mutation.createOperation.mutateAsync({ + id: intent, + ...body, + }); + props.onClose(); + } catch (failure) { + setError(failure); + } + })} + > + + + c.value !== source)} + categories={ + categories.data?.map((c) => ({ + value: c.id, + label: c.name, + })) ?? [] + } + defaultCategory={ + categories.data?.find((c) => c.isDefault)?.id ?? "" + } + /> + {kind === "refund" && ( + <> + ({ + value: item.id, + label: t("bank.expenseChoice", { + date: item.date, + name: item.description || item.categoryName, + amount: formatMoney(item.amount), + available: formatMoney(item.availableRefund), + }), + })) ?? [] + } + selected={ + sourceQuery.data + ? { + value: sourceQuery.data.id, + label: t("bank.expenseChoice", { + date: sourceQuery.data.date, + name: + sourceQuery.data.description || + sourceQuery.data.categoryName, + amount: formatMoney(sourceQuery.data.amount), + available: formatMoney( + sourceQuery.data.availableRefund, + ), + }), + } + : undefined + } + onSelect={setSourceId} + limit={ + props.operation + ? refundLimit + : sourceQuery.data?.availableRefund + } + /> + {expenses.isLoading && ( + {t("bank.loadingHistory")} + )} + {expenses.data && + !expenses.data.items.length && + !props.operation && {t("bank.noPurchases")}} + {!props.operation && ( + + setSourcePage(sourcePage - 1)} + > + {t("bank.previous")} + + {t("bank.page", { page: sourcePage })} + setSourcePage(sourcePage + 1)} + > + {t("bank.next")} + + + )} + {expenses.isError && ( + void expenses.refetch()} + /> + )}{" "} + {sourceQuery.isError && ( + void sourceQuery.refetch()} + /> + )} + > + )} + {kind === "expense" && ( + setCategoriesOpen(true)} + > + {t("bank.addCategory")} + + )} + {categories.isError && ( + void categories.refetch()} + /> + )} + {error ? ( + <> + + {(props.operation || sourceId) && ( + { + const query = props.operation + ? currentQuery + : sourceQuery; + const result = await query.refetch(); + if (result.data && !result.error) { + if (props.operation) { + setLatest(result.data); + setRefundLimit(result.data.availableRefund); + } else setError(undefined); + } else setError(result.error); + }} + > + {t("bank.reloadRecord")} + + )} + > + ) : null} + {latest && ( + <> + + {t("bank.versionHint", { + details: `${latest.date} · ${formatMoney(latest.amount)} · ${latest.description ?? ""}`, + })} + + { + setVersion(latest.version); + setLatest(undefined); + setError(undefined); + }} + > + {t("bank.adoptVersion", { version: latest.version })} + + > + )} + + + {t("bank.save")} + + + {t("bank.cancel")} + + + + + + + + {categoriesOpen && ( + setCategoriesOpen(false)} + onCreated={(id) => { + methods.setValue("categoryId", id); + setCategoriesOpen(false); + }} + /> + )} + > + ); +}; + +export { OperationForm }; diff --git a/src/pages/card/ui/OperationForm.types.ts b/src/pages/card/ui/OperationForm.types.ts new file mode 100644 index 0000000..9dca01f --- /dev/null +++ b/src/pages/card/ui/OperationForm.types.ts @@ -0,0 +1,14 @@ +import type { BankOperation } from "@/entities/bank-card"; + +type OperationValues = { + kind: BankOperation["kind"]; + cardId: string; + targetCardId: string; + categoryId: string; + originalExpenseId: string; + amount: string; + date: string; + description: string; +}; + +export type { OperationValues }; diff --git a/src/pages/card/ui/OperationForm.view.tsx b/src/pages/card/ui/OperationForm.view.tsx new file mode 100644 index 0000000..388fe1d --- /dev/null +++ b/src/pages/card/ui/OperationForm.view.tsx @@ -0,0 +1,198 @@ +import { Select, Stack, Text, Textarea, TextInput } from "@mantine/core"; +import { DateInput } from "@mantine/dates"; +import type { FC } from "react"; +import { Controller, useFormContext } from "react-hook-form"; +import { useTranslation } from "react-i18next"; +import { + type BankOperation, + bankDateSchema, + bankToday, +} from "@/entities/bank-card"; +import { formatMoney } from "@/shared/lib"; +import type { OperationValues } from "./OperationForm.types"; + +type Choice = { value: string; label: string }; +type OperationFieldsProps = { + editing: boolean; + refundCardId: string; + kind: OperationValues["kind"]; + cards: Choice[]; + targets: Choice[]; + categories: Choice[]; + defaultCategory: string; +}; +const OperationFields: FC = (props) => { + const { t } = useTranslation(); + const { + control, + register, + setValue, + formState: { errors, isSubmitting }, + } = useFormContext(); + return ( + + ( + ({ value, label: t(`bank.kinds.${value}`) }), + )} + {...field} + disabled={isSubmitting || props.editing} + onChange={(value) => { + field.onChange(value ?? "expense"); + if (value === "refund") setValue("cardId", props.refundCardId); + }} + /> + )} + /> + ( + + )} + /> + {props.kind === "transfer" && ( + ( + + )} + /> + )} + + ( + + bankDateSchema.safeParse(value).success ? value : null + } + fixOnBlur={false} + onInput={(event) => field.onChange(event.currentTarget.value)} + onChange={(value) => field.onChange(value ?? "")} + onBlur={(event) => { + field.onChange(event.currentTarget.value); + field.onBlur(); + }} + maxDate={bankToday()} + popoverProps={{ + withinPortal: false, + middlewares: { shift: { crossAxis: true } }, + }} + error={errors.date?.message} + /> + )} + /> + {props.kind === "expense" && ( + ( + + )} + /> + )} + + + ); +}; +type RefundFieldsProps = { + editing: boolean; + expenses: Choice[]; + selected?: Choice; + onSelect: (id: string) => void; + limit: BankOperation["availableRefund"] | undefined; +}; +const RefundFields: FC = (props) => { + const { t } = useTranslation(); + const { + control, + formState: { errors, isSubmitting }, + } = useFormContext(); + return ( + + ( + item.value === props.selected?.value, + ) + ? [props.selected, ...props.expenses] + : props.expenses + } + {...field} + onChange={(value) => { + field.onChange(value ?? ""); + props.onSelect(value ?? ""); + }} + disabled={isSubmitting || props.editing} + error={errors.originalExpenseId?.message} + /> + )} + /> + {props.limit && ( + + {t("bank.availableRefund", { amount: formatMoney(props.limit) })} + + )} + {props.editing && {t("bank.purchaseImmutable")}} + + ); +}; + +export { OperationFields, RefundFields }; diff --git a/src/pages/cards/index.ts b/src/pages/cards/index.ts new file mode 100644 index 0000000..4092743 --- /dev/null +++ b/src/pages/cards/index.ts @@ -0,0 +1 @@ +export { CardsPage } from "./ui/CardsPage"; diff --git a/src/pages/cards/ui/CardsPage.module.css b/src/pages/cards/ui/CardsPage.module.css new file mode 100644 index 0000000..3a95656 --- /dev/null +++ b/src/pages/cards/ui/CardsPage.module.css @@ -0,0 +1,10 @@ +.card { + text-decoration: none; + color: inherit; + min-width: 0; + overflow-wrap: anywhere; +} +.card:focus-visible { + outline: 2px solid var(--mantine-primary-color-filled); + outline-offset: 3px; +} diff --git a/src/pages/cards/ui/CardsPage.test.tsx b/src/pages/cards/ui/CardsPage.test.tsx new file mode 100644 index 0000000..0bb595a --- /dev/null +++ b/src/pages/cards/ui/CardsPage.test.tsx @@ -0,0 +1,185 @@ +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 { MemoryRouter } from "react-router"; +import { describe, expect, it } from "vitest"; +import { configureAuthClient } from "@/shared/api"; +import { createTestQueryClient } from "@/test/query"; +import { server } from "@/test/server"; +import { CardsPage } from "./CardsPage"; + +const money = (value: string) => ({ currency: "RUB", value }); +function renderCards(fail = false, categoryFail = false) { + configureAuthClient({ + accessToken: () => "test", + refresh: async () => false, + clear: () => {}, + }); + server.use( + http.get("*/api/v1/bank-cards/categories", () => + categoryFail ? HttpResponse.error() : HttpResponse.json([]), + ), + http.get("*/api/v1/bank-cards", () => + fail ? HttpResponse.error() : HttpResponse.json([]), + ), + http.get("*/api/v1/bank-cards/summary", () => + HttpResponse.json({ + month: "2026-09", + cardId: null, + expenses: money("0"), + refunds: money("0"), + net: money("0"), + categories: [], + }), + ), + ); + return render( + + + + + + + , + ); +} +describe("CardsPage", () => { + it("preserves exact negative opening amount and the creation intent through unknown failure", async () => { + renderCards(); + await screen.findByRole("heading", { name: "Добавьте первую карту" }); + fireEvent.click( + screen.getAllByRole("button", { name: "Добавить карту" })[0], + ); + const dialog = screen.getByRole("dialog"); + const submitted: Record[] = []; + let resolve: (() => void) | undefined; + server.use( + http.post("*/api/v1/bank-cards", async ({ request }) => { + submitted.push((await request.json()) as Record); + if (submitted.length === 1) { + await new Promise((r) => { + resolve = r; + }); + return HttpResponse.error(); + } + return HttpResponse.json({ id: submitted[0].id }, { status: 201 }); + }), + ); + fireEvent.change(within(dialog).getByLabelText(/Название карты/), { + target: { value: " Повседневная " }, + }); + fireEvent.change(within(dialog).getByLabelText(/Начальный остаток/), { + target: { value: "1,001" }, + }); + fireEvent.click(within(dialog).getByRole("button", { name: "Сохранить" })); + expect( + await screen.findByText(/Введите сумму в допустимом диапазоне/), + ).toBeInTheDocument(); + expect(submitted).toHaveLength(0); + fireEvent.change(within(dialog).getByLabelText(/Начальный остаток/), { + target: { value: "-92233720368547758,08" }, + }); + fireEvent.change(within(dialog).getByLabelText(/Дата начала учёта/), { + target: { value: "2026-01-01" }, + }); + fireEvent.click(within(dialog).getByRole("button", { name: "Сохранить" })); + await waitFor(() => expect(submitted).toHaveLength(1)); + expect( + within(dialog).getByRole("button", { name: "Отмена" }), + ).toBeDisabled(); + expect( + within(dialog).getByRole("button", { name: "Закрыть" }), + ).toBeDisabled(); + resolve?.(); + await screen.findByText(/Не удалось подтвердить сохранение/); + expect(within(dialog).getByLabelText(/Начальный остаток/)).toHaveValue( + "-92233720368547758,08", + ); + fireEvent.click(within(dialog).getByRole("button", { name: "Сохранить" })); + await waitFor(() => + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(), + ); + expect(submitted[0]).toEqual(submitted[1]); + expect(submitted[0]).toMatchObject({ + name: "Повседневная", + bankName: null, + lastFour: null, + openingBalance: money("-92233720368547758.08"), + }); + }); + it("uses archive only for card list and keeps all-card summary including negative net", async () => { + renderCards(); + const requests: string[] = []; + server.use( + http.get("*/api/v1/bank-cards", ({ request }) => { + requests.push(new URL(request.url).search); + return HttpResponse.json([ + { + id: "demo", + name: "Запасная", + bankName: null, + lastFour: null, + balance: money("0"), + archivedAt: "2026-09-01", + version: 1, + openingBalance: money("0"), + openingDate: "2026-01-01", + }, + ]); + }), + ); + fireEvent.click(screen.getByLabelText("Показать архив")); + await screen.findByText("Запасная"); + expect(requests).toContain("?includeArchived=true"); + expect(screen.getByLabelText("Месяц")).toHaveTextContent("September 2026"); + expect(screen.getByRole("link", { name: /Запасная/ })).toHaveAttribute( + "href", + "/cards/demo?month=2026-09", + ); + }); + it("shows retry after failure instead of presenting an empty list", async () => { + renderCards(true); + // Mount starts asynchronously; this handler owns the request. + expect(await screen.findByRole("alert")).toHaveTextContent( + "Не удалось загрузить данные карт.", + ); + expect( + screen.queryByRole("heading", { name: "Добавьте первую карту" }), + ).not.toBeInTheDocument(); + server.use(http.get("*/api/v1/bank-cards", () => HttpResponse.json([]))); + fireEvent.click( + within(screen.getByRole("alert")).getByRole("button", { + name: "Повторить", + }), + ); + await screen.findByRole("heading", { name: "Добавьте первую карту" }); + }); + it("makes failed category loading recoverable without hiding successful cards data", async () => { + renderCards(false, true); + await screen.findByRole("heading", { name: "Добавьте первую карту" }); + expect(await screen.findByRole("alert")).toHaveTextContent( + "Не удалось загрузить данные карт.", + ); + server.use( + http.get("*/api/v1/bank-cards/categories", () => + HttpResponse.json([ + { id: "default", name: "Другое", version: 1, isDefault: true }, + ]), + ), + ); + fireEvent.click( + within(screen.getByRole("alert")).getByRole("button", { + name: "Повторить", + }), + ); + await screen.findByRole("button", { name: "Категории" }); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + }); +}); diff --git a/src/pages/cards/ui/CardsPage.tsx b/src/pages/cards/ui/CardsPage.tsx new file mode 100644 index 0000000..d927e28 --- /dev/null +++ b/src/pages/cards/ui/CardsPage.tsx @@ -0,0 +1,19 @@ +import type { FC } from "react"; +import { BankCardForm, CategoryManager } from "@/entities/bank-card"; +import { CardsPageView } from "./CardsPage.view"; +import { useCardsPageVm } from "./CardsPage.vm"; + +const CardsPage: FC = () => { + const vm = useCardsPageVm(); + return ( + <> + + {vm.adding && vm.setAdding(false)} />} + {vm.managingCategories && ( + vm.setManagingCategories(false)} /> + )} + > + ); +}; + +export { CardsPage }; diff --git a/src/pages/cards/ui/CardsPage.view.tsx b/src/pages/cards/ui/CardsPage.view.tsx new file mode 100644 index 0000000..7dc27e7 --- /dev/null +++ b/src/pages/cards/ui/CardsPage.view.tsx @@ -0,0 +1,137 @@ +import { + Badge, + Button, + Group, + Paper, + SimpleGrid, + Skeleton, + Stack, + Switch, + Text, + Title, +} from "@mantine/core"; +import { MonthPickerInput } from "@mantine/dates"; +import type { FC } from "react"; +import { useTranslation } from "react-i18next"; +import { Link } from "react-router"; +import { BankFeedback, BankSummaryView } from "@/entities/bank-card"; +import { formatMoney } from "@/shared/lib"; +import { LoadingSkeleton, StateView } from "@/shared/ui"; +import classes from "./CardsPage.module.css"; +import type { CardsPageVm } from "./CardsPage.vm"; + +type CardsPageViewProps = { vm: CardsPageVm }; +const CardsPageView: FC = (props) => { + const { vm } = props; + const { t } = useTranslation(); + return ( + + + {t("bank.title")} + vm.setAdding(true)}>{t("bank.addCard")} + + {t("bank.manualBalance")} + vm.setArchive(event.currentTarget.checked)} + /> + {vm.items ? ( + vm.items.length ? ( + + {vm.items.map((card) => ( + + + + + {card.name} + + {card.archivedAt && ( + {t("bank.archived")} + )} + + + {card.bankName} + {card.lastFour ? ` · •• ${card.lastFour}` : ""} + + + {formatMoney(card.balance)} + + + + ))} + + ) : ( + vm.setAdding(true)}> + {t("bank.addCard")} + + } + /> + ) + ) : vm.cards.isLoading ? ( + + + + + + + ) : null} + {vm.cards.isError && ( + + {t(vm.items ? "bank.refreshError" : "bank.error")} + void vm.cards.refetch()}> + {t("bank.retry")} + + + )} + {vm.cards.data && vm.cards.isFetching && ( + {t("bank.refreshing")} + )} + { + if (value) vm.setMonth(value.slice(0, 7)); + }} + popoverProps={{ middlewares: { shift: { crossAxis: true } } }} + /> + void vm.summary.refetch()} + /> + {vm.categories.isError && ( + void vm.categories.refetch()} + /> + )} + {Boolean(vm.categories.data?.length || vm.cards.data?.length) && ( + vm.setManagingCategories(true)} + > + {t("bank.categories")} + + )} + + ); +}; + +export { CardsPageView }; diff --git a/src/pages/cards/ui/CardsPage.vm.ts b/src/pages/cards/ui/CardsPage.vm.ts new file mode 100644 index 0000000..84888fe --- /dev/null +++ b/src/pages/cards/ui/CardsPage.vm.ts @@ -0,0 +1,41 @@ +import { useState } from "react"; +import { useSearchParams } from "react-router"; +import { + bankMonth, + useBankCards, + useBankCategories, + useBankSummary, +} from "@/entities/bank-card"; + +function useCardsPageVm() { + const [params, setParams] = useSearchParams(); + const month = bankMonth(params.get("month")); + const [archive, setArchive] = useState(false); + const categories = useBankCategories(); + const [managingCategories, setManagingCategories] = useState(false); + const [adding, setAdding] = useState(false); + const cards = useBankCards(archive); + const summary = useBankSummary(month); + return { + categories, + managingCategories, + setManagingCategories, + month, + archive, + setArchive, + adding, + setAdding, + cards, + summary, + setMonth: (value: string) => { + if (value) setParams({ month: bankMonth(value) }); + }, + items: cards.data?.map((card) => ({ + ...card, + to: `/cards/${card.id}?month=${month}`, + })), + }; +} +type CardsPageVm = ReturnType; + +export { type CardsPageVm, useCardsPageVm }; diff --git a/src/shared/api/generated/schema.d.ts b/src/shared/api/generated/schema.d.ts index e57ad76..0c731be 100644 --- a/src/shared/api/generated/schema.d.ts +++ b/src/shared/api/generated/schema.d.ts @@ -84,6 +84,150 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/bank-cards": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["BankCardsController_list"]; + put?: never; + post: operations["BankCardsController_create"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/bank-cards/categories": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["BankCardsController_categories"]; + put?: never; + post: operations["BankCardsController_createCategory"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/bank-cards/categories/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch: operations["BankCardsController_updateCategory"]; + trace?: never; + }; + "/api/v1/bank-cards/summary": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["BankCardsController_summary"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/bank-cards/operations": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["BankCardsController_createOperation"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/bank-cards/operations/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["BankCardsController_operation"]; + put?: never; + post?: never; + delete: operations["BankCardsController_deleteOperation"]; + options?: never; + head?: never; + patch: operations["BankCardsController_updateOperation"]; + trace?: never; + }; + "/api/v1/bank-cards/{id}/operations": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["BankCardsController_history"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/bank-cards/{id}/expenses": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["BankCardsController_expenses"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/bank-cards/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["BankCardsController_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch: operations["BankCardsController_update"]; + trace?: never; + }; "/api/v1/stocks": { parameters: { query?: never; @@ -569,6 +713,129 @@ export interface components { currency: string; value: string; }; + BankCardDto: { + id: string; + name: string; + bankName: string | null; + lastFour: string | null; + openingBalance: components["schemas"]["MoneyDto"]; + openingDate: string; + balance: components["schemas"]["MoneyDto"]; + archivedAt: string | null; + version: number; + }; + RubMoneyInputDto: { + /** @enum {string} */ + currency: "RUB"; + value: string; + }; + CreateBankCardDto: { + /** Format: uuid */ + id: string; + name: string; + bankName?: string | null; + lastFour?: string | null; + openingBalance: components["schemas"]["RubMoneyInputDto"]; + /** Format: date */ + openingDate: string; + }; + ExpenseCategoryDto: { + id: string; + name: string; + isDefault: boolean; + version: number; + }; + CategoryNameDto: { + name: string; + }; + UpdateExpenseCategoryDto: { + name: string; + version: number; + }; + BankSummaryCategoryDto: { + categoryId: string; + categoryName: string; + expenses: components["schemas"]["MoneyDto"]; + refunds: components["schemas"]["MoneyDto"]; + net: components["schemas"]["MoneyDto"]; + }; + BankSummaryDto: { + month: string; + cardId: string | null; + expenses: components["schemas"]["MoneyDto"]; + refunds: components["schemas"]["MoneyDto"]; + net: components["schemas"]["MoneyDto"]; + categories: components["schemas"]["BankSummaryCategoryDto"][]; + }; + CreateBankOperationDto: { + /** Format: uuid */ + id: string; + /** @enum {string} */ + kind: "expense" | "topup" | "transfer" | "refund"; + /** Format: uuid */ + cardId: string; + /** Format: uuid */ + targetCardId?: string; + /** Format: uuid */ + categoryId?: string; + /** Format: uuid */ + originalExpenseId?: string; + amount: components["schemas"]["RubMoneyInputDto"]; + /** Format: date */ + date: string; + description?: string; + }; + BankOperationDto: { + id: string; + /** @enum {string} */ + kind: "expense" | "topup" | "transfer" | "refund"; + cardId: string; + cardName: string; + targetCardId: string | null; + targetCardName: string | null; + categoryId: string | null; + categoryName: string | null; + originalExpenseId: string | null; + amount: components["schemas"]["MoneyDto"]; + availableRefund: components["schemas"]["MoneyDto"] | null; + date: string; + description: string | null; + version: number; + createdAt: string; + }; + UpdateBankOperationDto: { + /** @enum {string} */ + kind?: "expense" | "topup" | "transfer" | "refund"; + /** Format: uuid */ + cardId?: string; + /** Format: uuid */ + targetCardId?: string; + /** Format: uuid */ + categoryId?: string; + /** Format: uuid */ + originalExpenseId?: string; + amount?: components["schemas"]["RubMoneyInputDto"]; + /** Format: date */ + date?: string; + description?: string; + version: number; + }; + BankOperationsPageDto: { + items: components["schemas"]["BankOperationDto"][]; + page: number; + total: number; + hasNext: boolean; + }; + UpdateBankCardDto: { + name?: string; + bankName?: string | null; + lastFour?: string | null; + openingBalance?: components["schemas"]["RubMoneyInputDto"]; + /** Format: date */ + openingDate?: string; + version: number; + archived?: boolean; + }; StockSearchResultDto: { /** Format: uuid */ instrumentUid: string; @@ -1408,6 +1675,1206 @@ export interface operations { }; }; }; + BankCardsController_list: { + parameters: { + query?: { + includeArchived?: "true" | "false"; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["BankCardDto"][]; + }; + }; + /** @description Invalid request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description Authentication required */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description Resource not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description BANK_CARD_ARCHIVED, BANK_VERSION_CONFLICT, BANK_INTENT_CONFLICT, BANK_REFUND_LIMIT, BANK_DEPENDENT_REFUNDS, BANK_DUPLICATE_CATEGORY or BANK_WRITE_CONFLICT */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description BANK_OPERATION_DELETED: creation replay refers to a deleted operation */ + 410: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description Internal server error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description Upstream service unavailable */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + }; + }; + BankCardsController_create: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CreateBankCardDto"]; + }; + }; + responses: { + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["BankCardDto"]; + }; + }; + /** @description Invalid request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description Authentication required */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description Resource not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description BANK_CARD_ARCHIVED, BANK_VERSION_CONFLICT, BANK_INTENT_CONFLICT, BANK_REFUND_LIMIT, BANK_DEPENDENT_REFUNDS, BANK_DUPLICATE_CATEGORY or BANK_WRITE_CONFLICT */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description BANK_OPERATION_DELETED: creation replay refers to a deleted operation */ + 410: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description Internal server error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description Upstream service unavailable */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + }; + }; + BankCardsController_categories: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ExpenseCategoryDto"][]; + }; + }; + /** @description Invalid request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description Authentication required */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description Resource not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description BANK_CARD_ARCHIVED, BANK_VERSION_CONFLICT, BANK_INTENT_CONFLICT, BANK_REFUND_LIMIT, BANK_DEPENDENT_REFUNDS, BANK_DUPLICATE_CATEGORY or BANK_WRITE_CONFLICT */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description BANK_OPERATION_DELETED: creation replay refers to a deleted operation */ + 410: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description Internal server error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description Upstream service unavailable */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + }; + }; + BankCardsController_createCategory: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CategoryNameDto"]; + }; + }; + responses: { + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ExpenseCategoryDto"]; + }; + }; + /** @description Invalid request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description Authentication required */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description Resource not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description BANK_CARD_ARCHIVED, BANK_VERSION_CONFLICT, BANK_INTENT_CONFLICT, BANK_REFUND_LIMIT, BANK_DEPENDENT_REFUNDS, BANK_DUPLICATE_CATEGORY or BANK_WRITE_CONFLICT */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description BANK_OPERATION_DELETED: creation replay refers to a deleted operation */ + 410: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description Internal server error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description Upstream service unavailable */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + }; + }; + BankCardsController_updateCategory: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["UpdateExpenseCategoryDto"]; + }; + }; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ExpenseCategoryDto"]; + }; + }; + /** @description Invalid request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description Authentication required */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description Resource not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description BANK_CARD_ARCHIVED, BANK_VERSION_CONFLICT, BANK_INTENT_CONFLICT, BANK_REFUND_LIMIT, BANK_DEPENDENT_REFUNDS, BANK_DUPLICATE_CATEGORY or BANK_WRITE_CONFLICT */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description BANK_OPERATION_DELETED: creation replay refers to a deleted operation */ + 410: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description Internal server error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description Upstream service unavailable */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + }; + }; + BankCardsController_summary: { + parameters: { + query: { + month: string; + cardId?: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["BankSummaryDto"]; + }; + }; + /** @description Invalid request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description Authentication required */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description Resource not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description BANK_CARD_ARCHIVED, BANK_VERSION_CONFLICT, BANK_INTENT_CONFLICT, BANK_REFUND_LIMIT, BANK_DEPENDENT_REFUNDS, BANK_DUPLICATE_CATEGORY or BANK_WRITE_CONFLICT */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description BANK_OPERATION_DELETED: creation replay refers to a deleted operation */ + 410: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description Internal server error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description Upstream service unavailable */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + }; + }; + BankCardsController_createOperation: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CreateBankOperationDto"]; + }; + }; + responses: { + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["BankOperationDto"]; + }; + }; + /** @description Invalid request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description Authentication required */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description Resource not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description BANK_CARD_ARCHIVED, BANK_VERSION_CONFLICT, BANK_INTENT_CONFLICT, BANK_REFUND_LIMIT, BANK_DEPENDENT_REFUNDS, BANK_DUPLICATE_CATEGORY or BANK_WRITE_CONFLICT */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description BANK_OPERATION_DELETED: creation replay refers to a deleted operation */ + 410: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description Internal server error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description Upstream service unavailable */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + }; + }; + BankCardsController_operation: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["BankOperationDto"]; + }; + }; + /** @description Invalid request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description Authentication required */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description Resource not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description BANK_CARD_ARCHIVED, BANK_VERSION_CONFLICT, BANK_INTENT_CONFLICT, BANK_REFUND_LIMIT, BANK_DEPENDENT_REFUNDS, BANK_DUPLICATE_CATEGORY or BANK_WRITE_CONFLICT */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description BANK_OPERATION_DELETED: creation replay refers to a deleted operation */ + 410: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description Internal server error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description Upstream service unavailable */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + }; + }; + BankCardsController_deleteOperation: { + parameters: { + query: { + version: number; + }; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Invalid request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description Authentication required */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description Resource not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description BANK_CARD_ARCHIVED, BANK_VERSION_CONFLICT, BANK_INTENT_CONFLICT, BANK_REFUND_LIMIT, BANK_DEPENDENT_REFUNDS, BANK_DUPLICATE_CATEGORY or BANK_WRITE_CONFLICT */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description BANK_OPERATION_DELETED: creation replay refers to a deleted operation */ + 410: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description Internal server error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description Upstream service unavailable */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + }; + }; + BankCardsController_updateOperation: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["UpdateBankOperationDto"]; + }; + }; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["BankOperationDto"]; + }; + }; + /** @description Invalid request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description Authentication required */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description Resource not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description BANK_CARD_ARCHIVED, BANK_VERSION_CONFLICT, BANK_INTENT_CONFLICT, BANK_REFUND_LIMIT, BANK_DEPENDENT_REFUNDS, BANK_DUPLICATE_CATEGORY or BANK_WRITE_CONFLICT */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description BANK_OPERATION_DELETED: creation replay refers to a deleted operation */ + 410: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description Internal server error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description Upstream service unavailable */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + }; + }; + BankCardsController_history: { + parameters: { + query: { + page?: number; + limit?: number; + month: string; + }; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["BankOperationsPageDto"]; + }; + }; + /** @description Invalid request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description Authentication required */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description Resource not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description BANK_CARD_ARCHIVED, BANK_VERSION_CONFLICT, BANK_INTENT_CONFLICT, BANK_REFUND_LIMIT, BANK_DEPENDENT_REFUNDS, BANK_DUPLICATE_CATEGORY or BANK_WRITE_CONFLICT */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description BANK_OPERATION_DELETED: creation replay refers to a deleted operation */ + 410: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description Internal server error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description Upstream service unavailable */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + }; + }; + BankCardsController_expenses: { + parameters: { + query?: { + page?: number; + limit?: number; + }; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["BankOperationsPageDto"]; + }; + }; + /** @description Invalid request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description Authentication required */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description Resource not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description BANK_CARD_ARCHIVED, BANK_VERSION_CONFLICT, BANK_INTENT_CONFLICT, BANK_REFUND_LIMIT, BANK_DEPENDENT_REFUNDS, BANK_DUPLICATE_CATEGORY or BANK_WRITE_CONFLICT */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description BANK_OPERATION_DELETED: creation replay refers to a deleted operation */ + 410: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description Internal server error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description Upstream service unavailable */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + }; + }; + BankCardsController_get: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["BankCardDto"]; + }; + }; + /** @description Invalid request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description Authentication required */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description Resource not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description BANK_CARD_ARCHIVED, BANK_VERSION_CONFLICT, BANK_INTENT_CONFLICT, BANK_REFUND_LIMIT, BANK_DEPENDENT_REFUNDS, BANK_DUPLICATE_CATEGORY or BANK_WRITE_CONFLICT */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description BANK_OPERATION_DELETED: creation replay refers to a deleted operation */ + 410: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description Internal server error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description Upstream service unavailable */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + }; + }; + BankCardsController_update: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["UpdateBankCardDto"]; + }; + }; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["BankCardDto"]; + }; + }; + /** @description Invalid request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description Authentication required */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description Resource not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description BANK_CARD_ARCHIVED, BANK_VERSION_CONFLICT, BANK_INTENT_CONFLICT, BANK_REFUND_LIMIT, BANK_DEPENDENT_REFUNDS, BANK_DUPLICATE_CATEGORY or BANK_WRITE_CONFLICT */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description BANK_OPERATION_DELETED: creation replay refers to a deleted operation */ + 410: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description Internal server error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description Upstream service unavailable */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + }; + }; StockController_search: { parameters: { query: { diff --git a/src/shared/api/spec/openapi.json b/src/shared/api/spec/openapi.json index 4780769..87e27bc 100644 --- a/src/shared/api/spec/openapi.json +++ b/src/shared/api/spec/openapi.json @@ -374,6 +374,1563 @@ ] } }, + "/api/v1/bank-cards": { + "get": { + "operationId": "BankCardsController_list", + "parameters": [ + { + "name": "includeArchived", + "required": false, + "in": "query", + "schema": { + "type": "string", + "enum": [ + "true", + "false" + ] + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BankCardDto" + } + } + } + } + }, + "400": { + "description": "Invalid request", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + }, + "401": { + "description": "Authentication required", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + }, + "404": { + "description": "Resource not found", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + }, + "409": { + "description": "BANK_CARD_ARCHIVED, BANK_VERSION_CONFLICT, BANK_INTENT_CONFLICT, BANK_REFUND_LIMIT, BANK_DEPENDENT_REFUNDS, BANK_DUPLICATE_CATEGORY or BANK_WRITE_CONFLICT", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + }, + "410": { + "description": "BANK_OPERATION_DELETED: creation replay refers to a deleted operation", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + }, + "503": { + "description": "Upstream service unavailable", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + } + }, + "security": [ + { + "bearer": [] + } + ], + "tags": [ + "bank-cards" + ] + }, + "post": { + "operationId": "BankCardsController_create", + "parameters": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateBankCardDto" + } + } + } + }, + "responses": { + "201": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BankCardDto" + } + } + } + }, + "400": { + "description": "Invalid request", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + }, + "401": { + "description": "Authentication required", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + }, + "404": { + "description": "Resource not found", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + }, + "409": { + "description": "BANK_CARD_ARCHIVED, BANK_VERSION_CONFLICT, BANK_INTENT_CONFLICT, BANK_REFUND_LIMIT, BANK_DEPENDENT_REFUNDS, BANK_DUPLICATE_CATEGORY or BANK_WRITE_CONFLICT", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + }, + "410": { + "description": "BANK_OPERATION_DELETED: creation replay refers to a deleted operation", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + }, + "503": { + "description": "Upstream service unavailable", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + } + }, + "security": [ + { + "bearer": [] + } + ], + "tags": [ + "bank-cards" + ] + } + }, + "/api/v1/bank-cards/categories": { + "get": { + "operationId": "BankCardsController_categories", + "parameters": [], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ExpenseCategoryDto" + } + } + } + } + }, + "400": { + "description": "Invalid request", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + }, + "401": { + "description": "Authentication required", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + }, + "404": { + "description": "Resource not found", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + }, + "409": { + "description": "BANK_CARD_ARCHIVED, BANK_VERSION_CONFLICT, BANK_INTENT_CONFLICT, BANK_REFUND_LIMIT, BANK_DEPENDENT_REFUNDS, BANK_DUPLICATE_CATEGORY or BANK_WRITE_CONFLICT", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + }, + "410": { + "description": "BANK_OPERATION_DELETED: creation replay refers to a deleted operation", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + }, + "503": { + "description": "Upstream service unavailable", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + } + }, + "security": [ + { + "bearer": [] + } + ], + "tags": [ + "bank-cards" + ] + }, + "post": { + "operationId": "BankCardsController_createCategory", + "parameters": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CategoryNameDto" + } + } + } + }, + "responses": { + "201": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExpenseCategoryDto" + } + } + } + }, + "400": { + "description": "Invalid request", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + }, + "401": { + "description": "Authentication required", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + }, + "404": { + "description": "Resource not found", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + }, + "409": { + "description": "BANK_CARD_ARCHIVED, BANK_VERSION_CONFLICT, BANK_INTENT_CONFLICT, BANK_REFUND_LIMIT, BANK_DEPENDENT_REFUNDS, BANK_DUPLICATE_CATEGORY or BANK_WRITE_CONFLICT", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + }, + "410": { + "description": "BANK_OPERATION_DELETED: creation replay refers to a deleted operation", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + }, + "503": { + "description": "Upstream service unavailable", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + } + }, + "security": [ + { + "bearer": [] + } + ], + "tags": [ + "bank-cards" + ] + } + }, + "/api/v1/bank-cards/categories/{id}": { + "patch": { + "operationId": "BankCardsController_updateCategory", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateExpenseCategoryDto" + } + } + } + }, + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExpenseCategoryDto" + } + } + } + }, + "400": { + "description": "Invalid request", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + }, + "401": { + "description": "Authentication required", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + }, + "404": { + "description": "Resource not found", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + }, + "409": { + "description": "BANK_CARD_ARCHIVED, BANK_VERSION_CONFLICT, BANK_INTENT_CONFLICT, BANK_REFUND_LIMIT, BANK_DEPENDENT_REFUNDS, BANK_DUPLICATE_CATEGORY or BANK_WRITE_CONFLICT", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + }, + "410": { + "description": "BANK_OPERATION_DELETED: creation replay refers to a deleted operation", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + }, + "503": { + "description": "Upstream service unavailable", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + } + }, + "security": [ + { + "bearer": [] + } + ], + "tags": [ + "bank-cards" + ] + } + }, + "/api/v1/bank-cards/summary": { + "get": { + "operationId": "BankCardsController_summary", + "parameters": [ + { + "name": "month", + "required": true, + "in": "query", + "schema": { + "pattern": "^[0-9]{4}-(0[1-9]|1[0-2])$", + "type": "string" + } + }, + { + "name": "cardId", + "required": false, + "in": "query", + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BankSummaryDto" + } + } + } + }, + "400": { + "description": "Invalid request", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + }, + "401": { + "description": "Authentication required", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + }, + "404": { + "description": "Resource not found", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + }, + "409": { + "description": "BANK_CARD_ARCHIVED, BANK_VERSION_CONFLICT, BANK_INTENT_CONFLICT, BANK_REFUND_LIMIT, BANK_DEPENDENT_REFUNDS, BANK_DUPLICATE_CATEGORY or BANK_WRITE_CONFLICT", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + }, + "410": { + "description": "BANK_OPERATION_DELETED: creation replay refers to a deleted operation", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + }, + "503": { + "description": "Upstream service unavailable", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + } + }, + "security": [ + { + "bearer": [] + } + ], + "tags": [ + "bank-cards" + ] + } + }, + "/api/v1/bank-cards/operations": { + "post": { + "operationId": "BankCardsController_createOperation", + "parameters": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateBankOperationDto" + } + } + } + }, + "responses": { + "201": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BankOperationDto" + } + } + } + }, + "400": { + "description": "Invalid request", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + }, + "401": { + "description": "Authentication required", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + }, + "404": { + "description": "Resource not found", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + }, + "409": { + "description": "BANK_CARD_ARCHIVED, BANK_VERSION_CONFLICT, BANK_INTENT_CONFLICT, BANK_REFUND_LIMIT, BANK_DEPENDENT_REFUNDS, BANK_DUPLICATE_CATEGORY or BANK_WRITE_CONFLICT", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + }, + "410": { + "description": "BANK_OPERATION_DELETED: creation replay refers to a deleted operation", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + }, + "503": { + "description": "Upstream service unavailable", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + } + }, + "security": [ + { + "bearer": [] + } + ], + "tags": [ + "bank-cards" + ] + } + }, + "/api/v1/bank-cards/operations/{id}": { + "get": { + "operationId": "BankCardsController_operation", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BankOperationDto" + } + } + } + }, + "400": { + "description": "Invalid request", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + }, + "401": { + "description": "Authentication required", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + }, + "404": { + "description": "Resource not found", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + }, + "409": { + "description": "BANK_CARD_ARCHIVED, BANK_VERSION_CONFLICT, BANK_INTENT_CONFLICT, BANK_REFUND_LIMIT, BANK_DEPENDENT_REFUNDS, BANK_DUPLICATE_CATEGORY or BANK_WRITE_CONFLICT", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + }, + "410": { + "description": "BANK_OPERATION_DELETED: creation replay refers to a deleted operation", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + }, + "503": { + "description": "Upstream service unavailable", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + } + }, + "security": [ + { + "bearer": [] + } + ], + "tags": [ + "bank-cards" + ] + }, + "patch": { + "operationId": "BankCardsController_updateOperation", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateBankOperationDto" + } + } + } + }, + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BankOperationDto" + } + } + } + }, + "400": { + "description": "Invalid request", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + }, + "401": { + "description": "Authentication required", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + }, + "404": { + "description": "Resource not found", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + }, + "409": { + "description": "BANK_CARD_ARCHIVED, BANK_VERSION_CONFLICT, BANK_INTENT_CONFLICT, BANK_REFUND_LIMIT, BANK_DEPENDENT_REFUNDS, BANK_DUPLICATE_CATEGORY or BANK_WRITE_CONFLICT", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + }, + "410": { + "description": "BANK_OPERATION_DELETED: creation replay refers to a deleted operation", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + }, + "503": { + "description": "Upstream service unavailable", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + } + }, + "security": [ + { + "bearer": [] + } + ], + "tags": [ + "bank-cards" + ] + }, + "delete": { + "operationId": "BankCardsController_deleteOperation", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + }, + { + "name": "version", + "required": true, + "in": "query", + "schema": { + "minimum": 1, + "type": "number" + } + } + ], + "responses": { + "204": { + "description": "" + }, + "400": { + "description": "Invalid request", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + }, + "401": { + "description": "Authentication required", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + }, + "404": { + "description": "Resource not found", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + }, + "409": { + "description": "BANK_CARD_ARCHIVED, BANK_VERSION_CONFLICT, BANK_INTENT_CONFLICT, BANK_REFUND_LIMIT, BANK_DEPENDENT_REFUNDS, BANK_DUPLICATE_CATEGORY or BANK_WRITE_CONFLICT", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + }, + "410": { + "description": "BANK_OPERATION_DELETED: creation replay refers to a deleted operation", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + }, + "503": { + "description": "Upstream service unavailable", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + } + }, + "security": [ + { + "bearer": [] + } + ], + "tags": [ + "bank-cards" + ] + } + }, + "/api/v1/bank-cards/{id}/operations": { + "get": { + "operationId": "BankCardsController_history", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + }, + { + "name": "page", + "required": false, + "in": "query", + "schema": { + "minimum": 1, + "default": 1, + "type": "number" + } + }, + { + "name": "limit", + "required": false, + "in": "query", + "schema": { + "minimum": 1, + "maximum": 100, + "default": 50, + "type": "number" + } + }, + { + "name": "month", + "required": true, + "in": "query", + "schema": { + "pattern": "^[0-9]{4}-(0[1-9]|1[0-2])$", + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BankOperationsPageDto" + } + } + } + }, + "400": { + "description": "Invalid request", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + }, + "401": { + "description": "Authentication required", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + }, + "404": { + "description": "Resource not found", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + }, + "409": { + "description": "BANK_CARD_ARCHIVED, BANK_VERSION_CONFLICT, BANK_INTENT_CONFLICT, BANK_REFUND_LIMIT, BANK_DEPENDENT_REFUNDS, BANK_DUPLICATE_CATEGORY or BANK_WRITE_CONFLICT", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + }, + "410": { + "description": "BANK_OPERATION_DELETED: creation replay refers to a deleted operation", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + }, + "503": { + "description": "Upstream service unavailable", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + } + }, + "security": [ + { + "bearer": [] + } + ], + "tags": [ + "bank-cards" + ] + } + }, + "/api/v1/bank-cards/{id}/expenses": { + "get": { + "operationId": "BankCardsController_expenses", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + }, + { + "name": "page", + "required": false, + "in": "query", + "schema": { + "minimum": 1, + "default": 1, + "type": "number" + } + }, + { + "name": "limit", + "required": false, + "in": "query", + "schema": { + "minimum": 1, + "maximum": 100, + "default": 50, + "type": "number" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BankOperationsPageDto" + } + } + } + }, + "400": { + "description": "Invalid request", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + }, + "401": { + "description": "Authentication required", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + }, + "404": { + "description": "Resource not found", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + }, + "409": { + "description": "BANK_CARD_ARCHIVED, BANK_VERSION_CONFLICT, BANK_INTENT_CONFLICT, BANK_REFUND_LIMIT, BANK_DEPENDENT_REFUNDS, BANK_DUPLICATE_CATEGORY or BANK_WRITE_CONFLICT", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + }, + "410": { + "description": "BANK_OPERATION_DELETED: creation replay refers to a deleted operation", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + }, + "503": { + "description": "Upstream service unavailable", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + } + }, + "security": [ + { + "bearer": [] + } + ], + "tags": [ + "bank-cards" + ] + } + }, + "/api/v1/bank-cards/{id}": { + "get": { + "operationId": "BankCardsController_get", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BankCardDto" + } + } + } + }, + "400": { + "description": "Invalid request", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + }, + "401": { + "description": "Authentication required", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + }, + "404": { + "description": "Resource not found", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + }, + "409": { + "description": "BANK_CARD_ARCHIVED, BANK_VERSION_CONFLICT, BANK_INTENT_CONFLICT, BANK_REFUND_LIMIT, BANK_DEPENDENT_REFUNDS, BANK_DUPLICATE_CATEGORY or BANK_WRITE_CONFLICT", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + }, + "410": { + "description": "BANK_OPERATION_DELETED: creation replay refers to a deleted operation", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + }, + "503": { + "description": "Upstream service unavailable", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + } + }, + "security": [ + { + "bearer": [] + } + ], + "tags": [ + "bank-cards" + ] + }, + "patch": { + "operationId": "BankCardsController_update", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateBankCardDto" + } + } + } + }, + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BankCardDto" + } + } + } + }, + "400": { + "description": "Invalid request", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + }, + "401": { + "description": "Authentication required", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + }, + "404": { + "description": "Resource not found", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + }, + "409": { + "description": "BANK_CARD_ARCHIVED, BANK_VERSION_CONFLICT, BANK_INTENT_CONFLICT, BANK_REFUND_LIMIT, BANK_DEPENDENT_REFUNDS, BANK_DUPLICATE_CATEGORY or BANK_WRITE_CONFLICT", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + }, + "410": { + "description": "BANK_OPERATION_DELETED: creation replay refers to a deleted operation", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + }, + "503": { + "description": "Upstream service unavailable", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + } + }, + "security": [ + { + "bearer": [] + } + ], + "tags": [ + "bank-cards" + ] + } + }, "/api/v1/stocks": { "get": { "operationId": "StockController_search", @@ -3206,6 +4763,472 @@ "value" ] }, + "BankCardDto": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "bankName": { + "type": "string", + "nullable": true + }, + "lastFour": { + "type": "string", + "nullable": true + }, + "openingBalance": { + "$ref": "#/components/schemas/MoneyDto" + }, + "openingDate": { + "type": "string" + }, + "balance": { + "$ref": "#/components/schemas/MoneyDto" + }, + "archivedAt": { + "type": "string", + "nullable": true + }, + "version": { + "type": "number" + } + }, + "required": [ + "id", + "name", + "bankName", + "lastFour", + "openingBalance", + "openingDate", + "balance", + "archivedAt", + "version" + ] + }, + "RubMoneyInputDto": { + "type": "object", + "properties": { + "currency": { + "type": "string", + "enum": [ + "RUB" + ] + }, + "value": { + "type": "string", + "pattern": "^-?[0-9]+(\\.[0-9]{1,2})?$" + } + }, + "required": [ + "currency", + "value" + ] + }, + "CreateBankCardDto": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 100 + }, + "bankName": { + "type": "string", + "nullable": true, + "maxLength": 100 + }, + "lastFour": { + "type": "string", + "nullable": true, + "pattern": "^[0-9]{4}$" + }, + "openingBalance": { + "$ref": "#/components/schemas/RubMoneyInputDto" + }, + "openingDate": { + "type": "string", + "format": "date" + } + }, + "required": [ + "id", + "name", + "openingBalance", + "openingDate" + ] + }, + "ExpenseCategoryDto": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "isDefault": { + "type": "boolean" + }, + "version": { + "type": "number" + } + }, + "required": [ + "id", + "name", + "isDefault", + "version" + ] + }, + "CategoryNameDto": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 80 + } + }, + "required": [ + "name" + ] + }, + "UpdateExpenseCategoryDto": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 80 + }, + "version": { + "type": "number", + "minimum": 1 + } + }, + "required": [ + "name", + "version" + ] + }, + "BankSummaryCategoryDto": { + "type": "object", + "properties": { + "categoryId": { + "type": "string" + }, + "categoryName": { + "type": "string" + }, + "expenses": { + "$ref": "#/components/schemas/MoneyDto" + }, + "refunds": { + "$ref": "#/components/schemas/MoneyDto" + }, + "net": { + "$ref": "#/components/schemas/MoneyDto" + } + }, + "required": [ + "categoryId", + "categoryName", + "expenses", + "refunds", + "net" + ] + }, + "BankSummaryDto": { + "type": "object", + "properties": { + "month": { + "type": "string" + }, + "cardId": { + "type": "string", + "nullable": true + }, + "expenses": { + "$ref": "#/components/schemas/MoneyDto" + }, + "refunds": { + "$ref": "#/components/schemas/MoneyDto" + }, + "net": { + "$ref": "#/components/schemas/MoneyDto" + }, + "categories": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BankSummaryCategoryDto" + } + } + }, + "required": [ + "month", + "cardId", + "expenses", + "refunds", + "net", + "categories" + ] + }, + "CreateBankOperationDto": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "kind": { + "type": "string", + "enum": [ + "expense", + "topup", + "transfer", + "refund" + ] + }, + "cardId": { + "type": "string", + "format": "uuid" + }, + "targetCardId": { + "type": "string", + "format": "uuid" + }, + "categoryId": { + "type": "string", + "format": "uuid" + }, + "originalExpenseId": { + "type": "string", + "format": "uuid" + }, + "amount": { + "$ref": "#/components/schemas/RubMoneyInputDto" + }, + "date": { + "type": "string", + "format": "date" + }, + "description": { + "type": "string", + "maxLength": 500 + } + }, + "required": [ + "id", + "kind", + "cardId", + "amount", + "date" + ] + }, + "BankOperationDto": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "kind": { + "type": "string", + "enum": [ + "expense", + "topup", + "transfer", + "refund" + ] + }, + "cardId": { + "type": "string" + }, + "cardName": { + "type": "string" + }, + "targetCardId": { + "type": "string", + "nullable": true + }, + "targetCardName": { + "type": "string", + "nullable": true + }, + "categoryId": { + "type": "string", + "nullable": true + }, + "categoryName": { + "type": "string", + "nullable": true + }, + "originalExpenseId": { + "type": "string", + "nullable": true + }, + "amount": { + "$ref": "#/components/schemas/MoneyDto" + }, + "availableRefund": { + "nullable": true, + "type": "object", + "allOf": [ + { + "$ref": "#/components/schemas/MoneyDto" + } + ] + }, + "date": { + "type": "string" + }, + "description": { + "type": "string", + "nullable": true + }, + "version": { + "type": "number" + }, + "createdAt": { + "type": "string" + } + }, + "required": [ + "id", + "kind", + "cardId", + "cardName", + "targetCardId", + "targetCardName", + "categoryId", + "categoryName", + "originalExpenseId", + "amount", + "availableRefund", + "date", + "description", + "version", + "createdAt" + ] + }, + "UpdateBankOperationDto": { + "type": "object", + "properties": { + "kind": { + "type": "string", + "enum": [ + "expense", + "topup", + "transfer", + "refund" + ] + }, + "cardId": { + "type": "string", + "format": "uuid" + }, + "targetCardId": { + "type": "string", + "format": "uuid" + }, + "categoryId": { + "type": "string", + "format": "uuid" + }, + "originalExpenseId": { + "type": "string", + "format": "uuid" + }, + "amount": { + "$ref": "#/components/schemas/RubMoneyInputDto" + }, + "date": { + "type": "string", + "format": "date" + }, + "description": { + "type": "string", + "maxLength": 500 + }, + "version": { + "type": "number", + "minimum": 1 + } + }, + "required": [ + "version" + ] + }, + "BankOperationsPageDto": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BankOperationDto" + } + }, + "page": { + "type": "number" + }, + "total": { + "type": "number" + }, + "hasNext": { + "type": "boolean" + } + }, + "required": [ + "items", + "page", + "total", + "hasNext" + ] + }, + "UpdateBankCardDto": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 100 + }, + "bankName": { + "type": "string", + "nullable": true, + "maxLength": 100 + }, + "lastFour": { + "type": "string", + "nullable": true, + "pattern": "^[0-9]{4}$" + }, + "openingBalance": { + "$ref": "#/components/schemas/RubMoneyInputDto" + }, + "openingDate": { + "type": "string", + "format": "date" + }, + "version": { + "type": "number", + "minimum": 1 + }, + "archived": { + "type": "boolean" + } + }, + "required": [ + "version" + ] + }, "StockSearchResultDto": { "type": "object", "properties": { diff --git a/src/shared/i18n/locales/ru.ts b/src/shared/i18n/locales/ru.ts index 83f3712..59c2e13 100644 --- a/src/shared/i18n/locales/ru.ts +++ b/src/shared/i18n/locales/ru.ts @@ -2,6 +2,7 @@ const ru = { translation: { appName: "WealthLedger", navigation: { + cards: "Карты", accounts: "Счета", stocks: "Акции", bonds: "Облигации", @@ -16,6 +17,142 @@ const ru = { events: "Будущие события", settings: "Настройки", }, + bank: { + title: "Карты", + card: "Карта", + addCard: "Добавить карту", + editCard: "Изменить карту", + manualBalance: "Баланс по внесённым операциям", + openingHint: + "Остаток до операций с выбранной даты. Валюта — рубли (RUB).", + name: "Название карты", + bankName: "Банк (необязательно)", + lastFour: "Последние четыре цифры (необязательно)", + openingBalance: "Начальный остаток, ₽", + openingDate: "Дата начала учёта", + showArchive: "Показать архив", + archived: "В архиве", + archive: "Убрать в архив", + restore: "Вернуть из архива", + archiveHint: + "История и деньги сохраняются. Для финансовых изменений верните карту из архива.", + empty: "Добавьте первую карту", + emptyHint: "Укажите начальный остаток и вносите расходы вручную.", + noActive: "Нет активных карт. Включите показ архива.", + loading: "Загрузка банковских карт", + loadingHistory: "Загрузка операций", + error: "Не удалось загрузить данные карт.", + retry: "Повторить", + refreshing: "Обновляем данные…", + refreshError: + "Не удалось обновить данные. Показаны последние полученные.", + save: "Сохранить", + cancel: "Отмена", + close: "Закрыть", + summary: "Расходы за месяц", + allSummary: "Расходы по всем картам", + month: "Месяц", + expenses: "Потрачено", + refunds: "Возвращено", + net: "После возвратов", + summaryHint: + "Возвраты учитываются в месяце получения. Итог может быть отрицательным.", + noExpenses: "Расходов и возвратов за месяц нет.", + categories: "Категории", + category: "Категория", + categoryName: "Название категории", + addCategory: "Добавить категорию", + rename: "Переименовать", + defaultCategory: "По умолчанию", + notFound: "Карта не найдена", + back: "К картам", + operations: "Операции", + noOperations: "Операций за месяц нет.", + addOperation: "Добавить операцию", + editOperation: "Изменить операцию", + kind: "Вид операции", + kinds: { + expense: "Расход", + topup: "Пополнение", + transfer: "Перевод", + refund: "Возврат", + }, + source: "Карта-источник", + destination: "Карта-получатель", + operationCard: "Карта операции", + amount: "Сумма, ₽", + date: "Дата операции", + description: "Описание (необязательно)", + expense: "Исходная покупка за любой период", + availableRefund: "Доступно к возврату: {{amount}}", + expenseChoice: + "{{date}} · {{name}} · {{amount}} · доступно {{available}}", + noPurchases: + "На этой странице нет покупок. Проверьте другие страницы истории.", + purchaseImmutable: "Исходная покупка и карта возврата не изменяются.", + previous: "Назад", + next: "Далее", + page: "Страница {{page}}", + transferTo: "Перевод на «{{name}}»", + transferFrom: "Перевод с «{{name}}»", + edit: "Изменить", + delete: "Удалить", + deleteTitle: "Удалить операцию?", + deleteHint: + "{{kind}} · {{amount}} · {{card}}. Денежное влияние будет отменено.", + deleteTransfer: + "Перевод {{amount}}: «{{source}}» → «{{target}}». Обе стороны перевода будут удалены.", + reloadRecord: "Перечитать запись", + adoptVersion: "Использовать актуальную версию {{version}}", + versionHint: + "Актуальные данные: {{details}}. Черновик сохранён; проверьте его перед повторной отправкой.", + requestError: + "Не удалось подтвердить сохранение. Ввод сохранён; повтор использует то же намерение.", + errors: { + BANK_VERSION_CONFLICT: + "Запись уже изменена. Перечитайте её и проверьте черновик перед повторным сохранением.", + BANK_WRITE_CONFLICT: + "Данные изменяются другим запросом. Перечитайте запись и повторите действие.", + BANK_DUPLICATE_CATEGORY: "Категория с таким названием уже есть.", + BANK_NOT_FOUND: "Запись не найдена или недоступна.", + BANK_INTENT_CONFLICT: + "Это намерение уже сохранено с другим содержимым. Закройте форму и проверьте историю перед новым действием.", + BANK_CARD_ARCHIVED: + "Затронутая карта в архиве. Сначала верните её из архива.", + BANK_REFUND_LIMIT: + "Сумма превышает доступный возврат. Перечитайте покупку.", + BANK_DEPENDENT_REFUNDS: + "Изменению мешают связанные возвраты. Сначала исправьте или удалите их.", + BANK_OPENING_DATE_CONFLICT: + "Дата начала противоречит сохранённой истории карты.", + BANK_INVALID_DATE: + "Проверьте дату операции и даты начала учёта обеих карт.", + BANK_INVALID_MONEY: "Введите допустимую сумму с точностью до копеек.", + BANK_INVALID_INPUT: "Проверьте поля и связанные карты.", + BANK_INVALID_REFUND: "Исходная покупка недоступна для этого возврата.", + BANK_IMMUTABLE_OPERATION: + "Вид операции и исходную покупку возврата изменить нельзя.", + BANK_OPERATION_DELETED: + "Операция уже удалена. Она не будет создана повторно.", + }, + validation: { + name: "Введите название от 1 до 100 символов.", + bankName: "Не более 100 символов.", + lastFour: "Укажите ровно четыре цифры или оставьте поле пустым.", + money: + "Введите сумму в допустимом диапазоне, не более двух знаков после запятой.", + positive: "Сумма операции должна быть больше нуля.", + date: "Укажите существующую дату не позже сегодняшнего дня по Москве.", + start: + "Дата не может быть раньше начала учёта затронутых карт или исходной покупки.", + required: "Выберите значение.", + destination: "Выберите другую активную карту.", + description: "Не более 500 символов.", + category: "Введите название от 1 до 80 символов.", + duplicate: "Категория с таким названием уже есть.", + refund: "Сумма превышает доступный возврат.", + }, + }, bond: { instrument: "Облигация", searching: "Ищем облигации", diff --git a/src/shared/ui/AppShell.module.css b/src/shared/ui/AppShell.module.css index 80c4ad5..30767b4 100644 --- a/src/shared/ui/AppShell.module.css +++ b/src/shared/ui/AppShell.module.css @@ -98,7 +98,14 @@ display: flex; justify-content: space-around; left: 0; - padding: var(--mantine-spacing-sm); + padding: var(--mantine-spacing-xs) 2px; + gap: 2px; + font-size: 11px; + & a { + min-width: 0; + padding: 8px 2px; + text-align: center; + } position: fixed; right: 0; z-index: 200; diff --git a/src/shared/ui/AppShell.tsx b/src/shared/ui/AppShell.tsx index 69ba5f6..b9f85a5 100644 --- a/src/shared/ui/AppShell.tsx +++ b/src/shared/ui/AppShell.tsx @@ -21,6 +21,11 @@ const AppShell: FC = (props) => { ), }, + { + to: "/cards", + label: t("navigation.cards"), + icon: , + }, { to: "/stocks", label: t("navigation.stocks"),