Skip to content

feat(connector): [NMI] Add mandates flow #8652

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 10 commits into from
Aug 5, 2025
40 changes: 23 additions & 17 deletions crates/hyperswitch_connectors/src/connectors/nmi.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ use hyperswitch_interfaces::{
types::{
PaymentsAuthorizeType, PaymentsCaptureType, PaymentsCompleteAuthorizeType,
PaymentsPreProcessingType, PaymentsSyncType, PaymentsVoidType, RefundExecuteType,
RefundSyncType, Response,
RefundSyncType, Response, SetupMandateType,
},
webhooks::{IncomingWebhook, IncomingWebhookRequestDetails},
};
Expand All @@ -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)]
Expand Down Expand Up @@ -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<enums::PaymentMethodType>,
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<PaymentMethodToken, PaymentMethodTokenizationData, PaymentsResponseData>
Expand Down Expand Up @@ -194,27 +204,23 @@ impl ConnectorIntegration<SetupMandate, SetupMandateRequestData, PaymentsRespons
req: &SetupMandateRouterData,
_connectors: &Connectors,
) -> CustomResult<RequestContent, ConnectorError> {
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<Option<Request>, 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(
Expand Down
180 changes: 140 additions & 40 deletions crates/hyperswitch_connectors/src/connectors/nmi/transformers.rs
Original file line number Diff line number Diff line change
@@ -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::{
Expand Down Expand Up @@ -384,7 +382,7 @@ impl
incremental_authorization_allowed: None,
charges: None,
}),
if let Some(CaptureMethod::Automatic) = item.data.request.capture_method {
if item.data.request.is_auto_capture()? {
AttemptStatus::CaptureInitiated
} else {
AttemptStatus::Authorizing
Expand Down Expand Up @@ -417,6 +415,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<String>,
ccnumber: CardNumber,
ccexp: Secret<String>,
cvv: Secret<String>,
orderid: String,
customer_vault: CustomerAction,
}

#[derive(Debug, Serialize)]
pub struct NmiPaymentsRequest {
#[serde(rename = "type")]
Expand All @@ -429,6 +439,8 @@ pub struct NmiPaymentsRequest {
#[serde(flatten)]
merchant_defined_field: Option<NmiMerchantDefinedField>,
orderid: String,
#[serde(skip_serializing_if = "Option::is_none")]
customer_vault: Option<CustomerAction>,
}

#[derive(Debug, Serialize)]
Expand Down Expand Up @@ -462,6 +474,12 @@ pub enum PaymentMethod {
CardThreeDs(Box<CardThreeDsData>),
GPay(Box<GooglePayData>),
ApplePay(Box<ApplePayData>),
MandatePayment(Box<MandatePayment>),
}

#[derive(Debug, Serialize)]
pub struct MandatePayment {
customer_vault_id: Secret<String>,
}

#[derive(Debug, Serialize)]
Expand Down Expand Up @@ -503,25 +521,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),
})
}
}
}
}

Expand Down Expand Up @@ -662,20 +725,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<Self, Self::Error> {
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())
}
}
}
}
}

Expand Down Expand Up @@ -746,7 +825,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)),
Expand Down Expand Up @@ -827,6 +906,7 @@ pub struct StandardResponse {
pub cvvresponse: Option<String>,
pub orderid: String,
pub response_code: String,
pub customer_vault_id: Option<Secret<String>>,
}

impl<T> TryFrom<ResponseRouterData<SetupMandate, StandardResponse, T, PaymentsResponseData>>
Expand All @@ -843,7 +923,17 @@ impl<T> TryFrom<ResponseRouterData<SetupMandate, StandardResponse, T, PaymentsRe
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),
Expand Down Expand Up @@ -897,15 +987,25 @@ impl TryFrom<PaymentsResponseRouterData<StandardResponse>>
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
},
Expand Down Expand Up @@ -1094,7 +1194,7 @@ impl TryFrom<RefundsResponseRouterData<Capture, StandardResponse>> for RefundsRo
impl From<Response> for RefundStatus {
fn from(item: Response) -> Self {
match item {
Response::Approved => Self::Pending,
Response::Approved => Self::Success,
Response::Declined | Response::Error => Self::Failure,
}
}
Expand Down
Loading
Loading