diff --git a/config/config.example.toml b/config/config.example.toml index 8c45e94d95..30e3bcb9fb 100644 --- a/config/config.example.toml +++ b/config/config.example.toml @@ -169,6 +169,7 @@ payu.base_url = "https://secure.snd.payu.com/" rapyd.base_url = "https://sandboxapi.rapyd.net" shift4.base_url = "https://api.shift4.com/" stripe.base_url = "https://api.stripe.com/" +stripe.base_url_file_upload = "https://files.stripe.com/" worldline.base_url = "https://eu.sandbox.api-ingenico.com/" worldpay.base_url = "https://try.access.worldpay.com/" trustpay.base_url = "https://test-tpgw.trustpay.eu/" diff --git a/config/development.toml b/config/development.toml index 65cc1bc4d6..2ca4ba2777 100644 --- a/config/development.toml +++ b/config/development.toml @@ -122,6 +122,7 @@ payu.base_url = "https://secure.snd.payu.com/" rapyd.base_url = "https://sandboxapi.rapyd.net" shift4.base_url = "https://api.shift4.com/" stripe.base_url = "https://api.stripe.com/" +stripe.base_url_file_upload = "https://files.stripe.com/" worldline.base_url = "https://eu.sandbox.api-ingenico.com/" worldpay.base_url = "https://try.access.worldpay.com/" trustpay.base_url = "https://test-tpgw.trustpay.eu/" @@ -185,6 +186,10 @@ paypal = { currency = "CHF,DKK,EUR,GBP,NOK,PLN,SEK,USD,AUD,NZD,CAD" } google_pay = { country = "AL,DZ,AS,AO,AG,AR,AU,AT,AZ,BH,BY,BE,BR,BG,CA,CL,CO,HR,CZ,DK,DO,EG,EE,FI,FR,DE,GR,HK,HU,IN,ID,IE,IL,IT,JP,JO,KZ,KE,KW,LV,LB,LT,LU,MY,MX,NL,NZ,NO,OM,PK,PA,PE,PH,PL,PT,QA,RO,RU,SA,SG,SK,ZA,ES,LK,SE,CH,TW,TH,TR,UA,AE,GB,US,UY,VN" } apple_pay = { country = "AU,CN,HK,JP,MO,MY,NZ,SG,TW,AM,AT,AZ,BY,BE,BG,HR,CY,CZ,DK,EE,FO,FI,FR,GE,DE,GR,GL,GG,HU,IS,IE,IM,IT,KZ,JE,LV,LI,LT,LU,MT,MD,MC,ME,NL,NO,PL,PT,RO,SM,RS,SK,SI,ES,SE,CH,UA,GB,AR,CO,CR,BR,MX,PE,BH,IL,JO,KW,PS,QA,SA,AE,CA,UM,US" } +[file_upload_config] +bucket_name = "" +region = "" + [tokenization] stripe = { long_lived_token = false, payment_method = "wallet"} checkout = { long_lived_token = false, payment_method = "wallet"} diff --git a/config/docker_compose.toml b/config/docker_compose.toml index 0633e03bda..d1882c6140 100644 --- a/config/docker_compose.toml +++ b/config/docker_compose.toml @@ -96,6 +96,7 @@ payu.base_url = "https://secure.snd.payu.com/" rapyd.base_url = "https://sandboxapi.rapyd.net" shift4.base_url = "https://api.shift4.com/" stripe.base_url = "https://api.stripe.com/" +stripe.base_url_file_upload = "https://files.stripe.com/" worldline.base_url = "https://eu.sandbox.api-ingenico.com/" worldpay.base_url = "https://try.access.worldpay.com/" trustpay.base_url = "https://test-tpgw.trustpay.eu/" diff --git a/crates/api_models/src/disputes.rs b/crates/api_models/src/disputes.rs index df8d9118a1..8dc56fba94 100644 --- a/crates/api_models/src/disputes.rs +++ b/crates/api_models/src/disputes.rs @@ -73,3 +73,57 @@ pub struct DisputeListConstraints { #[serde(rename = "received_time.gte")] pub received_time_gte: Option, } + +#[derive(Default, Clone, Debug, Serialize, Deserialize, ToSchema)] +pub struct SubmitEvidenceRequest { + ///Dispute Id + pub dispute_id: String, + /// Logs showing the usage of service by customer + pub access_activity_log: Option, + /// Billing address of the customer + pub billing_address: Option, + /// File Id of cancellation policy + pub cancellation_policy: Option, + /// Details of showing cancellation policy to customer before purchase + pub cancellation_policy_disclosure: Option, + /// Details telling why customer's subscription was not cancelled + pub cancellation_rebuttal: Option, + /// File Id of customer communication + pub customer_communication: Option, + /// Customer email address + pub customer_email_address: Option, + /// Customer name + pub customer_name: Option, + /// IP address of the customer + pub customer_purchase_ip: Option, + /// Fild Id of customer signature + pub customer_signature: Option, + /// Product Description + pub product_description: Option, + /// File Id of receipt + pub receipt: Option, + /// File Id of refund policy + pub refund_policy: Option, + /// Details of showing refund policy to customer before purchase + pub refund_policy_disclosure: Option, + /// Details why customer is not entitled to refund + pub refund_refusal_explanation: Option, + /// Customer service date + pub service_date: Option, + /// File Id service documentation + pub service_documentation: Option, + /// Shipping address of the customer + pub shipping_address: Option, + /// Delivery service that shipped the product + pub shipping_carrier: Option, + /// Shipping date + pub shipping_date: Option, + /// File Id shipping documentation + pub shipping_documentation: Option, + /// Tracking number of shipped product + pub shipping_tracking_number: Option, + /// Any additional supporting file + pub uncategorized_file: Option, + /// Any additional evidence statements + pub uncategorized_text: Option, +} diff --git a/crates/api_models/src/enums.rs b/crates/api_models/src/enums.rs index 43c341dcd6..39e04d243d 100644 --- a/crates/api_models/src/enums.rs +++ b/crates/api_models/src/enums.rs @@ -625,6 +625,9 @@ impl Connector { | (Self::Trustpay, PaymentMethod::BankRedirect) ) } + pub fn supports_file_storage_module(&self) -> bool { + matches!(self, Self::Stripe) + } } #[derive( diff --git a/crates/api_models/src/files.rs b/crates/api_models/src/files.rs index 8b13789179..6fafce7864 100644 --- a/crates/api_models/src/files.rs +++ b/crates/api_models/src/files.rs @@ -1 +1,7 @@ +use utoipa::ToSchema; +#[derive(Debug, serde::Serialize, ToSchema)] +pub struct CreateFileResponse { + /// ID of the file created + pub file_id: String, +} diff --git a/crates/router/Cargo.toml b/crates/router/Cargo.toml index 70ae4c33e4..be6d7cf5bb 100644 --- a/crates/router/Cargo.toml +++ b/crates/router/Cargo.toml @@ -11,11 +11,12 @@ build = "src/build.rs" [features] default = ["kv_store", "stripe", "oltp", "olap", "accounts_cache"] +s3 = [] kms = ["external_services/kms"] basilisk = ["kms"] stripe = ["dep:serde_qs"] -sandbox = ["kms", "stripe", "basilisk"] -production = ["kms", "stripe", "basilisk"] +sandbox = ["kms", "stripe", "basilisk", "s3"] +production = ["kms", "stripe", "basilisk", "s3"] olap = [] oltp = [] kv_store = [] @@ -60,7 +61,7 @@ num_cpus = "1.15.0" once_cell = "1.17.1" rand = "0.8.5" regex = "1.7.3" -reqwest = { version = "0.11.16", features = ["json", "native-tls", "gzip"] } +reqwest = { version = "0.11.16", features = ["json", "native-tls", "gzip", "multipart"] } ring = "0.16.20" serde = { version = "1.0.160", features = ["derive"] } serde_json = "1.0.96" @@ -88,6 +89,10 @@ redis_interface = { version = "0.1.0", path = "../redis_interface" } router_derive = { version = "0.1.0", path = "../router_derive" } router_env = { version = "0.1.0", path = "../router_env", features = ["log_extra_implicit_fields", "log_custom_entries_to_extra"] } storage_models = { version = "0.1.0", path = "../storage_models", features = ["kv_store"] } +actix-multipart = "0.6.0" +aws-sdk-s3 = "0.25.0" +aws-config = "0.55.1" +infer = "0.13.0" [build-dependencies] router_env = { version = "0.1.0", path = "../router_env", default-features = false } diff --git a/crates/router/src/compatibility/stripe/errors.rs b/crates/router/src/compatibility/stripe/errors.rs index e718651796..766aadd212 100644 --- a/crates/router/src/compatibility/stripe/errors.rs +++ b/crates/router/src/compatibility/stripe/errors.rs @@ -182,6 +182,20 @@ pub enum StripeErrorCode { IncorrectConnectorNameGiven, #[error(error_type = StripeErrorType::HyperswitchError, code = "", message = "No such {object}: '{id}'")] ResourceMissing { object: String, id: String }, + #[error(error_type = StripeErrorType::HyperswitchError, code = "", message = "File validation failed")] + FileValidationFailed, + #[error(error_type = StripeErrorType::HyperswitchError, code = "", message = "File not found in the request")] + MissingFile, + #[error(error_type = StripeErrorType::HyperswitchError, code = "", message = "File puropse not found in the request")] + MissingFilePurpose, + #[error(error_type = StripeErrorType::HyperswitchError, code = "", message = "File content type not found")] + MissingFileContentType, + #[error(error_type = StripeErrorType::HyperswitchError, code = "", message = "Dispute id not found in the request")] + MissingDisputeId, + #[error(error_type = StripeErrorType::HyperswitchError, code = "", message = "File does not exists in our records")] + FileNotFound, + #[error(error_type = StripeErrorType::HyperswitchError, code = "", message = "File not available")] + FileNotAvailable, // [#216]: https://github.com/juspay/hyperswitch/issues/216 // Implement the remaining stripe error codes @@ -466,6 +480,16 @@ impl From for StripeErrorCode { object: "dispute".to_owned(), id: dispute_id, }, + errors::ApiErrorResponse::DisputeStatusValidationFailed { reason } => { + Self::InternalServerError + } + errors::ApiErrorResponse::FileValidationFailed { .. } => Self::FileValidationFailed, + errors::ApiErrorResponse::MissingFile => Self::MissingFile, + errors::ApiErrorResponse::MissingFilePurpose => Self::MissingFilePurpose, + errors::ApiErrorResponse::MissingFileContentType => Self::MissingFileContentType, + errors::ApiErrorResponse::MissingDisputeId => Self::MissingDisputeId, + errors::ApiErrorResponse::FileNotFound => Self::FileNotFound, + errors::ApiErrorResponse::FileNotAvailable => Self::FileNotAvailable, errors::ApiErrorResponse::NotSupported { .. } => Self::InternalServerError, } } @@ -514,7 +538,14 @@ impl actix_web::ResponseError for StripeErrorCode { | Self::PaymentIntentUnexpectedState { .. } | Self::DuplicatePayment { .. } | Self::IncorrectConnectorNameGiven - | Self::ResourceMissing { .. } => StatusCode::BAD_REQUEST, + | Self::ResourceMissing { .. } + | Self::FileValidationFailed + | Self::MissingFile + | Self::MissingFileContentType + | Self::MissingFilePurpose + | Self::MissingDisputeId + | Self::FileNotFound + | Self::FileNotAvailable => StatusCode::BAD_REQUEST, Self::RefundFailed | Self::InternalServerError | Self::MandateActive diff --git a/crates/router/src/compatibility/wrap.rs b/crates/router/src/compatibility/wrap.rs index 69ca3c5272..3ed5f8f67a 100644 --- a/crates/router/src/compatibility/wrap.rs +++ b/crates/router/src/compatibility/wrap.rs @@ -56,6 +56,9 @@ where } Ok(api::ApplicationResponse::StatusOk) => api::http_response_ok(), Ok(api::ApplicationResponse::TextPlain(text)) => api::http_response_plaintext(text), + Ok(api::ApplicationResponse::FileData((file_data, content_type))) => { + api::http_response_file_data(file_data, content_type) + } Ok(api::ApplicationResponse::JsonForRedirection(response)) => { match serde_json::to_string(&response) { Ok(res) => api::http_redirect_response(res, response), diff --git a/crates/router/src/configs/settings.rs b/crates/router/src/configs/settings.rs index 8589b25753..9eebc58c8c 100644 --- a/crates/router/src/configs/settings.rs +++ b/crates/router/src/configs/settings.rs @@ -62,6 +62,8 @@ pub struct Settings { pub api_keys: ApiKeys, #[cfg(feature = "kms")] pub kms: kms::KmsConfig, + #[cfg(feature = "s3")] + pub file_upload_config: FileUploadConfig, pub tokenization: TokenizationConfig, } @@ -306,7 +308,7 @@ pub struct Connectors { pub payu: ConnectorParams, pub rapyd: ConnectorParams, pub shift4: ConnectorParams, - pub stripe: ConnectorParams, + pub stripe: ConnectorParamsWithFileUploadUrl, pub worldline: ConnectorParams, pub worldpay: ConnectorParams, pub trustpay: ConnectorParamsWithMoreUrls, @@ -328,6 +330,13 @@ pub struct ConnectorParamsWithMoreUrls { pub base_url_bank_redirects: String, } +#[derive(Debug, Deserialize, Clone, Default)] +#[serde(default)] +pub struct ConnectorParamsWithFileUploadUrl { + pub base_url: String, + pub base_url_file_upload: String, +} + #[derive(Debug, Clone, Deserialize)] #[serde(default)] pub struct SchedulerSettings { @@ -387,6 +396,16 @@ pub struct ApiKeys { pub hash_key: String, } +#[cfg(feature = "s3")] +#[derive(Debug, Deserialize, Clone, Default)] +#[serde(default)] +pub struct FileUploadConfig { + /// The AWS region to send file uploads + pub region: String, + /// The AWS s3 bucket to send file uploads + pub bucket_name: String, +} + impl Settings { pub fn new() -> ApplicationResult { Self::with_config_path(None) @@ -465,7 +484,8 @@ impl Settings { self.kms .validate() .map_err(|error| ApplicationError::InvalidConfigurationValueError(error.into()))?; - + #[cfg(feature = "s3")] + self.file_upload_config.validate()?; Ok(()) } } diff --git a/crates/router/src/configs/validations.rs b/crates/router/src/configs/validations.rs index 7049fb5f6a..a3e56ac2e6 100644 --- a/crates/router/src/configs/validations.rs +++ b/crates/router/src/configs/validations.rs @@ -155,6 +155,21 @@ impl super::settings::ConnectorParams { } } +impl super::settings::ConnectorParamsWithFileUploadUrl { + pub fn validate(&self) -> Result<(), ApplicationError> { + common_utils::fp_utils::when(self.base_url.is_default_or_empty(), || { + Err(ApplicationError::InvalidConfigurationValueError( + "connector base URL must not be empty".into(), + )) + })?; + common_utils::fp_utils::when(self.base_url_file_upload.is_default_or_empty(), || { + Err(ApplicationError::InvalidConfigurationValueError( + "connector file upload base URL must not be empty".into(), + )) + }) + } +} + impl super::settings::SchedulerSettings { pub fn validate(&self) -> Result<(), ApplicationError> { use common_utils::fp_utils::when; @@ -198,6 +213,25 @@ impl super::settings::DrainerSettings { } } +#[cfg(feature = "s3")] +impl super::settings::FileUploadConfig { + pub fn validate(&self) -> Result<(), ApplicationError> { + use common_utils::fp_utils::when; + + when(self.region.is_default_or_empty(), || { + Err(ApplicationError::InvalidConfigurationValueError( + "s3 region must not be empty".into(), + )) + })?; + + when(self.bucket_name.is_default_or_empty(), || { + Err(ApplicationError::InvalidConfigurationValueError( + "s3 bucket name must not be empty".into(), + )) + }) + } +} + impl super::settings::ApiKeys { pub fn validate(&self) -> Result<(), ApplicationError> { use common_utils::fp_utils::when; diff --git a/crates/router/src/connector/checkout.rs b/crates/router/src/connector/checkout.rs index b034f25bb4..25e4a6e14e 100644 --- a/crates/router/src/connector/checkout.rs +++ b/crates/router/src/connector/checkout.rs @@ -115,7 +115,9 @@ impl api::PaymentVoid for Checkout {} impl api::PaymentCapture for Checkout {} impl api::PaymentSession for Checkout {} impl api::ConnectorAccessToken for Checkout {} +impl api::AcceptDispute for Checkout {} impl api::PaymentToken for Checkout {} +impl api::Dispute for Checkout {} impl ConnectorIntegration< @@ -697,6 +699,139 @@ impl ConnectorIntegration + for Checkout +{ + fn get_headers( + &self, + req: &types::AcceptDisputeRouterData, + _connectors: &settings::Connectors, + ) -> CustomResult, errors::ConnectorError> { + let mut header = vec![( + headers::CONTENT_TYPE.to_string(), + types::AcceptDisputeType::get_content_type(self).to_string(), + )]; + let mut api_key = self.get_auth_header(&req.connector_auth_type)?; + header.append(&mut api_key); + Ok(header) + } + + fn get_url( + &self, + req: &types::AcceptDisputeRouterData, + connectors: &settings::Connectors, + ) -> CustomResult { + Ok(format!( + "{}{}{}{}", + self.base_url(connectors), + "disputes/", + req.request.connector_dispute_id, + "/accept" + )) + } + + fn build_request( + &self, + req: &types::AcceptDisputeRouterData, + connectors: &settings::Connectors, + ) -> CustomResult, errors::ConnectorError> { + Ok(Some( + services::RequestBuilder::new() + .method(services::Method::Post) + .url(&types::AcceptDisputeType::get_url(self, req, connectors)?) + .attach_default_headers() + .headers(types::AcceptDisputeType::get_headers( + self, req, connectors, + )?) + .build(), + )) + } + + fn handle_response( + &self, + data: &types::AcceptDisputeRouterData, + _res: types::Response, + ) -> CustomResult { + Ok(types::AcceptDisputeRouterData { + response: Ok(types::AcceptDisputeResponse { + dispute_status: api::enums::DisputeStatus::DisputeAccepted, + connector_status: None, + }), + ..data.clone() + }) + } + + fn get_error_response( + &self, + res: types::Response, + ) -> CustomResult { + let response: checkout::ErrorResponse = if res.response.is_empty() { + checkout::ErrorResponse { + request_id: None, + error_type: if res.status_code == 401 { + Some("Invalid Api Key".to_owned()) + } else { + None + }, + error_codes: None, + } + } else { + res.response + .parse_struct("ErrorResponse") + .change_context(errors::ConnectorError::ResponseDeserializationFailed)? + }; + + Ok(types::ErrorResponse { + status_code: res.status_code, + code: response + .error_codes + .unwrap_or_else(|| vec![consts::NO_ERROR_CODE.to_string()]) + .join(" & "), + message: response + .error_type + .unwrap_or_else(|| consts::NO_ERROR_MESSAGE.to_string()), + reason: None, + }) + } +} + +impl api::UploadFile for Checkout {} + +impl ConnectorIntegration + for Checkout +{ +} + +#[async_trait::async_trait] +impl api::FileUpload for Checkout { + fn validate_file_upload( + &self, + purpose: api::FilePurpose, + file_size: i32, + file_type: mime::Mime, + ) -> CustomResult<(), errors::ConnectorError> { + match purpose { + api::FilePurpose::DisputeEvidence => { + let supported_file_types = + vec!["image/jpeg", "image/jpg", "image/png", "application/pdf"]; + // 4 Megabytes (MB) + if file_size > 4000000 { + Err(errors::ConnectorError::FileValidationFailed { + reason: "file_size exceeded the max file size of 4MB".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(()) + } +} + #[async_trait::async_trait] impl api::IncomingWebhook for Checkout { fn get_webhook_source_verification_algorithm( diff --git a/crates/router/src/connector/stripe.rs b/crates/router/src/connector/stripe.rs index 7e8881d8f3..dbdcb7cf48 100644 --- a/crates/router/src/connector/stripe.rs +++ b/crates/router/src/connector/stripe.rs @@ -948,6 +948,249 @@ impl services::ConnectorIntegration CustomResult<(), errors::ConnectorError> { + match purpose { + api::FilePurpose::DisputeEvidence => { + let supported_file_types = vec!["image/jpeg", "image/png", "application/pdf"]; + // 5 Megabytes (MB) + if file_size > 5000000 { + Err(errors::ConnectorError::FileValidationFailed { + reason: "file_size exceeded the max file size of 5MB".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(()) + } +} + +impl + services::ConnectorIntegration< + api::Upload, + types::UploadFileRequestData, + types::UploadFileResponse, + > for Stripe +{ + fn get_headers( + &self, + req: &types::RouterData< + api::Upload, + types::UploadFileRequestData, + types::UploadFileResponse, + >, + _connectors: &settings::Connectors, + ) -> CustomResult, errors::ConnectorError> { + self.get_auth_header(&req.connector_auth_type) + } + + fn get_content_type(&self) -> &'static str { + "multipart/form-data" + } + + fn get_url( + &self, + _req: &types::UploadFileRouterData, + connectors: &settings::Connectors, + ) -> CustomResult { + Ok(format!( + "{}{}", + connectors.stripe.base_url_file_upload, "v1/files" + )) + } + + fn get_request_form_data( + &self, + req: &types::UploadFileRouterData, + ) -> CustomResult, errors::ConnectorError> { + let stripe_req = transformers::construct_file_upload_request(req.clone())?; + Ok(Some(stripe_req)) + } + + fn build_request( + &self, + req: &types::UploadFileRouterData, + connectors: &settings::Connectors, + ) -> CustomResult, errors::ConnectorError> { + Ok(Some( + services::RequestBuilder::new() + .method(services::Method::Post) + .url(&types::UploadFileType::get_url(self, req, connectors)?) + .attach_default_headers() + .headers(types::UploadFileType::get_headers(self, req, connectors)?) + .form_data(types::UploadFileType::get_request_form_data(self, req)?) + .content_type(services::request::ContentType::FormData) + .build(), + )) + } + + #[instrument(skip_all)] + fn handle_response( + &self, + data: &types::UploadFileRouterData, + res: types::Response, + ) -> CustomResult< + types::RouterData, + errors::ConnectorError, + > { + let response: stripe::FileUploadResponse = res + .response + .parse_struct("Stripe FileUploadResponse") + .change_context(errors::ConnectorError::ResponseDeserializationFailed)?; + Ok(types::UploadFileRouterData { + response: Ok(types::UploadFileResponse { + provider_file_id: response.file_id, + }), + ..data.clone() + }) + } + + fn get_error_response( + &self, + res: types::Response, + ) -> CustomResult { + let response: stripe::ErrorResponse = res + .response + .parse_struct("ErrorResponse") + .change_context(errors::ConnectorError::ResponseDeserializationFailed)?; + Ok(types::ErrorResponse { + status_code: res.status_code, + code: response + .error + .code + .unwrap_or_else(|| consts::NO_ERROR_CODE.to_string()), + message: response + .error + .message + .unwrap_or_else(|| consts::NO_ERROR_MESSAGE.to_string()), + reason: None, + }) + } +} + +impl api::SubmitEvidence for Stripe {} + +impl + services::ConnectorIntegration< + api::Evidence, + types::SubmitEvidenceRequestData, + types::SubmitEvidenceResponse, + > for Stripe +{ + fn get_headers( + &self, + req: &types::SubmitEvidenceRouterData, + _connectors: &settings::Connectors, + ) -> CustomResult, errors::ConnectorError> { + let mut header = vec![( + headers::CONTENT_TYPE.to_string(), + types::SubmitEvidenceType::get_content_type(self).to_string(), + )]; + let mut api_key = self.get_auth_header(&req.connector_auth_type)?; + header.append(&mut api_key); + Ok(header) + } + + fn get_content_type(&self) -> &'static str { + "application/x-www-form-urlencoded" + } + + fn get_url( + &self, + req: &types::SubmitEvidenceRouterData, + connectors: &settings::Connectors, + ) -> CustomResult { + Ok(format!( + "{}{}{}", + self.base_url(connectors), + "v1/disputes/", + req.request.connector_dispute_id + )) + } + + fn get_request_body( + &self, + req: &types::SubmitEvidenceRouterData, + ) -> CustomResult, errors::ConnectorError> { + let stripe_req = stripe::Evidence::try_from(req)?; + let stripe_req_string = utils::Encode::::url_encode(&stripe_req) + .change_context(errors::ConnectorError::RequestEncodingFailed)?; + print!("Stripe request: {:?}", stripe_req_string); + Ok(Some(stripe_req_string)) + } + + fn build_request( + &self, + req: &types::SubmitEvidenceRouterData, + connectors: &settings::Connectors, + ) -> CustomResult, errors::ConnectorError> { + let request = services::RequestBuilder::new() + .method(services::Method::Post) + .url(&types::SubmitEvidenceType::get_url(self, req, connectors)?) + .attach_default_headers() + .headers(types::SubmitEvidenceType::get_headers( + self, req, connectors, + )?) + .body(types::SubmitEvidenceType::get_request_body(self, req)?) + .build(); + Ok(Some(request)) + } + + #[instrument(skip_all)] + fn handle_response( + &self, + data: &types::SubmitEvidenceRouterData, + res: types::Response, + ) -> CustomResult { + let response: stripe::DisputeObj = res + .response + .parse_struct("Stripe DisputeObj") + .change_context(errors::ConnectorError::ResponseDeserializationFailed)?; + Ok(types::SubmitEvidenceRouterData { + response: Ok(types::SubmitEvidenceResponse { + dispute_status: api_models::enums::DisputeStatus::DisputeChallenged, + connector_status: Some(response.status), + }), + ..data.clone() + }) + } + + fn get_error_response( + &self, + res: types::Response, + ) -> CustomResult { + let response: stripe::ErrorResponse = res + .response + .parse_struct("ErrorResponse") + .change_context(errors::ConnectorError::ResponseDeserializationFailed)?; + Ok(types::ErrorResponse { + status_code: res.status_code, + code: response + .error + .code + .unwrap_or_else(|| consts::NO_ERROR_CODE.to_string()), + message: response + .error + .message + .unwrap_or_else(|| consts::NO_ERROR_MESSAGE.to_string()), + reason: None, + }) + } +} + fn get_signature_elements_from_header( headers: &actix_web::http::header::HeaderMap, ) -> CustomResult>, errors::ConnectorError> { diff --git a/crates/router/src/connector/stripe/transformers.rs b/crates/router/src/connector/stripe/transformers.rs index 7b5e7b607a..7b7cbe8694 100644 --- a/crates/router/src/connector/stripe/transformers.rs +++ b/crates/router/src/connector/stripe/transformers.rs @@ -1,6 +1,6 @@ use api_models::{self, enums as api_enums, payments}; use base64::Engine; -use common_utils::{fp_utils, pii}; +use common_utils::{errors::CustomResult, fp_utils, pii}; use error_stack::{IntoReport, ResultExt}; use masking::{ExposeInterface, ExposeOptionInterface, Secret}; use serde::{Deserialize, Serialize}; @@ -1681,3 +1681,121 @@ impl } } } + +pub fn construct_file_upload_request( + file_upload_router_data: types::UploadFileRouterData, +) -> CustomResult { + let request = file_upload_router_data.request; + let mut multipart = reqwest::multipart::Form::new(); + multipart = multipart.text("purpose", "dispute_evidence"); + let file_data = reqwest::multipart::Part::bytes(request.file) + .file_name(request.file_key) + .mime_str(request.file_type.as_ref()) + .map_err(|_| errors::ConnectorError::RequestEncodingFailed)?; + multipart = multipart.part("file", file_data); + Ok(multipart) +} + +#[derive(Debug, Deserialize)] +pub struct FileUploadResponse { + #[serde(rename = "id")] + pub file_id: String, +} + +#[derive(Debug, Serialize)] +pub struct Evidence { + #[serde(rename = "evidence[access_activity_log]")] + pub access_activity_log: Option, + #[serde(rename = "evidence[billing_address]")] + pub billing_address: Option, + #[serde(rename = "evidence[cancellation_policy]")] + pub cancellation_policy: Option, + #[serde(rename = "evidence[cancellation_policy_disclosure]")] + pub cancellation_policy_disclosure: Option, + #[serde(rename = "evidence[cancellation_rebuttal]")] + pub cancellation_rebuttal: Option, + #[serde(rename = "evidence[customer_communication]")] + pub customer_communication: Option, + #[serde(rename = "evidence[customer_email_address]")] + pub customer_email_address: Option, + #[serde(rename = "evidence[customer_name]")] + pub customer_name: Option, + #[serde(rename = "evidence[customer_purchase_ip]")] + pub customer_purchase_ip: Option, + #[serde(rename = "evidence[customer_signature]")] + pub customer_signature: Option, + #[serde(rename = "evidence[product_description]")] + pub product_description: Option, + #[serde(rename = "evidence[receipt]")] + pub receipt: Option, + #[serde(rename = "evidence[refund_policy]")] + pub refund_policy: Option, + #[serde(rename = "evidence[refund_policy_disclosure]")] + pub refund_policy_disclosure: Option, + #[serde(rename = "evidence[refund_refusal_explanation]")] + pub refund_refusal_explanation: Option, + #[serde(rename = "evidence[service_date]")] + pub service_date: Option, + #[serde(rename = "evidence[service_documentation]")] + pub service_documentation: Option, + #[serde(rename = "evidence[shipping_address]")] + pub shipping_address: Option, + #[serde(rename = "evidence[shipping_carrier]")] + pub shipping_carrier: Option, + #[serde(rename = "evidence[shipping_date]")] + pub shipping_date: Option, + #[serde(rename = "evidence[shipping_documentation]")] + pub shipping_documentation: Option, + #[serde(rename = "evidence[shipping_tracking_number]")] + pub shipping_tracking_number: Option, + #[serde(rename = "evidence[uncategorized_file]")] + pub uncategorized_file: Option, + #[serde(rename = "evidence[uncategorized_text]")] + pub uncategorized_text: Option, + pub submit: bool, +} + +impl TryFrom<&types::SubmitEvidenceRouterData> for Evidence { + type Error = error_stack::Report; + fn try_from(item: &types::SubmitEvidenceRouterData) -> Result { + let submit_evidence_request_data = item.request.clone(); + Ok(Self { + access_activity_log: submit_evidence_request_data.access_activity_log, + billing_address: submit_evidence_request_data.billing_address, + cancellation_policy: submit_evidence_request_data.cancellation_policy_provider_file_id, + cancellation_policy_disclosure: submit_evidence_request_data + .cancellation_policy_disclosure, + cancellation_rebuttal: submit_evidence_request_data.cancellation_rebuttal, + customer_communication: submit_evidence_request_data + .customer_communication_provider_file_id, + customer_email_address: submit_evidence_request_data.customer_email_address, + customer_name: submit_evidence_request_data.customer_name, + customer_purchase_ip: submit_evidence_request_data.customer_purchase_ip, + customer_signature: submit_evidence_request_data.customer_signature_provider_file_id, + product_description: submit_evidence_request_data.product_description, + receipt: submit_evidence_request_data.receipt_provider_file_id, + refund_policy: submit_evidence_request_data.refund_policy_provider_file_id, + refund_policy_disclosure: submit_evidence_request_data.refund_policy_disclosure, + refund_refusal_explanation: submit_evidence_request_data.refund_refusal_explanation, + service_date: submit_evidence_request_data.service_date, + service_documentation: submit_evidence_request_data + .service_documentation_provider_file_id, + shipping_address: submit_evidence_request_data.shipping_address, + shipping_carrier: submit_evidence_request_data.shipping_carrier, + shipping_date: submit_evidence_request_data.shipping_date, + shipping_documentation: submit_evidence_request_data + .shipping_documentation_provider_file_id, + shipping_tracking_number: submit_evidence_request_data.shipping_tracking_number, + uncategorized_file: submit_evidence_request_data.uncategorized_file_provider_file_id, + uncategorized_text: submit_evidence_request_data.uncategorized_text, + submit: true, + }) + } +} + +#[derive(Debug, Deserialize)] +pub struct DisputeObj { + #[serde(rename = "id")] + pub dispute_id: String, + pub status: String, +} diff --git a/crates/router/src/core.rs b/crates/router/src/core.rs index 83b321f8ca..ada93005f6 100644 --- a/crates/router/src/core.rs +++ b/crates/router/src/core.rs @@ -5,6 +5,7 @@ pub mod configs; pub mod customers; pub mod disputes; pub mod errors; +pub mod files; pub mod mandate; pub mod metrics; pub mod payment_methods; diff --git a/crates/router/src/core/disputes.rs b/crates/router/src/core/disputes.rs index d00779677b..4b7a031634 100644 --- a/crates/router/src/core/disputes.rs +++ b/crates/router/src/core/disputes.rs @@ -1,10 +1,23 @@ +use api_models::disputes as dispute_models; +use error_stack::ResultExt; use router_env::{instrument, tracing}; +pub mod transformers; -use super::errors::{self, RouterResponse, StorageErrorExt}; +use super::{ + errors::{self, RouterResponse, StorageErrorExt}, + metrics, +}; use crate::{ + core::{payments, utils}, routes::AppState, services, - types::{api::disputes, storage, transformers::ForeignFrom}, + types::{ + api::{self, disputes}, + storage::{self, enums as storage_enums}, + transformers::{ForeignFrom, ForeignInto}, + AcceptDisputeRequestData, AcceptDisputeResponse, SubmitEvidenceRequestData, + SubmitEvidenceResponse, + }, }; #[instrument(skip(state))] @@ -34,10 +47,218 @@ pub async fn retrieve_disputes_list( .store .find_disputes_by_merchant_id(&merchant_account.merchant_id, constraints) .await - .to_not_found_response(errors::ApiErrorResponse::InternalServerError)?; + .to_not_found_response(errors::ApiErrorResponse::InternalServerError) + .attach_printable("Unable to retrieve disputes")?; let disputes_list = disputes .into_iter() .map(api_models::disputes::DisputeResponse::foreign_from) .collect(); Ok(services::ApplicationResponse::Json(disputes_list)) } + +#[instrument(skip(state))] +pub async fn accept_dispute( + state: &AppState, + merchant_account: storage::MerchantAccount, + req: disputes::DisputeId, +) -> RouterResponse { + let db = &state.store; + let dispute = state + .store + .find_dispute_by_merchant_id_dispute_id(&merchant_account.merchant_id, &req.dispute_id) + .await + .to_not_found_response(errors::ApiErrorResponse::DisputeNotFound { + dispute_id: req.dispute_id, + })?; + 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), + || { + metrics::ACCEPT_DISPUTE_STATUS_VALIDATION_FAILURE_METRIC.add(&metrics::CONTEXT, 1, &[]); + Err(errors::ApiErrorResponse::DisputeStatusValidationFailed { + reason: format!( + "This dispute cannot be accepted because the dispute is in {} stage and has {} status", + dispute.dispute_stage, dispute.dispute_status + ), + }) + }, + )?; + let payment_intent = db + .find_payment_intent_by_payment_id_merchant_id( + &dispute.payment_id, + &merchant_account.merchant_id, + 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_account.merchant_id, + 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, + )?; + let connector_integration: services::BoxedConnectorIntegration< + '_, + api::Accept, + AcceptDisputeRequestData, + AcceptDisputeResponse, + > = connector_data.connector.get_connector_integration(); + let router_data = utils::construct_accept_dispute_router_data( + state, + &payment_intent, + &payment_attempt, + &merchant_account, + &dispute, + ) + .await?; + let response = services::execute_connector_processing_step( + state, + connector_integration, + &router_data, + payments::CallConnectorAction::Trigger, + ) + .await + .change_context(errors::ApiErrorResponse::InternalServerError) + .attach_printable("Failed while calling accept dispute connector api")?; + let accept_dispute_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 update_dispute = storage_models::dispute::DisputeUpdate::StatusUpdate { + dispute_status: accept_dispute_response + .dispute_status + .clone() + .foreign_into(), + connector_status: accept_dispute_response.connector_status.clone(), + }; + let updated_dispute = db + .update_dispute(dispute.clone(), update_dispute) + .await + .change_context(errors::ApiErrorResponse::InternalServerError) + .attach_printable_lazy(|| { + format!("Unable to update dispute with dispute_id: {}", dispute_id) + })?; + let dispute_response = api_models::disputes::DisputeResponse::foreign_from(updated_dispute); + Ok(services::ApplicationResponse::Json(dispute_response)) +} + +#[instrument(skip(state))] +pub async fn submit_evidence( + state: &AppState, + merchant_account: storage::MerchantAccount, + req: dispute_models::SubmitEvidenceRequest, +) -> RouterResponse { + let db = &state.store; + let dispute = state + .store + .find_dispute_by_merchant_id_dispute_id(&merchant_account.merchant_id, &req.dispute_id) + .await + .to_not_found_response(errors::ApiErrorResponse::DisputeNotFound { + dispute_id: req.dispute_id.clone(), + })?; + 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), + || { + metrics::EVIDENCE_SUBMISSION_DISPUTE_STATUS_VALIDATION_FAILURE_METRIC.add( + &metrics::CONTEXT, + 1, + &[], + ); + Err(errors::ApiErrorResponse::DisputeStatusValidationFailed { + reason: format!( + "Evidence cannot be submitted because the dispute is in {} stage and has {} status", + dispute.dispute_stage, dispute.dispute_status + ), + }) + }, + )?; + let submit_evidence_request_data = + transformers::get_evidence_request_data(state, &merchant_account, req, &dispute).await?; + let payment_intent = db + .find_payment_intent_by_payment_id_merchant_id( + &dispute.payment_id, + &merchant_account.merchant_id, + 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_account.merchant_id, + 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, + )?; + let connector_integration: services::BoxedConnectorIntegration< + '_, + api::Evidence, + SubmitEvidenceRequestData, + SubmitEvidenceResponse, + > = connector_data.connector.get_connector_integration(); + let router_data = utils::construct_submit_evidence_router_data( + state, + &payment_intent, + &payment_attempt, + &merchant_account, + &dispute, + submit_evidence_request_data, + ) + .await?; + let response = services::execute_connector_processing_step( + state, + connector_integration, + &router_data, + payments::CallConnectorAction::Trigger, + ) + .await + .change_context(errors::ApiErrorResponse::InternalServerError) + .attach_printable("Failed while calling submit evidence connector api")?; + let submit_evidence_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 update_dispute = storage_models::dispute::DisputeUpdate::StatusUpdate { + dispute_status: submit_evidence_response + .dispute_status + .clone() + .foreign_into(), + connector_status: submit_evidence_response.connector_status.clone(), + }; + let updated_dispute = db + .update_dispute(dispute.clone(), update_dispute) + .await + .change_context(errors::ApiErrorResponse::InternalServerError) + .attach_printable_lazy(|| { + format!("Unable to update dispute with dispute_id: {}", dispute_id) + })?; + let dispute_response = api_models::disputes::DisputeResponse::foreign_from(updated_dispute); + Ok(services::ApplicationResponse::Json(dispute_response)) +} diff --git a/crates/router/src/core/disputes/transformers.rs b/crates/router/src/core/disputes/transformers.rs new file mode 100644 index 0000000000..9080941a3b --- /dev/null +++ b/crates/router/src/core/disputes/transformers.rs @@ -0,0 +1,106 @@ +use common_utils::errors::CustomResult; + +use crate::{ + core::{errors, files::helpers::retrieve_file_and_provider_file_id_from_file_id}, + routes::AppState, + types::SubmitEvidenceRequestData, +}; + +pub async fn get_evidence_request_data( + state: &AppState, + merchant_account: &storage_models::merchant_account::MerchantAccount, + evidence_request: api_models::disputes::SubmitEvidenceRequest, + dispute: &storage_models::dispute::Dispute, +) -> CustomResult { + let (cancellation_policy, cancellation_policy_provider_file_id) = + retrieve_file_and_provider_file_id_from_file_id( + state, + evidence_request.cancellation_policy, + merchant_account, + ) + .await?; + let (customer_communication, customer_communication_provider_file_id) = + retrieve_file_and_provider_file_id_from_file_id( + state, + evidence_request.customer_communication, + merchant_account, + ) + .await?; + let (customer_signature, customer_signature_provider_file_id) = + retrieve_file_and_provider_file_id_from_file_id( + state, + evidence_request.customer_signature, + merchant_account, + ) + .await?; + let (receipt, receipt_provider_file_id) = retrieve_file_and_provider_file_id_from_file_id( + state, + evidence_request.receipt, + merchant_account, + ) + .await?; + let (refund_policy, refund_policy_provider_file_id) = + retrieve_file_and_provider_file_id_from_file_id( + state, + evidence_request.refund_policy, + merchant_account, + ) + .await?; + let (service_documentation, service_documentation_provider_file_id) = + retrieve_file_and_provider_file_id_from_file_id( + state, + evidence_request.service_documentation, + merchant_account, + ) + .await?; + let (shipping_documentation, shipping_documentation_provider_file_id) = + retrieve_file_and_provider_file_id_from_file_id( + state, + evidence_request.shipping_documentation, + merchant_account, + ) + .await?; + let (uncategorized_file, uncategorized_file_provider_file_id) = + retrieve_file_and_provider_file_id_from_file_id( + state, + evidence_request.uncategorized_file, + merchant_account, + ) + .await?; + Ok(SubmitEvidenceRequestData { + dispute_id: dispute.dispute_id.clone(), + connector_dispute_id: dispute.connector_dispute_id.clone(), + access_activity_log: evidence_request.access_activity_log, + billing_address: evidence_request.billing_address, + cancellation_policy, + cancellation_policy_provider_file_id, + cancellation_policy_disclosure: evidence_request.cancellation_policy_disclosure, + cancellation_rebuttal: evidence_request.cancellation_rebuttal, + customer_communication, + customer_communication_provider_file_id, + customer_email_address: evidence_request.customer_email_address, + customer_name: evidence_request.customer_name, + customer_purchase_ip: evidence_request.customer_purchase_ip, + customer_signature, + customer_signature_provider_file_id, + product_description: evidence_request.product_description, + receipt, + receipt_provider_file_id, + refund_policy, + refund_policy_provider_file_id, + refund_policy_disclosure: evidence_request.refund_policy_disclosure, + refund_refusal_explanation: evidence_request.refund_refusal_explanation, + service_date: evidence_request.service_date, + service_documentation, + service_documentation_provider_file_id, + shipping_address: evidence_request.shipping_address, + shipping_carrier: evidence_request.shipping_carrier, + shipping_date: evidence_request.shipping_date, + shipping_documentation, + shipping_documentation_provider_file_id, + shipping_tracking_number: evidence_request.shipping_tracking_number, + uncategorized_file, + uncategorized_file_provider_file_id, + uncategorized_text: evidence_request.uncategorized_text, + }) +} diff --git a/crates/router/src/core/errors.rs b/crates/router/src/core/errors.rs index c6c4e0ffdb..c4a6a7d863 100644 --- a/crates/router/src/core/errors.rs +++ b/crates/router/src/core/errors.rs @@ -293,6 +293,8 @@ pub enum ConnectorError { MismatchedPaymentData, #[error("Failed to parse Wallet token")] InvalidWalletToken, + #[error("File Validation failed")] + FileValidationFailed { reason: String }, } #[derive(Debug, thiserror::Error)] diff --git a/crates/router/src/core/errors/api_error_response.rs b/crates/router/src/core/errors/api_error_response.rs index 79a021fcf1..801c812522 100644 --- a/crates/router/src/core/errors/api_error_response.rs +++ b/crates/router/src/core/errors/api_error_response.rs @@ -160,10 +160,26 @@ pub enum ApiErrorResponse { AddressNotFound, #[error(error_type = ErrorType::ObjectNotFound, code = "HE_04", message = "Dispute does not exist in our records")] DisputeNotFound { dispute_id: String }, + #[error(error_type = ErrorType::ObjectNotFound, code = "HE_04", message = "File does not exist in our records")] + FileNotFound, + #[error(error_type = ErrorType::ObjectNotFound, code = "HE_04", message = "File not available")] + FileNotAvailable, + #[error(error_type = ErrorType::InvalidRequestError, code = "HE_04", message = "Dispute status validation failed")] + DisputeStatusValidationFailed { reason: String }, #[error(error_type = ErrorType::InvalidRequestError, code = "HE_04", message = "Card with the provided iin does not exist")] InvalidCardIin, #[error(error_type = ErrorType::InvalidRequestError, code = "HE_04", message = "The provided card IIN length is invalid, please provide an iin with 6 or 8 digits")] InvalidCardIinLength, + #[error(error_type = ErrorType::ValidationError, code = "HE_03", message = "File validation failed")] + FileValidationFailed { reason: String }, + #[error(error_type = ErrorType::InvalidRequestError, code = "HE_04", message = "File not found / valid in the request")] + MissingFile, + #[error(error_type = ErrorType::InvalidRequestError, code = "HE_04", message = "Dispute id not found in the request")] + MissingDisputeId, + #[error(error_type = ErrorType::InvalidRequestError, code = "HE_04", message = "File purpose not found in the request or is invalid")] + MissingFilePurpose, + #[error(error_type = ErrorType::InvalidRequestError, code = "HE_04", message = "File content type not found / valid")] + MissingFileContentType, } #[derive(Clone)] @@ -251,12 +267,20 @@ impl actix_web::ResponseError for ApiErrorResponse { | Self::AddressNotFound | Self::NotSupported { .. } | Self::FlowNotSupported { .. } - | Self::ApiKeyNotFound => StatusCode::BAD_REQUEST, // 400 + | Self::ApiKeyNotFound + | Self::DisputeStatusValidationFailed { .. } => StatusCode::BAD_REQUEST, // 400 Self::DuplicateMerchantAccount | Self::DuplicateMerchantConnectorAccount | Self::DuplicatePaymentMethod | Self::DuplicateMandate - | Self::DisputeNotFound { .. } => StatusCode::BAD_REQUEST, // 400 + | Self::DisputeNotFound { .. } + | Self::MissingFile + | Self::FileValidationFailed { .. } + | Self::MissingFileContentType + | Self::MissingFilePurpose + | Self::MissingDisputeId + | Self::FileNotFound + | Self::FileNotAvailable => StatusCode::BAD_REQUEST, // 400 Self::ReturnUrlUnavailable => StatusCode::SERVICE_UNAVAILABLE, // 503 Self::PaymentNotSucceeded => StatusCode::BAD_REQUEST, // 400 Self::NotImplemented { .. } => StatusCode::NOT_IMPLEMENTED, // 501 @@ -446,10 +470,34 @@ impl common_utils::errors::ErrorSwitch AER::BadRequest(ApiError::new("HE", 3, "The provided card IIN length is invalid, please provide an IIN with 6 digits", None)), Self::FlowNotSupported { flow, connector } => { AER::BadRequest(ApiError::new("IR", 20, format!("{flow} flow not supported"), Some(Extra {connector: Some(connector.to_owned()), ..Default::default()}))) //FIXME: error message - }, + } Self::DisputeNotFound { .. } => { AER::NotFound(ApiError::new("HE", 2, "Dispute does not exist in our records", None)) - }, + } + Self::FileNotFound => { + AER::NotFound(ApiError::new("HE", 2, "File does not exist in our records", None)) + } + Self::FileNotAvailable => { + AER::NotFound(ApiError::new("HE", 2, "File not available", None)) + } + Self::DisputeStatusValidationFailed { .. } => { + AER::BadRequest(ApiError::new("HE", 2, "Dispute status validation failed", None)) + } + Self::FileValidationFailed { reason } => { + AER::BadRequest(ApiError::new("HE", 2, format!("File validation failed {reason}"), None)) + } + Self::MissingFile => { + AER::BadRequest(ApiError::new("HE", 2, "File not found in the request", None)) + } + Self::MissingFilePurpose => { + AER::BadRequest(ApiError::new("HE", 2, "File purpose not found in the request or is invalid", None)) + } + Self::MissingFileContentType => { + AER::BadRequest(ApiError::new("HE", 2, "File content type not found", None)) + } + Self::MissingDisputeId => { + AER::BadRequest(ApiError::new("HE", 2, "Dispute id not found in the request", None)) + } } } } diff --git a/crates/router/src/core/files.rs b/crates/router/src/core/files.rs new file mode 100644 index 0000000000..718f5f2923 --- /dev/null +++ b/crates/router/src/core/files.rs @@ -0,0 +1,120 @@ +pub mod helpers; +#[cfg(feature = "s3")] +pub mod s3_utils; + +#[cfg(not(feature = "s3"))] +pub mod fs_utils; + +use api_models::files; +use error_stack::{IntoReport, ResultExt}; + +use super::errors::{self, RouterResponse}; +use crate::{ + consts, + routes::AppState, + services::{self, ApplicationResponse}, + types::{api, storage, transformers::ForeignInto}, +}; + +pub async fn files_create_core( + state: &AppState, + merchant_account: storage::merchant_account::MerchantAccount, + create_file_request: api::CreateFileRequest, +) -> RouterResponse { + helpers::validate_file_upload(state, merchant_account.clone(), create_file_request.clone()) + .await?; + let file_id = common_utils::generate_id(consts::ID_LENGTH, "file"); + #[cfg(feature = "s3")] + let file_key = format!("{}/{}", merchant_account.merchant_id, file_id); + #[cfg(not(feature = "s3"))] + let file_key = format!("{}_{}", merchant_account.merchant_id, file_id); + let file_new = storage_models::file::FileMetadataNew { + file_id: file_id.clone(), + merchant_id: merchant_account.merchant_id.clone(), + file_name: create_file_request.file_name.clone(), + file_size: create_file_request.file_size, + file_type: create_file_request.file_type.to_string(), + provider_file_id: None, + file_upload_provider: None, + available: false, + }; + let file_metadata_object = state + .store + .insert_file_metadata(file_new) + .await + .change_context(errors::ApiErrorResponse::InternalServerError) + .attach_printable("Unable to insert file_metadata")?; + let (provider_file_id, file_upload_provider) = + helpers::upload_and_get_provider_provider_file_id( + state, + &merchant_account, + &create_file_request, + file_key.clone(), + ) + .await?; + //Update file metadata + let update_file_metadata = storage_models::file::FileMetadataUpdate::Update { + provider_file_id: Some(provider_file_id), + file_upload_provider: Some(file_upload_provider.foreign_into()), + available: true, + }; + state + .store + .update_file_metadata(file_metadata_object, update_file_metadata) + .await + .change_context(errors::ApiErrorResponse::InternalServerError) + .attach_printable_lazy(|| { + format!("Unable to update file_metadata with file_id: {}", file_id) + })?; + Ok(services::api::ApplicationResponse::Json( + files::CreateFileResponse { file_id }, + )) +} + +pub async fn files_delete_core( + state: &AppState, + merchant_account: storage::MerchantAccount, + req: api::FileId, +) -> RouterResponse { + helpers::delete_file_using_file_id(state, req.file_id.clone(), &merchant_account).await?; + state + .store + .delete_file_metadata_by_merchant_id_file_id(&merchant_account.merchant_id, &req.file_id) + .await + .change_context(errors::ApiErrorResponse::InternalServerError) + .attach_printable("Unable to delete file_metadata")?; + Ok(ApplicationResponse::StatusOk) +} + +pub async fn files_retrieve_core( + state: &AppState, + merchant_account: storage::MerchantAccount, + req: api::FileId, +) -> RouterResponse { + let file_metadata_object = state + .store + .find_file_metadata_by_merchant_id_file_id(&merchant_account.merchant_id, &req.file_id) + .await + .change_context(errors::ApiErrorResponse::FileNotFound) + .attach_printable("Unable to retrieve file_metadata")?; + let (received_data, _provider_file_id) = + helpers::retrieve_file_and_provider_file_id_from_file_id( + state, + Some(req.file_id), + &merchant_account, + ) + .await?; + let content_type = file_metadata_object + .file_type + .parse::() + .into_report() + .change_context(errors::ApiErrorResponse::InternalServerError) + .attach_printable("Failed to parse file content type")?; + Ok(ApplicationResponse::FileData(( + received_data + .ok_or(errors::ApiErrorResponse::FileNotAvailable) + .into_report() + .attach_printable("File data not found")?, + content_type, + ))) +} diff --git a/crates/router/src/core/files/fs_utils.rs b/crates/router/src/core/files/fs_utils.rs new file mode 100644 index 0000000000..795f2fad75 --- /dev/null +++ b/crates/router/src/core/files/fs_utils.rs @@ -0,0 +1,57 @@ +use std::{ + fs::{remove_file, File}, + io::{Read, Write}, + path::PathBuf, +}; + +use common_utils::errors::CustomResult; +use error_stack::{IntoReport, ResultExt}; + +use crate::{core::errors, env}; + +pub fn get_file_path(file_key: String) -> PathBuf { + let mut file_path = PathBuf::new(); + file_path.push(env::workspace_path()); + file_path.push("files"); + file_path.push(file_key); + file_path +} + +pub fn save_file_to_fs( + file_key: String, + file_data: Vec, +) -> CustomResult<(), errors::ApiErrorResponse> { + let file_path = get_file_path(file_key); + let mut file = File::create(file_path) + .into_report() + .change_context(errors::ApiErrorResponse::InternalServerError) + .attach_printable("Failed to create file")?; + file.write_all(&file_data) + .into_report() + .change_context(errors::ApiErrorResponse::InternalServerError) + .attach_printable("Failed while writing into file")?; + Ok(()) +} + +pub fn delete_file_from_fs(file_key: String) -> CustomResult<(), errors::ApiErrorResponse> { + let file_path = get_file_path(file_key); + remove_file(file_path) + .into_report() + .change_context(errors::ApiErrorResponse::InternalServerError) + .attach_printable("Failed while deleting the file")?; + Ok(()) +} + +pub fn retrieve_file_from_fs(file_key: String) -> CustomResult, errors::ApiErrorResponse> { + let mut received_data: Vec = Vec::new(); + let file_path = get_file_path(file_key); + let mut file = File::open(file_path) + .into_report() + .change_context(errors::ApiErrorResponse::InternalServerError) + .attach_printable("Failed while opening the file")?; + file.read_to_end(&mut received_data) + .into_report() + .change_context(errors::ApiErrorResponse::InternalServerError) + .attach_printable("Failed while reading the file")?; + Ok(received_data) +} diff --git a/crates/router/src/core/files/helpers.rs b/crates/router/src/core/files/helpers.rs new file mode 100644 index 0000000000..a6a01fe09b --- /dev/null +++ b/crates/router/src/core/files/helpers.rs @@ -0,0 +1,280 @@ +use actix_multipart::Field; +use common_utils::errors::CustomResult; +use error_stack::{IntoReport, ResultExt}; +use futures::TryStreamExt; + +use crate::{ + core::{ + errors::{self, StorageErrorExt}, + files, payments, utils, + }, + routes::AppState, + services, + types::{self, api, storage}, +}; + +pub async fn read_string(field: &mut Field) -> Option { + let bytes = field.try_next().await; + if let Ok(Some(bytes)) = bytes { + String::from_utf8(bytes.to_vec()).ok() + } else { + None + } +} + +pub async fn get_file_purpose(field: &mut Field) -> Option { + let purpose = read_string(field).await; + match purpose.as_deref() { + Some("dispute_evidence") => Some(api::FilePurpose::DisputeEvidence), + _ => None, + } +} + +pub async fn upload_file( + #[cfg(feature = "s3")] state: &AppState, + file_key: String, + file: Vec, +) -> CustomResult<(), errors::ApiErrorResponse> { + #[cfg(feature = "s3")] + return files::s3_utils::upload_file_to_s3(state, file_key, file).await; + #[cfg(not(feature = "s3"))] + return files::fs_utils::save_file_to_fs(file_key, file); +} + +pub async fn delete_file( + #[cfg(feature = "s3")] state: &AppState, + file_key: String, +) -> CustomResult<(), errors::ApiErrorResponse> { + #[cfg(feature = "s3")] + return files::s3_utils::delete_file_from_s3(state, file_key).await; + #[cfg(not(feature = "s3"))] + return files::fs_utils::delete_file_from_fs(file_key); +} + +pub async fn retrieve_file( + #[cfg(feature = "s3")] state: &AppState, + file_key: String, +) -> CustomResult, errors::ApiErrorResponse> { + #[cfg(feature = "s3")] + return files::s3_utils::retrieve_file_from_s3(state, file_key).await; + #[cfg(not(feature = "s3"))] + return files::fs_utils::retrieve_file_from_fs(file_key); +} + +pub async fn validate_file_upload( + state: &AppState, + merchant_account: storage::merchant_account::MerchantAccount, + create_file_request: api::CreateFileRequest, +) -> CustomResult<(), errors::ApiErrorResponse> { + //File Validation based on the purpose of file upload + match create_file_request.purpose { + api::FilePurpose::DisputeEvidence => { + let dispute_id = &create_file_request + .dispute_id + .ok_or(errors::ApiErrorResponse::MissingDisputeId)?; + let dispute = state + .store + .find_dispute_by_merchant_id_dispute_id(&merchant_account.merchant_id, dispute_id) + .await + .to_not_found_response(errors::ApiErrorResponse::DisputeNotFound { + dispute_id: dispute_id.to_string(), + })?; + let connector_data = api::ConnectorData::get_connector_by_name( + &state.conf.connectors, + &dispute.connector, + api::GetToken::Connector, + )?; + let validation = connector_data.connector.validate_file_upload( + create_file_request.purpose, + create_file_request.file_size, + create_file_request.file_type.clone(), + ); + match validation { + Ok(()) => Ok(()), + Err(err) => match err.current_context() { + errors::ConnectorError::FileValidationFailed { reason } => { + Err(errors::ApiErrorResponse::FileValidationFailed { + reason: reason.to_string(), + } + .into()) + } + //We are using parent error and ignoring this + _error => Err(err + .change_context(errors::ApiErrorResponse::InternalServerError) + .attach_printable("File validation failed"))?, + }, + } + } + } +} + +pub async fn delete_file_using_file_id( + state: &AppState, + file_key: String, + merchant_account: &storage_models::merchant_account::MerchantAccount, +) -> CustomResult<(), errors::ApiErrorResponse> { + let file_metadata_object = state + .store + .find_file_metadata_by_merchant_id_file_id(&merchant_account.merchant_id, &file_key) + .await + .change_context(errors::ApiErrorResponse::FileNotFound)?; + let (provider, provider_file_id) = match ( + file_metadata_object.file_upload_provider, + file_metadata_object.provider_file_id, + file_metadata_object.available, + ) { + (Some(provider), Some(provider_file_id), true) => (provider, provider_file_id), + _ => Err(errors::ApiErrorResponse::FileNotAvailable) + .into_report() + .attach_printable("File not available")?, + }; + match provider { + storage_models::enums::FileUploadProvider::Router => { + delete_file( + #[cfg(feature = "s3")] + state, + provider_file_id, + ) + .await + } + _ => Err(errors::ApiErrorResponse::NotSupported { + message: "Not Supported if provider is not Router".to_owned(), + } + .into()), + } +} + +pub async fn retrieve_file_and_provider_file_id_from_file_id( + state: &AppState, + file_id: Option, + merchant_account: &storage_models::merchant_account::MerchantAccount, +) -> CustomResult<(Option>, Option), errors::ApiErrorResponse> { + match file_id { + None => Ok((None, None)), + Some(file_key) => { + let file_metadata_object = state + .store + .find_file_metadata_by_merchant_id_file_id(&merchant_account.merchant_id, &file_key) + .await + .change_context(errors::ApiErrorResponse::FileNotFound)?; + let (provider, provider_file_id) = match ( + file_metadata_object.file_upload_provider, + file_metadata_object.provider_file_id, + ) { + (Some(provider), Some(provider_file_id)) => (provider, provider_file_id), + _ => Err(errors::ApiErrorResponse::FileNotFound)?, + }; + match provider { + storage_models::enums::FileUploadProvider::Router => Ok(( + Some( + retrieve_file( + #[cfg(feature = "s3")] + state, + provider_file_id.clone(), + ) + .await?, + ), + Some(provider_file_id), + )), + //TODO: Handle Retrieve for other providers + _ => Ok((None, Some(provider_file_id))), + } + } + } +} + +//Upload file to connector if it supports / store it in S3 and return file_upload_provider, provider_file_id accordingly +pub async fn upload_and_get_provider_provider_file_id( + state: &AppState, + merchant_account: &storage::merchant_account::MerchantAccount, + create_file_request: &api::CreateFileRequest, + file_key: String, +) -> CustomResult<(String, api::FileUploadProvider), errors::ApiErrorResponse> { + match create_file_request.purpose { + api::FilePurpose::DisputeEvidence => { + let dispute_id = create_file_request + .dispute_id + .clone() + .ok_or(errors::ApiErrorResponse::MissingDisputeId)?; + let dispute = state + .store + .find_dispute_by_merchant_id_dispute_id(&merchant_account.merchant_id, &dispute_id) + .await + .to_not_found_response(errors::ApiErrorResponse::DisputeNotFound { dispute_id })?; + let connector_data = api::ConnectorData::get_connector_by_name( + &state.conf.connectors, + &dispute.connector, + api::GetToken::Connector, + )?; + if connector_data.connector_name.supports_file_storage_module() { + let payment_intent = state + .store + .find_payment_intent_by_payment_id_merchant_id( + &dispute.payment_id, + &merchant_account.merchant_id, + merchant_account.storage_scheme, + ) + .await + .change_context(errors::ApiErrorResponse::PaymentNotFound)?; + let payment_attempt = state + .store + .find_payment_attempt_by_attempt_id_merchant_id( + &dispute.attempt_id, + &merchant_account.merchant_id, + merchant_account.storage_scheme, + ) + .await + .change_context(errors::ApiErrorResponse::PaymentNotFound)?; + let connector_integration: services::BoxedConnectorIntegration< + '_, + api::Upload, + types::UploadFileRequestData, + types::UploadFileResponse, + > = connector_data.connector.get_connector_integration(); + let router_data = utils::construct_upload_file_router_data( + state, + &payment_intent, + &payment_attempt, + merchant_account, + create_file_request, + &dispute.connector, + file_key, + ) + .await + .change_context(errors::ApiErrorResponse::InternalServerError) + .attach_printable("Failed constructing the upload file router data")?; + let response = services::execute_connector_processing_step( + state, + connector_integration, + &router_data, + payments::CallConnectorAction::Trigger, + ) + .await + .change_context(errors::ApiErrorResponse::InternalServerError) + .attach_printable("Failed while calling upload file connector api")?; + let upload_file_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, + } + })?; + Ok(( + upload_file_response.provider_file_id, + api::FileUploadProvider::try_from(&connector_data.connector_name)?, + )) + } else { + upload_file( + #[cfg(feature = "s3")] + state, + file_key.clone(), + create_file_request.file.clone(), + ) + .await?; + Ok((file_key, api::FileUploadProvider::Router)) + } + } + } +} diff --git a/crates/router/src/core/files/s3_utils.rs b/crates/router/src/core/files/s3_utils.rs new file mode 100644 index 0000000000..228c23528c --- /dev/null +++ b/crates/router/src/core/files/s3_utils.rs @@ -0,0 +1,87 @@ +use aws_config::{self, meta::region::RegionProviderChain}; +use aws_sdk_s3::{config::Region, Client}; +use common_utils::errors::CustomResult; +use error_stack::{IntoReport, ResultExt}; +use futures::TryStreamExt; + +use crate::{core::errors, routes}; + +async fn get_aws_client(state: &routes::AppState) -> Client { + let region_provider = + RegionProviderChain::first_try(Region::new(state.conf.file_upload_config.region.clone())); + let sdk_config = aws_config::from_env().region(region_provider).load().await; + Client::new(&sdk_config) +} + +pub async fn upload_file_to_s3( + state: &routes::AppState, + file_key: String, + file: Vec, +) -> CustomResult<(), errors::ApiErrorResponse> { + let client = get_aws_client(state).await; + let bucket_name = &state.conf.file_upload_config.bucket_name; + // Upload file to S3 + let upload_res = client + .put_object() + .bucket(bucket_name) + .key(file_key.clone()) + .body(file.into()) + .send() + .await; + upload_res + .into_report() + .change_context(errors::ApiErrorResponse::InternalServerError) + .attach_printable("File upload to S3 failed")?; + Ok(()) +} + +pub async fn delete_file_from_s3( + state: &routes::AppState, + file_key: String, +) -> CustomResult<(), errors::ApiErrorResponse> { + let client = get_aws_client(state).await; + let bucket_name = &state.conf.file_upload_config.bucket_name; + // Delete file from S3 + let delete_res = client + .delete_object() + .bucket(bucket_name) + .key(file_key) + .send() + .await; + delete_res + .into_report() + .change_context(errors::ApiErrorResponse::InternalServerError) + .attach_printable("File delete from S3 failed")?; + Ok(()) +} + +pub async fn retrieve_file_from_s3( + state: &routes::AppState, + file_key: String, +) -> CustomResult, errors::ApiErrorResponse> { + let client = get_aws_client(state).await; + let bucket_name = &state.conf.file_upload_config.bucket_name; + // Get file data from S3 + let get_res = client + .get_object() + .bucket(bucket_name) + .key(file_key) + .send() + .await; + let mut object = get_res + .into_report() + .change_context(errors::ApiErrorResponse::InternalServerError) + .attach_printable("File retrieve from S3 failed")?; + let mut received_data: Vec = Vec::new(); + while let Some(bytes) = object + .body + .try_next() + .await + .into_report() + .change_context(errors::ApiErrorResponse::InternalServerError) + .attach_printable("Invalid file data received from S3")? + { + received_data.extend_from_slice(&bytes); // Collect the bytes in the Vec + } + Ok(received_data) +} diff --git a/crates/router/src/core/metrics.rs b/crates/router/src/core/metrics.rs index fe17204965..507de9d73b 100644 --- a/crates/router/src/core/metrics.rs +++ b/crates/router/src/core/metrics.rs @@ -18,3 +18,11 @@ counter_metric!( INCOMING_DISPUTE_WEBHOOK_MERCHANT_NOTIFIED_METRIC, GLOBAL_METER ); // No. of incoming dispute webhooks which are notified to merchant +counter_metric!( + ACCEPT_DISPUTE_STATUS_VALIDATION_FAILURE_METRIC, + GLOBAL_METER +); //No. of status validation fialures while accpeting a dispute +counter_metric!( + EVIDENCE_SUBMISSION_DISPUTE_STATUS_VALIDATION_FAILURE_METRIC, + GLOBAL_METER +); //No. of status validation fialures while submitting evidence for a dispute diff --git a/crates/router/src/core/payments/flows.rs b/crates/router/src/core/payments/flows.rs index 2c1a0a1c44..f1c50f3e78 100644 --- a/crates/router/src/core/payments/flows.rs +++ b/crates/router/src/core/payments/flows.rs @@ -190,3 +190,142 @@ default_imp_for_connector_request_id!( connector::Worldline, connector::Worldpay ); + +macro_rules! default_imp_for_accept_dispute{ + ($($path:ident::$connector:ident),*)=> { + $( + impl api::Dispute for $path::$connector {} + impl api::AcceptDispute for $path::$connector {} + impl + services::ConnectorIntegration< + api::Accept, + types::AcceptDisputeRequestData, + types::AcceptDisputeResponse, + > for $path::$connector + {} + )* + }; +} + +default_imp_for_accept_dispute!( + connector::Aci, + connector::Adyen, + connector::Airwallex, + connector::Authorizedotnet, + connector::Bambora, + connector::Bluesnap, + connector::Braintree, + connector::Coinbase, + connector::Cybersource, + connector::Dlocal, + connector::Fiserv, + connector::Forte, + connector::Globalpay, + connector::Klarna, + connector::Mollie, + connector::Multisafepay, + connector::Nexinets, + connector::Nuvei, + connector::Payeezy, + connector::Paypal, + connector::Payu, + connector::Rapyd, + connector::Shift4, + connector::Stripe, + connector::Trustpay, + connector::Opennode, + connector::Worldline, + connector::Worldpay +); + +macro_rules! default_imp_for_file_upload{ + ($($path:ident::$connector:ident),*)=> { + $( + impl api::FileUpload for $path::$connector {} + impl api::UploadFile for $path::$connector {} + impl + services::ConnectorIntegration< + api::Upload, + types::UploadFileRequestData, + types::UploadFileResponse, + > for $path::$connector + {} + )* + }; +} + +default_imp_for_file_upload!( + connector::Aci, + connector::Adyen, + connector::Airwallex, + connector::Authorizedotnet, + connector::Bambora, + connector::Bluesnap, + connector::Braintree, + connector::Coinbase, + connector::Cybersource, + connector::Dlocal, + connector::Fiserv, + connector::Forte, + connector::Globalpay, + connector::Klarna, + connector::Mollie, + connector::Multisafepay, + connector::Nexinets, + connector::Nuvei, + connector::Payeezy, + connector::Paypal, + connector::Payu, + connector::Rapyd, + connector::Shift4, + connector::Trustpay, + connector::Opennode, + connector::Worldline, + connector::Worldpay +); + +macro_rules! default_imp_for_submit_evidence{ + ($($path:ident::$connector:ident),*)=> { + $( + impl api::SubmitEvidence for $path::$connector {} + impl + services::ConnectorIntegration< + api::Evidence, + types::SubmitEvidenceRequestData, + types::SubmitEvidenceResponse, + > for $path::$connector + {} + )* + }; +} + +default_imp_for_submit_evidence!( + connector::Aci, + connector::Adyen, + connector::Airwallex, + connector::Authorizedotnet, + connector::Bambora, + connector::Bluesnap, + connector::Braintree, + connector::Checkout, + connector::Cybersource, + connector::Coinbase, + connector::Dlocal, + connector::Fiserv, + connector::Forte, + connector::Globalpay, + connector::Klarna, + connector::Mollie, + connector::Multisafepay, + connector::Nexinets, + connector::Nuvei, + connector::Payeezy, + connector::Paypal, + connector::Payu, + connector::Rapyd, + connector::Shift4, + connector::Trustpay, + connector::Opennode, + connector::Worldline, + connector::Worldpay +); diff --git a/crates/router/src/core/utils.rs b/crates/router/src/core/utils.rs index 98c87ad2bb..98a899ce7f 100644 --- a/crates/router/src/core/utils.rs +++ b/crates/router/src/core/utils.rs @@ -222,3 +222,181 @@ pub fn validate_dispute_stage_and_dispute_status( }, ) } + +#[instrument(skip_all)] +pub async fn construct_accept_dispute_router_data<'a>( + state: &'a AppState, + payment_intent: &'a storage::PaymentIntent, + payment_attempt: &storage::PaymentAttempt, + merchant_account: &storage::MerchantAccount, + dispute: &storage::Dispute, +) -> RouterResult { + let db = &*state.store; + let connector_id = &dispute.connector; + let connector_label = helpers::get_connector_label( + payment_intent.business_country, + &payment_intent.business_label, + payment_attempt.business_sub_label.as_ref(), + connector_id, + ); + let merchant_connector_account = helpers::get_merchant_connector_account( + db, + merchant_account.merchant_id.as_str(), + &connector_label, + None, + ) + .await?; + 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_type")?; + let router_data = types::RouterData { + flow: PhantomData, + merchant_id: merchant_account.merchant_id.clone(), + connector: connector_id.to_string(), + payment_id: payment_attempt.payment_id.clone(), + attempt_id: payment_attempt.attempt_id.clone(), + status: payment_attempt.status, + payment_method, + connector_auth_type: auth_type, + description: None, + return_url: payment_intent.return_url.clone(), + payment_method_id: payment_attempt.payment_method_id.clone(), + address: PaymentAddress::default(), + auth_type: payment_attempt.authentication_type.unwrap_or_default(), + connector_meta_data: merchant_connector_account.get_metadata(), + amount_captured: payment_intent.amount_captured, + request: types::AcceptDisputeRequestData { + dispute_id: dispute.dispute_id.clone(), + connector_dispute_id: dispute.connector_dispute_id.clone(), + }, + response: Err(types::ErrorResponse::default()), + access_token: None, + session_token: None, + reference_id: None, + payment_method_token: None, + }; + Ok(router_data) +} + +#[instrument(skip_all)] +pub async fn construct_submit_evidence_router_data<'a>( + state: &'a AppState, + payment_intent: &'a storage::PaymentIntent, + payment_attempt: &storage::PaymentAttempt, + merchant_account: &storage::MerchantAccount, + dispute: &storage::Dispute, + submit_evidence_request_data: types::SubmitEvidenceRequestData, +) -> RouterResult { + let db = &*state.store; + let connector_id = &dispute.connector; + let connector_label = helpers::get_connector_label( + payment_intent.business_country, + &payment_intent.business_label, + payment_attempt.business_sub_label.as_ref(), + connector_id, + ); + let merchant_connector_account = helpers::get_merchant_connector_account( + db, + merchant_account.merchant_id.as_str(), + &connector_label, + None, + ) + .await?; + 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_type")?; + let router_data = types::RouterData { + flow: PhantomData, + merchant_id: merchant_account.merchant_id.clone(), + connector: connector_id.to_string(), + payment_id: payment_attempt.payment_id.clone(), + attempt_id: payment_attempt.attempt_id.clone(), + status: payment_attempt.status, + payment_method, + connector_auth_type: auth_type, + description: None, + return_url: payment_intent.return_url.clone(), + payment_method_id: payment_attempt.payment_method_id.clone(), + address: PaymentAddress::default(), + auth_type: payment_attempt.authentication_type.unwrap_or_default(), + connector_meta_data: merchant_connector_account.get_metadata(), + amount_captured: payment_intent.amount_captured, + request: submit_evidence_request_data, + response: Err(types::ErrorResponse::default()), + access_token: None, + session_token: None, + reference_id: None, + payment_method_token: None, + }; + Ok(router_data) +} + +#[instrument(skip_all)] +pub async fn construct_upload_file_router_data<'a>( + state: &'a AppState, + payment_intent: &'a storage::PaymentIntent, + payment_attempt: &storage::PaymentAttempt, + merchant_account: &storage::MerchantAccount, + create_file_request: &types::api::CreateFileRequest, + connector_id: &str, + file_key: String, +) -> RouterResult { + let db = &*state.store; + let connector_label = helpers::get_connector_label( + payment_intent.business_country, + &payment_intent.business_label, + payment_attempt.business_sub_label.as_ref(), + connector_id, + ); + let merchant_connector_account = helpers::get_merchant_connector_account( + db, + merchant_account.merchant_id.as_str(), + &connector_label, + None, + ) + .await?; + 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_type")?; + let router_data = types::RouterData { + flow: PhantomData, + merchant_id: merchant_account.merchant_id.clone(), + connector: connector_id.to_string(), + payment_id: payment_attempt.payment_id.clone(), + attempt_id: payment_attempt.attempt_id.clone(), + status: payment_attempt.status, + payment_method, + connector_auth_type: auth_type, + description: None, + return_url: payment_intent.return_url.clone(), + payment_method_id: payment_attempt.payment_method_id.clone(), + address: PaymentAddress::default(), + auth_type: payment_attempt.authentication_type.unwrap_or_default(), + connector_meta_data: merchant_connector_account.get_metadata(), + amount_captured: payment_intent.amount_captured, + request: types::UploadFileRequestData { + file_key, + file: create_file_request.file.clone(), + file_type: create_file_request.file_type.clone(), + file_size: create_file_request.file_size, + }, + response: Err(types::ErrorResponse::default()), + access_token: None, + session_token: None, + reference_id: None, + payment_method_token: None, + }; + Ok(router_data) +} diff --git a/crates/router/src/db.rs b/crates/router/src/db.rs index 9e8c08f1d2..e8a00809cb 100644 --- a/crates/router/src/db.rs +++ b/crates/router/src/db.rs @@ -8,6 +8,7 @@ pub mod customers; pub mod dispute; pub mod ephemeral_key; pub mod events; +pub mod file; pub mod locker_mock_up; pub mod mandate; pub mod merchant_account; @@ -46,6 +47,7 @@ pub trait StorageInterface: + dispute::DisputeInterface + ephemeral_key::EphemeralKeyInterface + events::EventInterface + + file::FileMetadataInterface + locker_mock_up::LockerMockUpInterface + mandate::MandateInterface + merchant_account::MerchantAccountInterface diff --git a/crates/router/src/db/file.rs b/crates/router/src/db/file.rs new file mode 100644 index 0000000000..5ed8f22a2f --- /dev/null +++ b/crates/router/src/db/file.rs @@ -0,0 +1,119 @@ +use error_stack::IntoReport; + +use super::{MockDb, Store}; +use crate::{ + connection, + core::errors::{self, CustomResult}, + types::storage, +}; + +#[async_trait::async_trait] +pub trait FileMetadataInterface { + async fn insert_file_metadata( + &self, + file: storage::FileMetadataNew, + ) -> CustomResult; + + async fn find_file_metadata_by_merchant_id_file_id( + &self, + merchant_id: &str, + file_id: &str, + ) -> CustomResult; + + async fn delete_file_metadata_by_merchant_id_file_id( + &self, + merchant_id: &str, + file_id: &str, + ) -> CustomResult; + + async fn update_file_metadata( + &self, + this: storage::FileMetadata, + file_metadata: storage::FileMetadataUpdate, + ) -> CustomResult; +} + +#[async_trait::async_trait] +impl FileMetadataInterface for Store { + async fn insert_file_metadata( + &self, + file: storage::FileMetadataNew, + ) -> CustomResult { + let conn = connection::pg_connection_write(self).await?; + file.insert(&conn).await.map_err(Into::into).into_report() + } + + async fn find_file_metadata_by_merchant_id_file_id( + &self, + merchant_id: &str, + file_id: &str, + ) -> CustomResult { + let conn = connection::pg_connection_read(self).await?; + storage::FileMetadata::find_by_merchant_id_file_id(&conn, merchant_id, file_id) + .await + .map_err(Into::into) + .into_report() + } + + async fn delete_file_metadata_by_merchant_id_file_id( + &self, + merchant_id: &str, + file_id: &str, + ) -> CustomResult { + let conn = connection::pg_connection_write(self).await?; + storage::FileMetadata::delete_by_merchant_id_file_id(&conn, merchant_id, file_id) + .await + .map_err(Into::into) + .into_report() + } + + async fn update_file_metadata( + &self, + this: storage::FileMetadata, + file_metadata: storage::FileMetadataUpdate, + ) -> CustomResult { + let conn = connection::pg_connection_write(self).await?; + this.update(&conn, file_metadata) + .await + .map_err(Into::into) + .into_report() + } +} + +#[async_trait::async_trait] +impl FileMetadataInterface for MockDb { + async fn insert_file_metadata( + &self, + _file: storage::FileMetadataNew, + ) -> CustomResult { + // TODO: Implement function for `MockDb` + Err(errors::StorageError::MockDbError)? + } + + async fn find_file_metadata_by_merchant_id_file_id( + &self, + _merchant_id: &str, + _file_id: &str, + ) -> CustomResult { + // TODO: Implement function for `MockDb` + Err(errors::StorageError::MockDbError)? + } + + async fn delete_file_metadata_by_merchant_id_file_id( + &self, + _merchant_id: &str, + _file_id: &str, + ) -> CustomResult { + // TODO: Implement function for `MockDb` + Err(errors::StorageError::MockDbError)? + } + + async fn update_file_metadata( + &self, + _this: storage::FileMetadata, + _file_metadata: storage::FileMetadataUpdate, + ) -> CustomResult { + // TODO: Implement function for `MockDb` + Err(errors::StorageError::MockDbError)? + } +} diff --git a/crates/router/src/lib.rs b/crates/router/src/lib.rs index f2d6cab64c..3fb44938a1 100644 --- a/crates/router/src/lib.rs +++ b/crates/router/src/lib.rs @@ -120,6 +120,7 @@ pub fn mk_app( server_app = server_app .service(routes::MerchantAccount::server(state.clone())) .service(routes::ApiKeys::server(state.clone())) + .service(routes::Files::server(state.clone())) .service(routes::Disputes::server(state.clone())); } diff --git a/crates/router/src/routes.rs b/crates/router/src/routes.rs index f0a19e4267..4b73b2a7e9 100644 --- a/crates/router/src/routes.rs +++ b/crates/router/src/routes.rs @@ -6,6 +6,7 @@ pub mod configs; pub mod customers; pub mod disputes; pub mod ephemeral_key; +pub mod files; pub mod health; pub mod mandates; pub mod metrics; @@ -16,7 +17,7 @@ pub mod refunds; pub mod webhooks; pub use self::app::{ - ApiKeys, AppState, Cards, Configs, Customers, Disputes, EphemeralKey, Health, Mandates, + ApiKeys, AppState, Cards, Configs, Customers, Disputes, EphemeralKey, Files, Health, Mandates, MerchantAccount, MerchantConnectorAccount, PaymentMethods, Payments, Payouts, Refunds, Webhooks, }; diff --git a/crates/router/src/routes/app.rs b/crates/router/src/routes/app.rs index 3559403da3..970debf1c1 100644 --- a/crates/router/src/routes/app.rs +++ b/crates/router/src/routes/app.rs @@ -3,7 +3,7 @@ use tokio::sync::oneshot; use super::health::*; #[cfg(feature = "olap")] -use super::{admin::*, api_keys::*, disputes::*}; +use super::{admin::*, api_keys::*, disputes::*, files::*}; #[cfg(any(feature = "olap", feature = "oltp"))] use super::{configs::*, customers::*, mandates::*, payments::*, payouts::*, refunds::*}; #[cfg(feature = "oltp")] @@ -393,6 +393,8 @@ impl Disputes { web::scope("/disputes") .app_data(web::Data::new(state)) .service(web::resource("/list").route(web::get().to(retrieve_disputes_list))) + .service(web::resource("/accept/{dispute_id}").route(web::post().to(accept_dispute))) + .service(web::resource("/evidence").route(web::post().to(submit_dispute_evidence))) .service(web::resource("/{dispute_id}").route(web::get().to(retrieve_dispute))) } } @@ -406,3 +408,19 @@ impl Cards { .service(web::resource("/{bin}").route(web::get().to(card_iin_info))) } } + +pub struct Files; + +#[cfg(feature = "olap")] +impl Files { + pub fn server(state: AppState) -> Scope { + web::scope("/files") + .app_data(web::Data::new(state)) + .service(web::resource("").route(web::post().to(files_create))) + .service( + web::resource("/{file_id}") + .route(web::delete().to(files_delete)) + .route(web::get().to(files_retrieve)), + ) + } +} diff --git a/crates/router/src/routes/disputes.rs b/crates/router/src/routes/disputes.rs index 95ffd9852f..0c8b9cd869 100644 --- a/crates/router/src/routes/disputes.rs +++ b/crates/router/src/routes/disputes.rs @@ -1,12 +1,12 @@ use actix_web::{web, HttpRequest, HttpResponse}; -use api_models::disputes::DisputeListConstraints; +use api_models::disputes as dispute_models; use router_env::{instrument, tracing, Flow}; use super::app::AppState; use crate::{ core::disputes, services::{api, authentication as auth}, - types::api::disputes as dispute_types, + types::api::disputes::{self as dispute_types}, }; /// Diputes - Retrieve Dispute @@ -73,7 +73,7 @@ pub async fn retrieve_dispute( pub async fn retrieve_disputes_list( state: web::Data, req: HttpRequest, - payload: web::Query, + payload: web::Query, ) -> HttpResponse { let flow = Flow::DisputesList; let payload = payload.into_inner(); @@ -87,3 +87,70 @@ pub async fn retrieve_disputes_list( ) .await } + +/// Diputes - Accept Dispute +#[utoipa::path( + get, + path = "/disputes/accept/{dispute_id}", + params( + ("dispute_id" = String, Path, description = "The identifier for dispute") + ), + responses( + (status = 200, description = "The dispute was accepted successfully", body = DisputeResponse), + (status = 404, description = "Dispute does not exist in our records") + ), + tag = "Disputes", + operation_id = "Accept a Dispute", + security(("api_key" = [])) +)] +#[instrument(skip_all, fields(flow = ?Flow::DisputesRetrieve))] +pub async fn accept_dispute( + state: web::Data, + req: HttpRequest, + path: web::Path, +) -> HttpResponse { + let flow = Flow::DisputesRetrieve; + let dispute_id = dispute_types::DisputeId { + dispute_id: path.into_inner(), + }; + api::server_wrap( + flow, + state.get_ref(), + &req, + dispute_id, + disputes::accept_dispute, + auth::auth_type(&auth::ApiKeyAuth, &auth::JWTAuth, req.headers()), + ) + .await +} + +/// Diputes - Submit Dispute Evidence +#[utoipa::path( + post, + path = "/disputes/evidence", + request_body=AcceptDisputeRequestData, + responses( + (status = 200, description = "The dispute evidence submitted successfully", body = AcceptDisputeResponse), + (status = 404, description = "Dispute does not exist in our records") + ), + tag = "Disputes", + operation_id = "Submit Dispute Evidence", + security(("api_key" = [])) +)] +#[instrument(skip_all, fields(flow = ?Flow::DisputesEvidenceSubmit))] +pub async fn submit_dispute_evidence( + state: web::Data, + req: HttpRequest, + json_payload: web::Json, +) -> HttpResponse { + let flow = Flow::DisputesEvidenceSubmit; + api::server_wrap( + flow, + state.get_ref(), + &req, + json_payload.into_inner(), + disputes::submit_evidence, + auth::auth_type(&auth::ApiKeyAuth, &auth::JWTAuth, req.headers()), + ) + .await +} diff --git a/crates/router/src/routes/files.rs b/crates/router/src/routes/files.rs new file mode 100644 index 0000000000..2553022e7a --- /dev/null +++ b/crates/router/src/routes/files.rs @@ -0,0 +1,125 @@ +use actix_multipart::Multipart; +use actix_web::{web, HttpRequest, HttpResponse}; +use router_env::{instrument, tracing, Flow}; +pub mod transformers; + +use super::app::AppState; +use crate::{ + core::files::*, + services::{api, authentication as auth}, + types::api::files, +}; + +/// Files - Create +/// +/// To create a file +#[utoipa::path( + post, + path = "/files", + request_body=MultipartRequestWithFile, + responses( + (status = 200, description = "File created", body = CreateFileResponse), + (status = 400, description = "Bad Request") + ), + tag = "Files", + operation_id = "Create a File", + security(("api_key" = [])) +)] +#[instrument(skip_all, fields(flow = ?Flow::CreateFile))] +pub async fn files_create( + state: web::Data, + req: HttpRequest, + payload: Multipart, +) -> HttpResponse { + let flow = Flow::CreateFile; + let create_file_request_result = transformers::get_create_file_request(payload).await; + let create_file_request = match create_file_request_result { + Ok(valid_request) => valid_request, + Err(err) => return api::log_and_return_error_response(err), + }; + api::server_wrap( + flow, + state.get_ref(), + &req, + create_file_request, + files_create_core, + auth::auth_type(&auth::ApiKeyAuth, &auth::JWTAuth, req.headers()), + ) + .await +} + +/// Files - Delete +/// +/// To delete a file +#[utoipa::path( + delete, + path = "/files/{file_id}", + params( + ("file_id" = String, Path, description = "The identifier for file") + ), + responses( + (status = 200, description = "File deleted"), + (status = 404, description = "File not found") + ), + tag = "Files", + operation_id = "Delete a File", + security(("api_key" = [])) +)] +#[instrument(skip_all, fields(flow = ?Flow::DeleteFile))] +pub async fn files_delete( + state: web::Data, + req: HttpRequest, + path: web::Path, +) -> HttpResponse { + let flow = Flow::DeleteFile; + let file_id = files::FileId { + file_id: path.into_inner(), + }; + api::server_wrap( + flow, + state.get_ref(), + &req, + file_id, + files_delete_core, + auth::auth_type(&auth::ApiKeyAuth, &auth::JWTAuth, req.headers()), + ) + .await +} + +/// Files - Retrieve +/// +/// To retrieve a file +#[utoipa::path( + get, + path = "/files/{file_id}", + params( + ("file_id" = String, Path, description = "The identifier for file") + ), + responses( + (status = 200, description = "File body"), + (status = 400, description = "Bad Request") + ), + tag = "Files", + operation_id = "Retrieve a File", + security(("api_key" = [])) +)] +#[instrument(skip_all, fields(flow = ?Flow::RetrieveFile))] +pub async fn files_retrieve( + state: web::Data, + req: HttpRequest, + path: web::Path, +) -> HttpResponse { + let flow = Flow::RetrieveFile; + let file_id = files::FileId { + file_id: path.into_inner(), + }; + api::server_wrap( + flow, + state.get_ref(), + &req, + file_id, + files_retrieve_core, + auth::auth_type(&auth::ApiKeyAuth, &auth::JWTAuth, req.headers()), + ) + .await +} diff --git a/crates/router/src/routes/files/transformers.rs b/crates/router/src/routes/files/transformers.rs new file mode 100644 index 0000000000..c49f6892b5 --- /dev/null +++ b/crates/router/src/routes/files/transformers.rs @@ -0,0 +1,89 @@ +use actix_multipart::Multipart; +use actix_web::web::Bytes; +use common_utils::errors::CustomResult; +use error_stack::{IntoReport, ResultExt}; +use futures::{StreamExt, TryStreamExt}; + +use crate::{ + core::{errors, files::helpers}, + types::api::files::{self, CreateFileRequest}, + utils::OptionExt, +}; + +pub async fn get_create_file_request( + mut payload: Multipart, +) -> CustomResult { + let mut option_purpose: Option = None; + let mut dispute_id: Option = None; + + let mut file_name: Option = None; + let mut file_content: Option> = None; + + while let Ok(Some(mut field)) = payload.try_next().await { + let content_disposition = field.content_disposition(); + let field_name = content_disposition.get_name(); + // Parse the different parameters expected in the multipart request + match field_name { + Some("purpose") => { + option_purpose = helpers::get_file_purpose(&mut field).await; + } + Some("file") => { + file_name = content_disposition.get_filename().map(String::from); + + //Collect the file content and throw error if something fails + let mut file_data = Vec::new(); + let mut stream = field.into_stream(); + while let Some(chunk) = stream.next().await { + match chunk { + Ok(bytes) => file_data.push(bytes), + Err(err) => Err(errors::ApiErrorResponse::InternalServerError) + .into_report() + .attach_printable(format!("{}{}", "File parsing error: ", err))?, + } + } + file_content = Some(file_data) + } + Some("dispute_id") => { + dispute_id = helpers::read_string(&mut field).await; + } + // Can ignore other params + _ => (), + } + } + let purpose = option_purpose.get_required_value("purpose")?; + let file = match file_content { + Some(valid_file_content) => valid_file_content.concat().to_vec(), + None => Err(errors::ApiErrorResponse::MissingFile) + .into_report() + .attach_printable("Missing / Invalid file in the request")?, + }; + //Get and validate file size + let file_size: i32 = file + .len() + .try_into() + .into_report() + .change_context(errors::ApiErrorResponse::InternalServerError) + .attach_printable("File size error")?; + // Check if empty file and throw error + if file_size <= 0 { + Err(errors::ApiErrorResponse::MissingFile) + .into_report() + .attach_printable("Missing / Invalid file in the request")? + } + // Get file mime type using 'infer' + let kind = infer::get(&file).ok_or(errors::ApiErrorResponse::MissingFileContentType)?; + let file_type = kind + .mime_type() + .parse::() + .into_report() + .change_context(errors::ApiErrorResponse::MissingFileContentType) + .attach_printable("File content type error")?; + Ok(CreateFileRequest { + file, + file_name, + file_size, + file_type, + purpose, + dispute_id, + }) +} diff --git a/crates/router/src/services/api.rs b/crates/router/src/services/api.rs index 48f52d2c97..aa4bdd9c49 100644 --- a/crates/router/src/services/api.rs +++ b/crates/router/src/services/api.rs @@ -81,6 +81,13 @@ pub trait ConnectorIntegration: ConnectorIntegrationAny, + ) -> CustomResult, errors::ConnectorError> { + Ok(None) + } + /// This module can be called before executing a payment flow where a pre-task is needed /// Eg: Some connectors requires one-time session token before making a payment, we can add the session token creation logic in this block async fn execute_pretasks( @@ -309,6 +316,12 @@ async fn send_request( match request.content_type { Some(ContentType::Json) => client.json(&request.payload), + Some(ContentType::FormData) => client.multipart( + request + .form_data + .unwrap_or_else(reqwest::multipart::Form::new), + ), + // Currently this is not used remove this if not required // If using this then handle the serde_part Some(ContentType::FormUrlEncoded) => { @@ -336,11 +349,9 @@ async fn send_request( } } - Method::Put => { - client - .put(url) - .body(request.payload.expose_option().unwrap_or_default()) // If payload needs processing the body cannot have default - } + Method::Put => client + .put(url) + .body(request.payload.expose_option().unwrap_or_default()), // If payload needs processing the body cannot have default Method::Delete => client.delete(url), } .add_headers(headers) @@ -367,7 +378,7 @@ async fn handle_response( logger::info!(?response); let status_code = response.status().as_u16(); match status_code { - 200..=202 | 302 => { + 200..=202 | 302 | 204 => { logger::debug!(response=?response); // If needed add log line // logger:: error!( error_parsing_response=?err); @@ -441,6 +452,7 @@ pub enum ApplicationResponse { TextPlain(String), JsonForRedirection(api::RedirectionResponse), Form(RedirectForm), + FileData((Vec, mime::Mime)), } #[derive(Debug, Eq, PartialEq)] @@ -556,6 +568,9 @@ where }, Ok(ApplicationResponse::StatusOk) => http_response_ok(), Ok(ApplicationResponse::TextPlain(text)) => http_response_plaintext(text), + Ok(ApplicationResponse::FileData((file_data, content_type))) => { + http_response_file_data(file_data, content_type) + } Ok(ApplicationResponse::JsonForRedirection(response)) => { match serde_json::to_string(&response) { Ok(res) => http_redirect_response(res, response), @@ -605,6 +620,13 @@ pub fn http_response_plaintext(res: T) -> HttpRe HttpResponse::Ok().content_type(mime::TEXT_PLAIN).body(res) } +pub fn http_response_file_data( + res: T, + content_type: mime::Mime, +) -> HttpResponse { + HttpResponse::Ok().content_type(content_type).body(res) +} + pub fn http_response_ok() -> HttpResponse { HttpResponse::Ok().finish() } diff --git a/crates/router/src/services/api/request.rs b/crates/router/src/services/api/request.rs index 90e994874f..468d89e500 100644 --- a/crates/router/src/services/api/request.rs +++ b/crates/router/src/services/api/request.rs @@ -28,6 +28,7 @@ pub enum Method { pub enum ContentType { Json, FormUrlEncoded, + FormData, } fn default_request_headers() -> [(String, String); 1] { @@ -36,7 +37,7 @@ fn default_request_headers() -> [(String, String); 1] { [(header::VIA.to_string(), "HyperSwitch".into())] } -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug)] pub struct Request { pub url: String, pub headers: Headers, @@ -45,6 +46,7 @@ pub struct Request { pub content_type: Option, pub certificate: Option, pub certificate_key: Option, + pub form_data: Option, } impl Request { @@ -57,6 +59,7 @@ impl Request { content_type: None, certificate: None, certificate_key: None, + form_data: None, } } @@ -84,6 +87,10 @@ impl Request { pub fn add_certificate_key(&mut self, certificate_key: Option) { self.certificate = certificate_key; } + + pub fn set_form_data(&mut self, form_data: reqwest::multipart::Form) { + self.form_data = Some(form_data); + } } pub struct RequestBuilder { @@ -94,6 +101,7 @@ pub struct RequestBuilder { pub content_type: Option, pub certificate: Option, pub certificate_key: Option, + pub form_data: Option, } impl RequestBuilder { @@ -106,6 +114,7 @@ impl RequestBuilder { content_type: None, certificate: None, certificate_key: None, + form_data: None, } } @@ -135,6 +144,11 @@ impl RequestBuilder { self } + pub fn form_data(mut self, form_data: Option) -> Self { + self.form_data = form_data; + self + } + pub fn body(mut self, body: Option) -> Self { self.payload = body.map(From::from); self @@ -164,6 +178,7 @@ impl RequestBuilder { content_type: self.content_type, certificate: self.certificate, certificate_key: self.certificate_key, + form_data: self.form_data, } } } diff --git a/crates/router/src/types.rs b/crates/router/src/types.rs index fa7a69f7ce..6b7850b6a8 100644 --- a/crates/router/src/types.rs +++ b/crates/router/src/types.rs @@ -105,8 +105,31 @@ pub type RefundSyncType = pub type RefreshTokenType = dyn services::ConnectorIntegration; +pub type AcceptDisputeType = dyn services::ConnectorIntegration< + api::Accept, + AcceptDisputeRequestData, + AcceptDisputeResponse, +>; + +pub type SubmitEvidenceType = dyn services::ConnectorIntegration< + api::Evidence, + SubmitEvidenceRequestData, + SubmitEvidenceResponse, +>; + +pub type UploadFileType = + dyn services::ConnectorIntegration; + pub type VerifyRouterData = RouterData; +pub type AcceptDisputeRouterData = + RouterData; + +pub type SubmitEvidenceRouterData = + RouterData; + +pub type UploadFileRouterData = RouterData; + #[derive(Debug, Clone)] pub struct RouterData { pub flow: PhantomData, @@ -357,6 +380,75 @@ pub enum Redirection { NoRedirect, } +#[derive(Default, Debug, Clone)] +pub struct AcceptDisputeRequestData { + pub dispute_id: String, + pub connector_dispute_id: String, +} + +#[derive(Default, Clone, Debug)] +pub struct AcceptDisputeResponse { + pub dispute_status: api_models::enums::DisputeStatus, + pub connector_status: Option, +} + +#[derive(Default, Debug, Clone)] +pub struct SubmitEvidenceRequestData { + pub dispute_id: String, + pub connector_dispute_id: String, + pub access_activity_log: Option, + pub billing_address: Option, + pub cancellation_policy: Option>, + pub cancellation_policy_provider_file_id: Option, + pub cancellation_policy_disclosure: Option, + pub cancellation_rebuttal: Option, + pub customer_communication: Option>, + pub customer_communication_provider_file_id: Option, + pub customer_email_address: Option, + pub customer_name: Option, + pub customer_purchase_ip: Option, + pub customer_signature: Option>, + pub customer_signature_provider_file_id: Option, + pub product_description: Option, + pub receipt: Option>, + pub receipt_provider_file_id: Option, + pub refund_policy: Option>, + pub refund_policy_provider_file_id: Option, + pub refund_policy_disclosure: Option, + pub refund_refusal_explanation: Option, + pub service_date: Option, + pub service_documentation: Option>, + pub service_documentation_provider_file_id: Option, + pub shipping_address: Option, + pub shipping_carrier: Option, + pub shipping_date: Option, + pub shipping_documentation: Option>, + pub shipping_documentation_provider_file_id: Option, + pub shipping_tracking_number: Option, + pub uncategorized_file: Option>, + pub uncategorized_file_provider_file_id: Option, + pub uncategorized_text: Option, +} + +#[derive(Default, Clone, Debug)] +pub struct SubmitEvidenceResponse { + pub dispute_status: api_models::enums::DisputeStatus, + pub connector_status: Option, +} + +#[derive(Clone, Debug)] +pub struct UploadFileRequestData { + pub file_key: String, + pub file: Vec, + pub file_type: mime::Mime, + pub file_size: i32, +} + +#[derive(Default, Clone, Debug)] +pub struct UploadFileResponse { + pub provider_file_id: String, +} + #[derive(Debug, Clone, Default, serde::Deserialize, serde::Serialize)] pub struct ConnectorResponse { pub merchant_id: String, diff --git a/crates/router/src/types/api.rs b/crates/router/src/types/api.rs index 84304d6f1c..ff1a895854 100644 --- a/crates/router/src/types/api.rs +++ b/crates/router/src/types/api.rs @@ -4,6 +4,7 @@ pub mod configs; pub mod customers; pub mod disputes; pub mod enums; +pub mod files; pub mod mandates; pub mod payment_methods; pub mod payments; @@ -15,8 +16,8 @@ use std::{fmt::Debug, str::FromStr}; use error_stack::{report, IntoReport, ResultExt}; pub use self::{ - admin::*, api_keys::*, configs::*, customers::*, disputes::*, payment_methods::*, payments::*, - refunds::*, webhooks::*, + admin::*, api_keys::*, configs::*, customers::*, disputes::*, files::*, payment_methods::*, + payments::*, refunds::*, webhooks::*, }; use super::ErrorResponse; use crate::{ @@ -106,6 +107,8 @@ pub trait Connector: + ConnectorRedirectResponse + IncomingWebhook + ConnectorAccessToken + + Dispute + + FileUpload + ConnectorTransactionId { } @@ -122,6 +125,8 @@ impl< + Send + IncomingWebhook + ConnectorAccessToken + + Dispute + + FileUpload + ConnectorTransactionId, > Connector for T { diff --git a/crates/router/src/types/api/disputes.rs b/crates/router/src/types/api/disputes.rs index bd0f3b4b92..246c4b15cf 100644 --- a/crates/router/src/types/api/disputes.rs +++ b/crates/router/src/types/api/disputes.rs @@ -1,5 +1,7 @@ use masking::{Deserialize, Serialize}; +use crate::{services, types}; + #[derive(Default, Debug, Deserialize, Serialize)] pub struct DisputeId { pub dispute_id: String, @@ -18,3 +20,29 @@ pub struct DisputePayload { pub created_at: Option, pub updated_at: Option, } + +#[derive(Debug, Clone)] +pub struct Accept; + +pub trait AcceptDispute: + services::ConnectorIntegration< + Accept, + types::AcceptDisputeRequestData, + types::AcceptDisputeResponse, +> +{ +} + +#[derive(Debug, Clone)] +pub struct Evidence; + +pub trait SubmitEvidence: + services::ConnectorIntegration< + Evidence, + types::SubmitEvidenceRequestData, + types::SubmitEvidenceResponse, +> +{ +} + +pub trait Dispute: super::ConnectorCommon + AcceptDispute + SubmitEvidence {} diff --git a/crates/router/src/types/api/files.rs b/crates/router/src/types/api/files.rs index e69de29bb2..1a61bad3f9 100644 --- a/crates/router/src/types/api/files.rs +++ b/crates/router/src/types/api/files.rs @@ -0,0 +1,67 @@ +use masking::{Deserialize, Serialize}; + +use super::ConnectorCommon; +use crate::{core::errors, services, types}; + +#[derive(Default, Debug, Deserialize, Serialize)] +pub struct FileId { + pub file_id: String, +} + +#[derive(Debug, Clone, frunk::LabelledGeneric)] +pub enum FileUploadProvider { + Router, + Stripe, +} + +impl TryFrom<&types::Connector> for FileUploadProvider { + type Error = error_stack::Report; + fn try_from(item: &types::Connector) -> Result { + match item { + &types::Connector::Stripe => Ok(Self::Stripe), + _ => Err(errors::ApiErrorResponse::NotSupported { + message: "Connector not supported as file provider".to_owned(), + } + .into()), + } + } +} + +#[derive(Debug, Clone)] +pub struct CreateFileRequest { + pub file: Vec, + pub file_name: Option, + pub file_size: i32, + pub file_type: mime::Mime, + pub purpose: FilePurpose, + pub dispute_id: Option, +} + +#[derive(Debug, serde::Deserialize, strum::Display, Clone)] +#[serde(rename_all = "snake_case")] +#[strum(serialize_all = "snake_case")] +pub enum FilePurpose { + DisputeEvidence, +} + +#[derive(Debug, Clone)] +pub struct Upload; + +pub trait UploadFile: + services::ConnectorIntegration +{ +} + +pub trait FileUpload: ConnectorCommon + Sync + UploadFile { + fn validate_file_upload( + &self, + _purpose: FilePurpose, + _file_size: i32, + _file_type: mime::Mime, + ) -> common_utils::errors::CustomResult<(), errors::ConnectorError> { + Err(errors::ConnectorError::FileValidationFailed { + reason: "".to_owned(), + } + .into()) + } +} diff --git a/crates/router/src/types/storage.rs b/crates/router/src/types/storage.rs index 55bed00073..e56d93b08a 100644 --- a/crates/router/src/types/storage.rs +++ b/crates/router/src/types/storage.rs @@ -8,6 +8,7 @@ pub mod dispute; pub mod enums; pub mod ephemeral_key; pub mod events; +pub mod file; pub mod locker_mock_up; pub mod mandate; pub mod merchant_account; @@ -26,7 +27,7 @@ pub mod kv; pub use self::{ address::*, api_keys::*, cards_info::*, configs::*, connector_response::*, customers::*, - dispute::*, events::*, locker_mock_up::*, mandate::*, merchant_account::*, + dispute::*, events::*, file::*, locker_mock_up::*, mandate::*, merchant_account::*, merchant_connector_account::*, payment_attempt::*, payment_intent::*, payment_method::*, process_tracker::*, refund::*, reverse_lookup::*, }; diff --git a/crates/router/src/types/storage/file.rs b/crates/router/src/types/storage/file.rs new file mode 100644 index 0000000000..ddba5f442d --- /dev/null +++ b/crates/router/src/types/storage/file.rs @@ -0,0 +1,3 @@ +pub use storage_models::file::{ + FileMetadata, FileMetadataNew, FileMetadataUpdate, FileMetadataUpdateInternal, +}; diff --git a/crates/router/src/types/transformers.rs b/crates/router/src/types/transformers.rs index 2b7b393783..e57ee3a662 100644 --- a/crates/router/src/types/transformers.rs +++ b/crates/router/src/types/transformers.rs @@ -457,6 +457,12 @@ impl ForeignFrom for api_enums::DisputeStatus { } } +impl ForeignFrom for storage_enums::FileUploadProvider { + fn foreign_from(provider: api_types::FileUploadProvider) -> Self { + frunk::labelled_convert_from(provider) + } +} + impl ForeignTryFrom for storage_enums::DisputeStatus { type Error = errors::ValidationError; diff --git a/crates/router_env/src/logger/types.rs b/crates/router_env/src/logger/types.rs index 2addcf3a70..f4b8f303d9 100644 --- a/crates/router_env/src/logger/types.rs +++ b/crates/router_env/src/logger/types.rs @@ -168,6 +168,14 @@ pub enum Flow { DisputesList, /// Cards Info flow CardsInfo, + /// Create File flow + CreateFile, + /// Delete File flow + DeleteFile, + /// Retrieve File flow + RetrieveFile, + /// Dispute Evidence submission flow + DisputesEvidenceSubmit, } /// diff --git a/crates/storage_models/src/dispute.rs b/crates/storage_models/src/dispute.rs index 5b4e48601a..7022f399ac 100644 --- a/crates/storage_models/src/dispute.rs +++ b/crates/storage_models/src/dispute.rs @@ -17,7 +17,6 @@ pub struct DisputeNew { pub payment_id: String, pub attempt_id: String, pub merchant_id: String, - pub connector: String, pub connector_status: String, pub connector_dispute_id: String, pub connector_reason: Option, @@ -25,6 +24,7 @@ pub struct DisputeNew { pub challenge_required_by: Option, pub dispute_created_at: Option, pub updated_at: Option, + pub connector: String, } #[derive(Clone, Debug, Deserialize, Serialize, Identifiable, Queryable)] @@ -65,14 +65,18 @@ pub enum DisputeUpdate { challenge_required_by: Option, updated_at: Option, }, + StatusUpdate { + dispute_status: storage_enums::DisputeStatus, + connector_status: Option, + }, } #[derive(Clone, Debug, Default, AsChangeset, router_derive::DebugAsDisplay)] #[diesel(table_name = dispute)] pub struct DisputeUpdateInternal { - dispute_stage: storage_enums::DisputeStage, + dispute_stage: Option, dispute_status: storage_enums::DisputeStatus, - connector_status: String, + connector_status: Option, connector_reason: Option, connector_reason_code: Option, challenge_required_by: Option, @@ -92,15 +96,24 @@ impl From for DisputeUpdateInternal { challenge_required_by, updated_at, } => Self { - dispute_stage, + dispute_stage: Some(dispute_stage), dispute_status, - connector_status, + connector_status: Some(connector_status), connector_reason, connector_reason_code, challenge_required_by, updated_at, modified_at: Some(common_utils::date_time::now()), }, + DisputeUpdate::StatusUpdate { + dispute_status, + connector_status, + } => Self { + dispute_status, + connector_status, + modified_at: Some(common_utils::date_time::now()), + ..Default::default() + }, } } } diff --git a/crates/storage_models/src/enums.rs b/crates/storage_models/src/enums.rs index c858ea2593..81165c789b 100644 --- a/crates/storage_models/src/enums.rs +++ b/crates/storage_models/src/enums.rs @@ -796,3 +796,25 @@ pub enum DisputeStatus { DisputeWon, DisputeLost, } + +#[derive( + Clone, + Copy, + Debug, + Eq, + PartialEq, + Default, + serde::Deserialize, + serde::Serialize, + strum::Display, + strum::EnumString, + frunk::LabelledGeneric, +)] +#[router_derive::diesel_enum(storage_type = "text")] +#[serde(rename_all = "snake_case")] +#[strum(serialize_all = "snake_case")] +pub enum FileUploadProvider { + #[default] + Router, + Stripe, +} diff --git a/crates/storage_models/src/file.rs b/crates/storage_models/src/file.rs new file mode 100644 index 0000000000..08363c2cbf --- /dev/null +++ b/crates/storage_models/src/file.rs @@ -0,0 +1,68 @@ +use common_utils::custom_serde; +use diesel::{AsChangeset, Identifiable, Insertable, Queryable}; +use masking::{Deserialize, Serialize}; + +use crate::{enums as storage_enums, schema::file_metadata}; + +#[derive(Clone, Debug, Deserialize, Insertable, Serialize, router_derive::DebugAsDisplay)] +#[diesel(table_name = file_metadata)] +#[serde(deny_unknown_fields)] +pub struct FileMetadataNew { + pub file_id: String, + pub merchant_id: String, + pub file_name: Option, + pub file_size: i32, + pub file_type: String, + pub provider_file_id: Option, + pub file_upload_provider: Option, + pub available: bool, +} + +#[derive(Clone, Debug, Deserialize, Serialize, Identifiable, Queryable)] +#[diesel(table_name = file_metadata, primary_key(file_id, merchant_id))] +pub struct FileMetadata { + #[serde(skip_serializing)] + pub file_id: String, + pub merchant_id: String, + pub file_name: Option, + pub file_size: i32, + pub file_type: String, + pub provider_file_id: Option, + pub file_upload_provider: Option, + pub available: bool, + #[serde(with = "custom_serde::iso8601")] + pub created_at: time::PrimitiveDateTime, +} + +#[derive(Debug)] +pub enum FileMetadataUpdate { + Update { + provider_file_id: Option, + file_upload_provider: Option, + available: bool, + }, +} + +#[derive(Clone, Debug, Default, AsChangeset, router_derive::DebugAsDisplay)] +#[diesel(table_name = file_metadata)] +pub struct FileMetadataUpdateInternal { + provider_file_id: Option, + file_upload_provider: Option, + available: bool, +} + +impl From for FileMetadataUpdateInternal { + fn from(merchant_account_update: FileMetadataUpdate) -> Self { + match merchant_account_update { + FileMetadataUpdate::Update { + provider_file_id, + file_upload_provider, + available, + } => Self { + provider_file_id, + file_upload_provider, + available, + }, + } + } +} diff --git a/crates/storage_models/src/lib.rs b/crates/storage_models/src/lib.rs index a373bcaa2d..45a40b26f0 100644 --- a/crates/storage_models/src/lib.rs +++ b/crates/storage_models/src/lib.rs @@ -9,6 +9,7 @@ pub mod enums; pub mod ephemeral_key; pub mod errors; pub mod events; +pub mod file; #[cfg(feature = "kv_store")] pub mod kv; pub mod locker_mock_up; diff --git a/crates/storage_models/src/query.rs b/crates/storage_models/src/query.rs index 83baa4502c..e26e754bdb 100644 --- a/crates/storage_models/src/query.rs +++ b/crates/storage_models/src/query.rs @@ -6,6 +6,7 @@ pub mod connector_response; pub mod customers; pub mod dispute; pub mod events; +pub mod file; pub mod generics; pub mod locker_mock_up; pub mod mandate; diff --git a/crates/storage_models/src/query/file.rs b/crates/storage_models/src/query/file.rs new file mode 100644 index 0000000000..0314b04fb2 --- /dev/null +++ b/crates/storage_models/src/query/file.rs @@ -0,0 +1,75 @@ +use diesel::{associations::HasTable, BoolExpressionMethods, ExpressionMethods}; +use router_env::{instrument, tracing}; + +use super::generics; +use crate::{ + errors, + file::{FileMetadata, FileMetadataNew, FileMetadataUpdate, FileMetadataUpdateInternal}, + schema::file_metadata::dsl, + PgPooledConn, StorageResult, +}; + +impl FileMetadataNew { + #[instrument(skip(conn))] + pub async fn insert(self, conn: &PgPooledConn) -> StorageResult { + generics::generic_insert(conn, self).await + } +} + +impl FileMetadata { + #[instrument(skip(conn))] + pub async fn find_by_merchant_id_file_id( + conn: &PgPooledConn, + merchant_id: &str, + file_id: &str, + ) -> StorageResult { + generics::generic_find_one::<::Table, _, _>( + conn, + dsl::merchant_id + .eq(merchant_id.to_owned()) + .and(dsl::file_id.eq(file_id.to_owned())), + ) + .await + } + + #[instrument(skip(conn))] + pub async fn delete_by_merchant_id_file_id( + conn: &PgPooledConn, + merchant_id: &str, + file_id: &str, + ) -> StorageResult { + generics::generic_delete::<::Table, _>( + conn, + dsl::merchant_id + .eq(merchant_id.to_owned()) + .and(dsl::file_id.eq(file_id.to_owned())), + ) + .await + } + + #[instrument(skip(conn))] + pub async fn update( + self, + conn: &PgPooledConn, + file_metadata: FileMetadataUpdate, + ) -> StorageResult { + match generics::generic_update_with_unique_predicate_get_result::< + ::Table, + _, + _, + _, + >( + conn, + dsl::file_id.eq(self.file_id.to_owned()), + FileMetadataUpdateInternal::from(file_metadata), + ) + .await + { + Err(error) => match error.current_context() { + errors::DatabaseError::NoFieldsToUpdate => Ok(self), + _ => Err(error), + }, + result => result, + } + } +} diff --git a/crates/storage_models/src/schema.rs b/crates/storage_models/src/schema.rs index a1d9ec2fd0..2a642a42ee 100644 --- a/crates/storage_models/src/schema.rs +++ b/crates/storage_models/src/schema.rs @@ -154,6 +154,23 @@ diesel::table! { } } +diesel::table! { + use diesel::sql_types::*; + use crate::enums::diesel_exports::*; + + file_metadata (file_id, merchant_id) { + file_id -> Varchar, + merchant_id -> Varchar, + file_name -> Nullable, + file_size -> Int4, + file_type -> Varchar, + provider_file_id -> Nullable, + file_upload_provider -> Nullable, + available -> Bool, + created_at -> Timestamp, + } +} + diesel::table! { use diesel::sql_types::*; use crate::enums::diesel_exports::*; @@ -438,6 +455,7 @@ diesel::allow_tables_to_appear_in_same_query!( customers, dispute, events, + file_metadata, locker_mock_up, mandate, merchant_account, diff --git a/loadtest/config/development.toml b/loadtest/config/development.toml index b1a2907e9e..51911bba83 100644 --- a/loadtest/config/development.toml +++ b/loadtest/config/development.toml @@ -82,6 +82,7 @@ payu.base_url = "https://secure.snd.payu.com/" rapyd.base_url = "https://sandboxapi.rapyd.net" shift4.base_url = "https://api.shift4.com/" stripe.base_url = "https://api.stripe.com/" +stripe.base_url_file_upload = "https://files.stripe.com/" worldline.base_url = "https://eu.sandbox.api-ingenico.com/" worldpay.base_url = "https://try.access.worldpay.com/" trustpay.base_url = "https://test-tpgw.trustpay.eu/" diff --git a/migrations/2023-04-04-061926_add_dispute_api_schema/down.sql b/migrations/2023-04-04-061926_add_dispute_api_schema/down.sql new file mode 100644 index 0000000000..1a3aa682c7 --- /dev/null +++ b/migrations/2023-04-04-061926_add_dispute_api_schema/down.sql @@ -0,0 +1 @@ +DROP TABLE file_metadata; diff --git a/migrations/2023-04-04-061926_add_dispute_api_schema/up.sql b/migrations/2023-04-04-061926_add_dispute_api_schema/up.sql new file mode 100644 index 0000000000..877e9fc0e6 --- /dev/null +++ b/migrations/2023-04-04-061926_add_dispute_api_schema/up.sql @@ -0,0 +1,13 @@ +-- Your SQL goes here +CREATE TABLE file_metadata ( + file_id VARCHAR(64) NOT NULL, + merchant_id VARCHAR(255) NOT NULL, + file_name VARCHAR(255), + file_size INTEGER NOT NULL, + file_type VARCHAR(255) NOT NULL, + provider_file_id VARCHAR(255), + file_upload_provider VARCHAR(255), + available BOOLEAN NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT now()::TIMESTAMP, + PRIMARY KEY (file_id, merchant_id) +);