feat(connector): [payload] add webhook support (#8558)

This commit is contained in:
Pa1NarK
2025-07-11 17:55:18 +05:30
committed by GitHub
parent 8a7590cf60
commit 2fe3132da8
8 changed files with 262 additions and 110 deletions

View File

@ -10,9 +10,9 @@ use crate::{disputes, enums as api_enums, mandates, payments, refunds};
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Copy)] #[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Copy)]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
pub enum IncomingWebhookEvent { pub enum IncomingWebhookEvent {
/// Authorization + Capture success
PaymentIntentFailure,
/// Authorization + Capture failure /// Authorization + Capture failure
PaymentIntentFailure,
/// Authorization + Capture success
PaymentIntentSuccess, PaymentIntentSuccess,
PaymentIntentProcessing, PaymentIntentProcessing,
PaymentIntentPartiallyFunded, PaymentIntentPartiallyFunded,

View File

@ -6328,8 +6328,7 @@ type="Text"
payment_method_type = "Visa" payment_method_type = "Visa"
[payload.connector_auth.HeaderKey] [payload.connector_auth.HeaderKey]
api_key="API Key" api_key="API Key"
[payload.connector_webhook_details]
merchant_secret="Source verification key"
[silverflow] [silverflow]
[silverflow.connector_auth.BodyKey] [silverflow.connector_auth.BodyKey]
api_key="API Key" api_key="API Key"

View File

@ -4935,10 +4935,8 @@ type="Text"
payment_method_type = "Visa" payment_method_type = "Visa"
[payload.connector_auth.HeaderKey] [payload.connector_auth.HeaderKey]
api_key="API Key" api_key="API Key"
[payload.connector_webhook_details]
merchant_secret="Source verification key"
[silverflow] [silverflow]
[silverflow.connector_auth.BodyKey] [silverflow.connector_auth.BodyKey]
api_key="API Key" api_key="API Key"
key1="Secret Key" key1="Secret Key"

View File

@ -6302,8 +6302,7 @@ type="Text"
payment_method_type = "Visa" payment_method_type = "Visa"
[payload.connector_auth.HeaderKey] [payload.connector_auth.HeaderKey]
api_key="API Key" api_key="API Key"
[payload.connector_webhook_details]
merchant_secret="Source verification key"
[silverflow] [silverflow]
[silverflow.connector_auth.BodyKey] [silverflow.connector_auth.BodyKey]
api_key="API Key" api_key="API Key"

View File

@ -8,12 +8,12 @@ use base64::Engine;
use common_enums::enums; use common_enums::enums;
use common_utils::{ use common_utils::{
consts::BASE64_ENGINE, consts::BASE64_ENGINE,
errors::CustomResult, errors::{CustomResult, ReportSwitchExt},
ext_traits::BytesExt, ext_traits::{ByteSliceExt, BytesExt},
request::{Method, Request, RequestBuilder, RequestContent}, request::{Method, Request, RequestBuilder, RequestContent},
types::{AmountConvertor, StringMajorUnit, StringMajorUnitForConnector}, types::{AmountConvertor, StringMajorUnit, StringMajorUnitForConnector},
}; };
use error_stack::{report, ResultExt}; use error_stack::ResultExt;
use hyperswitch_domain_models::{ use hyperswitch_domain_models::{
payment_method_data::PaymentMethodData, payment_method_data::PaymentMethodData,
router_data::{AccessToken, ConnectorAuthType, ErrorResponse, RouterData}, router_data::{AccessToken, ConnectorAuthType, ErrorResponse, RouterData},
@ -47,7 +47,7 @@ use hyperswitch_interfaces::{
types::{self, PaymentsVoidType, Response}, types::{self, PaymentsVoidType, Response},
webhooks, webhooks,
}; };
use masking::{ExposeInterface, Mask}; use masking::{ExposeInterface, Mask, Secret};
use transformers as payload; use transformers as payload;
use crate::{constants::headers, types::ResponseRouterData, utils}; use crate::{constants::headers, types::ResponseRouterData, utils};
@ -195,7 +195,18 @@ impl ConnectorIntegration<Session, PaymentsSessionData, PaymentsResponseData> fo
impl ConnectorIntegration<AccessTokenAuth, AccessTokenRequestData, AccessToken> for Payload {} impl ConnectorIntegration<AccessTokenAuth, AccessTokenRequestData, AccessToken> for Payload {}
impl ConnectorIntegration<SetupMandate, SetupMandateRequestData, PaymentsResponseData> for Payload {} impl ConnectorIntegration<SetupMandate, SetupMandateRequestData, PaymentsResponseData> for Payload {
fn build_request(
&self,
_req: &RouterData<SetupMandate, SetupMandateRequestData, PaymentsResponseData>,
_connectors: &Connectors,
) -> CustomResult<Option<Request>, errors::ConnectorError> {
Err(
errors::ConnectorError::NotImplemented("Setup Mandate flow for Payload".to_string())
.into(),
)
}
}
impl ConnectorIntegration<Authorize, PaymentsAuthorizeData, PaymentsResponseData> for Payload { impl ConnectorIntegration<Authorize, PaymentsAuthorizeData, PaymentsResponseData> for Payload {
fn get_headers( fn get_headers(
@ -701,25 +712,95 @@ impl ConnectorIntegration<RSync, RefundsData, RefundsResponseData> for Payload {
#[async_trait::async_trait] #[async_trait::async_trait]
impl webhooks::IncomingWebhook for Payload { impl webhooks::IncomingWebhook for Payload {
fn get_webhook_object_reference_id( async fn verify_webhook_source(
&self, &self,
_request: &webhooks::IncomingWebhookRequestDetails<'_>, _request: &webhooks::IncomingWebhookRequestDetails<'_>,
_merchant_id: &common_utils::id_type::MerchantId,
_connector_webhook_details: Option<common_utils::pii::SecretSerdeValue>,
_connector_account_details: common_utils::crypto::Encryptable<Secret<serde_json::Value>>,
_connector_label: &str,
) -> CustomResult<bool, errors::ConnectorError> {
// Payload does not support source verification
// It does, but the client id and client secret generation is not possible at present
// It requires OAuth connect which falls under Access Token flow and it also requires multiple calls to be made
// We return false just so that a PSync call is triggered internally
Ok(false)
}
fn get_webhook_object_reference_id(
&self,
request: &webhooks::IncomingWebhookRequestDetails<'_>,
) -> CustomResult<api_models::webhooks::ObjectReferenceId, errors::ConnectorError> { ) -> CustomResult<api_models::webhooks::ObjectReferenceId, errors::ConnectorError> {
Err(report!(errors::ConnectorError::WebhooksNotImplemented)) let webhook_body: responses::PayloadWebhookEvent = request
.body
.parse_struct("PayloadWebhookEvent")
.change_context(errors::ConnectorError::WebhookBodyDecodingFailed)?;
let reference_id = match webhook_body.trigger {
responses::PayloadWebhooksTrigger::Payment
| responses::PayloadWebhooksTrigger::Processed
| responses::PayloadWebhooksTrigger::Authorized
| responses::PayloadWebhooksTrigger::Credit
| responses::PayloadWebhooksTrigger::Reversal
| responses::PayloadWebhooksTrigger::Void
| responses::PayloadWebhooksTrigger::AutomaticPayment
| responses::PayloadWebhooksTrigger::Decline
| responses::PayloadWebhooksTrigger::Deposit
| responses::PayloadWebhooksTrigger::Reject
| responses::PayloadWebhooksTrigger::PaymentActivationStatus
| responses::PayloadWebhooksTrigger::PaymentLinkStatus
| responses::PayloadWebhooksTrigger::ProcessingStatus
| responses::PayloadWebhooksTrigger::BankAccountReject
| responses::PayloadWebhooksTrigger::Chargeback
| responses::PayloadWebhooksTrigger::ChargebackReversal
| responses::PayloadWebhooksTrigger::TransactionOperation
| responses::PayloadWebhooksTrigger::TransactionOperationClear => {
api_models::webhooks::ObjectReferenceId::PaymentId(
api_models::payments::PaymentIdType::ConnectorTransactionId(
webhook_body
.triggered_on
.transaction_id
.ok_or(errors::ConnectorError::WebhookReferenceIdNotFound)?,
),
)
}
responses::PayloadWebhooksTrigger::Refund => {
api_models::webhooks::ObjectReferenceId::RefundId(
api_models::webhooks::RefundIdType::ConnectorRefundId(
webhook_body
.triggered_on
.transaction_id
.ok_or(errors::ConnectorError::WebhookReferenceIdNotFound)?,
),
)
}
};
Ok(reference_id)
} }
fn get_webhook_event_type( fn get_webhook_event_type(
&self, &self,
_request: &webhooks::IncomingWebhookRequestDetails<'_>, request: &webhooks::IncomingWebhookRequestDetails<'_>,
) -> CustomResult<api_models::webhooks::IncomingWebhookEvent, errors::ConnectorError> { ) -> CustomResult<api_models::webhooks::IncomingWebhookEvent, errors::ConnectorError> {
Err(report!(errors::ConnectorError::WebhooksNotImplemented)) let webhook_body: responses::PayloadWebhookEvent =
request.body.parse_struct("PayloadWebhookEvent").switch()?;
Ok(api_models::webhooks::IncomingWebhookEvent::from(
webhook_body.trigger,
))
} }
fn get_webhook_resource_object( fn get_webhook_resource_object(
&self, &self,
_request: &webhooks::IncomingWebhookRequestDetails<'_>, request: &webhooks::IncomingWebhookRequestDetails<'_>,
) -> CustomResult<Box<dyn masking::ErasedMaskSerialize>, errors::ConnectorError> { ) -> CustomResult<Box<dyn masking::ErasedMaskSerialize>, errors::ConnectorError> {
Err(report!(errors::ConnectorError::WebhooksNotImplemented)) let webhook_body: responses::PayloadWebhookEvent = request
.body
.parse_struct("PayloadWebhookEvent")
.change_context(errors::ConnectorError::WebhookReferenceIdNotFound)?;
Ok(Box::new(webhook_body))
} }
} }
@ -782,7 +863,11 @@ static PAYLOAD_CONNECTOR_INFO: ConnectorInfo = ConnectorInfo {
connector_type: enums::PaymentConnectorCategory::PaymentGateway, connector_type: enums::PaymentConnectorCategory::PaymentGateway,
}; };
static PAYLOAD_SUPPORTED_WEBHOOK_FLOWS: [enums::EventClass; 0] = []; static PAYLOAD_SUPPORTED_WEBHOOK_FLOWS: [enums::EventClass; 3] = [
enums::EventClass::Disputes,
enums::EventClass::Payments,
enums::EventClass::Refunds,
];
impl ConnectorSpecifications for Payload { impl ConnectorSpecifications for Payload {
fn get_connector_about(&self) -> Option<&'static ConnectorInfo> { fn get_connector_about(&self) -> Option<&'static ConnectorInfo> {

View File

@ -38,6 +38,7 @@ pub struct PayloadCardsResponseData {
#[serde(rename = "id")] #[serde(rename = "id")]
pub transaction_id: String, pub transaction_id: String,
pub payment_method_id: Option<Secret<String>>, pub payment_method_id: Option<Secret<String>>,
// Connector customer id
pub processing_id: Option<String>, pub processing_id: Option<String>,
pub processing_method_id: Option<String>, pub processing_method_id: Option<String>,
pub ref_number: Option<String>, pub ref_number: Option<String>,
@ -84,6 +85,7 @@ pub struct PayloadRefundResponse {
pub transaction_id: String, pub transaction_id: String,
pub ledger: Vec<RefundsLedger>, pub ledger: Vec<RefundsLedger>,
pub payment_method_id: Option<Secret<String>>, pub payment_method_id: Option<Secret<String>>,
// Connector customer id
pub processing_id: Option<String>, pub processing_id: Option<String>,
pub ref_number: Option<String>, pub ref_number: Option<String>,
pub status: RefundStatus, pub status: RefundStatus,
@ -99,3 +101,51 @@ pub struct PayloadErrorResponse {
/// Payload returns arbitrary details in JSON format /// Payload returns arbitrary details in JSON format
pub details: Option<serde_json::Value>, pub details: Option<serde_json::Value>,
} }
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum PayloadWebhooksTrigger {
Payment,
Processed,
Authorized,
Credit,
Refund,
Reversal,
Void,
AutomaticPayment,
Decline,
Deposit,
Reject,
#[serde(rename = "payment_activation:status")]
PaymentActivationStatus,
#[serde(rename = "payment_link:status")]
PaymentLinkStatus,
ProcessingStatus,
BankAccountReject,
Chargeback,
ChargebackReversal,
#[serde(rename = "transaction:operation")]
TransactionOperation,
#[serde(rename = "transaction:operation:clear")]
TransactionOperationClear,
}
// Webhook response structures
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PayloadWebhookEvent {
pub object: String, // Added to match actual webhook structure
pub trigger: PayloadWebhooksTrigger,
pub webhook_id: String,
pub triggered_at: String, // Added to match actual webhook structure
// Webhooks Payload
pub triggered_on: PayloadEventDetails,
pub url: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PayloadEventDetails {
#[serde(rename = "id")]
pub transaction_id: Option<String>,
pub object: String,
pub value: Option<serde_json::Value>, // Changed to handle any value type including null
}

View File

@ -1,20 +1,24 @@
use api_models::webhooks::IncomingWebhookEvent;
use common_enums::enums; use common_enums::enums;
use common_utils::types::StringMajorUnit; use common_utils::types::StringMajorUnit;
use hyperswitch_domain_models::{ use hyperswitch_domain_models::{
payment_method_data::PaymentMethodData, payment_method_data::PaymentMethodData,
router_data::{ConnectorAuthType, RouterData}, router_data::{ConnectorAuthType, ErrorResponse, RouterData},
router_flow_types::refunds::{Execute, RSync}, router_flow_types::refunds::{Execute, RSync},
router_request_types::ResponseId, router_request_types::ResponseId,
router_response_types::{PaymentsResponseData, RefundsResponseData}, router_response_types::{PaymentsResponseData, RefundsResponseData},
types::{PaymentsAuthorizeRouterData, PaymentsCaptureRouterData, RefundsRouterData}, types::{PaymentsAuthorizeRouterData, PaymentsCaptureRouterData, RefundsRouterData},
}; };
use hyperswitch_interfaces::errors; use hyperswitch_interfaces::{
consts::{NO_ERROR_CODE, NO_ERROR_MESSAGE},
errors,
};
use masking::Secret; use masking::Secret;
use super::{requests, responses}; use super::{requests, responses};
use crate::{ use crate::{
types::{RefundsResponseRouterData, ResponseRouterData}, types::{RefundsResponseRouterData, ResponseRouterData},
utils::{is_manual_capture, CardData, RouterData as OtherRouterData}, utils::{is_manual_capture, AddressDetailsData, CardData, RouterData as OtherRouterData},
}; };
//TODO: Fill the struct with respective fields //TODO: Fill the struct with respective fields
@ -56,12 +60,20 @@ impl TryFrom<&PayloadRouterData<&PaymentsAuthorizeRouterData>>
cvc: req_card.card_cvc, cvc: req_card.card_cvc,
}; };
let address = item.router_data.get_billing_address()?; let address = item.router_data.get_billing_address()?;
// Check for required fields and fail if they're missing
let city = address.get_city()?.to_owned();
let country = address.get_country()?.to_owned();
let postal_code = address.get_zip()?.to_owned();
let state_province = address.get_state()?.to_owned();
let street_address = address.get_line1()?.to_owned();
let billing_address = requests::BillingAddress { let billing_address = requests::BillingAddress {
city: address.city.clone().unwrap_or_default(), city,
country: address.country.unwrap_or_default(), country,
postal_code: address.zip.clone().unwrap_or_default(), postal_code,
state_province: address.state.clone().unwrap_or_default(), state_province,
street_address: address.line1.clone().unwrap_or_default(), street_address,
}; };
// For manual capture, set status to "authorized" // For manual capture, set status to "authorized"
@ -127,13 +139,29 @@ impl<F, T>
) -> Result<Self, Self::Error> { ) -> Result<Self, Self::Error> {
match item.response.clone() { match item.response.clone() {
responses::PayloadPaymentsResponse::PayloadCardsResponse(response) => { responses::PayloadPaymentsResponse::PayloadCardsResponse(response) => {
let payment_status = response.status; let status = enums::AttemptStatus::from(response.status);
let transaction_id = response.transaction_id; let connector_customer = response.processing_id.clone();
let response_result = if status == enums::AttemptStatus::Failure {
Ok(Self { Err(ErrorResponse {
status: common_enums::AttemptStatus::from(payment_status), attempt_status: None,
response: Ok(PaymentsResponseData::TransactionResponse { code: response
resource_id: ResponseId::ConnectorTransactionId(transaction_id), .status_code
.clone()
.unwrap_or_else(|| NO_ERROR_CODE.to_string()),
message: response
.status_message
.clone()
.unwrap_or_else(|| NO_ERROR_MESSAGE.to_string()),
reason: response.status_message,
status_code: item.http_code,
connector_transaction_id: Some(response.transaction_id.clone()),
network_decline_code: None,
network_advice_code: None,
network_error_message: None,
})
} else {
Ok(PaymentsResponseData::TransactionResponse {
resource_id: ResponseId::ConnectorTransactionId(response.transaction_id),
redirection_data: Box::new(None), redirection_data: Box::new(None),
mandate_reference: Box::new(None), mandate_reference: Box::new(None),
connector_metadata: None, connector_metadata: None,
@ -141,7 +169,12 @@ impl<F, T>
connector_response_reference_id: response.ref_number, connector_response_reference_id: response.ref_number,
incremental_authorization_allowed: None, incremental_authorization_allowed: None,
charges: None, charges: None,
}), })
};
Ok(Self {
status,
response: response_result,
connector_customer,
..item.data ..item.data
}) })
} }
@ -225,3 +258,41 @@ impl TryFrom<RefundsResponseRouterData<RSync, responses::PayloadRefundResponse>>
}) })
} }
} }
// Webhook transformations
impl From<responses::PayloadWebhooksTrigger> for IncomingWebhookEvent {
fn from(trigger: responses::PayloadWebhooksTrigger) -> Self {
match trigger {
// Payment Success Events
responses::PayloadWebhooksTrigger::Processed => Self::PaymentIntentSuccess,
responses::PayloadWebhooksTrigger::Authorized => {
Self::PaymentIntentAuthorizationSuccess
}
// Payment Processing Events
responses::PayloadWebhooksTrigger::Payment
| responses::PayloadWebhooksTrigger::AutomaticPayment => Self::PaymentIntentProcessing,
// Payment Failure Events
responses::PayloadWebhooksTrigger::Decline
| responses::PayloadWebhooksTrigger::Reject
| responses::PayloadWebhooksTrigger::BankAccountReject => Self::PaymentIntentFailure,
responses::PayloadWebhooksTrigger::Void
| responses::PayloadWebhooksTrigger::Reversal => Self::PaymentIntentCancelled,
// Refund Events
responses::PayloadWebhooksTrigger::Refund => Self::RefundSuccess,
// Dispute Events
responses::PayloadWebhooksTrigger::Chargeback => Self::DisputeOpened,
responses::PayloadWebhooksTrigger::ChargebackReversal => Self::DisputeWon,
// Other payment-related events
// Events not supported by our standard flows
responses::PayloadWebhooksTrigger::PaymentActivationStatus
| responses::PayloadWebhooksTrigger::Credit
| responses::PayloadWebhooksTrigger::Deposit
| responses::PayloadWebhooksTrigger::PaymentLinkStatus
| responses::PayloadWebhooksTrigger::ProcessingStatus
| responses::PayloadWebhooksTrigger::TransactionOperation
| responses::PayloadWebhooksTrigger::TransactionOperationClear => {
Self::EventNotSupported
}
}
}
}

View File

@ -1,4 +1,8 @@
import { customerAcceptance } from "./Commons"; import {
customerAcceptance,
singleUseMandateData,
multiUseMandateData,
} from "./Commons";
const successfulNo3DSCardDetails = { const successfulNo3DSCardDetails = {
card_number: "4242424242424242", card_number: "4242424242424242",
@ -8,43 +12,18 @@ const successfulNo3DSCardDetails = {
card_cvc: "123", card_cvc: "123",
}; };
// Note: Payload may not support 3DS authentication - using same card for consistency
const successfulThreeDSTestCardDetails = { const successfulThreeDSTestCardDetails = {
card_number: "4242424242424242", ...successfulNo3DSCardDetails,
card_exp_month: "12",
card_exp_year: "25",
card_holder_name: "John Doe",
card_cvc: "123",
}; };
const failedNo3DSCardDetails = { const failedNo3DSCardDetails = {
card_number: "4000000000000002", card_number: "4111111111119903",
card_exp_month: "01", card_exp_month: "01",
card_exp_year: "25", card_exp_year: "25",
card_holder_name: "John Doe", card_holder_name: "John Doe",
card_cvc: "123", card_cvc: "123",
}; };
const singleUseMandateData = {
customer_acceptance: customerAcceptance,
mandate_type: {
single_use: {
amount: 8000,
currency: "USD",
},
},
};
const multiUseMandateData = {
customer_acceptance: customerAcceptance,
mandate_type: {
multi_use: {
amount: 8000,
currency: "USD",
},
},
};
const payment_method_data_no3ds = { const payment_method_data_no3ds = {
card: { card: {
last4: "4242", last4: "4242",
@ -95,14 +74,6 @@ export const connectorDetails = {
}, },
}, },
}, },
SessionToken: {
Response: {
status: 200,
body: {
session_token: [],
},
},
},
PaymentIntentWithShippingCost: { PaymentIntentWithShippingCost: {
Request: { Request: {
currency: "USD", currency: "USD",
@ -151,7 +122,7 @@ export const connectorDetails = {
setup_future_usage: "on_session", setup_future_usage: "on_session",
}, },
Response: { Response: {
status: 501, status: 400,
body: { body: {
error: { error: {
type: "invalid_request", type: "invalid_request",
@ -175,7 +146,7 @@ export const connectorDetails = {
setup_future_usage: "on_session", setup_future_usage: "on_session",
}, },
Response: { Response: {
status: 501, status: 400,
body: { body: {
error: { error: {
type: "invalid_request", type: "invalid_request",
@ -433,24 +404,25 @@ export const connectorDetails = {
}, },
SaveCardUse3DSAutoCaptureOffSession: { SaveCardUse3DSAutoCaptureOffSession: {
Configs: { Configs: {
DELAY: { TRIGGER_SKIP: true,
STATUS: true,
TIMEOUT: 15000,
},
}, },
Request: { Request: {
payment_method: "card", payment_method: "card",
payment_method_type: "debit",
payment_method_data: { payment_method_data: {
card: successfulThreeDSTestCardDetails, card: successfulThreeDSTestCardDetails,
}, },
setup_future_usage: "off_session", currency: "USD",
customer_acceptance: customerAcceptance, customer_acceptance: null,
setup_future_usage: "on_session",
}, },
Response: { Response: {
status: 200, status: 400,
body: { body: {
status: "requires_customer_action", error: {
type: "invalid_request",
message: "3DS authentication is not supported by Payload",
code: "IR_00",
},
}, },
}, },
}, },
@ -693,33 +665,11 @@ export const connectorDetails = {
mandate_data: singleUseMandateData, mandate_data: singleUseMandateData,
}, },
Response: { Response: {
status: 200, status: 501,
body: { body: {
status: "succeeded", code: "IR_00",
}, message: "Setup Mandate flow for Payload is not implemented",
}, type: "invalid_request",
},
ZeroAuthConfirmPayment: {
Configs: {
DELAY: {
STATUS: true,
TIMEOUT: 15000,
},
TRIGGER_SKIP: true,
},
Request: {
payment_type: "setup_mandate",
payment_method: "card",
payment_method_type: "credit",
payment_method_data: {
card: successfulNo3DSCardDetails,
},
},
Response: {
status: 200,
body: {
status: "succeeded",
setup_future_usage: "off_session",
}, },
}, },
}, },