Реалізація продажу підписок на цифровий контент на сайті
Підписочна модель складніше разової продажі: потрібно управляти життєвим циклом підписки, автоматичним продовженням, доступом до контенту залежно від активності, апгрейдами та даунгрейдами тарифів, а також коректно обробляти сценарії з істекшими картами та неуспішними списаннями.
Ключові концепції
Plan — тарифний план з ціною, періодом та набором прав доступу (features).
Subscription — екземпляр підписки конкретного користувача на конкретний план.
Billing period — розрахунковий період (місяць, рік, квартал).
Grace period — льготний період після неуспішного списання до блокування доступу.
Trial — пробний період без оплати.
Схема даних
Schema::create('subscription_plans', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('slug')->unique();
$table->decimal('price_monthly', 10, 2)->nullable();
$table->decimal('price_yearly', 10, 2)->nullable();
$table->integer('trial_days')->default(0);
$table->jsonb('features'); // {"downloads_per_month": 50, "max_quality": "4k", ...}
$table->boolean('is_active')->default(true);
$table->timestamps();
});
Schema::create('subscriptions', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained();
$table->foreignId('plan_id')->constrained('subscription_plans');
$table->enum('billing_period', ['monthly', 'yearly']);
$table->enum('status', ['trialing', 'active', 'past_due', 'cancelled', 'expired']);
$table->string('payment_provider'); // stripe, yookassa, paddle
$table->string('provider_subscription_id')->nullable(); // ID у платіжній системі
$table->string('provider_customer_id')->nullable();
$table->timestamp('trial_ends_at')->nullable();
$table->timestamp('current_period_start');
$table->timestamp('current_period_end');
$table->timestamp('grace_period_ends_at')->nullable();
$table->timestamp('cancelled_at')->nullable();
$table->timestamp('ends_at')->nullable(); // null = до скасування
$table->timestamps();
$table->index(['user_id', 'status']);
$table->index('current_period_end');
});
Створення підписки
class CreateSubscriptionAction
{
public function execute(User $user, Plan $plan, string $billingPeriod, string $paymentMethodId): Subscription
{
$provider = PaymentProviderFactory::make(config('subscriptions.provider'));
// Створюємо або отримуємо клієнта у платіжній системі
$customerId = $user->payment_customer_id
?? $this->createCustomer($provider, $user);
// Створюємо підписку у платіжній системі
$providerSub = $provider->createSubscription([
'customer' => $customerId,
'payment_method' => $paymentMethodId,
'price_id' => $plan->getProviderPriceId($billingPeriod),
'trial_end' => $plan->trial_days > 0
? now()->addDays($plan->trial_days)->timestamp
: 'now',
]);
return DB::transaction(function () use ($user, $plan, $billingPeriod, $customerId, $providerSub) {
$subscription = Subscription::create([
'user_id' => $user->id,
'plan_id' => $plan->id,
'billing_period' => $billingPeriod,
'status' => $providerSub->status, // trialing або active
'payment_provider' => config('subscriptions.provider'),
'provider_subscription_id' => $providerSub->id,
'provider_customer_id' => $customerId,
'trial_ends_at' => $plan->trial_days > 0
? now()->addDays($plan->trial_days)
: null,
'current_period_start' => now(),
'current_period_end' => $this->calcPeriodEnd($billingPeriod),
]);
event(new SubscriptionCreatedEvent($subscription));
return $subscription;
});
}
}
Webhook від платіжної системи
Всі зміни статусу підписки приходять через webhook:
class StripeWebhookHandler
{
public function handle(array $payload): void
{
match($payload['type']) {
'invoice.paid' => $this->onInvoicePaid($payload),
'invoice.payment_failed' => $this->onPaymentFailed($payload),
'customer.subscription.updated' => $this->onSubscriptionUpdated($payload),
'customer.subscription.deleted' => $this->onSubscriptionDeleted($payload),
default => null,
};
}
private function onInvoicePaid(array $payload): void
{
$providerSubId = $payload['data']['object']['subscription'];
$sub = Subscription::where('provider_subscription_id', $providerSubId)->firstOrFail();
$sub->update([
'status' => 'active',
'grace_period_ends_at' => null,
'current_period_start' => Carbon::createFromTimestamp($payload['data']['object']['period_start']),
'current_period_end' => Carbon::createFromTimestamp($payload['data']['object']['period_end']),
]);
event(new SubscriptionRenewedEvent($sub));
}
private function onPaymentFailed(array $payload): void
{
$providerSubId = $payload['data']['object']['subscription'];
$sub = Subscription::where('provider_subscription_id', $providerSubId)->firstOrFail();
$sub->update([
'status' => 'past_due',
'grace_period_ends_at' => now()->addDays(config('subscriptions.grace_period_days', 3)),
]);
// Сповіщаємо користувача
$sub->user->notify(new PaymentFailedNotification($sub));
event(new SubscriptionPaymentFailedEvent($sub));
}
}
Контроль доступу до контенту
class SubscriptionAccessGuard
{
public function canAccess(User $user, string $feature): bool
{
$subscription = $user->activeSubscription();
if (!$subscription) {
return false;
}
// active або в grace period
if (!in_array($subscription->status, ['active', 'trialing', 'past_due'])) {
return false;
}
// У grace period — доступ збережений
if ($subscription->status === 'past_due') {
if ($subscription->grace_period_ends_at?->isPast()) {
return false;
}
}
// Перевіряємо feature у тарифному плані
$features = $subscription->plan->features;
return isset($features[$feature]) && $features[$feature] !== false;
}
public function getFeatureValue(User $user, string $feature): mixed
{
$subscription = $user->activeSubscription();
return $subscription?->plan->features[$feature] ?? null;
}
}
Апгрейд та даунгрейд тарифу
class ChangePlanAction
{
public function execute(Subscription $sub, Plan $newPlan, string $billingPeriod): void
{
$provider = PaymentProviderFactory::make($sub->payment_provider);
// Stripe підтримує негайний апгрейд з пропорційним розрахунком
$provider->updateSubscription($sub->provider_subscription_id, [
'items' => [[
'id' => $sub->provider_item_id,
'price' => $newPlan->getProviderPriceId($billingPeriod),
]],
'proration_behavior' => 'always_invoice', // одразу виставити рахунок за різницю
]);
$sub->update([
'plan_id' => $newPlan->id,
'billing_period' => $billingPeriod,
]);
event(new SubscriptionPlanChangedEvent($sub, $newPlan));
}
}
Скасування підписки
Два варіанти скасування:
- Негайне — доступ припиняється одразу, можливий повернення
-
В кінці періоду — доступ до
current_period_end, без повернення
public function cancel(Subscription $sub, bool $immediately = false): void
{
$provider = PaymentProviderFactory::make($sub->payment_provider);
if ($immediately) {
$provider->cancelSubscription($sub->provider_subscription_id);
$sub->update(['status' => 'cancelled', 'ends_at' => now(), 'cancelled_at' => now()]);
} else {
$provider->cancelSubscriptionAtPeriodEnd($sub->provider_subscription_id);
$sub->update(['cancelled_at' => now(), 'ends_at' => $sub->current_period_end]);
}
}
Ліміти в рамках періоду
Якщо тариф обмежує кількість завантажень на місяць:
class MonthlyDownloadLimiter
{
public function canDownload(User $user): bool
{
$subscription = $user->activeSubscription();
$limit = $subscription?->plan->features['downloads_per_month'] ?? 0;
if ($limit === -1) return true; // безліміт
$used = DownloadEvent::where('user_id', $user->id)
->where('downloaded_at', '>=', $subscription->current_period_start)
->count();
return $used < $limit;
}
}
Строки реалізації
| Компонент | Строк |
|---|---|
| Схема даних, моделі, базові CRUD | 2 дні |
| Створення підписки + інтеграція Stripe/ЮKassa | 3 дні |
| Webhook-обробник (оплата, сбій, скасування) | 2 дні |
| Контроль доступу до контенту | 1 день |
| Апгрейд / даунгрейд тарифу | 2 дні |
| Особистий кабінет (управління підпиской) | 2 дні |
| Сповіщення (продовження, сбій, закінчення) | 1 день |
| Тестування всіх сценаріїв | 3 дні |
Разом: 16–20 робочих днів для повнофункціональної підписочної системи.







