import type { MedusaRequest, MedusaResponse } from "@medusajs/framework/http" import type { IPaymentModuleService, Logger } from "@medusajs/framework/types" import { ContainerRegistrationKeys, MedusaError, Modules, } from "@medusajs/framework/utils" import { randomUUID } from "crypto" import { isPayPalProviderId } from "../../../modules/paypal/utils/provider-ids" import { runCoreWorkflow } from "../../../modules/paypal/utils/core-workflow" /** * POST /store/paypal-complete — finalize a cart after the buyer's PayPal * payment was captured/authorized. * * Authorizes the PayPal payment session (which re-verifies the payment against * PayPal) and then runs the core complete-cart workflow. A cart completed by a * concurrent request (e.g. the webhook safety net) is reported as success. */ export async function POST(req: MedusaRequest, res: MedusaResponse) { const logger = req.scope.resolve(ContainerRegistrationKeys.LOGGER) const requestId = randomUUID() const { cart_id } = (req.body || {}) as { cart_id: string } if (!cart_id || typeof cart_id !== "string") { return res.status(400).json({ message: "cart_id is required" }) } if (!cart_id.startsWith("cart_")) { return res.status(400).json({ message: "Invalid cart_id format" }) } try { const query = req.scope.resolve("query") const { data: carts } = await query.graph({ entity: "cart", fields: [ "id", "completed_at", "payment_collection.payment_sessions.id", "payment_collection.payment_sessions.data", "payment_collection.payment_sessions.status", "payment_collection.payment_sessions.provider_id", "payment_collection.payment_sessions.created_at", "payment_collection.payment_sessions.amount", "payment_collection.payment_sessions.currency_code", ], filters: { id: cart_id }, }) const cart = carts?.[0] if (cart?.completed_at) { return res.json({ success: true, cart_id, already_completed: true }) } const sessions = cart?.payment_collection?.payment_sessions || [] const session = sessions .filter((s: any) => isPayPalProviderId(s.provider_id)) .sort( (a: any, b: any) => new Date(b.created_at || 0).getTime() - new Date(a.created_at || 0).getTime() )[0] if (!session) { return res .status(400) .json({ message: "No PayPal payment session found for cart" }) } const paymentModule = req.scope.resolve( Modules.PAYMENT ) as IPaymentModuleService // Do NOT fabricate captured_at/authorized_at here. The authoritative // timestamp and status are set by the provider's authorizePayment (invoked // via authorizePaymentSession below) only after it verifies the capture / // authorization against PayPal. Stamping it ourselves would mark an unpaid // session as paid. const currentStatus = String(session.status || "") if (currentStatus !== "authorized") { try { await (paymentModule as any).authorizePaymentSession(session.id, {}) logger.info( `[paypal] paypal-complete authorizePaymentSession succeeded (request_id=${requestId}, session_id=${session.id})` ) } catch (e: any) { logger.warn( `[paypal] paypal-complete authorizePaymentSession failed (request_id=${requestId}, session_id=${session.id}): ${ e?.message ?? String(e) }` ) // The providers throw NOT_ALLOWED when PayPal has no approved / // captured payment for the session (buyer never approved, capture // declined, …). Nothing was charged, so say so — the generic // "payment was processed but the order could not be finalized" below // would tell a storefront to show "do not pay again" for a payment // that never happened. Anything else (transient DB/PayPal errors) is // still allowed to fall through to the completion attempt and its // retryable 5xx. Checked via the `__isMedusaError` flag, not // `instanceof`: the provider runs inside the payment module's own // loader and its MedusaError is a different class instance than ours. if ( MedusaError.isMedusaError(e) && (e as MedusaError).type === MedusaError.Types.NOT_ALLOWED ) { return res.status(402).json({ success: false, cart_id, session_id: session.id, payment_authorized: false, message: e.message || "The PayPal payment for this cart has not been approved.", request_id: requestId, }) } } } try { const { result } = await runCoreWorkflow<{ id: string }, { id?: string }>( req.scope, "complete-cart", { id: cart_id } ) logger.info( `[paypal] paypal-complete cart completed (request_id=${requestId}, cart_id=${cart_id}, order_id=${result?.id})` ) return res.json({ success: true, cart_id, order_id: result?.id, request_id: requestId, }) } catch (e: any) { // The cart may have been completed by a concurrent request between our // initial check and now — treat that as success, not an error. const recheck = await query .graph({ entity: "cart", fields: ["id", "completed_at"], filters: { id: cart_id }, }) .catch(() => ({ data: [] as any[] })) if (recheck?.data?.[0]?.completed_at) { return res.json({ success: true, cart_id, already_completed: true, request_id: requestId, }) } logger.error( `[paypal] paypal-complete completeCartWorkflow failed (request_id=${requestId}, cart_id=${cart_id}): ${ e?.message ?? String(e) }`, e instanceof Error ? e : undefined ) // The payment may have been captured at PayPal but the order was not // finalized. Report failure (not a false success) so the storefront can // surface a "payment taken, contact support" state. return res.status(500).json({ success: false, cart_id, session_id: session.id, message: "Payment was processed but the order could not be finalized.", request_id: requestId, }) } } catch (e: any) { logger.error( `[paypal] paypal-complete failed (request_id=${requestId}, cart_id=${cart_id}): ${ e?.message ?? String(e) }`, e instanceof Error ? e : undefined ) return res .status(500) .json({ message: "Internal error", request_id: requestId }) } }