При спробі підключити кастомний платіжний шлюз у Medusa.js розробники часто стикаються з неочевидними помилками: після capturePayment статус сесії залишається pending, хоча провайдер підтвердив списання. Корінь — невірний маппінг статусів або відсутність виклику authorizePaymentService. Medusa документація підкреслює важливість точного маппінгу. Наш досвід — понад 50 інтеграцій для Stripe, PayPal, Klarna та десятка кастомних шлюзів. У цій статті — перевірена архітектура та типові рішення, які допоможуть уникнути цих проблем. Кастомний провайдер дає в 3 рази більше гнучкості порівняно з готовим плагіном, а час розробки — всього 2-4 дні для базового сценарію. Отримайте консультацію по вашому кейсу — просто напишіть нам. Ми надішлемо терміни та комерційну пропозицію протягом дня.
Чому статус payment залишається pending після capture?
Часта причина — невідповідність етапів. Medusa очікує, що після initiatePayment буде викликаний authorizePayment, потім capturePayment. Якщо провайдер одразу списує кошти (однофазна оплата), необхідно налаштувати маппінг: при отриманні статусу succeeded від провайдера повернути AUTHORIZED, а потім негайно викликати capturePayment. Інакше статус зависає в pending. У нашій практиці 70% клієнтів з двофазними шлюзами стикаються з цією проблемою на перших етапах інтеграції.
Як влаштований платіжний провайдер у Medusa.js
Кожен провайдер — це клас, що успадковує AbstractPaymentProvider та реалізує обов'язкові методи. Medusa викликає їх у строгій послідовності: initiatePayment → authorizePayment → capturePayment (або 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 робочих днів залежно від складності. Вартість фіксується після аналізу.







