feat(router): added dispute accept api, file module apis and dispute evidence submission api (#900)

Co-authored-by: Sangamesh <sangamesh.kulkarni@juspay.in>
Co-authored-by: sai harsha <sai.harsha@sai.harsha-MacBookPro>
Co-authored-by: Arun Raj M <jarnura47@gmail.com>
This commit is contained in:
Sai Harsha Vardhan
2023-04-25 01:05:21 +05:30
committed by GitHub
parent bcbf4c882c
commit bdf1e5147e
54 changed files with 2822 additions and 34 deletions

View File

@ -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<errors::ApiErrorResponse> 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

View File

@ -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),

View File

@ -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> {
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(())
}
}

View File

@ -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;

View File

@ -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<api::RSync, types::RefundsData, types::RefundsResponse
}
}
impl
ConnectorIntegration<api::Accept, types::AcceptDisputeRequestData, types::AcceptDisputeResponse>
for Checkout
{
fn get_headers(
&self,
req: &types::AcceptDisputeRouterData,
_connectors: &settings::Connectors,
) -> CustomResult<Vec<(String, String)>, 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<String, errors::ConnectorError> {
Ok(format!(
"{}{}{}{}",
self.base_url(connectors),
"disputes/",
req.request.connector_dispute_id,
"/accept"
))
}
fn build_request(
&self,
req: &types::AcceptDisputeRouterData,
connectors: &settings::Connectors,
) -> CustomResult<Option<services::Request>, 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<types::AcceptDisputeRouterData, errors::ConnectorError> {
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<types::ErrorResponse, errors::ConnectorError> {
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<api::Upload, types::UploadFileRequestData, types::UploadFileResponse>
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(

View File

@ -948,6 +948,249 @@ impl services::ConnectorIntegration<api::RSync, types::RefundsData, types::Refun
}
}
impl api::UploadFile for Stripe {}
#[async_trait::async_trait]
impl api::FileUpload for Stripe {
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/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<Vec<(String, String)>, 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<String, errors::ConnectorError> {
Ok(format!(
"{}{}",
connectors.stripe.base_url_file_upload, "v1/files"
))
}
fn get_request_form_data(
&self,
req: &types::UploadFileRouterData,
) -> CustomResult<Option<reqwest::multipart::Form>, 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<Option<services::Request>, 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<api::Upload, types::UploadFileRequestData, types::UploadFileResponse>,
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<types::ErrorResponse, errors::ConnectorError> {
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<Vec<(String, String)>, 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<String, errors::ConnectorError> {
Ok(format!(
"{}{}{}",
self.base_url(connectors),
"v1/disputes/",
req.request.connector_dispute_id
))
}
fn get_request_body(
&self,
req: &types::SubmitEvidenceRouterData,
) -> CustomResult<Option<String>, errors::ConnectorError> {
let stripe_req = stripe::Evidence::try_from(req)?;
let stripe_req_string = utils::Encode::<stripe::Evidence>::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<Option<services::Request>, 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<types::SubmitEvidenceRouterData, errors::ConnectorError> {
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<types::ErrorResponse, errors::ConnectorError> {
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<HashMap<String, Vec<u8>>, errors::ConnectorError> {

View File

@ -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<reqwest::multipart::Form, errors::ConnectorError> {
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<String>,
#[serde(rename = "evidence[billing_address]")]
pub billing_address: Option<String>,
#[serde(rename = "evidence[cancellation_policy]")]
pub cancellation_policy: Option<String>,
#[serde(rename = "evidence[cancellation_policy_disclosure]")]
pub cancellation_policy_disclosure: Option<String>,
#[serde(rename = "evidence[cancellation_rebuttal]")]
pub cancellation_rebuttal: Option<String>,
#[serde(rename = "evidence[customer_communication]")]
pub customer_communication: Option<String>,
#[serde(rename = "evidence[customer_email_address]")]
pub customer_email_address: Option<String>,
#[serde(rename = "evidence[customer_name]")]
pub customer_name: Option<String>,
#[serde(rename = "evidence[customer_purchase_ip]")]
pub customer_purchase_ip: Option<String>,
#[serde(rename = "evidence[customer_signature]")]
pub customer_signature: Option<String>,
#[serde(rename = "evidence[product_description]")]
pub product_description: Option<String>,
#[serde(rename = "evidence[receipt]")]
pub receipt: Option<String>,
#[serde(rename = "evidence[refund_policy]")]
pub refund_policy: Option<String>,
#[serde(rename = "evidence[refund_policy_disclosure]")]
pub refund_policy_disclosure: Option<String>,
#[serde(rename = "evidence[refund_refusal_explanation]")]
pub refund_refusal_explanation: Option<String>,
#[serde(rename = "evidence[service_date]")]
pub service_date: Option<String>,
#[serde(rename = "evidence[service_documentation]")]
pub service_documentation: Option<String>,
#[serde(rename = "evidence[shipping_address]")]
pub shipping_address: Option<String>,
#[serde(rename = "evidence[shipping_carrier]")]
pub shipping_carrier: Option<String>,
#[serde(rename = "evidence[shipping_date]")]
pub shipping_date: Option<String>,
#[serde(rename = "evidence[shipping_documentation]")]
pub shipping_documentation: Option<String>,
#[serde(rename = "evidence[shipping_tracking_number]")]
pub shipping_tracking_number: Option<String>,
#[serde(rename = "evidence[uncategorized_file]")]
pub uncategorized_file: Option<String>,
#[serde(rename = "evidence[uncategorized_text]")]
pub uncategorized_text: Option<String>,
pub submit: bool,
}
impl TryFrom<&types::SubmitEvidenceRouterData> for Evidence {
type Error = error_stack::Report<errors::ConnectorError>;
fn try_from(item: &types::SubmitEvidenceRouterData) -> Result<Self, Self::Error> {
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,
}

View File

@ -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;

View File

@ -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<dispute_models::DisputeResponse> {
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<dispute_models::DisputeResponse> {
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))
}

View File

@ -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<SubmitEvidenceRequestData, errors::ApiErrorResponse> {
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,
})
}

View File

@ -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)]

View File

@ -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<api_models::errors::types::ApiErrorRespon
Self::InvalidCardIinLength => 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))
}
}
}
}

View File

@ -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<files::CreateFileResponse> {
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<serde_json::Value> {
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<serde_json::Value> {
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::<mime::Mime>()
.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,
)))
}

View File

@ -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<u8>,
) -> 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<Vec<u8>, errors::ApiErrorResponse> {
let mut received_data: Vec<u8> = 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)
}

View File

@ -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<String> {
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<api::FilePurpose> {
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<u8>,
) -> 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<Vec<u8>, 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<String>,
merchant_account: &storage_models::merchant_account::MerchantAccount,
) -> CustomResult<(Option<Vec<u8>>, Option<String>), 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))
}
}
}
}

View File

@ -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<u8>,
) -> 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<Vec<u8>, 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<u8> = 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)
}

View File

@ -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

View File

@ -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
);

View File

@ -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<types::AcceptDisputeRouterData> {
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<types::SubmitEvidenceRouterData> {
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<types::UploadFileRouterData> {
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)
}

View File

@ -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

View File

@ -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<storage::FileMetadata, errors::StorageError>;
async fn find_file_metadata_by_merchant_id_file_id(
&self,
merchant_id: &str,
file_id: &str,
) -> CustomResult<storage::FileMetadata, errors::StorageError>;
async fn delete_file_metadata_by_merchant_id_file_id(
&self,
merchant_id: &str,
file_id: &str,
) -> CustomResult<bool, errors::StorageError>;
async fn update_file_metadata(
&self,
this: storage::FileMetadata,
file_metadata: storage::FileMetadataUpdate,
) -> CustomResult<storage::FileMetadata, errors::StorageError>;
}
#[async_trait::async_trait]
impl FileMetadataInterface for Store {
async fn insert_file_metadata(
&self,
file: storage::FileMetadataNew,
) -> CustomResult<storage::FileMetadata, errors::StorageError> {
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<storage::FileMetadata, errors::StorageError> {
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<bool, errors::StorageError> {
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<storage::FileMetadata, errors::StorageError> {
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<storage::FileMetadata, errors::StorageError> {
// 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<storage::FileMetadata, errors::StorageError> {
// 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<bool, errors::StorageError> {
// TODO: Implement function for `MockDb`
Err(errors::StorageError::MockDbError)?
}
async fn update_file_metadata(
&self,
_this: storage::FileMetadata,
_file_metadata: storage::FileMetadataUpdate,
) -> CustomResult<storage::FileMetadata, errors::StorageError> {
// TODO: Implement function for `MockDb`
Err(errors::StorageError::MockDbError)?
}
}

View File

@ -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()));
}

View File

@ -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,
};

View File

@ -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)),
)
}
}

View File

@ -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<AppState>,
req: HttpRequest,
payload: web::Query<DisputeListConstraints>,
payload: web::Query<dispute_models::DisputeListConstraints>,
) -> 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<AppState>,
req: HttpRequest,
path: web::Path<String>,
) -> 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<AppState>,
req: HttpRequest,
json_payload: web::Json<dispute_models::SubmitEvidenceRequest>,
) -> 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
}

View File

@ -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<AppState>,
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<AppState>,
req: HttpRequest,
path: web::Path<String>,
) -> 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<AppState>,
req: HttpRequest,
path: web::Path<String>,
) -> 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
}

View File

@ -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<CreateFileRequest, errors::ApiErrorResponse> {
let mut option_purpose: Option<files::FilePurpose> = None;
let mut dispute_id: Option<String> = None;
let mut file_name: Option<String> = None;
let mut file_content: Option<Vec<Bytes>> = 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::<mime::Mime>()
.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,
})
}

View File

@ -81,6 +81,13 @@ pub trait ConnectorIntegration<T, Req, Resp>: ConnectorIntegrationAny<T, Req, Re
Ok(None)
}
fn get_request_form_data(
&self,
_req: &types::RouterData<T, Req, Resp>,
) -> CustomResult<Option<reqwest::multipart::Form>, 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<R> {
TextPlain(String),
JsonForRedirection(api::RedirectionResponse),
Form(RedirectForm),
FileData((Vec<u8>, 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<T: body::MessageBody + 'static>(res: T) -> HttpRe
HttpResponse::Ok().content_type(mime::TEXT_PLAIN).body(res)
}
pub fn http_response_file_data<T: body::MessageBody + 'static>(
res: T,
content_type: mime::Mime,
) -> HttpResponse {
HttpResponse::Ok().content_type(content_type).body(res)
}
pub fn http_response_ok() -> HttpResponse {
HttpResponse::Ok().finish()
}

View File

@ -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<ContentType>,
pub certificate: Option<String>,
pub certificate_key: Option<String>,
pub form_data: Option<reqwest::multipart::Form>,
}
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<String>) {
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<ContentType>,
pub certificate: Option<String>,
pub certificate_key: Option<String>,
pub form_data: Option<reqwest::multipart::Form>,
}
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<reqwest::multipart::Form>) -> Self {
self.form_data = form_data;
self
}
pub fn body(mut self, body: Option<String>) -> 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,
}
}
}

View File

@ -105,8 +105,31 @@ pub type RefundSyncType =
pub type RefreshTokenType =
dyn services::ConnectorIntegration<api::AccessTokenAuth, AccessTokenRequestData, AccessToken>;
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<api::Upload, UploadFileRequestData, UploadFileResponse>;
pub type VerifyRouterData = RouterData<api::Verify, VerifyRequestData, PaymentsResponseData>;
pub type AcceptDisputeRouterData =
RouterData<api::Accept, AcceptDisputeRequestData, AcceptDisputeResponse>;
pub type SubmitEvidenceRouterData =
RouterData<api::Evidence, SubmitEvidenceRequestData, SubmitEvidenceResponse>;
pub type UploadFileRouterData = RouterData<api::Upload, UploadFileRequestData, UploadFileResponse>;
#[derive(Debug, Clone)]
pub struct RouterData<Flow, Request, Response> {
pub flow: PhantomData<Flow>,
@ -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<String>,
}
#[derive(Default, Debug, Clone)]
pub struct SubmitEvidenceRequestData {
pub dispute_id: String,
pub connector_dispute_id: String,
pub access_activity_log: Option<String>,
pub billing_address: Option<String>,
pub cancellation_policy: Option<Vec<u8>>,
pub cancellation_policy_provider_file_id: Option<String>,
pub cancellation_policy_disclosure: Option<String>,
pub cancellation_rebuttal: Option<String>,
pub customer_communication: Option<Vec<u8>>,
pub customer_communication_provider_file_id: Option<String>,
pub customer_email_address: Option<String>,
pub customer_name: Option<String>,
pub customer_purchase_ip: Option<String>,
pub customer_signature: Option<Vec<u8>>,
pub customer_signature_provider_file_id: Option<String>,
pub product_description: Option<String>,
pub receipt: Option<Vec<u8>>,
pub receipt_provider_file_id: Option<String>,
pub refund_policy: Option<Vec<u8>>,
pub refund_policy_provider_file_id: Option<String>,
pub refund_policy_disclosure: Option<String>,
pub refund_refusal_explanation: Option<String>,
pub service_date: Option<String>,
pub service_documentation: Option<Vec<u8>>,
pub service_documentation_provider_file_id: Option<String>,
pub shipping_address: Option<String>,
pub shipping_carrier: Option<String>,
pub shipping_date: Option<String>,
pub shipping_documentation: Option<Vec<u8>>,
pub shipping_documentation_provider_file_id: Option<String>,
pub shipping_tracking_number: Option<String>,
pub uncategorized_file: Option<Vec<u8>>,
pub uncategorized_file_provider_file_id: Option<String>,
pub uncategorized_text: Option<String>,
}
#[derive(Default, Clone, Debug)]
pub struct SubmitEvidenceResponse {
pub dispute_status: api_models::enums::DisputeStatus,
pub connector_status: Option<String>,
}
#[derive(Clone, Debug)]
pub struct UploadFileRequestData {
pub file_key: String,
pub file: Vec<u8>,
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,

View File

@ -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
{

View File

@ -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<String>,
pub updated_at: Option<String>,
}
#[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 {}

View File

@ -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<errors::ApiErrorResponse>;
fn try_from(item: &types::Connector) -> Result<Self, Self::Error> {
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<u8>,
pub file_name: Option<String>,
pub file_size: i32,
pub file_type: mime::Mime,
pub purpose: FilePurpose,
pub dispute_id: Option<String>,
}
#[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<Upload, types::UploadFileRequestData, types::UploadFileResponse>
{
}
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())
}
}

View File

@ -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::*,
};

View File

@ -0,0 +1,3 @@
pub use storage_models::file::{
FileMetadata, FileMetadataNew, FileMetadataUpdate, FileMetadataUpdateInternal,
};

View File

@ -457,6 +457,12 @@ impl ForeignFrom<storage_enums::DisputeStatus> for api_enums::DisputeStatus {
}
}
impl ForeignFrom<api_types::FileUploadProvider> for storage_enums::FileUploadProvider {
fn foreign_from(provider: api_types::FileUploadProvider) -> Self {
frunk::labelled_convert_from(provider)
}
}
impl ForeignTryFrom<api_models::webhooks::IncomingWebhookEvent> for storage_enums::DisputeStatus {
type Error = errors::ValidationError;