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=true → fromAccountName=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 EXPIRED — expires_at < now().
- 409 VERSION_MISMATCH — version не совпал; в ответе текущая версия.
При успехе:
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) — ConflictError → 409 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_ACTOR — X-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_SECRETenv.- 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.ts—POST /intents/:id/confirm.src/intent/cancel-handler.ts—POST /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. См. также¶
- ../architecture/04-two-phase-channels.md — теория двухфазных каналов.
- ./add-channel.md — как добавить новый канал (single- или two-phase).
- ./add-operation-type.md — как добавить новый
operationType. - ./add-payment-route.md — настройка маршрутизации
operationType+amount → channel.