feat: delete customer data in compliance with GDPR regulations (#64)

This commit is contained in:
Manoj Ghorela
2022-12-13 19:39:32 +05:30
committed by GitHub
parent 3655c8de82
commit ae8869318b
12 changed files with 246 additions and 35 deletions

View File

@ -39,7 +39,9 @@ pub struct CustomerId {
#[derive(Default, Debug, Deserialize, Serialize)] #[derive(Default, Debug, Deserialize, Serialize)]
pub struct CustomerDeleteResponse { pub struct CustomerDeleteResponse {
pub customer_id: String, pub customer_id: String,
pub deleted: bool, pub customer_deleted: bool,
pub address_deleted: bool,
pub payment_methods_deleted: bool,
} }
pub fn generate_customer_id() -> String { pub fn generate_customer_id() -> String {

View File

@ -111,9 +111,7 @@ pub async fn customer_delete(
&state, &state,
&req, &req,
payload, payload,
|state, merchant_account, req| { customers::delete_customer,
customers::delete_customer(&*state.store, merchant_account, req)
},
api::MerchantAuthentication::ApiKey, api::MerchantAuthentication::ApiKey,
) )
.await .await

View File

@ -104,7 +104,7 @@ impl From<api::CustomerDeleteResponse> for CustomerDeleteResponse {
fn from(cust: api::CustomerDeleteResponse) -> Self { fn from(cust: api::CustomerDeleteResponse) -> Self {
Self { Self {
id: cust.customer_id, id: cust.customer_id,
deleted: cust.deleted, deleted: cust.customer_deleted,
} }
} }
} }

View File

@ -55,6 +55,12 @@ pub(crate) enum ErrorCode {
#[error(error_type = StripeErrorType::ApiError, code = "internal_server_error", message = "Server is down")] #[error(error_type = StripeErrorType::ApiError, code = "internal_server_error", message = "Server is down")]
DuplicateRefundRequest, DuplicateRefundRequest,
#[error(error_type = StripeErrorType::InvalidRequestError, code = "active_mandate", message = "Customer has active mandate")]
MandateActive,
#[error(error_type = StripeErrorType::InvalidRequestError, code = "customer_redacted", message = "Customer has redacted")]
CustomerRedacted,
#[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,
@ -340,6 +346,8 @@ impl From<ApiErrorResponse> for ErrorCode {
ApiErrorResponse::RefundFailed { data } => ErrorCode::RefundFailed, // Nothing at stripe to map ApiErrorResponse::RefundFailed { data } => ErrorCode::RefundFailed, // Nothing at stripe to map
ApiErrorResponse::InternalServerError => ErrorCode::InternalServerError, // not a stripe code ApiErrorResponse::InternalServerError => ErrorCode::InternalServerError, // not a stripe code
ApiErrorResponse::MandateActive => ErrorCode::MandateActive, //not a stripe code
ApiErrorResponse::CustomerRedacted => ErrorCode::CustomerRedacted, //not a stripe code
ApiErrorResponse::DuplicateRefundRequest => ErrorCode::DuplicateRefundRequest, ApiErrorResponse::DuplicateRefundRequest => ErrorCode::DuplicateRefundRequest,
ApiErrorResponse::RefundNotFound => ErrorCode::RefundNotFound, ApiErrorResponse::RefundNotFound => ErrorCode::RefundNotFound,
ApiErrorResponse::CustomerNotFound => ErrorCode::CustomerNotFound, ApiErrorResponse::CustomerNotFound => ErrorCode::CustomerNotFound,
@ -433,9 +441,10 @@ impl actix_web::ResponseError for ErrorCode {
| ErrorCode::ResourceIdNotFound | ErrorCode::ResourceIdNotFound
| ErrorCode::PaymentIntentMandateInvalid { .. } | ErrorCode::PaymentIntentMandateInvalid { .. }
| ErrorCode::PaymentIntentUnexpectedState { .. } => StatusCode::BAD_REQUEST, | ErrorCode::PaymentIntentUnexpectedState { .. } => StatusCode::BAD_REQUEST,
ErrorCode::RefundFailed | ErrorCode::InternalServerError => { ErrorCode::RefundFailed
StatusCode::INTERNAL_SERVER_ERROR | ErrorCode::InternalServerError
} | ErrorCode::MandateActive
| ErrorCode::CustomerRedacted => StatusCode::INTERNAL_SERVER_ERROR,
ErrorCode::ReturnUrlUnavailable => StatusCode::SERVICE_UNAVAILABLE, ErrorCode::ReturnUrlUnavailable => StatusCode::SERVICE_UNAVAILABLE,
} }
} }

View File

@ -2,12 +2,16 @@ use error_stack::ResultExt;
use router_env::{tracing, tracing::instrument}; use router_env::{tracing, tracing::instrument};
use crate::{ use crate::{
core::errors::{self, RouterResponse, StorageErrorExt}, core::{
errors::{self, RouterResponse, StorageErrorExt},
payment_methods::cards,
},
db::StorageInterface, db::StorageInterface,
routes::AppState,
services, services,
types::{ types::{
api::customers::{self, CustomerRequestExt}, api::customers::{self, CustomerRequestExt},
storage, storage::{self, enums},
}, },
}; };
@ -63,20 +67,99 @@ pub async fn retrieve_customer(
Ok(services::BachResponse::Json(response.into())) Ok(services::BachResponse::Json(response.into()))
} }
#[instrument(skip(db))] #[instrument(skip_all)]
pub async fn delete_customer( pub async fn delete_customer(
db: &dyn StorageInterface, state: &AppState,
merchant_account: storage::MerchantAccount, merchant_account: storage::MerchantAccount,
req: customers::CustomerId, req: customers::CustomerId,
) -> RouterResponse<customers::CustomerDeleteResponse> { ) -> RouterResponse<customers::CustomerDeleteResponse> {
let response = db let db = &state.store;
.delete_customer_by_customer_id_merchant_id(&req.customer_id, &merchant_account.merchant_id)
let cust = db
.find_customer_by_customer_id_merchant_id(&req.customer_id, &merchant_account.merchant_id)
.await .await
.map(|response| customers::CustomerDeleteResponse { .map_err(|err| err.to_not_found_response(errors::ApiErrorResponse::CustomerNotFound))?;
customer_id: req.customer_id, if cust.name == Some("Redacted".to_string()) {
deleted: response, Err(errors::ApiErrorResponse::CustomerRedacted)?
}) }
.map_err(|error| error.to_not_found_response(errors::ApiErrorResponse::CustomerNotFound))?;
let customer_mandates = db
.find_mandate_by_merchant_id_customer_id(&merchant_account.merchant_id, &req.customer_id)
.await
.map_err(|err| err.to_not_found_response(errors::ApiErrorResponse::MandateNotFound))?;
for mandate in customer_mandates.into_iter() {
if mandate.mandate_status == enums::MandateStatus::Active {
Err(errors::ApiErrorResponse::MandateActive)?
}
}
let customer_payment_methods = db
.find_payment_method_by_customer_id_merchant_id_list(
&req.customer_id,
&merchant_account.merchant_id,
)
.await
.map_err(|err| {
err.to_not_found_response(errors::ApiErrorResponse::PaymentMethodNotFound)
})?;
for pm in customer_payment_methods.into_iter() {
if pm.payment_method == enums::PaymentMethodType::Card {
cards::delete_card(state, &merchant_account.merchant_id, &pm.payment_method_id).await?;
}
db.delete_payment_method_by_merchant_id_payment_method_id(
&merchant_account.merchant_id,
&pm.payment_method_id,
)
.await
.map_err(|error| {
error.to_not_found_response(errors::ApiErrorResponse::PaymentMethodNotFound)
})?;
}
let update_address = storage::AddressUpdate::Update {
city: Some("Redacted".to_string()),
country: Some("Redacted".to_string()),
line1: Some("Redacted".to_string().into()),
line2: Some("Redacted".to_string().into()),
line3: Some("Redacted".to_string().into()),
state: Some("Redacted".to_string().into()),
zip: Some("Redacted".to_string().into()),
first_name: Some("Redacted".to_string().into()),
last_name: Some("Redacted".to_string().into()),
phone_number: Some("Redacted".to_string().into()),
country_code: Some("Redacted".to_string()),
};
db.update_address_by_merchant_id_customer_id(
&req.customer_id,
&merchant_account.merchant_id,
update_address,
)
.await
.change_context(errors::ApiErrorResponse::AddressNotFound)?;
let updated_customer = storage::CustomerUpdate::Update {
name: Some("Redacted".to_string()),
email: Some("Redacted".to_string().into()),
phone: Some("Redacted".to_string().into()),
description: Some("Redacted".to_string()),
phone_country_code: Some("Redacted".to_string()),
metadata: None,
};
db.update_customer_by_customer_id_merchant_id(
req.customer_id.clone(),
merchant_account.merchant_id,
updated_customer,
)
.await
.change_context(errors::ApiErrorResponse::CustomerNotFound)?;
let response = customers::CustomerDeleteResponse {
customer_id: req.customer_id,
customer_deleted: true,
address_deleted: true,
payment_methods_deleted: true,
};
Ok(services::BachResponse::Json(response)) Ok(services::BachResponse::Json(response))
} }

View File

@ -28,8 +28,6 @@ pub enum ApiErrorResponse {
the Dashboard Settings section." the Dashboard Settings section."
)] )]
BadCredentials, BadCredentials,
#[error(error_type = ErrorType::InvalidRequestError, code = "IR_10", message = "Invalid Ephemeral Key for the customer")]
InvalidEphermeralKey,
#[error(error_type = ErrorType::InvalidRequestError, code = "IR_03", message = "Unrecognized request URL.")] #[error(error_type = ErrorType::InvalidRequestError, code = "IR_03", message = "Unrecognized request URL.")]
InvalidRequestUrl, InvalidRequestUrl,
#[error(error_type = ErrorType::InvalidRequestError, code = "IR_04", message = "The HTTP method is not applicable for this API.")] #[error(error_type = ErrorType::InvalidRequestError, code = "IR_04", message = "The HTTP method is not applicable for this API.")]
@ -46,14 +44,20 @@ pub enum ApiErrorResponse {
}, },
#[error(error_type = ErrorType::InvalidRequestError, code = "IR_07", message = "{message}")] #[error(error_type = ErrorType::InvalidRequestError, code = "IR_07", message = "{message}")]
InvalidRequestData { message: String }, InvalidRequestData { message: String },
#[error(error_type = ErrorType::InvalidRequestError, code = "IR_08", message = "Refund amount exceeds the payment amount.")]
RefundAmountExceedsPaymentAmount,
/// Typically used when a field has invalid value, or deserialization of the value contained in /// Typically used when a field has invalid value, or deserialization of the value contained in
/// a field fails. /// a field fails.
#[error(error_type = ErrorType::InvalidRequestError, code = "IR_07", message = "Invalid value provided: {field_name}.")] #[error(error_type = ErrorType::InvalidRequestError, code = "IR_07", message = "Invalid value provided: {field_name}.")]
InvalidDataValue { field_name: &'static str }, InvalidDataValue { field_name: &'static str },
#[error(error_type = ErrorType::InvalidRequestError, code = "IR_07", message = "The client_secret provided does not match the client_secret associated with the Payment.")]
ClientSecretInvalid,
#[error(error_type = ErrorType::InvalidRequestError, code = "IR_07", message = "Customer has existing mandate/subsciption.")]
MandateActive,
#[error(error_type = ErrorType::InvalidRequestError, code = "IR_07", message = "Customer has already redacted.")]
CustomerRedacted,
#[error(error_type = ErrorType::InvalidRequestError, code = "IR_08", message = "Reached maximum refund attempts")] #[error(error_type = ErrorType::InvalidRequestError, code = "IR_08", message = "Reached maximum refund attempts")]
MaximumRefundCount, MaximumRefundCount,
#[error(error_type = ErrorType::InvalidRequestError, code = "IR_08", message = "Refund amount exceeds the payment amount.")]
RefundAmountExceedsPaymentAmount,
#[error(error_type = ErrorType::InvalidRequestError, code = "IR_09", message = "This PaymentIntent could not be {current_flow} because it has a {field_name} of {current_value}. The expected state is {states}.")] #[error(error_type = ErrorType::InvalidRequestError, code = "IR_09", message = "This PaymentIntent could not be {current_flow} because it has a {field_name} of {current_value}. The expected state is {states}.")]
PaymentUnexpectedState { PaymentUnexpectedState {
current_flow: String, current_flow: String,
@ -61,14 +65,13 @@ pub enum ApiErrorResponse {
current_value: String, current_value: String,
states: String, states: String,
}, },
#[error(error_type = ErrorType::InvalidRequestError, code = "IR_10", message = "Invalid Ephemeral Key for the customer")]
InvalidEphermeralKey,
/// Typically used when information involving multiple fields or previously provided /// Typically used when information involving multiple fields or previously provided
/// information doesn't satisfy a condition. /// information doesn't satisfy a condition.
#[error(error_type = ErrorType::InvalidRequestError, code = "IR_10", message = "{message}")] #[error(error_type = ErrorType::InvalidRequestError, code = "IR_10", message = "{message}")]
PreconditionFailed { message: String }, PreconditionFailed { message: String },
#[error(error_type = ErrorType::InvalidRequestError, code = "IR_07", message = "The client_secret provided does not match the client_secret associated with the Payment.")]
ClientSecretInvalid,
#[error(error_type = ErrorType::ProcessingError, code = "CE_01", message = "Payment failed while processing with connector. Retry payment.")] #[error(error_type = ErrorType::ProcessingError, code = "CE_01", message = "Payment failed while processing with connector. Retry payment.")]
PaymentAuthorizationFailed { data: Option<serde_json::Value> }, PaymentAuthorizationFailed { data: Option<serde_json::Value> },
#[error(error_type = ErrorType::ProcessingError, code = "CE_02", message = "Payment failed while processing with connector. Retry payment.")] #[error(error_type = ErrorType::ProcessingError, code = "CE_02", message = "Payment failed while processing with connector. Retry payment.")]
@ -168,6 +171,8 @@ impl actix_web::ResponseError for ApiErrorResponse {
ApiErrorResponse::DuplicateRefundRequest => StatusCode::BAD_REQUEST, // 400 ApiErrorResponse::DuplicateRefundRequest => StatusCode::BAD_REQUEST, // 400
ApiErrorResponse::RefundNotFound ApiErrorResponse::RefundNotFound
| ApiErrorResponse::CustomerNotFound | ApiErrorResponse::CustomerNotFound
| ApiErrorResponse::MandateActive
| ApiErrorResponse::CustomerRedacted
| ApiErrorResponse::PaymentNotFound | ApiErrorResponse::PaymentNotFound
| ApiErrorResponse::PaymentMethodNotFound | ApiErrorResponse::PaymentMethodNotFound
| ApiErrorResponse::MerchantAccountNotFound | ApiErrorResponse::MerchantAccountNotFound

View File

@ -204,6 +204,23 @@ pub async fn mock_get_card<'a>(
)) ))
} }
#[instrument(skip_all)]
pub async fn mock_delete_card<'a>(
db: &dyn db::StorageInterface,
card_id: &'a str,
) -> errors::CustomResult<payment_methods::DeleteCardResponse, errors::CardVaultError> {
let locker_mock_up = db
.delete_locker_mock_up(card_id)
.await
.change_context(errors::CardVaultError::FetchCardFailed)?;
Ok(payment_methods::DeleteCardResponse {
card_id: locker_mock_up.card_id,
external_id: locker_mock_up.external_id,
card_isin: None,
status: "SUCCESS".to_string(),
})
}
#[instrument(skip_all)] #[instrument(skip_all)]
pub async fn get_card_from_legacy_locker<'a>( pub async fn get_card_from_legacy_locker<'a>(
state: &'a routes::AppState, state: &'a routes::AppState,
@ -245,17 +262,25 @@ pub async fn delete_card<'a>(
merchant_id: &'a str, merchant_id: &'a str,
card_id: &'a str, card_id: &'a str,
) -> errors::RouterResult<payment_methods::DeleteCardResponse> { ) -> errors::RouterResult<payment_methods::DeleteCardResponse> {
let locker = &state.conf.locker;
let request = payment_methods::mk_delete_card_request(&state.conf.locker, merchant_id, card_id) let request = payment_methods::mk_delete_card_request(&state.conf.locker, merchant_id, card_id)
.change_context(errors::ApiErrorResponse::InternalServerError) .change_context(errors::ApiErrorResponse::InternalServerError)
.attach_printable("Making Delete card request Failed")?; .attach_printable("Making Delete card request Failed")?;
// FIXME use call_api 2. Serde's handle should be inside the generic function // FIXME use call_api 2. Serde's handle should be inside the generic function
let delete_card_resp = services::call_connector_api(state, request) let delete_card_resp = if !locker.mock_locker {
.await services::call_connector_api(state, request)
.change_context(errors::ApiErrorResponse::InternalServerError)? .await
.map_err(|_x| errors::ApiErrorResponse::InternalServerError)? .change_context(errors::ApiErrorResponse::InternalServerError)?
.response .map_err(|_x| errors::ApiErrorResponse::InternalServerError)?
.parse_struct("DeleteCardResponse") .response
.change_context(errors::ApiErrorResponse::InternalServerError)?; .parse_struct("DeleteCardResponse")
.change_context(errors::ApiErrorResponse::InternalServerError)?
} else {
mock_delete_card(&*state.store, card_id)
.await
.change_context(errors::ApiErrorResponse::InternalServerError)?
};
Ok(delete_card_resp) Ok(delete_card_resp)
} }

View File

@ -14,14 +14,23 @@ pub trait AddressInterface {
address_id: String, address_id: String,
address: storage::AddressUpdate, address: storage::AddressUpdate,
) -> CustomResult<storage::Address, errors::StorageError>; ) -> CustomResult<storage::Address, errors::StorageError>;
async fn insert_address( async fn insert_address(
&self, &self,
address: storage::AddressNew, address: storage::AddressNew,
) -> CustomResult<storage::Address, errors::StorageError>; ) -> CustomResult<storage::Address, errors::StorageError>;
async fn find_address( async fn find_address(
&self, &self,
address_id: &str, address_id: &str,
) -> CustomResult<storage::Address, errors::StorageError>; ) -> CustomResult<storage::Address, errors::StorageError>;
async fn update_address_by_merchant_id_customer_id(
&self,
customer_id: &str,
merchant_id: &str,
address: storage::AddressUpdate,
) -> CustomResult<Vec<storage::Address>, errors::StorageError>;
} }
#[async_trait::async_trait] #[async_trait::async_trait]
@ -60,6 +69,24 @@ impl AddressInterface for Store {
.map_err(Into::into) .map_err(Into::into)
.into_report() .into_report()
} }
async fn update_address_by_merchant_id_customer_id(
&self,
customer_id: &str,
merchant_id: &str,
address: storage::AddressUpdate,
) -> CustomResult<Vec<storage::Address>, errors::StorageError> {
let conn = pg_connection(&self.master_pool).await;
storage::Address::update_by_merchant_id_customer_id(
&conn,
customer_id,
merchant_id,
address,
)
.await
.map_err(Into::into)
.into_report()
}
} }
#[async_trait::async_trait] #[async_trait::async_trait]
@ -85,4 +112,13 @@ impl AddressInterface for MockDb {
) -> CustomResult<storage::Address, errors::StorageError> { ) -> CustomResult<storage::Address, errors::StorageError> {
todo!() todo!()
} }
async fn update_address_by_merchant_id_customer_id(
&self,
_customer_id: &str,
_merchant_id: &str,
_address: storage::AddressUpdate,
) -> CustomResult<Vec<storage::Address>, errors::StorageError> {
todo!()
}
} }

View File

@ -18,6 +18,11 @@ pub trait LockerMockUpInterface {
&self, &self,
new: storage::LockerMockUpNew, new: storage::LockerMockUpNew,
) -> CustomResult<storage::LockerMockUp, errors::StorageError>; ) -> CustomResult<storage::LockerMockUp, errors::StorageError>;
async fn delete_locker_mock_up(
&self,
card_id: &str,
) -> CustomResult<storage::LockerMockUp, errors::StorageError>;
} }
#[async_trait::async_trait] #[async_trait::async_trait]
@ -40,6 +45,17 @@ impl LockerMockUpInterface for Store {
let conn = pg_connection(&self.master_pool).await; let conn = pg_connection(&self.master_pool).await;
new.insert(&conn).await.map_err(Into::into).into_report() new.insert(&conn).await.map_err(Into::into).into_report()
} }
async fn delete_locker_mock_up(
&self,
card_id: &str,
) -> CustomResult<storage::LockerMockUp, errors::StorageError> {
let conn = pg_connection(&self.master_pool).await;
storage::LockerMockUp::delete_by_card_id(&conn, card_id)
.await
.map_err(Into::into)
.into_report()
}
} }
#[async_trait::async_trait] #[async_trait::async_trait]
@ -57,4 +73,11 @@ impl LockerMockUpInterface for MockDb {
) -> CustomResult<storage::LockerMockUp, errors::StorageError> { ) -> CustomResult<storage::LockerMockUp, errors::StorageError> {
todo!() todo!()
} }
async fn delete_locker_mock_up(
&self,
_card_id: &str,
) -> CustomResult<storage::LockerMockUp, errors::StorageError> {
todo!()
}
} }

View File

@ -94,7 +94,7 @@ pub async fn customers_delete(
&state, &state,
&req, &req,
payload, payload,
|state, merchant_account, req| delete_customer(&*state.store, merchant_account, req), delete_customer,
api::MerchantAuthentication::ApiKey, api::MerchantAuthentication::ApiKey,
) )
.await .await

View File

@ -1,4 +1,4 @@
use diesel::{associations::HasTable, ExpressionMethods}; use diesel::{associations::HasTable, BoolExpressionMethods, ExpressionMethods};
use router_env::{tracing, tracing::instrument}; use router_env::{tracing, tracing::instrument};
use super::generics::{self, ExecuteQuery}; use super::generics::{self, ExecuteQuery};
@ -61,6 +61,23 @@ impl Address {
.await .await
} }
pub async fn update_by_merchant_id_customer_id(
conn: &PgPooledConn,
customer_id: &str,
merchant_id: &str,
address: AddressUpdate,
) -> CustomResult<Vec<Self>, errors::DatabaseError> {
generics::generic_update_with_results::<<Self as HasTable>::Table, _, _, Self, _>(
conn,
dsl::merchant_id
.eq(merchant_id.to_owned())
.and(dsl::customer_id.eq(customer_id.to_owned())),
AddressUpdateInternal::from(address),
ExecuteQuery::new(),
)
.await
}
#[instrument(skip(conn))] #[instrument(skip(conn))]
pub async fn find_by_address_id<'a>( pub async fn find_by_address_id<'a>(
conn: &PgPooledConn, conn: &PgPooledConn,

View File

@ -31,4 +31,17 @@ impl LockerMockUp {
) )
.await .await
} }
#[instrument(skip(conn))]
pub async fn delete_by_card_id(
conn: &PgPooledConn,
card_id: &str,
) -> CustomResult<Self, errors::DatabaseError> {
generics::generic_delete_one_with_result::<<Self as HasTable>::Table, _, Self, _>(
conn,
dsl::card_id.eq(card_id.to_owned()),
ExecuteQuery::new(),
)
.await
}
} }