diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..9d4c170 --- /dev/null +++ b/.github/workflows/test.yml @@ -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 diff --git a/.mise.toml b/.mise.toml new file mode 100644 index 0000000..0c4809e --- /dev/null +++ b/.mise.toml @@ -0,0 +1,2 @@ +[tools] +ruby = "3.3" diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 2123848..0000000 --- a/.travis.yml +++ /dev/null @@ -1,14 +0,0 @@ -language: ruby - -rvm: - - 2.3 - - 2.4 - - 2.5 - - 2.6 - - 2.7 - -cache: bundler - -before_install: gem install bundler - -script: bundle exec rspec diff --git a/Gemfile b/Gemfile index 738a848..0dbf38f 100644 --- a/Gemfile +++ b/Gemfile @@ -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' diff --git a/README.md b/README.md index 740b59a..7572bee 100644 --- a/README.md +++ b/README.md @@ -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 ) diff --git a/cloud_payments.gemspec b/cloud_payments.gemspec index 45f082a..7aa1816 100644 --- a/cloud_payments.gemspec +++ b/cloud_payments.gemspec @@ -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' diff --git a/lib/cloud_payments/client.rb b/lib/cloud_payments/client.rb index 72ddf70..9d26bee 100644 --- a/lib/cloud_payments/client.rb +++ b/lib/cloud_payments/client.rb @@ -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 @@ -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 diff --git a/lib/cloud_payments/namespaces/apple_pay.rb b/lib/cloud_payments/namespaces/apple_pay.rb index eee0f19..ace8f1b 100644 --- a/lib/cloud_payments/namespaces/apple_pay.rb +++ b/lib/cloud_payments/namespaces/apple_pay.rb @@ -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 diff --git a/lib/cloud_payments/namespaces/base.rb b/lib/cloud_payments/namespaces/base.rb index 58d0d21..ba54604 100644 --- a/lib/cloud_payments/namespaces/base.rb +++ b/lib/cloud_payments/namespaces/base.rb @@ -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 diff --git a/lib/cloud_payments/namespaces/cards.rb b/lib/cloud_payments/namespaces/cards.rb index ad75b18..fe29674 100644 --- a/lib/cloud_payments/namespaces/cards.rb +++ b/lib/cloud_payments/namespaces/cards.rb @@ -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 diff --git a/lib/cloud_payments/namespaces/kassa.rb b/lib/cloud_payments/namespaces/kassa.rb index 422e395..030b1f7 100644 --- a/lib/cloud_payments/namespaces/kassa.rb +++ b/lib/cloud_payments/namespaces/kassa.rb @@ -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 diff --git a/lib/cloud_payments/namespaces/orders.rb b/lib/cloud_payments/namespaces/orders.rb index a70fce4..deeff2f 100644 --- a/lib/cloud_payments/namespaces/orders.rb +++ b/lib/cloud_payments/namespaces/orders.rb @@ -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 diff --git a/lib/cloud_payments/namespaces/payments.rb b/lib/cloud_payments/namespaces/payments.rb index a4d5943..a8f35bf 100644 --- a/lib/cloud_payments/namespaces/payments.rb +++ b/lib/cloud_payments/namespaces/payments.rb @@ -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 diff --git a/lib/cloud_payments/namespaces/subscriptions.rb b/lib/cloud_payments/namespaces/subscriptions.rb index 5a56aa1..543cd17 100644 --- a/lib/cloud_payments/namespaces/subscriptions.rb +++ b/lib/cloud_payments/namespaces/subscriptions.rb @@ -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 diff --git a/lib/cloud_payments/namespaces/tokens.rb b/lib/cloud_payments/namespaces/tokens.rb index 625d453..f13b2b1 100644 --- a/lib/cloud_payments/namespaces/tokens.rb +++ b/lib/cloud_payments/namespaces/tokens.rb @@ -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 diff --git a/spec/cloud_payments/client_spec.rb b/spec/cloud_payments/client_spec.rb new file mode 100644 index 0000000..1445ad9 --- /dev/null +++ b/spec/cloud_payments/client_spec.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true +require 'spec_helper' + +describe CloudPayments::Client do + describe '#headers' do + let(:client) { CloudPayments::Client.new } + + it 'includes Content-Type header' do + expect(client.send(:headers)).to include('Content-Type' => 'application/json') + end + + it 'does not include X-Request-ID when not provided' do + expect(client.send(:headers)).not_to have_key('X-Request-ID') + end + + it 'does not include X-Request-ID when nil' do + expect(client.send(:headers, request_id: nil)).not_to have_key('X-Request-ID') + end + + it 'does not include X-Request-ID when empty string' do + expect(client.send(:headers, request_id: '')).not_to have_key('X-Request-ID') + end + + it 'does not include X-Request-ID when whitespace only' do + expect(client.send(:headers, request_id: ' ')).not_to have_key('X-Request-ID') + end + + it 'strips whitespace from request_id' do + expect(client.send(:headers, request_id: ' my-key ')).to include('X-Request-ID' => 'my-key') + end + + it 'includes X-Request-ID when provided' do + expect(client.send(:headers, request_id: 'test-idempotency-key')).to include('X-Request-ID' => 'test-idempotency-key') + end + end + + describe '#perform_request with request_id' do + let(:request_id) { 'charge:invoice-12345' } + + it 'sends X-Request-ID header with the request' do + stub_request(:post, 'http://localhost:9292/payments/tokens/charge') + .with( + headers: { + 'Content-Type' => 'application/json', + 'X-Request-ID' => request_id + }, + basic_auth: ['user', 'pass'] + ) + .to_return( + status: 200, + body: '{"Success":true,"Model":{"TransactionId":12345,"Status":"Completed"}}', + headers: { 'Content-Type' => 'application/json' } + ) + + response = CloudPayments.client.perform_request('/payments/tokens/charge', { amount: 100 }, request_id: request_id) + expect(response.status).to eq(200) + end + + it 'does not send X-Request-ID header when not provided' do + request_headers = nil + stub_request(:post, 'http://localhost:9292/payments/tokens/charge') + .with { |request| request_headers = request.headers; true } + .to_return( + status: 200, + body: '{"Success":true,"Model":{"TransactionId":12345,"Status":"Completed"}}', + headers: { 'Content-Type' => 'application/json' } + ) + + CloudPayments.client.perform_request('/payments/tokens/charge', { amount: 100 }) + expect(request_headers).not_to have_key('X-Request-Id') + end + end +end diff --git a/spec/cloud_payments/namespaces/base_spec.rb b/spec/cloud_payments/namespaces/base_spec.rb index 6dab937..bc8e7ef 100644 --- a/spec/cloud_payments/namespaces/base_spec.rb +++ b/spec/cloud_payments/namespaces/base_spec.rb @@ -25,6 +25,22 @@ def stub_api(path, body = '') specify{ expect(subject.request(nil, request_params)) } end + context 'with request_id' do + let(:request_id) { 'test-idempotency-key' } + + before do + stub_request(:post, 'http://localhost:9292/testnamespace') + .with( + body: request_body, + headers: headers.merge('X-Request-ID' => request_id), + basic_auth: ['user', 'pass'] + ) + .to_return(body: successful_body, headers: headers) + end + + specify { expect(subject.request(nil, request_params, request_id: request_id)).to include(success: true) } + end + context 'with path' do before{ stub_api('/testnamespace/path', request_body).to_return(body: successful_body, headers: headers) } diff --git a/spec/cloud_payments/namespaces/tokens_spec.rb b/spec/cloud_payments/namespaces/tokens_spec.rb index b064781..a8a1a48 100644 --- a/spec/cloud_payments/namespaces/tokens_spec.rb +++ b/spec/cloud_payments/namespaces/tokens_spec.rb @@ -15,6 +15,30 @@ } } describe '#charge' do + context 'with request_id' do + let(:request_id) { 'charge:invoice-12345' } + let(:response_body) do + <<~JSON + {"Success":true,"Model":{ + "TransactionId":12345,"Amount":10.0,"Currency":"RUB","CurrencyCode":0, + "Status":"Completed","StatusCode":3,"TestMode":true, + "CardFirstSix":"411111","CardLastFour":"1111","CardType":"Visa" + }} + JSON + end + + before do + stub_request(:post, 'http://localhost:9292/payments/tokens/charge') + .with( + headers: { 'Content-Type' => 'application/json', 'X-Request-ID' => request_id }, + basic_auth: ['user', 'pass'] + ) + .to_return(status: 200, body: response_body, headers: { 'Content-Type' => 'application/json' }) + end + + specify { expect(subject.charge(attributes, request_id: request_id)).to be_instance_of(CloudPayments::Transaction) } + end + context 'config.raise_banking_errors = true' do before { CloudPayments.config.raise_banking_errors = true } after { CloudPayments.config.raise_banking_errors = false }