Интеграция рекуррентных платежей на сайт

Наша компания занимается разработкой, поддержкой и обслуживанием сайтов любой сложности. От простых одностраничных сайтов до масштабных кластерных систем построенных на микро сервисах. Опыт разработчиков подтвержден сертификатами от вендоров.
Разработка и обслуживание любых видов сайтов:
Информационные сайты или веб-приложения
Сайты визитки, landing page, корпоративные сайты, онлайн каталоги, квиз, промо-сайты, блоги, новостные ресурсы, информационные порталы, форумы, агрегаторы
Сайты или веб-приложения электронной коммерции
Интернет-магазины, B2B-порталы, маркетплейсы, онлайн-обменники, кэшбэк-сайты, биржи, дропшиппинг-платформы, парсеры товаров
Веб-приложения для управления бизнес-процессами
CRM-системы, ERP-системы, корпоративные порталы, системы управления производством, парсеры информации
Сайты или веб-приложения электронных услуг
Доски объявлений, онлайн-школы, онлайн-кинотеатры, конструкторы сайтов, порталы предоставления электронных услуг, видеохостинги, тематические порталы

Это лишь некоторые из технических типов сайтов, с которыми мы работаем, и каждый из них может иметь свои специфические особенности и функциональность, а также быть адаптированным под конкретные потребности и цели клиента

Предлагаемые услуги
Показано 1 из 1 услугВсе 2065 услуг
Интеграция рекуррентных платежей на сайт
Сложная
~2-3 рабочих дня
Часто задаваемые вопросы
Наши компетенции:
Этапы разработки
Последние работы
  • image_website-b2b-advance_0.png
    Разработка сайта компании B2B ADVANCE
    1214
  • image_web-applications_feedme_466_0.webp
    Разработка веб-приложения для компании FEEDME
    1161
  • image_websites_belfingroup_462_0.webp
    Разработка веб-сайта для компании БЕЛФИНГРУПП
    852
  • image_ecommerce_furnoro_435_0.webp
    Разработка интернет магазина для компании FURNORO
    1041
  • image_crm_enviok_479_0.webp
    Разработка веб-приложения для компании Enviok
    823
  • image_bitrix-bitrix-24-1c_fixper_448_0.png
    Разработка веб-сайта для компании ФИКСПЕР
    815

Интеграция рекуррентных платежей на сайт

Рекуррентные (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 критичен — планировщик будет регулярно выбирать подписки к списанию именно по этому полю.