invoq 0.2.1 → 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: e4394aa795d6674da3055ce51a03de79b19602bc50139dc19bd00488b0bcc267
4
- data.tar.gz: 4bba9c3d77bf88951e393097b7b9e47301f42d668acdae4b93ac24adbcf9254b
3
+ metadata.gz: d6735a9a9a69b7d895e5195f2c996067a5e6abc84535da30bb9c1994b235b15d
4
+ data.tar.gz: 8882cfaa9d72e9beda66de5bf69a213058164e2b0043aeb2681351f0838a9ad6
5
5
  SHA512:
6
- metadata.gz: e6606f9c4a3f0043e1353f007da072d9cae06edf823e3f695e9aac80938010baa38bd98bbbeb24d12adb164bbfea2f25f38db62f2a8e14267282d3876bb9fd21
7
- data.tar.gz: 6beee35201584d88936d0d289301436eac266f87e0ef0a03889207fce892e72f36a50885314a365f63d93ca8ca714acf0889046150da1069f138469f63ccc48d
6
+ metadata.gz: a3ae2327b5bfed2af09db878361a2a15028f4943e905ec610884c1e36dc5cdacd75d2aa9385eda9f61dff1382739a58553a449f9233c2e4e160bf1136803f610
7
+ data.tar.gz: 43efaca949a9c6e2d9ddaf8d34a4381baf70a9a59eaf07902b32616269a57ec4b1aa7371bfdf49c99decaf0f0b2140ab641c8f4ebc9ef1cd5fca43e247fd013e
data/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # invoq Ruby SDK
2
2
 
3
- **English** · [Bahasa Indonesia](./docs/README.id.md) · [Español](./docs/README.es-419.md) · [Français](./docs/README.fr.md) · [Português](./docs/README.pt-BR.md) · [Tiếng Việt](./docs/README.vi.md) · [Türkçe](./docs/README.tr.md) · [ไทย](./docs/README.th.md) · [简体中文](./docs/README.zh-Hans.md) · [繁體中文](./docs/README.zh-Hant.md)
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
4
 
5
5
  Accept stablecoin payments with invoq from Ruby server code. This SDK wraps
6
6
  invoq server APIs and verifies signed webhooks.
@@ -8,6 +8,12 @@ invoq server APIs and verifies signed webhooks.
8
8
  Use this gem only on your server. It handles secret keys and should not be
9
9
  bundled into browser code.
10
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
+
11
17
  ## Server SDKs
12
18
 
13
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.
@@ -49,6 +55,9 @@ Requires Ruby 2.6 or newer.
49
55
  3. In your project's **webhooks** settings, save your webhook URL. The webhook
50
56
  secret (`whsec_...`) for that mode is shown once, when you first enable the
51
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`.
52
61
 
53
62
  Add both to your server environment:
54
63
 
@@ -97,7 +106,6 @@ Create an invoice:
97
106
  ```ruby
98
107
  invoice = invoq.invoices.create(
99
108
  amount: "129",
100
- currency: "USD",
101
109
  description: "SaaS boilerplate",
102
110
  reference_id: "order_1234",
103
111
  return_url: "https://merchant.example/thanks"
@@ -110,9 +118,9 @@ checkout_url = "https://pay.invoq.money/#{invoice_id}"
110
118
  Notes:
111
119
 
112
120
  - Use a server-side amount. Do not trust client-supplied amounts.
113
- - `amount` is a decimal USD string from `"0.01"` to `"999.99"` with up
114
- to 2 decimal places, such as `"129"` or `"129.99"`.
115
- - `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.
116
124
  - Use a stable, non-empty `reference_id` to map `invoice.paid` webhooks back to
117
125
  your order. Creating again with the same `reference_id` and invoice terms
118
126
  returns the existing invoice; different terms fail with a
@@ -131,12 +139,10 @@ Get an invoice:
131
139
  invoice = invoq.invoices.get("inv_123")
132
140
  ```
133
141
 
134
- `invoices.get` returns the public invoice shape used by hosted checkout. It
135
- includes fields such as `amount_paid`, `amount_due`, `amount_overpaid`,
136
- `payment_status`, `project`, `deposit_address`, `monitoring_ends_at`,
137
- `monitoring_status`, `transfers`, and `direct_onchain_rails`, but does not
138
- include `reference_id`. Use the create response or `invoice.paid` webhook when
139
- 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`.
140
146
 
141
147
  Create a test payment:
142
148
 
@@ -161,8 +167,7 @@ webhook URL.
161
167
 
162
168
  To receive webhooks on your machine, expose your local server with an HTTPS
163
169
  tunnel such as ngrok or cloudflared and save the tunnel URL as your test webhook
164
- URL in the dashboard. The dashboard can also send a signed `webhook.ping` to
165
- check connectivity.
170
+ URL in the dashboard.
166
171
 
167
172
  Each invoice method returns the response `data` object directly as a Ruby hash.
168
173
 
@@ -179,22 +184,40 @@ Share the link or redirect to it when an in-page checkout modal is not a fit.
179
184
  ## Inputs and responses
180
185
 
181
186
  The SDK checks that `amount` values and `invoice_id` arguments are non-empty
182
- strings before sending requests. The invoq API validates the amount format,
183
- range, and currency.
187
+ strings before sending requests. The invoq API validates the amount format and
188
+ range.
184
189
 
185
190
  Leave unset optional fields out of the request hash. When you include
186
191
  `description` or `reference_id`, pass a string. `return_url` can be a string or
187
- `nil`.
192
+ `nil`. Any other key in the hash is dropped rather than sent, because the API
193
+ rejects unknown body keys.
188
194
 
189
195
  Amounts in responses are normalized to 4 decimal places: create with `"129"`
190
196
  and the invoice returns `amount: "129.0000"`. Compare amounts numerically, not
191
197
  as strings. `amount_due` is derived as `max(amount - amount_paid, 0)` and uses
192
198
  the same 18-decimal scale as `amount_paid`; `amount_overpaid` is its mirror,
193
199
  `max(amount_paid - amount, 0)`, so you never subtract money yourself.
194
- `monitoring_status` is `"active"` or `"ended"` — once it is `"ended"`, the
195
- deposit address is no longer watchedand `transfers` is the confirmed
196
- on-chain receipt trail (each entry has `tx_hash`, `amount`, and
197
- `explorer_tx_url`). Both are `nil` / `[]` for test invoices.
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.
198
221
 
199
222
  ## Webhooks
200
223
 
@@ -229,6 +252,11 @@ def handle_invoq_webhook(env)
229
252
  fulfillment_key = invoice["reference_id"] || invoice.fetch("id")
230
253
 
231
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.
232
260
  end
233
261
 
234
262
  [
@@ -245,11 +273,16 @@ from browser results.
245
273
 
246
274
  When `Invoq.invoice_paid?(event)` is true, the invoice is ready for automatic
247
275
  fulfillment; use the invoice `reference_id` or a stored invoice `id` to find
248
- and fulfill your order. A `review_required` invoice does not emit an
249
- `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
250
278
  pending-review state and wait for a later `invoice.paid` webhook after review
251
279
  is approved.
252
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
+
253
286
  Important:
254
287
 
255
288
  - Pass the exact raw request body string received by your Ruby framework.
@@ -259,12 +292,19 @@ Important:
259
292
  - Use your webhook secret (`whsec_...`), not `INVOQ_SECRET_KEY`.
260
293
  - Make fulfillment idempotent. Retried webhook deliveries can send the same
261
294
  event more than once.
262
- - Respond with a 2xx quickly. Any other status counts as a failed delivery.
263
- Transient failures such as timeouts, `429`, and `5xx` responses are retried;
264
- 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`.
265
301
 
266
302
  `Invoq.invoice_paid?` accepts fulfillable `invoice.paid` events whose invoice
267
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.
268
308
 
269
309
  Webhook verification failures raise `Invoq::SignatureVerificationError`. The
270
310
  SDK allows a 5-minute timestamp tolerance. The signature header is:
@@ -277,7 +317,7 @@ invoq-signature: t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<raw body>">
277
317
 
278
318
  ```ruby
279
319
  begin
280
- invoq.invoices.create(amount: "0.001", currency: "USD")
320
+ invoq.invoices.create(amount: "0.001")
281
321
  rescue Invoq::ApiError => error
282
322
  warn error.status
283
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.1"
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,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: invoq
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.1
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - invoq