feat(payments): add support for manual retries in payments confirm call (#1170)

This commit is contained in:
Abhishek Marrivagu
2023-05-18 13:46:38 +05:30
committed by GitHub
parent 5e51b6b16d
commit 1f52a66452
5 changed files with 273 additions and 28 deletions

View File

@ -219,6 +219,10 @@ pub struct PaymentsRequest {
/// Business sub label for the payment
pub business_sub_label: Option<String>,
/// If enabled payment can be retried from the client side until the payment is successful or payment expires or the attempts(configured by the merchant) for payment are exhausted.
#[serde(default)]
pub manual_retry: bool,
}
#[derive(Default, Debug, serde::Deserialize, serde::Serialize, Clone, Copy, PartialEq, Eq)]

View File

@ -89,6 +89,7 @@ where
&merchant_account,
)
.await?;
authenticate_client_secret(
req.get_client_secret(),
&payment_data.payment_intent,

View File

@ -25,6 +25,7 @@ use crate::{
core::{
errors::{self, CustomResult, RouterResult, StorageErrorExt},
payment_methods::{cards, vault},
payments,
},
db::StorageInterface,
routes::{metrics, AppState},
@ -1683,3 +1684,215 @@ pub fn router_data_type_conversion<F1, F2, Req1, Req2, Res1, Res2>(
connector_customer: router_data.connector_customer,
}
}
pub fn get_attempt_type(
payment_intent: &storage::PaymentIntent,
payment_attempt: &storage::PaymentAttempt,
request: &api::PaymentsRequest,
action: &str,
) -> RouterResult<AttemptType> {
match payment_intent.status {
enums::IntentStatus::Failed => {
if request.manual_retry {
match payment_attempt.status {
enums::AttemptStatus::Started
| enums::AttemptStatus::AuthenticationPending
| enums::AttemptStatus::AuthenticationSuccessful
| enums::AttemptStatus::Authorized
| enums::AttemptStatus::Charged
| enums::AttemptStatus::Authorizing
| enums::AttemptStatus::CodInitiated
| enums::AttemptStatus::VoidInitiated
| enums::AttemptStatus::CaptureInitiated
| enums::AttemptStatus::Unresolved
| enums::AttemptStatus::Pending
| enums::AttemptStatus::ConfirmationAwaited
| enums::AttemptStatus::PartialCharged
| enums::AttemptStatus::Voided
| enums::AttemptStatus::AutoRefunded
| enums::AttemptStatus::PaymentMethodAwaited
| enums::AttemptStatus::DeviceDataCollectionPending => {
Err(errors::ApiErrorResponse::InternalServerError)
.into_report()
.attach_printable("Payment Attempt unexpected state")
}
storage_enums::AttemptStatus::VoidFailed
| storage_enums::AttemptStatus::RouterDeclined
| storage_enums::AttemptStatus::CaptureFailed => Err(report!(errors::ApiErrorResponse::PreconditionFailed {
message:
format!("You cannot {action} this payment because it has status {}, and the previous attempt has the status {}", payment_intent.status, payment_attempt.status)
}
)),
storage_enums::AttemptStatus::AuthenticationFailed
| storage_enums::AttemptStatus::AuthorizationFailed
| storage_enums::AttemptStatus::Failure => Ok(AttemptType::New),
}
} else {
Err(report!(errors::ApiErrorResponse::PreconditionFailed {
message:
format!("You cannot {action} this payment because it has status {}, you can pass manual_retry as true in request to try this payment again", payment_intent.status)
}
))
}
}
enums::IntentStatus::Cancelled
| enums::IntentStatus::RequiresCapture
| enums::IntentStatus::Processing
| enums::IntentStatus::Succeeded => {
Err(report!(errors::ApiErrorResponse::PreconditionFailed {
message: format!(
"You cannot {action} this payment because it has status {}",
payment_intent.status,
),
}))
}
enums::IntentStatus::RequiresCustomerAction
| enums::IntentStatus::RequiresMerchantAction
| enums::IntentStatus::RequiresPaymentMethod
| enums::IntentStatus::RequiresConfirmation => Ok(AttemptType::SameOld),
}
}
#[derive(Debug, Eq, PartialEq, Clone)]
pub enum AttemptType {
New,
SameOld,
}
impl AttemptType {
// The function creates a new payment_attempt from the previous payment attempt but doesn't populate fields like payment_method, error_code etc.
// Logic to override the fields with data provided in the request should be done after this if required.
// In case if fields are not overridden by the request then they contain the same data that was in the previous attempt provided it is populated in this function.
#[inline(always)]
fn make_new_payment_attempt(
payment_method_data: &Option<api_models::payments::PaymentMethodData>,
old_payment_attempt: storage::PaymentAttempt,
) -> storage::PaymentAttemptNew {
let created_at @ modified_at @ last_synced = Some(common_utils::date_time::now());
storage::PaymentAttemptNew {
payment_id: old_payment_attempt.payment_id,
merchant_id: old_payment_attempt.merchant_id,
attempt_id: uuid::Uuid::new_v4().simple().to_string(),
// A new payment attempt is getting created so, used the same function which is used to populate status in PaymentCreate Flow.
status: payment_attempt_status_fsm(payment_method_data, Some(true)),
amount: old_payment_attempt.amount,
currency: old_payment_attempt.currency,
save_to_locker: old_payment_attempt.save_to_locker,
connector: None,
error_message: None,
offer_amount: old_payment_attempt.offer_amount,
surcharge_amount: old_payment_attempt.surcharge_amount,
tax_amount: old_payment_attempt.tax_amount,
payment_method_id: None,
payment_method: None,
capture_method: old_payment_attempt.capture_method,
capture_on: old_payment_attempt.capture_on,
confirm: old_payment_attempt.confirm,
authentication_type: old_payment_attempt.authentication_type,
created_at,
modified_at,
last_synced,
cancellation_reason: None,
amount_to_capture: old_payment_attempt.amount_to_capture,
// Once the payment_attempt is authorised then mandate_id is created. If this payment attempt is authorised then mandate_id will be overridden.
// Since mandate_id is a contract between merchant and customer to debit customers amount adding it to newly created attempt
mandate_id: old_payment_attempt.mandate_id,
// The payment could be done from a different browser or same browser, it would probably be overridden by request data.
browser_info: None,
error_code: None,
payment_token: None,
connector_metadata: None,
payment_experience: None,
payment_method_type: None,
payment_method_data: None,
// In case it is passed in create and not in confirm,
business_sub_label: old_payment_attempt.business_sub_label,
// If the algorithm is entered in Create call from server side, it needs to be populated here, however it could be overridden from the request.
straight_through_algorithm: old_payment_attempt.straight_through_algorithm,
}
}
pub async fn modify_payment_intent_and_payment_attempt(
&self,
request: &api::PaymentsRequest,
fetched_payment_intent: storage::PaymentIntent,
fetched_payment_attempt: storage::PaymentAttempt,
db: &dyn StorageInterface,
storage_scheme: storage::enums::MerchantStorageScheme,
) -> RouterResult<(storage::PaymentIntent, storage::PaymentAttempt)> {
match self {
Self::SameOld => Ok((fetched_payment_intent, fetched_payment_attempt)),
Self::New => {
let new_payment_attempt = db
.insert_payment_attempt(
Self::make_new_payment_attempt(
&request.payment_method_data,
fetched_payment_attempt,
),
storage_scheme,
)
.await
.to_duplicate_response(errors::ApiErrorResponse::DuplicatePayment {
payment_id: fetched_payment_intent.payment_id.to_owned(),
})?;
let updated_payment_intent = db
.update_payment_intent(
fetched_payment_intent,
storage::PaymentIntentUpdate::StatusAndAttemptUpdate {
status: payment_intent_status_fsm(
&request.payment_method_data,
Some(true),
),
active_attempt_id: new_payment_attempt.attempt_id.to_owned(),
},
storage_scheme,
)
.await
.to_not_found_response(errors::ApiErrorResponse::PaymentNotFound)?;
Ok((updated_payment_intent, new_payment_attempt))
}
}
}
pub async fn get_connector_response(
&self,
payment_attempt: &storage::PaymentAttempt,
db: &dyn StorageInterface,
storage_scheme: storage::enums::MerchantStorageScheme,
) -> RouterResult<storage::ConnectorResponse> {
match self {
Self::New => db
.insert_connector_response(
payments::PaymentCreate::make_connector_response(payment_attempt),
storage_scheme,
)
.await
.to_duplicate_response(errors::ApiErrorResponse::DuplicatePayment {
payment_id: payment_attempt.payment_id.clone(),
}),
Self::SameOld => db
.find_connector_response_by_payment_id_merchant_id_attempt_id(
&payment_attempt.payment_id,
&payment_attempt.merchant_id,
&payment_attempt.attempt_id,
storage_scheme,
)
.await
.to_not_found_response(errors::ApiErrorResponse::PaymentNotFound),
}
}
}

View File

@ -57,20 +57,46 @@ impl<F: Send + Clone> GetTracker<F, PaymentData<F>, api::PaymentsRequest> for Pa
.await
.to_not_found_response(errors::ApiErrorResponse::PaymentNotFound)?;
helpers::validate_payment_status_against_not_allowed_statuses(
&payment_intent.status,
&[
storage_enums::IntentStatus::Cancelled,
storage_enums::IntentStatus::Succeeded,
storage_enums::IntentStatus::Processing,
storage_enums::IntentStatus::RequiresCapture,
storage_enums::IntentStatus::RequiresMerchantAction,
],
"confirm",
)?;
payment_attempt = db
.find_payment_attempt_by_payment_id_merchant_id_attempt_id(
payment_intent.payment_id.as_str(),
merchant_id,
payment_intent.active_attempt_id.as_str(),
storage_scheme,
)
.await
.to_not_found_response(errors::ApiErrorResponse::PaymentNotFound)?;
let attempt_type =
helpers::get_attempt_type(&payment_intent, &payment_attempt, request, "confirm")?;
(payment_intent, payment_attempt) = attempt_type
.modify_payment_intent_and_payment_attempt(
request,
payment_intent,
payment_attempt,
db,
storage_scheme,
)
.await?;
payment_intent.setup_future_usage = request
.setup_future_usage
.map(ForeignInto::foreign_into)
.or(payment_intent.setup_future_usage);
helpers::validate_payment_status_against_not_allowed_statuses(
&payment_intent.status,
&[
storage_enums::IntentStatus::Failed,
storage_enums::IntentStatus::Succeeded,
],
"confirm",
)?;
let (token, payment_method, setup_mandate) = helpers::get_token_pm_type_mandate_details(
state,
request,
@ -88,16 +114,6 @@ impl<F: Send + Clone> GetTracker<F, PaymentData<F>, api::PaymentsRequest> for Pa
field_name: "browser_info",
})?;
payment_attempt = db
.find_payment_attempt_by_payment_id_merchant_id_attempt_id(
payment_intent.payment_id.as_str(),
merchant_id,
payment_intent.active_attempt_id.as_str(),
storage_scheme,
)
.await
.to_not_found_response(errors::ApiErrorResponse::PaymentNotFound)?;
let token = token.or_else(|| payment_attempt.payment_token.clone());
helpers::validate_pm_or_token_given(
@ -119,6 +135,11 @@ impl<F: Send + Clone> GetTracker<F, PaymentData<F>, api::PaymentsRequest> for Pa
.payment_experience
.map(|experience| experience.foreign_into());
payment_attempt.capture_method = request
.capture_method
.or(payment_attempt.capture_method.map(|cm| cm.foreign_into()))
.map(|cm| cm.foreign_into());
currency = payment_attempt.currency.get_required_value("currency")?;
amount = payment_attempt.amount.into();
@ -149,15 +170,9 @@ impl<F: Send + Clone> GetTracker<F, PaymentData<F>, api::PaymentsRequest> for Pa
)
.await?;
connector_response = db
.find_connector_response_by_payment_id_merchant_id_attempt_id(
&payment_attempt.payment_id,
&payment_attempt.merchant_id,
&payment_attempt.attempt_id,
storage_scheme,
)
.await
.to_not_found_response(errors::ApiErrorResponse::PaymentNotFound)?;
connector_response = attempt_type
.get_connector_response(&payment_attempt, db, storage_scheme)
.await?;
payment_intent.shipping_address_id = shipping_address.clone().map(|i| i.address_id);
payment_intent.billing_address_id = billing_address.clone().map(|i| i.address_id);

View File

@ -123,6 +123,10 @@ pub enum PaymentIntentUpdate {
PaymentAttemptUpdate {
active_attempt_id: String,
},
StatusAndAttemptUpdate {
status: storage_enums::IntentStatus,
active_attempt_id: String,
},
}
#[derive(Clone, Debug, Default, AsChangeset, router_derive::DebugAsDisplay)]
@ -273,6 +277,14 @@ impl From<PaymentIntentUpdate> for PaymentIntentUpdateInternal {
active_attempt_id: Some(active_attempt_id),
..Default::default()
},
PaymentIntentUpdate::StatusAndAttemptUpdate {
status,
active_attempt_id,
} => Self {
status: Some(status),
active_attempt_id: Some(active_attempt_id),
..Default::default()
},
}
}
}