fix(connector): [STRIPE] Retrieving Connect Account Id from Mandate Metadata in MITs (#8326)

Co-authored-by: Sayak Bhattacharya <sayak.b@Sayak-Bhattacharya-G092THXJ34.local>
This commit is contained in:
Sayak Bhattacharya
2025-06-18 17:21:14 +05:30
committed by GitHub
parent aee3f6441f
commit 17c30b6105
11 changed files with 229 additions and 31 deletions

View File

@ -79,6 +79,8 @@ pub struct Extra {
pub reason: Option<String>, pub reason: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub connector_transaction_id: Option<String>, pub connector_transaction_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub fields: Option<String>,
} }
#[derive(Serialize, Debug, Clone)] #[derive(Serialize, Debug, Clone)]

View File

@ -13,7 +13,6 @@ use serde::{Deserialize, Serialize};
use utoipa::ToSchema; use utoipa::ToSchema;
use crate::domain::{AdyenSplitData, XenditSplitSubMerchantData}; use crate::domain::{AdyenSplitData, XenditSplitSubMerchantData};
#[derive( #[derive(
Serialize, Deserialize, Debug, Clone, PartialEq, Eq, FromSqlRow, AsExpression, ToSchema, Serialize, Deserialize, Debug, Clone, PartialEq, Eq, FromSqlRow, AsExpression, ToSchema,
)] )]
@ -44,7 +43,7 @@ pub struct StripeSplitPaymentRequest {
/// Platform fees to be collected on the payment /// Platform fees to be collected on the payment
#[schema(value_type = i64, example = 6540)] #[schema(value_type = i64, example = 6540)]
pub application_fees: MinorUnit, pub application_fees: Option<MinorUnit>,
/// Identifier for the reseller's account where the funds were transferred /// Identifier for the reseller's account where the funds were transferred
pub transfer_account_id: String, pub transfer_account_id: String,
@ -139,7 +138,7 @@ pub struct StripeChargeResponseData {
/// Platform fees collected on the payment /// Platform fees collected on the payment
#[schema(value_type = i64, example = 6540)] #[schema(value_type = i64, example = 6540)]
pub application_fees: MinorUnit, pub application_fees: Option<MinorUnit>,
/// Identifier for the reseller's account where the funds were transferred /// Identifier for the reseller's account where the funds were transferred
pub transfer_account_id: String, pub transfer_account_id: String,

View File

@ -13,6 +13,7 @@ use std::{
borrow::Cow, borrow::Cow,
fmt::Display, fmt::Display,
iter::Sum, iter::Sum,
num::NonZeroI64,
ops::{Add, Mul, Sub}, ops::{Add, Mul, Sub},
primitive::i64, primitive::i64,
str::FromStr, str::FromStr,
@ -452,6 +453,12 @@ impl MinorUnit {
} }
} }
impl From<NonZeroI64> for MinorUnit {
fn from(val: NonZeroI64) -> Self {
Self::new(val.get())
}
}
impl Display for MinorUnit { impl Display for MinorUnit {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0) write!(f, "{}", self.0)

View File

@ -870,9 +870,13 @@ impl ConnectorIntegration<Authorize, PaymentsAuthorizeData, PaymentsResponseData
.to_string() .to_string()
.into(), .into(),
)]; )];
let mut api_key = self.get_auth_header(&req.connector_auth_type)?; let mut api_key = self.get_auth_header(&req.connector_auth_type)?;
header.append(&mut api_key); header.append(&mut api_key);
let stripe_split_payment_metadata = stripe::StripeSplitPaymentRequest::try_from(req)?;
// if the request has split payment object, then append the transfer account id in headers in charge_type is Direct
if let Some(common_types::payments::SplitPaymentsRequest::StripeSplitPayment( if let Some(common_types::payments::SplitPaymentsRequest::StripeSplitPayment(
stripe_split_payment, stripe_split_payment,
)) = &req.request.split_payments )) = &req.request.split_payments
@ -890,6 +894,16 @@ impl ConnectorIntegration<Authorize, PaymentsAuthorizeData, PaymentsResponseData
header.append(&mut customer_account_header); header.append(&mut customer_account_header);
} }
} }
// if request doesn't have transfer_account_id, but stripe_split_payment_metadata has it, append it
else if let Some(transfer_account_id) =
stripe_split_payment_metadata.transfer_account_id.clone()
{
let mut customer_account_header = vec![(
STRIPE_COMPATIBLE_CONNECT_ACCOUNT.to_string(),
transfer_account_id.into_masked(),
)];
header.append(&mut customer_account_header);
}
Ok(header) Ok(header)
} }

View File

@ -2,6 +2,7 @@ use std::{collections::HashMap, ops::Deref};
use api_models::{self, enums as api_enums, payments}; use api_models::{self, enums as api_enums, payments};
use common_enums::{enums, AttemptStatus, PaymentChargeType, StripeChargeType}; use common_enums::{enums, AttemptStatus, PaymentChargeType, StripeChargeType};
use common_types::payments::SplitPaymentsRequest;
use common_utils::{ use common_utils::{
collect_missing_value_keys, collect_missing_value_keys,
errors::CustomResult, errors::CustomResult,
@ -198,7 +199,7 @@ pub struct PaymentIntentRequest {
#[derive(Debug, Eq, PartialEq, Serialize)] #[derive(Debug, Eq, PartialEq, Serialize)]
pub struct IntentCharges { pub struct IntentCharges {
pub application_fee_amount: MinorUnit, pub application_fee_amount: Option<MinorUnit>,
#[serde( #[serde(
rename = "transfer_data[destination]", rename = "transfer_data[destination]",
skip_serializing_if = "Option::is_none" skip_serializing_if = "Option::is_none"
@ -1668,8 +1669,39 @@ impl TryFrom<(&PaymentsAuthorizeRouterData, MinorUnit)> for PaymentIntentRequest
fn try_from(data: (&PaymentsAuthorizeRouterData, MinorUnit)) -> Result<Self, Self::Error> { fn try_from(data: (&PaymentsAuthorizeRouterData, MinorUnit)) -> Result<Self, Self::Error> {
let item = data.0; 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<StripeSplitPaymentRequest, _> = 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 { let payment_method_token = match &item.request.split_payments {
Some(common_types::payments::SplitPaymentsRequest::StripeSplitPayment(_)) => { Some(SplitPaymentsRequest::StripeSplitPayment(_)) => {
match item.payment_method_token.clone() { match item.payment_method_token.clone() {
Some(PaymentMethodToken::Token(secret)) => Some(secret), Some(PaymentMethodToken::Token(secret)) => Some(secret),
_ => None, _ => None,
@ -1948,27 +1980,45 @@ impl TryFrom<(&PaymentsAuthorizeRouterData, MinorUnit)> for PaymentIntentRequest
}; };
let charges = match &item.request.split_payments { let charges = match &item.request.split_payments {
Some(common_types::payments::SplitPaymentsRequest::StripeSplitPayment( Some(SplitPaymentsRequest::StripeSplitPayment(stripe_split_payment)) => {
stripe_split_payment, match &stripe_split_payment.charge_type {
)) => match &stripe_split_payment.charge_type { PaymentChargeType::Stripe(charge_type) => match charge_type {
PaymentChargeType::Stripe(charge_type) => match charge_type { StripeChargeType::Direct => Some(IntentCharges {
StripeChargeType::Direct => Some(IntentCharges { application_fee_amount: stripe_split_payment.application_fees,
application_fee_amount: stripe_split_payment.application_fees, destination_account_id: None,
destination_account_id: None, }),
}), StripeChargeType::Destination => Some(IntentCharges {
StripeChargeType::Destination => Some(IntentCharges { application_fee_amount: stripe_split_payment.application_fees,
application_fee_amount: stripe_split_payment.application_fees, destination_account_id: Some(
destination_account_id: Some( stripe_split_payment.transfer_account_id.clone(),
stripe_split_payment.transfer_account_id.clone(), ),
), }),
}), },
}, }
}, }
Some(common_types::payments::SplitPaymentsRequest::AdyenSplitPayment(_)) Some(SplitPaymentsRequest::AdyenSplitPayment(_))
| Some(common_types::payments::SplitPaymentsRequest::XenditSplitPayment(_)) | Some(SplitPaymentsRequest::XenditSplitPayment(_))
| None => None, | 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()) { let pm = match (payment_method, payment_method_token.clone()) {
(Some(method), _) => Some(Secret::new(method)), (Some(method), _) => Some(Secret::new(method)),
(None, Some(token)) => Some(token), (None, Some(token)) => Some(token),
@ -2009,7 +2059,7 @@ impl TryFrom<(&PaymentsAuthorizeRouterData, MinorUnit)> for PaymentIntentRequest
payment_method_types, payment_method_types,
expand: Some(ExpandableObjects::LatestCharge), expand: Some(ExpandableObjects::LatestCharge),
browser_info, 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<PaymentChargeType>,
pub application_fees: Option<MinorUnit>,
pub transfer_account_id: Option<String>,
}
impl TryFrom<&PaymentsAuthorizeRouterData> for StripeSplitPaymentRequest {
type Error = error_stack::Report<ConnectorError>;
fn try_from(item: &PaymentsAuthorizeRouterData) -> Result<Self, Self::Error> {
//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::<Self>(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)] #[derive(Clone, Default, Debug, Eq, PartialEq, Deserialize, Serialize)]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
pub enum StripePaymentStatus { pub enum StripePaymentStatus {
@ -2524,10 +2663,23 @@ where
// For backward compatibility payment_method_id & connector_mandate_id is being populated with the same value // 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 connector_mandate_id = Some(payment_method_id.clone().expose());
let payment_method_id = Some(payment_method_id.expose()); let payment_method_id = Some(payment_method_id.expose());
let mandate_metadata: Option<Secret<Value>> =
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 { MandateReference {
connector_mandate_id, connector_mandate_id,
payment_method_id, payment_method_id,
mandate_metadata: None, mandate_metadata,
connector_mandate_request_reference_id: None, connector_mandate_request_reference_id: None,
} }
}); });
@ -4252,10 +4404,7 @@ where
T: SplitPaymentData, T: SplitPaymentData,
{ {
let charge_request = request.get_split_payment_data(); let charge_request = request.get_split_payment_data();
if let Some(common_types::payments::SplitPaymentsRequest::StripeSplitPayment( if let Some(SplitPaymentsRequest::StripeSplitPayment(stripe_split_payment)) = charge_request {
stripe_split_payment,
)) = charge_request
{
let stripe_charge_response = common_types::payments::StripeChargeResponseData { let stripe_charge_response = common_types::payments::StripeChargeResponseData {
charge_id: Some(charge_id), charge_id: Some(charge_id),
charge_type: stripe_split_payment.charge_type, charge_type: stripe_split_payment.charge_type,

View File

@ -290,6 +290,8 @@ pub enum ApiErrorResponse {
InvalidPlatformOperation, InvalidPlatformOperation,
#[error(error_type = ErrorType::InvalidRequestError, code = "IR_45", message = "External vault failed during processing with connector")] #[error(error_type = ErrorType::InvalidRequestError, code = "IR_45", message = "External vault failed during processing with connector")]
ExternalVaultFailed, 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")] #[error(error_type = ErrorType::InvalidRequestError, code = "WE_01", message = "Failed to authenticate the webhook")]
WebhookAuthenticationFailed, WebhookAuthenticationFailed,
#[error(error_type = ErrorType::InvalidRequestError, code = "WE_02", message = "Bad request received in webhook")] #[error(error_type = ErrorType::InvalidRequestError, code = "WE_02", message = "Bad request received in webhook")]
@ -651,6 +653,9 @@ impl ErrorSwitch<api_models::errors::types::ApiErrorResponse> for ApiErrorRespon
Self::ExternalVaultFailed => { Self::ExternalVaultFailed => {
AER::BadRequest(ApiError::new("IR", 45, "External Vault failed while processing with connector.", None)) 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 => { Self::WebhookAuthenticationFailed => {
AER::Unauthorized(ApiError::new("WE", 1, "Webhook authentication failed", None)) AER::Unauthorized(ApiError::new("WE", 1, "Webhook authentication failed", None))

View File

@ -125,6 +125,8 @@ pub enum ConnectorError {
error_message: String, error_message: String,
error_object: serde_json::Value, error_object: serde_json::Value,
}, },
#[error("Field {fields} doesn't match with the ones used during mandate creation")]
MandatePaymentDataMismatch { fields: String },
} }
impl ConnectorError { impl ConnectorError {

View File

@ -600,6 +600,7 @@ impl From<errors::ApiErrorResponse> for StripeErrorCode {
errors::ApiErrorResponse::AddressNotFound => Self::AddressNotFound, errors::ApiErrorResponse::AddressNotFound => Self::AddressNotFound,
errors::ApiErrorResponse::NotImplemented { .. } => Self::Unauthorized, errors::ApiErrorResponse::NotImplemented { .. } => Self::Unauthorized,
errors::ApiErrorResponse::FlowNotSupported { .. } => Self::InternalServerError, errors::ApiErrorResponse::FlowNotSupported { .. } => Self::InternalServerError,
errors::ApiErrorResponse::MandatePaymentDataMismatch { .. } => Self::PlatformBadRequest,
errors::ApiErrorResponse::PaymentUnexpectedState { errors::ApiErrorResponse::PaymentUnexpectedState {
current_flow, current_flow,
field_name, field_name,

View File

@ -176,6 +176,7 @@ impl<T> ConnectorErrorExt<T> for error_stack::Result<T, errors::ConnectorError>
| errors::ConnectorError::DateFormattingFailed | errors::ConnectorError::DateFormattingFailed
| errors::ConnectorError::InvalidDataFormat { .. } | errors::ConnectorError::InvalidDataFormat { .. }
| errors::ConnectorError::MismatchedPaymentData | errors::ConnectorError::MismatchedPaymentData
| errors::ConnectorError::MandatePaymentDataMismatch { .. }
| errors::ConnectorError::InvalidWalletToken { .. } | errors::ConnectorError::InvalidWalletToken { .. }
| errors::ConnectorError::MissingConnectorRelatedTransactionID { .. } | errors::ConnectorError::MissingConnectorRelatedTransactionID { .. }
| errors::ConnectorError::FileValidationFailed { .. } | errors::ConnectorError::FileValidationFailed { .. }
@ -230,6 +231,11 @@ impl<T> ConnectorErrorExt<T> for error_stack::Result<T, errors::ConnectorError>
"payment_method_data, payment_method_type and payment_experience does not match", "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::ConnectorError::NotSupported { message, connector } => {
errors::ApiErrorResponse::NotSupported { message: format!("{message} is not supported by {connector}") } errors::ApiErrorResponse::NotSupported { message: format!("{message} is not supported by {connector}") }
}, },
@ -376,6 +382,7 @@ impl<T> ConnectorErrorExt<T> for error_stack::Result<T, errors::ConnectorError>
| errors::ConnectorError::DateFormattingFailed | errors::ConnectorError::DateFormattingFailed
| errors::ConnectorError::InvalidDataFormat { .. } | errors::ConnectorError::InvalidDataFormat { .. }
| errors::ConnectorError::MismatchedPaymentData | errors::ConnectorError::MismatchedPaymentData
| errors::ConnectorError::MandatePaymentDataMismatch { .. }
| errors::ConnectorError::MissingConnectorRelatedTransactionID { .. } | errors::ConnectorError::MissingConnectorRelatedTransactionID { .. }
| errors::ConnectorError::FileValidationFailed { .. } | errors::ConnectorError::FileValidationFailed { .. }
| errors::ConnectorError::MissingConnectorRedirectionPayload { .. } | errors::ConnectorError::MissingConnectorRedirectionPayload { .. }

View File

@ -7017,14 +7017,24 @@ pub fn validate_platform_request_for_marketplace(
stripe_split_payment, stripe_split_payment,
)) => match amount { )) => match amount {
api::Amount::Zero => { 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 { return Err(errors::ApiErrorResponse::InvalidDataValue {
field_name: "split_payments.stripe_split_payment.application_fees", field_name: "split_payments.stripe_split_payment.application_fees",
}); });
} }
} }
api::Amount::Value(amount) => { 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 { return Err(errors::ApiErrorResponse::InvalidDataValue {
field_name: "split_payments.stripe_split_payment.application_fees", field_name: "split_payments.stripe_split_payment.application_fees",
}); });

View File

@ -193,6 +193,8 @@ pub enum ConnectorError {
InvalidDataFormat { field_name: &'static str }, InvalidDataFormat { field_name: &'static str },
#[error("Payment Method data / Payment Method Type / Payment Experience Mismatch ")] #[error("Payment Method data / Payment Method Type / Payment Experience Mismatch ")]
MismatchedPaymentData, MismatchedPaymentData,
#[error("Field {fields} doesn't match with the ones used during mandate creation")]
MandatePaymentDataMismatch { fields: String },
#[error("Failed to parse Wallet token")] #[error("Failed to parse Wallet token")]
InvalidWalletToken { wallet_name: String }, InvalidWalletToken { wallet_name: String },
#[error("Missing Connector Related Transaction ID")] #[error("Missing Connector Related Transaction ID")]