dpay 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.
Files changed (137) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +29 -0
  3. data/LICENSE +201 -0
  4. data/README.md +191 -0
  5. data/lib/dpay/bank/bank.rb +28 -0
  6. data/lib/dpay/bank/bank_service.rb +28 -0
  7. data/lib/dpay/blik/blik_alias.rb +27 -0
  8. data/lib/dpay/blik/blik_alias_registration.rb +21 -0
  9. data/lib/dpay/blik/blik_alias_type.rb +18 -0
  10. data/lib/dpay/blik/blik_app.rb +17 -0
  11. data/lib/dpay/blik/blik_recurring_registration.rb +74 -0
  12. data/lib/dpay/blik/blik_recurring_registration_info.rb +26 -0
  13. data/lib/dpay/blik/blik_recurring_status.rb +28 -0
  14. data/lib/dpay/blik/blik_service.rb +51 -0
  15. data/lib/dpay/card/apple_pay_request.rb +38 -0
  16. data/lib/dpay/card/card_data.rb +30 -0
  17. data/lib/dpay/card/card_encryptor.rb +27 -0
  18. data/lib/dpay/card/card_payment_request.rb +59 -0
  19. data/lib/dpay/card/card_payment_result.rb +58 -0
  20. data/lib/dpay/card/card_recurring_frequency.rb +23 -0
  21. data/lib/dpay/card/card_recurring_operation.rb +19 -0
  22. data/lib/dpay/card/card_recurring_registration.rb +69 -0
  23. data/lib/dpay/card/card_service.rb +47 -0
  24. data/lib/dpay/card/dcc_decision.rb +18 -0
  25. data/lib/dpay/card/dcc_markup.rb +18 -0
  26. data/lib/dpay/card/dcc_offer.rb +36 -0
  27. data/lib/dpay/card/google_pay_request.rb +35 -0
  28. data/lib/dpay/card/redirect_type.rb +20 -0
  29. data/lib/dpay/client.rb +26 -0
  30. data/lib/dpay/config.rb +39 -0
  31. data/lib/dpay/currency.rb +19 -0
  32. data/lib/dpay/errors.rb +77 -0
  33. data/lib/dpay/http/client.rb +11 -0
  34. data/lib/dpay/http/net_http_client.rb +52 -0
  35. data/lib/dpay/http/request.rb +17 -0
  36. data/lib/dpay/http/response.rb +29 -0
  37. data/lib/dpay/internal/api_requestor.rb +65 -0
  38. data/lib/dpay/internal/base_urls.rb +38 -0
  39. data/lib/dpay/internal/checksum_calculator.rb +26 -0
  40. data/lib/dpay/internal/coerce.rb +46 -0
  41. data/lib/dpay/internal/error_mapper.rb +90 -0
  42. data/lib/dpay/internal/php.rb +71 -0
  43. data/lib/dpay/ipn/ipn_event.rb +84 -0
  44. data/lib/dpay/ipn/ipn_type.rb +19 -0
  45. data/lib/dpay/ipn/ipn_verifier.rb +57 -0
  46. data/lib/dpay/money.rb +85 -0
  47. data/lib/dpay/payment/device_info.rb +42 -0
  48. data/lib/dpay/payment/invoice_details.rb +48 -0
  49. data/lib/dpay/payment/payer.rb +26 -0
  50. data/lib/dpay/payment/payment_service.rb +35 -0
  51. data/lib/dpay/payment/payout_fee_mode.rb +18 -0
  52. data/lib/dpay/payment/payout_instruction.rb +26 -0
  53. data/lib/dpay/payment/payout_position.rb +23 -0
  54. data/lib/dpay/payment/register_payment_request.rb +246 -0
  55. data/lib/dpay/payment/registered_payment.rb +42 -0
  56. data/lib/dpay/payment/return_urls.rb +20 -0
  57. data/lib/dpay/payment/transaction.rb +54 -0
  58. data/lib/dpay/payment/transaction_refund.rb +21 -0
  59. data/lib/dpay/payment/transaction_status.rb +21 -0
  60. data/lib/dpay/payment/transaction_type.rb +23 -0
  61. data/lib/dpay/payout/payout_details.rb +50 -0
  62. data/lib/dpay/payout/payout_receiver.rb +22 -0
  63. data/lib/dpay/payout/payout_service.rb +23 -0
  64. data/lib/dpay/refund/refund.rb +22 -0
  65. data/lib/dpay/refund/refund_availability.rb +23 -0
  66. data/lib/dpay/refund/refund_service.rb +46 -0
  67. data/lib/dpay/testing/mock_http_client.rb +51 -0
  68. data/lib/dpay/testing.rb +4 -0
  69. data/lib/dpay/version.rb +5 -0
  70. data/lib/dpay.rb +68 -0
  71. data/sig/dpay/bank/bank.rbs +17 -0
  72. data/sig/dpay/bank/bank_service.rbs +13 -0
  73. data/sig/dpay/blik/blik_alias.rbs +17 -0
  74. data/sig/dpay/blik/blik_alias_registration.rbs +6 -0
  75. data/sig/dpay/blik/blik_alias_type.rbs +11 -0
  76. data/sig/dpay/blik/blik_app.rbs +10 -0
  77. data/sig/dpay/blik/blik_recurring_registration.rbs +24 -0
  78. data/sig/dpay/blik/blik_recurring_registration_info.rbs +17 -0
  79. data/sig/dpay/blik/blik_recurring_status.rbs +17 -0
  80. data/sig/dpay/blik/blik_service.rbs +13 -0
  81. data/sig/dpay/card/apple_pay_request.rbs +13 -0
  82. data/sig/dpay/card/card_data.rbs +15 -0
  83. data/sig/dpay/card/card_encryptor.rbs +9 -0
  84. data/sig/dpay/card/card_payment_request.rbs +17 -0
  85. data/sig/dpay/card/card_payment_result.rbs +22 -0
  86. data/sig/dpay/card/card_recurring_frequency.rbs +16 -0
  87. data/sig/dpay/card/card_recurring_operation.rbs +12 -0
  88. data/sig/dpay/card/card_recurring_registration.rbs +22 -0
  89. data/sig/dpay/card/card_service.rbs +16 -0
  90. data/sig/dpay/card/dcc_decision.rbs +11 -0
  91. data/sig/dpay/card/dcc_markup.rbs +10 -0
  92. data/sig/dpay/card/dcc_offer.rbs +21 -0
  93. data/sig/dpay/card/google_pay_request.rbs +12 -0
  94. data/sig/dpay/card/redirect_type.rbs +13 -0
  95. data/sig/dpay/client.rbs +21 -0
  96. data/sig/dpay/config.rbs +25 -0
  97. data/sig/dpay/currency.rbs +11 -0
  98. data/sig/dpay/errors.rbs +86 -0
  99. data/sig/dpay/http/client.rbs +7 -0
  100. data/sig/dpay/http/net_http_client.rbs +15 -0
  101. data/sig/dpay/http/request.rbs +12 -0
  102. data/sig/dpay/http/response.rbs +15 -0
  103. data/sig/dpay/internal/api_requestor.rbs +23 -0
  104. data/sig/dpay/internal/base_urls.rbs +13 -0
  105. data/sig/dpay/internal/checksum_calculator.rbs +9 -0
  106. data/sig/dpay/internal/coerce.rbs +12 -0
  107. data/sig/dpay/internal/error_mapper.rbs +16 -0
  108. data/sig/dpay/internal/php.rbs +16 -0
  109. data/sig/dpay/ipn/ipn_event.rbs +28 -0
  110. data/sig/dpay/ipn/ipn_type.rbs +12 -0
  111. data/sig/dpay/ipn/ipn_verifier.rbs +11 -0
  112. data/sig/dpay/money.rbs +25 -0
  113. data/sig/dpay/payment/device_info.rbs +25 -0
  114. data/sig/dpay/payment/invoice_details.rbs +17 -0
  115. data/sig/dpay/payment/payer.rbs +14 -0
  116. data/sig/dpay/payment/payment_service.rbs +11 -0
  117. data/sig/dpay/payment/payout_fee_mode.rbs +11 -0
  118. data/sig/dpay/payment/payout_instruction.rbs +8 -0
  119. data/sig/dpay/payment/payout_position.rbs +10 -0
  120. data/sig/dpay/payment/register_payment_request.rbs +56 -0
  121. data/sig/dpay/payment/registered_payment.rbs +20 -0
  122. data/sig/dpay/payment/return_urls.rbs +11 -0
  123. data/sig/dpay/payment/transaction.rbs +25 -0
  124. data/sig/dpay/payment/transaction_refund.rbs +14 -0
  125. data/sig/dpay/payment/transaction_status.rbs +14 -0
  126. data/sig/dpay/payment/transaction_type.rbs +16 -0
  127. data/sig/dpay/payout/payout_details.rbs +24 -0
  128. data/sig/dpay/payout/payout_receiver.rbs +15 -0
  129. data/sig/dpay/payout/payout_service.rbs +6 -0
  130. data/sig/dpay/refund/refund.rbs +11 -0
  131. data/sig/dpay/refund/refund_availability.rbs +12 -0
  132. data/sig/dpay/refund/refund_service.rbs +14 -0
  133. data/sig/dpay/testing/mock_http_client.rbs +16 -0
  134. data/sig/dpay/testing.rbs +4 -0
  135. data/sig/dpay/version.rbs +3 -0
  136. data/sig/dpay.rbs +2 -0
  137. metadata +180 -0
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DPay
4
+ module Error; end
5
+
6
+ class InvalidArgumentError < ArgumentError
7
+ include Error
8
+ end
9
+
10
+ class TransportError < StandardError
11
+ include Error
12
+ end
13
+
14
+ class SignatureVerificationError < StandardError
15
+ include Error
16
+ end
17
+
18
+ class CardEncryptionError < StandardError
19
+ include Error
20
+ end
21
+
22
+ class ApiError < StandardError
23
+ include Error
24
+
25
+ attr_reader :http_status, :error_code, :field_errors, :raw_body
26
+
27
+ def initialize(message, http_status, error_code = nil, field_errors = {}, raw_body = "")
28
+ super(message)
29
+ @http_status = http_status
30
+ @error_code = error_code
31
+ @field_errors = field_errors
32
+ @raw_body = raw_body
33
+ end
34
+ end
35
+
36
+ class AuthenticationError < ApiError; end
37
+ class InvalidRequestError < ApiError; end
38
+ class AccessDeniedError < ApiError; end
39
+ class NotFoundError < ApiError; end
40
+ class ApiServerError < ApiError; end
41
+
42
+ class RateLimitError < ApiError
43
+ attr_reader :retry_after, :limit, :remaining
44
+
45
+ def initialize(message, http_status, retry_after = nil, limit = nil, remaining = nil, raw_body = "")
46
+ super(message, http_status, nil, {}, raw_body)
47
+ @retry_after = retry_after
48
+ @limit = limit
49
+ @remaining = remaining
50
+ end
51
+ end
52
+
53
+ class PaymentRejectedError < ApiError
54
+ attr_reader :transaction_id
55
+
56
+ def self.from_api(data)
57
+ message = %w[message msg].filter_map { |key| data[key] if data[key].is_a?(String) }.first || "Payment rejected"
58
+ error_code = data["errorcode"].is_a?(String) ? data["errorcode"] : nil
59
+ transaction_id = data["transactionId"].nil? ? nil : Internal::PHP.strval(data["transactionId"])
60
+
61
+ new(message, 200, error_code, {}, Internal::PHP.json_encode(data), transaction_id)
62
+ end
63
+
64
+ def initialize(message, http_status, error_code = nil, field_errors = {}, raw_body = "", transaction_id = nil)
65
+ super(message, http_status, error_code, field_errors, raw_body)
66
+ @transaction_id = transaction_id
67
+ end
68
+ end
69
+
70
+ class CardPaymentError < ApiError
71
+ def self.from_api(data)
72
+ message = data["message"].is_a?(String) && !data["message"].empty? ? data["message"] : "Card payment rejected"
73
+
74
+ new(message, 200, message, {}, Internal::PHP.json_encode(data))
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DPay
4
+ module HTTP
5
+ class Client
6
+ def request(_api_request)
7
+ raise NotImplementedError, "#{self.class}#request must return a DPay::HTTP::Response"
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "openssl"
5
+ require "uri"
6
+
7
+ module DPay
8
+ module HTTP
9
+ class NetHTTPClient < Client
10
+ CONNECT_TIMEOUT = 10
11
+
12
+ NETWORK_ERRORS = [
13
+ Timeout::Error, SocketError, IOError, SystemCallError, OpenSSL::SSL::SSLError, Net::HTTPBadResponse
14
+ ].freeze
15
+
16
+ def initialize(timeout = 30)
17
+ super()
18
+ @timeout = timeout
19
+ end
20
+
21
+ def request(api_request)
22
+ uri = URI.parse(api_request.url)
23
+ raise TransportError, "Unsupported URL scheme: #{api_request.url}" unless uri.is_a?(URI::HTTP)
24
+
25
+ host = uri.host
26
+ raise TransportError, "URL has no host: #{api_request.url}" if host.nil?
27
+
28
+ http = Net::HTTP.new(host, uri.port)
29
+ http.use_ssl = uri.scheme == "https"
30
+ http.open_timeout = CONNECT_TIMEOUT
31
+ http.read_timeout = @timeout
32
+ http.write_timeout = @timeout
33
+
34
+ response = http.request(build_request(uri, api_request))
35
+
36
+ Response.new(response.code.to_i, response.each_header.to_h, response.body.to_s)
37
+ rescue *NETWORK_ERRORS => e
38
+ raise TransportError, "HTTP transport error: #{e.class}: #{e.message}"
39
+ end
40
+
41
+ private
42
+
43
+ def build_request(uri, api_request)
44
+ klass = Net::HTTP.const_get(api_request.method.to_s.capitalize)
45
+ request = klass.new(uri.request_uri)
46
+ api_request.headers.each { |name, value| request[name] = value }
47
+ request.body = api_request.body unless api_request.body.nil?
48
+ request
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DPay
4
+ module HTTP
5
+ class Request
6
+ attr_reader :method, :url, :headers, :body
7
+
8
+ def initialize(method, url, headers = {}, body = nil)
9
+ @method = method
10
+ @url = url
11
+ @headers = headers.freeze
12
+ @body = body
13
+ freeze
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module DPay
6
+ module HTTP
7
+ class Response
8
+ attr_reader :status, :headers, :body
9
+
10
+ def initialize(status, headers, body)
11
+ @status = status
12
+ @headers = headers.each_with_object({}) { |(name, value), result| result[name.to_s.downcase] = value }.freeze
13
+ @body = body
14
+ freeze
15
+ end
16
+
17
+ def header(name)
18
+ @headers[name.to_s.downcase]
19
+ end
20
+
21
+ def decode_json
22
+ decoded = JSON.parse(@body.to_s)
23
+ decoded.is_a?(Hash) || decoded.is_a?(Array) ? decoded : nil
24
+ rescue JSON::ParserError
25
+ nil
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DPay
4
+ module Internal
5
+ class ApiRequestor
6
+ USER_AGENT = "dpay-ruby-sdk/#{VERSION} ruby/#{RUBY_VERSION}".freeze
7
+
8
+ attr_reader :config, :checksum
9
+
10
+ def initialize(config, http_client)
11
+ @config = config
12
+ @http_client = http_client
13
+ @checksum = ChecksumCalculator.new(config.secret_hash)
14
+ end
15
+
16
+ def service
17
+ @config.service
18
+ end
19
+
20
+ def post_json(host, path, body)
21
+ decode(send_request("POST", host, path, body))
22
+ end
23
+
24
+ def get_json(host, path)
25
+ decode(send_request("GET", host, path, nil))
26
+ end
27
+
28
+ def get_text(host, path)
29
+ send_request("GET", host, path, nil).body
30
+ end
31
+
32
+ def send_request(method, host, path, body)
33
+ response = send_raw(method, host, path, body)
34
+ raise map_error(response) if response.status >= 400
35
+
36
+ response
37
+ end
38
+
39
+ def send_raw(method, host, path, body)
40
+ headers = { "Accept" => "application/json", "User-Agent" => USER_AGENT }
41
+ encoded = nil
42
+
43
+ unless body.nil?
44
+ headers["Content-Type"] = "application/json"
45
+ encoded = PHP.json_encode(body)
46
+ end
47
+
48
+ @http_client.request(HTTP::Request.new(method, @config.base_urls.resolve(host) + path, headers, encoded))
49
+ end
50
+
51
+ def map_error(response)
52
+ ErrorMapper.map(response)
53
+ end
54
+
55
+ private
56
+
57
+ def decode(response)
58
+ decoded = response.decode_json
59
+ return decoded unless decoded.nil?
60
+
61
+ raise ApiServerError.new("Invalid JSON in API response", response.status, nil, {}, response.body)
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DPay
4
+ module Internal
5
+ class BaseUrls
6
+ API_PAYMENTS = :api_payments
7
+ PANEL = :panel
8
+ GATEWAY = :gateway
9
+
10
+ DEFAULTS = {
11
+ API_PAYMENTS => "https://api-payments.dpay.pl",
12
+ PANEL => "https://panel.dpay.pl",
13
+ GATEWAY => "https://secure.dpay.pl"
14
+ }.freeze
15
+
16
+ def initialize(overrides = {})
17
+ urls = DEFAULTS.dup
18
+
19
+ overrides.each do |host, url|
20
+ key = host.to_sym
21
+ raise InvalidArgumentError, %(Unknown base URL key "#{host}") unless DEFAULTS.key?(key)
22
+
23
+ urls[key] = url.to_s.sub(%r{/+\z}, "")
24
+ end
25
+
26
+ @urls = urls.freeze
27
+ freeze
28
+ end
29
+
30
+ def resolve(host)
31
+ key = host.to_sym
32
+ raise InvalidArgumentError, %(Unknown API host "#{host}") unless @urls.key?(key)
33
+
34
+ @urls[key]
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module DPay
6
+ module Internal
7
+ class ChecksumCalculator
8
+ def initialize(secret_hash)
9
+ @secret_hash = secret_hash
10
+ freeze
11
+ end
12
+
13
+ def secret_second(service, fields)
14
+ parts = [service, @secret_hash] + fields.map { |field| PHP.strval(field) }
15
+
16
+ Digest::SHA256.hexdigest(parts.join("|"))
17
+ end
18
+
19
+ def ordered_body(values)
20
+ joined = values.map { |value| PHP.strval(value) }.join("|")
21
+
22
+ Digest::SHA256.hexdigest("#{joined}|#{@secret_hash}")
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DPay
4
+ module Internal
5
+ module Coerce
6
+ module_function
7
+
8
+ def string(value)
9
+ return nil unless value.is_a?(String) || value.is_a?(Numeric) ||
10
+ value.is_a?(TrueClass) || value.is_a?(FalseClass)
11
+
12
+ PHP.strval(value)
13
+ end
14
+
15
+ def money(value, currency = Currency::PLN)
16
+ Money.try_from_api_number(value.nil? ? 0 : value, currency) || Money.pln(0)
17
+ end
18
+
19
+ def boolean(value)
20
+ return value if value.is_a?(TrueClass) || value.is_a?(FalseClass)
21
+ return value != 0 if value.is_a?(Numeric)
22
+ return !value.empty? && value != "0" if value.is_a?(String)
23
+
24
+ false
25
+ end
26
+
27
+ def integer(value)
28
+ return value.to_i if value.is_a?(Numeric)
29
+ return value.to_i if value.is_a?(String) && /\A\s*[+-]?\d+/.match?(value)
30
+
31
+ 0
32
+ end
33
+
34
+ def optional_integer(value)
35
+ return value.to_i if value.is_a?(Numeric)
36
+ return value.to_i if value.is_a?(String) && /\A\s*[+-]?\d+\z/.match?(value)
37
+
38
+ nil
39
+ end
40
+
41
+ def list(value)
42
+ value.is_a?(Array) ? value.grep(Hash) : []
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DPay
4
+ module Internal
5
+ module ErrorMapper
6
+ DEFAULT_MESSAGE = "Unexpected API error"
7
+
8
+ module_function
9
+
10
+ def map(response)
11
+ decoded = response.decode_json
12
+ data = decoded.is_a?(Hash) ? decoded : {}
13
+ message = extract_message(data)
14
+
15
+ return rate_limit(response, message) if response.status == 429
16
+
17
+ error_class(response.status).new(
18
+ message,
19
+ response.status,
20
+ data["errorcode"].is_a?(String) ? data["errorcode"] : nil,
21
+ normalize_field_errors(data["errors"]),
22
+ response.body
23
+ )
24
+ end
25
+
26
+ def error_class(status)
27
+ case status
28
+ when 401 then AuthenticationError
29
+ when 403 then AccessDeniedError
30
+ when 404 then NotFoundError
31
+ when 400, 422 then InvalidRequestError
32
+ else status >= 500 ? ApiServerError : ApiError
33
+ end
34
+ end
35
+
36
+ def extract_message(data)
37
+ return data["message"] if data["message"].is_a?(String)
38
+ return data["msg"] if data["msg"].is_a?(String)
39
+
40
+ DEFAULT_MESSAGE
41
+ end
42
+
43
+ def rate_limit(response, message)
44
+ RateLimitError.new(
45
+ message,
46
+ response.status,
47
+ int_header(response, "Retry-After"),
48
+ int_header(response, "X-RateLimit-Limit"),
49
+ int_header(response, "X-RateLimit-Remaining"),
50
+ response.body
51
+ )
52
+ end
53
+
54
+ def normalize_field_errors(errors)
55
+ pairs = case errors
56
+ when Array then errors.each_with_index.map { |messages, index| [index, messages] }
57
+ when Hash then errors.to_a
58
+ else return {}
59
+ end
60
+
61
+ pairs.each_with_object({}) do |(field, messages), result|
62
+ normalized = normalize_messages(messages)
63
+ result[PHP.strval(field)] = normalized unless normalized.nil?
64
+ end
65
+ end
66
+
67
+ def normalize_messages(messages)
68
+ case messages
69
+ when String then [messages]
70
+ when Array then messages.select { |message| scalar?(message) }.map { |message| PHP.strval(message) }
71
+ end
72
+ end
73
+
74
+ def scalar?(value)
75
+ value.is_a?(String) || value.is_a?(Numeric) || value.is_a?(TrueClass) || value.is_a?(FalseClass)
76
+ end
77
+
78
+ def int_header(response, name)
79
+ value = response.header(name)
80
+ return nil if value.nil?
81
+
82
+ match = /\A\s*[+-]?\d+/.match(value.to_s)
83
+ return 0 if match.nil?
84
+
85
+ digits = match[0]
86
+ digits.nil? ? 0 : digits.strip.to_i
87
+ end
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module DPay
6
+ module Internal
7
+ module PHP
8
+ SAFE_INTEGRAL_FLOAT = 2**53
9
+ NON_UNRESERVED = /[^A-Za-z0-9\-_.~]/n
10
+
11
+ module_function
12
+
13
+ def strval(value)
14
+ case value
15
+ when nil, false then ""
16
+ when true then "1"
17
+ when Float then float_to_string(value)
18
+ when String then value
19
+ else value.to_s
20
+ end
21
+ end
22
+
23
+ def round(value)
24
+ value >= 0 ? (value + 0.5).floor : (value - 0.5).ceil
25
+ end
26
+
27
+ def json_encode(data, escape_slashes: false)
28
+ encoded = JSON.generate(normalize_floats(data))
29
+ escape_slashes ? encoded.gsub("/") { "\\/" } : encoded
30
+ end
31
+
32
+ def raw_url_encode(value)
33
+ value.to_s.b.gsub(NON_UNRESERVED) { |byte| format("%%%02X", byte.ord) }
34
+ end
35
+
36
+ def float_to_string(value)
37
+ return "NAN" if value.nan?
38
+ return value.positive? ? "INF" : "-INF" if value.infinite?
39
+
40
+ formatted = format("%.14G", value)
41
+ return formatted unless formatted.include?("E")
42
+
43
+ parts = formatted.split("E")
44
+ mantissa = parts.fetch(0)
45
+ exponent = parts.fetch(1)
46
+ mantissa += ".0" unless mantissa.include?(".")
47
+ sign = exponent.start_with?("-") ? "-" : "+"
48
+ digits = exponent.sub(/\A[+-]/, "").sub(/\A0+/, "")
49
+ digits = "0" if digits.empty?
50
+ "#{mantissa}E#{sign}#{digits}"
51
+ end
52
+
53
+ def normalize_floats(value)
54
+ case value
55
+ when Float
56
+ integral_float?(value) ? value.to_i : value
57
+ when Hash
58
+ value.each_with_object({}) { |(key, item), result| result[key] = normalize_floats(item) }
59
+ when Array
60
+ value.map { |item| normalize_floats(item) }
61
+ else
62
+ value
63
+ end
64
+ end
65
+
66
+ def integral_float?(value)
67
+ value.finite? && value.abs < SAFE_INTEGRAL_FLOAT && value.to_i == value
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DPay
4
+ class IpnEvent
5
+ ACK = "OK"
6
+
7
+ attr_reader :raw
8
+
9
+ def self.from_verified_payload(payload)
10
+ new(payload)
11
+ end
12
+
13
+ def initialize(payload)
14
+ @raw = payload
15
+ freeze
16
+ end
17
+
18
+ def id
19
+ scalar("id") || ""
20
+ end
21
+
22
+ def amount
23
+ scalar("amount") || ""
24
+ end
25
+
26
+ def type
27
+ scalar("type") || ""
28
+ end
29
+
30
+ def signature
31
+ scalar("signature") || ""
32
+ end
33
+
34
+ def email
35
+ scalar("email")
36
+ end
37
+
38
+ def custom
39
+ scalar("custom")
40
+ end
41
+
42
+ def capture_payment_id
43
+ scalar("capture_payment_id")
44
+ end
45
+
46
+ def attempt
47
+ integer("attempt")
48
+ end
49
+
50
+ def version
51
+ integer("version")
52
+ end
53
+
54
+ def transfer?
55
+ type == IpnType::TRANSFER
56
+ end
57
+
58
+ def capture?
59
+ type == IpnType::CAPTURE
60
+ end
61
+
62
+ def dcb?
63
+ type == IpnType::DCB
64
+ end
65
+
66
+ private
67
+
68
+ def scalar(key)
69
+ value = @raw[key]
70
+ return nil unless value.is_a?(String) || value.is_a?(Numeric) ||
71
+ value.is_a?(TrueClass) || value.is_a?(FalseClass)
72
+
73
+ Internal::PHP.strval(value)
74
+ end
75
+
76
+ def integer(key)
77
+ value = @raw[key]
78
+ return value.to_i if value.is_a?(Integer) || value.is_a?(Float)
79
+ return value.to_i if value.is_a?(String) && /\A[+-]?\d+\z/.match?(value)
80
+
81
+ 0
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DPay
4
+ module IpnType
5
+ TRANSFER = "transfer"
6
+ CAPTURE = "capture"
7
+ DCB = "dcb"
8
+
9
+ ALL = [TRANSFER, CAPTURE, DCB].freeze
10
+
11
+ def self.valid?(value)
12
+ ALL.include?(value)
13
+ end
14
+
15
+ def self.assert_valid(value)
16
+ raise InvalidArgumentError, %(Invalid IPN type "#{value}") unless valid?(value)
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "json"
5
+ require "openssl"
6
+
7
+ module DPay
8
+ module IpnVerifier
9
+ REQUIRED_FIELDS = %w[id amount type attempt version signature].freeze
10
+ INVALID_PAYLOAD = "Invalid IPN payload"
11
+
12
+ module_function
13
+
14
+ def construct_event(raw_body, secret_hash)
15
+ payload = parse(raw_body)
16
+ signature = payload["signature"]
17
+ raise SignatureVerificationError, INVALID_PAYLOAD unless signature.is_a?(String)
18
+
19
+ expected = expected_signature(payload, secret_hash)
20
+ raise SignatureVerificationError, "Invalid IPN signature" unless secure_compare(expected, signature)
21
+
22
+ IpnEvent.from_verified_payload(payload)
23
+ end
24
+
25
+ def parse(raw_body)
26
+ payload = begin
27
+ JSON.parse(raw_body.to_s)
28
+ rescue JSON::ParserError
29
+ raise SignatureVerificationError, INVALID_PAYLOAD
30
+ end
31
+
32
+ raise SignatureVerificationError, INVALID_PAYLOAD unless payload.is_a?(Hash)
33
+ raise SignatureVerificationError, INVALID_PAYLOAD unless REQUIRED_FIELDS.all? { |f| !payload[f].nil? }
34
+
35
+ payload
36
+ end
37
+
38
+ def expected_signature(payload, secret_hash)
39
+ type = Internal::PHP.strval(payload["type"])
40
+
41
+ parts = [Internal::PHP.strval(payload["id"]), secret_hash, Internal::PHP.strval(payload["amount"])]
42
+ parts << Internal::PHP.strval(payload["email"]) unless type == IpnType::DCB
43
+ parts << type
44
+ parts << Internal::PHP.strval(payload["attempt"])
45
+ parts << Internal::PHP.strval(payload["version"])
46
+ parts << Internal::PHP.strval(payload["custom"])
47
+
48
+ Digest::SHA256.hexdigest(parts.join)
49
+ end
50
+
51
+ def secure_compare(expected, actual)
52
+ expected.bytesize == actual.bytesize && OpenSSL.fixed_length_secure_compare(expected, actual)
53
+ end
54
+
55
+ private_class_method :parse, :expected_signature, :secure_compare
56
+ end
57
+ end