Розробка системи подписки на регулярні поставки для E-Commerce
Подписка змінює економіку інтернет-магазину: покупатель більше не приймає рішення про покупку кожен раз — він його прийняв один раз. Для магазину це передбачувана грошовий потік та зниження вартості повторної продажі. Технічно це одна з найскладніших задач у 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 |
| ЮКасса | 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 в середині цикла: доcharge за різницю
$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 тижнів залежно від платіжного шлюзу та складності тарифної сітки.







