invoq 0.2.0 → 0.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 11bbb19ba09943897da6097ebf8d655bf8a658d4b3cf081eb3c5ccd177b2f6b3
4
- data.tar.gz: 91fb9ffc921bdb61f622d615a05a0d87efb6cb853db8b6e60eb3fc9007f7669f
3
+ metadata.gz: d6735a9a9a69b7d895e5195f2c996067a5e6abc84535da30bb9c1994b235b15d
4
+ data.tar.gz: 8882cfaa9d72e9beda66de5bf69a213058164e2b0043aeb2681351f0838a9ad6
5
5
  SHA512:
6
- metadata.gz: 066dd20b88afcdc0e1f5af02d3cf35a2bcbb1b2af19b96ea7cd87e2d8c2d88cb66582d5c4357853fc95543aa598ded42f3265561c057e2e997b2c390300c2ba6
7
- data.tar.gz: 850c9c77ca95c4b1df54a5c90fcc17cfd130f04c899562445852fdeb9a526d8ce5b7a2d444fc3bcc770c32827dac9c808314556c4fac3a9f394481f721638bd6
6
+ metadata.gz: a3ae2327b5bfed2af09db878361a2a15028f4943e905ec610884c1e36dc5cdacd75d2aa9385eda9f61dff1382739a58553a449f9233c2e4e160bf1136803f610
7
+ data.tar.gz: 43efaca949a9c6e2d9ddaf8d34a4381baf70a9a59eaf07902b32616269a57ec4b1aa7371bfdf49c99decaf0f0b2140ab641c8f4ebc9ef1cd5fca43e247fd013e
data/README.md CHANGED
@@ -1,11 +1,34 @@
1
1
  # invoq Ruby SDK
2
2
 
3
+ **English** · [Bahasa Indonesia](https://github.com/invoqmoney/sdk-ruby/blob/main/docs/README.id.md) · [Español](https://github.com/invoqmoney/sdk-ruby/blob/main/docs/README.es-419.md) · [Français](https://github.com/invoqmoney/sdk-ruby/blob/main/docs/README.fr.md) · [Português](https://github.com/invoqmoney/sdk-ruby/blob/main/docs/README.pt-BR.md) · [Tiếng Việt](https://github.com/invoqmoney/sdk-ruby/blob/main/docs/README.vi.md) · [Türkçe](https://github.com/invoqmoney/sdk-ruby/blob/main/docs/README.tr.md) · [ไทย](https://github.com/invoqmoney/sdk-ruby/blob/main/docs/README.th.md) · [简体中文](https://github.com/invoqmoney/sdk-ruby/blob/main/docs/README.zh-Hans.md) · [繁體中文](https://github.com/invoqmoney/sdk-ruby/blob/main/docs/README.zh-Hant.md)
4
+
3
5
  Accept stablecoin payments with invoq from Ruby server code. This SDK wraps
4
6
  invoq server APIs and verifies signed webhooks.
5
7
 
6
8
  Use this gem only on your server. It handles secret keys and should not be
7
9
  bundled into browser code.
8
10
 
11
+ **Coding with AI? Paste this.**
12
+
13
+ ```
14
+ Add stablecoin payments to my project with invoq. Start in test mode. Read the docs before you write any code: https://invoq.money/llms.txt
15
+ ```
16
+
17
+ ## Server SDKs
18
+
19
+ Create invoices and verify webhooks from your backend in any of these languages — same REST API, same webhook signature. This repository is the Ruby SDK.
20
+
21
+ | Language | Repository |
22
+ | --- | --- |
23
+ | Node.js | [github.com/invoqmoney/sdk-js](https://github.com/invoqmoney/sdk-js) (`@invoq/server`) |
24
+ | Python | [github.com/invoqmoney/sdk-python](https://github.com/invoqmoney/sdk-python) |
25
+ | PHP | [github.com/invoqmoney/sdk-php](https://github.com/invoqmoney/sdk-php) |
26
+ | Go | [github.com/invoqmoney/sdk-go](https://github.com/invoqmoney/sdk-go) |
27
+ | Rust | [github.com/invoqmoney/sdk-rust](https://github.com/invoqmoney/sdk-rust) |
28
+ | Ruby | **this repo** |
29
+
30
+ The browser side is the same for every backend: **`@invoq/checkout`** (JavaScript, in [github.com/invoqmoney/sdk-js](https://github.com/invoqmoney/sdk-js)) opens the in-page checkout modal for any frontend.
31
+
9
32
  ## Installation
10
33
 
11
34
  Install the gem:
@@ -32,6 +55,9 @@ Requires Ruby 2.6 or newer.
32
55
  3. In your project's **webhooks** settings, save your webhook URL. The webhook
33
56
  secret (`whsec_...`) for that mode is shown once, when you first enable the
34
57
  webhook. Store it right away. Webhook URLs must be public HTTPS URLs.
58
+ 4. Set up your **Receiving wallet** before going live. Test invoices don't need
59
+ one; a live invoice with nowhere to settle fails with
60
+ `409 no_payment_options_available`.
35
61
 
36
62
  Add both to your server environment:
37
63
 
@@ -80,7 +106,6 @@ Create an invoice:
80
106
  ```ruby
81
107
  invoice = invoq.invoices.create(
82
108
  amount: "129",
83
- currency: "USD",
84
109
  description: "SaaS boilerplate",
85
110
  reference_id: "order_1234",
86
111
  return_url: "https://merchant.example/thanks"
@@ -93,9 +118,9 @@ checkout_url = "https://pay.invoq.money/#{invoice_id}"
93
118
  Notes:
94
119
 
95
120
  - Use a server-side amount. Do not trust client-supplied amounts.
96
- - `amount` is a decimal USD string from `"0.01"` to `"999.99"` with up
97
- to 2 decimal places, such as `"129"` or `"129.99"`.
98
- - `currency` is optional and defaults to `"USD"`.
121
+ - `amount` is a decimal USD string from `"0.01"` to `"1000000.00"` with up
122
+ to 2 decimal places, such as `"129"` or `"129.99"`. Currency is always USD,
123
+ and test or live comes from the key — neither is a request field.
99
124
  - Use a stable, non-empty `reference_id` to map `invoice.paid` webhooks back to
100
125
  your order. Creating again with the same `reference_id` and invoice terms
101
126
  returns the existing invoice; different terms fail with a
@@ -114,11 +139,10 @@ Get an invoice:
114
139
  invoice = invoq.invoices.get("inv_123")
115
140
  ```
116
141
 
117
- `invoices.get` returns the public invoice shape used by hosted checkout. It
118
- includes fields such as `amount_paid`, `amount_due`, `payment_status`,
119
- `project`, `deposit_address`, `monitoring_ends_at`, and `direct_onchain_rails`,
120
- but does not include `reference_id`. Use the create response or
121
- `invoice.paid` webhook when you need your merchant `reference_id`.
142
+ `invoices.get` returns the public invoice shape used by hosted checkout: the
143
+ create shape plus `amount_paid`, `project`, and `transfers`, minus
144
+ `reference_id`. Use the create response or the `invoice.paid` webhook when you
145
+ need your merchant `reference_id`.
122
146
 
123
147
  Create a test payment:
124
148
 
@@ -143,8 +167,7 @@ webhook URL.
143
167
 
144
168
  To receive webhooks on your machine, expose your local server with an HTTPS
145
169
  tunnel such as ngrok or cloudflared and save the tunnel URL as your test webhook
146
- URL in the dashboard. The dashboard can also send a signed `webhook.ping` to
147
- check connectivity.
170
+ URL in the dashboard.
148
171
 
149
172
  Each invoice method returns the response `data` object directly as a Ruby hash.
150
173
 
@@ -161,17 +184,40 @@ Share the link or redirect to it when an in-page checkout modal is not a fit.
161
184
  ## Inputs and responses
162
185
 
163
186
  The SDK checks that `amount` values and `invoice_id` arguments are non-empty
164
- strings before sending requests. The invoq API validates the amount format,
165
- range, and currency.
187
+ strings before sending requests. The invoq API validates the amount format and
188
+ range.
166
189
 
167
190
  Leave unset optional fields out of the request hash. When you include
168
191
  `description` or `reference_id`, pass a string. `return_url` can be a string or
169
- `nil`.
192
+ `nil`. Any other key in the hash is dropped rather than sent, because the API
193
+ rejects unknown body keys.
170
194
 
171
195
  Amounts in responses are normalized to 4 decimal places: create with `"129"`
172
196
  and the invoice returns `amount: "129.0000"`. Compare amounts numerically, not
173
197
  as strings. `amount_due` is derived as `max(amount - amount_paid, 0)` and uses
174
- the same 18-decimal scale as `amount_paid`.
198
+ the same 18-decimal scale as `amount_paid`; `amount_overpaid` is its mirror,
199
+ `max(amount_paid - amount, 0)`, so you never subtract money yourself.
200
+
201
+ Two status fields. `status` is the accounting one — `unpaid`, `partially_paid`,
202
+ `paid`, `settling`, `settled`, `review_required` — where the three paid-like
203
+ values differ only in how far the funds have moved to your wallet.
204
+ `checkout_status` is payer-facing — `open`, `confirming`, `expired`, `paid`,
205
+ `unavailable` — and never authorizes fulfillment. `payment_revision` is a
206
+ non-negative integer that increments whenever the confirmed payment set changes,
207
+ so you can discard a snapshot older than one you already hold.
208
+
209
+ `payment_options` holds the payment instructions, fixed at creation and `[]` in
210
+ test mode. Entries are discriminated by `status`, then `collection_method`: only
211
+ `"ready"` is payable, `"evm_deposit"` carries `deposit_address` and
212
+ `suggested_amount`, `"direct_exact"` carries `recipient_address` and an
213
+ `exact_amount` the buyer must send to the digit. `transfers` is the confirmed
214
+ receipt trail — `transaction_id`, `event_index`, `amount`,
215
+ `explorer_transaction_url` — and stays `[]` until a payment confirms. Full field
216
+ reference: [REST API docs](https://github.com/invoqmoney/api).
217
+
218
+ Identify a payment option by `chain_namespace`, `chain_reference`, and
219
+ `token_address`, never by its position in the array. `monitoring_ends_at` is the
220
+ end of the payment window, and is `nil` for test invoices.
175
221
 
176
222
  ## Webhooks
177
223
 
@@ -206,6 +252,11 @@ def handle_invoq_webhook(env)
206
252
  fulfillment_key = invoice["reference_id"] || invoice.fetch("id")
207
253
 
208
254
  # Fulfill the order for fulfillment_key idempotently.
255
+ elsif Invoq.invoice_payment_reversed?(event)
256
+ invoice = event.fetch("data").fetch("invoice")
257
+ fulfillment_key = invoice["reference_id"] || invoice.fetch("id")
258
+
259
+ # Hold or reverse the order for fulfillment_key.
209
260
  end
210
261
 
211
262
  [
@@ -222,11 +273,16 @@ from browser results.
222
273
 
223
274
  When `Invoq.invoice_paid?(event)` is true, the invoice is ready for automatic
224
275
  fulfillment; use the invoice `reference_id` or a stored invoice `id` to find
225
- and fulfill your order. A `review_required` invoice does not emit an
226
- `invoice.paid` webhook yet. If checkout reports `review_required`, show a
276
+ and fulfill your order. A `review_required` invoice emits no `invoice.paid`
277
+ until the review clears. If checkout reports `review_required`, show a
227
278
  pending-review state and wait for a later `invoice.paid` webhook after review
228
279
  is approved.
229
280
 
281
+ invoq also sends `invoice.payment_reversed` when a previously paid invoice drops
282
+ back below its amount — a chain reorg dropping a confirmed transfer, for
283
+ example. Catch it with `Invoq.invoice_payment_reversed?(event)` and hold or
284
+ reverse the fulfillment according to your own policy.
285
+
230
286
  Important:
231
287
 
232
288
  - Pass the exact raw request body string received by your Ruby framework.
@@ -236,12 +292,19 @@ Important:
236
292
  - Use your webhook secret (`whsec_...`), not `INVOQ_SECRET_KEY`.
237
293
  - Make fulfillment idempotent. Retried webhook deliveries can send the same
238
294
  event more than once.
239
- - Respond with a 2xx quickly. Any other status counts as a failed delivery.
240
- Transient failures such as timeouts, `429`, and `5xx` responses are retried;
241
- other `4xx` responses are not.
295
+ - Respond with a 2xx quickly. Any other status counts as a failed delivery and
296
+ is retried, including redirects and `4xx`, so a deploy window or a temporarily
297
+ misrouted path is retried rather than dropped. The ladder is 1 minute,
298
+ 5 minutes, 30 minutes, then 2 hours, for 5 attempts in total.
299
+ - Deliveries can arrive out of order. Keep the snapshot with the highest
300
+ `payment_revision`.
242
301
 
243
302
  `Invoq.invoice_paid?` accepts fulfillable `invoice.paid` events whose invoice
244
303
  status is `paid`, `settling`, or `settled`; it rejects `review_required`.
304
+ `Invoq.invoice_payment_reversed?` accepts `invoice.payment_reversed` events
305
+ without checking the status at all: a reversal you drop leaves an order
306
+ fulfilled on a payment that no longer exists. An event type this SDK version
307
+ does not model still verifies and is returned as-is.
245
308
 
246
309
  Webhook verification failures raise `Invoq::SignatureVerificationError`. The
247
310
  SDK allows a 5-minute timestamp tolerance. The signature header is:
@@ -254,7 +317,7 @@ invoq-signature: t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<raw body>">
254
317
 
255
318
  ```ruby
256
319
  begin
257
- invoq.invoices.create(amount: "0.001", currency: "USD")
320
+ invoq.invoices.create(amount: "0.001")
258
321
  rescue Invoq::ApiError => error
259
322
  warn error.status
260
323
  warn error.code
data/lib/invoq/client.rb CHANGED
@@ -18,6 +18,12 @@ module Invoq
18
18
  raise Error, "invoq API key must be a non-empty string."
19
19
  end
20
20
 
21
+ # A control character in a key is either rejected deep in the transport or
22
+ # silently sent; reject it here so every SDK answers the same way.
23
+ if api_key.match?(/[\x00-\x1F\x7F]/)
24
+ raise Error, "invoq API key must not contain control characters."
25
+ end
26
+
21
27
  @api_key = api_key
22
28
  @api_origin = Invoq.normalize_api_origin(api_origin)
23
29
  @timeout_ms = Invoq.normalize_timeout_ms(timeout_ms)
@@ -51,6 +51,15 @@ module Invoq
51
51
  )
52
52
  end
53
53
 
54
+ # A non-Hash data is a broken envelope; returning it defers the failure
55
+ # to the caller's first read.
56
+ unless payload["data"].is_a?(Hash)
57
+ raise Error.new(
58
+ "invoq API response data envelope was not an object.",
59
+ payload: payload
60
+ )
61
+ end
62
+
54
63
  payload["data"]
55
64
  rescue JSON::GeneratorError
56
65
  raise Error, "Failed to encode invoq API request."
@@ -116,8 +125,11 @@ module Invoq
116
125
  fields = value.each_with_object([]) do |field, result|
117
126
  next unless field.is_a?(Hash)
118
127
 
128
+ # A location this version does not know is passed through, not
129
+ # dropped: the caller is already on an error path and needs the code
130
+ # and message. Only a structurally invalid entry is discarded.
119
131
  location = field["location"]
120
- next unless %w[query path body header].include?(location)
132
+ next unless location.is_a?(String)
121
133
  next unless field["field"].is_a?(String)
122
134
  next unless field["code"].is_a?(String)
123
135
  next unless field["message"].is_a?(String)
@@ -24,7 +24,7 @@ module Invoq
24
24
  end
25
25
 
26
26
  def get(invoice_id)
27
- id = encode_path_segment(required_request_string(invoice_id, "invoice_id"))
27
+ id = encode_path_segment(required_path_segment(invoice_id, "invoice_id"))
28
28
 
29
29
  Internal::Request.json(
30
30
  api_key: @api_key,
@@ -36,7 +36,7 @@ module Invoq
36
36
  end
37
37
 
38
38
  def create_test_payment(invoice_id, input)
39
- id = encode_path_segment(required_request_string(invoice_id, "invoice_id"))
39
+ id = encode_path_segment(required_path_segment(invoice_id, "invoice_id"))
40
40
 
41
41
  Internal::Request.json(
42
42
  api_key: @api_key,
@@ -54,15 +54,16 @@ module Invoq
54
54
  raise Error, "request body must be a hash."
55
55
  end
56
56
 
57
+ # Only these four fields exist: the API rejects unknown body keys, and
58
+ # currency (always USD) and mode (from the key) are not request fields,
59
+ # so anything else in the hash is dropped rather than forwarded.
57
60
  body = {
58
61
  "amount" => required_request_string(input_value(input, "amount"), "amount")
59
62
  }
60
- currency = optional_request_value(input, "currency")
61
63
  description = optional_request_string(input, "description")
62
64
  reference_id = optional_request_string(input, "reference_id")
63
65
  return_url = optional_nullable_request_string(input, "return_url")
64
66
 
65
- body["currency"] = currency unless currency.equal?(MISSING)
66
67
  body["description"] = description unless description.equal?(MISSING)
67
68
  body["reference_id"] = reference_id unless reference_id.equal?(MISSING)
68
69
  body["return_url"] = return_url unless return_url.equal?(MISSING)
@@ -107,6 +108,18 @@ module Invoq
107
108
  value
108
109
  end
109
110
 
111
+ # A URL resolver pops "." and "..", so an id of either would call a
112
+ # different endpoint instead of 404ing. Percent-encoding is no help.
113
+ def required_path_segment(value, field)
114
+ segment = required_request_string(value, field)
115
+
116
+ if segment == "." || segment == ".."
117
+ raise Error, "#{field} must not be a path segment that resolves ('.' or '..')."
118
+ end
119
+
120
+ segment
121
+ end
122
+
110
123
  def required_request_string(value, field)
111
124
  unless value.is_a?(String) && !value.strip.empty?
112
125
  raise Error, "#{field} must be a non-empty string."
data/lib/invoq/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Invoq
4
- VERSION = "0.2.0"
4
+ VERSION = "0.3.0"
5
5
  end
@@ -56,39 +56,65 @@ module Invoq
56
56
  end
57
57
 
58
58
  def self.invoice_paid?(event)
59
- return false unless event.is_a?(Hash)
60
- return false unless event["type"] == "invoice.paid"
61
- return false unless event["id"].is_a?(String)
62
- return false unless invoice_mode?(event["mode"])
63
- return false unless event["created_at"].is_a?(String)
59
+ invoice = lifecycle_event_invoice(event, "invoice.paid")
60
+
61
+ # Paid-equivalent statuses only: review_required has money against it but
62
+ # is not cleared for fulfillment.
63
+ !invoice.nil? && invoice_paid_status?(invoice["status"])
64
+ end
65
+
66
+ def self.is_invoice_paid(event)
67
+ invoice_paid?(event)
68
+ end
69
+
70
+ def self.invoice_payment_reversed?(event)
71
+ # No status check, unlike the paid guard: rejecting an unrecognized status
72
+ # would drop the event and leave the order fulfilled on a payment that no
73
+ # longer exists.
74
+ !lifecycle_event_invoice(event, "invoice.payment_reversed").nil?
75
+ end
76
+
77
+ def self.is_invoice_payment_reversed(event)
78
+ invoice_payment_reversed?(event)
79
+ end
80
+
81
+ # The fields both lifecycle events share, returned so each guard can apply
82
+ # its own status rule. nil when this is not a well-formed event of that type.
83
+ def self.lifecycle_event_invoice(event, type)
84
+ return nil unless event.is_a?(Hash)
85
+ return nil unless event["type"] == type
86
+ return nil unless event["id"].is_a?(String)
87
+ return nil unless invoice_mode?(event["mode"])
88
+ return nil unless event["created_at"].is_a?(String)
64
89
 
65
90
  data = event["data"]
66
- return false unless data.is_a?(Hash)
91
+ return nil unless data.is_a?(Hash)
67
92
 
68
93
  invoice = data["invoice"]
69
- return false unless invoice.is_a?(Hash)
94
+ return nil unless invoice.is_a?(Hash)
70
95
 
71
96
  reference_id = invoice.key?("reference_id") ? invoice["reference_id"] : MISSING
72
97
  fully_paid_at = invoice.key?("fully_paid_at") ? invoice["fully_paid_at"] : MISSING
73
98
 
74
- invoice["id"].is_a?(String) &&
99
+ valid = invoice["id"].is_a?(String) &&
75
100
  invoice_mode?(invoice["mode"]) &&
76
- invoice_paid_status?(invoice["status"]) &&
101
+ invoice["status"].is_a?(String) &&
77
102
  invoice["amount"].is_a?(String) &&
78
103
  invoice["currency"] == "USD" &&
79
104
  invoice["amount_paid"].is_a?(String) &&
80
105
  (reference_id.is_a?(String) || reference_id.nil?) &&
106
+ invoice["payment_revision"].is_a?(Integer) &&
81
107
  (fully_paid_at.is_a?(String) || fully_paid_at.nil?)
82
- end
83
108
 
84
- def self.is_invoice_paid(event)
85
- invoice_paid?(event)
109
+ valid ? invoice : nil
86
110
  end
111
+ private_class_method :lifecycle_event_invoice
87
112
 
88
113
  def self.parse_signature_header(signature_header)
89
114
  parts = {}
90
115
 
91
- signature_header.split(",").each do |part|
116
+ # -1 keeps trailing empty fields, so a trailing comma reaches the check below.
117
+ signature_header.split(",", -1).each do |part|
92
118
  separator_index = part.index("=")
93
119
 
94
120
  unless separator_index
@@ -146,7 +172,7 @@ module Invoq
146
172
  def self.hmac_sha256_hex(secret, timestamp, raw_body)
147
173
  OpenSSL::HMAC.hexdigest(
148
174
  "SHA256",
149
- secret.encode("UTF-8"),
175
+ secret.b,
150
176
  "#{timestamp}.".b + body_bytes(raw_body)
151
177
  )
152
178
  end
data/lib/invoq.rb CHANGED
@@ -21,4 +21,12 @@ module Invoq
21
21
  def self.is_invoice_paid(event)
22
22
  invoice_paid?(event)
23
23
  end
24
+
25
+ def self.invoice_payment_reversed?(event)
26
+ Webhooks.invoice_payment_reversed?(event)
27
+ end
28
+
29
+ def self.is_invoice_payment_reversed(event)
30
+ invoice_payment_reversed?(event)
31
+ end
24
32
  end
metadata CHANGED
@@ -1,14 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: invoq
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - invoq
8
- autorequire:
9
8
  bindir: bin
10
9
  cert_chain: []
11
- date: 2026-07-08 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
12
11
  dependencies:
13
12
  - !ruby/object:Gem::Dependency
14
13
  name: minitest
@@ -45,7 +44,6 @@ dependencies:
45
44
  - !ruby/object:Gem::Version
46
45
  version: '14.0'
47
46
  description: Ruby SDK for invoq server APIs and webhook verification.
48
- email:
49
47
  executables: []
50
48
  extensions: []
51
49
  extra_rdoc_files: []
@@ -64,7 +62,6 @@ licenses:
64
62
  - MIT
65
63
  metadata:
66
64
  allowed_push_host: https://rubygems.org
67
- post_install_message:
68
65
  rdoc_options: []
69
66
  require_paths:
70
67
  - lib
@@ -79,8 +76,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
79
76
  - !ruby/object:Gem::Version
80
77
  version: '0'
81
78
  requirements: []
82
- rubygems_version: 3.0.3.1
83
- signing_key:
79
+ rubygems_version: 4.0.16
84
80
  specification_version: 4
85
81
  summary: Ruby SDK for invoq stablecoin payment acceptance.
86
82
  test_files: []