diff --git a/config/deployments/production.toml b/config/deployments/production.toml index 23f5c72e3c..8ed1c92c48 100644 --- a/config/deployments/production.toml +++ b/config/deployments/production.toml @@ -23,7 +23,7 @@ payout_connector_list = "nomupay,stripe,wise" # base urls based on your need. # Note: These are not optional attributes. hyperswitch request can fail due to invalid/empty values. [connectors] -aci.base_url = "https://eu-test.oppwa.com/" +aci.base_url = "https://eu-prod.oppwa.com/" adyen.base_url = "https://{{merchant_endpoint_prefix}}-checkout-live.adyenpayments.com/checkout/" adyen.payout_base_url = "https://{{merchant_endpoint_prefix}}-pal-live.adyenpayments.com/" adyen.dispute_base_url = "https://{{merchant_endpoint_prefix}}-ca-live.adyen.com/" diff --git a/config/development.toml b/config/development.toml index 8107dce388..3dac3fb116 100644 --- a/config/development.toml +++ b/config/development.toml @@ -687,8 +687,8 @@ red_pagos = { country = "UY", currency = "UYU" } local_bank_transfer = { country = "CN", currency = "CNY" } [pm_filters.aci] -credit = { not_available_flows = { capture_method = "manual" }, country = "AD,AE,AT,BE,BG,CH,CN,CO,CR,CY,CZ,DE,DK,DO,EE,EG,ES,ET,FI,FR,GB,GH,GI,GR,GT,HN,HK,HR,HU,ID,IE,IS,IT,JP,KH,LA,LI,LT,LU,LY,MK,MM,MX,MY,MZ,NG,NZ,OM,PA,PE,PK,PL,PT,QA,RO,SA,SN,SE,SI,SK,SV,TH,UA,US,UY,VN,ZM", currency = "AED,ALL,ARS,BGN,CHF,CLP,CNY,COP,CRC,CZK,DKK,DOP,EGP,EUR,GBP,GHS,HKD,HNL,HRK,HUF,IDR,ILS,ISK,JPY,KHR,KPW,LAK,LKR,MAD,MKD,MMK,MXN,MYR,MZN,NGN,NOK,NZD,OMR,PAB,PEN,PHP,PKR,PLN,QAR,RON,RSD,SAR,SEK,SGD,THB,TRY,TWD,UAH,USD,UYU,VND,ZAR,ZMW" } -debit = { not_available_flows = { capture_method = "manual" }, country = "AD,AE,AT,BE,BG,CH,CN,CO,CR,CY,CZ,DE,DK,DO,EE,EG,ES,ET,FI,FR,GB,GH,GI,GR,GT,HN,HK,HR,HU,ID,IE,IS,IT,JP,KH,LA,LI,LT,LU,LY,MK,MM,MX,MY,MZ,NG,NZ,OM,PA,PE,PK,PL,PT,QA,RO,SA,SN,SE,SI,SK,SV,TH,UA,US,UY,VN,ZM", currency = "AED,ALL,ARS,BGN,CHF,CLP,CNY,COP,CRC,CZK,DKK,DOP,EGP,EUR,GBP,GHS,HKD,HNL,HRK,HUF,IDR,ILS,ISK,JPY,KHR,KPW,LAK,LKR,MAD,MKD,MMK,MXN,MYR,MZN,NGN,NOK,NZD,OMR,PAB,PEN,PHP,PKR,PLN,QAR,RON,RSD,SAR,SEK,SGD,THB,TRY,TWD,UAH,USD,UYU,VND,ZAR,ZMW" } +credit = { country = "AD,AE,AT,BE,BG,CH,CN,CO,CR,CY,CZ,DE,DK,DO,EE,EG,ES,ET,FI,FR,GB,GH,GI,GR,GT,HN,HK,HR,HU,ID,IE,IS,IT,JP,KH,LA,LI,LT,LU,LY,MK,MM,MX,MY,MZ,NG,NZ,OM,PA,PE,PK,PL,PT,QA,RO,SA,SN,SE,SI,SK,SV,TH,UA,US,UY,VN,ZM", currency = "AED,ALL,ARS,BGN,CHF,CLP,CNY,COP,CRC,CZK,DKK,DOP,EGP,EUR,GBP,GHS,HKD,HNL,HRK,HUF,IDR,ILS,ISK,JPY,KHR,KPW,LAK,LKR,MAD,MKD,MMK,MXN,MYR,MZN,NGN,NOK,NZD,OMR,PAB,PEN,PHP,PKR,PLN,QAR,RON,RSD,SAR,SEK,SGD,THB,TRY,TWD,UAH,USD,UYU,VND,ZAR,ZMW" } +debit = { country = "AD,AE,AT,BE,BG,CH,CN,CO,CR,CY,CZ,DE,DK,DO,EE,EG,ES,ET,FI,FR,GB,GH,GI,GR,GT,HN,HK,HR,HU,ID,IE,IS,IT,JP,KH,LA,LI,LT,LU,LY,MK,MM,MX,MY,MZ,NG,NZ,OM,PA,PE,PK,PL,PT,QA,RO,SA,SN,SE,SI,SK,SV,TH,UA,US,UY,VN,ZM", currency = "AED,ALL,ARS,BGN,CHF,CLP,CNY,COP,CRC,CZK,DKK,DOP,EGP,EUR,GBP,GHS,HKD,HNL,HRK,HUF,IDR,ILS,ISK,JPY,KHR,KPW,LAK,LKR,MAD,MKD,MMK,MXN,MYR,MZN,NGN,NOK,NZD,OMR,PAB,PEN,PHP,PKR,PLN,QAR,RON,RSD,SAR,SEK,SGD,THB,TRY,TWD,UAH,USD,UYU,VND,ZAR,ZMW" } mb_way = { country = "EE,ES,PT", currency = "EUR" } ali_pay = { country = "CN", currency = "CNY" } eps = { country = "AT", currency = "EUR" } diff --git a/crates/common_utils/src/errors.rs b/crates/common_utils/src/errors.rs index a731a5f463..c2cddf41bc 100644 --- a/crates/common_utils/src/errors.rs +++ b/crates/common_utils/src/errors.rs @@ -101,6 +101,9 @@ pub enum CryptoError { /// The provided IV length is invalid for the cryptographic algorithm #[error("Invalid IV length")] InvalidIvLength, + /// The provided authentication tag length is invalid for the cryptographic algorithm + #[error("Invalid authentication tag length")] + InvalidTagLength, } /// Errors for Qr code handling diff --git a/crates/hyperswitch_connectors/src/connectors/aci.rs b/crates/hyperswitch_connectors/src/connectors/aci.rs index 9218ef3e9f..16a85965e7 100644 --- a/crates/hyperswitch_connectors/src/connectors/aci.rs +++ b/crates/hyperswitch_connectors/src/connectors/aci.rs @@ -6,7 +6,8 @@ use std::sync::LazyLock; use api_models::webhooks::IncomingWebhookEvent; use common_enums::enums; use common_utils::{ - errors::CustomResult, + crypto, + errors::{CryptoError, CustomResult}, ext_traits::BytesExt, request::{Method, Request, RequestBuilder, RequestContent}, types::{AmountConvertor, StringMajorUnit, StringMajorUnitForConnector}, @@ -30,8 +31,8 @@ use hyperswitch_domain_models::{ SupportedPaymentMethods, SupportedPaymentMethodsExt, }, types::{ - PaymentsAuthorizeRouterData, PaymentsCancelRouterData, PaymentsSyncRouterData, - RefundsRouterData, + PaymentsAuthorizeRouterData, PaymentsCancelRouterData, PaymentsCaptureRouterData, + PaymentsSyncRouterData, RefundsRouterData, }, }; use hyperswitch_interfaces::{ @@ -42,11 +43,13 @@ use hyperswitch_interfaces::{ errors, events::connector_api_logs::ConnectorEvent, types::{ - PaymentsAuthorizeType, PaymentsSyncType, PaymentsVoidType, RefundExecuteType, Response, + PaymentsAuthorizeType, PaymentsCaptureType, PaymentsSyncType, PaymentsVoidType, + RefundExecuteType, Response, }, webhooks::{IncomingWebhook, IncomingWebhookRequestDetails}, }; use masking::{Mask, PeekInterface}; +use ring::aead::{self, UnboundKey}; use transformers as aci; use crate::{ @@ -181,8 +184,101 @@ impl ConnectorIntegration for Aci { - // Not Implemented (R) + fn get_headers( + &self, + req: &PaymentsCaptureRouterData, + _connectors: &Connectors, + ) -> CustomResult)>, errors::ConnectorError> { + let mut header = vec![( + headers::CONTENT_TYPE.to_string(), + PaymentsCaptureType::get_content_type(self) + .to_string() + .into(), + )]; + let mut api_key = self.get_auth_header(&req.connector_auth_type)?; + header.append(&mut api_key); + Ok(header) + } + + fn get_content_type(&self) -> &'static str { + self.common_get_content_type() + } + + fn get_url( + &self, + req: &PaymentsCaptureRouterData, + connectors: &Connectors, + ) -> CustomResult { + Ok(format!( + "{}{}{}", + self.base_url(connectors), + "v1/payments/", + req.request.connector_transaction_id, + )) + } + + fn get_request_body( + &self, + req: &PaymentsCaptureRouterData, + _connectors: &Connectors, + ) -> CustomResult { + let amount = convert_amount( + self.amount_converter, + req.request.minor_amount_to_capture, + req.request.currency, + )?; + let connector_router_data = aci::AciRouterData::from((amount, req)); + let connector_req = aci::AciCaptureRequest::try_from(&connector_router_data)?; + Ok(RequestContent::FormUrlEncoded(Box::new(connector_req))) + } + + fn build_request( + &self, + req: &PaymentsCaptureRouterData, + connectors: &Connectors, + ) -> CustomResult, errors::ConnectorError> { + Ok(Some( + RequestBuilder::new() + .method(Method::Post) + .url(&PaymentsCaptureType::get_url(self, req, connectors)?) + .attach_default_headers() + .headers(PaymentsCaptureType::get_headers(self, req, connectors)?) + .set_body(PaymentsCaptureType::get_request_body( + self, req, connectors, + )?) + .build(), + )) + } + + fn handle_response( + &self, + data: &PaymentsCaptureRouterData, + event_builder: Option<&mut ConnectorEvent>, + res: Response, + ) -> CustomResult { + let response: aci::AciCaptureResponse = res + .response + .parse_struct("AciCaptureResponse") + .change_context(errors::ConnectorError::ResponseDeserializationFailed)?; + event_builder.map(|i| i.set_response_body(&response)); + router_env::logger::info!(connector_response=?response); + RouterData::try_from(ResponseRouterData { + response, + data: data.clone(), + http_code: res.status_code, + }) + .change_context(errors::ConnectorError::ResponseHandlingFailed) + } + + fn get_error_response( + &self, + res: Response, + event_builder: Option<&mut ConnectorEvent>, + ) -> CustomResult { + self.build_error_response(res, event_builder) + } } impl ConnectorIntegration for Aci { @@ -555,32 +651,252 @@ impl ConnectorIntegration for Aci { impl ConnectorIntegration for Aci {} +/// Decrypts an AES-256-GCM encrypted payload where the IV, auth tag, and ciphertext +/// are provided separately as hex strings. This is specifically tailored for ACI webhooks. +/// +/// # Arguments +/// * `hex_key`: The encryption key as a hex string (must decode to 32 bytes). +/// * `hex_iv`: The initialization vector (nonce) as a hex string (must decode to 12 bytes). +/// * `hex_auth_tag`: The authentication tag as a hex string (must decode to 16 bytes). +/// * `hex_encrypted_body`: The encrypted payload as a hex string. +fn decrypt_aci_webhook_payload( + hex_key: &str, + hex_iv: &str, + hex_auth_tag: &str, + hex_encrypted_body: &str, +) -> CustomResult, CryptoError> { + let key_bytes = hex::decode(hex_key) + .change_context(CryptoError::DecodingFailed) + .attach_printable("Failed to decode hex key")?; + let iv_bytes = hex::decode(hex_iv) + .change_context(CryptoError::DecodingFailed) + .attach_printable("Failed to decode hex IV")?; + let auth_tag_bytes = hex::decode(hex_auth_tag) + .change_context(CryptoError::DecodingFailed) + .attach_printable("Failed to decode hex auth tag")?; + let encrypted_body_bytes = hex::decode(hex_encrypted_body) + .change_context(CryptoError::DecodingFailed) + .attach_printable("Failed to decode hex encrypted body")?; + if key_bytes.len() != 32 { + return Err(CryptoError::InvalidKeyLength) + .attach_printable("Key must be 32 bytes for AES-256-GCM"); + } + if iv_bytes.len() != aead::NONCE_LEN { + return Err(CryptoError::InvalidIvLength) + .attach_printable(format!("IV must be {} bytes for AES-GCM", aead::NONCE_LEN)); + } + if auth_tag_bytes.len() != 16 { + return Err(CryptoError::InvalidTagLength) + .attach_printable("Auth tag must be 16 bytes for AES-256-GCM"); + } + + let unbound_key = UnboundKey::new(&aead::AES_256_GCM, &key_bytes) + .change_context(CryptoError::DecodingFailed) + .attach_printable("Failed to create unbound key")?; + + let less_safe_key = aead::LessSafeKey::new(unbound_key); + + let nonce_arr: [u8; aead::NONCE_LEN] = iv_bytes + .as_slice() + .try_into() + .map_err(|_| CryptoError::InvalidIvLength) + .attach_printable_lazy(|| { + format!( + "IV length is {} but expected {}", + iv_bytes.len(), + aead::NONCE_LEN + ) + })?; + let nonce = aead::Nonce::assume_unique_for_key(nonce_arr); + + let mut ciphertext_and_tag = encrypted_body_bytes; + ciphertext_and_tag.extend_from_slice(&auth_tag_bytes); + + less_safe_key + .open_in_place(nonce, aead::Aad::empty(), &mut ciphertext_and_tag) + .change_context(CryptoError::DecodingFailed) + .attach_printable("Failed to decrypt payload using LessSafeKey")?; + + let original_ciphertext_len = ciphertext_and_tag.len() - auth_tag_bytes.len(); + ciphertext_and_tag.truncate(original_ciphertext_len); + + Ok(ciphertext_and_tag) +} + +// TODO: Test this webhook flow once dashboard access is available. #[async_trait::async_trait] impl IncomingWebhook for Aci { - fn get_webhook_object_reference_id( + fn get_webhook_source_verification_algorithm( &self, _request: &IncomingWebhookRequestDetails<'_>, + ) -> CustomResult, errors::ConnectorError> { + Ok(Box::new(crypto::NoAlgorithm)) + } + + fn get_webhook_source_verification_signature( + &self, + request: &IncomingWebhookRequestDetails<'_>, + _connector_webhook_secrets: &api_models::webhooks::ConnectorWebhookSecrets, + ) -> CustomResult, errors::ConnectorError> { + let header_value_str = request + .headers + .get("X-Authentication-Tag") + .ok_or(errors::ConnectorError::WebhookSignatureNotFound) + .attach_printable("Missing X-Authentication-Tag header")? + .to_str() + .map_err(|_| errors::ConnectorError::WebhookSignatureNotFound) + .attach_printable("Invalid X-Authentication-Tag header value (not UTF-8)")?; + Ok(header_value_str.as_bytes().to_vec()) + } + + fn get_webhook_source_verification_message( + &self, + request: &IncomingWebhookRequestDetails<'_>, + _merchant_id: &common_utils::id_type::MerchantId, + connector_webhook_secrets: &api_models::webhooks::ConnectorWebhookSecrets, + ) -> CustomResult, errors::ConnectorError> { + let webhook_secret_str = String::from_utf8(connector_webhook_secrets.secret.to_vec()) + .map_err(|_| errors::ConnectorError::WebhookVerificationSecretInvalid) + .attach_printable("ACI webhook secret is not a valid UTF-8 string")?; + + let iv_hex_str = request + .headers + .get("X-Initialization-Vector") + .ok_or(errors::ConnectorError::WebhookSourceVerificationFailed) + .attach_printable("Missing X-Initialization-Vector header")? + .to_str() + .map_err(|_| errors::ConnectorError::WebhookSourceVerificationFailed) + .attach_printable("Invalid X-Initialization-Vector header value (not UTF-8)")?; + + let auth_tag_hex_str = request + .headers + .get("X-Authentication-Tag") + .ok_or(errors::ConnectorError::WebhookSourceVerificationFailed) + .attach_printable("Missing X-Authentication-Tag header")? + .to_str() + .map_err(|_| errors::ConnectorError::WebhookSourceVerificationFailed) + .attach_printable("Invalid X-Authentication-Tag header value (not UTF-8)")?; + + let encrypted_body_hex = String::from_utf8(request.body.to_vec()) + .map_err(|_| errors::ConnectorError::WebhookBodyDecodingFailed) + .attach_printable( + "Failed to read encrypted body as UTF-8 string for verification message", + )?; + + decrypt_aci_webhook_payload( + &webhook_secret_str, + iv_hex_str, + auth_tag_hex_str, + &encrypted_body_hex, + ) + .change_context(errors::ConnectorError::WebhookSourceVerificationFailed) + .attach_printable("Failed to decrypt ACI webhook payload for verification") + } + + fn get_webhook_object_reference_id( + &self, + request: &IncomingWebhookRequestDetails<'_>, ) -> CustomResult { - Err(report!(errors::ConnectorError::WebhooksNotImplemented)) + let aci_notification: aci::AciWebhookNotification = + serde_json::from_slice(request.body) + .change_context(errors::ConnectorError::WebhookResourceObjectNotFound) + .attach_printable("Failed to deserialize ACI webhook notification for ID extraction (expected decrypted payload)")?; + + let id_value_str = aci_notification + .payload + .get("id") + .and_then(|id| id.as_str()) + .ok_or_else(|| { + report!(errors::ConnectorError::WebhookResourceObjectNotFound) + .attach_printable("Missing 'id' in webhook payload for ID extraction") + })?; + + let payment_type_str = aci_notification + .payload + .get("paymentType") + .and_then(|pt| pt.as_str()); + + if payment_type_str.is_some_and(|pt| pt.to_uppercase() == "RF") { + Ok(api_models::webhooks::ObjectReferenceId::RefundId( + api_models::webhooks::RefundIdType::ConnectorRefundId(id_value_str.to_string()), + )) + } else { + Ok(api_models::webhooks::ObjectReferenceId::PaymentId( + api_models::payments::PaymentIdType::ConnectorTransactionId( + id_value_str.to_string(), + ), + )) + } } fn get_webhook_event_type( &self, - _request: &IncomingWebhookRequestDetails<'_>, + request: &IncomingWebhookRequestDetails<'_>, ) -> CustomResult { - Ok(IncomingWebhookEvent::EventNotSupported) + let aci_notification: aci::AciWebhookNotification = + serde_json::from_slice(request.body) + .change_context(errors::ConnectorError::WebhookEventTypeNotFound) + .attach_printable("Failed to deserialize ACI webhook notification for event type (expected decrypted payload)")?; + + match aci_notification.event_type { + aci::AciWebhookEventType::Payment => { + let payment_payload: aci::AciPaymentWebhookPayload = + serde_json::from_value(aci_notification.payload) + .change_context(errors::ConnectorError::WebhookEventTypeNotFound) + .attach_printable("Could not deserialize ACI payment webhook payload for event type determination")?; + + let code = &payment_payload.result.code; + if aci_result_codes::SUCCESSFUL_CODES.contains(&code.as_str()) { + if payment_payload.payment_type.to_uppercase() == "RF" { + Ok(IncomingWebhookEvent::RefundSuccess) + } else { + Ok(IncomingWebhookEvent::PaymentIntentSuccess) + } + } else if aci_result_codes::PENDING_CODES.contains(&code.as_str()) { + if payment_payload.payment_type.to_uppercase() == "RF" { + Ok(IncomingWebhookEvent::EventNotSupported) + } else { + Ok(IncomingWebhookEvent::PaymentIntentProcessing) + } + } else if aci_result_codes::FAILURE_CODES.contains(&code.as_str()) { + if payment_payload.payment_type.to_uppercase() == "RF" { + Ok(IncomingWebhookEvent::RefundFailure) + } else { + Ok(IncomingWebhookEvent::PaymentIntentFailure) + } + } else { + Ok(IncomingWebhookEvent::EventNotSupported) + } + } + } } fn get_webhook_resource_object( &self, - _request: &IncomingWebhookRequestDetails<'_>, + request: &IncomingWebhookRequestDetails<'_>, ) -> CustomResult, errors::ConnectorError> { - Err(report!(errors::ConnectorError::WebhooksNotImplemented)) + let aci_notification: aci::AciWebhookNotification = + serde_json::from_slice(request.body) + .change_context(errors::ConnectorError::WebhookResourceObjectNotFound) + .attach_printable("Failed to deserialize ACI webhook notification for resource object (expected decrypted payload)")?; + + match aci_notification.event_type { + aci::AciWebhookEventType::Payment => { + let payment_payload: aci::AciPaymentWebhookPayload = + serde_json::from_value(aci_notification.payload) + .change_context(errors::ConnectorError::WebhookResourceObjectNotFound) + .attach_printable("Failed to deserialize ACI payment webhook payload")?; + Ok(Box::new(payment_payload)) + } + } } } static ACI_SUPPORTED_PAYMENT_METHODS: LazyLock = LazyLock::new(|| { - let supported_capture_methods = vec![enums::CaptureMethod::Automatic]; + let supported_capture_methods = vec![ + enums::CaptureMethod::Automatic, + enums::CaptureMethod::Manual, + ]; let supported_card_network = vec![ common_enums::CardNetwork::AmericanExpress, @@ -600,7 +916,7 @@ static ACI_SUPPORTED_PAYMENT_METHODS: LazyLock = LazyLo enums::PaymentMethodType::MbWay, PaymentMethodDetails { mandates: enums::FeatureStatus::NotSupported, - refunds: enums::FeatureStatus::NotSupported, + refunds: enums::FeatureStatus::Supported, supported_capture_methods: supported_capture_methods.clone(), specific_features: None, }, @@ -611,7 +927,7 @@ static ACI_SUPPORTED_PAYMENT_METHODS: LazyLock = LazyLo enums::PaymentMethodType::AliPay, PaymentMethodDetails { mandates: enums::FeatureStatus::NotSupported, - refunds: enums::FeatureStatus::NotSupported, + refunds: enums::FeatureStatus::Supported, supported_capture_methods: supported_capture_methods.clone(), specific_features: None, }, @@ -659,7 +975,7 @@ static ACI_SUPPORTED_PAYMENT_METHODS: LazyLock = LazyLo enums::PaymentMethodType::Eps, PaymentMethodDetails { mandates: enums::FeatureStatus::NotSupported, - refunds: enums::FeatureStatus::NotSupported, + refunds: enums::FeatureStatus::Supported, supported_capture_methods: supported_capture_methods.clone(), specific_features: None, }, @@ -669,7 +985,7 @@ static ACI_SUPPORTED_PAYMENT_METHODS: LazyLock = LazyLo enums::PaymentMethodType::Eft, PaymentMethodDetails { mandates: enums::FeatureStatus::NotSupported, - refunds: enums::FeatureStatus::NotSupported, + refunds: enums::FeatureStatus::Supported, supported_capture_methods: supported_capture_methods.clone(), specific_features: None, }, @@ -679,7 +995,7 @@ static ACI_SUPPORTED_PAYMENT_METHODS: LazyLock = LazyLo enums::PaymentMethodType::Ideal, PaymentMethodDetails { mandates: enums::FeatureStatus::NotSupported, - refunds: enums::FeatureStatus::NotSupported, + refunds: enums::FeatureStatus::Supported, supported_capture_methods: supported_capture_methods.clone(), specific_features: None, }, @@ -689,7 +1005,7 @@ static ACI_SUPPORTED_PAYMENT_METHODS: LazyLock = LazyLo enums::PaymentMethodType::Giropay, PaymentMethodDetails { mandates: enums::FeatureStatus::NotSupported, - refunds: enums::FeatureStatus::NotSupported, + refunds: enums::FeatureStatus::Supported, supported_capture_methods: supported_capture_methods.clone(), specific_features: None, }, @@ -699,7 +1015,7 @@ static ACI_SUPPORTED_PAYMENT_METHODS: LazyLock = LazyLo enums::PaymentMethodType::Sofort, PaymentMethodDetails { mandates: enums::FeatureStatus::NotSupported, - refunds: enums::FeatureStatus::NotSupported, + refunds: enums::FeatureStatus::Supported, supported_capture_methods: supported_capture_methods.clone(), specific_features: None, }, @@ -709,7 +1025,7 @@ static ACI_SUPPORTED_PAYMENT_METHODS: LazyLock = LazyLo enums::PaymentMethodType::Interac, PaymentMethodDetails { mandates: enums::FeatureStatus::NotSupported, - refunds: enums::FeatureStatus::NotSupported, + refunds: enums::FeatureStatus::Supported, supported_capture_methods: supported_capture_methods.clone(), specific_features: None, }, @@ -719,7 +1035,7 @@ static ACI_SUPPORTED_PAYMENT_METHODS: LazyLock = LazyLo enums::PaymentMethodType::Przelewy24, PaymentMethodDetails { mandates: enums::FeatureStatus::NotSupported, - refunds: enums::FeatureStatus::NotSupported, + refunds: enums::FeatureStatus::Supported, supported_capture_methods: supported_capture_methods.clone(), specific_features: None, }, @@ -729,7 +1045,7 @@ static ACI_SUPPORTED_PAYMENT_METHODS: LazyLock = LazyLo enums::PaymentMethodType::Trustly, PaymentMethodDetails { mandates: enums::FeatureStatus::NotSupported, - refunds: enums::FeatureStatus::NotSupported, + refunds: enums::FeatureStatus::Supported, supported_capture_methods: supported_capture_methods.clone(), specific_features: None, }, @@ -739,7 +1055,7 @@ static ACI_SUPPORTED_PAYMENT_METHODS: LazyLock = LazyLo enums::PaymentMethodType::Klarna, PaymentMethodDetails { mandates: enums::FeatureStatus::NotSupported, - refunds: enums::FeatureStatus::NotSupported, + refunds: enums::FeatureStatus::Supported, supported_capture_methods: supported_capture_methods.clone(), specific_features: None, }, diff --git a/crates/hyperswitch_connectors/src/connectors/aci/transformers.rs b/crates/hyperswitch_connectors/src/connectors/aci/transformers.rs index e5f7ec19b5..b2074cb8dc 100644 --- a/crates/hyperswitch_connectors/src/connectors/aci/transformers.rs +++ b/crates/hyperswitch_connectors/src/connectors/aci/transformers.rs @@ -6,11 +6,16 @@ use error_stack::report; use hyperswitch_domain_models::{ payment_method_data::{BankRedirectData, Card, PayLaterData, PaymentMethodData, WalletData}, router_data::{ConnectorAuthType, RouterData}, - router_request_types::ResponseId, + router_request_types::{ + PaymentsAuthorizeData, PaymentsCancelData, PaymentsSyncData, ResponseId, + }, router_response_types::{ MandateReference, PaymentsResponseData, RedirectForm, RefundsResponseData, }, - types::{PaymentsAuthorizeRouterData, PaymentsCancelRouterData, RefundsRouterData}, + types::{ + PaymentsAuthorizeRouterData, PaymentsCancelRouterData, PaymentsCaptureRouterData, + RefundsRouterData, + }, }; use hyperswitch_interfaces::errors; use masking::{ExposeInterface, Secret}; @@ -25,6 +30,28 @@ use crate::{ type Error = error_stack::Report; +trait GetCaptureMethod { + fn get_capture_method(&self) -> Option; +} + +impl GetCaptureMethod for PaymentsAuthorizeData { + fn get_capture_method(&self) -> Option { + self.capture_method + } +} + +impl GetCaptureMethod for PaymentsSyncData { + fn get_capture_method(&self) -> Option { + self.capture_method + } +} + +impl GetCaptureMethod for PaymentsCancelData { + fn get_capture_method(&self) -> Option { + None + } +} + #[derive(Debug, Serialize)] pub struct AciRouterData { amount: StringMajorUnit, @@ -629,14 +656,18 @@ pub enum AciPaymentStatus { RedirectShopper, } -impl From for enums::AttemptStatus { - fn from(item: AciPaymentStatus) -> Self { - match item { - AciPaymentStatus::Succeeded => Self::Charged, - AciPaymentStatus::Failed => Self::Failure, - AciPaymentStatus::Pending => Self::Authorizing, - AciPaymentStatus::RedirectShopper => Self::AuthenticationPending, +fn map_aci_attempt_status(item: AciPaymentStatus, auto_capture: bool) -> enums::AttemptStatus { + match item { + AciPaymentStatus::Succeeded => { + if auto_capture { + enums::AttemptStatus::Charged + } else { + enums::AttemptStatus::Authorized + } } + AciPaymentStatus::Failed => enums::AttemptStatus::Failure, + AciPaymentStatus::Pending => enums::AttemptStatus::Authorizing, + AciPaymentStatus::RedirectShopper => enums::AttemptStatus::AuthenticationPending, } } impl FromStr for AciPaymentStatus { @@ -708,12 +739,14 @@ pub struct ErrorParameters { pub(super) message: String, } -impl TryFrom> - for RouterData +impl TryFrom> + for RouterData +where + Req: GetCaptureMethod, { type Error = error_stack::Report; fn try_from( - item: ResponseRouterData, + item: ResponseRouterData, ) -> Result { let redirection_data = item.response.redirect.map(|data| { let form_fields = std::collections::HashMap::<_, _>::from_iter( @@ -740,16 +773,22 @@ impl TryFrom TryFrom> for AciCaptureRequest { + type Error = error_stack::Report; + + fn try_from(item: &AciRouterData<&PaymentsCaptureRouterData>) -> Result { + let auth = AciAuthType::try_from(&item.router_data.connector_auth_type)?; + Ok(Self { + txn_details: TransactionDetails { + entity_id: auth.entity_id, + amount: item.amount.to_owned(), + currency: item.router_data.request.currency.to_string(), + payment_type: AciPaymentType::Capture, + }, + }) + } +} + +#[derive(Debug, Default, Clone, Deserialize, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AciCaptureResponse { + id: String, + referenced_id: String, + payment_type: AciPaymentType, + amount: StringMajorUnit, + currency: String, + descriptor: String, + result: AciCaptureResult, + result_details: AciCaptureResultDetails, + build_number: String, + timestamp: String, + ndc: Secret, + source: Secret, + payment_method: String, + short_id: String, +} + +#[derive(Debug, Default, Clone, Deserialize, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AciCaptureResult { + code: String, + description: String, +} + +#[derive(Debug, Default, Clone, Deserialize, PartialEq, Eq, Serialize)] +#[serde(rename_all = "PascalCase")] +pub struct AciCaptureResultDetails { + extended_description: String, + #[serde(rename = "clearingInstituteName")] + clearing_institute_name: String, + connector_tx_id1: String, + connector_tx_id3: String, + connector_tx_id2: String, + acquirer_response: String, +} + +impl TryFrom> + for RouterData +{ + type Error = error_stack::Report; + fn try_from( + item: ResponseRouterData, + ) -> Result { + Ok(Self { + status: map_aci_attempt_status( + AciPaymentStatus::from_str(&item.response.result.code)?, + false, + ), + reference_id: Some(item.response.referenced_id.clone()), + response: Ok(PaymentsResponseData::TransactionResponse { + resource_id: ResponseId::ConnectorTransactionId(item.response.id.clone()), + redirection_data: Box::new(None), + mandate_reference: Box::new(None), + connector_metadata: None, + network_txn_id: None, + connector_response_reference_id: Some(item.response.referenced_id), + incremental_authorization_allowed: None, + charges: None, + }), + ..item.data + }) + } +} + #[derive(Default, Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct AciRefundRequest { @@ -854,3 +982,89 @@ impl TryFrom> for RefundsRout }) } } + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] +pub enum AciWebhookEventType { + Payment, +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] +pub enum AciWebhookAction { + Created, + Updated, + Deleted, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AciWebhookCardDetails { + pub bin: Option, + #[serde(rename = "last4Digits")] + pub last4_digits: Option, + pub holder: Option, + pub expiry_month: Option>, + pub expiry_year: Option>, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AciWebhookCustomerDetails { + #[serde(rename = "givenName")] + pub given_name: Option>, + pub surname: Option>, + #[serde(rename = "merchantCustomerId")] + pub merchant_customer_id: Option>, + pub sex: Option>, + pub email: Option, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AciWebhookAuthenticationDetails { + #[serde(rename = "entityId")] + pub entity_id: Secret, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AciWebhookRiskDetails { + pub score: Option, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AciPaymentWebhookPayload { + pub id: String, + pub payment_type: String, + pub payment_brand: String, + pub amount: StringMajorUnit, + pub currency: String, + pub presentation_amount: Option, + pub presentation_currency: Option, + pub descriptor: Option, + pub result: ResultCode, + pub authentication: Option, + pub card: Option, + pub customer: Option, + #[serde(rename = "customParameters")] + pub custom_parameters: Option, + pub risk: Option, + pub build_number: Option, + pub timestamp: String, + pub ndc: String, + #[serde(rename = "channelName")] + pub channel_name: Option, + pub source: Option, + pub payment_method: Option, + #[serde(rename = "shortId")] + pub short_id: Option, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AciWebhookNotification { + #[serde(rename = "type")] + pub event_type: AciWebhookEventType, + pub action: Option, + pub payload: serde_json::Value, +} diff --git a/loadtest/config/development.toml b/loadtest/config/development.toml index b82746362d..98bdb8224a 100644 --- a/loadtest/config/development.toml +++ b/loadtest/config/development.toml @@ -502,8 +502,8 @@ credit = { country = "AD,AT,AU,BE,BG,CA,CH,CY,CZ,DE,DK,EE,ES,FI,FR,GB,GG,GI,GR,H debit = { country = "AD,AT,AU,BE,BG,CA,CH,CY,CZ,DE,DK,EE,ES,FI,FR,GB,GG,GI,GR,HK,HR,HU,IE,IM,IT,JE,LI,LT,LU,LV,MT,MC,MY,NL,NO,NZ,PL,PT,RO,SE,SG,SI,SK,SM,US", currency = "AED,AMD,AOA,ARS,AUD,AWG,AZN,BAM,BBD,BDT,BGN,BIF,BMD,BND,BOB,BRL,BSD,BWP,BYN,BZD,CAD,CHF,CLP,CNY,COP,CRC,CVE,CZK,DJF,DKK,DOP,DZD,EGP,ETB,EUR,FJD,FKP,GBP,GEL,GHS,GIP,GMD,GNF,GTQ,GYD,HKD,HNL,HRK,HTG,HUF,IDR,ILS,INR,ISK,JMD,JPY,KES,KGS,KHR,KMF,KRW,KYD,KZT,LAK,LBP,LKR,LRD,LSL,MAD,MDL,MKD,MNT,MOP,MUR,MVR,MWK,MXN,MYR,MZN,NAD,NGN,NIO,NOK,NPR,NZD,PAB,PEN,PGK,PHP,PKR,PLN,PYG,QAR,RON,RSD,RUB,RWF,SAR,SBD,SCR,SEK,SGD,SHP,SLL,SOS,SRD,STD,SVC,SYP,SZL,THB,TJS,TOP,TRY,TTD,TWD,TZS,UAH,UGX,USD,UYU,UZS,VES,VND,VUV,WST,XAF,XCD,XOF,XPF,YER,ZAR,ZMW,ZWL"} [pm_filters.aci] -credit = { not_available_flows = { capture_method = "manual" }, country = "AD,AE,AT,BE,BG,CH,CN,CO,CR,CY,CZ,DE,DK,DO,EE,EG,ES,ET,FI,FR,GB,GH,GI,GR,GT,HN,HK,HR,HU,ID,IE,IS,IT,JP,KH,LA,LI,LT,LU,LY,MK,MM,MX,MY,MZ,NG,NZ,OM,PA,PE,PK,PL,PT,QA,RO,SA,SN,SE,SI,SK,SV,TH,UA,US,UY,VN,ZM", currency = "AED,ALL,ARS,BGN,CHF,CLP,CNY,COP,CRC,CZK,DKK,DOP,EGP,EUR,GBP,GHS,HKD,HNL,HRK,HUF,IDR,ILS,ISK,JPY,KHR,KPW,LAK,LKR,MAD,MKD,MMK,MXN,MYR,MZN,NGN,NOK,NZD,OMR,PAB,PEN,PHP,PKR,PLN,QAR,RON,RSD,SAR,SEK,SGD,THB,TRY,TWD,UAH,USD,UYU,VND,ZAR,ZMW" } -debit = { not_available_flows = { capture_method = "manual" }, country = "AD,AE,AT,BE,BG,CH,CN,CO,CR,CY,CZ,DE,DK,DO,EE,EG,ES,ET,FI,FR,GB,GH,GI,GR,GT,HN,HK,HR,HU,ID,IE,IS,IT,JP,KH,LA,LI,LT,LU,LY,MK,MM,MX,MY,MZ,NG,NZ,OM,PA,PE,PK,PL,PT,QA,RO,SA,SN,SE,SI,SK,SV,TH,UA,US,UY,VN,ZM", currency = "AED,ALL,ARS,BGN,CHF,CLP,CNY,COP,CRC,CZK,DKK,DOP,EGP,EUR,GBP,GHS,HKD,HNL,HRK,HUF,IDR,ILS,ISK,JPY,KHR,KPW,LAK,LKR,MAD,MKD,MMK,MXN,MYR,MZN,NGN,NOK,NZD,OMR,PAB,PEN,PHP,PKR,PLN,QAR,RON,RSD,SAR,SEK,SGD,THB,TRY,TWD,UAH,USD,UYU,VND,ZAR,ZMW" } +credit = { country = "AD,AE,AT,BE,BG,CH,CN,CO,CR,CY,CZ,DE,DK,DO,EE,EG,ES,ET,FI,FR,GB,GH,GI,GR,GT,HN,HK,HR,HU,ID,IE,IS,IT,JP,KH,LA,LI,LT,LU,LY,MK,MM,MX,MY,MZ,NG,NZ,OM,PA,PE,PK,PL,PT,QA,RO,SA,SN,SE,SI,SK,SV,TH,UA,US,UY,VN,ZM", currency = "AED,ALL,ARS,BGN,CHF,CLP,CNY,COP,CRC,CZK,DKK,DOP,EGP,EUR,GBP,GHS,HKD,HNL,HRK,HUF,IDR,ILS,ISK,JPY,KHR,KPW,LAK,LKR,MAD,MKD,MMK,MXN,MYR,MZN,NGN,NOK,NZD,OMR,PAB,PEN,PHP,PKR,PLN,QAR,RON,RSD,SAR,SEK,SGD,THB,TRY,TWD,UAH,USD,UYU,VND,ZAR,ZMW" } +debit = { country = "AD,AE,AT,BE,BG,CH,CN,CO,CR,CY,CZ,DE,DK,DO,EE,EG,ES,ET,FI,FR,GB,GH,GI,GR,GT,HN,HK,HR,HU,ID,IE,IS,IT,JP,KH,LA,LI,LT,LU,LY,MK,MM,MX,MY,MZ,NG,NZ,OM,PA,PE,PK,PL,PT,QA,RO,SA,SN,SE,SI,SK,SV,TH,UA,US,UY,VN,ZM", currency = "AED,ALL,ARS,BGN,CHF,CLP,CNY,COP,CRC,CZK,DKK,DOP,EGP,EUR,GBP,GHS,HKD,HNL,HRK,HUF,IDR,ILS,ISK,JPY,KHR,KPW,LAK,LKR,MAD,MKD,MMK,MXN,MYR,MZN,NGN,NOK,NZD,OMR,PAB,PEN,PHP,PKR,PLN,QAR,RON,RSD,SAR,SEK,SGD,THB,TRY,TWD,UAH,USD,UYU,VND,ZAR,ZMW" } mb_way = { country = "EE,ES,PT", currency = "EUR" } ali_pay = { country = "CN", currency = "CNY" } eps = { country = "AT", currency = "EUR" }