From c38ce386bda3aef1ce8a733c8df2a3a99b068396 Mon Sep 17 00:00:00 2001 From: Pa1NarK <69745008+pixincreate@users.noreply.github.com> Date: Thu, 31 Jul 2025 12:24:08 +0530 Subject: [PATCH] feat(connector): [facilitapay] fix refunds, add webhook and void support (#8778) --- .../connector_configs/toml/development.toml | 4 +- crates/connector_configs/toml/production.toml | 4 +- crates/connector_configs/toml/sandbox.toml | 4 +- .../src/connectors/facilitapay.rs | 221 +++++++++++++++--- .../src/connectors/facilitapay/requests.rs | 6 - .../src/connectors/facilitapay/responses.rs | 123 +++++++++- .../connectors/facilitapay/transformers.rs | 78 +++++-- 7 files changed, 369 insertions(+), 71 deletions(-) diff --git a/crates/connector_configs/toml/development.toml b/crates/connector_configs/toml/development.toml index 57632d00a4..6f797a90f1 100644 --- a/crates/connector_configs/toml/development.toml +++ b/crates/connector_configs/toml/development.toml @@ -6157,8 +6157,8 @@ api_secret="Secret Key" key1="Username" [facilitapay.metadata.destination_account_number] name="destination_account_number" - label="Destination Account Number" - placeholder="Enter Destination Account Number" + label="Merchant Account Number" + placeholder="Enter Merchant's (to_bank_account_id) Account Number" required=true type="Text" diff --git a/crates/connector_configs/toml/production.toml b/crates/connector_configs/toml/production.toml index 8f61879891..c3e8d13023 100644 --- a/crates/connector_configs/toml/production.toml +++ b/crates/connector_configs/toml/production.toml @@ -4725,8 +4725,8 @@ key1 = "Username" [facilitapay.metadata.destination_account_number] name="destination_account_number" -label="Destination Account Number" -placeholder="Enter Destination Account Number" +label="Merchant Account Number" +placeholder="Enter Merchant's (to_bank_account_id) Account Number" required=true type="Text" diff --git a/crates/connector_configs/toml/sandbox.toml b/crates/connector_configs/toml/sandbox.toml index 7322b7bd10..bccfdd133c 100644 --- a/crates/connector_configs/toml/sandbox.toml +++ b/crates/connector_configs/toml/sandbox.toml @@ -6139,8 +6139,8 @@ key1 = "Username" [facilitapay.metadata.destination_account_number] name="destination_account_number" -label="Destination Account Number" -placeholder="Enter Destination Account Number" +label="Merchant Account Number" +placeholder="Enter Merchant's (to_bank_account_id) Account Number" required=true type="Text" diff --git a/crates/hyperswitch_connectors/src/connectors/facilitapay.rs b/crates/hyperswitch_connectors/src/connectors/facilitapay.rs index 9b1a6486df..ffb74cc3a8 100644 --- a/crates/hyperswitch_connectors/src/connectors/facilitapay.rs +++ b/crates/hyperswitch_connectors/src/connectors/facilitapay.rs @@ -4,12 +4,13 @@ pub mod transformers; use common_enums::enums; use common_utils::{ + crypto, errors::CustomResult, - ext_traits::BytesExt, + ext_traits::{ByteSliceExt, BytesExt, ValueExt}, request::{Method, Request, RequestBuilder, RequestContent}, types::{AmountConvertor, StringMajorUnit, StringMajorUnitForConnector}, }; -use error_stack::{report, ResultExt}; +use error_stack::ResultExt; use hyperswitch_domain_models::{ router_data::{AccessToken, ErrorResponse, RouterData}, router_flow_types::{ @@ -30,8 +31,8 @@ use hyperswitch_domain_models::{ SupportedPaymentMethods, SupportedPaymentMethodsExt, }, types::{ - ConnectorCustomerRouterData, PaymentsAuthorizeRouterData, PaymentsCaptureRouterData, - PaymentsSyncRouterData, RefundSyncRouterData, RefundsRouterData, + ConnectorCustomerRouterData, PaymentsAuthorizeRouterData, PaymentsCancelRouterData, + PaymentsCaptureRouterData, PaymentsSyncRouterData, RefundSyncRouterData, RefundsRouterData, }, }; use hyperswitch_interfaces::{ @@ -46,18 +47,19 @@ use hyperswitch_interfaces::{ webhooks, }; use lazy_static::lazy_static; -use masking::{Mask, PeekInterface}; +use masking::{ExposeInterface, Mask, PeekInterface}; use requests::{ FacilitapayAuthRequest, FacilitapayCustomerRequest, FacilitapayPaymentsRequest, - FacilitapayRefundRequest, FacilitapayRouterData, + FacilitapayRouterData, }; use responses::{ FacilitapayAuthResponse, FacilitapayCustomerResponse, FacilitapayPaymentsResponse, - FacilitapayRefundResponse, + FacilitapayRefundResponse, FacilitapayWebhookEventType, }; use transformers::parse_facilitapay_error_response; use crate::{ + connectors::facilitapay::responses::FacilitapayVoidResponse, constants::headers, types::{RefreshTokenRouterData, ResponseRouterData}, utils::{self, RefundsRequestData}, @@ -581,7 +583,72 @@ impl ConnectorIntegration fo } } -impl ConnectorIntegration for Facilitapay {} +impl ConnectorIntegration for Facilitapay { + fn get_headers( + &self, + req: &PaymentsCancelRouterData, + connectors: &Connectors, + ) -> CustomResult)>, errors::ConnectorError> { + self.build_headers(req, connectors) + } + + fn get_content_type(&self) -> &'static str { + self.common_get_content_type() + } + + fn get_url( + &self, + req: &PaymentsCancelRouterData, + connectors: &Connectors, + ) -> CustomResult { + Ok(format!( + "{}/transactions/{}/refund", + self.base_url(connectors), + req.request.connector_transaction_id + )) + } + + fn build_request( + &self, + req: &PaymentsCancelRouterData, + connectors: &Connectors, + ) -> CustomResult, errors::ConnectorError> { + let request = RequestBuilder::new() + .method(Method::Get) + .url(&types::PaymentsVoidType::get_url(self, req, connectors)?) + .attach_default_headers() + .headers(types::PaymentsVoidType::get_headers(self, req, connectors)?) + .build(); + Ok(Some(request)) + } + + fn handle_response( + &self, + data: &PaymentsCancelRouterData, + event_builder: Option<&mut ConnectorEvent>, + res: Response, + ) -> CustomResult { + let response: FacilitapayVoidResponse = res + .response + .parse_struct("FacilitapayCancelResponse") + .change_context(errors::ConnectorError::ResponseDeserializationFailed)?; + event_builder.map(|i| i.set_response_body(&response)); + router_env::logger::info!(connector_response=?response); + RouterData::try_from(ResponseRouterData { + response, + data: data.clone(), + http_code: res.status_code, + }) + } + + fn get_error_response( + &self, + res: Response, + event_builder: Option<&mut ConnectorEvent>, + ) -> CustomResult { + self.build_error_response(res, event_builder) + } +} impl ConnectorIntegration for Facilitapay { fn get_headers( @@ -608,37 +675,27 @@ impl ConnectorIntegration for Facilit )) } - fn get_request_body( - &self, - req: &RefundsRouterData, - _connectors: &Connectors, - ) -> CustomResult { - let refund_amount = utils::convert_amount( - self.amount_converter, - req.request.minor_refund_amount, - req.request.currency, - )?; - - let connector_router_data = FacilitapayRouterData::from((refund_amount, req)); - let connector_req = FacilitapayRefundRequest::try_from(&connector_router_data)?; - Ok(RequestContent::Json(Box::new(connector_req))) - } - fn build_request( &self, req: &RefundsRouterData, connectors: &Connectors, ) -> CustomResult, errors::ConnectorError> { + // Validate that this is a full refund + if req.request.payment_amount != req.request.refund_amount { + return Err(errors::ConnectorError::NotSupported { + message: "Partial refund not supported by Facilitapay".to_string(), + connector: "Facilitapay", + } + .into()); + } + let request = RequestBuilder::new() - .method(Method::Post) + .method(Method::Get) .url(&types::RefundExecuteType::get_url(self, req, connectors)?) .attach_default_headers() .headers(types::RefundExecuteType::get_headers( self, req, connectors, )?) - .set_body(types::RefundExecuteType::get_request_body( - self, req, connectors, - )?) .build(); Ok(Some(request)) } @@ -743,25 +800,117 @@ impl ConnectorIntegration for Facilitap #[async_trait::async_trait] impl webhooks::IncomingWebhook for Facilitapay { + async fn verify_webhook_source( + &self, + request: &webhooks::IncomingWebhookRequestDetails<'_>, + _merchant_id: &common_utils::id_type::MerchantId, + connector_webhook_details: Option, + _connector_account_details: crypto::Encryptable>, + _connector_name: &str, + ) -> CustomResult { + let webhook_body: responses::FacilitapayWebhookNotification = request + .body + .parse_struct("FacilitapayWebhookNotification") + .change_context(errors::ConnectorError::WebhookSourceVerificationFailed)?; + + let connector_webhook_secrets = match connector_webhook_details { + Some(secret_value) => { + let secret = secret_value + .parse_value::( + "MerchantConnectorWebhookDetails", + ) + .change_context(errors::ConnectorError::WebhookSourceVerificationFailed)?; + secret.merchant_secret.expose() + } + None => "default_secret".to_string(), + }; + + // FacilitaPay uses a simple 4-digit secret for verification + Ok(webhook_body.notification.secret.peek() == &connector_webhook_secrets) + } + fn get_webhook_object_reference_id( &self, - _request: &webhooks::IncomingWebhookRequestDetails<'_>, + request: &webhooks::IncomingWebhookRequestDetails<'_>, ) -> CustomResult { - Err(report!(errors::ConnectorError::WebhooksNotImplemented)) + let webhook_body: responses::FacilitapayWebhookNotification = request + .body + .parse_struct("FacilitapayWebhookNotification") + .change_context(errors::ConnectorError::WebhookReferenceIdNotFound)?; + + // Extract transaction ID from the webhook data + let transaction_id = match &webhook_body.notification.data { + responses::FacilitapayWebhookData::Transaction { transaction_id } + | responses::FacilitapayWebhookData::CardPayment { transaction_id, .. } => { + transaction_id.clone() + } + responses::FacilitapayWebhookData::Exchange { + transaction_ids, .. + } + | responses::FacilitapayWebhookData::Wire { + transaction_ids, .. + } + | responses::FacilitapayWebhookData::WireError { + transaction_ids, .. + } => transaction_ids + .first() + .ok_or(errors::ConnectorError::WebhookReferenceIdNotFound)? + .clone(), + }; + + // For refund webhooks, Facilitapay sends the original payment transaction ID + // not the refund transaction ID + Ok(api_models::webhooks::ObjectReferenceId::PaymentId( + api_models::payments::PaymentIdType::ConnectorTransactionId(transaction_id), + )) } fn get_webhook_event_type( &self, - _request: &webhooks::IncomingWebhookRequestDetails<'_>, + request: &webhooks::IncomingWebhookRequestDetails<'_>, ) -> CustomResult { - Err(report!(errors::ConnectorError::WebhooksNotImplemented)) + let webhook_body: responses::FacilitapayWebhookNotification = request + .body + .parse_struct("FacilitapayWebhookNotification") + .change_context(errors::ConnectorError::WebhookEventTypeNotFound)?; + + // Note: For "identified" events, we need additional logic to determine if it's cross-currency + // Since we don't have access to the payment data here, we'll default to Success for now + // The actual status determination happens in the webhook processing flow + let event = match webhook_body.notification.event_type { + FacilitapayWebhookEventType::ExchangeCreated => { + api_models::webhooks::IncomingWebhookEvent::PaymentIntentProcessing + } + FacilitapayWebhookEventType::Identified + | FacilitapayWebhookEventType::PaymentApproved + | FacilitapayWebhookEventType::WireCreated => { + api_models::webhooks::IncomingWebhookEvent::PaymentIntentSuccess + } + FacilitapayWebhookEventType::PaymentExpired + | FacilitapayWebhookEventType::PaymentFailed => { + api_models::webhooks::IncomingWebhookEvent::PaymentIntentFailure + } + FacilitapayWebhookEventType::PaymentRefunded => { + api_models::webhooks::IncomingWebhookEvent::RefundSuccess + } + FacilitapayWebhookEventType::WireWaitingCorrection => { + api_models::webhooks::IncomingWebhookEvent::PaymentActionRequired + } + }; + + Ok(event) } fn get_webhook_resource_object( &self, - _request: &webhooks::IncomingWebhookRequestDetails<'_>, + request: &webhooks::IncomingWebhookRequestDetails<'_>, ) -> CustomResult, errors::ConnectorError> { - Err(report!(errors::ConnectorError::WebhooksNotImplemented)) + let webhook_body: responses::FacilitapayWebhookNotification = request + .body + .parse_struct("FacilitapayWebhookNotification") + .change_context(errors::ConnectorError::WebhookBodyDecodingFailed)?; + + Ok(Box::new(webhook_body)) } } @@ -794,7 +943,9 @@ lazy_static! { facilitapay_supported_payment_methods }; - static ref FACILITAPAY_SUPPORTED_WEBHOOK_FLOWS: Vec = Vec::new(); + static ref FACILITAPAY_SUPPORTED_WEBHOOK_FLOWS: Vec = vec![ + enums::EventClass::Payments, + ]; } impl ConnectorSpecifications for Facilitapay { diff --git a/crates/hyperswitch_connectors/src/connectors/facilitapay/requests.rs b/crates/hyperswitch_connectors/src/connectors/facilitapay/requests.rs index 29c2f34f33..a14da2dd57 100644 --- a/crates/hyperswitch_connectors/src/connectors/facilitapay/requests.rs +++ b/crates/hyperswitch_connectors/src/connectors/facilitapay/requests.rs @@ -69,12 +69,6 @@ pub struct FacilitapayPaymentsRequest { pub transaction: FacilitapayTransactionRequest, } -// Type definition for RefundRequest -#[derive(Default, Debug, Serialize)] -pub struct FacilitapayRefundRequest { - pub amount: StringMajorUnit, -} - #[derive(Debug, Serialize, PartialEq)] #[serde(rename_all = "snake_case")] pub struct FacilitapayCustomerRequest { diff --git a/crates/hyperswitch_connectors/src/connectors/facilitapay/responses.rs b/crates/hyperswitch_connectors/src/connectors/facilitapay/responses.rs index dfa01402ce..ead8569154 100644 --- a/crates/hyperswitch_connectors/src/connectors/facilitapay/responses.rs +++ b/crates/hyperswitch_connectors/src/connectors/facilitapay/responses.rs @@ -136,6 +136,7 @@ pub struct BankAccountDetail { pub routing_number: Option>, pub pix_info: Option, pub owner_name: Option>, + pub owner_document_type: Option, pub owner_document_number: Option>, pub owner_company: Option, pub internal: Option, @@ -176,7 +177,7 @@ pub struct TransactionData { pub subject_is_receiver: Option, // Source identification (potentially redundant with subject or card/bank info) - pub source_name: Secret, + pub source_name: Option>, pub source_document_type: DocumentType, pub source_document_number: Secret, @@ -204,14 +205,124 @@ pub struct TransactionData { pub meta: Option, } +// Void response structures (for /refund endpoint) #[derive(Debug, Clone, Serialize, Deserialize)] -pub struct RefundData { +pub struct VoidBankTransaction { #[serde(rename = "id")] - pub refund_id: String, - pub status: FacilitapayPaymentStatus, + pub transaction_id: String, + pub value: StringMajorUnit, + pub currency: api_models::enums::Currency, + pub iof_value: Option, + pub fx_value: Option, + pub exchange_rate: Option, + pub exchange_currency: api_models::enums::Currency, + pub exchanged_value: StringMajorUnit, + pub exchange_approved: bool, + pub wire_id: Option, + pub exchange_id: Option, + pub movement_date: String, + pub source_name: Secret, + pub source_document_number: Secret, + pub source_document_type: String, + pub source_id: String, + pub source_type: String, + pub source_description: String, + pub source_bank: Option, + pub source_branch: Option, + pub source_account: Option, + pub source_bank_ispb: Option, + pub company_id: String, + pub company_name: String, } #[derive(Debug, Clone, Serialize, Deserialize)] -pub struct FacilitapayRefundResponse { - pub data: RefundData, +pub struct VoidData { + #[serde(rename = "id")] + pub void_id: String, + pub reason: Option, + pub inserted_at: String, + pub status: FacilitapayPaymentStatus, + pub transaction_kind: String, + pub bank_transaction: VoidBankTransaction, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FacilitapayVoidResponse { + pub data: VoidData, +} + +// Refund response uses the same TransactionData structure as payments +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FacilitapayRefundResponse { + pub data: TransactionData, +} + +// Webhook structures +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FacilitapayWebhookNotification { + pub notification: FacilitapayWebhookBody, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub struct FacilitapayWebhookBody { + #[serde(rename = "type")] + pub event_type: FacilitapayWebhookEventType, + pub secret: Secret, + #[serde(flatten)] + pub data: FacilitapayWebhookData, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "snake_case")] +pub enum FacilitapayWebhookEventType { + ExchangeCreated, + Identified, + PaymentApproved, + PaymentExpired, + PaymentFailed, + PaymentRefunded, + WireCreated, + WireWaitingCorrection, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "UPPERCASE")] +pub enum FacilitapayWebhookErrorCode { + /// Creditor account number invalid or missing (branch_number or account_number incorrect) + Ac03, + /// Creditor account type missing or invalid (account_type incorrect) + Ac14, + /// Value in Creditor Identifier is incorrect (owner_document_number incorrect) + Ch11, + /// Transaction type not supported/authorized on this account (account rejected the payment) + Ag03, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(untagged)] +pub enum FacilitapayWebhookData { + CardPayment { + transaction_id: String, + checkout_id: Option, + }, + Exchange { + exchange_id: String, + transaction_ids: Vec, + }, + Transaction { + transaction_id: String, + }, + Wire { + wire_id: String, + transaction_ids: Vec, + }, + WireError { + error_code: FacilitapayWebhookErrorCode, + error_description: String, + bank_account_owner_id: String, + bank_account_id: String, + transaction_ids: Vec, + wire_id: String, + }, } diff --git a/crates/hyperswitch_connectors/src/connectors/facilitapay/transformers.rs b/crates/hyperswitch_connectors/src/connectors/facilitapay/transformers.rs index c0025fe44f..d0e186cf35 100644 --- a/crates/hyperswitch_connectors/src/connectors/facilitapay/transformers.rs +++ b/crates/hyperswitch_connectors/src/connectors/facilitapay/transformers.rs @@ -11,8 +11,11 @@ use error_stack::ResultExt; use hyperswitch_domain_models::{ payment_method_data::{BankTransferData, PaymentMethodData}, router_data::{AccessToken, ConnectorAuthType, ErrorResponse, RouterData}, - router_flow_types::refunds::{Execute, RSync}, - router_request_types::ResponseId, + router_flow_types::{ + payments::Void, + refunds::{Execute, RSync}, + }, + router_request_types::{PaymentsCancelData, ResponseId}, router_response_types::{PaymentsResponseData, RefundsResponseData}, types, }; @@ -27,12 +30,12 @@ use url::Url; use super::{ requests::{ DocumentType, FacilitapayAuthRequest, FacilitapayCredentials, FacilitapayCustomerRequest, - FacilitapayPaymentsRequest, FacilitapayPerson, FacilitapayRefundRequest, - FacilitapayRouterData, FacilitapayTransactionRequest, PixTransactionRequest, + FacilitapayPaymentsRequest, FacilitapayPerson, FacilitapayRouterData, + FacilitapayTransactionRequest, PixTransactionRequest, }, responses::{ FacilitapayAuthResponse, FacilitapayCustomerResponse, FacilitapayPaymentStatus, - FacilitapayPaymentsResponse, FacilitapayRefundResponse, + FacilitapayPaymentsResponse, FacilitapayRefundResponse, FacilitapayVoidResponse, }, }; use crate::{ @@ -509,17 +512,6 @@ fn get_qr_code_data( .change_context(errors::ConnectorError::ResponseHandlingFailed) } -impl TryFrom<&FacilitapayRouterData<&types::RefundsRouterData>> for FacilitapayRefundRequest { - type Error = Error; - fn try_from( - item: &FacilitapayRouterData<&types::RefundsRouterData>, - ) -> Result { - Ok(Self { - amount: item.amount.clone(), - }) - } -} - impl From for enums::RefundStatus { fn from(item: FacilitapayPaymentStatus) -> Self { match item { @@ -532,6 +524,56 @@ impl From for enums::RefundStatus { } } +// Void (cancel unprocessed payment) transformer +impl + TryFrom< + ResponseRouterData, + > for RouterData +{ + type Error = Error; + fn try_from( + item: ResponseRouterData< + Void, + FacilitapayVoidResponse, + PaymentsCancelData, + PaymentsResponseData, + >, + ) -> Result { + let status = common_enums::AttemptStatus::from(item.response.data.status.clone()); + + Ok(Self { + status, + response: if is_payment_failure(status) { + Err(ErrorResponse { + code: item.response.data.status.clone().to_string(), + message: item.response.data.status.clone().to_string(), + reason: item.response.data.reason, + status_code: item.http_code, + attempt_status: None, + connector_transaction_id: Some(item.response.data.void_id.clone()), + network_decline_code: None, + network_advice_code: None, + network_error_message: None, + }) + } else { + Ok(PaymentsResponseData::TransactionResponse { + resource_id: ResponseId::ConnectorTransactionId( + item.response.data.void_id.clone(), + ), + redirection_data: Box::new(None), + mandate_reference: Box::new(None), + connector_metadata: None, + network_txn_id: None, + connector_response_reference_id: Some(item.response.data.void_id), + incremental_authorization_allowed: None, + charges: None, + }) + }, + ..item.data + }) + } +} + impl TryFrom> for types::RefundsRouterData { @@ -541,7 +583,7 @@ impl TryFrom> ) -> Result { Ok(Self { response: Ok(RefundsResponseData { - connector_refund_id: item.response.data.refund_id.to_string(), + connector_refund_id: item.response.data.transaction_id.clone(), refund_status: enums::RefundStatus::from(item.response.data.status), }), ..item.data @@ -558,7 +600,7 @@ impl TryFrom> ) -> Result { Ok(Self { response: Ok(RefundsResponseData { - connector_refund_id: item.response.data.refund_id.to_string(), + connector_refund_id: item.response.data.transaction_id.clone(), refund_status: enums::RefundStatus::from(item.response.data.status), }), ..item.data