novapay · NovaPay Payout API 1.0.0 · 20260906-191850-8d9be3
exit 0 · WARN 3 · UNSUPPORTED 0 · INFO 3
Эндпоинты и роли
| Создание выплаты | POST /payouts createPayout |
0.95 |
|---|---|---|
| Статус | GET /payouts/{payout_id} getPayoutStatus |
0.90 |
| Отменавне контракта | POST /payouts/{payout_id}/cancel cancelPayout |
0.95 |
| Webhook | POST /webhooks/payout payoutWebhook |
0.90 |
| Балансвне контракта | GET /balance getBalance |
0.85 |
Авторизация и webhook
| Auth | API key · header `X-API-Key` → api_key | |
|---|---|---|
| Подпись | X-NovaPay-Signature · HMAC-SHA256 · raw_body · hex → credentials.callback_secret | |
| События | payout.completed → approvedpayout.failed → rejectedpayout.processing → in_progresspayout.cancelled → rejected | |
| Сумма | amount (integer) · минорные единицы ×100, минимум 1000 · валюты: RUB | |
Статусы поле status
| in_progress | pending processing | |
|---|---|---|
| approved | completed | |
| rejected | failed cancelled | |
Ошибки провайдера
| HTTP | Роль | Код | Действие |
|---|---|---|---|
| 400 | create | validation_error | reject |
| 401 | create | unauthorized | alert_block |
| 401 | status | unauthorized | alert_block |
| 402 | create | insufficient_balance | retry |
| 404 | status | not_found | reject |
| 409 | cancel | invalid_status | reject |
| 409 | create | duplicate | treat_as_success |
| 422 | create | validation_error | reject |
| 429 | create | rate_limit_exceeded | retry_backoff |
| 500 | create | internal_error | retry |
Поля запроса → источник на стороне Space Payments
| Поле провайдера | Источник | Обязательно | Confidence |
|---|---|---|---|
amount |
to_minor_units(operation.amount) |
да | 0.95 |
currency |
operation.currency |
да | 0.95 |
external_id |
operation.id.to_s |
да | 0.95 |
recipient.type |
requisite_type |
да | 0.95 |
recipient.phone |
operation.payout_requisite.dig(requisite_type, 'phone') |
да | 0.90 |
recipient.bank_code |
operation.payout_requisite.dig('sbp', 'bank_code') |
если type=sbp | 0.60 |
recipient.bank_name |
operation.payout_requisite.dig('sbp', 'bank_name') |
нет | 0.90 |
recipient.card_number |
operation.payout_requisite.dig('card', 'card_number') |
если type=card | 0.60 |
Допущения и предупреждения
WARN 3 · требуют решения человека — закрываются строкой в overrides.yml
signature_encoding_assumed X-NovaPay-Signature: encoding not stated; hex assumed
conditional_required recipient.bank_code: required only for type=sbp (from description)
conditional_required recipient.card_number: required only for type=card (from description)
INFO 3 · для сведения
outside_contract POST /payouts/{payout_id}/cancel (cancelPayout) — generated as `cancel_request` helper
outside_contract GET /balance (getBalance) — generated as `fetch_balance` helper
duplicate_as_success HTTP 409 returns the success schema (PayoutResponse); treated as success
novapay_extras.rb открыть · скачать
| 1 | # frozen_string_literal: true |
| 2 | |
| 3 | # Generated by forge 1.0.0 from NovaPay Payout API 1.0.0 (spec.yaml). |
| 4 | # Optional helpers for endpoints found in the spec but outside the Provider::BaseService contract. |
| 5 | # Kept out of NovapayService on purpose: the service exposes only the four contract methods. |
| 6 | |
| 7 | require_relative 'novapay_service' |
| 8 | |
| 9 | module Provider |
| 10 | class NovapayExtras < NovapayService |
| 11 | def cancel_request(operation) |
| 12 | response = client.post("#{BASE_URL}/payouts/#{operation.provider_operation_key}/cancel", headers: auth_headers) |
| 13 | return apply_status(operation, response.body['status']) if response.status == 200 |
| 14 | |
| 15 | failure(http_symbol(response.status), "provider.#{error_code_for(response)}") |
| 16 | end |
| 17 | |
| 18 | def fetch_balance |
| 19 | response = client.get("#{BASE_URL}/balance", headers: auth_headers) |
| 20 | return failure(http_symbol(response.status), "provider.#{error_code_for(response)}") unless response.status == 200 |
| 21 | |
| 22 | success(balance: response.body['balance'], currency: response.body['currency'], hold: response.body['hold']) |
| 23 | end |
| 24 | end |
| 25 | end |
novapay_service.rb открыть · скачать
| 1 | # frozen_string_literal: true |
| 2 | |
| 3 | # Generated by forge 1.0.0 from NovaPay Payout API 1.0.0 (spec.yaml). |
| 4 | # Do not edit by hand: regenerate with `bin/forge generate`; adjust decisions in overrides.yml. |
| 5 | |
| 6 | require 'json' |
| 7 | require 'openssl' |
| 8 | |
| 9 | module Provider |
| 10 | class NovapayService < BaseService |
| 11 | BASE_URL = ENV.fetch('NOVAPAY_BASE_URL', 'https://api.sandbox.novapay.example/v1') |
| 12 | PRODUCTION_URL = 'https://api.novapay.example/v1' |
| 13 | |
| 14 | STATUS_MAP = { |
| 15 | 'pending' => 'in_progress', |
| 16 | 'processing' => 'in_progress', |
| 17 | 'completed' => 'approved', |
| 18 | 'failed' => 'rejected', |
| 19 | 'cancelled' => 'rejected' |
| 20 | }.freeze |
| 21 | |
| 22 | EVENT_MAP = { |
| 23 | 'payout.completed' => 'approved', |
| 24 | 'payout.failed' => 'rejected', |
| 25 | 'payout.processing' => 'in_progress', |
| 26 | 'payout.cancelled' => 'rejected' |
| 27 | }.freeze |
| 28 | |
| 29 | ERROR_MAP = { |
| 30 | 400 => 'validation_error', |
| 31 | 401 => 'invalid_credentials', |
| 32 | 402 => 'insufficient_balance', |
| 33 | 404 => 'not_found', |
| 34 | 422 => 'validation_error', |
| 35 | 429 => 'rate_limit', |
| 36 | 500 => 'internal_error' |
| 37 | }.freeze |
| 38 | |
| 39 | SUCCESS_STATUSES = [201, 409].freeze # 409: duplicate by Idempotency-Key returns the payout |
| 40 | REQUISITE_TYPES = %w[sbp card].freeze |
| 41 | STATUS_METHODS = %w[status check].freeze |
| 42 | SUPPORTED_CURRENCIES = %w[RUB].freeze |
| 43 | AMOUNT_MULTIPLIER = 100 # amount is sent in minor units (kopecks) |
| 44 | MIN_AMOUNT = 1000 # RUB; schema minimum 100000 minor units |
| 45 | SIGNATURE_HEADER = 'X-NovaPay-Signature' |
| 46 | |
| 47 | def check_conditions(operation, request_method) |
| 48 | base_result = super |
| 49 | return base_result if base_result.failed? |
| 50 | return failure(:unprocessable_entity, 'amount_too_low') if operation.amount < MIN_AMOUNT |
| 51 | return failure(:unprocessable_entity, 'currency_not_supported') unless SUPPORTED_CURRENCIES.include?(operation.currency) |
| 52 | return failure(:unprocessable_entity, 'external_id_too_long') if operation.id.to_s.length > 64 |
| 53 | return failure(:unprocessable_entity, 'requisite_missing') unless requisite_type_for(operation, request_method) |
| 54 | |
| 55 | phone = requisite_for(operation, requisite_type_for(operation, request_method))['phone'] |
| 56 | return failure(:unprocessable_entity, 'phone_invalid') unless phone.to_s.match?(/\A7\d{10}\z/) |
| 57 | |
| 58 | success |
| 59 | rescue StandardError => e # кривой реквизит или override-выражение → отклонить операцию, а не уронить воркер |
| 60 | failure(:unprocessable_entity, 'requisite_invalid', message: e.message) |
| 61 | end |
| 62 | |
| 63 | def create_request(operation, request_method = 'create') |
| 64 | return fetch_status(operation) if STATUS_METHODS.include?(request_method) |
| 65 | |
| 66 | requisite_type = requisite_type_for(operation, request_method) |
| 67 | return failure(:unprocessable_entity, 'requisite_missing') unless requisite_type |
| 68 | |
| 69 | payload = build_payout_payload(operation, requisite_type) |
| 70 | response = client.post("#{BASE_URL}/payouts", json: payload, |
| 71 | headers: auth_headers.merge(idempotency_headers(operation))) |
| 72 | parse_create_response(operation, response) |
| 73 | rescue Provider::RateLimitError => e |
| 74 | failure(:too_many_requests, 'provider.rate_limit', retry_after: e.retry_after) |
| 75 | rescue Provider::UnauthorizedError |
| 76 | failure(:unauthorized, 'provider.invalid_credentials') |
| 77 | rescue Provider::ServerError, Provider::ConnectionError => e |
| 78 | failure(:bad_gateway, 'provider.unavailable', message: e.message) |
| 79 | end |
| 80 | |
| 81 | def fetch_status(operation) |
| 82 | response = client.get("#{BASE_URL}/payouts/#{operation.provider_operation_key}", headers: auth_headers) |
| 83 | return failure(http_symbol(response.status), "provider.#{error_code_for(response)}") unless response.status == 200 |
| 84 | |
| 85 | apply_status(operation, response.body['status'], strict: true) |
| 86 | rescue Provider::RateLimitError => e |
| 87 | failure(:too_many_requests, 'provider.rate_limit', retry_after: e.retry_after) |
| 88 | rescue Provider::UnauthorizedError |
| 89 | failure(:unauthorized, 'provider.invalid_credentials') |
| 90 | rescue Provider::ServerError, Provider::ConnectionError => e |
| 91 | failure(:bad_gateway, 'provider.unavailable', message: e.message) |
| 92 | end |
| 93 | |
| 94 | def process_callback(payload, raw_body: nil, headers: {}) |
| 95 | verify_signature!(raw_body || JSON.generate(payload), headers) |
| 96 | internal_status = EVENT_MAP[payload['event']] || STATUS_MAP[payload['status']] |
| 97 | return failure(:unprocessable_entity, 'unknown_event', event: payload['event']) unless internal_status |
| 98 | |
| 99 | case internal_status |
| 100 | when 'approved' then approve_operation(payload['payout_id']) |
| 101 | when 'rejected' then reject_operation(payload['payout_id'], payload.dig('error', 'code')) |
| 102 | else mark_in_progress(payload['payout_id']) |
| 103 | end |
| 104 | rescue Provider::SignatureError |
| 105 | failure(:unauthorized, 'invalid_signature') |
| 106 | end |
| 107 | |
| 108 | # Endpoints outside the BaseService contract (cancel, balance) |
| 109 | # are generated as optional helpers in novapay_extras.rb (NovapayExtras < NovapayService). |
| 110 | |
| 111 | private |
| 112 | |
| 113 | def auth_headers |
| 114 | { 'X-API-Key' => credentials.fetch('api_key') } |
| 115 | end |
| 116 | |
| 117 | def idempotency_headers(operation) |
| 118 | { 'Idempotency-Key' => operation.idempotency_key } |
| 119 | end |
| 120 | |
| 121 | def requisite_type_for(operation, request_method) |
| 122 | return request_method if REQUISITE_TYPES.include?(request_method) |
| 123 | |
| 124 | (operation.payout_requisite.keys & REQUISITE_TYPES).first || ('card' if operation.payout_requisite.key?('card_number')) |
| 125 | end |
| 126 | |
| 127 | # Реквизиты на платформе: вложенные (payout_requisite['sbp'] = {…}) или плоские (payout_requisite['card_number']). |
| 128 | def requisite_for(operation, requisite_type) |
| 129 | operation.payout_requisite.fetch(requisite_type) { operation.payout_requisite } |
| 130 | end |
| 131 | |
| 132 | def build_payout_payload(operation, requisite_type) |
| 133 | deep_compact( |
| 134 | { |
| 135 | amount: to_minor_units(operation.amount), |
| 136 | currency: operation.currency, |
| 137 | external_id: operation.id.to_s, |
| 138 | recipient: build_recipient(operation, requisite_type) |
| 139 | } |
| 140 | ) |
| 141 | end |
| 142 | |
| 143 | # Убирает nil, пустые Hash/Array и объекты из одних nil (поля без источника — TODO выше). |
| 144 | def deep_compact(value) |
| 145 | case value |
| 146 | when Hash then value.transform_values { |v| deep_compact(v) }.reject { |_k, v| v.nil? || v == {} || v == [] } |
| 147 | when Array then value.map { |v| deep_compact(v) }.compact |
| 148 | else value |
| 149 | end |
| 150 | end |
| 151 | |
| 152 | def build_recipient(operation, requisite_type) |
| 153 | requisite = requisite_for(operation, requisite_type) |
| 154 | base = { type: requisite_type, phone: requisite['phone'] } |
| 155 | case requisite_type |
| 156 | when 'sbp' then base.merge(bank_code: requisite['bank_code'], bank_name: requisite['bank_name']).compact # bank_code required for type=sbp (from description) |
| 157 | when 'card' then base.merge(card_number: requisite['card_number']).compact # card_number required for type=card (from description) |
| 158 | else base |
| 159 | end |
| 160 | end |
| 161 | |
| 162 | def parse_create_response(operation, response) |
| 163 | unless SUCCESS_STATUSES.include?(response.status) |
| 164 | return failure(http_symbol(response.status), "provider.#{error_code_for(response)}", |
| 165 | provider_code: response.body.dig('error', 'code'), message: response.body.dig('error', 'message')) |
| 166 | end |
| 167 | |
| 168 | operations.update(operation.id, provider_operation_key: response.body['id']) |
| 169 | apply_status(operation, response.body['status']) |
| 170 | end |
| 171 | |
| 172 | # Успешный ответ без распознаваемого статуса (нет поля или значение вне STATUS_MAP) → in_progress: |
| 173 | # операция создана, итог придёт через fetch_status / webhook. unknown_provider_status — только для fetch_status. |
| 174 | def apply_status(operation, provider_status, strict: false) |
| 175 | status = STATUS_MAP[provider_status.to_s] |
| 176 | if status.nil? |
| 177 | return failure(:unprocessable_entity, 'unknown_provider_status', provider_status: provider_status) if strict |
| 178 | |
| 179 | status = 'in_progress' |
| 180 | end |
| 181 | |
| 182 | transition(operation, status, provider_status: provider_status) |
| 183 | end |
| 184 | |
| 185 | def error_code_for(response) |
| 186 | ERROR_MAP.fetch(response.status) { response.body.dig('error', 'code') || 'unknown_error' } |
| 187 | end |
| 188 | |
| 189 | # HMAC-SHA256 over the raw request body, hex-encoded (encoding assumed: not stated in the spec). |
| 190 | def verify_signature!(raw_body, headers) |
| 191 | given = header_value(headers, SIGNATURE_HEADER) |
| 192 | expected = OpenSSL::HMAC.hexdigest('SHA256', credentials.fetch('callback_secret'), raw_body) |
| 193 | raise Provider::SignatureError, "invalid #{SIGNATURE_HEADER}" unless secure_compare(given, expected) |
| 194 | end |
| 195 | |
| 196 | def to_minor_units(amount) |
| 197 | (amount * AMOUNT_MULTIPLIER).round.to_i |
| 198 | end |
| 199 | end |
| 200 | end |
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 по умолчанию), productionhttps://api.novapay.example/v1; авторизация — API Key. - Выплата создаётся
POST /payouts, статус —GET /payouts/{payout_id}и webhookPOST /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_methodstatus/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):
{
"amount": 1500000,
"currency": "RUB",
"external_id": "op_abc123",
"recipient": {
"type": "sbp",
"phone": "79001234567",
"bank_code": "044525225",
"bank_name": "Сбербанк"
}
}
Пример ответа 201 (fixtures.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):
{
"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):
{
"event": "payout.completed",
"payout_id": "np_7f3a9b2c",
"external_id": "op_abc123",
"status": "completed",
"completed_at": "2026-07-30T10:05:00Z"
}
Неуспешное событие → rejected (fixtures.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 |
Проверка
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
fixtures {8}
meta {3}
auth {1}
headers {1}
create_request {9}
request {4}
recipient {4}
operation {4}
payout_requisite {1}
sbp {7}
response_201 {6}
response_401 {1}
error {2}
response_402 {1}
error {2}
response_422 {1}
error {3}
details [1]
0 {2}
response_429 {2}
headers {1}
body {1}
error {2}
fetch_status {5}
response_401 {1}
error {2}
response_404 {1}
error {2}
response_200 {6}
callback {3}
headers {1}
payload {5}
callback_failed {3}
headers {1}
payload {5}
error {2}
cancel {3}
response_409 {1}
error {2}
response_200 {6}
balance {2}
response_200 {3}
novapay_service_spec.rb
| 1 | # frozen_string_literal: true |
| 2 | |
| 3 | # Generated by forge 1.0.0 for NovapayService. Fixtures: fixtures.json (same directory). |
| 4 | require_relative 'generated_spec_helper' |
| 5 | require_relative 'novapay_service' |
| 6 | |
| 7 | RSpec.describe Provider::NovapayService do |
| 8 | subject(:service) { described_class.new(provider: build_record('novapay'), operations: operations) } |
| 9 | |
| 10 | let(:operations) { Provider::MemoryOperations.new } |
| 11 | let(:operation) { build_operation.tap { |op| operations.save(op) } } |
| 12 | let(:create_fixture) { fixtures['create_request'] } |
| 13 | let(:provider_id) { dig_path(create_fixture['response_201'], ["id"]) } |
| 14 | let(:create_url) { "#{described_class::BASE_URL}/payouts" } |
| 15 | let(:auth_headers) { { 'X-API-Key' => 'test_api_key' } } |
| 16 | |
| 17 | describe '#check_conditions' do |
| 18 | it 'rejects amount below minimum' do |
| 19 | operation.amount = described_class::MIN_AMOUNT - 0.01 |
| 20 | expect(service.check_conditions(operation, 'create').code).to eq('amount_too_low') |
| 21 | end |
| 22 | |
| 23 | it 'accepts the fixture operation' do |
| 24 | expect(service.check_conditions(operation, 'create')).to be_success |
| 25 | end |
| 26 | end |
| 27 | |
| 28 | describe '#create_request' do |
| 29 | it 'creates payout (201)' do |
| 30 | stub = stub_request(:post, create_url).with(headers: auth_headers) { |req| subset_of?(parse_body(req), create_fixture['request']) } |
| 31 | .to_return(status: 201, body: create_fixture['response_201'].to_json, |
| 32 | headers: { 'Content-Type' => 'application/json' }) |
| 33 | result = service.create_request(operation, 'create') |
| 34 | expect(result).to be_success |
| 35 | expect(stub).to have_been_requested |
| 36 | expect(operations.find(operation.id).provider_operation_key).to eq(provider_id) |
| 37 | expect(result.data.dig(:result, :id)).to eq(provider_id) # платформа: provider_operation_key = payload.dig(:result, :id) |
| 38 | expect(result.data[:status]).to eq(create_fixture['expected_operation_status']) |
| 39 | end |
| 40 | |
| 41 | it 'maps 422 to provider.validation_error' do |
| 42 | stub_request(:post, create_url).to_return(status: 422, body: (create_fixture['response_422'] || {}).to_json, |
| 43 | headers: { 'Content-Type' => 'application/json' }) |
| 44 | expect(service.create_request(operation, 'create').code).to eq('provider.validation_error') |
| 45 | end |
| 46 | |
| 47 | it 'refuses an operation without a known requisite type' do |
| 48 | operation.payout_requisite = { 'unknown_type' => {} } |
| 49 | expect(service.create_request(operation, 'create').code).to eq('requisite_missing') |
| 50 | end |
| 51 | |
| 52 | it 'accepts the flat card form of payout_requisite' do |
| 53 | operation.payout_requisite = { 'card_number' => "4111111111111111", 'holder' => "CARD HOLDER", 'expiry_month' => 12, 'expiry_year' => 2030, 'phone' => "79000000000" } |
| 54 | stub = stub_request(:post, create_url).to_return(status: 201, body: create_fixture['response_201'].to_json, |
| 55 | headers: { 'Content-Type' => 'application/json' }) |
| 56 | expect(service.create_request(operation, 'create')).to be_success |
| 57 | expect(stub).to have_been_requested |
| 58 | end |
| 59 | |
| 60 | it 'maps 401 to invalid_credentials' do |
| 61 | stub_request(:post, create_url).to_return(status: 401, body: '{}') |
| 62 | expect(service.create_request(operation, 'create').code).to eq('provider.invalid_credentials') |
| 63 | end |
| 64 | |
| 65 | it 'maps 429 to rate_limit with retry_after' do |
| 66 | stub_request(:post, create_url).to_return(status: 429, body: '{}', headers: { 'Retry-After' => '60' }) |
| 67 | result = service.create_request(operation, 'create') |
| 68 | expect(result.code).to eq('provider.rate_limit') |
| 69 | expect(result.data[:retry_after]).to eq(60) |
| 70 | end |
| 71 | |
| 72 | it 'maps 5xx to provider.unavailable' do |
| 73 | stub_request(:post, create_url).to_return(status: 503, body: '{}') |
| 74 | expect(service.create_request(operation, 'create').code).to eq('provider.unavailable') |
| 75 | end |
| 76 | |
| 77 | it 'delegates status request_method to fetch_status' do |
| 78 | operation.provider_operation_key = provider_id |
| 79 | stub = stub_request(:get, "#{described_class::BASE_URL}/payouts/#{provider_id}") |
| 80 | .to_return(status: 200, body: fixtures.dig('fetch_status', 'response_200').to_json, |
| 81 | headers: { 'Content-Type' => 'application/json' }) |
| 82 | service.create_request(operation, 'status') |
| 83 | expect(stub).to have_been_requested |
| 84 | end |
| 85 | end |
| 86 | |
| 87 | describe '#fetch_status' do |
| 88 | it 'maps the provider status to approved' do |
| 89 | operation.provider_operation_key = provider_id |
| 90 | stub_request(:get, "#{described_class::BASE_URL}/payouts/#{provider_id}") |
| 91 | .to_return(status: 200, body: fixtures.dig('fetch_status', 'response_200').to_json, |
| 92 | headers: { 'Content-Type' => 'application/json' }) |
| 93 | result = service.fetch_status(operation) |
| 94 | expect(result).to be_success |
| 95 | expect(result.data[:status]).to eq('approved') |
| 96 | end |
| 97 | end |
| 98 | |
| 99 | describe '#process_callback' do |
| 100 | let(:callback) { fixtures['callback'] } |
| 101 | let(:body) { JSON.generate(callback['payload']) } |
| 102 | let(:headers) { { 'X-NovaPay-Signature' => sign(body, algorithm: 'sha256', encoding: 'hex') } } |
| 103 | |
| 104 | before do |
| 105 | operation.provider_operation_key = dig_path(callback['payload'], ["payout_id"]) |
| 106 | operations.save(operation) |
| 107 | end |
| 108 | |
| 109 | it 'approves on payout.completed with valid signature' do |
| 110 | result = service.process_callback(callback['payload'], raw_body: body, headers: headers) |
| 111 | expect(result).to be_success |
| 112 | expect(result.data[:status]).to eq('approved') |
| 113 | end |
| 114 | |
| 115 | it 'rejects on payout.failed' do |
| 116 | failed = fixtures['callback_failed']['payload'] |
| 117 | failed_body = JSON.generate(failed) |
| 118 | failed_headers = { 'X-NovaPay-Signature' => sign(failed_body, algorithm: 'sha256', encoding: 'hex') } |
| 119 | operation.provider_operation_key = dig_path(failed, ["payout_id"]) |
| 120 | result = service.process_callback(failed, raw_body: failed_body, headers: failed_headers) |
| 121 | expect(result.data[:status]).to eq('rejected') |
| 122 | expect(result.data[:error_code]).to eq('recipient_not_found') |
| 123 | end |
| 124 | |
| 125 | it 'fails on invalid signature' do |
| 126 | result = service.process_callback(callback['payload'], raw_body: body, headers: { 'X-NovaPay-Signature' => 'bad' }) |
| 127 | expect(result.code).to eq('invalid_signature') |
| 128 | end |
| 129 | end |
| 130 | |
| 131 | describe 'NovapayExtras#cancel_request' do |
| 132 | it 'cancels a pending payout' do |
| 133 | require_relative 'novapay_extras' |
| 134 | extras = Provider::NovapayExtras.new(provider: build_record('novapay'), operations: operations) |
| 135 | operation.provider_operation_key = provider_id |
| 136 | stub_request(:post, "#{described_class::BASE_URL}/payouts/#{provider_id}/cancel") |
| 137 | .to_return(status: 200, body: fixtures.dig('cancel', 'response_200').to_json, |
| 138 | headers: { 'Content-Type' => 'application/json' }) |
| 139 | expect(extras.cancel_request(operation).data[:status]).to eq('in_progress') |
| 140 | end |
| 141 | end |
| 142 | end |
mock_server.rb
| 1 | # frozen_string_literal: true |
| 2 | |
| 3 | # Generated by forge 1.0.0: mock of NovaPay Payout API for e2e and demos. |
| 4 | # Run: PORT=4567 WEBHOOK_URL=http://127.0.0.1:9292/webhook ruby mock_server.rb |
| 5 | # Auth: MOCK_API_KEY (default 'test-key') — api key / bearer token / basic password. MOCK_CALLBACK_SECRET (default 'secret'). |
| 6 | require 'base64' |
| 7 | require 'json' |
| 8 | require 'net/http' |
| 9 | require 'openssl' |
| 10 | require 'sinatra/base' |
| 11 | |
| 12 | class NovapayMock < Sinatra::Base |
| 13 | FIXTURES = JSON.parse(File.read(File.join(__dir__, 'fixtures.json'))).freeze |
| 14 | ID_PATH = ["id"].freeze |
| 15 | STATUS_PATH = ["status"].freeze |
| 16 | AMOUNT_PATH = ["amount"].freeze |
| 17 | REQUIRED = ["amount", "currency", "external_id", "recipient"].freeze |
| 18 | MIN_AMOUNT = 100000 |
| 19 | INITIAL_STATUS = "pending" |
| 20 | CANCELLED_STATUS = "cancelled" |
| 21 | STATUS_MAP = {"pending"=>"in_progress", "processing"=>"in_progress", "completed"=>"approved", "failed"=>"rejected", "cancelled"=>"rejected"}.freeze |
| 22 | EVENTS = {"payout.completed"=>"approved", "payout.failed"=>"rejected", "payout.processing"=>"in_progress", "payout.cancelled"=>"rejected"}.freeze |
| 23 | IDEMPOTENCY_HEADER = "Idempotency-Key" |
| 24 | ID_PREFIX = 'novapay' |
| 25 | |
| 26 | set :port, ENV.fetch('PORT', 4567).to_i |
| 27 | set :bind, '127.0.0.1' |
| 28 | set :logging, false |
| 29 | |
| 30 | STATE = { payouts: {}, idempotency: {}, counter: 0 } |
| 31 | LOCK = Mutex.new |
| 32 | |
| 33 | helpers do |
| 34 | def json_body = @json_body ||= ((parsed = JSON.parse(request.body.tap(&:rewind).read)).is_a?(Hash) ? parsed : {} rescue {}) |
| 35 | def reply(status, body) = [status, { 'Content-Type' => 'application/json' }, JSON.generate(body)] |
| 36 | def dig_path(hash, path) = path.reduce(hash) { |node, key| node.is_a?(Hash) ? node[key] : nil } |
| 37 | |
| 38 | def set_path(hash, path, value) |
| 39 | *head, last = path |
| 40 | head.reduce(hash) { |node, key| node[key] ||= {} }[last] = value |
| 41 | hash |
| 42 | end |
| 43 | |
| 44 | def fixture(*keys) = dig_path(FIXTURES, keys) |
| 45 | def deep_copy(obj) = Marshal.load(Marshal.dump(obj)) |
| 46 | |
| 47 | def authorized? |
| 48 | key = ENV.fetch('MOCK_API_KEY', 'test-key') |
| 49 | request.env['HTTP_X_API_KEY'] == key |
| 50 | end |
| 51 | |
| 52 | def error_body(status, code) |
| 53 | fixture('create_request', "response_#{status}") || fixture('create_request', "response_#{status}", 'body') || |
| 54 | { 'error' => { 'code' => code, 'message' => code.tr('_', ' ') } } |
| 55 | end |
| 56 | |
| 57 | def payout_response(template, payout) |
| 58 | body = deep_copy(template || {}) |
| 59 | set_path(body, ID_PATH, payout['id']) |
| 60 | set_path(body, STATUS_PATH, payout['status']) |
| 61 | body |
| 62 | end |
| 63 | |
| 64 | def create_payout(body) |
| 65 | LOCK.synchronize do |
| 66 | STATE[:counter] += 1 |
| 67 | id = "#{ID_PREFIX}_#{STATE[:counter]}" |
| 68 | STATE[:payouts][id] = { 'id' => id, 'status' => INITIAL_STATUS, 'request' => body } |
| 69 | end |
| 70 | end |
| 71 | |
| 72 | def find_payout(id) = LOCK.synchronize { STATE[:payouts][id] } |
| 73 | |
| 74 | def send_webhook(payload) |
| 75 | url = ENV['WEBHOOK_URL'] |
| 76 | return { delivered: false, status: nil, reason: 'WEBHOOK_URL is not set' } unless url |
| 77 | |
| 78 | raw = JSON.generate(payload) |
| 79 | uri = URI(url) |
| 80 | req = Net::HTTP::Post.new(uri.path.empty? ? '/' : uri.request_uri, 'Content-Type' => 'application/json') |
| 81 | secret = ENV.fetch('MOCK_CALLBACK_SECRET', 'secret') |
| 82 | digest = OpenSSL::HMAC.digest('SHA256', secret, raw) |
| 83 | req['X-NovaPay-Signature'] = digest.unpack1('H*') |
| 84 | req.body = raw |
| 85 | res = Net::HTTP.start(uri.host, uri.port, read_timeout: 5, open_timeout: 5) { |http| http.request(req) } |
| 86 | { delivered: res.code.to_i.between?(200, 299), status: res.code.to_i } |
| 87 | rescue StandardError => e |
| 88 | { delivered: false, status: nil, reason: e.message } |
| 89 | end |
| 90 | |
| 91 | def callback_payload(internal, payout) |
| 92 | key = { 'approved' => 'callback', 'rejected' => 'callback_failed', 'in_progress' => 'callback_processing' }[internal] |
| 93 | payload = deep_copy(fixture(key, 'payload') || fixture('callback', 'payload') || {}) |
| 94 | set_path(payload, ["payout_id"], payout['id']) |
| 95 | set_path(payload, ["status"], payout['status']) |
| 96 | payload |
| 97 | end |
| 98 | end |
| 99 | |
| 100 | before { halt(*reply(401, error_body(401, 'unauthorized'))) unless request.path_info.start_with?('/_') || authorized? } |
| 101 | |
| 102 | post '/payouts' do |
| 103 | body = json_body |
| 104 | missing = REQUIRED.reject { |f| body.key?(f) } |
| 105 | unless missing.empty? |
| 106 | halt(*reply(400, { 'error' => { 'code' => 'validation_error', 'message' => "missing required fields: #{missing.join(', ')}", |
| 107 | 'missing' => missing } })) |
| 108 | end |
| 109 | amount = dig_path(body, AMOUNT_PATH) |
| 110 | amount = Float(amount.to_s, exception: false) unless amount.is_a?(Numeric) |
| 111 | halt(*reply(422, error_body(422, 'validation_error'))) if MIN_AMOUNT && amount && amount < MIN_AMOUNT |
| 112 | key = IDEMPOTENCY_HEADER && request.env["HTTP_#{IDEMPOTENCY_HEADER.upcase.tr('-', '_')}"] |
| 113 | if key && (existing = LOCK.synchronize { STATE[:idempotency][key] }) |
| 114 | halt(*reply(409, payout_response(fixture('create_request', 'response_201'), existing))) |
| 115 | end |
| 116 | payout = create_payout(body) |
| 117 | LOCK.synchronize { STATE[:idempotency][key] = payout } if key |
| 118 | reply(201, payout_response(fixture('create_request', 'response_201'), payout)) |
| 119 | end |
| 120 | |
| 121 | get '/payouts/:payout_id' do |
| 122 | payout = find_payout(params[:payout_id]) |
| 123 | halt(*reply(404, fixture('fetch_status', 'response_404') || error_body(404, 'not_found'))) unless payout |
| 124 | reply(200, payout_response(fixture('fetch_status', 'response_200'), payout)) |
| 125 | end |
| 126 | |
| 127 | post '/payouts/:payout_id/cancel' do |
| 128 | payout = find_payout(params[:payout_id]) |
| 129 | halt(*reply(404, error_body(404, 'not_found'))) unless payout |
| 130 | halt(*reply(409, fixture('cancel', 'response_409') || error_body(409, 'invalid_status'))) unless STATUS_MAP[payout['status']] == 'in_progress' |
| 131 | payout['status'] = CANCELLED_STATUS |
| 132 | reply(200, payout_response(fixture('cancel', 'response_200'), payout)) |
| 133 | end |
| 134 | |
| 135 | get '/balance' do |
| 136 | reply(200, fixture('balance', 'response_200') || {}) |
| 137 | end |
| 138 | |
| 139 | # Демо/e2e: перевести выплату в статус события и отправить подписанный webhook на WEBHOOK_URL. |
| 140 | post '/_simulate/:id/:event' do |
| 141 | payout = find_payout(params[:id]) |
| 142 | halt(*reply(404, error_body(404, 'not_found'))) unless payout |
| 143 | internal = EVENTS[params[:event]] || STATUS_MAP[params[:event]] || params[:event] |
| 144 | raw = STATUS_MAP.key(internal) || params[:event] |
| 145 | payout['status'] = STATUS_MAP.select { |_r, i| i == internal }.keys.find { |r| r == params[:event] } || raw |
| 146 | payload = callback_payload(internal, payout) |
| 147 | payload['event'] = params[:event] if EVENTS.key?(params[:event]) |
| 148 | reply(200, send_webhook(payload)) |
| 149 | end |
| 150 | |
| 151 | get '/_state' do |
| 152 | reply(200, LOCK.synchronize { STATE[:payouts].values }) |
| 153 | end |
| 154 | |
| 155 | run! if $PROGRAM_NAME == __FILE__ |
| 156 | end |
Parsing spec... ok (openapi 3.0.3, NovaPay Payout API 1.0.0)
Found 5 endpoints: POST /payouts, GET /payouts/{payout_id}, POST /payouts/{payout_id}/cancel,
POST /webhooks/payout, GET /balance
create POST /payouts createPayout confidence 0.95
status GET /payouts/{payout_id} getPayoutStatus confidence 0.90
cancel POST /payouts/{payout_id}/cancel cancelPayout confidence 0.95 (outside contract → cancel_request)
webhook POST /webhooks/payout payoutWebhook confidence 0.90
balance GET /balance getBalance confidence 0.85 (outside contract → fetch_balance)
Auth: ApiKeyAuth (api_key, header: X-API-Key) → credentials.api_key
Statuses (status): pending, processing → in_progress; completed → approved; failed, cancelled → rejected
Errors: 400 validation_error → reject; 401 (create) unauthorized → alert_block;
401 (status) unauthorized → alert_block; 402 insufficient_balance → retry;
404 not_found → reject; 409 (create) duplicate → treat_as_success;
409 (cancel) invalid_status → reject; 422 validation_error → reject;
429 rate_limit_exceeded → retry_backoff (Retry-After); 500 internal_error → retry
Webhook signature: X-NovaPay-Signature (HMAC-SHA256, raw body, hex) → credentials.callback_secret
Webhook events: payout.completed → approved; payout.failed → rejected; payout.processing → in_progress; payout.cancelled → rejected
Amount: integer, minor units (×100), min 1000 RUB — source: amount (integer, min 100000) → minor units: 'Сумма в копейках'
Fields: 8 request fields, 0 unmapped (recipient: sbp, card)
Generating service...
Generating integration guide...
Generating test fixtures...
Generating service spec...
Generating extras...
Generating mock server...
Verifying generated code... ok (ruby -c ×4, rspec 15 examples, 0 failures)
Output:
./novapay_service.rb
./INTEGRATION.md
./fixtures.json
./novapay_service_spec.rb
./novapay_extras.rb
./mock_server.rb
./report.txt
Warnings (3):
WARN signature_encoding_assumed X-NovaPay-Signature: encoding not stated; hex assumed
hint: webhook.signature_encoding: hex|base64 (overrides.yml)
WARN conditional_required recipient.bank_code: required only for type=sbp (from description)
hint: fields.recipient.bank_code.required_if: { field: type, equals: sbp } applied; verify
WARN conditional_required recipient.card_number: required only for type=card (from description)
hint: fields.recipient.card_number.required_if: { field: type, equals: card } applied; verify
Info (3):
INFO outside_contract POST /payouts/{payout_id}/cancel (cancelPayout) — generated as `cancel_request` helper
INFO outside_contract GET /balance (getBalance) — generated as `fetch_balance` helper
INFO duplicate_as_success HTTP 409 returns the success schema (PayoutResponse); treated as success
hint: the service reads the payout from the body
Done: 7 files, 3 warnings, 0 unsupported. Exit 0.
Сгенерированный RSpec
$ bundle exec rspec --options /dev/null -I lib -I /data/web/20260906-191850-8d9be3/out /data/web/20260906-191850-8d9be3/out/novapay_service_spec.rb ............... Finished in 0.12043 seconds (files took 0.35492 seconds to load) 15 examples, 0 failures [exit 0]
e2e: мок → сервис → webhook → approved
$ bin/e2e /data/web/20260906-191850-8d9be3/input/spec.yaml • generated novapay into tmp/e2e/spec • mock up on :41079, webhook receiver on :44621 • check_conditions ok • create_request ok → provider id novapay_1, status in_progress • fetch_status ok → in_progress (pending) • webhook received: POST /webhook HTTP/1.1 operation approved ✓ (novapay) [exit 0]