paygate_pk 0.2.3 → 1.0.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 +4 -4
- data/.rubocop.yml +31 -3
- data/CHANGELOG.md +126 -0
- data/Gemfile.lock +46 -5
- data/README.md +181 -70
- data/lib/paygate_pk/coercions.rb +46 -0
- data/lib/paygate_pk/config.rb +68 -24
- data/lib/paygate_pk/contracts/access_token.rb +8 -2
- data/lib/paygate_pk/contracts/callback_event.rb +38 -0
- data/lib/paygate_pk/contracts/redirect_request.rb +30 -0
- data/lib/paygate_pk/errors.rb +16 -3
- data/lib/paygate_pk/http/client.rb +84 -71
- data/lib/paygate_pk/pay_fast/auth.rb +79 -0
- data/lib/paygate_pk/pay_fast/callback.rb +92 -0
- data/lib/paygate_pk/pay_fast/endpoints.rb +38 -0
- data/lib/paygate_pk/pay_fast/redirect.rb +227 -0
- data/lib/paygate_pk/pay_fast.rb +19 -0
- data/lib/paygate_pk/rails/railtie.rb +19 -0
- data/lib/paygate_pk/rails/view_helpers.rb +59 -0
- data/lib/paygate_pk/util/signature/pay_fast.rb +25 -0
- data/lib/paygate_pk/version.rb +1 -1
- data/lib/paygate_pk.rb +34 -18
- metadata +25 -32
- data/lib/paygate_pk/contracts/bearer_token.rb +0 -18
- data/lib/paygate_pk/contracts/hosted_checkout.rb +0 -8
- data/lib/paygate_pk/contracts/instrument.rb +0 -10
- data/lib/paygate_pk/contracts/webhook_event.rb +0 -24
- data/lib/paygate_pk/providers/pay_fast/auth.rb +0 -61
- data/lib/paygate_pk/providers/pay_fast/checkout.rb +0 -157
- data/lib/paygate_pk/providers/pay_fast/client.rb +0 -53
- data/lib/paygate_pk/providers/pay_fast/tokenization/instrument.rb +0 -63
- data/lib/paygate_pk/providers/pay_fast/tokenization/token.rb +0 -65
- data/lib/paygate_pk/providers/pay_fast/webhook.rb +0 -74
- data/lib/paygate_pk/util/html.rb +0 -42
- data/lib/paygate_pk/util/signature.rb +0 -18
- data/paygate_pk.gemspec +0 -46
data/lib/paygate_pk/config.rb
CHANGED
|
@@ -1,45 +1,89 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module PaygatePk
|
|
4
|
-
# Global configuration
|
|
4
|
+
# Global gem configuration.
|
|
5
|
+
#
|
|
6
|
+
# Typical Rails usage:
|
|
7
|
+
# PaygatePk.configure do |c|
|
|
8
|
+
# c.default_currency = "PKR"
|
|
9
|
+
# c.pay_fast.environment = Rails.env.production? ? :production : :sandbox
|
|
10
|
+
# c.pay_fast.merchant_id = ENV["PAYFAST_MERCHANT_ID"]
|
|
11
|
+
# c.pay_fast.secured_key = ENV["PAYFAST_SECURED_KEY"]
|
|
12
|
+
# c.pay_fast.merchant_name = "Acme Store"
|
|
13
|
+
# c.pay_fast.store_id = ENV["PAYFAST_STORE_ID"]
|
|
14
|
+
# end
|
|
15
|
+
#
|
|
16
|
+
# After `configure`, the config object is frozen — mutating it raises.
|
|
5
17
|
class Config
|
|
6
|
-
attr_accessor :
|
|
18
|
+
attr_accessor :default_currency, :timeouts, :retry, :user_agent, :logger
|
|
7
19
|
attr_reader :pay_fast
|
|
8
20
|
|
|
9
21
|
def initialize
|
|
10
|
-
@logger = nil
|
|
11
22
|
@default_currency = "PKR"
|
|
12
|
-
@timeouts
|
|
13
|
-
@retry
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
@
|
|
23
|
+
@timeouts = { open_timeout: 5, read_timeout: 10 }
|
|
24
|
+
@retry = {
|
|
25
|
+
max: 2, interval: 0.2, backoff_factor: 2.0,
|
|
26
|
+
retry_statuses: [429, 500, 502, 503, 504]
|
|
27
|
+
}
|
|
28
|
+
@user_agent = "paygate_pk/#{PaygatePk::VERSION}"
|
|
29
|
+
@logger = nil
|
|
30
|
+
@pay_fast = PayFastConfig.new
|
|
31
|
+
@configured = false
|
|
18
32
|
end
|
|
19
33
|
|
|
34
|
+
# Mark as configured and deep-freeze. Called automatically by
|
|
35
|
+
# PaygatePk.configure after the block runs.
|
|
20
36
|
def freeze!
|
|
21
|
-
@
|
|
37
|
+
@configured = true
|
|
38
|
+
@pay_fast.freeze
|
|
39
|
+
freeze
|
|
22
40
|
self
|
|
23
41
|
end
|
|
24
42
|
|
|
25
|
-
def
|
|
26
|
-
@
|
|
43
|
+
def configured?
|
|
44
|
+
@configured
|
|
27
45
|
end
|
|
28
46
|
|
|
29
|
-
#
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
47
|
+
# Per-provider config for PayFast.
|
|
48
|
+
#
|
|
49
|
+
# `environment` selects the base URL via PayFast::Endpoints.
|
|
50
|
+
# `base_url` and `api_base_url` can override the env-derived URLs
|
|
51
|
+
# (useful for staging hosts PayFast hands out on request).
|
|
52
|
+
class PayFastConfig
|
|
53
|
+
ENVIRONMENTS = %i[sandbox production].freeze
|
|
54
|
+
DEFAULT_TRAN_TYPE = "ECOMM_PURCHASE"
|
|
55
|
+
|
|
56
|
+
attr_accessor :merchant_id, :secured_key, :merchant_name, :store_id,
|
|
57
|
+
:version_string, :tran_type, :base_url, :api_base_url
|
|
58
|
+
attr_reader :environment
|
|
33
59
|
|
|
34
60
|
def initialize
|
|
35
|
-
@
|
|
36
|
-
@merchant_id
|
|
37
|
-
@secured_key
|
|
38
|
-
@
|
|
39
|
-
@
|
|
40
|
-
@
|
|
41
|
-
@
|
|
42
|
-
@
|
|
61
|
+
@environment = :sandbox
|
|
62
|
+
@merchant_id = nil
|
|
63
|
+
@secured_key = nil
|
|
64
|
+
@merchant_name = nil
|
|
65
|
+
@store_id = nil
|
|
66
|
+
@version_string = nil
|
|
67
|
+
@tran_type = DEFAULT_TRAN_TYPE
|
|
68
|
+
@base_url = nil
|
|
69
|
+
@api_base_url = nil
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def environment=(env)
|
|
73
|
+
sym = env&.to_sym
|
|
74
|
+
unless ENVIRONMENTS.include?(sym)
|
|
75
|
+
raise ArgumentError,
|
|
76
|
+
"environment must be one of #{ENVIRONMENTS.inspect}, got #{env.inspect}"
|
|
77
|
+
end
|
|
78
|
+
@environment = sym
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Resolves the host URL used for redirect form action + token API.
|
|
82
|
+
# Explicit base_url wins; otherwise derived from environment.
|
|
83
|
+
def resolved_base_url
|
|
84
|
+
return base_url if base_url
|
|
85
|
+
|
|
86
|
+
PaygatePk::PayFast::Endpoints.base_url(environment)
|
|
43
87
|
end
|
|
44
88
|
end
|
|
45
89
|
end
|
|
@@ -1,8 +1,14 @@
|
|
|
1
|
-
# lib/paygate_pk/contracts/access_token.rb
|
|
2
1
|
# frozen_string_literal: true
|
|
3
2
|
|
|
4
3
|
module PaygatePk
|
|
5
4
|
module Contracts
|
|
6
|
-
|
|
5
|
+
# A PayFast ACCESS_TOKEN, fetched server-to-server via Auth#call,
|
|
6
|
+
# then plugged into the redirect form.
|
|
7
|
+
#
|
|
8
|
+
# `value` is the bare token string. `token` is kept as an alias so
|
|
9
|
+
# old call sites continue to read naturally (`access_token.token`).
|
|
10
|
+
AccessToken = Struct.new(:value, :raw, keyword_init: true) do
|
|
11
|
+
alias_method :token, :value
|
|
12
|
+
end
|
|
7
13
|
end
|
|
8
14
|
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PaygatePk
|
|
4
|
+
module Contracts
|
|
5
|
+
# Normalised IPN / browser-return notification.
|
|
6
|
+
#
|
|
7
|
+
# Built by PaygatePk::PayFast::Callback.verify!(params). Will also
|
|
8
|
+
# be returned by Easypaisa's callback handler in 1.1, with the same
|
|
9
|
+
# fields populated where applicable. This is the gem's universal
|
|
10
|
+
# contract for "the gateway told us something about a transaction".
|
|
11
|
+
#
|
|
12
|
+
# Numeric-looking fields (amount, merchant_amount, discounted_amount)
|
|
13
|
+
# are surfaced as Strings — exactly as PayFast sent them. The host
|
|
14
|
+
# app converts to BigDecimal/Money on its side.
|
|
15
|
+
CallbackEvent = Struct.new(
|
|
16
|
+
:provider, # Symbol e.g. :pay_fast
|
|
17
|
+
:transaction_id, # String or nil
|
|
18
|
+
:basket_id, # String
|
|
19
|
+
:order_date, # String "YYYY-MM-DD"
|
|
20
|
+
:approved, # Boolean — true if code == provider's success code
|
|
21
|
+
:code, # String — err_code or responseCode
|
|
22
|
+
:message, # String — err_msg or responseDesc
|
|
23
|
+
:amount, # String — transaction_amount
|
|
24
|
+
:merchant_amount, # String
|
|
25
|
+
:discounted_amount, # String
|
|
26
|
+
:currency, # String "PKR" etc.
|
|
27
|
+
:payment_method, # String — PaymentName ("account", "card", "wallet")
|
|
28
|
+
:instrument_token, # String or nil
|
|
29
|
+
:recurring, # Boolean
|
|
30
|
+
:raw, # Hash — original params, unmodified
|
|
31
|
+
keyword_init: true
|
|
32
|
+
) do
|
|
33
|
+
def approved?
|
|
34
|
+
!!approved
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PaygatePk
|
|
4
|
+
module Contracts
|
|
5
|
+
# Everything a host application needs to render a browser-side
|
|
6
|
+
# redirect to the gateway's checkout page.
|
|
7
|
+
#
|
|
8
|
+
# Built by PaygatePk::PayFast::Redirect.build(...). Consumed by the
|
|
9
|
+
# Rails view helper paygate_pk_redirect_form (or by hand-rolled
|
|
10
|
+
# HTML in non-Rails apps).
|
|
11
|
+
#
|
|
12
|
+
# redirect.action_url # => "https://ipguat.apps.net.pk/Ecommerce/api/Transaction/PostTransaction"
|
|
13
|
+
# redirect.http_method # => :post
|
|
14
|
+
# redirect.fields # => { "MERCHANT_ID" => "...", "TOKEN" => "...", ... }
|
|
15
|
+
#
|
|
16
|
+
# `fields` keys are the exact PayFast field names (UPPER_SNAKE_CASE)
|
|
17
|
+
# so the host app doesn't need to know the wire format.
|
|
18
|
+
RedirectRequest = Struct.new(
|
|
19
|
+
:provider, # Symbol, e.g. :pay_fast
|
|
20
|
+
:action_url, # String
|
|
21
|
+
:http_method, # Symbol, typically :post
|
|
22
|
+
:fields, # Hash<String,String>
|
|
23
|
+
:basket_id, # String — echoed for convenience
|
|
24
|
+
:amount, # String — echoed for convenience
|
|
25
|
+
:token, # String — the underlying ACCESS_TOKEN
|
|
26
|
+
:raw, # Hash — the raw token-API response
|
|
27
|
+
keyword_init: true
|
|
28
|
+
)
|
|
29
|
+
end
|
|
30
|
+
end
|
data/lib/paygate_pk/errors.rb
CHANGED
|
@@ -2,9 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
module PaygatePk
|
|
4
4
|
class Error < StandardError; end
|
|
5
|
+
|
|
5
6
|
class ConfigurationError < Error; end
|
|
6
7
|
|
|
7
|
-
|
|
8
|
+
class CapabilityNotSupported < Error; end
|
|
9
|
+
|
|
8
10
|
class ValidationError < Error
|
|
9
11
|
attr_reader :details
|
|
10
12
|
|
|
@@ -14,7 +16,6 @@ module PaygatePk
|
|
|
14
16
|
end
|
|
15
17
|
end
|
|
16
18
|
|
|
17
|
-
# Raised for HTTP errors, e.g. non-2xx responses.
|
|
18
19
|
class HTTPError < Error
|
|
19
20
|
attr_reader :status, :body
|
|
20
21
|
|
|
@@ -25,7 +26,19 @@ module PaygatePk
|
|
|
25
26
|
end
|
|
26
27
|
end
|
|
27
28
|
|
|
29
|
+
class TimeoutError < HTTPError; end
|
|
30
|
+
class ConnectionError < HTTPError; end
|
|
31
|
+
|
|
28
32
|
class AuthError < Error; end
|
|
29
33
|
class SignatureError < Error; end
|
|
30
|
-
|
|
34
|
+
|
|
35
|
+
class ProviderError < Error
|
|
36
|
+
attr_reader :code, :response
|
|
37
|
+
|
|
38
|
+
def initialize(message = "provider error", code: nil, response: nil)
|
|
39
|
+
@code = code
|
|
40
|
+
@response = response
|
|
41
|
+
super(message)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
31
44
|
end
|
|
@@ -7,24 +7,31 @@ require "securerandom"
|
|
|
7
7
|
|
|
8
8
|
module PaygatePk
|
|
9
9
|
module HTTP
|
|
10
|
-
#
|
|
10
|
+
# Thin Faraday wrapper used by every provider endpoint class.
|
|
11
|
+
#
|
|
12
|
+
# Responsibilities:
|
|
13
|
+
# - Build a single memoised Faraday connection per Client instance.
|
|
14
|
+
# - Map every Faraday::Error subclass to a typed PaygatePk error so
|
|
15
|
+
# consumers can rescue PaygatePk::Error and catch everything.
|
|
16
|
+
# - Optional info-level logging with secret redaction.
|
|
17
|
+
# - Decode JSON response bodies opportunistically; passes through raw
|
|
18
|
+
# String when the body isn't valid JSON (some PayFast endpoints
|
|
19
|
+
# return text/HTML on error pages).
|
|
11
20
|
class Client
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
@
|
|
26
|
-
@timeouts = timeouts
|
|
27
|
-
@logger = logger
|
|
21
|
+
# Field/header names whose values get masked in log output.
|
|
22
|
+
SENSITIVE_KEYS = %w[
|
|
23
|
+
SECURED_KEY secured_key password Password
|
|
24
|
+
Credentials credentials Authorization authorization
|
|
25
|
+
].freeze
|
|
26
|
+
|
|
27
|
+
MASK = "[REDACTED]"
|
|
28
|
+
|
|
29
|
+
def initialize(base_url:, headers: {}, timeouts: nil, retry_conf: nil, logger: nil)
|
|
30
|
+
@base_url = base_url
|
|
31
|
+
@headers = headers
|
|
32
|
+
@timeouts = timeouts || PaygatePk.config.timeouts
|
|
33
|
+
@retry_conf = retry_conf || PaygatePk.config.retry
|
|
34
|
+
@logger = logger || PaygatePk.config.logger
|
|
28
35
|
end
|
|
29
36
|
|
|
30
37
|
def post(path, json: nil, form: nil, headers: {})
|
|
@@ -37,89 +44,95 @@ module PaygatePk
|
|
|
37
44
|
|
|
38
45
|
private
|
|
39
46
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
47
|
+
def conn
|
|
48
|
+
@conn ||= Faraday.new(url: @base_url) do |f|
|
|
49
|
+
f.request :retry,
|
|
50
|
+
max: @retry_conf[:max] || 2,
|
|
51
|
+
interval: @retry_conf[:interval] || 0.2,
|
|
52
|
+
backoff_factor: @retry_conf[:backoff_factor] || 2.0,
|
|
53
|
+
retry_statuses: @retry_conf[:retry_statuses] || [429, 500, 502, 503, 504]
|
|
54
|
+
f.request :url_encoded
|
|
55
|
+
f.response :raise_error
|
|
56
|
+
f.adapter Faraday.default_adapter
|
|
45
57
|
end
|
|
46
|
-
|
|
47
|
-
log_response(resp)
|
|
48
|
-
parse_body(resp)
|
|
49
|
-
rescue Faraday::ClientError => e
|
|
50
|
-
handle_client_error(e)
|
|
51
58
|
end
|
|
52
|
-
# rubocop:enable Metrics/ParameterLists
|
|
53
59
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
60
|
+
# rubocop:disable Metrics/AbcSize
|
|
61
|
+
def request(method, path, json: nil, form: nil, params: nil, headers: {})
|
|
62
|
+
resp = conn.run_request(method, path, nil, merged_headers(headers)) do |req|
|
|
63
|
+
apply_timeouts(req)
|
|
64
|
+
req.params.update(params) if params && !params.empty?
|
|
65
|
+
apply_body(req, json: json, form: form)
|
|
58
66
|
end
|
|
67
|
+
|
|
68
|
+
log_response(method, path, resp.status, form: form, json: json)
|
|
69
|
+
parse_body(resp)
|
|
70
|
+
rescue Faraday::TimeoutError => e
|
|
71
|
+
raise PaygatePk::TimeoutError.new(e.message, status: nil, body: nil)
|
|
72
|
+
rescue Faraday::ConnectionFailed => e
|
|
73
|
+
raise PaygatePk::ConnectionError.new(e.message, status: nil, body: nil)
|
|
74
|
+
rescue Faraday::Error => e
|
|
75
|
+
raise PaygatePk::HTTPError.new(
|
|
76
|
+
e.message,
|
|
77
|
+
status: e.response&.dig(:status),
|
|
78
|
+
body: e.response&.dig(:body)
|
|
79
|
+
)
|
|
59
80
|
end
|
|
81
|
+
# rubocop:enable Metrics/AbcSize
|
|
60
82
|
|
|
61
83
|
def merged_headers(headers)
|
|
62
84
|
base_headers.merge(headers)
|
|
63
85
|
end
|
|
64
86
|
|
|
65
|
-
def
|
|
66
|
-
req.options.timeout = @timeouts[:read_timeout] if @timeouts[:read_timeout]
|
|
87
|
+
def apply_timeouts(req)
|
|
67
88
|
req.options.open_timeout = @timeouts[:open_timeout] if @timeouts[:open_timeout]
|
|
89
|
+
req.options.timeout = @timeouts[:read_timeout] if @timeouts[:read_timeout]
|
|
68
90
|
end
|
|
69
91
|
|
|
70
|
-
def
|
|
92
|
+
def apply_body(req, json:, form:)
|
|
71
93
|
if json
|
|
72
|
-
|
|
94
|
+
req.headers["Content-Type"] = "application/json"
|
|
95
|
+
req.body = JSON.generate(json)
|
|
73
96
|
elsif form
|
|
74
|
-
|
|
97
|
+
req.headers["Content-Type"] = "application/x-www-form-urlencoded"
|
|
98
|
+
req.body = URI.encode_www_form(form)
|
|
75
99
|
end
|
|
76
100
|
end
|
|
77
101
|
|
|
78
|
-
def set_json_body(req, json)
|
|
79
|
-
req.headers["Content-Type"] = "application/json"
|
|
80
|
-
req.body = JSON.generate(json)
|
|
81
|
-
end
|
|
82
|
-
|
|
83
|
-
def set_form_body(req, form)
|
|
84
|
-
req.headers["Content-Type"] = "application/x-www-form-urlencoded"
|
|
85
|
-
req.body = URI.encode_www_form(form)
|
|
86
|
-
end
|
|
87
|
-
|
|
88
|
-
def handle_client_error(error)
|
|
89
|
-
body = error.response&.dig(:body)
|
|
90
|
-
status = error.response&.dig(:status)
|
|
91
|
-
|
|
92
|
-
raise PaygatePk::HTTPError.new(
|
|
93
|
-
error.message,
|
|
94
|
-
status: status,
|
|
95
|
-
body: body
|
|
96
|
-
)
|
|
97
|
-
end
|
|
98
|
-
|
|
99
102
|
def base_headers
|
|
103
|
+
ua = PaygatePk.config.user_agent.to_s
|
|
104
|
+
ua = "paygate_pk" if ua.empty? # PayFast rejects empty user agents
|
|
100
105
|
{
|
|
101
|
-
"User-Agent" =>
|
|
106
|
+
"User-Agent" => ua,
|
|
102
107
|
"X-Request-Id" => SecureRandom.uuid
|
|
103
108
|
}.merge(@headers)
|
|
104
109
|
end
|
|
105
110
|
|
|
106
111
|
def parse_body(resp)
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
end
|
|
114
|
-
else
|
|
115
|
-
b
|
|
116
|
-
end
|
|
112
|
+
body = resp.body
|
|
113
|
+
return body unless body.is_a?(String) && !body.empty?
|
|
114
|
+
|
|
115
|
+
JSON.parse(body)
|
|
116
|
+
rescue JSON::ParserError
|
|
117
|
+
body
|
|
117
118
|
end
|
|
118
119
|
|
|
119
|
-
def log_response(
|
|
120
|
+
def log_response(method, path, status, form:, json:)
|
|
120
121
|
return unless @logger
|
|
121
122
|
|
|
122
|
-
|
|
123
|
+
sanitised = redact(form || json)
|
|
124
|
+
@logger.info(
|
|
125
|
+
"paygate_pk #{method.to_s.upcase} #{path} status=#{status} body=#{sanitised.inspect}"
|
|
126
|
+
)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def redact(body)
|
|
130
|
+
return nil if body.nil?
|
|
131
|
+
return body unless body.is_a?(Hash)
|
|
132
|
+
|
|
133
|
+
body.each_with_object({}) do |(k, v), acc|
|
|
134
|
+
acc[k] = SENSITIVE_KEYS.include?(k.to_s) ? MASK : v
|
|
135
|
+
end
|
|
123
136
|
end
|
|
124
137
|
end
|
|
125
138
|
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PaygatePk
|
|
4
|
+
module PayFast
|
|
5
|
+
# Server-to-server PayFast token fetch (Merchant Integration Guide
|
|
6
|
+
# v2.3 §3.1). Internal: invoked by Redirect#build. Not advertised
|
|
7
|
+
# on the 1.0 public surface; use Redirect.build, which calls this
|
|
8
|
+
# automatically.
|
|
9
|
+
class Auth
|
|
10
|
+
def self.call(**kwargs)
|
|
11
|
+
new.call(**kwargs)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def initialize(config: PaygatePk::PayFast.config, http: nil)
|
|
15
|
+
@config = config
|
|
16
|
+
@http = http
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def call(basket_id:, amount:, currency: nil)
|
|
20
|
+
currency ||= PaygatePk.config.default_currency
|
|
21
|
+
ensure_config!
|
|
22
|
+
ensure_args!(basket_id: basket_id, amount: amount, currency: currency)
|
|
23
|
+
|
|
24
|
+
resp = http.post(Endpoints::GET_ACCESS_TOKEN_PATH, form: payload(basket_id, amount, currency))
|
|
25
|
+
token = extract_token(resp)
|
|
26
|
+
raise PaygatePk::AuthError, "missing ACCESS_TOKEN in PayFast token response" if Coercions.blank?(token)
|
|
27
|
+
|
|
28
|
+
Contracts::AccessToken.new(value: token, raw: resp)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def http
|
|
34
|
+
@http ||= PaygatePk::HTTP::Client.new(
|
|
35
|
+
base_url: @config.resolved_base_url,
|
|
36
|
+
headers: { "Accept" => "application/json" }
|
|
37
|
+
)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def ensure_config!
|
|
41
|
+
missing = []
|
|
42
|
+
missing << :merchant_id if Coercions.blank?(@config.merchant_id)
|
|
43
|
+
missing << :secured_key if Coercions.blank?(@config.secured_key)
|
|
44
|
+
return if missing.empty?
|
|
45
|
+
|
|
46
|
+
raise PaygatePk::ConfigurationError, "PayFast config missing: #{missing.join(", ")}"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def ensure_args!(basket_id:, amount:, currency:)
|
|
50
|
+
missing = []
|
|
51
|
+
missing << :basket_id if Coercions.blank?(basket_id)
|
|
52
|
+
missing << :amount if amount.nil?
|
|
53
|
+
missing << :currency if Coercions.blank?(currency)
|
|
54
|
+
return if missing.empty?
|
|
55
|
+
|
|
56
|
+
raise PaygatePk::ValidationError.new(
|
|
57
|
+
"missing required args: #{missing.join(", ")}",
|
|
58
|
+
details: { missing: missing }
|
|
59
|
+
)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def payload(basket_id, amount, currency)
|
|
63
|
+
{
|
|
64
|
+
"MERCHANT_ID" => @config.merchant_id,
|
|
65
|
+
"SECURED_KEY" => @config.secured_key,
|
|
66
|
+
"BASKET_ID" => basket_id.to_s,
|
|
67
|
+
"TXNAMT" => Coercions.to_amount_string(amount),
|
|
68
|
+
"CURRENCY_CODE" => currency
|
|
69
|
+
}
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def extract_token(resp)
|
|
73
|
+
return nil unless resp.is_a?(Hash)
|
|
74
|
+
|
|
75
|
+
resp["ACCESS_TOKEN"] || resp["access_token"]
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PaygatePk
|
|
4
|
+
module PayFast
|
|
5
|
+
# Verifies the IPN / browser-return notification PayFast sends to
|
|
6
|
+
# SUCCESS_URL, FAILURE_URL, or the configured CHECKOUT_URL.
|
|
7
|
+
#
|
|
8
|
+
# Per Merchant Integration Guide v2.3 §3.2.3:
|
|
9
|
+
# - PayFast posts/redirects with mixed-case keys: `basket_id`,
|
|
10
|
+
# `err_code`, `validation_hash` are lower; `Instrument_token`,
|
|
11
|
+
# `Recurring_txn`, `PaymentName` are PascalCase. We down-case
|
|
12
|
+
# everything internally so consumers don't have to care.
|
|
13
|
+
# - validation_hash = SHA256("basket_id|secured_key|merchant_id|err_code")
|
|
14
|
+
# compared in constant time via Util::Security.secure_compare.
|
|
15
|
+
#
|
|
16
|
+
# Returns Contracts::CallbackEvent on success, raises SignatureError
|
|
17
|
+
# on missing fields or hash mismatch.
|
|
18
|
+
class Callback
|
|
19
|
+
REQUIRED_KEYS = %w[basket_id err_code validation_hash].freeze
|
|
20
|
+
SUCCESS_CODE = "000"
|
|
21
|
+
|
|
22
|
+
def self.verify!(params, config: PaygatePk::PayFast.config)
|
|
23
|
+
new(config: config).verify!(params)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def initialize(config: PaygatePk::PayFast.config)
|
|
27
|
+
@config = config
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def verify!(raw_params)
|
|
31
|
+
params = normalize_keys(raw_params)
|
|
32
|
+
ensure_required!(params)
|
|
33
|
+
ensure_signature!(params)
|
|
34
|
+
build_event(params, raw_params)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def normalize_keys(hash)
|
|
40
|
+
# ActionController::Parameters and HashWithIndifferentAccess both
|
|
41
|
+
# respond to #to_h; fall back to dup for plain Hashes.
|
|
42
|
+
source = hash.respond_to?(:to_unsafe_h) ? hash.to_unsafe_h : hash.to_h
|
|
43
|
+
source.transform_keys { |k| k.to_s.downcase }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def ensure_required!(params)
|
|
47
|
+
missing = REQUIRED_KEYS.select { |k| Coercions.blank?(params[k]) }
|
|
48
|
+
return if missing.empty?
|
|
49
|
+
|
|
50
|
+
raise PaygatePk::SignatureError, "missing required callback param(s): #{missing.join(", ")}"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def ensure_signature!(params)
|
|
54
|
+
expected = PaygatePk::Util::Signature::PayFast.validation_hash(
|
|
55
|
+
basket_id: params["basket_id"],
|
|
56
|
+
merchant_secret_key: @config.secured_key,
|
|
57
|
+
merchant_id: @config.merchant_id,
|
|
58
|
+
payfast_err_code: params["err_code"]
|
|
59
|
+
)
|
|
60
|
+
return if PaygatePk::Util::Security.secure_compare(expected, params["validation_hash"].to_s)
|
|
61
|
+
|
|
62
|
+
raise PaygatePk::SignatureError, "invalid validation_hash"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def build_event(params, raw)
|
|
66
|
+
Contracts::CallbackEvent.new(
|
|
67
|
+
provider: :pay_fast,
|
|
68
|
+
transaction_id: params["transaction_id"],
|
|
69
|
+
basket_id: params["basket_id"],
|
|
70
|
+
order_date: params["order_date"],
|
|
71
|
+
approved: params["err_code"] == SUCCESS_CODE,
|
|
72
|
+
code: params["err_code"],
|
|
73
|
+
message: params["err_msg"],
|
|
74
|
+
amount: params["transaction_amount"],
|
|
75
|
+
merchant_amount: params["merchant_amount"],
|
|
76
|
+
discounted_amount: params["discounted_amount"],
|
|
77
|
+
currency: params["transaction_currency"],
|
|
78
|
+
payment_method: params["paymentname"],
|
|
79
|
+
instrument_token: params["instrument_token"],
|
|
80
|
+
recurring: truthy?(params["recurring_txn"]),
|
|
81
|
+
raw: raw
|
|
82
|
+
)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def truthy?(value)
|
|
86
|
+
return false if value.nil?
|
|
87
|
+
|
|
88
|
+
%w[true 1 yes].include?(value.to_s.downcase)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PaygatePk
|
|
4
|
+
module PayFast
|
|
5
|
+
# Centralised URL map. Selecting between sandbox and production is a
|
|
6
|
+
# config flag (PayFastConfig#environment); callers never hand-type
|
|
7
|
+
# hostnames.
|
|
8
|
+
#
|
|
9
|
+
# If/when PayFast hands you a bespoke staging host, set
|
|
10
|
+
# PaygatePk.config.pay_fast.base_url = "https://..."
|
|
11
|
+
# to override the env-derived value.
|
|
12
|
+
module Endpoints
|
|
13
|
+
URLS = {
|
|
14
|
+
sandbox: "https://ipguat.apps.net.pk",
|
|
15
|
+
# PayFast has not published an official production base URL in
|
|
16
|
+
# the v2.3 doc; merchants typically receive it on go-live.
|
|
17
|
+
# Configure via `c.pay_fast.base_url = "..."` until then.
|
|
18
|
+
production: "https://ipg1.apps.net.pk"
|
|
19
|
+
}.freeze
|
|
20
|
+
|
|
21
|
+
GET_ACCESS_TOKEN_PATH = "/Ecommerce/api/Transaction/GetAccessToken"
|
|
22
|
+
POST_TRANSACTION_PATH = "/Ecommerce/api/Transaction/PostTransaction"
|
|
23
|
+
|
|
24
|
+
module_function
|
|
25
|
+
|
|
26
|
+
def base_url(env)
|
|
27
|
+
URLS.fetch(env) do
|
|
28
|
+
raise PaygatePk::ConfigurationError, "unknown PayFast environment: #{env.inspect}"
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def post_transaction_url(env_or_base)
|
|
33
|
+
host = URLS[env_or_base] || env_or_base
|
|
34
|
+
"#{host}#{POST_TRANSACTION_PATH}"
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|