paygate_pk 0.2.3 → 1.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 +4 -4
- data/.rubocop.yml +34 -3
- data/CHANGELOG.md +213 -0
- data/Gemfile.lock +46 -5
- data/README.md +291 -64
- data/lib/paygate_pk/coercions.rb +64 -0
- data/lib/paygate_pk/config.rb +113 -25
- 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/charge_result.rb +52 -0
- data/lib/paygate_pk/contracts/inquiry_result.rb +58 -0
- data/lib/paygate_pk/contracts/redirect_request.rb +30 -0
- data/lib/paygate_pk/easy_paisa/client.rb +64 -0
- data/lib/paygate_pk/easy_paisa/endpoints.rb +34 -0
- data/lib/paygate_pk/easy_paisa/inquiry.rb +87 -0
- data/lib/paygate_pk/easy_paisa/mobile_account.rb +123 -0
- data/lib/paygate_pk/easy_paisa/otc.rb +146 -0
- data/lib/paygate_pk/easy_paisa.rb +21 -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 +159 -0
- data/lib/paygate_pk/util/credentials.rb +27 -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 +54 -18
- metadata +34 -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/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
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "date"
|
|
4
|
+
require "securerandom"
|
|
5
|
+
|
|
6
|
+
module PaygatePk
|
|
7
|
+
module PayFast
|
|
8
|
+
# Builds the redirect form that the customer's browser submits to
|
|
9
|
+
# PayFast's hosted checkout page.
|
|
10
|
+
#
|
|
11
|
+
# Flow (Merchant Integration Guide v2.3 §3.2):
|
|
12
|
+
# 1. Fetch ACCESS_TOKEN server-to-server (delegated to Auth)
|
|
13
|
+
# 2. Assemble all the PostTransaction form fields (mandatory +
|
|
14
|
+
# optional), with PayFast's UPPER_SNAKE_CASE naming
|
|
15
|
+
# 3. Return a Contracts::RedirectRequest the host app renders
|
|
16
|
+
# as an auto-submitting <form>
|
|
17
|
+
#
|
|
18
|
+
# Usage:
|
|
19
|
+
#
|
|
20
|
+
# redirect = PaygatePk::PayFast::Redirect.build(
|
|
21
|
+
# basket_id: "sp-#{payment.id}",
|
|
22
|
+
# amount: 1500,
|
|
23
|
+
# description: "Subscription",
|
|
24
|
+
# customer: { mobile: "03001234567", email: "buyer@x.com", name: "Talha" },
|
|
25
|
+
# success_url: success_url,
|
|
26
|
+
# failure_url: failure_url,
|
|
27
|
+
# checkout_url: webhooks_pay_fast_url, # optional, IPN backend ping
|
|
28
|
+
# recurring: false
|
|
29
|
+
# )
|
|
30
|
+
class Redirect
|
|
31
|
+
# Address-block field mapping. Preserves the SHIPPING_ADDRESS_CITU
|
|
32
|
+
# typo from the PayFast spec — we mirror what the gateway accepts,
|
|
33
|
+
# not what looks correct.
|
|
34
|
+
SHIPPING_FIELDS = {
|
|
35
|
+
name: "SHIPPING_CUSTOMER_NAME",
|
|
36
|
+
address_1: "SHIPPING_ADDRESS_1",
|
|
37
|
+
address_2: "SHIPPING_ADDRESS_2",
|
|
38
|
+
state: "SHIPPING_STATE_PROVINCE",
|
|
39
|
+
city: "SHIPPING_ADDRESS_CITU",
|
|
40
|
+
postal_code: "SHIPPING_POSTALCODE",
|
|
41
|
+
method: "SHIPPING_METHOD"
|
|
42
|
+
}.freeze
|
|
43
|
+
|
|
44
|
+
BILLING_FIELDS = {
|
|
45
|
+
name: "BILLING_CUSTOMER_NAME",
|
|
46
|
+
city: "BILLING_ADDRESS_CITY",
|
|
47
|
+
address_1: "BILLING_ADDRESS_1",
|
|
48
|
+
address_2: "BILLING_ADDRESS_2",
|
|
49
|
+
state: "BILLING_STATE_PROVINCE",
|
|
50
|
+
postal_code: "BILLING_POSTALCODE"
|
|
51
|
+
}.freeze
|
|
52
|
+
|
|
53
|
+
def self.build(**kwargs)
|
|
54
|
+
new.build(**kwargs)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def initialize(config: PaygatePk::PayFast.config, auth: nil)
|
|
58
|
+
@config = config
|
|
59
|
+
@auth = auth
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def build(basket_id:, amount:, customer:, success_url:, failure_url:, description:,
|
|
63
|
+
currency: nil, order_date: nil, checkout_url: nil, store_id: nil,
|
|
64
|
+
items: [], recurring: false, tran_type: nil, processing_type: nil,
|
|
65
|
+
instrument_token: nil, shipping: nil, billing: nil, country: nil,
|
|
66
|
+
customer_ip: nil, merchant_customer_id: nil, merchant_user_agent: nil,
|
|
67
|
+
transaction_instrument: nil, extra_fields: {})
|
|
68
|
+
currency ||= PaygatePk.config.default_currency
|
|
69
|
+
order_date_str = Coercions.to_iso_date(order_date) || Date.today.strftime("%Y-%m-%d")
|
|
70
|
+
|
|
71
|
+
ensure_config!
|
|
72
|
+
ensure_args!(basket_id: basket_id, amount: amount, customer: customer,
|
|
73
|
+
success_url: success_url, failure_url: failure_url, description: description)
|
|
74
|
+
|
|
75
|
+
token = auth.call(basket_id: basket_id, amount: amount, currency: currency)
|
|
76
|
+
|
|
77
|
+
fields = build_fields(
|
|
78
|
+
token: token.value,
|
|
79
|
+
basket_id: basket_id,
|
|
80
|
+
amount: amount,
|
|
81
|
+
currency: currency,
|
|
82
|
+
customer: customer,
|
|
83
|
+
success_url: success_url,
|
|
84
|
+
failure_url: failure_url,
|
|
85
|
+
checkout_url: checkout_url,
|
|
86
|
+
description: description,
|
|
87
|
+
order_date_str: order_date_str,
|
|
88
|
+
store_id: store_id,
|
|
89
|
+
items: items,
|
|
90
|
+
recurring: recurring,
|
|
91
|
+
tran_type: tran_type,
|
|
92
|
+
processing_type: processing_type,
|
|
93
|
+
instrument_token: instrument_token,
|
|
94
|
+
transaction_instrument: transaction_instrument,
|
|
95
|
+
shipping: shipping,
|
|
96
|
+
billing: billing,
|
|
97
|
+
country: country,
|
|
98
|
+
customer_ip: customer_ip,
|
|
99
|
+
merchant_customer_id: merchant_customer_id,
|
|
100
|
+
merchant_user_agent: merchant_user_agent,
|
|
101
|
+
extra_fields: extra_fields
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
Contracts::RedirectRequest.new(
|
|
105
|
+
provider: :pay_fast,
|
|
106
|
+
action_url: Endpoints.post_transaction_url(@config.resolved_base_url),
|
|
107
|
+
http_method: :post,
|
|
108
|
+
fields: fields,
|
|
109
|
+
basket_id: basket_id.to_s,
|
|
110
|
+
amount: Coercions.to_amount_string(amount),
|
|
111
|
+
token: token.value,
|
|
112
|
+
raw: token.raw
|
|
113
|
+
)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
private
|
|
117
|
+
|
|
118
|
+
def auth
|
|
119
|
+
@auth ||= Auth.new(config: @config)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def ensure_config!
|
|
123
|
+
missing = []
|
|
124
|
+
missing << :merchant_id if Coercions.blank?(@config.merchant_id)
|
|
125
|
+
missing << :secured_key if Coercions.blank?(@config.secured_key)
|
|
126
|
+
missing << :merchant_name if Coercions.blank?(@config.merchant_name)
|
|
127
|
+
return if missing.empty?
|
|
128
|
+
|
|
129
|
+
raise PaygatePk::ConfigurationError, "PayFast config missing: #{missing.join(", ")}"
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
133
|
+
def ensure_args!(basket_id:, amount:, customer:, success_url:, failure_url:, description:)
|
|
134
|
+
missing = []
|
|
135
|
+
missing << :basket_id if Coercions.blank?(basket_id)
|
|
136
|
+
missing << :amount if amount.nil?
|
|
137
|
+
missing << :success_url if Coercions.blank?(success_url)
|
|
138
|
+
missing << :failure_url if Coercions.blank?(failure_url)
|
|
139
|
+
missing << :description if Coercions.blank?(description)
|
|
140
|
+
missing << "customer.mobile" if !customer.is_a?(Hash) || Coercions.blank?(customer[:mobile])
|
|
141
|
+
missing << "customer.email" if !customer.is_a?(Hash) || Coercions.blank?(customer[:email])
|
|
142
|
+
return if missing.empty?
|
|
143
|
+
|
|
144
|
+
raise PaygatePk::ValidationError.new(
|
|
145
|
+
"missing required args: #{missing.join(", ")}",
|
|
146
|
+
details: { missing: missing }
|
|
147
|
+
)
|
|
148
|
+
end
|
|
149
|
+
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
150
|
+
|
|
151
|
+
def build_fields(**opts)
|
|
152
|
+
fields = {}
|
|
153
|
+
add_mandatory_fields!(fields, opts)
|
|
154
|
+
add_optional_simple_fields!(fields, opts)
|
|
155
|
+
add_address_fields!(fields, "SHIPPING", opts[:shipping]) if opts[:shipping]
|
|
156
|
+
add_address_fields!(fields, "BILLING", opts[:billing]) if opts[:billing]
|
|
157
|
+
add_items_fields!(fields, opts[:items]) if opts[:items].is_a?(Array) && !opts[:items].empty?
|
|
158
|
+
add_extra_fields!(fields, opts[:extra_fields])
|
|
159
|
+
fields
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# rubocop:disable Metrics/AbcSize
|
|
163
|
+
def add_mandatory_fields!(fields, opts)
|
|
164
|
+
fields["MERCHANT_ID"] = @config.merchant_id
|
|
165
|
+
fields["MERCHANT_NAME"] = @config.merchant_name
|
|
166
|
+
fields["TOKEN"] = opts[:token]
|
|
167
|
+
fields["PROCCODE"] = "00"
|
|
168
|
+
fields["TXNAMT"] = Coercions.to_amount_string(opts[:amount])
|
|
169
|
+
fields["CUSTOMER_MOBILE_NO"] = opts[:customer][:mobile].to_s
|
|
170
|
+
fields["CUSTOMER_EMAIL_ADDRESS"] = opts[:customer][:email].to_s
|
|
171
|
+
# SIGNATURE and VERSION are documented as "A random string value"
|
|
172
|
+
# in Merchant Integration Guide v2.3 §3.2 — they are not crypto
|
|
173
|
+
# signatures. We generate a fresh random per request.
|
|
174
|
+
fields["SIGNATURE"] = SecureRandom.hex(16)
|
|
175
|
+
fields["VERSION"] = @config.version_string || "paygate_pk/#{PaygatePk::VERSION}"
|
|
176
|
+
fields["TXNDESC"] = opts[:description]
|
|
177
|
+
fields["SUCCESS_URL"] = opts[:success_url]
|
|
178
|
+
fields["FAILURE_URL"] = opts[:failure_url]
|
|
179
|
+
fields["BASKET_ID"] = opts[:basket_id].to_s
|
|
180
|
+
fields["ORDER_DATE"] = opts[:order_date_str]
|
|
181
|
+
fields["CURRENCY_CODE"] = opts[:currency]
|
|
182
|
+
fields["TRAN_TYPE"] = opts[:tran_type] || @config.tran_type
|
|
183
|
+
end
|
|
184
|
+
# rubocop:enable Metrics/AbcSize
|
|
185
|
+
|
|
186
|
+
# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
187
|
+
def add_optional_simple_fields!(fields, opts)
|
|
188
|
+
fields["CHECKOUT_URL"] = opts[:checkout_url] if opts[:checkout_url]
|
|
189
|
+
store_id = opts[:store_id] || @config.store_id
|
|
190
|
+
fields["STORE_ID"] = store_id if Coercions.present?(store_id)
|
|
191
|
+
fields["RECURRING_TXN"] = opts[:recurring] ? "TRUE" : "FALSE"
|
|
192
|
+
fields["CUSTOMER_NAME"] = opts[:customer][:name].to_s if opts[:customer][:name]
|
|
193
|
+
fields["CUSTOMER_IPADDRESS"] = opts[:customer_ip] if opts[:customer_ip]
|
|
194
|
+
fields["MERCHANT_CUSTOMER_ID"] = opts[:merchant_customer_id] if opts[:merchant_customer_id]
|
|
195
|
+
fields["MERCHANT_USERAGENT"] = opts[:merchant_user_agent] if opts[:merchant_user_agent]
|
|
196
|
+
fields["COUNTRY"] = opts[:country] if opts[:country]
|
|
197
|
+
fields["PROCESSING_TYPE"] = opts[:processing_type] if opts[:processing_type]
|
|
198
|
+
fields["INSTRUMENT_TOKEN"] = opts[:instrument_token] if opts[:instrument_token]
|
|
199
|
+
fields["Transaction_Instrument"] = opts[:transaction_instrument].to_s if opts[:transaction_instrument]
|
|
200
|
+
end
|
|
201
|
+
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
202
|
+
|
|
203
|
+
def add_address_fields!(fields, prefix, hash)
|
|
204
|
+
map = prefix == "SHIPPING" ? SHIPPING_FIELDS : BILLING_FIELDS
|
|
205
|
+
map.each do |key, payfast_name|
|
|
206
|
+
v = hash[key]
|
|
207
|
+
fields[payfast_name] = v.to_s if Coercions.present?(v)
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def add_items_fields!(fields, items)
|
|
212
|
+
items.each_with_index do |item, i|
|
|
213
|
+
fields["ITEMS[#{i}][SKU]"] = item[:sku].to_s if item[:sku]
|
|
214
|
+
fields["ITEMS[#{i}][NAME]"] = item[:name].to_s if item[:name]
|
|
215
|
+
fields["ITEMS[#{i}][PRICE]"] = Coercions.to_amount_string(item[:price]) if item[:price]
|
|
216
|
+
fields["ITEMS[#{i}][QTY]"] = item[:qty].to_s if item[:qty]
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def add_extra_fields!(fields, extras)
|
|
221
|
+
return unless extras.is_a?(Hash)
|
|
222
|
+
|
|
223
|
+
extras.each { |k, v| fields[k.to_s] = v.to_s }
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
end
|