Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
name: Tests

on:
push:
branches: [master]
pull_request:
branches: [master]

jobs:
test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
ruby-version: ['3.1', '3.2', '3.3', '3.4']

steps:
- uses: actions/checkout@v4

- name: Set up Ruby ${{ matrix.ruby-version }}
uses: ruby/setup-ruby@v1
with:
ruby-version: ${{ matrix.ruby-version }}
bundler-cache: true

- name: Run tests
run: bundle exec rspec
2 changes: 2 additions & 0 deletions .mise.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
[tools]
ruby = "3.3"
14 changes: 0 additions & 14 deletions .travis.yml

This file was deleted.

1 change: 1 addition & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ source 'https://rubygems.org'
# Specify your gem's dependencies in cloud_payments.gemspec
gemspec

gem 'base64'
gem 'oj'
gem 'pry'
gem 'rack'
Expand Down
33 changes: 33 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,39 @@ def recurrent
end
```

## Idempotency

CloudPayments supports idempotent requests via `X-Request-ID` header.
Results are cached for **1 hour** (server-side, not configurable).

```ruby
# Use deterministic keys based on business identifiers
CloudPayments.client.payments.tokens.charge(
{ token: token, amount: 100, currency: 'RUB', invoice_id: invoice_id },
request_id: "charge:#{invoice_id}"
)
```

This is useful for preventing double charges in scenarios like:
- Network timeouts where the payment succeeded but client didn't receive response
- Infrastructure errors after successful payment
- Retry mechanisms re-sending the same charge request

**Important:** Never include timestamps or random values in the key — this defeats the purpose of idempotency.

### Examples

```ruby
# Charge — prevent double charge for the same invoice
client.payments.tokens.charge(attributes, request_id: "charge:#{invoice_id}")

# Refund — prevent double refund for the same transaction
client.payments.refund(transaction_id, amount, request_id: "refund:#{transaction_id}")

# Subscription — prevent duplicate subscription creation
client.subscriptions.create(attributes, request_id: "subscription:#{account_id}:#{plan_id}")
```

## Contributing

1. Fork it ( https://github.com/platmart/cloud_payments/fork )
Expand Down
1 change: 1 addition & 0 deletions cloud_payments.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ Gem::Specification.new do |spec|
spec.executables = spec.files.grep(%r{^bin/}){|f| File.basename(f) }
spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
spec.require_paths = ['lib']
spec.required_ruby_version = '>= 3.1'

spec.add_dependency 'faraday', '< 3.0'
spec.add_dependency 'multi_json', '~> 1.11'
Expand Down
13 changes: 9 additions & 4 deletions lib/cloud_payments/client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@ def initialize(config = nil)
@connection = build_connection
end

def perform_request(path, params = nil)
response = connection.post(path, (params ? convert_to_json(params) : nil), headers)
def perform_request(path, params = nil, request_id: nil)
response = connection.post(path, (params ? convert_to_json(params) : nil), headers(request_id: request_id))

Response.new(response.status, response.body, response.headers).tap do |response|
raise_transport_error(response) if response.status.to_i >= 300
Expand All @@ -29,8 +29,13 @@ def convert_to_json(data)
config.serializer.dump(data)
end

def headers
{ 'Content-Type' => 'application/json' }
def headers(request_id: nil)
h = { 'Content-Type' => 'application/json' }
if request_id
request_id_str = request_id.to_s.strip
h['X-Request-ID'] = request_id_str unless request_id_str.empty?
end
h
end

def logger
Expand Down
4 changes: 2 additions & 2 deletions lib/cloud_payments/namespaces/apple_pay.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,10 @@ def self.resource_name
'applepay'
end

def start_session(attributes)
def start_session(attributes, request_id: nil)
validation_url = attributes.fetch(:validation_url) { raise ValidationUrlMissing.new('validation_url is required') }

request(:startsession, { "ValidationUrl" => validation_url })
request(:startsession, { "ValidationUrl" => validation_url }, request_id: request_id)
end
end
end
Expand Down
4 changes: 2 additions & 2 deletions lib/cloud_payments/namespaces/base.rb
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@ def initialize(client, parent_path = nil)
@parent_path = parent_path
end

def request(path, params = {})
response = client.perform_request(resource_path(path), params)
def request(path, params = {}, request_id: nil)
response = client.perform_request(resource_path(path), params, request_id: request_id)
raise_gateway_error(response.body) unless response.body[:success]
response.body
end
Expand Down
12 changes: 6 additions & 6 deletions lib/cloud_payments/namespaces/cards.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,18 @@
module CloudPayments
module Namespaces
class Cards < Base
def charge(attributes)
response = request(:charge, attributes)
def charge(attributes, request_id: nil)
response = request(:charge, attributes, request_id: request_id)
instantiate(response[:model])
end

def auth(attributes)
response = request(:auth, attributes)
def auth(attributes, request_id: nil)
response = request(:auth, attributes, request_id: request_id)
instantiate(response[:model])
end

def post3ds(attributes)
response = request(:post3ds, attributes)
def post3ds(attributes, request_id: nil)
response = request(:post3ds, attributes, request_id: request_id)
instantiate(response[:model])
end

Expand Down
12 changes: 6 additions & 6 deletions lib/cloud_payments/namespaces/kassa.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,31 +13,31 @@ def self.resource_name
'kkt'
end

def receipt(attributes)
def receipt(attributes, request_id: nil)
attributes.fetch(:inn) { raise InnNotProvided.new('inn attribute is required') }
attributes.fetch(:type) { raise TypeNotProvided.new('type attribute is required') }
attributes.fetch(:customer_receipt) { raise CustomerReceiptNotProvided.new('customer_receipt is required') }

request(:receipt, attributes)
request(:receipt, attributes, request_id: request_id)
end

def correction_receipt(attributes)
def correction_receipt(attributes, request_id: nil)
missing_attributes = CORRECTION_RECEIPT_REQUIRED_ATTRIBUTES.select do |attr|
!attributes.key?(attr)
end
raise KeyNotProvided.new("Attribute(s) #{missing_attributes.join(',')} are required") if missing_attributes.any?

request('correction-receipt', correction_receipt_data: attributes)
request('correction-receipt', { correction_receipt_data: attributes }, request_id: request_id)
end

# Запрос статуса чека коррекции
def correction_receipt_status(id)
request('correction-receipt/status/get', id: id)
request('correction-receipt/status/get', { id: id })
end

# Получение данных чека коррекции
def correction_receipt_info(id)
request('correction-receipt/get', id: id)
request('correction-receipt/get', { id: id })
end
end
end
Expand Down
8 changes: 4 additions & 4 deletions lib/cloud_payments/namespaces/orders.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,13 @@
module CloudPayments
module Namespaces
class Orders < Base
def create(attributes)
response = request(:create, attributes)
def create(attributes, request_id: nil)
response = request(:create, attributes, request_id: request_id)
Order.new(response[:model])
end

def cancel(order_id)
request(:cancel, id: order_id)[:success]
def cancel(order_id, request_id: nil)
request(:cancel, { id: order_id }, request_id: request_id)[:success]
end
end
end
Expand Down
24 changes: 13 additions & 11 deletions lib/cloud_payments/namespaces/payments.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,32 +10,34 @@ def tokens
Tokens.new(client, resource_path)
end

def confirm(id, amount)
request(:confirm, transaction_id: id, amount: amount)[:success]
def confirm(id, amount, request_id: nil)
request(:confirm, { transaction_id: id, amount: amount }, request_id: request_id)[:success]
end

def void(id)
request(:void, transaction_id: id)[:success]
def void(id, request_id: nil)
request(:void, { transaction_id: id }, request_id: request_id)[:success]
end

alias :cancel :void
def cancel(id, request_id: nil)
void(id, request_id: request_id)
end

def refund(id, amount)
request(:refund, transaction_id: id, amount: amount)[:success]
def refund(id, amount, request_id: nil)
request(:refund, { transaction_id: id, amount: amount }, request_id: request_id)[:success]
end

def post3ds(id, pa_res)
response = request(:post3ds, transaction_id: id, pa_res: pa_res)
def post3ds(id, pa_res, request_id: nil)
response = request(:post3ds, { transaction_id: id, pa_res: pa_res }, request_id: request_id)
Transaction.new(response[:model])
end

def get(id)
response = request(:get, transaction_id: id)
response = request(:get, { transaction_id: id })
Transaction.new(response[:model])
end

def find(invoice_id)
response = request(:find, invoice_id: invoice_id)
response = request(:find, { invoice_id: invoice_id })
Transaction.new(response[:model])
end
end
Expand Down
16 changes: 8 additions & 8 deletions lib/cloud_payments/namespaces/subscriptions.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,27 +3,27 @@ module CloudPayments
module Namespaces
class Subscriptions < Base
def find(id)
response = request(:get, id: id)
response = request(:get, { id: id })
Subscription.new(response[:model])
end

def find_all(account_id)
response = request(:find, account_id: account_id)
response = request(:find, { account_id: account_id })
Array(response[:model]).map { |item| Subscription.new(item) }
end

def create(attributes)
response = request(:create, attributes)
def create(attributes, request_id: nil)
response = request(:create, attributes, request_id: request_id)
Subscription.new(response[:model])
end

def update(id, attributes)
response = request(:update, attributes.merge(id: id))
def update(id, attributes, request_id: nil)
response = request(:update, attributes.merge(id: id), request_id: request_id)
Subscription.new(response[:model])
end

def cancel(id)
request(:cancel, id: id)[:success]
def cancel(id, request_id: nil)
request(:cancel, { id: id }, request_id: request_id)[:success]
end
end
end
Expand Down
8 changes: 4 additions & 4 deletions lib/cloud_payments/namespaces/tokens.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,13 @@
module CloudPayments
module Namespaces
class Tokens < Base
def charge(attributes)
response = request(:charge, attributes)
def charge(attributes, request_id: nil)
response = request(:charge, attributes, request_id: request_id)
Transaction.new(response[:model])
end

def auth(attributes)
response = request(:auth, attributes)
def auth(attributes, request_id: nil)
response = request(:auth, attributes, request_id: request_id)
Transaction.new(response[:model])
end
end
Expand Down
Loading