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

Cookbook: Выставить мерчант-инвойс (MERCHANT_INVOICE)

Полный сквозной сценарий двухфазного платежа MERCHANT_INVOICE: мерчант создаёт инвойс с QR-подписью, покупатель сканирует QR и подтверждает оплату, либо инвойс отменяется/истекает по TTL.

Это не «как добавить канал», а пошаговое практическое руководство для интегратора, который пишет код на стороне мерчанта или Auth Center.

1. Когда использовать

  • Мерчант хочет получить оплату через QR-код (POS, чек, экранный QR).
  • Сумма и валюта известны заранее (фиксированный инвойс).
  • Покупатель неизвестен в момент создания инвойса — определится только в confirm.

Не использовать, если: - Нужен P2P-перевод между двумя известными пользователями → P2P_TRANSFER (channel=INTERNAL_P2P, см. ./add-operation-type.md). - Нужно списание в пользу mini-app, где покупатель уже авторизован → MINIAPP_CHARGE.

2. Архитектура flow

Merchant ─── POST /intents (INVOICE_PAYMENT, HMAC) ──► PM
                            channel = MERCHANT_INVOICE │ resolveChannel() / pm.payment_route (0009)
                                          reserve()   │ INSERT intent CREATED + qr_signature + expires_at
                                                       │ (NO TigerBeetle — два-фазный канал без pending)
Merchant ◄── { intentId, qrSignature, expiresAt } ─────┘
   │ строит QR-payload и показывает покупателю
Customer (Flutter / Auth Center) ─── POST /intents/:id/confirm ──► PM
                                     payerTbAccountId               │
                                     payerUserId                    │ CREATED → VALIDATED (atomic UPDATE)
                                     idempotencyKey                 │ → AUTHORIZED → SETTLED
                                     If-Match: <version> (опц.)     │ runP2pSaga() в TigerBeetle
                                                          { status: 'SETTLED', version: N+1 }

Альтернатива:                                                   Альтернатива:
POST /intents/:id/cancel  ──► CANCELED                        invoice-expiry sweep
(merchant / admin)                                            ──► EXPIRED после expires_at

Реальные статусы интента: CREATED, VALIDATED, AUTHORIZED, SETTLING, SETTLED, FAILED, MANUAL_REVIEW, CANCELED, EXPIRED. Канал — строго MERCHANT_INVOICE (см. src/channels/merchant-invoice.ts).

3. Полный invoice-flow

Шаг 1. Мерчант создаёт инвойс

Запрос на единый endpoint POST /intents с operationType=INVOICE_PAYMENT. Канал НЕ передаётся — он автоматически резолвится через pm.payment_route (миграция 0009_*).

Что делает PM (src/intent/handler.ts): 1. Парсит body согласно INVOICE_PAYMENT operation-type. 2. Проверяет HMAC-подпись и сервисный ключ (allowedOperationTypes). 3. Идемпотентность: ключ (idempotencyKey, serviceId) — повтор возвращает существующий интент. 4. resolveChannel('INVOICE_PAYMENT', amount)MERCHANT_INVOICE. 5. isInvoiceCreate=truefromAccountName=null (покупатель ещё неизвестен), лимиты НЕ проверяются. 6. INSERT intent в статусе CREATED, issuedByUserId = userId мерчанта. 7. channel.reserve({ intent, ttlSeconds, appScope }) — НЕ создаёт TB pending, только: - вычисляет expiresAt = now + ttlSeconds (по умолчанию INVOICE_DEFAULT_TTL_SECONDS); - подписывает QR-payload через signQrPayload() (см. ниже); - UPDATE intent SET expires_at, qr_signature, reserved_at. 8. Возвращает 201 с intentId, status=CREATED, expiresAt, qrSignature (base64url), version=0.

Шаг 2. QR-payload и подпись

QR-подпись (src/shared/qr-signature.ts):

HMAC-SHA256(INVOICE_QR_SECRET, canonical)[:16]   // 16 байт, base64url
canonical = intentId|merchantTbAccountId|amountSatang|currency|expiresAtUnix|appScope

Поле qrSignature в ответе — 16-байтная HMAC-подпись, закодированная base64url. Мерчант кладёт её в QR-payload вместе с intentId, merchantTbAccountId, amountSatang, currency, expiresAt, appScope. Подписи достаточно, чтобы клиент Flutter мог локально верифицировать QR перед отправкой confirm (через тот же verifyQrPayload()).

appScope — таргетинг приложения: 'w' (wallet), 'c' (consumer mini-app), 'a' (any). По умолчанию 'a'.

Шаг 3. Покупатель сканирует QR

Клиент (Flutter) парсит QR, опционально верифицирует подпись (через серверный endpoint или встроенный shared-secret в режиме appScope='c'), берёт текущий tbAccountId покупателя и userId, генерирует свой idempotencyKey.

Шаг 4. Подтверждение оплаты

POST /intents/:id/confirm (src/intent/confirm-handler.ts):

Body:

{
  "payerTbAccountId": "<uuid аккаунта покупателя>",
  "payerUserId":      <int>,
  "idempotencyKey":   "<uuid>"
}

Опциональный заголовок If-Match: <version> — optimistic locking. Если задан, PM добавляет в WHERE-clause version = expectedVersion. При несовпадении возвращает 409 VERSION_MISMATCH с актуальной версией.

Что делает PM (атомарно в один UPDATE):

UPDATE intent
SET status='VALIDATED',
    from_account_name = :payerAccountName,
    user_id           = :payerUserId,
    redeemed_by_user_id = :payerUserId,
    redeemed_at       = now(),
    version           = version + 1
WHERE id = :intentId
  AND status='CREATED'
  AND operation_type='INVOICE_PAYMENT'
  AND expires_at > now()
  AND version = :expectedVersion  -- если задан If-Match

Если RETURNING пустой — PM проверяет причину и возвращает один из: - 409 ALREADY_PROCESSED — статус уже не CREATED. - 409 EXPIREDexpires_at < now(). - 409 VERSION_MISMATCHversion не совпал; в ответе текущая версия.

При успехе: 1. Idempotency для confirm: тот же (idempotencyKey, payerUserId) и интент уже не в CREATED → возврат с replayed: true. 2. Считаются комиссии PRE + POST для INVOICE_PAYMENT. 3. VALIDATED → AUTHORIZED (UPDATE + intent_event). 4. channel.redeem(ctx)runP2pSaga(ctx) — атомарно authorize + settle в TigerBeetle. 5. AUTHORIZED → SETTLED (UPDATE + intent_event + writeSettlement + Redis-нотификация).

Ответ:

{ "intentId": "...", "status": "SETTLED", "channel": "MERCHANT_INVOICE",
  "createdAt": "2026-05-29T12:00:00.000Z", "version": 1 }

Шаг 5 (альтернатива). Отмена мерчантом

POST /intents/:id/cancel (src/intent/cancel-handler.ts):

Разрешено только: - исходному мерчанту (actorUserId === issuedByUserId), либо - admin bypass (X-User-Id: 0).

PM делегирует channel.cancel() (src/channels/merchant-invoice.ts), который выполняет атомарный UPDATE status='CANCELED' WHERE status='CREATED'. При гонке (уже не CREATED) — ConflictError409 CANNOT_CANCEL.

Шаг 6 (альтернатива). Истечение TTL

invoice-expiry sweep job (src/jobs/invoice-expiry.ts) запускается как отдельная роль через setInterval в server.ts: - каждый intervalMs (обычно 30 c) выбирает batch CREATED INVOICE_PAYMENT с expires_at < now(); - для каждого вызывает channel.expire() — атомарный UPDATE status='EXPIRED' WHERE status='CREATED' AND expires_at < now(); - проверяет, что статус действительно стал EXPIRED (защита от race с confirm/cancel).

Несколько pod'ов с ролью invoice-expiry безопасны (UPDATE атомарен), но избыточны.

4. Curl-примеры

4.1. Создание инвойса мерчантом

curl -X POST https://pm.example.com/intents \
  -H "Content-Type: application/json" \
  -H "X-Service-Id: merchant-pos-01" \
  -H "X-User-Id: 42001" \
  -H "X-Timestamp: $(date +%s)" \
  -H "X-Signature: <hmac-sha256(secret, body|timestamp|...)>" \
  -d '{
    "operationType":  "INVOICE_PAYMENT",
    "idempotencyKey": "8b2e1f54-9a3d-4c10-b6c7-12d4f9e5b001",
    "amount":         "12500",
    "currency":       "THB",
    "toTbAccountId":  "8a17b4d3-7c2f-4b91-9e2a-4dd1c0e8a777",
    "comment":        "Order #58291",
    "metadata":       { "ttlSeconds": 600, "appScope": "a" }
  }'

Ответ 201:

{
  "intentId":           "f1b2c3d4-...-...-...",
  "status":             "CREATED",
  "channel":            "MERCHANT_INVOICE",
  "preFeeAmount":       "0",
  "postFeeAmount":      "0",
  "requiresMonitoring": false,
  "createdAt":          "2026-05-29T12:00:00.000Z",
  "expiresAt":          "2026-05-29T12:10:00.000Z",
  "qrSignature":        "kK9z3qFh1bN2-y_4aBcD8w",
  "version":            0
}

4.2. Подтверждение покупателем с optimistic locking

curl -X POST https://pm.example.com/intents/f1b2c3d4-.../confirm \
  -H "Content-Type: application/json" \
  -H "X-Service-Id: auth-center" \
  -H "X-User-Id: 70123" \
  -H "X-Timestamp: $(date +%s)" \
  -H "X-Signature: <hmac>" \
  -H "If-Match: 0" \
  -d '{
    "payerTbAccountId": "c1d2e3f4-5a6b-7c8d-9e0f-1234567890ab",
    "payerUserId":      70123,
    "idempotencyKey":   "11223344-5566-7788-99aa-bbccddeeff00"
  }'

Ответ 200:

{ "intentId": "f1b2c3d4-...", "status": "SETTLED",
  "channel": "MERCHANT_INVOICE", "createdAt": "2026-05-29T12:00:00.000Z",
  "version": 1 }

Возможные 409:

{ "error": "ALREADY_PROCESSED", "status": "SETTLED" }
{ "error": "EXPIRED" }
{ "error": "VERSION_MISMATCH", "currentVersion": 2 }

4.3. Отмена мерчантом

curl -X POST https://pm.example.com/intents/f1b2c3d4-.../cancel \
  -H "Content-Type: application/json" \
  -H "X-Service-Id: merchant-pos-01" \
  -H "X-User-Id: 42001" \
  -H "X-Timestamp: $(date +%s)" \
  -H "X-Signature: <hmac>" \
  -d '{ "reason": "out_of_stock" }'

Ответ 200:

{
  "intentId":      "f1b2c3d4-...",
  "status":        "CANCELED",
  "channel":       "MERCHANT_INVOICE",
  "preFeeAmount":  "0",
  "postFeeAmount": "0",
  "requiresMonitoring": false,
  "createdAt":     "2026-05-29T12:00:00.000Z"
}

Возможные ошибки: - 403 UNAUTHORIZED_ACTORX-User-Id не совпадает с issuedByUserId и не 0. - 409 CANNOT_CANCEL — статус уже не CREATED (например, уже SETTLED или EXPIRED).

4.4. Проверка статуса

curl https://pm.example.com/intents/f1b2c3d4-... \
  -H "X-Service-Id: merchant-pos-01" \
  -H "X-Timestamp: $(date +%s)" \
  -H "X-Signature: <hmac>"

Ответ 200:

{
  "intentId":      "f1b2c3d4-...",
  "status":        "SETTLED",
  "operationType": "INVOICE_PAYMENT",
  "channel":       "MERCHANT_INVOICE",
  "amount":        "12500",
  "currency":      "THB",
  "preFeeAmount":  "0",
  "postFeeAmount": "25",
  "fromAccount":   "user.70123.wallet.THB",
  "toAccount":     "merchant.42001.wallet.THB",
  "createdAt":     "2026-05-29T12:00:00.000Z",
  "updatedAt":     "2026-05-29T12:00:42.000Z"
}

fromAccount будет null пока инвойс в CREATED (покупатель ещё не подтвердил).

Полный аудит переходов — GET /intents/:id/events.

5. Ключевые инварианты

  • Поле from_account_name равно null пока статус CREATED. Заполняется атомарно в confirm.
  • qr_signature — 16 байт, HMAC-SHA256 от canonical-строки, секрет в INVOICE_QR_SECRET env.
  • TigerBeetle не задействован на этапе reserve() — нет pending transfer, нет блокировки баланса покупателя. Резервация ТОЛЬКО в pm.intent.
  • Лимиты покупателя проверяются в confirm-handler.ts, не в handler.ts (потому что покупатель неизвестен на этапе создания).
  • redeemedAt / redeemedByUserId фиксируют, кто и когда подтвердил инвойс — это иммутабельная история, не обнуляется при дальнейших переходах.
  • version инкрементируется при каждом значимом UPDATE (confirm, cancel, expire), служит ключом optimistic locking.

6. Связанные файлы

  • src/intent/handler.ts — dispatch к reserve() для isTwoPhase каналов.
  • src/intent/confirm-handler.tsPOST /intents/:id/confirm.
  • src/intent/cancel-handler.tsPOST /intents/:id/cancel.
  • src/channels/merchant-invoice.ts — реализация reserve / redeem / cancel / expire.
  • src/channels/_p2p-saga.ts — двухфазная TB-сага, вызывается из redeem().
  • src/jobs/invoice-expiry.ts — sweep job для перевода в EXPIRED.
  • src/shared/qr-signature.ts — HMAC-SHA256 подпись 16 байт + timing-safe verify.

7. См. также