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

Intents API — платёжные интенты

Документация всех HTTP-эндпоинтов модуля src/intent/ Payment Manager. Здесь — единый платёжный вход (POST /intents), его вспомогательные endpoint'ы (quote, confirm, cancel, get, events) и контракт авторизации.

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

Общие правила

Регистрация роутов

Все маршруты ниже регистрируются в src/server.ts внутри scope с hmacPlugin:

src/server.ts:152-163
  scope.register(hmacPlugin)
  scope.register(intentRoutes)    // POST /intents, GET /intents/:id, GET /intents/:id/events
  scope.register(confirmRoutes)   // POST /intents/:id/confirm
  scope.register(cancelRoutes)    // POST /intents/:id/cancel
  scope.register(quoteRoutes)     // POST /intents/quote

Auth: HMAC-SHA256

Каждый запрос обязан содержать четыре заголовка (см. hmacPlugin):

Заголовок Назначение
X-Service-Id Идентификатор сервиса-вызывающего (auth-center, nginx, admin-panel)
X-Timestamp Unix-секунды, окно ±60s от now()
X-Signature HMAC-SHA256(secret, "${X-Timestamp}\n${method}\n${path}\n${rawBody}") в hex
X-User-Id Идентификатор пользователя Serverpod (целое число; 0 — admin bypass)

Секрет хранится в service_key.secret — единый для всех сервисов, ротация через /admin/service-keys. Без HMAC → 401 INVALID_SIGNATURE или 401 INVALID_TIMESTAMP.

Разрешения сервиса (service_key.permissions)

Дополнительный слой авторизации поверх HMAC:

Поле Эффект
permissions.allowedOperationTypes[] Whitelist operationType. Пустой массив = разрешено всё
permissions.fromAccountOverride Разрешает body.fromAccountName
permissions.toAccountOverride Разрешает body.toAccountName
permissions.allowToTbAccountId Разрешает body.toTbAccountId

При нарушении — 403 FORBIDDEN. Подробнее: 06-service-keys.md.

Денежные единицы

Все суммы — целые сатанги (1 THB = 100 satang) в типе bigint. В JSON приходят и уходят строками, чтобы не терялись большие значения. Валюта — THB по умолчанию (ISO 4217).


1. POST /intents — создать платёжный интент

Назначение

Единственный платёжный вход PM. Принимает запрос на любую операцию (P2P_TRANSFER, IPPS_WITHDRAWAL, THAI_QR_PAY, MINIAPP_CHARGE, MINIAPP_CREDIT, WITHDRAWAL, QP_TOPUP, SERVICE_DEPOSIT, ADMIN_TRANSFER, NFC_CHARGE, INVOICE_PAYMENT), маршрутизирует её через канал (channels/*), создаёт запись в pm.intent, запускает шаги канала. Для одно-фазных каналов — синхронно проводит платёж (CREATED → VALIDATED → AUTHORIZED → SETTLED). Для двухфазных (MERCHANT_INVOICE) — резервирует QR/expiresAt и ждёт POST /intents/:id/confirm.

Auth

HMAC + X-User-Id. userId записывается в intent.userId и используется как актор для лимитов и auth-policies. Сервис должен иметь operationType в permissions.allowedOperationTypes (или пустой whitelist). Override полей fromAccountName/toAccountName/toTbAccountId — только при соответствующих permission'ах.

Request body

Zod-схема: BaseIntentBody (см. 01-dto-contracts.md#11-baseintentbody).

Поле Тип Обязательность Описание
idempotencyKey uuid v4 да Идемпотентность по (idempotencyKey, serviceId). При повторе — HTTP 200 с тем же intent'ом
operationType string да Тип операции (см. список выше)
amount int > 0 да Сумма в сатангах
currency string(3) нет (default THB) ISO 4217
metadata object нет (default {}) Operation-specific: ipps, customerUserId, recipientUserId, tags, ttlSeconds, appScope для invoice
fromAccountName string условно Override источника. Требует fromAccountOverride permission
toAccountName string условно Override получателя. Требует toAccountOverride permission
toTbAccountId uuid условно Получатель по TB-UUID. Требует allowToTbAccountId permission
fromName string<=255 нет Отображаемое имя плательщика (для истории и чеков)
toName string<=255 нет Отображаемое имя получателя
comment string<=500 нет Свободный комментарий
live boolean нет Если true — PM публикует переходы статусов в Redis-канал intent.{id}
externalRef string условно Внешний ID; обязателен для WITHDRAWAL, запрещён для P2P_TRANSFER
recipientUserId int > 0 условно Serverpod userId получателя; обязателен для P2P_TRANSFER (если нет toTbAccountId); запрещён для WITHDRAWAL/MINIAPP_CHARGE

Operation-specific поля валидируются opDef.parseBody(req.body) (handler.ts:103-105) — для каждого operationType своя дискриминированная схема.

Response 201 / 200

Zod-схема: CreateIntentResponse (handler.ts:31-44, см. 01-dto-contracts.md#14-createintentresponse).

  • 201 Created — новый intent;
  • 200 OK — idempotency replay (тот же (idempotencyKey, serviceId) уже обработан).
Поле Тип Описание
intentId uuid ID созданного интента
status enum CREATED | VALIDATED | AUTHORIZED | SETTLING | SETTLED | FAILED | MANUAL_REVIEW | CANCELED | EXPIRED
channel string Канал, выбранный роутером (INTERNAL_P2P, IPPS_TRANSFER, MERCHANT_INVOICE, ...)
amount string Сумма в сатангах (только при idempotency replay)
currency string Валюта (только при idempotency replay)
preFeeAmount string Сумма PRE-комиссий (взимается с отправителя)
postFeeAmount string Сумма POST-комиссий (вычитается из получателя)
requiresMonitoring boolean true если интент остался в AUTHORIZED (надо подписаться на Redis-канал intent.{id} за статусом)
createdAt string ISO 8601
expiresAt string Только для двухфазных (MERCHANT_INVOICE) — таймаут оплаты
qrSignature string Только для MERCHANT_INVOICE — base64url HMAC-SHA256 16 байт, подпись QR
version int Optimistic concurrency version (важна для confirm с If-Match)

Возможные ошибки

HTTP code Причина
400 BAD_REQUEST Отсутствует обязательное поле (X-User-Id, fromAccountName без override)
400 NO_ROUTE Нет строки в payment_route для пары (operationType, amount)
401 INVALID_SIGNATURE HMAC не совпал
401 INVALID_TIMESTAMP X-Timestamp вне окна ±60 секунд
403 FORBIDDEN Сервис неактивен / operationType не в whitelist / override не разрешён
422 VALIDATION_ERROR Zod не распарсил body
422 ACCOUNT_NOT_FOUND Не найден from/to/transit аккаунт
422 IPPS_NOT_REGISTERED Получатель IPPS не зарегистрирован у PSP
422 IPPS_METADATA_INVALID Невалидная структура metadata.ipps
422 INSUFFICIENT_FUNDS TigerBeetle отказал в pending: на источнике недостаточно средств
422 LIMIT_EXCEEDED Превышен лимит из auth-policies. В detail{ruleName, window, limitType, limit, current, requested}
500 TB_TRANSFER_ERROR TigerBeetle отверг трансфер по иной причине

Полный реестр — 05-error-codes.md.

curl-пример

P2P-перевод 150 THB между двумя пользователями:

TS=$(date +%s)
METHOD="POST"
PATH="/intents"
BODY='{"idempotencyKey":"6f1b3c5e-1234-4abc-9def-0123456789ab","operationType":"P2P_TRANSFER","amount":15000,"currency":"THB","recipientUserId":42,"comment":"Lunch split"}'
SECRET="<service-secret>"
SIG=$(printf "%s\n%s\n%s\n%s" "$TS" "$METHOD" "$PATH" "$BODY" | openssl dgst -sha256 -hmac "$SECRET" -hex | cut -d' ' -f2)

curl -X POST https://api.onewallet.local/intents \
  -H "Content-Type: application/json" \
  -H "X-Service-Id: auth-center" \
  -H "X-Timestamp: $TS" \
  -H "X-Signature: $SIG" \
  -H "X-User-Id: 17" \
  --data "$BODY"

Ответ (одно-фазный канал INTERNAL_P2P, синхронный SETTLED):

{
  "intentId": "8a2d50c4-7b91-4e6f-9d12-3c4e5f6a7b8c",
  "status": "SETTLED",
  "channel": "INTERNAL_P2P",
  "preFeeAmount": "0",
  "postFeeAmount": "0",
  "createdAt": "2026-05-29T17:42:11.103Z",
  "requiresMonitoring": false
}

Связанный код


2. POST /intents/quote — preview PRE-комиссий

Назначение

Без записи в БД: рассчитать сумму PRE-комиссий для операции. Используется UI чтобы показать «итого к списанию» перед нажатием «отправить». Файл отдельныйsrc/intent/quote.ts, не параметр у POST /intents.

Auth

HMAC. X-User-Id не используется (расчёт не зависит от пользователя). Любой сервис с валидным HMAC.

Request body

Zod-схема: QuoteBody (quote.ts:6-11, см. 01-dto-contracts.md#21-quotebody).

Поле Тип Обязательность Описание
operationType enum да P2P_TRANSFER | IPPS_WITHDRAWAL | THAI_QR_PAY | WITHDRAWAL | MINIAPP_CHARGE | MINIAPP_CREDIT | QP_TOPUP | SERVICE_DEPOSIT | ADMIN_TRANSFER | INVOICE_PAYMENT
amount int > 0 да Сумма в сатангах, для которой считаются комиссии
currency string(3) нет (default THB) ISO 4217
metadata object нет (default {}) Передаётся в rule-engine для матчинга правил (tags, признаки операции)

Response 200

Zod-схема: QuoteResponse (quote.ts:13-20, см. 01-dto-contracts.md#22-quoteresponse).

Поле Тип Описание
fee string Итоговая сумма PRE-комиссий в сатангах
totalCharge string amount + fee — то, что будет списано с отправителя
breakdown array Разбивка по получателям комиссии: [{ account: "fee.platform", amount: "150" }, ...]

Возможные ошибки

HTTP code Причина
401 INVALID_SIGNATURE HMAC
422 VALIDATION_ERROR Тело не прошло Zod (details содержит массив ошибок Zod, см. 05-error-codes.md)

curl-пример

TS=$(date +%s)
BODY='{"operationType":"IPPS_WITHDRAWAL","amount":500000,"currency":"THB","metadata":{"tags":["business"]}}'
SIG=$(printf "%s\nPOST\n/intents/quote\n%s" "$TS" "$BODY" | openssl dgst -sha256 -hmac "$SECRET" -hex | cut -d' ' -f2)

curl -X POST https://api.onewallet.local/intents/quote \
  -H "Content-Type: application/json" \
  -H "X-Service-Id: flutter-app" \
  -H "X-Timestamp: $TS" \
  -H "X-Signature: $SIG" \
  -H "X-User-Id: 17" \
  --data "$BODY"

Ответ:

{
  "fee": "1500",
  "totalCharge": "501500",
  "breakdown": [
    { "account": "fee.platform.THB",  "amount": "1000" },
    { "account": "fee.psp.IPPS.THB",  "amount": "500"  }
  ]
}

Связанный код


3. POST /intents/:id/confirm — подтверждение MERCHANT_INVOICE плательщиком

Назначение

Двухфазный flow: мерчант создал инвойс через POST /intents (operationType=INVOICE_PAYMENT) → получил QR с qrSignature/expiresAt. Покупатель сканирует QR, его клиент вызывает этот endpoint, передавая свой payerTbAccountId и payerUserId. PM атомарно проводит CREATED → VALIDATED → AUTHORIZED → SETTLED через P2P-сагу на TigerBeetle.

Auth

HMAC. X-User-Id — id покупателя (тот же, что в теле payerUserId). Поддерживает заголовок If-Match: <version> для optimistic concurrency на колонке intent.version (confirm-handler.ts:84-86). Если CAS не сработал → 409 VERSION_MISMATCH с currentVersion.

Request body

Zod-схема: ConfirmBody (confirm-handler.ts:28-32, см. 01-dto-contracts.md#31-confirmbody).

Поле Тип Обязательность Описание
payerTbAccountId uuid да TigerBeetle UUID счёта покупателя (tb_account_map.tbAccountId)
payerUserId int > 0 да Serverpod userId покупателя; сверяется с account.userId
idempotencyKey uuid да Защита от двойного нажатия — повтор → текущий статус, replayed: true

Заголовок:

Заголовок Обязательность Описание
If-Match нет Ожидаемое значение intent.version. Если не совпадает → 409 VERSION_MISMATCH. Без заголовка — CAS пропускается

Response 200

Zod-схема: см. 01-dto-contracts.md#33-confirmresponse.

Поле Тип Описание
intentId uuid ID инвойса
status enum SETTLED (норма) или FAILED (при ошибке redeem)
channel string MERCHANT_INVOICE
createdAt string ISO 8601 — когда инвойс был создан
version int Новая версия после успешного UPDATE
replayed boolean true если это idempotency-replay по тому же idempotencyKey + payerUserId

Возможные ошибки

HTTP code Причина
400 PAYER_ACCOUNT_USER_MISMATCH payerUserId не равен account.userId
400 VALIDATION_ERROR Невалидное тело
401 INVALID_SIGNATURE HMAC
404 INTENT_NOT_FOUND По id нет инвойса
404 PAYER_ACCOUNT_NOT_FOUND По payerTbAccountId нет счёта
409 ALREADY_PROCESSED Инвойс уже не в статусе CREATED (в теле — {status: <текущий>})
409 EXPIRED Инвойс истёк (expiresAt < now())
409 VERSION_MISMATCH CAS на version не сработал. В теле — {currentVersion}
500 TB_TRANSFER_ERROR TigerBeetle отверг redeem-трансфер

Подробнее: 05-error-codes.md#3-already_processed, 05-error-codes.md#4-version_mismatch.

curl-пример

TS=$(date +%s)
INTENT_ID="8a2d50c4-7b91-4e6f-9d12-3c4e5f6a7b8c"
BODY='{"payerTbAccountId":"11112222-3333-4444-5555-666677778888","payerUserId":42,"idempotencyKey":"af3b4c5d-6e7f-4890-abcd-ef1234567890"}'
SIG=$(printf "%s\nPOST\n/intents/%s/confirm\n%s" "$TS" "$INTENT_ID" "$BODY" | openssl dgst -sha256 -hmac "$SECRET" -hex | cut -d' ' -f2)

curl -X POST "https://api.onewallet.local/intents/${INTENT_ID}/confirm" \
  -H "Content-Type: application/json" \
  -H "X-Service-Id: flutter-app" \
  -H "X-Timestamp: $TS" \
  -H "X-Signature: $SIG" \
  -H "X-User-Id: 42" \
  -H "If-Match: 0" \
  --data "$BODY"

Ответ:

{
  "intentId": "8a2d50c4-7b91-4e6f-9d12-3c4e5f6a7b8c",
  "status": "SETTLED",
  "channel": "MERCHANT_INVOICE",
  "createdAt": "2026-05-29T17:30:00.000Z",
  "version": 1
}

Связанный код


4. POST /intents/:id/cancel — отмена MERCHANT_INVOICE

Назначение

Только инициатор инвойса (intent.issuedByUserId) или admin (X-User-Id: 0) может отменить инвойс в статусе CREATED. Атомарный UPDATE → CANCELED. Если инвойс уже не в CREATED (оплачен, отменён, истёк) — 409 CANNOT_CANCEL.

Auth

HMAC. Обязательно заголовок X-User-Id — идентификатор актора. PM сравнивает с intent.issuedByUserId; при X-User-Id: 0 — admin bypass. Любой другой userId → 403 UNAUTHORIZED_ACTOR.

Request body

Zod-схема: CancelBody (cancel-handler.ts:19-21, см. 01-dto-contracts.md#41-cancelbody).

Поле Тип Обязательность Описание
reason string(1..50) нет (default merchant_canceled) Причина отмены — записывается в intent_event.reason

Тело можно не передавать вовсе — будет {reason: 'merchant_canceled'}.

Response 200

Zod-схема: см. 01-dto-contracts.md#43-cancelresponse.

Поле Тип Описание
intentId uuid ID инвойса
status enum CANCELED
channel string MERCHANT_INVOICE
preFeeAmount string "0" (комиссии не считаются для отменённого инвойса)
postFeeAmount string "0"
requiresMonitoring boolean false
createdAt string ISO 8601

Возможные ошибки

HTTP code Причина
400 CANCEL_NOT_SUPPORTED Канал инвойса не двухфазный (диагностика — никогда не должно встречаться)
400 VALIDATION_ERROR X-User-Id не задан
401 INVALID_SIGNATURE HMAC
403 UNAUTHORIZED_ACTOR actorUserIdissuedByUserId и не admin
404 INTENT_NOT_FOUND По id нет инвойса
409 CANNOT_CANCEL Инвойс не в статусе CREATED (в теле — {status: <текущий>})

curl-пример

TS=$(date +%s)
INTENT_ID="8a2d50c4-7b91-4e6f-9d12-3c4e5f6a7b8c"
BODY='{"reason":"customer_changed_mind"}'
SIG=$(printf "%s\nPOST\n/intents/%s/cancel\n%s" "$TS" "$INTENT_ID" "$BODY" | openssl dgst -sha256 -hmac "$SECRET" -hex | cut -d' ' -f2)

curl -X POST "https://api.onewallet.local/intents/${INTENT_ID}/cancel" \
  -H "Content-Type: application/json" \
  -H "X-Service-Id: flutter-app" \
  -H "X-Timestamp: $TS" \
  -H "X-Signature: $SIG" \
  -H "X-User-Id: 17" \
  --data "$BODY"

Ответ:

{
  "intentId": "8a2d50c4-7b91-4e6f-9d12-3c4e5f6a7b8c",
  "status": "CANCELED",
  "channel": "MERCHANT_INVOICE",
  "preFeeAmount": "0",
  "postFeeAmount": "0",
  "requiresMonitoring": false,
  "createdAt": "2026-05-29T17:30:00.000Z"
}

Связанный код

⚠️ Не путать с POST /admin/intents/:id/resolve — там админ принудительно разрешает зависший интент (см. admin.md).


5. GET /intents/:id — получить статус интента

Назначение

Чтение интента по id. Сервис видит только свои интенты (по intent.serviceId). Используется клиентом для polling-fallback (если Redis subscription недоступна) и админ-панелью (через её собственный service-key).

Auth

HMAC. intent.serviceId обязан совпадать с X-Service-Id. Иначе — 404 (намеренно не 403, чтобы не утекала информация о существовании интентов другого сервиса).

Path параметры

Параметр Тип Описание
id uuid ID интента

Response 200

Zod-схема: IntentDetails (handler.ts:56-69).

Поле Тип Описание
intentId uuid ID интента
status enum Текущий статус (см. полный список в POST /intents)
operationType string Тип операции
channel string Канал, выбранный роутером
amount string Сумма в сатангах (BigInt → string)
currency string Валюта
preFeeAmount string PRE-комиссия
postFeeAmount string POST-комиссия
fromAccount string | null Имя счёта-источника (null для невыполненного MERCHANT_INVOICE до подтверждения)
toAccount string Имя счёта-получателя
createdAt string ISO 8601
updatedAt string ISO 8601

Возможные ошибки

HTTP code Причина
401 INVALID_SIGNATURE HMAC
404 NOT_FOUND Интент не существует или принадлежит другому сервису

curl-пример

TS=$(date +%s)
INTENT_ID="8a2d50c4-7b91-4e6f-9d12-3c4e5f6a7b8c"
SIG=$(printf "%s\nGET\n/intents/%s\n" "$TS" "$INTENT_ID" | openssl dgst -sha256 -hmac "$SECRET" -hex | cut -d' ' -f2)

curl "https://api.onewallet.local/intents/${INTENT_ID}" \
  -H "X-Service-Id: auth-center" \
  -H "X-Timestamp: $TS" \
  -H "X-Signature: $SIG" \
  -H "X-User-Id: 17"

Ответ:

{
  "intentId": "8a2d50c4-7b91-4e6f-9d12-3c4e5f6a7b8c",
  "status": "SETTLED",
  "operationType": "P2P_TRANSFER",
  "channel": "INTERNAL_P2P",
  "amount": "15000",
  "currency": "THB",
  "preFeeAmount": "0",
  "postFeeAmount": "0",
  "fromAccount": "user.17.wallet.THB",
  "toAccount": "user.42.wallet.THB",
  "createdAt": "2026-05-29T17:42:11.103Z",
  "updatedAt": "2026-05-29T17:42:11.250Z"
}

Связанный код


6. GET /intents/:id/events — audit-лог переходов

Назначение

Возвращает полный лог переходов статусов интента (pm.intent_event), в порядке возрастания createdAt. Используется для:

  • диагностики (почему интент FAILED — в каком переходе);
  • audit-trail для regulator-отчётов;
  • админ-панели (визуализация state machine);
  • автотестов: проверить последовательность статусов после операции.

Каждая строка intent_event пишется через writeIntentEvent() при каждом изменении статуса. Полный реестр типов событий: 04-event-types.md.

Auth

HMAC. Как и в GET /intents/:id, intent.serviceId обязан совпадать с X-Service-Id. Иначе — 404.

Path параметры

Параметр Тип Описание
id uuid ID интента

Response 200

Zod-схема: IntentEventsResponse (handler.ts:71-80).

Поле Тип Описание
events array Массив объектов IntentEvent (см. ниже)
events[].id int Auto-increment ID строки
events[].statusFrom string|null Предыдущий статус (null для самого первого CREATED-события)
events[].statusTo string Новый статус после перехода
events[].reason string|null Текст причины (заполняется для FAILED и CANCELED событий)
events[].payload object|null Доп. полезная нагрузка (PSP state changes, outbox-метаданные, см. 04-event-types.md)
events[].createdAt string ISO 8601

Возможные ошибки

HTTP code Причина
401 INVALID_SIGNATURE HMAC
404 NOT_FOUND Интент не существует или принадлежит другому сервису

curl-пример

TS=$(date +%s)
INTENT_ID="8a2d50c4-7b91-4e6f-9d12-3c4e5f6a7b8c"
SIG=$(printf "%s\nGET\n/intents/%s/events\n" "$TS" "$INTENT_ID" | openssl dgst -sha256 -hmac "$SECRET" -hex | cut -d' ' -f2)

curl "https://api.onewallet.local/intents/${INTENT_ID}/events" \
  -H "X-Service-Id: admin-panel" \
  -H "X-Timestamp: $TS" \
  -H "X-Signature: $SIG" \
  -H "X-User-Id: 0"

Ответ:

{
  "events": [
    { "id": 1051, "statusFrom": null,         "statusTo": "CREATED",    "reason": null, "payload": null, "createdAt": "2026-05-29T17:42:11.103Z" },
    { "id": 1052, "statusFrom": "CREATED",    "statusTo": "VALIDATED",  "reason": null, "payload": null, "createdAt": "2026-05-29T17:42:11.150Z" },
    { "id": 1053, "statusFrom": "VALIDATED",  "statusTo": "AUTHORIZED", "reason": null, "payload": null, "createdAt": "2026-05-29T17:42:11.220Z" },
    { "id": 1054, "statusFrom": "AUTHORIZED", "statusTo": "SETTLED",    "reason": null, "payload": null, "createdAt": "2026-05-29T17:42:11.250Z" }
  ]
}

Связанный код


Сводная таблица endpoint'ов

Endpoint Method Зачем Idempotent? Заголовки сверх HMAC Файл
/intents POST Создать платёжный intent да (по idempotencyKey+serviceId) X-User-Id (обязателен) handler.ts:84
/intents/quote POST Preview PRE-комиссий да (нет записи) quote.ts:28
/intents/:id/confirm POST Покупатель оплачивает invoice да (по idempotencyKey+payerUserId) X-User-Id, опционально If-Match confirm-handler.ts:50
/intents/:id/cancel POST Отменить invoice идемпотентен при повторе (CONFLICT) X-User-Id (issuer / admin) cancel-handler.ts:34
/intents/:id GET Получить статус n/a handler.ts:449
/intents/:id/events GET Audit-лог переходов n/a handler.ts:492

Смежные документы