feat(payment_link): add status page for payment link (#3213)

Co-authored-by: hyperswitch-bot[bot] <148525504+hyperswitch-bot[bot]@users.noreply.github.com>
Co-authored-by: Kashif <mohammed.kashif@juspay.in>
Co-authored-by: Kashif <kashif.dev@protonmail.com>
Co-authored-by: hrithikeshvm <hrithikeshmylatty@gmail.com>
Co-authored-by: hrithikeshvm <vmhrithikesh@gmail.com>
Co-authored-by: Sahkal Poddar <sahkalpoddar@Sahkals-MacBook-Air.local>
This commit is contained in:
Sahkal Poddar
2024-01-10 13:52:37 +05:30
committed by GitHub
parent 8decbea7e5
commit 50e4d797da
10 changed files with 642 additions and 133 deletions

View File

@ -473,7 +473,7 @@ apple_pay_merchant_cert = "APPLE_PAY_MERCHNAT_CERTIFICATE" #Merchant Cer
apple_pay_merchant_cert_key = "APPLE_PAY_MERCHNAT_CERTIFICATE_KEY" #Private key generate by RSA:2048 algorithm apple_pay_merchant_cert_key = "APPLE_PAY_MERCHNAT_CERTIFICATE_KEY" #Private key generate by RSA:2048 algorithm
[payment_link] [payment_link]
sdk_url = "http://localhost:9090/dist/HyperLoader.js" sdk_url = "http://localhost:9090/0.16.7/v0/HyperLoader.js"
[payment_method_auth] [payment_method_auth]
redis_expiry = 900 redis_expiry = 900

View File

@ -494,7 +494,7 @@ apple_pay_merchant_cert = "APPLE_PAY_MERCHNAT_CERTIFICATE"
apple_pay_merchant_cert_key = "APPLE_PAY_MERCHNAT_CERTIFICATE_KEY" apple_pay_merchant_cert_key = "APPLE_PAY_MERCHNAT_CERTIFICATE_KEY"
[payment_link] [payment_link]
sdk_url = "http://localhost:9090/dist/HyperLoader.js" sdk_url = "http://localhost:9050/HyperLoader.js"
[payment_method_auth] [payment_method_auth]
redis_expiry = 900 redis_expiry = 900

View File

@ -3358,6 +3358,13 @@ pub struct PaymentLinkInitiateRequest {
pub payment_id: String, pub payment_id: String,
} }
#[derive(Debug, serde::Serialize)]
#[serde(untagged)]
pub enum PaymentLinkData {
PaymentLinkDetails(PaymentLinkDetails),
PaymentLinkStatusDetails(PaymentLinkStatusDetails),
}
#[derive(Debug, serde::Serialize)] #[derive(Debug, serde::Serialize)]
pub struct PaymentLinkDetails { pub struct PaymentLinkDetails {
pub amount: String, pub amount: String,
@ -3376,6 +3383,21 @@ pub struct PaymentLinkDetails {
pub merchant_description: Option<String>, pub merchant_description: Option<String>,
} }
#[derive(Debug, serde::Serialize)]
pub struct PaymentLinkStatusDetails {
pub amount: String,
pub currency: api_enums::Currency,
pub payment_id: String,
pub merchant_logo: String,
pub merchant_name: String,
#[serde(with = "common_utils::custom_serde::iso8601")]
pub created: PrimitiveDateTime,
pub intent_status: api_enums::IntentStatus,
pub payment_link_status: PaymentLinkStatus,
pub error_code: Option<String>,
pub error_message: Option<String>,
}
#[derive(Clone, Debug, serde::Deserialize, ToSchema, serde::Serialize)] #[derive(Clone, Debug, serde::Deserialize, ToSchema, serde::Serialize)]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
@ -3451,7 +3473,8 @@ pub struct OrderDetailsWithStringAmount {
pub product_img_link: Option<String>, pub product_img_link: Option<String>,
} }
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, ToSchema)] #[derive(PartialEq, Debug, Clone, serde::Serialize, serde::Deserialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum PaymentLinkStatus { pub enum PaymentLinkStatus {
Active, Active,
Expired, Expired,

View File

@ -133,19 +133,34 @@ where
.map_into_boxed_body() .map_into_boxed_body()
} }
Ok(api::ApplicationResponse::PaymenkLinkForm(payment_link_data)) => { Ok(api::ApplicationResponse::PaymenkLinkForm(boxed_payment_link_data)) => {
match api::build_payment_link_html(*payment_link_data) { match *boxed_payment_link_data {
Ok(rendered_html) => api::http_response_html_data(rendered_html), api::PaymentLinkAction::PaymentLinkFormData(payment_link_data) => {
Err(_) => api::http_response_err( match api::build_payment_link_html(payment_link_data) {
r#"{ Ok(rendered_html) => api::http_response_html_data(rendered_html),
"error": { Err(_) => api::http_response_err(
"message": "Error while rendering payment link html page" r#"{
} "error": {
}"#, "message": "Error while rendering payment link html page"
), }
}"#,
),
}
}
api::PaymentLinkAction::PaymentLinkStatus(payment_link_data) => {
match api::get_payment_link_status(payment_link_data) {
Ok(rendered_html) => api::http_response_html_data(rendered_html),
Err(_) => api::http_response_err(
r#"{
"error": {
"message": "Error while rendering payment link status page"
}
}"#,
),
}
}
} }
} }
Err(error) => api::log_and_return_error_response(error), Err(error) => api::log_and_return_error_response(error),
}; };

View File

@ -13,7 +13,6 @@ use time::PrimitiveDateTime;
use super::errors::{self, RouterResult, StorageErrorExt}; use super::errors::{self, RouterResult, StorageErrorExt};
use crate::{ use crate::{
core::payments::helpers,
errors::RouterResponse, errors::RouterResponse,
routes::AppState, routes::AppState,
services, services,
@ -68,18 +67,6 @@ pub async fn intiate_payment_link_flow(
.get_required_value("payment_link_id") .get_required_value("payment_link_id")
.change_context(errors::ApiErrorResponse::PaymentLinkNotFound)?; .change_context(errors::ApiErrorResponse::PaymentLinkNotFound)?;
helpers::validate_payment_status_against_not_allowed_statuses(
&payment_intent.status,
&[
storage_enums::IntentStatus::Cancelled,
storage_enums::IntentStatus::Succeeded,
storage_enums::IntentStatus::Processing,
storage_enums::IntentStatus::RequiresCapture,
storage_enums::IntentStatus::RequiresMerchantAction,
],
"use payment link for",
)?;
let merchant_name_from_merchant_account = merchant_account let merchant_name_from_merchant_account = merchant_account
.merchant_name .merchant_name
.clone() .clone()
@ -101,7 +88,7 @@ pub async fn intiate_payment_link_flow(
} }
}; };
let return_url = if let Some(payment_create_return_url) = payment_intent.return_url { let return_url = if let Some(payment_create_return_url) = payment_intent.return_url.clone() {
payment_create_return_url payment_create_return_url
} else { } else {
merchant_account merchant_account
@ -114,23 +101,73 @@ pub async fn intiate_payment_link_flow(
let (pub_key, currency, client_secret) = validate_sdk_requirements( let (pub_key, currency, client_secret) = validate_sdk_requirements(
merchant_account.publishable_key, merchant_account.publishable_key,
payment_intent.currency, payment_intent.currency,
payment_intent.client_secret, payment_intent.client_secret.clone(),
)?; )?;
let order_details = validate_order_details(payment_intent.order_details, currency)?; let amount = currency
.to_currency_base_unit(payment_intent.amount)
.into_report()
.change_context(errors::ApiErrorResponse::CurrencyConversionFailed)?;
let order_details = validate_order_details(payment_intent.order_details.clone(), currency)?;
let session_expiry = payment_link.fulfilment_time.unwrap_or_else(|| { let session_expiry = payment_link.fulfilment_time.unwrap_or_else(|| {
common_utils::date_time::now() payment_intent
.created_at
.saturating_add(time::Duration::seconds(DEFAULT_SESSION_EXPIRY)) .saturating_add(time::Duration::seconds(DEFAULT_SESSION_EXPIRY))
}); });
// converting first letter of merchant name to upperCase // converting first letter of merchant name to upperCase
let merchant_name = capitalize_first_char(&payment_link_config.seller_name); let merchant_name = capitalize_first_char(&payment_link_config.seller_name);
let css_script = get_color_scheme_css(payment_link_config.clone());
let payment_link_status = check_payment_link_status(session_expiry);
if check_payment_link_invalid_conditions(
&payment_intent.status,
&[
storage_enums::IntentStatus::Cancelled,
storage_enums::IntentStatus::Failed,
storage_enums::IntentStatus::Processing,
storage_enums::IntentStatus::RequiresCapture,
storage_enums::IntentStatus::RequiresMerchantAction,
storage_enums::IntentStatus::Succeeded,
],
) || payment_link_status == api_models::payments::PaymentLinkStatus::Expired
{
let attempt_id = payment_intent.active_attempt.get_id().clone();
let payment_attempt = db
.find_payment_attempt_by_payment_id_merchant_id_attempt_id(
&payment_intent.payment_id,
&merchant_id,
&attempt_id.clone(),
merchant_account.storage_scheme,
)
.await
.to_not_found_response(errors::ApiErrorResponse::PaymentNotFound)?;
let payment_details = api_models::payments::PaymentLinkStatusDetails {
amount,
currency,
payment_id: payment_intent.payment_id,
merchant_name,
merchant_logo: payment_link_config.clone().logo,
created: payment_link.created_at,
intent_status: payment_intent.status,
payment_link_status,
error_code: payment_attempt.error_code,
error_message: payment_attempt.error_message,
};
let js_script = get_js_script(
api_models::payments::PaymentLinkData::PaymentLinkStatusDetails(payment_details),
)?;
let payment_link_error_data = services::PaymentLinkStatusData {
js_script,
css_script,
};
return Ok(services::ApplicationResponse::PaymenkLinkForm(Box::new(
services::api::PaymentLinkAction::PaymentLinkStatus(payment_link_error_data),
)));
};
let payment_details = api_models::payments::PaymentLinkDetails { let payment_details = api_models::payments::PaymentLinkDetails {
amount: currency amount,
.to_currency_base_unit(payment_intent.amount)
.into_report()
.change_context(errors::ApiErrorResponse::CurrencyConversionFailed)?,
currency, currency,
payment_id: payment_intent.payment_id, payment_id: payment_intent.payment_id,
merchant_name, merchant_name,
@ -145,15 +182,16 @@ pub async fn intiate_payment_link_flow(
merchant_description: payment_intent.description, merchant_description: payment_intent.description,
}; };
let js_script = get_js_script(payment_details)?; let js_script = get_js_script(api_models::payments::PaymentLinkData::PaymentLinkDetails(
let css_script = get_color_scheme_css(payment_link_config.clone()); payment_details,
))?;
let payment_link_data = services::PaymentLinkFormData { let payment_link_data = services::PaymentLinkFormData {
js_script, js_script,
sdk_url: state.conf.payment_link.sdk_url.clone(), sdk_url: state.conf.payment_link.sdk_url.clone(),
css_script, css_script,
}; };
Ok(services::ApplicationResponse::PaymenkLinkForm(Box::new( Ok(services::ApplicationResponse::PaymenkLinkForm(Box::new(
payment_link_data, services::api::PaymentLinkAction::PaymentLinkFormData(payment_link_data),
))) )))
} }
@ -161,13 +199,11 @@ pub async fn intiate_payment_link_flow(
The get_js_script function is used to inject dynamic value to payment_link sdk, which is unique to every payment. The get_js_script function is used to inject dynamic value to payment_link sdk, which is unique to every payment.
*/ */
fn get_js_script( fn get_js_script(payment_details: api_models::payments::PaymentLinkData) -> RouterResult<String> {
payment_details: api_models::payments::PaymentLinkDetails,
) -> RouterResult<String> {
let payment_details_str = serde_json::to_string(&payment_details) let payment_details_str = serde_json::to_string(&payment_details)
.into_report() .into_report()
.change_context(errors::ApiErrorResponse::InternalServerError) .change_context(errors::ApiErrorResponse::InternalServerError)
.attach_printable("Failed to serialize PaymentLinkDetails")?; .attach_printable("Failed to serialize PaymentLinkData")?;
Ok(format!("window.__PAYMENT_DETAILS = {payment_details_str};")) Ok(format!("window.__PAYMENT_DETAILS = {payment_details_str};"))
} }
@ -218,11 +254,11 @@ pub async fn list_payment_link(
} }
pub fn check_payment_link_status( pub fn check_payment_link_status(
max_age: PrimitiveDateTime, payment_link_expiry: PrimitiveDateTime,
) -> api_models::payments::PaymentLinkStatus { ) -> api_models::payments::PaymentLinkStatus {
let curr_time = common_utils::date_time::now(); let curr_time = common_utils::date_time::now();
if curr_time > max_age { if curr_time > payment_link_expiry {
api_models::payments::PaymentLinkStatus::Expired api_models::payments::PaymentLinkStatus::Expired
} else { } else {
api_models::payments::PaymentLinkStatus::Active api_models::payments::PaymentLinkStatus::Active
@ -369,3 +405,10 @@ fn capitalize_first_char(s: &str) -> String {
s.to_owned() s.to_owned()
} }
} }
fn check_payment_link_invalid_conditions(
intent_status: &storage_enums::IntentStatus,
not_allowed_statuses: &[storage_enums::IntentStatus],
) -> bool {
not_allowed_statuses.contains(intent_status)
}

View File

@ -63,7 +63,6 @@
width: 100vw; width: 100vw;
display: flex; display: flex;
justify-content: center; justify-content: center;
background-color: white;
padding: 20px 0; padding: 20px 0;
} }
@ -133,7 +132,6 @@
#hyper-checkout-cart-image { #hyper-checkout-cart-image {
height: 64px; height: 64px;
width: 64px; width: 64px;
border: 1px solid #e6e6e6;
border-radius: 4px; border-radius: 4px;
display: flex; display: flex;
align-self: flex-start; align-self: flex-start;
@ -344,10 +342,6 @@
font-size: 25px; font-size: 25px;
} }
.payNow {
margin-top: 10px;
}
.page-spinner { .page-spinner {
position: absolute; position: absolute;
width: 100vw; width: 100vw;
@ -605,9 +599,38 @@
text-align: center; text-align: center;
} }
#submit {
cursor: pointer;
margin-top: 20px;
width: 100%;
height: 38px;
background-color: var(--primary-color);
border: 0;
border-radius: 4px;
font-size: 18px;
display: flex;
justify-content: center;
align-items: center;
}
#submit.disabled {
cursor: not-allowed;
}
#submit-spinner {
width: 28px;
height: 28px;
border: 4px solid #fff;
border-bottom-color: #ff3d00;
border-radius: 50%;
display: inline-block;
box-sizing: border-box;
animation: loading 1s linear infinite;
}
@media only screen and (max-width: 1400px) { @media only screen and (max-width: 1400px) {
body { body {
overflow: scroll; overflow-y: scroll;
} }
.hyper-checkout { .hyper-checkout {
@ -720,6 +743,7 @@
background-color: transparent; background-color: transparent;
width: auto; width: auto;
min-width: 300px; min-width: 300px;
box-shadow: none;
} }
#payment-form-wrap { #payment-form-wrap {
@ -748,7 +772,7 @@
href="https://fonts.googleapis.com/css2?family=Montserrat:wght@400;500;600;700;800" href="https://fonts.googleapis.com/css2?family=Montserrat:wght@400;500;600;700;800"
/> />
</head> </head>
<body> <body class="hide-scrollbar">
<!-- SVG ICONS --> <!-- SVG ICONS -->
<svg xmlns="http://www.w3.org/2000/svg" display="none"> <svg xmlns="http://www.w3.org/2000/svg" display="none">
<defs> <defs>
@ -920,7 +944,7 @@
<div></div> <div></div>
</div> </div>
</div> </div>
<div class="hyper-checkout"> <div class="hyper-checkout hide-scrollbar">
<div class="main hidden" id="hyper-checkout-status-canvas"> <div class="main hidden" id="hyper-checkout-status-canvas">
<div class="hyper-checkout-status-wrap"> <div class="hyper-checkout-status-wrap">
<div id="hyper-checkout-status-header"></div> <div id="hyper-checkout-status-header"></div>
@ -1035,8 +1059,12 @@
<div></div> <div></div>
</div> </div>
</div> </div>
<form id="payment-form"> <form id="payment-form" onclick="handleSubmit(); return false;">
<div id="unified-checkout"></div> <div id="unified-checkout"></div>
<button id="submit" class="hidden">
<span id="submit-spinner" class="hidden"></span>
<span id="submit-button-text">Pay now</span>
</button>
<div id="payment-message" class="hidden"></div> <div id="payment-message" class="hidden"></div>
</form> </form>
</div> </div>
@ -1060,7 +1088,7 @@
window.state = { window.state = {
prevHeight: window.innerHeight, prevHeight: window.innerHeight,
prevWidth: window.innerWidth, prevWidth: window.innerWidth,
isMobileView: window.innerWidth <= 1200, isMobileView: window.innerWidth <= 1400,
currentScreen: "payment_link", currentScreen: "payment_link",
}; };
@ -1088,9 +1116,9 @@
} }
// Render UI // Render UI
renderPaymentDetails(); renderPaymentDetails(paymentDetails);
renderSDKHeader(); renderSDKHeader(paymentDetails);
renderCart(); renderCart(paymentDetails);
// Deal w loaders // Deal w loaders
show("#sdk-spinner"); show("#sdk-spinner");
@ -1098,7 +1126,7 @@
hide("#unified-checkout"); hide("#unified-checkout");
// Add event listeners // Add event listeners
initializeEventListeners(); initializeEventListeners(paymentDetails);
// Initialize SDK // Initialize SDK
if (window.Hyper) { if (window.Hyper) {
@ -1115,30 +1143,46 @@
} }
boot(); boot();
function initializeEventListeners() { function initializeEventListeners(paymentDetails) {
var primaryColor = window var primaryColor = paymentDetails.theme;
.getComputedStyle(document.documentElement)
.getPropertyValue("--primary-color");
var lighterColor = adjustLightness(primaryColor, 1.4); var lighterColor = adjustLightness(primaryColor, 1.4);
var darkerColor = adjustLightness(primaryColor, 0.8); var darkerColor = adjustLightness(primaryColor, 0.8);
var contrastBWColor = invert(primaryColor, true); var contrastBWColor = invert(primaryColor, true);
var contrastingTone =
Array.isArray(a) && a.length > 4 ? darkerColor : lighterColor;
var hyperCheckoutNode = document.getElementById( var hyperCheckoutNode = document.getElementById(
"hyper-checkout-payment" "hyper-checkout-payment"
); );
var hyperCheckoutCartImageNode = document.getElementById(
"hyper-checkout-cart-image"
);
var hyperCheckoutFooterNode = document.getElementById( var hyperCheckoutFooterNode = document.getElementById(
"hyper-checkout-payment-footer" "hyper-checkout-payment-footer"
); );
var statusRedirectTextNode = document.getElementById( var statusRedirectTextNode = document.getElementById(
"hyper-checkout-status-redirect-message" "hyper-checkout-status-redirect-message"
); );
var submitButtonNode = document.getElementById("submit");
var submitButtonLoaderNode = document.getElementById("submit-spinner");
if (window.innerWidth <= 1200) { if (submitButtonLoaderNode instanceof HTMLSpanElement) {
submitButtonLoaderNode.style.borderBottomColor = contrastingTone;
}
if (submitButtonNode instanceof HTMLButtonElement) {
submitButtonNode.style.color = contrastBWColor;
}
if (hyperCheckoutCartImageNode instanceof HTMLDivElement) {
hyperCheckoutCartImageNode.style.backgroundColor = contrastingTone;
}
if (window.innerWidth <= 1400) {
statusRedirectTextNode.style.color = "#333333"; statusRedirectTextNode.style.color = "#333333";
hyperCheckoutNode.style.color = contrastBWColor; hyperCheckoutNode.style.color = contrastBWColor;
var a = lighterColor.match(/[fF]/gi); var a = lighterColor.match(/[fF]/gi);
hyperCheckoutFooterNode.style.backgroundColor = hyperCheckoutFooterNode.style.backgroundColor = contrastingTone;
Array.isArray(a) && a.length > 4 ? darkerColor : lighterColor; } else if (window.innerWidth > 1400) {
} else if (window.innerWidth > 1200) {
statusRedirectTextNode.style.color = contrastBWColor; statusRedirectTextNode.style.color = contrastBWColor;
hyperCheckoutNode.style.color = "#333333"; hyperCheckoutNode.style.color = "#333333";
hyperCheckoutFooterNode.style.backgroundColor = "#F5F5F5"; hyperCheckoutFooterNode.style.backgroundColor = "#F5F5F5";
@ -1147,7 +1191,7 @@
window.addEventListener("resize", function (event) { window.addEventListener("resize", function (event) {
var currentHeight = window.innerHeight; var currentHeight = window.innerHeight;
var currentWidth = window.innerWidth; var currentWidth = window.innerWidth;
if (currentWidth <= 1200 && window.state.prevWidth > 1200) { if (currentWidth <= 1400 && window.state.prevWidth > 1400) {
hide("#hyper-checkout-cart"); hide("#hyper-checkout-cart");
if (window.state.currentScreen === "payment_link") { if (window.state.currentScreen === "payment_link") {
show("#hyper-footer"); show("#hyper-footer");
@ -1162,7 +1206,7 @@
error error
); );
} }
} else if (currentWidth > 1200 && window.state.prevWidth <= 1200) { } else if (currentWidth > 1400 && window.state.prevWidth <= 1400) {
if (window.state.currentScreen === "payment_link") { if (window.state.currentScreen === "payment_link") {
hide("#hyper-footer"); hide("#hyper-footer");
} }
@ -1178,16 +1222,17 @@
window.state.prevHeight = currentHeight; window.state.prevHeight = currentHeight;
window.state.prevWidth = currentWidth; window.state.prevWidth = currentWidth;
window.state.isMobileView = currentWidth <= 1200; window.state.isMobileView = currentWidth <= 1400;
}); });
} }
function showSDK() { function showSDK(paymentDetails) {
checkStatus() checkStatus(paymentDetails)
.then(function (res) { .then(function (res) {
if (res.showSdk) { if (res.showSdk) {
show("#hyper-checkout-sdk"); show("#hyper-checkout-sdk");
show("#hyper-checkout-details"); show("#hyper-checkout-details");
show("#submit");
} else { } else {
hide("#hyper-checkout-details"); hide("#hyper-checkout-details");
hide("#hyper-checkout-sdk"); hide("#hyper-checkout-sdk");
@ -1195,6 +1240,8 @@
hide("#hyper-footer"); hide("#hyper-footer");
window.state.currentScreen = "status"; window.state.currentScreen = "status";
} }
show("#unified-checkout");
hide("#sdk-spinner");
}) })
.catch(function (err) { .catch(function (err) {
console.error("Failed to check status", err); console.error("Failed to check status", err);
@ -1217,14 +1264,13 @@
colorBackground: "rgb(255, 255, 255)", colorBackground: "rgb(255, 255, 255)",
}, },
}; };
hyper = window.Hyper(pub_key); hyper = window.Hyper(pub_key, { isPreloadEnabled: false });
widgets = hyper.widgets({ widgets = hyper.widgets({
appearance: appearance, appearance: appearance,
clientSecret: client_secret, clientSecret: client_secret,
}); });
var unifiedCheckoutOptions = { var unifiedCheckoutOptions = {
layout: "tabs", layout: "tabs",
sdkHandleConfirmPayment: true,
branding: "never", branding: "never",
wallets: { wallets: {
walletReturnUrl: paymentDetails.return_url, walletReturnUrl: paymentDetails.return_url,
@ -1237,35 +1283,7 @@
}; };
unifiedCheckout = widgets.create("payment", unifiedCheckoutOptions); unifiedCheckout = widgets.create("payment", unifiedCheckoutOptions);
mountUnifiedCheckout("#unified-checkout"); mountUnifiedCheckout("#unified-checkout");
showSDK(paymentDetails);
// Add event listener for SDK iframe mutations
var orcaIFrame = document.getElementById(
"orca-payment-element-iframeRef-unified-checkout"
);
var callback = function (mutationList, observer) {
for (var i = 0; i < mutationList.length; i++) {
var mutation = mutationList[i];
if (
mutation.type === "attributes" &&
mutation.attributeName === "style"
) {
show("#unified-checkout");
hide("#sdk-spinner");
}
}
};
var observer = new MutationObserver(callback);
observer.observe(orcaIFrame, { attributes: true });
// Handle button press callback
var paymentElement = widgets.getElement("payment");
if (paymentElement) {
paymentElement.on("confirmTriggered", function (event) {
handleSubmit(event);
});
}
showSDK();
} }
// Util functions // Util functions
@ -1277,6 +1295,14 @@
function handleSubmit(e) { function handleSubmit(e) {
var paymentDetails = window.__PAYMENT_DETAILS; var paymentDetails = window.__PAYMENT_DETAILS;
// Update button loader
hide("#submit-button-text");
show("#submit-spinner");
var submitButtonNode = document.getElementById("submit");
submitButtonNode.disabled = true;
submitButtonNode.classList.add("disabled");
hyper hyper
.confirmPayment({ .confirmPayment({
widgets: widgets, widgets: widgets,
@ -1293,9 +1319,6 @@
} else { } else {
showMessage("An unexpected error occurred."); showMessage("An unexpected error occurred.");
} }
// Re-initialize SDK
mountUnifiedCheckout("#unified-checkout");
} else { } else {
// This point will only be reached if there is an immediate error occurring while confirming the payment. Otherwise, your customer will be redirected to your 'return_url'. // This point will only be reached if there is an immediate error occurring while confirming the payment. Otherwise, your customer will be redirected to your 'return_url'.
// For some payment flows such as Sofort, iDEAL, your customer will be redirected to an intermediate page to complete authorization of the payment, and then redirected to the 'return_url'. // For some payment flows such as Sofort, iDEAL, your customer will be redirected to an intermediate page to complete authorization of the payment, and then redirected to the 'return_url'.
@ -1320,13 +1343,18 @@
}) })
.catch(function (error) { .catch(function (error) {
console.error("Error confirming payment_intent", error); console.error("Error confirming payment_intent", error);
})
.finally(() => {
hide("#submit-spinner");
show("#submit-button-text");
submitButtonNode.disabled = false;
submitButtonNode.classList.remove("disabled");
}); });
} }
// Fetches the payment status after payment submission // Fetches the payment status after payment submission
function checkStatus() { function checkStatus(paymentDetails) {
return new window.Promise(function (resolve, reject) { return new window.Promise(function (resolve, reject) {
var paymentDetails = window.__PAYMENT_DETAILS;
var res = { var res = {
showSdk: true, showSdk: true,
}; };
@ -1669,9 +1697,7 @@
return formatted; return formatted;
} }
function renderPaymentDetails() { function renderPaymentDetails(paymentDetails) {
var paymentDetails = window.__PAYMENT_DETAILS;
// Create price node // Create price node
var priceNode = document.createElement("div"); var priceNode = document.createElement("div");
priceNode.className = "hyper-checkout-payment-price"; priceNode.className = "hyper-checkout-payment-price";
@ -1720,8 +1746,7 @@
footerNode.append(paymentExpiryNode); footerNode.append(paymentExpiryNode);
} }
function renderCart() { function renderCart(paymentDetails) {
var paymentDetails = window.__PAYMENT_DETAILS;
var orderDetails = paymentDetails.order_details; var orderDetails = paymentDetails.order_details;
// Cart items // Cart items
@ -1749,7 +1774,9 @@
if (totalItems > MAX_ITEMS_VISIBLE_AFTER_COLLAPSE) { if (totalItems > MAX_ITEMS_VISIBLE_AFTER_COLLAPSE) {
var expandButtonNode = document.createElement("div"); var expandButtonNode = document.createElement("div");
expandButtonNode.className = "hyper-checkout-cart-button"; expandButtonNode.className = "hyper-checkout-cart-button";
expandButtonNode.onclick = handleCartView; expandButtonNode.onclick = () => {
handleCartView(paymentDetails);
};
var buttonImageNode = document.createElement("svg"); var buttonImageNode = document.createElement("svg");
buttonImageNode.id = "hyper-checkout-cart-button-arrow"; buttonImageNode.id = "hyper-checkout-cart-button-arrow";
buttonImageNode.innerHTML = buttonImageNode.innerHTML =
@ -1822,8 +1849,7 @@
cartItemsNode.append(itemWrapperNode); cartItemsNode.append(itemWrapperNode);
} }
function handleCartView() { function handleCartView(paymentDetails) {
var paymentDetails = window.__PAYMENT_DETAILS;
var orderDetails = paymentDetails.order_details; var orderDetails = paymentDetails.order_details;
var MAX_ITEMS_VISIBLE_AFTER_COLLAPSE = var MAX_ITEMS_VISIBLE_AFTER_COLLAPSE =
paymentDetails.max_items_visible_after_collapse; paymentDetails.max_items_visible_after_collapse;
@ -1911,9 +1937,7 @@
show("#hyper-checkout-cart"); show("#hyper-checkout-cart");
} }
function renderSDKHeader() { function renderSDKHeader(paymentDetails) {
var paymentDetails = window.__PAYMENT_DETAILS;
// SDK headers' items // SDK headers' items
var sdkHeaderItemNode = document.createElement("div"); var sdkHeaderItemNode = document.createElement("div");
sdkHeaderItemNode.className = "hyper-checkout-sdk-items"; sdkHeaderItemNode.className = "hyper-checkout-sdk-items";

View File

@ -0,0 +1,355 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>404 Not Found</title>
<style>
{{ css_color_scheme }}
body,
body > div {
height: 100vh;
width: 100vw;
}
body {
font-family: "Montserrat";
background-color: var(--primary-color);
color: #333;
text-align: center;
margin: 0;
padding: 0;
overflow: hidden;
}
body > div {
height: 100vh;
width: 100vw;
overflow: scroll;
display: flex;
align-items: center;
justify-content: center;
}
.hyper-checkout-status-wrap {
display: flex;
flex-flow: column;
font-family: "Montserrat";
width: auto;
min-width: 400px;
max-width: 800px;
background-color: white;
border-radius: 5px;
}
#hyper-checkout-status-header {
max-width: 1200px;
border-radius: 3px;
border-bottom: 1px solid #e6e6e6;
}
#hyper-checkout-status-header,
#hyper-checkout-status-content {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 24px;
font-weight: 600;
padding: 15px 20px;
}
.hyper-checkout-status-amount {
font-family: "Montserrat";
font-size: 35px;
font-weight: 700;
}
.hyper-checkout-status-merchant-logo {
border: 1px solid #e6e6e6;
border-radius: 5px;
padding: 9px;
height: 48px;
width: 48px;
}
#hyper-checkout-status-content {
height: 100%;
flex-flow: column;
min-height: 500px;
align-items: center;
justify-content: center;
}
.hyper-checkout-status-image {
height: 200px;
width: 200px;
}
.hyper-checkout-status-text {
text-align: center;
font-size: 21px;
font-weight: 600;
margin-top: 20px;
}
.hyper-checkout-status-message {
text-align: center;
font-size: 12px !important;
margin-top: 10px;
font-size: 14px;
font-weight: 500;
max-width: 400px;
}
.hyper-checkout-status-details {
display: flex;
flex-flow: column;
margin-top: 20px;
border-radius: 3px;
border: 1px solid #e6e6e6;
max-width: calc(100vw - 40px);
}
.hyper-checkout-status-item {
display: flex;
align-items: center;
padding: 5px 10px;
border-bottom: 1px solid #e6e6e6;
word-wrap: break-word;
}
.hyper-checkout-status-item:last-child {
border-bottom: 0;
}
.hyper-checkout-item-header {
min-width: 13ch;
font-size: 12px;
}
.hyper-checkout-item-value {
font-size: 12px;
overflow-x: hidden;
overflow-y: auto;
word-wrap: break-word;
font-weight: 400;
text-align: center;
}
.ellipsis-container-2 {
height: 2.5em;
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
text-overflow: ellipsis;
white-space: normal;
}
@media only screen and (max-width: 1136px) {
.info {
flex-flow: column;
align-self: flex-start;
align-items: flex-start;
min-width: auto;
}
.value {
margin: 0;
}
}
</style>
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Montserrat:wght@400;500;600;700;800"
/>
<script>
{{ payment_details_js_script }}
function boot() {
var paymentDetails = window.__PAYMENT_DETAILS;
// Attach document icon
if (paymentDetails.merchant_logo) {
var link = document.createElement("link");
link.rel = "icon";
link.href = paymentDetails.merchant_logo;
link.type = "image/x-icon";
document.head.appendChild(link);
}
var statusDetails = {
imageSource: "",
message: "",
status: "",
items: [],
};
var paymentId = createItem("Ref Id", paymentDetails.payment_id);
statusDetails.items.push(paymentId);
// Decide screen to render
switch (paymentDetails.payment_link_status) {
case "expired": {
statusDetails.imageSource = "https://i.imgur.com/UD8CEuY.png";
statusDetails.status = "Payment Link Expired!";
statusDetails.message = "This payment link is expired.";
break;
}
default: {
statusDetails.status = paymentDetails.intent_status;
// Render status screen
switch (paymentDetails.intent_status) {
case "succeeded": {
statusDetails.imageSource = "https://i.imgur.com/5BOmYVl.png";
statusDetails.message =
"We have successfully received your payment";
break;
}
case "processing": {
statusDetails.imageSource = "https://i.imgur.com/Yb79Qt4.png";
statusDetails.message =
"Sorry! Your payment is taking longer than expected. Please check back again in sometime.";
statusDetails.status = "Payment Pending";
break;
}
case "failed": {
statusDetails.imageSource = "https://i.imgur.com/UD8CEuY.png";
statusDetails.status = "Payment Failed!";
var errorCodeNode = createItem(
"Error code",
paymentDetails.error_code
);
var errorMessageNode = createItem(
"Error message",
paymentDetails.error_message
);
// @ts-ignore
statusDetails.items.push(errorMessageNode, errorCodeNode);
break;
}
case "cancelled": {
statusDetails.imageSource = "https://i.imgur.com/UD8CEuY.png";
statusDetails.status = "Payment Cancelled";
break;
}
case "requires_merchant_action": {
statusDetails.imageSource = "https://i.imgur.com/Yb79Qt4.png";
statusDetails.status = "Payment under review";
break;
}
case "requires_capture": {
statusDetails.imageSource = "https://i.imgur.com/Yb79Qt4.png";
statusDetails.status = "Payment Pending";
break;
}
case "partially_captured": {
statusDetails.imageSource = "https://i.imgur.com/Yb79Qt4.png";
statusDetails.message = "Partial payment was captured.";
statusDetails.status = "Partial Payment Pending";
break;
}
default:
statusDetails.imageSource = "https://i.imgur.com/UD8CEuY.png";
statusDetails.status = "Something went wrong";
// Error details
if (typeof paymentDetails.error === "object") {
var errorCodeNode = createItem(
"Error Code",
paymentDetails.error.code
);
var errorMessageNode = createItem(
"Error Message",
paymentDetails.error.message
);
// @ts-ignore
statusDetails.items.push(errorMessageNode, errorCodeNode);
}
}
}
}
// Form header
var hyperCheckoutImageNode = document.createElement("img");
var hyperCheckoutAmountNode = document.createElement("div");
hyperCheckoutImageNode.src = paymentDetails.merchant_logo;
hyperCheckoutImageNode.className =
"hyper-checkout-status-merchant-logo";
hyperCheckoutAmountNode.innerText =
paymentDetails.currency + " " + paymentDetails.amount;
hyperCheckoutAmountNode.className = "hyper-checkout-status-amount";
var hyperCheckoutHeaderNode = document.getElementById(
"hyper-checkout-status-header"
);
if (hyperCheckoutHeaderNode instanceof HTMLDivElement) {
hyperCheckoutHeaderNode.append(
hyperCheckoutAmountNode,
hyperCheckoutImageNode
);
}
// Form and append items
var hyperCheckoutStatusTextNode = document.createElement("div");
hyperCheckoutStatusTextNode.innerText = statusDetails.status;
hyperCheckoutStatusTextNode.className = "hyper-checkout-status-text";
var merchantLogoNode = document.createElement("img");
merchantLogoNode.src = statusDetails.imageSource;
merchantLogoNode.className = "hyper-checkout-status-image";
var hyperCheckoutStatusMessageNode = document.createElement("div");
hyperCheckoutStatusMessageNode.innerText = statusDetails.message;
var hyperCheckoutDetailsNode = document.createElement("div");
hyperCheckoutDetailsNode.className = "hyper-checkout-status-details";
if (hyperCheckoutDetailsNode instanceof HTMLDivElement) {
hyperCheckoutDetailsNode.append(...statusDetails.items);
}
var hyperCheckoutContentNode = document.getElementById(
"hyper-checkout-status-content"
);
if (hyperCheckoutContentNode instanceof HTMLDivElement) {
hyperCheckoutContentNode.prepend(
merchantLogoNode,
hyperCheckoutStatusTextNode,
hyperCheckoutDetailsNode
);
}
}
function createItem(heading, value) {
var itemNode = document.createElement("div");
itemNode.className = "hyper-checkout-status-item";
var headerNode = document.createElement("div");
headerNode.className = "hyper-checkout-item-header";
headerNode.innerText = heading;
var valueNode = document.createElement("div");
valueNode.classList.add("hyper-checkout-item-value");
// valueNode.classList.add("ellipsis-container-2");
valueNode.innerText = value;
itemNode.append(headerNode);
itemNode.append(valueNode);
return itemNode;
}
</script>
</head>
<body onload="boot()">
<div>
<div class="hyper-checkout-status-wrap">
<div id="hyper-checkout-status-header"></div>
<div id="hyper-checkout-status-content"></div>
</div>
</div>
</body>
</html>

View File

@ -733,11 +733,17 @@ pub enum ApplicationResponse<R> {
TextPlain(String), TextPlain(String),
JsonForRedirection(api::RedirectionResponse), JsonForRedirection(api::RedirectionResponse),
Form(Box<RedirectionFormData>), Form(Box<RedirectionFormData>),
PaymenkLinkForm(Box<PaymentLinkFormData>), PaymenkLinkForm(Box<PaymentLinkAction>),
FileData((Vec<u8>, mime::Mime)), FileData((Vec<u8>, mime::Mime)),
JsonWithHeaders((R, Vec<(String, String)>)), JsonWithHeaders((R, Vec<(String, String)>)),
} }
#[derive(Debug, Eq, PartialEq)]
pub enum PaymentLinkAction {
PaymentLinkFormData(PaymentLinkFormData),
PaymentLinkStatus(PaymentLinkStatusData),
}
#[derive(Debug, Eq, PartialEq, Clone, serde::Serialize, serde::Deserialize)] #[derive(Debug, Eq, PartialEq, Clone, serde::Serialize, serde::Deserialize)]
pub struct PaymentLinkFormData { pub struct PaymentLinkFormData {
pub js_script: String, pub js_script: String,
@ -745,6 +751,12 @@ pub struct PaymentLinkFormData {
pub sdk_url: String, pub sdk_url: String,
} }
#[derive(Debug, Eq, PartialEq, Clone, serde::Serialize, serde::Deserialize)]
pub struct PaymentLinkStatusData {
pub js_script: String,
pub css_script: String,
}
#[derive(Debug, Eq, PartialEq)] #[derive(Debug, Eq, PartialEq)]
pub struct RedirectionFormData { pub struct RedirectionFormData {
pub redirect_form: RedirectForm, pub redirect_form: RedirectForm,
@ -1051,16 +1063,32 @@ where
.map_into_boxed_body() .map_into_boxed_body()
} }
Ok(ApplicationResponse::PaymenkLinkForm(payment_link_data)) => { Ok(ApplicationResponse::PaymenkLinkForm(boxed_payment_link_data)) => {
match build_payment_link_html(*payment_link_data) { match *boxed_payment_link_data {
Ok(rendered_html) => http_response_html_data(rendered_html), PaymentLinkAction::PaymentLinkFormData(payment_link_data) => {
Err(_) => http_response_err( match build_payment_link_html(payment_link_data) {
r#"{ Ok(rendered_html) => http_response_html_data(rendered_html),
"error": { Err(_) => http_response_err(
"message": "Error while rendering payment link html page" r#"{
} "error": {
}"#, "message": "Error while rendering payment link html page"
), }
}"#,
),
}
}
PaymentLinkAction::PaymentLinkStatus(payment_link_data) => {
match get_payment_link_status(payment_link_data) {
Ok(rendered_html) => http_response_html_data(rendered_html),
Err(_) => http_response_err(
r#"{
"error": {
"message": "Error while rendering payment link status page"
}
}"#,
),
}
}
} }
} }
@ -1634,6 +1662,26 @@ fn get_hyper_loader_sdk(sdk_url: &str) -> String {
format!("<script src=\"{sdk_url}\" onload=\"initializeSDK()\"></script>") format!("<script src=\"{sdk_url}\" onload=\"initializeSDK()\"></script>")
} }
pub fn get_payment_link_status(
payment_link_data: PaymentLinkStatusData,
) -> CustomResult<String, errors::ApiErrorResponse> {
let html_template = include_str!("../core/payment_link/status.html").to_string();
let mut tera = Tera::default();
let _ = tera.add_raw_template("payment_link_status", &html_template);
let mut context = Context::new();
context.insert("css_color_scheme", &payment_link_data.css_script);
context.insert("payment_details_js_script", &payment_link_data.js_script);
match tera.render("payment_link_status", &context) {
Ok(rendered_html) => Ok(rendered_html),
Err(tera_error) => {
crate::logger::warn!("{tera_error}");
Err(errors::ApiErrorResponse::InternalServerError)?
}
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
#[test] #[test]

View File

@ -15,7 +15,8 @@ pub(crate) trait PaymentLinkResponseExt: Sized {
impl PaymentLinkResponseExt for RetrievePaymentLinkResponse { impl PaymentLinkResponseExt for RetrievePaymentLinkResponse {
async fn from_db_payment_link(payment_link: storage::PaymentLink) -> RouterResult<Self> { async fn from_db_payment_link(payment_link: storage::PaymentLink) -> RouterResult<Self> {
let session_expiry = payment_link.fulfilment_time.unwrap_or_else(|| { let session_expiry = payment_link.fulfilment_time.unwrap_or_else(|| {
common_utils::date_time::now() payment_link
.created_at
.saturating_add(time::Duration::seconds(DEFAULT_SESSION_EXPIRY)) .saturating_add(time::Duration::seconds(DEFAULT_SESSION_EXPIRY))
}); });
let status = payment_link::check_payment_link_status(session_expiry); let status = payment_link::check_payment_link_status(session_expiry);

View File

@ -8760,8 +8760,8 @@
"PaymentLinkStatus": { "PaymentLinkStatus": {
"type": "string", "type": "string",
"enum": [ "enum": [
"Active", "active",
"Expired" "expired"
] ]
}, },
"PaymentListConstraints": { "PaymentListConstraints": {