feat(router): added incoming dispute webhooks flow (#769)

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:
saiharsha-juspay
2023-03-30 04:25:54 +05:30
committed by GitHub
parent fb66a0e0f2
commit a733eafbbe
28 changed files with 1139 additions and 32 deletions

View File

@ -1 +1,38 @@
use masking::Serialize;
use utoipa::ToSchema;
use super::enums::{DisputeStage, DisputeStatus};
#[derive(Default, Clone, Debug, Serialize, ToSchema)]
pub struct DisputeResponse {
/// The identifier for dispute
pub dispute_id: String,
/// The identifier for payment_intent
pub payment_id: String,
/// The identifier for payment_attempt
pub attempt_id: String,
/// The dispute amount
pub amount: String,
/// The three-letter ISO currency code
pub currency: String,
/// Stage of the dispute
pub dispute_stage: DisputeStage,
/// Status of the dispute
pub dispute_status: DisputeStatus,
/// Status of the dispute sent by connector
pub connector_status: String,
/// Dispute id sent by connector
pub connector_dispute_id: String,
/// Reason of dispute sent by connector
pub connector_reason: Option<String>,
/// Reason code of dispute sent by connector
pub connector_reason_code: Option<String>,
/// Evidence deadline of dispute sent by connector
pub challenge_required_by: Option<String>,
/// Dispute created time sent by connector
pub created_at: Option<String>,
/// Dispute updated time sent by connector
pub updated_at: Option<String>,
/// Time at which dispute is received
pub received_at: String,
}

View File

@ -267,6 +267,13 @@ pub enum EventType {
PaymentSucceeded,
RefundSucceeded,
RefundFailed,
DisputeOpened,
DisputeExpired,
DisputeAccepted,
DisputeCancelled,
DisputeChallenged,
DisputeWon,
DisputeLost,
}
#[derive(
@ -767,3 +774,51 @@ impl From<AttemptStatus> for IntentStatus {
}
}
}
#[derive(
Clone,
Default,
Debug,
Eq,
Hash,
PartialEq,
serde::Deserialize,
serde::Serialize,
strum::Display,
strum::EnumString,
frunk::LabelledGeneric,
ToSchema,
)]
pub enum DisputeStage {
PreDispute,
#[default]
Dispute,
PreArbitration,
}
#[derive(
Clone,
Debug,
Default,
Eq,
Hash,
PartialEq,
serde::Deserialize,
serde::Serialize,
strum::Display,
strum::EnumString,
frunk::LabelledGeneric,
ToSchema,
)]
pub enum DisputeStatus {
#[default]
DisputeOpened,
DisputeExpired,
DisputeAccepted,
DisputeCancelled,
DisputeChallenged,
// dispute has been successfully challenged by the merchant
DisputeWon,
// dispute has been unsuccessfully challenged
DisputeLost,
}

View File

@ -2,7 +2,7 @@ use common_utils::custom_serde;
use serde::{Deserialize, Serialize};
use time::PrimitiveDateTime;
use crate::{enums as api_enums, payments, refunds};
use crate::{disputes, enums as api_enums, payments, refunds};
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
@ -11,12 +11,22 @@ pub enum IncomingWebhookEvent {
PaymentIntentSuccess,
RefundFailure,
RefundSuccess,
DisputeOpened,
DisputeExpired,
DisputeAccepted,
DisputeCancelled,
DisputeChallenged,
// dispute has been successfully challenged by the merchant
DisputeWon,
// dispute has been unsuccessfully challenged
DisputeLost,
EndpointVerification,
}
pub enum WebhookFlow {
Payment,
Refund,
Dispute,
Subscription,
ReturnResponse,
}
@ -28,6 +38,13 @@ impl From<IncomingWebhookEvent> for WebhookFlow {
IncomingWebhookEvent::PaymentIntentSuccess => Self::Payment,
IncomingWebhookEvent::RefundSuccess => Self::Refund,
IncomingWebhookEvent::RefundFailure => Self::Refund,
IncomingWebhookEvent::DisputeOpened => Self::Dispute,
IncomingWebhookEvent::DisputeAccepted => Self::Dispute,
IncomingWebhookEvent::DisputeExpired => Self::Dispute,
IncomingWebhookEvent::DisputeCancelled => Self::Dispute,
IncomingWebhookEvent::DisputeChallenged => Self::Dispute,
IncomingWebhookEvent::DisputeWon => Self::Dispute,
IncomingWebhookEvent::DisputeLost => Self::Dispute,
IncomingWebhookEvent::EndpointVerification => Self::ReturnResponse,
}
}
@ -73,6 +90,7 @@ pub struct OutgoingWebhook {
pub enum OutgoingWebhookContent {
PaymentDetails(payments::PaymentsResponse),
RefundDetails(refunds::RefundResponse),
DisputeDetails(Box<disputes::DisputeResponse>),
}
pub trait OutgoingWebhookType: Serialize + From<OutgoingWebhook> + Sync + Send {}

View File

@ -1,4 +1,7 @@
use api_models::webhooks::{self as api};
use api_models::{
enums::DisputeStatus,
webhooks::{self as api},
};
use serde::Serialize;
use super::{
@ -20,6 +23,57 @@ impl api::OutgoingWebhookType for StripeOutgoingWebhook {}
pub enum StripeWebhookObject {
PaymentIntent(StripePaymentIntentResponse),
Refund(StripeRefundResponse),
Dispute(StripeDisputeResponse),
}
#[derive(Serialize)]
pub struct StripeDisputeResponse {
pub id: String,
pub amount: String,
pub currency: String,
pub payment_intent: String,
pub reason: Option<String>,
pub status: StripeDisputeStatus,
}
#[derive(Serialize)]
#[serde(rename_all = "snake_case")]
pub enum StripeDisputeStatus {
WarningNeedsResponse,
WarningUnderReview,
WarningClosed,
NeedsResponse,
UnderReview,
ChargeRefunded,
Won,
Lost,
}
impl From<api_models::disputes::DisputeResponse> for StripeDisputeResponse {
fn from(res: api_models::disputes::DisputeResponse) -> Self {
Self {
id: res.dispute_id,
amount: res.amount,
currency: res.currency,
payment_intent: res.payment_id,
reason: res.connector_reason,
status: StripeDisputeStatus::from(res.dispute_status),
}
}
}
impl From<DisputeStatus> for StripeDisputeStatus {
fn from(status: DisputeStatus) -> Self {
match status {
DisputeStatus::DisputeOpened => Self::WarningNeedsResponse,
DisputeStatus::DisputeExpired => Self::Lost,
DisputeStatus::DisputeAccepted => Self::Lost,
DisputeStatus::DisputeCancelled => Self::WarningClosed,
DisputeStatus::DisputeChallenged => Self::WarningUnderReview,
DisputeStatus::DisputeWon => Self::Won,
DisputeStatus::DisputeLost => Self::Lost,
}
}
}
impl From<api::OutgoingWebhook> for StripeOutgoingWebhook {
@ -40,6 +94,9 @@ impl From<api::OutgoingWebhookContent> for StripeWebhookObject {
Self::PaymentIntent(payment.into())
}
api::OutgoingWebhookContent::RefundDetails(refund) => Self::Refund(refund.into()),
api::OutgoingWebhookContent::DisputeDetails(dispute) => {
Self::Dispute((*dispute).into())
}
}
}
}
@ -49,6 +106,7 @@ impl StripeWebhookObject {
match self {
Self::PaymentIntent(p) => p.id.to_owned(),
Self::Refund(r) => Some(r.id.to_owned()),
Self::Dispute(d) => Some(d.id.to_owned()),
}
}
}

View File

@ -2,6 +2,7 @@ mod transformers;
use std::fmt::Debug;
use api_models::webhooks::IncomingWebhookEvent;
use base64::Engine;
use error_stack::{IntoReport, ResultExt};
use router_env::{instrument, tracing};
@ -17,6 +18,7 @@ use crate::{
types::{
self,
api::{self, ConnectorCommon},
transformers::ForeignFrom,
},
utils::{self, crypto, ByteSliceExt, BytesExt, OptionExt},
};
@ -719,27 +721,38 @@ impl api::IncomingWebhook for Adyen {
) -> CustomResult<api_models::webhooks::ObjectReferenceId, errors::ConnectorError> {
let notif = get_webhook_object_from_body(request.body)
.change_context(errors::ConnectorError::WebhookReferenceIdNotFound)?;
match notif.event_code {
adyen::WebhookEventCode::Authorisation => {
Ok(api_models::webhooks::ObjectReferenceId::PaymentId(
api_models::payments::PaymentIdType::ConnectorTransactionId(
notif.psp_reference,
),
))
if adyen::is_transaction_event(&notif.event_code) {
return Ok(api_models::webhooks::ObjectReferenceId::PaymentId(
api_models::payments::PaymentIdType::ConnectorTransactionId(notif.psp_reference),
));
}
_ => Ok(api_models::webhooks::ObjectReferenceId::RefundId(
if adyen::is_refund_event(&notif.event_code) {
return Ok(api_models::webhooks::ObjectReferenceId::RefundId(
api_models::webhooks::RefundIdType::ConnectorRefundId(notif.psp_reference),
)),
));
}
if adyen::is_chargeback_event(&notif.event_code) {
return Ok(api_models::webhooks::ObjectReferenceId::PaymentId(
api_models::payments::PaymentIdType::ConnectorTransactionId(
notif
.original_reference
.ok_or(errors::ConnectorError::WebhookReferenceIdNotFound)?,
),
));
}
Err(errors::ConnectorError::WebhookReferenceIdNotFound).into_report()
}
fn get_webhook_event_type(
&self,
request: &api::IncomingWebhookRequestDetails<'_>,
) -> CustomResult<api::IncomingWebhookEvent, errors::ConnectorError> {
) -> CustomResult<IncomingWebhookEvent, errors::ConnectorError> {
let notif = get_webhook_object_from_body(request.body)
.change_context(errors::ConnectorError::WebhookEventTypeNotFound)?;
Ok(notif.event_code.into())
Ok(IncomingWebhookEvent::foreign_from((
notif.event_code,
notif.additional_data.dispute_status,
)))
}
fn get_webhook_resource_object(
@ -767,4 +780,24 @@ impl api::IncomingWebhook for Adyen {
"[accepted]".to_string(),
))
}
fn get_dispute_details(
&self,
request: &api_models::webhooks::IncomingWebhookRequestDetails<'_>,
) -> CustomResult<api::disputes::DisputePayload, errors::ConnectorError> {
let notif = get_webhook_object_from_body(request.body)
.change_context(errors::ConnectorError::WebhookBodyDecodingFailed)?;
Ok(api::disputes::DisputePayload {
amount: notif.amount.value.to_string(),
currency: notif.amount.currency,
dispute_stage: api_models::enums::DisputeStage::from(notif.event_code.clone()),
connector_dispute_id: notif.psp_reference,
connector_reason: notif.reason,
connector_reason_code: notif.additional_data.chargeback_reason_code,
challenge_required_by: notif.additional_data.defense_period_ends_at,
connector_status: notif.event_code.to_string(),
created_at: notif.event_date.clone(),
updated_at: notif.event_date,
})
}
}

View File

@ -1,4 +1,4 @@
use api_models::webhooks::IncomingWebhookEvent;
use api_models::{enums::DisputeStage, webhooks::IncomingWebhookEvent};
use masking::PeekInterface;
use reqwest::Url;
use serde::{Deserialize, Serialize};
@ -13,6 +13,7 @@ use crate::{
self,
api::{self, enums as api_enums},
storage::enums as storage_enums,
transformers::ForeignFrom,
},
};
@ -1148,10 +1149,22 @@ pub struct ErrorResponse {
// }
// }
#[derive(Debug, Deserialize)]
pub enum DisputeStatus {
Undefended,
Pending,
Lost,
Accepted,
Won,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AdyenAdditionalDataWH {
pub hmac_signature: String,
pub dispute_status: Option<DisputeStatus>,
pub chargeback_reason_code: Option<String>,
pub defense_period_ends_at: Option<String>,
}
#[derive(Debug, Deserialize)]
@ -1160,7 +1173,7 @@ pub struct AdyenAmountWH {
pub currency: String,
}
#[derive(Debug, Deserialize, strum::Display)]
#[derive(Clone, Debug, Deserialize, strum::Display)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
#[strum(serialize_all = "SCREAMING_SNAKE_CASE")]
pub enum WebhookEventCode {
@ -1168,15 +1181,73 @@ pub enum WebhookEventCode {
Refund,
CancelOrRefund,
RefundFailed,
NotificationOfChargeback,
Chargeback,
ChargebackReversed,
SecondChargeback,
PrearbitrationWon,
PrearbitrationLost,
}
impl From<WebhookEventCode> for IncomingWebhookEvent {
pub fn is_transaction_event(event_code: &WebhookEventCode) -> bool {
matches!(event_code, WebhookEventCode::Authorisation)
}
pub fn is_refund_event(event_code: &WebhookEventCode) -> bool {
matches!(
event_code,
WebhookEventCode::Refund
| WebhookEventCode::CancelOrRefund
| WebhookEventCode::RefundFailed
)
}
pub fn is_chargeback_event(event_code: &WebhookEventCode) -> bool {
matches!(
event_code,
WebhookEventCode::NotificationOfChargeback
| WebhookEventCode::Chargeback
| WebhookEventCode::ChargebackReversed
| WebhookEventCode::SecondChargeback
| WebhookEventCode::PrearbitrationWon
| WebhookEventCode::PrearbitrationLost
)
}
impl ForeignFrom<(WebhookEventCode, Option<DisputeStatus>)> for IncomingWebhookEvent {
fn foreign_from((code, status): (WebhookEventCode, Option<DisputeStatus>)) -> Self {
match (code, status) {
(WebhookEventCode::Authorisation, _) => Self::PaymentIntentSuccess,
(WebhookEventCode::Refund, _) => Self::RefundSuccess,
(WebhookEventCode::CancelOrRefund, _) => Self::RefundSuccess,
(WebhookEventCode::RefundFailed, _) => Self::RefundFailure,
(WebhookEventCode::NotificationOfChargeback, _) => Self::DisputeOpened,
(WebhookEventCode::Chargeback, None) => Self::DisputeLost,
(WebhookEventCode::Chargeback, Some(DisputeStatus::Won)) => Self::DisputeWon,
(WebhookEventCode::Chargeback, Some(DisputeStatus::Lost)) => Self::DisputeLost,
(WebhookEventCode::Chargeback, Some(_)) => Self::DisputeOpened,
(WebhookEventCode::ChargebackReversed, Some(DisputeStatus::Pending)) => {
Self::DisputeChallenged
}
(WebhookEventCode::ChargebackReversed, _) => Self::DisputeWon,
(WebhookEventCode::SecondChargeback, _) => Self::DisputeLost,
(WebhookEventCode::PrearbitrationWon, Some(DisputeStatus::Pending)) => {
Self::DisputeOpened
}
(WebhookEventCode::PrearbitrationWon, _) => Self::DisputeWon,
(WebhookEventCode::PrearbitrationLost, _) => Self::DisputeLost,
}
}
}
impl From<WebhookEventCode> for DisputeStage {
fn from(code: WebhookEventCode) -> Self {
match code {
WebhookEventCode::Authorisation => Self::PaymentIntentSuccess,
WebhookEventCode::Refund => Self::RefundSuccess,
WebhookEventCode::CancelOrRefund => Self::RefundSuccess,
WebhookEventCode::RefundFailed => Self::RefundFailure,
WebhookEventCode::NotificationOfChargeback => Self::PreDispute,
WebhookEventCode::SecondChargeback => Self::PreArbitration,
WebhookEventCode::PrearbitrationWon => Self::PreArbitration,
WebhookEventCode::PrearbitrationLost => Self::PreArbitration,
_ => Self::Dispute,
}
}
}
@ -1192,6 +1263,8 @@ pub struct AdyenNotificationRequestItemWH {
pub merchant_account_code: String,
pub merchant_reference: String,
pub success: String,
pub reason: Option<String>,
pub event_date: Option<String>,
}
#[derive(Debug, Deserialize)]

View File

@ -598,7 +598,7 @@ impl api::IncomingWebhook for Trustpay {
match details.payment_information.credit_debit_indicator {
trustpay::CreditDebitIndicator::Crdt => {
Ok(api_models::webhooks::ObjectReferenceId::PaymentId(
api_models::payments::PaymentIdType::PaymentIntentId(
api_models::payments::PaymentIdType::PaymentAttemptId(
details.payment_information.references.merchant_reference,
),
))
@ -606,7 +606,7 @@ impl api::IncomingWebhook for Trustpay {
trustpay::CreditDebitIndicator::Dbit => {
if details.payment_information.status == trustpay::WebhookStatus::Chargebacked {
Ok(api_models::webhooks::ObjectReferenceId::PaymentId(
api_models::payments::PaymentIdType::PaymentIntentId(
api_models::payments::PaymentIdType::PaymentAttemptId(
details.payment_information.references.merchant_reference,
),
))
@ -648,6 +648,9 @@ impl api::IncomingWebhook for Trustpay {
(trustpay::CreditDebitIndicator::Dbit, trustpay::WebhookStatus::Rejected) => {
Ok(api_models::webhooks::IncomingWebhookEvent::RefundFailure)
}
(trustpay::CreditDebitIndicator::Dbit, trustpay::WebhookStatus::Chargebacked) => {
Ok(api_models::webhooks::IncomingWebhookEvent::DisputeLost)
}
_ => Err(errors::ConnectorError::WebhookEventTypeNotFound).into_report()?,
}
}
@ -716,6 +719,30 @@ impl api::IncomingWebhook for Trustpay {
.change_context(errors::ConnectorError::WebhookVerificationSecretNotFound)?;
Ok(secret)
}
fn get_dispute_details(
&self,
request: &api_models::webhooks::IncomingWebhookRequestDetails<'_>,
) -> CustomResult<api::disputes::DisputePayload, errors::ConnectorError> {
let trustpay_response: trustpay::TrustpayWebhookResponse = request
.body
.parse_struct("TrustpayWebhookResponse")
.switch()?;
let payment_info = trustpay_response.payment_information;
let reason = payment_info.status_reason_information.unwrap_or_default();
Ok(api::disputes::DisputePayload {
amount: payment_info.amount.amount.to_string(),
currency: payment_info.amount.currency,
dispute_stage: api_models::enums::DisputeStage::Dispute,
connector_dispute_id: payment_info.references.payment_id,
connector_reason: reason.reason.reject_reason,
connector_reason_code: Some(reason.reason.code),
challenge_required_by: None,
connector_status: payment_info.status.to_string(),
created_at: None,
updated_at: None,
})
}
}
impl services::ConnectorRedirectResponse for Trustpay {

View File

@ -219,7 +219,7 @@ fn get_card_request_data(
cvv: ccard.card_cvc.clone(),
expiry_date: ccard.get_card_expiry_month_year_2_digit_with_delimiter("/".to_owned()),
cardholder: ccard.card_holder_name.clone(),
reference: item.payment_id.clone(),
reference: item.attempt_id.clone(),
redirect_url: return_url,
billing_city: params.billing_city,
billing_country: params.billing_country,
@ -260,7 +260,7 @@ fn get_bank_redirection_request_data(
currency: item.request.currency.to_string(),
},
references: References {
merchant_reference: item.payment_id.clone(),
merchant_reference: item.attempt_id.clone(),
},
},
callback_urls: CallbackURLs {
@ -1115,12 +1115,11 @@ pub enum CreditDebitIndicator {
Dbit,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[derive(strum::Display, Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum WebhookStatus {
Paid,
Rejected,
Refunded,
// TODO (Handle Chargebacks)
Chargebacked,
}
@ -1151,15 +1150,25 @@ impl TryFrom<WebhookStatus> for storage_models::enums::RefundStatus {
#[serde(rename_all = "PascalCase")]
pub struct WebhookReferences {
pub merchant_reference: String,
pub payment_id: String,
pub payment_request_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "PascalCase")]
pub struct WebhookAmount {
pub amount: f64,
pub currency: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "PascalCase")]
pub struct WebhookPaymentInformation {
pub credit_debit_indicator: CreditDebitIndicator,
pub references: WebhookReferences,
pub status: WebhookStatus,
pub amount: WebhookAmount,
pub status_reason_information: Option<StatusReasonInformation>,
}
#[derive(Debug, Serialize, Deserialize, PartialEq)]

View File

@ -5,6 +5,7 @@ pub mod configs;
pub mod customers;
pub mod errors;
pub mod mandate;
pub mod metrics;
pub mod payment_methods;
pub mod payments;
pub mod refunds;

View File

@ -428,6 +428,8 @@ pub enum WebhooksFlowError {
PaymentsCoreFailed,
#[error("Refunds core flow failed")]
RefundsCoreFailed,
#[error("Dispuste core flow failed")]
DisputeCoreFailed,
#[error("Webhook event creation failed")]
WebhookEventCreationFailed,
#[error("Unable to fork webhooks flow for outgoing webhooks")]
@ -438,4 +440,12 @@ pub enum WebhooksFlowError {
NotReceivedByMerchant,
#[error("Resource not found")]
ResourceNotFound,
#[error("Webhook source verification failed")]
WebhookSourceVerificationFailed,
#[error("Webhook event object creation failed")]
WebhookEventObjectCreationFailed,
#[error("Not implemented")]
NotImplemented,
#[error("Dispute webhook status validation failed")]
DisputeWebhookValidationFailed,
}

View File

@ -0,0 +1,20 @@
use router_env::{counter_metric, global_meter, metrics_context};
metrics_context!(CONTEXT);
global_meter!(GLOBAL_METER, "ROUTER_API");
counter_metric!(INCOMING_DISPUTE_WEBHOOK_METRIC, GLOBAL_METER); // No. of incoming dispute webhooks
counter_metric!(
INCOMING_DISPUTE_WEBHOOK_SIGNATURE_FAILURE_METRIC,
GLOBAL_METER
); // No. of incoming dispute webhooks for which signature verification failed
counter_metric!(
INCOMING_DISPUTE_WEBHOOK_VALIDATION_FAILURE_METRIC,
GLOBAL_METER
); // No. of incoming dispute webhooks for which validation failed
counter_metric!(INCOMING_DISPUTE_WEBHOOK_NEW_RECORD_METRIC, GLOBAL_METER); // No. of incoming dispute webhooks for which new record is created in our db
counter_metric!(INCOMING_DISPUTE_WEBHOOK_UPDATE_RECORD_METRIC, GLOBAL_METER); // No. of incoming dispute webhooks for which we have updated the details to existing record
counter_metric!(
INCOMING_DISPUTE_WEBHOOK_MERCHANT_NOTIFIED_METRIC,
GLOBAL_METER
); // No. of incoming dispute webhooks which are notified to merchant

View File

@ -1,5 +1,7 @@
use std::marker::PhantomData;
use api_models::enums::{DisputeStage, DisputeStatus};
use common_utils::errors::CustomResult;
use error_stack::ResultExt;
use router_env::{instrument, tracing};
@ -149,3 +151,67 @@ mod tests {
assert_eq!(generated_id.len(), consts::ID_LENGTH + 4)
}
}
// Dispute Stage can move linearly from PreDispute -> Dispute -> PreArbitration
pub fn validate_dispute_stage(
prev_dispute_stage: &DisputeStage,
dispute_stage: &DisputeStage,
) -> bool {
match prev_dispute_stage {
DisputeStage::PreDispute => true,
DisputeStage::Dispute => !matches!(dispute_stage, DisputeStage::PreDispute),
DisputeStage::PreArbitration => matches!(dispute_stage, DisputeStage::PreArbitration),
}
}
//Dispute status can go from Opened -> (Expired | Accepted | Cancelled | Challenged -> (Won | Lost))
pub fn validate_dispute_status(
prev_dispute_status: DisputeStatus,
dispute_status: DisputeStatus,
) -> bool {
match prev_dispute_status {
DisputeStatus::DisputeOpened => true,
DisputeStatus::DisputeExpired => {
matches!(dispute_status, DisputeStatus::DisputeExpired)
}
DisputeStatus::DisputeAccepted => {
matches!(dispute_status, DisputeStatus::DisputeAccepted)
}
DisputeStatus::DisputeCancelled => {
matches!(dispute_status, DisputeStatus::DisputeCancelled)
}
DisputeStatus::DisputeChallenged => matches!(
dispute_status,
DisputeStatus::DisputeChallenged
| DisputeStatus::DisputeWon
| DisputeStatus::DisputeLost
),
DisputeStatus::DisputeWon => matches!(dispute_status, DisputeStatus::DisputeWon),
DisputeStatus::DisputeLost => matches!(dispute_status, DisputeStatus::DisputeLost),
}
}
pub fn validate_dispute_stage_and_dispute_status(
prev_dispute_stage: DisputeStage,
prev_dispute_status: DisputeStatus,
dispute_stage: DisputeStage,
dispute_status: DisputeStatus,
) -> CustomResult<(), errors::WebhooksFlowError> {
let dispute_stage_validation = validate_dispute_stage(&prev_dispute_stage, &dispute_stage);
let dispute_status_validation = if dispute_stage == prev_dispute_stage {
validate_dispute_status(prev_dispute_status, dispute_status)
} else {
true
};
common_utils::fp_utils::when(
!(dispute_stage_validation && dispute_status_validation),
|| {
super::metrics::INCOMING_DISPUTE_WEBHOOK_VALIDATION_FAILURE_METRIC.add(
&super::metrics::CONTEXT,
1,
&[],
);
Err(errors::WebhooksFlowError::DisputeWebhookValidationFailed)?
},
)
}

View File

@ -7,6 +7,7 @@ use error_stack::{IntoReport, ResultExt};
use masking::ExposeInterface;
use router_env::{instrument, tracing};
use super::metrics;
use crate::{
consts,
core::{
@ -197,6 +198,173 @@ async fn refunds_incoming_webhook_flow<W: api::OutgoingWebhookType>(
Ok(())
}
async fn get_payment_attempt_from_object_reference_id(
state: AppState,
object_reference_id: api_models::webhooks::ObjectReferenceId,
merchant_account: &storage::MerchantAccount,
) -> CustomResult<storage_models::payment_attempt::PaymentAttempt, errors::WebhooksFlowError> {
let db = &*state.store;
match object_reference_id {
api::ObjectReferenceId::PaymentId(api::PaymentIdType::ConnectorTransactionId(ref id)) => db
.find_payment_attempt_by_merchant_id_connector_txn_id(
&merchant_account.merchant_id,
id,
merchant_account.storage_scheme,
)
.await
.change_context(errors::WebhooksFlowError::ResourceNotFound),
api::ObjectReferenceId::PaymentId(api::PaymentIdType::PaymentAttemptId(ref id)) => db
.find_payment_attempt_by_merchant_id_attempt_id(
&merchant_account.merchant_id,
id,
merchant_account.storage_scheme,
)
.await
.change_context(errors::WebhooksFlowError::ResourceNotFound),
_ => Err(errors::WebhooksFlowError::ResourceNotFound).into_report(),
}
}
async fn get_or_update_dispute_object(
state: AppState,
option_dispute: Option<storage_models::dispute::Dispute>,
dispute_details: api::disputes::DisputePayload,
merchant_id: &str,
payment_id: &str,
attempt_id: &str,
event_type: api_models::webhooks::IncomingWebhookEvent,
) -> CustomResult<storage_models::dispute::Dispute, errors::WebhooksFlowError> {
let db = &*state.store;
match option_dispute {
None => {
metrics::INCOMING_DISPUTE_WEBHOOK_NEW_RECORD_METRIC.add(&metrics::CONTEXT, 1, &[]);
let dispute_id = generate_id(consts::ID_LENGTH, "dp");
let new_dispute = storage_models::dispute::DisputeNew {
dispute_id,
amount: dispute_details.amount,
currency: dispute_details.currency,
dispute_stage: dispute_details.dispute_stage.foreign_into(),
dispute_status: event_type
.foreign_try_into()
.into_report()
.change_context(errors::WebhooksFlowError::DisputeCoreFailed)?,
payment_id: payment_id.to_owned(),
attempt_id: attempt_id.to_owned(),
merchant_id: merchant_id.to_owned(),
connector_status: dispute_details.connector_status,
connector_dispute_id: dispute_details.connector_dispute_id,
connector_reason: dispute_details.connector_reason,
connector_reason_code: dispute_details.connector_reason_code,
challenge_required_by: dispute_details.challenge_required_by,
dispute_created_at: dispute_details.created_at,
updated_at: dispute_details.updated_at,
};
state
.store
.insert_dispute(new_dispute.clone())
.await
.change_context(errors::WebhooksFlowError::WebhookEventCreationFailed)
}
Some(dispute) => {
logger::info!("Dispute Already exists, Updating the dispute details");
metrics::INCOMING_DISPUTE_WEBHOOK_UPDATE_RECORD_METRIC.add(&metrics::CONTEXT, 1, &[]);
let dispute_status: storage_models::enums::DisputeStatus = event_type
.foreign_try_into()
.into_report()
.change_context(errors::WebhooksFlowError::DisputeCoreFailed)?;
crate::core::utils::validate_dispute_stage_and_dispute_status(
dispute.dispute_stage.foreign_into(),
dispute.dispute_status.foreign_into(),
dispute_details.dispute_stage.clone(),
dispute_status.foreign_into(),
)?;
let update_dispute = storage_models::dispute::DisputeUpdate::Update {
dispute_stage: dispute_details.dispute_stage.foreign_into(),
dispute_status,
connector_status: dispute_details.connector_status,
connector_reason: dispute_details.connector_reason,
connector_reason_code: dispute_details.connector_reason_code,
challenge_required_by: dispute_details.challenge_required_by,
updated_at: dispute_details.updated_at,
};
db.update_dispute(dispute, update_dispute)
.await
.change_context(errors::WebhooksFlowError::ResourceNotFound)
}
}
}
#[instrument(skip_all)]
async fn disputes_incoming_webhook_flow<W: api::OutgoingWebhookType>(
state: AppState,
merchant_account: storage::MerchantAccount,
webhook_details: api::IncomingWebhookDetails,
source_verified: bool,
connector: &(dyn api::Connector + Sync),
request_details: &api_models::webhooks::IncomingWebhookRequestDetails<'_>,
event_type: api_models::webhooks::IncomingWebhookEvent,
) -> CustomResult<(), errors::WebhooksFlowError> {
metrics::INCOMING_DISPUTE_WEBHOOK_METRIC.add(&metrics::CONTEXT, 1, &[]);
if source_verified {
let db = &*state.store;
let dispute_details = connector
.get_dispute_details(request_details)
.change_context(errors::WebhooksFlowError::WebhookEventObjectCreationFailed)?;
let payment_attempt = get_payment_attempt_from_object_reference_id(
state.clone(),
webhook_details.object_reference_id,
&merchant_account,
)
.await?;
let option_dispute = db
.find_by_merchant_id_payment_id_connector_dispute_id(
&merchant_account.merchant_id,
&payment_attempt.payment_id,
&dispute_details.connector_dispute_id,
)
.await
.change_context(errors::WebhooksFlowError::ResourceNotFound)?;
let dispute_object = get_or_update_dispute_object(
state.clone(),
option_dispute,
dispute_details,
&merchant_account.merchant_id,
&payment_attempt.payment_id,
&payment_attempt.attempt_id,
event_type.clone(),
)
.await?;
let disputes_response = Box::new(
dispute_object
.clone()
.foreign_try_into()
.into_report()
.change_context(errors::WebhooksFlowError::DisputeCoreFailed)?,
);
let event_type: enums::EventType = dispute_object
.dispute_status
.foreign_try_into()
.into_report()
.change_context(errors::WebhooksFlowError::DisputeCoreFailed)?;
create_event_and_trigger_outgoing_webhook::<W>(
state,
merchant_account,
event_type,
enums::EventClass::Disputes,
None,
dispute_object.dispute_id,
enums::EventObjectType::DisputeDetails,
api::OutgoingWebhookContent::DisputeDetails(disputes_response),
)
.await?;
metrics::INCOMING_DISPUTE_WEBHOOK_MERCHANT_NOTIFIED_METRIC.add(&metrics::CONTEXT, 1, &[]);
Ok(())
} else {
metrics::INCOMING_DISPUTE_WEBHOOK_SIGNATURE_FAILURE_METRIC.add(&metrics::CONTEXT, 1, &[]);
Err(errors::WebhooksFlowError::WebhookSourceVerificationFailed).into_report()
}
}
#[allow(clippy::too_many_arguments)]
#[instrument(skip_all)]
async fn create_event_and_trigger_outgoing_webhook<W: api::OutgoingWebhookType>(
@ -329,7 +497,7 @@ pub async fn webhooks_core<W: api::OutgoingWebhookType>(
.change_context(errors::ApiErrorResponse::InternalServerError)
.attach_printable("There was an error in parsing the query params")?;
let mut request_details = api::IncomingWebhookRequestDetails {
let mut request_details = api_models::webhooks::IncomingWebhookRequestDetails {
method: req.method().clone(),
headers: req.headers(),
query_params: req.query_string().to_string(),
@ -420,6 +588,19 @@ pub async fn webhooks_core<W: api::OutgoingWebhookType>(
.change_context(errors::ApiErrorResponse::InternalServerError)
.attach_printable("Incoming webhook flow for refunds failed")?,
api::WebhookFlow::Dispute => disputes_incoming_webhook_flow::<W>(
state.clone(),
merchant_account,
webhook_details,
source_verified,
*connector,
&request_details,
event_type,
)
.await
.change_context(errors::ApiErrorResponse::InternalServerError)
.attach_printable("Incoming webhook flow for disputes failed")?,
api::WebhookFlow::ReturnResponse => {}
_ => Err(errors::ApiErrorResponse::InternalServerError)

View File

@ -5,6 +5,7 @@ pub mod cards_info;
pub mod configs;
pub mod connector_response;
pub mod customers;
pub mod dispute;
pub mod ephemeral_key;
pub mod events;
pub mod locker_mock_up;
@ -42,6 +43,7 @@ pub trait StorageInterface:
+ configs::ConfigInterface
+ connector_response::ConnectorResponseInterface
+ customers::CustomerInterface
+ dispute::DisputeInterface
+ ephemeral_key::EphemeralKeyInterface
+ events::EventInterface
+ locker_mock_up::LockerMockUpInterface

View File

@ -0,0 +1,103 @@
use error_stack::IntoReport;
use super::{MockDb, Store};
use crate::{
connection,
core::errors::{self, CustomResult},
types::storage,
};
#[async_trait::async_trait]
pub trait DisputeInterface {
async fn insert_dispute(
&self,
dispute: storage::DisputeNew,
) -> CustomResult<storage::Dispute, errors::StorageError>;
async fn find_by_merchant_id_payment_id_connector_dispute_id(
&self,
merchant_id: &str,
payment_id: &str,
connector_dispute_id: &str,
) -> CustomResult<Option<storage::Dispute>, errors::StorageError>;
async fn update_dispute(
&self,
this: storage::Dispute,
dispute: storage::DisputeUpdate,
) -> CustomResult<storage::Dispute, errors::StorageError>;
}
#[async_trait::async_trait]
impl DisputeInterface for Store {
async fn insert_dispute(
&self,
dispute: storage::DisputeNew,
) -> CustomResult<storage::Dispute, errors::StorageError> {
let conn = connection::pg_connection_write(self).await?;
dispute
.insert(&conn)
.await
.map_err(Into::into)
.into_report()
}
async fn find_by_merchant_id_payment_id_connector_dispute_id(
&self,
merchant_id: &str,
payment_id: &str,
connector_dispute_id: &str,
) -> CustomResult<Option<storage::Dispute>, errors::StorageError> {
let conn = connection::pg_connection_read(self).await?;
storage::Dispute::find_by_merchant_id_payment_id_connector_dispute_id(
&conn,
merchant_id,
payment_id,
connector_dispute_id,
)
.await
.map_err(Into::into)
.into_report()
}
async fn update_dispute(
&self,
this: storage::Dispute,
dispute: storage::DisputeUpdate,
) -> CustomResult<storage::Dispute, errors::StorageError> {
let conn = connection::pg_connection_write(self).await?;
this.update(&conn, dispute)
.await
.map_err(Into::into)
.into_report()
}
}
#[async_trait::async_trait]
impl DisputeInterface for MockDb {
async fn insert_dispute(
&self,
_dispute: storage::DisputeNew,
) -> CustomResult<storage::Dispute, errors::StorageError> {
// TODO: Implement function for `MockDb`
Err(errors::StorageError::MockDbError)?
}
async fn find_by_merchant_id_payment_id_connector_dispute_id(
&self,
_merchant_id: &str,
_payment_id: &str,
_connector_dispute_id: &str,
) -> CustomResult<Option<storage::Dispute>, errors::StorageError> {
// TODO: Implement function for `MockDb`
Err(errors::StorageError::MockDbError)?
}
async fn update_dispute(
&self,
_this: storage::Dispute,
_dispute: storage::DisputeUpdate,
) -> CustomResult<storage::Dispute, errors::StorageError> {
// TODO: Implement function for `MockDb`
Err(errors::StorageError::MockDbError)?
}
}

View File

@ -2,6 +2,7 @@ pub mod admin;
pub mod api_keys;
pub mod configs;
pub mod customers;
pub mod disputes;
pub mod enums;
pub mod mandates;
pub mod payment_methods;

View File

@ -0,0 +1,15 @@
use masking::Deserialize;
#[derive(Default, Debug, Deserialize)]
pub struct DisputePayload {
pub amount: String,
pub currency: String,
pub dispute_stage: api_models::enums::DisputeStage,
pub connector_status: String,
pub connector_dispute_id: String,
pub connector_reason: Option<String>,
pub connector_reason_code: Option<String>,
pub challenge_required_by: Option<String>,
pub created_at: Option<String>,
pub updated_at: Option<String>,
}

View File

@ -137,4 +137,11 @@ pub trait IncomingWebhook: ConnectorCommon + Sync {
{
Ok(services::api::ApplicationResponse::StatusOk)
}
fn get_dispute_details(
&self,
_request: &IncomingWebhookRequestDetails<'_>,
) -> CustomResult<super::disputes::DisputePayload, errors::ConnectorError> {
Err(errors::ConnectorError::NotImplemented("get_dispute_details method".to_string()).into())
}
}

View File

@ -4,6 +4,7 @@ pub mod cards_info;
pub mod configs;
pub mod connector_response;
pub mod customers;
pub mod dispute;
pub mod enums;
pub mod ephemeral_key;
pub mod events;
@ -25,7 +26,7 @@ pub mod kv;
pub use self::{
address::*, api_keys::*, cards_info::*, configs::*, connector_response::*, customers::*,
events::*, locker_mock_up::*, mandate::*, merchant_account::*, merchant_connector_account::*,
payment_attempt::*, payment_intent::*, payment_method::*, process_tracker::*, refund::*,
reverse_lookup::*,
dispute::*, events::*, 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 @@
pub use storage_models::dispute::{Dispute, DisputeNew, DisputeUpdate};

View File

@ -177,6 +177,22 @@ impl ForeignTryFrom<storage_enums::RefundStatus> for storage_enums::EventType {
}
}
impl ForeignTryFrom<storage_enums::DisputeStatus> for storage_enums::EventType {
type Error = errors::ValidationError;
fn foreign_try_from(value: storage_enums::DisputeStatus) -> Result<Self, Self::Error> {
match value {
storage_enums::DisputeStatus::DisputeOpened => Ok(Self::DisputeOpened),
storage_enums::DisputeStatus::DisputeExpired => Ok(Self::DisputeExpired),
storage_enums::DisputeStatus::DisputeAccepted => Ok(Self::DisputeAccepted),
storage_enums::DisputeStatus::DisputeCancelled => Ok(Self::DisputeCancelled),
storage_enums::DisputeStatus::DisputeChallenged => Ok(Self::DisputeChallenged),
storage_enums::DisputeStatus::DisputeWon => Ok(Self::DisputeWon),
storage_enums::DisputeStatus::DisputeLost => Ok(Self::DisputeLost),
}
}
}
impl ForeignTryFrom<api_models::webhooks::IncomingWebhookEvent> for storage_enums::RefundStatus {
type Error = errors::ValidationError;
@ -436,6 +452,81 @@ impl ForeignFrom<storage_enums::AttemptStatus> for api_enums::AttemptStatus {
}
}
impl ForeignFrom<api_enums::DisputeStage> for storage_enums::DisputeStage {
fn foreign_from(status: api_enums::DisputeStage) -> Self {
frunk::labelled_convert_from(status)
}
}
impl ForeignFrom<api_enums::DisputeStatus> for storage_enums::DisputeStatus {
fn foreign_from(status: api_enums::DisputeStatus) -> Self {
frunk::labelled_convert_from(status)
}
}
impl ForeignFrom<storage_enums::DisputeStage> for api_enums::DisputeStage {
fn foreign_from(status: storage_enums::DisputeStage) -> Self {
frunk::labelled_convert_from(status)
}
}
impl ForeignFrom<storage_enums::DisputeStatus> for api_enums::DisputeStatus {
fn foreign_from(status: storage_enums::DisputeStatus) -> Self {
frunk::labelled_convert_from(status)
}
}
impl ForeignTryFrom<api_models::webhooks::IncomingWebhookEvent> for storage_enums::DisputeStatus {
type Error = errors::ValidationError;
fn foreign_try_from(
value: api_models::webhooks::IncomingWebhookEvent,
) -> Result<Self, Self::Error> {
match value {
api_models::webhooks::IncomingWebhookEvent::DisputeOpened => Ok(Self::DisputeOpened),
api_models::webhooks::IncomingWebhookEvent::DisputeExpired => Ok(Self::DisputeExpired),
api_models::webhooks::IncomingWebhookEvent::DisputeAccepted => {
Ok(Self::DisputeAccepted)
}
api_models::webhooks::IncomingWebhookEvent::DisputeCancelled => {
Ok(Self::DisputeCancelled)
}
api_models::webhooks::IncomingWebhookEvent::DisputeChallenged => {
Ok(Self::DisputeChallenged)
}
api_models::webhooks::IncomingWebhookEvent::DisputeWon => Ok(Self::DisputeWon),
api_models::webhooks::IncomingWebhookEvent::DisputeLost => Ok(Self::DisputeLost),
_ => Err(errors::ValidationError::IncorrectValueProvided {
field_name: "incoming_webhook_event",
}),
}
}
}
impl ForeignTryFrom<storage::Dispute> for api_models::disputes::DisputeResponse {
type Error = errors::ValidationError;
fn foreign_try_from(dispute: storage::Dispute) -> Result<Self, Self::Error> {
Ok(Self {
dispute_id: dispute.dispute_id,
payment_id: dispute.payment_id,
attempt_id: dispute.attempt_id,
amount: dispute.amount,
currency: dispute.currency,
dispute_stage: dispute.dispute_stage.foreign_into(),
dispute_status: dispute.dispute_status.foreign_into(),
connector_status: dispute.connector_status,
connector_dispute_id: dispute.connector_dispute_id,
connector_reason: dispute.connector_reason,
connector_reason_code: dispute.connector_reason_code,
challenge_required_by: dispute.challenge_required_by,
created_at: dispute.dispute_created_at,
updated_at: dispute.updated_at,
received_at: dispute.created_at.to_string(),
})
}
}
impl ForeignFrom<storage_models::cards_info::CardInfo>
for api_models::cards_info::CardInfoResponse
{

View File

@ -1 +1,104 @@
use common_utils::custom_serde;
use diesel::{AsChangeset, Identifiable, Insertable, Queryable};
use serde::{Deserialize, Serialize};
use time::PrimitiveDateTime;
use crate::{enums as storage_enums, schema::dispute};
#[derive(Clone, Debug, Deserialize, Insertable, Serialize, router_derive::DebugAsDisplay)]
#[diesel(table_name = dispute)]
#[serde(deny_unknown_fields)]
pub struct DisputeNew {
pub dispute_id: String,
pub amount: String,
pub currency: String,
pub dispute_stage: storage_enums::DisputeStage,
pub dispute_status: storage_enums::DisputeStatus,
pub payment_id: String,
pub attempt_id: String,
pub merchant_id: String,
pub connector_status: String,
pub connector_dispute_id: String,
pub connector_reason: Option<String>,
pub connector_reason_code: Option<String>,
pub challenge_required_by: Option<String>,
pub dispute_created_at: Option<String>,
pub updated_at: Option<String>,
}
#[derive(Clone, Debug, Deserialize, Serialize, Identifiable, Queryable)]
#[diesel(table_name = dispute)]
pub struct Dispute {
#[serde(skip_serializing)]
pub id: i32,
pub dispute_id: String,
pub amount: String,
pub currency: String,
pub dispute_stage: storage_enums::DisputeStage,
pub dispute_status: storage_enums::DisputeStatus,
pub payment_id: String,
pub attempt_id: String,
pub merchant_id: String,
pub connector_status: String,
pub connector_dispute_id: String,
pub connector_reason: Option<String>,
pub connector_reason_code: Option<String>,
pub challenge_required_by: Option<String>,
pub dispute_created_at: Option<String>,
pub updated_at: Option<String>,
#[serde(with = "custom_serde::iso8601")]
pub created_at: PrimitiveDateTime,
#[serde(with = "custom_serde::iso8601")]
pub modified_at: PrimitiveDateTime,
}
#[derive(Debug)]
pub enum DisputeUpdate {
Update {
dispute_stage: storage_enums::DisputeStage,
dispute_status: storage_enums::DisputeStatus,
connector_status: String,
connector_reason: Option<String>,
connector_reason_code: Option<String>,
challenge_required_by: Option<String>,
updated_at: Option<String>,
},
}
#[derive(Clone, Debug, Default, AsChangeset, router_derive::DebugAsDisplay)]
#[diesel(table_name = dispute)]
pub struct DisputeUpdateInternal {
dispute_stage: storage_enums::DisputeStage,
dispute_status: storage_enums::DisputeStatus,
connector_status: String,
connector_reason: Option<String>,
connector_reason_code: Option<String>,
challenge_required_by: Option<String>,
updated_at: Option<String>,
modified_at: Option<PrimitiveDateTime>,
}
impl From<DisputeUpdate> for DisputeUpdateInternal {
fn from(merchant_account_update: DisputeUpdate) -> Self {
match merchant_account_update {
DisputeUpdate::Update {
dispute_stage,
dispute_status,
connector_status,
connector_reason,
connector_reason_code,
challenge_required_by,
updated_at,
} => Self {
dispute_stage,
dispute_status,
connector_status,
connector_reason,
connector_reason_code,
challenge_required_by,
updated_at,
modified_at: Some(common_utils::date_time::now()),
},
}
}
}

View File

@ -3,6 +3,7 @@ pub mod diesel_exports {
pub use super::{
DbAttemptStatus as AttemptStatus, DbAuthenticationType as AuthenticationType,
DbCaptureMethod as CaptureMethod, DbConnectorType as ConnectorType, DbCurrency as Currency,
DbDisputeStage as DisputeStage, DbDisputeStatus as DisputeStatus,
DbEventClass as EventClass, DbEventObjectType as EventObjectType, DbEventType as EventType,
DbFutureUsage as FutureUsage, DbIntentStatus as IntentStatus,
DbMandateStatus as MandateStatus, DbMandateType as MandateType,
@ -273,6 +274,7 @@ pub enum Currency {
pub enum EventClass {
Payments,
Refunds,
Disputes,
}
#[derive(
@ -292,6 +294,7 @@ pub enum EventClass {
pub enum EventObjectType {
PaymentDetails,
RefundDetails,
DisputeDetails,
}
#[derive(
@ -313,6 +316,13 @@ pub enum EventType {
PaymentSucceeded,
RefundSucceeded,
RefundFailed,
DisputeOpened,
DisputeExpired,
DisputeAccepted,
DisputeCancelled,
DisputeChallenged,
DisputeWon,
DisputeLost,
}
#[derive(
@ -706,3 +716,53 @@ pub enum PaymentExperience {
LinkWallet,
InvokePaymentApp,
}
#[derive(
Clone,
Copy,
Debug,
Eq,
PartialEq,
Default,
serde::Deserialize,
serde::Serialize,
strum::Display,
strum::EnumString,
frunk::LabelledGeneric,
)]
#[router_derive::diesel_enum(storage_type = "pg_enum")]
#[serde(rename_all = "snake_case")]
#[strum(serialize_all = "snake_case")]
pub enum DisputeStage {
PreDispute,
#[default]
Dispute,
PreArbitration,
}
#[derive(
Clone,
Copy,
Debug,
Eq,
PartialEq,
Default,
serde::Deserialize,
serde::Serialize,
strum::Display,
strum::EnumString,
frunk::LabelledGeneric,
)]
#[router_derive::diesel_enum(storage_type = "pg_enum")]
#[serde(rename_all = "snake_case")]
#[strum(serialize_all = "snake_case")]
pub enum DisputeStatus {
#[default]
DisputeOpened,
DisputeExpired,
DisputeAccepted,
DisputeCancelled,
DisputeChallenged,
DisputeWon,
DisputeLost,
}

View File

@ -4,6 +4,7 @@ pub mod cards_info;
pub mod configs;
pub mod connector_response;
pub mod customers;
pub mod dispute;
pub mod events;
pub mod generics;
pub mod locker_mock_up;

View File

@ -0,0 +1,58 @@
use diesel::{associations::HasTable, BoolExpressionMethods, ExpressionMethods};
use router_env::{instrument, tracing};
use super::generics;
use crate::{
dispute::{Dispute, DisputeNew, DisputeUpdate, DisputeUpdateInternal},
errors,
schema::dispute::dsl,
PgPooledConn, StorageResult,
};
impl DisputeNew {
#[instrument(skip(conn))]
pub async fn insert(self, conn: &PgPooledConn) -> StorageResult<Dispute> {
generics::generic_insert(conn, self).await
}
}
impl Dispute {
#[instrument(skip(conn))]
pub async fn find_by_merchant_id_payment_id_connector_dispute_id(
conn: &PgPooledConn,
merchant_id: &str,
payment_id: &str,
connector_dispute_id: &str,
) -> StorageResult<Option<Self>> {
generics::generic_find_one_optional::<<Self as HasTable>::Table, _, _>(
conn,
dsl::merchant_id
.eq(merchant_id.to_owned())
.and(dsl::payment_id.eq(payment_id.to_owned()))
.and(dsl::connector_dispute_id.eq(connector_dispute_id.to_owned())),
)
.await
}
#[instrument(skip(conn))]
pub async fn update(self, conn: &PgPooledConn, dispute: DisputeUpdate) -> StorageResult<Self> {
match generics::generic_update_with_unique_predicate_get_result::<
<Self as HasTable>::Table,
_,
_,
_,
>(
conn,
dsl::dispute_id.eq(self.dispute_id.to_owned()),
DisputeUpdateInternal::from(dispute),
)
.await
{
Err(error) => match error.current_context() {
errors::DatabaseError::NoFieldsToUpdate => Ok(self),
_ => Err(error),
},
result => result,
}
}
}

View File

@ -106,6 +106,32 @@ diesel::table! {
}
}
diesel::table! {
use diesel::sql_types::*;
use crate::enums::diesel_exports::*;
dispute (id) {
id -> Int4,
dispute_id -> Varchar,
amount -> Varchar,
currency -> Varchar,
dispute_stage -> DisputeStage,
dispute_status -> DisputeStatus,
payment_id -> Varchar,
attempt_id -> Varchar,
merchant_id -> Varchar,
connector_status -> Varchar,
connector_dispute_id -> Varchar,
connector_reason -> Nullable<Varchar>,
connector_reason_code -> Nullable<Varchar>,
challenge_required_by -> Nullable<Varchar>,
dispute_created_at -> Nullable<Varchar>,
updated_at -> Nullable<Varchar>,
created_at -> Timestamp,
modified_at -> Timestamp,
}
}
diesel::table! {
use diesel::sql_types::*;
use crate::enums::diesel_exports::*;
@ -387,6 +413,7 @@ diesel::allow_tables_to_appear_in_same_query!(
configs,
connector_response,
customers,
dispute,
events,
locker_mock_up,
mandate,

View File

@ -0,0 +1,5 @@
DROP TABLE dispute;
DROP TYPE "DisputeStage";
DROP TYPE "DisputeStatus";

View File

@ -0,0 +1,44 @@
CREATE TYPE "DisputeStage" AS ENUM ('pre_dispute', 'dispute', 'pre_arbitration');
CREATE TYPE "DisputeStatus" AS ENUM ('dispute_opened', 'dispute_expired', 'dispute_accepted', 'dispute_cancelled', 'dispute_challenged', 'dispute_won', 'dispute_lost');
CREATE TABLE dispute (
id SERIAL PRIMARY KEY,
dispute_id VARCHAR(64) NOT NULL,
amount VARCHAR(255) NOT NULL,
currency VARCHAR(255) NOT NULL,
dispute_stage "DisputeStage" NOT NULL,
dispute_status "DisputeStatus" NOT NULL,
payment_id VARCHAR(255) NOT NULL,
attempt_id VARCHAR(64) NOT NULL,
merchant_id VARCHAR(255) NOT NULL,
connector_status VARCHAR(255) NOT NULL,
connector_dispute_id VARCHAR(255) NOT NULL,
connector_reason VARCHAR(255),
connector_reason_code VARCHAR(255),
challenge_required_by VARCHAR(255),
dispute_created_at VARCHAR(255),
updated_at VARCHAR(255),
created_at TIMESTAMP NOT NULL DEFAULT now()::TIMESTAMP,
modified_at TIMESTAMP NOT NULL DEFAULT now()::TIMESTAMP
);
CREATE UNIQUE INDEX dispute_id_index ON dispute (dispute_id);
CREATE UNIQUE INDEX merchant_id_payment_id_connector_dispute_id_index ON dispute (merchant_id, payment_id, connector_dispute_id);
CREATE INDEX dispute_status_index ON dispute (dispute_status);
CREATE INDEX dispute_stage_index ON dispute (dispute_stage);
ALTER TYPE "EventClass" ADD VALUE 'disputes';
ALTER TYPE "EventObjectType" ADD VALUE 'dispute_details';
ALTER TYPE "EventType" ADD VALUE 'dispute_opened';
ALTER TYPE "EventType" ADD VALUE 'dispute_expired';
ALTER TYPE "EventType" ADD VALUE 'dispute_accepted';
ALTER TYPE "EventType" ADD VALUE 'dispute_cancelled';
ALTER TYPE "EventType" ADD VALUE 'dispute_challenged';
ALTER TYPE "EventType" ADD VALUE 'dispute_won';
ALTER TYPE "EventType" ADD VALUE 'dispute_lost';