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
data/README.md CHANGED
@@ -1,118 +1,345 @@
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 ships:** Easypaisa REST APIs — Mobile Account, OTC voucher, Inquiry — plus a Rails OTC voucher helper.
7
+ **1.2 will ship:** PayFast tokenization / saved-instrument charge and Easypaisa IPN verification.
8
+
9
+ The gem wraps every documented field from the *PayFast Merchant Integration Guide v2.3* and the *Easypaisa REST APIs without RSA Integration Guide*, 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.1"
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
+ # PayFast (hosted checkout / redirection)
37
+ c.pay_fast.environment = Rails.env.production? ? :production : :sandbox
38
+ c.pay_fast.merchant_id = Rails.application.credentials.dig(:pay_fast, :merchant_id)
39
+ c.pay_fast.secured_key = Rails.application.credentials.dig(:pay_fast, :secured_key)
40
+ c.pay_fast.merchant_name = "Acme Store"
41
+ c.pay_fast.store_id = Rails.application.credentials.dig(:pay_fast, :store_id) # optional
42
+
43
+ # Easypaisa (REST: Mobile Account / OTC / Inquiry)
44
+ c.easy_paisa.environment = Rails.env.production? ? :production : :sandbox
45
+ c.easy_paisa.username = Rails.application.credentials.dig(:easy_paisa, :username)
46
+ c.easy_paisa.password = Rails.application.credentials.dig(:easy_paisa, :password)
47
+ c.easy_paisa.store_id = Rails.application.credentials.dig(:easy_paisa, :store_id)
48
+ c.easy_paisa.account_num = Rails.application.credentials.dig(:easy_paisa, :account_num) # required for Inquiry
49
+ end
50
+ ```
51
+
52
+ Sandbox URLs are built in for both providers. If either hands you a bespoke staging or production host, override with `c.pay_fast.base_url = "https://..."` / `c.easy_paisa.base_url = "https://..."`.
53
+
54
+ 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.
36
55
 
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"
56
+ ## Take a one-time payment via PayFast redirect
41
57
 
42
- # Optional: tune timeouts & retries
58
+ ### 1. Build the redirect
43
59
 
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] }
60
+ ```ruby
61
+ class Subscription::PaymentsController < ApplicationController
62
+ def create
63
+ @redirect = PaygatePk::PayFast::Redirect.build(
64
+ basket_id: "sp-#{payment.id}",
65
+ amount: 1500, # rupees
66
+ description: "Pro plan — monthly",
67
+ customer: {
68
+ mobile: current_user.mobile, # real 03xx mobile (mandatory)
69
+ email: current_user.email,
70
+ name: current_user.name # optional
71
+ },
72
+ success_url: success_subscription_payments_url,
73
+ failure_url: failed_subscription_payments_url,
74
+ checkout_url: webhooks_pay_fast_url, # optional backend IPN ping
75
+ recurring: false
76
+ )
77
+ end
46
78
  end
47
79
  ```
48
80
 
49
- ## QuickStart
81
+ ### 2. Render the auto-submitting form
50
82
 
51
- # 1) Get Access Token (PayFast)
83
+ ```erb
84
+ <%# app/views/subscription/payments/create.html.erb %>
85
+ <%= paygate_pk_redirect_form(@redirect, autosubmit: true) %>
86
+ ```
52
87
 
53
- ```ruby
88
+ 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`).
54
89
 
55
- client = PaygatePk::Providers::PayFast::Auth.new
90
+ ### 3. Verify the return / IPN
56
91
 
57
- token_obj = auth.get_access_token(
58
- basket_id: "B-1001",
59
- amount: 1500
92
+ ```ruby
93
+ class Subscription::PaymentsController < ApplicationController
94
+ def success
95
+ event = PaygatePk::PayFast::Callback.verify!(request.parameters)
96
+ if event.approved?
97
+ payment = SubscriptionPayment.find_by(basket_id: event.basket_id)
98
+ payment&.mark_completed!(transaction_id: event.transaction_id, amount: event.amount)
99
+ redirect_to dashboard_path, notice: "Payment received."
100
+ else
101
+ redirect_to dashboard_path, alert: "Payment failed: #{event.message}"
102
+ end
103
+ rescue PaygatePk::SignatureError
104
+ head :bad_request
105
+ end
106
+ end
107
+ ```
60
108
 
61
- # currency: "PKR", # optional; defaults to PaygatePk.config.default_currency
109
+ 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).
62
110
 
63
- # endpoint: "/Ecommerce/api/Transaction/GetAccessToken" # optional override
111
+ ## All redirect options
64
112
 
113
+ ```ruby
114
+ PaygatePk::PayFast::Redirect.build(
115
+ basket_id: "B-1001",
116
+ amount: 1500,
117
+ description: "Order #1001",
118
+ customer: { mobile: "03001234567", email: "buyer@x.com", name: "Talha" },
119
+ success_url: "https://app/success",
120
+ failure_url: "https://app/failure",
121
+
122
+ # — optional —
123
+ currency: "PKR", # defaults to config.default_currency
124
+ order_date: Date.today, # Date / Time / String, coerced to YYYY-MM-DD
125
+ checkout_url: "https://app/ipn",
126
+ store_id: "102-ABC", # overrides config.pay_fast.store_id
127
+ recurring: false,
128
+ tran_type: "ECOMM_PURCHASE", # overrides config.pay_fast.tran_type
129
+ processing_type: "HYBRID_TOKEN",
130
+ instrument_token: "tok-from-saved-card",
131
+ transaction_instrument: 3, # 1=bank, 2=UnionPay, 3=card, 4=wallet
132
+
133
+ items: [
134
+ { sku: "SKU-1", name: "Widget", price: 100, qty: 2 },
135
+ { sku: "SKU-2", name: "Gizmo", price: 50, qty: 1 }
136
+ ],
137
+
138
+ shipping: {
139
+ name: "Talha", address_1: "House 9", address_2: "St 4",
140
+ state: "Punjab", city: "Lahore", postal_code: "54000", method: "Courier"
141
+ },
142
+ billing: { name: "Talha", city: "Lahore", address_1: "House 9" },
143
+
144
+ country: "PK",
145
+ customer_ip: request.remote_ip,
146
+ merchant_customer_id: current_user.id.to_s,
147
+ merchant_user_agent: request.user_agent,
148
+
149
+ extra_fields: { "CUSTOM_X" => "value" } # forward-compatible passthrough
65
150
  )
151
+ ```
152
+
153
+ ## Take a payment via Easypaisa (REST)
154
+
155
+ Easypaisa offers two flavours; pick per use case:
66
156
 
67
- puts token_obj.token # => "ACCESS_TOKEN_STRING"
157
+ - **Mobile Account (MA)** — customer authorises in their Easypaisa app via OTP push; merchant gets paid immediately. No redirect, no voucher.
158
+ - **OTC (Over-the-Counter)** — merchant issues a payment token; customer pays cash at any Easypaisa shop within the expiry window.
68
159
 
160
+ ### Mobile Account
161
+
162
+ ```ruby
163
+ result = PaygatePk::EasyPaisa::MobileAccount.charge(
164
+ order_id: "lawzo-#{payment.id}",
165
+ amount: 1500,
166
+ mobile_account_no: "03001234567",
167
+ email: "client@example.com",
168
+ optional: { "1" => "tenant-42" } # optional1..5 passthrough
169
+ )
170
+
171
+ if result.success?
172
+ payment.update!(external_transaction_id: result.transaction_id, status: :pending)
173
+ # customer now sees the OTP prompt on their Easypaisa app
174
+ else
175
+ flash[:alert] = "Easypaisa: #{result.response_message} (code #{result.response_code})"
176
+ end
69
177
  ```
70
178
 
71
- # 2) Verify IPN
179
+ ### OTC voucher
72
180
 
73
181
  ```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)
182
+ voucher = PaygatePk::EasyPaisa::OTC.create(
183
+ order_id: "lawzo-#{payment.id}",
184
+ amount: 1500,
185
+ msisdn: "03001234567",
186
+ email: "client@example.com",
187
+ token_expiry: 7.days.from_now # Date / Time / DateTime / pre-formatted String
188
+ )
189
+ voucher.payment_token # => "40933012" print or show this
190
+ voucher.expires_at # => Time
191
+ ```
88
192
 
193
+ ```erb
194
+ <%# Rails: one-line printable voucher %>
195
+ <%= paygate_pk_otc_voucher(@voucher) %>
89
196
  ```
90
197
 
91
- # Error handling
198
+ ### Inquiry (poll status)
199
+
200
+ Use this after issuing an OTC voucher (or for any MA transaction you didn't catch via IPN):
201
+
202
+ ```ruby
203
+ status = PaygatePk::EasyPaisa::Inquiry.fetch(
204
+ order_id: "lawzo-#{payment.id}",
205
+ account_num: PaygatePk.config.easy_paisa.account_num # falls back to config
206
+ )
207
+
208
+ case status.transaction_status
209
+ when "PAID" then payment.mark_completed!(amount: status.transaction_amount)
210
+ when "FAILED", "EXPIRED", "BLOCKED", "REVERSED" then payment.mark_failed!(reason: status.transaction_status)
211
+ when "PENDING" then # customer hasn't visited a shop yet -- check again later
212
+ end
213
+ ```
92
214
 
93
- All errors inherit from PaygatePk::Error:
215
+ Predicates available on the result: `paid?`, `failed?`, `pending?`, `expired?`, `blocked?`, `reversed?`, `successful_lookup?`.
216
+
217
+ > **Note on IPN.** Easypaisa supports merchant-portal-configured IPN callbacks, but the wire spec isn't published in the REST guide. Until 1.2 ships `EasyPaisa::Callback.verify!`, poll `Inquiry.fetch` from a background job for OTC and on-demand for MA.
218
+
219
+ ## Contracts
220
+
221
+ ### `Contracts::RedirectRequest`
222
+
223
+ What `Redirect.build` returns, what the view helper consumes.
224
+
225
+ | Field | Type | Notes |
226
+ |---|---|---|
227
+ | `provider` | `Symbol` | `:pay_fast` |
228
+ | `action_url` | `String` | Where the browser POSTs |
229
+ | `http_method` | `Symbol` | `:post` |
230
+ | `fields` | `Hash<String,String>` | Every PayFast form field |
231
+ | `basket_id` | `String` | Echo |
232
+ | `amount` | `String` | Echo |
233
+ | `token` | `String` | The access token |
234
+ | `raw` | `Hash` | Raw token-API response |
235
+
236
+ ### `Contracts::CallbackEvent`
237
+
238
+ What `Callback.verify!` returns.
239
+
240
+ | Field | Type | Notes |
241
+ |---|---|---|
242
+ | `provider` | `Symbol` | `:pay_fast` |
243
+ | `transaction_id` | `String?` | |
244
+ | `basket_id` | `String` | |
245
+ | `order_date` | `String` | `YYYY-MM-DD` |
246
+ | `approved` / `approved?` | `Boolean` | `true` iff `err_code == "000"` |
247
+ | `code` | `String` | PayFast `err_code` |
248
+ | `message` | `String` | PayFast `err_msg` |
249
+ | `amount` | `String` | `transaction_amount` |
250
+ | `merchant_amount` | `String` | |
251
+ | `discounted_amount` | `String` | |
252
+ | `currency` | `String` | |
253
+ | `payment_method` | `String` | "card", "account", "wallet" |
254
+ | `instrument_token` | `String?` | |
255
+ | `recurring` | `Boolean` | |
256
+ | `raw` | `Hash` | Original params, unmodified |
257
+
258
+ ### `Contracts::ChargeResult` *(Easypaisa MA / OTC)*
259
+
260
+ | Field | Type | Notes |
261
+ |---|---|---|
262
+ | `provider` | `Symbol` | `:easy_paisa` |
263
+ | `basket_id` | `String` | Easypaisa `orderId` echo |
264
+ | `transaction_id` | `String?` | Ericsson EWP id (MA only) |
265
+ | `payment_token` | `String?` | Cash-redemption token (OTC only) |
266
+ | `payment_mode` | `Symbol` | `:mobile_account` or `:otc` |
267
+ | `expires_at` | `Time?` | OTC token expiry |
268
+ | `transacted_at` | `Time?` | |
269
+ | `amount` | `String` | |
270
+ | `currency` | `String` | |
271
+ | `customer` | `Hash` | Echo of submitted customer info |
272
+ | `response_code` | `String` | `"0000"` on success |
273
+ | `response_message` | `String` | `responseDesc` |
274
+ | `success_code` | `String` | `"0000"` — provider's success literal |
275
+ | `raw` | `Hash` | Full provider response |
276
+
277
+ Predicates: `success?`, `failed?`, `otc?`, `mobile_account?`.
278
+
279
+ ### `Contracts::InquiryResult` *(Easypaisa Inquire Transaction)*
280
+
281
+ | Field | Type | Notes |
282
+ |---|---|---|
283
+ | `provider` | `Symbol` | `:easy_paisa` |
284
+ | `basket_id` | `String` | Echoed `orderId` |
285
+ | `account_num` | `String` | |
286
+ | `store_id` / `store_name` | `String?` | |
287
+ | `payment_token` | `String?` | OTC only |
288
+ | `transaction_status` | `String` | `PAID`/`FAILED`/`PENDING`/`BLOCKED`/`EXPIRED`/`REVERSED` |
289
+ | `transaction_amount` | `String` | |
290
+ | `transaction_date_time` | `String` | As Easypaisa sends, `dd/MM/yyyy hh:mm a` |
291
+ | `payment_token_expiry` | `String?` | OTC only |
292
+ | `msisdn` | `String?` | |
293
+ | `payment_mode` | `String` | `"MA"` / `"OTC"` / `"CC"` |
294
+ | `response_code` / `response_message` | `String` | |
295
+ | `raw` | `Hash` | Full provider response |
296
+
297
+ Predicates: `successful_lookup?`, `paid?`, `failed?`, `pending?`, `expired?`, `blocked?`, `reversed?`.
298
+
299
+ ## Errors
300
+
301
+ All errors inherit from `PaygatePk::Error`:
302
+
303
+ | Class | Raised when |
304
+ |---|---|
305
+ | `ConfigurationError` | Required config is missing (e.g. `merchant_id`) |
306
+ | `ValidationError` | A required method argument is missing or blank (see `#details[:missing]`) |
307
+ | `HTTPError` | Non-2xx response from the gateway (carries `#status`, `#body`) |
308
+ | `TimeoutError` | Network timeout (subclass of `HTTPError`) |
309
+ | `ConnectionError` | DNS / SSL / refused connection (subclass of `HTTPError`) |
310
+ | `AuthError` | Token endpoint returned 2xx but the body had no `ACCESS_TOKEN` |
311
+ | `SignatureError` | Callback `validation_hash` mismatch or required field missing |
312
+ | `CapabilityNotSupported` | Provider asked for a flow it doesn't implement (1.1+) |
313
+ | `ProviderError` | Provider business-rule failure (carries `#code`, `#response`) |
314
+
315
+ ## Non-Rails apps
316
+
317
+ 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:
94
318
 
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.
319
+ ```ruby
320
+ redirect = PaygatePk::PayFast::Redirect.build(...)
321
+ # Render redirect.fields as <input type="hidden"> in your own template.
322
+ ```
101
323
 
102
324
  ## Development
103
325
 
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.
326
+ ```sh
327
+ bin/setup
328
+ bundle exec rake test # 76 examples, 200 assertions, 0 failures
329
+ bundle exec rubocop
330
+ ```
331
+
332
+ ## Roadmap
105
333
 
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).
334
+ - **1.0** PayFast hosted-checkout + callback verification.
335
+ - **1.1** ✅ — Easypaisa: Mobile Account, OTC voucher, Inquiry. `paygate_pk_otc_voucher` Rails helper. `c.easy_paisa.*` config block.
336
+ - **1.2** — Easypaisa IPN (`EasyPaisa::Callback.verify!`). PayFast tokenization: bearer-token auth (`/api/token`), saved instruments (`/api/user/instruments`), charge-against-saved-instrument.
337
+ - **1.x+** — Additional Pakistani gateways (JazzCash, HBL, SafePay) under the same `Contracts::RedirectRequest` / `ChargeResult` / `CallbackEvent` shapes so host code stays unchanged.
107
338
 
108
339
  ## Contributing
109
340
 
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).
341
+ Issues and PRs at <https://github.com/qbitechs/paygate_pk>.
111
342
 
112
343
  ## License
113
344
 
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).
345
+ MIT. See [LICENSE.txt](LICENSE.txt).
@@ -0,0 +1,64 @@
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
+ EASYPAISA_TIMESTAMP = "%Y%m%d %H%M%S"
13
+
14
+ # Returns "YYYY-MM-DD" (PayFast's ORDER_DATE format) for Date/Time/DateTime
15
+ # or a String already in that shape. nil-tolerant.
16
+ def to_iso_date(value)
17
+ return nil if value.nil?
18
+ return value if value.is_a?(String) && value.match?(/\A\d{4}-\d{2}-\d{2}\z/)
19
+
20
+ case value
21
+ when Date, Time, DateTime then value.strftime(DATE_ISO)
22
+ when String then Date.parse(value).strftime(DATE_ISO)
23
+ else
24
+ raise ArgumentError, "cannot coerce #{value.inspect} to ISO date"
25
+ end
26
+ end
27
+
28
+ # Render a numeric amount as a non-scientific decimal String. PayFast
29
+ # accepts TXNAMT as a string; 1500 → "1500", 1500.5 → "1500.5".
30
+ def to_amount_string(value)
31
+ return nil if value.nil?
32
+
33
+ case value
34
+ when Float, Rational, BigDecimal then format("%g", value)
35
+ else value.to_s
36
+ end
37
+ end
38
+
39
+ # Returns "yyyymmdd HHmmss" -- Easypaisa's tokenExpiry format
40
+ # (per the REST without RSA Integration Guide, Initiate OTC
41
+ # Transaction request parameters). Accepts Date/Time/DateTime or a
42
+ # String already in the right shape. nil-tolerant.
43
+ def to_easypaisa_timestamp(value)
44
+ return nil if value.nil?
45
+ return value if value.is_a?(String) && value.match?(/\A\d{8} \d{6}\z/)
46
+
47
+ case value
48
+ when Time, DateTime then value.strftime(EASYPAISA_TIMESTAMP)
49
+ when Date then value.to_time.strftime(EASYPAISA_TIMESTAMP)
50
+ when String then DateTime.parse(value).strftime(EASYPAISA_TIMESTAMP)
51
+ else
52
+ raise ArgumentError, "cannot coerce #{value.inspect} to Easypaisa timestamp"
53
+ end
54
+ end
55
+
56
+ def blank?(value)
57
+ value.nil? || (value.respond_to?(:empty?) && value.empty?)
58
+ end
59
+
60
+ def present?(value)
61
+ !blank?(value)
62
+ end
63
+ end
64
+ end
@@ -1,45 +1,133 @@
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
+ #
10
+ # c.pay_fast.environment = Rails.env.production? ? :production : :sandbox
11
+ # c.pay_fast.merchant_id = ENV["PAYFAST_MERCHANT_ID"]
12
+ # c.pay_fast.secured_key = ENV["PAYFAST_SECURED_KEY"]
13
+ # c.pay_fast.merchant_name = "Acme Store"
14
+ #
15
+ # c.easy_paisa.environment = Rails.env.production? ? :production : :sandbox
16
+ # c.easy_paisa.username = ENV["EASYPAISA_USERNAME"]
17
+ # c.easy_paisa.password = ENV["EASYPAISA_PASSWORD"]
18
+ # c.easy_paisa.store_id = ENV["EASYPAISA_STORE_ID"]
19
+ # c.easy_paisa.account_num = ENV["EASYPAISA_ACCOUNT"]
20
+ # end
21
+ #
22
+ # After `configure`, the config object is frozen — mutating it raises.
5
23
  class Config
6
- attr_accessor :logger, :default_currency, :timeouts, :retry, :user_agent
7
- attr_reader :pay_fast
24
+ attr_accessor :default_currency, :timeouts, :retry, :user_agent, :logger
25
+ attr_reader :pay_fast, :easy_paisa
8
26
 
9
27
  def initialize
10
- @logger = nil
11
28
  @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
29
+ @timeouts = { open_timeout: 5, read_timeout: 10 }
30
+ @retry = {
31
+ max: 2, interval: 0.2, backoff_factor: 2.0,
32
+ retry_statuses: [429, 500, 502, 503, 504]
33
+ }
34
+ @user_agent = "paygate_pk/#{PaygatePk::VERSION}"
35
+ @logger = nil
36
+ @pay_fast = PayFastConfig.new
37
+ @easy_paisa = EasyPaisaConfig.new
38
+ @configured = false
18
39
  end
19
40
 
41
+ # Mark as configured and deep-freeze. Called automatically by
42
+ # PaygatePk.configure after the block runs.
20
43
  def freeze!
21
- @frozen = true
44
+ @configured = true
45
+ @pay_fast.freeze
46
+ @easy_paisa.freeze
47
+ freeze
22
48
  self
23
49
  end
24
50
 
25
- def frozen?
26
- @frozen
51
+ def configured?
52
+ @configured
53
+ end
54
+
55
+ # Per-provider config for PayFast.
56
+ #
57
+ # `environment` selects the base URL via PayFast::Endpoints.
58
+ # `base_url` and `api_base_url` can override the env-derived URLs
59
+ # (useful for staging hosts PayFast hands out on request).
60
+ class PayFastConfig
61
+ ENVIRONMENTS = %i[sandbox production].freeze
62
+ DEFAULT_TRAN_TYPE = "ECOMM_PURCHASE"
63
+
64
+ attr_accessor :merchant_id, :secured_key, :merchant_name, :store_id,
65
+ :version_string, :tran_type, :base_url, :api_base_url
66
+ attr_reader :environment
67
+
68
+ def initialize
69
+ @environment = :sandbox
70
+ @merchant_id = nil
71
+ @secured_key = nil
72
+ @merchant_name = nil
73
+ @store_id = nil
74
+ @version_string = nil
75
+ @tran_type = DEFAULT_TRAN_TYPE
76
+ @base_url = nil
77
+ @api_base_url = nil
78
+ end
79
+
80
+ def environment=(env)
81
+ sym = env&.to_sym
82
+ unless ENVIRONMENTS.include?(sym)
83
+ raise ArgumentError,
84
+ "environment must be one of #{ENVIRONMENTS.inspect}, got #{env.inspect}"
85
+ end
86
+ @environment = sym
87
+ end
88
+
89
+ # Resolves the host URL used for redirect form action + token API.
90
+ # Explicit base_url wins; otherwise derived from environment.
91
+ def resolved_base_url
92
+ return base_url if base_url
93
+
94
+ PaygatePk::PayFast::Endpoints.base_url(environment)
95
+ end
27
96
  end
28
97
 
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
98
+ # Per-provider config for Easypaisa (REST without RSA).
99
+ #
100
+ # Required for any call: username + password + store_id. account_num
101
+ # is required for Inquiry only (it's the merchant's EWP Account #
102
+ # found on the Profile page of the Easypaisa Merchant Portal).
103
+ class EasyPaisaConfig
104
+ ENVIRONMENTS = %i[sandbox production].freeze
105
+
106
+ attr_accessor :username, :password, :store_id, :account_num, :base_url
107
+ attr_reader :environment
33
108
 
34
109
  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
110
+ @environment = :sandbox
111
+ @username = nil
112
+ @password = nil
113
+ @store_id = nil
114
+ @account_num = nil
115
+ @base_url = nil
116
+ end
117
+
118
+ def environment=(env)
119
+ sym = env&.to_sym
120
+ unless ENVIRONMENTS.include?(sym)
121
+ raise ArgumentError,
122
+ "environment must be one of #{ENVIRONMENTS.inspect}, got #{env.inspect}"
123
+ end
124
+ @environment = sym
125
+ end
126
+
127
+ def resolved_base_url
128
+ return base_url if base_url
129
+
130
+ PaygatePk::EasyPaisa::Endpoints.base_url(environment)
43
131
  end
44
132
  end
45
133
  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