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

Модуль 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). Он отвечает за:

  • Перевод бизнес-операций в вызовы PSPquery (валидация получателя + расчёт комиссии) + confirm (фактическое списание) + inquiry (опрос статуса для recovery).
  • Классификацию ошибокretry / fail / inquire по HTTP-коду + IPPS bankCode. Класс ошибки определяет: повторить, отдать в 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.
  • Всё неизвестное на confirminquire, на остальных операциях → 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.tspickUpJobs) использует 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-фильтр забирает строку обратно в трёх случаях:

  1. state ∈ {QUERY_PENDING, CONFIRM_PENDING, INQUIRING} и leased_at IS NULLapplyOutcome(in-progress) явно сбросил лиз, можно сразу обрабатывать дальше.
  2. То же состояние с retry_count = 0 и leased_at < now() - PSP_LEASE_SEC (10s) — первая попытка зависла (worker crashed mid-call).
  3. То же состояние с 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_chkstate IN ('NEW','QUERY_PENDING','QUERIED','CONFIRM_PENDING','INQUIRING','CONFIRMED','FAILED','MANUAL_REVIEW').
  • psp_tx_map_psp_chkpsp_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.mdpsp-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).