From 9654d18d74bf2b64c68b05ebc216f1b3ad99effe Mon Sep 17 00:00:00 2001 From: AkshayaFoiger <131388445+AkshayaFoiger@users.noreply.github.com> Date: Fri, 19 Sep 2025 16:40:31 +0530 Subject: [PATCH] fix(connectors): [Nuvei] payments, refunds and chargeback webhooks (#9378) Co-authored-by: hyperswitch-bot[bot] <148525504+hyperswitch-bot[bot]@users.noreply.github.com> --- .../src/connectors/nuvei.rs | 252 +++++-- .../src/connectors/nuvei/transformers.rs | 676 +++++++++++++++--- 2 files changed, 749 insertions(+), 179 deletions(-) diff --git a/crates/hyperswitch_connectors/src/connectors/nuvei.rs b/crates/hyperswitch_connectors/src/connectors/nuvei.rs index 9c906abeef..e7d3997693 100644 --- a/crates/hyperswitch_connectors/src/connectors/nuvei.rs +++ b/crates/hyperswitch_connectors/src/connectors/nuvei.rs @@ -1,7 +1,10 @@ pub mod transformers; use std::sync::LazyLock; -use api_models::{payments::PaymentIdType, webhooks::IncomingWebhookEvent}; +use api_models::{ + payments::PaymentIdType, + webhooks::{IncomingWebhookEvent, RefundIdType}, +}; use common_enums::{enums, CallConnectorAction, PaymentAction}; use common_utils::{ crypto, @@ -9,7 +12,10 @@ use common_utils::{ ext_traits::{ByteSliceExt, BytesExt, ValueExt}, id_type, request::{Method, Request, RequestBuilder, RequestContent}, - types::{AmountConvertor, StringMajorUnit, StringMajorUnitForConnector}, + types::{ + AmountConvertor, FloatMajorUnit, FloatMajorUnitForConnector, StringMajorUnit, + StringMajorUnitForConnector, StringMinorUnit, StringMinorUnitForConnector, + }, }; use error_stack::ResultExt; use hyperswitch_domain_models::{ @@ -44,7 +50,7 @@ use hyperswitch_interfaces::{ ConnectorSpecifications, ConnectorValidation, }, configs::Connectors, - errors, + disputes, errors, events::connector_api_logs::ConnectorEvent, types::{self, Response}, webhooks::{IncomingWebhook, IncomingWebhookRequestDetails}, @@ -62,11 +68,17 @@ use crate::{ #[derive(Clone)] pub struct Nuvei { pub amount_convertor: &'static (dyn AmountConvertor + Sync), + amount_converter_string_minor_unit: + &'static (dyn AmountConvertor + Sync), + amount_converter_float_major_unit: + &'static (dyn AmountConvertor + Sync), } impl Nuvei { pub fn new() -> &'static Self { &Self { amount_convertor: &StringMajorUnitForConnector, + amount_converter_string_minor_unit: &StringMinorUnitForConnector, + amount_converter_float_major_unit: &FloatMajorUnitForConnector, } } } @@ -546,13 +558,14 @@ impl ConnectorIntegration for Nuv event_builder: Option<&mut ConnectorEvent>, res: Response, ) -> CustomResult { - let response: NuveiTransactionSyncResponse = res + let nuvie_psync_common_response: nuvei::NuveiPaymentSyncResponse = res .response - .parse_struct("NuveiTransactionSyncResponse") + .parse_struct("NuveiPaymentSyncResponse") .switch()?; - event_builder.map(|i| i.set_response_body(&response)); - router_env::logger::info!(connector_response=?response); + event_builder.map(|i| i.set_response_body(&nuvie_psync_common_response)); + router_env::logger::info!(connector_response=?nuvie_psync_common_response); + let response = NuveiTransactionSyncResponse::from(nuvie_psync_common_response); RouterData::try_from(ResponseRouterData { response, @@ -987,7 +1000,30 @@ impl ConnectorIntegration for Nuvei { } } -impl ConnectorIntegration for Nuvei {} +impl ConnectorIntegration for Nuvei { + fn handle_response( + &self, + data: &RefundsRouterData, + event_builder: Option<&mut ConnectorEvent>, + res: Response, + ) -> CustomResult, errors::ConnectorError> + { + let nuvie_rsync_common_response: nuvei::PaymentDmnNotification = res + .response + .parse_struct("PaymentDmnNotification") + .switch()?; + event_builder.map(|i| i.set_response_body(&nuvie_rsync_common_response)); + router_env::logger::info!(connector_response=?nuvie_rsync_common_response); + let response = NuveiTransactionSyncResponse::from(nuvie_rsync_common_response); + + RouterData::try_from(ResponseRouterData { + response, + data: data.clone(), + http_code: res.status_code, + }) + .change_context(errors::ConnectorError::ResponseHandlingFailed) + } +} #[async_trait::async_trait] impl IncomingWebhook for Nuvei { @@ -1003,8 +1039,19 @@ impl IncomingWebhook for Nuvei { request: &IncomingWebhookRequestDetails<'_>, _connector_webhook_secrets: &api_models::webhooks::ConnectorWebhookSecrets, ) -> CustomResult, errors::ConnectorError> { - let signature = utils::get_header_key_value("advanceResponseChecksum", request.headers)?; - hex::decode(signature).change_context(errors::ConnectorError::WebhookResponseEncodingFailed) + let webhook = get_webhook_object_from_body(request.body)?; + + let nuvei_notification_signature = match webhook { + nuvei::NuveiWebhook::PaymentDmn(notification) => notification + .advance_response_checksum + .ok_or(errors::ConnectorError::WebhookSignatureNotFound)?, + nuvei::NuveiWebhook::Chargeback(_) => { + utils::get_header_key_value("Checksum", request.headers)?.to_string() + } + }; + + hex::decode(nuvei_notification_signature) + .change_context(errors::ConnectorError::WebhookSignatureNotFound) } fn get_webhook_source_verification_message( @@ -1014,9 +1061,7 @@ impl IncomingWebhook for Nuvei { connector_webhook_secrets: &api_models::webhooks::ConnectorWebhookSecrets, ) -> CustomResult, errors::ConnectorError> { // Parse the webhook payload - let webhook = serde_urlencoded::from_str::(&request.query_params) - .change_context(errors::ConnectorError::WebhookBodyDecodingFailed)?; - + let webhook = get_webhook_object_from_body(request.body)?; let secret_str = std::str::from_utf8(&connector_webhook_secrets.secret) .change_context(errors::ConnectorError::WebhookBodyDecodingFailed)?; @@ -1025,38 +1070,29 @@ impl IncomingWebhook for Nuvei { nuvei::NuveiWebhook::PaymentDmn(notification) => { // For payment DMNs, use the same format as before let status = notification - .transaction_status + .status .as_ref() .map(|s| format!("{s:?}").to_uppercase()) - .unwrap_or_else(|| "UNKNOWN".to_string()); + .unwrap_or_default(); let to_sign = transformers::concat_strings(&[ secret_str.to_string(), - notification.total_amount.unwrap_or_default(), - notification.currency.unwrap_or_default(), - notification.response_time_stamp.unwrap_or_default(), - notification.ppp_transaction_id.unwrap_or_default(), + notification.total_amount, + notification.currency, + notification.response_time_stamp, + notification.ppp_transaction_id, status, - notification.product_id.unwrap_or_default(), + notification.product_id.unwrap_or("NA".to_string()), ]); Ok(to_sign.into_bytes()) } nuvei::NuveiWebhook::Chargeback(notification) => { // For chargeback notifications, use a different format based on Nuvei's documentation // Note: This is a placeholder - you'll need to adjust based on Nuvei's actual chargeback signature format - let status = notification - .status - .as_ref() - .map(|s| format!("{s:?}").to_uppercase()) - .unwrap_or_else(|| "UNKNOWN".to_string()); + let response = serde_json::to_string(¬ification) + .change_context(errors::ConnectorError::WebhookBodyDecodingFailed)?; - let to_sign = transformers::concat_strings(&[ - secret_str.to_string(), - notification.chargeback_amount.unwrap_or_default(), - notification.chargeback_currency.unwrap_or_default(), - notification.ppp_transaction_id.unwrap_or_default(), - status, - ]); + let to_sign = format!("{secret_str}{response}"); Ok(to_sign.into_bytes()) } } @@ -1067,22 +1103,45 @@ impl IncomingWebhook for Nuvei { request: &IncomingWebhookRequestDetails<'_>, ) -> CustomResult { // Parse the webhook payload - let webhook = serde_urlencoded::from_str::(&request.query_params) - .change_context(errors::ConnectorError::WebhookBodyDecodingFailed)?; - + let webhook = get_webhook_object_from_body(request.body)?; // Extract transaction ID from the webhook - let transaction_id = match &webhook { - nuvei::NuveiWebhook::PaymentDmn(notification) => { - notification.ppp_transaction_id.clone().unwrap_or_default() - } + match &webhook { + nuvei::NuveiWebhook::PaymentDmn(notification) => match notification.transaction_type { + Some(nuvei::NuveiTransactionType::Auth) + | Some(nuvei::NuveiTransactionType::Sale) + | Some(nuvei::NuveiTransactionType::Settle) + | Some(nuvei::NuveiTransactionType::Void) + | Some(nuvei::NuveiTransactionType::Auth3D) + | Some(nuvei::NuveiTransactionType::InitAuth3D) => { + Ok(api_models::webhooks::ObjectReferenceId::PaymentId( + PaymentIdType::ConnectorTransactionId( + notification + .transaction_id + .clone() + .ok_or(errors::ConnectorError::MissingConnectorTransactionID)?, + ), + )) + } + Some(nuvei::NuveiTransactionType::Credit) => { + Ok(api_models::webhooks::ObjectReferenceId::RefundId( + RefundIdType::ConnectorRefundId( + notification + .transaction_id + .clone() + .ok_or(errors::ConnectorError::MissingConnectorRefundID)?, + ), + )) + } + None => Err(errors::ConnectorError::WebhookEventTypeNotFound.into()), + }, nuvei::NuveiWebhook::Chargeback(notification) => { - notification.ppp_transaction_id.clone().unwrap_or_default() + Ok(api_models::webhooks::ObjectReferenceId::PaymentId( + PaymentIdType::ConnectorTransactionId( + notification.transaction_details.transaction_id.to_string(), + ), + )) } - }; - - Ok(api_models::webhooks::ObjectReferenceId::PaymentId( - PaymentIdType::ConnectorTransactionId(transaction_id), - )) + } } fn get_webhook_event_type( @@ -1090,27 +1149,25 @@ impl IncomingWebhook for Nuvei { request: &IncomingWebhookRequestDetails<'_>, ) -> CustomResult { // Parse the webhook payload - let webhook = serde_urlencoded::from_str::(&request.query_params) - .change_context(errors::ConnectorError::WebhookBodyDecodingFailed)?; + let webhook = get_webhook_object_from_body(request.body)?; // Map webhook type to event type match webhook { nuvei::NuveiWebhook::PaymentDmn(notification) => { - match notification.transaction_status { - Some(nuvei::TransactionStatus::Approved) - | Some(nuvei::TransactionStatus::Settled) => { - Ok(IncomingWebhookEvent::PaymentIntentSuccess) - } - Some(nuvei::TransactionStatus::Declined) - | Some(nuvei::TransactionStatus::Error) => { - Ok(IncomingWebhookEvent::PaymentIntentFailure) - } - _ => Ok(IncomingWebhookEvent::EventNotSupported), + if let Some((status, transaction_type)) = + notification.status.zip(notification.transaction_type) + { + nuvei::map_notification_to_event(status, transaction_type) + } else { + Err(errors::ConnectorError::WebhookEventTypeNotFound.into()) } } - nuvei::NuveiWebhook::Chargeback(_) => { - // Chargeback notifications always map to dispute opened - Ok(IncomingWebhookEvent::DisputeOpened) + nuvei::NuveiWebhook::Chargeback(notification) => { + if let Some(dispute_event) = notification.chargeback.dispute_unified_status_code { + nuvei::map_dispute_notification_to_event(dispute_event) + } else { + Err(errors::ConnectorError::WebhookEventTypeNotFound.into()) + } } } } @@ -1119,12 +1176,72 @@ impl IncomingWebhook for Nuvei { &self, request: &IncomingWebhookRequestDetails<'_>, ) -> CustomResult, errors::ConnectorError> { - // Parse the webhook payload - let webhook = serde_urlencoded::from_str::(&request.query_params) - .change_context(errors::ConnectorError::WebhookBodyDecodingFailed)?; - // Convert webhook to payments response - let payment_response = NuveiPaymentsResponse::from(webhook); - Ok(Box::new(payment_response)) + let notification = get_webhook_object_from_body(request.body)?; + Ok(Box::new(notification)) + } + + fn get_dispute_details( + &self, + request: &IncomingWebhookRequestDetails<'_>, + ) -> CustomResult { + let webhook = request + .body + .parse_struct::("ChargebackNotification") + .change_context(errors::ConnectorError::ResponseDeserializationFailed)?; + let currency = webhook + .chargeback + .reported_currency + .to_uppercase() + .parse::() + .map_err(|_| errors::ConnectorError::ResponseDeserializationFailed)?; + let amount_minorunit = utils::convert_back_amount_to_minor_units( + self.amount_converter_float_major_unit, + webhook.chargeback.reported_amount, + currency, + )?; + + let amount = utils::convert_amount( + self.amount_converter_string_minor_unit, + amount_minorunit, + currency, + )?; + let dispute_unified_status_code = webhook + .chargeback + .dispute_unified_status_code + .ok_or(errors::ConnectorError::WebhookEventTypeNotFound)?; + let connector_dispute_id = webhook + .chargeback + .dispute_id + .ok_or(errors::ConnectorError::WebhookReferenceIdNotFound)?; + + Ok(disputes::DisputePayload { + amount, + currency, + dispute_stage: api_models::enums::DisputeStage::from( + dispute_unified_status_code.clone(), + ), + connector_dispute_id, + connector_reason: webhook.chargeback.chargeback_reason, + connector_reason_code: webhook.chargeback.chargeback_reason_category, + challenge_required_by: webhook.chargeback.dispute_due_date, + connector_status: dispute_unified_status_code.to_string(), + created_at: webhook.chargeback.date, + updated_at: None, + }) + } +} + +fn get_webhook_object_from_body( + body: &[u8], +) -> CustomResult { + let payments_response = serde_urlencoded::from_bytes::(body) + .change_context(errors::ConnectorError::ResponseDeserializationFailed); + + match payments_response { + Ok(webhook) => Ok(webhook), + Err(_) => body + .parse_struct::("NuveiWebhook") + .change_context(errors::ConnectorError::ResponseDeserializationFailed), } } @@ -1325,7 +1442,8 @@ static NUVEI_CONNECTOR_INFO: ConnectorInfo = ConnectorInfo { integration_status: enums::ConnectorIntegrationStatus::Beta, }; -static NUVEI_SUPPORTED_WEBHOOK_FLOWS: [enums::EventClass; 1] = [enums::EventClass::Payments]; +static NUVEI_SUPPORTED_WEBHOOK_FLOWS: [enums::EventClass; 2] = + [enums::EventClass::Payments, enums::EventClass::Disputes]; impl ConnectorSpecifications for Nuvei { fn get_connector_about(&self) -> Option<&'static ConnectorInfo> { diff --git a/crates/hyperswitch_connectors/src/connectors/nuvei/transformers.rs b/crates/hyperswitch_connectors/src/connectors/nuvei/transformers.rs index 4483412424..2d36a70535 100644 --- a/crates/hyperswitch_connectors/src/connectors/nuvei/transformers.rs +++ b/crates/hyperswitch_connectors/src/connectors/nuvei/transformers.rs @@ -8,7 +8,7 @@ use common_utils::{ id_type::CustomerId, pii::{self, Email, IpAddress}, request::Method, - types::{MinorUnit, StringMajorUnit, StringMajorUnitForConnector}, + types::{FloatMajorUnit, MinorUnit, StringMajorUnit, StringMajorUnitForConnector}, }; use error_stack::ResultExt; use hyperswitch_domain_models::{ @@ -925,6 +925,19 @@ pub fn encode_payload( Ok(hex::encode(digest)) } +impl From for NuveiTransactionSyncResponse { + fn from(value: NuveiPaymentSyncResponse) -> Self { + match value { + NuveiPaymentSyncResponse::NuveiDmn(payment_dmn_notification) => { + Self::from(*payment_dmn_notification) + } + NuveiPaymentSyncResponse::NuveiApi(nuvei_transaction_sync_response) => { + *nuvei_transaction_sync_response + } + } + } +} + impl TryFrom<&types::PaymentsAuthorizeSessionTokenRouterData> for NuveiSessionRequest { type Error = error_stack::Report; fn try_from( @@ -1503,7 +1516,7 @@ where card_holder_name: data.card_holder_name, expiration_month: Some(data.card_exp_month), expiration_year: Some(data.card_exp_year), - ..Default::default() // CVV should be disabled by nuvei fo + ..Default::default() // CVV should be disabled by nuvei }), redirect_url: None, user_payment_option_id: None, @@ -2340,6 +2353,7 @@ pub struct NuveiTransactionSyncResponseDetails { gw_error_reason: Option, gw_extended_error_code: Option, transaction_id: Option, + // Status of the payment transaction_status: Option, transaction_type: Option, auth_code: Option, @@ -2357,6 +2371,7 @@ pub struct NuveiTransactionSyncResponse { pub fraud_details: Option, pub client_unique_id: Option, pub internal_request_id: Option, + // API response status pub status: NuveiPaymentStatus, pub err_code: Option, pub reason: Option, @@ -2519,6 +2534,7 @@ struct ErrorResponseParams { gw_error_code: Option, gw_error_reason: Option, transaction_status: Option, + transaction_id: Option, } fn build_error_response(params: ErrorResponseParams) -> Option { @@ -2530,6 +2546,7 @@ fn build_error_response(params: ErrorResponseParams) -> Option { params.merchant_advice_code.clone(), params.gw_error_code.map(|code| code.to_string()), params.gw_error_reason.clone(), + params.transaction_id.clone(), )), _ => { @@ -2540,6 +2557,7 @@ fn build_error_response(params: ErrorResponseParams) -> Option { params.merchant_advice_code, params.gw_error_code.map(|e| e.to_string()), params.gw_error_reason.clone(), + params.transaction_id.clone(), )); match params.transaction_status { @@ -2630,6 +2648,7 @@ impl gw_error_code: response.gw_error_code, gw_error_reason: response.gw_error_reason.clone(), transaction_status: response.transaction_status.clone(), + transaction_id: response.transaction_id.clone(), }) { Err(err) } else { @@ -2852,6 +2871,7 @@ impl gw_error_code: response.gw_error_code, gw_error_reason: response.gw_error_reason.clone(), transaction_status: response.transaction_status.clone(), + transaction_id: response.transaction_id.clone(), }) { Err(err) } else { @@ -2914,6 +2934,7 @@ where gw_error_code: response.gw_error_code, gw_error_reason: response.gw_error_reason.clone(), transaction_status: response.transaction_status.clone(), + transaction_id: response.transaction_id.clone(), }) { Err(err) } else { @@ -2984,6 +3005,9 @@ where transaction_status: transaction_details .as_ref() .and_then(|details| details.transaction_status.clone()), + transaction_id: transaction_details + .as_ref() + .and_then(|details| details.transaction_id.clone()), }) { Err(err) } else { @@ -3075,21 +3099,70 @@ impl TryFrom> } } -impl TryFrom> +impl TryFrom> for types::RefundsRouterData { type Error = error_stack::Report; fn try_from( - item: RefundsResponseRouterData, + item: RefundsResponseRouterData, ) -> Result { - let transaction_id = item + let txn_id = item .response - .transaction_id - .clone() + .transaction_details + .as_ref() + .and_then(|details| details.transaction_id.clone()) .ok_or(errors::ConnectorError::MissingConnectorTransactionID)?; - let refund_response = - get_refund_response(item.response.clone(), item.http_code, transaction_id); + let refund_status = item + .response + .transaction_details + .as_ref() + .and_then(|details| details.transaction_status.clone()) + .map(enums::RefundStatus::from) + .unwrap_or(enums::RefundStatus::Failure); + + let network_decline_code = item + .response + .transaction_details + .as_ref() + .and_then(|details| details.gw_error_code.map(|e| e.to_string())); + + let network_error_msg = item + .response + .transaction_details + .as_ref() + .and_then(|details| details.gw_error_reason.clone()); + + let refund_response = match item.response.status { + NuveiPaymentStatus::Error => Err(Box::new(get_error_response( + item.response.err_code, + item.response.reason.clone(), + item.http_code, + item.response.merchant_advice_code, + network_decline_code, + network_error_msg, + Some(txn_id.clone()), + ))), + _ => match item + .response + .transaction_details + .and_then(|nuvei_response| nuvei_response.transaction_status) + { + Some(NuveiTransactionStatus::Error) => Err(Box::new(get_error_response( + item.response.err_code, + item.response.reason, + item.http_code, + item.response.merchant_advice_code, + network_decline_code, + network_error_msg, + Some(txn_id.clone()), + ))), + _ => Ok(RefundsResponseData { + connector_refund_id: txn_id, + refund_status, + }), + }, + }; Ok(Self { response: refund_response.map_err(|err| *err), @@ -3175,6 +3248,7 @@ fn get_refund_response( response.merchant_advice_code, response.gw_error_code.map(|e| e.to_string()), response.gw_error_reason, + Some(txn_id.clone()), ))), _ => match response.transaction_status { Some(NuveiTransactionStatus::Error) => Err(Box::new(get_error_response( @@ -3184,6 +3258,7 @@ fn get_refund_response( response.merchant_advice_code, response.gw_error_code.map(|e| e.to_string()), response.gw_error_reason, + Some(txn_id.clone()), ))), _ => Ok(RefundsResponseData { connector_refund_id: txn_id, @@ -3200,6 +3275,7 @@ fn get_error_response( network_advice_code: Option, network_decline_code: Option, network_error_message: Option, + transaction_id: Option, ) -> ErrorResponse { ErrorResponse { code: error_code @@ -3211,7 +3287,7 @@ fn get_error_response( reason: None, status_code: http_code, attempt_status: None, - connector_transaction_id: None, + connector_transaction_id: transaction_id, network_advice_code: network_advice_code.clone(), network_decline_code: network_decline_code.clone(), network_error_message: network_error_message.clone(), @@ -3227,6 +3303,14 @@ pub enum NuveiWebhook { Chargeback(ChargebackNotification), } +/// Represents Psync Response from Nuvei. +#[derive(Debug, Serialize, Deserialize)] +#[serde(untagged)] +pub enum NuveiPaymentSyncResponse { + NuveiDmn(Box), + NuveiApi(Box), +} + /// Represents the status of a chargeback event. #[derive(Debug, Serialize, Deserialize, PartialEq, Eq)] pub enum ChargebackStatus { @@ -3243,27 +3327,225 @@ pub enum ChargebackStatus { #[derive(Debug, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ChargebackNotification { - #[serde(rename = "ppp_TransactionID")] - pub ppp_transaction_id: Option, - pub merchant_unique_id: Option, - pub merchant_id: Option, - pub merchant_site_id: Option, - pub request_version: Option, - pub message: Option, - pub status: Option, - pub reason: Option, - pub case_id: Option, - pub processor_case_id: Option, + pub client_id: Option, + pub client_name: Option, + pub event_date_u_t_c: Option, + pub event_correlation_id: Option, + pub chargeback: ChargebackData, + pub transaction_details: ChargebackTransactionDetails, + pub event_id: Option, + pub event_date: Option, + pub processing_entity_type: Option, + pub processing_entity_id: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "PascalCase")] +pub struct ChargebackData { + pub date: Option, + pub chargeback_status_category: ChargebackStatusCategory, + #[serde(rename = "Type")] + pub webhook_type: ChargebackType, + pub status: Option, + pub amount: FloatMajorUnit, + pub currency: String, + pub reported_amount: FloatMajorUnit, + pub reported_currency: String, + pub chargeback_reason: Option, + pub chargeback_reason_category: Option, + pub reason_message: Option, + pub dispute_id: Option, + pub dispute_due_date: Option, + pub dispute_event_id: Option, + pub dispute_unified_status_code: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, strum::Display)] +pub enum DisputeUnifiedStatusCode { + #[serde(rename = "FC")] + FirstChargebackInitiatedByIssuer, + + #[serde(rename = "CC")] + CreditChargebackInitiatedByIssuer, + + #[serde(rename = "CC-A-ACPT")] + CreditChargebackAcceptedAutomatically, + + #[serde(rename = "FC-A-EPRD")] + FirstChargebackNoResponseExpired, + + #[serde(rename = "FC-M-ACPT")] + FirstChargebackAcceptedByMerchant, + + #[serde(rename = "FC-A-ACPT")] + FirstChargebackAcceptedAutomatically, + + #[serde(rename = "FC-A-ACPT-MCOLL")] + FirstChargebackAcceptedAutomaticallyMcoll, + + #[serde(rename = "FC-M-PART")] + FirstChargebackPartiallyAcceptedByMerchant, + + #[serde(rename = "FC-M-PART-EXP")] + FirstChargebackPartiallyAcceptedByMerchantExpired, + + #[serde(rename = "FC-M-RJCT")] + FirstChargebackRejectedByMerchant, + + #[serde(rename = "FC-M-RJCT-EXP")] + FirstChargebackRejectedByMerchantExpired, + + #[serde(rename = "FC-A-RJCT")] + FirstChargebackRejectedAutomatically, + + #[serde(rename = "FC-A-RJCT-EXP")] + FirstChargebackRejectedAutomaticallyExpired, + + #[serde(rename = "IPA")] + PreArbitrationInitiatedByIssuer, + + #[serde(rename = "MPA-I-ACPT")] + MerchantPreArbitrationAcceptedByIssuer, + + #[serde(rename = "MPA-I-RJCT")] + MerchantPreArbitrationRejectedByIssuer, + + #[serde(rename = "MPA-I-PART")] + MerchantPreArbitrationPartiallyAcceptedByIssuer, + + #[serde(rename = "FC-CLSD-MF")] + FirstChargebackClosedMerchantFavour, + + #[serde(rename = "FC-CLSD-CHF")] + FirstChargebackClosedCardholderFavour, + + #[serde(rename = "FC-CLSD-RCL")] + FirstChargebackClosedRecall, + + #[serde(rename = "FC-I-RCL")] + FirstChargebackRecalledByIssuer, + + #[serde(rename = "PA-CLSD-MF")] + PreArbitrationClosedMerchantFavour, + + #[serde(rename = "PA-CLSD-CHF")] + PreArbitrationClosedCardholderFavour, + + #[serde(rename = "RDR")] + Rdr, + + #[serde(rename = "FC-SPCSE")] + FirstChargebackDisputeResponseNotAllowed, + + #[serde(rename = "MCC")] + McCollaborationInitiatedByIssuer, + + #[serde(rename = "MCC-A-RJCT")] + McCollaborationPreviouslyRefundedAuto, + + #[serde(rename = "MCC-M-ACPT")] + McCollaborationRefundedByMerchant, + + #[serde(rename = "MCC-EXPR")] + McCollaborationExpired, + + #[serde(rename = "MCC-M-RJCT")] + McCollaborationRejectedByMerchant, + + #[serde(rename = "MCC-A-ACPT")] + McCollaborationAutomaticAccept, + + #[serde(rename = "MCC-CLSD-MF")] + McCollaborationClosedMerchantFavour, + + #[serde(rename = "MCC-CLSD-CHF")] + McCollaborationClosedCardholderFavour, + + #[serde(rename = "INQ")] + InquiryInitiatedByIssuer, + + #[serde(rename = "INQ-M-RSP")] + InquiryRespondedByMerchant, + + #[serde(rename = "INQ-EXPR")] + InquiryExpired, + + #[serde(rename = "INQ-A-RJCT")] + InquiryAutomaticallyRejected, + + #[serde(rename = "INQ-A-CNLD")] + InquiryCancelledAfterRefund, + + #[serde(rename = "INQ-M-RFND")] + InquiryAcceptedFullRefund, + + #[serde(rename = "INQ-M-P-RFND")] + InquiryPartialAcceptedPartialRefund, + + #[serde(rename = "INQ-UPD")] + InquiryUpdated, + + #[serde(rename = "IPA-M-ACPT")] + PreArbitrationAcceptedByMerchant, + + #[serde(rename = "IPA-M-PART")] + PreArbitrationPartiallyAcceptedByMerchant, + + #[serde(rename = "IPA-M-PART-EXP")] + PreArbitrationPartiallyAcceptedByMerchantExpired, + + #[serde(rename = "IPA-M-RJCT")] + PreArbitrationRejectedByMerchant, + + #[serde(rename = "IPA-M-RJCT-EXP")] + PreArbitrationRejectedByMerchantExpired, + + #[serde(rename = "IPA-A-ACPT")] + PreArbitrationAutomaticallyAcceptedByMerchant, + + #[serde(rename = "PA-CLSD-RC")] + PreArbitrationClosedRecall, + + #[serde(rename = "IPAR-M-ACPT")] + RejectedPreArbAcceptedByMerchant, + + #[serde(rename = "IPAR-A-ACPT")] + RejectedPreArbExpiredAutoAccepted, + + #[serde(rename = "CC-I-RCLL")] + CreditChargebackRecalledByIssuer, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "PascalCase")] +pub struct ChargebackTransactionDetails { + pub transaction_id: i64, + pub transaction_date: Option, + pub client_unique_id: Option, + pub acquirer_name: Option, + pub masked_card_number: Option, pub arn: Option, - pub retrieval_request_date: Option, - pub chargeback_date: Option, - pub chargeback_amount: Option, - pub chargeback_currency: Option, - pub original_amount: Option, - pub original_currency: Option, - #[serde(rename = "transactionID")] - pub transaction_id: Option, - pub user_token_id: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "PascalCase")] +pub enum ChargebackType { + Chargeback, + Retrieval, +} + +#[derive(Debug, Serialize, Deserialize)] +pub enum ChargebackStatusCategory { + #[serde(rename = "Regular")] + Regular, + #[serde(rename = "cancelled")] + Cancelled, + #[serde(rename = "Duplicate")] + Duplicate, + #[serde(rename = "RDR-Refund")] + RdrRefund, + #[serde(rename = "Soft_CB")] + SoftCb, } /// Represents the overall status of the DMN. @@ -3271,8 +3553,19 @@ pub struct ChargebackNotification { #[serde(rename_all = "UPPERCASE")] pub enum DmnStatus { Success, + Approved, Error, Pending, + Declined, +} + +/// Represents the transaction status of the DMN +#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "UPPERCASE")] +pub enum DmnApiTransactionStatus { + Ok, + Fail, + Pending, } /// Represents the status of the transaction itself. @@ -3288,63 +3581,51 @@ pub enum TransactionStatus { Settled, } -/// Represents the type of transaction. -#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "PascalCase")] -pub enum PaymentTransactionType { - Auth, - Sale, - Settle, - Credit, - Void, - Auth3D, - Sale3D, - Verif, -} - /// Represents a Payment Direct Merchant Notification (DMN) webhook. #[derive(Debug, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct PaymentDmnNotification { + // Status of the Api transaction + #[serde(rename = "ppp_status")] + pub ppp_status: DmnApiTransactionStatus, #[serde(rename = "PPP_TransactionID")] - pub ppp_transaction_id: Option, + pub ppp_transaction_id: String, + pub total_amount: String, + pub currency: String, #[serde(rename = "TransactionID")] pub transaction_id: Option, + // Status of the Payment + #[serde(rename = "Status")] pub status: Option, + pub transaction_type: Option, #[serde(rename = "ErrCode")] pub err_code: Option, - #[serde(rename = "ExErrCode")] - pub ex_err_code: Option, - pub desc: Option, - pub merchant_unique_id: Option, - pub custom_data: Option, - pub product_id: Option, - pub first_name: Option, - pub last_name: Option, - pub email: Option, - pub total_amount: Option, - pub currency: Option, - pub fee: Option, - #[serde(rename = "AuthCode")] - pub auth_code: Option, - pub transaction_status: Option, - pub transaction_type: Option, + #[serde(rename = "Reason")] + pub reason: Option, + #[serde(rename = "ReasonCode")] + pub reason_code: Option, #[serde(rename = "user_token_id")] pub user_token_id: Option, #[serde(rename = "payment_method")] pub payment_method: Option, #[serde(rename = "responseTimeStamp")] - pub response_time_stamp: Option, + pub response_time_stamp: String, #[serde(rename = "invoice_id")] pub invoice_id: Option, #[serde(rename = "merchant_id")] - pub merchant_id: Option, + pub merchant_id: Option>, #[serde(rename = "merchant_site_id")] - pub merchant_site_id: Option, + pub merchant_site_id: Option>, #[serde(rename = "responsechecksum")] pub response_checksum: Option, #[serde(rename = "advanceResponseChecksum")] pub advance_response_checksum: Option, + pub product_id: Option, + pub merchant_advice_code: Option, + #[serde(rename = "AuthCode")] + pub auth_code: Option, + pub acquirer_bank: Option, + pub client_request_id: Option, } // For backward compatibility with existing code @@ -3354,56 +3635,42 @@ pub struct NuveiWebhookTransactionId { pub ppp_transaction_id: String, } -// Helper struct to extract transaction ID from either webhook type -impl From<&NuveiWebhook> for NuveiWebhookTransactionId { - fn from(webhook: &NuveiWebhook) -> Self { - match webhook { - NuveiWebhook::Chargeback(notification) => Self { - ppp_transaction_id: notification.ppp_transaction_id.clone().unwrap_or_default(), - }, - NuveiWebhook::PaymentDmn(notification) => Self { - ppp_transaction_id: notification.ppp_transaction_id.clone().unwrap_or_default(), - }, - } - } -} - // Convert webhook to payments response for further processing -impl From for NuveiPaymentsResponse { - fn from(webhook: NuveiWebhook) -> Self { - match webhook { - NuveiWebhook::Chargeback(notification) => Self { - transaction_status: Some(NuveiTransactionStatus::Processing), - transaction_id: notification.transaction_id, - transaction_type: Some(NuveiTransactionType::Credit), // Using Credit as placeholder for chargeback - ..Default::default() +impl From for NuveiTransactionSyncResponse { + fn from(notification: PaymentDmnNotification) -> Self { + Self { + status: match notification.ppp_status { + DmnApiTransactionStatus::Ok => NuveiPaymentStatus::Success, + DmnApiTransactionStatus::Fail => NuveiPaymentStatus::Failed, + DmnApiTransactionStatus::Pending => NuveiPaymentStatus::Processing, }, - NuveiWebhook::PaymentDmn(notification) => { - let transaction_type = notification.transaction_type.map(|tt| match tt { - PaymentTransactionType::Auth => NuveiTransactionType::Auth, - PaymentTransactionType::Sale => NuveiTransactionType::Sale, - PaymentTransactionType::Settle => NuveiTransactionType::Settle, - PaymentTransactionType::Credit => NuveiTransactionType::Credit, - PaymentTransactionType::Void => NuveiTransactionType::Void, - PaymentTransactionType::Auth3D => NuveiTransactionType::Auth3D, - PaymentTransactionType::Sale3D => NuveiTransactionType::Auth3D, // Map to closest equivalent - PaymentTransactionType::Verif => NuveiTransactionType::Auth, // Map to closest equivalent - }); - - Self { - transaction_status: notification.transaction_status.map(|ts| match ts { - TransactionStatus::Approved => NuveiTransactionStatus::Approved, - TransactionStatus::Declined => NuveiTransactionStatus::Declined, - TransactionStatus::Error => NuveiTransactionStatus::Error, - TransactionStatus::Settled => NuveiTransactionStatus::Approved, - - _ => NuveiTransactionStatus::Processing, - }), - transaction_id: notification.transaction_id, - transaction_type, - ..Default::default() - } - } + err_code: notification + .err_code + .and_then(|code| code.parse::().ok()), + reason: notification.reason.clone(), + transaction_details: Some(NuveiTransactionSyncResponseDetails { + gw_error_code: notification + .reason_code + .and_then(|code| code.parse::().ok()), + gw_error_reason: notification.reason.clone(), + gw_extended_error_code: None, + transaction_id: notification.transaction_id, + transaction_status: notification.status.map(|ts| match ts { + DmnStatus::Success | DmnStatus::Approved => NuveiTransactionStatus::Approved, + DmnStatus::Declined => NuveiTransactionStatus::Declined, + DmnStatus::Pending => NuveiTransactionStatus::Pending, + DmnStatus::Error => NuveiTransactionStatus::Error, + }), + transaction_type: notification.transaction_type, + auth_code: notification.auth_code, + processed_amount: None, + processed_currency: None, + acquiring_bank_name: notification.acquirer_bank, + }), + merchant_id: notification.merchant_id, + merchant_site_id: notification.merchant_site_id, + merchant_advice_code: notification.merchant_advice_code, + ..Default::default() } } } @@ -3479,3 +3746,188 @@ fn convert_to_additional_payment_method_connector_response( Err(_) => None, } } + +pub fn map_notification_to_event( + status: DmnStatus, + transaction_type: NuveiTransactionType, +) -> Result> +{ + match (status, transaction_type) { + (DmnStatus::Success | DmnStatus::Approved, NuveiTransactionType::Auth) => { + Ok(api_models::webhooks::IncomingWebhookEvent::PaymentIntentAuthorizationSuccess) + } + (DmnStatus::Success | DmnStatus::Approved, NuveiTransactionType::Sale) => { + Ok(api_models::webhooks::IncomingWebhookEvent::PaymentIntentSuccess) + } + (DmnStatus::Success | DmnStatus::Approved, NuveiTransactionType::Settle) => { + Ok(api_models::webhooks::IncomingWebhookEvent::PaymentIntentCaptureSuccess) + } + (DmnStatus::Success | DmnStatus::Approved, NuveiTransactionType::Void) => { + Ok(api_models::webhooks::IncomingWebhookEvent::PaymentIntentCancelled) + } + (DmnStatus::Success | DmnStatus::Approved, NuveiTransactionType::Credit) => { + Ok(api_models::webhooks::IncomingWebhookEvent::RefundSuccess) + } + (DmnStatus::Error | DmnStatus::Declined, NuveiTransactionType::Auth) => { + Ok(api_models::webhooks::IncomingWebhookEvent::PaymentIntentAuthorizationFailure) + } + (DmnStatus::Error | DmnStatus::Declined, NuveiTransactionType::Sale) => { + Ok(api_models::webhooks::IncomingWebhookEvent::PaymentIntentFailure) + } + (DmnStatus::Error | DmnStatus::Declined, NuveiTransactionType::Settle) => { + Ok(api_models::webhooks::IncomingWebhookEvent::PaymentIntentCaptureFailure) + } + (DmnStatus::Error | DmnStatus::Declined, NuveiTransactionType::Void) => { + Ok(api_models::webhooks::IncomingWebhookEvent::PaymentIntentCancelFailure) + } + (DmnStatus::Error | DmnStatus::Declined, NuveiTransactionType::Credit) => { + Ok(api_models::webhooks::IncomingWebhookEvent::RefundFailure) + } + ( + DmnStatus::Pending, + NuveiTransactionType::Auth | NuveiTransactionType::Sale | NuveiTransactionType::Settle, + ) => Ok(api_models::webhooks::IncomingWebhookEvent::PaymentIntentProcessing), + _ => Err(errors::ConnectorError::WebhookEventTypeNotFound.into()), + } +} + +pub fn map_dispute_notification_to_event( + dispute_code: DisputeUnifiedStatusCode, +) -> Result> +{ + match dispute_code { + DisputeUnifiedStatusCode::FirstChargebackInitiatedByIssuer + | DisputeUnifiedStatusCode::CreditChargebackInitiatedByIssuer + | DisputeUnifiedStatusCode::McCollaborationInitiatedByIssuer + | DisputeUnifiedStatusCode::FirstChargebackClosedRecall + | DisputeUnifiedStatusCode::InquiryInitiatedByIssuer => { + Ok(api_models::webhooks::IncomingWebhookEvent::DisputeOpened) + } + DisputeUnifiedStatusCode::CreditChargebackAcceptedAutomatically + | DisputeUnifiedStatusCode::FirstChargebackAcceptedAutomatically + | DisputeUnifiedStatusCode::FirstChargebackAcceptedAutomaticallyMcoll + | DisputeUnifiedStatusCode::FirstChargebackAcceptedByMerchant + | DisputeUnifiedStatusCode::FirstChargebackDisputeResponseNotAllowed + | DisputeUnifiedStatusCode::Rdr + | DisputeUnifiedStatusCode::McCollaborationRefundedByMerchant + | DisputeUnifiedStatusCode::McCollaborationAutomaticAccept + | DisputeUnifiedStatusCode::InquiryAcceptedFullRefund + | DisputeUnifiedStatusCode::PreArbitrationAcceptedByMerchant + | DisputeUnifiedStatusCode::PreArbitrationPartiallyAcceptedByMerchant + | DisputeUnifiedStatusCode::PreArbitrationAutomaticallyAcceptedByMerchant + | DisputeUnifiedStatusCode::RejectedPreArbAcceptedByMerchant + | DisputeUnifiedStatusCode::RejectedPreArbExpiredAutoAccepted => { + Ok(api_models::webhooks::IncomingWebhookEvent::DisputeAccepted) + } + DisputeUnifiedStatusCode::FirstChargebackNoResponseExpired + | DisputeUnifiedStatusCode::FirstChargebackPartiallyAcceptedByMerchant + | DisputeUnifiedStatusCode::FirstChargebackClosedCardholderFavour + | DisputeUnifiedStatusCode::PreArbitrationClosedCardholderFavour + | DisputeUnifiedStatusCode::McCollaborationClosedCardholderFavour => { + Ok(api_models::webhooks::IncomingWebhookEvent::DisputeLost) + } + DisputeUnifiedStatusCode::FirstChargebackRejectedByMerchant + | DisputeUnifiedStatusCode::FirstChargebackRejectedAutomatically + | DisputeUnifiedStatusCode::PreArbitrationInitiatedByIssuer + | DisputeUnifiedStatusCode::MerchantPreArbitrationRejectedByIssuer + | DisputeUnifiedStatusCode::InquiryRespondedByMerchant + | DisputeUnifiedStatusCode::PreArbitrationRejectedByMerchant => { + Ok(api_models::webhooks::IncomingWebhookEvent::DisputeChallenged) + } + DisputeUnifiedStatusCode::FirstChargebackRejectedAutomaticallyExpired + | DisputeUnifiedStatusCode::FirstChargebackPartiallyAcceptedByMerchantExpired + | DisputeUnifiedStatusCode::FirstChargebackRejectedByMerchantExpired + | DisputeUnifiedStatusCode::McCollaborationExpired + | DisputeUnifiedStatusCode::InquiryExpired + | DisputeUnifiedStatusCode::PreArbitrationPartiallyAcceptedByMerchantExpired + | DisputeUnifiedStatusCode::PreArbitrationRejectedByMerchantExpired => { + Ok(api_models::webhooks::IncomingWebhookEvent::DisputeExpired) + } + DisputeUnifiedStatusCode::MerchantPreArbitrationAcceptedByIssuer + | DisputeUnifiedStatusCode::MerchantPreArbitrationPartiallyAcceptedByIssuer + | DisputeUnifiedStatusCode::FirstChargebackClosedMerchantFavour + | DisputeUnifiedStatusCode::McCollaborationClosedMerchantFavour + | DisputeUnifiedStatusCode::PreArbitrationClosedMerchantFavour => { + Ok(api_models::webhooks::IncomingWebhookEvent::DisputeWon) + } + DisputeUnifiedStatusCode::FirstChargebackRecalledByIssuer + | DisputeUnifiedStatusCode::InquiryCancelledAfterRefund + | DisputeUnifiedStatusCode::PreArbitrationClosedRecall + | DisputeUnifiedStatusCode::CreditChargebackRecalledByIssuer => { + Ok(api_models::webhooks::IncomingWebhookEvent::DisputeCancelled) + } + + DisputeUnifiedStatusCode::McCollaborationPreviouslyRefundedAuto + | DisputeUnifiedStatusCode::McCollaborationRejectedByMerchant + | DisputeUnifiedStatusCode::InquiryAutomaticallyRejected + | DisputeUnifiedStatusCode::InquiryPartialAcceptedPartialRefund + | DisputeUnifiedStatusCode::InquiryUpdated => { + Err(errors::ConnectorError::WebhookEventTypeNotFound.into()) + } + } +} + +impl From for common_enums::DisputeStage { + fn from(code: DisputeUnifiedStatusCode) -> Self { + match code { + // --- PreDispute --- + DisputeUnifiedStatusCode::Rdr + | DisputeUnifiedStatusCode::InquiryInitiatedByIssuer + | DisputeUnifiedStatusCode::InquiryRespondedByMerchant + | DisputeUnifiedStatusCode::InquiryExpired + | DisputeUnifiedStatusCode::InquiryAutomaticallyRejected + | DisputeUnifiedStatusCode::InquiryCancelledAfterRefund + | DisputeUnifiedStatusCode::InquiryAcceptedFullRefund + | DisputeUnifiedStatusCode::InquiryPartialAcceptedPartialRefund + | DisputeUnifiedStatusCode::InquiryUpdated => Self::PreDispute, + + // --- Dispute --- + DisputeUnifiedStatusCode::FirstChargebackInitiatedByIssuer + | DisputeUnifiedStatusCode::CreditChargebackInitiatedByIssuer + | DisputeUnifiedStatusCode::FirstChargebackNoResponseExpired + | DisputeUnifiedStatusCode::FirstChargebackAcceptedByMerchant + | DisputeUnifiedStatusCode::FirstChargebackAcceptedAutomatically + | DisputeUnifiedStatusCode::FirstChargebackAcceptedAutomaticallyMcoll + | DisputeUnifiedStatusCode::FirstChargebackPartiallyAcceptedByMerchant + | DisputeUnifiedStatusCode::FirstChargebackPartiallyAcceptedByMerchantExpired + | DisputeUnifiedStatusCode::FirstChargebackRejectedByMerchant + | DisputeUnifiedStatusCode::FirstChargebackRejectedByMerchantExpired + | DisputeUnifiedStatusCode::FirstChargebackRejectedAutomatically + | DisputeUnifiedStatusCode::FirstChargebackRejectedAutomaticallyExpired + | DisputeUnifiedStatusCode::FirstChargebackClosedMerchantFavour + | DisputeUnifiedStatusCode::FirstChargebackClosedCardholderFavour + | DisputeUnifiedStatusCode::FirstChargebackClosedRecall + | DisputeUnifiedStatusCode::FirstChargebackRecalledByIssuer + | DisputeUnifiedStatusCode::FirstChargebackDisputeResponseNotAllowed + | DisputeUnifiedStatusCode::McCollaborationInitiatedByIssuer + | DisputeUnifiedStatusCode::McCollaborationPreviouslyRefundedAuto + | DisputeUnifiedStatusCode::McCollaborationRefundedByMerchant + | DisputeUnifiedStatusCode::McCollaborationExpired + | DisputeUnifiedStatusCode::McCollaborationRejectedByMerchant + | DisputeUnifiedStatusCode::McCollaborationAutomaticAccept + | DisputeUnifiedStatusCode::McCollaborationClosedMerchantFavour + | DisputeUnifiedStatusCode::McCollaborationClosedCardholderFavour + | DisputeUnifiedStatusCode::CreditChargebackAcceptedAutomatically => Self::Dispute, + + // --- PreArbitration --- + DisputeUnifiedStatusCode::PreArbitrationInitiatedByIssuer + | DisputeUnifiedStatusCode::MerchantPreArbitrationAcceptedByIssuer + | DisputeUnifiedStatusCode::MerchantPreArbitrationRejectedByIssuer + | DisputeUnifiedStatusCode::MerchantPreArbitrationPartiallyAcceptedByIssuer + | DisputeUnifiedStatusCode::PreArbitrationClosedMerchantFavour + | DisputeUnifiedStatusCode::PreArbitrationClosedCardholderFavour + | DisputeUnifiedStatusCode::PreArbitrationAcceptedByMerchant + | DisputeUnifiedStatusCode::PreArbitrationPartiallyAcceptedByMerchant + | DisputeUnifiedStatusCode::PreArbitrationPartiallyAcceptedByMerchantExpired + | DisputeUnifiedStatusCode::PreArbitrationRejectedByMerchant + | DisputeUnifiedStatusCode::PreArbitrationRejectedByMerchantExpired + | DisputeUnifiedStatusCode::PreArbitrationAutomaticallyAcceptedByMerchant + | DisputeUnifiedStatusCode::PreArbitrationClosedRecall + | DisputeUnifiedStatusCode::RejectedPreArbAcceptedByMerchant + | DisputeUnifiedStatusCode::RejectedPreArbExpiredAutoAccepted => Self::PreArbitration, + + // --- DisputeReversal --- + DisputeUnifiedStatusCode::CreditChargebackRecalledByIssuer => Self::DisputeReversal, + } + } +}