Skip to content
Назад в блог

Монетизация SaaS: Как безболезненно настроить Stripe, подписки и биллинг

12 мин. чтения
Монетизация SaaS: Как безболезненно настроить Stripe, подписки и биллинг

Интеграция рекуррентных платежей в SaaS-продукты обманчиво проста на словах. Но если разовое списание средств с карты реализуется быстро, то управление жизненным циклом подписок вскрывает десятки подводных камней: отслеживание истечения срока действия карт, интервалы повторных попыток списания, апгрейды и даунгрейды тарифных планов, льготные периоды (grace periods) и обработка вебхуков.

Если вебхук от платежной системы обработан некорректно или не в том порядке, вы рискуете заблокировать платящих клиентов либо предоставить бесплатный доступ тем, кто уже отменил подписку.

В этом практическом руководстве мы настроим отказоустойчивый обработчик Stripe Webhooks с проверкой подписи на Node.js/TypeScript и опишем процесс синхронизации данных.

1. Анатомия жизненного цикла подписки

Ваша база данных должна точно отслеживать статус подписки клиента. Stripe использует несколько ключевых статусов, которые вы должны синхронизировать со своей БД:

               +--------------------------------------+
               |               trialing               |
               +------------------+-------------------+
                                  | (Триал завершен или оформлена оплата)
                                  v
+------------------->  +----------------------+
|                      |        active        | <------------------+
|                      +----------+-----------+                    |
| (Карта обновлена,               |                                |
|  списание успешно)              | (Списание не прошло, Stripe        | (Продление
|                                 |  повторяет попытки)                |  до окончания
|                                 v                                |  периода)
|                      +----------+-----------+                    |
+---------------------+|       past_due       |                    |
                       +----------+-----------+                    |
                                  | (Все попытки списания провалились) |
                                  v                                |
                       +----------+-----------+                    |
                       |       canceled       | -------------------+
                       +----------------------+
  • trialing: Бесплатный ознакомительный период. Доступ открыт.
  • active: Платеж прошел успешно. Полный доступ.
  • past_due: Очередной платеж не прошел. Не блокируйте пользователя сразу. Переведите аккаунт в предупредительный статус (льготный период от 3 до 7 дней), отключите ресурсоемкие API-методы и отправьте напоминание об обновлении платежных данных.
  • canceled: Подписка полностью аннулирована. Доступ закрыт. Для возобновления клиент должен заново привязать карту.

2. Реализация обработчика Stripe Webhooks с верификацией подписи

Stripe отправляет асинхронные уведомления об изменениях статусов через HTTP POST запросы. Поскольку любой злоумышленник может отправить поддельный запрос на ваш эндпоинт /api/billing/webhook, вы обязаны валидировать криптографическую подпись Stripe в заголовке stripe-signature.

Пример реализации обработчика на Node.js/TypeScript и Express:

import express from 'express';
import Stripe from 'stripe';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  apiVersion: '2023-10-16',
});

const app = express();
const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET!;

// ВАЖНО: Проверка подписи Stripe требует сырого буфера тела запроса (raw body).
// Не применяйте bodyParser.json() к этому маршруту до проверки подписи.
app.post(
  '/api/billing/webhook',
  express.raw({ type: 'application/json' }),
  async (req: express.Request, res: express.Response) => {
    const sig = req.headers['stripe-signature'];
    
    if (!sig) {
      return res.status(400).send('Отсутствует заголовок stripe-signature.');
    }

    let event: Stripe.Event;

    try {
      // Проверяем подпись вебхука, используя секрет из кабинета Stripe
      event = stripe.webhooks.constructEvent(req.body, sig, endpointSecret);
    } catch (err: any) {
      console.error(`[Security Warning] Ошибка проверки подписи: ${err.message}`);
      return res.status(400).send(`Webhook Error: ${err.message}`);
    }

    try {
      switch (event.type) {
        case 'customer.subscription.created':
        case 'customer.subscription.updated': {
          const subscription = event.data.object as Stripe.Subscription;
          await handleSubscriptionUpdate(subscription);
          break;
        }
        case 'customer.subscription.deleted': {
          const subscription = event.data.object as Stripe.Subscription;
          await handleSubscriptionDeletion(subscription);
          break;
        }
        case 'invoice.payment_failed': {
          const invoice = event.data.object as Stripe.Invoice;
          await handlePaymentFailure(invoice);
          break;
        }
        default:
          console.log(`[Stripe Webhook] Пропущено событие: ${event.type}`);
      }

      res.json({ received: true });
    } catch (err: any) {
      console.error(`[Webhook Process Error] Ошибка обработки события: ${err.message}`);
      res.status(500).send('Internal Server Error');
    }
  }
);

async function handleSubscriptionUpdate(sub: Stripe.Subscription) {
  const tenantId = sub.metadata.tenantId;
  if (!tenantId) {
    throw new Error(`В метаданных подписки ${sub.id} отсутствует ID клиента (tenantId).`);
  }

  const status = sub.status; // 'active', 'trialing', 'past_due' и др.
  const priceId = sub.items.data[0].price.id;
  const currentPeriodEnd = new Date(sub.current_period_end * 1000);

  // Синхронизируем статус в нашей базе данных
  await db.query(
    `UPDATE tenants 
     SET subscription_status = $1, stripe_price_id = $2, subscription_period_end = $3
     WHERE id = $4`,
    [status, priceId, currentPeriodEnd, tenantId]
  );

  console.log(`[Billing Sync] Статус клиента ${tenantId} обновлен на: ${status}`);
}

async function handleSubscriptionDeletion(sub: Stripe.Subscription) {
  const tenantId = sub.metadata.tenantId;
  if (!tenantId) return;

  await db.query(
    `UPDATE tenants 
     SET subscription_status = 'canceled', stripe_price_id = NULL
     WHERE id = $1`,
    [tenantId]
  );

  console.log(`[Billing Sync] Подписка клиента ${tenantId} аннулирована.`);
}

async function handlePaymentFailure(invoice: Stripe.Invoice) {
  const customerId = invoice.customer as string;
  // Находим пользователя по customerId и отправляем предупреждающее письмо
  console.warn(`[Billing Alert] Ошибка оплаты счета для клиента ${customerId}`);
}

3. Рекомендации по интеграции биллинга в SaaS

  1. Stripe Checkout и Customer Portal спасают недели работы: Не тратьте ресурсы на создание дашбордов привязки карт, просмотра истории инвойсов и отмены услуг. Готовые решения Stripe безопасны, соответствуют PCI DSS и переведены на десятки языков.
  2. Используйте Metadata: Всегда передавайте ID сущностей вашей базы данных в поле metadata при создании платежных сессий. Это единственный надежный способ сопоставить событие вебхука с конкретной строкой в вашей БД.
  3. Защита от дублирования (Идемпотентность): Вебхуки могут приходить повторно. Убедитесь, что ваш обработчик проверяет, не был ли статус уже записан, чтобы исключить лишние операции отправки писем или пересчета лимитов.

Например, в проекте TeleGo.io мы внедрили именно эту архитектуру верификации вебхуков для безопасного управления подписками. Мы также совместили её с микротранзакциями Telegram Stars, о чем вы можете прочитать в моей статье Разработка SaaS внутри Telegram Web Apps.

Источники и документация

Профессиональная платежная архитектура защищает доходы вашего SaaS-бизнеса и гарантирует высокий уровень лояльности платящих клиентов. Чтобы узнать, как биллинг встраивается в общий цикл быстрого запуска стартапа, ознакомьтесь с моим руководством Как собрать B2B SaaS за 30 дней.

Если вы планируете настроить прием платежей на своем проекте или провести рефакторинг текущей платежной системы, изучите мою услугу Разработки SaaS-платформ или запишитесь на Консультацию, чтобы разработать надежный биллинг.

Частые вопросы

Почему нельзя выдавать доступ сразу после редиректа с оплаты?

Редирект на success-страницу не гарантирует, что деньги списаны: пользователь мог закрыть вкладку, а платёж — не пройти антифрод. Единственный источник истины — вебхуки Stripe: доступ выдаётся по событию checkout.session.completed, а продлевается по invoice.paid.

Что делать, если вебхук от Stripe не дошёл?

Stripe автоматически повторяет доставку событий до трёх суток, поэтому обработчик обязан быть идемпотентным — повторное событие не должно дублировать письма или продления. Для страховки добавляют фоновую сверку статусов подписок по расписанию.

Как тестировать вебхуки локально?

Через Stripe CLI: команда stripe listen пробрасывает события на localhost, а stripe trigger генерирует тестовые события любого типа — от успешной оплаты до провала списания. Это позволяет отладить все ветки обработчика до продакшена.