1
0
зеркало из https://github.com/glebtv/yookassa.git synced 2026-08-28 15:16:18 +03:00

Add Rails webhook engine, docs, and dependency refresh

Этот коммит содержится в:
Gleb Tv
2026-03-02 18:16:57 +03:00
родитель 85e97dc70c
Коммит 8f13fe4748
19 изменённых файлов: 999 добавлений и 121 удалений

21
.github/workflows/main.yml поставляемый
Просмотреть файл

@@ -2,8 +2,6 @@ name: CI
on: on:
push: push:
branches:
- main
pull_request: pull_request:
@@ -19,7 +17,7 @@ jobs:
- 3.0 - 3.0
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v4
- name: Set up Ruby - name: Set up Ruby
uses: ruby/setup-ruby@v1 uses: ruby/setup-ruby@v1
with: with:
@@ -28,4 +26,19 @@ jobs:
- name: Rubocop - name: Rubocop
run: bundle exec rubocop run: bundle exec rubocop
- name: Rspec - name: Rspec
run: bundle exec rspec run: bundle exec rspec --tag ~browser
browser-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: 3.0
bundler-cache: true
- name: Set up Chrome
uses: browser-actions/setup-chrome@v1
- name: Browser specs (Cuprite)
run: bundle exec rspec --tag browser

17
Gemfile
Просмотреть файл

@@ -14,13 +14,18 @@ group :documentation do
end end
group :development, :test do group :development, :test do
gem "capybara", "~> 3.40"
gem "cuprite", "~> 0.15"
gem "pry" gem "pry"
gem "pry-byebug", "~> 3.8.0" gem "pry-byebug", "~> 3.10"
gem "rake", "~> 13.0" gem "rake", "~> 13.0"
gem "rspec", "~> 3.5" gem "rack-test", "~> 2.1"
gem "rails", "~> 6.1.7"
gem "rspec", "~> 3.13"
gem "sqlite3", "~> 1.4"
gem "rubocop", "~> 1.22" gem "rubocop", "~> 1.22"
gem "rubocop-rake", "~> 0.6.0" gem "rubocop-rake", "~> 0.7"
gem "rubocop-rspec", "~> 2.5" gem "rubocop-rspec", "~> 2.31"
gem "simplecov", "~> 0.16" gem "simplecov", "~> 0.22"
gem "webmock", "~> 3.14" gem "webmock", "~> 3.23"
end end

Просмотреть файл

@@ -1,158 +1,364 @@
PATH PATH
remote: . remote: .
specs: specs:
yookassa (0.2.0) yookassa (0.2.1)
dry-struct dry-struct
dry-validation dry-validation
http (~> 5.0.1) http (>= 5.0, < 6.0)
GEM GEM
remote: https://rubygems.org/ remote: https://rubygems.org/
specs: specs:
addressable (2.8.0) actioncable (6.1.7.10)
public_suffix (>= 2.0.2, < 5.0) actionpack (= 6.1.7.10)
ast (2.4.2) activesupport (= 6.1.7.10)
byebug (11.1.3) nio4r (~> 2.0)
websocket-driver (>= 0.6.1)
actionmailbox (6.1.7.10)
actionpack (= 6.1.7.10)
activejob (= 6.1.7.10)
activerecord (= 6.1.7.10)
activestorage (= 6.1.7.10)
activesupport (= 6.1.7.10)
mail (>= 2.7.1)
actionmailer (6.1.7.10)
actionpack (= 6.1.7.10)
actionview (= 6.1.7.10)
activejob (= 6.1.7.10)
activesupport (= 6.1.7.10)
mail (~> 2.5, >= 2.5.4)
rails-dom-testing (~> 2.0)
actionpack (6.1.7.10)
actionview (= 6.1.7.10)
activesupport (= 6.1.7.10)
rack (~> 2.0, >= 2.0.9)
rack-test (>= 0.6.3)
rails-dom-testing (~> 2.0)
rails-html-sanitizer (~> 1.0, >= 1.2.0)
actiontext (6.1.7.10)
actionpack (= 6.1.7.10)
activerecord (= 6.1.7.10)
activestorage (= 6.1.7.10)
activesupport (= 6.1.7.10)
nokogiri (>= 1.8.5)
actionview (6.1.7.10)
activesupport (= 6.1.7.10)
builder (~> 3.1)
erubi (~> 1.4)
rails-dom-testing (~> 2.0)
rails-html-sanitizer (~> 1.1, >= 1.2.0)
activejob (6.1.7.10)
activesupport (= 6.1.7.10)
globalid (>= 0.3.6)
activemodel (6.1.7.10)
activesupport (= 6.1.7.10)
activerecord (6.1.7.10)
activemodel (= 6.1.7.10)
activesupport (= 6.1.7.10)
activestorage (6.1.7.10)
actionpack (= 6.1.7.10)
activejob (= 6.1.7.10)
activerecord (= 6.1.7.10)
activesupport (= 6.1.7.10)
marcel (~> 1.0)
mini_mime (>= 1.1.0)
activesupport (6.1.7.10)
concurrent-ruby (~> 1.0, >= 1.0.2)
i18n (>= 1.6, < 2)
minitest (>= 5.1)
tzinfo (~> 2.0)
zeitwerk (~> 2.3)
addressable (2.8.9)
public_suffix (>= 2.0.2, < 8.0)
ast (2.4.3)
base64 (0.3.0)
bigdecimal (4.0.1)
builder (3.3.0)
byebug (13.0.0)
reline (>= 0.6.0)
capybara (3.40.0)
addressable
matrix
mini_mime (>= 0.1.3)
nokogiri (~> 1.11)
rack (>= 1.6.0)
rack-test (>= 0.6.3)
regexp_parser (>= 1.5, < 3.0)
xpath (~> 3.2)
coderay (1.1.3) coderay (1.1.3)
concurrent-ruby (1.1.9) concurrent-ruby (1.3.6)
crack (0.4.5) crack (1.0.1)
bigdecimal
rexml rexml
diff-lcs (1.4.4) crass (1.0.6)
docile (1.4.0) cuprite (0.17)
domain_name (0.5.20190701) capybara (~> 3.0)
unf (>= 0.0.5, < 1.0.0) ferrum (~> 0.17.0)
dry-configurable (0.13.0) date (3.5.1)
diff-lcs (1.6.2)
docile (1.4.1)
domain_name (0.6.20240107)
drb (2.2.3)
dry-configurable (1.3.0)
dry-core (~> 1.1)
zeitwerk (~> 2.6)
dry-core (1.2.0)
concurrent-ruby (~> 1.0) concurrent-ruby (~> 1.0)
dry-core (~> 0.6) logger
dry-container (0.9.0) zeitwerk (~> 2.6)
dry-inflector (1.3.1)
dry-initializer (3.2.0)
dry-logic (1.6.0)
bigdecimal
concurrent-ruby (~> 1.0) concurrent-ruby (~> 1.0)
dry-configurable (~> 0.13, >= 0.13.0) dry-core (~> 1.1)
dry-core (0.7.1) zeitwerk (~> 2.6)
dry-schema (1.15.0)
concurrent-ruby (~> 1.0) concurrent-ruby (~> 1.0)
dry-inflector (0.2.1) dry-configurable (~> 1.0, >= 1.0.1)
dry-initializer (3.0.4) dry-core (~> 1.1)
dry-logic (1.2.0) dry-initializer (~> 3.2)
concurrent-ruby (~> 1.0) dry-logic (~> 1.6)
dry-core (~> 0.5, >= 0.5) dry-types (~> 1.8)
dry-schema (1.8.0) zeitwerk (~> 2.6)
concurrent-ruby (~> 1.0) dry-struct (1.8.0)
dry-configurable (~> 0.13, >= 0.13.0) dry-core (~> 1.1)
dry-core (~> 0.5, >= 0.5) dry-types (~> 1.8, >= 1.8.2)
dry-initializer (~> 3.0)
dry-logic (~> 1.0)
dry-types (~> 1.5)
dry-struct (1.4.0)
dry-core (~> 0.5, >= 0.5)
dry-types (~> 1.5)
ice_nine (~> 0.11) ice_nine (~> 0.11)
dry-types (1.5.1) zeitwerk (~> 2.6)
dry-types (1.9.1)
bigdecimal (>= 3.0)
concurrent-ruby (~> 1.0) concurrent-ruby (~> 1.0)
dry-container (~> 0.3) dry-core (~> 1.0)
dry-core (~> 0.5, >= 0.5) dry-inflector (~> 1.0)
dry-inflector (~> 0.1, >= 0.1.2) dry-logic (~> 1.4)
dry-logic (~> 1.0, >= 1.0.2) zeitwerk (~> 2.6)
dry-validation (1.7.0) dry-validation (1.11.1)
concurrent-ruby (~> 1.0) concurrent-ruby (~> 1.0)
dry-container (~> 0.7, >= 0.7.1) dry-core (~> 1.1)
dry-core (~> 0.5, >= 0.5) dry-initializer (~> 3.2)
dry-initializer (~> 3.0) dry-schema (~> 1.14)
dry-schema (~> 1.8, >= 1.8.0) zeitwerk (~> 2.6)
ffi (1.15.4) erubi (1.13.1)
ffi-compiler (1.0.1) ferrum (0.17.1)
ffi (>= 1.0.0) addressable (~> 2.5)
base64 (~> 0.2)
concurrent-ruby (~> 1.1)
webrick (~> 1.7)
websocket-driver (~> 0.7)
ffi (1.17.3)
ffi-compiler (1.3.2)
ffi (>= 1.15.5)
rake rake
hashdiff (1.0.1) globalid (1.3.0)
http (5.0.4) activesupport (>= 6.1)
hashdiff (1.2.1)
http (5.3.1)
addressable (~> 2.8) addressable (~> 2.8)
http-cookie (~> 1.0) http-cookie (~> 1.0)
http-form_data (~> 2.2) http-form_data (~> 2.2)
llhttp-ffi (~> 0.4.0) llhttp-ffi (~> 0.5.0)
http-cookie (1.0.4) http-cookie (1.1.0)
domain_name (~> 0.5) domain_name (~> 0.5)
http-form_data (2.3.0) http-form_data (2.3.0)
i18n (1.14.8)
concurrent-ruby (~> 1.0)
ice_nine (0.11.2) ice_nine (0.11.2)
llhttp-ffi (0.4.0) io-console (0.8.2)
json (2.18.1)
json-schema (6.1.0)
addressable (~> 2.8)
bigdecimal (>= 3.1, < 5)
language_server-protocol (3.17.0.5)
lint_roller (1.1.0)
llhttp-ffi (0.5.1)
ffi-compiler (~> 1.0) ffi-compiler (~> 1.0)
rake (~> 13.0) rake (~> 13.0)
method_source (1.0.0) logger (1.7.0)
parallel (1.21.0) loofah (2.25.0)
parser (3.0.2.0) crass (~> 1.0.2)
nokogiri (>= 1.12.0)
mail (2.9.0)
logger
mini_mime (>= 0.1.1)
net-imap
net-pop
net-smtp
marcel (1.1.0)
matrix (0.4.3)
mcp (0.7.1)
json-schema (>= 4.1)
method_source (1.1.0)
mini_mime (1.1.5)
minitest (6.0.2)
drb (~> 2.0)
prism (~> 1.5)
net-imap (0.6.3)
date
net-protocol
net-pop (0.1.2)
net-protocol
net-protocol (0.2.2)
timeout
net-smtp (0.5.1)
net-protocol
nio4r (2.7.5)
nokogiri (1.19.1-x86_64-linux-gnu)
racc (~> 1.4)
parallel (1.27.0)
parser (3.3.10.2)
ast (~> 2.4.1) ast (~> 2.4.1)
pry (0.14.1) racc
prism (1.9.0)
pry (0.16.0)
coderay (~> 1.1) coderay (~> 1.1)
method_source (~> 1.0) method_source (~> 1.0)
pry-byebug (3.8.0) reline (>= 0.6.0)
byebug (~> 11.0) pry-byebug (3.12.0)
pry (~> 0.10) byebug (~> 13.0)
public_suffix (4.0.6) pry (>= 0.13, < 0.17)
rack (2.2.3) public_suffix (7.0.2)
rainbow (3.0.0) racc (1.8.1)
rake (13.0.6) rack (2.2.22)
regexp_parser (2.1.1) rack-test (2.2.0)
rexml (3.2.5) rack (>= 1.3)
rspec (3.10.0) rails (6.1.7.10)
rspec-core (~> 3.10.0) actioncable (= 6.1.7.10)
rspec-expectations (~> 3.10.0) actionmailbox (= 6.1.7.10)
rspec-mocks (~> 3.10.0) actionmailer (= 6.1.7.10)
rspec-core (3.10.1) actionpack (= 6.1.7.10)
rspec-support (~> 3.10.0) actiontext (= 6.1.7.10)
rspec-expectations (3.10.1) actionview (= 6.1.7.10)
activejob (= 6.1.7.10)
activemodel (= 6.1.7.10)
activerecord (= 6.1.7.10)
activestorage (= 6.1.7.10)
activesupport (= 6.1.7.10)
bundler (>= 1.15.0)
railties (= 6.1.7.10)
sprockets-rails (>= 2.0.0)
rails-dom-testing (2.3.0)
activesupport (>= 5.0.0)
minitest
nokogiri (>= 1.6)
rails-html-sanitizer (1.7.0)
loofah (~> 2.25)
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
railties (6.1.7.10)
actionpack (= 6.1.7.10)
activesupport (= 6.1.7.10)
method_source
rake (>= 12.2)
thor (~> 1.0)
rainbow (3.1.1)
rake (13.3.1)
regexp_parser (2.11.3)
reline (0.6.3)
io-console (~> 0.5)
rexml (3.4.4)
rspec (3.13.2)
rspec-core (~> 3.13.0)
rspec-expectations (~> 3.13.0)
rspec-mocks (~> 3.13.0)
rspec-core (3.13.6)
rspec-support (~> 3.13.0)
rspec-expectations (3.13.5)
diff-lcs (>= 1.2.0, < 2.0) diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.10.0) rspec-support (~> 3.13.0)
rspec-mocks (3.10.2) rspec-mocks (3.13.8)
diff-lcs (>= 1.2.0, < 2.0) diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.10.0) rspec-support (~> 3.13.0)
rspec-support (3.10.2) rspec-support (3.13.7)
rubocop (1.22.3) rubocop (1.85.0)
json (~> 2.3)
language_server-protocol (~> 3.17.0.2)
lint_roller (~> 1.1.0)
mcp (~> 0.6)
parallel (~> 1.10) parallel (~> 1.10)
parser (>= 3.0.0.0) parser (>= 3.3.0.2)
rainbow (>= 2.2.2, < 4.0) rainbow (>= 2.2.2, < 4.0)
regexp_parser (>= 1.8, < 3.0) regexp_parser (>= 2.9.3, < 3.0)
rexml rubocop-ast (>= 1.49.0, < 2.0)
rubocop-ast (>= 1.12.0, < 2.0)
ruby-progressbar (~> 1.7) ruby-progressbar (~> 1.7)
unicode-display_width (>= 1.4.0, < 3.0) unicode-display_width (>= 2.4.0, < 4.0)
rubocop-ast (1.12.0) rubocop-ast (1.49.0)
parser (>= 3.0.1.1) parser (>= 3.3.7.2)
rubocop-rake (0.6.0) prism (~> 1.7)
rubocop (~> 1.0) rubocop-capybara (2.22.1)
rubocop-rspec (2.5.0) lint_roller (~> 1.1)
rubocop (~> 1.19) rubocop (~> 1.72, >= 1.72.1)
ruby-progressbar (1.11.0) rubocop-factory_bot (2.28.0)
simplecov (0.21.2) lint_roller (~> 1.1)
rubocop (~> 1.72, >= 1.72.1)
rubocop-rake (0.7.1)
lint_roller (~> 1.1)
rubocop (>= 1.72.1)
rubocop-rspec (2.31.0)
rubocop (~> 1.40)
rubocop-capybara (~> 2.17)
rubocop-factory_bot (~> 2.22)
rubocop-rspec_rails (~> 2.28)
rubocop-rspec_rails (2.29.1)
rubocop (~> 1.61)
ruby-progressbar (1.13.0)
simplecov (0.22.0)
docile (~> 1.1) docile (~> 1.1)
simplecov-html (~> 0.11) simplecov-html (~> 0.11)
simplecov_json_formatter (~> 0.1) simplecov_json_formatter (~> 0.1)
simplecov-html (0.12.3) simplecov-html (0.13.2)
simplecov_json_formatter (0.1.3) simplecov_json_formatter (0.1.4)
unf (0.1.4) sprockets (4.2.2)
unf_ext concurrent-ruby (~> 1.0)
unf_ext (0.0.8) logger
unicode-display_width (2.1.0) rack (>= 2.2.4, < 4)
webmock (3.14.0) sprockets-rails (3.5.2)
actionpack (>= 6.1)
activesupport (>= 6.1)
sprockets (>= 3.0.0)
sqlite3 (1.7.3-x86_64-linux)
thor (1.5.0)
timeout (0.6.0)
tzinfo (2.0.6)
concurrent-ruby (~> 1.0)
unicode-display_width (3.2.0)
unicode-emoji (~> 4.1)
unicode-emoji (4.2.0)
webmock (3.26.1)
addressable (>= 2.8.0) addressable (>= 2.8.0)
crack (>= 0.3.2) crack (>= 0.3.2)
hashdiff (>= 0.4.0, < 2.0.0) hashdiff (>= 0.4.0, < 2.0.0)
webrick (1.7.0) webrick (1.9.2)
yard (0.9.26) websocket-driver (0.8.0)
base64
websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.5)
xpath (3.2.0)
nokogiri (~> 1.8)
yard (0.9.38)
zeitwerk (2.7.5)
PLATFORMS PLATFORMS
ruby x86_64-linux
DEPENDENCIES DEPENDENCIES
capybara (~> 3.40)
cuprite (~> 0.15)
pry pry
pry-byebug (~> 3.8.0) pry-byebug (~> 3.10)
rack rack
rack-test (~> 2.1)
rails (~> 6.1.7)
rake (~> 13.0) rake (~> 13.0)
rspec (~> 3.5) rspec (~> 3.13)
rubocop (~> 1.22) rubocop (~> 1.22)
rubocop-rake (~> 0.6.0) rubocop-rake (~> 0.7)
rubocop-rspec (~> 2.5) rubocop-rspec (~> 2.31)
simplecov (~> 0.16) simplecov (~> 0.22)
webmock (~> 3.14) sqlite3 (~> 1.4)
webmock (~> 3.23)
webrick webrick
yard yard
yookassa! yookassa!
BUNDLED WITH BUNDLED WITH
2.2.22 2.6.7

Просмотреть файл

@@ -80,6 +80,77 @@ Yookassa.payments.capture(payment_id: '12345')
Yookassa.payments.cancel(payment_id: '12345') Yookassa.payments.cancel(payment_id: '12345')
``` ```
### Rails webhook endpoint (engine)
The gem ships with a Rails engine and a default webhook controller you can use directly.
1) Configure the webhook token and (optionally) allowed source IPs:
```ruby
# config/initializers/yookassa.rb
Yookassa.configure do |config|
config.shop_id = ENV.fetch('YOOKASSA_SHOP_ID')
config.api_key = ENV.fetch('YOOKASSA_API_KEY')
# Random, long, secret token used in webhook URL path.
config.webhook_token = ENV.fetch('YOOKASSA_WEBHOOK_TOKEN')
# Optional override. Defaults come from YooKassa docs:
# https://yookassa.ru/developers/using-api/webhooks#ip
# config.webhook_allowed_ips = ['185.71.76.0/27', ...]
end
```
2) Mount the engine in routes:
```ruby
# config/routes.rb
Rails.application.routes.draw do
mount Yookassa::Engine => '/yookassa'
end
```
This exposes:
- `POST /yookassa/webhooks/:token`
Set your YooKassa webhook URL to include the real token value, for example:
- `https://example.com/yookassa/webhooks/<long-random-token>`
The default `Yookassa::WebhooksController` verifies:
- token in URL path
- request source `request.remote_ip` is in allowlist
- webhook object matches fresh API fetch by `id` and `status`
No signature headers are used.
### Overriding webhook handling
In host app, inherit from the gem controller and implement business logic in `process_webhook`:
```ruby
# app/controllers/my_yookassa_webhooks_controller.rb
class MyYookassaWebhooksController < Yookassa::WebhooksController
private
def process_webhook(payload)
object = payload['object'] || payload.dig('data', 'object')
return unless object
# Your app-specific processing
end
end
```
Then route to your controller (keeping token in path):
```ruby
# config/routes.rb
post '/webhooks/yookassa/:token', to: 'my_yookassa_webhooks#create'
```
### Path to 1.0 ### Path to 1.0
**Настройки SDK API ЮKassa** **Настройки SDK API ЮKassa**

Просмотреть файл

@@ -0,0 +1,83 @@
# frozen_string_literal: true
require "ipaddr"
require "json"
module Yookassa
class WebhooksController < ::ActionController::Base
skip_before_action :verify_authenticity_token, raise: false
def create
payload = parsed_payload
return head :unauthorized unless authentic_webhook?(payload)
process_webhook(payload)
head :ok
rescue JSON::ParserError
head :bad_request
end
private
def process_webhook(_payload)
nil
end
def authentic_webhook?(payload)
token_valid? && source_ip_allowed? && payload_matches_api_object?(payload)
end
def token_valid?
token = params[:token].to_s
configured_token = Yookassa.config.webhook_token.to_s
return false if token.empty? || configured_token.empty?
ActiveSupport::SecurityUtils.secure_compare(token, configured_token)
end
def source_ip_allowed?
source_ip = request.remote_ip
allowed_cidrs.any? { |cidr| IPAddr.new(cidr).include?(source_ip) }
rescue IPAddr::InvalidAddressError
false
end
def allowed_cidrs
ips = Yookassa.config.webhook_allowed_ips
return Yookassa::Config::DEFAULT_WEBHOOK_ALLOWED_IPS if ips.nil? || ips.empty?
ips
end
def parsed_payload
@parsed_payload ||= JSON.parse(request.raw_post)
end
def payload_matches_api_object?(payload)
object = payload["object"] || payload.dig("data", "object")
return false unless object.is_a?(Hash)
object_id = object["id"].to_s
object_status = object["status"].to_s
return false if object_id.empty? || object_status.empty?
fetched_object = fetch_object_from_api(payload, object_id)
return false if fetched_object.nil?
fetched_object.id == object_id && fetched_object.status == object_status
rescue StandardError
false
end
def fetch_object_from_api(payload, object_id)
event_name = payload["event"].to_s
event_name = payload["type"].to_s if event_name.empty?
if event_name.start_with?("payment.")
Yookassa.payments.find(payment_id: object_id)
elsif event_name.start_with?("refund.")
Yookassa.refunds.find(payment_id: object_id)
end
end
end
end

3
config/routes.rb Обычный файл
Просмотреть файл

@@ -0,0 +1,3 @@
Yookassa::Engine.routes.draw do
post "/webhooks/:token", to: "webhooks#create"
end

24
docs/changelog.md Обычный файл
Просмотреть файл

@@ -0,0 +1,24 @@
# Changelog
## 2026-03-02
### Added
- Rails engine integration for webhook handling (`Yookassa::Engine`).
- Default webhook controller (`Yookassa::WebhooksController`) with built-in authenticity checks.
- Config options for webhook security:
- `webhook_token` (secret URL token in path)
- `webhook_allowed_ips` (YooKassa source allowlist, overridable)
- RSpec coverage for webhook controller behavior.
- Cuprite browser spec for real browser webhook request flow.
- CI job for browser tests in GitHub Actions.
### Changed
- Documentation updated to use gem-based webhook integration instead of app-side hand-rolled service/controller code.
- Webhook security guidance switched to YooKassa-documented approach:
- secret URL token
- source IP allowlist
- API re-fetch and object/status comparison
### Notes
- No webhook signature header validation is implemented because YooKassa webhook docs describe authenticity checks via source IP and object status verification.
- Source for default IP ranges: https://yookassa.ru/developers/using-api/webhooks#ip

135
docs/chaos-yookassa.md Обычный файл
Просмотреть файл

@@ -0,0 +1,135 @@
# YooKassa Smart Payment Integration Guide
## Overview
This guide uses the `yookassa` gem for both API calls and webhook security handling.
Do not re-implement webhook signature checks in your app: this integration uses a
secret URL token + source IP allowlist + API re-fetch verification.
## Payment flow
```
User taps "Pay" -> backend creates payment via gem -> user pays on YooKassa page ->
YooKassa sends webhook -> gem controller verifies webhook -> app applies business logic
```
## 1) Install and configure the gem
```ruby
# Gemfile
gem "yookassa"
```
```ruby
# config/initializers/yookassa.rb
Yookassa.configure do |config|
config.shop_id = ENV.fetch("YOOKASSA_SHOP_ID")
config.api_key = ENV.fetch("YOOKASSA_SECRET_KEY")
# Required: long random token used in webhook route path.
config.webhook_token = ENV.fetch("YOOKASSA_WEBHOOK_TOKEN")
# Optional override, if you need a custom list.
# Defaults are from YooKassa docs:
# https://yookassa.ru/developers/using-api/webhooks#ip
# config.webhook_allowed_ips = ["185.71.76.0/27", ...]
end
```
## 2) Create payments with gem client
```ruby
payload = {
amount: {
value: "100.00",
currency: "RUB"
},
capture: true,
confirmation: {
type: "redirect",
return_url: "https://example.com/payment/return"
},
description: "Order 123"
}
payment = Yookassa.payments.create(payment: payload)
confirmation_url = payment.confirmation.confirmation_url
```
## 3) Mount gem webhook engine
```ruby
# config/routes.rb
Rails.application.routes.draw do
mount Yookassa::Engine => "/yookassa"
end
```
Default webhook endpoint:
- `POST /yookassa/webhooks/:token`
Your YooKassa dashboard webhook URL must include the configured token value, for example:
- `https://your-domain.com/yookassa/webhooks/<long-random-token>`
## 4) Add app-specific webhook logic by inheritance
`Yookassa::WebhooksController` handles authenticity checks. Override only processing.
```ruby
# app/controllers/yookassa_events_controller.rb
class YookassaEventsController < Yookassa::WebhooksController
private
def process_webhook(payload)
event = payload["event"] || payload["type"]
object = payload["object"] || payload.dig("data", "object")
return unless object
case event
when "payment.succeeded"
# apply balance / mark paid in your app
when "payment.canceled"
# mark failed in your app
end
end
end
```
```ruby
# config/routes.rb
post "/webhooks/yookassa/:token", to: "yookassa_events#create"
```
## Webhook security model used by gem
The default controller accepts webhook only if all checks pass:
1. route includes token and it matches `Yookassa.config.webhook_token`
2. source address from `request.remote_ip` is in allowlist
3. webhook object `id` + `status` matches a fresh API fetch
This approach intentionally does not use HMAC signature headers.
## Defaults and allowlist override
Default allowed IP ranges in gem:
- `185.71.76.0/27`
- `185.71.77.0/27`
- `77.75.153.0/25`
- `77.75.156.11`
- `77.75.156.35`
- `77.75.154.128/25`
- `2a02:5180::/32`
Source: https://yookassa.ru/developers/using-api/webhooks#ip
If needed:
```ruby
Yookassa.configure do |config|
config.webhook_allowed_ips = ["203.0.113.10"]
end
```

70
docs/webhooks.md Обычный файл
Просмотреть файл

@@ -0,0 +1,70 @@
# Webhooks
## Overview
The gem provides a Rails engine endpoint for YooKassa webhooks and a default controller
that performs security checks before running your app-specific logic.
Default endpoint after mounting engine:
- `POST /yookassa/webhooks/:token`
## Security model
The controller accepts a webhook only if all checks pass:
1. Route token matches configured `Yookassa.config.webhook_token`.
2. Source IP from `request.remote_ip` belongs to allowed CIDRs.
3. Webhook object `id` and `status` match a fresh API fetch (`payments.find` or `refunds.find`).
This follows YooKassa webhook guidance:
- https://yookassa.ru/developers/using-api/webhooks
## Default allowed IP ranges
From YooKassa docs (source: https://yookassa.ru/developers/using-api/webhooks#ip):
- `185.71.76.0/27`
- `185.71.77.0/27`
- `77.75.153.0/25`
- `77.75.156.11`
- `77.75.156.35`
- `77.75.154.128/25`
- `2a02:5180::/32`
## Configuration
```ruby
Yookassa.configure do |config|
config.shop_id = ENV.fetch("YOOKASSA_SHOP_ID")
config.api_key = ENV.fetch("YOOKASSA_API_KEY")
config.webhook_token = ENV.fetch("YOOKASSA_WEBHOOK_TOKEN")
# Optional override
# config.webhook_allowed_ips = ["203.0.113.10"]
end
```
## Overriding controller behavior
Inherit from `Yookassa::WebhooksController` and override `process_webhook`.
All security checks remain in the base controller.
```ruby
class MyYookassaWebhooksController < Yookassa::WebhooksController
private
def process_webhook(payload)
event = payload["event"]
object = payload["object"]
# app-specific logic
end
end
```
Route example:
```ruby
post "/webhooks/yookassa/:token", to: "my_yookassa_webhooks#create"
```

Просмотреть файл

@@ -7,6 +7,7 @@ require "yookassa/config"
require "yookassa/payments" require "yookassa/payments"
require "yookassa/refunds" require "yookassa/refunds"
require "yookassa/receipts" require "yookassa/receipts"
require "yookassa/webhooks"
module Yookassa module Yookassa
class << self class << self
@@ -31,5 +32,11 @@ module Yookassa
def receipts def receipts
@receipts ||= Receipts.new @receipts ||= Receipts.new
end end
def webhooks
@webhooks ||= Webhooks.new
end
end end
end end
require "yookassa/railtie" if defined?(Rails::Railtie)

Просмотреть файл

@@ -2,6 +2,21 @@
module Yookassa module Yookassa
class Config class Config
attr_accessor :shop_id, :api_key # Source: https://yookassa.ru/developers/using-api/webhooks#ip
DEFAULT_WEBHOOK_ALLOWED_IPS = [
"185.71.76.0/27",
"185.71.77.0/27",
"77.75.153.0/25",
"77.75.156.11",
"77.75.156.35",
"77.75.154.128/25",
"2a02:5180::/32"
].freeze
attr_accessor :shop_id, :api_key, :webhook_token, :webhook_allowed_ips
def initialize
@webhook_allowed_ips = DEFAULT_WEBHOOK_ALLOWED_IPS.dup
end
end end
end end

7
lib/yookassa/engine.rb Обычный файл
Просмотреть файл

@@ -0,0 +1,7 @@
# frozen_string_literal: true
module Yookassa
class Engine < ::Rails::Engine
isolate_namespace Yookassa
end
end

8
lib/yookassa/railtie.rb Обычный файл
Просмотреть файл

@@ -0,0 +1,8 @@
# frozen_string_literal: true
require "yookassa/engine"
module Yookassa
class Railtie < ::Rails::Railtie
end
end

Просмотреть файл

@@ -1,5 +1,5 @@
# frozen_string_literal: true # frozen_string_literal: true
module Yookassa module Yookassa
VERSION = "0.2.0" VERSION = "0.2.1"
end end

Просмотреть файл

@@ -8,9 +8,16 @@ end
require "yookassa" require "yookassa"
require "webmock/rspec" require "webmock/rspec"
WebMock.disable_net_connect!(allow_localhost: true)
Dir[File.expand_path("support/**/*.rb", __dir__)].sort.each { |file| require file }
RSpec.configure do |config| RSpec.configure do |config|
config.order = :random config.order = :random
config.filter_run focus: true config.filter_run focus: true
config.run_all_when_everything_filtered = true config.run_all_when_everything_filtered = true
config.before do
WebMock.reset!
end
end end

102
spec/support/rails_test_app.rb Обычный файл
Просмотреть файл

@@ -0,0 +1,102 @@
# frozen_string_literal: true
begin
require "logger"
require "rack/test"
require "rails"
require "action_controller/railtie"
require "capybara"
require "capybara/rspec"
require "capybara/cuprite"
rescue LoadError
nil
end
if defined?(Rails)
require "yookassa/engine"
require File.expand_path("../../app/controllers/yookassa/webhooks_controller", __dir__)
class YookassaSpecBrowserController < ActionController::Base
def index
render inline: <<~HTML
<!DOCTYPE html>
<html>
<body>
<button id="send">Send webhook</button>
<div id="result">pending</div>
<script>
document.getElementById("send").addEventListener("click", function() {
fetch("/yookassa/webhooks/<%= Yookassa.config.webhook_token %>", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
event: "payment.succeeded",
object: { id: "browser-payment", status: "succeeded" }
})
}).then(function(response) {
document.getElementById("result").textContent = String(response.status);
});
});
</script>
</body>
</html>
HTML
end
end
class YookassaSpecCustomWebhooksController < Yookassa::WebhooksController
class_attribute :last_payload, default: nil
private
def process_webhook(payload)
self.class.last_payload = payload
end
end
class YookassaSpecApp < Rails::Application
config.root = File.expand_path("../..", __dir__)
config.eager_load = false
config.secret_key_base = "test-secret-key-base"
config.logger = Logger.new(nil)
config.hosts << "www.example.com"
config.hosts << "example.org"
config.hosts << "localhost"
config.hosts << "127.0.0.1"
config.consider_all_requests_local = true
end
YookassaSpecApp.initialize!
YookassaSpecApp.routes.draw do
mount Yookassa::Engine => "/yookassa"
post "/custom-yookassa/:token", to: "yookassa_spec_custom_webhooks#create"
get "/browser", to: "yookassa_spec_browser#index"
end
Capybara.app = YookassaSpecApp
Capybara.server = :webrick
Capybara.default_max_wait_time = 5
Capybara.register_driver(:cuprite) do |app|
Capybara::Cuprite::Driver.new(app, headless: true)
end
RSpec.configure do |config|
config.include Rack::Test::Methods
config.define_derived_metadata(file_path: %r{/spec/yookassa/webhooks_}) do |metadata|
metadata[:rails] = true
end
config.before(:each, rails: true) do
Yookassa.configure do |yookassa_config|
yookassa_config.shop_id = "shop-id"
yookassa_config.api_key = "api-key"
yookassa_config.webhook_token = "secret-token"
yookassa_config.webhook_allowed_ips = Yookassa::Config::DEFAULT_WEBHOOK_ALLOWED_IPS.dup
end
YookassaSpecCustomWebhooksController.last_payload = nil
end
end
end

29
spec/yookassa/webhooks_browser_spec.rb Обычный файл
Просмотреть файл

@@ -0,0 +1,29 @@
# frozen_string_literal: true
RSpec.describe "Yookassa webhook endpoint", :rails, :browser do
include Capybara::DSL
around do |example|
previous_driver = Capybara.current_driver
Capybara.current_driver = :cuprite
example.run
Capybara.current_driver = previous_driver
end
before do
Yookassa.config.webhook_allowed_ips = ["127.0.0.1"]
payments_client = instance_double(Yookassa::Payments)
allow(Yookassa).to receive(:payments).and_return(payments_client)
allow(payments_client).to receive(:find).with(payment_id: "browser-payment")
.and_return(instance_double(Yookassa::Entity::Payment, id: "browser-payment", status: "succeeded"))
end
it "accepts browser-submitted webhook request" do
visit "/browser"
click_button "Send webhook"
expect(page).to have_text("200")
end
end

93
spec/yookassa/webhooks_controller_spec.rb Обычный файл
Просмотреть файл

@@ -0,0 +1,93 @@
# frozen_string_literal: true
RSpec.describe Yookassa::WebhooksController do
def app
YookassaSpecApp
end
let(:payload_hash) do
{
"event" => "payment.succeeded",
"object" => {
"id" => "payment-1",
"status" => "succeeded"
}
}
end
let(:payload) { JSON.generate(payload_hash) }
let(:headers) do
{
"CONTENT_TYPE" => "application/json",
"REMOTE_ADDR" => remote_addr
}
end
let(:remote_addr) { "185.71.76.1" }
describe "POST /yookassa/webhooks/:token", :rails do
it "accepts webhook when token, remote_ip, and API re-fetch checks pass" do
payments_client = instance_double(Yookassa::Payments)
allow(Yookassa).to receive(:payments).and_return(payments_client)
allow(payments_client).to receive(:find).with(payment_id: "payment-1")
.and_return(instance_double(Yookassa::Entity::Payment, id: "payment-1", status: "succeeded"))
post "/yookassa/webhooks/secret-token", payload, headers
expect(last_response.status).to eq(200)
end
it "rejects webhook when token is invalid" do
post "/yookassa/webhooks/wrong-token", payload, headers
expect(last_response.status).to eq(401)
end
it "rejects webhook when source remote_ip is outside allowlist" do
payments_client = instance_double(Yookassa::Payments)
allow(Yookassa).to receive(:payments).and_return(payments_client)
allow(payments_client).to receive(:find).with(payment_id: "payment-1")
.and_return(instance_double(Yookassa::Entity::Payment, id: "payment-1", status: "succeeded"))
post "/yookassa/webhooks/secret-token", payload, headers.merge("REMOTE_ADDR" => "203.0.113.10")
expect(last_response.status).to eq(401)
end
it "rejects webhook when fetched object differs by status" do
payments_client = instance_double(Yookassa::Payments)
allow(Yookassa).to receive(:payments).and_return(payments_client)
allow(payments_client).to receive(:find).with(payment_id: "payment-1")
.and_return(instance_double(Yookassa::Entity::Payment, id: "payment-1", status: "pending"))
post "/yookassa/webhooks/secret-token", payload, headers
expect(last_response.status).to eq(401)
end
end
describe "IP source check", :rails do
it "uses request.remote_ip" do
controller = described_class.new
request = instance_double(ActionDispatch::Request)
allow(request).to receive(:remote_ip).and_return("185.71.76.5")
allow(request).to receive(:ip).and_raise("request.ip should not be used")
allow(controller).to receive(:request).and_return(request)
expect(controller.send(:source_ip_allowed?)).to eq(true)
end
end
describe "controller inheritance", :rails do
it "allows app controller override via inheritance" do
payments_client = instance_double(Yookassa::Payments)
allow(Yookassa).to receive(:payments).and_return(payments_client)
allow(payments_client).to receive(:find).with(payment_id: "payment-1")
.and_return(instance_double(Yookassa::Entity::Payment, id: "payment-1", status: "succeeded"))
post "/custom-yookassa/secret-token", payload, headers
expect(last_response.status).to eq(200)
expect(YookassaSpecCustomWebhooksController.last_payload).to eq(payload_hash)
end
end
end

Просмотреть файл

@@ -15,14 +15,14 @@ Gem::Specification.new do |spec|
spec.license = "MIT" spec.license = "MIT"
spec.files = `git ls-files`.split($INPUT_RECORD_SEPARATOR) spec.files = `git ls-files`.split($INPUT_RECORD_SEPARATOR)
spec.test_files = spec.files.grep(/^spec/)
spec.extra_rdoc_files = Dir["README.md", "LICENSE", "CHANGELOG.md"] spec.extra_rdoc_files = Dir["README.md", "LICENSE", "CHANGELOG.md"]
spec.require_paths = ["lib"] spec.require_paths = ["lib"]
spec.required_ruby_version = ">= 2.6" spec.required_ruby_version = ">= 2.6"
spec.add_runtime_dependency "dry-struct" spec.add_dependency "dry-struct"
spec.add_runtime_dependency "dry-validation" spec.add_dependency "dry-validation"
spec.add_runtime_dependency "http", "~> 5.0" spec.add_dependency "http", ">= 5.0", "< 6.0"
spec.metadata["rubygems_mfa_required"] = "true"
end end