Разработка системы подписки на регулярные поставки для интернет-магазина
Подписка меняет экономику интернет-магазина: покупатель больше не принимает решение о покупке каждый раз — он принял его один раз. Для магазина это предсказуемый денежный поток и снижение стоимости повторной продажи. Технически это одна из сложнейших задач в e-commerce: рекуррентные платежи, управление расписанием, обработка сбоев оплаты, управление паузами и отменами.
Архитектура системы
Система подписок состоит из трёх независимых слоёв:
1. Subscription core — хранение планов, подписок, периодов 2. Billing engine — рекуррентные платежи, retry-логика, dunning 3. Fulfillment — генерация заказов и управление отгрузкой
Разделение принципиально: сбой биллинга не должен блокировать выполнение уже оплаченных подписок, а изменение плана не должно ломать текущие биллинг-циклы.
Схема данных
CREATE TABLE subscription_plans (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
slug VARCHAR(100) NOT NULL UNIQUE,
interval_type VARCHAR(20) NOT NULL, -- day | week | month | year
interval_count INTEGER NOT NULL DEFAULT 1,
trial_days INTEGER NOT NULL DEFAULT 0,
is_active BOOLEAN NOT NULL DEFAULT true
);
CREATE TABLE subscription_plan_items (
id BIGSERIAL PRIMARY KEY,
plan_id BIGINT NOT NULL REFERENCES subscription_plans(id),
variant_id BIGINT NOT NULL REFERENCES product_variants(id),
qty INTEGER NOT NULL DEFAULT 1,
discount NUMERIC(5,2) NOT NULL DEFAULT 0 -- скидка подписчика в %
);
CREATE TABLE subscriptions (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id),
plan_id BIGINT NOT NULL REFERENCES subscription_plans(id),
status VARCHAR(50) NOT NULL DEFAULT 'active',
-- trial | active | paused | past_due | cancelled | expired
payment_method_id VARCHAR(255) NOT NULL, -- токен из платёжной системы
current_period_start TIMESTAMP NOT NULL,
current_period_end TIMESTAMP NOT NULL,
trial_ends_at TIMESTAMP,
paused_at TIMESTAMP,
resumes_at TIMESTAMP,
cancelled_at TIMESTAMP,
cancel_reason TEXT,
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE TABLE subscription_billing_attempts (
id BIGSERIAL PRIMARY KEY,
subscription_id BIGINT NOT NULL REFERENCES subscriptions(id),
amount NUMERIC(12,2) NOT NULL,
status VARCHAR(50) NOT NULL, -- success | failed | pending
payment_id VARCHAR(255),
failure_code VARCHAR(100),
failure_message TEXT,
attempted_at TIMESTAMP NOT NULL DEFAULT NOW(),
next_retry_at TIMESTAMP
);
Рекуррентные платежи
Ключевой вопрос — какой платёжный шлюз поддерживает токенизацию карты и серверные списания без участия пользователя:
| Шлюз | Рекуррент | Особенности |
|---|---|---|
| Stripe | PaymentIntents + SetupIntents |
Лучшее API, SCA-ready |
| ЮKassa | recurring_object |
Актуально для РФ |
| CloudPayments | token-платежи |
Подходит для СНГ |
| Robokassa | Рекуррент через Recurring |
Базовый функционал |
Пример с CloudPayments — типичный для Беларуси/России:
class RecurringBillingService
{
public function chargeSubscription(Subscription $subscription): BillingAttempt
{
$amount = $this->calculateAmount($subscription);
$attempt = BillingAttempt::create([
'subscription_id' => $subscription->id,
'amount' => $amount,
'status' => 'pending',
]);
try {
$result = $this->cloudpayments->chargeToken([
'Token' => $subscription->payment_method_id,
'Amount' => $amount,
'Currency' => 'RUB',
'AccountId' => $subscription->user_id,
'Description' => "Подписка #{$subscription->id}",
]);
$attempt->update(['status' => 'success', 'payment_id' => $result->TransactionId]);
$this->advancePeriod($subscription);
} catch (PaymentDeclinedException $e) {
$attempt->update([
'status' => 'failed',
'failure_code' => $e->getCode(),
'failure_message' => $e->getMessage(),
'next_retry_at' => $this->calculateRetryTime($attempt),
]);
$this->handleFailedPayment($subscription, $attempt);
}
return $attempt;
}
}
Dunning — обработка просроченных платежей
Dunning — процесс повторных попыток списания при неудаче. Стандартная схема:
День 0: Списание не прошло → статус past_due, попытка 1
День 3: Retry попытка 2 + email "Проблема с оплатой"
День 7: Retry попытка 3 + email с кнопкой обновить карту
День 14: Retry попытка 4 + SMS
День 21: Подписка отменяется автоматически
Каждый шаг — джоб в очереди:
// Планируется при неудачном списании
RetrySubscriptionPayment::dispatch($subscription)
->delay(now()->addDays(3));
// Планируется при второй неудаче
SendDunningEmail::dispatch($subscription, 'update_card')
->delay(now()->addDays(7));
Важно: при неудаче сервис всё равно отгружает текущий период — подписчик не должен страдать за технический сбой. Подписка переходит в past_due, но заказ создаётся.
Управление паузами
Пауза — конкурентное преимущество перед полной отменой. Подписчик указывает дату возобновления:
class SubscriptionManager
{
public function pause(Subscription $subscription, Carbon $resumeAt): void
{
if ($resumeAt->lte(now())) {
throw new InvalidResumeDateException();
}
$subscription->update([
'status' => 'paused',
'paused_at' => now(),
'resumes_at' => $resumeAt,
]);
// Отменяем следующее списание
$subscription->pendingBillingJobs()->delete();
// Планируем возобновление
ResumeSubscription::dispatch($subscription)->delay($resumeAt);
}
}
Максимальный срок паузы — конфигурируемый (обычно 3 месяца). Паузы в середине периода: неиспользованное время не «сгорает» — следующий период начинается от resumes_at.
Кастомизация подписки
Подписчик должен управлять своей подпиской самостоятельно:
- Изменить состав (добавить/убрать товары)
- Изменить объём (×1, ×2 и т.д.)
- Изменить интервал (раз в 2 недели → раз в месяц)
- Пропустить один период
- Поменять адрес доставки
- Обновить карту
Изменение тарифного плана mid-cycle требует пересчёта суммы и возможного кредитования или доначисления:
// Upgrade в середине цикла: docharge за разницу
$unusedDays = now()->diffInDays($subscription->current_period_end);
$totalDays = $subscription->current_period_start->diffInDays($subscription->current_period_end);
$prorationFactor = $unusedDays / $totalDays;
$chargeNow = ($newPlan->price - $currentPlan->price) * $prorationFactor;
Генерация заказов
В день отгрузки создаётся реальный заказ:
class SubscriptionOrderFactory
{
public function createFromSubscription(Subscription $subscription): Order
{
return DB::transaction(function () use ($subscription) {
$order = Order::create([
'user_id' => $subscription->user_id,
'source' => 'subscription',
'subscription_id' => $subscription->id,
'delivery_address_id' => $subscription->delivery_address_id,
]);
foreach ($subscription->plan->items as $planItem) {
$price = $planItem->variant->price
* (1 - $planItem->discount / 100);
$order->items()->create([
'variant_id' => $planItem->variant_id,
'qty' => $planItem->qty,
'unit_price' => $price,
]);
$this->stockService->reserve($planItem->variant_id, $planItem->qty);
}
return $order;
});
}
}
Личный кабинет подписчика
Минимальный UI для управления подпиской:
- История поставок с возможностью просмотра каждого заказа
- Ближайшая дата следующей отгрузки
- Кнопки: «Пропустить следующую», «Пауза», «Отменить»
- Управление составом и объёмом
- История платежей с возможностью скачать чек
Сроки реализации
- Базовые подписки + рекуррентный биллинг (один шлюз): 7–10 дней
- Dunning + retry-логика + уведомления: 3–4 дня
- Управление паузами, пропусками, апгрейдом плана: 3–4 дня
- Личный кабинет подписчика: 3–5 дней
- Аналитика (MRR, churn, LTV): +2–3 дня
Полная система: 3–5 недель в зависимости от платёжного шлюза и сложности тарифной сетки.







