diff --git a/Cargo.lock b/Cargo.lock index 3812b1bb85..b14223062d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3015,6 +3015,7 @@ dependencies = [ "once_cell", "rand 0.8.5", "redis_interface", + "regex", "reqwest", "ring", "router_derive", diff --git a/config/Development.toml b/config/Development.toml index fa61e28810..e0315b066f 100644 --- a/config/Development.toml +++ b/config/Development.toml @@ -93,6 +93,9 @@ base_url = "https://secure.snd.payu.com/api/" [connectors.globalpay] base_url = "https://apis.sandbox.globalpay.com/ucp/" +[connectors.worldline] +base_url = "https://eu.sandbox.api-ingenico.com/" + [scheduler] stream = "SCHEDULER_STREAM" consumer_group = "SCHEDULER_GROUP" diff --git a/crates/api_models/src/enums.rs b/crates/api_models/src/enums.rs index 83ead25c35..0c77ca85bf 100644 --- a/crates/api_models/src/enums.rs +++ b/crates/api_models/src/enums.rs @@ -509,6 +509,7 @@ pub enum Connector { Payu, Shift4, Stripe, + Worldline, Worldpay, } diff --git a/crates/router/Cargo.toml b/crates/router/Cargo.toml index 9c95e070ab..45aeb8d377 100644 --- a/crates/router/Cargo.toml +++ b/crates/router/Cargo.toml @@ -53,6 +53,7 @@ nanoid = "0.4.0" num_cpus = "1.15.0" once_cell = "1.17.0" rand = "0.8.5" +regex = "1.7.1" reqwest = { version = "0.11.13", features = ["json", "native-tls", "gzip"] } ring = "0.16.20" serde = { version = "1.0.152", features = ["derive"] } diff --git a/crates/router/src/configs/settings.rs b/crates/router/src/configs/settings.rs index 3aeb0fc041..1f66cb22ff 100644 --- a/crates/router/src/configs/settings.rs +++ b/crates/router/src/configs/settings.rs @@ -134,6 +134,7 @@ pub struct Connectors { pub shift4: ConnectorParams, pub stripe: ConnectorParams, pub supported: SupportedConnectors, + pub worldline: ConnectorParams, pub worldpay: ConnectorParams, } diff --git a/crates/router/src/connector.rs b/crates/router/src/connector.rs index 2b9ef18559..4c218454d9 100644 --- a/crates/router/src/connector.rs +++ b/crates/router/src/connector.rs @@ -12,11 +12,12 @@ pub mod payu; pub mod shift4; pub mod stripe; pub mod utils; +pub mod worldline; 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, - worldpay::Worldpay, + worldline::Worldline, worldpay::Worldpay, }; diff --git a/crates/router/src/connector/worldline.rs b/crates/router/src/connector/worldline.rs new file mode 100644 index 0000000000..50419ee2b9 --- /dev/null +++ b/crates/router/src/connector/worldline.rs @@ -0,0 +1,606 @@ +mod transformers; + +use std::fmt::Debug; + +use base64::Engine; +use bytes::Bytes; +use error_stack::{IntoReport, ResultExt}; +use ring::hmac; +use time::{format_description, OffsetDateTime}; +use transformers as worldline; + +use crate::{ + configs::settings::Connectors, + consts, + core::errors::{self, CustomResult}, + headers, logger, + services::{self, ConnectorIntegration}, + types::{ + self, + api::{self, ConnectorCommon}, + ErrorResponse, Response, + }, + utils::{self, BytesExt, OptionExt}, +}; + +#[derive(Debug, Clone)] +pub struct Worldline; + +impl Worldline { + pub fn generate_authorization_token( + &self, + auth: worldline::AuthType, + http_method: &services::Method, + content_type: &str, + date: &str, + endpoint: &str, + ) -> CustomResult { + let signature_data: String = format!( + "{}\n{}\n{}\n/{}\n", + http_method, + content_type.trim(), + date.trim(), + endpoint.trim() + ); + let worldline::AuthType { + api_key, + api_secret, + .. + } = auth; + let key = hmac::Key::new(hmac::HMAC_SHA256, api_secret.as_bytes()); + let signed_data = consts::BASE64_ENGINE.encode(hmac::sign(&key, signature_data.as_bytes())); + + Ok(format!("GCS v1HMAC:{api_key}:{signed_data}")) + } + + pub fn get_current_date_time() -> CustomResult { + let format = format_description::parse( + "[weekday repr:short], [day] [month repr:short] [year] [hour]:[minute]:[second] GMT", + ) + .into_report() + .change_context(errors::ConnectorError::InvalidDateFormat)?; + OffsetDateTime::now_utc() + .format(&format) + .into_report() + .change_context(errors::ConnectorError::InvalidDateFormat) + } +} + +impl ConnectorCommon for Worldline { + fn id(&self) -> &'static str { + "worldline" + } + + fn base_url<'a>(&self, connectors: &'a Connectors) -> &'a str { + connectors.worldline.base_url.as_ref() + } + + fn build_error_response( + &self, + res: Bytes, + ) -> CustomResult { + let response: worldline::ErrorResponse = res + .parse_struct("Worldline ErrorResponse") + .change_context(errors::ConnectorError::ResponseDeserializationFailed)?; + let error = response.errors.into_iter().next().unwrap_or_default(); + Ok(ErrorResponse { + code: error + .code + .unwrap_or_else(|| consts::NO_ERROR_CODE.to_string()), + message: error + .message + .unwrap_or_else(|| consts::NO_ERROR_MESSAGE.to_string()), + ..Default::default() + }) + } +} + +impl api::Payment for Worldline {} + +impl api::PreVerify for Worldline {} +impl ConnectorIntegration + for Worldline +{ +} + +impl api::PaymentVoid for Worldline {} + +impl ConnectorIntegration + for Worldline +{ + fn get_headers( + &self, + req: &types::RouterData, + connectors: &Connectors, + ) -> CustomResult, errors::ConnectorError> { + let base_url = self.base_url(connectors); + let url = &types::PaymentsVoidType::get_url(self, req, connectors)?; + let endpoint = url.clone().replace(base_url, ""); + let http_method = services::Method::Post; + let auth = worldline::AuthType::try_from(&req.connector_auth_type)?; + let date = Self::get_current_date_time()?; + let content_type = types::PaymentsAuthorizeType::get_content_type(self); + let signed_data: String = + self.generate_authorization_token(auth, &http_method, content_type, &date, &endpoint)?; + + Ok(vec![ + (headers::DATE.to_string(), date), + (headers::AUTHORIZATION.to_string(), signed_data), + (headers::CONTENT_TYPE.to_string(), content_type.to_string()), + ]) + } + + fn get_content_type(&self) -> &'static str { + "application/json" + } + + fn get_url( + &self, + req: &types::PaymentsCancelRouterData, + connectors: &Connectors, + ) -> CustomResult { + let base_url = self.base_url(connectors); + let auth: worldline::AuthType = worldline::AuthType::try_from(&req.connector_auth_type)?; + let merchat_account_id = auth.merchant_account_id; + let payment_id: &str = req.request.connector_transaction_id.as_ref(); + Ok(format!( + "{base_url}v1/{merchat_account_id}/payments/{payment_id}/cancel" + )) + } + + fn build_request( + &self, + req: &types::RouterData, + connectors: &Connectors, + ) -> CustomResult, errors::ConnectorError> { + Ok(Some( + services::RequestBuilder::new() + .method(services::Method::Post) + .url(&types::PaymentsVoidType::get_url(self, req, connectors)?) + .headers(types::PaymentsVoidType::get_headers(self, req, connectors)?) + .build(), + )) + } + + fn handle_response( + &self, + data: &types::PaymentsCancelRouterData, + res: Response, + ) -> CustomResult { + let response: worldline::PaymentResponse = res + .response + .parse_struct("PaymentResponse") + .change_context(errors::ConnectorError::ResponseDeserializationFailed)?; + logger::debug!(payments_cancel_response=?response); + types::RouterData::try_from(types::ResponseRouterData { + response, + data: data.clone(), + http_code: res.status_code, + }) + .change_context(errors::ConnectorError::ResponseHandlingFailed) + } + + fn get_error_response( + &self, + res: Bytes, + ) -> CustomResult { + self.build_error_response(res) + } +} + +impl api::PaymentSync for Worldline {} +impl ConnectorIntegration + for Worldline +{ + fn get_headers( + &self, + req: &types::RouterData, + connectors: &Connectors, + ) -> CustomResult, errors::ConnectorError> { + let base_url = self.base_url(connectors); + let url = &types::PaymentsSyncType::get_url(self, req, connectors)?; + let endpoint = url.clone().replace(base_url, ""); + let auth = worldline::AuthType::try_from(&req.connector_auth_type)?; + let date = Self::get_current_date_time()?; + let signed_data: String = + self.generate_authorization_token(auth, &services::Method::Get, "", &date, &endpoint)?; + Ok(vec![ + (headers::DATE.to_string(), date), + (headers::AUTHORIZATION.to_string(), signed_data), + ]) + } + + fn get_url( + &self, + req: &types::PaymentsSyncRouterData, + connectors: &Connectors, + ) -> CustomResult { + let payment_id = req + .request + .connector_transaction_id + .get_connector_transaction_id() + .change_context(errors::ConnectorError::MissingConnectorTransactionID)?; + let base_url = self.base_url(connectors); + let auth = worldline::AuthType::try_from(&req.connector_auth_type)?; + let merchat_account_id = auth.merchant_account_id; + Ok(format!( + "{base_url}v1/{merchat_account_id}/payments/{payment_id}" + )) + } + + fn build_request( + &self, + req: &types::PaymentsSyncRouterData, + connectors: &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, connectors)?) + .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: worldline::Payment = res + .response + .parse_struct("Payment") + .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 Worldline {} +impl ConnectorIntegration + for Worldline +{ + // Not Implemented +} + +impl api::PaymentSession for Worldline {} + +impl ConnectorIntegration + for Worldline +{ + // Not Implemented +} + +impl api::PaymentAuthorize for Worldline {} + +impl ConnectorIntegration + for Worldline +{ + fn get_headers( + &self, + req: &types::RouterData< + api::Authorize, + types::PaymentsAuthorizeData, + types::PaymentsResponseData, + >, + connectors: &Connectors, + ) -> CustomResult, errors::ConnectorError> { + let base_url = self.base_url(connectors); + let url = &types::PaymentsAuthorizeType::get_url(self, req, connectors)?; + let endpoint = url.clone().replace(base_url, ""); + let auth = worldline::AuthType::try_from(&req.connector_auth_type)?; + let date = Self::get_current_date_time()?; + let content_type = types::PaymentsAuthorizeType::get_content_type(self); + let signed_data: String = self.generate_authorization_token( + auth, + &services::Method::Post, + content_type, + &date, + &endpoint, + )?; + + Ok(vec![ + (headers::DATE.to_string(), date), + (headers::AUTHORIZATION.to_string(), signed_data), + (headers::CONTENT_TYPE.to_string(), content_type.to_string()), + ]) + } + + fn get_content_type(&self) -> &'static str { + "application/json" + } + + fn get_url( + &self, + req: &types::PaymentsAuthorizeRouterData, + connectors: &Connectors, + ) -> CustomResult { + let base_url = self.base_url(connectors); + let auth = worldline::AuthType::try_from(&req.connector_auth_type)?; + let merchat_account_id = auth.merchant_account_id; + Ok(format!("{base_url}v1/{merchat_account_id}/payments")) + } + + fn get_request_body( + &self, + req: &types::PaymentsAuthorizeRouterData, + ) -> CustomResult, errors::ConnectorError> { + let worldline_req = utils::Encode::::convert_and_encode(req) + .change_context(errors::ConnectorError::RequestEncodingFailed)?; + Ok(Some(worldline_req)) + } + + fn build_request( + &self, + req: &types::RouterData< + api::Authorize, + types::PaymentsAuthorizeData, + types::PaymentsResponseData, + >, + connectors: &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, connectors, + )?) + .body(types::PaymentsAuthorizeType::get_request_body(self, req)?) + .build(), + )) + } + fn handle_response( + &self, + data: &types::PaymentsAuthorizeRouterData, + res: Response, + ) -> CustomResult { + let response: worldline::PaymentResponse = res + .response + .parse_struct("PaymentIntentResponse") + .change_context(errors::ConnectorError::ResponseDeserializationFailed)?; + logger::debug!(worldlinepayments_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 Worldline {} +impl api::RefundExecute for Worldline {} +impl api::RefundSync for Worldline {} + +impl ConnectorIntegration + for Worldline +{ + fn get_headers( + &self, + req: &types::RefundsRouterData, + connectors: &Connectors, + ) -> CustomResult, errors::ConnectorError> { + let base_url = self.base_url(connectors); + let url = &types::RefundExecuteType::get_url(self, req, connectors)?; + let endpoint = url.clone().replace(base_url, ""); + let auth = worldline::AuthType::try_from(&req.connector_auth_type)?; + let date = Self::get_current_date_time()?; + let content_type = types::RefundExecuteType::get_content_type(self); + let signed_data: String = self.generate_authorization_token( + auth, + &services::Method::Post, + content_type, + &date, + &endpoint, + )?; + + Ok(vec![ + (headers::DATE.to_string(), date), + (headers::AUTHORIZATION.to_string(), signed_data), + (headers::CONTENT_TYPE.to_string(), content_type.to_string()), + ]) + } + + fn get_content_type(&self) -> &'static str { + "application/json" + } + + fn get_url( + &self, + req: &types::RefundsRouterData, + connectors: &Connectors, + ) -> CustomResult { + let payment_id = req.request.connector_transaction_id.clone(); + let base_url = self.base_url(connectors); + let auth = worldline::AuthType::try_from(&req.connector_auth_type)?; + let merchat_account_id = auth.merchant_account_id; + Ok(format!( + "{base_url}v1/{merchat_account_id}/payments/{payment_id}/refund" + )) + } + + fn get_request_body( + &self, + req: &types::RefundsRouterData, + ) -> CustomResult, errors::ConnectorError> { + let refund_req = + utils::Encode::::convert_and_encode(req) + .change_context(errors::ConnectorError::RequestEncodingFailed)?; + Ok(Some(refund_req)) + } + + fn build_request( + &self, + req: &types::RefundsRouterData, + connectors: &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, connectors, + )?) + .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::worldline", response=?res); + let response: worldline::RefundResponse = res + .response + .parse_struct("worldline 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 { + self.build_error_response(res) + } +} + +impl ConnectorIntegration + for Worldline +{ + fn get_headers( + &self, + req: &types::RefundSyncRouterData, + connectors: &Connectors, + ) -> CustomResult, errors::ConnectorError> { + let base_url = self.base_url(connectors); + let url = &types::RefundSyncType::get_url(self, req, connectors)?; + let endpoint = url.clone().replace(base_url, ""); + let auth = worldline::AuthType::try_from(&req.connector_auth_type)?; + let date = Self::get_current_date_time()?; + let signed_data: String = + self.generate_authorization_token(auth, &services::Method::Get, "", &date, &endpoint)?; + + Ok(vec![ + (headers::DATE.to_string(), date), + (headers::AUTHORIZATION.to_string(), signed_data), + ]) + } + + fn get_url( + &self, + req: &types::RefundSyncRouterData, + connectors: &Connectors, + ) -> CustomResult { + let refund_id = req + .response + .as_ref() + .ok() + .get_required_value("response") + .change_context(errors::ConnectorError::FailedToObtainIntegrationUrl)? + .connector_refund_id + .clone(); + let base_url = self.base_url(connectors); + let auth: worldline::AuthType = worldline::AuthType::try_from(&req.connector_auth_type)?; + let merchat_account_id = auth.merchant_account_id; + Ok(format!( + "{base_url}v1/{merchat_account_id}/refunds/{refund_id}/" + )) + } + + fn build_request( + &self, + req: &types::RefundSyncRouterData, + connectors: &Connectors, + ) -> CustomResult, errors::ConnectorError> { + Ok(Some( + services::RequestBuilder::new() + .method(services::Method::Get) + .url(&types::RefundSyncType::get_url(self, req, connectors)?) + .headers(types::RefundSyncType::get_headers(self, req, connectors)?) + .build(), + )) + } + + fn handle_response( + &self, + data: &types::RefundSyncRouterData, + res: Response, + ) -> CustomResult { + logger::debug!(target: "router::connector::worldline", response=?res); + let response: worldline::RefundResponse = res + .response + .parse_struct("worldline 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 Worldline { + 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 Worldline {} diff --git a/crates/router/src/connector/worldline/transformers.rs b/crates/router/src/connector/worldline/transformers.rs new file mode 100644 index 0000000000..0c958a4ffe --- /dev/null +++ b/crates/router/src/connector/worldline/transformers.rs @@ -0,0 +1,487 @@ +use std::collections::HashMap; + +use api_models::payments as api_models; +use common_utils::pii::{self, Email}; +use error_stack::{IntoReport, ResultExt}; +use masking::{PeekInterface, Secret}; +use once_cell::sync::Lazy; +use regex::Regex; +use serde::{Deserialize, Serialize}; + +use crate::{ + core::errors, + types::{self, api, storage::enums}, +}; + +static CARD_REGEX: Lazy>> = Lazy::new(|| { + let mut map = HashMap::new(); + // Reference: https://gist.github.com/michaelkeevildown/9096cd3aac9029c4e6e05588448a8841 + // [#379]: Determine card issuer from card BIN number + map.insert(CardProduct::Master, Regex::new(r"^5[1-5][0-9]{14}$")); + map.insert( + CardProduct::AmericanExpress, + Regex::new(r"^3[47][0-9]{13}$"), + ); + map.insert(CardProduct::Visa, Regex::new(r"^4[0-9]{12}(?:[0-9]{3})?$")); + map.insert(CardProduct::Discover, Regex::new(r"^65[4-9][0-9]{13}|64[4-9][0-9]{13}|6011[0-9]{12}|(622(?:12[6-9]|1[3-9][0-9]|[2-8][0-9][0-9]|9[01][0-9]|92[0-5])[0-9]{10})$")); + map +}); + +#[derive(Default, Debug, Serialize, Eq, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct Card { + pub card_number: Secret, + pub cardholder_name: Secret, + pub cvv: Secret, + pub expiry_date: Secret, +} + +#[derive(Default, Debug, Serialize, Eq, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct CardPaymentMethod { + pub card: Card, + pub requires_approval: bool, + pub payment_product_id: u16, +} + +#[derive(Default, Debug, Serialize, Eq, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct AmountOfMoney { + pub amount: i64, + pub currency_code: String, +} + +#[derive(Default, Debug, Serialize, Eq, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct Order { + pub amount_of_money: AmountOfMoney, + pub customer: Customer, +} + +#[derive(Default, Debug, Serialize, Eq, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct BillingAddress { + pub city: Option, + pub country_code: Option, + pub house_number: Option, + pub state: Option>, + pub state_code: Option, + pub street: Option, + pub zip: Option>, +} + +#[derive(Default, Debug, Serialize, Eq, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct ContactDetails { + pub email_address: Option>, + pub mobile_phone_number: Option>, +} + +#[derive(Default, Debug, Serialize, Eq, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct Customer { + pub billing_address: BillingAddress, + pub contact_details: Option, +} + +#[derive(Default, Debug, Serialize, Eq, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct Name { + pub first_name: Option>, + pub surname: Option>, + pub surname_prefix: Option>, + pub title: Option>, +} + +#[derive(Default, Debug, Serialize, Eq, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct Shipping { + pub city: Option, + pub country_code: Option, + pub house_number: Option, + pub name: Option, + pub state: Option>, + pub state_code: Option, + pub street: Option, + pub zip: Option>, +} + +#[derive(Default, Debug, Serialize, Eq, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct PaymentsRequest { + pub card_payment_method_specific_input: CardPaymentMethod, + pub order: Order, + pub shipping: Option, +} + +impl TryFrom<&types::PaymentsAuthorizeRouterData> for PaymentsRequest { + type Error = error_stack::Report; + fn try_from(item: &types::PaymentsAuthorizeRouterData) -> Result { + match item.request.payment_method_data { + api::PaymentMethod::Card(ref card) => { + make_card_request(&item.address, &item.request, card) + } + _ => Err(errors::ConnectorError::NotImplemented("Payment methods".to_string()).into()), + } + } +} + +fn make_card_request( + address: &types::PaymentAddress, + req: &types::PaymentsAuthorizeData, + ccard: &api_models::CCard, +) -> Result> { + let card_number = ccard.card_number.peek().as_ref(); + let expiry_year = ccard.card_exp_year.peek().clone(); + let secret_value = format!("{}{}", ccard.card_exp_month.peek(), &expiry_year[2..]); + let expiry_date: Secret = Secret::new(secret_value); + let card = Card { + card_number: ccard.card_number.clone(), + cardholder_name: ccard.card_holder_name.clone(), + cvv: ccard.card_cvc.clone(), + expiry_date, + }; + let payment_product_id = get_card_product_id(card_number)?; + let card_payment_method_specific_input = CardPaymentMethod { + card, + requires_approval: matches!(req.capture_method, Some(enums::CaptureMethod::Manual)), + payment_product_id, + }; + + let customer = build_customer_info(address, &req.email)?; + + let order = Order { + amount_of_money: AmountOfMoney { + amount: req.amount, + currency_code: req.currency.to_string().to_uppercase(), + }, + customer, + }; + + let shipping = address + .shipping + .as_ref() + .and_then(|shipping| shipping.address.clone()) + .map(|address| Shipping { ..address.into() }); + + Ok(PaymentsRequest { + card_payment_method_specific_input, + order, + shipping, + }) +} + +fn get_card_product_id( + card_number: &str, +) -> Result> { + for (k, v) in CARD_REGEX.iter() { + let regex: Regex = v + .clone() + .into_report() + .change_context(errors::ConnectorError::RequestEncodingFailed)?; + if regex.is_match(card_number) { + return Ok(k.product_id()); + } + } + Err(error_stack::Report::new( + errors::ConnectorError::RequestEncodingFailed, + )) +} + +fn get_address( + payment_address: &types::PaymentAddress, +) -> Option<(&api_models::Address, &api_models::AddressDetails)> { + let billing = payment_address.billing.as_ref()?; + let address = billing.address.as_ref()?; + address.country.as_ref()?; + Some((billing, address)) +} + +fn build_customer_info( + payment_address: &types::PaymentAddress, + email: &Option>, +) -> Result> { + let (billing, address) = + get_address(payment_address).ok_or(errors::ConnectorError::RequestEncodingFailed)?; + + let number_with_country_code = billing.phone.as_ref().and_then(|phone| { + phone.number.as_ref().and_then(|number| { + phone + .country_code + .as_ref() + .map(|cc| Secret::new(format!("{}{}", cc, number.peek()))) + }) + }); + + Ok(Customer { + billing_address: BillingAddress { + ..address.clone().into() + }, + contact_details: Some(ContactDetails { + mobile_phone_number: number_with_country_code, + email_address: email.clone(), + }), + }) +} + +impl From for BillingAddress { + fn from(value: api_models::AddressDetails) -> Self { + Self { + city: value.city, + country_code: value.country, + state: value.state, + zip: value.zip, + ..Default::default() + } + } +} + +impl From for Shipping { + fn from(value: api_models::AddressDetails) -> Self { + Self { + city: value.city, + country_code: value.country, + name: Some(Name { + first_name: value.first_name, + surname: value.last_name, + ..Default::default() + }), + state: value.state, + zip: value.zip, + ..Default::default() + } + } +} + +pub struct AuthType { + pub api_key: String, + pub api_secret: String, + pub merchant_account_id: String, +} + +impl TryFrom<&types::ConnectorAuthType> for AuthType { + type Error = error_stack::Report; + fn try_from(auth_type: &types::ConnectorAuthType) -> Result { + if let types::ConnectorAuthType::SignatureKey { + api_key, + key1, + api_secret, + } = auth_type + { + Ok(Self { + api_key: api_key.to_string(), + api_secret: api_secret.to_string(), + merchant_account_id: key1.to_string(), + }) + } else { + Err(errors::ConnectorError::FailedToObtainAuthType)? + } + } +} + +#[derive(Debug, Clone, Default, Deserialize, PartialEq)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum PaymentStatus { + Captured, + Paid, + ChargebackNotification, + Cancelled, + Rejected, + RejectedCapture, + PendingApproval, + CaptureRequested, + #[default] + Processing, +} + +impl From for enums::AttemptStatus { + fn from(item: PaymentStatus) -> Self { + match item { + PaymentStatus::Captured + | PaymentStatus::Paid + | PaymentStatus::ChargebackNotification => Self::Charged, + PaymentStatus::Cancelled => Self::Voided, + PaymentStatus::Rejected | PaymentStatus::RejectedCapture => Self::Failure, + PaymentStatus::CaptureRequested => Self::CaptureInitiated, + PaymentStatus::PendingApproval => Self::Authorizing, + _ => Self::Pending, + } + } +} + +#[derive(Default, Debug, Clone, Deserialize, PartialEq)] +pub struct Payment { + id: String, + status: PaymentStatus, +} + +impl TryFrom> + for types::RouterData +{ + type Error = error_stack::Report; + fn try_from( + item: types::ResponseRouterData, + ) -> Result { + Ok(Self { + status: enums::AttemptStatus::from(item.response.status.clone()), + response: Ok(types::PaymentsResponseData::TransactionResponse { + resource_id: types::ResponseId::ConnectorTransactionId(item.response.id), + redirection_data: None, + redirect: false, + mandate_reference: None, + connector_metadata: None, + }), + ..item.data + }) + } +} + +#[derive(Default, Debug, Clone, Deserialize, PartialEq)] +pub struct PaymentResponse { + payment: Payment, +} + +impl TryFrom> + for types::RouterData +{ + type Error = error_stack::Report; + fn try_from( + item: types::ResponseRouterData, + ) -> Result { + Ok(Self { + status: enums::AttemptStatus::from(item.response.payment.status.clone()), + response: Ok(types::PaymentsResponseData::TransactionResponse { + resource_id: types::ResponseId::ConnectorTransactionId(item.response.payment.id), + redirection_data: None, + redirect: false, + mandate_reference: None, + connector_metadata: None, + }), + ..item.data + }) + } +} + +#[derive(Default, Debug, Serialize)] +pub struct WorldlineRefundRequest { + amount_of_money: AmountOfMoney, +} + +impl TryFrom<&types::RefundsRouterData> for WorldlineRefundRequest { + type Error = error_stack::Report; + fn try_from(item: &types::RefundsRouterData) -> Result { + Ok(Self { + amount_of_money: AmountOfMoney { + amount: item.request.refund_amount, + currency_code: item.request.currency.to_string(), + }, + }) + } +} + +#[allow(dead_code)] +#[derive(Debug, Default, Deserialize, Clone)] +#[serde(rename_all = "UPPERCASE")] +pub enum RefundStatus { + Cancelled, + Rejected, + Refunded, + #[default] + Processing, +} + +impl From for enums::RefundStatus { + fn from(item: RefundStatus) -> Self { + match item { + RefundStatus::Refunded => Self::Success, + RefundStatus::Cancelled | RefundStatus::Rejected => Self::Failure, + RefundStatus::Processing => Self::Pending, + } + } +} + +#[derive(Default, Debug, Clone, Deserialize)] +pub struct RefundResponse { + id: String, + status: RefundStatus, +} + +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.clone(), + 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.clone(), + refund_status, + }), + ..item.data + }) + } +} + +impl From<&PaymentResponse> for enums::AttemptStatus { + fn from(item: &PaymentResponse) -> Self { + if item.payment.status == PaymentStatus::Cancelled { + Self::Voided + } else { + Self::VoidFailed + } + } +} + +#[derive(Default, Debug, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct Error { + pub code: Option, + pub property_name: Option, + pub message: Option, +} + +#[derive(Default, Debug, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct ErrorResponse { + pub error_id: Option, + pub errors: Vec, +} + +#[derive(Debug, Eq, Hash, PartialEq)] +pub enum CardProduct { + AmericanExpress, + Master, + Visa, + Discover, +} + +impl CardProduct { + fn product_id(&self) -> u16 { + match *self { + Self::AmericanExpress => 2, + Self::Master => 3, + Self::Visa => 1, + Self::Discover => 128, + } + } +} diff --git a/crates/router/src/core/errors.rs b/crates/router/src/core/errors.rs index 899969d55d..f506f242dc 100644 --- a/crates/router/src/core/errors.rs +++ b/crates/router/src/core/errors.rs @@ -242,6 +242,8 @@ pub enum ConnectorError { WebhookEventTypeNotFound, #[error("Incoming webhook event resource object not found")] WebhookResourceObjectNotFound, + #[error("Invalid Date/time format")] + InvalidDateFormat, } #[derive(Debug, thiserror::Error)] diff --git a/crates/router/src/lib.rs b/crates/router/src/lib.rs index e130a70707..becbecdaeb 100644 --- a/crates/router/src/lib.rs +++ b/crates/router/src/lib.rs @@ -47,6 +47,7 @@ pub mod headers { pub const AUTHORIZATION: &str = "Authorization"; pub const ACCEPT: &str = "Accept"; pub const X_API_VERSION: &str = "X-ApiVersion"; + pub const DATE: &str = "Date"; } pub mod pii { diff --git a/crates/router/src/types/api.rs b/crates/router/src/types/api.rs index a14ad31c1d..0c21a8a45c 100644 --- a/crates/router/src/types/api.rs +++ b/crates/router/src/types/api.rs @@ -152,6 +152,7 @@ impl ConnectorData { "payu" => Ok(Box::new(&connector::Payu)), "shift4" => Ok(Box::new(&connector::Shift4)), "stripe" => Ok(Box::new(&connector::Stripe)), + "worldline" => Ok(Box::new(&connector::Worldline)), "worldpay" => Ok(Box::new(&connector::Worldpay)), _ => Err(report!(errors::ConnectorError::InvalidConnectorName) .attach_printable(format!("invalid connector name: {connector_name}"))) diff --git a/crates/router/tests/connectors/connector_auth.rs b/crates/router/tests/connectors/connector_auth.rs index 2237f0994d..9bb2cabdcc 100644 --- a/crates/router/tests/connectors/connector_auth.rs +++ b/crates/router/tests/connectors/connector_auth.rs @@ -11,6 +11,7 @@ pub(crate) struct ConnectorAuthentication { pub payu: Option, pub shift4: Option, pub worldpay: Option, + pub worldline: Option, } impl ConnectorAuthentication { diff --git a/crates/router/tests/connectors/main.rs b/crates/router/tests/connectors/main.rs index 3d5af329ed..39bb16e7a4 100644 --- a/crates/router/tests/connectors/main.rs +++ b/crates/router/tests/connectors/main.rs @@ -9,4 +9,5 @@ mod globalpay; mod payu; mod shift4; mod utils; +mod worldline; mod worldpay; diff --git a/crates/router/tests/connectors/sample_auth.toml b/crates/router/tests/connectors/sample_auth.toml index 463e6460b5..a161b71c07 100644 --- a/crates/router/tests/connectors/sample_auth.toml +++ b/crates/router/tests/connectors/sample_auth.toml @@ -31,3 +31,7 @@ api_key = "MyApiKey" key1 = "MerchantID" api_secret = "MySecretKey" +[worldline] +key1 = "Merchant Id" +api_key = "API Key" +api_secret = "API Secret Key" \ No newline at end of file diff --git a/crates/router/tests/connectors/worldline.rs b/crates/router/tests/connectors/worldline.rs new file mode 100644 index 0000000000..c8aa01549e --- /dev/null +++ b/crates/router/tests/connectors/worldline.rs @@ -0,0 +1,278 @@ +use api_models::payments::{Address, AddressDetails}; +use masking::Secret; +use router::{ + connector::Worldline, + types::{self, storage::enums, PaymentAddress}, +}; + +use crate::{ + connector_auth::ConnectorAuthentication, + utils::{self, ConnectorActions, PaymentInfo}, +}; + +struct WorldlineTest; + +impl ConnectorActions for WorldlineTest {} +impl utils::Connector for WorldlineTest { + fn get_data(&self) -> types::api::ConnectorData { + types::api::ConnectorData { + connector: Box::new(&Worldline), + connector_name: types::Connector::Worldline, + get_token: types::api::GetToken::Connector, + } + } + + fn get_auth_token(&self) -> types::ConnectorAuthType { + types::ConnectorAuthType::from( + ConnectorAuthentication::new() + .worldline + .expect("Missing connector authentication configuration"), + ) + } + + fn get_name(&self) -> String { + String::from("worldline") + } +} + +impl WorldlineTest { + fn get_payment_info() -> Option { + Some(PaymentInfo { + address: Some(PaymentAddress { + billing: Some(Address { + address: Some(AddressDetails { + country: Some("US".to_string()), + ..Default::default() + }), + phone: None, + }), + ..Default::default() + }), + auth_type: None, + }) + } + + fn get_payment_authorize_data( + card_number: &str, + card_exp_month: &str, + card_exp_year: &str, + card_cvc: &str, + capture_method: enums::CaptureMethod, + ) -> Option { + Some(types::PaymentsAuthorizeData { + amount: 3500, + currency: enums::Currency::USD, + payment_method_data: types::api::PaymentMethod::Card(types::api::CCard { + card_number: Secret::new(card_number.to_string()), + card_exp_month: Secret::new(card_exp_month.to_string()), + card_exp_year: Secret::new(card_exp_year.to_string()), + card_holder_name: Secret::new("John Doe".to_string()), + card_cvc: Secret::new(card_cvc.to_string()), + }), + confirm: true, + statement_descriptor_suffix: None, + setup_future_usage: None, + mandate_id: None, + off_session: None, + setup_mandate_details: None, + capture_method: Some(capture_method), + browser_info: None, + order_details: None, + email: None, + }) + } +} + +#[actix_web::test] +async fn should_requires_manual_authorization() { + let authorize_data = WorldlineTest::get_payment_authorize_data( + "4012000033330026", + "10", + "2025", + "123", + enums::CaptureMethod::Manual, + ); + let response = WorldlineTest {} + .make_payment(authorize_data, WorldlineTest::get_payment_info()) + .await; + assert_eq!(response.status, enums::AttemptStatus::Authorizing); +} + +#[actix_web::test] +async fn should_auto_authorize_and_request_capture() { + let authorize_data = WorldlineTest::get_payment_authorize_data( + "4012000033330026", + "10", + "2025", + "123", + enums::CaptureMethod::Automatic, + ); + let response = WorldlineTest {} + .make_payment(authorize_data, WorldlineTest::get_payment_info()) + .await; + assert_eq!(response.status, enums::AttemptStatus::CaptureInitiated); +} + +#[actix_web::test] +async fn should_fail_payment_for_invalid_cvc() { + let authorize_data = WorldlineTest::get_payment_authorize_data( + "4012000033330026", + "10", + "2025", + "", + enums::CaptureMethod::Automatic, + ); + let response = WorldlineTest {} + .make_payment(authorize_data, WorldlineTest::get_payment_info()) + .await; + assert_eq!( + response.response.unwrap_err().message, + "NULL VALUE NOT ALLOWED FOR cardPaymentMethodSpecificInput.card.cvv".to_string(), + ); +} + +#[actix_web::test] +async fn should_sync_manual_auth_payment() { + let connector = WorldlineTest {}; + let authorize_data = WorldlineTest::get_payment_authorize_data( + "4012000033330026", + "10", + "2025", + "123", + enums::CaptureMethod::Manual, + ); + let response = connector + .make_payment(authorize_data, WorldlineTest::get_payment_info()) + .await; + assert_eq!(response.status, enums::AttemptStatus::Authorizing); + let connector_payment_id = utils::get_connector_transaction_id(response).unwrap_or_default(); + let sync_response = connector + .sync_payment( + Some(types::PaymentsSyncData { + connector_transaction_id: router::types::ResponseId::ConnectorTransactionId( + connector_payment_id, + ), + encoded_data: None, + }), + None, + ) + .await; + assert_eq!(sync_response.status, enums::AttemptStatus::Authorizing); +} + +#[actix_web::test] +async fn should_sync_auto_auth_payment() { + let connector = WorldlineTest {}; + let authorize_data = WorldlineTest::get_payment_authorize_data( + "4012000033330026", + "10", + "2025", + "123", + enums::CaptureMethod::Automatic, + ); + let response = connector + .make_payment(authorize_data, WorldlineTest::get_payment_info()) + .await; + assert_eq!(response.status, enums::AttemptStatus::CaptureInitiated); + let connector_payment_id = utils::get_connector_transaction_id(response).unwrap_or_default(); + let sync_response = connector + .sync_payment( + Some(types::PaymentsSyncData { + connector_transaction_id: router::types::ResponseId::ConnectorTransactionId( + connector_payment_id, + ), + encoded_data: None, + }), + None, + ) + .await; + assert_eq!(sync_response.status, enums::AttemptStatus::CaptureInitiated); +} + +#[actix_web::test] +async fn should_fail_capture_payment() { + let capture_response = WorldlineTest {} + .capture_payment("123456789".to_string(), None, None) + .await; + assert_eq!( + capture_response.response.unwrap_err().message, + "Something went wrong.".to_string() + ); +} + +#[actix_web::test] +async fn should_cancel_unauthorized_payment() { + let connector = WorldlineTest {}; + let authorize_data = WorldlineTest::get_payment_authorize_data( + "4012000033330026", + "10", + "2025", + "123", + enums::CaptureMethod::Manual, + ); + let response = connector + .make_payment(authorize_data, WorldlineTest::get_payment_info()) + .await; + assert_eq!(response.status, enums::AttemptStatus::Authorizing); + let connector_payment_id = utils::get_connector_transaction_id(response).unwrap_or_default(); + let cancel_response = connector + .void_payment(connector_payment_id, None, None) + .await; + assert_eq!(cancel_response.status, enums::AttemptStatus::Voided); +} + +#[actix_web::test] +async fn should_cancel_uncaptured_payment() { + let connector = WorldlineTest {}; + let authorize_data = WorldlineTest::get_payment_authorize_data( + "4012000033330026", + "10", + "2025", + "123", + enums::CaptureMethod::Automatic, + ); + let response = connector + .make_payment(authorize_data, WorldlineTest::get_payment_info()) + .await; + assert_eq!(response.status, enums::AttemptStatus::CaptureInitiated); + let connector_payment_id = utils::get_connector_transaction_id(response).unwrap_or_default(); + let cancel_response = connector + .void_payment(connector_payment_id, None, None) + .await; + assert_eq!(cancel_response.status, enums::AttemptStatus::Voided); +} + +#[actix_web::test] +async fn should_fail_cancel_with_invalid_payment_id() { + let response = WorldlineTest {} + .void_payment("123456789".to_string(), None, None) + .await; + assert_eq!( + response.response.unwrap_err().message, + "UNKNOWN_PAYMENT_ID".to_string(), + ); +} + +#[actix_web::test] +async fn should_fail_refund_with_invalid_payment_status() { + let connector = WorldlineTest {}; + let authorize_data = WorldlineTest::get_payment_authorize_data( + "4012000033330026", + "10", + "2025", + "123", + enums::CaptureMethod::Manual, + ); + let response = connector + .make_payment(authorize_data, WorldlineTest::get_payment_info()) + .await; + assert_eq!(response.status, enums::AttemptStatus::Authorizing); + let connector_payment_id = utils::get_connector_transaction_id(response).unwrap_or_default(); + let refund_response = connector + .refund_payment(connector_payment_id, None, None) + .await; + assert_eq!( + refund_response.response.unwrap_err().message, + "ORDER WITHOUT REFUNDABLE PAYMENTS".to_string(), + ); +}