Skip to content

ТЗ — Чеки/ОФД (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_receiptsid, 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_itemsreceipt_id, order_item_id, title, price, quantity, tax_rate_id, subject_type (product|service|digital)позиции чека, снапшот на момент формирования
commerce_receipt_logid, receipt_id, action (created|sent|resent|failed|reversed), payload (json), created_atappend-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, ReceiptFiscalizationFailedpayload — см. «События и обмен»

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

КлючТипДефолтaffectsPageCacheОписание
commerce-receipts.enabledbooltrueнетОбязательность формирования чека при оплате (kill-switch, см. ниже)
commerce-receipts.ofd_providerstringnullнетКод активного провайдера ОФД (cms/ofd)
commerce-receipts.resend_max_attemptsint5нетМаксимум повторных отправок при ошибке фискализации
commerce-receipts.resend_interval_minutesint15нетИнтервал между повторными попытками
commerce-receipts.ofd_requests_per_minuteint60нетЛимит запросов к ОФД в минуту по тарифному плану провайдера — ограничивает размер батча send-queue
commerce-receipts.unsent_alert_hoursint6нетПорог недоставки чека (в часах) для алерта — юридический риск, не рядовой SLA

Kill-switch и отключение модуля — разные вещи. commerce-receipts.enabled=false — это kill-switch: чек не формируется на новых оплатах, но модуль остаётся включённым, а очередь уже сформированных неотправленных чеков продолжает попытки доставки в ОФД (обязанность отправить уже созданный чек не исчезает вместе с настройкой). Полное выключение модуля (disable жизненного цикла ядра) останавливает и формирование, и попытки отправки — доступно только studio-роли с осознанием, что обязанность пробития чека переходит на внешнюю кассу (см. «Зависимости и выключение»).

Лимиты и квоты: ofd_requests_per_minute — явная настройка под тариф провайдера; достижение лимита не роняет отправку 500-й ошибкой, а откладывает часть батча до следующего окна (см. «Крайние случаи»).

API

МетодПутьДоступНазначение
GET/api/v1/orders/{id}/receiptsauth (владелец заказа)Список чеков заказа (в т.ч. возврата)
GET/api/v1/admin/receiptsadmin (commerce-receipts.view)Список чеков с фильтром по статусу фискализации (keyset-пагинация)
POST/api/v1/admin/receipts/{id}/resendadmin (commerce-receipts.manage)Повторная отправка в ОФД
POST/api/v1/admin/receipts/resend-bulkadmin (commerce-receipts.manage)Массовая повторная отправка выбранных чеков
POST/api/v1/admin/receipts/{id}/correctionadmin (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_hourscms: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, коды ошибок ОФД) не показываются, это техническая кухня для админа, не для витрины.

Админ: список чеков с фильтром по статусу фискализации и типу (расчёт/предоплата/ возврат/коррекция); массовая повторная отправка нескольких выбранных чеков; человеческая ошибка при сбое ОФД («Не удалось отправить чек в ОФД: <причина от провайдера>. Проверьте код провайдера в настройках модуля»), не сырой стектрейс; подтверждение перед созданием чека коррекции («Будет создан новый чек коррекции, исходный чек не изменится и не удаляется — продолжить?») и перед массовой отправкой большой партии.

Крайние случаи и типовые баги

  1. Долгая недоступность ОФД — чек обязан уйти даже если провайдер лежит часами: это юридическая обязанность, не просто SLA → ретраи по расписанию (resend_interval_minutes до resend_max_attempts), после исчерпания счётчика попыток очередь не «сдаётся», а продолжает попытки реже с алертом после unsent_alert_hours.
  2. Чек коррекции — ошибка в уже отправленном чеке вскрылась позже (неверная сумма, ставка НДС) → создаётся отдельный чек type=correction, ссылающийся на исходный; правка/удаление уже отправленного чека не допускается ни при каких правах.
  3. Возврат чека при RMA/рефандеRefundApproved создаёт type=refund, ссылку на исходный чек по FK и корректный признак «возврат прихода»; суммы позиций возвратного чека не превышают суммы соответствующих позиций исходного чека.
  4. Сумма позиций vs сумма заказа — обязана сходиться до копейки: деньги — integer minor units, скидка распределяется по позициям пропорционально их доле в сумме, копейка округления уходит на последнюю позицию (не теряется и не задваивается) — контрактный тест.
  5. Расхождение ставки НДС между каталогом и моментом формирования чека — позиция заказа несёт tax_rate_id-снапшот на момент оформления (commerce-model); последующее изменение ставок в cms/commerce-tax не переспрашивается и не меняет уже оформленные заказы задним числом.
  6. Двойная доставка PaymentSucceeded (очередь гарантирует «хотя бы одну» доставку, не «ровно одну», data-exchange) → идемпотентность по паре order_id+payment_id, повторная доставка не создаёт второй чек расчёта.
  7. Частичная предоплата → доплата — на один заказ формируются два чека разных типов (prepayment, затем full_settlement на доплату), оба ссылаются на общий order_id и разные payment_id; сумма обоих чеков в сумме равна итогу заказа.
  8. Оплата бонусами — уменьшает цену позиций построчно (пересчёт по позициям, не отдельная строка «оплачено бонусами»), чек фиксирует итоговую уменьшенную цену — см. commerce-model, «Чеки (54-ФЗ)».
  9. Смена юрлица/ставки НДС продавца между заказами — чек хранит снапшот реквизитов продавца и режима налогообложения на момент формирования, не текущие настройки на момент чтения/повторной отправки (историчность).
  10. Suggests cms/commerce-rma выключенRefundApproved не публикуется → возвратные чеки не формируются автоматически; менеджер создаёт чек возврата вручную через Filament, если модуль рефандов недоступен — не молчаливая потеря обязанности.
  11. Исчерпан тарифный лимит ОФД (429 от провайдера) → send-queue откладывает часть батча до следующего окна (ofd_requests_per_minute), не роняет команду 500-й ошибкой и не теряет чеки — деградация очереди с алертом, не потеря отправки.
  12. Отмена заказа после отправки чека — журнал 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 запрещены

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