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
|
@@ -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
|