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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 170d4a77262048eb627400e9019deb92e25d0c6d3e73a49d5e477cc432a00baf
4
- data.tar.gz: bb92e9a4e584630a22271c93512ad094ec7cd5b47921c90ed9df19e0780fee3e
3
+ metadata.gz: 53529447a1b6190b8df0810a738fe95a68dbfcbb40bfc00eb0884bd57b191992
4
+ data.tar.gz: b94adfdafc75ba8e72e799dbd5df529c9926802553c7e2f17910a56fff4fa68b
5
5
  SHA512:
6
- metadata.gz: '084533c7bcc70a3eb7b1a50b8b2ca3e6617d2de251a19b8c9a04e6a4e8d9817898c21c1587977111afbb86ddcedc464d348d1739cb2d40fd01d023c01c00d528'
7
- data.tar.gz: 0a7d4bfc9102170947b98c356c3b97df9ec405056854d1040676024e0fd7371d8a0115975087a1171f3324e73a1ed533e0a3f50a82761783e57a630a609417db
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. Its only runtime dependency is
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("HTTP_PAYMOS_SIGNATURE"), request.body.read)
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
- raise ConfigurationError, 'base_url is required' if base_url.to_s.strip.empty?
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 => AuthenticationError,
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Paymos
4
- VERSION = '1.1.1'
4
+ VERSION = '1.4.0'
5
5
  end
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
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: paymos
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.1.1
4
+ version: 1.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Paymos Labs