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

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 /intentsPOST /intents/:id/confirm) + ветки отмены (/cancel) и истечения срока (invoice-expiry job);
  • 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_fromstatus_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.tsPOST /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'.

Поток:

  1. handler.ts делает обычный INSERT intent со статусом CREATED (строка 269). fromAccountName оставляется NULL — покупатель пока неизвестен. Лимиты на покупателя не проверяются на этом шаге (строка 254-266: пропуск для isInvoiceCreate).
  2. Распознаётся isTwoPhase(channel) (строка 301), и handler вызывает channel.reserve() (merchant-invoice.ts:26-47).
  3. reserve() НЕ создаёт ни одного TigerBeetle-перевода. Только:
  4. вычисляет expiresAt = now + ttlSeconds (по умолчанию config.INVOICE_DEFAULT_TTL_SECONDS);
  5. подписывает QR-payload через signQrPayload() (HMAC-SHA256, 16 байт);
  6. 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.

Поток:

  1. Idempotency check (строки 63-76): если idempotencyKey + redeemedByUserId уже сматчился и статус не CREATED → возврат текущего статуса с replayed: true.
  2. 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).
  3. writeIntentEvent(CREATED → VALIDATED) (строка 120).
  4. Считаются комиссии (calculateFees PRE+POST), резолвятся feeSplits, UPDATE intent SET pre_fee_amount, post_fee_amount (строка 142).
  5. UPDATE intent SET status='AUTHORIZED' (строка 163), writeIntentEvent(VALIDATED → AUTHORIZED).
  6. Вызывается 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).
  7. По успеху: UPDATE intent SET status='SETTLED', tb_transfer_ids=? (строка 173), writeIntentEvent(AUTHORIZED → SETTLED), writeSettlement(), publishPaymentNotification().
  8. По исключению из 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.tssrc/channels/merchant-invoice.ts cancel() (строки 55-74).

Поток:

  1. Загрузка инвойса; проверка actorUserId === existing.issuedByUserId (строка 47 в cancel-handler) либо admin bypass (actorUserId === 0). Иначе → 403 UNAUTHORIZED_ACTOR.
  2. Проверка isTwoPhase(channel) — иначе 400 CANCEL_NOT_SUPPORTED.
  3. 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.
  4. После успеха: publishIntentStatus(intentId, 'CANCELED') в Redis (строка 73 в merchant-invoice.ts).
  5. writeIntentEvent(CREATED → CANCELED, reason) (строка 66 в cancel-handler).

Никакие TB-операции не выполняются (PENDING transfers не создавались на этапе reserve()).

4.4 expire() — истечение TTL фоновым воркером

Вход: фоновая роль invoice-expiry (НЕ HTTP-роут). Файл — src/jobs/invoice-expiry.ts.

Поток:

  1. processExpiredInvoices(batchSize) (строка 28): SELECT id, channel FROM intent WHERE status='CREATED' AND operation_type='INVOICE_PAYMENT' AND expires_at < now() LIMIT batchSize.
  2. Для каждой строки: getChannel(row.channel), проверка isTwoPhase, загрузка полного intent, вызов channel.expire({ intent: fullIntent }).
  3. 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 (другой воркер успел раньше или инвойс был отменён/подтверждён в гонке).
  4. После успеха: 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_at vs now()).
  • cancel() теряет: ConflictError409 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:

  1. INSERT intent CREATEDwriteIntentEvent(null → CREATED).
  2. calculateFees PRE+POST → UPDATE status='VALIDATED'writeIntentEvent(CREATED → VALIDATED).
  3. channel.steps[0] = runP2pAuthorize (PENDING batch в TB) → UPDATE status='AUTHORIZED', tb_transfer_ids=?writeIntentEvent(VALIDATED → AUTHORIZED).
  4. 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:

  1. SELECT по (idempotency_key, service_id) LIMIT 1.
  2. Если строка найдена → возврат 200 OK с текущим статусом (replay).
  3. Иначе → дальше по обычному 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. Ссылки