paymos 1.1.0 → 1.3.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 +27 -0
- data/README.md +72 -2
- data/lib/paymos/client.rb +97 -9
- data/lib/paymos/errors.rb +10 -1
- data/lib/paymos/models.rb +80 -2
- data/lib/paymos/version.rb +1 -1
- data/sig/paymos.rbs +87 -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: c747bf5230f29f3a2dbed376f50af76f02c05b65b9a89558190dace7f76971dc
|
|
4
|
+
data.tar.gz: 6f26d4b9c429dd3d09d9f9c4edda6772bd8fd9b4fa77eb184188de10d831c61e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 865bc2412c06f86d0f152bf2767308a2fcb06cf7386f253bd2467d509a1fb8609c1a16aa3130c85600e1c57302d1c7d598bc05c8f8d7907b289b93a0dd540153
|
|
7
|
+
data.tar.gz: 9ecf3a0cce176412d201d3ec956731b1924f9b4c0ec188d5d0717e9a39367a24beac7031c74e0678cd887513443791d73aaae9c918929094b044196e723ce552
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,32 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [Unreleased]
|
|
4
|
+
|
|
5
|
+
## [1.3.0] - 2026-10-14
|
|
6
|
+
|
|
7
|
+
- Make the SDK CI tier green
|
|
8
|
+
- Fix invoice-late-payment audit findings (tasks 4.1-4.8)
|
|
9
|
+
- feat(invoices): accept full late payments without reopening invoices
|
|
10
|
+
- fix(sdk-conformance): восемь копий контракта разошлись с каноном — 403 снова permission
|
|
11
|
+
- fix(sdk): HTTPS обязателен, а 403 перестал притворяться ошибкой ключа
|
|
12
|
+
|
|
13
|
+
- **403 is no longer reported as an authentication failure.** `Paymos::PermissionError` (subclasses `Paymos::AuthenticationError`, so existing `rescue` blocks keep working) now carries every 403.
|
|
14
|
+
- **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.
|
|
15
|
+
|
|
16
|
+
## [1.2.0] - 2026-08-28
|
|
17
|
+
|
|
18
|
+
- release: the changelog rot had a cause, and it was not the one I named
|
|
19
|
+
- audit: the shipped plugin and SDK docs described a product we stopped shipping
|
|
20
|
+
- docs(sdk): the READMEs say static wallets, not payment channels
|
|
21
|
+
- feat(sdk): payment-channel webhooks and the flow in every README
|
|
22
|
+
- feat(ruby-sdk): payment channels, and a feed reader that cannot stop early
|
|
23
|
+
- feat(sdk-conformance): declare the payment-channel operations
|
|
24
|
+
- docs(api): pin the empty-body signing rule and fix the published POST vector
|
|
25
|
+
|
|
26
|
+
## [1.1.1] - 2026-08-02
|
|
27
|
+
|
|
28
|
+
- fix(ecosystem): recover SDK releases
|
|
29
|
+
|
|
3
30
|
## [1.1.0] - 2026-07-21
|
|
4
31
|
|
|
5
32
|
- feat(docs): make the developer surface consumable by LLM agents
|
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, 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,36 @@ 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
|
+
A payment channel is one payer's reusable set of deposit addresses. Create it,
|
|
36
|
+
read its rails, then poll the confirmed-deposit feed and persist the cursor:
|
|
37
|
+
|
|
38
|
+
```ruby
|
|
39
|
+
channel = paymos.payment_channels.create(project_id: "prj_...", external_id: "customer_42")
|
|
40
|
+
channel.networks.each do |rail|
|
|
41
|
+
next if rail.address.nil? # this rail has not provisioned yet
|
|
42
|
+
|
|
43
|
+
puts "#{rail.network} #{rail.status} #{rail.address}"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
feed = paymos.payment_channel_deposits.read(cursor: saved_cursor, limit: 100)
|
|
47
|
+
feed.items.each { |deposit| credit(deposit) }
|
|
48
|
+
save_cursor(feed.next_cursor)
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Four things that bite a caller who guesses. Repeating the same `external_id`
|
|
52
|
+
returns the **same** channel — `200` instead of `201`, and both bodies are a
|
|
53
|
+
channel, so calling this on every checkout is safe: a repeat is not a duplicate
|
|
54
|
+
and not an error. A rail's `address` is nil until that rail finishes
|
|
55
|
+
provisioning and never changes once set, so nil means "not yet", not "no
|
|
56
|
+
address". A nil `minimum_deposit` means "we cannot quote a minimum right now",
|
|
57
|
+
never "there is no minimum" — reading it as zero is how a merchant accepts a
|
|
58
|
+
deposit that lands below the live minimum and is never credited. And
|
|
59
|
+
`next_cursor` is never empty, not even on a page with no items: store it and
|
|
60
|
+
resume from it, and never loop until it is nil the way `each` ends a list,
|
|
61
|
+
because that loop either spins forever or stops on the first quiet page and
|
|
62
|
+
leaves reconciliation silently behind. `confirmed_from` is the first poll's
|
|
63
|
+
lower bound only; afterwards the stored cursor is the resume mechanism.
|
|
64
|
+
|
|
34
65
|
API failures raise typed `Paymos::ApiError` subclasses and preserve the status,
|
|
35
66
|
problem fields, headers, response body, and `Retry-After`. Cursor helpers are
|
|
36
67
|
lazy, bounded, and reject a repeated cursor.
|
|
@@ -40,9 +71,48 @@ signatures against the exact raw request body before parsing JSON.
|
|
|
40
71
|
|
|
41
72
|
```ruby
|
|
42
73
|
event = Paymos::WebhookVerifier.new(ENV.fetch("PAYMOS_WEBHOOK_SECRET"))
|
|
43
|
-
.construct_event(request.env.fetch("
|
|
74
|
+
.construct_event(request.env.fetch("HTTP_X_WEBHOOK_SIGNATURE"), request.body.read)
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Every `payment_channel.deposit.*` event carries a full payment-channel deposit as its
|
|
78
|
+
`data`; `data` comes back as a plain Hash, so hand it to `PaymentChannelDeposit.from`
|
|
79
|
+
to get the same typed model the deposit feed returns:
|
|
80
|
+
|
|
81
|
+
```ruby
|
|
82
|
+
case event.event_type
|
|
83
|
+
when "payment_channel.deposit.confirmed"
|
|
84
|
+
deposit = Paymos::PaymentChannelDeposit.from(event.data)
|
|
85
|
+
credit(deposit.payment_channel_external_id, deposit.net) if deposit.is_final
|
|
86
|
+
end
|
|
44
87
|
```
|
|
45
88
|
|
|
89
|
+
`confirming` and `reorged` are advisory and may arrive out of order — a `confirming`
|
|
90
|
+
can land after the `confirmed` for the same deposit, and a `reorged` can be superseded
|
|
91
|
+
by a later `confirmed`. Credit only on `payment_channel.deposit.confirmed` with
|
|
92
|
+
`is_final` true, and never let an advisory event regress a deposit you already know is
|
|
93
|
+
confirmed: crediting on `confirming` releases goods against money a reorg can still
|
|
94
|
+
take back.
|
|
95
|
+
|
|
46
96
|
Ruby 3.1 or newer is supported. See `conformance/contract.json` for the shared
|
|
47
97
|
cross-language protocol contract and https://paymos.io/docs/server-sdks for the
|
|
48
98
|
full API guide.
|
|
99
|
+
|
|
100
|
+
## 401 and 403 are different problems
|
|
101
|
+
|
|
102
|
+
A **401** means the credential is absent, malformed or unverifiable — fix the key or the signing
|
|
103
|
+
clock, and stop retrying.
|
|
104
|
+
|
|
105
|
+
A **403** means the credential was accepted and something else refused. It covers two very
|
|
106
|
+
different situations:
|
|
107
|
+
|
|
108
|
+
- **the key is wrong for this call** — `payout_key_required`, `payment_key_required`,
|
|
109
|
+
`forbidden`;
|
|
110
|
+
- **the key is fine, the account or project state says no** — `whitelist_required`,
|
|
111
|
+
`merchant_suspended`, `widget_inactive`, `terminal_not_enabled`, `not_sandbox`.
|
|
112
|
+
|
|
113
|
+
A customer entering a payout address that is not whitelisted yet is the second kind: nothing is
|
|
114
|
+
wrong with your credentials, and paging the on-call engineer about them is the wrong move. Always
|
|
115
|
+
switch on the error `code` before deciding what to do.
|
|
116
|
+
|
|
117
|
+
`Paymos::PermissionError` subclasses `Paymos::AuthenticationError`, so a `rescue` written before
|
|
118
|
+
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, :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,8 @@ module Paymos
|
|
|
49
43
|
@random = random
|
|
50
44
|
@invoices = Invoices.new(self)
|
|
51
45
|
@withdrawals = Withdrawals.new(self)
|
|
46
|
+
@payment_channels = PaymentChannels.new(self)
|
|
47
|
+
@payment_channel_deposits = PaymentChannelDeposits.new(self)
|
|
52
48
|
@balances = Balances.new(self)
|
|
53
49
|
@system = System.new(self)
|
|
54
50
|
end
|
|
@@ -104,6 +100,24 @@ module Paymos
|
|
|
104
100
|
|
|
105
101
|
private
|
|
106
102
|
|
|
103
|
+
# The API secret signs every request, so the scheme decides whether that credential is
|
|
104
|
+
# readable in transit. HTTPS in production; plain HTTP only against a loopback host, which
|
|
105
|
+
# is what a local integration test points at. Mirrors the Rust SDK's validate_base_url.
|
|
106
|
+
def validate_base_url(base_url)
|
|
107
|
+
raise ConfigurationError, 'base_url is required' if base_url.to_s.strip.empty?
|
|
108
|
+
|
|
109
|
+
origin = URI.parse(base_url)
|
|
110
|
+
unless origin.absolute? && origin.host && [nil, '', '/'].include?(origin.path) &&
|
|
111
|
+
origin.query.nil? && origin.fragment.nil?
|
|
112
|
+
raise ConfigurationError, 'base_url must be an absolute origin without a path'
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
loopback = %w[localhost 127.0.0.1 ::1].include?(origin.host.to_s.downcase)
|
|
116
|
+
return if origin.scheme == 'https' || (origin.scheme == 'http' && loopback)
|
|
117
|
+
|
|
118
|
+
raise ConfigurationError, 'base_url must use HTTPS (HTTP is allowed only for loopback tests)'
|
|
119
|
+
end
|
|
120
|
+
|
|
107
121
|
def default_transport(method, url, headers, body, timeout)
|
|
108
122
|
uri = URI(url)
|
|
109
123
|
request_type = Net::HTTP.const_get(method.capitalize)
|
|
@@ -228,7 +242,7 @@ module Paymos
|
|
|
228
242
|
end
|
|
229
243
|
|
|
230
244
|
def simulate_payment(invoice_id, stage:)
|
|
231
|
-
raise ArgumentError, 'Invalid simulation stage' unless %w[paid overpaid underpay cancel].include?(stage)
|
|
245
|
+
raise ArgumentError, 'Invalid simulation stage' unless %w[paid overpaid underpay cancel paid_late].include?(stage)
|
|
232
246
|
|
|
233
247
|
Invoice.from(@client.request('POST', "/v1/sandbox/invoices/#{segment(invoice_id)}/simulate-payment",
|
|
234
248
|
payload: { stage: }))
|
|
@@ -282,6 +296,80 @@ module Paymos
|
|
|
282
296
|
end
|
|
283
297
|
end
|
|
284
298
|
|
|
299
|
+
class PaymentChannels < Resource
|
|
300
|
+
# Create — or fetch. The same project plus external id answers 200 with the channel that already
|
|
301
|
+
# exists instead of 201 with a duplicate; both are a channel, so a caller never reads the status.
|
|
302
|
+
def create(project_id:, external_id:)
|
|
303
|
+
values = { project_id:, external_id: }
|
|
304
|
+
missing = values.select { |_name, value| !value.is_a?(String) || value.strip.empty? }.keys
|
|
305
|
+
raise ArgumentError, "Required values are missing: #{missing.join(', ')}" unless missing.empty?
|
|
306
|
+
|
|
307
|
+
PaymentChannel.from(@client.request('POST', '/v1/payment-channels', payload: values))
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
def get(payment_channel_id)
|
|
311
|
+
PaymentChannel.from(@client.request('GET', "/v1/payment-channels/#{segment(payment_channel_id)}"))
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
def list(limit: nil, cursor: nil, status: nil, external_id: nil, project_id: nil)
|
|
315
|
+
filters = validate_list_filters({ limit:, cursor:, status:, external_id:, project_id: }.compact)
|
|
316
|
+
Page.from(@client.request('GET', '/v1/payment-channels', query: Signing.query(filters)),
|
|
317
|
+
item_type: PaymentChannel)
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
def block(payment_channel_id)
|
|
321
|
+
PaymentChannel.from(@client.request('POST', "/v1/payment-channels/#{segment(payment_channel_id)}/block"))
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
def unblock(payment_channel_id)
|
|
325
|
+
PaymentChannel.from(@client.request('POST', "/v1/payment-channels/#{segment(payment_channel_id)}/unblock"))
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
# Sandbox only. network is required; stage defaults to confirmed server-side and is omitted from
|
|
329
|
+
# the payload entirely when unset, never sent as null.
|
|
330
|
+
def simulate_deposit(payment_channel_id, amount:, currency:, network:, stage: nil)
|
|
331
|
+
values = { amount:, currency:, network: }
|
|
332
|
+
missing = values.select { |_name, value| !value.is_a?(String) || value.strip.empty? }.keys
|
|
333
|
+
raise ArgumentError, "Required values are missing: #{missing.join(', ')}" unless missing.empty?
|
|
334
|
+
|
|
335
|
+
unless stage.nil? || %w[confirming reorged confirmed].include?(stage)
|
|
336
|
+
raise ArgumentError, 'Invalid simulation stage'
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
path = "/v1/sandbox/payment-channels/#{segment(payment_channel_id)}/simulate-deposit"
|
|
340
|
+
payload = { amount:, currency:, network:, stage: }.compact
|
|
341
|
+
PaymentChannelDeposit.from(@client.request('POST', path, payload:))
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
def each(max_pages: 100, **filters, &block)
|
|
345
|
+
return enum_for(__method__, max_pages: max_pages, **filters) unless block_given?
|
|
346
|
+
|
|
347
|
+
Pagination.each(method(:list), filters, max_pages:, &block)
|
|
348
|
+
end
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
class PaymentChannelDeposits < Resource
|
|
352
|
+
def get(deposit_id)
|
|
353
|
+
PaymentChannelDeposit.from(@client.request('GET', "/v1/payment-channel-deposits/#{segment(deposit_id)}"))
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
# Reads ONE page of the confirmed-deposit feed. Deliberately not named list and deliberately
|
|
357
|
+
# without an iterator: the feed never ends, so a helper that loops until the cursor is empty would
|
|
358
|
+
# either spin forever or stop on the first quiet page and leave reconciliation silently behind.
|
|
359
|
+
# confirmed_from is the initial-sync lower bound only; afterwards resume from the stored cursor.
|
|
360
|
+
def read(limit: nil, cursor: nil, project_id: nil, payment_channel_id: nil, confirmed_from: nil)
|
|
361
|
+
unless confirmed_from.nil? || (confirmed_from.is_a?(Integer) && confirmed_from >= 0)
|
|
362
|
+
raise ArgumentError, 'confirmed_from must be non-negative Unix seconds'
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
filters = validate_list_filters({ limit:, cursor:, project_id:, payment_channel_id:,
|
|
366
|
+
confirmed_from: }.compact)
|
|
367
|
+
PaymentChannelDepositFeedPage.from(
|
|
368
|
+
@client.request('GET', '/v1/payment-channel-deposits', query: Signing.query(filters))
|
|
369
|
+
)
|
|
370
|
+
end
|
|
371
|
+
end
|
|
372
|
+
|
|
285
373
|
class Balances < Resource
|
|
286
374
|
def get = Array(@client.request('GET', '/v1/balances')).map { |item| Balance.from(item) }.freeze
|
|
287
375
|
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
|
@@ -98,8 +98,11 @@ module Paymos
|
|
|
98
98
|
end
|
|
99
99
|
|
|
100
100
|
class Invoice < Model
|
|
101
|
+
# paid_late_at: invoice-late-payment — money crossed the invoice's total after it had already
|
|
102
|
+
# closed as expired/underpaid. Unix seconds; nil while there is no late mark (field omitted).
|
|
103
|
+
# status keeps its terminal meaning; the marker travels beside it.
|
|
101
104
|
fields :invoice_id, :project_id, :status, :is_final, :is_test, :payment_url,
|
|
102
|
-
:order, :payment, :created_at, :updated_at, :expires_at, :completed_at,
|
|
105
|
+
:order, :payment, :created_at, :updated_at, :expires_at, :completed_at, :paid_late_at,
|
|
103
106
|
required: %i[invoice_id project_id status is_final is_test payment_url order created_at updated_at]
|
|
104
107
|
|
|
105
108
|
def self.from(value)
|
|
@@ -109,9 +112,10 @@ module Paymos
|
|
|
109
112
|
end
|
|
110
113
|
|
|
111
114
|
class InvoiceListItem < Model
|
|
115
|
+
# paid_late_at: invoice-late-payment additive late mark, Unix seconds; nil while there is no mark (field omitted).
|
|
112
116
|
fields :invoice_id, :project_id, :external_order_id, :client_id, :status,
|
|
113
117
|
:is_final, :is_test, :amount, :currency, :network, :created_at,
|
|
114
|
-
:expires_at, :completed_at,
|
|
118
|
+
:expires_at, :completed_at, :paid_late_at,
|
|
115
119
|
required: %i[invoice_id project_id external_order_id status is_final is_test amount currency created_at]
|
|
116
120
|
end
|
|
117
121
|
|
|
@@ -123,6 +127,80 @@ module Paymos
|
|
|
123
127
|
network destination_address created_at]
|
|
124
128
|
end
|
|
125
129
|
|
|
130
|
+
# One accepted token on one rail. A missing minimum_deposit means the rail cannot be quoted right
|
|
131
|
+
# now — it never means the token has no minimum.
|
|
132
|
+
class PaymentChannelToken < Model
|
|
133
|
+
fields :symbol, :minimum_deposit, required: %i[symbol]
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# One rail of a channel. The address is absent until that rail provisions, and never changes after.
|
|
137
|
+
class PaymentChannelNetwork < Model
|
|
138
|
+
fields :network, :status, :address, :tokens, required: %i[network status tokens]
|
|
139
|
+
|
|
140
|
+
def self.from(value)
|
|
141
|
+
model = super
|
|
142
|
+
new(**model.to_h, tokens: model.tokens.map { |item| PaymentChannelToken.from(item) })
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
class PaymentChannel < Model
|
|
147
|
+
fields :id, :project_id, :external_id, :status, :is_accepting_payments, :is_fully_provisioned,
|
|
148
|
+
:is_test, :applied_fee_percent, :customer_fee_percent, :networks, :created_at, :updated_at,
|
|
149
|
+
required: %i[id project_id external_id status is_accepting_payments is_fully_provisioned
|
|
150
|
+
is_test applied_fee_percent customer_fee_percent networks created_at updated_at]
|
|
151
|
+
|
|
152
|
+
def self.from(value)
|
|
153
|
+
model = super
|
|
154
|
+
new(**model.to_h, networks: model.networks.map { |item| PaymentChannelNetwork.from(item) })
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Doubles as the `data` payload of every payment_channel.deposit.* webhook event (decoded via
|
|
159
|
+
# WebhookVerifier#construct_event, then PaymentChannelDeposit.from(event.data)). `confirming` and
|
|
160
|
+
# `reorged` are ADVISORY and may arrive out of order — a `confirming` can land after the
|
|
161
|
+
# `confirmed` for the same deposit, and a `reorged` can be superseded by a later `confirmed`.
|
|
162
|
+
# Credit only on payment_channel.deposit.confirmed with is_final true, and never let an advisory
|
|
163
|
+
# event regress a deposit already known to be confirmed: crediting on `confirming` releases goods
|
|
164
|
+
# against money a reorg can still take back.
|
|
165
|
+
class PaymentChannelDeposit < Model
|
|
166
|
+
fields :id, :payment_channel_id, :project_id, :payment_channel_external_id, :status, :is_final,
|
|
167
|
+
:is_test, :currency, :network, :chain_id, :contract_address, :gross, :fee, :net,
|
|
168
|
+
:applied_fee_percent, :customer_fee_percent, :tx_hash, :transfer_id, :source_address,
|
|
169
|
+
:destination_address, :block_height, :first_included_block_timestamp, :explorer_url,
|
|
170
|
+
:created_at, :updated_at, :confirmed_at,
|
|
171
|
+
required: %i[id payment_channel_id project_id payment_channel_external_id status is_final
|
|
172
|
+
is_test currency network chain_id gross fee net applied_fee_percent
|
|
173
|
+
customer_fee_percent tx_hash transfer_id destination_address block_height
|
|
174
|
+
created_at updated_at]
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# The one feed position that could not be rendered, and why. Without it a stuck feed is
|
|
178
|
+
# byte-identical to a quiet day, and a merchant cannot tell "no new payments" from "I am not
|
|
179
|
+
# seeing my money". Reasons: booked_auth_missing, inconsistent, deposit_missing, channel_missing,
|
|
180
|
+
# transfer_missing, not_confirmed.
|
|
181
|
+
class PaymentChannelDepositFeedBlockage < Model
|
|
182
|
+
fields :deposit_id, :reason
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# One page of the confirmed-deposit feed. Deliberately NOT a Page: next_cursor is required here,
|
|
186
|
+
# because the feed has no last page. An empty page still hands back a cursor past the global
|
|
187
|
+
# publication positions this merchant's filter skipped, so store it and resume from it — never
|
|
188
|
+
# stop polling because a page came back empty.
|
|
189
|
+
class PaymentChannelDepositFeedPage < Model
|
|
190
|
+
fields :items, :next_cursor, :blocked, required: %i[items next_cursor]
|
|
191
|
+
|
|
192
|
+
def self.from(value)
|
|
193
|
+
model = super
|
|
194
|
+
raise Error, 'Paymos API page items must be an array' unless model.items.is_a?(Array)
|
|
195
|
+
|
|
196
|
+
cursor = model.next_cursor
|
|
197
|
+
raise Error, 'Paymos deposit feed page is missing a cursor' unless cursor.is_a?(String) && !cursor.empty?
|
|
198
|
+
|
|
199
|
+
new(items: model.items.map { |item| PaymentChannelDeposit.from(item) }, next_cursor: cursor,
|
|
200
|
+
blocked: model.blocked && PaymentChannelDepositFeedBlockage.from(model.blocked))
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
|
|
126
204
|
class Balance < Model
|
|
127
205
|
fields :currency, :available
|
|
128
206
|
end
|
data/lib/paymos/version.rb
CHANGED
data/sig/paymos.rbs
CHANGED
|
@@ -133,6 +133,75 @@ module Paymos
|
|
|
133
133
|
attr_reader cancelled_at: Integer?
|
|
134
134
|
end
|
|
135
135
|
|
|
136
|
+
class PaymentChannelToken < Model
|
|
137
|
+
attr_reader symbol: String
|
|
138
|
+
attr_reader minimum_deposit: String?
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
class PaymentChannelNetwork < Model
|
|
142
|
+
attr_reader network: String
|
|
143
|
+
attr_reader status: String
|
|
144
|
+
attr_reader address: String?
|
|
145
|
+
attr_reader tokens: Array[PaymentChannelToken]
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
class PaymentChannel < Model
|
|
149
|
+
attr_reader id: String
|
|
150
|
+
attr_reader project_id: String
|
|
151
|
+
attr_reader external_id: String
|
|
152
|
+
attr_reader status: String
|
|
153
|
+
attr_reader is_accepting_payments: bool
|
|
154
|
+
attr_reader is_fully_provisioned: bool
|
|
155
|
+
attr_reader is_test: bool
|
|
156
|
+
attr_reader applied_fee_percent: Numeric
|
|
157
|
+
attr_reader customer_fee_percent: Integer
|
|
158
|
+
attr_reader networks: Array[PaymentChannelNetwork]
|
|
159
|
+
attr_reader created_at: Integer
|
|
160
|
+
attr_reader updated_at: Integer
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
class PaymentChannelDeposit < Model
|
|
164
|
+
attr_reader id: String
|
|
165
|
+
attr_reader payment_channel_id: String
|
|
166
|
+
attr_reader project_id: String
|
|
167
|
+
attr_reader payment_channel_external_id: String
|
|
168
|
+
attr_reader status: String
|
|
169
|
+
attr_reader is_final: bool
|
|
170
|
+
attr_reader is_test: bool
|
|
171
|
+
attr_reader currency: String
|
|
172
|
+
attr_reader network: String
|
|
173
|
+
attr_reader chain_id: Integer
|
|
174
|
+
attr_reader contract_address: String?
|
|
175
|
+
attr_reader gross: String
|
|
176
|
+
attr_reader fee: String
|
|
177
|
+
attr_reader net: String
|
|
178
|
+
attr_reader applied_fee_percent: Numeric
|
|
179
|
+
attr_reader customer_fee_percent: Integer
|
|
180
|
+
attr_reader tx_hash: String
|
|
181
|
+
attr_reader transfer_id: String
|
|
182
|
+
attr_reader source_address: String?
|
|
183
|
+
attr_reader destination_address: String
|
|
184
|
+
attr_reader block_height: Integer
|
|
185
|
+
attr_reader first_included_block_timestamp: Integer?
|
|
186
|
+
attr_reader explorer_url: String?
|
|
187
|
+
attr_reader created_at: Integer
|
|
188
|
+
attr_reader updated_at: Integer
|
|
189
|
+
attr_reader confirmed_at: Integer?
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
class PaymentChannelDepositFeedBlockage < Model
|
|
193
|
+
attr_reader deposit_id: String
|
|
194
|
+
attr_reader reason: String
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# next_cursor is NOT optional: the feed never ends, so an empty page still carries the cursor to
|
|
198
|
+
# resume from. Store it; never stop because a page came back empty.
|
|
199
|
+
class PaymentChannelDepositFeedPage < Model
|
|
200
|
+
attr_reader items: Array[PaymentChannelDeposit]
|
|
201
|
+
attr_reader next_cursor: String
|
|
202
|
+
attr_reader blocked: PaymentChannelDepositFeedBlockage?
|
|
203
|
+
end
|
|
204
|
+
|
|
136
205
|
class Balance < Model
|
|
137
206
|
attr_reader currency: String
|
|
138
207
|
attr_reader available: String
|
|
@@ -176,6 +245,22 @@ module Paymos
|
|
|
176
245
|
| (?max_pages: Integer, **untyped filters) -> Enumerator[Withdrawal, void]
|
|
177
246
|
end
|
|
178
247
|
|
|
248
|
+
class PaymentChannels
|
|
249
|
+
def create: (project_id: String, external_id: String) -> PaymentChannel
|
|
250
|
+
def get: (String payment_channel_id) -> PaymentChannel
|
|
251
|
+
def list: (?limit: Integer?, ?cursor: String?, ?status: String | Array[String]?, ?external_id: String?, ?project_id: String?) -> Page[PaymentChannel]
|
|
252
|
+
def block: (String payment_channel_id) -> PaymentChannel
|
|
253
|
+
def unblock: (String payment_channel_id) -> PaymentChannel
|
|
254
|
+
def simulate_deposit: (String payment_channel_id, amount: String, currency: String, network: String, ?stage: String?) -> PaymentChannelDeposit
|
|
255
|
+
def each: (?max_pages: Integer, **untyped filters) { (PaymentChannel) -> void } -> void
|
|
256
|
+
| (?max_pages: Integer, **untyped filters) -> Enumerator[PaymentChannel, void]
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
class PaymentChannelDeposits
|
|
260
|
+
def get: (String deposit_id) -> PaymentChannelDeposit
|
|
261
|
+
def read: (?limit: Integer?, ?cursor: String?, ?project_id: String?, ?payment_channel_id: String?, ?confirmed_from: Integer?) -> PaymentChannelDepositFeedPage
|
|
262
|
+
end
|
|
263
|
+
|
|
179
264
|
class Balances
|
|
180
265
|
def get: () -> Array[Balance]
|
|
181
266
|
end
|
|
@@ -187,6 +272,8 @@ module Paymos
|
|
|
187
272
|
class Client
|
|
188
273
|
attr_reader invoices: Invoices
|
|
189
274
|
attr_reader withdrawals: Withdrawals
|
|
275
|
+
attr_reader payment_channels: PaymentChannels
|
|
276
|
+
attr_reader payment_channel_deposits: PaymentChannelDeposits
|
|
190
277
|
attr_reader balances: Balances
|
|
191
278
|
attr_reader system: System
|
|
192
279
|
|