Как создать собственный платёжный модуль на PHP для интеграции с любой системой

Вы написали свой интернет-магазин или CRM на PHP, и теперь нужно принимать платежи. Готовые решения не подходят — то API не те, то негибкие, то слишком громоздкие. Хочется сделать свой модуль, который встроится куда угодно и будет работать именно так, как вам нужно. Это вполне реальная задача, если понимать, как устроены платёжные протоколы и на какие грабли чаще всего наступают.

Ниже — практический путь от идеи до рабочего модуля. Без воды, с конкретными примерами на PHP и объяснением, почему каждый шаг именно такой.

Что на самом деле делает платёжный модуль

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

  • Формирование запроса на оплату — собираете данные о заказе, сумме, валюте и отправляете их в платёжный шлюз.
  • Обработка ответа шлюза — получаете результат, проверяете подпись, обновляете статус заказа.
  • Приём уведомлений (callback/webhook) — шлюз сообщает о смене статуса платежа асинхронно, без участия пользователя.
  • Возвраты и частичные списания — если нужно вернуть деньги клиенту или скорректировать сумму.
  • Логирование и обработка ошибок — всё, что поможет разобраться, почему платёж не прошёл.

Хорошая новость: большая часть этого однотипна от шлюза к шлюзу. Плохая: у каждого свои нюансы в формате подписи, полях и поведении webhook’ов.

Выбор подхода: универсальный интерфейс или под конкретный шлюз

Здесь сразу два пути, и выбор зависит от вашей задачи.

Вариант 1: Пишем под конкретный шлюз. Если вы точно знаете, что проект будет работать с одним провайдером (например, ЮKassa, Stripe, CloudPayments), нет смысла абстрагироваться. Вы читаете их документацию, реализуете их протокол — и всё работает. Быстрее, проще, меньше сюрпризов.

Вариант 2: Делаем универсальный модуль с адаптерами. Если продукт продаётся разным клиентам, и каждый хочет свой шлюз — нужна абстракция. Вы определяете общий интерфейс, а под каждый шлюз пишете отдельный адаптер. Это архитектурно правильнее, но требует больше кода и тестирования.

В реальности чаще всего начинают с одного шлюза, а потом выделяют интерфейс, когда появляется второй. Это нормально — не стоит строить «универсальный платёжный фреймворк» заранее.

Архитектура модуля: на что разбить код

Минимальная структура, которая позволяет поддерживать несколько шлюзов и не сойти с ума:

Payment/
  Contracts/
    PaymentGatewayInterface.php
    PaymentRequest.php
    PaymentResponse.php
  Gateways/
    StripeGateway.php
    YooKassaGateway.php
    CloudPaymentsGateway.php
  Services/
    PaymentLogger.php
    WebhookVerifier.php
  Exceptions/
    PaymentException.php
    InvalidSignatureException.php
  PaymentService.php

Ключевая идея — PaymentService не знает, какой шлюз используется. Он работает через интерфейс. Это значит, что основная логика приложения (создание заказа, обработка результата, отправка уведомлений клиенту) не меняется при смене провайдера.

Интерфейс шлюза: что он должен уметь

Вот минимальный контракт, которого достаточно для большинства сценариев:

interface PaymentGatewayInterface
{
    public function createPayment(PaymentRequest $request): PaymentResponse;
    public function capture(string transactionId, intamount): PaymentResponse;
    public function refund(string transactionId, intamount): PaymentResponse;
    public function handleWebhook(array headers, stringbody): WebhookResult;
}

Каждый метод возвращает объект ответа с полями: статус, ID транзакции, сумма, причина отказа (если есть), сырой ответ шлюза. Это удобно — вы всегда можете залогировать оригинальный ответ, если что-то пойдёт не так.

Пошагово: создаём модуль на примере Stripe

Возьмём Stripe как пример — у них понятное API и хорошая документация. Принцип тот же для любого другого шлюза.

Шаг 1. Установка SDK и базовая настройка

composer require stripe/stripe-php

В конструктор адаптера передаём секретный ключ и, опционально, webhook-секрет:

class StripeGateway implements PaymentGatewayInterface
{
    private \Stripe\Client $client;
    private string $webhookSecret;

    public function __construct(string secretKey, stringwebhookSecret)
    {
        this->client = new StripeClient(secretKey);
        this->webhookSecret =webhookSecret;
    }
}

Шаг 2. Создание сессии оплаты

Stripe работает через Checkout Sessions — вы создаёте сессию, получаете URL и перенаправляете туда пользователя:

public function createPayment(PaymentRequest $request): PaymentResponse
{
    try {
        session =this->client->checkout->sessions->create([
            'payment_method_types' => ['card'],
            'line_items' => [[
                'price_data' => [
                    'currency' => $request->getCurrency(),
                    'product_data' => [
                        'name' => $request->getDescription(),
                    ],
                    'unit_amount' => $request->getAmountInMinorUnits(),
                ],
                'quantity' => 1,
            ]],
            'mode' => 'payment',
            'success_url' => $request->getSuccessUrl() . '?session_id={CHECKOUT_SESSION_ID}',
            'cancel_url' => $request->getCancelUrl(),
            'metadata' => [
                'order_id' => $request->getOrderId(),
            ],
        ]);

        return new PaymentResponse(
            status: 'pending',
            transactionId: $session->id,
            redirectUrl: $session->url,
        );
    } catch (\Stripe\Exception\ApiErrorException $e) {
        throw new PaymentException('Stripe error: ' . $e->getMessage());
    }
}

Обратите внимание: сумма передаётся в копейках/центах (minor units). Это стандартная практика — никогда не передавайте деньги как числа с плавающей точкой.

Шаг 3. Обработка webhook — самое важное

Пользователь может закрыть вкладку после оплаты. Вы узнаете о результате только через webhook. И здесь главное — проверить подпись, иначе любый желающий сможет «подтвердить» оплату вашего заказа.

public function handleWebhook(array headers, stringbody): WebhookResult
{
    sigHeader =headers['Stripe-Signature'] ?? '';

    try {
        $event = \Stripe\Webhook::constructEvent(
            $body,
            $sigHeader,
            $this->webhookSecret
        );
    } catch (\UnexpectedValueException $e) {
        throw new InvalidSignatureException('Invalid payload');
    } catch (\Stripe\Exception\SignatureVerificationException $e) {
        throw new InvalidSignatureException('Invalid signature');
    }

    return match ($event->type) {
        'checkout.session.completed' => this->handleSessionCompleted(event->data->object),
        'charge.refunded' => this->handleRefund(event->data->object),
        default => new WebhookResult(ignored: true),
    };
}

private function handleSessionCompleted(\Stripe\CheckoutSession $session): WebhookResult
{
    orderId =session->metadata['order_id'];

    if ($session->payment_status === 'paid') {
        return new WebhookResult(
            status: 'completed',
            orderId: $orderId,
            transactionId: $session->payment_intent,
        );
    }

    return new WebhookResult(status: 'pending', orderId: $orderId);
}

Важный момент: webhook может придти несколько раз. Обработчик должен быть идемпотентным — если заказ уже оплачен, повторное уведомление не должно ломать логику.

Шаг 4. Возврат средств

public function refund(string transactionId, intamount): PaymentResponse
{
    try {
        refund =this->client->refunds->create([
            'payment_intent' => $transactionId,
            'amount' => $amount,
        ]);

        return new PaymentResponse(
            status: $refund->status === 'succeeded' ? 'refunded' : 'refund_pending',
            transactionId: $refund->id,
        );
    } catch (\Stripe\Exception\ApiErrorException $e) {
        throw new PaymentException('Refund failed: ' . $e->getMessage());
    }
}

Сравнение популярных шлюзов для PHP-интеграции

Если вы выбираете, с каким провайдером работать, вот что реально влияет на разработку:

Параметр Stripe ЮKassa CloudPayments
PHP SDK Официальный, хорошо документирован Есть официальный, но менее удобный Нет официального SDK, работа через HTTP
Webhook подпись HMAC-SHA256, встроенная проверка MD5-хэш из полей уведомления HMAC-SHA256 из заголовка
Способ оплаты Checkout Session / Payment Intents POST-запрос на создание платежа CloudPayments Checkout / Apple Pay
Возвраты Через API, поддержка частичных Через API, нужен статус магазина Через API, частичные через отдельный метод
Документация для разработчика Отличная, с примерами кода Средняя, много устаревших примеров Средняя, не хватает примеров на PHP
Регион работы Международный Россия, СНГ Россия

Чего делать в зависимости от вашей ситуации

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

Вы делаете платформенное решение (SaaS, конструктор магазинов).
Сразу закладывайте интерфейс и систему адаптеров. Храните настройки шлюза (ключи, идентификаторы) в базе для каждого клиента. Сделайте отдельный класс для маршрутизации — какой клиент каким шлюзом пользуется.

Нужна поддержка Apple Pay / Google Pay.
Stripe и CloudPayments поддерживают это из коробки. Для ЮKassa нужна дополнительная интеграция. Если это критично — учитывайте на этапе выбора шлюза.

Проект работает с российскими картами.
Stripe не работает с картами российских банков. Ваш выбор — ЮKassa, CloudPayments, Робокасса или Тинькофф Эквайринг.

Частые ошибки, которые ломают интеграцию

Не проверяется подпись webhook. Это дыра в безопасности. Любой может отправить POST-запрос на ваш endpoint и подтвердить неоплаченный заказ. Всегда проверяйте подпись перед обработкой.

Сумма передаётся как float. 99.99 в PHP может превратиться в 99.98999999999999. Передавайте сумму в копейках как целое число: 9999. Это касается всех шлюзов без исключения.

Нет идемпотентности при обработке webhook. Шлюз может отправить уведомление дважды. Если ваш код каждый раз начисляет бонус или отправляет письмо — будут дубли. Проверяйте статус заказа перед действиями.

Секретные ключи хранятся в коде или Git. Используйте переменные окружения. Один случайный коммит с ключом в репозитории — и ваши платежи под угрозой.

Нет таймаутов при запросах к API шлюза. Если шлюз «завис», ваш сервер тоже повиснет. Всегда задавайте timeout и connect_timeout в HTTP-клиенте. Разумное значение — 10–15 секунд.

Практические рекомендации

  1. Логируйте всё. Каждый запрос к шлюзу и каждый ответ должны записываться в лог. Когда клиент говорит «деньги списаны, но заказ не подтверждён» — логи единственное, что поможет разобраться.
  2. Используйте уникальные идентификаторы заказов. Передавайте свой ID заказа в metadata / description платежа. Это свяжет транзакцию шлюза с вашим заказом.
  3. Обрабатывайте статусы правильно. Не ставьте заказ «оплачен» сразу после создания сессии. Только после подтверждения от webhook с корректной подписью.
  4. Делайте тестовые платежи. У всех шлюзов есть тестовый режим и тестовые карты. Не депойте в боевую среду, пока не прогнали полный цикл: создание → оплата → webhook → отмена → возврат.
  5. Обновляйте SDK. Платёжные провайдеры периодически меняют API. Раз в пару месяцев проверяйте релизы и обновляйте зависимости.

Как встроить модуль в любую систему

Если модуль правильно спроектирован, его интеграция в существующий проект сводится к нескольким шагам:

  1. Подключите модуль через Composer или скопируйте файлы.
  2. Создайте конфигурационный файл с ключами и настройками шлюза.
  3. В контроллере создания заказа вызовите PaymentService::createPayment() и перенаправьте пользователя на полученный URL.
  4. Добавьте публичный endpoint для webhook’ов и пропишите его URL в личном кабинете шлюза.
  5. В обработчике webhook вызовите PaymentService::handleWebhook() и обновите статус заказа.

Endpoint для webhook’ов не должен требовать авторизации — шлюз приходит сам. Но он должен быть защищён проверкой подписи. Это единственный способ убедиться, что запрос действительно от вашего провайдера.

Итог

Создать собственный платёжный модуль на PHP — задача не сложная, если разбить её на части: интерфейс, адаптеры под шлюзы, обработка webhook’ов, логирование. Главное — не экономить на проверке подписи, не передавать суммы как float и всегда обрабатывать асинхронные уведомления от шлюза.

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

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

Platejigid.ru