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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2736cc49f2b203095fdacfdecba63406ee29e6d1bd5672f910bf3826478d3310
4
- data.tar.gz: 40d83470713f8cead797d7e6aac1ca0127c8d18672882a2883a6c3cfc1ad43a2
3
+ metadata.gz: 9705a5a01a864f4d8e318e11448540887091d6ef3d7294c1abaeb00df24a4cba
4
+ data.tar.gz: d0408482c432003735671608da1a46e71f1bae509c8b7f455d7dcbd1dd99865e
5
5
  SHA512:
6
- metadata.gz: 4c7bd5fc036b0f16b293dd2347d2a13f305ac6f6d8d1c0b3f9a5b41bf1d283be4d3e0742948ad26609e9fa5c6db203130751d4cf6c22f759ab8bd3ca98437f73
7
- data.tar.gz: d6591bd6735779a9ac1bb4eaf4e30b3e80c71a7ed59a75097253b30714e4f2ffb5818921c53a247dc37b5c3db4d90b572c24b761839483d528d75765c047dd30
6
+ metadata.gz: 95ff08faf44d5d6bb64311e24b02ce077ac0e19c28b33ce322acd647f1728cd3702b5af08f42bf89dd9d80f1378ccff424ad510e60e30c78de264d2b580acebb
7
+ data.tar.gz: 43e241ecdaa260a068e5224f0a560845b09a07de9f61cb2cef667796e8611697b50070d55f7d6ddee6a66b431794444ad748bb63128ac498272a86e9cb7a569d
data/.rubocop.yml CHANGED
@@ -1,5 +1,11 @@
1
1
  AllCops:
2
- TargetRubyVersion: 2.6
2
+ TargetRubyVersion: 3.1
3
+ NewCops: enable
4
+ SuggestExtensions: false
5
+ Exclude:
6
+ - "coverage/**/*"
7
+ - "vendor/**/*"
8
+ - "*.gemspec"
3
9
 
4
10
  Style/StringLiterals:
5
11
  Enabled: true
@@ -9,16 +15,38 @@ Style/StringLiteralsInInterpolation:
9
15
  Enabled: true
10
16
  EnforcedStyle: double_quotes
11
17
 
18
+ Style/Documentation:
19
+ Enabled: false
20
+
12
21
  Layout/LineLength:
13
22
  Max: 120
14
23
 
15
24
  Metrics/MethodLength:
16
- Max: 20 # Example: Set maximum method length to 15 lines
25
+ Max: 25
17
26
  CountAsOne:
18
27
  - array
19
28
  - hash
20
29
  - heredoc
21
30
  - method_call
31
+ Exclude:
32
+ - "test/**/*"
22
33
 
23
34
  Metrics/AbcSize:
24
- Max: 20
35
+ Max: 25
36
+ Exclude:
37
+ - "test/**/*"
38
+
39
+ Metrics/ParameterLists:
40
+ CountKeywordArgs: false
41
+
42
+ Metrics/ClassLength:
43
+ Max: 200
44
+
45
+ # PayFast field names use ADDRESS_1, ADDRESS_2 -- mirror them in the
46
+ # option hashes so it's obvious how each key maps to the wire format.
47
+ Naming/VariableNumber:
48
+ Enabled: false
49
+
50
+ Naming/MethodParameterName:
51
+ Exclude:
52
+ - "test/**/*"
data/CHANGELOG.md CHANGED
@@ -1,5 +1,131 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.0.0] - 2026-05-12
4
+
5
+ ### Overview
6
+
7
+ Complete rewrite focused on **PayFast hosted-checkout (redirection)**.
8
+ The gem now ships everything needed to take a one-time payment in three
9
+ files — initializer, controller, and a one-line ERB. The 0.x server-to-
10
+ server `Checkout` class that POSTed to `PostTransaction` has been removed;
11
+ that endpoint is browser-side per PayFast's Merchant Integration Guide,
12
+ and the 0.x implementation was architecturally wrong.
13
+
14
+ ### Public API (new)
15
+
16
+ ```ruby
17
+ PaygatePk::PayFast::Redirect.build(...) # → Contracts::RedirectRequest
18
+ PaygatePk::PayFast::Callback.verify!(p) # → Contracts::CallbackEvent
19
+ ```
20
+
21
+ Rails view helper:
22
+
23
+ ```erb
24
+ <%= paygate_pk_redirect_form(@redirect, autosubmit: true) %>
25
+ ```
26
+
27
+ ### Added
28
+
29
+ - `PaygatePk::PayFast::Redirect` — fetches an access token, then assembles
30
+ every mandatory + optional PayFast form field documented in Merchant
31
+ Integration Guide v2.3 §3.2. Handles `items[]` arrays, shipping/billing
32
+ blocks, recurring flag, store override, currency override, and
33
+ free-form `extra_fields` passthrough.
34
+ - `PaygatePk::PayFast::Callback` — replaces the 0.x `Webhook` class.
35
+ Down-cases all incoming param keys once at entry so PayFast's
36
+ mixed-case `Recurring_txn`/`Instrument_token`/`PaymentName` and
37
+ lower-snake `basket_id`/`err_code`/`validation_hash` all work without
38
+ caller intervention.
39
+ - `PaygatePk::PayFast::Endpoints` — URL map keyed by environment
40
+ (`:sandbox`, `:production`); `c.pay_fast.base_url=` override still
41
+ supported for custom staging hosts.
42
+ - `PaygatePk::Rails::ViewHelpers#paygate_pk_redirect_form` —
43
+ auto-submitting form helper with CSP-nonce support, autoloaded via
44
+ a Railtie when the gem boots inside a Rails app.
45
+ - `Contracts::RedirectRequest` — `{ provider, action_url, http_method,
46
+ fields, basket_id, amount, token, raw }`.
47
+ - `Contracts::CallbackEvent` — universal IPN/return shape with
48
+ `approved?` predicate. Future Easypaisa callbacks will return the
49
+ same struct.
50
+ - `PaygatePk::Coercions` — `to_iso_date`, `to_amount_string`,
51
+ `blank?`/`present?`.
52
+ - `Config::PayFastConfig#environment` toggle, validated setter.
53
+ - `Config::PayFastConfig#merchant_name` (was hardcoded to `""` in 0.x).
54
+ - `HTTP::Client`: full Faraday error mapping (`TimeoutError`,
55
+ `ConnectionError`, `HTTPError` cover-all), memoised connection,
56
+ log redaction for `SECURED_KEY`/`Authorization`/`Credentials`.
57
+ - `Errors::CapabilityNotSupported`, `Errors::TimeoutError`,
58
+ `Errors::ConnectionError`, `Errors::ProviderError`.
59
+
60
+ ### Changed
61
+
62
+ - Public namespace flattened from `PaygatePk::Providers::PayFast::*`
63
+ to `PaygatePk::PayFast::*`.
64
+ - `Util::Signature::Payfast` renamed to `Util::Signature::PayFast`.
65
+ - `Config#frozen?` renamed to `Config#configured?`. `Config#freeze!`
66
+ now actually deep-freezes (was a no-op `@frozen = true` boolean).
67
+ - `Contracts::AccessToken#token` is now an alias for `#value`; the
68
+ primary field name is `value` to stay consistent with future
69
+ bearer/charge tokens.
70
+ - Required Ruby version: `>= 3.1.0` (matches the 0.x README claim;
71
+ gemspec used to say `>= 2.6.0` despite Faraday 2 effectively needing
72
+ 3.1).
73
+ - `spec.require_paths` reduced from `%w[lib test]` to `["lib"]` so
74
+ test helpers are no longer shipped on the gem load path.
75
+ - `nokogiri` removed from runtime deps (no longer parsing HTML
76
+ responses now that `Checkout` is gone).
77
+ - Test suite rewritten end-to-end. 76 tests / 200 assertions /
78
+ 96 % line coverage / 77 % branch coverage.
79
+
80
+ ### Removed
81
+
82
+ - `Providers::PayFast::Client` — dual facade/base-class
83
+ identity replaced by namespace module + `Endpoints`.
84
+ - `Providers::PayFast::Checkout` — server-to-server POST
85
+ to `PostTransaction` was wrong for the redirection flow.
86
+ - `Providers::PayFast::Webhook` — renamed to `Callback`.
87
+ - `Providers::PayFast::Tokenization::Token`/`Instrument` — deferred
88
+ to 1.2 alongside the saved-instrument charge endpoint. Source
89
+ preserved in git history.
90
+ - `Util::Html` — no longer needed.
91
+ - `Contracts::HostedCheckout` — replaced by
92
+ `RedirectRequest`.
93
+ - `Contracts::WebhookEvent` — renamed to
94
+ `CallbackEvent` with broader field coverage and an `approved?`
95
+ predicate.
96
+ - `Contracts::BearerToken`/`Instrument` — deferred to 1.2.
97
+
98
+ ### Fixed
99
+
100
+ - Callback key-casing bug. The 0.x `Webhook#verify!` only aliased
101
+ two specific PascalCase keys; in production PayFast sends a mix of
102
+ lower-snake and PascalCase keys that the old code couldn't read.
103
+ - `ORDER_DATE` is now coerced to `YYYY-MM-DD` per spec; the 0.x
104
+ flow let timestamped strings through silently.
105
+ - `SIGNATURE` is generated fresh per call (PayFast doc: "A random
106
+ string value"). The 0.x integration in office-management was
107
+ shipping the literal demo string `"SOMERANDOM-STRING"` to
108
+ production.
109
+ - `CURRENCY_CODE` is now plumbed through `Redirect.build`; the 0.x
110
+ `Checkout` read the global `PaygatePk.config.default_currency` and
111
+ ignored per-call values, silently mismatching auth and checkout.
112
+ - All `Faraday::Error` subclasses (timeout, connection failure, 5xx,
113
+ SSL) now surface as typed `PaygatePk` errors instead of escaping
114
+ as raw `Faraday::*` exceptions.
115
+ - `SECURED_KEY` no longer leaks into Rails logs when a debug logger
116
+ is wired up.
117
+ - `Config#freeze!` is now a real deep-freeze.
118
+
119
+ ### Migration from 0.x
120
+
121
+ | 0.x | 1.0 |
122
+ |-----|-----|
123
+ | `PaygatePk::Providers::PayFast::Auth.new.get_access_token(...)` | `PaygatePk::PayFast::Redirect.build(...)` calls `Auth` internally — you usually don't need it directly. |
124
+ | `PaygatePk::Providers::PayFast::Checkout.new.create!(opts: {...})` | `PaygatePk::PayFast::Redirect.build(...)` (kwargs, no `opts:` wrapper). |
125
+ | `PaygatePk::Providers::PayFast::Webhook.new.verify!(params)` | `PaygatePk::PayFast::Callback.verify!(params)` |
126
+ | `c.pay_fast.base_url = "..."` (hand-typed sandbox host) | `c.pay_fast.environment = :sandbox` (override only when PayFast hands you a custom staging URL). |
127
+ | Hand-built 18-field hidden form ERB | `<%= paygate_pk_redirect_form(@redirect) %>` |
128
+
3
129
  ## [0.2.0] - 2025-10-10
4
130
 
5
131
  - Added: IPN verification, Tokenization 3.1 & 3.15.
data/Gemfile.lock CHANGED
@@ -1,24 +1,49 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- paygate_pk (0.2.2)
4
+ paygate_pk (1.0.0)
5
5
  faraday (>= 2.7)
6
6
  faraday-retry (>= 2.0)
7
7
  json
8
- nokogiri (>= 1.16, < 2.0)
9
8
 
10
9
  GEM
11
10
  remote: https://rubygems.org/
12
11
  specs:
12
+ actionview (8.1.3)
13
+ activesupport (= 8.1.3)
14
+ builder (~> 3.1)
15
+ erubi (~> 1.11)
16
+ rails-dom-testing (~> 2.2)
17
+ rails-html-sanitizer (~> 1.6)
18
+ activesupport (8.1.3)
19
+ base64
20
+ bigdecimal
21
+ concurrent-ruby (~> 1.0, >= 1.3.1)
22
+ connection_pool (>= 2.2.5)
23
+ drb
24
+ i18n (>= 1.6, < 2)
25
+ json
26
+ logger (>= 1.4.2)
27
+ minitest (>= 5.1)
28
+ securerandom (>= 0.3)
29
+ tzinfo (~> 2.0, >= 2.0.5)
30
+ uri (>= 0.13.1)
13
31
  addressable (2.8.7)
14
32
  public_suffix (>= 2.0.2, < 7.0)
15
33
  ast (2.4.3)
34
+ base64 (0.3.0)
16
35
  bigdecimal (3.2.3)
36
+ builder (3.3.0)
17
37
  byebug (12.0.0)
38
+ concurrent-ruby (1.3.6)
39
+ connection_pool (3.0.2)
18
40
  crack (1.0.0)
19
41
  bigdecimal
20
42
  rexml
43
+ crass (1.0.6)
21
44
  docile (1.4.1)
45
+ drb (2.2.3)
46
+ erubi (1.13.1)
22
47
  faraday (2.13.1)
23
48
  faraday-net_http (>= 2.0, < 3.5)
24
49
  json
@@ -28,16 +53,21 @@ GEM
28
53
  faraday-retry (2.3.2)
29
54
  faraday (~> 2.0)
30
55
  hashdiff (1.2.1)
56
+ i18n (1.14.8)
57
+ concurrent-ruby (~> 1.0)
31
58
  json (2.12.2)
32
59
  language_server-protocol (3.17.0.5)
33
60
  lint_roller (1.1.0)
34
61
  logger (1.7.0)
62
+ loofah (2.25.1)
63
+ crass (~> 1.0.2)
64
+ nokogiri (>= 1.12.0)
35
65
  minitest (5.25.5)
36
66
  net-http (0.6.0)
37
67
  uri
38
- nokogiri (1.18.9-x86_64-darwin)
68
+ nokogiri (1.19.3-x86_64-darwin)
39
69
  racc (~> 1.4)
40
- nokogiri (1.18.9-x86_64-linux-gnu)
70
+ nokogiri (1.19.3-x86_64-linux-gnu)
41
71
  racc (~> 1.4)
42
72
  parallel (1.27.0)
43
73
  parser (3.3.8.0)
@@ -46,6 +76,13 @@ GEM
46
76
  prism (1.5.1)
47
77
  public_suffix (6.0.2)
48
78
  racc (1.8.1)
79
+ rails-dom-testing (2.3.0)
80
+ activesupport (>= 5.0.0)
81
+ minitest
82
+ nokogiri (>= 1.6)
83
+ rails-html-sanitizer (1.7.0)
84
+ loofah (~> 2.25)
85
+ nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
49
86
  rainbow (3.1.1)
50
87
  rake (13.3.0)
51
88
  regexp_parser (2.10.0)
@@ -65,16 +102,19 @@ GEM
65
102
  parser (>= 3.3.7.2)
66
103
  prism (~> 1.4)
67
104
  ruby-progressbar (1.13.0)
105
+ securerandom (0.4.1)
68
106
  simplecov (0.22.0)
69
107
  docile (~> 1.1)
70
108
  simplecov-html (~> 0.11)
71
109
  simplecov_json_formatter (~> 0.1)
72
110
  simplecov-html (0.13.2)
73
111
  simplecov_json_formatter (0.1.4)
112
+ tzinfo (2.0.6)
113
+ concurrent-ruby (~> 1.0)
74
114
  unicode-display_width (3.1.4)
75
115
  unicode-emoji (~> 4.0, >= 4.0.4)
76
116
  unicode-emoji (4.0.4)
77
- uri (1.0.3)
117
+ uri (1.1.1)
78
118
  webmock (3.25.1)
79
119
  addressable (>= 2.8.0)
80
120
  crack (>= 0.3.2)
@@ -85,6 +125,7 @@ PLATFORMS
85
125
  x86_64-linux
86
126
 
87
127
  DEPENDENCIES
128
+ actionview (>= 7.0)
88
129
  byebug
89
130
  minitest (~> 5.0)
90
131
  paygate_pk!
data/README.md CHANGED
@@ -1,118 +1,229 @@
1
1
  # PaygatePk
2
2
 
3
- Unified Ruby wrapper for PayFast (and soon Easypaisa) payments in Pakistan.
3
+ Unified Ruby/Rails client for Pakistani payment gateways.
4
4
 
5
- This gem provides a clean, provider-agnostic interface to obtain access tokens and create hosted checkouts with PayFast. It wraps HTTP details, validates required fields, and exposes simple, Ruby-friendly objects. Rails-friendly configuration is included; IPN verification and recurring/tokenized flows are on the roadmap.
5
+ **1.0 ships:** PayFast hosted-checkout (redirection flow) and callback verification, with a one-line Rails view helper.
6
+ **1.1 will ship:** Easypaisa REST APIs (Mobile Account, OTC voucher, Inquiry).
7
+ **1.2 will ship:** PayFast tokenization / saved-instrument charge.
8
+
9
+ The gem wraps every PayFast field documented in *Merchant Integration Guide v2.3*, validates required inputs, normalises dates and amounts, and returns plain Struct value objects you can pass around your Rails app.
6
10
 
7
11
  ## Requirements
8
12
 
9
13
  - Ruby ≥ 3.1
10
- - Faraday (runtime, included by gemspec)
11
- - Nokogiri (runtime, for HTML redirect parsing, included)
12
- - (Dev) Byebug, SimpleCov, RuboCop — optional
14
+ - Faraday 2.7
15
+ - (Rails apps) ActionView ≥ 7.0 for the view helper
13
16
 
14
17
  ## Installation
15
18
 
16
- Install the gem and add to the application's Gemfile by executing:
17
-
18
- $ bundle add "paygate_pk", "~> 0.2.0"
19
-
20
- If bundler is not being used to manage dependencies, install the gem by executing:
19
+ ```sh
20
+ bundle add paygate_pk
21
+ ```
21
22
 
22
- $ gem install "paygate_pk", "~> 0.2.0"
23
+ Or in a Gemfile:
23
24
 
24
- ## Usage
25
+ ```ruby
26
+ gem "paygate_pk", "~> 1.0"
27
+ ```
25
28
 
26
- # Initializer
29
+ ## Configure
27
30
 
28
31
  ```ruby
29
32
  # config/initializers/paygate_pk.rb
30
-
31
33
  PaygatePk.configure do |c|
32
34
  c.default_currency = "PKR"
33
- c.user_agent = "paygate_pk/#{PaygatePk::VERSION}"
34
35
 
35
- # PayFast base host only; endpoints include /Ecommerce/api internally
36
-
37
- c.pay_fast.base_url = "https://ipguat.apps.net.pk"
38
- c.pay_fast.merchant_id = ENV.fetch("PAYFAST_MERCHANT_ID")
39
- c.pay_fast.secured_key = ENV.fetch("PAYFAST_SECURED_KEY")
40
- c.pay_fast.api_base_url = "https://api.getfrompayfast.com"
41
-
42
- # Optional: tune timeouts & retries
43
-
44
- c.timeouts = { open_timeout: 5, read_timeout: 10 }
45
- c.retry = { max: 2, interval: 0.2, backoff_factor: 2.0, retry_statuses: [429, 500, 502, 503, 504] }
36
+ c.pay_fast.environment = Rails.env.production? ? :production : :sandbox
37
+ c.pay_fast.merchant_id = Rails.application.credentials.dig(:pay_fast, :merchant_id)
38
+ c.pay_fast.secured_key = Rails.application.credentials.dig(:pay_fast, :secured_key)
39
+ c.pay_fast.merchant_name = "Acme Store"
40
+ c.pay_fast.store_id = Rails.application.credentials.dig(:pay_fast, :store_id) # optional
46
41
  end
47
42
  ```
48
43
 
49
- ## QuickStart
44
+ Sandbox URL is built in. If PayFast hands you a bespoke staging or production host, override with `c.pay_fast.base_url = "https://..."`.
50
45
 
51
- # 1) Get Access Token (PayFast)
46
+ After the block runs the config is deep-frozen for the lifetime of the process. Use `PaygatePk.reset_config!` in console/tests to start over.
52
47
 
53
- ```ruby
48
+ ## Take a one-time payment via PayFast redirect
54
49
 
55
- client = PaygatePk::Providers::PayFast::Auth.new
50
+ ### 1. Build the redirect
56
51
 
57
- token_obj = auth.get_access_token(
58
- basket_id: "B-1001",
59
- amount: 1500
52
+ ```ruby
53
+ class Subscription::PaymentsController < ApplicationController
54
+ def create
55
+ @redirect = PaygatePk::PayFast::Redirect.build(
56
+ basket_id: "sp-#{payment.id}",
57
+ amount: 1500, # rupees
58
+ description: "Pro plan — monthly",
59
+ customer: {
60
+ mobile: current_user.mobile, # real 03xx mobile (mandatory)
61
+ email: current_user.email,
62
+ name: current_user.name # optional
63
+ },
64
+ success_url: success_subscription_payments_url,
65
+ failure_url: failed_subscription_payments_url,
66
+ checkout_url: webhooks_pay_fast_url, # optional backend IPN ping
67
+ recurring: false
68
+ )
69
+ end
70
+ end
71
+ ```
60
72
 
61
- # currency: "PKR", # optional; defaults to PaygatePk.config.default_currency
73
+ ### 2. Render the auto-submitting form
62
74
 
63
- # endpoint: "/Ecommerce/api/Transaction/GetAccessToken" # optional override
75
+ ```erb
76
+ <%# app/views/subscription/payments/create.html.erb %>
77
+ <%= paygate_pk_redirect_form(@redirect, autosubmit: true) %>
78
+ ```
64
79
 
65
- )
80
+ The customer's browser is now on PayFast. They pick a bank/card/wallet, enter their OTP, and PayFast redirects them back to your `success_url` (or `failure_url`).
66
81
 
67
- puts token_obj.token # => "ACCESS_TOKEN_STRING"
82
+ ### 3. Verify the return / IPN
68
83
 
84
+ ```ruby
85
+ class Subscription::PaymentsController < ApplicationController
86
+ def success
87
+ event = PaygatePk::PayFast::Callback.verify!(request.parameters)
88
+ if event.approved?
89
+ payment = SubscriptionPayment.find_by(basket_id: event.basket_id)
90
+ payment&.mark_completed!(transaction_id: event.transaction_id, amount: event.amount)
91
+ redirect_to dashboard_path, notice: "Payment received."
92
+ else
93
+ redirect_to dashboard_path, alert: "Payment failed: #{event.message}"
94
+ end
95
+ rescue PaygatePk::SignatureError
96
+ head :bad_request
97
+ end
98
+ end
69
99
  ```
70
100
 
71
- # 2) Verify IPN
101
+ For the server-to-server IPN (PayFast also POSTs to `CHECKOUT_URL`), wire the same call into your webhook controller — it's the more reliable source of truth (it doesn't depend on the customer's browser making it back).
72
102
 
73
- ```ruby
74
- verified_data = client.verify_ipn!(request.params)
75
-
76
- :provider, # Symbol e.g., :payfast
77
- :transaction_id, # String or nil
78
- :basket_id, # String
79
- :order_date, # String (YYYY-MM-DD) or Time/Date if you coerce later
80
- :approved, # Boolean (true if err_code == "000")
81
- :code, # Provider code, e.g., "000"
82
- :message, # Human-readable message
83
- :amount, # String/Integer (as received)
84
- :currency, # String "PKR" etc.
85
- :instrument_token, # String or nil (for tokenized flows)
86
- :recurring, # Boolean
87
- :raw, # Original params Hash (unmodified input)
103
+ ## All redirect options
88
104
 
105
+ ```ruby
106
+ PaygatePk::PayFast::Redirect.build(
107
+ basket_id: "B-1001",
108
+ amount: 1500,
109
+ description: "Order #1001",
110
+ customer: { mobile: "03001234567", email: "buyer@x.com", name: "Talha" },
111
+ success_url: "https://app/success",
112
+ failure_url: "https://app/failure",
113
+
114
+ # — optional —
115
+ currency: "PKR", # defaults to config.default_currency
116
+ order_date: Date.today, # Date / Time / String, coerced to YYYY-MM-DD
117
+ checkout_url: "https://app/ipn",
118
+ store_id: "102-ABC", # overrides config.pay_fast.store_id
119
+ recurring: false,
120
+ tran_type: "ECOMM_PURCHASE", # overrides config.pay_fast.tran_type
121
+ processing_type: "HYBRID_TOKEN",
122
+ instrument_token: "tok-from-saved-card",
123
+ transaction_instrument: 3, # 1=bank, 2=UnionPay, 3=card, 4=wallet
124
+
125
+ items: [
126
+ { sku: "SKU-1", name: "Widget", price: 100, qty: 2 },
127
+ { sku: "SKU-2", name: "Gizmo", price: 50, qty: 1 }
128
+ ],
129
+
130
+ shipping: {
131
+ name: "Talha", address_1: "House 9", address_2: "St 4",
132
+ state: "Punjab", city: "Lahore", postal_code: "54000", method: "Courier"
133
+ },
134
+ billing: { name: "Talha", city: "Lahore", address_1: "House 9" },
135
+
136
+ country: "PK",
137
+ customer_ip: request.remote_ip,
138
+ merchant_customer_id: current_user.id.to_s,
139
+ merchant_user_agent: request.user_agent,
140
+
141
+ extra_fields: { "CUSTOM_X" => "value" } # forward-compatible passthrough
142
+ )
89
143
  ```
90
144
 
91
- # Error handling
145
+ ## Contracts
146
+
147
+ ### `Contracts::RedirectRequest`
148
+
149
+ What `Redirect.build` returns, what the view helper consumes.
150
+
151
+ | Field | Type | Notes |
152
+ |---|---|---|
153
+ | `provider` | `Symbol` | `:pay_fast` |
154
+ | `action_url` | `String` | Where the browser POSTs |
155
+ | `http_method` | `Symbol` | `:post` |
156
+ | `fields` | `Hash<String,String>` | Every PayFast form field |
157
+ | `basket_id` | `String` | Echo |
158
+ | `amount` | `String` | Echo |
159
+ | `token` | `String` | The access token |
160
+ | `raw` | `Hash` | Raw token-API response |
161
+
162
+ ### `Contracts::CallbackEvent`
163
+
164
+ What `Callback.verify!` returns.
165
+
166
+ | Field | Type | Notes |
167
+ |---|---|---|
168
+ | `provider` | `Symbol` | `:pay_fast` |
169
+ | `transaction_id` | `String?` | |
170
+ | `basket_id` | `String` | |
171
+ | `order_date` | `String` | `YYYY-MM-DD` |
172
+ | `approved` / `approved?` | `Boolean` | `true` iff `err_code == "000"` |
173
+ | `code` | `String` | PayFast `err_code` |
174
+ | `message` | `String` | PayFast `err_msg` |
175
+ | `amount` | `String` | `transaction_amount` |
176
+ | `merchant_amount` | `String` | |
177
+ | `discounted_amount` | `String` | |
178
+ | `currency` | `String` | |
179
+ | `payment_method` | `String` | "card", "account", "wallet" |
180
+ | `instrument_token` | `String?` | |
181
+ | `recurring` | `Boolean` | |
182
+ | `raw` | `Hash` | Original params, unmodified |
183
+
184
+ ## Errors
185
+
186
+ All errors inherit from `PaygatePk::Error`:
187
+
188
+ | Class | Raised when |
189
+ |---|---|
190
+ | `ConfigurationError` | Required config is missing (e.g. `merchant_id`) |
191
+ | `ValidationError` | A required method argument is missing or blank (see `#details[:missing]`) |
192
+ | `HTTPError` | Non-2xx response from the gateway (carries `#status`, `#body`) |
193
+ | `TimeoutError` | Network timeout (subclass of `HTTPError`) |
194
+ | `ConnectionError` | DNS / SSL / refused connection (subclass of `HTTPError`) |
195
+ | `AuthError` | Token endpoint returned 2xx but the body had no `ACCESS_TOKEN` |
196
+ | `SignatureError` | Callback `validation_hash` mismatch or required field missing |
197
+ | `CapabilityNotSupported` | Provider asked for a flow it doesn't implement (1.1+) |
198
+ | `ProviderError` | Provider business-rule failure (carries `#code`, `#response`) |
199
+
200
+ ## Non-Rails apps
201
+
202
+ The view helper is the only Rails-specific piece — and it's autoloaded only if `Rails::Railtie` is present. Everything else works in plain Ruby / Sinatra / Hanami:
92
203
 
93
- All errors inherit from PaygatePk::Error:
94
-
95
- - PaygatePk::ConfigurationError missing/invalid configuration (e.g., merchant_id, secured_key, or base_url).
96
- - PaygatePk::ValidationError — missing required method arguments or required form fields.
97
- - PaygatePk::HTTPError — network/HTTP failure (wraps response status & body).
98
- - PaygatePk::AuthError — auth call succeeded at HTTP level but token missing/invalid in body.
99
- - PaygatePk::SignatureError — reserved for webhook/IPN verification (upcoming).
100
- - PaygatePk::ProviderError — reserved for provider business-rule failures.
204
+ ```ruby
205
+ redirect = PaygatePk::PayFast::Redirect.build(...)
206
+ # Render redirect.fields as <input type="hidden"> in your own template.
207
+ ```
101
208
 
102
209
  ## Development
103
210
 
104
- After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
211
+ ```sh
212
+ bin/setup
213
+ bundle exec rake test # 76 examples, 200 assertions, 0 failures
214
+ bundle exec rubocop
215
+ ```
105
216
 
106
- To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
217
+ ## Roadmap
218
+
219
+ - **1.1** — Easypaisa: Mobile Account, OTC voucher, Inquiry, IPN. New helper `paygate_pk_otc_voucher`. Adds `c.easy_paisa.*` config block.
220
+ - **1.2** — PayFast tokenization: bearer-token auth (`/api/token`), saved instruments (`/api/user/instruments`), charge-against-saved-instrument.
221
+ - **1.x+** — Additional Pakistani gateways (JazzCash, HBL, SafePay) under the same `Contracts::RedirectRequest`/`CallbackEvent` shape so host code stays unchanged.
107
222
 
108
223
  ## Contributing
109
224
 
110
- Bug reports and pull requests are welcome on GitHub at https://github.com/[USERNAME]/paygate_pk. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/[USERNAME]/paygate_pk/blob/master/CODE_OF_CONDUCT.md).
225
+ Issues and PRs at <https://github.com/qbitechs/paygate_pk>.
111
226
 
112
227
  ## License
113
228
 
114
- The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
115
-
116
- ## Code of Conduct
117
-
118
- Everyone interacting in the PaygatePk project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/[USERNAME]/paygate_pk/blob/master/CODE_OF_CONDUCT.md).
229
+ MIT. See [LICENSE.txt](LICENSE.txt).
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+
5
+ module PaygatePk
6
+ # Small set of input normalisers shared across providers.
7
+ # All methods are pure and side-effect-free.
8
+ module Coercions
9
+ module_function
10
+
11
+ DATE_ISO = "%Y-%m-%d"
12
+
13
+ # Returns "YYYY-MM-DD" (PayFast's ORDER_DATE format) for Date/Time/DateTime
14
+ # or a String already in that shape. nil-tolerant.
15
+ def to_iso_date(value)
16
+ return nil if value.nil?
17
+ return value if value.is_a?(String) && value.match?(/\A\d{4}-\d{2}-\d{2}\z/)
18
+
19
+ case value
20
+ when Date, Time, DateTime then value.strftime(DATE_ISO)
21
+ when String then Date.parse(value).strftime(DATE_ISO)
22
+ else
23
+ raise ArgumentError, "cannot coerce #{value.inspect} to ISO date"
24
+ end
25
+ end
26
+
27
+ # Render a numeric amount as a non-scientific decimal String. PayFast
28
+ # accepts TXNAMT as a string; 1500 → "1500", 1500.5 → "1500.5".
29
+ def to_amount_string(value)
30
+ return nil if value.nil?
31
+
32
+ case value
33
+ when Float, Rational, BigDecimal then format("%g", value)
34
+ else value.to_s
35
+ end
36
+ end
37
+
38
+ def blank?(value)
39
+ value.nil? || (value.respond_to?(:empty?) && value.empty?)
40
+ end
41
+
42
+ def present?(value)
43
+ !blank?(value)
44
+ end
45
+ end
46
+ end