Разработка формы с интеграцией оплаты на сайте
Форма оплаты — точка, где пользователь превращается в покупателя или уходит навсегда. Здесь нет места для редиректов на сторонние страницы с чужим дизайном, для непонятных ошибок без объяснений, для полей, которые сбрасываются после неудачной попытки. Задача — встроить приём платежей прямо в интерфейс сайта так, чтобы пользователь не покидал страницу и не терял контекст.
Архитектура встроенной формы
Современные платёжные шлюзы предоставляют два варианта интеграции: редирект на страницу шлюза и встроенный виджет (embedded form). Второй вариант предпочтителен для большинства сайтов, потому что сохраняет визуальный контекст и повышает доверие.
Типичная схема для Stripe:
// Инициализация Stripe Elements
const stripe = Stripe('pk_live_...');
const elements = stripe.elements({
appearance: {
theme: 'flat',
variables: {
colorPrimary: '#0f172a',
fontFamily: 'Inter, sans-serif',
},
},
});
const paymentElement = elements.create('payment', {
layout: { type: 'tabs', defaultCollapsed: false },
});
paymentElement.mount('#payment-element');
// Обработка сабмита
const form = document.getElementById('payment-form');
form.addEventListener('submit', async (e) => {
e.preventDefault();
const { error } = await stripe.confirmPayment({
elements,
confirmParams: {
return_url: 'https://example.com/order/complete',
},
});
if (error) {
showError(error.message);
}
});
Для российского рынка чаще используется ЮКасса (бывший Яндекс.Касса) или CloudPayments. CloudPayments предоставляет собственный SDK для встроенной формы:
var widget = new cp.CloudPayments();
widget.charge(
{
publicId: 'pk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
description: 'Заказ #12345',
amount: 4990,
currency: 'RUB',
invoiceId: '12345',
email: '[email protected]',
skin: 'mini',
data: { orderId: '12345', userId: 42 },
},
function (options) {
// success callback
updateOrderStatus(options.invoiceId, 'paid');
},
function (reason, options) {
// fail callback
logPaymentError(reason, options);
}
);
Серверная часть: создание платёжного намерения
Никакая логика суммы не должна идти со стороны клиента. Браузер не доверяет сумме из hidden-поля — её подменяют. Сервер создаёт платёжное намерение с реальной суммой из базы данных:
// Laravel — создание PaymentIntent через Stripe
use Stripe\StripeClient;
public function createPaymentIntent(Request $request): JsonResponse
{
$order = Order::findOrFail($request->order_id);
// Проверка, что заказ принадлежит текущему пользователю
abort_if($order->user_id !== auth()->id(), 403);
$stripe = new StripeClient(config('services.stripe.secret'));
$intent = $stripe->paymentIntents->create([
'amount' => $order->total_cents, // в копейках/центах
'currency' => 'rub',
'metadata' => [
'order_id' => $order->id,
'user_id' => $order->user_id,
],
'automatic_payment_methods' => ['enabled' => true],
]);
return response()->json([
'client_secret' => $intent->client_secret,
]);
}
Клиент получает только client_secret — он не содержит суммы, не может быть использован для изменения параметров.
Webhook и подтверждение оплаты
Форма на фронтенде сообщает об успехе, но это не гарантия. Деньги могут зависнуть, банк может отклонить транзакцию уже после редиректа. Единственный надёжный источник истины — webhook от платёжного шлюза.
// Обработчик webhook Stripe
public function handleWebhook(Request $request): Response
{
$payload = $request->getContent();
$sigHeader = $request->header('Stripe-Signature');
try {
$event = \Stripe\Webhook::constructEvent(
$payload,
$sigHeader,
config('services.stripe.webhook_secret')
);
} catch (\Stripe\Exception\SignatureVerificationException $e) {
return response('Invalid signature', 400);
}
match ($event->type) {
'payment_intent.succeeded' => $this->handlePaymentSucceeded($event->data->object),
'payment_intent.payment_failed' => $this->handlePaymentFailed($event->data->object),
'charge.dispute.created' => $this->handleDispute($event->data->object),
default => null,
};
return response('OK', 200);
}
private function handlePaymentSucceeded(\Stripe\PaymentIntent $intent): void
{
$order = Order::where('stripe_payment_intent', $intent->id)->firstOrFail();
$order->update(['status' => 'paid', 'paid_at' => now()]);
// Отправка чека, запуск доставки, уведомление
dispatch(new SendReceiptJob($order));
dispatch(new InitiateShippingJob($order));
}
Для ЮКасса аналогичная схема через HMAC-подпись:
$body = $request->getContent();
$key = config('services.yookassa.secret_key');
// ЮКасса не использует подпись для webhook — проверяем через API
$notification = new \YooKassa\Model\Notification\NotificationSucceeded(
json_decode($body, true)
);
$payment = $notification->getObject();
UX-детали, которые влияют на конверсию
Валидация в реальном времени. Номер карты должен форматироваться группами по 4 цифры прямо в процессе ввода. Срок действия — автоматически добавлять /. Если Luhn-проверка не проходит — сообщать сразу, не ждать сабмита.
Сохранение прогресса. Если пользователь заполнил email, имя, адрес — и форма оплаты упала с ошибкой, все поля должны остаться. Очищать только CVV (требование PCI DSS).
Индикация состояния. Кнопка «Оплатить» должна показывать спиннер во время запроса и блокироваться от повторного нажатия. Двойная оплата — реальная проблема.
Мобильная клавиатура. Поле номера карты должно открывать числовую клавиатуру (inputmode="numeric"), а не буквенную. Мелочь, которую забывают в половине случаев.
<input
type="text"
inputmode="numeric"
autocomplete="cc-number"
placeholder="0000 0000 0000 0000"
pattern="[0-9\s]{13,19}"
/>
Безопасность и соответствие PCI DSS
Данные карты никогда не должны проходить через ваш сервер — только через iframe платёжного шлюза или его JavaScript-библиотеку. Это называется уровень PCI DSS SAQ A (самый простой для мерчанта).
Если данные карты хотя бы на миллисекунду оказались в вашем приложении — вы автоматически переходите на уровень SAQ D с ежегодным аудитом, пентестом и несколькими сотнями обязательных требований.
Content Security Policy для страниц с формой оплаты:
Content-Security-Policy:
default-src 'self';
script-src 'self' https://js.stripe.com https://widget.cloudpayments.ru;
frame-src https://js.stripe.com https://widget.cloudpayments.ru;
connect-src 'self' https://api.stripe.com;
Поддержка нескольких методов оплаты
Stripe Payment Element из коробки показывает карты, Apple Pay, Google Pay, SEPA, Klarna и ещё десяток методов — автоматически, в зависимости от страны пользователя и его браузера.
CloudPayments поддерживает карты, СБП (Система Быстрых Платежей), Tinkoff Pay. СБП особенно полезен: меньше комиссия, высокая конверсия на мобильных устройствах, нет необходимости вводить данные карты.
Для настройки Apple Pay через CloudPayments нужна верификация домена — разместить файл на /.well-known/apple-developer-merchantid-domain-association. Это обязательное требование Apple.
Сроки и этапы
Типичная интеграция для одного шлюза с тестовым окружением занимает 3–5 рабочих дней. Это включает: настройку аккаунта мерчанта, серверную часть (создание платёжного намерения, webhook), клиентскую форму, тестирование на тестовых картах, переключение на боевой режим.
Добавление второго шлюза (например, для резервирования) — ещё 2–3 дня на логику маршрутизации платежей.
Интеграция фискализации (54-ФЗ, отправка чеков через ОФД) — отдельная задача, 2–4 дня, зависит от того, есть ли у шлюза встроенная поддержка (у ЮКасса и CloudPayments есть).







