diff --git a/api-reference/v1/openapi_spec_v1.json b/api-reference/v1/openapi_spec_v1.json index b3d22d2759..9aa9e190d6 100644 --- a/api-reference/v1/openapi_spec_v1.json +++ b/api-reference/v1/openapi_spec_v1.json @@ -4140,6 +4140,16 @@ "schema": { "type": "string" } + }, + { + "name": "force_sync", + "in": "query", + "description": "Decider to enable or disable the connector call for dispute retrieve request", + "required": false, + "schema": { + "type": "boolean", + "nullable": true + } } ], "responses": { @@ -14348,7 +14358,9 @@ "enum": [ "pre_dispute", "dispute", - "pre_arbitration" + "pre_arbitration", + "arbitration", + "dispute_reversal" ] }, "DisputeStatus": { @@ -28116,6 +28128,13 @@ } ], "nullable": true + }, + "dispute_polling_interval": { + "type": "integer", + "format": "int32", + "description": "Time interval (in hours) for polling the connector to check dispute statuses", + "example": 2, + "nullable": true } }, "additionalProperties": false @@ -28434,6 +28453,13 @@ } ], "nullable": true + }, + "dispute_polling_interval": { + "type": "integer", + "format": "int32", + "example": 2, + "nullable": true, + "minimum": 0 } } }, diff --git a/api-reference/v2/openapi_spec_v2.json b/api-reference/v2/openapi_spec_v2.json index 8753e8e7ba..4487baf27d 100644 --- a/api-reference/v2/openapi_spec_v2.json +++ b/api-reference/v2/openapi_spec_v2.json @@ -10404,7 +10404,9 @@ "enum": [ "pre_dispute", "dispute", - "pre_arbitration" + "pre_arbitration", + "arbitration", + "dispute_reversal" ] }, "DisputeStatus": { diff --git a/config/config.example.toml b/config/config.example.toml index 5b414d7eea..cdcbd43dcb 100644 --- a/config/config.example.toml +++ b/config/config.example.toml @@ -316,6 +316,7 @@ worldline.base_url = "https://eu.sandbox.api-ingenico.com/" worldpay.base_url = "https://try.access.worldpay.com/" worldpayvantiv.base_url = "https://transact.vantivprelive.com/vap/communicator/online" worldpayvantiv.secondary_base_url = "https://onlinessr.vantivprelive.com" +worldpayvantiv.third_base_url = "https://services.vantivprelive.com" worldpayxml.base_url = "https://secure-test.worldpay.com/jsp/merchant/xml/paymentService.jsp" xendit.base_url = "https://api.xendit.co" zsl.base_url = "https://api.sitoffalb.net/" @@ -1177,4 +1178,6 @@ enabled = false # Enable or disable chat features hyperswitch_ai_host = "http://0.0.0.0:8000" # Hyperswitch ai workflow host [proxy_status_mapping] -proxy_connector_http_status_code = false # If enabled, the http status code of the connector will be proxied in the response \ No newline at end of file +proxy_connector_http_status_code = false # If enabled, the http status code of the connector will be proxied in the response +[list_dispute_supported_connectors] +connector_list = "worldpayvantiv" \ No newline at end of file diff --git a/config/deployments/integration_test.toml b/config/deployments/integration_test.toml index 6660a1cbbe..7ba1006192 100644 --- a/config/deployments/integration_test.toml +++ b/config/deployments/integration_test.toml @@ -151,6 +151,7 @@ worldline.base_url = "https://eu.sandbox.api-ingenico.com/" worldpay.base_url = "https://try.access.worldpay.com/" worldpayvantiv.base_url = "https://transact.vantivprelive.com/vap/communicator/online" worldpayvantiv.secondary_base_url = "https://onlinessr.vantivprelive.com" +worldpayvantiv.third_base_url = "https://services.vantivprelive.com" worldpayxml.base_url = "https://secure-test.worldpay.com/jsp/merchant/xml/paymentService.jsp" xendit.base_url = "https://api.xendit.co" zen.base_url = "https://api.zen-test.com/" @@ -824,3 +825,6 @@ retry_algorithm_type = "cascading" [authentication_providers] click_to_pay = {connector_list = "adyen, cybersource, trustpay"} + +[list_dispute_supported_connectors] +connector_list = "worldpayvantiv" \ No newline at end of file diff --git a/config/deployments/production.toml b/config/deployments/production.toml index 78f6e2c067..1bdd2c37f9 100644 --- a/config/deployments/production.toml +++ b/config/deployments/production.toml @@ -155,6 +155,7 @@ worldline.base_url = "https://eu.api-ingenico.com/" worldpay.base_url = "https://access.worldpay.com/" worldpayvantiv.base_url = "https://transact.vantivcnp.com/vap/communicator/online" worldpayvantiv.secondary_base_url = "https://onlinessr.vantivcnp.com" +worldpayvantiv.third_base_url = "https://services.vantivprelive.com" # pre-live environment worldpayxml.base_url = "https://secure.worldpay.com/jsp/merchant/xml/paymentService.jsp" xendit.base_url = "https://api.xendit.co" zen.base_url = "https://api.zen.com/" @@ -837,3 +838,6 @@ click_to_pay = {connector_list = "adyen, cybersource, trustpay"} [revenue_recovery] monitoring_threshold_in_seconds = 2592000 retry_algorithm_type = "cascading" + +[list_dispute_supported_connectors] +connector_list = "worldpayvantiv" \ No newline at end of file diff --git a/config/deployments/sandbox.toml b/config/deployments/sandbox.toml index f5155be4e2..04797608af 100644 --- a/config/deployments/sandbox.toml +++ b/config/deployments/sandbox.toml @@ -155,6 +155,7 @@ worldline.base_url = "https://eu.sandbox.api-ingenico.com/" worldpay.base_url = "https://try.access.worldpay.com/" worldpayvantiv.base_url = "https://transact.vantivprelive.com/vap/communicator/online" worldpayvantiv.secondary_base_url = "https://onlinessr.vantivprelive.com" +worldpayvantiv.third_base_url = "https://services.vantivprelive.com" worldpayxml.base_url = "https://secure-test.worldpay.com/jsp/merchant/xml/paymentService.jsp" xendit.base_url = "https://api.xendit.co" zen.base_url = "https://api.zen-test.com/" @@ -842,3 +843,6 @@ click_to_pay = {connector_list = "adyen, cybersource, trustpay"} [revenue_recovery] monitoring_threshold_in_seconds = 2592000 retry_algorithm_type = "cascading" + +[list_dispute_supported_connectors] +connector_list = "worldpayvantiv" \ No newline at end of file diff --git a/config/development.toml b/config/development.toml index 257e857c2f..9105b448e1 100644 --- a/config/development.toml +++ b/config/development.toml @@ -346,6 +346,7 @@ worldline.base_url = "https://eu.sandbox.api-ingenico.com/" worldpay.base_url = "https://try.access.worldpay.com/" worldpayvantiv.base_url = "https://transact.vantivprelive.com/vap/communicator/online" worldpayvantiv.secondary_base_url = "https://onlinessr.vantivprelive.com" +worldpayvantiv.third_base_url = "https://services.vantivprelive.com" worldpayxml.base_url = "https://secure-test.worldpay.com/jsp/merchant/xml/paymentService.jsp" xendit.base_url = "https://api.xendit.co" trustpay.base_url = "https://test-tpgw.trustpay.eu/" @@ -1279,4 +1280,6 @@ enabled = false hyperswitch_ai_host = "http://0.0.0.0:8000" [proxy_status_mapping] -proxy_connector_http_status_code = false # If enabled, the http status code of the connector will be proxied in the response \ No newline at end of file +proxy_connector_http_status_code = false # If enabled, the http status code of the connector will be proxied in the response +[list_dispute_supported_connectors] +connector_list = "worldpayvantiv" \ No newline at end of file diff --git a/config/docker_compose.toml b/config/docker_compose.toml index d8b310d241..ffb30192bc 100644 --- a/config/docker_compose.toml +++ b/config/docker_compose.toml @@ -242,6 +242,7 @@ worldline.base_url = "https://eu.sandbox.api-ingenico.com/" worldpay.base_url = "https://try.access.worldpay.com/" worldpayvantiv.base_url = "https://transact.vantivprelive.com/vap/communicator/online" worldpayvantiv.secondary_base_url = "https://onlinessr.vantivprelive.com" +worldpayvantiv.third_base_url = "https://services.vantivprelive.com" worldpayxml.base_url = "https://secure-test.worldpay.com/jsp/merchant/xml/paymentService.jsp" xendit.base_url = "https://api.xendit.co" zen.base_url = "https://api.zen-test.com/" @@ -1167,4 +1168,6 @@ cluster = "CLUSTER" # value of CLUSTER from deployment version = "HOSTNAME" # value of HOSTNAME from deployment which tells its version [proxy_status_mapping] -proxy_connector_http_status_code = false # If enabled, the http status code of the connector will be proxied in the response \ No newline at end of file +proxy_connector_http_status_code = false # If enabled, the http status code of the connector will be proxied in the response +[list_dispute_supported_connectors] +connector_list = "worldpayvantiv" \ No newline at end of file diff --git a/crates/api_models/src/admin.rs b/crates/api_models/src/admin.rs index 22c1868f6a..f203570636 100644 --- a/crates/api_models/src/admin.rs +++ b/crates/api_models/src/admin.rs @@ -2183,6 +2183,10 @@ pub struct ProfileCreate { /// It is used in payment processing, fraud detection, and regulatory compliance to determine regional rules and routing behavior. #[schema(value_type = Option, example = "840")] pub merchant_country_code: Option, + + /// Time interval (in hours) for polling the connector to check dispute statuses + #[schema(value_type = Option, example = 2)] + pub dispute_polling_interval: Option, } #[nutype::nutype( @@ -2518,6 +2522,9 @@ pub struct ProfileResponse { /// It is used in payment processing, fraud detection, and regulatory compliance to determine regional rules and routing behavior. #[schema(value_type = Option, example = "840")] pub merchant_country_code: Option, + + #[schema(value_type = Option, example = 2)] + pub dispute_polling_interval: Option, } #[cfg(feature = "v2")] @@ -2846,6 +2853,9 @@ pub struct ProfileUpdate { /// It is used in payment processing, fraud detection, and regulatory compliance to determine regional rules and routing behavior. #[schema(value_type = Option, example = "840")] pub merchant_country_code: Option, + + #[schema(value_type = Option, example = 2)] + pub dispute_polling_interval: Option, } #[cfg(feature = "v2")] diff --git a/crates/api_models/src/disputes.rs b/crates/api_models/src/disputes.rs index 55d02c872c..7844b2ff6a 100644 --- a/crates/api_models/src/disputes.rs +++ b/crates/api_models/src/disputes.rs @@ -224,12 +224,26 @@ pub struct DeleteEvidenceRequest { pub evidence_type: EvidenceType, } +#[derive(Debug, Deserialize, Serialize)] +pub struct DisputeRetrieveRequest { + /// The identifier for dispute + pub dispute_id: String, + /// Decider to enable or disable the connector call for dispute retrieve request + pub force_sync: Option, +} + #[derive(Clone, Debug, serde::Serialize)] pub struct DisputesAggregateResponse { /// Different status of disputes with their count pub status_with_count: HashMap, } +#[derive(Debug, Deserialize, Serialize)] +pub struct DisputeRetrieveBody { + /// Decider to enable or disable the connector call for dispute retrieve request + pub force_sync: Option, +} + fn parse_comma_separated<'de, D, T>(v: D) -> Result>, D::Error> where D: serde::Deserializer<'de>, diff --git a/crates/api_models/src/events/dispute.rs b/crates/api_models/src/events/dispute.rs index 57f91330cc..1b23029508 100644 --- a/crates/api_models/src/events/dispute.rs +++ b/crates/api_models/src/events/dispute.rs @@ -2,7 +2,7 @@ use common_utils::events::{ApiEventMetric, ApiEventsType}; use super::{ DeleteEvidenceRequest, DisputeResponse, DisputeResponsePaymentsRetrieve, - DisputesAggregateResponse, SubmitEvidenceRequest, + DisputeRetrieveRequest, DisputesAggregateResponse, SubmitEvidenceRequest, }; impl ApiEventMetric for SubmitEvidenceRequest { @@ -12,6 +12,15 @@ impl ApiEventMetric for SubmitEvidenceRequest { }) } } + +impl ApiEventMetric for DisputeRetrieveRequest { + fn get_api_event_type(&self) -> Option { + Some(ApiEventsType::Dispute { + dispute_id: self.dispute_id.clone(), + }) + } +} + impl ApiEventMetric for DisputeResponsePaymentsRetrieve { fn get_api_event_type(&self) -> Option { Some(ApiEventsType::Dispute { diff --git a/crates/api_models/src/files.rs b/crates/api_models/src/files.rs index b8cb8bd3d9..dde0f08e12 100644 --- a/crates/api_models/src/files.rs +++ b/crates/api_models/src/files.rs @@ -19,3 +19,9 @@ pub struct FileMetadataResponse { /// File availability pub available: bool, } + +#[derive(Clone, Debug, serde::Deserialize, serde::Serialize)] +pub struct FileRetrieveQuery { + ///Dispute Id + pub dispute_id: Option, +} diff --git a/crates/api_models/src/webhooks.rs b/crates/api_models/src/webhooks.rs index fcab9461a0..a01ca3eeee 100644 --- a/crates/api_models/src/webhooks.rs +++ b/crates/api_models/src/webhooks.rs @@ -242,32 +242,32 @@ impl From for WebhookFlow { pub type MerchantWebhookConfig = std::collections::HashSet; -#[derive(Clone)] +#[derive(serde::Serialize, serde::Deserialize, Clone, Debug)] pub enum RefundIdType { RefundId(String), ConnectorRefundId(String), } -#[derive(Clone)] +#[derive(serde::Serialize, serde::Deserialize, Clone, Debug)] pub enum MandateIdType { MandateId(String), ConnectorMandateId(String), } -#[derive(Clone)] +#[derive(serde::Serialize, serde::Deserialize, Clone, Debug)] pub enum AuthenticationIdType { AuthenticationId(common_utils::id_type::AuthenticationId), ConnectorAuthenticationId(String), } #[cfg(feature = "payouts")] -#[derive(Clone)] +#[derive(serde::Serialize, serde::Deserialize, Clone, Debug)] pub enum PayoutIdType { PayoutAttemptId(String), ConnectorPayoutId(String), } -#[derive(Clone)] +#[derive(serde::Serialize, serde::Deserialize, Clone, Debug)] pub enum ObjectReferenceId { PaymentId(payments::PaymentIdType), RefundId(RefundIdType), @@ -280,7 +280,7 @@ pub enum ObjectReferenceId { } #[cfg(all(feature = "revenue_recovery", feature = "v2"))] -#[derive(Clone)] +#[derive(serde::Serialize, serde::Deserialize, Clone, Debug)] pub enum InvoiceIdType { ConnectorInvoiceId(String), } diff --git a/crates/common_enums/src/connector_enums.rs b/crates/common_enums/src/connector_enums.rs index d1bd11bb42..d59093ee28 100644 --- a/crates/common_enums/src/connector_enums.rs +++ b/crates/common_enums/src/connector_enums.rs @@ -401,7 +401,7 @@ impl Connector { matches!((self, payment_method), (Self::Razorpay, PaymentMethod::Upi)) } pub fn supports_file_storage_module(self) -> bool { - matches!(self, Self::Stripe | Self::Checkout) + matches!(self, Self::Stripe | Self::Checkout | Self::Worldpayvantiv) } pub fn requires_defend_dispute(self) -> bool { matches!(self, Self::Checkout) diff --git a/crates/common_enums/src/enums.rs b/crates/common_enums/src/enums.rs index 7443fbac93..1536d5665b 100644 --- a/crates/common_enums/src/enums.rs +++ b/crates/common_enums/src/enums.rs @@ -2627,6 +2627,8 @@ pub enum DisputeStage { #[default] Dispute, PreArbitration, + Arbitration, + DisputeReversal, } /// Status of the dispute @@ -3125,6 +3127,7 @@ pub enum FileUploadProvider { Router, Stripe, Checkout, + Worldpayvantiv, } #[derive( @@ -8620,6 +8623,8 @@ pub enum ProcessTrackerRunner { AttachPayoutAccountWorkflow, PaymentMethodStatusUpdateWorkflow, PassiveRecoveryWorkflow, + ProcessDisputeWorkflow, + DisputeListWorkflow, } #[derive(Debug)] diff --git a/crates/common_types/src/consts.rs b/crates/common_types/src/consts.rs index 1ceb999908..2e622307d7 100644 --- a/crates/common_types/src/consts.rs +++ b/crates/common_types/src/consts.rs @@ -8,6 +8,12 @@ pub const API_VERSION: common_enums::ApiVersion = common_enums::ApiVersion::V1; #[cfg(feature = "v2")] pub const API_VERSION: common_enums::ApiVersion = common_enums::ApiVersion::V2; +/// Maximum Dispute Polling Interval In Hours +pub const MAX_DISPUTE_POLLING_INTERVAL_IN_HOURS: i32 = 24; + +///Default Dispute Polling Interval In Hours +pub const DEFAULT_DISPUTE_POLLING_INTERVAL_IN_HOURS: i32 = 24; + /// Default payment intent statuses that trigger a webhook pub const DEFAULT_PAYMENT_WEBHOOK_TRIGGER_STATUSES: &[common_enums::IntentStatus] = &[ common_enums::IntentStatus::Succeeded, diff --git a/crates/common_types/src/primitive_wrappers.rs b/crates/common_types/src/primitive_wrappers.rs index a3eee31660..1c1952db51 100644 --- a/crates/common_types/src/primitive_wrappers.rs +++ b/crates/common_types/src/primitive_wrappers.rs @@ -1,5 +1,5 @@ pub use bool_wrappers::*; - +pub use u32_wrappers::*; mod bool_wrappers { use std::ops::Deref; @@ -171,3 +171,73 @@ mod bool_wrappers { } } } + +mod u32_wrappers { + use std::ops::Deref; + + use serde::{de::Error, Deserialize, Serialize}; + + use crate::consts::{ + DEFAULT_DISPUTE_POLLING_INTERVAL_IN_HOURS, MAX_DISPUTE_POLLING_INTERVAL_IN_HOURS, + }; + /// Time interval in hours for polling disputes + #[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, diesel::expression::AsExpression)] + #[diesel(sql_type = diesel::sql_types::Integer)] + pub struct DisputePollingIntervalInHours(i32); + + impl Deref for DisputePollingIntervalInHours { + type Target = i32; + + fn deref(&self) -> &Self::Target { + &self.0 + } + } + + impl<'de> Deserialize<'de> for DisputePollingIntervalInHours { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let val = i32::deserialize(deserializer)?; + if val < 0 { + Err(D::Error::custom( + "DisputePollingIntervalInHours cannot be negative", + )) + } else if val > MAX_DISPUTE_POLLING_INTERVAL_IN_HOURS { + Err(D::Error::custom( + "DisputePollingIntervalInHours exceeds the maximum allowed value of 24", + )) + } else { + Ok(Self(val)) + } + } + } + + impl diesel::deserialize::FromSql + for DisputePollingIntervalInHours + { + fn from_sql(value: diesel::pg::PgValue<'_>) -> diesel::deserialize::Result { + i32::from_sql(value).map(Self) + } + } + + impl diesel::serialize::ToSql + for DisputePollingIntervalInHours + { + fn to_sql<'b>( + &'b self, + out: &mut diesel::serialize::Output<'b, '_, diesel::pg::Pg>, + ) -> diesel::serialize::Result { + >::to_sql( + &self.0, out, + ) + } + } + + impl Default for DisputePollingIntervalInHours { + /// Default for `ShouldCollectCvvDuringPayment` is `true` + fn default() -> Self { + Self(DEFAULT_DISPUTE_POLLING_INTERVAL_IN_HOURS) + } + } +} diff --git a/crates/diesel_models/src/business_profile.rs b/crates/diesel_models/src/business_profile.rs index e36eb305c4..8a76b648d0 100644 --- a/crates/diesel_models/src/business_profile.rs +++ b/crates/diesel_models/src/business_profile.rs @@ -77,6 +77,7 @@ pub struct Profile { pub acquirer_config_map: Option, pub merchant_category_code: Option, pub merchant_country_code: Option, + pub dispute_polling_interval: Option, } #[cfg(feature = "v1")] @@ -134,6 +135,7 @@ pub struct ProfileNew { pub is_pre_network_tokenization_enabled: Option, pub merchant_category_code: Option, pub merchant_country_code: Option, + pub dispute_polling_interval: Option, } #[cfg(feature = "v1")] @@ -191,6 +193,7 @@ pub struct ProfileUpdateInternal { pub acquirer_config_map: Option, pub merchant_category_code: Option, pub merchant_country_code: Option, + pub dispute_polling_interval: Option, } #[cfg(feature = "v1")] @@ -245,6 +248,7 @@ impl ProfileUpdateInternal { acquirer_config_map, merchant_category_code, merchant_country_code, + dispute_polling_interval, } = self; Profile { profile_id: source.profile_id, @@ -330,6 +334,7 @@ impl ProfileUpdateInternal { acquirer_config_map: acquirer_config_map.or(source.acquirer_config_map), merchant_category_code: merchant_category_code.or(source.merchant_category_code), merchant_country_code: merchant_country_code.or(source.merchant_country_code), + dispute_polling_interval: dispute_polling_interval.or(source.dispute_polling_interval), } } } @@ -393,6 +398,7 @@ pub struct Profile { pub acquirer_config_map: Option, pub merchant_category_code: Option, pub merchant_country_code: Option, + pub dispute_polling_interval: Option, pub routing_algorithm_id: Option, pub order_fulfillment_time: Option, pub order_fulfillment_time_origin: Option, @@ -691,6 +697,7 @@ impl ProfileUpdateInternal { acquirer_config_map: None, merchant_category_code: merchant_category_code.or(source.merchant_category_code), merchant_country_code: merchant_country_code.or(source.merchant_country_code), + dispute_polling_interval: None, } } } diff --git a/crates/diesel_models/src/schema.rs b/crates/diesel_models/src/schema.rs index 738877d86d..2347239f2f 100644 --- a/crates/diesel_models/src/schema.rs +++ b/crates/diesel_models/src/schema.rs @@ -252,6 +252,7 @@ diesel::table! { merchant_category_code -> Nullable, #[max_length = 32] merchant_country_code -> Nullable, + dispute_polling_interval -> Nullable, } } diff --git a/crates/diesel_models/src/schema_v2.rs b/crates/diesel_models/src/schema_v2.rs index fe343758c6..49f22ab3dd 100644 --- a/crates/diesel_models/src/schema_v2.rs +++ b/crates/diesel_models/src/schema_v2.rs @@ -247,6 +247,7 @@ diesel::table! { merchant_category_code -> Nullable, #[max_length = 32] merchant_country_code -> Nullable, + dispute_polling_interval -> Nullable, #[max_length = 64] routing_algorithm_id -> Nullable, order_fulfillment_time -> Nullable, diff --git a/crates/hyperswitch_connectors/src/connectors/worldpayvantiv.rs b/crates/hyperswitch_connectors/src/connectors/worldpayvantiv.rs index e1d9d12e0f..c23fed0b6f 100644 --- a/crates/hyperswitch_connectors/src/connectors/worldpayvantiv.rs +++ b/crates/hyperswitch_connectors/src/connectors/worldpayvantiv.rs @@ -21,15 +21,20 @@ use hyperswitch_domain_models::{ Void, }, refunds::{Execute, RSync}, + Accept, Dsync, Evidence, Fetch, Retrieve, Upload, }, router_request_types::{ - AccessTokenRequestData, PaymentMethodTokenizationData, PaymentsAuthorizeData, + AcceptDisputeRequestData, AccessTokenRequestData, DisputeSyncData, + FetchDisputesRequestData, PaymentMethodTokenizationData, PaymentsAuthorizeData, PaymentsCancelData, PaymentsCancelPostCaptureData, PaymentsCaptureData, - PaymentsSessionData, PaymentsSyncData, RefundsData, SetupMandateRequestData, + PaymentsSessionData, PaymentsSyncData, RefundsData, RetrieveFileRequestData, + SetupMandateRequestData, SubmitEvidenceRequestData, UploadFileRequestData, }, router_response_types::{ - ConnectorInfo, PaymentMethodDetails, PaymentsResponseData, RefundsResponseData, - SupportedPaymentMethods, SupportedPaymentMethodsExt, + AcceptDisputeResponse, ConnectorInfo, DisputeSyncResponse, FetchDisputesResponse, + PaymentMethodDetails, PaymentsResponseData, RefundsResponseData, RetrieveFileResponse, + SubmitEvidenceResponse, SupportedPaymentMethods, SupportedPaymentMethodsExt, + UploadFileResponse, }, types::{ PaymentsAuthorizeRouterData, PaymentsCancelPostCaptureRouterData, PaymentsCancelRouterData, @@ -38,7 +43,10 @@ use hyperswitch_domain_models::{ }; use hyperswitch_interfaces::{ api::{ - self, ConnectorCommon, ConnectorCommonExt, ConnectorIntegration, ConnectorSpecifications, + self, + disputes::{AcceptDispute, Dispute, DisputeSync, FetchDisputes, SubmitEvidence}, + files::{FilePurpose, FileUpload, RetrieveFile, UploadFile}, + ConnectorCommon, ConnectorCommonExt, ConnectorIntegration, ConnectorSpecifications, ConnectorValidation, }, configs::Connectors, @@ -51,7 +59,14 @@ use hyperswitch_interfaces::{ use masking::{Mask, PeekInterface}; use transformers as worldpayvantiv; -use crate::{constants::headers, types::ResponseRouterData, utils as connector_utils}; +use crate::{ + constants::headers, + types::{ + AcceptDisputeRouterData, DisputeSyncRouterData, FetchDisputeRouterData, ResponseRouterData, + RetrieveFileRouterData, SubmitEvidenceRouterData, UploadFileRouterData, + }, + utils as connector_utils, +}; #[derive(Clone)] pub struct Worldpayvantiv { @@ -800,6 +815,573 @@ impl ConnectorIntegration for Worldpayv } } +impl Dispute for Worldpayvantiv {} +impl FetchDisputes for Worldpayvantiv {} +impl DisputeSync for Worldpayvantiv {} +impl SubmitEvidence for Worldpayvantiv {} +impl AcceptDispute for Worldpayvantiv {} + +impl ConnectorIntegration + for Worldpayvantiv +{ + fn get_headers( + &self, + req: &FetchDisputeRouterData, + _connectors: &Connectors, + ) -> CustomResult)>, errors::ConnectorError> { + let mut headers = vec![ + ( + headers::CONTENT_TYPE.to_string(), + types::FetchDisputesType::get_content_type(self) + .to_string() + .into(), + ), + ( + headers::ACCEPT.to_string(), + types::FetchDisputesType::get_content_type(self) + .to_string() + .into(), + ), + ]; + + let mut auth_header = self.get_auth_header(&req.connector_auth_type)?; + headers.append(&mut auth_header); + Ok(headers) + } + + fn get_content_type(&self) -> &'static str { + "application/com.vantivcnp.services-v2+xml" + } + + fn get_url( + &self, + req: &FetchDisputeRouterData, + connectors: &Connectors, + ) -> CustomResult { + let date = req.request.created_from.date(); + let day = date.day(); + let month = u8::from(date.month()); + let year = date.year(); + + Ok(format!( + "{}/services/chargebacks/?date={year}-{month}-{day}", + connectors.worldpayvantiv.third_base_url.to_owned() + )) + } + + fn build_request( + &self, + req: &FetchDisputeRouterData, + connectors: &Connectors, + ) -> CustomResult, errors::ConnectorError> { + let request = RequestBuilder::new() + .method(Method::Get) + .url(&types::FetchDisputesType::get_url(self, req, connectors)?) + .attach_default_headers() + .headers(types::FetchDisputesType::get_headers( + self, req, connectors, + )?) + .build(); + Ok(Some(request)) + } + + fn handle_response( + &self, + data: &FetchDisputeRouterData, + event_builder: Option<&mut ConnectorEvent>, + res: Response, + ) -> CustomResult { + let response: worldpayvantiv::ChargebackRetrievalResponse = + connector_utils::deserialize_xml_to_struct(&res.response)?; + event_builder.map(|i| i.set_response_body(&response)); + router_env::logger::info!(connector_response=?response); + RouterData::try_from(ResponseRouterData { + response, + data: data.clone(), + http_code: res.status_code, + }) + } + + fn get_error_response( + &self, + res: Response, + event_builder: Option<&mut ConnectorEvent>, + ) -> CustomResult { + handle_vantiv_dispute_error_response(res, event_builder) + } +} + +impl ConnectorIntegration for Worldpayvantiv { + fn get_headers( + &self, + req: &DisputeSyncRouterData, + _connectors: &Connectors, + ) -> CustomResult)>, errors::ConnectorError> { + let mut headers = vec![ + ( + headers::CONTENT_TYPE.to_string(), + types::FetchDisputesType::get_content_type(self) + .to_string() + .into(), + ), + ( + headers::ACCEPT.to_string(), + types::FetchDisputesType::get_content_type(self) + .to_string() + .into(), + ), + ]; + + let mut auth_header = self.get_auth_header(&req.connector_auth_type)?; + headers.append(&mut auth_header); + Ok(headers) + } + + fn get_url( + &self, + req: &DisputeSyncRouterData, + connectors: &Connectors, + ) -> CustomResult { + Ok(format!( + "{}/services/chargebacks/{}", + connectors.worldpayvantiv.third_base_url.to_owned(), + req.request.connector_dispute_id + )) + } + + fn build_request( + &self, + req: &DisputeSyncRouterData, + connectors: &Connectors, + ) -> CustomResult, errors::ConnectorError> { + let request = RequestBuilder::new() + .method(Method::Get) + .url(&types::DisputeSyncType::get_url(self, req, connectors)?) + .attach_default_headers() + .headers(types::DisputeSyncType::get_headers(self, req, connectors)?) + .build(); + Ok(Some(request)) + } + + fn handle_response( + &self, + data: &DisputeSyncRouterData, + event_builder: Option<&mut ConnectorEvent>, + res: Response, + ) -> CustomResult { + let response: worldpayvantiv::ChargebackRetrievalResponse = + connector_utils::deserialize_xml_to_struct(&res.response)?; + event_builder.map(|i| i.set_response_body(&response)); + router_env::logger::info!(connector_response=?response); + RouterData::try_from(ResponseRouterData { + response, + data: data.clone(), + http_code: res.status_code, + }) + } + + fn get_error_response( + &self, + res: Response, + event_builder: Option<&mut ConnectorEvent>, + ) -> CustomResult { + handle_vantiv_dispute_error_response(res, event_builder) + } +} + +impl ConnectorIntegration + for Worldpayvantiv +{ + fn get_headers( + &self, + req: &SubmitEvidenceRouterData, + _connectors: &Connectors, + ) -> CustomResult)>, errors::ConnectorError> { + let mut headers = vec![ + ( + headers::CONTENT_TYPE.to_string(), + types::FetchDisputesType::get_content_type(self) + .to_string() + .into(), + ), + ( + headers::ACCEPT.to_string(), + types::FetchDisputesType::get_content_type(self) + .to_string() + .into(), + ), + ]; + + let mut auth_header = self.get_auth_header(&req.connector_auth_type)?; + headers.append(&mut auth_header); + Ok(headers) + } + + fn get_url( + &self, + req: &SubmitEvidenceRouterData, + connectors: &Connectors, + ) -> CustomResult { + Ok(format!( + "{}/services/chargebacks/{}", + connectors.worldpayvantiv.third_base_url.to_owned(), + req.request.connector_dispute_id + )) + } + + fn get_request_body( + &self, + req: &SubmitEvidenceRouterData, + _connectors: &Connectors, + ) -> CustomResult { + let connector_req_object = worldpayvantiv::ChargebackUpdateRequest::from(req); + router_env::logger::info!(raw_connector_request=?connector_req_object); + let connector_req = connector_utils::XmlSerializer::serialize_to_xml_bytes( + &connector_req_object, + worldpayvantiv::worldpayvantiv_constants::XML_VERSION, + Some(worldpayvantiv::worldpayvantiv_constants::XML_ENCODING), + None, + None, + )?; + + Ok(RequestContent::RawBytes(connector_req)) + } + + fn build_request( + &self, + req: &SubmitEvidenceRouterData, + connectors: &Connectors, + ) -> CustomResult, errors::ConnectorError> { + Ok(Some( + RequestBuilder::new() + .method(Method::Put) + .url(&types::SubmitEvidenceType::get_url(self, req, connectors)?) + .attach_default_headers() + .headers(types::SubmitEvidenceType::get_headers( + self, req, connectors, + )?) + .set_body(types::SubmitEvidenceType::get_request_body( + self, req, connectors, + )?) + .build(), + )) + } + + fn handle_response( + &self, + data: &SubmitEvidenceRouterData, + _event_builder: Option<&mut ConnectorEvent>, + _res: Response, + ) -> CustomResult { + Ok(SubmitEvidenceRouterData { + response: Ok(SubmitEvidenceResponse { + dispute_status: data.request.dispute_status, + connector_status: None, + }), + ..data.clone() + }) + } + + fn get_error_response( + &self, + res: Response, + event_builder: Option<&mut ConnectorEvent>, + ) -> CustomResult { + handle_vantiv_dispute_error_response(res, event_builder) + } +} + +impl ConnectorIntegration + for Worldpayvantiv +{ + fn get_headers( + &self, + req: &AcceptDisputeRouterData, + _connectors: &Connectors, + ) -> CustomResult)>, errors::ConnectorError> { + let mut headers = vec![ + ( + headers::CONTENT_TYPE.to_string(), + types::FetchDisputesType::get_content_type(self) + .to_string() + .into(), + ), + ( + headers::ACCEPT.to_string(), + types::FetchDisputesType::get_content_type(self) + .to_string() + .into(), + ), + ]; + + let mut auth_header = self.get_auth_header(&req.connector_auth_type)?; + headers.append(&mut auth_header); + Ok(headers) + } + + fn get_url( + &self, + req: &AcceptDisputeRouterData, + connectors: &Connectors, + ) -> CustomResult { + Ok(format!( + "{}/services/chargebacks/{}", + connectors.worldpayvantiv.third_base_url.to_owned(), + req.request.connector_dispute_id + )) + } + + fn get_request_body( + &self, + req: &AcceptDisputeRouterData, + _connectors: &Connectors, + ) -> CustomResult { + let connector_req_object = worldpayvantiv::ChargebackUpdateRequest::from(req); + router_env::logger::info!(raw_connector_request=?connector_req_object); + let connector_req = connector_utils::XmlSerializer::serialize_to_xml_bytes( + &connector_req_object, + worldpayvantiv::worldpayvantiv_constants::XML_VERSION, + Some(worldpayvantiv::worldpayvantiv_constants::XML_ENCODING), + Some(worldpayvantiv::worldpayvantiv_constants::XML_STANDALONE), + None, + )?; + + Ok(RequestContent::RawBytes(connector_req)) + } + + fn build_request( + &self, + req: &AcceptDisputeRouterData, + connectors: &Connectors, + ) -> CustomResult, errors::ConnectorError> { + Ok(Some( + RequestBuilder::new() + .method(Method::Put) + .url(&types::AcceptDisputeType::get_url(self, req, connectors)?) + .attach_default_headers() + .headers(types::AcceptDisputeType::get_headers( + self, req, connectors, + )?) + .set_body(types::AcceptDisputeType::get_request_body( + self, req, connectors, + )?) + .build(), + )) + } + + fn handle_response( + &self, + data: &AcceptDisputeRouterData, + _event_builder: Option<&mut ConnectorEvent>, + _res: Response, + ) -> CustomResult { + Ok(AcceptDisputeRouterData { + response: Ok(AcceptDisputeResponse { + dispute_status: data.request.dispute_status, + connector_status: None, + }), + ..data.clone() + }) + } + + fn get_error_response( + &self, + res: Response, + event_builder: Option<&mut ConnectorEvent>, + ) -> CustomResult { + handle_vantiv_dispute_error_response(res, event_builder) + } +} + +impl UploadFile for Worldpayvantiv {} + +impl ConnectorIntegration for Worldpayvantiv { + fn get_headers( + &self, + req: &RouterData, + _connectors: &Connectors, + ) -> CustomResult)>, errors::ConnectorError> { + let mut headers = vec![( + headers::CONTENT_TYPE.to_string(), + req.request.file_type.to_string().into(), + )]; + + let mut auth_header = self.get_auth_header(&req.connector_auth_type)?; + headers.append(&mut auth_header); + Ok(headers) + } + + fn get_url( + &self, + req: &UploadFileRouterData, + connectors: &Connectors, + ) -> CustomResult { + let file_type = if req.request.file_type == mime::IMAGE_GIF { + "gif" + } else if req.request.file_type == mime::IMAGE_JPEG { + "jpeg" + } else if req.request.file_type == mime::IMAGE_PNG { + "png" + } else if req.request.file_type == mime::APPLICATION_PDF { + "pdf" + } else { + return Err(errors::ConnectorError::FileValidationFailed { + reason: "file_type does not match JPEG, JPG, PNG, or PDF format".to_owned(), + })?; + }; + let file_name = req.request.file_key.split('/').next_back().ok_or( + errors::ConnectorError::RequestEncodingFailedWithReason( + "Failed fetching file_id from file_key".to_string(), + ), + )?; + Ok(format!( + "{}/services/chargebacks/upload/{}/{file_name}.{file_type}", + connectors.worldpayvantiv.third_base_url.to_owned(), + req.request.connector_dispute_id, + )) + } + + fn get_request_body( + &self, + req: &UploadFileRouterData, + _connectors: &Connectors, + ) -> CustomResult { + Ok(RequestContent::RawBytes(req.request.file.clone())) + } + + fn build_request( + &self, + req: &UploadFileRouterData, + connectors: &Connectors, + ) -> CustomResult, errors::ConnectorError> { + Ok(Some( + RequestBuilder::new() + .method(Method::Post) + .url(&types::UploadFileType::get_url(self, req, connectors)?) + .attach_default_headers() + .headers(types::UploadFileType::get_headers(self, req, connectors)?) + .set_body(types::UploadFileType::get_request_body( + self, req, connectors, + )?) + .build(), + )) + } + + fn handle_response( + &self, + data: &UploadFileRouterData, + event_builder: Option<&mut ConnectorEvent>, + res: Response, + ) -> CustomResult< + RouterData, + errors::ConnectorError, + > { + let response: worldpayvantiv::ChargebackDocumentUploadResponse = + connector_utils::deserialize_xml_to_struct(&res.response)?; + event_builder.map(|i| i.set_response_body(&response)); + router_env::logger::info!(connector_response=?response); + RouterData::try_from(ResponseRouterData { + response, + data: data.clone(), + http_code: res.status_code, + }) + } + + fn get_error_response( + &self, + res: Response, + event_builder: Option<&mut ConnectorEvent>, + ) -> CustomResult { + handle_vantiv_dispute_error_response(res, event_builder) + } +} + +impl RetrieveFile for Worldpayvantiv {} + +impl ConnectorIntegration + for Worldpayvantiv +{ + fn get_headers( + &self, + req: &RouterData, + _connectors: &Connectors, + ) -> CustomResult)>, errors::ConnectorError> { + self.get_auth_header(&req.connector_auth_type) + } + + fn get_url( + &self, + req: &RetrieveFileRouterData, + connectors: &Connectors, + ) -> CustomResult { + let connector_dispute_id = req.request.connector_dispute_id.clone().ok_or( + errors::ConnectorError::MissingRequiredField { + field_name: "dispute_id", + }, + )?; + Ok(format!( + "{}/services/chargebacks/retrieve/{connector_dispute_id}/{}", + connectors.worldpayvantiv.third_base_url.to_owned(), + req.request.provider_file_id, + )) + } + + fn build_request( + &self, + req: &RetrieveFileRouterData, + connectors: &Connectors, + ) -> CustomResult, errors::ConnectorError> { + Ok(Some( + RequestBuilder::new() + .method(Method::Get) + .url(&types::RetrieveFileType::get_url(self, req, connectors)?) + .attach_default_headers() + .headers(types::RetrieveFileType::get_headers(self, req, connectors)?) + .build(), + )) + } + + fn handle_response( + &self, + data: &RetrieveFileRouterData, + event_builder: Option<&mut ConnectorEvent>, + res: Response, + ) -> CustomResult { + let response: Result = + connector_utils::deserialize_xml_to_struct(&res.response); + match response { + Ok(response) => { + event_builder.map(|i| i.set_response_body(&response)); + router_env::logger::info!(connector_response=?response); + RouterData::try_from(ResponseRouterData { + response, + data: data.clone(), + http_code: res.status_code, + }) + } + Err(_) => { + event_builder.map(|event| event.set_response_body(&serde_json::json!({"connector_response_type": "file", "status_code": res.status_code}))); + router_env::logger::info!(connector_response_type=?"file"); + let response = res.response; + Ok(RetrieveFileRouterData { + response: Ok(RetrieveFileResponse { + file_data: response.to_vec(), + }), + ..data.clone() + }) + } + } + } + + fn get_error_response( + &self, + res: Response, + event_builder: Option<&mut ConnectorEvent>, + ) -> CustomResult { + handle_vantiv_dispute_error_response(res, event_builder) + } +} + #[async_trait::async_trait] impl webhooks::IncomingWebhook for Worldpayvantiv { fn get_webhook_object_reference_id( @@ -824,6 +1406,115 @@ impl webhooks::IncomingWebhook for Worldpayvantiv { } } +fn handle_vantiv_json_error_response( + res: Response, + event_builder: Option<&mut ConnectorEvent>, +) -> CustomResult { + let response: Result = res + .response + .parse_struct("VantivSyncErrorResponse") + .change_context(errors::ConnectorError::ResponseDeserializationFailed); + + match response { + Ok(response_data) => { + event_builder.map(|i| i.set_response_body(&response_data)); + router_env::logger::info!(connector_response=?response_data); + let error_reason = response_data.error_messages.join(" & "); + + Ok(ErrorResponse { + status_code: res.status_code, + code: NO_ERROR_CODE.to_string(), + message: error_reason.clone(), + reason: Some(error_reason.clone()), + attempt_status: None, + connector_transaction_id: None, + network_decline_code: None, + network_advice_code: None, + network_error_message: None, + }) + } + Err(error_msg) => { + event_builder.map(|event| event.set_error(serde_json::json!({"error": res.response.escape_ascii().to_string(), "status_code": res.status_code}))); + router_env::logger::error!(deserialization_error =? error_msg); + connector_utils::handle_json_response_deserialization_failure(res, "worldpayvantiv") + } + } +} + +fn handle_vantiv_dispute_error_response( + res: Response, + event_builder: Option<&mut ConnectorEvent>, +) -> CustomResult { + let response: Result = + connector_utils::deserialize_xml_to_struct::( + &res.response, + ); + + match response { + Ok(response_data) => { + event_builder.map(|i| i.set_response_body(&response_data)); + router_env::logger::info!(connector_response=?response_data); + let error_reason = response_data + .errors + .iter() + .map(|error_info| error_info.error.clone()) + .collect::>() + .join(" & "); + + Ok(ErrorResponse { + status_code: res.status_code, + code: NO_ERROR_CODE.to_string(), + message: error_reason.clone(), + reason: Some(error_reason.clone()), + attempt_status: None, + connector_transaction_id: None, + network_decline_code: None, + network_advice_code: None, + network_error_message: None, + }) + } + Err(error_msg) => { + event_builder.map(|event| event.set_error(serde_json::json!({"error": res.response.escape_ascii().to_string(), "status_code": res.status_code}))); + router_env::logger::error!(deserialization_error =? error_msg); + connector_utils::handle_json_response_deserialization_failure(res, "worldpayvantiv") + } + } +} + +#[async_trait::async_trait] +impl FileUpload for Worldpayvantiv { + fn validate_file_upload( + &self, + purpose: FilePurpose, + file_size: i32, + file_type: mime::Mime, + ) -> CustomResult<(), errors::ConnectorError> { + match purpose { + FilePurpose::DisputeEvidence => { + let supported_file_types = [ + "image/gif", + "image/jpeg", + "image/jpg", + "application/pdf", + "image/png", + "image/tiff", + ]; + if file_size > 2000000 { + Err(errors::ConnectorError::FileValidationFailed { + reason: "file_size exceeded the max file size of 2MB".to_owned(), + })? + } + if !supported_file_types.contains(&file_type.to_string().as_str()) { + Err(errors::ConnectorError::FileValidationFailed { + reason: "file_type does not match JPEG, JPG, PNG, or PDF format".to_owned(), + })? + } + } + } + Ok(()) + } +} + static WORLDPAYVANTIV_SUPPORTED_PAYMENT_METHODS: LazyLock = LazyLock::new(|| { let supported_capture_methods = vec![ @@ -916,38 +1607,3 @@ impl ConnectorSpecifications for Worldpayvantiv { Some(&WORLDPAYVANTIV_SUPPORTED_WEBHOOK_FLOWS) } } - -fn handle_vantiv_json_error_response( - res: Response, - event_builder: Option<&mut ConnectorEvent>, -) -> CustomResult { - let response: Result = res - .response - .parse_struct("VantivSyncErrorResponse") - .change_context(errors::ConnectorError::ResponseDeserializationFailed); - - match response { - Ok(response_data) => { - event_builder.map(|i| i.set_response_body(&response_data)); - router_env::logger::info!(connector_response=?response_data); - let error_reason = response_data.error_messages.join(" & "); - - Ok(ErrorResponse { - status_code: res.status_code, - code: NO_ERROR_CODE.to_string(), - message: error_reason.clone(), - reason: Some(error_reason.clone()), - attempt_status: None, - connector_transaction_id: None, - network_decline_code: None, - network_advice_code: None, - network_error_message: None, - }) - } - Err(error_msg) => { - event_builder.map(|event| event.set_error(serde_json::json!({"error": res.response.escape_ascii().to_string(), "status_code": res.status_code}))); - router_env::logger::error!(deserialization_error =? error_msg); - connector_utils::handle_json_response_deserialization_failure(res, "worldpayvantiv") - } - } -} diff --git a/crates/hyperswitch_connectors/src/connectors/worldpayvantiv/transformers.rs b/crates/hyperswitch_connectors/src/connectors/worldpayvantiv/transformers.rs index 619fc8dd22..09c9522091 100644 --- a/crates/hyperswitch_connectors/src/connectors/worldpayvantiv/transformers.rs +++ b/crates/hyperswitch_connectors/src/connectors/worldpayvantiv/transformers.rs @@ -1,4 +1,8 @@ -use common_utils::{ext_traits::Encode, id_type::CustomerId, types::MinorUnit}; +use common_utils::{ + ext_traits::Encode, + id_type::CustomerId, + types::{MinorUnit, StringMinorUnitForConnector}, +}; use error_stack::ResultExt; use hyperswitch_domain_models::{ payment_method_data::PaymentMethodData, @@ -6,12 +10,19 @@ use hyperswitch_domain_models::{ AdditionalPaymentMethodConnectorResponse, ConnectorAuthType, ConnectorResponseData, ErrorResponse, RouterData, }, - router_flow_types::refunds::{Execute, RSync}, - router_request_types::{ - PaymentsAuthorizeData, PaymentsCancelData, PaymentsCancelPostCaptureData, - PaymentsCaptureData, PaymentsSyncData, ResponseId, + router_flow_types::{ + refunds::{Execute, RSync}, + Dsync, Fetch, Retrieve, Upload, + }, + router_request_types::{ + DisputeSyncData, FetchDisputesRequestData, PaymentsAuthorizeData, PaymentsCancelData, + PaymentsCancelPostCaptureData, PaymentsCaptureData, PaymentsSyncData, ResponseId, + RetrieveFileRequestData, UploadFileRequestData, + }, + router_response_types::{ + DisputeSyncResponse, FetchDisputesResponse, MandateReference, PaymentsResponseData, + RefundsResponseData, RetrieveFileResponse, UploadFileResponse, }, - router_response_types::{MandateReference, PaymentsResponseData, RefundsResponseData}, types::{ PaymentsAuthorizeRouterData, PaymentsCancelPostCaptureRouterData, PaymentsCancelRouterData, PaymentsCaptureRouterData, RefundsRouterData, @@ -22,7 +33,11 @@ use masking::{ExposeInterface, PeekInterface, Secret}; use serde::{Deserialize, Serialize}; use crate::{ - types::{RefundsResponseRouterData, ResponseRouterData}, + types::{ + AcceptDisputeRouterData, DisputeSyncRouterData, FetchDisputeRouterData, + RefundsResponseRouterData, ResponseRouterData, RetrieveFileRouterData, + SubmitEvidenceRouterData, + }, utils::{self as connector_utils, CardData, PaymentsAuthorizeRequestData, RouterData as _}, }; @@ -32,6 +47,8 @@ pub mod worldpayvantiv_constants { pub const XML_ENCODING: &str = "UTF-8"; pub const XMLNS: &str = "http://www.vantivcnp.com/schema"; pub const MAX_ID_LENGTH: usize = 26; + pub const XML_STANDALONE: &str = "yes"; + pub const XML_CHARGEBACK: &str = "http://www.vantivcnp.com/chargebacks"; } pub struct WorldpayvantivRouterData { @@ -952,6 +969,19 @@ pub struct VantivSyncErrorResponse { pub error_messages: Vec, } +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename = "errorResponse")] +pub struct VantivDisputeErrorResponse { + #[serde(rename = "@xmlns")] + pub xmlns: String, + pub errors: Vec, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct VantivDisputeErrors { + pub error: String, +} + #[derive(Debug, Serialize, Deserialize)] #[serde(rename = "cnpOnlineResponse", rename_all = "camelCase")] pub struct CnpOnlineResponse { @@ -3465,3 +3495,447 @@ fn get_connector_response(payment_response: &FraudResult) -> ConnectorResponseDa }, ) } + +#[derive(Debug, Deserialize, Serialize)] +#[serde(rename = "chargebackRetrievalResponse", rename_all = "camelCase")] +pub struct ChargebackRetrievalResponse { + #[serde(rename = "@xmlns")] + pub xmlns: String, + pub transaction_id: String, + pub chargeback_case: Option>, +} + +#[derive(Debug, Deserialize, Serialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct ChargebackCase { + pub case_id: String, + pub merchant_id: String, + pub day_issued_by_bank: Option, + pub date_received_by_vantiv_cnp: Option, + pub vantiv_cnp_txn_id: String, + pub cycle: String, + pub order_id: String, + pub card_number_last4: Option, + pub card_type: Option, + pub chargeback_amount: MinorUnit, + pub chargeback_currency_type: common_enums::enums::Currency, + pub original_txn_day: Option, + pub chargeback_type: Option, + pub reason_code: Option, + pub reason_code_description: Option, + pub current_queue: Option, + pub acquirer_reference_number: Option, + pub chargeback_reference_number: Option, + pub bin: Option, + pub payment_amount: Option, + pub reply_by_day: Option, + pub pre_arbitration_amount: Option, + pub pre_arbitration_currency_type: Option, + pub activity: Vec, +} + +#[derive(Debug, Deserialize, Serialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct Activity { + pub activity_date: Option, + pub activity_type: Option, + pub from_queue: Option, + pub to_queue: Option, + pub notes: Option, +} + +impl + TryFrom< + ResponseRouterData< + Fetch, + ChargebackRetrievalResponse, + FetchDisputesRequestData, + FetchDisputesResponse, + >, + > for RouterData +{ + type Error = error_stack::Report; + fn try_from( + item: ResponseRouterData< + Fetch, + ChargebackRetrievalResponse, + FetchDisputesRequestData, + FetchDisputesResponse, + >, + ) -> Result { + let dispute_list = item + .response + .chargeback_case + .unwrap_or_default() + .into_iter() + .map(DisputeSyncResponse::try_from) + .collect::, _>>()?; + + Ok(FetchDisputeRouterData { + response: Ok(dispute_list), + ..item.data + }) + } +} + +impl + TryFrom< + ResponseRouterData< + Dsync, + ChargebackRetrievalResponse, + DisputeSyncData, + DisputeSyncResponse, + >, + > for RouterData +{ + type Error = error_stack::Report; + fn try_from( + item: ResponseRouterData< + Dsync, + ChargebackRetrievalResponse, + DisputeSyncData, + DisputeSyncResponse, + >, + ) -> Result { + let dispute_response = item + .response + .chargeback_case + .and_then(|chargeback_case| chargeback_case.first().cloned()) + .ok_or(errors::ConnectorError::RequestEncodingFailedWithReason( + "Could not find chargeback case".to_string(), + ))?; + + let dispute_sync_response = DisputeSyncResponse::try_from(dispute_response.clone())?; + Ok(DisputeSyncRouterData { + response: Ok(dispute_sync_response), + ..item.data + }) + } +} + +fn get_dispute_stage( + dispute_cycle: String, +) -> Result> { + match connector_utils::normalize_string(dispute_cycle.clone()) + .change_context(errors::ConnectorError::RequestEncodingFailed)? + .as_str() + { + "arbitration" + | "arbitrationmastercard" + | "arbitrationchargeback" + | "arbitrationlost" + | "arbitrationsplit" + | "arbitrationwon" => Ok(common_enums::enums::DisputeStage::Arbitration), + "chargebackreversal" | "firstchargeback" | "rapiddisputeresolution" | "representment" => { + Ok(common_enums::enums::DisputeStage::Dispute) + } + + "issueracceptedprearbitration" + | "issuerarbitration" + | "issuerdeclinedprearbitration" + | "prearbitration" + | "responsetoissuerarbitration" => Ok(common_enums::enums::DisputeStage::PreArbitration), + + "retrievalrequest" => Ok(common_enums::enums::DisputeStage::PreDispute), + _ => Err(errors::ConnectorError::NotSupported { + message: format!("Dispute stage {dispute_cycle}",), + connector: "worldpayvantiv", + } + .into()), + } +} + +pub fn get_dispute_status( + dispute_cycle: String, +) -> Result> { + match connector_utils::normalize_string(dispute_cycle.clone()) + .change_context(errors::ConnectorError::RequestEncodingFailed)? + .as_str() + { + "arbitration" + | "arbitrationmastercard" + | "arbitrationsplit" + | "representment" + | "issuerarbitration" + | "prearbitration" + | "responsetoissuerarbitration" + | "arbitrationchargeback" => Ok(api_models::enums::DisputeStatus::DisputeChallenged), + "chargebackreversal" | "issueracceptedprearbitration" | "arbitrationwon" => { + Ok(api_models::enums::DisputeStatus::DisputeWon) + } + "arbitrationlost" | "issuerdeclinedprearbitration" => { + Ok(api_models::enums::DisputeStatus::DisputeLost) + } + "firstchargeback" | "retrievalrequest" | "rapiddisputeresolution" => { + Ok(api_models::enums::DisputeStatus::DisputeOpened) + } + _ => Err(errors::ConnectorError::NotSupported { + message: format!("Dispute status {dispute_cycle}"), + connector: "worldpayvantiv", + } + .into()), + } +} + +fn convert_string_to_primitive_date( + item: Option, +) -> Result, error_stack::Report> { + item.map(|day| { + let full_datetime_str = format!("{day}T00:00:00"); + let format = + time::macros::format_description!("[year]-[month]-[day]T[hour]:[minute]:[second]"); + time::PrimitiveDateTime::parse(&full_datetime_str, &format) + }) + .transpose() + .change_context(errors::ConnectorError::ParsingFailed) +} + +impl TryFrom for DisputeSyncResponse { + type Error = error_stack::Report; + fn try_from(item: ChargebackCase) -> Result { + let amount = connector_utils::convert_amount( + &StringMinorUnitForConnector, + item.chargeback_amount, + item.chargeback_currency_type, + )?; + Ok(Self { + object_reference_id: api_models::webhooks::ObjectReferenceId::PaymentId( + api_models::payments::PaymentIdType::ConnectorTransactionId(item.vantiv_cnp_txn_id), + ), + amount, + currency: item.chargeback_currency_type, + dispute_stage: get_dispute_stage(item.cycle.clone())?, + dispute_status: get_dispute_status(item.cycle.clone())?, + connector_status: item.cycle.clone(), + connector_dispute_id: item.case_id.clone(), + connector_reason: item.reason_code_description.clone(), + connector_reason_code: item.reason_code.clone(), + challenge_required_by: convert_string_to_primitive_date(item.reply_by_day.clone())?, + created_at: convert_string_to_primitive_date(item.date_received_by_vantiv_cnp.clone())?, + updated_at: None, + }) + } +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum ActivityType { + MerchantAcceptsLiability, + MerchantRepresent, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename = "chargebackUpdateRequest", rename_all = "camelCase")] +pub struct ChargebackUpdateRequest { + #[serde(rename = "@xmlns")] + xmlns: String, + activity_type: ActivityType, +} + +impl From<&SubmitEvidenceRouterData> for ChargebackUpdateRequest { + fn from(_item: &SubmitEvidenceRouterData) -> Self { + Self { + xmlns: worldpayvantiv_constants::XML_CHARGEBACK.to_string(), + activity_type: ActivityType::MerchantRepresent, + } + } +} + +impl From<&AcceptDisputeRouterData> for ChargebackUpdateRequest { + fn from(_item: &AcceptDisputeRouterData) -> Self { + Self { + xmlns: worldpayvantiv_constants::XML_CHARGEBACK.to_string(), + activity_type: ActivityType::MerchantAcceptsLiability, + } + } +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename = "chargebackDocumentUploadResponse", rename_all = "camelCase")] +pub struct ChargebackDocumentUploadResponse { + #[serde(rename = "@xmlns")] + pub xmlns: String, + pub merchant_id: String, + pub case_id: String, + pub document_id: Option, + pub response_code: WorldpayvantivFileUploadResponseCode, + pub response_message: String, +} + +#[derive(Debug, strum::Display, Serialize, Deserialize, PartialEq, Clone, Copy)] +pub enum WorldpayvantivFileUploadResponseCode { + #[serde(rename = "000")] + #[strum(serialize = "000")] + Success, + + #[serde(rename = "001")] + #[strum(serialize = "001")] + InvalidMerchant, + + #[serde(rename = "002")] + #[strum(serialize = "002")] + FutureUse002, + + #[serde(rename = "003")] + #[strum(serialize = "003")] + CaseNotFound, + + #[serde(rename = "004")] + #[strum(serialize = "004")] + CaseNotInMerchantQueue, + + #[serde(rename = "005")] + #[strum(serialize = "005")] + DocumentAlreadyExists, + + #[serde(rename = "006")] + #[strum(serialize = "006")] + InternalError, + + #[serde(rename = "007")] + #[strum(serialize = "007")] + FutureUse007, + + #[serde(rename = "008")] + #[strum(serialize = "008")] + MaxDocumentLimitReached, + + #[serde(rename = "009")] + #[strum(serialize = "009")] + DocumentNotFound, + + #[serde(rename = "010")] + #[strum(serialize = "010")] + CaseNotInValidCycle, + + #[serde(rename = "011")] + #[strum(serialize = "011")] + ServerBusy, + + #[serde(rename = "012")] + #[strum(serialize = "012")] + FileSizeExceedsLimit, + + #[serde(rename = "013")] + #[strum(serialize = "013")] + InvalidFileContent, + + #[serde(rename = "014")] + #[strum(serialize = "014")] + UnableToConvert, + + #[serde(rename = "015")] + #[strum(serialize = "015")] + InvalidImageSize, + + #[serde(rename = "016")] + #[strum(serialize = "016")] + MaxDocumentPageCountReached, +} + +fn is_file_uploaded(vantiv_file_upload_status: WorldpayvantivFileUploadResponseCode) -> bool { + match vantiv_file_upload_status { + WorldpayvantivFileUploadResponseCode::Success + | WorldpayvantivFileUploadResponseCode::FutureUse002 + | WorldpayvantivFileUploadResponseCode::FutureUse007 => true, + WorldpayvantivFileUploadResponseCode::InvalidMerchant + | WorldpayvantivFileUploadResponseCode::CaseNotFound + | WorldpayvantivFileUploadResponseCode::CaseNotInMerchantQueue + | WorldpayvantivFileUploadResponseCode::DocumentAlreadyExists + | WorldpayvantivFileUploadResponseCode::InternalError + | WorldpayvantivFileUploadResponseCode::MaxDocumentLimitReached + | WorldpayvantivFileUploadResponseCode::DocumentNotFound + | WorldpayvantivFileUploadResponseCode::CaseNotInValidCycle + | WorldpayvantivFileUploadResponseCode::ServerBusy + | WorldpayvantivFileUploadResponseCode::FileSizeExceedsLimit + | WorldpayvantivFileUploadResponseCode::InvalidFileContent + | WorldpayvantivFileUploadResponseCode::UnableToConvert + | WorldpayvantivFileUploadResponseCode::InvalidImageSize + | WorldpayvantivFileUploadResponseCode::MaxDocumentPageCountReached => false, + } +} + +impl + TryFrom< + ResponseRouterData< + Upload, + ChargebackDocumentUploadResponse, + UploadFileRequestData, + UploadFileResponse, + >, + > for RouterData +{ + type Error = error_stack::Report; + + fn try_from( + item: ResponseRouterData< + Upload, + ChargebackDocumentUploadResponse, + UploadFileRequestData, + UploadFileResponse, + >, + ) -> Result { + let response = if is_file_uploaded(item.response.response_code) { + let provider_file_id = item + .response + .document_id + .ok_or(errors::ConnectorError::MissingConnectorTransactionID)?; + + Ok(UploadFileResponse { provider_file_id }) + } else { + Err(ErrorResponse { + code: item.response.response_code.to_string(), + message: item.response.response_message.clone(), + reason: Some(item.response.response_message.clone()), + status_code: item.http_code, + attempt_status: None, + connector_transaction_id: None, + network_advice_code: None, + network_decline_code: None, + network_error_message: None, + }) + }; + + Ok(Self { + response, + ..item.data + }) + } +} + +impl + TryFrom< + ResponseRouterData< + Retrieve, + ChargebackDocumentUploadResponse, + RetrieveFileRequestData, + RetrieveFileResponse, + >, + > for RouterData +{ + type Error = error_stack::Report; + + fn try_from( + item: ResponseRouterData< + Retrieve, + ChargebackDocumentUploadResponse, + RetrieveFileRequestData, + RetrieveFileResponse, + >, + ) -> Result { + Ok(RetrieveFileRouterData { + response: Err(ErrorResponse { + code: item.response.response_code.to_string(), + message: item.response.response_message.clone(), + reason: Some(item.response.response_message.clone()), + status_code: item.http_code, + attempt_status: None, + connector_transaction_id: None, + network_advice_code: None, + network_decline_code: None, + network_error_message: None, + }), + ..item.data.clone() + }) + } +} diff --git a/crates/hyperswitch_connectors/src/default_implementations.rs b/crates/hyperswitch_connectors/src/default_implementations.rs index 4f23064f7a..22c87d0832 100644 --- a/crates/hyperswitch_connectors/src/default_implementations.rs +++ b/crates/hyperswitch_connectors/src/default_implementations.rs @@ -47,7 +47,7 @@ use hyperswitch_domain_models::{ authentication::{ Authentication, PostAuthentication, PreAuthentication, PreAuthenticationVersionCall, }, - dispute::{Accept, Defend, Evidence}, + dispute::{Accept, Defend, Dsync, Evidence, Fetch}, files::{Retrieve, Upload}, mandate_revoke::MandateRevoke, payments::{ @@ -68,18 +68,19 @@ use hyperswitch_domain_models::{ UasPreAuthenticationRequestData, }, AcceptDisputeRequestData, AuthorizeSessionTokenData, CompleteAuthorizeData, - ConnectorCustomerData, CreateOrderRequestData, DefendDisputeRequestData, - MandateRevokeRequestData, PaymentsApproveData, PaymentsCancelPostCaptureData, - PaymentsIncrementalAuthorizationData, PaymentsPostProcessingData, - PaymentsPostSessionTokensData, PaymentsPreProcessingData, PaymentsRejectData, - PaymentsTaxCalculationData, PaymentsUpdateMetadataData, RetrieveFileRequestData, - SdkPaymentsSessionUpdateData, SubmitEvidenceRequestData, UploadFileRequestData, - VaultRequestData, VerifyWebhookSourceRequestData, + ConnectorCustomerData, CreateOrderRequestData, DefendDisputeRequestData, DisputeSyncData, + FetchDisputesRequestData, MandateRevokeRequestData, PaymentsApproveData, + PaymentsCancelPostCaptureData, PaymentsIncrementalAuthorizationData, + PaymentsPostProcessingData, PaymentsPostSessionTokensData, PaymentsPreProcessingData, + PaymentsRejectData, PaymentsTaxCalculationData, PaymentsUpdateMetadataData, + RetrieveFileRequestData, SdkPaymentsSessionUpdateData, SubmitEvidenceRequestData, + UploadFileRequestData, VaultRequestData, VerifyWebhookSourceRequestData, }, router_response_types::{ AcceptDisputeResponse, AuthenticationResponseData, DefendDisputeResponse, - MandateRevokeResponseData, PaymentsResponseData, RetrieveFileResponse, - SubmitEvidenceResponse, TaxCalculationResponseData, UploadFileResponse, VaultResponseData, + DisputeSyncResponse, FetchDisputesResponse, MandateRevokeResponseData, + PaymentsResponseData, RetrieveFileResponse, SubmitEvidenceResponse, + TaxCalculationResponseData, UploadFileResponse, VaultResponseData, VerifyWebhookSourceResponseData, }, }; @@ -108,7 +109,9 @@ use hyperswitch_interfaces::{ ConnectorAuthentication, ConnectorPostAuthentication, ConnectorPreAuthentication, ConnectorPreAuthenticationVersionCall, ExternalAuthentication, }, - disputes::{AcceptDispute, DefendDispute, Dispute, SubmitEvidence}, + disputes::{ + AcceptDispute, DefendDispute, Dispute, DisputeSync, FetchDisputes, SubmitEvidence, + }, files::{FileUpload, RetrieveFile, UploadFile}, payments::{ ConnectorCustomer, PaymentApprove, PaymentAuthorizeSessionToken, @@ -2431,7 +2434,6 @@ default_imp_for_accept_dispute!( connectors::Wise, connectors::Worldline, connectors::Worldpay, - connectors::Worldpayvantiv, connectors::Worldpayxml, connectors::Wellsfargo, connectors::Wellsfargopayout, @@ -2569,7 +2571,6 @@ default_imp_for_submit_evidence!( connectors::Wise, connectors::Worldline, connectors::Worldpay, - connectors::Worldpayvantiv, connectors::Worldpayxml, connectors::Wellsfargo, connectors::Wellsfargopayout, @@ -2719,6 +2720,286 @@ default_imp_for_defend_dispute!( connectors::CtpMastercard ); +macro_rules! default_imp_for_fetch_disputes { + ($($path:ident::$connector:ident),*) => { + $( + impl FetchDisputes for $path::$connector {} + impl + ConnectorIntegration< + Fetch, + FetchDisputesRequestData, + FetchDisputesResponse, + > for $path::$connector + {} + )* + }; +} + +default_imp_for_fetch_disputes!( + connectors::Vgs, + connectors::Aci, + connectors::Adyen, + connectors::Adyenplatform, + connectors::Affirm, + connectors::Airwallex, + connectors::Amazonpay, + connectors::Archipel, + connectors::Authipay, + connectors::Authorizedotnet, + connectors::Bambora, + connectors::Bamboraapac, + connectors::Bankofamerica, + connectors::Barclaycard, + connectors::Blackhawknetwork, + connectors::Bluecode, + connectors::Billwerk, + connectors::Bitpay, + connectors::Bluesnap, + connectors::Braintree, + connectors::Breadpay, + connectors::Boku, + connectors::Cashtocode, + connectors::Celero, + connectors::Chargebee, + connectors::Checkbook, + connectors::Checkout, + connectors::Coinbase, + connectors::Coingate, + connectors::Cryptopay, + connectors::Custombilling, + connectors::Cybersource, + connectors::Datatrans, + connectors::Deutschebank, + connectors::Digitalvirgo, + connectors::Dlocal, + connectors::Dwolla, + connectors::Ebanx, + connectors::Elavon, + connectors::Facilitapay, + connectors::Fiserv, + connectors::Fiservemea, + connectors::Fiuu, + connectors::Forte, + connectors::Flexiti, + connectors::Getnet, + connectors::Globalpay, + connectors::Globepay, + connectors::Gocardless, + connectors::Gpayments, + connectors::Hipay, + connectors::HyperswitchVault, + connectors::Iatapay, + connectors::Inespay, + connectors::Itaubank, + connectors::Jpmorgan, + connectors::Juspaythreedsserver, + connectors::Klarna, + connectors::Katapult, + connectors::Helcim, + connectors::Netcetera, + connectors::Nmi, + connectors::Nomupay, + connectors::Noon, + connectors::Nordea, + connectors::Novalnet, + connectors::Nexinets, + connectors::Nexixpay, + connectors::Opayo, + connectors::Opennode, + connectors::Nuvei, + connectors::Paybox, + connectors::Payeezy, + connectors::Payload, + connectors::Payme, + connectors::Payone, + connectors::Paypal, + connectors::Paystack, + connectors::Payu, + connectors::Paytm, + connectors::Phonepe, + connectors::Placetopay, + connectors::Plaid, + connectors::Powertranz, + connectors::Prophetpay, + connectors::Mifinity, + connectors::Mollie, + connectors::Moneris, + connectors::Mpgs, + connectors::Multisafepay, + connectors::Rapyd, + connectors::Razorpay, + connectors::Recurly, + connectors::Redsys, + connectors::Riskified, + connectors::Santander, + connectors::Shift4, + connectors::Silverflow, + connectors::Signifyd, + connectors::Stax, + connectors::Square, + connectors::Stripe, + connectors::Stripebilling, + connectors::Taxjar, + connectors::Threedsecureio, + connectors::Thunes, + connectors::Tokenio, + connectors::Trustpay, + connectors::Trustpayments, + connectors::Tsys, + connectors::UnifiedAuthenticationService, + connectors::Wise, + connectors::Worldline, + connectors::Worldpay, + connectors::Worldpayxml, + connectors::Wellsfargo, + connectors::Wellsfargopayout, + connectors::Volt, + connectors::Xendit, + connectors::Zen, + connectors::Zsl, + connectors::CtpMastercard +); + +macro_rules! default_imp_for_dispute_sync { + ($($path:ident::$connector:ident),*) => { + $( + impl DisputeSync for $path::$connector {} + impl + ConnectorIntegration< + Dsync, + DisputeSyncData, + DisputeSyncResponse, + > for $path::$connector + {} + )* + }; +} + +default_imp_for_dispute_sync!( + connectors::Vgs, + connectors::Aci, + connectors::Adyen, + connectors::Adyenplatform, + connectors::Affirm, + connectors::Airwallex, + connectors::Amazonpay, + connectors::Archipel, + connectors::Authipay, + connectors::Authorizedotnet, + connectors::Bambora, + connectors::Bamboraapac, + connectors::Bankofamerica, + connectors::Barclaycard, + connectors::Billwerk, + connectors::Bitpay, + connectors::Bluesnap, + connectors::Blackhawknetwork, + connectors::Bluecode, + connectors::Braintree, + connectors::Breadpay, + connectors::Boku, + connectors::Cashtocode, + connectors::Celero, + connectors::Chargebee, + connectors::Checkbook, + connectors::Checkout, + connectors::Coinbase, + connectors::Coingate, + connectors::Cryptopay, + connectors::Custombilling, + connectors::Cybersource, + connectors::Datatrans, + connectors::Deutschebank, + connectors::Digitalvirgo, + connectors::Dlocal, + connectors::Dwolla, + connectors::Ebanx, + connectors::Elavon, + connectors::Facilitapay, + connectors::Fiserv, + connectors::Fiservemea, + connectors::Fiuu, + connectors::Forte, + connectors::Flexiti, + connectors::Getnet, + connectors::Globalpay, + connectors::Globepay, + connectors::Gocardless, + connectors::Gpayments, + connectors::Hipay, + connectors::HyperswitchVault, + connectors::Iatapay, + connectors::Inespay, + connectors::Itaubank, + connectors::Jpmorgan, + connectors::Juspaythreedsserver, + connectors::Klarna, + connectors::Katapult, + connectors::Helcim, + connectors::Netcetera, + connectors::Nmi, + connectors::Nomupay, + connectors::Noon, + connectors::Nordea, + connectors::Novalnet, + connectors::Nexinets, + connectors::Nexixpay, + connectors::Opayo, + connectors::Opennode, + connectors::Nuvei, + connectors::Paybox, + connectors::Payeezy, + connectors::Payload, + connectors::Payme, + connectors::Payone, + connectors::Paypal, + connectors::Paystack, + connectors::Payu, + connectors::Paytm, + connectors::Phonepe, + connectors::Placetopay, + connectors::Plaid, + connectors::Powertranz, + connectors::Prophetpay, + connectors::Mifinity, + connectors::Mollie, + connectors::Moneris, + connectors::Multisafepay, + connectors::Mpgs, + connectors::Rapyd, + connectors::Razorpay, + connectors::Recurly, + connectors::Redsys, + connectors::Riskified, + connectors::Santander, + connectors::Shift4, + connectors::Silverflow, + connectors::Signifyd, + connectors::Stax, + connectors::Square, + connectors::Stripe, + connectors::Stripebilling, + connectors::Taxjar, + connectors::Threedsecureio, + connectors::Thunes, + connectors::Tokenio, + connectors::Trustpay, + connectors::Trustpayments, + connectors::Tsys, + connectors::UnifiedAuthenticationService, + connectors::Wise, + connectors::Worldline, + connectors::Worldpay, + connectors::Worldpayxml, + connectors::Wellsfargo, + connectors::Wellsfargopayout, + connectors::Volt, + connectors::Xendit, + connectors::Zen, + connectors::Zsl, + connectors::CtpMastercard +); + macro_rules! default_imp_for_file_upload { ($($path:ident::$connector:ident),*) => { $( @@ -2855,7 +3136,6 @@ default_imp_for_file_upload!( connectors::Wise, connectors::Worldline, connectors::Worldpay, - connectors::Worldpayvantiv, connectors::Worldpayxml, connectors::Wellsfargo, connectors::Wellsfargopayout, @@ -7254,6 +7534,22 @@ impl ConnectorIntegration FetchDisputes for connectors::DummyConnector {} +#[cfg(feature = "dummy_connector")] +impl ConnectorIntegration + for connectors::DummyConnector +{ +} + +#[cfg(feature = "dummy_connector")] +impl DisputeSync for connectors::DummyConnector {} +#[cfg(feature = "dummy_connector")] +impl ConnectorIntegration + for connectors::DummyConnector +{ +} + #[cfg(feature = "dummy_connector")] impl PaymentsPreProcessing for connectors::DummyConnector {} #[cfg(feature = "dummy_connector")] diff --git a/crates/hyperswitch_connectors/src/default_implementations_v2.rs b/crates/hyperswitch_connectors/src/default_implementations_v2.rs index fedb562e51..c40ac1d346 100644 --- a/crates/hyperswitch_connectors/src/default_implementations_v2.rs +++ b/crates/hyperswitch_connectors/src/default_implementations_v2.rs @@ -12,7 +12,7 @@ use hyperswitch_domain_models::{ authentication::{ Authentication, PostAuthentication, PreAuthentication, PreAuthenticationVersionCall, }, - dispute::{Accept, Defend, Evidence}, + dispute::{Accept, Defend, Dsync, Evidence, Fetch}, files::{Retrieve, Upload}, mandate_revoke::MandateRevoke, payments::{ @@ -37,11 +37,12 @@ use hyperswitch_domain_models::{ }, AcceptDisputeRequestData, AccessTokenRequestData, AuthorizeSessionTokenData, CompleteAuthorizeData, ConnectorCustomerData, CreateOrderRequestData, - DefendDisputeRequestData, MandateRevokeRequestData, PaymentMethodTokenizationData, - PaymentsApproveData, PaymentsAuthorizeData, PaymentsCancelData, - PaymentsCancelPostCaptureData, PaymentsCaptureData, PaymentsIncrementalAuthorizationData, - PaymentsPostProcessingData, PaymentsPostSessionTokensData, PaymentsPreProcessingData, - PaymentsRejectData, PaymentsSessionData, PaymentsSyncData, PaymentsTaxCalculationData, + DefendDisputeRequestData, DisputeSyncData, FetchDisputesRequestData, + MandateRevokeRequestData, PaymentMethodTokenizationData, PaymentsApproveData, + PaymentsAuthorizeData, PaymentsCancelData, PaymentsCancelPostCaptureData, + PaymentsCaptureData, PaymentsIncrementalAuthorizationData, PaymentsPostProcessingData, + PaymentsPostSessionTokensData, PaymentsPreProcessingData, PaymentsRejectData, + PaymentsSessionData, PaymentsSyncData, PaymentsTaxCalculationData, PaymentsUpdateMetadataData, RefundsData, RetrieveFileRequestData, SdkPaymentsSessionUpdateData, SetupMandateRequestData, SubmitEvidenceRequestData, UploadFileRequestData, VaultRequestData, VerifyWebhookSourceRequestData, @@ -52,8 +53,9 @@ use hyperswitch_domain_models::{ RevenueRecoveryRecordBackResponse, }, AcceptDisputeResponse, AuthenticationResponseData, DefendDisputeResponse, - MandateRevokeResponseData, PaymentsResponseData, RefundsResponseData, RetrieveFileResponse, - SubmitEvidenceResponse, TaxCalculationResponseData, UploadFileResponse, VaultResponseData, + DisputeSyncResponse, FetchDisputesResponse, MandateRevokeResponseData, + PaymentsResponseData, RefundsResponseData, RetrieveFileResponse, SubmitEvidenceResponse, + TaxCalculationResponseData, UploadFileResponse, VaultResponseData, VerifyWebhookSourceResponseData, }, }; @@ -93,7 +95,10 @@ use hyperswitch_interfaces::{ ConnectorAuthenticationV2, ConnectorPostAuthenticationV2, ConnectorPreAuthenticationV2, ConnectorPreAuthenticationVersionCallV2, ExternalAuthenticationV2, }, - disputes_v2::{AcceptDisputeV2, DefendDisputeV2, DisputeV2, SubmitEvidenceV2}, + disputes_v2::{ + AcceptDisputeV2, DefendDisputeV2, DisputeSyncV2, DisputeV2, FetchDisputesV2, + SubmitEvidenceV2, + }, files_v2::{FileUploadV2, RetrieveFileV2, UploadFileV2}, payments_v2::{ ConnectorCustomerV2, MandateSetupV2, PaymentApproveV2, PaymentAuthorizeSessionTokenV2, @@ -794,30 +799,30 @@ default_imp_for_new_connector_integration_accept_dispute!( connectors::Zsl ); -macro_rules! default_imp_for_new_connector_integration_submit_evidence { +macro_rules! default_imp_for_new_connector_integration_fetch_disputes { ($($path:ident::$connector:ident),*) => { $( - impl SubmitEvidenceV2 for $path::$connector {} + impl FetchDisputesV2 for $path::$connector {} impl ConnectorIntegrationV2< - Evidence, + Fetch, DisputesFlowData, - SubmitEvidenceRequestData, - SubmitEvidenceResponse, + FetchDisputesRequestData, + FetchDisputesResponse, > for $path::$connector {} )* }; } -default_imp_for_new_connector_integration_submit_evidence!( - connectors::Vgs, +default_imp_for_new_connector_integration_fetch_disputes!( connectors::Aci, connectors::Adyen, connectors::Adyenplatform, connectors::Affirm, connectors::Airwallex, connectors::Amazonpay, + connectors::Archipel, connectors::Authipay, connectors::Authorizedotnet, connectors::Bambora, @@ -829,9 +834,9 @@ default_imp_for_new_connector_integration_submit_evidence!( connectors::Blackhawknetwork, connectors::Bluecode, connectors::Bluesnap, + connectors::Boku, connectors::Braintree, connectors::Breadpay, - connectors::Boku, connectors::Cashtocode, connectors::Celero, connectors::Chargebee, @@ -842,6 +847,7 @@ default_imp_for_new_connector_integration_submit_evidence!( connectors::Cryptopay, connectors::CtpMastercard, connectors::Custombilling, + connectors::Cybersource, connectors::Datatrans, connectors::Deutschebank, connectors::Digitalvirgo, @@ -860,8 +866,8 @@ default_imp_for_new_connector_integration_submit_evidence!( connectors::Globepay, connectors::Gocardless, connectors::Gpayments, - connectors::Hipay, connectors::Helcim, + connectors::Hipay, connectors::HyperswitchVault, connectors::Iatapay, connectors::Inespay, @@ -870,36 +876,36 @@ default_imp_for_new_connector_integration_submit_evidence!( connectors::Juspaythreedsserver, connectors::Katapult, connectors::Klarna, - connectors::Nomupay, - connectors::Noon, - connectors::Nordea, - connectors::Novalnet, - connectors::Netcetera, - connectors::Nexinets, - connectors::Nexixpay, - connectors::Nmi, - connectors::Payone, - connectors::Opayo, - connectors::Opennode, - connectors::Nuvei, - connectors::Paybox, - connectors::Payeezy, - connectors::Payload, - connectors::Payme, - connectors::Paypal, - connectors::Paystack, - connectors::Paytm, - connectors::Payu, - connectors::Phonepe, - connectors::Placetopay, - connectors::Plaid, - connectors::Powertranz, - connectors::Prophetpay, connectors::Mifinity, connectors::Mollie, connectors::Moneris, connectors::Mpgs, connectors::Multisafepay, + connectors::Netcetera, + connectors::Nexinets, + connectors::Nexixpay, + connectors::Nmi, + connectors::Nomupay, + connectors::Noon, + connectors::Nordea, + connectors::Novalnet, + connectors::Nuvei, + connectors::Opayo, + connectors::Opennode, + connectors::Paybox, + connectors::Payeezy, + connectors::Payload, + connectors::Payme, + connectors::Payone, + connectors::Paypal, + connectors::Paystack, + connectors::Paytm, + connectors::Payu, + connectors::Phonepe, + connectors::Plaid, + connectors::Placetopay, + connectors::Powertranz, + connectors::Prophetpay, connectors::Rapyd, connectors::Razorpay, connectors::Recurly, @@ -907,11 +913,11 @@ default_imp_for_new_connector_integration_submit_evidence!( connectors::Riskified, connectors::Santander, connectors::Shift4, - connectors::Silverflow, connectors::Signifyd, + connectors::Silverflow, + connectors::Square, connectors::Stax, connectors::Stripe, - connectors::Square, connectors::Stripebilling, connectors::Taxjar, connectors::Threedsecureio, @@ -921,14 +927,157 @@ default_imp_for_new_connector_integration_submit_evidence!( connectors::Trustpayments, connectors::Tsys, connectors::UnifiedAuthenticationService, + connectors::Vgs, + connectors::Volt, + connectors::Wellsfargo, + connectors::Wellsfargopayout, connectors::Wise, connectors::Worldline, - connectors::Volt, connectors::Worldpay, connectors::Worldpayvantiv, connectors::Worldpayxml, + connectors::Xendit, + connectors::Zen, + connectors::Zsl +); + +macro_rules! default_imp_for_new_connector_integration_dispute_sync { + ($($path:ident::$connector:ident),*) => { + $( + impl DisputeSyncV2 for $path::$connector {} + impl + ConnectorIntegrationV2< + Dsync, + DisputesFlowData, + DisputeSyncData, + DisputeSyncResponse, + > for $path::$connector + {} + )* + }; +} + +default_imp_for_new_connector_integration_dispute_sync!( + connectors::Aci, + connectors::Adyen, + connectors::Adyenplatform, + connectors::Affirm, + connectors::Airwallex, + connectors::Amazonpay, + connectors::Archipel, + connectors::Authipay, + connectors::Authorizedotnet, + connectors::Bambora, + connectors::Bamboraapac, + connectors::Bankofamerica, + connectors::Barclaycard, + connectors::Billwerk, + connectors::Bitpay, + connectors::Blackhawknetwork, + connectors::Bluecode, + connectors::Bluesnap, + connectors::Boku, + connectors::Braintree, + connectors::Breadpay, + connectors::Cashtocode, + connectors::Celero, + connectors::Chargebee, + connectors::Checkbook, + connectors::Checkout, + connectors::Coinbase, + connectors::Coingate, + connectors::Cryptopay, + connectors::CtpMastercard, + connectors::Custombilling, + connectors::Cybersource, + connectors::Datatrans, + connectors::Deutschebank, + connectors::Digitalvirgo, + connectors::Dlocal, + connectors::Dwolla, + connectors::Ebanx, + connectors::Elavon, + connectors::Facilitapay, + connectors::Fiserv, + connectors::Fiservemea, + connectors::Fiuu, + connectors::Flexiti, + connectors::Forte, + connectors::Getnet, + connectors::Globalpay, + connectors::Globepay, + connectors::Gocardless, + connectors::Gpayments, + connectors::Helcim, + connectors::Hipay, + connectors::HyperswitchVault, + connectors::Iatapay, + connectors::Inespay, + connectors::Itaubank, + connectors::Jpmorgan, + connectors::Juspaythreedsserver, + connectors::Katapult, + connectors::Klarna, + connectors::Mifinity, + connectors::Mollie, + connectors::Moneris, + connectors::Mpgs, + connectors::Multisafepay, + connectors::Netcetera, + connectors::Nexinets, + connectors::Nexixpay, + connectors::Nmi, + connectors::Nomupay, + connectors::Noon, + connectors::Nordea, + connectors::Novalnet, + connectors::Nuvei, + connectors::Opayo, + connectors::Opennode, + connectors::Paybox, + connectors::Payeezy, + connectors::Payload, + connectors::Payme, + connectors::Payone, + connectors::Paypal, + connectors::Paystack, + connectors::Paytm, + connectors::Payu, + connectors::Phonepe, + connectors::Plaid, + connectors::Placetopay, + connectors::Powertranz, + connectors::Prophetpay, + connectors::Rapyd, + connectors::Razorpay, + connectors::Recurly, + connectors::Redsys, + connectors::Riskified, + connectors::Santander, + connectors::Shift4, + connectors::Signifyd, + connectors::Silverflow, + connectors::Square, + connectors::Stax, + connectors::Stripe, + connectors::Stripebilling, + connectors::Taxjar, + connectors::Threedsecureio, + connectors::Thunes, + connectors::Tokenio, + connectors::Trustpay, + connectors::Trustpayments, + connectors::Tsys, + connectors::UnifiedAuthenticationService, + connectors::Vgs, + connectors::Volt, connectors::Wellsfargo, connectors::Wellsfargopayout, + connectors::Wise, + connectors::Worldline, + connectors::Worldpay, + connectors::Worldpayvantiv, + connectors::Worldpayxml, connectors::Xendit, connectors::Zen, connectors::Zsl @@ -1075,6 +1224,146 @@ default_imp_for_new_connector_integration_defend_dispute!( connectors::Zsl ); +macro_rules! default_imp_for_new_connector_integration_submit_evidence { + ($($path:ident::$connector:ident),*) => { + $( + impl SubmitEvidenceV2 for $path::$connector {} + impl + ConnectorIntegrationV2< + Evidence, + DisputesFlowData, + SubmitEvidenceRequestData, + SubmitEvidenceResponse, + > for $path::$connector + {} + )* + }; +} + +default_imp_for_new_connector_integration_submit_evidence!( + connectors::Vgs, + connectors::Aci, + connectors::Adyen, + connectors::Adyenplatform, + connectors::Affirm, + connectors::Airwallex, + connectors::Amazonpay, + connectors::Authipay, + connectors::Authorizedotnet, + connectors::Bambora, + connectors::Bamboraapac, + connectors::Bankofamerica, + connectors::Barclaycard, + connectors::Billwerk, + connectors::Bitpay, + connectors::Bluesnap, + connectors::Bluecode, + connectors::Blackhawknetwork, + connectors::Braintree, + connectors::Breadpay, + connectors::Boku, + connectors::Cashtocode, + connectors::Celero, + connectors::Chargebee, + connectors::Checkbook, + connectors::Checkout, + connectors::Coinbase, + connectors::Coingate, + connectors::Cryptopay, + connectors::CtpMastercard, + connectors::Custombilling, + connectors::Datatrans, + connectors::Deutschebank, + connectors::Digitalvirgo, + connectors::Dlocal, + connectors::Dwolla, + connectors::Ebanx, + connectors::Elavon, + connectors::Facilitapay, + connectors::Fiserv, + connectors::Fiservemea, + connectors::Fiuu, + connectors::Flexiti, + connectors::Forte, + connectors::Getnet, + connectors::Globalpay, + connectors::Globepay, + connectors::Gocardless, + connectors::Gpayments, + connectors::Hipay, + connectors::Helcim, + connectors::HyperswitchVault, + connectors::Iatapay, + connectors::Inespay, + connectors::Itaubank, + connectors::Jpmorgan, + connectors::Juspaythreedsserver, + connectors::Klarna, + connectors::Katapult, + connectors::Nomupay, + connectors::Noon, + connectors::Nordea, + connectors::Novalnet, + connectors::Netcetera, + connectors::Nexinets, + connectors::Nexixpay, + connectors::Nmi, + connectors::Payone, + connectors::Opayo, + connectors::Opennode, + connectors::Nuvei, + connectors::Paybox, + connectors::Payeezy, + connectors::Payload, + connectors::Payme, + connectors::Paypal, + connectors::Paystack, + connectors::Payu, + connectors::Paytm, + connectors::Placetopay, + connectors::Plaid, + connectors::Phonepe, + connectors::Powertranz, + connectors::Prophetpay, + connectors::Mifinity, + connectors::Mollie, + connectors::Moneris, + connectors::Mpgs, + connectors::Multisafepay, + connectors::Rapyd, + connectors::Razorpay, + connectors::Recurly, + connectors::Redsys, + connectors::Riskified, + connectors::Santander, + connectors::Shift4, + connectors::Silverflow, + connectors::Signifyd, + connectors::Stax, + connectors::Stripe, + connectors::Square, + connectors::Stripebilling, + connectors::Taxjar, + connectors::Threedsecureio, + connectors::Thunes, + connectors::Tokenio, + connectors::Trustpay, + connectors::Trustpayments, + connectors::Tsys, + connectors::UnifiedAuthenticationService, + connectors::Wise, + connectors::Worldline, + connectors::Volt, + connectors::Worldpay, + connectors::Worldpayvantiv, + connectors::Worldpayxml, + connectors::Wellsfargo, + connectors::Wellsfargopayout, + connectors::Xendit, + connectors::Zen, + connectors::Zsl +); + macro_rules! default_imp_for_new_connector_integration_file_upload { ($($path:ident::$connector:ident),*) => { $( diff --git a/crates/hyperswitch_connectors/src/types.rs b/crates/hyperswitch_connectors/src/types.rs index 6136d281ae..ae702122e1 100644 --- a/crates/hyperswitch_connectors/src/types.rs +++ b/crates/hyperswitch_connectors/src/types.rs @@ -8,8 +8,8 @@ use hyperswitch_domain_models::{ authentication::{ Authentication, PostAuthentication, PreAuthentication, PreAuthenticationVersionCall, }, - Accept, AccessTokenAuth, Authorize, Capture, CreateOrder, Defend, Evidence, PSync, - PostProcessing, PreProcessing, Retrieve, Session, Upload, Void, + Accept, AccessTokenAuth, Authorize, Capture, CreateOrder, Defend, Dsync, Evidence, Fetch, + PSync, PostProcessing, PreProcessing, Retrieve, Session, Upload, Void, }, router_request_types::{ authentication::{ @@ -17,15 +17,15 @@ use hyperswitch_domain_models::{ PreAuthNRequestData, }, AcceptDisputeRequestData, AccessTokenRequestData, CreateOrderRequestData, - DefendDisputeRequestData, PaymentsAuthorizeData, PaymentsCancelData, PaymentsCaptureData, - PaymentsPostProcessingData, PaymentsPreProcessingData, PaymentsSessionData, - PaymentsSyncData, RefundsData, RetrieveFileRequestData, SubmitEvidenceRequestData, - UploadFileRequestData, + DefendDisputeRequestData, DisputeSyncData, FetchDisputesRequestData, PaymentsAuthorizeData, + PaymentsCancelData, PaymentsCaptureData, PaymentsPostProcessingData, + PaymentsPreProcessingData, PaymentsSessionData, PaymentsSyncData, RefundsData, + RetrieveFileRequestData, SubmitEvidenceRequestData, UploadFileRequestData, }, router_response_types::{ AcceptDisputeResponse, AuthenticationResponseData, DefendDisputeResponse, - PaymentsResponseData, RefundsResponseData, RetrieveFileResponse, SubmitEvidenceResponse, - UploadFileResponse, + DisputeSyncResponse, FetchDisputesResponse, PaymentsResponseData, RefundsResponseData, + RetrieveFileResponse, SubmitEvidenceResponse, UploadFileResponse, }, }; #[cfg(feature = "frm")] @@ -67,6 +67,9 @@ pub(crate) type UploadFileRouterData = RouterData; pub(crate) type DefendDisputeRouterData = RouterData; +pub(crate) type FetchDisputeRouterData = + RouterData; +pub(crate) type DisputeSyncRouterData = RouterData; #[cfg(feature = "payouts")] pub(crate) type PayoutsResponseRouterData = diff --git a/crates/hyperswitch_domain_models/src/business_profile.rs b/crates/hyperswitch_domain_models/src/business_profile.rs index c5a78c6f39..1ea3973313 100644 --- a/crates/hyperswitch_domain_models/src/business_profile.rs +++ b/crates/hyperswitch_domain_models/src/business_profile.rs @@ -83,6 +83,7 @@ pub struct Profile { pub acquirer_config_map: Option, pub merchant_category_code: Option, pub merchant_country_code: Option, + pub dispute_polling_interval: Option, } #[cfg(feature = "v1")] @@ -138,6 +139,7 @@ pub struct ProfileSetter { pub is_pre_network_tokenization_enabled: bool, pub merchant_category_code: Option, pub merchant_country_code: Option, + pub dispute_polling_interval: Option, } #[cfg(feature = "v1")] @@ -200,6 +202,7 @@ impl From for Profile { acquirer_config_map: None, merchant_category_code: value.merchant_category_code, merchant_country_code: value.merchant_country_code, + dispute_polling_interval: value.dispute_polling_interval, } } } @@ -262,6 +265,7 @@ pub struct ProfileGeneralUpdate { pub is_pre_network_tokenization_enabled: Option, pub merchant_category_code: Option, pub merchant_country_code: Option, + pub dispute_polling_interval: Option, } #[cfg(feature = "v1")] @@ -343,6 +347,7 @@ impl From for ProfileUpdateInternal { is_pre_network_tokenization_enabled, merchant_category_code, merchant_country_code, + dispute_polling_interval, } = *update; Self { @@ -395,6 +400,7 @@ impl From for ProfileUpdateInternal { acquirer_config_map: None, merchant_category_code, merchant_country_code, + dispute_polling_interval, } } ProfileUpdate::RoutingAlgorithmUpdate { @@ -450,6 +456,7 @@ impl From for ProfileUpdateInternal { acquirer_config_map: None, merchant_category_code: None, merchant_country_code: None, + dispute_polling_interval: None, }, ProfileUpdate::DynamicRoutingAlgorithmUpdate { dynamic_routing_algorithm, @@ -502,6 +509,7 @@ impl From for ProfileUpdateInternal { acquirer_config_map: None, merchant_category_code: None, merchant_country_code: None, + dispute_polling_interval: None, }, ProfileUpdate::ExtendedCardInfoUpdate { is_extended_card_info_enabled, @@ -554,6 +562,7 @@ impl From for ProfileUpdateInternal { acquirer_config_map: None, merchant_category_code: None, merchant_country_code: None, + dispute_polling_interval: None, }, ProfileUpdate::ConnectorAgnosticMitUpdate { is_connector_agnostic_mit_enabled, @@ -606,6 +615,7 @@ impl From for ProfileUpdateInternal { acquirer_config_map: None, merchant_category_code: None, merchant_country_code: None, + dispute_polling_interval: None, }, ProfileUpdate::NetworkTokenizationUpdate { is_network_tokenization_enabled, @@ -658,6 +668,7 @@ impl From for ProfileUpdateInternal { acquirer_config_map: None, merchant_category_code: None, merchant_country_code: None, + dispute_polling_interval: None, }, ProfileUpdate::CardTestingSecretKeyUpdate { card_testing_secret_key, @@ -710,6 +721,7 @@ impl From for ProfileUpdateInternal { acquirer_config_map: None, merchant_category_code: None, merchant_country_code: None, + dispute_polling_interval: None, }, ProfileUpdate::AcquirerConfigMapUpdate { acquirer_config_map, @@ -762,6 +774,7 @@ impl From for ProfileUpdateInternal { acquirer_config_map, merchant_category_code: None, merchant_country_code: None, + dispute_polling_interval: None, }, } } @@ -834,6 +847,7 @@ impl super::behaviour::Conversion for Profile { acquirer_config_map: self.acquirer_config_map, merchant_category_code: self.merchant_category_code, merchant_country_code: self.merchant_country_code, + dispute_polling_interval: self.dispute_polling_interval, }) } @@ -932,6 +946,7 @@ impl super::behaviour::Conversion for Profile { acquirer_config_map: item.acquirer_config_map, merchant_category_code: item.merchant_category_code, merchant_country_code: item.merchant_country_code, + dispute_polling_interval: item.dispute_polling_interval, }) } .await @@ -997,6 +1012,7 @@ impl super::behaviour::Conversion for Profile { is_pre_network_tokenization_enabled: Some(self.is_pre_network_tokenization_enabled), merchant_category_code: self.merchant_category_code, merchant_country_code: self.merchant_country_code, + dispute_polling_interval: self.dispute_polling_interval, }) } } @@ -2063,6 +2079,7 @@ impl super::behaviour::Conversion for Profile { acquirer_config_map: None, merchant_category_code: self.merchant_category_code, merchant_country_code: self.merchant_country_code, + dispute_polling_interval: None, }) } diff --git a/crates/hyperswitch_domain_models/src/connector_endpoints.rs b/crates/hyperswitch_domain_models/src/connector_endpoints.rs index ad80bdb9ea..d9691d8382 100644 --- a/crates/hyperswitch_domain_models/src/connector_endpoints.rs +++ b/crates/hyperswitch_domain_models/src/connector_endpoints.rs @@ -133,7 +133,7 @@ pub struct Connectors { pub wise: ConnectorParams, pub worldline: ConnectorParams, pub worldpay: ConnectorParams, - pub worldpayvantiv: ConnectorParamsWithSecondaryBaseUrl, + pub worldpayvantiv: ConnectorParamsWithThreeUrls, pub worldpayxml: ConnectorParams, pub xendit: ConnectorParams, pub zen: ConnectorParams, diff --git a/crates/hyperswitch_domain_models/src/merchant_connector_account.rs b/crates/hyperswitch_domain_models/src/merchant_connector_account.rs index b7c3dda604..0b1beda8dd 100644 --- a/crates/hyperswitch_domain_models/src/merchant_connector_account.rs +++ b/crates/hyperswitch_domain_models/src/merchant_connector_account.rs @@ -89,6 +89,10 @@ impl MerchantConnectorAccount { pub fn get_connector_name_as_string(&self) -> String { self.connector_name.clone() } + + pub fn get_metadata(&self) -> Option> { + self.metadata.clone() + } } #[cfg(feature = "v2")] diff --git a/crates/hyperswitch_domain_models/src/router_flow_types/dispute.rs b/crates/hyperswitch_domain_models/src/router_flow_types/dispute.rs index ec13cae516..cba10bb4ab 100644 --- a/crates/hyperswitch_domain_models/src/router_flow_types/dispute.rs +++ b/crates/hyperswitch_domain_models/src/router_flow_types/dispute.rs @@ -5,3 +5,9 @@ pub struct Evidence; #[derive(Debug, Clone)] pub struct Defend; + +#[derive(Debug, Clone)] +pub struct Fetch; + +#[derive(Debug, Clone)] +pub struct Dsync; diff --git a/crates/hyperswitch_domain_models/src/router_request_types.rs b/crates/hyperswitch_domain_models/src/router_request_types.rs index 6205c54633..59269fb68b 100644 --- a/crates/hyperswitch_domain_models/src/router_request_types.rs +++ b/crates/hyperswitch_domain_models/src/router_request_types.rs @@ -851,6 +851,7 @@ impl TryFrom for AccessTokenRequestData { pub struct AcceptDisputeRequestData { pub dispute_id: String, pub connector_dispute_id: String, + pub dispute_status: storage_enums::DisputeStatus, } #[derive(Default, Debug, Clone)] @@ -862,6 +863,7 @@ pub struct DefendDisputeRequestData { #[derive(Default, Debug, Clone)] pub struct SubmitEvidenceRequestData { pub dispute_id: String, + pub dispute_status: storage_enums::DisputeStatus, pub connector_dispute_id: String, pub access_activity_log: Option, pub billing_address: Option, @@ -921,9 +923,17 @@ pub struct SubmitEvidenceRequestData { pub uncategorized_file_provider_file_id: Option, pub uncategorized_text: Option, } + +#[derive(Debug, Serialize, Clone)] +pub struct FetchDisputesRequestData { + pub created_from: time::PrimitiveDateTime, + pub created_till: time::PrimitiveDateTime, +} + #[derive(Clone, Debug)] pub struct RetrieveFileRequestData { pub provider_file_id: String, + pub connector_dispute_id: Option, } #[serde_as] @@ -935,6 +945,8 @@ pub struct UploadFileRequestData { #[serde_as(as = "serde_with::DisplayFromStr")] pub file_type: mime::Mime, pub file_size: i32, + pub dispute_id: String, + pub connector_dispute_id: String, } #[cfg(feature = "payouts")] @@ -1051,3 +1063,9 @@ pub struct VaultRequestData { pub connector_vault_id: Option, pub connector_customer_id: Option, } + +#[derive(Debug, Serialize, Clone)] +pub struct DisputeSyncData { + pub dispute_id: String, + pub connector_dispute_id: String, +} diff --git a/crates/hyperswitch_domain_models/src/router_response_types.rs b/crates/hyperswitch_domain_models/src/router_response_types.rs index b5181dee31..f4b40e2355 100644 --- a/crates/hyperswitch_domain_models/src/router_response_types.rs +++ b/crates/hyperswitch_domain_models/src/router_response_types.rs @@ -4,7 +4,10 @@ pub mod revenue_recovery; use std::collections::HashMap; use common_utils::{pii, request::Method, types::MinorUnit}; -pub use disputes::{AcceptDisputeResponse, DefendDisputeResponse, SubmitEvidenceResponse}; +pub use disputes::{ + AcceptDisputeResponse, DefendDisputeResponse, DisputeSyncResponse, FetchDisputesResponse, + SubmitEvidenceResponse, +}; use crate::{ errors::api_error_response::ApiErrorResponse, diff --git a/crates/hyperswitch_domain_models/src/router_response_types/disputes.rs b/crates/hyperswitch_domain_models/src/router_response_types/disputes.rs index 3eb50e59ca..8a4cc0a0a0 100644 --- a/crates/hyperswitch_domain_models/src/router_response_types/disputes.rs +++ b/crates/hyperswitch_domain_models/src/router_response_types/disputes.rs @@ -21,3 +21,21 @@ pub struct FileInfo { pub provider_file_id: Option, pub file_type: Option, } + +#[derive(serde::Serialize, serde::Deserialize, Clone, Debug)] +pub struct DisputeSyncResponse { + pub object_reference_id: api_models::webhooks::ObjectReferenceId, + pub amount: common_utils::types::StringMinorUnit, + pub currency: common_enums::enums::Currency, + pub dispute_stage: common_enums::enums::DisputeStage, + pub dispute_status: api_models::enums::DisputeStatus, + pub connector_status: String, + pub connector_dispute_id: String, + pub connector_reason: Option, + pub connector_reason_code: Option, + pub challenge_required_by: Option, + pub created_at: Option, + pub updated_at: Option, +} + +pub type FetchDisputesResponse = Vec; diff --git a/crates/hyperswitch_interfaces/src/api/disputes.rs b/crates/hyperswitch_interfaces/src/api/disputes.rs index 3ff5d6fb95..fb76d2af57 100644 --- a/crates/hyperswitch_interfaces/src/api/disputes.rs +++ b/crates/hyperswitch_interfaces/src/api/disputes.rs @@ -1,11 +1,15 @@ //! Disputes interface use hyperswitch_domain_models::{ - router_flow_types::dispute::{Accept, Defend, Evidence}, + router_flow_types::dispute::{Accept, Defend, Dsync, Evidence, Fetch}, router_request_types::{ - AcceptDisputeRequestData, DefendDisputeRequestData, SubmitEvidenceRequestData, + AcceptDisputeRequestData, DefendDisputeRequestData, DisputeSyncData, + FetchDisputesRequestData, SubmitEvidenceRequestData, + }, + router_response_types::{ + AcceptDisputeResponse, DefendDisputeResponse, DisputeSyncResponse, FetchDisputesResponse, + SubmitEvidenceResponse, }, - router_response_types::{AcceptDisputeResponse, DefendDisputeResponse, SubmitEvidenceResponse}, }; use crate::api::ConnectorIntegration; @@ -29,4 +33,21 @@ pub trait DefendDispute: } /// trait Dispute -pub trait Dispute: super::ConnectorCommon + AcceptDispute + SubmitEvidence + DefendDispute {} +pub trait Dispute: + super::ConnectorCommon + + AcceptDispute + + SubmitEvidence + + DefendDispute + + FetchDisputes + + DisputeSync +{ +} + +/// trait FetchDisputes +pub trait FetchDisputes: + ConnectorIntegration +{ +} + +/// trait SyncDisputes +pub trait DisputeSync: ConnectorIntegration {} diff --git a/crates/hyperswitch_interfaces/src/api/disputes_v2.rs b/crates/hyperswitch_interfaces/src/api/disputes_v2.rs index b038b141d9..b6cd194367 100644 --- a/crates/hyperswitch_interfaces/src/api/disputes_v2.rs +++ b/crates/hyperswitch_interfaces/src/api/disputes_v2.rs @@ -1,11 +1,15 @@ //! Disputes V2 interface use hyperswitch_domain_models::{ router_data_v2::DisputesFlowData, - router_flow_types::dispute::{Accept, Defend, Evidence}, + router_flow_types::dispute::{Accept, Defend, Dsync, Evidence, Fetch}, router_request_types::{ - AcceptDisputeRequestData, DefendDisputeRequestData, SubmitEvidenceRequestData, + AcceptDisputeRequestData, DefendDisputeRequestData, DisputeSyncData, + FetchDisputesRequestData, SubmitEvidenceRequestData, + }, + router_response_types::{ + AcceptDisputeResponse, DefendDisputeResponse, DisputeSyncResponse, FetchDisputesResponse, + SubmitEvidenceResponse, }, - router_response_types::{AcceptDisputeResponse, DefendDisputeResponse, SubmitEvidenceResponse}, }; use crate::api::ConnectorIntegrationV2; @@ -35,6 +39,23 @@ pub trait DefendDisputeV2: /// trait DisputeV2 pub trait DisputeV2: - super::ConnectorCommon + AcceptDisputeV2 + SubmitEvidenceV2 + DefendDisputeV2 + super::ConnectorCommon + + AcceptDisputeV2 + + SubmitEvidenceV2 + + DefendDisputeV2 + + FetchDisputesV2 + + DisputeSyncV2 +{ +} + +/// trait FetchDisputeV2 +pub trait FetchDisputesV2: + ConnectorIntegrationV2 +{ +} + +/// trait DisputeSyncV2 +pub trait DisputeSyncV2: + ConnectorIntegrationV2 { } diff --git a/crates/hyperswitch_interfaces/src/disputes.rs b/crates/hyperswitch_interfaces/src/disputes.rs index 55965fb2c6..6c89c6dfc7 100644 --- a/crates/hyperswitch_interfaces/src/disputes.rs +++ b/crates/hyperswitch_interfaces/src/disputes.rs @@ -1,5 +1,6 @@ //! Disputes interface use common_utils::types::StringMinorUnit; +use hyperswitch_domain_models::router_response_types::DisputeSyncResponse; use time::PrimitiveDateTime; /// struct DisputePayload @@ -26,3 +27,20 @@ pub struct DisputePayload { /// updated_at pub updated_at: Option, } + +impl From for DisputePayload { + fn from(dispute_sync_data: DisputeSyncResponse) -> Self { + Self { + amount: dispute_sync_data.amount, + currency: dispute_sync_data.currency, + dispute_stage: dispute_sync_data.dispute_stage, + connector_status: dispute_sync_data.connector_status, + connector_dispute_id: dispute_sync_data.connector_dispute_id, + connector_reason: dispute_sync_data.connector_reason, + connector_reason_code: dispute_sync_data.connector_reason_code, + challenge_required_by: dispute_sync_data.challenge_required_by, + created_at: dispute_sync_data.created_at, + updated_at: dispute_sync_data.updated_at, + } + } +} diff --git a/crates/hyperswitch_interfaces/src/types.rs b/crates/hyperswitch_interfaces/src/types.rs index c23a4c6c6c..04053546c9 100644 --- a/crates/hyperswitch_interfaces/src/types.rs +++ b/crates/hyperswitch_interfaces/src/types.rs @@ -5,7 +5,7 @@ use hyperswitch_domain_models::{ router_data_v2::flow_common_types, router_flow_types::{ access_token_auth::AccessTokenAuth, - dispute::{Accept, Defend, Evidence}, + dispute::{Accept, Defend, Dsync, Evidence, Fetch}, files::{Retrieve, Upload}, mandate_revoke::MandateRevoke, payments::{ @@ -38,9 +38,10 @@ use hyperswitch_domain_models::{ }, AcceptDisputeRequestData, AccessTokenRequestData, AuthorizeSessionTokenData, CompleteAuthorizeData, ConnectorCustomerData, CreateOrderRequestData, - DefendDisputeRequestData, MandateRevokeRequestData, PaymentMethodTokenizationData, - PaymentsAuthorizeData, PaymentsCancelData, PaymentsCancelPostCaptureData, - PaymentsCaptureData, PaymentsIncrementalAuthorizationData, PaymentsPostProcessingData, + DefendDisputeRequestData, DisputeSyncData, FetchDisputesRequestData, + MandateRevokeRequestData, PaymentMethodTokenizationData, PaymentsAuthorizeData, + PaymentsCancelData, PaymentsCancelPostCaptureData, PaymentsCaptureData, + PaymentsIncrementalAuthorizationData, PaymentsPostProcessingData, PaymentsPostSessionTokensData, PaymentsPreProcessingData, PaymentsSessionData, PaymentsSyncData, PaymentsTaxCalculationData, PaymentsUpdateMetadataData, RefundsData, RetrieveFileRequestData, SdkPaymentsSessionUpdateData, SetupMandateRequestData, @@ -52,9 +53,9 @@ use hyperswitch_domain_models::{ BillingConnectorInvoiceSyncResponse, BillingConnectorPaymentsSyncResponse, RevenueRecoveryRecordBackResponse, }, - AcceptDisputeResponse, DefendDisputeResponse, MandateRevokeResponseData, - PaymentsResponseData, RefundsResponseData, RetrieveFileResponse, SubmitEvidenceResponse, - TaxCalculationResponseData, UploadFileResponse, VaultResponseData, + AcceptDisputeResponse, DefendDisputeResponse, DisputeSyncResponse, FetchDisputesResponse, + MandateRevokeResponseData, PaymentsResponseData, RefundsResponseData, RetrieveFileResponse, + SubmitEvidenceResponse, TaxCalculationResponseData, UploadFileResponse, VaultResponseData, VerifyWebhookSourceResponseData, }, }; @@ -222,6 +223,13 @@ pub type RetrieveFileType = pub type DefendDisputeType = dyn ConnectorIntegration; +/// Type alias for `ConnectorIntegration` +pub type FetchDisputesType = + dyn ConnectorIntegration; + +/// Type alias for `ConnectorIntegration` +pub type DisputeSyncType = dyn ConnectorIntegration; + /// Type alias for `ConnectorIntegration` pub type UasPreAuthenticationType = dyn ConnectorIntegration< PreAuthenticate, diff --git a/crates/openapi/src/routes/disputes.rs b/crates/openapi/src/routes/disputes.rs index 977f0b775f..2fa9c23f22 100644 --- a/crates/openapi/src/routes/disputes.rs +++ b/crates/openapi/src/routes/disputes.rs @@ -4,7 +4,8 @@ get, path = "/disputes/{dispute_id}", params( - ("dispute_id" = String, Path, description = "The identifier for dispute") + ("dispute_id" = String, Path, description = "The identifier for dispute"), + ("force_sync" = Option, Query, description = "Decider to enable or disable the connector call for dispute retrieve request"), ), responses( (status = 200, description = "The dispute was retrieved successfully", body = DisputeResponse), diff --git a/crates/router/src/bin/scheduler.rs b/crates/router/src/bin/scheduler.rs index 747cc1d4ad..492c07e5ab 100644 --- a/crates/router/src/bin/scheduler.rs +++ b/crates/router/src/bin/scheduler.rs @@ -281,6 +281,12 @@ impl ProcessTrackerWorkflows for WorkflowRunner { storage::ProcessTrackerRunner::RefundWorkflowRouter => { Ok(Box::new(workflows::refund_router::RefundWorkflowRouter)) } + storage::ProcessTrackerRunner::ProcessDisputeWorkflow => { + Ok(Box::new(workflows::process_dispute::ProcessDisputeWorkflow)) + } + storage::ProcessTrackerRunner::DisputeListWorkflow => { + Ok(Box::new(workflows::dispute_list::DisputeListWorkflow)) + } storage::ProcessTrackerRunner::DeleteTokenizeDataWorkflow => Ok(Box::new( workflows::tokenized_data::DeleteTokenizeDataWorkflow, )), diff --git a/crates/router/src/configs/secrets_transformers.rs b/crates/router/src/configs/secrets_transformers.rs index ac14bdc1fc..3f85f8e2b3 100644 --- a/crates/router/src/configs/secrets_transformers.rs +++ b/crates/router/src/configs/secrets_transformers.rs @@ -491,6 +491,7 @@ pub(crate) async fn fetch_raw_secrets( zero_mandates: conf.zero_mandates, network_transaction_id_supported_connectors: conf .network_transaction_id_supported_connectors, + list_dispute_supported_connectors: conf.list_dispute_supported_connectors, required_fields: conf.required_fields, delayed_session_response: conf.delayed_session_response, webhook_source_verification_call: conf.webhook_source_verification_call, diff --git a/crates/router/src/configs/settings.rs b/crates/router/src/configs/settings.rs index b175e9c266..6bc5b35d2b 100644 --- a/crates/router/src/configs/settings.rs +++ b/crates/router/src/configs/settings.rs @@ -107,6 +107,7 @@ pub struct Settings { pub mandates: Mandates, pub zero_mandates: ZeroMandates, pub network_transaction_id_supported_connectors: NetworkTransactionIdSupportedConnectors, + pub list_dispute_supported_connectors: ListDiputeSupportedConnectors, pub required_fields: RequiredFields, pub delayed_session_response: DelayedSessionConfig, pub webhook_source_verification_call: WebhookSourceVerificationCall, @@ -602,6 +603,13 @@ pub struct NetworkTransactionIdSupportedConnectors { pub connector_list: HashSet, } +/// Connectors that support only dispute list API for syncing disputes with Hyperswitch +#[derive(Debug, Deserialize, Clone, Default)] +pub struct ListDiputeSupportedConnectors { + #[serde(deserialize_with = "deserialize_hashset")] + pub connector_list: HashSet, +} + #[derive(Debug, Deserialize, Clone, Default)] pub struct NetworkTokenizationSupportedCardNetworks { #[serde(deserialize_with = "deserialize_hashset")] diff --git a/crates/router/src/consts.rs b/crates/router/src/consts.rs index a6c0954f8f..3907592d40 100644 --- a/crates/router/src/consts.rs +++ b/crates/router/src/consts.rs @@ -274,6 +274,10 @@ pub const IRRELEVANT_PAYMENT_INTENT_ID: &str = "irrelevant_payment_intent_id"; /// Default payment attempt id pub const IRRELEVANT_PAYMENT_ATTEMPT_ID: &str = "irrelevant_payment_attempt_id"; +/// Default payment attempt id +pub const IRRELEVANT_CONNECTOR_REQUEST_REFERENCE_ID: &str = + "irrelevant_connector_request_reference_id"; + // Default payment method storing TTL in redis in seconds pub const DEFAULT_PAYMENT_METHOD_STORE_TTL: i64 = 86400; // 1 day diff --git a/crates/router/src/core/admin.rs b/crates/router/src/core/admin.rs index c116f266df..8c6165c48e 100644 --- a/crates/router/src/core/admin.rs +++ b/crates/router/src/core/admin.rs @@ -29,6 +29,7 @@ use crate::{ consts, core::{ connector_validation::ConnectorAuthTypeAndMetadataValidation, + disputes, encryption::transfer_encryption_key, errors::{self, RouterResponse, RouterResult, StorageErrorExt}, payment_methods::{cards, transformers}, @@ -37,6 +38,7 @@ use crate::{ routing, utils as core_utils, }, db::{AccountsStorageInterface, StorageInterface}, + logger, routes::{metrics, SessionState}, services::{ self, @@ -675,7 +677,7 @@ impl CreateProfile { ) .await .map_err(|profile_insert_error| { - crate::logger::warn!("Profile already exists {profile_insert_error:?}"); + logger::warn!("Profile already exists {profile_insert_error:?}"); }) .map(|business_profile| business_profiles_vector.push(business_profile)) .ok(); @@ -911,7 +913,7 @@ pub async fn create_profile_from_business_labels( .await .map_err(|profile_insert_error| { // If there is any duplicate error, we need not take any action - crate::logger::warn!("Profile already exists {profile_insert_error:?}"); + logger::warn!("Profile already exists {profile_insert_error:?}"); }); // If a profile is created, then unset the default profile @@ -1231,7 +1233,7 @@ pub async fn merchant_account_delete( .await .transpose() .map_err(|err| { - crate::logger::error!("Failed to delete merchant in Decision Engine {err:?}"); + logger::error!("Failed to delete merchant in Decision Engine {err:?}"); }) .ok(); } @@ -1256,7 +1258,7 @@ pub async fn merchant_account_delete( Ok(_) => Ok::<_, errors::ApiErrorResponse>(()), Err(err) => { if err.current_context().is_db_not_found() { - crate::logger::error!("requires_cvv config not found in db: {err:?}"); + logger::error!("requires_cvv config not found in db: {err:?}"); Ok(()) } else { Err(err @@ -2594,6 +2596,9 @@ pub async fn create_connector( }, )?; + #[cfg(feature = "v1")] + disputes::schedule_dispute_sync_task(&state, &business_profile, &mca).await?; + #[cfg(feature = "v1")] //update merchant default config let merchant_default_config_update = MerchantDefaultConfigUpdate { @@ -3489,6 +3494,7 @@ impl ProfileCreateBridge for api::ProfileCreate { .unwrap_or_default(), merchant_category_code: self.merchant_category_code, merchant_country_code: self.merchant_country_code, + dispute_polling_interval: self.dispute_polling_interval, })) } @@ -3982,6 +3988,7 @@ impl ProfileUpdateBridge for api::ProfileUpdate { is_pre_network_tokenization_enabled: self.is_pre_network_tokenization_enabled, merchant_category_code: self.merchant_category_code, merchant_country_code: self.merchant_country_code, + dispute_polling_interval: self.dispute_polling_interval, }, ))) } diff --git a/crates/router/src/core/disputes.rs b/crates/router/src/core/disputes.rs index 873401d771..82e8548408 100644 --- a/crates/router/src/core/disputes.rs +++ b/crates/router/src/core/disputes.rs @@ -1,11 +1,14 @@ -use std::collections::HashMap; +use std::{collections::HashMap, ops::Deref, str::FromStr}; use api_models::{ admin::MerchantConnectorInfo, disputes as dispute_models, files as files_api_models, }; use common_utils::ext_traits::{Encode, ValueExt}; use error_stack::ResultExt; -use router_env::{instrument, tracing}; +use router_env::{ + instrument, logger, + tracing::{self, Instrument}, +}; use strum::IntoEnumIterator; pub mod transformers; @@ -14,25 +17,41 @@ use super::{ metrics, }; use crate::{ - core::{files, payments, utils as core_utils}, - routes::SessionState, + core::{files, payments, utils as core_utils, webhooks}, + routes::{app::StorageInterface, metrics::TASKS_ADDED_COUNT, SessionState}, services, types::{ api::{self, disputes}, domain, storage::enums as storage_enums, - transformers::ForeignFrom, + transformers::{ForeignFrom, ForeignInto}, AcceptDisputeRequestData, AcceptDisputeResponse, DefendDisputeRequestData, - DefendDisputeResponse, SubmitEvidenceRequestData, SubmitEvidenceResponse, + DefendDisputeResponse, DisputePayload, DisputeSyncData, DisputeSyncResponse, + FetchDisputesRequestData, FetchDisputesResponse, SubmitEvidenceRequestData, + SubmitEvidenceResponse, }, + workflows::process_dispute, }; +pub(crate) fn should_call_connector_for_dispute_sync( + force_sync: Option, + dispute_status: storage_enums::DisputeStatus, +) -> bool { + force_sync == Some(true) + && matches!( + dispute_status, + common_enums::DisputeStatus::DisputeAccepted + | common_enums::DisputeStatus::DisputeChallenged + | common_enums::DisputeStatus::DisputeOpened + ) +} + #[instrument(skip(state))] pub async fn retrieve_dispute( state: SessionState, merchant_context: domain::MerchantContext, profile_id: Option, - req: disputes::DisputeId, + req: dispute_models::DisputeRetrieveRequest, ) -> RouterResponse { let dispute = state .store @@ -44,8 +63,105 @@ pub async fn retrieve_dispute( .to_not_found_response(errors::ApiErrorResponse::DisputeNotFound { dispute_id: req.dispute_id, })?; - core_utils::validate_profile_id_from_auth_layer(profile_id, &dispute)?; + core_utils::validate_profile_id_from_auth_layer(profile_id.clone(), &dispute)?; + + #[cfg(feature = "v1")] + let dispute_response = + if should_call_connector_for_dispute_sync(req.force_sync, dispute.dispute_status) { + let db = &state.store; + core_utils::validate_profile_id_from_auth_layer(profile_id.clone(), &dispute)?; + let payment_intent = db + .find_payment_intent_by_payment_id_merchant_id( + &(&state).into(), + &dispute.payment_id, + merchant_context.get_merchant_account().get_id(), + merchant_context.get_merchant_key_store(), + merchant_context.get_merchant_account().storage_scheme, + ) + .await + .change_context(errors::ApiErrorResponse::PaymentNotFound)?; + + let payment_attempt = db + .find_payment_attempt_by_attempt_id_merchant_id( + &dispute.attempt_id, + merchant_context.get_merchant_account().get_id(), + merchant_context.get_merchant_account().storage_scheme, + ) + .await + .change_context(errors::ApiErrorResponse::PaymentNotFound)?; + + let connector_data = api::ConnectorData::get_connector_by_name( + &state.conf.connectors, + &dispute.connector, + api::GetToken::Connector, + dispute.merchant_connector_id.clone(), + )?; + + let connector_integration: services::BoxedDisputeConnectorIntegrationInterface< + api::Dsync, + DisputeSyncData, + DisputeSyncResponse, + > = connector_data.connector.get_connector_integration(); + let router_data = core_utils::construct_dispute_sync_router_data( + &state, + &payment_intent, + &payment_attempt, + &merchant_context, + &dispute, + ) + .await?; + let response = services::execute_connector_processing_step( + &state, + connector_integration, + &router_data, + payments::CallConnectorAction::Trigger, + None, + None, + ) + .await + .to_dispute_failed_response() + .attach_printable("Failed while calling accept dispute connector api")?; + + let dispute_sync_response = response.response.map_err(|err| { + errors::ApiErrorResponse::ExternalConnectorError { + code: err.code, + message: err.message, + connector: dispute.connector.clone(), + status_code: err.status_code, + reason: err.reason, + } + })?; + + let business_profile = state + .store + .find_business_profile_by_profile_id( + &(&state).into(), + merchant_context.get_merchant_key_store(), + &payment_attempt.profile_id, + ) + .await + .to_not_found_response(errors::ApiErrorResponse::ProfileNotFound { + id: payment_attempt.profile_id.get_string_repr().to_owned(), + })?; + + update_dispute_data( + &state, + merchant_context, + business_profile, + Some(dispute.clone()), + dispute_sync_response, + payment_attempt, + dispute.connector.as_str(), + ) + .await + .attach_printable("Dispute update failed")? + } else { + api_models::disputes::DisputeResponse::foreign_from(dispute) + }; + + #[cfg(not(feature = "v1"))] let dispute_response = api_models::disputes::DisputeResponse::foreign_from(dispute); + Ok(services::ApplicationResponse::Json(dispute_response)) } @@ -280,8 +396,10 @@ pub async fn submit_evidence( core_utils::validate_profile_id_from_auth_layer(profile_id, &dispute)?; let dispute_id = dispute.dispute_id.clone(); common_utils::fp_utils::when( - !(dispute.dispute_stage == storage_enums::DisputeStage::Dispute - && dispute.dispute_status == storage_enums::DisputeStatus::DisputeOpened), + !core_utils::should_proceed_with_submit_evidence( + dispute.dispute_stage, + dispute.dispute_status, + ), || { metrics::EVIDENCE_SUBMISSION_DISPUTE_STATUS_VALIDATION_FAILURE_METRIC.add(1, &[]); Err(errors::ApiErrorResponse::DisputeStatusValidationFailed { @@ -600,3 +718,338 @@ pub async fn get_aggregates_for_disputes( }, )) } + +#[cfg(feature = "v1")] +#[instrument(skip(state))] +pub async fn connector_sync_disputes( + state: SessionState, + merchant_context: domain::MerchantContext, + merchant_connector_id: String, + payload: disputes::DisputeFetchQueryData, +) -> RouterResponse { + let connector_id = + common_utils::id_type::MerchantConnectorAccountId::wrap(merchant_connector_id) + .change_context(errors::ApiErrorResponse::InternalServerError) + .attach_printable("Failed to parse merchant connector account id format")?; + let format = time::format_description::parse("[year]-[month]-[day]T[hour]:[minute]:[second]") + .change_context(errors::ApiErrorResponse::InternalServerError) + .attach_printable("Failed to parse the date-time format")?; + let created_from = time::PrimitiveDateTime::parse(&payload.fetch_from, &format) + .change_context(errors::ApiErrorResponse::InvalidDataFormat { + field_name: "fetch_from".to_string(), + expected_format: "YYYY-MM-DDTHH:MM:SS".to_string(), + })?; + let created_till = time::PrimitiveDateTime::parse(&payload.fetch_till, &format) + .change_context(errors::ApiErrorResponse::InvalidDataFormat { + field_name: "fetch_till".to_string(), + expected_format: "YYYY-MM-DDTHH:MM:SS".to_string(), + })?; + let fetch_dispute_request = FetchDisputesRequestData { + created_from, + created_till, + }; + Box::pin(fetch_disputes_from_connector( + state, + merchant_context, + connector_id, + fetch_dispute_request, + )) + .await +} + +#[cfg(feature = "v1")] +#[instrument(skip(state))] +pub async fn fetch_disputes_from_connector( + state: SessionState, + merchant_context: domain::MerchantContext, + merchant_connector_id: common_utils::id_type::MerchantConnectorAccountId, + req: FetchDisputesRequestData, +) -> RouterResponse { + let db = &*state.store; + let key_manager_state = &(&state).into(); + let merchant_id = merchant_context.get_merchant_account().get_id(); + let merchant_connector_account = db + .find_by_merchant_connector_account_merchant_id_merchant_connector_id( + key_manager_state, + merchant_id, + &merchant_connector_id, + merchant_context.get_merchant_key_store(), + ) + .await + .to_not_found_response(errors::ApiErrorResponse::MerchantConnectorAccountNotFound { + id: merchant_connector_id.get_string_repr().to_string(), + })?; + let connector_name = merchant_connector_account.connector_name.clone(); + let connector_data = api::ConnectorData::get_connector_by_name( + &state.conf.connectors, + &connector_name, + api::GetToken::Connector, + Some(merchant_connector_id.clone()), + )?; + let connector_integration: services::BoxedDisputeConnectorIntegrationInterface< + api::Fetch, + FetchDisputesRequestData, + FetchDisputesResponse, + > = connector_data.connector.get_connector_integration(); + + let router_data = + core_utils::construct_dispute_list_router_data(&state, merchant_connector_account, req) + .await?; + + let response = services::execute_connector_processing_step( + &state, + connector_integration, + &router_data, + payments::CallConnectorAction::Trigger, + None, + None, + ) + .await + .to_dispute_failed_response() + .attach_printable("Failed while calling accept dispute connector api")?; + let fetch_dispute_response = + response + .response + .map_err(|err| errors::ApiErrorResponse::ExternalConnectorError { + code: err.code, + message: err.message, + connector: connector_name.clone(), + status_code: err.status_code, + reason: err.reason, + })?; + + for dispute in &fetch_dispute_response { + // check if payment already exist + let payment_attempt = webhooks::incoming::get_payment_attempt_from_object_reference_id( + &state, + dispute.object_reference_id.clone(), + &merchant_context, + ) + .await; + + if payment_attempt.is_ok() { + let schedule_time = process_dispute::get_sync_process_schedule_time( + &*state.store, + &connector_name, + merchant_id, + 0, + ) + .await + .change_context(errors::ApiErrorResponse::InternalServerError) + .attach_printable("Failed while getting process schedule time")?; + + let response = add_process_dispute_task_to_pt( + db, + &connector_name, + dispute, + merchant_id.clone(), + schedule_time, + ) + .await; + + match response { + Err(report) + if report + .downcast_ref::() + .is_some_and(|error| { + matches!(error, errors::StorageError::DuplicateValue { .. }) + }) => + { + Ok(()) + } + Ok(_) => Ok(()), + Err(_) => Err(errors::ApiErrorResponse::InternalServerError) + .attach_printable("Failed while adding task to process tracker"), + }?; + } else { + router_env::logger::info!("Disputed payment does not exist in our records"); + } + } + + Ok(services::ApplicationResponse::Json(fetch_dispute_response)) +} + +#[cfg(feature = "v1")] +#[instrument(skip_all)] +pub async fn update_dispute_data( + state: &SessionState, + merchant_context: domain::MerchantContext, + business_profile: domain::Profile, + option_dispute: Option, + dispute_details: DisputeSyncResponse, + payment_attempt: domain::PaymentAttempt, + connector_name: &str, +) -> errors::CustomResult { + let dispute_data = DisputePayload::from(dispute_details.clone()); + let dispute_object = webhooks::incoming::get_or_update_dispute_object( + state.clone(), + option_dispute, + dispute_data, + merchant_context.get_merchant_account().get_id(), + &merchant_context.get_merchant_account().organization_id, + &payment_attempt, + dispute_details.dispute_status, + &business_profile, + connector_name, + ) + .await?; + let disputes_response: dispute_models::DisputeResponse = dispute_object.clone().foreign_into(); + let event_type: storage_enums::EventType = dispute_details.dispute_status.into(); + + Box::pin(webhooks::create_event_and_trigger_outgoing_webhook( + state.clone(), + merchant_context, + business_profile, + event_type, + storage_enums::EventClass::Disputes, + dispute_object.dispute_id.clone(), + storage_enums::EventObjectType::DisputeDetails, + api::OutgoingWebhookContent::DisputeDetails(Box::new(disputes_response.clone())), + Some(dispute_object.created_at), + )) + .await?; + Ok(disputes_response) +} + +#[cfg(feature = "v1")] +pub async fn add_process_dispute_task_to_pt( + db: &dyn StorageInterface, + connector_name: &str, + dispute_payload: &DisputeSyncResponse, + merchant_id: common_utils::id_type::MerchantId, + schedule_time: Option, +) -> common_utils::errors::CustomResult<(), errors::StorageError> { + match schedule_time { + Some(time) => { + TASKS_ADDED_COUNT.add( + 1, + router_env::metric_attributes!(("flow", "dispute_process")), + ); + let tracking_data = disputes::ProcessDisputePTData { + connector_name: connector_name.to_string(), + dispute_payload: dispute_payload.clone(), + merchant_id: merchant_id.clone(), + }; + let runner = common_enums::ProcessTrackerRunner::ProcessDisputeWorkflow; + let task = "DISPUTE_PROCESS"; + let tag = ["PROCESS", "DISPUTE"]; + let process_tracker_id = scheduler::utils::get_process_tracker_id( + runner, + task, + &dispute_payload.connector_dispute_id.clone(), + &merchant_id, + ); + let process_tracker_entry = diesel_models::ProcessTrackerNew::new( + process_tracker_id, + task, + runner, + tag, + tracking_data, + None, + time, + common_types::consts::API_VERSION, + ) + .map_err(errors::StorageError::from)?; + db.insert_process(process_tracker_entry).await?; + Ok(()) + } + None => Ok(()), + } +} + +#[cfg(feature = "v1")] +pub async fn add_dispute_list_task_to_pt( + db: &dyn StorageInterface, + connector_name: &str, + merchant_id: common_utils::id_type::MerchantId, + merchant_connector_id: common_utils::id_type::MerchantConnectorAccountId, + profile_id: common_utils::id_type::ProfileId, + fetch_request: FetchDisputesRequestData, +) -> common_utils::errors::CustomResult<(), errors::StorageError> { + TASKS_ADDED_COUNT.add(1, router_env::metric_attributes!(("flow", "dispute_list"))); + let tracking_data = disputes::DisputeListPTData { + connector_name: connector_name.to_string(), + merchant_id: merchant_id.clone(), + merchant_connector_id: merchant_connector_id.clone(), + created_from: fetch_request.created_from, + created_till: fetch_request.created_till, + profile_id, + }; + let runner = common_enums::ProcessTrackerRunner::DisputeListWorkflow; + let task = "DISPUTE_LIST"; + let tag = ["LIST", "DISPUTE"]; + let process_tracker_id = scheduler::utils::get_process_tracker_id_for_dispute_list( + runner, + &merchant_connector_id, + fetch_request.created_from, + &merchant_id, + ); + let process_tracker_entry = diesel_models::ProcessTrackerNew::new( + process_tracker_id, + task, + runner, + tag, + tracking_data, + None, + fetch_request.created_from, + common_types::consts::API_VERSION, + ) + .map_err(errors::StorageError::from)?; + db.insert_process(process_tracker_entry).await?; + Ok(()) +} + +#[cfg(feature = "v1")] +pub async fn schedule_dispute_sync_task( + state: &SessionState, + business_profile: &domain::Profile, + mca: &domain::MerchantConnectorAccount, +) -> common_utils::errors::CustomResult<(), errors::ApiErrorResponse> { + let connector = api::enums::Connector::from_str(&mca.connector_name).change_context( + errors::ApiErrorResponse::InvalidDataValue { + field_name: "connector", + }, + )?; + + if core_utils::should_add_dispute_sync_task_to_pt(state, connector) { + let offset_date_time = time::OffsetDateTime::now_utc(); + let created_from = + time::PrimitiveDateTime::new(offset_date_time.date(), offset_date_time.time()); + let dispute_polling_interval = *business_profile + .dispute_polling_interval + .unwrap_or_default() + .deref(); + + let created_till = created_from + .checked_add(time::Duration::hours(i64::from(dispute_polling_interval))) + .ok_or(errors::ApiErrorResponse::InternalServerError)?; + + let m_db = state.clone().store; + let connector_name = mca.connector_name.clone(); + let merchant_id = mca.merchant_id.clone(); + let merchant_connector_id = mca.merchant_connector_id.clone(); + let business_profile_id = business_profile.get_id().clone(); + + tokio::spawn( + async move { + add_dispute_list_task_to_pt( + &*m_db, + &connector_name, + merchant_id.clone(), + merchant_connector_id.clone(), + business_profile_id, + FetchDisputesRequestData { + created_from, + created_till, + }, + ) + .await + .map_err(|error| { + logger::error!("Failed to add dispute list task to process tracker: {error}") + }) + } + .in_current_span(), + ); + } + Ok(()) +} diff --git a/crates/router/src/core/disputes/transformers.rs b/crates/router/src/core/disputes/transformers.rs index d87b1f1b85..b6158b17b8 100644 --- a/crates/router/src/core/disputes/transformers.rs +++ b/crates/router/src/core/disputes/transformers.rs @@ -22,6 +22,7 @@ pub async fn get_evidence_request_data( let cancellation_policy_file_info = retrieve_file_and_provider_file_id_from_file_id( state, evidence_request.cancellation_policy, + None, merchant_context, api::FileDataRequired::NotRequired, ) @@ -29,6 +30,7 @@ pub async fn get_evidence_request_data( let customer_communication_file_info = retrieve_file_and_provider_file_id_from_file_id( state, evidence_request.customer_communication, + None, merchant_context, api::FileDataRequired::NotRequired, ) @@ -36,6 +38,7 @@ pub async fn get_evidence_request_data( let customer_sifnature_file_info = retrieve_file_and_provider_file_id_from_file_id( state, evidence_request.customer_signature, + None, merchant_context, api::FileDataRequired::NotRequired, ) @@ -43,6 +46,7 @@ pub async fn get_evidence_request_data( let receipt_file_info = retrieve_file_and_provider_file_id_from_file_id( state, evidence_request.receipt, + None, merchant_context, api::FileDataRequired::NotRequired, ) @@ -50,6 +54,7 @@ pub async fn get_evidence_request_data( let refund_policy_file_info = retrieve_file_and_provider_file_id_from_file_id( state, evidence_request.refund_policy, + None, merchant_context, api::FileDataRequired::NotRequired, ) @@ -57,6 +62,7 @@ pub async fn get_evidence_request_data( let service_documentation_file_info = retrieve_file_and_provider_file_id_from_file_id( state, evidence_request.service_documentation, + None, merchant_context, api::FileDataRequired::NotRequired, ) @@ -64,6 +70,7 @@ pub async fn get_evidence_request_data( let shipping_documentation_file_info = retrieve_file_and_provider_file_id_from_file_id( state, evidence_request.shipping_documentation, + None, merchant_context, api::FileDataRequired::NotRequired, ) @@ -72,6 +79,7 @@ pub async fn get_evidence_request_data( retrieve_file_and_provider_file_id_from_file_id( state, evidence_request.invoice_showing_distinct_transactions, + None, merchant_context, api::FileDataRequired::NotRequired, ) @@ -80,6 +88,7 @@ pub async fn get_evidence_request_data( retrieve_file_and_provider_file_id_from_file_id( state, evidence_request.recurring_transaction_agreement, + None, merchant_context, api::FileDataRequired::NotRequired, ) @@ -87,12 +96,14 @@ pub async fn get_evidence_request_data( let uncategorized_file_info = retrieve_file_and_provider_file_id_from_file_id( state, evidence_request.uncategorized_file, + None, merchant_context, api::FileDataRequired::NotRequired, ) .await?; Ok(SubmitEvidenceRequestData { dispute_id: dispute.dispute_id.clone(), + dispute_status: dispute.dispute_status, connector_dispute_id: dispute.connector_dispute_id.clone(), access_activity_log: evidence_request.access_activity_log, billing_address: evidence_request.billing_address, diff --git a/crates/router/src/core/errors/utils.rs b/crates/router/src/core/errors/utils.rs index 29f2023b9a..69b219f4ae 100644 --- a/crates/router/src/core/errors/utils.rs +++ b/crates/router/src/core/errors/utils.rs @@ -85,6 +85,8 @@ pub trait ConnectorErrorExt { fn to_setup_mandate_failed_response(self) -> error_stack::Result; #[track_caller] fn to_dispute_failed_response(self) -> error_stack::Result; + #[track_caller] + fn to_files_failed_response(self) -> error_stack::Result; #[cfg(feature = "payouts")] #[track_caller] fn to_payout_failed_response(self) -> error_stack::Result; @@ -439,6 +441,38 @@ impl ConnectorErrorExt for error_stack::Result }) } + fn to_files_failed_response(self) -> error_stack::Result { + self.map_err(|err| { + let error = match err.current_context() { + errors::ConnectorError::ProcessingStepFailed(Some(bytes)) => { + let response_str = std::str::from_utf8(bytes); + let data = match response_str { + Ok(s) => serde_json::from_str(s) + .map_err( + |error| logger::error!(%error,"Failed to convert response to JSON"), + ) + .ok(), + Err(error) => { + logger::error!(%error,"Failed to convert response to UTF8 string"); + None + } + }; + errors::ApiErrorResponse::DisputeFailed { data } + } + errors::ConnectorError::MissingRequiredField { field_name } => { + errors::ApiErrorResponse::MissingRequiredField { field_name } + } + errors::ConnectorError::MissingRequiredFields { field_names } => { + errors::ApiErrorResponse::MissingRequiredFields { + field_names: field_names.to_vec(), + } + } + _ => errors::ApiErrorResponse::InternalServerError, + }; + err.change_context(error) + }) + } + #[cfg(feature = "payouts")] fn to_payout_failed_response(self) -> error_stack::Result { self.map_err(|err| { diff --git a/crates/router/src/core/files.rs b/crates/router/src/core/files.rs index b43ed94661..01fdc1d7fe 100644 --- a/crates/router/src/core/files.rs +++ b/crates/router/src/core/files.rs @@ -105,7 +105,7 @@ pub async fn files_delete_core( pub async fn files_retrieve_core( state: SessionState, merchant_context: domain::MerchantContext, - req: api::FileId, + req: api::FileRetrieveRequest, ) -> RouterResponse { let file_metadata_object = state .store @@ -120,6 +120,7 @@ pub async fn files_retrieve_core( let file_info = helpers::retrieve_file_and_provider_file_id_from_file_id( &state, Some(req.file_id), + req.dispute_id, &merchant_context, api::FileDataRequired::Required, ) diff --git a/crates/router/src/core/files/helpers.rs b/crates/router/src/core/files/helpers.rs index 4c585ccd22..ab7fdbc2bd 100644 --- a/crates/router/src/core/files/helpers.rs +++ b/crates/router/src/core/files/helpers.rs @@ -6,7 +6,7 @@ use hyperswitch_domain_models::router_response_types::disputes::FileInfo; use crate::{ core::{ - errors::{self, StorageErrorExt}, + errors::{self, utils::ConnectorErrorExt, StorageErrorExt}, payments, utils, }, routes::SessionState, @@ -122,6 +122,7 @@ pub async fn delete_file_using_file_id( pub async fn retrieve_file_from_connector( state: &SessionState, file_metadata: diesel_models::file::FileMetadata, + dispute_id: Option, merchant_context: &domain::MerchantContext, ) -> CustomResult, errors::ApiErrorResponse> { let connector = &types::Connector::foreign_try_from( @@ -137,6 +138,23 @@ pub async fn retrieve_file_from_connector( api::GetToken::Connector, file_metadata.merchant_connector_id.clone(), )?; + + let dispute = match dispute_id { + Some(dispute) => Some( + state + .store + .find_dispute_by_merchant_id_dispute_id( + merchant_context.get_merchant_account().get_id(), + &dispute, + ) + .await + .to_not_found_response(errors::ApiErrorResponse::DisputeNotFound { + dispute_id: dispute, + })?, + ), + None => None, + }; + let connector_integration: services::BoxedFilesConnectorIntegrationInterface< api::Retrieve, types::RetrieveFileRequestData, @@ -146,6 +164,7 @@ pub async fn retrieve_file_from_connector( state, merchant_context, &file_metadata, + dispute, connector, ) .await @@ -160,7 +179,7 @@ pub async fn retrieve_file_from_connector( None, ) .await - .change_context(errors::ApiErrorResponse::InternalServerError) + .to_files_failed_response() .attach_printable("Failed while calling retrieve file connector api")?; let retrieve_file_response = response @@ -178,6 +197,7 @@ pub async fn retrieve_file_from_connector( pub async fn retrieve_file_and_provider_file_id_from_file_id( state: &SessionState, file_id: Option, + dispute_id: Option, merchant_context: &domain::MerchantContext, is_connector_file_data_required: api::FileDataRequired, ) -> CustomResult { @@ -223,6 +243,7 @@ pub async fn retrieve_file_and_provider_file_id_from_file_id( retrieve_file_from_connector( state, file_metadata_object.clone(), + dispute_id, merchant_context, ) .await?, @@ -317,7 +338,6 @@ pub async fn upload_and_get_provider_provider_file_id_profile_id( ) .await .change_context(errors::ApiErrorResponse::PaymentNotFound)?; - let connector_integration: services::BoxedFilesConnectorIntegrationInterface< api::Upload, types::UploadFileRequestData, @@ -329,7 +349,8 @@ pub async fn upload_and_get_provider_provider_file_id_profile_id( &payment_attempt, merchant_context, create_file_request, - &dispute.connector, + dispute, + &connector_data.connector_name.to_string(), file_key, ) .await @@ -351,7 +372,7 @@ pub async fn upload_and_get_provider_provider_file_id_profile_id( errors::ApiErrorResponse::ExternalConnectorError { code: err.code, message: err.message, - connector: dispute.connector.clone(), + connector: connector_data.connector_name.to_string(), status_code: err.status_code, reason: err.reason, } diff --git a/crates/router/src/core/utils.rs b/crates/router/src/core/utils.rs index 9ebecccc1c..de5f3d4997 100644 --- a/crates/router/src/core/utils.rs +++ b/crates/router/src/core/utils.rs @@ -883,7 +883,7 @@ mod tests { } } -// Dispute Stage can move linearly from PreDispute -> Dispute -> PreArbitration +// Dispute Stage can move linearly from PreDispute -> Dispute -> PreArbitration -> Arbitration -> DisputeReversal pub fn validate_dispute_stage( prev_dispute_stage: DisputeStage, dispute_stage: DisputeStage, @@ -891,7 +891,17 @@ pub fn validate_dispute_stage( match prev_dispute_stage { DisputeStage::PreDispute => true, DisputeStage::Dispute => !matches!(dispute_stage, DisputeStage::PreDispute), - DisputeStage::PreArbitration => matches!(dispute_stage, DisputeStage::PreArbitration), + DisputeStage::PreArbitration => matches!( + dispute_stage, + DisputeStage::PreArbitration + | DisputeStage::Arbitration + | DisputeStage::DisputeReversal + ), + DisputeStage::Arbitration => matches!( + dispute_stage, + DisputeStage::Arbitration | DisputeStage::DisputeReversal + ), + DisputeStage::DisputeReversal => matches!(dispute_stage, DisputeStage::DisputeReversal), } } @@ -1002,6 +1012,7 @@ pub async fn construct_accept_dispute_router_data<'a>( request: types::AcceptDisputeRequestData { dispute_id: dispute.dispute_id.clone(), connector_dispute_id: dispute.connector_dispute_id.clone(), + dispute_status: dispute.dispute_status, }, response: Err(ErrorResponse::default()), access_token: None, @@ -1157,6 +1168,7 @@ pub async fn construct_upload_file_router_data<'a>( payment_attempt: &storage::PaymentAttempt, merchant_context: &domain::MerchantContext, create_file_request: &api::CreateFileRequest, + dispute_data: storage::Dispute, connector_id: &str, file_key: String, ) -> RouterResult { @@ -1212,6 +1224,8 @@ pub async fn construct_upload_file_router_data<'a>( file: create_file_request.file.clone(), file_type: create_file_request.file_type.clone(), file_size: create_file_request.file_size, + dispute_id: dispute_data.dispute_id.clone(), + connector_dispute_id: dispute_data.connector_dispute_id.clone(), }, response: Err(ErrorResponse::default()), access_token: None, @@ -1256,6 +1270,182 @@ pub async fn construct_upload_file_router_data<'a>( Ok(router_data) } +#[cfg(feature = "v1")] +#[instrument(skip_all)] +pub async fn construct_dispute_list_router_data<'a>( + state: &'a SessionState, + merchant_connector_account: MerchantConnectorAccount, + req: types::FetchDisputesRequestData, +) -> RouterResult { + let merchant_id = merchant_connector_account.merchant_id.clone(); + let auth_type: types::ConnectorAuthType = merchant_connector_account + .get_connector_account_details() + .change_context(errors::ApiErrorResponse::MerchantConnectorAccountNotFound { + id: "ConnectorAuthType".to_string(), + })?; + + Ok(types::RouterData { + flow: PhantomData, + merchant_id, + customer_id: None, + connector_customer: None, + connector: merchant_connector_account.connector_name.clone(), + payment_id: consts::IRRELEVANT_PAYMENT_INTENT_ID.to_owned(), + tenant_id: state.tenant.tenant_id.clone(), + attempt_id: consts::IRRELEVANT_PAYMENT_ATTEMPT_ID.to_owned(), + status: common_enums::AttemptStatus::default(), + payment_method: common_enums::PaymentMethod::default(), + connector_auth_type: auth_type, + description: None, + address: PaymentAddress::default(), + auth_type: common_enums::AuthenticationType::default(), + connector_meta_data: merchant_connector_account.get_metadata().clone(), + connector_wallets_details: merchant_connector_account.get_connector_wallets_details(), + amount_captured: None, + minor_amount_captured: None, + access_token: None, + session_token: None, + reference_id: None, + payment_method_token: None, + recurring_mandate_payment_data: None, + preprocessing_id: None, + payment_method_balance: None, + connector_api_version: None, + request: req, + response: Err(ErrorResponse::default()), + //TODO + connector_request_reference_id: + "IRRELEVANT_CONNECTOR_REQUEST_REFERENCE_ID_IN_AUTHENTICATION_FLOW".to_owned(), + #[cfg(feature = "payouts")] + payout_method_data: None, + #[cfg(feature = "payouts")] + quote_id: None, + test_mode: None, + connector_http_status_code: None, + external_latency: None, + apple_pay_flow: None, + frm_metadata: None, + dispute_id: None, + refund_id: None, + payment_method_status: None, + connector_response: None, + integrity_check: Ok(()), + additional_merchant_data: None, + header_payload: None, + connector_mandate_request_reference_id: None, + authentication_id: None, + psd2_sca_exemption_type: None, + raw_connector_response: None, + is_payment_id_from_merchant: None, + l2_l3_data: None, + }) +} + +#[cfg(feature = "v1")] +#[instrument(skip_all)] +pub async fn construct_dispute_sync_router_data<'a>( + state: &'a SessionState, + payment_intent: &'a storage::PaymentIntent, + payment_attempt: &storage::PaymentAttempt, + merchant_context: &domain::MerchantContext, + dispute: &storage::Dispute, +) -> RouterResult { + let _db = &*state.store; + let connector_id = &dispute.connector; + let profile_id = payment_intent + .profile_id + .as_ref() + .get_required_value("profile_id") + .change_context(errors::ApiErrorResponse::InternalServerError) + .attach_printable("profile_id is not set in payment_intent")? + .clone(); + + let merchant_connector_account = helpers::get_merchant_connector_account( + state, + merchant_context.get_merchant_account().get_id(), + None, + merchant_context.get_merchant_key_store(), + &profile_id, + connector_id, + payment_attempt.merchant_connector_id.as_ref(), + ) + .await?; + + let test_mode: Option = merchant_connector_account.is_test_mode_on(); + let auth_type: types::ConnectorAuthType = merchant_connector_account + .get_connector_account_details() + .parse_value("ConnectorAuthType") + .change_context(errors::ApiErrorResponse::InternalServerError)?; + let payment_method = payment_attempt + .payment_method + .get_required_value("payment_method")?; + let router_data = types::RouterData { + flow: PhantomData, + merchant_id: merchant_context.get_merchant_account().get_id().clone(), + connector: connector_id.to_string(), + payment_id: payment_attempt.payment_id.get_string_repr().to_owned(), + tenant_id: state.tenant.tenant_id.clone(), + attempt_id: payment_attempt.attempt_id.clone(), + status: payment_attempt.status, + payment_method, + connector_auth_type: auth_type, + description: None, + address: PaymentAddress::default(), + auth_type: payment_attempt.authentication_type.unwrap_or_default(), + connector_meta_data: merchant_connector_account.get_metadata(), + connector_wallets_details: merchant_connector_account.get_connector_wallets_details(), + amount_captured: payment_intent + .amount_captured + .map(|amt| amt.get_amount_as_i64()), + minor_amount_captured: payment_intent.amount_captured, + payment_method_status: None, + request: types::DisputeSyncData { + dispute_id: dispute.dispute_id.clone(), + connector_dispute_id: dispute.connector_dispute_id.clone(), + }, + response: Err(ErrorResponse::get_not_implemented()), + access_token: None, + session_token: None, + reference_id: None, + payment_method_token: None, + customer_id: None, + connector_customer: None, + recurring_mandate_payment_data: None, + preprocessing_id: None, + payment_method_balance: None, + connector_request_reference_id: get_connector_request_reference_id( + &state.conf, + merchant_context.get_merchant_account().get_id(), + payment_intent, + payment_attempt, + connector_id, + )?, + #[cfg(feature = "payouts")] + payout_method_data: None, + #[cfg(feature = "payouts")] + quote_id: None, + test_mode, + connector_api_version: None, + connector_http_status_code: None, + external_latency: None, + apple_pay_flow: None, + frm_metadata: None, + refund_id: None, + dispute_id: Some(dispute.dispute_id.clone()), + connector_response: None, + integrity_check: Ok(()), + additional_merchant_data: None, + header_payload: None, + connector_mandate_request_reference_id: None, + authentication_id: None, + psd2_sca_exemption_type: None, + raw_connector_response: None, + is_payment_id_from_merchant: None, + l2_l3_data: None, + }; + Ok(router_data) +} + #[cfg(feature = "v2")] pub async fn construct_payments_dynamic_tax_calculation_router_data( state: &SessionState, @@ -1493,6 +1683,7 @@ pub async fn construct_retrieve_file_router_data<'a>( state: &'a SessionState, merchant_context: &domain::MerchantContext, file_metadata: &diesel_models::file::FileMetadata, + dispute: Option, connector_id: &str, ) -> RouterResult { let profile_id = file_metadata @@ -1548,6 +1739,7 @@ pub async fn construct_retrieve_file_router_data<'a>( .clone() .ok_or(errors::ApiErrorResponse::InternalServerError) .attach_printable("Missing provider file id")?, + connector_dispute_id: dispute.map(|dispute_data| dispute_data.connector_dispute_id), }, response: Err(ErrorResponse::default()), access_token: None, @@ -2397,3 +2589,26 @@ fn validate_plusgiro_number(number: &Secret) -> RouterResult<()> { } Ok(()) } + +pub fn should_add_dispute_sync_task_to_pt(state: &SessionState, connector_name: Connector) -> bool { + let list_dispute_supported_connectors = state + .conf + .list_dispute_supported_connectors + .connector_list + .clone(); + list_dispute_supported_connectors.contains(&connector_name) +} + +pub fn should_proceed_with_submit_evidence( + dispute_stage: DisputeStage, + dispute_status: DisputeStatus, +) -> bool { + matches!(dispute_stage, DisputeStage::DisputeReversal) + || matches!( + dispute_status, + DisputeStatus::DisputeExpired + | DisputeStatus::DisputeCancelled + | DisputeStatus::DisputeWon + | DisputeStatus::DisputeLost, + ) +} diff --git a/crates/router/src/core/webhooks.rs b/crates/router/src/core/webhooks.rs index 88a3361ec0..51f0f3a37f 100644 --- a/crates/router/src/core/webhooks.rs +++ b/crates/router/src/core/webhooks.rs @@ -1,5 +1,5 @@ #[cfg(feature = "v1")] -mod incoming; +pub mod incoming; #[cfg(feature = "v2")] mod incoming_v2; #[cfg(feature = "v1")] diff --git a/crates/router/src/core/webhooks/incoming.rs b/crates/router/src/core/webhooks/incoming.rs index 71d1381331..fae94ceb11 100644 --- a/crates/router/src/core/webhooks/incoming.rs +++ b/crates/router/src/core/webhooks/incoming.rs @@ -1251,7 +1251,7 @@ async fn relay_incoming_webhook_flow( Ok(result_response) } -async fn get_payment_attempt_from_object_reference_id( +pub async fn get_payment_attempt_from_object_reference_id( state: &SessionState, object_reference_id: webhooks::ObjectReferenceId, merchant_context: &domain::MerchantContext, @@ -1288,14 +1288,14 @@ async fn get_payment_attempt_from_object_reference_id( } #[allow(clippy::too_many_arguments)] -async fn get_or_update_dispute_object( +pub async fn get_or_update_dispute_object( state: SessionState, option_dispute: Option, dispute_details: api::disputes::DisputePayload, merchant_id: &common_utils::id_type::MerchantId, organization_id: &common_utils::id_type::OrganizationId, payment_attempt: &PaymentAttempt, - event_type: webhooks::IncomingWebhookEvent, + dispute_status: common_enums::enums::DisputeStatus, business_profile: &domain::Profile, connector_name: &str, ) -> CustomResult { @@ -1309,9 +1309,7 @@ async fn get_or_update_dispute_object( amount: dispute_details.amount.clone(), currency: dispute_details.currency.to_string(), dispute_stage: dispute_details.dispute_stage, - dispute_status: common_enums::DisputeStatus::foreign_try_from(event_type) - .change_context(errors::ApiErrorResponse::WebhookProcessingFailure) - .attach_printable("event type to dispute status mapping failed")?, + dispute_status, payment_id: payment_attempt.payment_id.to_owned(), connector: connector_name.to_owned(), attempt_id: payment_attempt.attempt_id.to_owned(), @@ -1348,9 +1346,6 @@ async fn get_or_update_dispute_object( Some(dispute) => { logger::info!("Dispute Already exists, Updating the dispute details"); metrics::INCOMING_DISPUTE_WEBHOOK_UPDATE_RECORD_METRIC.add(1, &[]); - let dispute_status = diesel_models::enums::DisputeStatus::foreign_try_from(event_type) - .change_context(errors::ApiErrorResponse::WebhookProcessingFailure) - .attach_printable("event type to dispute state conversion failure")?; crate::core::utils::validate_dispute_stage_and_dispute_status( dispute.dispute_stage, dispute.dispute_status, @@ -1359,6 +1354,7 @@ async fn get_or_update_dispute_object( ) .change_context(errors::ApiErrorResponse::WebhookProcessingFailure) .attach_printable("dispute stage and status validation failed")?; + let update_dispute = diesel_models::dispute::DisputeUpdate::Update { dispute_stage: dispute_details.dispute_stage, dispute_status, @@ -1788,6 +1784,10 @@ async fn disputes_incoming_webhook_flow( ) .await .to_not_found_response(errors::ApiErrorResponse::WebhookResourceNotFound)?; + let dispute_status = common_enums::DisputeStatus::foreign_try_from(event_type) + .change_context(errors::ApiErrorResponse::WebhookProcessingFailure) + .attach_printable("event type to dispute status mapping failed")?; + let dispute_object = get_or_update_dispute_object( state.clone(), option_dispute, @@ -1795,7 +1795,7 @@ async fn disputes_incoming_webhook_flow( merchant_context.get_merchant_account().get_id(), &merchant_context.get_merchant_account().organization_id, &payment_attempt, - event_type, + dispute_status, &business_profile, connector.id(), ) @@ -2010,7 +2010,7 @@ async fn verify_webhook_source_verification_call( } } -fn get_connector_by_connector_name( +pub fn get_connector_by_connector_name( state: &SessionState, connector_name: &str, merchant_connector_id: Option, diff --git a/crates/router/src/db/events.rs b/crates/router/src/db/events.rs index 1482aeb7cd..1a3fdb3cab 100644 --- a/crates/router/src/db/events.rs +++ b/crates/router/src/db/events.rs @@ -1288,6 +1288,7 @@ mod tests { is_iframe_redirection_enabled: None, is_pre_network_tokenization_enabled: false, merchant_category_code: None, + dispute_polling_interval: None, }); let business_profile = state diff --git a/crates/router/src/events/api_logs.rs b/crates/router/src/events/api_logs.rs index 9435a0e318..01c7323686 100644 --- a/crates/router/src/events/api_logs.rs +++ b/crates/router/src/events/api_logs.rs @@ -17,7 +17,8 @@ use crate::{ core::payments::PaymentsRedirectResponseData, services::{authentication::AuthenticationType, kafka::KafkaMessage}, types::api::{ - AttachEvidenceRequest, Config, ConfigUpdate, CreateFileRequest, DisputeId, FileId, PollId, + AttachEvidenceRequest, Config, ConfigUpdate, CreateFileRequest, DisputeFetchQueryData, + DisputeId, FileId, FileRetrieveRequest, PollId, }, }; @@ -111,7 +112,9 @@ impl_api_event_type!( Config, CreateFileRequest, FileId, + FileRetrieveRequest, AttachEvidenceRequest, + DisputeFetchQueryData, ConfigUpdate ) ); diff --git a/crates/router/src/routes/app.rs b/crates/router/src/routes/app.rs index 0fbb177120..d4bee7276a 100644 --- a/crates/router/src/routes/app.rs +++ b/crates/router/src/routes/app.rs @@ -1971,6 +1971,10 @@ impl Disputes { .service( web::resource("/{dispute_id}").route(web::get().to(disputes::retrieve_dispute)), ) + .service( + web::resource("/{connector_id}/fetch") + .route(web::get().to(disputes::fetch_disputes)), + ) } } diff --git a/crates/router/src/routes/disputes.rs b/crates/router/src/routes/disputes.rs index 7e69c40424..86f0492c15 100644 --- a/crates/router/src/routes/disputes.rs +++ b/crates/router/src/routes/disputes.rs @@ -34,16 +34,19 @@ pub async fn retrieve_dispute( state: web::Data, req: HttpRequest, path: web::Path, + json_payload: web::Query, ) -> HttpResponse { let flow = Flow::DisputesRetrieve; - let dispute_id = dispute_types::DisputeId { + let payload = dispute_models::DisputeRetrieveRequest { dispute_id: path.into_inner(), + force_sync: json_payload.force_sync, }; + Box::pin(api::server_wrap( flow, state, &req, - dispute_id, + payload, |state, auth: auth::AuthenticationData, req, _| { let merchant_context = domain::MerchantContext::NormalMerchant(Box::new( domain::Context(auth.merchant_account, auth.key_store), @@ -64,6 +67,45 @@ pub async fn retrieve_dispute( )) .await } + +#[cfg(feature = "v1")] +#[instrument(skip_all, fields(flow = ?Flow::DisputesRetrieve))] +pub async fn fetch_disputes( + state: web::Data, + req: HttpRequest, + path: web::Path, + json_payload: web::Query, +) -> HttpResponse { + let flow = Flow::DisputesList; + let connector_id = path.into_inner(); + let payload = json_payload.into_inner(); + + Box::pin(api::server_wrap( + flow, + state, + &req, + payload, + |state, auth: auth::AuthenticationData, req, _| { + let merchant_context = domain::MerchantContext::NormalMerchant(Box::new( + domain::Context(auth.merchant_account, auth.key_store), + )); + disputes::connector_sync_disputes(state, merchant_context, connector_id.clone(), req) + }, + auth::auth_type( + &auth::HeaderAuth(auth::ApiKeyAuth { + is_connected_allowed: false, + is_platform_allowed: false, + }), + &auth::JWTAuth { + permission: Permission::ProfileDisputeRead, + }, + req.headers(), + ), + api_locking::LockAction::NotApplicable, + )) + .await +} + /// Disputes - List Disputes #[utoipa::path( get, diff --git a/crates/router/src/routes/files.rs b/crates/router/src/routes/files.rs index c3e0580e5a..bef5677a08 100644 --- a/crates/router/src/routes/files.rs +++ b/crates/router/src/routes/files.rs @@ -1,5 +1,6 @@ use actix_multipart::Multipart; use actix_web::{web, HttpRequest, HttpResponse}; +use api_models::files as file_types; use router_env::{instrument, tracing, Flow}; use crate::core::api_locking; @@ -139,10 +140,12 @@ pub async fn files_retrieve( state: web::Data, req: HttpRequest, path: web::Path, + json_payload: web::Query, ) -> HttpResponse { let flow = Flow::RetrieveFile; - let file_id = files::FileId { + let file_id = files::FileRetrieveRequest { file_id: path.into_inner(), + dispute_id: json_payload.dispute_id.clone(), }; Box::pin(api::server_wrap( flow, diff --git a/crates/router/src/types.rs b/crates/router/src/types.rs index 012188adfc..f7ab32badc 100644 --- a/crates/router/src/types.rs +++ b/crates/router/src/types.rs @@ -32,7 +32,7 @@ pub use hyperswitch_domain_models::router_data_v2::FrmFlowData; use hyperswitch_domain_models::router_flow_types::{ self, access_token_auth::AccessTokenAuth, - dispute::{Accept, Defend, Evidence}, + dispute::{Accept, Defend, Dsync, Evidence, Fetch}, files::{Retrieve, Upload}, mandate_revoke::MandateRevoke, payments::{ @@ -70,12 +70,13 @@ pub use hyperswitch_domain_models::{ AcceptDisputeRequestData, AccessTokenRequestData, AuthorizeSessionTokenData, BrowserInformation, ChargeRefunds, ChargeRefundsOptions, CompleteAuthorizeData, CompleteAuthorizeRedirectResponse, ConnectorCustomerData, CreateOrderRequestData, - DefendDisputeRequestData, DestinationChargeRefund, DirectChargeRefund, - MandateRevokeRequestData, MultipleCaptureRequestData, PaymentMethodTokenizationData, - PaymentsApproveData, PaymentsAuthorizeData, PaymentsCancelData, - PaymentsCancelPostCaptureData, PaymentsCaptureData, PaymentsIncrementalAuthorizationData, - PaymentsPostProcessingData, PaymentsPostSessionTokensData, PaymentsPreProcessingData, - PaymentsRejectData, PaymentsSessionData, PaymentsSyncData, PaymentsTaxCalculationData, + DefendDisputeRequestData, DestinationChargeRefund, DirectChargeRefund, DisputeSyncData, + FetchDisputesRequestData, MandateRevokeRequestData, MultipleCaptureRequestData, + PaymentMethodTokenizationData, PaymentsApproveData, PaymentsAuthorizeData, + PaymentsCancelData, PaymentsCancelPostCaptureData, PaymentsCaptureData, + PaymentsIncrementalAuthorizationData, PaymentsPostProcessingData, + PaymentsPostSessionTokensData, PaymentsPreProcessingData, PaymentsRejectData, + PaymentsSessionData, PaymentsSyncData, PaymentsTaxCalculationData, PaymentsUpdateMetadataData, RefundsData, ResponseId, RetrieveFileRequestData, SdkPaymentsSessionUpdateData, SetupMandateRequestData, SplitRefundsRequest, SubmitEvidenceRequestData, SyncRequestType, UploadFileRequestData, VaultRequestData, @@ -86,9 +87,9 @@ pub use hyperswitch_domain_models::{ BillingConnectorInvoiceSyncResponse, BillingConnectorPaymentsSyncResponse, RevenueRecoveryRecordBackResponse, }, - AcceptDisputeResponse, CaptureSyncResponse, DefendDisputeResponse, MandateReference, - MandateRevokeResponseData, PaymentsResponseData, PreprocessingResponseId, - RefundsResponseData, RetrieveFileResponse, SubmitEvidenceResponse, + AcceptDisputeResponse, CaptureSyncResponse, DefendDisputeResponse, DisputeSyncResponse, + FetchDisputesResponse, MandateReference, MandateRevokeResponseData, PaymentsResponseData, + PreprocessingResponseId, RefundsResponseData, RetrieveFileResponse, SubmitEvidenceResponse, TaxCalculationResponseData, UploadFileResponse, VaultResponseData, VerifyWebhookSourceResponseData, VerifyWebhookStatus, }, @@ -98,21 +99,24 @@ pub use hyperswitch_domain_models::{ router_data_v2::PayoutFlowData, router_request_types::PayoutsData, router_response_types::PayoutsResponseData, }; -pub use hyperswitch_interfaces::types::{ - AcceptDisputeType, ConnectorCustomerType, DefendDisputeType, IncrementalAuthorizationType, - MandateRevokeType, PaymentsAuthorizeType, PaymentsBalanceType, PaymentsCaptureType, - PaymentsCompleteAuthorizeType, PaymentsInitType, PaymentsPostCaptureVoidType, - PaymentsPostProcessingType, PaymentsPostSessionTokensType, PaymentsPreAuthorizeType, - PaymentsPreProcessingType, PaymentsSessionType, PaymentsSyncType, PaymentsUpdateMetadataType, - PaymentsVoidType, RefreshTokenType, RefundExecuteType, RefundSyncType, Response, - RetrieveFileType, SdkSessionUpdateType, SetupMandateType, SubmitEvidenceType, TokenizationType, - UploadFileType, VerifyWebhookSourceType, -}; #[cfg(feature = "payouts")] pub use hyperswitch_interfaces::types::{ PayoutCancelType, PayoutCreateType, PayoutEligibilityType, PayoutFulfillType, PayoutQuoteType, PayoutRecipientAccountType, PayoutRecipientType, PayoutSyncType, }; +pub use hyperswitch_interfaces::{ + disputes::DisputePayload, + types::{ + AcceptDisputeType, ConnectorCustomerType, DefendDisputeType, FetchDisputesType, + IncrementalAuthorizationType, MandateRevokeType, PaymentsAuthorizeType, + PaymentsBalanceType, PaymentsCaptureType, PaymentsCompleteAuthorizeType, PaymentsInitType, + PaymentsPostCaptureVoidType, PaymentsPostProcessingType, PaymentsPostSessionTokensType, + PaymentsPreAuthorizeType, PaymentsPreProcessingType, PaymentsSessionType, PaymentsSyncType, + PaymentsUpdateMetadataType, PaymentsVoidType, RefreshTokenType, RefundExecuteType, + RefundSyncType, Response, RetrieveFileType, SdkSessionUpdateType, SetupMandateType, + SubmitEvidenceType, TokenizationType, UploadFileType, VerifyWebhookSourceType, + }, +}; pub use crate::core::payments::CustomerDetails; #[cfg(feature = "payouts")] @@ -233,6 +237,11 @@ pub type RetrieveFileRouterData = pub type DefendDisputeRouterData = RouterData; +pub type FetchDisputesRouterData = + RouterData; + +pub type DisputeSyncRouterData = RouterData; + pub type MandateRevokeRouterData = RouterData; diff --git a/crates/router/src/types/api/admin.rs b/crates/router/src/types/api/admin.rs index 1b478cee4c..e0866e13eb 100644 --- a/crates/router/src/types/api/admin.rs +++ b/crates/router/src/types/api/admin.rs @@ -232,6 +232,7 @@ impl ForeignTryFrom for ProfileResponse { is_iframe_redirection_enabled: item.is_iframe_redirection_enabled, merchant_category_code: item.merchant_category_code, merchant_country_code: item.merchant_country_code, + dispute_polling_interval: item.dispute_polling_interval, }) } } @@ -487,5 +488,6 @@ pub async fn create_profile_from_merchant_account( .unwrap_or_default(), merchant_category_code: request.merchant_category_code, merchant_country_code: request.merchant_country_code, + dispute_polling_interval: request.dispute_polling_interval, })) } diff --git a/crates/router/src/types/api/disputes.rs b/crates/router/src/types/api/disputes.rs index b3bc66e010..62bda6157c 100644 --- a/crates/router/src/types/api/disputes.rs +++ b/crates/router/src/types/api/disputes.rs @@ -1,5 +1,7 @@ pub use hyperswitch_interfaces::{ - api::disputes::{AcceptDispute, DefendDispute, Dispute, SubmitEvidence}, + api::disputes::{ + AcceptDispute, DefendDispute, Dispute, DisputeSync, FetchDisputes, SubmitEvidence, + }, disputes::DisputePayload, }; use masking::{Deserialize, Serialize}; @@ -11,9 +13,19 @@ pub struct DisputeId { pub dispute_id: String, } -pub use hyperswitch_domain_models::router_flow_types::dispute::{Accept, Defend, Evidence}; +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct DisputeFetchQueryData { + pub fetch_from: String, + pub fetch_till: String, +} -pub use super::disputes_v2::{AcceptDisputeV2, DefendDisputeV2, DisputeV2, SubmitEvidenceV2}; +pub use hyperswitch_domain_models::router_flow_types::dispute::{ + Accept, Defend, Dsync, Evidence, Fetch, +}; + +pub use super::disputes_v2::{ + AcceptDisputeV2, DefendDisputeV2, DisputeSyncV2, DisputeV2, FetchDisputesV2, SubmitEvidenceV2, +}; #[derive(Default, Debug, Deserialize, Serialize)] pub struct DisputeEvidence { @@ -50,3 +62,20 @@ pub enum EvidenceType { RecurringTransactionAgreement, UncategorizedFile, } + +#[derive(Debug, serde::Deserialize, serde::Serialize)] +pub struct ProcessDisputePTData { + pub connector_name: String, + pub dispute_payload: types::DisputeSyncResponse, + pub merchant_id: common_utils::id_type::MerchantId, +} + +#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)] +pub struct DisputeListPTData { + pub connector_name: String, + pub merchant_connector_id: common_utils::id_type::MerchantConnectorAccountId, + pub merchant_id: common_utils::id_type::MerchantId, + pub profile_id: common_utils::id_type::ProfileId, + pub created_from: time::PrimitiveDateTime, + pub created_till: time::PrimitiveDateTime, +} diff --git a/crates/router/src/types/api/disputes_v2.rs b/crates/router/src/types/api/disputes_v2.rs index af9594cf60..4fe9e6da65 100644 --- a/crates/router/src/types/api/disputes_v2.rs +++ b/crates/router/src/types/api/disputes_v2.rs @@ -1,3 +1,3 @@ pub use hyperswitch_interfaces::api::disputes_v2::{ - AcceptDisputeV2, DefendDisputeV2, DisputeV2, SubmitEvidenceV2, + AcceptDisputeV2, DefendDisputeV2, DisputeSyncV2, DisputeV2, FetchDisputesV2, SubmitEvidenceV2, }; diff --git a/crates/router/src/types/api/files.rs b/crates/router/src/types/api/files.rs index 9a991c040f..50ca78a1d6 100644 --- a/crates/router/src/types/api/files.rs +++ b/crates/router/src/types/api/files.rs @@ -15,6 +15,12 @@ pub struct FileId { pub file_id: String, } +#[derive(Default, Debug, Deserialize, Serialize)] +pub struct FileRetrieveRequest { + pub file_id: String, + pub dispute_id: Option, +} + #[derive(Debug)] pub enum FileDataRequired { Required, @@ -27,6 +33,7 @@ impl ForeignTryFrom for types::Connector { match item { FileUploadProvider::Stripe => Ok(Self::Stripe), FileUploadProvider::Checkout => Ok(Self::Checkout), + FileUploadProvider::Worldpayvantiv => Ok(Self::Worldpayvantiv), FileUploadProvider::Router => Err(errors::ApiErrorResponse::NotSupported { message: "File upload provider is not a connector".to_owned(), } @@ -41,6 +48,7 @@ impl ForeignTryFrom<&types::Connector> for FileUploadProvider { match *item { types::Connector::Stripe => Ok(Self::Stripe), types::Connector::Checkout => Ok(Self::Checkout), + types::Connector::Worldpayvantiv => Ok(Self::Worldpayvantiv), _ => Err(errors::ApiErrorResponse::NotSupported { message: "Connector not supported as file provider".to_owned(), } diff --git a/crates/router/src/types/domain.rs b/crates/router/src/types/domain.rs index a825853620..c5efabce39 100644 --- a/crates/router/src/types/domain.rs +++ b/crates/router/src/types/domain.rs @@ -2,6 +2,10 @@ pub mod behaviour { pub use hyperswitch_domain_models::behaviour::{Conversion, ReverseConversion}; } +mod payment_attempt { + pub use hyperswitch_domain_models::payments::payment_attempt::*; +} + mod merchant_account { pub use hyperswitch_domain_models::merchant_account::*; } @@ -80,6 +84,7 @@ pub use merchant_connector_account::*; pub use merchant_context::*; pub use merchant_key_store::*; pub use network_tokenization::*; +pub use payment_attempt::*; pub use payment_method_data::*; pub use payment_methods::*; pub use routing::*; diff --git a/crates/router/src/workflows.rs b/crates/router/src/workflows.rs index b62a3b4c81..2c2410f744 100644 --- a/crates/router/src/workflows.rs +++ b/crates/router/src/workflows.rs @@ -11,3 +11,7 @@ pub mod refund_router; pub mod tokenized_data; pub mod revenue_recovery; + +pub mod process_dispute; + +pub mod dispute_list; diff --git a/crates/router/src/workflows/dispute_list.rs b/crates/router/src/workflows/dispute_list.rs new file mode 100644 index 0000000000..81d1f70541 --- /dev/null +++ b/crates/router/src/workflows/dispute_list.rs @@ -0,0 +1,227 @@ +use std::ops::Deref; + +use common_utils::ext_traits::{StringExt, ValueExt}; +use diesel_models::process_tracker::business_status; +use error_stack::ResultExt; +use router_env::{logger, tracing::Instrument}; +use scheduler::{ + consumer::{self, types::process_data, workflows::ProcessTrackerWorkflow}, + errors as sch_errors, utils as scheduler_utils, +}; + +use crate::{ + core::disputes, + db::StorageInterface, + errors, + routes::SessionState, + types::{api, domain, storage}, +}; + +pub struct DisputeListWorkflow; + +/// This workflow fetches disputes from the connector for a given time range +/// and creates a process tracker task for each dispute. +/// It also schedules the next dispute list sync after dispute_polling_hours. +#[async_trait::async_trait] +impl ProcessTrackerWorkflow for DisputeListWorkflow { + #[cfg(feature = "v2")] + async fn execute_workflow<'a>( + &'a self, + _state: &'a SessionState, + _process: storage::ProcessTracker, + ) -> Result<(), sch_errors::ProcessTrackerError> { + todo!() + } + + #[cfg(feature = "v1")] + async fn execute_workflow<'a>( + &'a self, + state: &'a SessionState, + process: storage::ProcessTracker, + ) -> Result<(), sch_errors::ProcessTrackerError> { + let db = &*state.store; + let tracking_data: api::DisputeListPTData = process + .tracking_data + .clone() + .parse_value("ProcessDisputePTData")?; + let key_manager_state = &state.into(); + let key_store = db + .get_merchant_key_store_by_merchant_id( + key_manager_state, + &tracking_data.merchant_id, + &db.get_master_key().to_vec().into(), + ) + .await?; + + let merchant_account = db + .find_merchant_account_by_merchant_id( + key_manager_state, + &tracking_data.merchant_id.clone(), + &key_store, + ) + .await?; + + let merchant_context = domain::MerchantContext::NormalMerchant(Box::new(domain::Context( + merchant_account.clone(), + key_store.clone(), + ))); + + let business_profile = state + .store + .find_business_profile_by_profile_id( + &(state).into(), + merchant_context.get_merchant_key_store(), + &tracking_data.profile_id, + ) + .await?; + + if process.retry_count == 0 { + let m_db = state.clone().store; + let m_tracking_data = tracking_data.clone(); + let dispute_polling_interval = *business_profile + .dispute_polling_interval + .unwrap_or_default() + .deref(); + + tokio::spawn( + async move { + schedule_next_dispute_list_task( + &*m_db, + &m_tracking_data, + dispute_polling_interval, + ) + .await + .map_err(|error| { + logger::error!( + "Failed to add dispute list task to process tracker: {error}" + ) + }) + } + .in_current_span(), + ); + }; + + let response = Box::pin(disputes::fetch_disputes_from_connector( + state.clone(), + merchant_context, + tracking_data.merchant_connector_id, + hyperswitch_domain_models::router_request_types::FetchDisputesRequestData { + created_from: tracking_data.created_from, + created_till: tracking_data.created_till, + }, + )) + .await + .attach_printable("Dispute update failed"); + + if response.is_err() { + retry_sync_task( + db, + tracking_data.connector_name, + tracking_data.merchant_id, + process, + ) + .await?; + } else { + state + .store + .as_scheduler() + .finish_process_with_business_status(process, business_status::COMPLETED_BY_PT) + .await? + } + + Ok(()) + } + + async fn error_handler<'a>( + &'a self, + state: &'a SessionState, + process: storage::ProcessTracker, + error: sch_errors::ProcessTrackerError, + ) -> errors::CustomResult<(), sch_errors::ProcessTrackerError> { + consumer::consumer_error_handler(state.store.as_scheduler(), process, error).await + } +} + +pub async fn get_sync_process_schedule_time( + db: &dyn StorageInterface, + connector: &str, + merchant_id: &common_utils::id_type::MerchantId, + retry_count: i32, +) -> Result, errors::ProcessTrackerError> { + let mapping: common_utils::errors::CustomResult< + process_data::ConnectorPTMapping, + errors::StorageError, + > = db + .find_config_by_key(&format!("pt_mapping_{connector}")) + .await + .map(|value| value.config) + .and_then(|config| { + config + .parse_struct("ConnectorPTMapping") + .change_context(errors::StorageError::DeserializationFailed) + }); + let mapping = match mapping { + Ok(x) => x, + Err(error) => { + logger::info!(?error, "Redis Mapping Error"); + process_data::ConnectorPTMapping::default() + } + }; + let time_delta = scheduler_utils::get_schedule_time(mapping, merchant_id, retry_count); + + Ok(scheduler_utils::get_time_from_delta(time_delta)) +} + +/// Schedule the task for retry +/// +/// Returns bool which indicates whether this was the last retry or not +pub async fn retry_sync_task( + db: &dyn StorageInterface, + connector: String, + merchant_id: common_utils::id_type::MerchantId, + pt: storage::ProcessTracker, +) -> Result { + let schedule_time: Option = + get_sync_process_schedule_time(db, &connector, &merchant_id, pt.retry_count + 1).await?; + + match schedule_time { + Some(s_time) => { + db.as_scheduler().retry_process(pt, s_time).await?; + Ok(false) + } + None => { + db.as_scheduler() + .finish_process_with_business_status(pt, business_status::RETRIES_EXCEEDED) + .await?; + Ok(true) + } + } +} + +#[cfg(feature = "v1")] +pub async fn schedule_next_dispute_list_task( + db: &dyn StorageInterface, + tracking_data: &api::DisputeListPTData, + dispute_polling_interval: i32, +) -> Result<(), errors::ProcessTrackerError> { + let new_created_till = tracking_data + .created_till + .checked_add(time::Duration::hours(i64::from(dispute_polling_interval))) + .ok_or(sch_errors::ProcessTrackerError::TypeConversionError)?; + + let fetch_request = hyperswitch_domain_models::router_request_types::FetchDisputesRequestData { + created_from: tracking_data.created_till, + created_till: new_created_till, + }; + + disputes::add_dispute_list_task_to_pt( + db, + &tracking_data.connector_name, + tracking_data.merchant_id.clone(), + tracking_data.merchant_connector_id.clone(), + tracking_data.profile_id.clone(), + fetch_request, + ) + .await?; + Ok(()) +} diff --git a/crates/router/src/workflows/outgoing_webhook_retry.rs b/crates/router/src/workflows/outgoing_webhook_retry.rs index 243c738202..559f62b1a4 100644 --- a/crates/router/src/workflows/outgoing_webhook_retry.rs +++ b/crates/router/src/workflows/outgoing_webhook_retry.rs @@ -360,6 +360,7 @@ async fn get_outgoing_webhook_content_and_event_type( tracking_data: &OutgoingWebhookTrackingData, ) -> Result<(OutgoingWebhookContent, Option), errors::ProcessTrackerError> { use api_models::{ + disputes::DisputeRetrieveRequest, mandates::MandateId, payments::{PaymentIdType, PaymentsResponse, PaymentsRetrieveRequest}, refunds::{RefundResponse, RefundsRetrieveRequest}, @@ -373,10 +374,7 @@ async fn get_outgoing_webhook_content_and_event_type( refunds::refund_retrieve_core_with_refund_id, }, services::{ApplicationResponse, AuthFlow}, - types::{ - api::{DisputeId, PSync}, - transformers::ForeignFrom, - }, + types::{api::PSync, transformers::ForeignFrom}, }; let merchant_context = domain::MerchantContext::NormalMerchant(Box::new(domain::Context( @@ -474,27 +472,36 @@ async fn get_outgoing_webhook_content_and_event_type( diesel_models::enums::EventClass::Disputes => { let dispute_id = tracking_data.primary_object_id.clone(); - let request = DisputeId { dispute_id }; + let request = DisputeRetrieveRequest { + dispute_id, + force_sync: None, + }; - let dispute_response = - match retrieve_dispute(state, merchant_context.clone(), None, request).await? { - ApplicationResponse::Json(dispute_response) - | ApplicationResponse::JsonWithHeaders((dispute_response, _)) => { - Ok(dispute_response) - } - ApplicationResponse::StatusOk - | ApplicationResponse::TextPlain(_) - | ApplicationResponse::JsonForRedirection(_) - | ApplicationResponse::Form(_) - | ApplicationResponse::GenericLinkForm(_) - | ApplicationResponse::PaymentLinkForm(_) - | ApplicationResponse::FileData(_) => { - Err(errors::ProcessTrackerError::ResourceFetchingFailed { - resource_name: tracking_data.primary_object_id.clone(), - }) - } + let dispute_response = match Box::pin(retrieve_dispute( + state, + merchant_context.clone(), + None, + request, + )) + .await? + { + ApplicationResponse::Json(dispute_response) + | ApplicationResponse::JsonWithHeaders((dispute_response, _)) => { + Ok(dispute_response) } - .map(Box::new)?; + ApplicationResponse::StatusOk + | ApplicationResponse::TextPlain(_) + | ApplicationResponse::JsonForRedirection(_) + | ApplicationResponse::Form(_) + | ApplicationResponse::GenericLinkForm(_) + | ApplicationResponse::PaymentLinkForm(_) + | ApplicationResponse::FileData(_) => { + Err(errors::ProcessTrackerError::ResourceFetchingFailed { + resource_name: tracking_data.primary_object_id.clone(), + }) + } + } + .map(Box::new)?; let event_type = Some(EventType::from(dispute_response.dispute_status)); logger::debug!(current_resource_status=%dispute_response.dispute_status); diff --git a/crates/router/src/workflows/process_dispute.rs b/crates/router/src/workflows/process_dispute.rs new file mode 100644 index 0000000000..1f38cde450 --- /dev/null +++ b/crates/router/src/workflows/process_dispute.rs @@ -0,0 +1,205 @@ +use common_utils::ext_traits::{StringExt, ValueExt}; +use diesel_models::process_tracker::business_status; +use error_stack::ResultExt; +use router_env::logger; +use scheduler::{ + consumer::{self, types::process_data, workflows::ProcessTrackerWorkflow}, + errors as sch_errors, utils as scheduler_utils, +}; + +#[cfg(feature = "v1")] +use crate::core::webhooks::incoming::get_payment_attempt_from_object_reference_id; +use crate::{ + core::disputes, + db::StorageInterface, + errors, + routes::SessionState, + types::{api, domain, storage}, +}; + +pub struct ProcessDisputeWorkflow; + +/// This workflow inserts only new dispute records into the dispute table and triggers related outgoing webhook +#[async_trait::async_trait] +impl ProcessTrackerWorkflow for ProcessDisputeWorkflow { + #[cfg(feature = "v2")] + async fn execute_workflow<'a>( + &'a self, + _state: &'a SessionState, + _process: storage::ProcessTracker, + ) -> Result<(), sch_errors::ProcessTrackerError> { + todo!() + } + + #[cfg(feature = "v1")] + async fn execute_workflow<'a>( + &'a self, + state: &'a SessionState, + process: storage::ProcessTracker, + ) -> Result<(), sch_errors::ProcessTrackerError> { + let db: &dyn StorageInterface = &*state.store; + let tracking_data: api::ProcessDisputePTData = process + .tracking_data + .clone() + .parse_value("ProcessDisputePTData")?; + let key_manager_state = &state.into(); + let key_store = db + .get_merchant_key_store_by_merchant_id( + key_manager_state, + &tracking_data.merchant_id, + &db.get_master_key().to_vec().into(), + ) + .await?; + + let merchant_account = db + .find_merchant_account_by_merchant_id( + key_manager_state, + &tracking_data.merchant_id, + &key_store, + ) + .await?; + + let merchant_context = domain::MerchantContext::NormalMerchant(Box::new(domain::Context( + merchant_account.clone(), + key_store.clone(), + ))); + + let payment_attempt = get_payment_attempt_from_object_reference_id( + state, + tracking_data.dispute_payload.object_reference_id.clone(), + &merchant_context, + ) + .await?; + + let business_profile = state + .store + .find_business_profile_by_profile_id( + &(state).into(), + merchant_context.get_merchant_key_store(), + &payment_attempt.profile_id, + ) + .await?; + + // Check if the dispute already exists + let dispute = state + .store + .find_by_merchant_id_payment_id_connector_dispute_id( + merchant_context.get_merchant_account().get_id(), + &payment_attempt.payment_id, + &tracking_data.dispute_payload.connector_dispute_id, + ) + .await + .ok() + .flatten(); + + if dispute.is_some() { + // Dispute already exists — mark the process as complete + state + .store + .as_scheduler() + .finish_process_with_business_status(process, business_status::COMPLETED_BY_PT) + .await?; + } else { + // Update dispute data + let response = disputes::update_dispute_data( + state, + merchant_context, + business_profile, + dispute, + tracking_data.dispute_payload, + payment_attempt, + tracking_data.connector_name.as_str(), + ) + .await + .map_err(|error| logger::error!("Dispute update failed: {error}")); + + match response { + Ok(_) => { + state + .store + .as_scheduler() + .finish_process_with_business_status( + process, + business_status::COMPLETED_BY_PT, + ) + .await?; + } + Err(_) => { + retry_sync_task( + db, + tracking_data.connector_name, + tracking_data.merchant_id, + process, + ) + .await?; + } + } + } + Ok(()) + } + + async fn error_handler<'a>( + &'a self, + state: &'a SessionState, + process: storage::ProcessTracker, + error: sch_errors::ProcessTrackerError, + ) -> errors::CustomResult<(), sch_errors::ProcessTrackerError> { + consumer::consumer_error_handler(state.store.as_scheduler(), process, error).await + } +} + +pub async fn get_sync_process_schedule_time( + db: &dyn StorageInterface, + connector: &str, + merchant_id: &common_utils::id_type::MerchantId, + retry_count: i32, +) -> Result, errors::ProcessTrackerError> { + let mapping: common_utils::errors::CustomResult< + process_data::ConnectorPTMapping, + errors::StorageError, + > = db + .find_config_by_key(&format!("pt_mapping_{connector}")) + .await + .map(|value| value.config) + .and_then(|config| { + config + .parse_struct("ConnectorPTMapping") + .change_context(errors::StorageError::DeserializationFailed) + }); + let mapping = match mapping { + Ok(x) => x, + Err(error) => { + logger::info!(?error, "Redis Mapping Error"); + process_data::ConnectorPTMapping::default() + } + }; + let time_delta = scheduler_utils::get_schedule_time(mapping, merchant_id, retry_count); + + Ok(scheduler_utils::get_time_from_delta(time_delta)) +} + +/// Schedule the task for retry +/// +/// Returns bool which indicates whether this was the last retry or not +pub async fn retry_sync_task( + db: &dyn StorageInterface, + connector: String, + merchant_id: common_utils::id_type::MerchantId, + pt: storage::ProcessTracker, +) -> Result { + let schedule_time = + get_sync_process_schedule_time(db, &connector, &merchant_id, pt.retry_count + 1).await?; + + match schedule_time { + Some(s_time) => { + db.as_scheduler().retry_process(pt, s_time).await?; + Ok(false) + } + None => { + db.as_scheduler() + .finish_process_with_business_status(pt, business_status::RETRIES_EXCEEDED) + .await?; + Ok(true) + } + } +} diff --git a/crates/scheduler/src/utils.rs b/crates/scheduler/src/utils.rs index 3d7f637de9..58dd0be4da 100644 --- a/crates/scheduler/src/utils.rs +++ b/crates/scheduler/src/utils.rs @@ -259,6 +259,23 @@ pub fn get_process_tracker_id<'a>( ) } +pub fn get_process_tracker_id_for_dispute_list<'a>( + runner: storage::ProcessTrackerRunner, + merchant_connector_account_id: &'a common_utils::id_type::MerchantConnectorAccountId, + created_from: time::PrimitiveDateTime, + merchant_id: &'a common_utils::id_type::MerchantId, +) -> String { + format!( + "{runner}_{:04}{}{:02}{:02}_{}_{}", + created_from.year(), + created_from.month(), + created_from.day(), + created_from.hour(), + merchant_connector_account_id.get_string_repr(), + merchant_id.get_string_repr() + ) +} + pub fn get_time_from_delta(delta: Option) -> Option { delta.map(|t| common_utils::date_time::now().saturating_add(time::Duration::seconds(t.into()))) } diff --git a/loadtest/config/development.toml b/loadtest/config/development.toml index a626aa6001..48612eb130 100644 --- a/loadtest/config/development.toml +++ b/loadtest/config/development.toml @@ -208,6 +208,7 @@ worldline.base_url = "https://eu.sandbox.api-ingenico.com/" worldpay.base_url = "https://try.access.worldpay.com/" worldpayvantiv.base_url = "https://transact.vantivprelive.com/vap/communicator/online" worldpayvantiv.secondary_base_url = "https://onlinessr.vantivprelive.com" +worldpayvantiv.third_base_url = "https://services.vantivprelive.com" worldpayxml.base_url = "https://secure-test.worldpay.com/jsp/merchant/xml/paymentService.jsp" xendit.base_url = "https://api.xendit.co" wise.base_url = "https://api.sandbox.transferwise.tech/" diff --git a/migrations/2025-07-14-add_dispute_polling_interval_to_business_profile/down.sql b/migrations/2025-07-14-add_dispute_polling_interval_to_business_profile/down.sql new file mode 100644 index 0000000000..f2bf135a81 --- /dev/null +++ b/migrations/2025-07-14-add_dispute_polling_interval_to_business_profile/down.sql @@ -0,0 +1 @@ +ALTER TABLE business_profile DROP COLUMN IF EXISTS dispute_polling_interval; \ No newline at end of file diff --git a/migrations/2025-07-14-add_dispute_polling_interval_to_business_profile/up.sql b/migrations/2025-07-14-add_dispute_polling_interval_to_business_profile/up.sql new file mode 100644 index 0000000000..14f0d5a066 --- /dev/null +++ b/migrations/2025-07-14-add_dispute_polling_interval_to_business_profile/up.sql @@ -0,0 +1,4 @@ + +ALTER TABLE business_profile ADD COLUMN IF NOT EXISTS dispute_polling_interval INTEGER; +ALTER TYPE "DisputeStage" ADD VALUE 'arbitration'; +AlTER TYPE "DisputeStage" ADD VALUE 'dispute_reversal';