paymos 1.1.1 → 1.4.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 +4 -4
- data/CHANGELOG.md +33 -0
- data/README.md +110 -2
- data/lib/paymos/client.rb +145 -9
- data/lib/paymos/errors.rb +10 -1
- data/lib/paymos/models.rb +93 -2
- data/lib/paymos/version.rb +1 -1
- data/sig/paymos.rbs +126 -0
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 53529447a1b6190b8df0810a738fe95a68dbfcbb40bfc00eb0884bd57b191992
|
|
4
|
+
data.tar.gz: b94adfdafc75ba8e72e799dbd5df529c9926802553c7e2f17910a56fff4fa68b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 8d4261c76165f99433e7d2711f46e990136ab7d009d0d4ddc84e82bacd72d5470c963212a9fc7bc3acde0ffb3b295f89afb2401739c1c890c35f2b8e651147b8
|
|
7
|
+
data.tar.gz: 460c55e0c00c9c0ccafb66ad7b2f13beaaefd3f3797daab6a65a9c46f662e031c542f98ec8ba476e660fabe2839f42eb0ab8cb68e59c332f0b63fd21412b4236
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,38 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [Unreleased]
|
|
4
|
+
|
|
5
|
+
## [1.4.0] - 2026-10-30
|
|
6
|
+
|
|
7
|
+
- fix(plugins): reject invalid Woo refund controls and unblock SDK checks
|
|
8
|
+
- fix(sdk): preserve current refund contracts in TON conformance
|
|
9
|
+
- fix(ton): return friendly addresses with existing webhook schema
|
|
10
|
+
- feat(sdk-ruby): add refund create, list and get
|
|
11
|
+
|
|
12
|
+
- **403 is no longer reported as an authentication failure.** `Paymos::PermissionError` (subclasses `Paymos::AuthenticationError`, so existing `rescue` blocks keep working) now carries every 403.
|
|
13
|
+
- **Base URL must use HTTPS.** Plain HTTP is accepted only against a loopback host, which is what a local integration test points at. The API secret signs every request, so a plaintext base URL put that credential on the wire.
|
|
14
|
+
|
|
15
|
+
## [1.3.0] - 2026-10-14
|
|
16
|
+
|
|
17
|
+
- Make the SDK CI tier green
|
|
18
|
+
- Fix invoice-late-payment audit findings (tasks 4.1-4.8)
|
|
19
|
+
- feat(invoices): accept full late payments without reopening invoices
|
|
20
|
+
- fix(sdk-conformance): восемь копий контракта разошлись с каноном — 403 снова permission
|
|
21
|
+
- fix(sdk): HTTPS обязателен, а 403 перестал притворяться ошибкой ключа
|
|
22
|
+
|
|
23
|
+
- **403 is no longer reported as an authentication failure.** `Paymos::PermissionError` (subclasses `Paymos::AuthenticationError`, so existing `rescue` blocks keep working) now carries every 403.
|
|
24
|
+
- **Base URL must use HTTPS.** Plain HTTP is accepted only against a loopback host, which is what a local integration test points at. The API secret signs every request, so a plaintext base URL put that credential on the wire.
|
|
25
|
+
|
|
26
|
+
## [1.2.0] - 2026-08-28
|
|
27
|
+
|
|
28
|
+
- release: the changelog rot had a cause, and it was not the one I named
|
|
29
|
+
- audit: the shipped plugin and SDK docs described a product we stopped shipping
|
|
30
|
+
- docs(sdk): the READMEs say static wallets, not payment channels
|
|
31
|
+
- feat(sdk): payment-channel webhooks and the flow in every README
|
|
32
|
+
- feat(ruby-sdk): payment channels, and a feed reader that cannot stop early
|
|
33
|
+
- feat(sdk-conformance): declare the payment-channel operations
|
|
34
|
+
- docs(api): pin the empty-body signing rule and fix the published POST vector
|
|
35
|
+
|
|
3
36
|
## [1.1.1] - 2026-08-02
|
|
4
37
|
|
|
5
38
|
- fix(ecosystem): recover SDK releases
|
data/README.md
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# Paymos Ruby SDK
|
|
2
2
|
|
|
3
|
-
Official Ruby client for the Paymos Merchant API
|
|
3
|
+
Official Ruby client for the Paymos Merchant API — invoices, withdrawals, refunds, balances,
|
|
4
|
+
and static per-customer wallets with their confirmed-deposit feed. Its only runtime dependency is
|
|
4
5
|
Ruby's official `base64` gem (required separately by modern Ruby versions).
|
|
5
6
|
|
|
6
7
|
```bash
|
|
@@ -31,6 +32,74 @@ Responses are immutable Ruby objects with documented RBS signatures; request
|
|
|
31
32
|
keywords remain idiomatic `snake_case` and are converted directly to the Paymos
|
|
32
33
|
wire contract.
|
|
33
34
|
|
|
35
|
+
Create invoice refunds with a Payment key. The server calculates the amount and
|
|
36
|
+
returns it as a decimal string; there are no amount or destination arguments:
|
|
37
|
+
|
|
38
|
+
```ruby
|
|
39
|
+
refund = paymos.refunds.create(
|
|
40
|
+
invoice_id: invoice.invoice_id,
|
|
41
|
+
mode: Paymos::RefundMode::OVERPAYMENT,
|
|
42
|
+
external_order_id: "refund_order_123_overpayment"
|
|
43
|
+
)
|
|
44
|
+
puts "#{refund.withdrawal_id}: #{refund.amount} #{refund.currency}"
|
|
45
|
+
|
|
46
|
+
filters = {
|
|
47
|
+
invoice_id: invoice.invoice_id,
|
|
48
|
+
status: [Paymos::WithdrawalStatus::COMPLETED],
|
|
49
|
+
created_from: "2026-01-01T00:00:00Z",
|
|
50
|
+
created_to: "2027-01-01T00:00:00Z",
|
|
51
|
+
limit: 100
|
|
52
|
+
}
|
|
53
|
+
page = paymos.refunds.list(**filters)
|
|
54
|
+
next_page = paymos.refunds.list(**filters, cursor: page.next_cursor) if page.next_cursor
|
|
55
|
+
refund = paymos.refunds.get(refund.withdrawal_id)
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
`full` returns all remaining payment; `overpayment` returns all remaining excess.
|
|
59
|
+
For a payment of 120 against an invoice of 100, these are initially 120 and 20.
|
|
60
|
+
After the overpayment refund completes, a new full refund returns 100. Refunds
|
|
61
|
+
and their pages are immutable, including their decimal strings and first sender.
|
|
62
|
+
|
|
63
|
+
Keep the same invoice, mode and `external_order_id` when retrying an uncertain
|
|
64
|
+
outcome. Creation returns 201 and an identical replay returns 200; both yield a
|
|
65
|
+
`Paymos::Refund`. Changing the mode under the same key raises
|
|
66
|
+
`Paymos::ConflictError` (409). Missing or foreign refund IDs raise
|
|
67
|
+
`Paymos::NotFoundError` (404). List periods use RFC 3339 timestamps, and
|
|
68
|
+
`paymos.refunds.each` provides the same bounded cursor iteration as invoices.
|
|
69
|
+
In sandbox, use `paymos.withdrawals.simulate_completion(refund.withdrawal_id)`,
|
|
70
|
+
then read the refund again. Refunds have their own list and do not appear in the
|
|
71
|
+
ordinary withdrawals list.
|
|
72
|
+
|
|
73
|
+
A payment channel is one payer's reusable set of deposit addresses. Create it,
|
|
74
|
+
read its rails, then poll the confirmed-deposit feed and persist the cursor:
|
|
75
|
+
|
|
76
|
+
```ruby
|
|
77
|
+
channel = paymos.payment_channels.create(project_id: "prj_...", external_id: "customer_42")
|
|
78
|
+
channel.networks.each do |rail|
|
|
79
|
+
next if rail.address.nil? # this rail has not provisioned yet
|
|
80
|
+
|
|
81
|
+
puts "#{rail.network} #{rail.status} #{rail.address}"
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
feed = paymos.payment_channel_deposits.read(cursor: saved_cursor, limit: 100)
|
|
85
|
+
feed.items.each { |deposit| credit(deposit) }
|
|
86
|
+
save_cursor(feed.next_cursor)
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Four things that bite a caller who guesses. Repeating the same `external_id`
|
|
90
|
+
returns the **same** channel — `200` instead of `201`, and both bodies are a
|
|
91
|
+
channel, so calling this on every checkout is safe: a repeat is not a duplicate
|
|
92
|
+
and not an error. A rail's `address` is nil until that rail finishes
|
|
93
|
+
provisioning and never changes once set, so nil means "not yet", not "no
|
|
94
|
+
address". A nil `minimum_deposit` means "we cannot quote a minimum right now",
|
|
95
|
+
never "there is no minimum" — reading it as zero is how a merchant accepts a
|
|
96
|
+
deposit that lands below the live minimum and is never credited. And
|
|
97
|
+
`next_cursor` is never empty, not even on a page with no items: store it and
|
|
98
|
+
resume from it, and never loop until it is nil the way `each` ends a list,
|
|
99
|
+
because that loop either spins forever or stops on the first quiet page and
|
|
100
|
+
leaves reconciliation silently behind. `confirmed_from` is the first poll's
|
|
101
|
+
lower bound only; afterwards the stored cursor is the resume mechanism.
|
|
102
|
+
|
|
34
103
|
API failures raise typed `Paymos::ApiError` subclasses and preserve the status,
|
|
35
104
|
problem fields, headers, response body, and `Retry-After`. Cursor helpers are
|
|
36
105
|
lazy, bounded, and reject a repeated cursor.
|
|
@@ -40,9 +109,48 @@ signatures against the exact raw request body before parsing JSON.
|
|
|
40
109
|
|
|
41
110
|
```ruby
|
|
42
111
|
event = Paymos::WebhookVerifier.new(ENV.fetch("PAYMOS_WEBHOOK_SECRET"))
|
|
43
|
-
.construct_event(request.env.fetch("
|
|
112
|
+
.construct_event(request.env.fetch("HTTP_X_WEBHOOK_SIGNATURE"), request.body.read)
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
Every `payment_channel.deposit.*` event carries a full payment-channel deposit as its
|
|
116
|
+
`data`; `data` comes back as a plain Hash, so hand it to `PaymentChannelDeposit.from`
|
|
117
|
+
to get the same typed model the deposit feed returns:
|
|
118
|
+
|
|
119
|
+
```ruby
|
|
120
|
+
case event.event_type
|
|
121
|
+
when "payment_channel.deposit.confirmed"
|
|
122
|
+
deposit = Paymos::PaymentChannelDeposit.from(event.data)
|
|
123
|
+
credit(deposit.payment_channel_external_id, deposit.net) if deposit.is_final
|
|
124
|
+
end
|
|
44
125
|
```
|
|
45
126
|
|
|
127
|
+
`confirming` and `reorged` are advisory and may arrive out of order — a `confirming`
|
|
128
|
+
can land after the `confirmed` for the same deposit, and a `reorged` can be superseded
|
|
129
|
+
by a later `confirmed`. Credit only on `payment_channel.deposit.confirmed` with
|
|
130
|
+
`is_final` true, and never let an advisory event regress a deposit you already know is
|
|
131
|
+
confirmed: crediting on `confirming` releases goods against money a reorg can still
|
|
132
|
+
take back.
|
|
133
|
+
|
|
46
134
|
Ruby 3.1 or newer is supported. See `conformance/contract.json` for the shared
|
|
47
135
|
cross-language protocol contract and https://paymos.io/docs/server-sdks for the
|
|
48
136
|
full API guide.
|
|
137
|
+
|
|
138
|
+
## 401 and 403 are different problems
|
|
139
|
+
|
|
140
|
+
A **401** means the credential is absent, malformed or unverifiable — fix the key or the signing
|
|
141
|
+
clock, and stop retrying.
|
|
142
|
+
|
|
143
|
+
A **403** means the credential was accepted and something else refused. It covers two very
|
|
144
|
+
different situations:
|
|
145
|
+
|
|
146
|
+
- **the key is wrong for this call** — `payout_key_required`, `payment_key_required`,
|
|
147
|
+
`forbidden`;
|
|
148
|
+
- **the key is fine, the account or project state says no** — `whitelist_required`,
|
|
149
|
+
`merchant_suspended`, `widget_inactive`, `terminal_not_enabled`, `not_sandbox`.
|
|
150
|
+
|
|
151
|
+
A customer entering a payout address that is not whitelisted yet is the second kind: nothing is
|
|
152
|
+
wrong with your credentials, and paging the on-call engineer about them is the wrong move. Always
|
|
153
|
+
switch on the error `code` before deciding what to do.
|
|
154
|
+
|
|
155
|
+
`Paymos::PermissionError` subclasses `Paymos::AuthenticationError`, so a `rescue` written before
|
|
156
|
+
this split keeps catching 403.
|
data/lib/paymos/client.rb
CHANGED
|
@@ -19,7 +19,7 @@ module Paymos
|
|
|
19
19
|
class Client
|
|
20
20
|
SAFE_METHODS = %w[GET HEAD OPTIONS].freeze
|
|
21
21
|
|
|
22
|
-
attr_reader :invoices, :withdrawals, :balances, :system
|
|
22
|
+
attr_reader :invoices, :withdrawals, :refunds, :balances, :system, :payment_channels, :payment_channel_deposits
|
|
23
23
|
|
|
24
24
|
def initialize(api_key:, api_secret:, base_url: 'https://api.paymos.io', timeout: 30,
|
|
25
25
|
max_retries: 2, base_delay: 0.150, transport: nil,
|
|
@@ -29,16 +29,10 @@ module Paymos
|
|
|
29
29
|
raise ConfigurationError,
|
|
30
30
|
'timeout, max_retries, and base_delay are invalid'
|
|
31
31
|
end
|
|
32
|
-
|
|
32
|
+
validate_base_url(base_url)
|
|
33
33
|
|
|
34
34
|
@api_key = api_key
|
|
35
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
36
|
@base_url = base_url.delete_suffix('/')
|
|
43
37
|
@timeout = timeout
|
|
44
38
|
@max_retries = max_retries
|
|
@@ -49,6 +43,9 @@ module Paymos
|
|
|
49
43
|
@random = random
|
|
50
44
|
@invoices = Invoices.new(self)
|
|
51
45
|
@withdrawals = Withdrawals.new(self)
|
|
46
|
+
@refunds = Refunds.new(self)
|
|
47
|
+
@payment_channels = PaymentChannels.new(self)
|
|
48
|
+
@payment_channel_deposits = PaymentChannelDeposits.new(self)
|
|
52
49
|
@balances = Balances.new(self)
|
|
53
50
|
@system = System.new(self)
|
|
54
51
|
end
|
|
@@ -104,6 +101,24 @@ module Paymos
|
|
|
104
101
|
|
|
105
102
|
private
|
|
106
103
|
|
|
104
|
+
# The API secret signs every request, so the scheme decides whether that credential is
|
|
105
|
+
# readable in transit. HTTPS in production; plain HTTP only against a loopback host, which
|
|
106
|
+
# is what a local integration test points at. Mirrors the Rust SDK's validate_base_url.
|
|
107
|
+
def validate_base_url(base_url)
|
|
108
|
+
raise ConfigurationError, 'base_url is required' if base_url.to_s.strip.empty?
|
|
109
|
+
|
|
110
|
+
origin = URI.parse(base_url)
|
|
111
|
+
unless origin.absolute? && origin.host && [nil, '', '/'].include?(origin.path) &&
|
|
112
|
+
origin.query.nil? && origin.fragment.nil?
|
|
113
|
+
raise ConfigurationError, 'base_url must be an absolute origin without a path'
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
loopback = %w[localhost 127.0.0.1 ::1].include?(origin.host.to_s.downcase)
|
|
117
|
+
return if origin.scheme == 'https' || (origin.scheme == 'http' && loopback)
|
|
118
|
+
|
|
119
|
+
raise ConfigurationError, 'base_url must use HTTPS (HTTP is allowed only for loopback tests)'
|
|
120
|
+
end
|
|
121
|
+
|
|
107
122
|
def default_transport(method, url, headers, body, timeout)
|
|
108
123
|
uri = URI(url)
|
|
109
124
|
request_type = Net::HTTP.const_get(method.capitalize)
|
|
@@ -228,7 +243,7 @@ module Paymos
|
|
|
228
243
|
end
|
|
229
244
|
|
|
230
245
|
def simulate_payment(invoice_id, stage:)
|
|
231
|
-
raise ArgumentError, 'Invalid simulation stage' unless %w[paid overpaid underpay cancel].include?(stage)
|
|
246
|
+
raise ArgumentError, 'Invalid simulation stage' unless %w[paid overpaid underpay cancel paid_late].include?(stage)
|
|
232
247
|
|
|
233
248
|
Invoice.from(@client.request('POST', "/v1/sandbox/invoices/#{segment(invoice_id)}/simulate-payment",
|
|
234
249
|
payload: { stage: }))
|
|
@@ -282,6 +297,127 @@ module Paymos
|
|
|
282
297
|
end
|
|
283
298
|
end
|
|
284
299
|
|
|
300
|
+
class Refunds < Resource
|
|
301
|
+
def create(invoice_id:, mode:, external_order_id:)
|
|
302
|
+
values = { invoice_id:, mode:, external_order_id: }
|
|
303
|
+
missing = values.select { |_name, value| !value.is_a?(String) || value.strip.empty? }.keys
|
|
304
|
+
raise ArgumentError, "Required values are missing: #{missing.join(', ')}" unless missing.empty?
|
|
305
|
+
raise ArgumentError, 'Invalid refund mode' unless [RefundMode::FULL, RefundMode::OVERPAYMENT].include?(mode)
|
|
306
|
+
|
|
307
|
+
Refund.from(@client.request('POST', '/v1/refunds', payload: values))
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
def simulate(refund_id, status)
|
|
311
|
+
Refund.from(@client.request('POST', "/v1/sandbox/refunds/#{segment(refund_id)}/simulate",
|
|
312
|
+
payload: { status: }))
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
def get(refund_id) = Refund.from(@client.request('GET', "/v1/refunds/#{segment(refund_id)}"))
|
|
316
|
+
|
|
317
|
+
def list(limit: nil, cursor: nil, invoice_id: nil, status: nil, created_from: nil, created_to: nil)
|
|
318
|
+
filters = validate_list_filters({ limit:, cursor:, invoice_id:, status: }.compact)
|
|
319
|
+
validate_period(created_from, created_to)
|
|
320
|
+
filters.merge!({ created_from:, created_to: }.compact)
|
|
321
|
+
Page.from(@client.request('GET', '/v1/refunds', query: Signing.query(filters)), item_type: Refund)
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
def each(max_pages: 100, **filters, &block)
|
|
325
|
+
return enum_for(__method__, max_pages: max_pages, **filters) unless block_given?
|
|
326
|
+
|
|
327
|
+
Pagination.each(method(:list), filters, max_pages:, &block)
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
private
|
|
331
|
+
|
|
332
|
+
def validate_period(created_from, created_to)
|
|
333
|
+
from, to = [created_from, created_to].map do |value|
|
|
334
|
+
next if value.nil?
|
|
335
|
+
|
|
336
|
+
unless value.is_a?(String) &&
|
|
337
|
+
value.match?(/\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})\z/i)
|
|
338
|
+
raise ArgumentError, 'created_from and created_to must be RFC 3339 timestamps'
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
Time.iso8601(value)
|
|
342
|
+
end
|
|
343
|
+
raise ArgumentError, 'created_to must be later than created_from' if from && to && from >= to
|
|
344
|
+
end
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
class PaymentChannels < Resource
|
|
348
|
+
# Create — or fetch. The same project plus external id answers 200 with the channel that already
|
|
349
|
+
# exists instead of 201 with a duplicate; both are a channel, so a caller never reads the status.
|
|
350
|
+
def create(project_id:, external_id:)
|
|
351
|
+
values = { project_id:, external_id: }
|
|
352
|
+
missing = values.select { |_name, value| !value.is_a?(String) || value.strip.empty? }.keys
|
|
353
|
+
raise ArgumentError, "Required values are missing: #{missing.join(', ')}" unless missing.empty?
|
|
354
|
+
|
|
355
|
+
PaymentChannel.from(@client.request('POST', '/v1/payment-channels', payload: values))
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
def get(payment_channel_id)
|
|
359
|
+
PaymentChannel.from(@client.request('GET', "/v1/payment-channels/#{segment(payment_channel_id)}"))
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
def list(limit: nil, cursor: nil, status: nil, external_id: nil, project_id: nil)
|
|
363
|
+
filters = validate_list_filters({ limit:, cursor:, status:, external_id:, project_id: }.compact)
|
|
364
|
+
Page.from(@client.request('GET', '/v1/payment-channels', query: Signing.query(filters)),
|
|
365
|
+
item_type: PaymentChannel)
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
def block(payment_channel_id)
|
|
369
|
+
PaymentChannel.from(@client.request('POST', "/v1/payment-channels/#{segment(payment_channel_id)}/block"))
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
def unblock(payment_channel_id)
|
|
373
|
+
PaymentChannel.from(@client.request('POST', "/v1/payment-channels/#{segment(payment_channel_id)}/unblock"))
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
# Sandbox only. network is required; stage defaults to confirmed server-side and is omitted from
|
|
377
|
+
# the payload entirely when unset, never sent as null.
|
|
378
|
+
def simulate_deposit(payment_channel_id, amount:, currency:, network:, stage: nil)
|
|
379
|
+
values = { amount:, currency:, network: }
|
|
380
|
+
missing = values.select { |_name, value| !value.is_a?(String) || value.strip.empty? }.keys
|
|
381
|
+
raise ArgumentError, "Required values are missing: #{missing.join(', ')}" unless missing.empty?
|
|
382
|
+
|
|
383
|
+
unless stage.nil? || %w[confirming reorged confirmed].include?(stage)
|
|
384
|
+
raise ArgumentError, 'Invalid simulation stage'
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
path = "/v1/sandbox/payment-channels/#{segment(payment_channel_id)}/simulate-deposit"
|
|
388
|
+
payload = { amount:, currency:, network:, stage: }.compact
|
|
389
|
+
PaymentChannelDeposit.from(@client.request('POST', path, payload:))
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
def each(max_pages: 100, **filters, &block)
|
|
393
|
+
return enum_for(__method__, max_pages: max_pages, **filters) unless block_given?
|
|
394
|
+
|
|
395
|
+
Pagination.each(method(:list), filters, max_pages:, &block)
|
|
396
|
+
end
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
class PaymentChannelDeposits < Resource
|
|
400
|
+
def get(deposit_id)
|
|
401
|
+
PaymentChannelDeposit.from(@client.request('GET', "/v1/payment-channel-deposits/#{segment(deposit_id)}"))
|
|
402
|
+
end
|
|
403
|
+
|
|
404
|
+
# Reads ONE page of the confirmed-deposit feed. Deliberately not named list and deliberately
|
|
405
|
+
# without an iterator: the feed never ends, so a helper that loops until the cursor is empty would
|
|
406
|
+
# either spin forever or stop on the first quiet page and leave reconciliation silently behind.
|
|
407
|
+
# confirmed_from is the initial-sync lower bound only; afterwards resume from the stored cursor.
|
|
408
|
+
def read(limit: nil, cursor: nil, project_id: nil, payment_channel_id: nil, confirmed_from: nil)
|
|
409
|
+
unless confirmed_from.nil? || (confirmed_from.is_a?(Integer) && confirmed_from >= 0)
|
|
410
|
+
raise ArgumentError, 'confirmed_from must be non-negative Unix seconds'
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
filters = validate_list_filters({ limit:, cursor:, project_id:, payment_channel_id:,
|
|
414
|
+
confirmed_from: }.compact)
|
|
415
|
+
PaymentChannelDepositFeedPage.from(
|
|
416
|
+
@client.request('GET', '/v1/payment-channel-deposits', query: Signing.query(filters))
|
|
417
|
+
)
|
|
418
|
+
end
|
|
419
|
+
end
|
|
420
|
+
|
|
285
421
|
class Balances < Resource
|
|
286
422
|
def get = Array(@client.request('GET', '/v1/balances')).map { |item| Balance.from(item) }.freeze
|
|
287
423
|
end
|
data/lib/paymos/errors.rb
CHANGED
|
@@ -69,6 +69,15 @@ module Paymos
|
|
|
69
69
|
|
|
70
70
|
class ValidationError < ApiError; end
|
|
71
71
|
class AuthenticationError < ApiError; end
|
|
72
|
+
|
|
73
|
+
# A 403: the credential was accepted, something else refused. Deliberately a SUBCLASS of
|
|
74
|
+
# AuthenticationError so existing rescue blocks keep working, and deliberately its own class
|
|
75
|
+
# because the remedy is usually not "fix the key": 403 carries wrong-key-type
|
|
76
|
+
# (payout_key_required, payment_key_required, forbidden) next to plain business
|
|
77
|
+
# state (whitelist_required, merchant_suspended, widget_inactive, terminal_not_enabled,
|
|
78
|
+
# not_sandbox). An unwhitelisted payout address reported as an authentication failure sends the
|
|
79
|
+
# on-call engineer to rotate perfectly good credentials. Switch on `code` for the remedy.
|
|
80
|
+
class PermissionError < AuthenticationError; end
|
|
72
81
|
class NotFoundError < ApiError; end
|
|
73
82
|
class ConflictError < ApiError; end
|
|
74
83
|
class GoneError < ApiError; end
|
|
@@ -79,7 +88,7 @@ module Paymos
|
|
|
79
88
|
ERROR_TYPES = {
|
|
80
89
|
400 => ValidationError,
|
|
81
90
|
401 => AuthenticationError,
|
|
82
|
-
403 =>
|
|
91
|
+
403 => PermissionError,
|
|
83
92
|
404 => NotFoundError,
|
|
84
93
|
409 => ConflictError,
|
|
85
94
|
410 => GoneError,
|
data/lib/paymos/models.rb
CHANGED
|
@@ -23,6 +23,11 @@ module Paymos
|
|
|
23
23
|
CANCELLED = 'cancelled'
|
|
24
24
|
end
|
|
25
25
|
|
|
26
|
+
module RefundMode
|
|
27
|
+
FULL = 'full'
|
|
28
|
+
OVERPAYMENT = 'overpayment'
|
|
29
|
+
end
|
|
30
|
+
|
|
26
31
|
class Model
|
|
27
32
|
class << self
|
|
28
33
|
attr_reader :field_names, :required_names
|
|
@@ -98,8 +103,11 @@ module Paymos
|
|
|
98
103
|
end
|
|
99
104
|
|
|
100
105
|
class Invoice < Model
|
|
106
|
+
# paid_late_at: invoice-late-payment — money crossed the invoice's total after it had already
|
|
107
|
+
# closed as expired/underpaid. Unix seconds; nil while there is no late mark (field omitted).
|
|
108
|
+
# status keeps its terminal meaning; the marker travels beside it.
|
|
101
109
|
fields :invoice_id, :project_id, :status, :is_final, :is_test, :payment_url,
|
|
102
|
-
:order, :payment, :created_at, :updated_at, :expires_at, :completed_at,
|
|
110
|
+
:order, :payment, :created_at, :updated_at, :expires_at, :completed_at, :paid_late_at,
|
|
103
111
|
required: %i[invoice_id project_id status is_final is_test payment_url order created_at updated_at]
|
|
104
112
|
|
|
105
113
|
def self.from(value)
|
|
@@ -109,9 +117,10 @@ module Paymos
|
|
|
109
117
|
end
|
|
110
118
|
|
|
111
119
|
class InvoiceListItem < Model
|
|
120
|
+
# paid_late_at: invoice-late-payment additive late mark, Unix seconds; nil while there is no mark (field omitted).
|
|
112
121
|
fields :invoice_id, :project_id, :external_order_id, :client_id, :status,
|
|
113
122
|
:is_final, :is_test, :amount, :currency, :network, :created_at,
|
|
114
|
-
:expires_at, :completed_at,
|
|
123
|
+
:expires_at, :completed_at, :paid_late_at,
|
|
115
124
|
required: %i[invoice_id project_id external_order_id status is_final is_test amount currency created_at]
|
|
116
125
|
end
|
|
117
126
|
|
|
@@ -123,6 +132,88 @@ module Paymos
|
|
|
123
132
|
network destination_address created_at]
|
|
124
133
|
end
|
|
125
134
|
|
|
135
|
+
class Refund < Model
|
|
136
|
+
fields :withdrawal_id, :invoice_id, :mode, :external_order_id, :status, :is_final, :is_test,
|
|
137
|
+
:amount, :fee, :currency, :network, :destination_address, :first_sender, :tx_hash,
|
|
138
|
+
:explorer_url, :created_at, :completed_at, :failed_at, :cancelled_at, :type, :project_id,
|
|
139
|
+
required: %i[withdrawal_id invoice_id mode external_order_id status is_final is_test amount
|
|
140
|
+
currency network destination_address first_sender created_at]
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# One accepted token on one rail. A missing minimum_deposit means the rail cannot be quoted right
|
|
144
|
+
# now — it never means the token has no minimum.
|
|
145
|
+
class PaymentChannelToken < Model
|
|
146
|
+
fields :symbol, :minimum_deposit, required: %i[symbol]
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# One rail of a channel. The address is absent until that rail provisions, and never changes after.
|
|
150
|
+
class PaymentChannelNetwork < Model
|
|
151
|
+
fields :network, :status, :address, :tokens, required: %i[network status tokens]
|
|
152
|
+
|
|
153
|
+
def self.from(value)
|
|
154
|
+
model = super
|
|
155
|
+
new(**model.to_h, tokens: model.tokens.map { |item| PaymentChannelToken.from(item) })
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
class PaymentChannel < Model
|
|
160
|
+
fields :id, :project_id, :external_id, :status, :is_accepting_payments, :is_fully_provisioned,
|
|
161
|
+
:is_test, :applied_fee_percent, :customer_fee_percent, :networks, :created_at, :updated_at,
|
|
162
|
+
required: %i[id project_id external_id status is_accepting_payments is_fully_provisioned
|
|
163
|
+
is_test applied_fee_percent customer_fee_percent networks created_at updated_at]
|
|
164
|
+
|
|
165
|
+
def self.from(value)
|
|
166
|
+
model = super
|
|
167
|
+
new(**model.to_h, networks: model.networks.map { |item| PaymentChannelNetwork.from(item) })
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Doubles as the `data` payload of every payment_channel.deposit.* webhook event (decoded via
|
|
172
|
+
# WebhookVerifier#construct_event, then PaymentChannelDeposit.from(event.data)). `confirming` and
|
|
173
|
+
# `reorged` are ADVISORY and may arrive out of order — a `confirming` can land after the
|
|
174
|
+
# `confirmed` for the same deposit, and a `reorged` can be superseded by a later `confirmed`.
|
|
175
|
+
# Credit only on payment_channel.deposit.confirmed with is_final true, and never let an advisory
|
|
176
|
+
# event regress a deposit already known to be confirmed: crediting on `confirming` releases goods
|
|
177
|
+
# against money a reorg can still take back.
|
|
178
|
+
class PaymentChannelDeposit < Model
|
|
179
|
+
fields :id, :payment_channel_id, :project_id, :payment_channel_external_id, :status, :is_final,
|
|
180
|
+
:is_test, :currency, :network, :chain_id, :contract_address, :gross, :fee, :net,
|
|
181
|
+
:applied_fee_percent, :customer_fee_percent, :tx_hash, :transfer_id, :source_address,
|
|
182
|
+
:destination_address, :block_height, :first_included_block_timestamp, :explorer_url,
|
|
183
|
+
:created_at, :updated_at, :confirmed_at,
|
|
184
|
+
required: %i[id payment_channel_id project_id payment_channel_external_id status is_final
|
|
185
|
+
is_test currency network chain_id gross fee net applied_fee_percent
|
|
186
|
+
customer_fee_percent tx_hash transfer_id destination_address block_height
|
|
187
|
+
created_at updated_at]
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# The one feed position that could not be rendered, and why. Without it a stuck feed is
|
|
191
|
+
# byte-identical to a quiet day, and a merchant cannot tell "no new payments" from "I am not
|
|
192
|
+
# seeing my money". Reasons: booked_auth_missing, inconsistent, deposit_missing, channel_missing,
|
|
193
|
+
# transfer_missing, not_confirmed.
|
|
194
|
+
class PaymentChannelDepositFeedBlockage < Model
|
|
195
|
+
fields :deposit_id, :reason
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# One page of the confirmed-deposit feed. Deliberately NOT a Page: next_cursor is required here,
|
|
199
|
+
# because the feed has no last page. An empty page still hands back a cursor past the global
|
|
200
|
+
# publication positions this merchant's filter skipped, so store it and resume from it — never
|
|
201
|
+
# stop polling because a page came back empty.
|
|
202
|
+
class PaymentChannelDepositFeedPage < Model
|
|
203
|
+
fields :items, :next_cursor, :blocked, required: %i[items next_cursor]
|
|
204
|
+
|
|
205
|
+
def self.from(value)
|
|
206
|
+
model = super
|
|
207
|
+
raise Error, 'Paymos API page items must be an array' unless model.items.is_a?(Array)
|
|
208
|
+
|
|
209
|
+
cursor = model.next_cursor
|
|
210
|
+
raise Error, 'Paymos deposit feed page is missing a cursor' unless cursor.is_a?(String) && !cursor.empty?
|
|
211
|
+
|
|
212
|
+
new(items: model.items.map { |item| PaymentChannelDeposit.from(item) }, next_cursor: cursor,
|
|
213
|
+
blocked: model.blocked && PaymentChannelDepositFeedBlockage.from(model.blocked))
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
|
|
126
217
|
class Balance < Model
|
|
127
218
|
fields :currency, :available
|
|
128
219
|
end
|
data/lib/paymos/version.rb
CHANGED
data/sig/paymos.rbs
CHANGED
|
@@ -133,6 +133,104 @@ module Paymos
|
|
|
133
133
|
attr_reader cancelled_at: Integer?
|
|
134
134
|
end
|
|
135
135
|
|
|
136
|
+
module RefundMode
|
|
137
|
+
FULL: "full"
|
|
138
|
+
OVERPAYMENT: "overpayment"
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
class Refund < Model
|
|
142
|
+
attr_reader withdrawal_id: String
|
|
143
|
+
attr_reader invoice_id: String
|
|
144
|
+
attr_reader mode: "full" | "overpayment"
|
|
145
|
+
attr_reader external_order_id: String
|
|
146
|
+
attr_reader status: String
|
|
147
|
+
attr_reader is_final: bool
|
|
148
|
+
attr_reader is_test: bool
|
|
149
|
+
attr_reader amount: String
|
|
150
|
+
attr_reader fee: String?
|
|
151
|
+
attr_reader currency: String
|
|
152
|
+
attr_reader network: String
|
|
153
|
+
attr_reader destination_address: String
|
|
154
|
+
attr_reader first_sender: String
|
|
155
|
+
attr_reader type: String?
|
|
156
|
+
attr_reader project_id: String?
|
|
157
|
+
attr_reader tx_hash: String?
|
|
158
|
+
attr_reader explorer_url: String?
|
|
159
|
+
attr_reader created_at: Integer
|
|
160
|
+
attr_reader completed_at: Integer?
|
|
161
|
+
attr_reader failed_at: Integer?
|
|
162
|
+
attr_reader cancelled_at: Integer?
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
class PaymentChannelToken < Model
|
|
166
|
+
attr_reader symbol: String
|
|
167
|
+
attr_reader minimum_deposit: String?
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
class PaymentChannelNetwork < Model
|
|
171
|
+
attr_reader network: String
|
|
172
|
+
attr_reader status: String
|
|
173
|
+
attr_reader address: String?
|
|
174
|
+
attr_reader tokens: Array[PaymentChannelToken]
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
class PaymentChannel < Model
|
|
178
|
+
attr_reader id: String
|
|
179
|
+
attr_reader project_id: String
|
|
180
|
+
attr_reader external_id: String
|
|
181
|
+
attr_reader status: String
|
|
182
|
+
attr_reader is_accepting_payments: bool
|
|
183
|
+
attr_reader is_fully_provisioned: bool
|
|
184
|
+
attr_reader is_test: bool
|
|
185
|
+
attr_reader applied_fee_percent: Numeric
|
|
186
|
+
attr_reader customer_fee_percent: Integer
|
|
187
|
+
attr_reader networks: Array[PaymentChannelNetwork]
|
|
188
|
+
attr_reader created_at: Integer
|
|
189
|
+
attr_reader updated_at: Integer
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
class PaymentChannelDeposit < Model
|
|
193
|
+
attr_reader id: String
|
|
194
|
+
attr_reader payment_channel_id: String
|
|
195
|
+
attr_reader project_id: String
|
|
196
|
+
attr_reader payment_channel_external_id: String
|
|
197
|
+
attr_reader status: String
|
|
198
|
+
attr_reader is_final: bool
|
|
199
|
+
attr_reader is_test: bool
|
|
200
|
+
attr_reader currency: String
|
|
201
|
+
attr_reader network: String
|
|
202
|
+
attr_reader chain_id: Integer
|
|
203
|
+
attr_reader contract_address: String?
|
|
204
|
+
attr_reader gross: String
|
|
205
|
+
attr_reader fee: String
|
|
206
|
+
attr_reader net: String
|
|
207
|
+
attr_reader applied_fee_percent: Numeric
|
|
208
|
+
attr_reader customer_fee_percent: Integer
|
|
209
|
+
attr_reader tx_hash: String
|
|
210
|
+
attr_reader transfer_id: String
|
|
211
|
+
attr_reader source_address: String?
|
|
212
|
+
attr_reader destination_address: String
|
|
213
|
+
attr_reader block_height: Integer
|
|
214
|
+
attr_reader first_included_block_timestamp: Integer?
|
|
215
|
+
attr_reader explorer_url: String?
|
|
216
|
+
attr_reader created_at: Integer
|
|
217
|
+
attr_reader updated_at: Integer
|
|
218
|
+
attr_reader confirmed_at: Integer?
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
class PaymentChannelDepositFeedBlockage < Model
|
|
222
|
+
attr_reader deposit_id: String
|
|
223
|
+
attr_reader reason: String
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# next_cursor is NOT optional: the feed never ends, so an empty page still carries the cursor to
|
|
227
|
+
# resume from. Store it; never stop because a page came back empty.
|
|
228
|
+
class PaymentChannelDepositFeedPage < Model
|
|
229
|
+
attr_reader items: Array[PaymentChannelDeposit]
|
|
230
|
+
attr_reader next_cursor: String
|
|
231
|
+
attr_reader blocked: PaymentChannelDepositFeedBlockage?
|
|
232
|
+
end
|
|
233
|
+
|
|
136
234
|
class Balance < Model
|
|
137
235
|
attr_reader currency: String
|
|
138
236
|
attr_reader available: String
|
|
@@ -176,6 +274,31 @@ module Paymos
|
|
|
176
274
|
| (?max_pages: Integer, **untyped filters) -> Enumerator[Withdrawal, void]
|
|
177
275
|
end
|
|
178
276
|
|
|
277
|
+
class Refunds
|
|
278
|
+
def simulate: (String refund_id, String status) -> Refund
|
|
279
|
+
def create: (invoice_id: String, mode: "full" | "overpayment", external_order_id: String) -> Refund
|
|
280
|
+
def get: (String refund_id) -> Refund
|
|
281
|
+
def list: (?limit: Integer?, ?cursor: String?, ?invoice_id: String?, ?status: String | Array[String]?, ?created_from: String?, ?created_to: String?) -> Page[Refund]
|
|
282
|
+
def each: (?max_pages: Integer, **untyped filters) { (Refund) -> void } -> void
|
|
283
|
+
| (?max_pages: Integer, **untyped filters) -> Enumerator[Refund, void]
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
class PaymentChannels
|
|
287
|
+
def create: (project_id: String, external_id: String) -> PaymentChannel
|
|
288
|
+
def get: (String payment_channel_id) -> PaymentChannel
|
|
289
|
+
def list: (?limit: Integer?, ?cursor: String?, ?status: String | Array[String]?, ?external_id: String?, ?project_id: String?) -> Page[PaymentChannel]
|
|
290
|
+
def block: (String payment_channel_id) -> PaymentChannel
|
|
291
|
+
def unblock: (String payment_channel_id) -> PaymentChannel
|
|
292
|
+
def simulate_deposit: (String payment_channel_id, amount: String, currency: String, network: String, ?stage: String?) -> PaymentChannelDeposit
|
|
293
|
+
def each: (?max_pages: Integer, **untyped filters) { (PaymentChannel) -> void } -> void
|
|
294
|
+
| (?max_pages: Integer, **untyped filters) -> Enumerator[PaymentChannel, void]
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
class PaymentChannelDeposits
|
|
298
|
+
def get: (String deposit_id) -> PaymentChannelDeposit
|
|
299
|
+
def read: (?limit: Integer?, ?cursor: String?, ?project_id: String?, ?payment_channel_id: String?, ?confirmed_from: Integer?) -> PaymentChannelDepositFeedPage
|
|
300
|
+
end
|
|
301
|
+
|
|
179
302
|
class Balances
|
|
180
303
|
def get: () -> Array[Balance]
|
|
181
304
|
end
|
|
@@ -187,6 +310,9 @@ module Paymos
|
|
|
187
310
|
class Client
|
|
188
311
|
attr_reader invoices: Invoices
|
|
189
312
|
attr_reader withdrawals: Withdrawals
|
|
313
|
+
attr_reader refunds: Refunds
|
|
314
|
+
attr_reader payment_channels: PaymentChannels
|
|
315
|
+
attr_reader payment_channel_deposits: PaymentChannelDeposits
|
|
190
316
|
attr_reader balances: Balances
|
|
191
317
|
attr_reader system: System
|
|
192
318
|
|