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

04 — Реестр событий

Сводный каталог runtime-событий Payment Manager: actions для transactional outbox, каналы Redis pub/sub для real-time сигналов клиентам, поток push-уведомлений и заготовка под Phase 2B Redis Streams для внешних PSP-адаптеров.

Назначение

Этот документ — единая точка истины для всех событийных артефактов PM: что отправляется, кто продюсер, кто потребитель, в какой фазе жизненного цикла. Документ разделён по транспортным уровням:

  1. Outbox actions — записи в таблице pm.outbox_event (durable, обрабатываются outbox-worker).
  2. Redis pub/sub каналы — эфемерные runtime-сигналы для real-time подписчиков (Auth Center → Flutter).
  3. Redis Streams (notifications) — durable push-jobs для Notifications Service (FCM worker).
  4. Заготовка Phase 2B — внешние Redis Streams для PSP-адаптеров (IPPS / QP / Webhook Gateway).

Связь с БД-аудитом интентов — см. секцию «Связь с intent_event».


Outbox actions

Таблица pm.outbox_event использует VARCHAR(20) колонку action с CHECK-ограничением. Допустимые значения (из src/shared/schema.ts, тип OutboxAction):

action Когда создаётся Кто обрабатывает Эффект
post_pending После успешного подтверждения PSP (intent в AUTHORIZED/SETTLING) — выпуск финальных TB transfers. outbox-worker TB createTransfers с post_pending_transfer (linked chain), enrichment toName/settlementDate из psp_tx_map, опциональный PSP-fee transfer, intent → SETTLED.
void_pending После отказа PSP / финального FAILED состояния (intent в AUTHORIZED, требует отката резервов). outbox-worker TB createTransfers с void_pending_transfer (linked chain), intent → FAILED с failureReason из psp_tx_map.lastError.

Дополнительно колонка status использует свой CHECK со значениями: pending, processed, failed (тип OutboxStatus). Полную схему таблицы см. в ../database/09-outbox-event.md.

Worker role: outbox-worker — один из четырёх WORKER_ROLES PM (api, outbox-worker, psp-worker, invoice-expiry). Запускается в единственном экземпляре (см. правило в CLAUDE.md корня проекта PM: «NEVER run multiple OutboxWorker instances simultaneously»).

Жизненный цикл записи

created → status='pending'  (insert вместе с intent в одной транзакции)
       ↓ outbox-worker берёт батч (ORDER BY createdAt LIMIT 10, WHERE status='pending')
       ├─► OK  → status='processed', processedAt=now()
       └─► error / fatal TB-status → retryCount += 1 (status остаётся 'pending')

Финального status='failed' worker сам не выставляет — увеличивает retryCount и пробует снова на следующем тике (1 сек по умолчанию). Перевод в 'failed' — операция ручная (ops/admin), когда retryCount превышает порог и ситуация требует разбора.

После успешной обработки

Каждый обработчик (processPostPending / processVoidPending) после фиксации TB-операции в одной транзакции:

  1. Обновляет intent.status (SETTLED или FAILED) — внутри db.transaction.
  2. Пишет settlement (только для post_pending) через writeSettlementpm.tx_history.
  3. Помечает outbox_event.status='processed' с processedAt — в той же транзакции.
  4. Пишет аудит-запись через writeIntentEventpm.intent_event (после commit).
  5. Публикует Redis-сигнал на канал intent.<id> (см. ниже) — best-effort.
  6. Публикует payload в Redis Stream stream.notifications.jobs для FCM-доставки — best-effort.

Шаги 1–3 атомарны; шаги 4–6 — best-effort после commit. Если сервис упадёт между 3 и 4, потерянный аудит-event можно восстановить только из логов outbox-worker (info: 'outbox: intent settled').


Redis pub/sub каналы

PM использует Redis pub/sub для эфемерных real-time сигналов: подписчик получает сообщение, только если был онлайн в момент publish. Это контракт для Serverpod streamStatus(intentId) → Flutter app.

Канал Продюсер (PM) Потребитель Payload
intent.<id> publishIntentStatus(intentId, status)src/intent/intent-events.ts. Также используется outbox-worker через локальный helper publishStatus(). Auth Center (streamStatus) → Flutter { intentId, status, updatedAt } (intent-events) / { status } (outbox-helper).

Точки вызова publishIntentStatus:

  • writeIntentEvent(..., publishFn) — если передан publishFn, вызывается асинхронно после insert в intent_event. Сбой publish — non-fatal (warn в лог, аудит-запись уже зафиксирована).
  • outbox-worker.processPostPendingpublishStatus(rec.id, 'SETTLED') после фиксации settlement.
  • outbox-worker.processVoidPendingpublishStatus(rec.id, 'FAILED') после void.

Пример payload (формат publishIntentStatus):

{
  "intentId": "550e8400-e29b-41d4-a716-446655440000",
  "status":   "SETTLED",
  "updatedAt": "2026-05-29T10:15:30.123Z"
}

Outbox-helper publishStatus отправляет упрощённый вариант {"status":"SETTLED"} — клиенту достаточно, т.к. он уже знает intentId из имени канала.

Гарантии:

  • Best-effort: ошибка Redis не откатывает БД-транзакцию (см. catch в publishStatus — только logger.warn).
  • Доставка не гарантирована — для durable истории используется intent_event (см. ниже).
  • Подписчики обязаны делать первичный select текущего статуса перед подпиской, иначе пропустят финальный SETTLED/FAILED, выпущенный до момента подписки.
  • Канал — Redis pub/sub (не Stream), без хранения и без consumer groups: отписался → сообщение потеряно.

Redis Streams: уведомления

Notifications Service (FCM push) подписан на единственный stream:

Stream Продюсер (PM) Потребитель Payload
stream.notifications.jobs publishPaymentNotification(intent, status)src/intent/notify.ts (XADD через выделенный notifClient). Notifications Service { userId, title, body, channel: 'payment', traceId, data: {...} }

Логика формирования payloads (src/intent/notify.ts):

  • На SETTLED для INVOICE_PAYMENT — два notification: покупателю (rec.userId) и мерчанту (rec.issuedByUserId).
  • На SETTLED для INTERNAL_P2P / SERVICE_TRANSFER / ADMIN — отправителю и получателю (если recipientUserId есть в metadata).
  • На SETTLED для остальных операций — только инициатору.
  • На FAILED — одно уведомление инициатору.

Канал доставки в payload: channel: 'payment' (это поле внутри notification, не путать с intent.channel).

Redis-клиент для notifications: если задан NOTIFICATIONS_REDIS_URL, используется отдельное соединение (см. getNotifRedis); иначе — основной Redis инстанс PM.


Заготовка на будущее: Redis Streams для PSP-адаптеров (Phase 2B)

Заготовка на будущее: в текущей Phase 1 PSP-логика (IPPS) выполняется in-process через psp-worker + PostgreSQL queue (psp_tx_map + FOR UPDATE SKIP LOCKED). Внешние Redis Streams ниже зарезервированы для Phase 2B, когда PSP-адаптеры будут вынесены в отдельные Node.js процессы. Имена и направления зафиксированы в корневом /CLAUDE.md — менять без апдейта корневого документа нельзя.

Цитата из /CLAUDE.md (раздел «Redis Streams»):

Stream Продюсер → Потребитель Фаза
stream.ipps.jobs / stream.ipps.results PM ↔ IPPS Adapter Phase 2B
stream.qp.jobs / stream.qp.results PM ↔ QP Adapter Phase 2B
stream.webhook.ipps Webhook Gateway → PM Phase 2B
stream.notifications.jobs PM / Auth Center / KYC → Notifications Service Phase 1 (уже активен)

Контракт Phase 2B (черновик):

  • PM публикует в stream.<psp>.jobs запросы query/confirm/inquiry.
  • PSP-адаптер читает jobs-stream через consumer group, исполняет HTTP-вызов в PSP, публикует результат в stream.<psp>.results.
  • PM читает results-stream, обновляет psp_tx_map, инициирует outbox post_pending / void_pending.
  • stream.webhook.ipps — отдельный канал для асинхронных уведомлений от PSP (settlement notifications, отзывы), производитель — Webhook Gateway.

Никакого кода для этих streams в Phase 1 нет. Раздел документирует резервирование имён, чтобы adapter-команда (Phase 2B) использовала именно их.


Связь с intent_event

pm.intent_eventdurable БД-таблица аудита жизненного цикла интента: каждая смена intent.status (включая виртуальные PSP-состояния EventStatus = IntentStatus | PspState) фиксируется одной строкой (intentId, statusFrom, statusTo, reason, payload, createdAt). Запись делает writeIntentEvent (src/intent/intent-events.ts).

Pub/sub-канал intent.<id>эфемерный runtime-сигнал того же события для real-time UI. Pub/sub-сообщение не заменяет запись в intent_event — оно дополняет её для случаев, когда клиент подписан в момент изменения.

Аспект intent_event (БД) intent.<id> (Redis pub/sub)
Persistence durable, навсегда эфемерно, без буферизации
Доставка гарантирована (insert в транзакции) best-effort (warn при сбое)
Назначение аудит, отчётность, debugging real-time UX (streamStatus → Flutter)
История полная (SELECT ORDER BY createdAt) только текущий момент

Полное описание таблицы и индексов — ../database/10-intent-event.md.


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

  • ../database/09-outbox-event.md — схема outbox-таблицы, индексы, retry-логика.
  • ../database/10-intent-event.md — схема аудит-таблицы.
  • ../database/07-psp-tx-map.md — состояния PSP (PspState), которые тоже могут попадать в intent_event.statusTo.
  • Корневой /CLAUDE.md — раздел «Redis Streams» (источник истины для Phase 2B имён).