From ba8a17d66f12fce01fa3a2d50bd9a5591bf8ef2f Mon Sep 17 00:00:00 2001 From: SamraatBansal <55536657+SamraatBansal@users.noreply.github.com> Date: Mon, 5 Jun 2023 15:06:59 +0530 Subject: [PATCH] feat(connector): [Noon] Add Card Mandates and Webhooks Support (#1243) Co-authored-by: Arjun Karthik Co-authored-by: Arun Raj M --- crates/api_models/src/payments.rs | 2 + crates/router/src/connector/bluesnap.rs | 2 +- crates/router/src/connector/noon.rs | 102 ++++++++++++- .../router/src/connector/noon/transformers.rs | 144 +++++++++++++++--- crates/router/src/core/payments.rs | 1 + crates/router/src/core/payments/helpers.rs | 8 +- .../router/src/core/payments/transformers.rs | 4 + crates/router/src/types.rs | 2 + crates/router/src/types/api/payments.rs | 18 ++- crates/router/tests/connectors/aci.rs | 1 + crates/router/tests/connectors/adyen.rs | 1 + crates/router/tests/connectors/bitpay.rs | 1 + crates/router/tests/connectors/coinbase.rs | 1 + crates/router/tests/connectors/opennode.rs | 1 + crates/router/tests/connectors/utils.rs | 1 + crates/router/tests/connectors/worldline.rs | 1 + 16 files changed, 256 insertions(+), 34 deletions(-) diff --git a/crates/api_models/src/payments.rs b/crates/api_models/src/payments.rs index 56b781da2f..4815b19ad2 100644 --- a/crates/api_models/src/payments.rs +++ b/crates/api_models/src/payments.rs @@ -1559,6 +1559,8 @@ pub struct Metadata { #[schema(value_type = Object, example = r#"{ "city": "NY", "unit": "245" }"#)] #[serde(flatten)] pub data: pii::SecretSerdeValue, + /// Information about the order category that merchant wants to specify at connector level. (e.g. In Noon Payments it can take values like "pay", "food", or any other custom string set by the merchant in Noon's Dashboard) + pub order_category: Option, /// Redirection response coming in request as metadata field only for redirection scenarios pub redirect_response: Option, diff --git a/crates/router/src/connector/bluesnap.rs b/crates/router/src/connector/bluesnap.rs index 7a23fcae65..e0c60ca6a2 100644 --- a/crates/router/src/connector/bluesnap.rs +++ b/crates/router/src/connector/bluesnap.rs @@ -1057,7 +1057,7 @@ impl api::IncomingWebhook for Bluesnap { let details: bluesnap::BluesnapWebhookObjectResource = serde_urlencoded::from_bytes(request.body) .into_report() - .change_context(errors::ConnectorError::WebhookEventTypeNotFound)?; + .change_context(errors::ConnectorError::WebhookResourceObjectNotFound)?; let res_json = utils::Encode::::encode_to_value(&details) .change_context(errors::ConnectorError::WebhookResourceObjectNotFound)?; diff --git a/crates/router/src/connector/noon.rs b/crates/router/src/connector/noon.rs index 80927b3f5f..eae29e156f 100644 --- a/crates/router/src/connector/noon.rs +++ b/crates/router/src/connector/noon.rs @@ -3,6 +3,7 @@ mod transformers; use std::fmt::Debug; use base64::Engine; +use common_utils::{crypto, ext_traits::ByteSliceExt}; use error_stack::{IntoReport, ResultExt}; use transformers as noon; @@ -14,6 +15,7 @@ use crate::{ errors::{self, CustomResult}, payments, }, + db::StorageInterface, headers, services::{ self, @@ -585,24 +587,112 @@ impl services::ConnectorRedirectResponse for Noon { #[async_trait::async_trait] impl api::IncomingWebhook for Noon { - fn get_webhook_object_reference_id( + fn get_webhook_source_verification_algorithm( &self, _request: &api::IncomingWebhookRequestDetails<'_>, + ) -> CustomResult, errors::ConnectorError> { + Ok(Box::new(crypto::HmacSha512)) + } + + fn get_webhook_source_verification_signature( + &self, + request: &api::IncomingWebhookRequestDetails<'_>, + ) -> CustomResult, errors::ConnectorError> { + let webhook_body: noon::NoonWebhookSignature = request + .body + .parse_struct("NoonWebhookSignature") + .change_context(errors::ConnectorError::WebhookSignatureNotFound)?; + let signature = webhook_body.signature; + consts::BASE64_ENGINE + .decode(signature) + .into_report() + .change_context(errors::ConnectorError::WebhookSignatureNotFound) + } + + fn get_webhook_source_verification_message( + &self, + request: &api::IncomingWebhookRequestDetails<'_>, + _merchant_id: &str, + _secret: &[u8], + ) -> CustomResult, errors::ConnectorError> { + let webhook_body: noon::NoonWebhookBody = request + .body + .parse_struct("NoonWebhookBody") + .change_context(errors::ConnectorError::WebhookSignatureNotFound)?; + let message = format!( + "{},{},{},{},{}", + webhook_body.order_id, + webhook_body.order_status, + webhook_body.event_id, + webhook_body.event_type, + webhook_body.time_stamp, + ); + Ok(message.into_bytes()) + } + + async fn get_webhook_source_verification_merchant_secret( + &self, + db: &dyn StorageInterface, + merchant_id: &str, + ) -> CustomResult, errors::ConnectorError> { + let key = format!("whsec_verification_{}_{}", self.id(), merchant_id); + let secret = match db.find_config_by_key(&key).await { + Ok(config) => Some(config), + Err(e) => { + crate::logger::warn!("Unable to fetch merchant webhook secret from DB: {:#?}", e); + None + } + }; + Ok(secret + .map(|conf| conf.config.into_bytes()) + .unwrap_or_default()) + } + + fn get_webhook_object_reference_id( + &self, + request: &api::IncomingWebhookRequestDetails<'_>, ) -> CustomResult { - Err(errors::ConnectorError::WebhooksNotImplemented).into_report() + let details: noon::NoonWebhookOrderId = request + .body + .parse_struct("NoonWebhookOrderId") + .change_context(errors::ConnectorError::WebhookReferenceIdNotFound)?; + Ok(api_models::webhooks::ObjectReferenceId::PaymentId( + api_models::payments::PaymentIdType::ConnectorTransactionId( + details.order_id.to_string(), + ), + )) } fn get_webhook_event_type( &self, - _request: &api::IncomingWebhookRequestDetails<'_>, + request: &api::IncomingWebhookRequestDetails<'_>, ) -> CustomResult { - Err(errors::ConnectorError::WebhooksNotImplemented).into_report() + let details: noon::NoonWebhookEvent = request + .body + .parse_struct("NoonWebhookEvent") + .change_context(errors::ConnectorError::WebhookEventTypeNotFound)?; + + Ok(match &details.event_type { + noon::NoonWebhookEventTypes::Sale | noon::NoonWebhookEventTypes::Capture => { + match &details.order_status { + noon::NoonPaymentStatus::Captured => { + api::IncomingWebhookEvent::PaymentIntentSuccess + } + _ => Err(errors::ConnectorError::WebhookEventTypeNotFound)?, + } + } + noon::NoonWebhookEventTypes::Fail => api::IncomingWebhookEvent::PaymentIntentFailure, + _ => Err(errors::ConnectorError::WebhookEventTypeNotFound)?, + }) } fn get_webhook_resource_object( &self, - _request: &api::IncomingWebhookRequestDetails<'_>, + request: &api::IncomingWebhookRequestDetails<'_>, ) -> CustomResult { - Err(errors::ConnectorError::WebhooksNotImplemented).into_report() + let reference_object: serde_json::Value = serde_json::from_slice(request.body) + .into_report() + .change_context(errors::ConnectorError::WebhookResourceObjectNotFound)?; + Ok(reference_object) } } diff --git a/crates/router/src/connector/noon/transformers.rs b/crates/router/src/connector/noon/transformers.rs index 71a5ce81f2..ec494ee5a6 100644 --- a/crates/router/src/connector/noon/transformers.rs +++ b/crates/router/src/connector/noon/transformers.rs @@ -16,13 +16,27 @@ pub enum NoonChannels { Web, } +#[derive(Debug, Serialize)] +#[serde(rename_all = "UPPERCASE")] +pub enum NoonSubscriptionType { + Unscheduled, +} + +#[derive(Debug, Serialize)] +pub struct NoonSubscriptionData { + #[serde(rename = "type")] + subscription_type: NoonSubscriptionType, + //Short description about the subscription. + name: String, +} + #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct NoonOrder { amount: String, - currency: storage_models::enums::Currency, + currency: Option, channel: NoonChannels, - category: String, + category: Option, //Short description of the order. name: String, } @@ -37,10 +51,17 @@ pub enum NoonPaymentActions { #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct NoonConfiguration { + tokenize_c_c: Option, payment_action: NoonPaymentActions, return_url: Option, } +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct NoonSubscription { + subscription_identifier: String, +} + #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct NoonCard { @@ -55,6 +76,7 @@ pub struct NoonCard { #[serde(tag = "type", content = "data")] pub enum NoonPaymentData { Card(NoonCard), + Subscription(NoonSubscription), } #[derive(Debug, Serialize)] @@ -72,30 +94,55 @@ pub struct NoonPaymentsRequest { order: NoonOrder, configuration: NoonConfiguration, payment_data: NoonPaymentData, + subscription: Option, } impl TryFrom<&types::PaymentsAuthorizeRouterData> for NoonPaymentsRequest { type Error = error_stack::Report; fn try_from(item: &types::PaymentsAuthorizeRouterData) -> Result { - let payment_data = match item.request.payment_method_data.clone() { - api::PaymentMethodData::Card(req_card) => Ok(NoonPaymentData::Card(NoonCard { - name_on_card: req_card.card_holder_name, - number_plain: req_card.card_number, - expiry_month: req_card.card_exp_month, - expiry_year: req_card.card_exp_year, - cvv: req_card.card_cvc, - })), - _ => Err(errors::ConnectorError::NotImplemented( - "Payment methods".to_string(), - )), - }?; - + let (payment_data, currency, category) = match item.request.connector_mandate_id() { + Some(subscription_identifier) => ( + NoonPaymentData::Subscription(NoonSubscription { + subscription_identifier, + }), + None, + None, + ), + _ => ( + match item.request.payment_method_data.clone() { + api::PaymentMethodData::Card(req_card) => Ok(NoonPaymentData::Card(NoonCard { + name_on_card: req_card.card_holder_name, + number_plain: req_card.card_number, + expiry_month: req_card.card_exp_month, + expiry_year: req_card.card_exp_year, + cvv: req_card.card_cvc, + })), + _ => Err(errors::ConnectorError::NotImplemented( + "Payment methods".to_string(), + )), + }?, + Some(item.request.currency), + item.request.order_category.clone(), + ), + }; + let name = item.get_description()?; + let (subscription, tokenize_c_c) = + match item.request.setup_future_usage.is_some().then_some(( + NoonSubscriptionData { + subscription_type: NoonSubscriptionType::Unscheduled, + name: name.clone(), + }, + true, + )) { + Some((a, b)) => (Some(a), Some(b)), + None => (None, None), + }; let order = NoonOrder { amount: conn_utils::to_currency_base_unit(item.request.amount, item.request.currency)?, - currency: item.request.currency, + currency, channel: NoonChannels::Web, - category: "pay".to_string(), - name: item.get_description()?, + category, + name, }; let payment_action = if item.request.is_auto_capture()? { NoonPaymentActions::Sale @@ -108,8 +155,10 @@ impl TryFrom<&types::PaymentsAuthorizeRouterData> for NoonPaymentsRequest { configuration: NoonConfiguration { payment_action, return_url: item.request.router_return_url.clone(), + tokenize_c_c, }, payment_data, + subscription, }) } } @@ -138,13 +187,15 @@ impl TryFrom<&types::ConnectorAuthType> for NoonAuthType { } } } -#[derive(Default, Debug, Deserialize)] +#[derive(Default, Debug, Deserialize, strum::Display)] #[serde(rename_all = "SCREAMING_SNAKE_CASE")] +#[strum(serialize_all = "UPPERCASE")] pub enum NoonPaymentStatus { Authorized, Captured, PartiallyCaptured, Reversed, + Cancelled, #[serde(rename = "3DS_ENROLL_INITIATED")] ThreeDsEnrollInitiated, Failed, @@ -158,6 +209,7 @@ impl From for enums::AttemptStatus { NoonPaymentStatus::Authorized => Self::Authorized, NoonPaymentStatus::Captured | NoonPaymentStatus::PartiallyCaptured => Self::Charged, NoonPaymentStatus::Reversed => Self::Voided, + NoonPaymentStatus::Cancelled => Self::AuthenticationFailed, NoonPaymentStatus::ThreeDsEnrollInitiated => Self::AuthenticationPending, NoonPaymentStatus::Failed => Self::Failure, NoonPaymentStatus::Pending => Self::Pending, @@ -165,6 +217,11 @@ impl From for enums::AttemptStatus { } } +#[derive(Debug, Deserialize)] +pub struct NoonSubscriptionResponse { + identifier: String, +} + #[derive(Default, Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct NoonPaymentsOrderResponse { @@ -183,6 +240,7 @@ pub struct NoonCheckoutData { pub struct NoonPaymentsResponseResult { order: NoonPaymentsOrderResponse, checkout_data: Option, + subscription: Option, } #[derive(Debug, Deserialize)] @@ -205,6 +263,14 @@ impl form_fields: std::collections::HashMap::new(), } }); + let mandate_reference = + item.response + .result + .subscription + .map(|subscription_data| types::MandateReference { + connector_mandate_id: Some(subscription_data.identifier), + payment_method_id: None, + }); Ok(Self { status: enums::AttemptStatus::from(item.response.result.order.status), response: Ok(types::PaymentsResponseData::TransactionResponse { @@ -212,7 +278,7 @@ impl item.response.result.order.id.to_string(), ), redirection_data, - mandate_reference: None, + mandate_reference, connector_metadata: None, network_txn_id: None, }), @@ -399,6 +465,44 @@ impl TryFrom> } } +#[derive(Debug, Deserialize, strum::Display)] +pub enum NoonWebhookEventTypes { + Authenticate, + Authorize, + Capture, + Fail, + Refund, + Sale, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct NoonWebhookBody { + pub order_id: u64, + pub order_status: NoonPaymentStatus, + pub event_type: NoonWebhookEventTypes, + pub event_id: String, + pub time_stamp: String, +} + +#[derive(Debug, Deserialize)] +pub struct NoonWebhookSignature { + pub signature: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct NoonWebhookOrderId { + pub order_id: u64, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct NoonWebhookEvent { + pub order_status: NoonPaymentStatus, + pub event_type: NoonWebhookEventTypes, +} + #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct NoonErrorResponse { diff --git a/crates/router/src/core/payments.rs b/crates/router/src/core/payments.rs index 978f89a9f8..f2392d2693 100644 --- a/crates/router/src/core/payments.rs +++ b/crates/router/src/core/payments.rs @@ -364,6 +364,7 @@ impl PaymentRedirectFlow for PaymentRedirectCompleteAuthorize { json_payload: Some(req.json_payload.unwrap_or(serde_json::json!({})).into()), }), allowed_payment_method_types: None, + order_category: None, }), ..Default::default() }; diff --git a/crates/router/src/core/payments/helpers.rs b/crates/router/src/core/payments/helpers.rs index ee2d402803..88c71df481 100644 --- a/crates/router/src/core/payments/helpers.rs +++ b/crates/router/src/core/payments/helpers.rs @@ -393,9 +393,13 @@ pub fn validate_request_amount_and_amount_to_capture( pub fn validate_mandate( req: impl Into, is_confirm_operation: bool, -) -> RouterResult> { +) -> CustomResult, errors::ApiErrorResponse> { let req: api::MandateValidationFields = req.into(); - match req.is_mandate() { + match req.validate_and_get_mandate_type().change_context( + errors::ApiErrorResponse::MandateValidationFailed { + reason: "Expected one out of mandate_id and mandate_data but got both".to_string(), + }, + )? { Some(api::MandateTxnType::NewMandateTxn) => { validate_new_mandate_request(req, is_confirm_operation)?; Ok(Some(api::MandateTxnType::NewMandateTxn)) diff --git a/crates/router/src/core/payments/transformers.rs b/crates/router/src/core/payments/transformers.rs index 83e14f9dc2..cc29e34fe5 100644 --- a/crates/router/src/core/payments/transformers.rs +++ b/crates/router/src/core/payments/transformers.rs @@ -605,6 +605,9 @@ impl TryFrom> for types::PaymentsAuthoriz .transpose() .unwrap_or_default(); + let order_category = parsed_metadata + .as_ref() + .and_then(|data| data.order_category.clone()); let order_details = parsed_metadata.and_then(|data| data.order_details); let complete_authorize_url = Some(helpers::create_complete_authorize_url( router_base_url, @@ -647,6 +650,7 @@ impl TryFrom> for types::PaymentsAuthoriz email: payment_data.email, payment_experience: payment_data.payment_attempt.payment_experience, order_details, + order_category, session_token: None, enrolled_for_3ds: true, related_transaction_id: None, diff --git a/crates/router/src/types.rs b/crates/router/src/types.rs index fde4204845..cb9647a291 100644 --- a/crates/router/src/types.rs +++ b/crates/router/src/types.rs @@ -226,6 +226,7 @@ pub struct PaymentsAuthorizeData { pub setup_mandate_details: Option, pub browser_info: Option, pub order_details: Option, + pub order_category: Option, pub session_token: Option, pub enrolled_for_3ds: bool, pub related_transaction_id: Option, @@ -729,6 +730,7 @@ impl From<&VerifyRouterData> for PaymentsAuthorizeData { complete_authorize_url: None, browser_info: None, order_details: None, + order_category: None, session_token: None, enrolled_for_3ds: true, related_transaction_id: None, diff --git a/crates/router/src/types/api/payments.rs b/crates/router/src/types/api/payments.rs index 58509f9397..9a24f871db 100644 --- a/crates/router/src/types/api/payments.rs +++ b/crates/router/src/types/api/payments.rs @@ -113,15 +113,23 @@ impl PaymentIdTypeExt for PaymentIdType { } pub(crate) trait MandateValidationFieldsExt { - fn is_mandate(&self) -> Option; + fn validate_and_get_mandate_type( + &self, + ) -> errors::CustomResult, errors::ValidationError>; } impl MandateValidationFieldsExt for MandateValidationFields { - fn is_mandate(&self) -> Option { + fn validate_and_get_mandate_type( + &self, + ) -> errors::CustomResult, errors::ValidationError> { match (&self.mandate_data, &self.mandate_id) { - (None, None) => None, - (_, Some(_)) => Some(MandateTxnType::RecurringMandateTxn), - (Some(_), _) => Some(MandateTxnType::NewMandateTxn), + (None, None) => Ok(None), + (Some(_), Some(_)) => Err(errors::ValidationError::InvalidValue { + message: "Expected one out of mandate_id and mandate_data but got both".to_string(), + }) + .into_report(), + (_, Some(_)) => Ok(Some(MandateTxnType::RecurringMandateTxn)), + (Some(_), _) => Ok(Some(MandateTxnType::NewMandateTxn)), } } } diff --git a/crates/router/tests/connectors/aci.rs b/crates/router/tests/connectors/aci.rs index 2dd3eb35a0..2f1374d912 100644 --- a/crates/router/tests/connectors/aci.rs +++ b/crates/router/tests/connectors/aci.rs @@ -53,6 +53,7 @@ fn construct_payment_router_data() -> types::PaymentsAuthorizeRouterData { capture_method: None, browser_info: None, order_details: None, + order_category: None, email: None, session_token: None, enrolled_for_3ds: false, diff --git a/crates/router/tests/connectors/adyen.rs b/crates/router/tests/connectors/adyen.rs index a9d3a03053..2084df1117 100644 --- a/crates/router/tests/connectors/adyen.rs +++ b/crates/router/tests/connectors/adyen.rs @@ -81,6 +81,7 @@ impl AdyenTest { capture_method: Some(capture_method), browser_info: None, order_details: None, + order_category: None, email: None, payment_experience: None, payment_method_type: None, diff --git a/crates/router/tests/connectors/bitpay.rs b/crates/router/tests/connectors/bitpay.rs index e3e4f06efa..61818100a7 100644 --- a/crates/router/tests/connectors/bitpay.rs +++ b/crates/router/tests/connectors/bitpay.rs @@ -75,6 +75,7 @@ fn payment_method_details() -> Option { // capture_method: Some(capture_method), browser_info: None, order_details: None, + order_category: None, email: None, payment_experience: None, payment_method_type: None, diff --git a/crates/router/tests/connectors/coinbase.rs b/crates/router/tests/connectors/coinbase.rs index bbdb25f474..0bb8a87c35 100644 --- a/crates/router/tests/connectors/coinbase.rs +++ b/crates/router/tests/connectors/coinbase.rs @@ -77,6 +77,7 @@ fn payment_method_details() -> Option { // capture_method: Some(capture_method), browser_info: None, order_details: None, + order_category: None, email: None, payment_experience: None, payment_method_type: None, diff --git a/crates/router/tests/connectors/opennode.rs b/crates/router/tests/connectors/opennode.rs index c5be307172..20a3fde4b0 100644 --- a/crates/router/tests/connectors/opennode.rs +++ b/crates/router/tests/connectors/opennode.rs @@ -76,6 +76,7 @@ fn payment_method_details() -> Option { // capture_method: Some(capture_method), browser_info: None, order_details: None, + order_category: None, email: None, payment_experience: None, payment_method_type: None, diff --git a/crates/router/tests/connectors/utils.rs b/crates/router/tests/connectors/utils.rs index 8e87f19edd..6f0a39dc41 100644 --- a/crates/router/tests/connectors/utils.rs +++ b/crates/router/tests/connectors/utils.rs @@ -508,6 +508,7 @@ impl Default for PaymentAuthorizeType { setup_mandate_details: None, browser_info: Some(BrowserInfoType::default().0), order_details: None, + order_category: None, email: None, session_token: None, enrolled_for_3ds: false, diff --git a/crates/router/tests/connectors/worldline.rs b/crates/router/tests/connectors/worldline.rs index 6b9414b6a2..f46aaa33a7 100644 --- a/crates/router/tests/connectors/worldline.rs +++ b/crates/router/tests/connectors/worldline.rs @@ -84,6 +84,7 @@ impl WorldlineTest { capture_method: Some(capture_method), browser_info: None, order_details: None, + order_category: None, email: None, session_token: None, enrolled_for_3ds: false,