From 7f0d04fe3782cf6777c67e40e708c7abb5c4f45e Mon Sep 17 00:00:00 2001 From: Sakil Mostak <73734619+Sakilmostak@users.noreply.github.com> Date: Thu, 25 Apr 2024 12:02:42 +0530 Subject: [PATCH] feat(core): [Payouts] Add access_token flow for Payout Create and Fulfill flow (#4375) Co-authored-by: hyperswitch-bot[bot] <148525504+hyperswitch-bot[bot]@users.noreply.github.com> --- crates/api_models/src/enums.rs | 23 ++ crates/connector_configs/src/connector.rs | 1 + crates/router/src/core/payouts.rs | 284 ++++++++++++------ .../router/src/core/payouts/access_token.rs | 194 ++++++++++++ crates/router/src/core/payouts/retry.rs | 2 +- openapi/openapi_spec.json | 3 +- 6 files changed, 420 insertions(+), 87 deletions(-) create mode 100644 crates/router/src/core/payouts/access_token.rs diff --git a/crates/api_models/src/enums.rs b/crates/api_models/src/enums.rs index 3aa2a676d4..6bdb51281f 100644 --- a/crates/api_models/src/enums.rs +++ b/crates/api_models/src/enums.rs @@ -135,6 +135,25 @@ pub enum Connector { } impl Connector { + #[cfg(feature = "payouts")] + pub fn supports_instant_payout(&self, payout_method: PayoutType) -> bool { + matches!( + (self, payout_method), + (Self::Paypal, PayoutType::Wallet) | (_, PayoutType::Card) + ) + } + #[cfg(feature = "payouts")] + pub fn supports_create_recipient(&self, payout_method: PayoutType) -> bool { + matches!((self, payout_method), (_, PayoutType::Bank)) + } + #[cfg(feature = "payouts")] + pub fn supports_payout_eligibility(&self, payout_method: PayoutType) -> bool { + matches!((self, payout_method), (_, PayoutType::Card)) + } + #[cfg(feature = "payouts")] + pub fn supports_access_token_for_payout(&self, payout_method: PayoutType) -> bool { + matches!((self, payout_method), (Self::Paypal, _)) + } pub fn supports_access_token(&self, payment_method: PaymentMethod) -> bool { matches!( (self, payment_method), @@ -320,6 +339,7 @@ pub enum AuthenticationConnectors { pub enum PayoutConnectors { Adyen, Wise, + Paypal, } #[cfg(feature = "payouts")] @@ -328,6 +348,7 @@ impl From for RoutableConnectors { match value { PayoutConnectors::Adyen => Self::Adyen, PayoutConnectors::Wise => Self::Wise, + PayoutConnectors::Paypal => Self::Paypal, } } } @@ -338,6 +359,7 @@ impl From for Connector { match value { PayoutConnectors::Adyen => Self::Adyen, PayoutConnectors::Wise => Self::Wise, + PayoutConnectors::Paypal => Self::Paypal, } } } @@ -349,6 +371,7 @@ impl TryFrom for PayoutConnectors { match value { Connector::Adyen => Ok(Self::Adyen), Connector::Wise => Ok(Self::Wise), + Connector::Paypal => Ok(Self::Paypal), _ => Err(format!("Invalid payout connector {}", value)), } } diff --git a/crates/connector_configs/src/connector.rs b/crates/connector_configs/src/connector.rs index 47dad675a4..d7ae7b8a01 100644 --- a/crates/connector_configs/src/connector.rs +++ b/crates/connector_configs/src/connector.rs @@ -215,6 +215,7 @@ impl ConnectorConfig { match connector { PayoutConnectors::Adyen => Ok(connector_data.adyen_payout), PayoutConnectors::Wise => Ok(connector_data.wise_payout), + PayoutConnectors::Paypal => Ok(connector_data.paypal), } } diff --git a/crates/router/src/core/payouts.rs b/crates/router/src/core/payouts.rs index 47ea7ff376..1d791e7867 100644 --- a/crates/router/src/core/payouts.rs +++ b/crates/router/src/core/payouts.rs @@ -1,3 +1,4 @@ +pub mod access_token; pub mod helpers; #[cfg(feature = "payout_retry")] pub mod retry; @@ -160,7 +161,7 @@ pub async fn make_connector_decision( key_store, req, &connector_data, - &mut payout_data, + payout_data, ) .await?; @@ -200,7 +201,7 @@ pub async fn make_connector_decision( key_store, req, &connector_data, - &mut payout_data, + payout_data, ) .await?; @@ -858,7 +859,7 @@ pub async fn call_connector_payout( key_store: &domain::MerchantKeyStore, req: &payouts::PayoutCreateRequest, connector_data: &api::ConnectorData, - payout_data: &mut PayoutData, + mut payout_data: PayoutData, ) -> RouterResult { let payout_attempt = &payout_data.payout_attempt.to_owned(); let payouts = &payout_data.payouts.to_owned(); @@ -896,7 +897,7 @@ pub async fn call_connector_payout( &payout_attempt.merchant_id, Some(&payouts.payout_type), key_store, - Some(payout_data), + Some(&mut payout_data), merchant_account.storage_scheme, ) .await? @@ -906,96 +907,83 @@ pub async fn call_connector_payout( if let Some(true) = req.confirm { // Eligibility flow - if payouts.payout_type == storage_enums::PayoutType::Card - && payout_attempt.is_eligible.is_none() - { - *payout_data = check_payout_eligibility( - state, - merchant_account, - key_store, - req, - connector_data, - payout_data, - ) - .await - .attach_printable("Eligibility failed for given Payout request")?; - } + payout_data = complete_payout_eligibility( + state, + merchant_account, + key_store, + req, + connector_data, + payout_data, + ) + .await?; + + // Create customer flow + payout_data = complete_create_recipient( + state, + merchant_account, + key_store, + req, + connector_data, + payout_data, + ) + .await?; // Payout creation flow - utils::when( - !payout_attempt - .is_eligible - .unwrap_or(state.conf.payouts.payout_eligibility), - || { - Err(report!(errors::ApiErrorResponse::PayoutFailed { - data: Some(serde_json::json!({ - "message": "Payout method data is invalid" - })) - }) - .attach_printable("Payout data provided is invalid")) - }, - )?; - if payout_data.payouts.payout_type == storage_enums::PayoutType::Bank - && payout_data.payout_attempt.status == storage_enums::PayoutStatus::RequiresCreation - { - // Create customer flow - *payout_data = create_recipient( - state, - merchant_account, - key_store, - req, - connector_data, - payout_data, - ) - .await - .attach_printable("Creation of customer failed")?; - - // Create payout flow - *payout_data = create_payout( - state, - merchant_account, - key_store, - req, - connector_data, - payout_data, - ) - .await - .attach_printable("Payout creation failed for given Payout request")?; - } - - if payout_data.payouts.payout_type == storage_enums::PayoutType::Wallet - && payout_data.payout_attempt.status == storage_enums::PayoutStatus::RequiresCreation - { - // Create payout flow - *payout_data = create_payout( - state, - merchant_account, - key_store, - req, - connector_data, - payout_data, - ) - .await - .attach_printable("Payout creation failed for given Payout request")?; - } + payout_data = complete_create_payout( + state, + merchant_account, + key_store, + req, + connector_data, + payout_data, + ) + .await?; }; // Auto fulfillment flow let status = payout_data.payout_attempt.status; if payouts.auto_fulfill && status == storage_enums::PayoutStatus::RequiresFulfillment { - *payout_data = fulfill_payout( + payout_data = fulfill_payout( state, merchant_account, key_store, &payouts::PayoutRequest::PayoutCreateRequest(req.to_owned()), connector_data, - payout_data, + &mut payout_data, ) .await .attach_printable("Payout fulfillment failed for given Payout request")?; } - Ok(payout_data.to_owned()) + Ok(payout_data) +} + +pub async fn complete_create_recipient( + state: &AppState, + merchant_account: &domain::MerchantAccount, + key_store: &domain::MerchantKeyStore, + req: &payouts::PayoutCreateRequest, + connector_data: &api::ConnectorData, + mut payout_data: PayoutData, +) -> RouterResult { + if payout_data.payout_attempt.status == storage_enums::PayoutStatus::RequiresCreation + && connector_data + .connector_name + .supports_create_recipient(payout_data.payouts.payout_type) + { + payout_data = create_recipient( + state, + merchant_account, + key_store, + req, + connector_data, + &mut payout_data, + ) + .await + .attach_printable("Creation of customer failed")?; + } + + Ok(payout_data) } pub async fn create_recipient( @@ -1084,6 +1072,50 @@ pub async fn create_recipient( Ok(payout_data.clone()) } +pub async fn complete_payout_eligibility( + state: &AppState, + merchant_account: &domain::MerchantAccount, + key_store: &domain::MerchantKeyStore, + req: &payouts::PayoutCreateRequest, + connector_data: &api::ConnectorData, + mut payout_data: PayoutData, +) -> RouterResult { + let payout_attempt = &payout_data.payout_attempt.to_owned(); + + if payout_attempt.is_eligible.is_none() + && connector_data + .connector_name + .supports_payout_eligibility(payout_data.payouts.payout_type) + { + payout_data = check_payout_eligibility( + state, + merchant_account, + key_store, + req, + connector_data, + &mut payout_data, + ) + .await + .attach_printable("Eligibility failed for given Payout request")?; + } + + utils::when( + !payout_attempt + .is_eligible + .unwrap_or(state.conf.payouts.payout_eligibility), + || { + Err(report!(errors::ApiErrorResponse::PayoutFailed { + data: Some(serde_json::json!({ + "message": "Payout method data is invalid" + })) + }) + .attach_printable("Payout data provided is invalid")) + }, + )?; + + Ok(payout_data) +} + pub async fn check_payout_eligibility( state: &AppState, merchant_account: &domain::MerchantAccount, @@ -1200,6 +1232,68 @@ pub async fn check_payout_eligibility( Ok(payout_data.clone()) } +pub async fn complete_create_payout( + state: &AppState, + merchant_account: &domain::MerchantAccount, + key_store: &domain::MerchantKeyStore, + req: &payouts::PayoutCreateRequest, + connector_data: &api::ConnectorData, + mut payout_data: PayoutData, +) -> RouterResult { + if payout_data.payout_attempt.status == storage_enums::PayoutStatus::RequiresCreation { + if connector_data + .connector_name + .supports_instant_payout(payout_data.payouts.payout_type) + { + // create payout_object only in router + let db = &*state.store; + let payout_attempt = &payout_data.payout_attempt; + let updated_payout_attempt = storage::PayoutAttemptUpdate::StatusUpdate { + connector_payout_id: "".to_string(), + status: storage::enums::PayoutStatus::RequiresFulfillment, + error_code: None, + error_message: None, + is_eligible: None, + }; + payout_data.payout_attempt = db + .update_payout_attempt( + payout_attempt, + updated_payout_attempt, + &payout_data.payouts, + merchant_account.storage_scheme, + ) + .await + .change_context(errors::ApiErrorResponse::InternalServerError) + .attach_printable("Error updating payout_attempt in db")?; + payout_data.payouts = db + .update_payout( + &payout_data.payouts, + storage::PayoutsUpdate::StatusUpdate { + status: storage::enums::PayoutStatus::RequiresFulfillment, + }, + &payout_data.payout_attempt, + merchant_account.storage_scheme, + ) + .await + .change_context(errors::ApiErrorResponse::InternalServerError) + .attach_printable("Error updating payouts in db")?; + } else { + // create payout_object in connector as well as router + payout_data = create_payout( + state, + merchant_account, + key_store, + req, + connector_data, + &mut payout_data, + ) + .await + .attach_printable("Payout creation failed for given Payout request")?; + } + } + Ok(payout_data) +} + pub async fn create_payout( state: &AppState, merchant_account: &domain::MerchantAccount, @@ -1219,7 +1313,17 @@ pub async fn create_payout( ) .await?; - // 2. Fetch connector integration details + // 2. Get/Create access token + access_token::create_access_token( + state, + connector_data, + merchant_account, + &mut router_data, + payout_data.payouts.payout_type.to_owned(), + ) + .await?; + + // 3. Fetch connector integration details let connector_integration: services::BoxedConnectorIntegration< '_, api::PoCreate, @@ -1227,13 +1331,13 @@ pub async fn create_payout( types::PayoutsResponseData, > = connector_data.connector.get_connector_integration(); - // 3. Execute pretasks + // 4. Execute pretasks connector_integration .execute_pretasks(&mut router_data, state) .await .to_payout_failed_response()?; - // 4. Call connector service + // 5. Call connector service let router_data_resp = services::execute_connector_processing_step( state, connector_integration, @@ -1244,7 +1348,7 @@ pub async fn create_payout( .await .to_payout_failed_response()?; - // 5. Process data returned by the connector + // 6. Process data returned by the connector let db = &*state.store; match router_data_resp.response { Ok(payout_response_data) => { @@ -1439,7 +1543,7 @@ pub async fn fulfill_payout( payout_data: &mut PayoutData, ) -> RouterResult { // 1. Form Router data - let router_data = core_utils::construct_payout_router_data( + let mut router_data = core_utils::construct_payout_router_data( state, &connector_data.connector_name.to_string(), merchant_account, @@ -1449,7 +1553,17 @@ pub async fn fulfill_payout( ) .await?; - // 2. Fetch connector integration details + // 2. Get/Create access token + access_token::create_access_token( + state, + connector_data, + merchant_account, + &mut router_data, + payout_data.payouts.payout_type.to_owned(), + ) + .await?; + + // 3. Fetch connector integration details let connector_integration: services::BoxedConnectorIntegration< '_, api::PoFulfill, @@ -1457,7 +1571,7 @@ pub async fn fulfill_payout( types::PayoutsResponseData, > = connector_data.connector.get_connector_integration(); - // 3. Call connector service + // 4. Call connector service let router_data_resp = services::execute_connector_processing_step( state, connector_integration, @@ -1468,7 +1582,7 @@ pub async fn fulfill_payout( .await .to_payout_failed_response()?; - // 4. Process data returned by the connector + // 5. Process data returned by the connector let db = &*state.store; match router_data_resp.response { Ok(payout_response_data) => { diff --git a/crates/router/src/core/payouts/access_token.rs b/crates/router/src/core/payouts/access_token.rs new file mode 100644 index 0000000000..8dddc0c570 --- /dev/null +++ b/crates/router/src/core/payouts/access_token.rs @@ -0,0 +1,194 @@ +use common_utils::ext_traits::AsyncExt; +use error_stack::ResultExt; + +use crate::{ + consts, + core::{ + errors::{self, RouterResult}, + payments, + }, + routes::{metrics, AppState}, + services, + types::{self, api as api_types, domain, storage::enums}, +}; + +/// After we get the access token, check if there was an error and if the flow should proceed further +/// Everything is well, continue with the flow +/// There was an error, cannot proceed further +#[cfg(feature = "payouts")] +pub async fn create_access_token( + state: &AppState, + connector_data: &api_types::ConnectorData, + merchant_account: &domain::MerchantAccount, + router_data: &mut types::PayoutsRouterData, + payout_type: enums::PayoutType, +) -> RouterResult<()> { + let connector_access_token = add_access_token_for_payout( + state, + connector_data, + merchant_account, + router_data, + payout_type, + ) + .await?; + + if connector_access_token.connector_supports_access_token { + match connector_access_token.access_token_result { + Ok(access_token) => { + router_data.access_token = access_token; + } + Err(connector_error) => { + router_data.response = Err(connector_error); + } + } + } + + Ok(()) +} + +#[cfg(feature = "payouts")] +pub async fn add_access_token_for_payout( + state: &AppState, + connector: &api_types::ConnectorData, + merchant_account: &domain::MerchantAccount, + router_data: &types::PayoutsRouterData, + payout_type: enums::PayoutType, +) -> RouterResult { + if connector + .connector_name + .supports_access_token_for_payout(payout_type) + { + let merchant_id = &merchant_account.merchant_id; + let store = &*state.store; + let old_access_token = store + .get_access_token(merchant_id, connector.connector.id()) + .await + .change_context(errors::ApiErrorResponse::InternalServerError) + .attach_printable("DB error when accessing the access token")?; + + let res = match old_access_token { + Some(access_token) => Ok(Some(access_token)), + None => { + let cloned_router_data = router_data.clone(); + let refresh_token_request_data = types::AccessTokenRequestData::try_from( + router_data.connector_auth_type.clone(), + ) + .attach_printable( + "Could not create access token request, invalid connector account credentials", + )?; + + let refresh_token_response_data: Result = + Err(types::ErrorResponse::default()); + let refresh_token_router_data = payments::helpers::router_data_type_conversion::< + _, + api_types::AccessTokenAuth, + _, + _, + _, + _, + >( + cloned_router_data, + refresh_token_request_data, + refresh_token_response_data, + ); + refresh_connector_auth( + state, + connector, + merchant_account, + &refresh_token_router_data, + ) + .await? + .async_map(|access_token| async { + //Store the access token in db + let store = &*state.store; + // This error should not be propagated, we don't want payments to fail once we have + // the access token, the next request will create new access token + let _ = store + .set_access_token( + merchant_id, + connector.connector.id(), + access_token.clone(), + ) + .await + .change_context(errors::ApiErrorResponse::InternalServerError) + .attach_printable("DB error when setting the access token"); + Some(access_token) + }) + .await + } + }; + + Ok(types::AddAccessTokenResult { + access_token_result: res, + connector_supports_access_token: true, + }) + } else { + Ok(types::AddAccessTokenResult { + access_token_result: Err(types::ErrorResponse::default()), + connector_supports_access_token: false, + }) + } +} + +#[cfg(feature = "payouts")] +pub async fn refresh_connector_auth( + state: &AppState, + connector: &api_types::ConnectorData, + _merchant_account: &domain::MerchantAccount, + router_data: &types::RouterData< + api_types::AccessTokenAuth, + types::AccessTokenRequestData, + types::AccessToken, + >, +) -> RouterResult> { + let connector_integration: services::BoxedConnectorIntegration< + '_, + api_types::AccessTokenAuth, + types::AccessTokenRequestData, + types::AccessToken, + > = connector.connector.get_connector_integration(); + + let access_token_router_data_result = services::execute_connector_processing_step( + state, + connector_integration, + router_data, + payments::CallConnectorAction::Trigger, + None, + ) + .await; + + let access_token_router_data = match access_token_router_data_result { + Ok(router_data) => Ok(router_data.response), + Err(connector_error) => { + // If we receive a timeout error from the connector, then + // the error has to be handled gracefully by updating the payment status to failed. + // further payment flow will not be continued + if connector_error.current_context().is_connector_timeout() { + let error_response = types::ErrorResponse { + code: consts::REQUEST_TIMEOUT_ERROR_CODE.to_string(), + message: consts::REQUEST_TIMEOUT_ERROR_MESSAGE.to_string(), + reason: Some(consts::REQUEST_TIMEOUT_ERROR_MESSAGE.to_string()), + status_code: 504, + attempt_status: None, + connector_transaction_id: None, + }; + + Ok(Err(error_response)) + } else { + Err(connector_error + .change_context(errors::ApiErrorResponse::InternalServerError) + .attach_printable("Could not refresh access token")) + } + } + }?; + + metrics::ACCESS_TOKEN_CREATION.add( + &metrics::CONTEXT, + 1, + &[metrics::request::add_attributes( + "connector", + connector.connector_name.to_string(), + )], + ); + Ok(access_token_router_data) +} diff --git a/crates/router/src/core/payouts/retry.rs b/crates/router/src/core/payouts/retry.rs index 7c0b826560..af8b016d88 100644 --- a/crates/router/src/core/payouts/retry.rs +++ b/crates/router/src/core/payouts/retry.rs @@ -258,7 +258,7 @@ pub async fn do_retry( key_store, req, &connector, - &mut payout_data, + payout_data, ) .await } diff --git a/openapi/openapi_spec.json b/openapi/openapi_spec.json index 913782448f..b64756fc43 100644 --- a/openapi/openapi_spec.json +++ b/openapi/openapi_spec.json @@ -15607,7 +15607,8 @@ "type": "string", "enum": [ "adyen", - "wise" + "wise", + "paypal" ] }, "PayoutCreateRequest": {