diff --git a/crates/hyperswitch_connectors/src/connectors/nmi.rs b/crates/hyperswitch_connectors/src/connectors/nmi.rs index b2037c45256..7ae21664e73 100644 --- a/crates/hyperswitch_connectors/src/connectors/nmi.rs +++ b/crates/hyperswitch_connectors/src/connectors/nmi.rs @@ -38,7 +38,7 @@ use hyperswitch_interfaces::{ types::{ PaymentsAuthorizeType, PaymentsCaptureType, PaymentsCompleteAuthorizeType, PaymentsPreProcessingType, PaymentsSyncType, PaymentsVoidType, RefundExecuteType, - RefundSyncType, Response, + RefundSyncType, Response, SetupMandateType, }, webhooks::{IncomingWebhook, IncomingWebhookRequestDetails}, }; @@ -48,7 +48,7 @@ use transformers as nmi; use crate::{ types::ResponseRouterData, - utils::{construct_not_supported_error_report, convert_amount, get_header_key_value}, + utils::{self, construct_not_supported_error_report, convert_amount, get_header_key_value}, }; #[derive(Clone)] @@ -161,6 +161,16 @@ impl ConnectorValidation for Nmi { // in case we dont have transaction id, we can make psync using attempt id Ok(()) } + + fn validate_mandate_payment( + &self, + pm_type: Option, + pm_data: hyperswitch_domain_models::payment_method_data::PaymentMethodData, + ) -> CustomResult<(), ConnectorError> { + let mandate_supported_pmd = + std::collections::HashSet::from([utils::PaymentMethodDataType::Card]); + utils::is_mandate_supported(pm_data, pm_type, mandate_supported_pmd, self.id()) + } } impl ConnectorIntegration @@ -194,27 +204,23 @@ impl ConnectorIntegration CustomResult { - let connector_req = nmi::NmiPaymentsRequest::try_from(req)?; + let connector_req = nmi::NmiValidateRequest::try_from(req)?; Ok(RequestContent::FormUrlEncoded(Box::new(connector_req))) } fn build_request( &self, - _req: &SetupMandateRouterData, - _connectors: &Connectors, + req: &SetupMandateRouterData, + connectors: &Connectors, ) -> CustomResult, ConnectorError> { - Err(ConnectorError::NotImplemented("Setup Mandate flow for Nmi".to_string()).into()) - - // Ok(Some( - // RequestBuilder::new() - // .method(Method::Post) - // .url(&SetupMandateType::get_url(self, req, connectors)?) - // .headers(SetupMandateType::get_headers(self, req, connectors)?) - // .set_body(SetupMandateType::get_request_body( - // self, req, connectors, - // )?) - // .build(), - // )) + Ok(Some( + RequestBuilder::new() + .method(Method::Post) + .url(&SetupMandateType::get_url(self, req, connectors)?) + .headers(SetupMandateType::get_headers(self, req, connectors)?) + .set_body(SetupMandateType::get_request_body(self, req, connectors)?) + .build(), + )) } fn handle_response( diff --git a/crates/hyperswitch_connectors/src/connectors/nmi/transformers.rs b/crates/hyperswitch_connectors/src/connectors/nmi/transformers.rs index ec5ce3b2e65..32436f307af 100644 --- a/crates/hyperswitch_connectors/src/connectors/nmi/transformers.rs +++ b/crates/hyperswitch_connectors/src/connectors/nmi/transformers.rs @@ -1,8 +1,6 @@ use api_models::webhooks::IncomingWebhookEvent; use cards::CardNumber; -use common_enums::{ - AttemptStatus, AuthenticationType, CaptureMethod, CountryAlpha2, Currency, RefundStatus, -}; +use common_enums::{AttemptStatus, AuthenticationType, CountryAlpha2, Currency, RefundStatus}; use common_utils::{errors::CustomResult, ext_traits::XmlExt, pii::Email, types::FloatMajorUnit}; use error_stack::{report, Report, ResultExt}; use hyperswitch_domain_models::{ @@ -351,6 +349,7 @@ pub struct NmiCompleteResponse { pub cvvresponse: Option, pub orderid: String, pub response_code: String, + customer_vault_id: Option>, } impl @@ -377,17 +376,27 @@ impl Ok(PaymentsResponseData::TransactionResponse { resource_id: ResponseId::ConnectorTransactionId(item.response.transactionid), redirection_data: Box::new(None), - mandate_reference: Box::new(None), + mandate_reference: match item.response.customer_vault_id { + Some(vault_id) => Box::new(Some( + hyperswitch_domain_models::router_response_types::MandateReference { + connector_mandate_id: Some(vault_id.expose()), + payment_method_id: None, + mandate_metadata: None, + connector_mandate_request_reference_id: None, + }, + )), + None => Box::new(None), + }, connector_metadata: None, network_txn_id: None, connector_response_reference_id: Some(item.response.orderid), incremental_authorization_allowed: None, charges: None, }), - if let Some(CaptureMethod::Automatic) = item.data.request.capture_method { - AttemptStatus::CaptureInitiated + if item.data.request.is_auto_capture()? { + AttemptStatus::Charged } else { - AttemptStatus::Authorizing + AttemptStatus::Authorized }, ), Response::Declined | Response::Error => ( @@ -417,6 +426,18 @@ fn get_nmi_error_response(response: NmiCompleteResponse, http_code: u16) -> Erro } } +#[derive(Debug, Serialize)] +pub struct NmiValidateRequest { + #[serde(rename = "type")] + transaction_type: TransactionType, + security_key: Secret, + ccnumber: CardNumber, + ccexp: Secret, + cvv: Secret, + orderid: String, + customer_vault: CustomerAction, +} + #[derive(Debug, Serialize)] pub struct NmiPaymentsRequest { #[serde(rename = "type")] @@ -429,6 +450,8 @@ pub struct NmiPaymentsRequest { #[serde(flatten)] merchant_defined_field: Option, orderid: String, + #[serde(skip_serializing_if = "Option::is_none")] + customer_vault: Option, } #[derive(Debug, Serialize)] @@ -462,6 +485,12 @@ pub enum PaymentMethod { CardThreeDs(Box), GPay(Box), ApplePay(Box), + MandatePayment(Box), +} + +#[derive(Debug, Serialize)] +pub struct MandatePayment { + customer_vault_id: Secret, } #[derive(Debug, Serialize)] @@ -503,25 +532,70 @@ impl TryFrom<&NmiRouterData<&PaymentsAuthorizeRouterData>> for NmiPaymentsReques }; let auth_type: NmiAuthType = (&item.router_data.connector_auth_type).try_into()?; let amount = item.amount; - let payment_method = PaymentMethod::try_from(( - &item.router_data.request.payment_method_data, - Some(item.router_data), - ))?; - Ok(Self { - transaction_type, - security_key: auth_type.api_key, - amount, - currency: item.router_data.request.currency, - payment_method, - merchant_defined_field: item - .router_data - .request - .metadata - .as_ref() - .map(NmiMerchantDefinedField::new), - orderid: item.router_data.connector_request_reference_id.clone(), - }) + match item + .router_data + .request + .mandate_id + .clone() + .and_then(|mandate_ids| mandate_ids.mandate_reference_id) + { + Some(api_models::payments::MandateReferenceId::ConnectorMandateId( + connector_mandate_id, + )) => Ok(Self { + transaction_type, + security_key: auth_type.api_key, + amount, + currency: item.router_data.request.currency, + payment_method: PaymentMethod::MandatePayment(Box::new(MandatePayment { + customer_vault_id: Secret::new( + connector_mandate_id + .get_connector_mandate_id() + .ok_or(ConnectorError::MissingConnectorMandateID)?, + ), + })), + merchant_defined_field: item + .router_data + .request + .metadata + .as_ref() + .map(NmiMerchantDefinedField::new), + orderid: item.router_data.connector_request_reference_id.clone(), + customer_vault: None, + }), + Some(api_models::payments::MandateReferenceId::NetworkMandateId(_)) + | Some(api_models::payments::MandateReferenceId::NetworkTokenWithNTI(_)) => { + Err(ConnectorError::NotImplemented( + get_unimplemented_payment_method_error_message("nmi"), + ))? + } + None => { + let payment_method = PaymentMethod::try_from(( + &item.router_data.request.payment_method_data, + Some(item.router_data), + ))?; + + Ok(Self { + transaction_type, + security_key: auth_type.api_key, + amount, + currency: item.router_data.request.currency, + payment_method, + merchant_defined_field: item + .router_data + .request + .metadata + .as_ref() + .map(NmiMerchantDefinedField::new), + orderid: item.router_data.connector_request_reference_id.clone(), + customer_vault: item + .router_data + .request + .is_mandate_payment() + .then_some(CustomerAction::AddCustomer), + }) + } + } } } @@ -662,20 +736,36 @@ impl From<&ApplePayWalletData> for PaymentMethod { } } -impl TryFrom<&SetupMandateRouterData> for NmiPaymentsRequest { +impl TryFrom<&SetupMandateRouterData> for NmiValidateRequest { type Error = Error; fn try_from(item: &SetupMandateRouterData) -> Result { - let auth_type: NmiAuthType = (&item.connector_auth_type).try_into()?; - let payment_method = PaymentMethod::try_from((&item.request.payment_method_data, None))?; - Ok(Self { - transaction_type: TransactionType::Validate, - security_key: auth_type.api_key, - amount: FloatMajorUnit::zero(), - currency: item.request.currency, - payment_method, - merchant_defined_field: None, - orderid: item.connector_request_reference_id.clone(), - }) + match item.request.amount { + Some(amount) if amount > 0 => Err(ConnectorError::FlowNotSupported { + flow: "Setup Mandate with non zero amount".to_string(), + connector: "NMI".to_string(), + } + .into()), + _ => { + if let PaymentMethodData::Card(card_details) = &item.request.payment_method_data { + let auth_type: NmiAuthType = (&item.connector_auth_type).try_into()?; + Ok(Self { + transaction_type: TransactionType::Validate, + security_key: auth_type.api_key, + ccnumber: card_details.card_number.clone(), + ccexp: card_details + .get_card_expiry_month_year_2_digit_with_delimiter("".to_string())?, + cvv: card_details.card_cvc.clone(), + orderid: item.connector_request_reference_id.clone(), + customer_vault: CustomerAction::AddCustomer, + }) + } else { + Err(ConnectorError::NotImplemented( + get_unimplemented_payment_method_error_message("Nmi"), + ) + .into()) + } + } + } } } @@ -746,7 +836,7 @@ impl incremental_authorization_allowed: None, charges: None, }), - AttemptStatus::CaptureInitiated, + AttemptStatus::Charged, ), Response::Declined | Response::Error => ( Err(get_standard_error_response(item.response, item.http_code)), @@ -827,6 +917,7 @@ pub struct StandardResponse { pub cvvresponse: Option, pub orderid: String, pub response_code: String, + pub customer_vault_id: Option>, } impl TryFrom> @@ -843,7 +934,17 @@ impl TryFrom Box::new(Some( + hyperswitch_domain_models::router_response_types::MandateReference { + connector_mandate_id: Some(vault_id.expose()), + payment_method_id: None, + mandate_metadata: None, + connector_mandate_request_reference_id: None, + }, + )), + None => Box::new(None), + }, connector_metadata: None, network_txn_id: None, connector_response_reference_id: Some(item.response.orderid), @@ -897,15 +998,25 @@ impl TryFrom> item.response.transactionid.clone(), ), redirection_data: Box::new(None), - mandate_reference: Box::new(None), + mandate_reference: match item.response.customer_vault_id { + Some(vault_id) => Box::new(Some( + hyperswitch_domain_models::router_response_types::MandateReference { + connector_mandate_id: Some(vault_id.expose()), + payment_method_id: None, + mandate_metadata: None, + connector_mandate_request_reference_id: None, + }, + )), + None => Box::new(None), + }, connector_metadata: None, network_txn_id: None, connector_response_reference_id: Some(item.response.orderid), incremental_authorization_allowed: None, charges: None, }), - if let Some(CaptureMethod::Automatic) = item.data.request.capture_method { - AttemptStatus::CaptureInitiated + if item.data.request.is_auto_capture()? { + AttemptStatus::Charged } else { AttemptStatus::Authorized }, @@ -1094,7 +1205,7 @@ impl TryFrom> for RefundsRo impl From for RefundStatus { fn from(item: Response) -> Self { match item { - Response::Approved => Self::Pending, + Response::Approved => Self::Success, Response::Declined | Response::Error => Self::Failure, } } diff --git a/cypress-tests/cypress/e2e/configs/Payment/Nmi.js b/cypress-tests/cypress/e2e/configs/Payment/Nmi.js index de8a1a5f77b..db9f90f3d04 100644 --- a/cypress-tests/cypress/e2e/configs/Payment/Nmi.js +++ b/cypress-tests/cypress/e2e/configs/Payment/Nmi.js @@ -1,6 +1,6 @@ const successfulNo3DSCardDetails = { - card_number: "4000000000002503", - card_exp_month: "08", + card_number: "4532111111111112", + card_exp_month: "10", card_exp_year: "50", card_holder_name: "joseph Doe", card_cvc: "999", @@ -83,7 +83,7 @@ export const connectorDetails = { Response: { status: 200, body: { - status: "processing", + status: "succeeded", shipping_cost: 50, amount: 6000, }, @@ -149,7 +149,16 @@ export const connectorDetails = { Response: { status: 200, body: { - status: "processing", + status: "succeeded", + }, + }, + }, + MITManualCapture: { + Request: {}, + Response: { + status: 200, + body: { + status: "requires_capture", }, }, }, @@ -160,9 +169,9 @@ export const connectorDetails = { Response: { status: 200, body: { - status: "processing", + status: "succeeded", amount: 6000, - amount_capturable: 6000, + amount_capturable: 0, }, }, }, @@ -173,9 +182,9 @@ export const connectorDetails = { Response: { status: 200, body: { - status: "processing", + status: "partially_captured", amount: 6000, - amount_capturable: 6000, + amount_capturable: 0, }, }, }, @@ -209,7 +218,7 @@ export const connectorDetails = { Response: { status: 200, body: { - status: "pending", + status: "succeeded", }, }, }, @@ -220,7 +229,7 @@ export const connectorDetails = { Response: { status: 200, body: { - status: "pending", + status: "succeeded", }, }, }, @@ -231,7 +240,7 @@ export const connectorDetails = { Response: { status: 200, body: { - status: "pending", + status: "succeeded", }, }, }, @@ -242,7 +251,7 @@ export const connectorDetails = { Response: { status: 200, body: { - status: "pending", + status: "succeeded", }, }, }, @@ -255,14 +264,18 @@ export const connectorDetails = { }, }, ZeroAuthMandate: { + Request: { + payment_type: "setup_mandate", + payment_method: "card", + setup_future_usage: "off_session", + payment_method_data: { + card: successfulNo3DSCardDetails, + }, + }, Response: { - status: 501, + status: 200, body: { - error: { - type: "invalid_request", - message: "Setup Mandate flow for Nmi is not implemented", - code: "IR_00", - }, + status: "succeeded", }, }, }, @@ -284,18 +297,16 @@ export const connectorDetails = { Request: { payment_type: "setup_mandate", payment_method: "card", + payment_method_type: "credit", payment_method_data: { card: successfulNo3DSCardDetails, }, }, Response: { - status: 501, + status: 200, body: { - error: { - type: "invalid_request", - message: "Setup Mandate flow for Nmi is not implemented", - code: "IR_00", - }, + status: "succeeded", + setup_future_usage: "off_session", }, }, }, @@ -311,7 +322,7 @@ export const connectorDetails = { Response: { status: 200, body: { - status: "processing", + status: "succeeded", }, }, }, @@ -462,7 +473,7 @@ export const connectorDetails = { Response: { status: 200, body: { - status: "processing", + status: "succeeded", }, }, }, @@ -485,10 +496,36 @@ export const connectorDetails = { }, }, }, - PaymentMethodIdMandateNo3DSAutoCapture: { - Configs: { - TRIGGER_SKIP: true, + PaymentIntentOffSession: { + Request: { + amount: 6000, + authentication_type: "no_three_ds", + currency: "USD", + customer_acceptance: null, + setup_future_usage: "off_session", + }, + Response: { + status: 200, + body: { + status: "requires_payment_method", + }, + }, + }, + SaveCardConfirmAutoCaptureOffSession: { + Request: { + setup_future_usage: "off_session", + payment_method_data: { + card: successfulNo3DSCardDetails, + }, + }, + Response: { + status: 200, + body: { + status: "succeeded", + }, }, + }, + PaymentMethodIdMandateNo3DSAutoCapture: { Request: { payment_method: "card", payment_method_data: { @@ -505,10 +542,53 @@ export const connectorDetails = { }, }, }, - PaymentMethodIdMandateNo3DSManualCapture: { + SaveCardUse3DSAutoCaptureOffSession: { Configs: { - TRIGGER_SKIP: true, + CONNECTOR_CREDENTIAL: { + specName: ["connectorAgnosticNTID"], + value: "connector_2", + }, }, + Request: { + payment_method: "card", + payment_method_type: "debit", + payment_method_data: { + card: successfulThreeDSTestCardDetails, + }, + setup_future_usage: "off_session", + customer_acceptance: customerAcceptance, + }, + Response: { + status: 200, + body: { + status: "requires_customer_action", + }, + }, + }, + SaveCardConfirmManualCaptureOffSession: { + Request: { + setup_future_usage: "off_session", + }, + Response: { + status: 200, + body: { + status: "requires_capture", + }, + }, + }, + SaveCardConfirmAutoCaptureOffSessionWithoutBilling: { + Request: { + setup_future_usage: "off_session", + billing: null, + }, + Response: { + status: 200, + body: { + status: "succeeded", + }, + }, + }, + PaymentMethodIdMandateNo3DSManualCapture: { Request: { payment_method: "card", payment_method_data: { @@ -526,15 +606,12 @@ export const connectorDetails = { }, }, PaymentMethodIdMandate3DSAutoCapture: { - Configs: { - // Skipping redirection here for mandate 3ds auto capture as it requires changes from the core - TRIGGER_SKIP: true, - }, Request: { payment_method: "card", payment_method_data: { card: successfulThreeDSTestCardDetails, }, + currency: "USD", mandate_data: null, authentication_type: "three_ds", customer_acceptance: customerAcceptance, @@ -547,10 +624,8 @@ export const connectorDetails = { }, }, PaymentMethodIdMandate3DSManualCapture: { - Configs: { - TRIGGER_SKIP: true, - }, Request: { + payment_method: "card", payment_method_data: { card: successfulThreeDSTestCardDetails, },