Integration of Recurring Payments on Website
Recurring (recurring) payments are automatic periodic debits from a card or other payment instrument without user participation in each transaction. Used in subscription services, SaaS, utility payment systems, marketplaces with automatic balance top-up.
Implementation Models
There are two fundamentally different approaches:
1. Card Tokenization (Card-on-file) — first payment goes through standard flow, gateway returns card_token. All subsequent debits initiated by server using this token, without user participation.
2. Subscription API of gateway — gateway (Stripe, CloudPayments, etc.) manages schedule itself, sends reminders and handles failures. Server only reacts to webhook events.
Second approach is more reliable, but ties to specific gateway. First gives more control.
Implementation via Tokenization (Stripe)
First payment — saving payment method:
\Stripe\Stripe::setApiKey(config('services.stripe.secret'));
// Create Customer once for each user
$stripeCustomer = \Stripe\Customer::create([
'email' => $user->email,
'metadata' => ['user_id' => $user->id],
]);
$user->update(['stripe_customer_id' => $stripeCustomer->id]);
// SetupIntent for saving card without immediate debit
$setupIntent = \Stripe\SetupIntent::create([
'customer' => $user->stripe_customer_id,
'usage' => 'off_session', // for subsequent debits without 3DS
'automatic_payment_methods' => ['enabled' => true],
]);
return response()->json(['clientSecret' => $setupIntent->client_secret]);
Client — confirming SetupIntent:
const { setupIntent, error } = await stripe.confirmSetup({
elements,
confirmParams: {
return_url: `${window.location.origin}/billing/setup-complete`,
},
redirect: 'if_required',
});
if (setupIntent?.status === 'succeeded') {
// Card saved, payment_method ID - setupIntent.payment_method
await savePaymentMethod(setupIntent.payment_method as string);
}
Server — subsequent debit without user participation:
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, // critical for recurring
'description' => "Subscription - {$user->id}",
]);
if ($paymentIntent->status === 'succeeded') {
$this->recordSuccessfulCharge($user, $paymentIntent);
}
} catch (\Stripe\Exception\CardException $e) {
// Card declined — notify user, update subscription status
$this->handleFailedCharge($user, $e->getError()->decline_code);
} catch (\Stripe\Exception\InvalidRequestException $e) {
if ($e->getStripeCode() === 'authentication_required') {
// Additional authentication required (3DS)
$this->sendAuthenticationEmail($user, $e->getError()->payment_intent->id);
}
}
}
Handling Failed Charges
Failures are normal for recurring payments. Standard retry strategy:
class RecurringChargeJob implements ShouldQueue
{
public int $tries = 4;
// Exponential backoff: 1 day, 3 days, 7 days, final attempt
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 logic (handling overdue payments) is one of most complex parts of subscription services. Need to balance between: not disabling service too quickly (user may have just changed card) and not giving too much grace period (lost revenue).
Stripe Billing — Ready Solution
If managing schedule yourself doesn't make sense, Stripe Billing does it for you:
// Create product and price
$product = \Stripe\Product::create(['name' => 'Pro Plan']);
$price = \Stripe\Price::create([
'unit_amount' => 2900,
'currency' => 'usd',
'recurring' => ['interval' => 'month'],
'product' => $product->id,
]);
// Create subscription
$subscription = \Stripe\Subscription::create([
'customer' => $user->stripe_customer_id,
'items' => [['price' => $price->id]],
'payment_behavior' => 'default_incomplete',
'expand' => ['latest_invoice.payment_intent'],
]);
// Webhook will handle all events: invoice.paid, invoice.payment_failed,
// customer.subscription.deleted, etc.
Webhook for 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,
};
Recurring Payments via CloudPayments
// First payment — get Token in webhook
// Subsequent debit:
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' => "Subscription for {$month}",
]);
Subscription Data Storage
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';
Index on next_charge_at is critical — scheduler will regularly select subscriptions for debit by this field.







