Приём платежей — код, где ошибка стоит денег напрямую, причём в обе стороны: можно отдать товар без оплаты, а можно списать дважды. При этом сама интеграция обманчиво простая — создать платёж и принять уведомление, полсотни строк.
Сложность не в вызовах API, а в том, что происходит между ними: пользователь закрывает вкладку, уведомление приходит дважды, сеть отваливается на середине. Разберём и то, и другое.
Выбор платёжной системы
| Система | Регион | Комиссия | Особенности |
|---|---|---|---|
| ЮKassa | Россия | 2.8-3.5% | СБП, рассрочка |
| Stripe | Мир | 2.9% + 30¢ | Лучшее API |
| CloudPayments | Россия | 2.7% | Быстрое подключение |
| Тинькофф | Россия | от 1.79% | Интеграция с банком |
Рекомендация: Для России — ЮKassa (популярность, СБП). Для международных — Stripe (лучший DX).
ЮKassa — установка
composer require yoomoney/yookassa-sdk-php# .env
YOOKASSA_SHOP_ID=your_shop_id
YOOKASSA_SECRET_KEY=your_secret_keyСервис для ЮKassa
// app/Services/YooKassaService.php
namespace App\Services;
use App\Models\Order;
use App\Models\Payment;
use YooKassa\Client;
use YooKassa\Model\Notification\NotificationSucceeded;
use YooKassa\Model\Notification\NotificationWaitingForCapture;
class YooKassaService
{
private Client $client;
public function __construct()
{
$this->client = new Client();
$this->client->setAuth(
config('services.yookassa.shop_id'),
config('services.yookassa.secret_key')
);
}
/**
* Создание платежа
*/
public function createPayment(Order $order, string $returnUrl): array
{
$idempotenceKey = uniqid('', true);
$payment = $this->client->createPayment([
'amount' => [
'value' => number_format($order->total / 100, 2, '.', ''),
'currency' => 'RUB',
],
'confirmation' => [
'type' => 'redirect',
'return_url' => $returnUrl,
],
'capture' => true,
'description' => "Заказ #{$order->id}",
'metadata' => [
'order_id' => $order->id,
],
'receipt' => $this->buildReceipt($order),
], $idempotenceKey);
// Сохраняем платёж в БД
Payment::create([
'order_id' => $order->id,
'external_id' => $payment->getId(),
'amount' => $order->total,
'status' => $payment->getStatus(),
]);
return [
'id' => $payment->getId(),
'confirmation_url' => $payment->getConfirmation()->getConfirmationUrl(),
];
}
/**
* Возврат платежа
*/
public function refund(Payment $payment, ?int $amount = null): bool
{
$idempotenceKey = uniqid('refund_', true);
$refund = $this->client->createRefund([
'payment_id' => $payment->external_id,
'amount' => [
'value' => number_format(($amount ?? $payment->amount) / 100, 2, '.', ''),
'currency' => 'RUB',
],
], $idempotenceKey);
return $refund->getStatus() === 'succeeded';
}
/**
* Обработка webhook
*/
public function handleWebhook(array $data): void
{
$notification = match ($data['event']) {
'payment.succeeded' => new NotificationSucceeded($data),
'payment.waiting_for_capture' => new NotificationWaitingForCapture($data),
default => null,
};
if (!$notification) {
return;
}
$paymentData = $notification->getObject();
$orderId = $paymentData->getMetadata()->order_id;
$payment = Payment::where('external_id', $paymentData->getId())->first();
if (!$payment) {
logger()->error('Payment not found', ['id' => $paymentData->getId()]);
return;
}
$payment->update([
'status' => $paymentData->getStatus(),
'paid_at' => $paymentData->getStatus() === 'succeeded' ? now() : null,
]);
if ($paymentData->getStatus() === 'succeeded') {
$payment->order->update(['status' => 'paid']);
event(new OrderPaid($payment->order));
}
}
/**
* Чек для 54-ФЗ
*/
private function buildReceipt(Order $order): array
{
$items = $order->items->map(fn($item) => [
'description' => mb_substr($item->product->name, 0, 128),
'quantity' => (string) $item->quantity,
'amount' => [
'value' => number_format($item->price / 100, 2, '.', ''),
'currency' => 'RUB',
],
'vat_code' => 1, // Без НДС
'payment_subject' => 'commodity',
'payment_mode' => 'full_payment',
])->toArray();
return [
'customer' => [
'email' => $order->user->email,
],
'items' => $items,
];
}
}Контроллер платежей
// app/Http/Controllers/PaymentController.php
namespace App\Http\Controllers;
use App\Models\Order;
use App\Services\YooKassaService;
use Illuminate\Http\Request;
class PaymentController extends Controller
{
public function __construct(
private YooKassaService $yookassa
) {}
public function create(Order $order)
{
$this->authorize('pay', $order);
if ($order->status !== 'pending') {
return back()->with('error', 'Заказ уже оплачен или отменён');
}
$payment = $this->yookassa->createPayment(
$order,
route('payment.callback', $order)
);
return redirect($payment['confirmation_url']);
}
public function callback(Order $order)
{
return view('payment.result', [
'order' => $order->fresh(),
]);
}
public function webhook(Request $request)
{
$this->yookassa->handleWebhook($request->all());
return response('OK', 200);
}
}Stripe — установка
composer require stripe/stripe-php# .env
STRIPE_KEY=pk_test_...
STRIPE_SECRET=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...Сервис для Stripe
// app/Services/StripeService.php
namespace App\Services;
use App\Models\Order;
use App\Models\Payment;
use Stripe\StripeClient;
use Stripe\Webhook;
class StripeService
{
private StripeClient $stripe;
public function __construct()
{
$this->stripe = new StripeClient(config('services.stripe.secret'));
}
/**
* Создание Checkout Session
*/
public function createCheckoutSession(Order $order): string
{
$lineItems = $order->items->map(fn($item) => [
'price_data' => [
'currency' => 'usd',
'product_data' => [
'name' => $item->product->name,
],
'unit_amount' => $item->price, // в центах
],
'quantity' => $item->quantity,
])->toArray();
$session = $this->stripe->checkout->sessions->create([
'payment_method_types' => ['card'],
'line_items' => $lineItems,
'mode' => 'payment',
'success_url' => route('payment.success', ['order' => $order->id]),
'cancel_url' => route('payment.cancel', ['order' => $order->id]),
'metadata' => [
'order_id' => $order->id,
],
'customer_email' => $order->user->email,
]);
Payment::create([
'order_id' => $order->id,
'external_id' => $session->id,
'amount' => $order->total,
'status' => 'pending',
]);
return $session->url;
}
/**
* Payment Intent (для кастомной формы)
*/
public function createPaymentIntent(Order $order): array
{
$intent = $this->stripe->paymentIntents->create([
'amount' => $order->total,
'currency' => 'usd',
'metadata' => [
'order_id' => $order->id,
],
]);
return [
'clientSecret' => $intent->client_secret,
];
}
/**
* Обработка webhook
*/
public function handleWebhook(string $payload, string $signature): void
{
$event = Webhook::constructEvent(
$payload,
$signature,
config('services.stripe.webhook_secret')
);
match ($event->type) {
'checkout.session.completed' => $this->handleSessionCompleted($event->data->object),
'payment_intent.succeeded' => $this->handlePaymentSucceeded($event->data->object),
'payment_intent.payment_failed' => $this->handlePaymentFailed($event->data->object),
default => null,
};
}
private function handleSessionCompleted($session): void
{
$payment = Payment::where('external_id', $session->id)->first();
if ($payment) {
$payment->update(['status' => 'succeeded', 'paid_at' => now()]);
$payment->order->update(['status' => 'paid']);
event(new OrderPaid($payment->order));
}
}
private function handlePaymentSucceeded($intent): void
{
$orderId = $intent->metadata->order_id;
$order = Order::find($orderId);
if ($order) {
$order->update(['status' => 'paid']);
event(new OrderPaid($order));
}
}
private function handlePaymentFailed($intent): void
{
$orderId = $intent->metadata->order_id;
logger()->warning('Payment failed', ['order_id' => $orderId]);
}
/**
* Возврат
*/
public function refund(string $paymentIntentId, ?int $amount = null): bool
{
$params = ['payment_intent' => $paymentIntentId];
if ($amount) {
$params['amount'] = $amount;
}
$refund = $this->stripe->refunds->create($params);
return $refund->status === 'succeeded';
}
}Роуты
// routes/web.php
Route::middleware('auth')->group(function () {
Route::post('/orders/{order}/pay', [PaymentController::class, 'create'])
->name('payment.create');
Route::get('/orders/{order}/payment/callback', [PaymentController::class, 'callback'])
->name('payment.callback');
});
// routes/api.php
Route::post('/webhooks/yookassa', [PaymentController::class, 'webhookYookassa']);
Route::post('/webhooks/stripe', [PaymentController::class, 'webhookStripe']);Миграция для платежей
// database/migrations/create_payments_table.php
Schema::create('payments', function (Blueprint $table) {
$table->id();
$table->foreignId('order_id')->constrained()->cascadeOnDelete();
$table->string('external_id')->unique();
$table->integer('amount');
$table->string('status')->default('pending');
$table->timestamp('paid_at')->nullable();
$table->timestamps();
});Webhook безопасность:
- Всегда проверяйте подпись webhook
- Используйте HTTPS
- Не доверяйте query-параметрам — проверяйте статус через API
Четыре правила приёма платежей
Это то, что определяет, будет интеграция надёжной или нет. Конкретный платёжный провайдер здесь неважен — правила одинаковы для всех.
Источником истины является только webhook. return_url — это страница, на которую браузер вернул пользователя, и она ничего не доказывает: пользователь мог закрыть вкладку сразу после оплаты (и тогда возврата не будет вовсе) или подобрать адрес вручную (и тогда возврат будет без оплаты). Заказ переводится в оплаченный только по подтверждённому уведомлению от платёжной системы.
Проверяйте сумму и валюту, а не только факт оплаты. В уведомлении приходит сумма — сверяйте её с суммой заказа в вашей базе. Расхождение означает либо ошибку в вашем коде, либо попытку оплатить дешевле, и в обоих случаях заказ нельзя считать оплаченным.
Обработчик уведомлений обязан быть идемпотентным. Платёжные системы повторяют доставку, если не получили 200 вовремя, — и это нормальное поведение, а не сбой. Без защиты повторное уведомление второй раз спишет товар со склада, второй раз отправит письмо, второй раз начислит бонусы. Проверяйте идентификатор платежа: если он уже обработан, возвращайте 200 и ничего не делайте.
Отвечайте быстро и логируйте всё. Тяжёлая работа в обработчике уведомления приводит к таймауту, таймаут — к повтору, повтор — к дублям. Принимайте уведомление, сохраняйте, отвечайте 200, обрабатывайте отдельно. А полный лог всех платёжных операций — это то, чем вы будете доказывать свою правоту в споре с клиентом или банком; вести его нужно с первого дня.
Про переходы между статусами. Уведомления могут прийти не в том порядке, в каком происходили события: сначала об успешной оплате, потом — задержавшееся о создании платежа. Обработчик, слепо применяющий последний пришедший статус, откатит оплаченный заказ обратно в ожидание.
Опишите допустимые переходы явно и игнорируйте недопустимые: из «оплачен» нельзя вернуться в «ожидает оплаты», из «возвращён» — в «оплачен».
Итоги
| Операция | ЮKassa | Stripe |
|---|---|---|
| Создание платежа | createPayment() | checkout.sessions.create() |
| Проверка статуса | Webhook / getPaymentInfo() | Webhook / retrieve() |
| Возврат | createRefund() | refunds.create() |
| Рекуррент | savePaymentMethod | Subscriptions API |
Чек-лист перед приёмом реальных денег:
- Ключи в
.env, боевые и тестовые разделены, боевые не лежат ни в репозитории, ни в чате. - Подпись уведомления проверяется — иначе любой может прислать вам «оплату». Проверка выполняется до всякой обработки, а сравнение подписей — через
hash_equals(). - Статус меняется только по webhook, не по
return_url. - Сумма и валюта сверяются с заказом.
- Обработчик идемпотентен по идентификатору платежа.
- Недопустимые переходы статусов игнорируются.
- Всё логируется — запрос, ответ, решение, время.
- Проверено на реальном платеже в боевом режиме: тестовый контур не воспроизводит всё, и первый рубль стоит провести самому. Заодно проверьте возврат — обнаруживать, что он не работает, в момент, когда его требует клиент, крайне неприятно.
- Для 54-ФЗ формируются чеки — и это отдельная задача со своими сроками и требованиями к составу данных, а не галочка в настройках.
Комментарии
Система комментариев скоро будет подключена. А пока вы можете написать мне в Telegram или на email.