From b014b1387acec0a0a9f699332ce486bb8d28c620 Mon Sep 17 00:00:00 2001 From: Ben Janecke Date: Wed, 10 Sep 2025 14:18:42 +0200 Subject: [PATCH] feat(connector): enhance ACI connector with comprehensive 3DS support - DRAFT (#8986) Co-authored-by: Ben Janecke Co-authored-by: Sweta-Kumari-Sharma --- config/config.example.toml | 4 +- config/deployments/integration_test.toml | 4 +- config/deployments/production.toml | 4 +- config/deployments/sandbox.toml | 4 +- config/development.toml | 4 +- config/docker_compose.toml | 4 +- .../src/connectors/aci.rs | 144 ++- .../src/connectors/aci/transformers.rs | 372 ++++++- crates/router/tests/connectors/aci.rs | 927 ++++++++++-------- loadtest/config/development.toml | 4 +- 10 files changed, 998 insertions(+), 473 deletions(-) diff --git a/config/config.example.toml b/config/config.example.toml index d2bbb75a04..b725be102c 100644 --- a/config/config.example.toml +++ b/config/config.example.toml @@ -541,8 +541,8 @@ wallet.google_pay = { connector_list = "bankofamerica,authorizedotnet,cybersourc bank_redirect.giropay = { connector_list = "globalpay" } [mandates.supported_payment_methods] -card.credit.connector_list = "checkout,stripe,adyen,authorizedotnet,cybersource,datatrans,globalpay,worldpay,multisafepay,nmi,nexinets,noon,bankofamerica,braintree,nuvei,payme,wellsfargo,bamboraapac,elavon,fiuu,nexixpay,novalnet,paybox,paypal,xendit,moneris,archipel,worldpayvantiv" -card.debit.connector_list = "checkout,stripe,adyen,authorizedotnet,cybersource,datatrans,globalpay,worldpay,multisafepay,nmi,nexinets,noon,bankofamerica,braintree,nuvei,payme,wellsfargo,bamboraapac,elavon,fiuu,nexixpay,novalnet,paybox,paypal,xendit,moneris,archipel,worldpayvantiv" +card.credit.connector_list = "aci,checkout,stripe,adyen,authorizedotnet,cybersource,datatrans,globalpay,worldpay,multisafepay,nmi,nexinets,noon,bankofamerica,braintree,nuvei,payme,wellsfargo,bamboraapac,elavon,fiuu,nexixpay,novalnet,paybox,paypal,xendit,moneris,archipel,worldpayvantiv" +card.debit.connector_list = "aci,checkout,stripe,adyen,authorizedotnet,cybersource,datatrans,globalpay,worldpay,multisafepay,nmi,nexinets,noon,bankofamerica,braintree,nuvei,payme,wellsfargo,bamboraapac,elavon,fiuu,nexixpay,novalnet,paybox,paypal,xendit,moneris,archipel,worldpayvantiv" wallet.paypal = { connector_list = "adyen,novalnet" } # Mandate supported payment method type and connector for wallets pay_later.klarna = { connector_list = "adyen" } # Mandate supported payment method type and connector for pay_later bank_debit.ach = { connector_list = "gocardless,adyen" } # Mandate supported payment method type and connector for bank_debit diff --git a/config/deployments/integration_test.toml b/config/deployments/integration_test.toml index 15665793cc..c756b0a9e9 100644 --- a/config/deployments/integration_test.toml +++ b/config/deployments/integration_test.toml @@ -231,8 +231,8 @@ bank_debit.ach = { connector_list = "gocardless,adyen,stripe" } bank_debit.becs = { connector_list = "gocardless,stripe,adyen" } bank_debit.bacs = { connector_list = "stripe,gocardless" } bank_debit.sepa = { connector_list = "gocardless,adyen,stripe,deutschebank" } -card.credit.connector_list = "checkout,stripe,adyen,authorizedotnet,cybersource,datatrans,globalpay,worldpay,multisafepay,nmi,nexinets,noon,bankofamerica,braintree,nuvei,payme,wellsfargo,bamboraapac,elavon,fiuu,nexixpay,novalnet,paybox,paypal,xendit,moneris,archipel,worldpayvantiv,payload" -card.debit.connector_list = "checkout,stripe,adyen,authorizedotnet,cybersource,datatrans,globalpay,worldpay,multisafepay,nmi,nexinets,noon,bankofamerica,braintree,nuvei,payme,wellsfargo,bamboraapac,elavon,fiuu,nexixpay,novalnet,paybox,paypal,xendit,moneris,archipel,worldpayvantiv,payload" +card.credit.connector_list = "aci,checkout,stripe,adyen,authorizedotnet,cybersource,datatrans,globalpay,worldpay,multisafepay,nmi,nexinets,noon,bankofamerica,braintree,nuvei,payme,wellsfargo,bamboraapac,elavon,fiuu,nexixpay,novalnet,paybox,paypal,xendit,moneris,archipel,worldpayvantiv,payload" +card.debit.connector_list = "aci,checkout,stripe,adyen,authorizedotnet,cybersource,datatrans,globalpay,worldpay,multisafepay,nmi,nexinets,noon,bankofamerica,braintree,nuvei,payme,wellsfargo,bamboraapac,elavon,fiuu,nexixpay,novalnet,paybox,paypal,xendit,moneris,archipel,worldpayvantiv,payload" pay_later.klarna.connector_list = "adyen,aci" wallet.apple_pay.connector_list = "stripe,adyen,cybersource,noon,bankofamerica,nexinets,novalnet,nuvei,authorizedotnet,wellsfargo" wallet.samsung_pay.connector_list = "cybersource" diff --git a/config/deployments/production.toml b/config/deployments/production.toml index 3843682e91..f685bbe278 100644 --- a/config/deployments/production.toml +++ b/config/deployments/production.toml @@ -231,8 +231,8 @@ bank_debit.ach = { connector_list = "gocardless,adyen,stripe" } bank_debit.becs = { connector_list = "gocardless,stripe,adyen" } bank_debit.bacs = { connector_list = "stripe,gocardless" } bank_debit.sepa = { connector_list = "gocardless,adyen,stripe,deutschebank" } -card.credit.connector_list = "checkout,stripe,adyen,authorizedotnet,cybersource,datatrans,globalpay,worldpay,multisafepay,nmi,nexinets,noon,bankofamerica,braintree,nuvei,payme,wellsfargo,bamboraapac,elavon,fiuu,nexixpay,novalnet,paybox,paypal,xendit,moneris,archipel,worldpayvantiv,payload" -card.debit.connector_list = "checkout,stripe,adyen,authorizedotnet,cybersource,datatrans,globalpay,worldpay,multisafepay,nmi,nexinets,noon,bankofamerica,braintree,nuvei,payme,wellsfargo,bamboraapac,elavon,fiuu,nexixpay,novalnet,paybox,paypal,xendit,moneris,archipel,worldpayvantiv,payload" +card.credit.connector_list = "aci,checkout,stripe,adyen,authorizedotnet,cybersource,datatrans,globalpay,worldpay,multisafepay,nmi,nexinets,noon,bankofamerica,braintree,nuvei,payme,wellsfargo,bamboraapac,elavon,fiuu,nexixpay,novalnet,paybox,paypal,xendit,moneris,archipel,worldpayvantiv,payload" +card.debit.connector_list = "aci,checkout,stripe,adyen,authorizedotnet,cybersource,datatrans,globalpay,worldpay,multisafepay,nmi,nexinets,noon,bankofamerica,braintree,nuvei,payme,wellsfargo,bamboraapac,elavon,fiuu,nexixpay,novalnet,paybox,paypal,xendit,moneris,archipel,worldpayvantiv,payload" pay_later.klarna.connector_list = "adyen,aci" wallet.apple_pay.connector_list = "stripe,adyen,cybersource,noon,bankofamerica,nexinets,novalnet,nuvei,authorizedotnet,wellsfargo" wallet.samsung_pay.connector_list = "cybersource" diff --git a/config/deployments/sandbox.toml b/config/deployments/sandbox.toml index 3fd60dfe8f..9ed89a01c9 100644 --- a/config/deployments/sandbox.toml +++ b/config/deployments/sandbox.toml @@ -238,8 +238,8 @@ bank_debit.ach = { connector_list = "gocardless,adyen,stripe" } bank_debit.becs = { connector_list = "gocardless,stripe,adyen" } bank_debit.bacs = { connector_list = "stripe,gocardless" } bank_debit.sepa = { connector_list = "gocardless,adyen,stripe,deutschebank" } -card.credit.connector_list = "checkout,stripe,adyen,authorizedotnet,cybersource,datatrans,globalpay,worldpay,multisafepay,nmi,nexinets,noon,bankofamerica,braintree,nuvei,payme,wellsfargo,bamboraapac,elavon,fiuu,nexixpay,novalnet,paybox,paypal,xendit,moneris,archipel,worldpayvantiv,payload" -card.debit.connector_list = "checkout,stripe,adyen,authorizedotnet,cybersource,datatrans,globalpay,worldpay,multisafepay,nmi,nexinets,noon,bankofamerica,braintree,nuvei,payme,wellsfargo,bamboraapac,elavon,fiuu,nexixpay,novalnet,paybox,paypal,xendit,moneris,archipel,worldpayvantiv,payload" +card.credit.connector_list = "aci,checkout,stripe,adyen,authorizedotnet,cybersource,datatrans,globalpay,worldpay,multisafepay,nmi,nexinets,noon,bankofamerica,braintree,nuvei,payme,wellsfargo,bamboraapac,elavon,fiuu,nexixpay,novalnet,paybox,paypal,xendit,moneris,archipel,worldpayvantiv,payload" +card.debit.connector_list = "aci,checkout,stripe,adyen,authorizedotnet,cybersource,datatrans,globalpay,worldpay,multisafepay,nmi,nexinets,noon,bankofamerica,braintree,nuvei,payme,wellsfargo,bamboraapac,elavon,fiuu,nexixpay,novalnet,paybox,paypal,xendit,moneris,archipel,worldpayvantiv,payload" pay_later.klarna.connector_list = "adyen,aci" wallet.apple_pay.connector_list = "stripe,adyen,cybersource,noon,bankofamerica,nexinets,novalnet,nuvei,authorizedotnet,wellsfargo,worldpayvantiv" wallet.samsung_pay.connector_list = "cybersource" diff --git a/config/development.toml b/config/development.toml index 0454eb1931..940e43b50c 100644 --- a/config/development.toml +++ b/config/development.toml @@ -1081,8 +1081,8 @@ bank_debit.ach = { connector_list = "gocardless,adyen,stripe" } bank_debit.becs = { connector_list = "gocardless,stripe,adyen" } bank_debit.bacs = { connector_list = "stripe,gocardless" } bank_debit.sepa = { connector_list = "gocardless,adyen,stripe,deutschebank" } -card.credit.connector_list = "checkout,stripe,adyen,authorizedotnet,cybersource,datatrans,globalpay,worldpay,multisafepay,nmi,nexinets,noon,bankofamerica,braintree,nuvei,payme,wellsfargo,bamboraapac,elavon,fiuu,nexixpay,novalnet,paybox,paypal,xendit,moneris,archipel,worldpayvantiv,payload" -card.debit.connector_list = "checkout,stripe,adyen,authorizedotnet,cybersource,datatrans,globalpay,worldpay,multisafepay,nmi,nexinets,noon,bankofamerica,braintree,nuvei,payme,wellsfargo,bamboraapac,elavon,fiuu,nexixpay,novalnet,paybox,paypal,xendit,moneris,archipel,worldpayvantiv,payload" +card.credit.connector_list = "aci,checkout,stripe,adyen,authorizedotnet,cybersource,datatrans,globalpay,worldpay,multisafepay,nmi,nexinets,noon,bankofamerica,braintree,nuvei,payme,wellsfargo,bamboraapac,elavon,fiuu,nexixpay,novalnet,paybox,paypal,xendit,moneris,archipel,worldpayvantiv,payload" +card.debit.connector_list = "aci,checkout,stripe,adyen,authorizedotnet,cybersource,datatrans,globalpay,worldpay,multisafepay,nmi,nexinets,noon,bankofamerica,braintree,nuvei,payme,wellsfargo,bamboraapac,elavon,fiuu,nexixpay,novalnet,paybox,paypal,xendit,moneris,archipel,worldpayvantiv,payload" pay_later.klarna.connector_list = "adyen,aci" wallet.apple_pay.connector_list = "stripe,adyen,cybersource,noon,bankofamerica,nuvei,nexinets,novalnet,authorizedotnet,wellsfargo,worldpayvantiv" wallet.samsung_pay.connector_list = "cybersource" diff --git a/config/docker_compose.toml b/config/docker_compose.toml index b154cea783..d299323f2c 100644 --- a/config/docker_compose.toml +++ b/config/docker_compose.toml @@ -1009,8 +1009,8 @@ wallet.google_pay = { connector_list = "stripe,cybersource,adyen,bankofamerica,a wallet.apple_pay = { connector_list = "stripe,adyen,cybersource,noon,bankofamerica,authorizedotnet,novalnet,multisafepay,wellsfargo,nuvei" } wallet.samsung_pay = { connector_list = "cybersource" } wallet.paypal = { connector_list = "adyen,novalnet,authorizedotnet" } -card.credit = { connector_list = "checkout,stripe,adyen,authorizedotnet,cybersource,datatrans,globalpay,worldpay,multisafepay,nmi,nexinets,noon,bankofamerica,elavon,xendit,novalnet,bamboraapac,archipel,wellsfargo,worldpayvantiv,payload" } -card.debit = { connector_list = "checkout,stripe,adyen,authorizedotnet,cybersource,datatrans,globalpay,worldpay,multisafepay,nmi,nexinets,noon,bankofamerica,elavon,xendit,novalnet,bamboraapac,archipel,wellsfargo,worldpayvantiv,payload" } +card.credit = { connector_list = "aci,checkout,stripe,adyen,authorizedotnet,cybersource,datatrans,globalpay,worldpay,multisafepay,nmi,nexinets,noon,bankofamerica,elavon,xendit,novalnet,bamboraapac,archipel,wellsfargo,worldpayvantiv,payload" } +card.debit = { connector_list = "aci,checkout,stripe,adyen,authorizedotnet,cybersource,datatrans,globalpay,worldpay,multisafepay,nmi,nexinets,noon,bankofamerica,elavon,xendit,novalnet,bamboraapac,archipel,wellsfargo,worldpayvantiv,payload" } bank_debit.ach = { connector_list = "gocardless,adyen" } bank_debit.becs = { connector_list = "gocardless" } bank_debit.bacs = { connector_list = "adyen" } diff --git a/crates/hyperswitch_connectors/src/connectors/aci.rs b/crates/hyperswitch_connectors/src/connectors/aci.rs index 59c89ae977..96c3a059c2 100644 --- a/crates/hyperswitch_connectors/src/connectors/aci.rs +++ b/crates/hyperswitch_connectors/src/connectors/aci.rs @@ -96,7 +96,7 @@ impl ConnectorCommon for Aci { .change_context(errors::ConnectorError::FailedToObtainAuthType)?; Ok(vec![( headers::AUTHORIZATION.to_string(), - auth.api_key.into_masked(), + format!("Bearer {}", auth.api_key.peek()).into_masked(), )]) } @@ -161,31 +161,133 @@ impl api::PaymentToken for Aci {} impl ConnectorIntegration for Aci { - // Not Implemented (R) + fn build_request( + &self, + _req: &RouterData, + _connectors: &Connectors, + ) -> CustomResult, errors::ConnectorError> { + Err(errors::ConnectorError::NotSupported { + message: "Payment method tokenization not supported".to_string(), + connector: "ACI", + } + .into()) + } } impl ConnectorIntegration for Aci { - // Not Implemented (R) + fn build_request( + &self, + _req: &RouterData, + _connectors: &Connectors, + ) -> CustomResult, errors::ConnectorError> { + Err(errors::ConnectorError::NotSupported { + message: "Payment sessions not supported".to_string(), + connector: "ACI", + } + .into()) + } } impl ConnectorIntegration for Aci { - // Not Implemented (R) + fn build_request( + &self, + _req: &RouterData, + _connectors: &Connectors, + ) -> CustomResult, errors::ConnectorError> { + Err(errors::ConnectorError::NotSupported { + message: "Access token authentication not supported".to_string(), + connector: "ACI", + } + .into()) + } } impl api::MandateSetup for Aci {} impl ConnectorIntegration for Aci { - // Issue: #173 - fn build_request( + fn get_headers( + &self, + req: &RouterData, + _connectors: &Connectors, + ) -> CustomResult)>, errors::ConnectorError> { + let mut header = vec![( + headers::CONTENT_TYPE.to_string(), + self.common_get_content_type().to_string().into(), + )]; + let mut api_key = self.get_auth_header(&req.connector_auth_type)?; + header.append(&mut api_key); + Ok(header) + } + + fn get_content_type(&self) -> &'static str { + self.common_get_content_type() + } + + fn get_url( &self, _req: &RouterData, + connectors: &Connectors, + ) -> CustomResult { + Ok(format!("{}v1/registrations", self.base_url(connectors))) + } + + fn get_request_body( + &self, + req: &RouterData, _connectors: &Connectors, + ) -> CustomResult { + let connector_req = aci::AciMandateRequest::try_from(req)?; + Ok(RequestContent::FormUrlEncoded(Box::new(connector_req))) + } + + fn build_request( + &self, + req: &RouterData, + connectors: &Connectors, ) -> CustomResult, errors::ConnectorError> { - Err(errors::ConnectorError::NotImplemented("Setup Mandate flow for Aci".to_string()).into()) + Ok(Some( + RequestBuilder::new() + .method(Method::Post) + .url(&self.get_url(req, connectors)?) + .attach_default_headers() + .headers(self.get_headers(req, connectors)?) + .set_body(self.get_request_body(req, connectors)?) + .build(), + )) + } + + fn handle_response( + &self, + data: &RouterData, + event_builder: Option<&mut ConnectorEvent>, + res: Response, + ) -> CustomResult< + RouterData, + errors::ConnectorError, + > { + let response: aci::AciMandateResponse = res + .response + .parse_struct("AciMandateResponse") + .change_context(errors::ConnectorError::ResponseDeserializationFailed)?; + 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, + }) + .change_context(errors::ConnectorError::ResponseHandlingFailed) + } + + fn get_error_response( + &self, + res: Response, + event_builder: Option<&mut ConnectorEvent>, + ) -> CustomResult { + self.build_error_response(res, event_builder) } } -// TODO: Investigate unexplained error in capture flow from connector. impl ConnectorIntegration for Aci { fn get_headers( &self, @@ -409,8 +511,6 @@ impl ConnectorIntegration CustomResult { - // encode only for for urlencoded things. - let amount = convert_amount( self.amount_converter, req.request.minor_amount, @@ -724,14 +824,13 @@ fn decrypt_aci_webhook_payload( Ok(ciphertext_and_tag) } -// TODO: Test this webhook flow once dashboard access is available. #[async_trait::async_trait] impl IncomingWebhook for Aci { fn get_webhook_source_verification_algorithm( &self, _request: &IncomingWebhookRequestDetails<'_>, ) -> CustomResult, errors::ConnectorError> { - Ok(Box::new(crypto::NoAlgorithm)) + Ok(Box::new(crypto::HmacSha256)) } fn get_webhook_source_verification_signature( @@ -899,15 +998,15 @@ static ACI_SUPPORTED_PAYMENT_METHODS: LazyLock = LazyLo enums::CaptureMethod::Manual, ]; - let supported_card_network = vec![ + let supported_card_networks = vec![ + common_enums::CardNetwork::Visa, + common_enums::CardNetwork::Mastercard, common_enums::CardNetwork::AmericanExpress, + common_enums::CardNetwork::JCB, common_enums::CardNetwork::DinersClub, common_enums::CardNetwork::Discover, - common_enums::CardNetwork::JCB, - common_enums::CardNetwork::Maestro, - common_enums::CardNetwork::Mastercard, common_enums::CardNetwork::UnionPay, - common_enums::CardNetwork::Visa, + common_enums::CardNetwork::Maestro, ]; let mut aci_supported_payment_methods = SupportedPaymentMethods::new(); @@ -944,9 +1043,9 @@ static ACI_SUPPORTED_PAYMENT_METHODS: LazyLock = LazyLo specific_features: Some( api_models::feature_matrix::PaymentMethodSpecificFeatures::Card({ api_models::feature_matrix::CardSpecificFeatures { - three_ds: common_enums::FeatureStatus::NotSupported, + three_ds: common_enums::FeatureStatus::Supported, no_three_ds: common_enums::FeatureStatus::Supported, - supported_card_networks: supported_card_network.clone(), + supported_card_networks: supported_card_networks.clone(), } }), ), @@ -963,14 +1062,15 @@ static ACI_SUPPORTED_PAYMENT_METHODS: LazyLock = LazyLo specific_features: Some( api_models::feature_matrix::PaymentMethodSpecificFeatures::Card({ api_models::feature_matrix::CardSpecificFeatures { - three_ds: common_enums::FeatureStatus::NotSupported, + three_ds: common_enums::FeatureStatus::Supported, no_three_ds: common_enums::FeatureStatus::Supported, - supported_card_networks: supported_card_network.clone(), + supported_card_networks: supported_card_networks.clone(), } }), ), }, ); + aci_supported_payment_methods.add( enums::PaymentMethod::BankRedirect, enums::PaymentMethodType::Eps, @@ -1051,6 +1151,7 @@ static ACI_SUPPORTED_PAYMENT_METHODS: LazyLock = LazyLo specific_features: None, }, ); + aci_supported_payment_methods.add( enums::PaymentMethod::PayLater, enums::PaymentMethodType::Klarna, @@ -1061,6 +1162,7 @@ static ACI_SUPPORTED_PAYMENT_METHODS: LazyLock = LazyLo specific_features: None, }, ); + aci_supported_payment_methods }); diff --git a/crates/hyperswitch_connectors/src/connectors/aci/transformers.rs b/crates/hyperswitch_connectors/src/connectors/aci/transformers.rs index 3edd04f830..c3f5e53d9a 100644 --- a/crates/hyperswitch_connectors/src/connectors/aci/transformers.rs +++ b/crates/hyperswitch_connectors/src/connectors/aci/transformers.rs @@ -4,10 +4,15 @@ use common_enums::enums; use common_utils::{id_type, pii::Email, request::Method, types::StringMajorUnit}; use error_stack::report; use hyperswitch_domain_models::{ - payment_method_data::{BankRedirectData, Card, PayLaterData, PaymentMethodData, WalletData}, + network_tokenization::NetworkTokenNumber, + payment_method_data::{ + BankRedirectData, Card, NetworkTokenData, PayLaterData, PaymentMethodData, WalletData, + }, router_data::{ConnectorAuthType, RouterData}, + router_flow_types::SetupMandate, router_request_types::{ PaymentsAuthorizeData, PaymentsCancelData, PaymentsSyncData, ResponseId, + SetupMandateRequestData, }, router_response_types::{ MandateReference, PaymentsResponseData, RedirectForm, RefundsResponseData, @@ -25,7 +30,10 @@ use url::Url; use super::aci_result_codes::{FAILURE_CODES, PENDING_CODES, SUCCESSFUL_CODES}; use crate::{ types::{RefundsResponseRouterData, ResponseRouterData}, - utils::{self, PhoneDetailsData, RouterData as _}, + utils::{ + self, CardData, NetworkTokenData as NetworkTokenDataTrait, PaymentsAuthorizeRequestData, + PhoneDetailsData, RouterData as _, + }, }; type Error = error_stack::Report; @@ -54,8 +62,8 @@ impl GetCaptureMethod for PaymentsCancelData { #[derive(Debug, Serialize)] pub struct AciRouterData { - amount: StringMajorUnit, - router_data: T, + pub amount: StringMajorUnit, + pub router_data: T, } impl From<(StringMajorUnit, T)> for AciRouterData { @@ -114,6 +122,24 @@ pub struct AciCancelRequest { pub payment_type: AciPaymentType, } +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AciMandateRequest { + pub entity_id: Secret, + pub payment_brand: PaymentBrand, + #[serde(flatten)] + pub payment_details: PaymentDetails, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AciMandateResponse { + pub id: String, + pub result: ResultCode, + pub build_number: String, + pub timestamp: String, +} + #[derive(Debug, Clone, Serialize)] #[serde(untagged)] pub enum PaymentDetails { @@ -123,6 +149,7 @@ pub enum PaymentDetails { Wallet(Box), Klarna, Mandate, + AciNetworkToken(Box), } impl TryFrom<(&WalletData, &PaymentsAuthorizeRouterData)> for PaymentDetails { @@ -321,21 +348,117 @@ impl } } +fn get_aci_payment_brand( + card_network: Option, + is_network_token_flow: bool, +) -> Result { + match card_network { + Some(common_enums::CardNetwork::Visa) => Ok(PaymentBrand::Visa), + Some(common_enums::CardNetwork::Mastercard) => Ok(PaymentBrand::Mastercard), + Some(common_enums::CardNetwork::AmericanExpress) => Ok(PaymentBrand::AmericanExpress), + Some(common_enums::CardNetwork::JCB) => Ok(PaymentBrand::Jcb), + Some(common_enums::CardNetwork::DinersClub) => Ok(PaymentBrand::DinersClub), + Some(common_enums::CardNetwork::Discover) => Ok(PaymentBrand::Discover), + Some(common_enums::CardNetwork::UnionPay) => Ok(PaymentBrand::UnionPay), + Some(common_enums::CardNetwork::Maestro) => Ok(PaymentBrand::Maestro), + Some(unsupported_network) => Err(errors::ConnectorError::NotSupported { + message: format!( + "Card network {:?} is not supported by ACI", + unsupported_network + ), + connector: "ACI", + })?, + None => { + if is_network_token_flow { + Ok(PaymentBrand::Visa) + } else { + Err(errors::ConnectorError::MissingRequiredField { + field_name: "card.card_network", + } + .into()) + } + } + } +} + impl TryFrom<(Card, Option>)> for PaymentDetails { type Error = Error; fn try_from( (card_data, card_holder_name): (Card, Option>), ) -> Result { + let card_expiry_year = card_data.get_expiry_year_4_digit(); + + let payment_brand = get_aci_payment_brand(card_data.card_network, false)?; + Ok(Self::AciCard(Box::new(CardDetails { card_number: card_data.card_number, - card_holder: card_holder_name.unwrap_or(Secret::new("".to_string())), - card_expiry_month: card_data.card_exp_month, - card_expiry_year: card_data.card_exp_year, + card_holder: card_holder_name.ok_or(errors::ConnectorError::MissingRequiredField { + field_name: "card_holder_name", + })?, + card_expiry_month: card_data.card_exp_month.clone(), + card_expiry_year, card_cvv: card_data.card_cvc, + payment_brand, }))) } } +impl + TryFrom<( + &AciRouterData<&PaymentsAuthorizeRouterData>, + &NetworkTokenData, + )> for PaymentDetails +{ + type Error = Error; + fn try_from( + value: ( + &AciRouterData<&PaymentsAuthorizeRouterData>, + &NetworkTokenData, + ), + ) -> Result { + let (_item, network_token_data) = value; + let token_number = network_token_data.get_network_token(); + let payment_brand = get_aci_payment_brand(network_token_data.card_network.clone(), true)?; + let aci_network_token_data = AciNetworkTokenData { + token_type: AciTokenAccountType::Network, + token_number, + token_expiry_month: network_token_data.get_network_token_expiry_month(), + token_expiry_year: network_token_data.get_expiry_year_4_digit(), + token_cryptogram: Some( + network_token_data + .get_cryptogram() + .clone() + .unwrap_or_default(), + ), + payment_brand, + }; + Ok(Self::AciNetworkToken(Box::new(aci_network_token_data))) + } +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "UPPERCASE")] +pub enum AciTokenAccountType { + Network, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AciNetworkTokenData { + #[serde(rename = "tokenAccount.type")] + pub token_type: AciTokenAccountType, + #[serde(rename = "tokenAccount.number")] + pub token_number: NetworkTokenNumber, + #[serde(rename = "tokenAccount.expiryMonth")] + pub token_expiry_month: Secret, + #[serde(rename = "tokenAccount.expiryYear")] + pub token_expiry_year: Secret, + #[serde(rename = "tokenAccount.cryptogram")] + pub token_cryptogram: Option>, + #[serde(rename = "paymentBrand")] + pub payment_brand: PaymentBrand, +} + #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct BankRedirectionPMData { @@ -365,7 +488,7 @@ pub struct WalletPMData { account_id: Option>, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "SCREAMING_SNAKE_CASE")] pub enum PaymentBrand { Eps, @@ -379,6 +502,23 @@ pub enum PaymentBrand { Mbway, #[serde(rename = "ALIPAY")] AliPay, + // Card network brands + #[serde(rename = "VISA")] + Visa, + #[serde(rename = "MASTER")] + Mastercard, + #[serde(rename = "AMEX")] + AmericanExpress, + #[serde(rename = "JCB")] + Jcb, + #[serde(rename = "DINERS")] + DinersClub, + #[serde(rename = "DISCOVER")] + Discover, + #[serde(rename = "UNIONPAY")] + UnionPay, + #[serde(rename = "MAESTRO")] + Maestro, } #[derive(Debug, Clone, Eq, PartialEq, Serialize)] @@ -393,6 +533,8 @@ pub struct CardDetails { pub card_expiry_year: Secret, #[serde(rename = "card.cvv")] pub card_cvv: Secret, + #[serde(rename = "paymentBrand")] + pub payment_brand: PaymentBrand, } #[derive(Debug, Clone, Serialize)] @@ -460,6 +602,9 @@ impl TryFrom<&AciRouterData<&PaymentsAuthorizeRouterData>> for AciPaymentsReques fn try_from(item: &AciRouterData<&PaymentsAuthorizeRouterData>) -> Result { match item.router_data.request.payment_method_data.clone() { PaymentMethodData::Card(ref card_data) => Self::try_from((item, card_data)), + PaymentMethodData::NetworkToken(ref network_token_data) => { + Self::try_from((item, network_token_data)) + } PaymentMethodData::Wallet(ref wallet_data) => Self::try_from((item, wallet_data)), PaymentMethodData::PayLater(ref pay_later_data) => { Self::try_from((item, pay_later_data)) @@ -487,7 +632,6 @@ impl TryFrom<&AciRouterData<&PaymentsAuthorizeRouterData>> for AciPaymentsReques | PaymentMethodData::Voucher(_) | PaymentMethodData::OpenBanking(_) | PaymentMethodData::CardToken(_) - | PaymentMethodData::NetworkToken(_) | PaymentMethodData::CardDetailsForNetworkTransactionId(_) => { Err(errors::ConnectorError::NotImplemented( utils::get_unimplemented_payment_method_error_message("Aci"), @@ -574,7 +718,34 @@ impl TryFrom<(&AciRouterData<&PaymentsAuthorizeRouterData>, &Card)> for AciPayme txn_details, payment_method, instruction, - shopper_result_url: None, + shopper_result_url: item.router_data.request.router_return_url.clone(), + }) + } +} + +impl + TryFrom<( + &AciRouterData<&PaymentsAuthorizeRouterData>, + &NetworkTokenData, + )> for AciPaymentsRequest +{ + type Error = Error; + fn try_from( + value: ( + &AciRouterData<&PaymentsAuthorizeRouterData>, + &NetworkTokenData, + ), + ) -> Result { + let (item, network_token_data) = value; + let txn_details = get_transaction_details(item)?; + let payment_method = PaymentDetails::try_from((item, network_token_data))?; + let instruction = get_instruction_details(item); + + Ok(Self { + txn_details, + payment_method, + instruction, + shopper_result_url: item.router_data.request.router_return_url.clone(), }) } } @@ -609,11 +780,16 @@ fn get_transaction_details( item: &AciRouterData<&PaymentsAuthorizeRouterData>, ) -> Result> { let auth = AciAuthType::try_from(&item.router_data.connector_auth_type)?; + let payment_type = if item.router_data.request.is_auto_capture()? { + AciPaymentType::Debit + } else { + AciPaymentType::Preauthorization + }; Ok(TransactionDetails { entity_id: auth.entity_id, amount: item.amount.to_owned(), currency: item.router_data.request.currency.to_string(), - payment_type: AciPaymentType::Debit, + payment_type, }) } @@ -650,6 +826,61 @@ impl TryFrom<&PaymentsCancelRouterData> for AciCancelRequest { } } +impl TryFrom<&RouterData> + for AciMandateRequest +{ + type Error = error_stack::Report; + + fn try_from( + item: &RouterData, + ) -> Result { + let auth = AciAuthType::try_from(&item.connector_auth_type)?; + + let (payment_brand, payment_details) = match &item.request.payment_method_data { + PaymentMethodData::Card(card_data) => { + let brand = get_aci_payment_brand(card_data.card_network.clone(), false)?; + match brand { + PaymentBrand::Visa + | PaymentBrand::Mastercard + | PaymentBrand::AmericanExpress => {} + _ => Err(errors::ConnectorError::NotSupported { + message: "Payment method not supported for mandate setup".to_string(), + connector: "ACI", + })?, + } + + let details = PaymentDetails::AciCard(Box::new(CardDetails { + card_number: card_data.card_number.clone(), + card_expiry_month: card_data.card_exp_month.clone(), + card_expiry_year: card_data.get_expiry_year_4_digit(), + card_cvv: card_data.card_cvc.clone(), + card_holder: card_data.card_holder_name.clone().ok_or( + errors::ConnectorError::MissingRequiredField { + field_name: "card_holder_name", + }, + )?, + payment_brand: brand.clone(), + })); + + (brand, details) + } + _ => { + return Err(errors::ConnectorError::NotSupported { + message: "Payment method not supported for mandate setup".to_string(), + connector: "ACI", + } + .into()); + } + }; + + Ok(Self { + entity_id: auth.entity_id, + payment_brand, + payment_details, + }) + } +} + #[derive(Debug, Default, Clone, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum AciPaymentStatus { @@ -674,6 +905,7 @@ fn map_aci_attempt_status(item: AciPaymentStatus, auto_capture: bool) -> enums:: AciPaymentStatus::RedirectShopper => enums::AttemptStatus::AuthenticationPending, } } + impl FromStr for AciPaymentStatus { type Err = error_stack::Report; fn from_str(s: &str) -> Result { @@ -696,10 +928,8 @@ impl FromStr for AciPaymentStatus { pub struct AciPaymentsResponse { id: String, registration_id: Option>, - // ndc is an internal unique identifier for the request. ndc: String, timestamp: String, - // Number useful for support purposes. build_number: String, pub(super) result: ResultCode, pub(super) redirect: Option, @@ -717,15 +947,24 @@ pub struct AciErrorResponse { #[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] #[serde(rename_all = "camelCase")] pub struct AciRedirectionData { - method: Option, - parameters: Vec, - url: Url, + pub method: Option, + pub parameters: Vec, + pub url: Url, + pub preconditions: Option>, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PreconditionData { + pub method: Option, + pub parameters: Vec, + pub url: Url, } #[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] pub struct Parameters { - name: String, - value: String, + pub name: String, + pub value: String, } #[derive(Default, Debug, Clone, Deserialize, PartialEq, Eq, Serialize)] @@ -863,12 +1102,45 @@ pub struct AciCaptureResultDetails { extended_description: String, #[serde(rename = "clearingInstituteName")] clearing_institute_name: String, - connector_tx_id1: String, - connector_tx_id3: String, - connector_tx_id2: String, + connector_tx_i_d1: String, + connector_tx_i_d3: String, + connector_tx_i_d2: String, acquirer_response: String, } +#[derive(Debug, Default, Clone, Deserialize)] +pub enum AciCaptureStatus { + Succeeded, + Failed, + #[default] + Pending, +} + +impl FromStr for AciCaptureStatus { + type Err = error_stack::Report; + fn from_str(s: &str) -> Result { + if FAILURE_CODES.contains(&s) { + Ok(Self::Failed) + } else if PENDING_CODES.contains(&s) { + Ok(Self::Pending) + } else if SUCCESSFUL_CODES.contains(&s) { + Ok(Self::Succeeded) + } else { + Err(report!(errors::ConnectorError::UnexpectedResponseError( + bytes::Bytes::from(s.to_owned()) + ))) + } + } +} + +fn map_aci_capture_status(item: AciCaptureStatus) -> enums::AttemptStatus { + match item { + AciCaptureStatus::Succeeded => enums::AttemptStatus::Charged, + AciCaptureStatus::Failed => enums::AttemptStatus::Failure, + AciCaptureStatus::Pending => enums::AttemptStatus::Pending, + } +} + impl TryFrom> for RouterData { @@ -877,10 +1149,7 @@ impl TryFrom, ) -> Result { Ok(Self { - status: map_aci_attempt_status( - AciPaymentStatus::from_str(&item.response.result.code)?, - false, - ), + status: map_aci_capture_status(AciCaptureStatus::from_str(&item.response.result.code)?), reference_id: Some(item.response.referenced_id.clone()), response: Ok(PaymentsResponseData::TransactionResponse { resource_id: ResponseId::ConnectorTransactionId(item.response.id.clone()), @@ -963,7 +1232,6 @@ impl From for enums::RefundStatus { #[serde(rename_all = "camelCase")] pub struct AciRefundResponse { id: String, - //ndc is an internal unique identifier for the request. ndc: String, timestamp: String, build_number: String, @@ -987,6 +1255,58 @@ impl TryFrom> for RefundsRout } } +impl + TryFrom< + ResponseRouterData< + SetupMandate, + AciMandateResponse, + SetupMandateRequestData, + PaymentsResponseData, + >, + > for RouterData +{ + type Error = error_stack::Report; + + fn try_from( + item: ResponseRouterData< + SetupMandate, + AciMandateResponse, + SetupMandateRequestData, + PaymentsResponseData, + >, + ) -> Result { + let mandate_reference = Some(MandateReference { + connector_mandate_id: Some(item.response.id.clone()), + payment_method_id: None, + mandate_metadata: None, + connector_mandate_request_reference_id: None, + }); + + let status = if SUCCESSFUL_CODES.contains(&item.response.result.code.as_str()) { + enums::AttemptStatus::Charged + } else if FAILURE_CODES.contains(&item.response.result.code.as_str()) { + enums::AttemptStatus::Failure + } else { + enums::AttemptStatus::Pending + }; + + Ok(Self { + status, + response: Ok(PaymentsResponseData::TransactionResponse { + resource_id: ResponseId::ConnectorTransactionId(item.response.id.clone()), + redirection_data: Box::new(None), + mandate_reference: Box::new(mandate_reference), + connector_metadata: None, + network_txn_id: None, + connector_response_reference_id: Some(item.response.id), + incremental_authorization_allowed: None, + charges: None, + }), + ..item.data + }) + } +} + #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] pub enum AciWebhookEventType { Payment, diff --git a/crates/router/tests/connectors/aci.rs b/crates/router/tests/connectors/aci.rs index 9d3544d5ce..f50a7c1607 100644 --- a/crates/router/tests/connectors/aci.rs +++ b/crates/router/tests/connectors/aci.rs @@ -1,456 +1,559 @@ -#![allow(clippy::print_stdout)] +use std::str::FromStr; -use std::{borrow::Cow, marker::PhantomData, str::FromStr, sync::Arc}; - -use common_utils::id_type; -use hyperswitch_domain_models::address::{Address, AddressDetails, PhoneDetails}; -use masking::Secret; -use router::{ - configs::settings::Settings, - core::payments, - db::StorageImpl, - routes, services, - types::{self, storage::enums, PaymentAddress}, +use hyperswitch_domain_models::{ + address::{Address, AddressDetails, PhoneDetails}, + payment_method_data::{Card, PaymentMethodData}, + router_request_types::AuthenticationData, }; -use tokio::sync::oneshot; +use masking::Secret; +use router::types::{self, storage::enums, PaymentAddress}; -use crate::{connector_auth::ConnectorAuthentication, utils}; +use crate::{ + connector_auth, + utils::{self, ConnectorActions, PaymentInfo}, +}; -fn construct_payment_router_data() -> types::PaymentsAuthorizeRouterData { - let auth = ConnectorAuthentication::new() - .aci - .expect("Missing ACI connector authentication configuration"); - - let merchant_id = id_type::MerchantId::try_from(Cow::from("aci")).unwrap(); - - types::RouterData { - flow: PhantomData, - merchant_id, - customer_id: Some(id_type::CustomerId::try_from(Cow::from("aci")).unwrap()), - tenant_id: id_type::TenantId::try_from_string("public".to_string()).unwrap(), - connector: "aci".to_string(), - payment_id: uuid::Uuid::new_v4().to_string(), - attempt_id: uuid::Uuid::new_v4().to_string(), - status: enums::AttemptStatus::default(), - auth_type: enums::AuthenticationType::NoThreeDs, - payment_method: enums::PaymentMethod::Card, - connector_auth_type: utils::to_connector_auth_type(auth.into()), - description: Some("This is a test".to_string()), - payment_method_status: None, - request: types::PaymentsAuthorizeData { - amount: 1000, - currency: enums::Currency::USD, - payment_method_data: types::domain::PaymentMethodData::Card(types::domain::Card { - card_number: cards::CardNumber::from_str("4200000000000000").unwrap(), - card_exp_month: Secret::new("10".to_string()), - card_exp_year: Secret::new("2025".to_string()), - card_cvc: Secret::new("999".to_string()), - card_issuer: None, - card_network: None, - card_type: None, - card_issuing_country: None, - bank_code: None, - nick_name: Some(Secret::new("nick_name".into())), - card_holder_name: Some(Secret::new("card holder name".into())), - co_badged_card_data: None, - }), - confirm: true, - statement_descriptor_suffix: None, - statement_descriptor: None, - setup_future_usage: None, - mandate_id: None, - off_session: None, - setup_mandate_details: None, - capture_method: None, - browser_info: None, - order_details: None, - order_category: None, - email: None, - customer_name: None, - session_token: None, - enrolled_for_3ds: false, - related_transaction_id: None, - payment_experience: None, - payment_method_type: None, - router_return_url: None, - webhook_url: None, - complete_authorize_url: None, - customer_id: None, - surcharge_details: None, - request_incremental_authorization: false, - metadata: None, - authentication_data: None, - customer_acceptance: None, - locale: None, - enable_partial_authorization: None, - ..utils::PaymentAuthorizeType::default().0 - }, - response: Err(types::ErrorResponse::default()), - address: PaymentAddress::new( +#[derive(Clone, Copy)] +struct AciTest; +impl ConnectorActions for AciTest {} +impl utils::Connector for AciTest { + fn get_data(&self) -> types::api::ConnectorData { + use router::connector::Aci; + utils::construct_connector_data_old( + Box::new(Aci::new()), + types::Connector::Aci, + types::api::GetToken::Connector, None, + ) + } + + fn get_auth_token(&self) -> types::ConnectorAuthType { + utils::to_connector_auth_type( + connector_auth::ConnectorAuthentication::new() + .aci + .expect("Missing connector authentication configuration") + .into(), + ) + } + + fn get_name(&self) -> String { + "aci".to_string() + } +} + +static CONNECTOR: AciTest = AciTest {}; + +fn get_default_payment_info() -> Option { + Some(PaymentInfo { + address: Some(PaymentAddress::new( None, Some(Address { address: Some(AddressDetails { first_name: Some(Secret::new("John".to_string())), last_name: Some(Secret::new("Doe".to_string())), + line1: Some(Secret::new("123 Main St".to_string())), + city: Some("New York".to_string()), + state: Some(Secret::new("NY".to_string())), + zip: Some(Secret::new("10001".to_string())), + country: Some(enums::CountryAlpha2::US), ..Default::default() }), phone: Some(PhoneDetails { - number: Some(Secret::new("9123456789".to_string())), - country_code: Some("+351".to_string()), + number: Some(Secret::new("+1234567890".to_string())), + country_code: Some("+1".to_string()), }), email: None, }), None, - ), - connector_meta_data: None, - connector_wallets_details: None, - amount_captured: None, - minor_amount_captured: None, - access_token: None, - session_token: None, - reference_id: None, - payment_method_token: None, - connector_customer: None, - recurring_mandate_payment_data: None, - connector_response: None, - preprocessing_id: None, - connector_request_reference_id: uuid::Uuid::new_v4().to_string(), - #[cfg(feature = "payouts")] - payout_method_data: None, - #[cfg(feature = "payouts")] - quote_id: None, - test_mode: None, - payment_method_balance: None, - connector_api_version: None, - connector_http_status_code: None, - apple_pay_flow: None, - external_latency: None, - frm_metadata: None, - refund_id: None, - dispute_id: None, - integrity_check: Ok(()), - additional_merchant_data: None, - header_payload: None, - connector_mandate_request_reference_id: None, - authentication_id: None, - psd2_sca_exemption_type: None, - raw_connector_response: None, - is_payment_id_from_merchant: None, - l2_l3_data: None, - minor_amount_capturable: None, - } + None, + )), + ..Default::default() + }) } -fn construct_refund_router_data() -> types::RefundsRouterData { - let auth = ConnectorAuthentication::new() - .aci - .expect("Missing ACI connector authentication configuration"); +fn get_payment_authorize_data() -> Option { + Some(types::PaymentsAuthorizeData { + payment_method_data: PaymentMethodData::Card(Card { + card_number: cards::CardNumber::from_str("4200000000000000").unwrap(), + card_exp_month: Secret::new("10".to_string()), + card_exp_year: Secret::new("2025".to_string()), + card_cvc: Secret::new("999".to_string()), + card_holder_name: Some(Secret::new("John Doe".to_string())), + ..utils::CCardType::default().0 + }), + ..utils::PaymentAuthorizeType::default().0 + }) +} - let merchant_id = id_type::MerchantId::try_from(Cow::from("aci")).unwrap(); - - types::RouterData { - flow: PhantomData, - merchant_id, - customer_id: Some(id_type::CustomerId::try_from(Cow::from("aci")).unwrap()), - tenant_id: id_type::TenantId::try_from_string("public".to_string()).unwrap(), - connector: "aci".to_string(), - payment_id: uuid::Uuid::new_v4().to_string(), - attempt_id: uuid::Uuid::new_v4().to_string(), - payment_method_status: None, - status: enums::AttemptStatus::default(), - payment_method: enums::PaymentMethod::Card, - auth_type: enums::AuthenticationType::NoThreeDs, - connector_auth_type: utils::to_connector_auth_type(auth.into()), - description: Some("This is a test".to_string()), - request: types::RefundsData { - payment_amount: 1000, - currency: enums::Currency::USD, - - refund_id: uuid::Uuid::new_v4().to_string(), - connector_transaction_id: String::new(), - refund_amount: 100, - webhook_url: None, - connector_metadata: None, - reason: None, - connector_refund_id: None, - browser_info: None, - ..utils::PaymentRefundType::default().0 - }, - response: Err(types::ErrorResponse::default()), - address: PaymentAddress::default(), - connector_meta_data: None, - connector_wallets_details: None, - amount_captured: None, - minor_amount_captured: None, - access_token: None, - session_token: None, - reference_id: None, - payment_method_token: None, - connector_customer: None, - recurring_mandate_payment_data: None, - connector_response: None, - preprocessing_id: None, - connector_request_reference_id: uuid::Uuid::new_v4().to_string(), - #[cfg(feature = "payouts")] - payout_method_data: None, - #[cfg(feature = "payouts")] - quote_id: None, - test_mode: None, - payment_method_balance: None, - connector_api_version: None, - connector_http_status_code: None, - apple_pay_flow: None, - external_latency: None, - frm_metadata: None, - refund_id: None, - dispute_id: None, - integrity_check: Ok(()), - additional_merchant_data: None, - header_payload: None, - connector_mandate_request_reference_id: None, - authentication_id: None, - psd2_sca_exemption_type: None, - raw_connector_response: None, - is_payment_id_from_merchant: None, - l2_l3_data: None, - minor_amount_capturable: None, - } +fn get_threeds_payment_authorize_data() -> Option { + Some(types::PaymentsAuthorizeData { + payment_method_data: PaymentMethodData::Card(Card { + card_number: cards::CardNumber::from_str("4200000000000000").unwrap(), + card_exp_month: Secret::new("10".to_string()), + card_exp_year: Secret::new("2025".to_string()), + card_cvc: Secret::new("999".to_string()), + card_holder_name: Some(Secret::new("John Doe".to_string())), + ..utils::CCardType::default().0 + }), + enrolled_for_3ds: true, + authentication_data: Some(AuthenticationData { + eci: Some("05".to_string()), + cavv: Secret::new("jJ81HADVRtXfCBATEp01CJUAAAA".to_string()), + threeds_server_transaction_id: Some("9458d8d4-f19f-4c28-b5c7-421b1dd2e1aa".to_string()), + message_version: Some(common_utils::types::SemanticVersion::new(2, 1, 0)), + ds_trans_id: Some("97267598FAE648F28083B2D2AF7B1234".to_string()), + created_at: common_utils::date_time::now(), + challenge_code: Some("01".to_string()), + challenge_cancel: None, + challenge_code_reason: Some("01".to_string()), + message_extension: None, + acs_trans_id: None, + authentication_type: None, + }), + ..utils::PaymentAuthorizeType::default().0 + }) } #[actix_web::test] -async fn payments_create_success() { - let conf = Settings::new().unwrap(); - let tx: oneshot::Sender<()> = oneshot::channel().0; - - let app_state = Box::pin(routes::AppState::with_storage( - conf, - StorageImpl::PostgresqlTest, - tx, - Box::new(services::MockApiClient), - )) - .await; - let state = Arc::new(app_state) - .get_session_state( - &id_type::TenantId::try_from_string("public".to_string()).unwrap(), - None, - || {}, - ) - .unwrap(); - - use router::connector::Aci; - let connector = utils::construct_connector_data_old( - Box::new(Aci::new()), - types::Connector::Aci, - types::api::GetToken::Connector, - None, - ); - let connector_integration: services::BoxedPaymentConnectorIntegrationInterface< - types::api::Authorize, - types::PaymentsAuthorizeData, - types::PaymentsResponseData, - > = connector.connector.get_connector_integration(); - let request = construct_payment_router_data(); - let response = services::api::execute_connector_processing_step( - &state, - connector_integration, - &request, - payments::CallConnectorAction::Trigger, - None, - None, - ) - .await - .unwrap(); - assert!( - response.status == enums::AttemptStatus::Charged, - "The payment failed" - ); +async fn should_only_authorize_payment() { + let response = CONNECTOR + .authorize_payment(get_payment_authorize_data(), get_default_payment_info()) + .await + .expect("Authorize payment response"); + assert_eq!(response.status, enums::AttemptStatus::Authorized); } #[actix_web::test] -#[ignore] -async fn payments_create_failure() { - { - let conf = Settings::new().unwrap(); - use router::connector::Aci; - let tx: oneshot::Sender<()> = oneshot::channel().0; - - let app_state = Box::pin(routes::AppState::with_storage( - conf, - StorageImpl::PostgresqlTest, - tx, - Box::new(services::MockApiClient), - )) - .await; - let state = Arc::new(app_state) - .get_session_state( - &id_type::TenantId::try_from_string("public".to_string()).unwrap(), - None, - || {}, - ) - .unwrap(); - let connector = utils::construct_connector_data_old( - Box::new(Aci::new()), - types::Connector::Aci, - types::api::GetToken::Connector, - None, - ); - let connector_integration: services::BoxedPaymentConnectorIntegrationInterface< - types::api::Authorize, - types::PaymentsAuthorizeData, - types::PaymentsResponseData, - > = connector.connector.get_connector_integration(); - let mut request = construct_payment_router_data(); - request.request.payment_method_data = - types::domain::PaymentMethodData::Card(types::domain::Card { - card_number: cards::CardNumber::from_str("4200000000000000").unwrap(), - card_exp_month: Secret::new("10".to_string()), - card_exp_year: Secret::new("2025".to_string()), - card_cvc: Secret::new("99".to_string()), - card_issuer: None, - card_network: None, - card_type: None, - card_issuing_country: None, - bank_code: None, - nick_name: Some(Secret::new("nick_name".into())), - card_holder_name: Some(Secret::new("card holder name".into())), - co_badged_card_data: None, - }); - - let response = services::api::execute_connector_processing_step( - &state, - connector_integration, - &request, - payments::CallConnectorAction::Trigger, - None, +async fn should_capture_authorized_payment() { + let response = CONNECTOR + .authorize_and_capture_payment( + get_payment_authorize_data(), None, + get_default_payment_info(), ) .await - .is_err(); - println!("{response:?}"); - assert!(response, "The payment was intended to fail but it passed"); - } + .expect("Capture payment response"); + assert_eq!(response.status, enums::AttemptStatus::Charged); } #[actix_web::test] -async fn refund_for_successful_payments() { - let conf = Settings::new().unwrap(); - use router::connector::Aci; - let connector = utils::construct_connector_data_old( - Box::new(Aci::new()), - types::Connector::Aci, - types::api::GetToken::Connector, - None, - ); - let tx: oneshot::Sender<()> = oneshot::channel().0; - - let app_state = Box::pin(routes::AppState::with_storage( - conf, - StorageImpl::PostgresqlTest, - tx, - Box::new(services::MockApiClient), - )) - .await; - let state = Arc::new(app_state) - .get_session_state( - &id_type::TenantId::try_from_string("public".to_string()).unwrap(), - None, - || {}, +async fn should_partially_capture_authorized_payment() { + let response = CONNECTOR + .authorize_and_capture_payment( + get_payment_authorize_data(), + Some(types::PaymentsCaptureData { + amount_to_capture: 50, + ..utils::PaymentCaptureType::default().0 + }), + get_default_payment_info(), ) + .await + .expect("Capture payment response"); + assert_eq!(response.status, enums::AttemptStatus::Charged); +} + +#[actix_web::test] +async fn should_sync_authorized_payment() { + let authorize_response = CONNECTOR + .authorize_payment(get_payment_authorize_data(), get_default_payment_info()) + .await + .expect("Authorize payment response"); + let txn_id = utils::get_connector_transaction_id(authorize_response.response); + let response = CONNECTOR + .psync_retry_till_status_matches( + enums::AttemptStatus::Authorized, + Some(types::PaymentsSyncData { + connector_transaction_id: types::ResponseId::ConnectorTransactionId( + txn_id.unwrap(), + ), + ..Default::default() + }), + get_default_payment_info(), + ) + .await + .expect("PSync response"); + assert_eq!(response.status, enums::AttemptStatus::Authorized,); +} + +#[actix_web::test] +async fn should_void_authorized_payment() { + let response = CONNECTOR + .authorize_and_void_payment( + get_payment_authorize_data(), + Some(types::PaymentsCancelData { + connector_transaction_id: String::from(""), + cancellation_reason: Some("requested_by_customer".to_string()), + ..Default::default() + }), + get_default_payment_info(), + ) + .await + .expect("Void payment response"); + assert_eq!(response.status, enums::AttemptStatus::Voided); +} + +#[actix_web::test] +async fn should_refund_manually_captured_payment() { + let response = CONNECTOR + .capture_payment_and_refund( + get_payment_authorize_data(), + None, + None, + get_default_payment_info(), + ) + .await .unwrap(); - let connector_integration: services::BoxedPaymentConnectorIntegrationInterface< - types::api::Authorize, - types::PaymentsAuthorizeData, - types::PaymentsResponseData, - > = connector.connector.get_connector_integration(); - let request = construct_payment_router_data(); - let response = services::api::execute_connector_processing_step( - &state, - connector_integration, - &request, - payments::CallConnectorAction::Trigger, - None, - None, - ) - .await - .unwrap(); - assert!( - response.status == enums::AttemptStatus::Charged, - "The payment failed" + assert_eq!( + response.response.unwrap().refund_status, + enums::RefundStatus::Success, ); - let connector_integration: services::BoxedRefundConnectorIntegrationInterface< - types::api::Execute, - types::RefundsData, - types::RefundsResponseData, - > = connector.connector.get_connector_integration(); - let mut refund_request = construct_refund_router_data(); - refund_request.request.connector_transaction_id = match response.response.unwrap() { - types::PaymentsResponseData::TransactionResponse { resource_id, .. } => { - resource_id.get_connector_transaction_id().unwrap() - } - _ => panic!("Connector transaction id not found"), - }; - let response = services::api::execute_connector_processing_step( - &state, - connector_integration, - &refund_request, - payments::CallConnectorAction::Trigger, - None, - None, - ) - .await - .unwrap(); - println!("{response:?}"); +} + +#[actix_web::test] +async fn should_partially_refund_manually_captured_payment() { + let response = CONNECTOR + .capture_payment_and_refund( + get_payment_authorize_data(), + None, + Some(types::RefundsData { + refund_amount: 50, + ..utils::PaymentRefundType::default().0 + }), + get_default_payment_info(), + ) + .await + .unwrap(); + assert_eq!( + response.response.unwrap().refund_status, + enums::RefundStatus::Success, + ); +} + +#[actix_web::test] +async fn should_sync_manually_captured_refund() { + let refund_response = CONNECTOR + .capture_payment_and_refund( + get_payment_authorize_data(), + None, + None, + get_default_payment_info(), + ) + .await + .unwrap(); + let response = CONNECTOR + .rsync_retry_till_status_matches( + enums::RefundStatus::Success, + refund_response.response.unwrap().connector_refund_id, + None, + get_default_payment_info(), + ) + .await + .unwrap(); + assert_eq!( + response.response.unwrap().refund_status, + enums::RefundStatus::Success, + ); +} + +#[actix_web::test] +async fn should_make_payment() { + let authorize_response = CONNECTOR + .make_payment(get_payment_authorize_data(), get_default_payment_info()) + .await + .unwrap(); + assert_eq!(authorize_response.status, enums::AttemptStatus::Charged); +} + +#[actix_web::test] +async fn should_sync_auto_captured_payment() { + let authorize_response = CONNECTOR + .make_payment(get_payment_authorize_data(), get_default_payment_info()) + .await + .unwrap(); + assert_eq!(authorize_response.status, enums::AttemptStatus::Charged); + let txn_id = utils::get_connector_transaction_id(authorize_response.response); + assert_ne!(txn_id, None, "Empty connector transaction id"); + let response = CONNECTOR + .psync_retry_till_status_matches( + enums::AttemptStatus::Charged, + Some(types::PaymentsSyncData { + connector_transaction_id: types::ResponseId::ConnectorTransactionId( + txn_id.unwrap(), + ), + capture_method: Some(enums::CaptureMethod::Automatic), + ..Default::default() + }), + get_default_payment_info(), + ) + .await + .unwrap(); + assert_eq!(response.status, enums::AttemptStatus::Charged,); +} + +#[actix_web::test] +async fn should_refund_auto_captured_payment() { + let response = CONNECTOR + .make_payment_and_refund( + get_payment_authorize_data(), + None, + get_default_payment_info(), + ) + .await + .unwrap(); + assert_eq!( + response.response.unwrap().refund_status, + enums::RefundStatus::Success, + ); +} + +#[actix_web::test] +async fn should_partially_refund_succeeded_payment() { + let refund_response = CONNECTOR + .make_payment_and_refund( + get_payment_authorize_data(), + Some(types::RefundsData { + refund_amount: 50, + ..utils::PaymentRefundType::default().0 + }), + get_default_payment_info(), + ) + .await + .unwrap(); + assert_eq!( + refund_response.response.unwrap().refund_status, + enums::RefundStatus::Success, + ); +} + +#[actix_web::test] +async fn should_refund_succeeded_payment_multiple_times() { + CONNECTOR + .make_payment_and_multiple_refund( + get_payment_authorize_data(), + Some(types::RefundsData { + refund_amount: 50, + ..utils::PaymentRefundType::default().0 + }), + get_default_payment_info(), + ) + .await; +} + +#[actix_web::test] +async fn should_sync_refund() { + let refund_response = CONNECTOR + .make_payment_and_refund( + get_payment_authorize_data(), + None, + get_default_payment_info(), + ) + .await + .unwrap(); + let response = CONNECTOR + .rsync_retry_till_status_matches( + enums::RefundStatus::Success, + refund_response.response.unwrap().connector_refund_id, + None, + get_default_payment_info(), + ) + .await + .unwrap(); + assert_eq!( + response.response.unwrap().refund_status, + enums::RefundStatus::Success, + ); +} + +#[actix_web::test] +async fn should_fail_payment_for_incorrect_cvc() { + let response = CONNECTOR + .make_payment( + Some(types::PaymentsAuthorizeData { + payment_method_data: PaymentMethodData::Card(Card { + card_cvc: Secret::new("12345".to_string()), + ..utils::CCardType::default().0 + }), + ..utils::PaymentAuthorizeType::default().0 + }), + get_default_payment_info(), + ) + .await + .unwrap(); assert!( - response.response.unwrap().refund_status == enums::RefundStatus::Success, - "The refund transaction failed" + response.response.is_err(), + "Payment should fail with incorrect CVC" + ); +} + +#[actix_web::test] +async fn should_fail_payment_for_invalid_exp_month() { + let response = CONNECTOR + .make_payment( + Some(types::PaymentsAuthorizeData { + payment_method_data: PaymentMethodData::Card(Card { + card_exp_month: Secret::new("20".to_string()), + ..utils::CCardType::default().0 + }), + ..utils::PaymentAuthorizeType::default().0 + }), + get_default_payment_info(), + ) + .await + .unwrap(); + assert!( + response.response.is_err(), + "Payment should fail with invalid expiry month" + ); +} + +#[actix_web::test] +async fn should_fail_payment_for_incorrect_expiry_year() { + let response = CONNECTOR + .make_payment( + Some(types::PaymentsAuthorizeData { + payment_method_data: PaymentMethodData::Card(Card { + card_exp_year: Secret::new("2000".to_string()), + ..utils::CCardType::default().0 + }), + ..utils::PaymentAuthorizeType::default().0 + }), + get_default_payment_info(), + ) + .await + .unwrap(); + assert!( + response.response.is_err(), + "Payment should fail with incorrect expiry year" + ); +} + +#[actix_web::test] +async fn should_fail_void_payment_for_auto_capture() { + let authorize_response = CONNECTOR + .make_payment(get_payment_authorize_data(), get_default_payment_info()) + .await + .unwrap(); + assert_eq!(authorize_response.status, enums::AttemptStatus::Charged); + let txn_id = utils::get_connector_transaction_id(authorize_response.response); + assert_ne!(txn_id, None, "Empty connector transaction id"); + let void_response = CONNECTOR + .void_payment(txn_id.unwrap(), None, get_default_payment_info()) + .await + .unwrap(); + assert!( + void_response.response.is_err(), + "Void should fail for already captured payment" + ); +} + +#[actix_web::test] +async fn should_fail_capture_for_invalid_payment() { + let capture_response = CONNECTOR + .capture_payment("123456789".to_string(), None, get_default_payment_info()) + .await + .unwrap(); + assert!( + capture_response.response.is_err(), + "Capture should fail for invalid payment ID" + ); +} + +#[actix_web::test] +async fn should_fail_for_refund_amount_higher_than_payment_amount() { + let response = CONNECTOR + .make_payment_and_refund( + get_payment_authorize_data(), + Some(types::RefundsData { + refund_amount: 150, + ..utils::PaymentRefundType::default().0 + }), + get_default_payment_info(), + ) + .await + .unwrap(); + assert!( + response.response.is_err(), + "Refund should fail when amount exceeds payment amount" ); } #[actix_web::test] #[ignore] -async fn refunds_create_failure() { - let conf = Settings::new().unwrap(); - use router::connector::Aci; - let connector = utils::construct_connector_data_old( - Box::new(Aci::new()), - types::Connector::Aci, - types::api::GetToken::Connector, - None, - ); - let tx: oneshot::Sender<()> = oneshot::channel().0; - - let app_state = Box::pin(routes::AppState::with_storage( - conf, - StorageImpl::PostgresqlTest, - tx, - Box::new(services::MockApiClient), - )) - .await; - let state = Arc::new(app_state) - .get_session_state( - &id_type::TenantId::try_from_string("public".to_string()).unwrap(), - None, - || {}, +async fn should_make_threeds_payment() { + let authorize_response = CONNECTOR + .make_payment( + get_threeds_payment_authorize_data(), + get_default_payment_info(), ) + .await .unwrap(); - let connector_integration: services::BoxedRefundConnectorIntegrationInterface< - types::api::Execute, - types::RefundsData, - types::RefundsResponseData, - > = connector.connector.get_connector_integration(); - let mut request = construct_refund_router_data(); - request.request.connector_transaction_id = "1234".to_string(); - let response = services::api::execute_connector_processing_step( - &state, - connector_integration, - &request, - payments::CallConnectorAction::Trigger, - None, - None, - ) - .await - .is_err(); - println!("{response:?}"); - assert!(response, "The refund was intended to fail but it passed"); + + assert!( + authorize_response.status == enums::AttemptStatus::AuthenticationPending + || authorize_response.status == enums::AttemptStatus::Charged, + "3DS payment should result in AuthenticationPending or Charged status, got: {:?}", + authorize_response.status + ); + + if let Ok(types::PaymentsResponseData::TransactionResponse { + redirection_data, .. + }) = &authorize_response.response + { + if authorize_response.status == enums::AttemptStatus::AuthenticationPending { + assert!( + redirection_data.is_some(), + "3DS flow should include redirection data for authentication" + ); + } + } +} + +#[actix_web::test] +#[ignore] +async fn should_authorize_threeds_payment() { + let response = CONNECTOR + .authorize_payment( + get_threeds_payment_authorize_data(), + get_default_payment_info(), + ) + .await + .expect("Authorize 3DS payment response"); + + assert!( + response.status == enums::AttemptStatus::AuthenticationPending + || response.status == enums::AttemptStatus::Authorized, + "3DS authorization should result in AuthenticationPending or Authorized status, got: {:?}", + response.status + ); +} + +#[actix_web::test] +#[ignore] +async fn should_sync_threeds_payment() { + let authorize_response = CONNECTOR + .authorize_payment( + get_threeds_payment_authorize_data(), + get_default_payment_info(), + ) + .await + .expect("Authorize 3DS payment response"); + let txn_id = utils::get_connector_transaction_id(authorize_response.response); + let response = CONNECTOR + .psync_retry_till_status_matches( + enums::AttemptStatus::AuthenticationPending, + Some(types::PaymentsSyncData { + connector_transaction_id: types::ResponseId::ConnectorTransactionId( + txn_id.unwrap(), + ), + ..Default::default() + }), + get_default_payment_info(), + ) + .await + .expect("PSync 3DS response"); + assert!( + response.status == enums::AttemptStatus::AuthenticationPending + || response.status == enums::AttemptStatus::Authorized, + "3DS sync should maintain AuthenticationPending or Authorized status" + ); } diff --git a/loadtest/config/development.toml b/loadtest/config/development.toml index 65fd42accf..7508208069 100644 --- a/loadtest/config/development.toml +++ b/loadtest/config/development.toml @@ -721,8 +721,8 @@ bank_debit.ach = { connector_list = "gocardless,adyen,stripe" } bank_debit.becs = { connector_list = "gocardless,stripe,adyen" } bank_debit.bacs = { connector_list = "stripe,gocardless" } bank_debit.sepa = { connector_list = "gocardless,adyen,stripe,deutschebank" } -card.credit.connector_list = "checkout,stripe,adyen,authorizedotnet,cybersource,datatrans,globalpay,worldpay,multisafepay,nmi,nexinets,noon,bankofamerica,braintree,nuvei,payme,wellsfargo,bamboraapac,elavon,fiuu,nexixpay,novalnet,paybox,paypal,xendit,moneris,worldpayvantiv,payload" -card.debit.connector_list = "checkout,stripe,adyen,authorizedotnet,cybersource,datatrans,globalpay,worldpay,multisafepay,nmi,nexinets,noon,bankofamerica,braintree,nuvei,payme,wellsfargo,bamboraapac,elavon,fiuu,nexixpay,novalnet,paybox,paypal,xendit,moneris,worldpayvantiv,payload" +card.credit.connector_list = "aci,checkout,stripe,adyen,authorizedotnet,cybersource,datatrans,globalpay,worldpay,multisafepay,nmi,nexinets,noon,bankofamerica,braintree,nuvei,payme,wellsfargo,bamboraapac,elavon,fiuu,nexixpay,novalnet,paybox,paypal,xendit,moneris,worldpayvantiv,payload" +card.debit.connector_list = "aci,checkout,stripe,adyen,authorizedotnet,cybersource,datatrans,globalpay,worldpay,multisafepay,nmi,nexinets,noon,bankofamerica,braintree,nuvei,payme,wellsfargo,bamboraapac,elavon,fiuu,nexixpay,novalnet,paybox,paypal,xendit,moneris,worldpayvantiv,payload" pay_later.klarna.connector_list = "adyen,aci" wallet.apple_pay.connector_list = "stripe,adyen,cybersource,noon,bankofamerica,nexinets,novalnet,authorizedotnet" wallet.samsung_pay.connector_list = "cybersource"