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
|
@@ -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
|