Skip to content

ТЗ — Налоги/НДС (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_ratesid, code, rate_percent (nullable — null = «без НДС»), title, is_defaultсправочник ставок: vat0, vat10, vat20, no_vat из коробки
commerce_tax_rate_assignmentsid, tax_rate_id, taxable_type, taxable_idпривязка ставки к товару/категории (полиморфно), индекс по (taxable_type, taxable_id)
commerce_tax_legal_modeid, 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_defaultFormRequest whitelist, rate_percent — 0..100 либо null, code уникален
Привязка ставки к товару/категории (POST /admin/tax-rates/{id}/assignments)taxable_type (whitelist), taxable_idFormRequest whitelist зарегистрированных taxable_type (product, category, commerce-catalog variant), проверка существования сущности
Смена режима юрлица (POST /admin/tax-rates/legal-mode)mode (osn|usn), effective_fromFormRequest 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_codestringvat20нетСтавка НДС по умолчанию для товара без привязки
commerce-tax.legal_modestringosnнетРежим юрлица (ОСН/УСН), влияет на чеки
commerce-tax.prices_include_vatbooltrueдаРозничные цены хранятся с НДС включённым (false — B2B-режим, НДС начисляется сверху)
commerce-tax.rounding_remainder_targetstringlargest_lineнетКуда относить копейку расхождения округления: позиция с наибольшей суммой (см. «Крайние случаи»)
commerce-tax.registered_taxable_typesarray['product', 'category']нетWhitelist полиморфных типов для привязки ставки (лимит расширения домена)

API

МетодПутьДоступНазначение
GET/api/v1/checkout/tax-summarypublicРазбивка суммы заказа по ставкам НДС
GET/api/v1/admin/tax-ratesadmin (commerce-tax.view)Справочник ставок
POST/api/v1/admin/tax-ratesadmin (commerce-tax.manage)Создание/правка ставки и привязок
POST/api/v1/admin/tax-rates/legal-modeadmin (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-receiptsrequires (сервис-вызов, suggests со стороны receipts)commerce-receipts → commerce-taxTaxLineResolver::resolveForOrderItem() при формировании чека
cms/commerce-b2brequires (сервис-вызов)commerce-b2b → commerce-taxРасчёт «НДС сверху» для B2B-инвойса при prices_include_vat=false
Ядро: EventBusканал 1commerce-tax → все подписчикиИздание TaxRateChanged/LegalModeChanged
Ядро: CacheTagsrequirescommerce-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_id FK-индекс; 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 запрещены

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