Модуль 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:
- Импорт
./shared/config— синхронная валидация env. Любой провал —process.exit(1)с дампом всех Zod-ошибок (parsed.error.flatten().fieldErrors). connectTb(config.TB_ADDRESS)— с ретраями (см.TB_RETRY_ATTEMPTSв server.ts). После всех неудач — abort, потому что PM без TigerBeetle бесполезен.getRedis()вызывается лениво на первом обращении (publish-ивент, BullMQ из других модулей и т.д.). Ошибки соединения логируются, но не убивают процесс — publish считается best-effort.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-signature (в MerchantInvoiceChannel). |
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-стейтов достаточно расширитьPspState—EventStatusподтянется автоматически.- Расширение
errors.ts. По мере появления новых доменных провалов (например,WalletFrozenError,KycRequiredError) стоит добавлять подклассыPaymentErrorс конкретнымcode, а не пробрасывать genericBadRequestError— это позволит фронту маршрутизировать UI-фолбэки.