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

Интеграция с IPPS (PromptPay)

Драйвер IPPS — единственный путь PM наружу для исходящих платежей по PromptPay-инфраструктуре Таиланда. В Phase 1 драйвер работает in-process через PspWorker и PostgreSQL-очередь pm.psp_tx_map. Phase 2B заменит его внешним адаптером поверх Redis Streams, контракт драйвера при этом не меняется.


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

IPPS (Innovative Payment Processing Service) — провайдер PromptPay-шины, через которого OneWallet выводит деньги пользователей на банковские счета и QR-кошельки в Таиланде. PM выступает партнёром IPPS:

  • Регистрация конечных пользователей-кошельков (POST /register-wallet-user)
  • Двухфазовый перевод: query (lookup получателя) → confirm (списание + отправка)
  • Восстановление состояния (inquiry) при обрывах
  • Мониторинг партнёрского баланса для алёрта на пополнение

Регулятор — Bank of Thailand (TNB), спонсорский банк — Bangkok Bank (BBL). Реальные деньги двигает спонсор; PM с TNB не имеет прямой связи.

Файлы драйвера:

Файл Что внутри
src/psp/ipps/client.ts Низкоуровневый HTTP-клиент: POST/GET, нормализация ответов, классификация ошибок (IppsApiError, IppsTransportError)
src/psp/ipps/driver.ts Стейт-машина обработки psp_tx_map-строки: NEW → QUERY → QUERIED → CONFIRM → CONFIRMED/FAILED/INQUIRING/MANUAL_REVIEW
src/psp/ipps/error.ts Иерархия ошибок IPPS
src/psp/ipps/bank-codes.ts Справочник тайских банковских кодов (PPXC v1.2.4)
src/channels/ipps-transfer.ts Channel IPPS_TRANSFER — единственная точка, где платёжная сага создаёт psp_tx_map-строку
src/workers/psp-worker.ts Раннер: pickUpJobsloadContextdriver.process()applyOutcome

2. Связь с PM: channel и operationTypes

Драйвер вызывается из ровно одного channel:

Channel operationTypes, которые на него маршрутизируются
IPPS_TRANSFER IPPS_WITHDRAWAL, THAI_QR_PAY
  • IPPS_WITHDRAWAL — вывод средств пользователя на банковский счёт / PromptPay-получателя (MSISDN, NATID, EWALLETID, BANKAC).
  • THAI_QR_PAY — оплата по QR-коду магазина (BILLERID, biller-references). Кладёт ровно те же psp_tx_map поля; различается только receiverType и billReference* в metadata.

Терминологическая ловушка. IPPS (без суффикса) — это PspName во внутренней реестре драйверов (getDriver('IPPS')). IPPS_TRANSFER — это channel. IPPS_WITHDRAWAL и THAI_QR_PAY — operationTypes. Эти три слоя не путать: channel выбирается в зависимости от operationType, а драйвер — фиксированно по psp_tx_map.psp_name.

Service, который инициирует intent, обязан иметь соответствующий operationType в allowedOperationTypes (см. src/accounts/register-ipps.ts:61).


3. Архитектура Phase 1: in-process + PostgreSQL-очередь

Phase 1 не использует Redis для координации с драйвером. Очередь — таблица pm.psp_tx_map в PostgreSQL, лизы — FOR UPDATE SKIP LOCKED.

   ┌─ POST /intents ──────────────────────────────────────────────┐
   │  intent/handler.ts                                           │
   │  ├─ resolveChannel() → 'IPPS_TRANSFER'                       │
   │  └─ saga.run(ippsTransferChannel)                            │
   │      step 1: TB pending (user → system.nostro.ipps.{cur})    │
   │              + INSERT pm.psp_tx_map(state='NEW')             │
   │      step 2: NOOP → handler возвращает 201 AUTHORIZED        │
   │              requiresMonitoring=true                          │
   └──────────────────────────────────────────────────────────────┘
                                  ▼ (асинхронно)
   ┌─ PspWorker (один процесс, role=psp-worker) ──────────────────┐
   │  setInterval(PSP_POLL_INTERVAL_MS):                          │
   │    1. pickUpJobs('IPPS')   — CTE + SKIP LOCKED               │
   │    2. loadContext(row)     — intent + tb_account_map         │
   │    3. driver.process(input) — query/confirm/inquiry          │
   │    4. applyOutcome(row, outcome):                            │
   │       - completed     → outbox 'post_pending' + CONFIRMED    │
   │       - failed        → outbox 'void_pending' + FAILED       │
   │       - in-progress   → next state, lease released           │
   │       - manual-review → MANUAL_REVIEW + alert                │
   └──────────────────────────────────────────────────────────────┘
   ┌─ OutboxWorker (singleton) ───────────────────────────────────┐
   │  reads pm.outbox_event → TB post_pending / void_pending      │
   │  → tx_history insert → intent.SETTLED/FAILED                 │
   │  → Redis PUBLISH intent.{id}                                 │
   └──────────────────────────────────────────────────────────────┘

Стейт-машина psp_tx_map

state Кто пишет Что значит
NEW ippsTransferChannel.steps[0] Строка создана, query ещё не вызывался
QUERY_PENDING pickUpJobs flip + runQuery retry/inquire Идёт query или ждёт повторного pickup
QUERIED runQuery → completed lookupRef сохранён, готовы к confirm
CONFIRM_PENDING pickUpJobs flip + runConfirm retry Идёт confirm или ждёт inquiry
INQUIRING runConfirm → in-progress + confirmRqUid сохранён Confirm успешно вернул rqUID, но IPPS говорит PENDING — поллим inquiry
CONFIRMED runConfirm / runInquiry → completed Деньги ушли; outbox задача post_pending ждёт
FAILED runConfirm / runInquiry → failed Терминальный отказ; outbox задача void_pending ждёт
MANUAL_REVIEW escalateMissingContext / manual-review outcome Ops должен разобраться вручную

Лизы и SKIP LOCKED

pickUpJobs (src/workers/psp-worker.ts:44) — единственный путь забрать строку:

WITH picked AS (
  SELECT id FROM pm.psp_tx_map
  WHERE psp_name = 'IPPS'
    AND (
      state IN ('NEW','QUERIED')
      OR (state IN ('QUERY_PENDING','CONFIRM_PENDING','INQUIRING')
          AND leased_at IS NULL)                       -- ← in-progress в applyOutcome
      OR (state IN ('QUERY_PENDING','CONFIRM_PENDING','INQUIRING')
          AND retry_count = 0
          AND leased_at < now() - make_interval(secs => PSP_LEASE_SEC))
      OR (state IN ('QUERY_PENDING','CONFIRM_PENDING','INQUIRING')
          AND retry_count >= 1
          AND leased_at < now() - make_interval(secs => PSP_RETRY_LEASE_SEC))
    )
  ORDER BY created_at
  LIMIT 10
  FOR UPDATE SKIP LOCKED
)
UPDATE pm.psp_tx_map
SET leased_by = WORKER_ID, leased_at = now(),
    state = CASE state
              WHEN 'NEW'     THEN 'QUERY_PENDING'
              WHEN 'QUERIED' THEN 'CONFIRM_PENDING'
              ELSE state
            END

Write-ahead-flip NEW → QUERY_PENDING гарантирует, что даже если воркер упадёт после COMMIT этой транзакции, следующий pickup увидит уже QUERY_PENDING + leased_at и попадёт в lease-expiry-ветку, а не в "fresh NEW".


4. Phase 2B: внешний адаптер через Redis Streams

Заготовка на будущее (Phase 2B). В Phase 2B in-process драйвер заменяется внешним IPPS Adapter (отдельный Node.js-сервис). Контракт остаётся: IppsDriver.process() логика мигрирует в адаптер один-в-один, PM становится продюсером job'ов и консьюмером результатов.

Stream Producer Consumer Payload
stream.ipps.jobs PM PspWorker (становится router'ом) IPPS Adapter { rowId, intentId, action: 'query'\|'confirm'\|'inquiry', payload }
stream.ipps.results IPPS Adapter PM { rowId, outcome: PspProcessOutcome }
stream.webhook.ipps nginx Webhook Gateway PM IPPS push-уведомления (refunds, settlement updates)

pm.psp_tx_map сохраняется как источник истины о состоянии — Redis используется только как транспорт. Лизы и SKIP LOCKED продолжают защищать от двойной обработки. Идемпотентность ответа адаптера обеспечивается ключом rowId в pm.outbox_event (см. Q-IPPS-2 — повтор confirm категорически запрещён).

Миграция: добавляется IPPS_ADAPTER_MODE=in-process|stream (in-process — default до Phase 2B). Внутренние тесты драйвера переезжают в репозиторий адаптера; PM запускает только integration-тесты через моки Redis.


5. Инварианты IPPS-интеграции (живёт кровью)

Эти три факта переоткрыты SIT-смоук-тестами 2026-04-29 (см. docs/IPPS-OPEN-QUESTIONS.md, Resolved). Любая ошибка в драйвере, нарушающая один из них, == финансовый инцидент.

Q-IPPS-2: confirm НЕ идемпотентен (CRITICAL)

"SIT smoke-test confirmed two confirms with same lookupRef produced two independent successful payments (different rqUID, responseId, retrievalReferenceNumber, both feeAmount=5, real double-charge). Worker NEVER repeats confirm with the same lookupRef; recovery only via inquiry against the saved confirm_rq_uid." — Q-IPPS-2, resolved 2026-04-29

Следствие для драйвера. В IppsDriver.runConfirm (src/psp/ipps/driver.ts:273) catch-блок НИКОГДА не возвращает kind: 'completed' и НЕ повторяет HTTP-запрос. На retry/inquire/transport-class ошибке:

  • Если в psp_tx_map.confirm_rq_uid IS NULL → следующий pickup попадает в ветку orphan_lookup_ref (driver.ts:51) → MANUAL_REVIEW. Ops смотрит и/или вызывает inquiry вручную.
  • Если confirm_rq_uid IS NOT NULL (драйвер успел сохранить ответ, но потом упал) → следующий pickup идёт в runInquiry(rqUID) (driver.ts:78). Inquiry — единственный безопасный путь к "узнать, прошёл ли confirm".

Что НИКОГДА не делается: - Не вызывается client.confirmTransfer повторно с тем же lookupRef. - outbox_event для post_pending пишется ровно один раз в транзакции с state := 'CONFIRMED' (applyOutcome case 'completed', psp-worker.ts:241). - idempotency-key на партнёрской стороне у IPPS отсутствует (Q-B7 — открытый follow-up).

Q-IPPS-5: query НЕ идемпотентен, но безопасно повторять

"Each call returns a fresh lookupRef and rqUID. Receiver info (receiverNameEn, receiverBank) is stable. Re-running query on QUERY_PENDING is therefore safe (no money moves), just yields a new lookupRef." — Q-IPPS-5, resolved 2026-04-29

Следствие. В runQuery (driver.ts:215) на retry-class ошибке возвращается kind: 'in-progress', nextState: 'QUERY_PENDING', retryIncrement: true. Старый lookupRef остаётся в строке только как археологический след — следующий pickup сделает новый query и перезапишет lookupRef, queryRqUid, receiverBank, receiverNameEn, receiverDisplayName.

Открытый вопрос (Q-B8). TTL "брошенного" lookupRef со стороны IPPS — неизвестен. Сейчас не GC-им; полагаемся на то, что IPPS сам инвалидирует устаревший lookupRef.

Q-IPPS-13: inquiry принимает ТОЛЬКО confirmRqUID

"All three fallback inputs (lookupRef, retrievalReferenceNumber, query-level rqUID) returned HTTP 404 'No transfer log found for rqUID: '. Without a saved confirm_rq_uid, programmatic recovery is impossible — Plan 2 escalates to MANUAL_REVIEW with reason orphan_lookup_ref." — Q-IPPS-13, resolved 2026-04-29

Следствие. Если confirm крашится ДО того, как драйвер сохранил confirm_rq_uid в psp_tx_map, состояние перевода у IPPS становится программно непознаваемым. Defense-in-depth:

  1. IppsClient.confirmTransfer парсит ответ синхронно сразу после fetch().json() (client.ts:179) — между получением body и сохранением rqUID в psp_tx_map не должно быть await-точек, способных уронить процесс.
  2. runConfirm (driver.ts:273) возвращает outcome.confirmRef = r.rqUID тут же; applyOutcome пишет confirm_rq_uid в одной транзакции с state = CONFIRMED и outbox_event.
  3. Если всё-таки кейс случился (kernel kill, OOM между fetch и save) → ветка orphan_lookup_ref (driver.ts:51) → MANUAL_REVIEW с reason: 'orphan_lookup_ref'. Ops видит lastError и решает вручную.

Дополнительная защита. INQUIRING без confirmRqUid — невозможное состояние; если оно случится (баг в воркере), driver.ts:68 ловит и эскалирует в MANUAL_REVIEW (inquiring_without_confirm_rquid).


6. Webhook gateway

В Phase 1 PM не принимает webhooks от IPPS. Все события узнаются через inquiry-поллинг.

Заготовка на будущее (Phase 2B). Webhook'и от IPPS (refunds, settlement-date-population, async failures для Tag 30 bill payment) принимаются nginx-Webhook-Gateway'ем, валидируются (HMAC от IPPS) и публикуются в stream.webhook.ipps. PM-консьюмер этого стрима записывает событие в pm.intent_event и инициирует соответствующий outbox-action. Refund-логика (Q-A1) ждёт ответа compliance перед имплементацией.

Пока (Phase 1) — если IPPS пытается прислать webhook, он улетает в 404 (endpoint не зарегистрирован). Договорённость с IPPS — webhooks выключены до Phase 2B.


7. Конфигурация (env vars)

# Базовый URL IPPS API. SIT по умолчанию; prod URL — TBD (Q-B12).
IPPS_BASE_URL=https://promptpay-api-sit.ipps.cloud

# x-api-key для всех запросов. Выдаётся IPPS интеграционным мейлом.
# В prod хранится в secrets-manager, в .env только для локальной разработки.
IPPS_API_KEY=<secret>

# 2-значный partner-id, присваивается IPPS при онбординге.
# Используется в URL /partners/:id/balance для BalanceMonitor.
IPPS_PARTNER_ID=<assigned>

# HTTP-таймаут для отдельного запроса (fetch AbortController).
# Должен быть < PSP_LEASE_SEC, иначе worker заберёт строку повторно
# ещё до того, как первый POST завершится.
IPPS_HTTP_TIMEOUT_MS=8000

Связанные настройки PspWorker (src/shared/config.ts):

Env var Default Назначение
PSP_POLL_INTERVAL_MS 1000 Cadence setInterval в startPspWorker
PSP_LEASE_SEC 10 TTL лизы на первый attempt (retry_count = 0)
PSP_RETRY_LEASE_SEC 30 TTL лизы на retry attempts
PSP_MAX_RETRIES 3 Максимум для inquiry-PENDING / inquiry-error до MANUAL_REVIEW

SIT-специфика. На SIT-хосте сертификат — CloudFlare Origin Cert, требует NODE_TLS_REJECT_UNAUTHORIZED=0. В prod обязательно выключить (Q-B12 — открытый блокер до cutover).


8. Outcome shapes и интеграция с saga/outbox

driver.process() возвращает PspProcessOutcome — discriminated union, который applyOutcome транслирует в DB-транзакцию:

type PspProcessOutcome =
  | { kind: 'completed';     confirmRef; responseId?; settlementDate;
                             pspFeeMinor; retrievalReferenceNumber?; audit }
  | { kind: 'failed';        reason: string; audit }
  | { kind: 'in-progress';   nextState: PspState; partial; retryIncrement;
                             lastError?; audit }
  | { kind: 'manual-review'; reason: string; partial; audit }
outcome.kind DB-эффект (applyOutcome) Outbox-эффект Intent-эффект
completed state := 'CONFIRMED', сохранён confirmRqUid, responseId, settlementDate, pspFeeSatang; retry_count := 0, leased_at := NULL INSERT outbox_event(action='post_pending') intent_event(status_to='CONFIRMED')
failed state := 'FAILED', last_error := outcome.reason INSERT outbox_event(action='void_pending') intent_event(status_to='FAILED', reason)
in-progress state := outcome.nextState, частичные поля из partial, retry_count += retryIncrement
manual-review state := 'MANUAL_REVIEW', last_error := outcome.reason intent_event(status_to='MANUAL_REVIEW', reason) + лог manual_review_required

audit идёт в intent_event.payload.psp — это то, что ops видит в admin-panel при разборе. Для Q-IPPS-2 orphan-кейса туда кладётся { lookupRef, retryCount, previousError } — без этого ops не сможет связать строку с реальной IPPS-транзакцией.

completed и failed — атомарные транзакции: psp_tx_map UPDATE + outbox_event INSERT + intent_event INSERT в одном db.transaction. Если кто-то из этой тройки падает, ничего не сохраняется — следующий pickup увидит "висящий" *_PENDING с leased_at IS NULL и попробует ещё раз (для query — это безопасно; для confirm — попадёт в orphan-ветку).


9. BalanceMonitor

Партнёрский баланс у IPPS опрашивается отдельным воркером (src/monitor/balance-monitor.ts):

  • Тик setInterval каждые 60 секунд → driver.getPartnerBalance()GET /partners/:id/balance
  • Парсится mainBalance (сатанги) — это сумма, доступная для исходящих переводов
  • Если mainBalance < IPPS_BALANCE_LOW_THRESHOLD → лог event: 'ipps_balance_low' с уровнем error → ops-алёрт (через Loki/Grafana)
  • feeBalance — отдельный счёт под комиссии IPPS, мониторится тем же тиком

Без BalanceMonitor мы рискуем получить FAIL на confirm с code: 'BBL_undefined' и пустым кошельком в самый невовремя. Дополнение баланса — ручная операция со стороны OneWallet treasury (банковский перевод спонсорскому банку, IPPS обновляет mainBalance асинхронно).


10. Эндпоинты IPPS и mapping в драйвере

IPPS endpoint IppsClient method IppsDriver method Когда вызывается
POST /register-wallet-user registerWallet register POST /accounts/register-ipps (Plan 3)
POST /wallet-transfer/query queryTransfer query / runQuery NEW / QUERY_PENDING state — первый leg, lookup получателя
POST /wallet-transfer/confirm confirmTransfer confirm / runConfirm QUERIED state — settle, движение денег. Non-idempotent
POST /wallet-transfer/inquiry inquireTransaction inquiry / runInquiry CONFIRM_PENDING + сохранён rqUID, или INQUIRING — recovery
GET /partners/:id/balance getPartnerBalance getPartnerBalance BalanceMonitor 60s tick (см. src/monitor/balance-monitor.ts)

receiverType в metadata.ipps: MSISDN | NATID | EWALLETID | BANKAC | BILLERID. receiverBank — двухзначный код (см. bank-codes.ts), обязателен для confirm.


11. Классификация ошибок

IppsDriver.classifyError(err, op) возвращает 'retry' | 'fail' | 'inquire':

Источник Class Комментарий
IppsTransportError (network/timeout) на confirm inquire Не знаем, дошёл ли запрос → inquiry единственный путь
IppsTransportError на query / inquiry retry Безопасно повторить
HTTP 429, 502, 503, 504 retry Транзиентные
HTTP 400, 401, 403, 404, 409, 422 fail Терминальные ошибки логики
Bank E008 (service unavailable) retry По PPXC v1.2.4 §41 — пробуем ещё раз
Bank E001–E007, E009, E010 fail По PPXC v1.2.4 §40-41
Неизвестный bank code на confirm inquire Безопасное поведение по умолчанию (а не retry — Q-IPPS-2)
Неизвестный bank code на query / inquiry retry Деньги не двигаются — можно

Замечание про HTTP 500. Намеренно НЕ классифицируется ни как retry, ни как fail — попадает в safeUnknown. На confirm это превращается в inquire (правильно: не знаем, дошёл ли запрос); на остальных операциях — retry.

Реальные коды на SIT (Q-B5). PPXC v1.2.4 декларирует data.code: 'E001'..'E010', но SIT возвращает code: 'BBL_undefined' (формат <BANK>_<tag>). До разъяснений от IPPS неизвестные коды идут через safeUnknown-ветку.


12. Ссылки и работа в проде

  • Полный лист открытых вопросов и SIT-нюансов — docs/IPPS-OPEN-QUESTIONS.md.
  • Канонический контракт PM (то, что PM обещает наружу) — PASSPORT.md.
  • Spec B7 (паттерн PSP-адаптера) — docs/superpowers/specs/2026-04-28-pm-b7-psp-adapter-pattern.md.
  • Pre-prod чеклист (Q-B12 TLS, Q-B6 PENDING shape, Q-A2 deactivation) — docs/IPPS-OPEN-QUESTIONS.md#integration-checklist-pre-production.
  • Manual smoke-tests: npm run ipps-smoke -- balance|register|query|confirm|inquiry (scripts/ipps-smoke.ts). На SIT квота 5 транзакций/день, общая на команду — расходовать аккуратно.