orders. Подписка, смена тарифа, пополнение кошелька,
дополнение в середине периода, подарок, покупка цифровых товаров и автоматическое продление
используют одну и ту же запись, одни и те же платёжные намерения, один и тот же приём вебхуков,
одну и ту же сверку и один и тот же путь возврата. Альтернатива — второй денежный путь для
пополнений, третий для магазина — была бы вторым и третьим местом, где можно ошибиться с
идемпотентностью, и комментарии в миграциях говорят об этом там, где были добавлены операции
topup и goods.
Операции различаются только тем, что даёт проведение платежа: право на услугу, средства в кошельке,
строка выдачи товара или подарок, который можно получить. Всё, что происходит до этого момента, —
общее.
Одна запись, девять операций
orders.operation называет, что именно покупается. Набор значений задан табличным CHECK, поэтому
неизвестную операцию вообще нельзя записать.
renewal намеренно выделен из extension. Поддержка отвечает на вопрос «за что с меня списали?»
именно по этому столбцу, и честный ответ различается для изменения, о котором клиент попросил сам,
и для списания, которое произошло, пока он спал. См. Регулярные
платежи.
Суммы в заказе
Несущее ограничение —
wallet_minor + external_minor = subtotal_minor - discount_minor. Это
табличный CHECK, а не правило приложения, поэтому ни скрипт, ни бэкфилл, ни будущий код не смогут
записать заказ, части которого не сходятся. Все суммы — целые числа в минорных единицах с валютой
из трёх заглавных букв; ни на проводе, ни в схеме нет ни одного числа с плавающей точкой.
Автомат состояний
У заказа восемь состояний. Три вопроса, которые состояния держат порознь: взяты ли деньги (paid), выдано ли купленное
(fulfilled) и отданы ли деньги обратно (partially_refunded, refunded). Исполнение вынесено в
отдельное от оплаты состояние, потому что выдача доступа в Remnawave — асинхронная операция с
повторными попытками, которая может провалиться уже после того, как деньги пришли; см.
Исполнение.
draft есть и в схеме, и в домене, но все создатели заказов в коде пишут сразу pending или
paid, поэтому клиент его обычно не увидит.
Домен объявляет карту разрешённых переходов, но в продакшене её никто не вызывает. Жизненный
цикл на самом деле обеспечивает набор проверок, разбросанных по запросам, которые записывают
каждый переход; они перечислены ниже. Не читайте диаграмму как единый шлюз, через который
проходит каждая запись.
Отмена идемпотентна: запрос принимает
cancelled как исходное состояние, а строка мутации
записывается через upsert по (order_id, action, idempotency_key), поэтому двойное нажатие
закрывает один заказ один раз.
Закрытый заказ всё ещё можно оплатить. Чекаут провайдера живёт дольше открывшего его заказа:
счёт CryptoBot или страница YooKassa, оставленная в другой вкладке, принимает деньги после того, как
клиент нажал Отменить, а ручной перевод можно подтвердить после окна. Пришедшие деньги — это
деньги, которые клиент отправил, поэтому платёж, совпадающий с external_minor, проводит заказ —
право на услугу создаётся, а выдача доступа ставится в очередь ровно так же, как при обычной
продаже, — но классифицируется отдельно: late на истёкшем заказе, paid_after_cancellation — на
отменённом. Второй случай ещё и ставит в очередь операторское уведомление, потому что клиенту было
сказано, что ничего списано не будет.
Позднее проведение заново проверяет то, по чему заказ проверялся при открытии, потому что, пока он
стоял закрытым, мир мог измениться. Три исхода заказ не проводят, и каждый классифицируется так,
чтобы оператор мог на него отреагировать:
stale_quote— заказ в магазине, чья котировка шлюза истекла. Его окно оплаты и есть срок действия котировки, а выдача по курсу, который больше не действует, может стоить дороже, чем заплатил клиент.limit_exceeded— его акцию или триал забрал другой заказ, пока этот стоял закрытым. См. Каталог и акции.subscription_unavailable— подписка, которую он открыл, была освобождена, пока он стоял закрытым, а потолок параллельности не позволяет открыть новую. См. Подписки.
cancelled с платёжным событием status_changed.
Окно оплаты
expires_at задаётся при создании заказа и зависит от платёжного провайдера:
Поверхность, которая знает провайдера в момент открытия заказа, называет его, и окно верно с самого
начала. Поверхность, которая даёт клиенту выбрать провайдера потом, открывает заказ с часовым окном
по умолчанию, а создание платёжного намерения затем расширяет окно до того, которого заслуживает
выбранный провайдер. Расширение только удлиняет окно, только пока заказ в
pending, и никогда не
трогает заказ на товары, поэтому закрытый заказ никогда не открывается заново, а просроченная
котировка никогда не оплачивается — платёж за товар, пришедший после истечения котировки,
классифицируется как stale_quote и не проводится. Выбор карточного провайдера после ручного оставляет более длинное
окно в силе.
Что видит клиент: фаза платежа
Ни одна из клиентских поверхностей не показывает сырое состояние заказа. Состояние заказа, статус платёжного намерения и статус последней операции исполнения сворачиваются на сервере в одну фазу, потому что только сервер видит все три сразу.
Сначала смотрят на заказ и только потом на намерение — именно это не даёт опоздавшему или
продублированному вебхуку провайдера откатить оплаченный заказ назад у кого-то на экране.
- Веб.
/account/orders/{id}рисует трёхшаговый индикатор прогресса — оплачено, выдача доступа, готово, — управляемый только фазой, поэтому перезагрузка или вторая вкладка показывают тот же прогресс. - Telegram. Экран заказа переключается по той же фазе и предлагает свою клавиатуру для каждой фазы: ссылку провайдера и Отменить, пока заказ ожидает оплаты, Подключить — после завершения.
GET /v1/account/orders и GET /v1/account/orders/{orderID} возвращают phase рядом с state.
Принадлежность заказа зашита в сам SQL (WHERE o.id = $3 AND o.user_id = $1), поэтому
идентификатор заказа, подсмотренный в URL или в callback-данных, не позволит обратиться к чужому
заказу, а «не ваш» неотличимо от «не существует».
Список заказов клиента намеренно исключает пополнения: он требует строку в
order_lines, а у
пополнения её нет. Страница самого заказа-пополнения по-прежнему доступна по идентификатору —
именно на неё ведёт ссылка с экрана кошелька.Расчёт цены при оформлении заказа
Одна функция ценообразования обслуживает и предпросмотр, и заказ, который за ним следует. Предпросмотр выполняет её внутри транзакции, которая всегда откатывается, поэтому просмотр цены не может ни погасить акцию, ни зарезервировать средства в кошельке; подтверждение выполняет ту же функцию внутри сериализуемой транзакции, которая записывает заказ, поэтому показанные и сохранённые цифры не могут разойтись. Шаги по порядку:- Определить версию тарифа для запрошенной валюты. Архивированная версия, снятая с продажи версия
или версия без цены в этой валюте даёт
plan_unavailable. - Сверить операцию с политиками повышения и понижения тарифа.
- Прочитать доступный баланс кошелька — общую сумму минус то, что уже заняли собственные заказы
клиента в состояниях
draftиpending. - Определить акцию, если код был указан, взяв
FOR UPDATEна строку акции, чтобы два клиента, гонящиеся за последним использованием ограниченного кода, выстроились в очередь, а не получили его оба. - Разрешить выбор сквадов по политике выбора, заданной версией тарифа.
- Оценить дополнения по полной каталожной цене, потому что дополнение, купленное вместе с тарифом, покрывает весь первый период.
- Разделить сумму к оплате на кошельковую часть и внешнюю часть.
POST /v1/account/checkout/promo отвечает 200
с полем promoRejection, а не ошибкой, и расчёт пересчитывается без кода, поэтому клиент может
попробовать другой, не потеряв корзину. Устойчивые причины отказа — promo_unknown,
promo_ineligible, promo_exhausted, promo_invalid и — для магазина — promo_below_cost. См.
Каталог и акции.
На маршруте заказов с bearer-токеном попытки применить промокод ограничены десятью в минуту на
клиента через Valkey. Если сам ограничитель недоступен, запрос отвечает 503, потому что
пропускать всё при отказе ограничителя акций — это способ подобрать код перебором.
Средства кошелька списываются раньше внешнего платежа
paid и проводится
внутри создающей его транзакции. Такому заказу вообще не предлагается платёжный провайдер: попытка
начать по нему платёж отвечает 409 payment_not_required.
Оформление заказа несёт переключатель использовать кошелёк на обеих поверхностях
(PATCH /v1/account/checkout с {"applyWallet": false} в браузере, кнопка переключения кошелька в
боте) — для клиента, который предпочитает сохранить баланс на потом.
При проведении платежа баланс перечитывается. Если с момента подтверждения он опустился ниже
wallet_minor заказа, платёж классифицируется как wallet_unavailable, и заказ не проводится.
Что после этого остаётся, описано в разделе Когда платёж не
проводится. Механика кошелька целиком описана на странице Кошелёк и
книга проводок.
Сессия оформления заказа
У клиента не больше одного открытого оформления заказа, общего для браузера и чата: это обеспечивает частичный уникальный индекс по клиенту, поэтому открытие оформления в браузере вытесняет то, что осталось открытым в Telegram, а не работает рядом с ним. Сессия несёт версию тарифа, операцию, валюту, выбранного провайдера, промокод и причину его отклонения, переключатель кошелька, целевую подписку, выбранные сквады и один ключ идемпотентности, выпущенный при открытии оформления и переиспользуемый при каждой попытке подтверждения. Именно поэтому второе нажатие, дважды отправленная форма или повтор после оборвавшегося соединения приводят к уже существующему заказу.1
Откройте оформление заказа
На
/account/store выберите тариф и нажмите Купить. Это POST /v1/account/checkout с
{planVersionId, operation, subscriptionId?, newSubscription?}.2
Отредактируйте его
/account/checkout показывает разбивку суммы. Любое изменение — это
PATCH /v1/account/checkout с любым из полей provider, currency, applyWallet,
subscriptionId, newSubscription, squadIds; каждое из них — JSON-указатель, поэтому «не
передано» и «очищено» — разные запросы. Дополнения переключаются на
POST /v1/account/checkout/addons/{addonVersionID}, промокоды — на
POST/DELETE /v1/account/checkout/promo.3
Подтвердите
Подтвердить отправляет
POST /v1/account/checkout/confirm с заголовком Idempotency-Key и
отвечает 201 с заказом. Здесь ничего не списывается.4
Оплатите
Оплатить отправляет
POST /v1/account/orders/{orderID}/payment с {"provider": "..."} и
заголовком Idempotency-Key и возвращает способ передачи к оплате.- Каждый запрос на изменение требует, чтобы у оформления ещё не было заказа, поэтому правка,
пришедшая после подтверждения, сообщается как
409 checkout_settled, а не как отсутствующее оформление. - Изменение жизненного цикла при нескольких подписках и без указанной цели отклоняется с
422 subscription_target_required. Панель обязана спросить, а не позволять серверу угадывать. - Валюта, которую выбранный адаптер не может провести, отклоняется в момент выбора.
- Оформление истекает через час после открытия; чтение истёкшего удаляет его, поэтому клиент начинает заново со свежего расчёта, а не с устаревшей цены.
- В Telegram членство в обязательных каналах перепроверяется в момент подтверждения, потому что клиент может выйти из канала между просмотром и оплатой.
Платёжные намерения
Строкаpayment_intents — это одна попытка провести один заказ через одного провайдера. Она хранит
провайдера, статус (pending, requires_action, processing, succeeded, failed, cancelled,
expired), сумму и валюту, собственный референс провайдера, URL оплаты, ключ идемпотентности
вызывающей стороны, снимок объявленных возможностей адаптера и несекретные receipt_metadata — там
живут региональные поля фискализации, так что адаптер может их заполнить, не протаскивая поля
провайдера в модель заказа.
Создание намерения:
- Ключ идемпотентности проверяется (от 8 до 128 символов).
- Незарегистрированный провайдер отклоняется с «payment provider is not enabled».
- Рекомендательная блокировка PostgreSQL по ключу
provider + ":" + keyвыстраивает параллельные попытки в очередь. - Заказ должен быть в состоянии
pendingи иметь внешнюю сумму к оплате, иначе — «order does not require an external payment». - Существующее намерение под тем же ключом, но с другим заказом, суммой или валютой отклоняется с «idempotency key was already used with different payment parameters»; те же параметры возвращают намерение без изменений.
- Если адаптер умеет находить счёт по референсу мерчанта (CryptoBot умеет), Omniflow подхватывает уже созданный им счёт, а не создаёт второй.
- Иначе адаптеру поручается создать платёж, а его статус, референс и URL оплаты сохраняются.
Привязка клиентского ключа к заказу и к провайдеру сделана намеренно: собственный ключ заказа не
различил бы клиента, который бросил один способ оплаты и вернулся с другим, а переиспользование
такого ключа связало бы вторую попытку с намерением первого адаптера.
Ответ несёт тип передачи к оплате, и каждый из них выводит свою формулировку, потому что для
читающего они означают разное:
Контракт провайдера
Каждый адаптер реализует один интерфейс: имя, возможности, создание, опрос, возврат, проверка вебхука. Его уточняют четыре необязательных интерфейса — списать с сохранённого способа оплаты, найти счёт по референсу мерчанта, ограничить валюты расчётов и проверить учётные данные, не двигая деньги. Возможности — это{refunds, partialRefund, recurring, webhooks, polling}; они снимаются
снимком в каждое созданное адаптером намерение, поэтому изменившаяся позже возможность не может
переписать историю того, чем было намерение.
manual и telegram_stars зарегистрированы всегда. cryptobot регистрируется, когда задан
APP_CRYPTOBOT_TOKEN; yookassa — когда заданы и идентификатор магазина, и секрет. Пустые учётные
данные оставляют адаптер незарегистрированным, вместо того чтобы зарегистрировать сломанный.
Способ оплаты предлагается клиенту, только если установка его настроила и у тарифа есть цена в
валюте, которую этот способ проводит. Порядок отображения задан фиксированной картой: Telegram
Stars, YooKassa, CryptoBot, затем manual.
Настройка каждого провайдера, учётные данные и URL вебхуков — на странице Платёжные
провайдеры.
Приём вебхуков и идемпотентность
Обратные вызовы провайдеров принимает один публичный маршрут без аутентификации:POST /v1/payments/webhooks/{provider}. Подлинность обеспечивает проверка подписи в адаптере, а не
учётные данные на маршруте; тело ограничено 64 КиБ (более крупное получает 413): настоящие
уведомления весят несколько килобайт, а маршрут открыт всем.
- Названный адаптер должен быть зарегистрирован и объявлять поддержку вебхуков. Именно это
закрывает публичный маршрут для
telegram_starsиmanual: Telegram не подписывает обновления Bot API для публичного HTTP-маршрута, поэтому принимать там «вебхук Stars» означало бы принимать деньги без аутентификации. - Тело хешируется.
- Адаптер проверяет подпись. При неудаче строка всё равно записывается — с
signature_valid = false, идентификатором события, выведенным из дайджеста тела, и пустым телом (дайджест опознаёт повторяющийся мусор, а хранить всё, что прислал посторонний, значило бы отдать ему диск базы), — увеличиваетсяomniflow_provider_webhooks_total{outcome="signature_invalid"}, и возвращается400. - Событие вставляется в
provider_webhook_eventsпо(provider, provider_event_id). Если сохранённая строка уже читается какprocessedилиignored, запрос возвращаетduplicateи останавливается. Это и есть защита от дублей. - Только для YooKassa упомянутый платёж перезапрашивается через аутентифицированный API, и
полученные опросом статус и сумма заменяют то, что пришло в уведомлении: YooKassa не подписывает
свои обратные вызовы, поэтому подлинность устанавливается прямым вопросом провайдеру.
Перезапрос делается только для платёжного намерения, которое открыла эта установка:
уведомление о неизвестном платеже завершается как
failedсpayment_not_foundбез обращения к YooKassa, поэтому посторонний, присылающий корректно оформленные тела, не может заставить API тратить запросы к провайдеру. Неудавшийся перезапрос завершает строку какfailedсreconciliation_failedи ничего не применяет. - Проверенное событие, которое не является успехом, завершается как
ignored, а не как ошибка. - Намерение находится по референсу провайдера, с откатом на заказ, указанный в референсе мерчанта.
Отсутствие совпадения завершает строку как
failedсpayment_not_found. - Платёж применяется через общий путь проведения, и строка завершается как
processedс классификацией.
content-type и user-agent. Сохранение заголовка авторизации или подписи положило бы учётные
данные провайдера в базу; сохранение тела вместе с дайджестом — то, что делает спор разрешимым, а
повтор безопасным. Строки удаляются чисткой по срокам хранения через 30 дней после приёма.
Операторы могут посмотреть недавние события через GET /v1/panel/system/webhooks
(system.read).
Классификация платежей
Каждый проведённый провайдером платёж классифицируется относительно заказа, и каждая классификация записывается вpayment_events независимо от того, проводится заказ или нет. «С этим платежом
что-то не так» — это не одно условие, и такой словарь позволяет оператору поддержки прочитать, что
произошло, а не выводить это из двух значений статуса.
Повтор — не дубликат. Об одном списании регулярно сообщают больше одного раза: списание
сохранённым способом при автопродлении проводится синхронно, а затем провайдер присылает
payment.succeeded по тому же платежу; вебхук может прийти и после того, как сверка уже опросила
успех. Если намерение уже в succeeded, его заказ уже проведён, а сумма совпадает с намерением,
сообщение записывается как replay и ничего не меняет. duplicate остаётся для денег, пришедших на
проведённый заказ через другое намерение, — это второе списание, которое оператору нужно вернуть.
payment_events.type — закрытый набор, и отображение переименовывает paid в status_changed,
currency_mismatch — в amount_mismatch, replay — в reconciled, а paid_after_cancellation — в late; details каждого
события несёт классификацию и состояние заказа до платежа, поэтому лента по-прежнему отличает поздний
платёж от платежа, попавшего на отменённый заказ. Строка уникальна по
(payment_intent_id, provider_event_id, type) с трактовкой NULL как совпадающих значений, поэтому
одно и то же событие провайдера не может добавить одну и ту же запись дважды.
Пополнения — исключение. Пополнение не обязано ничем, кроме самих денег, поэтому переплата или
недоплата при operation = 'topup' принудительно считается paid, а на кошелёк зачисляется ровно
то, что пришло. Расхождение всё равно записывается платёжным событием и всё равно попадает в
операторское уведомление о пополнении.
Когда платёж не проводится
Классификация, не приводящая к проведению, всё равно фиксируется. Платёжное событие сохраняется надёжно, а заказ остаётся в своём состоянии, поэтому ничего не теряется молча. Каждая из них —duplicate, underpayment, overpayment, currency_mismatch и
wallet_unavailable — также ставит в очередь операторское уведомление в той же транзакции, вида
incident, с заказом, провайдером, классификацией и классом суммы. Оно дедуплицируется по платёжному
намерению и классификации, поэтому повторно присланный вебхук ничего не добавляет. То же уведомление
ставится для paid_after_cancellation, которая заказ проводит. См.
Операторские уведомления.
Проведение: что даёт оплаченный заказ
Проведение выполняется внутри транзакции, записавшей платёж, и ветвится по операции.
Путь подписки по порядку:
- Двойная проводка движения выручки.
- График права на услугу вычисляется из операции и политик тарифа: покупка начинается сейчас;
продление по запросу или автоматическое заканчивается в
max(сейчас, текущее окончание) + длительность; повышение тарифа либо заменяет, либо складывается; понижение вступает в силу немедленно или в момент текущего окончания. - Право на услугу создаётся с набором сквадов, подтверждённым клиентом, с откатом на собственный набор версии тарифа.
- Применяются строки дополнений, идемпотентно по заказу и версии дополнения.
- Создаётся операция исполнения с ключом
order:{orderID}:fulfillи желаемым состоянием в Remnawave. - Записывается строка
outbox_eventsс темойorder.paid. - В очередь ставится операторское уведомление, дедуплицированное по заказу.
- Реферальные вознаграждения начисляются в этой же транзакции, поэтому повторный вебхук никогда не начислит их дважды.
Строка
outbox_events записывается, но никогда не помечается опубликованной: сегодня ни один
процесс в репозитории не выставляет published_at. Считайте эту таблицу внутренней записью о
том, что было проведено, а не лентой событий, на которую можно подписаться.- Дополнение в середине периода, чья подписка исчезла между покупкой и проведением, не
откатывает деньги. Заказ остаётся оплаченным, записывается событие журнала аудита
addon.no_active_entitlementс категориейfinancialи исходомfailure, и в очередь ставится операторское уведомление. Автоматический возврат означал бы решение за клиента о том, чего он хотел. - Сохранённая корзина, освобождённая проведённым пополнением, оплачивается после коммита, потому что сбой корзины никогда не должен откатывать полученный платёж. См. Пополнение кошелька и сохранённые корзины.
Ручные платежи
У адаптераmanual нет внешней системы: он возвращает намерение в состоянии requires_action,
референс провайдера у которого — идентификатор заказа, а проведение происходит, когда оператор его
подтверждает. Он существует для банковских переводов, наличных и любого канала, который Omniflow не
интегрирует, а подтверждение попадает в журнал аудита, поэтому деньги привязаны к конкретному
оператору и к причине.
manual и сообщение о том, что оператор
подтвердит платёж. Заказ остаётся оплачиваемым семьдесят два часа с момента создания ручного
намерения — см. окно оплаты, — а подтверждение даже после этого окна всё равно
проводит его с классификацией late.
Возвраты
История платежей никогда не переписывается. Возврат — это новая строка вrefunds плюс новая
сбалансированная транзакция в книге проводок, поэтому запись о том, что было списано, сохраняется
нетронутой.
Рабочий путь по шагам:
- Отказать при отсутствии корректного ключа идемпотентности и непустой причины.
- Взять рекомендательную блокировку
refund:{paymentIntentID}, чтобы изменения возвратов по одному намерению выстраивались в очередь. - Отказать, если намерение не в состоянии
succeeded, если валюта не совпадает или если сумма не положительна либо превышает сумму намерения. - Отказать, если адаптер не зарегистрирован, не объявляет поддержку возвратов или у намерения нет референса провайдера. Поэтому CryptoBot нельзя вернуть вообще.
- Просуммировать уже имеющиеся у намерения возвраты в состояниях pending, processing и succeeded и отказать, если этот возврат уведёт сумму за размер намерения, — до обращения к провайдеру, потому что с провайдером, который принял избыточный возврат, потом придётся спорить.
- Сначала записать строку в
refunds—pending, без ссылки провайдера, уникальную по намерению и ключу идемпотентности, — чтобы возврат был зафиксирован, а его сумма зарезервирована до того, как уйдёт хоть один запрос. - Вызвать адаптер с ключом идемпотентности возврата и записать его ответ в строку: статус и ссылку провайдера. Только если провайдер сообщил об успехе, фиксируются движение в книге проводок и новое состояние заказа.
- Явный отказ на первый запрос — провайдер ответил
4xx, кроме408,409и429, или адаптер не умеет возвращать — помечает строкуfailed, что освобождает зарезервированную ею сумму. - Нет ответа — таймаут, недоступность,
5xxили падение процесса — оставляет её вpendingбез ссылки провайдера. Повтор запроса на возврат с тем же ключом идемпотентности снова спрашивает провайдера, и так же делает фоновая сверка — раз в минуту для каждой такой строки возрастом от минуты до суток. Провайдер узнаёт ключ, поэтому повторный вопрос не может вернуть деньги дважды. - Оживление, которое всё равно не получило ответа, ставит одно уведомление
incidentсо статусомrefund_outcome_unknown, дедуплицированное по возврату, чтобы оператор проверил исход у провайдера. Через сутки — столько провайдер хранит ключ идемпотентности — сверка перестаёт спрашивать.
refunded, когда суммарный возврат достигает paid_minor, и
partially_refunded во всех остальных случаях; в очередь ставится операторское уведомление.
Что забирает возврат
Возвращённые деньги не должны оставлять на месте то, что на них куплено, поэтому та же транзакция, что записывает возврат, и отменяет покупку:- Полный возврат заказа подписки (
purchase,extension,renewal,upgrade,downgrade, дошедший доrefunded) снимает купленный период с права на услугу, которое создал заказ. Любое расписание прибавляет длительность тарифа к базе, поэтому окончание сдвигается назад ровно на эту длительность: возвращённое продление откатывается к окончанию, которое было у клиента до него, а возвращённая покупка заканчивается сейчас. Уже использованное время не отнимается, а приостановленное право на услугу заканчивается в момент паузы. Операцияreconcileс ключомrefund:{refundID}:revokeпередаёт укороченное окончание в Remnawave, и тот завершает пользователя в это время плюс льготный период тарифа. Право на услугу, которое ещё не началось (понижениеat_expiry) или так и не было выдано, вместо этого выводится из обращения какsuperseded, а егоcreateотменяется при запуске. Право на услугу, которое уже вытеснило более позднее изменение, не трогается: возвращённый период теперь лежит под этим изменением, и уведомление о возврате об этом говорит. - Возврат пополнения кошелька списывает возвращённую сумму с кошелька (тип проводки
debit, ключrefund-debit:{refundID}), потому что именно эти средства ушли обратно. Если доступного баланса уже не хватает — клиент их потратил или их резервирует неоплаченный заказ, — ничего не списывается, поскольку книга проводок никогда не уходит в минус, и для оператора ставится уведомлениеincidentсо статусомtopup_refund_wallet_short. - Частичный возврат заказа подписки меняет только деньги: обычно это жест доброй воли, и менять ли доступ — решение оператора. Заказ, частично оплаченный из кошелька, через провайдера можно вернуть только частично, поэтому он в том же положении.
access — revoked, retired или kept, а wallet —
debited или short.
Ограничения конкретных провайдеров:
- Telegram Stars возвращает всё списание целиком или ничего и требует платящий аккаунт Telegram, который записывается только тогда, когда платёж был начат с поверхности, которая его знает. Платёж Stars, начатый в веб-панели, не несёт идентификатора Telegram и не может быть возвращён через адаптер.
- CryptoBot не имеет API возвратов.
- YooKassa поддерживает полные и частичные возвраты.
- Manual возвращает запись возврата в состоянии ожидания; деньги движутся так, как их отправил оператор.
GET /v1/account/orders/{orderID} включает до десяти
возвратов по намерениям заказа. Отмена — единственное изменение заказа, доступное клиенту, и после
состояния pending она отклоняется с 409 order_not_cancellable.
Сверка и зависшие платежи
Вебхук может потеряться, задержаться или быть заблокирован файрволом, поэтому Omniflow ещё и сам спрашивает провайдеров, что произошло. Поскольку сверка применяет полученный ответ через тот же идемпотентный путь проведения, она может работать рядом с вебхуком, не начисляя дважды.
Сверщик выбирает намерения в состояниях
pending или processing, принадлежащие cryptobot или
yookassa, имеющие референс провайдера и не тронутые в течение минуты. Сверка отказывает, если
адаптер не объявляет опрос; если удалённый статус — успех, платёж записывается с идентификатором
события reconcile:{providerReference}, а статус, референс и URL оплаты намерения обновляются в
любом случае.
Клиентская кнопка Обновить действует только в фазах pending или awaiting_action и
проглатывает ошибку неподдерживающего адаптера. Для Telegram Stars, который вообще нельзя
опрашивать, Обновить поэтому не делает ничего: платёж проводится из собственного потока
обновлений бота или не проводится вовсе.
/admin/finance → Зависшие перечисляет намерения, находящиеся в полёте больше двух часов, с
указанием их возраста.
Что видит оператор
У/admin/finance три вкладки, потому что это один и тот же вопрос на трёх стадиях: деньги
ожидаемые, деньги зависшие, деньги, которые не пришли.
- Заказы. Фильтр по состоянию и операции, курсорная пагинация, экспорт в CSV. Столбцы: дата
создания, клиент, операция, значок состояния, оплачено, возвращено. Строка открывает заказ.
GET /v1/panel/finance/orders— право доступаfinance.read. - Зависшие. Очередь сверки, описанная выше.
- Списания. Неудачные попытки автоматического продления с номером попытки, источником средств, исходом и кодом ошибки. Это поверхность только для просмотра: отсюда ничто не перезапускает списание, потому что клиенту уже сказали продлить, и повторное списание по клику в панели было бы попыткой, о которой он не просил.
paidMinor − refundedMinor и требующую причину, — с учётом двух предупреждений выше. Сырое тело от
провайдера намеренно отсутствует: оно хранится для повтора и разбора спора и доступно через
диагностику вебхуков под собственным правом доступа.
Изменения из панели несут своё обоснование в заголовке X-Operator-Reason, а не в теле каждого
запроса; чтение требует finance.read, а сверка и возврат — finance.write. Права доступа
объявлены там, где монтируются маршруты, а не внутри обработчиков, поэтому добавленный ранний
return не может их обойти.
renewal отсутствует в фильтре операций финансового браузера, поэтому заказы продления нельзя
отфильтровать по операции в /admin/finance, хотя столбец несёт это значение. Они всё равно
видны без фильтра и попадают в экспорт CSV.orders.csv со стабильным порядком столбцов:
более поздняя версия добавляет столбец в конец и никогда не вставляет его в середину:
Метрики
Деньги покрывают два счётчика, и оба маркируют только ограниченные наборы значений — идентификатор никогда не становится меткой:outcome — это либо signature_invalid, либо классификация проведения, поэтому серия отклонённых
подписей и серия повторных доставок оказываются двумя разными линиями на одном графике. См.
Наблюдаемость.