paygate_pk 1.0.0 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9705a5a01a864f4d8e318e11448540887091d6ef3d7294c1abaeb00df24a4cba
4
- data.tar.gz: d0408482c432003735671608da1a46e71f1bae509c8b7f455d7dcbd1dd99865e
3
+ metadata.gz: dd62d57ff115a50c39bc303dbc2713554f220d64e7bfa262f5973851ccbef217
4
+ data.tar.gz: 9e459c21d4d178b2601c5ccc37b245defce997995e31bf85e82b5f08ceed9758
5
5
  SHA512:
6
- metadata.gz: 95ff08faf44d5d6bb64311e24b02ce077ac0e19c28b33ce322acd647f1728cd3702b5af08f42bf89dd9d80f1378ccff424ad510e60e30c78de264d2b580acebb
7
- data.tar.gz: 43e241ecdaa260a068e5224f0a560845b09a07de9f61cb2cef667796e8611697b50070d55f7d6ddee6a66b431794444ad748bb63128ac498272a86e9cb7a569d
6
+ metadata.gz: 6ad4cb08e2ced98d4daaad9bf4f519615b773dacdca507b9e5eaeee0b03cc9f4cb29eb87e62aee815cfb99c99167e71176c63362d3fa8e20b0d1a82dfe8ac112
7
+ data.tar.gz: fd859b7d8ef82f97e4a34b02dcd3b7b077b6cf5f479069d75194dd2bbd9587beb629cad6bf9e7d8573a0ce4cde4c1e80965d868fcb8489f4eddfb8eb3baf09b5
data/.rubocop.yml CHANGED
@@ -42,6 +42,9 @@ Metrics/ParameterLists:
42
42
  Metrics/ClassLength:
43
43
  Max: 200
44
44
 
45
+ Metrics/ModuleLength:
46
+ Max: 150
47
+
45
48
  # PayFast field names use ADDRESS_1, ADDRESS_2 -- mirror them in the
46
49
  # option hashes so it's obvious how each key maps to the wire format.
47
50
  Naming/VariableNumber:
data/CHANGELOG.md CHANGED
@@ -1,5 +1,92 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.1.0] - 2026-05-12
4
+
5
+ ### Overview
6
+
7
+ Easypaisa lands — three new REST endpoints + a Rails OTC voucher helper.
8
+ Pure addition; nothing in 1.0 changes shape, no breaking changes.
9
+
10
+ ### Public API (new)
11
+
12
+ ```ruby
13
+ # Mobile Account: customer authorises via OTP push to their Easypaisa wallet
14
+ result = PaygatePk::EasyPaisa::MobileAccount.charge(
15
+ order_id: "lawzo-#{payment.id}",
16
+ amount: 1500,
17
+ mobile_account_no: "03001234567",
18
+ email: "client@example.com"
19
+ )
20
+ result.success? # response_code == "0000"
21
+ result.transaction_id # Ericsson EWP ID
22
+
23
+ # OTC voucher: customer pays cash at any Easypaisa shop
24
+ voucher = PaygatePk::EasyPaisa::OTC.create(
25
+ order_id: "lawzo-#{payment.id}",
26
+ amount: 1500,
27
+ msisdn: "03001234567",
28
+ email: "client@example.com",
29
+ token_expiry: 7.days.from_now
30
+ )
31
+ voucher.payment_token # "40933012" -- show this to the customer
32
+ voucher.expires_at # Time
33
+
34
+ # Inquiry: poll status until PAID
35
+ status = PaygatePk::EasyPaisa::Inquiry.fetch(
36
+ order_id: "lawzo-...",
37
+ account_num: PaygatePk.config.easy_paisa.account_num
38
+ )
39
+ status.paid?
40
+ status.pending?
41
+ ```
42
+
43
+ Rails OTC voucher helper:
44
+
45
+ ```erb
46
+ <%= paygate_pk_otc_voucher(@voucher) %>
47
+ ```
48
+
49
+ ### Added
50
+
51
+ - `PaygatePk::EasyPaisa::MobileAccount.charge` — `initiate-ma-transaction`.
52
+ - `PaygatePk::EasyPaisa::OTC.create` — `initiate-otc-transaction`. Accepts
53
+ Date/Time/DateTime/String for `token_expiry`, formats to
54
+ `"yyyymmdd HHmmss"` internally, validates future-dated up-front.
55
+ - `PaygatePk::EasyPaisa::Inquiry.fetch` — `inquire-transaction`. Returns
56
+ `InquiryResult` with `paid?` / `failed?` / `pending?` / `expired?` /
57
+ `blocked?` / `reversed?` predicates covering every documented
58
+ `transactionStatus` value.
59
+ - `PaygatePk::EasyPaisa::Endpoints` — sandbox URL baked in;
60
+ `c.easy_paisa.base_url = "..."` override for production (Easypaisa
61
+ hands the host out at go-live).
62
+ - `Contracts::ChargeResult` — universal value object for any REST-style
63
+ "take a payment" call. Carries `success?`, `failed?`, `otc?`,
64
+ `mobile_account?` predicates.
65
+ - `Contracts::InquiryResult` — universal value object for status lookups.
66
+ - `Config::EasyPaisaConfig` — `environment` / `username` / `password` /
67
+ `store_id` / `account_num` / `base_url` override.
68
+ - `Util::Credentials.basic(user, pass)` — `Base64.strict_encode64`
69
+ helper for Easypaisa's `Credentials` HTTP header.
70
+ - `Coercions.to_easypaisa_timestamp` — formats Date/Time to the
71
+ `"yyyymmdd HHmmss"` format `tokenExpiry` demands.
72
+ - `PaygatePk::Rails::ViewHelpers#paygate_pk_otc_voucher(charge_result)`
73
+ — prints a styled voucher (token, amount, mobile, expiry, order id,
74
+ instructions). Renders a failure card with `response_message` when
75
+ the upstream call returned a non-`0000` `responseCode`, never a fake
76
+ token.
77
+
78
+ ### Deferred to 1.2
79
+
80
+ - `EasyPaisa::Callback.verify!` — Easypaisa's integration guide mentions
81
+ IPN configuration in the merchant portal but does not ship the full
82
+ wire spec. Until that's published, host apps should poll
83
+ `Inquiry.fetch` after issuing an OTC voucher.
84
+
85
+ ### Not changed
86
+
87
+ - Nothing in the PayFast surface; 1.0's contracts and call sites are
88
+ unchanged.
89
+
3
90
  ## [1.0.0] - 2026-05-12
4
91
 
5
92
  ### Overview
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- paygate_pk (1.0.0)
4
+ paygate_pk (1.1.0)
5
5
  faraday (>= 2.7)
6
6
  faraday-retry (>= 2.0)
7
7
  json
data/README.md CHANGED
@@ -3,10 +3,10 @@
3
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 will ship:** Easypaisa REST APIs (Mobile Account, OTC voucher, Inquiry).
7
- **1.2 will ship:** PayFast tokenization / saved-instrument charge.
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
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.
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.
10
10
 
11
11
  ## Requirements
12
12
 
@@ -23,7 +23,7 @@ bundle add paygate_pk
23
23
  Or in a Gemfile:
24
24
 
25
25
  ```ruby
26
- gem "paygate_pk", "~> 1.0"
26
+ gem "paygate_pk", "~> 1.1"
27
27
  ```
28
28
 
29
29
  ## Configure
@@ -33,15 +33,23 @@ gem "paygate_pk", "~> 1.0"
33
33
  PaygatePk.configure do |c|
34
34
  c.default_currency = "PKR"
35
35
 
36
+ # PayFast (hosted checkout / redirection)
36
37
  c.pay_fast.environment = Rails.env.production? ? :production : :sandbox
37
38
  c.pay_fast.merchant_id = Rails.application.credentials.dig(:pay_fast, :merchant_id)
38
39
  c.pay_fast.secured_key = Rails.application.credentials.dig(:pay_fast, :secured_key)
39
40
  c.pay_fast.merchant_name = "Acme Store"
40
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
41
49
  end
42
50
  ```
43
51
 
44
- Sandbox URL is built in. If PayFast hands you a bespoke staging or production host, override with `c.pay_fast.base_url = "https://..."`.
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://..."`.
45
53
 
46
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.
47
55
 
@@ -142,6 +150,72 @@ PaygatePk::PayFast::Redirect.build(
142
150
  )
143
151
  ```
144
152
 
153
+ ## Take a payment via Easypaisa (REST)
154
+
155
+ Easypaisa offers two flavours; pick per use case:
156
+
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.
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
177
+ ```
178
+
179
+ ### OTC voucher
180
+
181
+ ```ruby
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
+ ```
192
+
193
+ ```erb
194
+ <%# Rails: one-line printable voucher %>
195
+ <%= paygate_pk_otc_voucher(@voucher) %>
196
+ ```
197
+
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
+ ```
214
+
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
+
145
219
  ## Contracts
146
220
 
147
221
  ### `Contracts::RedirectRequest`
@@ -181,6 +255,47 @@ What `Callback.verify!` returns.
181
255
  | `recurring` | `Boolean` | |
182
256
  | `raw` | `Hash` | Original params, unmodified |
183
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
+
184
299
  ## Errors
185
300
 
186
301
  All errors inherit from `PaygatePk::Error`:
@@ -216,9 +331,10 @@ bundle exec rubocop
216
331
 
217
332
  ## Roadmap
218
333
 
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.
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.
222
338
 
223
339
  ## Contributing
224
340
 
@@ -8,7 +8,8 @@ module PaygatePk
8
8
  module Coercions
9
9
  module_function
10
10
 
11
- DATE_ISO = "%Y-%m-%d"
11
+ DATE_ISO = "%Y-%m-%d"
12
+ EASYPAISA_TIMESTAMP = "%Y%m%d %H%M%S"
12
13
 
13
14
  # Returns "YYYY-MM-DD" (PayFast's ORDER_DATE format) for Date/Time/DateTime
14
15
  # or a String already in that shape. nil-tolerant.
@@ -35,6 +36,23 @@ module PaygatePk
35
36
  end
36
37
  end
37
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
+
38
56
  def blank?(value)
39
57
  value.nil? || (value.respond_to?(:empty?) && value.empty?)
40
58
  end
@@ -6,17 +6,23 @@ module PaygatePk
6
6
  # Typical Rails usage:
7
7
  # PaygatePk.configure do |c|
8
8
  # c.default_currency = "PKR"
9
+ #
9
10
  # c.pay_fast.environment = Rails.env.production? ? :production : :sandbox
10
11
  # c.pay_fast.merchant_id = ENV["PAYFAST_MERCHANT_ID"]
11
12
  # c.pay_fast.secured_key = ENV["PAYFAST_SECURED_KEY"]
12
13
  # c.pay_fast.merchant_name = "Acme Store"
13
- # c.pay_fast.store_id = ENV["PAYFAST_STORE_ID"]
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"]
14
20
  # end
15
21
  #
16
22
  # After `configure`, the config object is frozen — mutating it raises.
17
23
  class Config
18
24
  attr_accessor :default_currency, :timeouts, :retry, :user_agent, :logger
19
- attr_reader :pay_fast
25
+ attr_reader :pay_fast, :easy_paisa
20
26
 
21
27
  def initialize
22
28
  @default_currency = "PKR"
@@ -28,6 +34,7 @@ module PaygatePk
28
34
  @user_agent = "paygate_pk/#{PaygatePk::VERSION}"
29
35
  @logger = nil
30
36
  @pay_fast = PayFastConfig.new
37
+ @easy_paisa = EasyPaisaConfig.new
31
38
  @configured = false
32
39
  end
33
40
 
@@ -36,6 +43,7 @@ module PaygatePk
36
43
  def freeze!
37
44
  @configured = true
38
45
  @pay_fast.freeze
46
+ @easy_paisa.freeze
39
47
  freeze
40
48
  self
41
49
  end
@@ -86,5 +94,41 @@ module PaygatePk
86
94
  PaygatePk::PayFast::Endpoints.base_url(environment)
87
95
  end
88
96
  end
97
+
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
108
+
109
+ def initialize
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)
131
+ end
132
+ end
89
133
  end
90
134
  end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PaygatePk
4
+ module Contracts
5
+ # Returned by every "create a charge / take a payment" call against a
6
+ # REST-style provider (Easypaisa MA + OTC in 1.1; future providers
7
+ # to follow). Universal shape so host apps can branch on
8
+ # payment_mode rather than provider-specific response classes.
9
+ #
10
+ # OTC fills payment_token and expires_at (the customer takes that
11
+ # token to an Easypaisa shop). MA fills transaction_id (the
12
+ # gateway's Ericsson EWP id) and leaves payment_token nil.
13
+ #
14
+ # response_code/response_message are the upstream provider's raw
15
+ # status code and human-readable reason. Compare against
16
+ # success_code for the success predicate -- Easypaisa says "0000",
17
+ # but other providers may say "000" or "OK".
18
+ ChargeResult = Struct.new(
19
+ :provider, # Symbol e.g. :easy_paisa
20
+ :basket_id, # String -- the merchant's reference (Easypaisa orderId)
21
+ :transaction_id, # String or nil (Easypaisa Ericsson EWP id for MA)
22
+ :payment_token, # String or nil (OTC only)
23
+ :payment_mode, # Symbol :mobile_account | :otc
24
+ :expires_at, # Time or nil (OTC only)
25
+ :transacted_at, # Time or nil
26
+ :amount, # String -- echo of input amount
27
+ :currency, # String
28
+ :customer, # Hash echo of customer info actually sent
29
+ :response_code, # String e.g. "0000"
30
+ :response_message, # String e.g. "SUCCESS"
31
+ :success_code, # String -- provider's success literal
32
+ :raw, # Hash -- full provider response
33
+ keyword_init: true
34
+ ) do
35
+ def success?
36
+ response_code == success_code
37
+ end
38
+
39
+ def failed?
40
+ !success?
41
+ end
42
+
43
+ def otc?
44
+ payment_mode == :otc
45
+ end
46
+
47
+ def mobile_account?
48
+ payment_mode == :mobile_account
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PaygatePk
4
+ module Contracts
5
+ # Returned by status-inquiry calls (Easypaisa Inquire Transaction in
6
+ # 1.1; future providers to follow). Captures the canonical
7
+ # transaction status alongside everything the upstream API hands
8
+ # back so the host app can rebuild its mental model of the
9
+ # transaction without making additional calls.
10
+ InquiryResult = Struct.new(
11
+ :provider, # Symbol :easy_paisa
12
+ :basket_id, # String Easypaisa orderId
13
+ :account_num, # String Merchant EWP account number
14
+ :store_id, # String
15
+ :store_name, # String
16
+ :payment_token, # String or nil OTC only
17
+ :transaction_status, # String "PAID" | "FAILED" | "PENDING" | "BLOCKED" | "EXPIRED" | "REVERSED"
18
+ :transaction_amount, # String
19
+ :transaction_date_time, # String "dd/MM/yyyy hh:mm a" as Easypaisa sends
20
+ :payment_token_expiry, # String or nil OTC only
21
+ :msisdn, # String
22
+ :payment_mode, # String "MA" | "OTC" | "CC"
23
+ :response_code, # String "0000" on a successful lookup
24
+ :response_message, # String
25
+ :success_code, # String provider's success literal
26
+ :raw, # Hash full provider response
27
+ keyword_init: true
28
+ ) do
29
+ def successful_lookup?
30
+ response_code == success_code
31
+ end
32
+
33
+ def paid?
34
+ transaction_status == "PAID"
35
+ end
36
+
37
+ def failed?
38
+ transaction_status == "FAILED"
39
+ end
40
+
41
+ def pending?
42
+ transaction_status == "PENDING"
43
+ end
44
+
45
+ def expired?
46
+ transaction_status == "EXPIRED"
47
+ end
48
+
49
+ def blocked?
50
+ transaction_status == "BLOCKED"
51
+ end
52
+
53
+ def reversed?
54
+ transaction_status == "REVERSED"
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PaygatePk
4
+ module EasyPaisa
5
+ # Internal shared HTTP wrapper for the three Easypaisa REST endpoints.
6
+ # Not advertised on the public surface; MobileAccount / OTC /
7
+ # Inquiry instantiate it directly and consume the parsed response.
8
+ #
9
+ # Responsibilities:
10
+ # - Validate provider config (username + password + store_id) before
11
+ # touching the network; raises ConfigurationError with a list of
12
+ # missing keys.
13
+ # - Build the Credentials header (Base64Strict("user:pass")).
14
+ # - POST a JSON body, returning the decoded Hash.
15
+ # - Convert any non-Hash response (Easypaisa always returns JSON, so
16
+ # this is defensive) into a ProviderError with the raw body
17
+ # attached.
18
+ class Client
19
+ SUCCESS_CODE = "0000"
20
+
21
+ def initialize(config: PaygatePk::EasyPaisa.config, http: nil)
22
+ @config = config
23
+ @http = http
24
+ end
25
+
26
+ # POSTs json to the given path. Returns the decoded Hash. Does NOT
27
+ # interpret responseCode -- the caller decides whether non-0000
28
+ # warrants raising vs. surfacing as a result with success? == false.
29
+ def post(path, json:)
30
+ ensure_config!
31
+ http.post(path, json: json, headers: auth_headers)
32
+ end
33
+
34
+ def success_code
35
+ SUCCESS_CODE
36
+ end
37
+
38
+ private
39
+
40
+ def http
41
+ @http ||= PaygatePk::HTTP::Client.new(
42
+ base_url: @config.resolved_base_url,
43
+ headers: { "Accept" => "application/json" }
44
+ )
45
+ end
46
+
47
+ def auth_headers
48
+ {
49
+ "Credentials" => PaygatePk::Util::Credentials.basic(@config.username, @config.password)
50
+ }
51
+ end
52
+
53
+ def ensure_config!
54
+ missing = []
55
+ missing << :username if Coercions.blank?(@config.username)
56
+ missing << :password if Coercions.blank?(@config.password)
57
+ missing << :store_id if Coercions.blank?(@config.store_id)
58
+ return if missing.empty?
59
+
60
+ raise PaygatePk::ConfigurationError, "Easypaisa config missing: #{missing.join(", ")}"
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PaygatePk
4
+ module EasyPaisa
5
+ # Centralised URL map for Easypaisa REST endpoints. Picks between
6
+ # sandbox and production based on EasyPaisaConfig#environment;
7
+ # callers never hand-type hostnames.
8
+ #
9
+ # The path constants encode the three documented endpoints from the
10
+ # vendor's "REST APIs without RSA Integration Guide".
11
+ module Endpoints
12
+ URLS = {
13
+ sandbox: "https://easypaystg.easypaisa.com.pk",
14
+ # Easypaisa has not published an official production base URL in
15
+ # the public REST guide; merchants typically receive it on
16
+ # go-live. Configure via `c.easy_paisa.base_url = "..."` until
17
+ # then.
18
+ production: "https://easypay.easypaisa.com.pk"
19
+ }.freeze
20
+
21
+ INITIATE_MA_PATH = "/easypay-service/rest/v4/initiate-ma-transaction"
22
+ INITIATE_OTC_PATH = "/easypay-service/rest/v4/initiate-otc-transaction"
23
+ INQUIRE_PATH = "/easypay-service/rest/v4/inquire-transaction"
24
+
25
+ module_function
26
+
27
+ def base_url(env)
28
+ URLS.fetch(env) do
29
+ raise PaygatePk::ConfigurationError, "unknown Easypaisa environment: #{env.inspect}"
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PaygatePk
4
+ module EasyPaisa
5
+ # Inquire Transaction Status — given an orderId we previously
6
+ # submitted (and the merchant EWP account number), Easypaisa
7
+ # returns the current state. Use this to drive your own state
8
+ # machine for OTC payments (where success arrives asynchronously
9
+ # when the customer pays at a shop) or as a poor-man's IPN
10
+ # replacement until the IPN spec is wired.
11
+ #
12
+ # POST {base}/easypay-service/rest/v4/inquire-transaction
13
+ #
14
+ # Usage:
15
+ #
16
+ # status = PaygatePk::EasyPaisa::Inquiry.fetch(
17
+ # order_id: "lawzo-#{payment.id}",
18
+ # account_num: PaygatePk.config.easy_paisa.account_num
19
+ # )
20
+ # status.paid? # transactionStatus == "PAID"
21
+ # status.pending? # transactionStatus == "PENDING"
22
+ class Inquiry
23
+ def self.fetch(**kwargs)
24
+ new.fetch(**kwargs)
25
+ end
26
+
27
+ def initialize(config: PaygatePk::EasyPaisa.config, client: nil)
28
+ @config = config
29
+ @client = client
30
+ end
31
+
32
+ def fetch(order_id:, account_num: nil)
33
+ account_num ||= @config.account_num
34
+ ensure_args!(order_id: order_id, account_num: account_num)
35
+
36
+ body = {
37
+ "orderId" => order_id.to_s,
38
+ "storeId" => @config.store_id.to_s,
39
+ "accountNum" => account_num.to_s
40
+ }
41
+
42
+ resp = client.post(Endpoints::INQUIRE_PATH, json: body)
43
+ build_result(order_id, account_num, resp)
44
+ end
45
+
46
+ private
47
+
48
+ def client
49
+ @client ||= Client.new(config: @config)
50
+ end
51
+
52
+ def ensure_args!(order_id:, account_num:)
53
+ missing = []
54
+ missing << :order_id if Coercions.blank?(order_id)
55
+ missing << :account_num if Coercions.blank?(account_num)
56
+ return if missing.empty?
57
+
58
+ raise PaygatePk::ValidationError.new(
59
+ "missing required args: #{missing.join(", ")}",
60
+ details: { missing: missing }
61
+ )
62
+ end
63
+
64
+ def build_result(order_id, account_num, resp)
65
+ resp = {} unless resp.is_a?(Hash)
66
+ Contracts::InquiryResult.new(
67
+ provider: :easy_paisa,
68
+ basket_id: order_id.to_s,
69
+ account_num: account_num.to_s,
70
+ store_id: resp["storeId"]&.to_s,
71
+ store_name: resp["storeName"],
72
+ payment_token: resp["paymentToken"],
73
+ transaction_status: resp["transactionStatus"],
74
+ transaction_amount: resp["transactionAmount"]&.to_s,
75
+ transaction_date_time: resp["transactionDateTime"],
76
+ payment_token_expiry: resp["paymentTokenExpiryDateTime"],
77
+ msisdn: resp["msisdn"],
78
+ payment_mode: resp["paymentMode"],
79
+ response_code: resp["responseCode"],
80
+ response_message: resp["responseDesc"],
81
+ success_code: client.success_code,
82
+ raw: resp
83
+ )
84
+ end
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PaygatePk
4
+ module EasyPaisa
5
+ # Initiate MA Transaction — pushes an OTP confirmation to the
6
+ # customer's Easypaisa wallet so they can authorise payment without
7
+ # leaving the merchant site.
8
+ #
9
+ # POST {base}/easypay-service/rest/v4/initiate-ma-transaction
10
+ #
11
+ # Usage:
12
+ #
13
+ # result = PaygatePk::EasyPaisa::MobileAccount.charge(
14
+ # order_id: "lawzo-#{payment.id}",
15
+ # amount: 1500,
16
+ # mobile_account_no: "03001234567",
17
+ # email: "client@example.com"
18
+ # )
19
+ # result.success? # => true if responseCode == "0000"
20
+ # result.transaction_id # => Ericsson EWP ID (string)
21
+ class MobileAccount
22
+ TRANSACTION_TYPE = "MA"
23
+
24
+ def self.charge(**kwargs)
25
+ new.charge(**kwargs)
26
+ end
27
+
28
+ def initialize(config: PaygatePk::EasyPaisa.config, client: nil)
29
+ @config = config
30
+ @client = client
31
+ end
32
+
33
+ def charge(order_id:, amount:, mobile_account_no:, email:, optional: {})
34
+ ensure_args!(order_id: order_id, amount: amount,
35
+ mobile_account_no: mobile_account_no, email: email)
36
+
37
+ body = build_body(order_id: order_id, amount: amount,
38
+ mobile_account_no: mobile_account_no, email: email,
39
+ optional: optional)
40
+
41
+ resp = client.post(Endpoints::INITIATE_MA_PATH, json: body)
42
+ build_result(order_id: order_id, amount: amount,
43
+ mobile_account_no: mobile_account_no, email: email,
44
+ resp: resp)
45
+ end
46
+
47
+ private
48
+
49
+ def client
50
+ @client ||= Client.new(config: @config)
51
+ end
52
+
53
+ def ensure_args!(order_id:, amount:, mobile_account_no:, email:)
54
+ missing = []
55
+ missing << :order_id if Coercions.blank?(order_id)
56
+ missing << :amount if amount.nil?
57
+ missing << :mobile_account_no if Coercions.blank?(mobile_account_no)
58
+ missing << :email if Coercions.blank?(email)
59
+ return if missing.empty?
60
+
61
+ raise PaygatePk::ValidationError.new(
62
+ "missing required args: #{missing.join(", ")}",
63
+ details: { missing: missing }
64
+ )
65
+ end
66
+
67
+ def build_body(order_id:, amount:, mobile_account_no:, email:, optional:)
68
+ body = {
69
+ "orderId" => order_id.to_s,
70
+ "storeId" => @config.store_id.to_s,
71
+ "transactionAmount" => Coercions.to_amount_string(amount),
72
+ "transactionType" => TRANSACTION_TYPE,
73
+ "mobileAccountNo" => mobile_account_no.to_s,
74
+ "emailAddress" => email.to_s
75
+ }
76
+ add_optionals!(body, optional)
77
+ body
78
+ end
79
+
80
+ def add_optionals!(body, optional)
81
+ return unless optional.is_a?(Hash)
82
+
83
+ optional.each do |key, value|
84
+ next if Coercions.blank?(value)
85
+ next unless ("1".."5").cover?(key.to_s)
86
+
87
+ body["optional#{key}"] = value.to_s
88
+ end
89
+ end
90
+
91
+ def build_result(order_id:, amount:, mobile_account_no:, email:, resp:)
92
+ resp = {} unless resp.is_a?(Hash)
93
+ Contracts::ChargeResult.new(
94
+ provider: :easy_paisa,
95
+ basket_id: order_id.to_s,
96
+ transaction_id: resp["transactionId"],
97
+ payment_token: nil,
98
+ payment_mode: :mobile_account,
99
+ expires_at: nil,
100
+ transacted_at: parse_easypaisa_time(resp["transactionDateTime"]),
101
+ amount: Coercions.to_amount_string(amount),
102
+ currency: PaygatePk.config.default_currency,
103
+ customer: { mobile_account_no: mobile_account_no.to_s, email: email.to_s },
104
+ response_code: resp["responseCode"],
105
+ response_message: resp["responseDesc"],
106
+ success_code: client.success_code,
107
+ raw: resp
108
+ )
109
+ end
110
+
111
+ # Easypaisa hands back "dd/MM/yyyy hh:mm a" (e.g. "11/08/2018 11:30 PM").
112
+ # Best-effort parse -- returns the raw string if it's in some
113
+ # unexpected shape rather than blowing up the call site.
114
+ def parse_easypaisa_time(value)
115
+ return nil if Coercions.blank?(value)
116
+
117
+ DateTime.strptime(value, "%d/%m/%Y %I:%M %p").to_time
118
+ rescue ArgumentError, TypeError
119
+ value
120
+ end
121
+ end
122
+ end
123
+ end
@@ -0,0 +1,146 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PaygatePk
4
+ module EasyPaisa
5
+ # Initiate OTC Transaction — issues a paymentToken the customer
6
+ # takes to any Easypaisa shop and pays with cash. The merchant gets
7
+ # paid when Easypaisa reconciles the shop deposit (status moves to
8
+ # PAID; poll via Inquiry.fetch or rely on IPN once configured in
9
+ # the merchant portal).
10
+ #
11
+ # POST {base}/easypay-service/rest/v4/initiate-otc-transaction
12
+ #
13
+ # Usage:
14
+ #
15
+ # voucher = PaygatePk::EasyPaisa::OTC.create(
16
+ # order_id: "lawzo-#{payment.id}",
17
+ # amount: 1500,
18
+ # msisdn: "03001234567",
19
+ # email: "client@example.com",
20
+ # token_expiry: 7.days.from_now
21
+ # )
22
+ # voucher.payment_token # => "40933012" (show this to the customer)
23
+ # voucher.expires_at # => Time
24
+ class OTC
25
+ TRANSACTION_TYPE = "OTC"
26
+
27
+ def self.create(**kwargs)
28
+ new.create(**kwargs)
29
+ end
30
+
31
+ def initialize(config: PaygatePk::EasyPaisa.config, client: nil)
32
+ @config = config
33
+ @client = client
34
+ end
35
+
36
+ def create(order_id:, amount:, msisdn:, email:, token_expiry:, optional: {})
37
+ ensure_args!(order_id: order_id, amount: amount,
38
+ msisdn: msisdn, email: email, token_expiry: token_expiry)
39
+
40
+ formatted_expiry = format_expiry(token_expiry)
41
+
42
+ body = build_body(order_id: order_id, amount: amount, msisdn: msisdn,
43
+ email: email, token_expiry: formatted_expiry, optional: optional)
44
+
45
+ resp = client.post(Endpoints::INITIATE_OTC_PATH, json: body)
46
+ build_result(order_id: order_id, amount: amount, msisdn: msisdn,
47
+ email: email, formatted_expiry: formatted_expiry, resp: resp)
48
+ end
49
+
50
+ private
51
+
52
+ def client
53
+ @client ||= Client.new(config: @config)
54
+ end
55
+
56
+ def ensure_args!(order_id:, amount:, msisdn:, email:, token_expiry:)
57
+ missing = []
58
+ missing << :order_id if Coercions.blank?(order_id)
59
+ missing << :amount if amount.nil?
60
+ missing << :msisdn if Coercions.blank?(msisdn)
61
+ missing << :email if Coercions.blank?(email)
62
+ missing << :token_expiry if Coercions.blank?(token_expiry)
63
+ return if missing.empty?
64
+
65
+ raise PaygatePk::ValidationError.new(
66
+ "missing required args: #{missing.join(", ")}",
67
+ details: { missing: missing }
68
+ )
69
+ end
70
+
71
+ def format_expiry(value)
72
+ formatted = Coercions.to_easypaisa_timestamp(value)
73
+ validate_future!(formatted)
74
+ formatted
75
+ end
76
+
77
+ # Easypaisa returns code "0016" when tokenExpiry is in the past.
78
+ # Surface that as a ValidationError up-front so the caller doesn't
79
+ # waste a round-trip.
80
+ def validate_future!(formatted)
81
+ # Parse back to compare; we trust to_easypaisa_timestamp's shape.
82
+ parsed = DateTime.strptime(formatted, Coercions::EASYPAISA_TIMESTAMP).to_time
83
+ return if parsed > Time.now
84
+
85
+ raise PaygatePk::ValidationError.new(
86
+ "token_expiry must be in the future (got #{formatted})",
87
+ details: { token_expiry: formatted }
88
+ )
89
+ end
90
+
91
+ def build_body(order_id:, amount:, msisdn:, email:, token_expiry:, optional:)
92
+ body = {
93
+ "orderId" => order_id.to_s,
94
+ "storeId" => @config.store_id.to_s,
95
+ "transactionAmount" => Coercions.to_amount_string(amount),
96
+ "transactionType" => TRANSACTION_TYPE,
97
+ "msisdn" => msisdn.to_s,
98
+ "emailAddress" => email.to_s,
99
+ "tokenExpiry" => token_expiry
100
+ }
101
+ add_optionals!(body, optional)
102
+ body
103
+ end
104
+
105
+ def add_optionals!(body, optional)
106
+ return unless optional.is_a?(Hash)
107
+
108
+ optional.each do |key, value|
109
+ next if Coercions.blank?(value)
110
+ next unless ("1".."5").cover?(key.to_s)
111
+
112
+ body["optional#{key}"] = value.to_s
113
+ end
114
+ end
115
+
116
+ def build_result(order_id:, amount:, msisdn:, email:, formatted_expiry:, resp:)
117
+ resp = {} unless resp.is_a?(Hash)
118
+ Contracts::ChargeResult.new(
119
+ provider: :easy_paisa,
120
+ basket_id: order_id.to_s,
121
+ transaction_id: nil,
122
+ payment_token: resp["paymentToken"],
123
+ payment_mode: :otc,
124
+ expires_at: parse_easypaisa_time(resp["paymentTokenExpiryDateTime"]) ||
125
+ DateTime.strptime(formatted_expiry, Coercions::EASYPAISA_TIMESTAMP).to_time,
126
+ transacted_at: parse_easypaisa_time(resp["transactionDateTime"]),
127
+ amount: Coercions.to_amount_string(amount),
128
+ currency: PaygatePk.config.default_currency,
129
+ customer: { msisdn: msisdn.to_s, email: email.to_s },
130
+ response_code: resp["responseCode"],
131
+ response_message: resp["responseDesc"],
132
+ success_code: client.success_code,
133
+ raw: resp
134
+ )
135
+ end
136
+
137
+ def parse_easypaisa_time(value)
138
+ return nil if Coercions.blank?(value)
139
+
140
+ DateTime.strptime(value, "%d/%m/%Y %I:%M %p").to_time
141
+ rescue ArgumentError, TypeError
142
+ value
143
+ end
144
+ end
145
+ end
146
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PaygatePk
4
+ # Easypaisa integration (REST APIs without RSA encryption, per the
5
+ # vendor's "REST APIs without RSA Integration Guide").
6
+ #
7
+ # Public API (1.1):
8
+ #
9
+ # PaygatePk::EasyPaisa::MobileAccount.charge(...) # => Contracts::ChargeResult
10
+ # PaygatePk::EasyPaisa::OTC.create(...) # => Contracts::ChargeResult
11
+ # PaygatePk::EasyPaisa::Inquiry.fetch(...) # => Contracts::InquiryResult
12
+ #
13
+ # Callback (IPN) verification is deferred to 1.2 until Easypaisa
14
+ # publishes the full IPN wire spec; for now configure the IPN URL in
15
+ # the Easypaisa Merchant Portal and use Inquiry.fetch to poll status.
16
+ module EasyPaisa
17
+ def self.config
18
+ PaygatePk.config.easy_paisa
19
+ end
20
+ end
21
+ end
@@ -8,6 +8,10 @@ module PaygatePk
8
8
  DEFAULT_FORM_ID = "paygate-pk-redirect-form"
9
9
  SUBMIT_LABEL = "Pay now"
10
10
 
11
+ OTC_VOUCHER_SUCCESS_CLASS = "paygate-pk-otc-voucher rounded-2xl border border-emerald-200 bg-emerald-50 p-6"
12
+ OTC_VOUCHER_FAILURE_CLASS = "paygate-pk-otc-voucher paygate-pk-otc-voucher--failed " \
13
+ "rounded-2xl border border-rose-200 bg-rose-50 p-6"
14
+
11
15
  # Renders the auto-submitting redirect form for any provider that
12
16
  # produces a Contracts::RedirectRequest.
13
17
  #
@@ -29,6 +33,26 @@ module PaygatePk
29
33
  form_html + autosubmit_script(form_id)
30
34
  end
31
35
 
36
+ # Renders a printable Easypaisa OTC voucher from a
37
+ # Contracts::ChargeResult (from PaygatePk::EasyPaisa::OTC.create).
38
+ #
39
+ # <%= paygate_pk_otc_voucher(@voucher) %>
40
+ #
41
+ # Options:
42
+ # html: { class:, ... } — outer container overrides
43
+ # title: heading text (default "Pay at any Easypaisa shop")
44
+ # instructions: array of bullet strings; falls back to a sane
45
+ # default that mentions the expiry time
46
+ def paygate_pk_otc_voucher(result, html: {}, title: "Pay at any Easypaisa shop", instructions: nil)
47
+ return otc_voucher_failure(result, html) unless result.success? && result.payment_token.present?
48
+
49
+ container_attrs = otc_voucher_attrs(html, success: true)
50
+ body = otc_voucher_success_body(result, title: title,
51
+ instructions: instructions || default_otc_instructions(result))
52
+
53
+ content_tag(:div, body, container_attrs)
54
+ end
55
+
32
56
  private
33
57
 
34
58
  def render_form(redirect, form_id, html, autosubmit, submit_label)
@@ -54,6 +78,82 @@ module PaygatePk
54
78
  nonce: respond_to?(:content_security_policy_nonce) ? content_security_policy_nonce : nil
55
79
  )
56
80
  end
81
+
82
+ # ── OTC voucher rendering ─────────────────────────────────────
83
+
84
+ def otc_voucher_attrs(html, success:)
85
+ default_class = success ? OTC_VOUCHER_SUCCESS_CLASS : OTC_VOUCHER_FAILURE_CLASS
86
+ html.merge(class: [default_class, html[:class]].compact.join(" "))
87
+ end
88
+
89
+ def otc_voucher_success_body(result, title:, instructions:)
90
+ safe_join([
91
+ content_tag(:h3, title, class: "text-base font-bold text-slate-900 mb-4"),
92
+ otc_voucher_token_block(result),
93
+ otc_voucher_meta(result),
94
+ otc_voucher_instructions(instructions)
95
+ ])
96
+ end
97
+
98
+ def otc_voucher_token_block(result)
99
+ content_tag(:div, class: "rounded-xl bg-white border border-emerald-200 px-5 py-4 mb-4 text-center") do
100
+ safe_join([
101
+ content_tag(:p, "Payment token",
102
+ class: "text-xs font-semibold uppercase tracking-wide text-slate-500"),
103
+ content_tag(:p, result.payment_token,
104
+ class: "mt-1 font-mono text-2xl font-bold tracking-widest text-slate-900")
105
+ ])
106
+ end
107
+ end
108
+
109
+ def otc_voucher_meta(result)
110
+ rows = []
111
+ rows << ["Amount", "PKR #{result.amount}"]
112
+ rows << ["Mobile", result.customer[:msisdn]] if result.customer[:msisdn].present?
113
+ rows << ["Expires", otc_voucher_format_time(result.expires_at)] if result.expires_at
114
+ rows << ["Order", result.basket_id]
115
+
116
+ content_tag(:dl, class: "grid grid-cols-2 gap-y-2 text-sm mb-4") do
117
+ safe_join(rows.flat_map do |label, value|
118
+ [
119
+ content_tag(:dt, label, class: "text-slate-500"),
120
+ content_tag(:dd, value, class: "text-right font-semibold text-slate-900")
121
+ ]
122
+ end)
123
+ end
124
+ end
125
+
126
+ def otc_voucher_instructions(instructions)
127
+ content_tag(:ul, class: "list-disc pl-5 space-y-1 text-xs text-slate-600") do
128
+ safe_join(instructions.map { |line| content_tag(:li, line) })
129
+ end
130
+ end
131
+
132
+ def default_otc_instructions(result)
133
+ expiry = result.expires_at ? otc_voucher_format_time(result.expires_at) : nil
134
+ [
135
+ "Visit any Easypaisa shop with the token above.",
136
+ "Pay PKR #{result.amount} in cash and quote your token to the agent.",
137
+ expiry ? "Pay before #{expiry} — the token expires after that." : nil
138
+ ].compact
139
+ end
140
+
141
+ def otc_voucher_failure(result, html)
142
+ container_attrs = otc_voucher_attrs(html, success: false)
143
+ message = result.response_message.presence || "Easypaisa did not issue a token for this order."
144
+ body = safe_join([
145
+ content_tag(:h3, "Voucher unavailable", class: "text-base font-bold text-rose-900 mb-2"),
146
+ content_tag(:p, message, class: "text-sm text-rose-700")
147
+ ])
148
+ content_tag(:div, body, container_attrs)
149
+ end
150
+
151
+ def otc_voucher_format_time(value)
152
+ return value if value.is_a?(String)
153
+ return nil unless value.respond_to?(:strftime)
154
+
155
+ value.strftime("%d %b %Y, %I:%M %p")
156
+ end
57
157
  end
58
158
  end
59
159
  end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "base64"
4
+
5
+ module PaygatePk
6
+ module Util
7
+ # Header-credential helpers used by REST providers that authenticate
8
+ # with a static username/password pair.
9
+ #
10
+ # Easypaisa: header key "Credentials", value is Base64Strict of
11
+ # "username:password" -- HTTP-Basic style but WITHOUT the literal
12
+ # "Basic " scheme prefix.
13
+ module Credentials
14
+ module_function
15
+
16
+ # Returns Base64Strict("user:pass"). Raises if either is blank
17
+ # -- empty credentials would silently authenticate as a different
18
+ # principal and produce baffling 401s downstream.
19
+ def basic(username, password)
20
+ raise PaygatePk::ConfigurationError, "username is required" if Coercions.blank?(username)
21
+ raise PaygatePk::ConfigurationError, "password is required" if Coercions.blank?(password)
22
+
23
+ Base64.strict_encode64("#{username}:#{password}")
24
+ end
25
+ end
26
+ end
27
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module PaygatePk
4
- VERSION = "1.0.0"
4
+ VERSION = "1.1.0"
5
5
  end
data/lib/paygate_pk.rb CHANGED
@@ -7,12 +7,15 @@ require_relative "paygate_pk/config"
7
7
 
8
8
  require_relative "paygate_pk/util/security"
9
9
  require_relative "paygate_pk/util/signature/pay_fast"
10
+ require_relative "paygate_pk/util/credentials"
10
11
 
11
12
  require_relative "paygate_pk/http/client"
12
13
 
13
14
  require_relative "paygate_pk/contracts/access_token"
14
15
  require_relative "paygate_pk/contracts/redirect_request"
15
16
  require_relative "paygate_pk/contracts/callback_event"
17
+ require_relative "paygate_pk/contracts/charge_result"
18
+ require_relative "paygate_pk/contracts/inquiry_result"
16
19
 
17
20
  require_relative "paygate_pk/pay_fast"
18
21
  require_relative "paygate_pk/pay_fast/endpoints"
@@ -20,19 +23,36 @@ require_relative "paygate_pk/pay_fast/auth"
20
23
  require_relative "paygate_pk/pay_fast/redirect"
21
24
  require_relative "paygate_pk/pay_fast/callback"
22
25
 
26
+ require_relative "paygate_pk/easy_paisa"
27
+ require_relative "paygate_pk/easy_paisa/endpoints"
28
+ require_relative "paygate_pk/easy_paisa/client"
29
+ require_relative "paygate_pk/easy_paisa/mobile_account"
30
+ require_relative "paygate_pk/easy_paisa/otc"
31
+ require_relative "paygate_pk/easy_paisa/inquiry"
32
+
23
33
  require_relative "paygate_pk/rails/railtie" if defined?(Rails::Railtie)
24
34
 
25
35
  # Unified Ruby/Rails client for Pakistani payment gateways.
26
36
  #
27
37
  # PaygatePk.configure do |c|
38
+ # # PayFast (hosted checkout / redirection)
28
39
  # c.pay_fast.environment = :sandbox
29
40
  # c.pay_fast.merchant_id = ENV["PAYFAST_MERCHANT_ID"]
30
41
  # c.pay_fast.secured_key = ENV["PAYFAST_SECURED_KEY"]
31
42
  # c.pay_fast.merchant_name = "Acme Store"
43
+ #
44
+ # # Easypaisa (REST APIs)
45
+ # c.easy_paisa.environment = :sandbox
46
+ # c.easy_paisa.username = ENV["EASYPAISA_USERNAME"]
47
+ # c.easy_paisa.password = ENV["EASYPAISA_PASSWORD"]
48
+ # c.easy_paisa.store_id = ENV["EASYPAISA_STORE_ID"]
32
49
  # end
33
50
  #
34
51
  # redirect = PaygatePk::PayFast::Redirect.build(...)
35
52
  # event = PaygatePk::PayFast::Callback.verify!(request.parameters)
53
+ # result = PaygatePk::EasyPaisa::MobileAccount.charge(...)
54
+ # voucher = PaygatePk::EasyPaisa::OTC.create(...)
55
+ # status = PaygatePk::EasyPaisa::Inquiry.fetch(...)
36
56
  module PaygatePk
37
57
  class << self
38
58
  def configure
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: paygate_pk
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.0
4
+ version: 1.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Talha Junaid
@@ -173,7 +173,15 @@ files:
173
173
  - lib/paygate_pk/config.rb
174
174
  - lib/paygate_pk/contracts/access_token.rb
175
175
  - lib/paygate_pk/contracts/callback_event.rb
176
+ - lib/paygate_pk/contracts/charge_result.rb
177
+ - lib/paygate_pk/contracts/inquiry_result.rb
176
178
  - lib/paygate_pk/contracts/redirect_request.rb
179
+ - lib/paygate_pk/easy_paisa.rb
180
+ - lib/paygate_pk/easy_paisa/client.rb
181
+ - lib/paygate_pk/easy_paisa/endpoints.rb
182
+ - lib/paygate_pk/easy_paisa/inquiry.rb
183
+ - lib/paygate_pk/easy_paisa/mobile_account.rb
184
+ - lib/paygate_pk/easy_paisa/otc.rb
177
185
  - lib/paygate_pk/errors.rb
178
186
  - lib/paygate_pk/http/client.rb
179
187
  - lib/paygate_pk/pay_fast.rb
@@ -183,6 +191,7 @@ files:
183
191
  - lib/paygate_pk/pay_fast/redirect.rb
184
192
  - lib/paygate_pk/rails/railtie.rb
185
193
  - lib/paygate_pk/rails/view_helpers.rb
194
+ - lib/paygate_pk/util/credentials.rb
186
195
  - lib/paygate_pk/util/security.rb
187
196
  - lib/paygate_pk/util/signature/pay_fast.rb
188
197
  - lib/paygate_pk/version.rb