invoq 0.1.0 → 0.2.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/LICENSE.txt +2 -2
- data/README.md +272 -6
- data/lib/invoq/client.rb +85 -0
- data/lib/invoq/errors.rb +33 -0
- data/lib/invoq/internal/request.rb +138 -0
- data/lib/invoq/invoices_resource.rb +153 -0
- data/lib/invoq/version.rb +1 -1
- data/lib/invoq/webhooks.rb +198 -0
- data/lib/invoq.rb +18 -1
- metadata +45 -6
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 11bbb19ba09943897da6097ebf8d655bf8a658d4b3cf081eb3c5ccd177b2f6b3
|
|
4
|
+
data.tar.gz: 91fb9ffc921bdb61f622d615a05a0d87efb6cb853db8b6e60eb3fc9007f7669f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 066dd20b88afcdc0e1f5af02d3cf35a2bcbb1b2af19b96ea7cd87e2d8c2d88cb66582d5c4357853fc95543aa598ded42f3265561c057e2e997b2c390300c2ba6
|
|
7
|
+
data.tar.gz: 850c9c77ca95c4b1df54a5c90fcc17cfd130f04c899562445852fdeb9a526d8ce5b7a2d444fc3bcc770c32827dac9c808314556c4fac3a9f394481f721638bd6
|
data/LICENSE.txt
CHANGED
data/README.md
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
|
-
#
|
|
1
|
+
# invoq Ruby SDK
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Accept stablecoin payments with invoq from Ruby server code. This SDK wraps
|
|
4
|
+
invoq server APIs and verifies signed webhooks.
|
|
4
5
|
|
|
5
|
-
|
|
6
|
+
Use this gem only on your server. It handles secret keys and should not be
|
|
7
|
+
bundled into browser code.
|
|
6
8
|
|
|
7
9
|
## Installation
|
|
8
10
|
|
|
@@ -18,12 +20,276 @@ Or add it to a Gemfile:
|
|
|
18
20
|
gem "invoq"
|
|
19
21
|
```
|
|
20
22
|
|
|
21
|
-
|
|
23
|
+
Requires Ruby 2.6 or newer.
|
|
24
|
+
|
|
25
|
+
## Get your keys
|
|
26
|
+
|
|
27
|
+
1. Sign in to the [invoq dashboard](https://app.invoq.money) and create a
|
|
28
|
+
project.
|
|
29
|
+
2. On the **API keys** page, create a secret key. Test keys start with
|
|
30
|
+
`sk_test_`, live keys with `sk_live_`. The key mode determines whether
|
|
31
|
+
invoices are test or live.
|
|
32
|
+
3. In your project's **webhooks** settings, save your webhook URL. The webhook
|
|
33
|
+
secret (`whsec_...`) for that mode is shown once, when you first enable the
|
|
34
|
+
webhook. Store it right away. Webhook URLs must be public HTTPS URLs.
|
|
35
|
+
|
|
36
|
+
Add both to your server environment:
|
|
37
|
+
|
|
38
|
+
```sh
|
|
39
|
+
INVOQ_SECRET_KEY=sk_test_...
|
|
40
|
+
INVOQ_WEBHOOK_SECRET=whsec_...
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Start with test keys. Switch to the live key and live webhook secret when you
|
|
44
|
+
go to production.
|
|
45
|
+
|
|
46
|
+
## Create a client
|
|
22
47
|
|
|
23
48
|
```ruby
|
|
24
49
|
require "invoq"
|
|
25
50
|
|
|
26
|
-
Invoq
|
|
51
|
+
invoq = Invoq.new(ENV.fetch("INVOQ_SECRET_KEY"))
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Default production API origin:
|
|
55
|
+
|
|
56
|
+
```txt
|
|
57
|
+
https://api.invoq.money
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Override the API origin and request timeout during development:
|
|
61
|
+
|
|
62
|
+
```ruby
|
|
63
|
+
invoq = Invoq.new(
|
|
64
|
+
ENV.fetch("INVOQ_SECRET_KEY"),
|
|
65
|
+
api_origin: "http://localhost:8787",
|
|
66
|
+
timeout_ms: 10_000
|
|
67
|
+
)
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
`api_origin` must be an absolute `http` or `https` origin with no path, query,
|
|
71
|
+
hash, username, or password. The SDK appends `/v1/...` resource paths.
|
|
72
|
+
|
|
73
|
+
Requests time out after 10 seconds by default. Pass `timeout_ms` to change the
|
|
74
|
+
timeout. `timeout_ms` must be a positive integer in milliseconds.
|
|
75
|
+
|
|
76
|
+
## Invoices
|
|
77
|
+
|
|
78
|
+
Create an invoice:
|
|
79
|
+
|
|
80
|
+
```ruby
|
|
81
|
+
invoice = invoq.invoices.create(
|
|
82
|
+
amount: "129",
|
|
83
|
+
currency: "USD",
|
|
84
|
+
description: "SaaS boilerplate",
|
|
85
|
+
reference_id: "order_1234",
|
|
86
|
+
return_url: "https://merchant.example/thanks"
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
invoice_id = invoice.fetch("id")
|
|
90
|
+
checkout_url = "https://pay.invoq.money/#{invoice_id}"
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Notes:
|
|
94
|
+
|
|
95
|
+
- 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"`.
|
|
99
|
+
- Use a stable, non-empty `reference_id` to map `invoice.paid` webhooks back to
|
|
100
|
+
your order. Creating again with the same `reference_id` and invoice terms
|
|
101
|
+
returns the existing invoice; different terms fail with a
|
|
102
|
+
`409 reference_id_conflict` API error.
|
|
103
|
+
- If you fulfill by invoice ID instead of `reference_id`, store `invoice_id`
|
|
104
|
+
with your order when you create the invoice.
|
|
105
|
+
- Omit `return_url` to use the project's default return URL. Pass `nil` to send
|
|
106
|
+
JSON `null` and create the invoice without a return URL. On `reference_id`
|
|
107
|
+
retries, pass `return_url` explicitly when you need to assert a specific
|
|
108
|
+
value.
|
|
109
|
+
- `description` and `reference_id` must be strings when present.
|
|
110
|
+
|
|
111
|
+
Get an invoice:
|
|
112
|
+
|
|
113
|
+
```ruby
|
|
114
|
+
invoice = invoq.invoices.get("inv_123")
|
|
115
|
+
```
|
|
116
|
+
|
|
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`.
|
|
122
|
+
|
|
123
|
+
Create a test payment:
|
|
124
|
+
|
|
125
|
+
```ruby
|
|
126
|
+
paid_invoice = invoq.invoices.create_test_payment(
|
|
127
|
+
"inv_123",
|
|
128
|
+
amount: "129",
|
|
129
|
+
reference_id: "test_payment_001"
|
|
130
|
+
)
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
Test invoices cannot receive real funds. Simulate payments from your server
|
|
134
|
+
instead.
|
|
135
|
+
|
|
136
|
+
`create_test_payment` only works for invoices created with a `sk_test_` key.
|
|
137
|
+
Partial amounts are allowed and produce `partially_paid`; when payments reach
|
|
138
|
+
the invoice amount, invoq sends a signed `invoice.paid` webhook to your test
|
|
139
|
+
webhook URL.
|
|
140
|
+
|
|
141
|
+
`reference_id` is optional for test payments. Omit it when unset; do not pass
|
|
142
|
+
`nil`.
|
|
143
|
+
|
|
144
|
+
To receive webhooks on your machine, expose your local server with an HTTPS
|
|
145
|
+
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.
|
|
148
|
+
|
|
149
|
+
Each invoice method returns the response `data` object directly as a Ruby hash.
|
|
150
|
+
|
|
151
|
+
## Hosted checkout page
|
|
152
|
+
|
|
153
|
+
Every invoice also has a hosted checkout page at:
|
|
154
|
+
|
|
155
|
+
```txt
|
|
156
|
+
https://pay.invoq.money/<invoice id>
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
Share the link or redirect to it when an in-page checkout modal is not a fit.
|
|
160
|
+
|
|
161
|
+
## Inputs and responses
|
|
162
|
+
|
|
163
|
+
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.
|
|
166
|
+
|
|
167
|
+
Leave unset optional fields out of the request hash. When you include
|
|
168
|
+
`description` or `reference_id`, pass a string. `return_url` can be a string or
|
|
169
|
+
`nil`.
|
|
170
|
+
|
|
171
|
+
Amounts in responses are normalized to 4 decimal places: create with `"129"`
|
|
172
|
+
and the invoice returns `amount: "129.0000"`. Compare amounts numerically, not
|
|
173
|
+
as strings. `amount_due` is derived as `max(amount - amount_paid, 0)` and uses
|
|
174
|
+
the same 18-decimal scale as `amount_paid`.
|
|
175
|
+
|
|
176
|
+
## Webhooks
|
|
177
|
+
|
|
178
|
+
Pass the raw request body to `verify_webhook`. Do not parse JSON and encode it
|
|
179
|
+
again before verification.
|
|
180
|
+
|
|
181
|
+
This Rack example returns `[status, headers, body]`. In Rails, use
|
|
182
|
+
`request.raw_post` and `request.get_header("HTTP_INVOQ_SIGNATURE")`; in Sinatra
|
|
183
|
+
or another Ruby framework, use the framework's raw request body and exposed
|
|
184
|
+
`invoq-signature` or `HTTP_INVOQ_SIGNATURE` header.
|
|
185
|
+
|
|
186
|
+
```ruby
|
|
187
|
+
def handle_invoq_webhook(env)
|
|
188
|
+
raw_body = env.fetch("rack.input").read
|
|
189
|
+
|
|
190
|
+
begin
|
|
191
|
+
event = Invoq.verify_webhook(
|
|
192
|
+
raw_body,
|
|
193
|
+
{ "invoq-signature" => env["HTTP_INVOQ_SIGNATURE"] },
|
|
194
|
+
ENV.fetch("INVOQ_WEBHOOK_SECRET")
|
|
195
|
+
)
|
|
196
|
+
rescue Invoq::SignatureVerificationError
|
|
197
|
+
return [
|
|
198
|
+
400,
|
|
199
|
+
{ "content-type" => "application/json" },
|
|
200
|
+
['{"error":"invalid signature"}']
|
|
201
|
+
]
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
if Invoq.invoice_paid?(event)
|
|
205
|
+
invoice = event.fetch("data").fetch("invoice")
|
|
206
|
+
fulfillment_key = invoice["reference_id"] || invoice.fetch("id")
|
|
207
|
+
|
|
208
|
+
# Fulfill the order for fulfillment_key idempotently.
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
[
|
|
212
|
+
200,
|
|
213
|
+
{ "content-type" => "application/json" },
|
|
214
|
+
['{"received":true}']
|
|
215
|
+
]
|
|
216
|
+
end
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
Use `invoice.paid` webhooks to fulfill orders on your server. Browser checkout
|
|
220
|
+
results are only for updating the customer experience; do not fulfill orders
|
|
221
|
+
from browser results.
|
|
222
|
+
|
|
223
|
+
When `Invoq.invoice_paid?(event)` is true, the invoice is ready for automatic
|
|
224
|
+
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
|
|
227
|
+
pending-review state and wait for a later `invoice.paid` webhook after review
|
|
228
|
+
is approved.
|
|
229
|
+
|
|
230
|
+
Important:
|
|
231
|
+
|
|
232
|
+
- Pass the exact raw request body string received by your Ruby framework.
|
|
233
|
+
- Pass the `invoq-signature` header.
|
|
234
|
+
- `verify_webhook` does not require `Invoq.new(...)` or your invoq API secret
|
|
235
|
+
key.
|
|
236
|
+
- Use your webhook secret (`whsec_...`), not `INVOQ_SECRET_KEY`.
|
|
237
|
+
- Make fulfillment idempotent. Retried webhook deliveries can send the same
|
|
238
|
+
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.
|
|
242
|
+
|
|
243
|
+
`Invoq.invoice_paid?` accepts fulfillable `invoice.paid` events whose invoice
|
|
244
|
+
status is `paid`, `settling`, or `settled`; it rejects `review_required`.
|
|
245
|
+
|
|
246
|
+
Webhook verification failures raise `Invoq::SignatureVerificationError`. The
|
|
247
|
+
SDK allows a 5-minute timestamp tolerance. The signature header is:
|
|
248
|
+
|
|
249
|
+
```txt
|
|
250
|
+
invoq-signature: t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<raw body>">
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
## Errors
|
|
254
|
+
|
|
255
|
+
```ruby
|
|
256
|
+
begin
|
|
257
|
+
invoq.invoices.create(amount: "0.001", currency: "USD")
|
|
258
|
+
rescue Invoq::ApiError => error
|
|
259
|
+
warn error.status
|
|
260
|
+
warn error.code
|
|
261
|
+
warn error.fields
|
|
262
|
+
warn error.meta
|
|
263
|
+
warn error.payload
|
|
264
|
+
rescue Invoq::Error
|
|
265
|
+
raise
|
|
266
|
+
end
|
|
27
267
|
```
|
|
28
268
|
|
|
29
|
-
|
|
269
|
+
Non-2xx API responses raise `Invoq::ApiError` with `status`, `code`, `fields`,
|
|
270
|
+
`meta`, and the raw `payload`.
|
|
271
|
+
|
|
272
|
+
Connection failures, timeouts, invalid input, and response parse failures raise
|
|
273
|
+
`Invoq::Error`. Timed-out invoice creation is safe to retry with the same
|
|
274
|
+
`reference_id`.
|
|
275
|
+
|
|
276
|
+
Webhook verification failures raise `Invoq::SignatureVerificationError` with one
|
|
277
|
+
of these codes:
|
|
278
|
+
|
|
279
|
+
```txt
|
|
280
|
+
missing_signature
|
|
281
|
+
invalid_signature_header
|
|
282
|
+
timestamp_outside_tolerance
|
|
283
|
+
signature_mismatch
|
|
284
|
+
invalid_payload
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
## Development
|
|
288
|
+
|
|
289
|
+
```sh
|
|
290
|
+
bundle exec rake test
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
## License
|
|
294
|
+
|
|
295
|
+
Licensed under the MIT license.
|
data/lib/invoq/client.rb
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
5
|
+
require_relative "errors"
|
|
6
|
+
require_relative "invoices_resource"
|
|
7
|
+
|
|
8
|
+
module Invoq
|
|
9
|
+
DEFAULT_API_ORIGIN = "https://api.invoq.money"
|
|
10
|
+
DEFAULT_TIMEOUT_MS = 10_000
|
|
11
|
+
MAX_TIMEOUT_MS = 4_294_967_295
|
|
12
|
+
|
|
13
|
+
class Client
|
|
14
|
+
attr_reader :invoices
|
|
15
|
+
|
|
16
|
+
def initialize(api_key, api_origin: DEFAULT_API_ORIGIN, timeout_ms: DEFAULT_TIMEOUT_MS)
|
|
17
|
+
unless api_key.is_a?(String) && !api_key.strip.empty?
|
|
18
|
+
raise Error, "invoq API key must be a non-empty string."
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
@api_key = api_key
|
|
22
|
+
@api_origin = Invoq.normalize_api_origin(api_origin)
|
|
23
|
+
@timeout_ms = Invoq.normalize_timeout_ms(timeout_ms)
|
|
24
|
+
@invoices = InvoicesResource.new(
|
|
25
|
+
api_key: @api_key,
|
|
26
|
+
api_origin: @api_origin,
|
|
27
|
+
timeout_ms: @timeout_ms
|
|
28
|
+
)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def inspect
|
|
32
|
+
"#<#{self.class} api_origin=#{@api_origin.inspect} timeout_ms=#{@timeout_ms.inspect}>"
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def self.normalize_api_origin(value)
|
|
37
|
+
unless value.is_a?(String)
|
|
38
|
+
raise Error, "api_origin must be an absolute http or https origin."
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
uri = URI.parse(value)
|
|
42
|
+
|
|
43
|
+
unless uri.is_a?(URI::HTTP) && uri.host && !uri.host.empty?
|
|
44
|
+
raise Error, "api_origin must be an absolute http or https origin."
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
unless uri.scheme == "http" || uri.scheme == "https"
|
|
48
|
+
raise Error, "api_origin must be an absolute http or https origin."
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
if uri.user || uri.password
|
|
52
|
+
raise Error, "api_origin must be an absolute http or https origin."
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
if uri.port < 1 || uri.port > 65_535
|
|
56
|
+
raise Error, "api_origin must be an absolute http or https origin."
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
if uri.query || uri.fragment
|
|
60
|
+
raise Error, "api_origin must not include query or hash parts."
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
pathname = uri.path.to_s.sub(%r{/+\z}, "")
|
|
64
|
+
pathname = "/" if pathname.empty?
|
|
65
|
+
|
|
66
|
+
unless pathname == "/"
|
|
67
|
+
raise Error, "api_origin must not include a path."
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
uri.path = "/"
|
|
71
|
+
uri.query = nil
|
|
72
|
+
uri.fragment = nil
|
|
73
|
+
uri.to_s
|
|
74
|
+
rescue URI::InvalidURIError
|
|
75
|
+
raise Error, "api_origin must be an absolute http or https origin."
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def self.normalize_timeout_ms(value)
|
|
79
|
+
unless value.is_a?(Integer) && value.positive? && value <= MAX_TIMEOUT_MS
|
|
80
|
+
raise Error, "timeout_ms must be a positive integer of at most 4294967295."
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
value
|
|
84
|
+
end
|
|
85
|
+
end
|
data/lib/invoq/errors.rb
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Invoq
|
|
4
|
+
class Error < StandardError
|
|
5
|
+
attr_reader :payload
|
|
6
|
+
|
|
7
|
+
def initialize(message = nil, payload: nil)
|
|
8
|
+
super(message)
|
|
9
|
+
@payload = payload
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
class ApiError < Error
|
|
14
|
+
attr_reader :status, :code, :fields, :meta
|
|
15
|
+
|
|
16
|
+
def initialize(message, status:, code: nil, fields: nil, meta: nil, payload: nil)
|
|
17
|
+
super(message, payload: payload)
|
|
18
|
+
@status = status
|
|
19
|
+
@code = code
|
|
20
|
+
@fields = fields
|
|
21
|
+
@meta = meta
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
class SignatureVerificationError < Error
|
|
26
|
+
attr_reader :code
|
|
27
|
+
|
|
28
|
+
def initialize(code, message)
|
|
29
|
+
super(message)
|
|
30
|
+
@code = code
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "timeout"
|
|
6
|
+
require "uri"
|
|
7
|
+
|
|
8
|
+
require_relative "../errors"
|
|
9
|
+
require_relative "../version"
|
|
10
|
+
|
|
11
|
+
module Invoq
|
|
12
|
+
module Internal
|
|
13
|
+
class Request
|
|
14
|
+
NO_BODY = Object.new
|
|
15
|
+
USER_AGENT = "invoq-ruby/#{Invoq::VERSION}"
|
|
16
|
+
|
|
17
|
+
def self.json(api_key:, api_origin:, timeout_ms:, path:, method: "POST", body: NO_BODY)
|
|
18
|
+
url = URI.parse(api_origin + path.sub(%r{\A/+}, ""))
|
|
19
|
+
request = build_request(url, method)
|
|
20
|
+
request["Accept"] = "application/json"
|
|
21
|
+
request["Authorization"] = "Bearer #{api_key}"
|
|
22
|
+
request["User-Agent"] = USER_AGENT
|
|
23
|
+
|
|
24
|
+
unless body.equal?(NO_BODY)
|
|
25
|
+
request.body = JSON.generate(body)
|
|
26
|
+
request["Content-Type"] = "application/json"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
response = perform_request(url, request, timeout_ms)
|
|
30
|
+
status = response.code.to_i
|
|
31
|
+
response_text = response.body.to_s
|
|
32
|
+
|
|
33
|
+
begin
|
|
34
|
+
payload = JSON.parse(response_text)
|
|
35
|
+
rescue JSON::ParserError
|
|
36
|
+
if status < 200 || status >= 300
|
|
37
|
+
raise api_error_from_response(status, response_text)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
raise Error, "Failed to parse invoq API response."
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
if status < 200 || status >= 300
|
|
44
|
+
raise api_error_from_response(status, payload)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
unless payload.is_a?(Hash) && payload.key?("data")
|
|
48
|
+
raise Error.new(
|
|
49
|
+
"invoq API response did not include a data envelope.",
|
|
50
|
+
payload: payload
|
|
51
|
+
)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
payload["data"]
|
|
55
|
+
rescue JSON::GeneratorError
|
|
56
|
+
raise Error, "Failed to encode invoq API request."
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def self.build_request(url, method)
|
|
60
|
+
case method
|
|
61
|
+
when "GET"
|
|
62
|
+
Net::HTTP::Get.new(url)
|
|
63
|
+
when "POST"
|
|
64
|
+
Net::HTTP::Post.new(url)
|
|
65
|
+
else
|
|
66
|
+
raise Error, "Unsupported invoq API request method."
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
private_class_method :build_request
|
|
70
|
+
|
|
71
|
+
def self.perform_request(url, request, timeout_ms)
|
|
72
|
+
timeout_seconds = timeout_ms / 1000.0
|
|
73
|
+
|
|
74
|
+
Timeout.timeout(timeout_seconds) do
|
|
75
|
+
Net::HTTP.start(
|
|
76
|
+
url.host,
|
|
77
|
+
url.port,
|
|
78
|
+
use_ssl: url.scheme == "https",
|
|
79
|
+
open_timeout: timeout_seconds,
|
|
80
|
+
read_timeout: timeout_seconds,
|
|
81
|
+
write_timeout: timeout_seconds
|
|
82
|
+
) do |http|
|
|
83
|
+
http.request(request)
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
rescue Net::OpenTimeout, Net::ReadTimeout, Timeout::Error
|
|
87
|
+
raise Error, "invoq API request timed out."
|
|
88
|
+
rescue Error
|
|
89
|
+
raise
|
|
90
|
+
rescue StandardError
|
|
91
|
+
raise Error, "Failed to connect to invoq API."
|
|
92
|
+
end
|
|
93
|
+
private_class_method :perform_request
|
|
94
|
+
|
|
95
|
+
def self.api_error_from_response(status, payload)
|
|
96
|
+
error = payload.is_a?(Hash) ? payload : nil
|
|
97
|
+
code = error && error["code"].is_a?(String) ? error["code"] : nil
|
|
98
|
+
message = error && error["message"].is_a?(String) ? error["message"] : "invoq API request failed."
|
|
99
|
+
fields = parse_fields(error && error["fields"])
|
|
100
|
+
meta = error && error["meta"].is_a?(Hash) ? error["meta"] : nil
|
|
101
|
+
|
|
102
|
+
ApiError.new(
|
|
103
|
+
message,
|
|
104
|
+
status: status,
|
|
105
|
+
code: code,
|
|
106
|
+
fields: fields,
|
|
107
|
+
meta: meta,
|
|
108
|
+
payload: payload
|
|
109
|
+
)
|
|
110
|
+
end
|
|
111
|
+
private_class_method :api_error_from_response
|
|
112
|
+
|
|
113
|
+
def self.parse_fields(value)
|
|
114
|
+
return nil unless value.is_a?(Array)
|
|
115
|
+
|
|
116
|
+
fields = value.each_with_object([]) do |field, result|
|
|
117
|
+
next unless field.is_a?(Hash)
|
|
118
|
+
|
|
119
|
+
location = field["location"]
|
|
120
|
+
next unless %w[query path body header].include?(location)
|
|
121
|
+
next unless field["field"].is_a?(String)
|
|
122
|
+
next unless field["code"].is_a?(String)
|
|
123
|
+
next unless field["message"].is_a?(String)
|
|
124
|
+
|
|
125
|
+
result << {
|
|
126
|
+
"field" => field["field"],
|
|
127
|
+
"location" => location,
|
|
128
|
+
"code" => field["code"],
|
|
129
|
+
"message" => field["message"]
|
|
130
|
+
}
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
fields
|
|
134
|
+
end
|
|
135
|
+
private_class_method :parse_fields
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "errors"
|
|
4
|
+
require_relative "internal/request"
|
|
5
|
+
|
|
6
|
+
module Invoq
|
|
7
|
+
class InvoicesResource
|
|
8
|
+
MISSING = Object.new
|
|
9
|
+
|
|
10
|
+
def initialize(api_key:, api_origin:, timeout_ms:)
|
|
11
|
+
@api_key = api_key
|
|
12
|
+
@api_origin = api_origin
|
|
13
|
+
@timeout_ms = timeout_ms
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def create(input)
|
|
17
|
+
Internal::Request.json(
|
|
18
|
+
api_key: @api_key,
|
|
19
|
+
api_origin: @api_origin,
|
|
20
|
+
timeout_ms: @timeout_ms,
|
|
21
|
+
path: "/v1/invoices",
|
|
22
|
+
body: create_invoice_request_body(input)
|
|
23
|
+
)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def get(invoice_id)
|
|
27
|
+
id = encode_path_segment(required_request_string(invoice_id, "invoice_id"))
|
|
28
|
+
|
|
29
|
+
Internal::Request.json(
|
|
30
|
+
api_key: @api_key,
|
|
31
|
+
api_origin: @api_origin,
|
|
32
|
+
timeout_ms: @timeout_ms,
|
|
33
|
+
method: "GET",
|
|
34
|
+
path: "/v1/invoices/#{id}"
|
|
35
|
+
)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def create_test_payment(invoice_id, input)
|
|
39
|
+
id = encode_path_segment(required_request_string(invoice_id, "invoice_id"))
|
|
40
|
+
|
|
41
|
+
Internal::Request.json(
|
|
42
|
+
api_key: @api_key,
|
|
43
|
+
api_origin: @api_origin,
|
|
44
|
+
timeout_ms: @timeout_ms,
|
|
45
|
+
path: "/v1/invoices/#{id}/test-payments",
|
|
46
|
+
body: create_test_payment_request_body(input)
|
|
47
|
+
)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def create_invoice_request_body(input)
|
|
53
|
+
unless input.is_a?(Hash)
|
|
54
|
+
raise Error, "request body must be a hash."
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
body = {
|
|
58
|
+
"amount" => required_request_string(input_value(input, "amount"), "amount")
|
|
59
|
+
}
|
|
60
|
+
currency = optional_request_value(input, "currency")
|
|
61
|
+
description = optional_request_string(input, "description")
|
|
62
|
+
reference_id = optional_request_string(input, "reference_id")
|
|
63
|
+
return_url = optional_nullable_request_string(input, "return_url")
|
|
64
|
+
|
|
65
|
+
body["currency"] = currency unless currency.equal?(MISSING)
|
|
66
|
+
body["description"] = description unless description.equal?(MISSING)
|
|
67
|
+
body["reference_id"] = reference_id unless reference_id.equal?(MISSING)
|
|
68
|
+
body["return_url"] = return_url unless return_url.equal?(MISSING)
|
|
69
|
+
|
|
70
|
+
body
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def create_test_payment_request_body(input)
|
|
74
|
+
unless input.is_a?(Hash)
|
|
75
|
+
raise Error, "request body must be a hash."
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
body = {
|
|
79
|
+
"amount" => required_request_string(input_value(input, "amount"), "amount")
|
|
80
|
+
}
|
|
81
|
+
reference_id = optional_request_string(input, "reference_id")
|
|
82
|
+
|
|
83
|
+
body["reference_id"] = reference_id unless reference_id.equal?(MISSING)
|
|
84
|
+
|
|
85
|
+
body
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def optional_request_string(input, field)
|
|
89
|
+
value = optional_request_value(input, field)
|
|
90
|
+
return MISSING if value.equal?(MISSING)
|
|
91
|
+
|
|
92
|
+
unless value.is_a?(String)
|
|
93
|
+
raise Error, "#{field} must be a string when provided."
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
value
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def optional_nullable_request_string(input, field)
|
|
100
|
+
value = optional_request_value(input, field)
|
|
101
|
+
return MISSING if value.equal?(MISSING)
|
|
102
|
+
|
|
103
|
+
unless value.nil? || value.is_a?(String)
|
|
104
|
+
raise Error, "#{field} must be a string or nil when provided."
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
value
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def required_request_string(value, field)
|
|
111
|
+
unless value.is_a?(String) && !value.strip.empty?
|
|
112
|
+
raise Error, "#{field} must be a non-empty string."
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
value
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def optional_request_value(input, field)
|
|
119
|
+
if input.key?(field)
|
|
120
|
+
input[field]
|
|
121
|
+
elsif input.key?(field.to_sym)
|
|
122
|
+
input[field.to_sym]
|
|
123
|
+
else
|
|
124
|
+
MISSING
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def input_value(input, field)
|
|
129
|
+
return input[field] if input.key?(field)
|
|
130
|
+
|
|
131
|
+
input[field.to_sym]
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def encode_path_segment(value)
|
|
135
|
+
value.encode("UTF-8").bytes.map do |byte|
|
|
136
|
+
if unescaped_path_byte?(byte)
|
|
137
|
+
byte.chr
|
|
138
|
+
else
|
|
139
|
+
format("%%%02X", byte)
|
|
140
|
+
end
|
|
141
|
+
end.join
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def unescaped_path_byte?(byte)
|
|
145
|
+
case byte
|
|
146
|
+
when 65..90, 97..122, 48..57, 45, 95, 46, 33, 126, 42, 39, 40, 41
|
|
147
|
+
true
|
|
148
|
+
else
|
|
149
|
+
false
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
data/lib/invoq/version.rb
CHANGED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "openssl"
|
|
5
|
+
|
|
6
|
+
require_relative "errors"
|
|
7
|
+
|
|
8
|
+
module Invoq
|
|
9
|
+
module Webhooks
|
|
10
|
+
DEFAULT_TOLERANCE_SECONDS = 300
|
|
11
|
+
SIGNATURE_PATTERN = /\A[a-f0-9]{64}\z/i
|
|
12
|
+
MISSING = Object.new
|
|
13
|
+
|
|
14
|
+
def self.verify_webhook(raw_body, headers, webhook_secret)
|
|
15
|
+
signature_header = get_signature_header(headers)
|
|
16
|
+
|
|
17
|
+
if signature_header.nil? || signature_header.empty?
|
|
18
|
+
raise signature_error("missing_signature", "Missing invoq-signature header.")
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
unless webhook_secret.is_a?(String) && !webhook_secret.empty?
|
|
22
|
+
raise signature_error(
|
|
23
|
+
"invalid_signature_header",
|
|
24
|
+
"Webhook secret must be a non-empty string."
|
|
25
|
+
)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
timestamp, timestamp_seconds, signature = parse_signature_header(signature_header)
|
|
29
|
+
now_seconds = Time.now.to_i
|
|
30
|
+
|
|
31
|
+
if (now_seconds - timestamp_seconds).abs > DEFAULT_TOLERANCE_SECONDS
|
|
32
|
+
raise signature_error(
|
|
33
|
+
"timestamp_outside_tolerance",
|
|
34
|
+
"Webhook timestamp is outside the allowed tolerance."
|
|
35
|
+
)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
expected_signature = hmac_sha256_hex(webhook_secret, timestamp, raw_body)
|
|
39
|
+
|
|
40
|
+
unless constant_time_equal?(expected_signature, signature)
|
|
41
|
+
raise signature_error("signature_mismatch", "Webhook signature mismatch.")
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
payload = JSON.parse(body_text(raw_body))
|
|
45
|
+
|
|
46
|
+
unless payload.is_a?(Hash) && payload["type"].is_a?(String)
|
|
47
|
+
raise signature_error(
|
|
48
|
+
"invalid_payload",
|
|
49
|
+
"Webhook payload must be an object with a string type."
|
|
50
|
+
)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
payload
|
|
54
|
+
rescue JSON::ParserError
|
|
55
|
+
raise signature_error("invalid_payload", "Webhook payload is not valid JSON.")
|
|
56
|
+
end
|
|
57
|
+
|
|
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)
|
|
64
|
+
|
|
65
|
+
data = event["data"]
|
|
66
|
+
return false unless data.is_a?(Hash)
|
|
67
|
+
|
|
68
|
+
invoice = data["invoice"]
|
|
69
|
+
return false unless invoice.is_a?(Hash)
|
|
70
|
+
|
|
71
|
+
reference_id = invoice.key?("reference_id") ? invoice["reference_id"] : MISSING
|
|
72
|
+
fully_paid_at = invoice.key?("fully_paid_at") ? invoice["fully_paid_at"] : MISSING
|
|
73
|
+
|
|
74
|
+
invoice["id"].is_a?(String) &&
|
|
75
|
+
invoice_mode?(invoice["mode"]) &&
|
|
76
|
+
invoice_paid_status?(invoice["status"]) &&
|
|
77
|
+
invoice["amount"].is_a?(String) &&
|
|
78
|
+
invoice["currency"] == "USD" &&
|
|
79
|
+
invoice["amount_paid"].is_a?(String) &&
|
|
80
|
+
(reference_id.is_a?(String) || reference_id.nil?) &&
|
|
81
|
+
(fully_paid_at.is_a?(String) || fully_paid_at.nil?)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def self.is_invoice_paid(event)
|
|
85
|
+
invoice_paid?(event)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def self.parse_signature_header(signature_header)
|
|
89
|
+
parts = {}
|
|
90
|
+
|
|
91
|
+
signature_header.split(",").each do |part|
|
|
92
|
+
separator_index = part.index("=")
|
|
93
|
+
|
|
94
|
+
unless separator_index
|
|
95
|
+
raise signature_error(
|
|
96
|
+
"invalid_signature_header",
|
|
97
|
+
"Invalid invoq-signature header."
|
|
98
|
+
)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
key = part[0...separator_index].strip
|
|
102
|
+
value = part[(separator_index + 1)..-1].to_s.strip
|
|
103
|
+
next if key.empty? || value.empty?
|
|
104
|
+
|
|
105
|
+
parts[key] = value
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
timestamp = parts["t"]
|
|
109
|
+
signature = parts["v1"]
|
|
110
|
+
|
|
111
|
+
unless timestamp && signature && timestamp.match?(/\A\d+\z/)
|
|
112
|
+
raise signature_error(
|
|
113
|
+
"invalid_signature_header",
|
|
114
|
+
"Invalid invoq-signature header."
|
|
115
|
+
)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
unless signature.match?(SIGNATURE_PATTERN)
|
|
119
|
+
raise signature_error(
|
|
120
|
+
"invalid_signature_header",
|
|
121
|
+
"Invalid invoq-signature signature."
|
|
122
|
+
)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
[timestamp, timestamp.to_i, signature.downcase]
|
|
126
|
+
end
|
|
127
|
+
private_class_method :parse_signature_header
|
|
128
|
+
|
|
129
|
+
def self.get_signature_header(headers)
|
|
130
|
+
return nil if headers.nil?
|
|
131
|
+
return nil unless headers.respond_to?(:each)
|
|
132
|
+
|
|
133
|
+
headers.each do |key, value|
|
|
134
|
+
next unless key.to_s.downcase == "invoq-signature"
|
|
135
|
+
|
|
136
|
+
return nil if value.nil?
|
|
137
|
+
return value.map(&:to_s).join(",") if value.is_a?(Array)
|
|
138
|
+
|
|
139
|
+
return value.to_s
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
nil
|
|
143
|
+
end
|
|
144
|
+
private_class_method :get_signature_header
|
|
145
|
+
|
|
146
|
+
def self.hmac_sha256_hex(secret, timestamp, raw_body)
|
|
147
|
+
OpenSSL::HMAC.hexdigest(
|
|
148
|
+
"SHA256",
|
|
149
|
+
secret.encode("UTF-8"),
|
|
150
|
+
"#{timestamp}.".b + body_bytes(raw_body)
|
|
151
|
+
)
|
|
152
|
+
end
|
|
153
|
+
private_class_method :hmac_sha256_hex
|
|
154
|
+
|
|
155
|
+
def self.body_text(raw_body)
|
|
156
|
+
body_bytes(raw_body).force_encoding("UTF-8")
|
|
157
|
+
end
|
|
158
|
+
private_class_method :body_text
|
|
159
|
+
|
|
160
|
+
def self.body_bytes(raw_body)
|
|
161
|
+
unless raw_body.is_a?(String)
|
|
162
|
+
raise signature_error("invalid_payload", "Webhook payload is not valid JSON.")
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
raw_body.b
|
|
166
|
+
end
|
|
167
|
+
private_class_method :body_bytes
|
|
168
|
+
|
|
169
|
+
def self.constant_time_equal?(left, right)
|
|
170
|
+
left_bytes = left.b.bytes
|
|
171
|
+
right_bytes = right.b.bytes
|
|
172
|
+
max_length = [left_bytes.length, right_bytes.length, 1].max
|
|
173
|
+
result = left_bytes.length ^ right_bytes.length
|
|
174
|
+
|
|
175
|
+
max_length.times do |index|
|
|
176
|
+
result |= (left_bytes[index] || 0) ^ (right_bytes[index] || 0)
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
result.zero?
|
|
180
|
+
end
|
|
181
|
+
private_class_method :constant_time_equal?
|
|
182
|
+
|
|
183
|
+
def self.invoice_mode?(value)
|
|
184
|
+
value == "test" || value == "live"
|
|
185
|
+
end
|
|
186
|
+
private_class_method :invoice_mode?
|
|
187
|
+
|
|
188
|
+
def self.invoice_paid_status?(value)
|
|
189
|
+
value == "paid" || value == "settling" || value == "settled"
|
|
190
|
+
end
|
|
191
|
+
private_class_method :invoice_paid_status?
|
|
192
|
+
|
|
193
|
+
def self.signature_error(code, message)
|
|
194
|
+
SignatureVerificationError.new(code, message)
|
|
195
|
+
end
|
|
196
|
+
private_class_method :signature_error
|
|
197
|
+
end
|
|
198
|
+
end
|
data/lib/invoq.rb
CHANGED
|
@@ -1,7 +1,24 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "invoq/version"
|
|
4
|
+
require_relative "invoq/errors"
|
|
5
|
+
require_relative "invoq/client"
|
|
6
|
+
require_relative "invoq/webhooks"
|
|
4
7
|
|
|
5
8
|
module Invoq
|
|
6
|
-
|
|
9
|
+
def self.new(api_key, api_origin: DEFAULT_API_ORIGIN, timeout_ms: DEFAULT_TIMEOUT_MS)
|
|
10
|
+
Client.new(api_key, api_origin: api_origin, timeout_ms: timeout_ms)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def self.verify_webhook(raw_body, headers, webhook_secret)
|
|
14
|
+
Webhooks.verify_webhook(raw_body, headers, webhook_secret)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def self.invoice_paid?(event)
|
|
18
|
+
Webhooks.invoice_paid?(event)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def self.is_invoice_paid(event)
|
|
22
|
+
invoice_paid?(event)
|
|
23
|
+
end
|
|
7
24
|
end
|
metadata
CHANGED
|
@@ -1,16 +1,50 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: invoq
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
|
-
-
|
|
7
|
+
- invoq
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-
|
|
12
|
-
dependencies:
|
|
13
|
-
|
|
11
|
+
date: 2026-07-08 00:00:00.000000000 Z
|
|
12
|
+
dependencies:
|
|
13
|
+
- !ruby/object:Gem::Dependency
|
|
14
|
+
name: minitest
|
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
|
16
|
+
requirements:
|
|
17
|
+
- - "~>"
|
|
18
|
+
- !ruby/object:Gem::Version
|
|
19
|
+
version: '5.0'
|
|
20
|
+
type: :development
|
|
21
|
+
prerelease: false
|
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
23
|
+
requirements:
|
|
24
|
+
- - "~>"
|
|
25
|
+
- !ruby/object:Gem::Version
|
|
26
|
+
version: '5.0'
|
|
27
|
+
- !ruby/object:Gem::Dependency
|
|
28
|
+
name: rake
|
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
|
30
|
+
requirements:
|
|
31
|
+
- - ">="
|
|
32
|
+
- !ruby/object:Gem::Version
|
|
33
|
+
version: '10.0'
|
|
34
|
+
- - "<"
|
|
35
|
+
- !ruby/object:Gem::Version
|
|
36
|
+
version: '14.0'
|
|
37
|
+
type: :development
|
|
38
|
+
prerelease: false
|
|
39
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
40
|
+
requirements:
|
|
41
|
+
- - ">="
|
|
42
|
+
- !ruby/object:Gem::Version
|
|
43
|
+
version: '10.0'
|
|
44
|
+
- - "<"
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: '14.0'
|
|
47
|
+
description: Ruby SDK for invoq server APIs and webhook verification.
|
|
14
48
|
email:
|
|
15
49
|
executables: []
|
|
16
50
|
extensions: []
|
|
@@ -19,7 +53,12 @@ files:
|
|
|
19
53
|
- LICENSE.txt
|
|
20
54
|
- README.md
|
|
21
55
|
- lib/invoq.rb
|
|
56
|
+
- lib/invoq/client.rb
|
|
57
|
+
- lib/invoq/errors.rb
|
|
58
|
+
- lib/invoq/internal/request.rb
|
|
59
|
+
- lib/invoq/invoices_resource.rb
|
|
22
60
|
- lib/invoq/version.rb
|
|
61
|
+
- lib/invoq/webhooks.rb
|
|
23
62
|
homepage: https://rubygems.org/gems/invoq
|
|
24
63
|
licenses:
|
|
25
64
|
- MIT
|
|
@@ -43,5 +82,5 @@ requirements: []
|
|
|
43
82
|
rubygems_version: 3.0.3.1
|
|
44
83
|
signing_key:
|
|
45
84
|
specification_version: 4
|
|
46
|
-
summary: Ruby SDK for
|
|
85
|
+
summary: Ruby SDK for invoq stablecoin payment acceptance.
|
|
47
86
|
test_files: []
|