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

@ -59,6 +59,13 @@ impl ApiEventMetric for RefundsRetrieveRequest {
}
}
#[cfg(feature = "v2")]
impl ApiEventMetric for refunds::RefundsRetrieveRequest {
fn get_api_event_type(&self) -> Option<ApiEventsType> {
None
}
}
#[cfg(feature = "v1")]
impl ApiEventMetric for RefundUpdateRequest {
fn get_api_event_type(&self) -> Option<ApiEventsType> {

View File

@ -105,11 +105,19 @@ pub struct RefundsCreateRequest {
pub metadata: Option<pii::SecretSerdeValue>,
}
#[cfg(all(any(feature = "v1", feature = "v2"), not(feature = "refunds_v2")))]
#[derive(Default, Debug, Clone, Deserialize)]
pub struct RefundsRetrieveBody {
pub force_sync: Option<bool>,
}
#[cfg(all(feature = "v2", feature = "refunds_v2"))]
#[derive(Default, Debug, Clone, Deserialize)]
pub struct RefundsRetrieveBody {
pub force_sync: Option<bool>,
}
#[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<admin::MerchantConnectorDetailsWrap>,
}
#[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<bool>,
}
#[derive(Default, Debug, ToSchema, Clone, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct RefundUpdateRequest {

View File

@ -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<storage_enums::RefundStatus>,
refund_error_message: Option<String>,
refund_error_code: Option<String>,
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")))]

View File

@ -763,6 +763,10 @@ impl PaymentIntent {
})
.transpose()
}
pub fn get_currency(&self) -> storage_enums::Currency {
self.amount_details.currency
}
}
#[cfg(feature = "v1")]

View File

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

View File

@ -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() {}

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