Биллинг — самая невыносимая часть 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, Сбер. Все умеют рекурренты, кроме самых простых интеграций.
Схема:
- Первый платёж — пользователь вводит карту, шлюз сохраняет токен.
- Сохраняем токен в
card_tokens(без хранения PAN!). - По расписанию cron каждый день: ищем подписки с
current_period_end < NOW()иstatus = 'active'. Для каждой — списание по сохранённому токену. - 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 месяца после релиза.