Skip to content

ТЗ — Подписки/рекуррентные платежи (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_plansid, code, title, price, currency_code, period (month|quarter|year)тарифный план, price — integer minor units
commerce_subscriptionsid, user_id, plan_id, status (active|paused|grace|cancelled), payment_method_token, current_period_ends_atподписка пользователя, status/period — PHP Enum
commerce_subscription_chargesid, subscription_id, payment_id, status, attempt_number, charged_atappend-only журнал попыток списания, BRIN по charged_at
commerce_subscription_plan_changessubscription_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_idFormRequest 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, amountpayload чужого модуля — сверяется с ожидаемой попыткой по subscription_id+период, не пользовательский ввод
Событие PaymentFailed (cms/commerce-payments)неудачное списаниеpayment_id, subscription_id, reasonpayload чужого модуля
Событие UserMerged (ядро)слияние аккаунтовprimary, secondarypayload события ядра — см. ПДн-паспорт и «Крайние случаи»
Событие 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}, reasonpermission 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}, codeplan_not_found, subscription_limit_exceeded, already_cancelled, version_conflict

Настройки (группа commerce-subscriptions)

КлючТипДефолтaffectsPageCacheОписание
commerce-subscriptions.grace_period_daysint3нетGrace-период после неудачного списания
commerce-subscriptions.max_retry_attemptsint3нетЧисло повторных попыток списания
commerce-subscriptions.retry_interval_hoursint24нетИнтервал между повторными попытками
commerce-subscriptions.proration_enabledbooltrueнетПроration при смене плана
commerce-subscriptions.max_active_per_userint5нетЛимит активных подписок на пользователя (защита от дублей и злоупотреблений)
commerce-subscriptions.manual_retry_cooldown_minutesint15нетМинимальный интервал между ручными повторами списания одной подписки
commerce-subscriptions.charges_retention_monthsint36нетСрок хранения журнала списаний как финансового документа (ретеньш)
commerce-subscriptions.autocharge_enabledbooltrueнетKill-switch: аварийно остановить автосписание по всем подпискам без выключения модуля целиком — управление и история остаются доступны

Достижение max_active_per_user/кулдауна ручного ретрая отдаёт 422/429 с понятным сообщением и метрикой, не тихий отказ.

API

МетодПутьДоступНазначение
GET/api/v1/subscriptions/myauthТекущая подписка пользователя
POST/api/v1/subscriptions/my/change-planauthАпгрейд/даунгрейд плана (с предпросмотром проration)
POST/api/v1/subscriptions/my/cancelauthОтмена подписки (immediate или end_of_period)
POST/api/v1/subscriptions/my/pauseauthПауза подписки на срок
POST/api/v1/subscriptions/my/resumeauthВозобновление после паузы
GET/api/v1/admin/subscriptionsadmin (commerce-subscriptions.view)Список подписок с фильтрами по статусу (keyset-пагинация)
POST/api/v1/admin/subscriptions/{id}/retry-chargeadmin (commerce-subscriptions.manage)Ручной повтор списания
POST/api/v1/admin/subscriptions/{id}/canceladmin (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смена плана с проrationsubscription_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-paymentsrequires (канал 4)subscriptions → paymentsPaymentsService::charge() — списание сохранённым методом оплаты
cms/commerce-paymentsсобытие (канал 1)payments → subscriptionsPaymentSucceeded/PaymentFailed — обновление статуса попытки списания
cms/notifications-busprovides notification-channel (канал 3, suggests)subscriptions → notificationsУведомление об истечении карты заранее, неудачном списании, приближающемся продлении
Ядро: UserMerged/UserDeletedсобытие (канал 1)ядро → subscriptionsПеренос подписки на первичный аккаунт / принудительная отмена и обезличивание
cms/commerce-receipts (suggests, при наличии)событие (канал 1)subscriptions → receiptsSubscriptionCharged триггерит фискализацию платежа за период
Личный кабинетвнешний канал (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 jobsqueue: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-paymentscommerce_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 запрещены

Внутренняя база знаний студии