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.
- checksums.yaml +4 -4
- data/Gemfile.lock +1 -1
- data/app/assets/config/spree_vpago_manifest.js +2 -0
- data/app/assets/javascripts/vpago/wing_mini_app/login.js +75 -0
- data/app/assets/javascripts/vpago/wing_mini_app/web_jsbridge.bundle.js +1507 -0
- data/app/controllers/concerns/spree/mini_app_concern.rb +25 -0
- data/app/controllers/spree/admin/payment_acleda_v2_base_controller.rb +21 -0
- data/app/controllers/spree/admin/payment_acleda_v2_checkers_controller.rb +26 -0
- data/app/controllers/spree/admin/payment_acleda_v2_queriers_controller.rb +38 -0
- data/app/controllers/spree/admin/payment_payway_queriers_controller.rb +7 -0
- data/app/controllers/spree/admin/payment_vattanac_base_controller.rb +21 -0
- data/app/controllers/spree/admin/payment_vattanac_checkers_controller.rb +26 -0
- data/app/controllers/spree/admin/payment_vattanac_queriers_controller.rb +38 -0
- data/app/controllers/spree/{miniapp → mini_app}/acledas_controller.rb +12 -6
- data/app/controllers/spree/mini_app/wings_controller.rb +58 -0
- data/app/controllers/spree/vpago_payments_controller.rb +15 -3
- data/app/helpers/vpago/vpago_payments_helper.rb +6 -0
- data/app/models/spree/gateway/acleda_mini_app.rb +4 -6
- data/app/models/spree/gateway/wing_mini_app.rb +58 -0
- data/app/models/vpago/payment_decorator.rb +4 -0
- data/app/models/vpago/payment_method_decorator.rb +14 -0
- data/app/serializers/spree/v2/storefront/payment_method_serializer_decorator.rb +6 -0
- data/app/services/vpago/acleda_mini_app/session/create.rb +45 -23
- data/app/services/vpago/payment_finder.rb +11 -0
- data/app/services/vpago/wing_mini_app/session/create.rb +55 -0
- data/app/views/layouts/wing_mini_app.html.erb +20 -0
- data/app/views/spree/admin/payments/source_views/_acleda_v2.html.erb +2 -2
- data/app/views/spree/admin/payments/source_views/_vattanac.html.erb +3 -3
- data/app/views/spree/admin/payments/source_views/_wing_mini_app.html.erb +6 -0
- data/app/views/spree/mini_app/wings/show.html.erb +44 -0
- data/app/views/spree/vpago_payments/_success_script.html.erb +7 -0
- data/app/views/spree/vpago_payments/forms/spree/gateway/_acleda_mini_app.html.erb +80 -79
- data/app/views/spree/vpago_payments/forms/spree/gateway/_wing_mini_app.html.erb +88 -0
- data/app/views/spree/vpago_payments/processing_scripts/spree/gateway/_wing_mini_app.html.erb +15 -0
- data/app/views/spree/vpago_payments/success.html.erb +2 -0
- data/config/locales/en.yml +1 -0
- data/config/locales/km.yml +1 -0
- data/config/routes.rb +17 -4
- data/lib/spree_vpago/engine.rb +1 -0
- data/lib/spree_vpago/version.rb +1 -1
- data/lib/vpago/acleda_mini_app/checkout.rb +5 -9
- data/lib/vpago/acleda_mini_app/session_hash_validator.rb +35 -0
- data/lib/vpago/acleda_v2/payment_request_updater.rb +54 -0
- data/lib/vpago/http_timeouts.rb +6 -0
- data/lib/vpago/payway_v2/payment_request_updater.rb +5 -0
- data/lib/vpago/payway_v2/transaction_status.rb +2 -0
- data/lib/vpago/payway_v2/transaction_status_v2.rb +2 -0
- data/lib/vpago/true_money/base.rb +1 -1
- data/lib/vpago/true_money/transaction_status.rb +1 -1
- data/lib/vpago/vattanac/base.rb +3 -2
- data/lib/vpago/vattanac/payment_request_updater.rb +54 -0
- data/lib/vpago/vattanac/transaction_status.rb +17 -1
- data/lib/vpago/wing_mini_app/checkout.rb +60 -0
- data/lib/vpago/wing_mini_app/webhook_verifier.rb +78 -0
- 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,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…</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
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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="
|
|
18
|
+
<p id="acleda-message"
|
|
13
19
|
style="margin-top: 10px; font-weight: bold; color: red; display: none;">
|
|
14
20
|
</p>
|
|
15
21
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
document.
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
59
|
-
|
|
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
|
-
|
|
64
|
-
|
|
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
|
-
|
|
67
|
-
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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>
|
data/config/locales/en.yml
CHANGED
|
@@ -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"
|
data/config/locales/km.yml
CHANGED
|
@@ -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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
data/lib/spree_vpago/engine.rb
CHANGED
data/lib/spree_vpago/version.rb
CHANGED
|
@@ -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
|
|
4
|
-
#
|
|
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
|
|
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
|
|
14
|
-
#
|
|
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
|