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 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
+ [![Gem Version](https://badge.fury.io/rb/multicard.svg)](https://badge.fury.io/rb/multicard)
4
+ [![CI](https://github.com/pashgo/multicard-ruby/actions/workflows/ci.yml/badge.svg)](https://github.com/pashgo/multicard-ruby/actions/workflows/ci.yml)
5
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](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