diff --git a/crates/router/tests/connectors/connector_auth.rs b/crates/router/tests/connectors/connector_auth.rs index cd696c9e0b..01505e6a01 100644 --- a/crates/router/tests/connectors/connector_auth.rs +++ b/crates/router/tests/connectors/connector_auth.rs @@ -12,6 +12,7 @@ pub(crate) struct ConnectorAuthentication { pub payu: Option, pub rapyd: Option, pub shift4: Option, + pub stripe: Option, pub worldpay: Option, pub worldline: Option, } diff --git a/crates/router/tests/connectors/main.rs b/crates/router/tests/connectors/main.rs index cca173d602..87e15657a2 100644 --- a/crates/router/tests/connectors/main.rs +++ b/crates/router/tests/connectors/main.rs @@ -10,6 +10,7 @@ mod globalpay; mod payu; mod rapyd; mod shift4; +mod stripe; mod utils; mod worldline; mod worldpay; diff --git a/crates/router/tests/connectors/stripe.rs b/crates/router/tests/connectors/stripe.rs new file mode 100644 index 0000000000..612086bc0e --- /dev/null +++ b/crates/router/tests/connectors/stripe.rs @@ -0,0 +1,337 @@ +use std::time::Duration; + +use masking::Secret; +use router::types::{self, api, storage::enums}; + +use crate::{ + connector_auth, + utils::{self, ConnectorActions}, +}; + +struct Stripe; +impl ConnectorActions for Stripe {} +impl utils::Connector for Stripe { + fn get_data(&self) -> types::api::ConnectorData { + use router::connector::Stripe; + types::api::ConnectorData { + connector: Box::new(&Stripe), + connector_name: types::Connector::Stripe, + get_token: types::api::GetToken::Connector, + } + } + + fn get_auth_token(&self) -> types::ConnectorAuthType { + types::ConnectorAuthType::from( + connector_auth::ConnectorAuthentication::new() + .stripe + .expect("Missing connector authentication configuration"), + ) + } + + fn get_name(&self) -> String { + "stripe".to_string() + } +} + +fn get_payment_authorize_data() -> Option { + Some(types::PaymentsAuthorizeData { + payment_method_data: types::api::PaymentMethod::Card(api::CCard { + card_number: Secret::new("4242424242424242".to_string()), + ..utils::CCardType::default().0 + }), + ..utils::PaymentAuthorizeType::default().0 + }) +} + +#[actix_web::test] +async fn should_only_authorize_payment() { + let response = Stripe {} + .authorize_payment(get_payment_authorize_data(), None) + .await + .unwrap(); + assert_eq!(response.status, enums::AttemptStatus::Authorized); +} + +#[actix_web::test] +async fn should_authorize_and_capture_payment() { + let response = Stripe {} + .make_payment(get_payment_authorize_data(), None) + .await + .unwrap(); + assert_eq!(response.status, enums::AttemptStatus::Charged); +} + +#[actix_web::test] +async fn should_capture_already_authorized_payment() { + let connector = Stripe {}; + let response = connector + .authorize_and_capture_payment(get_payment_authorize_data(), None, None) + .await; + assert_eq!(response.unwrap().status, enums::AttemptStatus::Charged); +} + +#[actix_web::test] +async fn should_partially_capture_already_authorized_payment() { + let connector = Stripe {}; + let response = connector + .authorize_and_capture_payment( + get_payment_authorize_data(), + Some(types::PaymentsCaptureData { + amount_to_capture: Some(50), + ..utils::PaymentCaptureType::default().0 + }), + None, + ) + .await; + assert_eq!(response.unwrap().status, enums::AttemptStatus::Charged); +} + +#[actix_web::test] +async fn should_sync_payment() { + let connector = Stripe {}; + let authorize_response = connector + .authorize_payment(get_payment_authorize_data(), None) + .await + .unwrap(); + let txn_id = utils::get_connector_transaction_id(authorize_response); + let response = connector + .psync_retry_till_status_matches( + enums::AttemptStatus::Authorized, + Some(types::PaymentsSyncData { + connector_transaction_id: router::types::ResponseId::ConnectorTransactionId( + txn_id.unwrap(), + ), + encoded_data: None, + capture_method: None, + }), + None, + ) + .await + .unwrap(); + assert_eq!(response.status, enums::AttemptStatus::Authorized,); +} + +#[actix_web::test] +async fn should_void_already_authorized_payment() { + let connector = Stripe {}; + let response = connector + .authorize_and_void_payment( + get_payment_authorize_data(), + Some(types::PaymentsCancelData { + connector_transaction_id: "".to_string(), + cancellation_reason: Some("requested_by_customer".to_string()), + }), + None, + ) + .await; + assert_eq!(response.unwrap().status, enums::AttemptStatus::Voided); +} + +#[actix_web::test] +async fn should_fail_payment_for_incorrect_card_number() { + let response = Stripe {} + .make_payment( + Some(types::PaymentsAuthorizeData { + payment_method_data: types::api::PaymentMethod::Card(api::CCard { + card_number: Secret::new("4024007134364842".to_string()), + ..utils::CCardType::default().0 + }), + ..utils::PaymentAuthorizeType::default().0 + }), + None, + ) + .await + .unwrap(); + let x = response.response.unwrap_err(); + assert_eq!( + x.message, + "Your card was declined. Your request was in test mode, but used a non test (live) card. For a list of valid test cards, visit: https://stripe.com/docs/testing.", + ); +} + +#[actix_web::test] +async fn should_fail_payment_for_no_card_number() { + let response = Stripe {} + .make_payment( + Some(types::PaymentsAuthorizeData { + payment_method_data: types::api::PaymentMethod::Card(api::CCard { + card_number: Secret::new("".to_string()), + ..utils::CCardType::default().0 + }), + ..utils::PaymentAuthorizeType::default().0 + }), + None, + ) + .await + .unwrap(); + let x = response.response.unwrap_err(); + assert_eq!( + x.message, + "You passed an empty string for 'payment_method_data[card][number]'. We assume empty values are an attempt to unset a parameter; however 'payment_method_data[card][number]' cannot be unset. You should remove 'payment_method_data[card][number]' from your request or supply a non-empty value.", + ); +} + +#[actix_web::test] +async fn should_fail_payment_for_invalid_exp_month() { + let response = Stripe {} + .make_payment( + Some(types::PaymentsAuthorizeData { + payment_method_data: types::api::PaymentMethod::Card(api::CCard { + card_exp_month: Secret::new("13".to_string()), + ..utils::CCardType::default().0 + }), + ..utils::PaymentAuthorizeType::default().0 + }), + None, + ) + .await + .unwrap(); + let x = response.response.unwrap_err(); + assert_eq!(x.message, "Your card's expiration month is invalid.",); +} + +#[actix_web::test] +async fn should_fail_payment_for_invalid_exp_year() { + let response = Stripe {} + .make_payment( + Some(types::PaymentsAuthorizeData { + payment_method_data: types::api::PaymentMethod::Card(api::CCard { + card_exp_year: Secret::new("2022".to_string()), + ..utils::CCardType::default().0 + }), + ..utils::PaymentAuthorizeType::default().0 + }), + None, + ) + .await + .unwrap(); + let x = response.response.unwrap_err(); + assert_eq!(x.message, "Your card's expiration year is invalid.",); +} + +#[actix_web::test] +async fn should_fail_payment_for_invalid_card_cvc() { + let response = Stripe {} + .make_payment( + Some(types::PaymentsAuthorizeData { + payment_method_data: types::api::PaymentMethod::Card(api::CCard { + card_cvc: Secret::new("12".to_string()), + ..utils::CCardType::default().0 + }), + ..utils::PaymentAuthorizeType::default().0 + }), + None, + ) + .await + .unwrap(); + let x = response.response.unwrap_err(); + assert_eq!(x.message, "Your card's security code is invalid.",); +} + +#[actix_web::test] +async fn should_fail_capture_for_invalid_payment() { + let connector = Stripe {}; + let authorize_response = connector + .authorize_payment(get_payment_authorize_data(), None) + .await + .unwrap(); + assert_eq!(authorize_response.status, enums::AttemptStatus::Authorized); + tokio::time::sleep(Duration::from_secs(5)).await; // to avoid 404 error as stripe takes some time to process the new transaction + let response = connector + .capture_payment("12345".to_string(), None, None) + .await + .unwrap(); + let err = response.response.unwrap_err(); + assert_eq!(err.message, "No such payment_intent: '12345'".to_string()); + assert_eq!(err.code, "resource_missing".to_string()); +} + +#[actix_web::test] +async fn should_refund_succeeded_payment() { + let connector = Stripe {}; + let response = connector + .make_payment_and_refund(get_payment_authorize_data(), None, None) + .await + .unwrap(); + assert_eq!( + response.response.unwrap().refund_status, + enums::RefundStatus::Success, + ); +} + +#[actix_web::test] +async fn should_partially_refund_succeeded_payment() { + let connector = Stripe {}; + let refund_response = connector + .make_payment_and_refund( + get_payment_authorize_data(), + Some(types::RefundsData { + refund_amount: 50, + ..utils::PaymentRefundType::default().0 + }), + None, + ) + .await + .unwrap(); + assert_eq!( + refund_response.response.unwrap().refund_status, + enums::RefundStatus::Success, + ); +} + +#[actix_web::test] +async fn should_refund_succeeded_payment_multiple_times() { + let connector = Stripe {}; + connector + .make_payment_and_multiple_refund( + get_payment_authorize_data(), + Some(types::RefundsData { + refund_amount: 50, + ..utils::PaymentRefundType::default().0 + }), + None, + ) + .await; +} + +#[actix_web::test] +async fn should_fail_refund_for_invalid_amount() { + let connector = Stripe {}; + let response = connector + .make_payment_and_refund( + get_payment_authorize_data(), + Some(types::RefundsData { + refund_amount: 150, + ..utils::PaymentRefundType::default().0 + }), + None, + ) + .await + .unwrap(); + assert_eq!( + response.response.unwrap_err().message, + "Refund amount ($1.50) is greater than charge amount ($1.00)", + ); +} + +#[actix_web::test] +async fn should_sync_refund() { + let connector = Stripe {}; + let refund_response = connector + .make_payment_and_refund(get_payment_authorize_data(), None, None) + .await + .unwrap(); + let response = connector + .rsync_retry_till_status_matches( + enums::RefundStatus::Success, + refund_response.response.unwrap().connector_refund_id, + None, + None, + ) + .await + .unwrap(); + assert_eq!( + response.response.unwrap().refund_status, + enums::RefundStatus::Success, + ); +} diff --git a/crates/router/tests/connectors/utils.rs b/crates/router/tests/connectors/utils.rs index f493c2e8da..e1cee6bd41 100644 --- a/crates/router/tests/connectors/utils.rs +++ b/crates/router/tests/connectors/utils.rs @@ -1,4 +1,4 @@ -use std::{fmt::Debug, marker::PhantomData, thread::sleep, time::Duration}; +use std::{fmt::Debug, marker::PhantomData, time::Duration}; use async_trait::async_trait; use error_stack::Report; @@ -18,6 +18,10 @@ pub trait Connector { fn get_connector_meta(&self) -> Option { None } + /// interval in seconds to be followed when making the subsequent request whenever needed + fn get_request_interval(&self) -> u64 { + 5 + } } #[derive(Debug, Default, Clone)] @@ -36,14 +40,16 @@ pub trait ConnectorActions: Connector { ) -> Result> { let integration = self.get_data().connector.get_connector_integration(); let request = self.generate_data( - payment_data.unwrap_or_else(|| types::PaymentsAuthorizeData { + types::PaymentsAuthorizeData { + confirm: false, capture_method: Some(storage_models::enums::CaptureMethod::Manual), - ..PaymentAuthorizeType::default().0 - }), + ..(payment_data.unwrap_or(PaymentAuthorizeType::default().0)) + }, payment_info, ); call_connector(request, integration).await } + async fn make_payment( &self, payment_data: Option, @@ -70,25 +76,23 @@ pub trait ConnectorActions: Connector { call_connector(request, integration).await } - /// will retry the psync till the given status matches or retry max 3 times in a 10secs interval + /// will retry the psync till the given status matches or retry max 3 times async fn psync_retry_till_status_matches( &self, status: enums::AttemptStatus, payment_data: Option, payment_info: Option, ) -> Result> { - let max_try = 3; - let mut curr_try = 1; - while curr_try <= max_try { + let max_tries = 3; + for curr_try in 0..max_tries { let sync_res = self .sync_payment(payment_data.clone(), payment_info.clone()) .await .unwrap(); - if (sync_res.status == status) || (curr_try == max_try) { + if (sync_res.status == status) || (curr_try == max_tries - 1) { return Ok(sync_res); } - sleep(Duration::from_secs(10)); - curr_try += 1; + tokio::time::sleep(Duration::from_secs(self.get_request_interval())).await; } Err(errors::ConnectorError::ProcessingStepFailed(None).into()) } @@ -101,17 +105,34 @@ pub trait ConnectorActions: Connector { ) -> Result> { let integration = self.get_data().connector.get_connector_integration(); let request = self.generate_data( - payment_data.unwrap_or(types::PaymentsCaptureData { - amount_to_capture: Some(100), - currency: enums::Currency::USD, + types::PaymentsCaptureData { connector_transaction_id: transaction_id, - amount: 100, - }), + ..payment_data.unwrap_or(PaymentCaptureType::default().0) + }, payment_info, ); call_connector(request, integration).await } + async fn authorize_and_capture_payment( + &self, + authorize_data: Option, + capture_data: Option, + payment_info: Option, + ) -> Result> { + let authorize_response = self + .authorize_payment(authorize_data, payment_info.clone()) + .await + .unwrap(); + assert_eq!(authorize_response.status, enums::AttemptStatus::Authorized); + let txn_id = get_connector_transaction_id(authorize_response); + let response = self + .capture_payment(txn_id.unwrap(), capture_data, payment_info) + .await + .unwrap(); + return Ok(response); + } + async fn void_payment( &self, transaction_id: String, @@ -120,15 +141,35 @@ pub trait ConnectorActions: Connector { ) -> Result> { let integration = self.get_data().connector.get_connector_integration(); let request = self.generate_data( - payment_data.unwrap_or(types::PaymentsCancelData { + types::PaymentsCancelData { connector_transaction_id: transaction_id, - cancellation_reason: Some("Test cancellation".to_string()), - }), + ..payment_data.unwrap_or(PaymentCancelType::default().0) + }, payment_info, ); call_connector(request, integration).await } + async fn authorize_and_void_payment( + &self, + authorize_data: Option, + void_data: Option, + payment_info: Option, + ) -> Result> { + let authorize_response = self + .authorize_payment(authorize_data, payment_info.clone()) + .await + .unwrap(); + assert_eq!(authorize_response.status, enums::AttemptStatus::Authorized); + let txn_id = get_connector_transaction_id(authorize_response); + tokio::time::sleep(Duration::from_secs(self.get_request_interval())).await; // to avoid 404 error + let response = self + .void_payment(txn_id.unwrap(), void_data, payment_info) + .await + .unwrap(); + return Ok(response); + } + async fn refund_payment( &self, transaction_id: String, @@ -137,24 +178,70 @@ pub trait ConnectorActions: Connector { ) -> Result> { let integration = self.get_data().connector.get_connector_integration(); let request = self.generate_data( - payment_data.unwrap_or_else(|| types::RefundsData { - amount: 100, - currency: enums::Currency::USD, - refund_id: uuid::Uuid::new_v4().to_string(), + types::RefundsData { connector_transaction_id: transaction_id, - refund_amount: 100, - connector_metadata: None, - connector_refund_id: None, - reason: Some("Customer returned product".to_string()), - }), + ..payment_data.unwrap_or(PaymentRefundType::default().0) + }, payment_info, ); call_connector(request, integration).await } + async fn make_payment_and_refund( + &self, + authorize_data: Option, + refund_data: Option, + payment_info: Option, + ) -> Result> { + //make a successful payment + let response = self + .make_payment(authorize_data, payment_info.clone()) + .await + .unwrap(); + + //try refund for previous payment + let transaction_id = get_connector_transaction_id(response).unwrap(); + tokio::time::sleep(Duration::from_secs(self.get_request_interval())).await; // to avoid 404 error + Ok(self + .refund_payment(transaction_id, refund_data, payment_info) + .await + .unwrap()) + } + + async fn make_payment_and_multiple_refund( + &self, + authorize_data: Option, + refund_data: Option, + payment_info: Option, + ) { + //make a successful payment + let response = self + .make_payment(authorize_data, payment_info.clone()) + .await + .unwrap(); + + //try refund for previous payment + let transaction_id = get_connector_transaction_id(response).unwrap(); + for _x in 0..2 { + tokio::time::sleep(Duration::from_secs(self.get_request_interval())).await; // to avoid 404 error + let refund_response = self + .refund_payment( + transaction_id.clone(), + refund_data.clone(), + payment_info.clone(), + ) + .await + .unwrap(); + assert_eq!( + refund_response.response.unwrap().refund_status, + enums::RefundStatus::Success, + ); + } + } + async fn sync_refund( &self, - transaction_id: String, + refund_id: String, payment_data: Option, payment_info: Option, ) -> Result> { @@ -164,17 +251,45 @@ pub trait ConnectorActions: Connector { amount: 1000, currency: enums::Currency::USD, refund_id: uuid::Uuid::new_v4().to_string(), - connector_transaction_id: transaction_id, + connector_transaction_id: "".to_string(), refund_amount: 100, connector_metadata: None, reason: None, - connector_refund_id: None, + connector_refund_id: Some(refund_id), }), payment_info, ); call_connector(request, integration).await } + /// will retry the rsync till the given status matches or retry max 3 times + async fn rsync_retry_till_status_matches( + &self, + status: enums::RefundStatus, + refund_id: String, + payment_data: Option, + payment_info: Option, + ) -> Result> { + let max_tries = 3; + for curr_try in 0..max_tries { + let sync_res = self + .sync_refund( + refund_id.clone(), + payment_data.clone(), + payment_info.clone(), + ) + .await + .unwrap(); + if (sync_res.clone().response.unwrap().refund_status == status) + || (curr_try == max_tries - 1) + { + return Ok(sync_res); + } + tokio::time::sleep(Duration::from_secs(self.get_request_interval())).await; + } + Err(errors::ConnectorError::ProcessingStepFailed(None).into()) + } + fn generate_data, Res>( &self, req: Req, @@ -258,6 +373,8 @@ pub trait LocalMock { } pub struct PaymentAuthorizeType(pub types::PaymentsAuthorizeData); +pub struct PaymentCaptureType(pub types::PaymentsCaptureData); +pub struct PaymentCancelType(pub types::PaymentsCancelData); pub struct PaymentSyncType(pub types::PaymentsSyncData); pub struct PaymentRefundType(pub types::RefundsData); pub struct CCardType(pub api::CCard); @@ -296,6 +413,26 @@ impl Default for PaymentAuthorizeType { } } +impl Default for PaymentCaptureType { + fn default() -> Self { + Self(types::PaymentsCaptureData { + amount_to_capture: Some(100), + currency: enums::Currency::USD, + connector_transaction_id: "".to_string(), + amount: 100, + }) + } +} + +impl Default for PaymentCancelType { + fn default() -> Self { + Self(types::PaymentsCancelData { + cancellation_reason: Some("requested_by_customer".to_string()), + connector_transaction_id: "".to_string(), + }) + } +} + impl Default for BrowserInfoType { fn default() -> Self { let data = types::BrowserInformation { @@ -330,13 +467,13 @@ impl Default for PaymentSyncType { impl Default for PaymentRefundType { fn default() -> Self { let data = types::RefundsData { - amount: 1000, + amount: 100, currency: enums::Currency::USD, refund_id: uuid::Uuid::new_v4().to_string(), connector_transaction_id: String::new(), refund_amount: 100, connector_metadata: None, - reason: None, + reason: Some("Customer returned product".to_string()), connector_refund_id: None, }; Self(data)