Інтеграція рекурентних платежів на сайт

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

Це лише деякі з технічних типів сайтів, з якими ми працюємо, і кожен із них може мати свої специфічні особливості та функціональність, а також бути адаптованим під конкретні потреби та цілі клієнта.

Пропоновані послуги
Показано 1 з 1 послугУсі 2065 послуг
Інтеграція рекурентних платежів на сайт
Складна
~2-3 робочих дні
Часті питання
Наші компетенції:
Етапи розробки
Останні роботи
  • image_website-b2b-advance_0.png
    Розробка сайту компанії B2B ADVANCE
    1262
  • image_web-applications_feedme_466_0.webp
    Розробка веб-додатків для компанії FEEDME
    1171
  • image_websites_belfingroup_462_0.webp
    Розробка веб-сайту для компанії БЕЛФІНГРУП
    874
  • image_ecommerce_furnoro_435_0.webp
    Розробка інтернет магазину для компанії FURNORO
    1094
  • image_crm_enviok_479_0.webp
    Розробка веб-додатків для компанії Enviok
    831
  • image_bitrix-bitrix-24-1c_fixper_448_0.png
    Розробка веб-сайту для компанії ФІКСПЕР
    851

Інтеграція рекурентних платежів на веб-сайт

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