spree_vpago 2.3.2 → 2.3.5.pre.pre.pre.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 40b1b2417da2209fc3545dc75fb99354e9fd009adc565d3c4d920b59f18c4655
4
- data.tar.gz: dbb1754379d9378dcbfb9fb1d225b7c73628475ae1155e1ad5b198df1e323535
3
+ metadata.gz: c0262a0bad21d6ed9d53d73ba6805f307e6b9f82db837ac96fe4f3c4725ae49b
4
+ data.tar.gz: 71f83ae18d1c306419bbef87f291185e1835a653fde1d1f0a09e1c783cf6f54c
5
5
  SHA512:
6
- metadata.gz: 007761d54598f02ee411f16962eec6ad167eb5eb4623f11f27c8e278e05b2fbc023f71ef4ed3e8eb723870692d83d25155f3fffd85d9fd7880057a9fa1ba2791
7
- data.tar.gz: a5ddf6866c3905d6b12224388e9a1b91ad3b19dbdd7fa6dfd936b23227081d8910820795678fac206a7384066e479ae851a23f75dd035abd62abcf47d9e25446
6
+ metadata.gz: ffc03cf77c82129ee5cf13ca1d3abb1646a9d39878ea647e1fd2451c30c027a885d8e8cb99fe45fabef2d7308c73f70c11b654ef9043b59814ef4737a983392c
7
+ data.tar.gz: cde29375e88d3428cd5854b47bb597fac4697eaf60550dfb74573dd0d5edff55624ea0df78659328ba40343548c39b60d4284fe7c6bfcd495a24ba405b82fcea
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- spree_vpago (2.3.2)
4
+ spree_vpago (2.3.5.pre.pre.pre.1)
5
5
  faraday
6
6
  google-cloud-firestore
7
7
  spree_api (>= 4.5)
@@ -17,7 +17,7 @@ module Spree
17
17
 
18
18
  @order = @payment.order
19
19
 
20
- Rails.logger.info("[Vpago][#{@payment.number}] Showing checkout page for order #{@order.number}")
20
+ VpagoLogger.log(label: 'Spree::VpagoPaymentsController#checkout', data: vpago_log_context)
21
21
  end
22
22
 
23
23
  # GET
@@ -27,7 +27,7 @@ module Spree
27
27
 
28
28
  @order = @payment.order
29
29
 
30
- Rails.logger.info("[Vpago][#{@payment.number}] Showing processing page for order #{@order.number}")
30
+ VpagoLogger.log(label: 'Spree::VpagoPaymentsController#processing', data: vpago_log_context)
31
31
  end
32
32
 
33
33
  # GET
@@ -38,7 +38,7 @@ module Spree
38
38
  @order = @payment.order
39
39
  raise CanCan::AccessDenied unless @order.completed?
40
40
 
41
- Rails.logger.info("[Vpago][#{@payment.number}] Showing success page for order #{@order.number}")
41
+ VpagoLogger.log(label: 'Spree::VpagoPaymentsController#success', data: vpago_log_context)
42
42
  end
43
43
 
44
44
  # GET
@@ -49,20 +49,21 @@ module Spree
49
49
  return render json: { status: :success }, status: :ok if @payment.completed?
50
50
  return render json: { status: :failed }, status: :ok if @payment.failed?
51
51
 
52
- if @payment.payment_method.support_check_transaction_api?
53
- Rails.logger.info("[Vpago][#{@payment.number}] Checking transaction for payment #{@payment.number}")
54
- checker = @payment.payment_method.check_transaction(@payment)
55
- Rails.logger.info("[Vpago][#{@payment.number}] Check transaction result for payment #{@payment.number} with success: #{checker.success?}, failed: #{checker.try(:failed?)}")
56
-
57
- if checker.success?
58
- render json: { status: :success }, status: :ok
59
- elsif checker.try(:failed?) == true
60
- render json: { status: :failed }, status: :ok
61
- else
62
- render json: { status: :pending }, status: :ok
63
- end
52
+ unless @payment.payment_method.support_check_transaction_api?
53
+ VpagoLogger.log(label: 'Spree::VpagoPaymentsController#check_transaction unsupported', data: vpago_log_context)
54
+ return render json: { status: :pending }, status: :ok
55
+ end
56
+
57
+ checker = VpagoLogger.log(
58
+ label: 'Spree::VpagoPaymentsController#check_transaction',
59
+ data: vpago_log_context
60
+ ) { @payment.payment_method.check_transaction(@payment) }
61
+
62
+ if checker.success?
63
+ render json: { status: :success }, status: :ok
64
+ elsif checker.try(:failed?) == true
65
+ render json: { status: :failed }, status: :ok
64
66
  else
65
- Rails.logger.info("[Vpago][#{@payment.number}] Payment method does not support check transaction API")
66
67
  render json: { status: :pending }, status: :ok
67
68
  end
68
69
  end
@@ -75,30 +76,35 @@ module Spree
75
76
  @payment = Vpago::PaymentFinder.new(return_params).find_and_verify
76
77
 
77
78
  if @payment.nil?
78
- Rails.logger.error("[Vpago] Payment not found for params: #{return_params}")
79
+ VpagoLogger.error(
80
+ label: 'Spree::VpagoPaymentsController#process_payment payment_not_found',
81
+ data: vpago_log_context(params: return_params)
82
+ )
79
83
  return render_not_found
80
84
  end
81
85
 
82
- Rails.logger.info("[Vpago][#{@payment.number}] Payment found: #{@payment&.number}, order: #{@payment&.order&.number}")
86
+ VpagoLogger.log(label: 'Spree::VpagoPaymentsController#process_payment payment_found', data: vpago_log_context)
83
87
 
84
88
  # for ABA reviewing mode, we can disable pushback from bank, and only process it from our app UI instead.
85
89
  # This will give ABA team to know that we don't rely on just pushback and have fallback to process payment.
86
90
  if @payment.payment_method.type_payway_v2? && @payment.payment_method.reviewing_mode? && request_from_external_server?
87
- Rails.logger.info("[Vpago][#{@payment.number}] Received payment notification from bank in reviewing mode, skipping processing")
91
+ VpagoLogger.log(label: 'Spree::VpagoPaymentsController#process_payment skipped_reviewing_mode', data: vpago_log_context)
88
92
  return render json: { status: :ok }, status: :ok
89
93
  end
90
94
 
91
95
  unless @payment.order.paid?
92
- Rails.logger.info("[Vpago][#{@payment.number}] Enqueuing payment processor job for payment #{@payment.number}")
93
- Vpago::PaymentProcessorJob.perform_later(
94
- payment_number: @payment.number
95
- )
96
+ VpagoLogger.log(
97
+ label: 'Spree::VpagoPaymentsController#process_payment enqueue_payment_processor_job',
98
+ data: vpago_log_context
99
+ ) { Vpago::PaymentProcessorJob.perform_later(payment_number: @payment.number) }
96
100
  end
97
101
 
98
- Rails.logger.info("[Vpago][#{@payment.number}] Successfully enqueued payment processor job for payment #{@payment.number}")
99
102
  render json: { status: :ok }, status: :ok
100
103
  rescue StandardError => e
101
- Rails.logger.error("[Vpago][#{@payment&.number}] Failed to enqueue payment processor job: #{e.message}")
104
+ VpagoLogger.error(
105
+ label: 'Spree::VpagoPaymentsController#process_payment failed',
106
+ data: vpago_log_context(error_class: e.class.name, error_message: e.message, backtrace: e.backtrace&.first(5))
107
+ )
102
108
  render json: { status: :internal_server_error, message: 'Failed to enqueue payment processor job' }, status: :internal_server_error
103
109
  end
104
110
 
@@ -107,14 +113,28 @@ module Spree
107
113
  return render json: { status: { code: '000001', message: 'success' }, data: nil }, status: :ok if request.method != 'POST'
108
114
 
109
115
  @payment = Spree::Payment.find_by(number: params.dig(:data, :external_ref_id))
110
- return render_not_found unless @payment
111
116
 
112
- Vpago::PaymentProcessorJob.perform_later(payment_number: @payment.number) unless @payment.order.paid?
113
- Rails.logger.info("[Vpago][#{@payment.number}] Successfully enqueued payment processor job for payment #{@payment.number}")
117
+ if @payment.nil?
118
+ VpagoLogger.error(
119
+ label: 'Spree::VpagoPaymentsController#true_money_process_payment payment_not_found',
120
+ data: vpago_log_context(external_ref_id: params.dig(:data, :external_ref_id))
121
+ )
122
+ return render_not_found
123
+ end
124
+
125
+ unless @payment.order.paid?
126
+ VpagoLogger.log(
127
+ label: 'Spree::VpagoPaymentsController#true_money_process_payment enqueue_payment_processor_job',
128
+ data: vpago_log_context
129
+ ) { Vpago::PaymentProcessorJob.perform_later(payment_number: @payment.number) }
130
+ end
114
131
 
115
132
  render json: { status: { code: '000001', message: 'success' }, data: nil }, status: :ok
116
133
  rescue StandardError => e
117
- Rails.logger.error("[Vpago][#{@payment&.number}] Failed to enqueue payment processor job: #{e.message}")
134
+ VpagoLogger.error(
135
+ label: 'Spree::VpagoPaymentsController#true_money_process_payment failed',
136
+ data: vpago_log_context(error_class: e.class.name, error_message: e.message, backtrace: e.backtrace&.first(5))
137
+ )
118
138
  render json: { status: :internal_server_error, message: 'Failed to enqueue payment processor job' }, status: :internal_server_error
119
139
  end
120
140
 
@@ -144,5 +164,17 @@ module Spree
144
164
  def request_from_external_server?
145
165
  params[:internal_client].blank? || params[:internal_client] == 'false'
146
166
  end
167
+
168
+ def vpago_log_context(extra = {})
169
+ {
170
+ timestamp: Time.current.utc.iso8601(3),
171
+ remote_ip: request.remote_ip,
172
+ payment_number: @payment&.number,
173
+ order_number: @payment&.order&.number,
174
+ payment_method_type: @payment&.payment_method&.type,
175
+ payment_method_name: @payment&.payment_method&.name,
176
+ request_id: request.request_id
177
+ }.merge(extra)
178
+ end
147
179
  end
148
180
  end
@@ -12,6 +12,7 @@ module Vpago
12
12
  payway_abapay
13
13
  payway_alipay
14
14
  payway_wechat
15
+ acleda_v2
15
16
  acleda
16
17
  acleda_khqr
17
18
  acleda_cards
@@ -28,6 +29,10 @@ module Vpago
28
29
  %w[khqr deeplink all]
29
30
  end
30
31
 
32
+ def available_acleda_v2_modes
33
+ Spree::Gateway::AcledaV2::MODES
34
+ end
35
+
31
36
  def preference_field_for(form, field, options)
32
37
  case field
33
38
  when 'preferred_acleda_type'
@@ -45,6 +50,8 @@ module Vpago
45
50
  return form.select(:preferred_abapay_khqr_deeplink_option, Spree::Gateway::PaywayV2::ABAPAY_KHQR_DEEPLINK_OPTIONS, {}, class: 'fullwidth select2')
46
51
  when 'preferred_vattanac_payment_option'
47
52
  return form.select(:preferred_vattanac_payment_option, available_vattanac_payment_options, {}, class: 'fullwidth select2')
53
+ when 'preferred_acleda_v2_mode'
54
+ return form.select(:preferred_acleda_v2_mode, available_acleda_v2_modes, {}, class: 'fullwidth select2')
48
55
  end
49
56
  super
50
57
  end
@@ -5,7 +5,22 @@ module Vpago
5
5
 
6
6
  def perform(options)
7
7
  payment = Spree::Payment.find_by!(number: options[:payment_number])
8
- Vpago::PaymentProcessor.new(payment: payment).call
8
+
9
+ VpagoLogger.log(
10
+ label: 'Vpago::PaymentProcessorJob#perform',
11
+ data: {
12
+ payment_number: payment.number,
13
+ order_number: payment.order.number,
14
+ payment_method_type: payment.payment_method.type,
15
+ payment_method_name: payment.payment_method.name
16
+ }
17
+ ) { Vpago::PaymentProcessor.new(payment: payment).call }
18
+ rescue StandardError => e
19
+ VpagoLogger.error(
20
+ label: 'Vpago::PaymentProcessorJob#perform failed',
21
+ data: { payment_number: options[:payment_number], error_class: e.class.name, error_message: e.message, backtrace: e.backtrace&.first(5) }
22
+ )
23
+ raise
9
24
  end
10
25
  end
11
26
  end
@@ -0,0 +1,93 @@
1
+ module Spree
2
+ class Gateway::AcledaV2 < PaymentMethod
3
+ MODES = %w[khqr card deeplink].freeze
4
+
5
+ # BOOKMEPLUS — sent as the "merchantName" field in getTxnStatus requests
6
+ preference :merchant_name, :string
7
+ # e.g. https://epaymentuat.acledabank.com.kh/BOOKMEPLUS/XPAYConnectorServiceInterfaceImplV2/XPAYConnectorServiceInterfaceImplV2RS
8
+ preference :xpay_service_base_url, :string
9
+ # e.g. https://epaymentuat.acledabank.com.kh/BOOKMEPLUS/paymentPage.jsp
10
+ preference :payment_page_url, :string
11
+ preference :merchant_id, :string
12
+ preference :login_id, :string
13
+ preference :password, :string
14
+ preference :secret, :string # HMAC-SHA512 key
15
+ preference :payment_expiry_time_in_mn, :integer, default: 5
16
+ preference :acleda_v2_mode, :string, default: 'khqr'
17
+
18
+ validates :preferred_acleda_v2_mode, inclusion: { in: MODES }
19
+
20
+ def khqr?
21
+ preferred_acleda_v2_mode == 'khqr'
22
+ end
23
+
24
+ def card?
25
+ preferred_acleda_v2_mode == 'card'
26
+ end
27
+
28
+ def deeplink?
29
+ preferred_acleda_v2_mode == 'deeplink'
30
+ end
31
+
32
+ # KHQR (Scan with Bank under Bakong) = "0"; Credit/debit card = "1"
33
+ def payment_card_value
34
+ card? ? '1' : '0'
35
+ end
36
+
37
+ # override: partial to render in admin
38
+ def method_type
39
+ 'acleda_v2'
40
+ end
41
+
42
+ # override
43
+ def payment_source_class
44
+ Spree::VpagoPaymentSource
45
+ end
46
+
47
+ # override
48
+ def payment_profiles_supported?
49
+ false
50
+ end
51
+
52
+ # override
53
+ def available_for_order?(_order)
54
+ true
55
+ end
56
+
57
+ # override: process! -> purchase! immediately (no pre-auth for ACLEDA V2)
58
+ def auto_capture?
59
+ true
60
+ end
61
+
62
+ # override
63
+ # mirror PayWay V2: gateway_options[:order_id] == "<order_number>-<payment_number>"
64
+ def purchase(_amount, _source, gateway_options = {})
65
+ _, payment_number = gateway_options[:order_id].split('-')
66
+ payment = Spree::Payment.find_by(number: payment_number)
67
+
68
+ checker = check_transaction(payment)
69
+ payment.update(transaction_response: checker.json_response)
70
+
71
+ params = { check: { response: checker.json_response } }
72
+
73
+ if checker.success?
74
+ ActiveMerchant::Billing::Response.new(true, 'Acleda V2: Purchased', params)
75
+ else
76
+ ActiveMerchant::Billing::Response.new(false, 'Acleda V2: Purchasing Failed', params)
77
+ end
78
+ end
79
+
80
+ # Defining check_transaction makes support_check_transaction_api? true,
81
+ # enabling the /vpago_payments/check_transaction poller.
82
+ def check_transaction(payment)
83
+ checker = Vpago::AcledaV2::TransactionStatus.new(payment)
84
+ checker.call
85
+ checker
86
+ end
87
+
88
+ # override
89
+ def cancel(_response_code, _payment = nil)
90
+ ActiveMerchant::Billing::Response.new(true, 'Acleda V2: Payment has been canceled.')
91
+ end
92
+ end
93
+ end
@@ -4,6 +4,7 @@ module Vpago
4
4
  TYPE_PAYWAY_V2 = 'Spree::Gateway::PaywayV2'.freeze
5
5
  TYPE_WINGSDK = 'Spree::Gateway::WingSdk'.freeze
6
6
  TYPE_ACLEDA = 'Spree::Gateway::Acleda'.freeze
7
+ TYPE_ACLEDA_V2 = 'Spree::Gateway::AcledaV2'.freeze
7
8
  TYPE_ACLEDA_MOBILE = 'Spree::Gateway::AcledaMobile'.freeze
8
9
  TYPE_TRUE_MONEY = 'Spree::Gateway::TrueMoney'.freeze
9
10
  TYPE_VATTANAC = 'Spree::Gateway::Vattanac'.freeze
@@ -21,6 +22,7 @@ module Vpago
21
22
  Spree::PaymentMethod::TYPE_PAYWAY,
22
23
  Spree::PaymentMethod::TYPE_WINGSDK,
23
24
  Spree::PaymentMethod::TYPE_ACLEDA,
25
+ Spree::PaymentMethod::TYPE_ACLEDA_V2,
24
26
  Spree::PaymentMethod::TYPE_ACLEDA_MOBILE,
25
27
  Spree::PaymentMethod::TYPE_VATTANAC,
26
28
  Spree::PaymentMethod::TYPE_VATTANAC_MINI_APP,
@@ -89,6 +91,10 @@ module Vpago
89
91
  type == Spree::PaymentMethod::TYPE_ACLEDA
90
92
  end
91
93
 
94
+ def type_acleda_v2?
95
+ type == Spree::PaymentMethod::TYPE_ACLEDA_V2
96
+ end
97
+
92
98
  def type_payway?
93
99
  type == Spree::PaymentMethod::TYPE_PAYWAY
94
100
  end
@@ -14,11 +14,15 @@ module Vpago
14
14
  end
15
15
 
16
16
  def find_and_verify!
17
- if vattanac_mini_app_payload?
17
+ if vattanac_mini_app_callback?
18
18
  payload = Vpago::VattanacMiniAppDataHandler.new.decrypt_data(@params_hash[:data])
19
19
  payment = Spree::Payment.find_by!(number: payload['paymentId'])
20
20
  payment.update(transaction_response: payload)
21
21
  payment
22
+ elsif acleda_v2_callback?
23
+ # ACLEDA V2's static portal callback carries only _paymenttokenid, which we
24
+ # stored on the payment source at openSessionV2. Resolve the payment by it.
25
+ Spree::VpagoPaymentSource.find_by!(transaction_id: params_hash[:_paymenttokenid]).payment
22
26
  else
23
27
  order = Spree::Order.find_by!(number: params_hash[:order_number])
24
28
  verify_jwt!(order)
@@ -31,8 +35,12 @@ module Vpago
31
35
  JWT.decode(params_hash[:order_jwt_token], order.token, 'HS256')
32
36
  end
33
37
 
34
- def vattanac_mini_app_payload?
38
+ def vattanac_mini_app_callback?
35
39
  params_hash[:data].present?
36
40
  end
41
+
42
+ def acleda_v2_callback?
43
+ params_hash[:_paymenttokenid].present?
44
+ end
37
45
  end
38
46
  end
@@ -24,17 +24,18 @@ module Vpago
24
24
  :unable_to_complete_order
25
25
  end
26
26
 
27
- # example.
28
- # Started Vpago::PaymentProcessor#process_payment! for payment_number: PX81YZX with args: {}
29
- # Completed Vpago::PaymentProcessor#process_payment! for payment_number: PX81YZX in 2000ms
30
- def log_process(method, *args)
31
- start_time = Time.now
32
- Rails.logger.error("Started #{self.class}##{method} for payment_number: #{@payment.number} with args: #{args}")
33
-
34
- yield
35
-
36
- duration_ms = (Time.now - start_time) * 1000
37
- Rails.logger.error("Completed #{self.class}##{method} for payment_number: #{@payment.number} in #{duration_ms}ms")
27
+ def log_process(method, *args, &)
28
+ VpagoLogger.log(
29
+ label: "#{self.class.name}##{method}",
30
+ data: {
31
+ payment_number: @payment.number,
32
+ order_number: @payment.order.number,
33
+ payment_method_type: @payment.payment_method.type,
34
+ payment_method_name: @payment.payment_method.name,
35
+ args: args
36
+ },
37
+ &
38
+ )
38
39
  end
39
40
 
40
41
  def user_informer
@@ -0,0 +1 @@
1
+ <%= render 'spree/admin/payments/source_views/vpago_payment_tmpl', payment: payment %>
@@ -0,0 +1,12 @@
1
+ <form method="post" action="<%= action_url %>" id="acleda_v2_checkout_form" style="display:none;">
2
+ <% gateway_params.each do |name, value| %>
3
+ <input type="hidden" name="<%= name %>" value="<%= value %>">
4
+ <% end %>
5
+ </form>
6
+
7
+ <script>
8
+ (function () {
9
+ var form = document.getElementById("acleda_v2_checkout_form");
10
+ if (form) form.submit();
11
+ })();
12
+ </script>
@@ -0,0 +1,47 @@
1
+ <%
2
+ opr_device = request.user_agent.to_s.downcase.match?(/iphone|ipad|ipod/) ? 'ios' : 'android'
3
+ checkout = ::Vpago::AcledaV2::Checkout.new(@payment, { platform: params[:platform], opr_device: opr_device })
4
+
5
+ begin
6
+ checkout.call
7
+ rescue => e
8
+ Rails.logger.error("Acleda V2 checkout.call failed: #{e.message}")
9
+ end
10
+ %>
11
+
12
+ <% if checkout.failed? %>
13
+ <script>
14
+ console.error("[Vpago] Acleda V2 checkout error: <%= j checkout.error_message.to_s %>");
15
+ document.addEventListener("DOMContentLoaded", () => {
16
+ const loadingContainer = document.getElementById('vpago_loading_container');
17
+ if (loadingContainer) {
18
+ loadingContainer.textContent = "We couldn't start your payment. Please go back and try again.";
19
+ }
20
+ });
21
+ </script>
22
+ <% elsif @payment.payment_method.deeplink? %>
23
+ <script>
24
+ function showRedirectContainer(buttonLabel, onClick) {
25
+ const loadingContainer = document.getElementById('vpago_loading_container');
26
+ const redirectContainer = document.getElementById('vpago_redirect_container');
27
+ const redirectButton = document.getElementById('vpago_redirect_button');
28
+
29
+ redirectButton.textContent = buttonLabel;
30
+ redirectButton.addEventListener('click', onClick);
31
+
32
+ loadingContainer.style.display = 'none';
33
+ redirectContainer.style.removeProperty('display');
34
+ }
35
+
36
+ const deeplinkUrl = "<%= raw checkout.deeplink_url %>";
37
+ const redirectTo = (url) => { if (url) window.location.href = url; };
38
+
39
+
40
+ redirectTo(deeplinkUrl);
41
+ showRedirectContainer("Open ACLEDA", () => redirectTo(deeplinkUrl));
42
+ </script>
43
+ <% else %>
44
+ <%= render 'spree/checkout/payment/acleda_v2_checkout_form',
45
+ gateway_params: checkout.gateway_params,
46
+ action_url: checkout.action_url %>
47
+ <% end %>
@@ -21,6 +21,7 @@ module SpreeVpago
21
21
  Spree::Gateway::PaywayV2,
22
22
  Spree::Gateway::WingSdk,
23
23
  Spree::Gateway::Acleda,
24
+ Spree::Gateway::AcledaV2,
24
25
  Spree::Gateway::AcledaMobile,
25
26
  Spree::Gateway::Vattanac,
26
27
  Spree::Gateway::VattanacMiniApp,
@@ -1,7 +1,7 @@
1
1
  module SpreeVpago
2
2
  module_function
3
3
 
4
- VERSION = '2.3.2'.freeze
4
+ VERSION = '2.3.5-pre-1'.freeze
5
5
 
6
6
  def version
7
7
  Gem::Version.new VERSION
@@ -0,0 +1,149 @@
1
+ module Vpago
2
+ module AcledaV2
3
+ class Base
4
+ CONTENT_TYPE_JSON = 'application/json'.freeze
5
+ # ACLEDA requires purchaseDesc to be <= 16 chars, English only.
6
+ PURCHASE_DESC = 'Purchase goods'.freeze
7
+
8
+ def initialize(payment, options = {})
9
+ @options = options
10
+ @payment = payment
11
+ end
12
+
13
+ def payment_method
14
+ @payment.payment_method
15
+ end
16
+
17
+ def payment_number
18
+ @payment.number
19
+ end
20
+
21
+ # PDF samples quote every xpayTransaction field as a JSON string.
22
+ def amount
23
+ format('%.2f', @payment.amount)
24
+ end
25
+
26
+ # txid <= 16 chars; payment.number fits.
27
+ alias transaction_id payment_number
28
+
29
+ def order_number
30
+ @payment.order.number
31
+ end
32
+
33
+ def merchant_name
34
+ payment_method.preferences[:merchant_name]
35
+ end
36
+
37
+ def merchant_id
38
+ payment_method.preferences[:merchant_id]
39
+ end
40
+
41
+ def login_id
42
+ payment_method.preferences[:login_id]
43
+ end
44
+
45
+ def password
46
+ payment_method.preferences[:password]
47
+ end
48
+
49
+ def secret
50
+ payment_method.preferences[:secret]
51
+ end
52
+
53
+ # PDF samples quote every xpayTransaction field as a JSON string, even ones
54
+ # labeled "Numeric" in the field tables (paymentCard, quantity, expiryTime).
55
+ def expiry_time
56
+ payment_method.preferences[:payment_expiry_time_in_mn].to_s
57
+ end
58
+
59
+ # PDF: "Date of Transaction in UTC Time", sent as a YYYY-MM-DD string in the
60
+ # samples. Explicit .utc so this stays correct even if config.time_zone is
61
+ # ever changed away from UTC.
62
+ def purchase_date
63
+ @payment.created_at.utc.strftime('%Y-%m-%d')
64
+ end
65
+
66
+ def purchase_currency
67
+ @payment.order.currency
68
+ end
69
+
70
+ # --- Endpoints (each stored as a full URL preference) ---
71
+ # Strip embedded whitespace: these URLs are long and wrap across lines in
72
+ # the bank's PDFs, so a copy-paste into the admin preference easily carries
73
+ # over a stray space/line-break where the PDF wrapped.
74
+
75
+ def xpay_service_base_url
76
+ strip_whitespace(payment_method.preferences[:xpay_service_base_url])
77
+ end
78
+
79
+ def open_session_url
80
+ "#{xpay_service_base_url}/openSessionV2"
81
+ end
82
+
83
+ def get_txn_status_url # rubocop:disable Naming/AccessorMethodName
84
+ "#{xpay_service_base_url}/getTxnStatus"
85
+ end
86
+
87
+ # KHQR/Card secure web redirect target
88
+ def action_url
89
+ strip_whitespace(payment_method.preferences[:payment_page_url])
90
+ end
91
+
92
+ # successUrlToReturn / errorUrl. The bank appends its own query string
93
+ # (_paymenttokenid, _resultCode, ...) when it redirects back.
94
+ def continue_success_url
95
+ @payment.processing_url
96
+ end
97
+
98
+ def callback_url
99
+ if platform == 'app' && app_scheme.present?
100
+ uri = URI.parse(@payment.processing_url)
101
+ uri.scheme = app_scheme
102
+ uri.to_s
103
+ else
104
+ @payment.processing_url
105
+ end
106
+ end
107
+
108
+ def app_scheme
109
+ payment_method.preferences[:app_scheme]
110
+ end
111
+
112
+ # android | ios (defaults to android when not supplied by the caller)
113
+ def opr_device
114
+ @options[:opr_device].presence || 'android'
115
+ end
116
+
117
+ def platform
118
+ @options[:platform]
119
+ end
120
+
121
+ # --- HMAC-SHA512 (uppercase hex) signing ---
122
+ # openSessionV2 message: merchantID + loginId + password + txid
123
+ def open_session_hash
124
+ hmac512("#{merchant_id}#{login_id}#{password}#{payment_number}")
125
+ end
126
+
127
+ # getTxnStatus message: merchantId + loginId + password + paymentTokenid
128
+ def txn_status_hash(token)
129
+ hmac512("#{merchant_id}#{login_id}#{password}#{token}")
130
+ end
131
+
132
+ def hmac512(message)
133
+ OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new('sha512'), secret, message).upcase
134
+ end
135
+
136
+ def parse_json(body)
137
+ JSON.parse(body)
138
+ rescue JSON::ParserError
139
+ {}
140
+ end
141
+
142
+ private
143
+
144
+ def strip_whitespace(value)
145
+ value.to_s.gsub(/\s+/, '')
146
+ end
147
+ end
148
+ end
149
+ end
@@ -0,0 +1,124 @@
1
+ require 'faraday'
2
+
3
+ module Vpago
4
+ module AcledaV2
5
+ # Single class handling all three modes (khqr, card, deeplink).
6
+ # Two-step ACLEDA flow: openSessionV2 (JSON, here) yields paymentTokenid +
7
+ # sessionid, then either an auto-POST form to paymentPage.jsp (KHQR/Card) or
8
+ # a JS deeplink redirect (Deep Link).
9
+ class Checkout < Base
10
+ attr_reader :error_message
11
+
12
+ def call
13
+ @error_message = nil
14
+ @response = open_session
15
+
16
+ if @response.status != 500 && @response.status != 200
17
+ @error_message = 'Server Error'
18
+ elsif @response.status == 500 # mostly when passing a wrong parameter name
19
+ @error_message = @response.body
20
+ elsif json_response.dig('result', 'code') != 0 # invalid payload, wrong loginId, etc.
21
+ @error_message = json_response.dig('result', 'errorDetails') || 'Unknown Error'
22
+ end
23
+
24
+ update_payment_source
25
+
26
+ self
27
+ end
28
+
29
+ def failed?
30
+ @error_message.present?
31
+ end
32
+
33
+ def json_response
34
+ @json_response ||= parse_json(@response.body)
35
+ end
36
+
37
+ # KHQR/Card secure web redirect form fields (auto-POSTed to paymentPage.jsp).
38
+ def gateway_params
39
+ return {} if failed?
40
+
41
+ {
42
+ merchantID: merchant_id,
43
+ sessionid: json_response.dig('result', 'sessionid'),
44
+ paymenttokenid: payment_token_id,
45
+ description: Base::PURCHASE_DESC,
46
+ expirytime: expiry_time,
47
+ amount: amount,
48
+ quantity: '1',
49
+ item: '1',
50
+ invoiceid: order_number,
51
+ currencytype: purchase_currency,
52
+ transactionID: payment_number,
53
+ successUrlToReturn: continue_success_url,
54
+ errorUrl: continue_success_url
55
+ }
56
+ end
57
+
58
+ # Deep Link redirect target.
59
+ def deeplink_url
60
+ return nil if failed?
61
+
62
+ json_response.dig('result', 'deeplinkUrl')
63
+ end
64
+
65
+ private
66
+
67
+ def open_session
68
+ conn = Faraday::Connection.new
69
+
70
+ conn.post(open_session_url) do |request|
71
+ request.headers['Content-Type'] = Base::CONTENT_TYPE_JSON
72
+ request.body = open_session_payload.to_json
73
+ end
74
+ end
75
+
76
+ def open_session_payload
77
+ base = {
78
+ password: password,
79
+ loginId: login_id,
80
+ merchantID: merchant_id,
81
+ hash: open_session_hash,
82
+ xpayTransaction: xpay_transaction_base
83
+ }
84
+
85
+ if payment_method.deeplink?
86
+ base[:xpayTransaction].merge!(oprDevice: opr_device, callBackUrl: callback_url)
87
+ else
88
+ base[:xpayTransaction][:paymentCard] = payment_method.payment_card_value
89
+ end
90
+
91
+ base
92
+ end
93
+
94
+ def xpay_transaction_base
95
+ {
96
+ txid: payment_number,
97
+ invoiceid: order_number,
98
+ purchaseAmount: amount,
99
+ purchaseCurrency: purchase_currency,
100
+ purchaseDate: purchase_date,
101
+ purchaseDesc: Base::PURCHASE_DESC,
102
+ item: '1',
103
+ quantity: '1',
104
+ expiryTime: expiry_time
105
+ }
106
+ end
107
+
108
+ def payment_token_id
109
+ json_response.dig('result', 'xTran', 'paymentTokenid')
110
+ end
111
+
112
+ # Persist the paymentTokenid so check_transaction / the bank callback can
113
+ # resolve the payment later. Wrap only the write in the writing role, since
114
+ # the checkout form partial runs on a GET request (replica connection).
115
+ def update_payment_source
116
+ return if failed?
117
+
118
+ ActiveRecord::Base.connected_to(role: :writing) do
119
+ @payment.source.update_column(:transaction_id, payment_token_id)
120
+ end
121
+ end
122
+ end
123
+ end
124
+ end
@@ -0,0 +1,80 @@
1
+ require 'faraday'
2
+
3
+ module Vpago
4
+ module AcledaV2
5
+ class TransactionStatus < Base
6
+ # Terminal failure indicators in result.errorDetails. The PDFs only document
7
+ # code 0 / "SUCCESS"; decline/expiry wording is conservative and should be
8
+ # confirmed against real responses during UAT. Anything not success and not
9
+ # terminal is treated as pending so the poller keeps trying.
10
+ FAILED_ERROR_DETAILS = %w[EXPIRED DECLINED CANCELLED FAILED REJECTED].freeze
11
+
12
+ def call
13
+ @response = check_remote_status
14
+ self
15
+ end
16
+
17
+ def json_response
18
+ @json_response ||= parse_json(@response.body)
19
+ end
20
+
21
+ def result
22
+ json_response['result'] || {}
23
+ end
24
+
25
+ def result_code
26
+ result['code']
27
+ end
28
+
29
+ def error_message
30
+ result['errorDetails']
31
+ end
32
+
33
+ def success?
34
+ http_success? && result_code == 0 && error_message == 'SUCCESS'
35
+ end
36
+
37
+ def failed?
38
+ return false unless http_success?
39
+
40
+ FAILED_ERROR_DETAILS.include?(error_message.to_s.upcase)
41
+ end
42
+
43
+ def pending?
44
+ !success? && !failed?
45
+ end
46
+
47
+ private
48
+
49
+ def http_success?
50
+ @response&.status == 200
51
+ end
52
+
53
+ def token
54
+ @payment.source&.transaction_id
55
+ end
56
+
57
+ def check_remote_status
58
+ conn = Faraday::Connection.new
59
+
60
+ conn.post(get_txn_status_url) do |request|
61
+ request.headers['Content-Type'] = Base::CONTENT_TYPE_JSON
62
+ request.body = payload.to_json
63
+ end
64
+ end
65
+
66
+ # NOTE: getTxnStatus uses merchantId (lowercase d) and an extra merchantName
67
+ # field. merchantName is NOT part of the HMAC message.
68
+ def payload
69
+ {
70
+ loginId: login_id,
71
+ password: password,
72
+ merchantId: merchant_id,
73
+ merchantName: merchant_name,
74
+ hash: txn_status_hash(token),
75
+ paymentTokenid: token
76
+ }
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,59 @@
1
+ # lib/vpago_logger.rb
2
+ module VpagoLogger
3
+ def self.log(label:, data: nil)
4
+ message = { label: label, data: safe_serialize(data) }
5
+ start_time = Time.current
6
+ Rails.logger.info(message.to_json)
7
+
8
+ return unless block_given?
9
+
10
+ # Capture the block's return value and return it to preserve existing behavior for callers expecting that value.
11
+ block_result = yield
12
+
13
+ message[:start_time] = start_time.iso8601(3)
14
+ message[:duration_ms] = (Time.current - start_time) * 1000
15
+ message[:result] = safe_serialize(block_result)
16
+ Rails.logger.info(message.to_json)
17
+ block_result
18
+ end
19
+
20
+ def self.error(label:, data: nil)
21
+ message = {
22
+ label: label,
23
+ data: safe_serialize(data)
24
+ }
25
+
26
+ Rails.logger.error(message.to_json)
27
+ end
28
+
29
+ # Safely serializes objects for JSON logging.
30
+ #
31
+ # @param obj [Object] The object to serialize
32
+ # @param depth [Integer] Internal parameter tracking recursion depth (max: 50)
33
+ # @return [Object] A JSON-safe representation of the input object
34
+ def self.safe_serialize(obj, depth: 0)
35
+ return '[Max Depth Exceeded]' if depth > 50
36
+
37
+ if obj.is_a?(Hash)
38
+ obj.each_with_object({}) do |(k, v), memo|
39
+ memo[safe_serialize(k, depth: depth + 1)] = safe_serialize(v, depth: depth + 1)
40
+ end
41
+ elsif obj.is_a?(Array)
42
+ obj.map { |item| safe_serialize(item, depth: depth + 1) }
43
+ elsif obj.is_a?(Date)
44
+ obj.iso8601
45
+ elsif obj.is_a?(Time) || obj.is_a?(DateTime) || obj.is_a?(ActiveSupport::TimeWithZone)
46
+ obj.iso8601(3)
47
+ elsif obj.is_a?(ActiveJob::Base)
48
+ {
49
+ job_class: obj.class.name,
50
+ job_id: obj.job_id,
51
+ arguments: safe_serialize(obj.arguments, depth: depth + 1)
52
+ }
53
+ elsif obj.respond_to?(:id)
54
+ { class: obj.class.name, id: obj.id }
55
+ else
56
+ obj.to_s
57
+ end
58
+ end
59
+ end
@@ -1,5 +1,5 @@
1
1
  {
2
- "systemParams": "linux-x64-115",
2
+ "systemParams": "linux-x64-127",
3
3
  "modulesFolders": [
4
4
  "node_modules"
5
5
  ],
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: spree_vpago
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.3.2
4
+ version: 2.3.5.pre.pre.pre.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - You
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-05-07 00:00:00.000000000 Z
11
+ date: 2026-07-03 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: faraday
@@ -192,6 +192,7 @@ files:
192
192
  - app/models/concerns/vpago/payoutable.rb
193
193
  - app/models/spree/gateway/acleda.rb
194
194
  - app/models/spree/gateway/acleda_mobile.rb
195
+ - app/models/spree/gateway/acleda_v2.rb
195
196
  - app/models/spree/gateway/payway.rb
196
197
  - app/models/spree/gateway/payway_v2.rb
197
198
  - app/models/spree/gateway/true_money.rb
@@ -280,6 +281,7 @@ files:
280
281
  - app/views/spree/admin/payments/source_forms/_payway_v2.html.erb
281
282
  - app/views/spree/admin/payments/source_views/_acleda.html.erb
282
283
  - app/views/spree/admin/payments/source_views/_acleda_mobile.html.erb
284
+ - app/views/spree/admin/payments/source_views/_acleda_v2.html.erb
283
285
  - app/views/spree/admin/payments/source_views/_payment_payway.html.erb
284
286
  - app/views/spree/admin/payments/source_views/_payway_v2.html.erb
285
287
  - app/views/spree/admin/payments/source_views/_true_money.html.erb
@@ -309,6 +311,7 @@ files:
309
311
  - app/views/spree/checkout/payment/_acleda.html.erb
310
312
  - app/views/spree/checkout/payment/_acleda_checkout_form.html.erb
311
313
  - app/views/spree/checkout/payment/_acleda_mobile.html.erb
314
+ - app/views/spree/checkout/payment/_acleda_v2_checkout_form.html.erb
312
315
  - app/views/spree/checkout/payment/_payment_payway.html.erb
313
316
  - app/views/spree/checkout/payment/_payway_loader.html.erb
314
317
  - app/views/spree/checkout/payment/_payway_root.html.erb
@@ -331,6 +334,7 @@ files:
331
334
  - app/views/spree/payway_v2_card_popups/show.html.erb
332
335
  - app/views/spree/vpago_payments/_transaction_checker.html.erb
333
336
  - app/views/spree/vpago_payments/checkout.html.erb
337
+ - app/views/spree/vpago_payments/forms/spree/gateway/_acleda_v2.html.erb
334
338
  - app/views/spree/vpago_payments/forms/spree/gateway/_payway_v2.html.erb
335
339
  - app/views/spree/vpago_payments/forms/spree/gateway/_true_money.html.erb
336
340
  - app/views/spree/vpago_payments/forms/spree/gateway/_vattanac.html.erb
@@ -388,6 +392,9 @@ files:
388
392
  - lib/vpago/acleda_mobile/payment_request_updater.rb
389
393
  - lib/vpago/acleda_mobile/payment_retriever.rb
390
394
  - lib/vpago/acleda_mobile/transaction_status.rb
395
+ - lib/vpago/acleda_v2/base.rb
396
+ - lib/vpago/acleda_v2/checkout.rb
397
+ - lib/vpago/acleda_v2/transaction_status.rb
391
398
  - lib/vpago/payment_amount_calculator.rb
392
399
  - lib/vpago/payment_request_updater.rb
393
400
  - lib/vpago/payment_status_marker.rb
@@ -421,6 +428,7 @@ files:
421
428
  - lib/vpago/wing_sdk/payment_retriever.rb
422
429
  - lib/vpago/wing_sdk/transaction_status_checker.rb
423
430
  - lib/vpago/wing_sdk/transaction_status_response.rb
431
+ - lib/vpago_logger.rb
424
432
  - node_modules/.yarn-integrity
425
433
  - package-lock.json
426
434
  - package.json
@@ -442,9 +450,9 @@ required_ruby_version: !ruby/object:Gem::Requirement
442
450
  version: 3.2.0
443
451
  required_rubygems_version: !ruby/object:Gem::Requirement
444
452
  requirements:
445
- - - ">="
453
+ - - ">"
446
454
  - !ruby/object:Gem::Version
447
- version: '0'
455
+ version: 1.3.1
448
456
  requirements:
449
457
  - none
450
458
  rubygems_version: 3.4.1