import { AbstractPaymentProvider, MedusaError } from "@medusajs/framework/utils" import { randomUUID } from "crypto" import type { CapturePaymentInput, CapturePaymentOutput, CreateAccountHolderInput, CreateAccountHolderOutput, DeletePaymentInput, DeletePaymentOutput, ProviderWebhookPayload, WebhookActionResult, } from "@medusajs/framework/types" import { formatAmountForPayPal, getCurrencyExponent, toAmountNumber, } from "../utils/amounts" import { assertPayPalCurrencySupported, normalizeCurrencyCode, } from "../utils/currencies" import { PayPalCredentialResolver } from "../utils/credential-resolver" import { PAYPAL_PARTNER_ATTRIBUTION_ID } from "../utils/partner" import { paypalFetch, paypalFetchWithRetry } from "../utils/paypal-fetch" import { getPayPalWebhookActionAndData } from "./webhook-utils" import { extractCaptureStatus, isCaptureCompleted, mapPayPalCaptureStatus, sumCountedCaptureAmounts, sumSettledCaptureAmounts, } from "./status-utils" /** Compact record of one PayPal capture kept on the session (`paypal.captures`). */ type StoredCaptureEntry = { id?: string status?: string amount?: unknown final_capture?: boolean raw?: unknown } function toStoredCaptureEntry(capture: any): StoredCaptureEntry { return { id: capture?.id, status: capture?.status, amount: capture?.amount, final_capture: capture?.final_capture, raw: capture, } } /** Merge capture entries by id — a later entry for the same id wins. */ function mergeCaptureEntries( ...lists: (StoredCaptureEntry[] | undefined)[] ): StoredCaptureEntry[] { const byId = new Map() const anonymous: StoredCaptureEntry[] = [] for (const list of lists) { for (const entry of list || []) { if (entry?.id) byId.set(String(entry.id), entry) else if (entry) anonymous.push(entry) } } return [...byId.values(), ...anonymous] } export type Options = {} /** * Shared base for the two PayPal payment providers (wallet buttons and advanced * card fields). Both providers ran nearly identical credential/token handling, * order-detail fetching, idempotency-key generation, amount normalization, and * PayPal→Medusa status mapping — that logic lives here once. Each provider * supplies its own `sessionPrefix` / `idempotencyPrefix` and keeps the pieces * that genuinely differ (create/authorize/capture/refund/cancel business logic, * metric recording, 3-D Secure handling, provider-id passthrough). */ export abstract class PayPalProviderBase extends AbstractPaymentProvider { protected readonly options_: Options protected paypal: PayPalCredentialResolver /** Prefix for generated session ids (e.g. "pp" or "pp_card"). */ protected abstract readonly sessionPrefix: string /** Prefix for generated idempotency keys (e.g. "pp" or "pp-card"). */ protected abstract readonly idempotencyPrefix: string constructor(cradle: Record, options: Options) { super(cradle, options) this.options_ = options const pg = PayPalProviderBase.resolvePgConnection(cradle) this.paypal = new PayPalCredentialResolver(pg) } protected static resolvePgConnection(cradle: Record): any { for (const key of ["__pg_connection__", "pgConnection", "pg_connection"]) { try { const val = cradle[key] if (val) return val } catch {} } throw new Error( "Could not resolve pgConnection from the payment module container. " + "Ensure the paypal module is registered in medusa-config and the database is accessible." ) } /** Serialize an error for audit metadata without losing cause chains. */ protected serializeError(error: unknown) { if (error instanceof Error) { const errorWithCause = error as Error & { cause?: unknown } const cause = errorWithCause.cause return { name: error.name, message: error.message, stack: error.stack, cause: cause instanceof Error ? { name: cause.name, message: cause.message, stack: cause.stack } : cause, } } return { message: String(error) } } protected async recordFailure( eventType: string, metadata?: Record ) { await this.paypal.recordAuditEvent(eventType, metadata) await this.paypal.recordMetric(eventType, metadata) } protected async recordSuccess(metricName: string) { await this.paypal.recordMetric(metricName) } protected async recordPaymentEvent( eventType: string, metadata?: Record ) { await this.paypal.recordAuditEvent(eventType, metadata) } protected generateSessionId(): string { try { return randomUUID() } catch { return `${this.sessionPrefix}_${Date.now()}_${Math.random().toString(16).slice(2)}` } } async resolveSettings() { return this.paypal.getSettings() } protected async resolveCurrencyOverride(): Promise { const { apiDetails } = await this.resolveSettings() const code = apiDetails.currency_code if (typeof code === "string" && code.trim()) { return normalizeCurrencyCode(code) } return normalizeCurrencyCode(process.env.PAYPAL_CURRENCY || "EUR") } protected async getPayPalAccessToken() { return this.paypal.getAccessToken() } protected async getOrderDetails(orderId: string) { const { accessToken, base } = await this.getPayPalAccessToken() const resp = await paypalFetch( `${base}/v2/checkout/orders/${encodeURIComponent(orderId)}`, { method: "GET", headers: { Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json", "PayPal-Partner-Attribution-Id": PAYPAL_PARTNER_ATTRIBUTION_ID, }, } ) const text = await resp.text() if (!resp.ok) { throw new Error(`PayPal get order error (${resp.status}): ${text}`) } return JSON.parse(text) } protected getIdempotencyKey( input: { context?: { idempotency_key?: string } }, suffix: string ): string { const key = input?.context?.idempotency_key?.trim() if (key) { return `${key}-${suffix}` } return `${this.idempotencyPrefix}-${suffix}-${this.generateSessionId()}` } protected async normalizePaymentData(input: { data?: Record }) { const data = (input.data || {}) as Record const amount = toAmountNumber(data.amount) const currencyOverride = await this.resolveCurrencyOverride() const currencyCode = normalizeCurrencyCode( data.currency_code || currencyOverride || "EUR" ) assertPayPalCurrencySupported({ currencyCode, paypalCurrencyOverride: currencyOverride, }) return { data, amount, currencyCode } } protected formatAmount(amount: number, currencyCode?: string): string { return formatAmountForPayPal(amount, currencyCode || "EUR") } /** * Amount to send to PayPal for a capture. Medusa invokes the provider's * capturePayment with only `{ data, context: { idempotency_key } }` — the * admin's requested (possibly partial) capture amount is never passed in the * input. The idempotency_key IS the Medusa `capture` row id, so resolve the * requested amount from that row; otherwise a partial capture would silently * charge the buyer the full session total. Falls back to the session amount * when the row can't be resolved (preserving full-capture behavior), and * never returns more than the session amount. */ protected async resolveRequestedCaptureAmount( input: { context?: { idempotency_key?: string } }, sessionAmount: number ): Promise { const captureRowId = input?.context?.idempotency_key?.trim() if (!captureRowId) return sessionAmount const requested = await this.paypal.getRequestedCaptureAmount(captureRowId) if (requested === null || !Number.isFinite(requested) || requested <= 0) { return sessionAmount } return Math.min(requested, sessionAmount) } /** * When the session amount or currency changes while a not-yet-approved PayPal * order is stored on the session, drop the stored order reference so the next * create-order call mints a fresh order at the correct total. Without this, a * buyer who opens PayPal (order created for the old total), backs out, and * changes the cart gets charged the stale amount. Returns a `{ paypal }` * patch to spread into the updated session data, or `{}` when nothing needs * invalidating. Sessions that already carry a capture/authorization are never * touched. */ protected invalidateStaleOrder( input: { data?: Record; amount?: unknown }, nextCurrencyCode: string ): Record { const data = (input.data || {}) as Record const paypal = (data.paypal || {}) as Record if (!paypal.order_id) return {} if (paypal.capture_id || paypal.authorization_id) return {} const prevAmount = toAmountNumber(data.amount) const nextAmount = toAmountNumber(input.amount) const prevCurrency = String(data.currency_code || "").toUpperCase() const amountChanged = prevAmount > 0 && nextAmount > 0 && prevAmount !== nextAmount const currencyChanged = !!prevCurrency && prevCurrency !== nextCurrencyCode if (!amountChanged && !currencyChanged) return {} const { order_id: _orderId, order: _order, ...rest } = paypal return { paypal: rest } } protected mapCaptureStatus(status?: string) { // Delegate to the shared, unit-tested mapping so the card and wallet // providers agree with each other and with the webhook processor. Notably a // PARTIALLY_REFUNDED capture must stay "captured" (only part of the funds // were returned) — mapping it to "canceled" would wrongly unwind a live // capture. return mapPayPalCaptureStatus(status) } protected mapAuthorizationStatus(status?: string) { const normalized = String(status || "").toUpperCase() if (!normalized) return null if (["CREATED", "APPROVED", "PENDING"].includes(normalized)) return "authorized" if (["VOIDED", "EXPIRED"].includes(normalized)) return "canceled" if (["DENIED", "DECLINED", "FAILED"].includes(normalized)) return "error" return null } protected mapOrderStatus(status?: string) { const normalized = String(status || "").toUpperCase() if (!normalized) return "pending" if (normalized === "COMPLETED") return "captured" if (normalized === "APPROVED") return "authorized" if (["VOIDED", "CANCELLED"].includes(normalized)) return "canceled" if (["CREATED", "SAVED", "PAYER_ACTION_REQUIRED"].includes(normalized)) return "pending" if (["FAILED", "EXPIRED"].includes(normalized)) return "error" return "pending" } /** * Capture funds for a session — one PayPal capture per Medusa capture row. * * Medusa calls this once per admin capture (full or partial) with the * requested amount living on the Medusa capture row (see * `resolveRequestedCaptureAmount`). The decision to call PayPal is driven by * what PayPal already holds for the order, NOT by whether the session happens * to carry a `capture_id`: after a first partial capture the session always * has one, and short-circuiting on it silently skipped every later capture * (Medusa recorded the money, PayPal never took it). * * - Order already fully captured at PayPal → idempotent success with the * latest COMPLETED capture (a retry after a crash between the PayPal call * and the session write, or a storefront capture-order that Medusa never * saw). If everything it holds is still PENDING, fail — funds in flight * are not settled money. * - Otherwise capture exactly the requested amount; refuse (rather than * silently clamp) if more than what remains capturable is requested, since * Medusa records the requested amount regardless of what is returned. * - The last capture that exhausts the session amount closes the * authorization (`final_capture`), unless the session says otherwise. */ async capturePayment( input: CapturePaymentInput ): Promise { const data = (input.data || {}) as Record const paypalData = (data.paypal || {}) as Record const orderId = String(paypalData.order_id || data.order_id || "") let authorizationId = String( paypalData.authorization_id || data.authorization_id || "" ) if (!orderId) { throw new MedusaError( MedusaError.Types.INVALID_DATA, "PayPal order_id is required to capture payment" ) } const { amount: sessionAmount, currencyCode } = await this.normalizePaymentData(input) const exponent = getCurrencyExponent(currencyCode) const roundMoney = (value: number) => Number(value.toFixed(exponent)) // The requested (possibly partial) capture amount lives on the Medusa // capture row, not in the provider input — resolve it so partial captures // don't charge the full session total. const amount = await this.resolveRequestedCaptureAmount( input, sessionAmount ) // Include the amount in the idempotency suffix: PayPal deduplicates by // PayPal-Request-Id, so two sequential partial captures of the same order // that share an upstream idempotency_key would otherwise collide and the // second capture would silently return the first one's result. const requestId = this.getIdempotencyKey( input, `capture-${orderId}-${amount}` ) let debugId: string | null = null const storedCaptures: StoredCaptureEntry[] = Array.isArray( paypalData.captures ) ? paypalData.captures : [] const successResult = ( capture: any, liveCaptures: any[], newEntry?: StoredCaptureEntry ): CapturePaymentOutput => { const captureId = capture?.id || capture?.purchase_units?.[0]?.payments?.captures?.[0]?.id return { data: { ...(input.data || {}), paypal: { ...paypalData, order_id: orderId, capture_id: captureId, capture, authorization_id: authorizationId || paypalData.authorization_id, captures: mergeCaptureEntries( storedCaptures, liveCaptures.map(toStoredCaptureEntry), newEntry ? [newEntry] : undefined ), }, captured_at: new Date().toISOString(), }, } } try { const { accessToken, base } = await this.getPayPalAccessToken() const order = await this.getOrderDetails(orderId).catch(() => null) const liveCaptures: any[] = order?.purchase_units?.[0]?.payments?.captures ?? [] if (order) { // What PayPal has already taken (or reserved) for this order, and how // much of it is settled. const alreadyCaptured = roundMoney( sumCountedCaptureAmounts(liveCaptures) ) const settled = roundMoney(sumSettledCaptureAmounts(liveCaptures)) const remaining = roundMoney(sessionAmount - alreadyCaptured) const requested = roundMoney(amount) // Reconcile against Medusa's own ledger. Medusa creates the capture row // BEFORE calling us and deletes it when the call (or its follow-up // write) fails, so a retry arrives with a fresh row and a fresh // PayPal-Request-Id. Whatever PayPal holds beyond the captures Medusa // has booked on this payment is therefore a capture Medusa lost — // that money IS this capture, and must not be taken from the buyer a // second time. (Also covers a storefront capture-order the session // never recorded.) Falls through to the amount-based checks below when // the ledger can't be read. const captureRowId = input?.context?.idempotency_key?.trim() || "" const booked = captureRowId ? await this.paypal.getBookedCaptureTotal(captureRowId) : null const unbookedHeld = booked === null ? 0 : roundMoney(alreadyCaptured - booked) const unbookedSettled = booked === null ? 0 : roundMoney(settled - booked) const pickCompletedCapture = () => [...liveCaptures] .reverse() .find( (c) => isCaptureCompleted(c) && roundMoney(Number(c?.amount?.value)) === requested ) ?? [...liveCaptures].reverse().find((c) => isCaptureCompleted(c)) if (booked !== null && unbookedHeld >= requested && requested > 0) { const completed = pickCompletedCapture() if (!completed || unbookedSettled < requested) { // The unbooked money is still PENDING (eCheck / review): not // settled, so it can't be booked yet — the CAPTURE.COMPLETED // webhook settles it later. throw new Error( `PayPal already holds ${formatAmountForPayPal(unbookedHeld, currencyCode)} ${currencyCode} for order ${orderId} that Medusa has not booked, ` + `but only ${formatAmountForPayPal(unbookedSettled, currencyCode)} of it has COMPLETED (statuses: ${ liveCaptures.map((c) => c?.status).join(", ") || "none" }). The payment was not recorded as captured.` ) } console.info( `[PayPal] capturePayment: order ${orderId} already holds ${unbookedHeld} ${currencyCode} not booked in Medusa (PayPal ${alreadyCaptured}, Medusa ${booked}); returning capture ${completed.id} instead of capturing again` ) return successResult(completed, liveCaptures) } if (remaining <= 0) { // Nothing left to capture at PayPal. Only settled money may be // booked: a PENDING capture that fills the remainder is not // captured yet. const completed = pickCompletedCapture() if (!completed || settled < roundMoney(sessionAmount)) { throw new Error( `PayPal already holds captures covering the full amount for order ${orderId}, ` + `but only ${formatAmountForPayPal(settled, currencyCode)} ${currencyCode} has COMPLETED (statuses: ${ liveCaptures.map((c) => c?.status).join(", ") || "none" }). The payment was not recorded as captured.` ) } console.info( `[PayPal] capturePayment: order ${orderId} is already fully captured at PayPal (${alreadyCaptured} ${currencyCode}); returning capture ${completed.id}` ) return successResult(completed, liveCaptures) } if (requested > remaining) { throw new Error( `PayPal can only capture ${formatAmountForPayPal(remaining, currencyCode)} ${currencyCode} more for order ${orderId} ` + `(${formatAmountForPayPal(alreadyCaptured, currencyCode)} of ${formatAmountForPayPal(sessionAmount, currencyCode)} already captured); ` + `${formatAmountForPayPal(amount, currencyCode)} was requested.` ) } } else { // PayPal unreachable for the order lookup: the only safe short-circuit // is a stored capture that demonstrably COMPLETED for the full session // amount. Anything else proceeds to the capture call below, where the // PayPal-Request-Id keeps a retry idempotent and PayPal itself rejects // an over-capture. const stored = paypalData.capture const storedAmount = Number( stored?.amount?.value ?? stored?.purchase_units?.[0]?.payments?.captures?.[0]?.amount?.value ) if ( stored && isCaptureCompleted(stored) && Number.isFinite(storedAmount) && roundMoney(storedAmount) >= roundMoney(sessionAmount) ) { return successResult(stored, []) } } const resolvedIntent = String( order?.intent || paypalData.order?.intent || data.intent || "" ).toUpperCase() if (!authorizationId && resolvedIntent === "AUTHORIZE") { const liveAuthorizationId = order?.purchase_units?.[0]?.payments?.authorizations?.[0]?.id if (liveAuthorizationId) { authorizationId = String(liveAuthorizationId) } else { 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() debugId = authorizeResp.headers.get("paypal-debug-id") if (!authorizeResp.ok) { throw new Error( `PayPal authorize order error (${authorizeResp.status}): ${authorizeText}${ debugId ? ` debug_id=${debugId}` : "" }` ) } const authorization = JSON.parse(authorizeText) authorizationId = authorization?.purchase_units?.[0]?.payments?.authorizations?.[0] ?.id } } const explicitFinalCapture = paypalData.is_final_capture ?? data.is_final_capture ?? data.final_capture ?? undefined const captureValue = amount > 0 ? formatAmountForPayPal(amount, currencyCode) : null // `amount` and `final_capture` are only honored on the authorizations // capture endpoint. The orders capture endpoint always captures the FULL // order and silently ignores an `amount` body — so a partial amount there // would over-capture while we record the smaller requested value. Route // partial captures through the authorization, and fail closed if a // partial capture is attempted against a capture-intent order. let capturePayload: Record let captureUrl: string if (authorizationId) { captureUrl = `${base}/v2/payments/authorizations/${encodeURIComponent(authorizationId)}/capture` // Close the authorization once the session amount is exhausted, unless // the session explicitly says otherwise. const finalCapture = typeof explicitFinalCapture === "boolean" ? explicitFinalCapture : order ? roundMoney(sumCountedCaptureAmounts(liveCaptures) + amount) >= roundMoney(sessionAmount) : undefined capturePayload = { ...(captureValue ? { amount: { currency_code: currencyCode, value: captureValue } } : {}), ...(typeof finalCapture === "boolean" ? { final_capture: finalCapture } : {}), } } else { captureUrl = `${base}/v2/checkout/orders/${encodeURIComponent(orderId)}/capture` capturePayload = {} const orderTotal = order?.purchase_units?.[0]?.amount?.value if (captureValue && orderTotal && captureValue !== String(orderTotal)) { throw new Error( `PayPal partial capture (${captureValue} ${currencyCode}) is not supported for ` + `capture-intent orders (order total ${orderTotal}). Create the order with intent ` + `AUTHORIZE to capture a partial amount.` ) } } // Retry transient 5xx/429/timeout: the PayPal-Request-Id makes the // capture idempotent, so a retry after a network blip re-uses the same // capture instead of double-charging. `Prefer: return=representation` // asks for the full capture resource (amount, final_capture, …) instead // of the default id/status/links stub, so the session keeps a usable // record of every capture. const ppResp = await paypalFetchWithRetry(captureUrl, { method: "POST", headers: { Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json", Prefer: "return=representation", "PayPal-Request-Id": requestId, "PayPal-Partner-Attribution-Id": PAYPAL_PARTNER_ATTRIBUTION_ID, }, body: JSON.stringify(capturePayload), }) const ppText = await ppResp.text() debugId = ppResp.headers.get("paypal-debug-id") if (!ppResp.ok) { throw new Error( `PayPal capture error (${ppResp.status}): ${ppText}${ debugId ? ` debug_id=${debugId}` : "" }` ) } const capture = JSON.parse(ppText) // A 2xx response does NOT mean the funds were captured. PayPal returns 201 // for captures that are PENDING (pending review / eCheck), DECLINED, or // FAILED. Recording any of these as "captured" books money that never // settled, so only a COMPLETED capture is treated as success. const captureStatus = extractCaptureStatus(capture) if (captureStatus !== "COMPLETED") { throw new Error( `PayPal capture did not complete (status=${captureStatus || "UNKNOWN"}). ` + `The payment was not captured.${debugId ? ` debug_id=${debugId}` : ""}` ) } const captureResource = authorizationId ? capture : (capture?.purchase_units?.[0]?.payments?.captures?.[0] ?? capture) const captureId = captureResource?.id || capture?.id await this.recordSuccess("capture_success") await this.recordPaymentEvent("capture", { order_id: orderId, capture_id: captureId, authorization_id: authorizationId || undefined, amount, currency_code: currencyCode, request_id: requestId, }) return successResult(capture, liveCaptures, { ...toStoredCaptureEntry(captureResource), id: captureId, raw: capture, }) } catch (error: any) { await this.recordFailure("capture_failed", { order_id: orderId, request_id: requestId, debug_id: debugId, message: error?.message, }) // Surface the real reason: Medusa masks non-MedusaErrors as a generic // "unknown error" in production, hiding the PayPal failure. throw error instanceof MedusaError ? error : new MedusaError( MedusaError.Types.INVALID_DATA, error?.message || "PayPal capture failed." ) } } async createAccountHolder( input: CreateAccountHolderInput ): Promise { const customerId = input.context?.customer?.id const externalId = customerId ? `paypal_${customerId}` : `paypal_${this.generateSessionId()}` return { id: externalId, data: { email: input.context?.customer?.email || null, customer_id: customerId || null, }, } } async deletePayment( _input: DeletePaymentInput ): Promise { return { data: {} } } async getWebhookActionAndData( payload: ProviderWebhookPayload["payload"] ): Promise { return getPayPalWebhookActionAndData(payload) } }