feat(router): add attach dispute evidence api (#1070)

Co-authored-by: Sanchith Hegde <22217505+SanchithHegde@users.noreply.github.com>
This commit is contained in:
Sai Harsha Vardhan
2023-05-13 14:59:11 +05:30
committed by GitHub
parent cc121d0feb
commit a5756aaecf
13 changed files with 350 additions and 16 deletions

View File

@ -1,5 +1,6 @@
use api_models::disputes as dispute_models;
use error_stack::ResultExt;
use api_models::{disputes as dispute_models, files as files_api_models};
use common_utils::ext_traits::ValueExt;
use error_stack::{IntoReport, ResultExt};
use router_env::{instrument, tracing};
pub mod transformers;
@ -8,7 +9,7 @@ use super::{
metrics,
};
use crate::{
core::{payments, utils},
core::{files, payments, utils as core_utils},
routes::AppState,
services,
types::{
@ -18,6 +19,7 @@ use crate::{
AcceptDisputeRequestData, AcceptDisputeResponse, DefendDisputeRequestData,
DefendDisputeResponse, SubmitEvidenceRequestData, SubmitEvidenceResponse,
},
utils,
};
#[instrument(skip(state))]
@ -111,7 +113,7 @@ pub async fn accept_dispute(
AcceptDisputeRequestData,
AcceptDisputeResponse,
> = connector_data.connector.get_connector_integration();
let router_data = utils::construct_accept_dispute_router_data(
let router_data = core_utils::construct_accept_dispute_router_data(
state,
&payment_intent,
&payment_attempt,
@ -150,7 +152,7 @@ pub async fn accept_dispute(
.await
.change_context(errors::ApiErrorResponse::InternalServerError)
.attach_printable_lazy(|| {
format!("Unable to update dispute with dispute_id: {}", dispute_id)
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))
@ -217,7 +219,7 @@ pub async fn submit_evidence(
SubmitEvidenceRequestData,
SubmitEvidenceResponse,
> = connector_data.connector.get_connector_integration();
let router_data = utils::construct_submit_evidence_router_data(
let router_data = core_utils::construct_submit_evidence_router_data(
state,
&payment_intent,
&payment_attempt,
@ -254,7 +256,7 @@ pub async fn submit_evidence(
DefendDisputeRequestData,
DefendDisputeResponse,
> = connector_data.connector.get_connector_integration();
let defend_dispute_router_data = utils::construct_defend_dispute_router_data(
let defend_dispute_router_data = core_utils::construct_defend_dispute_router_data(
state,
&payment_intent,
&payment_attempt,
@ -299,8 +301,80 @@ pub async fn submit_evidence(
.await
.change_context(errors::ApiErrorResponse::InternalServerError)
.attach_printable_lazy(|| {
format!("Unable to update dispute with dispute_id: {}", dispute_id)
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))
}
pub async fn attach_evidence(
state: &AppState,
merchant_account: storage::MerchantAccount,
attach_evidence_request: api::AttachEvidenceRequest,
) -> RouterResponse<files_api_models::CreateFileResponse> {
let db = &state.store;
let dispute_id = attach_evidence_request
.create_file_request
.dispute_id
.clone()
.ok_or(errors::ApiErrorResponse::MissingDisputeId)?;
let dispute = db
.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.clone(),
})?;
common_utils::fp_utils::when(
!(dispute.dispute_stage == storage_enums::DisputeStage::Dispute
&& dispute.dispute_status == storage_enums::DisputeStatus::DisputeOpened),
|| {
metrics::ATTACH_EVIDENCE_DISPUTE_STATUS_VALIDATION_FAILURE_METRIC.add(
&metrics::CONTEXT,
1,
&[],
);
Err(errors::ApiErrorResponse::DisputeStatusValidationFailed {
reason: format!(
"Evidence cannot be attached because the dispute is in {} stage and has {} status",
dispute.dispute_stage, dispute.dispute_status
),
})
},
)?;
let create_file_response = files::files_create_core(
state,
merchant_account,
attach_evidence_request.create_file_request,
)
.await?;
let file_id = match &create_file_response {
services::ApplicationResponse::Json(res) => res.file_id.clone(),
_ => Err(errors::ApiErrorResponse::InternalServerError)
.into_report()
.attach_printable("Unexpected response received from files create core")?,
};
let dispute_evidence: api::DisputeEvidence = dispute
.evidence
.clone()
.parse_value("DisputeEvidence")
.change_context(errors::ApiErrorResponse::InternalServerError)
.attach_printable("Error while parsing dispute evidence record")?;
let updated_dispute_evidence = transformers::update_dispute_evidence(
dispute_evidence,
attach_evidence_request.evidence_type,
file_id,
);
let update_dispute = storage_models::dispute::DisputeUpdate::EvidenceUpdate {
evidence: utils::Encode::<api::DisputeEvidence>::encode_to_value(&updated_dispute_evidence)
.change_context(errors::ApiErrorResponse::InternalServerError)
.attach_printable("Error while encoding dispute evidence")?
.into(),
};
db.update_dispute(dispute, update_dispute)
.await
.change_context(errors::ApiErrorResponse::InternalServerError)
.attach_printable_lazy(|| {
format!("Unable to update dispute with dispute_id: {dispute_id}")
})?;
Ok(create_file_response)
}

View File

@ -3,7 +3,10 @@ use common_utils::errors::CustomResult;
use crate::{
core::{errors, files::helpers::retrieve_file_and_provider_file_id_from_file_id},
routes::AppState,
types::{api, SubmitEvidenceRequestData},
types::{
api::{self, DisputeEvidence},
SubmitEvidenceRequestData,
},
};
pub async fn get_evidence_request_data(
@ -134,3 +137,52 @@ pub async fn get_evidence_request_data(
uncategorized_text: evidence_request.uncategorized_text,
})
}
pub fn update_dispute_evidence(
dispute_evidence: DisputeEvidence,
evidence_type: api::EvidenceType,
file_id: String,
) -> DisputeEvidence {
match evidence_type {
api::EvidenceType::CancellationPolicy => DisputeEvidence {
cancellation_policy: Some(file_id),
..dispute_evidence
},
api::EvidenceType::CustomerCommunication => DisputeEvidence {
customer_communication: Some(file_id),
..dispute_evidence
},
api::EvidenceType::CustomerSignature => DisputeEvidence {
customer_signature: Some(file_id),
..dispute_evidence
},
api::EvidenceType::Receipt => DisputeEvidence {
receipt: Some(file_id),
..dispute_evidence
},
api::EvidenceType::RefundPolicy => DisputeEvidence {
refund_policy: Some(file_id),
..dispute_evidence
},
api::EvidenceType::ServiceDocumentation => DisputeEvidence {
service_documentation: Some(file_id),
..dispute_evidence
},
api::EvidenceType::ShippingDocumentation => DisputeEvidence {
shipping_documentation: Some(file_id),
..dispute_evidence
},
api::EvidenceType::InvoiceShowingDistinctTransactions => DisputeEvidence {
invoice_showing_distinct_transactions: Some(file_id),
..dispute_evidence
},
api::EvidenceType::RecurringTransactionAgreement => DisputeEvidence {
recurring_transaction_agreement: Some(file_id),
..dispute_evidence
},
api::EvidenceType::UncategorizedFile => DisputeEvidence {
uncategorized_file: Some(file_id),
..dispute_evidence
},
}
}

View File

@ -21,8 +21,13 @@ counter_metric!(
counter_metric!(
ACCEPT_DISPUTE_STATUS_VALIDATION_FAILURE_METRIC,
GLOBAL_METER
); //No. of status validation fialures while accpeting a dispute
); //No. of status validation failures 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
); //No. of status validation failures while submitting evidence for a dispute
//No. of status validation failures while attaching evidence for a dispute
counter_metric!(
ATTACH_EVIDENCE_DISPUTE_STATUS_VALIDATION_FAILURE_METRIC,
GLOBAL_METER
);

View File

@ -258,6 +258,7 @@ async fn get_or_update_dispute_object(
challenge_required_by: dispute_details.challenge_required_by,
connector_created_at: dispute_details.created_at,
connector_updated_at: dispute_details.updated_at,
evidence: None,
};
state
.store

View File

@ -424,7 +424,11 @@ impl 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("/evidence")
.route(web::post().to(submit_dispute_evidence))
.route(web::put().to(attach_dispute_evidence)),
)
.service(web::resource("/{dispute_id}").route(web::get().to(retrieve_dispute)))
}
}

View File

@ -1,12 +1,14 @@
use actix_multipart::Multipart;
use actix_web::{web, HttpRequest, HttpResponse};
use api_models::disputes as dispute_models;
use router_env::{instrument, tracing, Flow};
pub mod utils;
use super::app::AppState;
use crate::{
core::disputes,
services::{api, authentication as auth},
types::api::disputes::{self as dispute_types},
types::api::disputes as dispute_types,
};
/// Diputes - Retrieve Dispute
@ -154,3 +156,42 @@ pub async fn submit_dispute_evidence(
)
.await
}
/// Disputes - Attach Evidence to Dispute
///
/// To attach an evidence file to dispute
#[utoipa::path(
put,
path = "/disputes/evidence",
request_body=MultipartRequestWithFile,
responses(
(status = 200, description = "Evidence attached to dispute", body = CreateFileResponse),
(status = 400, description = "Bad Request")
),
tag = "Disputes",
operation_id = "Attach Evidence to Dispute",
security(("api_key" = []))
)]
#[instrument(skip_all, fields(flow = ?Flow::AttachDisputeEvidence))]
pub async fn attach_dispute_evidence(
state: web::Data<AppState>,
req: HttpRequest,
payload: Multipart,
) -> HttpResponse {
let flow = Flow::AttachDisputeEvidence;
//Get attach_evidence_request from the multipart request
let attach_evidence_request_result = utils::get_attach_evidence_request(payload).await;
let attach_evidence_request = match attach_evidence_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,
attach_evidence_request,
disputes::attach_evidence,
auth::auth_type(&auth::ApiKeyAuth, &auth::JWTAuth, req.headers()),
)
.await
}

View File

@ -0,0 +1,102 @@
use actix_multipart::{Field, Multipart};
use actix_web::web::Bytes;
use common_utils::{errors::CustomResult, ext_traits::StringExt, fp_utils};
use error_stack::{IntoReport, ResultExt};
use futures::{StreamExt, TryStreamExt};
use crate::{
core::{errors, files::helpers},
types::api::{disputes, files},
utils::OptionExt,
};
pub async fn parse_evidence_type(
field: &mut Field,
) -> CustomResult<Option<disputes::EvidenceType>, errors::ApiErrorResponse> {
let purpose = helpers::read_string(field).await;
match purpose {
Some(evidence_type) => Ok(Some(
evidence_type
.parse_enum("Evidence Type")
.change_context(errors::ApiErrorResponse::InternalServerError)
.attach_printable("Error parsing evidence type")?,
)),
_ => Ok(None),
}
}
pub async fn get_attach_evidence_request(
mut payload: Multipart,
) -> CustomResult<disputes::AttachEvidenceRequest, errors::ApiErrorResponse> {
let mut option_evidence_type: Option<disputes::EvidenceType> = 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("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_lazy(|| format!("File parsing error: {err}"))?,
}
}
file_content = Some(file_data)
}
Some("dispute_id") => {
dispute_id = helpers::read_string(&mut field).await;
}
Some("evidence_type") => {
option_evidence_type = parse_evidence_type(&mut field).await?;
}
// Can ignore other params
_ => (),
}
}
let evidence_type = option_evidence_type.get_required_value("evidence_type")?;
let file = file_content.get_required_value("file")?.concat().to_vec();
//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
fp_utils::when(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")?;
let create_file_request = files::CreateFileRequest {
file,
file_name,
file_size,
file_type,
purpose: files::FilePurpose::DisputeEvidence,
dispute_id,
};
Ok(disputes::AttachEvidenceRequest {
evidence_type,
create_file_request,
})
}

View File

@ -22,6 +22,42 @@ pub struct DisputePayload {
pub updated_at: Option<PrimitiveDateTime>,
}
#[derive(Default, Debug, Deserialize, Serialize)]
pub struct DisputeEvidence {
pub cancellation_policy: Option<String>,
pub customer_communication: Option<String>,
pub customer_signature: Option<String>,
pub receipt: Option<String>,
pub refund_policy: Option<String>,
pub service_documentation: Option<String>,
pub shipping_documentation: Option<String>,
pub invoice_showing_distinct_transactions: Option<String>,
pub recurring_transaction_agreement: Option<String>,
pub uncategorized_file: Option<String>,
}
#[derive(Debug, Clone)]
pub struct AttachEvidenceRequest {
pub create_file_request: types::api::CreateFileRequest,
pub evidence_type: EvidenceType,
}
#[derive(Debug, serde::Deserialize, strum::Display, strum::EnumString, Clone)]
#[serde(rename_all = "snake_case")]
#[strum(serialize_all = "snake_case")]
pub enum EvidenceType {
CancellationPolicy,
CustomerCommunication,
CustomerSignature,
Receipt,
RefundPolicy,
ServiceDocumentation,
ShippingDocumentation,
InvoiceShowingDistinctTransactions,
RecurringTransactionAgreement,
UncategorizedFile,
}
#[derive(Debug, Clone)]
pub struct Accept;

View File

@ -176,6 +176,8 @@ pub enum Flow {
RetrieveFile,
/// Dispute Evidence submission flow
DisputesEvidenceSubmit,
/// Attach Dispute Evidence flow
AttachDisputeEvidence,
}
///

View File

@ -1,5 +1,6 @@
use common_utils::custom_serde;
use diesel::{AsChangeset, Identifiable, Insertable, Queryable};
use masking::Secret;
use serde::Serialize;
use time::PrimitiveDateTime;
@ -25,6 +26,7 @@ pub struct DisputeNew {
pub connector_created_at: Option<PrimitiveDateTime>,
pub connector_updated_at: Option<PrimitiveDateTime>,
pub connector: String,
pub evidence: Option<Secret<serde_json::Value>>,
}
#[derive(Clone, Debug, Serialize, Identifiable, Queryable)]
@ -52,6 +54,7 @@ pub struct Dispute {
#[serde(with = "custom_serde::iso8601")]
pub modified_at: PrimitiveDateTime,
pub connector: String,
pub evidence: Secret<serde_json::Value>,
}
#[derive(Debug)]
@ -69,19 +72,23 @@ pub enum DisputeUpdate {
dispute_status: storage_enums::DisputeStatus,
connector_status: Option<String>,
},
EvidenceUpdate {
evidence: Secret<serde_json::Value>,
},
}
#[derive(Clone, Debug, Default, AsChangeset, router_derive::DebugAsDisplay)]
#[diesel(table_name = dispute)]
pub struct DisputeUpdateInternal {
dispute_stage: Option<storage_enums::DisputeStage>,
dispute_status: storage_enums::DisputeStatus,
dispute_status: Option<storage_enums::DisputeStatus>,
connector_status: Option<String>,
connector_reason: Option<String>,
connector_reason_code: Option<String>,
challenge_required_by: Option<PrimitiveDateTime>,
connector_updated_at: Option<PrimitiveDateTime>,
modified_at: Option<PrimitiveDateTime>,
evidence: Option<Secret<serde_json::Value>>,
}
impl From<DisputeUpdate> for DisputeUpdateInternal {
@ -97,23 +104,28 @@ impl From<DisputeUpdate> for DisputeUpdateInternal {
connector_updated_at,
} => Self {
dispute_stage: Some(dispute_stage),
dispute_status,
dispute_status: Some(dispute_status),
connector_status: Some(connector_status),
connector_reason,
connector_reason_code,
challenge_required_by,
connector_updated_at,
modified_at: Some(common_utils::date_time::now()),
..Default::default()
},
DisputeUpdate::StatusUpdate {
dispute_status,
connector_status,
} => Self {
dispute_status,
dispute_status: Some(dispute_status),
connector_status,
modified_at: Some(common_utils::date_time::now()),
..Default::default()
},
DisputeUpdate::EvidenceUpdate { evidence } => Self {
evidence: Some(evidence),
..Default::default()
},
}
}
}

View File

@ -135,6 +135,7 @@ diesel::table! {
created_at -> Timestamp,
modified_at -> Timestamp,
connector -> Varchar,
evidence -> Jsonb,
}
}

View File

@ -0,0 +1 @@
ALTER TABLE dispute DROP COLUMN evidence;

View File

@ -0,0 +1,3 @@
-- Your SQL goes here
ALTER TABLE dispute
ADD COLUMN evidence JSONB NOT NULL DEFAULT '{}'::JSONB;