nodexpay 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,240 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "uri"
5
+
6
+ module NodexPay
7
+ class Client
8
+ BASE_URLS = {
9
+ production: "https://app.nodexpay.com/api/v1",
10
+ testnet: "https://testnet.app.nodexpay.com/api/v1"
11
+ }.freeze
12
+
13
+ DEFAULT_OPEN_TIMEOUT = 5
14
+ DEFAULT_READ_TIMEOUT = 30
15
+ DEFAULT_WRITE_TIMEOUT = 30
16
+
17
+ attr_reader :environment, :open_timeout, :read_timeout, :write_timeout
18
+
19
+ def initialize(
20
+ identification_token:,
21
+ hash_token:,
22
+ environment:,
23
+ open_timeout: DEFAULT_OPEN_TIMEOUT,
24
+ read_timeout: DEFAULT_READ_TIMEOUT,
25
+ write_timeout: DEFAULT_WRITE_TIMEOUT,
26
+ transport: nil
27
+ )
28
+ unless BASE_URLS.key?(environment)
29
+ raise ConfigurationError, "environment must be :production or :testnet"
30
+ end
31
+
32
+ @identification_token = Validator.credential(identification_token, "identification_token")
33
+ @hash_token = Validator.credential(hash_token, "hash_token")
34
+ @environment = environment
35
+ @open_timeout = Validator.timeout(open_timeout, "open_timeout")
36
+ @read_timeout = Validator.timeout(read_timeout, "read_timeout")
37
+ @write_timeout = Validator.timeout(write_timeout, "write_timeout")
38
+ @transport = transport || Transport::NetHTTP.new
39
+
40
+ unless @transport.respond_to?(:call)
41
+ raise ConfigurationError, "transport must respond to #call"
42
+ end
43
+
44
+ freeze
45
+ end
46
+
47
+ def create_payment(
48
+ order_code:,
49
+ amount: nil,
50
+ amount_type: nil,
51
+ color_theme: nil,
52
+ ui_mode: nil,
53
+ ext_description: nil,
54
+ deadline: nil,
55
+ description: nil
56
+ )
57
+ order_code = Validator.order_code(order_code)
58
+ amount = AmountSerializer.serialize(amount) unless amount.nil?
59
+
60
+ fields = authenticated_fields(order_code:, amount:)
61
+ fields["amount"] = amount unless amount.nil?
62
+ add_optional_field(fields, "amount_type", Validator.amount_type(amount_type))
63
+ add_optional_field(fields, "color_theme", Validator.color_theme(color_theme))
64
+ add_optional_field(fields, "uimode", Validator.ui_mode(ui_mode))
65
+ add_optional_field(fields, "ext_description", Validator.text(ext_description, "ext_description"))
66
+ add_optional_field(fields, "deadline", Validator.deadline(deadline))
67
+ add_optional_field(fields, "description", Validator.text(description, "description"))
68
+
69
+ response = perform(:create_payment, "/payment/receive", fields)
70
+ decode_payment(response.body)
71
+ end
72
+
73
+ def cancel_payment(order_code:)
74
+ order_code = Validator.order_code(order_code)
75
+ fields = authenticated_fields(order_code:, amount: nil)
76
+
77
+ perform(:cancel_payment, "/payment/cancel", fields)
78
+ true
79
+ end
80
+
81
+ def verify_payment_result(raw_body)
82
+ unless raw_body.is_a?(String)
83
+ raise InvalidResponseError, "Payment Result body must be a String"
84
+ end
85
+
86
+ payload = parse_json_object(raw_body, context: "Payment Result")
87
+ order_code = Validator.webhook_string(payload, "order_code")
88
+ transaction_code = Validator.transaction_code(payload)
89
+ amount = Validator.webhook_string(payload, "amount")
90
+ symbol = Validator.webhook_string(payload, "symbol")
91
+ result = Validator.webhook_result(payload)
92
+ verify_token = Validator.verify_token(payload)
93
+ chain_id = Validator.webhook_string(payload, "chain_id")
94
+ transaction_hash = Validator.webhook_string(payload, "transaction_hash")
95
+
96
+ unless Signer.valid?(order_code:, amount:, hash_token: @hash_token, signature: verify_token)
97
+ raise SignatureVerificationError, "Payment Result signature verification failed"
98
+ end
99
+
100
+ PaymentResult.new(
101
+ order_code:,
102
+ transaction_code:,
103
+ amount:,
104
+ symbol:,
105
+ result:,
106
+ chain_id:,
107
+ transaction_hash:
108
+ )
109
+ end
110
+
111
+ def inspect
112
+ "#<#{self.class} environment=#{environment.inspect}>"
113
+ end
114
+
115
+ private
116
+
117
+ def authenticated_fields(order_code:, amount:)
118
+ {
119
+ "identification_token" => @identification_token,
120
+ "order_code" => order_code,
121
+ "verify_token" => Signer.generate(order_code:, amount:, hash_token: @hash_token)
122
+ }
123
+ end
124
+
125
+ def add_optional_field(fields, name, value)
126
+ fields[name] = value unless value.nil?
127
+ end
128
+
129
+ def perform(operation, path, fields)
130
+ request = Transport::Request.new(
131
+ method: :post,
132
+ uri: URI("#{BASE_URLS.fetch(environment)}#{path}"),
133
+ headers: request_headers,
134
+ body: URI.encode_www_form(fields),
135
+ open_timeout:,
136
+ read_timeout:,
137
+ write_timeout:
138
+ )
139
+
140
+ response = @transport.call(request)
141
+ validate_transport_response!(response)
142
+
143
+ case response.status
144
+ when 200
145
+ response
146
+ when 400
147
+ raise_api_error(BadRequestError, operation, response)
148
+ when 503
149
+ raise_api_error(ServiceUnavailableError, operation, response)
150
+ else
151
+ raise_api_error(UnexpectedHTTPStatusError, operation, response)
152
+ end
153
+ end
154
+
155
+ def request_headers
156
+ {
157
+ "Accept" => "application/json",
158
+ "Content-Type" => "application/x-www-form-urlencoded",
159
+ "User-Agent" => "nodexpay/#{VERSION} ruby/#{RUBY_VERSION}"
160
+ }.freeze
161
+ end
162
+
163
+ def validate_transport_response!(response)
164
+ valid = response.is_a?(Transport::Response)
165
+ valid &&= response.status.is_a?(Integer)
166
+ valid &&= response.headers.is_a?(Hash)
167
+ valid &&= response.headers.all? do |key, value|
168
+ key.is_a?(String) && key == key.downcase && value.is_a?(String)
169
+ end
170
+ valid &&= response.body.is_a?(String)
171
+ return if valid
172
+
173
+ raise InvalidResponseError, "transport returned an invalid response"
174
+ end
175
+
176
+ def raise_api_error(error_class, operation, response)
177
+ raise error_class.new(
178
+ operation:,
179
+ status: response.status,
180
+ error_codes: extract_error_codes(response.body),
181
+ response_headers: response.headers,
182
+ response_body: response.body
183
+ )
184
+ end
185
+
186
+ def extract_error_codes(body)
187
+ payload = JSON.parse(body)
188
+ return [] unless payload.is_a?(Hash)
189
+
190
+ values = if payload.key?("errors")
191
+ payload["errors"]
192
+ elsif payload["data"].is_a?(Hash)
193
+ payload["data"]["errors"]
194
+ end
195
+
196
+ Array(values).filter_map do |value|
197
+ case value
198
+ when Integer then value
199
+ when String then Integer(value, 10) if /\A[0-9]+\z/.match?(value)
200
+ end
201
+ end
202
+ rescue JSON::ParserError
203
+ []
204
+ end
205
+
206
+ def decode_payment(body)
207
+ payload = parse_json_object(body, context: "Payment Request response")
208
+ url = required_response_string(payload, "url")
209
+ token = required_response_string(payload, "token")
210
+ ext_reserved = optional_response_string(payload, "ext_reserved")
211
+ ext_description = optional_response_string(payload, "ext_description")
212
+
213
+ Payment.new(url:, token:, ext_reserved:, ext_description:)
214
+ end
215
+
216
+ def parse_json_object(body, context:)
217
+ payload = JSON.parse(body)
218
+ return payload if payload.is_a?(Hash)
219
+
220
+ raise InvalidResponseError, "#{context} must be a JSON object"
221
+ rescue JSON::ParserError
222
+ raise InvalidResponseError, "#{context} is not valid JSON"
223
+ end
224
+
225
+ def required_response_string(payload, field)
226
+ value = payload[field]
227
+ return value.dup.freeze if value.is_a?(String) && !value.empty?
228
+
229
+ raise InvalidResponseError, "Payment Request response field #{field.inspect} must be a non-empty String"
230
+ end
231
+
232
+ def optional_response_string(payload, field)
233
+ value = payload[field]
234
+ return nil if value.nil?
235
+ return value.dup.freeze if value.is_a?(String)
236
+
237
+ raise InvalidResponseError, "Payment Request response field #{field.inspect} must be a String or null"
238
+ end
239
+ end
240
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NodexPay
4
+ class Error < StandardError; end
5
+
6
+ class ConfigurationError < Error; end
7
+ class ValidationError < Error; end
8
+ class SignatureVerificationError < Error; end
9
+ class TransportError < Error; end
10
+ class TimeoutError < TransportError; end
11
+ class InvalidResponseError < Error; end
12
+
13
+ class APIError < Error
14
+ ERROR_NAMES = {
15
+ 2010 => "RECEIVED_IDENTIFICATION_TOKEN_EMPTY",
16
+ 2011 => "RECEIVED_IDENTIFICATION_TOKEN_INVALID",
17
+ 2020 => "RECEIVED_ORDER_CODE_EMPTY",
18
+ 2021 => "RECEIVED_ORDER_CODE_DUPLICATE",
19
+ 2022 => "RECEIVED_ORDER_CODE_ALREADY_CANCELLED",
20
+ 2023 => "PAYMENT_NOT_FOUND",
21
+ 2024 => "PAYMENT_NOT_CANCELABLE",
22
+ 2030 => "RECEIVED_AMOUNT_INVALID_FORMAT",
23
+ 2040 => "RECEIVED_INVALID_CALLBACK_URL",
24
+ 2050 => "RECEIVED_COLOR_THEME_INVALID_FORMAT",
25
+ 2060 => "RECEIVED_VERIFY_TOKEN_EMPTY",
26
+ 2061 => "RECEIVED_VERIFY_TOKEN_INVALID",
27
+ 2101 => "RECEIVED_EXT_RESERVED_INVALID",
28
+ 2102 => "RECEIVED_EXT_DESCRIPTION_INVALID",
29
+ 2370 => "RECEIVE_CONTRACT_UNPUBLISHED",
30
+ 2460 => "RECEIVED_INVALID_DEADLINE"
31
+ }.freeze
32
+
33
+ attr_reader :operation, :status, :error_codes, :response_headers, :response_body
34
+
35
+ def initialize(operation:, status:, error_codes:, response_headers:, response_body:)
36
+ @operation = operation
37
+ @status = status
38
+ @error_codes = error_codes.map { Integer(_1) }.freeze
39
+ @response_headers = response_headers.to_h.transform_keys(&:to_s).transform_values(&:to_s).freeze
40
+ @response_body = response_body.to_s.dup.freeze
41
+
42
+ details = @error_codes.map { |code| ERROR_NAMES.fetch(code, code.to_s) }
43
+ suffix = details.empty? ? "" : " (#{details.join(", ")})"
44
+ super("Nodex Pay #{operation} failed with HTTP #{status}#{suffix}")
45
+ end
46
+
47
+ def inspect
48
+ "#<#{self.class} operation=#{operation.inspect} status=#{status.inspect} error_codes=#{error_codes.inspect}>"
49
+ end
50
+ end
51
+
52
+ class BadRequestError < APIError; end
53
+ class ServiceUnavailableError < APIError; end
54
+ class UnexpectedHTTPStatusError < APIError; end
55
+ end
56
+
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NodexPay
4
+ Payment = Data.define(:url, :token, :ext_reserved, :ext_description) do
5
+ def initialize(url:, token:, ext_reserved:, ext_description:)
6
+ super(
7
+ url: url.dup.freeze,
8
+ token: token.dup.freeze,
9
+ ext_reserved: ext_reserved&.dup&.freeze,
10
+ ext_description: ext_description&.dup&.freeze
11
+ )
12
+ end
13
+ end
14
+
15
+ PaymentResult = Data.define(
16
+ :order_code,
17
+ :transaction_code,
18
+ :amount,
19
+ :symbol,
20
+ :result,
21
+ :chain_id,
22
+ :transaction_hash
23
+ ) do
24
+ def initialize(order_code:, transaction_code:, amount:, symbol:, result:, chain_id:, transaction_hash:)
25
+ super(
26
+ order_code: order_code.dup.freeze,
27
+ transaction_code: transaction_code.dup.freeze,
28
+ amount: amount.dup.freeze,
29
+ symbol: symbol.dup.freeze,
30
+ result:,
31
+ chain_id: chain_id.dup.freeze,
32
+ transaction_hash: transaction_hash.dup.freeze
33
+ )
34
+ end
35
+
36
+ def success?
37
+ result
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "openssl"
5
+
6
+ module NodexPay
7
+ module Signer
8
+ module_function
9
+
10
+ def generate(order_code:, amount:, hash_token:)
11
+ Digest::SHA256.hexdigest([order_code, amount, hash_token].join("::"))
12
+ end
13
+
14
+ def valid?(order_code:, amount:, hash_token:, signature:)
15
+ expected = generate(order_code:, amount:, hash_token:)
16
+ return false unless signature.bytesize == expected.bytesize
17
+
18
+ OpenSSL.fixed_length_secure_compare(expected, signature)
19
+ end
20
+ end
21
+ end
22
+
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "openssl"
5
+ require "uri"
6
+
7
+ module NodexPay
8
+ module Transport
9
+ Request = Data.define(
10
+ :method,
11
+ :uri,
12
+ :headers,
13
+ :body,
14
+ :open_timeout,
15
+ :read_timeout,
16
+ :write_timeout
17
+ ) do
18
+ def initialize(method:, uri:, headers:, body:, open_timeout:, read_timeout:, write_timeout:)
19
+ super(
20
+ method:,
21
+ uri: uri.dup.freeze,
22
+ headers: immutable_headers(headers),
23
+ body: body.dup.freeze,
24
+ open_timeout:,
25
+ read_timeout:,
26
+ write_timeout:
27
+ )
28
+ end
29
+
30
+ private
31
+
32
+ def immutable_headers(headers)
33
+ headers.to_h.each_with_object({}) do |(key, value), copy|
34
+ copy[key.dup.freeze] = value.dup.freeze
35
+ end.freeze
36
+ end
37
+ end
38
+
39
+ Response = Data.define(:status, :headers, :body) do
40
+ def initialize(status:, headers:, body:)
41
+ immutable_headers = headers.to_h.each_with_object({}) do |(key, value), copy|
42
+ copy[key.dup.freeze] = value.dup.freeze
43
+ end.freeze
44
+
45
+ super(status:, headers: immutable_headers, body: body.dup.freeze)
46
+ end
47
+ end
48
+
49
+ class NetHTTP
50
+ def call(request)
51
+ http = Net::HTTP.new(request.uri.host, request.uri.port)
52
+ http.use_ssl = request.uri.scheme == "https"
53
+ http.verify_mode = OpenSSL::SSL::VERIFY_PEER if http.use_ssl?
54
+ http.open_timeout = request.open_timeout
55
+ http.read_timeout = request.read_timeout
56
+ http.write_timeout = request.write_timeout
57
+ http.max_retries = 0
58
+
59
+ net_request = build_request(request)
60
+ net_response = http.request(net_request)
61
+
62
+ Response.new(
63
+ status: net_response.code.to_i,
64
+ headers: response_headers(net_response),
65
+ body: net_response.body.to_s
66
+ )
67
+ rescue Net::OpenTimeout, Net::ReadTimeout, Net::WriteTimeout => error
68
+ raise TimeoutError.new("Nodex Pay request timed out for #{safe_target(request.uri)}"), cause: error
69
+ rescue SocketError, SystemCallError, IOError, EOFError, OpenSSL::SSL::SSLError => error
70
+ raise TransportError.new("Nodex Pay request failed for #{safe_target(request.uri)}"), cause: error
71
+ end
72
+
73
+ private
74
+
75
+ def build_request(request)
76
+ unless request.method == :post
77
+ raise ConfigurationError, "NetHTTP transport only supports POST"
78
+ end
79
+
80
+ Net::HTTP::Post.new(request.uri.request_uri, request.headers).tap do |net_request|
81
+ net_request.body = request.body
82
+ end
83
+ end
84
+
85
+ def response_headers(response)
86
+ response.each_header.to_h.transform_keys(&:downcase).transform_values(&:to_s).freeze
87
+ end
88
+
89
+ def safe_target(uri)
90
+ "#{uri.scheme}://#{uri.host}:#{uri.port}#{uri.path}"
91
+ end
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NodexPay
4
+ module Validator
5
+ AMOUNT_TYPES = %w[USD JPY EUR AED SGD HKD CAD IDR PHP INR KRW].freeze
6
+ COLOR_THEMES = %w[dark light].freeze
7
+ UI_MODES = %w[switchable].freeze
8
+ TRANSACTION_CODE_PATTERN = /\A[A-Za-z0-9]{4}(?:-[A-Za-z0-9]{4}){2}\z/
9
+ VERIFY_TOKEN_PATTERN = /\A[0-9a-f]{64}\z/
10
+
11
+ module_function
12
+
13
+ def credential(value, name)
14
+ return value.dup.freeze if value.is_a?(String) && !value.empty?
15
+
16
+ raise ConfigurationError, "#{name} must be a non-empty String"
17
+ end
18
+
19
+ def timeout(value, name)
20
+ valid = value.is_a?(Numeric) && value.positive?
21
+ valid &&= !value.respond_to?(:finite?) || value.finite?
22
+ return value if valid
23
+
24
+ raise ConfigurationError, "#{name} must be a positive finite number"
25
+ end
26
+
27
+ def order_code(value)
28
+ unless value.is_a?(String) && !value.empty? && value.ascii_only? && value.bytesize <= 255
29
+ raise ValidationError, "order_code must be 1..255 ASCII bytes"
30
+ end
31
+
32
+ value.dup.freeze
33
+ end
34
+
35
+ def amount_type(value)
36
+ enum(value, "amount_type", AMOUNT_TYPES, :upcase)
37
+ end
38
+
39
+ def color_theme(value)
40
+ enum(value, "color_theme", COLOR_THEMES, :downcase)
41
+ end
42
+
43
+ def ui_mode(value)
44
+ enum(value, "ui_mode", UI_MODES, :downcase)
45
+ end
46
+
47
+ def text(value, name)
48
+ return nil if value.nil?
49
+ unless value.is_a?(String) && value.encoding == Encoding::UTF_8 && value.valid_encoding? && value.length <= 100
50
+ raise ValidationError, "#{name} must be a valid UTF-8 String of at most 100 characters"
51
+ end
52
+
53
+ value.dup.freeze
54
+ end
55
+
56
+ def deadline(value)
57
+ return nil if value.nil?
58
+
59
+ seconds = value.is_a?(Time) ? value.to_i : value
60
+ return seconds if seconds.is_a?(Integer) && !seconds.negative?
61
+
62
+ raise ValidationError, "deadline must be a Time or non-negative Integer"
63
+ end
64
+
65
+ def webhook_string(payload, field)
66
+ value = payload[field]
67
+ return value.dup.freeze if value.is_a?(String) && !value.empty?
68
+
69
+ raise InvalidResponseError, "Payment Result field #{field.inspect} must be a non-empty String"
70
+ end
71
+
72
+ def transaction_code(payload)
73
+ value = webhook_string(payload, "transaction_code")
74
+ return value if TRANSACTION_CODE_PATTERN.match?(value)
75
+
76
+ raise InvalidResponseError, "Payment Result transaction_code has an invalid format"
77
+ end
78
+
79
+ def verify_token(payload)
80
+ value = webhook_string(payload, "verify_token")
81
+ return value if VERIFY_TOKEN_PATTERN.match?(value)
82
+
83
+ raise InvalidResponseError, "Payment Result verify_token has an invalid format"
84
+ end
85
+
86
+ def webhook_result(payload)
87
+ value = payload["result"]
88
+ return value if value.equal?(true) || value.equal?(false)
89
+
90
+ raise InvalidResponseError, "Payment Result field \"result\" must be a Boolean"
91
+ end
92
+
93
+ def enum(value, name, allowed, transform)
94
+ return nil if value.nil?
95
+ unless value.is_a?(String) || value.is_a?(Symbol)
96
+ raise ValidationError, "#{name} must be one of: #{allowed.join(", ")}"
97
+ end
98
+
99
+ normalized = value.to_s.public_send(transform)
100
+ return normalized.freeze if allowed.include?(normalized)
101
+
102
+ raise ValidationError, "#{name} must be one of: #{allowed.join(", ")}"
103
+ end
104
+ private_class_method :enum
105
+ end
106
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NodexPay
4
+ VERSION = '0.1.0'
5
+ end
data/lib/nodex_pay.rb ADDED
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "nodex_pay/version"
4
+ require_relative "nodex_pay/errors"
5
+ require_relative "nodex_pay/models"
6
+ require_relative "nodex_pay/amount_serializer"
7
+ require_relative "nodex_pay/validator"
8
+ require_relative "nodex_pay/signer"
9
+ require_relative "nodex_pay/transport"
10
+ require_relative "nodex_pay/client"
11
+