feat(router): Better UI payment link and order details product image and merchant config support (#2583)

Co-authored-by: Sahkal Poddar <sahkal.poddar@juspay.in>
Co-authored-by: Kashif <46213975+kashif-m@users.noreply.github.com>
Co-authored-by: Kashif <mohammed.kashif@juspay.in>
Co-authored-by: Bernard Eugine <114725419+bernard-eugine@users.noreply.github.com>
Co-authored-by: Kashif <kashif@protonmail.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
This commit is contained in:
Sahkal Poddar
2023-10-17 14:40:45 +05:30
committed by GitHub
parent 3807601ee1
commit fdd9580012
19 changed files with 469 additions and 205 deletions

View File

@ -95,6 +95,8 @@ pub struct MerchantAccountCreate {
/// The id of the organization to which the merchant belongs to
pub organization_id: Option<String>,
pub payment_link_config: Option<PaymentLinkConfig>,
}
#[derive(Clone, Debug, Deserialize, Serialize, ToSchema)]
@ -184,6 +186,8 @@ pub struct MerchantAccountUpdate {
/// To unset this field, pass an empty string
#[schema(max_length = 64)]
pub default_profile: Option<String>,
pub payment_link_config: Option<serde_json::Value>,
}
#[derive(Clone, Debug, ToSchema, Serialize)]
@ -277,6 +281,8 @@ pub struct MerchantAccountResponse {
/// A enum value to indicate the status of recon service. By default it is not_requested.
#[schema(value_type = ReconStatus, example = "not_requested")]
pub recon_status: enums::ReconStatus,
pub payment_link_config: Option<serde_json::Value>,
}
#[derive(Clone, Debug, Deserialize, ToSchema, Serialize)]
@ -497,6 +503,22 @@ pub struct PrimaryBusinessDetails {
pub business: String,
}
#[derive(Clone, Debug, Deserialize, ToSchema, Serialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct PaymentLinkConfig {
pub merchant_logo: Option<String>,
pub color_scheme: Option<PaymentLinkColorSchema>,
}
#[derive(Clone, Debug, Deserialize, ToSchema, Serialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct PaymentLinkColorSchema {
pub primary_color: Option<String>,
pub primary_accent_color: Option<String>,
pub secondary_color: Option<String>,
}
#[derive(Clone, Debug, Deserialize, ToSchema, Serialize)]
#[serde(deny_unknown_fields)]
pub struct WebhookDetails {

View File

@ -226,6 +226,7 @@ pub struct PaymentsRequest {
"product_name": "gillete creme",
"quantity": 15,
"amount" : 900
"product_img_link" : "https://dummy-img-link.com"
}]"#)]
pub order_details: Option<Vec<OrderDetailsWithAmount>>,
@ -2418,6 +2419,8 @@ pub struct OrderDetailsWithAmount {
pub quantity: u16,
/// the amount per quantity of product
pub amount: i64,
/// The image URL of the product
pub product_img_link: Option<String>,
}
#[derive(Debug, Default, Eq, PartialEq, serde::Deserialize, serde::Serialize, Clone, ToSchema)]
@ -2428,6 +2431,8 @@ pub struct OrderDetails {
/// The quantity of the product to be purchased
#[schema(example = 1)]
pub quantity: u16,
/// The image URL of the product
pub product_img_link: Option<String>,
}
#[derive(Default, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize, Clone, ToSchema)]
@ -3120,3 +3125,19 @@ pub struct PaymentLinkInitiateRequest {
pub merchant_id: String,
pub payment_id: String,
}
#[derive(Debug, serde::Serialize)]
pub struct PaymentLinkDetails {
pub amount: i64,
pub currency: api_enums::Currency,
pub pub_key: String,
pub client_secret: String,
pub payment_id: String,
#[serde(with = "common_utils::custom_serde::iso8601")]
pub expiry: PrimitiveDateTime,
pub merchant_logo: String,
pub return_url: String,
pub merchant_name: crypto::OptionalEncryptableName,
pub order_details: Vec<pii::SecretSerdeValue>,
pub max_items_visible_after_collapse: i8,
}

View File

@ -40,6 +40,7 @@ pub struct MerchantAccount {
pub is_recon_enabled: bool,
pub default_profile: Option<String>,
pub recon_status: storage_enums::ReconStatus,
pub payment_link_config: Option<serde_json::Value>,
}
#[derive(Clone, Debug, Insertable, router_derive::DebugAsDisplay)]
@ -69,6 +70,7 @@ pub struct MerchantAccountNew {
pub is_recon_enabled: bool,
pub default_profile: Option<String>,
pub recon_status: storage_enums::ReconStatus,
pub payment_link_config: Option<serde_json::Value>,
}
#[derive(Clone, Debug, Default, AsChangeset, router_derive::DebugAsDisplay)]
@ -97,4 +99,5 @@ pub struct MerchantAccountUpdateInternal {
pub is_recon_enabled: bool,
pub default_profile: Option<Option<String>>,
pub recon_status: storage_enums::ReconStatus,
pub payment_link_config: Option<serde_json::Value>,
}

View File

@ -442,6 +442,7 @@ diesel::table! {
#[max_length = 64]
default_profile -> Nullable<Varchar>,
recon_status -> ReconStatus,
payment_link_config -> Nullable<Jsonb>,
}
}

View File

@ -136,6 +136,17 @@ pub async fn create_merchant_account(
.transpose()?
.map(Secret::new);
let payment_link_config = req
.payment_link_config
.as_ref()
.map(|pl_metadata| {
utils::Encode::<admin_types::PaymentLinkConfig>::encode_to_value(pl_metadata)
.change_context(errors::ApiErrorResponse::InvalidDataValue {
field_name: "payment_link_config",
})
})
.transpose()?;
let mut merchant_account = async {
Ok(domain::MerchantAccount {
merchant_id: req.merchant_id,
@ -171,6 +182,7 @@ pub async fn create_merchant_account(
is_recon_enabled: false,
default_profile: None,
recon_status: diesel_models::enums::ReconStatus::NotRequested,
payment_link_config,
})
}
.await
@ -458,6 +470,7 @@ pub async fn merchant_account_update(
intent_fulfillment_time: req.intent_fulfillment_time.map(i64::from),
payout_routing_algorithm: req.payout_routing_algorithm,
default_profile: business_profile_id_update,
payment_link_config: req.payment_link_config,
};
let response = db

View File

@ -1,7 +1,8 @@
use api_models::admin as admin_types;
use common_utils::ext_traits::AsyncExt;
use error_stack::ResultExt;
use error_stack::{IntoReport, ResultExt};
use super::errors::{self, StorageErrorExt};
use super::errors::{self, RouterResult, StorageErrorExt};
use crate::{
core::payments::helpers,
errors::RouterResponse,
@ -41,6 +42,19 @@ pub async fn intiate_payment_link_flow(
)
.await
.to_not_found_response(errors::ApiErrorResponse::PaymentNotFound)?;
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,
],
"create payment link",
)?;
let fulfillment_time = payment_intent
.payment_link_id
.as_ref()
@ -56,32 +70,63 @@ pub async fn intiate_payment_link_flow(
.get_required_value("fulfillment_time")
.change_context(errors::ApiErrorResponse::PaymentNotFound)?;
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,
],
"create payment link",
let payment_link_config = merchant_account
.payment_link_config
.map(|pl_config| {
serde_json::from_value::<admin_types::PaymentLinkConfig>(pl_config)
.into_report()
.change_context(errors::ApiErrorResponse::InvalidDataValue {
field_name: "payment_link_config",
})
})
.transpose()?;
let order_details = payment_intent
.order_details
.get_required_value("order_details")
.change_context(errors::ApiErrorResponse::MissingRequiredField {
field_name: "order_details",
})?;
let return_url = if let Some(payment_create_return_url) = payment_intent.return_url {
payment_create_return_url
} else {
merchant_account
.return_url
.ok_or(errors::ApiErrorResponse::MissingRequiredField {
field_name: "return_url",
})?
};
let (pub_key, currency, client_secret) = validate_sdk_requirements(
merchant_account.publishable_key,
payment_intent.currency,
payment_intent.client_secret,
)?;
let expiry = fulfillment_time.assume_utc().unix_timestamp();
let js_script = get_js_script(
payment_intent.amount.to_string(),
payment_intent.currency.unwrap_or_default().to_string(),
merchant_account.publishable_key.unwrap_or_default(),
payment_intent.client_secret.unwrap_or_default(),
payment_intent.payment_id,
expiry,
);
let payment_details = api_models::payments::PaymentLinkDetails {
amount: payment_intent.amount,
currency,
payment_id: payment_intent.payment_id,
merchant_name: merchant_account.merchant_name,
order_details,
return_url,
expiry: fulfillment_time,
pub_key,
client_secret,
merchant_logo: payment_link_config
.clone()
.map(|pl_metadata| pl_metadata.merchant_logo.unwrap_or_default())
.unwrap_or_default(),
max_items_visible_after_collapse: 3,
};
let js_script = get_js_script(payment_details)?;
let css_script = get_color_scheme_css(payment_link_config.clone());
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,
@ -93,80 +138,68 @@ The get_js_script function is used to inject dynamic value to payment_link sdk,
*/
fn get_js_script(
amount: String,
currency: String,
pub_key: String,
secret: String,
payment_id: String,
expiry: i64,
) -> String {
format!(
"window.__PAYMENT_DETAILS_STR = JSON.stringify({{
client_secret: '{secret}',
amount: '{amount}',
currency: '{currency}',
payment_id: '{payment_id}',
expiry: {expiry},
// TODO: Remove hardcoded values
merchant_logo: 'https://upload.wikimedia.org/wikipedia/commons/8/83/Steam_icon_logo.svg',
return_url: 'http://localhost:5500/public/index.html',
currency_symbol: '$',
merchant: 'Steam',
max_items_visible_after_collapse: 3,
order_details: [
{{
product_name:
'dskjghbdsiuh sagfvbsajd ugbfiusedg fiudshgiu sdhgvishd givuhdsifu gnb gidsug biuesbdg iubsedg bsduxbg jhdxbgv jdskfbgi sdfgibuh ew87t54378 ghdfjbv jfdhgvb dufhvbfidu hg5784ghdfbjnk f (taxes incl.)',
quantity: 2,
amount: 100,
product_image:
'https://upload.wikimedia.org/wikipedia/commons/8/83/Steam_icon_logo.svg',
}},
{{
product_name: \"F1 '23\",
quantity: 4,
amount: 500,
product_image:
'https://upload.wikimedia.org/wikipedia/commons/8/83/Steam_icon_logo.svg',
}},
{{
product_name: \"Motosport '24\",
quantity: 4,
amount: 500,
product_image:
'https://upload.wikimedia.org/wikipedia/commons/8/83/Steam_icon_logo.svg',
}},
{{
product_name: 'Trackmania',
quantity: 4,
amount: 500,
product_image:
'https://upload.wikimedia.org/wikipedia/commons/8/83/Steam_icon_logo.svg',
}},
{{
product_name: 'Ghost Recon',
quantity: 4,
amount: 500,
product_image:
'https://upload.wikimedia.org/wikipedia/commons/8/83/Steam_icon_logo.svg',
}},
{{
product_name: 'Cup of Tea',
quantity: 4,
amount: 500,
product_image:
'https://upload.wikimedia.org/wikipedia/commons/8/83/Steam_icon_logo.svg',
}},
{{
product_name: 'Tea cups',
quantity: 4,
amount: 500,
product_image:
'https://upload.wikimedia.org/wikipedia/commons/8/83/Steam_icon_logo.svg',
}},
]
}});
payment_details: api_models::payments::PaymentLinkDetails,
) -> 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")?;
Ok(format!("window.__PAYMENT_DETAILS = {payment_details_str};"))
}
const hyper = Hyper(\"{pub_key}\");"
fn get_color_scheme_css(
payment_link_config: Option<api_models::admin::PaymentLinkConfig>,
) -> String {
let (default_primary_color, default_accent_color, default_secondary_color) = (
"#C6C7C8".to_string(),
"#6A8EF5".to_string(),
"#0C48F6".to_string(),
);
let (primary_color, primary_accent_color, secondary_color) = payment_link_config
.and_then(|pl_config| {
pl_config.color_scheme.map(|color| {
(
color.primary_color.unwrap_or(default_primary_color.clone()),
color
.primary_accent_color
.unwrap_or(default_accent_color.clone()),
color
.secondary_color
.unwrap_or(default_secondary_color.clone()),
)
})
})
.unwrap_or((
default_primary_color,
default_accent_color,
default_secondary_color,
));
format!(
":root {{
--primary-color: {primary_color};
--primary-accent-color: {primary_accent_color};
--secondary-color: {secondary_color};
}}"
)
}
fn validate_sdk_requirements(
pub_key: Option<String>,
currency: Option<api_models::enums::Currency>,
client_secret: Option<String>,
) -> Result<(String, api_models::enums::Currency, String), errors::ApiErrorResponse> {
let pub_key = pub_key.ok_or(errors::ApiErrorResponse::MissingRequiredField {
field_name: "pub_key",
})?;
let currency = currency.ok_or(errors::ApiErrorResponse::MissingRequiredField {
field_name: "currency",
})?;
let client_secret = client_secret.ok_or(errors::ApiErrorResponse::MissingRequiredField {
field_name: "client_secret",
})?;
Ok((pub_key, currency, client_secret))
}

View File

@ -3,15 +3,12 @@
<head>
{{ hyperloader_sdk_link }}
<style>
:root {
--primary-color: #26c5a0;
--primary-accent-color: #f8e5a0;
--secondary-color: #1c7ed9;
}
{{ css_color_scheme }}
html,
body {
height: 100%;
overflow: hidden;
}
body {
@ -24,6 +21,19 @@
color: #292929;
}
/* Hide scrollbar for Chrome, Safari and Opera */
.hide-scrollbar::-webkit-scrollbar {
display: none;
}
/* Hide scrollbar for IE, Edge and Firefox */
.hide-scrollbar {
-ms-overflow-style: none;
/* IE and Edge */
scrollbar-width: none;
/* Firefox */
}
.hidden {
display: none !important;
}
@ -125,17 +135,18 @@
#hyper-checkout-cart {
display: flex;
flex-flow: column;
min-width: 582px;
width: min-content;
min-width: 584px;
width: 400px;
margin-top: 30px;
max-height: 600px;
}
#hyper-checkout-cart-items {
margin: 10px 0;
max-height: 600px;
margin: 10px 20px 10px 0;
max-height: 354px;
height: 354px;
overflow: scroll;
padding-right: 20px;
transition: all 0.3s ease;
}
.hyper-checkout-cart-header {
@ -152,18 +163,20 @@
.cart-close {
display: none;
cursor: pointer;
}
.hyper-checkout-cart-item {
display: flex;
flex-flow: row;
margin: 20px 0;
padding: 20px 10px;
font-size: 15px;
box-shadow: 0px 0px 10px #efefef;
}
.hyper-checkout-cart-product-image {
height: 88px;
width: 88px;
height: 72px;
width: 72px;
}
.hyper-checkout-card-item-name {
@ -199,6 +212,7 @@
font-size: 16px;
padding-left: 30px;
text-align: end;
min-width: max-content;
}
.hyper-checkout-cart-item-divider {
@ -373,6 +387,24 @@
}
}
@keyframes slide-from-right {
from {
right: -582px;
}
to {
right: 0;
}
}
@keyframes slide-to-right {
from {
right: 0;
}
to {
right: -582px;
}
}
.spinner:before {
width: 10.4px;
height: 20.4px;
@ -481,12 +513,14 @@
z-index: 100;
margin: 0;
min-width: 300px;
max-width: 600px;
max-width: 582px;
max-height: 100vh;
width: 100vw;
height: 100vh;
background-color: #f5f5f5;
box-shadow: 0px 10px 10px #aeaeae;
right: 0px;
animation: slide-from-right 0.3s linear;
}
.hyper-checkout-cart-header {
@ -499,7 +533,7 @@
}
#hyper-checkout-cart-items {
margin: 20px;
margin: 20px 20px 0 20px;
padding: 0;
max-height: max-content;
}
@ -594,7 +628,7 @@
<path
d="M671.504,577.829l110.485-432.609H902.86v-68H729.174L703.128,179.2L0,178.697l74.753,399.129h596.751V577.829z
M685.766,247.188l-67.077,262.64H131.199L81.928,246.756L685.766,247.188z"
/>
></path>
<path
d="M578.418,825.641c59.961,0,108.743-48.783,108.743-108.744s-48.782-108.742-108.743-108.742H168.717
c-59.961,0-108.744,48.781-108.744,108.742s48.782,108.744,108.744,108.744c59.962,0,108.743-48.783,108.743-108.744
@ -603,7 +637,7 @@
c-22.466,0-40.744-18.277-40.744-40.744c0-22.465,18.277-40.742,40.744-40.742C191.183,676.155,209.46,694.432,209.46,716.897z
M619.162,716.897c0,22.467-18.277,40.744-40.743,40.744s-40.743-18.277-40.743-40.744c0-22.465,18.277-40.742,40.743-40.742
S619.162,694.432,619.162,716.897z"
/>
></path>
</g>
</g>
</svg>
@ -612,7 +646,7 @@
</div>
<div id="hyper-checkout-payment-footer"></div>
</div>
<div id="hyper-checkout-cart">
<div id="hyper-checkout-cart" class="hidden">
<div class="hyper-checkout-cart-header">
<svg
width="16"
@ -632,18 +666,18 @@
width="16"
height="16"
>
<rect width="16" height="16" fill="#D9D9D9" />
<rect width="16" height="16" fill="#D9D9D9"></rect>
</mask>
<g mask="url(#mask0_11421_6708)">
<path
d="M3.53716 14.3331C3.20469 14.3331 2.92071 14.2153 2.68525 13.9798C2.44977 13.7444 2.33203 13.4604 2.33203 13.1279V5.53823C2.33203 5.20575 2.44977 4.92178 2.68525 4.68631C2.92071 4.45083 3.20469 4.3331 3.53716 4.3331H4.9987C4.9987 3.50063 5.29058 2.79252 5.87433 2.20876C6.4581 1.62501 7.16621 1.33313 7.99868 1.33313C8.83115 1.33313 9.53927 1.62501 10.123 2.20876C10.7068 2.79252 10.9987 3.50063 10.9987 4.3331H12.4602C12.7927 4.3331 13.0766 4.45083 13.3121 4.68631C13.5476 4.92178 13.6653 5.20575 13.6653 5.53823V13.1279C13.6653 13.4604 13.5476 13.7444 13.3121 13.9798C13.0766 14.2153 12.7927 14.3331 12.4602 14.3331H3.53716ZM3.53716 13.3331H12.4602C12.5115 13.3331 12.5585 13.3117 12.6012 13.269C12.644 13.2262 12.6653 13.1792 12.6653 13.1279V5.53823C12.6653 5.48694 12.644 5.43992 12.6012 5.39718C12.5585 5.35445 12.5115 5.33308 12.4602 5.33308H3.53716C3.48588 5.33308 3.43886 5.35445 3.39611 5.39718C3.35338 5.43992 3.33201 5.48694 3.33201 5.53823V13.1279C3.33201 13.1792 3.35338 13.2262 3.39611 13.269C3.43886 13.3117 3.48588 13.3331 3.53716 13.3331ZM7.99868 8.99973C8.83115 8.99973 9.53927 8.70785 10.123 8.1241C10.7068 7.54033 10.9987 6.83221 10.9987 5.99975H9.99868C9.99868 6.5553 9.80424 7.02752 9.41535 7.41641C9.02646 7.8053 8.55424 7.99975 7.99868 7.99975C7.44313 7.99975 6.9709 7.8053 6.58202 7.41641C6.19313 7.02752 5.99868 6.5553 5.99868 5.99975H4.9987C4.9987 6.83221 5.29058 7.54033 5.87433 8.1241C6.4581 8.70785 7.16621 8.99973 7.99868 8.99973ZM5.99868 4.3331H9.99868C9.99868 3.77754 9.80424 3.30532 9.41535 2.91643C9.02646 2.52754 8.55424 2.3331 7.99868 2.3331C7.44313 2.3331 6.9709 2.52754 6.58202 2.91643C6.19313 3.30532 5.99868 3.77754 5.99868 4.3331Z"
fill="#333333"
/>
></path>
</g>
</g>
<defs>
<clipPath id="clip0_11421_6708">
<rect width="16" height="16" fill="white" />
<rect width="16" height="16" fill="white"></rect>
</clipPath>
</defs>
</svg>
@ -658,10 +692,10 @@
>
<path
d="M 9.15625 6.3125 L 6.3125 9.15625 L 22.15625 25 L 6.21875 40.96875 L 9.03125 43.78125 L 25 27.84375 L 40.9375 43.78125 L 43.78125 40.9375 L 27.84375 25 L 43.6875 9.15625 L 40.84375 6.3125 L 25 22.15625 Z"
/>
></path>
</svg>
</div>
<div id="hyper-checkout-cart-items"></div>
<div id="hyper-checkout-cart-items" class="hide-scrollbar"></div>
</div>
</div>
<div class="hyper-checkout-sdk" id="hyper-checkout-sdk">
@ -762,16 +796,11 @@
prevHeight: window.innerHeight,
prevWidth: window.innerWidth,
isMobileView: window.innerWidth <= 1200,
}
};
var widgets = null;
window.__PAYMENT_DETAILS = {};
try {
window.__PAYMENT_DETAILS = JSON.parse(window.__PAYMENT_DETAILS_STR);
} catch (error) {
console.error("Failed to parse payment details");
}
const pub_key = window.__PAYMENT_DETAILS.pub_key;
const hyper = Hyper(pub_key);
async function initialize() {
const paymentDetails = window.__PAYMENT_DETAILS;
@ -1076,12 +1105,13 @@
var priceNode = document.createElement("div");
priceNode.className = "hyper-checkout-payment-price";
priceNode.innerText =
paymentDetails.currency_symbol + paymentDetails.amount;
paymentDetails.currency + " " + paymentDetails.amount;
// Create merchant name's node
var merchantNameNode = document.createElement("div");
merchantNameNode.className = "hyper-checkout-payment-merchant-name";
merchantNameNode.innerText = "Requested by " + paymentDetails.merchant;
merchantNameNode.innerText =
"Requested by " + paymentDetails.merchant_name;
// Create payment ID node
var paymentIdNode = document.createElement("div");
@ -1128,46 +1158,15 @@
// Cart items
if (Array.isArray(orderDetails)) {
orderDetails.map((item, index) => {
// Wrappers
var itemWrapperNode = document.createElement("div");
itemWrapperNode.className = `${
index >= MAX_ITEMS_VISIBLE_AFTER_COLLAPSE ? "hidden " : ""
}hyper-checkout-cart-item`;
var nameAndQuantityWrapperNode = document.createElement("div");
nameAndQuantityWrapperNode.className =
"hyper-checkout-cart-product-details";
// Image
var productImageNode = document.createElement("img");
productImageNode.className = "hyper-checkout-cart-product-image";
productImageNode.src = item.product_image;
// Product title
var productNameNode = document.createElement("div");
productNameNode.className = "hyper-checkout-card-item-name";
productNameNode.innerText = item.product_name;
// Product quantity
var quantityNode = document.createElement("div");
quantityNode.className = "hyper-checkout-card-item-quantity";
quantityNode.innerText = "Qty: " + item.quantity;
// Product price
var priceNode = document.createElement("div");
priceNode.className = "hyper-checkout-card-item-price";
priceNode.innerText = paymentDetails.currency_symbol + item.amount;
// Divider node
var dividerNode = document.createElement("div");
dividerNode.className = `${
index !== 0 && index < MAX_ITEMS_VISIBLE_AFTER_COLLAPSE
? ""
: "hidden "
}hyper-checkout-cart-item-divider`;
// Append items
nameAndQuantityWrapperNode.append(productNameNode, quantityNode);
itemWrapperNode.append(
productImageNode,
nameAndQuantityWrapperNode,
priceNode
if (index >= MAX_ITEMS_VISIBLE_AFTER_COLLAPSE) {
return;
}
renderCartItem(
item,
paymentDetails,
index !== 0 && index < MAX_ITEMS_VISIBLE_AFTER_COLLAPSE,
cartItemsNode
);
cartItemsNode.append(dividerNode, itemWrapperNode);
});
}
@ -1188,6 +1187,49 @@
}
}
function renderCartItem(
item,
paymentDetails,
shouldAddDividerNode,
cartItemsNode
) {
// Wrappers
var itemWrapperNode = document.createElement("div");
itemWrapperNode.className = "hyper-checkout-cart-item";
var nameAndQuantityWrapperNode = document.createElement("div");
nameAndQuantityWrapperNode.className =
"hyper-checkout-cart-product-details";
// Image
var productImageNode = document.createElement("img");
productImageNode.className = "hyper-checkout-cart-product-image";
productImageNode.src = item.product_img_link;
// Product title
var productNameNode = document.createElement("div");
productNameNode.className = "hyper-checkout-card-item-name";
productNameNode.innerText = item.product_name;
// Product quantity
var quantityNode = document.createElement("div");
quantityNode.className = "hyper-checkout-card-item-quantity";
quantityNode.innerText = "Qty: " + item.quantity;
// Product price
var priceNode = document.createElement("div");
priceNode.className = "hyper-checkout-card-item-price";
priceNode.innerText = paymentDetails.currency + " " + item.amount;
// Append items
nameAndQuantityWrapperNode.append(productNameNode, quantityNode);
itemWrapperNode.append(
productImageNode,
nameAndQuantityWrapperNode,
priceNode
);
if (shouldAddDividerNode) {
var dividerNode = document.createElement("div");
dividerNode.className = "hyper-checkout-cart-item-divider";
cartItemsNode.append(dividerNode);
}
cartItemsNode.append(itemWrapperNode);
}
function handleCartView() {
const paymentDetails = window.__PAYMENT_DETAILS;
const orderDetails = paymentDetails.order_details;
@ -1201,34 +1243,70 @@
);
var cartItems = [].slice.call(itemsHTMLCollection);
var dividerItems = [].slice.call(dividerHTMLCollection);
var isHidden = false;
cartItems.map((item) => {
isHidden = item.classList.contains("hidden");
});
cartItems.map((item, index) => {
if (index >= MAX_ITEMS_VISIBLE_AFTER_COLLAPSE) {
item.className = `${
isHidden ? "" : "hidden "
}hyper-checkout-cart-item`;
}
});
dividerItems.map((divider, index) => {
if (index >= MAX_ITEMS_VISIBLE_AFTER_COLLAPSE) {
divider.className = `${
isHidden ? "" : "hidden "
}hyper-checkout-cart-item-divider`;
}
});
var isHidden = cartItems.length < orderDetails.length;
var cartItemsNode = document.getElementById("hyper-checkout-cart-items");
var cartButtonTextNode = document.getElementById(
"hyper-checkout-cart-button-text"
);
if (isHidden) {
if (Array.isArray(orderDetails)) {
orderDetails.map((item, index) => {
if (index < MAX_ITEMS_VISIBLE_AFTER_COLLAPSE) {
return;
}
renderCartItem(
item,
paymentDetails,
index >= MAX_ITEMS_VISIBLE_AFTER_COLLAPSE,
cartItemsNode
);
});
}
cartItemsNode.style.maxHeight = cartItemsNode.scrollHeight + "px";
cartItemsNode.style.height = cartItemsNode.scrollHeight + "px";
cartButtonTextNode.innerText = "Show Less";
} else {
cartItemsNode.style.maxHeight = "354px";
cartItemsNode.style.height = "354px";
cartItemsNode.scrollTo({ top: 0, behavior: "smooth" });
setTimeout(() => {
cartItems.map((item, index) => {
if (index < MAX_ITEMS_VISIBLE_AFTER_COLLAPSE) {
return;
}
cartItemsNode.removeChild(item);
});
dividerItems.map((item, index) => {
if (index < MAX_ITEMS_VISIBLE_AFTER_COLLAPSE - 1) {
return;
}
cartItemsNode.removeChild(item);
});
}, 300);
setTimeout(() => {
const hiddenItemsCount =
orderDetails.length - MAX_ITEMS_VISIBLE_AFTER_COLLAPSE;
cartButtonTextNode.innerText = isHidden
? "Show Less"
: `Show More (${hiddenItemsCount})`;
cartButtonTextNode.innerText = `Show More (${hiddenItemsCount})`;
}, 250);
}
}
function hideCartInMobileView() {
window.history.back();
const cartNode = document.getElementById("hyper-checkout-cart");
cartNode.style.animation = "slide-to-right 0.3s linear";
cartNode.style.right = "-582px";
setTimeout(() => {
hide("#hyper-checkout-cart");
}, 300);
}
function viewCartInMobileView() {
window.history.pushState("view-cart", "");
const cartNode = document.getElementById("hyper-checkout-cart");
cartNode.style.animation = "slide-from-right 0.3s linear";
cartNode.style.right = "0px";
show("#hyper-checkout-cart");
}
function hideCartInMobileView() {
@ -1250,7 +1328,7 @@
var sdkHeaderMerchantNameNode = document.createElement("div");
sdkHeaderMerchantNameNode.className =
"hyper-checkout-sdk-header-brand-name";
sdkHeaderMerchantNameNode.innerText = paymentDetails.merchant;
sdkHeaderMerchantNameNode.innerText = paymentDetails.merchant_name;
var sdkHeaderAmountNode = document.createElement("div");
sdkHeaderAmountNode.className = "hyper-checkout-sdk-header-amount";
sdkHeaderAmountNode.innerText =
@ -1295,7 +1373,6 @@
window.addEventListener("resize", (event) => {
const currentHeight = window.innerHeight;
const currentWidth = window.innerWidth;
if (currentWidth <= 1200 && window.state.prevWidth > 1200) {
hide("#hyper-checkout-cart");
} else if (currentWidth > 1200 && window.state.prevWidth <= 1200) {

View File

@ -3410,6 +3410,7 @@ impl ApplePayData {
pub fn validate_payment_link_request(
payment_link_object: &api_models::payments::PaymentLinkObject,
confirm: Option<bool>,
order_details: Option<Vec<api_models::payments::OrderDetailsWithAmount>>,
) -> Result<(), errors::ApiErrorResponse> {
if let Some(cnf) = confirm {
if !cnf {
@ -3418,6 +3419,10 @@ pub fn validate_payment_link_request(
return Err(errors::ApiErrorResponse::InvalidRequestData {
message: "link_expiry time cannot be less than current time".to_string(),
});
} else if order_details.is_none() {
return Err(errors::ApiErrorResponse::InvalidRequestData {
message: "cannot create payment link without order details".to_string(),
});
}
} else {
return Err(errors::ApiErrorResponse::InvalidRequestData {

View File

@ -486,7 +486,11 @@ impl<F: Send + Clone, Ctx: PaymentMethodRetrieve> ValidateRequest<F, api::Paymen
helpers::validate_customer_details_in_request(request)?;
if let Some(payment_link_object) = &request.payment_link_object {
helpers::validate_payment_link_request(payment_link_object, request.confirm)?;
helpers::validate_payment_link_request(
payment_link_object,
request.confirm,
request.order_details.clone(),
)?;
}
let given_payment_id = match &request.payment_id {

View File

@ -916,6 +916,7 @@ pub fn change_order_details_to_new_type(
product_name: order_details.product_name,
quantity: order_details.quantity,
amount: order_amount,
product_img_link: order_details.product_img_link,
}])
}

View File

@ -180,6 +180,8 @@ Never share your secret api keys. Keep them guarded and secure.
api_models::admin::MerchantConnectorDetailsWrap,
api_models::admin::MerchantConnectorDetails,
api_models::admin::MerchantConnectorWebhookDetails,
api_models::admin::PaymentLinkConfig,
api_models::admin::PaymentLinkColorSchema,
api_models::disputes::DisputeResponse,
api_models::disputes::DisputeResponsePaymentsRetrieve,
api_models::payments::AddressDetails,

View File

@ -663,6 +663,7 @@ pub enum ApplicationResponse<R> {
#[derive(Debug, Eq, PartialEq, Clone, serde::Serialize, serde::Deserialize)]
pub struct PaymentLinkFormData {
pub js_script: String,
pub css_script: String,
pub sdk_url: String,
}
@ -1378,6 +1379,7 @@ pub fn build_payment_link_html(
"hyperloader_sdk_link",
&get_hyper_loader_sdk(&payment_link_data.sdk_url),
);
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", &context) {

View File

@ -46,6 +46,7 @@ impl TryFrom<domain::MerchantAccount> for MerchantAccountResponse {
is_recon_enabled: item.is_recon_enabled,
default_profile: item.default_profile,
recon_status: item.recon_status,
payment_link_config: item.payment_link_config,
})
}
}

View File

@ -44,6 +44,7 @@ pub struct MerchantAccount {
pub is_recon_enabled: bool,
pub default_profile: Option<String>,
pub recon_status: diesel_models::enums::ReconStatus,
pub payment_link_config: Option<serde_json::Value>,
}
#[allow(clippy::large_enum_variant)]
@ -68,6 +69,7 @@ pub enum MerchantAccountUpdate {
frm_routing_algorithm: Option<serde_json::Value>,
payout_routing_algorithm: Option<serde_json::Value>,
default_profile: Option<Option<String>>,
payment_link_config: Option<serde_json::Value>,
},
StorageSchemeUpdate {
storage_scheme: MerchantStorageScheme,
@ -100,6 +102,7 @@ impl From<MerchantAccountUpdate> for MerchantAccountUpdateInternal {
frm_routing_algorithm,
payout_routing_algorithm,
default_profile,
payment_link_config,
} => Self {
merchant_name: merchant_name.map(Encryption::from),
merchant_details: merchant_details.map(Encryption::from),
@ -120,6 +123,7 @@ impl From<MerchantAccountUpdate> for MerchantAccountUpdateInternal {
intent_fulfillment_time,
payout_routing_algorithm,
default_profile,
payment_link_config,
..Default::default()
},
MerchantAccountUpdate::StorageSchemeUpdate { storage_scheme } => Self {
@ -173,6 +177,7 @@ impl super::behaviour::Conversion for MerchantAccount {
is_recon_enabled: self.is_recon_enabled,
default_profile: self.default_profile,
recon_status: self.recon_status,
payment_link_config: self.payment_link_config,
})
}
@ -217,6 +222,7 @@ impl super::behaviour::Conversion for MerchantAccount {
is_recon_enabled: item.is_recon_enabled,
default_profile: item.default_profile,
recon_status: item.recon_status,
payment_link_config: item.payment_link_config,
})
}
.await
@ -252,6 +258,7 @@ impl super::behaviour::Conversion for MerchantAccount {
is_recon_enabled: self.is_recon_enabled,
default_profile: self.default_profile,
recon_status: self.recon_status,
payment_link_config: self.payment_link_config,
})
}
}

View File

@ -76,6 +76,7 @@ fn payment_method_details() -> Option<types::PaymentsAuthorizeData> {
product_name: "iphone 13".to_string(),
quantity: 1,
amount: 1000,
product_img_link: None,
}]),
router_return_url: Some("https://hyperswitch.io".to_string()),
webhook_url: Some("https://hyperswitch.io".to_string()),
@ -370,6 +371,7 @@ async fn should_fail_payment_for_incorrect_cvc() {
product_name: "iphone 13".to_string(),
quantity: 1,
amount: 100,
product_img_link: None,
}]),
router_return_url: Some("https://hyperswitch.io".to_string()),
webhook_url: Some("https://hyperswitch.io".to_string()),
@ -402,6 +404,7 @@ async fn should_fail_payment_for_invalid_exp_month() {
product_name: "iphone 13".to_string(),
quantity: 1,
amount: 100,
product_img_link: None,
}]),
router_return_url: Some("https://hyperswitch.io".to_string()),
webhook_url: Some("https://hyperswitch.io".to_string()),
@ -434,6 +437,7 @@ async fn should_fail_payment_for_incorrect_expiry_year() {
product_name: "iphone 13".to_string(),
quantity: 1,
amount: 100,
product_img_link: None,
}]),
router_return_url: Some("https://hyperswitch.io".to_string()),
webhook_url: Some("https://hyperswitch.io".to_string()),

View File

@ -313,6 +313,7 @@ async fn should_fail_payment_for_incorrect_card_number() {
product_name: "test".to_string(),
quantity: 1,
amount: 1000,
product_img_link: None,
}]),
email: Some(Email::from_str("test@gmail.com").unwrap()),
webhook_url: Some("https://1635-116-74-253-164.ngrok-free.app".to_string()),
@ -348,6 +349,7 @@ async fn should_fail_payment_for_incorrect_cvc() {
product_name: "test".to_string(),
quantity: 1,
amount: 1000,
product_img_link: None,
}]),
email: Some(Email::from_str("test@gmail.com").unwrap()),
webhook_url: Some("https://1635-116-74-253-164.ngrok-free.app".to_string()),
@ -383,6 +385,7 @@ async fn should_fail_payment_for_invalid_exp_month() {
product_name: "test".to_string(),
quantity: 1,
amount: 1000,
product_img_link: None,
}]),
email: Some(Email::from_str("test@gmail.com").unwrap()),
webhook_url: Some("https://1635-116-74-253-164.ngrok-free.app".to_string()),
@ -418,6 +421,7 @@ async fn should_fail_payment_for_incorrect_expiry_year() {
product_name: "test".to_string(),
quantity: 1,
amount: 1000,
product_img_link: None,
}]),
email: Some(Email::from_str("test@gmail.com").unwrap()),
webhook_url: Some("https://1635-116-74-253-164.ngrok-free.app".to_string()),

View File

@ -0,0 +1,2 @@
-- This file should undo anything in `up.sql`
ALTER TABLE merchant_account DROP COLUMN payment_link_config;

View File

@ -0,0 +1,4 @@
-- Your SQL goes here
ALTER TABLE merchant_account
ADD COLUMN IF NOT EXISTS payment_link_config JSONB NULL;

View File

@ -6172,6 +6172,14 @@
"type": "string",
"description": "The id of the organization to which the merchant belongs to",
"nullable": true
},
"payment_link_config": {
"allOf": [
{
"$ref": "#/components/schemas/PaymentLinkConfig"
}
],
"nullable": true
}
}
},
@ -6345,6 +6353,9 @@
},
"recon_status": {
"$ref": "#/components/schemas/ReconStatus"
},
"payment_link_config": {
"nullable": true
}
}
},
@ -6477,6 +6488,9 @@
"description": "The default business profile that must be used for creating merchant accounts and payments\nTo unset this field, pass an empty string",
"nullable": true,
"maxLength": 64
},
"payment_link_config": {
"nullable": true
}
}
},
@ -7323,6 +7337,11 @@
"description": "The quantity of the product to be purchased",
"example": 1,
"minimum": 0
},
"product_img_link": {
"type": "string",
"description": "The image URL of the product",
"nullable": true
}
}
},
@ -7351,6 +7370,11 @@
"type": "integer",
"format": "int64",
"description": "the amount per quantity of product"
},
"product_img_link": {
"type": "string",
"description": "The image URL of the product",
"nullable": true
}
}
},
@ -7790,6 +7814,40 @@
}
]
},
"PaymentLinkColorSchema": {
"type": "object",
"properties": {
"primary_color": {
"type": "string",
"nullable": true
},
"primary_accent_color": {
"type": "string",
"nullable": true
},
"secondary_color": {
"type": "string",
"nullable": true
}
}
},
"PaymentLinkConfig": {
"type": "object",
"properties": {
"merchant_logo": {
"type": "string",
"nullable": true
},
"color_scheme": {
"allOf": [
{
"$ref": "#/components/schemas/PaymentLinkColorSchema"
}
],
"nullable": true
}
}
},
"PaymentLinkInitiateRequest": {
"type": "object",
"required": [
@ -8778,7 +8836,7 @@
"$ref": "#/components/schemas/OrderDetailsWithAmount"
},
"description": "Information about the product , quantity and amount for connectors. (e.g. Klarna)",
"example": "[{\n \"product_name\": \"gillete creme\",\n \"quantity\": 15,\n \"amount\" : 900\n }]",
"example": "[{\n \"product_name\": \"gillete creme\",\n \"quantity\": 15,\n \"amount\" : 900\n \"product_img_link\" : \"https://dummy-img-link.com\"\n }]",
"nullable": true
},
"client_secret": {
@ -9142,7 +9200,7 @@
"$ref": "#/components/schemas/OrderDetailsWithAmount"
},
"description": "Information about the product , quantity and amount for connectors. (e.g. Klarna)",
"example": "[{\n \"product_name\": \"gillete creme\",\n \"quantity\": 15,\n \"amount\" : 900\n }]",
"example": "[{\n \"product_name\": \"gillete creme\",\n \"quantity\": 15,\n \"amount\" : 900\n \"product_img_link\" : \"https://dummy-img-link.com\"\n }]",
"nullable": true
},
"client_secret": {