From b5e2e907b7bcc0f716e83c25a6f815e039b99ce5 Mon Sep 17 00:00:00 2001 From: "yashasvi.kapil" Date: Tue, 2 Dec 2025 03:22:09 +0530 Subject: [PATCH 1/7] feat : [AIRWALLEX] integration --- .../connector-integration/src/connectors.rs | 3 + .../src/connectors/airwallex.rs | 968 ++++++++ .../src/connectors/airwallex/transformers.rs | 2038 +++++++++++++++++ backend/connector-integration/src/types.rs | 1 + backend/domain_types/src/connector_types.rs | 2 + backend/domain_types/src/types.rs | 1 + backend/grpc-server/src/server/payments.rs | 123 +- config/development.toml | 1 + config/production.toml | 1 + config/sandbox.toml | 1 + 10 files changed, 3078 insertions(+), 61 deletions(-) create mode 100644 backend/connector-integration/src/connectors/airwallex.rs create mode 100644 backend/connector-integration/src/connectors/airwallex/transformers.rs diff --git a/backend/connector-integration/src/connectors.rs b/backend/connector-integration/src/connectors.rs index 0cb706495..72d83b222 100644 --- a/backend/connector-integration/src/connectors.rs +++ b/backend/connector-integration/src/connectors.rs @@ -160,3 +160,6 @@ pub use self::shift4::Shift4; pub mod nexixpay; pub use self::nexixpay::Nexixpay; + +pub mod airwallex; +pub use self::airwallex::Airwallex; diff --git a/backend/connector-integration/src/connectors/airwallex.rs b/backend/connector-integration/src/connectors/airwallex.rs new file mode 100644 index 000000000..d8bb1497e --- /dev/null +++ b/backend/connector-integration/src/connectors/airwallex.rs @@ -0,0 +1,968 @@ +pub mod transformers; + +use std::fmt::Debug; + +use common_enums::CurrencyUnit; +use common_utils::{ + errors::CustomResult, events, ext_traits::ByteSliceExt, types::StringMajorUnit, +}; +use domain_types::{ + connector_flow::{ + Accept, Authenticate, Authorize, Capture, CreateAccessToken, CreateOrder, + CreateSessionToken, DefendDispute, PSync, PaymentMethodToken, PostAuthenticate, + PreAuthenticate, RSync, Refund, RepeatPayment, SetupMandate, SubmitEvidence, Void, VoidPC, + }, + connector_types::{ + AcceptDisputeData, AccessTokenRequestData, AccessTokenResponseData, ConnectorCustomerData, + ConnectorCustomerResponse, DisputeDefendData, DisputeFlowData, DisputeResponseData, + PaymentCreateOrderData, PaymentCreateOrderResponse, PaymentFlowData, + PaymentMethodTokenResponse, PaymentMethodTokenizationData, PaymentVoidData, + PaymentsAuthenticateData, PaymentsAuthorizeData, PaymentsCancelPostCaptureData, + PaymentsCaptureData, PaymentsPostAuthenticateData, PaymentsPreAuthenticateData, + PaymentsResponseData, PaymentsSyncData, RefundFlowData, RefundSyncData, RefundsData, + RefundsResponseData, RepeatPaymentData, ResponseId, SessionTokenRequestData, + SessionTokenResponseData, SetupMandateRequestData, SubmitEvidenceData, + }, + errors::{self}, + payment_method_data::PaymentMethodDataTypes, + router_data::{ConnectorAuthType, ErrorResponse}, + router_data_v2::RouterDataV2, + router_response_types::Response, + types::Connectors, +}; +use error_stack::ResultExt; +use hyperswitch_masking::{ExposeInterface, Maskable}; +use interfaces::{ + api::ConnectorCommon, connector_integration_v2::ConnectorIntegrationV2, connector_types, +}; +use serde::Serialize; +use transformers::{ + self as airwallex, AirwallexAccessTokenRequest, AirwallexAccessTokenResponse, + AirwallexCaptureRequest, AirwallexCaptureResponse, AirwallexIntentRequest, + AirwallexIntentResponse, AirwallexPaymentRequest, AirwallexPaymentResponse, + AirwallexRefundRequest, AirwallexRefundResponse, AirwallexRefundSyncResponse, + AirwallexSyncResponse, AirwallexVoidRequest, AirwallexVoidResponse, +}; + +use super::macros; +use crate::types::ResponseRouterData; +use crate::with_error_response_body; + +pub(crate) mod headers { + pub(crate) const CONTENT_TYPE: &str = "Content-Type"; + pub(crate) const AUTHORIZATION: &str = "Authorization"; +} + +// Airwallex struct will be generated by create_all_prerequisites! macro + +// ===== CONNECTOR SERVICE TRAIT IMPLEMENTATIONS ===== +impl + connector_types::ConnectorServiceTrait for Airwallex +{ +} + +impl + connector_types::PaymentAuthorizeV2 for Airwallex +{ +} + +impl + connector_types::PaymentSyncV2 for Airwallex +{ +} + +impl + connector_types::PaymentVoidV2 for Airwallex +{ +} + +impl + connector_types::PaymentVoidPostCaptureV2 for Airwallex +{ +} + +impl + connector_types::PaymentCapture for Airwallex +{ +} + +impl + connector_types::RefundV2 for Airwallex +{ +} + +impl + connector_types::RefundSyncV2 for Airwallex +{ +} + +impl + connector_types::SetupMandateV2 for Airwallex +{ +} + +impl + connector_types::RepeatPaymentV2 for Airwallex +{ +} + +impl + connector_types::PaymentAccessToken for Airwallex +{ +} + +impl + connector_types::PaymentOrderCreate for Airwallex +{ +} + +impl + connector_types::PaymentSessionToken for Airwallex +{ +} + +impl + connector_types::PaymentTokenV2 for Airwallex +{ +} + +impl + connector_types::PaymentPreAuthenticateV2 for Airwallex +{ +} + +impl + connector_types::PaymentAuthenticateV2 for Airwallex +{ +} + +impl + connector_types::PaymentPostAuthenticateV2 for Airwallex +{ +} + +impl + connector_types::AcceptDispute for Airwallex +{ +} + +impl + connector_types::DisputeDefend for Airwallex +{ +} + +impl + connector_types::SubmitEvidenceV2 for Airwallex +{ +} + +impl + connector_types::IncomingWebhook for Airwallex +{ +} + +impl + connector_types::ValidationTrait for Airwallex +{ + fn should_do_access_token(&self, _payment_method: common_enums::PaymentMethod) -> bool { + true + } + + fn should_do_order_create(&self) -> bool { + true // Enable 2-step flow: CreateAccessToken → CreateOrder → Authorize + } +} + +impl + connector_types::CreateConnectorCustomer for Airwallex +{ +} + +// ===== MACRO-BASED IMPLEMENTATION ===== + +macros::create_all_prerequisites!( + connector_name: Airwallex, + generic_type: T, + api: [ + ( + flow: Authorize, + request_body: AirwallexPaymentRequest, + response_body: AirwallexPaymentResponse, + router_data: RouterDataV2, PaymentsResponseData>, + ), + ( + flow: PSync, + response_body: AirwallexSyncResponse, + router_data: RouterDataV2, + ), + ( + flow: Capture, + request_body: AirwallexCaptureRequest, + response_body: AirwallexCaptureResponse, + router_data: RouterDataV2, + ), + ( + flow: Refund, + request_body: AirwallexRefundRequest, + response_body: AirwallexRefundResponse, + router_data: RouterDataV2, + ), + ( + flow: RSync, + response_body: AirwallexRefundSyncResponse, + router_data: RouterDataV2, + ), + ( + flow: Void, + request_body: AirwallexVoidRequest, + response_body: AirwallexVoidResponse, + router_data: RouterDataV2, + ), + ( + flow: CreateAccessToken, + request_body: AirwallexAccessTokenRequest, + response_body: AirwallexAccessTokenResponse, + router_data: RouterDataV2, + ), + ( + flow: CreateOrder, + request_body: AirwallexIntentRequest, + response_body: AirwallexIntentResponse, + router_data: RouterDataV2, + ) + ], + amount_converters: [ + amount_converter: StringMajorUnit + ], + member_functions: { + pub fn build_headers( + &self, + req: &RouterDataV2, + ) -> CustomResult)>, errors::ConnectorError> + where + Self: ConnectorIntegrationV2, + { + let content_type = ConnectorCommon::common_get_content_type(self); + let mut common_headers = self.get_auth_header(&req.connector_auth_type)?; + common_headers.push(( + headers::CONTENT_TYPE.to_string(), + content_type.to_string().into(), + )); + Ok(common_headers) + } + + /// Build headers for payment flows with OAuth token + pub fn build_payment_headers( + &self, + req: &RouterDataV2, + ) -> CustomResult)>, errors::ConnectorError> { + let access_token = req + .resource_common_data + .get_access_token() + .change_context(errors::ConnectorError::FailedToObtainAuthType) + .attach_printable("Failed to get OAuth access token for Airwallex")?; + + Ok(vec![ + ( + headers::CONTENT_TYPE.to_string(), + self.common_get_content_type().to_string().into(), + ), + ( + headers::AUTHORIZATION.to_string(), + format!("Bearer {}", access_token).into(), + ), + ]) + } + + /// Build headers for refund flows with OAuth token + pub fn build_refund_headers( + &self, + req: &RouterDataV2, + ) -> CustomResult)>, errors::ConnectorError> { + let access_token = req + .resource_common_data + .get_access_token() + .change_context(errors::ConnectorError::FailedToObtainAuthType) + .attach_printable("Failed to get OAuth access token for Airwallex refund flow")?; + + Ok(vec![ + ( + headers::CONTENT_TYPE.to_string(), + self.common_get_content_type().to_string().into(), + ), + ( + headers::AUTHORIZATION.to_string(), + format!("Bearer {}", access_token).into(), + ), + ]) + } + + pub fn connector_base_url_payments<'a, F, Req, Res>( + &self, + req: &'a RouterDataV2, + ) -> &'a str { + &req.resource_common_data.connectors.airwallex.base_url + } + + pub fn connector_base_url_refunds<'a, F, Req, Res>( + &self, + req: &'a RouterDataV2, + ) -> &'a str { + &req.resource_common_data.connectors.airwallex.base_url + } + } +); + +macros::macro_connector_implementation!( + connector_default_implementations: [get_content_type, get_error_response_v2], + connector: Airwallex, + curl_request: Json(AirwallexPaymentRequest), + curl_response: AirwallexPaymentResponse, + flow_name: Authorize, + resource_common_data: PaymentFlowData, + flow_request: PaymentsAuthorizeData, + flow_response: PaymentsResponseData, + http_method: Post, + generic_type: T, + [PaymentMethodDataTypes + Debug + Sync + Send + 'static + Serialize], + other_functions: { + fn get_headers( + &self, + req: &RouterDataV2, PaymentsResponseData>, + ) -> CustomResult)>, errors::ConnectorError> { + self.build_payment_headers(req) + } + fn get_url( + &self, + req: &RouterDataV2, PaymentsResponseData>, + ) -> CustomResult { + // Check if we have reference_id from CreateOrder (like Razorpay V2 pattern) + if let Some(reference_id) = &req.resource_common_data.reference_id { + // 2-step flow: confirm existing payment intent using reference_id as order_id + Ok(format!("{}/pa/payment_intents/{}/confirm", self.connector_base_url_payments(req), reference_id)) + } else { + // Fallback: unified flow for direct payment creation + Ok(format!("{}/pa/payment_intents/create", self.connector_base_url_payments(req))) + } + } + } +); + +macros::macro_connector_implementation!( + connector_default_implementations: [get_content_type, get_error_response_v2], + connector: Airwallex, + curl_response: AirwallexSyncResponse, + flow_name: PSync, + resource_common_data: PaymentFlowData, + flow_request: PaymentsSyncData, + flow_response: PaymentsResponseData, + http_method: Get, + generic_type: T, + [PaymentMethodDataTypes + Debug + Sync + Send + 'static + Serialize], + other_functions: { + fn get_headers( + &self, + req: &RouterDataV2, + ) -> CustomResult)>, errors::ConnectorError> { + self.build_payment_headers(req) + } + fn get_url( + &self, + req: &RouterDataV2, + ) -> CustomResult { + let payment_id = req.request.get_connector_transaction_id()?; + Ok(format!("{}/pa/payment_intents/{}", self.connector_base_url_payments(req), payment_id)) + } + } +); + +macros::macro_connector_implementation!( + connector_default_implementations: [get_content_type, get_error_response_v2], + connector: Airwallex, + curl_request: Json(AirwallexCaptureRequest), + curl_response: AirwallexCaptureResponse, + flow_name: Capture, + resource_common_data: PaymentFlowData, + flow_request: PaymentsCaptureData, + flow_response: PaymentsResponseData, + http_method: Post, + generic_type: T, + [PaymentMethodDataTypes + Debug + Sync + Send + 'static + Serialize], + other_functions: { + fn get_headers( + &self, + req: &RouterDataV2, + ) -> CustomResult)>, errors::ConnectorError> { + self.build_payment_headers(req) + } + fn get_url( + &self, + req: &RouterDataV2, + ) -> CustomResult { + let payment_id = match &req.request.connector_transaction_id { + ResponseId::ConnectorTransactionId(id) => id, + _ => return Err(errors::ConnectorError::MissingConnectorTransactionID.into()), + }; + Ok(format!("{}/pa/payment_intents/{}/capture", self.connector_base_url_payments(req), payment_id)) + } + } +); + +macros::macro_connector_implementation!( + connector_default_implementations: [get_content_type, get_error_response_v2], + connector: Airwallex, + curl_request: Json(AirwallexRefundRequest), + curl_response: AirwallexRefundResponse, + flow_name: Refund, + resource_common_data: RefundFlowData, + flow_request: RefundsData, + flow_response: RefundsResponseData, + http_method: Post, + generic_type: T, + [PaymentMethodDataTypes + Debug + Sync + Send + 'static + Serialize], + other_functions: { + fn get_headers( + &self, + req: &RouterDataV2, + ) -> CustomResult)>, errors::ConnectorError> { + self.build_refund_headers(req) + } + fn get_url( + &self, + req: &RouterDataV2, + ) -> CustomResult { + Ok(format!("{}/pa/refunds/create", self.connector_base_url_refunds(req))) + } + } +); + +macros::macro_connector_implementation!( + connector_default_implementations: [get_content_type, get_error_response_v2], + connector: Airwallex, + curl_response: AirwallexRefundSyncResponse, + flow_name: RSync, + resource_common_data: RefundFlowData, + flow_request: RefundSyncData, + flow_response: RefundsResponseData, + http_method: Get, + generic_type: T, + [PaymentMethodDataTypes + Debug + Sync + Send + 'static + Serialize], + other_functions: { + fn get_headers( + &self, + req: &RouterDataV2, + ) -> CustomResult)>, errors::ConnectorError> { + self.build_refund_headers(req) + } + fn get_url( + &self, + req: &RouterDataV2, + ) -> CustomResult { + let refund_id = req.request.connector_refund_id.clone(); + Ok(format!("{}/pa/refunds/{}", self.connector_base_url_refunds(req), refund_id)) + } + } +); + +macros::macro_connector_implementation!( + connector_default_implementations: [get_content_type, get_error_response_v2], + connector: Airwallex, + curl_request: Json(AirwallexAccessTokenRequest), + curl_response: AirwallexAccessTokenResponse, + flow_name: CreateAccessToken, + resource_common_data: PaymentFlowData, + flow_request: AccessTokenRequestData, + flow_response: AccessTokenResponseData, + http_method: Post, + generic_type: T, + [PaymentMethodDataTypes + Debug + Sync + Send + 'static + Serialize], + other_functions: { + fn get_headers( + &self, + req: &RouterDataV2, + ) -> CustomResult)>, errors::ConnectorError> { + let auth = airwallex::AirwallexAuthType::try_from(&req.connector_auth_type) + .change_context(errors::ConnectorError::FailedToObtainAuthType)?; + Ok(vec![ + ( + headers::CONTENT_TYPE.to_string(), + "application/json".to_string().into(), + ), + ( + "x-api-key".to_string(), + auth.x_api_key.expose().to_string().into(), + ), + ( + "x-client-id".to_string(), + auth.x_client_id.expose().to_string().into(), + ), + ]) + } + fn get_url( + &self, + req: &RouterDataV2, + ) -> CustomResult { + Ok(format!("{}/authentication/login", self.connector_base_url_payments(req))) + } + } +); + +macros::macro_connector_implementation!( + connector_default_implementations: [get_content_type, get_error_response_v2], + connector: Airwallex, + curl_request: Json(AirwallexIntentRequest), + curl_response: AirwallexIntentResponse, + flow_name: CreateOrder, + resource_common_data: PaymentFlowData, + flow_request: PaymentCreateOrderData, + flow_response: PaymentCreateOrderResponse, + http_method: Post, + generic_type: T, + [PaymentMethodDataTypes + Debug + Sync + Send + 'static + Serialize], + other_functions: { + fn get_headers( + &self, + req: &RouterDataV2, + ) -> CustomResult)>, errors::ConnectorError> { + self.build_payment_headers(req) + } + fn get_url( + &self, + req: &RouterDataV2, + ) -> CustomResult { + Ok(format!("{}/pa/payment_intents/create", self.connector_base_url_payments(req))) + } + } +); + +// ===== EMPTY IMPLEMENTATIONS FOR OTHER FLOWS ===== +// These will be implemented separately as needed + +macros::macro_connector_implementation!( + connector_default_implementations: [get_content_type, get_error_response_v2], + connector: Airwallex, + curl_request: Json(AirwallexVoidRequest), + curl_response: AirwallexVoidResponse, + flow_name: Void, + resource_common_data: PaymentFlowData, + flow_request: PaymentVoidData, + flow_response: PaymentsResponseData, + http_method: Post, + generic_type: T, + [PaymentMethodDataTypes + Debug + Sync + Send + 'static + Serialize], + other_functions: { + fn get_headers( + &self, + req: &RouterDataV2, + ) -> CustomResult)>, errors::ConnectorError> { + self.build_headers(req) + } + fn get_url( + &self, + req: &RouterDataV2, + ) -> CustomResult { + let payment_id = req.request.connector_transaction_id.clone(); + Ok(format!("{}/pa/payment_intents/{}/cancel", self.connector_base_url_payments(req), payment_id)) + } + } +); + +// Payment Void Post Capture +impl + ConnectorIntegrationV2< + VoidPC, + PaymentFlowData, + PaymentsCancelPostCaptureData, + PaymentsResponseData, + > for Airwallex +{ +} + +// Payment Capture - implemented using macro above + +// Refund - implemented using macro above + +// Refund Sync - implemented using macro above + +// Setup Mandate +impl + ConnectorIntegrationV2< + SetupMandate, + PaymentFlowData, + SetupMandateRequestData, + PaymentsResponseData, + > for Airwallex +{ +} + +// CreateAccessToken - Airwallex Authentication Flow - will be implemented using macro + +// Repeat Payment +impl + ConnectorIntegrationV2 + for Airwallex +{ +} + +// Order Create - implemented using macro above + +// Session Token +impl + ConnectorIntegrationV2< + CreateSessionToken, + PaymentFlowData, + SessionTokenRequestData, + SessionTokenResponseData, + > for Airwallex +{ +} + +// Dispute Accept +impl + ConnectorIntegrationV2 + for Airwallex +{ +} + +// Dispute Defend +impl + ConnectorIntegrationV2 + for Airwallex +{ +} + +// Submit Evidence +impl + ConnectorIntegrationV2 + for Airwallex +{ +} + +// Payment Token (required by PaymentTokenV2 trait) +impl + ConnectorIntegrationV2< + PaymentMethodToken, + PaymentFlowData, + PaymentMethodTokenizationData, + PaymentMethodTokenResponse, + > for Airwallex +{ +} + +// Access Token implementation is above - removing duplicate + +// ===== AUTHENTICATION FLOW CONNECTOR INTEGRATIONS ===== +// Pre Authentication +impl + ConnectorIntegrationV2< + PreAuthenticate, + PaymentFlowData, + PaymentsPreAuthenticateData, + PaymentsResponseData, + > for Airwallex +{ +} + +// Authentication +impl + ConnectorIntegrationV2< + Authenticate, + PaymentFlowData, + PaymentsAuthenticateData, + PaymentsResponseData, + > for Airwallex +{ +} + +// Post Authentication +impl + ConnectorIntegrationV2< + PostAuthenticate, + PaymentFlowData, + PaymentsPostAuthenticateData, + PaymentsResponseData, + > for Airwallex +{ +} + +// ===== CONNECTOR CUSTOMER CONNECTOR INTEGRATIONS ===== +// Create Connector Customer +impl + ConnectorIntegrationV2< + domain_types::connector_flow::CreateConnectorCustomer, + PaymentFlowData, + ConnectorCustomerData, + ConnectorCustomerResponse, + > for Airwallex +{ +} + +// ===== SOURCE VERIFICATION IMPLEMENTATIONS ===== +impl + interfaces::verification::SourceVerification< + Authorize, + PaymentFlowData, + PaymentsAuthorizeData, + PaymentsResponseData, + > for Airwallex +{ +} + +impl + interfaces::verification::SourceVerification< + PSync, + PaymentFlowData, + PaymentsSyncData, + PaymentsResponseData, + > for Airwallex +{ +} + +impl + interfaces::verification::SourceVerification< + Capture, + PaymentFlowData, + PaymentsCaptureData, + PaymentsResponseData, + > for Airwallex +{ +} + +impl + interfaces::verification::SourceVerification< + Void, + PaymentFlowData, + PaymentVoidData, + PaymentsResponseData, + > for Airwallex +{ +} + +impl + interfaces::verification::SourceVerification< + VoidPC, + PaymentFlowData, + PaymentsCancelPostCaptureData, + PaymentsResponseData, + > for Airwallex +{ +} + +impl + interfaces::verification::SourceVerification< + Refund, + RefundFlowData, + RefundsData, + RefundsResponseData, + > for Airwallex +{ +} + +impl + interfaces::verification::SourceVerification< + RSync, + RefundFlowData, + RefundSyncData, + RefundsResponseData, + > for Airwallex +{ +} + +impl + interfaces::verification::SourceVerification< + SetupMandate, + PaymentFlowData, + SetupMandateRequestData, + PaymentsResponseData, + > for Airwallex +{ +} + +impl + interfaces::verification::SourceVerification< + Accept, + DisputeFlowData, + AcceptDisputeData, + DisputeResponseData, + > for Airwallex +{ +} + +impl + interfaces::verification::SourceVerification< + SubmitEvidence, + DisputeFlowData, + SubmitEvidenceData, + DisputeResponseData, + > for Airwallex +{ +} + +impl + interfaces::verification::SourceVerification< + DefendDispute, + DisputeFlowData, + DisputeDefendData, + DisputeResponseData, + > for Airwallex +{ +} + +impl + interfaces::verification::SourceVerification< + CreateOrder, + PaymentFlowData, + PaymentCreateOrderData, + PaymentCreateOrderResponse, + > for Airwallex +{ +} + +impl + interfaces::verification::SourceVerification< + RepeatPayment, + PaymentFlowData, + RepeatPaymentData, + PaymentsResponseData, + > for Airwallex +{ +} + +impl + interfaces::verification::SourceVerification< + CreateSessionToken, + PaymentFlowData, + SessionTokenRequestData, + SessionTokenResponseData, + > for Airwallex +{ +} + +impl + interfaces::verification::SourceVerification< + PaymentMethodToken, + PaymentFlowData, + PaymentMethodTokenizationData, + PaymentMethodTokenResponse, + > for Airwallex +{ +} + +impl + interfaces::verification::SourceVerification< + CreateAccessToken, + PaymentFlowData, + AccessTokenRequestData, + AccessTokenResponseData, + > for Airwallex +{ +} + +// ===== AUTHENTICATION FLOW SOURCE VERIFICATION ===== +impl + interfaces::verification::SourceVerification< + PreAuthenticate, + PaymentFlowData, + PaymentsPreAuthenticateData, + PaymentsResponseData, + > for Airwallex +{ +} + +impl + interfaces::verification::SourceVerification< + Authenticate, + PaymentFlowData, + PaymentsAuthenticateData, + PaymentsResponseData, + > for Airwallex +{ +} + +impl + interfaces::verification::SourceVerification< + PostAuthenticate, + PaymentFlowData, + PaymentsPostAuthenticateData, + PaymentsResponseData, + > for Airwallex +{ +} + +// ===== CONNECTOR CUSTOMER SOURCE VERIFICATION ===== +impl + interfaces::verification::SourceVerification< + domain_types::connector_flow::CreateConnectorCustomer, + PaymentFlowData, + ConnectorCustomerData, + ConnectorCustomerResponse, + > for Airwallex +{ +} + +// ===== CONNECTOR COMMON IMPLEMENTATION ===== +impl ConnectorCommon + for Airwallex +{ + fn id(&self) -> &'static str { + "airwallex" + } + + fn get_currency_unit(&self) -> CurrencyUnit { + CurrencyUnit::Minor + } + + fn common_get_content_type(&self) -> &'static str { + "application/json" + } + + fn base_url<'a>(&self, connectors: &'a Connectors) -> &'a str { + connectors.airwallex.base_url.as_ref() + } + + fn get_auth_header( + &self, + auth_type: &ConnectorAuthType, + ) -> CustomResult)>, errors::ConnectorError> { + // Note: This method should not be used for OAuth-based connectors like Airwallex + // Use build_payment_headers or build_refund_headers instead for OAuth flows + // This method is only used for CreateAccessToken flow + let auth = airwallex::AirwallexAuthType::try_from(auth_type) + .change_context(errors::ConnectorError::FailedToObtainAuthType)?; + Ok(vec![ + ( + "x-api-key".to_string(), + auth.x_api_key.expose().to_string().into(), + ), + ( + "x-client-id".to_string(), + auth.x_client_id.expose().to_string().into(), + ), + ]) + } + + fn build_error_response( + &self, + res: Response, + event_builder: Option<&mut events::Event>, + ) -> CustomResult { + let response: airwallex::AirwallexErrorResponse = res + .response + .parse_struct("AirwallexErrorResponse") + .change_context(errors::ConnectorError::ResponseDeserializationFailed)?; + + with_error_response_body!(event_builder, response); + + Ok(ErrorResponse { + status_code: res.status_code, + code: response.code, + message: response.message, + reason: None, + attempt_status: None, + connector_transaction_id: None, + network_decline_code: None, + network_advice_code: None, + network_error_message: None, + }) + } +} diff --git a/backend/connector-integration/src/connectors/airwallex/transformers.rs b/backend/connector-integration/src/connectors/airwallex/transformers.rs new file mode 100644 index 000000000..b8459a82b --- /dev/null +++ b/backend/connector-integration/src/connectors/airwallex/transformers.rs @@ -0,0 +1,2038 @@ +use crate::types::ResponseRouterData; +use common_enums::{AttemptStatus, Currency, RefundStatus}; +use common_utils::types::StringMajorUnit; +use domain_types::{ + connector_flow::{Authorize, Capture, PSync, RSync, Refund, Void}, + connector_types::{ + PaymentFlowData, PaymentVoidData, PaymentsAuthorizeData, PaymentsCaptureData, + PaymentsResponseData, PaymentsSyncData, RefundFlowData, RefundSyncData, RefundsData, + RefundsResponseData, ResponseId, + }, + errors, + payment_method_data::PaymentMethodDataTypes, + router_data::ConnectorAuthType, + router_data_v2::RouterDataV2, +}; +use hyperswitch_masking::{ExposeInterface, Secret}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +// Airwallex connector configuration constants +pub mod connector_config { + pub const PARTNER_TYPE: &str = "hyperswitch-connector"; + pub const API_VERSION: &str = "v2024.12"; +} + +#[derive(Debug, Clone)] +pub struct AirwallexAuthType { + pub x_api_key: Secret, + pub x_client_id: Secret, +} + +impl TryFrom<&ConnectorAuthType> for AirwallexAuthType { + type Error = error_stack::Report; + + fn try_from(auth_type: &ConnectorAuthType) -> Result { + match auth_type { + ConnectorAuthType::BodyKey { api_key, key1 } => Ok(Self { + x_api_key: api_key.to_owned(), + x_client_id: key1.to_owned(), + }), + _ => Err(error_stack::report!( + errors::ConnectorError::FailedToObtainAuthType + )), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AirwallexErrorResponse { + pub code: String, + pub message: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct AirwallexAccessTokenResponse { + pub token: Secret, + pub expires_at: Option, +} + +// Empty request body for CreateAccessToken - Airwallex requires empty JSON object {} +#[derive(Debug, Serialize)] +pub struct AirwallexAccessTokenRequest { + // Empty struct that serializes to {} - Airwallex API requirement +} + +#[derive(Debug, Serialize)] +pub struct AirwallexPaymentsRequest { + pub amount: StringMajorUnit, + pub currency: Currency, + pub reference: String, +} + +// New unified request type for macro pattern that includes payment intent creation and confirmation +#[derive(Debug, Serialize)] +pub struct AirwallexPaymentRequest { + // Request ID for payment intent creation + pub request_id: String, + // Amount in major currency units (following hyperswitch pattern) + pub amount: StringMajorUnit, + pub currency: Currency, + // Payment method data for confirm step + pub payment_method: AirwallexPaymentMethod, + // Auto-confirm the payment intent + pub confirm: Option, + pub return_url: Option, + // Merchant order reference + pub merchant_order_id: String, + // Device data for fraud detection + pub device_data: Option, + // Options for payment processing + pub payment_method_options: Option, + // UCS identification for Airwallex whitelisting + pub referrer_data: Option, +} + +#[derive(Debug, Serialize)] +pub struct AirwallexPaymentMethod { + #[serde(rename = "type")] + pub method_type: String, + // Remove flatten to create proper nesting: card details under 'card' field + pub card: Option, + // Other payment methods (wallet, pay_later, bank_redirect) not implemented yet +} + +// Removed old AirwallexPaymentMethodData enum - now using individual Option fields for cleaner serialization + +#[derive(Debug, Serialize)] +pub struct AirwallexCardData { + pub number: Secret, + pub expiry_month: Secret, + pub expiry_year: Secret, + pub cvc: Secret, + pub name: Option, +} + +// Note: Wallet, PayLater, and BankRedirect data structures removed +// as they are not implemented yet. Only card payments are supported. + +#[derive(Debug, Serialize)] +pub struct AirwallexDeviceData { + pub ip_address: Option, + pub user_agent: Option, +} + +#[derive(Debug, Serialize)] +pub struct AirwallexPaymentOptions { + pub card: Option, +} + +#[derive(Debug, Serialize)] +pub struct AirwallexCardOptions { + pub auto_capture: Option, + pub three_ds: Option, +} + +#[derive(Debug, Serialize)] +pub struct AirwallexThreeDsOptions { + pub attempt_three_ds: Option, +} + +// Confirm request structure for 2-step flow (only payment method data) +#[derive(Debug, Serialize)] +pub struct AirwallexConfirmRequest { + pub request_id: String, + pub payment_method: AirwallexPaymentMethod, + pub payment_method_options: Option, + pub return_url: Option, + pub device_data: Option, +} + +// Implementation for new unified request type +impl< + T: PaymentMethodDataTypes + + std::fmt::Debug + + std::marker::Sync + + std::marker::Send + + 'static + + Serialize, + > + TryFrom< + super::AirwallexRouterData< + RouterDataV2< + Authorize, + PaymentFlowData, + PaymentsAuthorizeData, + PaymentsResponseData, + >, + T, + >, + > for AirwallexPaymentRequest +{ + type Error = error_stack::Report; + + fn try_from( + item: super::AirwallexRouterData< + RouterDataV2< + Authorize, + PaymentFlowData, + PaymentsAuthorizeData, + PaymentsResponseData, + >, + T, + >, + ) -> Result { + // UCS unified flow - always create payment intent with payment method + + let payment_method = match item.router_data.request.payment_method_data.clone() { + domain_types::payment_method_data::PaymentMethodData::Card(card_data) => { + AirwallexPaymentMethod { + method_type: "card".to_string(), + card: Some(AirwallexCardData { + number: Secret::new(card_data.card_number.peek().to_string()), + expiry_month: card_data.card_exp_month.clone(), + expiry_year: card_data.get_expiry_year_4_digit(), + cvc: card_data.card_cvc.clone(), + name: card_data.card_holder_name.map(|name| name.expose()), + }), + } + } + _ => { + return Err(errors::ConnectorError::NotSupported { + message: "Only card payments are supported by Airwallex connector".to_string(), + connector: "Airwallex", + } + .into()) + } + }; + + let auto_capture = matches!( + item.router_data.request.capture_method, + Some(common_enums::CaptureMethod::Automatic) + ); + + let payment_method_options = Some(AirwallexPaymentOptions { + card: Some(AirwallexCardOptions { + auto_capture: Some(auto_capture), + three_ds: Some(AirwallexThreeDsOptions { + attempt_three_ds: Some(false), // 3DS not implemented yet + }), + }), + }); + + let device_data = item + .router_data + .request + .browser_info + .as_ref() + .map(|browser_info| AirwallexDeviceData { + ip_address: browser_info.ip_address.map(|ip| ip.to_string()), + user_agent: browser_info.user_agent.clone(), + }); + + // Create referrer data for Airwallex identification + let referrer_data = Some(AirwallexReferrerData { + r_type: connector_config::PARTNER_TYPE.to_string(), + version: connector_config::API_VERSION.to_string(), + }); + + // Check if we're in 2-step flow (like Razorpay V2 pattern) + let (request_id, amount, currency, confirm, merchant_order_id) = + if let Some(_reference_id) = &item.router_data.resource_common_data.reference_id { + // 2-step flow: this is a confirm call, reference_id is the payment intent ID + // For confirm endpoint, we don't need amount/currency as they're already set in the intent + ( + format!( + "confirm_{}", + item.router_data + .resource_common_data + .connector_request_reference_id + ), + StringMajorUnit::zero(), // Zero amount for confirm flow - amount already established in CreateOrder + item.router_data.request.currency, + None, // Don't set confirm flag, it's implied by the /confirm endpoint + item.router_data + .resource_common_data + .connector_request_reference_id + .clone(), + ) + } else { + // Unified flow: create and confirm in one call + ( + item.router_data + .resource_common_data + .connector_request_reference_id + .clone(), + item.connector + .amount_converter + .convert( + item.router_data.request.minor_amount, + item.router_data.request.currency, + ) + .map_err(|e| { + errors::ConnectorError::RequestEncodingFailedWithReason(format!( + "Amount conversion failed: {}", + e + )) + })?, + item.router_data.request.currency, + Some(true), // Auto-confirm for UCS pattern + item.router_data + .resource_common_data + .connector_request_reference_id + .clone(), + ) + }; + + Ok(Self { + request_id, + amount, + currency, + payment_method, + confirm, + return_url: item.router_data.request.get_router_return_url().ok(), + merchant_order_id, + device_data, + payment_method_options, + referrer_data, + }) + } +} + +// New unified response type for macro pattern +#[derive(Debug, Deserialize, Serialize)] +pub struct AirwallexPaymentResponse { + pub id: String, + pub status: AirwallexPaymentStatus, + pub amount: Option, // Amount from API response (minor units) + pub currency: Option, + pub created_at: Option, + pub updated_at: Option, + // Payment method information + pub payment_method: Option, + // Next action for 3DS or other redirects + pub next_action: Option, + // Payment intent details + pub payment_intent_id: Option, + // Capture information + pub captured_amount: Option, // Captured amount from API response (minor units) + // Authorization code from processor + pub authorization_code: Option, + // Network transaction ID + pub network_transaction_id: Option, + // Processor response + pub processor_response: Option, + // Risk information + pub risk_score: Option, +} + +// Sync response struct - reuses same structure as payment response since it's the same endpoint +#[derive(Debug, Deserialize, Serialize)] +pub struct AirwallexSyncResponse { + pub id: String, + pub status: AirwallexPaymentStatus, + pub amount: Option, // Amount from API response (minor units) + pub currency: Option, + pub created_at: Option, + pub updated_at: Option, + // Latest payment attempt information + pub latest_payment_attempt: Option, + // Payment method information + pub payment_method: Option, + // Payment intent details + pub payment_intent_id: Option, + // Capture information + pub captured_amount: Option, // Captured amount from API response (minor units) + // Authorization code from processor + pub authorization_code: Option, + // Network transaction ID + pub network_transaction_id: Option, + // Processor response + pub processor_response: Option, + // Risk information + pub risk_score: Option, +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct AirwallexPaymentAttempt { + pub id: Option, + pub status: Option, + pub amount: Option, // Amount from API response (minor units) + pub payment_method: Option, + pub authorization_code: Option, + pub network_transaction_id: Option, + pub processor_response: Option, + pub created_at: Option, + pub updated_at: Option, +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum AirwallexPaymentStatus { + RequiresPaymentMethod, + RequiresCustomerAction, + RequiresCapture, + CaptureRequested, // Payment captured but settlement in progress + Processing, + Succeeded, + Settled, // Payment fully settled - indicates successful completion + Cancelled, + Failed, +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct AirwallexPaymentMethodInfo { + #[serde(rename = "type")] + pub method_type: String, + pub card: Option, +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct AirwallexCardInfo { + pub last4: Option, + pub brand: Option, + pub exp_month: Option, + pub exp_year: Option, + pub fingerprint: Option, +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct AirwallexNextAction { + #[serde(rename = "type")] + pub action_type: String, + pub redirect_to_url: Option, +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct AirwallexRedirectAction { + pub redirect_url: String, + pub return_url: String, +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct AirwallexProcessorResponse { + pub code: Option, + pub message: Option, + pub decline_code: Option, + pub network_code: Option, +} + +// New response transformer that addresses PR #240 critical issues +impl + TryFrom< + ResponseRouterData< + AirwallexPaymentResponse, + RouterDataV2< + Authorize, + PaymentFlowData, + PaymentsAuthorizeData, + PaymentsResponseData, + >, + >, + > for RouterDataV2, PaymentsResponseData> +{ + type Error = error_stack::Report; + + fn try_from( + item: ResponseRouterData< + AirwallexPaymentResponse, + RouterDataV2< + Authorize, + PaymentFlowData, + PaymentsAuthorizeData, + PaymentsResponseData, + >, + >, + ) -> Result { + let status = match item.response.status { + AirwallexPaymentStatus::Succeeded => { + // Verify both authorization and clearing/settlement status + if let Some(processor_response) = &item.response.processor_response { + // Check processor-level status for additional validation + match processor_response.code.as_deref() { + Some("00") | Some("0000") => AttemptStatus::Charged, // Standard approval codes + Some("pending") => AttemptStatus::AuthorizationFailed, // Authorization succeeded but settlement pending + Some(decline_code) if decline_code.starts_with('0') => { + AttemptStatus::Charged + } + _ => AttemptStatus::AuthorizationFailed, // Authorization failed at processor level + } + } else { + // If payment succeeded but we don't have processor details, assume charged + AttemptStatus::Charged + } + } + AirwallexPaymentStatus::RequiresCapture => { + // Payment authorized but not captured yet + AttemptStatus::Authorized + } + AirwallexPaymentStatus::CaptureRequested => { + // Payment captured, settlement in progress - treat as charged + AttemptStatus::Charged + } + AirwallexPaymentStatus::RequiresCustomerAction => { + // 3DS authentication or other customer action needed + AttemptStatus::AuthenticationPending + } + AirwallexPaymentStatus::RequiresPaymentMethod => { + // Payment method validation failed + AttemptStatus::PaymentMethodAwaited + } + AirwallexPaymentStatus::Processing => { + // Payment is being processed + AttemptStatus::Pending + } + AirwallexPaymentStatus::Failed => { + // Payment explicitly failed + AttemptStatus::Failure + } + AirwallexPaymentStatus::Settled => { + // Payment fully settled - final successful state + AttemptStatus::Charged + } + AirwallexPaymentStatus::Cancelled => { + // Payment was cancelled + AttemptStatus::Voided + } + }; + + // Handle 3DS redirection for customer action required + // For now, set to None - will be implemented in a separate flow + let redirection_data = None; + + // Extract network transaction ID for network response fields (PR #240 Issue #4) + let network_txn_id = item + .response + .network_transaction_id + .or(item.response.authorization_code.clone()); + + // Build connector metadata with network-specific fields + let connector_metadata = { + let mut metadata = HashMap::new(); + + if let Some(auth_code) = &item.response.authorization_code { + metadata.insert( + "authorization_code".to_string(), + serde_json::Value::String(auth_code.clone()), + ); + } + + if let Some(risk_score) = &item.response.risk_score { + metadata.insert( + "risk_score".to_string(), + serde_json::Value::String(risk_score.clone()), + ); + } + + if let Some(processor) = &item.response.processor_response { + if let Some(decline_code) = &processor.decline_code { + metadata.insert( + "decline_code".to_string(), + serde_json::Value::String(decline_code.clone()), + ); + } + if let Some(network_code) = &processor.network_code { + metadata.insert( + "network_code".to_string(), + serde_json::Value::String(network_code.clone()), + ); + } + } + + if metadata.is_empty() { + None + } else { + Some(metadata) + } + }; + + // Network response fields for better error handling (PR #240 Issue #4) + let (_network_decline_code, _network_error_message) = + if let Some(processor) = &item.response.processor_response { + (processor.decline_code.clone(), processor.message.clone()) + } else { + (None, None) + }; + + Ok(Self { + response: Ok(PaymentsResponseData::TransactionResponse { + resource_id: ResponseId::ConnectorTransactionId(item.response.id), + redirection_data, + mandate_reference: None, + connector_metadata: connector_metadata + .map(|m| serde_json::Value::Object(m.into_iter().collect())), + network_txn_id, + connector_response_reference_id: item.response.payment_intent_id, + incremental_authorization_allowed: Some(false), // Airwallex doesn't support incremental auth + status_code: item.http_code, + }), + resource_common_data: PaymentFlowData { + status, + ..item.router_data.resource_common_data + }, + ..item.router_data + }) + } +} + +// PSync response transformer that addresses PR #240 critical issues +impl + TryFrom< + ResponseRouterData< + AirwallexSyncResponse, + RouterDataV2, + >, + > for RouterDataV2 +{ + type Error = error_stack::Report; + + fn try_from( + item: ResponseRouterData< + AirwallexSyncResponse, + RouterDataV2, + >, + ) -> Result { + // Address PR #240 Issue #1 & #2: Proper status mapping + // DON'T assume all status: "success" means successful - Check detailed status values + // Handle authorization + clearing status properly - Check both payment intent status and processor codes + let status = match item.response.status { + AirwallexPaymentStatus::Succeeded => { + // Address PR #240 Issue #3: Action Array Handling + // Check latest_payment_attempt if available for more detailed status + if let Some(latest_attempt) = &item.response.latest_payment_attempt { + if let Some(attempt_status) = &latest_attempt.status { + match attempt_status { + AirwallexPaymentStatus::Succeeded => { + // Verify processor-level status for additional validation + if let Some(processor_response) = &latest_attempt.processor_response + { + match processor_response.code.as_deref() { + Some("00") | Some("0000") => AttemptStatus::Charged, + Some("pending") => AttemptStatus::AuthorizationFailed, + Some(decline_code) if decline_code.starts_with('0') => { + AttemptStatus::Charged + } + _ => AttemptStatus::AuthorizationFailed, + } + } else { + AttemptStatus::Charged + } + } + AirwallexPaymentStatus::RequiresCapture => AttemptStatus::Authorized, + AirwallexPaymentStatus::Failed => AttemptStatus::Failure, + _ => { + // Fallback to main payment status + AttemptStatus::Charged + } + } + } else { + AttemptStatus::Charged + } + } else { + // Verify processor-level status for additional validation + if let Some(processor_response) = &item.response.processor_response { + match processor_response.code.as_deref() { + Some("00") | Some("0000") => AttemptStatus::Charged, + Some("pending") => AttemptStatus::AuthorizationFailed, + Some(decline_code) if decline_code.starts_with('0') => { + AttemptStatus::Charged + } + _ => AttemptStatus::AuthorizationFailed, + } + } else { + AttemptStatus::Charged + } + } + } + AirwallexPaymentStatus::RequiresCapture => AttemptStatus::Authorized, + AirwallexPaymentStatus::CaptureRequested => AttemptStatus::Charged, + AirwallexPaymentStatus::RequiresCustomerAction => AttemptStatus::AuthenticationPending, + AirwallexPaymentStatus::RequiresPaymentMethod => AttemptStatus::PaymentMethodAwaited, + AirwallexPaymentStatus::Processing => AttemptStatus::Pending, + AirwallexPaymentStatus::Failed => AttemptStatus::Failure, + AirwallexPaymentStatus::Settled => AttemptStatus::Charged, + AirwallexPaymentStatus::Cancelled => AttemptStatus::Voided, + }; + + // Address PR #240 Issue #4: Network Specific Fields + // Extract network transaction ID (check latest_payment_attempt first, then main response) + let network_txn_id = item + .response + .latest_payment_attempt + .as_ref() + .and_then(|attempt| attempt.network_transaction_id.clone()) + .or_else(|| item.response.network_transaction_id.clone()) + .or_else(|| { + item.response + .latest_payment_attempt + .as_ref() + .and_then(|attempt| attempt.authorization_code.clone()) + }) + .or(item.response.authorization_code.clone()); + + // Build connector metadata with network-specific fields (from latest attempt if available) + let connector_metadata = { + let mut metadata = HashMap::new(); + + // Prefer latest attempt data over main response data + let auth_code = item + .response + .latest_payment_attempt + .as_ref() + .and_then(|attempt| attempt.authorization_code.as_ref()) + .or(item.response.authorization_code.as_ref()); + + if let Some(auth_code) = auth_code { + metadata.insert( + "authorization_code".to_string(), + serde_json::Value::String(auth_code.clone()), + ); + } + + if let Some(risk_score) = &item.response.risk_score { + metadata.insert( + "risk_score".to_string(), + serde_json::Value::String(risk_score.clone()), + ); + } + + // Processor response data (prefer latest attempt) + let processor_response = item + .response + .latest_payment_attempt + .as_ref() + .and_then(|attempt| attempt.processor_response.as_ref()) + .or(item.response.processor_response.as_ref()); + + if let Some(processor) = processor_response { + if let Some(decline_code) = &processor.decline_code { + metadata.insert( + "decline_code".to_string(), + serde_json::Value::String(decline_code.clone()), + ); + } + if let Some(network_code) = &processor.network_code { + metadata.insert( + "network_code".to_string(), + serde_json::Value::String(network_code.clone()), + ); + } + } + + if metadata.is_empty() { + None + } else { + Some(metadata) + } + }; + + // Network response fields for better error handling (PR #240 Issue #4) + let processor_response = item + .response + .latest_payment_attempt + .as_ref() + .and_then(|attempt| attempt.processor_response.as_ref()) + .or(item.response.processor_response.as_ref()); + + let (_network_decline_code, _network_error_message) = + if let Some(processor) = processor_response { + (processor.decline_code.clone(), processor.message.clone()) + } else { + (None, None) + }; + + Ok(Self { + response: Ok(PaymentsResponseData::TransactionResponse { + resource_id: ResponseId::ConnectorTransactionId(item.response.id), + redirection_data: None, // PSync doesn't handle redirections + mandate_reference: None, + connector_metadata: connector_metadata + .map(|m| serde_json::Value::Object(m.into_iter().collect())), + network_txn_id, + connector_response_reference_id: item.response.payment_intent_id, + incremental_authorization_allowed: Some(false), // Airwallex doesn't support incremental auth + status_code: item.http_code, + }), + resource_common_data: PaymentFlowData { + status, + ..item.router_data.resource_common_data + }, + ..item.router_data + }) + } +} +// ===== CAPTURE FLOW TYPES ===== + +#[derive(Debug, Serialize)] +pub struct AirwallexCaptureRequest { + pub amount: StringMajorUnit, // Amount in major units + pub request_id: String, // Unique identifier for this capture request +} + +// Reuse the same response structure as payment response since capture returns updated payment intent +#[derive(Debug, Deserialize, Serialize)] +pub struct AirwallexCaptureResponse { + pub id: String, + pub status: AirwallexPaymentStatus, + pub amount: Option, // Amount from API response (minor units) + pub captured_amount: Option, // Captured amount from API response (minor units) + pub currency: Option, + pub created_at: Option, + pub updated_at: Option, + // Payment method information + pub payment_method: Option, + // Payment intent details + pub payment_intent_id: Option, + // Authorization code from processor + pub authorization_code: Option, + // Network transaction ID + pub network_transaction_id: Option, + // Processor response + pub processor_response: Option, + // Risk information + pub risk_score: Option, + // Latest payment attempt information + pub latest_payment_attempt: Option, +} + +// Request transformer for Capture flow +impl< + T: PaymentMethodDataTypes + + std::fmt::Debug + + std::marker::Sync + + std::marker::Send + + 'static + + Serialize, + > + TryFrom< + super::AirwallexRouterData< + RouterDataV2, + T, + >, + > for AirwallexCaptureRequest +{ + type Error = error_stack::Report; + + fn try_from( + item: super::AirwallexRouterData< + RouterDataV2, + T, + >, + ) -> Result { + // Extract capture amount from the capture data + let capture_amount = item.router_data.request.amount_to_capture; + + // Use connector amount converter for proper amount formatting in major units (hyperswitch pattern) + let amount = item + .connector + .amount_converter + .convert( + common_utils::MinorUnit::new(capture_amount), + item.router_data.request.currency, + ) + .map_err(|e| { + errors::ConnectorError::RequestEncodingFailedWithReason(format!( + "Amount conversion failed: {}", + e + )) + })?; + + // Generate unique request_id for idempotency + let request_id = format!( + "capture_{}", + item.router_data.resource_common_data.payment_id + ); + + Ok(Self { amount, request_id }) + } +} + +// Response transformer for Capture flow - addresses PR #240 critical issues +impl + TryFrom< + ResponseRouterData< + AirwallexCaptureResponse, + RouterDataV2, + >, + > for RouterDataV2 +{ + type Error = error_stack::Report; + + fn try_from( + item: ResponseRouterData< + AirwallexCaptureResponse, + RouterDataV2, + >, + ) -> Result { + // Address PR #240 Issue #1 & #2: Enhanced Capture Status Logic + // DON'T assume all status: "success" means successful capture + // Check both capture status AND detailed response with action type verification + let status = match item.response.status { + AirwallexPaymentStatus::Succeeded => { + // Verify capture was successful by checking captured amount exists + if item.response.captured_amount.unwrap_or(0) > 0 { + // Additional verification with processor-level status + if let Some(processor_response) = &item.response.processor_response { + match processor_response.code.as_deref() { + Some("00") | Some("0000") => AttemptStatus::Charged, // Standard capture approval codes + Some("pending") => AttemptStatus::Pending, // Capture processing + Some(decline_code) if decline_code.starts_with('0') => { + AttemptStatus::Charged + } + _ => AttemptStatus::Failure, // Capture failed at processor level + } + } else { + AttemptStatus::Charged // Valid capture amount confirmed + } + } else { + AttemptStatus::Failure // No captured amount means capture failed + } + } + AirwallexPaymentStatus::Processing => { + // Capture is being processed - check for partial capture + if item.response.captured_amount.unwrap_or(0) > 0 { + AttemptStatus::Charged // Partial capture succeeded + } else { + AttemptStatus::Pending // Still processing + } + } + AirwallexPaymentStatus::RequiresCapture => { + // Payment is still in requires capture state - capture may have failed + AttemptStatus::Failure + } + AirwallexPaymentStatus::Failed => { + // Explicit failure + AttemptStatus::Failure + } + AirwallexPaymentStatus::Cancelled => { + // Payment was cancelled + AttemptStatus::Voided + } + _ => { + // Handle any other statuses as failure for capture flow + AttemptStatus::Failure + } + }; + + // Address PR #240 Issue #3: Action Array Handling + // Check latest_payment_attempt if available for detailed capture status + let refined_status = if let Some(latest_attempt) = &item.response.latest_payment_attempt { + if let Some(attempt_status) = &latest_attempt.status { + match attempt_status { + AirwallexPaymentStatus::Succeeded => { + // Verify processor-level status for capture confirmation + if let Some(processor_response) = &latest_attempt.processor_response { + match processor_response.code.as_deref() { + Some("00") | Some("0000") => AttemptStatus::Charged, + Some("pending") => AttemptStatus::Pending, + Some(decline_code) if decline_code.starts_with('0') => { + AttemptStatus::Charged + } + _ => AttemptStatus::Failure, + } + } else { + status // Use main status if no processor details + } + } + AirwallexPaymentStatus::Failed => AttemptStatus::Failure, + _ => status, // Use main status for other attempt statuses + } + } else { + status + } + } else { + status + }; + + // Address PR #240 Issue #4: Network Specific Fields + // Extract network transaction ID (prefer latest attempt, then main response) + let network_txn_id = item + .response + .latest_payment_attempt + .as_ref() + .and_then(|attempt| attempt.network_transaction_id.clone()) + .or_else(|| item.response.network_transaction_id.clone()) + .or_else(|| { + item.response + .latest_payment_attempt + .as_ref() + .and_then(|attempt| attempt.authorization_code.clone()) + }) + .or(item.response.authorization_code.clone()); + + // Build connector metadata with capture-specific and network fields + let connector_metadata = { + let mut metadata = HashMap::new(); + + // Capture-specific fields + if let Some(captured_amount) = &item.response.captured_amount { + metadata.insert( + "captured_amount".to_string(), + serde_json::Value::Number(serde_json::Number::from(*captured_amount)), + ); + } + + // Authorization code (prefer latest attempt) + let auth_code = item + .response + .latest_payment_attempt + .as_ref() + .and_then(|attempt| attempt.authorization_code.as_ref()) + .or(item.response.authorization_code.as_ref()); + + if let Some(auth_code) = auth_code { + metadata.insert( + "authorization_code".to_string(), + serde_json::Value::String(auth_code.clone()), + ); + } + + if let Some(risk_score) = &item.response.risk_score { + metadata.insert( + "risk_score".to_string(), + serde_json::Value::String(risk_score.clone()), + ); + } + + // Processor response data (prefer latest attempt) + let processor_response = item + .response + .latest_payment_attempt + .as_ref() + .and_then(|attempt| attempt.processor_response.as_ref()) + .or(item.response.processor_response.as_ref()); + + if let Some(processor) = processor_response { + if let Some(decline_code) = &processor.decline_code { + metadata.insert( + "decline_code".to_string(), + serde_json::Value::String(decline_code.clone()), + ); + } + if let Some(network_code) = &processor.network_code { + metadata.insert( + "network_code".to_string(), + serde_json::Value::String(network_code.clone()), + ); + } + if let Some(code) = &processor.code { + metadata.insert( + "processor_code".to_string(), + serde_json::Value::String(code.clone()), + ); + } + } + + if metadata.is_empty() { + None + } else { + Some(metadata) + } + }; + + // Network response fields for better error handling + let processor_response = item + .response + .latest_payment_attempt + .as_ref() + .and_then(|attempt| attempt.processor_response.as_ref()) + .or(item.response.processor_response.as_ref()); + + let (_network_decline_code, _network_error_message) = + if let Some(processor) = processor_response { + (processor.decline_code.clone(), processor.message.clone()) + } else { + (None, None) + }; + + Ok(Self { + response: Ok(PaymentsResponseData::TransactionResponse { + resource_id: ResponseId::ConnectorTransactionId(item.response.id), + redirection_data: None, // Capture doesn't involve redirections + mandate_reference: None, + connector_metadata: connector_metadata + .map(|m| serde_json::Value::Object(m.into_iter().collect())), + network_txn_id, + connector_response_reference_id: item.response.payment_intent_id, + incremental_authorization_allowed: Some(false), // Airwallex doesn't support incremental auth + status_code: item.http_code, + }), + resource_common_data: PaymentFlowData { + status: refined_status, + ..item.router_data.resource_common_data + }, + ..item.router_data + }) + } +} + +// ===== REFUND FLOW TYPES ===== + +#[derive(Debug, Serialize)] +pub struct AirwallexRefundRequest { + pub payment_attempt_id: String, // From connector_transaction_id + pub amount: StringMajorUnit, // Refund amount in major units + pub reason: Option, // Refund reason if provided + pub request_id: String, // Unique identifier for idempotency +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct AirwallexRefundResponse { + pub id: String, // Refund ID + pub request_id: Option, // Echo back request ID + pub payment_intent_id: Option, // Original payment intent ID + pub payment_attempt_id: Option, // Original payment attempt ID + pub amount: Option, // Refund amount from API response + pub currency: Option, // Currency code + pub reason: Option, // Refund reason + pub status: AirwallexRefundStatus, // RECEIVED, ACCEPTED, SETTLED, FAILED + pub created_at: Option, // Creation timestamp + pub updated_at: Option, // Update timestamp + pub acquirer_reference_number: Option, // Network reference + pub failure_details: Option, // Error details if failed + pub metadata: Option, // Additional metadata +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum AirwallexRefundStatus { + Received, + Accepted, + Settled, + Failed, +} + +// Request transformer for Refund flow +impl< + T: PaymentMethodDataTypes + + std::fmt::Debug + + std::marker::Sync + + std::marker::Send + + 'static + + Serialize, + > + TryFrom< + super::AirwallexRouterData< + RouterDataV2, + T, + >, + > for AirwallexRefundRequest +{ + type Error = error_stack::Report; + + fn try_from( + item: super::AirwallexRouterData< + RouterDataV2, + T, + >, + ) -> Result { + // Extract payment attempt ID from connector_transaction_id + let payment_attempt_id = item.router_data.request.connector_transaction_id.clone(); + + // Extract refund amount from RefundsData and convert to major units (hyperswitch pattern) + let refund_amount = item.router_data.request.refund_amount; + let amount = item + .connector + .amount_converter + .convert( + common_utils::MinorUnit::new(refund_amount), + item.router_data.request.currency, + ) + .map_err(|e| { + errors::ConnectorError::RequestEncodingFailedWithReason(format!( + "Amount conversion failed: {}", + e + )) + })?; + + // Generate unique request_id for idempotency + let request_id = format!( + "refund_{}", + item.router_data + .resource_common_data + .refund_id + .as_ref() + .unwrap_or(&"unknown".to_string()) + ); + + Ok(Self { + payment_attempt_id, + amount, + reason: item.router_data.request.reason.clone(), + request_id, + }) + } +} + +// Response transformer for Refund flow - addresses PR #240 critical issues +impl + TryFrom< + ResponseRouterData< + AirwallexRefundResponse, + RouterDataV2, + >, + > for RouterDataV2 +{ + type Error = error_stack::Report; + + fn try_from( + item: ResponseRouterData< + AirwallexRefundResponse, + RouterDataV2, + >, + ) -> Result { + // Address PR #240 Issue #1: Enhanced Refund Status Logic + // DON'T assume all refund actions with status: "success" are successful + // Check multiple layers for comprehensive validation + let status = map_airwallex_refund_status(&item.response.status, &item.response); + + Ok(Self { + response: Ok(RefundsResponseData { + connector_refund_id: item.response.id, + refund_status: status, + status_code: item.http_code, + }), + resource_common_data: RefundFlowData { + status, // Use the same refund status for the flow data + ..item.router_data.resource_common_data + }, + ..item.router_data + }) + } +} + +// ===== REFUND SYNC FLOW TYPES ===== + +// Reuse the same response structure as AirwallexRefundResponse since it's the same endpoint (GET /pa/refunds/{id}) +pub type AirwallexRefundSyncResponse = AirwallexRefundResponse; + +// Response transformer for RSync flow - addresses PR #240 critical issues +impl + TryFrom< + ResponseRouterData< + AirwallexRefundSyncResponse, + RouterDataV2, + >, + > for RouterDataV2 +{ + type Error = error_stack::Report; + + fn try_from( + item: ResponseRouterData< + AirwallexRefundSyncResponse, + RouterDataV2, + >, + ) -> Result { + // Address PR #240 Critical Issues for RSync: + // 1. REFUND STATUS LOGIC - Apply EXACT same validation as Refund flow + // 2. ACTION ARRAY HANDLING - Parse and validate any action arrays in sync response + // 3. NETWORK SPECIFIC FIELDS - Extract all necessary network fields + + // Use the SAME comprehensive status mapping function to ensure consistency + // This prevents the exact issues identified in PR #240 + let status = map_airwallex_refund_status(&item.response.status, &item.response); + + // Additional validation for RSync specific edge cases + // Ensure we're not returning success for stale data or inconsistent states + let validated_status = validate_rsync_status(status, &item.response); + + Ok(Self { + response: Ok(RefundsResponseData { + connector_refund_id: item.response.id, + refund_status: validated_status, + status_code: item.http_code, + }), + resource_common_data: RefundFlowData { + status: validated_status, + ..item.router_data.resource_common_data + }, + ..item.router_data + }) + } +} + +// Address PR #240 Issue #3: Action Array Handling and status validation for RSync +// This function provides additional validation layer for RSync to prevent stale or inconsistent status +fn validate_rsync_status( + status: RefundStatus, + response: &AirwallexRefundSyncResponse, +) -> RefundStatus { + match status { + RefundStatus::Success => { + // Additional validation for success state in RSync + // Verify all expected fields are present for a truly successful refund + if response.acquirer_reference_number.is_some() + && response.amount.unwrap_or(0.0) > 0.0 + && response.failure_details.is_none() + { + RefundStatus::Success + } else { + // If any validation fails, mark as failure + RefundStatus::Failure + } + } + RefundStatus::Pending => { + // For pending status, ensure minimum required fields are present + if response.amount.unwrap_or(0.0) > 0.0 && response.failure_details.is_none() { + RefundStatus::Pending + } else { + RefundStatus::Failure + } + } + RefundStatus::Failure => { + // Keep failure status + RefundStatus::Failure + } + RefundStatus::ManualReview => { + // Manual review status should be preserved + RefundStatus::ManualReview + } + RefundStatus::TransactionFailure => { + // Transaction failure should be preserved + RefundStatus::TransactionFailure + } + } +} + +// Address PR #240 Issue #1: Comprehensive refund status validation +// This function implements robust status checking to avoid the issues identified in PR #240 +fn map_airwallex_refund_status( + status: &AirwallexRefundStatus, + response: &AirwallexRefundResponse, +) -> RefundStatus { + match status { + AirwallexRefundStatus::Received => { + // Check if refund is actually processed or just received + // Validate amount exists and is greater than 0 + if response.amount.unwrap_or(0.0) > 0.0 { + // Also check that no failure details are present + if response.failure_details.is_none() { + RefundStatus::Pending + } else { + RefundStatus::Failure + } + } else { + RefundStatus::Failure + } + } + AirwallexRefundStatus::Accepted => { + // Validate that acquirer_reference_number exists and failure_details is None + // This addresses the issue of not checking detailed response fields + if response.acquirer_reference_number.is_some() && response.failure_details.is_none() { + // Check amount is valid + if response.amount.unwrap_or(0.0) > 0.0 { + RefundStatus::Pending // Will be settled later + } else { + RefundStatus::Failure + } + } else { + RefundStatus::Failure + } + } + AirwallexRefundStatus::Settled => { + // Final success state - but still validate thoroughly + // Check no failure details and valid amount + if response.failure_details.is_none() { + if response.amount.unwrap_or(0.0) > 0.0 { + RefundStatus::Success + } else { + RefundStatus::Failure + } + } else { + RefundStatus::Failure + } + } + AirwallexRefundStatus::Failed => { + // Explicit failure + RefundStatus::Failure + } + } +} + +// ===== VOID FLOW TYPES ===== + +#[derive(Debug, Serialize)] +pub struct AirwallexVoidRequest { + pub cancellation_reason: Option, // Reason for cancellation + pub request_id: String, // Unique identifier for idempotency +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct AirwallexVoidResponse { + pub id: String, // Payment intent ID + pub status: AirwallexPaymentStatus, // Should be CANCELLED + pub amount: Option, // Original payment amount from API response (minor units) + pub currency: Option, // Currency code + pub created_at: Option, // Original creation timestamp + pub updated_at: Option, // Cancellation timestamp + pub cancelled_at: Option, // Specific cancellation timestamp + pub cancellation_reason: Option, // Echo back cancellation reason + // Payment method information + pub payment_method: Option, + // Payment intent details + pub payment_intent_id: Option, + // Authorization code from processor + pub authorization_code: Option, + // Network transaction ID + pub network_transaction_id: Option, + // Processor response + pub processor_response: Option, + // Risk information + pub risk_score: Option, + // Latest payment attempt information + pub latest_payment_attempt: Option, +} + +// Request transformer for Void flow +impl< + T: PaymentMethodDataTypes + + std::fmt::Debug + + std::marker::Sync + + std::marker::Send + + 'static + + Serialize, + > + TryFrom< + super::AirwallexRouterData< + RouterDataV2, + T, + >, + > for AirwallexVoidRequest +{ + type Error = error_stack::Report; + + fn try_from( + item: super::AirwallexRouterData< + RouterDataV2, + T, + >, + ) -> Result { + // Extract cancellation reason from PaymentVoidData (if available) + let cancellation_reason = item + .router_data + .request + .cancellation_reason + .clone() + .or_else(|| Some("Voided by merchant".to_string())); + + // Generate unique request_id for idempotency + let request_id = format!("void_{}", item.router_data.resource_common_data.payment_id); + + Ok(Self { + cancellation_reason, + request_id, + }) + } +} + +// Response transformer for Void flow - addresses PR #240 critical issues +impl + TryFrom< + ResponseRouterData< + AirwallexVoidResponse, + RouterDataV2, + >, + > for RouterDataV2 +{ + type Error = error_stack::Report; + + fn try_from( + item: ResponseRouterData< + AirwallexVoidResponse, + RouterDataV2, + >, + ) -> Result { + // Address PR #240 Critical Issues for Void Operations: + // 1. Enhanced Void Status Logic - Don't assume success based on simple status + // 2. Authorization + Clearing Status validation + // 3. Network Fields extraction + // 4. Comprehensive validation for void completion + + let status = map_airwallex_void_status(&item.response.status, &item.response); + + // Address PR #240 Issue #4: Network Specific Fields + // Extract network transaction ID (prefer latest attempt, then main response) + let network_txn_id = item + .response + .latest_payment_attempt + .as_ref() + .and_then(|attempt| attempt.network_transaction_id.clone()) + .or_else(|| item.response.network_transaction_id.clone()) + .or_else(|| { + item.response + .latest_payment_attempt + .as_ref() + .and_then(|attempt| attempt.authorization_code.clone()) + }) + .or(item.response.authorization_code.clone()); + + // Build connector metadata with void-specific and network fields + let connector_metadata = { + let mut metadata = std::collections::HashMap::new(); + + // Void-specific fields + if let Some(cancelled_at) = &item.response.cancelled_at { + metadata.insert( + "cancelled_at".to_string(), + serde_json::Value::String(cancelled_at.clone()), + ); + } + + if let Some(cancellation_reason) = &item.response.cancellation_reason { + metadata.insert( + "cancellation_reason".to_string(), + serde_json::Value::String(cancellation_reason.clone()), + ); + } + + // Authorization code (prefer latest attempt) + let auth_code = item + .response + .latest_payment_attempt + .as_ref() + .and_then(|attempt| attempt.authorization_code.as_ref()) + .or(item.response.authorization_code.as_ref()); + + if let Some(auth_code) = auth_code { + metadata.insert( + "authorization_code".to_string(), + serde_json::Value::String(auth_code.clone()), + ); + } + + if let Some(risk_score) = &item.response.risk_score { + metadata.insert( + "risk_score".to_string(), + serde_json::Value::String(risk_score.clone()), + ); + } + + // Processor response data (prefer latest attempt) + let processor_response = item + .response + .latest_payment_attempt + .as_ref() + .and_then(|attempt| attempt.processor_response.as_ref()) + .or(item.response.processor_response.as_ref()); + + if let Some(processor) = processor_response { + if let Some(decline_code) = &processor.decline_code { + metadata.insert( + "decline_code".to_string(), + serde_json::Value::String(decline_code.clone()), + ); + } + if let Some(network_code) = &processor.network_code { + metadata.insert( + "network_code".to_string(), + serde_json::Value::String(network_code.clone()), + ); + } + if let Some(code) = &processor.code { + metadata.insert( + "processor_code".to_string(), + serde_json::Value::String(code.clone()), + ); + } + } + + if metadata.is_empty() { + None + } else { + Some(metadata) + } + }; + + Ok(Self { + response: Ok(PaymentsResponseData::TransactionResponse { + resource_id: ResponseId::ConnectorTransactionId(item.response.id), + redirection_data: None, // Void doesn't involve redirections + mandate_reference: None, + connector_metadata: connector_metadata + .map(|m| serde_json::Value::Object(m.into_iter().collect())), + network_txn_id, + connector_response_reference_id: item.response.payment_intent_id, + incremental_authorization_allowed: Some(false), // Airwallex doesn't support incremental auth + status_code: item.http_code, + }), + resource_common_data: PaymentFlowData { + status, + ..item.router_data.resource_common_data + }, + ..item.router_data + }) + } +} + +// Address PR #240 Issue #1: Comprehensive void status validation +// This function implements robust void status checking to avoid the critical issues identified in PR #240 +fn map_airwallex_void_status( + status: &AirwallexPaymentStatus, + response: &AirwallexVoidResponse, +) -> AttemptStatus { + match status { + AirwallexPaymentStatus::Cancelled => { + // Enhanced validation for void success - don't assume success based on status alone + // Validate cancelled_at timestamp exists for confirmation + if response.cancelled_at.is_some() { + // Additional validation: Check no error fields and valid cancellation reason processing + if let Some(processor_response) = &response.processor_response { + // Check processor-level status for void confirmation + match processor_response.code.as_deref() { + Some("00") | Some("0000") => AttemptStatus::Voided, // Standard void approval codes + Some("cancelled") | Some("voided") => AttemptStatus::Voided, // Direct void confirmation + Some(decline_code) if decline_code.starts_with('0') => { + AttemptStatus::Voided + } + None => AttemptStatus::Voided, // No processor code but valid cancelled_at means successful void + _ => AttemptStatus::VoidFailed, // Void failed at processor level + } + } else { + // No processor response but has cancelled_at timestamp - likely successful void + AttemptStatus::Voided + } + } else { + // No cancelled_at timestamp means void not completed successfully + AttemptStatus::VoidFailed + } + } + AirwallexPaymentStatus::RequiresPaymentMethod + | AirwallexPaymentStatus::RequiresCustomerAction + | AirwallexPaymentStatus::RequiresCapture => { + // These statuses indicate payment is still in a voidable state but void action may be in progress + // Check if void is actually being processed + if response.cancellation_reason.is_some() { + AttemptStatus::VoidInitiated // Void request received and being processed + } else { + AttemptStatus::VoidFailed // No void action detected + } + } + AirwallexPaymentStatus::CaptureRequested => { + // Payment capture has been requested but not yet settled - still voidable + // Check if void is actually being processed + if response.cancellation_reason.is_some() { + AttemptStatus::VoidInitiated // Void request received and being processed + } else { + AttemptStatus::VoidFailed // No void action detected + } + } + AirwallexPaymentStatus::Processing => { + // Payment in processing - check if void is being applied + if response.cancellation_reason.is_some() { + AttemptStatus::VoidInitiated + } else { + AttemptStatus::VoidFailed + } + } + AirwallexPaymentStatus::Succeeded => { + // Payment already succeeded - void not possible + AttemptStatus::VoidFailed + } + AirwallexPaymentStatus::Settled => { + // Payment already settled - void not possible + AttemptStatus::VoidFailed + } + AirwallexPaymentStatus::Failed => { + // Payment already failed - no need to void + AttemptStatus::VoidFailed + } + } +} + +// Implementation for confirm request type (2-step flow) +impl< + T: PaymentMethodDataTypes + + std::fmt::Debug + + std::marker::Sync + + std::marker::Send + + 'static + + Serialize, + > + TryFrom< + super::AirwallexRouterData< + RouterDataV2< + Authorize, + PaymentFlowData, + PaymentsAuthorizeData, + PaymentsResponseData, + >, + T, + >, + > for AirwallexConfirmRequest +{ + type Error = error_stack::Report; + + fn try_from( + item: super::AirwallexRouterData< + RouterDataV2< + Authorize, + PaymentFlowData, + PaymentsAuthorizeData, + PaymentsResponseData, + >, + T, + >, + ) -> Result { + // Confirm flow for 2-step process (not currently used in UCS) + + let payment_method = match item.router_data.request.payment_method_data.clone() { + domain_types::payment_method_data::PaymentMethodData::Card(card_data) => { + AirwallexPaymentMethod { + method_type: "card".to_string(), + card: Some(AirwallexCardData { + number: Secret::new(card_data.card_number.peek().to_string()), + expiry_month: card_data.card_exp_month.clone(), + expiry_year: card_data.get_expiry_year_4_digit(), + cvc: card_data.card_cvc.clone(), + name: card_data.card_holder_name.map(|name| name.expose()), + }), + } + } + _ => { + return Err(errors::ConnectorError::NotSupported { + message: "Only card payments are supported by Airwallex connector".to_string(), + connector: "Airwallex", + } + .into()) + } + }; + + let auto_capture = matches!( + item.router_data.request.capture_method, + Some(common_enums::CaptureMethod::Automatic) + ); + + let payment_method_options = Some(AirwallexPaymentOptions { + card: Some(AirwallexCardOptions { + auto_capture: Some(auto_capture), + three_ds: Some(AirwallexThreeDsOptions { + attempt_three_ds: Some(false), // 3DS not implemented yet + }), + }), + }); + + let device_data = item + .router_data + .request + .browser_info + .as_ref() + .map(|browser_info| AirwallexDeviceData { + ip_address: browser_info.ip_address.map(|ip| ip.to_string()), + user_agent: browser_info.user_agent.clone(), + }); + + Ok(Self { + request_id: format!( + "confirm_{}", + item.router_data.resource_common_data.payment_id + ), + payment_method, + payment_method_options, + return_url: item.router_data.request.get_router_return_url().ok(), + device_data, + }) + } +} + +// ===== CREATE ORDER FLOW TYPES ===== + +// Referrer data to identify UCS implementation to Airwallex +#[derive(Debug, Serialize)] +pub struct AirwallexReferrerData { + #[serde(rename = "type")] + pub r_type: String, + pub version: String, +} + +// Order data for payment intents (required for pay-later methods) +#[derive(Debug, Serialize)] +pub struct AirwallexOrderData { + pub products: Vec, + pub shipping: Option, +} + +#[derive(Debug, Serialize)] +pub struct AirwallexProductData { + pub name: String, + pub quantity: u16, + pub unit_price: StringMajorUnit, // Using StringMajorUnit for amount consistency +} + +#[derive(Debug, Serialize)] +pub struct AirwallexShippingData { + pub first_name: Option, + pub last_name: Option, + pub phone_number: Option, + pub shipping_method: Option, + pub address: Option, +} + +#[derive(Debug, Serialize)] +pub struct AirwallexAddressData { + pub country_code: String, + pub state: Option, + pub city: Option, + pub street: Option, + pub postcode: Option, +} + +// CreateOrder request structure (Step 1 - Intent creation without payment method) +#[derive(Debug, Serialize)] +pub struct AirwallexIntentRequest { + pub request_id: String, + pub amount: StringMajorUnit, + pub currency: Currency, + pub merchant_order_id: String, + // UCS identification for Airwallex whitelisting + pub referrer_data: AirwallexReferrerData, + // Optional order data for pay-later methods + pub order: Option, +} + +// CreateOrder response structure +#[derive(Debug, Deserialize, Serialize)] +pub struct AirwallexIntentResponse { + pub id: String, + pub request_id: Option, + pub amount: Option, // Amount from API response (minor units) + pub currency: Option, + pub merchant_order_id: Option, + pub status: AirwallexPaymentStatus, + pub created_at: Option, + pub updated_at: Option, + // Client secret for frontend integration + pub client_secret: Option, + // Available payment method types + pub available_payment_method_types: Option>, +} + +// Request transformer for CreateOrder flow +impl< + T: PaymentMethodDataTypes + + std::fmt::Debug + + std::marker::Sync + + std::marker::Send + + 'static + + Serialize, + > + TryFrom< + super::AirwallexRouterData< + RouterDataV2< + domain_types::connector_flow::CreateOrder, + PaymentFlowData, + domain_types::connector_types::PaymentCreateOrderData, + domain_types::connector_types::PaymentCreateOrderResponse, + >, + T, + >, + > for AirwallexIntentRequest +{ + type Error = error_stack::Report; + + fn try_from( + item: super::AirwallexRouterData< + RouterDataV2< + domain_types::connector_flow::CreateOrder, + PaymentFlowData, + domain_types::connector_types::PaymentCreateOrderData, + domain_types::connector_types::PaymentCreateOrderResponse, + >, + T, + >, + ) -> Result { + // Create referrer data for Airwallex identification + let referrer_data = AirwallexReferrerData { + r_type: connector_config::PARTNER_TYPE.to_string(), + version: connector_config::API_VERSION.to_string(), + }; + + // Convert amount using the same converter as other flows + let amount = item + .connector + .amount_converter + .convert( + item.router_data.request.amount, + item.router_data.request.currency, + ) + .map_err(|e| { + errors::ConnectorError::RequestEncodingFailedWithReason(format!( + "Amount conversion failed: {}", + e + )) + })?; + + // For now, no order data - can be enhanced later when order details are needed + let order = None; + + Ok(Self { + request_id: item + .router_data + .resource_common_data + .connector_request_reference_id + .clone(), + amount, + currency: item.router_data.request.currency, + merchant_order_id: item + .router_data + .resource_common_data + .connector_request_reference_id + .clone(), + referrer_data, + order, + }) + } +} + +// Response transformer for CreateOrder flow +impl + TryFrom< + crate::types::ResponseRouterData< + AirwallexIntentResponse, + RouterDataV2< + domain_types::connector_flow::CreateOrder, + PaymentFlowData, + domain_types::connector_types::PaymentCreateOrderData, + domain_types::connector_types::PaymentCreateOrderResponse, + >, + >, + > + for RouterDataV2< + domain_types::connector_flow::CreateOrder, + PaymentFlowData, + domain_types::connector_types::PaymentCreateOrderData, + domain_types::connector_types::PaymentCreateOrderResponse, + > +{ + type Error = error_stack::Report; + + fn try_from( + item: crate::types::ResponseRouterData< + AirwallexIntentResponse, + RouterDataV2< + domain_types::connector_flow::CreateOrder, + PaymentFlowData, + domain_types::connector_types::PaymentCreateOrderData, + domain_types::connector_types::PaymentCreateOrderResponse, + >, + >, + ) -> Result { + let mut router_data = item.router_data; + + // Map intent status to order status + let status = match item.response.status { + AirwallexPaymentStatus::RequiresPaymentMethod => { + common_enums::AttemptStatus::PaymentMethodAwaited + } + AirwallexPaymentStatus::RequiresCustomerAction => { + common_enums::AttemptStatus::AuthenticationPending + } + AirwallexPaymentStatus::Processing => common_enums::AttemptStatus::Pending, + AirwallexPaymentStatus::Succeeded => common_enums::AttemptStatus::Charged, + AirwallexPaymentStatus::Settled => common_enums::AttemptStatus::Charged, + AirwallexPaymentStatus::Failed => common_enums::AttemptStatus::Failure, + AirwallexPaymentStatus::Cancelled => common_enums::AttemptStatus::Voided, + AirwallexPaymentStatus::RequiresCapture => common_enums::AttemptStatus::Authorized, + AirwallexPaymentStatus::CaptureRequested => common_enums::AttemptStatus::Charged, + }; + + router_data.response = Ok(domain_types::connector_types::PaymentCreateOrderResponse { + order_id: item.response.id.clone(), + }); + + // Update the flow data with the new status and store payment intent ID as reference_id (like Razorpay V2) + router_data.resource_common_data = PaymentFlowData { + status, + reference_id: Some(item.response.id), // Store payment intent ID for subsequent Authorize call + ..router_data.resource_common_data + }; + + Ok(router_data) + } +} + +// Access Token Request Transformer +impl< + T: PaymentMethodDataTypes + + std::fmt::Debug + + std::marker::Sync + + std::marker::Send + + 'static + + Serialize, + > + TryFrom< + super::AirwallexRouterData< + RouterDataV2< + domain_types::connector_flow::CreateAccessToken, + domain_types::connector_types::PaymentFlowData, + domain_types::connector_types::AccessTokenRequestData, + domain_types::connector_types::AccessTokenResponseData, + >, + T, + >, + > for AirwallexAccessTokenRequest +{ + type Error = error_stack::Report; + + fn try_from( + _item: super::AirwallexRouterData< + RouterDataV2< + domain_types::connector_flow::CreateAccessToken, + domain_types::connector_types::PaymentFlowData, + domain_types::connector_types::AccessTokenRequestData, + domain_types::connector_types::AccessTokenResponseData, + >, + T, + >, + ) -> Result { + // Airwallex CreateAccessToken requires empty JSON body {} + // The authentication headers (x-api-key, x-client-id) are set separately + Ok(Self { + // Empty struct serializes to {} + }) + } +} + +// Access Token Response Transformer +impl + TryFrom< + crate::types::ResponseRouterData< + AirwallexAccessTokenResponse, + RouterDataV2< + domain_types::connector_flow::CreateAccessToken, + domain_types::connector_types::PaymentFlowData, + domain_types::connector_types::AccessTokenRequestData, + domain_types::connector_types::AccessTokenResponseData, + >, + >, + > + for RouterDataV2< + domain_types::connector_flow::CreateAccessToken, + domain_types::connector_types::PaymentFlowData, + domain_types::connector_types::AccessTokenRequestData, + domain_types::connector_types::AccessTokenResponseData, + > +{ + type Error = error_stack::Report; + + fn try_from( + item: crate::types::ResponseRouterData< + AirwallexAccessTokenResponse, + RouterDataV2< + domain_types::connector_flow::CreateAccessToken, + domain_types::connector_types::PaymentFlowData, + domain_types::connector_types::AccessTokenRequestData, + domain_types::connector_types::AccessTokenResponseData, + >, + >, + ) -> Result { + let mut router_data = item.router_data; + + router_data.response = Ok(domain_types::connector_types::AccessTokenResponseData { + access_token: item.response.token.expose(), + token_type: Some("Bearer".to_string()), + expires_in: None, // Airwallex doesn't provide explicit expiry in seconds, only timestamp + }); + + Ok(router_data) + } +} diff --git a/backend/connector-integration/src/types.rs b/backend/connector-integration/src/types.rs index c5f8b35c2..c600d4d1d 100644 --- a/backend/connector-integration/src/types.rs +++ b/backend/connector-integration/src/types.rs @@ -77,6 +77,7 @@ impl Box::new(connectors::Barclaycard::new()), ConnectorEnum::Billwerk => Box::new(connectors::Billwerk::new()), ConnectorEnum::Nuvei => Box::new(connectors::Nuvei::new()), + ConnectorEnum::Airwallex => Box::new(connectors::Airwallex::new()), ConnectorEnum::Shift4 => Box::new(connectors::Shift4::new()), ConnectorEnum::Bamboraapac => Box::new(connectors::Bamboraapac::new()), } diff --git a/backend/domain_types/src/connector_types.rs b/backend/domain_types/src/connector_types.rs index 79a2eda63..0270bf105 100644 --- a/backend/domain_types/src/connector_types.rs +++ b/backend/domain_types/src/connector_types.rs @@ -99,6 +99,7 @@ pub enum ConnectorEnum { Shift4, Barclaycard, Nexixpay, + Airwallex, } impl ForeignTryFrom for ConnectorEnum { @@ -161,6 +162,7 @@ impl ForeignTryFrom for ConnectorEnum { grpc_api_types::payments::Connector::Shift4 => Ok(Self::Shift4), grpc_api_types::payments::Connector::Barclaycard => Ok(Self::Barclaycard), grpc_api_types::payments::Connector::Nexixpay => Ok(Self::Nexixpay), + grpc_api_types::payments::Connector::Airwallex => Ok(Self::Airwallex), grpc_api_types::payments::Connector::Unspecified => { Err(ApplicationErrorResponse::BadRequest(ApiError { sub_code: "UNSPECIFIED_CONNECTOR".to_owned(), diff --git a/backend/domain_types/src/types.rs b/backend/domain_types/src/types.rs index faa1d0fd1..2d72d506a 100644 --- a/backend/domain_types/src/types.rs +++ b/backend/domain_types/src/types.rs @@ -160,6 +160,7 @@ pub struct Connectors { pub shift4: ConnectorParams, pub barclaycard: ConnectorParams, pub nexixpay: ConnectorParams, + pub airwallex: ConnectorParams, } #[derive(Clone, Deserialize, Serialize, Debug, Default)] diff --git a/backend/grpc-server/src/server/payments.rs b/backend/grpc-server/src/server/payments.rs index a3de53864..3c71fe34b 100644 --- a/backend/grpc-server/src/server/payments.rs +++ b/backend/grpc-server/src/server/payments.rs @@ -305,67 +305,6 @@ impl Payments { let lineage_ids = &metadata_payload.lineage_ids; let reference_id = &metadata_payload.reference_id; - let should_do_order_create = connector_data.connector.should_do_order_create(); - - let payment_flow_data = if should_do_order_create { - let event_params = EventParams { - _connector_name: &connector.to_string(), - _service_name: service_name, - request_id, - lineage_ids, - reference_id, - shadow_mode: metadata_payload.shadow_mode, - }; - - let order_id = Box::pin(self.handle_order_creation( - config, - connector_data.clone(), - &payment_flow_data, - connector_auth_details.clone(), - &payload, - &connector.to_string(), - service_name, - event_params, - )) - .await?; - - tracing::info!("Order created successfully with order_id: {}", order_id); - payment_flow_data.set_order_reference_id(Some(order_id)) - } else { - payment_flow_data - }; - - let should_do_session_token = connector_data.connector.should_do_session_token(); - - let payment_flow_data = if should_do_session_token { - let event_params = EventParams { - _connector_name: &connector.to_string(), - _service_name: service_name, - request_id, - lineage_ids, - reference_id, - shadow_mode: metadata_payload.shadow_mode, - }; - - let payment_session_data = Box::pin(self.handle_session_token( - config, - connector_data.clone(), - &payment_flow_data, - connector_auth_details.clone(), - &payload, - &connector.to_string(), - service_name, - event_params, - )) - .await?; - tracing::info!( - "Session Token created successfully with session_id: {}", - payment_session_data.session_token - ); - payment_flow_data.set_session_token_id(Some(payment_session_data.session_token)) - } else { - payment_flow_data - }; // Extract access token from Hyperswitch request let cached_access_token = payload @@ -430,6 +369,68 @@ impl Payments { payment_flow_data }; + let should_do_order_create = connector_data.connector.should_do_order_create(); + + let payment_flow_data = if should_do_order_create { + let event_params = EventParams { + _connector_name: &connector.to_string(), + _service_name: service_name, + request_id, + lineage_ids, + reference_id, + shadow_mode: metadata_payload.shadow_mode, + }; + + let order_id = Box::pin(self.handle_order_creation( + config, + connector_data.clone(), + &payment_flow_data, + connector_auth_details.clone(), + &payload, + &connector.to_string(), + service_name, + event_params, + )) + .await?; + + tracing::info!("Order created successfully with order_id: {}", order_id); + payment_flow_data.set_order_reference_id(Some(order_id)) + } else { + payment_flow_data + }; + + let should_do_session_token = connector_data.connector.should_do_session_token(); + + let payment_flow_data = if should_do_session_token { + let event_params = EventParams { + _connector_name: &connector.to_string(), + _service_name: service_name, + request_id, + lineage_ids, + reference_id, + shadow_mode: metadata_payload.shadow_mode, + }; + + let payment_session_data = Box::pin(self.handle_session_token( + config, + connector_data.clone(), + &payment_flow_data, + connector_auth_details.clone(), + &payload, + &connector.to_string(), + service_name, + event_params, + )) + .await?; + tracing::info!( + "Session Token created successfully with session_id: {}", + payment_session_data.session_token + ); + payment_flow_data.set_session_token_id(Some(payment_session_data.session_token)) + } else { + payment_flow_data + }; + // Extract connector customer ID (if provided by Hyperswitch) let cached_connector_customer_id = payload.connector_customer_id.clone(); diff --git a/config/development.toml b/config/development.toml index f3b3bb16a..57bd29e77 100644 --- a/config/development.toml +++ b/config/development.toml @@ -44,6 +44,7 @@ psync = "GW_TXN_SYNC" [connectors] barclaycard.base_url = "https://api.smartpayfuse-test.barclaycard" +airwallex.base_url = "https://api-demo.airwallex.com/api/v1/" shift4.base_url = "https://api.shift4.com" bluesnap.base_url = "https://sandbox.bluesnap.com" bluesnap.secondary_base_url = "https://sandpay.bluesnap.com" diff --git a/config/production.toml b/config/production.toml index fabbdcdc1..b4d7db460 100644 --- a/config/production.toml +++ b/config/production.toml @@ -22,6 +22,7 @@ mitm_proxy_enabled = false [connectors] barclaycard.base_url = "https://api.smartpayfuse.barclaycard" +airwallex.base_url = "https://api.airwallex.com/api/v1/" shift4.base_url = "https://api.shift4.com" bluesnap.base_url = "https://ws.bluesnap.com" bluesnap.secondary_base_url = "https://pay.bluesnap.com" diff --git a/config/sandbox.toml b/config/sandbox.toml index a256656a9..87ed205c0 100644 --- a/config/sandbox.toml +++ b/config/sandbox.toml @@ -22,6 +22,7 @@ mitm_proxy_enabled = false [connectors] barclaycard.base_url = "https://api.smartpayfuse-test.barclaycard" +airwallex.base_url = "https://api-demo.airwallex.com/api/v1/" shift4.base_url = "https://api.shift4.com" bluesnap.base_url = "https://sandbox.bluesnap.com" bluesnap.secondary_base_url = "https://sandpay.bluesnap.com" From 14ff837a86e21ee7983fff5a27d967e2257968f4 Mon Sep 17 00:00:00 2001 From: "yashasvi.kapil" Date: Thu, 4 Dec 2025 20:12:32 +0530 Subject: [PATCH 2/7] chore: REFACTOR --- .../src/connectors/airwallex.rs | 179 ++- .../src/connectors/airwallex/transformers.rs | 1053 +++-------------- 2 files changed, 252 insertions(+), 980 deletions(-) diff --git a/backend/connector-integration/src/connectors/airwallex.rs b/backend/connector-integration/src/connectors/airwallex.rs index d8bb1497e..e30f2e798 100644 --- a/backend/connector-integration/src/connectors/airwallex.rs +++ b/backend/connector-integration/src/connectors/airwallex.rs @@ -10,7 +10,8 @@ use domain_types::{ connector_flow::{ Accept, Authenticate, Authorize, Capture, CreateAccessToken, CreateOrder, CreateSessionToken, DefendDispute, PSync, PaymentMethodToken, PostAuthenticate, - PreAuthenticate, RSync, Refund, RepeatPayment, SetupMandate, SubmitEvidence, Void, VoidPC, + PreAuthenticate, RSync, Refund, RepeatPayment, SdkSessionToken, SetupMandate, + SubmitEvidence, Void, VoidPC, }, connector_types::{ AcceptDisputeData, AccessTokenRequestData, AccessTokenResponseData, ConnectorCustomerData, @@ -19,9 +20,10 @@ use domain_types::{ PaymentMethodTokenResponse, PaymentMethodTokenizationData, PaymentVoidData, PaymentsAuthenticateData, PaymentsAuthorizeData, PaymentsCancelPostCaptureData, PaymentsCaptureData, PaymentsPostAuthenticateData, PaymentsPreAuthenticateData, - PaymentsResponseData, PaymentsSyncData, RefundFlowData, RefundSyncData, RefundsData, - RefundsResponseData, RepeatPaymentData, ResponseId, SessionTokenRequestData, - SessionTokenResponseData, SetupMandateRequestData, SubmitEvidenceData, + PaymentsResponseData, PaymentsSdkSessionTokenData, PaymentsSyncData, RefundFlowData, + RefundSyncData, RefundsData, RefundsResponseData, RepeatPaymentData, ResponseId, + SessionTokenRequestData, SessionTokenResponseData, SetupMandateRequestData, + SubmitEvidenceData, }, errors::{self}, payment_method_data::PaymentMethodDataTypes, @@ -39,7 +41,7 @@ use serde::Serialize; use transformers::{ self as airwallex, AirwallexAccessTokenRequest, AirwallexAccessTokenResponse, AirwallexCaptureRequest, AirwallexCaptureResponse, AirwallexIntentRequest, - AirwallexIntentResponse, AirwallexPaymentRequest, AirwallexPaymentResponse, + AirwallexIntentResponse, AirwallexPaymentRequest, AirwallexPaymentsResponse, AirwallexRefundRequest, AirwallexRefundResponse, AirwallexRefundSyncResponse, AirwallexSyncResponse, AirwallexVoidRequest, AirwallexVoidResponse, }; @@ -56,6 +58,11 @@ pub(crate) mod headers { // Airwallex struct will be generated by create_all_prerequisites! macro // ===== CONNECTOR SERVICE TRAIT IMPLEMENTATIONS ===== +impl + connector_types::SdkSessionTokenV2 for Airwallex +{ +} + impl connector_types::ConnectorServiceTrait for Airwallex { @@ -187,7 +194,7 @@ macros::create_all_prerequisites!( ( flow: Authorize, request_body: AirwallexPaymentRequest, - response_body: AirwallexPaymentResponse, + response_body: AirwallexPaymentsResponse, router_data: RouterDataV2, PaymentsResponseData>, ), ( @@ -235,57 +242,12 @@ macros::create_all_prerequisites!( amount_converter: StringMajorUnit ], member_functions: { - pub fn build_headers( - &self, - req: &RouterDataV2, - ) -> CustomResult)>, errors::ConnectorError> - where - Self: ConnectorIntegrationV2, - { - let content_type = ConnectorCommon::common_get_content_type(self); - let mut common_headers = self.get_auth_header(&req.connector_auth_type)?; - common_headers.push(( - headers::CONTENT_TYPE.to_string(), - content_type.to_string().into(), - )); - Ok(common_headers) - } - - /// Build headers for payment flows with OAuth token - pub fn build_payment_headers( - &self, - req: &RouterDataV2, - ) -> CustomResult)>, errors::ConnectorError> { - let access_token = req - .resource_common_data - .get_access_token() - .change_context(errors::ConnectorError::FailedToObtainAuthType) - .attach_printable("Failed to get OAuth access token for Airwallex")?; - - Ok(vec![ - ( - headers::CONTENT_TYPE.to_string(), - self.common_get_content_type().to_string().into(), - ), - ( - headers::AUTHORIZATION.to_string(), - format!("Bearer {}", access_token).into(), - ), - ]) - } - - /// Build headers for refund flows with OAuth token - pub fn build_refund_headers( + /// Build headers with OAuth Bearer token - works for all flow types + pub fn build_headers( &self, - req: &RouterDataV2, - ) -> CustomResult)>, errors::ConnectorError> { - let access_token = req - .resource_common_data - .get_access_token() - .change_context(errors::ConnectorError::FailedToObtainAuthType) - .attach_printable("Failed to get OAuth access token for Airwallex refund flow")?; - - Ok(vec![ + access_token: &str, + ) -> Vec<(String, Maskable)> { + vec![ ( headers::CONTENT_TYPE.to_string(), self.common_get_content_type().to_string().into(), @@ -294,21 +256,7 @@ macros::create_all_prerequisites!( headers::AUTHORIZATION.to_string(), format!("Bearer {}", access_token).into(), ), - ]) - } - - pub fn connector_base_url_payments<'a, F, Req, Res>( - &self, - req: &'a RouterDataV2, - ) -> &'a str { - &req.resource_common_data.connectors.airwallex.base_url - } - - pub fn connector_base_url_refunds<'a, F, Req, Res>( - &self, - req: &'a RouterDataV2, - ) -> &'a str { - &req.resource_common_data.connectors.airwallex.base_url + ] } } ); @@ -317,7 +265,7 @@ macros::macro_connector_implementation!( connector_default_implementations: [get_content_type, get_error_response_v2], connector: Airwallex, curl_request: Json(AirwallexPaymentRequest), - curl_response: AirwallexPaymentResponse, + curl_response: AirwallexPaymentsResponse, flow_name: Authorize, resource_common_data: PaymentFlowData, flow_request: PaymentsAuthorizeData, @@ -330,20 +278,20 @@ macros::macro_connector_implementation!( &self, req: &RouterDataV2, PaymentsResponseData>, ) -> CustomResult)>, errors::ConnectorError> { - self.build_payment_headers(req) + let access_token = req.resource_common_data.get_access_token() + .change_context(errors::ConnectorError::FailedToObtainAuthType)?; + Ok(self.build_headers(&access_token)) } fn get_url( &self, req: &RouterDataV2, PaymentsResponseData>, ) -> CustomResult { - // Check if we have reference_id from CreateOrder (like Razorpay V2 pattern) - if let Some(reference_id) = &req.resource_common_data.reference_id { - // 2-step flow: confirm existing payment intent using reference_id as order_id - Ok(format!("{}/pa/payment_intents/{}/confirm", self.connector_base_url_payments(req), reference_id)) - } else { - // Fallback: unified flow for direct payment creation - Ok(format!("{}/pa/payment_intents/create", self.connector_base_url_payments(req))) - } + // 2-step flow: Authorize always confirms the payment intent created by CreateOrder + // Get order_id from reference_id (stored after CreateOrder via set_order_reference_id) + let order_id = req.resource_common_data.reference_id + .as_ref() + .ok_or(errors::ConnectorError::MissingRequiredField { field_name: "reference_id" })?; + Ok(format!("{}/pa/payment_intents/{}/confirm", &req.resource_common_data.connectors.airwallex.base_url, order_id)) } } ); @@ -364,14 +312,16 @@ macros::macro_connector_implementation!( &self, req: &RouterDataV2, ) -> CustomResult)>, errors::ConnectorError> { - self.build_payment_headers(req) + let access_token = req.resource_common_data.get_access_token() + .change_context(errors::ConnectorError::FailedToObtainAuthType)?; + Ok(self.build_headers(&access_token)) } fn get_url( &self, req: &RouterDataV2, ) -> CustomResult { let payment_id = req.request.get_connector_transaction_id()?; - Ok(format!("{}/pa/payment_intents/{}", self.connector_base_url_payments(req), payment_id)) + Ok(format!("{}/pa/payment_intents/{}", &req.resource_common_data.connectors.airwallex.base_url, payment_id)) } } ); @@ -393,7 +343,9 @@ macros::macro_connector_implementation!( &self, req: &RouterDataV2, ) -> CustomResult)>, errors::ConnectorError> { - self.build_payment_headers(req) + let access_token = req.resource_common_data.get_access_token() + .change_context(errors::ConnectorError::FailedToObtainAuthType)?; + Ok(self.build_headers(&access_token)) } fn get_url( &self, @@ -403,7 +355,7 @@ macros::macro_connector_implementation!( ResponseId::ConnectorTransactionId(id) => id, _ => return Err(errors::ConnectorError::MissingConnectorTransactionID.into()), }; - Ok(format!("{}/pa/payment_intents/{}/capture", self.connector_base_url_payments(req), payment_id)) + Ok(format!("{}/pa/payment_intents/{}/capture", &req.resource_common_data.connectors.airwallex.base_url, payment_id)) } } ); @@ -425,13 +377,15 @@ macros::macro_connector_implementation!( &self, req: &RouterDataV2, ) -> CustomResult)>, errors::ConnectorError> { - self.build_refund_headers(req) + let access_token = req.resource_common_data.get_access_token() + .change_context(errors::ConnectorError::FailedToObtainAuthType)?; + Ok(self.build_headers(&access_token)) } fn get_url( &self, req: &RouterDataV2, ) -> CustomResult { - Ok(format!("{}/pa/refunds/create", self.connector_base_url_refunds(req))) + Ok(format!("{}/pa/refunds/create", &req.resource_common_data.connectors.airwallex.base_url)) } } ); @@ -452,14 +406,16 @@ macros::macro_connector_implementation!( &self, req: &RouterDataV2, ) -> CustomResult)>, errors::ConnectorError> { - self.build_refund_headers(req) + let access_token = req.resource_common_data.get_access_token() + .change_context(errors::ConnectorError::FailedToObtainAuthType)?; + Ok(self.build_headers(&access_token)) } fn get_url( &self, req: &RouterDataV2, ) -> CustomResult { let refund_id = req.request.connector_refund_id.clone(); - Ok(format!("{}/pa/refunds/{}", self.connector_base_url_refunds(req), refund_id)) + Ok(format!("{}/pa/refunds/{}", &req.resource_common_data.connectors.airwallex.base_url, refund_id)) } } ); @@ -490,11 +446,11 @@ macros::macro_connector_implementation!( ), ( "x-api-key".to_string(), - auth.x_api_key.expose().to_string().into(), + auth.api_key.expose().to_string().into(), ), ( "x-client-id".to_string(), - auth.x_client_id.expose().to_string().into(), + auth.client_id.expose().to_string().into(), ), ]) } @@ -502,7 +458,7 @@ macros::macro_connector_implementation!( &self, req: &RouterDataV2, ) -> CustomResult { - Ok(format!("{}/authentication/login", self.connector_base_url_payments(req))) + Ok(format!("{}/authentication/login", &req.resource_common_data.connectors.airwallex.base_url)) } } ); @@ -524,13 +480,15 @@ macros::macro_connector_implementation!( &self, req: &RouterDataV2, ) -> CustomResult)>, errors::ConnectorError> { - self.build_payment_headers(req) + let access_token = req.resource_common_data.get_access_token() + .change_context(errors::ConnectorError::FailedToObtainAuthType)?; + Ok(self.build_headers(&access_token)) } fn get_url( &self, req: &RouterDataV2, ) -> CustomResult { - Ok(format!("{}/pa/payment_intents/create", self.connector_base_url_payments(req))) + Ok(format!("{}/pa/payment_intents/create", &req.resource_common_data.connectors.airwallex.base_url)) } } ); @@ -555,14 +513,16 @@ macros::macro_connector_implementation!( &self, req: &RouterDataV2, ) -> CustomResult)>, errors::ConnectorError> { - self.build_headers(req) + let access_token = req.resource_common_data.get_access_token() + .change_context(errors::ConnectorError::FailedToObtainAuthType)?; + Ok(self.build_headers(&access_token)) } fn get_url( &self, req: &RouterDataV2, ) -> CustomResult { let payment_id = req.request.connector_transaction_id.clone(); - Ok(format!("{}/pa/payment_intents/{}/cancel", self.connector_base_url_payments(req), payment_id)) + Ok(format!("{}/pa/payment_intents/{}/cancel", &req.resource_common_data.connectors.airwallex.base_url, payment_id)) } } ); @@ -578,6 +538,27 @@ impl { } +// SdkSessionToken - Empty implementation (Airwallex doesn't support SDK session tokens) +impl + interfaces::verification::SourceVerification< + SdkSessionToken, + PaymentFlowData, + PaymentsSdkSessionTokenData, + PaymentsResponseData, + > for Airwallex +{ +} + +impl + ConnectorIntegrationV2< + SdkSessionToken, + PaymentFlowData, + PaymentsSdkSessionTokenData, + PaymentsResponseData, + > for Airwallex +{ +} + // Payment Capture - implemented using macro above // Refund - implemented using macro above @@ -909,7 +890,7 @@ impl Conn } fn get_currency_unit(&self) -> CurrencyUnit { - CurrencyUnit::Minor + CurrencyUnit::Base } fn common_get_content_type(&self) -> &'static str { @@ -932,11 +913,11 @@ impl Conn Ok(vec![ ( "x-api-key".to_string(), - auth.x_api_key.expose().to_string().into(), + auth.api_key.expose().to_string().into(), ), ( "x-client-id".to_string(), - auth.x_client_id.expose().to_string().into(), + auth.client_id.expose().to_string().into(), ), ]) } diff --git a/backend/connector-integration/src/connectors/airwallex/transformers.rs b/backend/connector-integration/src/connectors/airwallex/transformers.rs index b8459a82b..f44b55e55 100644 --- a/backend/connector-integration/src/connectors/airwallex/transformers.rs +++ b/backend/connector-integration/src/connectors/airwallex/transformers.rs @@ -1,6 +1,6 @@ use crate::types::ResponseRouterData; use common_enums::{AttemptStatus, Currency, RefundStatus}; -use common_utils::types::StringMajorUnit; +use common_utils::types::{FloatMajorUnit, StringMajorUnit}; use domain_types::{ connector_flow::{Authorize, Capture, PSync, RSync, Refund, Void}, connector_types::{ @@ -15,32 +15,26 @@ use domain_types::{ }; use hyperswitch_masking::{ExposeInterface, Secret}; use serde::{Deserialize, Serialize}; -use std::collections::HashMap; - -// Airwallex connector configuration constants -pub mod connector_config { - pub const PARTNER_TYPE: &str = "hyperswitch-connector"; - pub const API_VERSION: &str = "v2024.12"; -} #[derive(Debug, Clone)] pub struct AirwallexAuthType { - pub x_api_key: Secret, - pub x_client_id: Secret, + pub api_key: Secret, + pub client_id: Secret, } impl TryFrom<&ConnectorAuthType> for AirwallexAuthType { type Error = error_stack::Report; fn try_from(auth_type: &ConnectorAuthType) -> Result { - match auth_type { - ConnectorAuthType::BodyKey { api_key, key1 } => Ok(Self { - x_api_key: api_key.to_owned(), - x_client_id: key1.to_owned(), - }), - _ => Err(error_stack::report!( + if let ConnectorAuthType::BodyKey { api_key, key1 } = auth_type { + Ok(Self { + api_key: api_key.clone(), + client_id: key1.clone(), + }) + } else { + Err(error_stack::report!( errors::ConnectorError::FailedToObtainAuthType - )), + )) } } } @@ -54,7 +48,8 @@ pub struct AirwallexErrorResponse { #[derive(Debug, Serialize, Deserialize)] pub struct AirwallexAccessTokenResponse { pub token: Secret, - pub expires_at: Option, + #[serde(with = "common_utils::custom_serde::iso8601")] + pub expires_at: time::PrimitiveDateTime, } // Empty request body for CreateAccessToken - Airwallex requires empty JSON object {} @@ -73,33 +68,22 @@ pub struct AirwallexPaymentsRequest { // New unified request type for macro pattern that includes payment intent creation and confirmation #[derive(Debug, Serialize)] pub struct AirwallexPaymentRequest { - // Request ID for payment intent creation + // Request ID for confirm request pub request_id: String, - // Amount in major currency units (following hyperswitch pattern) - pub amount: StringMajorUnit, - pub currency: Currency, // Payment method data for confirm step pub payment_method: AirwallexPaymentMethod, - // Auto-confirm the payment intent - pub confirm: Option, + // Options for payment processing + pub payment_method_options: Option, pub return_url: Option, - // Merchant order reference - pub merchant_order_id: String, // Device data for fraud detection pub device_data: Option, - // Options for payment processing - pub payment_method_options: Option, - // UCS identification for Airwallex whitelisting - pub referrer_data: Option, } #[derive(Debug, Serialize)] pub struct AirwallexPaymentMethod { + pub card: AirwallexCardData, #[serde(rename = "type")] - pub method_type: String, - // Remove flatten to create proper nesting: card details under 'card' field - pub card: Option, - // Other payment methods (wallet, pay_later, bank_redirect) not implemented yet + pub payment_method_type: AirwallexPaymentType, } // Removed old AirwallexPaymentMethodData enum - now using individual Option fields for cleaner serialization @@ -116,6 +100,21 @@ pub struct AirwallexCardData { // Note: Wallet, PayLater, and BankRedirect data structures removed // as they are not implemented yet. Only card payments are supported. +#[derive(Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum AirwallexPaymentType { + Card, + Googlepay, + Paypal, + Klarna, + Atome, + Trustly, + Blik, + Ideal, + Skrill, + BankTransfer, +} + #[derive(Debug, Serialize)] pub struct AirwallexDeviceData { pub ip_address: Option, @@ -187,19 +186,19 @@ impl< let payment_method = match item.router_data.request.payment_method_data.clone() { domain_types::payment_method_data::PaymentMethodData::Card(card_data) => { AirwallexPaymentMethod { - method_type: "card".to_string(), - card: Some(AirwallexCardData { + card: AirwallexCardData { number: Secret::new(card_data.card_number.peek().to_string()), expiry_month: card_data.card_exp_month.clone(), expiry_year: card_data.get_expiry_year_4_digit(), cvc: card_data.card_cvc.clone(), name: card_data.card_holder_name.map(|name| name.expose()), - }), + }, + payment_method_type: AirwallexPaymentType::Card, } } _ => { return Err(errors::ConnectorError::NotSupported { - message: "Only card payments are supported by Airwallex connector".to_string(), + message: "Payment Method".to_string(), connector: "Airwallex", } .into()) @@ -230,84 +229,36 @@ impl< user_agent: browser_info.user_agent.clone(), }); - // Create referrer data for Airwallex identification - let referrer_data = Some(AirwallexReferrerData { - r_type: connector_config::PARTNER_TYPE.to_string(), - version: connector_config::API_VERSION.to_string(), - }); - - // Check if we're in 2-step flow (like Razorpay V2 pattern) - let (request_id, amount, currency, confirm, merchant_order_id) = - if let Some(_reference_id) = &item.router_data.resource_common_data.reference_id { - // 2-step flow: this is a confirm call, reference_id is the payment intent ID - // For confirm endpoint, we don't need amount/currency as they're already set in the intent - ( - format!( - "confirm_{}", - item.router_data - .resource_common_data - .connector_request_reference_id - ), - StringMajorUnit::zero(), // Zero amount for confirm flow - amount already established in CreateOrder - item.router_data.request.currency, - None, // Don't set confirm flag, it's implied by the /confirm endpoint - item.router_data - .resource_common_data - .connector_request_reference_id - .clone(), - ) - } else { - // Unified flow: create and confirm in one call - ( - item.router_data - .resource_common_data - .connector_request_reference_id - .clone(), - item.connector - .amount_converter - .convert( - item.router_data.request.minor_amount, - item.router_data.request.currency, - ) - .map_err(|e| { - errors::ConnectorError::RequestEncodingFailedWithReason(format!( - "Amount conversion failed: {}", - e - )) - })?, - item.router_data.request.currency, - Some(true), // Auto-confirm for UCS pattern - item.router_data - .resource_common_data - .connector_request_reference_id - .clone(), - ) - }; + // Generate unique request_id for Authorize/confirm step + // Different from CreateOrder to avoid Airwallex duplicate_request error + let request_id = format!( + "confirm_{}", + item.router_data + .resource_common_data + .connector_request_reference_id + ); Ok(Self { request_id, - amount, - currency, payment_method, - confirm, + payment_method_options, return_url: item.router_data.request.get_router_return_url().ok(), - merchant_order_id, device_data, - payment_method_options, - referrer_data, }) } } -// New unified response type for macro pattern +// Unified response type for all payment operations (Authorize, PSync, Capture, Void) #[derive(Debug, Deserialize, Serialize)] -pub struct AirwallexPaymentResponse { +pub struct AirwallexPaymentsResponse { pub id: String, pub status: AirwallexPaymentStatus, - pub amount: Option, // Amount from API response (minor units) - pub currency: Option, + pub amount: Option, + pub currency: Option, pub created_at: Option, pub updated_at: Option, + // Latest payment attempt information + pub latest_payment_attempt: Option, // Payment method information pub payment_method: Option, // Next action for 3DS or other redirects @@ -315,7 +266,7 @@ pub struct AirwallexPaymentResponse { // Payment intent details pub payment_intent_id: Option, // Capture information - pub captured_amount: Option, // Captured amount from API response (minor units) + pub captured_amount: Option, // Authorization code from processor pub authorization_code: Option, // Network transaction ID @@ -324,40 +275,19 @@ pub struct AirwallexPaymentResponse { pub processor_response: Option, // Risk information pub risk_score: Option, + // Void-specific fields + pub cancelled_at: Option, + pub cancellation_reason: Option, } -// Sync response struct - reuses same structure as payment response since it's the same endpoint -#[derive(Debug, Deserialize, Serialize)] -pub struct AirwallexSyncResponse { - pub id: String, - pub status: AirwallexPaymentStatus, - pub amount: Option, // Amount from API response (minor units) - pub currency: Option, - pub created_at: Option, - pub updated_at: Option, - // Latest payment attempt information - pub latest_payment_attempt: Option, - // Payment method information - pub payment_method: Option, - // Payment intent details - pub payment_intent_id: Option, - // Capture information - pub captured_amount: Option, // Captured amount from API response (minor units) - // Authorization code from processor - pub authorization_code: Option, - // Network transaction ID - pub network_transaction_id: Option, - // Processor response - pub processor_response: Option, - // Risk information - pub risk_score: Option, -} +// Type alias - reuse the same response structure for PSync +pub type AirwallexSyncResponse = AirwallexPaymentsResponse; #[derive(Debug, Deserialize, Serialize)] pub struct AirwallexPaymentAttempt { pub id: Option, pub status: Option, - pub amount: Option, // Amount from API response (minor units) + pub amount: Option, pub payment_method: Option, pub authorization_code: Option, pub network_transaction_id: Option, @@ -372,6 +302,8 @@ pub enum AirwallexPaymentStatus { RequiresPaymentMethod, RequiresCustomerAction, RequiresCapture, + Authorized, // Payment authorized (from latest_payment_attempt) + Paid, // Payment paid/captured (from latest_payment_attempt) CaptureRequested, // Payment captured but settlement in progress Processing, Succeeded, @@ -417,11 +349,42 @@ pub struct AirwallexProcessorResponse { pub network_code: Option, } +// Helper function to get payment status from Airwallex status (following Hyperswitch pattern) +fn get_payment_status( + status: &AirwallexPaymentStatus, + next_action: &Option, +) -> AttemptStatus { + match status { + AirwallexPaymentStatus::Succeeded => AttemptStatus::Charged, + AirwallexPaymentStatus::Failed => AttemptStatus::Failure, + AirwallexPaymentStatus::Processing => AttemptStatus::Pending, + AirwallexPaymentStatus::RequiresPaymentMethod => AttemptStatus::PaymentMethodAwaited, + AirwallexPaymentStatus::RequiresCustomerAction => { + // Check next_action to determine specific pending state based on action_type + next_action + .as_ref() + .map_or(AttemptStatus::AuthenticationPending, |action| match action + .action_type + .as_str() + { + "device_data_collection" => AttemptStatus::DeviceDataCollectionPending, + _ => AttemptStatus::AuthenticationPending, + }) + } + AirwallexPaymentStatus::RequiresCapture => AttemptStatus::Authorized, + AirwallexPaymentStatus::Authorized => AttemptStatus::Authorized, + AirwallexPaymentStatus::Paid => AttemptStatus::Charged, + AirwallexPaymentStatus::Cancelled => AttemptStatus::Voided, + AirwallexPaymentStatus::CaptureRequested => AttemptStatus::Charged, + AirwallexPaymentStatus::Settled => AttemptStatus::Charged, + } +} + // New response transformer that addresses PR #240 critical issues impl TryFrom< ResponseRouterData< - AirwallexPaymentResponse, + AirwallexPaymentsResponse, RouterDataV2< Authorize, PaymentFlowData, @@ -435,7 +398,7 @@ impl fn try_from( item: ResponseRouterData< - AirwallexPaymentResponse, + AirwallexPaymentsResponse, RouterDataV2< Authorize, PaymentFlowData, @@ -444,57 +407,7 @@ impl >, >, ) -> Result { - let status = match item.response.status { - AirwallexPaymentStatus::Succeeded => { - // Verify both authorization and clearing/settlement status - if let Some(processor_response) = &item.response.processor_response { - // Check processor-level status for additional validation - match processor_response.code.as_deref() { - Some("00") | Some("0000") => AttemptStatus::Charged, // Standard approval codes - Some("pending") => AttemptStatus::AuthorizationFailed, // Authorization succeeded but settlement pending - Some(decline_code) if decline_code.starts_with('0') => { - AttemptStatus::Charged - } - _ => AttemptStatus::AuthorizationFailed, // Authorization failed at processor level - } - } else { - // If payment succeeded but we don't have processor details, assume charged - AttemptStatus::Charged - } - } - AirwallexPaymentStatus::RequiresCapture => { - // Payment authorized but not captured yet - AttemptStatus::Authorized - } - AirwallexPaymentStatus::CaptureRequested => { - // Payment captured, settlement in progress - treat as charged - AttemptStatus::Charged - } - AirwallexPaymentStatus::RequiresCustomerAction => { - // 3DS authentication or other customer action needed - AttemptStatus::AuthenticationPending - } - AirwallexPaymentStatus::RequiresPaymentMethod => { - // Payment method validation failed - AttemptStatus::PaymentMethodAwaited - } - AirwallexPaymentStatus::Processing => { - // Payment is being processed - AttemptStatus::Pending - } - AirwallexPaymentStatus::Failed => { - // Payment explicitly failed - AttemptStatus::Failure - } - AirwallexPaymentStatus::Settled => { - // Payment fully settled - final successful state - AttemptStatus::Charged - } - AirwallexPaymentStatus::Cancelled => { - // Payment was cancelled - AttemptStatus::Voided - } - }; + let status = get_payment_status(&item.response.status, &item.response.next_action); // Handle 3DS redirection for customer action required // For now, set to None - will be implemented in a separate flow @@ -506,61 +419,15 @@ impl .network_transaction_id .or(item.response.authorization_code.clone()); - // Build connector metadata with network-specific fields - let connector_metadata = { - let mut metadata = HashMap::new(); - - if let Some(auth_code) = &item.response.authorization_code { - metadata.insert( - "authorization_code".to_string(), - serde_json::Value::String(auth_code.clone()), - ); - } - - if let Some(risk_score) = &item.response.risk_score { - metadata.insert( - "risk_score".to_string(), - serde_json::Value::String(risk_score.clone()), - ); - } - - if let Some(processor) = &item.response.processor_response { - if let Some(decline_code) = &processor.decline_code { - metadata.insert( - "decline_code".to_string(), - serde_json::Value::String(decline_code.clone()), - ); - } - if let Some(network_code) = &processor.network_code { - metadata.insert( - "network_code".to_string(), - serde_json::Value::String(network_code.clone()), - ); - } - } - - if metadata.is_empty() { - None - } else { - Some(metadata) - } - }; - - // Network response fields for better error handling (PR #240 Issue #4) - let (_network_decline_code, _network_error_message) = - if let Some(processor) = &item.response.processor_response { - (processor.decline_code.clone(), processor.message.clone()) - } else { - (None, None) - }; + // Following hyperswitch pattern - no connector_metadata + let connector_metadata = None; Ok(Self { response: Ok(PaymentsResponseData::TransactionResponse { resource_id: ResponseId::ConnectorTransactionId(item.response.id), redirection_data, mandate_reference: None, - connector_metadata: connector_metadata - .map(|m| serde_json::Value::Object(m.into_iter().collect())), + connector_metadata, network_txn_id, connector_response_reference_id: item.response.payment_intent_id, incremental_authorization_allowed: Some(false), // Airwallex doesn't support incremental auth @@ -575,7 +442,6 @@ impl } } -// PSync response transformer that addresses PR #240 critical issues impl TryFrom< ResponseRouterData< @@ -592,69 +458,9 @@ impl RouterDataV2, >, ) -> Result { - // Address PR #240 Issue #1 & #2: Proper status mapping - // DON'T assume all status: "success" means successful - Check detailed status values - // Handle authorization + clearing status properly - Check both payment intent status and processor codes - let status = match item.response.status { - AirwallexPaymentStatus::Succeeded => { - // Address PR #240 Issue #3: Action Array Handling - // Check latest_payment_attempt if available for more detailed status - if let Some(latest_attempt) = &item.response.latest_payment_attempt { - if let Some(attempt_status) = &latest_attempt.status { - match attempt_status { - AirwallexPaymentStatus::Succeeded => { - // Verify processor-level status for additional validation - if let Some(processor_response) = &latest_attempt.processor_response - { - match processor_response.code.as_deref() { - Some("00") | Some("0000") => AttemptStatus::Charged, - Some("pending") => AttemptStatus::AuthorizationFailed, - Some(decline_code) if decline_code.starts_with('0') => { - AttemptStatus::Charged - } - _ => AttemptStatus::AuthorizationFailed, - } - } else { - AttemptStatus::Charged - } - } - AirwallexPaymentStatus::RequiresCapture => AttemptStatus::Authorized, - AirwallexPaymentStatus::Failed => AttemptStatus::Failure, - _ => { - // Fallback to main payment status - AttemptStatus::Charged - } - } - } else { - AttemptStatus::Charged - } - } else { - // Verify processor-level status for additional validation - if let Some(processor_response) = &item.response.processor_response { - match processor_response.code.as_deref() { - Some("00") | Some("0000") => AttemptStatus::Charged, - Some("pending") => AttemptStatus::AuthorizationFailed, - Some(decline_code) if decline_code.starts_with('0') => { - AttemptStatus::Charged - } - _ => AttemptStatus::AuthorizationFailed, - } - } else { - AttemptStatus::Charged - } - } - } - AirwallexPaymentStatus::RequiresCapture => AttemptStatus::Authorized, - AirwallexPaymentStatus::CaptureRequested => AttemptStatus::Charged, - AirwallexPaymentStatus::RequiresCustomerAction => AttemptStatus::AuthenticationPending, - AirwallexPaymentStatus::RequiresPaymentMethod => AttemptStatus::PaymentMethodAwaited, - AirwallexPaymentStatus::Processing => AttemptStatus::Pending, - AirwallexPaymentStatus::Failed => AttemptStatus::Failure, - AirwallexPaymentStatus::Settled => AttemptStatus::Charged, - AirwallexPaymentStatus::Cancelled => AttemptStatus::Voided, - }; + // Use the same simple status mapping as hyperswitch + let status = get_payment_status(&item.response.status, &item.response.next_action); - // Address PR #240 Issue #4: Network Specific Fields // Extract network transaction ID (check latest_payment_attempt first, then main response) let network_txn_id = item .response @@ -670,84 +476,15 @@ impl }) .or(item.response.authorization_code.clone()); - // Build connector metadata with network-specific fields (from latest attempt if available) - let connector_metadata = { - let mut metadata = HashMap::new(); - - // Prefer latest attempt data over main response data - let auth_code = item - .response - .latest_payment_attempt - .as_ref() - .and_then(|attempt| attempt.authorization_code.as_ref()) - .or(item.response.authorization_code.as_ref()); - - if let Some(auth_code) = auth_code { - metadata.insert( - "authorization_code".to_string(), - serde_json::Value::String(auth_code.clone()), - ); - } - - if let Some(risk_score) = &item.response.risk_score { - metadata.insert( - "risk_score".to_string(), - serde_json::Value::String(risk_score.clone()), - ); - } - - // Processor response data (prefer latest attempt) - let processor_response = item - .response - .latest_payment_attempt - .as_ref() - .and_then(|attempt| attempt.processor_response.as_ref()) - .or(item.response.processor_response.as_ref()); - - if let Some(processor) = processor_response { - if let Some(decline_code) = &processor.decline_code { - metadata.insert( - "decline_code".to_string(), - serde_json::Value::String(decline_code.clone()), - ); - } - if let Some(network_code) = &processor.network_code { - metadata.insert( - "network_code".to_string(), - serde_json::Value::String(network_code.clone()), - ); - } - } - - if metadata.is_empty() { - None - } else { - Some(metadata) - } - }; - - // Network response fields for better error handling (PR #240 Issue #4) - let processor_response = item - .response - .latest_payment_attempt - .as_ref() - .and_then(|attempt| attempt.processor_response.as_ref()) - .or(item.response.processor_response.as_ref()); - - let (_network_decline_code, _network_error_message) = - if let Some(processor) = processor_response { - (processor.decline_code.clone(), processor.message.clone()) - } else { - (None, None) - }; + // Following hyperswitch pattern - no connector_metadata + let connector_metadata = None; Ok(Self { response: Ok(PaymentsResponseData::TransactionResponse { resource_id: ResponseId::ConnectorTransactionId(item.response.id), redirection_data: None, // PSync doesn't handle redirections mandate_reference: None, - connector_metadata: connector_metadata - .map(|m| serde_json::Value::Object(m.into_iter().collect())), + connector_metadata, network_txn_id, connector_response_reference_id: item.response.payment_intent_id, incremental_authorization_allowed: Some(false), // Airwallex doesn't support incremental auth @@ -769,31 +506,8 @@ pub struct AirwallexCaptureRequest { pub request_id: String, // Unique identifier for this capture request } -// Reuse the same response structure as payment response since capture returns updated payment intent -#[derive(Debug, Deserialize, Serialize)] -pub struct AirwallexCaptureResponse { - pub id: String, - pub status: AirwallexPaymentStatus, - pub amount: Option, // Amount from API response (minor units) - pub captured_amount: Option, // Captured amount from API response (minor units) - pub currency: Option, - pub created_at: Option, - pub updated_at: Option, - // Payment method information - pub payment_method: Option, - // Payment intent details - pub payment_intent_id: Option, - // Authorization code from processor - pub authorization_code: Option, - // Network transaction ID - pub network_transaction_id: Option, - // Processor response - pub processor_response: Option, - // Risk information - pub risk_score: Option, - // Latest payment attempt information - pub latest_payment_attempt: Option, -} +// Type alias - reuse the same response structure for Capture +pub type AirwallexCaptureResponse = AirwallexPaymentsResponse; // Request transformer for Capture flow impl< @@ -837,10 +551,12 @@ impl< )) })?; - // Generate unique request_id for idempotency + // Generate unique request_id for idempotency using connector_request_reference_id let request_id = format!( "capture_{}", - item.router_data.resource_common_data.payment_id + item.router_data + .resource_common_data + .connector_request_reference_id ); Ok(Self { amount, request_id }) @@ -864,85 +580,8 @@ impl RouterDataV2, >, ) -> Result { - // Address PR #240 Issue #1 & #2: Enhanced Capture Status Logic - // DON'T assume all status: "success" means successful capture - // Check both capture status AND detailed response with action type verification - let status = match item.response.status { - AirwallexPaymentStatus::Succeeded => { - // Verify capture was successful by checking captured amount exists - if item.response.captured_amount.unwrap_or(0) > 0 { - // Additional verification with processor-level status - if let Some(processor_response) = &item.response.processor_response { - match processor_response.code.as_deref() { - Some("00") | Some("0000") => AttemptStatus::Charged, // Standard capture approval codes - Some("pending") => AttemptStatus::Pending, // Capture processing - Some(decline_code) if decline_code.starts_with('0') => { - AttemptStatus::Charged - } - _ => AttemptStatus::Failure, // Capture failed at processor level - } - } else { - AttemptStatus::Charged // Valid capture amount confirmed - } - } else { - AttemptStatus::Failure // No captured amount means capture failed - } - } - AirwallexPaymentStatus::Processing => { - // Capture is being processed - check for partial capture - if item.response.captured_amount.unwrap_or(0) > 0 { - AttemptStatus::Charged // Partial capture succeeded - } else { - AttemptStatus::Pending // Still processing - } - } - AirwallexPaymentStatus::RequiresCapture => { - // Payment is still in requires capture state - capture may have failed - AttemptStatus::Failure - } - AirwallexPaymentStatus::Failed => { - // Explicit failure - AttemptStatus::Failure - } - AirwallexPaymentStatus::Cancelled => { - // Payment was cancelled - AttemptStatus::Voided - } - _ => { - // Handle any other statuses as failure for capture flow - AttemptStatus::Failure - } - }; - - // Address PR #240 Issue #3: Action Array Handling - // Check latest_payment_attempt if available for detailed capture status - let refined_status = if let Some(latest_attempt) = &item.response.latest_payment_attempt { - if let Some(attempt_status) = &latest_attempt.status { - match attempt_status { - AirwallexPaymentStatus::Succeeded => { - // Verify processor-level status for capture confirmation - if let Some(processor_response) = &latest_attempt.processor_response { - match processor_response.code.as_deref() { - Some("00") | Some("0000") => AttemptStatus::Charged, - Some("pending") => AttemptStatus::Pending, - Some(decline_code) if decline_code.starts_with('0') => { - AttemptStatus::Charged - } - _ => AttemptStatus::Failure, - } - } else { - status // Use main status if no processor details - } - } - AirwallexPaymentStatus::Failed => AttemptStatus::Failure, - _ => status, // Use main status for other attempt statuses - } - } else { - status - } - } else { - status - }; + // Use the same simple status mapping as hyperswitch + let status = get_payment_status(&item.response.status, &item.response.next_action); // Address PR #240 Issue #4: Network Specific Fields // Extract network transaction ID (prefer latest attempt, then main response) @@ -960,105 +599,22 @@ impl }) .or(item.response.authorization_code.clone()); - // Build connector metadata with capture-specific and network fields - let connector_metadata = { - let mut metadata = HashMap::new(); - - // Capture-specific fields - if let Some(captured_amount) = &item.response.captured_amount { - metadata.insert( - "captured_amount".to_string(), - serde_json::Value::Number(serde_json::Number::from(*captured_amount)), - ); - } - - // Authorization code (prefer latest attempt) - let auth_code = item - .response - .latest_payment_attempt - .as_ref() - .and_then(|attempt| attempt.authorization_code.as_ref()) - .or(item.response.authorization_code.as_ref()); - - if let Some(auth_code) = auth_code { - metadata.insert( - "authorization_code".to_string(), - serde_json::Value::String(auth_code.clone()), - ); - } - - if let Some(risk_score) = &item.response.risk_score { - metadata.insert( - "risk_score".to_string(), - serde_json::Value::String(risk_score.clone()), - ); - } - - // Processor response data (prefer latest attempt) - let processor_response = item - .response - .latest_payment_attempt - .as_ref() - .and_then(|attempt| attempt.processor_response.as_ref()) - .or(item.response.processor_response.as_ref()); - - if let Some(processor) = processor_response { - if let Some(decline_code) = &processor.decline_code { - metadata.insert( - "decline_code".to_string(), - serde_json::Value::String(decline_code.clone()), - ); - } - if let Some(network_code) = &processor.network_code { - metadata.insert( - "network_code".to_string(), - serde_json::Value::String(network_code.clone()), - ); - } - if let Some(code) = &processor.code { - metadata.insert( - "processor_code".to_string(), - serde_json::Value::String(code.clone()), - ); - } - } - - if metadata.is_empty() { - None - } else { - Some(metadata) - } - }; - - // Network response fields for better error handling - let processor_response = item - .response - .latest_payment_attempt - .as_ref() - .and_then(|attempt| attempt.processor_response.as_ref()) - .or(item.response.processor_response.as_ref()); - - let (_network_decline_code, _network_error_message) = - if let Some(processor) = processor_response { - (processor.decline_code.clone(), processor.message.clone()) - } else { - (None, None) - }; + // Following hyperswitch pattern - no connector_metadata + let connector_metadata = None; Ok(Self { response: Ok(PaymentsResponseData::TransactionResponse { resource_id: ResponseId::ConnectorTransactionId(item.response.id), redirection_data: None, // Capture doesn't involve redirections mandate_reference: None, - connector_metadata: connector_metadata - .map(|m| serde_json::Value::Object(m.into_iter().collect())), + connector_metadata, network_txn_id, connector_response_reference_id: item.response.payment_intent_id, incremental_authorization_allowed: Some(false), // Airwallex doesn't support incremental auth status_code: item.http_code, }), resource_common_data: PaymentFlowData { - status: refined_status, + status, ..item.router_data.resource_common_data }, ..item.router_data @@ -1078,12 +634,12 @@ pub struct AirwallexRefundRequest { #[derive(Debug, Deserialize, Serialize)] pub struct AirwallexRefundResponse { - pub id: String, // Refund ID - pub request_id: Option, // Echo back request ID - pub payment_intent_id: Option, // Original payment intent ID - pub payment_attempt_id: Option, // Original payment attempt ID - pub amount: Option, // Refund amount from API response - pub currency: Option, // Currency code + pub id: String, // Refund ID + pub request_id: Option, // Echo back request ID + pub payment_intent_id: Option, // Original payment intent ID + pub payment_attempt_id: Option, // Original payment attempt ID + pub amount: Option, + pub currency: Option, // Currency code pub reason: Option, // Refund reason pub status: AirwallexRefundStatus, // RECEIVED, ACCEPTED, SETTLED, FAILED pub created_at: Option, // Creation timestamp @@ -1145,14 +701,12 @@ impl< )) })?; - // Generate unique request_id for idempotency + // Generate unique request_id for idempotency using connector_request_reference_id let request_id = format!( "refund_{}", item.router_data .resource_common_data - .refund_id - .as_ref() - .unwrap_or(&"unknown".to_string()) + .connector_request_reference_id ); Ok(Self { @@ -1181,10 +735,7 @@ impl RouterDataV2, >, ) -> Result { - // Address PR #240 Issue #1: Enhanced Refund Status Logic - // DON'T assume all refund actions with status: "success" are successful - // Check multiple layers for comprehensive validation - let status = map_airwallex_refund_status(&item.response.status, &item.response); + let status = RefundStatus::from(item.response.status); Ok(Self { response: Ok(RefundsResponseData { @@ -1193,7 +744,7 @@ impl status_code: item.http_code, }), resource_common_data: RefundFlowData { - status, // Use the same refund status for the flow data + status, ..item.router_data.resource_common_data }, ..item.router_data @@ -1223,27 +774,16 @@ impl RouterDataV2, >, ) -> Result { - // Address PR #240 Critical Issues for RSync: - // 1. REFUND STATUS LOGIC - Apply EXACT same validation as Refund flow - // 2. ACTION ARRAY HANDLING - Parse and validate any action arrays in sync response - // 3. NETWORK SPECIFIC FIELDS - Extract all necessary network fields - - // Use the SAME comprehensive status mapping function to ensure consistency - // This prevents the exact issues identified in PR #240 - let status = map_airwallex_refund_status(&item.response.status, &item.response); - - // Additional validation for RSync specific edge cases - // Ensure we're not returning success for stale data or inconsistent states - let validated_status = validate_rsync_status(status, &item.response); + let status = RefundStatus::from(item.response.status); Ok(Self { response: Ok(RefundsResponseData { connector_refund_id: item.response.id, - refund_status: validated_status, + refund_status: status, status_code: item.http_code, }), resource_common_data: RefundFlowData { - status: validated_status, + status, ..item.router_data.resource_common_data }, ..item.router_data @@ -1251,100 +791,14 @@ impl } } -// Address PR #240 Issue #3: Action Array Handling and status validation for RSync -// This function provides additional validation layer for RSync to prevent stale or inconsistent status -fn validate_rsync_status( - status: RefundStatus, - response: &AirwallexRefundSyncResponse, -) -> RefundStatus { - match status { - RefundStatus::Success => { - // Additional validation for success state in RSync - // Verify all expected fields are present for a truly successful refund - if response.acquirer_reference_number.is_some() - && response.amount.unwrap_or(0.0) > 0.0 - && response.failure_details.is_none() - { - RefundStatus::Success - } else { - // If any validation fails, mark as failure - RefundStatus::Failure - } - } - RefundStatus::Pending => { - // For pending status, ensure minimum required fields are present - if response.amount.unwrap_or(0.0) > 0.0 && response.failure_details.is_none() { - RefundStatus::Pending - } else { - RefundStatus::Failure - } - } - RefundStatus::Failure => { - // Keep failure status - RefundStatus::Failure - } - RefundStatus::ManualReview => { - // Manual review status should be preserved - RefundStatus::ManualReview - } - RefundStatus::TransactionFailure => { - // Transaction failure should be preserved - RefundStatus::TransactionFailure - } - } -} - -// Address PR #240 Issue #1: Comprehensive refund status validation -// This function implements robust status checking to avoid the issues identified in PR #240 -fn map_airwallex_refund_status( - status: &AirwallexRefundStatus, - response: &AirwallexRefundResponse, -) -> RefundStatus { - match status { - AirwallexRefundStatus::Received => { - // Check if refund is actually processed or just received - // Validate amount exists and is greater than 0 - if response.amount.unwrap_or(0.0) > 0.0 { - // Also check that no failure details are present - if response.failure_details.is_none() { - RefundStatus::Pending - } else { - RefundStatus::Failure - } - } else { - RefundStatus::Failure - } - } - AirwallexRefundStatus::Accepted => { - // Validate that acquirer_reference_number exists and failure_details is None - // This addresses the issue of not checking detailed response fields - if response.acquirer_reference_number.is_some() && response.failure_details.is_none() { - // Check amount is valid - if response.amount.unwrap_or(0.0) > 0.0 { - RefundStatus::Pending // Will be settled later - } else { - RefundStatus::Failure - } - } else { - RefundStatus::Failure - } - } - AirwallexRefundStatus::Settled => { - // Final success state - but still validate thoroughly - // Check no failure details and valid amount - if response.failure_details.is_none() { - if response.amount.unwrap_or(0.0) > 0.0 { - RefundStatus::Success - } else { - RefundStatus::Failure - } - } else { - RefundStatus::Failure - } - } - AirwallexRefundStatus::Failed => { - // Explicit failure - RefundStatus::Failure +// Simple status mapping following Hyperswitch pattern +// Trust the Airwallex API to return correct status +impl From for RefundStatus { + fn from(status: AirwallexRefundStatus) -> Self { + match status { + AirwallexRefundStatus::Settled => Self::Success, + AirwallexRefundStatus::Failed => Self::Failure, + AirwallexRefundStatus::Received | AirwallexRefundStatus::Accepted => Self::Pending, } } } @@ -1357,31 +811,8 @@ pub struct AirwallexVoidRequest { pub request_id: String, // Unique identifier for idempotency } -#[derive(Debug, Deserialize, Serialize)] -pub struct AirwallexVoidResponse { - pub id: String, // Payment intent ID - pub status: AirwallexPaymentStatus, // Should be CANCELLED - pub amount: Option, // Original payment amount from API response (minor units) - pub currency: Option, // Currency code - pub created_at: Option, // Original creation timestamp - pub updated_at: Option, // Cancellation timestamp - pub cancelled_at: Option, // Specific cancellation timestamp - pub cancellation_reason: Option, // Echo back cancellation reason - // Payment method information - pub payment_method: Option, - // Payment intent details - pub payment_intent_id: Option, - // Authorization code from processor - pub authorization_code: Option, - // Network transaction ID - pub network_transaction_id: Option, - // Processor response - pub processor_response: Option, - // Risk information - pub risk_score: Option, - // Latest payment attempt information - pub latest_payment_attempt: Option, -} +// Type alias - reuse the same response structure for Void +pub type AirwallexVoidResponse = AirwallexPaymentsResponse; // Request transformer for Void flow impl< @@ -1415,8 +846,13 @@ impl< .clone() .or_else(|| Some("Voided by merchant".to_string())); - // Generate unique request_id for idempotency - let request_id = format!("void_{}", item.router_data.resource_common_data.payment_id); + // Generate unique request_id for idempotency using connector_request_reference_id + let request_id = format!( + "void_{}", + item.router_data + .resource_common_data + .connector_request_reference_id + ); Ok(Self { cancellation_reason, @@ -1442,13 +878,7 @@ impl RouterDataV2, >, ) -> Result { - // Address PR #240 Critical Issues for Void Operations: - // 1. Enhanced Void Status Logic - Don't assume success based on simple status - // 2. Authorization + Clearing Status validation - // 3. Network Fields extraction - // 4. Comprehensive validation for void completion - - let status = map_airwallex_void_status(&item.response.status, &item.response); + let status = get_payment_status(&item.response.status, &item.response.next_action); // Address PR #240 Issue #4: Network Specific Fields // Extract network transaction ID (prefer latest attempt, then main response) @@ -1466,90 +896,15 @@ impl }) .or(item.response.authorization_code.clone()); - // Build connector metadata with void-specific and network fields - let connector_metadata = { - let mut metadata = std::collections::HashMap::new(); - - // Void-specific fields - if let Some(cancelled_at) = &item.response.cancelled_at { - metadata.insert( - "cancelled_at".to_string(), - serde_json::Value::String(cancelled_at.clone()), - ); - } - - if let Some(cancellation_reason) = &item.response.cancellation_reason { - metadata.insert( - "cancellation_reason".to_string(), - serde_json::Value::String(cancellation_reason.clone()), - ); - } - - // Authorization code (prefer latest attempt) - let auth_code = item - .response - .latest_payment_attempt - .as_ref() - .and_then(|attempt| attempt.authorization_code.as_ref()) - .or(item.response.authorization_code.as_ref()); - - if let Some(auth_code) = auth_code { - metadata.insert( - "authorization_code".to_string(), - serde_json::Value::String(auth_code.clone()), - ); - } - - if let Some(risk_score) = &item.response.risk_score { - metadata.insert( - "risk_score".to_string(), - serde_json::Value::String(risk_score.clone()), - ); - } - - // Processor response data (prefer latest attempt) - let processor_response = item - .response - .latest_payment_attempt - .as_ref() - .and_then(|attempt| attempt.processor_response.as_ref()) - .or(item.response.processor_response.as_ref()); - - if let Some(processor) = processor_response { - if let Some(decline_code) = &processor.decline_code { - metadata.insert( - "decline_code".to_string(), - serde_json::Value::String(decline_code.clone()), - ); - } - if let Some(network_code) = &processor.network_code { - metadata.insert( - "network_code".to_string(), - serde_json::Value::String(network_code.clone()), - ); - } - if let Some(code) = &processor.code { - metadata.insert( - "processor_code".to_string(), - serde_json::Value::String(code.clone()), - ); - } - } - - if metadata.is_empty() { - None - } else { - Some(metadata) - } - }; + // Following hyperswitch pattern - no connector_metadata for void + let connector_metadata = None; Ok(Self { response: Ok(PaymentsResponseData::TransactionResponse { resource_id: ResponseId::ConnectorTransactionId(item.response.id), redirection_data: None, // Void doesn't involve redirections mandate_reference: None, - connector_metadata: connector_metadata - .map(|m| serde_json::Value::Object(m.into_iter().collect())), + connector_metadata, network_txn_id, connector_response_reference_id: item.response.payment_intent_id, incremental_authorization_allowed: Some(false), // Airwallex doesn't support incremental auth @@ -1564,80 +919,8 @@ impl } } -// Address PR #240 Issue #1: Comprehensive void status validation -// This function implements robust void status checking to avoid the critical issues identified in PR #240 -fn map_airwallex_void_status( - status: &AirwallexPaymentStatus, - response: &AirwallexVoidResponse, -) -> AttemptStatus { - match status { - AirwallexPaymentStatus::Cancelled => { - // Enhanced validation for void success - don't assume success based on status alone - // Validate cancelled_at timestamp exists for confirmation - if response.cancelled_at.is_some() { - // Additional validation: Check no error fields and valid cancellation reason processing - if let Some(processor_response) = &response.processor_response { - // Check processor-level status for void confirmation - match processor_response.code.as_deref() { - Some("00") | Some("0000") => AttemptStatus::Voided, // Standard void approval codes - Some("cancelled") | Some("voided") => AttemptStatus::Voided, // Direct void confirmation - Some(decline_code) if decline_code.starts_with('0') => { - AttemptStatus::Voided - } - None => AttemptStatus::Voided, // No processor code but valid cancelled_at means successful void - _ => AttemptStatus::VoidFailed, // Void failed at processor level - } - } else { - // No processor response but has cancelled_at timestamp - likely successful void - AttemptStatus::Voided - } - } else { - // No cancelled_at timestamp means void not completed successfully - AttemptStatus::VoidFailed - } - } - AirwallexPaymentStatus::RequiresPaymentMethod - | AirwallexPaymentStatus::RequiresCustomerAction - | AirwallexPaymentStatus::RequiresCapture => { - // These statuses indicate payment is still in a voidable state but void action may be in progress - // Check if void is actually being processed - if response.cancellation_reason.is_some() { - AttemptStatus::VoidInitiated // Void request received and being processed - } else { - AttemptStatus::VoidFailed // No void action detected - } - } - AirwallexPaymentStatus::CaptureRequested => { - // Payment capture has been requested but not yet settled - still voidable - // Check if void is actually being processed - if response.cancellation_reason.is_some() { - AttemptStatus::VoidInitiated // Void request received and being processed - } else { - AttemptStatus::VoidFailed // No void action detected - } - } - AirwallexPaymentStatus::Processing => { - // Payment in processing - check if void is being applied - if response.cancellation_reason.is_some() { - AttemptStatus::VoidInitiated - } else { - AttemptStatus::VoidFailed - } - } - AirwallexPaymentStatus::Succeeded => { - // Payment already succeeded - void not possible - AttemptStatus::VoidFailed - } - AirwallexPaymentStatus::Settled => { - // Payment already settled - void not possible - AttemptStatus::VoidFailed - } - AirwallexPaymentStatus::Failed => { - // Payment already failed - no need to void - AttemptStatus::VoidFailed - } - } -} +// Removed over-engineered validation - use simple get_payment_status instead +// The Airwallex API is trusted to return correct status (following Hyperswitch pattern) // Implementation for confirm request type (2-step flow) impl< @@ -1678,19 +961,19 @@ impl< let payment_method = match item.router_data.request.payment_method_data.clone() { domain_types::payment_method_data::PaymentMethodData::Card(card_data) => { AirwallexPaymentMethod { - method_type: "card".to_string(), - card: Some(AirwallexCardData { + card: AirwallexCardData { number: Secret::new(card_data.card_number.peek().to_string()), expiry_month: card_data.card_exp_month.clone(), expiry_year: card_data.get_expiry_year_4_digit(), cvc: card_data.card_cvc.clone(), name: card_data.card_holder_name.map(|name| name.expose()), - }), + }, + payment_method_type: AirwallexPaymentType::Card, } } _ => { return Err(errors::ConnectorError::NotSupported { - message: "Only card payments are supported by Airwallex connector".to_string(), + message: "Payment Method".to_string(), connector: "Airwallex", } .into()) @@ -1794,8 +1077,8 @@ pub struct AirwallexIntentRequest { pub struct AirwallexIntentResponse { pub id: String, pub request_id: Option, - pub amount: Option, // Amount from API response (minor units) - pub currency: Option, + pub amount: Option, + pub currency: Option, pub merchant_order_id: Option, pub status: AirwallexPaymentStatus, pub created_at: Option, @@ -1842,8 +1125,8 @@ impl< ) -> Result { // Create referrer data for Airwallex identification let referrer_data = AirwallexReferrerData { - r_type: connector_config::PARTNER_TYPE.to_string(), - version: connector_config::API_VERSION.to_string(), + r_type: "hyperswitch".to_string(), + version: "1.0.0".to_string(), }; // Convert amount using the same converter as other flows @@ -1864,12 +1147,16 @@ impl< // For now, no order data - can be enhanced later when order details are needed let order = None; - Ok(Self { - request_id: item - .router_data + // Generate unique request_id for CreateOrder step + let request_id = format!( + "create_{}", + item.router_data .resource_common_data .connector_request_reference_id - .clone(), + ); + + Ok(Self { + request_id, amount, currency: item.router_data.request.currency, merchant_order_id: item @@ -1932,6 +1219,8 @@ impl AirwallexPaymentStatus::Failed => common_enums::AttemptStatus::Failure, AirwallexPaymentStatus::Cancelled => common_enums::AttemptStatus::Voided, AirwallexPaymentStatus::RequiresCapture => common_enums::AttemptStatus::Authorized, + AirwallexPaymentStatus::Authorized => common_enums::AttemptStatus::Authorized, + AirwallexPaymentStatus::Paid => common_enums::AttemptStatus::Charged, AirwallexPaymentStatus::CaptureRequested => common_enums::AttemptStatus::Charged, }; @@ -2027,10 +1316,12 @@ impl ) -> Result { let mut router_data = item.router_data; + let expires = (item.response.expires_at - common_utils::date_time::now()).whole_seconds(); + router_data.response = Ok(domain_types::connector_types::AccessTokenResponseData { access_token: item.response.token.expose(), token_type: Some("Bearer".to_string()), - expires_in: None, // Airwallex doesn't provide explicit expiry in seconds, only timestamp + expires_in: Some(expires), }); Ok(router_data) From d29d5d5918544a15c916f39655278585ac2c26ce Mon Sep 17 00:00:00 2001 From: "yashasvi.kapil" Date: Fri, 5 Dec 2025 15:00:02 +0530 Subject: [PATCH 3/7] Introduce Acesstoken in create order --- backend/domain_types/src/types.rs | 9 ++++++++- backend/grpc-api-types/proto/payment.proto | 3 +++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/backend/domain_types/src/types.rs b/backend/domain_types/src/types.rs index 2d72d506a..148353c97 100644 --- a/backend/domain_types/src/types.rs +++ b/backend/domain_types/src/types.rs @@ -6122,6 +6122,13 @@ impl }) .transpose()?; + // Extract access token from state if present + let access_token = value + .state + .as_ref() + .and_then(|state| state.access_token.as_ref()) + .map(AccessTokenResponseData::from); + Ok(Self { merchant_id: merchant_id_from_header, payment_id: "IRRELEVANT_PAYMENT_ID".to_string(), @@ -6141,7 +6148,7 @@ impl amount_captured: None, minor_amount_captured: None, minor_amount_capturable: None, - access_token: None, + access_token, session_token: None, reference_id: None, payment_method_token: None, diff --git a/backend/grpc-api-types/proto/payment.proto b/backend/grpc-api-types/proto/payment.proto index 280ee9d0b..77ae988ea 100644 --- a/backend/grpc-api-types/proto/payment.proto +++ b/backend/grpc-api-types/proto/payment.proto @@ -2003,6 +2003,9 @@ message PaymentServiceCreateOrderRequest { // Metadata map metadata = 5; // Additional metadata for the connector + + // State Information + optional ConnectorState state = 6; // State data for access token storage and other connector-specific state } // Response message for create order operation. From 6351ac89e942236603e416d8c4627fb21bb6fa2e Mon Sep 17 00:00:00 2001 From: "yashasvi.kapil" Date: Fri, 5 Dec 2025 19:03:39 +0530 Subject: [PATCH 4/7] chore: diff check fix --- .../src/connectors/airwallex/transformers.rs | 105 ++++++++++++------ config/development.toml | 2 +- config/production.toml | 2 +- config/sandbox.toml | 2 +- 4 files changed, 77 insertions(+), 34 deletions(-) diff --git a/backend/connector-integration/src/connectors/airwallex/transformers.rs b/backend/connector-integration/src/connectors/airwallex/transformers.rs index f44b55e55..8c616358b 100644 --- a/backend/connector-integration/src/connectors/airwallex/transformers.rs +++ b/backend/connector-integration/src/connectors/airwallex/transformers.rs @@ -117,8 +117,29 @@ pub enum AirwallexPaymentType { #[derive(Debug, Serialize)] pub struct AirwallexDeviceData { + pub accept_header: String, + pub browser: AirwallexBrowser, pub ip_address: Option, - pub user_agent: Option, + pub language: String, + pub mobile: Option, + pub screen_color_depth: u8, + pub screen_height: u32, + pub screen_width: u32, + pub timezone: String, +} + +#[derive(Debug, Serialize)] +pub struct AirwallexBrowser { + pub java_enabled: bool, + pub javascript_enabled: bool, + pub user_agent: String, +} + +#[derive(Debug, Serialize)] +pub struct AirwallexMobile { + pub device_model: Option, + pub os_type: Option, + pub os_version: Option, } #[derive(Debug, Serialize)] @@ -129,12 +150,6 @@ pub struct AirwallexPaymentOptions { #[derive(Debug, Serialize)] pub struct AirwallexCardOptions { pub auto_capture: Option, - pub three_ds: Option, -} - -#[derive(Debug, Serialize)] -pub struct AirwallexThreeDsOptions { - pub attempt_three_ds: Option, } // Confirm request structure for 2-step flow (only payment method data) @@ -147,6 +162,56 @@ pub struct AirwallexConfirmRequest { pub device_data: Option, } +// Helper function to extract device data from browser info (matching Hyperswitch pattern) +fn get_device_data( + request: &domain_types::connector_types::PaymentsAuthorizeData, +) -> Result, error_stack::Report> { + let browser_info = match request.get_browser_info() { + Ok(info) => info, + Err(_) => return Ok(None), // If browser info is not available, return None instead of erroring + }; + + let browser = AirwallexBrowser { + java_enabled: browser_info.get_java_enabled().unwrap_or(false), + javascript_enabled: browser_info.get_java_script_enabled().unwrap_or(true), + user_agent: browser_info.get_user_agent().unwrap_or_default(), + }; + + let mobile = { + let device_model = browser_info.device_model.clone(); + let os_type = browser_info.os_type.clone(); + let os_version = browser_info.os_version.clone(); + + if device_model.is_some() || os_type.is_some() || os_version.is_some() { + Some(AirwallexMobile { + device_model, + os_type, + os_version, + }) + } else { + None + } + }; + + Ok(Some(AirwallexDeviceData { + accept_header: browser_info.get_accept_header().unwrap_or_default(), + browser, + ip_address: browser_info + .get_ip_address() + .ok() + .map(|ip| ip.expose().to_string()), + language: browser_info.get_language().unwrap_or_default(), + mobile, + screen_color_depth: browser_info.get_color_depth().unwrap_or(24), + screen_height: browser_info.get_screen_height().unwrap_or(1080), + screen_width: browser_info.get_screen_width().unwrap_or(1920), + timezone: browser_info + .get_time_zone() + .map(|tz| tz.to_string()) + .unwrap_or_else(|_| "0".to_string()), + })) +} + // Implementation for new unified request type impl< T: PaymentMethodDataTypes @@ -213,21 +278,10 @@ impl< let payment_method_options = Some(AirwallexPaymentOptions { card: Some(AirwallexCardOptions { auto_capture: Some(auto_capture), - three_ds: Some(AirwallexThreeDsOptions { - attempt_three_ds: Some(false), // 3DS not implemented yet - }), }), }); - let device_data = item - .router_data - .request - .browser_info - .as_ref() - .map(|browser_info| AirwallexDeviceData { - ip_address: browser_info.ip_address.map(|ip| ip.to_string()), - user_agent: browser_info.user_agent.clone(), - }); + let device_data = get_device_data(&item.router_data.request)?; // Generate unique request_id for Authorize/confirm step // Different from CreateOrder to avoid Airwallex duplicate_request error @@ -988,21 +1042,10 @@ impl< let payment_method_options = Some(AirwallexPaymentOptions { card: Some(AirwallexCardOptions { auto_capture: Some(auto_capture), - three_ds: Some(AirwallexThreeDsOptions { - attempt_three_ds: Some(false), // 3DS not implemented yet - }), }), }); - let device_data = item - .router_data - .request - .browser_info - .as_ref() - .map(|browser_info| AirwallexDeviceData { - ip_address: browser_info.ip_address.map(|ip| ip.to_string()), - user_agent: browser_info.user_agent.clone(), - }); + let device_data = get_device_data(&item.router_data.request)?; Ok(Self { request_id: format!( diff --git a/config/development.toml b/config/development.toml index 57bd29e77..424bf7e62 100644 --- a/config/development.toml +++ b/config/development.toml @@ -44,7 +44,7 @@ psync = "GW_TXN_SYNC" [connectors] barclaycard.base_url = "https://api.smartpayfuse-test.barclaycard" -airwallex.base_url = "https://api-demo.airwallex.com/api/v1/" +airwallex.base_url = "https://api-demo.airwallex.com/api/v1" shift4.base_url = "https://api.shift4.com" bluesnap.base_url = "https://sandbox.bluesnap.com" bluesnap.secondary_base_url = "https://sandpay.bluesnap.com" diff --git a/config/production.toml b/config/production.toml index b4d7db460..a93fa23a2 100644 --- a/config/production.toml +++ b/config/production.toml @@ -22,7 +22,7 @@ mitm_proxy_enabled = false [connectors] barclaycard.base_url = "https://api.smartpayfuse.barclaycard" -airwallex.base_url = "https://api.airwallex.com/api/v1/" +airwallex.base_url = "https://api.airwallex.com/api/v1" shift4.base_url = "https://api.shift4.com" bluesnap.base_url = "https://ws.bluesnap.com" bluesnap.secondary_base_url = "https://pay.bluesnap.com" diff --git a/config/sandbox.toml b/config/sandbox.toml index 87ed205c0..b0e5e5758 100644 --- a/config/sandbox.toml +++ b/config/sandbox.toml @@ -22,7 +22,7 @@ mitm_proxy_enabled = false [connectors] barclaycard.base_url = "https://api.smartpayfuse-test.barclaycard" -airwallex.base_url = "https://api-demo.airwallex.com/api/v1/" +airwallex.base_url = "https://api-demo.airwallex.com/api/v1" shift4.base_url = "https://api.shift4.com" bluesnap.base_url = "https://sandbox.bluesnap.com" bluesnap.secondary_base_url = "https://sandpay.bluesnap.com" From b9f91e9f9f3d0318dfdcb6573a559937c7601747 Mon Sep 17 00:00:00 2001 From: "yashasvi.kapil" Date: Fri, 5 Dec 2025 19:45:39 +0530 Subject: [PATCH 5/7] remove unused code --- .../src/connectors/airwallex/transformers.rs | 7 ------- 1 file changed, 7 deletions(-) diff --git a/backend/connector-integration/src/connectors/airwallex/transformers.rs b/backend/connector-integration/src/connectors/airwallex/transformers.rs index 8c616358b..e54c06048 100644 --- a/backend/connector-integration/src/connectors/airwallex/transformers.rs +++ b/backend/connector-integration/src/connectors/airwallex/transformers.rs @@ -58,13 +58,6 @@ pub struct AirwallexAccessTokenRequest { // Empty struct that serializes to {} - Airwallex API requirement } -#[derive(Debug, Serialize)] -pub struct AirwallexPaymentsRequest { - pub amount: StringMajorUnit, - pub currency: Currency, - pub reference: String, -} - // New unified request type for macro pattern that includes payment intent creation and confirmation #[derive(Debug, Serialize)] pub struct AirwallexPaymentRequest { From fa9165ddabda523ab252ce7f0848750d346688f3 Mon Sep 17 00:00:00 2001 From: "yashasvi.kapil" Date: Mon, 8 Dec 2025 16:38:48 +0530 Subject: [PATCH 6/7] resolve comments --- .../src/connectors/airwallex.rs | 9 +++-- .../src/connectors/airwallex/transformers.rs | 33 +++++++++++-------- 2 files changed, 26 insertions(+), 16 deletions(-) diff --git a/backend/connector-integration/src/connectors/airwallex.rs b/backend/connector-integration/src/connectors/airwallex.rs index e30f2e798..1f247551b 100644 --- a/backend/connector-integration/src/connectors/airwallex.rs +++ b/backend/connector-integration/src/connectors/airwallex.rs @@ -934,11 +934,16 @@ impl Conn with_error_response_body!(event_builder, response); + let error_message = match response.source { + Some(ref source) => format!("{} {}", response.message, source), + None => response.message.clone(), + }; + Ok(ErrorResponse { status_code: res.status_code, code: response.code, - message: response.message, - reason: None, + message: error_message.clone(), + reason: Some(error_message), attempt_status: None, connector_transaction_id: None, network_decline_code: None, diff --git a/backend/connector-integration/src/connectors/airwallex/transformers.rs b/backend/connector-integration/src/connectors/airwallex/transformers.rs index e54c06048..935357a9c 100644 --- a/backend/connector-integration/src/connectors/airwallex/transformers.rs +++ b/backend/connector-integration/src/connectors/airwallex/transformers.rs @@ -43,6 +43,7 @@ impl TryFrom<&ConnectorAuthType> for AirwallexAuthType { pub struct AirwallexErrorResponse { pub code: String, pub message: String, + pub source: Option, } #[derive(Debug, Serialize, Deserialize)] @@ -87,7 +88,7 @@ pub struct AirwallexCardData { pub expiry_month: Secret, pub expiry_year: Secret, pub cvc: Secret, - pub name: Option, + pub name: Option>, } // Note: Wallet, PayLater, and BankRedirect data structures removed @@ -112,7 +113,7 @@ pub enum AirwallexPaymentType { pub struct AirwallexDeviceData { pub accept_header: String, pub browser: AirwallexBrowser, - pub ip_address: Option, + pub ip_address: Option>, pub language: String, pub mobile: Option, pub screen_color_depth: u8, @@ -192,7 +193,7 @@ fn get_device_data ip_address: browser_info .get_ip_address() .ok() - .map(|ip| ip.expose().to_string()), + .map(|ip| Secret::new(ip.expose().to_string())), language: browser_info.get_language().unwrap_or_default(), mobile, screen_color_depth: browser_info.get_color_depth().unwrap_or(24), @@ -249,7 +250,9 @@ impl< expiry_month: card_data.card_exp_month.clone(), expiry_year: card_data.get_expiry_year_4_digit(), cvc: card_data.card_cvc.clone(), - name: card_data.card_holder_name.map(|name| name.expose()), + name: card_data + .card_holder_name + .map(|name| Secret::new(name.expose())), }, payment_method_type: AirwallexPaymentType::Card, } @@ -370,8 +373,8 @@ pub struct AirwallexPaymentMethodInfo { pub struct AirwallexCardInfo { pub last4: Option, pub brand: Option, - pub exp_month: Option, - pub exp_year: Option, + pub exp_month: Option>, + pub exp_year: Option>, pub fingerprint: Option, } @@ -1013,7 +1016,9 @@ impl< expiry_month: card_data.card_exp_month.clone(), expiry_year: card_data.get_expiry_year_4_digit(), cvc: card_data.card_cvc.clone(), - name: card_data.card_holder_name.map(|name| name.expose()), + name: card_data + .card_holder_name + .map(|name| Secret::new(name.expose())), }, payment_method_type: AirwallexPaymentType::Card, } @@ -1079,9 +1084,9 @@ pub struct AirwallexProductData { #[derive(Debug, Serialize)] pub struct AirwallexShippingData { - pub first_name: Option, - pub last_name: Option, - pub phone_number: Option, + pub first_name: Option>, + pub last_name: Option>, + pub phone_number: Option>, pub shipping_method: Option, pub address: Option, } @@ -1089,10 +1094,10 @@ pub struct AirwallexShippingData { #[derive(Debug, Serialize)] pub struct AirwallexAddressData { pub country_code: String, - pub state: Option, - pub city: Option, - pub street: Option, - pub postcode: Option, + pub state: Option>, + pub city: Option>, + pub street: Option>, + pub postcode: Option>, } // CreateOrder request structure (Step 1 - Intent creation without payment method) From b93736620f3081df32c97cd51ebad71014d869cd Mon Sep 17 00:00:00 2001 From: Yashasvi Kapil <74726400+iemyashasvi@users.noreply.github.com> Date: Mon, 8 Dec 2025 17:31:57 +0530 Subject: [PATCH 7/7] Fix Co-authored-by: DEEPANSHU BANSAL <41580413+deepanshu-iiitu@users.noreply.github.com> --- backend/connector-integration/src/connectors/airwallex.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/connector-integration/src/connectors/airwallex.rs b/backend/connector-integration/src/connectors/airwallex.rs index 1f247551b..a75aff64d 100644 --- a/backend/connector-integration/src/connectors/airwallex.rs +++ b/backend/connector-integration/src/connectors/airwallex.rs @@ -935,7 +935,7 @@ impl Conn with_error_response_body!(event_builder, response); let error_message = match response.source { - Some(ref source) => format!("{} {}", response.message, source), + Some(ref source) => format!("{}; {}", response.message, source), None => response.message.clone(), };