multicard 0.1.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 +7 -0
- data/CHANGELOG.md +22 -0
- data/LICENSE +21 -0
- data/README.md +405 -0
- data/lib/multicard/client.rb +60 -0
- data/lib/multicard/configuration.rb +41 -0
- data/lib/multicard/errors.rb +41 -0
- data/lib/multicard/http_client.rb +146 -0
- data/lib/multicard/resources/base.rb +36 -0
- data/lib/multicard/resources/cards.rb +80 -0
- data/lib/multicard/resources/holds.rb +60 -0
- data/lib/multicard/resources/invoices.rb +53 -0
- data/lib/multicard/resources/payments.rb +133 -0
- data/lib/multicard/resources/payouts.rb +37 -0
- data/lib/multicard/resources/registry.rb +37 -0
- data/lib/multicard/response.rb +33 -0
- data/lib/multicard/signature.rb +53 -0
- data/lib/multicard/token_manager.rb +50 -0
- data/lib/multicard/version.rb +5 -0
- data/lib/multicard.rb +42 -0
- metadata +124 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 4deef0f73a9d0f65799ff892e950f24b744a350cadd90b2956fc5dd4c91f6f2f
|
|
4
|
+
data.tar.gz: 1471a57173d4a1b868867350f9de3e9e72513f6fe873bba4fa10767f79044eba
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 9ba05346db8cf1fe2b16577f06423fe19b24652c4cb5210124ef313c94355c4645008be1d57157095c33acce7f4b2197f159905fde1188977bc1cc0c2ae44aa4
|
|
7
|
+
data.tar.gz: e9e987599830d737e9f878bd46938df9308607bc79a5cf3314bf6b4df4f7076ec81a4389deba588ace709ed517b111844da9c1ebe8ec22661e56cf9df6148802
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [0.1.0] - 2026-02-16
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- Initial release
|
|
13
|
+
- Configuration (global and per-client with immutable merge)
|
|
14
|
+
- HTTP client (HTTP.rb) with retry logic and exponential backoff
|
|
15
|
+
- Thread-safe token management with Mutex (23h TTL, auto-refresh)
|
|
16
|
+
- Automatic retry on 401 (token expiry)
|
|
17
|
+
- Resources: Invoices, Payments, Cards, Holds, Payouts, Registry (33 methods, 100% API coverage)
|
|
18
|
+
- Split payments support (multi-recipient)
|
|
19
|
+
- Wallet payments (Payme, Click, Uzum, Anorbank, Xazna, etc.)
|
|
20
|
+
- Webhook signature verification (MD5, constant-time comparison, amount normalization)
|
|
21
|
+
- Full error hierarchy with Multicard-specific error codes (ERROR_MAP)
|
|
22
|
+
- Comprehensive test suite (90 specs, WebMock)
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Honey Murena
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
# Multicard Ruby SDK
|
|
2
|
+
|
|
3
|
+
[](https://badge.fury.io/rb/multicard)
|
|
4
|
+
[](https://github.com/pashgo/multicard-ruby/actions/workflows/ci.yml)
|
|
5
|
+
[](https://opensource.org/licenses/MIT)
|
|
6
|
+
|
|
7
|
+
Ruby client for the [Multicard](https://multicard.uz) payment gateway (Uzbekistan).
|
|
8
|
+
|
|
9
|
+
Supports Uzcard, Humo, and wallet apps: Payme, Click, Uzum, Anorbank, Xazna, and more.
|
|
10
|
+
|
|
11
|
+
## Why This Gem?
|
|
12
|
+
|
|
13
|
+
Before this SDK, integrating with Multicard meant writing raw HTTP calls, managing tokens manually, and handling errors ad-hoc. Here's what the gem gives you:
|
|
14
|
+
|
|
15
|
+
### Full API Coverage
|
|
16
|
+
|
|
17
|
+
30 methods across 6 resource groups — invoices, payments (token/card/wallet/split), card binding, holds (pre-auth), payouts, and registry. No need to study the API docs for every endpoint.
|
|
18
|
+
|
|
19
|
+
### Clean Resource-Based Interface
|
|
20
|
+
|
|
21
|
+
Stripe/Shopify-style SDK design:
|
|
22
|
+
|
|
23
|
+
```ruby
|
|
24
|
+
client.payments.create_by_token(card_token: 'tok_abc', amount: 500_000, invoice_id: 'ORD-1')
|
|
25
|
+
client.holds.capture(hold_id, amount: 300_000)
|
|
26
|
+
client.cards.create_binding_link
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Discoverable, self-documenting API — IDE autocompletion works out of the box.
|
|
30
|
+
|
|
31
|
+
### Automatic Token Management
|
|
32
|
+
|
|
33
|
+
Bearer tokens (24h TTL) are fetched, cached, and refreshed transparently. Thread-safe with Mutex. On 401, the gem automatically refreshes the token and retries the request — zero manual intervention.
|
|
34
|
+
|
|
35
|
+
### Typed Error Hierarchy
|
|
36
|
+
|
|
37
|
+
Multicard error codes map to specific Ruby exceptions:
|
|
38
|
+
|
|
39
|
+
```ruby
|
|
40
|
+
rescue Multicard::InsufficientFundsError # not enough funds
|
|
41
|
+
rescue Multicard::CardExpiredError # card expired
|
|
42
|
+
rescue Multicard::DebitUnknownError # need to poll for status
|
|
43
|
+
rescue Multicard::NetworkError # timeout / connection lost
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Each exception carries `http_status`, `error_code`, `error_details`, and `response_body` — no parsing required.
|
|
47
|
+
|
|
48
|
+
### Framework-Agnostic
|
|
49
|
+
|
|
50
|
+
Zero runtime dependencies. Uses Ruby's built-in `Net::HTTP` — no external gems required. Works in any Ruby app — Rails, Sinatra, Hanami, plain scripts.
|
|
51
|
+
|
|
52
|
+
### Built-In Security
|
|
53
|
+
|
|
54
|
+
- Webhook signature verification with constant-time comparison (timing-attack safe)
|
|
55
|
+
- No sensitive data in logs (token values are never logged)
|
|
56
|
+
- Automatic retry with exponential backoff for transient failures
|
|
57
|
+
|
|
58
|
+
### Production-Ready
|
|
59
|
+
|
|
60
|
+
- 91 specs with WebMock (no real HTTP calls in tests)
|
|
61
|
+
- Thread-safe token caching
|
|
62
|
+
- Configurable timeouts (connect + read)
|
|
63
|
+
- Optional logger support for debugging
|
|
64
|
+
- Global config + per-client overrides for multi-tenant setups
|
|
65
|
+
|
|
66
|
+
## Installation
|
|
67
|
+
|
|
68
|
+
Add to your Gemfile:
|
|
69
|
+
|
|
70
|
+
```ruby
|
|
71
|
+
gem 'multicard'
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Or install directly:
|
|
75
|
+
|
|
76
|
+
```
|
|
77
|
+
gem install multicard
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## Quick Start
|
|
81
|
+
|
|
82
|
+
```ruby
|
|
83
|
+
require 'multicard'
|
|
84
|
+
|
|
85
|
+
client = Multicard::Client.new(
|
|
86
|
+
application_id: ENV['MULTICARD_APPLICATION_ID'],
|
|
87
|
+
secret: ENV['MULTICARD_SECRET'],
|
|
88
|
+
store_id: 123 # default register ID
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
# Create a hosted checkout invoice
|
|
92
|
+
invoice = client.invoices.create(
|
|
93
|
+
amount: 500_000, # 5,000 UZS in tiyin
|
|
94
|
+
invoice_id: 'ORD-001',
|
|
95
|
+
callback_url: 'https://example.com/webhooks/multicard'
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
# Redirect user to payment page
|
|
99
|
+
invoice.data[:checkout_url]
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
## Configuration
|
|
103
|
+
|
|
104
|
+
### Global (optional)
|
|
105
|
+
|
|
106
|
+
```ruby
|
|
107
|
+
Multicard.configure do |config|
|
|
108
|
+
config.application_id = ENV['MULTICARD_APPLICATION_ID']
|
|
109
|
+
config.secret = ENV['MULTICARD_SECRET']
|
|
110
|
+
config.base_url = 'https://api.multicard.uz' # default
|
|
111
|
+
config.timeout = 30 # default (seconds)
|
|
112
|
+
config.open_timeout = 10 # default (seconds)
|
|
113
|
+
config.logger = Logger.new($stdout) # optional
|
|
114
|
+
config.store_id = 123 # default store/register ID
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Then create clients without repeating credentials:
|
|
118
|
+
client = Multicard::Client.new
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
### Per-client (overrides global)
|
|
122
|
+
|
|
123
|
+
```ruby
|
|
124
|
+
client = Multicard::Client.new(
|
|
125
|
+
application_id: 'other_app_id',
|
|
126
|
+
secret: 'other_secret',
|
|
127
|
+
store_id: 456
|
|
128
|
+
)
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
## Invoices (Hosted Checkout)
|
|
132
|
+
|
|
133
|
+
```ruby
|
|
134
|
+
# Create invoice
|
|
135
|
+
invoice = client.invoices.create(
|
|
136
|
+
amount: 500_000,
|
|
137
|
+
invoice_id: 'ORD-001',
|
|
138
|
+
callback_url: 'https://example.com/cb',
|
|
139
|
+
return_url: 'https://example.com/success',
|
|
140
|
+
description: 'Order payment'
|
|
141
|
+
)
|
|
142
|
+
invoice.data[:checkout_url] # redirect user here
|
|
143
|
+
|
|
144
|
+
# Get invoice info
|
|
145
|
+
info = client.invoices.retrieve('ORD-001')
|
|
146
|
+
|
|
147
|
+
# Cancel unpaid invoice
|
|
148
|
+
client.invoices.cancel('ORD-001')
|
|
149
|
+
|
|
150
|
+
# Quick Pay (Payme, Click, Uzum QR)
|
|
151
|
+
client.invoices.quick_pay(invoice_id: 'ORD-001', service: 'payme')
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
## Payments
|
|
155
|
+
|
|
156
|
+
### By Card Token
|
|
157
|
+
|
|
158
|
+
```ruby
|
|
159
|
+
payment = client.payments.create_by_token(
|
|
160
|
+
card_token: 'tok_abc',
|
|
161
|
+
amount: 500_000,
|
|
162
|
+
invoice_id: 'ORD-001',
|
|
163
|
+
callback_url: 'https://example.com/cb'
|
|
164
|
+
)
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
### By Card Number (PCI DSS required)
|
|
168
|
+
|
|
169
|
+
```ruby
|
|
170
|
+
payment = client.payments.create_by_card(
|
|
171
|
+
card_number: '8600123456781234',
|
|
172
|
+
card_expiry: '1228',
|
|
173
|
+
amount: 500_000,
|
|
174
|
+
invoice_id: 'ORD-002'
|
|
175
|
+
)
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
### Wallet Payment
|
|
179
|
+
|
|
180
|
+
```ruby
|
|
181
|
+
payment = client.payments.create_wallet(
|
|
182
|
+
service: 'payme', # or 'click', 'uzum', etc.
|
|
183
|
+
amount: 300_000,
|
|
184
|
+
invoice_id: 'ORD-003'
|
|
185
|
+
)
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
### Split Payment
|
|
189
|
+
|
|
190
|
+
```ruby
|
|
191
|
+
payment = client.payments.create_split(
|
|
192
|
+
card_token: 'tok_abc',
|
|
193
|
+
amount: 500_000,
|
|
194
|
+
invoice_id: 'ORD-004',
|
|
195
|
+
split: [
|
|
196
|
+
{ type: 'account', amount: 400_000, details: 'Store share', recipient: 'uuid-1' },
|
|
197
|
+
{ type: 'wallet', amount: 100_000, details: 'Platform fee' }
|
|
198
|
+
]
|
|
199
|
+
)
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
### OTP Confirmation
|
|
203
|
+
|
|
204
|
+
```ruby
|
|
205
|
+
client.payments.confirm('payment-uuid', otp_code: '123456')
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
### Refunds
|
|
209
|
+
|
|
210
|
+
```ruby
|
|
211
|
+
# Full refund
|
|
212
|
+
client.payments.refund('payment-uuid')
|
|
213
|
+
|
|
214
|
+
# Partial refund
|
|
215
|
+
client.payments.partial_refund('payment-uuid', amount: 100_000)
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
### Fiscal Receipt
|
|
219
|
+
|
|
220
|
+
```ruby
|
|
221
|
+
client.payments.send_fiscal_link('payment-uuid', fiscal_url: 'https://ofd.uz/receipt/123')
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
### With OFD Data
|
|
225
|
+
|
|
226
|
+
```ruby
|
|
227
|
+
client.payments.create_by_token(
|
|
228
|
+
card_token: 'tok_abc',
|
|
229
|
+
amount: 500_000,
|
|
230
|
+
invoice_id: 'ORD-005',
|
|
231
|
+
ofd: [
|
|
232
|
+
{ name: 'Product', price: 500_000, qty: 1, vat: 12,
|
|
233
|
+
tin: '123456789', mxik: '10202001001000000', package_code: '1508574' }
|
|
234
|
+
]
|
|
235
|
+
)
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
## Card Binding
|
|
239
|
+
|
|
240
|
+
### Form-Based (recommended)
|
|
241
|
+
|
|
242
|
+
```ruby
|
|
243
|
+
# Get binding link
|
|
244
|
+
link = client.cards.create_binding_link
|
|
245
|
+
# Redirect user to: link.data[:url]
|
|
246
|
+
|
|
247
|
+
# Check status (polling)
|
|
248
|
+
status = client.cards.binding_status(link.data[:session_id])
|
|
249
|
+
status.data[:token] # card token when bound
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
### API-Based (PCI DSS required)
|
|
253
|
+
|
|
254
|
+
```ruby
|
|
255
|
+
# Send OTP
|
|
256
|
+
client.cards.add(card_number: '8600123456781234', card_expiry: '1228')
|
|
257
|
+
|
|
258
|
+
# Confirm with OTP
|
|
259
|
+
result = client.cards.confirm_binding(otp_code: '123456')
|
|
260
|
+
result.data[:token]
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
### Card Operations
|
|
264
|
+
|
|
265
|
+
```ruby
|
|
266
|
+
# Get card info
|
|
267
|
+
card = client.cards.retrieve('card_token')
|
|
268
|
+
|
|
269
|
+
# Check card number
|
|
270
|
+
client.cards.check('8600123456781234')
|
|
271
|
+
|
|
272
|
+
# Verify ownership (PINFL)
|
|
273
|
+
client.cards.verify_pinfl(token: 'card_token', pinfl: '12345678901234')
|
|
274
|
+
|
|
275
|
+
# Unbind card
|
|
276
|
+
client.cards.revoke('card_token')
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
## Holds (Pre-Authorization)
|
|
280
|
+
|
|
281
|
+
```ruby
|
|
282
|
+
# Create hold
|
|
283
|
+
hold = client.holds.create(
|
|
284
|
+
card_token: 'tok_abc',
|
|
285
|
+
amount: 500_000,
|
|
286
|
+
invoice_id: 'HOLD-001'
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
# Confirm hold (block funds)
|
|
290
|
+
client.holds.confirm(hold.data[:id], otp_code: '123456')
|
|
291
|
+
|
|
292
|
+
# Capture full amount
|
|
293
|
+
client.holds.capture(hold.data[:id])
|
|
294
|
+
|
|
295
|
+
# Capture partial amount
|
|
296
|
+
client.holds.capture(hold.data[:id], amount: 300_000)
|
|
297
|
+
|
|
298
|
+
# Cancel hold (release funds)
|
|
299
|
+
client.holds.cancel(hold.data[:id])
|
|
300
|
+
|
|
301
|
+
# Check hold status
|
|
302
|
+
client.holds.retrieve(hold.data[:id])
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
## Payouts
|
|
306
|
+
|
|
307
|
+
```ruby
|
|
308
|
+
# Create payout
|
|
309
|
+
payout = client.payouts.create(card_number: '8600999988887777', amount: 100_000)
|
|
310
|
+
|
|
311
|
+
# Confirm payout
|
|
312
|
+
client.payouts.confirm(payout.data[:id])
|
|
313
|
+
|
|
314
|
+
# Check status
|
|
315
|
+
client.payouts.retrieve(payout.data[:id])
|
|
316
|
+
```
|
|
317
|
+
|
|
318
|
+
## Registry
|
|
319
|
+
|
|
320
|
+
```ruby
|
|
321
|
+
# Payment registry
|
|
322
|
+
client.registry.payments(date_from: '2025-01-01', date_to: '2025-01-31')
|
|
323
|
+
|
|
324
|
+
# Payout history
|
|
325
|
+
client.registry.payouts
|
|
326
|
+
|
|
327
|
+
# Application info
|
|
328
|
+
client.registry.application_info
|
|
329
|
+
|
|
330
|
+
# Merchant banking details
|
|
331
|
+
client.registry.merchant_details
|
|
332
|
+
```
|
|
333
|
+
|
|
334
|
+
## Webhook Verification
|
|
335
|
+
|
|
336
|
+
Multicard signs callback requests with MD5: `sign = md5(store_id + invoice_id + amount + secret)`.
|
|
337
|
+
|
|
338
|
+
`Signature.verify` handles this for you, including:
|
|
339
|
+
- **Constant-time comparison** — prevents timing attacks (no `Rack` dependency needed)
|
|
340
|
+
- **Amount normalization** — Multicard callbacks inconsistently format amounts (`"50000"`, `"50000.0"`, or `"50000.00"`). The signature is always computed against the integer form, so trailing `.0`/`.00` are stripped automatically.
|
|
341
|
+
- **Case-insensitive** — uppercase/lowercase hex signatures both accepted
|
|
342
|
+
|
|
343
|
+
```ruby
|
|
344
|
+
# In your webhook controller:
|
|
345
|
+
def multicard_callback
|
|
346
|
+
params = request.params.symbolize_keys
|
|
347
|
+
|
|
348
|
+
unless Multicard::Signature.verify(params, secret: ENV['MULTICARD_SECRET'])
|
|
349
|
+
head :unauthorized
|
|
350
|
+
return
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
payment = client.payments.retrieve(params[:uuid])
|
|
354
|
+
# Process payment...
|
|
355
|
+
head :ok
|
|
356
|
+
end
|
|
357
|
+
```
|
|
358
|
+
|
|
359
|
+
## Error Handling
|
|
360
|
+
|
|
361
|
+
```ruby
|
|
362
|
+
begin
|
|
363
|
+
client.payments.create_by_token(
|
|
364
|
+
card_token: 'tok_abc',
|
|
365
|
+
amount: 500_000,
|
|
366
|
+
invoice_id: 'ORD-001'
|
|
367
|
+
)
|
|
368
|
+
rescue Multicard::CardNotFoundError => e
|
|
369
|
+
# Card token is invalid or revoked
|
|
370
|
+
rescue Multicard::InsufficientFundsError => e
|
|
371
|
+
# Not enough funds on the card
|
|
372
|
+
rescue Multicard::CardExpiredError => e
|
|
373
|
+
# Card has expired
|
|
374
|
+
rescue Multicard::DebitUnknownError => e
|
|
375
|
+
# Unknown debit status - poll for result
|
|
376
|
+
payment = client.payments.retrieve(e.response_body.dig(:data, :uuid))
|
|
377
|
+
rescue Multicard::InvalidFieldsError => e
|
|
378
|
+
# Validation error - check e.error_details
|
|
379
|
+
rescue Multicard::AuthenticationError => e
|
|
380
|
+
# Invalid credentials
|
|
381
|
+
rescue Multicard::NetworkError => e
|
|
382
|
+
# Timeout or connection failure
|
|
383
|
+
rescue Multicard::ServerError => e
|
|
384
|
+
# Multicard server error (5xx)
|
|
385
|
+
rescue Multicard::Error => e
|
|
386
|
+
# Any other Multicard error
|
|
387
|
+
e.http_status # HTTP status code
|
|
388
|
+
e.error_code # Multicard error code string
|
|
389
|
+
e.error_details # Human-readable error description
|
|
390
|
+
e.response_body # Full response body hash
|
|
391
|
+
end
|
|
392
|
+
```
|
|
393
|
+
|
|
394
|
+
## Development
|
|
395
|
+
|
|
396
|
+
```bash
|
|
397
|
+
bundle install
|
|
398
|
+
bundle exec rspec # run tests
|
|
399
|
+
bundle exec rubocop # lint
|
|
400
|
+
gem build multicard.gemspec # build gem
|
|
401
|
+
```
|
|
402
|
+
|
|
403
|
+
## License
|
|
404
|
+
|
|
405
|
+
MIT License. See [LICENSE](LICENSE).
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Multicard
|
|
4
|
+
class Client
|
|
5
|
+
attr_reader :config
|
|
6
|
+
|
|
7
|
+
def initialize(**options)
|
|
8
|
+
@config = if Multicard.configuration
|
|
9
|
+
Multicard.configuration.merge(options)
|
|
10
|
+
else
|
|
11
|
+
Configuration.new(**options)
|
|
12
|
+
end
|
|
13
|
+
@config.validate!
|
|
14
|
+
@http_client = HttpClient.new(@config)
|
|
15
|
+
@token_manager = TokenManager.new(@http_client, @config)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Resource accessors (lazy-initialized)
|
|
19
|
+
def invoices = @invoices ||= Resources::Invoices.new(self)
|
|
20
|
+
def payments = @payments ||= Resources::Payments.new(self)
|
|
21
|
+
def cards = @cards ||= Resources::Cards.new(self)
|
|
22
|
+
def holds = @holds ||= Resources::Holds.new(self)
|
|
23
|
+
def payouts = @payouts ||= Resources::Payouts.new(self)
|
|
24
|
+
def registry = @registry ||= Resources::Registry.new(self)
|
|
25
|
+
|
|
26
|
+
# Execute an authenticated API request. Automatically retries once on 401.
|
|
27
|
+
#
|
|
28
|
+
# @param method [Symbol] :get, :post, or :delete
|
|
29
|
+
# @param path [String] API path
|
|
30
|
+
# @param body [Hash, nil] request body (for POST)
|
|
31
|
+
# @param params [Hash, nil] query params (for GET/DELETE)
|
|
32
|
+
# @return [Response]
|
|
33
|
+
def authenticated_request(method, path, body: nil, params: nil)
|
|
34
|
+
execute_authenticated(method, path, body: body, params: params)
|
|
35
|
+
rescue AuthenticationError
|
|
36
|
+
@token_manager.reset!
|
|
37
|
+
execute_authenticated(method, path, body: body, params: params)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def execute_authenticated(method, path, body: nil, params: nil)
|
|
43
|
+
headers = { 'Authorization' => "Bearer #{@token_manager.token}" }
|
|
44
|
+
|
|
45
|
+
case method
|
|
46
|
+
when :get
|
|
47
|
+
# GET is idempotent — safe to retry on transient failures (timeout, 429, 5xx)
|
|
48
|
+
@http_client.get_with_retry(path, params: params || {}, headers: headers)
|
|
49
|
+
when :post
|
|
50
|
+
# POST is NOT retried by default — retrying a payment could cause double charges.
|
|
51
|
+
# The 401 retry in authenticated_request is still applied (token refresh).
|
|
52
|
+
@http_client.post(path, body: body || {}, headers: headers)
|
|
53
|
+
when :delete
|
|
54
|
+
@http_client.delete(path, params: params || {}, headers: headers)
|
|
55
|
+
else
|
|
56
|
+
raise ArgumentError, "Unsupported HTTP method: #{method}"
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Multicard
|
|
4
|
+
class Configuration
|
|
5
|
+
attr_accessor :application_id, :secret, :base_url, :timeout, :open_timeout, :logger, :store_id
|
|
6
|
+
|
|
7
|
+
DEFAULT_BASE_URL = 'https://api.multicard.uz'
|
|
8
|
+
DEFAULT_TIMEOUT = 30
|
|
9
|
+
DEFAULT_OPEN_TIMEOUT = 10
|
|
10
|
+
|
|
11
|
+
def initialize(**options)
|
|
12
|
+
@application_id = options[:application_id]
|
|
13
|
+
@secret = options[:secret]
|
|
14
|
+
@base_url = options[:base_url] || DEFAULT_BASE_URL
|
|
15
|
+
@timeout = options[:timeout] || DEFAULT_TIMEOUT
|
|
16
|
+
@open_timeout = options[:open_timeout] || DEFAULT_OPEN_TIMEOUT
|
|
17
|
+
@logger = options[:logger]
|
|
18
|
+
@store_id = options[:store_id]
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def validate!
|
|
22
|
+
raise ArgumentError, 'application_id is required' if application_id.nil? || application_id.to_s.empty?
|
|
23
|
+
raise ArgumentError, 'secret is required' if secret.nil? || secret.to_s.empty?
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Merge per-client overrides into this configuration.
|
|
27
|
+
# Uses .key? for fields where nil/0/false are valid override values
|
|
28
|
+
# (e.g., store_id: nil to clear, timeout: 0 to disable).
|
|
29
|
+
def merge(overrides)
|
|
30
|
+
self.class.new(
|
|
31
|
+
application_id: overrides[:application_id] || application_id,
|
|
32
|
+
secret: overrides[:secret] || secret,
|
|
33
|
+
base_url: overrides[:base_url] || base_url,
|
|
34
|
+
timeout: overrides.key?(:timeout) ? overrides[:timeout] : timeout,
|
|
35
|
+
open_timeout: overrides.key?(:open_timeout) ? overrides[:open_timeout] : open_timeout,
|
|
36
|
+
logger: overrides.key?(:logger) ? overrides[:logger] : logger,
|
|
37
|
+
store_id: overrides.key?(:store_id) ? overrides[:store_id] : store_id
|
|
38
|
+
)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Multicard
|
|
4
|
+
class Error < StandardError
|
|
5
|
+
attr_reader :http_status, :error_code, :error_details, :response_body
|
|
6
|
+
|
|
7
|
+
def initialize(message = nil, http_status: nil, error_code: nil, error_details: nil, response_body: nil)
|
|
8
|
+
@http_status = http_status
|
|
9
|
+
@error_code = error_code
|
|
10
|
+
@error_details = error_details
|
|
11
|
+
@response_body = response_body
|
|
12
|
+
super(message || error_details || 'Multicard API error')
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# HTTP errors
|
|
17
|
+
class AuthenticationError < Error; end
|
|
18
|
+
class ValidationError < Error; end
|
|
19
|
+
class NotFoundError < Error; end
|
|
20
|
+
class RateLimitError < Error; end
|
|
21
|
+
class ServerError < Error; end
|
|
22
|
+
class NetworkError < Error; end
|
|
23
|
+
|
|
24
|
+
# Business errors (from error.code in API response)
|
|
25
|
+
class CardNotFoundError < ValidationError; end
|
|
26
|
+
class InsufficientFundsError < ValidationError; end
|
|
27
|
+
class CardExpiredError < ValidationError; end
|
|
28
|
+
class DebitUnknownError < Error; end
|
|
29
|
+
class CallbackTimeoutError < Error; end
|
|
30
|
+
class InvalidFieldsError < ValidationError; end
|
|
31
|
+
|
|
32
|
+
# Maps Multicard error codes to Ruby exception classes
|
|
33
|
+
ERROR_MAP = {
|
|
34
|
+
'ERROR_CARD_NOT_FOUND' => CardNotFoundError,
|
|
35
|
+
'ERROR_INSUFFICIENT_FUNDS' => InsufficientFundsError,
|
|
36
|
+
'ERROR_CARD_EXPIRED' => CardExpiredError,
|
|
37
|
+
'ERROR_DEBIT_UNKNOWN' => DebitUnknownError,
|
|
38
|
+
'ERROR_CALLBACK_TIMEOUT' => CallbackTimeoutError,
|
|
39
|
+
'ERROR_FIELDS' => InvalidFieldsError
|
|
40
|
+
}.freeze
|
|
41
|
+
end
|