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.
@@ -0,0 +1,146 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'net/http'
4
+ require 'uri'
5
+ require 'json'
6
+
7
+ module Multicard
8
+ class HttpClient
9
+ def initialize(config)
10
+ @config = config
11
+ end
12
+
13
+ def get(path, params: {}, headers: {})
14
+ request(:get, path, params: params, headers: headers)
15
+ end
16
+
17
+ def post(path, body: {}, headers: {})
18
+ request(:post, path, body: body, headers: headers)
19
+ end
20
+
21
+ def delete(path, params: {}, headers: {})
22
+ request(:delete, path, params: params, headers: headers)
23
+ end
24
+
25
+ def get_with_retry(path, params: {}, headers: {}, retries: 2)
26
+ request_with_retry(:get, path, params: params, headers: headers, retries: retries)
27
+ end
28
+
29
+ private
30
+
31
+ def request(method, path, body: nil, params: nil, headers: {})
32
+ uri = build_uri(path, params)
33
+ log_request(method, uri.to_s)
34
+
35
+ req = build_request(method, uri, body, headers)
36
+ raw = execute(uri, req)
37
+
38
+ log_response(raw)
39
+ handle_response(raw)
40
+ rescue Net::OpenTimeout, Net::ReadTimeout => e
41
+ raise NetworkError, "Request timed out: #{e.message}"
42
+ rescue SocketError, Errno::ECONNREFUSED, Errno::ECONNRESET => e
43
+ raise NetworkError, "Connection failed: #{e.message}"
44
+ end
45
+
46
+ def request_with_retry(method, path, retries: 2, **options)
47
+ attempts = 0
48
+ begin
49
+ request(method, path, **options)
50
+ rescue NetworkError, RateLimitError, ServerError
51
+ attempts += 1
52
+ raise if attempts > retries
53
+
54
+ sleep((2**attempts) * 0.5)
55
+ retry
56
+ end
57
+ end
58
+
59
+ def build_uri(path, params)
60
+ uri = URI("#{@config.base_url}#{path}")
61
+ uri.query = URI.encode_www_form(params) if params && !params.empty?
62
+ uri
63
+ end
64
+
65
+ def build_request(method, uri, body, headers)
66
+ req_class = case method
67
+ when :get then Net::HTTP::Get
68
+ when :post then Net::HTTP::Post
69
+ when :delete then Net::HTTP::Delete
70
+ else raise ArgumentError, "Unsupported HTTP method: #{method}"
71
+ end
72
+
73
+ req = req_class.new(uri)
74
+ req['Accept'] = 'application/json'
75
+ req['Content-Type'] = 'application/json'
76
+ headers.each { |key, value| req[key] = value }
77
+ req.body = body.to_json if body
78
+ req
79
+ end
80
+
81
+ def execute(uri, req)
82
+ http = Net::HTTP.new(uri.host, uri.port)
83
+ http.use_ssl = uri.scheme == 'https'
84
+ http.open_timeout = @config.open_timeout
85
+ http.read_timeout = @config.timeout
86
+ http.request(req)
87
+ end
88
+
89
+ def handle_response(raw)
90
+ status = raw.code.to_i
91
+ body = parse_body(raw)
92
+ raise_api_error(status, body) unless status.between?(200, 299)
93
+
94
+ Response.new(http_status: status, body: body, headers: response_headers(raw))
95
+ end
96
+
97
+ def parse_body(raw)
98
+ JSON.parse(raw.body || '', symbolize_names: true)
99
+ rescue JSON::ParserError
100
+ { raw: raw.body }
101
+ end
102
+
103
+ def response_headers(raw)
104
+ headers = {}
105
+ raw.each_header { |key, value| headers[key] = value }
106
+ headers
107
+ end
108
+
109
+ def raise_api_error(status, body)
110
+ error_code = body.dig(:error, :code)
111
+ error_details = body.dig(:error, :details)
112
+ error_class = ERROR_MAP[error_code] || error_class_for_status(status)
113
+
114
+ raise error_class.new(
115
+ error_details,
116
+ http_status: status,
117
+ error_code: error_code,
118
+ error_details: error_details,
119
+ response_body: body
120
+ )
121
+ end
122
+
123
+ def error_class_for_status(status)
124
+ case status
125
+ when 401 then AuthenticationError
126
+ when 404 then NotFoundError
127
+ when 429 then RateLimitError
128
+ when 400..499 then ValidationError
129
+ when 500..599 then ServerError
130
+ else Error
131
+ end
132
+ end
133
+
134
+ def log_request(method, url)
135
+ return unless @config.logger
136
+
137
+ @config.logger.info("[Multicard] #{method.to_s.upcase} #{url}")
138
+ end
139
+
140
+ def log_response(raw)
141
+ return unless @config.logger
142
+
143
+ @config.logger.info("[Multicard] #{raw.code}")
144
+ end
145
+ end
146
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'uri'
4
+
5
+ module Multicard
6
+ module Resources
7
+ class Base
8
+ def initialize(client)
9
+ @client = client
10
+ end
11
+
12
+ private
13
+
14
+ def get(path, params = {})
15
+ @client.authenticated_request(:get, path, params: params)
16
+ end
17
+
18
+ def post(path, body = {})
19
+ @client.authenticated_request(:post, path, body: body)
20
+ end
21
+
22
+ def delete(path, params = {})
23
+ @client.authenticated_request(:delete, path, params: params)
24
+ end
25
+
26
+ def default_store_id
27
+ @client.config.store_id
28
+ end
29
+
30
+ # Encode a path segment to prevent path traversal (../) or special characters.
31
+ def encode_path(value)
32
+ URI.encode_www_form_component(value.to_s)
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Multicard
4
+ module Resources
5
+ class Cards < Base
6
+ # --- Form-based card binding ---
7
+
8
+ # Create a card binding session (returns a link for the user).
9
+ #
10
+ # @param options [Hash] optional params
11
+ # @return [Response] with binding URL and session_id
12
+ def create_binding_link(**options)
13
+ post('/card/bind/session', options)
14
+ end
15
+
16
+ # Check binding session status.
17
+ #
18
+ # @param session_id [String] binding session ID
19
+ # @return [Response]
20
+ def binding_status(session_id)
21
+ get("/card/bind/status/#{encode_path(session_id)}")
22
+ end
23
+
24
+ # --- API-based card binding (PCI DSS required) ---
25
+
26
+ # Send SMS OTP for card binding.
27
+ #
28
+ # @param card_number [String] card number
29
+ # @param card_expiry [String] card expiry (MMYY)
30
+ # @return [Response]
31
+ def add(card_number:, card_expiry:)
32
+ post('/card/add', { number: card_number, expiry: card_expiry })
33
+ end
34
+
35
+ # Confirm card binding with SMS code.
36
+ #
37
+ # @param otp_code [String] SMS OTP code
38
+ # @param params [Hash] additional params
39
+ # @return [Response]
40
+ def confirm_binding(otp_code:, **params)
41
+ post('/card/bind/confirm', { code: otp_code, **params })
42
+ end
43
+
44
+ # --- Common operations ---
45
+
46
+ # Get card info by token.
47
+ #
48
+ # @param token [String] card token
49
+ # @return [Response]
50
+ def retrieve(token)
51
+ get("/card/#{encode_path(token)}")
52
+ end
53
+
54
+ # Check a card number (validate).
55
+ #
56
+ # @param card_number [String] card number
57
+ # @return [Response]
58
+ def check(card_number)
59
+ get("/card/check/#{encode_path(card_number)}")
60
+ end
61
+
62
+ # Verify card ownership via PINFL (personal ID).
63
+ #
64
+ # @param token [String] card token
65
+ # @param pinfl [String] personal identification number
66
+ # @return [Response]
67
+ def verify_pinfl(token:, pinfl:)
68
+ post('/card/verify/pinfl', { token: token, pinfl: pinfl })
69
+ end
70
+
71
+ # Revoke (unbind) a card token.
72
+ #
73
+ # @param token [String] card token
74
+ # @return [Response]
75
+ def revoke(token)
76
+ delete("/card/#{encode_path(token)}")
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Multicard
4
+ module Resources
5
+ class Holds < Base
6
+ # Create a hold (pre-authorization).
7
+ #
8
+ # @param card_token [String] card token
9
+ # @param amount [Integer] amount in tiyin
10
+ # @param invoice_id [String] your order ID
11
+ # @param store_id [Integer, nil] register ID
12
+ # @return [Response]
13
+ def create(card_token:, amount:, invoice_id:, store_id: nil, **options)
14
+ post('/hold', {
15
+ card: { token: card_token },
16
+ amount: amount,
17
+ store_id: store_id || default_store_id,
18
+ invoice_id: invoice_id,
19
+ **options
20
+ }.compact)
21
+ end
22
+
23
+ # Confirm a hold (block funds on card).
24
+ #
25
+ # @param hold_id [String] hold ID
26
+ # @param otp_code [String, nil] SMS OTP code if required
27
+ # @return [Response]
28
+ def confirm(hold_id, otp_code: nil)
29
+ body = otp_code ? { code: otp_code } : {}
30
+ post("/hold/#{encode_path(hold_id)}/confirm", body)
31
+ end
32
+
33
+ # Capture (debit) held funds. Full or partial.
34
+ #
35
+ # @param hold_id [String] hold ID
36
+ # @param amount [Integer, nil] partial capture amount (nil = full capture)
37
+ # @return [Response]
38
+ def capture(hold_id, amount: nil)
39
+ body = amount ? { amount: amount } : {}
40
+ post("/hold/#{encode_path(hold_id)}/charge", body)
41
+ end
42
+
43
+ # Retrieve hold info.
44
+ #
45
+ # @param hold_id [String] hold ID
46
+ # @return [Response]
47
+ def retrieve(hold_id)
48
+ get("/hold/#{encode_path(hold_id)}")
49
+ end
50
+
51
+ # Cancel a hold (release blocked funds).
52
+ #
53
+ # @param hold_id [String] hold ID
54
+ # @return [Response]
55
+ def cancel(hold_id)
56
+ delete("/hold/#{encode_path(hold_id)}")
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Multicard
4
+ module Resources
5
+ class Invoices < Base
6
+ # Create an invoice (hosted checkout page).
7
+ #
8
+ # @param amount [Integer] amount in tiyin
9
+ # @param invoice_id [String] your order ID
10
+ # @param callback_url [String] webhook URL
11
+ # @param store_id [Integer, nil] register ID (falls back to config.store_id)
12
+ # @param options [Hash] additional params (description, lifetime, return_url, etc.)
13
+ # @return [Response]
14
+ def create(amount:, invoice_id:, callback_url:, store_id: nil, **options)
15
+ post('/payment/invoice', {
16
+ amount: amount,
17
+ store_id: store_id || default_store_id,
18
+ invoice_id: invoice_id,
19
+ callback_url: callback_url,
20
+ **options
21
+ }.compact)
22
+ end
23
+
24
+ # Retrieve invoice info.
25
+ #
26
+ # @param invoice_id [String] invoice ID
27
+ # @return [Response]
28
+ def retrieve(invoice_id)
29
+ get("/invoice/#{encode_path(invoice_id)}")
30
+ end
31
+
32
+ # Cancel an unpaid invoice.
33
+ #
34
+ # @param invoice_id [String] invoice ID
35
+ # @return [Response]
36
+ def cancel(invoice_id)
37
+ delete("/invoice/#{encode_path(invoice_id)}")
38
+ end
39
+
40
+ # Generate a Quick Pay link (Payme, Click, Uzum QR, etc.).
41
+ #
42
+ # @param invoice_id [String] invoice ID
43
+ # @param service [String] payment service name
44
+ # @return [Response]
45
+ def quick_pay(invoice_id:, service:)
46
+ post('/invoice/quick-pay', {
47
+ invoice_id: invoice_id,
48
+ service: service
49
+ })
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,133 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Multicard
4
+ module Resources
5
+ class Payments < Base
6
+ # Pay by saved card token.
7
+ #
8
+ # @param card_token [String] card token from binding
9
+ # @param amount [Integer] amount in tiyin
10
+ # @param invoice_id [String] your order ID
11
+ # @param store_id [Integer, nil] register ID
12
+ # @param callback_url [String, nil] webhook URL
13
+ # @param ofd [Array<Hash>, nil] fiscal receipt items
14
+ # @return [Response]
15
+ def create_by_token(card_token:, amount:, invoice_id:, store_id: nil,
16
+ callback_url: nil, ofd: nil, **options)
17
+ post('/payment/token', {
18
+ card: { token: card_token },
19
+ amount: amount,
20
+ store_id: store_id || default_store_id,
21
+ invoice_id: invoice_id,
22
+ callback_url: callback_url,
23
+ ofd: ofd,
24
+ **options
25
+ }.compact)
26
+ end
27
+
28
+ # Pay by card number (requires PCI DSS).
29
+ #
30
+ # @param card_number [String] card number
31
+ # @param card_expiry [String] card expiry (MMYY or MM/YY)
32
+ # @param amount [Integer] amount in tiyin
33
+ # @param invoice_id [String] your order ID
34
+ # @return [Response]
35
+ def create_by_card(card_number:, card_expiry:, amount:, invoice_id:, store_id: nil,
36
+ callback_url: nil, ofd: nil, **options)
37
+ post('/payment/card', {
38
+ card: { number: card_number, expiry: card_expiry },
39
+ amount: amount,
40
+ store_id: store_id || default_store_id,
41
+ invoice_id: invoice_id,
42
+ callback_url: callback_url,
43
+ ofd: ofd,
44
+ **options
45
+ }.compact)
46
+ end
47
+
48
+ # Split payment across multiple recipients.
49
+ #
50
+ # @param card_token [String] card token
51
+ # @param amount [Integer] total amount in tiyin
52
+ # @param invoice_id [String] your order ID
53
+ # @param split [Array<Hash>] split recipients [{type:, amount:, details:, recipient:}]
54
+ # @return [Response]
55
+ def create_split(card_token:, amount:, invoice_id:, split:, store_id: nil,
56
+ callback_url: nil, ofd: nil, **options)
57
+ post('/payment/split', {
58
+ card: { token: card_token },
59
+ amount: amount,
60
+ store_id: store_id || default_store_id,
61
+ invoice_id: invoice_id,
62
+ callback_url: callback_url,
63
+ split: split,
64
+ ofd: ofd,
65
+ **options
66
+ }.compact)
67
+ end
68
+
69
+ # Pay via wallet app (Payme, Click, Uzum, etc.).
70
+ #
71
+ # @param service [String] wallet service name
72
+ # @param amount [Integer] amount in tiyin
73
+ # @param invoice_id [String] your order ID
74
+ # @return [Response]
75
+ def create_wallet(service:, amount:, invoice_id:, store_id: nil,
76
+ callback_url: nil, ofd: nil, **options)
77
+ post('/payment/app', {
78
+ service: service,
79
+ amount: amount,
80
+ store_id: store_id || default_store_id,
81
+ invoice_id: invoice_id,
82
+ callback_url: callback_url,
83
+ ofd: ofd,
84
+ **options
85
+ }.compact)
86
+ end
87
+
88
+ # Confirm a payment (submit OTP code if required).
89
+ #
90
+ # @param payment_id [String] payment ID
91
+ # @param otp_code [String, nil] SMS confirmation code
92
+ # @return [Response]
93
+ def confirm(payment_id, otp_code: nil)
94
+ body = otp_code ? { code: otp_code } : {}
95
+ post("/payment/#{encode_path(payment_id)}/confirm", body)
96
+ end
97
+
98
+ # Retrieve payment info.
99
+ #
100
+ # @param payment_id [String] payment ID (UUID)
101
+ # @return [Response]
102
+ def retrieve(payment_id)
103
+ get("/payment/#{encode_path(payment_id)}")
104
+ end
105
+
106
+ # Full refund.
107
+ #
108
+ # @param payment_id [String] payment ID (UUID)
109
+ # @return [Response]
110
+ def refund(payment_id)
111
+ delete("/payment/#{encode_path(payment_id)}")
112
+ end
113
+
114
+ # Partial refund.
115
+ #
116
+ # @param payment_id [String] payment ID (UUID)
117
+ # @param amount [Integer] refund amount in tiyin
118
+ # @return [Response]
119
+ def partial_refund(payment_id, amount:)
120
+ post("/payment/#{encode_path(payment_id)}/refund/partial", { amount: amount })
121
+ end
122
+
123
+ # Submit a fiscal receipt link.
124
+ #
125
+ # @param payment_id [String] payment ID (UUID)
126
+ # @param fiscal_url [String] URL of the fiscal receipt
127
+ # @return [Response]
128
+ def send_fiscal_link(payment_id, fiscal_url:)
129
+ post("/payment/#{encode_path(payment_id)}/fiscal", { fiscal_url: fiscal_url })
130
+ end
131
+ end
132
+ end
133
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Multicard
4
+ module Resources
5
+ class Payouts < Base
6
+ # Create a payout to a card.
7
+ #
8
+ # @param card_number [String] recipient card number
9
+ # @param amount [Integer] amount in tiyin
10
+ # @param options [Hash] additional params
11
+ # @return [Response]
12
+ def create(card_number:, amount:, **options)
13
+ post('/payout', {
14
+ card_number: card_number,
15
+ amount: amount,
16
+ **options
17
+ })
18
+ end
19
+
20
+ # Confirm a payout.
21
+ #
22
+ # @param payout_id [String] payout ID
23
+ # @return [Response]
24
+ def confirm(payout_id)
25
+ post("/payout/#{encode_path(payout_id)}/confirm")
26
+ end
27
+
28
+ # Retrieve payout info.
29
+ #
30
+ # @param payout_id [String] payout ID
31
+ # @return [Response]
32
+ def retrieve(payout_id)
33
+ get("/payout/#{encode_path(payout_id)}")
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Multicard
4
+ module Resources
5
+ class Registry < Base
6
+ # Get payment registry (list of processed payments).
7
+ #
8
+ # @param filters [Hash] filter params (date_from, date_to, status, etc.)
9
+ # @return [Response]
10
+ def payments(**filters)
11
+ get('/payments/registry', filters)
12
+ end
13
+
14
+ # Get payout history.
15
+ #
16
+ # @param filters [Hash] filter params
17
+ # @return [Response]
18
+ def payouts(**filters)
19
+ get('/payout/history', filters)
20
+ end
21
+
22
+ # Get application info.
23
+ #
24
+ # @return [Response]
25
+ def application_info
26
+ get('/app/info')
27
+ end
28
+
29
+ # Get merchant banking details.
30
+ #
31
+ # @return [Response]
32
+ def merchant_details
33
+ get('/merchant/details')
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Multicard
4
+ class Response
5
+ attr_reader :http_status, :body, :headers
6
+
7
+ def initialize(http_status:, body:, headers: {})
8
+ @http_status = http_status
9
+ @body = body
10
+ @headers = headers
11
+ end
12
+
13
+ def success?
14
+ body[:success] == true
15
+ end
16
+
17
+ def data
18
+ body[:data]
19
+ end
20
+
21
+ def error_code
22
+ body.dig(:error, :code)
23
+ end
24
+
25
+ def error_details
26
+ body.dig(:error, :details)
27
+ end
28
+
29
+ def [](key)
30
+ data&.[](key)
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest'
4
+
5
+ module Multicard
6
+ class Signature
7
+ # Verify a webhook callback signature.
8
+ #
9
+ # @param params [Hash] webhook parameters (:store_id, :invoice_id, :amount, :sign)
10
+ # @param secret [String] application secret
11
+ # @return [Boolean]
12
+ def self.verify(params, secret:)
13
+ store_id = params[:store_id].to_s
14
+ invoice_id = params[:invoice_id].to_s
15
+ amount = normalize_amount(params[:amount].to_s)
16
+ sign = params[:sign].to_s
17
+
18
+ return false if sign.empty?
19
+
20
+ expected = Digest::MD5.hexdigest("#{store_id}#{invoice_id}#{amount}#{secret}")
21
+ secure_compare(expected.downcase, sign.downcase)
22
+ end
23
+
24
+ # Constant-time string comparison to prevent timing attacks.
25
+ #
26
+ # Regular == short-circuits on the first mismatched byte, so an attacker
27
+ # can measure response time and brute-force the signature byte by byte.
28
+ # XOR-ing every pair and OR-accumulating makes execution time depend only
29
+ # on string length, not content.
30
+ #
31
+ # We implement this inline instead of using Rack::Utils.secure_compare
32
+ # to keep the gem dependency-free (no Rack requirement).
33
+ def self.secure_compare(a, b)
34
+ return false unless a.bytesize == b.bytesize
35
+
36
+ a.bytes.zip(b.bytes).reduce(0) { |acc, (x, y)| acc | (x ^ y) }.zero?
37
+ end
38
+
39
+ # Normalize amount: remove trailing ".0" / ".00" / ".000" etc.
40
+ #
41
+ # Multicard callbacks may send amounts as "50000", "50000.0", or "50000.00".
42
+ # The signature is always computed against the integer form (no decimals).
43
+ #
44
+ # Only trailing zeros are stripped (not arbitrary decimals like "50000.10")
45
+ # because Multicard amounts are in tiyin (1/100 of UZS) — always integers.
46
+ # Non-zero decimal digits would indicate a bug in the callback, not valid data.
47
+ def self.normalize_amount(amount)
48
+ amount.sub(/\.0+\z/, '')
49
+ end
50
+
51
+ private_class_method :secure_compare, :normalize_amount
52
+ end
53
+ end