From 7bd6e05c0c05ebae9b82a6f410e61ca4409d088b Mon Sep 17 00:00:00 2001 From: Mani Chandra <84711804+ThisIsMani@users.noreply.github.com> Date: Tue, 5 Dec 2023 15:29:10 +0530 Subject: [PATCH] feat(connector_onboarding): Add Connector onboarding APIs (#3050) Co-authored-by: hyperswitch-bot[bot] <148525504+hyperswitch-bot[bot]@users.noreply.github.com> --- config/config.example.toml | 6 + config/development.toml | 8 +- config/docker_compose.toml | 6 + crates/api_models/src/connector_onboarding.rs | 54 ++++ crates/api_models/src/events.rs | 1 + .../src/events/connector_onboarding.rs | 12 + crates/api_models/src/lib.rs | 1 + crates/router/src/configs/kms.rs | 33 +++ crates/router/src/configs/settings.rs | 17 ++ crates/router/src/core.rs | 2 + .../router/src/core/connector_onboarding.rs | 96 +++++++ .../src/core/connector_onboarding/paypal.rs | 174 ++++++++++++ crates/router/src/lib.rs | 1 + crates/router/src/routes.rs | 8 +- crates/router/src/routes/app.rs | 26 +- .../router/src/routes/connector_onboarding.rs | 47 ++++ crates/router/src/routes/lock_utils.rs | 3 + crates/router/src/types/api.rs | 2 + .../src/types/api/connector_onboarding.rs | 1 + .../types/api/connector_onboarding/paypal.rs | 247 ++++++++++++++++++ crates/router/src/utils.rs | 2 + .../router/src/utils/connector_onboarding.rs | 36 +++ .../src/utils/connector_onboarding/paypal.rs | 89 +++++++ crates/router_env/src/logger/types.rs | 4 + loadtest/config/development.toml | 6 + 25 files changed, 876 insertions(+), 6 deletions(-) create mode 100644 crates/api_models/src/connector_onboarding.rs create mode 100644 crates/api_models/src/events/connector_onboarding.rs create mode 100644 crates/router/src/core/connector_onboarding.rs create mode 100644 crates/router/src/core/connector_onboarding/paypal.rs create mode 100644 crates/router/src/routes/connector_onboarding.rs create mode 100644 crates/router/src/types/api/connector_onboarding.rs create mode 100644 crates/router/src/types/api/connector_onboarding/paypal.rs create mode 100644 crates/router/src/utils/connector_onboarding.rs create mode 100644 crates/router/src/utils/connector_onboarding/paypal.rs diff --git a/config/config.example.toml b/config/config.example.toml index d935a4e7f20d..fad4da3e7c36 100644 --- a/config/config.example.toml +++ b/config/config.example.toml @@ -477,3 +477,9 @@ connection_timeout = 10 # Timeout for database connection in seconds [kv_config] # TTL for KV in seconds ttl = 900 + +[paypal_onboarding] +client_id = "paypal_client_id" # Client ID for PayPal onboarding +client_secret = "paypal_secret_key" # Secret key for PayPal onboarding +partner_id = "paypal_partner_id" # Partner ID for PayPal onboarding +enabled = true # Switch to enable or disable PayPal onboarding diff --git a/config/development.toml b/config/development.toml index fa5fddb0d60a..2eb8b00b9c08 100644 --- a/config/development.toml +++ b/config/development.toml @@ -504,4 +504,10 @@ port = 5432 dbname = "hyperswitch_db" pool_size = 5 connection_timeout = 10 -queue_strategy = "Fifo" \ No newline at end of file +queue_strategy = "Fifo" + +[connector_onboarding.paypal] +client_id = "" +client_secret = "" +partner_id = "" +enabled = true diff --git a/config/docker_compose.toml b/config/docker_compose.toml index 4d50600e1bf8..de90f3c70abd 100644 --- a/config/docker_compose.toml +++ b/config/docker_compose.toml @@ -362,3 +362,9 @@ queue_strategy = "Fifo" [kv_config] ttl = 900 # 15 * 60 seconds + +[connector_onboarding.paypal] +client_id = "" +client_secret = "" +partner_id = "" +enabled = true diff --git a/crates/api_models/src/connector_onboarding.rs b/crates/api_models/src/connector_onboarding.rs new file mode 100644 index 000000000000..759d3cb97f13 --- /dev/null +++ b/crates/api_models/src/connector_onboarding.rs @@ -0,0 +1,54 @@ +use super::{admin, enums}; + +#[derive(serde::Deserialize, serde::Serialize, Debug, Clone)] +pub struct ActionUrlRequest { + pub connector: enums::Connector, + pub connector_id: String, + pub return_url: String, +} + +#[derive(serde::Serialize, Debug, Clone)] +#[serde(rename_all = "lowercase")] +pub enum ActionUrlResponse { + PayPal(PayPalActionUrlResponse), +} + +#[derive(serde::Deserialize, serde::Serialize, Debug, Clone)] +pub struct OnboardingSyncRequest { + pub profile_id: String, + pub connector_id: String, + pub connector: enums::Connector, +} + +#[derive(serde::Serialize, Debug, Clone)] +pub struct PayPalActionUrlResponse { + pub action_url: String, +} + +#[derive(serde::Serialize, Debug, Clone)] +#[serde(rename_all = "lowercase")] +pub enum OnboardingStatus { + PayPal(PayPalOnboardingStatus), +} + +#[derive(serde::Serialize, Debug, Clone)] +#[serde(rename_all = "snake_case")] +pub enum PayPalOnboardingStatus { + AccountNotFound, + PaymentsNotReceivable, + PpcpCustomDenied, + MorePermissionsNeeded, + EmailNotVerified, + Success(PayPalOnboardingDone), + ConnectorIntegrated(admin::MerchantConnectorResponse), +} + +#[derive(serde::Serialize, Debug, Clone)] +pub struct PayPalOnboardingDone { + pub payer_id: String, +} + +#[derive(serde::Serialize, Debug, Clone)] +pub struct PayPalIntegrationDone { + pub connector_id: String, +} diff --git a/crates/api_models/src/events.rs b/crates/api_models/src/events.rs index ac7cdeb83d94..457d3fde05b7 100644 --- a/crates/api_models/src/events.rs +++ b/crates/api_models/src/events.rs @@ -1,3 +1,4 @@ +pub mod connector_onboarding; pub mod customer; pub mod gsm; mod locker_migration; diff --git a/crates/api_models/src/events/connector_onboarding.rs b/crates/api_models/src/events/connector_onboarding.rs new file mode 100644 index 000000000000..998dc384d620 --- /dev/null +++ b/crates/api_models/src/events/connector_onboarding.rs @@ -0,0 +1,12 @@ +use common_utils::events::{ApiEventMetric, ApiEventsType}; + +use crate::connector_onboarding::{ + ActionUrlRequest, ActionUrlResponse, OnboardingStatus, OnboardingSyncRequest, +}; + +common_utils::impl_misc_api_event_type!( + ActionUrlRequest, + ActionUrlResponse, + OnboardingSyncRequest, + OnboardingStatus +); diff --git a/crates/api_models/src/lib.rs b/crates/api_models/src/lib.rs index 056888839a54..ce3c11d9c2f3 100644 --- a/crates/api_models/src/lib.rs +++ b/crates/api_models/src/lib.rs @@ -5,6 +5,7 @@ pub mod api_keys; pub mod bank_accounts; pub mod cards_info; pub mod conditional_configs; +pub mod connector_onboarding; pub mod currency; pub mod customers; pub mod disputes; diff --git a/crates/router/src/configs/kms.rs b/crates/router/src/configs/kms.rs index 37f2d15774a5..bf6ee44d28be 100644 --- a/crates/router/src/configs/kms.rs +++ b/crates/router/src/configs/kms.rs @@ -69,3 +69,36 @@ impl KmsDecrypt for settings::Database { }) } } + +#[cfg(feature = "olap")] +#[async_trait::async_trait] +impl KmsDecrypt for settings::PayPalOnboarding { + type Output = Self; + + async fn decrypt_inner( + mut self, + kms_client: &KmsClient, + ) -> CustomResult { + self.client_id = kms_client.decrypt(self.client_id.expose()).await?.into(); + self.client_secret = kms_client + .decrypt(self.client_secret.expose()) + .await? + .into(); + self.partner_id = kms_client.decrypt(self.partner_id.expose()).await?.into(); + Ok(self) + } +} + +#[cfg(feature = "olap")] +#[async_trait::async_trait] +impl KmsDecrypt for settings::ConnectorOnboarding { + type Output = Self; + + async fn decrypt_inner( + mut self, + kms_client: &KmsClient, + ) -> CustomResult { + self.paypal = self.paypal.decrypt_inner(kms_client).await?; + Ok(self) + } +} diff --git a/crates/router/src/configs/settings.rs b/crates/router/src/configs/settings.rs index f2d962b0abee..68af91d06612 100644 --- a/crates/router/src/configs/settings.rs +++ b/crates/router/src/configs/settings.rs @@ -116,6 +116,8 @@ pub struct Settings { #[cfg(feature = "olap")] pub report_download_config: ReportConfig, pub events: EventsConfig, + #[cfg(feature = "olap")] + pub connector_onboarding: ConnectorOnboarding, } #[derive(Debug, Deserialize, Clone)] @@ -884,3 +886,18 @@ impl<'de> Deserialize<'de> for LockSettings { }) } } + +#[cfg(feature = "olap")] +#[derive(Debug, Deserialize, Clone, Default)] +pub struct ConnectorOnboarding { + pub paypal: PayPalOnboarding, +} + +#[cfg(feature = "olap")] +#[derive(Debug, Deserialize, Clone, Default)] +pub struct PayPalOnboarding { + pub client_id: masking::Secret, + pub client_secret: masking::Secret, + pub partner_id: masking::Secret, + pub enabled: bool, +} diff --git a/crates/router/src/core.rs b/crates/router/src/core.rs index 08de9cf80384..6a167be48dae 100644 --- a/crates/router/src/core.rs +++ b/crates/router/src/core.rs @@ -5,6 +5,8 @@ pub mod cache; pub mod cards_info; pub mod conditional_config; pub mod configs; +#[cfg(feature = "olap")] +pub mod connector_onboarding; #[cfg(any(feature = "olap", feature = "oltp"))] pub mod currency; pub mod customers; diff --git a/crates/router/src/core/connector_onboarding.rs b/crates/router/src/core/connector_onboarding.rs new file mode 100644 index 000000000000..e48026edc2d5 --- /dev/null +++ b/crates/router/src/core/connector_onboarding.rs @@ -0,0 +1,96 @@ +use api_models::{connector_onboarding as api, enums}; +use error_stack::ResultExt; +use masking::Secret; + +use crate::{ + core::errors::{ApiErrorResponse, RouterResponse, RouterResult}, + services::{authentication as auth, ApplicationResponse}, + types::{self as oss_types}, + utils::connector_onboarding as utils, + AppState, +}; + +pub mod paypal; + +#[async_trait::async_trait] +pub trait AccessToken { + async fn access_token(state: &AppState) -> RouterResult; +} + +pub async fn get_action_url( + state: AppState, + request: api::ActionUrlRequest, +) -> RouterResponse { + let connector_onboarding_conf = state.conf.connector_onboarding.clone(); + let is_enabled = utils::is_enabled(request.connector, &connector_onboarding_conf); + + 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, + request.return_url, + )) + .await?; + Ok(ApplicationResponse::Json(api::ActionUrlResponse::PayPal( + api::PayPalActionUrlResponse { action_url }, + ))) + } + _ => Err(ApiErrorResponse::FlowNotSupported { + flow: "Connector onboarding".to_string(), + connector: request.connector.to_string(), + } + .into()), + } +} + +pub async fn sync_onboarding_status( + state: AppState, + 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)?; + let connector_onboarding_conf = state.conf.connector_onboarding.clone(); + let is_enabled = utils::is_enabled(request.connector, &connector_onboarding_conf); + + 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(), + )) + .await?; + if let api::OnboardingStatus::PayPal(api::PayPalOnboardingStatus::Success( + ref inner_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()), + }; + let some_data = paypal::update_mca( + &state, + &merchant_account, + request.connector_id.to_owned(), + auth_details, + ) + .await?; + + return Ok(ApplicationResponse::Json(api::OnboardingStatus::PayPal( + api::PayPalOnboardingStatus::ConnectorIntegrated(some_data), + ))); + } + Ok(ApplicationResponse::Json(status)) + } + _ => Err(ApiErrorResponse::FlowNotSupported { + flow: "Connector onboarding".to_string(), + connector: request.connector.to_string(), + } + .into()), + } +} diff --git a/crates/router/src/core/connector_onboarding/paypal.rs b/crates/router/src/core/connector_onboarding/paypal.rs new file mode 100644 index 000000000000..30aa69067b5d --- /dev/null +++ b/crates/router/src/core/connector_onboarding/paypal.rs @@ -0,0 +1,174 @@ +use api_models::{admin::MerchantConnectorUpdate, connector_onboarding as api}; +use common_utils::ext_traits::Encode; +use error_stack::{IntoReport, ResultExt}; +use masking::{ExposeInterface, PeekInterface, Secret}; + +use crate::{ + core::{ + admin, + errors::{ApiErrorResponse, RouterResult}, + }, + services::{send_request, ApplicationResponse, Request}, + types::{self as oss_types, api as oss_api_types, api::connector_onboarding as types}, + utils::connector_onboarding as utils, + AppState, +}; + +fn build_referral_url(state: AppState) -> String { + format!( + "{}v2/customer/partner-referrals", + state.conf.connectors.paypal.base_url + ) +} + +async fn build_referral_request( + state: AppState, + connector_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); + + utils::paypal::build_paypal_post_request( + build_referral_url(state), + request_body, + access_token.token.expose(), + ) +} + +pub async fn get_action_url_from_paypal( + state: AppState, + connector_id: String, + return_url: String, +) -> RouterResult { + let referral_request = Box::pin(build_referral_request( + state.clone(), + connector_id, + return_url, + )) + .await?; + let referral_response = send_request(&state, referral_request, None) + .await + .change_context(ApiErrorResponse::InternalServerError) + .attach_printable("Failed to send request to paypal referrals")?; + + let parsed_response: types::paypal::PartnerReferralResponse = referral_response + .json() + .await + .into_report() + .change_context(ApiErrorResponse::InternalServerError) + .attach_printable("Failed to parse paypal response")?; + + parsed_response.extract_action_url() +} + +fn merchant_onboarding_status_url(state: AppState, tracking_id: String) -> String { + let partner_id = state.conf.connector_onboarding.paypal.partner_id.to_owned(); + format!( + "{}v1/customer/partners/{}/merchant-integrations?tracking_id={}", + state.conf.connectors.paypal.base_url, + partner_id.expose(), + tracking_id + ) +} + +pub async fn sync_merchant_onboarding_status( + state: AppState, + tracking_id: String, +) -> RouterResult { + let access_token = utils::paypal::generate_access_token(state.clone()).await?; + + let Some(seller_status_response) = + find_paypal_merchant_by_tracking_id(state.clone(), tracking_id, &access_token).await? + else { + return Ok(api::OnboardingStatus::PayPal( + api::PayPalOnboardingStatus::AccountNotFound, + )); + }; + + let merchant_details_url = seller_status_response + .extract_merchant_details_url(&state.conf.connectors.paypal.base_url)?; + + let merchant_details_request = + utils::paypal::build_paypal_get_request(merchant_details_url, access_token.token.expose())?; + + let merchant_details_response = send_request(&state, merchant_details_request, None) + .await + .change_context(ApiErrorResponse::InternalServerError) + .attach_printable("Failed to send request to paypal merchant details")?; + + let parsed_response: types::paypal::SellerStatusDetailsResponse = merchant_details_response + .json() + .await + .into_report() + .change_context(ApiErrorResponse::InternalServerError) + .attach_printable("Failed to parse paypal merchant details response")?; + + let eligibity = parsed_response.get_eligibility_status().await?; + Ok(api::OnboardingStatus::PayPal(eligibity)) +} + +async fn find_paypal_merchant_by_tracking_id( + state: AppState, + tracking_id: String, + access_token: &oss_types::AccessToken, +) -> RouterResult> { + let seller_status_request = utils::paypal::build_paypal_get_request( + merchant_onboarding_status_url(state.clone(), tracking_id), + access_token.token.peek().to_string(), + )?; + let seller_status_response = send_request(&state, seller_status_request, None) + .await + .change_context(ApiErrorResponse::InternalServerError) + .attach_printable("Failed to send request to paypal onboarding status")?; + + if seller_status_response.status().is_success() { + return Ok(Some( + seller_status_response + .json() + .await + .into_report() + .change_context(ApiErrorResponse::InternalServerError) + .attach_printable("Failed to parse paypal onboarding status response")?, + )); + } + Ok(None) +} + +pub async fn update_mca( + state: &AppState, + merchant_account: &oss_types::domain::MerchantAccount, + connector_id: String, + auth_details: oss_types::ConnectorAuthType, +) -> RouterResult { + let connector_auth_json = + Encode::::encode_to_value(&auth_details) + .change_context(ApiErrorResponse::InternalServerError) + .attach_printable("Error while deserializing connector_account_details")?; + + let request = MerchantConnectorUpdate { + connector_type: common_enums::ConnectorType::PaymentProcessor, + connector_account_details: Some(Secret::new(connector_auth_json)), + disabled: Some(false), + status: Some(common_enums::ConnectorStatus::Active), + test_mode: None, + connector_label: None, + payment_methods_enabled: None, + metadata: None, + frm_configs: None, + 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?; + + match mca_response { + ApplicationResponse::Json(mca_data) => Ok(mca_data), + _ => Err(ApiErrorResponse::InternalServerError.into()), + } +} diff --git a/crates/router/src/lib.rs b/crates/router/src/lib.rs index fb8be9636748..3b4c7ce9b7d3 100644 --- a/crates/router/src/lib.rs +++ b/crates/router/src/lib.rs @@ -147,6 +147,7 @@ pub fn mk_app( .service(routes::Gsm::server(state.clone())) .service(routes::PaymentLink::server(state.clone())) .service(routes::User::server(state.clone())) + .service(routes::ConnectorOnboarding::server(state.clone())) } #[cfg(all(feature = "olap", feature = "kms"))] diff --git a/crates/router/src/routes.rs b/crates/router/src/routes.rs index b19ef5d7016b..9b3006692d34 100644 --- a/crates/router/src/routes.rs +++ b/crates/router/src/routes.rs @@ -4,6 +4,8 @@ pub mod app; pub mod cache; pub mod cards_info; pub mod configs; +#[cfg(feature = "olap")] +pub mod connector_onboarding; #[cfg(any(feature = "olap", feature = "oltp"))] pub mod currency; pub mod customers; @@ -47,9 +49,9 @@ pub use self::app::Routing; #[cfg(all(feature = "olap", feature = "kms"))] pub use self::app::Verify; pub use self::app::{ - ApiKeys, AppState, BusinessProfile, Cache, Cards, Configs, Customers, Disputes, EphemeralKey, - Files, Gsm, Health, LockerMigrate, Mandates, MerchantAccount, MerchantConnectorAccount, - PaymentLink, PaymentMethods, Payments, Refunds, User, Webhooks, + ApiKeys, AppState, BusinessProfile, Cache, Cards, Configs, ConnectorOnboarding, Customers, + Disputes, EphemeralKey, Files, Gsm, Health, LockerMigrate, Mandates, MerchantAccount, + MerchantConnectorAccount, PaymentLink, PaymentMethods, Payments, Refunds, User, Webhooks, }; #[cfg(feature = "stripe")] pub use super::compatibility::stripe::StripeApis; diff --git a/crates/router/src/routes/app.rs b/crates/router/src/routes/app.rs index acf98c658a7c..9739d18864b8 100644 --- a/crates/router/src/routes/app.rs +++ b/crates/router/src/routes/app.rs @@ -26,8 +26,8 @@ use super::routing as cloud_routing; use super::verification::{apple_pay_merchant_registration, retrieve_apple_pay_verified_domains}; #[cfg(feature = "olap")] use super::{ - admin::*, api_keys::*, disputes::*, files::*, gsm::*, locker_migration, payment_link::*, - user::*, user_role::*, + admin::*, api_keys::*, connector_onboarding::*, disputes::*, files::*, gsm::*, + locker_migration, payment_link::*, user::*, user_role::*, }; use super::{cache::*, health::*}; #[cfg(any(feature = "olap", feature = "oltp"))] @@ -185,6 +185,16 @@ impl AppState { } }; + #[cfg(all(feature = "kms", feature = "olap"))] + #[allow(clippy::expect_used)] + { + conf.connector_onboarding = conf + .connector_onboarding + .decrypt_inner(kms_client) + .await + .expect("Failed to decrypt connector onboarding credentials"); + } + #[cfg(feature = "olap")] let pool = crate::analytics::AnalyticsProvider::from_conf(&conf.analytics).await; @@ -888,3 +898,15 @@ impl LockerMigrate { ) } } + +pub struct ConnectorOnboarding; + +#[cfg(feature = "olap")] +impl ConnectorOnboarding { + pub fn server(state: AppState) -> Scope { + web::scope("/connector_onboarding") + .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))) + } +} diff --git a/crates/router/src/routes/connector_onboarding.rs b/crates/router/src/routes/connector_onboarding.rs new file mode 100644 index 000000000000..b7c39b3c1d2e --- /dev/null +++ b/crates/router/src/routes/connector_onboarding.rs @@ -0,0 +1,47 @@ +use actix_web::{web, HttpRequest, HttpResponse}; +use api_models::connector_onboarding as api_types; +use router_env::Flow; + +use super::AppState; +use crate::{ + core::{api_locking, connector_onboarding as core}, + services::{api, authentication as auth, authorization::permissions::Permission}, +}; + +pub async fn get_action_url( + state: web::Data, + http_req: HttpRequest, + json_payload: web::Json, +) -> HttpResponse { + let flow = Flow::GetActionUrl; + let req_payload = json_payload.into_inner(); + Box::pin(api::server_wrap( + flow.clone(), + state, + &http_req, + req_payload.clone(), + |state, _: auth::UserFromToken, req| core::get_action_url(state, req), + &auth::JWTAuth(Permission::MerchantAccountWrite), + api_locking::LockAction::NotApplicable, + )) + .await +} + +pub async fn sync_onboarding_status( + state: web::Data, + http_req: HttpRequest, + json_payload: web::Json, +) -> HttpResponse { + let flow = Flow::SyncOnboardingStatus; + let req_payload = json_payload.into_inner(); + Box::pin(api::server_wrap( + flow.clone(), + state, + &http_req, + req_payload.clone(), + core::sync_onboarding_status, + &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 0c850922fff4..dcae11f58b76 100644 --- a/crates/router/src/routes/lock_utils.rs +++ b/crates/router/src/routes/lock_utils.rs @@ -28,6 +28,7 @@ pub enum ApiIdentifier { Gsm, User, UserRole, + ConnectorOnboarding, } impl From for ApiIdentifier { @@ -171,6 +172,8 @@ impl From for ApiIdentifier { Flow::ListRoles | Flow::GetRole | Flow::UpdateUserRole | Flow::GetAuthorizationInfo => { Self::UserRole } + + Flow::GetActionUrl | Flow::SyncOnboardingStatus => Self::ConnectorOnboarding, } } } diff --git a/crates/router/src/types/api.rs b/crates/router/src/types/api.rs index c74608ea20a1..0ec158199cea 100644 --- a/crates/router/src/types/api.rs +++ b/crates/router/src/types/api.rs @@ -1,6 +1,8 @@ pub mod admin; pub mod api_keys; pub mod configs; +#[cfg(feature = "olap")] +pub mod connector_onboarding; pub mod customers; pub mod disputes; pub mod enums; diff --git a/crates/router/src/types/api/connector_onboarding.rs b/crates/router/src/types/api/connector_onboarding.rs new file mode 100644 index 000000000000..5b1d581a20ef --- /dev/null +++ b/crates/router/src/types/api/connector_onboarding.rs @@ -0,0 +1 @@ +pub mod paypal; diff --git a/crates/router/src/types/api/connector_onboarding/paypal.rs b/crates/router/src/types/api/connector_onboarding/paypal.rs new file mode 100644 index 000000000000..0cc026d4d7ad --- /dev/null +++ b/crates/router/src/types/api/connector_onboarding/paypal.rs @@ -0,0 +1,247 @@ +use api_models::connector_onboarding as api; +use error_stack::{IntoReport, ResultExt}; + +use crate::core::errors::{ApiErrorResponse, RouterResult}; + +#[derive(serde::Deserialize, Debug)] +pub struct HateoasLink { + pub href: String, + pub rel: String, + pub method: String, +} + +#[derive(serde::Deserialize, Debug)] +pub struct PartnerReferralResponse { + pub links: Vec, +} + +#[derive(serde::Serialize, Debug)] +pub struct PartnerReferralRequest { + pub tracking_id: String, + pub operations: Vec, + pub products: Vec, + pub capabilities: Vec, + pub partner_config_override: PartnerConfigOverride, + pub legal_consents: Vec, +} + +#[derive(serde::Serialize, Debug)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum PayPalProducts { + Ppcp, + AdvancedVaulting, +} + +#[derive(serde::Serialize, Debug)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum PayPalCapabilities { + PaypalWalletVaultingAdvanced, +} + +#[derive(serde::Serialize, Debug)] +pub struct PartnerReferralOperations { + pub operation: PayPalReferralOperationType, + pub api_integration_preference: PartnerReferralIntegrationPreference, +} + +#[derive(serde::Serialize, Debug)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum PayPalReferralOperationType { + ApiIntegration, +} + +#[derive(serde::Serialize, Debug)] +pub struct PartnerReferralIntegrationPreference { + pub rest_api_integration: PartnerReferralRestApiIntegration, +} + +#[derive(serde::Serialize, Debug)] +pub struct PartnerReferralRestApiIntegration { + pub integration_method: IntegrationMethod, + pub integration_type: PayPalIntegrationType, + pub third_party_details: PartnerReferralThirdPartyDetails, +} + +#[derive(serde::Serialize, Debug)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum IntegrationMethod { + Paypal, +} + +#[derive(serde::Serialize, Debug)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum PayPalIntegrationType { + ThirdParty, +} + +#[derive(serde::Serialize, Debug)] +pub struct PartnerReferralThirdPartyDetails { + pub features: Vec, +} + +#[derive(serde::Serialize, Debug)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum PayPalFeatures { + Payment, + Refund, + Vault, + AccessMerchantInformation, + BillingAgreement, + ReadSellerDispute, +} + +#[derive(serde::Serialize, Debug)] +pub struct PartnerConfigOverride { + pub partner_logo_url: String, + pub return_url: String, +} + +#[derive(serde::Serialize, Debug)] +pub struct LegalConsent { + #[serde(rename = "type")] + pub consent_type: LegalConsentType, + pub granted: bool, +} + +#[derive(serde::Serialize, Debug)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum LegalConsentType { + ShareDataConsent, +} + +impl PartnerReferralRequest { + pub fn new(tracking_id: String, return_url: String) -> Self { + Self { + tracking_id, + operations: vec![PartnerReferralOperations { + operation: PayPalReferralOperationType::ApiIntegration, + api_integration_preference: PartnerReferralIntegrationPreference { + rest_api_integration: PartnerReferralRestApiIntegration { + integration_method: IntegrationMethod::Paypal, + integration_type: PayPalIntegrationType::ThirdParty, + third_party_details: PartnerReferralThirdPartyDetails { + features: vec![ + PayPalFeatures::Payment, + PayPalFeatures::Refund, + PayPalFeatures::Vault, + PayPalFeatures::AccessMerchantInformation, + PayPalFeatures::BillingAgreement, + PayPalFeatures::ReadSellerDispute, + ], + }, + }, + }, + }], + products: vec![PayPalProducts::Ppcp, PayPalProducts::AdvancedVaulting], + capabilities: vec![PayPalCapabilities::PaypalWalletVaultingAdvanced], + partner_config_override: PartnerConfigOverride { + partner_logo_url: "https://hyperswitch.io/img/websiteIcon.svg".to_string(), + return_url, + }, + legal_consents: vec![LegalConsent { + consent_type: LegalConsentType::ShareDataConsent, + granted: true, + }], + } + } +} + +#[derive(serde::Deserialize, Debug)] +pub struct SellerStatusResponse { + pub merchant_id: String, + pub links: Vec, +} + +#[derive(serde::Deserialize, Debug)] +pub struct SellerStatusDetailsResponse { + pub merchant_id: String, + pub primary_email_confirmed: bool, + pub payments_receivable: bool, + pub products: Vec, +} + +#[derive(serde::Deserialize, Debug)] +pub struct SellerStatusProducts { + pub name: String, + pub vetting_status: Option, +} + +#[derive(serde::Deserialize, Debug, Clone)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum VettingStatus { + NeedMoreData, + Subscribed, + Denied, +} + +impl SellerStatusResponse { + pub fn extract_merchant_details_url(self, paypal_base_url: &str) -> RouterResult { + self.links + .get(0) + .and_then(|link| link.href.strip_prefix('/')) + .map(|link| format!("{}{}", paypal_base_url, link)) + .ok_or(ApiErrorResponse::InternalServerError) + .into_report() + .attach_printable("Merchant details not received in onboarding status") + } +} + +impl SellerStatusDetailsResponse { + pub fn check_payments_receivable(&self) -> Option { + if !self.payments_receivable { + return Some(api::PayPalOnboardingStatus::PaymentsNotReceivable); + } + None + } + + pub fn check_ppcp_custom_status(&self) -> Option { + match self.get_ppcp_custom_status() { + Some(VettingStatus::Denied) => Some(api::PayPalOnboardingStatus::PpcpCustomDenied), + Some(VettingStatus::Subscribed) => None, + _ => Some(api::PayPalOnboardingStatus::MorePermissionsNeeded), + } + } + + fn check_email_confirmation(&self) -> Option { + if !self.primary_email_confirmed { + return Some(api::PayPalOnboardingStatus::EmailNotVerified); + } + None + } + + pub async fn get_eligibility_status(&self) -> RouterResult { + Ok(self + .check_payments_receivable() + .or(self.check_email_confirmation()) + .or(self.check_ppcp_custom_status()) + .unwrap_or(api::PayPalOnboardingStatus::Success( + api::PayPalOnboardingDone { + payer_id: self.get_payer_id(), + }, + ))) + } + + fn get_ppcp_custom_status(&self) -> Option { + self.products + .iter() + .find(|product| product.name == "PPCP_CUSTOM") + .and_then(|ppcp_custom| ppcp_custom.vetting_status.clone()) + } + + fn get_payer_id(&self) -> String { + self.merchant_id.to_string() + } +} + +impl PartnerReferralResponse { + pub fn extract_action_url(self) -> RouterResult { + Ok(self + .links + .into_iter() + .find(|hateoas_link| hateoas_link.rel == "action_url") + .ok_or(ApiErrorResponse::InternalServerError) + .into_report() + .attach_printable("Failed to get action_url from paypal response")? + .href) + } +} diff --git a/crates/router/src/utils.rs b/crates/router/src/utils.rs index f1590342e17c..42116e1ecbf0 100644 --- a/crates/router/src/utils.rs +++ b/crates/router/src/utils.rs @@ -1,3 +1,5 @@ +#[cfg(feature = "olap")] +pub mod connector_onboarding; pub mod currency; pub mod custom_serde; pub mod db_utils; diff --git a/crates/router/src/utils/connector_onboarding.rs b/crates/router/src/utils/connector_onboarding.rs new file mode 100644 index 000000000000..e8afcd68a468 --- /dev/null +++ b/crates/router/src/utils/connector_onboarding.rs @@ -0,0 +1,36 @@ +use crate::{ + core::errors::{api_error_response::NotImplementedMessage, ApiErrorResponse, RouterResult}, + routes::app::settings, + types::{self, api::enums}, +}; + +pub mod paypal; + +pub fn get_connector_auth( + connector: enums::Connector, + connector_data: &settings::ConnectorOnboarding, +) -> RouterResult { + match connector { + enums::Connector::Paypal => Ok(types::ConnectorAuthType::BodyKey { + api_key: connector_data.paypal.client_secret.clone(), + key1: connector_data.paypal.client_id.clone(), + }), + _ => Err(ApiErrorResponse::NotImplemented { + message: NotImplementedMessage::Reason(format!( + "Onboarding is not implemented for {}", + connector + )), + } + .into()), + } +} + +pub fn is_enabled( + connector: types::Connector, + conf: &settings::ConnectorOnboarding, +) -> Option { + match connector { + enums::Connector::Paypal => Some(conf.paypal.enabled), + _ => None, + } +} diff --git a/crates/router/src/utils/connector_onboarding/paypal.rs b/crates/router/src/utils/connector_onboarding/paypal.rs new file mode 100644 index 000000000000..c803775be071 --- /dev/null +++ b/crates/router/src/utils/connector_onboarding/paypal.rs @@ -0,0 +1,89 @@ +use common_utils::{ + ext_traits::Encode, + request::{Method, Request, RequestBuilder}, +}; +use error_stack::{IntoReport, ResultExt}; +use http::header; +use serde_json::json; + +use crate::{ + connector, + core::errors::{ApiErrorResponse, RouterResult}, + routes::AppState, + types, + types::api::{ + enums, + verify_connector::{self as verify_connector_types, VerifyConnector}, + }, + utils::verify_connector as verify_connector_utils, +}; + +pub async fn generate_access_token(state: AppState) -> RouterResult { + let connector = enums::Connector::Paypal; + let boxed_connector = types::api::ConnectorData::convert_connector( + &state.conf.connectors, + connector.to_string().as_str(), + )?; + let connector_auth = super::get_connector_auth(connector, &state.conf.connector_onboarding)?; + + connector::Paypal::get_access_token( + &state, + verify_connector_types::VerifyConnectorData { + connector: *boxed_connector, + connector_auth, + card_details: verify_connector_utils::get_test_card_details(connector)? + .ok_or(ApiErrorResponse::FlowNotSupported { + flow: "Connector onboarding".to_string(), + connector: connector.to_string(), + }) + .into_report()?, + }, + ) + .await? + .ok_or(ApiErrorResponse::InternalServerError) + .into_report() + .attach_printable("Error occurred while retrieving access token") +} + +pub fn build_paypal_post_request( + url: String, + body: T, + access_token: String, +) -> RouterResult +where + T: serde::Serialize, +{ + let body = types::RequestBody::log_and_get_request_body( + &json!(body), + Encode::::encode_to_string_of_json, + ) + .change_context(ApiErrorResponse::InternalServerError) + .attach_printable("Failed to build request body")?; + + Ok(RequestBuilder::new() + .method(Method::Post) + .url(&url) + .attach_default_headers() + .header( + header::AUTHORIZATION.to_string().as_str(), + format!("Bearer {}", access_token).as_str(), + ) + .header( + header::CONTENT_TYPE.to_string().as_str(), + "application/json", + ) + .body(Some(body)) + .build()) +} + +pub fn build_paypal_get_request(url: String, access_token: String) -> RouterResult { + Ok(RequestBuilder::new() + .method(Method::Get) + .url(&url) + .attach_default_headers() + .header( + header::AUTHORIZATION.to_string().as_str(), + format!("Bearer {}", access_token).as_str(), + ) + .build()) +} diff --git a/crates/router_env/src/logger/types.rs b/crates/router_env/src/logger/types.rs index d35090551de7..4948bdd575b3 100644 --- a/crates/router_env/src/logger/types.rs +++ b/crates/router_env/src/logger/types.rs @@ -301,6 +301,10 @@ pub enum Flow { InviteUser, /// Incremental Authorization flow PaymentsIncrementalAuthorization, + /// Get action URL for connector onboarding + GetActionUrl, + /// Sync connector onboarding status + SyncOnboardingStatus, } /// diff --git a/loadtest/config/development.toml b/loadtest/config/development.toml index bec1074b99d0..2159d2d7994f 100644 --- a/loadtest/config/development.toml +++ b/loadtest/config/development.toml @@ -262,3 +262,9 @@ connection_timeout = 10 [kv_config] ttl = 300 # 5 * 60 seconds + +[connector_onboarding.paypal] +client_id = "" +client_secret = "" +partner_id = "" +enabled = true