/** * Structured card/wallet decline information extracted from PayPal responses. * * A declined capture used to surface as a generic "Failed to capture PayPal * order" 500. This maps PayPal's processor response codes and issue names to * a buyer-friendly message plus a `retryable` flag, so storefronts can say * "insufficient funds — try another card" and decide whether to offer a retry. */ export type PayPalDeclineInfo = { /** Processor `response_code` (e.g. "5120") or PayPal issue name. */ code: string /** Buyer-friendly message, safe to show in the storefront. */ message: string /** * Whether another attempt could plausibly succeed (usually with a different * card or funding source). `false` marks hard declines — fraud, restricted * accounts — where inviting a retry only frustrates the buyer. */ retryable: boolean avs_code?: string cvv_code?: string } const PROCESSOR_CODES: Record = { "0500": { message: "The card was declined by the issuing bank. Please try a different card or contact your bank.", retryable: true, }, "1330": { message: "The card account is not valid. Please use a different card.", retryable: true, }, "5100": { message: "The card was declined. Please try a different payment method.", retryable: true, }, "5110": { message: "The card's security code (CVV) did not match. Please check the code and try again.", retryable: true, }, "5120": { message: "The card was declined due to insufficient funds. Please use a different payment method.", retryable: true, }, "5400": { message: "The card has expired. Please use a different card.", retryable: true, }, "5180": { message: "This card cannot be used for this purchase. Please use a different card.", retryable: true, }, "9500": { message: "This transaction was declined. Please contact your bank or use a different payment method.", retryable: false, }, "9520": { message: "This card has been reported lost or stolen and cannot be used. Please use a different card.", retryable: false, }, "00N7": { message: "The card's security code (CVV) was incorrect. Please check the code and try again.", retryable: true, }, } const ISSUE_NAMES: Record = { INSTRUMENT_DECLINED: { message: "Your payment method was declined. Please try a different card or payment method.", retryable: true, }, TRANSACTION_REFUSED: { message: "The payment was refused. Please try again or use a different payment method.", retryable: true, }, PAYER_CANNOT_PAY: { message: "This PayPal account cannot complete the payment. Please use a different payment method.", retryable: false, }, PAYER_ACCOUNT_RESTRICTED: { message: "The PayPal account is restricted and cannot complete this payment.", retryable: false, }, PAYER_ACCOUNT_LOCKED_OR_CLOSED: { message: "The PayPal account is locked or closed. Please use a different payment method.", retryable: false, }, INSUFFICIENT_FUNDS: { message: "The payment was declined due to insufficient funds. Please use a different payment method.", retryable: true, }, CARD_EXPIRED: { message: "The card has expired. Please use a different card.", retryable: true, }, COMPLIANCE_VIOLATION: { message: "This transaction cannot be processed.", retryable: false, }, } const GENERIC_DECLINE: { message: string; retryable: boolean } = { message: "The payment was declined. Please try again or use a different payment method.", retryable: true, } /** * Extract decline info from a PayPal response body — either a 4xx error body * (`{ name, details: [{ issue }] }`) or a 2xx capture/authorize payload whose * capture came back DECLINED/FAILED (carrying `processor_response`). * Returns null when the payload holds no recognizable decline. */ export function extractPayPalDecline( payload: unknown ): PayPalDeclineInfo | null { if (!payload || typeof payload !== "object") return null const body = payload as Record // 4xx error body with issue details (e.g. 422 UNPROCESSABLE_ENTITY). const details = Array.isArray(body.details) ? body.details : [] for (const detail of details) { const issue = String(detail?.issue || "").toUpperCase() if (issue && ISSUE_NAMES[issue]) { return { code: issue, ...ISSUE_NAMES[issue] } } if (issue === "INSTRUMENT_DECLINED" || issue.includes("DECLINED")) { return { code: issue, ...GENERIC_DECLINE } } } // 2xx payload with a non-completed capture carrying processor_response. const captures: Record[] = [] const direct = body.purchase_units?.[0]?.payments?.captures if (Array.isArray(direct)) captures.push(...direct) if (body.processor_response) captures.push(body) for (const capture of captures) { const status = String(capture?.status || "").toUpperCase() const processor = capture?.processor_response as | Record | undefined if (!processor) continue if (status && status !== "DECLINED" && status !== "FAILED") continue const responseCode = String(processor.response_code || "").toUpperCase() const mapped = PROCESSOR_CODES[responseCode] ?? GENERIC_DECLINE return { code: responseCode || "DECLINED", ...mapped, avs_code: processor.avs_code ? String(processor.avs_code) : undefined, cvv_code: processor.cvv_code ? String(processor.cvv_code) : undefined, } } return null }