spree_vpago 2.4.0.pre.pre2 → 2.4.0.pre.pre4

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 (55) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile.lock +1 -1
  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/admin/payment_acleda_v2_base_controller.rb +21 -0
  8. data/app/controllers/spree/admin/payment_acleda_v2_checkers_controller.rb +26 -0
  9. data/app/controllers/spree/admin/payment_acleda_v2_queriers_controller.rb +38 -0
  10. data/app/controllers/spree/admin/payment_payway_queriers_controller.rb +7 -0
  11. data/app/controllers/spree/admin/payment_vattanac_base_controller.rb +21 -0
  12. data/app/controllers/spree/admin/payment_vattanac_checkers_controller.rb +26 -0
  13. data/app/controllers/spree/admin/payment_vattanac_queriers_controller.rb +38 -0
  14. data/app/controllers/spree/{miniapp → mini_app}/acledas_controller.rb +12 -6
  15. data/app/controllers/spree/mini_app/wings_controller.rb +58 -0
  16. data/app/controllers/spree/vpago_payments_controller.rb +15 -3
  17. data/app/helpers/vpago/vpago_payments_helper.rb +6 -0
  18. data/app/models/spree/gateway/acleda_mini_app.rb +4 -6
  19. data/app/models/spree/gateway/wing_mini_app.rb +58 -0
  20. data/app/models/vpago/payment_decorator.rb +4 -0
  21. data/app/models/vpago/payment_method_decorator.rb +14 -0
  22. data/app/serializers/spree/v2/storefront/payment_method_serializer_decorator.rb +6 -0
  23. data/app/services/vpago/acleda_mini_app/session/create.rb +45 -23
  24. data/app/services/vpago/payment_finder.rb +11 -0
  25. data/app/services/vpago/wing_mini_app/session/create.rb +55 -0
  26. data/app/views/layouts/wing_mini_app.html.erb +20 -0
  27. data/app/views/spree/admin/payments/source_views/_acleda_v2.html.erb +2 -2
  28. data/app/views/spree/admin/payments/source_views/_vattanac.html.erb +3 -3
  29. data/app/views/spree/admin/payments/source_views/_wing_mini_app.html.erb +6 -0
  30. data/app/views/spree/mini_app/wings/show.html.erb +44 -0
  31. data/app/views/spree/vpago_payments/_success_script.html.erb +7 -0
  32. data/app/views/spree/vpago_payments/forms/spree/gateway/_acleda_mini_app.html.erb +80 -79
  33. data/app/views/spree/vpago_payments/forms/spree/gateway/_wing_mini_app.html.erb +88 -0
  34. data/app/views/spree/vpago_payments/processing_scripts/spree/gateway/_wing_mini_app.html.erb +15 -0
  35. data/app/views/spree/vpago_payments/success.html.erb +2 -0
  36. data/config/locales/en.yml +1 -0
  37. data/config/locales/km.yml +1 -0
  38. data/config/routes.rb +17 -4
  39. data/lib/spree_vpago/engine.rb +1 -0
  40. data/lib/spree_vpago/version.rb +1 -1
  41. data/lib/vpago/acleda_mini_app/checkout.rb +5 -9
  42. data/lib/vpago/acleda_mini_app/session_hash_validator.rb +35 -0
  43. data/lib/vpago/acleda_v2/payment_request_updater.rb +54 -0
  44. data/lib/vpago/http_timeouts.rb +6 -0
  45. data/lib/vpago/payway_v2/payment_request_updater.rb +5 -0
  46. data/lib/vpago/payway_v2/transaction_status.rb +2 -0
  47. data/lib/vpago/payway_v2/transaction_status_v2.rb +2 -0
  48. data/lib/vpago/true_money/base.rb +1 -1
  49. data/lib/vpago/true_money/transaction_status.rb +1 -1
  50. data/lib/vpago/vattanac/base.rb +3 -2
  51. data/lib/vpago/vattanac/payment_request_updater.rb +54 -0
  52. data/lib/vpago/vattanac/transaction_status.rb +17 -1
  53. data/lib/vpago/wing_mini_app/checkout.rb +60 -0
  54. data/lib/vpago/wing_mini_app/webhook_verifier.rb +78 -0
  55. metadata +27 -3
@@ -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,21 @@
1
+ module Spree
2
+ module Admin
3
+ class PaymentAcledaV2BaseController < Spree::Admin::BaseController
4
+ include Spree::Backend::Callbacks
5
+
6
+ before_action :load_payment, only: %i[show update]
7
+ before_action :validate_order, only: [:update]
8
+
9
+ def validate_order
10
+ return unless @payment.order.completed?
11
+
12
+ flash[:error] = Spree.t('vpago.payments.not_allow_for_order_completed')
13
+ redirect_to admin_order_payment_path(order_id: @payment.order.number, id: @payment.number)
14
+ end
15
+
16
+ def load_payment
17
+ @payment = Payment.find_by!(number: params[:id])
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,26 @@
1
+ module Spree
2
+ module Admin
3
+ class PaymentAcledaV2CheckersController < PaymentAcledaV2BaseController
4
+ include Spree::Backend::Callbacks
5
+
6
+ def update
7
+ options = {
8
+ updated_by_user_id: try_spree_current_user.id,
9
+ updated_reason: Spree.t('vpago.payments.checker_updated_by_description')
10
+ }
11
+ spree_updater = Vpago::AcledaV2::PaymentRequestUpdater.new(@payment, options)
12
+ spree_updater.call
13
+
14
+ @payment.reload
15
+
16
+ if @payment.order.completed?
17
+ flash[:success] = Spree.t(:successfully_updated, resource: Spree.t(:payments))
18
+ else
19
+ flash[:error] = Spree.t(:unsuccessfully_updated, resource: Spree.t(:payments))
20
+ end
21
+
22
+ redirect_to admin_order_payment_path(order_id: @payment.order.number, id: @payment.number)
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,38 @@
1
+ module Spree
2
+ module Admin
3
+ class PaymentAcledaV2QueriersController < PaymentAcledaV2BaseController
4
+ include Spree::Backend::Callbacks
5
+
6
+ around_action :set_writing_role, only: %i[show]
7
+
8
+ def show
9
+ tran_status = @payment.payment_method.check_transaction(@payment)
10
+
11
+ if tran_status.success?
12
+ @payment.update_column(:gateway_status, true)
13
+ flash[:success] =
14
+ Spree.t('vpago.payments.payment_found_with_result', result: tran_status.json_response)
15
+ else
16
+ flash[:error] = Spree.t('vpago.payments.payment_not_found_with_error', error: tran_status.error_message)
17
+ end
18
+
19
+ redirect_to admin_order_payment_path(order_id: @payment.order.number, id: @payment.number)
20
+ rescue Faraday::TimeoutError, Faraday::ConnectionFailed => e
21
+ VpagoLogger.error(
22
+ label: 'Spree::Admin::PaymentAcledaV2QueriersController#show gateway_timeout',
23
+ data: { payment_number: @payment.number, error_class: e.class.name, error_message: e.message }
24
+ )
25
+ flash[:error] = Spree.t('vpago.payments.gateway_timeout')
26
+ redirect_to admin_order_payment_path(order_id: @payment.order.number, id: @payment.number)
27
+ end
28
+
29
+ private
30
+
31
+ def set_writing_role
32
+ ActiveRecord::Base.connected_to(role: :writing) do
33
+ yield if block_given?
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
@@ -16,6 +16,13 @@ module Spree
16
16
  flash[:error] = Spree.t('vpago.payments.payment_not_found_with_error', error: tran_status.error_message)
17
17
  end
18
18
 
19
+ redirect_to admin_order_payment_path(order_id: @payment.order.number, id: @payment.number)
20
+ rescue Faraday::TimeoutError, Faraday::ConnectionFailed => e
21
+ VpagoLogger.error(
22
+ label: 'Spree::Admin::PaymentPaywayQueriersController#show gateway_timeout',
23
+ data: { payment_number: @payment.number, error_class: e.class.name, error_message: e.message }
24
+ )
25
+ flash[:error] = Spree.t('vpago.payments.gateway_timeout')
19
26
  redirect_to admin_order_payment_path(order_id: @payment.order.number, id: @payment.number)
20
27
  end
21
28
 
@@ -0,0 +1,21 @@
1
+ module Spree
2
+ module Admin
3
+ class PaymentVattanacBaseController < Spree::Admin::BaseController
4
+ include Spree::Backend::Callbacks
5
+
6
+ before_action :load_payment, only: %i[show update]
7
+ before_action :validate_order, only: [:update]
8
+
9
+ def validate_order
10
+ return unless @payment.order.completed?
11
+
12
+ flash[:error] = Spree.t('vpago.payments.not_allow_for_order_completed')
13
+ redirect_to admin_order_payment_path(order_id: @payment.order.number, id: @payment.number)
14
+ end
15
+
16
+ def load_payment
17
+ @payment = Payment.find_by!(number: params[:id])
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,26 @@
1
+ module Spree
2
+ module Admin
3
+ class PaymentVattanacCheckersController < PaymentVattanacBaseController
4
+ include Spree::Backend::Callbacks
5
+
6
+ def update
7
+ options = {
8
+ updated_by_user_id: try_spree_current_user.id,
9
+ updated_reason: Spree.t('vpago.payments.checker_updated_by_description')
10
+ }
11
+ spree_updater = Vpago::Vattanac::PaymentRequestUpdater.new(@payment, options)
12
+ spree_updater.call
13
+
14
+ @payment.reload
15
+
16
+ if @payment.order.completed?
17
+ flash[:success] = Spree.t(:successfully_updated, resource: Spree.t(:payments))
18
+ else
19
+ flash[:error] = Spree.t(:unsuccessfully_updated, resource: Spree.t(:payments))
20
+ end
21
+
22
+ redirect_to admin_order_payment_path(order_id: @payment.order.number, id: @payment.number)
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,38 @@
1
+ module Spree
2
+ module Admin
3
+ class PaymentVattanacQueriersController < PaymentVattanacBaseController
4
+ include Spree::Backend::Callbacks
5
+
6
+ around_action :set_writing_role, only: %i[show]
7
+
8
+ def show
9
+ tran_status = @payment.payment_method.check_transaction(@payment)
10
+
11
+ if tran_status.success?
12
+ @payment.update_column(:gateway_status, true)
13
+ flash[:success] =
14
+ Spree.t('vpago.payments.payment_found_with_result', result: tran_status.json_response)
15
+ else
16
+ flash[:error] = Spree.t('vpago.payments.payment_not_found_with_error', error: tran_status.error_message)
17
+ end
18
+
19
+ redirect_to admin_order_payment_path(order_id: @payment.order.number, id: @payment.number)
20
+ rescue Faraday::TimeoutError, Faraday::ConnectionFailed => e
21
+ VpagoLogger.error(
22
+ label: 'Spree::Admin::PaymentVattanacQueriersController#show gateway_timeout',
23
+ data: { payment_number: @payment.number, error_class: e.class.name, error_message: e.message }
24
+ )
25
+ flash[:error] = Spree.t('vpago.payments.gateway_timeout')
26
+ redirect_to admin_order_payment_path(order_id: @payment.order.number, id: @payment.number)
27
+ end
28
+
29
+ private
30
+
31
+ def set_writing_role
32
+ ActiveRecord::Base.connected_to(role: :writing) do
33
+ yield if block_given?
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
@@ -1,21 +1,27 @@
1
1
  module Spree
2
- module Miniapp
2
+ module MiniApp
3
3
  # Partner Session Initialization API (ACLEDA Mini App Integration Spec, Step 01).
4
- # ACLEDA (server-to-server) POSTs { phone, first_name, last_name }; we return a
5
- # session-based miniAppUrl for ACLEDA to open in the WebView.
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
6
  class AcledasController < Spree::Api::V2::BaseController
7
- # POST /miniapp/acleda
7
+ include Spree::MiniAppConcern
8
+
9
+ PROVIDER = 'acleda_bank'.freeze
10
+
11
+ # POST /mini_app/acleda
8
12
  def create
9
13
  result = ::Vpago::AcledaMiniApp::Session::Create.call(
10
14
  phone: session_params[:phone],
11
15
  first_name: session_params[:first_name],
12
- last_name: session_params[:last_name]
16
+ last_name: session_params[:last_name],
17
+ signature: request.headers['X-Acleda-Signature'],
18
+ gate: session_gate(PROVIDER)
13
19
  )
14
20
 
15
21
  if result.success?
16
22
  render json: { message: 'SUCCESS', miniAppUrl: result.value[:mini_app_url] }
17
23
  else
18
- render json: { message: 'FAILED', error: result.error.to_s }, status: :unprocessable_entity
24
+ render json: { message: 'FAILED', error: result.error.to_s }, status: failure_status(result)
19
25
  end
20
26
  end
21
27
 
@@ -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
@@ -24,6 +24,12 @@ module Spree
24
24
  def processing
25
25
  @order = @payment.order
26
26
 
27
+ # Common, not an edge case: the gateway callback or the checkout page's transaction
28
+ # checker often completes the order before the browser lands here. Skipping this page
29
+ # also skips its Firebase listener, so success must post order_is_completed itself
30
+ # (render_success_script) or an embedding iframe modal never closes.
31
+ return redirect_to @payment.success_url, allow_other_host: true if @order.completed?
32
+
27
33
  VpagoLogger.log(label: 'Spree::VpagoPaymentsController#processing', data: vpago_log_context)
28
34
  end
29
35
 
@@ -60,6 +66,12 @@ module Spree
60
66
  else
61
67
  render json: { status: :pending }, status: :ok
62
68
  end
69
+ rescue Faraday::TimeoutError, Faraday::ConnectionFailed => e
70
+ VpagoLogger.error(
71
+ label: 'Spree::VpagoPaymentsController#check_transaction gateway_timeout',
72
+ data: vpago_log_context(error_class: e.class.name, error_message: e.message)
73
+ )
74
+ render json: { status: :pending }, status: :ok
63
75
  end
64
76
 
65
77
  # POST
@@ -146,9 +158,9 @@ module Spree
146
158
  raise ActiveRecord::RecordNotFound unless @payment.present?
147
159
  end
148
160
 
149
- # frame-ancestors (set above) supersedes X-Frame-Options in modern
150
- # browsers, but the default SAMEORIGIN header would still block legacy
151
- # browsers from embedding these pages, so drop it for the iframe actions.
161
+ # These pages are embedded in iframes on many partner sites we don't
162
+ # enumerate, so framing is left unrestricted: no frame-ancestors policy, and
163
+ # Rails' default SAMEORIGIN header is dropped for the iframe actions.
152
164
  def allow_iframe_embedding
153
165
  response.headers.delete('X-Frame-Options')
154
166
  end
@@ -13,6 +13,12 @@ module Vpago
13
13
  render partial: 'spree/vpago_payments/transaction_checker'
14
14
  end
15
15
 
16
+ # Host apps override success.html.erb; call this from every override so an
17
+ # embedding iframe modal still learns the order completed.
18
+ def render_success_script
19
+ render partial: 'spree/vpago_payments/success_script'
20
+ end
21
+
16
22
  # Each payment method may have their own additional processing script,
17
23
  # so we will look for the partial based on the payment method class name.
18
24
  #
@@ -1,18 +1,16 @@
1
1
  module Spree
2
2
  # ACLEDA Mini App payment method.
3
3
  #
4
- # Reuses the ACLEDA V2 integration in deeplink mode: the deeplink produced by
5
- # ACLEDA's openSessionV2 API is handed to the ACLEDA super-app through the
6
- # `acledaJSBridge` (startPayment) instead of redirecting the page. Server-side
7
- # verification reuses AcledaV2's #check_transaction (getTxnStatus).
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).
8
7
  class Gateway::AcledaMiniApp < Gateway::AcledaV2
9
8
  # override: partial to render in admin / checkout form lookup
10
9
  def method_type
11
10
  'acleda_mini_app'
12
11
  end
13
12
 
14
- # The mini app always uses the deeplink + JS bridge, so admins don't need to
15
- # set the ACLEDA V2 mode.
13
+ # The mini app always pays by deeplink, so admins don't need to set the ACLEDA V2 mode.
16
14
  def deeplink?
17
15
  true
18
16
  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
@@ -113,6 +113,10 @@ module Vpago
113
113
  payment_method.type_acleda_mini_app?
114
114
  end
115
115
 
116
+ def wing_mini_app_payment?
117
+ payment_method.type_wing_mini_app?
118
+ end
119
+
116
120
  def check_payment?
117
121
  payment_method.type_check?
118
122
  end
@@ -10,6 +10,7 @@ module Vpago
10
10
  TYPE_TRUE_MONEY = 'Spree::Gateway::TrueMoney'.freeze
11
11
  TYPE_VATTANAC = 'Spree::Gateway::Vattanac'.freeze
12
12
  TYPE_VATTANAC_MINI_APP = 'Spree::Gateway::VattanacMiniApp'.freeze
13
+ TYPE_WING_MINI_APP = 'Spree::Gateway::WingMiniApp'.freeze
13
14
  TYPE_CASH_ON = 'Spree::PaymentMethod::CashOn'.freeze
14
15
 
15
16
  def self.prepended(base)
@@ -28,6 +29,7 @@ module Vpago
28
29
  Spree::PaymentMethod::TYPE_ACLEDA_MINI_APP,
29
30
  Spree::PaymentMethod::TYPE_VATTANAC,
30
31
  Spree::PaymentMethod::TYPE_VATTANAC_MINI_APP,
32
+ Spree::PaymentMethod::TYPE_WING_MINI_APP,
31
33
  Spree::PaymentMethod::TYPE_TRUE_MONEY,
32
34
  Spree::PaymentMethod::TYPE_CASH_ON
33
35
  ]
@@ -82,6 +84,10 @@ module Vpago
82
84
  ::Vpago::Acleda::PaymentRequestUpdater
83
85
  elsif type_acleda_mobile?
84
86
  ::Vpago::AcledaMobile::PaymentRequestUpdater
87
+ elsif type_acleda_v2?
88
+ ::Vpago::AcledaV2::PaymentRequestUpdater
89
+ elsif type_vattanac?
90
+ ::Vpago::Vattanac::PaymentRequestUpdater
85
91
  end
86
92
  end
87
93
 
@@ -113,10 +119,18 @@ module Vpago
113
119
  type == Spree::PaymentMethod::TYPE_WINGSDK
114
120
  end
115
121
 
122
+ def type_vattanac?
123
+ type == Spree::PaymentMethod::TYPE_VATTANAC
124
+ end
125
+
116
126
  def type_vattanac_mini_app?
117
127
  type == Spree::PaymentMethod::TYPE_VATTANAC_MINI_APP
118
128
  end
119
129
 
130
+ def type_wing_mini_app?
131
+ type == Spree::PaymentMethod::TYPE_WING_MINI_APP
132
+ end
133
+
120
134
  def type_true_money?
121
135
  type == Spree::PaymentMethod::TYPE_TRUE_MONEY
122
136
  end
@@ -14,6 +14,12 @@ module Spree
14
14
 
15
15
  pref.blank? || pref[:payment_option].blank? ? payment_method.method_type : pref[:payment_option]
16
16
  end
17
+
18
+ # Overried the payment method type to show all payment methods as one list on App.
19
+ # Controlled by ENV["PAYMENT_METHOD_VIEW"] = "split" | "join" (default: "join").
20
+ base.attribute :type do |payment_method|
21
+ ENV.fetch('PAYMENT_METHOD_VIEW', 'join') == 'split' ? payment_method.type : 'payment_method'
22
+ end
17
23
  end
18
24
  end
19
25
  end
@@ -3,20 +3,32 @@ module Vpago
3
3
  module Session
4
4
  # Partner Session Initialization (ACLEDA Mini App Integration Spec, Step 01).
5
5
  #
6
- # ACLEDA calls POST /miniapp/acleda with { phone, first_name, last_name }.
7
- # We find or create the customer (tagged with the acleda_bank identity),
8
- # mint a session JWT, and return a session-based miniAppUrl for ACLEDA to
9
- # open in the WebView. The generic session_id OAuth grant validates the JWT
10
- # when the WebView later authenticates.
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.
11
10
  class Create
12
11
  prepend Spree::ServiceModule::Base
13
12
 
14
- def call(phone: nil, first_name: nil, last_name: nil)
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)
15
19
  return failure(nil, 'phone is required') if phone.blank?
16
20
 
17
21
  @phone = phone
18
22
  @first_name = first_name
19
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)
20
32
 
21
33
  user = find_or_create_user
22
34
  return failure(user, 'user creation failed') if user.nil? || !user.persisted?
@@ -26,7 +38,20 @@ module Vpago
26
38
 
27
39
  private
28
40
 
29
- attr_reader :phone, :first_name, :last_name
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
30
55
 
31
56
  def find_or_create_user
32
57
  identity = acleda_identity
@@ -34,9 +59,10 @@ module Vpago
34
59
  # Returning customer: the ACLEDA identity already points at a user.
35
60
  return identity.user if identity.persisted?
36
61
 
37
- # New to ACLEDA: reuse a customer matched by phone, otherwise build one,
38
- # then tag them with the acleda_bank identity so the lookup above hits
39
- # next time.
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.
40
66
  user = find_existing_user || build_user
41
67
  identity.name = full_name
42
68
  user.user_identity_providers << identity
@@ -45,14 +71,17 @@ module Vpago
45
71
  user
46
72
  end
47
73
 
74
+ # Memoized: #new_user_throttled? asks the same questions, and this row is the one
75
+ # that gets tagged onto the user.
48
76
  def acleda_identity
49
- SpreeCmCommissioner::UserIdentityProvider.acleda_bank.find_or_initialize_by(sub: phone)
77
+ @acleda_identity ||= SpreeCmCommissioner::UserIdentityProvider.acleda_bank.find_or_initialize_by(sub: phone)
50
78
  end
51
79
 
52
80
  def find_existing_user
53
- return if intel_phone.blank?
81
+ return @find_existing_user if defined?(@find_existing_user)
82
+ return @find_existing_user = nil if intel_phone.blank?
54
83
 
55
- Spree::User.by_non_tenant.find_by(intel_phone_number: intel_phone)
84
+ @find_existing_user = Spree::User.by_non_tenant.find_by(intel_phone_number: intel_phone)
56
85
  end
57
86
 
58
87
  def build_user
@@ -67,15 +96,14 @@ module Vpago
67
96
  end
68
97
 
69
98
  def mini_app_url(user)
70
- # A JWT signed with the user's secure_token. Passed as `session_key` (not
71
- # `session_id`) so the OAuth grant routes it to the ACLEDA authenticator,
72
- # which enforces the signature.
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.
73
101
  session_key = SpreeCmCommissioner::UserSessionJwtToken.encode(
74
102
  { user_id: user.id },
75
103
  user.reload.secure_token
76
104
  )
77
105
 
78
- "#{Spree::Store.default.formatted_url}/mini_app/acleda?session_key=#{session_key}&ds=#{CGI.escape(disable_services)}"
106
+ "#{Spree::Store.default.formatted_url}/mini_app/acleda?session_key=#{session_key}"
79
107
  end
80
108
 
81
109
  def full_name
@@ -87,12 +115,6 @@ module Vpago
87
115
 
88
116
  @intel_phone = SpreeCmCommissioner::PhoneNumberParser.call(phone_number: phone).intel_phone_number
89
117
  end
90
-
91
- # Services to disable in the mini app storefront. Configurable per ACLEDA's
92
- # requirement; defaults to none.
93
- def disable_services
94
- ENV.fetch('ACLEDA_MINI_APP_DISABLE_SERVICES', '')
95
- end
96
118
  end
97
119
  end
98
120
  end