diff --git a/docs/bridge-integration/API.md b/docs/bridge-integration/API.md index f43046226..6ead13714 100644 --- a/docs/bridge-integration/API.md +++ b/docs/bridge-integration/API.md @@ -295,6 +295,7 @@ query BridgeWithdrawals { | `BRIDGE_KYC_PENDING` | Operation requires approved KYC, but it is still pending. | | `BRIDGE_KYC_REJECTED` | KYC was rejected. | | `BRIDGE_KYC_OFFBOARDED` | Bridge offboarded the customer. | +| `BRIDGE_KYC_TIER_CEILING_EXCEEDED` | Withdrawal amount exceeds the KYC tier ceiling. | | `BRIDGE_CUSTOMER_NOT_FOUND` | Bridge customer record not found for the user. | | `BRIDGE_WITHDRAWAL_NOT_FOUND` | Withdrawal request not found or does not belong to the caller. | | `BRIDGE_WITHDRAWAL_ALREADY_INITIATED` | Withdrawal was already submitted to Bridge. | diff --git a/src/graphql/error-map.ts b/src/graphql/error-map.ts index 9defa8f00..dcaaaee13 100644 --- a/src/graphql/error-map.ts +++ b/src/graphql/error-map.ts @@ -541,6 +541,13 @@ export const mapError = (error: ApplicationError): CustomApolloError => { message, }) + case "BridgeKycTierCeilingExceededError": + message = error.message || "Withdrawal amount exceeds the KYC tier ceiling" + return bridgeGqlError({ + code: "BRIDGE_KYC_TIER_CEILING_EXCEEDED", + message, + }) + case "BridgeCustomerNotFoundError": message = "Bridge customer not found" return bridgeGqlError({ @@ -810,8 +817,9 @@ export const mapError = (error: ApplicationError): CustomApolloError => { case "InvalidCarrierForPhoneMetadataError": case "InvalidCarrierTypeForPhoneMetadataError": case "InvalidCountryCodeForPhoneMetadataError": - message = `Unexpected error occurred, please try again or contact support if it persists (code: ${error.name - }${error.message ? ": " + error.message : ""})` + message = `Unexpected error occurred, please try again or contact support if it persists (code: ${ + error.name + }${error.message ? ": " + error.message : ""})` return new UnexpectedClientError({ message, logger: baseLogger }) case "MissingSessionIdError": @@ -907,8 +915,9 @@ export const mapError = (error: ApplicationError): CustomApolloError => { return new ValidationInternalError({ message, logger: baseLogger }) case "UnknownCaptchaError": - message = `Unknown error occurred (code: ${error.name}${error.message ? ": " + error.message : "" - })` + message = `Unknown error occurred (code: ${error.name}${ + error.message ? ": " + error.message : "" + })` return new UnknownClientError({ message, logger: baseLogger }) default: diff --git a/src/services/bridge/errors.ts b/src/services/bridge/errors.ts index 9eff26707..5e0e125e4 100644 --- a/src/services/bridge/errors.ts +++ b/src/services/bridge/errors.ts @@ -46,7 +46,9 @@ export class BridgeKycRejectedError extends BridgeError { } export class BridgeKycOffboardedError extends BridgeError { - constructor(message: string = "Your account has been offboarded from Bridge. Please contact support.") { + constructor( + message: string = "Your account has been offboarded from Bridge. Please contact support.", + ) { super(message) } } @@ -58,7 +60,9 @@ export class BridgeInsufficientFundsError extends BridgeError { } export class BridgeAccountLevelError extends BridgeError { - constructor(message: string = "Bridge requires at least a Personal account (Level 1+)") { + constructor( + message: string = "Bridge requires at least a Personal account (Level 1+)", + ) { super(message) } } @@ -95,6 +99,12 @@ export class BridgeWebhookValidationError extends BridgeError { } } +export class BridgeKycTierCeilingExceededError extends BridgeError { + constructor(message: string = "Withdrawal amount exceeds the KYC tier ceiling") { + super(message) + } +} + export class BridgeWithdrawalNotFoundError extends BridgeError { constructor(message: string = "Withdrawal request not found") { super(message) @@ -102,18 +112,52 @@ export class BridgeWithdrawalNotFoundError extends BridgeError { } export class BridgeWithdrawalAlreadyInitiatedError extends BridgeError { - constructor(message: string = "Withdrawal has already been submitted to Bridge and cannot be cancelled") { + constructor( + message: string = "Withdrawal has already been submitted to Bridge and cannot be cancelled", + ) { super(message) } } /** * Maps HTTP status codes from Bridge API to domain error types + * + * Checks the response body for specific Bridge error types when applicable. */ export const mapBridgeHttpError = ( statusCode: number, response?: unknown, ): BridgeError => { + // Bridge returns 422/400 with a specific error type for KYC tier ceiling violations. + if ( + (statusCode === 422 || statusCode === 400) && + typeof response === "object" && + response !== null + ) { + const resp = response as Record + const errorObj = (resp.error ?? resp) as Record | undefined + const errorType = String(errorObj?.type ?? "").toLowerCase() + const errorMessage = String(errorObj?.message ?? resp?.message ?? "").toLowerCase() + + if ( + errorType.includes("kyc_tier_limit") || + errorType.includes("kyc_limit") || + errorType.includes("tier_ceiling") || + (errorMessage.includes("kyc") && + (errorMessage.includes("limit") || + errorMessage.includes("ceiling") || + errorMessage.includes("tier"))) + ) { + const message = + typeof errorObj?.message === "string" + ? errorObj.message + : typeof resp.message === "string" + ? resp.message + : undefined + return new BridgeKycTierCeilingExceededError(message) + } + } + switch (statusCode) { case 404: return new BridgeCustomerNotFoundError() diff --git a/test/flash/unit/graphql/bridge-error-map.spec.ts b/test/flash/unit/graphql/bridge-error-map.spec.ts index 194a83b39..286d6b2a3 100644 --- a/test/flash/unit/graphql/bridge-error-map.spec.ts +++ b/test/flash/unit/graphql/bridge-error-map.spec.ts @@ -11,10 +11,12 @@ import { BridgeKycOffboardedError, BridgeKycPendingError, BridgeKycRejectedError, + BridgeKycTierCeilingExceededError, BridgeRateLimitError, BridgeTimeoutError, BridgeTransferFailedError, BridgeWebhookValidationError, + mapBridgeHttpError, } from "@services/bridge/errors" describe("error-map: Bridge errors", () => { @@ -32,6 +34,7 @@ describe("error-map: Bridge errors", () => { [new BridgeTimeoutError(), "BRIDGE_TIMEOUT"], [new BridgeTransferFailedError(), "BRIDGE_TRANSFER_FAILED"], [new BridgeWebhookValidationError(), "BRIDGE_WEBHOOK_VALIDATION"], + [new BridgeKycTierCeilingExceededError(), "BRIDGE_KYC_TIER_CEILING_EXCEEDED"], [new BridgeApiError("Bridge API error", 500), "BRIDGE_API_ERROR"], [new BridgeError("Bridge unavailable"), "BRIDGE_ERROR"], ] @@ -52,3 +55,41 @@ describe("error-map: Bridge errors", () => { expect(result.message).toBeTruthy() }) }) + +describe("error-map: mapBridgeHttpError KYC tier ceiling detection", () => { + it("detects KYC tier ceiling via error.type", () => { + const result = mapBridgeHttpError(422, { + error: { type: "kyc_tier_limit_exceeded", message: "KYC tier limit reached" }, + }) + expect(result).toBeInstanceOf(BridgeKycTierCeilingExceededError) + expect(result.message).toBe("KYC tier limit reached") + }) + + it("detects KYC tier ceiling via error.type kyc_limit", () => { + const result = mapBridgeHttpError(400, { + error: { type: "kyc_limit_exceeded", message: "KYC limit exceeded" }, + }) + expect(result).toBeInstanceOf(BridgeKycTierCeilingExceededError) + }) + + it("detects KYC tier ceiling via response.message", () => { + const result = mapBridgeHttpError(422, { + message: "exceeds kyc ceiling", + }) + expect(result).toBeInstanceOf(BridgeKycTierCeilingExceededError) + }) + + it("does not detect on unrelated 422 errors", () => { + const result = mapBridgeHttpError(422, { + error: { type: "validation_error", message: "Invalid amount" }, + }) + expect(result).not.toBeInstanceOf(BridgeKycTierCeilingExceededError) + }) + + it("does not detect on non-422/400 errors", () => { + const result = mapBridgeHttpError(500, { + error: { type: "kyc_tier_limit_exceeded", message: "KYC tier limit" }, + }) + expect(result).not.toBeInstanceOf(BridgeKycTierCeilingExceededError) + }) +})