Модуль psp/ — драйверы внешних платёжных провайдеров¶
Модуль src/psp/ инкапсулирует интеграции с внешними PSP (Payment Service Providers). В Phase 1 единственный реализованный драйвер — IPPS PromptPay для исходящих переводов на тайские банковские счета и e-wallet'ы. Драйвер не имеет state — всё состояние живёт в pm.psp_tx_map, а psp-worker забирает строки из PG-очереди и применяет результат к БД в одной транзакции.
Назначение модуля¶
PSP-драйвер — это адаптер между внутренним протоколом PM (intent + saga) и внешним API провайдера (IPPS PPXC). Он отвечает за:
- Перевод бизнес-операций в вызовы PSP —
query(валидация получателя + расчёт комиссии) +confirm(фактическое списание) +inquiry(опрос статуса для recovery). - Классификацию ошибок —
retry/fail/inquireпо HTTP-коду + IPPSbankCode. Класс ошибки определяет: повторить, отдать вFAILED, или вызвать inquiry для восстановления исхода. - Управление state-machine — каждый драйвер сам решает, в какое состояние перевести строку
psp_tx_map, исходя из текущегоstate, набора уже сохранённых полей (queryRqUid,lookupRef,confirmRqUid) и результата вызова PSP.
Драйвер вызывается исключительно из psp-worker через метод PspDriver.process(input). Worker — единственная точка чтения/записи в psp_tx_map; драйвер лишь возвращает PspProcessOutcome, который worker применяет к БД.
Структура файлов¶
src/psp/
├── bootstrap.ts — регистрация всех production-драйверов при старте сервера
├── registry.ts — Map<PspName, PspDriver>, getDriver()/registerDriver()
├── types.ts — PspDriver interface, PspProcessInput/Outcome, PspErrorClass
└── ipps/
├── driver.ts — IppsDriver: state-machine + классификация ошибок
├── client.ts — низкоуровневый HTTP-клиент IPPS PPXC API
├── error.ts — IppsApiError, IppsTransportError
└── bank-codes.ts — справочник THAI_BANK_CODES (BBL/KBANK/SCB/…)
Ключевые типы¶
PspName и PspState (импортируются из shared/schema.ts)¶
export type PspName = 'IPPS' | 'QP' | 'WISE'
export type PspState =
| 'NEW' // строка создана, query ещё не отправлен
| 'QUERY_PENDING' // query в полёте (или ретраится)
| 'QUERIED' // query успешен — есть lookupRef
| 'CONFIRM_PENDING' // confirm в полёте
| 'INQUIRING' // confirm крашнулся / висит → опрашиваем через inquiry
| 'CONFIRMED' // терминальный успех
| 'FAILED' // терминальный провал
| 'MANUAL_REVIEW' // ops-эскалация (orphan, exhausted retries, неизвестное состояние)
Только CONFIRMED, FAILED и MANUAL_REVIEW — терминальные. Все остальные продолжают забираться psp-worker'ом из очереди до выхода в один из этих трёх.
PspDriver (types.ts)¶
export interface PspDriver {
readonly name: PspName
// Высокоуровневый entry-point — единственный метод, вызываемый psp-worker'ом.
process(input: PspProcessInput): Promise<PspProcessOutcome>
// Низкоуровневые операции — для прямого использования (admin tooling, /accounts/register-ipps).
register?(input: PspRegisterInput): Promise<PspRegisterResult>
query(input: PspQueryInput): Promise<PspQueryResult>
confirm(input: PspConfirmInput): Promise<PspConfirmResult>
inquiry(rqUID: string): Promise<PspInquiryResult>
getPartnerBalance?(): Promise<PspBalanceResult>
classifyError(err: unknown, op: PspOperation): PspErrorClass
}
PspProcessOutcome — что драйвер сообщает воркеру¶
Discriminated union из четырёх вариантов; worker применяет каждый в одной транзакции:
kind |
Запись в psp_tx_map |
Outbox | Триггер |
|---|---|---|---|
completed |
state=CONFIRMED + confirmRqUid + settlementDate + pspFeeSatang |
post_pending |
Терминальный успех (IPPS confirm OK / inquiry SUCCESS). |
failed |
state=FAILED + lastError |
void_pending |
Терминальный провал (HTTP 400/422, IPPS bankCode ∈ E001…E010, inquiry FAILED). |
in-progress |
state=nextState + опционально retryCount+=1 |
— | Промежуточное состояние (query OK → QUERIED; query retry; inquiry PENDING). |
manual-review |
state=MANUAL_REVIEW + lastError |
— | Ops-эскалация (orphan lookupRef, exhausted retries, inquiry 404). Дополнительно logger.error('manual_review_required'). |
Подробнее о маппинге колонок — reference/database/07-psp-tx-map.md.
PspErrorClass¶
export type PspErrorClass =
| 'retry' // transient — повторить (HTTP 429/502/503/504, IPPS E008)
| 'fail' // permanent — FAILED + void (HTTP 400/401/403/404/409/422, IPPS E001-E007/E009/E010)
| 'inquire' // unknown — нужен inquiry для определения исхода (transport, неизвестные коды на confirm)
Для confirm неизвестная ошибка → inquire (никогда не retry — см. инвариант ниже). Для query неизвестное → retry (query не двигает деньги).
Основные функции¶
Реестр драйверов — registry.ts¶
registerDriver(driver)— кладёт драйвер вMap<PspName, PspDriver>поdriver.name. Идемпотентна (overwrites).getDriver(name)— возвращает зарегистрированный драйвер или бросаетError: 'PspDriver not registered: ...'.clearRegistryForTests()— test-only, очищает реестр между тестами.
bootstrapPspDrivers() — bootstrap.ts¶
Вызывается один раз при старте сервера (независимо от WORKER_ROLES). Создаёт и регистрирует IppsDriver с зависимостями из конфига:
registerDriver(new IppsDriver(
new IppsClient({
baseUrl: config.IPPS_BASE_URL,
apiKey: config.IPPS_API_KEY,
partnerId: config.IPPS_PARTNER_ID,
timeoutMs: config.IPPS_HTTP_TIMEOUT_MS,
}),
config.PSP_MAX_RETRIES,
))
Регистрация дёшева (один Map.set), поэтому выполняется на всех ролях — те, кто не использует драйвер, просто не вызывают getDriver.
IppsDriver.process(input) — state-machine¶
Драйвер ветвится по row.state + наличию confirmRqUid:
state |
confirmRqUid |
retryCount |
Действие |
|---|---|---|---|
NEW, QUERY_PENDING |
— | — | runQuery() → IPPS query → QUERIED (либо retry-цикл при ошибке). |
QUERIED |
— | — | runConfirm() → IPPS confirm → CONFIRMED (либо CONFIRM_PENDING при ошибке). |
CONFIRM_PENDING |
NULL |
0 |
runConfirm() — первая попытка confirm для свежей строки. |
CONFIRM_PENDING |
NULL |
> 0 |
manual-review: orphan_lookup_ref — confirm крашнулся без сохранения rqUID, повтор = double-charge. |
CONFIRM_PENDING / INQUIRING |
NOT NULL |
— | runInquiry() → опрос статуса по confirmRqUid. |
INQUIRING |
NULL |
— | manual-review: inquiring_without_confirm_rquid (контрактное нарушение). |
| любое неожиданное | — | — | manual-review: unexpected_state_combination. |
IppsDriver.classifyError(err, op)¶
IppsTransportError(DNS, TCP reset, timeout) →confirm:inquire; иначеretry.IppsApiErrorс HTTP 429/502/503/504 →retry.IppsApiErrorс HTTP 400/401/403/404/409/422 →fail.IppsApiErrorсbankCode∈{E008}→retry; ∈{E001…E010}→fail.- Всё неизвестное на
confirm→inquire, на остальных операциях →retry.
Таблица HTTP/bankCode → класс ошибки покрыта unit-тестом test/psp/ipps/classify-error.test.ts (см. ниже). При добавлении новых IPPS-кодов в BANK_RETRY / BANK_FAIL обязательно расширять и тест-таблицу — иначе регрессии в классификации проходят мимо CI.
Низкоуровневые методы (для admin / register-роутов)¶
Кроме process(), драйвер выставляет наружу четыре операции, которые вызываются напрямую вне worker-цикла:
register(input)— регистрация кошелька в IPPS (вызывается роутомPOST /accounts/register-ipps). По документации IPPS register идемпотентен поexternalWalletUserId, но различить «уже был зарегистрирован» и «новый» по shape ответа нельзя — поэтому caller проверяет наличие записи вtb_account_map.ipps_wallet_idдо вызова.query(input)— отдельный query без сохранения состояния. Используется admin-инструментом для проверки получателя без создания intent.confirm(input)— прямой confirm; в production-сценарии вызывается только изнутриprocess(). Прямой вызов из admin запрещён (риск double-charge).inquiry(rqUID)— опрос IPPS по сохранённомуconfirmRqUid. Admin может вызывать для диагностики строк вMANUAL_REVIEW.getPartnerBalance()— мониторинг партнёрского баланса IPPS (для алертовIPPS_DRIFT_THRESHOLD_SATANG/IPPS_LOW_BALANCE_THRESHOLD_SATANG).
Жизненный цикл (Phase 1)¶
intent created
│
▼
psp_tx_map INSERT(state='NEW') ◄── intent-saga вставляет строку при channel=IPPS
│
▼
psp-worker (PG-очередь, см. workers.md)
│ SELECT … WHERE state IN ('NEW','QUERIED',… expired …) FOR UPDATE SKIP LOCKED
│ UPDATE state = NEW→QUERY_PENDING | QUERIED→CONFIRM_PENDING, leased_by=…, leased_at=now()
│
▼
loadContext() ── читает intent + tb_account_map.ipps_wallet_id
│ missing → escalateMissingContext() → MANUAL_REVIEW
│
▼
IppsDriver.process(input) ◄── чистая функция: row + intent + walletId → outcome
│ ветвление по state + confirmRqUid (см. таблицу выше)
│
▼
applyOutcome(row, outcome)
│ transactional UPDATE psp_tx_map + INSERT outbox_event + INSERT intent_event
│
▼
CONFIRMED → OutboxWorker → TigerBeetle post_pending → intent CONFIRMED
FAILED → OutboxWorker → TigerBeetle void_pending → intent FAILED
PG-очередь — единственный транспорт в Phase 1. Никаких Redis Streams. Pickup-запрос (см. src/workers/psp-worker.ts → pickUpJobs) использует FOR UPDATE SKIP LOCKED в CTE, чтобы несколько воркеров (потенциально на разных нодах) не выбирали одну и ту же строку. Внешний UPDATE сразу же переводит NEW → QUERY_PENDING / QUERIED → CONFIRM_PENDING ещё до фактического вызова IPPS — чтобы соседний воркер, проснувшись через PSP_POLL_INTERVAL_MS (500ms), увидел изменённый state и попал в ветку lease-expiry, а не дублировал работу.
Логика лиза и пере-pickup¶
Колонки leased_by (hostname.pid) и leased_at (timestamp) служат soft-lock'ом. Pickup-фильтр забирает строку обратно в трёх случаях:
state ∈ {QUERY_PENDING, CONFIRM_PENDING, INQUIRING}иleased_at IS NULL—applyOutcome(in-progress)явно сбросил лиз, можно сразу обрабатывать дальше.- То же состояние с
retry_count = 0иleased_at < now() - PSP_LEASE_SEC(10s) — первая попытка зависла (worker crashed mid-call). - То же состояние с
retry_count ≥ 1иleased_at < now() - PSP_RETRY_LEASE_SEC(30s) — увеличенный лиз для ретраев, чтобы дать IPPS отдышаться.
Это не гарантирует exactly-once на IPPS-уровне (Q-IPPS-2 это и не позволяет), но гарантирует, что строка не застрянет навсегда из-за упавшего worker'а.
NO-GO инварианты PSP-слоя¶
- IPPS confirm НЕ idempotent (Q-IPPS-2 из спецификации). Повторный confirm с тем же
lookupRef→ double-charge на стороне банка-получателя. Драйвер никогда не ретраит confirm напрямую: при любой ошибке остаётся вCONFIRM_PENDING, а решение принимает следующий pickup через inquiry (еслиconfirmRqUidсохранён) или manual-review (если нет). - IPPS inquiry принимает только confirm-rqUID (Q-IPPS-13). Если confirm крашнулся до записи
confirmRqUidв БД — восстановиться автоматически нельзя, строка идёт вMANUAL_REVIEW: orphan_lookup_ref. Ops запрашивает статус у IPPS-support и принудительно переводит строку вCONFIRMEDилиFAILED. - Драйвер stateless. Чтение/запись
psp_tx_map— исключительно черезpsp-worker. Это позволяет тестироватьprocess()как pure function (см.test/psp/ipps/driver.test.ts). - Денежные единицы на границе IPPS — major units (баты, float). Все суммы внутри PM — minor units (сатанги, BigInt). Конвертация через
toMinorUnits/fromMinorUnitsизshared/currency.ts(валюто-чувствительно: THB ×100, JPY/KRW ×1, KWD/BHD ×1000).
Конфигурация¶
Env vars (src/shared/config.ts)¶
| Переменная | Назначение | Default |
|---|---|---|
IPPS_BASE_URL |
Базовый URL IPPS PPXC API | https://promptpay-api-sit.ipps.cloud |
IPPS_API_KEY |
Bearer-токен партнёра | test-api-key (dev) |
IPPS_PARTNER_ID |
ID партнёра (поле partnerId в IPPS-запросах) |
00 |
IPPS_HTTP_TIMEOUT_MS |
HTTP-таймаут до IPPS | 8000 |
PSP_NAMES |
Список PSP, который запускает psp-worker | IPPS |
PSP_POLL_INTERVAL_MS |
Период опроса PG-очереди | 500 |
PSP_LEASE_SEC |
Лиз для первой попытки (state-PENDING + retryCount=0) | 10 |
PSP_RETRY_LEASE_SEC |
Лиз для retry-попытки (retryCount ≥ 1) | 30 |
PSP_MAX_RETRIES |
Максимум попыток inquiry перед MANUAL_REVIEW |
3 |
IPPS_DRIFT_THRESHOLD_SATANG |
Порог расхождения партнёрского баланса (для мониторинга) | 10_000 (100 THB) |
IPPS_LOW_BALANCE_THRESHOLD_SATANG |
Порог low-balance алерта | 100_000 (1000 THB) |
Таблица pm.psp_tx_map¶
Хранит всё состояние PSP-перевода: state + IPPS rqUID'ы + lookupRef + leased_by / leased_at + retry_count. Подробное описание полей и индексов — reference/database/07-psp-tx-map.md.
CHECK-ограничения (важные для p2p-инвариантов):
psp_tx_map_state_chk—state IN ('NEW','QUERY_PENDING','QUERIED','CONFIRM_PENDING','INQUIRING','CONFIRMED','FAILED','MANUAL_REVIEW').psp_tx_map_psp_chk—psp_name IN ('IPPS','QP','WISE').intent_id UNIQUE— один intent ↔ одна psp_tx_map строка (нет ретраев на уровне разных строк).
Тестирование¶
test/psp/
├── bootstrap.test.ts — bootstrapPspDrivers регистрирует IPPS-драйвер
├── registry.test.ts — register/get/clear, ошибка на незарегистрированном PSP
└── ipps/
├── bank-codes.test.ts — isKnownBankCode, статика THAI_BANK_CODES
├── classify-error.test.ts — таблица HTTP/bankCode → PspErrorClass для каждой операции
├── client.test.ts — IppsClient: парсинг ответов, обработка ошибок, таймауты
└── driver.test.ts — IppsDriver.process: state-machine по всем веткам state × confirmRqUid
Driver-тесты прогоняются как pure-function unit-тесты — IppsClient мокается, psp_tx_map не трогается, process() принимает PspProcessInput и проверяется PspProcessOutcome. Это даёт быструю обратную связь по edge-кейсам state-machine без поднятия Postgres.
Связанные модули¶
- workers.md —
psp-worker: лифтинг строк из PG-очереди (pickUpJobs), вызовloadContext, применениеapplyOutcome, циклsetInterval(PSP_POLL_INTERVAL_MS). - intent.md — кто создаёт строку
psp_tx_mapпри channel=IPPS (intent-saga / route handler/intents). - ledger.md — что происходит после
CONFIRMED/FAILED: outbox → TigerBeetle (post_pending / void_pending). - ../reference/database/07-psp-tx-map.md — схема и инварианты таблицы.
- ../integrations/ipps.md — детали IPPS PPXC API: формат запросов, error codes, специфика SIT.
Заготовки на будущее¶
Заготовка на будущее: Phase 2B переводит PSP-транспорт с PG-очереди на Redis Streams. Стримы:
stream.ipps.jobs(PM → IPPS Adapter),stream.ipps.results(IPPS Adapter → PM),stream.webhook.ipps(Webhook Gateway → PM). IPPS Adapter становится отдельным процессом со своим pod'ом; PM перестаёт держать IPPS HTTP-клиент в памяти и теряет прямую SDK-зависимость. Сейчас (Phase 1) этого нет —IppsClientживёт внутри PM, аpsp-workerсам делает HTTP-вызовы.Заготовка на будущее:
PspNameуже содержитQPиWISE(валюты, отличные от THB; международные переводы), но вsrc/psp/присутствует толькоipps/. ДрайверыQpDriverиWiseDriver— стабы; при попытке создатьpsp_tx_mapсpsp_name='QP'или'WISE'getDriver()броситError: 'PspDriver not registered: ...'. CHECK на таблице специально допускает эти значения, чтобы Phase 2/3 не требовал миграцииpsp_tx_map_psp_chk. До реализации соответствующих драйверов intent-роуты не выпускают подобные строки (валидация на уровнеchannel).