Реалізація продажу підписок на цифровий контент на сайті

Наша компанія займається розробкою, підтримкою та обслуговуванням сайтів будь-якої складності. Від простих односторінкових сайтів до масштабних кластерних систем, побудованих на мікро сервісах. Досвід розробників підтверджено сертифікатами від вендорів.

Розробка та обслуговування будь-яких видів сайтів:

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

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

Пропоновані послуги
Показано 1 з 1 послугУсі 2065 послуг
Реалізація продажу підписок на цифровий контент на сайті
Складна
~1-2 тижні
Часті питання

Наші компетенції:

Етапи розробки

Останні роботи

  • 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

Реалізація продажу підписок на цифровий контент на сайті

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

Ключові концепції

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));
    }
}

Скасування підписки

Два варіанти скасування:

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