Тема
ТЗ — Налоги/НДС (cms/commerce-tax)
Слой: 🟡 функц-модуль · Зрелость доноров: ★ · Донор: — Статус: ТЗ к разработке
Назначение и возможности
Справочник ставок НДС и режима юрлица продавца, снапшот ставки в строку заказа на момент оформления. Модель согласована с доменной моделью коммерции, см. /cms-v2/commerce-model, раздел «НДС и налоги».
- Справочник ставок НДС: 20% (основная), 10% (льготная), 0% (экспорт и аналоги), «без НДС» (УСН/освобождение) — все четыре различаются явно, не сведены к «20% vs без»
- Привязка ставки к товару/категории, с наследованием и точечным переопределением на ТП
- Ставка по умолчанию для товаров без явной привязки
- Режим юрлица (ОСН/УСН) как настройка — влияет на признак НДС в чеках
- Снапшот ставки НДС в строку заказа на момент оформления (правки каталога не меняют историю)
- Расчёт и вывод сумм НДС в итогах заказа и B2B-инвойсах в двух режимах: НДС «в цене» (розница,
prices_include_vat=true) и НДС «сверху» (B2B-документы,prices_include_vat=false) — см. «Крайние случаи» - Распределение округления суммы НДС по позициям корзины со смешанными ставками так, чтобы сумма НДС по позициям сходилась с округлением итога заказа до копейки (правило — «Крайние случаи»)
- Подготовка данных строки заказа (ставка + сумма) для
cms/commerce-receipts
Зависимости и выключение
requires: ядро, cms/commerce-model (заказы) · suggests: cms/commerce-receipts, cms/commerce-b2b
Поведение при выключении: все товары считаются «без НДС», суммы налога не выводятся в заказе и инвойсах — оформление заказа и чеки продолжают работать, но без разбивки по ставкам.
Модель данных
| Таблица | Ключевые поля | Примечание |
|---|---|---|
commerce_tax_rates | id, code, rate_percent (nullable — null = «без НДС»), title, is_default | справочник ставок: vat0, vat10, vat20, no_vat из коробки |
commerce_tax_rate_assignments | id, tax_rate_id, taxable_type, taxable_id | привязка ставки к товару/категории (полиморфно), индекс по (taxable_type, taxable_id) |
commerce_tax_legal_mode | id, mode (osn|usn), effective_from | режим юрлица, история смены, mode — PHP Enum |
tax_rate_id — FK constrained()->index(). История смены legal_mode — append-only (новая запись с effective_from, не перезапись текущей). Ставки commerce_tax_rates — справочник ядра модуля, не финансовый журнал, но правки логируются в cms/audit (фискально значимые данные, см. «Безопасность»).
ПДн-паспорт. Модуль не хранит ПДн — справочник ставок и режим юрлица обезличены, привязки ведутся к товарам/категориям, не к пользователям. Хуки «выгрузить всё по субъекту»/«забыть по запросу» не применимы.
Входные и выходные данные
Входы:
| Источник | Поля | Чем валидируется |
|---|---|---|
Форма справочника ставок (POST/PUT /admin/tax-rates) | code, rate_percent (nullable), title, is_default | FormRequest whitelist, rate_percent — 0..100 либо null, code уникален |
Привязка ставки к товару/категории (POST /admin/tax-rates/{id}/assignments) | taxable_type (whitelist), taxable_id | FormRequest whitelist зарегистрированных taxable_type (product, category, commerce-catalog variant), проверка существования сущности |
Смена режима юрлица (POST /admin/tax-rates/legal-mode) | mode (osn|usn), effective_from | FormRequest whitelist, enum, effective_from >= сегодня |
Событие OrderPlaced (ядро/cms/commerce-model) | позиции заказа: product_id, price, quantity | тонкий слушатель — резолвит ставку по product_id, снапшотит в строку заказа, не пересчитывает уже снапшотнутое |
Всё, что не перечислено — отвергается (whitelist-принцип): произвольные поля в теле запроса, незарегистрированный taxable_type, ставка вне диапазона 0–100.
Выходы:
| Потребитель | Данные | Формат |
|---|---|---|
GET /api/v1/checkout/tax-summary | разбивка суммы заказа по ставкам НДС | конверт {data, meta}, список {rate_code, base_amount, tax_amount} |
cms/commerce-receipts (сервис-вызов по requires) | ставка + сумма НДС строки заказа | TaxLineResolver::resolveForOrderItem() — DTO, не массив |
Шина событий: TaxRateChanged/LegalModeChanged | см. «События и обмен» | канал 1, после коммита |
| Filament (справочник) | список ставок и привязок с фильтрами | таблица ресурса, экспорт CSV вручную |
cms:commerce-tax:reassign-defaults --json | отчёт пересчёта | stdout JSON (диагностика/CI) |
Настройки (группа commerce-tax)
| Ключ | Тип | Дефолт | affectsPageCache | Описание |
|---|---|---|---|---|
commerce-tax.default_rate_code | string | vat20 | нет | Ставка НДС по умолчанию для товара без привязки |
commerce-tax.legal_mode | string | osn | нет | Режим юрлица (ОСН/УСН), влияет на чеки |
commerce-tax.prices_include_vat | bool | true | да | Розничные цены хранятся с НДС включённым (false — B2B-режим, НДС начисляется сверху) |
commerce-tax.rounding_remainder_target | string | largest_line | нет | Куда относить копейку расхождения округления: позиция с наибольшей суммой (см. «Крайние случаи») |
commerce-tax.registered_taxable_types | array | ['product', 'category'] | нет | Whitelist полиморфных типов для привязки ставки (лимит расширения домена) |
API
| Метод | Путь | Доступ | Назначение |
|---|---|---|---|
| GET | /api/v1/checkout/tax-summary | public | Разбивка суммы заказа по ставкам НДС |
| GET | /api/v1/admin/tax-rates | admin (commerce-tax.view) | Справочник ставок |
| POST | /api/v1/admin/tax-rates | admin (commerce-tax.manage) | Создание/правка ставки и привязок |
| POST | /api/v1/admin/tax-rates/legal-mode | admin (commerce-tax.manage) | Смена режима юрлица (ОСН/УСН) |
POST /admin/tax-rates и смена режима юрлица несут Idempotency-Key (фискально значимый эффект — влияет на будущие чеки). Ошибка расхождения округления — код rounding_mismatch в конверте ошибок.
Компоненты
Filament: справочник ставок НДС, привязка к товару/категории, настройка режима юрлица. Команды: cms:commerce-tax:reassign-defaults --json (пересчёт ставки по умолчанию для товаров без явной привязки).
Фронтенд-бюджет: модуль не рендерит публичных блоков/виджетов — только API и серверный расчёт в checkout; собственных JS/CSS-ассетов нет.
Демо-контент: сидер TaxRatesDemoSeeder — 4 ставки из коробки (vat0, vat10, vat20, no_vat) и режим юрлица osn для playground/тестового каталога.
События и обмен
| Событие | Когда | Payload |
|---|---|---|
TaxRateChanged | изменена ставка НДС товара/категории | taxable_type, taxable_id, tax_rate_id |
LegalModeChanged | сменён режим юрлица (ОСН/УСН) | mode, effective_from |
Слушает: — (справочник, реагирует на собственные административные действия). Обеспечивает данные для FilterBus пересчёта заказа (ставка + сумма НДС строки), потребляется cms/commerce-receipts при формировании чека.
Таблица взаимодействий:
| Сущность/модуль | Канал | Направление | Что происходит |
|---|---|---|---|
cms/commerce-model (заказы) | requires (сервис-вызов при оформлении) | commerce-tax → commerce-model | Снапшот ставки+суммы НДС в строку заказа на момент OrderPlaced |
cms/commerce-receipts | requires (сервис-вызов, suggests со стороны receipts) | commerce-receipts → commerce-tax | TaxLineResolver::resolveForOrderItem() при формировании чека |
cms/commerce-b2b | requires (сервис-вызов) | commerce-b2b → commerce-tax | Расчёт «НДС сверху» для B2B-инвойса при prices_include_vat=false |
Ядро: EventBus | канал 1 | commerce-tax → все подписчики | Издание TaxRateChanged/LegalModeChanged |
Ядро: CacheTags | requires | commerce-tax → ядро | Объявление тегов commerce-tax:rates/commerce-tax:legal-mode |
| Filament / кабинет | внешний канал (не из 5) | admin → commerce-tax | Правка справочника и режима юрлица |
Фоновая работа
Пересчёт ставок по умолчанию (cms:commerce-tax:reassign-defaults) — разовая администраторская команда, идемпотентна. Регулярной фоновой синхронизации с внешними системами нет.
Эксплуатация (ранбук). Метрики: commerce_tax_rate_resolution_duration_ms, commerce_tax_reassign_defaults_affected_total, commerce_tax_rounding_remainder_kopecks (гистограмма — сколько копеек ушло в компенсацию на заказ). Алерты: доля заказов с rounding_mismatch растёт выше нуля (сигнал ошибки в правиле распределения); джоба reassign-defaults падает.
| Симптом | Проверить | Команда |
|---|---|---|
| Сумма НДС в чеке не сошлась с ОФД | правило распределения округления по последнему заказу | ручной пересчёт по формуле «Крайние случаи», сверка commerce_receipt_items.tax_rate_id |
| Товар без привязки получил не ту ставку | default_rate_code, наследование категория→товар | cms:commerce-tax:reassign-defaults --dry-run |
Чек после смены legal_mode не отразил УСН | effective_from относительно даты заказа | сверка commerce_tax_legal_mode истории |
Бэкап/рестор: в бэкап попадают commerce_tax_rates, commerce_tax_rate_assignments, commerce_tax_legal_mode (справочные и исторические данные). После рестора пересоздавать нечего — снапшоты ставок в строках заказа принадлежат cms/commerce-model, справочник сам по себе консистентен сразу после restore.
Производительность и кеш
- Ожидаемые объёмы: справочник ставок — единицы записей (4–10), привязок — по числу товаров/категорий с переопределением (обычно десятки, не весь каталог).
- Горячие пути: резолв ставки при добавлении товара в корзину и на checkout (высокочастотный, публичный); расчёт разбивки НДС на
tax-summary. - Бюджет запросов: резолв ставки товара — 0 запросов на горячем пути (справочник + привязки читаются из кеша группы настроек/тега, не SQL на каждый товар корзины);
tax-summary— 1 агрегирующий проход по позициям корзины из уже загруженных данных, без доп. запросов на позицию. - Критичные индексы:
(taxable_type, taxable_id)наcommerce_tax_rate_assignments;tax_rate_idFK-индекс;effective_fromнаcommerce_tax_legal_modeдля выборки действующего режима на дату. - Теги кеша:
commerce-tax:rates,commerce-tax:legal-mode. Инвалидация — событиямиTaxRateChanged/LegalModeChanged. Справочник ставок читается из кеша группы настроек на горячем пути расчёта корзины/checkout (0 запросов). - Массовые операции:
reassign-defaults— батчами поcommerce-tax-конфигу чанка, один flush тегаcommerce-tax:ratesпо завершении команды, не событие на товар.
Безопасность
Границы входа: FormRequest-whitelist на всех формах справочника (code, rate_percent, title, taxable_type из commerce-tax.registered_taxable_types, mode). Правка справочника ставок и режима юрлица — только под правом commerce-tax.manage, изменения логируются в cms/audit (фискально значимые данные). Расчёт НДС в API — публичный (/checkout/tax-summary), но не раскрывает внутренние идентификаторы привязок сверх необходимого (только rate_code, суммы).
Векторы: подмена taxable_type на незарегистрированный домен (whitelist на FormRequest) · попытка задать rate_percent вне 0–100 или отрицательный (валидация диапазона) · смена legal_mode задним числом (effective_from >= сегодня, история append-only — прошлые заказы не пересчитываются).
Разграничение прав: commerce-tax.view, commerce-tax.manage.
Матрица ролей:
| Permission | Админ | Менеджер | Редактор | Studio |
|---|---|---|---|---|
commerce-tax.view | ✅ | ✅ | ✅ | ✅ |
commerce-tax.manage (справочник, режим юрлица) | ✅ | — | — | ✅ |
Смена режима юрлица и ставок — только повышенные роли (фискальный эффект на все будущие чеки), менеджеру/редактору доступен только просмотр.
UX-требования
Админ: пустое состояние справочника — «Ставки НДС не настроены, товары считаются без НДС» со ссылкой на создание первой ставки; массовые действия — привязка ставки сразу к списку выбранных товаров в каталоге; человеческие ошибки — «Нельзя удалить ставку, использованную в оформленных заказах — снимите привязки или переведите товары на другую ставку» вместо 500/FK-ошибки; подтверждение необратимых операций — смена режима юрлица и изменение ставки по умолчанию для всего каталога запрашивают «да» с пояснением объёма затронутых товаров.
Посетитель: разбивка НДС на checkout пересчитывается без перезагрузки страницы при смене состава корзины (island/фрагмент), отклик — мгновенный (расчёт из кеша, без внешних вызовов); при meta.degraded: true (справочник временно недоступен) сумма заказа показывается без разбивки по ставкам, а не блокирует оформление.
Крайние случаи и типовые баги
- НДС «в цене» vs «сверху». При
prices_include_vat=true(розница) сумма НДС строки вычисляется какprice * rate / (100 + rate)(налог выделяется из цены с НДС включённым). Приprices_include_vat=false(B2B-документы) сумма НДС начисляется сверх цены:price * rate / 100, итоговая сумма к оплате —price + tax_amount. Оба режима покрыты контрактными тестами; переключение настройки не пересчитывает уже снапшотнутые позиции оформленных заказов (снапшот на момент оформления, см. «Модель данных»). - Расхождение округления НДС в чеке с ОФД (округление по копейке). При смешанных ставках в одной корзине (например, товар по 20% и товар по 10%) округление суммы НДС каждой позиции независимо может дать сумму, отличную на 1 копейку от округления итоговой суммы заказа целиком. Правило распределения: сумма НДС округляется до копейки по каждой позиции независимо (
round(tax_amount, 2)); затем считается разница между суммой округлённых сумм НДС по позициям и НДС, посчитанным от округлённого итога заказа; вся разница (обычно ±0.01) относится на позицию с наибольшей суммой НДС в корзине (commerce-tax.rounding_remainder_target = largest_line) — не размазывается пропорционально и не остаётся «зависшей» нигде. Если после применения правила расхождение всё ещё не нулевое (двойная ошибка округления при экстремальном числе позиций) — заказ не блокируется, но событие логируется с кодомrounding_mismatchвcms/auditдля ручной проверки перед пробитием чека. - Товар без явной привязки ставки → используется
default_rate_code; при сменеdefault_rate_codeуже созданные (но не оформленные) позиции корзины пересчитываются при следующем открытии корзины/checkout, оформленные заказы — нет (снапшот). - Ставка «0%» (экспорт) и «без НДС» (УСН/освобождение) — разные коды, не взаимозаменяемы.
rate_percent = 0— товар облагается НДС по ставке 0% (требует документального подтверждения для экспорта, отражается в чеке как ставка «НДС 0%»);rate_percent = null(«без НДС») — операция вообще не облагается НДС (УСН/освобождение по ст. 145) и в чеке отражается признаком «без НДС». Подстановка одного вместо другого — фискальная ошибка; справочник и Filament-форма визуально разделяют оба варианта отдельными строками, не единым «0/без». - Смена
legal_modeна УСН посреди действующих заказов →effective_fromопределяет границу: заказы, оформленные до даты вступления, используют режим, действовавший на момент оформления (историяcommerce_tax_legal_mode— append-only, резолв по дате заказа, не по «текущему» значению). - Переопределение ставки на конкретной ТП при базовой ставке на товаре/категории → приоритет: привязка на ТП > привязка на товаре > привязка на категории >
default_rate_code; резолвер идёт по этой цепочке и останавливается на первом найденном уровне, не суммирует и не усредняет. - Выключение модуля посреди оформления заказа (модуль выключен между добавлением товара в корзину и checkout) → все позиции считаются «без НДС» с этого момента, оформление не падает; ранее снапшотнутые (в других заказах) ставки не пересчитываются задним числом.
- Удаление ставки, использованной в привязках или в истории заказов → удаление ставки, на которую ссылаются
commerce_tax_rate_assignmentsили снапшоты в заказах, отклоняется на уровне сервиса (мягкая проверка использования передDELETE, не FK-исключение на уровне БД) — понятная ошибка админу вместо 500. - B2B-инвойс с «без НДС» продавцом, но покупатель ожидает НДС в документе → инвойс отражает фактический режим юрлица продавца на дату документа, не «ожидания» покупателя; расхождение — не баг модуля, а корректное отражение налогового статуса продавца.
Донорский код
Донор: — (новая разработка). Готовых легаси-таблиц ставок НДС/режима юрлога с прежних проектов студии для маппинга нет — модуль без донора, cms:commerce-tax:import-legacy не требуется на старте. Если в будущем появится проект-донор с собственным справочником налоговых ставок (например, при переносе клиента с внешней CMS с фискальными данными), команда cms:commerce-tax:import-legacy --source=<профиль> заводится по общей схеме §16 стандарта: маппинг ставок по проценту, идемпотентность по external_id, --dry-run с отчётом расхождений.
Тесты и приёмка
- [ ] Контрактный тест: строка заказа хранит снапшот ставки НДС, последующая смена ставки товара не меняет оформленные заказы
- [ ] Товар без явной привязки использует
default_rate_code, переопределение на ТП имеет приоритет над категорией - [ ] Смена
legal_modeна УСН отражается в новых чеках как «без НДС», не затрагивая прошлые (резолв поeffective_from, не по «текущему» значению) - [ ] Сумма НДС в итогах заказа корректна при смешанных ставках в одной корзине
- [ ] Контрактный тест: правило распределения округления (разница относится на позицию с наибольшей суммой НДС) даёт сумму НДС по позициям, точно равную округлению итога заказа
- [ ] Ставка
0%(экспорт) и «без НДС» (УСН) не взаимозаменяемы — разные коды в чеке - [ ] Оба режима
prices_include_vat=true/falseпокрыты тестами: НДС «в цене» и «сверху» - [ ] При выключении модуля заказ и чек оформляются без разбивки НДС, без ошибок
- [ ] Удаление используемой ставки (в привязках или снапшотах заказов) отклоняется с понятной ошибкой, не 500
- [ ] Права
commerce-tax.manageразграничивают просмотр справочника и его редактирование - [ ] Контрактный набор
cms-testingзелёный, пакет тестируется в testbench-изоляции - [ ] Feature-тест на каждый роут API
- [ ] Тестовая БД — только
commerce-tax_test;migrate:fresh/refresh/resetзапрещены