Skip to main content
Каталог намеренно разделён надвое. Тариф — это стабильный продукт, который видит клиент: код, тип, русский и английский текст, флаг видимости, порядок сортировки и необязательный лимит одновременных подписок на этот тариф. Версия тарифа — это конфигурация с ценой, по которой продукт продаётся: длительность, период оплаты, лимит трафика, лимит устройств, сквады, льготный период, политики жизненного цикла и по одной цене на валюту. Тариф — это то, что оператор переименовывает, переупорядочивает, скрывает и архивирует. Версия — это то, по чему рассчитывается цена заказа, и она никогда не редактируется. Всё остальное на этой странице — пробные периоды, акции, промокоды, персональные предложения — держится на этом разделении. Скидка меняет то, сколько списывается по заказу; она никогда не меняет версию, на которую ссылается заказ, и никогда не меняет то, что клиент уже заплатил.
У нескольких частей этой области нет элементов управления в /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 с операторским токеном:
Вызов создаёт тариф и его первую версию в одной транзакции уровня 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) и возвращает созданную версию. Экран сообщает о последствиях: существующие заказы сохраняют версию, по которой были куплены, и публикация не меняет ни одну цену, которую клиент уже оплатил.
Null и ноль означают здесь разное, и код говорит об этом дважды: отсутствующий лимит трафика или лимит устройств — это безлимит, тогда как ноль означал бы тариф, который не разрешает ничего. Панель, которой пришлось бы угадывать, что означает ноль, рано или поздно объявила бы клиенту неверное, поэтому ноль вместо этого отклоняется. Льготный период существует для того, чтобы текст об окончании подписки не был догадкой: он сохраняет доступ после ends_at, чтобы клиенту можно было сказать точно, сколько у него осталось времени на продление. Как он вычисляется, описано в разделе Подписки.

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

  • Версия вообще без цен отклоняется. Версия, которую никто не может купить, — не версия.
  • 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.
Публикация версии с чем-либо, кроме Выбор сквадов = назначаются автоматически и Пробный период = только новым клиентам, падает на уровне базы данных и выглядит как 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, не предлагает никаких дополнений. Заполнить набор предложений можно только прямым вызовом эндпоинта. Страница Дополнения и конфигуратор подписки объясняет, от чего защищает эта связь.

Цены

plan_prices — это одна строка на (plan_version_id, currency) с целочисленным amount_minor. Нигде в Omniflow нет десятичных дробей: деньги всегда в минорных единицах — копейках, центах, Stars, — а валюта проверяется по ^[A-Z]{3}$. Поле «Цены» в редакторе версий — свободный текст: RUB=49900, USD=599. Десятичные значения отвергаются сразу, потому что «4.99» неоднозначно относительно того, какая минорная единица имеется в виду. Версия может быть оценена в любом числе валют, и обычно ей нужна не одна, потому что выбранный платёжный провайдер задаёт валюту заказа. Именно поэтому при оформлении заказа цена показывается рядом с каждым способом оплаты: один и тот же тариф через другого провайдера может стоить другую сумму, и скрывать это до страницы самого провайдера было бы неприятным сюрпризом.
Тариф без цены в расчётной валюте полностью исключается из клиентского каталога, а не показывается как недоступный. Купить его здесь нельзя вовсе, а запись, с которой нельзя ничего сделать, — это лишь вопрос, на который клиент не может ответить.Если тариф отсутствует в /account/store, а сам тариф видимый и не в архиве, первым делом проверьте строку цены в APP_DEFAULT_CURRENCY.
Во время оформления заказа выборка соединяется по запрошенной валюте. Отсутствие строки означает ErrPlanUnavailable и HTTP 404 plan_unavailable. APP_DEFAULT_CURRENCY (по умолчанию RUB) — расчётная валюта, в которой обе клиентские поверхности показывают каталог; для установки, работающей только на Telegram Stars, используйте XTR. Она проверяется при запуске. См. Конфигурацию.

Локализация

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= имеет приоритет над сохранённым предпочтением клиента, поэтому переключение языка срабатывает до сохранения профиля.

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

Три элемента управления с тремя разными значениями: Архивирование скрывает тариф и прекращает новые покупки из каталога. Оно намеренно не трогает существующие права на услугу: клиент, купивший тариф, сохраняет то, что купил, а его заказ сохраняет неизменяемый снимок версии, по которой он был оценён. Архивирование идемпотентно и обратимо — 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. В панели элемента управления для вывода из обращения нет.
Вывод из обращения не перепроверяется при создании заказа. Запрос, который рассчитывает цену заказа, фильтрует по ID версии и валюте и больше ни по чему, а собственное чтение тарифа при оформлении заказа фильтрует только по archived_at. Оформление заказа, открытое до вывода версии, сохранённая корзина, ссылка на версию тарифа из закладок или устаревший коллбэк бота по-прежнему могут создать заказ по выведенной версии.Действительно недоступной для заказа версию делает удаление строки её цены в расчётной валюте.

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

Пробный период — это тариф с kind = 'trial' плюс правило trial_eligibility на уровне версии. Его получение записывает строку в trial_claims, первичный ключ которой — клиент: один пробный период на клиента на установку, навсегда, и никакого второго пробного периода для второй подписки. Бесплатный тариф, который можно брать сколько угодно раз, — это бесплатный VPN, поэтому защита выстроена слоями, и каждый слой называет своё злоупотребление: Правила, как они хранятся: 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; они описаны в разделе Одновременные подписки.

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

Версия несёт две коллекции: plan_versions.remnawave_squad_ids, которые назначаются всегда, и plan_version_squads — выбираемый набор с русской и английской подписью для каждой строки. squad_selection решает, выбирает ли клиент вообще. Итоговый набор дедуплицируется и сортируется, поэтому два клиента, выбравшие одни и те же сквады в разном порядке, дают побайтово идентичное состояние права на услугу. Без этого сверка расхождений сообщала бы о несовпадении, которого нет. Выбор проверяется на сервере по тарифу, потому что идентификаторы приходят в данных коллбэка и телах запросов, которые контролирует клиент. Клиентская половина этого — группа полей при оформлении заказа, конфигуратор в боте и то, как выглядит каждый отказ, — описана на странице Дополнения и конфигуратор подписки. Учтите оговорку про панель выше: выбор, доступный клиенту, сейчас нельзя настроить из /admin.

Акции

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

Типы наград

promotions_kind_known допускает percent, fixed, wallet_credit, days и trial.
Применяются только percent и fixed. Промокод, принадлежащий акции типа wallet_credit, days или trial, отклоняется при погашении с promo_ineligible, и никакая проводка по акции не пишется вообще.Страница тарифа усугубляет это: она перечисляет каждую активную акцию, привязанную к тарифам, независимо от типа и отображает всё, что не percent, как фиксированную денежную скидку. Кампания wallet_credit таким образом рекламируется клиентам как скидка деньгами, а её собственный код затем отказывает. Пока типы наград не реализованы, создавайте только акции percent и fixed.
Арифметика процента — промежуточная сумма × величина ÷ 10000 с целочисленным отбрасыванием, поэтому округление идёт в пользу клиента. Фиксированная скидка — это min(величина, промежуточная сумма), поэтому она никогда не превысит цену; слой заказов отклоняет скидку выше промежуточной суммы, и база данных также обеспечивает discount_minor <= subtotal_minor.

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

JSON-объект eligibility вычисляется в SQL. Понимаются три ключа, а отсутствующий ключ не накладывает ограничений: Один и тот же запрос рекламирует кампанию на странице тарифа и принимает код при погашении, поэтому кампания, объявленная доступной, — это та, которую заказ примет.

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

Создание — только через API с операторским токеном:
Одна транзакция уровня 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_below_cost намеренно выделен в отдельную причину: недоступный код — это неправильный код, а этот — правильный код на неправильном товаре. Обе клиентские поверхности переводят эти значения сами, а не показывают текст сервера, и обе откатываются к честной фразе «код отклонён» для причины, которую прислал более новый сервер и о которой сборка никогда не слышала. Код, переставший быть действительным посреди оформления заказа, не ломает экран. Стоимость пересчитывается без кода, и отказ сообщается рядом с ней.

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

Погашение — не отдельный шаг. Оно происходит внутри собственной транзакции заказа:
  1. Создание заказа выполняется на уровне Serializable.
  2. Код выбирается вместе с его акцией через FOR UPDATE с фильтром на активность обоих. Два клиента, погашающие последнее использование лимитированного кода, сериализуются, а не выигрывают оба.
  3. Один запрос возвращает три счётчика: общий по акции, по этому клиенту и по этому коду.
  4. Проверяется область действия по тарифам, затем условия участия клиента.
  5. Сверяются лимиты и рассчитывается скидка.
  6. Строка погашения пишется в той же транзакции, что и заказ.
promo_redemptions.order_id объявлен UNIQUE, поэтому на один заказ погашается не более одной акции. Предварительный расчёт цены выполняет те же чтения на уровне ReadCommitted и затем откатывается, поэтому предпросмотр никогда не может израсходовать погашение или зарезервировать средства кошелька. Именно это делает цену, которую подтверждает клиент, той ценой, по которой создаётся заказ. Погашение, чей заказ закрылся неоплаченным, не считается. Строка погашения пишется при создании заказа, до того как двинутся деньги, и никогда не удаляется — это финансовая запись. Поэтому то, что считают счётчики на шаге 3, уточняется состоянием заказа: погашение за заказом, который был отменён или истёк, не входит ни в общий счётчик акции, ни в счётчик клиента, ни в счётчик кода, так что персональный оффер на одно использование переживает «нажал Оплатить — передумал», а лимитированный публичный код не исчерпывается чекаутами, которые так и не оплатили. Погашение за живым заказом в pending всё ещё считается — именно это не даёт двум параллельным чекаутам обоим пройти лимит в одно использование. То же правило действует для единственного триала клиента: заявка на триал, чей заказ закрылся неоплаченным, перехватывается следующим триальным заказом, а не отказывает ему. Закрытый заказ, оплаченный поздно, заново проверяется по этим лимитам. Закрытый заказ всё ещё можно оплатить — см. машину состояний, — а раз он перестал считаться в момент закрытия, его акцию или триал с тех пор мог забрать другой заказ. Проведение пересчитывает акцию по тому же правилу, что и создание заказа, под теми же блокировками строк, и смотрит, кому принадлежит заявка на триал. Если проведение вывело бы акцию за лимит или заявка на триал теперь принадлежит заказу, который не закрыт, платёж классифицируется как limit_exceeded, не проводится, и оператор уведомляется. Иначе он проводится, а триальный заказ забирает свою заявку обратно.
Счётчики погашений, показываемые в /admin/catalog → Promotions и возвращаемые списком промокодов, считают каждую строку погашения, включая закрытые заказы. Они описывают, сколько раз код вводили, а не сколько использований у него осталось.

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

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

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

promotions.applies_to называет каталог, на который акция даёт скидку, — plans или goods, никогда оба. Внутри каталога область задаётся через promotion_plans или promotion_goods, а пустая область означает все тарифы или все видимые товары. Акция без указанной области уже означала «все тарифы». Если бы товары просто присоединились к этому множеству, каждая написанная оператором акция молча начала бы давать скидку на заказы магазина в момент обновления. Это неверное умолчание вдвойне: никто на него не соглашался, и две цены — числа разной природы. Существующие строки получают значение plans по умолчанию, поэтому при обновлении ничего не расширилось. Условие applies_to несёт нагрузку, а не подстраховывает: у акции на товары без указанной области нет строк области по тарифам, поэтому без этого условия она прошла бы по ветке «все» и дала бы скидку на каждый тариф. Доменный слой повторяет проверку внутри самой функции расчёта скидки, чтобы два каталога не разошлись. Триггер базы данных отклоняет строку области по товарам у акции, чья applies_to не равна goods, потому что акция на тарифы, накапливающая строки области по товарам, выглядит настроенной, а ведёт себя как отсутствующая.
У applies_to нет пути записи ни в одном API. Ни эндпоинт создания, ни обновление из панели её не задают, поэтому каждая акция, созданная через Omniflow, — это акция на тарифы. Акция на товары сегодня требует записи колонки и строк promotion_goods через SQL.

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

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

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

promotions.stackable по умолчанию false, а promotions.precedence — 0. По задумке выключенное по умолчанию складывание означает, что две акции применяются вместе только тогда, когда обе это разрешают, а приоритет задаёт порядок вычисления, чтобы результат применения нескольких акций был детерминированным, а не зависел от порядка, в котором клиент их вводил.
Ни одно из этих полей не читается ни одним ценовым путём. Они сохраняются, редактируются, отправляются при каждом обновлении акции и рисуются меткой в панели, но не доходят ни до какого кода в коммерческом домене.Более того, один заказ по построению может нести не больше одной акции: вход заказа принимает один промокод, оформление заказа в боте и сохранённая корзина каждый хранят одну колонку кода, а 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. В клиентской веб-панели нет ни маршрута, ни экрана, ни эндпоинта для предложений, поэтому клиент, который заходит только в браузере, не увидит и не возьмёт предложение. В главном меню бота появляется пункт «Ваше предложение», когда ждёт хотя бы одно предложение. Открыв его, клиент видит список с заголовком каждого предложения и обратным отсчётом дней с кнопками Посмотреть предложение и Не интересно. Открытие одного показывает заголовок, условия, обратный отсчёт, промокод для использования и пометку об одноразовости. Открытие предложения не помечает его использованным. Клиент, который открыл каталог и передумал, ничего не израсходовал, а сжигать одноразовое предложение по одному нажатию значило бы забрать у него что-то ни за что. Отклонение окончательно, а предикат обновления включает клиента, поэтому идентификатор предложения из данных коллбэка — которые контролирует клиент — не может отклонить чужое предложение. Список в боте применяет окно действия в собственном запросе, а не доверяет сборщику, поэтому предложение никогда не переживает свой срок на экране, даже если сборщик отстаёт. Само истечение обрабатывается сборщиком по умолчанию каждые пять минут. Предложение показывает первый активный промокод акции в порядке создания. Акция без кодов даёт предложение без кода, и клиенту нечего вводить.
Ничто в пути заказа не знает о персональных предложениях. Статус переходит только из active в dismissed, revoked или expired; order_id всегда null, из-за чего фильтр «использованные» в реестре и его ссылки на заказы мертвы. «Одноразовость» обеспечивается лишь косвенно — через per_customer_limit лежащей в основе акции.

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

/admin/catalog умеет публиковать версии тарифов, переключать видимость тарифа, сохранять дополнения, выводить версии дополнений из обращения, править числа акции и переключать флаг активности промокода. Всё перечисленное ниже требует вызова API, а последние два пункта — SQL: Три из этих маршрутов панели — эндпоинт версий, сохранение дополнения и вывод версии дополнения из обращения — реализованы, но отсутствуют в api/openapi.yaml, поэтому их нет ни в сгенерированном клиенте, ни в опубликованном справочнике API. О том, как к ним аутентифицироваться, см. Интеграцию с API. Все формы панели требуют причину и отправляют её в X-Operator-Reason, но ни один сервис каталога или предложений её на самом деле не проверяет. Прямой вызов API публикует версию тарифа, сохраняет дополнение, правит акцию или создаёт предложение с null-причиной в событии аудита.

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

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

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

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

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

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

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

Погашение

Код партии и код подарка — одни и те же шестнадцать символов Crockford, см. internal/accesscode, — поэтому клиентские поверхности предлагают одно поле и пробуют оба. Клиент, держащий код, никак не может знать, какой он, и просьба выбрать правильную форму была бы просьбой знать то, что знает только оператор. Однократность погашения — свойство предиката UPDATE: подходит только код в статусе issued из партии, которая не отозвана и не истекла, и тот же оператор записывает погашение. Две одновременные попытки по одному коду дают одну подписку и один отказ, без блокировки, которую кто-то должен помнить взять. Любой отказ — один и тот же: неизвестен, уже использован, отозван, истёк. Различать их значило бы превратить эндпойнт в оракул о том, какие коды существуют, — ровно то, что хочет узнать перебирающий варианты. Погашение продлевает то, что у клиента уже есть, а не заменяет его — по той же причине, что и подарок: замена означала бы код, укорачивающий чью-то подписку. Погашение выполняется в транзакции Serializable, как и любой другой путь, который строит право на услугу поверх текущего права клиента. Поэтому код, погашаемый в тот же момент, когда проводится продление той же подписки, не может прочитать то же текущее окончание: одна из двух транзакций прерывается, а погашение повторяется поверх результата другой, и клиент сохраняет оба периода. Прерванная попытка ничего не погасила, поэтому повтор безопасен, а код, который тем временем погасил кто-то другой, при повторе отклоняется.
Погашение создаёт заказ нулевой стоимости с операцией code.entitlements.order_id — NOT NULL, и это стоит сохранить: любую подписку, выданную этой установкой, можно проследить до транзакции, которая её произвела. При погашении денег не поступает — дистрибьютор заплатил вне продукта, — поэтому заказ ничего не несёт, и отчёт о продажах показывает погашение с пустыми денежными колонками, а не выдумывает выручку в день, когда никто не платил. Оптовая цена живёт на партии, где договорённость и находится.

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

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

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

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

Выбираемые сквады, версии дополнений, пропорциональный расчёт и то, как ёмкость доходит до права на услугу.

Одновременные подписки

Права на услугу, потолок одновременных подписок, арифметика повышения и понижения тарифа, льготный период и восстановление.

Заказы и платежи

Машина состояний заказа, внутри которой применяется скидка, и путь возврата.

Подарки и цифровые товары

Каталог магазина, его модель наценки и порог себестоимости, который скидка не вправе пробить.