Command Palette

Search for a command to run...

Интеграция оплаты Mollie в витрину магазина

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

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

После оплаты Mollie вызывает вебхук и перенаправляет покупателя обратно на витрину. Корзину завершит и новый заказ в Medusa создаст событие, пришедшее первым.

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

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

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

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

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

src/lib/constants.tsx
1export const paymentInfoMap: Record<string, PaymentInfo> = {
2 // ...
3 pp_mollie_mollie: {
4 title: "Mollie",
5 subtitle: "Cards, iDEAL, PayPal",
6 icon: <CreditCard />,
7 },
8 // Add more payment providers here
9}
10
11// ...
12
13export const isMollie = (providerId?: string) => {
14 return providerId?.startsWith("pp_mollie_")
15}

Вы дополняете объект записью . Она задаёт название, подзаголовок и иконку Mollie на странице оформления заказа, а также служит запасным вариантом, когда для локали нет перевода. Mollie принимает много способов оплаты, поэтому запись использует общую иконку из стартера.

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


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

Чтобы отправить покупателя в Mollie, платёжной сессии нужны адрес, на который покупатель вернётся после оплаты, и язык страницы Mollie.

Откройте тот же файл и импортируйте в начале файла локаль по умолчанию и базовый URL витрины:

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

src/lib/constants.tsx
1import { defaultLocale } from "@i18n/config"
2import { getBaseURL } from "@lib/util/env"

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

src/lib/constants.tsx
1/**
2 * The language of the page. `<html lang>` carries the locale the checkout was
3 * rendered in, which a provider with its own payment page takes to show that
4 * page in the customer's language.
5 */
6const pageLocale = () =>
7 typeof document === "undefined"
8 ? defaultLocale
9 : document.documentElement.lang || defaultLocale
10
11/**
12 * The address the customer comes back to after paying on the provider's own
13 * page, in the language they left the shop with.
14 */
15const paymentReturnUrl = (providerId: string, cartId: string) => {
16 const origin =
17 typeof window === "undefined" ? getBaseURL() : window.location.origin
18
19 return `${origin}/api/payment-return?provider=${providerId}&cart_id=${cartId}&locale=${pageLocale()}`
20}
21
22const paymentSessionDataBuilders: Array<{
23 test: (providerId?: string) => boolean | undefined
24 isReady?: (cart: HttpTypes.StoreCart) => boolean
25 build: (cart: HttpTypes.StoreCart) => Record<string, unknown>
26 /**
27 * The provider's own pay button starts the payment session when the customer
28 * pays, so the checkout does not start one when the method is picked or the
29 * cart changes. `isReady` still gates the pay button.
30 */
31 deferred?: boolean
32}> = [
33 {
34 test: (providerId) => isMollie(providerId),
35 // A Mollie payment is created with the session, and Mollie neither changes
36 // its amount nor cancels it while it is open, so a session replaced at the
37 // payment step would leave a payable page behind. The pay button starts the
38 // session, once the checkout has the email, the address and the shipping
39 // method the payment is created with.
40 deferred: true,
41 isReady: (cart) =>
42 Boolean(
43 cart.email &&
44 cart.shipping_address &&
45 (cart.shipping_methods?.length ?? 0) > 0
46 ),
47 build: (cart) => ({
48 redirect_url: paymentReturnUrl("mollie", cart.id),
49 locale: pageLocale(),
50 }),
51 },
52]

После добавьте функцию, по которой оформление заказа понимает, что способ запускает сессию с кнопки оплаты:

src/lib/constants.tsx
1export const isPaymentSessionDeferred = (providerId: string | undefined) => {
2 return !!paymentSessionDataBuilders.find((b) => b.test(providerId))?.deferred
3}

При инициализации платежа Mollie функция передаёт , абсолютный адрес, на который вернётся покупатель, и , язык страницы. Провайдер создаёт с ними платёж в Mollie и сохраняет адрес платёжной страницы Mollie в сессии как . Язык без страны, например , провайдер превращает в по правилам Mollie, а для языка, которого у Mollie нет, оставляет выбор браузеру покупателя.

ведёт на API-роут вашей витрины и используется для оплаченных, отменённых и неудачных платежей. API-роут находит корзину по её .

С выбор Mollie и изменения корзины сессию не запускают, а держит кнопку оплаты неактивной, пока в корзине нет email, адреса доставки и способа доставки. Провайдер создаёт платёж в Mollie сразу при старте сессии, с итогом корзины и адресами покупателя. Сумму этого платежа Mollie не меняет и открытым его не отменяет, поэтому сессия, запущенная при выборе способа и потом заменённая, оставила бы после себя оплачиваемую страницу Mollie.


Шаг 3: Запустите платёжную сессию с кнопки оплаты

Оформление заказа в стартере запускает платёжную сессию, когда покупатель выбирает способ, когда меняется корзина и когда сессия пропадает. Для Mollie её вместо этого запускает кнопка оплаты.

Откройте и пропустите отложенный способ в этих трёх местах:

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

src/modules/checkout/components/checkout-payment-section/index.tsx
1import {
2 // ...
3 isPaymentSessionDeferred,
4 isPaymentSessionReady,
5 // ...
6} from "@lib/constants"
7
8// ...
9
10 // Re-initiate payment session when it gets cleared (e.g. after promo code change)
11 useEffect(() => {
12 if (
13 selectedPaymentMethod &&
14 !latestSession &&
15 !paidByGiftcard &&
16 !isPaymentSessionDeferred(selectedPaymentMethod) &&
17 isPaymentSessionReady(selectedPaymentMethod, cart)
18 // ...
19
20// ...
21
22 if (
23 !selectedPaymentMethod ||
24 paidByGiftcard ||
25 isPaymentSessionDeferred(selectedPaymentMethod)
26 ) {
27 return
28 }
29
30// ...
31
32 const handleSelectPayment = async (providerId: string) => {
33 setSelectedPaymentMethod(providerId)
34
35 // a deferred provider's pay button starts the session when the customer pays
36 if (
37 isPaymentSessionDeferred(providerId) ||
38 !isPaymentSessionReady(providerId, cart)
39 ) {
40 return
41 }

Эффект, который перезапускает пропавшую сессию, эффект, который запускает её заново при смене адресов, email, доставки или региона, и обработчик выбора способа теперь не трогают отложенный способ. Оформление заказа по-прежнему показывает Mollie выбранным, а когда создать платёж, решает кнопка оплаты.


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

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

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

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

src/modules/checkout/components/payment-button/providers/mollie.tsx
1"use client"
2
3import { buildPaymentSessionData } from "@lib/constants"
4import { initiatePaymentSession } from "@lib/data/cart"
5import type { HttpTypes } from "@medusajs/types"
6import { useTranslations } from "next-intl"
7import { useState } from "react"
8import { PaymentButtonShell, type PaymentProviderButtonProps } from "../shared"
9
10const MOLLIE_PROVIDER_ID = "pp_mollie_mollie"
11
12const findSession = (payment_collection?: HttpTypes.StorePaymentCollection | null) =>
13 payment_collection?.payment_sessions?.find((s) =>
14 s.provider_id.startsWith("pp_mollie_")
15 )
16
17/**
18 * The address of the payment page of a session started earlier, unless it is
19 * missing or Mollie has let the payment expire. A customer who went back from
20 * Mollie's page without paying returns to the same page this way, instead of a
21 * second payment Mollie would refuse to cancel. A card payment expires after
22 * 30 minutes.
23 */
24const liveCheckoutUrl = (session?: HttpTypes.StorePaymentSession) => {
25 const url = session?.data?.checkout_url
26 const expiresAt = session?.data?.expires_at
27
28 if (typeof url !== "string" || !url) {
29 return null
30 }
31
32 if (typeof expiresAt === "string" && Date.parse(expiresAt) <= Date.now()) {
33 return null
34 }
35
36 return url
37}
38
39/**
40 * Mollie collects the payment on its own page. This button starts the payment
41 * session, which creates the Mollie payment, and sends the customer to its
42 * page; the order is placed once they come back through `/api/payment-return`,
43 * or by the webhook if that arrives first. A session started before, whose
44 * payment is still open, is reused.
45 */
46const MolliePaymentButton = ({
47 cart,
48 notReady,
49 label,
50 cartUpdating,
51 "data-testid": dataTestId,
52}: PaymentProviderButtonProps) => {
53 const t = useTranslations("Errors")
54 const [submitting, setSubmitting] = useState(false)
55 const [errorMessage, setErrorMessage] = useState<string | null>(null)
56
57 const handlePayment = async () => {
58 setSubmitting(true)
59 setErrorMessage(null)
60
61 try {
62 const session = findSession(cart.payment_collection)
63 let url = liveCheckoutUrl(session)
64
65 if (!url) {
66 const providerId = session?.provider_id ?? MOLLIE_PROVIDER_ID
67 const { payment_collection } = await initiatePaymentSession(cart, {
68 provider_id: providerId,
69 data: buildPaymentSessionData(providerId, cart),
70 })
71
72 url = liveCheckoutUrl(findSession(payment_collection))
73 }
74
75 if (!url) {
76 throw new Error(t("paymentFailed"))
77 }
78
79 window.location.assign(url)
80 } catch (error) {
81 setErrorMessage(error instanceof Error ? error.message : t("paymentFailed"))
82 setSubmitting(false)
83 }
84 }
85
86 return (
87 <PaymentButtonShell
88 label={label}
89 onClick={handlePayment}
90 disabled={notReady || cartUpdating}
91 loading={submitting}
92 errorMessage={errorMessage}
93 errorTestId="mollie-payment-error-message"
94 data-testid={dataTestId ?? "submit-order-button"}
95 />
96 )
97}
98
99export default MolliePaymentButton

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

У покупателя, который вернулся со страницы Mollie, не заплатив, уже есть сессия с открытым платежом. Кнопка отправляет его на ту же страницу, если срок не прошёл (карточный платёж истекает через 30 минут), и только иначе запускает новую сессию. Второй открытый платёж для той же корзины и предотвращает отложенная сессия, потому что первый Mollie отменить не даёт. Если адрес не пришёл, кнопка показывает сообщение об ошибке вместо перехода.


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

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

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

src/modules/checkout/components/payment-button/index.tsx
1import {
2 isManual,
3 isMollie,
4 // ...
5} from "@lib/constants"
6// ...
7import MolliePaymentButton from "./providers/mollie"
8
9// ...
10
11 switch (true) {
12 // ...
13 case isMollie(activePaymentMethod):
14 return <MolliePaymentButton {...providerProps} />
15 // ...
16 }

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


Шаг 6: Обработайте возврат с оплаты

После оплаты, отмены или неудачи на странице Mollie покупатель возвращается на витрину. О статусе платежа Mollie сообщает и этим перенаправлением, и вебхуком, поэтому общий API-роут проверяет, нет ли у корзины заказа, прежде чем создать его сам.

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

Структура каталогов витрины Medusa после изменения файла API-роута

src/app/api/payment-return/route.ts
1type ReturnContext = {
2 cartId: string
3 locale: string
4 origin: string
5}
6
7/**
8 * Comes back from a provider that takes the customer to its own payment page
9 * and returns them with nothing but the cart id, as Mollie and YooKassa do.
10 *
11 * The provider's webhook and this redirect race each other, and whichever
12 * arrives first places the order, so the cart is checked for an order before
13 * one is placed.
14 */
15async function redirectProviderReturn({
16 cartId,
17 locale,
18 origin,
19}: ReturnContext) {
20 const cart = await sdk.client
21 .fetch<HttpTypes.StoreCartResponse>(`/store/carts/${cartId}`, {
22 method: "GET",
23 query: { fields: "id,order_link.order_id" },
24 headers: { ...(await getAuthHeaders()) },
25 cache: "no-store",
26 })
27 .then(({ cart }) => cart)
28 .catch(() => null)
29
30 if (!cart) {
31 return NextResponse.redirect(
32 `${origin}/${locale}/cart?error=payment_failed`
33 )
34 }
35
36 const orderId = (cart as unknown as { order_link?: { order_id?: string } })
37 .order_link?.order_id
38
39 if (orderId) {
40 const orderCacheTag = await getCacheTag("orders")
41 revalidateTag(orderCacheTag)
42 await removeCartId()
43
44 return NextResponse.redirect(
45 `${origin}/${locale}/order/${orderId}/confirmed`
46 )
47 }
48
49 // The customer backed out or the payment was declined, so they land back on
50 // the checkout with their cart intact and can pick a method again.
51 await setCartId(cartId)
52
53 try {
54 await placeOrder(cartId)
55 } catch (error) {
56 unstable_rethrow(error)
57
58 return NextResponse.redirect(
59 `${origin}/${locale}/checkout?error=payment_failed`
60 )
61 }
62
63 return NextResponse.redirect(
64 `${origin}/${locale}/checkout?error=payment_failed`
65 )
66}

Затем в функции направьте возврат из Mollie в :

src/app/api/payment-return/route.ts
1if (provider === "mollie") {
2 return redirectProviderReturn({ cartId, locale, origin })
3 }

Этот API-роут обрабатывает возврат из Mollie после попытки оплаты. Он всегда получает актуальное состояние корзины без кэша, чтобы не пропустить изменения, внесённые во время оплаты.

Если у корзины уже есть заказ, значит подтверждение уже пришло по вебхуку. API-роут обновляет кэш заказа, удаляет cookie корзины и отправляет покупателя на страницу подтверждения.

Иначе API-роут пытается создать заказ сам. При успехе покупатель попадает на страницу подтверждения заказа. Если покупатель отменил оплату на странице Mollie или платёж не прошёл, заказ не создаётся, и покупатель возвращается к оформлению заказа с , чтобы выбрать способ оплаты заново.


Шаг 7: Покажите карту на странице заказа

При оплате картой провайдер сохраняет последние четыре цифры карты в данных платежа как , так же, как провайдеры Stripe и Authorize.net от Gorgo. Страница заказа стартера показывает их только для Stripe.

Откройте и измените условие:

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

src/modules/order/components/payment-details/index.tsx
1const { title, subtitle } = paymentMethodLabel(tm, payment.provider_id)
2 const card =
3 (isStripeLike(payment.provider_id) || isMollie(payment.provider_id)) &&
4 payment.data?.card_last4
5 // The card the order was actually paid with says more than the schemes the
6 // method accepts, so it takes the subtitle when the payment carries one.
7 const methodSubtitle = card ? `•••• ${card}` : subtitle

Теперь при оплате картой страница заказа показывает под Mollie . Для iDEAL, PayPal и других способов остаётся подзаголовок из локали.


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

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

messages/ru.json
1{
2 "PaymentMethods": {
3 // ...
4 "pp_mollie_mollie": {
5 "title": "Mollie",
6 "subtitle": "Карты, iDEAL, PayPal"
7 }
8 }
9}

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


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

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

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