feat(connector): [Nuvei] Implement setup mandate flow for cards (#9012)

Co-authored-by: Vani Gupta <vani.gupta@juspay.in>
Co-authored-by: hyperswitch-bot[bot] <148525504+hyperswitch-bot[bot]@users.noreply.github.com>
This commit is contained in:
Vani Gupta
2025-08-26 17:34:41 +05:30
committed by GitHub
parent 30925ca5dd
commit 58ff01bab2
8 changed files with 603 additions and 103 deletions

View File

@ -844,7 +844,7 @@ pub fn get_avs_definition(code: &str) -> Option<&'static str> {
"8" => Some("Cardholder name, address, and ZIP do not match"), "8" => Some("Cardholder name, address, and ZIP do not match"),
_ => { _ => {
router_env::logger::info!( router_env::logger::info!(
"Celero avs response code ({:?}) is not mapped to any defination.", "Celero avs response code ({:?}) is not mapped to any definition.",
code code
); );

View File

@ -129,6 +129,8 @@ impl ConnectorIntegration<PaymentMethodToken, PaymentMethodTokenizationData, Pay
// Not Implemented (R) // Not Implemented (R)
} }
impl ConnectorIntegration<Session, PaymentsSessionData, PaymentsResponseData> for Nuvei {}
impl api::MandateSetup for Nuvei {} impl api::MandateSetup for Nuvei {}
impl api::PaymentVoid for Nuvei {} impl api::PaymentVoid for Nuvei {}
impl api::PaymentSync for Nuvei {} impl api::PaymentSync for Nuvei {}
@ -143,16 +145,87 @@ impl api::PaymentsCompleteAuthorize for Nuvei {}
impl api::ConnectorAccessToken for Nuvei {} impl api::ConnectorAccessToken for Nuvei {}
impl api::PaymentsPreProcessing for Nuvei {} impl api::PaymentsPreProcessing for Nuvei {}
impl api::PaymentPostCaptureVoid for Nuvei {} impl api::PaymentPostCaptureVoid for Nuvei {}
impl ConnectorIntegration<SetupMandate, SetupMandateRequestData, PaymentsResponseData> for Nuvei { impl ConnectorIntegration<SetupMandate, SetupMandateRequestData, PaymentsResponseData> for Nuvei {
fn build_request( fn get_headers(
&self,
req: &RouterData<SetupMandate, SetupMandateRequestData, PaymentsResponseData>,
connectors: &Connectors,
) -> CustomResult<Vec<(String, masking::Maskable<String>)>, errors::ConnectorError> {
self.build_headers(req, connectors)
}
fn get_content_type(&self) -> &'static str {
self.common_get_content_type()
}
fn get_url(
&self, &self,
_req: &RouterData<SetupMandate, SetupMandateRequestData, PaymentsResponseData>, _req: &RouterData<SetupMandate, SetupMandateRequestData, PaymentsResponseData>,
connectors: &Connectors,
) -> CustomResult<String, errors::ConnectorError> {
Ok(format!(
"{}ppp/api/v1/payment.do",
ConnectorCommon::base_url(self, connectors)
))
}
fn get_request_body(
&self,
req: &RouterData<SetupMandate, SetupMandateRequestData, PaymentsResponseData>,
_connectors: &Connectors, _connectors: &Connectors,
) -> CustomResult<RequestContent, errors::ConnectorError> {
let connector_req = nuvei::NuveiPaymentsRequest::try_from((req, req.get_session_token()?))?;
Ok(RequestContent::Json(Box::new(connector_req)))
}
fn build_request(
&self,
req: &RouterData<SetupMandate, SetupMandateRequestData, PaymentsResponseData>,
connectors: &Connectors,
) -> CustomResult<Option<Request>, errors::ConnectorError> { ) -> CustomResult<Option<Request>, errors::ConnectorError> {
Err( Ok(Some(
errors::ConnectorError::NotImplemented("Setup Mandate flow for Nuvei".to_string()) RequestBuilder::new()
.into(), .method(Method::Post)
) .url(&types::SetupMandateType::get_url(self, req, connectors)?)
.attach_default_headers()
.headers(types::SetupMandateType::get_headers(self, req, connectors)?)
.set_body(types::SetupMandateType::get_request_body(
self, req, connectors,
)?)
.build(),
))
}
fn handle_response(
&self,
data: &RouterData<SetupMandate, SetupMandateRequestData, PaymentsResponseData>,
event_builder: Option<&mut ConnectorEvent>,
res: Response,
) -> CustomResult<
RouterData<SetupMandate, SetupMandateRequestData, PaymentsResponseData>,
errors::ConnectorError,
> {
let response: nuvei::NuveiPaymentsResponse = res
.response
.parse_struct("NuveiPaymentsResponse")
.switch()?;
event_builder.map(|i| i.set_response_body(&response));
router_env::logger::info!(connector_response=?response);
RouterData::try_from(ResponseRouterData {
response,
data: data.clone(),
http_code: res.status_code,
})
}
fn get_error_response(
&self,
res: Response,
event_builder: Option<&mut ConnectorEvent>,
) -> CustomResult<ErrorResponse, errors::ConnectorError> {
self.build_error_response(res, event_builder)
} }
} }
@ -569,8 +642,6 @@ impl ConnectorIntegration<Capture, PaymentsCaptureData, PaymentsResponseData> fo
} }
} }
impl ConnectorIntegration<Session, PaymentsSessionData, PaymentsResponseData> for Nuvei {}
#[async_trait::async_trait] #[async_trait::async_trait]
impl ConnectorIntegration<Authorize, PaymentsAuthorizeData, PaymentsResponseData> for Nuvei { impl ConnectorIntegration<Authorize, PaymentsAuthorizeData, PaymentsResponseData> for Nuvei {
fn get_headers( fn get_headers(

View File

@ -1,19 +1,18 @@
use common_enums::{enums, CaptureMethod, PaymentChannel}; use common_enums::{enums, CaptureMethod, FutureUsage, PaymentChannel};
use common_types::payments::{ApplePayPaymentData, GpayTokenizationData}; use common_types::payments::{ApplePayPaymentData, GpayTokenizationData};
use common_utils::{ use common_utils::{
crypto::{self, GenerateDigest}, crypto::{self, GenerateDigest},
date_time, date_time,
ext_traits::{Encode, OptionExt}, ext_traits::Encode,
fp_utils, fp_utils,
id_type::CustomerId, id_type::CustomerId,
pii::{Email, IpAddress}, pii::{self, Email, IpAddress},
request::Method, request::Method,
types::{MinorUnit, StringMajorUnit, StringMajorUnitForConnector}, types::{MinorUnit, StringMajorUnit, StringMajorUnitForConnector},
}; };
use error_stack::ResultExt; use error_stack::ResultExt;
use hyperswitch_domain_models::{ use hyperswitch_domain_models::{
address::Address, address::Address,
mandates::{MandateData, MandateDataType},
payment_method_data::{ payment_method_data::{
self, ApplePayWalletData, BankRedirectData, GooglePayWalletData, PayLaterData, self, ApplePayWalletData, BankRedirectData, GooglePayWalletData, PayLaterData,
PaymentMethodData, WalletData, PaymentMethodData, WalletData,
@ -24,11 +23,11 @@ use hyperswitch_domain_models::{
}, },
router_flow_types::{ router_flow_types::{
refunds::{Execute, RSync}, refunds::{Execute, RSync},
Authorize, Capture, CompleteAuthorize, PSync, PostCaptureVoid, Void, Authorize, Capture, CompleteAuthorize, PSync, PostCaptureVoid, SetupMandate, Void,
}, },
router_request_types::{ router_request_types::{
authentication::MessageExtensionAttribute, BrowserInformation, PaymentsAuthorizeData, authentication::MessageExtensionAttribute, BrowserInformation, PaymentsAuthorizeData,
PaymentsPreProcessingData, ResponseId, PaymentsPreProcessingData, ResponseId, SetupMandateRequestData,
}, },
router_response_types::{ router_response_types::{
MandateReference, PaymentsResponseData, RedirectForm, RefundsResponseData, MandateReference, PaymentsResponseData, RedirectForm, RefundsResponseData,
@ -70,12 +69,11 @@ fn to_boolean(string: String) -> bool {
// The dimensions of the challenge window for full screen. // The dimensions of the challenge window for full screen.
const CHALLENGE_WINDOW_SIZE: &str = "05"; const CHALLENGE_WINDOW_SIZE: &str = "05";
// The challenge preference for the challenge flow. // The challenge preference for the challenge flow.
const CHALLENGE_PREFERNCE: &str = "01"; const CHALLENGE_PREFERENCE: &str = "01";
trait NuveiAuthorizePreprocessingCommon { trait NuveiAuthorizePreprocessingCommon {
fn get_browser_info(&self) -> Option<BrowserInformation>; fn get_browser_info(&self) -> Option<BrowserInformation>;
fn get_related_transaction_id(&self) -> Option<String>; fn get_related_transaction_id(&self) -> Option<String>;
fn get_setup_mandate_details(&self) -> Option<MandateData>;
fn get_complete_authorize_url(&self) -> Option<String>; fn get_complete_authorize_url(&self) -> Option<String>;
fn get_is_moto(&self) -> Option<bool>; fn get_is_moto(&self) -> Option<bool>;
fn get_connector_mandate_id(&self) -> Option<String>; fn get_connector_mandate_id(&self) -> Option<String>;
@ -98,6 +96,92 @@ trait NuveiAuthorizePreprocessingCommon {
fn get_order_tax_amount( fn get_order_tax_amount(
&self, &self,
) -> Result<Option<MinorUnit>, error_stack::Report<errors::ConnectorError>>; ) -> Result<Option<MinorUnit>, error_stack::Report<errors::ConnectorError>>;
fn is_customer_initiated_mandate_payment(&self) -> bool;
}
impl NuveiAuthorizePreprocessingCommon for SetupMandateRequestData {
fn get_browser_info(&self) -> Option<BrowserInformation> {
self.browser_info.clone()
}
fn get_related_transaction_id(&self) -> Option<String> {
self.related_transaction_id.clone()
}
fn get_is_moto(&self) -> Option<bool> {
match self.payment_channel {
Some(PaymentChannel::MailOrder) | Some(PaymentChannel::TelephoneOrder) => Some(true),
_ => None,
}
}
fn get_customer_id_required(&self) -> Option<CustomerId> {
self.customer_id.clone()
}
fn get_complete_authorize_url(&self) -> Option<String> {
self.complete_authorize_url.clone()
}
fn get_connector_mandate_id(&self) -> Option<String> {
self.mandate_id.as_ref().and_then(|mandate_ids| {
mandate_ids.mandate_reference_id.as_ref().and_then(
|mandate_ref_id| match mandate_ref_id {
api_models::payments::MandateReferenceId::ConnectorMandateId(id) => {
id.get_connector_mandate_id()
}
_ => None,
},
)
})
}
fn get_return_url_required(
&self,
) -> Result<String, error_stack::Report<errors::ConnectorError>> {
self.router_return_url
.clone()
.ok_or_else(missing_field_err("return_url"))
}
fn get_capture_method(&self) -> Option<CaptureMethod> {
self.capture_method
}
fn get_currency_required(
&self,
) -> Result<enums::Currency, error_stack::Report<errors::ConnectorError>> {
Ok(self.currency)
}
fn get_payment_method_data_required(
&self,
) -> Result<PaymentMethodData, error_stack::Report<errors::ConnectorError>> {
Ok(self.payment_method_data.clone())
}
fn get_order_tax_amount(
&self,
) -> Result<Option<MinorUnit>, error_stack::Report<errors::ConnectorError>> {
Ok(None)
}
fn get_minor_amount_required(
&self,
) -> Result<MinorUnit, error_stack::Report<errors::ConnectorError>> {
self.minor_amount
.ok_or_else(missing_field_err("minor_amount"))
}
fn get_is_partial_approval(&self) -> Option<PartialApprovalFlag> {
self.enable_partial_authorization
.map(PartialApprovalFlag::from)
}
fn get_email_required(&self) -> Result<Email, error_stack::Report<errors::ConnectorError>> {
self.email.clone().ok_or_else(missing_field_err("email"))
}
fn is_customer_initiated_mandate_payment(&self) -> bool {
(self.customer_acceptance.is_some() || self.setup_mandate_details.is_some())
&& self.setup_future_usage == Some(FutureUsage::OffSession)
}
} }
impl NuveiAuthorizePreprocessingCommon for PaymentsAuthorizeData { impl NuveiAuthorizePreprocessingCommon for PaymentsAuthorizeData {
@ -119,10 +203,6 @@ impl NuveiAuthorizePreprocessingCommon for PaymentsAuthorizeData {
self.customer_id.clone() self.customer_id.clone()
} }
fn get_setup_mandate_details(&self) -> Option<MandateData> {
self.setup_mandate_details.clone()
}
fn get_complete_authorize_url(&self) -> Option<String> { fn get_complete_authorize_url(&self) -> Option<String> {
self.complete_authorize_url.clone() self.complete_authorize_url.clone()
} }
@ -166,7 +246,10 @@ impl NuveiAuthorizePreprocessingCommon for PaymentsAuthorizeData {
fn get_email_required(&self) -> Result<Email, error_stack::Report<errors::ConnectorError>> { fn get_email_required(&self) -> Result<Email, error_stack::Report<errors::ConnectorError>> {
self.get_email() self.get_email()
} }
fn is_customer_initiated_mandate_payment(&self) -> bool {
(self.customer_acceptance.is_some() || self.setup_mandate_details.is_some())
&& self.setup_future_usage == Some(FutureUsage::OffSession)
}
fn get_is_partial_approval(&self) -> Option<PartialApprovalFlag> { fn get_is_partial_approval(&self) -> Option<PartialApprovalFlag> {
self.enable_partial_authorization self.enable_partial_authorization
.map(PartialApprovalFlag::from) .map(PartialApprovalFlag::from)
@ -192,8 +275,9 @@ impl NuveiAuthorizePreprocessingCommon for PaymentsPreProcessingData {
fn get_email_required(&self) -> Result<Email, error_stack::Report<errors::ConnectorError>> { fn get_email_required(&self) -> Result<Email, error_stack::Report<errors::ConnectorError>> {
self.get_email() self.get_email()
} }
fn get_setup_mandate_details(&self) -> Option<MandateData> { fn is_customer_initiated_mandate_payment(&self) -> bool {
self.setup_mandate_details.clone() (self.customer_acceptance.is_some() || self.setup_mandate_details.is_some())
&& self.setup_future_usage == Some(FutureUsage::OffSession)
} }
fn get_complete_authorize_url(&self) -> Option<String> { fn get_complete_authorize_url(&self) -> Option<String> {
@ -1430,6 +1514,19 @@ where
item.get_optional_shipping().map(|address| address.into()); item.get_optional_shipping().map(|address| address.into());
let billing_address: Option<BillingAddress> = address.map(|ref address| address.into()); let billing_address: Option<BillingAddress> = address.map(|ref address| address.into());
let device_details = if request_data
.device_details
.ip_address
.clone()
.expose()
.is_empty()
{
DeviceDetails::foreign_try_from(&item.request.get_browser_info())?
} else {
request_data.device_details.clone()
};
Ok(Self { Ok(Self {
is_rebilling: request_data.is_rebilling, is_rebilling: request_data.is_rebilling,
user_token_id: item.customer_id.clone(), user_token_id: item.customer_id.clone(),
@ -1437,9 +1534,7 @@ where
payment_option: request_data.payment_option, payment_option: request_data.payment_option,
billing_address, billing_address,
shipping_address, shipping_address,
device_details: DeviceDetails::foreign_try_from( device_details,
&item.request.get_browser_info().clone(),
)?,
url_details: Some(UrlDetails { url_details: Some(UrlDetails {
success_url: return_url.clone(), success_url: return_url.clone(),
failure_url: return_url.clone(), failure_url: return_url.clone(),
@ -1471,59 +1566,41 @@ where
.and_then(|billing_details| billing_details.address.as_ref()); .and_then(|billing_details| billing_details.address.as_ref());
if let Some(address) = address { if let Some(address) = address {
// mandatory feilds check // mandatory fields check
address.get_first_name()?; address.get_first_name()?;
item.request.get_email_required()?; item.request.get_email_required()?;
item.get_billing_country()?; item.get_billing_country()?;
} }
let (is_rebilling, additional_params, user_token_id) = let (is_rebilling, additional_params, user_token_id) =
match item.request.get_setup_mandate_details().clone() { match item.request.is_customer_initiated_mandate_payment() {
Some(mandate_data) => { true => {
let details = match mandate_data
.mandate_type
.get_required_value("mandate_type")
.change_context(errors::ConnectorError::MissingRequiredField {
field_name: "mandate_type",
})? {
MandateDataType::SingleUse(details) => details,
MandateDataType::MultiUse(details) => {
details.ok_or(errors::ConnectorError::MissingRequiredField {
field_name: "mandate_data.mandate_type.multi_use",
})?
}
};
let mandate_meta: NuveiMandateMeta = utils::to_connector_meta_from_secret(Some(
details.get_metadata().ok_or_else(missing_field_err(
"mandate_data.mandate_type.{multi_use|single_use}.metadata",
))?,
))?;
( (
Some("0".to_string()), // In case of first installment, rebilling should be 0 Some("0".to_string()), // In case of first installment, rebilling should be 0
Some(V2AdditionalParams { Some(V2AdditionalParams {
rebill_expiry: Some( rebill_expiry: Some(
details time::OffsetDateTime::now_utc()
.get_end_date(date_time::DateFormat::YYYYMMDD) .replace_year(time::OffsetDateTime::now_utc().year() + 5)
.change_context(errors::ConnectorError::DateFormattingFailed)? .map_err(|_| errors::ConnectorError::DateFormattingFailed)?
.ok_or_else(missing_field_err( .date()
"mandate_data.mandate_type.{multi_use|single_use}.end_date", .format(&time::macros::format_description!("[year][month][day]"))
))?, .map_err(|_| errors::ConnectorError::DateFormattingFailed)?,
), ),
rebill_frequency: Some(mandate_meta.frequency), rebill_frequency: Some("0".to_string()),
challenge_window_size: None, challenge_window_size: Some(CHALLENGE_WINDOW_SIZE.to_string()),
challenge_preference: None, challenge_preference: Some(CHALLENGE_PREFERENCE.to_string()),
}), }),
item.request.get_customer_id_required(), item.request.get_customer_id_required(),
) )
} }
// non mandate transactions // non mandate transactions
_ => ( false => (
None, None,
Some(V2AdditionalParams { Some(V2AdditionalParams {
rebill_expiry: None, rebill_expiry: None,
rebill_frequency: None, rebill_frequency: None,
challenge_window_size: Some(CHALLENGE_WINDOW_SIZE.to_string()), challenge_window_size: Some(CHALLENGE_WINDOW_SIZE.to_string()),
challenge_preference: Some(CHALLENGE_PREFERNCE.to_string()), challenge_preference: Some(CHALLENGE_PREFERENCE.to_string()),
}), }),
None, None,
), ),
@ -1570,7 +1647,6 @@ where
three_d, three_d,
card_holder_name: item.get_optional_billing_full_name(), card_holder_name: item.get_optional_billing_full_name(),
}), }),
is_moto, is_moto,
..Default::default() ..Default::default()
}) })
@ -2110,6 +2186,66 @@ impl NuveiPaymentsGenericResponse for PSync {}
impl NuveiPaymentsGenericResponse for Capture {} impl NuveiPaymentsGenericResponse for Capture {}
impl NuveiPaymentsGenericResponse for PostCaptureVoid {} impl NuveiPaymentsGenericResponse for PostCaptureVoid {}
impl
TryFrom<
ResponseRouterData<
SetupMandate,
NuveiPaymentsResponse,
SetupMandateRequestData,
PaymentsResponseData,
>,
> for RouterData<SetupMandate, SetupMandateRequestData, PaymentsResponseData>
{
type Error = error_stack::Report<errors::ConnectorError>;
fn try_from(
item: ResponseRouterData<
SetupMandate,
NuveiPaymentsResponse,
SetupMandateRequestData,
PaymentsResponseData,
>,
) -> Result<Self, Self::Error> {
let amount = item.data.request.amount;
let (status, redirection_data, connector_response_data) =
process_nuvei_payment_response(&item, amount)?;
let (amount_captured, minor_amount_capturable) = item.response.get_amount_captured()?;
let ip_address = item
.data
.request
.browser_info
.as_ref()
.ok_or_else(|| errors::ConnectorError::MissingRequiredField {
field_name: "browser_info",
})?
.ip_address
.as_ref()
.ok_or_else(|| errors::ConnectorError::MissingRequiredField {
field_name: "browser_info.ip_address",
})?
.to_string();
Ok(Self {
status,
response: if let Some(err) = build_error_response(&item.response, item.http_code) {
Err(err)
} else {
Ok(create_transaction_response(
&item.response,
redirection_data,
Some(ip_address),
)?)
},
amount_captured,
minor_amount_capturable,
connector_response: connector_response_data,
..item.data
})
}
}
// Helper function to process Nuvei payment response // Helper function to process Nuvei payment response
fn process_nuvei_payment_response<F, T>( fn process_nuvei_payment_response<F, T>(
@ -2160,6 +2296,7 @@ where
fn create_transaction_response( fn create_transaction_response(
response: &NuveiPaymentsResponse, response: &NuveiPaymentsResponse,
redirection_data: Option<RedirectForm>, redirection_data: Option<RedirectForm>,
ip_address: Option<String>,
) -> Result<PaymentsResponseData, error_stack::Report<errors::ConnectorError>> { ) -> Result<PaymentsResponseData, error_stack::Report<errors::ConnectorError>> {
Ok(PaymentsResponseData::TransactionResponse { Ok(PaymentsResponseData::TransactionResponse {
resource_id: response resource_id: response
@ -2177,7 +2314,8 @@ fn create_transaction_response(
.map(|id| MandateReference { .map(|id| MandateReference {
connector_mandate_id: Some(id), connector_mandate_id: Some(id),
payment_method_id: None, payment_method_id: None,
mandate_metadata: None, mandate_metadata: ip_address
.map(|ip| pii::SecretSerdeValue::new(serde_json::Value::String(ip))),
connector_mandate_request_reference_id: None, connector_mandate_request_reference_id: None,
}), }),
), ),
@ -2226,6 +2364,14 @@ impl
process_nuvei_payment_response(&item, amount)?; process_nuvei_payment_response(&item, amount)?;
let (amount_captured, minor_amount_capturable) = item.response.get_amount_captured()?; let (amount_captured, minor_amount_capturable) = item.response.get_amount_captured()?;
let ip_address = item
.data
.request
.browser_info
.clone()
.and_then(|browser_info| browser_info.ip_address.map(|ip| ip.to_string()));
Ok(Self { Ok(Self {
status, status,
response: if let Some(err) = build_error_response(&item.response, item.http_code) { response: if let Some(err) = build_error_response(&item.response, item.http_code) {
@ -2234,6 +2380,7 @@ impl
Ok(create_transaction_response( Ok(create_transaction_response(
&item.response, &item.response,
redirection_data, redirection_data,
ip_address,
)?) )?)
}, },
amount_captured, amount_captured,
@ -2273,6 +2420,7 @@ where
Ok(create_transaction_response( Ok(create_transaction_response(
&item.response, &item.response,
redirection_data, redirection_data,
None,
)?) )?)
}, },
amount_captured, amount_captured,
@ -2388,11 +2536,26 @@ where
None None
}; };
let ip_address = data
.recurring_mandate_payment_data
.as_ref()
.and_then(|r| r.mandate_metadata.as_ref())
.ok_or(errors::ConnectorError::MissingRequiredField {
field_name: "browser_info.ip_address",
})?
.clone()
.expose()
.as_str()
.ok_or(errors::ConnectorError::MissingRequiredField {
field_name: "browser_info.ip_address",
})?
.to_owned();
Ok(Self { Ok(Self {
related_transaction_id, related_transaction_id,
device_details: DeviceDetails::foreign_try_from( device_details: DeviceDetails {
&item.request.get_browser_info().clone(), ip_address: Secret::new(ip_address),
)?, },
is_rebilling: Some("1".to_string()), // In case of second installment, rebilling should be 1 is_rebilling: Some("1".to_string()), // In case of second installment, rebilling should be 1
user_token_id: Some(customer_id), user_token_id: Some(customer_id),
payment_option: PaymentOption { payment_option: PaymentOption {

View File

@ -305,6 +305,38 @@ impl TryFrom<SetupMandateRequestData> for ConnectorCustomerData {
}) })
} }
} }
impl TryFrom<SetupMandateRequestData> for PaymentsPreProcessingData {
type Error = error_stack::Report<ApiErrorResponse>;
fn try_from(data: SetupMandateRequestData) -> Result<Self, Self::Error> {
Ok(Self {
payment_method_data: Some(data.payment_method_data),
amount: data.amount,
minor_amount: data.minor_amount,
email: data.email,
currency: Some(data.currency),
payment_method_type: data.payment_method_type,
setup_mandate_details: data.setup_mandate_details,
capture_method: data.capture_method,
order_details: None,
router_return_url: data.router_return_url,
webhook_url: data.webhook_url,
complete_authorize_url: data.complete_authorize_url,
browser_info: data.browser_info,
surcharge_details: None,
connector_transaction_id: None,
mandate_id: data.mandate_id,
related_transaction_id: None,
redirect_response: None,
enrolled_for_3ds: false,
split_payments: None,
metadata: data.metadata,
customer_acceptance: data.customer_acceptance,
setup_future_usage: data.setup_future_usage,
})
}
}
impl impl
TryFrom< TryFrom<
&RouterData<flows::Authorize, PaymentsAuthorizeData, response_types::PaymentsResponseData>, &RouterData<flows::Authorize, PaymentsAuthorizeData, response_types::PaymentsResponseData>,
@ -515,7 +547,8 @@ pub struct PaymentsPreProcessingData {
pub redirect_response: Option<CompleteAuthorizeRedirectResponse>, pub redirect_response: Option<CompleteAuthorizeRedirectResponse>,
pub metadata: Option<Secret<serde_json::Value>>, pub metadata: Option<Secret<serde_json::Value>>,
pub split_payments: Option<common_types::payments::SplitPaymentsRequest>, pub split_payments: Option<common_types::payments::SplitPaymentsRequest>,
pub customer_acceptance: Option<common_payments_types::CustomerAcceptance>,
pub setup_future_usage: Option<storage_enums::FutureUsage>,
// New amount for amount frame work // New amount for amount frame work
pub minor_amount: Option<MinorUnit>, pub minor_amount: Option<MinorUnit>,
} }
@ -546,6 +579,8 @@ impl TryFrom<PaymentsAuthorizeData> for PaymentsPreProcessingData {
enrolled_for_3ds: data.enrolled_for_3ds, enrolled_for_3ds: data.enrolled_for_3ds,
split_payments: data.split_payments, split_payments: data.split_payments,
metadata: data.metadata.map(Secret::new), metadata: data.metadata.map(Secret::new),
customer_acceptance: data.customer_acceptance,
setup_future_usage: data.setup_future_usage,
}) })
} }
} }
@ -576,6 +611,8 @@ impl TryFrom<CompleteAuthorizeData> for PaymentsPreProcessingData {
split_payments: None, split_payments: None,
enrolled_for_3ds: true, enrolled_for_3ds: true,
metadata: data.connector_meta.map(Secret::new), metadata: data.connector_meta.map(Secret::new),
customer_acceptance: data.customer_acceptance,
setup_future_usage: data.setup_future_usage,
}) })
} }
} }
@ -1215,6 +1252,8 @@ pub struct SetupMandateRequestData {
pub metadata: Option<pii::SecretSerdeValue>, pub metadata: Option<pii::SecretSerdeValue>,
pub complete_authorize_url: Option<String>, pub complete_authorize_url: Option<String>,
pub capture_method: Option<storage_enums::CaptureMethod>, pub capture_method: Option<storage_enums::CaptureMethod>,
pub enrolled_for_3ds: bool,
pub related_transaction_id: Option<String>,
// MinorUnit for amount framework // MinorUnit for amount framework
pub minor_amount: Option<MinorUnit>, pub minor_amount: Option<MinorUnit>,

View File

@ -19,7 +19,10 @@ use crate::{
}, },
routes::SessionState, routes::SessionState,
services, services,
types::{self, api, domain, transformers::ForeignTryFrom}, types::{
self, api, domain,
transformers::{ForeignFrom, ForeignTryFrom},
},
}; };
#[cfg(feature = "v1")] #[cfg(feature = "v1")]
@ -155,6 +158,38 @@ impl Feature<api::SetupMandate, types::SetupMandateRequestData> for types::Setup
.await .await
} }
async fn add_session_token<'a>(
self,
state: &SessionState,
connector: &api::ConnectorData,
) -> RouterResult<Self>
where
Self: Sized,
{
let connector_integration: services::BoxedPaymentConnectorIntegrationInterface<
api::AuthorizeSessionToken,
types::AuthorizeSessionTokenData,
types::PaymentsResponseData,
> = connector.connector.get_connector_integration();
let authorize_data = &types::PaymentsAuthorizeSessionTokenRouterData::foreign_from((
&self,
types::AuthorizeSessionTokenData::foreign_from(&self),
));
let resp = services::execute_connector_processing_step(
state,
connector_integration,
authorize_data,
payments::CallConnectorAction::Trigger,
None,
None,
)
.await
.to_payment_failed_response()?;
let mut router_data = self;
router_data.session_token = resp.session_token;
Ok(router_data)
}
async fn add_payment_method_token<'a>( async fn add_payment_method_token<'a>(
&mut self, &mut self,
state: &SessionState, state: &SessionState,
@ -213,6 +248,14 @@ impl Feature<api::SetupMandate, types::SetupMandateRequestData> for types::Setup
} }
} }
async fn preprocessing_steps<'a>(
self,
state: &SessionState,
connector: &api::ConnectorData,
) -> RouterResult<Self> {
setup_mandate_preprocessing_steps(state, &self, true, connector).await
}
async fn call_unified_connector_service<'a>( async fn call_unified_connector_service<'a>(
&mut self, &mut self,
state: &SessionState, state: &SessionState,
@ -301,3 +344,66 @@ impl mandate::MandateBehaviour for types::SetupMandateRequestData {
self.customer_acceptance.clone() self.customer_acceptance.clone()
} }
} }
pub async fn setup_mandate_preprocessing_steps<F: Clone>(
state: &SessionState,
router_data: &types::RouterData<F, types::SetupMandateRequestData, types::PaymentsResponseData>,
confirm: bool,
connector: &api::ConnectorData,
) -> RouterResult<types::RouterData<F, types::SetupMandateRequestData, types::PaymentsResponseData>>
{
if confirm {
let connector_integration: services::BoxedPaymentConnectorIntegrationInterface<
api::PreProcessing,
types::PaymentsPreProcessingData,
types::PaymentsResponseData,
> = connector.connector.get_connector_integration();
let preprocessing_request_data =
types::PaymentsPreProcessingData::try_from(router_data.request.clone())?;
let preprocessing_response_data: Result<types::PaymentsResponseData, types::ErrorResponse> =
Err(types::ErrorResponse::default());
let preprocessing_router_data =
helpers::router_data_type_conversion::<_, api::PreProcessing, _, _, _, _>(
router_data.clone(),
preprocessing_request_data,
preprocessing_response_data,
);
let resp = services::execute_connector_processing_step(
state,
connector_integration,
&preprocessing_router_data,
payments::CallConnectorAction::Trigger,
None,
None,
)
.await
.to_payment_failed_response()?;
let mut setup_mandate_router_data = helpers::router_data_type_conversion::<_, F, _, _, _, _>(
resp.clone(),
router_data.request.to_owned(),
resp.response.clone(),
);
if connector.connector_name == api_models::enums::Connector::Nuvei {
let (enrolled_for_3ds, related_transaction_id) =
match &setup_mandate_router_data.response {
Ok(types::PaymentsResponseData::ThreeDSEnrollmentResponse {
enrolled_v2,
related_transaction_id,
}) => (*enrolled_v2, related_transaction_id.clone()),
_ => (false, None),
};
setup_mandate_router_data.request.enrolled_for_3ds = enrolled_for_3ds;
setup_mandate_router_data.request.related_transaction_id = related_transaction_id;
}
Ok(setup_mandate_router_data)
} else {
Ok(router_data.clone())
}
}

View File

@ -1301,6 +1301,8 @@ pub async fn construct_payment_router_data_for_setup_mandate<'a>(
customer_id: None, customer_id: None,
enable_partial_authorization: None, enable_partial_authorization: None,
payment_channel: None, payment_channel: None,
enrolled_for_3ds: true,
related_transaction_id: None,
}; };
let connector_mandate_request_reference_id = payment_data let connector_mandate_request_reference_id = payment_data
.payment_attempt .payment_attempt
@ -5118,6 +5120,8 @@ impl<F: Clone> TryFrom<PaymentAdditionalData<'_, F>> for types::SetupMandateRequ
customer_id: payment_data.payment_intent.customer_id, customer_id: payment_data.payment_intent.customer_id,
enable_partial_authorization: payment_data.payment_intent.enable_partial_authorization, enable_partial_authorization: payment_data.payment_intent.enable_partial_authorization,
payment_channel: payment_data.payment_intent.payment_channel, payment_channel: payment_data.payment_intent.payment_channel,
related_transaction_id: None,
enrolled_for_3ds: true,
}) })
} }
} }
@ -5369,6 +5373,8 @@ impl<F: Clone> TryFrom<PaymentAdditionalData<'_, F>> for types::PaymentsPreProce
enrolled_for_3ds: true, enrolled_for_3ds: true,
split_payments: payment_data.payment_intent.split_payments, split_payments: payment_data.payment_intent.split_payments,
metadata: payment_data.payment_intent.metadata.map(Secret::new), metadata: payment_data.payment_intent.metadata.map(Secret::new),
customer_acceptance: payment_data.customer_acceptance,
setup_future_usage: payment_data.payment_intent.setup_future_usage,
}) })
} }
} }

View File

@ -1158,6 +1158,17 @@ impl ForeignFrom<&ExternalVaultProxyPaymentsRouterData> for AuthorizeSessionToke
} }
} }
impl<'a> ForeignFrom<&'a SetupMandateRouterData> for AuthorizeSessionTokenData {
fn foreign_from(data: &'a SetupMandateRouterData) -> Self {
Self {
amount_to_capture: data.request.amount,
currency: data.request.currency,
connector_transaction_id: data.payment_id.clone(),
amount: data.request.amount,
}
}
}
pub trait Tokenizable { pub trait Tokenizable {
fn set_session_token(&mut self, token: Option<String>); fn set_session_token(&mut self, token: Option<String>);
} }

View File

@ -16,6 +16,25 @@ const successfulThreeDSCardDetails = {
card_holder_name: "CL-BRW1", card_holder_name: "CL-BRW1",
card_cvc: "123", card_cvc: "123",
}; };
const singleUseMandateData = {
customer_acceptance: customerAcceptance,
mandate_type: {
single_use: {
amount: 8000,
currency: "USD",
},
},
};
const multiUseMandateData = {
customer_acceptance: customerAcceptance,
mandate_type: {
multi_use: {
amount: 8000,
currency: "USD",
},
},
};
// Payment method data objects for responses // Payment method data objects for responses
const payment_method_data_no3ds = { const payment_method_data_no3ds = {
card: { card: {
@ -42,31 +61,6 @@ const payment_method_data_no3ds = {
billing: null, billing: null,
}; };
const payment_method_data_3ds = {
card: {
last4: "0961",
card_type: "CREDIT",
card_network: "Visa",
card_issuer: "RIVER VALLEY CREDIT UNION",
card_issuing_country: "UNITEDSTATES",
card_isin: "400002",
card_extended_bin: null,
card_exp_month: "10",
card_exp_year: "25",
card_holder_name: "CL-BRW1",
payment_checks: {
avs_description: null,
avs_result_code: "",
cvv_2_reply_code: "",
cvv_2_description: null,
merchant_advice_code: "",
merchant_advice_code_description: null,
},
authentication_data: {},
},
billing: null,
};
export const connectorDetails = { export const connectorDetails = {
card_pm: { card_pm: {
// Basic payment intent creation // Basic payment intent creation
@ -204,7 +198,6 @@ export const connectorDetails = {
body: { body: {
status: "requires_customer_action", status: "requires_customer_action",
setup_future_usage: "on_session", setup_future_usage: "on_session",
payment_method_data: payment_method_data_3ds,
}, },
}, },
}, },
@ -315,7 +308,25 @@ export const connectorDetails = {
}, },
}, },
}, },
ZeroAuthMandate: {
Configs: {
TRIGGER_SKIP: true,
},
Request: {
payment_method: "card",
payment_method_data: {
card: successfulNo3DSCardDetails,
},
currency: "USD",
customer_acceptance: customerAcceptance,
},
Response: {
status: 200,
body: {
status: "succeeded",
},
},
},
ZeroAuthPaymentIntent: { ZeroAuthPaymentIntent: {
Request: { Request: {
amount: 0, amount: 0,
@ -331,9 +342,6 @@ export const connectorDetails = {
}, },
}, },
ZeroAuthConfirmPayment: { ZeroAuthConfirmPayment: {
Configs: {
TRIGGER_SKIP: true,
},
Request: { Request: {
payment_type: "setup_mandate", payment_type: "setup_mandate",
payment_method: "card", payment_method: "card",
@ -343,11 +351,110 @@ export const connectorDetails = {
}, },
}, },
Response: { Response: {
status: 501, status: 200,
error: { body: {
type: "invalid_request", status: "succeeded",
message: "Setup Mandate flow for Nuvei is not implemented", },
code: "IR_00", },
},
MITManualCapture: {
Configs: {
TRIGGER_SKIP: true,
},
Request: {},
Response: {
status: 200,
body: {
status: "requires_capture",
},
},
},
MandateSingleUseNo3DSAutoCapture: {
Configs: {
TRIGGER_SKIP: true,
},
Request: {
payment_method: "card",
payment_method_data: {
card: successfulNo3DSCardDetails,
},
currency: "USD",
customer_acceptance: customerAcceptance,
},
Response: {
status: 200,
body: {
status: "succeeded",
},
},
},
MandateMultiUseNo3DSAutoCapture: {
Configs: {
TRIGGER_SKIP: true,
},
Request: {
payment_method: "card",
payment_method_data: {
card: successfulNo3DSCardDetails,
},
currency: "USD",
mandate_data: multiUseMandateData,
},
Response: {
status: 200,
body: {
status: "succeeded",
},
},
},
MandateSingleUseNo3DSManualCapture: {
Configs: {
TRIGGER_SKIP: true,
},
Request: {
payment_method: "card",
payment_method_data: {
card: successfulNo3DSCardDetails,
},
currency: "USD",
mandate_data: singleUseMandateData,
},
Response: {
status: 200,
body: {
status: "requires_capture",
},
},
},
MandateMultiUseNo3DSManualCapture: {
Configs: {
TRIGGER_SKIP: true,
},
Request: {
payment_method: "card",
payment_method_data: {
card: successfulNo3DSCardDetails,
},
currency: "USD",
mandate_data: multiUseMandateData,
},
Response: {
status: 200,
body: {
status: "requires_capture",
payment_method_data: payment_method_data_no3ds,
payment_method: "card",
},
},
},
MITAutoCapture: {
Request: {
amount_to_capture: 6000,
},
Response: {
status: 200,
body: {
status: "succeeded",
}, },
}, },
}, },
@ -475,9 +582,6 @@ export const connectorDetails = {
}, },
// Payment method ID mandate scenarios // Payment method ID mandate scenarios
PaymentMethodIdMandateNo3DSAutoCapture: { PaymentMethodIdMandateNo3DSAutoCapture: {
Configs: {
TRIGGER_SKIP: true,
},
Request: { Request: {
payment_method: "card", payment_method: "card",
payment_method_data: { payment_method_data: {