payu_pl 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,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry/validation"
4
+
5
+ module PayuPl
6
+ module Contracts
7
+ class RefundCreateContract < Dry::Validation::Contract
8
+ params do
9
+ required(:order_id).filled(:string)
10
+ required(:description).filled(:string)
11
+ optional(:amount).maybe(:string)
12
+ optional(:ext_refund_id).maybe(:string)
13
+ end
14
+
15
+ rule(:amount) do
16
+ next if value.nil?
17
+
18
+ amount_string = value.to_s
19
+ key.failure(PayuPl.t(:numeric_string)) unless amount_string.match?(/\A\d+\z/)
20
+ end
21
+
22
+ rule(:ext_refund_id) do
23
+ next if value.nil?
24
+
25
+ key.failure(PayuPl.t(:max_length, max: 1024)) if value.length > 1024
26
+ end
27
+
28
+ rule(:description) do
29
+ key.failure(PayuPl.t(:max_length, max: 4000)) if value.length > 4000
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ module PayuPl
6
+ module Endpoints
7
+ OAUTH_TOKEN = "/pl/standard/user/oauth/authorize"
8
+
9
+ ORDERS = "/api/v2_1/orders"
10
+
11
+ def self.order(order_id)
12
+ "#{ORDERS}/#{URI.encode_www_form_component(order_id.to_s)}"
13
+ end
14
+
15
+ def self.order_captures(order_id)
16
+ "#{order(order_id)}/captures"
17
+ end
18
+
19
+ def self.order_transactions(order_id)
20
+ "#{order(order_id)}/transactions"
21
+ end
22
+
23
+ def self.order_refunds(order_id)
24
+ "#{order(order_id)}/refunds"
25
+ end
26
+
27
+ def self.order_refund(order_id, refund_id)
28
+ "#{order_refunds(order_id)}/#{URI.encode_www_form_component(refund_id.to_s)}"
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PayuPl
4
+ class Error < StandardError; end
5
+
6
+ class ValidationError < Error
7
+ attr_reader :errors, :input
8
+
9
+ def initialize(errors:, input: nil, message: "Validation failed")
10
+ super(message)
11
+ @errors = errors
12
+ @input = input
13
+ end
14
+ end
15
+
16
+ class NetworkError < Error
17
+ attr_reader :original
18
+
19
+ def initialize(message = "Network error", original: nil)
20
+ super(message)
21
+ @original = original
22
+ end
23
+ end
24
+
25
+ class ResponseError < Error
26
+ attr_reader :http_status, :correlation_id, :raw_body, :parsed_body
27
+
28
+ def initialize(message, http_status:, correlation_id: nil, raw_body: nil, parsed_body: nil)
29
+ super(message)
30
+ @http_status = http_status
31
+ @correlation_id = correlation_id
32
+ @raw_body = raw_body
33
+ @parsed_body = parsed_body
34
+ end
35
+ end
36
+
37
+ class ClientError < ResponseError; end
38
+ class UnauthorizedError < ClientError; end
39
+ class ForbiddenError < ClientError; end
40
+ class NotFoundError < ClientError; end
41
+ class RateLimitedError < ClientError; end
42
+
43
+ class ServerError < ResponseError; end
44
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+ require "dry-initializer"
5
+
6
+ module PayuPl
7
+ module Operations
8
+ class Base
9
+ extend Dry::Initializer
10
+
11
+ option :client
12
+
13
+ private
14
+
15
+ def transport
16
+ client.transport
17
+ end
18
+
19
+ def escape_path(segment)
20
+ URI.encode_www_form_component(segment.to_s)
21
+ end
22
+
23
+ def validate_contract!(contract_class, params, input: params)
24
+ result = contract_class.new.call(params)
25
+ return if result.success?
26
+
27
+ raise ValidationError.new(errors: result.errors.to_h, input: input)
28
+ end
29
+
30
+ def validate_id!(value, input_key: :id)
31
+ result = Contracts::IdContract.new.call(id: value.to_s)
32
+ return if result.success?
33
+
34
+ raise ValidationError.new(
35
+ errors: { input_key => result.errors.to_h[:id] }.compact,
36
+ input: { input_key => value }
37
+ )
38
+ end
39
+
40
+ def validate_ids!(**ids)
41
+ id_contract = Contracts::IdContract.new
42
+ errors = {}
43
+
44
+ ids.each do |key, value|
45
+ res = id_contract.call(id: value.to_s)
46
+ errors[key] = res.errors.to_h[:id] unless res.success?
47
+ end
48
+
49
+ return if errors.empty?
50
+
51
+ raise ValidationError.new(errors: errors, input: ids)
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PayuPl
4
+ module Orders
5
+ class Cancel < Operations::Base
6
+ def call(order_id)
7
+ validate_id!(order_id, input_key: :order_id)
8
+ transport.request(:delete, Endpoints.order(order_id), json: nil)
9
+ end
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PayuPl
4
+ module Orders
5
+ class Capture < Operations::Base
6
+ def call(order_id, amount: nil, currency_code: nil)
7
+ params = {
8
+ order_id: order_id.to_s,
9
+ amount: amount,
10
+ currency_code: currency_code
11
+ }
12
+ validate_contract!(Contracts::CaptureContract, params, input: params)
13
+
14
+ path = Endpoints.order_captures(order_id)
15
+
16
+ if amount.nil? && currency_code.nil?
17
+ transport.request(:post, path, json: nil)
18
+ else
19
+ payload = {
20
+ amount: amount,
21
+ currencyCode: currency_code
22
+ }.compact
23
+ transport.request(:post, path, json: payload)
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PayuPl
4
+ module Orders
5
+ class Create < Operations::Base
6
+ def call(order_create_request)
7
+ validate_contract!(Contracts::OrderCreateContract, order_create_request, input: order_create_request)
8
+
9
+ # Do NOT use result.to_h here: dry-schema would drop unknown keys, and PayU supports many optional fields.
10
+ transport.request(:post, Endpoints::ORDERS, json: order_create_request)
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PayuPl
4
+ module Orders
5
+ class Retrieve < Operations::Base
6
+ def call(order_id)
7
+ validate_id!(order_id, input_key: :order_id)
8
+ transport.request(:get, Endpoints.order(order_id))
9
+ end
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PayuPl
4
+ module Orders
5
+ class Transactions < Operations::Base
6
+ def call(order_id)
7
+ validate_id!(order_id, input_key: :order_id)
8
+ transport.request(:get, Endpoints.order_transactions(order_id))
9
+ end
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PayuPl
4
+ module Refunds
5
+ class Create < Operations::Base
6
+ def call(order_id, description:, amount: nil, ext_refund_id: nil)
7
+ params = {
8
+ order_id: order_id.to_s,
9
+ description: description,
10
+ amount: amount,
11
+ ext_refund_id: ext_refund_id
12
+ }
13
+ validate_contract!(Contracts::RefundCreateContract, params, input: params)
14
+
15
+ refund = {
16
+ description: description,
17
+ amount: amount,
18
+ extRefundId: ext_refund_id
19
+ }.compact
20
+
21
+ transport.request(
22
+ :post,
23
+ Endpoints.order_refunds(order_id),
24
+ json: { refund: refund }
25
+ )
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PayuPl
4
+ module Refunds
5
+ class List < Operations::Base
6
+ def call(order_id)
7
+ validate_id!(order_id, input_key: :order_id)
8
+ transport.request(:get, Endpoints.order_refunds(order_id))
9
+ end
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PayuPl
4
+ module Refunds
5
+ class Retrieve < Operations::Base
6
+ def call(order_id, refund_id)
7
+ validate_ids!(order_id: order_id, refund_id: refund_id)
8
+ transport.request(:get, Endpoints.order_refund(order_id, refund_id))
9
+ end
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,146 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "uri"
6
+
7
+ module PayuPl
8
+ class Transport
9
+ def initialize(base_url:, access_token_provider:, open_timeout: 10, read_timeout: 30)
10
+ @base_url = base_url
11
+ @access_token_provider = access_token_provider
12
+ @open_timeout = open_timeout
13
+ @read_timeout = read_timeout
14
+
15
+ validate!
16
+ end
17
+
18
+ def request(method, path, headers: {}, json: :__no_json_argument_given, form: nil, authorize: true)
19
+ uri = URI.join(@base_url, path)
20
+ http = Net::HTTP.new(uri.host, uri.port)
21
+ http.use_ssl = (uri.scheme == "https")
22
+ http.open_timeout = @open_timeout
23
+ http.read_timeout = @read_timeout
24
+
25
+ request_klass = case method.to_s.downcase
26
+ when "get" then Net::HTTP::Get
27
+ when "post" then Net::HTTP::Post
28
+ when "put" then Net::HTTP::Put
29
+ when "delete" then Net::HTTP::Delete
30
+ else
31
+ raise ArgumentError, "Unsupported HTTP method: #{method.inspect}"
32
+ end
33
+
34
+ req = request_klass.new(uri)
35
+ req["Accept"] = "application/json"
36
+
37
+ if authorize
38
+ token = @access_token_provider.call
39
+ raise ArgumentError, "access_token is required for this request (call oauth_token first or pass access_token:)" if token.nil? || token.to_s.empty?
40
+
41
+ req["Authorization"] = "Bearer #{token}"
42
+ end
43
+
44
+ headers.each { |k, v| req[k] = v }
45
+
46
+ if method.to_s.downcase == "get"
47
+ # PayU rejects GET with a body (HTTP 403) per RFC 9110 note in docs
48
+ raise ArgumentError, "GET requests must not include a JSON body" if json != :__no_json_argument_given && !json.nil?
49
+ raise ArgumentError, "GET requests must not include a form body" if form
50
+ end
51
+
52
+ if form
53
+ req["Content-Type"] ||= "application/x-www-form-urlencoded"
54
+ req.body = URI.encode_www_form(form)
55
+ elsif json != :__no_json_argument_given
56
+ # json:nil means explicit empty body for endpoints that accept it
57
+ req["Content-Type"] ||= "application/json"
58
+ req.body = json.nil? ? nil : JSON.generate(json)
59
+ end
60
+
61
+ begin
62
+ res = http.request(req)
63
+ rescue Timeout::Error, Errno::ETIMEDOUT => e
64
+ raise NetworkError.new("Request timed out", original: e)
65
+ rescue SocketError, Errno::ECONNREFUSED, Errno::EHOSTUNREACH, EOFError => e
66
+ raise NetworkError.new("Network failure", original: e)
67
+ end
68
+
69
+ handle_response(res)
70
+ end
71
+
72
+ private
73
+
74
+ def validate!
75
+ uri = URI.parse(@base_url)
76
+ raise ArgumentError, "base_url must be http(s)" unless uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)
77
+ rescue URI::InvalidURIError
78
+ raise ArgumentError, "base_url is invalid"
79
+ end
80
+
81
+ def handle_response(res)
82
+ http_status = res.code.to_i
83
+ correlation_id = res["Correlation-Id"] || res["correlation-id"]
84
+ raw_body = res.body
85
+ parsed = parse_body(res)
86
+
87
+ return parsed if http_status >= 200 && http_status < 400
88
+
89
+ message = build_error_message(http_status, parsed, raw_body)
90
+
91
+ error_class = case http_status
92
+ when 401 then UnauthorizedError
93
+ when 403 then ForbiddenError
94
+ when 404 then NotFoundError
95
+ when 429 then RateLimitedError
96
+ when 400..499 then ClientError
97
+ else
98
+ ServerError
99
+ end
100
+
101
+ raise error_class.new(
102
+ message,
103
+ http_status: http_status,
104
+ correlation_id: correlation_id,
105
+ raw_body: raw_body,
106
+ parsed_body: parsed
107
+ )
108
+ end
109
+
110
+ def parse_body(res)
111
+ body = res.body
112
+ return nil if body.nil? || body.empty?
113
+
114
+ content_type = res["Content-Type"].to_s
115
+ if content_type.include?("application/json") || body.match?(/\A\s*[\[{]/)
116
+ JSON.parse(body)
117
+ else
118
+ body
119
+ end
120
+ rescue JSON::ParserError
121
+ body
122
+ end
123
+
124
+ def build_error_message(http_status, parsed, raw_body)
125
+ status_desc = nil
126
+ status_code = nil
127
+
128
+ if parsed.is_a?(Hash) && parsed["status"].is_a?(Hash)
129
+ status_code = parsed.dig("status", "statusCode")
130
+ status_desc = parsed.dig("status", "statusDesc")
131
+ end
132
+
133
+ parts = ["HTTP #{http_status}"]
134
+ parts << status_code if status_code
135
+ parts << status_desc if status_desc
136
+
137
+ if parts.length == 1
138
+ preview = raw_body.to_s.strip
139
+ preview = "#{preview.each_char.take(300).join}…" if preview.length > 300
140
+ parts << preview unless preview.empty?
141
+ end
142
+
143
+ parts.compact.join(" - ")
144
+ end
145
+ end
146
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PayuPl
4
+ VERSION = "0.1.0"
5
+ end
data/lib/payu_pl.rb ADDED
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "payu_pl/version"
4
+ require_relative "payu_pl/configuration"
5
+ require_relative "payu_pl/errors"
6
+ require_relative "payu_pl/endpoints"
7
+ require_relative "payu_pl/transport"
8
+
9
+ require_relative "payu_pl/operations/base"
10
+
11
+ require_relative "payu_pl/contracts/order_create_contract"
12
+ require_relative "payu_pl/contracts/id_contract"
13
+ require_relative "payu_pl/contracts/capture_contract"
14
+ require_relative "payu_pl/contracts/refund_create_contract"
15
+
16
+ require_relative "payu_pl/authorize/oauth_token"
17
+
18
+ require_relative "payu_pl/orders/create"
19
+ require_relative "payu_pl/orders/retrieve"
20
+ require_relative "payu_pl/orders/capture"
21
+ require_relative "payu_pl/orders/cancel"
22
+ require_relative "payu_pl/orders/transactions"
23
+
24
+ require_relative "payu_pl/refunds/create"
25
+ require_relative "payu_pl/refunds/list"
26
+ require_relative "payu_pl/refunds/retrieve"
27
+ require_relative "payu_pl/client"
28
+
29
+ module PayuPl
30
+ end
data/sig/payu_pl.rbs ADDED
@@ -0,0 +1,94 @@
1
+ module PayuPl
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+
5
+ class Configuration
6
+ attr_accessor locale: Symbol
7
+ def initialize: () -> void
8
+ end
9
+
10
+ def self.config: () -> Configuration
11
+ def self.configure: () { (Configuration) -> void } -> void
12
+ def self.load_translations!: () -> void
13
+ def self.t: (Symbol | String, **untyped) -> String
14
+
15
+ class Error < StandardError
16
+ end
17
+
18
+ class ValidationError < Error
19
+ attr_reader errors: untyped
20
+ attr_reader input: untyped
21
+
22
+ def initialize: (errors: untyped, ?input: untyped, ?message: String) -> void
23
+ end
24
+
25
+ class NetworkError < Error
26
+ attr_reader original: untyped
27
+ def initialize: (?String message, ?original: untyped) -> void
28
+ end
29
+
30
+ class ResponseError < Error
31
+ attr_reader http_status: Integer
32
+ attr_reader correlation_id: String?
33
+ attr_reader raw_body: String?
34
+ attr_reader parsed_body: untyped
35
+
36
+ def initialize: (String message, http_status: Integer, ?correlation_id: String?, ?raw_body: String?, ?parsed_body: untyped) -> void
37
+ end
38
+
39
+ class ClientError < ResponseError
40
+ end
41
+
42
+ class UnauthorizedError < ClientError
43
+ end
44
+
45
+ class ForbiddenError < ClientError
46
+ end
47
+
48
+ class NotFoundError < ClientError
49
+ end
50
+
51
+ class RateLimitedError < ClientError
52
+ end
53
+
54
+ class ServerError < ResponseError
55
+ end
56
+
57
+ class Transport
58
+ def initialize: (base_url: String, access_token_provider: ^() -> String?, ?open_timeout: Integer, ?read_timeout: Integer) -> void
59
+ def request: (untyped method, String path, ?headers: Hash[String, String], ?json: untyped, ?form: Hash[untyped, untyped], ?authorize: bool) -> untyped
60
+ end
61
+
62
+ class Client
63
+ DEFAULT_PRODUCTION_BASE_URL: String
64
+ DEFAULT_SANDBOX_BASE_URL: String
65
+
66
+ attr_reader base_url: String
67
+ attr_reader client_id: String
68
+ attr_reader client_secret: String
69
+ attr_accessor access_token: String?
70
+
71
+ def initialize: (
72
+ client_id: String,
73
+ client_secret: String,
74
+ ?access_token: String?,
75
+ ?base_url: String?,
76
+ ?environment: Symbol,
77
+ ?open_timeout: Integer,
78
+ ?read_timeout: Integer
79
+ ) -> void
80
+
81
+ def oauth_token: (?grant_type: String) -> Hash[String, untyped]
82
+
83
+ def create_order: (untyped order_create_request) -> untyped
84
+ def retrieve_order: (untyped order_id) -> untyped
85
+ def capture_order: (untyped order_id, ?amount: untyped, ?currency_code: untyped) -> untyped
86
+ def cancel_order: (untyped order_id) -> untyped
87
+
88
+ def create_refund: (untyped order_id, description: String, ?amount: untyped, ?ext_refund_id: untyped) -> untyped
89
+ def list_refunds: (untyped order_id) -> untyped
90
+ def retrieve_refund: (untyped order_id, untyped refund_id) -> untyped
91
+
92
+ def retrieve_transactions: (untyped order_id) -> untyped
93
+ end
94
+ end