mirror of
https://github.com/juspay/hyperswitch.git
synced 2025-11-02 04:04:43 +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
|
.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))]
|
#[instrument(skip(conn))]
|
||||||
pub async fn find_optional_by_customer_id_merchant_id(
|
pub async fn find_optional_by_customer_id_merchant_id(
|
||||||
conn: &PgPooledConn,
|
conn: &PgPooledConn,
|
||||||
|
|||||||
@ -69,6 +69,9 @@ pub enum StripeErrorCode {
|
|||||||
#[error(error_type = StripeErrorType::InvalidRequestError, code = "customer_redacted", message = "Customer has redacted")]
|
#[error(error_type = StripeErrorType::InvalidRequestError, code = "customer_redacted", message = "Customer has redacted")]
|
||||||
CustomerRedacted,
|
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")]
|
#[error(error_type = StripeErrorType::InvalidRequestError, code = "resource_missing", message = "No such refund")]
|
||||||
RefundNotFound,
|
RefundNotFound,
|
||||||
|
|
||||||
@ -652,6 +655,7 @@ impl actix_web::ResponseError for StripeErrorCode {
|
|||||||
| Self::FileNotAvailable
|
| Self::FileNotAvailable
|
||||||
| Self::FileProviderNotSupported
|
| Self::FileProviderNotSupported
|
||||||
| Self::CurrencyNotSupported { .. }
|
| Self::CurrencyNotSupported { .. }
|
||||||
|
| Self::DuplicateCustomer
|
||||||
| Self::PaymentMethodUnactivated => StatusCode::BAD_REQUEST,
|
| Self::PaymentMethodUnactivated => StatusCode::BAD_REQUEST,
|
||||||
Self::RefundFailed
|
Self::RefundFailed
|
||||||
| Self::PayoutFailed
|
| Self::PayoutFailed
|
||||||
@ -730,6 +734,7 @@ impl ErrorSwitch<StripeErrorCode> for CustomersErrorResponse {
|
|||||||
Self::InternalServerError => SC::InternalServerError,
|
Self::InternalServerError => SC::InternalServerError,
|
||||||
Self::MandateActive => SC::MandateActive,
|
Self::MandateActive => SC::MandateActive,
|
||||||
Self::CustomerNotFound => SC::CustomerNotFound,
|
Self::CustomerNotFound => SC::CustomerNotFound,
|
||||||
|
Self::CustomerAlreadyExists => SC::DuplicateCustomer,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,13 +2,13 @@ use common_utils::{
|
|||||||
crypto::{Encryptable, GcmAes256},
|
crypto::{Encryptable, GcmAes256},
|
||||||
errors::ReportSwitchExt,
|
errors::ReportSwitchExt,
|
||||||
};
|
};
|
||||||
use error_stack::ResultExt;
|
use error_stack::{IntoReport, ResultExt};
|
||||||
use masking::ExposeInterface;
|
use masking::ExposeInterface;
|
||||||
use router_env::{instrument, tracing};
|
use router_env::{instrument, tracing};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
core::{
|
core::{
|
||||||
errors::{self},
|
errors::{self, StorageErrorExt},
|
||||||
payment_methods::cards,
|
payment_methods::cards,
|
||||||
},
|
},
|
||||||
pii::PeekInterface,
|
pii::PeekInterface,
|
||||||
@ -39,6 +39,25 @@ pub async fn create_customer(
|
|||||||
let merchant_id = &merchant_account.merchant_id;
|
let merchant_id = &merchant_account.merchant_id;
|
||||||
customer_data.merchant_id = merchant_id.to_owned();
|
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 key = key_store.key.get_inner().peek();
|
||||||
let address = if let Some(addr) = &customer_data.address {
|
let address = if let Some(addr) = &customer_data.address {
|
||||||
let customer_address: api_models::payments::AddressDetails = addr.clone();
|
let customer_address: api_models::payments::AddressDetails = addr.clone();
|
||||||
@ -89,23 +108,10 @@ pub async fn create_customer(
|
|||||||
.switch()
|
.switch()
|
||||||
.attach_printable("Failed while encrypting Customer")?;
|
.attach_printable("Failed while encrypting Customer")?;
|
||||||
|
|
||||||
let customer = match db.insert_customer(new_customer, &key_store).await {
|
let customer = db
|
||||||
Ok(customer) => customer,
|
.insert_customer(new_customer, &key_store)
|
||||||
Err(error) => {
|
|
||||||
if error.current_context().is_db_unique_violation() {
|
|
||||||
db.find_customer_by_customer_id_merchant_id(customer_id, merchant_id, &key_store)
|
|
||||||
.await
|
.await
|
||||||
.switch()
|
.to_duplicate_response(errors::CustomersErrorResponse::CustomerAlreadyExists)?;
|
||||||
.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"))?
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let address_details = address.map(api_models::payments::AddressDetails::from);
|
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)]
|
#[instrument(skip_all)]
|
||||||
pub async fn delete_customer(
|
pub async fn delete_customer(
|
||||||
state: AppState,
|
state: AppState,
|
||||||
|
|||||||
@ -13,6 +13,9 @@ pub enum CustomersErrorResponse {
|
|||||||
|
|
||||||
#[error("Customer does not exist in our records")]
|
#[error("Customer does not exist in our records")]
|
||||||
CustomerNotFound,
|
CustomerNotFound,
|
||||||
|
|
||||||
|
#[error("Customer with the given customer id already exists")]
|
||||||
|
CustomerAlreadyExists,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl actix_web::ResponseError for CustomersErrorResponse {
|
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",
|
"Customer does not exist in our records",
|
||||||
None,
|
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 common_utils::ext_traits::AsyncExt;
|
||||||
use error_stack::{IntoReport, ResultExt};
|
use error_stack::{IntoReport, ResultExt};
|
||||||
|
use futures::future::try_join_all;
|
||||||
use masking::PeekInterface;
|
use masking::PeekInterface;
|
||||||
use router_env::{instrument, tracing};
|
use router_env::{instrument, tracing};
|
||||||
|
|
||||||
@ -52,6 +53,12 @@ where
|
|||||||
key_store: &domain::MerchantKeyStore,
|
key_store: &domain::MerchantKeyStore,
|
||||||
) -> CustomResult<domain::Customer, errors::StorageError>;
|
) -> 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(
|
async fn insert_customer(
|
||||||
&self,
|
&self,
|
||||||
customer_data: domain::Customer,
|
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(
|
async fn insert_customer(
|
||||||
&self,
|
&self,
|
||||||
customer_data: domain::Customer,
|
customer_data: domain::Customer,
|
||||||
@ -209,6 +241,30 @@ impl CustomerInterface for MockDb {
|
|||||||
.transpose()
|
.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)]
|
#[instrument(skip_all)]
|
||||||
async fn update_customer_by_customer_id_merchant_id(
|
async fn update_customer_by_customer_id_merchant_id(
|
||||||
&self,
|
&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_retrieve,
|
||||||
crate::routes::customers::customers_update,
|
crate::routes::customers::customers_update,
|
||||||
crate::routes::customers::customers_delete,
|
crate::routes::customers::customers_delete,
|
||||||
|
crate::routes::customers::customers_list,
|
||||||
// crate::routes::api_keys::api_key_create,
|
// crate::routes::api_keys::api_key_create,
|
||||||
// crate::routes::api_keys::api_key_retrieve,
|
// crate::routes::api_keys::api_key_retrieve,
|
||||||
// crate::routes::api_keys::api_key_update,
|
// crate::routes::api_keys::api_key_update,
|
||||||
|
|||||||
@ -275,10 +275,12 @@ impl Customers {
|
|||||||
|
|
||||||
#[cfg(feature = "olap")]
|
#[cfg(feature = "olap")]
|
||||||
{
|
{
|
||||||
route = route.service(
|
route = route
|
||||||
|
.service(
|
||||||
web::resource("/{customer_id}/mandates")
|
web::resource("/{customer_id}/mandates")
|
||||||
.route(web::get().to(get_customer_mandates)),
|
.route(web::get().to(get_customer_mandates)),
|
||||||
);
|
)
|
||||||
|
.service(web::resource("/list").route(web::get().to(customers_list)))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "oltp")]
|
#[cfg(feature = "oltp")]
|
||||||
@ -300,6 +302,7 @@ impl Customers {
|
|||||||
.route(web::delete().to(customers_delete)),
|
.route(web::delete().to(customers_delete)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
route
|
route
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -85,6 +85,37 @@ pub async fn customers_retrieve(
|
|||||||
)
|
)
|
||||||
.await
|
.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
|
/// Update Customer
|
||||||
///
|
///
|
||||||
/// Updates the customer's details in a customer object.
|
/// Updates the customer's details in a customer object.
|
||||||
|
|||||||
@ -47,7 +47,8 @@ impl From<Flow> for ApiIdentifier {
|
|||||||
| Flow::CustomersRetrieve
|
| Flow::CustomersRetrieve
|
||||||
| Flow::CustomersUpdate
|
| Flow::CustomersUpdate
|
||||||
| Flow::CustomersDelete
|
| Flow::CustomersDelete
|
||||||
| Flow::CustomersGetMandates => Self::Customers,
|
| Flow::CustomersGetMandates
|
||||||
|
| Flow::CustomersList => Self::Customers,
|
||||||
|
|
||||||
Flow::EphemeralKeyCreate | Flow::EphemeralKeyDelete => Self::Ephemeral,
|
Flow::EphemeralKeyCreate | Flow::EphemeralKeyDelete => Self::Ephemeral,
|
||||||
|
|
||||||
|
|||||||
@ -104,6 +104,8 @@ pub enum Flow {
|
|||||||
PaymentMethodsList,
|
PaymentMethodsList,
|
||||||
/// Customer payment methods list flow.
|
/// Customer payment methods list flow.
|
||||||
CustomerPaymentMethodsList,
|
CustomerPaymentMethodsList,
|
||||||
|
/// List Customers for a merchant
|
||||||
|
CustomersList,
|
||||||
/// Payment methods retrieve flow.
|
/// Payment methods retrieve flow.
|
||||||
PaymentMethodsRetrieve,
|
PaymentMethodsRetrieve,
|
||||||
/// Payment methods update flow.
|
/// 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": {
|
"/customers/payment_methods": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"tags": [
|
||||||
|
|||||||
Reference in New Issue
Block a user