From 17c30b6105d9086585edac0c89432b1f4568c3de Mon Sep 17 00:00:00 2001 From: Sayak Bhattacharya Date: Wed, 18 Jun 2025 17:21:14 +0530 Subject: [PATCH] fix(connector): [STRIPE] Retrieving Connect Account Id from Mandate Metadata in MITs (#8326) Co-authored-by: Sayak Bhattacharya --- crates/api_models/src/errors/types.rs | 2 + crates/common_types/src/payments.rs | 5 +- crates/common_utils/src/types.rs | 7 + .../src/connectors/stripe.rs | 14 ++ .../src/connectors/stripe/transformers.rs | 201 +++++++++++++++--- .../src/errors/api_error_response.rs | 5 + crates/hyperswitch_interfaces/src/errors.rs | 2 + .../router/src/compatibility/stripe/errors.rs | 1 + crates/router/src/core/errors/utils.rs | 7 + crates/router/src/core/payments/helpers.rs | 14 +- crates/storage_impl/src/errors.rs | 2 + 11 files changed, 229 insertions(+), 31 deletions(-) diff --git a/crates/api_models/src/errors/types.rs b/crates/api_models/src/errors/types.rs index 2b88bdba91..233e0d933b 100644 --- a/crates/api_models/src/errors/types.rs +++ b/crates/api_models/src/errors/types.rs @@ -79,6 +79,8 @@ pub struct Extra { pub reason: Option, #[serde(skip_serializing_if = "Option::is_none")] pub connector_transaction_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub fields: Option, } #[derive(Serialize, Debug, Clone)] diff --git a/crates/common_types/src/payments.rs b/crates/common_types/src/payments.rs index 996478e8bf..1a73e2f978 100644 --- a/crates/common_types/src/payments.rs +++ b/crates/common_types/src/payments.rs @@ -13,7 +13,6 @@ use serde::{Deserialize, Serialize}; use utoipa::ToSchema; use crate::domain::{AdyenSplitData, XenditSplitSubMerchantData}; - #[derive( Serialize, Deserialize, Debug, Clone, PartialEq, Eq, FromSqlRow, AsExpression, ToSchema, )] @@ -44,7 +43,7 @@ pub struct StripeSplitPaymentRequest { /// Platform fees to be collected on the payment #[schema(value_type = i64, example = 6540)] - pub application_fees: MinorUnit, + pub application_fees: Option, /// Identifier for the reseller's account where the funds were transferred pub transfer_account_id: String, @@ -139,7 +138,7 @@ pub struct StripeChargeResponseData { /// Platform fees collected on the payment #[schema(value_type = i64, example = 6540)] - pub application_fees: MinorUnit, + pub application_fees: Option, /// Identifier for the reseller's account where the funds were transferred pub transfer_account_id: String, diff --git a/crates/common_utils/src/types.rs b/crates/common_utils/src/types.rs index 62c6e9cf75..4422b3e3d5 100644 --- a/crates/common_utils/src/types.rs +++ b/crates/common_utils/src/types.rs @@ -13,6 +13,7 @@ use std::{ borrow::Cow, fmt::Display, iter::Sum, + num::NonZeroI64, ops::{Add, Mul, Sub}, primitive::i64, str::FromStr, @@ -452,6 +453,12 @@ impl MinorUnit { } } +impl From for MinorUnit { + fn from(val: NonZeroI64) -> Self { + Self::new(val.get()) + } +} + impl Display for MinorUnit { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}", self.0) diff --git a/crates/hyperswitch_connectors/src/connectors/stripe.rs b/crates/hyperswitch_connectors/src/connectors/stripe.rs index 9817d4e056..695825a156 100644 --- a/crates/hyperswitch_connectors/src/connectors/stripe.rs +++ b/crates/hyperswitch_connectors/src/connectors/stripe.rs @@ -870,9 +870,13 @@ impl ConnectorIntegration, #[serde( rename = "transfer_data[destination]", skip_serializing_if = "Option::is_none" @@ -1668,8 +1669,39 @@ impl TryFrom<(&PaymentsAuthorizeRouterData, MinorUnit)> for PaymentIntentRequest fn try_from(data: (&PaymentsAuthorizeRouterData, MinorUnit)) -> Result { let item = data.0; + let mandate_metadata = item + .request + .mandate_id + .as_ref() + .and_then(|mandate_id| mandate_id.mandate_reference_id.as_ref()) + .and_then(|reference_id| match reference_id { + payments::MandateReferenceId::ConnectorMandateId(mandate_data) => { + Some(mandate_data.get_mandate_metadata()) + } + _ => None, + }); + + let (transfer_account_id, charge_type, application_fees) = if let Some(secret_value) = + mandate_metadata.as_ref().and_then(|s| s.as_ref()) + { + let json_value = secret_value.clone().expose(); + + let parsed: Result = serde_json::from_value(json_value); + + match parsed { + Ok(data) => ( + data.transfer_account_id, + data.charge_type, + data.application_fees, + ), + Err(_) => (None, None, None), + } + } else { + (None, None, None) + }; + let payment_method_token = match &item.request.split_payments { - Some(common_types::payments::SplitPaymentsRequest::StripeSplitPayment(_)) => { + Some(SplitPaymentsRequest::StripeSplitPayment(_)) => { match item.payment_method_token.clone() { Some(PaymentMethodToken::Token(secret)) => Some(secret), _ => None, @@ -1948,27 +1980,45 @@ impl TryFrom<(&PaymentsAuthorizeRouterData, MinorUnit)> for PaymentIntentRequest }; let charges = match &item.request.split_payments { - Some(common_types::payments::SplitPaymentsRequest::StripeSplitPayment( - stripe_split_payment, - )) => match &stripe_split_payment.charge_type { - PaymentChargeType::Stripe(charge_type) => match charge_type { - StripeChargeType::Direct => Some(IntentCharges { - application_fee_amount: stripe_split_payment.application_fees, - destination_account_id: None, - }), - StripeChargeType::Destination => Some(IntentCharges { - application_fee_amount: stripe_split_payment.application_fees, - destination_account_id: Some( - stripe_split_payment.transfer_account_id.clone(), - ), - }), - }, - }, - Some(common_types::payments::SplitPaymentsRequest::AdyenSplitPayment(_)) - | Some(common_types::payments::SplitPaymentsRequest::XenditSplitPayment(_)) + Some(SplitPaymentsRequest::StripeSplitPayment(stripe_split_payment)) => { + match &stripe_split_payment.charge_type { + PaymentChargeType::Stripe(charge_type) => match charge_type { + StripeChargeType::Direct => Some(IntentCharges { + application_fee_amount: stripe_split_payment.application_fees, + destination_account_id: None, + }), + StripeChargeType::Destination => Some(IntentCharges { + application_fee_amount: stripe_split_payment.application_fees, + destination_account_id: Some( + stripe_split_payment.transfer_account_id.clone(), + ), + }), + }, + } + } + Some(SplitPaymentsRequest::AdyenSplitPayment(_)) + | Some(SplitPaymentsRequest::XenditSplitPayment(_)) | None => None, }; + let charges_in = if charges.is_none() { + match charge_type { + Some(PaymentChargeType::Stripe(StripeChargeType::Direct)) => Some(IntentCharges { + application_fee_amount: application_fees, // default to 0 if None + destination_account_id: None, + }), + Some(PaymentChargeType::Stripe(StripeChargeType::Destination)) => { + Some(IntentCharges { + application_fee_amount: application_fees, + destination_account_id: transfer_account_id, + }) + } + _ => None, + } + } else { + charges + }; + let pm = match (payment_method, payment_method_token.clone()) { (Some(method), _) => Some(Secret::new(method)), (None, Some(token)) => Some(token), @@ -2009,7 +2059,7 @@ impl TryFrom<(&PaymentsAuthorizeRouterData, MinorUnit)> for PaymentIntentRequest payment_method_types, expand: Some(ExpandableObjects::LatestCharge), browser_info, - charges, + charges: charges_in, }) } } @@ -2146,6 +2196,95 @@ impl TryFrom<&ConnectorCustomerRouterData> for CustomerRequest { } } +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct StripeSplitPaymentRequest { + pub charge_type: Option, + pub application_fees: Option, + pub transfer_account_id: Option, +} + +impl TryFrom<&PaymentsAuthorizeRouterData> for StripeSplitPaymentRequest { + type Error = error_stack::Report; + + fn try_from(item: &PaymentsAuthorizeRouterData) -> Result { + //extracting mandate metadata from CIT call if CIT call was a Split Payment + let from_metadata = item + .request + .mandate_id + .as_ref() + .and_then(|mandate_id| mandate_id.mandate_reference_id.as_ref()) + .and_then(|reference_id| match reference_id { + payments::MandateReferenceId::ConnectorMandateId(mandate_data) => { + mandate_data.get_mandate_metadata() + } + _ => None, + }) + .and_then(|secret_value| { + let json_value = secret_value.clone().expose(); + match serde_json::from_value::(json_value.clone()) { + Ok(val) => Some(val), + Err(err) => { + router_env::logger::info!( + "STRIPE: Picking merchant_account_id and merchant_config_currency from payments request: {:?}", err + ); + None + } + } + }); + + // If the Split Payment Request in MIT mismatches with the metadata from CIT, throw an error + if from_metadata.is_some() && item.request.split_payments.is_some() { + let mut mit_charge_type = None; + let mut mit_application_fees = None; + let mut mit_transfer_account_id = None; + if let Some(SplitPaymentsRequest::StripeSplitPayment(stripe_split_payment)) = + item.request.split_payments.as_ref() + { + mit_charge_type = Some(stripe_split_payment.charge_type.clone()); + mit_application_fees = stripe_split_payment.application_fees; + mit_transfer_account_id = Some(stripe_split_payment.transfer_account_id.clone()); + } + + if mit_charge_type != from_metadata.as_ref().and_then(|m| m.charge_type.clone()) + || mit_application_fees != from_metadata.as_ref().and_then(|m| m.application_fees) + || mit_transfer_account_id + != from_metadata + .as_ref() + .and_then(|m| m.transfer_account_id.clone()) + { + let mismatched_fields = ["transfer_account_id", "application_fees", "charge_type"]; + + let field_str = mismatched_fields.join(", "); + return Err(error_stack::Report::from( + ConnectorError::MandatePaymentDataMismatch { fields: field_str }, + )); + } + } + + // If Mandate Metadata from CIT call has something, populate it + let (charge_type, mut transfer_account_id, application_fees) = + if let Some(ref metadata) = from_metadata { + ( + metadata.charge_type.clone(), + metadata.transfer_account_id.clone(), + metadata.application_fees, + ) + } else { + (None, None, None) + }; + + // If Charge Type is Destination, transfer_account_id need not be appended in headers + if charge_type == Some(PaymentChargeType::Stripe(StripeChargeType::Destination)) { + transfer_account_id = None; + } + Ok(Self { + charge_type, + transfer_account_id, + application_fees, + }) + } +} + #[derive(Clone, Default, Debug, Eq, PartialEq, Deserialize, Serialize)] #[serde(rename_all = "snake_case")] pub enum StripePaymentStatus { @@ -2524,10 +2663,23 @@ where // For backward compatibility payment_method_id & connector_mandate_id is being populated with the same value let connector_mandate_id = Some(payment_method_id.clone().expose()); let payment_method_id = Some(payment_method_id.expose()); + + let mandate_metadata: Option> = + match item.data.request.get_split_payment_data() { + Some(SplitPaymentsRequest::StripeSplitPayment(stripe_split_data)) => { + Some(Secret::new(serde_json::json!({ + "transfer_account_id": stripe_split_data.transfer_account_id, + "charge_type": stripe_split_data.charge_type, + "application_fees": stripe_split_data.application_fees, + }))) + } + _ => None, + }; + MandateReference { connector_mandate_id, payment_method_id, - mandate_metadata: None, + mandate_metadata, connector_mandate_request_reference_id: None, } }); @@ -4252,10 +4404,7 @@ where T: SplitPaymentData, { let charge_request = request.get_split_payment_data(); - if let Some(common_types::payments::SplitPaymentsRequest::StripeSplitPayment( - stripe_split_payment, - )) = charge_request - { + if let Some(SplitPaymentsRequest::StripeSplitPayment(stripe_split_payment)) = charge_request { let stripe_charge_response = common_types::payments::StripeChargeResponseData { charge_id: Some(charge_id), charge_type: stripe_split_payment.charge_type, diff --git a/crates/hyperswitch_domain_models/src/errors/api_error_response.rs b/crates/hyperswitch_domain_models/src/errors/api_error_response.rs index 86e12d2aca..3acabaca12 100644 --- a/crates/hyperswitch_domain_models/src/errors/api_error_response.rs +++ b/crates/hyperswitch_domain_models/src/errors/api_error_response.rs @@ -290,6 +290,8 @@ pub enum ApiErrorResponse { InvalidPlatformOperation, #[error(error_type = ErrorType::InvalidRequestError, code = "IR_45", message = "External vault failed during processing with connector")] ExternalVaultFailed, + #[error(error_type = ErrorType::InvalidRequestError, code = "IR_46", message = "Field {fields} doesn't match with the ones used during mandate creation")] + MandatePaymentDataMismatch { fields: String }, #[error(error_type = ErrorType::InvalidRequestError, code = "WE_01", message = "Failed to authenticate the webhook")] WebhookAuthenticationFailed, #[error(error_type = ErrorType::InvalidRequestError, code = "WE_02", message = "Bad request received in webhook")] @@ -651,6 +653,9 @@ impl ErrorSwitch for ApiErrorRespon Self::ExternalVaultFailed => { AER::BadRequest(ApiError::new("IR", 45, "External Vault failed while processing with connector.", None)) }, + Self::MandatePaymentDataMismatch { fields} => { + AER::BadRequest(ApiError::new("IR", 46, format!("Field {fields} doesn't match with the ones used during mandate creation"), Some(Extra {fields: Some(fields.to_owned()), ..Default::default()}))) //FIXME: error message + } Self::WebhookAuthenticationFailed => { AER::Unauthorized(ApiError::new("WE", 1, "Webhook authentication failed", None)) diff --git a/crates/hyperswitch_interfaces/src/errors.rs b/crates/hyperswitch_interfaces/src/errors.rs index 1a11c2d761..be67daaa09 100644 --- a/crates/hyperswitch_interfaces/src/errors.rs +++ b/crates/hyperswitch_interfaces/src/errors.rs @@ -125,6 +125,8 @@ pub enum ConnectorError { error_message: String, error_object: serde_json::Value, }, + #[error("Field {fields} doesn't match with the ones used during mandate creation")] + MandatePaymentDataMismatch { fields: String }, } impl ConnectorError { diff --git a/crates/router/src/compatibility/stripe/errors.rs b/crates/router/src/compatibility/stripe/errors.rs index 016e06198c..39d5d7040d 100644 --- a/crates/router/src/compatibility/stripe/errors.rs +++ b/crates/router/src/compatibility/stripe/errors.rs @@ -600,6 +600,7 @@ impl From for StripeErrorCode { errors::ApiErrorResponse::AddressNotFound => Self::AddressNotFound, errors::ApiErrorResponse::NotImplemented { .. } => Self::Unauthorized, errors::ApiErrorResponse::FlowNotSupported { .. } => Self::InternalServerError, + errors::ApiErrorResponse::MandatePaymentDataMismatch { .. } => Self::PlatformBadRequest, errors::ApiErrorResponse::PaymentUnexpectedState { current_flow, field_name, diff --git a/crates/router/src/core/errors/utils.rs b/crates/router/src/core/errors/utils.rs index 8e5862909e..4302dba446 100644 --- a/crates/router/src/core/errors/utils.rs +++ b/crates/router/src/core/errors/utils.rs @@ -176,6 +176,7 @@ impl ConnectorErrorExt for error_stack::Result | errors::ConnectorError::DateFormattingFailed | errors::ConnectorError::InvalidDataFormat { .. } | errors::ConnectorError::MismatchedPaymentData + | errors::ConnectorError::MandatePaymentDataMismatch { .. } | errors::ConnectorError::InvalidWalletToken { .. } | errors::ConnectorError::MissingConnectorRelatedTransactionID { .. } | errors::ConnectorError::FileValidationFailed { .. } @@ -230,6 +231,11 @@ impl ConnectorErrorExt for error_stack::Result "payment_method_data, payment_method_type and payment_experience does not match", } }, + errors::ConnectorError::MandatePaymentDataMismatch {fields}=> { + errors::ApiErrorResponse::MandatePaymentDataMismatch { + fields: fields.to_owned(), + } + }, errors::ConnectorError::NotSupported { message, connector } => { errors::ApiErrorResponse::NotSupported { message: format!("{message} is not supported by {connector}") } }, @@ -376,6 +382,7 @@ impl ConnectorErrorExt for error_stack::Result | errors::ConnectorError::DateFormattingFailed | errors::ConnectorError::InvalidDataFormat { .. } | errors::ConnectorError::MismatchedPaymentData + | errors::ConnectorError::MandatePaymentDataMismatch { .. } | errors::ConnectorError::MissingConnectorRelatedTransactionID { .. } | errors::ConnectorError::FileValidationFailed { .. } | errors::ConnectorError::MissingConnectorRedirectionPayload { .. } diff --git a/crates/router/src/core/payments/helpers.rs b/crates/router/src/core/payments/helpers.rs index cfec9d6eaa..9f6e317531 100644 --- a/crates/router/src/core/payments/helpers.rs +++ b/crates/router/src/core/payments/helpers.rs @@ -7017,14 +7017,24 @@ pub fn validate_platform_request_for_marketplace( stripe_split_payment, )) => match amount { api::Amount::Zero => { - if stripe_split_payment.application_fees.get_amount_as_i64() != 0 { + if stripe_split_payment + .application_fees + .as_ref() + .map_or(MinorUnit::zero(), |amount| *amount) + != MinorUnit::zero() + { return Err(errors::ApiErrorResponse::InvalidDataValue { field_name: "split_payments.stripe_split_payment.application_fees", }); } } api::Amount::Value(amount) => { - if stripe_split_payment.application_fees.get_amount_as_i64() > amount.into() { + if stripe_split_payment + .application_fees + .as_ref() + .map_or(MinorUnit::zero(), |amount| *amount) + > amount.into() + { return Err(errors::ApiErrorResponse::InvalidDataValue { field_name: "split_payments.stripe_split_payment.application_fees", }); diff --git a/crates/storage_impl/src/errors.rs b/crates/storage_impl/src/errors.rs index 5bab662687..9a14320a85 100644 --- a/crates/storage_impl/src/errors.rs +++ b/crates/storage_impl/src/errors.rs @@ -193,6 +193,8 @@ pub enum ConnectorError { InvalidDataFormat { field_name: &'static str }, #[error("Payment Method data / Payment Method Type / Payment Experience Mismatch ")] MismatchedPaymentData, + #[error("Field {fields} doesn't match with the ones used during mandate creation")] + MandatePaymentDataMismatch { fields: String }, #[error("Failed to parse Wallet token")] InvalidWalletToken { wallet_name: String }, #[error("Missing Connector Related Transaction ID")]