wajub 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: d9c7d3e31696d7f37926d39cba029ab759220d634b654cf3612ca3ae1218932a
4
+ data.tar.gz: ed5abaf317be01db7425470f5a9029b20ed3cf82b026f61f29346296f40ee595
5
+ SHA512:
6
+ metadata.gz: e060e51ecc44fcf979e9b25f3290ae50101270a046455bf05d63a451abf155f8550fd1ec41f37ea9151372dc548891c67c73bf090041f444493d9066cb4737d2
7
+ data.tar.gz: 89ee4872f3fac3a97ebbc7484f394487bc434f79aeca1fb320348c9424e6d4a097373cdde0a62afb839290d22f14146bd1e1a30faa5a69cf6780591b5e075a64
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Wajub
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,228 @@
1
+ # Wajub Ruby SDK
2
+
3
+ [![Gem Version](https://img.shields.io/gem/v/wajub)](https://rubygems.org/gems/wajub)
4
+ [![Ruby](https://img.shields.io/badge/Ruby-3.1%2B-red)](https://www.ruby-lang.org/)
5
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
6
+
7
+ Official **server-side** SDK for the [Wajub merchant API](https://docs.wajub.com). Accept mobile-money and card payments across Africa with a Stripe-inspired, resource-oriented client.
8
+
9
+ Use **[Wajub.js](https://docs.wajub.com/libraries/components/js)** for embedded checkout in the browser. Use this SDK on your backend with a secret (`sk_`) or restricted (`rk_`) API key — never expose secret keys in client-side code.
10
+
11
+ This SDK covers the **merchant API**. It does not wrap checkout session endpoints (`/pay/*`), public link checkout (`/q/*`, `/i/*`), or sandbox simulation — those belong to Wajub.js or direct HTTP during the payment flow.
12
+
13
+ ## Features
14
+
15
+ - Resource-oriented API (`client.payments`, `client.customers`, …)
16
+ - Automatic `Idempotency-Key` on mutating requests (override per call)
17
+ - Typed errors per HTTP status (`AuthenticationError`, `RateLimitError`, …)
18
+ - Automatic retries on 429 and 5xx (max 2, exponential backoff)
19
+ - Page-based pagination with `auto_paging_each` and `next_page`
20
+ - Webhook signature verification (HMAC-SHA256, timestamp tolerance)
21
+ - Zero third-party runtime dependencies (stdlib only)
22
+
23
+ ## Requirements
24
+
25
+ | Requirement | Version |
26
+ |-------------|---------|
27
+ | Ruby | 3.1 or later |
28
+ | Dependencies | Standard library only (`net/http`, `openssl`, `json`) |
29
+
30
+ ## Installation
31
+
32
+ Add to your Gemfile:
33
+
34
+ ```ruby
35
+ gem 'wajub', '~> 1.1'
36
+ ```
37
+
38
+ Or install directly:
39
+
40
+ ```bash
41
+ gem install wajub
42
+ ```
43
+
44
+ ## Quick start
45
+
46
+ Amounts are passed in the **smallest currency unit** (e.g. cents for EUR/USD; whole francs for XAF).
47
+
48
+ ### Redirect checkout
49
+
50
+ ```ruby
51
+ require 'wajub'
52
+
53
+ client = Wajub::Client.new(api_key: ENV['WAJUB_API_KEY'])
54
+
55
+ payment = client.payments.create(
56
+ 'amount' => 15_000,
57
+ 'currency' => 'XAF',
58
+ 'email' => 'buyer@example.com',
59
+ 'callback' => 'https://shop.example.com/order/complete'
60
+ )
61
+
62
+ puts payment.authorization_url
63
+ ```
64
+
65
+ ### Inline / overlay (embed token)
66
+
67
+ ```ruby
68
+ embed = client.payments.create(
69
+ 'amount' => 15_000,
70
+ 'currency' => 'XAF',
71
+ 'metadata' => { 'mode' => 'embed' }
72
+ )
73
+
74
+ # Pass to Wajub.js: embed.authorization_token
75
+ ```
76
+
77
+ `create()` and `retrieve()` return a typed `Payment` object — prefer method access (`payment.authorization_url`). List pages from `list()` yield plain hashes; bracket syntax (`payment['…']`) remains available on typed objects.
78
+
79
+ ## Rails
80
+
81
+ ```ruby
82
+ # config/initializers/wajub.rb
83
+ WajubClient = Wajub::Client.new(
84
+ api_key: Rails.application.credentials.dig(:wajub, :api_key) || ENV['WAJUB_API_KEY'],
85
+ webhook_secret: ENV['WAJUB_WEBHOOK_SECRET']
86
+ )
87
+ ```
88
+
89
+ ### Webhook controller
90
+
91
+ Use the **raw request body**:
92
+
93
+ ```ruby
94
+ class WebhooksController < ApplicationController
95
+ skip_before_action :verify_authenticity_token
96
+
97
+ def wajub
98
+ event = WajubClient.webhooks.construct_event(
99
+ request.body.read, # raw body — not params
100
+ request.headers['X-Wajub-Signature'],
101
+ request.headers['X-Wajub-Timestamp']
102
+ )
103
+
104
+ case event['type']
105
+ when 'payment.succeeded'
106
+ # fulfill order
107
+ end
108
+
109
+ head :ok
110
+ rescue Wajub::WebhookSignatureVerificationError
111
+ head :bad_request
112
+ end
113
+ end
114
+ ```
115
+
116
+ ## Configuration
117
+
118
+ | Variable | Description |
119
+ |----------|-------------|
120
+ | `WAJUB_API_KEY` | Secret or restricted API key (`sk_`, `sk_test.`, `rk_`, …) |
121
+ | `WAJUB_WEBHOOK_SECRET` | Webhook signing secret (`whsec_`) for `construct_event()` |
122
+
123
+ Test mode is selected by your API key prefix (`sk_test.…`), not by the API URL. Production calls always go to `https://api.wajub.com`.
124
+
125
+ ## Resources (merchant API)
126
+
127
+ | Property | Methods |
128
+ |----------|---------|
129
+ | `client.global` | `ping`, `channels`, `countries`, `currencies` |
130
+ | `client.payments` | `create`, `initialize_payment`, `retrieve`, `list`, `cancel`, `process`, `process_split`, `list_refunds` |
131
+ | `client.customers` | `create`, `retrieve`, `update`, `delete`, `list`, `block`, `unblock`, `activate`, `deactivate`, `list_tax_ids`, `create_tax_id`, `delete_tax_id` |
132
+ | `client.refunds` | `create`, `retrieve`, `list` |
133
+ | `client.transfers` | `create`, `retrieve`, `list` |
134
+ | `client.beneficiaries` | `create`, `retrieve`, `update`, `delete`, `list` |
135
+ | `client.links` | `create`, `retrieve`, `update`, `delete`, `list` |
136
+ | `client.invoices` | `create`, `retrieve`, `update`, `delete`, `list`, `send`, `mark_paid`, `cancel` |
137
+ | `client.accounts` | `create`, `retrieve`, `update`, `delete`, `list`, `regenerate_token` |
138
+ | `client.webhook_endpoints` | `create`, `retrieve`, `update`, `delete`, `list`, `rotate_secret` |
139
+ | `client.balance` | `retrieve` |
140
+ | `client.events` | `list`, `retrieve`, `resend` |
141
+ | `client.disputes` | `list`, `retrieve`, `submit_evidence`, `accept`, `close`, `send_message` |
142
+ | `client.identity` | `resolve`, `validate` |
143
+ | `client.tax` | `get_settings`, `update_settings`, `rates`, `calculate`, `reports`, `list_codes`, `retrieve_code`, `list_registrations`, `create_registration`, `retrieve_registration`, `update_registration`, `delete_registration`, `jurisdictions`, `thresholds`, `threshold_alerts` |
144
+ | `client.shield` | `get_settings`, `update_settings`, `stats`, `list_blocklist`, `add_to_blocklist`, `remove_from_blocklist` |
145
+ | `client.listen` | `config`, `auth` |
146
+ | `client.webhooks` | `construct_event` (local — no HTTP) |
147
+
148
+ ## Sync (Connect)
149
+
150
+ ```ruby
151
+ client.payments.create(params, Wajub::RequestOptions.new(sync: 'acct_sync_ref'))
152
+ ```
153
+
154
+ ## Webhooks
155
+
156
+ ```ruby
157
+ begin
158
+ event = client.webhooks.construct_event(
159
+ request.body.read, # String — raw body bytes
160
+ request.env['HTTP_X_WAJUB_SIGNATURE'],
161
+ request.env['HTTP_X_WAJUB_TIMESTAMP']
162
+ )
163
+ rescue Wajub::WebhookSignatureVerificationError
164
+ halt 400
165
+ end
166
+
167
+ case event['type']
168
+ when 'payment.succeeded'
169
+ # fulfill order
170
+ end
171
+ ```
172
+
173
+ During local development, use the [Wajub CLI](https://github.com/wajubhq/wajub-cli) to forward webhooks to your machine.
174
+
175
+ ## Pagination
176
+
177
+ ```ruby
178
+ page = client.payments.list('per_page' => 50)
179
+
180
+ page.auto_paging_each do |payment|
181
+ puts "#{payment['id']} #{payment['status']}"
182
+ end
183
+
184
+ # Manual page control
185
+ first = client.payments.list
186
+ second = first.next_page if first.has_more
187
+ ```
188
+
189
+ ## Idempotency
190
+
191
+ POST and PUT requests automatically receive an `Idempotency-Key` header. Pass your own:
192
+
193
+ ```ruby
194
+ client.payments.create(
195
+ params,
196
+ Wajub::RequestOptions.new(idempotency_key: "order-#{order_id}")
197
+ )
198
+ ```
199
+
200
+ ## Error handling
201
+
202
+ ```ruby
203
+ begin
204
+ client.payments.create(params)
205
+ rescue Wajub::InvalidRequestError => e
206
+ puts e.errors # field-level validation errors
207
+ rescue Wajub::AuthenticationError
208
+ # 401 — bad API key
209
+ rescue Wajub::RateLimitError
210
+ # 429 — back off and retry
211
+ end
212
+ ```
213
+
214
+ ## Development
215
+
216
+ ```bash
217
+ bundle install
218
+ bundle exec rake test
219
+ ```
220
+
221
+ ## Documentation & support
222
+
223
+ - Full API reference: [docs.wajub.com/libraries/sdks/ruby](https://docs.wajub.com/libraries/sdks/ruby)
224
+ - Report issues: [github.com/wajubhq/wajub-ruby/issues](https://github.com/wajubhq/wajub-ruby/issues)
225
+
226
+ ## License
227
+
228
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'http/http_utils'
4
+ require_relative 'http/base_client'
5
+ require_relative 'resources'
6
+ require_relative 'webhooks'
7
+
8
+ module Wajub
9
+ class Client
10
+ attr_reader :global, :payments, :customers, :refunds, :transfers, :beneficiaries,
11
+ :links, :balance, :events, :accounts, :webhook_endpoints, :invoices,
12
+ :disputes, :identity, :tax, :shield, :listen, :webhooks
13
+
14
+ def initialize(api_key: nil, webhook_secret: nil, idempotency_key_prefix: 'wajub', transport: nil)
15
+ key = HttpUtils.normalize_api_key(api_key || ENV.fetch('WAJUB_API_KEY', ''))
16
+ raise ArgumentError, 'Wajub: api_key is required' if key.empty?
17
+
18
+ base_url = HttpUtils.api_url
19
+ prefix = idempotency_key_prefix
20
+ @transport = transport
21
+
22
+ @global = GlobalResource.new(key, base_url, prefix, transport: shared_transport(base_url))
23
+ @payments = PaymentsResource.new(key, base_url, prefix, transport: shared_transport(base_url))
24
+ @customers = CustomersResource.new(key, base_url, prefix, transport: shared_transport(base_url))
25
+ @refunds = CrudResource.new(key, base_url, 'refunds', 'refund', 'refunds', prefix, transport: shared_transport(base_url))
26
+ @transfers = CrudResource.new(key, base_url, 'transfers', 'transfer', 'transfers', prefix, transport: shared_transport(base_url))
27
+ @beneficiaries = CrudResource.new(key, base_url, 'beneficiaries', 'beneficiary', 'beneficiaries', prefix, transport: shared_transport(base_url))
28
+ @links = CrudResource.new(key, base_url, 'links', 'link', 'links', prefix, transport: shared_transport(base_url))
29
+ @balance = BalanceResource.new(key, base_url, prefix, transport: shared_transport(base_url))
30
+ @events = EventsResource.new(key, base_url, prefix, transport: shared_transport(base_url))
31
+ @accounts = AccountsResource.new(key, base_url, prefix, transport: shared_transport(base_url))
32
+ @webhook_endpoints = WebhookEndpointsResource.new(key, base_url, prefix, transport: shared_transport(base_url))
33
+ @invoices = InvoicesResource.new(key, base_url, prefix, transport: shared_transport(base_url))
34
+ @disputes = DisputesResource.new(key, base_url, prefix, transport: shared_transport(base_url))
35
+ @identity = IdentityResource.new(key, base_url, prefix, transport: shared_transport(base_url))
36
+ @tax = TaxResource.new(key, base_url, prefix, transport: shared_transport(base_url))
37
+ @shield = ShieldResource.new(key, base_url, prefix, transport: shared_transport(base_url))
38
+ @listen = ListenResource.new(key, base_url, prefix, transport: shared_transport(base_url))
39
+ @webhooks = Webhooks.new(webhook_secret || ENV.fetch('WAJUB_WEBHOOK_SECRET', ''))
40
+ end
41
+
42
+ private
43
+
44
+ def shared_transport(base_url)
45
+ @transport || @shared ||= NetHttpTransport.new(base_url, user_agent: "wajub-ruby/#{VERSION}")
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'openssl'
4
+
5
+ module Wajub
6
+ module Crypto
7
+ module_function
8
+
9
+ def hmac_sha256(key, data)
10
+ OpenSSL::HMAC.hexdigest('SHA256', key, data)
11
+ end
12
+
13
+ def timing_safe_equal(a, b)
14
+ return false unless a.bytesize == b.bytesize
15
+
16
+ l = a.unpack('C*')
17
+ r = b.unpack('C*')
18
+ result = 0
19
+ l.zip(r) { |x, y| result |= x ^ y }
20
+ result.zero?
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wajub
4
+ class WajubError < StandardError
5
+ attr_reader :code, :http_status, :errors, :raw
6
+
7
+ def initialize(message, code:, http_status:, errors: nil, raw: nil)
8
+ super(message)
9
+ @code = code
10
+ @http_status = http_status
11
+ @errors = errors
12
+ @raw = raw
13
+ end
14
+ end
15
+
16
+ class AuthenticationError < WajubError; end
17
+ class PermissionError < WajubError; end
18
+ class NotFoundError < WajubError; end
19
+ class InvalidRequestError < WajubError; end
20
+
21
+ class RateLimitError < WajubError
22
+ attr_reader :retry_after
23
+
24
+ def initialize(message, code:, http_status:, errors: nil, raw: nil, retry_after: nil)
25
+ super(message, code: code, http_status: http_status, errors: errors, raw: raw)
26
+ @retry_after = retry_after
27
+ end
28
+ end
29
+
30
+ class ApiConnectionError < WajubError
31
+ def initialize(message, code: 'network_error', http_status: 0, errors: nil, raw: nil)
32
+ super(message, code: code, http_status: http_status, errors: errors, raw: raw)
33
+ end
34
+ end
35
+
36
+ class WebhookSignatureVerificationError < StandardError; end
37
+
38
+ module Errors
39
+ module_function
40
+
41
+ def from_response(status, body, retry_after: nil)
42
+ message = body['message'] || "Request failed (#{status})"
43
+ code = body['error_code'] || body['code'] || "http_#{status}"
44
+ field_errors = parse_field_errors(body['errors'])
45
+
46
+ case status
47
+ when 401 then AuthenticationError.new(message, code: code, http_status: status, errors: field_errors, raw: body)
48
+ when 403 then PermissionError.new(message, code: code, http_status: status, errors: field_errors, raw: body)
49
+ when 404 then NotFoundError.new(message, code: code, http_status: status, errors: field_errors, raw: body)
50
+ when 429 then RateLimitError.new(message, code: code, http_status: status, errors: field_errors, raw: body, retry_after: retry_after)
51
+ when 400, 422 then InvalidRequestError.new(message, code: code, http_status: status, errors: field_errors, raw: body)
52
+ else WajubError.new(message, code: code, http_status: status, errors: field_errors, raw: body)
53
+ end
54
+ end
55
+
56
+ def parse_field_errors(raw)
57
+ return nil unless raw.is_a?(Hash)
58
+
59
+ raw.transform_values do |value|
60
+ value.is_a?(Array) ? value.first.to_s : value.to_s
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,173 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'net/http'
5
+ require 'uri'
6
+
7
+ require_relative 'http_utils'
8
+ require_relative 'retry_policy'
9
+ require_relative '../errors'
10
+ require_relative '../request_options'
11
+ require_relative '../version'
12
+
13
+ module Wajub
14
+ class BaseClient
15
+ attr_reader :api_key, :base_url, :idempotency_key_prefix, :transport, :last_request
16
+
17
+ def initialize(api_key, base_url, idempotency_key_prefix = 'wajub', transport: nil)
18
+ @api_key = HttpUtils.normalize_api_key(api_key)
19
+ @base_url = base_url.sub(%r{/+\z}, '')
20
+ @idempotency_key_prefix = idempotency_key_prefix
21
+ @transport = transport || NetHttpTransport.new(@base_url, user_agent: "wajub-ruby/#{VERSION}")
22
+ @last_request = nil
23
+ end
24
+
25
+ def get(path, params = nil)
26
+ request('GET', path, body: nil, options: nil, query: params)
27
+ end
28
+
29
+ def post(path, body = nil, options = nil)
30
+ request('POST', path, body: body, options: options)
31
+ end
32
+
33
+ def put(path, body = nil, options = nil)
34
+ request('PUT', path, body: body, options: options)
35
+ end
36
+
37
+ def delete(path, options = nil)
38
+ request('DELETE', path, body: nil, options: options)
39
+ end
40
+
41
+ def request(method, path, body: nil, options: nil, query: nil)
42
+ headers = {
43
+ 'Accept' => 'application/json',
44
+ 'Authorization' => @api_key
45
+ }
46
+
47
+ options ||= RequestOptions.new
48
+ headers.merge!(options.headers) if options.headers
49
+ headers['X-Sync'] = options.sync if options.sync
50
+
51
+ idempotency_key = options.idempotency_key
52
+ if idempotency_key.nil? && !%w[GET DELETE].include?(method)
53
+ idempotency_key = HttpUtils.create_idempotency_key(@idempotency_key_prefix)
54
+ end
55
+ headers['Idempotency-Key'] = idempotency_key if idempotency_key
56
+
57
+ payload = body.nil? ? nil : JSON.generate(body)
58
+ headers['Content-Type'] = 'application/json' if payload
59
+
60
+ attempt = 0
61
+
62
+ loop do
63
+ begin
64
+ response = @transport.call(
65
+ method: method,
66
+ path: path,
67
+ headers: headers,
68
+ body: payload,
69
+ query: query
70
+ )
71
+ rescue StandardError => e
72
+ if attempt < RetryPolicy.max_retries
73
+ attempt += 1
74
+ sleep RetryPolicy.delay_seconds(attempt)
75
+ next
76
+ end
77
+ raise ApiConnectionError.new(e.message)
78
+ end
79
+
80
+ @last_request = response[:request]
81
+ status = response[:status]
82
+ data = response[:body].is_a?(Hash) ? response[:body] : HttpUtils.decode_json(response[:body].to_s)
83
+
84
+ return data if status >= 200 && status < 300
85
+
86
+ if RetryPolicy.should_retry_status?(status) && attempt < RetryPolicy.max_retries
87
+ attempt += 1
88
+ sleep RetryPolicy.delay_seconds(attempt, response: response[:headers] || {})
89
+ next
90
+ end
91
+
92
+ retry_after = response.dig(:headers, 'retry-after')&.to_i
93
+ raise Errors.from_response(status, data, retry_after: retry_after)
94
+ end
95
+ end
96
+ end
97
+
98
+ class NetHttpTransport
99
+ def initialize(base_url, user_agent:)
100
+ @base_url = base_url
101
+ @user_agent = user_agent
102
+ @mutex = Mutex.new
103
+ end
104
+
105
+ def call(method:, path:, headers:, body:, query:)
106
+ uri = URI.parse("#{@base_url}#{path}")
107
+ if query
108
+ filtered = query.compact
109
+ uri.query = URI.encode_www_form(filtered) unless filtered.empty?
110
+ end
111
+
112
+ http = connection(uri)
113
+
114
+ klass = Net::HTTP.const_get(method.capitalize)
115
+ request = klass.new(uri)
116
+ headers.each { |k, v| request[k] = v }
117
+ request['User-Agent'] = @user_agent
118
+ request.body = body if body
119
+
120
+ response = http.request(request)
121
+ {
122
+ status: response.code.to_i,
123
+ body: HttpUtils.decode_json(response.body),
124
+ headers: response.to_hash.transform_values(&:first),
125
+ request: {
126
+ method: method,
127
+ path: path,
128
+ uri: uri.to_s,
129
+ headers: headers.merge('User-Agent' => @user_agent)
130
+ }
131
+ }
132
+ end
133
+
134
+ private
135
+
136
+ # Reuses a single keep-alive Net::HTTP connection across calls (instead of
137
+ # opening a fresh TCP+TLS connection per request) since every call on this
138
+ # transport targets the same host/port (derived from @base_url).
139
+ def connection(uri)
140
+ @mutex.synchronize do
141
+ if @http && !@http.started?
142
+ @http = nil
143
+ end
144
+
145
+ @http ||= begin
146
+ http = Net::HTTP.new(uri.host, uri.port)
147
+ http.use_ssl = uri.scheme == 'https'
148
+ http.open_timeout = 30
149
+ http.read_timeout = 30
150
+ http.start
151
+ http
152
+ end
153
+ end
154
+ end
155
+ end
156
+
157
+ class MockTransport
158
+ attr_reader :last_request
159
+
160
+ def initialize(responses)
161
+ @responses = responses
162
+ @index = 0
163
+ @last_request = nil
164
+ end
165
+
166
+ def call(method:, path:, headers:, body:, query:)
167
+ @last_request = { method: method, path: path, headers: headers, body: body, query: query }
168
+ response = @responses[@index] || { status: 200, body: {} }
169
+ @index += 1
170
+ response.merge(request: @last_request)
171
+ end
172
+ end
173
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'securerandom'
4
+ require 'json'
5
+
6
+ module Wajub
7
+ module HttpUtils
8
+ module_function
9
+
10
+ def normalize_api_key(raw)
11
+ key = raw.to_s.strip
12
+ key = key.sub(/\Abearer\s+/i, '').strip if key.match?(/\Abearer\s+/i)
13
+ key
14
+ end
15
+
16
+ def api_url
17
+ API_URL
18
+ end
19
+
20
+ def create_idempotency_key(prefix = 'wajub')
21
+ "#{prefix}-#{SecureRandom.uuid}"
22
+ end
23
+
24
+ def pick_resource(body, *keys)
25
+ keys.each do |key|
26
+ return body[key] if body.key?(key) && !body[key].nil?
27
+ end
28
+ body
29
+ end
30
+
31
+ def pick_list(body, *keys)
32
+ keys.each do |key|
33
+ if body.key?(key) && !body[key].nil?
34
+ return { data: body[key], meta: body['meta'] }
35
+ end
36
+ end
37
+ if body.key?('items') && !body['items'].nil?
38
+ return { data: body['items'], meta: body['meta'] }
39
+ end
40
+ { data: body['data'] || [], meta: body['meta'] }
41
+ end
42
+
43
+ def decode_json(data)
44
+ JSON.parse(data)
45
+ rescue JSON::ParserError
46
+ {}
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wajub
4
+ module RetryPolicy
5
+ MAX_RETRIES = 2
6
+ RETRYABLE = [429, 500, 502, 503, 504].freeze
7
+
8
+ module_function
9
+
10
+ def should_retry_status?(status)
11
+ RETRYABLE.include?(status)
12
+ end
13
+
14
+ def max_retries
15
+ MAX_RETRIES
16
+ end
17
+
18
+ def delay_seconds(attempt, response: nil)
19
+ header = response&.[]('retry-after')&.strip
20
+ if header && !header.empty?
21
+ seconds = Integer(header, exception: false)
22
+ return seconds if seconds
23
+
24
+ time = Time.httpdate(header) rescue nil
25
+ return [(time - Time.now).to_i, 0].max if time
26
+ end
27
+
28
+ 0.5 * (2**(attempt - 1))
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wajub
4
+ class ApiObject
5
+ def initialize(data)
6
+ @data = data.transform_keys(&:to_s)
7
+ end
8
+
9
+ def [](key)
10
+ @data[key.to_s]
11
+ end
12
+
13
+ def to_h
14
+ @data.dup
15
+ end
16
+
17
+ def method_missing(name, *args)
18
+ return super if args.any? || name.end_with?('=')
19
+
20
+ key = name.to_s
21
+ return @data[key] if @data.key?(key)
22
+
23
+ super
24
+ end
25
+
26
+ def respond_to_missing?(name, include_private = false)
27
+ @data.key?(name.to_s) || super
28
+ end
29
+ end
30
+
31
+ class Payment < ApiObject
32
+ def id
33
+ self['id']
34
+ end
35
+
36
+ def status
37
+ self['status']
38
+ end
39
+
40
+ def authorization_url
41
+ self['authorization_url']
42
+ end
43
+
44
+ def authorization_token
45
+ self['authorization_token']
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wajub
4
+ class PagedResult
5
+ attr_reader :data, :meta, :has_more
6
+
7
+ def initialize(data:, meta:, has_more:, fetch_page:)
8
+ @data = data
9
+ @meta = meta
10
+ @has_more = has_more
11
+ @fetch_page = fetch_page
12
+ end
13
+
14
+ def next_page
15
+ raise WajubError.new('no more pages available', code: 'pagination_error', http_status: 0) unless @has_more
16
+
17
+ current = @meta&.fetch('current_page', 1).to_i
18
+ @fetch_page.call(current + 1)
19
+ end
20
+
21
+ def auto_paging_each(&block)
22
+ page = self
23
+ loop do
24
+ page.data.each(&block)
25
+ break unless page.has_more
26
+
27
+ page = page.next_page
28
+ end
29
+ end
30
+
31
+ def self.create(client, path, plural_key, params = nil)
32
+ params ||= {}
33
+ fetch = lambda do |page_num|
34
+ query = params.merge('page' => page_num)
35
+ res = client.get(path, query)
36
+ list = HttpUtils.pick_list(res, plural_key)
37
+ meta = list[:meta]
38
+ has_more = false
39
+ if meta
40
+ current = meta['current_page'].to_i
41
+ last = meta['last_page'].to_i
42
+ has_more = current < last if current.positive? && last.positive?
43
+ end
44
+ new(data: list[:data], meta: meta, has_more: has_more, fetch_page: fetch)
45
+ end
46
+
47
+ page = params['page'] || params[:page] || 1
48
+ fetch.call(page.to_i)
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wajub
4
+ class RequestOptions
5
+ attr_reader :idempotency_key, :headers, :sync
6
+
7
+ def initialize(idempotency_key: nil, headers: nil, sync: nil)
8
+ @idempotency_key = idempotency_key
9
+ @headers = headers || {}
10
+ @sync = sync
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,282 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'uri'
4
+
5
+ require_relative 'http/base_client'
6
+ require_relative 'http/http_utils'
7
+ require_relative 'pagination'
8
+
9
+ module Wajub
10
+ class CrudResource < BaseClient
11
+ def initialize(api_key, base_url, path, singular, plural, idempotency_key_prefix = 'wajub', transport: nil)
12
+ super(api_key, base_url, idempotency_key_prefix, transport: transport)
13
+ @path = path
14
+ @singular = singular
15
+ @plural = plural
16
+ end
17
+
18
+ def create(params, options = nil)
19
+ HttpUtils.pick_resource(post("/#{@path}", params, options), @singular)
20
+ end
21
+
22
+ def retrieve(id)
23
+ HttpUtils.pick_resource(get("/#{@path}/#{URI.encode_www_form_component(id)}"), @singular)
24
+ end
25
+
26
+ def list(params = nil)
27
+ PagedResult.create(self, "/#{@path}", @plural, params)
28
+ end
29
+
30
+ def update(id, params, options = nil)
31
+ HttpUtils.pick_resource(put("/#{@path}/#{URI.encode_www_form_component(id)}", params, options), @singular)
32
+ end
33
+
34
+ def delete(id, options = nil)
35
+ request('DELETE', "/#{@path}/#{URI.encode_www_form_component(id)}", options: options)
36
+ end
37
+ end
38
+
39
+ class PaymentsResource < BaseClient
40
+ def create(params, options = nil)
41
+ to_payment_object(post('/payments', params, options))
42
+ end
43
+
44
+ def initialize_payment(params, options = nil)
45
+ create(params, options)
46
+ end
47
+
48
+ def retrieve(id)
49
+ res = get("/payments/#{URI.encode_www_form_component(id)}")
50
+ to_payment_object('transaction' => HttpUtils.pick_resource(res, 'transaction'))
51
+ end
52
+
53
+ def list(params = nil)
54
+ PagedResult.create(self, '/payments', 'transactions', params)
55
+ end
56
+
57
+ def cancel(id, options = nil)
58
+ res = delete("/payments/#{URI.encode_www_form_component(id)}", options)
59
+ to_payment_object('transaction' => HttpUtils.pick_resource(res, 'transaction'))
60
+ end
61
+
62
+ def process(id, params, options = nil)
63
+ res = post("/payments/#{URI.encode_www_form_component(id)}", params, options)
64
+ to_payment_object('transaction' => HttpUtils.pick_resource(res, 'transaction'))
65
+ end
66
+
67
+ def process_split(id, params, options = nil)
68
+ res = post("/payments/#{URI.encode_www_form_component(id)}/splits", params, options)
69
+ to_payment_object('transaction' => HttpUtils.pick_resource(res, 'transaction'))
70
+ end
71
+
72
+ def list_refunds(id, params = nil)
73
+ list = HttpUtils.pick_list(get("/payments/#{URI.encode_www_form_component(id)}/refunds", params), 'refunds')
74
+ [list[:data], list[:meta]]
75
+ end
76
+
77
+ private
78
+
79
+ def to_payment_object(body)
80
+ transaction = HttpUtils.pick_resource(body, 'transaction')
81
+ Payment.new(
82
+ transaction.merge(
83
+ 'authorization_token' => body['authorization_token'] || transaction['authorization_token'] || transaction['id'],
84
+ 'authorization_url' => body['authorization_url'] || transaction['authorization_url'] || ''
85
+ )
86
+ )
87
+ end
88
+ end
89
+
90
+ class GlobalResource < BaseClient
91
+ def ping
92
+ get('/')
93
+ end
94
+
95
+ def channels(params = nil)
96
+ list = HttpUtils.pick_list(get('/channels', params), 'channels')
97
+ [list[:data], list[:meta]]
98
+ end
99
+
100
+ def countries(params = nil)
101
+ list = HttpUtils.pick_list(get('/countries', params), 'countries')
102
+ [list[:data], list[:meta]]
103
+ end
104
+
105
+ def currencies(params = nil)
106
+ list = HttpUtils.pick_list(get('/currencies', params), 'currencies')
107
+ [list[:data], list[:meta]]
108
+ end
109
+ end
110
+
111
+ class BalanceResource < BaseClient
112
+ def retrieve
113
+ HttpUtils.pick_resource(get('/balance'), 'balance', 'data')
114
+ end
115
+ end
116
+
117
+ class EventsResource < BaseClient
118
+ def list(params = nil)
119
+ PagedResult.create(self, '/events', 'events', params)
120
+ end
121
+
122
+ def retrieve(id)
123
+ HttpUtils.pick_resource(get("/events/#{URI.encode_www_form_component(id)}"), 'event')
124
+ end
125
+
126
+ def resend(id, options = nil)
127
+ HttpUtils.pick_resource(post("/events/#{URI.encode_www_form_component(id)}/resend", nil, options), 'event')
128
+ end
129
+ end
130
+
131
+ class CustomersResource < CrudResource
132
+ def initialize(api_key, base_url, idempotency_key_prefix = 'wajub', transport: nil)
133
+ super(api_key, base_url, 'customers', 'customer', 'customers', idempotency_key_prefix, transport: transport)
134
+ end
135
+
136
+ def block(id, params = nil, options = nil)
137
+ HttpUtils.pick_resource(post("/customers/#{URI.encode_www_form_component(id)}/block", params, options), 'customer')
138
+ end
139
+
140
+ def unblock(id, options = nil)
141
+ HttpUtils.pick_resource(post("/customers/#{URI.encode_www_form_component(id)}/unblock", nil, options), 'customer')
142
+ end
143
+
144
+ def activate(id, options = nil)
145
+ HttpUtils.pick_resource(post("/customers/#{URI.encode_www_form_component(id)}/activate", nil, options), 'customer')
146
+ end
147
+
148
+ def deactivate(id, options = nil)
149
+ HttpUtils.pick_resource(post("/customers/#{URI.encode_www_form_component(id)}/deactivate", nil, options), 'customer')
150
+ end
151
+
152
+ def list_tax_ids(customer_id)
153
+ list = HttpUtils.pick_list(get("/customers/#{URI.encode_www_form_component(customer_id)}/tax_ids"), 'tax_ids')
154
+ [list[:data], list[:meta]]
155
+ end
156
+
157
+ def create_tax_id(customer_id, params, options = nil)
158
+ HttpUtils.pick_resource(post("/customers/#{URI.encode_www_form_component(customer_id)}/tax_ids", params, options), 'tax_id')
159
+ end
160
+
161
+ def delete_tax_id(customer_id, tax_id, options = nil)
162
+ delete("/customers/#{URI.encode_www_form_component(customer_id)}/tax_ids/#{URI.encode_www_form_component(tax_id)}", options)
163
+ end
164
+ end
165
+
166
+ class AccountsResource < CrudResource
167
+ def initialize(api_key, base_url, idempotency_key_prefix = 'wajub', transport: nil)
168
+ super(api_key, base_url, 'accounts', 'account', 'accounts', idempotency_key_prefix, transport: transport)
169
+ end
170
+
171
+ def regenerate_token(id, options = nil)
172
+ HttpUtils.pick_resource(post("/accounts/#{URI.encode_www_form_component(id)}/token", nil, options), 'account')
173
+ end
174
+ end
175
+
176
+ class WebhookEndpointsResource < CrudResource
177
+ def initialize(api_key, base_url, idempotency_key_prefix = 'wajub', transport: nil)
178
+ super(api_key, base_url, 'webhooks', 'endpoint', 'endpoints', idempotency_key_prefix, transport: transport)
179
+ end
180
+
181
+ def rotate_secret(id, options = nil)
182
+ HttpUtils.pick_resource(post("/webhooks/#{URI.encode_www_form_component(id)}/rotate-secret", nil, options), 'endpoint')
183
+ end
184
+ end
185
+
186
+ class InvoicesResource < CrudResource
187
+ def initialize(api_key, base_url, idempotency_key_prefix = 'wajub', transport: nil)
188
+ super(api_key, base_url, 'invoices', 'invoice', 'invoices', idempotency_key_prefix, transport: transport)
189
+ end
190
+
191
+ def send(id, options = nil)
192
+ HttpUtils.pick_resource(post("/invoices/#{URI.encode_www_form_component(id)}/send", nil, options), 'invoice')
193
+ end
194
+
195
+ def mark_paid(id, params = nil, options = nil)
196
+ HttpUtils.pick_resource(post("/invoices/#{URI.encode_www_form_component(id)}/mark-paid", params, options), 'invoice')
197
+ end
198
+
199
+ def cancel(id, options = nil)
200
+ HttpUtils.pick_resource(post("/invoices/#{URI.encode_www_form_component(id)}/cancel", nil, options), 'invoice')
201
+ end
202
+ end
203
+
204
+ class DisputesResource < BaseClient
205
+ def list(params = nil)
206
+ PagedResult.create(self, '/disputes', 'disputes', params)
207
+ end
208
+
209
+ def retrieve(id)
210
+ HttpUtils.pick_resource(get("/disputes/#{URI.encode_www_form_component(id)}"), 'dispute')
211
+ end
212
+
213
+ def submit_evidence(id, params, options = nil)
214
+ HttpUtils.pick_resource(post("/disputes/#{URI.encode_www_form_component(id)}/submit-evidence", params, options), 'dispute')
215
+ end
216
+
217
+ def accept(id, options = nil)
218
+ HttpUtils.pick_resource(post("/disputes/#{URI.encode_www_form_component(id)}/accept", nil, options), 'dispute')
219
+ end
220
+
221
+ def close(id, options = nil)
222
+ HttpUtils.pick_resource(post("/disputes/#{URI.encode_www_form_component(id)}/close", nil, options), 'dispute')
223
+ end
224
+
225
+ def send_message(id, params, options = nil)
226
+ HttpUtils.pick_resource(post("/disputes/#{URI.encode_www_form_component(id)}/messages", params, options), 'dispute')
227
+ end
228
+ end
229
+
230
+ class IdentityResource < BaseClient
231
+ def resolve(params, options = nil)
232
+ HttpUtils.pick_resource(post('/identity/resolve', params, options), 'identity')
233
+ end
234
+
235
+ def validate(params, options = nil)
236
+ HttpUtils.pick_resource(post('/identity/validate', params, options), 'identity')
237
+ end
238
+ end
239
+
240
+ class TaxResource < BaseClient
241
+ def get_settings
242
+ HttpUtils.pick_resource(get('/tax/settings'), 'tax', 'settings')
243
+ end
244
+
245
+ def update_settings(params, options = nil)
246
+ HttpUtils.pick_resource(put('/tax/settings', params, options), 'tax', 'settings')
247
+ end
248
+
249
+ def rates(params = nil)
250
+ list = HttpUtils.pick_list(get('/tax/rates', params), 'rates', 'tax_rates')
251
+ [list[:data], list[:meta]]
252
+ end
253
+
254
+ def calculate(params, options = nil)
255
+ HttpUtils.pick_resource(post('/tax/calculate', params, options), 'tax', 'calculation')
256
+ end
257
+ end
258
+
259
+ class ShieldResource < BaseClient
260
+ def get_settings
261
+ HttpUtils.pick_resource(get('/shield/settings'), 'shield', 'settings')
262
+ end
263
+
264
+ def update_settings(params, options = nil)
265
+ HttpUtils.pick_resource(put('/shield/settings', params, options), 'shield', 'settings')
266
+ end
267
+
268
+ def stats(params = nil)
269
+ HttpUtils.pick_resource(get('/shield/stats', params), 'shield', 'stats')
270
+ end
271
+ end
272
+
273
+ class ListenResource < BaseClient
274
+ def config
275
+ HttpUtils.pick_resource(get('/listen/config'), 'config', 'listen')
276
+ end
277
+
278
+ def auth(params, options = nil)
279
+ HttpUtils.pick_resource(post('/listen/auth', params, options), 'auth', 'listen')
280
+ end
281
+ end
282
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wajub
4
+ VERSION = '1.1.0'
5
+ API_URL = 'https://api.wajub.com'
6
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ require_relative 'crypto'
6
+ require_relative 'errors'
7
+
8
+ module Wajub
9
+ class Webhooks
10
+ DEFAULT_TOLERANCE = 300
11
+
12
+ attr_reader :secret
13
+
14
+ def initialize(secret)
15
+ @secret = secret.to_s
16
+ end
17
+
18
+ def construct_event(payload, signature, timestamp, tolerance = DEFAULT_TOLERANCE)
19
+ raise ArgumentError, 'Wajub: webhook_secret is required for construct_event' if @secret.empty?
20
+ raise WebhookSignatureVerificationError, 'Invalid webhook signature format. Expected v1={hash}.' unless signature.start_with?('v1=')
21
+ raise ArgumentError, 'Wajub: payload must be the raw request body as a String, not a parsed object' unless payload.is_a?(String)
22
+
23
+ body = payload
24
+ expected = Crypto.hmac_sha256(@secret, "#{timestamp}.#{body}")
25
+ received = signature.delete_prefix('v1=')
26
+
27
+ raise WebhookSignatureVerificationError, 'Webhook signature verification failed.' unless Crypto.timing_safe_equal(expected, received)
28
+
29
+ ts = Float(timestamp)
30
+ raise WebhookSignatureVerificationError, 'Invalid webhook timestamp.' unless ts.finite?
31
+
32
+ drift = (Time.now.to_i - ts).abs
33
+ if tolerance.positive? && drift > tolerance
34
+ raise WebhookSignatureVerificationError,
35
+ "Timestamp outside tolerance zone (#{drift.to_i}s drift, allowed #{tolerance}s)."
36
+ end
37
+
38
+ event = JSON.parse(body)
39
+ raise WebhookSignatureVerificationError, 'Invalid webhook payload JSON.' unless event.is_a?(Hash)
40
+
41
+ event
42
+ end
43
+ end
44
+ end
data/lib/wajub.rb ADDED
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'wajub/version'
4
+ require_relative 'wajub/errors'
5
+ require_relative 'wajub/request_options'
6
+ require_relative 'wajub/objects'
7
+ require_relative 'wajub/client'
8
+ require_relative 'wajub/webhooks'
9
+
10
+ module Wajub
11
+ end
metadata ADDED
@@ -0,0 +1,57 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: wajub
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Wajub
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies: []
12
+ description: Server-side Wajub payments, webhooks, and merchant API client.
13
+ email:
14
+ - hello@wajub.com
15
+ executables: []
16
+ extensions: []
17
+ extra_rdoc_files: []
18
+ files:
19
+ - LICENSE
20
+ - README.md
21
+ - lib/wajub.rb
22
+ - lib/wajub/client.rb
23
+ - lib/wajub/crypto.rb
24
+ - lib/wajub/errors.rb
25
+ - lib/wajub/http/base_client.rb
26
+ - lib/wajub/http/http_utils.rb
27
+ - lib/wajub/http/retry_policy.rb
28
+ - lib/wajub/objects.rb
29
+ - lib/wajub/pagination.rb
30
+ - lib/wajub/request_options.rb
31
+ - lib/wajub/resources.rb
32
+ - lib/wajub/version.rb
33
+ - lib/wajub/webhooks.rb
34
+ homepage: https://docs.wajub.com/libraries/sdks/ruby
35
+ licenses:
36
+ - MIT
37
+ metadata:
38
+ homepage_uri: https://docs.wajub.com/libraries/sdks/ruby
39
+ source_code_uri: https://github.com/wajubhq/wajub-ruby
40
+ rdoc_options: []
41
+ require_paths:
42
+ - lib
43
+ required_ruby_version: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '3.1'
48
+ required_rubygems_version: !ruby/object:Gem::Requirement
49
+ requirements:
50
+ - - ">="
51
+ - !ruby/object:Gem::Version
52
+ version: '0'
53
+ requirements: []
54
+ rubygems_version: 4.0.17
55
+ specification_version: 4
56
+ summary: Official Wajub Ruby SDK
57
+ test_files: []