From 006e9a88927ff619ee24ebcbb66eac34afbe66ca Mon Sep 17 00:00:00 2001 From: Manoj Ghorela <118727120+manoj-juspay@users.noreply.github.com> Date: Mon, 16 Jan 2023 23:58:21 +0530 Subject: [PATCH] feat(connector_integration): integrate Rapyd connector (#357) --- config/Development.toml | 4 + config/config.example.toml | 3 + config/docker_compose.toml | 3 + crates/api_models/src/enums.rs | 1 + crates/router/src/configs/defaults.toml | 2 +- crates/router/src/configs/settings.rs | 1 + crates/router/src/connector.rs | 3 +- crates/router/src/connector/rapyd.rs | 656 ++++++++++++++++++ .../src/connector/rapyd/transformers.rs | 481 +++++++++++++ crates/router/src/consts.rs | 3 + crates/router/src/types/api.rs | 1 + .../router/tests/connectors/connector_auth.rs | 1 + crates/router/tests/connectors/main.rs | 1 + crates/router/tests/connectors/rapyd.rs | 144 ++++ .../router/tests/connectors/sample_auth.toml | 4 + 15 files changed, 1306 insertions(+), 2 deletions(-) create mode 100644 crates/router/src/connector/rapyd.rs create mode 100644 crates/router/src/connector/rapyd/transformers.rs create mode 100644 crates/router/tests/connectors/rapyd.rs diff --git a/config/Development.toml b/config/Development.toml index e0315b066f..761156a9a3 100644 --- a/config/Development.toml +++ b/config/Development.toml @@ -82,6 +82,9 @@ base_url = "https://apitest.cybersource.com/" [connectors.shift4] base_url = "https://api.shift4.com/" +[connectors.rapyd] +base_url = "https://sandboxapi.rapyd.net" + [connectors.fiserv] base_url = "https://cert.api.fiservapps.com/" @@ -90,6 +93,7 @@ base_url = "http://localhost:9090/" [connectors.payu] base_url = "https://secure.snd.payu.com/api/" + [connectors.globalpay] base_url = "https://apis.sandbox.globalpay.com/ucp/" diff --git a/config/config.example.toml b/config/config.example.toml index 6bedd98ab2..ed90f40319 100644 --- a/config/config.example.toml +++ b/config/config.example.toml @@ -133,6 +133,9 @@ base_url = "https://apitest.cybersource.com/" [connectors.shift4] base_url = "https://api.shift4.com/" +[connectors.rapyd] +base_url = "https://sandboxapi.rapyd.net" + [connectors.fiserv] base_url = "https://cert.api.fiservapps.com/" diff --git a/config/docker_compose.toml b/config/docker_compose.toml index d38380b73c..bf6ec07b30 100644 --- a/config/docker_compose.toml +++ b/config/docker_compose.toml @@ -85,6 +85,9 @@ base_url = "https://apitest.cybersource.com/" [connectors.shift4] base_url = "https://api.shift4.com/" +[connectors.rapyd] +base_url = "https://sandboxapi.rapyd.net" + [connectors.fiserv] base_url = "https://cert.api.fiservapps.com/" diff --git a/crates/api_models/src/enums.rs b/crates/api_models/src/enums.rs index 0c77ca85bf..c24b915e32 100644 --- a/crates/api_models/src/enums.rs +++ b/crates/api_models/src/enums.rs @@ -507,6 +507,7 @@ pub enum Connector { Globalpay, Klarna, Payu, + Rapyd, Shift4, Stripe, Worldline, diff --git a/crates/router/src/configs/defaults.toml b/crates/router/src/configs/defaults.toml index 5e447f2775..437f78eed2 100644 --- a/crates/router/src/configs/defaults.toml +++ b/crates/router/src/configs/defaults.toml @@ -63,4 +63,4 @@ max_read_count = 100 [connectors.supported] wallets = ["klarna","braintree"] -cards = ["stripe","adyen","authorizedotnet","checkout","braintree", "cybersource", "fiserv"] +cards = ["stripe","adyen","authorizedotnet","checkout","braintree", "cybersource", "fiserv", "rapyd"] diff --git a/crates/router/src/configs/settings.rs b/crates/router/src/configs/settings.rs index 1f66cb22ff..72b641f9ae 100644 --- a/crates/router/src/configs/settings.rs +++ b/crates/router/src/configs/settings.rs @@ -131,6 +131,7 @@ pub struct Connectors { pub globalpay: ConnectorParams, pub klarna: ConnectorParams, pub payu: ConnectorParams, + pub rapyd: ConnectorParams, pub shift4: ConnectorParams, pub stripe: ConnectorParams, pub supported: SupportedConnectors, diff --git a/crates/router/src/connector.rs b/crates/router/src/connector.rs index 4c218454d9..a692e1e48c 100644 --- a/crates/router/src/connector.rs +++ b/crates/router/src/connector.rs @@ -9,6 +9,7 @@ pub mod fiserv; pub mod globalpay; pub mod klarna; pub mod payu; +pub mod rapyd; pub mod shift4; pub mod stripe; pub mod utils; @@ -18,6 +19,6 @@ pub mod worldpay; pub use self::{ aci::Aci, adyen::Adyen, applepay::Applepay, authorizedotnet::Authorizedotnet, braintree::Braintree, checkout::Checkout, cybersource::Cybersource, fiserv::Fiserv, - globalpay::Globalpay, klarna::Klarna, payu::Payu, shift4::Shift4, stripe::Stripe, + globalpay::Globalpay, klarna::Klarna, payu::Payu, rapyd::Rapyd, shift4::Shift4, stripe::Stripe, worldline::Worldline, worldpay::Worldpay, }; diff --git a/crates/router/src/connector/rapyd.rs b/crates/router/src/connector/rapyd.rs new file mode 100644 index 0000000000..b6f35e1a93 --- /dev/null +++ b/crates/router/src/connector/rapyd.rs @@ -0,0 +1,656 @@ +mod transformers; +use std::fmt::Debug; + +use base64::Engine; +use bytes::Bytes; +use common_utils::date_time; +use error_stack::{IntoReport, ResultExt}; +use rand::distributions::{Alphanumeric, DistString}; +use ring::hmac; +use transformers as rapyd; + +use crate::{ + configs::settings, + consts, + core::{ + errors::{self, CustomResult}, + payments, + }, + headers, logger, services, + types::{ + self, + api::{self, ConnectorCommon}, + ErrorResponse, Response, + }, + utils::{self, BytesExt}, +}; + +#[derive(Debug, Clone)] +pub struct Rapyd; + +impl Rapyd { + pub fn generate_signature( + &self, + auth: &rapyd::RapydAuthType, + http_method: &str, + url_path: &str, + body: &str, + timestamp: &i64, + salt: &str, + ) -> CustomResult { + let rapyd::RapydAuthType { + access_key, + secret_key, + } = auth; + let to_sign = + format!("{http_method}{url_path}{salt}{timestamp}{access_key}{secret_key}{body}"); + let key = hmac::Key::new(hmac::HMAC_SHA256, secret_key.as_bytes()); + let tag = hmac::sign(&key, to_sign.as_bytes()); + let hmac_sign = hex::encode(tag); + let signature_value = consts::BASE64_ENGINE_URL_SAFE.encode(hmac_sign); + Ok(signature_value) + } +} + +impl ConnectorCommon for Rapyd { + fn id(&self) -> &'static str { + "rapyd" + } + + fn common_get_content_type(&self) -> &'static str { + "application/json" + } + + fn base_url<'a>(&self, connectors: &'a settings::Connectors) -> &'a str { + connectors.rapyd.base_url.as_ref() + } + + fn get_auth_header( + &self, + _auth_type: &types::ConnectorAuthType, + ) -> CustomResult, errors::ConnectorError> { + Ok(vec![]) + } +} + +impl api::PaymentAuthorize for Rapyd {} + +impl + services::ConnectorIntegration< + api::Authorize, + types::PaymentsAuthorizeData, + types::PaymentsResponseData, + > for Rapyd +{ + fn get_headers( + &self, + _req: &types::PaymentsAuthorizeRouterData, + _connectors: &settings::Connectors, + ) -> CustomResult, errors::ConnectorError> { + Ok(vec![( + headers::CONTENT_TYPE.to_string(), + types::PaymentsAuthorizeType::get_content_type(self).to_string(), + )]) + } + + 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!("{}/v1/payments", self.base_url(connectors))) + } + + fn build_request( + &self, + req: &types::RouterData< + api::Authorize, + types::PaymentsAuthorizeData, + types::PaymentsResponseData, + >, + connectors: &settings::Connectors, + ) -> CustomResult, errors::ConnectorError> { + let timestamp = date_time::now_unix_timestamp(); + let salt = Alphanumeric.sample_string(&mut rand::thread_rng(), 12); + + let rapyd_req = utils::Encode::::convert_and_encode(req) + .change_context(errors::ConnectorError::RequestEncodingFailed)?; + + let auth: rapyd::RapydAuthType = rapyd::RapydAuthType::try_from(&req.connector_auth_type)?; + let signature = + self.generate_signature(&auth, "post", "/v1/payments", &rapyd_req, ×tamp, &salt)?; + let headers = vec![ + ("access_key".to_string(), auth.access_key), + ("salt".to_string(), salt), + ("timestamp".to_string(), timestamp.to_string()), + ("signature".to_string(), signature), + ]; + let request = services::RequestBuilder::new() + .method(services::Method::Post) + .url(&types::PaymentsAuthorizeType::get_url( + self, req, connectors, + )?) + .headers(types::PaymentsAuthorizeType::get_headers( + self, req, connectors, + )?) + .headers(headers) + .body(Some(rapyd_req)) + .build(); + Ok(Some(request)) + } + + fn get_request_body( + &self, + req: &types::PaymentsAuthorizeRouterData, + ) -> CustomResult, errors::ConnectorError> { + let rapyd_req = utils::Encode::::convert_and_url_encode(req) + .change_context(errors::ConnectorError::RequestEncodingFailed)?; + Ok(Some(rapyd_req)) + } + + fn handle_response( + &self, + data: &types::PaymentsAuthorizeRouterData, + res: Response, + ) -> CustomResult { + let response: rapyd::RapydPaymentsResponse = res + .response + .parse_struct("Rapyd PaymentResponse") + .change_context(errors::ConnectorError::ResponseDeserializationFailed)?; + logger::debug!(rapydpayments_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 { + let response: rapyd::RapydPaymentsResponse = res + .parse_struct("Rapyd ErrorResponse") + .change_context(errors::ConnectorError::ResponseDeserializationFailed)?; + Ok(ErrorResponse { + code: response.status.error_code, + message: response.status.status, + reason: response.status.message, + }) + } +} + +impl api::Payment for Rapyd {} + +impl api::PreVerify for Rapyd {} +impl + services::ConnectorIntegration< + api::Verify, + types::VerifyRequestData, + types::PaymentsResponseData, + > for Rapyd +{ +} + +impl api::PaymentVoid for Rapyd {} + +impl + services::ConnectorIntegration< + api::Void, + types::PaymentsCancelData, + types::PaymentsResponseData, + > for Rapyd +{ + fn get_headers( + &self, + _req: &types::PaymentsCancelRouterData, + _connectors: &settings::Connectors, + ) -> CustomResult, errors::ConnectorError> { + Ok(vec![( + headers::CONTENT_TYPE.to_string(), + types::PaymentsVoidType::get_content_type(self).to_string(), + )]) + } + + fn get_content_type(&self) -> &'static str { + self.common_get_content_type() + } + + fn get_url( + &self, + req: &types::PaymentsCancelRouterData, + connectors: &settings::Connectors, + ) -> CustomResult { + Ok(format!( + "{}/v1/payments/{}", + self.base_url(connectors), + req.request.connector_transaction_id + )) + } + + fn build_request( + &self, + req: &types::PaymentsCancelRouterData, + connectors: &settings::Connectors, + ) -> CustomResult, errors::ConnectorError> { + let timestamp = date_time::now_unix_timestamp(); + let salt = Alphanumeric.sample_string(&mut rand::thread_rng(), 12); + + let auth: rapyd::RapydAuthType = rapyd::RapydAuthType::try_from(&req.connector_auth_type)?; + let url_path = format!("/v1/payments/{}", req.request.connector_transaction_id); + let signature = + self.generate_signature(&auth, "delete", &url_path, "", ×tamp, &salt)?; + + let headers = vec![ + ("access_key".to_string(), auth.access_key), + ("salt".to_string(), salt), + ("timestamp".to_string(), timestamp.to_string()), + ("signature".to_string(), signature), + ]; + let request = services::RequestBuilder::new() + .method(services::Method::Delete) + .url(&types::PaymentsVoidType::get_url(self, req, connectors)?) + .headers(types::PaymentsVoidType::get_headers(self, req, connectors)?) + .headers(headers) + .build(); + Ok(Some(request)) + } + + fn handle_response( + &self, + data: &types::PaymentsCancelRouterData, + res: Response, + ) -> CustomResult { + let response: rapyd::RapydPaymentsResponse = res + .response + .parse_struct("Rapyd PaymentResponse") + .change_context(errors::ConnectorError::ResponseDeserializationFailed)?; + logger::debug!(rapydpayments_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 { + let response: rapyd::RapydPaymentsResponse = res + .parse_struct("Rapyd ErrorResponse") + .change_context(errors::ConnectorError::ResponseDeserializationFailed)?; + Ok(ErrorResponse { + code: response.status.error_code, + message: response.status.status, + reason: response.status.message, + }) + } +} + +impl api::PaymentSync for Rapyd {} +impl + services::ConnectorIntegration + for Rapyd +{ + fn get_headers( + &self, + _req: &types::PaymentsSyncRouterData, + _connectors: &settings::Connectors, + ) -> CustomResult, errors::ConnectorError> { + Ok(vec![]) + } + + fn get_content_type(&self) -> &'static str { + self.common_get_content_type() + } + + fn get_url( + &self, + _req: &types::PaymentsSyncRouterData, + _connectors: &settings::Connectors, + ) -> CustomResult { + Err(errors::ConnectorError::NotImplemented("PSync".to_string()).into()) + } + + fn build_request( + &self, + _req: &types::PaymentsSyncRouterData, + _connectors: &settings::Connectors, + ) -> CustomResult, errors::ConnectorError> { + Ok(None) + } + + fn get_error_response( + &self, + _res: Bytes, + ) -> CustomResult { + Err(errors::ConnectorError::NotImplemented("PSync".to_string()).into()) + } + + fn handle_response( + &self, + _data: &types::PaymentsSyncRouterData, + _res: Response, + ) -> CustomResult { + Err(errors::ConnectorError::NotImplemented("PSync".to_string()).into()) + } +} + +impl api::PaymentCapture for Rapyd {} +impl + services::ConnectorIntegration< + api::Capture, + types::PaymentsCaptureData, + types::PaymentsResponseData, + > for Rapyd +{ + fn get_headers( + &self, + _req: &types::PaymentsCaptureRouterData, + _connectors: &settings::Connectors, + ) -> CustomResult, errors::ConnectorError> { + Ok(vec![( + headers::CONTENT_TYPE.to_string(), + types::PaymentsCaptureType::get_content_type(self).to_string(), + )]) + } + + fn get_content_type(&self) -> &'static str { + self.common_get_content_type() + } + + fn get_request_body( + &self, + req: &types::PaymentsCaptureRouterData, + ) -> CustomResult, errors::ConnectorError> { + let rapyd_req = utils::Encode::::convert_and_encode(req) + .change_context(errors::ConnectorError::RequestEncodingFailed)?; + Ok(Some(rapyd_req)) + } + + fn build_request( + &self, + req: &types::PaymentsCaptureRouterData, + connectors: &settings::Connectors, + ) -> CustomResult, errors::ConnectorError> { + let timestamp = date_time::now_unix_timestamp(); + let salt = Alphanumeric.sample_string(&mut rand::thread_rng(), 12); + + let rapyd_req = utils::Encode::::convert_and_encode(req) + .change_context(errors::ConnectorError::RequestEncodingFailed)?; + + let auth: rapyd::RapydAuthType = rapyd::RapydAuthType::try_from(&req.connector_auth_type)?; + let url_path = format!( + "/v1/payments/{}/capture", + req.request.connector_transaction_id + ); + let signature = + self.generate_signature(&auth, "post", &url_path, &rapyd_req, ×tamp, &salt)?; + let headers = vec![ + ("access_key".to_string(), auth.access_key), + ("salt".to_string(), salt), + ("timestamp".to_string(), timestamp.to_string()), + ("signature".to_string(), signature), + ]; + let request = services::RequestBuilder::new() + .method(services::Method::Post) + .url(&types::PaymentsCaptureType::get_url(self, req, connectors)?) + .headers(types::PaymentsCaptureType::get_headers( + self, req, connectors, + )?) + .headers(headers) + .body(Some(rapyd_req)) + .build(); + Ok(Some(request)) + } + + fn handle_response( + &self, + data: &types::PaymentsCaptureRouterData, + res: Response, + ) -> CustomResult { + let response: rapyd::RapydPaymentsResponse = res + .response + .parse_struct("RapydPaymentResponse") + .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) + } + + fn get_url( + &self, + req: &types::PaymentsCaptureRouterData, + connectors: &settings::Connectors, + ) -> CustomResult { + Ok(format!( + "{}/v1/payments/{}/capture", + self.base_url(connectors), + req.request.connector_transaction_id + )) + } + + fn get_error_response( + &self, + res: Bytes, + ) -> CustomResult { + let response: rapyd::RapydPaymentsResponse = res + .parse_struct("Rapyd ErrorResponse") + .change_context(errors::ConnectorError::ResponseDeserializationFailed)?; + Ok(ErrorResponse { + code: response.status.error_code, + message: response.status.status, + reason: response.status.message, + }) + } +} + +impl api::PaymentSession for Rapyd {} + +impl + services::ConnectorIntegration< + api::Session, + types::PaymentsSessionData, + types::PaymentsResponseData, + > for Rapyd +{ + //TODO: implement sessions flow +} + +impl api::Refund for Rapyd {} +impl api::RefundExecute for Rapyd {} +impl api::RefundSync for Rapyd {} + +impl services::ConnectorIntegration + for Rapyd +{ + fn get_headers( + &self, + _req: &types::RefundsRouterData, + _connectors: &settings::Connectors, + ) -> CustomResult, errors::ConnectorError> { + Ok(vec![( + headers::CONTENT_TYPE.to_string(), + types::RefundExecuteType::get_content_type(self).to_string(), + )]) + } + + fn get_content_type(&self) -> &'static str { + api::ConnectorCommon::common_get_content_type(self) + } + + fn get_url( + &self, + _req: &types::RefundsRouterData, + connectors: &settings::Connectors, + ) -> CustomResult { + Ok(format!("{}/v1/refunds", self.base_url(connectors))) + } + + fn get_request_body( + &self, + req: &types::RefundsRouterData, + ) -> CustomResult, errors::ConnectorError> { + let rapyd_req = utils::Encode::::convert_and_url_encode(req) + .change_context(errors::ConnectorError::RequestEncodingFailed)?; + Ok(Some(rapyd_req)) + } + + fn build_request( + &self, + req: &types::RefundsRouterData, + connectors: &settings::Connectors, + ) -> CustomResult, errors::ConnectorError> { + let timestamp = date_time::now_unix_timestamp(); + let salt = Alphanumeric.sample_string(&mut rand::thread_rng(), 12); + + let rapyd_req = utils::Encode::::convert_and_encode(req) + .change_context(errors::ConnectorError::RequestEncodingFailed)?; + + let auth: rapyd::RapydAuthType = rapyd::RapydAuthType::try_from(&req.connector_auth_type)?; + let signature = + self.generate_signature(&auth, "post", "/v1/refunds", &rapyd_req, ×tamp, &salt)?; + let headers = vec![ + ("access_key".to_string(), auth.access_key), + ("salt".to_string(), salt), + ("timestamp".to_string(), timestamp.to_string()), + ("signature".to_string(), signature), + ]; + let request = services::RequestBuilder::new() + .method(services::Method::Post) + .url(&types::RefundExecuteType::get_url(self, req, connectors)?) + .headers(headers) + .body(Some(rapyd_req)) + .build(); + Ok(Some(request)) + } + + fn handle_response( + &self, + data: &types::RefundsRouterData, + res: Response, + ) -> CustomResult, errors::ConnectorError> { + logger::debug!(target: "router::connector::rapyd", response=?res); + let response: rapyd::RefundResponse = res + .response + .parse_struct("rapyd RefundResponse") + .change_context(errors::ConnectorError::RequestEncodingFailed)?; + 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 { + let response: rapyd::RapydPaymentsResponse = res + .parse_struct("Rapyd ErrorResponse") + .change_context(errors::ConnectorError::ResponseDeserializationFailed)?; + Ok(ErrorResponse { + code: response.status.error_code, + message: response.status.status, + reason: response.status.message, + }) + } +} + +impl services::ConnectorIntegration + for Rapyd +{ + fn get_headers( + &self, + _req: &types::RefundSyncRouterData, + _connectors: &settings::Connectors, + ) -> CustomResult, errors::ConnectorError> { + Ok(vec![]) + } + + fn get_content_type(&self) -> &'static str { + self.common_get_content_type() + } + + fn get_url( + &self, + _req: &types::RefundSyncRouterData, + _connectors: &settings::Connectors, + ) -> CustomResult { + Err(errors::ConnectorError::NotImplemented("RSync".to_string()).into()) + } + + fn handle_response( + &self, + data: &types::RefundSyncRouterData, + res: Response, + ) -> CustomResult { + logger::debug!(target: "router::connector::rapyd", response=?res); + let response: rapyd::RefundResponse = res + .response + .parse_struct("rapyd 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 { + Err(errors::ConnectorError::NotImplemented("RSync".to_string()).into()) + } +} + +#[async_trait::async_trait] +impl api::IncomingWebhook for Rapyd { + fn get_webhook_object_reference_id( + &self, + _body: &[u8], + ) -> CustomResult { + Err(errors::ConnectorError::WebhooksNotImplemented).into_report() + } + + fn get_webhook_event_type( + &self, + _body: &[u8], + ) -> CustomResult { + Err(errors::ConnectorError::WebhooksNotImplemented).into_report() + } + + fn get_webhook_resource_object( + &self, + _body: &[u8], + ) -> CustomResult { + Err(errors::ConnectorError::WebhooksNotImplemented).into_report() + } +} + +impl services::ConnectorRedirectResponse for Rapyd { + fn get_flow_type( + &self, + _query_params: &str, + ) -> CustomResult { + Ok(payments::CallConnectorAction::Trigger) + } +} diff --git a/crates/router/src/connector/rapyd/transformers.rs b/crates/router/src/connector/rapyd/transformers.rs new file mode 100644 index 0000000000..f2bd831525 --- /dev/null +++ b/crates/router/src/connector/rapyd/transformers.rs @@ -0,0 +1,481 @@ +use error_stack::{IntoReport, ResultExt}; +use serde::{Deserialize, Serialize}; +use url::Url; + +use crate::{ + core::errors, + pii::{self, Secret}, + services, + types::{ + self, api, + storage::enums, + transformers::{self, ForeignFrom}, + }, +}; + +#[derive(Default, Debug, Serialize)] +pub struct RapydPaymentsRequest { + pub amount: i64, + pub currency: enums::Currency, + pub payment_method: PaymentMethod, + pub payment_method_options: PaymentMethodOptions, + pub capture: bool, +} + +#[derive(Default, Debug, Serialize)] +pub struct PaymentMethodOptions { + #[serde(rename = "3d_required")] + pub three_ds: bool, +} +#[derive(Default, Debug, Serialize)] +pub struct PaymentMethod { + #[serde(rename = "type")] + pub pm_type: String, + pub fields: PaymentFields, +} + +#[derive(Default, Debug, Serialize)] +pub struct PaymentFields { + pub number: Secret, + pub expiration_month: Secret, + pub expiration_year: Secret, + pub name: Secret, + pub cvv: Secret, +} + +impl TryFrom<&types::PaymentsAuthorizeRouterData> for RapydPaymentsRequest { + type Error = error_stack::Report; + fn try_from(item: &types::PaymentsAuthorizeRouterData) -> Result { + match item.request.payment_method_data { + api_models::payments::PaymentMethod::Card(ref ccard) => { + let payment_method = PaymentMethod { + pm_type: "in_amex_card".to_owned(), //[#369] Map payment method type based on country + fields: PaymentFields { + number: ccard.card_number.to_owned(), + expiration_month: ccard.card_exp_month.to_owned(), + expiration_year: ccard.card_exp_year.to_owned(), + name: ccard.card_holder_name.to_owned(), + cvv: ccard.card_cvc.to_owned(), + }, + }; + let three_ds_enabled = matches!(item.auth_type, enums::AuthenticationType::ThreeDs); + let payment_method_options = PaymentMethodOptions { + three_ds: three_ds_enabled, + }; + Ok(Self { + amount: item.request.amount, + currency: item.request.currency, + payment_method, + capture: matches!( + item.request.capture_method, + Some(enums::CaptureMethod::Automatic) | None + ), + payment_method_options, + }) + } + _ => Err(errors::ConnectorError::NotImplemented("Payment methods".to_string()).into()), + } + } +} + +pub struct RapydAuthType { + pub access_key: String, + pub secret_key: String, +} + +impl TryFrom<&types::ConnectorAuthType> for RapydAuthType { + type Error = error_stack::Report; + fn try_from(auth_type: &types::ConnectorAuthType) -> Result { + if let types::ConnectorAuthType::BodyKey { api_key, key1 } = auth_type { + Ok(Self { + access_key: api_key.to_string(), + secret_key: key1.to_string(), + }) + } else { + Err(errors::ConnectorError::FailedToObtainAuthType)? + } + } +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] +#[allow(clippy::upper_case_acronyms)] +pub enum RapydPaymentStatus { + #[serde(rename = "ACT")] + Active, + #[serde(rename = "CAN")] + CanceledByClientOrBank, + #[serde(rename = "CLO")] + Closed, + #[serde(rename = "ERR")] + Error, + #[serde(rename = "EXP")] + Expired, + #[serde(rename = "REV")] + ReversedByRapyd, + #[default] + #[serde(rename = "NEW")] + New, +} + +impl From> + for transformers::Foreign +{ + fn from(item: transformers::Foreign<(RapydPaymentStatus, String)>) -> Self { + let (status, next_action) = item.0; + match status { + RapydPaymentStatus::Closed => enums::AttemptStatus::Charged, + RapydPaymentStatus::Active => { + if next_action == "3d_verification" { + enums::AttemptStatus::AuthenticationPending + } else if next_action == "pending_capture" { + enums::AttemptStatus::Authorized + } else { + enums::AttemptStatus::Pending + } + } + RapydPaymentStatus::CanceledByClientOrBank + | RapydPaymentStatus::Error + | RapydPaymentStatus::Expired + | RapydPaymentStatus::ReversedByRapyd => enums::AttemptStatus::Failure, + RapydPaymentStatus::New => enums::AttemptStatus::Authorizing, + } + .into() + } +} + +#[derive(Default, Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct RapydPaymentsResponse { + pub status: Status, + pub data: Option, +} + +#[derive(Default, Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct Status { + pub error_code: String, + pub status: String, + pub message: Option, + pub response_code: Option, + pub operation_id: String, +} + +#[derive(Default, Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct ResponseData { + pub id: String, + pub amount: i64, + pub status: RapydPaymentStatus, + pub next_action: String, + pub redirect_url: Option, + pub original_amount: Option, + pub is_partial: Option, + pub currency_code: Option, + pub country_code: Option, + pub captured: Option, + pub transaction_id: String, + pub paid: Option, + pub failure_code: Option, + pub failure_message: Option, +} + +impl TryFrom> + for types::PaymentsAuthorizeRouterData +{ + type Error = error_stack::Report; + fn try_from( + item: types::PaymentsResponseRouterData, + ) -> Result { + let (status, response) = match item.response.status.status.as_str() { + "SUCCESS" => match item.response.data { + Some(data) => { + let redirection_data = match (data.next_action.as_str(), data.redirect_url) { + ("3d_verification", Some(url)) => { + let url = Url::parse(&url) + .into_report() + .change_context(errors::ParsingError)?; + let mut base_url = url.clone(); + base_url.set_query(None); + Some(services::RedirectForm { + url: base_url.to_string(), + method: services::Method::Get, + form_fields: std::collections::HashMap::from_iter( + url.query_pairs() + .map(|(k, v)| (k.to_string(), v.to_string())), + ), + }) + } + (_, _) => None, + }; + ( + enums::AttemptStatus::foreign_from((data.status, data.next_action)), + Ok(types::PaymentsResponseData::TransactionResponse { + resource_id: types::ResponseId::ConnectorTransactionId(data.id), //transaction_id is also the field but this id is used to initiate a refund + redirect: redirection_data.is_some(), + redirection_data, + mandate_reference: None, + connector_metadata: None, + }), + ) + } + None => ( + enums::AttemptStatus::Failure, + Err(types::ErrorResponse { + code: item.response.status.error_code, + message: item.response.status.status, + reason: item.response.status.message, + }), + ), + }, + "ERROR" => ( + enums::AttemptStatus::Failure, + Err(types::ErrorResponse { + code: item.response.status.error_code, + message: item.response.status.status, + reason: item.response.status.message, + }), + ), + _ => ( + enums::AttemptStatus::Failure, + Err(types::ErrorResponse { + code: item.response.status.error_code, + message: item.response.status.status, + reason: item.response.status.message, + }), + ), + }; + + Ok(Self { + status, + response, + ..item.data + }) + } +} + +#[derive(Default, Debug, Serialize)] +pub struct RapydRefundRequest { + pub payment: String, + pub amount: Option, + pub currency: Option, +} + +impl TryFrom<&types::RefundsRouterData> for RapydRefundRequest { + type Error = error_stack::Report; + fn try_from(item: &types::RefundsRouterData) -> Result { + Ok(Self { + payment: item.request.connector_transaction_id.to_string(), + amount: Some(item.request.amount), + currency: Some(item.request.currency), + }) + } +} + +#[allow(dead_code)] +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] +pub enum RefundStatus { + Completed, + Error, + Rejected, + #[default] + Pending, +} + +impl From for enums::RefundStatus { + fn from(item: RefundStatus) -> Self { + match item { + RefundStatus::Completed => Self::Success, + RefundStatus::Error | RefundStatus::Rejected => Self::Failure, + RefundStatus::Pending => Self::Pending, + } + } +} + +#[derive(Default, Debug, Clone, Serialize, Deserialize)] +pub struct RefundResponse { + pub status: Status, + pub data: Option, +} + +#[derive(Default, Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct RefundResponseData { + //Some field related to forign exchange and split payment can be added as and when implemented + pub id: String, + pub payment: String, + pub amount: i64, + pub currency: enums::Currency, + pub status: RefundStatus, + pub created_at: Option, + pub failure_reason: Option, +} + +impl TryFrom> + for types::RefundsRouterData +{ + type Error = error_stack::Report; + fn try_from( + item: types::RefundsResponseRouterData, + ) -> Result { + let (connector_refund_id, refund_status) = match item.response.data { + Some(data) => (data.id, enums::RefundStatus::from(data.status)), + None => ( + item.response.status.error_code, + enums::RefundStatus::Failure, + ), + }; + Ok(Self { + response: Ok(types::RefundsResponseData { + connector_refund_id, + refund_status, + }), + ..item.data + }) + } +} + +impl TryFrom> + for types::RefundsRouterData +{ + type Error = error_stack::Report; + fn try_from( + item: types::RefundsResponseRouterData, + ) -> Result { + let (connector_refund_id, refund_status) = match item.response.data { + Some(data) => (data.id, enums::RefundStatus::from(data.status)), + None => ( + item.response.status.error_code, + enums::RefundStatus::Failure, + ), + }; + Ok(Self { + response: Ok(types::RefundsResponseData { + connector_refund_id, + refund_status, + }), + ..item.data + }) + } +} + +#[derive(Debug, Serialize, Clone)] +pub struct CaptureRequest { + amount: Option, + receipt_email: Option, + statement_descriptor: Option, +} + +impl TryFrom<&types::PaymentsCaptureRouterData> for CaptureRequest { + type Error = error_stack::Report; + fn try_from(item: &types::PaymentsCaptureRouterData) -> Result { + Ok(Self { + amount: item.request.amount_to_capture, + receipt_email: None, + statement_descriptor: None, + }) + } +} + +impl TryFrom> + for types::PaymentsCaptureRouterData +{ + type Error = error_stack::Report; + fn try_from( + item: types::PaymentsCaptureResponseRouterData, + ) -> Result { + let (status, response) = match item.response.status.status.as_str() { + "SUCCESS" => match item.response.data { + Some(data) => ( + enums::AttemptStatus::foreign_from((data.status, data.next_action)), + Ok(types::PaymentsResponseData::TransactionResponse { + resource_id: types::ResponseId::ConnectorTransactionId(data.id), //transaction_id is also the field but this id is used to initiate a refund + redirection_data: None, + redirect: false, + mandate_reference: None, + connector_metadata: None, + }), + ), + None => ( + enums::AttemptStatus::Failure, + Err(types::ErrorResponse { + code: item.response.status.error_code, + message: item.response.status.status, + reason: item.response.status.message, + }), + ), + }, + "ERROR" => ( + enums::AttemptStatus::Failure, + Err(types::ErrorResponse { + code: item.response.status.error_code, + message: item.response.status.status, + reason: item.response.status.message, + }), + ), + _ => ( + enums::AttemptStatus::Failure, + Err(types::ErrorResponse { + code: item.response.status.error_code, + message: item.response.status.status, + reason: item.response.status.message, + }), + ), + }; + + Ok(Self { + status, + response, + ..item.data + }) + } +} + +impl TryFrom> + for types::PaymentsCancelRouterData +{ + type Error = error_stack::Report; + fn try_from( + item: types::PaymentsCancelResponseRouterData, + ) -> Result { + let (status, response) = match item.response.status.status.as_str() { + "SUCCESS" => match item.response.data { + Some(data) => ( + enums::AttemptStatus::foreign_from((data.status, data.next_action)), + Ok(types::PaymentsResponseData::TransactionResponse { + resource_id: types::ResponseId::ConnectorTransactionId(data.id), //transaction_id is also the field but this id is used to initiate a refund + redirection_data: None, + redirect: false, + mandate_reference: None, + connector_metadata: None, + }), + ), + None => ( + enums::AttemptStatus::Failure, + Err(types::ErrorResponse { + code: item.response.status.error_code, + message: item.response.status.status, + reason: item.response.status.message, + }), + ), + }, + "ERROR" => ( + enums::AttemptStatus::Failure, + Err(types::ErrorResponse { + code: item.response.status.error_code, + message: item.response.status.status, + reason: item.response.status.message, + }), + ), + _ => ( + enums::AttemptStatus::Failure, + Err(types::ErrorResponse { + code: item.response.status.error_code, + message: item.response.status.status, + reason: item.response.status.message, + }), + ), + }; + + Ok(Self { + status, + response, + ..item.data + }) + } +} diff --git a/crates/router/src/consts.rs b/crates/router/src/consts.rs index 687d7e6ac0..3d30069981 100644 --- a/crates/router/src/consts.rs +++ b/crates/router/src/consts.rs @@ -19,3 +19,6 @@ pub(crate) const NO_ERROR_CODE: &str = "No error code"; // General purpose base64 engine pub(crate) const BASE64_ENGINE: base64::engine::GeneralPurpose = base64::engine::general_purpose::STANDARD; + +pub(crate) const BASE64_ENGINE_URL_SAFE: base64::engine::GeneralPurpose = + base64::engine::general_purpose::URL_SAFE; diff --git a/crates/router/src/types/api.rs b/crates/router/src/types/api.rs index 0c21a8a45c..d8baef0eab 100644 --- a/crates/router/src/types/api.rs +++ b/crates/router/src/types/api.rs @@ -150,6 +150,7 @@ impl ConnectorData { "globalpay" => Ok(Box::new(&connector::Globalpay)), "klarna" => Ok(Box::new(&connector::Klarna)), "payu" => Ok(Box::new(&connector::Payu)), + "rapyd" => Ok(Box::new(&connector::Rapyd)), "shift4" => Ok(Box::new(&connector::Shift4)), "stripe" => Ok(Box::new(&connector::Stripe)), "worldline" => Ok(Box::new(&connector::Worldline)), diff --git a/crates/router/tests/connectors/connector_auth.rs b/crates/router/tests/connectors/connector_auth.rs index 9bb2cabdcc..0e74e936fd 100644 --- a/crates/router/tests/connectors/connector_auth.rs +++ b/crates/router/tests/connectors/connector_auth.rs @@ -9,6 +9,7 @@ pub(crate) struct ConnectorAuthentication { pub fiserv: Option, pub globalpay: Option, pub payu: Option, + pub rapyd: Option, pub shift4: Option, pub worldpay: Option, pub worldline: Option, diff --git a/crates/router/tests/connectors/main.rs b/crates/router/tests/connectors/main.rs index 39bb16e7a4..2cd9aa7d24 100644 --- a/crates/router/tests/connectors/main.rs +++ b/crates/router/tests/connectors/main.rs @@ -7,6 +7,7 @@ mod connector_auth; mod fiserv; mod globalpay; mod payu; +mod rapyd; mod shift4; mod utils; mod worldline; diff --git a/crates/router/tests/connectors/rapyd.rs b/crates/router/tests/connectors/rapyd.rs new file mode 100644 index 0000000000..0625d3b08d --- /dev/null +++ b/crates/router/tests/connectors/rapyd.rs @@ -0,0 +1,144 @@ +use futures::future::OptionFuture; +use masking::Secret; +use router::types::{self, api, storage::enums}; +use serial_test::serial; + +use crate::{ + connector_auth, + utils::{self, ConnectorActions}, +}; + +struct Rapyd; +impl ConnectorActions for Rapyd {} +impl utils::Connector for Rapyd { + fn get_data(&self) -> types::api::ConnectorData { + use router::connector::Rapyd; + types::api::ConnectorData { + connector: Box::new(&Rapyd), + connector_name: types::Connector::Rapyd, + get_token: types::api::GetToken::Connector, + } + } + + fn get_auth_token(&self) -> types::ConnectorAuthType { + types::ConnectorAuthType::from( + connector_auth::ConnectorAuthentication::new() + .rapyd + .expect("Missing connector authentication configuration"), + ) + } + + fn get_name(&self) -> String { + "rapyd".to_string() + } +} + +#[actix_web::test] +async fn should_only_authorize_payment() { + let response = Rapyd {} + .authorize_payment( + Some(types::PaymentsAuthorizeData { + payment_method_data: types::api::PaymentMethod::Card(api::CCard { + card_number: Secret::new("4111111111111111".to_string()), + card_exp_month: Secret::new("02".to_string()), + card_exp_year: Secret::new("2024".to_string()), + card_holder_name: Secret::new("John Doe".to_string()), + card_cvc: Secret::new("123".to_string()), + }), + capture_method: Some(storage_models::enums::CaptureMethod::Manual), + ..utils::PaymentAuthorizeType::default().0 + }), + None, + ) + .await; + assert_eq!(response.status, enums::AttemptStatus::Authorized); +} + +#[actix_web::test] +async fn should_authorize_and_capture_payment() { + let response = Rapyd {} + .make_payment( + Some(types::PaymentsAuthorizeData { + payment_method_data: types::api::PaymentMethod::Card(api::CCard { + card_number: Secret::new("4111111111111111".to_string()), + card_exp_month: Secret::new("02".to_string()), + card_exp_year: Secret::new("2024".to_string()), + card_holder_name: Secret::new("John Doe".to_string()), + card_cvc: Secret::new("123".to_string()), + }), + ..utils::PaymentAuthorizeType::default().0 + }), + None, + ) + .await; + assert_eq!(response.status, enums::AttemptStatus::Charged); +} + +#[actix_web::test] +async fn should_capture_already_authorized_payment() { + let connector = Rapyd {}; + let authorize_response = connector.authorize_payment(None, 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, None) + .await + .status + }) + .into(); + assert_eq!(response.await, Some(enums::AttemptStatus::Charged)); +} + +#[actix_web::test] +#[serial] +async fn voiding_already_authorized_payment_fails() { + let connector = Rapyd {}; + let authorize_response = connector.authorize_payment(None, 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 + .void_payment(transaction_id, None, None) + .await + .status + }) + .into(); + assert_eq!(response.await, Some(enums::AttemptStatus::Failure)); //rapyd doesn't allow authorize transaction to be voided +} + +#[actix_web::test] +async fn should_refund_succeeded_payment() { + let connector = Rapyd {}; + //make a successful payment + let response = connector.make_payment(None, 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, None).await; + assert_eq!( + response.response.unwrap().refund_status, + enums::RefundStatus::Success, + ); + } +} + +#[actix_web::test] +async fn should_fail_payment_for_incorrect_card_number() { + let response = Rapyd {} + .make_payment( + Some(types::PaymentsAuthorizeData { + payment_method_data: types::api::PaymentMethod::Card(api::CCard { + card_number: Secret::new("0000000000000000".to_string()), + ..utils::CCardType::default().0 + }), + ..utils::PaymentAuthorizeType::default().0 + }), + None, + ) + .await; + + assert!(response.response.is_err(), "The Payment pass"); +} diff --git a/crates/router/tests/connectors/sample_auth.toml b/crates/router/tests/connectors/sample_auth.toml index a161b71c07..fcb5c75df2 100644 --- a/crates/router/tests/connectors/sample_auth.toml +++ b/crates/router/tests/connectors/sample_auth.toml @@ -26,6 +26,10 @@ key1 = "MerchantPosId" [globalpay] api_key = "Bearer MyApiKey" +[rapyd] +api_key = "access_key" +key1 = "secret_key" + [fiserv] api_key = "MyApiKey" key1 = "MerchantID"