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
[payment_link]
sdk_url = "http://localhost:9090/dist/HyperLoader.js"
sdk_url = "http://localhost:9090/0.16.7/v0/HyperLoader.js"
[payment_method_auth]
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"
[payment_link]
sdk_url = "http://localhost:9090/dist/HyperLoader.js"
sdk_url = "http://localhost:9050/HyperLoader.js"
[payment_method_auth]
redis_expiry = 900

View File

@ -3358,6 +3358,13 @@ pub struct PaymentLinkInitiateRequest {
pub payment_id: String,
}
#[derive(Debug, serde::Serialize)]
#[serde(untagged)]
pub enum PaymentLinkData {
PaymentLinkDetails(PaymentLinkDetails),
PaymentLinkStatusDetails(PaymentLinkStatusDetails),
}
#[derive(Debug, serde::Serialize)]
pub struct PaymentLinkDetails {
pub amount: String,
@ -3376,6 +3383,21 @@ pub struct PaymentLinkDetails {
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)]
#[serde(deny_unknown_fields)]
@ -3451,7 +3473,8 @@ pub struct OrderDetailsWithStringAmount {
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 {
Active,
Expired,

View File

@ -133,19 +133,34 @@ where
.map_into_boxed_body()
}
Ok(api::ApplicationResponse::PaymenkLinkForm(payment_link_data)) => {
match api::build_payment_link_html(*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 html page"
}
}"#,
),
Ok(api::ApplicationResponse::PaymenkLinkForm(boxed_payment_link_data)) => {
match *boxed_payment_link_data {
api::PaymentLinkAction::PaymentLinkFormData(payment_link_data) => {
match api::build_payment_link_html(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 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),
};

View File

@ -13,7 +13,6 @@ use time::PrimitiveDateTime;
use super::errors::{self, RouterResult, StorageErrorExt};
use crate::{
core::payments::helpers,
errors::RouterResponse,
routes::AppState,
services,
@ -68,18 +67,6 @@ pub async fn intiate_payment_link_flow(
.get_required_value("payment_link_id")
.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
.merchant_name
.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
} else {
merchant_account
@ -114,23 +101,73 @@ pub async fn intiate_payment_link_flow(
let (pub_key, currency, client_secret) = validate_sdk_requirements(
merchant_account.publishable_key,
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(|| {
common_utils::date_time::now()
payment_intent
.created_at
.saturating_add(time::Duration::seconds(DEFAULT_SESSION_EXPIRY))
});
// converting first letter of merchant name to upperCase
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 {
amount: currency
.to_currency_base_unit(payment_intent.amount)
.into_report()
.change_context(errors::ApiErrorResponse::CurrencyConversionFailed)?,
amount,
currency,
payment_id: payment_intent.payment_id,
merchant_name,
@ -145,15 +182,16 @@ pub async fn intiate_payment_link_flow(
merchant_description: payment_intent.description,
};
let js_script = get_js_script(payment_details)?;
let css_script = get_color_scheme_css(payment_link_config.clone());
let js_script = get_js_script(api_models::payments::PaymentLinkData::PaymentLinkDetails(
payment_details,
))?;
let payment_link_data = services::PaymentLinkFormData {
js_script,
sdk_url: state.conf.payment_link.sdk_url.clone(),
css_script,
};
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.
*/
fn get_js_script(
payment_details: api_models::payments::PaymentLinkDetails,
) -> RouterResult<String> {
fn get_js_script(payment_details: api_models::payments::PaymentLinkData) -> RouterResult<String> {
let payment_details_str = serde_json::to_string(&payment_details)
.into_report()
.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};"))
}
@ -218,11 +254,11 @@ pub async fn list_payment_link(
}
pub fn check_payment_link_status(
max_age: PrimitiveDateTime,
payment_link_expiry: PrimitiveDateTime,
) -> api_models::payments::PaymentLinkStatus {
let curr_time = common_utils::date_time::now();
if curr_time > max_age {
if curr_time > payment_link_expiry {
api_models::payments::PaymentLinkStatus::Expired
} else {
api_models::payments::PaymentLinkStatus::Active
@ -369,3 +405,10 @@ fn capitalize_first_char(s: &str) -> String {
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;
display: flex;
justify-content: center;
background-color: white;
padding: 20px 0;
}
@ -133,7 +132,6 @@
#hyper-checkout-cart-image {
height: 64px;
width: 64px;
border: 1px solid #e6e6e6;
border-radius: 4px;
display: flex;
align-self: flex-start;
@ -344,10 +342,6 @@
font-size: 25px;
}
.payNow {
margin-top: 10px;
}
.page-spinner {
position: absolute;
width: 100vw;
@ -605,9 +599,38 @@
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) {
body {
overflow: scroll;
overflow-y: scroll;
}
.hyper-checkout {
@ -720,6 +743,7 @@
background-color: transparent;
width: auto;
min-width: 300px;
box-shadow: none;
}
#payment-form-wrap {
@ -748,7 +772,7 @@
href="https://fonts.googleapis.com/css2?family=Montserrat:wght@400;500;600;700;800"
/>
</head>
<body>
<body class="hide-scrollbar">
<!-- SVG ICONS -->
<svg xmlns="http://www.w3.org/2000/svg" display="none">
<defs>
@ -920,7 +944,7 @@
<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="hyper-checkout-status-wrap">
<div id="hyper-checkout-status-header"></div>
@ -1035,8 +1059,12 @@
<div></div>
</div>
</div>
<form id="payment-form">
<form id="payment-form" onclick="handleSubmit(); return false;">
<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>
</form>
</div>
@ -1060,7 +1088,7 @@
window.state = {
prevHeight: window.innerHeight,
prevWidth: window.innerWidth,
isMobileView: window.innerWidth <= 1200,
isMobileView: window.innerWidth <= 1400,
currentScreen: "payment_link",
};
@ -1088,9 +1116,9 @@
}
// Render UI
renderPaymentDetails();
renderSDKHeader();
renderCart();
renderPaymentDetails(paymentDetails);
renderSDKHeader(paymentDetails);
renderCart(paymentDetails);
// Deal w loaders
show("#sdk-spinner");
@ -1098,7 +1126,7 @@
hide("#unified-checkout");
// Add event listeners
initializeEventListeners();
initializeEventListeners(paymentDetails);
// Initialize SDK
if (window.Hyper) {
@ -1115,30 +1143,46 @@
}
boot();
function initializeEventListeners() {
var primaryColor = window
.getComputedStyle(document.documentElement)
.getPropertyValue("--primary-color");
function initializeEventListeners(paymentDetails) {
var primaryColor = paymentDetails.theme;
var lighterColor = adjustLightness(primaryColor, 1.4);
var darkerColor = adjustLightness(primaryColor, 0.8);
var contrastBWColor = invert(primaryColor, true);
var contrastingTone =
Array.isArray(a) && a.length > 4 ? darkerColor : lighterColor;
var hyperCheckoutNode = document.getElementById(
"hyper-checkout-payment"
);
var hyperCheckoutCartImageNode = document.getElementById(
"hyper-checkout-cart-image"
);
var hyperCheckoutFooterNode = document.getElementById(
"hyper-checkout-payment-footer"
);
var statusRedirectTextNode = document.getElementById(
"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";
hyperCheckoutNode.style.color = contrastBWColor;
var a = lighterColor.match(/[fF]/gi);
hyperCheckoutFooterNode.style.backgroundColor =
Array.isArray(a) && a.length > 4 ? darkerColor : lighterColor;
} else if (window.innerWidth > 1200) {
hyperCheckoutFooterNode.style.backgroundColor = contrastingTone;
} else if (window.innerWidth > 1400) {
statusRedirectTextNode.style.color = contrastBWColor;
hyperCheckoutNode.style.color = "#333333";
hyperCheckoutFooterNode.style.backgroundColor = "#F5F5F5";
@ -1147,7 +1191,7 @@
window.addEventListener("resize", function (event) {
var currentHeight = window.innerHeight;
var currentWidth = window.innerWidth;
if (currentWidth <= 1200 && window.state.prevWidth > 1200) {
if (currentWidth <= 1400 && window.state.prevWidth > 1400) {
hide("#hyper-checkout-cart");
if (window.state.currentScreen === "payment_link") {
show("#hyper-footer");
@ -1162,7 +1206,7 @@
error
);
}
} else if (currentWidth > 1200 && window.state.prevWidth <= 1200) {
} else if (currentWidth > 1400 && window.state.prevWidth <= 1400) {
if (window.state.currentScreen === "payment_link") {
hide("#hyper-footer");
}
@ -1178,16 +1222,17 @@
window.state.prevHeight = currentHeight;
window.state.prevWidth = currentWidth;
window.state.isMobileView = currentWidth <= 1200;
window.state.isMobileView = currentWidth <= 1400;
});
}
function showSDK() {
checkStatus()
function showSDK(paymentDetails) {
checkStatus(paymentDetails)
.then(function (res) {
if (res.showSdk) {
show("#hyper-checkout-sdk");
show("#hyper-checkout-details");
show("#submit");
} else {
hide("#hyper-checkout-details");
hide("#hyper-checkout-sdk");
@ -1195,6 +1240,8 @@
hide("#hyper-footer");
window.state.currentScreen = "status";
}
show("#unified-checkout");
hide("#sdk-spinner");
})
.catch(function (err) {
console.error("Failed to check status", err);
@ -1217,14 +1264,13 @@
colorBackground: "rgb(255, 255, 255)",
},
};
hyper = window.Hyper(pub_key);
hyper = window.Hyper(pub_key, { isPreloadEnabled: false });
widgets = hyper.widgets({
appearance: appearance,
clientSecret: client_secret,
});
var unifiedCheckoutOptions = {
layout: "tabs",
sdkHandleConfirmPayment: true,
branding: "never",
wallets: {
walletReturnUrl: paymentDetails.return_url,
@ -1237,35 +1283,7 @@
};
unifiedCheckout = widgets.create("payment", unifiedCheckoutOptions);
mountUnifiedCheckout("#unified-checkout");
// 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();
showSDK(paymentDetails);
}
// Util functions
@ -1277,6 +1295,14 @@
function handleSubmit(e) {
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
.confirmPayment({
widgets: widgets,
@ -1293,9 +1319,6 @@
} else {
showMessage("An unexpected error occurred.");
}
// Re-initialize SDK
mountUnifiedCheckout("#unified-checkout");
} 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'.
// 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) {
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
function checkStatus() {
function checkStatus(paymentDetails) {
return new window.Promise(function (resolve, reject) {
var paymentDetails = window.__PAYMENT_DETAILS;
var res = {
showSdk: true,
};
@ -1669,9 +1697,7 @@
return formatted;
}
function renderPaymentDetails() {
var paymentDetails = window.__PAYMENT_DETAILS;
function renderPaymentDetails(paymentDetails) {
// Create price node
var priceNode = document.createElement("div");
priceNode.className = "hyper-checkout-payment-price";
@ -1720,8 +1746,7 @@
footerNode.append(paymentExpiryNode);
}
function renderCart() {
var paymentDetails = window.__PAYMENT_DETAILS;
function renderCart(paymentDetails) {
var orderDetails = paymentDetails.order_details;
// Cart items
@ -1749,7 +1774,9 @@
if (totalItems > MAX_ITEMS_VISIBLE_AFTER_COLLAPSE) {
var expandButtonNode = document.createElement("div");
expandButtonNode.className = "hyper-checkout-cart-button";
expandButtonNode.onclick = handleCartView;
expandButtonNode.onclick = () => {
handleCartView(paymentDetails);
};
var buttonImageNode = document.createElement("svg");
buttonImageNode.id = "hyper-checkout-cart-button-arrow";
buttonImageNode.innerHTML =
@ -1822,8 +1849,7 @@
cartItemsNode.append(itemWrapperNode);
}
function handleCartView() {
var paymentDetails = window.__PAYMENT_DETAILS;
function handleCartView(paymentDetails) {
var orderDetails = paymentDetails.order_details;
var MAX_ITEMS_VISIBLE_AFTER_COLLAPSE =
paymentDetails.max_items_visible_after_collapse;
@ -1911,9 +1937,7 @@
show("#hyper-checkout-cart");
}
function renderSDKHeader() {
var paymentDetails = window.__PAYMENT_DETAILS;
function renderSDKHeader(paymentDetails) {
// SDK headers' items
var sdkHeaderItemNode = document.createElement("div");
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),
JsonForRedirection(api::RedirectionResponse),
Form(Box<RedirectionFormData>),
PaymenkLinkForm(Box<PaymentLinkFormData>),
PaymenkLinkForm(Box<PaymentLinkAction>),
FileData((Vec<u8>, mime::Mime)),
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)]
pub struct PaymentLinkFormData {
pub js_script: String,
@ -745,6 +751,12 @@ pub struct PaymentLinkFormData {
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)]
pub struct RedirectionFormData {
pub redirect_form: RedirectForm,
@ -1051,16 +1063,32 @@ where
.map_into_boxed_body()
}
Ok(ApplicationResponse::PaymenkLinkForm(payment_link_data)) => {
match build_payment_link_html(*payment_link_data) {
Ok(rendered_html) => http_response_html_data(rendered_html),
Err(_) => http_response_err(
r#"{
"error": {
"message": "Error while rendering payment link html page"
}
}"#,
),
Ok(ApplicationResponse::PaymenkLinkForm(boxed_payment_link_data)) => {
match *boxed_payment_link_data {
PaymentLinkAction::PaymentLinkFormData(payment_link_data) => {
match build_payment_link_html(payment_link_data) {
Ok(rendered_html) => http_response_html_data(rendered_html),
Err(_) => http_response_err(
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>")
}
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)]
mod tests {
#[test]

View File

@ -15,7 +15,8 @@ pub(crate) trait PaymentLinkResponseExt: Sized {
impl PaymentLinkResponseExt for RetrievePaymentLinkResponse {
async fn from_db_payment_link(payment_link: storage::PaymentLink) -> RouterResult<Self> {
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))
});
let status = payment_link::check_payment_link_status(session_expiry);

View File

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