mirror of
https://github.com/juspay/hyperswitch.git
synced 2025-11-01 19:42:27 +08:00
feat(customers): add customer list endpoint (#2564)
Co-authored-by: Bernard Eugine <114725419+bernard-eugine@users.noreply.github.com>
This commit is contained in:
@ -73,6 +73,21 @@ impl Customer {
|
||||
.await
|
||||
}
|
||||
|
||||
#[instrument(skip(conn))]
|
||||
pub async fn list_by_merchant_id(
|
||||
conn: &PgPooledConn,
|
||||
merchant_id: &str,
|
||||
) -> StorageResult<Vec<Self>> {
|
||||
generics::generic_filter::<<Self as HasTable>::Table, _, _, _>(
|
||||
conn,
|
||||
dsl::merchant_id.eq(merchant_id.to_owned()),
|
||||
None,
|
||||
None,
|
||||
Some(dsl::created_at),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[instrument(skip(conn))]
|
||||
pub async fn find_optional_by_customer_id_merchant_id(
|
||||
conn: &PgPooledConn,
|
||||
|
||||
@ -69,6 +69,9 @@ pub enum StripeErrorCode {
|
||||
#[error(error_type = StripeErrorType::InvalidRequestError, code = "customer_redacted", message = "Customer has redacted")]
|
||||
CustomerRedacted,
|
||||
|
||||
#[error(error_type = StripeErrorType::InvalidRequestError, code = "customer_already_exists", message = "Customer with the given customer_id already exists")]
|
||||
DuplicateCustomer,
|
||||
|
||||
#[error(error_type = StripeErrorType::InvalidRequestError, code = "resource_missing", message = "No such refund")]
|
||||
RefundNotFound,
|
||||
|
||||
@ -652,6 +655,7 @@ impl actix_web::ResponseError for StripeErrorCode {
|
||||
| Self::FileNotAvailable
|
||||
| Self::FileProviderNotSupported
|
||||
| Self::CurrencyNotSupported { .. }
|
||||
| Self::DuplicateCustomer
|
||||
| Self::PaymentMethodUnactivated => StatusCode::BAD_REQUEST,
|
||||
Self::RefundFailed
|
||||
| Self::PayoutFailed
|
||||
@ -730,6 +734,7 @@ impl ErrorSwitch<StripeErrorCode> for CustomersErrorResponse {
|
||||
Self::InternalServerError => SC::InternalServerError,
|
||||
Self::MandateActive => SC::MandateActive,
|
||||
Self::CustomerNotFound => SC::CustomerNotFound,
|
||||
Self::CustomerAlreadyExists => SC::DuplicateCustomer,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,13 +2,13 @@ use common_utils::{
|
||||
crypto::{Encryptable, GcmAes256},
|
||||
errors::ReportSwitchExt,
|
||||
};
|
||||
use error_stack::ResultExt;
|
||||
use error_stack::{IntoReport, ResultExt};
|
||||
use masking::ExposeInterface;
|
||||
use router_env::{instrument, tracing};
|
||||
|
||||
use crate::{
|
||||
core::{
|
||||
errors::{self},
|
||||
errors::{self, StorageErrorExt},
|
||||
payment_methods::cards,
|
||||
},
|
||||
pii::PeekInterface,
|
||||
@ -39,6 +39,25 @@ pub async fn create_customer(
|
||||
let merchant_id = &merchant_account.merchant_id;
|
||||
customer_data.merchant_id = merchant_id.to_owned();
|
||||
|
||||
// We first need to validate whether the customer with the given customer id already exists
|
||||
// this may seem like a redundant db call, as the insert_customer will anyway return this error
|
||||
//
|
||||
// Consider a scenerio where the address is inserted and then when inserting the customer,
|
||||
// it errors out, now the address that was inserted is not deleted
|
||||
match db
|
||||
.find_customer_by_customer_id_merchant_id(customer_id, merchant_id, &key_store)
|
||||
.await
|
||||
{
|
||||
Err(err) => {
|
||||
if !err.current_context().is_db_not_found() {
|
||||
Err(err).switch()
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
Ok(_) => Err(errors::CustomersErrorResponse::CustomerAlreadyExists).into_report(),
|
||||
}?;
|
||||
|
||||
let key = key_store.key.get_inner().peek();
|
||||
let address = if let Some(addr) = &customer_data.address {
|
||||
let customer_address: api_models::payments::AddressDetails = addr.clone();
|
||||
@ -89,23 +108,10 @@ pub async fn create_customer(
|
||||
.switch()
|
||||
.attach_printable("Failed while encrypting Customer")?;
|
||||
|
||||
let customer = match db.insert_customer(new_customer, &key_store).await {
|
||||
Ok(customer) => customer,
|
||||
Err(error) => {
|
||||
if error.current_context().is_db_unique_violation() {
|
||||
db.find_customer_by_customer_id_merchant_id(customer_id, merchant_id, &key_store)
|
||||
let customer = db
|
||||
.insert_customer(new_customer, &key_store)
|
||||
.await
|
||||
.switch()
|
||||
.attach_printable(format!(
|
||||
"Failed while fetching Customer, customer_id: {customer_id}",
|
||||
))?
|
||||
} else {
|
||||
Err(error
|
||||
.change_context(errors::CustomersErrorResponse::InternalServerError)
|
||||
.attach_printable("Failed while inserting new customer"))?
|
||||
}
|
||||
}
|
||||
};
|
||||
.to_duplicate_response(errors::CustomersErrorResponse::CustomerAlreadyExists)?;
|
||||
|
||||
let address_details = address.map(api_models::payments::AddressDetails::from);
|
||||
|
||||
@ -143,6 +149,27 @@ pub async fn retrieve_customer(
|
||||
))
|
||||
}
|
||||
|
||||
#[instrument(skip(state))]
|
||||
pub async fn list_customers(
|
||||
state: AppState,
|
||||
merchant_id: String,
|
||||
key_store: domain::MerchantKeyStore,
|
||||
) -> errors::CustomerResponse<Vec<customers::CustomerResponse>> {
|
||||
let db = state.store.as_ref();
|
||||
|
||||
let domain_customers = db
|
||||
.list_customers_by_merchant_id(&merchant_id, &key_store)
|
||||
.await
|
||||
.switch()?;
|
||||
|
||||
let customers = domain_customers
|
||||
.into_iter()
|
||||
.map(|domain_customer| customers::CustomerResponse::from((domain_customer, None)))
|
||||
.collect();
|
||||
|
||||
Ok(services::ApplicationResponse::Json(customers))
|
||||
}
|
||||
|
||||
#[instrument(skip_all)]
|
||||
pub async fn delete_customer(
|
||||
state: AppState,
|
||||
|
||||
@ -13,6 +13,9 @@ pub enum CustomersErrorResponse {
|
||||
|
||||
#[error("Customer does not exist in our records")]
|
||||
CustomerNotFound,
|
||||
|
||||
#[error("Customer with the given customer id already exists")]
|
||||
CustomerAlreadyExists,
|
||||
}
|
||||
|
||||
impl actix_web::ResponseError for CustomersErrorResponse {
|
||||
|
||||
@ -314,6 +314,12 @@ impl ErrorSwitch<api_models::errors::types::ApiErrorResponse> for CustomersError
|
||||
"Customer does not exist in our records",
|
||||
None,
|
||||
)),
|
||||
Self::CustomerAlreadyExists => AER::BadRequest(ApiError::new(
|
||||
"IR",
|
||||
12,
|
||||
"Customer with the given `customer_id` already exists",
|
||||
None,
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
use common_utils::ext_traits::AsyncExt;
|
||||
use error_stack::{IntoReport, ResultExt};
|
||||
use futures::future::try_join_all;
|
||||
use masking::PeekInterface;
|
||||
use router_env::{instrument, tracing};
|
||||
|
||||
@ -52,6 +53,12 @@ where
|
||||
key_store: &domain::MerchantKeyStore,
|
||||
) -> CustomResult<domain::Customer, errors::StorageError>;
|
||||
|
||||
async fn list_customers_by_merchant_id(
|
||||
&self,
|
||||
merchant_id: &str,
|
||||
key_store: &domain::MerchantKeyStore,
|
||||
) -> CustomResult<Vec<domain::Customer>, errors::StorageError>;
|
||||
|
||||
async fn insert_customer(
|
||||
&self,
|
||||
customer_data: domain::Customer,
|
||||
@ -148,6 +155,31 @@ impl CustomerInterface for Store {
|
||||
}
|
||||
}
|
||||
|
||||
async fn list_customers_by_merchant_id(
|
||||
&self,
|
||||
merchant_id: &str,
|
||||
key_store: &domain::MerchantKeyStore,
|
||||
) -> CustomResult<Vec<domain::Customer>, errors::StorageError> {
|
||||
let conn = connection::pg_connection_read(self).await?;
|
||||
|
||||
let encrypted_customers = storage::Customer::list_by_merchant_id(&conn, merchant_id)
|
||||
.await
|
||||
.map_err(Into::into)
|
||||
.into_report()?;
|
||||
|
||||
let customers = try_join_all(encrypted_customers.into_iter().map(
|
||||
|encrypted_customer| async {
|
||||
encrypted_customer
|
||||
.convert(key_store.key.get_inner())
|
||||
.await
|
||||
.change_context(errors::StorageError::DecryptionError)
|
||||
},
|
||||
))
|
||||
.await?;
|
||||
|
||||
Ok(customers)
|
||||
}
|
||||
|
||||
async fn insert_customer(
|
||||
&self,
|
||||
customer_data: domain::Customer,
|
||||
@ -209,6 +241,30 @@ impl CustomerInterface for MockDb {
|
||||
.transpose()
|
||||
}
|
||||
|
||||
async fn list_customers_by_merchant_id(
|
||||
&self,
|
||||
merchant_id: &str,
|
||||
key_store: &domain::MerchantKeyStore,
|
||||
) -> CustomResult<Vec<domain::Customer>, errors::StorageError> {
|
||||
let customers = self.customers.lock().await;
|
||||
|
||||
let customers = try_join_all(
|
||||
customers
|
||||
.iter()
|
||||
.filter(|customer| customer.merchant_id == merchant_id)
|
||||
.map(|customer| async {
|
||||
customer
|
||||
.to_owned()
|
||||
.convert(key_store.key.get_inner())
|
||||
.await
|
||||
.change_context(errors::StorageError::DecryptionError)
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(customers)
|
||||
}
|
||||
|
||||
#[instrument(skip_all)]
|
||||
async fn update_customer_by_customer_id_merchant_id(
|
||||
&self,
|
||||
|
||||
@ -101,6 +101,7 @@ Never share your secret api keys. Keep them guarded and secure.
|
||||
crate::routes::customers::customers_retrieve,
|
||||
crate::routes::customers::customers_update,
|
||||
crate::routes::customers::customers_delete,
|
||||
crate::routes::customers::customers_list,
|
||||
// crate::routes::api_keys::api_key_create,
|
||||
// crate::routes::api_keys::api_key_retrieve,
|
||||
// crate::routes::api_keys::api_key_update,
|
||||
|
||||
@ -275,10 +275,12 @@ impl Customers {
|
||||
|
||||
#[cfg(feature = "olap")]
|
||||
{
|
||||
route = route.service(
|
||||
route = route
|
||||
.service(
|
||||
web::resource("/{customer_id}/mandates")
|
||||
.route(web::get().to(get_customer_mandates)),
|
||||
);
|
||||
)
|
||||
.service(web::resource("/list").route(web::get().to(customers_list)))
|
||||
}
|
||||
|
||||
#[cfg(feature = "oltp")]
|
||||
@ -300,6 +302,7 @@ impl Customers {
|
||||
.route(web::delete().to(customers_delete)),
|
||||
);
|
||||
}
|
||||
|
||||
route
|
||||
}
|
||||
}
|
||||
|
||||
@ -85,6 +85,37 @@ pub async fn customers_retrieve(
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// List customers for a merchant
|
||||
///
|
||||
/// To filter and list the customers for a particular merchant id
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/customers/list",
|
||||
responses(
|
||||
(status = 200, description = "Customers retrieved", body = Vec<CustomerResponse>),
|
||||
(status = 400, description = "Invalid Data"),
|
||||
),
|
||||
tag = "Customers List",
|
||||
operation_id = "List all Customers for a Merchant",
|
||||
security(("api_key" = []))
|
||||
)]
|
||||
#[instrument(skip_all, fields(flow = ?Flow::CustomersList))]
|
||||
pub async fn customers_list(state: web::Data<AppState>, req: HttpRequest) -> HttpResponse {
|
||||
let flow = Flow::CustomersList;
|
||||
|
||||
api::server_wrap(
|
||||
flow,
|
||||
state,
|
||||
&req,
|
||||
(),
|
||||
|state, auth, _| list_customers(state, auth.merchant_account.merchant_id, auth.key_store),
|
||||
&auth::ApiKeyAuth,
|
||||
api_locking::LockAction::NotApplicable,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Update Customer
|
||||
///
|
||||
/// Updates the customer's details in a customer object.
|
||||
|
||||
@ -47,7 +47,8 @@ impl From<Flow> for ApiIdentifier {
|
||||
| Flow::CustomersRetrieve
|
||||
| Flow::CustomersUpdate
|
||||
| Flow::CustomersDelete
|
||||
| Flow::CustomersGetMandates => Self::Customers,
|
||||
| Flow::CustomersGetMandates
|
||||
| Flow::CustomersList => Self::Customers,
|
||||
|
||||
Flow::EphemeralKeyCreate | Flow::EphemeralKeyDelete => Self::Ephemeral,
|
||||
|
||||
|
||||
@ -104,6 +104,8 @@ pub enum Flow {
|
||||
PaymentMethodsList,
|
||||
/// Customer payment methods list flow.
|
||||
CustomerPaymentMethodsList,
|
||||
/// List Customers for a merchant
|
||||
CustomersList,
|
||||
/// Payment methods retrieve flow.
|
||||
PaymentMethodsRetrieve,
|
||||
/// Payment methods update flow.
|
||||
|
||||
@ -169,6 +169,39 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/customers/list": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Customers List"
|
||||
],
|
||||
"summary": "List customers for a merchant",
|
||||
"description": "List customers for a merchant\n\nTo filter and list the customers for a particular merchant id",
|
||||
"operationId": "List all Customers for a Merchant",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Customers retrieved",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/CustomerResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Invalid Data"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"api_key": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/customers/payment_methods": {
|
||||
"get": {
|
||||
"tags": [
|
||||
|
||||
Reference in New Issue
Block a user