paygate_pk 0.2.2 → 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 -22
  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 -72
  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,61 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "date"
4
-
5
- module PaygatePk
6
- module Providers
7
- module PayFast
8
- # Auth client for PayFast API
9
- class Auth < Client
10
- ENDPOINT = "/Ecommerce/api/Transaction/GetAccessToken"
11
-
12
- # Returns Contracts::AccessToken
13
- # Required by PayFast: MERCHANT_ID, SECURED_KEY, BASKET_ID, TXNAMT, CURRENCY_CODE
14
- #
15
- def get_access_token(basket_id:, amount:, currency: PaygatePk.config.default_currency, endpoint: ENDPOINT)
16
- ensure_config!
17
- ensure_args!(basket_id: basket_id, amount: amount, currency: currency)
18
-
19
- # Guide endpoint: .../Ecommerce/api/Transaction/GetAccessToken
20
- resp = http.post(endpoint,
21
- form: payload(basket_id, amount, currency))
22
- token = resp.is_a?(Hash) ? (resp["ACCESS_TOKEN"] || resp["access_token"]) : nil
23
- raise AuthError, "missing ACCESS_TOKEN in response" unless token
24
-
25
- Contracts::AccessToken.new(token: token, raw: resp)
26
- end
27
-
28
- private
29
-
30
- def base_url
31
- config.base_url
32
- end
33
-
34
- def ensure_config!
35
- missing = []
36
- missing << :merchant_id if @config.merchant_id.to_s.strip.empty?
37
- missing << :secured_key if @config.secured_key.to_s.strip.empty?
38
- raise ConfigurationError, "PayFast config missing: #{missing.join(", ")}" unless missing.empty?
39
- end
40
-
41
- def ensure_args!(basket_id:, amount:, currency:)
42
- missing = []
43
- missing << :basket_id if basket_id.to_s.strip.empty?
44
- missing << :amount if amount.nil?
45
- missing << :currency if currency.to_s.strip.empty?
46
- raise ValidationError, "missing required args: #{missing.join(", ")}" unless missing.empty?
47
- end
48
-
49
- def payload(basket_id, amount, currency)
50
- {
51
- "MERCHANT_ID" => @config.merchant_id,
52
- "SECURED_KEY" => @config.secured_key,
53
- "BASKET_ID" => basket_id,
54
- "TXNAMT" => amount.to_s,
55
- "CURRENCY_CODE" => currency
56
- }
57
- end
58
- end
59
- end
60
- end
61
- end
@@ -1,157 +0,0 @@
1
- # lib/paygate_pk/providers/pay_fast/checkout.rb
2
- # frozen_string_literal: true
3
-
4
- require "securerandom"
5
- require "date"
6
- require "nokogiri"
7
-
8
- module PaygatePk
9
- module Providers
10
- module PayFast
11
- # Builds and submits a hosted checkout request to PayFast.
12
- #
13
- # Usage:
14
- # client = PaygatePk::Providers::PayFast::Checkout.new(config: PaygatePk.config.payfast)
15
- # checkout = client.create!(
16
- # token: token.token,
17
- # basket_id: "B-1001",
18
- # amount: 1500,
19
- # customer: { mobile: "03xxxxxxxxx", email: "buyer@example.com" },
20
- # success_url: "...",
21
- # failure_url: "...",
22
- # description: "Order #1001",
23
- # checkout_mode: :immediate
24
- # )
25
- class Checkout < Client
26
- ENDPOINT = "/Ecommerce/api/Transaction/PostTransaction"
27
-
28
- REQUIRED_ROOT_KEYS = %i[token basket_id amount success_url failure_url description].freeze
29
- REQUIRED_CUSTOMER_KEYS = %i[mobile email].freeze
30
-
31
- # Creates a hosted checkout via PayFast.
32
- #
33
- # @param token [String] ACCESS_TOKEN from GetAccessToken
34
- # @param basket_id [String]
35
- # @param amount [Integer, Float] rupees
36
- # @param customer [Hash] keys: :mobile, :email
37
- # @param success_url [String]
38
- # @param failure_url [String]
39
- # @param description [String] TXNDESC
40
- # @param order_date [Date] defaults: Date.today
41
- # @param checkout_mode [Symbol] :immediate or :delayed (defaults from config)
42
- # @param endpoint [Symbol] default to ENDPOINT constant
43
-
44
- # @return [PaygatePk::Contracts::HostedCheckout]
45
- def create!(opts: {})
46
- validate_config!
47
- validate_args!(opts)
48
-
49
- form = build_form(opts)
50
-
51
- response = http.post(opts[:endpoint] || ENDPOINT, form: form)
52
-
53
- PaygatePk::Contracts::HostedCheckout.new(
54
- provider: :payfast,
55
- basket_id: opts[:basket_id],
56
- amount: opts[:amount],
57
- url: PaygatePk::Util::Html.first_anchor_href(response),
58
- form: PaygatePk::Util::Html.extract_form(response),
59
- raw: response
60
- )
61
- end
62
-
63
- private
64
-
65
- def base_url
66
- config.base_url
67
- end
68
-
69
- # -- Validation ---------------------------------------------------------
70
-
71
- def validate_config!
72
- missing = []
73
- missing << :merchant_id if blank?(config.merchant_id)
74
- missing << :secured_key if blank?(config.secured_key)
75
- raise PaygatePk::ConfigurationError, "PayFast config missing: #{missing.join(", ")}" unless missing.empty?
76
- end
77
-
78
- def validate_args!(opts)
79
- missing = []
80
- REQUIRED_ROOT_KEYS.each do |k|
81
- v = opts[k]
82
- missing << k if k == :amount ? opts[:amount].nil? : blank?(v)
83
- end
84
- REQUIRED_CUSTOMER_KEYS.each { |k| missing << :"customer.#{k}" if blank?(opts[:customer][k]) }
85
- raise PaygatePk::ValidationError, "missing required args: #{missing.join(", ")}" unless missing.empty?
86
- end
87
-
88
- # -- Builders -----------------------------------------------------------
89
-
90
- def merchant_params
91
- {
92
- "MERCHANT_ID" => config.merchant_id,
93
- "MERCHANT_NAME" => merchant_name_default
94
- }
95
- end
96
-
97
- def order_params(opts)
98
- {
99
- "BASKET_ID" => opts[:basket_id],
100
- "TXNDESC" => opts[:description],
101
- "TXNAMT" => opts[:amount].to_s,
102
- "CURRENCY_CODE" => PaygatePk.config.default_currency,
103
- "ORDER_DATE" => (opts[:order_date] || Date.today).to_s,
104
- "SUCCESS_URL" => opts[:success_url],
105
- "FAILURE_URL" => opts[:failure_url],
106
- "CHECKOUT_URL" => normalize_checkout_mode(opts[:checkout_mode] || config.checkout_mode)
107
- }
108
- end
109
-
110
- def customer_params(opts)
111
- {
112
- "CUSTOMER_MOBILE_NO" => opts[:customer][:mobile],
113
- "CUSTOMER_EMAIL_ADDRESS" => opts[:customer][:email]
114
- }
115
- end
116
-
117
- def generic_params(opts)
118
- {
119
- "TOKEN" => opts[:token],
120
- "PROCCODE" => "00",
121
- "SIGNATURE" => SecureRandom.hex(16),
122
- "VERSION" => PaygatePk::VERSION
123
- }
124
- end
125
-
126
- def build_form(opts)
127
- merchant_params
128
- .merge(order_params(opts))
129
- .merge(customer_params(opts))
130
- .merge(generic_params(opts))
131
- end
132
-
133
- # -- Helpers ------------------------------------------------------------
134
-
135
- def normalize_checkout_mode(mode)
136
- case mode&.to_sym
137
- when :delayed then "DELAYED"
138
- when :immediate, nil then "IMMEDIATE"
139
- else
140
- # Be lenient but explicit: unknown symbols fall back to IMMEDIATE
141
- "IMMEDIATE"
142
- end
143
- end
144
-
145
- def merchant_name_default
146
- # Kept blank as many integrations treat it as optional; override here
147
- # later if you decide to expose it via config.
148
- ""
149
- end
150
-
151
- def blank?(value)
152
- value.nil? || (value.respond_to?(:empty?) && value.empty?)
153
- end
154
- end
155
- end
156
- end
157
- end
@@ -1,53 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative "../../http/client"
4
- require_relative "../../version"
5
-
6
- module PaygatePk
7
- module Providers
8
- module PayFast
9
- # HTTP client for PayFast API
10
- class Client
11
- def initialize(config: PaygatePk.config.pay_fast)
12
- @config = config
13
- end
14
-
15
- def get_access_token(**params)
16
- Auth.new(config: @config).get_access_token(**params)
17
- end
18
-
19
- def verify_ipn!(params)
20
- Webhook.new.verify!(params)
21
- end
22
-
23
- def create_checkout(**params)
24
- Checkout.new(config: @config).create!(**params)
25
- end
26
-
27
- def get_bearer_token(**params)
28
- Tokenization::Token.new(config: @config).get(**params)
29
- end
30
-
31
- def instruments(**params)
32
- Tokenization::Instrument.new(config: @config).list(**params)
33
- end
34
-
35
- private
36
-
37
- attr_reader :config
38
-
39
- def http
40
- raise ConfigurationError, "PayFast base_url not set" unless config.base_url
41
-
42
- PaygatePk::HTTP::Client.new(
43
- base_url: base_url,
44
- headers: { "Accept" => "application/json" },
45
- timeouts: PaygatePk.config.timeouts,
46
- retry_conf: PaygatePk.config.retry,
47
- logger: PaygatePk.config.logger
48
- )
49
- end
50
- end
51
- end
52
- end
53
- end
@@ -1,63 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module PaygatePk
4
- module Providers
5
- module PayFast
6
- module Tokenization
7
- # used to generate the bearer_token
8
- class Instrument < PaygatePk::Providers::PayFast::Client
9
- INSTRUMENTS_ENDPOINT = "/api/user/instruments"
10
-
11
- def list(token:, user_id:, mobile_number:, options: {})
12
- ensure_present!(token: token,
13
- user_id: user_id,
14
- mobile_number: mobile_number)
15
-
16
- resp = http.get(INSTRUMENTS_ENDPOINT,
17
- params: body(user_id, mobile_number, options),
18
- headers: { "Authorization" => "Bearer #{token}" })
19
-
20
- # Response is an array of hashes with instrument_token, account_type, description, instrument_alias
21
- Array(resp).map do |h|
22
- PaygatePk::Contracts::Instrument.new(
23
- instrument_token: h["instrument_token"],
24
- account_type: h["account_type"],
25
- description: h["description"],
26
- instrument_alias: h["instrument_alias"],
27
- raw: h
28
- )
29
- end
30
- end
31
-
32
- private
33
-
34
- def base_url
35
- config.api_base_url
36
- end
37
-
38
- def body(user_id, mobile_no, options)
39
- attrs = {
40
- "merchant_user_id" => user_id,
41
- "user_mobile_number" => mobile_no
42
- }
43
-
44
- # rubocop:disable Naming/VariableNumber
45
- attrs["customer_ip"] = options[:customer_ip] if options[:customer_ip]
46
- attrs["reserved_1"] = options[:reserved_1] if options[:reserved_1]
47
- attrs["reserved_2"] = options[:reserved_2] if options[:reserved_2]
48
- attrs["reserved_3"] = options[:reserved_3] if options[:reserved_3]
49
- attrs["api_version"] = options[:api_version] if options[:api_version]
50
- # rubocop:enable Naming/VariableNumber
51
-
52
- attrs
53
- end
54
-
55
- def ensure_present!(**pairs)
56
- missing = pairs.select { |_k, v| v.nil? || (v.respond_to?(:empty?) && v.empty?) }.keys
57
- raise PaygatePk::ValidationError, "missing required args: #{missing.join(", ")}" unless missing.empty?
58
- end
59
- end
60
- end
61
- end
62
- end
63
- end
@@ -1,65 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "bigdecimal"
4
-
5
- module PaygatePk
6
- module Providers
7
- module PayFast
8
- module Tokenization
9
- # used to generate the bearer_token
10
- class Token < PaygatePk::Providers::PayFast::Client
11
- TOKEN_ENDPOINT = "/api/token"
12
- DEFAULT_GRANT_TYPE = "client_credentials"
13
-
14
- # 3.1 Authentication Access Token
15
- # Required: merchant_id, secured_key, grant_type
16
- # Optional: customer_ip, reserved_1..3, api_version
17
- # Returns: PaygatePk::Contracts::BearerToken
18
- def get(grant_type: DEFAULT_GRANT_TYPE, options: {})
19
- mid = config.merchant_id
20
- sec = config.secured_key
21
-
22
- ensure_present!(merchant_id: mid, secured_key: sec, grant_type: grant_type)
23
-
24
- resp = http.post(TOKEN_ENDPOINT, form: body(mid, sec, grant_type, options))
25
-
26
- PaygatePk::Contracts::BearerToken.new(
27
- access_token: resp["token"], # if present
28
- refresh_token: resp["refresh_token"], # shown in doc example
29
- expiry: resp["expiry"],
30
- code: resp["code"],
31
- message: resp["message"],
32
- raw: resp
33
- )
34
- end
35
-
36
- private
37
-
38
- def base_url
39
- config.api_base_url
40
- end
41
-
42
- def body(mid, sec, grant_type, options)
43
- attrs = {
44
- "merchant_id" => mid,
45
- "secured_key" => sec,
46
- "grant_type" => grant_type
47
- }
48
- attrs["customer_ip"] = options[:customer_ip] if options[:customer_ip]
49
- attrs["reserved_1"] = options[:reserved1] if options[:reserved1]
50
- attrs["reserved_2"] = options[:reserved2] if options[:reserved2]
51
- attrs["reserved_3"] = options[:reserved3] if options[:reserved3]
52
- attrs["api_version"] = options[:api_version] if options[:api_version]
53
-
54
- attrs
55
- end
56
-
57
- def ensure_present!(**pairs)
58
- missing = pairs.select { |_k, v| v.nil? || (v.respond_to?(:empty?) && v.empty?) }.keys
59
- raise PaygatePk::ValidationError, "missing required args: #{missing.join(", ")}" unless missing.empty?
60
- end
61
- end
62
- end
63
- end
64
- end
65
- end
@@ -1,72 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module PaygatePk
4
- module Providers
5
- module PayFast
6
- # Verifies PayFast IPN/notification params (GET to your CHECKOUT_URL)
7
- # Returns PaygatePk::Contracts::WebhookEvent on success, raises on failure.
8
- class Webhook
9
- def verify!(raw_params)
10
- params = normalize_keys(raw_params)
11
-
12
- validate_required_params(params)
13
- verify_signature(params)
14
- build_webhook(params, raw_params)
15
- end
16
-
17
- private
18
-
19
- def verify_signature(params)
20
- expected = PaygatePk::Util::Signature::Payfast.validation_hash(
21
- basket_id: params["basket_id"],
22
- merchant_secret_key: PaygatePk.config.pay_fast.secured_key,
23
- merchant_id: PaygatePk.config.pay_fast.merchant_id,
24
- payfast_err_code: params["err_code"]
25
- )
26
- return if PaygatePk::Util::Security.secure_compare(expected, params["validation_hash"])
27
-
28
- raise PaygatePk::SignatureError, "invalid validation_hash"
29
- end
30
-
31
- def validate_required_params(params)
32
- %w[basket_id err_code validation_hash].each do |k|
33
- raise PaygatePk::SignatureError, "missing #{k}" unless present?(params[k])
34
- end
35
- end
36
-
37
- def normalize_keys(hash)
38
- hash.transform_keys(&:to_s).tap do |x|
39
- # common aliases to lowercase
40
- x["instrument_token"] ||= x["Instrument_token"]
41
- x["recurring_txn"] ||= x["Recurring_txn"] || x["RECURRING_TXN"]
42
- end
43
- end
44
-
45
- def present?(val)
46
- !(val.nil? || (val.respond_to?(:empty?) && val.empty?))
47
- end
48
-
49
- def truthy?(val)
50
- [true, "true", "TRUE", "1", 1].include?(val)
51
- end
52
-
53
- def build_webhook(params, raw_params)
54
- PaygatePk::Contracts::WebhookEvent.new(
55
- provider: :payfast,
56
- transaction_id: params["transaction_id"],
57
- basket_id: params["basket_id"],
58
- order_date: params["order_date"],
59
- approved: params["err_code"] == "000",
60
- code: params["err_code"],
61
- message: params["err_msg"],
62
- amount: params["amount"],
63
- currency: params["currency"],
64
- instrument_token: params["instrument_token"] || params["Instrument_token"],
65
- recurring: truthy?(params["recurring_txn"]) || truthy?(params["RECURRING_TXN"]),
66
- raw: raw_params
67
- )
68
- end
69
- end
70
- end
71
- end
72
- end
@@ -1,42 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "nokogiri"
4
-
5
- module PaygatePk
6
- module Util
7
- # HTML parsing utilities
8
- module Html
9
- module_function
10
-
11
- # Extract a specific <form> (default: first) into a hash of action/method/inputs
12
- def extract_form(html, index: 0)
13
- doc = parse(html)
14
- form = doc.css("form")[index]
15
- return nil unless form
16
-
17
- {
18
- action: form["action"],
19
- method: (form["method"] || "GET").upcase,
20
- inputs: form.css("input[name]").to_h { |i| [i["name"], i["value"]] }
21
- }
22
- end
23
-
24
- # NEW: Return the first anchor href (or nil) – handy for redirect pages
25
- # Optionally pass a CSS selector (e.g., "a.pay-button") if you need a specific link.
26
- def first_anchor_href(html, selector: "a")
27
- doc = parse(html)
28
- a = doc.at(selector)
29
- a ? a["href"] : nil
30
- end
31
-
32
- # --- internals ----------------------------------------------------------
33
-
34
- def parse(html)
35
- Nokogiri::HTML5(html)
36
- rescue NoMethodError
37
- # Fallback for environments without HTML5 parser
38
- Nokogiri::HTML(html)
39
- end
40
- end
41
- end
42
- end
@@ -1,18 +0,0 @@
1
- # lib/paygate_pk/util/signature.rb
2
- # frozen_string_literal: true
3
-
4
- require "openssl"
5
-
6
- module PaygatePk
7
- module Util
8
- module Signature
9
- # validation_hash = SHA256("basket_id|merchant_secret_key|merchant_id|payfast_err_code")
10
- module Payfast
11
- def self.validation_hash(basket_id:, merchant_secret_key:, merchant_id:, payfast_err_code:)
12
- data = [basket_id, merchant_secret_key, merchant_id, payfast_err_code].join("|")
13
- OpenSSL::Digest::SHA256.hexdigest(data)
14
- end
15
- end
16
- end
17
- end
18
- end
data/paygate_pk.gemspec DELETED
@@ -1,46 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative "lib/paygate_pk/version"
4
-
5
- Gem::Specification.new do |spec|
6
- spec.name = "paygate_pk"
7
- spec.version = PaygatePk::VERSION
8
- spec.authors = ["Talha Junaid"]
9
- spec.email = ["talhajunaid65@gmail.com"]
10
- spec.summary = "Unified Ruby wrapper for PayFast"
11
- spec.description = "Provider-agnostic Ruby/Rails client for PayFast: checkout, webhooks/IPN verification,
12
- tokenized & recurring payments."
13
- spec.license = "MIT"
14
- spec.homepage = "https://github.com/qbitechs/paygate_pk"
15
- spec.metadata = {
16
- "source_code_uri" => "https://github.com/qbitechs/paygate_pk",
17
- "changelog_uri" => "https://github.com/qbitechs/paygate_pk/blob/main/CHANGELOG.md",
18
- "homepage_url" => spec.homepage
19
- }
20
-
21
- spec.required_ruby_version = ">= 2.6.0"
22
-
23
- # Specify which files should be added to the gem when it is released.
24
- # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
25
- spec.files = Dir.chdir(__dir__) do
26
- `git ls-files -z`.split("\x0").reject do |f|
27
- (File.expand_path(f) == __FILE__) || f.start_with?(*%w[bin/ test/ spec/ features/ .git .circleci appveyor])
28
- end
29
- end
30
- spec.bindir = "exe"
31
- spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
32
- spec.require_paths = %w[lib test]
33
-
34
- # Runtime deps
35
- spec.add_dependency "faraday", ">= 2.7"
36
- spec.add_dependency "faraday-retry", ">= 2.0"
37
- spec.add_dependency "json"
38
- spec.add_dependency "nokogiri", ">= 1.16", "< 2.0"
39
-
40
- spec.add_development_dependency "byebug"
41
- spec.add_development_dependency "minitest", "~> 5.0"
42
- spec.add_development_dependency "rake", "~> 13.0"
43
- spec.add_development_dependency "rubocop", "~> 1.21"
44
- spec.add_development_dependency "simplecov", ">= 0.22"
45
- spec.add_development_dependency "webmock"
46
- end