feat(connector): [STRIPE] Added Connector Tokenization Flow for Cards (#8248)

Co-authored-by: Sayak Bhattacharya <sayak.b@Sayak-Bhattacharya-G092THXJ34.local>
This commit is contained in:
Sayak Bhattacharya
2025-06-07 19:46:13 +05:30
committed by GitHub
parent a88eebdec7
commit 8129260238
8 changed files with 223 additions and 131 deletions

View File

@ -263,6 +263,23 @@ impl ConnectorIntegration<CreateConnectorCustomer, ConnectorCustomerData, Paymen
.to_string()
.into(),
)];
if let Some(common_types::payments::SplitPaymentsRequest::StripeSplitPayment(
stripe_split_payment,
)) = &req.request.split_payments
{
if stripe_split_payment.charge_type
== PaymentChargeType::Stripe(StripeChargeType::Direct)
{
let mut customer_account_header = vec![(
STRIPE_COMPATIBLE_CONNECT_ACCOUNT.to_string(),
stripe_split_payment
.transfer_account_id
.clone()
.into_masked(),
)];
header.append(&mut customer_account_header);
}
}
let mut api_key = self.get_auth_header(&req.connector_auth_type)?;
header.append(&mut api_key);
Ok(header)
@ -389,6 +406,23 @@ impl ConnectorIntegration<PaymentMethodToken, PaymentMethodTokenizationData, Pay
CONTENT_TYPE.to_string(),
TokenizationType::get_content_type(self).to_string().into(),
)];
if let Some(common_types::payments::SplitPaymentsRequest::StripeSplitPayment(
stripe_split_payment,
)) = &req.request.split_payments
{
if stripe_split_payment.charge_type
== PaymentChargeType::Stripe(StripeChargeType::Direct)
{
let mut customer_account_header = vec![(
STRIPE_COMPATIBLE_CONNECT_ACCOUNT.to_string(),
stripe_split_payment
.transfer_account_id
.clone()
.into_masked(),
)];
header.append(&mut customer_account_header);
}
}
let mut api_key = self.get_auth_header(&req.connector_auth_type)?;
header.append(&mut api_key);
Ok(header)
@ -400,9 +434,19 @@ impl ConnectorIntegration<PaymentMethodToken, PaymentMethodTokenizationData, Pay
fn get_url(
&self,
_req: &TokenizationRouterData,
req: &TokenizationRouterData,
connectors: &Connectors,
) -> CustomResult<String, ConnectorError> {
if matches!(
req.request.split_payments,
Some(common_types::payments::SplitPaymentsRequest::StripeSplitPayment(_))
) {
return Ok(format!(
"{}{}",
self.base_url(connectors),
"v1/payment_methods"
));
}
Ok(format!("{}{}", self.base_url(connectors), "v1/tokens"))
}
@ -526,7 +570,6 @@ impl ConnectorIntegration<Capture, PaymentsCaptureData, PaymentsResponseData> fo
connectors: &Connectors,
) -> CustomResult<String, ConnectorError> {
let id = req.request.connector_transaction_id.as_str();
Ok(format!(
"{}{}/{}/capture",
self.base_url(connectors),

View File

@ -37,7 +37,7 @@ use hyperswitch_domain_models::{
},
};
use hyperswitch_interfaces::{consts, errors::ConnectorError};
use masking::{ExposeInterface, ExposeOptionInterface, Mask, Maskable, PeekInterface, Secret};
use masking::{ExposeInterface, Mask, Maskable, PeekInterface, Secret};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use time::PrimitiveDateTime;
@ -170,7 +170,7 @@ pub struct PaymentIntentRequest {
pub meta_data: HashMap<String, String>,
pub return_url: String,
pub confirm: bool,
pub payment_method: Option<String>,
pub payment_method: Option<Secret<String>>,
pub customer: Option<Secret<String>>,
#[serde(flatten)]
pub setup_mandate_details: Option<StripeMandateRequest>,
@ -486,9 +486,28 @@ pub enum StripePaymentMethodData {
BankTransfer(StripeBankTransferData),
}
#[derive(Debug, Clone, Default, Eq, PartialEq, Serialize)]
pub struct StripeBillingAddressCardToken {
#[serde(rename = "billing_details[name]")]
pub name: Option<Secret<String>>,
#[serde(rename = "billing_details[email]")]
pub email: Option<Email>,
#[serde(rename = "billing_details[phone]")]
pub phone: Option<Secret<String>>,
#[serde(rename = "billing_details[address][line1]")]
pub address_line1: Option<Secret<String>>,
#[serde(rename = "billing_details[address][line2]")]
pub address_line2: Option<Secret<String>>,
#[serde(rename = "billing_details[address][state]")]
pub state: Option<Secret<String>>,
#[serde(rename = "billing_details[address][city]")]
pub city: Option<String>,
}
// Struct to call the Stripe tokens API to create a PSP token for the card details provided
#[derive(Debug, Eq, PartialEq, Serialize)]
pub struct StripeCardToken {
#[serde(rename = "type")]
pub payment_method_type: Option<StripePaymentMethodType>,
#[serde(rename = "card[number]")]
pub token_card_number: cards::CardNumber,
#[serde(rename = "card[exp_month]")]
@ -497,6 +516,8 @@ pub struct StripeCardToken {
pub token_card_exp_year: Secret<String>,
#[serde(rename = "card[cvc]")]
pub token_card_cvc: Secret<String>,
#[serde(flatten)]
pub billing: StripeBillingAddressCardToken,
}
#[derive(Debug, Eq, PartialEq, Serialize)]
@ -1642,80 +1663,51 @@ impl TryFrom<(&PaymentsAuthorizeRouterData, MinorUnit)> for PaymentIntentRequest
type Error = error_stack::Report<ConnectorError>;
fn try_from(data: (&PaymentsAuthorizeRouterData, MinorUnit)) -> Result<Self, Self::Error> {
let item = data.0;
let payment_method_token = match &item.request.split_payments {
Some(common_types::payments::SplitPaymentsRequest::StripeSplitPayment(_)) => {
match item.payment_method_token.clone() {
Some(PaymentMethodToken::Token(secret)) => Some(secret),
_ => None,
}
}
_ => None,
};
let amount = data.1;
let order_id = item.connector_request_reference_id.clone();
let shipping_address = match item.get_optional_shipping() {
Some(shipping_details) => {
let shipping_address = shipping_details.address.as_ref();
shipping_address.and_then(|shipping_detail| {
shipping_detail
.first_name
.as_ref()
.map(|first_name| StripeShippingAddress {
city: shipping_address.and_then(|a| a.city.clone()),
country: shipping_address.and_then(|a| a.country),
line1: shipping_address.and_then(|a| a.line1.clone()),
line2: shipping_address.and_then(|a| a.line2.clone()),
zip: shipping_address.and_then(|a| a.zip.clone()),
state: shipping_address.and_then(|a| a.state.clone()),
name: format!(
"{} {}",
first_name.clone().expose(),
shipping_detail
.last_name
.clone()
.expose_option()
.unwrap_or_default()
)
.into(),
phone: shipping_details.phone.as_ref().map(|p| {
format!(
"{}{}",
p.country_code.clone().unwrap_or_default(),
p.number.clone().expose_option().unwrap_or_default()
)
.into()
}),
let shipping_address = if payment_method_token.is_some() {
None
} else {
Some(StripeShippingAddress {
city: item.get_optional_shipping_city(),
country: item.get_optional_shipping_country(),
line1: item.get_optional_shipping_line1(),
line2: item.get_optional_shipping_line2(),
zip: item.get_optional_shipping_zip(),
state: item.get_optional_shipping_state(),
name: item.get_optional_shipping_full_name(),
phone: item.get_optional_shipping_phone_number(),
})
})
}
None => None,
};
let billing_address = match item.get_optional_billing() {
Some(billing_details) => {
let billing_address = billing_details.address.as_ref();
StripeBillingAddress {
city: billing_address.and_then(|a| a.city.clone()),
country: billing_address.and_then(|a| a.country),
address_line1: billing_address.and_then(|a| a.line1.clone()),
address_line2: billing_address.and_then(|a| a.line2.clone()),
zip_code: billing_address.and_then(|a| a.zip.clone()),
state: billing_address.and_then(|a| a.state.clone()),
name: billing_address.and_then(|a| {
a.first_name.as_ref().map(|first_name| {
format!(
"{} {}",
first_name.clone().expose(),
a.last_name.clone().expose_option().unwrap_or_default()
)
.into()
let billing_address = if payment_method_token.is_some() {
None
} else {
Some(StripeBillingAddress {
city: item.get_optional_billing_city(),
country: item.get_optional_billing_country(),
address_line1: item.get_optional_billing_line1(),
address_line2: item.get_optional_billing_line2(),
zip_code: item.get_optional_billing_zip(),
state: item.get_optional_billing_state(),
name: item.get_optional_billing_full_name(),
email: item.get_optional_billing_email(),
phone: item.get_optional_billing_phone_number(),
})
}),
email: billing_details.email.clone(),
phone: billing_details.phone.as_ref().map(|p| {
format!(
"{}{}",
p.country_code.clone().unwrap_or_default(),
p.number.clone().expose_option().unwrap_or_default()
)
.into()
}),
}
}
None => StripeBillingAddress::default(),
};
let mut payment_method_options = None;
let (
@ -1724,7 +1716,9 @@ impl TryFrom<(&PaymentsAuthorizeRouterData, MinorUnit)> for PaymentIntentRequest
billing_address,
payment_method_types,
setup_future_usage,
) = {
) = if payment_method_token.is_some() {
(None, None, StripeBillingAddress::default(), None, None)
} else {
match item
.request
.mandate_id
@ -1812,7 +1806,11 @@ impl TryFrom<(&PaymentsAuthorizeRouterData, MinorUnit)> for PaymentIntentRequest
&item.request,
),
),
billing_address,
billing_address.ok_or_else(|| {
ConnectorError::MissingRequiredField {
field_name: "billing_address",
}
})?,
)?;
validate_shipping_address_against_payment_method(
@ -1831,6 +1829,7 @@ impl TryFrom<(&PaymentsAuthorizeRouterData, MinorUnit)> for PaymentIntentRequest
}
};
if payment_method_token.is_none() {
payment_data = match item.request.payment_method_data {
PaymentMethodData::Wallet(WalletData::ApplePay(_)) => {
let payment_method_token = item
@ -1863,6 +1862,9 @@ impl TryFrom<(&PaymentsAuthorizeRouterData, MinorUnit)> for PaymentIntentRequest
))
}
_ => payment_data,
}
} else {
payment_data = None
};
let setup_mandate_details = item
@ -1932,7 +1934,7 @@ impl TryFrom<(&PaymentsAuthorizeRouterData, MinorUnit)> for PaymentIntentRequest
// We pass browser_info only when payment_data exists.
// Hence, we're pass Null during recurring payments as payment_method_data[type] is not passed
let browser_info = if payment_data.is_some() {
let browser_info = if payment_data.is_some() && payment_method_token.is_none() {
item.request
.browser_info
.clone()
@ -1941,11 +1943,10 @@ impl TryFrom<(&PaymentsAuthorizeRouterData, MinorUnit)> for PaymentIntentRequest
None
};
let (charges, customer) = match &item.request.split_payments {
let charges = match &item.request.split_payments {
Some(common_types::payments::SplitPaymentsRequest::StripeSplitPayment(
stripe_split_payment,
)) => {
let charges = match &stripe_split_payment.charge_type {
)) => match &stripe_split_payment.charge_type {
PaymentChargeType::Stripe(charge_type) => match charge_type {
StripeChargeType::Direct => Some(IntentCharges {
application_fee_amount: stripe_split_payment.application_fees,
@ -1958,14 +1959,24 @@ impl TryFrom<(&PaymentsAuthorizeRouterData, MinorUnit)> for PaymentIntentRequest
),
}),
},
};
(charges, None)
}
},
Some(common_types::payments::SplitPaymentsRequest::AdyenSplitPayment(_))
| Some(common_types::payments::SplitPaymentsRequest::XenditSplitPayment(_))
| None => (None, item.connector_customer.to_owned().map(Secret::new)),
| None => None,
};
let pm = match (payment_method, payment_method_token.clone()) {
(Some(method), _) => Some(Secret::new(method)),
(None, Some(token)) => Some(token),
(None, None) => None,
};
let customer_id = item.connector_customer.clone().ok_or_else(|| {
ConnectorError::MissingRequiredField {
field_name: "connector_customer",
}
})?;
Ok(Self {
amount, //hopefully we don't loose some cents here
currency: item.request.currency.to_string(), //we need to copy the value and not transfer ownership
@ -1984,8 +1995,8 @@ impl TryFrom<(&PaymentsAuthorizeRouterData, MinorUnit)> for PaymentIntentRequest
capture_method: StripeCaptureMethod::from(item.request.capture_method),
payment_data,
payment_method_options,
payment_method,
customer,
payment_method: pm,
customer: Some(Secret::new(customer_id)),
setup_mandate_details,
off_session: item.request.off_session,
setup_future_usage,
@ -2076,14 +2087,26 @@ impl TryFrom<&SetupMandateRouterData> for SetupIntentRequest {
impl TryFrom<&TokenizationRouterData> for TokenRequest {
type Error = error_stack::Report<ConnectorError>;
fn try_from(item: &TokenizationRouterData) -> Result<Self, Self::Error> {
let billing_address = StripeBillingAddressCardToken {
name: item.get_optional_billing_full_name(),
email: item.get_optional_billing_email(),
phone: item.get_optional_billing_phone_number(),
address_line1: item.get_optional_billing_line1(),
address_line2: item.get_optional_billing_line2(),
city: item.get_optional_billing_city(),
state: item.get_optional_billing_state(),
};
// Card flow for tokenization is handled separately because of API contact difference
let request_payment_data = match &item.request.payment_method_data {
PaymentMethodData::Card(card_details) => {
StripePaymentMethodData::CardToken(StripeCardToken {
payment_method_type: Some(StripePaymentMethodType::Card),
token_card_number: card_details.card_number.clone(),
token_card_exp_month: card_details.card_exp_month.clone(),
token_card_exp_year: card_details.card_exp_year.clone(),
token_card_cvc: card_details.card_cvc.clone(),
billing: billing_address,
})
}
_ => {
@ -3327,7 +3350,7 @@ pub struct StripeShippingAddress {
#[serde(rename = "shipping[address][state]")]
pub state: Option<Secret<String>>,
#[serde(rename = "shipping[name]")]
pub name: Secret<String>,
pub name: Option<Secret<String>>,
#[serde(rename = "shipping[phone]")]
pub phone: Option<Secret<String>>,
}
@ -3593,10 +3616,9 @@ impl<F, T> TryFrom<ResponseRouterData<F, StripeTokenResponse, T, PaymentsRespons
fn try_from(
item: ResponseRouterData<F, StripeTokenResponse, T, PaymentsResponseData>,
) -> Result<Self, Self::Error> {
let token = item.response.id.clone().expose();
Ok(Self {
response: Ok(PaymentsResponseData::TokenizationResponse {
token: item.response.id.expose(),
}),
response: Ok(PaymentsResponseData::TokenizationResponse { token }),
..item.data
})
}
@ -4392,7 +4414,7 @@ mod test_validate_shipping_address_against_payment_method {
zip: Option<String>,
) -> StripeShippingAddress {
StripeShippingAddress {
name: Secret::new(name),
name: Some(Secret::new(name)),
line1: line1.map(Secret::new),
country,
zip: zip.map(Secret::new),

View File

@ -177,6 +177,7 @@ pub struct ConnectorCustomerData {
pub name: Option<Secret<String>>,
pub preprocessing_id: Option<String>,
pub payment_method_data: Option<PaymentMethodData>,
pub split_payments: Option<common_types::payments::SplitPaymentsRequest>,
}
impl TryFrom<SetupMandateRequestData> for ConnectorCustomerData {
@ -189,6 +190,7 @@ impl TryFrom<SetupMandateRequestData> for ConnectorCustomerData {
phone: None,
name: None,
preprocessing_id: None,
split_payments: None,
})
}
}
@ -213,6 +215,7 @@ impl
phone: None,
name: data.request.customer_name.clone(),
preprocessing_id: data.preprocessing_id.clone(),
split_payments: data.request.split_payments.clone(),
})
}
}
@ -236,6 +239,7 @@ impl TryFrom<&RouterData<flows::Session, PaymentsSessionData, response_types::Pa
phone: None,
name: data.request.customer_name.clone(),
preprocessing_id: data.preprocessing_id.clone(),
split_payments: None,
})
}
}
@ -246,6 +250,7 @@ pub struct PaymentMethodTokenizationData {
pub browser_info: Option<BrowserInformation>,
pub currency: storage_enums::Currency,
pub amount: Option<i64>,
pub split_payments: Option<common_types::payments::SplitPaymentsRequest>,
}
impl TryFrom<SetupMandateRequestData> for PaymentMethodTokenizationData {
@ -257,6 +262,7 @@ impl TryFrom<SetupMandateRequestData> for PaymentMethodTokenizationData {
browser_info: None,
currency: data.currency,
amount: data.amount,
split_payments: None,
})
}
}
@ -271,6 +277,7 @@ impl<F> From<&RouterData<F, PaymentsAuthorizeData, response_types::PaymentsRespo
browser_info: None,
currency: data.request.currency,
amount: Some(data.request.amount),
split_payments: data.request.split_payments.clone(),
}
}
}
@ -284,6 +291,7 @@ impl TryFrom<PaymentsAuthorizeData> for PaymentMethodTokenizationData {
browser_info: data.browser_info,
currency: data.currency,
amount: Some(data.amount),
split_payments: data.split_payments.clone(),
})
}
}
@ -302,6 +310,7 @@ impl TryFrom<CompleteAuthorizeData> for PaymentMethodTokenizationData {
browser_info: data.browser_info,
currency: data.currency,
amount: Some(data.amount),
split_payments: None,
})
}
}

View File

@ -3227,6 +3227,7 @@ async fn create_single_use_tokenization_flow(
browser_info: None,
currency: api_models::enums::Currency::default(),
amount: None,
split_payments: None,
};
let payment_method_session_address = types::PaymentAddress::new(

View File

@ -5241,6 +5241,7 @@ async fn decide_payment_method_tokenize_action(
state: &SessionState,
connector_name: &str,
payment_method: storage::enums::PaymentMethod,
payment_intent_data: payments::PaymentIntent,
pm_parent_token: Option<&str>,
is_connector_tokenization_enabled: bool,
apple_pay_flow: Option<domain::ApplePayFlow>,
@ -5276,6 +5277,11 @@ async fn decide_payment_method_tokenize_action(
}
}
}
} else if matches!(
payment_intent_data.split_payments,
Some(common_types::payments::SplitPaymentsRequest::StripeSplitPayment(_))
) {
Ok(TokenizationAction::TokenizeInConnector)
} else {
match pm_parent_token {
None => Ok(match (is_connector_tokenization_enabled, apple_pay_flow) {
@ -5445,6 +5451,7 @@ where
state,
&connector,
payment_method,
payment_data.get_payment_intent().clone(),
payment_data.get_token(),
is_connector_tokenization_enabled,
apple_pay_flow,

View File

@ -67,6 +67,7 @@ fn token_details() -> Option<types::PaymentMethodTokenizationData> {
browser_info: None,
amount: None,
currency: enums::Currency::USD,
split_payments: None,
})
}
@ -440,6 +441,7 @@ async fn should_fail_payment_for_incorrect_cvc() {
browser_info: None,
amount: None,
currency: enums::Currency::USD,
split_payments: None,
}),
get_default_payment_info(None),
)
@ -471,6 +473,7 @@ async fn should_fail_payment_for_invalid_exp_month() {
browser_info: None,
amount: None,
currency: enums::Currency::USD,
split_payments: None,
}),
get_default_payment_info(None),
)
@ -502,6 +505,7 @@ async fn should_fail_payment_for_incorrect_expiry_year() {
browser_info: None,
amount: None,
currency: enums::Currency::USD,
split_payments: None,
}),
get_default_payment_info(None),
)

View File

@ -72,6 +72,7 @@ fn token_details() -> Option<types::PaymentMethodTokenizationData> {
browser_info: None,
amount: None,
currency: enums::Currency::USD,
split_payments: None,
})
}
@ -482,6 +483,7 @@ async fn should_fail_payment_for_incorrect_cvc() {
browser_info: None,
amount: None,
currency: enums::Currency::USD,
split_payments: None,
}),
get_default_payment_info(connector_customer_id, None),
)
@ -520,6 +522,7 @@ async fn should_fail_payment_for_invalid_exp_month() {
browser_info: None,
amount: None,
currency: enums::Currency::USD,
split_payments: None,
}),
get_default_payment_info(connector_customer_id, None),
)
@ -558,6 +561,7 @@ async fn should_fail_payment_for_incorrect_expiry_year() {
browser_info: None,
amount: None,
currency: enums::Currency::USD,
split_payments: None,
}),
get_default_payment_info(connector_customer_id, None),
)

View File

@ -1107,6 +1107,7 @@ impl Default for CustomerType {
phone: None,
name: None,
preprocessing_id: None,
split_payments: None,
};
Self(data)
}
@ -1119,6 +1120,7 @@ impl Default for TokenType {
browser_info: None,
amount: Some(100),
currency: enums::Currency::USD,
split_payments: None,
};
Self(data)
}