refactor: enforce enum machine codes #18
Frontend verify / verify (pull_request) Canceled after 0s
Frontend security / security (pull_request) Canceled after 0s
Frontend security / security (push) Canceled after 0s
Frontend verify / verify (push) Canceled after 0s
Conventional commits / commits (pull_request) Canceled after 0s
Frontend verify / verify (pull_request) Canceled after 0s
Frontend security / security (pull_request) Canceled after 0s
Frontend security / security (push) Canceled after 0s
Frontend verify / verify (push) Canceled after 0s
Conventional commits / commits (pull_request) Canceled after 0s
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.
This commit is contained in:
@@ -70,7 +70,7 @@ Network/unknown error получает безопасное локальное
|
|||||||
|
|
||||||
Если одна группа API codes требует одинакового recovery в нескольких views,
|
Если одна группа API codes требует одинакового recovery в нескольких views,
|
||||||
сведи классификацию у ближайшего общего owner. Не размножай magic comparisons
|
сведи классификацию у ближайшего общего owner. Не размножай magic comparisons
|
||||||
и не объявляй закрытый frontend error enum: generated `code` пока `string`.
|
и используй `ApiErrorCode` для известных значений: generated `code` остаётся `string`.
|
||||||
Неизвестный новый code должен сохранять fallback. Рефакторинг существующих
|
Неизвестный новый code должен сохранять fallback. Рефакторинг существующих
|
||||||
разных recovery paths требует behavior proof, а не общей обработки всех ошибок.
|
разных recovery paths требует behavior proof, а не общей обработки всех ошибок.
|
||||||
|
|
||||||
|
|||||||
@@ -29,7 +29,8 @@ owner и 1–3 примера. Код показывает механизм; с
|
|||||||
## Domain values
|
## Domain values
|
||||||
|
|
||||||
Finite business vocabulary принадлежит одному domain/module/entity owner.
|
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 не переписывай вручную.
|
Сохраняй wire values; generated/external enums не переписывай вручную.
|
||||||
Numeric policy называй по смыслу и единицам. Индексы, `Math.max(value, 0)` и
|
Numeric policy называй по смыслу и единицам. Индексы, `Math.max(value, 0)` и
|
||||||
обычные labels не выноси в constants без semantic benefit.
|
обычные labels не выноси в constants без semantic benefit.
|
||||||
|
|||||||
@@ -5,7 +5,8 @@
|
|||||||
- Сохраняй strict TS profile и `import type`. Для недоверенного значения используй
|
- Сохраняй strict TS profile и `import type`. Для недоверенного значения используй
|
||||||
`unknown` с narrowing; `any` не заменяет DTO/validation. Локальное исключение
|
`unknown` с narrowing; `any` не заменяет DTO/validation. Локальное исключение
|
||||||
на нетипизированной boundary должно быть узким и объяснённым.
|
на нетипизированной boundary должно быть узким и объяснённым.
|
||||||
- `enum` и `namespace` не используются: frontend имеет `erasableSyntaxOnly`.
|
- Строковые `enum` используются для machine codes и protocol values.
|
||||||
|
`namespace` не используется; tooling сохраняет `erasableSyntaxOnly`.
|
||||||
Для состояния с разными payload выбирай discriminated union; не добавляй
|
Для состояния с разными payload выбирай discriminated union; не добавляй
|
||||||
необязательные поля, допускающие невозможные комбинации.
|
необязательные поля, допускающие невозможные комбинации.
|
||||||
- Named components — const-valued `FC<Props>`; named props находятся рядом или
|
- Named components — const-valued `FC<Props>`; named props находятся рядом или
|
||||||
@@ -33,19 +34,23 @@ Container/VM/view и state owners — в [UI](ui.md).
|
|||||||
|
|
||||||
Общее правило ownership и символических значений —
|
Общее правило ownership и символических значений —
|
||||||
[Domain values](engineering.md#domain-values).
|
[Domain values](engineering.md#domain-values).
|
||||||
Backend vocabulary бери из generated `paths`, не создавай ручную копию enum.
|
Backend vocabulary бери из generated `paths`; generated artifacts вручную не меняй.
|
||||||
Текущий `schema.d.ts` содержит только types: typed wire literals допустимы,
|
Для открытых `string` полей допускается локальный enum известных значений,
|
||||||
когда generator не предоставляет runtime values.
|
используемых UI. Он не закрывает wire contract и сохраняет unknown fallback.
|
||||||
|
Если generator предоставляет runtime enum, используй его.
|
||||||
|
|
||||||
`ProblemDetailsDto.code` сейчас `string`. Не объявляй закрытый frontend error
|
`ProblemDetailsDto.code` сейчас `string`. `ApiErrorCode` перечисляет известные UI коды, а не закрывает wire contract.
|
||||||
enum и не превращай неизвестный code в известный через cast. Повторяющуюся
|
`apiErrorCode` сохраняет произвольную строку; `isApiErrorCode` проверяет известное
|
||||||
|
значение без cast и сохраняет fallback для неизвестного кода. Повторяющуюся
|
||||||
обработку своди у ответственного owner; [errors](data.md#errors).
|
обработку своди у ответственного owner; [errors](data.md#errors).
|
||||||
|
|
||||||
`@typescript-eslint/no-magic-numbers` — error; fixtures/tests/stories исключены
|
`@typescript-eslint/no-magic-numbers` — error; fixtures/tests/stories исключены
|
||||||
из этой дополнительной проверки. Policy получает имя и единицы; подавление допустимо для
|
из этой дополнительной проверки. Policy получает имя и единицы; подавление допустимо для
|
||||||
incidental value с объяснением. Обычный UI-текст не превращай в constant:
|
incidental value с объяснением. Обычный UI-текст не превращай в constant:
|
||||||
его owner — [i18n](ui.md#строки-и-валидация).
|
его 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.
|
||||||
|
|
||||||
## Проверка
|
## Проверка
|
||||||
|
|
||||||
|
|||||||
@@ -118,4 +118,45 @@ export default defineConfig(
|
|||||||
files: ["**/*.stories.tsx", ".storybook/preview.tsx"],
|
files: ["**/*.stories.tsx", ".storybook/preview.tsx"],
|
||||||
rules: { "import-x/exports-last": "off", "import-x/no-default-export": "off" },
|
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)]",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -185,3 +185,27 @@ test("ESLint formatting preserves spaces around JSX text and interpolations", as
|
|||||||
assert.equal(fixed.errorCount, 0);
|
assert.equal(fixed.errorCount, 0);
|
||||||
assert.ok(fixed.output?.includes("Amount: {value} / {value}"));
|
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"));
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
import { apiClient, apiErrorCode } from "@/shared/api";
|
import { apiClient, ApiErrorCode, apiErrorCode, isApiErrorCode } from "@/shared/api";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
CreateBankCard,
|
CreateBankCard,
|
||||||
@@ -34,14 +34,15 @@ function useBankMutations() {
|
|||||||
});
|
});
|
||||||
const options = {
|
const options = {
|
||||||
onError: (error: unknown) => {
|
onError: (error: unknown) => {
|
||||||
|
const errorCode = apiErrorCode(error);
|
||||||
if (
|
if (
|
||||||
[
|
[
|
||||||
"BANK_DEPENDENT_REFUNDS",
|
ApiErrorCode.BankDependentRefunds,
|
||||||
"BANK_INTENT_CONFLICT",
|
ApiErrorCode.BankIntentConflict,
|
||||||
"BANK_REFUND_LIMIT",
|
ApiErrorCode.BankRefundLimit,
|
||||||
"BANK_VERSION_CONFLICT",
|
ApiErrorCode.BankVersionConflict,
|
||||||
"BANK_WRITE_CONFLICT",
|
ApiErrorCode.BankWriteConflict,
|
||||||
].includes(apiErrorCode(error) ?? "")
|
].some(code => isApiErrorCode(errorCode, code))
|
||||||
)
|
)
|
||||||
return invalidate();
|
return invalidate();
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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}`);
|
||||||
|
});
|
||||||
@@ -2,6 +2,23 @@ import { formatMoney } from "@/shared/lib";
|
|||||||
|
|
||||||
import type { BrokerageOperation } from "../api/useBrokerageOperations";
|
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 = {
|
type PaymentPresentation = {
|
||||||
amount: string;
|
amount: string;
|
||||||
label: string;
|
label: string;
|
||||||
@@ -10,12 +27,16 @@ type PaymentPresentation = {
|
|||||||
|
|
||||||
function entryLabelKey(operation: BrokerageOperation): string {
|
function entryLabelKey(operation: BrokerageOperation): string {
|
||||||
if (operation.category === "income")
|
if (operation.category === "income")
|
||||||
return operation.type === "OPERATION_TYPE_COUPON"
|
return isOperationType(operation.type, OperationType.Coupon)
|
||||||
? "ledger.entryLabel.income.coupon"
|
? "ledger.entryLabel.income.coupon"
|
||||||
: "ledger.entryLabel.income.dividend";
|
: "ledger.entryLabel.income.dividend";
|
||||||
return `ledger.category.${operation.category}`;
|
return `ledger.category.${operation.category}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isOperationType(value: unknown, expected: OperationType): boolean {
|
||||||
|
return value === expected;
|
||||||
|
}
|
||||||
|
|
||||||
function isZeroDecimal(value: string): boolean {
|
function isZeroDecimal(value: string): boolean {
|
||||||
return /^-?0$|^-?0\.0+$/.test(value);
|
return /^-?0$|^-?0\.0+$/.test(value);
|
||||||
}
|
}
|
||||||
@@ -23,35 +44,35 @@ function isZeroDecimal(value: string): boolean {
|
|||||||
function operationLabelKey(type: string): string {
|
function operationLabelKey(type: string): string {
|
||||||
if (
|
if (
|
||||||
[
|
[
|
||||||
"OPERATION_TYPE_BUY",
|
OperationType.Buy,
|
||||||
"OPERATION_TYPE_BUY_CARD",
|
OperationType.BuyCard,
|
||||||
"OPERATION_TYPE_BUY_MARGIN",
|
OperationType.BuyMargin,
|
||||||
].includes(type)
|
].some(candidate => isOperationType(type, candidate))
|
||||||
)
|
)
|
||||||
return "ledger.operationType.buy";
|
return "ledger.operationType.buy";
|
||||||
if (
|
if (
|
||||||
[
|
[
|
||||||
"OPERATION_TYPE_SELL",
|
OperationType.Sell,
|
||||||
"OPERATION_TYPE_SELL_CARD",
|
OperationType.SellCard,
|
||||||
"OPERATION_TYPE_SELL_MARGIN",
|
OperationType.SellMargin,
|
||||||
].includes(type)
|
].some(candidate => isOperationType(type, candidate))
|
||||||
)
|
)
|
||||||
return "ledger.operationType.sell";
|
return "ledger.operationType.sell";
|
||||||
if (
|
if (
|
||||||
[
|
[
|
||||||
"OPERATION_TYPE_DIV_EXT",
|
OperationType.DivExtension,
|
||||||
"OPERATION_TYPE_DIVIDEND",
|
OperationType.Dividend,
|
||||||
"OPERATION_TYPE_DIVIDEND_TRANSFER",
|
OperationType.DividendTransfer,
|
||||||
].includes(type)
|
].some(candidate => isOperationType(type, candidate))
|
||||||
)
|
)
|
||||||
return "ledger.operationType.dividend";
|
return "ledger.operationType.dividend";
|
||||||
if (type.includes("TAX")) return "ledger.operationType.tax";
|
if (type.includes("TAX")) return "ledger.operationType.tax";
|
||||||
if (type.includes("FEE")) return "ledger.operationType.fee";
|
if (type.includes("FEE")) return "ledger.operationType.fee";
|
||||||
if (type === "OPERATION_TYPE_INPUT_SECURITIES")
|
if (isOperationType(type, OperationType.InputSecurities))
|
||||||
return "ledger.operationType.input";
|
return "ledger.operationType.input";
|
||||||
if (type === "OPERATION_TYPE_OUTPUT_SECURITIES")
|
if (isOperationType(type, OperationType.OutputSecurities))
|
||||||
return "ledger.operationType.output";
|
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(
|
function paymentPresentation(
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
type OperationCategory,
|
type OperationCategory,
|
||||||
useBrokerageOperations,
|
useBrokerageOperations,
|
||||||
} from "@/entities/broker-operation";
|
} from "@/entities/broker-operation";
|
||||||
import { apiErrorCode } from "@/shared/api";
|
import { ApiErrorCode, apiErrorCode, isApiErrorCode } from "@/shared/api";
|
||||||
|
|
||||||
function useInstrumentOperations(
|
function useInstrumentOperations(
|
||||||
uid: string,
|
uid: string,
|
||||||
@@ -26,7 +26,7 @@ function useInstrumentOperations(
|
|||||||
cursor: cursors.at(-1),
|
cursor: cursors.at(-1),
|
||||||
});
|
});
|
||||||
const retry = (retryScreen: () => unknown) => {
|
const retry = (retryScreen: () => unknown) => {
|
||||||
if (apiErrorCode(operations.error) === "INVALID_OPERATIONS_CURSOR") {
|
if (isApiErrorCode(apiErrorCode(operations.error), ApiErrorCode.InvalidOperationsCursor)) {
|
||||||
setPagination({ cursors: [], scope });
|
setPagination({ cursors: [], scope });
|
||||||
void client.invalidateQueries({
|
void client.invalidateQueries({
|
||||||
exact: true,
|
exact: true,
|
||||||
|
|||||||
@@ -2,9 +2,11 @@ import { type QueryKey, useQueryClient } from "@tanstack/react-query";
|
|||||||
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
ApiErrorCode,
|
||||||
apiErrorCode,
|
apiErrorCode,
|
||||||
apiQueryClient,
|
apiQueryClient,
|
||||||
invalidateResourceAccess,
|
invalidateResourceAccess,
|
||||||
|
isApiErrorCode,
|
||||||
type paths,
|
type paths,
|
||||||
useAuthenticatedQueries,
|
useAuthenticatedQueries,
|
||||||
} from "@/shared/api";
|
} from "@/shared/api";
|
||||||
@@ -112,7 +114,7 @@ function useScreenSync(scope: InvestmentScreen, resources: ScreenResource[]) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
deniedRef.current.add(status.error);
|
deniedRef.current.add(status.error);
|
||||||
if (apiErrorCode(status.error) !== "NOT_FOUND")
|
if (!isApiErrorCode(apiErrorCode(status.error), ApiErrorCode.NotFound))
|
||||||
invalidateResourceAccess(client, "brokerage", status.error);
|
invalidateResourceAccess(client, "brokerage", status.error);
|
||||||
}
|
}
|
||||||
const latest = new Map<string, ResourceMeta>();
|
const latest = new Map<string, ResourceMeta>();
|
||||||
|
|||||||
+6
-4
@@ -13,13 +13,15 @@ import { useEffect, useRef, useState } from "react";
|
|||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { apiErrorCode, apiQueryClient } from "@/shared/api";
|
import { ApiErrorCode, apiErrorCode, apiQueryClient, isApiErrorCode } from "@/shared/api";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
commitTbankConnectionBoundary,
|
commitTbankConnectionBoundary,
|
||||||
type TbankConnectionStatus,
|
type TbankConnectionStatus,
|
||||||
} from "../../model/connection-boundary";
|
} from "../../model/connection-boundary";
|
||||||
|
|
||||||
|
enum ConnectionErrorCode { RequestFailed = "REQUEST_ERROR" }
|
||||||
|
|
||||||
type TbankConnectionSectionLoadedProps = {
|
type TbankConnectionSectionLoadedProps = {
|
||||||
refreshError: boolean;
|
refreshError: boolean;
|
||||||
refreshing: boolean;
|
refreshing: boolean;
|
||||||
@@ -43,7 +45,7 @@ const TbankConnectionSectionLoaded: FC<TbankConnectionSectionLoadedProps> = (
|
|||||||
reset,
|
reset,
|
||||||
} = useForm<{ token: string }>();
|
} = useForm<{ token: string }>();
|
||||||
const update = apiQueryClient.useMutation("put", "/api/v1/tbank-connection", {
|
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) => {
|
onSuccess: (data) => {
|
||||||
commitTbankConnectionBoundary(queryClient, data);
|
commitTbankConnectionBoundary(queryClient, data);
|
||||||
reset();
|
reset();
|
||||||
@@ -62,7 +64,7 @@ const TbankConnectionSectionLoaded: FC<TbankConnectionSectionLoadedProps> = (
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
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 isPending = update.isPending || remove.isPending;
|
||||||
const isConnected = status.connected;
|
const isConnected = status.connected;
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -119,7 +121,7 @@ const TbankConnectionSectionLoaded: FC<TbankConnectionSectionLoadedProps> = (
|
|||||||
{requestError && (
|
{requestError && (
|
||||||
<Alert color="red">
|
<Alert color="red">
|
||||||
{t(
|
{t(
|
||||||
requestError === "INVALID_TBANK_TOKEN"
|
isApiErrorCode(requestError, ApiErrorCode.InvalidTbankToken)
|
||||||
? "tbank.invalidToken"
|
? "tbank.invalidToken"
|
||||||
: unavailableMessage,
|
: unavailableMessage,
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
useBrokerageOperations,
|
useBrokerageOperations,
|
||||||
} from "@/entities/broker-operation";
|
} from "@/entities/broker-operation";
|
||||||
import { useScreenSync } from "@/features/tbank-connection";
|
import { useScreenSync } from "@/features/tbank-connection";
|
||||||
import { apiErrorCode } from "@/shared/api";
|
import { ApiErrorCode, apiErrorCode, isApiErrorCode } from "@/shared/api";
|
||||||
|
|
||||||
type AccountOperationsPageViewModel
|
type AccountOperationsPageViewModel
|
||||||
= | {
|
= | {
|
||||||
@@ -114,7 +114,7 @@ const useAccountOperationsPageVm = (
|
|||||||
|
|
||||||
const resetCursors = () => setPagination({ cursors: [], scopeKey });
|
const resetCursors = () => setPagination({ cursors: [], scopeKey });
|
||||||
const retry = () => {
|
const retry = () => {
|
||||||
if (apiErrorCode(operations.error) === "INVALID_OPERATIONS_CURSOR") {
|
if (isApiErrorCode(apiErrorCode(operations.error), ApiErrorCode.InvalidOperationsCursor)) {
|
||||||
void queryClient.invalidateQueries({
|
void queryClient.invalidateQueries({
|
||||||
exact: true,
|
exact: true,
|
||||||
queryKey: brokerageOperationsQueryOptions(accountId, firstPageQuery)
|
queryKey: brokerageOperationsQueryOptions(accountId, firstPageQuery)
|
||||||
@@ -136,7 +136,7 @@ const useAccountOperationsPageVm = (
|
|||||||
if (
|
if (
|
||||||
(operations.isError || operations.meta?.sync.state === "failed")
|
(operations.isError || operations.meta?.sync.state === "failed")
|
||||||
&& (!operations.data
|
&& (!operations.data
|
||||||
|| apiErrorCode(operations.error) === "INVALID_OPERATIONS_CURSOR")
|
|| isApiErrorCode(apiErrorCode(operations.error), ApiErrorCode.InvalidOperationsCursor))
|
||||||
)
|
)
|
||||||
return { onReset, retry, state: "error" };
|
return { onReset, retry, state: "error" };
|
||||||
if (!operations.data) return { state: "loading" };
|
if (!operations.data) return { state: "loading" };
|
||||||
|
|||||||
@@ -5,12 +5,14 @@ import { StateView } from "@wealthledger/ui";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Link } from "react-router";
|
import { Link } from "react-router";
|
||||||
|
|
||||||
|
import { ApiErrorCode, isApiErrorCode } from "@/shared/api";
|
||||||
|
|
||||||
type AccountPageErrorProps = { code?: string; retry: () => void };
|
type AccountPageErrorProps = { code?: string; retry: () => void };
|
||||||
|
|
||||||
const AccountPageError: FC<AccountPageErrorProps> = (props) => {
|
const AccountPageError: FC<AccountPageErrorProps> = (props) => {
|
||||||
const { code, retry } = props;
|
const { code, retry } = props;
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
if (code === "NOT_FOUND")
|
if (isApiErrorCode(code, ApiErrorCode.NotFound))
|
||||||
return (
|
return (
|
||||||
<StateView
|
<StateView
|
||||||
action={(
|
action={(
|
||||||
@@ -23,7 +25,7 @@ const AccountPageError: FC<AccountPageErrorProps> = (props) => {
|
|||||||
title={t("portfolio.accountUnavailableTitle")}
|
title={t("portfolio.accountUnavailableTitle")}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
if (code === "TBANK_NOT_CONNECTED" || code === "INVALID_TBANK_TOKEN")
|
if (isApiErrorCode(code, ApiErrorCode.TbankNotConnected) || isApiErrorCode(code, ApiErrorCode.InvalidTbankToken))
|
||||||
return (
|
return (
|
||||||
<StateView
|
<StateView
|
||||||
action={(
|
action={(
|
||||||
@@ -36,7 +38,7 @@ const AccountPageError: FC<AccountPageErrorProps> = (props) => {
|
|||||||
title={t("portfolio.connectionErrorTitle")}
|
title={t("portfolio.connectionErrorTitle")}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
if (code === "TBANK_UNAVAILABLE")
|
if (isApiErrorCode(code, ApiErrorCode.TbankUnavailable))
|
||||||
return (
|
return (
|
||||||
<StateView
|
<StateView
|
||||||
action={<Button onClick={retry}>{t("accounts.retry")}</Button>}
|
action={<Button onClick={retry}>{t("accounts.retry")}</Button>}
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import { StateView } from "@wealthledger/ui";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Link } from "react-router";
|
import { Link } from "react-router";
|
||||||
|
|
||||||
|
import { ApiErrorCode, isApiErrorCode } from "@/shared/api";
|
||||||
|
|
||||||
type AccountsPageErrorProps = { code?: string; retry?: () => void };
|
type AccountsPageErrorProps = { code?: string; retry?: () => void };
|
||||||
|
|
||||||
const AccountsPageError: FC<AccountsPageErrorProps> = (props) => {
|
const AccountsPageError: FC<AccountsPageErrorProps> = (props) => {
|
||||||
@@ -15,7 +17,7 @@ const AccountsPageError: FC<AccountsPageErrorProps> = (props) => {
|
|||||||
{t("accounts.openSettings")}
|
{t("accounts.openSettings")}
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
if (code === "TBANK_NOT_CONNECTED")
|
if (isApiErrorCode(code, ApiErrorCode.TbankNotConnected))
|
||||||
return (
|
return (
|
||||||
<StateView
|
<StateView
|
||||||
action={settingsAction}
|
action={settingsAction}
|
||||||
@@ -24,7 +26,7 @@ const AccountsPageError: FC<AccountsPageErrorProps> = (props) => {
|
|||||||
title={t("accounts.notConnectedTitle")}
|
title={t("accounts.notConnectedTitle")}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
if (code === "INVALID_TBANK_TOKEN")
|
if (isApiErrorCode(code, ApiErrorCode.InvalidTbankToken))
|
||||||
return (
|
return (
|
||||||
<StateView
|
<StateView
|
||||||
action={settingsAction}
|
action={settingsAction}
|
||||||
@@ -33,7 +35,7 @@ const AccountsPageError: FC<AccountsPageErrorProps> = (props) => {
|
|||||||
title={t("accounts.invalidTokenTitle")}
|
title={t("accounts.invalidTokenTitle")}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
if (code === "TBANK_UNAVAILABLE")
|
if (isApiErrorCode(code, ApiErrorCode.TbankUnavailable))
|
||||||
return (
|
return (
|
||||||
<StateView
|
<StateView
|
||||||
action={(
|
action={(
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import { LoadingSkeleton } from "@wealthledger/ui";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Link, Navigate } from "react-router";
|
import { Link, Navigate } from "react-router";
|
||||||
|
|
||||||
|
import { ApiErrorCode, isApiErrorCode } from "@/shared/api";
|
||||||
|
|
||||||
import { BondPageView } from "./BondPage.view";
|
import { BondPageView } from "./BondPage.view";
|
||||||
import { useBondPageVm } from "./BondPage.vm";
|
import { useBondPageVm } from "./BondPage.vm";
|
||||||
|
|
||||||
@@ -22,15 +24,15 @@ const BondPage: FC = () => {
|
|||||||
</Stack>
|
</Stack>
|
||||||
</LoadingSkeleton>
|
</LoadingSkeleton>
|
||||||
);
|
);
|
||||||
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)
|
if (!vm.bond)
|
||||||
return (
|
return (
|
||||||
<Stack role="alert">
|
<Stack role="alert">
|
||||||
<Title order={1}>{t("bond.unavailable")}</Title>
|
<Title order={1}>{t("bond.unavailable")}</Title>
|
||||||
<Text>
|
<Text>
|
||||||
{t(
|
{t(
|
||||||
vm.code === "TBANK_NOT_CONNECTED"
|
isApiErrorCode(vm.code, ApiErrorCode.TbankNotConnected)
|
||||||
|| vm.code === "INVALID_TBANK_TOKEN"
|
|| isApiErrorCode(vm.code, ApiErrorCode.InvalidTbankToken)
|
||||||
? "bond.connect"
|
? "bond.connect"
|
||||||
: unavailableMessage,
|
: unavailableMessage,
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { LoadingSkeleton } from "@wealthledger/ui";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Link } from "react-router";
|
import { Link } from "react-router";
|
||||||
|
|
||||||
|
import { ApiErrorCode, isApiErrorCode } from "@/shared/api";
|
||||||
import {
|
import {
|
||||||
formatDate,
|
formatDate,
|
||||||
formatDecimal,
|
formatDecimal,
|
||||||
@@ -188,8 +189,8 @@ const BondsPageView: FC<BondsPageViewProps> = (props) => {
|
|||||||
<Stack gap="xs">
|
<Stack gap="xs">
|
||||||
<Text>
|
<Text>
|
||||||
{t(
|
{t(
|
||||||
vm.code === "TBANK_NOT_CONNECTED"
|
isApiErrorCode(vm.code, ApiErrorCode.TbankNotConnected)
|
||||||
|| vm.code === "INVALID_TBANK_TOKEN"
|
|| isApiErrorCode(vm.code, ApiErrorCode.InvalidTbankToken)
|
||||||
? "bond.connect"
|
? "bond.connect"
|
||||||
: "stock.sectionError",
|
: "stock.sectionError",
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
useBankOperations,
|
useBankOperations,
|
||||||
useBankSummary,
|
useBankSummary,
|
||||||
} from "@/entities/bank-card";
|
} from "@/entities/bank-card";
|
||||||
import { apiErrorCode } from "@/shared/api";
|
import { ApiErrorCode, apiErrorCode, isApiErrorCode } from "@/shared/api";
|
||||||
import { formatDate, formatMoney } from "@/shared/lib";
|
import { formatDate, formatMoney } from "@/shared/lib";
|
||||||
|
|
||||||
const PAGE_SIZES = new Set(["5", "10", "20", "50", "100", "all"]);
|
const PAGE_SIZES = new Set(["5", "10", "20", "50", "100", "all"]);
|
||||||
@@ -81,7 +81,7 @@ function useCardPageVm() {
|
|||||||
deleting,
|
deleting,
|
||||||
editingCard,
|
editingCard,
|
||||||
history,
|
history,
|
||||||
notFound: apiErrorCode(card.error) === "BANK_NOT_FOUND",
|
notFound: isApiErrorCode(apiErrorCode(card.error), ApiErrorCode.BankNotFound),
|
||||||
operation,
|
operation,
|
||||||
page,
|
page,
|
||||||
pageSize,
|
pageSize,
|
||||||
|
|||||||
@@ -27,6 +27,9 @@ import type {
|
|||||||
OperationValues,
|
OperationValues,
|
||||||
} from "./OperationForm.types";
|
} 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 MAX_DESCRIPTION_LENGTH = 500;
|
||||||
|
|
||||||
const operationSchema = z.object({
|
const operationSchema = z.object({
|
||||||
@@ -49,13 +52,13 @@ function validateRefund(values: OperationValues, context: z.RefinementCtx,
|
|||||||
expense: BankOperation | undefined, limit: BankOperation["availableRefund"] | undefined) {
|
expense: BankOperation | undefined, limit: BankOperation["availableRefund"] | undefined) {
|
||||||
if (!expense || expense.cardId !== values.cardId)
|
if (!expense || expense.cardId !== values.cardId)
|
||||||
context.addIssue({
|
context.addIssue({
|
||||||
code: "custom",
|
code: FormIssueCode.Custom,
|
||||||
path: ["originalExpenseId"],
|
path: ["originalExpenseId"],
|
||||||
...validationMessage("bank.validation.required")(undefined),
|
...validationMessage("bank.validation.required")(undefined),
|
||||||
});
|
});
|
||||||
if (expense && values.date < expense.date)
|
if (expense && values.date < expense.date)
|
||||||
context.addIssue({
|
context.addIssue({
|
||||||
code: "custom",
|
code: FormIssueCode.Custom,
|
||||||
path: ["date"],
|
path: ["date"],
|
||||||
...validationMessage("bank.validation.start")(undefined),
|
...validationMessage("bank.validation.start")(undefined),
|
||||||
});
|
});
|
||||||
@@ -66,7 +69,7 @@ function validateRefund(values: OperationValues, context: z.RefinementCtx,
|
|||||||
&& amount > (moneyCents(limit.value) ?? 0n)
|
&& amount > (moneyCents(limit.value) ?? 0n)
|
||||||
)
|
)
|
||||||
context.addIssue({
|
context.addIssue({
|
||||||
code: "custom",
|
code: FormIssueCode.Custom,
|
||||||
path: ["amount"],
|
path: ["amount"],
|
||||||
...validationMessage("bank.validation.refund")(undefined),
|
...validationMessage("bank.validation.refund")(undefined),
|
||||||
});
|
});
|
||||||
@@ -116,7 +119,7 @@ const useOperationFormVm = (props: OperationFormProps) => {
|
|||||||
const target = props.cards.find(c => c.id === values.targetCardId);
|
const target = props.cards.find(c => c.id === values.targetCardId);
|
||||||
if (!source || source.archivedAt)
|
if (!source || source.archivedAt)
|
||||||
context.addIssue({
|
context.addIssue({
|
||||||
code: "custom",
|
code: FormIssueCode.Custom,
|
||||||
path: ["cardId"],
|
path: ["cardId"],
|
||||||
...validationMessage("bank.validation.required")(undefined),
|
...validationMessage("bank.validation.required")(undefined),
|
||||||
});
|
});
|
||||||
@@ -125,7 +128,7 @@ const useOperationFormVm = (props: OperationFormProps) => {
|
|||||||
&& (!target || target.archivedAt || target.id === values.cardId)
|
&& (!target || target.archivedAt || target.id === values.cardId)
|
||||||
)
|
)
|
||||||
context.addIssue({
|
context.addIssue({
|
||||||
code: "custom",
|
code: FormIssueCode.Custom,
|
||||||
path: ["targetCardId"],
|
path: ["targetCardId"],
|
||||||
...validationMessage("bank.validation.destination")(undefined),
|
...validationMessage("bank.validation.destination")(undefined),
|
||||||
});
|
});
|
||||||
@@ -136,7 +139,7 @@ const useOperationFormVm = (props: OperationFormProps) => {
|
|||||||
&& values.date < target.openingDate)
|
&& values.date < target.openingDate)
|
||||||
)
|
)
|
||||||
context.addIssue({
|
context.addIssue({
|
||||||
code: "custom",
|
code: FormIssueCode.Custom,
|
||||||
path: ["date"],
|
path: ["date"],
|
||||||
...validationMessage("bank.validation.start")(undefined),
|
...validationMessage("bank.validation.start")(undefined),
|
||||||
});
|
});
|
||||||
@@ -152,7 +155,7 @@ const useOperationFormVm = (props: OperationFormProps) => {
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
context.addIssue({
|
context.addIssue({
|
||||||
code: "custom",
|
code: FormIssueCode.Custom,
|
||||||
path: ["categoryId"],
|
path: ["categoryId"],
|
||||||
...validationMessage("bank.validation.required")(undefined),
|
...validationMessage("bank.validation.required")(undefined),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import { LoadingSkeleton } from "@wealthledger/ui";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Link, Navigate } from "react-router";
|
import { Link, Navigate } from "react-router";
|
||||||
|
|
||||||
|
import { ApiErrorCode, isApiErrorCode } from "@/shared/api";
|
||||||
|
|
||||||
import { StockPageView } from "./StockPage.view";
|
import { StockPageView } from "./StockPage.view";
|
||||||
import { useStockPageVm } from "./StockPage.vm";
|
import { useStockPageVm } from "./StockPage.vm";
|
||||||
|
|
||||||
@@ -22,15 +24,15 @@ const StockPage: FC = () => {
|
|||||||
</Stack>
|
</Stack>
|
||||||
</LoadingSkeleton>
|
</LoadingSkeleton>
|
||||||
);
|
);
|
||||||
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)
|
if (!vm.stock)
|
||||||
return (
|
return (
|
||||||
<Stack role="alert">
|
<Stack role="alert">
|
||||||
<Title order={1}>{t("stock.unavailable")}</Title>
|
<Title order={1}>{t("stock.unavailable")}</Title>
|
||||||
<Text>
|
<Text>
|
||||||
{t(
|
{t(
|
||||||
vm.code === "TBANK_NOT_CONNECTED"
|
isApiErrorCode(vm.code, ApiErrorCode.TbankNotConnected)
|
||||||
|| vm.code === "INVALID_TBANK_TOKEN"
|
|| isApiErrorCode(vm.code, ApiErrorCode.InvalidTbankToken)
|
||||||
? "stock.connect"
|
? "stock.connect"
|
||||||
: unavailableMessage,
|
: unavailableMessage,
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { LoadingSkeleton } from "@wealthledger/ui";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Link } from "react-router";
|
import { Link } from "react-router";
|
||||||
|
|
||||||
|
import { ApiErrorCode, isApiErrorCode } from "@/shared/api";
|
||||||
import { formatMoney, formatSnapshotTime } from "@/shared/lib";
|
import { formatMoney, formatSnapshotTime } from "@/shared/lib";
|
||||||
|
|
||||||
import type { StocksPageVm } from "./StocksPage.vm";
|
import type { StocksPageVm } from "./StocksPage.vm";
|
||||||
@@ -156,8 +157,8 @@ const StocksPageView: FC<StocksPageViewProps> = (props) => {
|
|||||||
<Stack gap="xs">
|
<Stack gap="xs">
|
||||||
<Text>
|
<Text>
|
||||||
{t(
|
{t(
|
||||||
vm.code === "TBANK_NOT_CONNECTED"
|
isApiErrorCode(vm.code, ApiErrorCode.TbankNotConnected)
|
||||||
|| vm.code === "INVALID_TBANK_TOKEN"
|
|| isApiErrorCode(vm.code, ApiErrorCode.InvalidTbankToken)
|
||||||
? "stock.connect"
|
? "stock.connect"
|
||||||
: "stock.sectionError",
|
: "stock.sectionError",
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import createQueryClient from "openapi-react-query";
|
|||||||
|
|
||||||
import type { paths } from "./generated/schema";
|
import type { paths } from "./generated/schema";
|
||||||
|
|
||||||
import { apiErrorCode } from "./problem-details";
|
import { ApiErrorCode, apiErrorCode, isApiErrorCode } from "./problem-details";
|
||||||
|
|
||||||
const HTTP_UNAUTHORIZED = 401;
|
const HTTP_UNAUTHORIZED = 401;
|
||||||
|
|
||||||
@@ -101,7 +101,7 @@ async function fetchCurrent(
|
|||||||
catch {
|
catch {
|
||||||
return response;
|
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 (!isCurrent()) throw new DOMException("Session changed", "AbortError");
|
||||||
if (!(await sessionState.handlers.refresh())) {
|
if (!(await sessionState.handlers.refresh())) {
|
||||||
if (isCurrent()) sessionState.handlers.clear();
|
if (isCurrent()) sessionState.handlers.clear();
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ export {
|
|||||||
invalidateApiRequests,
|
invalidateApiRequests,
|
||||||
} from "./client";
|
} from "./client";
|
||||||
export type { paths } from "./generated/schema";
|
export type { paths } from "./generated/schema";
|
||||||
export { apiErrorCode } from "./problem-details";
|
export { ApiErrorCode, apiErrorCode, isApiErrorCode } from "./problem-details";
|
||||||
|
|
||||||
export {
|
export {
|
||||||
invalidateResourceAccess,
|
invalidateResourceAccess,
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
@@ -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 {
|
function apiErrorCode(error: unknown): string | undefined {
|
||||||
return typeof error === "object" && error !== null && "code" in error
|
return typeof error === "object" && error !== null && "code" in error
|
||||||
? String(error.code)
|
? String(error.code)
|
||||||
: undefined;
|
: 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 };
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import type { components } from "./generated/schema";
|
|||||||
|
|
||||||
import { useAuthenticatedQuery } from "./authenticated-query";
|
import { useAuthenticatedQuery } from "./authenticated-query";
|
||||||
import { getAuthenticatedSessionSnapshot } from "./client";
|
import { getAuthenticatedSessionSnapshot } from "./client";
|
||||||
import { apiErrorCode } from "./problem-details";
|
import { ApiErrorCode, apiErrorCode, isApiErrorCode } from "./problem-details";
|
||||||
|
|
||||||
const accessEpochs = new WeakMap<QueryClient, Map<string, number>>();
|
const accessEpochs = new WeakMap<QueryClient, Map<string, number>>();
|
||||||
|
|
||||||
@@ -23,13 +23,14 @@ function invalidateResourceAccess(
|
|||||||
group: string,
|
group: string,
|
||||||
error: unknown,
|
error: unknown,
|
||||||
) {
|
) {
|
||||||
|
const errorCode = apiErrorCode(error);
|
||||||
if (
|
if (
|
||||||
![
|
[
|
||||||
"FORBIDDEN",
|
ApiErrorCode.Forbidden,
|
||||||
"INVALID_TBANK_TOKEN",
|
ApiErrorCode.InvalidTbankToken,
|
||||||
"NOT_FOUND",
|
ApiErrorCode.NotFound,
|
||||||
"TBANK_NOT_CONNECTED",
|
ApiErrorCode.TbankNotConnected,
|
||||||
].includes(apiErrorCode(error) ?? "")
|
].every(code => !isApiErrorCode(errorCode, code))
|
||||||
)
|
)
|
||||||
return;
|
return;
|
||||||
const epochs = accessEpochs.get(client) ?? new Map<string, number>();
|
const epochs = accessEpochs.get(client) ?? new Map<string, number>();
|
||||||
|
|||||||
@@ -21,7 +21,6 @@
|
|||||||
"jsx": "react-jsx",
|
"jsx": "react-jsx",
|
||||||
"noUnusedLocals": true,
|
"noUnusedLocals": true,
|
||||||
"noUnusedParameters": true,
|
"noUnusedParameters": true,
|
||||||
"erasableSyntaxOnly": true,
|
|
||||||
"noFallthroughCasesInSwitch": true
|
"noFallthroughCasesInSwitch": true
|
||||||
},
|
},
|
||||||
"include": ["src", ".storybook/preview.tsx", "packages/*/src"]
|
"include": ["src", ".storybook/preview.tsx", "packages/*/src"]
|
||||||
|
|||||||
Reference in New Issue
Block a user