Перейти к содержанию

ADR 0004: Единый платёжный endpoint POST /intents

Дата: 2026-06-06 Статус: Accepted

На какие вопросы отвечает

  • Через какой API делается любой платёж в OneWallet?
  • Почему P2P-перевод, оплата QR-инвойса, NFC-списание, пополнение и комиссия миниаппа идут в один и тот же endpoint?
  • Чем различаются вызывающие (Auth Center, nginx, mini-app, будущие PSP) на уровне API?
  • Что отличает один платёж от другого, если endpoint один?
  • Почему INTERNAL-перевод завершается сразу, а внешний возвращается «недоделанным»?

Контекст

Деньги в OneWallet пишутся только в TigerBeetle, и единственный write-клиент к нему — Payment Manager (см. ADR 0001). К PM обращаются разные источники:

  • Auth Center (Serverpod) — P2P, инициация QR/NFC из приложений;
  • хостовой nginx-ingress (/api/pm/*, service-id nginx-gateway);
  • mini-app backends (charge/credit);
  • в перспективе — PSP-инициированные потоки.

Альтернатива — по отдельному endpoint на каждую операцию (/p2p, /withdrawal, /invoice, /nfc…) — была отвергнута: дублирование валидации, лимитов, расчёта комиссий, аудита (intent_event) и связи с леджером в N местах; рост площади атаки; рассинхрон контрактов между сервисами.

Решение

Все платежи проходят через один endpoint — POST /intents (источник: один обработчик app.post('/intents', …) в projects/payment-manager/src/intent/handler.ts). Тип операции передаётся полем operationType, а не разными путями.

Поддерживаемые operationType (11): P2P_TRANSFER, IPPS_WITHDRAWAL, THAI_QR_PAY, WITHDRAWAL, QP_TOPUP, MINIAPP_CHARGE, MINIAPP_CREDIT, SERVICE_DEPOSIT, ADMIN_TRANSFER, INVOICE_PAYMENT, NFC_CHARGE.

Единые свойства endpoint для всех вызывающих:

  • Единая аутентификация — HMAC-SHA256 (X-Service-Id/X-Timestamp/X-Signature), без «доверенных» исключений (см. ADR 0002).
  • Единая воронка — валидация → лимиты (limit_rule) → расчёт комиссий (fee_rule) → выбор канала (payment_route по operationType+amount) → запись в TigerBeetle (two-phase) → аудит в intent / intent_event.
  • Единый трейсингtrace_id = intent_id в каждом событии.

Вызывающие различаются только значением X-Service-Id (свой HMAC-секрет) и полезной нагрузкой; бизнес-логика и контракт одни на всех.

Один endpoint — две развязки финализации

POST /intents создаёт intent и сразу прогоняет его по state machine (9 состояний). Финал зависит от выбранного канала:

  • INTERNAL (например P2P_TRANSFER): CREATED → … → SETTLED синхронно, в одном HTTP-запросе.
  • Внешние PSP-каналы (IPPS/QP): запрос возвращается на AUTHORIZED с requiresMonitoring=true, финал приходит асинхронно через Redis pub/sub intent.{id}.

Особый случай: INVOICE_PAYMENT создаётся через тот же POST /intents (продавец резервирует сумму, плательщик неизвестен до подтверждения), а завершается отдельными POST /intents/:id/confirm | /cancel. См. ADR 0006 и dev-доку ниже.

flowchart LR
  AC[Auth Center] -->|HMAC| EP
  NG[nginx-gateway] -->|HMAC| EP
  MA[mini-app backend] -->|HMAC| EP
  PSP[PSP flows] -->|HMAC| EP
  EP[POST /intents\noperationType=...] --> SM{channel?}
  SM -->|INTERNAL| S[SETTLED синхронно]
  SM -->|PSP| A[AUTHORIZED\nrequiresMonitoring=true]
  A -.->|Redis pub/sub intent.id| F[SETTLED / FAILED]
  S --> TB[(TigerBeetle)]
  F --> TB

Пример

P2P-перевод из приложения (Auth Center как подписант). Тело запроса:

{
  "operationType": "P2P_TRANSFER",
  "fromAccountName": "user-1042-main",
  "recipientUserId": 2087,
  "amount": "150.00",
  "currency": "THB"
}

P2P_TRANSFER маршрутизируется на канал INTERNAL → ответ приходит уже со статусом SETTLED в том же запросе. Если бы тот же endpoint вызвали с operationType=IPPS_WITHDRAWAL, ответ вернулся бы на AUTHORIZED c requiresMonitoring=true, а финал — событием intent.{id} в Redis.

Последствия

Плюсы: - Один контракт и одна реализация валидации/лимитов/комиссий/аудита — меньше дублирования и рассинхрона. - Новая операция = новая запись payment_route + ветка в реестре операций, а не новый endpoint и новый клиент. - Единая граница безопасности: один HMAC-механизм, одна точка логирования и идемпотентности.

Минусы / ограничения: - Endpoint «полиморфен» — поля payload зависят от operationType (например, у INVOICE_PAYMENT плательщик неизвестен на этапе create). Валидация per-operation обязательна. - Две модели завершения (sync INTERNAL vs async PSP) в одном API — вызывающий обязан корректно обрабатывать requiresMonitoring и подписку на intent.{id}. - Любая ошибка в общей воронке затрагивает все операции — высокие требования к тестам.

Связанные документы