Для интеграции платёжного провайдера Mollie в витрину Medusa DTC Starter от Gorgo начните с добавления необходимых UI-компонентов. Так провайдер появится на странице оформления заказа рядом с другими способами оплаты.
Выбор Mollie запросов не отправляет. Когда покупатель нажимает кнопку оплаты, она запускает платёжную сессию, провайдер создаёт платёж в Mollie и возвращает адрес платёжной страницы Mollie, а кнопка отправляет покупателя на эту страницу, где он платит картой, через iDEAL, PayPal или другим способом, включённым в вашем профиле Mollie.
После оплаты Mollie вызывает вебхук и перенаправляет покупателя обратно на витрину. Корзину завершит и новый заказ в Medusa создаст событие, пришедшее первым.
Для витрины на Next.js необходимо внести следующие изменения:
Чтобы Mollie появился среди способов оплаты на странице оформления заказа, добавьте его конфигурацию в маппинг платёжных провайдеров в файле с константами вашей витрины. Этот маппинг определяет, как каждый провайдер отображается в интерфейсе.
Откройте и добавьте следующий код:

src/lib/constants.tsx1export 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 here9}1011// ...1213export const isMollie = (providerId?: string) => {14 return providerId?.startsWith("pp_mollie_")15}
Вы дополняете объект записью . Она задаёт название, подзаголовок и иконку Mollie на странице оформления заказа, а также служит запасным вариантом, когда для локали нет перевода. Mollie принимает много способов оплаты, поэтому запись использует общую иконку из стартера.
Вспомогательная функция проверяет, относится ли к Mollie. Её используют диспетчер кнопок оплаты и страница заказа в следующих шагах.
Чтобы отправить покупателя в Mollie, платёжной сессии нужны адрес, на который покупатель вернётся после оплаты, и язык страницы Mollie.
Откройте тот же файл и импортируйте в начале файла локаль по умолчанию и базовый URL витрины:

src/lib/constants.tsx1import { defaultLocale } from "@i18n/config"2import { getBaseURL } from "@lib/util/env"
Затем добавьте язык страницы, адрес возврата и функцию сборки данных сессии после маппинга платёжных провайдеров:
src/lib/constants.tsx1/**2 * The language of the page. `<html lang>` carries the locale the checkout was3 * rendered in, which a provider with its own payment page takes to show that4 * page in the customer's language.5 */6const pageLocale = () =>7 typeof document === "undefined"8 ? defaultLocale9 : document.documentElement.lang || defaultLocale1011/**12 * The address the customer comes back to after paying on the provider's own13 * 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.origin1819 return `${origin}/api/payment-return?provider=${providerId}&cart_id=${cartId}&locale=${pageLocale()}`20}2122const paymentSessionDataBuilders: Array<{23 test: (providerId?: string) => boolean | undefined24 isReady?: (cart: HttpTypes.StoreCart) => boolean25 build: (cart: HttpTypes.StoreCart) => Record<string, unknown>26 /**27 * The provider's own pay button starts the payment session when the customer28 * pays, so the checkout does not start one when the method is picked or the29 * cart changes. `isReady` still gates the pay button.30 */31 deferred?: boolean32}> = [33 {34 test: (providerId) => isMollie(providerId),35 // A Mollie payment is created with the session, and Mollie neither changes36 // its amount nor cancels it while it is open, so a session replaced at the37 // payment step would leave a payable page behind. The pay button starts the38 // session, once the checkout has the email, the address and the shipping39 // 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) > 046 ),47 build: (cart) => ({48 redirect_url: paymentReturnUrl("mollie", cart.id),49 locale: pageLocale(),50 }),51 },52]
После добавьте функцию, по которой оформление заказа понимает, что способ запускает сессию с кнопки оплаты:
src/lib/constants.tsx1export const isPaymentSessionDeferred = (providerId: string | undefined) => {2 return !!paymentSessionDataBuilders.find((b) => b.test(providerId))?.deferred3}
При инициализации платежа Mollie функция передаёт , абсолютный адрес, на который вернётся покупатель, и , язык страницы. Провайдер создаёт с ними платёж в Mollie и сохраняет адрес платёжной страницы Mollie в сессии как . Язык без страны, например , провайдер превращает в по правилам Mollie, а для языка, которого у Mollie нет, оставляет выбор браузеру покупателя.
ведёт на API-роут вашей витрины и используется для оплаченных, отменённых и неудачных платежей. API-роут находит корзину по её .
С выбор Mollie и изменения корзины сессию не запускают, а держит кнопку оплаты неактивной, пока в корзине нет email, адреса доставки и способа доставки. Провайдер создаёт платёж в Mollie сразу при старте сессии, с итогом корзины и адресами покупателя. Сумму этого платежа Mollie не меняет и открытым его не отменяет, поэтому сессия, запущенная при выборе способа и потом заменённая, оставила бы после себя оплачиваемую страницу Mollie.
Оформление заказа в стартере запускает платёжную сессию, когда покупатель выбирает способ, когда меняется корзина и когда сессия пропадает. Для Mollie её вместо этого запускает кнопка оплаты.
Откройте и пропустите отложенный способ в этих трёх местах:

src/modules/checkout/components/checkout-payment-section/index.tsx1import {2 // ...3 isPaymentSessionDeferred,4 isPaymentSessionReady,5 // ...6} from "@lib/constants"78// ...910 // 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 // ...1920// ...2122 if (23 !selectedPaymentMethod ||24 paidByGiftcard ||25 isPaymentSessionDeferred(selectedPaymentMethod)26 ) {27 return28 }2930// ...3132 const handleSelectPayment = async (providerId: string) => {33 setSelectedPaymentMethod(providerId)3435 // a deferred provider's pay button starts the session when the customer pays36 if (37 isPaymentSessionDeferred(providerId) ||38 !isPaymentSessionReady(providerId, cart)39 ) {40 return41 }
Эффект, который перезапускает пропавшую сессию, эффект, который запускает её заново при смене адресов, email, доставки или региона, и обработчик выбора способа теперь не трогают отложенный способ. Оформление заказа по-прежнему показывает Mollie выбранным, а когда создать платёж, решает кнопка оплаты.
Для каждого платёжного провайдера витрине нужен свой компонент кнопки оплаты. По данным платёжной сессии он отправляет покупателя на платёжную страницу Mollie. Сам заказ создаётся позже, когда покупатель вернётся через , или раньше по вебхуку, если тот придёт первым.
Создайте файл и добавьте следующий код:

src/modules/checkout/components/payment-button/providers/mollie.tsx1"use client"23import { 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"910const MOLLIE_PROVIDER_ID = "pp_mollie_mollie"1112const findSession = (payment_collection?: HttpTypes.StorePaymentCollection | null) =>13 payment_collection?.payment_sessions?.find((s) =>14 s.provider_id.startsWith("pp_mollie_")15 )1617/**18 * The address of the payment page of a session started earlier, unless it is19 * missing or Mollie has let the payment expire. A customer who went back from20 * Mollie's page without paying returns to the same page this way, instead of a21 * second payment Mollie would refuse to cancel. A card payment expires after22 * 30 minutes.23 */24const liveCheckoutUrl = (session?: HttpTypes.StorePaymentSession) => {25 const url = session?.data?.checkout_url26 const expiresAt = session?.data?.expires_at2728 if (typeof url !== "string" || !url) {29 return null30 }3132 if (typeof expiresAt === "string" && Date.parse(expiresAt) <= Date.now()) {33 return null34 }3536 return url37}3839/**40 * Mollie collects the payment on its own page. This button starts the payment41 * session, which creates the Mollie payment, and sends the customer to its42 * 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, whose44 * 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)5657 const handlePayment = async () => {58 setSubmitting(true)59 setErrorMessage(null)6061 try {62 const session = findSession(cart.payment_collection)63 let url = liveCheckoutUrl(session)6465 if (!url) {66 const providerId = session?.provider_id ?? MOLLIE_PROVIDER_ID67 const { payment_collection } = await initiatePaymentSession(cart, {68 provider_id: providerId,69 data: buildPaymentSessionData(providerId, cart),70 })7172 url = liveCheckoutUrl(findSession(payment_collection))73 }7475 if (!url) {76 throw new Error(t("paymentFailed"))77 }7879 window.location.assign(url)80 } catch (error) {81 setErrorMessage(error instanceof Error ? error.message : t("paymentFailed"))82 setSubmitting(false)83 }84 }8586 return (87 <PaymentButtonShell88 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}9899export default MolliePaymentButton
Когда покупатель нажимает кнопку оплаты, компонент запускает платёжную сессию с данными из , читает новой сессии Mollie и отправляет покупателя по этому адресу платить на странице Mollie.
У покупателя, который вернулся со страницы Mollie, не заплатив, уже есть сессия с открытым платежом. Кнопка отправляет его на ту же страницу, если срок не прошёл (карточный платёж истекает через 30 минут), и только иначе запускает новую сессию. Второй открытый платёж для той же корзины и предотвращает отложенная сессия, потому что первый Mollie отменить не даёт. Если адрес не пришёл, кнопка показывает сообщение об ошибке вместо перехода.
Импортируйте кнопку и в , затем добавьте для неё в :

src/modules/checkout/components/payment-button/index.tsx1import {2 isManual,3 isMollie,4 // ...5} from "@lib/constants"6// ...7import MolliePaymentButton from "./providers/mollie"89// ...1011 switch (true) {12 // ...13 case isMollie(activePaymentMethod):14 return <MolliePaymentButton {...providerProps} />15 // ...16 }
Родительский компонент с помощью определяет, показывать ли для текущей сессии, а иначе переходит к следующему варианту.
После оплаты, отмены или неудачи на странице Mollie покупатель возвращается на витрину. О статусе платежа Mollie сообщает и этим перенаправлением, и вебхуком, поэтому общий API-роут проверяет, нет ли у корзины заказа, прежде чем создать его сам.
Откройте и добавьте следующий код:

src/app/api/payment-return/route.ts1type ReturnContext = {2 cartId: string3 locale: string4 origin: string5}67/**8 * Comes back from a provider that takes the customer to its own payment page9 * 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 whichever12 * arrives first places the order, so the cart is checked for an order before13 * one is placed.14 */15async function redirectProviderReturn({16 cartId,17 locale,18 origin,19}: ReturnContext) {20 const cart = await sdk.client21 .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)2930 if (!cart) {31 return NextResponse.redirect(32 `${origin}/${locale}/cart?error=payment_failed`33 )34 }3536 const orderId = (cart as unknown as { order_link?: { order_id?: string } })37 .order_link?.order_id3839 if (orderId) {40 const orderCacheTag = await getCacheTag("orders")41 revalidateTag(orderCacheTag)42 await removeCartId()4344 return NextResponse.redirect(45 `${origin}/${locale}/order/${orderId}/confirmed`46 )47 }4849 // The customer backed out or the payment was declined, so they land back on50 // the checkout with their cart intact and can pick a method again.51 await setCartId(cartId)5253 try {54 await placeOrder(cartId)55 } catch (error) {56 unstable_rethrow(error)5758 return NextResponse.redirect(59 `${origin}/${locale}/checkout?error=payment_failed`60 )61 }6263 return NextResponse.redirect(64 `${origin}/${locale}/checkout?error=payment_failed`65 )66}
Затем в функции направьте возврат из Mollie в :
src/app/api/payment-return/route.ts1if (provider === "mollie") {2 return redirectProviderReturn({ cartId, locale, origin })3 }
Этот API-роут обрабатывает возврат из Mollie после попытки оплаты. Он всегда получает актуальное состояние корзины без кэша, чтобы не пропустить изменения, внесённые во время оплаты.
Если у корзины уже есть заказ, значит подтверждение уже пришло по вебхуку. API-роут обновляет кэш заказа, удаляет cookie корзины и отправляет покупателя на страницу подтверждения.
Иначе API-роут пытается создать заказ сам. При успехе покупатель попадает на страницу подтверждения заказа. Если покупатель отменил оплату на странице Mollie или платёж не прошёл, заказ не создаётся, и покупатель возвращается к оформлению заказа с , чтобы выбрать способ оплаты заново.
При оплате картой провайдер сохраняет последние четыре цифры карты в данных платежа как , так же, как провайдеры Stripe и Authorize.net от Gorgo. Страница заказа стартера показывает их только для Stripe.

src/modules/order/components/payment-details/index.tsx1const { title, subtitle } = paymentMethodLabel(tm, payment.provider_id)2 const card =3 (isStripeLike(payment.provider_id) || isMollie(payment.provider_id)) &&4 payment.data?.card_last45 // The card the order was actually paid with says more than the schemes the6 // method accepts, so it takes the subtitle when the payment carries one.7 const methodSubtitle = card ? `•••• ${card}` : subtitle
Теперь при оплате картой страница заказа показывает под Mollie . Для iDEAL, PayPal и других способов остаётся подзаголовок из локали.
Добавьте в и в каждую другую локаль под :
messages/ru.json1{2 "PaymentMethods": {3 // ...4 "pp_mollie_mollie": {5 "title": "Mollie",6 "subtitle": "Карты, iDEAL, PayPal"7 }8 }9}
Если для локали нет записи, используются английские и из в Шаге 1.
Полный код витрины находится в , собранной на основе Medusa DTC Starter от Gorgo. В README примера перечислены все файлы, из которых состоит интеграция, включая бэкенд.