From 58cc8d6109ce49d385b06c762ab3f6670f5094eb Mon Sep 17 00:00:00 2001 From: Mani Chandra <84711804+ThisIsMani@users.noreply.github.com> Date: Tue, 16 Jan 2024 13:06:47 +0530 Subject: [PATCH 1/3] fix(connector_onboarding): Check if connector exists for the merchant account and add reset tracking id API (#3229) --- crates/api_models/src/connector_onboarding.rs | 6 + .../src/events/connector_onboarding.rs | 4 +- crates/router/src/consts.rs | 3 + .../router/src/core/connector_onboarding.rs | 44 +++++-- .../src/core/connector_onboarding/paypal.rs | 20 ++-- crates/router/src/routes/app.rs | 1 + .../router/src/routes/connector_onboarding.rs | 21 +++- crates/router/src/routes/lock_utils.rs | 4 +- .../router/src/utils/connector_onboarding.rs | 109 +++++++++++++++++- crates/router_env/src/logger/types.rs | 2 + 10 files changed, 186 insertions(+), 28 deletions(-) diff --git a/crates/api_models/src/connector_onboarding.rs b/crates/api_models/src/connector_onboarding.rs index 759d3cb97f13..7e8288d9747f 100644 --- a/crates/api_models/src/connector_onboarding.rs +++ b/crates/api_models/src/connector_onboarding.rs @@ -52,3 +52,9 @@ pub struct PayPalOnboardingDone { pub struct PayPalIntegrationDone { pub connector_id: String, } + +#[derive(serde::Deserialize, serde::Serialize, Debug, Clone)] +pub struct ResetTrackingIdRequest { + pub connector_id: String, + pub connector: enums::Connector, +} diff --git a/crates/api_models/src/events/connector_onboarding.rs b/crates/api_models/src/events/connector_onboarding.rs index 998dc384d620..0da89f61da7e 100644 --- a/crates/api_models/src/events/connector_onboarding.rs +++ b/crates/api_models/src/events/connector_onboarding.rs @@ -2,11 +2,13 @@ use common_utils::events::{ApiEventMetric, ApiEventsType}; use crate::connector_onboarding::{ ActionUrlRequest, ActionUrlResponse, OnboardingStatus, OnboardingSyncRequest, + ResetTrackingIdRequest, }; common_utils::impl_misc_api_event_type!( ActionUrlRequest, ActionUrlResponse, OnboardingSyncRequest, - OnboardingStatus + OnboardingStatus, + ResetTrackingIdRequest ); diff --git a/crates/router/src/consts.rs b/crates/router/src/consts.rs index ed020b0c7e0f..387da3c06415 100644 --- a/crates/router/src/consts.rs +++ b/crates/router/src/consts.rs @@ -77,6 +77,9 @@ pub const VERIFY_CONNECTOR_ID_PREFIX: &str = "conn_verify"; #[cfg(feature = "olap")] pub const VERIFY_CONNECTOR_MERCHANT_ID: &str = "test_merchant"; +#[cfg(feature = "olap")] +pub const CONNECTOR_ONBOARDING_CONFIG_PREFIX: &str = "onboarding"; + /// Max payment session expiry pub const MAX_SESSION_EXPIRY: u32 = 7890000; diff --git a/crates/router/src/core/connector_onboarding.rs b/crates/router/src/core/connector_onboarding.rs index e48026edc2d5..e6c1fc9d378d 100644 --- a/crates/router/src/core/connector_onboarding.rs +++ b/crates/router/src/core/connector_onboarding.rs @@ -1,5 +1,4 @@ use api_models::{connector_onboarding as api, enums}; -use error_stack::ResultExt; use masking::Secret; use crate::{ @@ -19,16 +18,23 @@ pub trait AccessToken { pub async fn get_action_url( state: AppState, + user_from_token: auth::UserFromToken, request: api::ActionUrlRequest, ) -> RouterResponse { + utils::check_if_connector_exists(&state, &request.connector_id, &user_from_token.merchant_id) + .await?; + let connector_onboarding_conf = state.conf.connector_onboarding.clone(); let is_enabled = utils::is_enabled(request.connector, &connector_onboarding_conf); + let tracking_id = + utils::get_tracking_id_from_configs(&state, &request.connector_id, request.connector) + .await?; match (is_enabled, request.connector) { (Some(true), enums::Connector::Paypal) => { let action_url = Box::pin(paypal::get_action_url_from_paypal( state, - request.connector_id, + tracking_id, request.return_url, )) .await?; @@ -49,40 +55,42 @@ pub async fn sync_onboarding_status( user_from_token: auth::UserFromToken, request: api::OnboardingSyncRequest, ) -> RouterResponse { - let merchant_account = user_from_token - .get_merchant_account(state.clone()) - .await - .change_context(ApiErrorResponse::MerchantAccountNotFound)?; + utils::check_if_connector_exists(&state, &request.connector_id, &user_from_token.merchant_id) + .await?; + let connector_onboarding_conf = state.conf.connector_onboarding.clone(); let is_enabled = utils::is_enabled(request.connector, &connector_onboarding_conf); + let tracking_id = + utils::get_tracking_id_from_configs(&state, &request.connector_id, request.connector) + .await?; match (is_enabled, request.connector) { (Some(true), enums::Connector::Paypal) => { let status = Box::pin(paypal::sync_merchant_onboarding_status( state.clone(), - request.connector_id.clone(), + tracking_id, )) .await?; if let api::OnboardingStatus::PayPal(api::PayPalOnboardingStatus::Success( - ref inner_data, + ref paypal_onboarding_data, )) = status { let connector_onboarding_conf = state.conf.connector_onboarding.clone(); let auth_details = oss_types::ConnectorAuthType::SignatureKey { api_key: connector_onboarding_conf.paypal.client_secret, key1: connector_onboarding_conf.paypal.client_id, - api_secret: Secret::new(inner_data.payer_id.clone()), + api_secret: Secret::new(paypal_onboarding_data.payer_id.clone()), }; - let some_data = paypal::update_mca( + let update_mca_data = paypal::update_mca( &state, - &merchant_account, + user_from_token.merchant_id, request.connector_id.to_owned(), auth_details, ) .await?; return Ok(ApplicationResponse::Json(api::OnboardingStatus::PayPal( - api::PayPalOnboardingStatus::ConnectorIntegrated(some_data), + api::PayPalOnboardingStatus::ConnectorIntegrated(update_mca_data), ))); } Ok(ApplicationResponse::Json(status)) @@ -94,3 +102,15 @@ pub async fn sync_onboarding_status( .into()), } } + +pub async fn reset_tracking_id( + state: AppState, + user_from_token: auth::UserFromToken, + request: api::ResetTrackingIdRequest, +) -> RouterResponse<()> { + utils::check_if_connector_exists(&state, &request.connector_id, &user_from_token.merchant_id) + .await?; + utils::set_tracking_id_in_configs(&state, &request.connector_id, request.connector).await?; + + Ok(ApplicationResponse::StatusOk) +} diff --git a/crates/router/src/core/connector_onboarding/paypal.rs b/crates/router/src/core/connector_onboarding/paypal.rs index 30aa69067b5d..f18681f8cfdb 100644 --- a/crates/router/src/core/connector_onboarding/paypal.rs +++ b/crates/router/src/core/connector_onboarding/paypal.rs @@ -23,11 +23,11 @@ fn build_referral_url(state: AppState) -> String { async fn build_referral_request( state: AppState, - connector_id: String, + tracking_id: String, return_url: String, ) -> RouterResult { let access_token = utils::paypal::generate_access_token(state.clone()).await?; - let request_body = types::paypal::PartnerReferralRequest::new(connector_id, return_url); + let request_body = types::paypal::PartnerReferralRequest::new(tracking_id, return_url); utils::paypal::build_paypal_post_request( build_referral_url(state), @@ -38,12 +38,12 @@ async fn build_referral_request( pub async fn get_action_url_from_paypal( state: AppState, - connector_id: String, + tracking_id: String, return_url: String, ) -> RouterResult { let referral_request = Box::pin(build_referral_request( state.clone(), - connector_id, + tracking_id, return_url, )) .await?; @@ -137,7 +137,7 @@ async fn find_paypal_merchant_by_tracking_id( pub async fn update_mca( state: &AppState, - merchant_account: &oss_types::domain::MerchantAccount, + merchant_id: String, connector_id: String, auth_details: oss_types::ConnectorAuthType, ) -> RouterResult { @@ -159,13 +159,9 @@ pub async fn update_mca( connector_webhook_details: None, pm_auth_config: None, }; - let mca_response = admin::update_payment_connector( - state.clone(), - &merchant_account.merchant_id, - &connector_id, - request, - ) - .await?; + let mca_response = + admin::update_payment_connector(state.clone(), &merchant_id, &connector_id, request) + .await?; match mca_response { ApplicationResponse::Json(mca_data) => Ok(mca_data), diff --git a/crates/router/src/routes/app.rs b/crates/router/src/routes/app.rs index 0b2acaf4e506..77253d1d75c4 100644 --- a/crates/router/src/routes/app.rs +++ b/crates/router/src/routes/app.rs @@ -961,5 +961,6 @@ impl ConnectorOnboarding { .app_data(web::Data::new(state)) .service(web::resource("/action_url").route(web::post().to(get_action_url))) .service(web::resource("/sync").route(web::post().to(sync_onboarding_status))) + .service(web::resource("/reset_tracking_id").route(web::post().to(reset_tracking_id))) } } diff --git a/crates/router/src/routes/connector_onboarding.rs b/crates/router/src/routes/connector_onboarding.rs index b7c39b3c1d2e..f5555f5bf9bf 100644 --- a/crates/router/src/routes/connector_onboarding.rs +++ b/crates/router/src/routes/connector_onboarding.rs @@ -20,7 +20,7 @@ pub async fn get_action_url( state, &http_req, req_payload.clone(), - |state, _: auth::UserFromToken, req| core::get_action_url(state, req), + core::get_action_url, &auth::JWTAuth(Permission::MerchantAccountWrite), api_locking::LockAction::NotApplicable, )) @@ -45,3 +45,22 @@ pub async fn sync_onboarding_status( )) .await } + +pub async fn reset_tracking_id( + state: web::Data, + http_req: HttpRequest, + json_payload: web::Json, +) -> HttpResponse { + let flow = Flow::ResetTrackingId; + let req_payload = json_payload.into_inner(); + Box::pin(api::server_wrap( + flow.clone(), + state, + &http_req, + req_payload.clone(), + core::reset_tracking_id, + &auth::JWTAuth(Permission::MerchantAccountWrite), + api_locking::LockAction::NotApplicable, + )) + .await +} diff --git a/crates/router/src/routes/lock_utils.rs b/crates/router/src/routes/lock_utils.rs index 55c6cbc23d70..c560f0d988af 100644 --- a/crates/router/src/routes/lock_utils.rs +++ b/crates/router/src/routes/lock_utils.rs @@ -183,7 +183,9 @@ impl From for ApiIdentifier { Self::UserRole } - Flow::GetActionUrl | Flow::SyncOnboardingStatus => Self::ConnectorOnboarding, + Flow::GetActionUrl | Flow::SyncOnboardingStatus | Flow::ResetTrackingId => { + Self::ConnectorOnboarding + } } } } diff --git a/crates/router/src/utils/connector_onboarding.rs b/crates/router/src/utils/connector_onboarding.rs index e8afcd68a468..03735e61cc70 100644 --- a/crates/router/src/utils/connector_onboarding.rs +++ b/crates/router/src/utils/connector_onboarding.rs @@ -1,6 +1,11 @@ +use diesel_models::{ConfigNew, ConfigUpdate}; +use error_stack::ResultExt; + +use super::errors::StorageErrorExt; use crate::{ + consts, core::errors::{api_error_response::NotImplementedMessage, ApiErrorResponse, RouterResult}, - routes::app::settings, + routes::{app::settings, AppState}, types::{self, api::enums}, }; @@ -34,3 +39,105 @@ pub fn is_enabled( _ => None, } } + +pub async fn check_if_connector_exists( + state: &AppState, + connector_id: &str, + merchant_id: &str, +) -> RouterResult<()> { + let key_store = state + .store + .get_merchant_key_store_by_merchant_id( + merchant_id, + &state.store.get_master_key().to_vec().into(), + ) + .await + .to_not_found_response(ApiErrorResponse::MerchantAccountNotFound)?; + + let _connector = state + .store + .find_by_merchant_connector_account_merchant_id_merchant_connector_id( + merchant_id, + connector_id, + &key_store, + ) + .await + .to_not_found_response(ApiErrorResponse::MerchantConnectorAccountNotFound { + id: connector_id.to_string(), + })?; + + Ok(()) +} + +pub async fn set_tracking_id_in_configs( + state: &AppState, + connector_id: &str, + connector: enums::Connector, +) -> RouterResult<()> { + let timestamp = common_utils::date_time::now_unix_timestamp().to_string(); + let find_config = state + .store + .find_config_by_key(&build_key(connector_id, connector)) + .await; + + if find_config.is_ok() { + state + .store + .update_config_by_key( + &build_key(connector_id, connector), + ConfigUpdate::Update { + config: Some(timestamp), + }, + ) + .await + .change_context(ApiErrorResponse::InternalServerError) + .attach_printable("Error updating data in configs table")?; + } else if find_config + .as_ref() + .map_err(|e| e.current_context().is_db_not_found()) + .err() + .unwrap_or(false) + { + state + .store + .insert_config(ConfigNew { + key: build_key(connector_id, connector), + config: timestamp, + }) + .await + .change_context(ApiErrorResponse::InternalServerError) + .attach_printable("Error inserting data in configs table")?; + } else { + find_config.change_context(ApiErrorResponse::InternalServerError)?; + } + + Ok(()) +} + +pub async fn get_tracking_id_from_configs( + state: &AppState, + connector_id: &str, + connector: enums::Connector, +) -> RouterResult { + let timestamp = state + .store + .find_config_by_key_unwrap_or( + &build_key(connector_id, connector), + Some(common_utils::date_time::now_unix_timestamp().to_string()), + ) + .await + .change_context(ApiErrorResponse::InternalServerError) + .attach_printable("Error getting data from configs table")? + .config; + + Ok(format!("{}_{}", connector_id, timestamp)) +} + +fn build_key(connector_id: &str, connector: enums::Connector) -> String { + format!( + "{}_{}_{}", + consts::CONNECTOR_ONBOARDING_CONFIG_PREFIX, + connector, + connector_id, + ) +} diff --git a/crates/router_env/src/logger/types.rs b/crates/router_env/src/logger/types.rs index a6ac1b1e0a14..8f0b9bad3e80 100644 --- a/crates/router_env/src/logger/types.rs +++ b/crates/router_env/src/logger/types.rs @@ -317,6 +317,8 @@ pub enum Flow { GetActionUrl, /// Sync connector onboarding status SyncOnboardingStatus, + /// Reset tracking id + ResetTrackingId, /// Verify email Token VerifyEmail, /// Send verify email From 5ad3f8939afafce3eec39704dcaa92270b384dcd Mon Sep 17 00:00:00 2001 From: Sahkal Poddar Date: Tue, 16 Jan 2024 13:43:19 +0530 Subject: [PATCH 2/3] fix(payment_link): added expires_on in payment response (#3332) Co-authored-by: hyperswitch-bot[bot] <148525504+hyperswitch-bot[bot]@users.noreply.github.com> --- crates/api_models/src/payments.rs | 5 +++++ crates/router/src/core/payments/transformers.rs | 2 ++ openapi/openapi_spec.json | 7 +++++++ 3 files changed, 14 insertions(+) diff --git a/crates/api_models/src/payments.rs b/crates/api_models/src/payments.rs index cac94a07326a..06bd229586d9 100644 --- a/crates/api_models/src/payments.rs +++ b/crates/api_models/src/payments.rs @@ -2277,6 +2277,11 @@ pub struct PaymentsResponse { /// List of incremental authorizations happened to the payment pub incremental_authorizations: Option>, + /// Date Time expiry of the payment + #[schema(example = "2022-09-10T10:11:12Z")] + #[serde(default, with = "common_utils::custom_serde::iso8601::option")] + pub expires_on: Option, + /// Payment Fingerprint pub fingerprint: Option, } diff --git a/crates/router/src/core/payments/transformers.rs b/crates/router/src/core/payments/transformers.rs index dffcff23595b..5ab6bffc8e6d 100644 --- a/crates/router/src/core/payments/transformers.rs +++ b/crates/router/src/core/payments/transformers.rs @@ -709,6 +709,7 @@ where .set_fingerprint(payment_intent.fingerprint_id) .set_authorization_count(payment_intent.authorization_count) .set_incremental_authorizations(incremental_authorizations_response) + .set_expires_on(payment_intent.session_expiry) .to_owned(), headers, )) @@ -775,6 +776,7 @@ where incremental_authorization_allowed: payment_intent.incremental_authorization_allowed, authorization_count: payment_intent.authorization_count, incremental_authorizations: incremental_authorizations_response, + expires_on: payment_intent.session_expiry, ..Default::default() }, headers, diff --git a/openapi/openapi_spec.json b/openapi/openapi_spec.json index c50f687a1810..466489e2f9f9 100644 --- a/openapi/openapi_spec.json +++ b/openapi/openapi_spec.json @@ -10948,6 +10948,13 @@ "description": "List of incremental authorizations happened to the payment", "nullable": true }, + "expires_on": { + "type": "string", + "format": "date-time", + "description": "Date Time expiry of the payment", + "example": "2022-09-10T10:11:12Z", + "nullable": true + }, "fingerprint": { "type": "string", "description": "Payment Fingerprint", From 8678f8d1448b5ce430931bfbbc269ef979d9eea7 Mon Sep 17 00:00:00 2001 From: Kashif <46213975+kashif-m@users.noreply.github.com> Date: Tue, 16 Jan 2024 16:37:44 +0530 Subject: [PATCH 3/3] feat(recon): add recon APIs (#3345) Co-authored-by: Kashif Co-authored-by: hyperswitch-bot[bot] <148525504+hyperswitch-bot[bot]@users.noreply.github.com> --- crates/api_models/Cargo.toml | 3 +- crates/api_models/src/events.rs | 2 + crates/api_models/src/events/recon.rs | 21 ++ crates/api_models/src/events/user.rs | 14 + crates/api_models/src/lib.rs | 2 + crates/api_models/src/recon.rs | 21 ++ crates/api_models/src/user.rs | 7 + crates/common_utils/src/events.rs | 1 + crates/router/Cargo.toml | 3 +- crates/router/src/core/user.rs | 29 ++ crates/router/src/lib.rs | 6 + crates/router/src/routes.rs | 4 + crates/router/src/routes/app.rs | 22 ++ crates/router/src/routes/lock_utils.rs | 6 + crates/router/src/routes/recon.rs | 250 ++++++++++++++++++ crates/router/src/routes/user.rs | 15 ++ crates/router/src/services.rs | 2 + crates/router/src/services/authentication.rs | 96 +++++++ ...n_activated.html => recon_activation.html} | 0 crates/router/src/services/email/types.rs | 105 +++++++- crates/router/src/services/recon.rs | 29 ++ crates/router_env/src/logger/types.rs | 8 + 22 files changed, 639 insertions(+), 7 deletions(-) create mode 100644 crates/api_models/src/events/recon.rs create mode 100644 crates/api_models/src/recon.rs create mode 100644 crates/router/src/routes/recon.rs rename crates/router/src/services/email/assets/{recon_activated.html => recon_activation.html} (100%) create mode 100644 crates/router/src/services/recon.rs diff --git a/crates/api_models/Cargo.toml b/crates/api_models/Cargo.toml index 69980361500c..45702a4ecb0a 100644 --- a/crates/api_models/Cargo.toml +++ b/crates/api_models/Cargo.toml @@ -8,7 +8,7 @@ readme = "README.md" license.workspace = true [features] -default = ["payouts", "frm"] +default = ["payouts", "frm", "recon"] business_profile_routing = [] connector_choice_bcompat = [] errors = ["dep:actix-web", "dep:reqwest"] @@ -18,6 +18,7 @@ dummy_connector = ["euclid/dummy_connector", "common_enums/dummy_connector"] detailed_errors = [] payouts = [] frm = [] +recon = [] [dependencies] actix-web = { version = "4.3.1", optional = true } diff --git a/crates/api_models/src/events.rs b/crates/api_models/src/events.rs index 6d9bd5db3429..26a9d222d6b9 100644 --- a/crates/api_models/src/events.rs +++ b/crates/api_models/src/events.rs @@ -5,6 +5,8 @@ mod locker_migration; pub mod payment; #[cfg(feature = "payouts")] pub mod payouts; +#[cfg(feature = "recon")] +pub mod recon; pub mod refund; pub mod routing; pub mod user; diff --git a/crates/api_models/src/events/recon.rs b/crates/api_models/src/events/recon.rs new file mode 100644 index 000000000000..aed648f4c869 --- /dev/null +++ b/crates/api_models/src/events/recon.rs @@ -0,0 +1,21 @@ +use common_utils::events::{ApiEventMetric, ApiEventsType}; + +use crate::recon::{ReconStatusResponse, ReconTokenResponse, ReconUpdateMerchantRequest}; + +impl ApiEventMetric for ReconUpdateMerchantRequest { + fn get_api_event_type(&self) -> Option { + Some(ApiEventsType::Recon) + } +} + +impl ApiEventMetric for ReconTokenResponse { + fn get_api_event_type(&self) -> Option { + Some(ApiEventsType::Recon) + } +} + +impl ApiEventMetric for ReconStatusResponse { + fn get_api_event_type(&self) -> Option { + Some(ApiEventsType::Recon) + } +} diff --git a/crates/api_models/src/events/user.rs b/crates/api_models/src/events/user.rs index 1f4cb7359c79..c0743c8b8fc0 100644 --- a/crates/api_models/src/events/user.rs +++ b/crates/api_models/src/events/user.rs @@ -1,7 +1,11 @@ use common_utils::events::{ApiEventMetric, ApiEventsType}; +#[cfg(feature = "recon")] +use masking::PeekInterface; #[cfg(feature = "dummy_connector")] use crate::user::sample_data::SampleDataRequest; +#[cfg(feature = "recon")] +use crate::user::VerifyTokenResponse; use crate::user::{ dashboard_metadata::{ GetMetaDataRequest, GetMetaDataResponse, GetMultipleMetaDataPayload, SetMetaDataRequest, @@ -21,6 +25,16 @@ impl ApiEventMetric for DashboardEntryResponse { } } +#[cfg(feature = "recon")] +impl ApiEventMetric for VerifyTokenResponse { + fn get_api_event_type(&self) -> Option { + Some(ApiEventsType::User { + merchant_id: self.merchant_id.clone(), + user_id: self.user_email.peek().to_string(), + }) + } +} + common_utils::impl_misc_api_event_type!( SignUpRequest, SignUpWithMerchantIdRequest, diff --git a/crates/api_models/src/lib.rs b/crates/api_models/src/lib.rs index dc1f6eb65375..1ea79ff6fe8f 100644 --- a/crates/api_models/src/lib.rs +++ b/crates/api_models/src/lib.rs @@ -26,6 +26,8 @@ pub mod payments; #[cfg(feature = "payouts")] pub mod payouts; pub mod pm_auth; +#[cfg(feature = "recon")] +pub mod recon; pub mod refunds; pub mod routing; pub mod surcharge_decision_configs; diff --git a/crates/api_models/src/recon.rs b/crates/api_models/src/recon.rs new file mode 100644 index 000000000000..efbe28f96ba4 --- /dev/null +++ b/crates/api_models/src/recon.rs @@ -0,0 +1,21 @@ +use common_utils::pii; +use masking::Secret; + +use crate::enums; + +#[derive(serde::Deserialize, Debug, serde::Serialize)] +pub struct ReconUpdateMerchantRequest { + pub merchant_id: String, + pub recon_status: enums::ReconStatus, + pub user_email: pii::Email, +} + +#[derive(Debug, serde::Serialize)] +pub struct ReconTokenResponse { + pub token: Secret, +} + +#[derive(Debug, serde::Serialize)] +pub struct ReconStatusResponse { + pub recon_status: enums::ReconStatus, +} diff --git a/crates/api_models/src/user.rs b/crates/api_models/src/user.rs index f5af31c8e7f6..a04c4fef6601 100644 --- a/crates/api_models/src/user.rs +++ b/crates/api_models/src/user.rs @@ -140,3 +140,10 @@ pub struct UserMerchantAccount { pub merchant_id: String, pub merchant_name: OptionalEncryptableName, } + +#[cfg(feature = "recon")] +#[derive(serde::Serialize, Debug)] +pub struct VerifyTokenResponse { + pub merchant_id: String, + pub user_email: pii::Email, +} diff --git a/crates/common_utils/src/events.rs b/crates/common_utils/src/events.rs index 6bbf78afe421..c2bf50d96c31 100644 --- a/crates/common_utils/src/events.rs +++ b/crates/common_utils/src/events.rs @@ -49,6 +49,7 @@ pub enum ApiEventsType { Miscellaneous, RustLocker, FraudCheck, + Recon, } impl ApiEventMetric for serde_json::Value {} diff --git a/crates/router/Cargo.toml b/crates/router/Cargo.toml index 8ecac3620919..0a544e0bd090 100644 --- a/crates/router/Cargo.toml +++ b/crates/router/Cargo.toml @@ -9,7 +9,7 @@ readme = "README.md" license.workspace = true [features] -default = ["kv_store", "stripe", "oltp", "olap", "backwards_compatibility", "accounts_cache", "dummy_connector", "payouts", "business_profile_routing", "connector_choice_mca_id", "profile_specific_fallback_routing", "retry", "frm"] +default = ["kv_store", "stripe", "oltp", "olap", "backwards_compatibility", "accounts_cache", "dummy_connector", "payouts", "business_profile_routing", "connector_choice_mca_id", "profile_specific_fallback_routing", "retry", "frm", "recon"] s3 = ["dep:aws-sdk-s3", "dep:aws-config"] kms = ["external_services/kms", "dep:aws-config"] email = ["external_services/email", "dep:aws-config", "olap"] @@ -30,6 +30,7 @@ connector_choice_mca_id = ["api_models/connector_choice_mca_id", "euclid/connect external_access_dc = ["dummy_connector"] detailed_errors = ["api_models/detailed_errors", "error-stack/serde"] payouts = [] +recon = ["email"] retry = [] [dependencies] diff --git a/crates/router/src/core/user.rs b/crates/router/src/core/user.rs index b1a582cedecf..27a4f67618e4 100644 --- a/crates/router/src/core/user.rs +++ b/crates/router/src/core/user.rs @@ -757,3 +757,32 @@ pub async fn send_verification_mail( Ok(ApplicationResponse::StatusOk) } + +#[cfg(feature = "recon")] +pub async fn verify_token( + state: AppState, + req: auth::ReconUser, +) -> UserResponse { + let user = state + .store + .find_user_by_id(&req.user_id) + .await + .map_err(|e| { + if e.current_context().is_db_not_found() { + e.change_context(UserErrors::UserNotFound) + } else { + e.change_context(UserErrors::InternalServerError) + } + })?; + let merchant_id = state + .store + .find_user_role_by_user_id(&req.user_id) + .await + .change_context(UserErrors::InternalServerError)? + .merchant_id; + + Ok(ApplicationResponse::Json(user_api::VerifyTokenResponse { + merchant_id: merchant_id.to_string(), + user_email: user.email, + })) +} diff --git a/crates/router/src/lib.rs b/crates/router/src/lib.rs index 696198f2153c..c38a4dc85b55 100644 --- a/crates/router/src/lib.rs +++ b/crates/router/src/lib.rs @@ -165,6 +165,12 @@ pub fn mk_app( { server_app = server_app.service(routes::StripeApis::server(state.clone())); } + + #[cfg(feature = "recon")] + { + server_app = server_app.service(routes::Recon::server(state.clone())); + } + server_app = server_app.service(routes::Cards::server(state.clone())); server_app = server_app.service(routes::Cache::server(state.clone())); server_app = server_app.service(routes::Health::server(state)); diff --git a/crates/router/src/routes.rs b/crates/router/src/routes.rs index d4bfabb6f92a..d9916f98e745 100644 --- a/crates/router/src/routes.rs +++ b/crates/router/src/routes.rs @@ -28,6 +28,8 @@ pub mod payment_methods; pub mod payments; #[cfg(feature = "payouts")] pub mod payouts; +#[cfg(feature = "recon")] +pub mod recon; pub mod refunds; #[cfg(feature = "olap")] pub mod routing; @@ -53,6 +55,8 @@ pub use self::app::DummyConnector; pub use self::app::Forex; #[cfg(feature = "payouts")] pub use self::app::Payouts; +#[cfg(all(feature = "olap", feature = "recon"))] +pub use self::app::Recon; #[cfg(all(feature = "olap", feature = "kms"))] pub use self::app::Verify; pub use self::app::{ diff --git a/crates/router/src/routes/app.rs b/crates/router/src/routes/app.rs index 77253d1d75c4..0c489dbe63a7 100644 --- a/crates/router/src/routes/app.rs +++ b/crates/router/src/routes/app.rs @@ -40,6 +40,8 @@ use super::{configs::*, customers::*, mandates::*, payments::*, refunds::*}; use super::{ephemeral_key::*, payment_methods::*, webhooks::*}; #[cfg(all(feature = "frm", feature = "oltp"))] use crate::routes::fraud_check as frm_routes; +#[cfg(all(feature = "recon", feature = "olap"))] +use crate::routes::recon as recon_routes; #[cfg(feature = "olap")] use crate::routes::verify_connector::payment_connector_verify; pub use crate::{ @@ -568,6 +570,26 @@ impl PaymentMethods { } } +#[cfg(all(feature = "olap", feature = "recon"))] +pub struct Recon; + +#[cfg(all(feature = "olap", feature = "recon"))] +impl Recon { + pub fn server(state: AppState) -> Scope { + web::scope("/recon") + .app_data(web::Data::new(state)) + .service( + web::resource("/update_merchant") + .route(web::post().to(recon_routes::update_merchant)), + ) + .service(web::resource("/token").route(web::get().to(recon_routes::get_recon_token))) + .service( + web::resource("/request").route(web::post().to(recon_routes::request_for_recon)), + ) + .service(web::resource("/verify_token").route(web::get().to(verify_recon_token))) + } +} + #[cfg(feature = "olap")] pub struct Blocklist; diff --git a/crates/router/src/routes/lock_utils.rs b/crates/router/src/routes/lock_utils.rs index c560f0d988af..12cf76be4759 100644 --- a/crates/router/src/routes/lock_utils.rs +++ b/crates/router/src/routes/lock_utils.rs @@ -31,6 +31,7 @@ pub enum ApiIdentifier { User, UserRole, ConnectorOnboarding, + Recon, } impl From for ApiIdentifier { @@ -186,6 +187,11 @@ impl From for ApiIdentifier { Flow::GetActionUrl | Flow::SyncOnboardingStatus | Flow::ResetTrackingId => { Self::ConnectorOnboarding } + + Flow::ReconMerchantUpdate + | Flow::ReconTokenRequest + | Flow::ReconServiceRequest + | Flow::ReconVerifyToken => Self::Recon, } } } diff --git a/crates/router/src/routes/recon.rs b/crates/router/src/routes/recon.rs new file mode 100644 index 000000000000..d34e30237ddc --- /dev/null +++ b/crates/router/src/routes/recon.rs @@ -0,0 +1,250 @@ +use actix_web::{web, HttpRequest, HttpResponse}; +use api_models::recon as recon_api; +use common_enums::ReconStatus; +use error_stack::ResultExt; +use masking::{ExposeInterface, PeekInterface, Secret}; +use router_env::Flow; + +use super::AppState; +use crate::{ + core::{ + api_locking, + errors::{self, RouterResponse, RouterResult, StorageErrorExt, UserErrors}, + }, + services::{ + api as service_api, api, + authentication::{self as auth, ReconUser, UserFromToken}, + email::types as email_types, + recon::ReconToken, + }, + types::{ + api::{self as api_types, enums}, + domain::{UserEmail, UserFromStorage, UserName}, + storage, + }, +}; + +pub async fn update_merchant( + state: web::Data, + req: HttpRequest, + json_payload: web::Json, +) -> HttpResponse { + let flow = Flow::ReconMerchantUpdate; + Box::pin(api::server_wrap( + flow, + state, + &req, + json_payload.into_inner(), + |state, _user, req| recon_merchant_account_update(state, req), + &auth::ReconAdmin, + api_locking::LockAction::NotApplicable, + )) + .await +} + +pub async fn request_for_recon(state: web::Data, http_req: HttpRequest) -> HttpResponse { + let flow = Flow::ReconServiceRequest; + Box::pin(api::server_wrap( + flow, + state, + &http_req, + (), + |state, user: UserFromToken, _req| send_recon_request(state, user), + &auth::DashboardNoPermissionAuth, + api_locking::LockAction::NotApplicable, + )) + .await +} + +pub async fn get_recon_token(state: web::Data, req: HttpRequest) -> HttpResponse { + let flow = Flow::ReconTokenRequest; + Box::pin(api::server_wrap( + flow, + state, + &req, + (), + |state, user: ReconUser, _| generate_recon_token(state, user), + &auth::ReconJWT, + api_locking::LockAction::NotApplicable, + )) + .await +} + +pub async fn send_recon_request( + state: AppState, + user: UserFromToken, +) -> RouterResponse { + let db = &*state.store; + let user_from_db = db + .find_user_by_id(&user.user_id) + .await + .change_context(errors::ApiErrorResponse::InternalServerError)?; + let merchant_id = db + .find_user_role_by_user_id(&user.user_id) + .await + .to_not_found_response(errors::ApiErrorResponse::MerchantAccountNotFound)? + .merchant_id; + let key_store = db + .get_merchant_key_store_by_merchant_id( + merchant_id.as_str(), + &db.get_master_key().to_vec().into(), + ) + .await + .to_not_found_response(errors::ApiErrorResponse::MerchantAccountNotFound)?; + let merchant_account = db + .find_merchant_account_by_merchant_id(merchant_id.as_str(), &key_store) + .await + .to_not_found_response(errors::ApiErrorResponse::MerchantAccountNotFound)?; + + let email_contents = email_types::ProFeatureRequest { + feature_name: "RECONCILIATION & SETTLEMENT".to_string(), + merchant_id: merchant_id.clone(), + user_name: UserName::new(user_from_db.name) + .change_context(errors::ApiErrorResponse::InternalServerError) + .attach_printable("Failed to form username")?, + recipient_email: UserEmail::from_pii_email(user_from_db.email.clone()) + .change_context(errors::ApiErrorResponse::InternalServerError) + .attach_printable("Failed to convert to UserEmail from pii::Email")?, + settings: state.conf.clone(), + subject: format!( + "Dashboard Pro Feature Request by {}", + user_from_db.email.expose().peek() + ), + }; + + let is_email_sent = state + .email_client + .compose_and_send_email( + Box::new(email_contents), + state.conf.proxy.https_url.as_ref(), + ) + .await + .change_context(UserErrors::InternalServerError) + .attach_printable("Failed to compose and send email for ProFeatureRequest") + .is_ok(); + + if is_email_sent { + let updated_merchant_account = storage::MerchantAccountUpdate::ReconUpdate { + recon_status: enums::ReconStatus::Requested, + }; + + let response = db + .update_merchant(merchant_account, updated_merchant_account, &key_store) + .await + .change_context(errors::ApiErrorResponse::InternalServerError) + .attach_printable_lazy(|| { + format!("Failed while updating merchant's recon status: {merchant_id}") + })?; + + Ok(service_api::ApplicationResponse::Json( + recon_api::ReconStatusResponse { + recon_status: response.recon_status, + }, + )) + } else { + Ok(service_api::ApplicationResponse::Json( + recon_api::ReconStatusResponse { + recon_status: enums::ReconStatus::NotRequested, + }, + )) + } +} + +pub async fn recon_merchant_account_update( + state: AppState, + req: recon_api::ReconUpdateMerchantRequest, +) -> RouterResponse { + let merchant_id = &req.merchant_id.clone(); + let user_email = &req.user_email.clone(); + + let db = &*state.store; + + let key_store = db + .get_merchant_key_store_by_merchant_id( + &req.merchant_id, + &db.get_master_key().to_vec().into(), + ) + .await + .to_not_found_response(errors::ApiErrorResponse::MerchantAccountNotFound)?; + + let merchant_account = db + .find_merchant_account_by_merchant_id(merchant_id, &key_store) + .await + .to_not_found_response(errors::ApiErrorResponse::MerchantAccountNotFound)?; + + let updated_merchant_account = storage::MerchantAccountUpdate::ReconUpdate { + recon_status: req.recon_status, + }; + + let response = db + .update_merchant(merchant_account, updated_merchant_account, &key_store) + .await + .change_context(errors::ApiErrorResponse::InternalServerError) + .attach_printable_lazy(|| { + format!("Failed while updating merchant's recon status: {merchant_id}") + })?; + + let email_contents = email_types::ReconActivation { + recipient_email: UserEmail::from_pii_email(user_email.clone()) + .change_context(errors::ApiErrorResponse::InternalServerError) + .attach_printable("Failed to convert to UserEmail from pii::Email")?, + user_name: UserName::new(Secret::new("HyperSwitch User".to_string())) + .change_context(errors::ApiErrorResponse::InternalServerError) + .attach_printable("Failed to form username")?, + settings: state.conf.clone(), + subject: "Approval of Recon Request - Access Granted to Recon Dashboard", + }; + + if req.recon_status == ReconStatus::Active { + let _is_email_sent = state + .email_client + .compose_and_send_email( + Box::new(email_contents), + state.conf.proxy.https_url.as_ref(), + ) + .await + .change_context(UserErrors::InternalServerError) + .attach_printable("Failed to compose and send email for ReconActivation") + .is_ok(); + } + + Ok(service_api::ApplicationResponse::Json( + response + .try_into() + .change_context(errors::ApiErrorResponse::InvalidDataValue { + field_name: "merchant_account", + })?, + )) +} + +pub async fn generate_recon_token( + state: AppState, + req: ReconUser, +) -> RouterResponse { + let db = &*state.store; + let user = db + .find_user_by_id(&req.user_id) + .await + .map_err(|e| { + if e.current_context().is_db_not_found() { + e.change_context(errors::ApiErrorResponse::InvalidJwtToken) + } else { + e.change_context(errors::ApiErrorResponse::InternalServerError) + } + })? + .into(); + + let token = Box::pin(get_recon_auth_token(user, state)) + .await + .change_context(errors::ApiErrorResponse::InternalServerError)?; + Ok(service_api::ApplicationResponse::Json( + recon_api::ReconTokenResponse { token }, + )) +} + +pub async fn get_recon_auth_token( + user: UserFromStorage, + state: AppState, +) -> RouterResult> { + ReconToken::new_token(user.0.user_id.clone(), &state.conf).await +} diff --git a/crates/router/src/routes/user.rs b/crates/router/src/routes/user.rs index a77b82c550e6..976fd5c9f564 100644 --- a/crates/router/src/routes/user.rs +++ b/crates/router/src/routes/user.rs @@ -388,3 +388,18 @@ pub async fn verify_email_request( )) .await } + +#[cfg(feature = "recon")] +pub async fn verify_recon_token(state: web::Data, http_req: HttpRequest) -> HttpResponse { + let flow = Flow::ReconVerifyToken; + Box::pin(api::server_wrap( + flow, + state.clone(), + &http_req, + (), + |state, user, _req| user_core::verify_token(state, user), + &auth::ReconJWT, + api_locking::LockAction::NotApplicable, + )) + .await +} diff --git a/crates/router/src/services.rs b/crates/router/src/services.rs index 57f3b802bd5d..8c973105d53b 100644 --- a/crates/router/src/services.rs +++ b/crates/router/src/services.rs @@ -7,6 +7,8 @@ pub mod jwt; pub mod kafka; pub mod logger; pub mod pm_auth; +#[cfg(feature = "recon")] +pub mod recon; #[cfg(feature = "email")] pub mod email; diff --git a/crates/router/src/services/authentication.rs b/crates/router/src/services/authentication.rs index b48465ebd174..3370912394e0 100644 --- a/crates/router/src/services/authentication.rs +++ b/crates/router/src/services/authentication.rs @@ -12,10 +12,14 @@ use serde::Serialize; use super::authorization::{self, permissions::Permission}; #[cfg(feature = "olap")] use super::jwt; +#[cfg(feature = "recon")] +use super::recon::ReconToken; #[cfg(feature = "olap")] use crate::consts; #[cfg(feature = "olap")] use crate::core::errors::UserResult; +#[cfg(feature = "recon")] +use crate::routes::AppState; use crate::{ configs::settings, core::{ @@ -822,3 +826,95 @@ where } default_auth } + +#[cfg(feature = "recon")] +static RECON_API_KEY: tokio::sync::OnceCell> = + tokio::sync::OnceCell::const_new(); + +#[cfg(feature = "recon")] +pub async fn get_recon_admin_api_key( + secrets: &settings::Secrets, + #[cfg(feature = "kms")] kms_client: &kms::KmsClient, +) -> RouterResult<&'static StrongSecret> { + RECON_API_KEY + .get_or_try_init(|| async { + #[cfg(feature = "kms")] + let recon_admin_api_key = secrets + .kms_encrypted_recon_admin_api_key + .decrypt_inner(kms_client) + .await + .change_context(errors::ApiErrorResponse::InternalServerError) + .attach_printable("Failed to KMS decrypt recon admin API key")?; + + #[cfg(not(feature = "kms"))] + let recon_admin_api_key = secrets.recon_admin_api_key.clone(); + + Ok(StrongSecret::new(recon_admin_api_key)) + }) + .await +} + +#[cfg(feature = "recon")] +pub struct ReconAdmin; + +#[async_trait] +#[cfg(feature = "recon")] +impl AuthenticateAndFetch<(), A> for ReconAdmin +where + A: AppStateInfo + Sync, +{ + async fn authenticate_and_fetch( + &self, + request_headers: &HeaderMap, + state: &A, + ) -> RouterResult<((), AuthenticationType)> { + let request_admin_api_key = + get_api_key(request_headers).change_context(errors::ApiErrorResponse::Unauthorized)?; + let conf = state.conf(); + + let admin_api_key = get_recon_admin_api_key( + &conf.secrets, + #[cfg(feature = "kms")] + kms::get_kms_client(&conf.kms).await, + ) + .await?; + + if request_admin_api_key != admin_api_key.peek() { + Err(report!(errors::ApiErrorResponse::Unauthorized) + .attach_printable("Recon Admin Authentication Failure"))?; + } + + Ok(((), AuthenticationType::NoAuth)) + } +} + +#[cfg(feature = "recon")] +pub struct ReconJWT; +#[cfg(feature = "recon")] +pub struct ReconUser { + pub user_id: String, +} +#[cfg(feature = "recon")] +impl AuthInfo for ReconUser { + fn get_merchant_id(&self) -> Option<&str> { + None + } +} +#[cfg(all(feature = "olap", feature = "recon"))] +#[async_trait] +impl AuthenticateAndFetch for ReconJWT { + async fn authenticate_and_fetch( + &self, + request_headers: &HeaderMap, + state: &AppState, + ) -> RouterResult<(ReconUser, AuthenticationType)> { + let payload = parse_jwt_payload::(request_headers, state).await?; + + Ok(( + ReconUser { + user_id: payload.user_id, + }, + AuthenticationType::NoAuth, + )) + } +} diff --git a/crates/router/src/services/email/assets/recon_activated.html b/crates/router/src/services/email/assets/recon_activation.html similarity index 100% rename from crates/router/src/services/email/assets/recon_activated.html rename to crates/router/src/services/email/assets/recon_activation.html diff --git a/crates/router/src/services/email/types.rs b/crates/router/src/services/email/types.rs index d5c28b1fd6af..0ef15eaa40d2 100644 --- a/crates/router/src/services/email/types.rs +++ b/crates/router/src/services/email/types.rs @@ -1,17 +1,37 @@ use common_utils::errors::CustomResult; use error_stack::ResultExt; use external_services::email::{EmailContents, EmailData, EmailError}; -use masking::ExposeInterface; +use masking::{ExposeInterface, PeekInterface}; use crate::{configs, consts}; #[cfg(feature = "olap")] 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 }, + Verify { + link: String, + }, + Reset { + link: String, + user_name: String, + }, + MagicLink { + link: String, + user_name: String, + }, + InviteUser { + link: String, + user_name: String, + }, + ReconActivation { + user_name: String, + }, + ProFeatureRequest { + feature_name: String, + merchant_id: String, + user_name: String, + user_email: String, + }, } pub mod html { @@ -43,6 +63,30 @@ pub mod html { link = link ) } + EmailBody::ReconActivation { user_name } => { + format!( + include_str!("assets/recon_activation.html"), + username = user_name, + ) + } + EmailBody::ProFeatureRequest { + feature_name, + merchant_id, + user_name, + user_email, + } => { + format!( + "Dear Hyperswitch Support Team, + + Dashboard Pro Feature Request, + Feature name : {feature_name} + Merchant ID : {merchant_id} + Merchant Name : {user_name} + Email : {user_email} + + (note: This is an auto generated email. use merchant email for any further comunications)", + ) + } } } } @@ -198,3 +242,54 @@ impl EmailData for InviteUser { }) } } + +pub struct ReconActivation { + 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 ReconActivation { + async fn get_email_data(&self) -> CustomResult { + let body = html::get_html_body(EmailBody::ReconActivation { + 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 ProFeatureRequest { + pub recipient_email: domain::UserEmail, + pub feature_name: String, + pub merchant_id: String, + pub user_name: domain::UserName, + pub settings: std::sync::Arc, + pub subject: String, +} + +#[async_trait::async_trait] +impl EmailData for ProFeatureRequest { + async fn get_email_data(&self) -> CustomResult { + let recipient = self.recipient_email.clone().into_inner(); + + let body = html::get_html_body(EmailBody::ProFeatureRequest { + user_name: self.user_name.clone().get_secret().expose(), + feature_name: self.feature_name.clone(), + merchant_id: self.merchant_id.clone(), + user_email: recipient.peek().to_string(), + }); + + Ok(EmailContents { + subject: self.subject.clone(), + body: external_services::email::IntermediateString::new(body), + recipient, + }) + } +} diff --git a/crates/router/src/services/recon.rs b/crates/router/src/services/recon.rs new file mode 100644 index 000000000000..d5a2151a487b --- /dev/null +++ b/crates/router/src/services/recon.rs @@ -0,0 +1,29 @@ +use error_stack::ResultExt; +use masking::Secret; + +use super::jwt; +use crate::{ + consts, + core::{self, errors::RouterResult}, + routes::app::settings::Settings, +}; + +#[derive(serde::Serialize, serde::Deserialize)] +pub struct ReconToken { + pub user_id: String, + pub exp: u64, +} + +impl ReconToken { + pub async fn new_token(user_id: String, settings: &Settings) -> RouterResult> { + let exp_duration = std::time::Duration::from_secs(consts::JWT_TOKEN_TIME_IN_SECS); + let exp = jwt::generate_exp(exp_duration) + .change_context(core::errors::ApiErrorResponse::InternalServerError)? + .as_secs(); + let token_payload = Self { user_id, exp }; + let token = jwt::generate_jwt(&token_payload, settings) + .await + .change_context(core::errors::ApiErrorResponse::InternalServerError)?; + Ok(Secret::new(token)) + } +} diff --git a/crates/router_env/src/logger/types.rs b/crates/router_env/src/logger/types.rs index 8f0b9bad3e80..0d6636e567da 100644 --- a/crates/router_env/src/logger/types.rs +++ b/crates/router_env/src/logger/types.rs @@ -165,6 +165,14 @@ pub enum Flow { RefundsList, // Retrieve forex flow. RetrieveForexFlow, + /// Toggles recon service for a merchant. + ReconMerchantUpdate, + /// Recon token request flow. + ReconTokenRequest, + /// Initial request for recon service. + ReconServiceRequest, + /// Recon token verification flow + ReconVerifyToken, /// Routing create flow, RoutingCreateConfig, /// Routing link config