Розробка форми з інтеграцією оплати на сайті
Форма оплати — момент, коли користувач перетворюється на покупця або йде назавжди. Тут немає місця для перенаправлень на сторонні сторінки з чужим дизайном, для незрозумілих помилок без пояснень, для полів, що скидаються після невдалої спроби. Завдання — вбудувати прийом платежів прямо в інтерфейс сайту так, щоб користувач не покидав сторінку та не втрачав контексту.
Архітектура вбудованої форми
Сучасні платіжні шлюзи пропонують два варіанти інтеграції: перенаправлення на сторінку шлюзу та вбудована форма (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->handleSuccess($event->data->object),
'payment_intent.payment_failed' => $this->handleFailed($event->data->object),
'charge.dispute.created' => $this->handleDispute($event->data->object),
default => null,
};
return response('OK', 200);
}
private function handleSuccess(\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 є).







