Skip to content

Биллинг (облако)

Модуль подписок и тарифов. Работает только в SaaS-режиме и только когда явно включён: on-premise-поставка биллинга не видит ни при каких настройках.

Включение

env
APP_MODE=saas
BILLING_ENABLED=true
BILLING_CURRENCY=USD
BILLING_GRACE_DAYS=          # см. «Grace-период» ниже
PAYPAL_MODE=sandbox          # sandbox | live
PAYPAL_CLIENT_ID=
PAYPAL_SECRET=
PAYPAL_WEBHOOK_ID=

Проверка: GET /api/app-config отдаёт billing_enabled: true. При false раздела нет в меню, маршруты /api/billing/* и /api/superadmin/plans отвечают 403 billing_disabled, а middleware режима «только чтение» пропускает все запросы.

После включения завести тарифную сетку:

bash
docker exec <backend> php artisan billing:seed-plans
docker exec <backend> php artisan billing:backfill      # подписки существующим тенантам

billing:seed-plans отказывается работать без BILLING_ENABLED — это защита от случайного заведения тарифов на чужом стенде. Повторный запуск возвращает цены к сетке из кода и дубликатов не создаёт.

Статусы подписки

СтатусЧто значитПоведение тенанта
trialПробный период, оплаты ещё не былоПолный доступ, баннер со счётчиком дней
activeПериод оплаченПолный доступ
past_dueСписание не прошло или кончился триалПолный доступ, баннер; идёт grace
suspendedGrace исчерпанЧтение работает, все изменения данных блокируются (403 billing_readonly)
cancelledАвтопродление отключеноДоступ до конца оплаченного периода, дальше suspended

Маршруты api/billing/*, вход и выход из системы работают и в suspended — иначе тенант не смог бы оплатить и выйти из блокировки.

Grace-период

BILLING_GRACE_DAYS — сколько дней подписка живёт в past_due до перехода в suspended.

Пока переменная не задана, billing:advance не переводит подписки в suspended и пишет предупреждение в лог; в суперадминке на странице «Подписки» висит соответствующее сообщение. Так неизвестная длительность не превращается в блокировку оплаченного клиента.

Лимиты

Оба лимита жёсткие.

Места. Место занимает пользователь с ролями или прямыми правами (в том числе унаследованными от оргюнита). Учётки без доступа — потребители портала — бесплатны, суперадмины не считаются. Отказ приходит с seat_limit_reached при создании пользователя с ролями, выдаче роли, отправке и принятии приглашения с ролями.

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

bash
docker exec <backend> php artisan billing:reconcile-storage            # все тенанты
docker exec <backend> php artisan billing:reconcile-storage --tenant=7

Величина последнего расхождения остаётся в billing_storage_usage.drift_bytes. Ненулевой дрейф, который держится изо дня в день, означает непокрытый путь — это повод искать место записи или удаления файлов мимо TenantStorageMeter.

Резервные копии тенантов в квоту не входят: они лежат на отдельном диске и создаются платформой, а не тенантом.

Расписание

КомандаВремяЧто делает
billing:advance05:00Кончившийся триал/период → past_due, исчерпанный grace → suspended
billing:reconcile-storage04:40Пересчёт счётчиков объёма по диску

Продление подписки по расписанию не происходит: период двигается по факту успешного списания (вебхук провайдера), иначе неоплаченный период молча открывал бы следующий.

Посмотреть, что команда сделает, ничего не меняя: billing:advance --dry-run.

PayPal

Периодическое списание ведёт PayPal (Subscriptions API), доплата за расширение внутри периода проводится разовым заказом (Orders API), после чего сумма следующего цикла приводится к новому составу подписки.

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

Проверить настройку до первого платежа:

bash
docker exec <backend> php artisan billing:paypal-check

Команда получает токен (то есть проверяет реквизиты), находит вебхук по PAYPAL_WEBHOOK_ID, сверяет его адрес с тем, что следует из APP_URL, и показывает, каких событий не хватает в подписке. Ничего не создаёт и не списывает.

Вебхук: POST /api/billing/paypal/webhook — публичный маршрут, один на инсталляцию. Подпись проверяется обращением к PayPal, для этого нужен PAYPAL_WEBHOOK_ID из панели разработчика. Без него все события отклоняются с 403 — эндпоинт публичный, и принимать неподписанные события нельзя.

Обрабатываемые события: BILLING.SUBSCRIPTION.ACTIVATED, .PAYMENT.FAILED, .CANCELLED, .EXPIRED, .SUSPENDED, PAYMENT.SALE.COMPLETED, PAYMENT.CAPTURE.COMPLETED, .DENIED, .REVERSED. Остальные записываются в billing_webhook_events и игнорируются.

Каждое событие проводится один раз: уникальность (provider, event_id) в billing_webhook_events. Возврат пользователя из Checkout ведёт к тому же результату, что и вебхук, — какой путь придёт первым, не определено.

Проверка не проведённых событий

sql
SELECT event_type, error, created_at
FROM billing_webhook_events
WHERE processed_at IS NULL OR error IS NOT NULL
ORDER BY created_at DESC LIMIT 50;

Событие с заполненным error уже захвачено, и повтор от PayPal его не переоткроет — разбирать руками.

Пропорциональный расчёт

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

Все суммы хранятся в целых минорных единицах (центах). Цены планов заданы за одно место за один месяц; годовой период — та же цена, умноженная на 12.

Ручная правка

Суперадминка → Подписки → «Ручная правка»: план, статус, места, дата окончания периода. Причина обязательна и попадает в журнал суперадмина (billing_subscription_override). Через провайдера ничего не проводится и деньги не двигаются.

Тенанты без подписки биллингом не ограничиваются — их количество показано там же. Подписки для них заводит billing:backfill.

Смена цены тарифа

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

Orbita ITSM — документация для системных администраторов