Legan Studio
Все статьи
~ 5 мин чтения

SaaS-биллинг: подписки и тарифы

Как устроен биллинг подписок: модель данных, рекуррентные платежи, апгрейды и пропорциональные пересчёты, грейс-период, налоги, чеки 54-ФЗ.

  • веб
  • saas
  • разработка

Биллинг — самая невыносимая часть SaaS-продукта. Не потому что сложный код, а потому что бесконечно много краевых случаев: пользователь сменил тариф в середине месяца, оплата задержалась на 3 дня, карта отвалилась, нужно вернуть 1437 рублей за неполный период. Один баг в биллинге — недовольный клиент и потенциальный возврат. Разберём, как это устроено и где минимум приемлемой реализации.

Из чего состоит биллинг

КомпонентЧто делает
Тарифные планыкаталог: цены, фичи, лимиты
Подпискисвязь пользователь ↔ план + статус
Счета (invoices)что и за какой период начислено
Платежи (payments)факт списания через шлюз
Чеки 54-ФЗфискализация для РФ
Card vaultсохранённая карта для рекуррентов
Промокоды и скидкимодификаторы цены
Webhook от шлюзаподтверждение оплаты, статусы

Это минимум. SaaS «по-взрослому» добавляет usage-based (за обращения к API), team-billing, возвраты, налоги по странам.

Модель данных

CREATE TABLE plans (
  id           BIGSERIAL PRIMARY KEY,
  code         TEXT NOT NULL UNIQUE,  -- "starter", "pro", "enterprise"
  name         TEXT NOT NULL,
  price_rub    INTEGER NOT NULL,      -- в копейках
  interval     TEXT NOT NULL,         -- "month", "year"
  features     JSONB NOT NULL DEFAULT '{}'::jsonb,
  is_active    BOOLEAN NOT NULL DEFAULT TRUE
);

CREATE TABLE subscriptions (
  id              BIGSERIAL PRIMARY KEY,
  user_id         BIGINT NOT NULL REFERENCES users(id),
  plan_id         BIGINT NOT NULL REFERENCES plans(id),
  status          TEXT NOT NULL,  -- "trial","active","past_due","canceled","expired"
  current_period_start TIMESTAMPTZ NOT NULL,
  current_period_end   TIMESTAMPTZ NOT NULL,
  cancel_at_period_end BOOLEAN NOT NULL DEFAULT FALSE,
  canceled_at     TIMESTAMPTZ,
  trial_end_at    TIMESTAMPTZ,
  created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE TABLE invoices (
  id            BIGSERIAL PRIMARY KEY,
  subscription_id BIGINT REFERENCES subscriptions(id),
  amount_rub    INTEGER NOT NULL,
  status        TEXT NOT NULL,  -- "draft","open","paid","void","uncollectible"
  period_start  TIMESTAMPTZ,
  period_end    TIMESTAMPTZ,
  due_at        TIMESTAMPTZ,
  paid_at       TIMESTAMPTZ
);

CREATE TABLE payments (
  id            BIGSERIAL PRIMARY KEY,
  invoice_id    BIGINT REFERENCES invoices(id),
  gateway       TEXT NOT NULL,   -- "yookassa", "tinkoff"
  gateway_id    TEXT NOT NULL,
  amount_rub    INTEGER NOT NULL,
  status        TEXT NOT NULL,
  created_at    TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

Ключевая идея: подписка — это «состояние», invoice — «что должно быть оплачено», payment — «факт оплаты». Они почти всегда 1:1, но разделение позволяет обрабатывать частичные оплаты, возвраты, корректировки.

Жизненный цикл

trial → active → past_due → canceled
                ↘                ↗
                  → cancel_at_period_end
  • trial — бесплатный пробный период, 7-30 дней.
  • active — оплачено, всё работает.
  • past_due — оплата не прошла, грейс 3-7 дней.
  • canceled — пользователь отменил, но текущий период оплачен → доступ до конца периода.
  • expired — период закончился без оплаты, доступ отозван.

Рекуррентные платежи

В РФ — через ЮKassa, Тинькофф, CloudPayments, Сбер. Все умеют рекурренты, кроме самых простых интеграций.

Схема:

  1. Первый платёж — пользователь вводит карту, шлюз сохраняет токен.
  2. Сохраняем токен в card_tokens (без хранения PAN!).
  3. По расписанию cron каждый день: ищем подписки с current_period_end < NOW() и status = 'active'. Для каждой — списание по сохранённому токену.
  4. Webhook от шлюза подтверждает оплату → создаём invoice + payment, продляем current_period_end.
// daily cron
const due = await db.subscription.findMany({
  where: {
    status: "active",
    current_period_end: { lt: addDays(new Date(), 1) },
    cancel_at_period_end: false,
  },
});

for (const sub of due) {
  try {
    const charge = await yookassa.chargeRecurring({
      paymentMethodId: sub.payment_method_id,
      amount: sub.plan.price_rub,
      description: `Подписка ${sub.plan.name}`,
    });
    // ждём webhook
  } catch (e) {
    await markPastDue(sub);
  }
}

Грейс-период и retry

Карта может временно не списать (лимит, заблокирована, сменилась). Не отзывайте доступ сразу. Стандарт:

  • День 0 — попытка списать, не вышло. status = past_due, шлём email «обнови карту».
  • День 1, 3, 5 — повторные попытки с экспоненциальным интервалом.
  • День 7 — финальная попытка. Не вышло — status = expired, доступ отозван.

В Stripe это называется Smart Retries — алгоритм решает, когда лучше повторить (например, в день зарплаты).

Апгрейд / даунгрейд

Самая частая боль — пользователь на Starter за 990 ₽/мес через 10 дней решил перейти на Pro за 2990 ₽/мес. Что делать?

Два подхода:

1. Пропорциональный (proration). Считаем неиспользованную часть Starter и зачисляем как кредит на новый Pro.

Старый план: 990 ₽ за 30 дней. Использовано 10 дней → возврат 660 ₽.
Новый план: 2990 ₽ за 30 дней. Списываем 2990 ₽, минус кредит 660 ₽ = 2330 ₽.
Период обнуляется: новый billing period с сегодня.

2. Без proration. Новая цена применяется со следующего периода, до конца текущего пользователь на старом плане.

Первый — справедливее, но сложнее. Второй — проще объяснить, проще считать. Большинство SaaS делают первый, но с округлением «в пользу клиента».

54-ФЗ и фискальные чеки

Любая оплата от физлица в РФ требует фискального чека. Это нельзя пропустить — штраф ФНС 30 000-100 000 ₽ за каждое нарушение.

Варианты:

  • Облачная касса — АТОЛ Онлайн, Эвотор, OFD.ru. Шлёте JSON с составом чека, они отправляют в ФНС и пользователю.
  • Внутри платёжного шлюза — ЮKassa, Тинькофф, CloudPayments умеют сами слать чек. Самый простой путь — указываете при создании платежа состав, шлюз делает остальное.
// ЮKassa — чек прямо в платеже
await yookassa.createPayment({
  amount: { value: "2990.00", currency: "RUB" },
  receipt: {
    customer: { email: user.email },
    items: [{
      description: "Подписка Pro, 1 месяц",
      quantity: "1",
      amount: { value: "2990.00", currency: "RUB" },
      vat_code: 1,
      payment_subject: "service",
      payment_mode: "full_payment",
    }],
  },
});

Для возвратов — отдельный чек «возврат прихода».

Промокоды

CREATE TABLE promo_codes (
  id          BIGSERIAL PRIMARY KEY,
  code        TEXT NOT NULL UNIQUE,
  discount_type TEXT NOT NULL,  -- "percent", "fixed"
  discount_value INTEGER NOT NULL,
  applies_to_plans BIGINT[],
  max_uses    INTEGER,
  uses_count  INTEGER NOT NULL DEFAULT 0,
  valid_until TIMESTAMPTZ,
  first_payment_only BOOLEAN NOT NULL DEFAULT FALSE
);

При активации — записываем subscription.promo_code_id. На каждое invoice применяем скидку, если промокод активен и first_payment_only = false (либо это первый платёж).

Email уведомления

Минимум:

  • Триал начинается / заканчивается через 3 дня.
  • Подписка успешно продлена.
  • Платёж не прошёл, обнови карту.
  • Подписка отменена, действует до даты X.
  • Возврат средств за период X-Y.

Без этих писем пользователь чувствует себя обманутым — «вы списали без предупреждения».

Тестирование

Тестовый режим в шлюзе:

  • ЮKassa: тестовые карты 5555 5555 5555 4477 (успех), 4111 1111 1111 1112 (отказ).
  • CloudPayments: тестовая среда отдельный API URL.

Сценарии для регресса:

  • Первый платёж по триалу → активация.
  • Рекуррент успешный → продление.
  • Рекуррент отказ → past_due, retry, expire.
  • Апгрейд с пропорциональным расчётом.
  • Отмена в середине периода → доступ до конца.
  • Возврат → корректный refund-чек.

Когда брать готовое

Stripe, Lago, Chargebee — закрывают всё. Stripe не работает с РФ-картами. Lago — open source biller, ставится свой инстанс, понимает все модели подписок и usage-based. Chargebee — SaaS, есть интеграция с ЮKassa, но дорого.

Часто оптимально: ваш биллинг для подписок (это ~2-4 недели работы) + ЮKassa как платёжный шлюз + отдельный сервис для чеков.

Итого

Биллинг подписок — это не код, это набор бизнес-правил, переведённых в код. Закладывайте 4-8 недель на нормальную реализацию с тестами. Не экономьте на retry, грейс-периоде и понятных email — там скрывается больше всего жалоб и оттоков.

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

Можно ли в России использовать Stripe?

Нет. Stripe не работает с российскими юрлицами и не принимает российские карты. Если ваш SaaS продаётся только зарубежной аудитории и вы оформлены в другой юрисдикции — да, Stripe идеален. Для российских клиентов — ЮKassa, Тинькофф, CloudPayments, Сбер.

Сколько стоит сделать биллинг подписок с нуля?

Минимальная версия с одним планом и фиксированной ценой — 2-3 недели разработки одного бэкенд-разработчика. С апгрейдами, промокодами, грейс-периодом, чеками 54-ФЗ — 6-10 недель. Если нужен team-billing, usage-based, мульти-валюта — 3-4 месяца. Часто проще взять Lago и потратить недели на интеграцию вместо месяцев на разработку.

Как хранить карты для рекуррентов?

Никогда не у себя. Шлюз хранит карту и отдаёт вам токен (например, payment_method_id). Этот токен бесполезен без вашего merchant-аккаунта в шлюзе — даже если утечёт, никто чужой им не воспользуется. Хранение CVV или PAN запрещено PCI DSS, нарушение — штрафы и потеря лицензии у шлюза.

Что делать с НДС для подписок?

Если вы плательщик НДС (ОСНО или УСН с НДС с 2025) — указывать НДС в чеке. УСН без НДС — vat_code: 1 (без НДС). Для иностранных клиентов с офертой в иностранной юрисдикции — отдельная история, тут лучше консультироваться с бухгалтером, особенно по «иностранным интернет-услугам» и налогу с iPhone-подписок.

Как обработать возврат частичной суммы?

Создаёте invoice типа credit_note с отрицательной суммой, делаете refund через шлюз API, шлюз отправляет refund-чек. Если возврат за неполный период (отмена в середине месяца с прорейшеном) — считайте по дням: оставшиеся дни × дневная стоимость = сумма к возврату, округление в пользу клиента.

Грейс-период обязателен?

Не обязателен юридически, но обязателен для здравого UX. Без него вы рассердите пользователей, у которых временно не списалась карта — они вернутся, увидят отозванный доступ и уйдут к конкуренту. 5-7 дней грейс с email-напоминаниями возвращают 30-50% «потерянных» подписок.

Что важнее в первой версии — апгрейды или промокоды?

Апгрейды важнее: без них теряете revenue с роста клиентов. Промокоды можно вначале сделать руками — оператор поддержки выдаёт скидку через админку как корректировку цены конкретной подписки. Полноценный код-движок с правилами и аналитикой — отдельный спринт через 2-3 месяца после релиза.