> ## Documentation Index
> Fetch the complete documentation index at: https://omniflow-vpn.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# Каталог и акции

> Как создаются тарифы, неизменяемые версии тарифов, цены, пробные периоды, промокоды и персональные предложения и что каждый из них отклоняет

Каталог намеренно разделён надвое. **Тариф** — это стабильный продукт, который видит клиент: код, тип, русский и английский текст, флаг видимости, порядок сортировки и необязательный лимит одновременных подписок на этот тариф. **Версия тарифа** — это конфигурация с ценой, по которой продукт продаётся: длительность, период оплаты, лимит трафика, лимит устройств, сквады, льготный период, политики жизненного цикла и по одной цене на валюту. Тариф — это то, что оператор переименовывает, переупорядочивает, скрывает и архивирует. Версия — это то, по чему рассчитывается цена заказа, и она никогда не редактируется.

Всё остальное на этой странице — пробные периоды, акции, промокоды, персональные предложения — держится на этом разделении. Скидка меняет то, сколько списывается по заказу; она никогда не меняет версию, на которую ссылается заказ, и никогда не меняет то, что клиент уже заплатил.

> [!NOTE]
>
> У нескольких частей этой области нет элементов управления в `/admin`, и
> добраться до них можно только через API. Каждый такой случай отмечен по месту, а
> [Что нельзя сделать в админ-панели](#что-нельзя-сделать-в-админ-панели) собирает
> их в одном месте.

## Тарифы

Строка тарифа не несёт ни цены, ни длительности. Она несёт `code`, `kind`, `visible`, `sort_order`, `archived_at` и `max_concurrent_per_customer`.

`code` должен соответствовать `^[a-z][a-z0-9_-]{1,63}$` и глобально уникален. `kind` — это одно из `trial`, `one_time`, `recurring`, `free` или `manual`, но только `trial` где-либо в коде меняет поведение. Остальные четыре сохраняются, проверяются и отображаются как подписи; ни одна ветка кода на них не завязана.

### Создание тарифа

В `/admin/catalog` нет элемента управления для создания. Единственный путь создания — API с операторским токеном:

```bash theme={null}
curl -X POST https://example.com/v1/admin/plans \
  -H "Authorization: Bearer $APP_OPERATOR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
        "code": "pro-monthly",
        "kind": "recurring",
        "visible": true,
        "sortOrder": 10,
        "localizations": {
          "ru": {"name": "Про", "description": "…"},
          "en": {"name": "Pro", "description": "…"}
        },
        "version": { "durationSeconds": 2592000, "prices": {"RUB": 49900} }
      }'
```

Вызов создаёт тариф и его первую версию в одной транзакции уровня Serializable. Он выполняет upsert по `code`, поэтому повторная отправка существующего кода возвращает существующий тариф, а не конфликт. Локаль, отличная от `ru` или `en`, отклоняется с 422 `plan_rejected`.

### Редактирование тарифа

Откройте `/admin/catalog` и останьтесь на вкладке **Тарифы**. В таблице перечислены все тарифы, включая архивные, с кодом, типом, текущей версией в виде `v{n}`, числом строк заказов, которые на него ссылаются, лимитом на клиента и переключателем **Видимость**.

Переключение **Видимости** вызывает `PATCH /v1/panel/catalog/plans/{planID}` (`catalog.write`) и передаёт `visible`, `sortOrder` и `maxConcurrentPerCustomer` вместе, в одной транзакции. Они передаются вместе, чтобы два оператора, редактирующие один тариф, не затирали поля друг друга. Панель показывает лимит, но не даёт поля ввода для него: при переключении текущее значение возвращается обратно без изменений. Изменить его можно только прямым вызовом эндпоинта.

`maxConcurrentPerCustomer` меньше 1 отклоняется с 422. Переключатель заблокирован для архивных тарифов и для операторов без `catalog.write`.

Число строк заказов стоит рядом с тарифом не просто так: архивировать тариф, на который ссылаются исторические заказы, безопасно *именно потому*, что версии неизменяемы, — но это всё равно решение, которое стоит принимать, видя, сколько заказов затронуто.

## Неизменяемые версии тарифов

Маршрута обновления версии тарифа нет, и это сделано намеренно. Комментарий у эндпоинта говорит об этом прямо: версия неизменяема, как только на неё ссылается заказ, поэтому редактор, способный её изменить, молча переоценил бы историю. Изменить цену означает опубликовать версию *n+1* и оставить версию *n* оценивать те заказы, которые уже её использовали.

Гарантия продублирована на уровне заказа. Строка заказа хранит `plan_version_id` **и** JSON-снимок периода, длительности, трафика, лимита устройств, сквадов и трёх политик жизненного цикла версии в том виде, в каком они были на момент покупки. Даже если бы строку версии изменили вручную, у заказа есть собственная копия.

### Публикация версии

**1. Откройте тариф**

Перейдите в `/admin/catalog`, найдите тариф и нажмите **Новая версия**.
Редактор открывается на месте, предзаполненный из текущей версии, потому что
изменение цены — обычно единственное отличие одной версии от следующей.

**2. Заполните версию**

Каждое поле ниже — часть договорённости, которую покупает клиент.

**3. Укажите причину и опубликуйте**

Форма требует причину, которая отправляется в `X-Operator-Reason`. Нажатие
**Опубликовать версию** вызывает
`POST /v1/panel/catalog/plans/{planID}/versions` (`catalog.write`) и
возвращает созданную версию. Экран сообщает о последствиях: существующие
заказы сохраняют версию, по которой были куплены, и публикация не меняет ни
одну цену, которую клиент уже оплатил.

| Поле | Имя в API | Примечания |
| - | - | - |
| Период оплаты | `billingPeriod` | `day`, `week`, `month`, `quarter`, `year`, `custom` |
| Длительность (дней) | `durationSeconds` | дни × 86 400; должна быть больше нуля |
| Льготный период (дней) | `gracePeriodSeconds` | дни × 86 400; ноль полностью отключает льготный период |
| Трафик (ГБ) | `trafficAllowanceBytes` | ГБ × 1 073 741 824; **пусто означает безлимит** |
| Лимит устройств | `deviceLimit` | пусто означает безлимит |
| ID сквадов | `squadIds` | UUID сквадов Remnawave через запятую, назначаются всегда |
| Выбор сквадов | `squadSelection` | см. [Наборы сквадов](#наборы-сквадов-на-версии) |
| Минимум / максимум выбора | `minSelectableSquads`, `maxSelectableSquads` | показываются только для выбора, доступного клиенту |
| Повышение тарифа | `upgradePolicy` | `forbid`, `replace`, `extend` |
| Понижение тарифа | `downgradePolicy` | `forbid`, `immediate`, `at_expiry` |
| Отмена | `cancellationPolicy` | `immediate`, `at_expiry` |
| Пробный период | `trialEligibility` | см. [Пробные периоды](#пробные-периоды-и-защита-от-злоупотреблений) |
| Доступно автосписание | `recurringCapable` | сохраняется и отображается, но никогда не читается как условие |
| Цены | `prices` | свободный текст вида `RUB=49900, USD=599` |

Null и ноль означают здесь разное, и код говорит об этом дважды: отсутствующий лимит трафика или лимит устройств — это *безлимит*, тогда как ноль означал бы тариф, который не разрешает ничего. Панель, которой пришлось бы угадывать, что означает ноль, рано или поздно объявила бы клиенту неверное, поэтому ноль вместо этого отклоняется.

Льготный период существует для того, чтобы текст об окончании подписки не был догадкой: он сохраняет доступ после `ends_at`, чтобы клиенту можно было сказать точно, сколько у него осталось времени на продление. Как он вычисляется, описано в разделе [Подписки](https://omniflow-vpn.mintlify.site/ru/commerce/subscriptions.md).

### Что публикация отклоняет

* Версия **вообще без цен** отклоняется. Версия, которую никто не может купить, — не версия.
* `durationSeconds` меньше или равный нулю и отрицательный `gracePeriodSeconds` отклоняются.
* Присутствующие `trafficAllowanceBytes` или `deviceLimit` должны быть больше нуля.
* Номера версий выделяются под рекомендательной блокировкой на тариф в пределах транзакции, поэтому два оператора, одновременно нажавшие **Опубликовать версию**, не смогут оба создать версию 4. В базе данных также есть `UNIQUE (plan_id, version)`.

### Два поля редактора, которые отвергает база данных

Панель проверяет `squadSelection` по значениям `automatic`, `customer_choice`, `fixed`, а `trialEligibility` — по `new_customer`, `never`, `any_customer`, и веб-редактор предлагает ровно эти значения. CHECK-ограничения `plan_versions` допускают `automatic`, `optional`, `required` и `new_customer`, `never_trialled`, `any`.

> [!WARNING]
>
> Публикация версии с чем-либо, кроме **Выбор сквадов = назначаются
> автоматически** и **Пробный период = только новым клиентам**, падает на уровне
> базы данных и выглядит как HTTP 500 `operation_failed`. Значения, которые
> домен действительно реализует, — `optional`, `required`, `never_trialled`,
> `any` — отклоняются валидатором панели с 422 ещё до базы.
>
> Пока эти два списка не согласованы, версию, которая предлагает выбор сквадов
> или нестандартное правило пробного периода, приходится публиковать напрямую
> через `POST /v1/panel/catalog/plans/{planID}/versions` со значениями базы
> данных.

База данных также допускает периоды оплаты `none` и `half_year` и политику отмены `forbid`; валидатор панели — нет, поэтому эти три значения из `/admin` недостижимы.

### Дополнения, предлагаемые версией

`plan_version_addons` определяет, какие дополнения предлагает версия тарифа, и заполняется только массивом `addonIds` в `POST /v1/panel/catalog/plans/{planID}/versions`. Публикация **заменяет** набор, а не дополняет его, поэтому он в точности такой, какой выбрал оператор, а не объединение всех правок.

Редактор версий в панели не отправляет `addonIds`. Поскольку публикация сначала очищает набор, каждая версия тарифа, опубликованная через `/admin/catalog`, не предлагает никаких дополнений. Заполнить набор предложений можно только прямым вызовом эндпоинта. Страница [Дополнения и конфигуратор подписки](https://omniflow-vpn.mintlify.site/ru/commerce/addons-configurator.md) объясняет, от чего защищает эта связь.

## Цены

`plan_prices` — это одна строка на `(plan_version_id, currency)` с целочисленным `amount_minor`. Нигде в Omniflow нет десятичных дробей: деньги всегда в минорных единицах — копейках, центах, Stars, — а валюта проверяется по `^[A-Z]{3}$`.

Поле «Цены» в редакторе версий — свободный текст: `RUB=49900, USD=599`. Десятичные значения отвергаются сразу, потому что «4.99» неоднозначно относительно того, какая минорная единица имеется в виду.

Версия может быть оценена в любом числе валют, и обычно ей нужна не одна, потому что выбранный платёжный провайдер задаёт валюту заказа. Именно поэтому при оформлении заказа цена показывается рядом с каждым способом оплаты: один и тот же тариф через другого провайдера может стоить другую сумму, и скрывать это до страницы самого провайдера было бы неприятным сюрпризом.

> [!WARNING]
>
> Тариф **без цены в расчётной валюте полностью исключается из клиентского
> каталога**, а не показывается как недоступный. Купить его здесь нельзя вовсе, а
> запись, с которой нельзя ничего сделать, — это лишь вопрос, на который клиент
> не может ответить.
>
> Если тариф отсутствует в `/account/store`, а сам тариф видимый и не в архиве,
> первым делом проверьте строку цены в `APP_DEFAULT_CURRENCY`.

Во время оформления заказа выборка соединяется по запрошенной валюте. Отсутствие строки означает `ErrPlanUnavailable` и HTTP 404 `plan_unavailable`.

`APP_DEFAULT_CURRENCY` (по умолчанию `RUB`) — расчётная валюта, в которой обе клиентские поверхности показывают каталог; для установки, работающей только на Telegram Stars, используйте `XTR`. Она проверяется при запуске. См. [Конфигурацию](https://omniflow-vpn.mintlify.site/ru/getting-started/configuration.md).

## Локализация

`plan_localizations` хранит `(plan_id, locale, name, description)`, где `locale` ограничен значениями `ru` и `en`. `name` — от 1 до 120 символов, `description` — не более 2000.

Клиентский каталог **соединяет** локализации внутренним join, а не левым, поэтому тариф без текста на нужной локали пропадает для клиентов, читающих на этом языке, а не отображается пустым. То же правило явно применяется при сохранении дополнения: отсутствующий перевод отклоняется, а не сохраняется пустым, потому что дополнение без названия на одном из языков — это пустая строка на экране клиента.

Тексты записываются через `PUT /v1/panel/catalog/plans/{planID}/localizations/{locale}` (`catalog.write`) с телом `name` и `description`, ответ — 204. Локаль, отличная от `ru` или `en`, отвечает 422. Редактора локализаций в панели нет — этот эндпоинт и карта `localizations` в `POST /v1/admin/plans` являются единственными двумя путями.

В клиентской веб-панели явный параметр `?locale=` имеет приоритет над сохранённым предпочтением клиента, поэтому переключение языка срабатывает до сохранения профиля.

## Публикация, скрытие, архивирование

Три элемента управления с тремя разными значениями:

| Элемент | Колонка | Действие |
| - | - | - |
| Видимость | `plans.visible` | Видят ли клиенты тариф в каталоге |
| Порядок | `plans.sort_order` | Где он отображается |
| В архиве | `plans.archived_at` | Мягкий вывод из продажи; также принудительно ставит `visible = false` |

Архивирование скрывает тариф и прекращает новые покупки из каталога. Оно намеренно не трогает существующие права на услугу: клиент, купивший тариф, сохраняет то, что купил, а его заказ сохраняет неизменяемый снимок версии, по которой он был оценён.

Архивирование идемпотентно и обратимо — `POST /v1/panel/catalog/plans/{planID}/archive` (`catalog.write`) с `archived` true или false, ответ 204. Восстановление очищает `archived_at`, но **не** возвращает `visible` в true, поэтому восстановленный тариф остаётся скрытым, пока оператор не решит иначе. Панель рисует метку «В архиве» и прячет **Новую версию** для архивных тарифов, но не даёт элемента управления для установки или снятия флага; единственный путь — эндпоинт.

События аудита: `panel.plan.updated`, `panel.plan.archived`, `panel.plan.restored`, все в категории `configuration`.

Оба клиентских чтения каталога фильтруют по `visible AND archived_at IS NULL`. Чтение одного тарифа, которое используется для открытия оформления заказа, фильтрует только по `archived_at`, поэтому скрытый, но не архивированный тариф остаётся доступным для покупки по прямой ссылке на версию тарифа.

## Вывод версии из обращения

`POST /v1/panel/catalog/plan-versions/{planVersionID}/retire` (`catalog.write`, 204, аудит `panel.plan_version.retired`) проставляет `retired_at`. Выведенная из обращения версия перестаёт быть той, которую предлагает каталог, без архивирования тарифа. Повторный вывод уже выведенной версии не находит ни одной строки и отвечает 409 `state_conflict`.

Каталог берёт версию с наибольшим номером, у которой `retired_at IS NULL`, поэтому вывод самой новой версии откатывает каталог к предыдущей. Публикация новой версии не выводит старую; новейшая просто побеждает при просмотре, а более старые версии остаются доступными для заказа по ID.

В панели элемента управления для вывода из обращения нет.

> [!WARNING]
>
> **Вывод из обращения не перепроверяется при создании заказа.** Запрос, который
> рассчитывает цену заказа, фильтрует по ID версии и валюте и больше ни по чему,
> а собственное чтение тарифа при оформлении заказа фильтрует только по
> `archived_at`. Оформление заказа, открытое до вывода версии, сохранённая
> корзина, ссылка на версию тарифа из закладок или устаревший коллбэк бота
> по-прежнему могут создать заказ по выведенной версии.
>
> Действительно недоступной для заказа версию делает удаление строки её цены в
> расчётной валюте.

## Пробные периоды и защита от злоупотреблений

Пробный период — это тариф с `kind = 'trial'` плюс правило `trial_eligibility` на уровне версии. Его получение записывает строку в `trial_claims`, первичный ключ которой — **клиент**: один пробный период на клиента на установку, навсегда, и никакого второго пробного периода для второй подписки.

Бесплатный тариф, который можно брать сколько угодно раз, — это бесплатный VPN, поэтому защита выстроена слоями, и каждый слой называет своё злоупотребление:

| Причина | Что предотвращает |
| - | - |
| `not_a_trial_plan` | Проверку правила пробного периода на платном тарифе |
| `trial_already_used` | Второй пробный период у того же клиента |
| `subscription_active` | Наложение пробного периода поверх действующей подписки |
| `identity_already_trialled` | Новый аккаунт, переиспользующий личность, которая уже израсходовала пробный период |
| `account_too_new` | Аккаунт, созданный только что исключительно ради пробного периода |
| `existing_customer` | Только при правиле `new_customer`: платящий клиент берёт бесплатный тариф |
| `unsupported_trial_rule` | Сохранённое значение правила, которого эта сборка не знает |

Правила, как они хранятся: `new_customer` (по умолчанию), `never_trialled` и `any`. `APP_TRIAL_MINIMUM_ACCOUNT_AGE_HOURS` (по умолчанию `0`) задаёт минимальный возраст аккаунта, и API, бот и воркер читают одну и ту же переменную — пробный период, в котором чат отказывает, а браузер выдаёт, был бы защитой с дырой.

**Что видит клиент.** Недоступный пробный тариф всё равно показывается в списке, а причина отказа отображается под ним на его языке — и в `/account/store`, и в списке тарифов бота. Скрытие тарифа оставило бы клиента гадать, куда делся бесплатный месяц. `GET /v1/account/plans` отдаёт `eligible` и `ineligibleReason` по каждому тарифу именно для этого.

Право проверяется дважды. Проверка в каталоге — вежливость; гарантией является отказ при подтверждении. Подтверждение недоступного пробного периода отвечает 422 с `type`, состоящим из `trial_` и причины.

Сама заявка записывается **внутри транзакции заказа** с `ON CONFLICT (user_id) DO NOTHING`. Ноль затронутых строк откатывает весь заказ и отвечает 409 `trial_already_used`, поэтому двойное нажатие или конкурентный запрос никогда не смогут создать второй пробный период.

## Лимит одновременных подписок на тариф

`plans.max_concurrent_per_customer` ограничивает, сколько активных подписок *этого тарифа* может держать один клиент. Он сужает общий потолок установки и никогда не может его расширить: оба проверяются в момент покупки, поэтому тариф, настроенный выше предела установки, ограничивается им, а не переопределяет его.

Проверки выполняются в фиксированном порядке после рекомендательной блокировки на клиента в пределах транзакции, поэтому две конкурентные покупки не могут обе увидеть устаревший итог:

1. `multi_subscription_disabled` — многоподписочность выключена, и у клиента уже есть одна подписка.
2. `subscription_limit_reached` — многоподписочность включена, и потолок установки исчерпан.
3. `plan_limit_reached` — исчерпан собственный лимит тарифа.

Половина, относящаяся к установке, — это `APP_MULTI_SUBSCRIPTION_ENABLED` и `APP_MAX_SUBSCRIPTIONS_PER_CUSTOMER`; они описаны в разделе [Одновременные подписки](https://omniflow-vpn.mintlify.site/ru/commerce/subscriptions.md).

## Наборы сквадов на версии

Версия несёт две коллекции: `plan_versions.remnawave_squad_ids`, которые назначаются всегда, и `plan_version_squads` — выбираемый набор с русской и английской подписью для каждой строки. `squad_selection` решает, выбирает ли клиент вообще.

| Сохранённое значение | Поведение | Отказы |
| - | - | - |
| пусто или `automatic` | Только сквады тарифа; любой присланный выбор отклоняется | `squad_selection_not_allowed` |
| `optional` | Выбор разрешён, ограничен минимумом и максимумом | `squad_not_offered`, `squad_selection_too_few`, `squad_selection_too_many` |
| `required` | Минимум принудительно не меньше 1 | `squad_selection_required`, далее как выше |
| что-либо ещё | Отклоняется сразу | `squad_selection_not_allowed` |

Итоговый набор дедуплицируется и сортируется, поэтому два клиента, выбравшие одни и те же сквады в разном порядке, дают побайтово идентичное состояние права на услугу. Без этого сверка расхождений сообщала бы о несовпадении, которого нет. Выбор проверяется на сервере по тарифу, потому что идентификаторы приходят в данных коллбэка и телах запросов, которые контролирует клиент.

Клиентская половина этого — группа полей при оформлении заказа, конфигуратор в боте и то, как выглядит каждый отказ, — описана на странице [Дополнения и конфигуратор подписки](https://omniflow-vpn.mintlify.site/ru/commerce/addons-configurator.md). Учтите оговорку про панель выше: выбор, доступный клиенту, сейчас нельзя настроить из `/admin`.

## Акции

Строка `promotions` — это сама кампания, а не погашаемый код. Она несёт собственный код в нижнем регистре, тип и величину награды, необязательную валюту, необязательное окно действия, необязательный глобальный лимит погашений, лимит на клиента, JSON-правило условий участия, флаг активности, `stackable`, `precedence` и `applies_to`. Группировка типов наград в одной записи — это то, что позволяет написать условия участия, лимиты погашений и журнал аудита один раз, а не по разу на каждый тип.

### Типы наград

`promotions_kind_known` допускает `percent`, `fixed`, `wallet_credit`, `days` и `trial`.

| Тип | Что означает величина | Применяется при погашении |
| - | - | - |
| `percent` | Базисные пункты — `1000` это 10% | Да |
| `fixed` | Минорные единицы в собственной валюте акции | Да |
| `wallet_credit` | Минорные единицы | **Нет** |
| `days` | Дни, не более 3650 | **Нет** |
| `trial` | Дни, не более 3650 | **Нет** |

> [!WARNING]
>
> Применяются только `percent` и `fixed`. Промокод, принадлежащий акции типа
> `wallet_credit`, `days` или `trial`, отклоняется при погашении с
> `promo_ineligible`, и никакая проводка по акции не пишется вообще.
>
> Страница тарифа усугубляет это: она перечисляет каждую активную акцию,
> привязанную к тарифам, независимо от типа и отображает всё, что не `percent`,
> как фиксированную денежную скидку. Кампания `wallet_credit` таким образом
> рекламируется клиентам как скидка деньгами, а её собственный код затем
> отказывает. Пока типы наград не реализованы, создавайте только акции `percent`
> и `fixed`.

Арифметика процента — `промежуточная сумма × величина ÷ 10000` с целочисленным отбрасыванием, поэтому округление идёт в пользу клиента. Фиксированная скидка — это `min(величина, промежуточная сумма)`, поэтому она никогда не превысит цену; слой заказов отклоняет скидку выше промежуточной суммы, и база данных также обеспечивает `discount_minor <= subtotal_minor`.

### Условия участия

JSON-объект `eligibility` вычисляется в SQL. Понимаются три ключа, а отсутствующий ключ не накладывает ограничений:

| Ключ | Значение |
| - | - |
| `locales` | Массив; сохранённая локаль клиента должна входить в него |
| `newCustomerOnly` | Булево; у клиента должно быть ноль заказов подписки (`purchase`, `extension`, `renewal`, `upgrade`, `downgrade`) в статусах `paid`, `fulfilled`, `partially_refunded` или `refunded`. Пополнение кошелька, покупка в магазине, подарок или код доступа не делают человека существующим клиентом |
| `minimumCompletedOrders` | Целое; у клиента должно быть не меньше такого числа таких заказов подписки |

Один и тот же запрос рекламирует кампанию на странице тарифа и принимает код при погашении, поэтому кампания, объявленная доступной, — это та, которую заказ примет.

### Создание и редактирование

Создание — только через API с операторским токеном:

```bash theme={null}
curl -X POST https://example.com/v1/admin/promotions \
  -H "Authorization: Bearer $APP_OPERATOR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
        "code": "spring",
        "kind": "percent",
        "value": 1000,
        "perCustomerLimit": 1,
        "active": true,
        "planIds": [],
        "codes": ["SPRING25"]
      }'
```

Одна транзакция уровня Serializable создаёт акцию, её строки области действия по тарифам и её промокоды. `code` обрабатывается через upsert, поэтому повторная отправка существующего кода привязывается к существующей акции.

`/admin/catalog` → **Промо** — это таблица, преимущественно предназначенная для чтения: код, награда в её собственных единицах, метка `Складывается`/`Отдельно` с номером приоритета, число погашений относительно лимита, суммарно выданная скидка и переключатель **Активно**. Его переключение вызывает `PATCH /v1/panel/catalog/promotions/{promotionID}` (`catalog.write`, 204, аудит `panel.promotion.updated`, категория `marketing`), повторно отправляя все редактируемые поля.

`Kind` намеренно отсутствует в теле обновления. Превращение живой акции из процентной скидки в зачисление на кошелёк молча изменило бы то, сколько стоит каждый непогашенный код; другая награда — это другая акция.

Сервис отклоняет `value` меньше или равное нулю, `perCustomerLimit` меньше 1 и окончание, которое не позже начала, — всё с 422. База данных добавляет: `value > 0`; `percent` подразумевает `value <= 10000`; `fixed` и `wallet_credit` подразумевают валюту; `days` и `trial` подразумевают `value <= 3650`, потому что десять лет подаренных дней далеко за пределами любой законной акции и именно так выглядит опечатка в значении.

Область действия акции по тарифам задаётся один раз. Она устанавливается при создании, и нет ни эндпоинта, ни экрана, ни запроса, который читал бы её обратно или менял впоследствии.

## Промокоды

`promo_codes` — это погашаемые значения на предъявителя, принадлежащие акции. Несколько кодов могут указывать на одну акцию, каждый со своим необязательным лимитом погашений и флагом активности, и именно это позволяет оператору раздать разным аудиториям разные коды, сохранив один набор правил.

Страница тарифа сообщает о существовании акции, но никогда не показывает её коды. Код — это значение на предъявителя, вручаемое конкретной аудитории; публикация всех кодов на странице тарифа вручила бы их всем сразу.

### Нормализация

`NormalizePromoCode` обрезает пробелы, переводит в верхний регистр и требует `^[A-Z0-9_-]{3,64}$`. Всё остальное — `promo_invalid` ещё до любого чтения из базы данных. `promo_codes.normalized_code` глобально уникален, и создание уже существующего кода привязывается к той акции, которой он уже принадлежит.

### Применение промокода

*Клиент, веб.* На `/account/checkout` введите код в поле **Промокод** и нажмите **Применить** — это вызывает `POST /v1/account/checkout/promo`. Принятый код отображается плашкой с кнопкой **Убрать** (`DELETE /v1/account/checkout/promo`). Отказ возвращается как **HTTP 200 с причиной**, а не как ошибка, и рисуется предупреждением под полем, поэтому оформление заказа переживает его, и клиент может попробовать другой код, не потеряв уже настроенное.

*Клиент, Telegram.* На экране оформления заказа нажмите **Промокод** и отправьте код одним сообщением. Бот отвечает подтверждением или причиной отказа и перерисовывает экран оформления заказа.

*Оператор.* Экрана промокодов нет. `GET /v1/panel/catalog/promotions/{promotionID}/codes` (`catalog.read`) возвращает каждый код со счётчиком погашений, а `POST /v1/panel/catalog/promo-codes/{promoCodeID}/status` (`catalog.write`) с полем `active` включает или выключает один код. Отключение останавливает новые погашения и не трогает ни одно уже завершённое. Код никогда не удаляется: погашения, которые на него ссылаются, — это финансовые записи.

### Все отказы, которые может увидеть клиент

| Причина | Когда возникает |
| - | - |
| `promo_invalid` | Строка не проходит нормализацию — недопустимые символы, короче 3, длиннее 64 |
| `promo_unknown` | Ни одна строка кода не совпала, либо код неактивен, либо неактивна его акция |
| `promo_ineligible` | Акция ограничена товарами; либо ограничена тарифами, и этот тариф не из их числа; либо клиент не проходит по условиям участия; либо код вне окна действия; либо награда `fixed` в другой валюте, чем заказ; либо тип награды не реализован ценовым слоем |
| `promo_exhausted` | Достигнут глобальный лимит акции, собственный лимит кода или лимит клиента |
| `promo_below_cost` | **Только заказы магазина**: скидка увела бы цену ниже себестоимости у провайдера, либо себестоимость неизвестна |

`promo_below_cost` намеренно выделен в отдельную причину: недоступный код — это неправильный код, а этот — правильный код на неправильном товаре.

Обе клиентские поверхности переводят эти значения сами, а не показывают текст сервера, и обе откатываются к честной фразе «код отклонён» для причины, которую прислал более новый сервер и о которой сборка никогда не слышала.

Код, переставший быть действительным посреди оформления заказа, не ломает экран. Стоимость пересчитывается без кода, и отказ сообщается рядом с ней.

### Атомарное погашение

Погашение — не отдельный шаг. Оно происходит внутри собственной транзакции заказа:

1. Создание заказа выполняется на уровне `Serializable`.
2. Код выбирается вместе с его акцией через `FOR UPDATE` с фильтром на активность обоих. Два клиента, погашающие последнее использование лимитированного кода, сериализуются, а не выигрывают оба.
3. Один запрос возвращает три счётчика: общий по акции, по этому клиенту и по этому коду.
4. Проверяется область действия по тарифам, затем условия участия клиента.
5. Сверяются лимиты и рассчитывается скидка.
6. Строка погашения пишется в той же транзакции, что и заказ.

`promo_redemptions.order_id` объявлен `UNIQUE`, поэтому **на один заказ погашается не более одной акции**.

Предварительный расчёт цены выполняет те же чтения на уровне `ReadCommitted` и затем откатывается, поэтому предпросмотр никогда не может израсходовать погашение или зарезервировать средства кошелька. Именно это делает цену, которую подтверждает клиент, той ценой, по которой создаётся заказ.

**Погашение, чей заказ закрылся неоплаченным, не считается.** Строка погашения пишется при создании заказа, до того как двинутся деньги, и никогда не удаляется — это финансовая запись. Поэтому то, что считают счётчики на шаге 3, уточняется состоянием заказа: погашение за заказом, который был отменён или истёк, не входит ни в общий счётчик акции, ни в счётчик клиента, ни в счётчик кода, так что персональный оффер на одно использование переживает «нажал Оплатить — передумал», а лимитированный публичный код не исчерпывается чекаутами, которые так и не оплатили. Погашение за живым заказом в `pending` всё ещё считается — именно это не даёт двум параллельным чекаутам обоим пройти лимит в одно использование. То же правило действует для единственного триала клиента: заявка на триал, чей заказ закрылся неоплаченным, перехватывается следующим триальным заказом, а не отказывает ему.

**Закрытый заказ, оплаченный поздно, заново проверяется по этим лимитам.** Закрытый заказ всё ещё можно оплатить — см. [машину состояний](https://omniflow-vpn.mintlify.site/ru/commerce/orders-payments.md#автомат-состояний), — а раз он перестал считаться в момент закрытия, его акцию или триал с тех пор мог забрать другой заказ. Проведение пересчитывает акцию по тому же правилу, что и создание заказа, под теми же блокировками строк, и смотрит, кому принадлежит заявка на триал. Если проведение вывело бы акцию за лимит или заявка на триал теперь принадлежит заказу, который не закрыт, платёж классифицируется как `limit_exceeded`, не проводится, и оператор уведомляется. Иначе он проводится, а триальный заказ забирает свою заявку обратно.

> [!NOTE]
>
> Счётчики погашений, показываемые в `/admin/catalog` → **Promotions** и возвращаемые списком промокодов, считают каждую строку погашения, включая закрытые заказы. Они описывают, сколько раз код вводили, а не сколько использований у него осталось.

### Ограничение частоты запросов

| Поверхность | Лимит | При превышении |
| - | - | - |
| Telegram-бот | 5 попыток на аккаунт Telegram в минуту | Бот отвечает «слишком много попыток ввода промокода» |
| `POST /v1/admin/orders` | 10 на клиента в минуту | Отказ лимитера отвечает 503 `rate_limit_unavailable`, а не пропускает запрос |
| `POST /v1/account/checkout/promo` | **Нет** | — |

Ограничение частоты в боте работает по принципу «отказать при сбое» для всех областей, двигающих деньги, и «пропустить» — только для навигации.

> [!WARNING]
>
> У промокод-эндпоинта клиентской веб-панели нет ограничения частоты. Ничто на
> его пути не обращается к лимитеру, поэтому вошедший клиент может перебирать
> коды так быстро, как отвечает API. Подбор кода всё ещё ограничен пространством
> имён из 3–64 символов и собственными лимитами акции, но бюджета попыток на
> клиента на этом маршруте нет, в отличие от бота.

## Область действия: тарифы или товары

`promotions.applies_to` называет каталог, на который акция даёт скидку, — `plans` или `goods`, никогда оба. Внутри каталога область задаётся через `promotion_plans` или `promotion_goods`, а пустая область означает все тарифы или все видимые товары.

Акция без указанной области уже означала «все тарифы». Если бы товары просто присоединились к этому множеству, каждая написанная оператором акция молча начала бы давать скидку на заказы магазина в момент обновления. Это неверное умолчание вдвойне: никто на него не соглашался, и две цены — числа разной природы. Существующие строки получают значение `plans` по умолчанию, поэтому при обновлении ничего не расширилось.

Условие `applies_to` несёт нагрузку, а не подстраховывает: у акции на товары без указанной области нет строк области по тарифам, поэтому без этого условия она прошла бы по ветке «все» и дала бы скидку на каждый тариф. Доменный слой повторяет проверку внутри самой функции расчёта скидки, чтобы два каталога не разошлись. Триггер базы данных отклоняет строку области по товарам у акции, чья `applies_to` не равна `goods`, потому что акция на тарифы, накапливающая строки области по товарам, выглядит настроенной, а ведёт себя как отсутствующая.

> [!NOTE]
>
> У `applies_to` нет пути записи ни в одном API. Ни эндпоинт создания, ни
> обновление из панели её не задают, поэтому каждая акция, созданная через
> Omniflow, — это акция на тарифы. Акция на товары сегодня требует записи колонки
> и строк `promotion_goods` через SQL.

### Нижняя граница цены товаров

Акция, применённая к заказу цифровых товаров, не может опустить списываемую цену ниже того, что берёт за то же самое вышестоящий провайдер. Цена тарифа — число, которое выбрал оператор, и скидка на неё стоит ему той маржи, которую он сам и назначил: нижняя граница там равна нулю, и оператор вправе отдать тариф даром. Цена в магазине — это котировка провайдера плюс наценка, и провайдер всё равно возьмёт свою себестоимость, сколько бы ни заплатил клиент, поэтому скидка глубже наценки означает, что установка доплачивает за продажу.

Это законный выбор и незаконная случайность, поэтому такой случай отклоняется с `promo_below_cost` и относится к настройке наценки. Он отклоняется, а не тихо ограничивается порогом, потому что процент, который молча становится другим процентом, — это то самое несовпадение, которое порождает обращение в поддержку.

Отсюда следуют два вывода: товар, продаваемый по фиксированной цене, заданной оператором, не имеет за собой котировки провайдера и может быть уценён до нуля, а товар, себестоимость которого шлюз не раскрывает, не может быть уценён вовсе. Нижняя граница реализована и как ограничение базы данных, и как проверка в приложении, поэтому скрипт или обратная заливка не смогут записать то, что отклоняет оформление заказа. Страница [Подарки и цифровые товары](https://omniflow-vpn.mintlify.site/ru/commerce/gifts-and-goods.md) полностью описывает сторону магазина.

## Складывание и приоритет

`promotions.stackable` по умолчанию false, а `promotions.precedence` — 0. По задумке выключенное по умолчанию складывание означает, что две акции применяются вместе только тогда, когда обе это разрешают, а приоритет задаёт порядок вычисления, чтобы результат применения нескольких акций был детерминированным, а не зависел от порядка, в котором клиент их вводил.

> [!NOTE]
>
> Ни одно из этих полей не читается ни одним ценовым путём. Они сохраняются,
> редактируются, отправляются при каждом обновлении акции и рисуются меткой в
> панели, но не доходят ни до какого кода в коммерческом домене.
>
> Более того, один заказ по построению может нести не больше одной акции: вход
> заказа принимает один промокод, оформление заказа в боте и сохранённая корзина
> каждый хранят одну колонку кода, а `promo_redemptions.order_id` уникален.
> Складывание невозможно, и приоритету нечего упорядочивать.

## Персональные предложения

Персональное предложение — это акция, направленная ровно одному названному клиенту, со своими русским и английским заголовком и условиями и со своим окном действия. Это акция с аудиторией из одного человека плюс собственное оформление, поэтому путь погашения, лимиты и эффекты в книге проводок — те же самые, что уже проверены. Скидка не определяется на предложении: оно указывает на существующую акцию, потому что второй, отдельный движок скидок означал бы второй набор правил, который надо согласовывать.

### Создание предложения

**1. Откройте реестр**

Перейдите в `/admin/offers`. В реестре перечислены предложения: английский
заголовок над русским, ссылка на клиента, окно от начала до истечения, метка
статуса и — когда предложение использовано — ссылка на заказ. Фильтры
покрывают все, активные, использованные, отклонённые, истёкшие и отозванные.

**2. Составьте предложение**

Нажмите **Новое предложение**. Заполните Клиента (UUID, вводится вручную),
Акцию (список, заполненный из списка акций), Заголовок (EN), Заголовок (RU),
Условия (EN), Условия (RU), «Действует с», «Действует до» и причину. Обе
даты выбираются: календарь для дня, часы и минуты двумя списками, — и
«Действует до» не предложит день раньше того, который назван в «Действует с».

**3. Проверьте предпросмотр**

Предпросмотр в две колонки рисует сообщение так, как его отправит бот на
каждом языке, с обратным отсчётом дней и двумя кнопками; отсутствующий
заголовок на одном из языков подсвечивается красным. Это макет, и он сам об
этом сообщает: бот — отдельный процесс, и панель не может попросить его
нарисовать сообщение.

**4. Создайте**

Отправка вызывает `POST /v1/panel/offers` (`marketing.write`) и отвечает 201
с предложением. `startsAt` по умолчанию равен текущему моменту. Аудит
`panel.offer.created`, категория `marketing`.

Обе локали обязательны. Предложение с текстом только на одном языке — это предложение, которое покажется половине клиентской базы пустой карточкой, а боту нечего разумно подставить взамен: отсутствующий заголовок или условия отвечают 422. То же самое отвечает срок истечения, который не позже начала.

В каждый момент времени существует только одно живое предложение на пару «клиент — акция». Частичный уникальный индекс плюс вставка с conflict-do-nothing делают повтор идемпотентным, а сервис отображает пустой результат в 409, поэтому конфликт читается как «уже предложено», а не как сбой.

Предложения создаются по одному клиенту за раз. Ни задания массового таргетинга, ни импорта нет; поле UUID в форме — единственный способ ввода.

### Отзыв

Каждая активная строка несёт встроенное поле причины и кнопку **Отозвать**, вызывающую `POST /v1/panel/offers/{offerID}/revoke` (`marketing.write`, 204, аудит `panel.offer.revoked`). Отзыв чего-либо, что не находится в статусе `active`, не находит ни одной строки и отвечает 409.

У использованного предложения элемента отзыва нет. Клиент уже купил по этой цене, и запись о том, что ему было предложено, — это то, что делает заказ объяснимым впоследствии.

### Что видит клиент

Персональные предложения существуют **только в Telegram**. В клиентской веб-панели нет ни маршрута, ни экрана, ни эндпоинта для предложений, поэтому клиент, который заходит только в браузере, не увидит и не возьмёт предложение.

В главном меню бота появляется пункт «Ваше предложение», когда ждёт хотя бы одно предложение. Открыв его, клиент видит список с заголовком каждого предложения и обратным отсчётом дней с кнопками **Посмотреть предложение** и **Не интересно**. Открытие одного показывает заголовок, условия, обратный отсчёт, промокод для использования и пометку об одноразовости.

Открытие предложения не помечает его использованным. Клиент, который открыл каталог и передумал, ничего не израсходовал, а сжигать одноразовое предложение по одному нажатию значило бы забрать у него что-то ни за что.

Отклонение окончательно, а предикат обновления включает клиента, поэтому идентификатор предложения из данных коллбэка — которые контролирует клиент — не может отклонить чужое предложение. Список в боте применяет окно действия в собственном запросе, а не доверяет сборщику, поэтому предложение никогда не переживает свой срок на экране, даже если сборщик отстаёт. Само истечение обрабатывается сборщиком по умолчанию каждые пять минут.

Предложение показывает первый активный промокод акции в порядке создания. Акция без кодов даёт предложение без кода, и клиенту нечего вводить.

> [!WARNING]
>
> Ничто в пути заказа не знает о персональных предложениях. Статус переходит
> только из `active` в `dismissed`, `revoked` или `expired`; `order_id` всегда
> null, из-за чего фильтр «использованные» в реестре и его ссылки на заказы
> мертвы. «Одноразовость» обеспечивается лишь косвенно — через
> `per_customer_limit` лежащей в основе акции.

## Что нельзя сделать в админ-панели

`/admin/catalog` умеет публиковать версии тарифов, переключать видимость тарифа, сохранять дополнения, выводить версии дополнений из обращения, править числа акции и переключать флаг активности промокода. Всё перечисленное ниже требует вызова API, а последние два пункта — SQL:

| Задача | Маршрут |
| - | - |
| Создать тариф | `POST /v1/admin/plans` (операторский токен) |
| Создать акцию или промокод | `POST /v1/admin/promotions` (операторский токен) |
| Изменить локализации тарифа | `PUT /v1/panel/catalog/plans/{planID}/localizations/{locale}` |
| Архивировать или восстановить тариф | `POST /v1/panel/catalog/plans/{planID}/archive` |
| Вывести версию тарифа из обращения | `POST /v1/panel/catalog/plan-versions/{planVersionID}/retire` |
| Изменить лимит одновременных подписок тарифа | `PATCH /v1/panel/catalog/plans/{planID}` |
| Предложить дополнения на версии тарифа | `addonIds` в `POST /v1/panel/catalog/plans/{planID}/versions` |
| Настроить выбор сквадов или нестандартное правило пробного периода | Тот же эндпоинт версий, со значениями enum из базы данных |
| Ограничить акцию товарами | Прямой SQL |
| Посмотреть или изменить область действия акции по тарифам | Прямой SQL |

Три из этих маршрутов панели — эндпоинт версий, сохранение дополнения и вывод версии дополнения из обращения — реализованы, но отсутствуют в `api/openapi.yaml`, поэтому их нет ни в сгенерированном клиенте, ни в опубликованном справочнике API. О том, как к ним аутентифицироваться, см. [Интеграцию с API](https://omniflow-vpn.mintlify.site/ru/integrations/api.md).

Все формы панели требуют причину и отправляют её в `X-Operator-Reason`, но ни один сервис каталога или предложений её на самом деле не проверяет. Прямой вызов API публикует версию тарифа, сохраняет дополнение, правит акцию или создаёт предложение с null-причиной в событии аудита.

## Неаутентифицированная лента каталога

`GET /v1/catalog/plans` принимает необязательный `locale` и не требует аутентификации. Это legacy-лента, и она не дедуплицирует соединение с ценами: тариф, оценённый в двух валютах, появляется дважды, по разу на валюту. Клиентские маршруты под `/v1/account` — это те, что возвращают одну запись на тариф с уже вычисленным правом на покупку.

## Оптовые партии кодов

Промокоды дают скидку на покупку, которую клиент всё равно совершает. Подарки
выпускаются по одному и покупаются через заказ. Реселлерская договорённость —
ни то ни другое: сгенерировать двести кодов по согласованной цене, передать
список и суметь убить остаток, когда список утечёт.

Они живут в `/admin/catalog`, потому что партия решает, что продаётся и по какой
согласованной цене, — то же решение, что и публикация версии тарифа. Деньги
через Omniflow не проходят: в этом и смысл опта.

### Коды существуют один раз

Хранится только SHA-256 каждого кода. Открытый текст появляется ровно в одном
месте — в ответе на генерацию партии — и больше нигде. Нет эндпойнта, способного
выдать их снова, и нет в базе того, из чего их можно было бы получить.

Поэтому экран говорит об этом до генерации партии, предлагает скачать и
скопировать и подтверждает закрытие списка. Оператору, который его потерял,
придётся выпустить новую партию.

### Что такое партия

| Поле | Значение |
| - | - |
| Название | Как оператор её называет и по чему ищет. Уникально. |
| **Версия** тарифа | Что даёт каждый код. Версия, а не тариф: публикация новой не должна менять то, на что двести человек уже держат код. |
| Количество | От 1 до 10 000. Более крупная договорённость — несколько партий. |
| Согласованная цена за код | Записывается, не списывается. Ноль допустим: партия, отданная в рамках партнёрства, — реальная вещь, и притворяться, что у неё была цена, хуже, чем записать, что цены не было. |
| Срок | Необязателен. Отсутствие срока — решение оператора и обязательство, которое он на себя берёт. |

### Погашение

Код партии и код подарка — **одни и те же шестнадцать символов Crockford**, см.
`internal/accesscode`, — поэтому клиентские поверхности предлагают одно поле и
пробуют оба. Клиент, держащий код, никак не может знать, какой он, и просьба
выбрать правильную форму была бы просьбой знать то, что знает только оператор.

Однократность погашения — свойство предиката `UPDATE`: подходит только код в
статусе `issued` из партии, которая не отозвана и не истекла, и тот же оператор
записывает погашение. Две одновременные попытки по одному коду дают одну подписку
и один отказ, без блокировки, которую кто-то должен помнить взять.

Любой отказ — один и тот же: неизвестен, уже использован, отозван, истёк.
Различать их значило бы превратить эндпойнт в оракул о том, какие коды
существуют, — ровно то, что хочет узнать перебирающий варианты.

Погашение **продлевает** то, что у клиента уже есть, а не заменяет его — по той
же причине, что и подарок: замена означала бы код, укорачивающий чью-то подписку.

Погашение выполняется в транзакции `Serializable`, как и любой другой путь, который
строит право на услугу поверх текущего права клиента. Поэтому код, погашаемый в тот
же момент, когда проводится продление той же подписки, не может прочитать то же
текущее окончание: одна из двух транзакций прерывается, а погашение повторяется
поверх результата другой, и клиент сохраняет оба периода. Прерванная попытка ничего
не погасила, поэтому повтор безопасен, а код, который тем временем погасил кто-то
другой, при повторе отклоняется.

> [!NOTE]
>
> Погашение создаёт **заказ нулевой стоимости** с операцией `code`.
>
> `entitlements.order_id` — NOT NULL, и это стоит сохранить: любую подписку,
> выданную этой установкой, можно проследить до транзакции, которая её
> произвела. При погашении денег не поступает — дистрибьютор заплатил вне
> продукта, — поэтому заказ ничего не несёт, и отчёт о продажах показывает
> погашение с пустыми денежными колонками, а не выдумывает выручку в день, когда
> никто не платил. Оптовая цена живёт на партии, где договорённость и находится.

### Отзыв остатка

Отзыв убивает все коды, которые никто не погасил, и сообщает, сколько их было.
**Погашенные не трогаются**: подпиской, которую каждый из них произвёл, кто-то
пользуется, и забрать её — другое решение, которого оператор здесь не принимал.

Причина обязательна, потому что это действие предпринимают, когда список утёк, а
«почему убили эти триста кодов» — вопрос, который задают через полгода. Она
попадает в журнал аудита как `codes.batch.revoked`.

## Смежные страницы

> **[Дополнения и конфигуратор](https://omniflow-vpn.mintlify.site/ru/commerce/addons-configurator.md)**
>
> Выбираемые сквады, версии дополнений, пропорциональный расчёт и то, как ёмкость доходит до права на услугу.

> **[Одновременные подписки](https://omniflow-vpn.mintlify.site/ru/commerce/subscriptions.md)**
>
> Права на услугу, потолок одновременных подписок, арифметика повышения и понижения тарифа, льготный период и восстановление.

> **[Заказы и платежи](https://omniflow-vpn.mintlify.site/ru/commerce/orders-payments.md)**
>
> Машина состояний заказа, внутри которой применяется скидка, и путь возврата.

> **[Подарки и цифровые товары](https://omniflow-vpn.mintlify.site/ru/commerce/gifts-and-goods.md)**
>
> Каталог магазина, его модель наценки и порог себестоимости, который скидка не вправе пробить.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.