From a289f19cd0675c52d59a0578292b4b086c1103c6 Mon Sep 17 00:00:00 2001 From: Amey Wale <76102448+AmeyWale@users.noreply.github.com> Date: Wed, 7 May 2025 12:04:39 +0530 Subject: [PATCH] feat(refunds_v2): Add Refunds Retrieve and Refunds Sync Core flow (#7835) Co-authored-by: hyperswitch-bot[bot] <148525504+hyperswitch-bot[bot]@users.noreply.github.com> --- api-reference-v2/openapi_spec.json | 41 +++ crates/api_models/src/events/refund.rs | 7 + crates/api_models/src/refunds.rs | 24 ++ crates/diesel_models/src/refund.rs | 18 + .../hyperswitch_domain_models/src/payments.rs | 4 + crates/openapi/src/openapi_v2.rs | 1 + crates/openapi/src/routes/refunds.rs | 21 ++ crates/router/src/core/refunds_v2.rs | 309 +++++++++++++++++- crates/router/src/core/utils.rs | 2 +- crates/router/src/routes/app.rs | 4 +- crates/router/src/routes/refunds.rs | 50 +++ crates/router/src/types/api/refunds.rs | 3 +- 12 files changed, 470 insertions(+), 14 deletions(-) diff --git a/api-reference-v2/openapi_spec.json b/api-reference-v2/openapi_spec.json index 8b27fde320..b6d6fb87e2 100644 --- a/api-reference-v2/openapi_spec.json +++ b/api-reference-v2/openapi_spec.json @@ -3265,6 +3265,47 @@ ] } }, + "/v2/refunds/{id}": { + "get": { + "tags": [ + "Refunds" + ], + "summary": "Refunds - Retrieve", + "description": "Retrieves a Refund. This may be used to get the status of a previously initiated refund", + "operationId": "Retrieve a Refund", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "The identifier for refund", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Refund retrieved", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RefundResponse" + } + } + } + }, + "404": { + "description": "Refund does not exist in our records" + } + }, + "security": [ + { + "api_key": [] + } + ] + } + }, "/v2/process_tracker/revenue_recovery_workflow/{revenue_recovery_id}": { "get": { "tags": [ diff --git a/crates/api_models/src/events/refund.rs b/crates/api_models/src/events/refund.rs index 14e1984a76..08c6dd52c3 100644 --- a/crates/api_models/src/events/refund.rs +++ b/crates/api_models/src/events/refund.rs @@ -59,6 +59,13 @@ impl ApiEventMetric for RefundsRetrieveRequest { } } +#[cfg(feature = "v2")] +impl ApiEventMetric for refunds::RefundsRetrieveRequest { + fn get_api_event_type(&self) -> Option { + None + } +} + #[cfg(feature = "v1")] impl ApiEventMetric for RefundUpdateRequest { fn get_api_event_type(&self) -> Option { diff --git a/crates/api_models/src/refunds.rs b/crates/api_models/src/refunds.rs index e8fcf39ca4..775e0dfeef 100644 --- a/crates/api_models/src/refunds.rs +++ b/crates/api_models/src/refunds.rs @@ -105,11 +105,19 @@ pub struct RefundsCreateRequest { pub metadata: Option, } +#[cfg(all(any(feature = "v1", feature = "v2"), not(feature = "refunds_v2")))] #[derive(Default, Debug, Clone, Deserialize)] pub struct RefundsRetrieveBody { pub force_sync: Option, } +#[cfg(all(feature = "v2", feature = "refunds_v2"))] +#[derive(Default, Debug, Clone, Deserialize)] +pub struct RefundsRetrieveBody { + pub force_sync: Option, +} + +#[cfg(all(any(feature = "v1", feature = "v2"), not(feature = "refunds_v2")))] #[derive(Default, Debug, ToSchema, Clone, Deserialize, Serialize)] pub struct RefundsRetrieveRequest { /// Unique Identifier for the Refund. This is to ensure idempotency for multiple partial refund initiated against the same payment. If the identifiers is not defined by the merchant, this filed shall be auto generated and provide in the API response. It is recommended to generate uuid(v4) as the refund_id. @@ -128,6 +136,22 @@ pub struct RefundsRetrieveRequest { pub merchant_connector_details: Option, } +#[cfg(all(feature = "v2", feature = "refunds_v2"))] +#[derive(Debug, ToSchema, Clone, Deserialize, Serialize)] +pub struct RefundsRetrieveRequest { + /// Unique Identifier for the Refund. This is to ensure idempotency for multiple partial refund initiated against the same payment. If the identifiers is not defined by the merchant, this filed shall be auto generated and provide in the API response. It is recommended to generate uuid(v4) as the refund_id. + #[schema( + max_length = 30, + min_length = 30, + example = "ref_mbabizu24mvu3mela5njyhpit4" + )] + pub refund_id: common_utils::id_type::GlobalRefundId, + + /// `force_sync` with the connector to get refund details + /// (defaults to false) + pub force_sync: Option, +} + #[derive(Default, Debug, ToSchema, Clone, Deserialize, Serialize)] #[serde(deny_unknown_fields)] pub struct RefundUpdateRequest { diff --git a/crates/diesel_models/src/refund.rs b/crates/diesel_models/src/refund.rs index e197bc3c21..927965a533 100644 --- a/crates/diesel_models/src/refund.rs +++ b/crates/diesel_models/src/refund.rs @@ -757,6 +757,24 @@ impl RefundUpdate { processor_refund_data: connector_refund_id.extract_hashed_data(), } } + + pub fn build_error_update_for_refund_failure( + refund_status: Option, + refund_error_message: Option, + refund_error_code: Option, + storage_scheme: &storage_enums::MerchantStorageScheme, + ) -> Self { + Self::ErrorUpdate { + refund_status, + refund_error_message, + refund_error_code, + updated_by: storage_scheme.to_string(), + connector_refund_id: None, + processor_refund_data: None, + unified_code: None, + unified_message: None, + } + } } #[cfg(all(any(feature = "v1", feature = "v2"), not(feature = "refunds_v2")))] diff --git a/crates/hyperswitch_domain_models/src/payments.rs b/crates/hyperswitch_domain_models/src/payments.rs index dea3d5f712..3cb9cf766d 100644 --- a/crates/hyperswitch_domain_models/src/payments.rs +++ b/crates/hyperswitch_domain_models/src/payments.rs @@ -763,6 +763,10 @@ impl PaymentIntent { }) .transpose() } + + pub fn get_currency(&self) -> storage_enums::Currency { + self.amount_details.currency + } } #[cfg(feature = "v1")] diff --git a/crates/openapi/src/openapi_v2.rs b/crates/openapi/src/openapi_v2.rs index b7f8834548..bb48871190 100644 --- a/crates/openapi/src/openapi_v2.rs +++ b/crates/openapi/src/openapi_v2.rs @@ -148,6 +148,7 @@ Never share your secret api keys. Keep them guarded and secure. //Routes for refunds routes::refunds::refunds_create, + routes::refunds::refunds_retrieve, // Routes for Revenue Recovery flow under Process Tracker routes::revenue_recovery::revenue_recovery_pt_retrieve_api diff --git a/crates/openapi/src/routes/refunds.rs b/crates/openapi/src/routes/refunds.rs index 0ff0891ee5..5c55de2d17 100644 --- a/crates/openapi/src/routes/refunds.rs +++ b/crates/openapi/src/routes/refunds.rs @@ -64,6 +64,7 @@ pub async fn refunds_create() {} operation_id = "Retrieve a Refund", security(("api_key" = [])) )] +#[cfg(feature = "v1")] pub async fn refunds_retrieve() {} /// Refunds - Retrieve (POST) @@ -212,3 +213,23 @@ pub async fn refunds_filter_list() {} )] #[cfg(feature = "v2")] pub async fn refunds_create() {} + +/// Refunds - Retrieve +/// +/// Retrieves a Refund. This may be used to get the status of a previously initiated refund +#[utoipa::path( + get, + path = "/v2/refunds/{id}", + params( + ("id" = String, Path, description = "The identifier for refund") + ), + responses( + (status = 200, description = "Refund retrieved", body = RefundResponse), + (status = 404, description = "Refund does not exist in our records") + ), + tag = "Refunds", + operation_id = "Retrieve a Refund", + security(("api_key" = [])) +)] +#[cfg(feature = "v2")] +pub async fn refunds_retrieve() {} diff --git a/crates/router/src/core/refunds_v2.rs b/crates/router/src/core/refunds_v2.rs index 89315006dc..b6ccfe8376 100644 --- a/crates/router/src/core/refunds_v2.rs +++ b/crates/router/src/core/refunds_v2.rs @@ -1,10 +1,17 @@ -use std::str::FromStr; +use std::{fmt::Debug, str::FromStr}; use api_models::{enums::Connector, refunds::RefundErrorDetails}; use common_utils::{id_type, types as common_utils_types}; use error_stack::{report, ResultExt}; -use hyperswitch_domain_models::router_data::{ErrorResponse, RouterData}; -use hyperswitch_interfaces::integrity::{CheckIntegrity, FlowIntegrity, GetIntegrityObject}; +use hyperswitch_domain_models::{ + router_data::{ErrorResponse, RouterData}, + router_data_v2::RefundFlowData, +}; +use hyperswitch_interfaces::{ + api::{Connector as ConnectorTrait, ConnectorIntegration}, + connector_integration_v2::{ConnectorIntegrationV2, ConnectorV2}, + integrity::{CheckIntegrity, FlowIntegrity, GetIntegrityObject}, +}; use router_env::{instrument, tracing}; use crate::{ @@ -202,20 +209,27 @@ pub async fn trigger_refund_to_gateway( Ok(response) } -async fn call_connector_service( +async fn call_connector_service( state: &SessionState, connector: &api::ConnectorData, add_access_token_result: types::AddAccessTokenResult, - router_data: RouterData, + router_data: RouterData, ) -> Result< - RouterData, + RouterData, error_stack::Report, -> { +> +where + F: Debug + Clone + 'static, + dyn ConnectorTrait + Sync: + ConnectorIntegration, + dyn ConnectorV2 + Sync: + ConnectorIntegrationV2, +{ if !(add_access_token_result.connector_supports_access_token && router_data.access_token.is_none()) { let connector_integration: services::BoxedRefundConnectorIntegrationInterface< - api::Execute, + F, types::RefundsData, types::RefundsResponseData, > = connector.connector.get_connector_integration(); @@ -382,9 +396,12 @@ pub fn get_refund_update_for_refund_response_data( } } -pub fn perform_integrity_check( - mut router_data: RouterData, -) -> RouterData { +pub fn perform_integrity_check( + mut router_data: RouterData, +) -> RouterData +where + F: Debug + Clone + 'static, +{ // Initiating Integrity check let integrity_result = check_refund_integrity(&router_data.request, &router_data.response); router_data.integrity_check = integrity_result; @@ -447,6 +464,276 @@ where request.check_integrity(request, connector_refund_id.to_owned()) } +// ********************************************** REFUND SYNC ********************************************** + +#[instrument(skip_all)] +pub async fn refund_retrieve_core_with_refund_id( + state: SessionState, + merchant_context: domain::MerchantContext, + profile: domain::Profile, + request: refunds::RefundsRetrieveRequest, +) -> errors::RouterResponse { + let refund_id = request.refund_id.clone(); + let db = &*state.store; + let profile_id = profile.get_id().to_owned(); + let refund = db + .find_refund_by_id( + &refund_id, + merchant_context.get_merchant_account().storage_scheme, + ) + .await + .to_not_found_response(errors::ApiErrorResponse::RefundNotFound)?; + + let response = Box::pin(refund_retrieve_core( + state.clone(), + merchant_context, + Some(profile_id), + request, + refund, + )) + .await?; + + api::RefundResponse::foreign_try_from(response).map(services::ApplicationResponse::Json) +} + +#[instrument(skip_all)] +pub async fn refund_retrieve_core( + state: SessionState, + merchant_context: domain::MerchantContext, + profile_id: Option, + request: refunds::RefundsRetrieveRequest, + refund: storage::Refund, +) -> errors::RouterResult { + let db = &*state.store; + + let key_manager_state = &(&state).into(); + core_utils::validate_profile_id_from_auth_layer(profile_id, &refund)?; + let payment_id = &refund.payment_id; + let payment_intent = db + .find_payment_intent_by_id( + key_manager_state, + payment_id, + merchant_context.get_merchant_key_store(), + merchant_context.get_merchant_account().storage_scheme, + ) + .await + .to_not_found_response(errors::ApiErrorResponse::PaymentNotFound)?; + + let active_attempt_id = payment_intent + .active_attempt_id + .clone() + .ok_or(errors::ApiErrorResponse::InternalServerError) + .attach_printable("Active attempt id not found")?; + + let payment_attempt = db + .find_payment_attempt_by_id( + key_manager_state, + merchant_context.get_merchant_key_store(), + &active_attempt_id, + merchant_context.get_merchant_account().storage_scheme, + ) + .await + .to_not_found_response(errors::ApiErrorResponse::InternalServerError)?; + + let unified_translated_message = if let (Some(unified_code), Some(unified_message)) = + (refund.unified_code.clone(), refund.unified_message.clone()) + { + helpers::get_unified_translation( + &state, + unified_code, + unified_message.clone(), + state.locale.to_string(), + ) + .await + .or(Some(unified_message)) + } else { + refund.unified_message + }; + + let refund = storage::Refund { + unified_message: unified_translated_message, + ..refund + }; + + let response = if should_call_refund(&refund, request.force_sync.unwrap_or(false)) { + Box::pin(sync_refund_with_gateway( + &state, + &merchant_context, + &payment_attempt, + &payment_intent, + &refund, + )) + .await + } else { + Ok(refund) + }?; + Ok(response) +} + +fn should_call_refund(refund: &diesel_models::refund::Refund, force_sync: bool) -> bool { + // This implies, we cannot perform a refund sync & `the connector_refund_id` + // doesn't exist + let predicate1 = refund.connector_refund_id.is_some(); + + // This allows refund sync at connector level if force_sync is enabled, or + // checks if the refund has failed + let predicate2 = force_sync + || !matches!( + refund.refund_status, + diesel_models::enums::RefundStatus::Failure + | diesel_models::enums::RefundStatus::Success + ); + + predicate1 && predicate2 +} + +#[allow(clippy::too_many_arguments)] +#[instrument(skip_all)] +pub async fn sync_refund_with_gateway( + state: &SessionState, + merchant_context: &domain::MerchantContext, + payment_attempt: &storage::PaymentAttempt, + payment_intent: &storage::PaymentIntent, + refund: &storage::Refund, +) -> errors::RouterResult { + let db = &*state.store; + + let connector_id = refund.connector.to_string(); + let connector: api::ConnectorData = api::ConnectorData::get_connector_by_name( + &state.conf.connectors, + &connector_id, + api::GetToken::Connector, + payment_attempt.merchant_connector_id.clone(), + ) + .change_context(errors::ApiErrorResponse::InternalServerError) + .attach_printable("Failed to get the connector")?; + + let mca_id = payment_attempt.get_attempt_merchant_connector_account_id()?; + + let mca = db + .find_merchant_connector_account_by_id( + &state.into(), + &mca_id, + merchant_context.get_merchant_key_store(), + ) + .await + .change_context(errors::ApiErrorResponse::InternalServerError) + .attach_printable("Failed to fetch merchant connector account")?; + + let connector_enum = mca.connector_name; + + let mut router_data = core_utils::construct_refund_router_data::( + state, + connector_enum, + merchant_context, + payment_intent, + payment_attempt, + refund, + &mca, + ) + .await?; + + let add_access_token_result = + access_token::add_access_token(state, &connector, merchant_context, &router_data, None) + .await?; + + logger::debug!(refund_retrieve_router_data=?router_data); + + access_token::update_router_data_with_access_token_result( + &add_access_token_result, + &mut router_data, + &payments::CallConnectorAction::Trigger, + ); + + let connector_response = + call_connector_service(state, &connector, add_access_token_result, router_data) + .await + .to_refund_failed_response()?; + + let connector_response = perform_integrity_check(connector_response); + + let refund_update = + build_refund_update_for_rsync(&connector, merchant_context, connector_response); + + let response = state + .store + .update_refund( + refund.to_owned(), + refund_update, + merchant_context.get_merchant_account().storage_scheme, + ) + .await + .to_not_found_response(errors::ApiErrorResponse::RefundNotFound) + .attach_printable_lazy(|| { + format!( + "Unable to update refund with refund_id: {}", + refund.id.get_string_repr() + ) + })?; + + // Implement outgoing webhook here + Ok(response) +} + +pub fn build_refund_update_for_rsync( + connector: &api::ConnectorData, + merchant_context: &domain::MerchantContext, + router_data_response: RouterData, +) -> storage::RefundUpdate { + let merchant_account = merchant_context.get_merchant_account(); + let storage_scheme = &merchant_context.get_merchant_account().storage_scheme; + + match router_data_response.response { + Err(error_message) => { + let refund_status = match error_message.status_code { + // marking failure for 2xx because this is genuine refund failure + 200..=299 => Some(enums::RefundStatus::Failure), + _ => None, + }; + let refund_error_message = error_message.reason.or(Some(error_message.message)); + let refund_error_code = Some(error_message.code); + + storage::RefundUpdate::build_error_update_for_refund_failure( + refund_status, + refund_error_message, + refund_error_code, + storage_scheme, + ) + } + Ok(response) => match router_data_response.integrity_check.clone() { + Err(err) => { + metrics::INTEGRITY_CHECK_FAILED.add( + 1, + router_env::metric_attributes!( + ("connector", connector.connector_name.to_string()), + ("merchant_id", merchant_account.get_id().clone()), + ), + ); + + let connector_refund_id = err + .connector_transaction_id + .map(common_utils_types::ConnectorTransactionId::from); + + storage::RefundUpdate::build_error_update_for_integrity_check_failure( + err.field_names, + connector_refund_id, + storage_scheme, + ) + } + Ok(()) => { + let connector_refund_id = + common_utils_types::ConnectorTransactionId::from(response.connector_refund_id); + + storage::RefundUpdate::build_refund_update( + connector_refund_id, + response.refund_status, + storage_scheme, + ) + } + }, + } +} + // ********************************************** VALIDATIONS ********************************************** #[instrument(skip_all)] diff --git a/crates/router/src/core/utils.rs b/crates/router/src/core/utils.rs index e123b792e7..d8b01bfd23 100644 --- a/crates/router/src/core/utils.rs +++ b/crates/router/src/core/utils.rs @@ -252,7 +252,7 @@ pub async fn construct_refund_router_data<'a, F>( let status = payment_attempt.status; let payment_amount = payment_attempt.get_total_amount(); - let currency = payment_intent.amount_details.currency; + let currency = payment_intent.get_currency(); let payment_method_type = payment_attempt.payment_method_type; diff --git a/crates/router/src/routes/app.rs b/crates/router/src/routes/app.rs index 87307441d7..f75fb9ad7f 100644 --- a/crates/router/src/routes/app.rs +++ b/crates/router/src/routes/app.rs @@ -1174,7 +1174,9 @@ impl Refunds { pub fn server(state: AppState) -> Scope { let mut route = web::scope("/v2/refunds").app_data(web::Data::new(state)); - route = route.service(web::resource("").route(web::post().to(refunds::refunds_create))); + route = route + .service(web::resource("").route(web::post().to(refunds::refunds_create))) + .service(web::resource("/{id}").route(web::get().to(refunds::refunds_retrieve))); route } diff --git a/crates/router/src/routes/refunds.rs b/crates/router/src/routes/refunds.rs index a7a4e5dddf..65ea8720f9 100644 --- a/crates/router/src/routes/refunds.rs +++ b/crates/router/src/routes/refunds.rs @@ -168,6 +168,56 @@ pub async fn refunds_retrieve( .await } +#[cfg(all(feature = "v2", feature = "refunds_v2"))] +#[instrument(skip_all, fields(flow))] +pub async fn refunds_retrieve( + state: web::Data, + req: HttpRequest, + path: web::Path, + query_params: web::Query, +) -> HttpResponse { + let refund_request = refunds::RefundsRetrieveRequest { + refund_id: path.into_inner(), + force_sync: query_params.force_sync, + }; + let flow = match query_params.force_sync { + Some(true) => Flow::RefundsRetrieveForceSync, + _ => Flow::RefundsRetrieve, + }; + + tracing::Span::current().record("flow", flow.to_string()); + + Box::pin(api::server_wrap( + flow, + state, + &req, + refund_request, + |state, auth: auth::AuthenticationData, refund_request, _| { + let merchant_context = domain::MerchantContext::NormalMerchant(Box::new( + domain::Context(auth.merchant_account, auth.key_store), + )); + refund_retrieve_core_with_refund_id( + state, + merchant_context, + auth.profile, + refund_request, + ) + }, + auth::auth_type( + &auth::V2ApiKeyAuth { + is_connected_allowed: false, + is_platform_allowed: false, + }, + &auth::JWTAuth { + permission: Permission::ProfileRefundRead, + }, + req.headers(), + ), + api_locking::LockAction::NotApplicable, + )) + .await +} + #[cfg(all(any(feature = "v1", feature = "v2"), not(feature = "refunds_v2")))] /// Refunds - Retrieve (POST) /// diff --git a/crates/router/src/types/api/refunds.rs b/crates/router/src/types/api/refunds.rs index ff1f192fd4..8beaf7010f 100644 --- a/crates/router/src/types/api/refunds.rs +++ b/crates/router/src/types/api/refunds.rs @@ -3,7 +3,8 @@ pub use api_models::refunds::RefundRequest; #[cfg(all(feature = "v2", feature = "refunds_v2"))] pub use api_models::refunds::RefundsCreateRequest; pub use api_models::refunds::{ - RefundResponse, RefundStatus, RefundType, RefundUpdateRequest, RefundsRetrieveRequest, + RefundResponse, RefundStatus, RefundType, RefundUpdateRequest, RefundsRetrieveBody, + RefundsRetrieveRequest, }; pub use hyperswitch_domain_models::router_flow_types::refunds::{Execute, RSync}; pub use hyperswitch_interfaces::api::refunds::{Refund, RefundExecute, RefundSync};