Skip to main content
Заказ в магазине — это обычный заказ. Кошелёк, промокоды, сохранённые корзины, платёжные провайдеры, возвраты, чеки и история работают так же, как и для тарифа. Отличается только нижний порог цены при скидках и то, что сохранённая покупка в магазине никогда не покупает себя сама, — оба случая разобраны в разделе Акции и сохранённые покупки.

Подарки

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

В чьей истории он появляется

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

Коды активации

Код состоит из шестнадцати символов Crockford base-32 — без I, L, O и U, поэтому в нём нет визуально неоднозначных символов и не складывается случайное слово. Восемьдесят бит вместе с потолком попыток и ограничением частоты запросов на эндпоинте делают подбор непрактичным. Хранится только SHA-256 от кода. Чтение из базы никогда не даёт код, пригодный для активации. Последние четыре символа хранятся отдельно как подсказка: этого достаточно, чтобы отправитель отличил два своих подарка друг от друга, а поддержка подтвердила, о каком подарке идёт речь, и недостаточно, чтобы активировать его. Ввод нормализуется перед поиском: разделители удаляются и применяются три замены Crockford (I/L в 1, O в 0), потому что именно эти символы надёжно путает человек, переписывающий код вручную. Всё остальное отклоняется, а не угадывается.

Правила активации

Активация возможна ровно один раз по построению: запрос активации совпадает только с подарком, который пригоден к выдаче и не истёк, поэтому вторая активация того же кода не обновляет ни одной строки. Причины отказа проверяются в намеренно выбранном порядке:
  1. Потолок попыток. После десяти неудачных предъявлений код перестаёт отвечать хоть что-либо, вместо того чтобы дальше выдавать, какая именно ошибка произошла. Счётчик — это устойчивое состояние в строке, поэтому он переживает перезапуск и его нельзя сбросить, переждав окно ограничения частоты запросов.
  2. Статус. Подарок, оплата которого ещё не прошла, отвечает «пока нет», а не отказом. Активированный, отозванный, возвращённый или истёкший подарок получает отказ.
  3. Истечение срока — даже если сборщик ещё не отработал.
  4. Активация самим собой. Отправитель не может активировать собственный подарок: иначе прогон денег через путь подарка дал бы ему доступ к акции или награде, на которую он не имеет права.
  5. Указанный получатель. Подарок, адресованный конкретному идентификатору Telegram, может активировать только этот идентификатор.

Отзыв и возвраты

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

Цифровые товары

Цифровой товар — это не доступ к VPN. Ничто в этой области не создаёт, не изменяет, не читает и не зависит от права на услугу в Remnawave, и сбой доставки здесь никогда не может задеть подписку.

Шлюз и почему Omniflow не обращается к Fragment

У Fragment нет API. Покупка там означает ведение аутентифицированной браузерной сессии через многошаговый процесс с состоянием и расчёт результата переводом TON в блокчейне с кошелька, выведенного из мнемонической фразы. Omniflow намеренно не держит этот кошелёк. Self-hosted продукт не должен поставлять поле конфигурации, способное тратить реальные деньги, а скрейпинговая сессия с состоянием — не то, чем фоновый воркер может безопасно делиться. Вместо этого оператор запускает шлюз, которому принадлежат сессия Fragment, кошелёк TON и расчёты, а Omniflow обращается к нему по HTTP Basic под фиксированным пользователем omniflow. Интерфейс адаптера остаётся небольшим — supports, quote, deliver, poll, balance, — поэтому второй шлюз или другой поставщик товаров — это пять методов, а не переписывание магазина.

Что шлюз предоставляет, а что нет

Доставка адресуется по имени пользователя. user_id, который шлюз тоже принимает, — это его собственный учёт для своих клиентских записей, поэтому Omniflow отправляет идентификатор Telegram покупателя: он не знает числового идентификатора за произвольным хендлом, а получает товар именно хендл.
Два ограничения шлюза влияют на то, что Omniflow может обещать.Ключа идемпотентности нет, поэтому запрос, ответ на который так и не пришёл, действительно неоднозначен: товар мог быть уже отправлен, а средства оператора — уже потрачены.И успешный ответ не является доказательством доставки. Шлюз отвечает 200 на покупку, которую его собственный обработчик бросил: нехватка TON, истёкшая сессия Fragment и отклонённая покупка сообщаются в чат оператора и после этого возвращаются как обычный результат. Пока шлюз не различает эти случаи, принятая заявка означает «шлюз её взял».И то и другое исправляется на стороне шлюза: поддержать заголовок Idempotency-Key и поднимать ошибку при неудавшейся покупке вместо возврата. До тех пор читайте «доставлено» в панели как «отправлено и принято».

Ценообразование

То, что платит клиент, выводится из того, что берёт поставщик: наценка в базисных пунктах, затем правило округления, и то и другое настраивается оператором для каждого продукта. Все режимы округляют вверх, и сама наценка округляется вверх, а не отбрасывает дробную часть. Магазин, теряющий по минимальной единице на каждой продаже, — это магазин, чья настроенная маржа не равна той, что он зарабатывает. Stars котируются по опубликованной шлюзом стоимости одной Star: она округляется до минимальной единицы за штуку, а затем умножается — та же арифметика, по которой шлюз выставляет счёт. У Premium опубликованной стоимости нет. Оператор задаёт цену продажи напрямую через fixed_amount_minor, а Omniflow фиксирует, что себестоимость неизвестна, а не равна нулю, поэтому финансовая витрина показывает маржу как неизвестную, а не как сто процентов. Обе стороны котировки хранятся в заказе: сколько запросил поставщик и что показали клиенту. Их разница — реализованная маржа, и именно поэтому финансовая витрина может её показать, не пересчитывая наценку, которая с тех пор могла измениться. Поскольку курс шлюза способен измениться, у котировки есть срок годности; его задаёт quote_ttl_seconds, и устаревшая котировка приводит к новой, а не к списанию по старому числу.

Получатели

Получатель — это имя пользователя Telegram и ничего больше. Никакого отображаемого имени, числового идентификатора или данных профиля: доставке нужно имя пользователя, а поддержке — возможность ответить «куда это ушло», и ни тому ни другому не нужно больше. Ввод нормализуется из тех форм, которые люди действительно вставляют, — голый хендл, префикс @, ссылка t.me/, ссылка со строкой запроса — и проверяется по собственному правилу Telegram: от пяти до тридцати двух символов, начиная с буквы. Шлюз не даёт проверки до оплаты, поэтому корректный по форме, но недостижимый хендл выявляется на доставке и приводит к возврату.

Доставка и классификация сбоев

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

Оплата с баланса кошелька

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

Контроль расходов

Запись поставщика несёт порог низкого баланса и скользящий потолок расходов, измеряемый в себестоимости поставщика, а не в цене для клиента: потолок защищает источник финансирования оператора, а именно его поставщик и расходует. Нулевой потолок означает отсутствие потолка. Шлюз не публикует баланс, поэтому оповещению о низком балансе сегодня не на чём срабатывать для него; исчерпанный кошелёк TON вместо этого проявляется как доставки, которые шлюз принимает и молча бросает. Публикация эндпоинта баланса — единственное изменение, которое это исправит. Учётные данные — URL шлюза и его общий токен — запечатываются ключом APP_DATA_ENCRYPTION_KEY, никогда не возвращаются API после записи и исключены из диагностики.

Акции и сохранённые покупки

Цена в магазине — не цена тарифа, и из этого следуют три решения.

Акция применяется либо к тарифам, либо к товарам, но никогда к обоим

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

Скидка не может опустить заказ ниже цены поставщика

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

Сохранённая покупка в магазине никогда не покупает себя сама

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

Покупка в боте

Подарки

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

Магазин

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

Доставка, опрос и неоднозначный исход

Источник истины о том, что должно быть выдано, — goods_deliveries, а не очередь задач. Планировщик переводит «эта строка подошла по сроку» в «запусти воркер», поэтому потерянная задача, вычищенная очередь или перезапущенный процесс стоят задержки, а не доставки. Строка, уже несущая ссылку поставщика, была отправлена и ещё не завершена. Спросить ещё раз означало бы попросить вторую покупку, поэтому воркер вместо этого опрашивает статус — вот что на практике означает отложенная доставка. Адаптер, который нельзя опросить, оставляет исход по-настоящему неизвестным, а неизвестное — это ни сбой для повтора, ни успех для записи. Шлюз, стоящий перед Fragment, — ровно такой случай: он не поддерживает ключ идемпотентности и не даёт способа опросить отправленную заявку. Такая доставка получает класс ambiguous и откладывается в очередь разбора в панели, пока оператор не подтвердит у поставщика, что произошло. Повтор мог бы доставить дважды; возврат мог бы вернуть деньги за товар, который получатель получил.