paymos 1.0.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: dd8a703e227987af0bfca337eef97398f776b82fd748b54af3ea9e2fd1d64a77
4
+ data.tar.gz: b5111762119eff9b677ecbf77d03425a8cf9d2252823570829d88fbf7f5f1b1f
5
+ SHA512:
6
+ metadata.gz: 6d42452233f39604cffc3ff1796f1c42b30b88a1f15c63366a2bac24f5d31900b898aa47b366f3aa8195493f711b9c55ede75c894d79b5e72348fd5f33495e88
7
+ data.tar.gz: 0b199b07cc0334255d66af452df50641c43c8b644e8eeac9fc8f7fee749b30f467f7159e03fb3d00f4ff297d1f54c33acb96a15770c3b10b5965a07ba56c1b19
data/CHANGELOG.md ADDED
@@ -0,0 +1,6 @@
1
+ # Changelog
2
+
3
+ ## 1.0.0 - 2026-07-11
4
+
5
+ - Initial official Paymos Ruby SDK.
6
+ - Covers invoices, withdrawals, balances, system time, cursor pagination, and webhooks.
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Paymos
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,48 @@
1
+ # Paymos Ruby SDK
2
+
3
+ Official Ruby client for the Paymos Merchant API. Its only runtime dependency is
4
+ Ruby's official `base64` gem (required separately by modern Ruby versions).
5
+
6
+ ```bash
7
+ gem install paymos
8
+ ```
9
+
10
+ ```ruby
11
+ require "paymos"
12
+
13
+ paymos = Paymos::Client.new(
14
+ api_key: ENV.fetch("PAYMOS_API_KEY"),
15
+ api_secret: ENV.fetch("PAYMOS_API_SECRET")
16
+ )
17
+
18
+ invoice = paymos.invoices.create(
19
+ project_id: "prj_...",
20
+ amount: "10.00",
21
+ currency: "USD",
22
+ external_order_id: "order_123"
23
+ )
24
+ paymos.invoices.each(status: [Paymos::InvoiceStatus::PAID]).each do |item|
25
+ puts item.invoice_id
26
+ end
27
+ balances = paymos.balances.get
28
+ ```
29
+
30
+ Responses are immutable Ruby objects with documented RBS signatures; request
31
+ keywords remain idiomatic `snake_case` and are converted directly to the Paymos
32
+ wire contract.
33
+
34
+ API failures raise typed `Paymos::ApiError` subclasses and preserve the status,
35
+ problem fields, headers, response body, and `Retry-After`. Cursor helpers are
36
+ lazy, bounded, and reject a repeated cursor.
37
+
38
+ Never expose the API secret in a browser or mobile application. Verify webhook
39
+ signatures against the exact raw request body before parsing JSON.
40
+
41
+ ```ruby
42
+ event = Paymos::WebhookVerifier.new(ENV.fetch("PAYMOS_WEBHOOK_SECRET"))
43
+ .construct_event(request.env.fetch("HTTP_PAYMOS_SIGNATURE"), request.body.read)
44
+ ```
45
+
46
+ Ruby 3.1 or newer is supported. See `conformance/contract.json` for the shared
47
+ cross-language protocol contract and https://paymos.io/docs/server-sdks for the
48
+ full API guide.
@@ -0,0 +1,313 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'net/http'
5
+ require 'time'
6
+ require 'timeout'
7
+ require 'uri'
8
+
9
+ module Paymos
10
+ Response = Struct.new(:status, :body, :headers, keyword_init: true) do
11
+ def initialize(status:, body:, headers: {})
12
+ super(status: Integer(status), body: String(body), headers: headers.to_h.transform_values do |value|
13
+ String(value)
14
+ end.freeze)
15
+ freeze
16
+ end
17
+ end
18
+
19
+ class Client
20
+ SAFE_METHODS = %w[GET HEAD OPTIONS].freeze
21
+
22
+ attr_reader :invoices, :withdrawals, :balances, :system
23
+
24
+ def initialize(api_key:, api_secret:, base_url: 'https://api.paymos.io', timeout: 30,
25
+ max_retries: 2, base_delay: 0.150, transport: nil,
26
+ clock: -> { Time.now.to_i }, sleeper: ->(seconds) { sleep(seconds) }, random: Random.new)
27
+ raise ConfigurationError, 'api_key and api_secret are required' if api_key.to_s.empty? || api_secret.to_s.empty?
28
+ unless timeout.positive? && max_retries >= 0 && base_delay >= 0
29
+ raise ConfigurationError,
30
+ 'timeout, max_retries, and base_delay are invalid'
31
+ end
32
+ raise ConfigurationError, 'base_url is required' if base_url.to_s.strip.empty?
33
+
34
+ @api_key = api_key
35
+ @api_secret = api_secret
36
+ origin = URI.parse(base_url)
37
+ unless origin.absolute? && origin.host && [nil, '',
38
+ '/'].include?(origin.path) && origin.query.nil? && origin.fragment.nil?
39
+ raise ConfigurationError, 'base_url must be an absolute origin without a path'
40
+ end
41
+
42
+ @base_url = base_url.delete_suffix('/')
43
+ @timeout = timeout
44
+ @max_retries = max_retries
45
+ @base_delay = base_delay
46
+ @transport = transport || method(:default_transport)
47
+ @clock = clock
48
+ @sleeper = sleeper
49
+ @random = random
50
+ @invoices = Invoices.new(self)
51
+ @withdrawals = Withdrawals.new(self)
52
+ @balances = Balances.new(self)
53
+ @system = System.new(self)
54
+ end
55
+
56
+ def request(method, path, payload: nil, query: '')
57
+ method = method.to_s.upcase
58
+ body = payload.nil? ? '' : JSON.generate(payload)
59
+ attempts = 0
60
+
61
+ loop do
62
+ begin
63
+ timestamp = @clock.call.to_i.to_s
64
+ headers = {
65
+ 'Authorization' => Signing.authorization(@api_key, @api_secret, timestamp, method, path, query, body),
66
+ 'X-Request-Timestamp' => timestamp,
67
+ 'Content-Type' => 'application/json',
68
+ 'Accept' => 'application/json',
69
+ 'User-Agent' => "paymos-ruby/#{VERSION}"
70
+ }
71
+ response = @transport.call(method, "#{@base_url}#{path}#{query}", headers, body.empty? ? nil : body, @timeout)
72
+ rescue IOError, SystemCallError, Timeout::Error, SocketError => e
73
+ if attempts < @max_retries && SAFE_METHODS.include?(method)
74
+ wait(attempts, nil)
75
+ attempts += 1
76
+ next
77
+ end
78
+ raise Error, "Paymos request failed: #{e.message}", cause: e
79
+ end
80
+
81
+ unless response.respond_to?(:status) && response.respond_to?(:body) && response.respond_to?(:headers)
82
+ raise Error, 'Paymos transport returned an invalid response'
83
+ end
84
+
85
+ if attempts < @max_retries && retryable?(method, response.status)
86
+ wait(attempts, header(response.headers, 'retry-after'))
87
+ attempts += 1
88
+ next
89
+ end
90
+
91
+ unless (200..299).cover?(response.status)
92
+ raise Paymos.api_error(response.status, response.body,
93
+ response.headers)
94
+ end
95
+ raise Error, 'Paymos API returned an empty response' if response.body.to_s.empty?
96
+
97
+ begin
98
+ return JSON.parse(response.body)
99
+ rescue JSON::ParserError => e
100
+ raise Error, "Paymos API returned invalid JSON: #{e.message}"
101
+ end
102
+ end
103
+ end
104
+
105
+ private
106
+
107
+ def default_transport(method, url, headers, body, timeout)
108
+ uri = URI(url)
109
+ request_type = Net::HTTP.const_get(method.capitalize)
110
+ request = request_type.new(uri)
111
+ headers.each { |key, value| request[key] = value }
112
+ request.body = body unless body.nil?
113
+ response = Net::HTTP.start(
114
+ uri.host,
115
+ uri.port,
116
+ use_ssl: uri.scheme == 'https',
117
+ open_timeout: timeout,
118
+ read_timeout: timeout
119
+ ) { |http| http.request(request) }
120
+ Response.new(status: response.code.to_i, body: response.body.to_s,
121
+ headers: response.to_hash.transform_values(&:first))
122
+ end
123
+
124
+ def retryable?(method, status)
125
+ status == 429 || (status >= 500 && SAFE_METHODS.include?(method))
126
+ end
127
+
128
+ def wait(attempt, retry_after)
129
+ delay = (@base_delay * (2**attempt)) + (@random.rand * @base_delay)
130
+ parsed_retry_after = retry_after_seconds(retry_after)
131
+ delay = [delay, parsed_retry_after].max unless parsed_retry_after.nil?
132
+ @sleeper.call(delay)
133
+ end
134
+
135
+ def retry_after_seconds(value)
136
+ return nil if value.to_s.empty?
137
+ return value.to_i if value.to_s.match?(/\A\d+\z/)
138
+
139
+ [Time.httpdate(value.to_s).to_i - @clock.call.to_i, 0].max
140
+ rescue ArgumentError
141
+ nil
142
+ end
143
+
144
+ def header(headers, name)
145
+ pair = headers.to_h.find { |key, _value| key.to_s.casecmp?(name) }
146
+ pair&.last
147
+ end
148
+ end
149
+
150
+ class Resource
151
+ def initialize(client)
152
+ @client = client
153
+ end
154
+
155
+ private
156
+
157
+ def segment(value)
158
+ raise ArgumentError, 'Resource ID is required' unless value.is_a?(String) && !value.strip.empty?
159
+
160
+ Signing.path_segment(value)
161
+ end
162
+
163
+ def validate_reason(reason)
164
+ unless reason.is_a?(String) && reason.length.between?(1, 500) && !reason.strip.empty?
165
+ raise ArgumentError, 'Cancellation reason must contain 1 to 500 characters'
166
+ end
167
+
168
+ reason
169
+ end
170
+
171
+ def validate_list_filters(filters)
172
+ limit = filters[:limit]
173
+ unless limit.nil? || (limit.is_a?(Integer) && limit.between?(
174
+ 1, 100
175
+ ))
176
+ raise ArgumentError,
177
+ 'limit must be between 1 and 100'
178
+ end
179
+ raise ArgumentError, 'cursor cannot be empty' if filters.key?(:cursor) && filters[:cursor].to_s.empty?
180
+
181
+ statuses = filters[:status]
182
+ if statuses.is_a?(Array) && (statuses.empty? || statuses.uniq.length != statuses.length)
183
+ raise ArgumentError, 'status must be non-empty and contain no duplicates'
184
+ end
185
+
186
+ from = filters[:created_from]
187
+ to = filters[:created_to]
188
+ unless [from, to].compact.all? { |value| value.is_a?(Integer) && value >= 0 }
189
+ raise ArgumentError, 'created_from and created_to must be non-negative Unix seconds'
190
+ end
191
+ raise ArgumentError, 'created_to must be later than created_from' if from && to && from >= to
192
+
193
+ filters
194
+ end
195
+ end
196
+
197
+ class Invoices < Resource
198
+ def create(project_id:, amount:, currency:, external_order_id:, network: nil,
199
+ allow_multiple_payments: nil, customer_fee_percent: nil, client_id: nil)
200
+ required_keywords(project_id:, amount:, currency:, external_order_id:)
201
+ if !customer_fee_percent.nil? && !(customer_fee_percent.is_a?(Integer) && customer_fee_percent.between?(0, 100))
202
+ raise ArgumentError, 'customer_fee_percent must be between 0 and 100'
203
+ end
204
+
205
+ payload = { project_id:, amount:, currency:, external_order_id:, network:,
206
+ allow_multiple_payments:, customer_fee_percent:, client_id: }.compact
207
+ Invoice.from(@client.request('POST', '/v1/invoices', payload:))
208
+ end
209
+
210
+ def get(invoice_id) = Invoice.from(@client.request('GET', "/v1/invoices/#{segment(invoice_id)}"))
211
+
212
+ def list(limit: nil, cursor: nil, status: nil, external_order_id: nil,
213
+ project_id: nil, created_from: nil, created_to: nil)
214
+ filters = validate_list_filters({ limit:, cursor:, status:, external_order_id:, project_id:, created_from:,
215
+ created_to: }.compact)
216
+ Page.from(@client.request('GET', '/v1/invoices', query: Signing.query(filters)), item_type: InvoiceListItem)
217
+ end
218
+
219
+ def cancel(invoice_id, reason:)
220
+ Invoice.from(@client.request('POST', "/v1/invoices/#{segment(invoice_id)}/cancel",
221
+ payload: { reason: validate_reason(reason) }))
222
+ end
223
+
224
+ def confirm_payment(invoice_id, currency:, network:)
225
+ required_keywords(currency:, network:)
226
+ Invoice.from(@client.request('POST', "/v1/invoices/#{segment(invoice_id)}/confirm-payment",
227
+ payload: { currency:, network: }))
228
+ end
229
+
230
+ def simulate_payment(invoice_id, stage:)
231
+ raise ArgumentError, 'Invalid simulation stage' unless %w[paid overpaid underpay cancel].include?(stage)
232
+
233
+ Invoice.from(@client.request('POST', "/v1/sandbox/invoices/#{segment(invoice_id)}/simulate-payment",
234
+ payload: { stage: }))
235
+ end
236
+
237
+ def each(max_pages: 100, **filters, &block)
238
+ return enum_for(__method__, max_pages: max_pages, **filters) unless block_given?
239
+
240
+ Pagination.each(method(:list), filters, max_pages:, &block)
241
+ end
242
+
243
+ private
244
+
245
+ def required_keywords(**values)
246
+ missing = values.select { |_name, value| !value.is_a?(String) || value.strip.empty? }.keys
247
+ raise ArgumentError, "Required values are missing: #{missing.join(', ')}" unless missing.empty?
248
+ end
249
+ end
250
+
251
+ class Withdrawals < Resource
252
+ def create(destination_address:, network:, currency:, amount:, external_order_id:)
253
+ values = { destination_address:, network:, currency:, amount:, external_order_id: }
254
+ missing = values.select { |_name, value| !value.is_a?(String) || value.strip.empty? }.keys
255
+ raise ArgumentError, "Required values are missing: #{missing.join(', ')}" unless missing.empty?
256
+
257
+ Withdrawal.from(@client.request('POST', '/v1/withdrawals', payload: values))
258
+ end
259
+
260
+ def get(withdrawal_id) = Withdrawal.from(@client.request('GET', "/v1/withdrawals/#{segment(withdrawal_id)}"))
261
+
262
+ def list(limit: nil, cursor: nil, status: nil, external_order_id: nil,
263
+ created_from: nil, created_to: nil)
264
+ filters = validate_list_filters({ limit:, cursor:, status:, external_order_id:, created_from:,
265
+ created_to: }.compact)
266
+ Page.from(@client.request('GET', '/v1/withdrawals', query: Signing.query(filters)), item_type: Withdrawal)
267
+ end
268
+
269
+ def cancel(withdrawal_id, reason:)
270
+ Withdrawal.from(@client.request('POST', "/v1/withdrawals/#{segment(withdrawal_id)}/cancel",
271
+ payload: { reason: validate_reason(reason) }))
272
+ end
273
+
274
+ def simulate_completion(withdrawal_id)
275
+ Withdrawal.from(@client.request('POST', "/v1/sandbox/withdrawals/#{segment(withdrawal_id)}/simulate-completion"))
276
+ end
277
+
278
+ def each(max_pages: 100, **filters, &block)
279
+ return enum_for(__method__, max_pages: max_pages, **filters) unless block_given?
280
+
281
+ Pagination.each(method(:list), filters, max_pages:, &block)
282
+ end
283
+ end
284
+
285
+ class Balances < Resource
286
+ def get = Array(@client.request('GET', '/v1/balances')).map { |item| Balance.from(item) }.freeze
287
+ end
288
+
289
+ class System < Resource
290
+ def time = ServerTime.from(@client.request('GET', '/v1/time'))
291
+ end
292
+
293
+ module Pagination
294
+ module_function
295
+
296
+ def each(fetch, filters, max_pages: 100, &block)
297
+ raise ArgumentError, 'max_pages must be a positive integer' unless max_pages.is_a?(Integer) && max_pages.positive?
298
+
299
+ filters = filters.dup
300
+ cursor = filters[:cursor]
301
+ max_pages.times do
302
+ page = fetch.call(**filters)
303
+ page.items.each(&block)
304
+ next_cursor = page.next_cursor
305
+ return if next_cursor.nil? || next_cursor.to_s.empty?
306
+ raise Error, 'Paymos API returned the same pagination cursor twice' if next_cursor == cursor
307
+
308
+ cursor = next_cursor
309
+ filters[:cursor] = cursor
310
+ end
311
+ end
312
+ end
313
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'time'
5
+
6
+ module Paymos
7
+ class Error < StandardError; end
8
+ class ConfigurationError < Error; end
9
+ class SignatureMismatchError < Error; end
10
+ class TimestampSkewError < Error; end
11
+
12
+ class ApiError < Error
13
+ attr_reader :status, :body, :headers, :problem
14
+
15
+ def initialize(status, body, headers = {})
16
+ @status = status
17
+ @body = body.to_s
18
+ @headers = headers.to_h.transform_keys { |key| key.to_s.downcase }
19
+ @problem = parse_problem(@body)
20
+ super("Paymos API #{status}: #{detail}")
21
+ end
22
+
23
+ def errors
24
+ value = problem.is_a?(Hash) ? problem['errors'] : nil
25
+ value.is_a?(Array) ? value : []
26
+ end
27
+
28
+ def code
29
+ first = errors.first
30
+ value = first.is_a?(Hash) ? first['code'] : problem&.fetch('code', nil)
31
+ value.to_s
32
+ end
33
+
34
+ def field
35
+ first = errors.first
36
+ first.is_a?(Hash) ? first['field'] : problem&.fetch('field', nil)
37
+ end
38
+
39
+ def detail
40
+ value = problem&.fetch('detail', nil)
41
+ first = errors.first
42
+ value ||= first['message'] if first.is_a?(Hash)
43
+ value ||= code unless code.empty?
44
+ value ||= problem&.fetch('title', nil)
45
+ value ||= body unless body.empty?
46
+ value || 'empty response'
47
+ end
48
+
49
+ def retry_after_seconds(now: Time.now)
50
+ value = headers['retry-after']
51
+ return nil if value.nil? || value.empty?
52
+ return value.to_i if value.match?(/\A\d+\z/)
53
+
54
+ [(Time.httpdate(value) - now).ceil, 0].max
55
+ rescue ArgumentError
56
+ nil
57
+ end
58
+
59
+ private
60
+
61
+ def parse_problem(value)
62
+ parsed = value.empty? ? nil : JSON.parse(value)
63
+ parsed.is_a?(Hash) ? parsed : nil
64
+ rescue JSON::ParserError
65
+ nil
66
+ end
67
+ end
68
+
69
+ class ValidationError < ApiError; end
70
+ class AuthenticationError < ApiError; end
71
+ class NotFoundError < ApiError; end
72
+ class ConflictError < ApiError; end
73
+ class GoneError < ApiError; end
74
+ class RateLimitError < ApiError; end
75
+ class ServerError < ApiError; end
76
+ class UnavailableError < ServerError; end
77
+
78
+ ERROR_TYPES = {
79
+ 400 => ValidationError,
80
+ 401 => AuthenticationError,
81
+ 403 => AuthenticationError,
82
+ 404 => NotFoundError,
83
+ 409 => ConflictError,
84
+ 410 => GoneError,
85
+ 429 => RateLimitError,
86
+ 503 => UnavailableError
87
+ }.freeze
88
+
89
+ def self.api_error(status, body, headers = {})
90
+ type = ERROR_TYPES.fetch(status, status >= 500 ? ServerError : ApiError)
91
+ type.new(status, body, headers)
92
+ end
93
+ end
@@ -0,0 +1,160 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Paymos
4
+ module InvoiceStatus
5
+ AWAITING_CLIENT = 'awaiting_client'
6
+ AWAITING_PAYMENT = 'awaiting_payment'
7
+ CONFIRMING = 'confirming'
8
+ UNDERPAID_WAITING = 'underpaid_waiting'
9
+ PAID = 'paid'
10
+ PAID_OVER = 'paid_over'
11
+ UNDERPAID = 'underpaid'
12
+ EXPIRED = 'expired'
13
+ CANCELLED = 'cancelled'
14
+ end
15
+
16
+ module WithdrawalStatus
17
+ CREATED = 'created'
18
+ PENDING_REVIEW = 'pending_review'
19
+ SIGNED = 'signed'
20
+ CANCELLING = 'cancelling'
21
+ COMPLETED = 'completed'
22
+ FAILED = 'failed'
23
+ CANCELLED = 'cancelled'
24
+ end
25
+
26
+ class Model
27
+ class << self
28
+ attr_reader :field_names, :required_names
29
+
30
+ def fields(*names, required: names)
31
+ @field_names = names.freeze
32
+ @required_names = required.freeze
33
+ attr_reader(*names)
34
+ end
35
+
36
+ def from(value)
37
+ raise Error, 'Paymos API response must be a JSON object' unless value.is_a?(Hash)
38
+
39
+ attributes = field_names.to_h { |name| [name, value[name.to_s]] }
40
+ missing = required_names.select { |name| attributes[name].nil? }
41
+ raise Error, "Paymos API response is missing: #{missing.join(', ')}" unless missing.empty?
42
+
43
+ new(**attributes)
44
+ end
45
+ end
46
+
47
+ def initialize(**attributes)
48
+ unknown = attributes.keys - self.class.field_names
49
+ raise ArgumentError, "Unknown model fields: #{unknown.join(', ')}" unless unknown.empty?
50
+
51
+ self.class.field_names.each do |name|
52
+ instance_variable_set("@#{name}", Model.deep_freeze(attributes[name]))
53
+ end
54
+ freeze
55
+ end
56
+
57
+ def [](name)
58
+ public_send(name.to_sym)
59
+ end
60
+
61
+ def to_h
62
+ self.class.field_names.to_h { |name| [name, public_send(name)] }
63
+ end
64
+
65
+ def self.deep_freeze(value)
66
+ case value
67
+ when Hash
68
+ value.transform_values { |item| deep_freeze(item) }.freeze
69
+ when Array
70
+ value.map { |item| deep_freeze(item) }.freeze
71
+ else
72
+ value.freeze
73
+ end
74
+ end
75
+ end
76
+
77
+ class Order < Model
78
+ fields :external_id, :client_id, :amount, :currency, :network,
79
+ required: %i[external_id amount currency]
80
+ end
81
+
82
+ class Transfer < Model
83
+ fields :tx_hash, :amount, :status, :created_at, :confirmed_at,
84
+ :required_confirmations, :estimated_confirmation_at, :explorer_url,
85
+ required: %i[tx_hash amount status created_at]
86
+ end
87
+
88
+ class Payment < Model
89
+ fields :currency, :network, :chain_id, :contract_address, :expected, :address,
90
+ :exchange_rate, :paid, :remaining, :fee, :net, :transfers,
91
+ required: %i[currency network chain_id expected]
92
+
93
+ def self.from(value)
94
+ model = super
95
+ transfers = model.transfers&.map { |item| Transfer.from(item) }
96
+ new(**model.to_h, transfers: transfers)
97
+ end
98
+ end
99
+
100
+ class Invoice < Model
101
+ fields :invoice_id, :project_id, :status, :is_final, :is_test, :payment_url,
102
+ :order, :payment, :created_at, :updated_at, :expires_at, :completed_at,
103
+ required: %i[invoice_id project_id status is_final is_test payment_url order created_at updated_at]
104
+
105
+ def self.from(value)
106
+ model = super
107
+ new(**model.to_h, order: Order.from(model.order), payment: model.payment && Payment.from(model.payment))
108
+ end
109
+ end
110
+
111
+ class InvoiceListItem < Model
112
+ fields :invoice_id, :project_id, :external_order_id, :client_id, :status,
113
+ :is_final, :is_test, :amount, :currency, :network, :created_at,
114
+ :expires_at, :completed_at,
115
+ required: %i[invoice_id project_id external_order_id status is_final is_test amount currency created_at]
116
+ end
117
+
118
+ class Withdrawal < Model
119
+ fields :withdrawal_id, :external_order_id, :status, :is_final, :is_test,
120
+ :amount, :fee, :currency, :network, :destination_address, :tx_hash,
121
+ :explorer_url, :created_at, :completed_at, :failed_at, :cancelled_at,
122
+ required: %i[withdrawal_id external_order_id status is_final is_test amount currency
123
+ network destination_address created_at]
124
+ end
125
+
126
+ class Balance < Model
127
+ fields :currency, :available
128
+ end
129
+
130
+ class ServerTime < Model
131
+ fields :server_time
132
+ end
133
+
134
+ class Page < Model
135
+ fields :items, :next_cursor, required: [:items]
136
+
137
+ def self.from(value, item_type:)
138
+ model = super(value)
139
+ raise Error, 'Paymos API page items must be an array' unless model.items.is_a?(Array)
140
+
141
+ new(items: model.items.map { |item| item_type.from(item) }, next_cursor: model.next_cursor)
142
+ end
143
+ end
144
+
145
+ class WebhookEvent < Model
146
+ fields :event_id, :event_type, :version, :occurred_at, :data
147
+
148
+ def self.from(value)
149
+ model = super
150
+ valid = model.event_id.is_a?(String) && !model.event_id.empty? &&
151
+ model.event_type.is_a?(String) && !model.event_type.empty? &&
152
+ model.version.is_a?(Integer) && model.version.positive? &&
153
+ model.occurred_at.is_a?(Integer) && model.occurred_at >= 0 &&
154
+ model.data.is_a?(Hash)
155
+ raise Error, 'Webhook event envelope is invalid' unless valid
156
+
157
+ model
158
+ end
159
+ end
160
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'base64'
4
+ require 'digest'
5
+ require 'openssl'
6
+
7
+ module Paymos
8
+ module Signing
9
+ module_function
10
+
11
+ def string_to_sign(timestamp, method, path, query = '', body = '')
12
+ body_hash = body.empty? ? '' : Digest::SHA256.hexdigest(body.encode(Encoding::UTF_8))
13
+ [timestamp, method.to_s.upcase, path, query, body_hash].join("\n")
14
+ end
15
+
16
+ def sign(secret, value)
17
+ Base64.strict_encode64(OpenSSL::HMAC.digest('SHA256', secret, value))
18
+ end
19
+
20
+ def authorization(api_key, api_secret, timestamp, method, path, query = '', body = '')
21
+ signature = sign(api_secret, string_to_sign(timestamp, method, path, query, body))
22
+ "HMAC-SHA256 #{api_key}:#{signature}"
23
+ end
24
+
25
+ def path_segment(value)
26
+ percent_encode(value)
27
+ end
28
+
29
+ def query(filters)
30
+ parts = filters.keys.sort_by(&:to_s).flat_map do |key|
31
+ raw = filters[key]
32
+ next [] if raw.nil?
33
+
34
+ values = raw.is_a?(Array) ? raw.sort_by(&:to_s) : [raw]
35
+ raise ArgumentError, "Paymos list filter cannot be empty: #{key}" if values.empty?
36
+
37
+ values.map do |value|
38
+ raise ArgumentError, "Invalid Paymos list filter: #{key}" unless value.is_a?(String) || value.is_a?(Integer)
39
+ raise ArgumentError, "Invalid Paymos list filter: #{key}" if value.to_s.empty?
40
+
41
+ "#{percent_encode(key)}=#{percent_encode(value)}"
42
+ end
43
+ end
44
+ parts.empty? ? '' : "?#{parts.join('&')}"
45
+ end
46
+
47
+ def percent_encode(value)
48
+ value.to_s.encode(Encoding::UTF_8).bytes.map do |byte|
49
+ character = byte.chr
50
+ character.match?(/[A-Za-z0-9._~-]/) ? character : format('%%%02X', byte)
51
+ end.join
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Paymos
4
+ VERSION = '1.0.0'
5
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'openssl'
5
+ require 'time'
6
+
7
+ module Paymos
8
+ class WebhookVerifier
9
+ def initialize(secret, tolerance: 300, clock: -> { Time.now.to_i })
10
+ unless secret.is_a?(String) && !secret.strip.empty?
11
+ raise ArgumentError,
12
+ 'Webhook secret must be a non-empty string'
13
+ end
14
+ raise ArgumentError, 'Webhook tolerance must be non-negative' unless tolerance.is_a?(Integer) && tolerance >= 0
15
+
16
+ @secret = secret
17
+ @tolerance = tolerance
18
+ @clock = clock
19
+ end
20
+
21
+ def verify(signature_header, raw_body, now: nil)
22
+ assert_valid(signature_header, raw_body, now: now)
23
+ true
24
+ rescue SignatureMismatchError, TimestampSkewError
25
+ false
26
+ end
27
+
28
+ def assert_valid(signature_header, raw_body, now: nil)
29
+ raise ArgumentError, 'Raw webhook body must be a string' unless raw_body.is_a?(String)
30
+
31
+ timestamp, signatures = parse_header(signature_header)
32
+ current = now.nil? ? @clock.call.to_i : now.to_i
33
+ if (current - timestamp).abs > @tolerance
34
+ raise TimestampSkewError,
35
+ 'Webhook timestamp is outside the allowed tolerance'
36
+ end
37
+
38
+ expected = OpenSSL::HMAC.hexdigest('SHA256', @secret, "#{timestamp}.#{raw_body}")
39
+ valid = signatures.any? { |value| secure_compare(expected, value.downcase) }
40
+ raise SignatureMismatchError, 'Webhook signature does not match payload' unless valid
41
+
42
+ nil
43
+ end
44
+
45
+ def construct_event(signature_header, raw_body, now: nil)
46
+ assert_valid(signature_header, raw_body, now: now)
47
+ WebhookEvent.from(JSON.parse(raw_body))
48
+ rescue JSON::ParserError => e
49
+ raise Error, "Webhook payload is invalid JSON: #{e.message}"
50
+ end
51
+
52
+ private
53
+
54
+ def parse_header(header)
55
+ timestamp = nil
56
+ signatures = []
57
+ header.to_s.split(',').each do |part|
58
+ key, value = part.strip.split('=', 2)
59
+ if key == 't'
60
+ unless timestamp.nil? && value&.match?(/\A\d+\z/)
61
+ raise SignatureMismatchError,
62
+ 'Webhook signature header is missing or malformed'
63
+ end
64
+
65
+ timestamp = value.to_i
66
+ end
67
+ signatures << value if key == 'v1' && !value.to_s.empty?
68
+ end
69
+ if timestamp.nil? || signatures.empty?
70
+ raise SignatureMismatchError,
71
+ 'Webhook signature header is missing or malformed'
72
+ end
73
+
74
+ [timestamp, signatures]
75
+ end
76
+
77
+ def secure_compare(expected, actual)
78
+ return false unless expected.bytesize == actual.bytesize
79
+
80
+ OpenSSL.fixed_length_secure_compare(expected, actual)
81
+ end
82
+ end
83
+ end
data/lib/paymos.rb ADDED
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'paymos/version'
4
+ require_relative 'paymos/errors'
5
+ require_relative 'paymos/models'
6
+ require_relative 'paymos/signing'
7
+ require_relative 'paymos/webhook_verifier'
8
+ require_relative 'paymos/client'
data/sig/paymos.rbs ADDED
@@ -0,0 +1,202 @@
1
+ module Paymos
2
+ VERSION: String
3
+
4
+ class Error < StandardError
5
+ end
6
+
7
+ class ConfigurationError < Error
8
+ end
9
+
10
+ class SignatureMismatchError < Error
11
+ end
12
+
13
+ class TimestampSkewError < Error
14
+ end
15
+
16
+ class ApiError < Error
17
+ attr_reader status: Integer
18
+ attr_reader body: String
19
+ attr_reader headers: Hash[String, String]
20
+ attr_reader problem: Hash[String, untyped]?
21
+
22
+ def initialize: (Integer status, String body, ?Hash[untyped, untyped] headers) -> void
23
+ def errors: () -> Array[Hash[String, untyped]]
24
+ def code: () -> String
25
+ def field: () -> untyped
26
+ def detail: () -> String
27
+ def retry_after_seconds: (?now: Time) -> Integer?
28
+ end
29
+
30
+ class ValidationError < ApiError
31
+ end
32
+ class AuthenticationError < ApiError
33
+ end
34
+ class NotFoundError < ApiError
35
+ end
36
+ class ConflictError < ApiError
37
+ end
38
+ class GoneError < ApiError
39
+ end
40
+ class RateLimitError < ApiError
41
+ end
42
+ class ServerError < ApiError
43
+ end
44
+ class UnavailableError < ServerError
45
+ end
46
+
47
+ class Model
48
+ def []: (String | Symbol name) -> untyped
49
+ def to_h: () -> Hash[Symbol, untyped]
50
+ end
51
+
52
+ class Order < Model
53
+ attr_reader external_id: String
54
+ attr_reader client_id: String?
55
+ attr_reader amount: String
56
+ attr_reader currency: String
57
+ attr_reader network: String?
58
+ end
59
+
60
+ class Transfer < Model
61
+ attr_reader tx_hash: String
62
+ attr_reader amount: String
63
+ attr_reader status: String
64
+ attr_reader created_at: Integer
65
+ attr_reader confirmed_at: Integer?
66
+ attr_reader required_confirmations: Integer?
67
+ attr_reader estimated_confirmation_at: Integer?
68
+ attr_reader explorer_url: String?
69
+ end
70
+
71
+ class Payment < Model
72
+ attr_reader currency: String
73
+ attr_reader network: String
74
+ attr_reader chain_id: Integer
75
+ attr_reader contract_address: String?
76
+ attr_reader expected: String
77
+ attr_reader address: String?
78
+ attr_reader exchange_rate: String?
79
+ attr_reader paid: String?
80
+ attr_reader remaining: String?
81
+ attr_reader fee: String?
82
+ attr_reader net: String?
83
+ attr_reader transfers: Array[Transfer]?
84
+ end
85
+
86
+ class Invoice < Model
87
+ attr_reader invoice_id: String
88
+ attr_reader project_id: String
89
+ attr_reader status: String
90
+ attr_reader is_final: bool
91
+ attr_reader is_test: bool
92
+ attr_reader payment_url: String
93
+ attr_reader order: Order
94
+ attr_reader payment: Payment?
95
+ attr_reader created_at: Integer
96
+ attr_reader updated_at: Integer
97
+ attr_reader expires_at: Integer?
98
+ attr_reader completed_at: Integer?
99
+ end
100
+
101
+ class InvoiceListItem < Model
102
+ attr_reader invoice_id: String
103
+ attr_reader project_id: String
104
+ attr_reader external_order_id: String
105
+ attr_reader client_id: String?
106
+ attr_reader status: String
107
+ attr_reader is_final: bool
108
+ attr_reader is_test: bool
109
+ attr_reader amount: String
110
+ attr_reader currency: String
111
+ attr_reader network: String?
112
+ attr_reader created_at: Integer
113
+ attr_reader expires_at: Integer?
114
+ attr_reader completed_at: Integer?
115
+ end
116
+
117
+ class Withdrawal < Model
118
+ attr_reader withdrawal_id: String
119
+ attr_reader external_order_id: String
120
+ attr_reader status: String
121
+ attr_reader is_final: bool
122
+ attr_reader is_test: bool
123
+ attr_reader amount: String
124
+ attr_reader fee: String?
125
+ attr_reader currency: String
126
+ attr_reader network: String
127
+ attr_reader destination_address: String
128
+ attr_reader tx_hash: String?
129
+ attr_reader explorer_url: String?
130
+ attr_reader created_at: Integer
131
+ attr_reader completed_at: Integer?
132
+ attr_reader failed_at: Integer?
133
+ attr_reader cancelled_at: Integer?
134
+ end
135
+
136
+ class Balance < Model
137
+ attr_reader currency: String
138
+ attr_reader available: String
139
+ end
140
+
141
+ class ServerTime < Model
142
+ attr_reader server_time: Integer
143
+ end
144
+
145
+ class Page[Item] < Model
146
+ attr_reader items: Array[Item]
147
+ attr_reader next_cursor: String?
148
+ end
149
+
150
+ class WebhookEvent[Data] < Model
151
+ attr_reader event_id: String
152
+ attr_reader event_type: String
153
+ attr_reader version: Integer
154
+ attr_reader occurred_at: Integer
155
+ attr_reader data: Data
156
+ end
157
+
158
+ class Invoices
159
+ def create: (project_id: String, amount: String, currency: String, external_order_id: String, ?network: String?, ?allow_multiple_payments: bool?, ?customer_fee_percent: Integer?, ?client_id: String?) -> Invoice
160
+ def get: (String invoice_id) -> Invoice
161
+ def list: (?limit: Integer?, ?cursor: String?, ?status: String | Array[String]?, ?external_order_id: String?, ?project_id: String?, ?created_from: Integer?, ?created_to: Integer?) -> Page[InvoiceListItem]
162
+ def cancel: (String invoice_id, reason: String) -> Invoice
163
+ def confirm_payment: (String invoice_id, currency: String, network: String) -> Invoice
164
+ def simulate_payment: (String invoice_id, stage: String) -> Invoice
165
+ def each: (?max_pages: Integer, **untyped filters) { (InvoiceListItem) -> void } -> void
166
+ | (?max_pages: Integer, **untyped filters) -> Enumerator[InvoiceListItem, void]
167
+ end
168
+
169
+ class Withdrawals
170
+ def create: (destination_address: String, network: String, currency: String, amount: String, external_order_id: String) -> Withdrawal
171
+ def get: (String withdrawal_id) -> Withdrawal
172
+ def list: (?limit: Integer?, ?cursor: String?, ?status: String | Array[String]?, ?external_order_id: String?, ?created_from: Integer?, ?created_to: Integer?) -> Page[Withdrawal]
173
+ def cancel: (String withdrawal_id, reason: String) -> Withdrawal
174
+ def simulate_completion: (String withdrawal_id) -> Withdrawal
175
+ def each: (?max_pages: Integer, **untyped filters) { (Withdrawal) -> void } -> void
176
+ | (?max_pages: Integer, **untyped filters) -> Enumerator[Withdrawal, void]
177
+ end
178
+
179
+ class Balances
180
+ def get: () -> Array[Balance]
181
+ end
182
+
183
+ class System
184
+ def time: () -> ServerTime
185
+ end
186
+
187
+ class Client
188
+ attr_reader invoices: Invoices
189
+ attr_reader withdrawals: Withdrawals
190
+ attr_reader balances: Balances
191
+ attr_reader system: System
192
+
193
+ def initialize: (api_key: String, api_secret: String, ?base_url: String, ?timeout: Numeric, ?max_retries: Integer, ?base_delay: Numeric, ?transport: untyped, ?clock: untyped, ?sleeper: untyped, ?random: Random) -> void
194
+ end
195
+
196
+ class WebhookVerifier
197
+ def initialize: (String secret, ?tolerance: Integer, ?clock: untyped) -> void
198
+ def verify: (String signature_header, String raw_body, ?now: Integer?) -> bool
199
+ def assert_valid: (String signature_header, String raw_body, ?now: Integer?) -> void
200
+ def construct_event: (String signature_header, String raw_body, ?now: Integer?) -> WebhookEvent[Hash[String, untyped]]
201
+ end
202
+ end
metadata ADDED
@@ -0,0 +1,70 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: paymos
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Paymos Labs
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: base64
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '0.3'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '0.3'
26
+ description: HMAC-authenticated client for invoices, withdrawals, balances, and webhooks.
27
+ email:
28
+ - support@paymos.io
29
+ executables: []
30
+ extensions: []
31
+ extra_rdoc_files: []
32
+ files:
33
+ - CHANGELOG.md
34
+ - LICENSE
35
+ - README.md
36
+ - lib/paymos.rb
37
+ - lib/paymos/client.rb
38
+ - lib/paymos/errors.rb
39
+ - lib/paymos/models.rb
40
+ - lib/paymos/signing.rb
41
+ - lib/paymos/version.rb
42
+ - lib/paymos/webhook_verifier.rb
43
+ - sig/paymos.rbs
44
+ homepage: https://paymos.io/docs/server-sdks
45
+ licenses:
46
+ - MIT
47
+ metadata:
48
+ source_code_uri: https://github.com/Paymos-labs/ruby-sdk
49
+ changelog_uri: https://github.com/Paymos-labs/ruby-sdk/blob/main/CHANGELOG.md
50
+ documentation_uri: https://paymos.io/docs/server-sdks
51
+ homepage_uri: https://paymos.io/docs/server-sdks
52
+ rubygems_mfa_required: 'true'
53
+ rdoc_options: []
54
+ require_paths:
55
+ - lib
56
+ required_ruby_version: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - ">="
59
+ - !ruby/object:Gem::Version
60
+ version: '3.1'
61
+ required_rubygems_version: !ruby/object:Gem::Requirement
62
+ requirements:
63
+ - - ">="
64
+ - !ruby/object:Gem::Version
65
+ version: '0'
66
+ requirements: []
67
+ rubygems_version: 3.6.9
68
+ specification_version: 4
69
+ summary: Official Ruby SDK for the Paymos Merchant API
70
+ test_files: []