From a996f0d89beaf7f4af84bede7fba5e16a790179b Mon Sep 17 00:00:00 2001 From: Jagan Date: Fri, 23 Dec 2022 22:49:33 +0530 Subject: [PATCH] feat(connector): Add support for shift4 connector (#205) --- config/Development.toml | 5 +- config/config.example.toml | 3 + config/docker_compose.toml | 5 +- connector-template/mod.rs | 236 ++++++-- connector-template/test.rs | 92 +++ connector-template/transformers.rs | 51 +- crates/api_models/src/enums.rs | 1 + crates/router/src/configs/settings.rs | 5 +- crates/router/src/connector.rs | 4 +- crates/router/src/connector/shift4.rs | 527 ++++++++++++++++++ .../src/connector/shift4/transformers.rs | 257 +++++++++ crates/router/src/core/refunds.rs | 8 +- crates/router/src/services/api.rs | 6 +- crates/router/src/types.rs | 3 +- crates/router/src/types/api.rs | 32 +- crates/router/src/types/connector.rs | 1 - .../router/tests/connectors/connector_auth.rs | 14 + crates/router/tests/connectors/main.rs | 2 + .../router/tests/connectors/sample_auth.toml | 3 + crates/router/tests/connectors/shift4.rs | 93 ++++ crates/router/tests/connectors/utils.rs | 197 +++++++ keys.conf | 2 +- scripts/add_connector.sh | 25 + scripts/create_connector_account.sh | 1 + 24 files changed, 1479 insertions(+), 94 deletions(-) create mode 100644 connector-template/test.rs create mode 100644 crates/router/src/connector/shift4.rs create mode 100644 crates/router/src/connector/shift4/transformers.rs delete mode 100644 crates/router/src/types/connector.rs create mode 100644 crates/router/tests/connectors/shift4.rs create mode 100644 crates/router/tests/connectors/utils.rs create mode 100644 scripts/add_connector.sh diff --git a/config/Development.toml b/config/Development.toml index 32c328baa1..bc2b912fa3 100644 --- a/config/Development.toml +++ b/config/Development.toml @@ -38,7 +38,7 @@ locker_decryption_key2 = "" [connectors.supported] wallets = ["klarna","braintree","applepay"] -cards = ["stripe","adyen","authorizedotnet","checkout","braintree","aci"] +cards = ["stripe","adyen","authorizedotnet","checkout","braintree","aci","shift4"] [eph_key] validity = 1 @@ -67,6 +67,9 @@ base_url = "https://api-na.playground.klarna.com/" [connectors.applepay] base_url = "https://apple-pay-gateway.apple.com/" +[connectors.shift4] +base_url = "https://api.shift4.com/" + [scheduler] stream = "SCHEDULER_STREAM" consumer_group = "SCHEDULER_GROUP" diff --git a/config/config.example.toml b/config/config.example.toml index 1187de15f0..6e9f4d152c 100644 --- a/config/config.example.toml +++ b/config/config.example.toml @@ -119,6 +119,9 @@ base_url = "https://api-na.playground.klarna.com/" [connectors.applepay] base_url = "https://apple-pay-gateway.apple.com/" +[connectors.shift4] +base_url = "https://api.shift4.com/" + # This data is used to call respective connectors for wallets and cards [connectors.supported] wallets = ["klarna","braintree","applepay"] diff --git a/config/docker_compose.toml b/config/docker_compose.toml index 32a45dde16..58a22c7c5c 100644 --- a/config/docker_compose.toml +++ b/config/docker_compose.toml @@ -74,6 +74,9 @@ base_url = "https://api-na.playground.klarna.com/" [connectors.applepay] base_url = "https://apple-pay-gateway.apple.com/" +[connectors.shift4] +base_url = "https://api.shift4.com/" + [connectors.supported] wallets = ["klarna","braintree","applepay"] -cards = ["stripe","adyen","authorizedotnet","checkout","braintree"] +cards = ["stripe","adyen","authorizedotnet","checkout","braintree","shift4"] diff --git a/connector-template/mod.rs b/connector-template/mod.rs index c7f3a22094..4cf6645993 100644 --- a/connector-template/mod.rs +++ b/connector-template/mod.rs @@ -6,13 +6,16 @@ use bytes::Bytes; use error_stack::ResultExt; use crate::{ - configs::settings::ConnectorParams, + configs::settings, utils::{self, BytesExt}, - core::errors::{self, CustomResult}, - logger, services, + core::{ + errors::{self, CustomResult}, + payments, + }, + headers, logger, services, types::{ self, - api, + api::{self, ConnectorCommon, ConnectorCommonExt}, ErrorResponse, Response, } }; @@ -21,15 +24,14 @@ use crate::{ use transformers as {{project-name | downcase}}; #[derive(Debug, Clone)] -pub struct {{project-name | downcase | pascal_case}} { - pub base_url: String, -} +pub struct {{project-name | downcase | pascal_case}}; -impl {{project-name | downcase | pascal_case}} { - pub fn make(params: &ConnectorParams) -> Self { - Self { - base_url: params.base_url.to_owned(), - } +impl api::ConnectorCommonExt for {{project-name | downcase | pascal_case}} { + fn build_headers( + &self, + req: &types::RouterData, + ) -> CustomResult, errors::ConnectorError> { + todo!() } } @@ -43,8 +45,8 @@ impl api::ConnectorCommon for {{project-name | downcase | pascal_case}} { // Ex: "application/x-www-form-urlencoded" } - fn base_url(&self) -> &str { - &self.base_url + fn base_url<'a>(&self, connectors: &'a settings::Connectors) -> &'a str { + connectors.{{project-name}}.base_url.as_ref() } fn get_auth_header(&self,_auth_type:&types::ConnectorAuthType)-> CustomResult,errors::ConnectorError> { @@ -54,19 +56,151 @@ impl api::ConnectorCommon for {{project-name | downcase | pascal_case}} { impl api::Payment for {{project-name | downcase | pascal_case}} {} +impl api::PreVerify for {{project-name | downcase | pascal_case}} {} +impl + services::ConnectorIntegration< + api::Verify, + types::VerifyRequestData, + types::PaymentsResponseData, + > for {{project-name | downcase | pascal_case}} +{ +} + +impl api::PaymentVoid for {{project-name | downcase | pascal_case}} {} + +impl + services::ConnectorIntegration< + api::Void, + types::PaymentsCancelData, + types::PaymentsResponseData, + > for {{project-name | downcase | pascal_case}} +{} + +impl api::PaymentSync for {{project-name | downcase | pascal_case}} {} +impl + services::ConnectorIntegration + for {{project-name | downcase | pascal_case}} +{ + fn get_headers( + &self, + req: &types::PaymentsSyncRouterData, + ) -> CustomResult, errors::ConnectorError> { + todo!() + } + + fn get_content_type(&self) -> &'static str { + todo!() + } + + fn get_url( + &self, + req: &types::PaymentsSyncRouterData, + connectors: &settings::Connectors, + ) -> CustomResult { + todo!() + } + + fn build_request( + &self, + req: &types::PaymentsSyncRouterData, + connectors: &settings::Connectors, + ) -> CustomResult, errors::ConnectorError> { + todo!() + } + + fn get_error_response( + &self, + res: Bytes, + ) -> CustomResult { + todo!() + } + + fn handle_response( + &self, + data: &types::PaymentsSyncRouterData, + res: Response, + ) -> CustomResult { + todo!() + } +} + + +impl api::PaymentCapture for {{project-name | downcase | pascal_case}} {} +impl + services::ConnectorIntegration< + api::Capture, + types::PaymentsCaptureData, + types::PaymentsResponseData, + > for {{project-name | downcase | pascal_case}} +{ + fn get_headers( + &self, + req: &types::PaymentsCaptureRouterData, + ) -> CustomResult, errors::ConnectorError> { + todo!() + } + + fn get_content_type(&self) -> &'static str { + todo!() + } + + fn get_request_body( + &self, + _req: &types::PaymentsCaptureRouterData, + ) -> CustomResult, errors::ConnectorError> { + todo!() + } + + fn build_request( + &self, + req: &types::PaymentsCaptureRouterData, + connectors: &settings::Connectors, + ) -> CustomResult, errors::ConnectorError> { + todo!() + } + + fn handle_response( + &self, + data: &types::PaymentsCaptureRouterData, + res: Response, + ) -> CustomResult { + todo!() + } + + fn get_url( + &self, + req: &types::PaymentsCaptureRouterData, + connectors: &settings::Connectors, + ) -> CustomResult { + todo!() + } + + fn get_error_response( + &self, + res: Bytes, + ) -> CustomResult { + todo!() + } +} + +impl api::PaymentSession for {{project-name | downcase | pascal_case}} {} + +impl + services::ConnectorIntegration< + api::Session, + types::PaymentsSessionData, + types::PaymentsResponseData, + > for {{project-name | downcase | pascal_case}} +{ + //TODO: implement sessions flow +} + impl api::PaymentAuthorize for {{project-name | downcase | pascal_case}} {} -type Authorize = dyn services::ConnectorIntegration< - api::Authorize, - types::PaymentsRequestData, - types::PaymentsResponseData, ->; - - impl services::ConnectorIntegration< api::Authorize, - types::PaymentsRequestData, + types::PaymentsAuthorizeData, types::PaymentsResponseData, > for {{project-name | downcase | pascal_case}} { fn get_headers(&self, _req: &types::PaymentsAuthorizeRouterData) -> CustomResult,errors::ConnectorError> { @@ -77,7 +211,7 @@ impl todo!() } - fn get_url(&self, _req: &types::PaymentsAuthorizeRouterData) -> CustomResult { + fn get_url(&self, _req: &types::PaymentsAuthorizeRouterData, connectors: &settings::Connectors,) -> CustomResult { todo!() } @@ -112,19 +246,13 @@ impl api::Refund for {{project-name | downcase | pascal_case}} {} impl api::RefundExecute for {{project-name | downcase | pascal_case}} {} impl api::RefundSync for {{project-name | downcase | pascal_case}} {} -type Execute = dyn services::ConnectorIntegration< - api::Execute, - types::RefundsRequestData, - types::RefundsResponseData, ->; - impl services::ConnectorIntegration< api::Execute, - types::RefundsRequestData, + types::RefundsData, types::RefundsResponseData, > for {{project-name | downcase | pascal_case}} { - fn get_headers(&self, _req: &types::RefundsRouterData) -> CustomResult,errors::ConnectorError> { + fn get_headers(&self, _req: &types::RefundsRouterData) -> CustomResult,errors::ConnectorError> { todo!() } @@ -132,32 +260,32 @@ impl todo!() } - fn get_url(&self, _req: &types::RefundsRouterData) -> CustomResult { + fn get_url(&self, _req: &types::RefundsRouterData, connectors: &settings::Connectors,) -> CustomResult { todo!() } - fn get_request_body(&self, req: &types::RefundsRouterData) -> CustomResult,errors::ConnectorError> { + fn get_request_body(&self, req: &types::RefundsRouterData) -> CustomResult,errors::ConnectorError> { let {{project-name | downcase}}_req = utils::Encode::<{{project-name| downcase}}::{{project-name | downcase | pascal_case}}RefundRequest>::convert_and_url_encode(req).change_context(errors::ConnectorError::RequestEncodingFailed)?; Ok(Some({{project-name | downcase}}_req)) } - fn build_request(&self, req: &types::RefundsRouterData) -> CustomResult,errors::ConnectorError> { + fn build_request(&self, req: &types::RefundsRouterData, connectors: &settings::Connectors,) -> CustomResult,errors::ConnectorError> { let request = services::RequestBuilder::new() .method(services::Method::Post) - .url(&Execute::get_url(self, req)?) - .headers(Execute::get_headers(self, req)?) - .body(Execute::get_request_body(self, req)?) + .url(&types::RefundExecuteType::get_url(self, req, connectors)?) + .headers(types::RefundExecuteType::get_headers(self, req)?) + .body(types::RefundExecuteType::get_request_body(self, req)?) .build(); Ok(Some(request)) } fn handle_response( &self, - data: &types::RefundsRouterData, + data: &types::RefundsRouterData, res: Response, - ) -> CustomResult { + ) -> CustomResult,errors::ConnectorError> { logger::debug!(target: "router::connector::{{project-name | downcase}}", response=?res); - let response: {{project-name| downcase}}::{{project-name | downcase| pascal_case}}RefundResponse = res.response.parse_struct("{{project-name | downcase}} RefundResponse").change_context(errors::ConnectorError::RequestEncodingFailed)?; + let response: {{project-name| downcase}}::RefundResponse = res.response.parse_struct("{{project-name | downcase}} RefundResponse").change_context(errors::ConnectorError::RequestEncodingFailed)?; types::ResponseRouterData { response, data: data.clone(), @@ -172,14 +300,9 @@ impl } } -type RSync = dyn services::ConnectorIntegration< - api::Sync, - types::RefundsRequestData, - types::RefundsResponseData, ->; impl - services::ConnectorIntegration for {{project-name | downcase | pascal_case}} { - fn get_headers(&self, _req: &types::RefundsRouterData) -> CustomResult,errors::ConnectorError> { + services::ConnectorIntegration for {{project-name | downcase | pascal_case}} { + fn get_headers(&self, _req: &types::RefundSyncRouterData) -> CustomResult,errors::ConnectorError> { todo!() } @@ -187,17 +310,17 @@ impl todo!() } - fn get_url(&self, _req: &types::RefundsRouterData) -> CustomResult { + fn get_url(&self, _req: &types::RefundSyncRouterData,_connectors: &settings::Connectors,) -> CustomResult { todo!() } fn handle_response( &self, - data: &types::RefundsRouterData, + data: &types::RefundSyncRouterData, res: Response, - ) -> CustomResult { + ) -> CustomResult { logger::debug!(target: "router::connector::{{project-name | downcase}}", response=?res); - let response: {{project-name | downcase}}::{{project-name | downcase | pascal_case}}RefundResponse = res.response.parse_struct("{{project-name | downcase}} RefundResponse").change_context(errors::ConnectorError::ResponseDeserializationFailed)?; + let response: {{project-name | downcase}}::RefundResponse = res.response.parse_struct("{{project-name | downcase}} RefundResponse").change_context(errors::ConnectorError::ResponseDeserializationFailed)?; types::ResponseRouterData { response, data: data.clone(), @@ -224,7 +347,7 @@ impl api::IncomingWebhook for {{project-name | downcase | pascal_case}} { fn get_webhook_event_type( &self, _body: &[u8], - ) -> CustomResult { + ) -> CustomResult { todo!() } @@ -235,3 +358,12 @@ impl api::IncomingWebhook for {{project-name | downcase | pascal_case}} { todo!() } } + +impl services::ConnectorRedirectResponse for {{project-name | downcase | pascal_case}} { + fn get_flow_type( + &self, + _query_params: &str, + ) -> CustomResult { + Ok(payments::CallConnectorAction::Trigger) + } +} diff --git a/connector-template/test.rs b/connector-template/test.rs new file mode 100644 index 0000000000..1ba4de5e6f --- /dev/null +++ b/connector-template/test.rs @@ -0,0 +1,92 @@ +use futures::future::OptionFuture; +use masking::Secret; +use router::types::{self, api, storage::enums}; + +use crate::{ + connector_auth, + utils::{self, ConnectorActions}, +}; + +struct {{project-name | downcase | pascal_case}}; +impl utils::ConnectorActions for {{project-name | downcase | pascal_case}} {} +impl utils::Connector for {{project-name | downcase | pascal_case}} { + fn get_data(&self) -> types::api::ConnectorData { + use router::connector::{{project-name | downcase | pascal_case}}; + types::api::ConnectorData { + connector: Box::new(&{{project-name | downcase | pascal_case}}), + connector_name: types::Connector::{{project-name | downcase | pascal_case}}, + get_token: types::api::GetToken::Connector, + } + } + + fn get_auth_token(&self) -> types::ConnectorAuthType { + types::ConnectorAuthType::from( + connector_auth::ConnectorAuthentication::new() + .{{project-name | downcase }} + .expect("Missing connector authentication configuration"), + ) + } + + fn get_name(&self) -> String { + "{{project-name | downcase }}".to_string() + } +} + +#[actix_web::test] +async fn should_only_authorize_payment() { + let response = {{project-name | downcase | pascal_case}} {}.authorize_payment(None).await; + assert_eq!(response.status, enums::AttemptStatus::Authorized); +} + +#[actix_web::test] +async fn should_authorize_and_capture_payment() { + let response = {{project-name | downcase | pascal_case}} {}.make_payment(None).await; + assert_eq!(response.status, enums::AttemptStatus::Charged); +} + +#[actix_web::test] +async fn should_capture_already_authorized_payment() { + let connector = {{project-name | downcase | pascal_case}} {}; + let authorize_response = connector.authorize_payment(None).await; + assert_eq!(authorize_response.status, enums::AttemptStatus::Authorized); + let txn_id = utils::get_connector_transaction_id(authorize_response); + let response: OptionFuture<_> = txn_id + .map(|transaction_id| async move { + connector.capture_payment(transaction_id, None).await.status + }) + .into(); + assert_eq!(response.await, Some(enums::AttemptStatus::Charged)); +} + +#[actix_web::test] +async fn should_fail_payment_for_incorrect_cvc() { + let response = {{project-name | downcase | pascal_case}} {}.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 + })) + .await; + let x = response.response.unwrap_err(); + assert_eq!( + x.message, + "The card's security code failed verification.".to_string(), + ); +} + +#[actix_web::test] +async fn should_refund_succeeded_payment() { + let connector = {{project-name | downcase | pascal_case}} {}; + //make a successful payment + let response = connector.make_payment(None).await; + + //try refund for previous payment + if let Some(transaction_id) = utils::get_connector_transaction_id(response) { + let response = connector.refund_payment(transaction_id, None).await; + assert_eq!( + response.response.unwrap().refund_status, + enums::RefundStatus::Success, + ); + } +} diff --git a/connector-template/transformers.rs b/connector-template/transformers.rs index 34ac1e9589..2d81e2d57c 100644 --- a/connector-template/transformers.rs +++ b/connector-template/transformers.rs @@ -1,8 +1,8 @@ use serde::{Deserialize, Serialize}; -use crate::{core::errors,types::{self,storage::enums}}; +use crate::{core::errors,pii::PeekInterface,types::{self,api, storage::enums}}; //TODO: Fill the struct with respective fields -#[derive(Default, Debug, Serialize, PartialEq)] +#[derive(Default, Debug, Serialize, Eq, PartialEq)] pub struct {{project-name | downcase | pascal_case}}PaymentsRequest {} impl TryFrom<&types::PaymentsAuthorizeRouterData> for {{project-name | downcase | pascal_case}}PaymentsRequest { @@ -24,21 +24,15 @@ impl TryFrom<&types::ConnectorAuthType> for {{project-name | downcase | pascal_c } // PaymentsResponse //TODO: Append the remaining status flags -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "lowercase")] pub enum {{project-name | downcase | pascal_case}}PaymentStatus { Succeeded, Failed, + #[default] Processing, } -// Default should be Processing -impl Default for {{project-name | downcase | pascal_case}}PaymentStatus { - fn default() -> Self { - {{project-name | downcase | pascal_case}}PaymentStatus::Processing - } -} - impl From<{{project-name | downcase | pascal_case}}PaymentStatus> for enums::AttemptStatus { fn from(item: {{project-name | downcase | pascal_case}}PaymentStatus) -> Self { match item { @@ -66,9 +60,9 @@ impl TryFrom for {{project-name | downcase | pascal_case}}RefundRequest { +impl TryFrom<&types::RefundsRouterData> for {{project-name | downcase | pascal_case}}RefundRequest { type Error = error_stack::Report; - fn try_from(_item: &types::RefundsRouterData) -> Result { + fn try_from(_item: &types::RefundsRouterData) -> Result { todo!() } } @@ -76,22 +70,16 @@ impl TryFrom<&types::RefundsRouterData> for {{project-name | downcase | pascal_c // Type definition for Refund Response #[allow(dead_code)] -#[derive(Debug, Serialize, Deserialize, Clone)] +#[derive(Debug, Serialize, Default, Deserialize, Clone)] pub enum RefundStatus { Succeeded, Failed, + #[default] Processing, } -// Default should be Processing -impl Default for RefundStatus { - fn default() -> Self { - RefundStatus::Processing - } -} - -impl From for enums::RefundStatus { - fn from(item: RefundStatus) -> Self { +impl From for enums::RefundStatus { + fn from(item: self::RefundStatus) -> Self { match item { RefundStatus::Succeeded => Self::Success, RefundStatus::Failed => Self::Failure, @@ -103,15 +91,28 @@ impl From for enums::RefundStatus { //TODO: Fill the struct with respective fields #[derive(Default, Debug, Clone, Serialize, Deserialize)] -pub struct {{project-name | downcase | pascal_case}}RefundResponse {} +pub struct RefundResponse { +} -impl TryFrom> for types::RefundsRouterData { +impl TryFrom> + for types::RefundsRouterData +{ type Error = error_stack::Report; - fn try_from(_item: types::RefundsResponseRouterData<{{project-name | downcase | pascal_case}}RefundResponse>) -> Result { + fn try_from( + item: types::RefundsResponseRouterData, + ) -> Result { todo!() } } +impl TryFrom> for types::RefundsRouterData +{ + type Error = error_stack::Report; + fn try_from(_item: types::RefundsResponseRouterData) -> Result { + todo!() + } + } + //TODO: Fill the struct with respective fields #[derive(Default, Debug, Serialize, Deserialize, PartialEq)] pub struct {{project-name | downcase | pascal_case}}ErrorResponse {} diff --git a/crates/api_models/src/enums.rs b/crates/api_models/src/enums.rs index f915cda517..791d304dca 100644 --- a/crates/api_models/src/enums.rs +++ b/crates/api_models/src/enums.rs @@ -494,6 +494,7 @@ pub enum Connector { #[default] Dummy, Klarna, + Shift4, Stripe, } diff --git a/crates/router/src/configs/settings.rs b/crates/router/src/configs/settings.rs index fa7fdefe7a..e0cc902531 100644 --- a/crates/router/src/configs/settings.rs +++ b/crates/router/src/configs/settings.rs @@ -110,10 +110,11 @@ pub struct Connectors { pub aci: ConnectorParams, pub adyen: ConnectorParams, pub authorizedotnet: ConnectorParams, - pub checkout: ConnectorParams, - pub stripe: ConnectorParams, pub braintree: ConnectorParams, + pub checkout: ConnectorParams, pub klarna: ConnectorParams, + pub shift4: ConnectorParams, + pub stripe: ConnectorParams, pub supported: SupportedConnectors, pub applepay: ConnectorParams, } diff --git a/crates/router/src/connector.rs b/crates/router/src/connector.rs index 9db784acd2..9476a02669 100644 --- a/crates/router/src/connector.rs +++ b/crates/router/src/connector.rs @@ -7,7 +7,9 @@ pub mod checkout; pub mod klarna; pub mod stripe; +pub mod shift4; + pub use self::{ aci::Aci, adyen::Adyen, applepay::Applepay, authorizedotnet::Authorizedotnet, - braintree::Braintree, checkout::Checkout, klarna::Klarna, stripe::Stripe, + braintree::Braintree, checkout::Checkout, klarna::Klarna, shift4::Shift4, stripe::Stripe, }; diff --git a/crates/router/src/connector/shift4.rs b/crates/router/src/connector/shift4.rs new file mode 100644 index 0000000000..4ba448dec7 --- /dev/null +++ b/crates/router/src/connector/shift4.rs @@ -0,0 +1,527 @@ +mod transformers; + +use std::fmt::Debug; + +use bytes::Bytes; +use common_utils::ext_traits::ByteSliceExt; +use error_stack::ResultExt; +use transformers as shift4; + +use crate::{ + configs::settings, + consts, + core::{ + errors::{self, CustomResult}, + payments, + }, + headers, logger, + services::{self, ConnectorIntegration}, + types::{ + self, + api::{self, ConnectorCommon, ConnectorCommonExt}, + ErrorResponse, Response, + }, + utils::{self, BytesExt}, +}; + +#[derive(Debug, Clone)] +pub struct Shift4; + +impl api::ConnectorCommonExt for Shift4 +where + Self: ConnectorIntegration, +{ + fn build_headers( + &self, + req: &types::RouterData, + ) -> CustomResult, errors::ConnectorError> { + let mut headers = vec![ + ( + headers::CONTENT_TYPE.to_string(), + self.get_content_type().to_string(), + ), + ( + headers::ACCEPT.to_string(), + self.get_content_type().to_string(), + ), + ]; + let mut api_key = self.get_auth_header(&req.connector_auth_type)?; + headers.append(&mut api_key); + Ok(headers) + } +} +impl api::ConnectorCommon for Shift4 { + fn id(&self) -> &'static str { + "shift4" + } + + fn common_get_content_type(&self) -> &'static str { + "application/json" + } + + fn base_url<'a>(&self, connectors: &'a settings::Connectors) -> &'a str { + connectors.shift4.base_url.as_ref() + } + + fn get_auth_header( + &self, + auth_type: &types::ConnectorAuthType, + ) -> CustomResult, errors::ConnectorError> { + let auth: shift4::Shift4AuthType = auth_type + .try_into() + .change_context(errors::ConnectorError::FailedToObtainAuthType)?; + Ok(vec![(headers::AUTHORIZATION.to_string(), auth.api_key)]) + } + + fn build_error_response( + &self, + res: Bytes, + ) -> CustomResult { + let response: shift4::ErrorResponse = res + .parse_struct("Shift4 ErrorResponse") + .change_context(errors::ConnectorError::ResponseDeserializationFailed)?; + + Ok(ErrorResponse { + code: response + .error + .code + .unwrap_or_else(|| consts::NO_ERROR_CODE.to_string()), + message: response.error.message, + reason: None, + }) + } +} + +impl api::Payment for Shift4 {} + +impl api::PreVerify for Shift4 {} +impl + services::ConnectorIntegration< + api::Verify, + types::VerifyRequestData, + types::PaymentsResponseData, + > for Shift4 +{ +} + +impl api::PaymentVoid for Shift4 {} + +impl + services::ConnectorIntegration< + api::Void, + types::PaymentsCancelData, + types::PaymentsResponseData, + > for Shift4 +{ +} + +impl api::PaymentSync for Shift4 {} +impl + services::ConnectorIntegration + for Shift4 +{ + fn get_headers( + &self, + req: &types::PaymentsSyncRouterData, + ) -> CustomResult, errors::ConnectorError> { + self.build_headers(req) + } + + fn get_content_type(&self) -> &'static str { + self.common_get_content_type() + } + + fn get_url( + &self, + req: &types::PaymentsSyncRouterData, + connectors: &settings::Connectors, + ) -> CustomResult { + let connector_payment_id = req + .request + .connector_transaction_id + .get_connector_transaction_id() + .change_context(errors::ConnectorError::MissingConnectorTransactionID)?; + Ok(format!( + "{}charges/{}", + self.base_url(connectors), + connector_payment_id + )) + } + + fn build_request( + &self, + req: &types::PaymentsSyncRouterData, + connectors: &settings::Connectors, + ) -> CustomResult, errors::ConnectorError> { + Ok(Some( + services::RequestBuilder::new() + .method(services::Method::Get) + .url(&types::PaymentsSyncType::get_url(self, req, connectors)?) + .headers(types::PaymentsSyncType::get_headers(self, req)?) + .build(), + )) + } + + fn get_error_response( + &self, + res: Bytes, + ) -> CustomResult { + self.build_error_response(res) + } + + fn handle_response( + &self, + data: &types::PaymentsSyncRouterData, + res: Response, + ) -> CustomResult { + logger::debug!(payment_sync_response=?res); + let response: shift4::Shift4PaymentsResponse = res + .response + .parse_struct("shift4 PaymentsResponse") + .change_context(errors::ConnectorError::ResponseDeserializationFailed)?; + types::RouterData::try_from(types::ResponseRouterData { + response, + data: data.clone(), + http_code: res.status_code, + }) + .change_context(errors::ConnectorError::ResponseHandlingFailed) + } +} + +impl api::PaymentCapture for Shift4 {} + +impl + services::ConnectorIntegration< + api::Capture, + types::PaymentsCaptureData, + types::PaymentsResponseData, + > for Shift4 +{ + fn get_headers( + &self, + req: &types::PaymentsCaptureRouterData, + ) -> CustomResult, errors::ConnectorError> { + self.build_headers(req) + } + + fn get_content_type(&self) -> &'static str { + self.common_get_content_type() + } + + fn build_request( + &self, + req: &types::PaymentsCaptureRouterData, + connectors: &settings::Connectors, + ) -> CustomResult, errors::ConnectorError> { + Ok(Some( + services::RequestBuilder::new() + .method(services::Method::Post) + .url(&types::PaymentsCaptureType::get_url(self, req, connectors)?) + .headers(types::PaymentsCaptureType::get_headers(self, req)?) + .build(), + )) + } + + fn handle_response( + &self, + data: &types::PaymentsCaptureRouterData, + res: Response, + ) -> CustomResult { + let response: shift4::Shift4PaymentsResponse = res + .response + .parse_struct("Shift4PaymentsResponse") + .change_context(errors::ConnectorError::ResponseDeserializationFailed)?; + logger::debug!(shift4payments_create_response=?response); + types::ResponseRouterData { + response, + data: data.clone(), + http_code: res.status_code, + } + .try_into() + .change_context(errors::ConnectorError::ResponseHandlingFailed) + } + + fn get_url( + &self, + req: &types::PaymentsCaptureRouterData, + connectors: &settings::Connectors, + ) -> CustomResult { + let connector_payment_id = req.request.connector_transaction_id.clone(); + Ok(format!( + "{}charges/{}/capture", + self.base_url(connectors), + connector_payment_id + )) + } + + fn get_error_response( + &self, + res: Bytes, + ) -> CustomResult { + self.build_error_response(res) + } +} + +impl api::PaymentSession for Shift4 {} + +impl + services::ConnectorIntegration< + api::Session, + types::PaymentsSessionData, + types::PaymentsResponseData, + > for Shift4 +{ + //TODO: implement sessions flow +} + +impl api::PaymentAuthorize for Shift4 {} + +impl + services::ConnectorIntegration< + api::Authorize, + types::PaymentsAuthorizeData, + types::PaymentsResponseData, + > for Shift4 +{ + fn get_headers( + &self, + req: &types::PaymentsAuthorizeRouterData, + ) -> CustomResult, errors::ConnectorError> { + self.build_headers(req) + } + + fn get_content_type(&self) -> &'static str { + self.common_get_content_type() + } + + fn get_url( + &self, + _req: &types::PaymentsAuthorizeRouterData, + connectors: &settings::Connectors, + ) -> CustomResult { + Ok(format!("{}charges", self.base_url(connectors))) + } + + fn get_request_body( + &self, + req: &types::PaymentsAuthorizeRouterData, + ) -> CustomResult, errors::ConnectorError> { + let shift4_req = utils::Encode::::convert_and_encode(req) + .change_context(errors::ConnectorError::RequestEncodingFailed)?; + Ok(Some(shift4_req)) + } + + fn build_request( + &self, + req: &types::PaymentsAuthorizeRouterData, + connectors: &settings::Connectors, + ) -> CustomResult, errors::ConnectorError> { + Ok(Some( + services::RequestBuilder::new() + .method(services::Method::Post) + .url(&types::PaymentsAuthorizeType::get_url( + self, req, connectors, + )?) + .headers(types::PaymentsAuthorizeType::get_headers(self, req)?) + .body(types::PaymentsAuthorizeType::get_request_body(self, req)?) + .build(), + )) + } + fn handle_response( + &self, + data: &types::PaymentsAuthorizeRouterData, + res: Response, + ) -> CustomResult { + let response: shift4::Shift4PaymentsResponse = res + .response + .parse_struct("Shift4PaymentsResponse") + .change_context(errors::ConnectorError::ResponseDeserializationFailed)?; + logger::debug!(shift4payments_create_response=?response); + types::ResponseRouterData { + response, + data: data.clone(), + http_code: res.status_code, + } + .try_into() + .change_context(errors::ConnectorError::ResponseHandlingFailed) + } + + fn get_error_response( + &self, + res: Bytes, + ) -> CustomResult { + self.build_error_response(res) + } +} + +impl api::Refund for Shift4 {} +impl api::RefundExecute for Shift4 {} +impl api::RefundSync for Shift4 {} + +impl services::ConnectorIntegration + for Shift4 +{ + fn get_headers( + &self, + req: &types::RefundsRouterData, + ) -> CustomResult, errors::ConnectorError> { + self.build_headers(req) + } + + fn get_content_type(&self) -> &'static str { + self.common_get_content_type() + } + + fn get_url( + &self, + _req: &types::RefundsRouterData, + connectors: &settings::Connectors, + ) -> CustomResult { + Ok(format!("{}refunds", self.base_url(connectors),)) + } + + fn get_request_body( + &self, + req: &types::RefundsRouterData, + ) -> CustomResult, errors::ConnectorError> { + let shift4_req = utils::Encode::::convert_and_encode(req) + .change_context(errors::ConnectorError::RequestEncodingFailed)?; + Ok(Some(shift4_req)) + } + + fn build_request( + &self, + req: &types::RefundsRouterData, + connectors: &settings::Connectors, + ) -> CustomResult, errors::ConnectorError> { + let request = services::RequestBuilder::new() + .method(services::Method::Post) + .url(&types::RefundExecuteType::get_url(self, req, connectors)?) + .headers(types::RefundExecuteType::get_headers(self, req)?) + .body(types::RefundExecuteType::get_request_body(self, req)?) + .build(); + Ok(Some(request)) + } + + fn handle_response( + &self, + data: &types::RefundsRouterData, + res: Response, + ) -> CustomResult, errors::ConnectorError> { + logger::debug!(target: "router::connector::shift4", response=?res); + let response: shift4::RefundResponse = res + .response + .parse_struct("RefundResponse") + .change_context(errors::ConnectorError::ResponseDeserializationFailed)?; + types::ResponseRouterData { + response, + data: data.clone(), + http_code: res.status_code, + } + .try_into() + .change_context(errors::ConnectorError::ResponseHandlingFailed) + } + + fn get_error_response( + &self, + res: Bytes, + ) -> CustomResult { + self.build_error_response(res) + } +} + +impl services::ConnectorIntegration + for Shift4 +{ + fn get_headers( + &self, + req: &types::RefundSyncRouterData, + ) -> CustomResult, errors::ConnectorError> { + self.build_headers(req) + } + + fn get_content_type(&self) -> &'static str { + self.common_get_content_type() + } + + fn get_url( + &self, + _req: &types::RefundSyncRouterData, + connectors: &settings::Connectors, + ) -> CustomResult { + Ok(format!("{}refunds", self.base_url(connectors),)) + } + + fn handle_response( + &self, + data: &types::RefundSyncRouterData, + res: Response, + ) -> CustomResult { + logger::debug!(target: "router::connector::shift4", response=?res); + let response: shift4::RefundResponse = + res.response + .parse_struct("shift4 RefundResponse") + .change_context(errors::ConnectorError::ResponseDeserializationFailed)?; + types::ResponseRouterData { + response, + data: data.clone(), + http_code: res.status_code, + } + .try_into() + .change_context(errors::ConnectorError::ResponseHandlingFailed) + } + + fn get_error_response( + &self, + res: Bytes, + ) -> CustomResult { + self.build_error_response(res) + } +} + +#[async_trait::async_trait] +impl api::IncomingWebhook for Shift4 { + fn get_webhook_object_reference_id( + &self, + body: &[u8], + ) -> CustomResult { + let details: shift4::Shift4WebhookObjectId = body + .parse_struct("Shift4WebhookObjectId") + .change_context(errors::ConnectorError::WebhookReferenceIdNotFound)?; + + Ok(details.data.id) + } + + fn get_webhook_event_type( + &self, + body: &[u8], + ) -> CustomResult { + let details: shift4::Shift4WebhookObjectEventType = body + .parse_struct("Shift4WebhookObjectEventType") + .change_context(errors::ConnectorError::WebhookEventTypeNotFound)?; + Ok(match details.event_type { + shift4::Shift4WebhookEvent::ChargeSucceeded => { + api::IncomingWebhookEvent::PaymentIntentSuccess + } + }) + } + + fn get_webhook_resource_object( + &self, + body: &[u8], + ) -> CustomResult { + let details: shift4::Shift4WebhookObjectResource = body + .parse_struct("Shift4WebhookObjectResource") + .change_context(errors::ConnectorError::WebhookResourceObjectNotFound)?; + Ok(details.data) + } +} + +impl services::ConnectorRedirectResponse for Shift4 { + fn get_flow_type( + &self, + _query_params: &str, + ) -> CustomResult { + Ok(payments::CallConnectorAction::Trigger) + } +} diff --git a/crates/router/src/connector/shift4/transformers.rs b/crates/router/src/connector/shift4/transformers.rs new file mode 100644 index 0000000000..dbcd99afaf --- /dev/null +++ b/crates/router/src/connector/shift4/transformers.rs @@ -0,0 +1,257 @@ +use serde::{Deserialize, Serialize}; + +use crate::{ + core::errors, + pii::PeekInterface, + types::{self, api, storage::enums}, +}; + +#[derive(Default, Debug, Serialize, Eq, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct Shift4PaymentsRequest { + amount: String, + card: Card, + currency: String, + description: Option, + captured: bool, +} + +#[derive(Default, Debug, Serialize, Eq, PartialEq)] +pub struct DeviceData; + +#[derive(Default, Debug, Serialize, Eq, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct Card { + number: String, + exp_month: String, + exp_year: String, + cardholder_name: String, +} + +impl TryFrom<&types::PaymentsAuthorizeRouterData> for Shift4PaymentsRequest { + type Error = error_stack::Report; + fn try_from(item: &types::PaymentsAuthorizeRouterData) -> Result { + match item.request.payment_method_data { + api::PaymentMethod::Card(ref ccard) => { + let submit_for_settlement = matches!( + item.request.capture_method, + Some(enums::CaptureMethod::Automatic) | None + ); + let payment_request = Self { + amount: item.request.amount.to_string(), + card: Card { + number: ccard.card_number.peek().clone(), + exp_month: ccard.card_exp_month.peek().clone(), + exp_year: ccard.card_exp_year.peek().clone(), + cardholder_name: ccard.card_holder_name.peek().clone(), + }, + currency: item.request.currency.to_string(), + description: item.description.clone(), + captured: submit_for_settlement, + }; + Ok(payment_request) + } + _ => Err( + errors::ConnectorError::NotImplemented("Current Payment Method".to_string()).into(), + ), + } + } +} + +// Auth Struct +pub struct Shift4AuthType { + pub(super) api_key: String, +} + +impl TryFrom<&types::ConnectorAuthType> for Shift4AuthType { + type Error = error_stack::Report; + fn try_from(item: &types::ConnectorAuthType) -> Result { + if let types::ConnectorAuthType::HeaderKey { api_key } = item { + Ok(Self { + api_key: api_key.to_string(), + }) + } else { + Err(errors::ConnectorError::FailedToObtainAuthType)? + } + } +} +// PaymentsResponse +#[derive(Debug, Clone, Default, Serialize, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "lowercase")] +pub enum Shift4PaymentStatus { + Successful, + Failed, + #[default] + Pending, +} + +impl From for enums::AttemptStatus { + fn from(item: Shift4PaymentStatus) -> Self { + match item { + Shift4PaymentStatus::Successful => Self::Charged, + Shift4PaymentStatus::Failed => Self::Failure, + Shift4PaymentStatus::Pending => Self::Pending, + } + } +} + +#[derive(Debug, Deserialize)] +pub struct Shift4WebhookObjectEventType { + #[serde(rename = "type")] + pub event_type: Shift4WebhookEvent, +} + +#[derive(Debug, Deserialize)] +pub enum Shift4WebhookEvent { + ChargeSucceeded, +} + +#[derive(Debug, Deserialize)] +pub struct Shift4WebhookObjectData { + pub id: String, +} + +#[derive(Debug, Deserialize)] +pub struct Shift4WebhookObjectId { + pub data: Shift4WebhookObjectData, +} + +#[derive(Debug, Deserialize)] +pub struct Shift4WebhookObjectResource { + pub data: serde_json::Value, +} + +fn get_payment_status(response: &Shift4PaymentsResponse) -> enums::AttemptStatus { + let is_authorized = + !response.captured && matches!(response.status, Shift4PaymentStatus::Successful); + if is_authorized { + enums::AttemptStatus::Authorized + } else { + enums::AttemptStatus::from(response.status.clone()) + } +} + +#[derive(Default, Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct Shift4PaymentsResponse { + id: String, + currency: String, + amount: u32, + status: Shift4PaymentStatus, + captured: bool, + refunded: bool, +} + +impl + TryFrom> + for types::RouterData +{ + type Error = error_stack::Report; + fn try_from( + item: types::ResponseRouterData, + ) -> Result { + Ok(Self { + status: get_payment_status(&item.response), + response: Ok(types::PaymentsResponseData::TransactionResponse { + resource_id: types::ResponseId::ConnectorTransactionId(item.response.id), + redirection_data: None, + redirect: false, + mandate_reference: None, + }), + ..item.data + }) + } +} + +// REFUND : +// Type definition for RefundRequest +#[derive(Default, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Shift4RefundRequest { + charge_id: String, + amount: i64, +} + +impl TryFrom<&types::RefundsRouterData> for Shift4RefundRequest { + type Error = error_stack::Report; + fn try_from(item: &types::RefundsRouterData) -> Result { + Ok(Self { + charge_id: item.request.connector_transaction_id.clone(), + amount: item.request.amount, + }) + } +} + +impl From for enums::RefundStatus { + fn from(item: self::Shift4RefundStatus) -> Self { + match item { + self::Shift4RefundStatus::Successful => Self::Success, + self::Shift4RefundStatus::Failed => Self::Failure, + self::Shift4RefundStatus::Processing => Self::Pending, + } + } +} + +#[derive(Default, Debug, Clone, Serialize, Deserialize)] +pub struct RefundResponse { + pub id: String, + pub amount: i64, + pub currency: String, + pub charge: String, + pub status: Shift4RefundStatus, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "lowercase")] +pub enum Shift4RefundStatus { + Successful, + Processing, + #[default] + Failed, +} + +impl TryFrom> + for types::RefundsRouterData +{ + type Error = error_stack::Report; + fn try_from( + item: types::RefundsResponseRouterData, + ) -> Result { + let refund_status = enums::RefundStatus::from(item.response.status); + Ok(Self { + response: Ok(types::RefundsResponseData { + connector_refund_id: item.response.id, + refund_status, + }), + ..item.data + }) + } +} + +impl TryFrom> + for types::RefundsRouterData +{ + type Error = error_stack::Report; + fn try_from( + item: types::RefundsResponseRouterData, + ) -> Result { + let refund_status = enums::RefundStatus::from(item.response.status); + Ok(Self { + response: Ok(types::RefundsResponseData { + connector_refund_id: item.response.id, + refund_status, + }), + ..item.data + }) + } +} + +#[derive(Debug, Default, Deserialize)] +pub struct ErrorResponse { + pub error: ApiErrorResponse, +} + +#[derive(Default, Debug, Clone, Deserialize, Eq, PartialEq)] +pub struct ApiErrorResponse { + pub code: Option, + pub message: String, +} diff --git a/crates/router/src/core/refunds.rs b/crates/router/src/core/refunds.rs index e044d16421..9e7fcf55fd 100644 --- a/crates/router/src/core/refunds.rs +++ b/crates/router/src/core/refunds.rs @@ -417,10 +417,10 @@ pub async fn validate_and_create_refund( validator::validate_maximum_refund_against_payment_attempt(&all_refunds) .change_context(errors::ApiErrorResponse::MaximumRefundCount)?; - let connector = payment_attempt - .connector - .clone() - .ok_or(errors::ApiErrorResponse::InternalServerError)?; + let connector = payment_attempt.connector.clone().ok_or_else(|| { + report!(errors::ApiErrorResponse::InternalServerError) + .attach_printable("connector not populated in payment attempt.") + })?; refund_create_req = mk_new_refund( req, diff --git a/crates/router/src/services/api.rs b/crates/router/src/services/api.rs index c69105df6b..96a5c55908 100644 --- a/crates/router/src/services/api.rs +++ b/crates/router/src/services/api.rs @@ -35,11 +35,11 @@ use crate::{ pub type BoxedConnectorIntegration<'a, T, Req, Resp> = Box<&'a (dyn ConnectorIntegration + Send + Sync)>; -pub trait ConnectorIntegrationExt: Send + Sync + 'static { +pub trait ConnectorIntegrationAny: Send + Sync + 'static { fn get_connector_integration(&self) -> BoxedConnectorIntegration; } -impl ConnectorIntegrationExt for S +impl ConnectorIntegrationAny for S where S: ConnectorIntegration + Send + Sync, { @@ -48,7 +48,7 @@ where } } -pub trait ConnectorIntegration: ConnectorIntegrationExt { +pub trait ConnectorIntegration: ConnectorIntegrationAny { fn get_headers( &self, _req: &types::RouterData, diff --git a/crates/router/src/types.rs b/crates/router/src/types.rs index 5aed689c9e..088e009038 100644 --- a/crates/router/src/types.rs +++ b/crates/router/src/types.rs @@ -7,7 +7,6 @@ // Separation of concerns instead of separation of forms. pub mod api; -pub mod connector; pub mod storage; pub mod transformers; @@ -29,6 +28,8 @@ pub type PaymentsCancelRouterData = RouterData; pub type RefundsRouterData = RouterData; +pub type RefundExecuteRouterData = RouterData; +pub type RefundSyncRouterData = RouterData; pub type PaymentsResponseRouterData = ResponseRouterData; diff --git a/crates/router/src/types/api.rs b/crates/router/src/types/api.rs index fb96a9d8a8..d390388b35 100644 --- a/crates/router/src/types/api.rs +++ b/crates/router/src/types/api.rs @@ -9,14 +9,16 @@ pub mod webhooks; use std::{fmt::Debug, marker, str::FromStr}; +use bytes::Bytes; use error_stack::{report, IntoReport, ResultExt}; pub use self::{admin::*, customers::*, payment_methods::*, payments::*, refunds::*, webhooks::*}; +use super::ErrorResponse; use crate::{ configs::settings::Connectors, - connector, + connector, consts, core::errors::{self, CustomResult}, - services::ConnectorRedirectResponse, + services::{ConnectorIntegration, ConnectorRedirectResponse}, types::{self, api::enums as api_enums}, }; @@ -43,6 +45,31 @@ pub trait ConnectorCommon { /// The base URL for interacting with the connector's API. fn base_url<'a>(&self, connectors: &'a Connectors) -> &'a str; + + /// common error response for a connector if it is same in all case + fn build_error_response( + &self, + _res: Bytes, + ) -> CustomResult { + Ok(ErrorResponse { + code: consts::NO_ERROR_CODE.to_string(), + message: consts::NO_ERROR_MESSAGE.to_string(), + reason: None, + }) + } +} + +/// Extended trait for connector common to allow functions with generic type +pub trait ConnectorCommonExt: + ConnectorCommon + ConnectorIntegration +{ + /// common header builder when every request for the connector have same headers + fn build_headers( + &self, + _req: &types::RouterData, + ) -> CustomResult, errors::ConnectorError> { + Ok(Vec::new()) + } } pub trait Router {} @@ -119,6 +146,7 @@ impl ConnectorData { "braintree" => Ok(Box::new(&connector::Braintree)), "klarna" => Ok(Box::new(&connector::Klarna)), "applepay" => Ok(Box::new(&connector::Applepay)), + "shift4" => Ok(Box::new(&connector::Shift4)), _ => Err(report!(errors::UnexpectedError) .attach_printable(format!("invalid connector name: {connector_name}"))) .change_context(errors::ConnectorError::InvalidConnectorName) diff --git a/crates/router/src/types/connector.rs b/crates/router/src/types/connector.rs deleted file mode 100644 index 8b13789179..0000000000 --- a/crates/router/src/types/connector.rs +++ /dev/null @@ -1 +0,0 @@ - diff --git a/crates/router/tests/connectors/connector_auth.rs b/crates/router/tests/connectors/connector_auth.rs index 33ac398f92..e06897d9ab 100644 --- a/crates/router/tests/connectors/connector_auth.rs +++ b/crates/router/tests/connectors/connector_auth.rs @@ -6,6 +6,7 @@ pub(crate) struct ConnectorAuthentication { pub aci: Option, pub authorizedotnet: Option, pub checkout: Option, + pub shift4: Option, } impl ConnectorAuthentication { @@ -18,6 +19,19 @@ impl ConnectorAuthentication { } } +#[derive(Debug, Deserialize, Clone)] +pub(crate) struct HeaderKey { + pub api_key: String, +} + +impl From for ConnectorAuthType { + fn from(key: HeaderKey) -> Self { + ConnectorAuthType::HeaderKey { + api_key: key.api_key, + } + } +} + #[derive(Debug, Deserialize, Clone)] pub(crate) struct BodyKey { pub api_key: String, diff --git a/crates/router/tests/connectors/main.rs b/crates/router/tests/connectors/main.rs index 7f852689d0..93c55c6e58 100644 --- a/crates/router/tests/connectors/main.rs +++ b/crates/router/tests/connectors/main.rs @@ -2,3 +2,5 @@ mod aci; mod authorizedotnet; mod checkout; mod connector_auth; +mod shift4; +mod utils; diff --git a/crates/router/tests/connectors/sample_auth.toml b/crates/router/tests/connectors/sample_auth.toml index 21b599d9d6..23b7a383ab 100644 --- a/crates/router/tests/connectors/sample_auth.toml +++ b/crates/router/tests/connectors/sample_auth.toml @@ -12,3 +12,6 @@ key1 = "MyTransactionKey" [checkout] api_key = "Bearer MyApiKey" key1 = "MyProcessingChannelId" + +[shift4] +api_key = "Bearer MyApiKey" \ No newline at end of file diff --git a/crates/router/tests/connectors/shift4.rs b/crates/router/tests/connectors/shift4.rs new file mode 100644 index 0000000000..dd4f68f4e7 --- /dev/null +++ b/crates/router/tests/connectors/shift4.rs @@ -0,0 +1,93 @@ +use futures::future::OptionFuture; +use masking::Secret; +use router::types::{self, api, storage::enums}; + +use crate::{ + connector_auth, + utils::{self, ConnectorActions}, +}; + +struct Shift4; +impl utils::ConnectorActions for Shift4 {} +impl utils::Connector for Shift4 { + fn get_data(&self) -> types::api::ConnectorData { + use router::connector::Shift4; + types::api::ConnectorData { + connector: Box::new(&Shift4), + connector_name: types::Connector::Shift4, + get_token: types::api::GetToken::Connector, + } + } + + fn get_auth_token(&self) -> types::ConnectorAuthType { + types::ConnectorAuthType::from( + connector_auth::ConnectorAuthentication::new() + .shift4 + .expect("Missing connector authentication configuration"), + ) + } + + fn get_name(&self) -> String { + "shift4".to_string() + } +} + +#[actix_web::test] +async fn should_only_authorize_payment() { + let response = Shift4 {}.authorize_payment(None).await; + assert_eq!(response.status, enums::AttemptStatus::Authorized); +} + +#[actix_web::test] +async fn should_authorize_and_capture_payment() { + let response = Shift4 {}.make_payment(None).await; + assert_eq!(response.status, enums::AttemptStatus::Charged); +} + +#[actix_web::test] +async fn should_capture_already_authorized_payment() { + let connector = Shift4 {}; + let authorize_response = connector.authorize_payment(None).await; + assert_eq!(authorize_response.status, enums::AttemptStatus::Authorized); + let txn_id = utils::get_connector_transaction_id(authorize_response); + let response: OptionFuture<_> = txn_id + .map(|transaction_id| async move { + connector.capture_payment(transaction_id, None).await.status + }) + .into(); + assert_eq!(response.await, Some(enums::AttemptStatus::Charged)); +} + +#[actix_web::test] +async fn should_fail_payment_for_incorrect_cvc() { + let response = Shift4 {} + .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 + })) + .await; + let x = response.response.unwrap_err(); + assert_eq!( + x.message, + "The card's security code failed verification.".to_string(), + ); +} + +#[actix_web::test] +async fn should_refund_succeeded_payment() { + let connector = Shift4 {}; + //make a successful payment + let response = connector.make_payment(None).await; + + //try refund for previous payment + if let Some(transaction_id) = utils::get_connector_transaction_id(response) { + let response = connector.refund_payment(transaction_id, None).await; + 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 new file mode 100644 index 0000000000..569a38586d --- /dev/null +++ b/crates/router/tests/connectors/utils.rs @@ -0,0 +1,197 @@ +use std::{fmt::Debug, marker::PhantomData}; + +use async_trait::async_trait; +use masking::Secret; +use router::{ + core::payments, + db::StorageImpl, + routes, + services::{self}, + types::{ + self, + api::{self}, + storage::enums, + PaymentAddress, RouterData, + }, +}; + +pub trait Connector { + fn get_data(&self) -> types::api::ConnectorData; + fn get_auth_token(&self) -> types::ConnectorAuthType; + fn get_name(&self) -> String; +} + +#[async_trait] +pub trait ConnectorActions: Connector { + async fn authorize_payment( + &self, + payment_data: Option, + ) -> types::PaymentsAuthorizeRouterData { + let integration = self.get_data().connector.get_connector_integration(); + let request = generate_data( + self.get_name(), + self.get_auth_token(), + payment_data.unwrap_or_else(|| types::PaymentsAuthorizeData { + capture_method: Some(storage_models::enums::CaptureMethod::Manual), + ..PaymentAuthorizeType::default().0 + }), + ); + call_connector(request, integration).await + } + async fn make_payment( + &self, + payment_data: Option, + ) -> types::PaymentsAuthorizeRouterData { + let integration = self.get_data().connector.get_connector_integration(); + let request = generate_data( + self.get_name(), + self.get_auth_token(), + payment_data.unwrap_or_else(|| PaymentAuthorizeType::default().0), + ); + call_connector(request, integration).await + } + async fn capture_payment( + &self, + transaction_id: String, + payment_data: Option, + ) -> types::PaymentsCaptureRouterData { + let integration = self.get_data().connector.get_connector_integration(); + let request = generate_data( + self.get_name(), + self.get_auth_token(), + payment_data.unwrap_or(types::PaymentsCaptureData { + amount_to_capture: Some(100), + connector_transaction_id: transaction_id, + }), + ); + call_connector(request, integration).await + } + async fn refund_payment( + &self, + transaction_id: String, + payment_data: Option, + ) -> types::RefundExecuteRouterData { + let integration = self.get_data().connector.get_connector_integration(); + let request = generate_data( + self.get_name(), + self.get_auth_token(), + payment_data.unwrap_or_else(|| types::RefundsData { + amount: 100, + currency: enums::Currency::USD, + refund_id: uuid::Uuid::new_v4().to_string(), + payment_method_data: types::api::PaymentMethod::Card(CCardType::default().0), + connector_transaction_id: transaction_id, + refund_amount: 100, + }), + ); + call_connector(request, integration).await + } +} + +async fn call_connector< + T: Debug + Clone + 'static, + Req: Debug + Clone + 'static, + Resp: Debug + Clone + 'static, +>( + request: RouterData, + integration: services::BoxedConnectorIntegration<'_, T, Req, Resp>, +) -> types::RouterData { + use router::configs::settings::Settings; + let conf = Settings::new().unwrap(); + let state = routes::AppState::with_storage(conf, StorageImpl::PostgresqlTest).await; + services::api::execute_connector_processing_step( + &state, + integration, + &request, + payments::CallConnectorAction::Trigger, + ) + .await + .unwrap() +} + +pub struct PaymentAuthorizeType(pub types::PaymentsAuthorizeData); +pub struct PaymentRefundType(pub types::RefundsData); +pub struct CCardType(pub api::CCard); + +impl Default for CCardType { + fn default() -> Self { + CCardType(api::CCard { + card_number: Secret::new("4200000000000000".to_string()), + card_exp_month: Secret::new("10".to_string()), + card_exp_year: Secret::new("2025".to_string()), + card_holder_name: Secret::new("John Doe".to_string()), + card_cvc: Secret::new("999".to_string()), + }) + } +} + +impl Default for PaymentAuthorizeType { + fn default() -> Self { + let data = types::PaymentsAuthorizeData { + payment_method_data: types::api::PaymentMethod::Card(CCardType::default().0), + amount: 100, + currency: enums::Currency::USD, + confirm: true, + statement_descriptor_suffix: None, + capture_method: None, + setup_future_usage: None, + mandate_id: None, + off_session: None, + setup_mandate_details: None, + browser_info: None, + order_details: None, + }; + PaymentAuthorizeType(data) + } +} + +impl Default for PaymentRefundType { + fn default() -> Self { + let data = types::RefundsData { + amount: 1000, + currency: enums::Currency::USD, + refund_id: uuid::Uuid::new_v4().to_string(), + payment_method_data: types::api::PaymentMethod::Card(CCardType::default().0), + connector_transaction_id: String::new(), + refund_amount: 100, + }; + PaymentRefundType(data) + } +} + +pub fn get_connector_transaction_id( + response: types::PaymentsAuthorizeRouterData, +) -> Option { + match response.response { + Ok(types::PaymentsResponseData::TransactionResponse { resource_id, .. }) => { + resource_id.get_connector_transaction_id().ok() + } + Ok(types::PaymentsResponseData::SessionResponse { .. }) => None, + Err(_) => None, + } +} + +fn generate_data, Res>( + connector: String, + connector_auth_type: types::ConnectorAuthType, + req: Req, +) -> types::RouterData { + types::RouterData { + flow: PhantomData, + merchant_id: connector.clone(), + connector, + payment_id: uuid::Uuid::new_v4().to_string(), + status: enums::AttemptStatus::default(), + orca_return_url: None, + auth_type: enums::AuthenticationType::NoThreeDs, + payment_method: enums::PaymentMethodType::Card, + connector_auth_type, + description: Some("This is a test".to_string()), + return_url: None, + request: req, + response: Err(types::ErrorResponse::default()), + payment_method_id: None, + address: PaymentAddress::default(), + connector_meta_data: None, + } +} diff --git a/keys.conf b/keys.conf index 9471e84cf9..772aca35ee 100644 --- a/keys.conf +++ b/keys.conf @@ -22,4 +22,4 @@ aci_api_key: aci_key1: braintree_api_key: -braintree_key1: +braintree_key1: \ No newline at end of file diff --git a/scripts/add_connector.sh b/scripts/add_connector.sh new file mode 100644 index 0000000000..38addff3b8 --- /dev/null +++ b/scripts/add_connector.sh @@ -0,0 +1,25 @@ +pg=$1; +pgc="$(tr '[:lower:]' '[:upper:]' <<< ${pg:0:1})${pg:1}" +src="crates/router/src" +conn="$src/connector" +SCRIPT="$( cd -- "$(dirname "$0")" >/dev/null 2>&1 ; pwd -P )" +if [[ -z "$pg" ]]; then + echo 'Connector name not present: try "sh add_connector.sh "' + exit +fi +cd $SCRIPT/.. +rm -rf $conn/$pg $conn/$pg.rs +git checkout $conn.rs $src/types/api.rs scripts/create_connector_account.sh $src/configs/settings.rs +sed -i'' -e "s/pub use self::{/pub mod ${pg};\n\npub use self::{/" $conn.rs +sed -i'' -e "s/};/${pg}::${pgc},\n};/" $conn.rs +sed -i'' -e "s/_ => Err/\"${pg}\" => Ok(Box::new(\&connector::${pgc})),\n\t\t\t_ => Err/" $src/types/api.rs +sed -i'' -e "s/*) echo \"This connector/${pg}) required_connector=\"${pg}\";;\n\t\t*) echo \"This connector/" scripts/create_connector_account.sh +sed -i'' -e "s/pub supported: SupportedConnectors,/pub supported: SupportedConnectors,\n\tpub ${pg}: ConnectorParams,/" $src/configs/settings.rs +rm $conn.rs-e $src/types/api.rs-e scripts/create_connector_account.sh-e $src/configs/settings.rs-e +cd $conn/ +cargo gen-pg $pg +mv $pg/mod.rs $pg.rs +mv $pg/test.rs ../../tests/connectors/$pg.rs +sed -i'' -e "s/mod utils;/mod ${pg};\nmod utils;/" ../../tests/connectors/main.rs +rm ../../tests/connectors/main.rs-e +echo "Successfully created connector: try running the tests of "$pg.rs \ No newline at end of file diff --git a/scripts/create_connector_account.sh b/scripts/create_connector_account.sh index 4815197eba..c03a3a4426 100644 --- a/scripts/create_connector_account.sh +++ b/scripts/create_connector_account.sh @@ -40,6 +40,7 @@ case "$connector" in aci) required_connector="aci";; adyen) required_connector="adyen";; braintree) required_connector="braintree";; + shift4) required_connector="shift4";; *) echo "This connector is not supported" 1>&2;exit 1;; esac