Интеграция с 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 |
Раннер: pickUpJobs → loadContext → driver.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.jobsPM PspWorker (становится router'ом) IPPS Adapter { rowId, intentId, action: 'query'\|'confirm'\|'inquiry', payload }stream.ipps.resultsIPPS Adapter PM { rowId, outcome: PspProcessOutcome }stream.webhook.ippsnginx 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
lookupRefproduced two independent successful payments (differentrqUID,responseId,retrievalReferenceNumber, bothfeeAmount=5, real double-charge). Worker NEVER repeats confirm with the same lookupRef; recovery only viainquiryagainst the savedconfirm_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
lookupRefandrqUID. Receiver info (receiverNameEn,receiverBank) is stable. Re-running query onQUERY_PENDINGis 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 savedconfirm_rq_uid, programmatic recovery is impossible — Plan 2 escalates toMANUAL_REVIEWwith reasonorphan_lookup_ref." — Q-IPPS-13, resolved 2026-04-29
Следствие. Если confirm крашится ДО того, как драйвер сохранил confirm_rq_uid в psp_tx_map, состояние перевода у IPPS становится программно непознаваемым. Defense-in-depth:
IppsClient.confirmTransferпарсит ответ синхронно сразу послеfetch().json()(client.ts:179) — между получением body и сохранениемrqUIDвpsp_tx_mapне должно быть await-точек, способных уронить процесс.runConfirm(driver.ts:273) возвращаетoutcome.confirmRef = r.rqUIDтут же;applyOutcomeпишетconfirm_rq_uidв одной транзакции сstate = CONFIRMEDиoutbox_event.- Если всё-таки кейс случился (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 транзакций/день, общая на команду — расходовать аккуратно.