forge

novapay · NovaPay Payout API 1.0.0 · 20260906-191850-8d9be3

exit 0 · WARN 3 · UNSUPPORTED 0 · INFO 3

← новый прогон скачать всё (.tar) report.json

Эндпоинты и роли

Создание выплаты 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

AuthAPI key · header `X-API-Key` → api_key
ПодписьX-NovaPay-Signature · HMAC-SHA256 · raw_body · hex → credentials.callback_secret
Событияpayout.completedapproved
payout.failedrejected
payout.processingin_progress
payout.cancelledrejected
Суммаamount (integer) · минорные единицы ×100, минимум 1000 · валюты: RUB

Статусы поле status

in_progresspending processing
approvedcompleted
rejectedfailed cancelled

Ошибки провайдера

HTTPРольКодДействие
400createvalidation_errorreject
401createunauthorizedalert_block
401statusunauthorizedalert_block
402createinsufficient_balanceretry
404statusnot_foundreject
409cancelinvalid_statusreject
409createduplicatetreat_as_success
422createvalidation_errorreject
429createrate_limit_exceededretry_backoff
500createinternal_errorretry

Поля запроса → источник на стороне 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
→ webhook.signature_encoding: hex|base64 (overrides.yml)
conditional_required recipient.bank_code: required only for type=sbp (from description)
→ fields.recipient.bank_code.required_if: { field: type, equals: sbp } applied; verify
conditional_required recipient.card_number: required only for type=card (from description)
→ fields.recipient.card_number.required_if: { field: type, equals: card } applied; verify
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
→ the service reads the payout from the body

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
7require_relative 'novapay_service'
8
9module 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
25end

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
6require 'json'
7require 'openssl'
8
9module 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
200end

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_payoutPOST /payoutsСоздание выплатыIdempotency-Key header
get_statusGET /payouts/{id}Статус-
cancelPOST /payouts/{id}/cancelОтмена-
balanceGET /balanceБаланс-
webhookPOST /webhooks/payoutCallbackX-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_conditionsamount_too_low).
  • Валюты: RUB; поле currency, иная валюта → currency_not_supported.

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

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

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

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

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

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

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

ProviderSpace Payments
pendingin_progress
processingin_progress
completedapproved
failedrejected
cancelledrejected

События webhook

EventSpace Payments
payout.completedapproved
payout.failedrejected
payout.processingin_progress
payout.cancelledrejected

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

HTTPProvider codeДействие
400validation_errorreject
401unauthorizedalert ops, block provider
402insufficient_balanceretry later
404not_foundreject
422validation_errorreject
429rate_limit_exceededretry with backoff
500internal_errorretry, 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 с тем же порядком ключей.

Поля запроса

ПолеИсточникОбязательностьПреобразование
amountto_minor_units(operation.amount)даamount
currencyoperation.currencyда-
external_idoperation.id.to_sдаto_s
recipient.typerequisite_typeда-
recipient.phoneoperation.payout_requisite.dig(requisite_type, 'phone')да-
recipient.bank_codeoperation.payout_requisite.dig('sbp', 'bank_code')если type=sbp-
recipient.bank_nameoperation.payout_requisite.dig('sbp', 'bank_name')нет-
recipient.card_numberoperation.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.rbRSpec сервиса на WebMock и фикстурах (доказательство работы)
generated_spec_helper.rbвспомогательный код для spec (учётные данные, подпись)
INTEGRATION.mdэтот гайд
fixtures.jsonпримеры запросов, ответов и webhook с ожидаемыми статусами
mock_server.rbSinatra-мок провайдера из той же спеки (демо и e2e)
report.txtчто распознано, с какой уверенностью, WARN / UNSUPPORTED / INFO с подсказками
novapay_extras.rbхелперы вне контракта (cancel_request, fetch_balance)

Допущения

РешениеИсточникУровеньКак переопределить
X-NovaPay-Signature: encoding not stated; hex assumedsignature_encoding_assumedWARNwebhook.signature_encoding: hex|base64 (overrides.yml)
recipient.bank_code: required only for type=sbp (from description)conditional_requiredWARNfields.recipient.bank_code.required_if: { field: type, equals: sbp } applied; verify
recipient.card_number: required only for type=card (from description)conditional_requiredWARNfields.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}
provider: "novapay"
spec: "NovaPay Payout API 1.0.0"
forge: "1.0.0"
auth {1}
headers {1}
X-API-Key: "<credentials.api_key>"
create_request {9}
endpoint: "POST /payouts"
request {4}
amount: 1500000
currency: "RUB"
external_id: "op_abc123"
recipient {4}
type: "sbp"
phone: "79001234567"
bank_code: "044525225"
bank_name: "Сбербанк"
operation {4}
id: "op_abc123"
amount: "15000.00"
currency: "RUB"
payout_requisite {1}
sbp {7}
phone: "79001234567"
bank_code: "044525225"
bank_name: "Сбербанк"
first_name: "Ivan"
last_name: "Ivanov"
middle_name: "Ivanovich"
tax_id: "123456789012"
response_201 {6}
id: "np_7f3a9b2c"
external_id: "op_abc123"
status: "pending"
amount: 1500000
currency: "RUB"
created_at: "2026-07-30T10:00:00Z"
response_401 {1}
error {2}
code: "unauthorized"
message: "Invalid API key"
response_402 {1}
error {2}
code: "insufficient_balance"
message: "Insufficient provider balance"
response_422 {1}
error {3}
code: "validation_error"
message: "Amount must be at least 100000 kopecks"
details [1]
0 {2}
field: "amount"
message: "minimum is 100000"
response_429 {2}
headers {1}
Retry-After: 60
body {1}
error {2}
code: "rate_limit_exceeded"
message: "Rate limit exceeded, retry after 60 seconds"
expected_operation_status: "in_progress"
fetch_status {5}
endpoint: "GET /payouts/{payout_id}"
response_401 {1}
error {2}
code: "unauthorized"
message: "Invalid API key"
response_404 {1}
error {2}
code: "not_found"
message: "Payout not found"
response_200 {6}
id: "np_7f3a9b2c"
external_id: "op_abc123"
status: "completed"
amount: 1500000
currency: "RUB"
created_at: "2026-07-30T10:00:00Z"
expected_operation_status: "approved"
callback {3}
headers {1}
X-NovaPay-Signature: "<HMAC-SHA256(raw_body, callback_secret) hex>"
payload {5}
event: "payout.completed"
payout_id: "np_7f3a9b2c"
external_id: "op_abc123"
status: "completed"
completed_at: "2026-07-30T10:05:00Z"
expected_operation_status: "approved"
callback_failed {3}
headers {1}
X-NovaPay-Signature: "<HMAC-SHA256(raw_body, callback_secret) hex>"
payload {5}
event: "payout.failed"
payout_id: "np_7f3a9b2c"
external_id: "op_abc123"
status: "failed"
error {2}
code: "recipient_not_found"
message: "Recipient account not found"
expected_operation_status: "rejected"
cancel {3}
endpoint: "POST /payouts/{payout_id}/cancel"
response_409 {1}
error {2}
code: "invalid_status"
message: "Cannot cancel payout in status completed"
response_200 {6}
id: "np_7f3a9b2c"
external_id: "op_abc123"
status: "pending"
amount: 1500000
currency: "RUB"
created_at: "2026-07-30T10:00:00Z"
balance {2}
endpoint: "GET /balance"
response_200 {3}
balance: 500000000
currency: "RUB"
hold: 15000000

novapay_service_spec.rb

1# frozen_string_literal: true
2
3# Generated by forge 1.0.0 for NovapayService. Fixtures: fixtures.json (same directory).
4require_relative 'generated_spec_helper'
5require_relative 'novapay_service'
6
7RSpec.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
142end

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').
6require 'base64'
7require 'json'
8require 'net/http'
9require 'openssl'
10require 'sinatra/base'
11
12class 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__
156end
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]