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

Модуль shared

Низкоуровневая инфраструктура Payment Manager: env-конфиг, singleton-клиенты к PostgreSQL/TigerBeetle/Redis, pino-логгер, доменные ошибки, валютная арифметика, QR-подпись инвойсов и Drizzle-схема.

1. Назначение

Каталог src/shared/* содержит «нулевой слой» PM — модули без бизнес-логики, на которые опираются все остальные модули (intent, channels, ledger, limits, rule-engine, psp, merchant, policies и т.д.). Здесь живут:

  • единая точка валидации env через Zod (config.ts);
  • singletons для подключения к внешним системам (PostgreSQL/Drizzle, TigerBeetle, Redis);
  • pino-логгер с pretty-форматом для dev и silent для test;
  • иерархия доменных ошибок (PaymentError и наследники), пробрасываемых HTTP-обработчиками;
  • утилиты конвертации мажорных/минорных валютных единиц (БAT ↔ сатанг и пр.);
  • HMAC-SHA256 подпись QR-payload для канала MERCHANT_INVOICE;
  • словарь человекочитаемых названий операций для push-уведомлений;
  • Drizzle-описание всех таблиц схемы pm.* (schema.ts).

Кто зависит: каждый модуль PM напрямую импортирует хотя бы один файл из shared/. Чаще всего — config, db, logger.

2. Структура файлов

Все 10 файлов лежат в src/shared/:

Файл Что делает
config.ts Zod-схема всех env-переменных; экспортирует config (validated) и тип Config. Падает на старте, если env невалиден.
currency.ts currencyMultiplier(), toMinorUnits(), fromMinorUnits() — конверсия мажор↔минор с учётом zero/three-decimal валют.
db.ts postgres-js клиент (prepare:false для PgBouncer transaction-mode) + Drizzle ORM. Экспортирует db, типы Db, DbOrTx.
errors.ts Базовый PaymentError и наследники: UnauthorizedError, ConflictError, BadRequestError, ValidationError, InsufficientFundsError, LimitExceededError, TransactionInProgressError, ForbiddenError, NotFoundError.
logger.ts pino-инстанс. В dev — pino-pretty, в test — silent.
notification-labels.ts Константа OPERATION_LABELS: Record<string, string> — человекочитаемые названия операций для FCM-push.
qr-signature.ts signQrPayload() / verifyQrPayload() — HMAC-SHA256 (16 байт) для подписи QR-payload MERCHANT_INVOICE.
redis.ts ioredis singleton (getRedis(), closeRedis()). Подключается лениво, ошибки логирует и не падает (publish — best-effort).
schema.ts Drizzle-определения всех таблиц pm.* и экспортируемые типы строк (Intent, TbAccountMap, PspTxMap, ...). DDL не дублируется здесь — см. ../reference/database/.
tb.ts TigerBeetle Node SDK singleton (connectTb(), getTb(), closeTb()). Резолвит DNS-хостнейм в IP, потому что TB принимает только IP-адреса.

3. Ключевые типы и интерфейсы

schema.ts экспортирует широкий набор union-типов, на которые опираются доменные модули. Полный DDL и комментарии — в ../reference/database/; ниже только сами TS-типы.

// Статусы платёжного интента (src/shared/schema.ts)
export type IntentStatus =
  | 'CREATED' | 'VALIDATED' | 'AUTHORIZED' | 'SETTLING'
  | 'SETTLED' | 'FAILED' | 'MANUAL_REVIEW'
  | 'CANCELED' | 'EXPIRED'

// Тип TB-аккаунта в pm.tb_account_map
export type AccountType =
  | 'USER_WALLET' | 'TRANSIT' | 'REVENUE' | 'NOSTRO'
  | 'MERCHANT_WALLET' | 'MERCHANT_SETTLEMENT'
  | 'AGENT_WALLET'    | 'AGENT_SETTLEMENT'
  | 'SERVICE_ACCOUNT' | 'EQUITY'

// PSP-стейт-машина в pm.psp_tx_map
export type PspName  = 'IPPS' | 'QP' | 'WISE'
export type PspState =
  | 'PENDING' | 'INQUIRY' | 'SETTLED'
  | 'FAILED'  | 'MANUAL_REVIEW' | ...

// Направление транзакции в pm.tx_history
export type TxDirection = 'DEBIT' | 'CREDIT'

// Outbox-воркер
export type OutboxAction = 'post_pending' | 'void_pending'
export type OutboxStatus = 'pending' | 'processed' | 'failed'

// Полиси step-up аутентификации
export type StepUpLevel = 'NONE' | 'PIN' | 'BIOMETRIC' | 'OTP' | 'KYC_UPLIFT'

errors.ts экспортирует иерархию ошибок, которая обрабатывается единым error-handler в Fastify (см. src/server.ts). Каждая несёт code: string и statusCode: number, что позволяет HTTP-роуту не знать о деталях:

class PaymentError extends Error {
  constructor(public code: string, message: string, public statusCode = 400)
}

interface LimitExceededDetail {
  ruleName:  string
  window:    string
  limitType: 'amount' | 'count'
  limit:     bigint
  current:   bigint
  requested: bigint
}

qr-signature.ts определяет каноническую форму payload-а:

interface QrCanonical {
  intentId:            string
  merchantTbAccountId: string
  amountSatang:        bigint
  currency:            string
  expiresAtUnix:       number
  appScope:            'w' | 'c' | 'a'    // wallet / cashier / agent
}

// Каноническая строка для HMAC:
// `${intentId}|${merchantTbAccountId}|${amountSatang}|${currency}|${expiresAtUnix}|${appScope}`
// Любое изменение порядка/разделителя — breaking change для всех ранее выпущенных QR.

db.ts экспортирует тип DbOrTx, который позволяет писать функции, работающие и с базовым db, и с активной транзакцией:

type DbOrTx = Db | Parameters<Parameters<typeof db.transaction>[0]>[0]

// Пример: ledger/transfers.ts принимает либо db, либо tx из db.transaction(...)
function recordTransfer(dbOrTx: DbOrTx, transfer: NewTransfer) { /* ... */ }

4. Основные функции

Функция Файл Описание
config (const) config.ts Распаршенный объект env, валидированный через Zod. Падение на старте, если поле не прошло проверку.
db (const) db.ts Drizzle-инстанс поверх postgres-js. Пул max:10 (соответствует контракту PgBouncer transaction-mode для PM).
connectTb(address) tb.ts Резолвит DNS → IP и поднимает TB-клиент. Зовётся ровно один раз из server.ts.
getTb() tb.ts Возвращает singleton; бросает ошибку, если connectTb() не был вызван.
closeTb() tb.ts Грейсфул shutdown — вешается на Fastify onClose.
getRedis() redis.ts ioredis singleton с lazyConnect:false и maxRetriesPerRequest:3. Best-effort: ошибки только в лог.
closeRedis() redis.ts Грейсфул quit().
logger (const) logger.ts pino-инстанс — единственный способ писать логи в PM. Никаких console.log.
signQrPayload(c) qr-signature.ts HMAC-SHA256(INVOICE_QR_SECRET, canonical)[:16].
verifyQrPayload(c, sig) qr-signature.ts Timing-safe сравнение (crypto.timingSafeEqual) — защита от timing-атак.
toMinorUnits(amount, currency) currency.ts Float БАТ → integer сатанг (через Math.round, чтобы не словить float-drift).
fromMinorUnits(amount, currency) currency.ts Integer минор → float мажор. Используется на границах с PSP-API.
currencyMultiplier(currency) currency.ts Возвращает 1, 100 или 1000 в зависимости от ISO-кода.

5. Жизненный цикл

Все клиенты в shared/singletons, инициализируются единожды на старте процесса в src/server.ts:

  1. Импорт ./shared/config — синхронная валидация env. Любой провал — process.exit(1) с дампом всех Zod-ошибок (parsed.error.flatten().fieldErrors).
  2. connectTb(config.TB_ADDRESS) — с ретраями (см. TB_RETRY_ATTEMPTS в server.ts). После всех неудач — abort, потому что PM без TigerBeetle бесполезен.
  3. getRedis() вызывается лениво на первом обращении (publish-ивент, BullMQ из других модулей и т.д.). Ошибки соединения логируются, но не убивают процесс — publish считается best-effort.
  4. db (Drizzle) держит пул postgres-js (max 10) — соединения создаются по требованию. connect_timeout: 5 и idle_timeout: 30 гарантируют, что зависший PgBouncer не «прожуёт» весь пул.

Грейсфул shutdown через Fastify onClose hooks: closeTb()closeRedis() → drizzle закрывается косвенно через postgres-js (отдельный close не вызывается, процесс просто завершается). Воркеры (outbox, psp-worker, invoice-expiry-sweep) останавливаются по таймерам в server.ts.

logger доступен сразу при импорте — config.NODE_ENV уже спарсен (pino определяет transport по результату). В test-режиме уровень silent, чтобы не засорять вывод Vitest.

6. Конфигурация

Полное описание env-переменных — в ../reference/env-vars.md. Ниже только ключевые для shared/:

Env Где читается Описание
NODE_ENV config.ts, logger.ts, db.ts development включает pino-pretty и Drizzle logger; test гасит логгер.
DATABASE_URL db.ts Строка подключения к PostgreSQL (через PgBouncer transaction-mode).
REDIS_URL redis.ts Основной Redis (publish intent.{id}, BullMQ, идемпотентность).
NOTIFICATIONS_REDIS_URL (опционально) Отдельный Redis для FCM-stream. На сегодня не используется в shared/redis.ts — модуль intent/notify.ts сам поднимает клиент при наличии.
TB_ADDRESS tb.ts host:port TigerBeetle. DNS-имя резолвится автоматически в IP.
INVOICE_QR_SECRET qr-signature.ts Секрет HMAC для подписи QR-payload (минимум 32 символа). Без него процесс не стартует.
ADMIN_SECRET config.ts (используется в src/admin/*) Bearer для админ-роутов.

7. Тестирование

Тест Что покрывает
test/shared/currency.test.ts Корректность toMinorUnits/fromMinorUnits для THB/USD/JPY/KWD; защита от float-drift; round-trip конверсия.
test/shared/qr-signature.test.ts Длина подписи (16 байт), детерминированность, чувствительность к amount/scope/expiresAt, timing-safe сравнение, отказ при неверной длине sig.
test/config.test.ts Zod-валидация: успешный парсинг и явные failure cases (короткий INVOICE_QR_SECRET, мусор в SERVICE_SECRETS, невалидный DATABASE_URL).
test/hmac.test.ts HMAC-аутентификация сервисных ключей (использует SERVICE_SECRETS из config.ts).

Отдельных тестов на db.ts, redis.ts, tb.ts нет: они тестируются опосредованно — через интеграционные тесты модулей (см. test/integration/*, test/ledger/*, test/intent/*). logger молчит в test-режиме, поэтому отдельный тест не требуется. errors.ts покрывается через тесты роутов (test/intent/*, test/limits/*), которые проверяют конкретные statusCode и code.

8. Связанные модули

Каждый модуль PM импортирует хотя бы один файл из shared/. Прямые зависимости:

Модуль Что использует
intent db, getRedis, getTb, logger, config, errors, schema (типы Intent/IntentStatus). notification-labels через intent/notify.ts.
channels db, getTb, logger, errors, qr-signatureMerchantInvoiceChannel).
ledger db, getTb, logger, errors, schema (TbAccountMap, types).
limits db, logger, errors (LimitExceededError + detail), schema (LimitRule).
policies db, logger, schema (AuthPolicy, StepUpLevel).
psp db, getRedis, logger, config (IPPS_*), schema (PspTxMap).
rule-engine db, schema (FeeRule), currency.ts (нормализация валют).
operations schema, errors, currency.ts.
merchant db, qr-signature, errors.

Канал INTERNAL_P2P напрямую не использует qr-signature (это исключительно MERCHANT_INVOICE). INTERNAL и другие имена каналов — см. channels.md.

Дополнительно shared/ опосредованно связан со всеми API-роутами через единый Fastify error-handler: любая ошибка-наследник PaymentError мапится в HTTP-ответ по statusCode + code без модификации. Это значит, что добавление нового подкласса в errors.ts автоматически даёт корректный HTTP-ответ во всех роутах — без правки server.ts.

notification-labels.ts используется ровно в одном месте — src/intent/notify.ts (build PaymentNotification.title). Это сознательно вынесено в shared/, чтобы при появлении других мест публикации push (например, KYC-сервис из PM) переиспользовать тот же словарь без копипасты.

9. Заготовки на будущее

  • Multi-currency. Поле tb_ledger int в pm.tb_account_map и хелперы из currency.ts уже поддерживают любую ISO-валюту (zero/two/three-decimal), но сейчас PM работает только с THB (ledger=1). Расширение — добавить routing по currency в intent/router.ts, mapping ISO→ledger в ledger/, и расширить тест-сьют currency.test.ts на JPY/KWD edge-cases.
  • Phase 2B: Redis Streams. getRedis() сейчас используется только для pub/sub (intent.{id}) и BullMQ (внутри адаптеров). В Phase 2B появятся внешние PSP-адаптеры, обмен с которыми пойдёт через stream.ipps.jobs/results и stream.qp.jobs/results. Singleton-клиент готов к этому без изменений — потребители просто будут вызывать getRedis().xadd() / xreadgroup().
  • NOTIFICATIONS_REDIS_URL. Если объём FCM-нотификаций вырастет, отдельный Redis для stream.notifications.jobs уйдёт под собственный singleton в shared/redis.ts (сейчас env есть, но клиент поднимает intent/notify.ts локально на каждый вызов — это сознательно сделано как «временно»).
  • Расширение notification-labels.ts. Список OPERATION_LABELS ручной; при добавлении новых operationType в operations/registry.ts нужно дополнить и его (иначе push придёт с raw-кодом). В будущем — авто-генерация из i18n-словаря Auth Center.
  • schema.ts. Тип EventStatus = IntentStatus | PspState уже выражает идею «единый поток статусов» для UI-таймлайна (pm.intent_event). При добавлении новых PSP-стейтов достаточно расширить PspStateEventStatus подтянется автоматически.
  • Расширение errors.ts. По мере появления новых доменных провалов (например, WalletFrozenError, KycRequiredError) стоит добавлять подклассы PaymentError с конкретным code, а не пробрасывать generic BadRequestError — это позволит фронту маршрутизировать UI-фолбэки.