Інтеграція рекурентних платежів на веб-сайт
Рекурентні платежі — це автоматичні періодичні списання з карти або іншого платіжного інструменту без участі користувача в кожній транзакції. Використовуються в підписочних сервісах, 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, тощо
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 критичний — планувальник буде регулярно вибирати підписки до списання саме за цим полем.







