Command Palette

Search for a command to run...

Интеграция PayPal в витрину магазина

Для интеграции платёжного провайдера PayPal в storefront Medusa DTC Starter от Gorgo начните с добавления необходимых UI-компонентов. Таким образом провайдер будет отображаться на странице оформления заказа наряду с другими доступными методами оплаты.

Когда покупатель нажимает кнопку PayPal на шаге оплаты или в ряду экспресс-оплаты, оформление заказа инициализирует платёжную сессию, а провайдер создаёт для неё заказ на стороне PayPal. Затем покупатель подтверждает оплату в окне PayPal.

После подтверждения оплаты витрина завершает корзину и создаёт новый заказ в Medusa. Если покупатель закрыл вкладку после того, как PayPal списал деньги, заказ создаст вебхук .

Для витрины на Next.js необходимо внести следующие изменения:

Шаг 1: Установите JS SDK PayPal

Кнопки берутся из официальной React-обёртки JS SDK PayPal. Установите её в витрину:

yarn add @paypal/react-paypal-js@^8.9.2

Версия 8 пакета загружает JavaScript SDK PayPal с и даёт компонент , колбэк которого сообщает адрес, выбранный покупателем в окне PayPal.


Шаг 2: Настройте платёжный провайдер

Чтобы сделать PayPal доступным в качестве способа оплаты на странице оформления заказа витрины магазина, необходимо добавить его конфигурацию в маппинг платёжных провайдеров в файле с константами вашей витрины. Этот маппинг определяет, как каждый провайдер отображается в интерфейсе.

Откройте и добавьте следующий код:

Структура проекта Medusa Storefront после обновления файла с константами

src/lib/constants.tsx
1export const paymentInfoMap: Record<string, PaymentInfo> = {
2 // ...
3 pp_paypal_paypal: {
4 title: "PayPal",
5 subtitle: "Cards, wallet",
6 icon: <PayPal />,
7 },
8 // ...
9}
10
11// ...
12
13export const isPaypal = (providerId?: string) => {
14 return providerId?.startsWith("pp_paypal")
15}

Вы расширяете объект , добавляя в него запись . Эта запись определяет заголовок, подпись и иконку, которые будут отображаться для PayPal на странице оформления заказа, а также служит резервным значением, когда для локали нет перевода. Иконка входит в стартер и находится в .

Вспомогательная функция проверяет, принадлежит ли переданный к PayPal. Она используется диспетчером ниже и функцией сборки данных платёжной сессии из следующего шага.


Шаг 3: Инициализируйте платёжную сессию

Чтобы создать заказ PayPal, платёжная сессия должна быть корректно инициализирована с необходимыми параметрами, включая адрес доставки и данные покупателя для формы оплаты картой PayPal.

Откройте тот же файл и добавьте функцию сборки данных сессии:

Структура проекта Medusa Storefront после обновления файла с константами

src/lib/constants.tsx
1const paymentSessionDataBuilders: Array<{
2 test: (providerId?: string) => boolean | undefined
3 isReady?: (cart: HttpTypes.StoreCart) => boolean
4 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 the
9 // session starts once the checkout has an email, an address and a shipping
10 // 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) > 0
16 ),
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 на шаге оплаты остаётся неактивной до этого момента.


Шаг 4: Загрузите настройки PayPal и данные покупателя

Витрина получает от бэкенда два вида данных. Чтобы показать кнопки PayPal, она загружает публичные настройки PayPal для SDK. После экспресс-оплаты она получает контакты и адрес доставки, которые покупатель выбрал в PayPal, и заполняет ими корзину.

Создайте файл и добавьте следующий код:

Структура проекта Medusa Storefront после создания файла с загрузчиками данных PayPal

src/lib/data/paypal.ts
1"use server"
2
3import { sdk } from "@lib/config"
4import { getAuthHeaders, getCartId } from "./cookies"
5
6/** What the storefront needs to load the PayPal JS SDK, from `GET /store/paypal/config`. */
7export type PayPalConfig = {
8 provider_id: string
9 client_id: string
10 environment: "sandbox" | "live"
11 intent: "capture" | "authorize"
12}
13
14/** The buyer of an approved PayPal order, from `GET /store/paypal/orders/:id/payer`. */
15export type PayPalPayer = {
16 amount: { currency_code: string; value: string } | null
17 email: string | null
18 phone: string | null
19 shipping_address: {
20 first_name: string | null
21 last_name: string | null
22 address_1: string | null
23 address_2: string | null
24 city: string | null
25 province: string | null
26 postal_code: string | null
27 country_code: string | null
28 phone: string | null
29 } | null
30}
31
32/**
33 * The public PayPal settings of the only PayPal integration, or null while it is
34 * not configured. The client ID and the intent change only in the Admin, so the
35 * answer is cached for a minute.
36 */
37export async function getPayPalConfig(): Promise<PayPalConfig | null> {
38 return sdk.client
39 .fetch<{ config: PayPalConfig }>("/store/paypal/config", {
40 method: "GET",
41 next: { revalidate: 60 },
42 })
43 .then(({ config }) => config)
44 .catch(() => null)
45}
46
47/**
48 * The contact and the shipping address the buyer chose in PayPal, and the amount
49 * 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 }
56
57 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 payer
67}

В файле две функции:

  • возвращает Client ID, окружение и способ списания из . Если в магазине нет включённой и полностью настроенной интеграции PayPal, функция возвращает , и витрина не показывает кнопки PayPal.
  • возвращает email, телефон и адрес доставки покупателя из , а также сумму, которую он одобрил в PayPal. Функция передаёт в запросе ID корзины, и бэкенд отвечает только по заказу PayPal, созданному для этой корзины. Так никто не сможет прочитать чужой адрес, подобрав ID заказа.

Обе функции работают с единственной интеграцией PayPal в магазине. Если вы подключили несколько интеграций PayPal, добавьте параметр в оба запроса.


Шаг 5: Загрузите SDK PayPal

Кнопки PayPal работают только внутри компонента, который загружает JS SDK PayPal. Кнопке оплаты и кнопке экспресс-оплаты нужен SDK с одинаковыми Client ID, способом списания и валютой, поэтому обе используют один компонент-обёртку.

Создайте файл и добавьте следующий код:

Структура проекта Medusa Storefront после создания файла с обёрткой SDK PayPal

src/modules/checkout/components/paypal-scope/index.tsx
1"use client"
2
3import { 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"
7
8// One request per page, shared by every PayPal button on it.
9let configRequest: Promise<PayPalConfig | null> | null = null
10
11/** 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)
14
15 useEffect(() => {
16 let active = true
17 configRequest ??= getPayPalConfig().catch(() => null)
18 configRequest.then((value) => {
19 if (active) setConfig(value)
20 })
21 return () => {
22 active = false
23 }
24 }, [])
25
26 return config
27}
28
29/**
30 * True for the control-flow error a placed order's `redirect()` throws. The
31 * router follows the redirect on its own, so a PayPal callback that meets it
32 * returns instead of reporting a failure over the placed order.
33 */
34export const isNavigation = (error: unknown) => {
35 try {
36 unstable_rethrow(error)
37 return false
38 } catch {
39 return true
40 }
41}
42
43/**
44 * Loads the PayPal JS SDK for the buttons inside it, with the client ID and the
45 * intent the backend creates orders with and the cart's currency. Keyed by the
46 * currency, because the provider reads its options once and a change inside a
47 * mounted provider leaves the buttons blank.
48 */
49export default function PayPalScope({
50 currencyCode,
51 onUnavailable,
52 children,
53}: {
54 currencyCode?: string | null
55 /** Called when the store has no PayPal integration configured. */
56 onUnavailable?: () => void
57 children: ReactNode
58}) {
59 const config = usePayPalConfig()
60
61 useEffect(() => {
62 if (config === null) onUnavailable?.()
63 }, [config, onUnavailable])
64
65 if (!config) {
66 return null
67 }
68
69 const currency = (currencyCode || "usd").toUpperCase()
70 return (
71 <PayPalScriptProvider
72 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 с Client ID и способом списания из настроек бэкенда и с валютой корзины. Кнопки PayPal размещаются внутри него. Пока настройки загружаются, компонент ничего не показывает, а если интеграции PayPal в магазине нет, вызывает .
  • запрашивает настройки PayPal один раз на страницу, сколько бы кнопок PayPal на ней ни было.
  • отличает переход после оформления заказа от настоящей ошибки. Когда заказ оформлен, в Next.js выбрасывает служебную ошибку, чтобы открыть страницу подтверждения заказа. распознаёт её, и кнопка PayPal не показывает ошибку поверх уже оформленного заказа.

Проп с валютой корзины пересоздаёт при смене валюты. Провайдер читает параметры только один раз, и без SDK остался бы загружен со старой валютой.


Шаг 6: Создайте кнопку оплаты

В витрине для каждого платёжного провайдера необходим отдельный компонент кнопки оплаты. На шаге оплаты PayPal показывает свою кнопку вместо кнопки оформления заказа. Кнопка открывает окно PayPal, а когда покупатель подтверждает в нём оплату, компонент оформляет заказ. Пока покупатель не заполнил всё, что нужно для заказа, например адрес доставки, компонент показывает неактивную кнопку оформления заказа.

Создайте файл и добавьте следующий код:

Структура проекта Medusa Storefront после создания файла с кнопкой оплаты PayPal

src/modules/checkout/components/payment-button/providers/paypal.tsx
1"use client"
2
3import { 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"
12
13function 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()
23
24 const providerId =
25 cart.payment_collection?.payment_sessions?.find((s) =>
26 isPaypal(s.provider_id)
27 )?.provider_id ?? "pp_paypal_paypal"
28
29 // The PayPal buttons show once the checkout has what the order needs.
30 if (notReady || cartUpdating) {
31 return (
32 <Button
33 disabled
34 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 }
51
52 // `isolate` keeps the SDK's iframe, at z-index 100, under the checkout modals.
53 return (
54 <div className="isolate" data-testid={dataTestId}>
55 <PayPalButtons
56 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_id
70 if (typeof pending === "string" && pending) {
71 return pending
72 }
73 const data = await startExpressPayment(
74 providerId,
75 buildPaymentSessionData(providerId, cart) ?? {}
76 )
77 const orderId = data.paypal_order_id
78 if (typeof orderId !== "string" || !orderId) {
79 throw new Error("PayPal payment session has no order")
80 }
81 return orderId
82 }}
83 onApprove={async () => {
84 setSubmitting(true)
85 try {
86 await completeOrder()
87 } catch (err) {
88 if (isNavigation(err)) return
89 throw err
90 }
91 }}
92 onCancel={() => setSubmitting(false)}
93 onError={(err) => {
94 setErrorMessage(err instanceof Error ? err.message : "payment failed")
95 setSubmitting(false)
96 }}
97 />
98 <ErrorMessage
99 error={errorMessage}
100 data-testid="paypal-payment-error-message"
101 />
102 </div>
103 )
104}
105
106/** 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)
112
113export default PayPalPaymentButton

Как работает компонент:

  • возвращает ID заказа PayPal из платёжной сессии корзины. Если сессии ещё нет, компонент создаёт её в момент клика. Тогда заказ PayPal соответствует корзине на момент оплаты, а PayPal показывает адрес доставки из корзины и не спрашивает его у покупателя.
  • оформляет заказ через , тот же хук, что используют остальные кнопки оплаты.

Шаг 7: Зарегистрируйте кнопку оплаты

Импортируйте кнопку и в , затем добавьте для неё в :

Структура проекта Medusa Storefront после обновления файла компонента кнопки оплаты

src/modules/checkout/components/payment-button/index.tsx
1import {
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"
12
13// ...
14
15 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 }

Родительский компонент использует , чтобы определить, нужно ли отобразить для текущей сессии, иначе выполнение переходит к следующей ветке.


Шаг 8: Добавьте PayPal в ряд экспресс-оплаты

Ряд экспресс-оплаты стартера показывает кнопку каждого платёжного провайдера, который есть в его реестре и включён в регионе корзины. При экспресс-оплате покупатель выбирает адрес доставки в окне PayPal, поэтому кнопка также пересчитывает доставку и итог. Как устроен ряд и что получает кнопка, читайте в разделе Настройка экспресс-оплаты.

Создайте файл и добавьте следующий код:

Структура проекта Medusa Storefront после создания файла с кнопкой PayPal для экспресс-оплаты

src/modules/checkout/components/express-checkout/buttons/paypal.tsx
53 }
54
55 // The SDK puts its iframe at z-index 100. `isolate` keeps it under the
56 // checkout modals, which would otherwise open beneath the button.
57 return (
58 <div className="isolate">
59 <PayPalButtons
60 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 = orderId
76 return orderId
77 }}
78 onShippingAddressChange={async (data, actions) => {
79 const orderId = data.orderID || orderRef.current
80 if (!orderId) return actions.reject()
81 let shipsThere = false
82 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 shippingOptionId
91 )
92 shipsThere = quote.ok
93 if (shipsThere) {
94 // A new total makes Medusa drop the session, so the order the
95 // 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 the
107 // 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.amount
123 ? Number(payer.amount.value)
124 : undefined
125 const saved = await saveExpressContact(
126 {
127 email: payer.email,
128 phone: payer.phone,
129 shipping_address: payer.shipping_address,
130 },
131 shippingOptionId,
132 approved
133 )
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 return
147 }
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)) return
157 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}
173
174/**
175 * PayPal in the express checkout row. The customer picks the address in the
176 * PayPal window, the row answers with the shipping and the new total, and the
177 * order is placed once they approve.
178 */
179export default function PayPalExpressButton(props: ExpressButtonProps) {
180 return (
181 <PayPalScope
182 currencyCode={props.cart.currency_code}
183 onUnavailable={props.onUnavailable}
184 >
185 <PayPalWalletButton {...props} />
186 </PayPalScope>
187 )
188}

Кнопка обрабатывает три колбэка PayPal:

  • создаёт платёжную сессию с . Тогда провайдер создаёт заказ PayPal без адреса, и PayPal спрашивает адрес у покупателя.
  • срабатывает, когда покупатель выбирает адрес в окне PayPal. Колбэк пересчитывает доставку и итог для этого адреса и обновляет сумму заказа PayPal. Если регион корзины не доставляет по этому адресу, колбэк выбрасывает ошибку, и окно PayPal сообщает об этом покупателю, чтобы он выбрал другой адрес.
  • сохраняет в корзине контакты и адрес покупателя из , проверяет, что итог не превысил одобренную покупателем сумму, и оформляет заказ.

Когда итог меняется, Medusa удаляет платёжную сессию. Поэтому кнопка создаёт новую сессию с тем же , а провайдер привязывает к ней заказ PayPal и обновляет его сумму.

Проверка итога нужна, потому что PayPal не всегда сообщает адрес до подтверждения оплаты, например при гостевой оплате картой. Доставка по этому адресу может увеличить итог, и тогда кнопка не оформляет заказ, а показывает сообщение с новым итогом и просит оплатить заново.

Затем добавьте кнопку в :

Структура проекта Medusa Storefront после обновления реестра экспресс-оплаты

src/modules/checkout/components/express-checkout/registry.ts
1import { isPaypal } from "@lib/constants"
2import type { HttpTypes } from "@medusajs/types"
3import type { ComponentType } from "react"
4import PayPalExpressButton from "./buttons/paypal"
5
6// ...
7
8export const expressButtons: {
9 match: (providerId: string) => boolean
10 Component: ComponentType<ExpressButtonProps>
11}[] = [
12 { match: (providerId) => Boolean(isPaypal(providerId)), Component: PayPalExpressButton },
13]

Теперь ряд показывает PayPal в каждом регионе, где включён , и прячет кнопку, если PayPal недоступен покупателю.


Шаг 9: Добавьте подписи провайдера в локали витрины

Добавьте в и в каждую другую локаль под :

messages/ru.json
1{
2 "PaymentMethods": {
3 // ...
4 "pp_paypal_paypal": {
5 "title": "PayPal",
6 "subtitle": "Карты, кошелёк"
7 }
8 }
9}

Если для локали нет записи, используются английские и из в Шаге 2.


Полный пример кода интеграции в витрину

Полный код витрины находится в , собранной на основе Medusa DTC Starter от Gorgo. В README примера перечислены все файлы, из которых состоит интеграция, включая бэкенд.

Изменено 27 октября 2026 г.·Редактировать страницу