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 +7 -0
- data/CHANGELOG.md +6 -0
- data/LICENSE +21 -0
- data/README.md +48 -0
- data/lib/paymos/client.rb +313 -0
- data/lib/paymos/errors.rb +93 -0
- data/lib/paymos/models.rb +160 -0
- data/lib/paymos/signing.rb +54 -0
- data/lib/paymos/version.rb +5 -0
- data/lib/paymos/webhook_verifier.rb +83 -0
- data/lib/paymos.rb +8 -0
- data/sig/paymos.rbs +202 -0
- metadata +70 -0
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
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,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
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: []
|