Биллинг
Как команда платит и что именно биллинг сообщает остальному продукту. Пять слайсов в группе billing/, и между ними и всем прочим — одно-единственное поле: Team.planId.
Эта узость и есть замысел. Ничто за пределами billing/ не спрашивает, кто провайдер, как выглядит подписка на проводе и был ли захвачен платёж, — оно читает тариф у команды и живёт дальше.

То, что показывает кабинет сегодня, уже того, что умеет api. На экране выше не предлагается ничего купить: он называет команду, пишет Free (early access) и сообщает, что подписки, лимиты и оплата по потреблению появятся ближе к v0.9. Всё описанное ниже собрано и доступно через api — просто путь покупки ещё не выведен в интерфейс.
Пять слайсов
| слайс | что держит |
|---|---|
billing/product | каталог: одна продаваемая вещь — имя, цена, planId, который получает подписчик |
billing/subscription | наша запись о подписке команды и запись в Team.planId |
billing/paymentProvider | провайдер за шлюзом — сегодня PayPal |
billing/webhook | приём событий провайдера |
billing/usage | что команда реально потребила и против какой квоты |
Провайдер спрятан за шлюзом
IPaymentProviderGateway живёт в domain/, PayPal — в data/. Потребители никогда не видят ни SDK, ни формата провода, ни словаря провайдера — та же форма, что у infra/storage и infra/vector, и по той же причине.
В 1.x было наоборот: слайс назывался paypal, его называл по имени каждый потребитель, а написания провайдера (APPROVAL_PENDING, BILLING.SUBSCRIPTION.ACTIVATED) дотягивались до сервисов, которым незачем было их знать.
Ход подписки
POST /billing/subscriptions— сначала пишется наша строка, потому что её id и есть та ссылка, которую провайдер носит с собой и возвращает в каждом событии. Затем у провайдера запрашивается подписка, а плательщику выдаётся ссылка на подтверждение.- Плательщик подтверждает у провайдера. До этого не списывается ничего.
- Провайдер доставляет
ACTIVATED. Слайс вебхуков проверяет событие, застолбляет его в журнале повторов и применяет — подписка становится активной, а вTeam.planIdпоявляется тариф. POST /billing/subscriptions/:id/cancelостанавливает будущие списания; приходитCANCELLED, и тариф снимается.
Одна открытая подписка на команду. pending, active и suspended считаются открытыми, поэтому плательщик, перезагрузивший недооформленную оплату, получает ту же ссылку на подтверждение, а не вторую подписку, за которую надо платить.
Два правила, существующие потому, что 1.x их нарушала
Здесь ничего не вычисляет дату. currentPeriodEnd читается у провайдера. В 1.x на каждом событии захвата прибавлялся месяц — из-за чего повторно доставленное событие стоило бесплатного месяца.
Каждая запись — присваивание, никогда не инкремент, поэтому обработать событие дважды безвредно — как вторая линия обороны за журналом повторов, а не вместо него.
Почему журнал повторов стоит отдельной таблицы
Соблазнительное возражение: применить «активировано» дважды ничего не меняет. Верно — и денег стоит не этот случай. Денег стоит нарушенный порядок: активировано, потом отменено, потом повторная доставка «активировано». Каждая запись по отдельности идемпотентна, а результат — команда на платном тарифе, которая перестала платить.
Порядок и есть замысел:
проверить → застолбить → обработать → отметить обработанным- Сначала проверить, потом разбирать — непроверенное тело есть ввод злоумышленника.
- Застолбить до обработки — застолблённое после оставляет окно, в котором вторая доставка приходит посреди обработки, не находит заявки и обрабатывает событие ещё раз.
- Освободить при сбое — обработчик, бросивший исключение, событие не применил, поэтому заявку надо снять, иначе повтор провайдера — то самое, что всё бы починило, — будет отброшен как дубликат.
Заявка — это INSERT по уникальному ключу, а не чтение с последующей записью: две одновременные доставки обе ничего не прочитают и обе пойдут дальше, а решит база, один раз.
Потребление
GET /usage/summary что потрачено, с разбивкой
GET /usage/total одно число
GET /usage/quota сколько осталось по тарифу
GET /usage/agents то же самое по агентамИзвестные дыры, названные, а не спрятанные
Брошенную оплату могут подтвердить позже. У провайдера нет операции отмены для неподтверждённой подписки — замерено живьём, /cancel отвечает 404 RESOURCE_NOT_FOUND, — поэтому отмена подписки в состоянии pending закрывает нашу строку и оставляет ссылку истекать по расписанию провайдера. Тот, кто бросил оплату, подписался на другое, а затем вернулся и подтвердил первую ссылку до её истечения, окажется с двумя живыми подписками.
Настоящего потолка трат не существует. Потребление измеряется и показывается; ничто не останавливает команду на границе.
Где код
api/src/slices/billing/ — по каталогу на каждый слайс выше. См. также Разбор слайсов и Ресурсы.