Интеграция рекуррентных платежей на сайт
Рекуррентные (recurring) платежи — автоматические периодические списания с карты или другого платёжного инструмента без участия пользователя в каждой транзакции. Используются в подписочных сервисах, SaaS, коммунальных платёжных системах, маркетплейсах с автоматическим пополнением баланса.
Модели реализации
Есть два принципиально разных подхода:
1. Токенизация карты (Card-on-file) — первый платёж проходит стандартно, шлюз возвращает card_token. Все последующие списания инициируются сервером с использованием этого токена, без участия пользователя.
2. Subscription API шлюза — шлюз (Stripe, CloudPayments и др.) сам управляет расписанием, отправляет напоминания и обрабатывает неудачи. Сервер только реагирует на webhook-события.
Второй подход надёжнее, но привязывает к конкретному шлюзу. Первый даёт больше контроля.
Реализация через токенизацию (Stripe)
Первый платёж — сохранение метода оплаты:
\Stripe\Stripe::setApiKey(config('services.stripe.secret'));
// Создаём Customer один раз для каждого пользователя
$stripeCustomer = \Stripe\Customer::create([
'email' => $user->email,
'metadata' => ['user_id' => $user->id],
]);
$user->update(['stripe_customer_id' => $stripeCustomer->id]);
// SetupIntent для сохранения карты без немедленного списания
$setupIntent = \Stripe\SetupIntent::create([
'customer' => $user->stripe_customer_id,
'usage' => 'off_session', // для последующих списаний без 3DS
'automatic_payment_methods' => ['enabled' => true],
]);
return response()->json(['clientSecret' => $setupIntent->client_secret]);
Клиент — подтверждение SetupIntent:
const { setupIntent, error } = await stripe.confirmSetup({
elements,
confirmParams: {
return_url: `${window.location.origin}/billing/setup-complete`,
},
redirect: 'if_required',
});
if (setupIntent?.status === 'succeeded') {
// Карта сохранена, payment_method ID - setupIntent.payment_method
await savePaymentMethod(setupIntent.payment_method as string);
}
Сервер — последующее списание без участия пользователя:
public function chargeRecurring(User $user, int $amountCents): void
{
\Stripe\Stripe::setApiKey(config('services.stripe.secret'));
$paymentMethod = $user->default_payment_method_id;
try {
$paymentIntent = \Stripe\PaymentIntent::create([
'amount' => $amountCents,
'currency' => 'usd',
'customer' => $user->stripe_customer_id,
'payment_method' => $paymentMethod,
'confirm' => true,
'off_session' => true, // критично для рекуррентов
'description' => "Subscription - {$user->id}",
]);
if ($paymentIntent->status === 'succeeded') {
$this->recordSuccessfulCharge($user, $paymentIntent);
}
} catch (\Stripe\Exception\CardException $e) {
// Карта отклонена — уведомить пользователя, обновить статус подписки
$this->handleFailedCharge($user, $e->getError()->decline_code);
} catch (\Stripe\Exception\InvalidRequestException $e) {
if ($e->getStripeCode() === 'authentication_required') {
// Требуется дополнительная аутентификация (3DS)
$this->sendAuthenticationEmail($user, $e->getError()->payment_intent->id);
}
}
}
Обработка неудачных списаний
Неудачи — норма для рекуррентных платежей. Стандартная стратегия повтора:
class RecurringChargeJob implements ShouldQueue
{
public int $tries = 4;
// Экспоненциальный backoff: 1 день, 3 дня, 7 дней, финальная попытка
public function backoff(): array
{
return [86400, 259200, 604800, 604800];
}
public function handle(): void
{
$subscription = Subscription::find($this->subscriptionId);
if ($subscription->failed_attempts >= 3) {
$subscription->update(['status' => 'past_due']);
Mail::to($subscription->user)->send(new PaymentFailedMail($subscription));
return;
}
try {
app(RecurringPaymentService::class)->charge($subscription);
$subscription->update([
'failed_attempts' => 0,
'status' => 'active',
'next_charge_at' => now()->addMonth(),
]);
} catch (PaymentFailedException $e) {
$subscription->increment('failed_attempts');
throw $e; // Job retry
}
}
}
Dunning-логика (работа с просроченными платежами) — одна из самых сложных частей подписочных сервисов. Нужно балансировать между: не отключать сервис слишком быстро (пользователь мог просто поменять карту) и не давать слишком много grace period (упущенная выручка).
Stripe Billing — готовое решение
Если управлять расписанием самостоятельно нет смысла, Stripe Billing делает это за вас:
// Создать продукт и цену
$product = \Stripe\Product::create(['name' => 'Pro Plan']);
$price = \Stripe\Price::create([
'unit_amount' => 2900,
'currency' => 'usd',
'recurring' => ['interval' => 'month'],
'product' => $product->id,
]);
// Создать подписку
$subscription = \Stripe\Subscription::create([
'customer' => $user->stripe_customer_id,
'items' => [['price' => $price->id]],
'payment_behavior' => 'default_incomplete',
'expand' => ['latest_invoice.payment_intent'],
]);
// Webhook обработает все события: invoice.paid, invoice.payment_failed,
// customer.subscription.deleted, etc.
Webhook для Stripe Billing
match ($event->type) {
'invoice.paid' => $this->onInvoicePaid($event->data->object),
'invoice.payment_failed' => $this->onPaymentFailed($event->data->object),
'customer.subscription.deleted' => $this->onSubscriptionCancelled($event->data->object),
'customer.subscription.updated' => $this->onSubscriptionUpdated($event->data->object),
default => null,
};
Рекуррентные платежи через CloudPayments
// Первый платёж — получаем Token в webhook
// Повторное списание:
Http::withBasicAuth(env('CP_PUBLIC_ID'), env('CP_API_SECRET'))
->post('https://api.cloudpayments.ru/payments/tokens/charge', [
'Amount' => 2900,
'Currency' => 'RUB',
'AccountId' => $user->email,
'Token' => $user->cp_card_token,
'InvoiceId' => 'sub-' . $subscriptionPeriodId,
'Description' => "Подписка за {$month}",
]);
Хранение данных подписки
CREATE TABLE subscriptions (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id),
plan_id BIGINT NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'active',
-- active, past_due, cancelled, paused
payment_method_id VARCHAR(255),
stripe_subscription_id VARCHAR(255),
current_period_start TIMESTAMP,
current_period_end TIMESTAMP,
next_charge_at TIMESTAMP,
failed_attempts SMALLINT DEFAULT 0,
cancelled_at TIMESTAMP,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX idx_subscriptions_next_charge ON subscriptions(next_charge_at)
WHERE status = 'active';
Индекс по next_charge_at критичен — планировщик будет регулярно выбирать подписки к списанию именно по этому полю.







