# frozen_string_literal: true # Generated by forge 1.0.0 from NovaPay Payout API 1.0.0 (spec.yaml). # Do not edit by hand: regenerate with `bin/forge generate`; adjust decisions in overrides.yml. require 'json' require 'openssl' module Provider class NovapayService < BaseService BASE_URL = ENV.fetch('NOVAPAY_BASE_URL', 'https://api.sandbox.novapay.example/v1') PRODUCTION_URL = 'https://api.novapay.example/v1' STATUS_MAP = { 'pending' => 'in_progress', 'processing' => 'in_progress', 'completed' => 'approved', 'failed' => 'rejected', 'cancelled' => 'rejected' }.freeze EVENT_MAP = { 'payout.completed' => 'approved', 'payout.failed' => 'rejected', 'payout.processing' => 'in_progress', 'payout.cancelled' => 'rejected' }.freeze ERROR_MAP = { 400 => 'validation_error', 401 => 'invalid_credentials', 402 => 'insufficient_balance', 404 => 'not_found', 422 => 'validation_error', 429 => 'rate_limit', 500 => 'internal_error' }.freeze SUCCESS_STATUSES = [201, 409].freeze # 409: duplicate by Idempotency-Key returns the payout REQUISITE_TYPES = %w[sbp card].freeze STATUS_METHODS = %w[status check].freeze SUPPORTED_CURRENCIES = %w[RUB].freeze AMOUNT_MULTIPLIER = 100 # amount is sent in minor units (kopecks) MIN_AMOUNT = 1000 # RUB; schema minimum 100000 minor units SIGNATURE_HEADER = 'X-NovaPay-Signature' def check_conditions(operation, request_method) base_result = super return base_result if base_result.failed? return failure(:unprocessable_entity, 'amount_too_low') if operation.amount < MIN_AMOUNT return failure(:unprocessable_entity, 'currency_not_supported') unless SUPPORTED_CURRENCIES.include?(operation.currency) return failure(:unprocessable_entity, 'external_id_too_long') if operation.id.to_s.length > 64 return failure(:unprocessable_entity, 'requisite_missing') unless requisite_type_for(operation, request_method) phone = requisite_for(operation, requisite_type_for(operation, request_method))['phone'] return failure(:unprocessable_entity, 'phone_invalid') unless phone.to_s.match?(/\A7\d{10}\z/) success rescue StandardError => e # кривой реквизит или override-выражение → отклонить операцию, а не уронить воркер failure(:unprocessable_entity, 'requisite_invalid', message: e.message) end def create_request(operation, request_method = 'create') return fetch_status(operation) if STATUS_METHODS.include?(request_method) requisite_type = requisite_type_for(operation, request_method) return failure(:unprocessable_entity, 'requisite_missing') unless requisite_type payload = build_payout_payload(operation, requisite_type) response = client.post("#{BASE_URL}/payouts", json: payload, headers: auth_headers.merge(idempotency_headers(operation))) parse_create_response(operation, response) rescue Provider::RateLimitError => e failure(:too_many_requests, 'provider.rate_limit', retry_after: e.retry_after) rescue Provider::UnauthorizedError failure(:unauthorized, 'provider.invalid_credentials') rescue Provider::ServerError, Provider::ConnectionError => e failure(:bad_gateway, 'provider.unavailable', message: e.message) end def fetch_status(operation) response = client.get("#{BASE_URL}/payouts/#{operation.provider_operation_key}", headers: auth_headers) return failure(http_symbol(response.status), "provider.#{error_code_for(response)}") unless response.status == 200 apply_status(operation, response.body['status'], strict: true) rescue Provider::RateLimitError => e failure(:too_many_requests, 'provider.rate_limit', retry_after: e.retry_after) rescue Provider::UnauthorizedError failure(:unauthorized, 'provider.invalid_credentials') rescue Provider::ServerError, Provider::ConnectionError => e failure(:bad_gateway, 'provider.unavailable', message: e.message) end def process_callback(payload, raw_body: nil, headers: {}) verify_signature!(raw_body || JSON.generate(payload), headers) internal_status = EVENT_MAP[payload['event']] || STATUS_MAP[payload['status']] return failure(:unprocessable_entity, 'unknown_event', event: payload['event']) unless internal_status case internal_status when 'approved' then approve_operation(payload['payout_id']) when 'rejected' then reject_operation(payload['payout_id'], payload.dig('error', 'code')) else mark_in_progress(payload['payout_id']) end rescue Provider::SignatureError failure(:unauthorized, 'invalid_signature') end # Endpoints outside the BaseService contract (cancel, balance) # are generated as optional helpers in novapay_extras.rb (NovapayExtras < NovapayService). private def auth_headers { 'X-API-Key' => credentials.fetch('api_key') } end def idempotency_headers(operation) { 'Idempotency-Key' => operation.idempotency_key } end def requisite_type_for(operation, request_method) return request_method if REQUISITE_TYPES.include?(request_method) (operation.payout_requisite.keys & REQUISITE_TYPES).first || ('card' if operation.payout_requisite.key?('card_number')) end # Реквизиты на платформе: вложенные (payout_requisite['sbp'] = {…}) или плоские (payout_requisite['card_number']). def requisite_for(operation, requisite_type) operation.payout_requisite.fetch(requisite_type) { operation.payout_requisite } end def build_payout_payload(operation, requisite_type) deep_compact( { amount: to_minor_units(operation.amount), currency: operation.currency, external_id: operation.id.to_s, recipient: build_recipient(operation, requisite_type) } ) end # Убирает nil, пустые Hash/Array и объекты из одних nil (поля без источника — TODO выше). def deep_compact(value) case value when Hash then value.transform_values { |v| deep_compact(v) }.reject { |_k, v| v.nil? || v == {} || v == [] } when Array then value.map { |v| deep_compact(v) }.compact else value end end def build_recipient(operation, requisite_type) requisite = requisite_for(operation, requisite_type) base = { type: requisite_type, phone: requisite['phone'] } case requisite_type when 'sbp' then base.merge(bank_code: requisite['bank_code'], bank_name: requisite['bank_name']).compact # bank_code required for type=sbp (from description) when 'card' then base.merge(card_number: requisite['card_number']).compact # card_number required for type=card (from description) else base end end def parse_create_response(operation, response) unless SUCCESS_STATUSES.include?(response.status) return failure(http_symbol(response.status), "provider.#{error_code_for(response)}", provider_code: response.body.dig('error', 'code'), message: response.body.dig('error', 'message')) end operations.update(operation.id, provider_operation_key: response.body['id']) apply_status(operation, response.body['status']) end # Успешный ответ без распознаваемого статуса (нет поля или значение вне STATUS_MAP) → in_progress: # операция создана, итог придёт через fetch_status / webhook. unknown_provider_status — только для fetch_status. def apply_status(operation, provider_status, strict: false) status = STATUS_MAP[provider_status.to_s] if status.nil? return failure(:unprocessable_entity, 'unknown_provider_status', provider_status: provider_status) if strict status = 'in_progress' end transition(operation, status, provider_status: provider_status) end def error_code_for(response) ERROR_MAP.fetch(response.status) { response.body.dig('error', 'code') || 'unknown_error' } end # HMAC-SHA256 over the raw request body, hex-encoded (encoding assumed: not stated in the spec). def verify_signature!(raw_body, headers) given = header_value(headers, SIGNATURE_HEADER) expected = OpenSSL::HMAC.hexdigest('SHA256', credentials.fetch('callback_secret'), raw_body) raise Provider::SignatureError, "invalid #{SIGNATURE_HEADER}" unless secure_compare(given, expected) end def to_minor_units(amount) (amount * AMOUNT_MULTIPLIER).round.to_i end end end