spree_vpago 2.4.0.pre.pre3 → 2.4.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 (31) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile.lock +26 -27
  3. data/app/assets/config/spree_vpago_manifest.js +2 -0
  4. data/app/assets/javascripts/vpago/wing_mini_app/login.js +75 -0
  5. data/app/assets/javascripts/vpago/wing_mini_app/web_jsbridge.bundle.js +1507 -0
  6. data/app/controllers/concerns/spree/mini_app_concern.rb +25 -0
  7. data/app/controllers/spree/mini_app/acledas_controller.rb +35 -0
  8. data/app/controllers/spree/mini_app/wings_controller.rb +58 -0
  9. data/app/models/spree/gateway/acleda_mini_app.rb +18 -0
  10. data/app/models/spree/gateway/wing_mini_app.rb +58 -0
  11. data/app/models/vpago/payment_decorator.rb +8 -0
  12. data/app/models/vpago/payment_method_decorator.rb +12 -0
  13. data/app/services/vpago/acleda_mini_app/session/create.rb +121 -0
  14. data/app/services/vpago/payment_finder.rb +11 -0
  15. data/app/services/vpago/wing_mini_app/session/create.rb +55 -0
  16. data/app/views/layouts/wing_mini_app.html.erb +20 -0
  17. data/app/views/spree/admin/payments/source_views/_acleda_mini_app.html.erb +6 -0
  18. data/app/views/spree/admin/payments/source_views/_wing_mini_app.html.erb +6 -0
  19. data/app/views/spree/mini_app/wings/show.html.erb +44 -0
  20. data/app/views/spree/vpago_payments/forms/spree/gateway/_acleda_mini_app.html.erb +94 -0
  21. data/app/views/spree/vpago_payments/forms/spree/gateway/_wing_mini_app.html.erb +88 -0
  22. data/app/views/spree/vpago_payments/processing_scripts/spree/gateway/_wing_mini_app.html.erb +15 -0
  23. data/config/routes.rb +14 -0
  24. data/lib/spree_vpago/engine.rb +2 -0
  25. data/lib/spree_vpago/version.rb +1 -1
  26. data/lib/vpago/acleda_mini_app/checkout.rb +19 -0
  27. data/lib/vpago/acleda_mini_app/session_hash_validator.rb +35 -0
  28. data/lib/vpago/wing_mini_app/checkout.rb +60 -0
  29. data/lib/vpago/wing_mini_app/webhook_verifier.rb +78 -0
  30. data/spree_vpago.gemspec +1 -1
  31. metadata +28 -8
@@ -0,0 +1,25 @@
1
+ module Spree
2
+ # Shared by every mini app controller. What lives here is whatever a mini app needs from the
3
+ # request or gives back in the response, rather than anything one partner does on its own —
4
+ # today the gate its session service runs the caller through, built from the two things only
5
+ # the request knows, and the status a refusal answers with.
6
+ module MiniAppConcern
7
+ extend ActiveSupport::Concern
8
+
9
+ private
10
+
11
+ def session_gate(provider)
12
+ SpreeCmCommissioner::MiniAppProvider::SessionGate.new(
13
+ provider: provider,
14
+ api_key: request.headers['X-Api-Key'],
15
+ ip: request.remote_ip
16
+ )
17
+ end
18
+
19
+ # A cap or a blocked address is not the payload being wrong, so each answers with its
20
+ # own status and the partner can tell them apart.
21
+ def failure_status(result)
22
+ (result.value.is_a?(Hash) && result.value[:status]) || :unprocessable_entity
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,35 @@
1
+ module Spree
2
+ module MiniApp
3
+ # Partner Session Initialization API (ACLEDA Mini App Integration Spec, Step 01).
4
+ # ACLEDA POSTs { phone, first_name, last_name } signed with X-Acleda-Signature, and
5
+ # gets back a session-based miniAppUrl to open in the WebView.
6
+ class AcledasController < Spree::Api::V2::BaseController
7
+ include Spree::MiniAppConcern
8
+
9
+ PROVIDER = 'acleda_bank'.freeze
10
+
11
+ # POST /mini_app/acleda
12
+ def create
13
+ result = ::Vpago::AcledaMiniApp::Session::Create.call(
14
+ phone: session_params[:phone],
15
+ first_name: session_params[:first_name],
16
+ last_name: session_params[:last_name],
17
+ signature: request.headers['X-Acleda-Signature'],
18
+ gate: session_gate(PROVIDER)
19
+ )
20
+
21
+ if result.success?
22
+ render json: { message: 'SUCCESS', miniAppUrl: result.value[:mini_app_url] }
23
+ else
24
+ render json: { message: 'FAILED', error: result.error.to_s }, status: failure_status(result)
25
+ end
26
+ end
27
+
28
+ private
29
+
30
+ def session_params
31
+ params.permit(:phone, :first_name, :last_name)
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,58 @@
1
+ module Spree
2
+ module MiniApp
3
+ # Wing Mini App WebView entry. Wing opens this page cold — no server-to-server
4
+ # pre-step like ACLEDA/Vattanac — so the page itself calls getProfile and posts
5
+ # the signed result to #create.
6
+ class WingsController < ApplicationController
7
+ include Spree::MiniAppConcern
8
+
9
+ layout 'wing_mini_app'
10
+
11
+ PROVIDER = 'wing_bank'.freeze
12
+
13
+ # Both actions carry their own HMAC (ProfileVerifier / WebhookVerifier), and
14
+ # neither has an authenticated session for a CSRF token to protect.
15
+ skip_before_action :verify_authenticity_token, only: %i[create callback]
16
+
17
+ def show; end
18
+
19
+ def create
20
+ result = ::Vpago::WingMiniApp::Session::Create.call(
21
+ profile: profile_params.to_h,
22
+ gate: session_gate(PROVIDER)
23
+ )
24
+
25
+ if result.success?
26
+ render json: { message: 'SUCCESS', miniAppUrl: result.value[:mini_app_url] }
27
+ else
28
+ render json: { message: 'FAILED', error: result.error.to_s }, status: failure_status(result)
29
+ end
30
+ end
31
+
32
+ # Wing POSTs its settlement here. Its own action rather than
33
+ # VpagoPaymentsController#process_payment, which is shaped around browser
34
+ # returns from other gateways — return_params merging, the ABA reviewing-mode
35
+ # branch, the internal_client flag — none of which a server-to-server webhook
36
+ # sends.
37
+ def callback
38
+ payment = ::Vpago::PaymentFinder.new(params.permit!.to_h).find_and_verify
39
+
40
+ return render json: { status: 'not_found' }, status: :not_found if payment.nil?
41
+
42
+ ::Vpago::PaymentProcessorJob.perform_later(payment_number: payment.number) unless payment.order.paid?
43
+
44
+ render json: { status: 'ok' }, status: :ok
45
+ end
46
+
47
+ private
48
+
49
+ def profile_params
50
+ params.require(:profile).permit(
51
+ :id, :appId, :firstName, :lastName, :middleName, :fullName, :sex, :age, :nationality,
52
+ :phone, :email, :city, :country, :address, :occupation, :addrCode, :dobFull, :nidNumber,
53
+ :nidType, :nidExpirationDate, :lang, :appVersion, :osVersion, :secret_key
54
+ )
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,18 @@
1
+ module Spree
2
+ # ACLEDA Mini App payment method.
3
+ #
4
+ # Reuses the ACLEDA V2 integration in deeplink mode: openSessionV2 returns a deeplink the
5
+ # payment page opens as a top-frame navigation, which ACLEDA's own WebView turns into an app
6
+ # launch. Server-side verification reuses AcledaV2's #check_transaction (getTxnStatus).
7
+ class Gateway::AcledaMiniApp < Gateway::AcledaV2
8
+ # override: partial to render in admin / checkout form lookup
9
+ def method_type
10
+ 'acleda_mini_app'
11
+ end
12
+
13
+ # The mini app always pays by deeplink, so admins don't need to set the ACLEDA V2 mode.
14
+ def deeplink?
15
+ true
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,58 @@
1
+ module Spree
2
+ # Wing Mini App payment method (doPayment). Wing pushes settlement to our webhook,
3
+ # where Vpago::PaymentFinder checks the secretKey and the amount/currency match
4
+ # before storing transaction_response — the only place that is verified.
5
+ class Gateway::WingMiniApp < PaymentMethod
6
+ def method_type
7
+ 'wing_mini_app'
8
+ end
9
+
10
+ # Read from Wing's mini app row (WingMiniApp::Config), not a payment method preference:
11
+ # login verifies against the same pair, and a second source could sign payments as a
12
+ # different partner — a mismatch that first surfaces at the webhook, after the debit.
13
+ def app_id
14
+ SpreeCmCommissioner::WingMiniApp::Config.app_id
15
+ end
16
+
17
+ def secret_api_key
18
+ SpreeCmCommissioner::WingMiniApp::Config.secret_api_key
19
+ end
20
+
21
+ def payment_source_class
22
+ Spree::VpagoPaymentSource
23
+ end
24
+
25
+ def auto_capture?
26
+ true
27
+ end
28
+
29
+ # override
30
+ def purchase(_amount, _source, gateway_options = {})
31
+ _, payment_number = gateway_options[:order_id].split('-')
32
+ payment = Spree::Payment.find_by(number: payment_number)
33
+ response = payment&.transaction_response || {}
34
+
35
+ params = { payment_response: response }
36
+
37
+ # A present transaction_response is PaymentFinder having already verified this
38
+ # settlement, so there is nothing left to check.
39
+ if response.present?
40
+ ActiveMerchant::Billing::Response.new(true, 'Wing Mini App: Purchased', params)
41
+ else
42
+ ActiveMerchant::Billing::Response.new(false, 'Wing Mini App: Purchasing Failed', params)
43
+ end
44
+ end
45
+
46
+ # override
47
+ def void(_response_code, _gateway_options)
48
+ ActiveMerchant::Billing::Response.new(true, 'Wing Mini App order has been voided.')
49
+ end
50
+
51
+ # override
52
+ # Wing documents no refund API, so this fails rather than returning a no-op success:
53
+ # refunds stay manual with Wing until they publish one.
54
+ def cancel(_response_code, _payment)
55
+ ActiveMerchant::Billing::Response.new(false, 'Wing Mini App: cancel is not supported — no Wing refund API is documented yet.')
56
+ end
57
+ end
58
+ end
@@ -109,6 +109,14 @@ module Vpago
109
109
  payment_method.type_vattanac_mini_app?
110
110
  end
111
111
 
112
+ def acleda_mini_app_payment?
113
+ payment_method.type_acleda_mini_app?
114
+ end
115
+
116
+ def wing_mini_app_payment?
117
+ payment_method.type_wing_mini_app?
118
+ end
119
+
112
120
  def check_payment?
113
121
  payment_method.type_check?
114
122
  end
@@ -6,9 +6,11 @@ module Vpago
6
6
  TYPE_ACLEDA = 'Spree::Gateway::Acleda'.freeze
7
7
  TYPE_ACLEDA_V2 = 'Spree::Gateway::AcledaV2'.freeze
8
8
  TYPE_ACLEDA_MOBILE = 'Spree::Gateway::AcledaMobile'.freeze
9
+ TYPE_ACLEDA_MINI_APP = 'Spree::Gateway::AcledaMiniApp'.freeze
9
10
  TYPE_TRUE_MONEY = 'Spree::Gateway::TrueMoney'.freeze
10
11
  TYPE_VATTANAC = 'Spree::Gateway::Vattanac'.freeze
11
12
  TYPE_VATTANAC_MINI_APP = 'Spree::Gateway::VattanacMiniApp'.freeze
13
+ TYPE_WING_MINI_APP = 'Spree::Gateway::WingMiniApp'.freeze
12
14
  TYPE_CASH_ON = 'Spree::PaymentMethod::CashOn'.freeze
13
15
 
14
16
  def self.prepended(base)
@@ -24,8 +26,10 @@ module Vpago
24
26
  Spree::PaymentMethod::TYPE_ACLEDA,
25
27
  Spree::PaymentMethod::TYPE_ACLEDA_V2,
26
28
  Spree::PaymentMethod::TYPE_ACLEDA_MOBILE,
29
+ Spree::PaymentMethod::TYPE_ACLEDA_MINI_APP,
27
30
  Spree::PaymentMethod::TYPE_VATTANAC,
28
31
  Spree::PaymentMethod::TYPE_VATTANAC_MINI_APP,
32
+ Spree::PaymentMethod::TYPE_WING_MINI_APP,
29
33
  Spree::PaymentMethod::TYPE_TRUE_MONEY,
30
34
  Spree::PaymentMethod::TYPE_CASH_ON
31
35
  ]
@@ -99,6 +103,10 @@ module Vpago
99
103
  type == Spree::PaymentMethod::TYPE_ACLEDA_V2
100
104
  end
101
105
 
106
+ def type_acleda_mini_app?
107
+ type == Spree::PaymentMethod::TYPE_ACLEDA_MINI_APP
108
+ end
109
+
102
110
  def type_payway?
103
111
  type == Spree::PaymentMethod::TYPE_PAYWAY
104
112
  end
@@ -119,6 +127,10 @@ module Vpago
119
127
  type == Spree::PaymentMethod::TYPE_VATTANAC_MINI_APP
120
128
  end
121
129
 
130
+ def type_wing_mini_app?
131
+ type == Spree::PaymentMethod::TYPE_WING_MINI_APP
132
+ end
133
+
122
134
  def type_true_money?
123
135
  type == Spree::PaymentMethod::TYPE_TRUE_MONEY
124
136
  end
@@ -0,0 +1,121 @@
1
+ module Vpago
2
+ module AcledaMiniApp
3
+ module Session
4
+ # Partner Session Initialization (ACLEDA Mini App Integration Spec, Step 01).
5
+ #
6
+ # ACLEDA POSTs { phone, first_name, last_name } with an X-Acleda-Signature HMAC
7
+ # (see SessionHashValidator), checked before we touch the user so nobody can mint
8
+ # a session for an arbitrary phone. We then find or create the customer, tag them
9
+ # with the acleda_bank identity, and hand back a miniAppUrl carrying a session JWT.
10
+ class Create
11
+ prepend Spree::ServiceModule::Base
12
+
13
+ # `signature`, not `hash`: an attr_reader named `hash` overrides Object#hash and
14
+ # silently breaks this object as a Hash key.
15
+ #
16
+ # `gate` answers who is calling, from where and how often. The controller builds it:
17
+ # the key header and the address exist only on the request.
18
+ def call(gate:, phone: nil, first_name: nil, last_name: nil, signature: nil)
19
+ return failure(nil, 'phone is required') if phone.blank?
20
+
21
+ @phone = phone
22
+ @first_name = first_name
23
+ @last_name = last_name
24
+ @signature = signature
25
+ @gate = gate
26
+
27
+ return failure({ status: :unauthorized }, 'missing or unknown api key') unless gate.api_key_accepted?
28
+ return failure({ status: :forbidden }, 'address not allowed') unless gate.address_allowed?
29
+ return failure(nil, 'invalid signature') unless valid_signature?
30
+ return failure(nil, 'phone is invalid') if intel_phone.blank?
31
+ return failure({ status: :too_many_requests }, 'too many requests') if gate.session_exceeded?(phone: intel_phone)
32
+
33
+ user = find_or_create_user
34
+ return failure(user, 'user creation failed') if user.nil? || !user.persisted?
35
+
36
+ success(mini_app_url: mini_app_url(user))
37
+ end
38
+
39
+ private
40
+
41
+ attr_reader :phone, :first_name, :last_name, :signature, :gate
42
+
43
+ # Only asked where this call would mint an account — the damage a leaked key does.
44
+ # Returning customers never spend that quota.
45
+ def new_user_throttled?
46
+ !acleda_identity.persisted? && find_existing_user.nil? && gate.new_user_exceeded?(phone: intel_phone)
47
+ end
48
+
49
+ def valid_signature?
50
+ ::Vpago::AcledaMiniApp::SessionHashValidator.new(
51
+ phone: phone, first_name: first_name, last_name: last_name, hash: signature,
52
+ secret_key: gate.mini_app&.preferred_secret_key
53
+ ).call
54
+ end
55
+
56
+ def find_or_create_user
57
+ identity = acleda_identity
58
+
59
+ # Returning customer: the ACLEDA identity already points at a user.
60
+ return identity.user if identity.persisted?
61
+
62
+ return nil if new_user_throttled?
63
+
64
+ # New to ACLEDA: reuse a customer matched by phone, otherwise build one, then
65
+ # tag them so the lookup above hits next time.
66
+ user = find_existing_user || build_user
67
+ identity.name = full_name
68
+ user.user_identity_providers << identity
69
+ user.save
70
+
71
+ user
72
+ end
73
+
74
+ # Memoized: #new_user_throttled? asks the same questions, and this row is the one
75
+ # that gets tagged onto the user.
76
+ def acleda_identity
77
+ @acleda_identity ||= SpreeCmCommissioner::UserIdentityProvider.acleda_bank.find_or_initialize_by(sub: phone)
78
+ end
79
+
80
+ def find_existing_user
81
+ return @find_existing_user if defined?(@find_existing_user)
82
+ return @find_existing_user = nil if intel_phone.blank?
83
+
84
+ @find_existing_user = Spree::User.by_non_tenant.find_by(intel_phone_number: intel_phone)
85
+ end
86
+
87
+ def build_user
88
+ Spree::User.new(
89
+ first_name: first_name,
90
+ last_name: last_name,
91
+ phone_number: phone,
92
+ intel_phone_number: intel_phone,
93
+ password: SecureRandom.base64(16),
94
+ confirmed_at: Time.zone.now
95
+ )
96
+ end
97
+
98
+ def mini_app_url(user)
99
+ # A JWT signed with the user's secure_token. `session_key`, not `session_id`, so
100
+ # the OAuth grant routes it to the ACLEDA authenticator.
101
+ session_key = SpreeCmCommissioner::UserSessionJwtToken.encode(
102
+ { user_id: user.id },
103
+ user.reload.secure_token
104
+ )
105
+
106
+ "#{Spree::Store.default.formatted_url}/mini_app/acleda?session_key=#{session_key}"
107
+ end
108
+
109
+ def full_name
110
+ [first_name, last_name].compact.join(' ').presence
111
+ end
112
+
113
+ def intel_phone
114
+ return @intel_phone if defined?(@intel_phone)
115
+
116
+ @intel_phone = SpreeCmCommissioner::PhoneNumberParser.call(phone_number: phone).intel_phone_number
117
+ end
118
+ end
119
+ end
120
+ end
121
+ end
@@ -19,6 +19,13 @@ module Vpago
19
19
  payment = Spree::Payment.find_by!(number: payload['paymentId'])
20
20
  payment.update(transaction_response: payload)
21
21
  payment
22
+ elsif wing_mini_app_callback?
23
+ payment = Spree::Payment.find_by!(number: params_hash[:orderRef])
24
+ verifier = Vpago::WingMiniApp::WebhookVerifier.new(params_hash, payment)
25
+ raise "Wing Mini App webhook: verification failed for payment #{payment.number}" unless verifier.valid?
26
+
27
+ payment.update(transaction_response: params_hash)
28
+ payment
22
29
  elsif acleda_v2_callback?
23
30
  # ACLEDA V2's static portal callback echoes back _transactionid, which is
24
31
  # the payment number we sent as transactionID/txid at openSessionV2.
@@ -42,5 +49,9 @@ module Vpago
42
49
  def acleda_v2_callback?
43
50
  params_hash[:_paymenttokenid].present?
44
51
  end
52
+
53
+ def wing_mini_app_callback?
54
+ params_hash[:orderRef].present? && params_hash[:secretKey].present?
55
+ end
45
56
  end
46
57
  end
@@ -0,0 +1,55 @@
1
+ module Vpago
2
+ module WingMiniApp
3
+ module Session
4
+ # Wing Mini App session init (getProfile). Same gates as ACLEDA, but the trust comes
5
+ # from a profile Wing signed and handed to the browser, not from a server-to-server
6
+ # call — so there is no key to name the caller with, only the signature to check.
7
+ class Create
8
+ prepend Spree::ServiceModule::Base
9
+
10
+ def call(profile:, gate:)
11
+ @gate = gate
12
+
13
+ return failure({ status: :unauthorized }, 'missing or unknown api key') unless gate.api_key_accepted?
14
+ return failure({ status: :forbidden }, 'address not allowed') unless gate.address_allowed?
15
+
16
+ verification = SpreeCmCommissioner::WingMiniApp::ProfileVerifier.call(profile: profile)
17
+ return failure(nil, verification.error) unless verification.success?
18
+
19
+ # After the signature, never before: a trip here is correctly signed traffic flooding us.
20
+ return failure({ status: :too_many_requests }, 'too many requests') if throttled?(profile)
21
+
22
+ user_result = SpreeCmCommissioner::WingMiniApp::FindOrCreateUser.call(profile: profile, gate: gate)
23
+ return failure(user_result.value, user_result.error) unless user_result.success?
24
+
25
+ success(mini_app_url: mini_app_url(user_result.value[:user]))
26
+ end
27
+
28
+ private
29
+
30
+ attr_reader :gate
31
+
32
+ def throttled?(profile)
33
+ gate.session_exceeded?(phone: intel_phone(profile[:phone] || profile['phone']))
34
+ end
35
+
36
+ def intel_phone(phone)
37
+ return nil if phone.blank?
38
+
39
+ SpreeCmCommissioner::PhoneNumberParser.call(phone_number: phone).intel_phone_number
40
+ end
41
+
42
+ def mini_app_url(user)
43
+ # A JWT signed with the user's secure_token. Passed as `wing_session_key`
44
+ # so the OAuth grant routes it to the Wing authenticator.
45
+ session_key = SpreeCmCommissioner::UserSessionJwtToken.encode(
46
+ { user_id: user.id },
47
+ user.reload.secure_token
48
+ )
49
+
50
+ "#{Spree::Store.default.formatted_url}/mini_app/wing?wing_session_key=#{session_key}"
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,20 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Wing</title>
7
+
8
+ <%= csrf_meta_tags %>
9
+ <%= csp_meta_tag %>
10
+
11
+ <%= yield :head %>
12
+
13
+ <%= javascript_include_tag "vpago/wing_mini_app/web_jsbridge.bundle" %>
14
+ <%= javascript_include_tag "vpago/wing_mini_app/login" %>
15
+ </head>
16
+
17
+ <body>
18
+ <%= yield %>
19
+ </body>
20
+ </html>
@@ -0,0 +1,6 @@
1
+ <%= render 'spree/admin/payments/source_views/vpago_payment_tmpl',
2
+ payment: payment,
3
+ check_status_url: '',
4
+ heal_payment_url: '',
5
+ manual_update_payment_url: ''
6
+ %>
@@ -0,0 +1,6 @@
1
+ <%= render 'spree/admin/payments/source_views/vpago_payment_tmpl',
2
+ payment: payment,
3
+ check_status_url: '',
4
+ heal_payment_url: '',
5
+ manual_update_payment_url: ''
6
+ %>
@@ -0,0 +1,44 @@
1
+ <style>
2
+ body {
3
+ display: flex;
4
+ align-items: center;
5
+ justify-content: center;
6
+ height: 100vh;
7
+ margin: 0;
8
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
9
+ }
10
+
11
+ #wing-mini-app-loading {
12
+ display: flex;
13
+ flex-direction: column;
14
+ align-items: center;
15
+ gap: 16px;
16
+ }
17
+
18
+ #wing-mini-app-spinner {
19
+ width: 32px;
20
+ height: 32px;
21
+ border: 3px solid #e0e0e0;
22
+ border-top-color: #6b46c1;
23
+ border-radius: 50%;
24
+ animation: wing-mini-app-spin 0.8s linear infinite;
25
+ }
26
+
27
+ @keyframes wing-mini-app-spin {
28
+ to { transform: rotate(360deg); }
29
+ }
30
+
31
+ #wing-mini-app-status {
32
+ color: #666;
33
+ font-size: 14px;
34
+ }
35
+
36
+ #wing-mini-app-status.wing-mini-app-error {
37
+ color: #c0392b;
38
+ }
39
+ </style>
40
+
41
+ <div id="wing-mini-app-loading">
42
+ <div id="wing-mini-app-spinner"></div>
43
+ <div id="wing-mini-app-status">Signing you in&hellip;</div>
44
+ </div>
@@ -0,0 +1,94 @@
1
+ <%
2
+ opr_device = request.user_agent.to_s.downcase.match?(/iphone|ipad|ipod/) ? 'ios' : 'android'
3
+ checkout = ::Vpago::AcledaMiniApp::Checkout.new(@payment, { platform: 'app', opr_device: opr_device })
4
+
5
+ # openSessionV2 runs here and persists paymentTokenid for the later getTxnStatus check.
6
+ # Rejected session or transport error both end with no deeplink — the one failure to render.
7
+ acleda_deeplink =
8
+ begin
9
+ checkout.acleda_deeplink
10
+ rescue StandardError => e
11
+ Rails.logger.error("[Acleda] openSessionV2 raised: #{e.message}")
12
+ nil
13
+ end
14
+
15
+ Rails.logger.error("[Acleda] openSessionV2 failed: #{checkout.error_message}") if checkout.failed?
16
+ %>
17
+
18
+ <p id="acleda-message"
19
+ style="margin-top: 10px; font-weight: bold; color: red; display: none;">
20
+ </p>
21
+
22
+ <script>
23
+ function showRedirectContainer(buttonLabel, onClick) {
24
+ const loadingContainer = document.getElementById('vpago_loading_container');
25
+ const redirectContainer = document.getElementById('vpago_redirect_container');
26
+ const redirectButton = document.getElementById('vpago_redirect_button');
27
+
28
+ redirectButton.textContent = buttonLabel;
29
+ redirectButton.addEventListener('click', onClick);
30
+
31
+ loadingContainer.style.display = 'none';
32
+ redirectContainer.style.removeProperty('display');
33
+ }
34
+
35
+ function showError(detail) {
36
+ console.error('[Acleda] ' + detail);
37
+
38
+ const message = document.getElementById('acleda-message');
39
+ message.innerText = "We couldn't start your ACLEDA payment.";
40
+ message.style.display = 'block';
41
+ }
42
+
43
+ document.addEventListener('DOMContentLoaded', () => {
44
+ const acledaDeeplink = "<%= raw acleda_deeplink %>";
45
+
46
+ if (!acledaDeeplink) {
47
+ showError('openSessionV2 returned no deeplinkUrl');
48
+ return;
49
+ }
50
+
51
+ const framed = window.top !== window.self;
52
+ const android = <%= (opr_device == 'android').to_json %>;
53
+
54
+ // The spec's `acledaJSBridge` does not exist in ACLEDA's Mini App (`window.webkit` is
55
+ // undefined), so they proposed this deeplink instead. getTxnStatus still confirms the
56
+ // payment, already polled by check_transaction_periodically.js.
57
+
58
+ // The three below open the wallet for the customer, so they tap Pay once and not twice.
59
+ // Two rules shape them: ACLEDA's WebView only launches the app for a navigation on the
60
+ // *top* frame, and a cross-origin iframe may navigate the top only while a tap's
61
+ // activation lasts — which the button has and the automatic run does not.
62
+ const openWallet = () => {
63
+ window.top.location.href = acledaDeeplink;
64
+ };
65
+
66
+ // So ask our web app, the page embedding this one, which *is* the top frame. It obeys
67
+ // only inside a bank mini app: anywhere else nothing intercepts the link and the checkout
68
+ // would really travel to ACLEDA's Play Store listing. Silence is fine — the button still works.
69
+ const askWebAppToOpen = () => {
70
+ window.parent.postMessage({ code: 'open_deeplink', url: acledaDeeplink }, '*');
71
+ };
72
+
73
+ // iOS resolves the universal link before the request is made, so a throwaway frame opens
74
+ // it. Android cannot: ACLEDA serves `frame-ancestors` on the link, so Chromium refuses to
75
+ // frame it at all.
76
+ const openThroughHiddenFrame = () => {
77
+ const frame = document.createElement('iframe');
78
+
79
+ frame.style.display = 'none';
80
+ frame.src = acledaDeeplink;
81
+
82
+ document.body.appendChild(frame);
83
+ setTimeout(() => frame.remove(), 1000);
84
+ };
85
+
86
+ if (!framed) openWallet();
87
+ else if (android) askWebAppToOpen();
88
+ else openThroughHiddenFrame();
89
+
90
+ // Straight away, not on re-show: opening the bank app only backgrounds this page, and
91
+ // waiting for it to come back left the customer stuck on "Loading..." with no way out.
92
+ showRedirectContainer('Pay with ACLEDA', openWallet);
93
+ });
94
+ </script>