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
@@ -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>
@@ -1,5 +1,5 @@
1
1
  <%= render 'spree/admin/payments/source_views/vpago_payment_tmpl',
2
2
  payment: payment,
3
- check_status_url: '',
4
- heal_payment_url: '',
3
+ check_status_url: admin_payment_acleda_v2_querier_path(payment),
4
+ heal_payment_url: admin_payment_acleda_v2_checker_path(payment),
5
5
  manual_update_payment_url: '' %>
@@ -1,6 +1,6 @@
1
1
  <%= render 'spree/admin/payments/source_views/vpago_payment_tmpl',
2
- payment: payment,
3
- check_status_url: '',
4
- heal_payment_url: '',
2
+ payment: payment,
3
+ check_status_url: admin_payment_vattanac_querier_path(payment),
4
+ heal_payment_url: admin_payment_vattanac_checker_path(payment),
5
5
  manual_update_payment_url: ''
6
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,7 @@
1
+ <script>
2
+ // processing redirects here without rendering once the order is complete, so its
3
+ // Firebase listener never posts this. success only renders for completed orders.
4
+ if (window.parent !== window) {
5
+ window.parent.postMessage("order_is_completed", "*");
6
+ }
7
+ </script>
@@ -2,92 +2,93 @@
2
2
  opr_device = request.user_agent.to_s.downcase.match?(/iphone|ipad|ipod/) ? 'ios' : 'android'
3
3
  checkout = ::Vpago::AcledaMiniApp::Checkout.new(@payment, { platform: 'app', opr_device: opr_device })
4
4
 
5
- begin
6
- checkout.call
7
- rescue => e
8
- Rails.logger.error("Acleda Mini App checkout.call failed: #{e.message}")
9
- end
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?
10
16
  %>
11
17
 
12
- <p id="unsupported-message"
18
+ <p id="acleda-message"
13
19
  style="margin-top: 10px; font-weight: bold; color: red; display: none;">
14
20
  </p>
15
21
 
16
- <% if checkout.failed? %>
17
- <script>
18
- console.error("[Vpago] Acleda Mini App checkout error: <%= j checkout.error_message.to_s %>");
19
- document.addEventListener("DOMContentLoaded", () => {
20
- const loadingContainer = document.getElementById('vpago_loading_container');
21
- if (loadingContainer) {
22
- loadingContainer.textContent = "We couldn't start your payment. Please go back and try again.";
23
- }
24
- });
25
- </script>
26
- <% else %>
27
- <script>
28
- // ACLEDA JS bridge (unified iOS + Android). Defined before use so it is
29
- // available when the native layer injects its handlers.
30
- window.acledaJSBridge = window.acledaJSBridge || {
31
- call: function (action, data = {}) {
32
- const payload = { action, data };
33
-
34
- // iOS WKWebView
35
- if (
36
- window.webkit &&
37
- window.webkit.messageHandlers &&
38
- window.webkit.messageHandlers.acledaIOSJSBridge
39
- ) {
40
- window.webkit.messageHandlers.acledaIOSJSBridge.postMessage(payload);
41
- return true;
42
- }
43
-
44
- // Android WebView
45
- if (
46
- window.acledaAndroidJSBridge &&
47
- typeof window.acledaAndroidJSBridge.postMessage === "function"
48
- ) {
49
- window.acledaAndroidJSBridge.postMessage(JSON.stringify(payload));
50
- return true;
51
- }
52
-
53
- console.warn("[acledaJSBridge] Native bridge not found");
54
- return false;
55
- }
56
- };
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
+ }
57
34
 
58
- function showRedirectContainer(buttonLabel, onClick) {
59
- const loadingContainer = document.getElementById('vpago_loading_container');
60
- const redirectContainer = document.getElementById('vpago_redirect_container');
61
- const redirectButton = document.getElementById('vpago_redirect_button');
35
+ function showError(detail) {
36
+ console.error('[Acleda] ' + detail);
62
37
 
63
- redirectButton.textContent = buttonLabel;
64
- redirectButton.addEventListener('click', onClick);
38
+ const message = document.getElementById('acleda-message');
39
+ message.innerText = "We couldn't start your ACLEDA payment.";
40
+ message.style.display = 'block';
41
+ }
65
42
 
66
- loadingContainer.style.display = 'none';
67
- redirectContainer.style.removeProperty('display');
43
+ document.addEventListener('DOMContentLoaded', () => {
44
+ const acledaDeeplink = "<%= raw acleda_deeplink %>";
45
+
46
+ if (!acledaDeeplink) {
47
+ showError('openSessionV2 returned no deeplinkUrl');
48
+ return;
68
49
  }
69
50
 
70
- document.addEventListener("DOMContentLoaded", () => {
71
- const acledaDeeplink = "<%= raw checkout.acleda_deeplink %>";
72
- const processingUrl = "<%= raw @payment.processing_url %>";
73
-
74
- // Native -> JS after payment completes. Primary completion signal; the
75
- // transaction checker polling is the fallback.
76
- window.onPaymentResult = function (result) {
77
- console.log("[acledaJSBridge] Payment result:", result);
78
- // result.batchId, result.status = "success" | "failed" | "cancelled"
79
- window.location.href = processingUrl;
80
- };
81
-
82
- const startPayment = () => window.acledaJSBridge.call("startPayment", { acleda_deeplink: acledaDeeplink });
83
-
84
- if (startPayment()) {
85
- showRedirectContainer("Continue", startPayment);
86
- } else {
87
- const unsupported = document.getElementById('unsupported-message');
88
- unsupported.style.display = 'block';
89
- unsupported.innerText = 'Unsupported environment';
90
- }
91
- });
92
- </script>
93
- <% end %>
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>
@@ -0,0 +1,88 @@
1
+ <% content_for :head do %>
2
+ <%= javascript_include_tag 'vpago/wing_mini_app/web_jsbridge.bundle', 'data-turbo-track': 'reload' %>
3
+ <% end %>
4
+
5
+ <% checkout = ::Vpago::WingMiniApp::Checkout.new(@payment) %>
6
+
7
+ <p id="wing-message"
8
+ style="margin-top: 10px; font-weight: bold; color: red; display: none;">
9
+ </p>
10
+
11
+ <script>
12
+ function showRedirectContainer(buttonLabel, onClick) {
13
+ const loadingContainer = document.getElementById('vpago_loading_container');
14
+ const redirectContainer = document.getElementById('vpago_redirect_container');
15
+ const redirectButton = document.getElementById('vpago_redirect_button');
16
+
17
+ redirectButton.textContent = buttonLabel;
18
+ redirectButton.addEventListener('click', onClick);
19
+
20
+ loadingContainer.style.display = 'none';
21
+ redirectContainer.style.removeProperty('display');
22
+ }
23
+
24
+ function showError(detail) {
25
+ console.error('[Wing] ' + detail);
26
+
27
+ const message = document.getElementById('wing-message');
28
+ message.innerText = "We couldn't start your Wing payment.";
29
+ message.style.display = 'block';
30
+ }
31
+
32
+ document.addEventListener('DOMContentLoaded', () => {
33
+ const processingUrl = "<%= raw @payment.processing_url %>";
34
+ const bridge = window.WebJSBridge && window.WebJSBridge.$bridge;
35
+
36
+ if (!bridge || !bridge.isAvailable()) {
37
+ showError('This page only works inside the Wing app.');
38
+ return;
39
+ }
40
+
41
+ // Whichever signal lands first wins; the rest are then noise from the same
42
+ // payment. Also keeps the fallback button from replacing a page already leaving.
43
+ let handedOff = false;
44
+
45
+ const goToProcessing = () => {
46
+ if (handedOff) return;
47
+ handedOff = true;
48
+
49
+ window.location.replace(processingUrl);
50
+ };
51
+
52
+ const startPayment = () => {
53
+ // Screen only, both here and in the relay below. Settlement stays server to
54
+ // server, where WebhookVerifier checks Wing's webhook before anything is
55
+ // captured, and the processing page keeps polling until that lands — so it is
56
+ // the safe destination even for a payment that failed.
57
+ bridge
58
+ .callHandler('doPayment', <%= raw checkout.signed_payload.to_json %>)
59
+ .then(() => goToProcessing())
60
+ .catch((error) => showError('doPayment call failed: ' + error.message));
61
+ };
62
+
63
+ // Wing delivers native -> web calls by evaluating JS against the main frame,
64
+ // which is our web app embedding this page, not this document — so in a framed
65
+ // checkout it is mini_app/wing_bridge.js over there that hears getStatus and
66
+ // posts the bare code on. Sender identity, not origin: this page and the web app
67
+ // are deliberately on different hosts.
68
+ window.addEventListener('message', (event) => {
69
+ if (event.source !== window.parent) return;
70
+ if (!event.data || event.data.code !== 'wing_payment_status') return;
71
+
72
+ goToProcessing();
73
+ });
74
+
75
+ // The same signal for an unframed checkout, where this document *is* the main
76
+ // frame and Wing reaches it directly.
77
+ bridge.registerHandler('getStatus', () => goToProcessing());
78
+
79
+ startPayment();
80
+
81
+ setTimeout(() => {
82
+ if (document.visibilityState === 'hidden') return;
83
+ if (handedOff) return;
84
+
85
+ showRedirectContainer('Pay with Wing', startPayment);
86
+ }, 1000);
87
+ });
88
+ </script>
@@ -0,0 +1,15 @@
1
+ <script>
2
+ // Wing settles only on its webhook — #purchase reads nothing but the
3
+ // transaction_response PaymentFinder stores on callback, and there is no status
4
+ // API to ask instead. Processing before it lands paints "Payment process failed"
5
+ // over a payment already debited, so skip it; the webhook enqueues
6
+ // PaymentProcessorJob itself.
7
+ //
8
+ // The replacement holds because the real definition is a classic script in the
9
+ // layout's head while vpago_payment_controller only connects at domReady, leaving
10
+ // this inline script in between. Load either of those differently and it stops —
11
+ // the log is how you would notice.
12
+ window.requestProcessPayment = function () {
13
+ return Promise.resolve();
14
+ };
15
+ </script>
@@ -1,3 +1,5 @@
1
1
  <h1>
2
2
  Success
3
3
  </h1>
4
+
5
+ <%= render_success_script %>
@@ -35,5 +35,6 @@ en:
35
35
  failed_require_updated_reason: "You must provide update reason"
36
36
  payment_found_with_result: "Found payment with result: %{result}"
37
37
  payment_not_found_with_error: "Payment not found with error: %{error}"
38
+ gateway_timeout: "The payment gateway did not respond in time. Please try again shortly."
38
39
  query_status: "Query payment status"
39
40
  confirm_manual_update: "Are you sure to update this order to complete"
@@ -31,5 +31,6 @@ spree:
31
31
  failed_require_updated_reason: "អ្នកត្រូវតែផ្តល់ហេតុផល"
32
32
  payment_found_with_result: "រកឃើញការទូទាត់ជាមួយលទ្ធផល %{result}"
33
33
  payment_not_found_with_error: "ការទូទាត់មិនត្រូវបានរកឃើញដោយកំហុស: %{error}"
34
+ gateway_timeout: "ផ្នែកទូទាត់មិនបានឆ្លើយតបទាន់ពេលទេ។ សូមព្យាយាមម្តងទៀតក្នុងពេលឆាប់ៗនេះ។"
34
35
  query_status: "សួរស្ថានភាពបង់ប្រាក់"
35
36
  confirm_manual_update: "តើអ្នកប្រាកដជាធ្វើបច្ចុប្បន្នភាពការបញ្ជាទិញនេះដើម្បីបញ្ចប់"
data/config/routes.rb CHANGED
@@ -17,11 +17,18 @@ Spree::Core::Engine.add_routes do
17
17
  resources :transactions, only: [:show]
18
18
  end
19
19
 
20
- # ACLEDA Mini App session init (ACLEDA Mini App Integration Spec, Step 01).
21
- # ACLEDA calls POST /miniapp/acleda with { phone, first_name, last_name }
22
- # and receives a session-based miniAppUrl.
23
- namespace :miniapp do
20
+ namespace :mini_app do
21
+ # ACLEDA calls POST /mini_app/acleda with { phone, first_name, last_name }
22
+ # and receives a session-based miniAppUrl.
24
23
  resources :acleda, only: [:create], controller: 'acledas'
24
+
25
+ # Wing Mini App WebView entry. Wing opens GET /mini_app/wing cold and the page
26
+ # itself authenticates via getProfile.
27
+ resource :wing, only: %i[show create], controller: 'wings' do
28
+ # Wing's settlement webhook: #callback verifies the payment via PaymentFinder and
29
+ # queues PaymentProcessorJob to complete the order unless it is already paid.
30
+ post :callback, on: :collection
31
+ end
25
32
  end
26
33
 
27
34
  resource :vpago_payments do
@@ -80,6 +87,12 @@ Spree::Core::Engine.add_routes do
80
87
  resources :payment_payway_checkers
81
88
  resources :payment_payway_markers
82
89
 
90
+ resources :payment_acleda_v2_queriers, only: [:show]
91
+ resources :payment_acleda_v2_checkers, only: [:update]
92
+
93
+ resources :payment_vattanac_queriers, only: [:show]
94
+ resources :payment_vattanac_checkers, only: [:update]
95
+
83
96
  resources :products do
84
97
  resources :payout_profile_products
85
98
  end
@@ -26,6 +26,7 @@ module SpreeVpago
26
26
  Spree::Gateway::AcledaMiniApp,
27
27
  Spree::Gateway::Vattanac,
28
28
  Spree::Gateway::VattanacMiniApp,
29
+ Spree::Gateway::WingMiniApp,
29
30
  Spree::Gateway::TrueMoney,
30
31
  Spree::PaymentMethod::CashOn
31
32
  )
@@ -1,7 +1,7 @@
1
1
  module SpreeVpago
2
2
  module_function
3
3
 
4
- VERSION = '2.4.0-pre2'.freeze
4
+ VERSION = '2.4.0-pre4'.freeze
5
5
 
6
6
  def version
7
7
  Gem::Version.new VERSION
@@ -1,19 +1,15 @@
1
1
  module Vpago
2
2
  module AcledaMiniApp
3
- # ACLEDA Mini App checkout. Reuses ACLEDA V2's openSessionV2 flow to obtain a
4
- # deeplink, which is passed to the ACLEDA super-app via the acledaJSBridge
5
- # (startPayment) instead of redirecting the page.
3
+ # ACLEDA Mini App checkout. Reuses ACLEDA V2's openSessionV2 flow to obtain the deeplink
4
+ # the payment page opens, instead of redirecting the page to ACLEDA.
6
5
  class Checkout < Vpago::AcledaV2::Checkout
7
- # The mini app always uses the deeplink + JS bridge, regardless of the
8
- # gateway's configured mode.
6
+ # The mini app always pays by deeplink, regardless of the gateway's configured mode.
9
7
  def deeplink?
10
8
  true
11
9
  end
12
10
 
13
- # The deeplink string handed to
14
- # acledaJSBridge.call('startPayment', { acleda_deeplink: ... })
15
- # Ensures openSessionV2 has run (persisting paymentTokenid for later
16
- # getTxnStatus verification) before returning the deeplinkUrl.
11
+ # The deeplink _acleda_mini_app.html.erb opens. Ensures openSessionV2 has run
12
+ # (persisting paymentTokenid for the later getTxnStatus check) before returning it.
17
13
  def acleda_deeplink
18
14
  call if @response.nil?
19
15
  deeplink_url
@@ -0,0 +1,35 @@
1
+ module Vpago
2
+ module AcledaMiniApp
3
+ # Verifies the HMAC-SHA256 signature ACLEDA sends via the X-Acleda-Signature
4
+ # header on the Session Initialization request (POST /mini_app/acleda), before
5
+ # we look up or create the user. Mirrors AcledaMobile::CallbackValidator's
6
+ # keyed-HMAC pattern, but out-of-band in a header instead of the JSON body.
7
+ class SessionHashValidator
8
+ def initialize(options)
9
+ @options = options
10
+ end
11
+
12
+ def call
13
+ valid?
14
+ end
15
+
16
+ # The key ACLEDA signs with lives only on their Spree::OauthApplication
17
+ # (MiniAppProvider::AcledaBank#secret_key), so it can be rotated from the admin
18
+ def secret_key
19
+ @options[:secret_key].presence
20
+ end
21
+
22
+ def valid?
23
+ return false if secret_key.blank? || @options[:hash].blank?
24
+
25
+ ActiveSupport::SecurityUtils.secure_compare(computed_hash, @options[:hash].to_s)
26
+ end
27
+
28
+ def computed_hash
29
+ message = "#{@options[:phone]}#{@options[:first_name]}#{@options[:last_name]}"
30
+
31
+ OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new('sha256'), secret_key, message)
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,54 @@
1
+ module Vpago
2
+ module AcledaV2
3
+ class PaymentRequestUpdater < ::Vpago::PaymentRequestUpdater
4
+ def call
5
+ return if @payment.order.paid?
6
+
7
+ if items_eligible?
8
+ process_payment_status
9
+ else
10
+ mark_items_as_ineligible
11
+ end
12
+ end
13
+
14
+ private
15
+
16
+ def process_payment_status
17
+ checker = @payment.payment_method.check_transaction(@payment)
18
+
19
+ if checker.success?
20
+ mark_payment_as_success
21
+ elsif checker.failed? && !ignore_on_failed?
22
+ mark_payment_as_failed(checker.error_message)
23
+ end
24
+ rescue Faraday::TimeoutError, Faraday::ConnectionFailed => e
25
+ VpagoLogger.error(
26
+ label: 'Vpago::AcledaV2::PaymentRequestUpdater#process_payment_status gateway_timeout',
27
+ data: { payment_number: @payment.number, error_class: e.class.name, error_message: e.message }
28
+ )
29
+ end
30
+
31
+ def items_eligible?
32
+ @payment&.order&.line_items&.all?(&:sufficient_stock?) == true # rubocop:disable Style/SafeNavigationChainLength
33
+ end
34
+
35
+ def mark_items_as_ineligible
36
+ @error_message = 'Items are not eligible due to insufficient stock'
37
+ marker_options = @options.merge(status: false, description: @error_message)
38
+ ::Vpago::PaymentStatusMarker.new(@payment, marker_options).call
39
+ end
40
+
41
+ def mark_payment_as_success
42
+ @error_message = nil
43
+ marker_options = @options.merge(status: true, description: nil)
44
+ ::Vpago::PaymentStatusMarker.new(@payment, marker_options).call
45
+ end
46
+
47
+ def mark_payment_as_failed(error_message)
48
+ @error_message = error_message
49
+ marker_options = @options.merge(status: false, description: @error_message)
50
+ ::Vpago::PaymentStatusMarker.new(@payment, marker_options).call
51
+ end
52
+ end
53
+ end
54
+ end