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.
Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +34 -3
  3. data/CHANGELOG.md +213 -0
  4. data/Gemfile.lock +46 -5
  5. data/README.md +291 -64
  6. data/lib/paygate_pk/coercions.rb +64 -0
  7. data/lib/paygate_pk/config.rb +113 -25
  8. data/lib/paygate_pk/contracts/access_token.rb +8 -2
  9. data/lib/paygate_pk/contracts/callback_event.rb +38 -0
  10. data/lib/paygate_pk/contracts/charge_result.rb +52 -0
  11. data/lib/paygate_pk/contracts/inquiry_result.rb +58 -0
  12. data/lib/paygate_pk/contracts/redirect_request.rb +30 -0
  13. data/lib/paygate_pk/easy_paisa/client.rb +64 -0
  14. data/lib/paygate_pk/easy_paisa/endpoints.rb +34 -0
  15. data/lib/paygate_pk/easy_paisa/inquiry.rb +87 -0
  16. data/lib/paygate_pk/easy_paisa/mobile_account.rb +123 -0
  17. data/lib/paygate_pk/easy_paisa/otc.rb +146 -0
  18. data/lib/paygate_pk/easy_paisa.rb +21 -0
  19. data/lib/paygate_pk/errors.rb +16 -3
  20. data/lib/paygate_pk/http/client.rb +84 -71
  21. data/lib/paygate_pk/pay_fast/auth.rb +79 -0
  22. data/lib/paygate_pk/pay_fast/callback.rb +92 -0
  23. data/lib/paygate_pk/pay_fast/endpoints.rb +38 -0
  24. data/lib/paygate_pk/pay_fast/redirect.rb +227 -0
  25. data/lib/paygate_pk/pay_fast.rb +19 -0
  26. data/lib/paygate_pk/rails/railtie.rb +19 -0
  27. data/lib/paygate_pk/rails/view_helpers.rb +159 -0
  28. data/lib/paygate_pk/util/credentials.rb +27 -0
  29. data/lib/paygate_pk/util/signature/pay_fast.rb +25 -0
  30. data/lib/paygate_pk/version.rb +1 -1
  31. data/lib/paygate_pk.rb +54 -18
  32. metadata +34 -32
  33. data/lib/paygate_pk/contracts/bearer_token.rb +0 -18
  34. data/lib/paygate_pk/contracts/hosted_checkout.rb +0 -8
  35. data/lib/paygate_pk/contracts/instrument.rb +0 -10
  36. data/lib/paygate_pk/contracts/webhook_event.rb +0 -24
  37. data/lib/paygate_pk/providers/pay_fast/auth.rb +0 -61
  38. data/lib/paygate_pk/providers/pay_fast/checkout.rb +0 -157
  39. data/lib/paygate_pk/providers/pay_fast/client.rb +0 -53
  40. data/lib/paygate_pk/providers/pay_fast/tokenization/instrument.rb +0 -63
  41. data/lib/paygate_pk/providers/pay_fast/tokenization/token.rb +0 -65
  42. data/lib/paygate_pk/providers/pay_fast/webhook.rb +0 -74
  43. data/lib/paygate_pk/util/html.rb +0 -42
  44. data/lib/paygate_pk/util/signature.rb +0 -18
  45. data/paygate_pk.gemspec +0 -46
@@ -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
- # Raised when a request validation fails.
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
- class ProviderError < Error; end
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
- # Simple HTTP client using Faraday
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
- def initialize(base_url:, headers: {}, timeouts: {}, retry_conf: {}, logger: nil)
13
- @conn = Faraday.new(url: base_url) do |f|
14
- f.request :retry,
15
- max: retry_conf[:max] || 2,
16
- interval: retry_conf[:interval] || 0.2,
17
- backoff_factor: retry_conf[:backoff_factor] || 2.0,
18
- retry_statuses: retry_conf[:retry_statuses] || [429, 500, 502, 503, 504]
19
-
20
- f.request :url_encoded
21
- f.response :raise_error
22
- f.adapter Faraday.default_adapter
23
- end
24
-
25
- @headers = headers
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
- # rubocop:disable Metrics/ParameterLists
41
- def request(method, path, json: nil, form: nil, params: nil, headers: {})
42
- resp = execute_request(method, path, params, headers) do |req|
43
- configure_timeouts(req)
44
- set_request_body(req, json, form)
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
- def execute_request(method, path, params, headers)
55
- @conn.run_request(method, path, nil, merged_headers(headers)) do |req|
56
- req.params.update(params) if params
57
- yield req if block_given?
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 configure_timeouts(req)
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 set_request_body(req, json, form)
92
+ def apply_body(req, json:, form:)
71
93
  if json
72
- set_json_body(req, json)
94
+ req.headers["Content-Type"] = "application/json"
95
+ req.body = JSON.generate(json)
73
96
  elsif form
74
- set_form_body(req, form)
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" => PaygatePk.config.user_agent || "paygate_pk",
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
- b = resp.body
108
- if b.is_a?(String) && !b.empty?
109
- begin
110
- JSON.parse(b)
111
- rescue StandardError
112
- b
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(resp)
120
+ def log_response(method, path, status, form:, json:)
120
121
  return unless @logger
121
122
 
122
- @logger.info("paygate_pk http #{resp.env.method.upcase} #{resp.env.url} -> #{resp.status}")
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