import { MedusaError } from "@medusajs/framework/utils" import type { AuthorizePaymentInput, AuthorizePaymentOutput, CancelPaymentInput, CancelPaymentOutput, GetPaymentStatusInput, GetPaymentStatusOutput, InitiatePaymentInput, InitiatePaymentOutput, RefundPaymentInput, RefundPaymentOutput, RetrievePaymentInput, RetrievePaymentOutput, UpdatePaymentInput, UpdatePaymentOutput, } from "@medusajs/framework/types" import { formatAmountForPayPal, toAmountNumber } from "../utils/amounts" import { assertPayPalCurrencySupported, normalizeCurrencyCode, } from "../utils/currencies" import { PAYPAL_PARTNER_ATTRIBUTION_ID } from "../utils/partner" import { paypalFetch, paypalFetchWithRetry } from "../utils/paypal-fetch" import { extractCaptureStatus, isRefundFailureStatus } from "./status-utils" import { PayPalProviderBase } from "./base-provider" /** * PayPal Advanced (hosted) Card Fields payment provider — * `pp_paypal_card_paypal_card`. * * Shares the order-based flow of the wallet provider but adds admin-configured * card-brand restrictions and 3-D Secure handling. Card orders are created * with a `payment_source.card` verification method (see create-order). */ class PayPalAdvancedCardProvider extends PayPalProviderBase { static identifier = "paypal_card" protected readonly sessionPrefix = "pp_card" protected readonly idempotencyPrefix = "pp-card" async initiatePayment( input: InitiatePaymentInput ): Promise { const currencyOverride = await this.resolveCurrencyOverride() const currencyCode = normalizeCurrencyCode( input.currency_code || currencyOverride || "EUR" ) assertPayPalCurrencySupported({ currencyCode, paypalCurrencyOverride: currencyOverride, }) return { id: this.generateSessionId(), data: { ...(input.data || {}), amount: input.amount, currency_code: currencyCode, }, } } async updatePayment(input: UpdatePaymentInput): Promise { const currencyOverride = await this.resolveCurrencyOverride() const currencyCode = normalizeCurrencyCode( input.currency_code || currencyOverride || "EUR" ) assertPayPalCurrencySupported({ currencyCode, paypalCurrencyOverride: currencyOverride, }) // Preserve the provider_id passthrough exactly like the wallet provider — // dropping it here would make the two providers' session data diverge. const providerId = (input.data as Record | undefined) ?.provider_id return { data: { ...(input.data || {}), ...this.invalidateStaleOrder(input, currencyCode), ...(providerId ? { provider_id: providerId } : {}), amount: input.amount, currency_code: currencyCode, }, } } async authorizePayment( input: AuthorizePaymentInput ): Promise { const { data } = await this.normalizePaymentData(input) const requestId = this.getIdempotencyKey(input, "authorize") const { advancedCardSettings } = await this.resolveSettings() const disabledCards = Array.isArray(advancedCardSettings.disabledCards) ? advancedCardSettings.disabledCards.map((card: string) => String(card).toLowerCase() ) : [] const cardBrand = String( data.card_brand || data.cardBrand || data?.paypal?.card_brand || "" ).toLowerCase() if (cardBrand && disabledCards.includes(cardBrand)) { throw new Error(`Card brand ${cardBrand} is disabled by admin settings.`) } const existingPayPal = (data.paypal || {}) as Record // Session already carries a settled capture: trust it without a network // round-trip, but only when the stored capture is actually COMPLETED — a // PENDING/DECLINED capture must never be booked as captured money. const storedCaptureStatus = extractCaptureStatus(existingPayPal.capture) if (storedCaptureStatus === "COMPLETED") { return { status: "captured", data: { ...(input.data || {}), captured_at: (data as any).captured_at || new Date().toISOString(), }, } } const orderId = String(existingPayPal.order_id || data.order_id || "") if (!orderId) { // Nothing to authorize: a PayPal order only exists after the buyer // submitted their card via the storefront (create-order + card fields). // The previous fallback created a fresh order here and immediately // called /authorize on it, which PayPal always rejects (no payment // source, and 422 UNSUPPORTED_INTENT for CAPTURE-intent orders). await this.recordFailure("authorize_failed", { cart_id: data.cart_id, payment_collection_id: data.payment_collection_id, message: "no PayPal order on card session", }) throw new MedusaError( MedusaError.Types.NOT_ALLOWED, "No PayPal order was found for this payment session. The buyer must submit and approve their card payment before the cart can be completed." ) } let order: Record | null = null try { order = (await this.getOrderDetails(orderId)) as Record< string, any > | null } catch (e: any) { console.warn( "[PayPal] card authorizePayment: order lookup failed:", e?.message ) // Fall back to the session's own record on a transient lookup failure so // checkout isn't blocked when a real authorization/capture exists. if ((data as any).captured_at) { return { status: "captured", data: { ...(input.data || {}), captured_at: (data as any).captured_at, }, } } if ( existingPayPal.capture_id || existingPayPal.authorization_id || (data as any).authorized_at ) { return { status: "authorized", data: { ...(input.data || {}), authorized_at: (data as any).authorized_at || new Date().toISOString(), }, } } throw e } if (!order) { throw new Error( "Unable to resolve PayPal order details for authorization." ) } const capture = order?.purchase_units?.[0]?.payments?.captures?.[0] const authorization = order?.purchase_units?.[0]?.payments?.authorizations?.[0] // Derive the status from what actually happened at PayPal, never from the // mere presence of a capture/authorization or from the configured // paymentAction: a PENDING (eCheck) capture is not settled money and a // DENIED/DECLINED one must fail the authorization. if (capture?.id) { const captureStatus = this.mapCaptureStatus(capture?.status) if (captureStatus === "captured") { return { status: "captured", data: { ...(data || {}), paypal: { ...existingPayPal, order_id: orderId, order, capture_id: capture.id, capture, }, captured_at: new Date().toISOString(), }, } } if (captureStatus === "pending") { return { status: "authorized", data: { ...(data || {}), paypal: { ...existingPayPal, order_id: orderId, order, capture_id: capture.id, }, authorized_at: new Date().toISOString(), }, } } if (captureStatus === "error" || captureStatus === "canceled") { return { status: "error", data: { ...(data || {}), paypal: { ...existingPayPal, order_id: orderId, order }, }, } } } if (authorization?.id) { const authStatus = this.mapAuthorizationStatus(authorization?.status) if (authStatus === "authorized") { return { status: "authorized", data: { ...(data || {}), paypal: { ...existingPayPal, order_id: orderId, order, authorization_id: authorization.id, authorizations: order?.purchase_units?.[0]?.payments?.authorizations || [], }, authorized_at: new Date().toISOString(), }, } } if (authStatus === "error" || authStatus === "canceled") { return { status: "error", data: { ...(data || {}), paypal: { ...existingPayPal, order_id: orderId, order }, }, } } } const orderStatus = String(order?.status || "").toUpperCase() const orderIntent = String(order?.intent || "").toUpperCase() if (orderStatus === "APPROVED") { if (orderIntent === "AUTHORIZE") { // Approved AUTHORIZE-intent order with no authorization yet: create it. const { accessToken, base } = await this.getPayPalAccessToken() const authorizeResp = await paypalFetch( `${base}/v2/checkout/orders/${encodeURIComponent(orderId)}/authorize`, { method: "POST", headers: { Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json", "PayPal-Request-Id": `${requestId}-auth`, "PayPal-Partner-Attribution-Id": PAYPAL_PARTNER_ATTRIBUTION_ID, }, } ) const authorizeText = await authorizeResp.text() const authorizeDebugId = authorizeResp.headers.get("paypal-debug-id") if (!authorizeResp.ok) { throw new Error( `PayPal authorize order error (${authorizeResp.status}): ${authorizeText}${ authorizeDebugId ? ` debug_id=${authorizeDebugId}` : "" }` ) } const authorized = JSON.parse(authorizeText) as Record const authorizationId = authorized?.purchase_units?.[0]?.payments?.authorizations?.[0]?.id return { status: "authorized", data: { ...(data || {}), paypal: { ...existingPayPal, order_id: orderId, order: authorized || order, authorization_id: authorizationId, authorizations: authorized?.purchase_units?.[0]?.payments?.authorizations || [], }, authorized_at: new Date().toISOString(), }, } } // Approved CAPTURE-intent order: the buyer approved but the capture has // not happened yet (e.g. the storefront capture call failed). Report // "authorized" so the order can complete; capturePayment then routes the // actual capture by intent. Calling /authorize here — as the previous // code did for every intent — always fails with 422 UNSUPPORTED_INTENT // and permanently stuck the checkout. return { status: "authorized", data: { ...(data || {}), paypal: { ...existingPayPal, order_id: orderId, order, }, authorized_at: new Date().toISOString(), }, } } // CREATED / SAVED / PAYER_ACTION_REQUIRED etc.: the buyer never approved // the payment — nothing is authorized at PayPal. await this.recordFailure("authorize_failed", { order_id: orderId, order_status: orderStatus, message: "PayPal order not approved by buyer", }) throw new MedusaError( MedusaError.Types.NOT_ALLOWED, `PayPal order ${orderId} has not been approved by the buyer (status ${orderStatus || "UNKNOWN"}). The payment cannot be authorized.` ) } async cancelPayment(input: CancelPaymentInput): Promise { const data = (input.data || {}) as Record const paypalData = (data.paypal || {}) as Record const orderId = String(paypalData.order_id || data.order_id || "") const captureId = String(paypalData.capture_id || data.capture_id || "") const storedAuthorizationId = String( paypalData.authorization_id || data.authorization_id || "" ) try { const order = orderId ? await this.getOrderDetails(orderId) : null const intent = String(order?.intent || "").toUpperCase() const authorizationId = order?.purchase_units?.[0]?.payments?.authorizations?.[0]?.id || storedAuthorizationId if (intent === "AUTHORIZE" && authorizationId) { const { accessToken, base } = await this.getPayPalAccessToken() const requestId = this.getIdempotencyKey( input, `void-${authorizationId}` ) const resp = await paypalFetch( `${base}/v2/payments/authorizations/${encodeURIComponent(authorizationId)}/void`, { method: "POST", headers: { Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json", "PayPal-Request-Id": requestId, "PayPal-Partner-Attribution-Id": PAYPAL_PARTNER_ATTRIBUTION_ID, }, } ) if (!resp.ok) { const text = await resp.text() const debugId = resp.headers.get("paypal-debug-id") throw new Error( `PayPal void error (${resp.status}): ${text}${ debugId ? ` debug_id=${debugId}` : "" }` ) } await this.recordSuccess("void_success") await this.recordPaymentEvent("void", { order_id: orderId, authorization_id: authorizationId, }) } else if (captureId) { const { accessToken, base } = await this.getPayPalAccessToken() const requestId = this.getIdempotencyKey( input, `cancel-refund-${captureId}` ) const resp = await paypalFetch( `${base}/v2/payments/captures/${encodeURIComponent(captureId)}/refund`, { method: "POST", headers: { Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json", "PayPal-Request-Id": requestId, "PayPal-Partner-Attribution-Id": PAYPAL_PARTNER_ATTRIBUTION_ID, }, body: JSON.stringify({}), } ) if (!resp.ok) { const text = await resp.text() const debugId = resp.headers.get("paypal-debug-id") throw new Error( `PayPal refund error (${resp.status}): ${text}${ debugId ? ` debug_id=${debugId}` : "" }` ) } const refund = await resp.json().catch(() => ({})) // As with refundPayment: a 2xx response does not guarantee the refund // stuck — FAILED / CANCELLED / DENIED refunds also return 2xx. Booking // `canceled_at` for a refund that never went through would record a // cancellation while the merchant keeps the funds. const cancelRefundStatus = String(refund?.status || "").toUpperCase() if (isRefundFailureStatus(cancelRefundStatus)) { throw new Error( `PayPal cancel-refund did not succeed (status=${cancelRefundStatus}). The payment was not canceled.` ) } const existingRefunds = Array.isArray(paypalData.refunds) ? paypalData.refunds : [] const refundEntry = { id: refund?.id, status: refund?.status, amount: refund?.amount, raw: refund, } await this.recordSuccess("cancel_refund_success") return { data: { ...(data || {}), paypal: { ...paypalData, order: order || undefined, authorization_id: authorizationId || storedAuthorizationId, capture_id: captureId || paypalData.capture_id, refund_id: refund?.id, refund_status: refund?.status, refunds: [...existingRefunds, refundEntry], }, canceled_at: new Date().toISOString(), }, } } return { data: { ...(data || {}), paypal: { ...paypalData, order: order || undefined, authorization_id: authorizationId || storedAuthorizationId, capture_id: captureId || paypalData.capture_id, }, canceled_at: new Date().toISOString(), }, } } catch (error: any) { await this.recordFailure("cancel_failed", { order_id: orderId, capture_id: captureId, message: error?.message, }) throw error } } async refundPayment(input: RefundPaymentInput): Promise { const data = (input.data || {}) as Record const paypalData = (data.paypal || {}) as Record const captureId = String(paypalData.capture_id || data.capture_id || "") if (!captureId) { throw new MedusaError( MedusaError.Types.INVALID_DATA, "PayPal capture_id is required to refund payment. No capture found in session data." ) } // Use the refund amount Medusa passes (top-level input), not the session // amount in `data` — otherwise a partial refund would refund the full order. // Medusa passes it as a BigNumberInput, so coerce it to a number; a naive // Number() of the object form yields NaN and silently refunds the full // capture. const amount = toAmountNumber(input.amount) const requestId = this.getIdempotencyKey( input, `refund-${captureId}-${amount}` ) const currencyOverride = await this.resolveCurrencyOverride() const currencyCode = normalizeCurrencyCode( data.currency_code || currencyOverride || "EUR" ) try { const { accessToken, base } = await this.getPayPalAccessToken() const refundPayload: Record = amount > 0 ? { amount: { currency_code: currencyCode, value: formatAmountForPayPal(amount, currencyCode), }, } : {} // Retry transient 5xx/429/timeout: the PayPal-Request-Id (which includes // the refund amount) makes the refund idempotent, so a retry after a // network blip re-uses the same refund instead of double-refunding. const resp = await paypalFetchWithRetry( `${base}/v2/payments/captures/${encodeURIComponent(captureId)}/refund`, { method: "POST", headers: { Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json", // Full refund resource (amount, status, …) instead of the // id/status/links stub, so the stored refund entry is usable. Prefer: "return=representation", "PayPal-Request-Id": requestId, "PayPal-Partner-Attribution-Id": PAYPAL_PARTNER_ATTRIBUTION_ID, }, body: JSON.stringify(refundPayload), } ) const text = await resp.text() if (!resp.ok) { const debugId = resp.headers.get("paypal-debug-id") throw new MedusaError( MedusaError.Types.UNEXPECTED_STATE, `PayPal refund error (${resp.status}): ${text}${ debugId ? ` debug_id=${debugId}` : "" }` ) } const refund = JSON.parse(text) // As with captures, a 2xx response does not guarantee the refund stuck. // FAILED / CANCELLED / DENIED refunds also return 2xx and must not be // recorded as a successful refund. PENDING is accepted: PayPal processes // refunds asynchronously and a pending refund will settle. const refundStatus = String(refund?.status || "").toUpperCase() if (isRefundFailureStatus(refundStatus)) { const refundDebugId = resp.headers.get("paypal-debug-id") throw new MedusaError( MedusaError.Types.UNEXPECTED_STATE, `PayPal refund did not succeed (status=${refundStatus}). The refund was not issued.` + (refundDebugId ? ` debug_id=${refundDebugId}` : "") ) } const existingRefunds = Array.isArray(paypalData.refunds) ? paypalData.refunds : [] const refundEntry = { id: refund?.id, status: refund?.status, amount: refund?.amount, raw: refund, } await this.recordSuccess("refund_success") await this.recordPaymentEvent("refund", { capture_id: captureId, refund_id: refund?.id, amount, currency_code: currencyCode, request_id: requestId, }) return { data: { ...(data || {}), paypal: { ...paypalData, refund_id: refund?.id, refund_status: refund?.status, refunds: [...existingRefunds, refundEntry], refund, }, refunded_at: new Date().toISOString(), }, } } catch (error: any) { await this.recordFailure("refund_failed", { capture_id: captureId, request_id: requestId, message: error?.message, }) // Surface the real reason: Medusa masks any non-MedusaError as a generic // "An unknown error occurred." in production, hiding the PayPal failure. throw error instanceof MedusaError ? error : new MedusaError( MedusaError.Types.UNEXPECTED_STATE, error?.message || "PayPal refund failed." ) } } async retrievePayment( input: RetrievePaymentInput ): Promise { const data = (input.data || {}) as Record const paypalData = (data.paypal || {}) as Record const orderId = String(paypalData.order_id || data.order_id || "") if (!orderId) { return { data: { ...(data || {}) } } } const order = await this.getOrderDetails(orderId) const capture = order?.purchase_units?.[0]?.payments?.captures?.[0] const authorization = order?.purchase_units?.[0]?.payments?.authorizations?.[0] return { data: { ...(data || {}), paypal: { ...paypalData, order, authorization_id: authorization?.id || paypalData.authorization_id, capture_id: capture?.id || paypalData.capture_id, }, }, } } async getPaymentStatus( input: GetPaymentStatusInput ): Promise { const data = (input.data || {}) as Record const paypalData = (data.paypal || {}) as Record const orderId = String(paypalData.order_id || data.order_id || "") if (!orderId) { return { status: "pending", data: { ...(data || {}) } } } const order = await this.getOrderDetails(orderId) const capture = order?.purchase_units?.[0]?.payments?.captures?.[0] const authorization = order?.purchase_units?.[0]?.payments?.authorizations?.[0] const mappedStatus = this.mapCaptureStatus(capture?.status) || this.mapAuthorizationStatus(authorization?.status) || this.mapOrderStatus(order?.status) || "pending" return { status: mappedStatus, data: { ...(data || {}), paypal: { ...paypalData, order, authorization_id: authorization?.id || paypalData.authorization_id, capture_id: capture?.id || paypalData.capture_id, }, }, } } } export default PayPalAdvancedCardProvider export { PayPalAdvancedCardProvider }