Тема
ТЗ — Подписки/рекуррентные платежи (cms/commerce-subscriptions)
Слой: 🟡 функц-модуль · Зрелость доноров: ★★ · Донор: er (
er/project/src/app/Domain/Billing/), notal Статус: ТЗ к разработке
Назначение и возможности
Тарифные планы с периодическим автосписанием сохранённым методом оплаты через cms/commerce-payments. Grace-период и ретраи при неудачном списании, апгрейд/даунгрейд плана с проration, отмена и пауза подписки, управление из личного кабинета.
- Тарифные планы с периодами (месяц/квартал/год), цена в minor units +
currency_code - Автосписание сохранённым токеном метода оплаты шлюза по расписанию
- Grace-период при неудачном списании + настраиваемое число ретраев перед отменой
- Апгрейд/даунгрейд плана с проration (пересчёт остатка оплаченного периода)
- Пауза подписки на срок и возобновление без потери истории
- Отмена подписки (немедленная или по окончании оплаченного периода)
- Управление подпиской в личном кабинете: смена плана, отмена, история списаний
Зависимости и выключение
requires: ядро, cms/commerce-payments · suggests: cms/notifications-bus (уведомления о неудачном списании)
Поведение при выключении: активные подписки не автосписываются, продление перестаёт работать — доступ по подписке следует переводить на ручное продление администратором, данные тарифов и истории списаний сохраняются.
Модель данных
| Таблица | Ключевые поля | Примечание |
|---|---|---|
commerce_subscription_plans | id, code, title, price, currency_code, period (month|quarter|year) | тарифный план, price — integer minor units |
commerce_subscriptions | id, user_id, plan_id, status (active|paused|grace|cancelled), payment_method_token, current_period_ends_at | подписка пользователя, status/period — PHP Enum |
commerce_subscription_charges | id, subscription_id, payment_id, status, attempt_number, charged_at | append-only журнал попыток списания, BRIN по charged_at |
commerce_subscription_plan_changes | subscription_id, from_plan_id, to_plan_id, proration_amount, changed_at | история апгрейда/даунгрейда |
user_id/plan_id/payment_id — FK constrained()->index(). История списаний и смен плана не редактируется задним числом — только новые записи (сторно при ошибке начисления).
ПДн-паспорт. Хранит: user_id (связь с профилем), payment_method_token — непрозрачный токен шлюза (сам по себе не ПДн: реквизиты карты не хранятся, токенизация на стороне шлюза), но история списаний (commerce_subscription_charges) косвенно раскрывает платёжную активность субъекта. Ретеньш: активная подписка — пока жива; журнал списаний как финансовый документ хранится дольше самой подписки — charges_retention_months (см. «Настройки», покрыто джобой purge-old-charges, «Фоновая работа»). Участие в «выгрузить всё по субъекту» — да (подписки, история списаний, смены плана пользователя); в «забыть по запросу» — обезличивание user_id → null на подписке (журнал списаний остаётся как финансовый документ, привязка к субъекту снимается), активная подписка при этом принудительно отменяется (нельзя продолжать списывать деньги с обезличенного метода оплаты). При UserMerged(primary, secondary) — подписка вторичного аккаунта переносится на первичный (user_id меняется); если у первичного уже есть активная подписка — см. «Крайние случаи».
Входные и выходные данные
Входы (whitelist-принцип: всё, что не перечислено ниже, модуль отвергает — 422 на границе FormRequest, посторонний payload события игнорируется):
| Откуда | Что приходит | Поля | Чем валидируется |
|---|---|---|---|
Личный кабинет: POST /subscriptions/my/change-plan | смена тарифа | plan_id | FormRequest whitelist: plan_id существует и активен, Idempotency-Key |
Личный кабинет: POST /subscriptions/my/cancel | отмена подписки | mode (immediate|end_of_period) | FormRequest whitelist enum, Idempotency-Key |
Личный кабинет: POST /subscriptions/my/pause | пауза подписки | resume_at (nullable date) | FormRequest: дата в будущем либо бессрочно, статус подписки active |
Личный кабинет: POST /subscriptions/my/resume | возобновление после паузы | — (без тела) | принадлежность подписки текущему пользователю, статус paused |
Событие PaymentSucceeded (cms/commerce-payments) | подтверждение списания | payment_id, subscription_id, amount | payload чужого модуля — сверяется с ожидаемой попыткой по subscription_id+период, не пользовательский ввод |
Событие PaymentFailed (cms/commerce-payments) | неудачное списание | payment_id, subscription_id, reason | payload чужого модуля |
Событие UserMerged (ядро) | слияние аккаунтов | primary, secondary | payload события ядра — см. ПДн-паспорт и «Крайние случаи» |
Событие UserDeleted (ядро) | удаление аккаунта | user_id | триггер принудительной отмены и обезличивания |
Плановый запуск cms:commerce-subscriptions:charge | инициация автосписания за период | внутренние subscription_id, period | не пользовательский вход — job по расписанию, не HTTP |
Админ: POST /admin/subscriptions/{id}/retry-charge | ручной повтор списания | {id} в пути | permission commerce-subscriptions.manage, Idempotency-Key, кулдаун (см. «Настройки») |
Админ: POST /admin/subscriptions/{id}/cancel | принудительная отмена | {id}, reason | permission commerce-subscriptions.manage, подтверждение необратимой операции |
Legacy-импорт cms:commerce-subscriptions:import-legacy | строки донорских таблиц (er/notal) | маппинг полей, external_id | идемпотентность по внешнему ключу, --dry-run |
Выходы:
| Куда | Формат | Содержимое |
|---|---|---|
GET /subscriptions/my | {data, meta} | текущий план, статус, current_period_ends_at, сумма и дата ближайшего списания, история попыток (keyset) |
События SubscriptionCharged/SubscriptionChargeFailed/SubscriptionCancelled/SubscriptionPlanChanged/SubscriptionPaused/SubscriptionResumed | канал 1 | см. «События и обмен» |
cms/commerce-receipts (suggests, при наличии) | триггер фискализации | позиция подписки, ставка НДС, признак «услуга» |
cms/notifications-bus (suggests, канал 3) | уведомление | истечение карты заранее, неудачное списание, приближающееся продление |
| Виджет «карточка подписки» в кабинете | рендер (island, personalized: true) | план, статус, остаток периода, кнопки управления — не в общем page-cache |
| Ошибка мутации | конверт ошибок ядра | {message, code, errors}, code ∈ plan_not_found, subscription_limit_exceeded, already_cancelled, version_conflict |
Настройки (группа commerce-subscriptions)
| Ключ | Тип | Дефолт | affectsPageCache | Описание |
|---|---|---|---|---|
commerce-subscriptions.grace_period_days | int | 3 | нет | Grace-период после неудачного списания |
commerce-subscriptions.max_retry_attempts | int | 3 | нет | Число повторных попыток списания |
commerce-subscriptions.retry_interval_hours | int | 24 | нет | Интервал между повторными попытками |
commerce-subscriptions.proration_enabled | bool | true | нет | Проration при смене плана |
commerce-subscriptions.max_active_per_user | int | 5 | нет | Лимит активных подписок на пользователя (защита от дублей и злоупотреблений) |
commerce-subscriptions.manual_retry_cooldown_minutes | int | 15 | нет | Минимальный интервал между ручными повторами списания одной подписки |
commerce-subscriptions.charges_retention_months | int | 36 | нет | Срок хранения журнала списаний как финансового документа (ретеньш) |
commerce-subscriptions.autocharge_enabled | bool | true | нет | Kill-switch: аварийно остановить автосписание по всем подпискам без выключения модуля целиком — управление и история остаются доступны |
Достижение max_active_per_user/кулдауна ручного ретрая отдаёт 422/429 с понятным сообщением и метрикой, не тихий отказ.
API
| Метод | Путь | Доступ | Назначение |
|---|---|---|---|
| GET | /api/v1/subscriptions/my | auth | Текущая подписка пользователя |
| POST | /api/v1/subscriptions/my/change-plan | auth | Апгрейд/даунгрейд плана (с предпросмотром проration) |
| POST | /api/v1/subscriptions/my/cancel | auth | Отмена подписки (immediate или end_of_period) |
| POST | /api/v1/subscriptions/my/pause | auth | Пауза подписки на срок |
| POST | /api/v1/subscriptions/my/resume | auth | Возобновление после паузы |
| GET | /api/v1/admin/subscriptions | admin (commerce-subscriptions.view) | Список подписок с фильтрами по статусу (keyset-пагинация) |
| POST | /api/v1/admin/subscriptions/{id}/retry-charge | admin (commerce-subscriptions.manage) | Ручной повтор списания |
| POST | /api/v1/admin/subscriptions/{id}/cancel | admin (commerce-subscriptions.manage) | Принудительная отмена (необратимо, требует подтверждения) |
Мутации с денежным/внешним эффектом (change-plan, cancel, retry-charge) — заголовок Idempotency-Key (API ядра).
Компоненты
Виджеты: карточка подписки (план, статус, дата/сумма ближайшего списания, замаскированный метод оплаты) и история списаний в личном кабинете (personalized: true, вне общего page-cache). Filament: справочник тарифных планов, список подписок со статусом grace/ретраев, массовый ретрай списания. Команды (все --json): cms:commerce-subscriptions:{charge, expire-grace, check-expiring-cards, purge-old-charges, import-legacy}.
Демо-контент: сидер SubscriptionsDemoSeeder — 2-3 тарифных плана и демо-подписки во всех статусах для /_gallery и playground, без обращения к реальному шлюзу.
События и обмен
| Событие | Когда | Payload |
|---|---|---|
SubscriptionCharged | успешное автосписание | subscription_id, payment_id, amount |
SubscriptionChargeFailed | неудачная попытка списания | subscription_id, attempt_number |
SubscriptionCancelled | подписка отменена (пользователем, администратором принудительно, по исчерпанию ретраев dunning или при слиянии дублирующих подписок) | subscription_id, reason |
SubscriptionPlanChanged | смена плана с проration | subscription_id, from_plan_id, to_plan_id |
SubscriptionPaused | подписка поставлена на паузу | subscription_id, resume_at |
SubscriptionResumed | подписка возобновлена после паузы | subscription_id |
Слушает: PaymentFailed/PaymentSucceeded из cms/commerce-payments для обновления статуса списания; UserMerged/UserDeleted из ядра для ПДн-каскада (см. «Модель данных»). Прямой сервис-вызов PaymentsService::charge() по requires для инициации автосписания.
Таблица взаимодействий:
| Сущность/модуль | Канал | Направление | Что происходит |
|---|---|---|---|
cms/commerce-payments | requires (канал 4) | subscriptions → payments | PaymentsService::charge() — списание сохранённым методом оплаты |
cms/commerce-payments | событие (канал 1) | payments → subscriptions | PaymentSucceeded/PaymentFailed — обновление статуса попытки списания |
cms/notifications-bus | provides notification-channel (канал 3, suggests) | subscriptions → notifications | Уведомление об истечении карты заранее, неудачном списании, приближающемся продлении |
Ядро: UserMerged/UserDeleted | событие (канал 1) | ядро → subscriptions | Перенос подписки на первичный аккаунт / принудительная отмена и обезличивание |
cms/commerce-receipts (suggests, при наличии) | событие (канал 1) | subscriptions → receipts | SubscriptionCharged триггерит фискализацию платежа за период |
| Личный кабинет | внешний канал (REST) | кабинет → subscriptions | Смена плана, отмена, пауза/возобновление, история через /api/v1/subscriptions/my |
| Filament/admin | внешний | admin → subscriptions | Ручной ретрай, принудительная отмена, справочник тарифов |
Фоновая работа
Именованная очередь commerce-subscriptions: плановое списание по расписанию (cms:commerce-subscriptions:charge), обработка ретраев с интервалом retry_interval_hours, перевод в cancelled по истечении grace. Вызов шлюза оплаты — только из очереди (никогда синхронно из HTTP-запроса), ретраи с backoff, идемпотентно на период подписки (ключ идемпотентности subscription_id+период — см. «Крайние случаи»). Плановое списание уважает kill-switch autocharge_enabled — при false джоба пропускает выборку без ошибки, лог фиксирует факт простоя.
Дополнительные джобы: check-expiring-cards (ежедневно — заблаговременное уведомление об истечении сохранённого метода оплаты); purge-old-charges (ретеншн журнала списаний старше charges_retention_months).
Эксплуатация (ранбук). Метрики: subscriptions_charge_success_rate, subscriptions_overdue_count (просроченные списания — период истёк, попытки не было), subscriptions_grace_count, subscriptions_retry_failure_rate. Алерты: доля неудачных попыток списания за сутки выше порога; плановое списание не отработало в ожидаемое окно (подписки с истёкшим current_period_ends_at без свежей попытки в commerce_subscription_charges).
| Симптом | Проверить | Команда |
|---|---|---|
Подписки копятся в grace, не переходят ни в active, ни в cancelled | воркер очереди commerce-subscriptions жив, failed jobs | queue:failed, cms:doctor --json |
| Плановое списание не запустилось за период | расписание ScheduleRegistrar, флаг autocharge_enabled, последний прогон | cms:commerce-subscriptions:charge --dry-run --json |
| Пользователь жалуется на двойное списание | commerce_subscription_charges по subscription_id+период, Idempotency-Key в логе платежей | ручной аудит; при подтверждении — возврат через cms/commerce-payments |
Статус подписки разошёлся с фактом в cms/commerce-payments | commerce_subscription_charges.payment_id сверить с commerce_payments.status | ручной аудит (см. «Крайние случаи» — рассинхрон вебхука) |
Бэкап/рестор: в бэкап попадают все свои таблицы — commerce_subscription_plans/commerce_subscriptions/commerce_subscription_charges/ commerce_subscription_plan_changes (журналы append-only). После рестора ничего не пересчитывается автоматически: обязательный шаг — сверка статуса/current_period_ends_at подписок с последними записями commerce_subscription_charges, иначе возможен пропуск или задвоение ближайшего списания (§15 стандарта).
Производительность и кеш
- Ожидаемые объёмы: десятки–тысячи активных подписок у типового клиента; списаний в месяц ≈ число активных подписок + ретраи (до
max_retry_attemptsна подписку). - Горячие пути: карточка подписки в личном кабинете (частый, персонализированный рендер), плановая выборка подписок к списанию (джоба
charge), админ-список подписок с фильтром по статусу. - Бюджет запросов: карточка подписки в кабинете — ≤ 2 запроса (подписка + последнее списание из кеша тега); выборка джобой подписок к списанию — keyset по индексу
(status, current_period_ends_at), без полного скана таблицы; админ-список — keyset, ≤ 15 запросов (бюджет ядра). - Критичные индексы:
(status, current_period_ends_at)наcommerce_subscriptions— под плановую выборку к списанию и ретраям;(subscription_id, charged_at)BRIN наcommerce_subscription_charges;user_id— под карточку кабинета и проверкуmax_active_per_user. - Теги кеша:
commerce-subscriptions:subscription:<id>,commerce-subscriptions:plan:<id>. Инвалидация — событиямиSubscriptionCharged/Cancelled/PlanChanged/Paused/Resumedи правкой тарифа в Filament. - Токен метода оплаты и история списаний — персональные/финансовые данные, в общий page-cache не попадают; карточка подписки в кабинете рендерится как персонализированный фрагмент (
personalized: true), не кешируется целиком со страницей.
Безопасность
payment_method_token — не сырые реквизиты карты (токенизация на стороне шлюза), хранится как непрозрачная строка. Плановое списание и колбэки шлюза проходят те же границы, что cms/commerce-payments (идемпотентность, подпись, Idempotency-Key). Ручной повтор списания администратором — под правом commerce-subscriptions.manage, логируется в cms/audit. Разграничение прав: commerce-subscriptions.view, commerce-subscriptions.manage.
Векторы: подмена subscription_id в запросах кабинета (принадлежность подписки текущему user_id проверяется на каждой мутации, публичные идентификаторы — UUID, не автоинкремент) · повторное списание при гонке ретраев (Idempotency-Key по subscription_id+период — см. «Крайние случаи») · перебор plan_id для оформления недоступного/архивного тарифа (whitelist активных планов на FormRequest).
Матрица ролей:
| Permission | Админ | Менеджер | Редактор | Studio |
|---|---|---|---|---|
commerce-subscriptions.view | ✅ | ✅ | — | ✅ |
commerce-subscriptions.manage (ручной повтор списания) | ✅ | ✅ | — | ✅ |
| Принудительная отмена подписки (необратимо) | ✅ | ✅ (с подтверждением) | — | ✅ |
Kill-switch autocharge_enabled (настройки) | — | — | — | ✅ |
UX-требования
Покупатель (личный кабинет). Смена плана показывает предпросмотр до подтверждения — сумму проration (доплата или зачёт остатка) и дату/сумму следующего списания по новому плану. Карточка подписки всегда показывает дату и сумму ближайшего списания и замаскированный метод оплаты («•••• 4242»). Отмена — явный выбор между «прямо сейчас» и «по окончании оплаченного периода» (не единственный вариант по умолчанию), с понятным итогом («доступ сохранится до 12.08»). При ошибке оплаты формы (отклонённая карта, недействительный токен) — сообщение на человеческом языке и ссылка «обновить способ оплаты», выбранный план не сбрасывается. Доступность: управление — с клавиатуры, кнопки с подписями («Отменить подписку», не голая иконка), статусы grace/pause промаркированы текстом, не только цветом.
Админ. Пустое состояние списка подписок — «Нет подписок» со ссылкой на справочник тарифов. Массовые действия — массовый ретрай списания для выбранных подписок в grace (итоговый отчёт «повторено N, ошибка M»). Человеческие ошибки — «Нельзя повторить списание — подписка уже отменена» вместо 500 или молчаливого no-op. Подтверждение необратимых операций — принудительная отмена подписки администратором запрашивает «да» и причину (пишется в cms/audit).
Крайние случаи и типовые баги
- Рекуррентный платёж не прошёл → не мгновенная отмена: подписка переходит в
grace, ретраи по расписанию с интерваломretry_interval_hoursдоmax_retry_attempts; исчерпание попыток послеgrace_period_daysпереводит вcancelled(reason: dunning_exhausted). Доступ сохраняется весь grace-период — dunning даёт время обновить карту, а не теряет пользователя на первой неудаче. - Смена тарифа посреди периода → проration при
proration_enabled=true: остаток неиспользованного периода старого плана пересчитывается в доплату/зачёт к новому плану, запись вcommerce_subscription_plan_changes.proration_amount; приproration_enabled=falseсмена применяется с начала следующего периода без пересчёта — обе ветки покрыты контрактным тестом. - Отмена в конце периода vs немедленная отмена → обе опции обязательны (
modeво входе): немедленная — статусcancelledсразу, доступ прекращается; «по окончании периода» — подписка помечается к отмене (cancel_at_period_end), доступ и автосписание продолжаются доcurrent_period_ends_at, финальный переход вcancelledвыполняет плановая джоба, не отдельное ручное действие. - Карта истекла → уведомление уходит заранее: джоба
check-expiring-cardsуведомляет черезcms/notifications-busза N дней до истечения (конфигурируемо), а не постфактум после первой неудачной попытки списания. - Двойное списание при ретрае → идемпотентность на двух уровнях: заголовок
Idempotency-Keyна ручном повторе (админ/API) и внутренний ключsubscription_id+период на плановом/ретрай-списании — повторный запуск джобы за уже списанный период не создаёт вторую записьcommerce_subscription_chargesи не вызываетPaymentsService::charge()повторно. - Параллельная смена плана двумя запросами (двойной клик, два открытых окна кабинета) → мутация меняет план внутри транзакции с блокировкой строки подписки (
SELECT … FOR UPDATE/lock_version); второй запрос получает 409 и просит повторить с актуальным состоянием — не «последний победил» молча и не двойной пересчёт проration. - Тариф, на который подписан пользователь, исчез/подорожал → активные подписчики сохраняют цену на момент оформления до конца текущего цикла (снапшот цены на подписке, по аналогии со снапшотом позиции заказа — commerce-model); дальнейший перевод на новую цену — явная настройка/ручная миграция администратором, не автоматическая подмена без уведомления. Архивный план виден только уже подписанным, не в списке доступных
plan_idдля новых подписок. - Webhook от шлюза о списании приходит раньше или позже, чем ожидает система → источник истины — запись попытки в
commerce_subscription_charges, а не предположение о порядке событий (порядок не гарантирован — обмен данными); обработчикPaymentSucceeded/PaymentFailedищет попытку поsubscription_id+период и обновляет её статус идемпотентно — повторная или запоздавшая доставка не создаёт вторую попытку и не откатывает более свежий статус на старый. - Слияние аккаунтов (
UserMerged), активная подписка есть у обоих → вторая активная подписка не появляется молча: подписка вторичного отменяется (reason: user_merged_duplicate) с уведомлением; история списаний переносится на первичного независимо от исхода — ПДн-каскад полный, двойного списания нет. - Legacy-импорт подписки с
current_period_ends_atв прошлом → списание задним числом не запускается; статус нормализует обычная плановая джоба на ближайшем прогоне (та же логика, что «просроченная подписка»); расхождение попадает в отчёт--dry-run. - ⚠️ Противоречие: модуль хранит
payment_method_tokenна своей таблицеcommerce_subscriptions, ноcms/commerce-paymentsне декларирует сущность «сохранённый способ оплаты»/vault токенов — его модель данных содержит толькоcommerce_payments(разовые транзакции), без таблицы сохранённых методов и без метода видаpaymentMethodExpiry(). Токенизация рекуррентных списаний логически принадлежит домену платежей (переиспользуется и для «сохранить карту» на чекауте), а по правилу владения данными таблица принадлежит одному модулю (обмен данными). Разрешение: завести вcms/commerce-paymentsсущностьcommerce_payment_methodsи методыPaymentsService::chargeSavedMethod(paymentMethodId, amount)/paymentMethodExpiry(paymentMethodId);commerce-subscriptionsхранит толькоpayment_method_id(FK на чужую сущность), не сырой токен. До реализации — фиксируется явно здесь; см. открытые вопросы, если не закроется до старта.
Донорский код
| Что взять | Путь |
|---|---|
| Рекуррентные платежи и биллинг (Cashier) | er/project/src/app/Domain/Billing/ |
| Токенизация метода оплаты, интеграция со шлюзом | notal/src/app/Services/ |
Legacy-импорт. cms:commerce-subscriptions:import-legacy --source=er|notal --dry-run — маппинг донорских таблиц биллинга (er: подписки/тарифы Cashier; notal: токенизация метода оплаты) на commerce_subscription_plans/commerce_subscriptions/ commerce_subscription_charges; идемпотентна по external_id (повторный прогон обновляет существующие записи, не дублирует); импортированная запись с current_period_ends_at в прошлом не запускает списание задним числом (см. «Крайние случаи»); --dry-run печатает отчёт расхождений без записи. Прогон на копии донорских данных — часть приёмки модуля (§16 стандарта).
Тесты и приёмка
- [ ] Контрактный тест: плановое списание идемпотентно на конкретный период (повторный запуск команды не создаёт дубль)
- [ ] Неудачное списание переводит подписку в
grace, ретраи не превышаютmax_retry_attempts - [ ] Исчерпание ретраев после grace-периода переводит подписку в
cancelledи публикуетSubscriptionCancelled(не мгновенно на первую неудачу) - [ ] Смена плана с
proration_enabled=trueкорректно пересчитывает остаток оплаченного периода; сfalse— переносит смену на начало следующего периода - [ ] Отмена «немедленно» и «по окончании периода» — оба режима покрыты тестами, доступ и автосписание ведут себя по-разному; пауза/возобновление не создают повторного списания за приостановленный период
- [ ] Джоба
check-expiring-cardsуведомляет заранее, до фактического истечения карты; параллельная смена плана двумя запросами — второй получает 409, не двойной пересчёт проration - [ ] Архивный/подорожавший план сохраняет цену для уже подписанных до конца цикла, не подставляется автоматически; обработка
PaymentSucceeded/PaymentFailedидемпотентна независимо от порядка доставки - [ ]
UserMergedс активной подпиской на обоих аккаунтах не создаёт вторую активную у первичного; хуки «выгрузить всё»/«забыть по запросу» покрывают подписки и историю списаний - [ ] Матрица ролей: kill-switch
autocharge_enabledдоступен только studio;autocharge_enabled=falseостанавливает автосписание, управление и история остаются доступны - [ ] Джоба
purge-old-chargesне удаляет записи моложеcharges_retention_months;import-legacy --dry-runпрогнан на копии донорских данных, отчёт полный - [ ] При выключении модуля автосписание останавливается, история подписок и списаний сохраняется; права
manageразграничивают просмотр и ручные операции - [ ] Контрактный набор
cms-testingзелёный, testbench-изоляция пакета, feature-тест на каждый роут, шлюз оплаты в тестах замокан - [ ] Тестовая БД — только
commerce-subscriptions_test;migrate:fresh/refresh/resetзапрещены