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

01. Обзор архитектуры Payment Manager

Payment Manager (PM) — единственный платёжный оркестратор OneWallet: владеет TigerBeetle, принимает все intent'ы, исполняет правила и саги.

Роль PM в системе OneWallet

PM — сервис на Node.js 20 + Fastify 5 + Drizzle ORM, единственный обладатель доступа к TigerBeetle SDK на запись. Через него проходят все денежные операции: P2P-переводы внутри кошелька, IPPS-вывод и Thai QR Pay, top-up через QP, mini-app charge/credit, merchant invoice, admin- и service-переводы. PM реализует машину состояний intent'а (CREATED → VALIDATED → AUTHORIZED → SETTLED, плюс FAILED и MANUAL_REVIEW), хранит идемпотентность, политики, лимиты, fee rules и саги в схеме pm.* PostgreSQL. Никакой другой сервис не имеет права создавать или модифицировать TB-трансферы — это якорь модели безопасности и аудита.

Все вызовы в PM авторизуются единой HMAC-схемой (X-Service-Id, X-Timestamp, X-Signature по timestamp\nMETHOD\nPATH\nsha256(body)), а пользовательский контекст передаётся в X-User-Id. Внешние клиенты (Flutter, Admin Panel) никогда не ходят в PM напрямую — только через nginx (auth_request → Auth Center → PM) или через Auth Center, который сам формирует подпись. PSP-логика (IPPS) в Phase 1 живёт внутри PM как роль psp-worker и тянет задания из PostgreSQL-очереди pm.psp_tx_map (через FOR UPDATE SKIP LOCKED); Phase 2B вынесет адаптеры в отдельные процессы поверх Redis Streams. До этого момента вся PSP-машина состояний — это in-process workflow внутри PM-бинарника, и при горизонтальном масштабировании важно следить: одновременно живой может быть только одна реплика outbox-worker (см. Critical Rules в /projects/payment-manager/CLAUDE.md).

Подробный системный контекст и правила взаимодействия между сервисами — в корневом /CLAUDE.md и /docs/ARCHITECTURE.md. Этот документ — точка входа в docs/dev/architecture/; за деталями отдельных модулей идите в ./02-module-map.md и далее.

Inbound / Outbound диаграмма

flowchart LR
    %% ─────── Inbound ───────
    subgraph INBOUND[Inbound — HMAC clients]
        AUTH[Auth Center<br/>Serverpod 3.4.6]
        NGINX[nginx<br/>public ingress]
        ADMIN[Admin Panel<br/>SvelteKit]
        MINIAPP[Mini-App backends<br/>HMAC service keys]
    end

    %% ─────── Payment Manager ───────
    subgraph PM[Payment Manager — Node.js 20 + Fastify 5]
        API[HTTP API<br/>POST /intents · /confirm · /cancel<br/>POST /accounts · /policies · /admin/*]
        HMAC[hmacPlugin<br/>X-Service-Id · X-Timestamp · X-Signature]
        INTENT[Intent state machine<br/>CREATED → VALIDATED → AUTHORIZED → SETTLED]
        RULES[Rule engine<br/>fees · limits · policies]
        SAGA[Saga / channel registry<br/>INTERNAL · IPPS · QP · MERCHANT_INVOICE · ADMIN]
        OUTBOX[OutboxWorker<br/>POST_PENDING TB transfers]
        PSPW[psp-worker<br/>PostgreSQL queue · IPPS driver]
        INVEXP[invoice-expiry sweep]
        PG[(PostgreSQL<br/>schema pm.*)]
    end

    %% ─────── Outbound ───────
    subgraph OUTBOUND[Outbound]
        TB[(TigerBeetle<br/>accounts · pending/post transfers)]
        IPPS[IPPS PSP<br/>PromptPay · Thai QR]
        REDIS[(Redis<br/>pub/sub intent.&#123;id&#125;)]
        NOTIF[Notifications Service<br/>stream.notifications.jobs]
        AUTHCB[Auth Center<br/>стрим intent.&#123;id&#125; → Flutter]
    end

    %% Inbound → PM
    AUTH    -- POST /intents (HMAC)         --> HMAC
    NGINX   -- GET /api/pm/accounts/balance --> HMAC
    ADMIN   -- POST /admin/* · /fee-rules   --> HMAC
    MINIAPP -- POST /intents (MINIAPP_*)    --> HMAC

    HMAC --> API --> INTENT
    INTENT --> RULES --> SAGA
    SAGA --> PG
    SAGA --> OUTBOX
    SAGA --> PSPW
    INVEXP --> PG

    %% PM → Outbound
    OUTBOX -- createTransfers / POST_PENDING --> TB
    SAGA   -- createTransfers (PENDING)      --> TB
    PSPW   -- HTTPS PPXC                     --> IPPS
    OUTBOX -- PUBLISH intent.&#123;id&#125;        --> REDIS
    SAGA   -- XADD stream.notifications.jobs --> NOTIF
    REDIS  -- SUBSCRIBE intent.&#123;id&#125;       --> AUTHCB

Стрелки слева направо — это разрешённый граф трафика. Любая стрелка, не нарисованная здесь (например, Auth Center → TigerBeetle, PSP → TigerBeetle, Admin Panel → TigerBeetle write), запрещена по политике из /CLAUDE.md.

Inbound в деталях

  • Auth Center (Serverpod). Основной clientы PM. Делает POST /intents для всех платёжных потоков, инициированных из Flutter (P2P_TRANSFER, IPPS_WITHDRAWAL, THAI_QR_PAY, MINIAPP_CHARGE/MINIAPP_CREDIT, WITHDRAWAL, QP_TOPUP, NFC_CHARGE, INVOICE_PAYMENT). Подписывается своим service_id и SERVICE_SECRET. После создания intent'а подписывается на Redis-канал intent.{id} и пересылает события в Flutter через Serverpod stream-endpoint.
  • nginx (public ingress). Обслуживает прямые read-запросы /api/pm/* от Flutter: balance, transaction history, прочие idempotent read'ы. Резолвит JWT через auth_request → Auth Center, получает X-User-Id и подписывает запрос своим ключом nginx-gateway. Write-роуты PM (/intents, /intents/*) на public-ingress отдают 404 — добраться до них можно только из внутренней сети.
  • Admin Panel. Использует свой service_id для административных endpoints: POST /admin/resolve-intent (force-transition после ручного разбора), POST /admin/fee-rules, POST /admin/debug-level, POST /accounts/register-ipps (provision Stage-2 IPPS-кошелька). Также делает HMAC-подписанные write-операции по mini-app onboarding (pm.service_key + tb.createAccounts).
  • Mini-App backends (Phase 2B). Каждый mini-app имеет свой service_id = miniapp-{id} и собственный HMAC-секрет. Может вызывать только MINIAPP_CHARGE и MINIAPP_CREDIT, и только на аккаунтах своего merchantId — это enforced в pm.service_key.permissions и проверяется в saga до создания TB-transfers.

Outbound в деталях

  • TigerBeetle. Единственный источник истины по балансам. PM держит подключение через TB Node.js SDK (src/shared/tb.ts), которое поднимается на старте с retry (5 попыток × экспоненциальная задержка). Все денежные движения — это createTransfers с двухфазным паттерном PENDING → POST_PENDING (или VOID_PENDING при отказе). Linked-флаг гарантирует атомарность группы трансферов.
  • PSP-каналы (IPPS, QP). В Phase 1 — внутренний driver через HTTPS PPXC; в Phase 2B — через Redis Streams (stream.ipps.jobs/stream.ipps.results, stream.qp.jobs/stream.qp.results). PSP не имеет прямого доступа ни к TB, ни к PostgreSQL PM'а.
  • Redis pub/sub intent.{id}. OutboxWorker публикует каждое изменение состояния intent'а в Redis-канал. Auth Center подписан на канал и пересылает события в Flutter через Serverpod-streaming endpoint. Если канал недоступен — Flutter может опросить getIntent(id) (fallback на polling).
  • Notifications Service. Через Redis Stream stream.notifications.jobs PM отправляет задания на push-уведомления (FCM/APNs) — например, после успешного P2P-перевода получателю или после settlement IPPS-операции.

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

Эти правила load-bearing для всех платёжных потоков. Любое изменение, нарушающее хотя бы один из них, должно явно пройти ревью архитектора и обновить этот документ.

  1. PM — единственный write-клиент TigerBeetle. Создание аккаунтов и трансферов (createAccounts, createTransfers, в т.ч. PENDING/POST_PENDING/VOID_PENDING) идёт только из PM. Admin Panel допускается только в read-only режиме (lookup/query). PSP-адаптеры, Auth Center, mini-app backend'ы — без прямого доступа. Это позволяет держать весь аудит платежей в одном бинарнике и не размазывать машину состояний по сервисам.
  2. PM никогда не пишет в public.*. Эта схема принадлежит Auth Center / Serverpod и мигрируется только через serverpod generate + dart bin/main.dart --apply-migrations. PM подключается к БД с search_path=pm,public и читает public.* только через SELECT (для join'ов с пользовательскими таблицами). Своя схема pm.* мигрируется исключительно drizzle-kit migrate — никаких ручных SQL. Обратная сторона: Serverpod читает pm.* только через views в public.* (public.v_user_tb_accounts, public.v_tx_history) с GRANT SELECT.
  3. HMAC обязателен на каждом inbound-запросе. Заголовки X-Service-Id, X-Timestamp, X-Signature (HMAC-SHA256 по timestamp\nMETHOD\nPATH\nsha256(body)) проверяются src/auth/hmacPlugin.ts на каждом scoped route. Без подписи запрос отклоняется 401. «Доверенных внутренних» вызовов без HMAC не существует. Секреты сервисов берутся только из env (SERVICE_SECRETS), хардкод запрещён. rawBody регистрируется runFirst: true — иначе хеш body будет считаться по уже разобранному JSON и подпись разломается.
  4. transit.balance = 0 — главный финансовый инвариант. Каждый канал использует двухфазный pending/post трансфер: средства уходят с пользовательского счёта на per-channel transit, оттуда — на получателя и fee-аккаунты, всё линкованно в одном batch. После SETTLED каждый transit-аккаунт обязан вернуться к нулю. Гарантируется linked flag TigerBeetle: если хоть один трансфер в группе отвалился, откатываются все. Reconciler на старте PM сверяет реальное состояние TB с ожидаемым (см. src/intent/startup-reconciler.ts) — если transit не ноль, intent уходит в MANUAL_REVIEW.
  5. trace_id = intent_id. В каждой строке лога, в каждом сообщении Redis Stream, в каждом OpenTelemetry-span'е, который касается платежа, обязано присутствовать поле intentId. По нему сшивается полный путь от POST /intents до публикации intent.{id} и попадания в pm.tx_history. Никаких альтернативных trace-id'ов на платёжном пути. Это упрощает корреляцию между сервисами (Auth Center stream, PM logs, IPPS adapter logs, Notifications worker).
  6. Auto-resolve аккаунтов по роли ЗАПРЕЩЁН. Auth Center и любой другой caller всегда передают в PM явные accountName (например user.42.available.THB, merchant.7.settlement.THB). PM не имеет «магической» логики, выбирающей аккаунт по роли пользователя или типу операции. Резолвинг идёт строго через pm.tb_account_map по имени; направление DEBIT/CREDIT определяется tb_account_map.userId, а не префиксом имени. См. memory feedback-account-resolution (Auth Center передаёт explicit account names — PM никогда не auto-resolve'ит по роли) и feedback-limit-direction (используем tb_account_map.userId, не name prefix). Это правило закрывает целый класс багов «не туда списали».
  7. TB account IDs детерминированы. Идентификатор аккаунта в TigerBeetle вычисляется как uuidv5("onewallet-tb", accountName). Это позволяет верифицировать соответствие name ↔ id в любой момент без внешнего state: достаточно знать имя. Никаких случайных UUID'ов, никаких счётчиков, никаких ребрендингов имён без миграции pm.tb_account_map. Если кто-то нашёл в логах TB ID — он за минуту найдёт human-readable имя и наоборот.

Запретные значения (чтобы не путаться при чтении кода)

  • Канал зовётся ADMIN, не ADMIN_TRANSFER (последнее — название operation type внутри канала). В registerChannel(adminTransferChannel) зарегистрирован канал ADMIN, а внутри саги он обслуживает op-type ADMIN_TRANSFER.
  • В машине состояний intent'а нет статусов RESERVED / INIT / DONE — это унаследованная терминология из ранней Serverpod-реализации, она удалена. Реальные статусы: CREATED, VALIDATED, AUTHORIZED, SETTLED, FAILED, MANUAL_REVIEW. Если где-то в коде или документах встретятся RESERVED/INIT/DONE — это либо legacy для удаления, либо bug.
  • Реальный набор WORKER_ROLES (из config.WORKER_ROLES в src/server.ts): api, outbox-worker, psp-worker, invoice-expiry. Других ролей нет. В production-обвязке обычно крутятся: одна реплика с api, одна с outbox-worker (строго одна!), несколько с psp-worker, одна с invoice-expiry.

Точки входа и наблюдаемость

  • HTTP API поднимается в src/server.ts (buildApp() + entrypoint в нижней части файла). Swagger UI доступен по /docs (защищён CSP-исключением — Swagger требует inline-скрипты).
  • Логгер — Pino (src/shared/logger.ts), все логи структурированы в JSON, обязательное поле intentId на платёжном пути.
  • Health-checks — GET /healthz, GET /readyz (см. src/admin/health.ts); underPressure плагин экспонирует event-loop-delay метрики и автоматически возвращает 503 при перегрузке.
  • Error-handler централизован в setErrorHandler (см. src/server.ts): PaymentError → mapping в code+statusCode+detail, Zod-ошибки → 422, всё остальное → 500.
  • requestContext плагин Fastify держит per-request scope (для проброса intentId и serviceId в логи без явного прокидывания).
  • genReqId: () => crypto.randomUUID() — каждому HTTP-запросу присваивается отдельный reqId (он не равен intentId; для платёжного потока сшивка идёт по intentId).

Жизненный цикл запроса POST /intents

  1. Bootstrapping. buildApp() регистрирует Fastify-плагины: rawBody (runFirst для HMAC), helmet, cors, sensible, requestContext, underPressure, Swagger.
  2. HMAC. Запрос попадает в scope с hmacPlugin. Заголовки проверяются, request.serviceId устанавливается.
  3. Парсинг и валидация. Zod-схема в intentRoutes валидирует тело; operationType резолвится через operation-registry.
  4. Машина состояний. CREATEDVALIDATED (правила, fees, limits) → AUTHORIZED (TB PENDING batch).
  5. Канал-специфичная развязка. INTERNAL_P2P — settle синхронно в том же запросе (SETTLED до возврата response'а). Остальные каналы — возвращают requiresMonitoring=true и завершают settlement через outbox-worker или psp-worker асинхронно.
  6. Outbox. OutboxWorker подбирает строки из pm.outbox и завершает TB transfers (POST_PENDING) + публикует intent.{id} в Redis.
  7. PSP. psp-worker тянет из pm.psp_tx_map, делает HTTPS вызов в IPPS PPXC, разбирает результат, либо помечает intent как SETTLED, либо отправляет в MANUAL_REVIEW при неопределённости (orphan confirmRqUid).
  8. Auth Center → Flutter. Auth Center подписан на intent.{id} и пересылает события на клиент через стрим Serverpod.

Реестры (registries) внутри PM

PM использует несколько глобальных реестров, заполняемых на старте в src/server.ts:

  • operation-registry (registerOperationType). Каждый operation type (P2P_TRANSFER, IPPS_WITHDRAWAL, THAI_QR_PAY, MINIAPP_CHARGE, MINIAPP_CREDIT, WITHDRAWAL, QP_TOPUP, SERVICE_DEPOSIT, ADMIN_TRANSFER, NFC_CHARGE, INVOICE_PAYMENT) описывается отдельным модулем в src/operation-types/ и регистрируется при старте.
  • step-registry (registerChannel). Каналы (INTERNAL, IPPS, SERVICE, ADMIN, MERCHANT_INVOICE) реализуют последовательность шагов саги (validate → authorize → settle). Регистрация идёт в entrypoint'е.
  • PSP drivers (bootstrapPspDrivers). На старте инициализируется IPPS driver; в Phase 1 он работает in-process, в Phase 2B будет вынесен.

Реестры устроены так, чтобы добавление нового operation type или канала не требовало правки центрального switch'а — достаточно создать модуль и вызвать registerOperationType / registerChannel в entrypoint'е.

Роли воркеров и их жизненный цикл

config.WORKER_ROLES — Zod-парсенный список, активирующий фоновые задачи в src/server.ts:

Роль Что делает Concurrency
api Открывает HTTP-порт через app.listen(). Без этой роли PM запускается в headless-режиме. Можно несколько реплик за балансировщиком.
outbox-worker Раз в OUTBOX_INTERVAL_MS обрабатывает pm.outbox: завершает TB transfers, публикует intent.{id}. Строго одна реплика — иначе двойное POST_PENDING.
psp-worker Для каждого PSP из PSP_NAMES поднимает worker, который опрашивает pm.psp_tx_map через FOR UPDATE SKIP LOCKED. Параллельно — balance-monitor если BALANCE_MONITOR_ENABLED. Несколько реплик безопасны (SKIP LOCKED).
invoice-expiry Раз в INVOICE_EXPIRY_SWEEP_INTERVAL_MS сметает просроченные merchant-invoice'ы (батч INVOICE_EXPIRY_BATCH_SIZE). Несколько реплик допустимы, но обычно достаточно одной.

Корректное завершение: PM ловит SIGTERM/SIGINT и через app.close() гарантированно дренирует TB-сокет, Redis-подключение и интервальные таймеры. Без этого heplerss-режим (без роли api) убивался бы сразу — Fastify в headless не ставит свой signal-handler.

Хранилища и их роль

Хранилище Назначение Доступ PM
TigerBeetle Source of truth балансов и трансферов. Аккаунты (account), pending/post трансферы (transfer). Read+write через TB Node.js SDK (src/shared/tb.ts).
PostgreSQL pm.* Intent'ы, idempotency, outbox, saga state, fee rules, политики, лимиты, tb_account_map, psp_tx_map, tx_history, account_balance снапшоты, service_key. Read+write через Drizzle ORM (src/shared/db.ts).
PostgreSQL public.* Сервиспод-таблицы (Auth Center): user, miniapp, KYC. Только read (joins для контекста), запись запрещена.
Redis Pub/sub intent.{id} для live status, streams для уведомлений и (Phase 2B) для PSP-адаптеров, кеш лимитов. Read+write через src/shared/redis.ts.

Каждое из хранилищ имеет свою retry-политику. TB подключение на старте делает 5 попыток с экспоненциальной задержкой (TB_RETRY_ATTEMPTS=5, TB_RETRY_DELAY_MS=2000); если не получилось — process.exit(1) и под рестартует через Kubernetes. PostgreSQL подключение делается через драйверный pool без явного retry — короткие сетевые сбои закрываются драйвером, длинные подымают ошибку наружу. Redis — fire-and-forget для status pub/sub (Flutter падает на polling), и at-least-once для streams (через consumer groups).

Безопасность по слоям

  • Транспорт. TLS терминирует nginx на public ingress. Внутри cluster'а трафик идёт по plain HTTP в private network.
  • Аутентификация. HMAC на каждом inbound; X-User-Id — это контекст пользователя, не аутентификация (его подделать нельзя, потому что подпись считается по всему запросу включая хедеры).
  • Авторизация. Через pm.service_key.permissions: какие operation types разрешены, на каких merchant'ах. Включается на этапе валидации intent'а.
  • Аудит. Каждый платёжный шаг логируется со структурированным intentId. TB сам по себе — append-only ledger, restoring/replay возможен.
  • Конфиденциальность. PII (имена, телефоны, документы) живёт в public.*. В pm.* хранятся только идентификаторы (user_id, merchant_id) — PM никогда не получает прямой видимости в личные данные.

Phase 1 vs Phase 2B

Текущее состояние — Phase 1: PSP-логика live in-process внутри PM, mini-app backend'ы не подключены, webhook-gateway ещё нет. Phase 2B приносит:

  • IPPS- и QP-адаптеры в отдельных Node.js-процессах поверх Redis Streams (stream.ipps.*, stream.qp.*).
  • Mini-app backend'ы как первоклассные HMAC-клиенты с собственными service_id = miniapp-{id}.
  • Webhook Gateway, принимающий inbound webhooks от PSP и пересылающий их в PM через stream.webhook.*.

Все 7 инвариантов из этого документа остаются неизменными при переходе на Phase 2B — изменяется только транспорт и расселение процессов.

Глоссарий ключевых терминов

  • Intent — единичная платёжная операция. Создаётся POST /intents, проходит через машину состояний, материализуется в pm.intent + TB-трансферы. intent_id — UUID v4, является и trace_id.
  • OperationType — тип операции (P2P_TRANSFER, IPPS_WITHDRAWAL, ...). Определяет, какой канал/сагу выбрать. Регистрируется в operation-registry.
  • Channel — стратегия исполнения саги: INTERNAL (синхронно), IPPS/QP (через PSP), ADMIN (ручные корректировки), SERVICE (deposit/credit от системы), MERCHANT_INVOICE. Регистрируется в step-registry.
  • Transit account — TB-аккаунт-перехватчик внутри саги. Между debit и credit-фазой деньги «стоят» на transit; после settlement transit обязан быть равен нулю.
  • Pending / Post / Void Pending — TB-механика двухфазного трансфера. PM создаёт PENDING на этапе AUTHORIZED, OutboxWorker делает POST_PENDING (успех) или VOID_PENDING (отмена) на этапе SETTLED/FAILED.
  • Outbox — таблица pm.outbox для гарантированной доставки эффектов (TB POST_PENDING, Redis publish, notifications). OutboxWorker — fan-out этих эффектов.
  • Saga — пошаговая сагa в src/intent/ + src/channels/. Каждый шаг идемпотентен и переживает crash.
  • HMAC service key — запись в pm.service_key: service_id, секрет (env), allowed operation types, опциональный merchantId. Используется для авторизации inbound запросов.
  • tb_account_map — справочник «имя TB-аккаунта → UUID v5 + метаданные (userId, role, валюта)». Источник истины для resolving аккаунтов внутри саги.
  • psp_tx_map — очередь PSP-задач: связка intent_idconfirmRqUid IPPS, статус, retry-метки. psp-worker берёт работы через FOR UPDATE SKIP LOCKED.
  • tx_history — материализованный feed транзакций для Flutter (читается через view public.v_tx_history).

FAQ для новых контрибьюторов

  • «Почему у меня запрос отлетает с 401 даже при правильном service_id Скорее всего рассинхронизация секрета (env vs клиент) или body хешируется до того, как rawBody его сохранил. Проверь, что HMAC рассчитан по тем же байтам, что отправлены (включая порядок ключей в JSON).
  • «Можно ли вызвать TB напрямую из Auth Center, если у меня read-only запрос?» Нет. Даже read'ы идут через nginx → PM. Это позволяет PM держать единые лимиты и кеши.
  • «Что делать, если intent залип в AUTHORIZED Сначала проверь outbox-worker — он жив? pm.outbox обрабатывается? Если worker мёртв — restart. Если worker жив, но не двигает intent — смотри логи саги по intentId. Resolve через POST /admin/resolve-intent — крайняя мера.
  • «В каком порядке регистрировать новый OperationType?» Создай модуль в src/operation-types/, добавь registerOperationType(myOp) в src/server.ts (там же где остальные), при необходимости — новый канал через registerChannel. Не забудь добавить тесты в test/intent/ и описание в /projects/payment-manager/PASSPORT.md.

Что дальше

  • Детальная карта модулей PM и точек входа — в ./02-module-map.md.
  • Графическая схема (исторически — D2 → PNG) лежит в ../../diagrams/pm-architecture.png. Возможно устарела — при расхождении считать источником истины Mermaid-схему выше.
  • Поверхностный системный контекст (все сервисы OneWallet) — /docs/ARCHITECTURE.md.
  • Кросс-сервисные правила и переменные окружения, общие для всех проектов, — /CLAUDE.md.
  • Канонический контракт PM (DTO, парсеры) — /projects/payment-manager/PASSPORT.md. Сверяться перед любыми изменениями API.