# PA Payments API — контракт и идемпотентность (Proposed)

Задача: PA-5 (раунд 2, блокер #5). Epic: PA-4. Статус: Proposed (черновик контракта для ревью).
Baseline: HYPERSWITCH_BASELINE v1.126.0 / `83cc4876dd06` (pa-platform-manifest.yaml).
Связанные: ADR-0002 (state machine), domain-model.md, c4-containers.puml (PA API — product layer).

## 1. Принципы

1. **PA API не раскрывает Hyperswitch API.** Публичные схемы — модель PA (payment_id,
   attempt-журнал свёрнут, статусы из ADR-0002 §2.2); названия/поля API Hyperswitch
   наружу не выходят. Маппинг PA↔HS — внутренняя обязанность PA Core.
2. **Ресурсы и версии:** `/v1/payments`, `/v1/payments/{id}`, `/v1/payments/{id}/capture`,
   `/v1/payments/{id}/cancel`, `/v1/refunds`, `/v1/refunds/{id}`, а также
   `/v1/payment-instruments` (токенизация и верификация платёжных инструментов — ввод PayAdmit review
   PA-6; see §2.0). Версионирование — префикс пути; несовместимые изменения → `/v2` (старая версия живёт ≥ 6 месяцев).
3. **Аутентификация:** `Authorization: Bearer <tenant_api_key>` для backend-операций; checkout-операции —
   publishable key (`pa_pk_...`) с ограниченным скоупом. Ключи выдаются на Tenant (domain-model.md §1).
   **Card Capture API — отдельная security boundary (MR !5 correction):** браузерный контур
   (`/v1/payment-instruments/*`) — publishable key + origin allowlist, transient PAN/CVV (не логируются,
   не хранятся вне Locker), никогда не принимает tenant secret; backend Payments API — tenant key +
   `instrument_id`, PAN/CVV не принимает вообще.
4. **Ошибки:** RFC 7807 (`application/problem+json`): `type`, `title`, `status`, `code`,
   `message`, `payment_id` (если есть). Коды PA стабильны и не наследуют коды PSP/Hyperswitch.

   **Контракт 400 vs 422 (QA PA-27 decision, задокументировано PA-30).** Граница проходит так:
   - **Ошибки схемы тела запроса → `400 invalid_request`** (extractor contract, экстрактор
     `ApiJson`): невалидный JSON, неверные типы/отсутствующие обязательные поля, неизвестные
     top-level поля (`deny_unknown_fields` на `POST /v1/payouts`; payload с `pan`/`cvv` на верхнем
     уровне отвергается именно здесь — молча отбрасывать такие поля нельзя). Текст ошибки serde
     наружу не эхоится (PCI-гигиена: ответ остаётся generic, клиентские значения не отражаются).
   - **Семантические/доменные ошибки → `422`** (`invalid_request` или специфичный код).
     PCI-подозрительные ключи (`pan`, `cvv`, `card_number`, …) внутри вложенных значений payout
     (`receiver`, `risk_context`) → `422 invalid_request` — **двойная защита (PA-27)**: экстрактор
     закрывает верхний уровень тела, доменный валидатор — вложенные структуры.
     Тот же контракт для свободных текстовых полей-референсов (`customer_reference` на
     `/v1/payment-instruments` и `/v1/payouts`, `customer.reference_id` в `/v1/payments`, PA-30):
     Luhn-валидные 13–19-значные последовательности (PAN-проба), значения длиннее 255 символов и
     вне opaque-charset `[A-Za-z0-9_.@-]` → `422 invalid_request` (PCI F1). PAN-проба применяется
     и к форматированным PAN (QA PA-30): разделители `[._@-]` между группами цифр снимаются, и
     если Luhn-валидная 13–19-значная последовательность появляется в «схлопнутом» значении —
     это 422 (форматированный PAN = PAN по PCI DSS; легитимные референсы с сепараторами —
     `INV-2024-001` — проходят). `customer.reference_id` валидируется независимо от JSON-типа
     (QA PA-30): строка — как есть; JSON-число — в канонической десятичной записи тем же
     валидатором (PAN цифрами как число → 422); `null` — отсутствие опционального поля; прочие
     типы → `422 invalid_request` (`customer.reference_id must be a string`). Сам `customer`
     в `/v1/payments` обязан быть JSON-объектом либо `null`/отсутствовать (QA re-verify PA-30):
     не-объект (строка/число/bool/массив) → `422 invalid_request` (`customer must be an object`,
     значение не эхоится) — иначе гейт `reference_id` обходился целиком и сырое значение
     сохранялось в `payments.customer` jsonb. Вложенный value-level скан произвольных полей
     объекта `customer` не выполняется — сознательно принятый риск.
5. **Свежесть (freshness):** GET возвращает последнее подтверждённое состояние; при
   `status=PENDING`/`UNKNOWN_PENDING_SYNC` ответ содержит `next_action` (`wait`/`redirect`/…) и
   `retry_after`. Webhooks PA (если подключены) — уведомления, но источник истины — GET/Polling + события.
6. **Rate limiting (PA-24, backends PA-47):** каждый аутентифицированный запрос расходует слот
   скользящего окна (sliding window, 60 сек) по паре `(tenant, endpoint)`; по умолчанию 100
   запросов/мин (env `PA_RATE_LIMIT_RPM`, per-endpoint override —
   `PA_RATE_LIMIT_OVERRIDES="/v1/payments:200,/v1/refunds:20"`). При превышении —
   `429 Too Many Requests` (`code=rate_limited`) + заголовок `Retry-After` (остаток окна, сек).
   Хранилище окна: `PA_RATE_LIMIT_BACKEND` = `memory` (по умолчанию, in-process — бюджет на
   процесс) | `redis` (скользящее окно в Redis ZSET, атомарный Lua-скрипт — единый бюджет на весь
   кластер; `PA_RATE_LIMIT_REDIS_URL`, по умолчанию `redis://127.0.0.1:6379/0`; при старте
   доступность Redis проверяется PING-пробой — недоступный Redis останавливает процесс с exit 2,
   отказ Redis в рантайме — fail-open: запрос пропускается, инцидент логируется структурным warn).
   Экземплярные пути схлопываются на route pattern (`/v1/payments/pay_...` → `/v1/payments`).
   **Неаутентифицированные запросы** (нет/неизвестный bearer) на `/v1/*` лимитируются по
   клиентскому IP: `PA_RATE_LIMIT_UNAUTH_IP_RPM` (по умолчанию 60/мин) — брут-форс ключей
   упирается в `429 rate_limited` вместо бесплатного ретраев-цикла; аутентифицированные запросы
   IP-бакет не расходуют. IP берётся из сокета; за reverse-proxy первый hop
   `X-Forwarded-For` используется только при `PA_RATE_LIMIT_TRUST_XFF=1` (прокси обязан
   перезаписывать, а не дописывать клиентский XFF — подделываемый XFF превращает лимит per-IP в
   per-случайный-ключ). `/healthz` не лимитируется.
7. **Жизненный цикл tenant API key (PA-24):** ключи выдаются на Tenant и хранятся только как
   SHA-256 хэши (таблица `tenant_api_keys`: `key_id`, `key_hash`, `tenant_id`, `created_at`,
   `expires_at`, `revoked_at`). Ротация — `POST /v1/tenants/{id}/rotate-key`: генерируется новый ключ
   (валиден немедленно, `expires_at=NULL`), старый остаётся рабочим 24 ч (grace period), затем
   истекает (`401`). Отзыв — `DELETE /v1/tenants/{id}/keys/{key_id}`: `revoked_at=now`, ключ
   отвечает `401` начиная со следующего запроса. Raw-ключ возвращается ровно один раз в ответе
   ротации и никогда не хранится и не логируется. Подробности — §2.3.

## 2. Ресурсы (outline)

### 2.0 Payment Instruments — токенизация и верификация (PA-6; corrected per MR !5 review — статус: Proposed)

Идея: PAN/expiry вводятся игроком в **PA Secure Fields SDK** (iframe-поля с PA origin; casino JS/backend
не имеют DOM-доступа к значениям — security invariant), данные уходят напрямую в Card Capture API
(браузерный контур) и токенизируются в PA Locker. Дальше используется только `instrument_id`.

**POST /v1/payment-instruments/tokenize** (Card Capture API: publishable key + origin allowlist; Idempotency-Key обязателен)

```json
{
  "card": {"number": "4242424242424242", "cvv": "123",
            "exp_month": "08", "exp_year": "2028", "holder": "Harry Potter"}
}
```

Ответ `201 Created`:

```json
{
  "instrument_id": "pin_...",
  "card_meta": {"masked_pan": "424242***4242", "brand": "VISA", "exp_month": "08", "exp_year": "2028"},
  "status": "ACTIVE"
}
```

Правила: PAN/expiry — только в Locker (Locker ref); **CVV — ephemeral credential текущей сессии:
не сохраняется в instrument** (MR !5 correction #1; повторное использование внутри cascade — только
после Locker spike + PCI review, payadmit-pilot-gates.md PG-1). Ответ содержит только метаданные.
Device/browser контекст — не часть instrument (MR !5 correction #2): передаётся в POST /v1/payments
как payment session context (§2). Токенизация не логирует PAN/CVV (no-log invariant).

**POST /v1/payment-instruments/{id}/verify** — верификация без списания (маппинг: PayAdmit `CARDVERIFY`,
amount=0). При успехе провайдерский токен сохраняется в `instrument.provider_tokens[]` с scope
provider+MID (MR !5 correction #4; payadmit-vs-pa-model.md §2).

**GET /v1/payment-instruments/{id}** — только метаданные (masked PAN, brand, expiry, status, provider_tokens наличие).
**POST /v1/payment-instruments/{id}/deactivate** — мягкая деактивация; полный lifecycle:
CREATE → ACTIVE → VERIFY → USE/REUSE → EXPIRED | DEACTIVATED → PURGED (domain-model.md §6).

### POST /v1/payments — создать платёж

Тело (обязательные поля по методу):

```json
{
  "merchant_id": "mer_...",            // скоуп тенанта
  "amount": 1050,                       // минорные единицы
  "currency": "EUR",
  "payment_method": {"type": "card", "instrument_id": "pin_..."},
  "capture_method": "automatic",        // automatic | manual (при поддержке PSP)
  "routing": {"mode": "auto"},          // auto | route_policy_id
  "customer": {                         // CustomerContext: identity/contact (MR !5: risk-поля вынесены в risk_context)
    "reference_id": "usr_123",          // user ID казино
    "first_name": "Harry", "last_name": "Potter",
    "email": "player@example.com", "phone": "357 123456789",
    "date_of_birth": "1996-01-05",      // опционально
    "citizenship_country_code": "GB"    // опционально
  },
  "risk_context": {                     // casino-supplied RiskContext — optional (domain-model §7; data minimization в connector projection)
    "kyc_status": true, "payment_instrument_kyc_status": true,
    "trust_level": "ftd", "date_of_first_deposit": "2026-01-01",
    "deposits_cnt": 12, "deposits_amount": 1000,
    "withdrawals_cnt": 3, "withdrawals_amount": 250,
    "affiliated": "no", "routing_tags": ["VIP"]
  },
  "session_context": {                  // DeviceContext: payment/attempt session (не instrument)
    "ip": "172.16.0.1", "locale": "en", "user_agent": "..."
  },
  "billing_address": {                  // addressLine1/city/country/postal_code — required по Data Requirements профилю eligible set
    "address_line1": "211 Victory st", "city": "Hogwarts",
    "country_code": "GB", "postal_code": "01001", "state": "CA"
  },
  "metadata": {"order_id": "..."}
}
```

**Data Requirements Resolver (MR !5 correction #2 — вместо global union):** PA Core не требует union
полей всех активных Route Targets. Для платёжного запроса вычисляется **Payment Data Requirements
Profile** для eligible cascade set (provider-capability-model.md): классы полей —
`REQUIRED_AT_CHECKOUT` (блокируют создание), `REQUIRED_IF_ROUTE_SELECTED` (запрашиваются при выборе
Route Target; при отсутствии — Route Target исключается из eligible), `COLLECTABLE_LATER`
(можно запросить по next_action=collect_data), `PROVIDER_SPECIFIC` (только в connector projection).
Profile определяется Route Target Capability Model (MR !5 correction #6), а не копией схемы PayAdmit.

Ошибка eligibility: если после capability/requirements-фильтрации нет Route Target —
`422 Unprocessable`, код `no_eligible_route` (без попыток исполнения). `capture_method` валидируется
по capability: PSP без подтверждённой manual capture отфильтровывается на eligibility.

Валидация REQUIRED_AT_CHECKOUT — на создании Payment; REQUIRED_IF_ROUTE_SELECTED — на выборе Route Target.

Ответ `201 Created`:

```json
{
  "payment_id": "pay_...",
  "status": "PENDING",                  // ADR-0002 §2.2
  "amount": 1050, "currency": "EUR",
  "capture_method": "automatic",
  "next_action": {                       // опционально; см. §2.1
    "type": "redirect",                  // redirect | sync_expected
    "redirect_url": "https://pa.example/3ds/start/...",
    "method": "GET"
  },
  "attempts_summary": [{"seq": 1, "route_target": {"provider": "payadmit", "mid": "mid_1"},
                        "status": "PENDING"}],   // свёрнуто: без секретов PSP
  "created_at": "2026-09-18T10:00:00Z"
}
```

### 2.1 next_action — typed action model (PA-6; per MR !5 ARC-PA6-02 APPLY — extensible union, статус: Proposed)

`next_action` — **typed union** (не набор nullable полей). Базовый набор (extensible, новые типы
только через versioning):

| Type | Смысл | Поля |
|---|---|---|
| `none` | Финал/действий нет | — |
| `wait` | Ждать/синхронизироваться | `retry_after`, `reason` |
| `redirect` | Перевести игрока по URL (3DS/HPP) | `redirect_url`, `method` |
| `collect_data` | Запросить данные у игрока (INPUT_REQUIRED) | `required_fields[]` |
| `approval` | Действие мерчанта/оператора (payout approval) | `resource`, `allowed_actions[]` |

Provider-specific async states PayAdmit (AWAITING_*, INPUT_REQUIRED, CASCADING_CONFIRMATION) НЕ
копируются в Payment state machine — выражаются normalized status + `next_action` + `reason`
+ provider status на Attempt (§2.3). `approve_cascade` (подтверждение игрока на продолжение
каскада) — отдельное product decision, в базовый набор не входит.

При `next_action.type=redirect` клиент казино переводит игрока по `redirect_url` (3DS challenge /
hosted page). Возврат игрока — на PA return-handler; **возврат не является результатом**: финал —
webhook PA и/или sync (webhooks.md, sequence-3ds.puml). После возврата/в ходе обработки
`GET /v1/payments/{id}` возвращает `next_action={type: wait, retry_after}` до финального статуса.

### 2.2 GET /v1/payments/{id} — состояние
Возвращает Payment + журнал попыток (сводно, ADR-0002 §2.2); `Retry-After` при
UNKNOWN_PENDING_SYNC. Свежесть: состояние подтверждено событием или sync'ом; не
«читает PSP» на каждый запрос.

### POST /v1/payments/{id}/capture | /cancel
Только для соответствующих состояний (AUTHORIZED → capture; PENDING → cancel/void).
Повтор → идемпотентный ответ (см. §3).

### POST /v1/refunds
```json
{"payment_id": "pay_...", "amount": 1050, "reason": "requested_by_customer"}
```
Возвраты идемпотентны по `Idempotency-Key` (§3); частичные возвраты суммируются,
контроль не-превышения AUTHORIZED-суммы.

### 2.3 Tenant API keys — ротация и отзыв (PA-24; реализовано в PA Core foundation)

Самообслуживание тенанта поверх текущего bearer-ключа (Admin API управления тенантами —
отдельный документ, §4.3). Аутентификация — тот же `Authorization: Bearer <tenant_api_key>`;
`{id}` в пути обязан совпадать с аутентифицированным тенантом, иначе `404 tenant_not_found`
(без утечки существования чужих тенантов). Хранение — таблица `tenant_api_keys` в PA Store
(`key_id`, `key_hash`, `tenant_id`, `created_at`, `expires_at`, `revoked_at`); raw-ключ не
хранится никогда — только SHA-256.

**POST /v1/tenants/{id}/rotate-key** — ротация с grace period:

```json
// Ответ 200 OK
{
  "new_key": "pay_sk_<64 hex>",
  "old_key_expires_at": "2026-09-20T21:15:00Z",
  "key_id": "<uuid нового ключа>"
}
```

Правила: новый ключ валиден немедленно (`expires_at=NULL`); старый ключ остаётся рабочим
ровно 24 ч (grace period, `old_key_expires_at`), затем отвечает `401`. Ротация атомарна
(одна транзакция); ключ, уже прошедший grace, повторной ротацией «омолодить» нельзя —
новый ключ создаётся каждый раз. `new_key` показывается ровно один раз.

**DELETE /v1/tenants/{id}/keys/{key_id}** — немедленный отзыв: `204 No Content`
(повторный вызов — идемпотентный `204`, `revoked_at` не меняется); отозванный ключ
отвечает `401 unauthorized` + `WWW-Authenticate: Bearer` со следующего запроса.
Неизвестный `key_id` или ключ другого тенанта — одинаковый `404 key_not_found`.

Правила приоритета аутентификации (защита от «воскрешения» отозванного ключа): хэш,
у которого есть строка в `tenant_api_keys` (валидная, истёкшая или отозванная),
обслуживается ТОЛЬКО этой строкой; legacy-поиск по `tenants.api_key_hash` (bootstrap-ключи
seed/PA-10) применяется только к хэшам вовсе без строки в `tenant_api_keys`.

## 3. Idempotency contract (требование ревью: повторный POST не создаёт новый logical payment)

| Аспект | Контракт |
|---|---|
| Заголовок | `Idempotency-Key: <uuid | client-defined>` — обязателен для POST (payments, refunds, capture, cancel, **payment-instruments/tokenize, payment-instruments/{id}/verify**); для GET игнорируется |
| Скоуп уникальности | `(tenant_id, api endpoint, Idempotency-Key)`; ключи изолированы по тенантам |
| Семантика повторов | Первый запрос выполняется; повтор с тем же ключом **не создаёт новый logical payment** — возвращает сохранённый результат оригинала (тот же `payment_id`, тот же статус на момент обработки) |
| In-flight повтор | Пока оригинал обрабатывается: `409 Conflict` + `Retry-After` (и сохранённый `payment_id`, если успел создаться) — клиент опрашивает GET; либо `202 Accepted` с `payment_id` (вариант фиксируется при реализации; ревью-ожидание: 409/202 — не новый ресурс) |
| Ключевая коллизия | Повтор ключа с **другим телом** → `422 Unprocessable` (`idempotency_key_reuse`), оригинал не изменяется |
| Хранение | Таблица idempotency в PA Store: ключ, хэш канонического тела, payment_id, статус, ответ, TTL 24 ч (конфигурируемо); запись атомарна с созданием Payment (single transaction) |
| Детерминированные vs транзиентные ошибки (PA-30, QA PA-13 F3) | В снапшот ключа сохраняются только **детерминированные** результаты (успех 2xx и детерминированные проблемы: `vault_token_unknown`, `vault_token_already_registered`, `unknown_merchant`, …) — повтор тем же ключом реплеит сохранённый результат. **Транзиентные** инфраструктурные отказы (`vault_unavailable` 502) НЕ сохраняются: claim освобождается (rollback), повтор тем же ключом начинает **новую** попытку доставки (новый вызов vault), а не реплей запиненной ошибки на TTL |
| TTL и реюз | По истечении TTL ключ можно переиспользовать; повтор после TTL = новый платёж — документируется в ответе заголовком `Idempotency-Expires` |
| Порядок обработки | Идемпотентность применяется ДО выбора Route Target и любых внешних вызовов — повтор не запускает новую Attempt/Cascade |
| Внутренняя идемпотентность | PA → Hyperswitch/PSP: attempt_id как ключ попытки (ADR-0002 §3.4); повторные вызовы HS с тем же attempt_id не создают вторую транзакцию |
| Наблюдаемость | Счётчики: idempotent_replays, key_conflicts; replay-ответы помечаются заголовком `Idempotent-Replayed: true` |

## 4. Что сознательно НЕ входит в публичный контракт

1. Понятия Hyperswitch (client_secret HS, connector-имена HS, HS-статусы, MCA id).
2. Секреты PSP, ключи MID, параметры connector_configs.
3. Управление тенантами/провайдерами — отдельный Admin API PA (другой аутен-скоуп, другой MR/документ).
   Исключение (PA-24): самообслуживание собственного ключа тенантом (ротация/отзыв, §2.3) —
   в этом контракте, т.к. аутентифицируется текущим tenant key.

## 5. Открытые вопросы (к реализации, вне PA-5)

1. Финальный выбор 409 vs 202 для in-flight повторов (влияет на SDK поведение).
2. Webhook-канал PA наружу (подписи, delivery) — отдельный документ при необходимости.
3. ~~Формат токенов checkout~~ — **закрыто (PA-6)**: специфицированы `/v1/payment-instruments/*`
   (PA Secure Fields → Locker → instrument_id) и `next_action.redirect`; см. §2.0/§2.1.
