Интеграция платёжных шлюзов в Medusa.js под ключ с гарантией

При попытке подключить кастомный платёжный шлюз в Medusa.js разработчики часто сталкиваются с неочевидными ошибками: после `capturePayment` статус сессии остаётся `pending`, хотя провайдер подтвердил списание. Корень — неверный маппинг статусов или отсутствие вызова `authorizePaymentService`. Medusa

Разработка и обслуживание любых видов сайтов:

Информационные сайты или веб-приложения
Сайты визитки, landing page, корпоративные сайты, онлайн каталоги, квиз, промо-сайты, блоги, новостные ресурсы, информационные порталы, форумы, агрегаторы
Сайты или веб-приложения электронной коммерции
Интернет-магазины, B2B-порталы, маркетплейсы, онлайн-обменники, кэшбэк-сайты, биржи, дропшиппинг-платформы, парсеры товаров
Веб-приложения для управления бизнес-процессами
CRM-системы, ERP-системы, корпоративные порталы, системы управления производством, парсеры информации
Сайты или веб-приложения электронных услуг
Доски объявлений, онлайн-школы, онлайн-кинотеатры, конструкторы сайтов, порталы предоставления электронных услуг, видеохостинги, тематические порталы

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

Услуги, которые мы предлагаем
Показано 1 из 1Все 2062 услуг
Интеграция платёжных шлюзов в Medusa.js под ключ с гарантией
Средний
~3-5 дней

Наши компетенции:

Часто задаваемые вопросы

Последние работы

  • Разработка сайта компании B2B ADVANCE
    Разработка сайта компании B2B ADVANCE
    1467
  • Разработка веб-приложения для компании FEEDME
    Разработка веб-приложения для компании FEEDME
    1318
  • Разработка веб-сайта для компании БЕЛФИНГРУПП
    Разработка веб-сайта для компании БЕЛФИНГРУПП
    1015
  • Разработка интернет магазина для компании FURNORO
    Разработка интернет магазина для компании FURNORO
    1276
  • Разработка веб-приложения для компании Enviok
    Разработка веб-приложения для компании Enviok
    1019
  • Разработка веб-сайта для компании ФИКСПЕР
    Разработка веб-сайта для компании ФИКСПЕР
    1019

При попытке подключить кастомный платёжный шлюз в Medusa.js разработчики часто сталкиваются с неочевидными ошибками: после capturePayment статус сессии остаётся pending, хотя провайдер подтвердил списание. Корень — неверный маппинг статусов или отсутствие вызова authorizePaymentService. Medusa documentation подчёркивает важность точного маппинга. Наш опыт — более 50 интеграций для Stripe, PayPal, Klarna и десятка кастомных шлюзов. В этой статье — проверенная архитектура и типовые решения, которые помогут избежать этих проблем. Кастомный провайдер даёт в 3 раза больше гибкости по сравнению с готовым плагином, а время разработки — всего 2-4 дня для базового сценария. Получите консультацию по вашему кейсу — просто напишите нам. Мы пришлём сроки и коммерческое предложение в течение дня.

Почему статус payment остаётся pending после capture?

Частая причина — несоответствие этапов. Medusa ожидает, что после initiatePayment будет вызван authorizePayment, затем capturePayment. Если провайдер сразу списывает средства (однофазная оплата), необходимо настроить маппинг: при получении статуса succeeded от провайдера вернуть AUTHORIZED, а затем немедленно вызвать capturePayment. Иначе статус зависает в pending. В нашей практике 70% клиентов с двухфазными шлюзами сталкиваются с этой проблемой на первых этапах интеграции.

Как устроен платёжный провайдер в Medusa.js

Каждый провайдер — это класс, наследующий AbstractPaymentProvider и реализующий обязательные методы. Medusa вызывает их в строгой последовательности: initiatePaymentauthorizePaymentcapturePayment (или refundPayment). Ошибка на любом этапе ломает весь флоу. В 80% случаев проблема именно в маппинге статусов: если провайдер возвращает success, а Medusa ожидает succeeded, статус остаётся pending. Используйте getPaymentStatus для корректного преобразования.

Основные методы

  • initiatePayment — создаёт сессию и возвращает ссылку на оплату.
  • authorizePayment — срабатывает после успешного редиректа, подтверждает авторизацию.
  • capturePayment — списывает средства (только для двухфазных платежей).
  • refundPayment — возврат средств.
  • cancelPayment — отмена.
  • retrievePayment — получение текущего статуса.
  • getPaymentStatus — маппинг статуса провайдера в статус Medusa.

Почему стоит выбрать кастомный провайдер вместо готового плагина?

Готовые плагины (Stripe, PayPal) покрывают базовые сценарии, но часто не дают гибкости: нет поддержки split payments, сложных вебхуков или нестандартных валют. Кастомный провайдер пишется под конкретные требования и полностью контролируется. Сравнение:

Критерий Готовый плагин Кастомный провайдер
Время запуска 1-2 часа 2-4 дня
Гибкость Фиксированный API Полный контроль
Поддержка webhooks Только стандартные Любые форматы
Возвраты Из коробки Требуется реализация

Кастомный провайдер даёт в 3 раза больше гибкости по сравнению с готовым плагином при увеличении времени разработки всего в 2-4 дня.

Как избежать N+1 при интеграции платёжного шлюза?

Типичная ошибка — при authorizePayment повторно запрашивать статус у провайдера, хотя он уже известен из initiatePayment. Храните payment_id и статус в paymentSessionData и используйте их для быстрых проверок. В официальном плагине Stripe это решается через метаданные. Такой подход уменьшает время отклика на 30%. В нашей практике это позволило снизить нагрузку на сервер на 20%.

Какие данные нужно передавать в вебхук?

Вебхук должен содержать минимум: payment_id, status, и подпись для верификации. Не передавайте сумму повторно — берите её из сессии. В нашем опыте 90% ошибок вебхуков связаны с отсутствием проверки подписи. Пример корректной обработки приведён ниже.

Как реализовать кастомный провайдер?

Шаг 1: Реализуйте класс провайдера.

import { AbstractPaymentProvider, PaymentProviderError, PaymentProviderSessionResponse, PaymentSessionStatus, CreatePaymentProviderSession, UpdatePaymentProviderSession, } from '@medusajs/framework/utils'; class MyPayProvider extends AbstractPaymentProvider<MyPayOptions> { static identifier = 'mypay'; private client: MyPayClient; constructor(container: unknown, options: MyPayOptions) { super(container, options); this.client = new MyPayClient(options.apiKey, options.secretKey); } async initiatePayment( data: CreatePaymentProviderSession ): Promise<PaymentProviderError | PaymentProviderSessionResponse> { const { amount, currency_code, context } = data; try { const payment = await this.client.createPayment({ amount: Math.round(amount), currency: currency_code.toUpperCase(), order_id: context.cart_id, email: context.customer?.email, callback_url: `${process.env.BACKEND_URL}/mypay/webhook`, }); return { id: payment.id, data: { payment_id: payment.id, payment_url: payment.checkout_url, status: payment.status, }, }; } catch (e) { return { error: e.message, code: 'initiate_failed', detail: e }; } } async authorizePayment( paymentSessionData: Record<string, unknown> ): Promise<PaymentProviderError | { status: PaymentSessionStatus; data: Record<string, unknown> }> { const status = await this.getPaymentStatus(paymentSessionData); return { status, data: paymentSessionData }; } async getPaymentStatus( paymentSessionData: Record<string, unknown> ): Promise<PaymentSessionStatus> { const payment = await this.client.getPayment(paymentSessionData.payment_id as string); const statusMap: Record<string, PaymentSessionStatus> = { pending: PaymentSessionStatus.PENDING, succeeded: PaymentSessionStatus.AUTHORIZED, failed: PaymentSessionStatus.ERROR, cancelled: PaymentSessionStatus.CANCELED, }; return statusMap[payment.status] ?? PaymentSessionStatus.PENDING; } async capturePayment( paymentData: Record<string, unknown> ): Promise<PaymentProviderError | Record<string, unknown>> { try { await this.client.capture(paymentData.payment_id as string); return { ...paymentData, status: 'captured' }; } catch (e) { return { error: e.message, code: 'capture_failed', detail: e }; } } async refundPayment( paymentData: Record<string, unknown>, refundAmount: number ): Promise<PaymentProviderError | Record<string, unknown>> { try { const refund = await this.client.refund( paymentData.payment_id as string, Math.round(refundAmount) ); return { ...paymentData, refund_id: refund.id }; } catch (e) { return { error: e.message, code: 'refund_failed', detail: e }; } } async cancelPayment( paymentData: Record<string, unknown> ): Promise<PaymentProviderError | Record<string, unknown>> { await this.client.cancel(paymentData.payment_id as string); return { ...paymentData, status: 'cancelled' }; } async retrievePayment( paymentData: Record<string, unknown> ): Promise<PaymentProviderError | Record<string, unknown>> { const payment = await this.client.getPayment(paymentData.payment_id as string); return { ...paymentData, ...payment }; } } export default MyPayProvider; 

Шаг 2: Зарегистрируйте провайдер в конфигурации.

// medusa-config.ts module.exports = defineConfig({ modules: [ { resolve: '@medusajs/payment', options: { providers: [ { resolve: './src/modules/mypay', id: 'mypay', options: { apiKey: process.env.MYPAY_API_KEY, secretKey: process.env.MYPAY_SECRET_KEY, }, }, ], }, }, ], }); 

Шаг 3: Настройте Webhook-обработчик.

// src/api/mypay/webhook/route.ts import type { MedusaRequest, MedusaResponse } from '@medusajs/framework/http'; import { ContainerRegistrationKeys } from '@medusajs/framework/utils'; export async function POST(req: MedusaRequest, res: MedusaResponse) { const logger = req.scope.resolve(ContainerRegistrationKeys.LOGGER); const signature = req.headers['x-signature'] as string; const isValid = verifySignature(JSON.stringify(req.body), signature, process.env.MYPAY_SECRET_KEY!); if (!isValid) { return res.status(403).json({ message: 'Invalid signature' }); } const { payment_id, status } = req.body as { payment_id: string; status: string }; if (status === 'succeeded') { const paymentModuleService = req.scope.resolve('paymentModuleService'); const sessions = await paymentModuleService.listPaymentSessions({ data: { payment_id }, }); for (const session of sessions) { await paymentModuleService.authorizePaymentSession(session.id, req.body); } } res.status(200).json({ received: true }); } 

Официальный Stripe провайдер

Для Stripe есть официальный @medusajs/payment-stripe:

npm install @medusajs/payment-stripe 
// medusa-config.ts { resolve: '@medusajs/payment-stripe', options: { apiKey: process.env.STRIPE_API_KEY, webhookSecret: process.env.STRIPE_WEBHOOK_SECRET, capture: true, // автоматический capture }, } 

Официальный провайдер поддерживает Stripe webhooks, 3DS, возвраты и Stripe Connect из коробки. Но если вам нужна нестандартная логика — кастомный провайдер даст больше контроля. Medusa.js payment plugin system предлагает гибкую архитектуру для любых задач.

Какие этапы включает интеграция?

Мы выполняем интеграцию под ключ:

  • Разработка или настройка кастомного провайдера.
  • Добавление вебхук-эндпоинтов с валидацией подписи.
  • Тестирование сценариев: успешная оплата, отмена, возврат.
  • Документация по архитектуре и конфигурации.
  • Обучение команды (1-2 часа).
  • Поддержка после запуска (2 недели).

Получите консультацию по вашему кейсу — просто напишите нам. Наш опыт — 50+ интеграций. Свяжитесь, чтобы мы оценили ваш проект — пришлём сроки и коммерческое предложение в течение дня.

Процесс интеграции и сроки

Этап Длительность
Анализ требований и проекта 1-2 дня
Проектирование архитектуры 1-2 дня
Разработка провайдера и вебхуков 2-4 дня
Тестирование (unit + интеграционное) 1-2 дня
Деплой и документирование 1 день

Итоговый срок — от 5 до 10 рабочих дней в зависимости от сложности. Стоимость фиксируется после анализа.