> ## 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.

# Дополнения и конфигуратор подписки

> Наборы сквадов на уровне тарифа, версии дополнений, ёмкость в середине периода и правила пропорционального расчёта за каждой ценой

Две вещи позволяют клиенту настроить подписку сверх выбора тарифа: выбор сквадов, которые она использует, и покупка дополнительной ёмкости посреди периода.

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

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

Версия тарифа несёт две коллекции сквадов:

* `remnawave_squad_ids` — назначаются каждой подписке на этом тарифе всегда;
* `plan_version_squads` — выбираемый набор, где каждая строка несёт русскую и английскую подпись и порядок сортировки.

`squad_selection` решает, что клиенту позволено:

| Значение | Поведение |
| - | - |
| `automatic` | Сквады назначаются из тарифа. Присланный выбор отклоняется. |
| `optional` | Клиент может добавить сквады из предложенного набора в пределах `max_selectable_squads`. |
| `required` | Клиент обязан выбрать не менее `min_selectable_squads` и хотя бы один. |

Пустое значение ведёт себя как `automatic`, а любое значение вне этих трёх отклоняется сразу, а не додумывается.

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

Выбор проверяется на сервере по тарифу. Данные коллбэка, называющие сквад, которого тариф не предлагает, отклоняются с `squad_not_offered`, поэтому подделанный коллбэк не может прикрепить сквад, за который клиент не платил.

Конфигуратор появляется только тогда, когда тариф действительно предлагает выбор. Тариф с выбором `automatic` идёт с экрана тарифа прямо к оплате, ровно как и раньше.

### Полный список отказов

| Причина | Когда возникает |
| - | - |
| `squad_selection_not_allowed` | Тариф назначает сквады автоматически, либо сохранённый режим выбора не распознан |
| `squad_selection_required` | Тариф требует выбора, а ничего не выбрано |
| `squad_not_offered` | Присланный сквад не входит в предлагаемый набор версии |
| `squad_selection_too_few` | Выбрано меньше, чем `min_selectable_squads` |
| `squad_selection_too_many` | Выбрано больше, чем `max_selectable_squads` |

Все пять оборачивают одну доменную ошибку и проявляются как HTTP 422 с причиной в поле `type` по RFC 9457, которое обе клиентские поверхности переводят в собственные формулировки.

База данных подкрепляет границы: `min_selectable_squads` не меньше 0, `max_selectable_squads` больше 0, если задан, именованная межколоночная проверка требует, чтобы максимум был не меньше минимума, а подписи — от 1 до 80 символов на каждом языке.

### Выбор сквадов при оформлении заказа

*Веб.* На `/account/checkout` группа полей **Серверы** появляется только тогда, когда тариф сообщает, что выбор настраиваемый. Каждый чекбокс отправляет `PATCH /v1/account/checkout` со всем массивом `squadIds`, а сервер пересчитывает цену и возвращает оформление заказа целиком. Границы, которые показывает страница, — только подсказка: настоящая проверка выполняется при создании заказа, потому что вторая реализация того же правила подсчёта на React — это ровно то, что рано или поздно пропустило бы набор, который заказ затем отклонит.

*Telegram.* Выбор способа оплаты сначала открывает конфигуратор сквадов, когда тариф настраиваемый, и каждый коллбэк `squad:` переключает один сквад.

`GET /v1/account/plans/{planVersionID}` возвращает всю картину в поле `squads` с `selection`, `minimum`, `maximum`, `configurable` и списком предложенных сквадов. Подтверждённый набор хранится в заказе в `selected_squad_ids`; если он пуст, при расчёте используется собственный набор версии тарифа.

> [!WARNING]
>
> Выбираемый набор сквадов сейчас нельзя настроить из `/admin`. Редактор версий
> тарифа проверяет `squadSelection` по значениям `automatic`, `customer_choice`
> и `fixed`, тогда как CHECK-ограничение `plan_versions` допускает `automatic`,
> `optional` и `required` — единственные три значения, которые реализует домен.
> Публикация чего-либо, кроме `automatic`, из панели падает на базе данных с
> HTTP 500 `operation_failed`, а `optional` или `required`, отправленные в
> панель, отклоняются с 422 ещё раньше.
>
> Пока эти два списка не согласованы, публикуйте такую версию прямым вызовом
> `POST /v1/panel/catalog/plans/{planID}/versions` со значениями базы данных.
> Полный справочник по редактору версий — на странице
> [Каталог и акции](https://omniflow-vpn.mintlify.site/ru/commerce/catalog-promotions.md).

## Дополнения

Дополнение добавляет подписке ёмкость. Купить его можно двумя способами:

* **Вместе с тарифом**, при оформлении заказа или в сохранённой корзине. Оно списывается тем же заказом, поэтому одно подтверждение — это один платёж, и покрывает весь первый период по каталожной цене.
* **В середине периода**, против живого права на услугу. Оно списывается отдельным заказом с `operation = 'addon'` и оценивается по правилу пропорционального расчёта своей версии относительно оставшегося времени периода.

Существует три типа:

| Тип | Что добавляет |
| - | - |
| `traffic` | Байты сверх лимита трафика права на услугу |
| `devices` | Слоты устройств сверх лимита права на услугу |
| `squads` | Дополнительные сквады Remnawave |

Дополнение предлагается только для версий тарифа, которые перечисляют его в `plan_version_addons`. Покупка дополнения для тарифа, который его не предлагает, отклоняется с `addon_unavailable`. Эта связь — не только курирование: идентификатор дополнения приходит в данных коллбэка или в пути URL, которые контролирует клиент, поэтому именно она останавливает попытку прикрепить дополнение к тарифу, для которого оно никогда не оценивалось. Идентификатор, который даже не является UUID, обрабатывается так же, а не как ошибка разбора.

### Версионированные цены

Дополнения версионируются точно так же, как тарифы. Строка дополнения несёт представление и изменяема; версия несёт то, что получает клиент, и то, сколько это стоит, и неизменяема. `addon_versions` неизменяема, как только на неё ссылается заказ, а `order_addon_lines` хранит снимок ёмкости, цены и правила пропорционального расчёта версии на момент покупки. Изменение цены дополнения создаёт новую версию; оно никогда не переписывает исторический заказ.

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

**1. Откройте каталог**

Перейдите в `/admin/catalog` и переключитесь на вкладку **Дополнения**. В
списке показаны код, метка типа, текущая версия в виде `v{n}` и метка
«Видно» или «Скрыто». Список только для чтения — в строке нет элементов
редактирования или вывода из обращения.

**2. Опишите его**

Нажмите **Новое дополнение**. Заполните Код, Тип, Название (EN), Название
(RU) и зависящее от типа содержимое: Трафик в ГБ, Слоты устройств или список
ID сквадов через запятую. Трафик переводится в байты в браузере до отправки.

**3. Назначьте цену и границы**

Задайте «Максимум в заказе», правило цены в середине периода и Цены в том же
виде `RUB=19900, USD=299`, который использует редактор тарифов. Включите
**Видимость** и введите причину.

**4. Сохраните**

Отправка вызывает `PUT /v1/panel/catalog/addons` (`catalog.write`) и
возвращает ID дополнения. Аудит `panel.addon.saved`.

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

Что сохранение отклоняет, всё с 422:

* Отсутствующий код или код, не соответствующий `^[a-z][a-z0-9_-]{1,63}$`;
* Правило пропорционального расчёта вне трёх известных;
* Максимум в заказе меньше 1;
* Полное отсутствие цен;
* Дополнение `traffic` без трафика, дополнение `devices` без слотов, дополнение `squads` без сквадов или любой другой тип;
* Отсутствие названия на одном из языков. Отсутствующий перевод отклоняется, а не сохраняется пустым, потому что дополнение без названия на одном языке — это пустая строка на экране клиента.

Вывод версии из обращения — это `POST /v1/panel/catalog/addon-versions/{addonVersionID}/retire` (`catalog.write`, аудит `panel.addon_version.retired`). Вывод уже выведенной версии не находит ни одной строки и отвечает 404. Элемента управления для вывода из обращения в панели тоже нет.

> [!NOTE]
>
> Набор предлагаемых дополнений на версии тарифа заполняется только массивом
> `addonIds` в `POST /v1/panel/catalog/plans/{planID}/versions`, и публикация
> **заменяет** этот набор, а не дополняет его. Редактор версий в панели это поле
> не отправляет, поэтому каждая версия тарифа, опубликованная из
> `/admin/catalog`, не предлагает никаких дополнений вовсе. Заполнить набор
> можно только прямым вызовом эндпоинта версий.

Проверка предложения сопоставляет по дополнению, а не по версии дополнения, поэтому любая живая версия предложенного дополнения подходит, как только само дополнение перечислено.

### Покупка дополнений вместе с тарифом

*Веб.* `/account/checkout` рисует раздел **Дополнения**, когда версия тарифа предлагает хотя бы одно. Каждая запись — кнопка-переключатель; нажатие вызывает `POST /v1/account/checkout/addons/{addonVersionID}`, который переключает строку с количеством один и возвращает всё оформление заказа с пересчитанной ценой. Разбивка расчёта после этого сообщает `addonMinor` отдельно от `subtotalMinor` и `discountMinor`, потому что клиент, которому назвали только итог к оплате, не может отличить акцию от дополнения, а последствия ошибки в каждом из них совершенно разные.

*Telegram.* На экране оформления заказа появляется кнопка дополнений, когда тариф их предлагает; в списке одно нажатие переключает одну запись.

Переключение дополнения, которого тариф не предлагает, отвечает 404 `plan_unavailable` в вебе и «это дополнение больше недоступно» в боте. Архивированное дополнение, выведенная из обращения версия или версия без цены в валюте заказа отклоняются с `addon_unavailable`.

> [!NOTE]
>
> Промокод даёт скидку **только на цену тарифа**. Промежуточная сумма — это цена
> тарифа плюс сумма дополнений, но скидка считается только от цены тарифа,
> поэтому процент скидки никогда не съедает ёмкость дополнений.

### Покупка дополнений в середине периода

*Telegram.* Подписки → выберите одну → **Дополнения** → нажмите на дополнение. Покупка становится собственным заказом с количеством один; кошелёк оплачивает его, когда баланса хватает, а иначе появляется обычный экран оплаты.

*Веб.* **Недоступно.** Под `/v1/account` нет маршрута для покупки дополнения к живой подписке. Клиентская веб-панель может выбирать дополнения внутри оформления заказа и больше нигде.

Покупка в середине периода отклоняется с:

| Отказ | Когда возникает |
| - | - |
| `no_active_subscription` | У целевой подписки нет живого права на услугу — «дополнения можно покупать только для активной подписки» |
| `ErrSubscriptionUnknown` | Подписка принадлежит не этому клиенту |
| `ErrAddonQuantity` | Количество меньше или равно нулю либо больше `max_quantity` версии |
| `ErrUnsupportedProration` | Сохранённое правило пропорционального расчёта не из тех, что знает эта сборка |
| `addon_unavailable` | Архивировано, выведено из обращения, без цены в валюте заказа или не предлагается для этого тарифа |

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

### Правила пропорционального расчёта

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

| Правило | Списание | Подходит для |
| - | - | - |
| `full_price` | Каталожная цена, сколько бы ни оставалось от периода | Ёмкости, которая не убывает, например слотов устройств |
| `remaining_period` | Каталожная цена × остаток ÷ весь период, с округлением вверх до следующей минорной единицы | Трафика, где ценность масштабируется со временем |
| `daily_rate` | Каталожная цена ÷ целые дни периода, округлённая вверх, × оставшиеся дни, округлённые вверх, ограниченная полной ценой | Дополнений, которые оператор оценивает по дням |

Три свойства выполняются для любого правила, и у каждого есть собственный модульный тест:

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

Промежуточное умножение выполняется в 128 битах. Длинный период и большая цена переполнили бы 64-битное произведение и дали бы бессмысленную сумму.

### Применение дополнения

При расчёте купленная ёмкость сворачивается в само право на услугу до того, как вычисляется желаемое состояние выдачи доступа, поэтому Remnawave получает один совокупный результат, а не тариф с последующей поправкой:

```text theme={null}
entitlement.traffic_allowance_bytes += Σ traffic × quantity
entitlement.device_limit            += Σ slots × quantity
entitlement.remnawave_squad_ids      = existing ∪ add-on squads
```

> [!WARNING]
>
> Право на услугу с безлимитом остаётся безлимитным: обновление оставляет
> null-лимит трафика или null-лимит устройств как есть, потому что добавлять
> ёмкость к «без ограничения» бессмысленно.
>
> При этом ничто на клиентских поверхностях не запрещает такую покупку и не
> предупреждает о ней. Дополнение с трафиком, предложенное на безлимитном
> тарифе, показывается, оценивается, списывается и записывается — и не даёт
> ничего. Не предлагайте дополнения с ёмкостью на версиях тарифа, у которых
> соответствующий лимит безлимитен.

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

Каждая оценённая строка снимается в `order_addon_lines` с кодом, типом, версией, трафиком, слотами, сквадами, правилом пропорционального расчёта и концом периода, чтобы заказ объяснял себя сам спустя годы, без обращения к каталогу.

### Идемпотентное исполнение

`entitlement_addons` уникальна по `(order_id, addon_version_id)`. Повторный расчёт находит строку уже на месте, пропускает её и оставляет право на услугу нетронутым. В итоги входят только те строки, которые были фактически вставлены, поэтому частичный повтор не может посчитать дважды.

Заказ на дополнение в середине периода ставит в очередь собственную операцию исполнения с ключом `order:<order id>:addon`, несущую операцию `set_limits` для совокупного результата, и заказ переходит в `fulfilled`. Дополнению, купленному вместе с тарифом, отдельная операция не нужна: оно уже часть права на услугу, которое выдаёт собственная операция `create` тарифа. Что происходит с этой операцией дальше, описано в разделе [Исполнение заказов](https://omniflow-vpn.mintlify.site/ru/commerce/fulfillment.md).

### Когда прикреплять не к чему

Если к моменту расчёта у целевой подписки нет живого права на услугу, деньги не теряются и ничто не откатывается. Заказ остаётся в `paid`, пишется событие аудита `addon.no_active_entitlement` в категории `financial` с результатом «сбой», и в очередь ставится операторское уведомление типа `fulfillment_failure`, дедуплицированное по заказу. Откат записанного платежа был бы хуже, чем оставить оператору понятную задачу, — ничто здесь не вправе откатывать уже принятые деньги.

## Справочник API по этой области

| Маршрут | Аутентификация | Назначение |
| - | - | - |
| `GET /v1/panel/catalog/addons` | `catalog.read` | Список дополнений |
| `GET /v1/panel/catalog/addons/{addonID}/versions` | `catalog.read` | История версий одного дополнения |
| `PUT /v1/panel/catalog/addons` | `catalog.write` | Создать дополнение или опубликовать его следующую версию |
| `POST /v1/panel/catalog/addon-versions/{addonVersionID}/retire` | `catalog.write` | Вывести одну версию из обращения |
| `POST /v1/panel/catalog/plans/{planID}/versions` | `catalog.write` | Опубликовать версию тарифа, включая её набор предложений `addonIds` |
| `GET /v1/account/plans/{planVersionID}` | Клиентская сессия | Предложение сквадов и предложение дополнений для одной версии |
| `PATCH /v1/account/checkout` | Клиентская сессия | Отправить выбор сквадов |
| `POST /v1/account/checkout/addons/{addonVersionID}` | Клиентская сессия | Переключить одно дополнение в открытом оформлении заказа |

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

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

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

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

> **[Исполнение заказов](https://omniflow-vpn.mintlify.site/ru/commerce/fulfillment.md)**
>
> Как совокупное желаемое состояние доходит до Remnawave и как выглядит неудавшаяся операция.

> **[Пополнение кошелька и сохранённые корзины](https://omniflow-vpn.mintlify.site/ru/commerce/wallet-topup-cart.md)**
>
> Дополнения внутри сохранённой корзины и почему корзина всегда переоценивается перед списанием.


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