diff --git a/crates/api_models/src/events/user.rs b/crates/api_models/src/events/user.rs index 50df0c9a584b..8b7cd02c9350 100644 --- a/crates/api_models/src/events/user.rs +++ b/crates/api_models/src/events/user.rs @@ -1,11 +1,13 @@ use common_utils::events::{ApiEventMetric, ApiEventsType}; +#[cfg(feature = "dummy_connector")] +use crate::user::sample_data::SampleDataRequest; use crate::user::{ dashboard_metadata::{ GetMetaDataRequest, GetMetaDataResponse, GetMultipleMetaDataPayload, SetMetaDataRequest, }, ChangePasswordRequest, ConnectAccountRequest, ConnectAccountResponse, - CreateInternalUserRequest, SwitchMerchantIdRequest, UserMerchantCreate, + CreateInternalUserRequest, GetUsersResponse, SwitchMerchantIdRequest, UserMerchantCreate, }; impl ApiEventMetric for ConnectAccountResponse { @@ -27,5 +29,9 @@ common_utils::impl_misc_api_event_type!( SetMetaDataRequest, SwitchMerchantIdRequest, CreateInternalUserRequest, - UserMerchantCreate + UserMerchantCreate, + GetUsersResponse ); + +#[cfg(feature = "dummy_connector")] +common_utils::impl_misc_api_event_type!(SampleDataRequest); diff --git a/crates/api_models/src/user.rs b/crates/api_models/src/user.rs index e0bfa50b4115..36d730f5118e 100644 --- a/crates/api_models/src/user.rs +++ b/crates/api_models/src/user.rs @@ -1,6 +1,10 @@ use common_utils::pii; use masking::Secret; + +use crate::user_role::UserStatus; pub mod dashboard_metadata; +#[cfg(feature = "dummy_connector")] +pub mod sample_data; #[derive(serde::Deserialize, Debug, Clone, serde::Serialize)] pub struct ConnectAccountRequest { @@ -43,3 +47,18 @@ pub struct CreateInternalUserRequest { pub struct UserMerchantCreate { pub company_name: String, } + +#[derive(Debug, serde::Serialize)] +pub struct GetUsersResponse(pub Vec); + +#[derive(Debug, serde::Serialize)] +pub struct UserDetails { + pub user_id: String, + pub email: pii::Email, + pub name: Secret, + pub role_id: String, + pub role_name: String, + pub status: UserStatus, + #[serde(with = "common_utils::custom_serde::iso8601")] + pub last_modified_at: time::PrimitiveDateTime, +} diff --git a/crates/api_models/src/user/sample_data.rs b/crates/api_models/src/user/sample_data.rs new file mode 100644 index 000000000000..6d20b20f369c --- /dev/null +++ b/crates/api_models/src/user/sample_data.rs @@ -0,0 +1,23 @@ +use common_enums::{AuthenticationType, CountryAlpha2}; +use common_utils::{self}; +use time::PrimitiveDateTime; + +use crate::enums::Connector; + +#[derive(serde::Deserialize, Debug, serde::Serialize)] +pub struct SampleDataRequest { + pub record: Option, + pub connector: Option>, + #[serde(default, with = "common_utils::custom_serde::iso8601::option")] + pub start_time: Option, + #[serde(default, with = "common_utils::custom_serde::iso8601::option")] + pub end_time: Option, + // The amount for each sample will be between min_amount and max_amount (in dollars) + pub min_amount: Option, + pub max_amount: Option, + pub currency: Option>, + pub auth_type: Option>, + pub business_country: Option, + pub business_label: Option, + pub profile_id: Option, +} diff --git a/crates/api_models/src/user_role.rs b/crates/api_models/src/user_role.rs index 521d17e73428..735cd240b6e7 100644 --- a/crates/api_models/src/user_role.rs +++ b/crates/api_models/src/user_role.rs @@ -80,3 +80,9 @@ pub struct UpdateUserRoleRequest { pub user_id: String, pub role_id: String, } + +#[derive(Debug, serde::Serialize)] +pub enum UserStatus { + Active, + InvitationSent, +} diff --git a/crates/diesel_models/src/query/dashboard_metadata.rs b/crates/diesel_models/src/query/dashboard_metadata.rs index 03e4a2dab38b..44fd24c7acf2 100644 --- a/crates/diesel_models/src/query/dashboard_metadata.rs +++ b/crates/diesel_models/src/query/dashboard_metadata.rs @@ -5,7 +5,10 @@ use crate::{ enums, query::generics, schema::dashboard_metadata::dsl, - user::dashboard_metadata::{DashboardMetadata, DashboardMetadataNew}, + user::dashboard_metadata::{ + DashboardMetadata, DashboardMetadataNew, DashboardMetadataUpdate, + DashboardMetadataUpdateInternal, + }, PgPooledConn, StorageResult, }; @@ -17,6 +20,31 @@ impl DashboardMetadataNew { } impl DashboardMetadata { + pub async fn update( + conn: &PgPooledConn, + user_id: Option, + merchant_id: String, + org_id: String, + data_key: enums::DashboardMetadata, + dashboard_metadata_update: DashboardMetadataUpdate, + ) -> StorageResult { + generics::generic_update_with_unique_predicate_get_result::< + ::Table, + _, + _, + _, + >( + conn, + dsl::user_id + .eq(user_id.to_owned()) + .and(dsl::merchant_id.eq(merchant_id.to_owned())) + .and(dsl::org_id.eq(org_id.to_owned())) + .and(dsl::data_key.eq(data_key.to_owned())), + DashboardMetadataUpdateInternal::from(dashboard_metadata_update), + ) + .await + } + pub async fn find_user_scoped_dashboard_metadata( conn: &PgPooledConn, user_id: String, diff --git a/crates/diesel_models/src/query/user.rs b/crates/diesel_models/src/query/user.rs index 5761d8af814d..b4d5976ba294 100644 --- a/crates/diesel_models/src/query/user.rs +++ b/crates/diesel_models/src/query/user.rs @@ -1,12 +1,24 @@ -use diesel::{associations::HasTable, ExpressionMethods}; -use error_stack::report; -use router_env::tracing::{self, instrument}; +use async_bb8_diesel::AsyncRunQueryDsl; +use diesel::{ + associations::HasTable, debug_query, result::Error as DieselError, ExpressionMethods, + JoinOnDsl, QueryDsl, +}; +use error_stack::{report, IntoReport}; +use router_env::{ + logger, + tracing::{self, instrument}, +}; +pub mod sample_data; use crate::{ errors::{self}, query::generics, - schema::users::dsl, + schema::{ + user_roles::{self, dsl as user_roles_dsl}, + users::dsl as users_dsl, + }, user::*, + user_role::UserRole, PgPooledConn, StorageResult, }; @@ -21,7 +33,7 @@ impl User { pub async fn find_by_user_email(conn: &PgPooledConn, user_email: &str) -> StorageResult { generics::generic_find_one::<::Table, _, _>( conn, - dsl::email.eq(user_email.to_owned()), + users_dsl::email.eq(user_email.to_owned()), ) .await } @@ -29,7 +41,7 @@ impl User { pub async fn find_by_user_id(conn: &PgPooledConn, user_id: &str) -> StorageResult { generics::generic_find_one::<::Table, _, _>( conn, - dsl::user_id.eq(user_id.to_owned()), + users_dsl::user_id.eq(user_id.to_owned()), ) .await } @@ -41,7 +53,7 @@ impl User { ) -> StorageResult { generics::generic_update_with_results::<::Table, _, _, _>( conn, - dsl::user_id.eq(user_id.to_owned()), + users_dsl::user_id.eq(user_id.to_owned()), UserUpdateInternal::from(user), ) .await? @@ -55,8 +67,28 @@ impl User { pub async fn delete_by_user_id(conn: &PgPooledConn, user_id: &str) -> StorageResult { generics::generic_delete::<::Table, _>( conn, - dsl::user_id.eq(user_id.to_owned()), + users_dsl::user_id.eq(user_id.to_owned()), ) .await } + + pub async fn find_joined_users_and_roles_by_merchant_id( + conn: &PgPooledConn, + mid: &str, + ) -> StorageResult> { + let query = Self::table() + .inner_join(user_roles::table.on(user_roles_dsl::user_id.eq(users_dsl::user_id))) + .filter(user_roles_dsl::merchant_id.eq(mid.to_owned())); + + logger::debug!(query = %debug_query::(&query).to_string()); + + query + .get_results_async::<(Self, UserRole)>(conn) + .await + .into_report() + .map_err(|err| match err.current_context() { + DieselError::NotFound => err.change_context(errors::DatabaseError::NotFound), + _ => err.change_context(errors::DatabaseError::Others), + }) + } } diff --git a/crates/diesel_models/src/query/user/sample_data.rs b/crates/diesel_models/src/query/user/sample_data.rs new file mode 100644 index 000000000000..a8ec2c3b0a4f --- /dev/null +++ b/crates/diesel_models/src/query/user/sample_data.rs @@ -0,0 +1,139 @@ +use async_bb8_diesel::AsyncRunQueryDsl; +use diesel::{associations::HasTable, debug_query, ExpressionMethods, TextExpressionMethods}; +use error_stack::{IntoReport, ResultExt}; +use router_env::logger; + +use crate::{ + errors, + schema::{ + payment_attempt::dsl as payment_attempt_dsl, payment_intent::dsl as payment_intent_dsl, + refund::dsl as refund_dsl, + }, + user::sample_data::PaymentAttemptBatchNew, + PaymentAttempt, PaymentIntent, PaymentIntentNew, PgPooledConn, Refund, RefundNew, + StorageResult, +}; + +pub async fn insert_payment_intents( + conn: &PgPooledConn, + batch: Vec, +) -> StorageResult> { + let query = diesel::insert_into(::table()).values(batch); + + logger::debug!(query = %debug_query::(&query).to_string()); + + query + .get_results_async(conn) + .await + .into_report() + .change_context(errors::DatabaseError::Others) + .attach_printable("Error while inserting payment intents") +} +pub async fn insert_payment_attempts( + conn: &PgPooledConn, + batch: Vec, +) -> StorageResult> { + let query = diesel::insert_into(::table()).values(batch); + + logger::debug!(query = %debug_query::(&query).to_string()); + + query + .get_results_async(conn) + .await + .into_report() + .change_context(errors::DatabaseError::Others) + .attach_printable("Error while inserting payment attempts") +} + +pub async fn insert_refunds( + conn: &PgPooledConn, + batch: Vec, +) -> StorageResult> { + let query = diesel::insert_into(::table()).values(batch); + + logger::debug!(query = %debug_query::(&query).to_string()); + + query + .get_results_async(conn) + .await + .into_report() + .change_context(errors::DatabaseError::Others) + .attach_printable("Error while inserting refunds") +} + +pub async fn delete_payment_intents( + conn: &PgPooledConn, + merchant_id: &str, +) -> StorageResult> { + let query = diesel::delete(::table()) + .filter(payment_intent_dsl::merchant_id.eq(merchant_id.to_owned())) + .filter(payment_intent_dsl::payment_id.like("test_%")); + + logger::debug!(query = %debug_query::(&query).to_string()); + + query + .get_results_async(conn) + .await + .into_report() + .change_context(errors::DatabaseError::Others) + .attach_printable("Error while deleting payment intents") + .and_then(|result| match result.len() { + n if n > 0 => { + logger::debug!("{n} records deleted"); + Ok(result) + } + 0 => Err(error_stack::report!(errors::DatabaseError::NotFound) + .attach_printable("No records deleted")), + _ => Ok(result), + }) +} +pub async fn delete_payment_attempts( + conn: &PgPooledConn, + merchant_id: &str, +) -> StorageResult> { + let query = diesel::delete(::table()) + .filter(payment_attempt_dsl::merchant_id.eq(merchant_id.to_owned())) + .filter(payment_attempt_dsl::payment_id.like("test_%")); + + logger::debug!(query = %debug_query::(&query).to_string()); + + query + .get_results_async(conn) + .await + .into_report() + .change_context(errors::DatabaseError::Others) + .attach_printable("Error while deleting payment attempts") + .and_then(|result| match result.len() { + n if n > 0 => { + logger::debug!("{n} records deleted"); + Ok(result) + } + 0 => Err(error_stack::report!(errors::DatabaseError::NotFound) + .attach_printable("No records deleted")), + _ => Ok(result), + }) +} + +pub async fn delete_refunds(conn: &PgPooledConn, merchant_id: &str) -> StorageResult> { + let query = diesel::delete(::table()) + .filter(refund_dsl::merchant_id.eq(merchant_id.to_owned())) + .filter(refund_dsl::payment_id.like("test_%")); + + logger::debug!(query = %debug_query::(&query).to_string()); + + query + .get_results_async(conn) + .await + .into_report() + .change_context(errors::DatabaseError::Others) + .attach_printable("Error while deleting refunds") + .and_then(|result| match result.len() { + n if n > 0 => { + logger::debug!("{n} records deleted"); + Ok(result) + } + 0 => Err(error_stack::report!(errors::DatabaseError::NotFound) + .attach_printable("No records deleted")), + _ => Ok(result), + }) +} diff --git a/crates/diesel_models/src/user.rs b/crates/diesel_models/src/user.rs index 4eec710ea185..c608f2654c6a 100644 --- a/crates/diesel_models/src/user.rs +++ b/crates/diesel_models/src/user.rs @@ -7,6 +7,7 @@ use crate::schema::users; pub mod dashboard_metadata; +pub mod sample_data; #[derive(Clone, Debug, Identifiable, Queryable)] #[diesel(table_name = users)] pub struct User { diff --git a/crates/diesel_models/src/user/dashboard_metadata.rs b/crates/diesel_models/src/user/dashboard_metadata.rs index 018808f1c0db..1eeb61d6135e 100644 --- a/crates/diesel_models/src/user/dashboard_metadata.rs +++ b/crates/diesel_models/src/user/dashboard_metadata.rs @@ -33,3 +33,40 @@ pub struct DashboardMetadataNew { pub last_modified_by: String, pub last_modified_at: PrimitiveDateTime, } + +#[derive( + router_derive::Setter, Clone, Debug, Insertable, router_derive::DebugAsDisplay, AsChangeset, +)] +#[diesel(table_name = dashboard_metadata)] +pub struct DashboardMetadataUpdateInternal { + pub data_key: enums::DashboardMetadata, + pub data_value: serde_json::Value, + pub last_modified_by: String, + pub last_modified_at: PrimitiveDateTime, +} + +pub enum DashboardMetadataUpdate { + UpdateData { + data_key: enums::DashboardMetadata, + data_value: serde_json::Value, + last_modified_by: String, + }, +} + +impl From for DashboardMetadataUpdateInternal { + fn from(metadata_update: DashboardMetadataUpdate) -> Self { + let last_modified_at = common_utils::date_time::now(); + match metadata_update { + DashboardMetadataUpdate::UpdateData { + data_key, + data_value, + last_modified_by, + } => Self { + data_key, + data_value, + last_modified_by, + last_modified_at, + }, + } + } +} diff --git a/crates/diesel_models/src/user/sample_data.rs b/crates/diesel_models/src/user/sample_data.rs new file mode 100644 index 000000000000..959d1ad9ee7e --- /dev/null +++ b/crates/diesel_models/src/user/sample_data.rs @@ -0,0 +1,119 @@ +use common_enums::{ + AttemptStatus, AuthenticationType, CaptureMethod, Currency, PaymentExperience, PaymentMethod, + PaymentMethodType, +}; +use serde::{Deserialize, Serialize}; +use time::PrimitiveDateTime; + +use crate::{enums::MandateDataType, schema::payment_attempt, PaymentAttemptNew}; + +#[derive( + Clone, Debug, Default, diesel::Insertable, router_derive::DebugAsDisplay, Serialize, Deserialize, +)] +#[diesel(table_name = payment_attempt)] +pub struct PaymentAttemptBatchNew { + pub payment_id: String, + pub merchant_id: String, + pub attempt_id: String, + pub status: AttemptStatus, + pub amount: i64, + pub currency: Option, + pub save_to_locker: Option, + pub connector: Option, + pub error_message: Option, + pub offer_amount: Option, + pub surcharge_amount: Option, + pub tax_amount: Option, + pub payment_method_id: Option, + pub payment_method: Option, + pub capture_method: Option, + #[serde(default, with = "common_utils::custom_serde::iso8601::option")] + pub capture_on: Option, + pub confirm: bool, + pub authentication_type: Option, + #[serde(default, with = "common_utils::custom_serde::iso8601::option")] + pub created_at: Option, + #[serde(default, with = "common_utils::custom_serde::iso8601::option")] + pub modified_at: Option, + #[serde(default, with = "common_utils::custom_serde::iso8601::option")] + pub last_synced: Option, + pub cancellation_reason: Option, + pub amount_to_capture: Option, + pub mandate_id: Option, + pub browser_info: Option, + pub payment_token: Option, + pub error_code: Option, + pub connector_metadata: Option, + pub payment_experience: Option, + pub payment_method_type: Option, + pub payment_method_data: Option, + pub business_sub_label: Option, + pub straight_through_algorithm: Option, + pub preprocessing_step_id: Option, + pub mandate_details: Option, + pub error_reason: Option, + pub connector_response_reference_id: Option, + pub connector_transaction_id: Option, + pub multiple_capture_count: Option, + pub amount_capturable: i64, + pub updated_by: String, + pub merchant_connector_id: Option, + pub authentication_data: Option, + pub encoded_data: Option, + pub unified_code: Option, + pub unified_message: Option, +} + +#[allow(dead_code)] +impl PaymentAttemptBatchNew { + // Used to verify compatibility with PaymentAttemptTable + fn convert_into_normal_attempt_insert(self) -> PaymentAttemptNew { + PaymentAttemptNew { + payment_id: self.payment_id, + merchant_id: self.merchant_id, + attempt_id: self.attempt_id, + status: self.status, + amount: self.amount, + currency: self.currency, + save_to_locker: self.save_to_locker, + connector: self.connector, + error_message: self.error_message, + offer_amount: self.offer_amount, + surcharge_amount: self.surcharge_amount, + tax_amount: self.tax_amount, + payment_method_id: self.payment_method_id, + payment_method: self.payment_method, + capture_method: self.capture_method, + capture_on: self.capture_on, + confirm: self.confirm, + authentication_type: self.authentication_type, + created_at: self.created_at, + modified_at: self.modified_at, + last_synced: self.last_synced, + cancellation_reason: self.cancellation_reason, + amount_to_capture: self.amount_to_capture, + mandate_id: self.mandate_id, + browser_info: self.browser_info, + payment_token: self.payment_token, + error_code: self.error_code, + connector_metadata: self.connector_metadata, + payment_experience: self.payment_experience, + payment_method_type: self.payment_method_type, + payment_method_data: self.payment_method_data, + business_sub_label: self.business_sub_label, + straight_through_algorithm: self.straight_through_algorithm, + preprocessing_step_id: self.preprocessing_step_id, + mandate_details: self.mandate_details, + error_reason: self.error_reason, + multiple_capture_count: self.multiple_capture_count, + connector_response_reference_id: self.connector_response_reference_id, + amount_capturable: self.amount_capturable, + updated_by: self.updated_by, + merchant_connector_id: self.merchant_connector_id, + authentication_data: self.authentication_data, + encoded_data: self.encoded_data, + unified_code: self.unified_code, + unified_message: self.unified_message, + } + } +} diff --git a/crates/router/src/connector/bankofamerica/transformers.rs b/crates/router/src/connector/bankofamerica/transformers.rs index 18ec8ceb89d9..e31a69669c6d 100644 --- a/crates/router/src/connector/bankofamerica/transformers.rs +++ b/crates/router/src/connector/bankofamerica/transformers.rs @@ -442,11 +442,18 @@ impl ForeignFrom<(BankofamericaPaymentStatus, bool)> for enums::AttemptStatus { | BankofamericaPaymentStatus::AuthorizedPendingReview => { if auto_capture { // Because BankOfAmerica will return Payment Status as Authorized even in AutoCapture Payment - Self::Pending + Self::Charged } else { Self::Authorized } } + BankofamericaPaymentStatus::Pending => { + if auto_capture { + Self::Charged + } else { + Self::Pending + } + } BankofamericaPaymentStatus::Succeeded | BankofamericaPaymentStatus::Transmitted => { Self::Charged } @@ -456,7 +463,6 @@ impl ForeignFrom<(BankofamericaPaymentStatus, bool)> for enums::AttemptStatus { BankofamericaPaymentStatus::Failed | BankofamericaPaymentStatus::Declined => { Self::Failure } - BankofamericaPaymentStatus::Pending => Self::Pending, } } } diff --git a/crates/router/src/connector/cybersource/transformers.rs b/crates/router/src/connector/cybersource/transformers.rs index 495e23e001ad..953f82c76a83 100644 --- a/crates/router/src/connector/cybersource/transformers.rs +++ b/crates/router/src/connector/cybersource/transformers.rs @@ -670,8 +670,9 @@ pub struct ApplicationInformation { fn get_payment_status(is_capture: bool, status: enums::AttemptStatus) -> enums::AttemptStatus { let is_authorized = matches!(status, enums::AttemptStatus::Authorized); - if is_capture && is_authorized { - return enums::AttemptStatus::Pending; + let is_pending = matches!(status, enums::AttemptStatus::Pending); + if is_capture && (is_authorized || is_pending) { + return enums::AttemptStatus::Charged; } status } diff --git a/crates/router/src/core/errors/user.rs b/crates/router/src/core/errors/user.rs index ba600917ecca..5e580b003408 100644 --- a/crates/router/src/core/errors/user.rs +++ b/crates/router/src/core/errors/user.rs @@ -4,6 +4,7 @@ use crate::services::ApplicationResponse; pub type UserResult = CustomResult; pub type UserResponse = CustomResult, UserErrors>; +pub mod sample_data; #[derive(Debug, thiserror::Error)] pub enum UserErrors { diff --git a/crates/router/src/core/errors/user/sample_data.rs b/crates/router/src/core/errors/user/sample_data.rs new file mode 100644 index 000000000000..11233b27b5cd --- /dev/null +++ b/crates/router/src/core/errors/user/sample_data.rs @@ -0,0 +1,73 @@ +use api_models::errors::types::{ApiError, ApiErrorResponse}; +use common_utils::errors::{CustomResult, ErrorSwitch, ErrorSwitchFrom}; +use data_models::errors::StorageError; + +pub type SampleDataResult = CustomResult; + +#[derive(Debug, Clone, serde::Serialize, thiserror::Error)] +pub enum SampleDataError { + #[error["Internal Server Error"]] + InternalServerError, + #[error("Data Does Not Exist")] + DataDoesNotExist, + #[error("Server Error")] + DatabaseError, + #[error("Merchant Id Not Found")] + MerchantIdNotFound, + #[error("Invalid Parameters")] + InvalidParameters, + #[error["Invalid Records"]] + InvalidRange, +} + +impl ErrorSwitch for SampleDataError { + fn switch(&self) -> ApiErrorResponse { + match self { + Self::InternalServerError => ApiErrorResponse::InternalServerError(ApiError::new( + "SD", + 0, + "Something went wrong", + None, + )), + Self::DatabaseError => ApiErrorResponse::InternalServerError(ApiError::new( + "SD", + 1, + "Server Error(DB is down)", + None, + )), + Self::DataDoesNotExist => ApiErrorResponse::NotFound(ApiError::new( + "SD", + 2, + "Sample Data not present for given request", + None, + )), + Self::MerchantIdNotFound => ApiErrorResponse::BadRequest(ApiError::new( + "SD", + 3, + "Merchant ID not provided", + None, + )), + Self::InvalidParameters => ApiErrorResponse::BadRequest(ApiError::new( + "SD", + 4, + "Invalid parameters to generate Sample Data", + None, + )), + Self::InvalidRange => ApiErrorResponse::BadRequest(ApiError::new( + "SD", + 5, + "Records to be generated should be between range 10 and 100", + None, + )), + } + } +} + +impl ErrorSwitchFrom for SampleDataError { + fn switch_from(error: &StorageError) -> Self { + match matches!(error, StorageError::ValueNotFound(_)) { + true => Self::DataDoesNotExist, + false => Self::DatabaseError, + } + } +} diff --git a/crates/router/src/core/user.rs b/crates/router/src/core/user.rs index 8e7f6c27a7da..7d0d599cc4ed 100644 --- a/crates/router/src/core/user.rs +++ b/crates/router/src/core/user.rs @@ -13,6 +13,8 @@ use crate::{ types::domain, utils, }; +#[cfg(feature = "dummy_connector")] +pub mod sample_data; pub mod dashboard_metadata; @@ -81,9 +83,10 @@ pub async fn connect_account( use crate::services::email::types as email_types; - let email_contents = email_types::WelcomeEmail { + let email_contents = email_types::VerifyEmail { recipient_email: domain::UserEmail::from_pii_email(user_from_db.get_email())?, settings: state.conf.clone(), + subject: "Welcome to the Hyperswitch community!", }; let send_email_result = state @@ -321,3 +324,29 @@ pub async fn create_merchant_account( Ok(ApplicationResponse::StatusOk) } + +pub async fn list_merchant_ids_for_user( + state: AppState, + user: auth::UserFromToken, +) -> UserResponse> { + Ok(ApplicationResponse::Json( + utils::user::get_merchant_ids_for_user(state, &user.user_id).await?, + )) +} + +pub async fn get_users_for_merchant_account( + state: AppState, + user_from_token: auth::UserFromToken, +) -> UserResponse { + let users = state + .store + .find_users_and_roles_by_merchant_id(user_from_token.merchant_id.as_str()) + .await + .change_context(UserErrors::InternalServerError) + .attach_printable("No users for given merchant id")? + .into_iter() + .filter_map(|(user, role)| domain::UserAndRoleJoined(user, role).try_into().ok()) + .collect(); + + Ok(ApplicationResponse::Json(user_api::GetUsersResponse(users))) +} diff --git a/crates/router/src/core/user/sample_data.rs b/crates/router/src/core/user/sample_data.rs new file mode 100644 index 000000000000..19b7d3bd815c --- /dev/null +++ b/crates/router/src/core/user/sample_data.rs @@ -0,0 +1,82 @@ +use api_models::user::sample_data::SampleDataRequest; +use common_utils::errors::ReportSwitchExt; +use data_models::payments::payment_intent::PaymentIntentNew; +use diesel_models::{user::sample_data::PaymentAttemptBatchNew, RefundNew}; + +pub type SampleDataApiResponse = SampleDataResult>; + +use crate::{ + core::errors::sample_data::SampleDataResult, + routes::AppState, + services::{authentication::UserFromToken, ApplicationResponse}, + utils::user::sample_data::generate_sample_data, +}; + +pub async fn generate_sample_data_for_user( + state: AppState, + user_from_token: UserFromToken, + req: SampleDataRequest, +) -> SampleDataApiResponse<()> { + let sample_data = + generate_sample_data(&state, req, user_from_token.merchant_id.as_str()).await?; + + let (payment_intents, payment_attempts, refunds): ( + Vec, + Vec, + Vec, + ) = sample_data.into_iter().fold( + (Vec::new(), Vec::new(), Vec::new()), + |(mut pi, mut pa, mut rf), (payment_intent, payment_attempt, refund)| { + pi.push(payment_intent); + pa.push(payment_attempt); + if let Some(refund) = refund { + rf.push(refund); + } + (pi, pa, rf) + }, + ); + + state + .store + .insert_payment_intents_batch_for_sample_data(payment_intents) + .await + .switch()?; + state + .store + .insert_payment_attempts_batch_for_sample_data(payment_attempts) + .await + .switch()?; + state + .store + .insert_refunds_batch_for_sample_data(refunds) + .await + .switch()?; + + Ok(ApplicationResponse::StatusOk) +} + +pub async fn delete_sample_data_for_user( + state: AppState, + user_from_token: UserFromToken, + _req: SampleDataRequest, +) -> SampleDataApiResponse<()> { + let merchant_id_del = user_from_token.merchant_id.as_str(); + + state + .store + .delete_payment_intents_for_sample_data(merchant_id_del) + .await + .switch()?; + state + .store + .delete_payment_attempts_for_sample_data(merchant_id_del) + .await + .switch()?; + state + .store + .delete_refunds_for_sample_data(merchant_id_del) + .await + .switch()?; + + Ok(ApplicationResponse::StatusOk) +} diff --git a/crates/router/src/db.rs b/crates/router/src/db.rs index 086a09b805c6..6558cc6ace50 100644 --- a/crates/router/src/db.rs +++ b/crates/router/src/db.rs @@ -100,6 +100,7 @@ pub trait StorageInterface: + gsm::GsmInterface + user::UserInterface + user_role::UserRoleInterface + + user::sample_data::BatchSampleDataInterface + 'static { fn get_scheduler_db(&self) -> Box; diff --git a/crates/router/src/db/dashboard_metadata.rs b/crates/router/src/db/dashboard_metadata.rs index 2e8129398ca3..ec24b4ed07da 100644 --- a/crates/router/src/db/dashboard_metadata.rs +++ b/crates/router/src/db/dashboard_metadata.rs @@ -14,6 +14,14 @@ pub trait DashboardMetadataInterface { &self, metadata: storage::DashboardMetadataNew, ) -> CustomResult; + async fn update_metadata( + &self, + user_id: Option, + merchant_id: String, + org_id: String, + data_key: enums::DashboardMetadata, + dashboard_metadata_update: storage::DashboardMetadataUpdate, + ) -> CustomResult; async fn find_user_scoped_dashboard_metadata( &self, @@ -44,6 +52,28 @@ impl DashboardMetadataInterface for Store { .into_report() } + async fn update_metadata( + &self, + user_id: Option, + merchant_id: String, + org_id: String, + data_key: enums::DashboardMetadata, + dashboard_metadata_update: storage::DashboardMetadataUpdate, + ) -> CustomResult { + let conn = connection::pg_connection_write(self).await?; + storage::DashboardMetadata::update( + &conn, + user_id, + merchant_id, + org_id, + data_key, + dashboard_metadata_update, + ) + .await + .map_err(Into::into) + .into_report() + } + async fn find_user_scoped_dashboard_metadata( &self, user_id: &str, @@ -121,6 +151,41 @@ impl DashboardMetadataInterface for MockDb { Ok(metadata_new) } + async fn update_metadata( + &self, + user_id: Option, + merchant_id: String, + org_id: String, + data_key: enums::DashboardMetadata, + dashboard_metadata_update: storage::DashboardMetadataUpdate, + ) -> CustomResult { + let mut dashboard_metadata = self.dashboard_metadata.lock().await; + + let dashboard_metadata_to_update = dashboard_metadata + .iter_mut() + .find(|metadata| { + metadata.user_id == user_id + && metadata.merchant_id == merchant_id + && metadata.org_id == org_id + && metadata.data_key == data_key + }) + .ok_or(errors::StorageError::MockDbError)?; + + match dashboard_metadata_update { + storage::DashboardMetadataUpdate::UpdateData { + data_key, + data_value, + last_modified_by, + } => { + dashboard_metadata_to_update.data_key = data_key; + dashboard_metadata_to_update.data_value = data_value; + dashboard_metadata_to_update.last_modified_by = last_modified_by; + dashboard_metadata_to_update.last_modified_at = common_utils::date_time::now(); + } + } + Ok(dashboard_metadata_to_update.clone()) + } + async fn find_user_scoped_dashboard_metadata( &self, user_id: &str, diff --git a/crates/router/src/db/kafka_store.rs b/crates/router/src/db/kafka_store.rs index fcceba7fadba..32548e36b6fb 100644 --- a/crates/router/src/db/kafka_store.rs +++ b/crates/router/src/db/kafka_store.rs @@ -23,7 +23,8 @@ use storage_impl::redis::kv_store::RedisConnInterface; use time::PrimitiveDateTime; use super::{ - dashboard_metadata::DashboardMetadataInterface, user::UserInterface, + dashboard_metadata::DashboardMetadataInterface, + user::{sample_data::BatchSampleDataInterface, UserInterface}, user_role::UserRoleInterface, }; use crate::{ @@ -1877,6 +1878,15 @@ impl UserInterface for KafkaStore { ) -> CustomResult { self.diesel_store.delete_user_by_user_id(user_id).await } + + async fn find_users_and_roles_by_merchant_id( + &self, + merchant_id: &str, + ) -> CustomResult, errors::StorageError> { + self.diesel_store + .find_users_and_roles_by_merchant_id(merchant_id) + .await + } } impl RedisConnInterface for KafkaStore { @@ -1929,6 +1939,25 @@ impl DashboardMetadataInterface for KafkaStore { self.diesel_store.insert_metadata(metadata).await } + async fn update_metadata( + &self, + user_id: Option, + merchant_id: String, + org_id: String, + data_key: enums::DashboardMetadata, + dashboard_metadata_update: storage::DashboardMetadataUpdate, + ) -> CustomResult { + self.diesel_store + .update_metadata( + user_id, + merchant_id, + org_id, + data_key, + dashboard_metadata_update, + ) + .await + } + async fn find_user_scoped_dashboard_metadata( &self, user_id: &str, @@ -1951,3 +1980,118 @@ impl DashboardMetadataInterface for KafkaStore { .await } } + +#[async_trait::async_trait] +impl BatchSampleDataInterface for KafkaStore { + async fn insert_payment_intents_batch_for_sample_data( + &self, + batch: Vec, + ) -> CustomResult, data_models::errors::StorageError> + { + let payment_intents_list = self + .diesel_store + .insert_payment_intents_batch_for_sample_data(batch) + .await?; + + for payment_intent in payment_intents_list.iter() { + let _ = self + .kafka_producer + .log_payment_intent(payment_intent, None) + .await; + } + Ok(payment_intents_list) + } + + async fn insert_payment_attempts_batch_for_sample_data( + &self, + batch: Vec, + ) -> CustomResult< + Vec, + data_models::errors::StorageError, + > { + let payment_attempts_list = self + .diesel_store + .insert_payment_attempts_batch_for_sample_data(batch) + .await?; + + for payment_attempt in payment_attempts_list.iter() { + let _ = self + .kafka_producer + .log_payment_attempt(payment_attempt, None) + .await; + } + Ok(payment_attempts_list) + } + + async fn insert_refunds_batch_for_sample_data( + &self, + batch: Vec, + ) -> CustomResult, data_models::errors::StorageError> { + let refunds_list = self + .diesel_store + .insert_refunds_batch_for_sample_data(batch) + .await?; + + for refund in refunds_list.iter() { + let _ = self.kafka_producer.log_refund(refund, None).await; + } + Ok(refunds_list) + } + + async fn delete_payment_intents_for_sample_data( + &self, + merchant_id: &str, + ) -> CustomResult, data_models::errors::StorageError> + { + let payment_intents_list = self + .diesel_store + .delete_payment_intents_for_sample_data(merchant_id) + .await?; + + for payment_intent in payment_intents_list.iter() { + let _ = self + .kafka_producer + .log_payment_intent_delete(payment_intent) + .await; + } + Ok(payment_intents_list) + } + + async fn delete_payment_attempts_for_sample_data( + &self, + merchant_id: &str, + ) -> CustomResult< + Vec, + data_models::errors::StorageError, + > { + let payment_attempts_list = self + .diesel_store + .delete_payment_attempts_for_sample_data(merchant_id) + .await?; + + for payment_attempt in payment_attempts_list.iter() { + let _ = self + .kafka_producer + .log_payment_attempt_delete(payment_attempt) + .await; + } + + Ok(payment_attempts_list) + } + + async fn delete_refunds_for_sample_data( + &self, + merchant_id: &str, + ) -> CustomResult, data_models::errors::StorageError> { + let refunds_list = self + .diesel_store + .delete_refunds_for_sample_data(merchant_id) + .await?; + + for refund in refunds_list.iter() { + let _ = self.kafka_producer.log_refund_delete(refund).await; + } + + Ok(refunds_list) + } +} diff --git a/crates/router/src/db/user.rs b/crates/router/src/db/user.rs index 6bb1d9e50b6a..e3dda965f9c9 100644 --- a/crates/router/src/db/user.rs +++ b/crates/router/src/db/user.rs @@ -1,4 +1,4 @@ -use diesel_models::user as storage; +use diesel_models::{user as storage, user_role::UserRole}; use error_stack::{IntoReport, ResultExt}; use masking::Secret; @@ -8,6 +8,7 @@ use crate::{ core::errors::{self, CustomResult}, services::Store, }; +pub mod sample_data; #[async_trait::async_trait] pub trait UserInterface { @@ -36,6 +37,11 @@ pub trait UserInterface { &self, user_id: &str, ) -> CustomResult; + + async fn find_users_and_roles_by_merchant_id( + &self, + merchant_id: &str, + ) -> CustomResult, errors::StorageError>; } #[async_trait::async_trait] @@ -96,6 +102,17 @@ impl UserInterface for Store { .map_err(Into::into) .into_report() } + + async fn find_users_and_roles_by_merchant_id( + &self, + merchant_id: &str, + ) -> CustomResult, errors::StorageError> { + let conn = connection::pg_connection_write(self).await?; + storage::User::find_joined_users_and_roles_by_merchant_id(&conn, merchant_id) + .await + .map_err(Into::into) + .into_report() + } } #[async_trait::async_trait] @@ -221,45 +238,11 @@ impl UserInterface for MockDb { users.remove(user_index); Ok(true) } -} -#[cfg(feature = "kafka_events")] -#[async_trait::async_trait] -impl UserInterface for super::KafkaStore { - async fn insert_user( - &self, - user_data: storage::UserNew, - ) -> CustomResult { - self.diesel_store.insert_user(user_data).await - } - async fn find_user_by_email( + async fn find_users_and_roles_by_merchant_id( &self, - user_email: &str, - ) -> CustomResult { - self.diesel_store.find_user_by_email(user_email).await - } - - async fn find_user_by_id( - &self, - user_id: &str, - ) -> CustomResult { - self.diesel_store.find_user_by_id(user_id).await - } - - async fn update_user_by_user_id( - &self, - user_id: &str, - user: storage::UserUpdate, - ) -> CustomResult { - self.diesel_store - .update_user_by_user_id(user_id, user) - .await - } - - async fn delete_user_by_user_id( - &self, - user_id: &str, - ) -> CustomResult { - self.diesel_store.delete_user_by_user_id(user_id).await + _merchant_id: &str, + ) -> CustomResult, errors::StorageError> { + Err(errors::StorageError::MockDbError)? } } diff --git a/crates/router/src/db/user/sample_data.rs b/crates/router/src/db/user/sample_data.rs new file mode 100644 index 000000000000..11def9026854 --- /dev/null +++ b/crates/router/src/db/user/sample_data.rs @@ -0,0 +1,205 @@ +use data_models::{ + errors::StorageError, + payments::{payment_attempt::PaymentAttempt, payment_intent::PaymentIntentNew, PaymentIntent}, +}; +use diesel_models::{ + errors::DatabaseError, + query::user::sample_data as sample_data_queries, + refund::{Refund, RefundNew}, + user::sample_data::PaymentAttemptBatchNew, +}; +use error_stack::{Report, ResultExt}; +use storage_impl::DataModelExt; + +use crate::{connection::pg_connection_write, core::errors::CustomResult, services::Store}; + +#[async_trait::async_trait] +pub trait BatchSampleDataInterface { + async fn insert_payment_intents_batch_for_sample_data( + &self, + batch: Vec, + ) -> CustomResult, StorageError>; + + async fn insert_payment_attempts_batch_for_sample_data( + &self, + batch: Vec, + ) -> CustomResult, StorageError>; + + async fn insert_refunds_batch_for_sample_data( + &self, + batch: Vec, + ) -> CustomResult, StorageError>; + + async fn delete_payment_intents_for_sample_data( + &self, + merchant_id: &str, + ) -> CustomResult, StorageError>; + + async fn delete_payment_attempts_for_sample_data( + &self, + merchant_id: &str, + ) -> CustomResult, StorageError>; + + async fn delete_refunds_for_sample_data( + &self, + merchant_id: &str, + ) -> CustomResult, StorageError>; +} + +#[async_trait::async_trait] +impl BatchSampleDataInterface for Store { + async fn insert_payment_intents_batch_for_sample_data( + &self, + batch: Vec, + ) -> CustomResult, StorageError> { + let conn = pg_connection_write(self) + .await + .change_context(StorageError::DatabaseConnectionError)?; + let new_intents = batch.into_iter().map(|i| i.to_storage_model()).collect(); + sample_data_queries::insert_payment_intents(&conn, new_intents) + .await + .map_err(diesel_error_to_data_error) + .map(|v| { + v.into_iter() + .map(PaymentIntent::from_storage_model) + .collect() + }) + } + + async fn insert_payment_attempts_batch_for_sample_data( + &self, + batch: Vec, + ) -> CustomResult, StorageError> { + let conn = pg_connection_write(self) + .await + .change_context(StorageError::DatabaseConnectionError)?; + sample_data_queries::insert_payment_attempts(&conn, batch) + .await + .map_err(diesel_error_to_data_error) + .map(|res| { + res.into_iter() + .map(PaymentAttempt::from_storage_model) + .collect() + }) + } + async fn insert_refunds_batch_for_sample_data( + &self, + batch: Vec, + ) -> CustomResult, StorageError> { + let conn = pg_connection_write(self) + .await + .change_context(StorageError::DatabaseConnectionError)?; + sample_data_queries::insert_refunds(&conn, batch) + .await + .map_err(diesel_error_to_data_error) + } + + async fn delete_payment_intents_for_sample_data( + &self, + merchant_id: &str, + ) -> CustomResult, StorageError> { + let conn = pg_connection_write(self) + .await + .change_context(StorageError::DatabaseConnectionError)?; + sample_data_queries::delete_payment_intents(&conn, merchant_id) + .await + .map_err(diesel_error_to_data_error) + .map(|v| { + v.into_iter() + .map(PaymentIntent::from_storage_model) + .collect() + }) + } + + async fn delete_payment_attempts_for_sample_data( + &self, + merchant_id: &str, + ) -> CustomResult, StorageError> { + let conn = pg_connection_write(self) + .await + .change_context(StorageError::DatabaseConnectionError)?; + sample_data_queries::delete_payment_attempts(&conn, merchant_id) + .await + .map_err(diesel_error_to_data_error) + .map(|res| { + res.into_iter() + .map(PaymentAttempt::from_storage_model) + .collect() + }) + } + async fn delete_refunds_for_sample_data( + &self, + merchant_id: &str, + ) -> CustomResult, StorageError> { + let conn = pg_connection_write(self) + .await + .change_context(StorageError::DatabaseConnectionError)?; + sample_data_queries::delete_refunds(&conn, merchant_id) + .await + .map_err(diesel_error_to_data_error) + } +} + +#[async_trait::async_trait] +impl BatchSampleDataInterface for storage_impl::MockDb { + async fn insert_payment_intents_batch_for_sample_data( + &self, + _batch: Vec, + ) -> CustomResult, StorageError> { + Err(StorageError::MockDbError)? + } + + async fn insert_payment_attempts_batch_for_sample_data( + &self, + _batch: Vec, + ) -> CustomResult, StorageError> { + Err(StorageError::MockDbError)? + } + + async fn insert_refunds_batch_for_sample_data( + &self, + _batch: Vec, + ) -> CustomResult, StorageError> { + Err(StorageError::MockDbError)? + } + + async fn delete_payment_intents_for_sample_data( + &self, + _merchant_id: &str, + ) -> CustomResult, StorageError> { + Err(StorageError::MockDbError)? + } + async fn delete_payment_attempts_for_sample_data( + &self, + _merchant_id: &str, + ) -> CustomResult, StorageError> { + Err(StorageError::MockDbError)? + } + async fn delete_refunds_for_sample_data( + &self, + _merchant_id: &str, + ) -> CustomResult, StorageError> { + Err(StorageError::MockDbError)? + } +} + +// TODO: This error conversion is re-used from storage_impl and is not DRY when it should be +// Ideally the impl's here should be defined in that crate avoiding this re-definition +fn diesel_error_to_data_error(diesel_error: Report) -> Report { + let new_err = match diesel_error.current_context() { + DatabaseError::DatabaseConnectionError => StorageError::DatabaseConnectionError, + DatabaseError::NotFound => StorageError::ValueNotFound("Value not found".to_string()), + DatabaseError::UniqueViolation => StorageError::DuplicateValue { + entity: "entity ", + key: None, + }, + DatabaseError::NoFieldsToUpdate => { + StorageError::DatabaseError("No fields to update".to_string()) + } + DatabaseError::QueryGenerationFailed => { + StorageError::DatabaseError("Query generation failed".to_string()) + } + DatabaseError::Others => StorageError::DatabaseError("Others".to_string()), + }; + diesel_error.change_context(new_err) +} diff --git a/crates/router/src/routes/app.rs b/crates/router/src/routes/app.rs index d462f4a27390..a145f3e7e5d7 100644 --- a/crates/router/src/routes/app.rs +++ b/crates/router/src/routes/app.rs @@ -820,8 +820,9 @@ pub struct User; #[cfg(feature = "olap")] impl User { pub fn server(state: AppState) -> Scope { - web::scope("/user") - .app_data(web::Data::new(state)) + let mut route = web::scope("/user").app_data(web::Data::new(state)); + + route = route .service(web::resource("/signin").route(web::post().to(user_connect_account))) .service(web::resource("/signup").route(web::post().to(user_connect_account))) .service(web::resource("/v2/signin").route(web::post().to(user_connect_account))) @@ -838,11 +839,23 @@ impl User { web::resource("/create_merchant") .route(web::post().to(user_merchant_account_create)), ) + .service(web::resource("/switch/list").route(web::get().to(list_merchant_ids_for_user))) + .service(web::resource("/user/list").route(web::get().to(get_user_details))) // User Role APIs .service(web::resource("/permission_info").route(web::get().to(get_authorization_info))) .service(web::resource("/user/update_role").route(web::post().to(update_user_role))) .service(web::resource("/role/list").route(web::get().to(list_roles))) - .service(web::resource("/role/{role_id}").route(web::get().to(get_role))) + .service(web::resource("/role/{role_id}").route(web::get().to(get_role))); + + #[cfg(feature = "dummy_connector")] + { + route = route.service( + web::resource("/sample_data") + .route(web::post().to(generate_sample_data)) + .route(web::delete().to(delete_sample_data)), + ) + } + route } } diff --git a/crates/router/src/routes/lock_utils.rs b/crates/router/src/routes/lock_utils.rs index 552deb85a2e1..6aa2bbad0b15 100644 --- a/crates/router/src/routes/lock_utils.rs +++ b/crates/router/src/routes/lock_utils.rs @@ -155,7 +155,11 @@ impl From for ApiIdentifier { | Flow::VerifyPaymentConnector | Flow::InternalUserSignup | Flow::SwitchMerchant - | Flow::UserMerchantAccountCreate => Self::User, + | Flow::UserMerchantAccountCreate + | Flow::GenerateSampleData + | Flow::DeleteSampleData + | Flow::UserMerchantAccountList + | Flow::GetUserDetails => Self::User, Flow::ListRoles | Flow::GetRole | Flow::UpdateUserRole | Flow::GetAuthorizationInfo => { Self::UserRole diff --git a/crates/router/src/routes/user.rs b/crates/router/src/routes/user.rs index 89c4bd4c90ec..97bd7054da9e 100644 --- a/crates/router/src/routes/user.rs +++ b/crates/router/src/routes/user.rs @@ -1,5 +1,10 @@ use actix_web::{web, HttpRequest, HttpResponse}; -use api_models::{errors::types::ApiErrorResponse, user as user_api}; +#[cfg(feature = "dummy_connector")] +use api_models::user::sample_data::SampleDataRequest; +use api_models::{ + errors::types::ApiErrorResponse, + user::{self as user_api}, +}; use common_utils::errors::ReportSwitchExt; use router_env::Flow; @@ -158,3 +163,75 @@ pub async fn user_merchant_account_create( )) .await } + +#[cfg(feature = "dummy_connector")] +pub async fn generate_sample_data( + state: web::Data, + http_req: HttpRequest, + payload: web::Json, +) -> impl actix_web::Responder { + use crate::core::user::sample_data; + + let flow = Flow::GenerateSampleData; + Box::pin(api::server_wrap( + flow, + state, + &http_req, + payload.into_inner(), + sample_data::generate_sample_data_for_user, + &auth::JWTAuth(Permission::MerchantAccountWrite), + api_locking::LockAction::NotApplicable, + )) + .await +} +#[cfg(feature = "dummy_connector")] +pub async fn delete_sample_data( + state: web::Data, + http_req: HttpRequest, + payload: web::Json, +) -> impl actix_web::Responder { + use crate::core::user::sample_data; + + let flow = Flow::DeleteSampleData; + Box::pin(api::server_wrap( + flow, + state, + &http_req, + payload.into_inner(), + sample_data::delete_sample_data_for_user, + &auth::JWTAuth(Permission::MerchantAccountWrite), + api_locking::LockAction::NotApplicable, + )) + .await +} + +pub async fn list_merchant_ids_for_user( + state: web::Data, + req: HttpRequest, +) -> HttpResponse { + let flow = Flow::UserMerchantAccountList; + Box::pin(api::server_wrap( + flow, + state, + &req, + (), + |state, user, _| user_core::list_merchant_ids_for_user(state, user), + &auth::DashboardNoPermissionAuth, + api_locking::LockAction::NotApplicable, + )) + .await +} + +pub async fn get_user_details(state: web::Data, req: HttpRequest) -> HttpResponse { + let flow = Flow::GetUserDetails; + Box::pin(api::server_wrap( + flow, + state.clone(), + &req, + (), + |state, user, _| user_core::get_users_for_merchant_account(state, user), + &auth::JWTAuth(Permission::UsersRead), + api_locking::LockAction::NotApplicable, + )) + .await +} diff --git a/crates/router/src/services/email/assets/magic_link.html b/crates/router/src/services/email/assets/magic_link.html index 6439c83f227c..643b6e230633 100644 --- a/crates/router/src/services/email/assets/magic_link.html +++ b/crates/router/src/services/email/assets/magic_link.html @@ -2,20 +2,16 @@ Login to Hyperswitch
Welcome to Hyperswitch!

Dear {user_name},

- We are thrilled to welcome you into our community! + + We are thrilled to welcome you into our community! @@ -140,8 +136,8 @@ align="center" >
- Simply click on the link below, and you'll be granted instant access - to your Hyperswitch account. Note that this link expires in 24 hours + Simply click on the link below, and you'll be granted instant access + to your Hyperswitch account. Note that this link expires in 24 hours and can only be used once.
diff --git a/crates/router/src/services/email/types.rs b/crates/router/src/services/email/types.rs index 8650e1c27c22..a4a4681c6001 100644 --- a/crates/router/src/services/email/types.rs +++ b/crates/router/src/services/email/types.rs @@ -5,10 +5,13 @@ use masking::ExposeInterface; use crate::{configs, consts}; #[cfg(feature = "olap")] -use crate::{core::errors::UserErrors, services::jwt, types::domain::UserEmail}; +use crate::{core::errors::UserErrors, services::jwt, types::domain}; pub enum EmailBody { Verify { link: String }, + Reset { link: String, user_name: String }, + MagicLink { link: String, user_name: String }, + InviteUser { link: String, user_name: String }, } pub mod html { @@ -19,6 +22,27 @@ pub mod html { EmailBody::Verify { link } => { format!(include_str!("assets/verify.html"), link = link) } + EmailBody::Reset { link, user_name } => { + format!( + include_str!("assets/reset.html"), + link = link, + username = user_name + ) + } + EmailBody::MagicLink { link, user_name } => { + format!( + include_str!("assets/magic_link.html"), + user_name = user_name, + link = link + ) + } + EmailBody::InviteUser { link, user_name } => { + format!( + include_str!("assets/invite.html"), + username = user_name, + link = link + ) + } } } } @@ -31,7 +55,7 @@ pub struct EmailToken { impl EmailToken { pub async fn new_token( - email: UserEmail, + email: domain::UserEmail, settings: &configs::settings::Settings, ) -> CustomResult { let expiration_duration = std::time::Duration::from_secs(consts::EMAIL_TOKEN_TIME_IN_SECS); @@ -44,35 +68,126 @@ impl EmailToken { } } -pub struct WelcomeEmail { - pub recipient_email: UserEmail, - pub settings: std::sync::Arc, -} - -pub fn get_email_verification_link( +pub fn get_link_with_token( base_url: impl std::fmt::Display, token: impl std::fmt::Display, + action: impl std::fmt::Display, ) -> String { - format!("{base_url}/user/verify_email/?token={token}") + format!("{base_url}/user/{action}/?token={token}") +} + +pub struct VerifyEmail { + pub recipient_email: domain::UserEmail, + pub settings: std::sync::Arc, + pub subject: &'static str, } /// Currently only HTML is supported #[async_trait::async_trait] -impl EmailData for WelcomeEmail { +impl EmailData for VerifyEmail { async fn get_email_data(&self) -> CustomResult { let token = EmailToken::new_token(self.recipient_email.clone(), &self.settings) .await .change_context(EmailError::TokenGenerationFailure)?; - let verify_email_link = get_email_verification_link(&self.settings.server.base_url, token); + let verify_email_link = + get_link_with_token(&self.settings.server.base_url, token, "verify_email"); let body = html::get_html_body(EmailBody::Verify { link: verify_email_link, }); - let subject = "Welcome to the Hyperswitch community!".to_string(); Ok(EmailContents { - subject, + subject: self.subject.to_string(), + body: external_services::email::IntermediateString::new(body), + recipient: self.recipient_email.clone().into_inner(), + }) + } +} + +pub struct ResetPassword { + pub recipient_email: domain::UserEmail, + pub user_name: domain::UserName, + pub settings: std::sync::Arc, + pub subject: &'static str, +} + +#[async_trait::async_trait] +impl EmailData for ResetPassword { + async fn get_email_data(&self) -> CustomResult { + let token = EmailToken::new_token(self.recipient_email.clone(), &self.settings) + .await + .change_context(EmailError::TokenGenerationFailure)?; + + let reset_password_link = + get_link_with_token(&self.settings.server.base_url, token, "set_password"); + + let body = html::get_html_body(EmailBody::Reset { + link: reset_password_link, + user_name: self.user_name.clone().get_secret().expose(), + }); + + Ok(EmailContents { + subject: self.subject.to_string(), + body: external_services::email::IntermediateString::new(body), + recipient: self.recipient_email.clone().into_inner(), + }) + } +} + +pub struct MagicLink { + pub recipient_email: domain::UserEmail, + pub user_name: domain::UserName, + pub settings: std::sync::Arc, + pub subject: &'static str, +} + +#[async_trait::async_trait] +impl EmailData for MagicLink { + async fn get_email_data(&self) -> CustomResult { + let token = EmailToken::new_token(self.recipient_email.clone(), &self.settings) + .await + .change_context(EmailError::TokenGenerationFailure)?; + + let magic_link_login = get_link_with_token(&self.settings.server.base_url, token, "login"); + + let body = html::get_html_body(EmailBody::MagicLink { + link: magic_link_login, + user_name: self.user_name.clone().get_secret().expose(), + }); + + Ok(EmailContents { + subject: self.subject.to_string(), + body: external_services::email::IntermediateString::new(body), + recipient: self.recipient_email.clone().into_inner(), + }) + } +} + +pub struct InviteUser { + pub recipient_email: domain::UserEmail, + pub user_name: domain::UserName, + pub settings: std::sync::Arc, + pub subject: &'static str, +} + +#[async_trait::async_trait] +impl EmailData for InviteUser { + async fn get_email_data(&self) -> CustomResult { + let token = EmailToken::new_token(self.recipient_email.clone(), &self.settings) + .await + .change_context(EmailError::TokenGenerationFailure)?; + + let invite_user_link = + get_link_with_token(&self.settings.server.base_url, token, "set_password"); + + let body = html::get_html_body(EmailBody::MagicLink { + link: invite_user_link, + user_name: self.user_name.clone().get_secret().expose(), + }); + + Ok(EmailContents { + subject: self.subject.to_string(), body: external_services::email::IntermediateString::new(body), recipient: self.recipient_email.clone().into_inner(), }) diff --git a/crates/router/src/types/domain/user.rs b/crates/router/src/types/domain/user.rs index 0c7760f84d36..082b29d80941 100644 --- a/crates/router/src/types/domain/user.rs +++ b/crates/router/src/types/domain/user.rs @@ -27,7 +27,7 @@ use crate::{ routes::AppState, services::{ authentication::{AuthToken, UserFromToken}, - authorization::info, + authorization::{info, predefined_permissions}, }, types::transformers::ForeignFrom, utils::user::password, @@ -671,3 +671,30 @@ impl TryFrom for user_role_api::PermissionInfo { }) } } + +pub struct UserAndRoleJoined(pub storage_user::User, pub UserRole); + +impl TryFrom for user_api::UserDetails { + type Error = (); + fn try_from(user_and_role: UserAndRoleJoined) -> Result { + let status = match user_and_role.1.status { + UserStatus::Active => user_role_api::UserStatus::Active, + UserStatus::InvitationSent => user_role_api::UserStatus::InvitationSent, + }; + + let role_id = user_and_role.1.role_id; + let role_name = predefined_permissions::get_role_name_from_id(role_id.as_str()) + .ok_or(())? + .to_string(); + + Ok(Self { + user_id: user_and_role.0.user_id, + email: user_and_role.0.email, + name: user_and_role.0.name, + role_id, + status, + role_name, + last_modified_at: user_and_role.1.last_modified_at, + }) + } +} diff --git a/crates/router/src/utils/user.rs b/crates/router/src/utils/user.rs index 4dc54ba3f708..696aa4090044 100644 --- a/crates/router/src/utils/user.rs +++ b/crates/router/src/utils/user.rs @@ -1,3 +1,4 @@ +use diesel_models::enums::UserStatus; use error_stack::ResultExt; use crate::{ @@ -9,6 +10,8 @@ use crate::{ pub mod dashboard_metadata; pub mod password; +#[cfg(feature = "dummy_connector")] +pub mod sample_data; impl UserFromToken { pub async fn get_merchant_account(&self, state: AppState) -> UserResult { @@ -49,3 +52,19 @@ impl UserFromToken { Ok(user) } } + +pub async fn get_merchant_ids_for_user(state: AppState, user_id: &str) -> UserResult> { + Ok(state + .store + .list_user_roles_by_user_id(user_id) + .await + .change_context(UserErrors::InternalServerError)? + .into_iter() + .filter_map(|ele| { + if ele.status == UserStatus::Active { + return Some(ele.merchant_id); + } + None + }) + .collect()) +} diff --git a/crates/router/src/utils/user/sample_data.rs b/crates/router/src/utils/user/sample_data.rs new file mode 100644 index 000000000000..7a9cf6d2b7db --- /dev/null +++ b/crates/router/src/utils/user/sample_data.rs @@ -0,0 +1,291 @@ +use api_models::{ + enums::Connector::{DummyConnector4, DummyConnector7}, + user::sample_data::SampleDataRequest, +}; +use data_models::payments::payment_intent::PaymentIntentNew; +use diesel_models::{user::sample_data::PaymentAttemptBatchNew, RefundNew}; +use error_stack::{IntoReport, ResultExt}; +use rand::{prelude::SliceRandom, thread_rng, Rng}; +use time::OffsetDateTime; + +use crate::{ + consts, + core::errors::sample_data::{SampleDataError, SampleDataResult}, + AppState, +}; + +#[allow(clippy::type_complexity)] +pub async fn generate_sample_data( + state: &AppState, + req: SampleDataRequest, + merchant_id: &str, +) -> SampleDataResult)>> { + let merchant_id = merchant_id.to_string(); + let sample_data_size: usize = req.record.unwrap_or(100); + + if !(10..=100).contains(&sample_data_size) { + return Err(SampleDataError::InvalidRange.into()); + } + + let key_store = state + .store + .get_merchant_key_store_by_merchant_id( + merchant_id.as_str(), + &state.store.get_master_key().to_vec().into(), + ) + .await + .change_context(SampleDataError::DatabaseError)?; + + let merchant_from_db = state + .store + .find_merchant_account_by_merchant_id(merchant_id.as_str(), &key_store) + .await + .change_context::(SampleDataError::DataDoesNotExist)?; + + let merchant_parsed_details: Vec = + serde_json::from_value(merchant_from_db.primary_business_details.clone()) + .into_report() + .change_context(SampleDataError::InternalServerError) + .attach_printable("Error while parsing primary business details")?; + + let business_country_default = merchant_parsed_details.get(0).map(|x| x.country); + + let business_label_default = merchant_parsed_details.get(0).map(|x| x.business.clone()); + + let profile_id = crate::core::utils::get_profile_id_from_business_details( + business_country_default, + business_label_default.as_ref(), + &merchant_from_db, + req.profile_id.as_ref(), + &*state.store, + false, + ) + .await + .change_context(SampleDataError::InternalServerError) + .attach_printable("Failed to get business profile")?; + + // 10 percent payments should be failed + #[allow(clippy::as_conversions)] + let failure_attempts = usize::try_from((sample_data_size as f32 / 10.0).round() as i64) + .into_report() + .change_context(SampleDataError::InvalidParameters)?; + + let failure_after_attempts = sample_data_size / failure_attempts; + + // 20 percent refunds for payments + #[allow(clippy::as_conversions)] + let number_of_refunds = usize::try_from((sample_data_size as f32 / 5.0).round() as i64) + .into_report() + .change_context(SampleDataError::InvalidParameters)?; + + let mut refunds_count = 0; + + let mut random_array: Vec = (1..=sample_data_size).collect(); + + // Shuffle the array + let mut rng = thread_rng(); + random_array.shuffle(&mut rng); + + let mut res: Vec<(PaymentIntentNew, PaymentAttemptBatchNew, Option)> = Vec::new(); + let start_time = req + .start_time + .unwrap_or(common_utils::date_time::now() - time::Duration::days(7)) + .assume_utc() + .unix_timestamp(); + let end_time = req + .end_time + .unwrap_or_else(common_utils::date_time::now) + .assume_utc() + .unix_timestamp(); + + let current_time = common_utils::date_time::now().assume_utc().unix_timestamp(); + + let min_amount = req.min_amount.unwrap_or(100); + let max_amount = req.max_amount.unwrap_or(min_amount + 100); + + if min_amount > max_amount + || start_time > end_time + || start_time > current_time + || end_time > current_time + { + return Err(SampleDataError::InvalidParameters.into()); + }; + + let currency_vec = req.currency.unwrap_or(vec![common_enums::Currency::USD]); + let currency_vec_len = currency_vec.len(); + + let connector_vec = req + .connector + .unwrap_or(vec![DummyConnector4, DummyConnector7]); + let connector_vec_len = connector_vec.len(); + + let auth_type = req.auth_type.unwrap_or(vec![ + common_enums::AuthenticationType::ThreeDs, + common_enums::AuthenticationType::NoThreeDs, + ]); + let auth_type_len = auth_type.len(); + + if currency_vec_len == 0 || connector_vec_len == 0 || auth_type_len == 0 { + return Err(SampleDataError::InvalidParameters.into()); + } + + for num in 1..=sample_data_size { + let payment_id = common_utils::generate_id_with_default_len("test"); + let attempt_id = crate::utils::get_payment_attempt_id(&payment_id, 1); + let client_secret = common_utils::generate_id( + consts::ID_LENGTH, + format!("{}_secret", payment_id.clone()).as_str(), + ); + let amount = thread_rng().gen_range(min_amount..=max_amount); + + let created_at @ modified_at @ last_synced = + OffsetDateTime::from_unix_timestamp(thread_rng().gen_range(start_time..=end_time)) + .map(common_utils::date_time::convert_to_pdt) + .unwrap_or( + req.start_time.unwrap_or_else(|| { + common_utils::date_time::now() - time::Duration::days(7) + }), + ); + + // After some set of payments sample data will have a failed attempt + let is_failed_payment = + (random_array.get(num - 1).unwrap_or(&0) % failure_after_attempts) == 0; + + let payment_intent = PaymentIntentNew { + payment_id: payment_id.clone(), + merchant_id: merchant_id.clone(), + status: match is_failed_payment { + true => common_enums::IntentStatus::Failed, + _ => common_enums::IntentStatus::Succeeded, + }, + amount: amount * 100, + currency: Some( + *currency_vec + .get((num - 1) % currency_vec_len) + .unwrap_or(&common_enums::Currency::USD), + ), + description: Some("This is a sample payment".to_string()), + created_at: Some(created_at), + modified_at: Some(modified_at), + last_synced: Some(last_synced), + client_secret: Some(client_secret), + business_country: business_country_default, + business_label: business_label_default.clone(), + active_attempt: data_models::RemoteStorageObject::ForeignID(attempt_id.clone()), + attempt_count: 1, + customer_id: Some("hs-dashboard-user".to_string()), + amount_captured: Some(amount * 100), + profile_id: Some(profile_id.clone()), + return_url: Default::default(), + metadata: Default::default(), + connector_id: Default::default(), + shipping_address_id: Default::default(), + billing_address_id: Default::default(), + statement_descriptor_name: Default::default(), + statement_descriptor_suffix: Default::default(), + setup_future_usage: Default::default(), + off_session: Default::default(), + order_details: Default::default(), + allowed_payment_method_types: Default::default(), + connector_metadata: Default::default(), + feature_metadata: Default::default(), + merchant_decision: Default::default(), + payment_link_id: Default::default(), + payment_confirm_source: Default::default(), + updated_by: merchant_from_db.storage_scheme.to_string(), + surcharge_applicable: Default::default(), + request_incremental_authorization: Default::default(), + incremental_authorization_allowed: Default::default(), + }; + let payment_attempt = PaymentAttemptBatchNew { + attempt_id: attempt_id.clone(), + payment_id: payment_id.clone(), + connector_transaction_id: Some(attempt_id.clone()), + merchant_id: merchant_id.clone(), + status: match is_failed_payment { + true => common_enums::AttemptStatus::Failure, + _ => common_enums::AttemptStatus::Charged, + }, + amount: amount * 100, + currency: payment_intent.currency, + connector: Some( + (*connector_vec + .get((num - 1) % connector_vec_len) + .unwrap_or(&DummyConnector4)) + .to_string(), + ), + payment_method: Some(common_enums::PaymentMethod::Card), + payment_method_type: Some(get_payment_method_type(thread_rng().gen_range(1..=2))), + authentication_type: Some( + *auth_type + .get((num - 1) % auth_type_len) + .unwrap_or(&common_enums::AuthenticationType::NoThreeDs), + ), + error_message: match is_failed_payment { + true => Some("This is a test payment which has a failed status".to_string()), + _ => None, + }, + error_code: match is_failed_payment { + true => Some("HS001".to_string()), + _ => None, + }, + confirm: true, + created_at: Some(created_at), + modified_at: Some(modified_at), + last_synced: Some(last_synced), + amount_to_capture: Some(amount * 100), + connector_response_reference_id: Some(attempt_id.clone()), + updated_by: merchant_from_db.storage_scheme.to_string(), + + ..Default::default() + }; + + let refund = if refunds_count < number_of_refunds && !is_failed_payment { + refunds_count += 1; + Some(RefundNew { + refund_id: common_utils::generate_id_with_default_len("test"), + internal_reference_id: common_utils::generate_id_with_default_len("test"), + external_reference_id: None, + payment_id: payment_id.clone(), + attempt_id: attempt_id.clone(), + merchant_id: merchant_id.clone(), + connector_transaction_id: attempt_id.clone(), + connector_refund_id: None, + description: Some("This is a sample refund".to_string()), + created_at: Some(created_at), + modified_at: Some(modified_at), + refund_reason: Some("Sample Refund".to_string()), + connector: payment_attempt + .connector + .clone() + .unwrap_or(DummyConnector4.to_string()), + currency: *currency_vec + .get((num - 1) % currency_vec_len) + .unwrap_or(&common_enums::Currency::USD), + total_amount: amount * 100, + refund_amount: amount * 100, + refund_status: common_enums::RefundStatus::Success, + sent_to_gateway: true, + refund_type: diesel_models::enums::RefundType::InstantRefund, + metadata: None, + refund_arn: None, + profile_id: payment_intent.profile_id.clone(), + updated_by: merchant_from_db.storage_scheme.to_string(), + merchant_connector_id: payment_attempt.merchant_connector_id.clone(), + }) + } else { + None + }; + + res.push((payment_intent, payment_attempt, refund)); + } + Ok(res) +} + +fn get_payment_method_type(num: u8) -> common_enums::PaymentMethodType { + let rem: u8 = (num) % 2; + match rem { + 0 => common_enums::PaymentMethodType::Debit, + _ => common_enums::PaymentMethodType::Credit, + } +} diff --git a/crates/router_env/src/logger/types.rs b/crates/router_env/src/logger/types.rs index eefdc86affad..f54a5a82baaf 100644 --- a/crates/router_env/src/logger/types.rs +++ b/crates/router_env/src/logger/types.rs @@ -279,6 +279,14 @@ pub enum Flow { UpdateUserRole, /// Create merchant account for user in a org UserMerchantAccountCreate, + /// Generate Sample Data + GenerateSampleData, + /// Delete Sample Data + DeleteSampleData, + /// List merchant accounts for user + UserMerchantAccountList, + /// Get users for merchant account + GetUserDetails, } /// diff --git a/migrations/2023-11-23-100644_create_dashboard_metadata_table/up.sql b/migrations/2023-11-23-100644_create_dashboard_metadata_table/up.sql index 8296f755f543..4a74afb9ad0e 100644 --- a/migrations/2023-11-23-100644_create_dashboard_metadata_table/up.sql +++ b/migrations/2023-11-23-100644_create_dashboard_metadata_table/up.sql @@ -1,15 +1,21 @@ -- Your SQL goes here + CREATE TABLE IF NOT EXISTS dashboard_metadata ( - id SERIAL PRIMARY KEY, - user_id VARCHAR(64), - merchant_id VARCHAR(64) NOT NULL, - org_id VARCHAR(64) NOT NULL, - data_key VARCHAR(64) NOT NULL, - data_value JSON NOT NULL, - created_by VARCHAR(64) NOT NULL, - created_at TIMESTAMP NOT NULL DEFAULT now(), - last_modified_by VARCHAR(64) NOT NULL, - last_modified_at TIMESTAMP NOT NULL DEFAULT now() -); + id SERIAL PRIMARY KEY, + user_id VARCHAR(64), + merchant_id VARCHAR(64) NOT NULL, + org_id VARCHAR(64) NOT NULL, + data_key VARCHAR(64) NOT NULL, + data_value JSON NOT NULL, + created_by VARCHAR(64) NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT now(), + last_modified_by VARCHAR(64) NOT NULL, + last_modified_at TIMESTAMP NOT NULL DEFAULT now() + ); -CREATE UNIQUE INDEX IF NOT EXISTS dashboard_metadata_index ON dashboard_metadata (COALESCE(user_id,'0'), merchant_id, org_id, data_key); \ No newline at end of file +CREATE UNIQUE INDEX IF NOT EXISTS dashboard_metadata_index ON dashboard_metadata ( + COALESCE(user_id, '0'), + merchant_id, + org_id, + data_key +); \ No newline at end of file diff --git a/postman/collection-dir/adyen_uk/Flow Testcases/Variation Cases/Scenario8-Refund for unsuccessful payment/Refunds - Create/event.test.js b/postman/collection-dir/adyen_uk/Flow Testcases/Variation Cases/Scenario8-Refund for unsuccessful payment/Refunds - Create/event.test.js index 6731d57fb694..b88beefec22e 100644 --- a/postman/collection-dir/adyen_uk/Flow Testcases/Variation Cases/Scenario8-Refund for unsuccessful payment/Refunds - Create/event.test.js +++ b/postman/collection-dir/adyen_uk/Flow Testcases/Variation Cases/Scenario8-Refund for unsuccessful payment/Refunds - Create/event.test.js @@ -50,10 +50,10 @@ if (jsonData?.error?.type) { // Response body should have value "invalid_request" for "error type" if (jsonData?.error?.message) { pm.test( - "[POST]::/payments - Content check if value for 'error.message' matches 'The payment has not succeeded yet. Please pass a successful payment to initiate refund'", + "[POST]::/payments - Content check if value for 'error.message' matches 'This Payment could not be refund because it has a status of requires_confirmation. The expected state is succeeded, partially_captured'", function () { pm.expect(jsonData.error.message).to.eql( - "The payment has not succeeded yet. Please pass a successful payment to initiate refund", + "This Payment could not be refund because it has a status of requires_confirmation. The expected state is succeeded, partially_captured", ); }, );