feat(refunds_v2): Add refunds list flow in v2 apis (#7966)

Co-authored-by: hyperswitch-bot[bot] <148525504+hyperswitch-bot[bot]@users.noreply.github.com>
This commit is contained in:
Amey Wale
2025-05-12 19:27:05 +05:30
committed by GitHub
parent 28b62e2693
commit 839eb2e8fb
14 changed files with 716 additions and 17 deletions

View File

@ -4,6 +4,7 @@ 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::{
refunds::RefundListConstraints,
router_data::{ErrorResponse, RouterData},
router_data_v2::RefundFlowData,
};
@ -734,6 +735,56 @@ pub fn build_refund_update_for_rsync(
}
}
// ********************************************** Refund list **********************************************
/// If payment_id is provided, lists all the refunds associated with that particular payment_id
/// If payment_id is not provided, lists the refunds associated with that particular merchant - to the limit specified,if no limits given, it is 10 by default
#[instrument(skip_all)]
#[cfg(feature = "olap")]
pub async fn refund_list(
state: SessionState,
merchant_account: domain::MerchantAccount,
profile: domain::Profile,
req: refunds::RefundListRequest,
) -> errors::RouterResponse<refunds::RefundListResponse> {
let db = state.store;
let limit = refunds_validator::validate_refund_list(req.limit)?;
let offset = req.offset.unwrap_or_default();
let refund_list = db
.filter_refund_by_constraints(
merchant_account.get_id(),
RefundListConstraints::from((req.clone(), profile.clone())),
merchant_account.storage_scheme,
limit,
offset,
)
.await
.to_not_found_response(errors::ApiErrorResponse::RefundNotFound)?;
let data: Vec<refunds::RefundResponse> = refund_list
.into_iter()
.map(refunds::RefundResponse::foreign_try_from)
.collect::<Result<_, _>>()?;
let total_count = db
.get_total_count_of_refunds(
merchant_account.get_id(),
RefundListConstraints::from((req, profile)),
merchant_account.storage_scheme,
)
.await
.to_not_found_response(errors::ApiErrorResponse::InternalServerError)?;
Ok(services::ApplicationResponse::Json(
api_models::refunds::RefundListResponse {
count: data.len(),
total_count,
data,
},
))
}
// ********************************************** VALIDATIONS **********************************************
#[instrument(skip_all)]

View File

@ -2844,6 +2844,26 @@ impl RefundInterface for KafkaStore {
.await
}
#[cfg(all(feature = "v2", feature = "refunds_v2"))]
async fn filter_refund_by_constraints(
&self,
merchant_id: &id_type::MerchantId,
refund_details: refunds::RefundListConstraints,
storage_scheme: MerchantStorageScheme,
limit: i64,
offset: i64,
) -> CustomResult<Vec<storage::Refund>, errors::StorageError> {
self.diesel_store
.filter_refund_by_constraints(
merchant_id,
refund_details,
storage_scheme,
limit,
offset,
)
.await
}
#[cfg(all(
any(feature = "v1", feature = "v2"),
not(feature = "refunds_v2"),
@ -2892,6 +2912,18 @@ impl RefundInterface for KafkaStore {
.get_total_count_of_refunds(merchant_id, refund_details, storage_scheme)
.await
}
#[cfg(all(feature = "v2", feature = "refunds_v2"))]
async fn get_total_count_of_refunds(
&self,
merchant_id: &id_type::MerchantId,
refund_details: refunds::RefundListConstraints,
storage_scheme: MerchantStorageScheme,
) -> CustomResult<i64, errors::StorageError> {
self.diesel_store
.get_total_count_of_refunds(merchant_id, refund_details, storage_scheme)
.await
}
}
#[async_trait::async_trait]

View File

@ -91,6 +91,16 @@ pub trait RefundInterface {
offset: i64,
) -> CustomResult<Vec<diesel_models::refund::Refund>, errors::StorageError>;
#[cfg(all(feature = "v2", feature = "refunds_v2"))]
async fn filter_refund_by_constraints(
&self,
merchant_id: &common_utils::id_type::MerchantId,
refund_details: refunds::RefundListConstraints,
storage_scheme: enums::MerchantStorageScheme,
limit: i64,
offset: i64,
) -> CustomResult<Vec<diesel_models::refund::Refund>, errors::StorageError>;
#[cfg(all(
any(feature = "v1", feature = "v2"),
not(feature = "refunds_v2"),
@ -127,6 +137,14 @@ pub trait RefundInterface {
refund_details: &refunds::RefundListConstraints,
storage_scheme: enums::MerchantStorageScheme,
) -> CustomResult<i64, errors::StorageError>;
#[cfg(all(feature = "v2", feature = "refunds_v2"))]
async fn get_total_count_of_refunds(
&self,
merchant_id: &common_utils::id_type::MerchantId,
refund_details: refunds::RefundListConstraints,
storage_scheme: enums::MerchantStorageScheme,
) -> CustomResult<i64, errors::StorageError>;
}
#[cfg(not(feature = "kv_store"))]
@ -925,6 +943,28 @@ mod storage {
.map_err(|error| report!(errors::StorageError::from(error)))
}
#[cfg(all(feature = "v2", feature = "refunds_v2"))]
#[instrument(skip_all)]
async fn filter_refund_by_constraints(
&self,
merchant_id: &common_utils::id_type::MerchantId,
refund_details: refunds::RefundListConstraints,
_storage_scheme: enums::MerchantStorageScheme,
limit: i64,
offset: i64,
) -> CustomResult<Vec<diesel_models::refund::Refund>, errors::StorageError> {
let conn = connection::pg_connection_read(self).await?;
<diesel_models::refund::Refund as storage_types::RefundDbExt>::filter_by_constraints(
&conn,
merchant_id,
refund_details,
limit,
offset,
)
.await
.map_err(|error| report!(errors::StorageError::from(error)))
}
#[cfg(all(
any(feature = "v1", feature = "v2"),
not(feature = "refunds_v2"),
@ -983,6 +1023,24 @@ mod storage {
.await
.map_err(|error| report!(errors::StorageError::from(error)))
}
#[cfg(all(feature = "v2", feature = "refunds_v2"))]
#[instrument(skip_all)]
async fn get_total_count_of_refunds(
&self,
merchant_id: &common_utils::id_type::MerchantId,
refund_details: refunds::RefundListConstraints,
_storage_scheme: enums::MerchantStorageScheme,
) -> CustomResult<i64, errors::StorageError> {
let conn = connection::pg_connection_read(self).await?;
<diesel_models::refund::Refund as storage_types::RefundDbExt>::get_refunds_count(
&conn,
merchant_id,
refund_details,
)
.await
.map_err(|error| report!(errors::StorageError::from(error)))
}
}
}
@ -1369,6 +1427,115 @@ impl RefundInterface for MockDb {
Ok(filtered_refunds)
}
#[cfg(all(feature = "v2", feature = "refunds_v2", feature = "olap"))]
async fn filter_refund_by_constraints(
&self,
merchant_id: &common_utils::id_type::MerchantId,
refund_details: refunds::RefundListConstraints,
_storage_scheme: enums::MerchantStorageScheme,
limit: i64,
offset: i64,
) -> CustomResult<Vec<diesel_models::refund::Refund>, errors::StorageError> {
let mut unique_connectors = HashSet::new();
let mut unique_connector_ids = HashSet::new();
let mut unique_currencies = HashSet::new();
let mut unique_statuses = HashSet::new();
// Fill the hash sets with data from refund_details
if let Some(connectors) = &refund_details.connector {
connectors.iter().for_each(|connector| {
unique_connectors.insert(connector);
});
}
if let Some(connector_id_list) = &refund_details.connector_id_list {
connector_id_list.iter().for_each(|unique_connector_id| {
unique_connector_ids.insert(unique_connector_id);
});
}
if let Some(currencies) = &refund_details.currency {
currencies.iter().for_each(|currency| {
unique_currencies.insert(currency);
});
}
if let Some(refund_statuses) = &refund_details.refund_status {
refund_statuses.iter().for_each(|refund_status| {
unique_statuses.insert(refund_status);
});
}
let refunds = self.refunds.lock().await;
let filtered_refunds = refunds
.iter()
.filter(|refund| refund.merchant_id == *merchant_id)
.filter(|refund| {
refund_details
.payment_id
.clone()
.map_or(true, |id| id == refund.payment_id)
})
.filter(|refund| {
refund_details
.refund_id
.clone()
.map_or(true, |id| id == refund.id)
})
.filter(|refund| {
refund
.profile_id
.as_ref()
.is_some_and(|profile_id| profile_id == &refund_details.profile_id)
})
.filter(|refund| {
refund.created_at
>= refund_details.time_range.map_or(
common_utils::date_time::now() - time::Duration::days(60),
|range| range.start_time,
)
&& refund.created_at
<= refund_details
.time_range
.map_or(common_utils::date_time::now(), |range| {
range.end_time.unwrap_or_else(common_utils::date_time::now)
})
})
.filter(|refund| {
refund_details
.amount_filter
.as_ref()
.map_or(true, |amount| {
refund.refund_amount
>= MinorUnit::new(amount.start_amount.unwrap_or(i64::MIN))
&& refund.refund_amount
<= MinorUnit::new(amount.end_amount.unwrap_or(i64::MAX))
})
})
.filter(|refund| {
unique_connectors.is_empty() || unique_connectors.contains(&refund.connector)
})
.filter(|refund| {
unique_connector_ids.is_empty()
|| refund
.connector_id
.as_ref()
.is_some_and(|id| unique_connector_ids.contains(id))
})
.filter(|refund| {
unique_currencies.is_empty() || unique_currencies.contains(&refund.currency)
})
.filter(|refund| {
unique_statuses.is_empty() || unique_statuses.contains(&refund.refund_status)
})
.skip(usize::try_from(offset).unwrap_or_default())
.take(usize::try_from(limit).unwrap_or(MAX_LIMIT))
.cloned()
.collect::<Vec<_>>();
Ok(filtered_refunds)
}
#[cfg(all(
any(feature = "v1", feature = "v2"),
not(feature = "refunds_v2"),
@ -1586,4 +1753,111 @@ impl RefundInterface for MockDb {
Ok(filtered_refunds_count)
}
#[cfg(all(feature = "v2", feature = "refunds_v2", feature = "olap"))]
async fn get_total_count_of_refunds(
&self,
merchant_id: &common_utils::id_type::MerchantId,
refund_details: refunds::RefundListConstraints,
_storage_scheme: enums::MerchantStorageScheme,
) -> CustomResult<i64, errors::StorageError> {
let mut unique_connectors = HashSet::new();
let mut unique_connector_ids = HashSet::new();
let mut unique_currencies = HashSet::new();
let mut unique_statuses = HashSet::new();
// Fill the hash sets with data from refund_details
if let Some(connectors) = &refund_details.connector {
connectors.iter().for_each(|connector| {
unique_connectors.insert(connector);
});
}
if let Some(connector_id_list) = &refund_details.connector_id_list {
connector_id_list.iter().for_each(|unique_connector_id| {
unique_connector_ids.insert(unique_connector_id);
});
}
if let Some(currencies) = &refund_details.currency {
currencies.iter().for_each(|currency| {
unique_currencies.insert(currency);
});
}
if let Some(refund_statuses) = &refund_details.refund_status {
refund_statuses.iter().for_each(|refund_status| {
unique_statuses.insert(refund_status);
});
}
let refunds = self.refunds.lock().await;
let filtered_refunds = refunds
.iter()
.filter(|refund| refund.merchant_id == *merchant_id)
.filter(|refund| {
refund_details
.payment_id
.clone()
.map_or(true, |id| id == refund.payment_id)
})
.filter(|refund| {
refund_details
.refund_id
.clone()
.map_or(true, |id| id == refund.id)
})
.filter(|refund| {
refund
.profile_id
.as_ref()
.is_some_and(|profile_id| profile_id == &refund_details.profile_id)
})
.filter(|refund| {
refund.created_at
>= refund_details.time_range.map_or(
common_utils::date_time::now() - time::Duration::days(60),
|range| range.start_time,
)
&& refund.created_at
<= refund_details
.time_range
.map_or(common_utils::date_time::now(), |range| {
range.end_time.unwrap_or_else(common_utils::date_time::now)
})
})
.filter(|refund| {
refund_details
.amount_filter
.as_ref()
.map_or(true, |amount| {
refund.refund_amount
>= MinorUnit::new(amount.start_amount.unwrap_or(i64::MIN))
&& refund.refund_amount
<= MinorUnit::new(amount.end_amount.unwrap_or(i64::MAX))
})
})
.filter(|refund| {
unique_connectors.is_empty() || unique_connectors.contains(&refund.connector)
})
.filter(|refund| {
unique_connector_ids.is_empty()
|| refund
.connector_id
.as_ref()
.is_some_and(|id| unique_connector_ids.contains(id))
})
.filter(|refund| {
unique_currencies.is_empty() || unique_currencies.contains(&refund.currency)
})
.filter(|refund| {
unique_statuses.is_empty() || unique_statuses.contains(&refund.refund_status)
})
.cloned()
.collect::<Vec<_>>();
let filtered_refunds_count = filtered_refunds.len().try_into().unwrap_or_default();
Ok(filtered_refunds_count)
}
}

View File

@ -1169,14 +1169,26 @@ impl Refunds {
}
}
#[cfg(all(feature = "v2", feature = "refunds_v2", feature = "oltp"))]
#[cfg(all(
feature = "v2",
feature = "refunds_v2",
any(feature = "olap", feature = "oltp")
))]
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)))
.service(web::resource("/{id}").route(web::get().to(refunds::refunds_retrieve)));
#[cfg(feature = "olap")]
{
route =
route.service(web::resource("/list").route(web::get().to(refunds::refunds_list)));
}
#[cfg(feature = "oltp")]
{
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

@ -375,6 +375,37 @@ pub async fn refunds_list(
.await
}
#[cfg(all(feature = "v2", feature = "refunds_v2", feature = "olap"))]
#[instrument(skip_all, fields(flow = ?Flow::RefundsList))]
pub async fn refunds_list(
state: web::Data<AppState>,
req: HttpRequest,
payload: web::Json<api_models::refunds::RefundListRequest>,
) -> HttpResponse {
let flow = Flow::RefundsList;
Box::pin(api::server_wrap(
flow,
state,
&req,
payload.into_inner(),
|state, auth: auth::AuthenticationData, req, _| {
refund_list(state, auth.merchant_account, auth.profile, req)
},
auth::auth_type(
&auth::V2ApiKeyAuth {
is_connected_allowed: false,
is_platform_allowed: false,
},
&auth::JWTAuth {
permission: Permission::MerchantRefundRead,
},
req.headers(),
),
api_locking::LockAction::NotApplicable,
))
.await
}
#[cfg(all(
any(feature = "v1", feature = "v2"),
not(feature = "refunds_v2"),

View File

@ -3,8 +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, RefundsRetrieveBody,
RefundsRetrieveRequest,
RefundListRequest, RefundListResponse, 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};

View File

@ -5,11 +5,14 @@ use diesel::{associations::HasTable, BoolExpressionMethods, ExpressionMethods, Q
pub use diesel_models::refund::{
Refund, RefundCoreWorkflow, RefundNew, RefundUpdate, RefundUpdateInternal,
};
#[cfg(all(any(feature = "v1", feature = "v2"), not(feature = "refunds_v2")))]
use diesel_models::schema::refund::dsl;
#[cfg(all(feature = "v2", feature = "refunds_v2"))]
use diesel_models::schema_v2::refund::dsl;
use diesel_models::{
enums::{Currency, RefundStatus},
errors,
query::generics::db_metrics,
schema::refund::dsl,
};
use error_stack::ResultExt;
use hyperswitch_domain_models::refunds;
@ -27,6 +30,15 @@ pub trait RefundDbExt: Sized {
offset: i64,
) -> CustomResult<Vec<Self>, errors::DatabaseError>;
#[cfg(all(feature = "v2", feature = "refunds_v2"))]
async fn filter_by_constraints(
conn: &PgPooledConn,
merchant_id: &common_utils::id_type::MerchantId,
refund_list_details: refunds::RefundListConstraints,
limit: i64,
offset: i64,
) -> CustomResult<Vec<Self>, errors::DatabaseError>;
#[cfg(all(any(feature = "v1", feature = "v2"), not(feature = "refunds_v2")))]
async fn filter_by_meta_constraints(
conn: &PgPooledConn,
@ -48,6 +60,13 @@ pub trait RefundDbExt: Sized {
profile_id_list: Option<Vec<common_utils::id_type::ProfileId>>,
time_range: &common_utils::types::TimeRange,
) -> CustomResult<Vec<(RefundStatus, i64)>, errors::DatabaseError>;
#[cfg(all(feature = "v2", feature = "refunds_v2"))]
async fn get_refunds_count(
conn: &PgPooledConn,
merchant_id: &common_utils::id_type::MerchantId,
refund_list_details: refunds::RefundListConstraints,
) -> CustomResult<i64, errors::DatabaseError>;
}
#[async_trait::async_trait]
@ -164,6 +183,82 @@ impl RefundDbExt for Refund {
.attach_printable_lazy(|| "Error filtering records by predicate")
}
#[cfg(all(feature = "v2", feature = "refunds_v2"))]
async fn filter_by_constraints(
conn: &PgPooledConn,
merchant_id: &common_utils::id_type::MerchantId,
refund_list_details: refunds::RefundListConstraints,
limit: i64,
offset: i64,
) -> CustomResult<Vec<Self>, errors::DatabaseError> {
let mut filter = <Self as HasTable>::table()
.filter(dsl::merchant_id.eq(merchant_id.to_owned()))
.order(dsl::modified_at.desc())
.into_boxed();
if let Some(payment_id) = &refund_list_details.payment_id {
filter = filter.filter(dsl::payment_id.eq(payment_id.to_owned()));
}
if let Some(refund_id) = &refund_list_details.refund_id {
filter = filter.filter(dsl::id.eq(refund_id.to_owned()));
}
if let Some(time_range) = &refund_list_details.time_range {
filter = filter.filter(dsl::created_at.ge(time_range.start_time));
if let Some(end_time) = time_range.end_time {
filter = filter.filter(dsl::created_at.le(end_time));
}
}
filter = match refund_list_details.amount_filter {
Some(AmountFilter {
start_amount: Some(start),
end_amount: Some(end),
}) => filter.filter(dsl::refund_amount.between(start, end)),
Some(AmountFilter {
start_amount: Some(start),
end_amount: None,
}) => filter.filter(dsl::refund_amount.ge(start)),
Some(AmountFilter {
start_amount: None,
end_amount: Some(end),
}) => filter.filter(dsl::refund_amount.le(end)),
_ => filter,
};
if let Some(connector) = refund_list_details.connector {
filter = filter.filter(dsl::connector.eq_any(connector));
}
if let Some(connector_id_list) = refund_list_details.connector_id_list {
filter = filter.filter(dsl::connector_id.eq_any(connector_id_list));
}
if let Some(filter_currency) = refund_list_details.currency {
filter = filter.filter(dsl::currency.eq_any(filter_currency));
}
if let Some(filter_refund_status) = refund_list_details.refund_status {
filter = filter.filter(dsl::refund_status.eq_any(filter_refund_status));
}
filter = filter.limit(limit).offset(offset);
logger::debug!(query = %diesel::debug_query::<diesel::pg::Pg, _>(&filter).to_string());
db_metrics::track_database_call::<<Self as HasTable>::Table, _, _>(
filter.get_results_async(conn),
db_metrics::DatabaseOperation::Filter,
)
.await
.change_context(errors::DatabaseError::NotFound)
.attach_printable_lazy(|| "Error filtering records by predicate")
// todo!()
}
#[cfg(all(any(feature = "v1", feature = "v2"), not(feature = "refunds_v2")))]
async fn filter_by_meta_constraints(
conn: &PgPooledConn,
@ -309,6 +404,74 @@ impl RefundDbExt for Refund {
.attach_printable_lazy(|| "Error filtering count of refunds")
}
#[cfg(all(feature = "v2", feature = "refunds_v2"))]
async fn get_refunds_count(
conn: &PgPooledConn,
merchant_id: &common_utils::id_type::MerchantId,
refund_list_details: refunds::RefundListConstraints,
) -> CustomResult<i64, errors::DatabaseError> {
let mut filter = <Self as HasTable>::table()
.count()
.filter(dsl::merchant_id.eq(merchant_id.to_owned()))
.into_boxed();
if let Some(payment_id) = &refund_list_details.payment_id {
filter = filter.filter(dsl::payment_id.eq(payment_id.to_owned()));
}
if let Some(refund_id) = &refund_list_details.refund_id {
filter = filter.filter(dsl::id.eq(refund_id.to_owned()));
}
if let Some(time_range) = refund_list_details.time_range {
filter = filter.filter(dsl::created_at.ge(time_range.start_time));
if let Some(end_time) = time_range.end_time {
filter = filter.filter(dsl::created_at.le(end_time));
}
}
filter = match refund_list_details.amount_filter {
Some(AmountFilter {
start_amount: Some(start),
end_amount: Some(end),
}) => filter.filter(dsl::refund_amount.between(start, end)),
Some(AmountFilter {
start_amount: Some(start),
end_amount: None,
}) => filter.filter(dsl::refund_amount.ge(start)),
Some(AmountFilter {
start_amount: None,
end_amount: Some(end),
}) => filter.filter(dsl::refund_amount.le(end)),
_ => filter,
};
if let Some(connector) = refund_list_details.connector {
filter = filter.filter(dsl::connector.eq_any(connector));
}
if let Some(connector_id_list) = refund_list_details.connector_id_list {
filter = filter.filter(dsl::connector_id.eq_any(connector_id_list));
}
if let Some(filter_currency) = refund_list_details.currency {
filter = filter.filter(dsl::currency.eq_any(filter_currency));
}
if let Some(filter_refund_status) = refund_list_details.refund_status {
filter = filter.filter(dsl::refund_status.eq_any(filter_refund_status));
}
logger::debug!(query = %diesel::debug_query::<diesel::pg::Pg, _>(&filter).to_string());
filter
.get_result_async::<i64>(conn)
.await
.change_context(errors::DatabaseError::NotFound)
.attach_printable_lazy(|| "Error filtering count of refunds")
}
#[cfg(all(any(feature = "v1", feature = "v2"), not(feature = "refunds_v2")))]
async fn get_refund_status_with_count(
conn: &PgPooledConn,