Оплата заказа

Оформление заказа у нас уже есть, пора добавить оплату.
Здесь мы заложим общие правила работы оплаты, причём сразу двумя способами: через редирект, и через вывод QR кода для сканирования.
Напишем интерфейс для платёжных провайдеров с общими методами, которые они должны будут реализовывать.
За оплату с редиректом у нас отдувается Yookassa, а за приём платежей через СБП банк Raiffeisen.

Модель Payment

Начинаем, как обычно, с новой миграции для создания таблицы платежей:
<?php

declare(strict_types=1);

use Illuminate\Database\Schema\Blueprint;
use Vesp\Services\Migration;

final class Payments extends Migration
{
    public function up(): void
    {
        $this->schema->create(
            'payments',
            static function (Blueprint $table) {
                $table->id();
                // Привязка к заказу
                $table->foreignId('order_id')
                    ->constrained('orders')->cascadeOnDelete();
                // Привязка к юзеру
                $table->foreignId('user_id')
                    ->constrained('users')->cascadeOnDelete();
                // Сюда будем писать название сервиса оплаты
                $table->string('service');
                $table->decimal('amount');
                $table->boolean('paid')->nullable()->index();
                // Готовая ссылка для оплаты от сервиса
                $table->string('link')->nullable();
                // Связь с операцией на сервисе
                $table->string('remote_id')->nullable()->index();
                $table->timestamps();
                $table->timestamp('paid_at')->nullable();
            }
        );
    }

    public function down(): void
    {
        $this->schema->drop('payments');
    }
}
Булева колонка paid показывает статус оплаты и может иметь 3 значения:
  • true - оплачено
  • false - отменено или ошибка
  • null - пусто, надо запрашивать статус от сервиса
Соответственно этой таблице пишем и модель Payment:
<?php

namespace App\Models;

use App\Interfaces\Payment as PaymentInterface;
// ...

class Payment extends Model
{
    protected $casts = [
        'amount' => 'float',
        'paid' => 'boolean',
        'paid_at' => 'datetime',
    ];
    protected $guarded = ['paid', 'paid_at'];

    public function order(): BelongsTo
    {
        return $this->belongsTo(Order::class);
    }

    public function user(): BelongsTo
    {
        return $this->belongsTo(User::class);
    }

    // Динамическое получение сервиса оплаты
    // Если нужного сервиса нет - будет ошибка
    protected function getService(): PaymentInterface
    {
        $service = '\App\Services\\' . ucfirst($this->service);

        return new $service();
    }

    // Проверка статуса оплаты
    public function checkStatus(): ?bool
    {
        // Работает только при неизвестном статусе
        if ($this->paid === null) {
            $timeout = getenv('PAYMENT_TIMEOUT') ?: 24;
            // Получаем сервис
            $service = $this->getService();
            // Спрашиваем у него статус
            $status = $service->getPaymentStatus($this);
            // Это если оплачено
            if ($status === true) {
                $this->paid = true;
                $this->paid_at = time();
                $this->save();
            } elseif ($status === false || $this->created_at->addHours($timeout) < 
Carbon::now()) {
                // Это если не оплачено, или просто больше 24 часов с момента заказа
                $this->paid = false;
                $this->save();
            }
        }

        // И возврат статуса: true, false или null
        return $this->paid;
    }

    // Генерация ссылки на оплату, она может быть также и картинкой
    public function getLink(): ?array
    {
        if ($link = $this->getService()->makePayment($this)) {
            return strpos($link, 'data:image/') === 0
                ? ['qr' => $link]
                : ['redirect' => $link];
        }

        return null;
    }
}
Как видно, в этой модели есть 2 публичных метода, использующих сервис оплаты:
  • генерация ссылки на оплату
  • проверка статуса оплаты
Оба они предполагают загрузку сервиса оплаты через внутренний метод getService(), который возвращает инстанс App\Interfaces\Payment.

Интерфейс Payment

Для того, чтобы все сервисы содержали нужные нам методы, прописываем новый интерфейс core/src/Interfaces/Payment.php:
<?php

namespace App\Interfaces;

interface Payment
{
    public function makePayment(\App\Models\Payment $payment): ?string;

    public function getPaymentStatus(\App\Models\Payment $payment): ?bool;

    public function getSuccessUrl(\App\Models\Payment $payment): string;
}
И теперь, когда мы будем писать конкретный метод оплаты, он должен реализовывать этот интерфейс:
<?php

namespace App\Services;

class MyService implements \App\Interfaces\Payment 
{
    // ...
}
И тогда мы точно знаем, что в этом классе есть все 3 нужных нам метода.
Переходим к сервисам оплаты.

Сервис Yookassa

Несмотря на то, что многие платёжные шлюзы предлагают свои библиотеки, я не хочу их использовать - мне больше нравится работать с пакетом для формирования и отправки HTTP запросов guzzlehttp/guzzle.
Он очень часто пригождается для межсерверных операций, и уж лучше освоить работу с ним (вместе с документацией шлюза), чем разбираться с готовыми библиотеками.
Так что добавляем полезную зависимость:
composer require guzzlehttp/guzzle
И пишем новый сервис Services/Yookassa:
<?php

namespace App\Services;

use App\Models\Payment;
use GuzzleHttp\Client;
use Ramsey\Uuid\Uuid;

class Yookassa implements \App\Interfaces\Payment
{
    protected Client $client;

    // Инициализируем клиент для запросов
    public function __construct()
    {
        $this->client = new Client([
            // Базовый адрес для запросов
            'base_uri' => getenv('PAYMENT_YOOKASSA_ENDPOINT'),
            // Авторизация
            'auth' => [
                getenv('PAYMENT_YOOKASSA_USER'),
                getenv('PAYMENT_YOOKASSA_PASSWORD'),
            ],
        ]);
    }

    // Метод создания платежа
    public function makePayment(Payment $payment): ?string
    {
        // Получаем адрес для возврата на сайт
        $url = $this->getSuccessUrl($payment);

        // Запрос методом POST
        $response = $this->client->post('payments', [
            // В заголовок включаем ключ идемпотентности, см. ниже
            'headers' => ['Idempotence-Key' => (string)Uuid::uuid5(Uuid::NAMESPACE_URL, $url)],
            // И JSON с нужными параметрами
            'json' => [
                // Стоимость заказа
                'amount' => [
                    'value' => $payment->amount,
                    'currency' => 'RUB',
                ],
                // Тип подтверждения от юзера вместе с url возврата
                'confirmation' => [
                    'type' => 'redirect',
                    'return_url' => $url,
                ],
                // Оплата в один этап
                'capture' => true,
                // Описание и метаданные на всякий случай
                'description' => $payment->order->num,
                'metadata' => [
                    'payment_id' => $payment->id,
                    'order_id' => $payment->order->id,
                ],
            ],
        ]);
        // Декодируем ответ от сервиса
        $output = json_decode((string)$response->getBody(), true);
        if (!empty($output['id'])) {
            // Сохраняем id и ссылку на оплату от сервиса
            $payment->remote_id = $output['id'];
            $payment->link = $output['confirmation']['confirmation_url'];
            $payment->save();

            // Возвращаем ссылку юзеру
            return $payment->link;
        }

        return null;
    }

    // Проверка статуса платежа
    public function getPaymentStatus(Payment $payment): ?bool
    {
        try {
            $url = $this->getSuccessUrl($payment);
            // Запрос методом GET с указанием remote_id
            $response = $this->client->get('payments/' . $payment->remote_id, [
                'headers' => ['Idempotence-Key' => (string)Uuid::uuid5(Uuid::NAMESPACE_URL, $url)],
            ]);
            $output = json_decode((string)$response->getBody(), true);

            // Оплата прошла успешно
            if ($output['status'] === 'succeeded') {
                return true;
            }
            // Оплата отменена
            if ($output['status'] === 'canceled') {
                return false;
            }
        } catch (\Throwable $e) {
            // Исключения игнорируем, потому что проверок может быть много, а ошибки нас не интересуют
            // По истечению таймаута в 24 часа платежу будет выставлен статус false в любом случае
        }

        return null;
    }

    // Ссылка на страницу заказа на нашем сайте
    public function getSuccessUrl(Payment $payment): string
    {
        return rtrim(getenv('SITE_URL'), '/') . '/orders/' . $payment->order->uuid;
    }
}
Как видно, в сервисе все 3 метода, которые требует реализовать интерфейс, плюс инициализация клиента Guzzle для создания запросов.
Для правильной работы мы должны задать переменные окружения в .env:
PAYMENT_YOOKASSA_ENDPOINT=https://api.yookassa.ru/v3/
PAYMENT_YOOKASSA_USER=123456
PAYMENT_YOOKASSA_PASSWORD=test_2jImUXswetWetwg4...
Конечно, вам нужно зарегистрироваться на Yookassa и получить тестовый логин с паролем для магазина.
Yookassa требует все запросы сопровождать ключом идемпотентности, который в её примерах, почему-то, генерируется случайный. Я же генерирую его из url для возврата на сайт, который уникален и повторяем для каждого заказа, потому что содержит его uuid.
Без комментариев весь сервис оплаты занимает 82 строки - вот почему мне не нравится устанавливать платёжные библиотеки.

Сервис Raiffeisen

Здесь мы так же использум Guzzle, для реализации тех же методов, но с небольшим отличием - возвращать мы будем не ссылку на редирект, а картинку с QR кодом.
Для генерации этих картинок устанавливаем еще одну мега-популярную библиотеку:
composer require bacon/bacon-qr-code
Пишем Services/Raiffeisen:
<?php

namespace App\Services;

// ...

class Raiffeisen implements \App\Interfaces\Payment
{
    protected Client $client;

    public function __construct()
    {
        $this->client = new Client(['base_uri' => getenv('PAYMENT_RAIFFEISEN_ENDPOINT')]);
    }

    public function makePayment(Payment $payment): ?string
    {
        // Отправляем нужные данные в JSON методом POST
        $response = $this->client->post('qrs', [
            'json' => [
                'qrType' => 'QRDynamic',
                'amount' => (string)$payment->amount,
                'currency' => 'RUB',
                'order' => $payment->order->uuid,
                'qrExpirationDate' => Carbon::now()->addDay()->toIso8601String(),
                // Авторизации нет, только ключ магазина
                'sbpMerchantId' => getenv('PAYMENT_RAIFFEISEN_ID'),
                'redirectUrl' => $this->getSuccessUrl($payment),
            ],
        ]);
        // Получаем ответ
        $output = json_decode((string)$response->getBody(), true);
        if (!empty($output['qrId'])) {
            // Сохраняем id и ссылку на оплату от сервиса
            $payment->remote_id = $output['qrId'];
            $payment->link = $output['payload'];
            $payment->save();

            // А в ответ выдаём закодированную картинку
            return $this::getQR($payment);
        }

        return null;
    }

    // Проверка оплаты
    public function getPaymentStatus(Payment $payment): ?bool
    {
        try {
            // Запрос методом GET
            $response = $this->client->get('qrs/' . $payment->remote_id, [
                // Здесь уже требуется указать ключ API
                'headers' => ['Authorization' => 'Bearer ' . getenv('PAYMENT_RAIFFEISEN_KEY')],
            ]);
            $output = json_decode((string)$response->getBody(), true);
            // Обарабатываем ответ
            if ($output['qrStatus'] === 'PAID') {
                return true;
            }
            if (in_array($output['qrStatus'], ['CANCELLED', 'EXPIRED'])) {
                return false;
            }
        } catch (\Throwable $e) {
        }

        return null;
    }

    // Метод генерации картинки из ссылки
    public static function getQR(Payment $payment): string
    {
        // Просто смотрим пример в доке bacon/bacon-qr-code и повторяем
        $renderer = new ImageRenderer(
            new RendererStyle(400),
            new SvgImageBackEnd()
        );
        $svg = (new Writer($renderer))->writeString($payment->link);

        // Кодируем SVG в base64
        return 'data:image/svg+xml;base64,' . base64_encode($svg);
    }

    // То же самое, что и в методе Yookassa
    public function getSuccessUrl(Payment $payment): string
    {
        return rtrim(getenv('SITE_URL'), '/') . '/orders/' . $payment->order->uuid;
    }
}
Здесь тоже понадобятся ваши переменные в .env:
PAYMENT_RAIFFEISEN_ENDPOINT=https://pay.raif.ru/api/sbp/v2/
PAYMENT_RAIFFEISEN_ID=MA12345678
PAYMENT_RAIFFEISEN_KEY=eyJ0eXAiOiJKV1QiLCJhbG...
На самом деле банк Райффайзен уже присылает готовую картинку в своём ответе, но мне хотелось показать, насколько легко её генерировать самостоятельно.
Тем более, что вместо Райфа можно использовать для оплаты через QR ту же Yookassa. Она тоже поддерживает работу через СБП, но сама картинки не делает.
Если вам интересно, что именно содержится в QR - то там просто ссылка на сервис СБП, которая распознаётся мобильными приложениями при сканировании.
https://qr.nspk.ru/BS1A0005QG9IVHF38L7P2E8L51I5B18H?type=01&bank=100000000007&crc=4968
Почитать про формат ссылки можно в официальной документации СБП.
Вообще, работа с СБП оказалась очень интересной, жаль только не нашёл как сделать тестовый режим. Все проверки пришлось проводить в боевом режиме через свой банк.

Добавляем оплату в заказ

Теперь переходим на фронтенд, в нашу форму заказа.
Здесь нам нужно отобразить имеющиеся способы оплаты, которыми я предлагаю управлять через переменную окружения
PAYMENT_SERVICES=yookassa,raiffeisen
Эту настройку мы добавляем в nuxt.config.js, чтобы получать на фронтенде:
Config.publicRuntimeConfig = {
  PRODUCTS_PREFIX: env.PRODUCTS_PREFIX,
  PAYMENT_SERVICES: env.PAYMENT_SERVICES,
}
Не забывайте, что при изменении настроек фронтенд нужно пересобирать, потому что переменные из .env читаются и сохраняются во время сборки. Набор методов оплаты меняется нечасто, так что это не будет проблемой.
Редактируем site/components/order.vue:
export default {
  // ...
  data() {
    return {
      // ...
      payment: null,
      qr: null,
    }
  },
  computed: {
    // ...
    services() {
        return this.$config.PAYMENT_SERVICES.split(',').map((i) => i.trim().toLowerCase())
    },
  },
  // ...
  mounted() {
    if (this.services.length) {
      this.payment = this.services[0]
    }
  },
  methods: {
    async onSubmit() {
      this.loading = true
      try {
        const id = await this.$store.dispatch('getCartId', false)
        // Добавляем отправку выбранного способа оплаты в заказ
        const params = {...this.form, address_id: this.addressId, payment: this.payment}
        const {data} = await this.$axios.put('web/orders/' + id, params)
        await this.$store.dispatch('deleteCart')

        // Получаем ответ и проверяем в нём наличие данных по оплате
        if (data.payment) {
          // Если прислали QR код
          if (data.payment.qr) {
            // Сохраняем uuid заказа и картинку для вывода в модалке
            this.qr = {uuid: data.uuid, image: data.payment.qr}
          } else if (data.payment.redirect) {
            // Если прислали редирект - редиректим
            window.location = data.payment.redirect
          }
        } else {
          // Оплаты нет - просто переходим на страницу заказа
          this.$store.commit('showCart', false)
          this.$router.push({name: 'orders-uuid', params: {uuid: data.uuid}})
        }
      } catch (e) {
        console.error(e)
      } finally {
        this.loading = false
      }
    },
    // Этот метод срабатывает на закрытие модалки с QR
    onQR() {
      // Закрываем корзину и переходим на страницу заказа,
      // используя ранее сохранённый uuid
      this.$store.commit('showCart', false)
      this.$router.push({name: 'orders-uuid', params: {uuid: this.qr.uuid}})
    },
  },
}
Шаблон оформления кнопок оплаты и модалки вы посмотрите в исходниках, а я расскажу логику работы.
При выборе метода оплаты он будет отправлен в заказ, сервер создаст запись об оплате, получит данные для платежа от сервиса и вернёт юзеру. Дальше, в зависимости от ответа, будет или показан QR код, или сделан редирект на страницу сервиса.
В конце концов юзер приземлится на страницу распечатки заказа, которая сделает запрос на сервер с указанием uuid. Это произойдёт и при закрытии модалки с QR, и при возврате с платёжной страницы - потому что мы там указываем successUrl.
Тестируем оплату с редиректом:
Протестировать оплату с QR без реальной оплаты никак не получится, во всяком случае, я таких способов не нашёл - поэтому просто смотрим как работает модалка и редирект:

Проверка статуса оплаты

Наверное, вы обратили внимание, как здорово при оплате с редиректом заказ уже становится оплаченным сразу при загрузке страницы? Как же это происходит?
А очень просто - проверяем статус оплаты при получении заказа по API в контроллере Web\Orders:
    public function get(): ResponseInterface
    {
        $uuid = $this->getProperty('uuid');
        /** @var Order $order */
        if (!$uuid || !$order = Order::query()->where('uuid', $uuid)->first()) {
            return $this->failure('Not Found', 404);
        }

        // Если статус оплаты неизвестен - проверяем через платёжный сервис
        if ($order->payment && $order->payment->paid === null) {
            $order->payment->checkStatus();
        }

        return $this->success($order->getData());
    }
Выходит, проверку оплаты можно делать просто обновляя страницу сайта!
Не забываем добавить вывод данных об оплате в модель Order::getData():
    public function getData(): array
    {
        return [
            'order' => $this->toArray(),
            'user' => $this->user->toArray(),
            'address' => $this->address->toArray(),
            // Вот и оплата, если есть
            'payment' => $this->payment ? $this->payment->toArray() : null,
            'products' => $this->orderProducts()
                ->with('product:id,uri', 'product.translations:product_id,lang,title', 'product.firstFile')
                ->get()
                ->toArray(),
        ];
    }
Оплату на странице заказа я вывожу вот так:
    <div v-if="payment" class="mt-5">
      <div v-if="payment.paid" class="alert alert-success">
        {{ $t('order.payment_paid', {date: $options.filters.datetime(payment.paid_at)}) }}
      </div>
      <div v-else class="alert alert-info">
        {{ $t('order.payment_unpaid') }}
      </div>
    </div>
Следует отметить, что благодаря сохранению ссылки на оплату от сервиса в свойстве Payment::link, мы можем оформлять её в письме о заказе или в личном кабинете. Типа, "вы забыли оплатить заказ, пройдите по ссылке".

Создание оплаты

Осталось только разобраться с созданием оплаты.
С фронтенда нам могут прислать всё, что угодно, в названии сервиса, поэтому нужно сделать специальный метод для проверки в модели Order.
Как я уже говорил, включать и выключать сервисы мы будем через настройку PAYMENT_SERVICES, её здесь и проверяем:
    public function createPayment(string $service): ?Payment
    {
         // Приводим все имена сервисов к нижнему регистру
        $service = strtolower($service);
        $allowedServices = array_map('trim', explode(',', strtolower(getenv('PAYMENT_SERVICES') ?: '')));
        // Если нет платежей, или прислали неверное название - выходим
        if (!in_array($service, $allowedServices, true)) {
            return null;
        }

        // А иначе создаём новый платёж или меняем старый
        if (!$payment = $this->payment) {
            $payment = new Payment([
                'order_id' => $this->id,
                'user_id' => $this->user_id,
                'amount' => $this->total,
            ]);
        }
        $payment->service = $service;
        $payment->save();

        return $payment;
    }
Вот через этот метод мы и будем создавать новые платежи. Связь платежей с заказами через колонку order_id подразумевает, что платежей для одного заказа может быть несколько, но я это ограничиваю на программном уровне.
Просто редактирую модель платежа, если она уже есть в БД. Такое, конечно, крайне маловероятно, но проверить не помешает.
Ну и создаём оплату в контроллере заказов Web\Orders:
    public function put(): ResponseInterface
    {
        // ...

        $order->save();
        $order->orderProducts()->saveMany($orderProducts);

        $response = $order->toArray();

        // Юзер прислал метод оплаты
        if (!empty($properties['payment'])) {
            // Пробуем создать из него платёж
            if ($payment = $order->createPayment($properties['payment'])) {
                // Если всё хорошо - получаем ссылку на оплату
                if ($link = $payment->getLink()) {
                    // И добавляем в ответ на фронтенд
                    $response['payment'] = $link;
                }
            }
        }
        // На момент отправки писем уже будут данные от платёжного сервиса, включая ссылку на оплату
        $order->sendEmails($lang);

        return $this->success($response);
    }
Итого:
  • юзер выбрал платёжный метод
  • он прилетел на сервер
  • из него была создана модель Payment с указанием сервиса
  • загружен сервис, на нём создана операция
  • в ответ получены id операции вместе с платёжной ссылкой
  • отправляем ссылку юзеру как редирект, или QR код
  • после оплаты юзер попадает на страницу заказа
  • страница делает запрос на сервер и проверяет статус оплаты
Как видите, я специально не использую никакие уведомления от сервиса, по двум причинам:
  • их довольно трудно отлаживать
  • они не работают локально - сервис не сможет прислать вам уведомление об оплате по адресу 127.0.0.1
Просто самостоятельно делать запросы на сервисы. Для пущей надёжности можно добавить такие проверки в cron.

Заключение

Вот мы и добавили оплату на сайте, аж сразу двумя способами.
Оплата через QR набирает обороты, позволяя получать денежки сразу на расчётный счёт вашего банка, минуя платёжные шлюзы и агрегаторы. С точки зрения нашего SPA приложения всё тоже хорошо - не нужно делать никаких редиретов.
Единственный минус - протестить оплату по QR не получается, работать можно только в боевом режиме. Во всяком случае лично я не смог добиться никаких тестовых данных от своего банка, хотя пытался. Но и это не проблема, можно делать много платежей на мелкие суммы.
По этой же причине на демо-сайте оплата через СБП не включена.
Итоговый коммит со всеми изменениями.

2 комментария

Александр
А если всё же использовать вебхук от сервиса для получения статуса платежа, как это лучше реализовать? В routes.php прописать отдельный адрес, который прописать в сервисе, т.е. чтобы сервис отправлял уведомления на site.ru/api/payments/yookassa например?
Василий Наумкин
Да, верно, именно так.
А в контроллере, скорее всего, ловить данные методом post.
bezumkin.ru
Personal website of Vasily Naumkin
Прямой эфир
Василий Наумкин
01.07.2024, 11:56:41
Да, верно, именно так. А в контроллере, скорее всего, ловить данные методом post.
Василий Наумкин
26.06.2024, 09:38:15
О, точно, вылезает если не залогинен. Спасибо, исправил!
Василий Наумкин
09.04.2024, 04:45:01
> Ошибка 500 Это не похоже на ошибку Nginx, это скорее всего ошибка PHP - надо смотреть его логи. ...
Futuris
04.04.2024, 08:56:12
Я просто немного запутался. Когда в абзаце "Vesp/Core" ты пишешь про "новый trait FileModel", я поду...
Василий Наумкин
20.03.2024, 21:21:52
Volledig!
Андрей
14.03.2024, 13:47:10
Василий! Как всегда очень круто! Моё почтение!
russel gal
09.03.2024, 20:17:18
> А этот стоило написать хотя бы затем, чтобы получить комментарий от юзера, который ничего не писал...
Александр Наумов
27.01.2024, 03:06:18
Василий, спасибо! Извини, тупанул.
Василий Наумкин
22.01.2024, 07:43:20
Давай-давай!
Василий Наумкин
24.12.2023, 14:26:13
Спасибо!