feat(connector): [facilitapay] fix refunds, add webhook and void support (#8778)

This commit is contained in:
Pa1NarK
2025-07-31 12:24:08 +05:30
committed by GitHub
parent 64e6dc860d
commit c38ce386bd
7 changed files with 369 additions and 71 deletions

View File

@ -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"

View File

@ -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"

View File

@ -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"

View File

@ -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<Capture, PaymentsCaptureData, PaymentsResponseData> fo
}
}
impl ConnectorIntegration<Void, PaymentsCancelData, PaymentsResponseData> for Facilitapay {}
impl ConnectorIntegration<Void, PaymentsCancelData, PaymentsResponseData> for Facilitapay {
fn get_headers(
&self,
req: &PaymentsCancelRouterData,
connectors: &Connectors,
) -> CustomResult<Vec<(String, masking::Maskable<String>)>, 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<String, errors::ConnectorError> {
Ok(format!(
"{}/transactions/{}/refund",
self.base_url(connectors),
req.request.connector_transaction_id
))
}
fn build_request(
&self,
req: &PaymentsCancelRouterData,
connectors: &Connectors,
) -> CustomResult<Option<Request>, 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<PaymentsCancelRouterData, errors::ConnectorError> {
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<ErrorResponse, errors::ConnectorError> {
self.build_error_response(res, event_builder)
}
}
impl ConnectorIntegration<Execute, RefundsData, RefundsResponseData> for Facilitapay {
fn get_headers(
@ -608,37 +675,27 @@ impl ConnectorIntegration<Execute, RefundsData, RefundsResponseData> for Facilit
))
}
fn get_request_body(
&self,
req: &RefundsRouterData<Execute>,
_connectors: &Connectors,
) -> CustomResult<RequestContent, errors::ConnectorError> {
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<Execute>,
connectors: &Connectors,
) -> CustomResult<Option<Request>, 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<RSync, RefundsData, RefundsResponseData> 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<common_utils::pii::SecretSerdeValue>,
_connector_account_details: crypto::Encryptable<masking::Secret<serde_json::Value>>,
_connector_name: &str,
) -> CustomResult<bool, errors::ConnectorError> {
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::<api_models::admin::MerchantConnectorWebhookDetails>(
"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<api_models::webhooks::ObjectReferenceId, errors::ConnectorError> {
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<api_models::webhooks::IncomingWebhookEvent, errors::ConnectorError> {
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<Box<dyn masking::ErasedMaskSerialize>, 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<enums::EventClass> = Vec::new();
static ref FACILITAPAY_SUPPORTED_WEBHOOK_FLOWS: Vec<enums::EventClass> = vec![
enums::EventClass::Payments,
];
}
impl ConnectorSpecifications for Facilitapay {

View File

@ -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 {

View File

@ -136,6 +136,7 @@ pub struct BankAccountDetail {
pub routing_number: Option<Secret<String>>,
pub pix_info: Option<PixInfo>,
pub owner_name: Option<Secret<String>>,
pub owner_document_type: Option<String>,
pub owner_document_number: Option<Secret<String>>,
pub owner_company: Option<OwnerCompany>,
pub internal: Option<bool>,
@ -176,7 +177,7 @@ pub struct TransactionData {
pub subject_is_receiver: Option<bool>,
// Source identification (potentially redundant with subject or card/bank info)
pub source_name: Secret<String>,
pub source_name: Option<Secret<String>>,
pub source_document_type: DocumentType,
pub source_document_number: Secret<String>,
@ -204,14 +205,124 @@ pub struct TransactionData {
pub meta: Option<serde_json::Value>,
}
// 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<StringMajorUnit>,
pub fx_value: Option<StringMajorUnit>,
pub exchange_rate: Option<StringMajorUnit>,
pub exchange_currency: api_models::enums::Currency,
pub exchanged_value: StringMajorUnit,
pub exchange_approved: bool,
pub wire_id: Option<String>,
pub exchange_id: Option<String>,
pub movement_date: String,
pub source_name: Secret<String>,
pub source_document_number: Secret<String>,
pub source_document_type: String,
pub source_id: String,
pub source_type: String,
pub source_description: String,
pub source_bank: Option<String>,
pub source_branch: Option<String>,
pub source_account: Option<String>,
pub source_bank_ispb: Option<String>,
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<String>,
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<String>,
#[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<String>,
},
Exchange {
exchange_id: String,
transaction_ids: Vec<String>,
},
Transaction {
transaction_id: String,
},
Wire {
wire_id: String,
transaction_ids: Vec<String>,
},
WireError {
error_code: FacilitapayWebhookErrorCode,
error_description: String,
bank_account_owner_id: String,
bank_account_id: String,
transaction_ids: Vec<String>,
wire_id: String,
},
}

View File

@ -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<F> TryFrom<&FacilitapayRouterData<&types::RefundsRouterData<F>>> for FacilitapayRefundRequest {
type Error = Error;
fn try_from(
item: &FacilitapayRouterData<&types::RefundsRouterData<F>>,
) -> Result<Self, Self::Error> {
Ok(Self {
amount: item.amount.clone(),
})
}
}
impl From<FacilitapayPaymentStatus> for enums::RefundStatus {
fn from(item: FacilitapayPaymentStatus) -> Self {
match item {
@ -532,6 +524,56 @@ impl From<FacilitapayPaymentStatus> for enums::RefundStatus {
}
}
// Void (cancel unprocessed payment) transformer
impl
TryFrom<
ResponseRouterData<Void, FacilitapayVoidResponse, PaymentsCancelData, PaymentsResponseData>,
> for RouterData<Void, PaymentsCancelData, PaymentsResponseData>
{
type Error = Error;
fn try_from(
item: ResponseRouterData<
Void,
FacilitapayVoidResponse,
PaymentsCancelData,
PaymentsResponseData,
>,
) -> Result<Self, Self::Error> {
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<RefundsResponseRouterData<Execute, FacilitapayRefundResponse>>
for types::RefundsRouterData<Execute>
{
@ -541,7 +583,7 @@ impl TryFrom<RefundsResponseRouterData<Execute, FacilitapayRefundResponse>>
) -> Result<Self, Self::Error> {
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<RefundsResponseRouterData<RSync, FacilitapayRefundResponse>>
) -> Result<Self, Self::Error> {
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