From 5b6900351612b6ba34c694fc99612733e7901c79 Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Mon, 28 Sep 2026 13:15:27 +0300 Subject: [PATCH] refactor: enforce enum machine codes #18 Use owner enums for known codes; preserve wire values and unknown fallbacks. Verified npm ci and verify: 379 tests, 5 lint checks, app and Storybook builds. --- .ai/conventions/data.md | 2 +- .ai/conventions/engineering.md | 3 +- .ai/conventions/typescript-react.md | 19 ++++--- eslint.config.mjs | 41 ++++++++++++++ scripts/test-eslint.mjs | 24 +++++++++ src/entities/bank-card/api/mutations.ts | 15 +++--- .../broker-operation/model/display.test.ts | 23 ++++++++ .../broker-operation/model/display.ts | 53 +++++++++++++------ .../model/useInstrumentOperations.ts | 4 +- .../tbank-connection/model/screen-sync.ts | 4 +- .../TbankConnectionSectionLoaded.tsx | 10 ++-- .../AccountOperationsPage.vm.ts | 6 +-- .../components/AccountPageError.view.tsx | 8 +-- .../components/AccountsPageError.view.tsx | 8 +-- src/pages/bond/ui/BondPage/BondPage.tsx | 8 +-- .../bonds/ui/BondsPage/BondsPage.view.tsx | 5 +- src/pages/card/ui/CardPage/CardPage.vm.ts | 4 +- .../card/ui/OperationForm/OperationForm.vm.ts | 17 +++--- src/pages/stock/ui/StockPage/StockPage.tsx | 8 +-- .../stocks/ui/StocksPage/StocksPage.view.tsx | 5 +- src/shared/api/client.ts | 4 +- src/shared/api/index.ts | 2 +- src/shared/api/problem-details.test.ts | 13 +++++ src/shared/api/problem-details.ts | 25 ++++++++- src/shared/api/resource-query.ts | 15 +++--- tsconfig.app.json | 1 - 26 files changed, 248 insertions(+), 79 deletions(-) create mode 100644 src/entities/broker-operation/model/display.test.ts create mode 100644 src/shared/api/problem-details.test.ts diff --git a/.ai/conventions/data.md b/.ai/conventions/data.md index d50f3ce..a6ed4d1 100644 --- a/.ai/conventions/data.md +++ b/.ai/conventions/data.md @@ -70,7 +70,7 @@ Network/unknown error получает безопасное локальное Если одна группа API codes требует одинакового recovery в нескольких views, сведи классификацию у ближайшего общего owner. Не размножай magic comparisons -и не объявляй закрытый frontend error enum: generated `code` пока `string`. +и используй `ApiErrorCode` для известных значений: generated `code` остаётся `string`. Неизвестный новый code должен сохранять fallback. Рефакторинг существующих разных recovery paths требует behavior proof, а не общей обработки всех ошибок. diff --git a/.ai/conventions/engineering.md b/.ai/conventions/engineering.md index 89db9f3..da167e5 100644 --- a/.ai/conventions/engineering.md +++ b/.ai/conventions/engineering.md @@ -29,7 +29,8 @@ owner и 1–3 примера. Код показывает механизм; с ## Domain values Finite business vocabulary принадлежит одному domain/module/entity owner. -Переиспользуй его symbols; для нового словаря без аналога — `as const` + union. +Переиспользуй его symbols; для machine codes и protocol values используй +строковые `enum`, для структуры с разными payload — discriminated union. Сохраняй wire values; generated/external enums не переписывай вручную. Numeric policy называй по смыслу и единицам. Индексы, `Math.max(value, 0)` и обычные labels не выноси в constants без semantic benefit. diff --git a/.ai/conventions/typescript-react.md b/.ai/conventions/typescript-react.md index 6e8a058..c9420f0 100644 --- a/.ai/conventions/typescript-react.md +++ b/.ai/conventions/typescript-react.md @@ -5,7 +5,8 @@ - Сохраняй strict TS profile и `import type`. Для недоверенного значения используй `unknown` с narrowing; `any` не заменяет DTO/validation. Локальное исключение на нетипизированной boundary должно быть узким и объяснённым. -- `enum` и `namespace` не используются: frontend имеет `erasableSyntaxOnly`. +- Строковые `enum` используются для machine codes и protocol values. + `namespace` не используется; tooling сохраняет `erasableSyntaxOnly`. Для состояния с разными payload выбирай discriminated union; не добавляй необязательные поля, допускающие невозможные комбинации. - Named components — const-valued `FC`; named props находятся рядом или @@ -33,19 +34,23 @@ Container/VM/view и state owners — в [UI](ui.md). Общее правило ownership и символических значений — [Domain values](engineering.md#domain-values). -Backend vocabulary бери из generated `paths`, не создавай ручную копию enum. -Текущий `schema.d.ts` содержит только types: typed wire literals допустимы, -когда generator не предоставляет runtime values. +Backend vocabulary бери из generated `paths`; generated artifacts вручную не меняй. +Для открытых `string` полей допускается локальный enum известных значений, +используемых UI. Он не закрывает wire contract и сохраняет unknown fallback. +Если generator предоставляет runtime enum, используй его. -`ProblemDetailsDto.code` сейчас `string`. Не объявляй закрытый frontend error -enum и не превращай неизвестный code в известный через cast. Повторяющуюся +`ProblemDetailsDto.code` сейчас `string`. `ApiErrorCode` перечисляет известные UI коды, а не закрывает wire contract. +`apiErrorCode` сохраняет произвольную строку; `isApiErrorCode` проверяет известное +значение без cast и сохраняет fallback для неизвестного кода. Повторяющуюся обработку своди у ответственного owner; [errors](data.md#errors). `@typescript-eslint/no-magic-numbers` — error; fixtures/tests/stories исключены из этой дополнительной проверки. Policy получает имя и единицы; подавление допустимо для incidental value с объяснением. Обычный UI-текст не превращай в constant: его owner — [i18n](ui.md#строки-и-валидация). -Semantic magic strings и props profile требуют review. +`no-restricted-syntax` запрещает inline machine codes и protocol values в production; +`no-unsafe-enum-comparison` проверяет сравнения enum. Fixtures/stories сохраняют +literal wire values. Новые vocabularies и props profile требуют review. ## Проверка diff --git a/eslint.config.mjs b/eslint.config.mjs index f2acd08..75cc19b 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -118,4 +118,45 @@ export default defineConfig( files: ["**/*.stories.tsx", ".storybook/preview.tsx"], rules: { "import-x/exports-last": "off", "import-x/no-default-export": "off" }, }, + { + files: [ + "src/**/*.{ts,tsx}", + "packages/*/src/**/*.{ts,tsx}", + ], + ignores: [ + "**/*.test.{ts,tsx}", + "**/*.stories.tsx", + "src/test/**", + ], + rules: { + "@typescript-eslint/no-unsafe-enum-comparison": "error", + "no-restricted-syntax": [ + "error", + { + message: "Use the owning enum member for machine codes and protocol values.", + selector: "Literal[value=/^(?:(?:INSTRUMENT_ID_TYPE|INSTRUMENT_TYPE|OPERATION_STATE|OPERATION_TYPE|ACCOUNT_STATUS|ACCOUNT_TYPE|CANDLE_INTERVAL|COUPON_TYPE|BANK|INVALID)_[A-Z0-9]+(?:_[A-Z0-9]+)*|[A-Z][A-Z0-9_]*_(?:ERROR|UNAVAILABLE|NOT_FOUND|NOT_CONNECTED|CONFLICT|AMBIGUOUS|REGISTERED)|P[0-9]{4}|UNAUTHORIZED|FORBIDDEN|BAD_REQUEST|NOT_FOUND|UNSUPPORTED_MEDIA_TYPE)$/]:not(TSEnumMember > Literal)", + }, + { + message: "Use the owning enum member instead of a template machine code.", + selector: "TemplateElement[value.cooked=/^(?:(?:INSTRUMENT_ID_TYPE|INSTRUMENT_TYPE|OPERATION_STATE|OPERATION_TYPE|ACCOUNT_STATUS|ACCOUNT_TYPE|CANDLE_INTERVAL|COUPON_TYPE|BANK|INVALID)_[A-Z0-9]+(?:_[A-Z0-9]+)*|[A-Z][A-Z0-9_]*_(?:ERROR|UNAVAILABLE|NOT_FOUND|NOT_CONNECTED|CONFLICT|AMBIGUOUS|REGISTERED)|P[0-9]{4}|UNAUTHORIZED|FORBIDDEN|BAD_REQUEST|NOT_FOUND|UNSUPPORTED_MEDIA_TYPE)$/]:not(TSEnumMember TemplateElement)", + }, + { + message: "Use an enum member instead of an inline code or protocol value.", + selector: "Property:matches([key.name=/^(code|errorCode|idType|instrumentKind)$/], [key.value=/^(code|errorCode|idType|instrumentKind)$/]) > Literal.value[value=type(string)]", + }, + { + message: "Use an enum member instead of constructing a code or protocol value.", + selector: "Property:matches([key.name=/^(code|errorCode|idType|instrumentKind)$/], [key.value=/^(code|errorCode|idType|instrumentKind)$/]) > TemplateLiteral.value", + }, + { + message: "Use a computed enum member as the protocol mapping key.", + selector: "Property[computed=false][key.name=/^(OPERATION_TYPE|ACCOUNT_TYPE)_/]", + }, + { + message: "Compare codes with their enum members, not string literals.", + selector: "BinaryExpression:has(> :matches(MemberExpression[property.name=/^(code|errorCode)$/], Identifier[name=/^(code|errorCode)$/])) > Literal[value=type(string)]", + }, + ], + }, + }, ); diff --git a/scripts/test-eslint.mjs b/scripts/test-eslint.mjs index 6f31241..36ca2e3 100644 --- a/scripts/test-eslint.mjs +++ b/scripts/test-eslint.mjs @@ -185,3 +185,27 @@ test("ESLint formatting preserves spaces around JSX text and interpolations", as assert.equal(fixed.errorCount, 0); assert.ok(fixed.output?.includes("Amount: {value} / {value}")); }); + +// Single-use codes must be caught too; fixture literals remain independent wire assertions. +test("machine codes require enum members without banning ordinary text or Prisma selects", async () => { + const eslint = new ESLint(); + const filePath = "src/shared/api/problem-details.ts"; + for (const code of ["export function check(code: string) { return code === \"one-off-code\"; }", "export const result = `MOEX_UNAVAILABLE`;", "export const result = { code: \"one-off-code\" };", "export const result = { \"errorCode\": \"one-off-code\" };", "export const result = { code: `dynamic-${Date.now()}` };", "export const result = \"MOEX_UNAVAILABLE\";", "export const result = [\"INSTRUMENT_ID_TYPE_FIGI\"];", "export const result = { OPERATION_TYPE_COUPON: \"income\" };"]) { + const [result] = await eslint.lintText(code, { filePath }); + assert.equal(result.fatalErrorCount, 0, code); + assert.ok(result.messages.some(({ ruleId }) => ruleId === "no-restricted-syntax"), code); + } + for (const code of [ + "enum Code { Unavailable = \"MOEX_UNAVAILABLE\" } export const result = { code: Code.Unavailable };", + "export const select = { code: true, errorCode: true };", + "export const label = \"Please try again\"; export const key = \"JWT_SECRET\";", + ]) { + const [result] = await eslint.lintText(code, { filePath }); + assert.equal(result.fatalErrorCount, 0, code); + assert.ok(result.messages.every(({ ruleId }) => ruleId !== "no-restricted-syntax"), code); + } + const [fixture] = await eslint.lintText("export const result = { code: \"MOEX_UNAVAILABLE\" };", { filePath: "src/shared/api/client.test.ts" }); + assert.ok(fixture.messages.every(({ ruleId }) => ruleId !== "no-restricted-syntax")); + const [comparison] = await eslint.lintText("enum Code { Ready = \"ready\" } export function check(code: Code) { return code === \"ready\"; }", { filePath }); + assert.ok(comparison.messages.some(({ ruleId }) => ruleId === "@typescript-eslint/no-unsafe-enum-comparison")); +}); diff --git a/src/entities/bank-card/api/mutations.ts b/src/entities/bank-card/api/mutations.ts index 85f13e6..cfe0b0d 100644 --- a/src/entities/bank-card/api/mutations.ts +++ b/src/entities/bank-card/api/mutations.ts @@ -1,6 +1,6 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { apiClient, apiErrorCode } from "@/shared/api"; +import { apiClient, ApiErrorCode, apiErrorCode, isApiErrorCode } from "@/shared/api"; import type { CreateBankCard, @@ -34,14 +34,15 @@ function useBankMutations() { }); const options = { onError: (error: unknown) => { + const errorCode = apiErrorCode(error); if ( [ - "BANK_DEPENDENT_REFUNDS", - "BANK_INTENT_CONFLICT", - "BANK_REFUND_LIMIT", - "BANK_VERSION_CONFLICT", - "BANK_WRITE_CONFLICT", - ].includes(apiErrorCode(error) ?? "") + ApiErrorCode.BankDependentRefunds, + ApiErrorCode.BankIntentConflict, + ApiErrorCode.BankRefundLimit, + ApiErrorCode.BankVersionConflict, + ApiErrorCode.BankWriteConflict, + ].some(code => isApiErrorCode(errorCode, code)) ) return invalidate(); }, diff --git a/src/entities/broker-operation/model/display.test.ts b/src/entities/broker-operation/model/display.test.ts new file mode 100644 index 0000000..b37267f --- /dev/null +++ b/src/entities/broker-operation/model/display.test.ts @@ -0,0 +1,23 @@ +import { expect, it } from "vitest"; + +import { operationLabelKey } from "./display"; + +it.each([ + ["OPERATION_TYPE_BUY", "buy"], + ["OPERATION_TYPE_BUY_CARD", "buy"], + ["OPERATION_TYPE_BUY_MARGIN", "buy"], + ["OPERATION_TYPE_SELL", "sell"], + ["OPERATION_TYPE_SELL_CARD", "sell"], + ["OPERATION_TYPE_SELL_MARGIN", "sell"], + ["OPERATION_TYPE_DIV_EXT", "dividend"], + ["OPERATION_TYPE_DIVIDEND", "dividend"], + ["OPERATION_TYPE_DIVIDEND_TRANSFER", "dividend"], + ["OPERATION_TYPE_INPUT_SECURITIES", "input"], + ["OPERATION_TYPE_OUTPUT_SECURITIES", "output"], + ["OPERATION_TYPE_FUTURE_TAX", "tax"], + ["OPERATION_TYPE_FUTURE_FEE", "fee"], + ["OPERATION_TYPE_TRANS_FUTURE", "transfer"], + ["FUTURE_UNKNOWN_OPERATION", "other"], +])("preserves the wire operation label for %s", (type, label) => { + expect(operationLabelKey(type)).toBe(`ledger.operationType.${label}`); +}); diff --git a/src/entities/broker-operation/model/display.ts b/src/entities/broker-operation/model/display.ts index 494613a..c4e834c 100644 --- a/src/entities/broker-operation/model/display.ts +++ b/src/entities/broker-operation/model/display.ts @@ -2,6 +2,23 @@ import { formatMoney } from "@/shared/lib"; import type { BrokerageOperation } from "../api/useBrokerageOperations"; +enum OperationType { + Buy = "OPERATION_TYPE_BUY", + BuyCard = "OPERATION_TYPE_BUY_CARD", + BuyMargin = "OPERATION_TYPE_BUY_MARGIN", + Coupon = "OPERATION_TYPE_COUPON", + DivExtension = "OPERATION_TYPE_DIV_EXT", + Dividend = "OPERATION_TYPE_DIVIDEND", + DividendTransfer = "OPERATION_TYPE_DIVIDEND_TRANSFER", + InputSecurities = "OPERATION_TYPE_INPUT_SECURITIES", + OutputSecurities = "OPERATION_TYPE_OUTPUT_SECURITIES", + Sell = "OPERATION_TYPE_SELL", + SellCard = "OPERATION_TYPE_SELL_CARD", + SellMargin = "OPERATION_TYPE_SELL_MARGIN", +} + +const operationTransferPrefix = "OPERATION_TYPE_TRANS_"; + type PaymentPresentation = { amount: string; label: string; @@ -10,12 +27,16 @@ type PaymentPresentation = { function entryLabelKey(operation: BrokerageOperation): string { if (operation.category === "income") - return operation.type === "OPERATION_TYPE_COUPON" + return isOperationType(operation.type, OperationType.Coupon) ? "ledger.entryLabel.income.coupon" : "ledger.entryLabel.income.dividend"; return `ledger.category.${operation.category}`; } +function isOperationType(value: unknown, expected: OperationType): boolean { + return value === expected; +} + function isZeroDecimal(value: string): boolean { return /^-?0$|^-?0\.0+$/.test(value); } @@ -23,35 +44,35 @@ function isZeroDecimal(value: string): boolean { function operationLabelKey(type: string): string { if ( [ - "OPERATION_TYPE_BUY", - "OPERATION_TYPE_BUY_CARD", - "OPERATION_TYPE_BUY_MARGIN", - ].includes(type) + OperationType.Buy, + OperationType.BuyCard, + OperationType.BuyMargin, + ].some(candidate => isOperationType(type, candidate)) ) return "ledger.operationType.buy"; if ( [ - "OPERATION_TYPE_SELL", - "OPERATION_TYPE_SELL_CARD", - "OPERATION_TYPE_SELL_MARGIN", - ].includes(type) + OperationType.Sell, + OperationType.SellCard, + OperationType.SellMargin, + ].some(candidate => isOperationType(type, candidate)) ) return "ledger.operationType.sell"; if ( [ - "OPERATION_TYPE_DIV_EXT", - "OPERATION_TYPE_DIVIDEND", - "OPERATION_TYPE_DIVIDEND_TRANSFER", - ].includes(type) + OperationType.DivExtension, + OperationType.Dividend, + OperationType.DividendTransfer, + ].some(candidate => isOperationType(type, candidate)) ) return "ledger.operationType.dividend"; if (type.includes("TAX")) return "ledger.operationType.tax"; if (type.includes("FEE")) return "ledger.operationType.fee"; - if (type === "OPERATION_TYPE_INPUT_SECURITIES") + if (isOperationType(type, OperationType.InputSecurities)) return "ledger.operationType.input"; - if (type === "OPERATION_TYPE_OUTPUT_SECURITIES") + if (isOperationType(type, OperationType.OutputSecurities)) return "ledger.operationType.output"; - return type.startsWith("OPERATION_TYPE_TRANS_") ? "ledger.operationType.transfer" : "ledger.operationType.other"; + return type.startsWith(operationTransferPrefix) ? "ledger.operationType.transfer" : "ledger.operationType.other"; } function paymentPresentation( diff --git a/src/features/instrument-details/model/useInstrumentOperations.ts b/src/features/instrument-details/model/useInstrumentOperations.ts index bb60525..1eca40f 100644 --- a/src/features/instrument-details/model/useInstrumentOperations.ts +++ b/src/features/instrument-details/model/useInstrumentOperations.ts @@ -6,7 +6,7 @@ import { type OperationCategory, useBrokerageOperations, } from "@/entities/broker-operation"; -import { apiErrorCode } from "@/shared/api"; +import { ApiErrorCode, apiErrorCode, isApiErrorCode } from "@/shared/api"; function useInstrumentOperations( uid: string, @@ -26,7 +26,7 @@ function useInstrumentOperations( cursor: cursors.at(-1), }); const retry = (retryScreen: () => unknown) => { - if (apiErrorCode(operations.error) === "INVALID_OPERATIONS_CURSOR") { + if (isApiErrorCode(apiErrorCode(operations.error), ApiErrorCode.InvalidOperationsCursor)) { setPagination({ cursors: [], scope }); void client.invalidateQueries({ exact: true, diff --git a/src/features/tbank-connection/model/screen-sync.ts b/src/features/tbank-connection/model/screen-sync.ts index af0a223..6bfd0a2 100644 --- a/src/features/tbank-connection/model/screen-sync.ts +++ b/src/features/tbank-connection/model/screen-sync.ts @@ -2,9 +2,11 @@ import { type QueryKey, useQueryClient } from "@tanstack/react-query"; import { useEffect, useLayoutEffect, useRef, useState } from "react"; import { + ApiErrorCode, apiErrorCode, apiQueryClient, invalidateResourceAccess, + isApiErrorCode, type paths, useAuthenticatedQueries, } from "@/shared/api"; @@ -112,7 +114,7 @@ function useScreenSync(scope: InvestmentScreen, resources: ScreenResource[]) { } deniedRef.current.add(status.error); - if (apiErrorCode(status.error) !== "NOT_FOUND") + if (!isApiErrorCode(apiErrorCode(status.error), ApiErrorCode.NotFound)) invalidateResourceAccess(client, "brokerage", status.error); } const latest = new Map(); diff --git a/src/features/tbank-connection/ui/TbankConnectionSection/TbankConnectionSectionLoaded.tsx b/src/features/tbank-connection/ui/TbankConnectionSection/TbankConnectionSectionLoaded.tsx index 21b9004..a84bf72 100644 --- a/src/features/tbank-connection/ui/TbankConnectionSection/TbankConnectionSectionLoaded.tsx +++ b/src/features/tbank-connection/ui/TbankConnectionSection/TbankConnectionSectionLoaded.tsx @@ -13,13 +13,15 @@ import { useEffect, useRef, useState } from "react"; import { useForm } from "react-hook-form"; import { useTranslation } from "react-i18next"; -import { apiErrorCode, apiQueryClient } from "@/shared/api"; +import { ApiErrorCode, apiErrorCode, apiQueryClient, isApiErrorCode } from "@/shared/api"; import { commitTbankConnectionBoundary, type TbankConnectionStatus, } from "../../model/connection-boundary"; +enum ConnectionErrorCode { RequestFailed = "REQUEST_ERROR" } + type TbankConnectionSectionLoadedProps = { refreshError: boolean; refreshing: boolean; @@ -43,7 +45,7 @@ const TbankConnectionSectionLoaded: FC = ( reset, } = useForm<{ token: string }>(); const update = apiQueryClient.useMutation("put", "/api/v1/tbank-connection", { - onError: error => setRequestError(apiErrorCode(error) ?? "REQUEST_ERROR"), + onError: error => setRequestError(apiErrorCode(error) ?? ConnectionErrorCode.RequestFailed), onSuccess: (data) => { commitTbankConnectionBoundary(queryClient, data); reset(); @@ -62,7 +64,7 @@ const TbankConnectionSectionLoaded: FC = ( }, }, ); - const unavailableMessage = requestError === "TBANK_UNAVAILABLE" ? "tbank.unavailable" : "tbank.requestError"; + const unavailableMessage = isApiErrorCode(requestError, ApiErrorCode.TbankUnavailable) ? "tbank.unavailable" : "tbank.requestError"; const isPending = update.isPending || remove.isPending; const isConnected = status.connected; useEffect(() => { @@ -119,7 +121,7 @@ const TbankConnectionSectionLoaded: FC = ( {requestError && ( {t( - requestError === "INVALID_TBANK_TOKEN" + isApiErrorCode(requestError, ApiErrorCode.InvalidTbankToken) ? "tbank.invalidToken" : unavailableMessage, )} diff --git a/src/pages/account-operations/ui/AccountOperationsPage/AccountOperationsPage.vm.ts b/src/pages/account-operations/ui/AccountOperationsPage/AccountOperationsPage.vm.ts index daa423b..dd7b799 100644 --- a/src/pages/account-operations/ui/AccountOperationsPage/AccountOperationsPage.vm.ts +++ b/src/pages/account-operations/ui/AccountOperationsPage/AccountOperationsPage.vm.ts @@ -11,7 +11,7 @@ import { useBrokerageOperations, } from "@/entities/broker-operation"; import { useScreenSync } from "@/features/tbank-connection"; -import { apiErrorCode } from "@/shared/api"; +import { ApiErrorCode, apiErrorCode, isApiErrorCode } from "@/shared/api"; type AccountOperationsPageViewModel = | { @@ -114,7 +114,7 @@ const useAccountOperationsPageVm = ( const resetCursors = () => setPagination({ cursors: [], scopeKey }); const retry = () => { - if (apiErrorCode(operations.error) === "INVALID_OPERATIONS_CURSOR") { + if (isApiErrorCode(apiErrorCode(operations.error), ApiErrorCode.InvalidOperationsCursor)) { void queryClient.invalidateQueries({ exact: true, queryKey: brokerageOperationsQueryOptions(accountId, firstPageQuery) @@ -136,7 +136,7 @@ const useAccountOperationsPageVm = ( if ( (operations.isError || operations.meta?.sync.state === "failed") && (!operations.data - || apiErrorCode(operations.error) === "INVALID_OPERATIONS_CURSOR") + || isApiErrorCode(apiErrorCode(operations.error), ApiErrorCode.InvalidOperationsCursor)) ) return { onReset, retry, state: "error" }; if (!operations.data) return { state: "loading" }; diff --git a/src/pages/account/ui/AccountPage/components/AccountPageError.view.tsx b/src/pages/account/ui/AccountPage/components/AccountPageError.view.tsx index 381335b..b216637 100644 --- a/src/pages/account/ui/AccountPage/components/AccountPageError.view.tsx +++ b/src/pages/account/ui/AccountPage/components/AccountPageError.view.tsx @@ -5,12 +5,14 @@ import { StateView } from "@wealthledger/ui"; import { useTranslation } from "react-i18next"; import { Link } from "react-router"; +import { ApiErrorCode, isApiErrorCode } from "@/shared/api"; + type AccountPageErrorProps = { code?: string; retry: () => void }; const AccountPageError: FC = (props) => { const { code, retry } = props; const { t } = useTranslation(); - if (code === "NOT_FOUND") + if (isApiErrorCode(code, ApiErrorCode.NotFound)) return ( = (props) => { title={t("portfolio.accountUnavailableTitle")} /> ); - if (code === "TBANK_NOT_CONNECTED" || code === "INVALID_TBANK_TOKEN") + if (isApiErrorCode(code, ApiErrorCode.TbankNotConnected) || isApiErrorCode(code, ApiErrorCode.InvalidTbankToken)) return ( = (props) => { title={t("portfolio.connectionErrorTitle")} /> ); - if (code === "TBANK_UNAVAILABLE") + if (isApiErrorCode(code, ApiErrorCode.TbankUnavailable)) return ( {t("accounts.retry")}} diff --git a/src/pages/accounts/ui/AccountsPage/components/AccountsPageError.view.tsx b/src/pages/accounts/ui/AccountsPage/components/AccountsPageError.view.tsx index 451d9fd..948c3ba 100644 --- a/src/pages/accounts/ui/AccountsPage/components/AccountsPageError.view.tsx +++ b/src/pages/accounts/ui/AccountsPage/components/AccountsPageError.view.tsx @@ -5,6 +5,8 @@ import { StateView } from "@wealthledger/ui"; import { useTranslation } from "react-i18next"; import { Link } from "react-router"; +import { ApiErrorCode, isApiErrorCode } from "@/shared/api"; + type AccountsPageErrorProps = { code?: string; retry?: () => void }; const AccountsPageError: FC = (props) => { @@ -15,7 +17,7 @@ const AccountsPageError: FC = (props) => { {t("accounts.openSettings")} ); - if (code === "TBANK_NOT_CONNECTED") + if (isApiErrorCode(code, ApiErrorCode.TbankNotConnected)) return ( = (props) => { title={t("accounts.notConnectedTitle")} /> ); - if (code === "INVALID_TBANK_TOKEN") + if (isApiErrorCode(code, ApiErrorCode.InvalidTbankToken)) return ( = (props) => { title={t("accounts.invalidTokenTitle")} /> ); - if (code === "TBANK_UNAVAILABLE") + if (isApiErrorCode(code, ApiErrorCode.TbankUnavailable)) return ( { ); - const unavailableMessage = vm.code === "BOND_TICKER_AMBIGUOUS" ? "bond.ambiguousTicker" : "bond.unavailableText"; + const unavailableMessage = isApiErrorCode(vm.code, ApiErrorCode.BondTickerAmbiguous) ? "bond.ambiguousTicker" : "bond.unavailableText"; if (!vm.bond) return ( {t("bond.unavailable")} {t( - vm.code === "TBANK_NOT_CONNECTED" - || vm.code === "INVALID_TBANK_TOKEN" + isApiErrorCode(vm.code, ApiErrorCode.TbankNotConnected) + || isApiErrorCode(vm.code, ApiErrorCode.InvalidTbankToken) ? "bond.connect" : unavailableMessage, )} diff --git a/src/pages/bonds/ui/BondsPage/BondsPage.view.tsx b/src/pages/bonds/ui/BondsPage/BondsPage.view.tsx index a755fb8..3a6077c 100644 --- a/src/pages/bonds/ui/BondsPage/BondsPage.view.tsx +++ b/src/pages/bonds/ui/BondsPage/BondsPage.view.tsx @@ -18,6 +18,7 @@ import { LoadingSkeleton } from "@wealthledger/ui"; import { useTranslation } from "react-i18next"; import { Link } from "react-router"; +import { ApiErrorCode, isApiErrorCode } from "@/shared/api"; import { formatDate, formatDecimal, @@ -188,8 +189,8 @@ const BondsPageView: FC = (props) => { {t( - vm.code === "TBANK_NOT_CONNECTED" - || vm.code === "INVALID_TBANK_TOKEN" + isApiErrorCode(vm.code, ApiErrorCode.TbankNotConnected) + || isApiErrorCode(vm.code, ApiErrorCode.InvalidTbankToken) ? "bond.connect" : "stock.sectionError", )} diff --git a/src/pages/card/ui/CardPage/CardPage.vm.ts b/src/pages/card/ui/CardPage/CardPage.vm.ts index 9919403..9b9a0db 100644 --- a/src/pages/card/ui/CardPage/CardPage.vm.ts +++ b/src/pages/card/ui/CardPage/CardPage.vm.ts @@ -11,7 +11,7 @@ import { useBankOperations, useBankSummary, } from "@/entities/bank-card"; -import { apiErrorCode } from "@/shared/api"; +import { ApiErrorCode, apiErrorCode, isApiErrorCode } from "@/shared/api"; import { formatDate, formatMoney } from "@/shared/lib"; const PAGE_SIZES = new Set(["5", "10", "20", "50", "100", "all"]); @@ -81,7 +81,7 @@ function useCardPageVm() { deleting, editingCard, history, - notFound: apiErrorCode(card.error) === "BANK_NOT_FOUND", + notFound: isApiErrorCode(apiErrorCode(card.error), ApiErrorCode.BankNotFound), operation, page, pageSize, diff --git a/src/pages/card/ui/OperationForm/OperationForm.vm.ts b/src/pages/card/ui/OperationForm/OperationForm.vm.ts index 48a0204..be8b756 100644 --- a/src/pages/card/ui/OperationForm/OperationForm.vm.ts +++ b/src/pages/card/ui/OperationForm/OperationForm.vm.ts @@ -27,6 +27,9 @@ import type { OperationValues, } from "./OperationForm.types"; +// ZodIssueCode is deprecated; this is the custom refinement code accepted by Zod. +enum FormIssueCode { Custom = "custom" } + const MAX_DESCRIPTION_LENGTH = 500; const operationSchema = z.object({ @@ -49,13 +52,13 @@ function validateRefund(values: OperationValues, context: z.RefinementCtx, expense: BankOperation | undefined, limit: BankOperation["availableRefund"] | undefined) { if (!expense || expense.cardId !== values.cardId) context.addIssue({ - code: "custom", + code: FormIssueCode.Custom, path: ["originalExpenseId"], ...validationMessage("bank.validation.required")(undefined), }); if (expense && values.date < expense.date) context.addIssue({ - code: "custom", + code: FormIssueCode.Custom, path: ["date"], ...validationMessage("bank.validation.start")(undefined), }); @@ -66,7 +69,7 @@ function validateRefund(values: OperationValues, context: z.RefinementCtx, && amount > (moneyCents(limit.value) ?? 0n) ) context.addIssue({ - code: "custom", + code: FormIssueCode.Custom, path: ["amount"], ...validationMessage("bank.validation.refund")(undefined), }); @@ -116,7 +119,7 @@ const useOperationFormVm = (props: OperationFormProps) => { const target = props.cards.find(c => c.id === values.targetCardId); if (!source || source.archivedAt) context.addIssue({ - code: "custom", + code: FormIssueCode.Custom, path: ["cardId"], ...validationMessage("bank.validation.required")(undefined), }); @@ -125,7 +128,7 @@ const useOperationFormVm = (props: OperationFormProps) => { && (!target || target.archivedAt || target.id === values.cardId) ) context.addIssue({ - code: "custom", + code: FormIssueCode.Custom, path: ["targetCardId"], ...validationMessage("bank.validation.destination")(undefined), }); @@ -136,7 +139,7 @@ const useOperationFormVm = (props: OperationFormProps) => { && values.date < target.openingDate) ) context.addIssue({ - code: "custom", + code: FormIssueCode.Custom, path: ["date"], ...validationMessage("bank.validation.start")(undefined), }); @@ -152,7 +155,7 @@ const useOperationFormVm = (props: OperationFormProps) => { ) ) context.addIssue({ - code: "custom", + code: FormIssueCode.Custom, path: ["categoryId"], ...validationMessage("bank.validation.required")(undefined), }); diff --git a/src/pages/stock/ui/StockPage/StockPage.tsx b/src/pages/stock/ui/StockPage/StockPage.tsx index 773f235..7c92d4d 100644 --- a/src/pages/stock/ui/StockPage/StockPage.tsx +++ b/src/pages/stock/ui/StockPage/StockPage.tsx @@ -5,6 +5,8 @@ import { LoadingSkeleton } from "@wealthledger/ui"; import { useTranslation } from "react-i18next"; import { Link, Navigate } from "react-router"; +import { ApiErrorCode, isApiErrorCode } from "@/shared/api"; + import { StockPageView } from "./StockPage.view"; import { useStockPageVm } from "./StockPage.vm"; @@ -22,15 +24,15 @@ const StockPage: FC = () => { ); - const unavailableMessage = vm.code === "STOCK_TICKER_AMBIGUOUS" ? "stock.ambiguousTicker" : "stock.unavailableText"; + const unavailableMessage = isApiErrorCode(vm.code, ApiErrorCode.StockTickerAmbiguous) ? "stock.ambiguousTicker" : "stock.unavailableText"; if (!vm.stock) return ( {t("stock.unavailable")} {t( - vm.code === "TBANK_NOT_CONNECTED" - || vm.code === "INVALID_TBANK_TOKEN" + isApiErrorCode(vm.code, ApiErrorCode.TbankNotConnected) + || isApiErrorCode(vm.code, ApiErrorCode.InvalidTbankToken) ? "stock.connect" : unavailableMessage, )} diff --git a/src/pages/stocks/ui/StocksPage/StocksPage.view.tsx b/src/pages/stocks/ui/StocksPage/StocksPage.view.tsx index 9963ae1..76dab51 100644 --- a/src/pages/stocks/ui/StocksPage/StocksPage.view.tsx +++ b/src/pages/stocks/ui/StocksPage/StocksPage.view.tsx @@ -18,6 +18,7 @@ import { LoadingSkeleton } from "@wealthledger/ui"; import { useTranslation } from "react-i18next"; import { Link } from "react-router"; +import { ApiErrorCode, isApiErrorCode } from "@/shared/api"; import { formatMoney, formatSnapshotTime } from "@/shared/lib"; import type { StocksPageVm } from "./StocksPage.vm"; @@ -156,8 +157,8 @@ const StocksPageView: FC = (props) => { {t( - vm.code === "TBANK_NOT_CONNECTED" - || vm.code === "INVALID_TBANK_TOKEN" + isApiErrorCode(vm.code, ApiErrorCode.TbankNotConnected) + || isApiErrorCode(vm.code, ApiErrorCode.InvalidTbankToken) ? "stock.connect" : "stock.sectionError", )} diff --git a/src/shared/api/client.ts b/src/shared/api/client.ts index 1ebc05f..7b9555a 100644 --- a/src/shared/api/client.ts +++ b/src/shared/api/client.ts @@ -3,7 +3,7 @@ import createQueryClient from "openapi-react-query"; import type { paths } from "./generated/schema"; -import { apiErrorCode } from "./problem-details"; +import { ApiErrorCode, apiErrorCode, isApiErrorCode } from "./problem-details"; const HTTP_UNAUTHORIZED = 401; @@ -101,7 +101,7 @@ async function fetchCurrent( catch { return response; } - if (apiErrorCode(problem) !== "UNAUTHORIZED") return response; + if (!isApiErrorCode(apiErrorCode(problem), ApiErrorCode.Unauthorized)) return response; if (!isCurrent()) throw new DOMException("Session changed", "AbortError"); if (!(await sessionState.handlers.refresh())) { if (isCurrent()) sessionState.handlers.clear(); diff --git a/src/shared/api/index.ts b/src/shared/api/index.ts index ba485e8..a2d2b84 100644 --- a/src/shared/api/index.ts +++ b/src/shared/api/index.ts @@ -9,7 +9,7 @@ export { invalidateApiRequests, } from "./client"; export type { paths } from "./generated/schema"; -export { apiErrorCode } from "./problem-details"; +export { ApiErrorCode, apiErrorCode, isApiErrorCode } from "./problem-details"; export { invalidateResourceAccess, diff --git a/src/shared/api/problem-details.test.ts b/src/shared/api/problem-details.test.ts new file mode 100644 index 0000000..6170ea8 --- /dev/null +++ b/src/shared/api/problem-details.test.ts @@ -0,0 +1,13 @@ +import { expect, it } from "vitest"; + +import { ApiErrorCode, apiErrorCode, isApiErrorCode } from "./problem-details"; + +it("matches known codes and preserves unknown wire codes for fallback handling", () => { + expect(isApiErrorCode(apiErrorCode({ code: "UNAUTHORIZED" }), ApiErrorCode.Unauthorized)).toBe(true); + const unknownCode = apiErrorCode({ code: "FUTURE_PROVIDER_ERROR" }); + expect(unknownCode).toBe("FUTURE_PROVIDER_ERROR"); + expect(isApiErrorCode(unknownCode, ApiErrorCode.Unauthorized)).toBe(false); + expect(isApiErrorCode(undefined, ApiErrorCode.Unauthorized)).toBe(false); + expect(apiErrorCode(null)).toBeUndefined(); + expect(apiErrorCode({})).toBeUndefined(); +}); diff --git a/src/shared/api/problem-details.ts b/src/shared/api/problem-details.ts index f32de0e..b0c2c63 100644 --- a/src/shared/api/problem-details.ts +++ b/src/shared/api/problem-details.ts @@ -1,7 +1,30 @@ +enum ApiErrorCode { + BankDependentRefunds = "BANK_DEPENDENT_REFUNDS", + BankIntentConflict = "BANK_INTENT_CONFLICT", + BankNotFound = "BANK_NOT_FOUND", + BankRefundLimit = "BANK_REFUND_LIMIT", + BankVersionConflict = "BANK_VERSION_CONFLICT", + BankWriteConflict = "BANK_WRITE_CONFLICT", + BondTickerAmbiguous = "BOND_TICKER_AMBIGUOUS", + Forbidden = "FORBIDDEN", + InvalidOperationsCursor = "INVALID_OPERATIONS_CURSOR", + InvalidTbankToken = "INVALID_TBANK_TOKEN", + NotFound = "NOT_FOUND", + StockTickerAmbiguous = "STOCK_TICKER_AMBIGUOUS", + TbankNotConnected = "TBANK_NOT_CONNECTED", + TbankUnavailable = "TBANK_UNAVAILABLE", + Unauthorized = "UNAUTHORIZED", +} + function apiErrorCode(error: unknown): string | undefined { return typeof error === "object" && error !== null && "code" in error ? String(error.code) : undefined; } -export { apiErrorCode }; +// Incoming codes are an open wire string; unknown values retain their fallback. +function isApiErrorCode(value: unknown, expected: ApiErrorCode): boolean { + return value === expected; +} + +export { ApiErrorCode, apiErrorCode, isApiErrorCode }; diff --git a/src/shared/api/resource-query.ts b/src/shared/api/resource-query.ts index 9a20602..31d37ba 100644 --- a/src/shared/api/resource-query.ts +++ b/src/shared/api/resource-query.ts @@ -10,7 +10,7 @@ import type { components } from "./generated/schema"; import { useAuthenticatedQuery } from "./authenticated-query"; import { getAuthenticatedSessionSnapshot } from "./client"; -import { apiErrorCode } from "./problem-details"; +import { ApiErrorCode, apiErrorCode, isApiErrorCode } from "./problem-details"; const accessEpochs = new WeakMap>(); @@ -23,13 +23,14 @@ function invalidateResourceAccess( group: string, error: unknown, ) { + const errorCode = apiErrorCode(error); if ( - ![ - "FORBIDDEN", - "INVALID_TBANK_TOKEN", - "NOT_FOUND", - "TBANK_NOT_CONNECTED", - ].includes(apiErrorCode(error) ?? "") + [ + ApiErrorCode.Forbidden, + ApiErrorCode.InvalidTbankToken, + ApiErrorCode.NotFound, + ApiErrorCode.TbankNotConnected, + ].every(code => !isApiErrorCode(errorCode, code)) ) return; const epochs = accessEpochs.get(client) ?? new Map(); diff --git a/tsconfig.app.json b/tsconfig.app.json index b452cb8..d595919 100644 --- a/tsconfig.app.json +++ b/tsconfig.app.json @@ -21,7 +21,6 @@ "jsx": "react-jsx", "noUnusedLocals": true, "noUnusedParameters": true, - "erasableSyntaxOnly": true, "noFallthroughCasesInSwitch": true }, "include": ["src", ".storybook/preview.tsx", "packages/*/src"]