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.{id})]
NOTIF[Notifications Service<br/>stream.notifications.jobs]
AUTHCB[Auth Center<br/>стрим intent.{id} → 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.{id} --> REDIS
SAGA -- XADD stream.notifications.jobs --> NOTIF
REDIS -- SUBSCRIBE intent.{id} --> 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.jobsPM отправляет задания на push-уведомления (FCM/APNs) — например, после успешного P2P-перевода получателю или после settlement IPPS-операции.
Ключевые инварианты¶
Эти правила load-bearing для всех платёжных потоков. Любое изменение, нарушающее хотя бы один из них, должно явно пройти ревью архитектора и обновить этот документ.
- PM — единственный write-клиент TigerBeetle. Создание аккаунтов и трансферов (
createAccounts,createTransfers, в т.ч. PENDING/POST_PENDING/VOID_PENDING) идёт только из PM. Admin Panel допускается только в read-only режиме (lookup/query). PSP-адаптеры, Auth Center, mini-app backend'ы — без прямого доступа. Это позволяет держать весь аудит платежей в одном бинарнике и не размазывать машину состояний по сервисам. - 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. - 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 и подпись разломается. transit.balance = 0— главный финансовый инвариант. Каждый канал использует двухфазный pending/post трансфер: средства уходят с пользовательского счёта на per-channel transit, оттуда — на получателя и fee-аккаунты, всё линкованно в одном batch. После SETTLED каждый transit-аккаунт обязан вернуться к нулю. Гарантируетсяlinkedflag TigerBeetle: если хоть один трансфер в группе отвалился, откатываются все. Reconciler на старте PM сверяет реальное состояние TB с ожидаемым (см.src/intent/startup-reconciler.ts) — если transit не ноль, intent уходит вMANUAL_REVIEW.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).- 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, а не префиксом имени. См. memoryfeedback-account-resolution(Auth Center передаёт explicit account names — PM никогда не auto-resolve'ит по роли) иfeedback-limit-direction(используемtb_account_map.userId, не name prefix). Это правило закрывает целый класс багов «не туда списали». - 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-typeADMIN_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¶
- Bootstrapping.
buildApp()регистрирует Fastify-плагины:rawBody(runFirst для HMAC),helmet,cors,sensible,requestContext,underPressure, Swagger. - HMAC. Запрос попадает в scope с
hmacPlugin. Заголовки проверяются,request.serviceIdустанавливается. - Парсинг и валидация. Zod-схема в
intentRoutesвалидирует тело;operationTypeрезолвится черезoperation-registry. - Машина состояний.
CREATED→VALIDATED(правила, fees, limits) →AUTHORIZED(TB PENDING batch). - Канал-специфичная развязка.
INTERNAL_P2P— settle синхронно в том же запросе (SETTLEDдо возврата response'а). Остальные каналы — возвращаютrequiresMonitoring=trueи завершают settlement черезoutbox-workerилиpsp-workerасинхронно. - Outbox. OutboxWorker подбирает строки из
pm.outboxи завершает TB transfers (POST_PENDING) + публикуетintent.{id}в Redis. - PSP.
psp-workerтянет изpm.psp_tx_map, делает HTTPS вызов в IPPS PPXC, разбирает результат, либо помечает intent как SETTLED, либо отправляет в MANUAL_REVIEW при неопределённости (orphanconfirmRqUid). - 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_id↔confirmRqUidIPPS, статус, retry-метки.psp-workerберёт работы черезFOR UPDATE SKIP LOCKED.tx_history— материализованный feed транзакций для Flutter (читается через viewpublic.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.