03. Intent Saga — конечный автомат жизненного цикла платежа¶
Конечный автомат IntentStatus в Payment Manager: 9 статусов, переходы выполняют HTTP-роуты (/intents, /intents/:id/confirm, /intents/:id/cancel) и фоновый job invoice-expiry. Все переходы пишут аудит в pm.intent_event и публикуют статус в Redis-канал intent.{id}.
1. Назначение¶
Intent — единый объект, отслеживающий выполнение одного платежа от момента создания (HTTP POST /intents) до терминального статуса (SETTLED, FAILED, CANCELED, EXPIRED).
Жизненный цикл управляется не одним глобальным оркестратором — переходы выполняются разными участниками:
- single-phase каналы (
INTERNAL_P2P,IPPS_TRANSFER, …) идут отCREATEDдоSETTLED/AUTHORIZEDвнутри одного HTTP-запросаPOST /intents; - two-phase канал
MERCHANT_INVOICEразрывается на 2 HTTP-запроса (POST /intents→POST /intents/:id/confirm) + ветки отмены (/cancel) и истечения срока (invoice-expiryjob); - async-каналы (IPPS) могут зависать в
AUTHORIZEDдо прихода PSP-результата.
Главные инварианты:
IntentStatus— закрытое множество из 9 значений (определено вsrc/shared/schema.ts:90-92);- любой переход атомарен на уровне SQL (
UPDATE … WHERE status = <from>+WHERE expires_at > now()где это применимо); - каждый переход → строка в
pm.intent_event(status_from→status_to, опциональноreason/payload), записывается черезwriteIntentEvent()изsrc/intent/intent-events.ts; - каждый переход публикуется в Redis-канал
intent.{id}(если включён real-time-monitoring) —publishIntentStatus()там же.
1.1 Полный список статусов¶
| Статус | Семантика | Тип | Где применяется |
|---|---|---|---|
CREATED |
строка вставлена в pm.intent, никаких TB-операций |
начальный | все каналы; для MERCHANT_INVOICE — длительное состояние до confirm/cancel/expire |
VALIDATED |
посчитаны комиссии (pre_fee_amount, post_fee_amount); для INVOICE_PAYMENT — определён покупатель (fromAccountName, redeemedByUserId) |
промежуточный | все каналы |
AUTHORIZED |
PENDING-перевод(ы) в TigerBeetle созданы; средства зарезервированы | промежуточный (sync-каналы) / длительный (async IPPS) | все каналы |
SETTLING |
PSP-запрос отправлен, ждём ACK/NACK; tb_transfer_ids уже зафиксированы |
длительный | только IPPS (Phase 1) |
SETTLED |
POST_PENDING применён в TB; pm.settlement запись создана; payment.notifications.jobs опубликовано |
терминальный | все каналы |
FAILED |
необработанное исключение или PSP NACK; в failure_reason причина; PENDING-переводы откатываются (VOID) |
терминальный | все каналы |
MANUAL_REVIEW |
зарезервирован для будущего risk-engine — операция требует ручного разбора ops-командой | терминальный (до ручного перевода) | не используется в Phase 1, оставлен в enum для forward-compat |
CANCELED |
мерчант или admin отменил инвойс до confirm | терминальный | только MERCHANT_INVOICE |
EXPIRED |
TTL истёк, sweeper перевёл CREATED инвойс в EXPIRED |
терминальный | только MERCHANT_INVOICE |
2. Полная диаграмма состояний¶
stateDiagram-v2
[*] --> CREATED: POST /intents (INSERT)
CREATED --> VALIDATED: fees computed
CREATED --> CANCELED: POST /intents/:id/cancel\n(MERCHANT_INVOICE)
CREATED --> EXPIRED: invoice-expiry job\n(expires_at < now)
CREATED --> FAILED: pre-step exception
VALIDATED --> AUTHORIZED: channel.steps[0]\n(TB pending batch)
VALIDATED --> FAILED: TB rejection / saga error
AUTHORIZED --> SETTLING: PSP worker picked up\n(IPPS_TRANSFER only)
AUTHORIZED --> SETTLED: channel.steps[1]\n(POST_PENDING) for sync channels
AUTHORIZED --> FAILED: settle step error
AUTHORIZED --> MANUAL_REVIEW: limit / risk hold\n(reserved, see note)
SETTLING --> SETTLED: PSP success +\nPOST_PENDING applied
SETTLING --> FAILED: PSP terminal error\n(VOID pending transfers)
SETTLING --> MANUAL_REVIEW: PSP inquiry timeout\n(reserved)
SETTLED --> [*]
FAILED --> [*]
CANCELED --> [*]
EXPIRED --> [*]
MANUAL_REVIEW --> SETTLED: ops resolves\n(reserved)
MANUAL_REVIEW --> FAILED: ops rejects\n(reserved)
Примечания к диаграмме:
MANUAL_REVIEWзарезервирован для будущего risk-engine; в Phase 1 ни один код-путь его не записывает напрямую, но статус присутствует вIntentStatusи предусмотрен в схемеintent_event.SETTLINGиспользуется в IPPS-флоу междуAUTHORIZEDи финальнымSETTLED/FAILED. В синхронных каналах (INTERNAL_P2P,MERCHANT_INVOICEчерезredeem())SETTLINGпропускается — переходAUTHORIZED → SETTLEDпроисходит в том же HTTP-запросе.- Терминальные статусы:
SETTLED,FAILED,CANCELED,EXPIRED. Из них нет обратных переходов.
3. Переходы — таблица ответственности¶
Условные сокращения: intent = pm.intent, intent_event = pm.intent_event, outbox = pm.outbox_event, TB = TigerBeetle.
| from | to | Инициатор (файл / функция) | Транзакция | Параллельно пишется |
|---|---|---|---|---|
| (none) | CREATED |
src/intent/handler.ts — POST /intents, db.insert(intent) строка 269 |
один SQL INSERT |
intent_event(null → CREATED) через writeIntentEvent() строка 297; Redis publish (опц.) |
CREATED |
VALIDATED |
src/intent/handler.ts — single-phase ветка, db.update(intent) строка 349; для INVOICE — src/intent/confirm-handler.ts строка 89 |
UPDATE intent SET status='VALIDATED' WHERE id=? AND status='CREATED' (atomic) |
intent_event(CREATED → VALIDATED); для INVOICE атомарно устанавливаются fromAccountName, redeemedByUserId, redeemedAt, version+1 |
VALIDATED |
AUTHORIZED |
src/intent/handler.ts после channel.steps[0](ctx) строка 378-385; для INVOICE — src/intent/confirm-handler.ts строка 163 |
UPDATE intent SET status='AUTHORIZED', tb_transfer_ids=? (atomic) |
intent_event(VALIDATED → AUTHORIZED); в TB создан PENDING batch (runP2pAuthorize в src/channels/_p2p-saga.ts) |
AUTHORIZED |
SETTLING |
PSP worker роль (IPPS) — поднимает строку из pm.psp_tx_map FOR UPDATE SKIP LOCKED (см. ./04-two-phase-channels.md) |
UPDATE intent SET status='SETTLING' + UPDATE psp_tx_map в одной транзакции |
intent_event(AUTHORIZED → SETTLING); запись в psp_tx_map.last_request_at |
AUTHORIZED |
SETTLED |
sync-каналы — src/intent/handler.ts после channel.steps[1](ctx) строка 388-396; INVOICE — src/intent/confirm-handler.ts строка 173 |
UPDATE intent SET status='SETTLED', tb_transfer_ids=? |
intent_event(AUTHORIZED → SETTLED); writeSettlement(...) (запись в settlement); publishPaymentNotification(...); в TB POST_PENDING применён |
SETTLING |
SETTLED |
PSP worker (IPPS) — после ACK от провайдера и POST_PENDING в TB | UPDATE intent SET status='SETTLED' + UPDATE psp_tx_map |
intent_event(SETTLING → SETTLED); writeSettlement(); в TB POST_PENDING |
SETTLING |
FAILED |
PSP worker — терминальный ответ провайдера (NACK/timeout) | UPDATE intent SET status='FAILED', failure_reason=? + VOID PENDING transfers в TB |
intent_event(SETTLING → FAILED, reason); psp_tx_map.last_status='FAILED' |
* |
FAILED |
src/intent/handler.ts catch блок строка 435-444; src/intent/confirm-handler.ts строка 197-206 |
UPDATE intent SET status='FAILED', failure_reason=? |
intent_event(<lastStatus> → FAILED, reason); Redis publish (опц.) |
CREATED |
CANCELED |
src/channels/merchant-invoice.ts cancel() строка 56-67; вызывается из src/intent/cancel-handler.ts |
UPDATE intent SET status='CANCELED' WHERE status='CREATED' (atomic) |
intent_event(CREATED → CANCELED, reason); publishIntentStatus() |
CREATED |
EXPIRED |
src/channels/merchant-invoice.ts expire() строка 78-91; вызывается из src/jobs/invoice-expiry.ts |
UPDATE intent SET status='EXPIRED' WHERE status='CREATED' AND expires_at < now() (atomic) |
publishIntentStatus(); не пишет intent_event напрямую — sweeper полагается на observability логов (intent_event запись для EXPIRED планируется в follow-up) |
Запись в intent_event всегда выполняется через хелпер writeIntentEvent() (см. src/intent/intent-events.ts:13). Сигнатура: (db, intentId, statusFrom, statusTo, reason?, payload?, publishFn?). Если передан publishFn, статус публикуется в Redis-канал intent.{intentId}.
4. Two-phase channel — MERCHANT_INVOICE¶
MERCHANT_INVOICE — единственный двухфазный канал в Phase 1. Поведение определено объектом TwoPhaseChannel в src/channels/merchant-invoice.ts и состоит из 4 операций.
4.1 reserve() — создание инвойса мерчантом¶
Вход: POST /intents с operationType='INVOICE_PAYMENT'.
Поток:
handler.tsделает обычныйINSERT intentсо статусомCREATED(строка 269).fromAccountNameоставляетсяNULL— покупатель пока неизвестен. Лимиты на покупателя не проверяются на этом шаге (строка 254-266: пропуск дляisInvoiceCreate).- Распознаётся
isTwoPhase(channel)(строка 301), и handler вызываетchannel.reserve()(merchant-invoice.ts:26-47). reserve()НЕ создаёт ни одного TigerBeetle-перевода. Только:- вычисляет
expiresAt = now + ttlSeconds(по умолчаниюconfig.INVOICE_DEFAULT_TTL_SECONDS); - подписывает QR-payload через
signQrPayload()(HMAC-SHA256, 16 байт); UPDATE intent SET expires_at, qr_signature, reserved_at.
Результат: ответ 201 CREATED с qrSignature (base64url) и expiresAt (ISO 8601). Статус остаётся CREATED.
Sequence reserve():
sequenceDiagram
participant M as Merchant Service
participant H as POST /intents
participant DB as pm.intent
participant Ch as MERCHANT_INVOICE.reserve()
M->>H: operationType=INVOICE_PAYMENT, amount, recipientUserId
H->>DB: INSERT intent (status=CREATED, from=NULL)
H->>DB: INSERT intent_event(null → CREATED)
H->>Ch: reserve({intent, ttlSeconds, appScope})
Ch->>Ch: signQrPayload(...)
Ch->>DB: UPDATE expires_at, qr_signature, reserved_at
Ch-->>H: { expiresAt, qrSignature }
H-->>M: 201 { intentId, status=CREATED, qrSignature, expiresAt }
4.2 confirm() — оплата покупателем¶
Вход: POST /intents/:id/confirm с телом { payerTbAccountId, payerUserId, idempotencyKey }. Файл — src/intent/confirm-handler.ts.
Атомарность перехода обеспечивается одним условным UPDATE. После него выполняется P2P-сага в TB.
Поток:
- Idempotency check (строки 63-76): если
idempotencyKey + redeemedByUserIdуже сматчился и статус неCREATED→ возврат текущего статуса сreplayed: true. - Optimistic concurrency UPDATE
CREATED → VALIDATED(строки 89-103): один атомарныйUPDATEс условиемWHERE status='CREATED' AND expires_at > now() AND version = ?. В одной операции SQL пишутсяfromAccountName=payerAcc,userId=payerUserId,redeemedByUserId,redeemedAt,version+1. Если 0 строк изменено → анализ причины:ALREADY_PROCESSED(409),EXPIRED(409) илиVERSION_MISMATCH(409). writeIntentEvent(CREATED → VALIDATED)(строка 120).- Считаются комиссии (
calculateFeesPRE+POST), резолвятсяfeeSplits,UPDATE intent SET pre_fee_amount, post_fee_amount(строка 142). UPDATE intent SET status='AUTHORIZED'(строка 163),writeIntentEvent(VALIDATED → AUTHORIZED).- Вызывается
channel.redeem(ctx)(строка 170).redeem()вmerchant-invoice.ts:50-52делегирует вrunP2pSaga(ctx)— функция изsrc/channels/_p2p-saga.ts:205-208, которая выполняетrunP2pAuthorize(создаёт PENDING batch) +runP2pSettle(POST_PENDING). - По успеху:
UPDATE intent SET status='SETTLED', tb_transfer_ids=?(строка 173),writeIntentEvent(AUTHORIZED → SETTLED),writeSettlement(),publishPaymentNotification(). - По исключению из
redeem:UPDATE intent SET status='FAILED', failure_reason=?+writeIntentEvent(AUTHORIZED → FAILED, reason)(строки 200-205).
Важно: формально внутри confirm() пишется 3 события (CREATED→VALIDATED, VALIDATED→AUTHORIZED, AUTHORIZED→SETTLED), но HTTP-запрос один. Покупатель получает финальный статус синхронно.
Sequence confirm():
sequenceDiagram
participant P as Payer Service
participant H as POST /intents/:id/confirm
participant DB as pm.intent
participant Ch as MERCHANT_INVOICE.redeem()
participant TB as TigerBeetle
P->>H: { payerTbAccountId, payerUserId, idempotencyKey }
H->>DB: SELECT intent
H->>DB: UPDATE status='VALIDATED' WHERE status='CREATED' AND expires_at>now()
alt 0 rows updated
H-->>P: 409 ALREADY_PROCESSED | EXPIRED | VERSION_MISMATCH
end
H->>DB: INSERT intent_event(CREATED → VALIDATED)
H->>H: calculateFees(PRE, POST)
H->>DB: UPDATE pre_fee, post_fee
H->>DB: UPDATE status='AUTHORIZED'
H->>DB: INSERT intent_event(VALIDATED → AUTHORIZED)
H->>Ch: redeem(ctx) → runP2pSaga
Ch->>TB: createTransfers(PENDING batch)
Ch->>TB: createTransfers(POST_PENDING batch)
Ch-->>H: { done='settled', tbTransferIds }
H->>DB: UPDATE status='SETTLED', tb_transfer_ids=?
H->>DB: INSERT intent_event(AUTHORIZED → SETTLED)
H->>DB: INSERT settlement
H-->>P: 200 { status=SETTLED, version+1 }
4.3 cancel() — отмена мерчантом¶
Вход: POST /intents/:id/cancel. Файлы — src/intent/cancel-handler.ts → src/channels/merchant-invoice.ts cancel() (строки 55-74).
Поток:
- Загрузка инвойса; проверка
actorUserId === existing.issuedByUserId(строка 47 в cancel-handler) либо admin bypass (actorUserId === 0). Иначе → 403UNAUTHORIZED_ACTOR. - Проверка
isTwoPhase(channel)— иначе 400CANCEL_NOT_SUPPORTED. channel.cancel({ intent, reason, actorUserId })выполняет атомарныйUPDATE intent SET status='CANCELED', cancel_reason=?, canceled_at=now(), version+1 WHERE id=? AND status='CREATED'(строки 56-67 вmerchant-invoice.ts). Если 0 строк →ConflictError('CANNOT_CANCEL')→ 409.- После успеха:
publishIntentStatus(intentId, 'CANCELED')в Redis (строка 73 вmerchant-invoice.ts). writeIntentEvent(CREATED → CANCELED, reason)(строка 66 в cancel-handler).
Никакие TB-операции не выполняются (PENDING transfers не создавались на этапе reserve()).
4.4 expire() — истечение TTL фоновым воркером¶
Вход: фоновая роль invoice-expiry (НЕ HTTP-роут). Файл — src/jobs/invoice-expiry.ts.
Поток:
processExpiredInvoices(batchSize)(строка 28):SELECT id, channel FROM intent WHERE status='CREATED' AND operation_type='INVOICE_PAYMENT' AND expires_at < now() LIMIT batchSize.- Для каждой строки:
getChannel(row.channel), проверкаisTwoPhase, загрузка полногоintent, вызовchannel.expire({ intent: fullIntent }). expire()вmerchant-invoice.ts:77-92выполняет атомарныйUPDATE intent SET status='EXPIRED', version+1 WHERE id=? AND status='CREATED' AND expires_at < now(). Если 0 строк → no-op (другой воркер успел раньше или инвойс был отменён/подтверждён в гонке).- После успеха:
publishIntentStatus(intentId, 'EXPIRED').
Race-safe: атомарный UPDATE гарантирует, что только один из конкурирующих процессов (sweeper, confirm(), cancel()) изменит статус.
Интервал и batch-size задаются в конфиге (см. ./04-two-phase-channels.md для подробностей о роли invoice-expiry и Kubernetes-deployment-конфигурации).
4.4.1 Race-сценарий: confirm vs cancel vs expire¶
Три параллельных пути могут попытаться вывести инвойс из CREATED:
- покупатель:
POST /intents/:id/confirm(хочетCREATED → VALIDATED → … → SETTLED); - мерчант:
POST /intents/:id/cancel(хочетCREATED → CANCELED); - sweeper:
processExpiredInvoices(хочетCREATED → EXPIRED).
Все три выполняют атомарный UPDATE … WHERE status='CREATED' …. Выигрывает ровно одна транзакция:
confirm()теряет: возвращает409 ALREADY_PROCESSEDили409 EXPIRED(зависит отexpires_atvsnow()).cancel()теряет:ConflictError→409 CANNOT_CANCEL.expire()теряет: 0 строк → no-op (тихо пропускает).
Гонка expire() vs confirm() в крайнем случае (TTL истёк прямо в момент confirm): condition expires_at > now() в UPDATE confirm-handler.ts:101 гарантирует, что после истечения confirm всегда даёт 409, даже если sweeper ещё не пробежал.
4.5 Поля схемы, специфичные для two-phase¶
Колонки pm.intent, которые используются исключительно в two-phase-флоу (схема в src/shared/schema.ts:94-137):
| Колонка | Записывается | Семантика |
|---|---|---|
version |
INSERT (0), все UPDATE через sql\version + 1`| optimistic concurrency дляconfirm()черезIf-Match` |
|
expires_at |
reserve() |
таймстамп истечения TTL; индекс intent_invoice_expiry_idx (partial: status=CREATED AND operation_type=INVOICE_PAYMENT) ускоряет sweeper |
reserved_at |
reserve() |
момент создания инвойса (для аналитики) |
canceled_at |
cancel() |
момент отмены |
redeemed_at |
confirm() |
момент оплаты покупателем |
cancel_reason |
cancel() |
причина отмены (varchar(50)) |
qr_signature |
reserve() |
bytea, HMAC-SHA256 16 байт, кодируется как base64url в ответе |
issued_by_user_id |
INSERT в handler при operationType='INVOICE_PAYMENT' |
мерчант, выпустивший инвойс; индекс intent_issued_by_idx |
redeemed_by_user_id |
confirm() |
покупатель, оплативший инвойс |
from_account_name для INVOICE остаётся NULL до confirm() — это единственный путь, при котором поле может быть null после INSERT (см. handler строка 101 в schema, nullable column).
5. Single-phase flow (INTERNAL_P2P) — для контраста¶
Для канала INTERNAL_P2P все переходы происходят синхронно в POST /intents:
INSERT intent CREATED→writeIntentEvent(null → CREATED).calculateFeesPRE+POST →UPDATE status='VALIDATED'→writeIntentEvent(CREATED → VALIDATED).channel.steps[0]=runP2pAuthorize(PENDING batch в TB) →UPDATE status='AUTHORIZED', tb_transfer_ids=?→writeIntentEvent(VALIDATED → AUTHORIZED).channel.steps[1]=runP2pSettle(POST_PENDING в TB) →UPDATE status='SETTLED', tb_transfer_ids=?→writeIntentEvent(AUTHORIZED → SETTLED)→writeSettlement()+publishPaymentNotification().
runSaga() из src/intent/saga-runner.ts — общий runner, который последовательно вызывает channel.steps[] пока не получит outcome != 'continue'. На практике в handler.ts шаги вызываются явно (строки 378, 388), чтобы между ними обновить intent.status. Сам runSaga() зарезервирован для случаев, где промежуточные статусы не нужны (внутренние утилиты, тесты).
5.1 Idempotency на уровне POST /intents¶
Для всех каналов (single- и two-phase) при создании intent уникальность определяется парой (idempotencyKey, service_id) — UNIQUE constraint intent_idempotency_key_service_id_uniq (schema.ts:132).
Поток в handler.ts:119-149:
SELECTпо(idempotency_key, service_id)LIMIT 1.- Если строка найдена → возврат
200 OKс текущим статусом (replay). - Иначе → дальше по обычному flow.
Для MERCHANT_INVOICE дополнительный уровень idempotency — на confirm: (intent.idempotency_key === body.idempotencyKey) AND redeemed_by_user_id === payer_user_id AND status !== 'CREATED' (confirm-handler.ts:63-76). Это позволяет покупателю безопасно ретраить confirm с тем же idempotency-ключом и получить тот же финальный статус (SETTLED/FAILED).
6. Запреты и инварианты¶
- Статусов
RESERVED,INIT,DONEне существует —IntentStatusзакрыт девятью значениями. Если в коде встречается строка'RESERVED'— это баг. - Из терминальных статусов (
SETTLED,FAILED,CANCELED,EXPIRED) нет обратных переходов. - Любой переход атомарен на уровне SQL:
UPDATE … WHERE status = <from>. Compare-and-swap гарантирует отсутствие двойного исполнения при гонках (confirm vs expire, например). intent_eventпишется в той же транзакции, что иUPDATE intent(по факту — последовательно через одинdb, но при ошибкеwriteIntentEventвсегда логируется warn, не fatal — см.handler.ts:443).- Redis publish — best-effort:
void publishFn(...).catch(...)вintent-events.ts:31. Падение Redis не блокирует переход.
7. Ссылки¶
../modules/intent.md— детали Intent-домена: DTO, IntentContext, channel registry.../modules/channels.md— реестр каналов: single-phase vs two-phase, контрактыSinglePhaseChannel/TwoPhaseChannel../04-two-phase-channels.md— глубокое описание two-phase механики, PSP worker,psp_tx_map, invoice-expiry sweeper.- Исходники:
src/shared/schema.ts(типIntentStatus),src/intent/handler.ts,src/intent/confirm-handler.ts,src/intent/cancel-handler.ts,src/intent/intent-events.ts,src/intent/saga-runner.ts,src/channels/merchant-invoice.ts,src/channels/_p2p-saga.ts,src/jobs/invoice-expiry.ts.