Appearance
Биллинг (облако)
Модуль подписок и тарифов. Работает только в 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 |
suspended | Grace исчерпан | Чтение работает, все изменения данных блокируются (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:advance | 05:00 | Кончившийся триал/период → past_due, исчерпанный grace → suspended |
billing:reconcile-storage | 04: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.
Смена цены тарифа
Цена действующего тарифа не правится на месте. Если на тариф ссылается хоть одна подписка, сохранение создаёт новый тариф и архивирует прежний: у существующих подписок остаётся та цена, по которой с них списывали. Интерфейс предупреждает об этом до сохранения.