From 7a581a669cd7b95cd68b80f1429f38098fcd1601 Mon Sep 17 00:00:00 2001 From: Prasunna Soppa <70575890+prasunna09@users.noreply.github.com> Date: Tue, 18 Apr 2023 00:00:16 +0530 Subject: [PATCH] feat(connector) : add template code for connector forte (#854) --- Cargo.lock | 10 +- config/Development.toml | 2 + config/config.example.toml | 1 + config/docker_compose.toml | 2 + connector-template/mod.rs | 23 +- crates/api_models/src/enums.rs | 2 + crates/router/src/configs/settings.rs | 1 + crates/router/src/connector.rs | 3 +- crates/router/src/connector/forte.rs | 497 ++++++++++++++++++ .../src/connector/forte/transformers.rs | 195 +++++++ crates/router/src/core/payments/flows.rs | 3 + crates/router/src/types/api.rs | 1 + .../router/tests/connectors/connector_auth.rs | 1 + crates/router/tests/connectors/forte.rs | 465 ++++++++++++++++ crates/router/tests/connectors/main.rs | 1 + .../router/tests/connectors/sample_auth.toml | 4 + loadtest/config/Development.toml | 2 + scripts/add_connector.sh | 2 +- 18 files changed, 1202 insertions(+), 13 deletions(-) create mode 100644 crates/router/src/connector/forte.rs create mode 100644 crates/router/src/connector/forte/transformers.rs create mode 100644 crates/router/tests/connectors/forte.rs diff --git a/Cargo.lock b/Cargo.lock index ff9c280597..b2225445c7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2758,7 +2758,7 @@ dependencies = [ [[package]] name = "opentelemetry" version = "0.18.0" -source = "git+https://github.com/open-telemetry/opentelemetry-rust?rev=44b90202fd744598db8b0ace5b8f0bad7ec45658#44b90202fd744598db8b0ace5b8f0bad7ec45658" +source = "git+https://github.com/open-telemetry/opentelemetry-rust/?rev=44b90202fd744598db8b0ace5b8f0bad7ec45658#44b90202fd744598db8b0ace5b8f0bad7ec45658" dependencies = [ "opentelemetry_api", "opentelemetry_sdk", @@ -2767,7 +2767,7 @@ dependencies = [ [[package]] name = "opentelemetry-otlp" version = "0.11.0" -source = "git+https://github.com/open-telemetry/opentelemetry-rust?rev=44b90202fd744598db8b0ace5b8f0bad7ec45658#44b90202fd744598db8b0ace5b8f0bad7ec45658" +source = "git+https://github.com/open-telemetry/opentelemetry-rust/?rev=44b90202fd744598db8b0ace5b8f0bad7ec45658#44b90202fd744598db8b0ace5b8f0bad7ec45658" dependencies = [ "async-trait", "futures", @@ -2784,7 +2784,7 @@ dependencies = [ [[package]] name = "opentelemetry-proto" version = "0.1.0" -source = "git+https://github.com/open-telemetry/opentelemetry-rust?rev=44b90202fd744598db8b0ace5b8f0bad7ec45658#44b90202fd744598db8b0ace5b8f0bad7ec45658" +source = "git+https://github.com/open-telemetry/opentelemetry-rust/?rev=44b90202fd744598db8b0ace5b8f0bad7ec45658#44b90202fd744598db8b0ace5b8f0bad7ec45658" dependencies = [ "futures", "futures-util", @@ -2796,7 +2796,7 @@ dependencies = [ [[package]] name = "opentelemetry_api" version = "0.18.0" -source = "git+https://github.com/open-telemetry/opentelemetry-rust?rev=44b90202fd744598db8b0ace5b8f0bad7ec45658#44b90202fd744598db8b0ace5b8f0bad7ec45658" +source = "git+https://github.com/open-telemetry/opentelemetry-rust/?rev=44b90202fd744598db8b0ace5b8f0bad7ec45658#44b90202fd744598db8b0ace5b8f0bad7ec45658" dependencies = [ "fnv", "futures-channel", @@ -2811,7 +2811,7 @@ dependencies = [ [[package]] name = "opentelemetry_sdk" version = "0.18.0" -source = "git+https://github.com/open-telemetry/opentelemetry-rust?rev=44b90202fd744598db8b0ace5b8f0bad7ec45658#44b90202fd744598db8b0ace5b8f0bad7ec45658" +source = "git+https://github.com/open-telemetry/opentelemetry-rust/?rev=44b90202fd744598db8b0ace5b8f0bad7ec45658#44b90202fd744598db8b0ace5b8f0bad7ec45658" dependencies = [ "async-trait", "crossbeam-channel", diff --git a/config/Development.toml b/config/Development.toml index 259aedc2cb..6886d16566 100644 --- a/config/Development.toml +++ b/config/Development.toml @@ -64,6 +64,7 @@ cards = [ "cybersource", "dlocal", "fiserv", + "forte", "globalpay", "mollie", "multisafepay", @@ -106,6 +107,7 @@ coinbase.base_url = "https://api.commerce.coinbase.com" cybersource.base_url = "https://apitest.cybersource.com/" dlocal.base_url = "https://sandbox.dlocal.com/" fiserv.base_url = "https://cert.api.fiservapps.com/" +forte.base_url = "https://sandbox.forte.net/api/v3" globalpay.base_url = "https://apis.sandbox.globalpay.com/ucp/" klarna.base_url = "https://api-na.playground.klarna.com/" mollie.base_url = "https://api.mollie.com/v2/" diff --git a/config/config.example.toml b/config/config.example.toml index 5d3af96963..9ab7759f54 100644 --- a/config/config.example.toml +++ b/config/config.example.toml @@ -155,6 +155,7 @@ coinbase.base_url = "https://api.commerce.coinbase.com" cybersource.base_url = "https://apitest.cybersource.com/" dlocal.base_url = "https://sandbox.dlocal.com/" fiserv.base_url = "https://cert.api.fiservapps.com/" +forte.base_url = "https://sandbox.forte.net/api/v3" globalpay.base_url = "https://apis.sandbox.globalpay.com/ucp/" klarna.base_url = "https://api-na.playground.klarna.com/" mollie.base_url = "https://api.mollie.com/v2/" diff --git a/config/docker_compose.toml b/config/docker_compose.toml index 5d2844ba99..612ffc8116 100644 --- a/config/docker_compose.toml +++ b/config/docker_compose.toml @@ -82,6 +82,7 @@ coinbase.base_url = "https://api.commerce.coinbase.com" cybersource.base_url = "https://apitest.cybersource.com/" dlocal.base_url = "https://sandbox.dlocal.com/" fiserv.base_url = "https://cert.api.fiservapps.com/" +forte.base_url = "https://sandbox.forte.net/api/v3" globalpay.base_url = "https://apis.sandbox.globalpay.com/ucp/" klarna.base_url = "https://api-na.playground.klarna.com/" mollie.base_url = "https://api.mollie.com/v2/" @@ -115,6 +116,7 @@ cards = [ "cybersource", "dlocal", "fiserv", + "forte", "globalpay", "mollie", "multisafepay", diff --git a/connector-template/mod.rs b/connector-template/mod.rs index 7bcf4d30fb..80330a9742 100644 --- a/connector-template/mod.rs +++ b/connector-template/mod.rs @@ -35,6 +35,17 @@ impl api::PaymentVoid for {{project-name | downcase | pascal_case}} {} impl api::Refund for {{project-name | downcase | pascal_case}} {} impl api::RefundExecute for {{project-name | downcase | pascal_case}} {} impl api::RefundSync for {{project-name | downcase | pascal_case}} {} +impl api::PaymentToken for {{project-name | downcase | pascal_case}} {} + +impl + ConnectorIntegration< + api::PaymentMethodToken, + types::PaymentMethodTokenizationData, + types::PaymentsResponseData, + > for {{project-name | downcase | pascal_case}} +{ + // Not Implemented (R) +} impl ConnectorCommonExt for {{project-name | downcase | pascal_case}} where @@ -80,7 +91,7 @@ impl ConnectorCommon for {{project-name | downcase | pascal_case}} { let response: {{project-name | downcase}}::{{project-name | downcase | pascal_case}}ErrorResponse = res .response .parse_struct("{{project-name | downcase | pascal_case}}ErrorResponse") - .switch()?; + .change_context(errors::ConnectorError::ResponseDeserializationFailed)?; Ok(ErrorResponse { status_code: res.status_code, @@ -168,7 +179,7 @@ impl data: &types::PaymentsAuthorizeRouterData, res: Response, ) -> CustomResult { - let response: {{project-name | downcase}}::{{project-name | downcase | pascal_case}}PaymentsResponse = res.response.parse_struct("{{project-name | downcase | pascal_case}} PaymentsAuthorizeResponse").switch()?; + let response: {{project-name | downcase}}::{{project-name | downcase | pascal_case}}PaymentsResponse = res.response.parse_struct("{{project-name | downcase | pascal_case}} PaymentsAuthorizeResponse").change_context(errors::ConnectorError::ResponseDeserializationFailed)?; types::RouterData::try_from(types::ResponseRouterData { response, data: data.clone(), @@ -229,7 +240,7 @@ impl let response: {{project-name | downcase}}:: {{project-name | downcase | pascal_case}}PaymentsResponse = res .response .parse_struct("{{project-name | downcase}} PaymentsSyncResponse") - .switch()?; + .change_context(errors::ConnectorError::ResponseDeserializationFailed)?; types::RouterData::try_from(types::ResponseRouterData { response, data: data.clone(), @@ -305,7 +316,7 @@ impl let response: {{project-name | downcase }}::{{project-name | downcase | pascal_case}}PaymentsResponse = res .response .parse_struct("{{project-name | downcase | pascal_case}} PaymentsCaptureResponse") - .switch()?; + .change_context(errors::ConnectorError::ResponseDeserializationFailed)?; types::RouterData::try_from(types::ResponseRouterData { response, data: data.clone(), @@ -374,7 +385,7 @@ impl data: &types::RefundsRouterData, res: Response, ) -> CustomResult,errors::ConnectorError> { - let response: {{project-name| downcase}}::RefundResponse = res.response.parse_struct("{{project-name | downcase}} RefundResponse").switch()?; + let response: {{project-name| downcase}}::RefundResponse = res.response.parse_struct("{{project-name | downcase}} RefundResponse").change_context(errors::ConnectorError::ResponseDeserializationFailed)?; types::RouterData::try_from(types::ResponseRouterData { response, data: data.clone(), @@ -423,7 +434,7 @@ impl data: &types::RefundSyncRouterData, res: Response, ) -> CustomResult { - let response: {{project-name | downcase}}::RefundResponse = res.response.parse_struct("{{project-name | downcase}} RefundSyncResponse").switch()?; + let response: {{project-name | downcase}}::RefundResponse = res.response.parse_struct("{{project-name | downcase}} RefundSyncResponse").change_context(errors::ConnectorError::ResponseDeserializationFailed)?; types::RouterData::try_from(types::ResponseRouterData { response, data: data.clone(), diff --git a/crates/api_models/src/enums.rs b/crates/api_models/src/enums.rs index 6c6bf24de2..44e8c9e583 100644 --- a/crates/api_models/src/enums.rs +++ b/crates/api_models/src/enums.rs @@ -576,6 +576,7 @@ pub enum Connector { Bambora, Dlocal, Fiserv, + //Forte, Globalpay, Klarna, Mollie, @@ -632,6 +633,7 @@ pub enum RoutableConnectors { Cybersource, Dlocal, Fiserv, + //Forte, Globalpay, Klarna, Mollie, diff --git a/crates/router/src/configs/settings.rs b/crates/router/src/configs/settings.rs index 23587497b1..cb96daeeb6 100644 --- a/crates/router/src/configs/settings.rs +++ b/crates/router/src/configs/settings.rs @@ -293,6 +293,7 @@ pub struct Connectors { pub cybersource: ConnectorParams, pub dlocal: ConnectorParams, pub fiserv: ConnectorParams, + pub forte: ConnectorParams, pub globalpay: ConnectorParams, pub klarna: ConnectorParams, pub mollie: ConnectorParams, diff --git a/crates/router/src/connector.rs b/crates/router/src/connector.rs index e9cbe411dc..05d5179eb2 100644 --- a/crates/router/src/connector.rs +++ b/crates/router/src/connector.rs @@ -11,6 +11,7 @@ pub mod coinbase; pub mod cybersource; pub mod dlocal; pub mod fiserv; +pub mod forte; pub mod globalpay; pub mod klarna; pub mod multisafepay; @@ -33,7 +34,7 @@ pub use self::{ aci::Aci, adyen::Adyen, airwallex::Airwallex, applepay::Applepay, authorizedotnet::Authorizedotnet, bambora::Bambora, bluesnap::Bluesnap, braintree::Braintree, checkout::Checkout, coinbase::Coinbase, cybersource::Cybersource, dlocal::Dlocal, - fiserv::Fiserv, globalpay::Globalpay, klarna::Klarna, mollie::Mollie, + fiserv::Fiserv, forte::Forte, globalpay::Globalpay, klarna::Klarna, mollie::Mollie, multisafepay::Multisafepay, nuvei::Nuvei, opennode::Opennode, payeezy::Payeezy, paypal::Paypal, payu::Payu, rapyd::Rapyd, shift4::Shift4, stripe::Stripe, trustpay::Trustpay, worldline::Worldline, worldpay::Worldpay, diff --git a/crates/router/src/connector/forte.rs b/crates/router/src/connector/forte.rs new file mode 100644 index 0000000000..3e834c2ed7 --- /dev/null +++ b/crates/router/src/connector/forte.rs @@ -0,0 +1,497 @@ +mod transformers; + +use std::fmt::Debug; + +use error_stack::{IntoReport, ResultExt}; +use transformers as forte; + +use crate::{ + configs::settings, + core::errors::{self, CustomResult}, + headers, + services::{self, ConnectorIntegration}, + types::{ + self, + api::{self, ConnectorCommon, ConnectorCommonExt}, + ErrorResponse, Response, + }, + utils::{self, BytesExt}, +}; + +#[derive(Debug, Clone)] +pub struct Forte; + +impl api::Payment for Forte {} +impl api::PaymentSession for Forte {} +impl api::ConnectorAccessToken for Forte {} +impl api::PreVerify for Forte {} +impl api::PaymentAuthorize for Forte {} +impl api::PaymentSync for Forte {} +impl api::PaymentCapture for Forte {} +impl api::PaymentVoid for Forte {} +impl api::Refund for Forte {} +impl api::RefundExecute for Forte {} +impl api::RefundSync for Forte {} +impl api::PaymentToken for Forte {} + +impl + ConnectorIntegration< + api::PaymentMethodToken, + types::PaymentMethodTokenizationData, + types::PaymentsResponseData, + > for Forte +{ +} + +impl ConnectorCommonExt for Forte +where + Self: ConnectorIntegration, +{ + fn build_headers( + &self, + req: &types::RouterData, + _connectors: &settings::Connectors, + ) -> CustomResult, errors::ConnectorError> { + let mut header = vec![( + headers::CONTENT_TYPE.to_string(), + types::PaymentsAuthorizeType::get_content_type(self).to_string(), + )]; + let mut api_key = self.get_auth_header(&req.connector_auth_type)?; + header.append(&mut api_key); + Ok(header) + } +} + +impl ConnectorCommon for Forte { + fn id(&self) -> &'static str { + "forte" + } + + fn common_get_content_type(&self) -> &'static str { + "application/json" + } + + fn base_url<'a>(&self, connectors: &'a settings::Connectors) -> &'a str { + connectors.forte.base_url.as_ref() + } + + fn get_auth_header( + &self, + auth_type: &types::ConnectorAuthType, + ) -> CustomResult, errors::ConnectorError> { + let auth = forte::ForteAuthType::try_from(auth_type) + .change_context(errors::ConnectorError::FailedToObtainAuthType)?; + Ok(vec![(headers::AUTHORIZATION.to_string(), auth.api_key)]) + } + + fn build_error_response( + &self, + res: Response, + ) -> CustomResult { + let response: forte::ForteErrorResponse = + res.response + .parse_struct("ForteErrorResponse") + .change_context(errors::ConnectorError::ResponseDeserializationFailed)?; + + Ok(ErrorResponse { + status_code: res.status_code, + code: response.code, + message: response.message, + reason: response.reason, + }) + } +} + +impl ConnectorIntegration + for Forte +{ +} + +impl ConnectorIntegration + for Forte +{ +} + +impl ConnectorIntegration + for Forte +{ +} + +impl ConnectorIntegration + for Forte +{ + 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 { + Err(errors::ConnectorError::NotImplemented("get_url method".to_string()).into()) + } + + fn get_request_body( + &self, + req: &types::PaymentsAuthorizeRouterData, + ) -> CustomResult, errors::ConnectorError> { + let req_obj = forte::FortePaymentsRequest::try_from(req)?; + let forte_req = + utils::Encode::::encode_to_string_of_json(&req_obj) + .change_context(errors::ConnectorError::RequestEncodingFailed)?; + Ok(Some(forte_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: forte::FortePaymentsResponse = res + .response + .parse_struct("Forte PaymentsAuthorizeResponse") + .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_error_response( + &self, + res: Response, + ) -> CustomResult { + self.build_error_response(res) + } +} + +impl ConnectorIntegration + for Forte +{ + 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 { + Err(errors::ConnectorError::NotImplemented("get_url method".to_string()).into()) + } + + 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: forte::FortePaymentsResponse = res + .response + .parse_struct("forte PaymentsSyncResponse") + .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_error_response( + &self, + res: Response, + ) -> CustomResult { + self.build_error_response(res) + } +} + +impl ConnectorIntegration + for Forte +{ + fn get_headers( + &self, + req: &types::PaymentsCaptureRouterData, + 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::PaymentsCaptureRouterData, + _connectors: &settings::Connectors, + ) -> CustomResult { + Err(errors::ConnectorError::NotImplemented("get_url method".to_string()).into()) + } + + fn get_request_body( + &self, + _req: &types::PaymentsCaptureRouterData, + ) -> CustomResult, errors::ConnectorError> { + Err(errors::ConnectorError::NotImplemented("get_request_body method".to_string()).into()) + } + + fn build_request( + &self, + req: &types::PaymentsCaptureRouterData, + connectors: &settings::Connectors, + ) -> CustomResult, errors::ConnectorError> { + Ok(Some( + services::RequestBuilder::new() + .method(services::Method::Post) + .url(&types::PaymentsCaptureType::get_url(self, req, connectors)?) + .attach_default_headers() + .headers(types::PaymentsCaptureType::get_headers( + self, req, connectors, + )?) + .build(), + )) + } + + fn handle_response( + &self, + data: &types::PaymentsCaptureRouterData, + res: Response, + ) -> CustomResult { + let response: forte::FortePaymentsResponse = res + .response + .parse_struct("Forte PaymentsCaptureResponse") + .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_error_response( + &self, + res: Response, + ) -> CustomResult { + self.build_error_response(res) + } +} + +impl ConnectorIntegration + for Forte +{ +} + +impl ConnectorIntegration for Forte { + fn get_headers( + &self, + req: &types::RefundsRouterData, + 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::RefundsRouterData, + _connectors: &settings::Connectors, + ) -> CustomResult { + Err(errors::ConnectorError::NotImplemented("get_url method".to_string()).into()) + } + + fn get_request_body( + &self, + req: &types::RefundsRouterData, + ) -> CustomResult, errors::ConnectorError> { + let req_obj = forte::ForteRefundRequest::try_from(req)?; + let forte_req = + utils::Encode::::encode_to_string_of_json(&req_obj) + .change_context(errors::ConnectorError::RequestEncodingFailed)?; + Ok(Some(forte_req)) + } + + fn build_request( + &self, + req: &types::RefundsRouterData, + connectors: &settings::Connectors, + ) -> CustomResult, errors::ConnectorError> { + let request = services::RequestBuilder::new() + .method(services::Method::Post) + .url(&types::RefundExecuteType::get_url(self, req, connectors)?) + .attach_default_headers() + .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> { + let response: forte::RefundResponse = res + .response + .parse_struct("forte RefundResponse") + .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_error_response( + &self, + res: Response, + ) -> CustomResult { + self.build_error_response(res) + } +} + +impl ConnectorIntegration for Forte { + fn get_headers( + &self, + req: &types::RefundSyncRouterData, + 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::RefundSyncRouterData, + _connectors: &settings::Connectors, + ) -> CustomResult { + Err(errors::ConnectorError::NotImplemented("get_url method".to_string()).into()) + } + + fn build_request( + &self, + req: &types::RefundSyncRouterData, + connectors: &settings::Connectors, + ) -> CustomResult, errors::ConnectorError> { + Ok(Some( + services::RequestBuilder::new() + .method(services::Method::Get) + .url(&types::RefundSyncType::get_url(self, req, connectors)?) + .attach_default_headers() + .headers(types::RefundSyncType::get_headers(self, req, connectors)?) + .body(types::RefundSyncType::get_request_body(self, req)?) + .build(), + )) + } + + fn handle_response( + &self, + data: &types::RefundSyncRouterData, + res: Response, + ) -> CustomResult { + let response: forte::RefundResponse = res + .response + .parse_struct("forte RefundSyncResponse") + .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_error_response( + &self, + res: Response, + ) -> CustomResult { + self.build_error_response(res) + } +} + +#[async_trait::async_trait] +impl api::IncomingWebhook for Forte { + fn get_webhook_object_reference_id( + &self, + _request: &api::IncomingWebhookRequestDetails<'_>, + ) -> CustomResult { + Err(errors::ConnectorError::WebhooksNotImplemented).into_report() + } + + fn get_webhook_event_type( + &self, + _request: &api::IncomingWebhookRequestDetails<'_>, + ) -> CustomResult { + Err(errors::ConnectorError::WebhooksNotImplemented).into_report() + } + + fn get_webhook_resource_object( + &self, + _request: &api::IncomingWebhookRequestDetails<'_>, + ) -> CustomResult { + Err(errors::ConnectorError::WebhooksNotImplemented).into_report() + } +} diff --git a/crates/router/src/connector/forte/transformers.rs b/crates/router/src/connector/forte/transformers.rs new file mode 100644 index 0000000000..f3c4a9bd82 --- /dev/null +++ b/crates/router/src/connector/forte/transformers.rs @@ -0,0 +1,195 @@ +use masking::Secret; +use serde::{Deserialize, Serialize}; + +use crate::{ + connector::utils::PaymentsAuthorizeRequestData, + core::errors, + types::{self, api, storage::enums}, +}; + +#[derive(Default, Debug, Serialize, Eq, PartialEq)] +pub struct FortePaymentsRequest { + amount: i64, + card: ForteCard, +} + +#[derive(Default, Debug, Serialize, Eq, PartialEq)] +pub struct ForteCard { + name: Secret, + number: Secret, + expiry_month: Secret, + expiry_year: Secret, + cvc: Secret, + complete: bool, +} + +impl TryFrom<&types::PaymentsAuthorizeRouterData> for FortePaymentsRequest { + type Error = error_stack::Report; + fn try_from(item: &types::PaymentsAuthorizeRouterData) -> Result { + match item.request.payment_method_data.clone() { + api::PaymentMethodData::Card(req_card) => { + let card = ForteCard { + name: req_card.card_holder_name, + number: req_card.card_number, + expiry_month: req_card.card_exp_month, + expiry_year: req_card.card_exp_year, + cvc: req_card.card_cvc, + complete: item.request.is_auto_capture(), + }; + Ok(Self { + amount: item.request.amount, + card, + }) + } + _ => Err(errors::ConnectorError::NotImplemented("Payment methods".to_string()).into()), + } + } +} + +// Auth Struct +pub struct ForteAuthType { + pub(super) api_key: String, +} + +impl TryFrom<&types::ConnectorAuthType> for ForteAuthType { + type Error = error_stack::Report; + fn try_from(auth_type: &types::ConnectorAuthType) -> Result { + match auth_type { + types::ConnectorAuthType::HeaderKey { api_key } => Ok(Self { + api_key: api_key.to_string(), + }), + _ => Err(errors::ConnectorError::FailedToObtainAuthType.into()), + } + } +} +// PaymentsResponse +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "lowercase")] +pub enum FortePaymentStatus { + Succeeded, + Failed, + #[default] + Processing, +} + +impl From for enums::AttemptStatus { + fn from(item: FortePaymentStatus) -> Self { + match item { + FortePaymentStatus::Succeeded => Self::Charged, + FortePaymentStatus::Failed => Self::Failure, + FortePaymentStatus::Processing => Self::Authorizing, + } + } +} + +#[derive(Default, Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct FortePaymentsResponse { + status: FortePaymentStatus, + id: String, +} + +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), + response: Ok(types::PaymentsResponseData::TransactionResponse { + resource_id: types::ResponseId::ConnectorTransactionId(item.response.id), + redirection_data: None, + mandate_reference: None, + connector_metadata: None, + }), + ..item.data + }) + } +} + +// REFUND : +// Type definition for RefundRequest +#[derive(Default, Debug, Serialize)] +pub struct ForteRefundRequest { + pub amount: i64, +} + +impl TryFrom<&types::RefundsRouterData> for ForteRefundRequest { + type Error = error_stack::Report; + fn try_from(item: &types::RefundsRouterData) -> Result { + Ok(Self { + amount: item.request.amount, + }) + } +} + +// Type definition for Refund Response + +#[allow(dead_code)] +#[derive(Debug, Serialize, Default, Deserialize, Clone)] +pub enum RefundStatus { + Succeeded, + Failed, + #[default] + Processing, +} + +impl From for enums::RefundStatus { + fn from(item: RefundStatus) -> Self { + match item { + RefundStatus::Succeeded => Self::Success, + RefundStatus::Failed => Self::Failure, + RefundStatus::Processing => Self::Pending, + } + } +} + +#[derive(Default, Debug, Clone, Serialize, 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 { + Ok(Self { + response: Ok(types::RefundsResponseData { + connector_refund_id: item.response.id.to_string(), + refund_status: enums::RefundStatus::from(item.response.status), + }), + ..item.data + }) + } +} + +impl TryFrom> + for types::RefundsRouterData +{ + type Error = error_stack::Report; + fn try_from( + item: types::RefundsResponseRouterData, + ) -> Result { + Ok(Self { + response: Ok(types::RefundsResponseData { + connector_refund_id: item.response.id.to_string(), + refund_status: enums::RefundStatus::from(item.response.status), + }), + ..item.data + }) + } +} + +#[derive(Default, Debug, Serialize, Deserialize, PartialEq)] +pub struct ForteErrorResponse { + pub status_code: u16, + pub code: String, + pub message: String, + pub reason: Option, +} diff --git a/crates/router/src/core/payments/flows.rs b/crates/router/src/core/payments/flows.rs index 06aede4f94..4754e6a1e0 100644 --- a/crates/router/src/core/payments/flows.rs +++ b/crates/router/src/core/payments/flows.rs @@ -98,6 +98,7 @@ default_imp_for_complete_authorize!( connector::Cybersource, connector::Dlocal, connector::Fiserv, + connector::Forte, connector::Klarna, connector::Multisafepay, connector::Opennode, @@ -140,6 +141,7 @@ default_imp_for_connector_redirect_response!( connector::Cybersource, connector::Dlocal, connector::Fiserv, + connector::Forte, connector::Klarna, connector::Multisafepay, connector::Opennode, @@ -173,6 +175,7 @@ default_imp_for_connector_request_id!( connector::Cybersource, connector::Dlocal, connector::Fiserv, + connector::Forte, connector::Globalpay, connector::Klarna, connector::Mollie, diff --git a/crates/router/src/types/api.rs b/crates/router/src/types/api.rs index b719986ba9..7a41746dfb 100644 --- a/crates/router/src/types/api.rs +++ b/crates/router/src/types/api.rs @@ -199,6 +199,7 @@ impl ConnectorData { "cybersource" => Ok(Box::new(&connector::Cybersource)), "dlocal" => Ok(Box::new(&connector::Dlocal)), "fiserv" => Ok(Box::new(&connector::Fiserv)), + // "forte" => Ok(Box::new(&connector::Forte)), "globalpay" => Ok(Box::new(&connector::Globalpay)), "klarna" => Ok(Box::new(&connector::Klarna)), "mollie" => Ok(Box::new(&connector::Mollie)), diff --git a/crates/router/tests/connectors/connector_auth.rs b/crates/router/tests/connectors/connector_auth.rs index 39e2d8a4ed..7fd086e83d 100644 --- a/crates/router/tests/connectors/connector_auth.rs +++ b/crates/router/tests/connectors/connector_auth.rs @@ -16,6 +16,7 @@ pub(crate) struct ConnectorAuthentication { pub cybersource: Option, pub dlocal: Option, pub fiserv: Option, + pub forte: Option, pub globalpay: Option, pub mollie: Option, pub multisafepay: Option, diff --git a/crates/router/tests/connectors/forte.rs b/crates/router/tests/connectors/forte.rs new file mode 100644 index 0000000000..5b5976acc6 --- /dev/null +++ b/crates/router/tests/connectors/forte.rs @@ -0,0 +1,465 @@ +use masking::Secret; +use router::types::{self, api, storage::enums}; + +use crate::{ + connector_auth, + utils::{self, ConnectorActions}, +}; + +#[derive(Clone, Copy)] +struct ForteTest; +impl ConnectorActions for ForteTest {} +impl utils::Connector for ForteTest { + fn get_data(&self) -> types::api::ConnectorData { + use router::connector::Forte; + types::api::ConnectorData { + connector: Box::new(&Forte), + connector_name: types::Connector::Dummy, + get_token: types::api::GetToken::Connector, + } + } + + fn get_auth_token(&self) -> types::ConnectorAuthType { + types::ConnectorAuthType::from( + connector_auth::ConnectorAuthentication::new() + .forte + .expect("Missing connector authentication configuration"), + ) + } + + fn get_name(&self) -> String { + "forte".to_string() + } +} + +static CONNECTOR: ForteTest = ForteTest {}; + +fn get_default_payment_info() -> Option { + None +} + +fn payment_method_details() -> Option { + None +} + +// Cards Positive Tests +// Creates a payment using the manual capture flow (Non 3DS). +#[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::Authorized); +} + +// Captures a payment using the manual capture flow (Non 3DS). +#[actix_web::test] +async fn should_capture_authorized_payment() { + let response = CONNECTOR + .authorize_and_capture_payment(payment_method_details(), None, get_default_payment_info()) + .await + .expect("Capture payment response"); + assert_eq!(response.status, enums::AttemptStatus::Charged); +} + +// Partially captures a payment using the manual capture flow (Non 3DS). +#[actix_web::test] +async fn should_partially_capture_authorized_payment() { + let response = CONNECTOR + .authorize_and_capture_payment( + payment_method_details(), + Some(types::PaymentsCaptureData { + amount_to_capture: 50, + ..utils::PaymentCaptureType::default().0 + }), + get_default_payment_info(), + ) + .await + .expect("Capture payment response"); + assert_eq!(response.status, enums::AttemptStatus::Charged); +} + +// Synchronizes a payment using the manual capture flow (Non 3DS). +#[actix_web::test] +async fn should_sync_authorized_payment() { + let authorize_response = CONNECTOR + .authorize_payment(payment_method_details(), get_default_payment_info()) + .await + .expect("Authorize payment response"); + let txn_id = utils::get_connector_transaction_id(authorize_response.response); + let response = CONNECTOR + .psync_retry_till_status_matches( + enums::AttemptStatus::Authorized, + Some(types::PaymentsSyncData { + connector_transaction_id: router::types::ResponseId::ConnectorTransactionId( + txn_id.unwrap(), + ), + ..Default::default() + }), + get_default_payment_info(), + ) + .await + .expect("PSync response"); + assert_eq!(response.status, enums::AttemptStatus::Authorized,); +} + +// Voids a payment using the manual capture flow (Non 3DS). +#[actix_web::test] +async fn should_void_authorized_payment() { + let response = CONNECTOR + .authorize_and_void_payment( + payment_method_details(), + Some(types::PaymentsCancelData { + connector_transaction_id: String::from(""), + cancellation_reason: Some("requested_by_customer".to_string()), + ..Default::default() + }), + get_default_payment_info(), + ) + .await + .expect("Void payment response"); + assert_eq!(response.status, enums::AttemptStatus::Voided); +} + +// Refunds a payment using the manual capture flow (Non 3DS). +#[actix_web::test] +async fn should_refund_manually_captured_payment() { + let response = CONNECTOR + .capture_payment_and_refund( + payment_method_details(), + None, + None, + get_default_payment_info(), + ) + .await + .unwrap(); + assert_eq!( + response.response.unwrap().refund_status, + enums::RefundStatus::Success, + ); +} + +// Partially refunds a payment using the manual capture flow (Non 3DS). +#[actix_web::test] +async fn should_partially_refund_manually_captured_payment() { + let response = CONNECTOR + .capture_payment_and_refund( + payment_method_details(), + None, + Some(types::RefundsData { + refund_amount: 50, + ..utils::PaymentRefundType::default().0 + }), + get_default_payment_info(), + ) + .await + .unwrap(); + assert_eq!( + response.response.unwrap().refund_status, + enums::RefundStatus::Success, + ); +} + +// Synchronizes a refund using the manual capture flow (Non 3DS). +#[actix_web::test] +async fn should_sync_manually_captured_refund() { + let refund_response = CONNECTOR + .capture_payment_and_refund( + payment_method_details(), + None, + None, + get_default_payment_info(), + ) + .await + .unwrap(); + let response = CONNECTOR + .rsync_retry_till_status_matches( + enums::RefundStatus::Success, + refund_response.response.unwrap().connector_refund_id, + None, + get_default_payment_info(), + ) + .await + .unwrap(); + assert_eq!( + response.response.unwrap().refund_status, + enums::RefundStatus::Success, + ); +} + +// Creates a payment using the automatic capture flow (Non 3DS). +#[actix_web::test] +async fn should_make_payment() { + let authorize_response = CONNECTOR + .make_payment(payment_method_details(), get_default_payment_info()) + .await + .unwrap(); + assert_eq!(authorize_response.status, enums::AttemptStatus::Charged); +} + +// Synchronizes a payment using the automatic capture flow (Non 3DS). +#[actix_web::test] +async fn should_sync_auto_captured_payment() { + let authorize_response = CONNECTOR + .make_payment(payment_method_details(), get_default_payment_info()) + .await + .unwrap(); + assert_eq!(authorize_response.status, enums::AttemptStatus::Charged); + let txn_id = utils::get_connector_transaction_id(authorize_response.response); + assert_ne!(txn_id, None, "Empty connector transaction id"); + let response = CONNECTOR + .psync_retry_till_status_matches( + enums::AttemptStatus::Charged, + Some(types::PaymentsSyncData { + connector_transaction_id: router::types::ResponseId::ConnectorTransactionId( + txn_id.unwrap(), + ), + capture_method: Some(enums::CaptureMethod::Automatic), + ..Default::default() + }), + get_default_payment_info(), + ) + .await + .unwrap(); + assert_eq!(response.status, enums::AttemptStatus::Charged,); +} + +// Refunds a payment using the automatic capture flow (Non 3DS). +#[actix_web::test] +async fn should_refund_auto_captured_payment() { + let response = CONNECTOR + .make_payment_and_refund(payment_method_details(), None, get_default_payment_info()) + .await + .unwrap(); + assert_eq!( + response.response.unwrap().refund_status, + enums::RefundStatus::Success, + ); +} + +// Partially refunds a payment using the automatic capture flow (Non 3DS). +#[actix_web::test] +async fn should_partially_refund_succeeded_payment() { + let refund_response = CONNECTOR + .make_payment_and_refund( + payment_method_details(), + Some(types::RefundsData { + refund_amount: 50, + ..utils::PaymentRefundType::default().0 + }), + get_default_payment_info(), + ) + .await + .unwrap(); + assert_eq!( + refund_response.response.unwrap().refund_status, + enums::RefundStatus::Success, + ); +} + +// Creates multiple refunds against a payment using the automatic capture flow (Non 3DS). +#[actix_web::test] +async fn should_refund_succeeded_payment_multiple_times() { + CONNECTOR + .make_payment_and_multiple_refund( + payment_method_details(), + Some(types::RefundsData { + refund_amount: 50, + ..utils::PaymentRefundType::default().0 + }), + get_default_payment_info(), + ) + .await; +} + +// Synchronizes a refund using the automatic capture flow (Non 3DS). +#[actix_web::test] +async fn should_sync_refund() { + let refund_response = CONNECTOR + .make_payment_and_refund(payment_method_details(), None, get_default_payment_info()) + .await + .unwrap(); + let response = CONNECTOR + .rsync_retry_till_status_matches( + enums::RefundStatus::Success, + refund_response.response.unwrap().connector_refund_id, + None, + get_default_payment_info(), + ) + .await + .unwrap(); + assert_eq!( + response.response.unwrap().refund_status, + enums::RefundStatus::Success, + ); +} + +// Cards Negative scenerios +// Creates a payment with incorrect card number. +#[actix_web::test] +async fn should_fail_payment_for_incorrect_card_number() { + let response = CONNECTOR + .make_payment( + Some(types::PaymentsAuthorizeData { + payment_method_data: types::api::PaymentMethodData::Card(api::Card { + card_number: Secret::new("1234567891011".to_string()), + ..utils::CCardType::default().0 + }), + ..utils::PaymentAuthorizeType::default().0 + }), + get_default_payment_info(), + ) + .await + .unwrap(); + assert_eq!( + response.response.unwrap_err().message, + "Your card number is incorrect.".to_string(), + ); +} + +// Creates a payment with empty card number. +#[actix_web::test] +async fn should_fail_payment_for_empty_card_number() { + let response = CONNECTOR + .make_payment( + Some(types::PaymentsAuthorizeData { + payment_method_data: types::api::PaymentMethodData::Card(api::Card { + card_number: Secret::new(String::from("")), + ..utils::CCardType::default().0 + }), + ..utils::PaymentAuthorizeType::default().0 + }), + get_default_payment_info(), + ) + .await + .unwrap(); + let x = response.response.unwrap_err(); + assert_eq!( + x.message, + "You passed an empty string for 'payment_method_data[card][number]'.", + ); +} + +// Creates a payment with incorrect CVC. +#[actix_web::test] +async fn should_fail_payment_for_incorrect_cvc() { + let response = CONNECTOR + .make_payment( + Some(types::PaymentsAuthorizeData { + payment_method_data: types::api::PaymentMethodData::Card(api::Card { + card_cvc: Secret::new("12345".to_string()), + ..utils::CCardType::default().0 + }), + ..utils::PaymentAuthorizeType::default().0 + }), + get_default_payment_info(), + ) + .await + .unwrap(); + assert_eq!( + response.response.unwrap_err().message, + "Your card's security code is invalid.".to_string(), + ); +} + +// Creates a payment with incorrect expiry month. +#[actix_web::test] +async fn should_fail_payment_for_invalid_exp_month() { + let response = CONNECTOR + .make_payment( + Some(types::PaymentsAuthorizeData { + payment_method_data: types::api::PaymentMethodData::Card(api::Card { + card_exp_month: Secret::new("20".to_string()), + ..utils::CCardType::default().0 + }), + ..utils::PaymentAuthorizeType::default().0 + }), + get_default_payment_info(), + ) + .await + .unwrap(); + assert_eq!( + response.response.unwrap_err().message, + "Your card's expiration month is invalid.".to_string(), + ); +} + +// Creates a payment with incorrect expiry year. +#[actix_web::test] +async fn should_fail_payment_for_incorrect_expiry_year() { + let response = CONNECTOR + .make_payment( + Some(types::PaymentsAuthorizeData { + payment_method_data: types::api::PaymentMethodData::Card(api::Card { + card_exp_year: Secret::new("2000".to_string()), + ..utils::CCardType::default().0 + }), + ..utils::PaymentAuthorizeType::default().0 + }), + get_default_payment_info(), + ) + .await + .unwrap(); + assert_eq!( + response.response.unwrap_err().message, + "Your card's expiration year is invalid.".to_string(), + ); +} + +// Voids a payment using automatic capture flow (Non 3DS). +#[actix_web::test] +async fn should_fail_void_payment_for_auto_capture() { + let authorize_response = CONNECTOR + .make_payment(payment_method_details(), get_default_payment_info()) + .await + .unwrap(); + assert_eq!(authorize_response.status, enums::AttemptStatus::Charged); + let txn_id = utils::get_connector_transaction_id(authorize_response.response); + assert_ne!(txn_id, None, "Empty connector transaction id"); + let void_response = CONNECTOR + .void_payment(txn_id.unwrap(), None, get_default_payment_info()) + .await + .unwrap(); + assert_eq!( + void_response.response.unwrap_err().message, + "You cannot cancel this PaymentIntent because it has a status of succeeded." + ); +} + +// Captures a payment using invalid connector payment id. +#[actix_web::test] +async fn should_fail_capture_for_invalid_payment() { + let capture_response = CONNECTOR + .capture_payment("123456789".to_string(), None, get_default_payment_info()) + .await + .unwrap(); + assert_eq!( + capture_response.response.unwrap_err().message, + String::from("No such payment_intent: '123456789'") + ); +} + +// Refunds a payment with refund amount higher than payment amount. +#[actix_web::test] +async fn should_fail_for_refund_amount_higher_than_payment_amount() { + let response = CONNECTOR + .make_payment_and_refund( + payment_method_details(), + Some(types::RefundsData { + refund_amount: 150, + ..utils::PaymentRefundType::default().0 + }), + get_default_payment_info(), + ) + .await + .unwrap(); + assert_eq!( + response.response.unwrap_err().message, + "Refund amount (₹1.50) is greater than charge amount (₹1.00)", + ); +} + +// Connector dependent test cases goes here + +// [#478]: add unit tests for non 3DS, wallets & webhooks in connector tests diff --git a/crates/router/tests/connectors/main.rs b/crates/router/tests/connectors/main.rs index 6bf25e42f4..3850c95320 100644 --- a/crates/router/tests/connectors/main.rs +++ b/crates/router/tests/connectors/main.rs @@ -12,6 +12,7 @@ mod connector_auth; mod cybersource; mod dlocal; mod fiserv; +mod forte; mod globalpay; mod mollie; mod multisafepay; diff --git a/crates/router/tests/connectors/sample_auth.toml b/crates/router/tests/connectors/sample_auth.toml index 21a7b1d14c..5c654aa4c5 100644 --- a/crates/router/tests/connectors/sample_auth.toml +++ b/crates/router/tests/connectors/sample_auth.toml @@ -74,6 +74,10 @@ key1 = "key1" [mollie] api_key = "API Key" +[forte] +api_key="API Key" + + [coinbase] api_key="API Key" diff --git a/loadtest/config/Development.toml b/loadtest/config/Development.toml index 43dc503403..cf1672fc67 100644 --- a/loadtest/config/Development.toml +++ b/loadtest/config/Development.toml @@ -68,6 +68,7 @@ coinbase.base_url = "https://api.commerce.coinbase.com" cybersource.base_url = "https://apitest.cybersource.com/" dlocal.base_url = "https://sandbox.dlocal.com/" fiserv.base_url = "https://cert.api.fiservapps.com/" +forte.base_url = "https://sandbox.forte.net/api/v3" globalpay.base_url = "https://apis.sandbox.globalpay.com/ucp/" klarna.base_url = "https://api-na.playground.klarna.com/" mollie.base_url = "https://api.mollie.com/v2/" @@ -100,6 +101,7 @@ cards = [ "cybersource", "dlocal", "fiserv", + "forte", "globalpay", "mollie", "multisafepay", diff --git a/scripts/add_connector.sh b/scripts/add_connector.sh index d37e040e3f..ad6a4f3abf 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 bluesnap braintree checkout coinbase cybersource dlocal fiserv globalpay klarna mollie multisafepay nuvei opennode payeezy payu rapyd shift4 stripe trustpay worldline worldpay "$1") + connectors=(aci adyen airwallex applepay authorizedotnet bambora bluesnap braintree checkout coinbase cybersource dlocal fiserv forte globalpay klarna mollie multisafepay nuvei opennode payeezy 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