Реалізація продажу онлайн-курсів (доступ після оплати) на сайті
Платформа продажу курсів — це не «додати товар в WooCommerce». Це зв'язка з платіжного шлюзу, системи управління доступом, відеохостингу та прогрес-трекінгу. Кожен шар має власну логіку відмови, а помилка в будь-якому означає або втрату грошей, або витік контенту до неоплачених користувачів.
Архітектурний скелет
Типова схема:
[Користувач] → [Checkout] → [Payment Gateway]
↓
[Webhook Handler]
↓
[Enrollment Service] → [DB: enrollments]
↓
[Access Control Layer]
↓
[LMS / Video Delivery / Downloads]
Webhook handler критичний — саме він створює запис про доступ після підтвердження оплати від шлюзу. Спроба виділити доступ синхронно в момент редиректу після оплати — класична помилка, що призводить до race conditions при збоях мережі.
Платіжні шлюзи та інтеграція
Stripe — предпочтительний варіант для міжнародних платежів. Використовуємо stripe.checkout.sessions.create з mode: 'payment' або mode: 'subscription' для підписочної моделі.
const session = await stripe.checkout.sessions.create({
mode: 'payment',
line_items: [{
price_data: {
currency: 'usd',
product_data: { name: course.title },
unit_amount: course.price_cents,
},
quantity: 1,
}],
metadata: { course_id: course.id, user_id: user.id },
success_url: `${BASE_URL}/courses/${course.slug}/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${BASE_URL}/courses/${course.slug}`,
});
Після оплати Stripe надсилає подію checkout.session.completed на webhook-ендпоінт. Валідуємо підпис (stripe.webhooks.constructEvent), витягуємо metadata.course_id та metadata.user_id, створюємо enrollment.
Для рунету підключаємо ЮKassa або Robokassa. Обидва працюють через аналогічну схему: сповіщення про платіж → перевірка підпису → виділення доступу.
Idempotency: webhook може прийти двічі. Enrollment-запис створюється з унікальним індексом за (user_id, course_id) або payment_id — другий виклик просто повертає існуючу запис.
Управління доступом
Таблиця enrollments:
| Поле | Тип | Опис |
|---|---|---|
| id | uuid | первинний ключ |
| user_id | bigint | FK → users |
| course_id | bigint | FK → courses |
| payment_id | varchar | ID транзакції від шлюзу |
| expires_at | timestamp | NULL = безстроково |
| status | enum | active / suspended / refunded |
| created_at | timestamp | дата покупки |
Middleware на кожному захищеному маршруті перевіряє наявність активної записи:
// Laravel Gate
Gate::define('access-course', function (User $user, Course $course) {
return $user->enrollments()
->where('course_id', $course->id)
->where('status', 'active')
->where(function ($q) {
$q->whereNull('expires_at')
->orWhere('expires_at', '>', now());
})
->exists();
});
Відеодоставка та захист контенту
Не можна давати користувачам прямі посилання на відео — вони їх поділять. Варіанти:
Signed URLs (AWS S3 / CloudFront):
$url = $s3->createPresignedRequest(
$s3->getCommand('GetObject', [
'Bucket' => 'courses-bucket',
'Key' => "courses/{$courseId}/lesson-{$lessonId}.mp4",
]),
'+2 hours'
)->getUri();
Посилання живе 2 години та прив'язане до конкретного файлу. Після закінчення — 403.
Vimeo Private / Mux: для продакшену з великим трафіком краще використовувати спеціалізовані відеоплатформи. Mux надає адаптивний стриміинг (HLS), аналітику переглядів та захист через підписані playback tokens:
const playbackId = lesson.mux_playback_id;
const token = await signMuxToken(playbackId, 'video', {
expiration: '2h',
params: { user_id: userId },
});
const src = `https://stream.mux.com/${playbackId}.m3u8?token=${token}`;
Для PDF/EPUB — аналогічно: генеруємо підписане посилання на завантаження, логуємо кожен доступ в content_access_logs.
Прогрес та сертифікати
Прогрес по урокам зберігається в lesson_progress(user_id, lesson_id, completed_at, watch_percent). Фронтенд надсилає eventos завершення:
// Кожні 30 секунд або при pause/end
videoPlayer.on('timeupdate', debounce(() => {
api.post('/progress', {
lesson_id: lessonId,
watch_percent: Math.round((player.currentTime / player.duration) * 100),
});
}, 5000));
Сертифікат генерується автоматично, коли watch_percent >= 80 для всіх уроків курсу. Для генерації PDF використовуємо puppeteer (рендер HTML-шаблону) або PDFKit для простих випадків.
Пробний доступ (preview)
Перші 1-2 урока зазвичай відкриті без оплати. Флаг is_free_preview на рівні урока, перевірка в middleware:
if ($lesson->is_free_preview || Gate::allows('access-course', $course)) {
return $next($request);
}
return redirect()->route('course.buy', $course);
Повернення
При поверненні платежу Stripe надсилає charge.refunded. Змінюємо enrollments.status = 'refunded', користувач втрачає доступ миттєво. Для ЮKassa — аналогічний webhook payment.canceled.
Строки реалізації
| Етап | Час |
|---|---|
| Базова інтеграція Stripe + enrollment | 2–3 дні |
| Захищена відеодоставка (S3 signed URL) | 1–2 дні |
| Прогрес-трекінг + UI прогрес-бара | 1–2 дні |
| Сертифікати (PDF-генерація) | 1 день |
| Інтеграція ЮKassa / другого шлюзу | 1–2 дні |
| Адміністративний дашборд (enrollments, revenue) | 2–3 дні |
Мінімальна робоча версія — 7–10 робочих днів.
Типові помилки
Виділення доступу до підтвердження від шлюзу. Користувач нажав кнопку оплати → попав на success-сторінку → отримав доступ. Платіж при цьому міг не пройти. Доступ виділяється тільки через webhook.
Зберігання відео на тому ж сервері що й додаток. Bandwidth убиває сервер при 50+ одночасних переглядах. Відео — тільки в об'єктному сховищі або в спеціалізованого провайдера.
Відсутність логів доступу. При спірній ситуації з користувачем («я заплатив, доступ не дали») нема можливості відновити ланцюжок подій. Логувати payment_id, webhook timestamp, enrollment_created_at обов'язково.







