From 7bb0aa5ceb2e0d12b590602b9ad7c6803e1d5c43 Mon Sep 17 00:00:00 2001 From: Arvind Patel <52006565+arvindpatel24@users.noreply.github.com> Date: Fri, 30 Jun 2023 14:57:35 +0530 Subject: [PATCH] feat(connector): [cryptopay] add new connector cryptopay, authorize, sync, webhook and testcases (#1511) Co-authored-by: arvindpatel24 --- .typos.toml | 1 + config/config.example.toml | 2 + config/development.toml | 2 + config/docker_compose.toml | 2 + crates/api_models/src/enums.rs | 2 + crates/api_models/src/payments.rs | 4 +- crates/common_utils/src/crypto.rs | 28 ++ crates/router/src/configs/settings.rs | 1 + crates/router/src/connector.rs | 13 +- crates/router/src/connector/cryptopay.rs | 456 ++++++++++++++++++ .../src/connector/cryptopay/transformers.rs | 174 +++++++ crates/router/src/connector/utils.rs | 13 + crates/router/src/core/payments/flows.rs | 9 + crates/router/src/types/api.rs | 1 + crates/router/src/types/api/payments.rs | 8 +- crates/router/tests/connectors/bitpay.rs | 4 +- crates/router/tests/connectors/coinbase.rs | 4 +- .../router/tests/connectors/connector_auth.rs | 1 + crates/router/tests/connectors/cryptopay.rs | 149 ++++++ crates/router/tests/connectors/main.rs | 1 + crates/router/tests/connectors/opennode.rs | 4 +- .../router/tests/connectors/sample_auth.toml | 4 + loadtest/config/development.toml | 2 + scripts/add_connector.sh | 2 +- 24 files changed, 872 insertions(+), 15 deletions(-) create mode 100644 crates/router/src/connector/cryptopay.rs create mode 100644 crates/router/src/connector/cryptopay/transformers.rs create mode 100644 crates/router/tests/connectors/cryptopay.rs diff --git a/.typos.toml b/.typos.toml index 465c9e1c6cfb..8b98c0fc126f 100644 --- a/.typos.toml +++ b/.typos.toml @@ -22,6 +22,7 @@ aci = "aci" # Name of a connector encrypter = "encrypter" # Used by the `ring` crate nin = "nin" # National identification number, a field used by PayU connector substituters = "substituters" # Present in `flake.nix` +unsuccess = "unsuccess" # Used in cryptopay request [files] extend-exclude = [ diff --git a/config/config.example.toml b/config/config.example.toml index 75f71b7514d3..2d43669ea1d4 100644 --- a/config/config.example.toml +++ b/config/config.example.toml @@ -161,6 +161,7 @@ braintree.base_url = "https://api.sandbox.braintreegateway.com/" cashtocode.base_url = "https://cluster05.api-test.cashtocode.com" checkout.base_url = "https://api.sandbox.checkout.com/" coinbase.base_url = "https://api.commerce.coinbase.com" +cryptopay.base_url = "https://business-sandbox.cryptopay.me" cybersource.base_url = "https://apitest.cybersource.com/" dlocal.base_url = "https://sandbox.dlocal.com/" dummyconnector.base_url = "http://localhost:8080/dummy-connector" @@ -215,6 +216,7 @@ cards = [ "adyen", "authorizedotnet", "coinbase", + "cryptopay", "braintree", "checkout", "cybersource", diff --git a/config/development.toml b/config/development.toml index 067bdb4f1f56..ce0ca1ce6e31 100644 --- a/config/development.toml +++ b/config/development.toml @@ -65,6 +65,7 @@ cards = [ "braintree", "checkout", "coinbase", + "cryptopay", "cybersource", "dlocal", "dummyconnector", @@ -118,6 +119,7 @@ braintree.base_url = "https://api.sandbox.braintreegateway.com/" cashtocode.base_url = "https://cluster05.api-test.cashtocode.com" checkout.base_url = "https://api.sandbox.checkout.com/" coinbase.base_url = "https://api.commerce.coinbase.com" +cryptopay.base_url = "https://business-sandbox.cryptopay.me" cybersource.base_url = "https://apitest.cybersource.com/" dlocal.base_url = "https://sandbox.dlocal.com/" dummyconnector.base_url = "http://localhost:8080/dummy-connector" diff --git a/config/docker_compose.toml b/config/docker_compose.toml index fab624cdf315..c5cad33dbe69 100644 --- a/config/docker_compose.toml +++ b/config/docker_compose.toml @@ -82,6 +82,7 @@ braintree.base_url = "https://api.sandbox.braintreegateway.com/" cashtocode.base_url = "https://cluster05.api-test.cashtocode.com" checkout.base_url = "https://api.sandbox.checkout.com/" coinbase.base_url = "https://api.commerce.coinbase.com" +cryptopay.base_url = "https://business-sandbox.cryptopay.me" cybersource.base_url = "https://apitest.cybersource.com/" dlocal.base_url = "https://sandbox.dlocal.com/" dummyconnector.base_url = "http://localhost:8080/dummy-connector" @@ -127,6 +128,7 @@ cards = [ "braintree", "checkout", "coinbase", + "cryptopay", "cybersource", "dlocal", "dummyconnector", diff --git a/crates/api_models/src/enums.rs b/crates/api_models/src/enums.rs index dc12d46698ce..f2b2a9d4c8d5 100644 --- a/crates/api_models/src/enums.rs +++ b/crates/api_models/src/enums.rs @@ -599,6 +599,7 @@ pub enum Connector { Cashtocode, Checkout, Coinbase, + Cryptopay, Cybersource, Iatapay, #[cfg(feature = "dummy_connector")] @@ -699,6 +700,7 @@ pub enum RoutableConnectors { Cashtocode, Checkout, Coinbase, + Cryptopay, Cybersource, Dlocal, Fiserv, diff --git a/crates/api_models/src/payments.rs b/crates/api_models/src/payments.rs index 9d8fe927663f..12796c982f45 100644 --- a/crates/api_models/src/payments.rs +++ b/crates/api_models/src/payments.rs @@ -771,7 +771,9 @@ pub struct SepaAndBacsBillingDetails { #[derive(Debug, Clone, Eq, PartialEq, serde::Deserialize, serde::Serialize, ToSchema)] #[serde(rename_all = "snake_case")] -pub struct CryptoData {} +pub struct CryptoData { + pub pay_currency: Option, +} #[derive(Debug, Clone, Eq, PartialEq, serde::Deserialize, serde::Serialize, ToSchema)] pub struct SofortBilling { diff --git a/crates/common_utils/src/crypto.rs b/crates/common_utils/src/crypto.rs index e75f78f8dd3d..cc9c0778d6fc 100644 --- a/crates/common_utils/src/crypto.rs +++ b/crates/common_utils/src/crypto.rs @@ -154,6 +154,34 @@ impl DecodeMessage for NoAlgorithm { } } +/// Represents the HMAC-SHA-1 algorithm +#[derive(Debug)] +pub struct HmacSha1; + +impl SignMessage for HmacSha1 { + fn sign_message( + &self, + secret: &[u8], + msg: &[u8], + ) -> CustomResult, errors::CryptoError> { + let key = hmac::Key::new(hmac::HMAC_SHA1_FOR_LEGACY_USE_ONLY, secret); + Ok(hmac::sign(&key, msg).as_ref().to_vec()) + } +} + +impl VerifySignature for HmacSha1 { + fn verify_signature( + &self, + secret: &[u8], + signature: &[u8], + msg: &[u8], + ) -> CustomResult { + let key = hmac::Key::new(hmac::HMAC_SHA1_FOR_LEGACY_USE_ONLY, secret); + + Ok(hmac::verify(&key, msg, signature).is_ok()) + } +} + /// Represents the HMAC-SHA-256 algorithm #[derive(Debug)] pub struct HmacSha256; diff --git a/crates/router/src/configs/settings.rs b/crates/router/src/configs/settings.rs index 469a314d7ad9..1d2ca3f501fa 100644 --- a/crates/router/src/configs/settings.rs +++ b/crates/router/src/configs/settings.rs @@ -388,6 +388,7 @@ pub struct Connectors { pub cashtocode: ConnectorParams, pub checkout: ConnectorParams, pub coinbase: ConnectorParams, + pub cryptopay: ConnectorParams, pub cybersource: ConnectorParams, pub dlocal: ConnectorParams, #[cfg(feature = "dummy_connector")] diff --git a/crates/router/src/connector.rs b/crates/router/src/connector.rs index 88e84f70df51..78a1aa480560 100644 --- a/crates/router/src/connector.rs +++ b/crates/router/src/connector.rs @@ -9,6 +9,7 @@ pub mod braintree; pub mod cashtocode; pub mod checkout; pub mod coinbase; +pub mod cryptopay; pub mod cybersource; pub mod dlocal; #[cfg(feature = "dummy_connector")] @@ -44,10 +45,10 @@ pub use self::dummyconnector::DummyConnector; pub use self::{ aci::Aci, adyen::Adyen, airwallex::Airwallex, authorizedotnet::Authorizedotnet, bambora::Bambora, bitpay::Bitpay, bluesnap::Bluesnap, braintree::Braintree, - cashtocode::Cashtocode, checkout::Checkout, coinbase::Coinbase, cybersource::Cybersource, - dlocal::Dlocal, fiserv::Fiserv, forte::Forte, globalpay::Globalpay, iatapay::Iatapay, - klarna::Klarna, mollie::Mollie, multisafepay::Multisafepay, nexinets::Nexinets, nmi::Nmi, - noon::Noon, nuvei::Nuvei, opayo::Opayo, opennode::Opennode, payeezy::Payeezy, payme::Payme, - paypal::Paypal, payu::Payu, rapyd::Rapyd, shift4::Shift4, stripe::Stripe, trustpay::Trustpay, - worldline::Worldline, worldpay::Worldpay, zen::Zen, + cashtocode::Cashtocode, checkout::Checkout, coinbase::Coinbase, cryptopay::Cryptopay, + cybersource::Cybersource, dlocal::Dlocal, fiserv::Fiserv, forte::Forte, globalpay::Globalpay, + iatapay::Iatapay, klarna::Klarna, mollie::Mollie, multisafepay::Multisafepay, + nexinets::Nexinets, nmi::Nmi, noon::Noon, nuvei::Nuvei, opayo::Opayo, opennode::Opennode, + payeezy::Payeezy, payme::Payme, paypal::Paypal, payu::Payu, rapyd::Rapyd, shift4::Shift4, + stripe::Stripe, trustpay::Trustpay, worldline::Worldline, worldpay::Worldpay, zen::Zen, }; diff --git a/crates/router/src/connector/cryptopay.rs b/crates/router/src/connector/cryptopay.rs new file mode 100644 index 000000000000..a23d9fdbc9ed --- /dev/null +++ b/crates/router/src/connector/cryptopay.rs @@ -0,0 +1,456 @@ +mod transformers; + +use std::fmt::Debug; + +use base64::Engine; +use common_utils::{ + crypto::{self, GenerateDigest, SignMessage}, + date_time, + ext_traits::ByteSliceExt, +}; +use error_stack::{IntoReport, ResultExt}; +use hex::encode; +use masking::PeekInterface; +use transformers as cryptopay; + +use self::cryptopay::CryptopayWebhookDetails; +use super::utils; +use crate::{ + configs::settings, + consts, + core::errors::{self, CustomResult}, + db, headers, + services::{ + self, + request::{self, Mask}, + ConnectorIntegration, + }, + types::{ + self, + api::{self, ConnectorCommon, ConnectorCommonExt}, + ErrorResponse, Response, + }, + utils::{BytesExt, Encode}, +}; + +#[derive(Debug, Clone)] +pub struct Cryptopay; + +impl api::Payment for Cryptopay {} +impl api::PaymentSession for Cryptopay {} +impl api::ConnectorAccessToken for Cryptopay {} +impl api::PreVerify for Cryptopay {} +impl api::PaymentAuthorize for Cryptopay {} +impl api::PaymentSync for Cryptopay {} +impl api::PaymentCapture for Cryptopay {} +impl api::PaymentVoid for Cryptopay {} +impl api::Refund for Cryptopay {} +impl api::RefundExecute for Cryptopay {} +impl api::RefundSync for Cryptopay {} +impl api::PaymentToken for Cryptopay {} + +impl + ConnectorIntegration< + api::PaymentMethodToken, + types::PaymentMethodTokenizationData, + types::PaymentsResponseData, + > for Cryptopay +{ + // Not Implemented (R) +} + +impl ConnectorCommonExt for Cryptopay +where + Self: ConnectorIntegration, +{ + fn build_headers( + &self, + req: &types::RouterData, + connectors: &settings::Connectors, + ) -> CustomResult)>, errors::ConnectorError> { + let api_method; + let payload = match self.get_request_body(req)? { + Some(val) => { + let body = types::RequestBody::get_inner_value(val).peek().to_owned(); + api_method = "POST".to_string(); + let md5_payload = crypto::Md5 + .generate_digest(body.as_bytes()) + .change_context(errors::ConnectorError::RequestEncodingFailed)?; + encode(md5_payload) + } + None => { + api_method = "GET".to_string(); + String::default() + } + }; + + let now = date_time::date_as_yyyymmddthhmmssmmmz() + .into_report() + .change_context(errors::ConnectorError::RequestEncodingFailed)?; + let date = format!("{}+00:00", now.split_at(now.len() - 5).0); + + let content_type = self.get_content_type().to_string(); + + let api = (self.get_url(req, connectors)?).replace(self.base_url(connectors), ""); + + let auth = cryptopay::CryptopayAuthType::try_from(&req.connector_auth_type)?; + + let sign_req: String = format!( + "{}\n{}\n{}\n{}\n{}", + api_method, payload, content_type, date, api + ); + let authz = crypto::HmacSha1::sign_message( + &crypto::HmacSha1, + auth.api_secret.peek().as_bytes(), + sign_req.as_bytes(), + ) + .change_context(errors::ConnectorError::RequestEncodingFailed) + .attach_printable("Failed to sign the message")?; + let authz = consts::BASE64_ENGINE.encode(authz); + let auth_string: String = format!("HMAC {}:{}", auth.api_key.peek(), authz); + + let headers = vec![ + ( + headers::AUTHORIZATION.to_string(), + auth_string.into_masked(), + ), + (headers::DATE.to_string(), date.into()), + ( + headers::CONTENT_TYPE.to_string(), + Self.get_content_type().to_string().into(), + ), + ]; + Ok(headers) + } +} + +impl ConnectorCommon for Cryptopay { + fn id(&self) -> &'static str { + "cryptopay" + } + + fn common_get_content_type(&self) -> &'static str { + "application/json" + } + + fn base_url<'a>(&self, connectors: &'a settings::Connectors) -> &'a str { + connectors.cryptopay.base_url.as_ref() + } + + fn get_auth_header( + &self, + auth_type: &types::ConnectorAuthType, + ) -> CustomResult)>, errors::ConnectorError> { + let auth = cryptopay::CryptopayAuthType::try_from(auth_type) + .change_context(errors::ConnectorError::FailedToObtainAuthType)?; + Ok(vec![( + headers::AUTHORIZATION.to_string(), + auth.api_key.peek().to_owned().into_masked(), + )]) + } + + fn build_error_response( + &self, + res: Response, + ) -> CustomResult { + let response: cryptopay::CryptopayErrorResponse = res + .response + .parse_struct("CryptopayErrorResponse") + .change_context(errors::ConnectorError::ResponseDeserializationFailed)?; + + Ok(ErrorResponse { + status_code: res.status_code, + code: response.error.code, + message: response.error.message, + reason: response.error.reason, + }) + } +} + +impl ConnectorIntegration + for Cryptopay +{ +} + +impl ConnectorIntegration + for Cryptopay +{ +} + +impl ConnectorIntegration + for Cryptopay +{ +} + +impl ConnectorIntegration + for Cryptopay +{ + fn get_headers( + &self, + req: &types::PaymentsAuthorizeRouterData, + connectors: &settings::Connectors, + ) -> CustomResult)>, errors::ConnectorError> { + self.build_headers(req, connectors) + } + + 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!("{}/api/invoices", self.base_url(connectors))) + } + + fn get_request_body( + &self, + req: &types::PaymentsAuthorizeRouterData, + ) -> CustomResult, errors::ConnectorError> { + let connector_request = cryptopay::CryptopayPaymentsRequest::try_from(req)?; + let cryptopay_req = types::RequestBody::log_and_get_request_body( + &connector_request, + Encode::::encode_to_string_of_json, + ) + .change_context(errors::ConnectorError::RequestEncodingFailed)?; + Ok(Some(cryptopay_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, + )?) + .attach_default_headers() + .headers(types::PaymentsAuthorizeType::get_headers( + self, req, connectors, + )?) + .body(types::PaymentsAuthorizeType::get_request_body(self, req)?) + .build(), + )) + } + + fn handle_response( + &self, + data: &types::PaymentsAuthorizeRouterData, + res: Response, + ) -> CustomResult { + let response: cryptopay::CryptopayPaymentsResponse = res + .response + .parse_struct("Cryptopay PaymentsAuthorizeResponse") + .change_context(errors::ConnectorError::ResponseDeserializationFailed)?; + types::RouterData::try_from(types::ResponseRouterData { + response, + data: data.clone(), + http_code: res.status_code, + }) + } + + fn get_error_response( + &self, + res: Response, + ) -> CustomResult { + self.build_error_response(res) + } +} + +impl ConnectorIntegration + for Cryptopay +{ + fn get_headers( + &self, + req: &types::PaymentsSyncRouterData, + connectors: &settings::Connectors, + ) -> CustomResult)>, errors::ConnectorError> { + self.build_headers(req, connectors) + } + + 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_id = req + .request + .connector_transaction_id + .get_connector_transaction_id() + .change_context(errors::ConnectorError::MissingConnectorTransactionID)?; + + Ok(format!( + "{}/api/invoices/{}", + self.base_url(connectors), + connector_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)?) + .attach_default_headers() + .headers(types::PaymentsSyncType::get_headers(self, req, connectors)?) + .build(), + )) + } + + fn handle_response( + &self, + data: &types::PaymentsSyncRouterData, + res: Response, + ) -> CustomResult { + let response: cryptopay::CryptopayPaymentsResponse = res + .response + .parse_struct("cryptopay PaymentsSyncResponse") + .change_context(errors::ConnectorError::ResponseDeserializationFailed)?; + types::RouterData::try_from(types::ResponseRouterData { + response, + data: data.clone(), + http_code: res.status_code, + }) + } + + fn get_error_response( + &self, + res: Response, + ) -> CustomResult { + self.build_error_response(res) + } +} + +impl ConnectorIntegration + for Cryptopay +{ +} + +impl ConnectorIntegration + for Cryptopay +{ +} + +impl ConnectorIntegration + for Cryptopay +{ +} + +impl ConnectorIntegration + for Cryptopay +{ +} + +#[async_trait::async_trait] +impl api::IncomingWebhook for Cryptopay { + fn get_webhook_source_verification_algorithm( + &self, + _request: &api::IncomingWebhookRequestDetails<'_>, + ) -> CustomResult, errors::ConnectorError> { + Ok(Box::new(crypto::HmacSha256)) + } + + fn get_webhook_source_verification_signature( + &self, + request: &api::IncomingWebhookRequestDetails<'_>, + ) -> CustomResult, errors::ConnectorError> { + let base64_signature = + utils::get_header_key_value("X-Cryptopay-Signature", request.headers)?; + hex::decode(base64_signature) + .into_report() + .change_context(errors::ConnectorError::WebhookSourceVerificationFailed) + } + + fn get_webhook_source_verification_message( + &self, + request: &api::IncomingWebhookRequestDetails<'_>, + _merchant_id: &str, + _secret: &[u8], + ) -> CustomResult, errors::ConnectorError> { + let message = std::str::from_utf8(request.body) + .into_report() + .change_context(errors::ConnectorError::WebhookSourceVerificationFailed)?; + Ok(message.to_string().into_bytes()) + } + + async fn get_webhook_source_verification_merchant_secret( + &self, + db: &dyn db::StorageInterface, + merchant_id: &str, + ) -> CustomResult, errors::ConnectorError> { + let key = utils::get_webhook_merchant_secret_key(self.id(), merchant_id); + let secret = match db.find_config_by_key(&key).await { + Ok(config) => Some(config), + Err(e) => { + crate::logger::warn!("Unable to fetch merchant webhook secret from DB: {:#?}", e); + None + } + }; + Ok(secret + .map(|conf| conf.config.into_bytes()) + .unwrap_or_default()) + } + + fn get_webhook_object_reference_id( + &self, + request: &api::IncomingWebhookRequestDetails<'_>, + ) -> CustomResult { + let notif: CryptopayWebhookDetails = + request + .body + .parse_struct("CryptopayWebhookDetails") + .change_context(errors::ConnectorError::WebhookReferenceIdNotFound)?; + Ok(api_models::webhooks::ObjectReferenceId::PaymentId( + api_models::payments::PaymentIdType::ConnectorTransactionId(notif.data.id), + )) + } + + fn get_webhook_event_type( + &self, + request: &api::IncomingWebhookRequestDetails<'_>, + ) -> CustomResult { + let notif: CryptopayWebhookDetails = + request + .body + .parse_struct("CryptopayWebhookDetails") + .change_context(errors::ConnectorError::WebhookEventTypeNotFound)?; + match notif.data.status { + cryptopay::CryptopayPaymentStatus::Completed => { + Ok(api::IncomingWebhookEvent::PaymentIntentSuccess) + } + cryptopay::CryptopayPaymentStatus::Unresolved => { + Ok(api::IncomingWebhookEvent::PaymentActionRequired) + } + cryptopay::CryptopayPaymentStatus::Cancelled => { + Ok(api::IncomingWebhookEvent::PaymentIntentFailure) + } + _ => Ok(api::IncomingWebhookEvent::EventNotSupported), + } + } + + fn get_webhook_resource_object( + &self, + request: &api::IncomingWebhookRequestDetails<'_>, + ) -> CustomResult { + let notif: CryptopayWebhookDetails = + request + .body + .parse_struct("CryptopayWebhookDetails") + .change_context(errors::ConnectorError::WebhookBodyDecodingFailed)?; + Encode::::encode_to_value(¬if) + .change_context(errors::ConnectorError::WebhookBodyDecodingFailed) + } +} diff --git a/crates/router/src/connector/cryptopay/transformers.rs b/crates/router/src/connector/cryptopay/transformers.rs new file mode 100644 index 000000000000..25bcdfdc3138 --- /dev/null +++ b/crates/router/src/connector/cryptopay/transformers.rs @@ -0,0 +1,174 @@ +use masking::Secret; +use reqwest::Url; +use serde::{Deserialize, Serialize}; + +use crate::{ + connector::utils::CryptoData, + core::errors, + services, + types::{self, api, storage::enums}, +}; + +#[derive(Default, Debug, Serialize)] +pub struct CryptopayPaymentsRequest { + price_amount: i64, + price_currency: enums::Currency, + pay_currency: String, + success_redirect_url: Option, + unsuccess_redirect_url: Option, +} + +impl TryFrom<&types::PaymentsAuthorizeRouterData> for CryptopayPaymentsRequest { + type Error = error_stack::Report; + fn try_from(item: &types::PaymentsAuthorizeRouterData) -> Result { + let cryptopay_request = match item.request.payment_method_data { + api::PaymentMethodData::Crypto(ref cryptodata) => { + let pay_currency = cryptodata.get_pay_currency()?; + Ok(Self { + price_amount: item.request.amount, + price_currency: item.request.currency, + pay_currency, + success_redirect_url: item.clone().request.router_return_url, + unsuccess_redirect_url: item.clone().request.router_return_url, + }) + } + _ => Err(errors::ConnectorError::NotImplemented( + "payment method".to_string(), + )), + }?; + Ok(cryptopay_request) + } +} + +// Auth Struct +pub struct CryptopayAuthType { + pub(super) api_key: Secret, + pub(super) api_secret: Secret, +} + +impl TryFrom<&types::ConnectorAuthType> for CryptopayAuthType { + 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 { + api_key: api_key.to_string().into(), + api_secret: key1.to_string().into(), + }) + } else { + Err(errors::ConnectorError::FailedToObtainAuthType.into()) + } + } +} +// PaymentsResponse +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum CryptopayPaymentStatus { + #[default] + New, + Completed, + Unresolved, + Refunded, + Cancelled, +} + +impl From for enums::AttemptStatus { + fn from(item: CryptopayPaymentStatus) -> Self { + match item { + CryptopayPaymentStatus::New => Self::AuthenticationPending, + CryptopayPaymentStatus::Completed => Self::Charged, + CryptopayPaymentStatus::Cancelled => Self::Failure, + CryptopayPaymentStatus::Unresolved => Self::Unresolved, + _ => Self::Voided, + } + } +} + +#[derive(Default, Debug, Serialize, Deserialize)] +pub struct CryptopayPaymentsResponse { + data: CryptopayPaymentResponseData, +} + +impl + TryFrom> + for types::RouterData +{ + type Error = error_stack::Report; + fn try_from( + item: types::ResponseRouterData< + F, + CryptopayPaymentsResponse, + T, + types::PaymentsResponseData, + >, + ) -> Result { + let redirection_data = item + .response + .data + .hosted_page_url + .map(|x| services::RedirectForm::from((x, services::Method::Get))); + Ok(Self { + status: enums::AttemptStatus::from(item.response.data.status), + response: Ok(types::PaymentsResponseData::TransactionResponse { + resource_id: types::ResponseId::ConnectorTransactionId(item.response.data.id), + redirection_data, + mandate_reference: None, + connector_metadata: None, + network_txn_id: None, + }), + ..item.data + }) + } +} + +#[derive(Default, Debug, Serialize, Deserialize)] +pub struct CryptopayErrorData { + pub code: String, + pub message: String, + pub reason: Option, +} + +#[derive(Default, Debug, Serialize, Deserialize)] +pub struct CryptopayErrorResponse { + pub error: CryptopayErrorData, +} + +#[derive(Debug, Default, Serialize, Deserialize)] +pub struct CryptopayPaymentResponseData { + pub id: String, + pub customer_id: Option, + pub status: CryptopayPaymentStatus, + pub status_context: Option, + pub address: Option, + pub network: Option, + pub uri: Option, + pub price_amount: Option, + pub price_currency: Option, + pub pay_amount: Option, + pub pay_currency: Option, + pub fee: Option, + pub fee_currency: Option, + pub paid_amount: Option, + pub name: Option, + pub description: Option, + pub success_redirect_url: Option, + pub unsuccess_redirect_url: Option, + pub hosted_page_url: Option, + pub created_at: Option, + pub expires_at: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct CryptopayWebhookDetails { + #[serde(rename = "type")] + pub service_type: String, + pub event: WebhookEvent, + pub data: CryptopayPaymentResponseData, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum WebhookEvent { + TransactionCreated, + TransactionConfirmed, + StatusChanged, +} diff --git a/crates/router/src/connector/utils.rs b/crates/router/src/connector/utils.rs index c836914cb4e2..da16b7f0a94e 100644 --- a/crates/router/src/connector/utils.rs +++ b/crates/router/src/connector/utils.rs @@ -595,6 +595,19 @@ impl ApplePay for payments::ApplePayWalletData { Ok(token) } } + +pub trait CryptoData { + fn get_pay_currency(&self) -> Result; +} + +impl CryptoData for api::CryptoData { + fn get_pay_currency(&self) -> Result { + self.pay_currency + .clone() + .ok_or_else(missing_field_err("crypto_data.pay_currency")) + } +} + pub trait PhoneDetailsData { fn get_number(&self) -> Result, Error>; fn get_country_code(&self) -> Result; diff --git a/crates/router/src/core/payments/flows.rs b/crates/router/src/core/payments/flows.rs index 49657c0c3927..a695a7166530 100644 --- a/crates/router/src/core/payments/flows.rs +++ b/crates/router/src/core/payments/flows.rs @@ -145,6 +145,7 @@ default_imp_for_complete_authorize!( connector::Cashtocode, connector::Checkout, connector::Coinbase, + connector::Cryptopay, connector::Cybersource, connector::Dlocal, connector::Fiserv, @@ -206,6 +207,7 @@ default_imp_for_create_customer!( connector::Cashtocode, connector::Checkout, connector::Coinbase, + connector::Cryptopay, connector::Cybersource, connector::Dlocal, connector::Fiserv, @@ -270,6 +272,7 @@ default_imp_for_connector_redirect_response!( connector::Braintree, connector::Cashtocode, connector::Coinbase, + connector::Cryptopay, connector::Cybersource, connector::Dlocal, connector::Fiserv, @@ -313,6 +316,7 @@ default_imp_for_connector_request_id!( connector::Cashtocode, connector::Checkout, connector::Coinbase, + connector::Cryptopay, connector::Cybersource, connector::Dlocal, connector::Fiserv, @@ -380,6 +384,7 @@ default_imp_for_accept_dispute!( connector::Braintree, connector::Cashtocode, connector::Coinbase, + connector::Cryptopay, connector::Cybersource, connector::Dlocal, connector::Fiserv, @@ -468,6 +473,7 @@ default_imp_for_file_upload!( connector::Braintree, connector::Cashtocode, connector::Coinbase, + connector::Cryptopay, connector::Cybersource, connector::Dlocal, connector::Fiserv, @@ -534,6 +540,7 @@ default_imp_for_submit_evidence!( connector::Cashtocode, connector::Cybersource, connector::Coinbase, + connector::Cryptopay, connector::Dlocal, connector::Fiserv, connector::Forte, @@ -599,6 +606,7 @@ default_imp_for_defend_dispute!( connector::Cashtocode, connector::Cybersource, connector::Coinbase, + connector::Cryptopay, connector::Dlocal, connector::Fiserv, connector::Forte, @@ -665,6 +673,7 @@ default_imp_for_pre_processing_steps!( connector::Cashtocode, connector::Checkout, connector::Coinbase, + connector::Cryptopay, connector::Cybersource, connector::Dlocal, connector::Iatapay, diff --git a/crates/router/src/types/api.rs b/crates/router/src/types/api.rs index fea703307f3a..66edf241cb18 100644 --- a/crates/router/src/types/api.rs +++ b/crates/router/src/types/api.rs @@ -212,6 +212,7 @@ impl ConnectorData { enums::Connector::Cashtocode => Ok(Box::new(&connector::Cashtocode)), enums::Connector::Checkout => Ok(Box::new(&connector::Checkout)), enums::Connector::Coinbase => Ok(Box::new(&connector::Coinbase)), + enums::Connector::Cryptopay => Ok(Box::new(&connector::Cryptopay)), enums::Connector::Cybersource => Ok(Box::new(&connector::Cybersource)), enums::Connector::Dlocal => Ok(Box::new(&connector::Dlocal)), #[cfg(feature = "dummy_connector")] diff --git a/crates/router/src/types/api/payments.rs b/crates/router/src/types/api/payments.rs index 7cccae18e135..20e01e52d5a8 100644 --- a/crates/router/src/types/api/payments.rs +++ b/crates/router/src/types/api/payments.rs @@ -1,9 +1,9 @@ pub use api_models::payments::{ AcceptanceType, Address, AddressDetails, Amount, AuthenticationForStartResponse, Card, - CustomerAcceptance, MandateData, MandateTransactionType, MandateType, MandateValidationFields, - NextActionType, OnlineMandate, PayLaterData, PaymentIdType, PaymentListConstraints, - PaymentListResponse, PaymentMethodData, PaymentMethodDataResponse, PaymentOp, - PaymentRetrieveBody, PaymentRetrieveBodyWithCredentials, PaymentsCancelRequest, + CryptoData, CustomerAcceptance, MandateData, MandateTransactionType, MandateType, + MandateValidationFields, NextActionType, OnlineMandate, PayLaterData, PaymentIdType, + PaymentListConstraints, PaymentListResponse, PaymentMethodData, PaymentMethodDataResponse, + PaymentOp, PaymentRetrieveBody, PaymentRetrieveBodyWithCredentials, PaymentsCancelRequest, PaymentsCaptureRequest, PaymentsRedirectRequest, PaymentsRedirectionResponse, PaymentsRequest, PaymentsResponse, PaymentsResponseForm, PaymentsRetrieveRequest, PaymentsSessionRequest, PaymentsSessionResponse, PaymentsStartRequest, PgRedirectResponse, PhoneDetails, diff --git a/crates/router/tests/connectors/bitpay.rs b/crates/router/tests/connectors/bitpay.rs index 61818100a73e..615582b751c2 100644 --- a/crates/router/tests/connectors/bitpay.rs +++ b/crates/router/tests/connectors/bitpay.rs @@ -64,7 +64,9 @@ fn payment_method_details() -> Option { Some(types::PaymentsAuthorizeData { amount: 1, currency: enums::Currency::USD, - payment_method_data: types::api::PaymentMethodData::Crypto(CryptoData {}), + payment_method_data: types::api::PaymentMethodData::Crypto(CryptoData { + pay_currency: None, + }), confirm: true, statement_descriptor_suffix: None, statement_descriptor: None, diff --git a/crates/router/tests/connectors/coinbase.rs b/crates/router/tests/connectors/coinbase.rs index 809fa83b85eb..e811f4935b33 100644 --- a/crates/router/tests/connectors/coinbase.rs +++ b/crates/router/tests/connectors/coinbase.rs @@ -66,7 +66,9 @@ fn payment_method_details() -> Option { Some(types::PaymentsAuthorizeData { amount: 1, currency: enums::Currency::USD, - payment_method_data: types::api::PaymentMethodData::Crypto(CryptoData {}), + payment_method_data: types::api::PaymentMethodData::Crypto(CryptoData { + pay_currency: None, + }), confirm: true, statement_descriptor_suffix: None, statement_descriptor: None, diff --git a/crates/router/tests/connectors/connector_auth.rs b/crates/router/tests/connectors/connector_auth.rs index e99072dad44e..3651f29f56b4 100644 --- a/crates/router/tests/connectors/connector_auth.rs +++ b/crates/router/tests/connectors/connector_auth.rs @@ -16,6 +16,7 @@ pub struct ConnectorAuthentication { pub cashtocode: Option, pub checkout: Option, pub coinbase: Option, + pub cryptopay: Option, pub cybersource: Option, pub dlocal: Option, #[cfg(feature = "dummy_connector")] diff --git a/crates/router/tests/connectors/cryptopay.rs b/crates/router/tests/connectors/cryptopay.rs new file mode 100644 index 000000000000..597e394e7064 --- /dev/null +++ b/crates/router/tests/connectors/cryptopay.rs @@ -0,0 +1,149 @@ +use api_models::payments::CryptoData; +use masking::Secret; +use router::types::{self, api, storage::enums, PaymentAddress}; + +use crate::{ + connector_auth, + utils::{self, ConnectorActions}, +}; + +#[derive(Clone, Copy)] +struct CryptopayTest; +impl ConnectorActions for CryptopayTest {} +impl utils::Connector for CryptopayTest { + fn get_data(&self) -> types::api::ConnectorData { + use router::connector::Cryptopay; + types::api::ConnectorData { + connector: Box::new(&Cryptopay), + connector_name: types::Connector::Cryptopay, + get_token: types::api::GetToken::Connector, + } + } + + fn get_auth_token(&self) -> types::ConnectorAuthType { + types::ConnectorAuthType::from( + connector_auth::ConnectorAuthentication::new() + .cryptopay + .expect("Missing connector authentication configuration"), + ) + } + + fn get_name(&self) -> String { + "cryptopay".to_string() + } +} + +static CONNECTOR: CryptopayTest = CryptopayTest {}; + +fn get_default_payment_info() -> Option { + Some(utils::PaymentInfo { + address: Some(PaymentAddress { + billing: Some(api::Address { + address: Some(api::AddressDetails { + first_name: Some(Secret::new("first".to_string())), + last_name: Some(Secret::new("last".to_string())), + line1: Some(Secret::new("line1".to_string())), + line2: Some(Secret::new("line2".to_string())), + city: Some("city".to_string()), + zip: Some(Secret::new("zip".to_string())), + country: Some(api_models::enums::CountryAlpha2::IN), + ..Default::default() + }), + phone: Some(api::PhoneDetails { + number: Some(Secret::new("1234567890".to_string())), + country_code: Some("+91".to_string()), + }), + }), + ..Default::default() + }), + return_url: Some(String::from("https://google.com")), + ..Default::default() + }) +} + +fn payment_method_details() -> Option { + Some(types::PaymentsAuthorizeData { + amount: 1, + currency: enums::Currency::USD, + payment_method_data: types::api::PaymentMethodData::Crypto(CryptoData { + pay_currency: Some("XRP".to_string()), + }), + confirm: true, + statement_descriptor_suffix: None, + statement_descriptor: None, + setup_future_usage: None, + mandate_id: None, + off_session: None, + setup_mandate_details: None, + browser_info: None, + order_details: None, + order_category: None, + email: None, + payment_experience: None, + payment_method_type: None, + session_token: None, + enrolled_for_3ds: false, + related_transaction_id: None, + router_return_url: Some(String::from("https://google.com/")), + webhook_url: None, + complete_authorize_url: None, + capture_method: None, + customer_id: None, + }) +} + +// Creates a payment using the manual capture flow +#[actix_web::test] +async fn should_only_authorize_payment() { + let response = CONNECTOR + .authorize_payment(payment_method_details(), get_default_payment_info()) + .await + .expect("Authorize payment response"); + assert_eq!(response.status, enums::AttemptStatus::AuthenticationPending); + let resp = response.response.ok().unwrap(); + let endpoint = match resp { + types::PaymentsResponseData::TransactionResponse { + redirection_data, .. + } => Some(redirection_data), + _ => None, + }; + assert!(endpoint.is_some()) +} + +// Synchronizes a successful transaction. +#[actix_web::test] +async fn should_sync_authorized_payment() { + let response = CONNECTOR + .psync_retry_till_status_matches( + enums::AttemptStatus::Authorized, + Some(types::PaymentsSyncData { + connector_transaction_id: router::types::ResponseId::ConnectorTransactionId( + "ea684036-2b54-44fa-bffe-8256650dce7c".to_string(), + ), + ..Default::default() + }), + get_default_payment_info(), + ) + .await + .expect("PSync response"); + assert_eq!(response.status, enums::AttemptStatus::Charged); +} + +// Synchronizes a unresolved(underpaid) transaction. +#[actix_web::test] +async fn should_sync_unresolved_payment() { + let response = CONNECTOR + .psync_retry_till_status_matches( + enums::AttemptStatus::Authorized, + Some(types::PaymentsSyncData { + connector_transaction_id: router::types::ResponseId::ConnectorTransactionId( + "7993d4c2-efbc-4360-b8ce-d1e957e6f827".to_string(), + ), + ..Default::default() + }), + get_default_payment_info(), + ) + .await + .expect("PSync response"); + assert_eq!(response.status, enums::AttemptStatus::Unresolved); +} diff --git a/crates/router/tests/connectors/main.rs b/crates/router/tests/connectors/main.rs index 58dcd13c44eb..9eef3ceba650 100644 --- a/crates/router/tests/connectors/main.rs +++ b/crates/router/tests/connectors/main.rs @@ -21,6 +21,7 @@ mod checkout; mod checkout_ui; mod coinbase; mod connector_auth; +mod cryptopay; mod cybersource; mod dlocal; #[cfg(feature = "dummy_connector")] diff --git a/crates/router/tests/connectors/opennode.rs b/crates/router/tests/connectors/opennode.rs index 8382c9cc6d39..8d43d6dcc76c 100644 --- a/crates/router/tests/connectors/opennode.rs +++ b/crates/router/tests/connectors/opennode.rs @@ -65,7 +65,9 @@ fn payment_method_details() -> Option { Some(types::PaymentsAuthorizeData { amount: 1, currency: enums::Currency::USD, - payment_method_data: types::api::PaymentMethodData::Crypto(CryptoData {}), + payment_method_data: types::api::PaymentMethodData::Crypto(CryptoData { + pay_currency: None, + }), confirm: true, statement_descriptor_suffix: None, statement_descriptor: None, diff --git a/crates/router/tests/connectors/sample_auth.toml b/crates/router/tests/connectors/sample_auth.toml index f65d56a2d4fe..451fccab1d28 100644 --- a/crates/router/tests/connectors/sample_auth.toml +++ b/crates/router/tests/connectors/sample_auth.toml @@ -130,6 +130,10 @@ pypl_pass="" gmail_email="" gmail_pass="" +[cryptopay] +api_key = "api_key" +key1 = "key1" + [payme] api_key="API Key" diff --git a/loadtest/config/development.toml b/loadtest/config/development.toml index 5446b38ebe19..ee2838661984 100644 --- a/loadtest/config/development.toml +++ b/loadtest/config/development.toml @@ -69,6 +69,7 @@ braintree.base_url = "https://api.sandbox.braintreegateway.com/" cashtocode.base_url = "https://cluster05.api-test.cashtocode.com" checkout.base_url = "https://api.sandbox.checkout.com/" coinbase.base_url = "https://api.commerce.coinbase.com" +cryptopay.base_url = "https://business-sandbox.cryptopay.me" cybersource.base_url = "https://apitest.cybersource.com/" dlocal.base_url = "https://sandbox.dlocal.com/" dummyconnector.base_url = "http://localhost:8080/dummy-connector" @@ -113,6 +114,7 @@ cards = [ "braintree", "checkout", "coinbase", + "cryptopay", "cybersource", "dlocal", "dummyconnector", diff --git a/scripts/add_connector.sh b/scripts/add_connector.sh index bd39144070c9..8b0ffb00fe58 100755 --- a/scripts/add_connector.sh +++ b/scripts/add_connector.sh @@ -4,7 +4,7 @@ function find_prev_connector() { git checkout $self cp $self $self.tmp # add new connector to existing list and sort it - connectors=(aci adyen airwallex applepay authorizedotnet bambora bitpay bluesnap braintree cashtocode checkout coinbase cybersource dlocal dummyconnector fiserv forte globalpay iatapay klarna mollie multisafepay nexinets noon nuvei opayo opennode payeezy payme paypal payu rapyd shift4 stripe trustpay worldline worldpay "$1") + connectors=(aci adyen airwallex applepay authorizedotnet bambora bitpay bluesnap braintree cashtocode checkout coinbase cryptopay cybersource dlocal dummyconnector fiserv forte globalpay iatapay klarna mollie multisafepay nexinets noon nuvei opayo opennode payeezy payme paypal payu rapyd shift4 stripe trustpay worldline worldpay "$1") IFS=$'\n' sorted=($(sort <<<"${connectors[*]}")); unset IFS res=`echo ${sorted[@]}` sed -i'' -e "s/^ connectors=.*/ connectors=($res \"\$1\")/" $self.tmp