О нас Руководства Проекты Контакты
Админка
пожалуйста подождите

Введение

Обработка платежей — это критически важная инфраструктура, которую необходимо реализовать корректно: ошибки означают потерю выручки или нарушение требований compliance. Stripe предоставляет надежные API для приема платежей, управления подписками и обработки сложных сценариев маркетплейсов с помощью Stripe Connect. В этом руководстве рассматриваются практические паттерны интеграции Stripe для типовых платежных сценариев.

Первоначальная настройка

Конфигурация аккаунта

  1. Создайте аккаунт Stripe на stripe.com
  2. Настройте профиль компании в Dashboard → Settings → Profile
  3. Укажите поддерживаемые валюты в Dashboard → Settings → Payouts
  4. Включите платежные методы в Dashboard → Settings → Payment Methods
  5. Сгенерируйте API keys в Dashboard → Developers → API Keys

Интеграция с PHP

composer require stripe/stripe-php
// config/services.php или .env
STRIPE_KEY=pk_test_xxx
STRIPE_SECRET=sk_test_xxx
STRIPE_WEBHOOK_SECRET=whsec_xxx
// Инициализация Stripe
\Stripe\Stripe::setApiKey(env('STRIPE_SECRET'));

Интеграция с JavaScript

npm install @stripe/stripe-js
import { loadStripe } from '@stripe/stripe-js';
const stripePromise = loadStripe('pk_test_xxx');

Checkout Sessions

На стороне сервера (PHP)

// Создание checkout session
public function createCheckoutSession(Request $request)
{
$session = \Stripe\Checkout\Session::create([
'payment_method_types' => ['card'],
'line_items' => [[
'price_data' => [
'currency' => 'usd',
'product_data' => [
'name' => $request->product_name,
'description' => $request->description,
'images' => [$request->image_url],
],
'unit_amount' => $request->price * 100, // Сумма в центах
],
'quantity' => $request->quantity,
]],
'mode' => 'payment',
'success_url' => route('checkout.success') . '?session_id={CHECKOUT_SESSION_ID}',
'cancel_url' => route('checkout.cancel'),
'customer_email' => auth()->user()->email,
'metadata' => [
'user_id' => auth()->id(),
'order_id' => $request->order_id,
],
]);
return response()->json(['id' => $session->id]);
}

Редирект на стороне клиента

async function handleCheckout() {
const response = await fetch('/create-checkout-session', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ product_id: 123, quantity: 1 }),
});
const { id } = await response.json();
const stripe = await stripePromise;
const { error } = await stripe.redirectToCheckout({ sessionId: id });
if (error) {
console.error(error);
}
}

Payment Intents (кастомный UI)

Создание Payment Intent

public function createPaymentIntent(Request $request)
{
$intent = \Stripe\PaymentIntent::create([
'amount' => $request->amount * 100,
'currency' => 'usd',
'payment_method_types' => ['card'],
'metadata' => [
'order_id' => $request->order_id,
],
]);
return response()->json([
'clientSecret' => $intent->client_secret,
]);
}

Платежная форма на стороне клиента

import { CardElement, useStripe, useElements } from '@stripe/react-stripe-js';
function PaymentForm({ amount, orderId }) {
const stripe = useStripe();
const elements = useElements();
const [error, setError] = useState(null);
const [processing, setProcessing] = useState(false);
async function handleSubmit(event) {
event.preventDefault();
setProcessing(true);
// Получить client secret с сервера
const response = await fetch('/create-payment-intent', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ amount, order_id: orderId }),
});
const { clientSecret } = await response.json();
// Подтвердить платеж
const { error, paymentIntent } = await stripe.confirmCardPayment(
clientSecret,
{
payment_method: {
card: elements.getElement(CardElement),
billing_details: {
name: 'Customer Name',
},
},
}
);
setProcessing(false);
if (error) {
setError(error.message);
} else if (paymentIntent.status === 'succeeded') {
// Платеж успешен
window.location.href = '/success';
}
}
return (
<form onSubmit={handleSubmit}>
<CardElement />
{error && <div className="error">{error}</div>}
<button disabled={!stripe || processing}>
{processing ? 'Processing...' : `Pay $${amount}`}
</button>
</form>
);
}

Webhooks

Настройка webhook endpoint

// routes/web.php (исключить CSRF для webhooks)
Route::post('/webhook/stripe', [WebhookController::class, 'handle'])
->withoutMiddleware([\App\Http\Middleware\VerifyCsrfToken::class]);

Обработка webhook events

// app/Http/Controllers/WebhookController.php
public function handle(Request $request)
{
$payload = $request->getContent();
$sigHeader = $request->header('Stripe-Signature');
try {
$event = \Stripe\Webhook::constructEvent(
$payload,
$sigHeader,
config('services.stripe.webhook_secret')
);
} catch (\Exception $e) {
return response('Webhook Error', 400);
}
switch ($event->type) {
case 'checkout.session.completed':
$session = $event->data->object;
$this->handleSuccessfulPayment($session);
break;
case 'payment_intent.succeeded':
$paymentIntent = $event->data->object;
$this->handlePaymentIntentSucceeded($paymentIntent);
break;
case 'payment_intent.payment_failed':
$paymentIntent = $event->data->object;
$this->handlePaymentFailed($paymentIntent);
break;
case 'customer.subscription.created':
case 'customer.subscription.updated':
case 'customer.subscription.deleted':
$subscription = $event->data->object;
$this->handleSubscriptionChange($subscription);
break;
default:
Log::info('Unhandled webhook event: ' . $event->type);
}
return response('OK', 200);
}
protected function handleSuccessfulPayment($session)
{
$orderId = $session->metadata->order_id;
$order = Order::find($orderId);
$order->update([
'status' => 'paid',
'stripe_session_id' => $session->id,
'paid_at' => now(),
]);
// Отправить письмо с подтверждением
Mail::to($order->user)->send(new OrderConfirmation($order));
}

Подписки

Создание подписки

public function subscribe(Request $request)
{
$user = auth()->user();
// Создать или получить customer
if (!$user->stripe_customer_id) {
$customer = \Stripe\Customer::create([
'email' => $user->email,
'name' => $user->name,
]);
$user->update(['stripe_customer_id' => $customer->id]);
}
// Создать подписку
$subscription = \Stripe\Subscription::create([
'customer' => $user->stripe_customer_id,
'items' => [
['price' => $request->price_id], // price_xxx из Stripe Dashboard
],
'payment_behavior' => 'default_incomplete',
'expand' => ['latest_invoice.payment_intent'],
]);
return response()->json([
'subscriptionId' => $subscription->id,
'clientSecret' => $subscription->latest_invoice->payment_intent->client_secret,
]);
}

Отмена подписки

public function cancel(Request $request)
{
$subscription = \Stripe\Subscription::retrieve($request->subscription_id);
// Отменить в конце периода (рекомендуется)
$subscription->cancel_at_period_end = true;
$subscription->save();
// Или отменить немедленно
// $subscription->cancel();
return response()->json(['status' => 'cancelled']);
}

Stripe Connect (маркетплейсы)

Онбординг подключенных аккаунтов

public function createConnectAccount(Request $request)
{
$account = \Stripe\Account::create([
'type' => 'express',
'country' => $request->country,
'email' => $request->email,
'capabilities' => [
'card_payments' => ['requested' => true],
'transfers' => ['requested' => true],
],
]);
// Создать account link для онбординга
$accountLink = \Stripe\AccountLink::create([
'account' => $account->id,
'refresh_url' => route('connect.refresh'),
'return_url' => route('connect.return'),
'type' => 'account_onboarding',
]);
// Сохранить account ID
$user = auth()->user();
$user->update(['stripe_connect_id' => $account->id]);
return redirect($accountLink->url);
}

Обработка платежа маркетплейса

public function processMarketplacePayment(Request $request)
{
$seller = User::find($request->seller_id);
$platformFee = $request->amount * 0.10; // Комиссия платформы — 10%
$paymentIntent = \Stripe\PaymentIntent::create([
'amount' => $request->amount * 100,
'currency' => 'usd',
'payment_method_types' => ['card'],
'transfer_data' => [
'destination' => $seller->stripe_connect_id,
],
'application_fee_amount' => $platformFee * 100,
]);
return response()->json([
'clientSecret' => $paymentIntent->client_secret,
]);
}

Тестирование

Тестовые карты

Card NumberСценарий
4242424242424242Успешный платеж
4000000000000077Автоматическое одобрение
4000000000009995Недостаточно средств
4000000000000069Срок действия карты истек
4000002500003155Требуется аутентификация

Локальное тестирование webhooks

# Установить Stripe CLI
# Скачать с https://stripe.com/docs/stripe-cli
# Войти в систему
stripe login
# Проксировать webhooks на локальный сервер
stripe listen --forward-to localhost:8000/webhook/stripe
# Сгенерировать тестовые события
stripe trigger payment_intent.succeeded

Лучшие практики безопасности

  1. Никогда не раскрывайте secret keys в клиентском коде
  2. Проверяйте webhooks с использованием signing secrets
  3. Используйте HTTPS для всех коммуникаций со Stripe
  4. Храните минимум данных карты — пусть Stripe обеспечивает соответствие PCI
  5. Проверяйте суммы на стороне сервера перед созданием списаний
  6. Используйте idempotency keys для запросов, изменяющих состояние
  7. Логируйте все платежные события для отладки и аудита

Идемпотентность: критически важный паттерн

Повторные попытки из-за сети — обычное явление. Без идемпотентности повторные запросы могут привести к двойному списанию средств у клиентов:

// Всегда указывайте idempotency key для операций записи платежей
$paymentIntent = \Stripe\PaymentIntent::create([
'amount' => $amount * 100,
'currency' => 'usd',
'payment_method_types' => ['card'],
], [
'idempotency_key' => 'order_' . $orderId . '_' . $attemptNumber,
]);
// Сохраняйте ключ вместе с записью о платеже
$order->update([
'idempotency_key' => $idempotencyKey,
'stripe_payment_intent_id' => $paymentIntent->id,
]);

Это также относится к возвратам и выплатам. Если запрос на возврат повторяется, он не должен создавать несколько возвратов.

Процесс сверки

Даже при наличии webhooks данные могут расходиться. Постройте сверку, которая сравнивает ваши записи с данными Stripe:

// Ежедневная задача сверки
class ReconcilePayments implements ShouldQueue
{
public function handle()
{
$yesterday = now()->subDay();
// Получить charges из Stripe
$stripeCharges = \Stripe\Charge::all([
'created' => [
'gte' => $yesterday->startOfDay()->timestamp,
'lte' => $yesterday->endOfDay()->timestamp,
],
'limit' => 100,
]);
foreach ($stripeCharges->autoPagingIterator() as $charge) {
$localPayment = Payment::where('stripe_charge_id', $charge->id)->first();
if (!$localPayment) {
Log::warning('Missing local payment record', ['charge_id' => $charge->id]);
// Создать отсутствующую запись или отправить alert
} elseif ($localPayment->amount != $charge->amount) {
Log::warning('Amount mismatch', [
'charge_id' => $charge->id,
'local' => $localPayment->amount,
'stripe' => $charge->amount,
]);
}
}
}
}

Проверьте:

  • Общую сумму списаний vs. общую сумму заказов
  • Общую сумму возвратов vs. записи о возвратах
  • Выплаты vs. балансы платформы

Возвраты и споры

Моделируйте возвраты и споры как явные состояния:

// Состояния модели возвратов
const REFUND_STATES = [
'initiated',
'submitted',
'succeeded',
'failed',
];
public function processRefund(Order $order, string $reason, float $amount = null)
{
$refund = Refund::create([
'order_id' => $order->id,
'amount' => $amount ?? $order->amount,
'reason' => $reason,
'initiated_by' => auth()->id(),
'status' => 'initiated',
]);
try {
$stripeRefund = \Stripe\Refund::create([
'charge' => $order->stripe_charge_id,
'amount' => ($amount ?? $order->amount) * 100,
], [
'idempotency_key' => 'refund_' . $refund->id,
]);
$refund->update([
'status' => 'submitted',
'stripe_refund_id' => $stripeRefund->id,
]);
} catch (\Stripe\Exception\ApiErrorException $e) {
$refund->update(['status' => 'failed', 'error' => $e->getMessage()]);
throw $e;
}
}

Мониторинг ключевых платежных метрик

Отслеживайте метрики операционной наблюдаемости:

// Платежные метрики для дашбордов мониторинга
class PaymentMetrics
{
public function getMetrics(): array
{
return [
'success_rate' => $this->calculateSuccessRate(),
'failure_rate' => $this->calculateFailureRate(),
'webhook_processing_delay' => $this->getWebhookDelay(),
'refund_volume' => $this->getRefundVolume(),
'dispute_volume' => $this->getDisputeVolume(),
'payout_failures' => $this->getPayoutFailures(),
];
}
private function calculateSuccessRate(): float
{
$total = Payment::whereDate('created_at', today())->count();
$succeeded = Payment::whereDate('created_at', today())
->where('status', 'succeeded')->count();
return $total > 0 ? round(($succeeded / $total) * 100, 2) : 0;
}
}

Резкое падение доли успешных платежей должно немедленно инициировать оповещение команды.

Распространенные ошибки

  • Принятие ответа клиента за успешный платеж без подтверждения через webhook
  • Отсутствие идемпотентности при создании платежа
  • Логирование чувствительных данных в открытом виде
  • Игнорирование сбоев провайдера до тех пор, пока клиенты не сообщат о проблемах
  • Неопределенность графиков выплат

Избегайте этого, проектируя платежные потоки как распределенные системы, а не как простые вызовы в стиле request-response.

Мышление Senior-инженера в платежах

Senior-инженеры рассматривают платежи как систему с высоким уровнем риска, требующую дисциплины. Они проектируют решения с упором на безопасность, аудируемость и восстановление. Когда платежи надежны, клиенты доверяют платформе, а выручка остается предсказуемой.

Заключение

Stripe предоставляет комплексную платежную инфраструктуру, однако корректная реализация критически важна для безопасности и надежности. Используйте Checkout Sessions для простых сценариев, Payment Intents — для кастомных UI, а webhooks — для надежного подтверждения платежей. Всегда тщательно тестируйте с помощью тестовых карт перед запуском в production и реализуйте корректную обработку ошибок и логирование. Помните: платежи — это финансовые события; относитесь к ним с должным уважением.

 
 
 
Языки
Темы
Copyright © 1999 — 2026
Зетка Интерактив