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>
This commit is contained in:
Amey Wale
2025-05-07 12:04:39 +05:30
committed by GitHub
parent f11de00f6e
commit a289f19cd0
12 changed files with 470 additions and 14 deletions

View File

@ -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<F>(
state: &SessionState,
connector: &api::ConnectorData,
add_access_token_result: types::AddAccessTokenResult,
router_data: RouterData<api::Execute, types::RefundsData, types::RefundsResponseData>,
router_data: RouterData<F, types::RefundsData, types::RefundsResponseData>,
) -> Result<
RouterData<api::Execute, types::RefundsData, types::RefundsResponseData>,
RouterData<F, types::RefundsData, types::RefundsResponseData>,
error_stack::Report<errors::ConnectorError>,
> {
>
where
F: Debug + Clone + 'static,
dyn ConnectorTrait + Sync:
ConnectorIntegration<F, types::RefundsData, types::RefundsResponseData>,
dyn ConnectorV2 + Sync:
ConnectorIntegrationV2<F, RefundFlowData, types::RefundsData, types::RefundsResponseData>,
{
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<api::Execute, types::RefundsData, types::RefundsResponseData>,
) -> RouterData<api::Execute, types::RefundsData, types::RefundsResponseData> {
pub fn perform_integrity_check<F>(
mut router_data: RouterData<F, types::RefundsData, types::RefundsResponseData>,
) -> RouterData<F, types::RefundsData, types::RefundsResponseData>
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<refunds::RefundResponse> {
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<id_type::ProfileId>,
request: refunds::RefundsRetrieveRequest,
refund: storage::Refund,
) -> errors::RouterResult<storage::Refund> {
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<storage::Refund> {
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::<api::RSync>(
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<api::RSync, types::RefundsData, types::RefundsResponseData>,
) -> 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)]

View File

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

View File

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

View File

@ -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<AppState>,
req: HttpRequest,
path: web::Path<common_utils::id_type::GlobalRefundId>,
query_params: web::Query<api_models::refunds::RefundsRetrieveBody>,
) -> 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)
///

View File

@ -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};