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
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PaygatePk
4
+ module Contracts
5
+ # Normalised IPN / browser-return notification.
6
+ #
7
+ # Built by PaygatePk::PayFast::Callback.verify!(params). Will also
8
+ # be returned by Easypaisa's callback handler in 1.1, with the same
9
+ # fields populated where applicable. This is the gem's universal
10
+ # contract for "the gateway told us something about a transaction".
11
+ #
12
+ # Numeric-looking fields (amount, merchant_amount, discounted_amount)
13
+ # are surfaced as Strings — exactly as PayFast sent them. The host
14
+ # app converts to BigDecimal/Money on its side.
15
+ CallbackEvent = Struct.new(
16
+ :provider, # Symbol e.g. :pay_fast
17
+ :transaction_id, # String or nil
18
+ :basket_id, # String
19
+ :order_date, # String "YYYY-MM-DD"
20
+ :approved, # Boolean — true if code == provider's success code
21
+ :code, # String — err_code or responseCode
22
+ :message, # String — err_msg or responseDesc
23
+ :amount, # String — transaction_amount
24
+ :merchant_amount, # String
25
+ :discounted_amount, # String
26
+ :currency, # String "PKR" etc.
27
+ :payment_method, # String — PaymentName ("account", "card", "wallet")
28
+ :instrument_token, # String or nil
29
+ :recurring, # Boolean
30
+ :raw, # Hash — original params, unmodified
31
+ keyword_init: true
32
+ ) do
33
+ def approved?
34
+ !!approved
35
+ end
36
+ end
37
+ end
38
+ 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,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PaygatePk
4
+ module Contracts
5
+ # Everything a host application needs to render a browser-side
6
+ # redirect to the gateway's checkout page.
7
+ #
8
+ # Built by PaygatePk::PayFast::Redirect.build(...). Consumed by the
9
+ # Rails view helper paygate_pk_redirect_form (or by hand-rolled
10
+ # HTML in non-Rails apps).
11
+ #
12
+ # redirect.action_url # => "https://ipguat.apps.net.pk/Ecommerce/api/Transaction/PostTransaction"
13
+ # redirect.http_method # => :post
14
+ # redirect.fields # => { "MERCHANT_ID" => "...", "TOKEN" => "...", ... }
15
+ #
16
+ # `fields` keys are the exact PayFast field names (UPPER_SNAKE_CASE)
17
+ # so the host app doesn't need to know the wire format.
18
+ RedirectRequest = Struct.new(
19
+ :provider, # Symbol, e.g. :pay_fast
20
+ :action_url, # String
21
+ :http_method, # Symbol, typically :post
22
+ :fields, # Hash<String,String>
23
+ :basket_id, # String — echoed for convenience
24
+ :amount, # String — echoed for convenience
25
+ :token, # String — the underlying ACCESS_TOKEN
26
+ :raw, # Hash — the raw token-API response
27
+ keyword_init: true
28
+ )
29
+ end
30
+ 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