Поставка софта Биллинг: пробный период, апгрейды, возвраты, налоги и валюты
0%

Биллинг: пробный период, апгрейды, возвраты, налоги и валюты

Биллинг: пробный период, апгрейды, возвраты, налоги и валюты

Обещание «первые 14 дней бесплатно» помещается в одну строку лендинга. В системе оно разворачивается в отдельное состояние права, идемпотентную фоновую задачу перевода в оплату (идемпотентную — потому что она запустится дважды), защиту от бесконечных пробников, решение про данные после окончания (удалить нельзя — человек вернётся, хранить вечно тоже нельзя) и различие между «пробник кончился сам» и «мы закрыли его вручную» в метриках конверсии. Эта глава — про то, как из коммерческих формулировок получается работающий учёт. Рамка трека — в карте трека, выбор самой модели цены — в предыдущей главе; здесь цена уже выбрана, и разговор идёт про механику: как деньги приходят, пересчитываются, уходят обратно и что при этом надо записать, чтобы через год ответить на вопрос клиента, бухгалтера или проверяющего.

Что такое биллинг и чем он не является

Система За что отвечает Что ломается при смешении
Платёжный шлюз (эквайринг) движение денег: авторизация, списание, возврат, спор редирект браузера начинают считать подтверждением оплаты
Биллинг кому что положено, сколько стоит, когда выставить счёт, что делать при неуплате пересчёты и пробники расползаются по обработчикам, каждый со своим округлением
Учёт и фискализация чек, признание выручки по периодам, отчётность выручка считается по приходу денег и не сходится с продуктовыми метриками
Право пользования (entitlement) что получателю разрешено прямо сейчас проверка доступа читает таблицу платежей вместо состояния права

Самая частая ошибка формулируется одной фразой: биллинг путают с приёмом платежей. Приём платежей — интеграция на две недели, её можно заменить. Биллинг — модель данных, которая живёт столько же, сколько продукт, и переписать её задним числом нельзя: часть исходных фактов к тому моменту уже не восстановить. Отсюда пять инвариантов, дешёвых на старте и дорогих через год.

  1. Деньги двигает провайдер, право выдаёт ваш биллинг, связь между ними — событие. Оно может прийти дважды, не прийти вовсе, прийти с задержкой или не в том порядке.
  2. Всё, что показано покупателю, фиксируется на момент продажи копией значения, а не ссылкой на справочник: цена, валюта, ставка и сумма налога, состав тарифа, редакция оферты, признаки местонахождения. Справочник изменится, а отвечать на «почему год назад списали столько» придётся.
  3. Факты неизменяемы, состояние — производная. Проведённый платёж не редактируется, строка счёта не удаляется; ошибка исправляется компенсирующей записью — та же дисциплина, что в журналах событий.
  4. Проверка доступа читает состояние права, а не факт оплаты. Между «есть подтверждённый платёж» и «сейчас можно пользоваться» лежат возврат, спор, ручная выдача, приостановка.
  5. Суммы — целые числа в минимальных единицах плюс код валюты.

Модель данных: минимум, без которого не выжить

PLAN и PRICE — разные сущности, и цена неизменяема. Поднять цену значит создать новую строку PRICE и переключить на неё новые подписки; старые остаются на старой. Это техническая основа «дедушкиной оговорки»: без неё вы либо не можете поднять цену, либо поднимаете её всем сразу и получаете волну оттока. ENTITLEMENT живёт отдельно от SUBSCRIPTION, потому что права возникают не только из подписки: ручная выдача поддержкой, миграция со старой системы, покупка разового продукта. У портала это видно прямо в схеме — у заказа есть колонка source со значениями purchase и grant, а при возврате право переводится в revoked (services/requests-api/internal/storage/queries.go).

USAGE_RECORD нужен, даже если тарификации по потреблению сейчас нет: как только в цене появится любая метрика, понадобится воспроизводимый учёт — не «сколько сейчас», а «сколько было в интервале, по данным, которые никто не переписывал» (многотенантность). А PAYMENT_EVENT — журнал, а не таблица состояния: первичный ключ по идентификатору события провайдера и есть идемпотентность, повторная доставка не вставит вторую строку, и обработчик увидит это по числу затронутых строк.

Деньги — целые числа. Цена 19.99 в двоичном представлении с плавающей точкой не равна 19.99, и сложение тысячи таких цен даёт расхождение, которое сначала не видно, потом видно в третьем знаке, а потом бухгалтерия не может свести отчёт. Суммы хранятся в минимальных единицах, рядом всегда лежит код валюты — у портала price_kopecks INTEGER, ни одного дробного типа. Дальше две ловушки. Первая: не у всех валют два знака после запятой — у иены и воны ноль, у динара Кувейта и Бахрейна три, так что константа «умножить на 100» ломается ровно там, где начинается международная продажа; показатель степени берут из справочника ISO 4217 (https://www.iso.org/iso-4217-currency-codes.html). Вторая: сумма двух денег в разных валютах — не сумма, а ошибка, и запрещать это должен неизменяемый тип «сумма плюс валюта», а не внимательность программиста — приём из принципов.

Два автомата: подписка и право

Состояние подписки и состояние права — не одно и то же. Попытка совместить их в одной колонке порождает вечный класс багов «клиент отменил, но период оплачен, а доступа нет».

Связи между автоматами несимметричны, и в этом всё дело. past_due не означает мгновенную потерю доступа: между ними льготный период, и отключать в тот же час, когда банк отказал, — способ потерять клиента, у которого истёк срок карты. canceled не означает конец права: право живёт до конца оплаченного периода, и это должно быть записано в коде одним условием, а в оферте — одной фразой. Возврат не является отменой задним числом: отмена про будущее, возврат про уже проведённые деньги, и он отзывает право отдельным переходом. Ручная выдача права вообще не проходит через автомат подписки — поэтому у права есть source. Отсюда практическое следствие: функция проверки доступа читает право, его срок и урезанный набор возможностей льготного состояния — и не содержит ни одного обращения к платежам.

Пробный период: четыре разные вещи под одним словом

Механика Что получает покупатель Во что обходится поставщику Какое обязательство создаёт
Бесплатный тариф навсегда пользуется бесконечно в рамках лимитов постоянная себестоимость на неплатящих: поддержка, хранение нельзя молча выключить: на нём построили процессы
Пробник без карты пробует без риска и без реквизитов низкая конверсия, злоупотребления дёшевы напомнить об окончании, не удалить данные сразу
Пробник с картой «попробую и решу», реквизиты уже введены поток возвратов и споров предупредить до списания, дать отменить в один шаг
Деньги вперёд с гарантией возврата полный продукт сразу, риск покрыт касса и возвраты, налоговый пересчёт при каждом вернуть без препирательств, если условие наступило

Пробник — не флажок, а тот же путь кода, что и оплата. Реализованный как if user.is_trial: skip_billing(), он через полгода окажется в двадцати местах, и в одном из них с ошибкой. Правильно: пробник — это подписка в состоянии trialing с ценой ноль на первый период, и тогда переход к оплате не требует нового кода. Отдельно: «пробник закончился» и «оплата не прошла» — разные события, разные письма и разные метрики; их смешение делает воронку нечитаемой, потому что вы не отличите тех, кому не подошёл продукт, от тех, у кого просто истекла карта.

Ограничение злоупотреблений — выбор между удобством и стоимостью. Привязка к почте бесплатно клонируется, к карте — отсекает злоупотребления и режет конверсию, к телефону — компромисс со своими проблемами по персональным данным. Часто лучше работает не календарное окно, а квота: не «14 дней», а «первые 500 операций»; её сложнее обойти, и она честнее отражает себестоимость. Данные после окончания требуют решения вида «мягкое удаление через N дней, окончательное через M», записанного в политике и реализованного фоновой задачей с журналом: слишком рано — потеряли вернувшегося клиента, слишком поздно — храните чужие персональные данные без основания (ограничения, приватность, живой пример — политика ПДн портала). А предупреждение до автосписания не вежливость: многие площадки требуют его прямо, и эта задача тоже обязана быть идемпотентной.

Апгрейд посреди периода: арифметика до копейки

Клиент заплатил за месяц вперёд и на восемнадцатые сутки переходит на тариф дороже. У вопроса «сколько списать сейчас» ровно один правильный ответ: тот, что записан в вашей оферте и одинаково реализован во всех точках кода.

Пропорциональный пересчёт: кредит за неиспользованный остаток, начисление за остаток по новому тарифу, разница к списанию

Пусть D — длительность периода, r — оставшаяся часть в тех же единицах, P_old и P_new — цены периода в минимальных единицах:

$$ \mathrm{credit} = \left\lceil \frac{P_{old} \cdot r}{D} \right\rceil, \qquad \mathrm{charge} = \left\lfloor \frac{P_{new} \cdot r}{D} \right\rfloor, \qquad \Delta = \mathrm{charge} - \mathrm{credit} $$

Направление округления выбрано в пользу покупателя: кредит вверх, начисление вниз, ошибка не превышает одной минимальной единицы на операцию и достаётся поставщику. Можно выбрать другое правило — важно, чтобы оно было одно и было записано. Четыре решения, которые надо принять явно, иначе они будут приняты случайно:

  1. Единица дробления — сутки или секунды: секунды точнее и не зависят от часового пояса, сутки понятнее в письме, а смешение единиц в разных местах кода даёт расхождение, которое всплывает раз в месяц и не воспроизводится.
  2. Якорь продления — смещается ли дата следующего списания. Обычно нет; если смещается, это надо объяснить в интерфейсе.
  3. Судьба остатка при понижении — кредит на баланс, а не возврат на карту, иначе появляется арбитраж: перейти на дорогой тариф, тут же вернуться на дешёвый и получить деньги.
  4. Момент применения — немедленно с пересчётом; немедленно, но начислением в следующий счёт; с конца периода. Первое честнее, второе проще для кассы, третье хуже всего в поддержке.
from datetime import datetime


def prorate(old_minor: int, new_minor: int, start: datetime, end: datetime,
            switch_at: datetime) -> tuple[int, int, int]:
    """Смена тарифа посреди периода: (кредит, начисление, к списанию).

    Всё в минимальных единицах, вычисления целочисленные, округление — один раз.
    Сложность: O(1) по времени и памяти.
    """
    total = int((end - start).total_seconds())
    if total <= 0:
        raise ValueError("период нулевой или отрицательный")
    switch_at = max(start, min(switch_at, end))    # выходы за границы приводим к границам
    remaining = int((end - switch_at).total_seconds())
    credit = -((-old_minor * remaining) // total)  # вверх: в пользу покупателя
    charge = (new_minor * remaining) // total      # вниз: в пользу покупателя
    return credit, charge, charge - credit


JUN, JUL = datetime(2026, 6, 1), datetime(2026, 7, 1)
# Апгрейд с 1000 на 2500 рублей на восемнадцатые сутки тридцатидневного периода.
assert prorate(100_000, 250_000, JUN, JUL, datetime(2026, 6, 19)) == (40_000, 100_000, 60_000)
# Понижение даёт отрицательную разницу: это кредит на баланс, а не возврат на карту.
assert prorate(250_000, 100_000, JUN, JUL, datetime(2026, 6, 19))[2] == -60_000
# Переход в последнюю секунду периода не порождает копеечных строк счёта.
assert abs(prorate(100_000, 250_000, JUN, JUL, datetime(2026, 6, 30, 23, 59, 59))[2]) <= 250

Отдельная тема — места (seats): добавление пользователя в середине периода считается тем же пересчётом, только P берётся за одно место. Отсюда два требования: решить, что такое «активный пользователь» и в какой момент он считается (на конец периода, пик за период, среднее по дням) — от этого прямо зависит сумма; и схлопывать изменения за короткое окно в одну строку, иначе счёт с сорока строками пересчёта по одному месту никто не прочитает. И ловушка, про которую вспоминают первого февраля: месяцы разной длины. Подписка, начатая 31 января, продлевается 28 февраля — а дальше 31 марта или 28-го? Якорь хранится отдельно от фактической даты списания, и при коротком месяце берётся последний день; логика «прибавить 30 суток» за год даёт смещение почти в неделю.

Событие оплаты: подпись, идемпотентность, порядок

Источник истины о деньгах — уведомление провайдера, а не редирект браузера: пользователь закроет вкладку, потеряет сеть, нажмёт «назад». В коде портала это записано прямым комментарием — «Возврат пользователя на SuccessURL ничего не значит» (services/requests-api/internal/tbank/notification.go).

Подпись проверяется до разбора полей — по сырым значениям в том виде, в каком они пришли: любое переформатирование числа ломает проверку. В ParseNotification портала порядок именно такой — сначала подпись, потом сверка терминала, потом всё остальное (прикладная криптография, управление секретами). Идемпотентность — свойство базы, а не памяти обработчика: ключ события кладётся в таблицу с уникальным индексом в той же транзакции, что и эффект. Переход состояния — условный UPDATE, а не «прочитал, проверил, записал»: разница проявляется ровно тогда, когда два уведомления приходят одновременно (транзакции и изоляция). И порядок событий не гарантирован — при повторах refunded может прийти раньше повторного confirmed, поэтому опоздавшее событие не должно откатывать более позднее состояние.

// ApplyPaymentEvent применяет событие провайдера ровно один раз.
// applied=false означает повторную доставку уже обработанного события.
func (s *Store) ApplyPaymentEvent(ctx context.Context, ev Event) (applied bool, err error) {
	tx, err := s.db.BeginTx(ctx, nil)
	if err != nil {
		return false, err
	}
	defer tx.Rollback() // после успешного Commit вернёт ErrTxDone — это нормально

	// Ключ идемпотентности живёт в той же транзакции, что и эффект события.
	res, err := tx.ExecContext(ctx, `
		INSERT OR IGNORE INTO payment_events (provider_event_id, payment_id, status, amount_minor)
		VALUES (?, ?, ?, ?)`, ev.ProviderEventID, ev.PaymentID, ev.Status, ev.AmountMinor)
	if err != nil {
		return false, fmt.Errorf("журнал событий: %w", err)
	}
	if n, _ := res.RowsAffected(); n == 0 {
		return false, tx.Commit() // уже обрабатывали: отвечаем провайдеру успехом
	}

	// Условный UPDATE: если заказ уже не в ожидании, событие опоздало или пришло не по порядку —
	// более позднее состояние мы НЕ откатываем. Сумма сверяется здесь же: уведомление
	// на сумму меньше ожидаемой не повод выдать право.
	upd, err := tx.ExecContext(ctx, `
		UPDATE orders SET status = 'confirmed'
		WHERE id = ? AND status = 'pending' AND amount_minor = ?`, ev.OrderID, ev.AmountMinor)
	if err != nil {
		return false, fmt.Errorf("подтверждение заказа: %w", err)
	}
	if n, _ := upd.RowsAffected(); n == 0 {
		return false, tx.Commit()
	}
	if err = grantEntitlement(ctx, tx, ev); err != nil {
		return false, err // право и деньги должны появляться атомарно
	}
	return true, tx.Commit()
}

Неудачное списание и повторные попытки

Часть платежей не проходит по причинам, не связанным с желанием клиента: истёк срок карты, банк отклонил операцию, не хватило средств именно сегодня, сработал антифрод. Это непроизвольный отток, и ориентир, который стоит проверить на своих данных: у продуктов с картами его доля измеряется не единицами процентов, а десятками. Работающий механизм повторов может оказаться дешевле любой продуктовой фичи в том же квартале.

Отказы бывают временные и окончательные. Повторять операцию по закрытой карте бессмысленно: это только повышает вашу долю отказов, а её смотрят и банк, и антифрод; код причины приходит в ответе провайдера, его надо разбирать, а не складывать в лог. Расписание повторов — не «каждый час, пока не получится», а три-четыре попытки за две-три недели, в разное время суток и разные дни недели: у части клиентов деньги появляются к дате зарплаты, у части лимит сбрасывается в начале месяца. Держите его отдельной таблицей констант, а не ветками внутри обработчика — тогда его видно целиком и можно протестировать без похода в платёжную систему. Отключение — последнее действие: мягкая деградация, когда чтение и экспорт остаются, а запись и тяжёлые операции выключаются, даёт клиенту шанс вернуться и снимает с вас расходы (деградация).

Возвраты, спорные операции и отзыв права

Операция Кто инициирует Что с деньгами Что должно произойти в системе
Отмена клиент или вы, до списания ничего подписка → canceled, право живёт до конца оплаченного периода
Возврат вы, по решению или по правилам идут обратно тем же инструментом компенсирующая запись, право → revoked, налоговая корректировка
Спорная операция банк покупателя, без вашего согласия забирают принудительно, плюс комиссия за спор право → revoked, сбор доказательств поставки, ответ в срок

Возврат — это не DELETE. Исходный платёж остаётся в истории, добавляется компенсирующая запись. Так это и сделано в портале: заказ переводится из confirmed в refunded условным UPDATE, право отдельным запросом из active в revoked, обе операции в одной транзакции вместе с записью в журнал событий. Дальше — техническая честность, которую стоит проговаривать вслух: отозвать право можно, отозвать скачанный файл — нельзя. Отзыв означает, что перестают работать выданные ссылки и прекращается лицензия на дальнейшее использование, а не что файл исчезает с диска покупателя. У портала это записано прямо в правилах возврата: полный подтверждённый возврат отзывает право, и ранее подготовленные, но ещё не использованные ссылки перестают работать. Инженерное следствие: ссылки на скачивание должны быть короткоживущими и одноразовыми, а их состояние проверяться в момент использования, а не только выдачи — в схеме портала для этого есть таблица access_tokens с полями expires_at и consumed_at.

Спорная операция стоит дороже суммы платежа. Помимо денег списывается фиксированная комиссия за спор — на момент написания, август 2026 года, у карточных провайдеров это обычно десятки единиц валюты расчётов, и она не возвращается даже при выигрыше; высокая доля споров может стоить вам условий обслуживания у эквайрера. Защита начинается в момент продажи: узнаваемое название в выписке по карте, письмо с составом заказа, зафиксированная редакция принятых условий, логи выдачи доступа — у портала эту роль играет таблица legal_acceptances. И отдельно: частичный возврат ломает наивные модели — вернули половину, право отзывать целиком или уменьшать объём? Это продуктовое решение, но в коде оно должно быть явным полем, а не выводиться из сравнения сумм.

Что здесь вопрос к юристу, а не к вам. Правила отказа от цифрового товара различаются по юрисдикциям и зависят от деталей: успел ли покупатель начать пользоваться, было ли получено явное согласие на немедленное начало исполнения, что именно продано — товар, услуга или доступ. Приходите с конкретными вопросами: (1) в какой момент по нашей схеме исполнение считается начатым; (2) какое согласие надо зафиксировать до выдачи доступа и в какой форме; (3) при каких условиях мы обязаны вернуть деньги независимо от нашей политики; (4) какой срок ответа на обращение обязателен. С этими четырьмя вопросами встреча займёт час, без них — месяц. Договорная сторона — в материале о контрактах.

Налоги: сумма к оплате зависит от того, кто покупает

Общий принцип, к которому за последние два десятилетия пришло большинство налоговых систем для цифровых услуг: косвенный налог считается по месту покупателя, а не продавца. Отсюда — что должна уметь система. Определять место покупателя и хранить доказательства: не один признак, а несколько независимых — страна платёжного инструмента, адрес выставления счёта, IP, код страны телефона, явно указанная страна; хранить надо все, потому что вопрос «на каком основании применена эта ставка» задают через два года. Различать продажу физлицу и организации: для организаций во многих системах работает перенос обязанности на покупателя при наличии номера плательщика, проверенного через официальный сервис, с сохранением ответа и даты; номер из формы без проверки не основание, а строка текста. Фиксировать ставку и сумму налога на момент продажи: ставки меняются, счета за прошлый год не пересчитываются, а при возврате возвращается и налог — по ставке исходной продажи.

Знать, включён налог в цену или добавляется сверху. Это не косметика, а разный код:

$$ \text{налог включён:}\quad \mathrm{base} = \left\lfloor \frac{\mathrm{gross} \cdot 100}{100 + \mathrm{rate}} \right\rfloor, \quad \mathrm{tax} = \mathrm{gross} - \mathrm{base} $$

$$ \text{налог сверху:}\quad \mathrm{tax} = \left\lfloor \frac{\mathrm{base} \cdot \mathrm{rate}}{100} \right\rfloor, \quad \mathrm{gross} = \mathrm{base} + \mathrm{tax} $$

При цене «включая налог» вы фиксируете сумму на витрине, и при разной ставке в разных странах ваша выручка отличается; при цене «плюс налог» на витрине одна цена, а к оплате в разных странах разные суммы, и это надо честно показать до нажатия кнопки. Оба варианта законны, но это разные продукты для покупателя. Плюс нужен отчёт «сколько продано в каждую страну за период»: пороги регистрации — вопрос к бухгалтеру, но без такого отчёта разговор с ним превращается в раскопки. Отдельный слой — фискализация: квитанция эквайринга не заменяет фискальный чек, это разные документы по разным основаниям; в портале состав чека передаётся в платёжный запрос, после подтверждения чек формируется и уходит на указанный контакт, а при возврате аннулируется (оферта, правила возврата).

Merchant of Record — способ выкупить всю эту сложность за процент: площадка или специальный провайдер становится продавцом для покупателя и берёт на себя налоги, регистрации, чеки и споры. Цена владения: минус несколько процентов оборота, минус часть данных о покупателе (они принадлежат площадке), минус гибкость в промоакциях; плюс — отсутствие налоговых регистраций в десятках стран. Для маленькой команды это часто лучший вариант, и решение принимается арифметикой (каналы поставки, магазины приложений).

Что здесь вопрос к бухгалтеру и юристу. Ставки, пороги, применимость режимов и отчётность — не к инженеру. Конкретные вопросы: (1) в каких юрисдикциях у нас возникает обязанность при нашей структуре продаж; (2) какие признаки места покупателя мы обязаны хранить и сколько лет; (3) наша цена включает налог или нет и что из этого следует для витрины; (4) как оформляется возврат налога при возврате платежа. Налоговые режимы инженера-предпринимателя разбирает материал о деньгах и рисках.

Валюты: цена в валюте покупателя — не умножение на курс

Курс скачет, а цена — обещание. Цена, которая меняется каждый день, выглядит как ошибка; нужна таблица цен по валютам с осознанно выбранными точками и правилом пересмотра — например, раз в квартал или при отклонении курса больше чем на десять процентов. Красивые цены в разных валютах разные: значение вида 9.99 после умножения на курс превращается в некрасивое число, и точки выбираются под привычки рынка, а не получаются делением (ценообразование, цена и рост).

Валюта витрины и валюта расчётов — разные вещи. Можно показывать цену в местной валюте, а получать деньги в своей; разницу между курсом на момент продажи и курсом на момент зачисления кто-то несёт, и если цена объявлена в местной валюте — несёте вы. Это не повод отказываться от местных цен: конверсия обычно растёт сильнее, чем валютный риск, — но риск надо посчитать заранее, а не обнаружить в отчёте. По той же причине возврат идёт в валюте продажи и тем же инструментом, а значит, в строке платежа нужно хранить сумму в обеих валютах и курс на момент продажи. И наконец, региональные цены — это не только валюта: вместе с ними появляется арбитраж, покупка через посредника в дешёвом регионе. Механизмы противодействия — привязка к региону аккаунта, ограничение смены региона, региональная активация — жёстче всего проработаны в играх, поэтому подробный разбор в дистрибуции игр.

Комиссии: механизм вместо таблицы процентов

Любая таблица процентов устареет за полгода, устойчиво только знание механизма. Удержания складываются из базовой ставки эквайринга (процент плюс фиксированная часть за операцию), надбавки за международную операцию, конверсии валюты, комиссии провайдера подписок, комиссии площадки и фиксированных комиссий за возврат и за спор. Порядок величин на момент написания, август 2026 года — проверяйте по документации конкретной площадки, все эти числа меняются:

  • карточный эквайринг напрямую — единицы процентов плюс фиксированная часть;
  • провайдер подписок или Merchant of Record — несколько процентов сверху, за что вы получаете налоги, чеки и споры «под ключ»;
  • магазины мобильных приложений и игровые площадки — десятки процентов, обычно примерно от 15 до 30, со сниженными ставками по программам для небольших разработчиков и, у части площадок, для подписок после первого года;
  • надбавки за международную операцию и конверсию — доли процента или единицы процентов; комиссия за спор — фиксированная сумма, обычно десятки единиц валюты расчётов.

Правильный способ говорить о комиссиях — не сравнивать объявленные ставки, а считать эффективную ставку по факту, после возвратов и после налога, за период не короче квартала:

$$ c_{eff} = \frac{\sum \text{все удержания за период}}{\sum \text{валовые начисления за период}} $$

Объявленная ставка почти всегда ниже эффективной: в неё не входят надбавки, конверсии, возвраты и споры. Разница между 2,5 процента на витрине провайдера и 4,1 процента по факту — это ваша маржа, и увидеть её можно только этой формулой. Порог, за которым свой канал выгоднее магазина, выведен в карте трека; экономика в целом — в юнит-экономике.

Метрики и учёт: почему их надо строить из событий

«Выручка выросла на 200 тысяч» — бесполезная фраза. Полезная: столько-то дали новые клиенты, столько-то апгрейды, столько-то отняли даунгрейды и отток. Из таблицы «текущий тариф клиента» это не выводится — нужны помесячные срезы, которые пишутся фоновой задачей и больше не переписываются; разложение считается сравнением среза с предыдущим (оконная функция lag по клиенту), а вернувшиеся отмечаются отдельным признаком, иначе смешаются с новыми. Вторая невосстановимая задним числом вещь — признание выручки по периодам: деньги за годовую подписку пришли в январе, но заработаны равномерно за двенадцать месяцев, и до этого момента это обязательство перед клиентом. За месяц признаётся доля P · d / D, где P — сумма периода, d — число суток месяца внутри периода, D — его длительность; значит, строка счёта обязана знать свой интервал. И даже минимальный журнал проводок — каждая операция порождает две строки, сумма по проводке равна нулю, а запрос HAVING sum(amount_minor) <> 0 обязан возвращать пустой результат — даёт то, ради чего это делают: расхождения находятся автоматически, а не письмом из бухгалтерии. Классическое изложение — у Мартина Фаулера: «Accounting Narrative» (https://martinfowler.com/eaaDev/AccountingNarrative.html) и «Event Sourcing» (https://martinfowler.com/eaaDev/EventSourcing.html); продуктовая сторона метрик — в метриках.

Отдельно про тесты, потому что цена ошибки здесь измеряется прямо в деньгах. Время инжектируется, а не берётся из системных часов, иначе тест невоспроизводим. Обязательны границы календаря: подписка от 31 января, високосный год, переход на летнее время внутри периода, часовой пояс биллинга, отличный от пояса клиента. Обязательны сценарии повторной доставки: одно уведомление дважды — право выдано один раз; refunded раньше повторного confirmed; неверная подпись; чужой идентификатор терминала. Хорошо ложатся проверки свойств: сумма строк счёта равна итогу, кредит плюс начисление не превышают цену периода больше чем на минимальную единицу, после возврата сумма движений по клиенту равна нулю. Живая песочница провайдера — для интеграционных прогонов, записанные ответы — для быстрых (тесты в конвейере).

Типичные ошибки

  • Считать редирект браузера подтверждением оплаты: право выдано, денег нет.
  • Хранить деньги в числах с плавающей точкой: обнаруживается через год, при сверке.
  • Одна колонка на состояние подписки и состояние права: тикеты «отменил, но период оплачен».
  • Ссылка на текущую цену вместо копии в строке счёта: старые счета начинают врать; то же с налогом, посчитанным на лету по сегодняшней ставке.
  • Пересчёт в сутках в одном месте и в секундах в другом: расхождение раз в месяц, которое никто не может воспроизвести.
  • Возврат разницы при понижении тарифа на карту: открывает арбитраж.
  • Повторы списания без разбора кода отказа: растёт доля отказов, портятся условия у эквайрера.
  • Отключение доступа в тот же час, что и неудачное списание: теряете клиента, у которого просто истёк срок карты.
  • Пробник как флажок вместо состояния подписки: логика расползается по коду.

Мини-итог

Биллинг — это учёт, а не интеграция. Интеграцию с платёжным провайдером можно заменить за две недели. Модель данных — неизменяемые цены, неизменяемые счета, права как отдельная сущность, события провайдера как журнал с ключом идемпотентности — живёт столько же, сколько продукт. Проверочный список перед запуском платежей:

  1. Суммы — целые числа в минимальных единицах с кодом валюты и показателем степени из ISO 4217; цену определяет сервер по справочнику, тело запроса от браузера на неё не влияет.
  2. Уведомление проверяется по подписи до разбора полей, кладётся в журнал с уникальным ключом, эффект применяется условным UPDATE в той же транзакции, сумма сверяется с заказом.
  3. Право — отдельная сущность с состояниями, источником и историей; проверка доступа читает только её. Цена, валюта, ставка и сумма налога, признаки места покупателя и редакция условий записаны копиями в момент продажи.
  4. Правило пересчёта при смене тарифа записано в оферте и реализовано одной функцией; возврат — компенсирующая запись плюс отзыв права плюс налоговая корректировка, а не удаление, а ссылки на скачивание короткоживущие и одноразовые.
  5. Есть расписание повторов с разбором кодов отказа и мягкая деградация вместо мгновенного отключения; есть отчёт «продажи по странам» и помесячные срезы выручки.
  6. Вопросы к юристу и бухгалтеру сформулированы и заданы до первой продажи.

Источники

Что дальше

Следующий вопрос — через какие каналы продажа вообще до вас доходит: свой сайт, где вы контролируете всё и платите за это налогами и спорами; маркетплейсы облаков, где покупка идёт через чужой договор и чужой бюджет клиента; партнёры и реселлеры со своими требованиями к прайсу и своей долей. У каждого канала свой протокол выдачи прав, своя комиссия и свой набор обязательств.

Каналы поставки: свой сайт, маркетплейсы облаков, партнёры

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов