diff --git a/crates/api_models/src/events/routing.rs b/crates/api_models/src/events/routing.rs index 5eca01acc6fb..a09735bc5722 100644 --- a/crates/api_models/src/events/routing.rs +++ b/crates/api_models/src/events/routing.rs @@ -1,8 +1,9 @@ use common_utils::events::{ApiEventMetric, ApiEventsType}; use crate::routing::{ - LinkedRoutingConfigRetrieveResponse, MerchantRoutingAlgorithm, RoutingAlgorithmId, - RoutingConfigRequest, RoutingDictionaryRecord, RoutingKind, + LinkedRoutingConfigRetrieveResponse, MerchantRoutingAlgorithm, ProfileDefaultRoutingConfig, + RoutingAlgorithmId, RoutingConfigRequest, RoutingDictionaryRecord, RoutingKind, + RoutingPayloadWrapper, }; #[cfg(feature = "business_profile_routing")] use crate::routing::{RoutingRetrieveLinkQuery, RoutingRetrieveQuery}; @@ -37,6 +38,17 @@ impl ApiEventMetric for LinkedRoutingConfigRetrieveResponse { } } +impl ApiEventMetric for RoutingPayloadWrapper { + fn get_api_event_type(&self) -> Option { + Some(ApiEventsType::Routing) + } +} +impl ApiEventMetric for ProfileDefaultRoutingConfig { + fn get_api_event_type(&self) -> Option { + Some(ApiEventsType::Routing) + } +} + #[cfg(feature = "business_profile_routing")] impl ApiEventMetric for RoutingRetrieveQuery { fn get_api_event_type(&self) -> Option { diff --git a/crates/api_models/src/routing.rs b/crates/api_models/src/routing.rs index 425ca364191d..363df5389a79 100644 --- a/crates/api_models/src/routing.rs +++ b/crates/api_models/src/routing.rs @@ -40,6 +40,12 @@ pub struct RoutingConfigRequest { pub profile_id: Option, } +#[derive(Debug, serde::Serialize)] +pub struct ProfileDefaultRoutingConfig { + pub profile_id: String, + pub connectors: Vec, +} + #[cfg(feature = "business_profile_routing")] #[derive(Debug, serde::Deserialize, serde::Serialize)] pub struct RoutingRetrieveQuery { @@ -389,6 +395,13 @@ pub enum RoutingAlgorithmKind { Advanced, } +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] + +pub struct RoutingPayloadWrapper { + pub updated_config: Vec, + pub profile_id: String, +} + #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] #[serde( tag = "type", diff --git a/crates/router/Cargo.toml b/crates/router/Cargo.toml index d765a5b5c5ed..8f6906e06855 100644 --- a/crates/router/Cargo.toml +++ b/crates/router/Cargo.toml @@ -9,13 +9,13 @@ readme = "README.md" license.workspace = true [features] -default = ["kv_store", "stripe", "oltp", "olap", "backwards_compatibility", "accounts_cache", "dummy_connector", "payouts"] +default = ["kv_store", "stripe", "oltp", "olap", "backwards_compatibility", "accounts_cache", "dummy_connector", "payouts", "profile_specific_fallback_routing"] s3 = ["dep:aws-sdk-s3", "dep:aws-config"] kms = ["external_services/kms", "dep:aws-config"] email = ["external_services/email", "dep:aws-config"] basilisk = ["kms"] stripe = ["dep:serde_qs"] -release = ["kms", "stripe", "basilisk", "s3", "email", "business_profile_routing", "accounts_cache", "kv_store", "olap"] +release = ["kms", "stripe", "basilisk", "s3", "email", "business_profile_routing", "accounts_cache", "kv_store", "profile_specific_fallback_routing"] olap = ["data_models/olap", "storage_impl/olap", "scheduler/olap"] oltp = ["data_models/oltp", "storage_impl/oltp"] kv_store = ["scheduler/kv_store"] @@ -24,6 +24,7 @@ openapi = ["olap", "oltp", "payouts"] vergen = ["router_env/vergen"] backwards_compatibility = ["api_models/backwards_compatibility", "euclid/backwards_compatibility", "kgraph_utils/backwards_compatibility"] business_profile_routing=["api_models/business_profile_routing"] +profile_specific_fallback_routing = [] dummy_connector = ["api_models/dummy_connector", "euclid/dummy_connector", "kgraph_utils/dummy_connector"] connector_choice_mca_id = ["api_models/connector_choice_mca_id", "euclid/connector_choice_mca_id", "kgraph_utils/connector_choice_mca_id"] external_access_dc = ["dummy_connector"] diff --git a/crates/router/src/core/admin.rs b/crates/router/src/core/admin.rs index e1e5ea744e2f..5ccd9e964866 100644 --- a/crates/router/src/core/admin.rs +++ b/crates/router/src/core/admin.rs @@ -916,13 +916,16 @@ pub async fn create_payment_connector( let mut default_routing_config = routing_helpers::get_merchant_default_config(&*state.store, merchant_id).await?; + let mut default_routing_config_for_profile = + routing_helpers::get_merchant_default_config(&*state.clone().store, &profile_id).await?; + let mca = state .store .insert_merchant_connector_account(merchant_connector_account, &key_store) .await .to_duplicate_response( errors::ApiErrorResponse::DuplicateMerchantConnectorAccount { - profile_id, + profile_id: profile_id.clone(), connector_name: req.connector_name.to_string(), }, )?; @@ -939,7 +942,7 @@ pub async fn create_payment_connector( }; if !default_routing_config.contains(&choice) { - default_routing_config.push(choice); + default_routing_config.push(choice.clone()); routing_helpers::update_merchant_default_config( &*state.store, merchant_id, @@ -947,6 +950,15 @@ pub async fn create_payment_connector( ) .await?; } + if !default_routing_config_for_profile.contains(&choice.clone()) { + default_routing_config_for_profile.push(choice); + routing_helpers::update_merchant_default_config( + &*state.store, + &profile_id.clone(), + default_routing_config_for_profile, + ) + .await?; + } } metrics::MCA_CREATE.add( diff --git a/crates/router/src/core/payments/routing.rs b/crates/router/src/core/payments/routing.rs index 4134ddf65ea0..3b89d4e38e4e 100644 --- a/crates/router/src/core/payments/routing.rs +++ b/crates/router/src/core/payments/routing.rs @@ -71,7 +71,10 @@ pub struct SessionRoutingPmTypeInput<'a> { routing_algorithm: &'a MerchantAccountRoutingAlgorithm, backend_input: dsl_inputs::BackendInput, allowed_connectors: FxHashMap, - #[cfg(feature = "business_profile_routing")] + #[cfg(any( + feature = "business_profile_routing", + feature = "profile_specific_fallback_routing" + ))] profile_id: Option, } static ROUTING_CACHE: StaticCache = StaticCache::new(); @@ -207,10 +210,22 @@ pub async fn perform_static_routing_v1( let algorithm_id = if let Some(id) = algorithm_ref.algorithm_id { id } else { - let fallback_config = - routing_helpers::get_merchant_default_config(&*state.clone().store, merchant_id) - .await - .change_context(errors::RoutingError::FallbackConfigFetchFailed)?; + let fallback_config = routing_helpers::get_merchant_default_config( + &*state.clone().store, + #[cfg(not(feature = "profile_specific_fallback_routing"))] + merchant_id, + #[cfg(feature = "profile_specific_fallback_routing")] + { + payment_data + .payment_intent + .profile_id + .as_ref() + .get_required_value("profile_id") + .change_context(errors::RoutingError::ProfileIdMissing)? + }, + ) + .await + .change_context(errors::RoutingError::FallbackConfigFetchFailed)?; return Ok(fallback_config); }; @@ -616,10 +631,22 @@ pub async fn perform_fallback_routing( eligible_connectors: Option<&Vec>, #[cfg(feature = "business_profile_routing")] profile_id: Option, ) -> RoutingResult> { - let fallback_config = - routing_helpers::get_merchant_default_config(&*state.store, &key_store.merchant_id) - .await - .change_context(errors::RoutingError::FallbackConfigFetchFailed)?; + let fallback_config = routing_helpers::get_merchant_default_config( + &*state.store, + #[cfg(not(feature = "profile_specific_fallback_routing"))] + &key_store.merchant_id, + #[cfg(feature = "profile_specific_fallback_routing")] + { + payment_data + .payment_intent + .profile_id + .as_ref() + .get_required_value("profile_id") + .change_context(errors::RoutingError::ProfileIdMissing)? + }, + ) + .await + .change_context(errors::RoutingError::FallbackConfigFetchFailed)?; let backend_input = make_dsl_input(payment_data)?; perform_kgraph_filtering( @@ -819,8 +846,11 @@ pub async fn perform_session_flow_routing( routing_algorithm: &routing_algorithm, backend_input: backend_input.clone(), allowed_connectors, - #[cfg(feature = "business_profile_routing")] - profile_id: session_input.payment_intent.clone().profile_id, + #[cfg(any( + feature = "business_profile_routing", + feature = "profile_specific_fallback_routing" + ))] + profile_id: session_input.payment_intent.profile_id.clone(), }; let maybe_choice = perform_session_routing_for_pm_type(session_pm_input).await?; @@ -880,7 +910,16 @@ async fn perform_session_routing_for_pm_type( } else { routing_helpers::get_merchant_default_config( &*session_pm_input.state.clone().store, + #[cfg(not(feature = "profile_specific_fallback_routing"))] merchant_id, + #[cfg(feature = "profile_specific_fallback_routing")] + { + session_pm_input + .profile_id + .as_ref() + .get_required_value("profile_id") + .change_context(errors::RoutingError::ProfileIdMissing)? + }, ) .await .change_context(errors::RoutingError::FallbackConfigFetchFailed)? @@ -903,7 +942,16 @@ async fn perform_session_routing_for_pm_type( if final_selection.is_empty() { let fallback = routing_helpers::get_merchant_default_config( &*session_pm_input.state.clone().store, + #[cfg(not(feature = "profile_specific_fallback_routing"))] merchant_id, + #[cfg(feature = "profile_specific_fallback_routing")] + { + session_pm_input + .profile_id + .as_ref() + .get_required_value("profile_id") + .change_context(errors::RoutingError::ProfileIdMissing)? + }, ) .await .change_context(errors::RoutingError::FallbackConfigFetchFailed)?; diff --git a/crates/router/src/core/routing.rs b/crates/router/src/core/routing.rs index 723611ed5009..4171c3385637 100644 --- a/crates/router/src/core/routing.rs +++ b/crates/router/src/core/routing.rs @@ -13,13 +13,14 @@ use diesel_models::routing_algorithm::RoutingAlgorithm; use error_stack::{IntoReport, ResultExt}; use rustc_hash::FxHashSet; -#[cfg(feature = "business_profile_routing")] -use crate::core::utils::validate_and_get_business_profile; #[cfg(feature = "business_profile_routing")] use crate::types::transformers::{ForeignInto, ForeignTryInto}; use crate::{ consts, - core::errors::{RouterResponse, StorageErrorExt}, + core::{ + errors::{RouterResponse, StorageErrorExt}, + utils as core_utils, + }, routes::AppState, types::domain, utils::{self, OptionExt, ValueExt}, @@ -111,8 +112,12 @@ pub async fn create_routing_config( }) .attach_printable("Profile_id not provided")?; - validate_and_get_business_profile(db, Some(&profile_id), &merchant_account.merchant_id) - .await?; + core_utils::validate_and_get_business_profile( + db, + Some(&profile_id), + &merchant_account.merchant_id, + ) + .await?; helpers::validate_connectors_in_routing_config( db, @@ -229,7 +234,7 @@ pub async fn link_routing_config( .await .change_context(errors::ApiErrorResponse::ResourceIdNotFound)?; - let business_profile = validate_and_get_business_profile( + let business_profile = core_utils::validate_and_get_business_profile( db, Some(&routing_algorithm.profile_id), &merchant_account.merchant_id, @@ -332,7 +337,7 @@ pub async fn retrieve_routing_config( .await .to_not_found_response(errors::ApiErrorResponse::ResourceIdNotFound)?; - validate_and_get_business_profile( + core_utils::validate_and_get_business_profile( db, Some(&routing_algorithm.profile_id), &merchant_account.merchant_id, @@ -401,9 +406,12 @@ pub async fn unlink_routing_config( field_name: "profile_id", }) .attach_printable("Profile_id not provided")?; - let business_profile = - validate_and_get_business_profile(db, Some(&profile_id), &merchant_account.merchant_id) - .await?; + let business_profile = core_utils::validate_and_get_business_profile( + db, + Some(&profile_id), + &merchant_account.merchant_id, + ) + .await?; match business_profile { Some(business_profile) => { let routing_algo_ref: routing_types::RoutingAlgorithmRef = business_profile @@ -622,13 +630,15 @@ pub async fn retrieve_linked_routing_config( #[cfg(feature = "business_profile_routing")] { let business_profiles = if let Some(profile_id) = query_params.profile_id { - validate_and_get_business_profile(db, Some(&profile_id), &merchant_account.merchant_id) - .await? - .map(|profile| vec![profile]) - .get_required_value("BusinessProfile") - .change_context(errors::ApiErrorResponse::BusinessProfileNotFound { - id: profile_id, - })? + core_utils::validate_and_get_business_profile( + db, + Some(&profile_id), + &merchant_account.merchant_id, + ) + .await? + .map(|profile| vec![profile]) + .get_required_value("BusinessProfile") + .change_context(errors::ApiErrorResponse::BusinessProfileNotFound { id: profile_id })? } else { db.list_business_profile_by_merchant_id(&merchant_account.merchant_id) .await @@ -711,3 +721,118 @@ pub async fn retrieve_linked_routing_config( Ok(service_api::ApplicationResponse::Json(response)) } } + +pub async fn retrieve_default_routing_config_for_profiles( + state: AppState, + merchant_account: domain::MerchantAccount, +) -> RouterResponse> { + let db = state.store.as_ref(); + + let all_profiles = db + .list_business_profile_by_merchant_id(&merchant_account.merchant_id) + .await + .to_not_found_response(errors::ApiErrorResponse::ResourceIdNotFound) + .attach_printable("error retrieving all business profiles for merchant")?; + + let retrieve_config_futures = all_profiles + .iter() + .map(|prof| helpers::get_merchant_default_config(db, &prof.profile_id)) + .collect::>(); + + let configs = futures::future::join_all(retrieve_config_futures) + .await + .into_iter() + .collect::, _>>()?; + + let default_configs = configs + .into_iter() + .zip(all_profiles.iter().map(|prof| prof.profile_id.clone())) + .map( + |(config, profile_id)| routing_types::ProfileDefaultRoutingConfig { + profile_id, + connectors: config, + }, + ) + .collect::>(); + + Ok(service_api::ApplicationResponse::Json(default_configs)) +} + +pub async fn update_default_routing_config_for_profile( + state: AppState, + merchant_account: domain::MerchantAccount, + updated_config: Vec, + profile_id: String, +) -> RouterResponse { + let db = state.store.as_ref(); + + let business_profile = core_utils::validate_and_get_business_profile( + db, + Some(&profile_id), + &merchant_account.merchant_id, + ) + .await? + .get_required_value("BusinessProfile") + .change_context(errors::ApiErrorResponse::BusinessProfileNotFound { id: profile_id })?; + let default_config = + helpers::get_merchant_default_config(db, &business_profile.profile_id).await?; + + utils::when(default_config.len() != updated_config.len(), || { + Err(errors::ApiErrorResponse::PreconditionFailed { + message: "current config and updated config have different lengths".to_string(), + }) + .into_report() + })?; + + let existing_set = FxHashSet::from_iter(default_config.iter().map(|c| { + ( + c.connector.to_string(), + #[cfg(feature = "connector_choice_mca_id")] + c.merchant_connector_id.as_ref(), + #[cfg(not(feature = "connector_choice_mca_id"))] + c.sub_label.as_ref(), + ) + })); + + let updated_set = FxHashSet::from_iter(updated_config.iter().map(|c| { + ( + c.connector.to_string(), + #[cfg(feature = "connector_choice_mca_id")] + c.merchant_connector_id.as_ref(), + #[cfg(not(feature = "connector_choice_mca_id"))] + c.sub_label.as_ref(), + ) + })); + + let symmetric_diff = existing_set + .symmetric_difference(&updated_set) + .cloned() + .collect::>(); + + utils::when(!symmetric_diff.is_empty(), || { + let error_str = symmetric_diff + .into_iter() + .map(|(connector, ident)| format!("'{connector}:{ident:?}'")) + .collect::>() + .join(", "); + + Err(errors::ApiErrorResponse::InvalidRequestData { + message: format!("connector mismatch between old and new configs ({error_str})"), + }) + .into_report() + })?; + + helpers::update_merchant_default_config( + db, + &business_profile.profile_id, + updated_config.clone(), + ) + .await?; + + Ok(service_api::ApplicationResponse::Json( + routing_types::ProfileDefaultRoutingConfig { + profile_id: business_profile.profile_id, + connectors: updated_config, + }, + )) +} diff --git a/crates/router/src/routes/app.rs b/crates/router/src/routes/app.rs index c34c542d1b6c..7f5c720be607 100644 --- a/crates/router/src/routes/app.rs +++ b/crates/router/src/routes/app.rs @@ -324,6 +324,16 @@ impl Routing { web::resource("/{algorithm_id}/activate") .route(web::post().to(cloud_routing::routing_link_config)), ) + .service( + web::resource("/default/profile/{profile_id}").route( + web::post().to(cloud_routing::routing_update_default_config_for_profile), + ), + ) + .service( + web::resource("/default/profile").route( + web::get().to(cloud_routing::routing_retrieve_default_config_for_profiles), + ), + ) } } diff --git a/crates/router/src/routes/routing.rs b/crates/router/src/routes/routing.rs index b87116f47fc5..606111a88818 100644 --- a/crates/router/src/routes/routing.rs +++ b/crates/router/src/routes/routing.rs @@ -296,3 +296,60 @@ pub async fn routing_retrieve_linked_config( .await } } + +#[cfg(feature = "olap")] +#[instrument(skip_all)] +pub async fn routing_retrieve_default_config_for_profiles( + state: web::Data, + req: HttpRequest, +) -> impl Responder { + oss_api::server_wrap( + Flow::RoutingRetrieveDefaultConfig, + state, + &req, + (), + |state, auth: auth::AuthenticationData, _| { + routing::retrieve_default_routing_config_for_profiles(state, auth.merchant_account) + }, + #[cfg(not(feature = "release"))] + auth::auth_type(&auth::ApiKeyAuth, &auth::JWTAuth, req.headers()), + #[cfg(feature = "release")] + &auth::JWTAuth, + api_locking::LockAction::NotApplicable, + ) + .await +} + +#[cfg(feature = "olap")] +#[instrument(skip_all)] +pub async fn routing_update_default_config_for_profile( + state: web::Data, + req: HttpRequest, + path: web::Path, + json_payload: web::Json>, +) -> impl Responder { + let routing_payload_wrapper = routing_types::RoutingPayloadWrapper { + updated_config: json_payload.into_inner(), + profile_id: path.into_inner(), + }; + oss_api::server_wrap( + Flow::RoutingUpdateDefaultConfig, + state, + &req, + routing_payload_wrapper, + |state, auth: auth::AuthenticationData, wrapper| { + routing::update_default_routing_config_for_profile( + state, + auth.merchant_account, + wrapper.updated_config, + wrapper.profile_id, + ) + }, + #[cfg(not(feature = "release"))] + auth::auth_type(&auth::ApiKeyAuth, &auth::JWTAuth, req.headers()), + #[cfg(feature = "release")] + &auth::JWTAuth, + api_locking::LockAction::NotApplicable, + ) + .await +}