feat(router): added support for optional defend dispute api call and added evidence submission flow for checkout connector (#979)

This commit is contained in:
Sai Harsha Vardhan
2023-05-03 02:25:53 +05:30
committed by GitHub
parent 0b7bc7bcd2
commit 4728d946e2
13 changed files with 529 additions and 46 deletions

View File

@ -122,6 +122,10 @@ pub struct SubmitEvidenceRequest {
pub shipping_documentation: Option<String>,
/// Tracking number of shipped product
pub shipping_tracking_number: Option<String>,
/// File Id showing two distinct transactions when customer claims a payment was charged twice
pub invoice_showing_distinct_transactions: Option<String>,
/// File Id of recurring transaction agreement
pub recurring_transaction_agreement: Option<String>,
/// Any additional supporting file
pub uncategorized_file: Option<String>,
/// Any additional evidence statements

View File

@ -627,7 +627,10 @@ impl Connector {
)
}
pub fn supports_file_storage_module(&self) -> bool {
matches!(self, Self::Stripe)
matches!(self, Self::Stripe | Self::Checkout)
}
pub fn requires_defend_dispute(&self) -> bool {
matches!(self, Self::Checkout)
}
}

View File

@ -118,6 +118,7 @@ impl api::ConnectorAccessToken for Checkout {}
impl api::AcceptDispute for Checkout {}
impl api::PaymentToken for Checkout {}
impl api::Dispute for Checkout {}
impl api::DefendDispute for Checkout {}
impl
ConnectorIntegration<
@ -766,43 +767,12 @@ impl
&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,
})
self.build_error_response(res)
}
}
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(
@ -832,6 +802,240 @@ impl api::FileUpload for Checkout {
}
}
impl ConnectorIntegration<api::Upload, types::UploadFileRequestData, types::UploadFileResponse>
for Checkout
{
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!("{}{}", self.base_url(connectors), "files"))
}
fn get_request_form_data(
&self,
req: &types::UploadFileRouterData,
) -> CustomResult<Option<reqwest::multipart::Form>, errors::ConnectorError> {
let checkout_req = transformers::construct_file_upload_request(req.clone())?;
Ok(Some(checkout_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(),
))
}
fn handle_response(
&self,
data: &types::UploadFileRouterData,
res: types::Response,
) -> CustomResult<
types::RouterData<api::Upload, types::UploadFileRequestData, types::UploadFileResponse>,
errors::ConnectorError,
> {
let response: checkout::FileUploadResponse = res
.response
.parse_struct("Checkout 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> {
self.build_error_response(res)
}
}
impl api::SubmitEvidence for Checkout {}
impl
ConnectorIntegration<
api::Evidence,
types::SubmitEvidenceRequestData,
types::SubmitEvidenceResponse,
> for Checkout
{
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_url(
&self,
req: &types::SubmitEvidenceRouterData,
connectors: &settings::Connectors,
) -> CustomResult<String, errors::ConnectorError> {
Ok(format!(
"{}disputes/{}/evidence",
self.base_url(connectors),
req.request.connector_dispute_id,
))
}
fn get_request_body(
&self,
req: &types::SubmitEvidenceRouterData,
) -> CustomResult<Option<String>, errors::ConnectorError> {
let checkout_req = checkout::Evidence::try_from(req)?;
let checkout_req_string =
utils::Encode::<checkout::Evidence>::encode_to_string_of_json(&checkout_req)
.change_context(errors::ConnectorError::RequestEncodingFailed)?;
Ok(Some(checkout_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::Put)
.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))
}
fn handle_response(
&self,
data: &types::SubmitEvidenceRouterData,
_res: types::Response,
) -> CustomResult<types::SubmitEvidenceRouterData, errors::ConnectorError> {
Ok(types::SubmitEvidenceRouterData {
response: Ok(types::SubmitEvidenceResponse {
dispute_status: api_models::enums::DisputeStatus::DisputeChallenged,
connector_status: None,
}),
..data.clone()
})
}
fn get_error_response(
&self,
res: types::Response,
) -> CustomResult<types::ErrorResponse, errors::ConnectorError> {
self.build_error_response(res)
}
}
impl
ConnectorIntegration<api::Defend, types::DefendDisputeRequestData, types::DefendDisputeResponse>
for Checkout
{
fn get_headers(
&self,
req: &types::DefendDisputeRouterData,
_connectors: &settings::Connectors,
) -> CustomResult<Vec<(String, String)>, errors::ConnectorError> {
let mut header = vec![(
headers::CONTENT_TYPE.to_string(),
types::DefendDisputeType::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::DefendDisputeRouterData,
connectors: &settings::Connectors,
) -> CustomResult<String, errors::ConnectorError> {
Ok(format!(
"{}disputes/{}/evidence",
self.base_url(connectors),
req.request.connector_dispute_id,
))
}
fn build_request(
&self,
req: &types::DefendDisputeRouterData,
connectors: &settings::Connectors,
) -> CustomResult<Option<services::Request>, errors::ConnectorError> {
Ok(Some(
services::RequestBuilder::new()
.method(services::Method::Post)
.url(&types::DefendDisputeType::get_url(self, req, connectors)?)
.attach_default_headers()
.headers(types::DefendDisputeType::get_headers(
self, req, connectors,
)?)
.build(),
))
}
fn handle_response(
&self,
data: &types::DefendDisputeRouterData,
_res: types::Response,
) -> CustomResult<types::DefendDisputeRouterData, errors::ConnectorError> {
Ok(types::DefendDisputeRouterData {
response: Ok(types::DefendDisputeResponse {
dispute_status: api::enums::DisputeStatus::DisputeChallenged,
connector_status: None,
}),
..data.clone()
})
}
fn get_error_response(
&self,
res: types::Response,
) -> CustomResult<types::ErrorResponse, errors::ConnectorError> {
self.build_error_response(res)
}
}
#[async_trait::async_trait]
impl api::IncomingWebhook for Checkout {
fn get_webhook_source_verification_algorithm(

View File

@ -1,4 +1,5 @@
use error_stack::IntoReport;
use common_utils::errors::CustomResult;
use error_stack::{IntoReport, ResultExt};
use serde::{Deserialize, Serialize};
use url::Url;
@ -775,3 +776,67 @@ impl From<CheckoutTxnType> for api_models::enums::DisputeStage {
pub struct CheckoutWebhookObjectResource {
pub data: serde_json::Value,
}
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(format!(
"{}.{}",
request.file_key,
request
.file_type
.to_string()
.split('/')
.last()
.unwrap_or_default()
))
.mime_str(request.file_type.as_ref())
.into_report()
.change_context(errors::ConnectorError::RequestEncodingFailed)
.attach_printable("Failure in constructing file data")?;
multipart = multipart.part("file", file_data);
Ok(multipart)
}
#[derive(Debug, Deserialize)]
pub struct FileUploadResponse {
#[serde(rename = "id")]
pub file_id: String,
}
#[derive(Default, Debug, Serialize)]
pub struct Evidence {
pub proof_of_delivery_or_service_file: Option<String>,
pub invoice_or_receipt_file: Option<String>,
pub invoice_showing_distinct_transactions_file: Option<String>,
pub customer_communication_file: Option<String>,
pub refund_or_cancellation_policy_file: Option<String>,
pub recurring_transaction_agreement_file: Option<String>,
pub additional_evidence_file: Option<String>,
}
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 {
proof_of_delivery_or_service_file: submit_evidence_request_data
.shipping_documentation_provider_file_id,
invoice_or_receipt_file: submit_evidence_request_data.receipt_provider_file_id,
invoice_showing_distinct_transactions_file: submit_evidence_request_data
.invoice_showing_distinct_transactions_provider_file_id,
customer_communication_file: submit_evidence_request_data
.customer_communication_provider_file_id,
refund_or_cancellation_policy_file: submit_evidence_request_data
.refund_policy_provider_file_id,
recurring_transaction_agreement_file: submit_evidence_request_data
.recurring_transaction_agreement_provider_file_id,
additional_evidence_file: submit_evidence_request_data
.uncategorized_file_provider_file_id,
})
}
}

View File

@ -1128,7 +1128,6 @@ impl
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))
}

View File

@ -15,8 +15,8 @@ use crate::{
api::{self, disputes},
storage::{self, enums as storage_enums},
transformers::{ForeignFrom, ForeignInto},
AcceptDisputeRequestData, AcceptDisputeResponse, SubmitEvidenceRequestData,
SubmitEvidenceResponse,
AcceptDisputeRequestData, AcceptDisputeResponse, DefendDisputeRequestData,
DefendDisputeResponse, SubmitEvidenceRequestData, SubmitEvidenceResponse,
},
};
@ -245,12 +245,54 @@ pub async fn submit_evidence(
status_code: err.status_code,
reason: err.reason,
})?;
//Defend Dispute Optionally if connector expects to defend / submit evidence in a separate api call
let (dispute_status, connector_status) =
if connector_data.connector_name.requires_defend_dispute() {
let connector_integration_defend_dispute: services::BoxedConnectorIntegration<
'_,
api::Defend,
DefendDisputeRequestData,
DefendDisputeResponse,
> = connector_data.connector.get_connector_integration();
let defend_dispute_router_data = utils::construct_defend_dispute_router_data(
state,
&payment_intent,
&payment_attempt,
&merchant_account,
&dispute,
)
.await?;
let defend_response = services::execute_connector_processing_step(
state,
connector_integration_defend_dispute,
&defend_dispute_router_data,
payments::CallConnectorAction::Trigger,
)
.await
.change_context(errors::ApiErrorResponse::InternalServerError)
.attach_printable("Failed while calling defend dispute connector api")?;
let defend_dispute_response = defend_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,
}
})?;
(
defend_dispute_response.dispute_status,
defend_dispute_response.connector_status,
)
} else {
(
submit_evidence_response.dispute_status,
submit_evidence_response.connector_status,
)
};
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(),
dispute_status: dispute_status.foreign_into(),
connector_status,
};
let updated_dispute = db
.update_dispute(dispute.clone(), update_dispute)

View File

@ -60,6 +60,22 @@ pub async fn get_evidence_request_data(
merchant_account,
)
.await?;
let (
invoice_showing_distinct_transactions,
invoice_showing_distinct_transactions_provider_file_id,
) = retrieve_file_and_provider_file_id_from_file_id(
state,
evidence_request.invoice_showing_distinct_transactions,
merchant_account,
)
.await?;
let (recurring_transaction_agreement, recurring_transaction_agreement_provider_file_id) =
retrieve_file_and_provider_file_id_from_file_id(
state,
evidence_request.recurring_transaction_agreement,
merchant_account,
)
.await?;
let (uncategorized_file, uncategorized_file_provider_file_id) =
retrieve_file_and_provider_file_id_from_file_id(
state,
@ -99,6 +115,10 @@ pub async fn get_evidence_request_data(
shipping_documentation,
shipping_documentation_provider_file_id,
shipping_tracking_number: evidence_request.shipping_tracking_number,
invoice_showing_distinct_transactions,
invoice_showing_distinct_transactions_provider_file_id,
recurring_transaction_agreement,
recurring_transaction_agreement_provider_file_id,
uncategorized_file,
uncategorized_file_provider_file_id,
uncategorized_text: evidence_request.uncategorized_text,

View File

@ -309,7 +309,6 @@ default_imp_for_submit_evidence!(
connector::Bambora,
connector::Bluesnap,
connector::Braintree,
connector::Checkout,
connector::Cybersource,
connector::Coinbase,
connector::Dlocal,
@ -332,3 +331,50 @@ default_imp_for_submit_evidence!(
connector::Worldpay,
connector::Zen
);
macro_rules! default_imp_for_defend_dispute{
($($path:ident::$connector:ident),*)=> {
$(
impl api::DefendDispute for $path::$connector {}
impl
services::ConnectorIntegration<
api::Defend,
types::DefendDisputeRequestData,
types::DefendDisputeResponse,
> for $path::$connector
{}
)*
};
}
default_imp_for_defend_dispute!(
connector::Aci,
connector::Adyen,
connector::Airwallex,
connector::Authorizedotnet,
connector::Bambora,
connector::Bluesnap,
connector::Braintree,
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::Stripe,
connector::Shift4,
connector::Trustpay,
connector::Opennode,
connector::Worldline,
connector::Worldpay,
connector::Zen
);

View File

@ -13,6 +13,7 @@ use crate::{
types::{
self,
storage::{self, enums},
ErrorResponse,
},
utils::{generate_id, OptionExt, ValueExt},
};
@ -400,3 +401,62 @@ pub async fn construct_upload_file_router_data<'a>(
};
Ok(router_data)
}
#[instrument(skip_all)]
pub async fn construct_defend_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::DefendDisputeRouterData> {
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::DefendDisputeRequestData {
dispute_id: dispute.dispute_id.clone(),
connector_dispute_id: dispute.connector_dispute_id.clone(),
},
response: Err(ErrorResponse::get_not_implemented()),
access_token: None,
session_token: None,
reference_id: None,
payment_method_token: None,
};
Ok(router_data)
}

View File

@ -120,6 +120,12 @@ pub type SubmitEvidenceType = dyn services::ConnectorIntegration<
pub type UploadFileType =
dyn services::ConnectorIntegration<api::Upload, UploadFileRequestData, UploadFileResponse>;
pub type DefendDisputeType = dyn services::ConnectorIntegration<
api::Defend,
DefendDisputeRequestData,
DefendDisputeResponse,
>;
pub type VerifyRouterData = RouterData<api::Verify, VerifyRequestData, PaymentsResponseData>;
pub type AcceptDisputeRouterData =
@ -130,6 +136,9 @@ pub type SubmitEvidenceRouterData =
pub type UploadFileRouterData = RouterData<api::Upload, UploadFileRequestData, UploadFileResponse>;
pub type DefendDisputeRouterData =
RouterData<api::Defend, DefendDisputeRequestData, DefendDisputeResponse>;
#[derive(Debug, Clone)]
pub struct RouterData<Flow, Request, Response> {
pub flow: PhantomData<Flow>,
@ -425,6 +434,10 @@ pub struct SubmitEvidenceRequestData {
pub shipping_documentation: Option<Vec<u8>>,
pub shipping_documentation_provider_file_id: Option<String>,
pub shipping_tracking_number: Option<String>,
pub invoice_showing_distinct_transactions: Option<Vec<u8>>,
pub invoice_showing_distinct_transactions_provider_file_id: Option<String>,
pub recurring_transaction_agreement: Option<Vec<u8>>,
pub recurring_transaction_agreement_provider_file_id: Option<String>,
pub uncategorized_file: Option<Vec<u8>>,
pub uncategorized_file_provider_file_id: Option<String>,
pub uncategorized_text: Option<String>,
@ -436,6 +449,18 @@ pub struct SubmitEvidenceResponse {
pub connector_status: Option<String>,
}
#[derive(Default, Debug, Clone)]
pub struct DefendDisputeRequestData {
pub dispute_id: String,
pub connector_dispute_id: String,
}
#[derive(Default, Debug, Clone)]
pub struct DefendDisputeResponse {
pub dispute_status: api_models::enums::DisputeStatus,
pub connector_status: Option<String>,
}
#[derive(Clone, Debug)]
pub struct UploadFileRequestData {
pub file_key: String,

View File

@ -45,4 +45,16 @@ pub trait SubmitEvidence:
{
}
pub trait Dispute: super::ConnectorCommon + AcceptDispute + SubmitEvidence {}
#[derive(Debug, Clone)]
pub struct Defend;
pub trait DefendDispute:
services::ConnectorIntegration<
Defend,
types::DefendDisputeRequestData,
types::DefendDisputeResponse,
>
{
}
pub trait Dispute: super::ConnectorCommon + AcceptDispute + SubmitEvidence + DefendDispute {}

View File

@ -12,13 +12,15 @@ pub struct FileId {
pub enum FileUploadProvider {
Router,
Stripe,
Checkout,
}
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),
match *item {
types::Connector::Stripe => Ok(Self::Stripe),
types::Connector::Checkout => Ok(Self::Checkout),
_ => Err(errors::ApiErrorResponse::NotSupported {
message: "Connector not supported as file provider".to_owned(),
}

View File

@ -817,4 +817,5 @@ pub enum FileUploadProvider {
#[default]
Router,
Stripe,
Checkout,
}