feat(payment_link): add provision for secured payment links (#5357)

Co-authored-by: hyperswitch-bot[bot] <148525504+hyperswitch-bot[bot]@users.noreply.github.com>
Co-authored-by: Sanchith Hegde <22217505+SanchithHegde@users.noreply.github.com>
This commit is contained in:
Kashif
2024-07-30 13:12:35 +05:30
committed by GitHub
parent a791391e2a
commit 043abb59b9
31 changed files with 841 additions and 318 deletions

View File

@ -3144,12 +3144,15 @@ pub async fn update_business_profile(
let payment_link_config = request
.payment_link_config
.as_ref()
.map(|pl_metadata| {
pl_metadata.encode_to_value().change_context(
.map(|payment_link_conf| match payment_link_conf.validate() {
Ok(_) => payment_link_conf.encode_to_value().change_context(
errors::ApiErrorResponse::InvalidDataValue {
field_name: "payment_link_config",
},
)
),
Err(e) => Err(report!(errors::ApiErrorResponse::InvalidRequestData {
message: e.to_string()
})),
})
.transpose()?;

View File

@ -1,14 +1,21 @@
use api_models::{admin as admin_types, payments::PaymentLinkStatusWrap};
pub mod validator;
use actix_web::http::header;
use api_models::{
admin::PaymentLinkConfig,
payments::{PaymentLinkData, PaymentLinkStatusWrap},
};
use common_utils::{
consts::{
DEFAULT_BACKGROUND_COLOR, DEFAULT_DISPLAY_SDK_ONLY, DEFAULT_ENABLE_SAVED_PAYMENT_METHOD,
DEFAULT_MERCHANT_LOGO, DEFAULT_PRODUCT_IMG, DEFAULT_SDK_LAYOUT, DEFAULT_SESSION_EXPIRY,
DEFAULT_ALLOWED_DOMAINS, DEFAULT_BACKGROUND_COLOR, DEFAULT_DISPLAY_SDK_ONLY,
DEFAULT_ENABLE_SAVED_PAYMENT_METHOD, DEFAULT_MERCHANT_LOGO, DEFAULT_PRODUCT_IMG,
DEFAULT_SDK_LAYOUT, DEFAULT_SESSION_EXPIRY,
},
ext_traits::{OptionExt, ValueExt},
types::{AmountConvertor, MinorUnit, StringMajorUnitForCore},
};
use error_stack::ResultExt;
use error_stack::{report, ResultExt};
use futures::future;
use hyperswitch_domain_models::api::{GenericLinks, GenericLinksData};
use masking::{PeekInterface, Secret};
use router_env::logger;
use time::PrimitiveDateTime;
@ -20,7 +27,9 @@ use crate::{
routes::SessionState,
services,
types::{
api::payment_link::PaymentLinkResponseExt, domain, storage::enums as storage_enums,
api::payment_link::PaymentLinkResponseExt,
domain,
storage::{enums as storage_enums, payment_link::PaymentLink},
transformers::ForeignFrom,
},
};
@ -49,17 +58,17 @@ pub async fn retrieve_payment_link(
Ok(services::ApplicationResponse::Json(response))
}
pub async fn initiate_payment_link_flow(
state: SessionState,
pub async fn form_payment_link_data(
state: &SessionState,
merchant_account: domain::MerchantAccount,
key_store: domain::MerchantKeyStore,
merchant_id: common_utils::id_type::MerchantId,
payment_id: String,
) -> RouterResponse<services::PaymentLinkFormData> {
) -> RouterResult<(PaymentLink, PaymentLinkData, PaymentLinkConfig)> {
let db = &*state.store;
let payment_intent = db
.find_payment_intent_by_payment_id_merchant_id(
&(&state).into(),
&(state).into(),
&payment_id,
&merchant_id,
&key_store,
@ -84,21 +93,24 @@ pub async fn initiate_payment_link_flow(
.await
.to_not_found_response(errors::ApiErrorResponse::PaymentLinkNotFound)?;
let payment_link_config = if let Some(pl_config_value) = payment_link.payment_link_config {
extract_payment_link_config(pl_config_value)?
} else {
admin_types::PaymentLinkConfig {
theme: DEFAULT_BACKGROUND_COLOR.to_string(),
logo: DEFAULT_MERCHANT_LOGO.to_string(),
seller_name: merchant_name_from_merchant_account,
sdk_layout: DEFAULT_SDK_LAYOUT.to_owned(),
display_sdk_only: DEFAULT_DISPLAY_SDK_ONLY,
enabled_saved_payment_method: DEFAULT_ENABLE_SAVED_PAYMENT_METHOD,
}
};
let payment_link_config =
if let Some(pl_config_value) = payment_link.payment_link_config.clone() {
extract_payment_link_config(pl_config_value)?
} else {
PaymentLinkConfig {
theme: DEFAULT_BACKGROUND_COLOR.to_string(),
logo: DEFAULT_MERCHANT_LOGO.to_string(),
seller_name: merchant_name_from_merchant_account,
sdk_layout: DEFAULT_SDK_LAYOUT.to_owned(),
display_sdk_only: DEFAULT_DISPLAY_SDK_ONLY,
enabled_saved_payment_method: DEFAULT_ENABLE_SAVED_PAYMENT_METHOD,
allowed_domains: DEFAULT_ALLOWED_DOMAINS,
}
};
let profile_id = payment_link
.profile_id
.clone()
.or(payment_intent.profile_id)
.ok_or(errors::ApiErrorResponse::InternalServerError)
.attach_printable("Profile id missing in payment link and payment intent")?;
@ -143,7 +155,6 @@ pub async fn initiate_payment_link_flow(
// 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);
let is_terminal_state = check_payment_link_invalid_conditions(
@ -205,78 +216,185 @@ pub async fn initiate_payment_link_flow(
return_url: return_url.clone(),
};
logger::info!(
"payment link data, for building payment link status page {:?}",
payment_details
);
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::PaymentLinkForm(Box::new(
services::api::PaymentLinkAction::PaymentLinkStatus(payment_link_error_data),
)));
return Ok((
payment_link,
PaymentLinkData::PaymentLinkStatusDetails(payment_details),
payment_link_config,
));
};
let payment_details = api_models::payments::PaymentLinkDetails {
amount,
currency,
payment_id: payment_intent.payment_id,
merchant_name,
order_details,
return_url,
session_expiry,
pub_key: merchant_account.publishable_key,
client_secret,
merchant_logo: payment_link_config.logo.clone(),
max_items_visible_after_collapse: 3,
theme: payment_link_config.theme.clone(),
merchant_description: payment_intent.description,
sdk_layout: payment_link_config.sdk_layout.clone(),
display_sdk_only: payment_link_config.display_sdk_only,
enabled_saved_payment_method: payment_link_config.enabled_saved_payment_method,
};
let payment_link_details =
PaymentLinkData::PaymentLinkDetails(api_models::payments::PaymentLinkDetails {
amount,
currency,
payment_id: payment_intent.payment_id,
merchant_name,
order_details,
return_url,
session_expiry,
pub_key: merchant_account.publishable_key,
client_secret,
merchant_logo: payment_link_config.logo.clone(),
max_items_visible_after_collapse: 3,
theme: payment_link_config.theme.clone(),
merchant_description: payment_intent.description,
sdk_layout: payment_link_config.sdk_layout.clone(),
display_sdk_only: payment_link_config.display_sdk_only,
});
let js_script = get_js_script(&api_models::payments::PaymentLinkData::PaymentLinkDetails(
&payment_details,
))?;
Ok((payment_link, payment_link_details, payment_link_config))
}
let html_meta_tags = get_meta_tags_html(payment_details);
pub async fn initiate_secure_payment_link_flow(
state: SessionState,
merchant_account: domain::MerchantAccount,
key_store: domain::MerchantKeyStore,
merchant_id: common_utils::id_type::MerchantId,
payment_id: String,
request_headers: &header::HeaderMap,
) -> RouterResponse<services::PaymentLinkFormData> {
let (payment_link, payment_link_details, payment_link_config) =
form_payment_link_data(&state, merchant_account, key_store, merchant_id, payment_id)
.await?;
let payment_link_data = services::PaymentLinkFormData {
js_script,
sdk_url: state.conf.payment_link.sdk_url.clone(),
css_script,
html_meta_tags,
};
validator::validate_secure_payment_link_render_request(
request_headers,
&payment_link,
&payment_link_config,
)?;
logger::info!(
"payment link data, for building payment link {:?}",
payment_link_data
);
Ok(services::ApplicationResponse::PaymentLinkForm(Box::new(
services::api::PaymentLinkAction::PaymentLinkFormData(payment_link_data),
)))
let css_script = get_color_scheme_css(&payment_link_config);
match payment_link_details {
PaymentLinkData::PaymentLinkStatusDetails(ref status_details) => {
let js_script = get_js_script(&payment_link_details)?;
let payment_link_error_data = services::PaymentLinkStatusData {
js_script,
css_script,
};
logger::info!(
"payment link data, for building payment link status page {:?}",
status_details
);
Ok(services::ApplicationResponse::PaymentLinkForm(Box::new(
services::api::PaymentLinkAction::PaymentLinkStatus(payment_link_error_data),
)))
}
PaymentLinkData::PaymentLinkDetails(link_details) => {
let secure_payment_link_details = api_models::payments::SecurePaymentLinkDetails {
enabled_saved_payment_method: payment_link_config.enabled_saved_payment_method,
payment_link_details: link_details.to_owned(),
};
let js_script = format!(
"window.__PAYMENT_DETAILS = {}",
serde_json::to_string(&secure_payment_link_details)
.change_context(errors::ApiErrorResponse::InternalServerError)
.attach_printable("Failed to serialize PaymentLinkData")?
);
let html_meta_tags = get_meta_tags_html(&link_details);
let payment_link_data = services::PaymentLinkFormData {
js_script,
sdk_url: state.conf.payment_link.sdk_url.clone(),
css_script,
html_meta_tags,
};
let allowed_domains = payment_link_config
.allowed_domains
.clone()
.ok_or(report!(errors::ApiErrorResponse::InternalServerError))
.attach_printable_lazy(|| {
format!(
"Invalid list of allowed_domains found - {:?}",
payment_link_config.allowed_domains.clone()
)
})?;
if allowed_domains.is_empty() {
return Err(report!(errors::ApiErrorResponse::InternalServerError))
.attach_printable_lazy(|| {
format!(
"Invalid list of allowed_domains found - {:?}",
payment_link_config.allowed_domains.clone()
)
});
}
let link_data = GenericLinks {
allowed_domains,
data: GenericLinksData::SecurePaymentLink(payment_link_data),
};
logger::info!(
"payment link data, for building secure payment link {:?}",
link_data
);
Ok(services::ApplicationResponse::GenericLinkForm(Box::new(
link_data,
)))
}
}
}
pub async fn initiate_payment_link_flow(
state: SessionState,
merchant_account: domain::MerchantAccount,
key_store: domain::MerchantKeyStore,
merchant_id: common_utils::id_type::MerchantId,
payment_id: String,
) -> RouterResponse<services::PaymentLinkFormData> {
let (_, payment_details, payment_link_config) =
form_payment_link_data(&state, merchant_account, key_store, merchant_id, payment_id)
.await?;
let css_script = get_color_scheme_css(&payment_link_config);
let js_script = get_js_script(&payment_details)?;
match payment_details {
PaymentLinkData::PaymentLinkStatusDetails(status_details) => {
let payment_link_error_data = services::PaymentLinkStatusData {
js_script,
css_script,
};
logger::info!(
"payment link data, for building payment link status page {:?}",
status_details
);
Ok(services::ApplicationResponse::PaymentLinkForm(Box::new(
services::api::PaymentLinkAction::PaymentLinkStatus(payment_link_error_data),
)))
}
PaymentLinkData::PaymentLinkDetails(payment_details) => {
let html_meta_tags = get_meta_tags_html(&payment_details);
let payment_link_data = services::PaymentLinkFormData {
js_script,
sdk_url: state.conf.payment_link.sdk_url.clone(),
css_script,
html_meta_tags,
};
logger::info!(
"payment link data, for building open payment link {:?}",
payment_link_data
);
Ok(services::ApplicationResponse::PaymentLinkForm(Box::new(
services::api::PaymentLinkAction::PaymentLinkFormData(payment_link_data),
)))
}
}
}
/*
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::PaymentLinkData<'_>,
) -> RouterResult<String> {
fn get_js_script(payment_details: &PaymentLinkData) -> RouterResult<String> {
let payment_details_str = serde_json::to_string(payment_details)
.change_context(errors::ApiErrorResponse::InternalServerError)
.attach_printable("Failed to serialize PaymentLinkData")?;
Ok(format!("window.__PAYMENT_DETAILS = {payment_details_str};"))
}
fn get_color_scheme_css(payment_link_config: api_models::admin::PaymentLinkConfig) -> String {
let background_primary_color = payment_link_config.theme;
fn get_color_scheme_css(payment_link_config: &PaymentLinkConfig) -> String {
let background_primary_color = payment_link_config.theme.clone();
format!(
":root {{
--primary-color: {background_primary_color};
@ -284,12 +402,15 @@ fn get_color_scheme_css(payment_link_config: api_models::admin::PaymentLinkConfi
)
}
fn get_meta_tags_html(payment_details: api_models::payments::PaymentLinkDetails) -> String {
fn get_meta_tags_html(payment_details: &api_models::payments::PaymentLinkDetails) -> String {
format!(
r#"<meta property="og:title" content="Payment request from {0}"/>
<meta property="og:description" content="{1}"/>"#,
payment_details.merchant_name,
payment_details.merchant_description.unwrap_or_default()
payment_details.merchant_name.clone(),
payment_details
.merchant_description
.clone()
.unwrap_or_default()
)
}
@ -395,11 +516,12 @@ fn validate_order_details(
pub fn extract_payment_link_config(
pl_config: serde_json::Value,
) -> Result<api_models::admin::PaymentLinkConfig, error_stack::Report<errors::ApiErrorResponse>> {
serde_json::from_value::<api_models::admin::PaymentLinkConfig>(pl_config.clone())
.change_context(errors::ApiErrorResponse::InvalidDataValue {
) -> Result<PaymentLinkConfig, error_stack::Report<errors::ApiErrorResponse>> {
serde_json::from_value::<PaymentLinkConfig>(pl_config).change_context(
errors::ApiErrorResponse::InvalidDataValue {
field_name: "payment_link_config",
})
},
)
}
pub fn get_payment_link_config_based_on_priority(
@ -408,39 +530,39 @@ pub fn get_payment_link_config_based_on_priority(
merchant_name: String,
default_domain_name: String,
payment_link_config_id: Option<String>,
) -> Result<(admin_types::PaymentLinkConfig, String), error_stack::Report<errors::ApiErrorResponse>>
{
let (domain_name, business_theme_configs) = if let Some(business_config) = business_link_config
{
let extracted_value: api_models::admin::BusinessPaymentLinkConfig = business_config
.parse_value("BusinessPaymentLinkConfig")
.change_context(errors::ApiErrorResponse::InvalidDataValue {
field_name: "payment_link_config",
})
.attach_printable("Invalid payment_link_config given in business config")?;
logger::info!(
"domain name set to custom domain https://{:?}",
extracted_value.domain_name
);
(
extracted_value
.domain_name
.clone()
.map(|d_name| format!("https://{}", d_name))
.unwrap_or_else(|| default_domain_name.clone()),
payment_link_config_id
.and_then(|id| {
extracted_value
.business_specific_configs
.as_ref()
.and_then(|specific_configs| specific_configs.get(&id).cloned())
) -> Result<(PaymentLinkConfig, String), error_stack::Report<errors::ApiErrorResponse>> {
let (domain_name, business_theme_configs, allowed_domains) =
if let Some(business_config) = business_link_config {
let extracted_value: api_models::admin::BusinessPaymentLinkConfig = business_config
.parse_value("BusinessPaymentLinkConfig")
.change_context(errors::ApiErrorResponse::InvalidDataValue {
field_name: "payment_link_config",
})
.or(extracted_value.default_config),
)
} else {
(default_domain_name, None)
};
.attach_printable("Invalid payment_link_config given in business config")?;
logger::info!(
"domain name set to custom domain https://{:?}",
extracted_value.domain_name
);
(
extracted_value
.domain_name
.clone()
.map(|d_name| format!("https://{}", d_name))
.unwrap_or_else(|| default_domain_name.clone()),
payment_link_config_id
.and_then(|id| {
extracted_value
.business_specific_configs
.as_ref()
.and_then(|specific_configs| specific_configs.get(&id).cloned())
})
.or(extracted_value.default_config),
extracted_value.allowed_domains,
)
} else {
(default_domain_name, None, None)
};
let (theme, logo, seller_name, sdk_layout, display_sdk_only, enabled_saved_payment_method) = get_payment_link_config_value!(
payment_create_link_config,
@ -456,13 +578,14 @@ pub fn get_payment_link_config_based_on_priority(
)
);
let payment_link_config = admin_types::PaymentLinkConfig {
let payment_link_config = PaymentLinkConfig {
theme,
logo,
seller_name,
sdk_layout,
display_sdk_only,
enabled_saved_payment_method,
allowed_domains,
};
Ok((payment_link_config, domain_name))
@ -537,13 +660,14 @@ pub async fn get_payment_link_status(
let payment_link_config = if let Some(pl_config_value) = payment_link.payment_link_config {
extract_payment_link_config(pl_config_value)?
} else {
admin_types::PaymentLinkConfig {
PaymentLinkConfig {
theme: DEFAULT_BACKGROUND_COLOR.to_string(),
logo: DEFAULT_MERCHANT_LOGO.to_string(),
seller_name: merchant_name_from_merchant_account,
sdk_layout: DEFAULT_SDK_LAYOUT.to_owned(),
display_sdk_only: DEFAULT_DISPLAY_SDK_ONLY,
enabled_saved_payment_method: DEFAULT_ENABLE_SAVED_PAYMENT_METHOD,
allowed_domains: DEFAULT_ALLOWED_DOMAINS,
}
};
@ -564,7 +688,7 @@ pub async fn get_payment_link_status(
// 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 css_script = get_color_scheme_css(&payment_link_config);
let profile_id = payment_link
.profile_id
@ -603,9 +727,7 @@ pub async fn get_payment_link_status(
theme: payment_link_config.theme.clone(),
return_url,
};
let js_script = get_js_script(
&api_models::payments::PaymentLinkData::PaymentLinkStatusDetails(payment_details),
)?;
let js_script = get_js_script(&PaymentLinkData::PaymentLinkStatusDetails(payment_details))?;
let payment_link_status_data = services::PaymentLinkStatusData {
js_script,
css_script,

View File

@ -93,7 +93,7 @@
}
</style>
</head>
<body class="hide-scrollbar">
<body id="payment-link" class="hide-scrollbar">
<div id="payment-details-shimmer">
<div class = "wrap">
<box class="shine"></box>
@ -324,6 +324,7 @@
</div>
<script>
{{rendered_js}}
{{payment_link_initiator}}
{{logging_template}}
</script>
{{ hyperloader_sdk_link }}

View File

@ -379,75 +379,6 @@ function showSDK(display_sdk_only) {
hide("#sdk-spinner");
}
/**
* Trigger - post downloading SDK
* Uses
* - Instantiate SDK
* - Create a payment widget
* - Decide whether or not to show SDK (based on status)
**/
function initializeSDK() {
// @ts-ignore
var paymentDetails = window.__PAYMENT_DETAILS;
var client_secret = paymentDetails.client_secret;
var appearance = {
variables: {
colorPrimary: paymentDetails.theme || "rgb(0, 109, 249)",
fontFamily: "Work Sans, sans-serif",
fontSizeBase: "16px",
colorText: "rgb(51, 65, 85)",
colorTextSecondary: "#334155B3",
colorPrimaryText: "rgb(51, 65, 85)",
colorTextPlaceholder: "#33415550",
borderColor: "#33415550",
colorBackground: "rgb(255, 255, 255)",
},
};
// @ts-ignore
hyper = window.Hyper(pub_key, {
isPreloadEnabled: false,
});
widgets = hyper.widgets({
appearance: appearance,
clientSecret: client_secret,
});
var type =
paymentDetails.sdk_layout === "spaced_accordion" ||
paymentDetails.sdk_layout === "accordion"
? "accordion"
: paymentDetails.sdk_layout;
var enableSavedPaymentMethod = paymentDetails.enabled_saved_payment_method;
var unifiedCheckoutOptions = {
displaySavedPaymentMethodsCheckbox: enableSavedPaymentMethod,
displaySavedPaymentMethods: enableSavedPaymentMethod,
layout: {
type: type, //accordion , tabs, spaced accordion
spacedAccordionItems: paymentDetails.sdk_layout === "spaced_accordion",
},
branding: "never",
wallets: {
walletReturnUrl: paymentDetails.return_url,
style: {
theme: "dark",
type: "default",
height: 55,
},
},
};
unifiedCheckout = widgets.create("payment", unifiedCheckoutOptions);
mountUnifiedCheckout("#unified-checkout");
showSDK(paymentDetails.display_sdk_only);
let shimmer = document.getElementById("payment-details-shimmer");
shimmer.classList.add("reduce-opacity")
setTimeout(() => {
document.body.removeChild(shimmer);
}, 500)
}
/**
* Use - mount payment widget on the passed element
* @param {String} id
@ -525,17 +456,6 @@ function showMessage(msg) {
addText("#payment-message", msg);
}
/**
* Use - redirect to /payment_link/status
*/
function redirectToStatus() {
var arr = window.location.pathname.split("/");
arr.splice(0, 2);
arr.unshift("status");
arr.unshift("payment_link");
window.location.href = window.location.origin + "/" + arr.join("/");
}
function addText(id, msg) {
var element = document.querySelector(id);
element.innerText = msg;

View File

@ -0,0 +1,82 @@
// @ts-check
/**
* Trigger - post downloading SDK
* Uses
* - Instantiate SDK
* - Create a payment widget
* - Decide whether or not to show SDK (based on status)
**/
function initializeSDK() {
// @ts-ignore
var paymentDetails = window.__PAYMENT_DETAILS;
var client_secret = paymentDetails.client_secret;
var appearance = {
variables: {
colorPrimary: paymentDetails.theme || "rgb(0, 109, 249)",
fontFamily: "Work Sans, sans-serif",
fontSizeBase: "16px",
colorText: "rgb(51, 65, 85)",
colorTextSecondary: "#334155B3",
colorPrimaryText: "rgb(51, 65, 85)",
colorTextPlaceholder: "#33415550",
borderColor: "#33415550",
colorBackground: "rgb(255, 255, 255)",
},
};
// @ts-ignore
hyper = window.Hyper(pub_key, {
isPreloadEnabled: false,
});
// @ts-ignore
widgets = hyper.widgets({
appearance: appearance,
clientSecret: client_secret,
});
var type =
paymentDetails.sdk_layout === "spaced_accordion" ||
paymentDetails.sdk_layout === "accordion"
? "accordion"
: paymentDetails.sdk_layout;
var unifiedCheckoutOptions = {
displaySavedPaymentMethodsCheckbox: false,
layout: {
type: type, //accordion , tabs, spaced accordion
spacedAccordionItems: paymentDetails.sdk_layout === "spaced_accordion",
},
branding: "never",
wallets: {
walletReturnUrl: paymentDetails.return_url,
style: {
theme: "dark",
type: "default",
height: 55,
},
},
};
// @ts-ignore
unifiedCheckout = widgets.create("payment", unifiedCheckoutOptions);
// @ts-ignore
mountUnifiedCheckout("#unified-checkout");
// @ts-ignore
showSDK(paymentDetails.display_sdk_only);
let shimmer = document.getElementById("payment-details-shimmer");
shimmer.classList.add("reduce-opacity");
setTimeout(() => {
document.body.removeChild(shimmer);
}, 500);
/**
* Use - redirect to /payment_link/status
*/
function redirectToStatus() {
var arr = window.location.pathname.split("/");
arr.splice(0, 2);
arr.unshift("status");
arr.unshift("payment_link");
window.location.href = window.location.origin + "/" + arr.join("/");
}
}

View File

@ -0,0 +1,107 @@
// @ts-check
// Top level checks
var isFramed = false;
try {
isFramed = window.parent.location !== window.location;
// If parent's window object is restricted, DOMException is
// thrown which concludes that the webpage is iframed
} catch (err) {
isFramed = true;
}
if (!isFramed) {
function initializeSDK() {
var errMsg = "You are not allowed to view this content.";
var contentElement = document.getElementById("payment-link");
if (contentElement instanceof HTMLDivElement) {
contentElement.innerHTML = errMsg;
} else {
document.body.innerHTML = errMsg;
}
}
} else {
/**
* Trigger - post downloading SDK
* Uses
* - Instantiate SDK
* - Create a payment widget
* - Decide whether or not to show SDK (based on status)
**/
function initializeSDK() {
// @ts-ignore
var paymentDetails = window.__PAYMENT_DETAILS;
var client_secret = paymentDetails.client_secret;
var appearance = {
variables: {
colorPrimary: paymentDetails.theme || "rgb(0, 109, 249)",
fontFamily: "Work Sans, sans-serif",
fontSizeBase: "16px",
colorText: "rgb(51, 65, 85)",
colorTextSecondary: "#334155B3",
colorPrimaryText: "rgb(51, 65, 85)",
colorTextPlaceholder: "#33415550",
borderColor: "#33415550",
colorBackground: "rgb(255, 255, 255)",
},
};
// @ts-ignore
hyper = window.Hyper(pub_key, {
isPreloadEnabled: false,
});
// @ts-ignore
widgets = hyper.widgets({
appearance: appearance,
clientSecret: client_secret,
});
var type =
paymentDetails.sdk_layout === "spaced_accordion" ||
paymentDetails.sdk_layout === "accordion"
? "accordion"
: paymentDetails.sdk_layout;
var enableSavedPaymentMethod = paymentDetails.enabled_saved_payment_method;
var unifiedCheckoutOptions = {
displaySavedPaymentMethodsCheckbox: enableSavedPaymentMethod,
displaySavedPaymentMethods: enableSavedPaymentMethod,
layout: {
type: type, //accordion , tabs, spaced accordion
spacedAccordionItems: paymentDetails.sdk_layout === "spaced_accordion",
},
branding: "never",
wallets: {
walletReturnUrl: paymentDetails.return_url,
style: {
theme: "dark",
type: "default",
height: 55,
},
},
};
// @ts-ignore
unifiedCheckout = widgets.create("payment", unifiedCheckoutOptions);
// @ts-ignore
mountUnifiedCheckout("#unified-checkout");
// @ts-ignore
showSDK(paymentDetails.display_sdk_only);
let shimmer = document.getElementById("payment-details-shimmer");
shimmer.classList.add("reduce-opacity");
setTimeout(() => {
document.body.removeChild(shimmer);
}, 500);
}
/**
* Use - redirect to /payment_link/status
*/
function redirectToStatus() {
var arr = window.location.pathname.split("/");
arr.splice(0, 3);
arr.unshift("status");
arr.unshift("payment_link");
window.location.href = window.location.origin + "/" + arr.join("/");
}
}

View File

@ -0,0 +1,118 @@
use actix_http::header;
use api_models::admin::PaymentLinkConfig;
use common_utils::validation::validate_domain_against_allowed_domains;
use error_stack::{report, ResultExt};
use url::Url;
use crate::{
core::errors::{self, RouterResult},
types::storage::PaymentLink,
};
pub fn validate_secure_payment_link_render_request(
request_headers: &header::HeaderMap,
payment_link: &PaymentLink,
payment_link_config: &PaymentLinkConfig,
) -> RouterResult<()> {
let link_id = payment_link.payment_link_id.clone();
let allowed_domains = payment_link_config
.allowed_domains
.clone()
.ok_or(report!(errors::ApiErrorResponse::InvalidRequestUrl))
.attach_printable_lazy(|| {
format!(
"Secure payment link was not generated for {}\nmissing allowed_domains",
link_id
)
})?;
// Validate secure_link was generated
if payment_link.secure_link.clone().is_none() {
return Err(report!(errors::ApiErrorResponse::InvalidRequestUrl)).attach_printable_lazy(
|| {
format!(
"Secure payment link was not generated for {}\nmissing secure_link",
link_id
)
},
);
}
// Fetch destination is "iframe"
match request_headers.get("sec-fetch-dest").and_then(|v| v.to_str().ok()) {
Some("iframe") => Ok(()),
Some(requestor) => Err(report!(errors::ApiErrorResponse::AccessForbidden {
resource: "payment_link".to_string(),
}))
.attach_printable_lazy(|| {
format!(
"Access to payment_link [{}] is forbidden when requested through {}",
link_id, requestor
)
}),
None => Err(report!(errors::ApiErrorResponse::AccessForbidden {
resource: "payment_link".to_string(),
}))
.attach_printable_lazy(|| {
format!(
"Access to payment_link [{}] is forbidden when sec-fetch-dest is not present in request headers",
link_id
)
}),
}?;
// Validate origin / referer
let domain_in_req = {
let origin_or_referer = request_headers
.get("origin")
.or_else(|| request_headers.get("referer"))
.and_then(|v| v.to_str().ok())
.ok_or_else(|| {
report!(errors::ApiErrorResponse::AccessForbidden {
resource: "payment_link".to_string(),
})
})
.attach_printable_lazy(|| {
format!(
"Access to payment_link [{}] is forbidden when origin or referer is not present in request headers",
link_id
)
})?;
let url = Url::parse(origin_or_referer)
.map_err(|_| {
report!(errors::ApiErrorResponse::AccessForbidden {
resource: "payment_link".to_string(),
})
})
.attach_printable_lazy(|| {
format!("Invalid URL found in request headers {}", origin_or_referer)
})?;
url.host_str()
.and_then(|host| url.port().map(|port| format!("{}:{}", host, port)))
.or_else(|| url.host_str().map(String::from))
.ok_or_else(|| {
report!(errors::ApiErrorResponse::AccessForbidden {
resource: "payment_link".to_string(),
})
})
.attach_printable_lazy(|| {
format!("host or port not found in request headers {:?}", url)
})?
};
if validate_domain_against_allowed_domains(&domain_in_req, allowed_domains) {
Ok(())
} else {
Err(report!(errors::ApiErrorResponse::AccessForbidden {
resource: "payment_link".to_string(),
}))
.attach_printable_lazy(|| {
format!(
"Access to payment_link [{}] is forbidden from requestor - {}",
link_id, domain_in_req
)
})
}
}

View File

@ -1218,13 +1218,22 @@ async fn create_payment_link(
) -> RouterResult<Option<api_models::payments::PaymentLinkResponse>> {
let created_at @ last_modified_at = Some(common_utils::date_time::now());
let payment_link_id = utils::generate_id(consts::ID_LENGTH, "plink");
let payment_link = format!(
let open_payment_link = format!(
"{}/payment_link/{}/{}",
domain_name,
merchant_id.get_string_repr(),
payment_id.clone()
);
let secure_link = payment_link_config.allowed_domains.as_ref().map(|_| {
format!(
"{}/payment_link/s/{}/{}",
domain_name,
merchant_id.clone(),
payment_id.clone()
)
});
let payment_link_config_encoded_value = payment_link_config.encode_to_value().change_context(
errors::ApiErrorResponse::InvalidDataValue {
field_name: "payment_link_config",
@ -1235,7 +1244,7 @@ async fn create_payment_link(
payment_link_id: payment_link_id.clone(),
payment_id: payment_id.clone(),
merchant_id: merchant_id.clone(),
link_to_pay: payment_link.clone(),
link_to_pay: open_payment_link.clone(),
amount: MinorUnit::from(amount),
currency: request.currency,
created_at,
@ -1245,6 +1254,7 @@ async fn create_payment_link(
description,
payment_link_config: Some(payment_link_config_encoded_value),
profile_id: Some(profile_id),
secure_link,
};
let payment_link_db = db
.insert_payment_link(payment_link_req)
@ -1254,7 +1264,8 @@ async fn create_payment_link(
})?;
Ok(Some(api_models::payments::PaymentLinkResponse {
link: payment_link_db.link_to_pay,
link: payment_link_db.link_to_pay.clone(),
secure_link: payment_link_db.secure_link,
payment_link_id: payment_link_db.payment_link_id,
}))
}

View File

@ -1,13 +1,11 @@
use std::collections::HashSet;
use actix_web::http::header;
#[cfg(feature = "olap")]
use common_utils::errors::CustomResult;
use common_utils::validation::validate_domain_against_allowed_domains;
use diesel_models::generic_link::PayoutLink;
use error_stack::{report, ResultExt};
use globset::Glob;
pub use hyperswitch_domain_models::errors::StorageError;
use router_env::{instrument, logger, tracing};
use router_env::{instrument, tracing};
use url::Url;
use super::helpers;
@ -255,7 +253,7 @@ pub fn validate_payout_link_render_request(
})?
};
if is_domain_allowed(&domain_in_req, link_data.allowed_domains) {
if validate_domain_against_allowed_domains(&domain_in_req, link_data.allowed_domains) {
Ok(())
} else {
Err(report!(errors::ApiErrorResponse::AccessForbidden {
@ -269,12 +267,3 @@ pub fn validate_payout_link_render_request(
})
}
}
fn is_domain_allowed(domain: &str, allowed_domains: HashSet<String>) -> bool {
allowed_domains.iter().any(|allowed_domain| {
Glob::new(allowed_domain)
.map(|glob| glob.compile_matcher().is_match(domain))
.map_err(|err| logger::error!("Invalid glob pattern! - {:?}", err))
.unwrap_or(false)
})
}

View File

@ -1339,6 +1339,10 @@ impl PaymentLink {
web::resource("{merchant_id}/{payment_id}")
.route(web::get().to(initiate_payment_link)),
)
.service(
web::resource("s/{merchant_id}/{payment_id}")
.route(web::get().to(initiate_secure_payment_link)),
)
.service(
web::resource("status/{merchant_id}/{payment_id}")
.route(web::get().to(payment_link_status)),

View File

@ -188,6 +188,7 @@ impl From<Flow> for ApiIdentifier {
Flow::PaymentLinkRetrieve
| Flow::PaymentLinkInitiate
| Flow::PaymentSecureLinkInitiate
| Flow::PaymentLinkList
| Flow::PaymentLinkStatus => Self::PaymentLink,

View File

@ -82,6 +82,39 @@ pub async fn initiate_payment_link(
.await
}
pub async fn initiate_secure_payment_link(
state: web::Data<AppState>,
req: actix_web::HttpRequest,
path: web::Path<(common_utils::id_type::MerchantId, String)>,
) -> impl Responder {
let flow = Flow::PaymentSecureLinkInitiate;
let (merchant_id, payment_id) = path.into_inner();
let payload = api_models::payments::PaymentLinkInitiateRequest {
payment_id,
merchant_id: merchant_id.clone(),
};
let headers = req.headers();
Box::pin(api::server_wrap(
flow,
state,
&req,
payload.clone(),
|state, auth, _, _| {
initiate_secure_payment_link_flow(
state,
auth.merchant_account,
auth.key_store,
payload.merchant_id.clone(),
payload.payment_id.clone(),
headers,
)
},
&crate::services::authentication::MerchantIdAuth(merchant_id),
api_locking::LockAction::NotApplicable,
))
.await
}
/// Payment Link - List
///
/// To list the payment links

View File

@ -47,7 +47,7 @@ use masking::{Maskable, PeekInterface};
use router_env::{instrument, metrics::add_attributes, tracing, tracing_actix_web::RequestId, Tag};
use serde::Serialize;
use serde_json::json;
use tera::{Context, Tera};
use tera::{Context, Error as TeraError, Tera};
use self::request::{HeaderExt, RequestBuilderExt};
use super::{
@ -989,14 +989,18 @@ where
let link_type = boxed_generic_link_data.data.to_string();
match build_generic_link_html(boxed_generic_link_data.data) {
Ok(rendered_html) => {
let domains_str = boxed_generic_link_data
.allowed_domains
.into_iter()
.collect::<Vec<String>>()
.join(" ");
let csp_header = format!("frame-ancestors 'self' {};", domains_str);
let headers = HashSet::from([("content-security-policy", csp_header)]);
http_response_html_data(rendered_html, Some(headers))
let headers = if !boxed_generic_link_data.allowed_domains.is_empty() {
let domains_str = boxed_generic_link_data
.allowed_domains
.into_iter()
.collect::<Vec<String>>()
.join(" ");
let csp_header = format!("frame-ancestors 'self' {};", domains_str);
Some(HashSet::from([("content-security-policy", csp_header)]))
} else {
None
};
http_response_html_data(rendered_html, headers)
}
Err(_) => {
http_response_err(format!("Error while rendering {} HTML page", link_type))
@ -1794,9 +1798,9 @@ pub fn build_redirection_form(
}
}
pub fn build_payment_link_html(
fn build_payment_link_template(
payment_link_data: PaymentLinkFormData,
) -> CustomResult<String, errors::ApiErrorResponse> {
) -> CustomResult<(Tera, Context), errors::ApiErrorResponse> {
let mut tera = Tera::default();
// Add modification to css template with dynamic data
@ -1847,11 +1851,6 @@ pub fn build_payment_link_html(
&get_preload_link_html_template(&payment_link_data.sdk_url),
);
context.insert(
"preload_link_tags",
&get_preload_link_html_template(&payment_link_data.sdk_url),
);
context.insert(
"hyperloader_sdk_link",
&get_hyper_loader_sdk(&payment_link_data.sdk_url),
@ -1861,13 +1860,43 @@ pub fn build_payment_link_html(
context.insert("logging_template", &logging_template);
match tera.render("payment_link", &context) {
Ok(rendered_html) => Ok(rendered_html),
Err(tera_error) => {
Ok((tera, context))
}
pub fn build_payment_link_html(
payment_link_data: PaymentLinkFormData,
) -> CustomResult<String, errors::ApiErrorResponse> {
let (tera, mut context) = build_payment_link_template(payment_link_data)
.attach_printable("Failed to build payment link's HTML template")?;
let payment_link_initiator =
include_str!("../core/payment_link/payment_link_initiate/payment_link_initiator.js")
.to_string();
context.insert("payment_link_initiator", &payment_link_initiator);
tera.render("payment_link", &context)
.map_err(|tera_error: TeraError| {
crate::logger::warn!("{tera_error}");
Err(errors::ApiErrorResponse::InternalServerError)?
}
}
report!(errors::ApiErrorResponse::InternalServerError)
})
.attach_printable("Error while rendering open payment link's HTML template")
}
pub fn build_secure_payment_link_html(
payment_link_data: PaymentLinkFormData,
) -> CustomResult<String, errors::ApiErrorResponse> {
let (tera, mut context) = build_payment_link_template(payment_link_data)
.attach_printable("Failed to build payment link's HTML template")?;
let payment_link_initiator =
include_str!("../core/payment_link/payment_link_initiate/secure_payment_link_initiator.js")
.to_string();
context.insert("payment_link_initiator", &payment_link_initiator);
tera.render("payment_link", &context)
.map_err(|tera_error: TeraError| {
crate::logger::warn!("{tera_error}");
report!(errors::ApiErrorResponse::InternalServerError)
})
.attach_printable("Error while rendering secure payment link's HTML template")
}
fn get_hyper_loader_sdk(sdk_url: &str) -> String {

View File

@ -5,6 +5,7 @@ use hyperswitch_domain_models::api::{
};
use tera::{Context, Tera};
use super::build_secure_payment_link_html;
use crate::core::errors;
pub fn build_generic_link_html(
@ -24,6 +25,9 @@ pub fn build_generic_link_html(
GenericLinksData::PayoutLinkStatus(pm_collect_data) => {
build_payout_link_status_html(&pm_collect_data)
}
GenericLinksData::SecurePaymentLink(payment_link_data) => {
build_secure_payment_link_html(payment_link_data)
}
}
}
@ -45,46 +49,58 @@ pub fn build_generic_expired_link_html(
.attach_printable("Failed to render expired link HTML template")
}
pub fn build_payout_link_html(
fn build_html_template(
link_data: &GenericLinkFormData,
) -> CustomResult<String, errors::ApiErrorResponse> {
let mut tera = Tera::default();
document: &'static str,
script: &'static str,
styles: &'static str,
) -> CustomResult<(Tera, Context), errors::ApiErrorResponse> {
let mut tera: Tera = Tera::default();
let mut context = Context::new();
// Insert dynamic context in CSS
let css_dynamic_context = "{{ color_scheme }}";
let css_template =
include_str!("../../core/generic_link/payout_link/initiate/styles.css").to_string();
let css_template = styles.to_string();
let final_css = format!("{}\n{}", css_dynamic_context, css_template);
let _ = tera.add_raw_template("payout_link_styles", &final_css);
let _ = tera.add_raw_template("document_styles", &final_css);
context.insert("color_scheme", &link_data.css_data);
// Insert dynamic context in JS
let js_dynamic_context = "{{ script_data }}";
let js_template = script.to_string();
let final_js = format!("{}\n{}", js_dynamic_context, js_template);
let _ = tera.add_raw_template("document_scripts", &final_js);
context.insert("script_data", &link_data.js_data);
let css_style_tag = tera
.render("payout_link_styles", &context)
.render("document_styles", &context)
.map(|css| format!("<style>{}</style>", css))
.change_context(errors::ApiErrorResponse::InternalServerError)
.attach_printable("Failed to render payout link's CSS template")?;
// Insert dynamic context in JS
let js_dynamic_context = "{{ payout_link_context }}";
let js_template =
include_str!("../../core/generic_link/payout_link/initiate/script.js").to_string();
let final_js = format!("{}\n{}", js_dynamic_context, js_template);
let _ = tera.add_raw_template("payout_link_script", &final_js);
context.insert("payout_link_context", &link_data.js_data);
.attach_printable("Failed to render CSS template")?;
let js_script_tag = tera
.render("payout_link_script", &context)
.render("document_scripts", &context)
.map(|js| format!("<script>{}</script>", js))
.change_context(errors::ApiErrorResponse::InternalServerError)
.attach_printable("Failed to render payout link's JS template")?;
.attach_printable("Failed to render JS template")?;
// Build HTML
let html_template =
include_str!("../../core/generic_link/payout_link/initiate/index.html").to_string();
let _ = tera.add_raw_template("payout_link", &html_template);
// Insert HTML context
let html_template = document.to_string();
let _ = tera.add_raw_template("html_template", &html_template);
context.insert("css_style_tag", &css_style_tag);
context.insert("js_script_tag", &js_script_tag);
Ok((tera, context))
}
pub fn build_payout_link_html(
link_data: &GenericLinkFormData,
) -> CustomResult<String, errors::ApiErrorResponse> {
let document = include_str!("../../core/generic_link/payout_link/initiate/index.html");
let script = include_str!("../../core/generic_link/payout_link/initiate/script.js");
let styles = include_str!("../../core/generic_link/payout_link/initiate/styles.css");
let (tera, mut context) = build_html_template(link_data, document, script, styles)
.attach_printable("Failed to build context for payout link's HTML template")?;
context.insert(
"hyper_sdk_loader_script_tag",
&format!(
@ -93,7 +109,7 @@ pub fn build_payout_link_html(
),
);
tera.render("payout_link", &context)
tera.render("html_template", &context)
.change_context(errors::ApiErrorResponse::InternalServerError)
.attach_printable("Failed to render payout link's HTML template")
}
@ -101,46 +117,14 @@ pub fn build_payout_link_html(
pub fn build_pm_collect_link_html(
link_data: &GenericLinkFormData,
) -> CustomResult<String, errors::ApiErrorResponse> {
let mut tera = Tera::default();
let mut context = Context::new();
// Insert dynamic context in CSS
let css_dynamic_context = "{{ color_scheme }}";
let css_template =
include_str!("../../core/generic_link/payment_method_collect/initiate/styles.css")
.to_string();
let final_css = format!("{}\n{}", css_dynamic_context, css_template);
let _ = tera.add_raw_template("pm_collect_link_styles", &final_css);
context.insert("color_scheme", &link_data.css_data);
let css_style_tag = tera
.render("pm_collect_link_styles", &context)
.map(|css| format!("<style>{}</style>", css))
.change_context(errors::ApiErrorResponse::InternalServerError)
.attach_printable("Failed to render payment method collect link's CSS template")?;
// Insert dynamic context in JS
let js_dynamic_context = "{{ collect_link_context }}";
let js_template =
include_str!("../../core/generic_link/payment_method_collect/initiate/script.js")
.to_string();
let final_js = format!("{}\n{}", js_dynamic_context, js_template);
let _ = tera.add_raw_template("pm_collect_link_script", &final_js);
context.insert("collect_link_context", &link_data.js_data);
let js_script_tag = tera
.render("pm_collect_link_script", &context)
.map(|js| format!("<script>{}</script>", js))
.change_context(errors::ApiErrorResponse::InternalServerError)
.attach_printable("Failed to render payment method collect link's JS template")?;
// Build HTML
let html_template =
include_str!("../../core/generic_link/payment_method_collect/initiate/index.html")
.to_string();
let _ = tera.add_raw_template("payment_method_collect_link", &html_template);
context.insert("css_style_tag", &css_style_tag);
context.insert("js_script_tag", &js_script_tag);
let document =
include_str!("../../core/generic_link/payment_method_collect/initiate/index.html");
let script = include_str!("../../core/generic_link/payment_method_collect/initiate/script.js");
let styles = include_str!("../../core/generic_link/payment_method_collect/initiate/styles.css");
let (tera, mut context) = build_html_template(link_data, document, script, styles)
.attach_printable(
"Failed to build context for payment method collect link's HTML template",
)?;
context.insert(
"hyper_sdk_loader_script_tag",
&format!(
@ -149,7 +133,7 @@ pub fn build_pm_collect_link_html(
),
);
tera.render("payment_method_collect_link", &context)
tera.render("html_template", &context)
.change_context(errors::ApiErrorResponse::InternalServerError)
.attach_printable("Failed to render payment method collect link's HTML template")
}

View File

@ -30,6 +30,7 @@ impl PaymentLinkResponseExt for RetrievePaymentLinkResponse {
expiry: payment_link.fulfilment_time,
currency: payment_link.currency,
status,
secure_link: payment_link.secure_link,
})
}
}

View File

@ -1355,6 +1355,7 @@ impl ForeignFrom<(storage::PaymentLink, payments::PaymentLinkStatus)>
description: payment_link_config.description,
currency: payment_link_config.currency,
status,
secure_link: payment_link_config.secure_link,
}
}
}