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.
- checksums.yaml +4 -4
- data/.rubocop.yml +34 -3
- data/CHANGELOG.md +213 -0
- data/Gemfile.lock +46 -5
- data/README.md +291 -64
- data/lib/paygate_pk/coercions.rb +64 -0
- data/lib/paygate_pk/config.rb +113 -25
- data/lib/paygate_pk/contracts/access_token.rb +8 -2
- data/lib/paygate_pk/contracts/callback_event.rb +38 -0
- data/lib/paygate_pk/contracts/charge_result.rb +52 -0
- data/lib/paygate_pk/contracts/inquiry_result.rb +58 -0
- data/lib/paygate_pk/contracts/redirect_request.rb +30 -0
- data/lib/paygate_pk/easy_paisa/client.rb +64 -0
- data/lib/paygate_pk/easy_paisa/endpoints.rb +34 -0
- data/lib/paygate_pk/easy_paisa/inquiry.rb +87 -0
- data/lib/paygate_pk/easy_paisa/mobile_account.rb +123 -0
- data/lib/paygate_pk/easy_paisa/otc.rb +146 -0
- data/lib/paygate_pk/easy_paisa.rb +21 -0
- data/lib/paygate_pk/errors.rb +16 -3
- data/lib/paygate_pk/http/client.rb +84 -71
- data/lib/paygate_pk/pay_fast/auth.rb +79 -0
- data/lib/paygate_pk/pay_fast/callback.rb +92 -0
- data/lib/paygate_pk/pay_fast/endpoints.rb +38 -0
- data/lib/paygate_pk/pay_fast/redirect.rb +227 -0
- data/lib/paygate_pk/pay_fast.rb +19 -0
- data/lib/paygate_pk/rails/railtie.rb +19 -0
- data/lib/paygate_pk/rails/view_helpers.rb +159 -0
- data/lib/paygate_pk/util/credentials.rb +27 -0
- data/lib/paygate_pk/util/signature/pay_fast.rb +25 -0
- data/lib/paygate_pk/version.rb +1 -1
- data/lib/paygate_pk.rb +54 -18
- metadata +34 -32
- data/lib/paygate_pk/contracts/bearer_token.rb +0 -18
- data/lib/paygate_pk/contracts/hosted_checkout.rb +0 -8
- data/lib/paygate_pk/contracts/instrument.rb +0 -10
- data/lib/paygate_pk/contracts/webhook_event.rb +0 -24
- data/lib/paygate_pk/providers/pay_fast/auth.rb +0 -61
- data/lib/paygate_pk/providers/pay_fast/checkout.rb +0 -157
- data/lib/paygate_pk/providers/pay_fast/client.rb +0 -53
- data/lib/paygate_pk/providers/pay_fast/tokenization/instrument.rb +0 -63
- data/lib/paygate_pk/providers/pay_fast/tokenization/token.rb +0 -65
- data/lib/paygate_pk/providers/pay_fast/webhook.rb +0 -74
- data/lib/paygate_pk/util/html.rb +0 -42
- data/lib/paygate_pk/util/signature.rb +0 -18
- data/paygate_pk.gemspec +0 -46
data/README.md
CHANGED
|
@@ -1,118 +1,345 @@
|
|
|
1
1
|
# PaygatePk
|
|
2
2
|
|
|
3
|
-
Unified Ruby
|
|
3
|
+
Unified Ruby/Rails client for Pakistani payment gateways.
|
|
4
4
|
|
|
5
|
-
|
|
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
|
|
11
|
-
-
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
|
|
23
|
+
Or in a Gemfile:
|
|
23
24
|
|
|
24
|
-
|
|
25
|
+
```ruby
|
|
26
|
+
gem "paygate_pk", "~> 1.1"
|
|
27
|
+
```
|
|
25
28
|
|
|
26
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
58
|
+
### 1. Build the redirect
|
|
43
59
|
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
81
|
+
### 2. Render the auto-submitting form
|
|
50
82
|
|
|
51
|
-
|
|
83
|
+
```erb
|
|
84
|
+
<%# app/views/subscription/payments/create.html.erb %>
|
|
85
|
+
<%= paygate_pk_redirect_form(@redirect, autosubmit: true) %>
|
|
86
|
+
```
|
|
52
87
|
|
|
53
|
-
|
|
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
|
-
|
|
90
|
+
### 3. Verify the return / IPN
|
|
56
91
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
179
|
+
### OTC voucher
|
|
72
180
|
|
|
73
181
|
```ruby
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
:
|
|
77
|
-
:
|
|
78
|
-
:
|
|
79
|
-
:
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
341
|
+
Issues and PRs at <https://github.com/qbitechs/paygate_pk>.
|
|
111
342
|
|
|
112
343
|
## License
|
|
113
344
|
|
|
114
|
-
|
|
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
|
data/lib/paygate_pk/config.rb
CHANGED
|
@@ -1,45 +1,133 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module PaygatePk
|
|
4
|
-
# Global configuration
|
|
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 :
|
|
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
|
|
13
|
-
@retry
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
@
|
|
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
|
-
@
|
|
44
|
+
@configured = true
|
|
45
|
+
@pay_fast.freeze
|
|
46
|
+
@easy_paisa.freeze
|
|
47
|
+
freeze
|
|
22
48
|
self
|
|
23
49
|
end
|
|
24
50
|
|
|
25
|
-
def
|
|
26
|
-
@
|
|
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
|
-
#
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
@
|
|
36
|
-
@
|
|
37
|
-
@
|
|
38
|
-
@
|
|
39
|
-
@
|
|
40
|
-
@
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
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
|