feat(relay): add relay void flow (#11167)

Co-authored-by: Shankar Singh C <83439957+ShankarSinghC@users.noreply.github.com>
This commit is contained in:
Sakil Mostak
2026-02-10 19:48:51 +05:30
committed by GitHub
parent 6dc0910c93
commit cd6458b938
9 changed files with 420 additions and 20 deletions

View File

@@ -35382,6 +35382,17 @@
"$ref": "#/components/schemas/RelayIncrementalAuthorizationRequestData"
}
}
},
{
"type": "object",
"required": [
"void"
],
"properties": {
"void": {
"$ref": "#/components/schemas/RelayVoidRequestData"
}
}
}
]
},
@@ -35558,9 +35569,38 @@
"enum": [
"refund",
"capture",
"incremental_authorization"
"incremental_authorization",
"void"
]
},
"RelayVoidRequestData": {
"type": "object",
"required": [
"amount"
],
"properties": {
"amount": {
"type": "integer",
"format": "int64",
"description": "The amount of the transaction that is being voided",
"example": 6540
},
"currency": {
"allOf": [
{
"$ref": "#/components/schemas/Currency"
}
],
"nullable": true
},
"cancellation_reason": {
"type": "string",
"description": "The cancellation reason for voiding the transaction",
"example": "Requested by merchant",
"nullable": true
}
}
},
"RequestPaymentMethodTypes": {
"type": "object",
"required": [

View File

@@ -27,6 +27,7 @@ pub enum RelayData {
Refund(RelayRefundRequestData),
Capture(RelayCaptureRequestData),
IncrementalAuthorization(RelayIncrementalAuthorizationRequestData),
Void(RelayVoidRequestData),
}
#[derive(Debug, ToSchema, Clone, Deserialize, Serialize)]
@@ -68,6 +69,19 @@ pub struct RelayIncrementalAuthorizationRequestData {
pub currency: api_enums::Currency,
}
#[derive(Debug, ToSchema, Clone, Deserialize, Serialize)]
pub struct RelayVoidRequestData {
/// The amount of the transaction that is being voided
#[schema(value_type = i64 , example = 6540)]
pub amount: Option<MinorUnit>,
/// The currency in which the amount is being voided
#[schema(value_type = Option<Currency>)]
pub currency: Option<api_enums::Currency>,
/// The cancellation reason for voiding the transaction
#[schema(example = "Requested by merchant")]
pub cancellation_reason: Option<String>,
}
#[derive(Debug, ToSchema, Clone, Deserialize, Serialize)]
pub struct RelayResponse {
/// The unique identifier for the Relay

View File

@@ -2954,6 +2954,7 @@ pub enum RelayType {
Refund,
Capture,
IncrementalAuthorization,
Void,
}
#[derive(

View File

@@ -86,6 +86,11 @@ impl From<api_models::relay::RelayData> for RelayData {
additional_amount: relay_incremental_authorization_request.additional_amount,
currency: relay_incremental_authorization_request.currency,
}),
api_models::relay::RelayData::Void(relay_void_request) => Self::Void(RelayVoidData {
amount: relay_void_request.amount,
currency: relay_void_request.currency,
cancellation_reason: relay_void_request.cancellation_reason,
}),
}
}
}
@@ -122,6 +127,16 @@ impl From<api_models::relay::RelayIncrementalAuthorizationRequestData>
}
}
impl From<api_models::relay::RelayVoidRequestData> for RelayVoidData {
fn from(relay: api_models::relay::RelayVoidRequestData) -> Self {
Self {
amount: relay.amount,
currency: relay.currency,
cancellation_reason: relay.cancellation_reason,
}
}
}
impl RelayUpdate {
pub fn from_refund_response(
response: Result<router_response_types::RefundsResponseData, ErrorResponse>,
@@ -228,6 +243,32 @@ impl RelayUpdate {
},
}
}
pub fn try_from_void_response(
(status, response): (
common_enums::AttemptStatus,
Result<router_response_types::PaymentsResponseData, ErrorResponse>,
),
) -> CustomResult<Self, ApiErrorResponse> {
match response {
Err(error) => Ok(Self::ErrorUpdate {
error_code: error.code,
error_message: error.reason.unwrap_or(error.message),
status: common_enums::RelayStatus::Failure,
}),
Ok(response) => match response {
router_response_types::PaymentsResponseData::TransactionResponse {
resource_id,
..
} => Ok(Self::StatusUpdate {
connector_reference_id: resource_id.get_optional_response_id(),
status: common_enums::RelayStatus::from(status),
}),
_ => Err(ApiErrorResponse::InternalServerError)
.attach_printable("Payment Response Not Supported"),
},
}
}
}
impl From<RelayData> for api_models::relay::RelayData {
@@ -257,6 +298,13 @@ impl From<RelayData> for api_models::relay::RelayData {
},
)
}
RelayData::Void(relay_void_request) => {
Self::Void(api_models::relay::RelayVoidRequestData {
amount: relay_void_request.amount,
currency: relay_void_request.currency,
cancellation_reason: relay_void_request.cancellation_reason,
})
}
}
}
}
@@ -298,6 +346,13 @@ impl From<Relay> for api_models::relay::RelayResponse {
},
)
}
RelayData::Void(relay_void_request) => {
api_models::relay::RelayData::Void(api_models::relay::RelayVoidRequestData {
amount: relay_void_request.amount,
currency: relay_void_request.currency,
cancellation_reason: relay_void_request.cancellation_reason,
})
}
});
Self {
id: value.id,
@@ -319,13 +374,14 @@ pub enum RelayData {
Refund(RelayRefundData),
Capture(RelayCaptureData),
IncrementalAuthorization(RelayIncrementalAuthorizationData),
Void(RelayVoidData),
}
impl RelayData {
pub fn get_refund_data(&self) -> CustomResult<RelayRefundData, ApiErrorResponse> {
match self.clone() {
Self::Refund(refund_data) => Ok(refund_data),
Self::Capture(_) | Self::IncrementalAuthorization(_) => {
Self::Capture(_) | Self::IncrementalAuthorization(_) | Self::Void(_) => {
Err(ApiErrorResponse::InternalServerError)
.attach_printable("relay data does not contain relay refund data")
}
@@ -335,7 +391,7 @@ impl RelayData {
pub fn get_capture_data(&self) -> CustomResult<RelayCaptureData, ApiErrorResponse> {
match self.clone() {
Self::Capture(capture_data) => Ok(capture_data),
Self::Refund(_) | Self::IncrementalAuthorization(_) => {
Self::Refund(_) | Self::IncrementalAuthorization(_) | Self::Void(_) => {
Err(ApiErrorResponse::InternalServerError)
.attach_printable("relay data does not contain relay capture data")
}
@@ -349,10 +405,20 @@ impl RelayData {
Self::IncrementalAuthorization(incremental_authorization_data) => {
Ok(incremental_authorization_data)
}
Self::Refund(_) | Self::Capture(_) => Err(ApiErrorResponse::InternalServerError)
.attach_printable(
"relay data does not contain relay incremental authorization data",
),
Self::Refund(_) | Self::Capture(_) | Self::Void(_) => Err(
ApiErrorResponse::InternalServerError,
)
.attach_printable("relay data does not contain relay incremental authorization data"),
}
}
pub fn get_void_data(&self) -> CustomResult<RelayVoidData, ApiErrorResponse> {
match self.clone() {
Self::Void(void_data) => Ok(void_data),
Self::Refund(_) | Self::Capture(_) | Self::IncrementalAuthorization(_) => Err(
ApiErrorResponse::InternalServerError,
)
.attach_printable("relay data does not contain relay incremental authorization data"),
}
}
}
@@ -378,6 +444,13 @@ pub struct RelayIncrementalAuthorizationData {
pub currency: enums::Currency,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct RelayVoidData {
pub amount: Option<MinorUnit>,
pub currency: Option<enums::Currency>,
pub cancellation_reason: Option<String>,
}
#[derive(Debug)]
pub enum RelayUpdate {
ErrorUpdate {

View File

@@ -713,6 +713,7 @@ Never share your secret api keys. Keep them guarded and secure.
api_models::relay::RelayRefundRequestData,
api_models::relay::RelayCaptureRequestData,
api_models::relay::RelayIncrementalAuthorizationRequestData,
api_models::relay::RelayVoidRequestData,
api_models::enums::RelayStatus,
api_models::relay::RelayError,
api_models::payments::AmountFilter,

View File

@@ -26,7 +26,9 @@ pub mod utils;
pub trait Validate {
type Error: error_stack::Context;
fn validate(&self) -> Result<(), Self::Error>;
fn validate(&self) -> Result<(), Self::Error> {
Ok(())
}
}
impl Validate for relay_api_models::RelayRefundRequestData {
@@ -76,6 +78,10 @@ impl Validate for relay_api_models::RelayIncrementalAuthorizationRequestData {
}
}
impl Validate for relay_api_models::RelayVoidRequestData {
type Error = errors::ApiErrorResponse;
}
#[async_trait]
pub trait RelayInterface {
type Request: Validate;
@@ -119,6 +125,7 @@ impl RelayRequestInner<RelayRefund> {
data: ref_data,
}),
Some(relay_api_models::RelayData::Capture(_))
| Some(relay_api_models::RelayData::Void(_))
| Some(relay_api_models::RelayData::IncrementalAuthorization(_))
| None => Err(errors::ApiErrorResponse::InvalidRequestData {
message: "Relay data is required for relay type refund".to_string(),
@@ -245,6 +252,7 @@ impl RelayRequestInner<RelayCapture> {
data: ref_data,
}),
Some(relay_api_models::RelayData::Refund(_))
| Some(relay_api_models::RelayData::Void(_))
| Some(relay_api_models::RelayData::IncrementalAuthorization(_))
| None => Err(errors::ApiErrorResponse::InvalidRequestData {
message: "Relay data is required for relay type capture".to_string(),
@@ -374,6 +382,7 @@ impl RelayRequestInner<RelayIncrementalAuthorization> {
data: ref_data,
}),
Some(relay_api_models::RelayData::Refund(_))
| Some(relay_api_models::RelayData::Void(_))
| Some(relay_api_models::RelayData::Capture(_))
| None => Err(errors::ApiErrorResponse::InvalidRequestData {
message: "Relay data is required for relay type capture".to_string(),
@@ -494,6 +503,135 @@ impl RelayInterface for RelayIncrementalAuthorization {
}
}
impl RelayRequestInner<RelayVoid> {
pub fn from_relay_request(relay_request: relay_api_models::RelayRequest) -> RouterResult<Self> {
match relay_request.data {
Some(relay_api_models::RelayData::Void(ref_data)) => Ok(Self {
connector_resource_id: relay_request.connector_resource_id,
connector_id: relay_request.connector_id,
relay_type: PhantomData,
data: ref_data,
}),
Some(relay_api_models::RelayData::Refund(_))
| Some(relay_api_models::RelayData::IncrementalAuthorization(_))
| Some(relay_api_models::RelayData::Capture(_))
| None => Err(errors::ApiErrorResponse::InvalidRequestData {
message: "Relay data is required for relay type void".to_string(),
})?,
}
}
}
pub struct RelayVoid;
#[async_trait]
impl RelayInterface for RelayVoid {
type Request = relay_api_models::RelayVoidRequestData;
fn get_domain_models(
relay_request: RelayRequestInner<Self>,
merchant_id: &id_type::MerchantId,
profile_id: &id_type::ProfileId,
) -> relay::Relay {
let relay_id = id_type::RelayId::generate();
let relay_void: relay::RelayVoidData = relay_request.data.into();
relay::Relay {
id: relay_id.clone(),
connector_resource_id: relay_request.connector_resource_id.clone(),
connector_id: relay_request.connector_id.clone(),
profile_id: profile_id.clone(),
merchant_id: merchant_id.clone(),
relay_type: common_enums::RelayType::Void,
request_data: Some(relay::RelayData::Void(relay_void)),
status: RelayStatus::Created,
connector_reference_id: None,
error_code: None,
error_message: None,
created_at: common_utils::date_time::now(),
modified_at: common_utils::date_time::now(),
response_data: None,
}
}
async fn process_relay(
state: &SessionState,
platform: domain::Platform,
connector_account: domain::MerchantConnectorAccount,
relay_record: &relay::Relay,
) -> RouterResult<relay::RelayUpdate> {
let connector_id = &relay_record.connector_id;
let merchant_id = platform.get_processor().get_account().get_id();
let connector_name = &connector_account.get_connector_name_as_string();
let connector_data = api::ConnectorData::get_connector_by_name(
&state.conf.connectors,
connector_name,
api::GetToken::Connector,
Some(connector_id.clone()),
)?;
let connector_integration: services::BoxedPaymentConnectorIntegrationInterface<
api::Void,
hyperswitch_domain_models::router_request_types::PaymentsCancelData,
hyperswitch_domain_models::router_response_types::PaymentsResponseData,
> = connector_data.connector.get_connector_integration();
let router_data = utils::construct_relay_void_router_data(
state,
merchant_id,
&connector_account,
relay_record,
)
.await?;
let router_data_res = services::execute_connector_processing_step(
state,
connector_integration,
&router_data,
payments::CallConnectorAction::Trigger,
None,
None,
)
.await
.to_payment_failed_response()?;
let relay_update = relay::RelayUpdate::try_from_void_response((
router_data_res.status,
router_data_res.response,
))?;
Ok(relay_update)
}
fn generate_response(value: relay::Relay) -> RouterResult<api_models::relay::RelayResponse> {
let error = value
.error_code
.zip(value.error_message)
.map(
|(error_code, error_message)| api_models::relay::RelayError {
code: error_code,
message: error_message,
},
);
let data =
api_models::relay::RelayData::from(value.request_data.get_required_value("RelayData")?);
Ok(api_models::relay::RelayResponse {
id: value.id,
status: value.status,
error,
connector_resource_id: value.connector_resource_id,
connector_id: value.connector_id,
profile_id: value.profile_id,
relay_type: value.relay_type,
data: Some(data),
connector_reference_id: value.connector_reference_id,
})
}
}
pub async fn relay_flow_decider(
state: SessionState,
platform: domain::Platform,
@@ -522,6 +660,11 @@ pub async fn relay_flow_decider(
)
.await
}
common_enums::RelayType::Void => {
let relay_capture_request =
RelayRequestInner::<RelayVoid>::from_relay_request(request)?;
relay(state, platform, profile_id_optional, relay_capture_request).await
}
}
}
@@ -712,7 +855,9 @@ pub async fn relay_retrieve(
relay_record
}
}
common_enums::RelayType::IncrementalAuthorization => relay_record,
common_enums::RelayType::IncrementalAuthorization | common_enums::RelayType::Void => {
relay_record
}
};
let response = relay_api_models::RelayResponse::from(relay_response);

View File

@@ -406,6 +406,137 @@ pub async fn construct_relay_incremental_authorization_router_data(
Ok(router_data)
}
pub async fn construct_relay_void_router_data(
state: &SessionState,
merchant_id: &id_type::MerchantId,
connector_account: &domain::MerchantConnectorAccount,
relay_record: &hyperswitch_domain_models::relay::Relay,
) -> RouterResult<types::PaymentsCancelRouterData> {
let connector_auth_type = connector_account
.get_connector_account_details()
.change_context(errors::ApiErrorResponse::InternalServerError)
.attach_printable("Failed while parsing value for ConnectorAuthType")?;
let connector_name = &connector_account.get_connector_name_as_string();
let webhook_url = Some(payments::helpers::create_webhook_url(
&state.base_url.clone(),
merchant_id,
connector_account.get_id().get_string_repr(),
));
let supported_connector = &state
.conf
.multiple_api_version_supported_connectors
.supported_connectors;
let connector_enum = api_models::enums::Connector::from_str(connector_name)
.change_context(errors::ConnectorError::InvalidConnectorName)
.change_context(errors::ApiErrorResponse::InvalidDataValue {
field_name: "connector",
})
.attach_printable_lazy(|| format!("unable to parse connector name {connector_name:?}"))?;
let connector_api_version = if supported_connector.contains(&connector_enum) {
state
.store
.find_config_by_key(&format!("connector_api_version_{connector_name}"))
.await
.map(|value| value.config)
.ok()
} else {
None
};
let relay_void_data = relay_record
.request_data
.clone()
.get_required_value("void relay data")
.change_context(errors::ApiErrorResponse::InternalServerError)
.attach_printable("Failed to obtain relay data to construct relay void data")?
.get_void_data()?;
let relay_id_string = relay_record.id.get_string_repr().to_string();
let router_data = hyperswitch_domain_models::router_data::RouterData {
flow: std::marker::PhantomData,
merchant_id: merchant_id.clone(),
customer_id: None,
tenant_id: state.tenant.tenant_id.clone(),
connector: connector_name.to_string(),
payment_id: IRRELEVANT_PAYMENT_INTENT_ID.to_string(),
attempt_id: IRRELEVANT_PAYMENT_ATTEMPT_ID.to_string(),
status: common_enums::AttemptStatus::Charged,
payment_method: common_enums::PaymentMethod::default(),
payment_method_type: None,
connector_auth_type,
description: None,
address: hyperswitch_domain_models::payment_address::PaymentAddress::default(),
auth_type: common_enums::AuthenticationType::default(),
connector_meta_data: connector_account.metadata.clone(),
connector_wallets_details: None,
amount_captured: None,
payment_method_status: None,
minor_amount_captured: None,
request: hyperswitch_domain_models::router_request_types::PaymentsCancelData {
amount: relay_void_data
.amount
.map(|value| value.get_amount_as_i64()),
currency: relay_void_data.currency,
connector_transaction_id: relay_record.connector_resource_id.clone(),
cancellation_reason: relay_void_data.cancellation_reason,
connector_meta: None,
browser_info: None,
metadata: None,
minor_amount: None,
webhook_url,
capture_method: None,
split_payments: None,
merchant_order_reference_id: None,
feature_metadata: None,
payment_method_type: None,
},
response: Err(ErrorResponse::default()),
access_token: None,
session_token: None,
reference_id: None,
payment_method_token: None,
connector_customer: None,
recurring_mandate_payment_data: None,
preprocessing_id: None,
connector_request_reference_id: relay_id_string.clone(),
#[cfg(feature = "payouts")]
payout_method_data: None,
#[cfg(feature = "payouts")]
quote_id: None,
test_mode: connector_account.get_connector_test_mode(),
payment_method_balance: None,
connector_api_version,
connector_http_status_code: None,
external_latency: None,
apple_pay_flow: None,
frm_metadata: None,
refund_id: None,
dispute_id: None,
payout_id: None,
connector_response: None,
integrity_check: Ok(()),
additional_merchant_data: None,
header_payload: None,
connector_mandate_request_reference_id: None,
authentication_id: None,
psd2_sca_exemption_type: None,
raw_connector_response: None,
is_payment_id_from_merchant: None,
l2_l3_data: None,
minor_amount_capturable: None,
authorized_amount: None,
customer_document_details: None,
};
Ok(router_data)
}
pub async fn construct_relay_payments_retrieve_router_data(
state: &SessionState,
merchant_id: &id_type::MerchantId,
@@ -443,22 +574,13 @@ pub async fn construct_relay_payments_retrieve_router_data(
None
};
let relay_capture_data = match relay_record
let relay_capture_data = relay_record
.request_data
.clone()
.get_required_value("capture relay data")
.change_context(errors::ApiErrorResponse::InternalServerError)
.attach_printable("Failed to obtain relay data to construct relay capture data")?
{
hyperswitch_domain_models::relay::RelayData::Capture(relay_capture_data) => {
Ok(relay_capture_data)
}
hyperswitch_domain_models::relay::RelayData::Refund(_)
| hyperswitch_domain_models::relay::RelayData::IncrementalAuthorization(_) => {
Err(errors::ApiErrorResponse::InternalServerError)
.attach_printable("Failed to obtain relay data to construct relay capture data")
}
}?;
.get_capture_data()?;
let connector_transaction_id = match capture_method_type {
Some(hyperswitch_interfaces::api::CaptureSyncMethod::Bulk) => {

View File

@@ -0,0 +1,2 @@
-- This file should undo anything in `up.sql`
select 1;

View File

@@ -0,0 +1,2 @@
-- Your SQL goes here
ALTER TYPE "RelayType" ADD VALUE IF NOT EXISTS 'void';