diff --git a/crates/integrations/connector-integration/src/connectors/paysafe/requests.rs b/crates/integrations/connector-integration/src/connectors/paysafe/requests.rs index 2c7a630dbf..b9eeb78694 100644 --- a/crates/integrations/connector-integration/src/connectors/paysafe/requests.rs +++ b/crates/integrations/connector-integration/src/connectors/paysafe/requests.rs @@ -42,6 +42,7 @@ pub enum MandateOccurrence { pub enum PaysafeStoredCredentialType { Adhoc, Topup, + Recurring, } /// CreateConnectorCustomer request body (`POST v1/customers`). @@ -522,10 +523,11 @@ pub enum PaysafeAuthorizeRequest { /// only mint SINGLE_USE handles this way (the vault endpoint rejects raw /// applePay/googlePay objects with 5068 "CARD object must be present"). /// -/// `VaultFromHandle`: wallet recurring leg 2 — convert an existing single-use -/// wallet handle into a customer-vaulted MULTI_USE (paymentType CARD) handle -/// via `POST v1/customers/{id}/paymenthandles {paymentHandleTokenFrom}`, -/// mirroring Paysafe's documented Apple Pay / Google Pay recurring flow. +/// `VaultFromHandle`: after a successful wallet CIT, convert its spent +/// SINGLE_USE handle into a customer-vaulted MULTI_USE (paymentType CARD) +/// handle via `POST v1/customers/{id}/paymenthandles +/// {paymentHandleTokenFrom}`, mirroring Paysafe's documented Apple Pay / +/// Google Pay recurring flow. #[derive(Debug, Serialize)] #[serde(untagged)] pub enum PaysafePaymentMethodTokenRequest { diff --git a/crates/integrations/connector-integration/src/connectors/paysafe/transformers.rs b/crates/integrations/connector-integration/src/connectors/paysafe/transformers.rs index 6851adbe53..32b7da8c20 100644 --- a/crates/integrations/connector-integration/src/connectors/paysafe/transformers.rs +++ b/crates/integrations/connector-integration/src/connectors/paysafe/transformers.rs @@ -93,17 +93,18 @@ impl TryFrom<&ConnectorSpecificConfig> for PaysafeAuthType { #[serde(rename_all = "snake_case")] pub struct PaysafeMandateMetadata { pub initial_transaction_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub payment_method: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub stored_credential_type: Option, } /// Self-contained mandate reference encoded into `connector_mandate_id`. /// -/// The gRPC recurring path cannot carry `mandate_metadata` (the proto -/// `ConnectorMandateReferenceId` has no metadata field and the Charge -> -/// RepeatPaymentData conversion hardcodes it to `None`), so the CIT Authorize -/// response encodes BOTH the reusable payment-handle token and the initial -/// transaction id (Paysafe payment `id`) into `connector_mandate_id`. The MIT -/// RepeatPayment request decodes both back out. Older bare-token values are -/// still handled via the `mandate_metadata` fallback. +/// New mandates store only the payment-handle token in `connector_mandate_id` +/// and carry the initial transaction data in `mandate_metadata`. This compact +/// type remains for mandates created before that metadata was forwarded through +/// the gRPC recurring path. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub struct PaysafeMandateReference { @@ -155,13 +156,35 @@ pub enum PaysafeMandatePaymentMethod { GooglePay, } -/// Resolve the processing account for a wallet stored-credential payment: the -/// CIT settle of a converted (customer-vaulted, paymentType CARD) wallet handle -/// and the MIT replay. Unlike a raw single-use wallet handle, a converted -/// handle carries no account binding, so Paysafe requires an explicit accountId -/// (5068 without one) — and the MIT must replay the SAME account that processed -/// the initial transaction (3061 "Invalid initial transaction reference" -/// otherwise). Mirrors the Tokenize minting resolution so all three legs agree: +fn paysafe_stored_credential_type( + mit_category: Option<&enums::MitCategory>, +) -> Result { + match mit_category { + Some(enums::MitCategory::Recurring) => Ok(PaysafeStoredCredentialType::Recurring), + Some(enums::MitCategory::Unscheduled) | None => Ok(PaysafeStoredCredentialType::Topup), + Some( + category @ (enums::MitCategory::Installment | enums::MitCategory::Resubmission), + ) => { + Err(IntegrationError::NotSupported { + message: format!("MIT category {category:?}"), + connector: "paysafe", + context: IntegrationErrorContext { + additional_context: Some( + "Paysafe wallet mandates currently support only recurring and unscheduled/top-up stored credentials." + .to_string(), + ), + ..Default::default() + }, + }) + } + } +} + +/// Resolve the processing account for a wallet stored-credential payment. The +/// CIT spends a SINGLE_USE wallet handle and the MIT replays the converted +/// MULTI_USE handle. The MIT must use the same account that processed the +/// initial transaction (3061 "Invalid initial transaction reference" +/// otherwise). Mirrors the Tokenize minting resolution so all legs agree: /// Apple Pay → encrypt/decrypt slot by payload variant (decrypt-first chain /// when the variant is unknown), fallback card no_three_ds; Google Pay → card /// no_three_ds. @@ -1552,6 +1575,35 @@ impl TryFrom, ) -> Result { + let is_wallet_vault_conversion = matches!( + &item.router_data.request.payment_method_data, + PaymentMethodData::Wallet(WalletData::ApplePay(_) | WalletData::GooglePay(_)) + ) && item + .router_data + .resource_common_data + .connector_customer + .is_some() + && paysafe_parse_feature_data_handle_token( + item.router_data.request.connector_feature_data.as_ref(), + ) + .is_some(); + + if is_wallet_vault_conversion + && (item.response.status != PaysafePaymentHandleStatus::Payable + || item.response.usage != Some(PaysafeUsage::MultiUse)) + { + return Err(ConnectorError::UnexpectedResponseError { + context: domain_types::errors::ResponseTransformationErrorContext { + http_status_code: Some(item.http_code), + additional_context: Some(format!( + "Paysafe wallet vault conversion must return a PAYABLE MULTI_USE handle; received status {:?} and usage {:?}.", + item.response.status, item.response.usage + )), + }, + } + .into()); + } + let status = enums::AttemptStatus::try_from(item.response.status)?; let mut router_data = item.router_data; @@ -1655,78 +1707,34 @@ impl { - if router_data.resource_common_data.is_three_ds() { - Some(account_id.get_account_id( - PaysafeAccountKind::CardThreeDs, - router_data.request.currency, - )?) - } else { - Some(account_id.get_account_id( - PaysafeAccountKind::CardNoThreeDs, - router_data.request.currency, - )?) - } - } - enums::PaymentMethod::Wallet - if router_data.request.is_customer_initiated_mandate_payment() => - { - match &router_data.request.payment_method_data { - PaymentMethodData::Wallet(WalletData::GooglePay(_)) => { + // Only card settles carry an explicit accountId. Wallet CIT spends the + // account-bound SINGLE_USE handle returned by Tokenize, so re-specifying + // accountId is rejected by Paysafe with error 5068. After CIT, HS converts + // that handle to MULTI_USE; the MIT path resolves and sends the matching + // accountId separately. + let is_wallet_handle = matches!( + router_data.request.payment_method_type, + Some(enums::PaymentMethodType::ApplePay | enums::PaymentMethodType::GooglePay) + ); + let account_id = if is_wallet_handle { + None + } else { + match router_data.resource_common_data.payment_method { + enums::PaymentMethod::Card => { + if router_data.resource_common_data.is_three_ds() { Some(account_id.get_account_id( - PaysafeAccountKind::CardNoThreeDs, + PaysafeAccountKind::CardThreeDs, router_data.request.currency, )?) - } - PaymentMethodData::Wallet(WalletData::ApplePay(apple_pay_data)) => { - let flow = match &apple_pay_data.payment_data { - ApplePayPaymentData::Encrypted(_) => PaysafeApplePayFlow::Encrypt, - _ => PaysafeApplePayFlow::Decrypt, - }; - Some(resolve_wallet_mandate_account( - &account_id, - Some(flow), + } else { + Some(account_id.get_account_id( + PaysafeAccountKind::CardNoThreeDs, router_data.request.currency, )?) } - // Token-only settle (payment_method_data is a handle, not the - // raw wallet payload). The encrypt/decrypt distinction is lost, - // so identify the wallet via payment_method_type and mirror the - // raw-payload arms above — scoped to Apple Pay / Google Pay so a - // different wallet (e.g. Skrill) doing a CIT is NOT forced onto a - // Paysafe apple_pay/card account. - _ => match router_data.request.payment_method_type { - Some(enums::PaymentMethodType::ApplePay) => { - Some(resolve_wallet_mandate_account( - &account_id, - None, - router_data.request.currency, - )?) - } - Some(enums::PaymentMethodType::GooglePay) => { - Some(account_id.get_account_id( - PaysafeAccountKind::CardNoThreeDs, - router_data.request.currency, - )?) - } - _ => None, - }, } + _ => None, } - _ => None, }; Ok(Self { @@ -1739,14 +1747,16 @@ impl TryFrom { let status = get_paysafe_payment_status(response.status, capture_method); - // Store payment_handle_token for mandate if present. Encode both the - // reusable payment-handle token and the initial transaction id (Paysafe - // payment `id`) into connector_mandate_id, because the gRPC recurring path - // cannot carry mandate_metadata. The MIT RepeatPayment request decodes both - // back out. // Record which account slot the CIT settled under so the MIT can - // replay the SAME one (3061 otherwise). For Apple Pay the slot is - // the encrypt/decrypt flow — derived from the payload exactly as the - // settle arm above resolves the account. Cards get `None` (omitted): - // it saves bytes in the VARCHAR(128) mandate blob and the MIT already - // defaults an absent `pm` to card no_three_ds. + // replay the same one (3061 otherwise). The connector mandate id + // initially carries the SINGLE_USE handle; HS replaces only that + // id with the post-CIT MULTI_USE handle while preserving this + // metadata. let mandate_payment_method = match &router_data.request.payment_method_data { PaymentMethodData::Wallet(WalletData::ApplePay(apple_pay_data)) => { Some(match &apple_pay_data.payment_data { @@ -1853,20 +1857,33 @@ impl TryFrom None, }, }; - let mandate_reference = response.payment_handle_token.as_ref().map(|token| { - let connector_mandate_id = serde_json::to_string(&PaysafeMandateReference { - payment_handle_token: token.peek().to_string(), - initial_transaction_id: response.id.clone(), - payment_method: mandate_payment_method, + let mandate_reference = router_data + .request + .is_customer_initiated_mandate_payment() + .then(|| { + response.payment_handle_token.as_ref().map(|token| { + let stored_credential_type = + match router_data.request.mit_category.as_ref() { + Some(enums::MitCategory::Recurring) => { + PaysafeStoredCredentialType::Recurring + } + _ => PaysafeStoredCredentialType::Topup, + }; + MandateReference { + connector_mandate_id: Some(token.peek().to_string()), + payment_method_id: None, + connector_mandate_request_reference_id: None, + mandate_metadata: Some(common_utils::pii::SecretSerdeValue::new( + serde_json::json!(PaysafeMandateMetadata { + initial_transaction_id: response.id.clone(), + payment_method: mandate_payment_method, + stored_credential_type: Some(stored_credential_type), + }), + )), + } + }) }) - .unwrap_or_else(|_| token.peek().to_string()); - MandateReference { - connector_mandate_id: Some(connector_mandate_id), - payment_method_id: None, - connector_mandate_request_reference_id: None, - mandate_metadata: None, - } - }); + .flatten(); router_data.resource_common_data.status = status; @@ -2204,31 +2221,36 @@ impl, String, Option, + PaysafeStoredCredentialType, ) = match serde_json::from_str::(&raw_connector_mandate_id) { Ok(decoded) => ( Secret::new(decoded.payment_handle_token), decoded.initial_transaction_id, decoded.payment_method, + PaysafeStoredCredentialType::Topup, ), Err(_) => { let mandate_metadata: PaysafeMandateMetadata = mandate_data @@ -2247,7 +2269,7 @@ impl { diff --git a/data/field_probe/finix.json b/data/field_probe/finix.json index e5c073da41..534ee248f9 100644 --- a/data/field_probe/finix.json +++ b/data/field_probe/finix.json @@ -785,7 +785,7 @@ "content-type": "application/json", "via": "HyperSwitch" }, - "body": "{\"type\":\"PAYMENT_CARD\",\"name\":\"John Doe\",\"number\":\"4111111111111111\",\"security_code\":\"737\",\"expiration_month\":3,\"expiration_year\":2030,\"identity\":\"cust_probe_123\",\"tags\":{\"merchant_reference\":\"probe_mandate_001\"},\"address\":{\"line1\":null,\"line2\":null,\"city\":null,\"region\":null,\"postal_code\":null,\"country\":null}}" + "body": "{\"type\":\"PAYMENT_CARD\",\"name\":\"John Doe\",\"number\":\"4111111111111111\",\"security_code\":\"737\",\"expiration_month\":3,\"expiration_year\":2030,\"identity\":\"cust_probe_123\",\"tags\":{\"merchant_reference\":\"probe_mandate_001\"},\"address\":{\"line1\":null,\"line2\":null,\"city\":null,\"region\":null,\"postal_code\":null,\"country\":null},\"card_brand\":null,\"card_type\":null,\"additional_data\":null,\"merchant_identity\":null,\"third_party_token\":null}" } } },