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.
Files changed (36) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +31 -3
  3. data/CHANGELOG.md +126 -0
  4. data/Gemfile.lock +46 -5
  5. data/README.md +181 -70
  6. data/lib/paygate_pk/coercions.rb +46 -0
  7. data/lib/paygate_pk/config.rb +68 -24
  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/redirect_request.rb +30 -0
  11. data/lib/paygate_pk/errors.rb +16 -3
  12. data/lib/paygate_pk/http/client.rb +84 -71
  13. data/lib/paygate_pk/pay_fast/auth.rb +79 -0
  14. data/lib/paygate_pk/pay_fast/callback.rb +92 -0
  15. data/lib/paygate_pk/pay_fast/endpoints.rb +38 -0
  16. data/lib/paygate_pk/pay_fast/redirect.rb +227 -0
  17. data/lib/paygate_pk/pay_fast.rb +19 -0
  18. data/lib/paygate_pk/rails/railtie.rb +19 -0
  19. data/lib/paygate_pk/rails/view_helpers.rb +59 -0
  20. data/lib/paygate_pk/util/signature/pay_fast.rb +25 -0
  21. data/lib/paygate_pk/version.rb +1 -1
  22. data/lib/paygate_pk.rb +34 -18
  23. metadata +25 -32
  24. data/lib/paygate_pk/contracts/bearer_token.rb +0 -18
  25. data/lib/paygate_pk/contracts/hosted_checkout.rb +0 -8
  26. data/lib/paygate_pk/contracts/instrument.rb +0 -10
  27. data/lib/paygate_pk/contracts/webhook_event.rb +0 -24
  28. data/lib/paygate_pk/providers/pay_fast/auth.rb +0 -61
  29. data/lib/paygate_pk/providers/pay_fast/checkout.rb +0 -157
  30. data/lib/paygate_pk/providers/pay_fast/client.rb +0 -53
  31. data/lib/paygate_pk/providers/pay_fast/tokenization/instrument.rb +0 -63
  32. data/lib/paygate_pk/providers/pay_fast/tokenization/token.rb +0 -65
  33. data/lib/paygate_pk/providers/pay_fast/webhook.rb +0 -74
  34. data/lib/paygate_pk/util/html.rb +0 -42
  35. data/lib/paygate_pk/util/signature.rb +0 -18
  36. data/paygate_pk.gemspec +0 -46
@@ -1,45 +1,89 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module PaygatePk
4
- # Global configuration for PaygatePk
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 :logger, :default_currency, :timeouts, :retry, :user_agent
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 = { open_timeout: 5, read_timeout: 10 }
13
- @retry = { max: 2, interval: 0.2, backoff_factor: 2.0, retry_statuses: [429, 500, 502, 503, 504] }
14
- @user_agent = "paygate_pk/#{PaygatePk::VERSION}"
15
-
16
- @pay_fast = ProviderConfig.new
17
- @frozen = false
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
- @frozen = true
37
+ @configured = true
38
+ @pay_fast.freeze
39
+ freeze
22
40
  self
23
41
  end
24
42
 
25
- def frozen?
26
- @frozen
43
+ def configured?
44
+ @configured
27
45
  end
28
46
 
29
- # Provider-specific configuration
30
- class ProviderConfig
31
- attr_accessor :api_base_url, :base_url, :merchant_id, :secured_key, :checkout_mode, :username, :password,
32
- :store_id
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
- @base_url = nil
36
- @merchant_id = nil
37
- @secured_key = nil
38
- @checkout_mode = :immediate
39
- @username = nil
40
- @password = nil
41
- @store_id = nil
42
- @api_base_url = nil
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
- AccessToken = Struct.new(:token, :raw, keyword_init: true)
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
@@ -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