Тема
ТЗ — Чеки/ОФД (54-ФЗ) (cms/commerce-receipts)
Слой: 🟡 функц-модуль · Зрелость доноров: ★ · Донор: — Статус: ТЗ к разработке
Назначение и возможности
Формирование фискального чека из заказа и очередь отправки в ОФД. Каждая позиция чека несёт ставку НДС и признак предмета расчёта; бонусы и скидки уменьшают цены строк, а не выносятся отдельной строкой — см. /cms-v2/commerce-model, раздел «Чеки (54-ФЗ)».
- Формирование чека из заказа: позиции со ставкой НДС (
cms/commerce-tax) и признаком предмета расчёта (товар/услуга/цифровой товар) - Признак способа расчёта: предоплата 100% / частичная предоплата / полный расчёт
- Бонусы и скидки уменьшают цены строк чека, не проводятся отдельной строкой
- Очередь отправки в ОФД через провайдер-контракт (АТОЛ и аналоги — коннектор
cms/ofd) - Статусы фискализации чека (создан → в очереди → отправлен → принят/ошибка)
- Повторная отправка чека при ошибке фискализации (ручная, массовая и по расписанию)
- Чек коррекции — отдельный тип чека, когда ошибка в уже отправленном чеке обнаружена позже (правка задним числом фискального документа невозможна и не предусмотрена)
- Чек возврата при RMA/рефанде — привязан к исходному чеку и платежу
- Журнал чеков append-only (сторно вместо удаления при отмене)
Зависимости и выключение
requires: ядро, cms/commerce-model (заказы), cms/commerce-payments, cms/commerce-tax · suggests: cms/commerce-rma, cms/commerce-digital · provides-потребитель: cms/ofd
Поведение при выключении: заказы оформляются и оплачиваются без формирования фискального чека — обязанность пробития чека переходит на внешнюю кассу/оператора вручную; данные уже отправленных чеков сохраняются в журнале без изменений.
Стоимость внешнего API: тариф провайдера ОФД взимается за каждый переданный фискальный документ (чек), не за факт обращения к API; расход виден в админке рядом с лимитом ofd_requests_per_minute (см. «Производительность и кеш»); поведение при исчерпании тарифного лимита провайдера — деградация очереди отправки с алертом, не 500 (детали — «Крайние случаи и типовые баги»).
Модель данных
| Таблица | Ключевые поля | Примечание |
|---|---|---|
commerce_receipts | id, order_id, payment_id, type (prepayment|full_settlement|refund|correction), fiscal_status, ofd_response (json) | шапка чека, type/fiscal_status — PHP Enum, ofd_response — JSONB + GIN |
commerce_receipt_items | receipt_id, order_item_id, title, price, quantity, tax_rate_id, subject_type (product|service|digital) | позиции чека, снапшот на момент формирования |
commerce_receipt_log | id, receipt_id, action (created|sent|resent|failed|reversed), payload (json), created_at | append-only журнал операций, BRIN по created_at |
order_id/payment_id/receipt_id/tax_rate_id — FK constrained()->index(). Отмена чека — новая запись reversed в журнале (сторно), исходная запись не удаляется и не редактируется. Составной индекс (fiscal_status, created_at) на commerce_receipts — см. «Производительность и кеш».
ПДн-паспорт. Чек может нести email/телефон покупателя (для эл. чека, минимально необходимый набор) в позициях снапшота или в ofd_response; ФИО и прочие ПДн, не требуемые 54-ФЗ, не хранятся. Срок хранения фискальных документов задаётся требованиями законодательства и провайдера ОФД и обычно дольше, чем стандартный ретеншн персональных данных сайта (годы, не месяцы). ⚠️ Противоречие: это создаёт формальный конфликт с правом субъекта «забыть по запросу» (152-ФЗ). Разрешение: фискальные документы — законодательное исключение из каскада «забыть» (обработка на основании исполнения требования закона, ст. 6 152-ФЗ, а не согласия субъекта) — хук ядра «забыть по запросу» для таблиц модуля обезличивает только необязательные для 54-ФЗ данные (например, отвязывает чек от профиля пользователя, если это не мешает фискальному учёту), но не удаляет и не редактирует сам фискальный документ и его позиции.
Входные и выходные данные
Входы (всё, что не перечислено ниже, модуль отвергает — whitelist-принцип §11 стандарта):
| Источник | Поля | Чем валидируется |
|---|---|---|
Событие PaymentSucceeded (cms/commerce-payments) | payment_id, order_id, amount, items[] (снапшот заказа) | контракт события; чек формируется только из данных снапшота, каталог на момент отправки не переспрашивается |
Событие RefundApproved (cms/commerce-rma, suggests) | refund_id, order_id, receipt_id (исходный) | контракт события; при выключенном cms/commerce-rma канал не активен |
Позиции заказа (снапшот, cms/commerce-model) | order_item_id, title, price, quantity, tax_rate_id, subject_type | читается через сервис владельца (не raw SQL), поля — строго по перечню |
Ответ ОФД (вебхук/поллинг через cms/ofd) | fiscal_status, fiscal_document_number, fiscal_sign, error | контракт ofd-provider; вебхук — только через cms/webhooks-in с проверкой подписи |
| Команда/API повторной отправки, ручной коррекции | receipt_id (или список — массовая отправка) | право commerce-receipts.manage, FormRequest |
Выходы:
| Потребитель | Данные | Формат |
|---|---|---|
| ОФД-провайдер | фискальный чек | формат провайдера (АТОЛ/ФФД) через контракт ofd-provider |
| Покупатель (API) | список чеков заказа, статус «направлен»/«аннулирован» | JSON-конверт {data, meta} |
| Админ (Filament/API) | список чеков с фильтром по статусу фискализации | keyset-JSON |
| Подписчики событий | ReceiptGenerated, ReceiptFiscalized, ReceiptFiscalizationFailed | payload — см. «События и обмен» |
Настройки (группа commerce-receipts)
| Ключ | Тип | Дефолт | affectsPageCache | Описание |
|---|---|---|---|---|
commerce-receipts.enabled | bool | true | нет | Обязательность формирования чека при оплате (kill-switch, см. ниже) |
commerce-receipts.ofd_provider | string | null | нет | Код активного провайдера ОФД (cms/ofd) |
commerce-receipts.resend_max_attempts | int | 5 | нет | Максимум повторных отправок при ошибке фискализации |
commerce-receipts.resend_interval_minutes | int | 15 | нет | Интервал между повторными попытками |
commerce-receipts.ofd_requests_per_minute | int | 60 | нет | Лимит запросов к ОФД в минуту по тарифному плану провайдера — ограничивает размер батча send-queue |
commerce-receipts.unsent_alert_hours | int | 6 | нет | Порог недоставки чека (в часах) для алерта — юридический риск, не рядовой SLA |
Kill-switch и отключение модуля — разные вещи. commerce-receipts.enabled=false — это kill-switch: чек не формируется на новых оплатах, но модуль остаётся включённым, а очередь уже сформированных неотправленных чеков продолжает попытки доставки в ОФД (обязанность отправить уже созданный чек не исчезает вместе с настройкой). Полное выключение модуля (disable жизненного цикла ядра) останавливает и формирование, и попытки отправки — доступно только studio-роли с осознанием, что обязанность пробития чека переходит на внешнюю кассу (см. «Зависимости и выключение»).
Лимиты и квоты: ofd_requests_per_minute — явная настройка под тариф провайдера; достижение лимита не роняет отправку 500-й ошибкой, а откладывает часть батча до следующего окна (см. «Крайние случаи»).
API
| Метод | Путь | Доступ | Назначение |
|---|---|---|---|
| GET | /api/v1/orders/{id}/receipts | auth (владелец заказа) | Список чеков заказа (в т.ч. возврата) |
| GET | /api/v1/admin/receipts | admin (commerce-receipts.view) | Список чеков с фильтром по статусу фискализации (keyset-пагинация) |
| POST | /api/v1/admin/receipts/{id}/resend | admin (commerce-receipts.manage) | Повторная отправка в ОФД |
| POST | /api/v1/admin/receipts/resend-bulk | admin (commerce-receipts.manage) | Массовая повторная отправка выбранных чеков |
| POST | /api/v1/admin/receipts/{id}/correction | admin (commerce-receipts.manage), Idempotency-Key | Создание чека коррекции по исходному чеку |
Компоненты
Filament: журнал чеков с фильтром по статусу фискализации, кнопка повторной отправки (одиночная и массовая по выбранным строкам), форма создания чека коррекции с подтверждением. Команды: cms:commerce-receipts:send-queue --json (отправка очереди в ОФД батчами), cms:commerce-receipts:retry-failed --json.
Демо-контент: CommerceReceiptsDemoSeeder создаёт несколько демо-чеков (расчёт, предоплата, возврат) с разными статусами фискализации (отправлен/ошибка/в очереди) — галерея /_gallery и playground показывают список с фильтром без реальных заказов.
Фронтенд-бюджет и a11y: не применимо — модуль не регистрирует блоков/виджетов со своим JS/CSS; страница «мои чеки» в кабинете использует стандартный список/детали ядра.
Эксплуатация (ранбук): метрики commerce_receipts_unsent_total, commerce_receipts_fiscalization_failed_total{reason}, commerce_receipts_send_queue_duration_seconds; алерт — доля неотправленных чеков старше unsent_alert_hours (юридический риск недоставки, не просто просроченная job).
| Симптом | Что проверить / команда |
|---|---|
Чек висит в статусе «в очереди» дольше unsent_alert_hours | cms:commerce-receipts:send-queue --json, health-чек cms/ofd, доступность провайдера |
| Массовые ошибки фискализации после смены реквизитов продавца | реквизиты юрлица/кассы у провайдера, код ofd_provider, тестовый прогон retry-failed --dry-run |
| Возвратный чек не создаётся при рефанде | включён ли cms/commerce-rma (suggests), наличие исходного чека по order_id |
| Сумма позиций чека не сходится с суммой заказа | контрактный тест «сумма позиций = сумма заказа», проверить распределение скидки по позициям |
| Провайдер отвечает 429/лимит исчерпан | ofd_requests_per_minute против фактического тарифа, размер батча send-queue |
Бэкап/рестор: commerce_receipts, commerce_receipt_items, commerce_receipt_log — обязательны в бэкапе целиком (журнал юридически значим, не только технически полезен); после рестора записи не пересоздаются автоматически (append-only) — send-queue --dry-run --json показывает состояние очереди, расхождение локального статуса с фактическим статусом в ОФД требует ручной сверки по ofd_response каждого чека.
События и обмен
| Событие | Когда | Payload |
|---|---|---|
ReceiptGenerated | чек сформирован из заказа | receipt_id, order_id, type |
ReceiptFiscalized | ОФД подтвердил приём чека | receipt_id, fiscal_status |
ReceiptFiscalizationFailed | ошибка отправки в ОФД | receipt_id, error |
Слушает: PaymentSucceeded из cms/commerce-payments (формирование чека расчёта), RefundApproved из cms/commerce-rma (формирование чека возврата). Потребляет provides-контракт ofd-provider, реализуемый cms/ofd.
Таблица взаимодействий:
| Сущность/модуль | Канал | Направление | Что происходит |
|---|---|---|---|
cms/commerce-payments (PaymentSucceeded) | событие (1, requires) | payments → receipts | формирование чека расчёта/предоплаты, идемпотентно по order_id+payment_id |
cms/commerce-rma (RefundApproved, suggests) | событие (1, suggests) | rma → receipts | формирование чека возврата, привязка к исходному чеку; при выключенном модуле канал не активен |
cms/commerce-model (снапшот позиций заказа) | сервис-вызов (4, requires) | receipts → commerce-model | чтение цены/НДС/признака предмета расчёта на момент оформления заказа, без обращения к каталогу |
cms/commerce-tax | сервис-вызов (4, requires) | receipts → tax | ставка НДС резолвится один раз на момент формирования заказа (снапшот), не переспрашивается при отправке чека |
cms/ofd (ofd-provider) | provides-контракт (3) | receipts → ofd | отправка чека провайдеру, приём статуса фискализации (вебхук/поллинг) |
cms/commerce-digital (suggests, косвенно) | — (данные приходят через снапшот commerce-model) | commerce-model → receipts | признак предмета расчёта «цифровой товар» — читается из позиции заказа, без прямого обращения к digital |
cms/commerce-vendor (косвенно) | — (данные приходят через снапшот commerce-model) | commerce-model → receipts | сплит по вендорам при мультивендорном заказе — позиции группируются по vendor_id заказа, отдельный чек на продавца |
cms/audit | событие (1) | receipts → audit | повторная отправка и создание чека коррекции логируются (фискальная значимость) |
Фоновая работа
Именованная очередь commerce-receipts: формирование чека по PaymentSucceeded (идемпотентно — повторная доставка события не создаёт второй чек расчёта), отправка очереди в ОФД (send-queue), ретраи неотправленных чеков с интервалом resend_interval_minutes до resend_max_attempts, после исчерпания — чек остаётся в очереди с алертом (юридическая обязанность не снимается исчерпанием попыток, ретраи по расписанию продолжаются реже, не прекращаются молча). Вызов ОФД — только из очереди, с backoff при недоступности провайдера.
Производительность и кеш
Объёмы: один чек на каждый оплаченный заказ плюс чек возврата на каждый одобренный рефанд — от сотен до тысяч записей в день на среднем магазине; commerce_receipt_log растёт быстрее (несколько записей на попытку отправки).
Горячий путь: отправка чека в ОФД не на горячем пути публичной страницы и не на горячем пути checkout/оплаты — формирование по PaymentSucceeded кладёт job, ответ покупателю о заказе не ждёт ответа ОФД. Бюджет: команда send-queue отправляет батчами (размер батча ограничен ofd_requests_per_minute), не по одному чеку синхронным HTTP на каждый tick очереди.
Индексы: составной (fiscal_status, created_at) на commerce_receipts — выборка неотправленных/просроченных чеков для send-queue/retry-failed без seq-scan; BRIN по created_at на журнальной commerce_receipt_log (см. «Модель данных»).
Теги кеша commerce-receipts:receipt:<id>. Инвалидация — событиями ReceiptGenerated/Fiscalized/FiscalizationFailed. Персональные и фискальные данные чека (суммы, статус, реквизиты покупателя) не попадают в общий page-cache — только персональная выдача владельцу заказа.
Безопасность
Формирование чека — только из события PaymentSucceeded/RefundApproved, без прямого публичного эндпоинта создания. Данные ОФД-провайдера (токен доступа) — только .env. Повторная отправка и создание чека коррекции — под правом commerce-receipts.manage, действие логируется в cms/audit (фискальная значимость). Журнал commerce_receipt_log неизменяем задним числом — только добавление записей. Разграничение прав: commerce-receipts.view, commerce-receipts.manage.
Матрица ролей:
| Действие | Администратор | Менеджер | Редактор | Studio |
|---|---|---|---|---|
Просмотр чеков и статусов фискализации (view) | ✅ | ✅ | — | ✅ |
Повторная отправка чека, одиночная и массовая (manage) | ✅ | ✅ | — | ✅ |
| Создание чека коррекции (правка задним числом фискального факта) | ✅ | — | — | ✅ |
Изменение настроек провайдера ОФД (ofd_provider, лимиты) | ✅ | — | — | ✅ |
commerce-receipts.enabled (kill-switch) | ✅ | — | — | ✅ |
ПДн-каскад: фискальные документы исключены из «забыть по запросу» по требованию 152-ФЗ ст. 6 (обработка в силу закона) — см. «Модель данных», помечено как ⚠️ Противоречие с общим правилом ядра «выгрузить всё / забыть по запросу» и явно разрешено там же.
Конкурентное редактирование: не применимо в классическом смысле — чек и журнал append-only, прямой правки полей нет; риск гонки закрывается идемпотентностью по order_id+payment_id (см. «Крайние случаи»), а не optimistic lock.
UX-требования
Покупатель: ссылка на печатную форму/эл. чек (если провайдер её отдаёт) — в кабинете на странице заказа; о статусе фискализации покупателю видно только человеческое «чек направлен» / «чек аннулирован» — внутренние статусы (fiscal_status, коды ошибок ОФД) не показываются, это техническая кухня для админа, не для витрины.
Админ: список чеков с фильтром по статусу фискализации и типу (расчёт/предоплата/ возврат/коррекция); массовая повторная отправка нескольких выбранных чеков; человеческая ошибка при сбое ОФД («Не удалось отправить чек в ОФД: <причина от провайдера>. Проверьте код провайдера в настройках модуля»), не сырой стектрейс; подтверждение перед созданием чека коррекции («Будет создан новый чек коррекции, исходный чек не изменится и не удаляется — продолжить?») и перед массовой отправкой большой партии.
Крайние случаи и типовые баги
- Долгая недоступность ОФД — чек обязан уйти даже если провайдер лежит часами: это юридическая обязанность, не просто SLA → ретраи по расписанию (
resend_interval_minutesдоresend_max_attempts), после исчерпания счётчика попыток очередь не «сдаётся», а продолжает попытки реже с алертом послеunsent_alert_hours. - Чек коррекции — ошибка в уже отправленном чеке вскрылась позже (неверная сумма, ставка НДС) → создаётся отдельный чек
type=correction, ссылающийся на исходный; правка/удаление уже отправленного чека не допускается ни при каких правах. - Возврат чека при RMA/рефанде —
RefundApprovedсоздаётtype=refund, ссылку на исходный чек по FK и корректный признак «возврат прихода»; суммы позиций возвратного чека не превышают суммы соответствующих позиций исходного чека. - Сумма позиций vs сумма заказа — обязана сходиться до копейки: деньги — integer minor units, скидка распределяется по позициям пропорционально их доле в сумме, копейка округления уходит на последнюю позицию (не теряется и не задваивается) — контрактный тест.
- Расхождение ставки НДС между каталогом и моментом формирования чека — позиция заказа несёт
tax_rate_id-снапшот на момент оформления (commerce-model); последующее изменение ставок вcms/commerce-taxне переспрашивается и не меняет уже оформленные заказы задним числом. - Двойная доставка
PaymentSucceeded(очередь гарантирует «хотя бы одну» доставку, не «ровно одну», data-exchange) → идемпотентность по пареorder_id+payment_id, повторная доставка не создаёт второй чек расчёта. - Частичная предоплата → доплата — на один заказ формируются два чека разных типов (
prepayment, затемfull_settlementна доплату), оба ссылаются на общийorder_idи разныеpayment_id; сумма обоих чеков в сумме равна итогу заказа. - Оплата бонусами — уменьшает цену позиций построчно (пересчёт по позициям, не отдельная строка «оплачено бонусами»), чек фиксирует итоговую уменьшенную цену — см. commerce-model, «Чеки (54-ФЗ)».
- Смена юрлица/ставки НДС продавца между заказами — чек хранит снапшот реквизитов продавца и режима налогообложения на момент формирования, не текущие настройки на момент чтения/повторной отправки (историчность).
- Suggests
cms/commerce-rmaвыключен —RefundApprovedне публикуется → возвратные чеки не формируются автоматически; менеджер создаёт чек возврата вручную через Filament, если модуль рефандов недоступен — не молчаливая потеря обязанности. - Исчерпан тарифный лимит ОФД (429 от провайдера) →
send-queueоткладывает часть батча до следующего окна (ofd_requests_per_minute), не роняет команду 500-й ошибкой и не теряет чеки — деградация очереди с алертом, не потеря отправки. - Отмена заказа после отправки чека — журнал append-only: создаётся сторно-запись
reversed, исходная запись не удаляется; если чек уже фискализирован, отмена в БД сама по себе не аннулирует фискальный документ — требуется отдельный чек возврата.
Донорский код
Донор: — (новая разработка).
Легаси-импорт (переезд клиента со старой платформы): команда cms:commerce-receipts:import-legacy --source=<профиль> --dry-run мапит экспорт старой кассы/CMS (шапка чека, позиции, статус фискализации, номер фискального документа) на commerce_receipts/commerce_receipt_items по ключу external_id; идемпотентна — повторный прогон обновляет существующие записи, а не дублирует; уже фискализированные исторические чеки переносятся в журнал без повторной отправки в ОФД (импорт истории, не попытка задним числом пробить чек через провайдера). Отчёт: прочитано/создано/обновлено/ пропущено с построчными причинами.
Тесты и приёмка
- [ ] Контрактный тест: позиция чека всегда несёт ставку НДС и признак предмета расчёта, отсутствие любого — ошибка формирования
- [ ] Бонусы/скидки отражены как уменьшение цены строки, сумма позиций чека равна фактически оплаченной сумме до копейки (округление — на последнюю позицию)
- [ ] Формирование чека по
PaymentSucceededидемпотентно поorder_id+payment_id— повторная доставка события не создаёт второй чек расчёта - [ ] Ошибка фискализации ставит чек в очередь повторной отправки, не блокируя выдачу товара покупателю; после исчерпания
resend_max_attemptsочередь продолжает попытки реже и алертит послеunsent_alert_hours - [ ] Чек коррекции создаётся отдельной записью со ссылкой на исходный чек; исходный чек не редактируется и не удаляется
- [ ] Чек возврата ссылается на исходный чек и корректно уменьшает фискальные суммы, не удаляя историю
- [ ] Частичная предоплата и последующая доплата формируют два чека разных типов на один заказ с корректной суммой в итоге
- [ ] Журнал чеков append-only: отмена заказа создаёт сторно-запись, не удаляет исходный чек
- [ ] Исчерпание тарифного лимита ОФД (429) откладывает батч
send-queue, не роняет команду и не теряет чеки - [ ] При выключении модуля заказ и оплата проходят без формирования чека, без падения checkout; при
enabled=false(kill-switch) очередь уже созданных чеков продолжает отправку - [ ]
cms:commerce-receipts:import-legacy --dry-runидемпотентен, повторный прогон не дублирует импортированные чеки - [ ] Права
commerce-receipts.view/commerce-receipts.manageразграничивают просмотр и управление (повтор отправки, коррекция) согласно матрице ролей - [ ] Контрактный набор
cms-testingзелёный, пакет тестируется в testbench-изоляции - [ ] Feature-тест на каждый роут API, провайдер ОФД в тестах замокан
- [ ] Тестовая БД — только
commerce-receipts_test;migrate:fresh/refresh/reset,db:wipeзапрещены