feat(customers): add customer list endpoint (#2564)

Co-authored-by: Bernard Eugine <114725419+bernard-eugine@users.noreply.github.com>
This commit is contained in:
Narayan Bhat
2023-10-12 19:58:34 +05:30
committed by GitHub
parent 28d02f94c6
commit c26620e041
12 changed files with 207 additions and 24 deletions

View File

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

View File

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

View File

@ -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) => { .await
if error.current_context().is_db_unique_violation() { .to_duplicate_response(errors::CustomersErrorResponse::CustomerAlreadyExists)?;
db.find_customer_by_customer_id_merchant_id(customer_id, merchant_id, &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"))?
}
}
};
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,

View File

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

View File

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

View File

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

View File

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

View File

@ -275,10 +275,12 @@ impl Customers {
#[cfg(feature = "olap")] #[cfg(feature = "olap")]
{ {
route = route.service( route = route
web::resource("/{customer_id}/mandates") .service(
.route(web::get().to(get_customer_mandates)), 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")] #[cfg(feature = "oltp")]
@ -300,6 +302,7 @@ impl Customers {
.route(web::delete().to(customers_delete)), .route(web::delete().to(customers_delete)),
); );
} }
route route
} }
} }

View File

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

View File

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

View File

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

View File

@ -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": [