# NovaPay Integration Guide

> Generated by forge 1.0.0 from NovaPay Payout API 1.0.0. Решения с confidence < 0.8 перечислены в разделе «Допущения».

## Кратко

- Провайдер: **NovaPay**; спецификация NovaPay Payout API 1.0.0 (`/data/web/20260906-191850-8d9be3/input/spec.yaml`).
- Сервис: `Provider::NovapayService` (`novapay_service.rb`) реализует контракт `Provider::BaseService`: `check_conditions`, `create_request`, `fetch_status`, `process_callback`.
- API: `https://api.sandbox.novapay.example/v1` (sandbox по умолчанию), production `https://api.novapay.example/v1`; авторизация — API Key.
- Выплата создаётся `POST /payouts`, статус — `GET /payouts/{payout_id}` и webhook `POST /webhooks/payout`; итог операции — `in_progress` / `approved` / `rejected`.
- Заполнить вручную: `credentials.api_key`, `credentials.callback_secret`; все допущения анализа — в разделе «Допущения».

## Как платформа работает с сервисом

- `check_conditions(operation, request_method)` — предпроверки до запроса (раздел «Проверки перед отправкой»); `request_method` — тип реквизитов (`sbp`, `card`) либо служебное `status` / `check`.
- `create_request(operation, request_method)` — собирает тело по таблице «Поля запроса» и шлёт `POST /payouts`; успех — HTTP 201, 409; id выплаты сохраняется в `operation.provider_operation_key` из `id`. `request_method` `status`/`check` делегируется в `fetch_status`.
- `fetch_status(operation)` — `GET /payouts/{payout_id}`, статус из `status` по «Маппингу статусов».
- `process_callback(payload, raw_body:, headers:)` — проверяет подпись `X-NovaPay-Signature`, берёт событие из `event`, находит операцию по `payout_id` и переводит её в approved / rejected / in_progress.
- Ошибки провайдера → `failure` по таблице «Обработка ошибок» (семантика действий — там же).

## Авторизация

- Тип: API Key
- Header: `X-API-Key: <credentials.api_key>`
- Хранение: `providers.credentials` (encrypted)
- Заполнить вручную: `credentials.api_key`, `credentials.callback_secret`

## Настройка

- ENV `NOVAPAY_BASE_URL` — базовый URL (default sandbox: `https://api.sandbox.novapay.example/v1`)
- Production: `https://api.novapay.example/v1`
- `config.callback_url` — адрес приёма webhook на стороне Space Payments
- ProviderGateway config — см. раздел ниже

Все ключи, которые нужно задать до первого запроса:

| Ключ | Назначение |
|------|------------|
| ENV `NOVAPAY_BASE_URL` | базовый URL; по умолчанию sandbox `https://api.sandbox.novapay.example/v1` |
| `credentials.api_key` | авторизация (API Key), выдаёт провайдер |
| `credentials.callback_secret` | секрет подписи webhook, выдаёт провайдер |
| `config.callback_url` | адрес приёма webhook на стороне Space Payments |

## Методы

| Метод | Endpoint | Назначение | Idempotency |
|-------|----------|------------|-------------|
| create_payout | POST /payouts | Создание выплаты | Idempotency-Key header |
| get_status | GET /payouts/{id} | Статус | - |
| cancel | POST /payouts/{id}/cancel | Отмена | - |
| balance | GET /balance | Баланс | - |
| webhook | POST /webhooks/payout | Callback | X-NovaPay-Signature |

### create_payout — POST /payouts

- Успех: HTTP 201, 409 (409 — повтор по Idempotency-Key, читается как успех); id выплаты — `id`, статус — `status`.
- Тело: json; заголовки: авторизация и `Idempotency-Key: operation.idempotency_key`.
- Ошибки: HTTP 400, 401, 402, 422, 429, 500 — см. «Обработка ошибок».

Пример запроса (`fixtures.json`):

```json
{
  "amount": 1500000,
  "currency": "RUB",
  "external_id": "op_abc123",
  "recipient": {
    "type": "sbp",
    "phone": "79001234567",
    "bank_code": "044525225",
    "bank_name": "Сбербанк"
  }
}
```

Пример ответа 201 (`fixtures.json`):

```json
{
  "id": "np_7f3a9b2c",
  "external_id": "op_abc123",
  "status": "pending",
  "amount": 1500000,
  "currency": "RUB",
  "created_at": "2026-07-30T10:00:00Z"
}
```

### get_status — GET /payouts/{payout_id}

- Успех: HTTP 200; id выплаты — `id`, статус — `status`.
- Ошибки: HTTP 401, 404 — см. «Обработка ошибок».

Пример ответа 200 (`fixtures.json`):

```json
{
  "id": "np_7f3a9b2c",
  "external_id": "op_abc123",
  "status": "completed",
  "amount": 1500000,
  "currency": "RUB",
  "created_at": "2026-07-30T10:00:00Z"
}
```

## Сумма и валюта

- Поле `amount` (integer): минорные единицы (×100) — `to_minor_units(operation.amount)`.
- Минимум: 1000 (`check_conditions` → `amount_too_low`).
- Валюты: RUB; поле `currency`, иная валюта → `currency_not_supported`.

## Реквизиты получателя (`operation.payout_requisite`)

Тип реквизитов приходит в `request_method` и выбирает набор полей; значение для провайдера — см. «Поля запроса».

| Тип | Поля из `payout_requisite` (обязательность) |
|-----|---------------------------------------------|
| sbp | `phone` (да), `bank_code` (если type=sbp), `bank_name` (нет) |
| card | `phone` (да), `card_number` (если type=card) |

## Проверки перед отправкой (`check_conditions`)

Базовые проверки `Provider::BaseService` (сумма положительна, валюта и реквизиты заданы) плюс правила из схемы запроса:

| Поле | Правило | Код ошибки |
|------|---------|------------|
| amount | ≥ 1000 | amount_too_low |
| currency | один из: RUB | currency_not_supported |
| external_id | длина ≤ 64 | external_id_too_long |
| recipient.phone | формат `^7\d{10}$` | phone_invalid |

## Маппинг статусов

| Provider | Space Payments |
|----------|----------------|
| pending | in_progress |
| processing | in_progress |
| completed | approved |
| failed | rejected |
| cancelled | rejected |

## События webhook

| Event | Space Payments |
|-------|----------------|
| payout.completed | approved |
| payout.failed | rejected |
| payout.processing | in_progress |
| payout.cancelled | rejected |

## Обработка ошибок

| HTTP | Provider code | Действие |
|------|---------------|----------|
| 400 | validation_error | reject |
| 401 | unauthorized | alert ops, block provider |
| 402 | insufficient_balance | retry later |
| 404 | not_found | reject |
| 422 | validation_error | reject |
| 429 | rate_limit_exceeded | retry with backoff |
| 500 | internal_error | retry, alert ops |

Что означают действия:

- **reject** — операция отклоняется: `failure(<http>, "provider.<code>")`, платформа не повторяет запрос.
- **retry later** / **retry with backoff** — временная ошибка: `failure` с `retry_after` из `Retry-After`, если провайдер его прислал; платформа повторяет позже.
- **alert ops, block provider** — неверные учётные данные: `provider.invalid_credentials`, провайдер блокируется до вмешательства.
- **treat as success** — идемпотентный повтор (тот же `Idempotency-Key`): ответ читается как успешный.
- Сеть, таймаут, 5xx → `provider.unavailable`; 429 → `provider.rate_limit`.

## ProviderGateway config

{ "external_method": "sbp_payout", "gateway": "RUB_SBP_WITHDRAW" }
{ "external_method": "card_payout", "gateway": "RUB_CARD_WITHDRAW" }

## Приём webhook

- Эндпоинт провайдера: `POST /webhooks/payout` (источник: эндпоинт в `paths`).
- Адрес приёма на стороне Space Payments — `config.callback_url`; его нужно зарегистрировать у провайдера.
- Поле события: `event`; поле статуса: `status`; id выплаты: `payout_id`.
- Повторная доставка того же события безопасна: переход в тот же статус идемпотентен.
- Проверка на моке: `POST /_simulate/<id>/<event>` — мок шлёт подписанный webhook на `WEBHOOK_URL`.

Успешное событие → `approved` (`fixtures.json`):

```json
{
  "event": "payout.completed",
  "payout_id": "np_7f3a9b2c",
  "external_id": "op_abc123",
  "status": "completed",
  "completed_at": "2026-07-30T10:05:00Z"
}
```

Неуспешное событие → `rejected` (`fixtures.json`):

```json
{
  "event": "payout.failed",
  "payout_id": "np_7f3a9b2c",
  "external_id": "op_abc123",
  "status": "failed",
  "error": {
    "code": "recipient_not_found",
    "message": "Recipient account not found"
  }
}
```

## Webhook signature

HMAC-SHA256(body, callback_secret) → hex → X-NovaPay-Signature

Подпись считается по сырым байтам тела. Платформа передаёт в `process_callback` уже разобранный JSON, поэтому передавайте и сырое тело: `process_callback(payload, raw_body: request.body.read, headers: request.headers)`. Без `raw_body` сервис подписывает `JSON.generate(payload)` — подпись сойдётся, только если провайдер шлёт компактный JSON с тем же порядком ключей.

## Поля запроса

| Поле | Источник | Обязательность | Преобразование |
|------|----------|----------------|----------------|
| amount | `to_minor_units(operation.amount)` | да | amount |
| currency | `operation.currency` | да | - |
| external_id | `operation.id.to_s` | да | to_s |
| recipient.type | `requisite_type` | да | - |
| recipient.phone | `operation.payout_requisite.dig(requisite_type, 'phone')` | да | - |
| recipient.bank_code | `operation.payout_requisite.dig('sbp', 'bank_code')` | если type=sbp | - |
| recipient.bank_name | `operation.payout_requisite.dig('sbp', 'bank_name')` | нет | - |
| recipient.card_number | `operation.payout_requisite.dig('card', 'card_number')` | если type=card | - |

## Вне контракта

- `POST /payouts/{payout_id}/cancel` — хелпер `cancel_request` в `novapay_extras.rb` (класс `NovapayExtras`, вне контракта)
- `GET /balance` — хелпер `fetch_balance` в `novapay_extras.rb` (класс `NovapayExtras`, вне контракта)

## Файлы

| Файл | Назначение |
|------|------------|
| `novapay_service.rb` | сервис `Provider::NovapayService` по контракту |
| `novapay_service_spec.rb` | RSpec сервиса на WebMock и фикстурах (доказательство работы) |
| `generated_spec_helper.rb` | вспомогательный код для spec (учётные данные, подпись) |
| `INTEGRATION.md` | этот гайд |
| `fixtures.json` | примеры запросов, ответов и webhook с ожидаемыми статусами |
| `mock_server.rb` | Sinatra-мок провайдера из той же спеки (демо и e2e) |
| `report.txt` | что распознано, с какой уверенностью, WARN / UNSUPPORTED / INFO с подсказками |
| `novapay_extras.rb` | хелперы вне контракта (`cancel_request`, `fetch_balance`) |

## Допущения

| Решение | Источник | Уровень | Как переопределить |
|---------|----------|---------|--------------------|
| X-NovaPay-Signature: encoding not stated; hex assumed | signature_encoding_assumed | WARN | `webhook.signature_encoding: hex\|base64  (overrides.yml)` |
| recipient.bank_code: required only for type=sbp (from description) | conditional_required | WARN | `fields.recipient.bank_code.required_if: { field: type, equals: sbp }  applied; verify` |
| recipient.card_number: required only for type=card (from description) | conditional_required | WARN | `fields.recipient.card_number.required_if: { field: type, equals: card }  applied; verify` |

## Проверка

```bash
bundle exec rspec output/novapay/novapay_service_spec.rb
bin/forge mock --spec /data/web/20260906-191850-8d9be3/input/spec.yaml
bin/e2e /data/web/20260906-191850-8d9be3/input/spec.yaml
```
