Search for a command to run...
Для интеграции платёжного провайдера PayPal в storefront Medusa DTC Starter от Gorgo начните с добавления необходимых UI-компонентов. Таким образом провайдер будет отображаться на странице оформления заказа наряду с другими доступными методами оплаты.
Когда покупатель нажимает кнопку PayPal на шаге оплаты или в ряду экспресс-оплаты, оформление заказа инициализирует платёжную сессию, а провайдер создаёт для неё заказ на стороне PayPal. Затем покупатель подтверждает оплату в окне PayPal.
После подтверждения оплаты витрина завершает корзину и создаёт новый заказ в Medusa. Если покупатель закрыл вкладку после того, как PayPal списал деньги, заказ создаст вебхук .
Для витрины на Next.js необходимо внести следующие изменения:
Кнопки берутся из официальной React-обёртки JS SDK PayPal. Установите её в витрину:
yarn add @paypal/react-paypal-js@^8.9.2Версия 8 пакета загружает JavaScript SDK PayPal с и даёт компонент , колбэк которого сообщает адрес, выбранный покупателем в окне PayPal.
Чтобы сделать PayPal доступным в качестве способа оплаты на странице оформления заказа витрины магазина, необходимо добавить его конфигурацию в маппинг платёжных провайдеров в файле с константами вашей витрины. Этот маппинг определяет, как каждый провайдер отображается в интерфейсе.
Откройте и добавьте следующий код:

src/lib/constants.tsx1export const paymentInfoMap: Record<string, PaymentInfo> = {2 // ...3 pp_paypal_paypal: {4 title: "PayPal",5 subtitle: "Cards, wallet",6 icon: <PayPal />,7 },8 // ...9}1011// ...1213export const isPaypal = (providerId?: string) => {14 return providerId?.startsWith("pp_paypal")15}
Вы расширяете объект , добавляя в него запись . Эта запись определяет заголовок, подпись и иконку, которые будут отображаться для PayPal на странице оформления заказа, а также служит резервным значением, когда для локали нет перевода. Иконка входит в стартер и находится в .
Вспомогательная функция проверяет, принадлежит ли переданный к PayPal. Она используется диспетчером ниже и функцией сборки данных платёжной сессии из следующего шага.
Чтобы создать заказ PayPal, платёжная сессия должна быть корректно инициализирована с необходимыми параметрами, включая адрес доставки и данные покупателя для формы оплаты картой PayPal.
Откройте тот же файл и добавьте функцию сборки данных сессии:

src/lib/constants.tsx1const paymentSessionDataBuilders: Array<{2 test: (providerId?: string) => boolean | undefined3 isReady?: (cart: HttpTypes.StoreCart) => boolean4 build: (cart: HttpTypes.StoreCart) => Record<string, unknown>5}> = [6 {7 test: (providerId) => isPaypal(providerId),8 // The PayPal order carries the cart's total and the delivery address, so the9 // session starts once the checkout has an email, an address and a shipping10 // method. Earlier, PayPal gets an order for a cart that has none of them.11 isReady: (cart) =>12 Boolean(13 cart.email &&14 cart.shipping_address?.address_1 &&15 (cart.shipping_methods?.length ?? 0) > 016 ),17 // The email, the name and the billing address fill in PayPal's card form.18 build: (cart) => ({19 shipping_address: cart.shipping_address,20 billing_address: cart.billing_address,21 email: cart.email,22 }),23 },24]
При инициализации платежа для PayPal передаёт адрес доставки, billing-адрес и email. Провайдер ставит адрес доставки в заказ PayPal, а email, имя и billing-адрес заполняют форму PayPal, когда покупатель платит картой.
удерживает сессию, пока в корзине не появятся email, адрес доставки и способ доставки, поскольку заказу PayPal нужны итог корзины и адрес доставки. Кнопка оплаты читает тот же , поэтому кнопка PayPal на шаге оплаты остаётся неактивной до этого момента.
Витрина получает от бэкенда два вида данных. Чтобы показать кнопки PayPal, она загружает публичные настройки PayPal для SDK. После экспресс-оплаты она получает контакты и адрес доставки, которые покупатель выбрал в PayPal, и заполняет ими корзину.
Создайте файл и добавьте следующий код:

src/lib/data/paypal.ts1"use server"23import { sdk } from "@lib/config"4import { getAuthHeaders, getCartId } from "./cookies"56/** What the storefront needs to load the PayPal JS SDK, from `GET /store/paypal/config`. */7export type PayPalConfig = {8 provider_id: string9 client_id: string10 environment: "sandbox" | "live"11 intent: "capture" | "authorize"12}1314/** The buyer of an approved PayPal order, from `GET /store/paypal/orders/:id/payer`. */15export type PayPalPayer = {16 amount: { currency_code: string; value: string } | null17 email: string | null18 phone: string | null19 shipping_address: {20 first_name: string | null21 last_name: string | null22 address_1: string | null23 address_2: string | null24 city: string | null25 province: string | null26 postal_code: string | null27 country_code: string | null28 phone: string | null29 } | null30}3132/**33 * The public PayPal settings of the only PayPal integration, or null while it is34 * not configured. The client ID and the intent change only in the Admin, so the35 * answer is cached for a minute.36 */37export async function getPayPalConfig(): Promise<PayPalConfig | null> {38 return sdk.client39 .fetch<{ config: PayPalConfig }>("/store/paypal/config", {40 method: "GET",41 next: { revalidate: 60 },42 })43 .then(({ config }) => config)44 .catch(() => null)45}4647/**48 * The contact and the shipping address the buyer chose in PayPal, and the amount49 * they approved. The backend answers only for an order created for this cart.50 */51export async function getPayPalPayer(orderId: string): Promise<PayPalPayer> {52 const cartId = await getCartId()53 if (!cartId) {54 throw new Error("No existing cart found for the PayPal order")55 }5657 const { payer } = await sdk.client.fetch<{ payer: PayPalPayer }>(58 `/store/paypal/orders/${encodeURIComponent(orderId)}/payer`,59 {60 method: "GET",61 query: { cart_id: cartId },62 headers: { ...(await getAuthHeaders()) },63 cache: "no-store",64 }65 )66 return payer67}
В файле две функции:
Обе функции работают с единственной интеграцией PayPal в магазине. Если вы подключили несколько интеграций PayPal, добавьте параметр в оба запроса.
Кнопки PayPal работают только внутри компонента, который загружает JS SDK PayPal. Кнопке оплаты и кнопке экспресс-оплаты нужен SDK с одинаковыми Client ID, способом списания и валютой, поэтому обе используют один компонент-обёртку.
Создайте файл и добавьте следующий код:

src/modules/checkout/components/paypal-scope/index.tsx1"use client"23import { getPayPalConfig, type PayPalConfig } from "@lib/data/paypal"4import { PayPalScriptProvider } from "@paypal/react-paypal-js"5import { unstable_rethrow } from "next/navigation"6import { type ReactNode, useEffect, useState } from "react"78// One request per page, shared by every PayPal button on it.9let configRequest: Promise<PayPalConfig | null> | null = null1011/** The PayPal settings, `undefined` while they load and `null` without a PayPal integration. */12export const usePayPalConfig = () => {13 const [config, setConfig] = useState<PayPalConfig | null | undefined>(undefined)1415 useEffect(() => {16 let active = true17 configRequest ??= getPayPalConfig().catch(() => null)18 configRequest.then((value) => {19 if (active) setConfig(value)20 })21 return () => {22 active = false23 }24 }, [])2526 return config27}2829/**30 * True for the control-flow error a placed order's `redirect()` throws. The31 * router follows the redirect on its own, so a PayPal callback that meets it32 * returns instead of reporting a failure over the placed order.33 */34export const isNavigation = (error: unknown) => {35 try {36 unstable_rethrow(error)37 return false38 } catch {39 return true40 }41}4243/**44 * Loads the PayPal JS SDK for the buttons inside it, with the client ID and the45 * intent the backend creates orders with and the cart's currency. Keyed by the46 * currency, because the provider reads its options once and a change inside a47 * mounted provider leaves the buttons blank.48 */49export default function PayPalScope({50 currencyCode,51 onUnavailable,52 children,53}: {54 currencyCode?: string | null55 /** Called when the store has no PayPal integration configured. */56 onUnavailable?: () => void57 children: ReactNode58}) {59 const config = usePayPalConfig()6061 useEffect(() => {62 if (config === null) onUnavailable?.()63 }, [config, onUnavailable])6465 if (!config) {66 return null67 }6869 const currency = (currencyCode || "usd").toUpperCase()70 return (71 <PayPalScriptProvider72 key={currency}73 options={{74 clientId: config.client_id,75 currency,76 intent: config.intent,77 components: "buttons",78 }}79 >80 {children}81 </PayPalScriptProvider>82 )83}
Что экспортирует файл:
Проп с валютой корзины пересоздаёт при смене валюты. Провайдер читает параметры только один раз, и без SDK остался бы загружен со старой валютой.
В витрине для каждого платёжного провайдера необходим отдельный компонент кнопки оплаты. На шаге оплаты PayPal показывает свою кнопку вместо кнопки оформления заказа. Кнопка открывает окно PayPal, а когда покупатель подтверждает в нём оплату, компонент оформляет заказ. Пока покупатель не заполнил всё, что нужно для заказа, например адрес доставки, компонент показывает неактивную кнопку оформления заказа.
Создайте файл и добавьте следующий код:

src/modules/checkout/components/payment-button/providers/paypal.tsx1"use client"23import { buildPaymentSessionData, isPaypal } from "@lib/constants"4import { startExpressPayment } from "@lib/data/express-checkout"5import { Button } from "@medusajs/ui"6import ErrorMessage from "@modules/checkout/components/error-message"7import PayPalScope, {8 isNavigation,9} from "@modules/checkout/components/paypal-scope"10import { PayPalButtons, usePayPalScriptReducer } from "@paypal/react-paypal-js"11import { type PaymentProviderButtonProps, usePlaceOrder } from "../shared"1213function PayPalButtonsInner({14 cart,15 notReady,16 cartUpdating,17 label,18 "data-testid": dataTestId,19}: PaymentProviderButtonProps) {20 const [{ isResolved, isRejected }] = usePayPalScriptReducer()21 const { setSubmitting, errorMessage, setErrorMessage, completeOrder } =22 usePlaceOrder()2324 const providerId =25 cart.payment_collection?.payment_sessions?.find((s) =>26 isPaypal(s.provider_id)27 )?.provider_id ?? "pp_paypal_paypal"2829 // The PayPal buttons show once the checkout has what the order needs.30 if (notReady || cartUpdating) {31 return (32 <Button33 disabled34 size="large"35 isLoading={cartUpdating}36 className="w-full"37 data-testid={dataTestId}38 >39 {label}40 </Button>41 )42 }43 if (isRejected) {44 return <ErrorMessage error="payment failed" data-testid={dataTestId} />45 }46 if (!isResolved) {47 return (48 <div className="h-12 rounded-md bg-ui-bg-component" aria-hidden="true" />49 )50 }5152 // `isolate` keeps the SDK's iframe, at z-index 100, under the checkout modals.53 return (54 <div className="isolate" data-testid={dataTestId}>55 <PayPalButtons56 style={{57 layout: "vertical",58 color: "gold",59 shape: "rect",60 label: "paypal",61 height: 48,62 }}63 // The order of the session the checkout started when the cart was ready.64 // Without one, a new session at the click carries the cart as it is now.65 createOrder={async () => {66 setErrorMessage(null)67 const pending = cart.payment_collection?.payment_sessions?.find(68 (s) => isPaypal(s.provider_id) && s.status === "pending"69 )?.data?.paypal_order_id70 if (typeof pending === "string" && pending) {71 return pending72 }73 const data = await startExpressPayment(74 providerId,75 buildPaymentSessionData(providerId, cart) ?? {}76 )77 const orderId = data.paypal_order_id78 if (typeof orderId !== "string" || !orderId) {79 throw new Error("PayPal payment session has no order")80 }81 return orderId82 }}83 onApprove={async () => {84 setSubmitting(true)85 try {86 await completeOrder()87 } catch (err) {88 if (isNavigation(err)) return89 throw err90 }91 }}92 onCancel={() => setSubmitting(false)}93 onError={(err) => {94 setErrorMessage(err instanceof Error ? err.message : "payment failed")95 setSubmitting(false)96 }}97 />98 <ErrorMessage99 error={errorMessage}100 data-testid="paypal-payment-error-message"101 />102 </div>103 )104}105106/** The PayPal button at the payment step, in place of the order button. */107const PayPalPaymentButton = (props: PaymentProviderButtonProps) => (108 <PayPalScope currencyCode={props.cart.currency_code}>109 <PayPalButtonsInner {...props} />110 </PayPalScope>111)112113export default PayPalPaymentButton
Как работает компонент:
Импортируйте кнопку и в , затем добавьте для неё в :

src/modules/checkout/components/payment-button/index.tsx1import {2 isManual,3 isPaymentSessionReady,4 isPaypal,5 isShippingMethodReady,6 isStripeLike,7} from "@lib/constants"8// ...9import ManualPaymentButton from "./providers/manual"10import PayPalPaymentButton from "./providers/paypal"11import StripePaymentButton from "./providers/stripe"1213// ...1415 switch (true) {16 case isStripeLike(activePaymentMethod):17 return <StripePaymentButton {...providerProps} />18 case isPaypal(activePaymentMethod):19 return <PayPalPaymentButton {...providerProps} />20 case isManual(activePaymentMethod):21 return <ManualPaymentButton {...providerProps} />22 // ...23 }
Родительский компонент использует , чтобы определить, нужно ли отобразить для текущей сессии, иначе выполнение переходит к следующей ветке.
Ряд экспресс-оплаты стартера показывает кнопку каждого платёжного провайдера, который есть в его реестре и включён в регионе корзины. При экспресс-оплате покупатель выбирает адрес доставки в окне PayPal, поэтому кнопка также пересчитывает доставку и итог. Как устроен ряд и что получает кнопка, читайте в разделе Настройка экспресс-оплаты.
Создайте файл и добавьте следующий код:

src/modules/checkout/components/express-checkout/buttons/paypal.tsx53 }5455 // The SDK puts its iframe at z-index 100. `isolate` keeps it under the56 // checkout modals, which would otherwise open beneath the button.57 return (58 <div className="isolate">59 <PayPalButtons60 fundingSource="paypal"61 style={{62 layout: "horizontal",63 color: "gold",64 shape: "rect",65 label: "paypal",66 height: 48,67 tagline: false,68 disableMaxWidth: true,69 }}70 createOrder={async () => {71 onError(null)72 const orderId = orderIdOf(73 await startExpressPayment(providerId, { express: true })74 )75 orderRef.current = orderId76 return orderId77 }}78 onShippingAddressChange={async (data, actions) => {79 const orderId = data.orderID || orderRef.current80 if (!orderId) return actions.reject()81 let shipsThere = false82 try {83 const quote = await quoteExpressShipping(84 {85 country_code: data.shippingAddress.countryCode ?? "",86 province: data.shippingAddress.state,87 city: data.shippingAddress.city,88 postal_code: data.shippingAddress.postalCode,89 },90 shippingOptionId91 )92 shipsThere = quote.ok93 if (shipsThere) {94 // A new total makes Medusa drop the session, so the order the95 // customer is approving moves to a new one, which patches its amount.96 await startExpressPayment(97 providerId,98 { express: true, paypal_order_id: orderId },99 "paypal_order_id"100 )101 }102 } catch {103 return actions.reject()104 }105 if (!shipsThere) {106 // PayPal shows the thrown message, so the customer learns why the107 // address is refused. The SDK puts its own wording in `data.errors`,108 // which its types leave out.109 const { errors } = data as typeof data & {110 errors?: { ADDRESS_ERROR?: string }111 }112 throw new Error(113 errors?.ADDRESS_ERROR ??114 "Your order can't be shipped to this address."115 )116 }117 }}118 onApprove={async (data) => {119 onBusy(true)120 try {121 const payer = await getPayPalPayer(data.orderID)122 const approved = payer.amount123 ? Number(payer.amount.value)124 : undefined125 const saved = await saveExpressContact(126 {127 email: payer.email,128 phone: payer.phone,129 shipping_address: payer.shipping_address,130 },131 shippingOptionId,132 approved133 )134 if (!saved.ok) {135 onError(136 saved.reason === "totalChanged"137 ? {138 code: "totalChanged",139 total: saved.total,140 currency_code: saved.currency_code,141 }142 : { code: saved.reason }143 )144 onBusy(false)145 onClosed()146 return147 }148 // The saved address may have changed the total once more.149 await startExpressPayment(150 providerId,151 { express: true, paypal_order_id: data.orderID },152 "paypal_order_id"153 )154 await placeOrder()155 } catch (err) {156 if (isNavigation(err)) return157 onError({ code: "failed", cause: err })158 onBusy(false)159 onClosed()160 }161 }}162 onCancel={() => onClosed()}163 onError={(err) => {164 onError({ code: "failed", cause: err })165 onClosed()166 }}167 >168 <Unavailable onMount={onUnavailable} />169 </PayPalButtons>170 </div>171 )172}173174/**175 * PayPal in the express checkout row. The customer picks the address in the176 * PayPal window, the row answers with the shipping and the new total, and the177 * order is placed once they approve.178 */179export default function PayPalExpressButton(props: ExpressButtonProps) {180 return (181 <PayPalScope182 currencyCode={props.cart.currency_code}183 onUnavailable={props.onUnavailable}184 >185 <PayPalWalletButton {...props} />186 </PayPalScope>187 )188}
Кнопка обрабатывает три колбэка PayPal:
Когда итог меняется, Medusa удаляет платёжную сессию. Поэтому кнопка создаёт новую сессию с тем же , а провайдер привязывает к ней заказ PayPal и обновляет его сумму.
Проверка итога нужна, потому что PayPal не всегда сообщает адрес до подтверждения оплаты, например при гостевой оплате картой. Доставка по этому адресу может увеличить итог, и тогда кнопка не оформляет заказ, а показывает сообщение с новым итогом и просит оплатить заново.

src/modules/checkout/components/express-checkout/registry.ts1import { isPaypal } from "@lib/constants"2import type { HttpTypes } from "@medusajs/types"3import type { ComponentType } from "react"4import PayPalExpressButton from "./buttons/paypal"56// ...78export const expressButtons: {9 match: (providerId: string) => boolean10 Component: ComponentType<ExpressButtonProps>11}[] = [12 { match: (providerId) => Boolean(isPaypal(providerId)), Component: PayPalExpressButton },13]
Теперь ряд показывает PayPal в каждом регионе, где включён , и прячет кнопку, если PayPal недоступен покупателю.
Добавьте в и в каждую другую локаль под :
messages/ru.json1{2 "PaymentMethods": {3 // ...4 "pp_paypal_paypal": {5 "title": "PayPal",6 "subtitle": "Карты, кошелёк"7 }8 }9}
Если для локали нет записи, используются английские и из в Шаге 2.
Полный код витрины находится в , собранной на основе Medusa DTC Starter от Gorgo. В README примера перечислены все файлы, из которых состоит интеграция, включая бэкенд.