From cd6458b9385e62cf9f86d43b71fb660bba236108 Mon Sep 17 00:00:00 2001 From: Sakil Mostak <73734619+Sakilmostak@users.noreply.github.com> Date: Tue, 10 Feb 2026 19:48:51 +0530 Subject: [PATCH] feat(relay): add relay void flow (#11167) Co-authored-by: Shankar Singh C <83439957+ShankarSinghC@users.noreply.github.com> --- api-reference/v1/openapi_spec_v1.json | 42 ++++- crates/api_models/src/relay.rs | 14 ++ crates/common_enums/src/enums.rs | 1 + crates/hyperswitch_domain_models/src/relay.rs | 85 +++++++++- crates/openapi/src/openapi.rs | 1 + crates/router/src/core/relay.rs | 149 +++++++++++++++++- crates/router/src/core/relay/utils.rs | 144 +++++++++++++++-- .../down.sql | 2 + .../up.sql | 2 + 9 files changed, 420 insertions(+), 20 deletions(-) create mode 100644 migrations/2026-02-04-131406_add_void_to_relay_type/down.sql create mode 100644 migrations/2026-02-04-131406_add_void_to_relay_type/up.sql diff --git a/api-reference/v1/openapi_spec_v1.json b/api-reference/v1/openapi_spec_v1.json index 34cb2f5cb3..55ade85863 100644 --- a/api-reference/v1/openapi_spec_v1.json +++ b/api-reference/v1/openapi_spec_v1.json @@ -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": [ diff --git a/crates/api_models/src/relay.rs b/crates/api_models/src/relay.rs index 9de386a6db..353817f000 100644 --- a/crates/api_models/src/relay.rs +++ b/crates/api_models/src/relay.rs @@ -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, + /// The currency in which the amount is being voided + #[schema(value_type = Option)] + pub currency: Option, + /// The cancellation reason for voiding the transaction + #[schema(example = "Requested by merchant")] + pub cancellation_reason: Option, +} + #[derive(Debug, ToSchema, Clone, Deserialize, Serialize)] pub struct RelayResponse { /// The unique identifier for the Relay diff --git a/crates/common_enums/src/enums.rs b/crates/common_enums/src/enums.rs index c959d5c88a..9afc5a06f2 100644 --- a/crates/common_enums/src/enums.rs +++ b/crates/common_enums/src/enums.rs @@ -2954,6 +2954,7 @@ pub enum RelayType { Refund, Capture, IncrementalAuthorization, + Void, } #[derive( diff --git a/crates/hyperswitch_domain_models/src/relay.rs b/crates/hyperswitch_domain_models/src/relay.rs index 44ec2bb612..fdd5cdab13 100644 --- a/crates/hyperswitch_domain_models/src/relay.rs +++ b/crates/hyperswitch_domain_models/src/relay.rs @@ -86,6 +86,11 @@ impl From 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 } } +impl From 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, @@ -228,6 +243,32 @@ impl RelayUpdate { }, } } + + pub fn try_from_void_response( + (status, response): ( + common_enums::AttemptStatus, + Result, + ), + ) -> CustomResult { + 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 for api_models::relay::RelayData { @@ -257,6 +298,13 @@ impl From 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 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 { 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 { 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 { + 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, + pub currency: Option, + pub cancellation_reason: Option, +} + #[derive(Debug)] pub enum RelayUpdate { ErrorUpdate { diff --git a/crates/openapi/src/openapi.rs b/crates/openapi/src/openapi.rs index 27fcc27705..df1d0fb0bb 100644 --- a/crates/openapi/src/openapi.rs +++ b/crates/openapi/src/openapi.rs @@ -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, diff --git a/crates/router/src/core/relay.rs b/crates/router/src/core/relay.rs index 969e3e9f06..912c331f46 100644 --- a/crates/router/src/core/relay.rs +++ b/crates/router/src/core/relay.rs @@ -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 { 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 { 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 { 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 { + pub fn from_relay_request(relay_request: relay_api_models::RelayRequest) -> RouterResult { + 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, + 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 { + 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 { + 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::::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); diff --git a/crates/router/src/core/relay/utils.rs b/crates/router/src/core/relay/utils.rs index 0c97bc05e7..43f64499e7 100644 --- a/crates/router/src/core/relay/utils.rs +++ b/crates/router/src/core/relay/utils.rs @@ -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 { + 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) => { diff --git a/migrations/2026-02-04-131406_add_void_to_relay_type/down.sql b/migrations/2026-02-04-131406_add_void_to_relay_type/down.sql new file mode 100644 index 0000000000..da13010c01 --- /dev/null +++ b/migrations/2026-02-04-131406_add_void_to_relay_type/down.sql @@ -0,0 +1,2 @@ +-- This file should undo anything in `up.sql` +select 1; \ No newline at end of file diff --git a/migrations/2026-02-04-131406_add_void_to_relay_type/up.sql b/migrations/2026-02-04-131406_add_void_to_relay_type/up.sql new file mode 100644 index 0000000000..465016d73b --- /dev/null +++ b/migrations/2026-02-04-131406_add_void_to_relay_type/up.sql @@ -0,0 +1,2 @@ +-- Your SQL goes here +ALTER TYPE "RelayType" ADD VALUE IF NOT EXISTS 'void'; \ No newline at end of file