From 9e8df8459013d2cda699c0e733f9f9b9a332bb7a Mon Sep 17 00:00:00 2001 From: chikke srujan <121822803+srujanchikke@users.noreply.github.com> Date: Fri, 8 Aug 2025 02:38:34 -0700 Subject: [PATCH] feat(recovery): add support for custom billing api for v2 (#8838) Co-authored-by: Chikke Srujan Co-authored-by: hyperswitch-bot[bot] <148525504+hyperswitch-bot[bot]@users.noreply.github.com> --- crates/api_models/src/events/payment.rs | 16 +- crates/api_models/src/payments.rs | 104 ++++++ crates/common_types/src/payments.rs | 18 + .../src/connectors/custombilling.rs | 10 +- .../connectors/custombilling/transformers.rs | 19 +- .../src/revenue_recovery.rs | 74 ----- .../router/src/core/revenue_recovery/api.rs | 130 +++++++- .../src/core/revenue_recovery/transformers.rs | 73 +++++ .../router/src/core/webhooks/incoming_v2.rs | 1 - .../src/core/webhooks/recovery_incoming.rs | 309 +++++++++++++----- crates/router/src/routes/app.rs | 4 + crates/router/src/routes/lock_utils.rs | 3 +- crates/router/src/routes/payments.rs | 36 ++ crates/router/src/types/api/payments.rs | 1 + crates/router_env/src/logger/types.rs | 2 + 15 files changed, 622 insertions(+), 178 deletions(-) diff --git a/crates/api_models/src/events/payment.rs b/crates/api_models/src/events/payment.rs index 2b10b3cd14..2571ca5381 100644 --- a/crates/api_models/src/events/payment.rs +++ b/crates/api_models/src/events/payment.rs @@ -4,6 +4,7 @@ use common_utils::events::{ApiEventMetric, ApiEventsType}; use super::{ PaymentAttemptListRequest, PaymentAttemptListResponse, PaymentStartRedirectionRequest, PaymentsCreateIntentRequest, PaymentsGetIntentRequest, PaymentsIntentResponse, PaymentsRequest, + RecoveryPaymentsCreate, RecoveryPaymentsResponse, }; #[cfg(feature = "v2")] use crate::payment_methods::{ @@ -416,6 +417,20 @@ impl ApiEventMetric for PaymentListResponse { } } +#[cfg(feature = "v2")] +impl ApiEventMetric for RecoveryPaymentsCreate { + fn get_api_event_type(&self) -> Option { + None + } +} + +#[cfg(feature = "v2")] +impl ApiEventMetric for RecoveryPaymentsResponse { + fn get_api_event_type(&self) -> Option { + None + } +} + #[cfg(feature = "v1")] impl ApiEventMetric for PaymentListResponseV2 { fn get_api_event_type(&self) -> Option { @@ -496,7 +511,6 @@ impl ApiEventMetric for payments::PaymentMethodListResponseForPayments { None } } - #[cfg(feature = "v2")] impl ApiEventMetric for PaymentMethodListResponseForSession {} diff --git a/crates/api_models/src/payments.rs b/crates/api_models/src/payments.rs index 98a812bc5a..b0f73c9569 100644 --- a/crates/api_models/src/payments.rs +++ b/crates/api_models/src/payments.rs @@ -1696,6 +1696,32 @@ pub struct PaymentAttemptRecordResponse { pub created_at: PrimitiveDateTime, } +#[cfg(feature = "v2")] +#[derive(Debug, serde::Serialize, Clone, ToSchema)] +pub struct RecoveryPaymentsResponse { + /// Unique identifier for the payment. + #[schema( + min_length = 30, + max_length = 30, + example = "pay_mbabizu24mvu3mela5njyhpit4", + value_type = String, + )] + pub id: id_type::GlobalPaymentId, + + #[schema(value_type = IntentStatus, example = "failed", default = "requires_confirmation")] + pub intent_status: api_enums::IntentStatus, + + /// Unique identifier for the payment. This ensures idempotency for multiple payments + /// that have been done by a single merchant. + #[schema( + value_type = Option, + min_length = 30, + max_length = 30, + example = "pay_mbabizu24mvu3mela5njyhpit4" + )] + pub merchant_reference_id: Option, +} + #[cfg(feature = "v2")] #[derive(Clone, Debug, serde::Deserialize, serde::Serialize, PartialEq, ToSchema)] pub struct PaymentAttemptFeatureMetadata { @@ -4318,6 +4344,12 @@ pub struct PaymentMethodDataResponseWithBilling { pub billing: Option
, } +#[derive(Debug, Clone, Eq, PartialEq, serde::Deserialize, ToSchema, serde::Serialize)] +pub struct CustomRecoveryPaymentMethodData { + #[serde(flatten)] + pub units: HashMap, +} + #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, ToSchema)] #[cfg(feature = "v1")] pub enum PaymentIdType { @@ -9100,6 +9132,78 @@ pub struct PaymentsAttemptRecordRequest { pub card_issuer: Option, } +// Serialize is required because the api event requires Serialize to be implemented +#[derive(Debug, serde::Serialize, serde::Deserialize, Clone, ToSchema)] +#[serde(deny_unknown_fields)] +#[cfg(feature = "v2")] +pub struct RecoveryPaymentsCreate { + /// The amount details for the payment + pub amount_details: AmountDetails, + + /// Unique identifier for the payment. This ensures idempotency for multiple payments + /// that have been done by a single merchant. + #[schema( + value_type = Option, + min_length = 30, + max_length = 30, + example = "pay_mbabizu24mvu3mela5njyhpit4" + )] + pub merchant_reference_id: id_type::PaymentReferenceId, + + /// Error details for the payment if any + pub error: Option, + + /// Billing connector id to update the invoices. + #[schema(value_type = String, example = "mca_1234567890")] + pub billing_merchant_connector_id: id_type::MerchantConnectorAccountId, + + /// Payments connector id to update the invoices. + #[schema(value_type = String, example = "mca_1234567890")] + pub payment_merchant_connector_id: id_type::MerchantConnectorAccountId, + + #[schema(value_type = AttemptStatus, example = "charged")] + pub attempt_status: enums::AttemptStatus, + + /// The billing details of the payment attempt. + pub billing: Option
, + + /// The payment method subtype to be used for the payment. This should match with the `payment_method_data` provided + #[schema(value_type = PaymentMethodType, example = "apple_pay")] + pub payment_method_sub_type: api_enums::PaymentMethodType, + + /// primary payment method token at payment processor end. + #[schema(value_type = String, example = "token_1234")] + pub primary_processor_payment_method_token: Secret, + + /// The time at which payment attempt was created. + #[schema(example = "2022-09-10T10:11:12Z")] + #[serde(default, with = "common_utils::custom_serde::iso8601::option")] + pub transaction_created_at: Option, + + /// Payment method type for the payment attempt + #[schema(value_type = Option, example = "wallet")] + pub payment_method_type: common_enums::PaymentMethod, + + /// customer id at payment connector for which mandate is attached. + #[schema(value_type = String, example = "cust_12345")] + pub connector_customer_id: Secret, + + /// Invoice billing started at billing connector end. + #[schema(example = "2022-09-10T10:11:12Z")] + #[serde(default, with = "common_utils::custom_serde::iso8601::option")] + pub billing_started_at: Option, + + /// A unique identifier for a payment provided by the payment connector + #[schema(value_type = Option, example = "993672945374576J")] + pub connector_transaction_id: Option>, + + /// payment method token units at payment processor end. + pub payment_method_units: CustomRecoveryPaymentMethodData, + + /// Type of action that needs to be taken after consuming the recovery payload. For example: scheduling a failed payment or stopping the invoice. + pub action: common_payments_types::RecoveryAction, +} + /// Error details for the payment #[cfg(feature = "v2")] #[derive(Debug, serde::Serialize, serde::Deserialize, Clone, ToSchema)] diff --git a/crates/common_types/src/payments.rs b/crates/common_types/src/payments.rs index d65492953e..a5c9ce5bb2 100644 --- a/crates/common_types/src/payments.rs +++ b/crates/common_types/src/payments.rs @@ -525,3 +525,21 @@ impl ApplePayPredecryptData { Ok(Secret::new(format!("{month}{year}"))) } } + +/// type of action that needs to taken after consuming recovery payload +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RecoveryAction { + /// Stops the process tracker and update the payment intent. + CancelInvoice, + /// Records the external transaction against payment intent. + ScheduleFailedPayment, + /// Records the external payment and stops the internal process tracker. + SuccessPaymentExternal, + /// Pending payments from billing processor. + PendingPayment, + /// No action required. + NoAction, + /// Invalid event has been received. + InvalidAction, +} diff --git a/crates/hyperswitch_connectors/src/connectors/custombilling.rs b/crates/hyperswitch_connectors/src/connectors/custombilling.rs index 85904369e5..709f5c633d 100644 --- a/crates/hyperswitch_connectors/src/connectors/custombilling.rs +++ b/crates/hyperswitch_connectors/src/connectors/custombilling.rs @@ -36,7 +36,6 @@ use hyperswitch_interfaces::{ types::{self, Response}, webhooks, }; -use masking::{ExposeInterface, Mask}; use transformers as custombilling; use crate::{constants::headers, types::ResponseRouterData, utils}; @@ -114,14 +113,9 @@ impl ConnectorCommon for Custombilling { fn get_auth_header( &self, - auth_type: &ConnectorAuthType, + _auth_type: &ConnectorAuthType, ) -> CustomResult)>, errors::ConnectorError> { - let auth = custombilling::CustombillingAuthType::try_from(auth_type) - .change_context(errors::ConnectorError::FailedToObtainAuthType)?; - Ok(vec![( - headers::AUTHORIZATION.to_string(), - auth.api_key.expose().into_masked(), - )]) + Ok(vec![]) } fn build_error_response( diff --git a/crates/hyperswitch_connectors/src/connectors/custombilling/transformers.rs b/crates/hyperswitch_connectors/src/connectors/custombilling/transformers.rs index bb8e8f83e3..6766c77a85 100644 --- a/crates/hyperswitch_connectors/src/connectors/custombilling/transformers.rs +++ b/crates/hyperswitch_connectors/src/connectors/custombilling/transformers.rs @@ -2,7 +2,7 @@ use common_enums::enums; use common_utils::types::StringMinorUnit; use hyperswitch_domain_models::{ payment_method_data::PaymentMethodData, - router_data::{ConnectorAuthType, RouterData}, + router_data::RouterData, router_flow_types::refunds::{Execute, RSync}, router_request_types::ResponseId, router_response_types::{PaymentsResponseData, RefundsResponseData}, @@ -75,23 +75,6 @@ impl TryFrom<&CustombillingRouterData<&PaymentsAuthorizeRouterData>> } } -//TODO: Fill the struct with respective fields -// Auth Struct -pub struct CustombillingAuthType { - pub(super) api_key: Secret, -} - -impl TryFrom<&ConnectorAuthType> for CustombillingAuthType { - type Error = error_stack::Report; - fn try_from(auth_type: &ConnectorAuthType) -> Result { - match auth_type { - ConnectorAuthType::HeaderKey { api_key } => Ok(Self { - api_key: api_key.to_owned(), - }), - _ => Err(errors::ConnectorError::FailedToObtainAuthType.into()), - } - } -} // PaymentsResponse //TODO: Append the remaining status flags #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] diff --git a/crates/hyperswitch_domain_models/src/revenue_recovery.rs b/crates/hyperswitch_domain_models/src/revenue_recovery.rs index f9506e2ed3..fd36c4420f 100644 --- a/crates/hyperswitch_domain_models/src/revenue_recovery.rs +++ b/crates/hyperswitch_domain_models/src/revenue_recovery.rs @@ -81,23 +81,6 @@ pub struct RevenueRecoveryInvoiceData { pub billing_started_at: Option, } -/// type of action that needs to taken after consuming recovery payload -#[derive(Debug)] -pub enum RecoveryAction { - /// Stops the process tracker and update the payment intent. - CancelInvoice, - /// Records the external transaction against payment intent. - ScheduleFailedPayment, - /// Records the external payment and stops the internal process tracker. - SuccessPaymentExternal, - /// Pending payments from billing processor. - PendingPayment, - /// No action required. - NoAction, - /// Invalid event has been received. - InvalidAction, -} - #[derive(Clone, Debug)] pub struct RecoveryPaymentIntent { pub payment_id: id_type::GlobalPaymentId, @@ -134,63 +117,6 @@ impl RecoveryPaymentAttempt { } } -impl RecoveryAction { - pub fn get_action( - event_type: webhooks::IncomingWebhookEvent, - attempt_triggered_by: Option, - ) -> Self { - match event_type { - webhooks::IncomingWebhookEvent::PaymentIntentFailure - | webhooks::IncomingWebhookEvent::PaymentIntentSuccess - | webhooks::IncomingWebhookEvent::PaymentIntentProcessing - | webhooks::IncomingWebhookEvent::PaymentIntentPartiallyFunded - | webhooks::IncomingWebhookEvent::PaymentIntentCancelled - | webhooks::IncomingWebhookEvent::PaymentIntentCancelFailure - | webhooks::IncomingWebhookEvent::PaymentIntentAuthorizationSuccess - | webhooks::IncomingWebhookEvent::PaymentIntentAuthorizationFailure - | webhooks::IncomingWebhookEvent::PaymentIntentCaptureSuccess - | webhooks::IncomingWebhookEvent::PaymentIntentCaptureFailure - | webhooks::IncomingWebhookEvent::PaymentIntentExpired - | webhooks::IncomingWebhookEvent::PaymentActionRequired - | webhooks::IncomingWebhookEvent::EventNotSupported - | webhooks::IncomingWebhookEvent::SourceChargeable - | webhooks::IncomingWebhookEvent::SourceTransactionCreated - | webhooks::IncomingWebhookEvent::RefundFailure - | webhooks::IncomingWebhookEvent::RefundSuccess - | webhooks::IncomingWebhookEvent::DisputeOpened - | webhooks::IncomingWebhookEvent::DisputeExpired - | webhooks::IncomingWebhookEvent::DisputeAccepted - | webhooks::IncomingWebhookEvent::DisputeCancelled - | webhooks::IncomingWebhookEvent::DisputeChallenged - | webhooks::IncomingWebhookEvent::DisputeWon - | webhooks::IncomingWebhookEvent::DisputeLost - | webhooks::IncomingWebhookEvent::MandateActive - | webhooks::IncomingWebhookEvent::MandateRevoked - | webhooks::IncomingWebhookEvent::EndpointVerification - | webhooks::IncomingWebhookEvent::ExternalAuthenticationARes - | webhooks::IncomingWebhookEvent::FrmApproved - | webhooks::IncomingWebhookEvent::FrmRejected - | webhooks::IncomingWebhookEvent::PayoutSuccess - | webhooks::IncomingWebhookEvent::PayoutFailure - | webhooks::IncomingWebhookEvent::PayoutProcessing - | webhooks::IncomingWebhookEvent::PayoutCancelled - | webhooks::IncomingWebhookEvent::PayoutCreated - | webhooks::IncomingWebhookEvent::PayoutExpired - | webhooks::IncomingWebhookEvent::PayoutReversed => Self::InvalidAction, - webhooks::IncomingWebhookEvent::RecoveryPaymentFailure => match attempt_triggered_by { - Some(common_enums::TriggeredBy::Internal) => Self::NoAction, - Some(common_enums::TriggeredBy::External) | None => Self::ScheduleFailedPayment, - }, - webhooks::IncomingWebhookEvent::RecoveryPaymentSuccess => match attempt_triggered_by { - Some(common_enums::TriggeredBy::Internal) => Self::NoAction, - Some(common_enums::TriggeredBy::External) | None => Self::SuccessPaymentExternal, - }, - webhooks::IncomingWebhookEvent::RecoveryPaymentPending => Self::PendingPayment, - webhooks::IncomingWebhookEvent::RecoveryInvoiceCancel => Self::CancelInvoice, - } - } -} - impl From<&RevenueRecoveryInvoiceData> for api_payments::AmountDetails { fn from(data: &RevenueRecoveryInvoiceData) -> Self { let amount = api_payments::AmountDetailsSetter { diff --git a/crates/router/src/core/revenue_recovery/api.rs b/crates/router/src/core/revenue_recovery/api.rs index 052f4351c7..b8f912030c 100644 --- a/crates/router/src/core/revenue_recovery/api.rs +++ b/crates/router/src/core/revenue_recovery/api.rs @@ -1,6 +1,7 @@ +use actix_web::{web, Responder}; use api_models::payments as payments_api; use common_utils::id_type; -use error_stack::ResultExt; +use error_stack::{report, FutureExt, ResultExt}; use hyperswitch_domain_models::{ merchant_context::{Context, MerchantContext}, payments as payments_domain, @@ -12,11 +13,13 @@ use crate::{ payments::{self, operations::Operation}, webhooks::recovery_incoming, }, + db::errors::{RouterResponse, StorageErrorExt}, logger, - routes::SessionState, + routes::{app::ReqState, SessionState}, services, types::{ api::payments as api_types, + domain, storage::{self, revenue_recovery as revenue_recovery_types}, }, }; @@ -231,3 +234,126 @@ pub async fn record_internal_attempt_api( } } } + +pub async fn custom_revenue_recovery_core( + state: SessionState, + req_state: ReqState, + merchant_context: MerchantContext, + profile: domain::Profile, + request: api_models::payments::RecoveryPaymentsCreate, +) -> RouterResponse { + let store = state.store.as_ref(); + let key_manager_state = &(&state).into(); + let payment_merchant_connector_account_id = request.payment_merchant_connector_id.to_owned(); + // Find the payment & billing merchant connector id at the top level to avoid multiple DB calls. + let payment_merchant_connector_account = store + .find_merchant_connector_account_by_id( + key_manager_state, + &payment_merchant_connector_account_id, + merchant_context.get_merchant_key_store(), + ) + .await + .to_not_found_response(errors::ApiErrorResponse::MerchantConnectorAccountNotFound { + id: payment_merchant_connector_account_id + .clone() + .get_string_repr() + .to_string(), + })?; + let billing_connector_account = store + .find_merchant_connector_account_by_id( + key_manager_state, + &request.billing_merchant_connector_id.clone(), + merchant_context.get_merchant_key_store(), + ) + .await + .to_not_found_response(errors::ApiErrorResponse::MerchantConnectorAccountNotFound { + id: request + .billing_merchant_connector_id + .clone() + .get_string_repr() + .to_string(), + })?; + + let recovery_intent = + recovery_incoming::RevenueRecoveryInvoice::get_or_create_custom_recovery_intent( + request.clone(), + &state, + &req_state, + &merchant_context, + &profile, + ) + .await + .change_context(errors::ApiErrorResponse::GenericNotFoundError { + message: format!( + "Failed to load recovery intent for merchant reference id : {:?}", + request.merchant_reference_id.to_owned() + ) + .to_string(), + })?; + + let (revenue_recovery_attempt_data, updated_recovery_intent) = + recovery_incoming::RevenueRecoveryAttempt::load_recovery_attempt_from_api( + request.clone(), + &state, + &req_state, + &merchant_context, + &profile, + recovery_intent.clone(), + payment_merchant_connector_account, + ) + .await + .change_context(errors::ApiErrorResponse::GenericNotFoundError { + message: format!( + "Failed to load recovery attempt for merchant reference id : {:?}", + request.merchant_reference_id.to_owned() + ) + .to_string(), + })?; + + let intent_retry_count = updated_recovery_intent + .feature_metadata + .as_ref() + .and_then(|metadata| metadata.get_retry_count()) + .ok_or(report!(errors::ApiErrorResponse::GenericNotFoundError { + message: "Failed to fetch retry count from intent feature metadata".to_string(), + }))?; + + router_env::logger::info!("Intent retry count: {:?}", intent_retry_count); + let recovery_action = recovery_incoming::RecoveryAction { + action: request.action.to_owned(), + }; + let mca_retry_threshold = billing_connector_account + .get_retry_threshold() + .ok_or(report!(errors::ApiErrorResponse::GenericNotFoundError { + message: "Failed to fetch retry threshold from billing merchant connector account" + .to_string(), + }))?; + + recovery_action + .handle_action( + &state, + &profile, + &merchant_context, + &billing_connector_account, + mca_retry_threshold, + intent_retry_count, + &( + Some(revenue_recovery_attempt_data), + updated_recovery_intent.clone(), + ), + ) + .await + .change_context(errors::ApiErrorResponse::GenericNotFoundError { + message: "Unexpected response from recovery core".to_string(), + })?; + + let response = api_models::payments::RecoveryPaymentsResponse { + id: updated_recovery_intent.payment_id.to_owned(), + intent_status: updated_recovery_intent.status.to_owned(), + merchant_reference_id: updated_recovery_intent.merchant_reference_id.to_owned(), + }; + + Ok(hyperswitch_domain_models::api::ApplicationResponse::Json( + response, + )) +} diff --git a/crates/router/src/core/revenue_recovery/transformers.rs b/crates/router/src/core/revenue_recovery/transformers.rs index e60eac6934..b282ac1e94 100644 --- a/crates/router/src/core/revenue_recovery/transformers.rs +++ b/crates/router/src/core/revenue_recovery/transformers.rs @@ -1,4 +1,5 @@ use common_enums::AttemptStatus; +use masking::PeekInterface; use crate::{ core::revenue_recovery::types::RevenueRecoveryPaymentsAttemptStatus, @@ -42,3 +43,75 @@ impl ForeignFrom for RevenueRecoveryPaymentsAttemptStatus { } } } + +impl ForeignFrom + for hyperswitch_domain_models::revenue_recovery::RevenueRecoveryInvoiceData +{ + fn foreign_from(data: api_models::payments::RecoveryPaymentsCreate) -> Self { + Self { + amount: data.amount_details.order_amount().into(), + currency: data.amount_details.currency(), + merchant_reference_id: data.merchant_reference_id, + billing_address: data.billing, + retry_count: None, + next_billing_at: None, + billing_started_at: data.billing_started_at, + } + } +} + +impl ForeignFrom<&api_models::payments::RecoveryPaymentsCreate> + for hyperswitch_domain_models::revenue_recovery::RevenueRecoveryAttemptData +{ + fn foreign_from(data: &api_models::payments::RecoveryPaymentsCreate) -> Self { + let primary_token = &data + .primary_processor_payment_method_token + .peek() + .to_string(); + let card_info = data.payment_method_units.units.get(primary_token); + Self { + amount: data.amount_details.order_amount().into(), + currency: data.amount_details.currency(), + merchant_reference_id: data.merchant_reference_id.to_owned(), + connector_transaction_id: data.connector_transaction_id.as_ref().map(|txn_id| { + common_utils::types::ConnectorTransactionId::TxnId(txn_id.peek().to_string()) + }), + error_code: data.error.as_ref().map(|error| error.code.clone()), + error_message: data.error.as_ref().map(|error| error.message.clone()), + processor_payment_method_token: data + .primary_processor_payment_method_token + .peek() + .to_string(), + connector_customer_id: data.connector_customer_id.peek().to_string(), + connector_account_reference_id: data + .payment_merchant_connector_id + .get_string_repr() + .to_string(), + transaction_created_at: data.transaction_created_at.to_owned(), + status: data.attempt_status, + payment_method_type: data.payment_method_type, + payment_method_sub_type: data.payment_method_sub_type, + network_advice_code: data + .error + .as_ref() + .and_then(|error| error.network_advice_code.clone()), + network_decline_code: data + .error + .as_ref() + .and_then(|error| error.network_decline_code.clone()), + network_error_message: data + .error + .as_ref() + .and_then(|error| error.network_error_message.clone()), + /// retry count will be updated whenever there is new attempt is created. + retry_count: None, + invoice_next_billing_time: None, + invoice_billing_started_at_time: data.billing_started_at, + card_network: card_info + .as_ref() + .and_then(|info| info.card_network.clone()), + card_isin: card_info.as_ref().and_then(|info| info.card_isin.clone()), + charge_id: None, + } + } +} diff --git a/crates/router/src/core/webhooks/incoming_v2.rs b/crates/router/src/core/webhooks/incoming_v2.rs index 464781876c..25eff5d799 100644 --- a/crates/router/src/core/webhooks/incoming_v2.rs +++ b/crates/router/src/core/webhooks/incoming_v2.rs @@ -371,7 +371,6 @@ async fn incoming_webhooks_core( state.clone(), merchant_context, profile, - webhook_details, source_verified, &connector, merchant_connector_account, diff --git a/crates/router/src/core/webhooks/recovery_incoming.rs b/crates/router/src/core/webhooks/recovery_incoming.rs index fe5613af88..32cb555265 100644 --- a/crates/router/src/core/webhooks/recovery_incoming.rs +++ b/crates/router/src/core/webhooks/recovery_incoming.rs @@ -14,7 +14,7 @@ use hyperswitch_domain_models::{ }; use hyperswitch_interfaces::webhooks as interface_webhooks; use masking::{PeekInterface, Secret}; -use router_env::{instrument, tracing}; +use router_env::{instrument, logger, tracing}; use services::kafka; use crate::{ @@ -29,7 +29,10 @@ use crate::{ self, connector_integration_interface::{self, RouterDataConversion}, }, - types::{self, api, domain, storage::revenue_recovery as storage_churn_recovery}, + types::{ + self, api, domain, storage::revenue_recovery as storage_churn_recovery, + transformers::ForeignFrom, + }, workflows::revenue_recovery as revenue_recovery_flow, }; @@ -40,7 +43,6 @@ pub async fn recovery_incoming_webhook_flow( state: SessionState, merchant_context: domain::MerchantContext, business_profile: domain::Profile, - _webhook_details: api::IncomingWebhookDetails, source_verified: bool, connector_enum: &connector_integration_interface::ConnectorEnum, billing_connector_account: hyperswitch_domain_models::merchant_connector_account::MerchantConnectorAccount, @@ -147,7 +149,7 @@ pub async fn recovery_incoming_webhook_flow( ) .await { - router_env::logger::error!( + logger::error!( "Failed to publish revenue recovery event to kafka : {:?}", e ); @@ -158,7 +160,9 @@ pub async fn recovery_incoming_webhook_flow( .as_ref() .and_then(|attempt| attempt.get_attempt_triggered_by()); - let action = revenue_recovery::RecoveryAction::get_action(event_type, attempt_triggered_by); + let recovery_action = RecoveryAction { + action: RecoveryAction::get_action(event_type, attempt_triggered_by), + }; let mca_retry_threshold = billing_connector_account .get_retry_threshold() @@ -172,67 +176,21 @@ pub async fn recovery_incoming_webhook_flow( .and_then(|metadata| metadata.get_retry_count()) .ok_or(report!(errors::RevenueRecoveryError::RetryCountFetchFailed))?; - router_env::logger::info!("Intent retry count: {:?}", intent_retry_count); - - match action { - revenue_recovery::RecoveryAction::CancelInvoice => todo!(), - revenue_recovery::RecoveryAction::ScheduleFailedPayment => { - let recovery_algorithm_type = business_profile - .revenue_recovery_retry_algorithm_type - .ok_or(report!( - errors::RevenueRecoveryError::RetryAlgorithmTypeNotFound - ))?; - match recovery_algorithm_type { - api_enums::RevenueRecoveryAlgorithmType::Monitoring => { - handle_monitoring_threshold( - &state, - &business_profile, - merchant_context.get_merchant_key_store(), - ) - .await - } - revenue_recovery_retry_type => { - handle_schedule_failed_payment( - &billing_connector_account, - intent_retry_count, - mca_retry_threshold, - &state, - &merchant_context, - &( - recovery_attempt_from_payment_attempt, - recovery_intent_from_payment_attempt, - ), - &business_profile, - revenue_recovery_retry_type, - ) - .await - } - } - } - revenue_recovery::RecoveryAction::SuccessPaymentExternal => { - // Need to add recovery stop flow for this scenario - router_env::logger::info!("Payment has been succeeded via external system"); - Ok(webhooks::WebhookResponseTracker::NoEffect) - } - revenue_recovery::RecoveryAction::PendingPayment => { - router_env::logger::info!( - "Pending transactions are not consumed by the revenue recovery webhooks" - ); - Ok(webhooks::WebhookResponseTracker::NoEffect) - } - revenue_recovery::RecoveryAction::NoAction => { - router_env::logger::info!( - "No Recovery action is taken place for recovery event : {:?} and attempt triggered_by : {:?} ", event_type.clone(), attempt_triggered_by - ); - Ok(webhooks::WebhookResponseTracker::NoEffect) - } - revenue_recovery::RecoveryAction::InvalidAction => { - router_env::logger::error!( - "Invalid Revenue recovery action state has been received, event : {:?}, triggered_by : {:?}", event_type, attempt_triggered_by - ); - Ok(webhooks::WebhookResponseTracker::NoEffect) - } - } + logger::info!("Intent retry count: {:?}", intent_retry_count); + recovery_action + .handle_action( + &state, + &business_profile, + &merchant_context, + &billing_connector_account, + mca_retry_threshold, + intent_retry_count, + &( + recovery_attempt_from_payment_attempt, + recovery_intent_from_payment_attempt, + ), + ) + .await } async fn handle_monitoring_threshold( @@ -284,7 +242,7 @@ async fn handle_schedule_failed_payment( payment_attempt_with_recovery_intent; (intent_retry_count <= mca_retry_threshold) .then(|| { - router_env::logger::error!( + logger::error!( "Payment retry count {} is less than threshold {}", intent_retry_count, mca_retry_threshold @@ -316,6 +274,27 @@ pub struct RevenueRecoveryInvoice(revenue_recovery::RevenueRecoveryInvoiceData); pub struct RevenueRecoveryAttempt(revenue_recovery::RevenueRecoveryAttemptData); impl RevenueRecoveryInvoice { + pub async fn get_or_create_custom_recovery_intent( + data: api_models::payments::RecoveryPaymentsCreate, + state: &SessionState, + req_state: &ReqState, + merchant_context: &domain::MerchantContext, + profile: &domain::Profile, + ) -> CustomResult { + let recovery_intent = Self(revenue_recovery::RevenueRecoveryInvoiceData::foreign_from( + data, + )); + recovery_intent + .get_payment_intent(state, req_state, merchant_context, profile) + .await + .transpose() + .async_unwrap_or_else(|| async { + recovery_intent + .create_payment_intent(state, req_state, merchant_context, profile) + .await + }) + .await + } fn get_recovery_invoice_details( connector_enum: &connector_integration_interface::ConnectorEnum, request_details: &hyperswitch_interfaces::webhooks::IncomingWebhookRequestDetails<'_>, @@ -390,7 +369,7 @@ impl RevenueRecoveryInvoice { Ok(_) => Err(errors::RevenueRecoveryError::PaymentIntentFetchFailed) .attach_printable("Unexpected response from payment intent core"), error @ Err(_) => { - router_env::logger::error!(?error); + logger::error!(?error); Err(errors::RevenueRecoveryError::PaymentIntentFetchFailed) .attach_printable("failed to fetch payment intent recovery webhook flow") } @@ -453,6 +432,44 @@ impl RevenueRecoveryInvoice { } impl RevenueRecoveryAttempt { + pub async fn load_recovery_attempt_from_api( + data: api_models::payments::RecoveryPaymentsCreate, + state: &SessionState, + req_state: &ReqState, + merchant_context: &domain::MerchantContext, + profile: &domain::Profile, + payment_intent: revenue_recovery::RecoveryPaymentIntent, + payment_merchant_connector_account: domain::MerchantConnectorAccount, + ) -> CustomResult< + ( + revenue_recovery::RecoveryPaymentAttempt, + revenue_recovery::RecoveryPaymentIntent, + ), + errors::RevenueRecoveryError, + > { + let recovery_attempt = Self(revenue_recovery::RevenueRecoveryAttemptData::foreign_from( + &data, + )); + recovery_attempt + .get_payment_attempt(state, req_state, merchant_context, profile, &payment_intent) + .await + .transpose() + .async_unwrap_or_else(|| async { + recovery_attempt + .record_payment_attempt( + state, + req_state, + merchant_context, + profile, + &payment_intent, + &data.billing_merchant_connector_id, + Some(payment_merchant_connector_account), + ) + .await + }) + .await + } + fn get_recovery_invoice_transaction_details( connector_enum: &connector_integration_interface::ConnectorEnum, request_details: &hyperswitch_interfaces::webhooks::IncomingWebhookRequestDetails<'_>, @@ -574,7 +591,7 @@ impl RevenueRecoveryAttempt { Ok(_) => Err(errors::RevenueRecoveryError::PaymentAttemptFetchFailed) .attach_printable("Unexpected response from payment intent core"), error @ Err(_) => { - router_env::logger::error!(?error); + logger::error!(?error); Err(errors::RevenueRecoveryError::PaymentAttemptFetchFailed) .attach_printable("failed to fetch payment attempt in recovery webhook flow") } @@ -600,7 +617,7 @@ impl RevenueRecoveryAttempt { errors::RevenueRecoveryError, > { let payment_connector_id = payment_connector_account.as_ref().map(|account: &hyperswitch_domain_models::merchant_connector_account::MerchantConnectorAccount| account.id.clone()); - let request_payload = self + let request_payload: api_payments::PaymentsAttemptRecordRequest = self .create_payment_record_request( state, billing_connector_account_id, @@ -660,7 +677,7 @@ impl RevenueRecoveryAttempt { Ok(_) => Err(errors::RevenueRecoveryError::PaymentAttemptFetchFailed) .attach_printable("Unexpected response from record attempt core"), error @ Err(_) => { - router_env::logger::error!(?error); + logger::error!(?error); Err(errors::RevenueRecoveryError::PaymentAttemptFetchFailed) .attach_printable("failed to record attempt in recovery webhook flow") } @@ -979,7 +996,7 @@ impl BillingConnectorPaymentsSyncResponseData { let additional_recovery_details = match response.response { Ok(response) => Ok(response), error @ Err(_) => { - router_env::logger::error!(?error); + logger::error!(?error); Err(errors::RevenueRecoveryError::BillingConnectorPaymentsSyncFailed) .attach_printable("Failed while fetching billing connector payment details") } @@ -1147,7 +1164,7 @@ impl BillingConnectorInvoiceSyncResponseData { let additional_recovery_details = match response.response { Ok(response) => Ok(response), error @ Err(_) => { - router_env::logger::error!(?error); + logger::error!(?error); Err(errors::RevenueRecoveryError::BillingConnectorPaymentsSyncFailed) .attach_printable("Failed while fetching billing connector Invoice details") } @@ -1353,3 +1370,149 @@ impl RecoveryPaymentTuple { Ok(()) } } + +#[cfg(feature = "v2")] +#[derive(Clone, Debug)] +pub struct RecoveryAction { + pub action: common_types::payments::RecoveryAction, +} + +impl RecoveryAction { + pub fn get_action( + event_type: webhooks::IncomingWebhookEvent, + attempt_triggered_by: Option, + ) -> common_types::payments::RecoveryAction { + match event_type { + webhooks::IncomingWebhookEvent::PaymentIntentFailure + | webhooks::IncomingWebhookEvent::PaymentIntentSuccess + | webhooks::IncomingWebhookEvent::PaymentIntentProcessing + | webhooks::IncomingWebhookEvent::PaymentIntentPartiallyFunded + | webhooks::IncomingWebhookEvent::PaymentIntentCancelled + | webhooks::IncomingWebhookEvent::PaymentIntentCancelFailure + | webhooks::IncomingWebhookEvent::PaymentIntentAuthorizationSuccess + | webhooks::IncomingWebhookEvent::PaymentIntentAuthorizationFailure + | webhooks::IncomingWebhookEvent::PaymentIntentCaptureSuccess + | webhooks::IncomingWebhookEvent::PaymentIntentCaptureFailure + | webhooks::IncomingWebhookEvent::PaymentIntentExpired + | webhooks::IncomingWebhookEvent::PaymentActionRequired + | webhooks::IncomingWebhookEvent::EventNotSupported + | webhooks::IncomingWebhookEvent::SourceChargeable + | webhooks::IncomingWebhookEvent::SourceTransactionCreated + | webhooks::IncomingWebhookEvent::RefundFailure + | webhooks::IncomingWebhookEvent::RefundSuccess + | webhooks::IncomingWebhookEvent::DisputeOpened + | webhooks::IncomingWebhookEvent::DisputeExpired + | webhooks::IncomingWebhookEvent::DisputeAccepted + | webhooks::IncomingWebhookEvent::DisputeCancelled + | webhooks::IncomingWebhookEvent::DisputeChallenged + | webhooks::IncomingWebhookEvent::DisputeWon + | webhooks::IncomingWebhookEvent::DisputeLost + | webhooks::IncomingWebhookEvent::MandateActive + | webhooks::IncomingWebhookEvent::MandateRevoked + | webhooks::IncomingWebhookEvent::EndpointVerification + | webhooks::IncomingWebhookEvent::ExternalAuthenticationARes + | webhooks::IncomingWebhookEvent::FrmApproved + | webhooks::IncomingWebhookEvent::FrmRejected + | webhooks::IncomingWebhookEvent::PayoutSuccess + | webhooks::IncomingWebhookEvent::PayoutFailure + | webhooks::IncomingWebhookEvent::PayoutProcessing + | webhooks::IncomingWebhookEvent::PayoutCancelled + | webhooks::IncomingWebhookEvent::PayoutCreated + | webhooks::IncomingWebhookEvent::PayoutExpired + | webhooks::IncomingWebhookEvent::PayoutReversed => { + common_types::payments::RecoveryAction::InvalidAction + } + webhooks::IncomingWebhookEvent::RecoveryPaymentFailure => match attempt_triggered_by { + Some(common_enums::TriggeredBy::Internal) => { + common_types::payments::RecoveryAction::NoAction + } + Some(common_enums::TriggeredBy::External) | None => { + common_types::payments::RecoveryAction::ScheduleFailedPayment + } + }, + webhooks::IncomingWebhookEvent::RecoveryPaymentSuccess => match attempt_triggered_by { + Some(common_enums::TriggeredBy::Internal) => { + common_types::payments::RecoveryAction::NoAction + } + Some(common_enums::TriggeredBy::External) | None => { + common_types::payments::RecoveryAction::SuccessPaymentExternal + } + }, + webhooks::IncomingWebhookEvent::RecoveryPaymentPending => { + common_types::payments::RecoveryAction::PendingPayment + } + webhooks::IncomingWebhookEvent::RecoveryInvoiceCancel => { + common_types::payments::RecoveryAction::CancelInvoice + } + } + } + + #[allow(clippy::too_many_arguments)] + pub async fn handle_action( + &self, + state: &SessionState, + business_profile: &domain::Profile, + merchant_context: &domain::MerchantContext, + billing_connector_account: &hyperswitch_domain_models::merchant_connector_account::MerchantConnectorAccount, + mca_retry_threshold: u16, + intent_retry_count: u16, + recovery_tuple: &( + Option, + revenue_recovery::RecoveryPaymentIntent, + ), + ) -> CustomResult { + match self.action { + common_types::payments::RecoveryAction::CancelInvoice => todo!(), + common_types::payments::RecoveryAction::ScheduleFailedPayment => { + let recovery_algorithm_type = business_profile + .revenue_recovery_retry_algorithm_type + .ok_or(report!( + errors::RevenueRecoveryError::RetryAlgorithmTypeNotFound + ))?; + match recovery_algorithm_type { + api_enums::RevenueRecoveryAlgorithmType::Monitoring => { + handle_monitoring_threshold( + state, + business_profile, + merchant_context.get_merchant_key_store(), + ) + .await + } + revenue_recovery_retry_type => { + handle_schedule_failed_payment( + billing_connector_account, + intent_retry_count, + mca_retry_threshold, + state, + merchant_context, + recovery_tuple, + business_profile, + revenue_recovery_retry_type, + ) + .await + } + } + } + common_types::payments::RecoveryAction::SuccessPaymentExternal => { + logger::info!("Payment has been succeeded via external system"); + Ok(webhooks::WebhookResponseTracker::NoEffect) + } + common_types::payments::RecoveryAction::PendingPayment => { + logger::info!( + "Pending transactions are not consumed by the revenue recovery webhooks" + ); + Ok(webhooks::WebhookResponseTracker::NoEffect) + } + common_types::payments::RecoveryAction::NoAction => { + logger::info!( + "No Recovery action is taken place for recovery event and attempt triggered_by" + ); + Ok(webhooks::WebhookResponseTracker::NoEffect) + } + common_types::payments::RecoveryAction::InvalidAction => { + logger::error!("Invalid Revenue recovery action state has been received"); + Ok(webhooks::WebhookResponseTracker::NoEffect) + } + } + } +} diff --git a/crates/router/src/routes/app.rs b/crates/router/src/routes/app.rs index 18044b371f..52c3b6c29c 100644 --- a/crates/router/src/routes/app.rs +++ b/crates/router/src/routes/app.rs @@ -653,6 +653,10 @@ impl Payments { .service( web::resource("/aggregate").route(web::get().to(payments::get_payments_aggregates)), ) + .service( + web::resource("/recovery") + .route(web::post().to(payments::recovery_payments_create)), + ) .service( web::resource("/profile/aggregate") .route(web::get().to(payments::get_payments_aggregates_profile)), diff --git a/crates/router/src/routes/lock_utils.rs b/crates/router/src/routes/lock_utils.rs index 8defd91705..67943da57f 100644 --- a/crates/router/src/routes/lock_utils.rs +++ b/crates/router/src/routes/lock_utils.rs @@ -168,7 +168,8 @@ impl From for ApiIdentifier { | Flow::PaymentStartRedirection | Flow::ProxyConfirmIntent | Flow::PaymentsRetrieveUsingMerchantReferenceId - | Flow::PaymentAttemptsList => Self::Payments, + | Flow::PaymentAttemptsList + | Flow::RecoveryPaymentsCreate => Self::Payments, Flow::PayoutsCreate | Flow::PayoutsRetrieve diff --git a/crates/router/src/routes/payments.rs b/crates/router/src/routes/payments.rs index c659dcc776..f9bee6bceb 100644 --- a/crates/router/src/routes/payments.rs +++ b/crates/router/src/routes/payments.rs @@ -10,6 +10,8 @@ use masking::PeekInterface; use router_env::{env, instrument, logger, tracing, types, Flow}; use super::app::ReqState; +#[cfg(feature = "v2")] +use crate::core::revenue_recovery::api as recovery; use crate::{ self as app, core::{ @@ -115,6 +117,40 @@ pub async fn payments_create( .await } +#[cfg(feature = "v2")] +pub async fn recovery_payments_create( + state: web::Data, + req: actix_web::HttpRequest, + json_payload: web::Json, +) -> impl Responder { + let flow = Flow::RecoveryPaymentsCreate; + let mut payload = json_payload.into_inner(); + Box::pin(api::server_wrap( + flow, + state, + &req.clone(), + payload, + |state, auth: auth::AuthenticationData, req_payload, req_state| { + let merchant_context = domain::MerchantContext::NormalMerchant(Box::new( + domain::Context(auth.merchant_account, auth.key_store), + )); + recovery::custom_revenue_recovery_core( + state.to_owned(), + req_state, + merchant_context, + auth.profile, + req_payload, + ) + }, + &auth::V2ApiKeyAuth { + is_connected_allowed: false, + is_platform_allowed: false, + }, + api_locking::LockAction::NotApplicable, + )) + .await +} + #[cfg(feature = "v2")] #[instrument(skip_all, fields(flow = ?Flow::PaymentsCreateIntent, payment_id))] pub async fn payments_create_intent( diff --git a/crates/router/src/types/api/payments.rs b/crates/router/src/types/api/payments.rs index f04dc1a6fd..057a2d7920 100644 --- a/crates/router/src/types/api/payments.rs +++ b/crates/router/src/types/api/payments.rs @@ -2,6 +2,7 @@ pub use api_models::payments::{ PaymentAttemptListRequest, PaymentAttemptListResponse, PaymentsConfirmIntentRequest, PaymentsCreateIntentRequest, PaymentsIntentResponse, PaymentsUpdateIntentRequest, + RecoveryPaymentsCreate, }; #[cfg(feature = "v1")] pub use api_models::payments::{ diff --git a/crates/router_env/src/logger/types.rs b/crates/router_env/src/logger/types.rs index e798e80e1d..de402b026f 100644 --- a/crates/router_env/src/logger/types.rs +++ b/crates/router_env/src/logger/types.rs @@ -640,6 +640,8 @@ pub enum Flow { DecisionEngineDecideGatewayCall, /// Decision Engine Gateway Feedback Call DecisionEngineGatewayFeedbackCall, + /// Recovery payments create flow. + RecoveryPaymentsCreate, } /// Trait for providing generic behaviour to flow metric