Skip to content

Биллинг

Как команда платит и что именно биллинг сообщает остальному продукту. Пять слайсов в группе 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) дотягивались до сервисов, которым незачем было их знать.

Ход подписки

  1. POST /billing/subscriptionsсначала пишется наша строка, потому что её id и есть та ссылка, которую провайдер носит с собой и возвращает в каждом событии. Затем у провайдера запрашивается подписка, а плательщику выдаётся ссылка на подтверждение.
  2. Плательщик подтверждает у провайдера. До этого не списывается ничего.
  3. Провайдер доставляет ACTIVATED. Слайс вебхуков проверяет событие, застолбляет его в журнале повторов и применяет — подписка становится активной, а в Team.planId появляется тариф.
  4. 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/ — по каталогу на каждый слайс выше. См. также Разбор слайсов и Ресурсы.