Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/bridge-integration/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
17 changes: 13 additions & 4 deletions src/graphql/error-map.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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":
Expand Down Expand Up @@ -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:
Expand Down
50 changes: 47 additions & 3 deletions src/services/bridge/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Expand All @@ -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)
}
}
Expand Down Expand Up @@ -95,25 +99,65 @@ 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)
}
}

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<string, unknown>
const errorObj = (resp.error ?? resp) as Record<string, unknown> | 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()
Expand Down
41 changes: 41 additions & 0 deletions test/flash/unit/graphql/bridge-error-map.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,12 @@ import {
BridgeKycOffboardedError,
BridgeKycPendingError,
BridgeKycRejectedError,
BridgeKycTierCeilingExceededError,
BridgeRateLimitError,
BridgeTimeoutError,
BridgeTransferFailedError,
BridgeWebhookValidationError,
mapBridgeHttpError,
} from "@services/bridge/errors"

describe("error-map: Bridge errors", () => {
Expand All @@ -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"],
]
Expand All @@ -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)
})
})