From fff565e8fbcf03a0fa87976b8745062606ec8d3b Mon Sep 17 00:00:00 2001 From: Ayush Anand <114248859+ayush22667@users.noreply.github.com> Date: Tue, 30 Dec 2025 17:06:06 +0530 Subject: [PATCH] feat(payments): add structured error details to payment attempts (#10646) Co-authored-by: hyperswitch-bot[bot] <148525504+hyperswitch-bot[bot]@users.noreply.github.com> --- crates/common_enums/src/enums.rs | 30 +++++++ crates/diesel_models/src/payment_attempt.rs | 81 +++++++++++++++++++ crates/diesel_models/src/schema.rs | 1 + crates/diesel_models/src/schema_v2.rs | 1 + crates/diesel_models/src/user/sample_data.rs | 1 + .../src/payments/payment_attempt.rs | 7 ++ .../down.sql | 2 + .../up.sql | 2 + 8 files changed, 125 insertions(+) create mode 100644 migrations/2025-12-12-120000_add_error_details_to_payment_attempt/down.sql create mode 100644 migrations/2025-12-12-120000_add_error_details_to_payment_attempt/up.sql diff --git a/crates/common_enums/src/enums.rs b/crates/common_enums/src/enums.rs index 00886a7398..aa4a869d38 100644 --- a/crates/common_enums/src/enums.rs +++ b/crates/common_enums/src/enums.rs @@ -343,6 +343,36 @@ pub enum GsmDecision { DoDefault, } +#[derive( + Clone, + Copy, + Debug, + strum::Display, + PartialEq, + Eq, + serde::Serialize, + serde::Deserialize, + strum::EnumString, + ToSchema, +)] +#[serde(rename_all = "snake_case")] +#[strum(serialize_all = "snake_case")] +#[router_derive::diesel_enum(storage_type = "text")] +pub enum RecommendedAction { + DoNotRetry, + RetryAfter10Days, + RetryAfter1Hour, + RetryAfter24Hours, + RetryAfter2Days, + RetryAfter4Days, + RetryAfter6Days, + RetryAfter8Days, + RetryAfterInstrumentUpdate, + RetryLater, + RetryWithDifferentPaymentMethodData, + StopRecurring, +} + #[derive( Clone, Copy, diff --git a/crates/diesel_models/src/payment_attempt.rs b/crates/diesel_models/src/payment_attempt.rs index f25e7e19cd..e61ba44867 100644 --- a/crates/diesel_models/src/payment_attempt.rs +++ b/crates/diesel_models/src/payment_attempt.rs @@ -47,6 +47,49 @@ pub struct NetworkDetails { pub network_advice_code: Option, } +// ErrorDetails nested structs for V1 payment_attempt +common_utils::impl_to_sql_from_sql_json!(ErrorDetails); +#[derive( + Clone, Default, Debug, serde::Deserialize, Eq, PartialEq, serde::Serialize, diesel::AsExpression, +)] +#[diesel(sql_type = diesel::sql_types::Jsonb)] +pub struct ErrorDetails { + pub unified_details: Option, + pub issuer_details: Option, + pub connector_details: Option, +} + +#[derive(Clone, Default, Debug, serde::Deserialize, Eq, PartialEq, serde::Serialize)] +pub struct UnifiedErrorDetails { + pub category: Option, + pub message: Option, + pub standardised_code: Option, + pub description: Option, + pub user_guidance_message: Option, + pub recommended_action: Option, +} + +#[derive(Clone, Default, Debug, serde::Deserialize, Eq, PartialEq, serde::Serialize)] +pub struct IssuerErrorDetails { + pub code: Option, + pub message: Option, + pub network_details: Option, +} + +#[derive(Clone, Default, Debug, serde::Deserialize, Eq, PartialEq, serde::Serialize)] +pub struct NetworkErrorDetails { + pub name: Option, + pub advice_code: Option, + pub advice_message: Option, +} + +#[derive(Clone, Default, Debug, serde::Deserialize, Eq, PartialEq, serde::Serialize)] +pub struct ConnectorErrorDetails { + pub code: Option, + pub message: Option, + pub reason: Option, +} + #[cfg(feature = "v2")] #[derive( Clone, Debug, Eq, PartialEq, Identifiable, Queryable, Serialize, Deserialize, Selectable, @@ -118,6 +161,7 @@ pub struct PaymentAttempt { pub extended_authorization_last_applied_at: Option, pub tokenization: Option, pub encrypted_payment_method_data: Option, + pub error_details: Option, #[diesel(deserialize_as = RequiredFromNullable)] pub payment_method_type_v2: storage_enums::PaymentMethod, pub connector_payment_id: Option, @@ -246,6 +290,7 @@ pub struct PaymentAttempt { pub extended_authorization_last_applied_at: Option, pub tokenization: Option, pub encrypted_payment_method_data: Option, + pub error_details: Option, } #[cfg(feature = "v1")] @@ -396,6 +441,7 @@ pub struct PaymentAttemptNew { /// Amount captured for this payment attempt pub amount_captured: Option, pub encrypted_payment_method_data: Option, + pub error_details: Option, } #[cfg(feature = "v1")] @@ -486,6 +532,7 @@ pub struct PaymentAttemptNew { pub extended_authorization_last_applied_at: Option, pub tokenization: Option, pub encrypted_payment_method_data: Option, + pub error_details: Option, } #[cfg(feature = "v1")] @@ -625,6 +672,7 @@ pub enum PaymentAttemptUpdate { setup_future_usage_applied: Option, is_overcapture_enabled: Option, authorized_amount: Option, + error_details: Box>, }, UnresolvedResponseUpdate { status: storage_enums::AttemptStatus, @@ -636,6 +684,7 @@ pub enum PaymentAttemptUpdate { error_reason: Option>, connector_response_reference_id: Option, updated_by: String, + error_details: Box>, }, StatusUpdate { status: storage_enums::AttemptStatus, @@ -658,6 +707,7 @@ pub enum PaymentAttemptUpdate { issuer_error_code: Option, issuer_error_message: Option, network_details: Option, + error_details: Box>, }, CaptureUpdate { amount_to_capture: Option, @@ -1072,6 +1122,7 @@ impl PaymentAttemptUpdateInternal { authorized_amount: source.authorized_amount, amount_captured: amount_captured.or(source.amount_captured), encrypted_payment_method_data: source.encrypted_payment_method_data, + error_details: source.error_details, } } } @@ -1147,6 +1198,7 @@ pub struct PaymentAttemptUpdateInternal { pub is_stored_credential: Option, pub request_extended_authorization: Option, pub authorized_amount: Option, + pub error_details: Option, } #[cfg(feature = "v1")] @@ -1347,6 +1399,7 @@ impl PaymentAttemptUpdate { is_stored_credential, request_extended_authorization, authorized_amount, + error_details, } = PaymentAttemptUpdateInternal::from(self).populate_derived_fields(&source); PaymentAttempt { amount: amount.unwrap_or(source.amount), @@ -1428,6 +1481,7 @@ impl PaymentAttemptUpdate { .or(source.request_extended_authorization), authorized_amount: authorized_amount.or(source.authorized_amount), tokenization: tokenization.or(source.tokenization), + error_details: error_details.or(source.error_details), ..source } } @@ -2783,6 +2837,7 @@ impl From for PaymentAttemptUpdateInternal { request_extended_authorization: None, authorized_amount: None, encrypted_payment_method_data: None, + error_details: None, }, PaymentAttemptUpdate::AuthenticationTypeUpdate { authentication_type, @@ -2856,6 +2911,7 @@ impl From for PaymentAttemptUpdateInternal { request_extended_authorization: None, authorized_amount: None, encrypted_payment_method_data: None, + error_details: None, }, PaymentAttemptUpdate::ConfirmUpdate { amount, @@ -2966,6 +3022,7 @@ impl From for PaymentAttemptUpdateInternal { request_extended_authorization, authorized_amount: None, encrypted_payment_method_data: None, + error_details: None, }, PaymentAttemptUpdate::VoidUpdate { status, @@ -3040,6 +3097,7 @@ impl From for PaymentAttemptUpdateInternal { request_extended_authorization: None, authorized_amount: None, encrypted_payment_method_data: None, + error_details: None, }, PaymentAttemptUpdate::RejectUpdate { status, @@ -3115,6 +3173,7 @@ impl From for PaymentAttemptUpdateInternal { request_extended_authorization: None, authorized_amount: None, encrypted_payment_method_data: None, + error_details: None, }, PaymentAttemptUpdate::BlocklistUpdate { status, @@ -3190,6 +3249,7 @@ impl From for PaymentAttemptUpdateInternal { request_extended_authorization: None, authorized_amount: None, encrypted_payment_method_data: None, + error_details: None, }, PaymentAttemptUpdate::ConnectorMandateDetailUpdate { connector_mandate_detail, @@ -3264,6 +3324,7 @@ impl From for PaymentAttemptUpdateInternal { request_extended_authorization: None, authorized_amount: None, encrypted_payment_method_data: None, + error_details: None, }, PaymentAttemptUpdate::PaymentMethodDetailsUpdate { payment_method_id, @@ -3337,6 +3398,7 @@ impl From for PaymentAttemptUpdateInternal { request_extended_authorization: None, authorized_amount: None, encrypted_payment_method_data: None, + error_details: None, }, PaymentAttemptUpdate::ResponseUpdate { status, @@ -3369,7 +3431,9 @@ impl From for PaymentAttemptUpdateInternal { network_transaction_id, is_overcapture_enabled, authorized_amount, + error_details: boxed_error_details, } => { + let error_details = *boxed_error_details; let (connector_transaction_id, processor_transaction_data) = connector_transaction_id .map(ConnectorTransactionId::form_id_and_data) @@ -3444,6 +3508,7 @@ impl From for PaymentAttemptUpdateInternal { request_extended_authorization: None, authorized_amount, encrypted_payment_method_data, + error_details, } } PaymentAttemptUpdate::ErrorUpdate { @@ -3463,7 +3528,9 @@ impl From for PaymentAttemptUpdateInternal { issuer_error_code, issuer_error_message, network_details, + error_details: boxed_error_details, } => { + let error_details = *boxed_error_details; let (connector_transaction_id, processor_transaction_data) = connector_transaction_id .map(ConnectorTransactionId::form_id_and_data) @@ -3538,6 +3605,7 @@ impl From for PaymentAttemptUpdateInternal { request_extended_authorization: None, authorized_amount: None, encrypted_payment_method_data, + error_details, } } PaymentAttemptUpdate::StatusUpdate { status, updated_by } => Self { @@ -3609,6 +3677,7 @@ impl From for PaymentAttemptUpdateInternal { request_extended_authorization: None, authorized_amount: None, encrypted_payment_method_data: None, + error_details: None, }, PaymentAttemptUpdate::UpdateTrackers { payment_token, @@ -3690,6 +3759,7 @@ impl From for PaymentAttemptUpdateInternal { request_extended_authorization: None, authorized_amount: None, encrypted_payment_method_data: None, + error_details: None, }, PaymentAttemptUpdate::UnresolvedResponseUpdate { status, @@ -3701,7 +3771,9 @@ impl From for PaymentAttemptUpdateInternal { error_reason, connector_response_reference_id, updated_by, + error_details: boxed_error_details, } => { + let error_details = *boxed_error_details; let (connector_transaction_id, processor_transaction_data) = connector_transaction_id .map(ConnectorTransactionId::form_id_and_data) @@ -3776,6 +3848,7 @@ impl From for PaymentAttemptUpdateInternal { request_extended_authorization: None, authorized_amount: None, encrypted_payment_method_data: None, + error_details, } } PaymentAttemptUpdate::PreprocessingUpdate { @@ -3861,6 +3934,7 @@ impl From for PaymentAttemptUpdateInternal { request_extended_authorization: None, authorized_amount: None, encrypted_payment_method_data: None, + error_details: None, } } PaymentAttemptUpdate::CaptureUpdate { @@ -3936,6 +4010,7 @@ impl From for PaymentAttemptUpdateInternal { request_extended_authorization: None, authorized_amount: None, encrypted_payment_method_data: None, + error_details: None, }, PaymentAttemptUpdate::AmountToCaptureUpdate { status, @@ -4010,6 +4085,7 @@ impl From for PaymentAttemptUpdateInternal { request_extended_authorization: None, authorized_amount: None, encrypted_payment_method_data: None, + error_details: None, }, PaymentAttemptUpdate::ConnectorResponse { authentication_data, @@ -4093,6 +4169,7 @@ impl From for PaymentAttemptUpdateInternal { request_extended_authorization: None, authorized_amount: None, encrypted_payment_method_data: None, + error_details: None, } } PaymentAttemptUpdate::IncrementalAuthorizationAmountUpdate { @@ -4167,6 +4244,7 @@ impl From for PaymentAttemptUpdateInternal { request_extended_authorization: None, authorized_amount: None, encrypted_payment_method_data: None, + error_details: None, }, PaymentAttemptUpdate::AuthenticationUpdate { status, @@ -4243,6 +4321,7 @@ impl From for PaymentAttemptUpdateInternal { request_extended_authorization: None, authorized_amount: None, encrypted_payment_method_data: None, + error_details: None, }, PaymentAttemptUpdate::ManualUpdate { status, @@ -4329,6 +4408,7 @@ impl From for PaymentAttemptUpdateInternal { request_extended_authorization: None, authorized_amount: None, encrypted_payment_method_data: None, + error_details: None, } } PaymentAttemptUpdate::PostSessionTokensUpdate { @@ -4403,6 +4483,7 @@ impl From for PaymentAttemptUpdateInternal { request_extended_authorization: None, authorized_amount: None, encrypted_payment_method_data: None, + error_details: None, }, } } diff --git a/crates/diesel_models/src/schema.rs b/crates/diesel_models/src/schema.rs index e8489f6580..280032d44f 100644 --- a/crates/diesel_models/src/schema.rs +++ b/crates/diesel_models/src/schema.rs @@ -1135,6 +1135,7 @@ diesel::table! { #[max_length = 64] tokenization -> Nullable, encrypted_payment_method_data -> Nullable, + error_details -> Nullable, } } diff --git a/crates/diesel_models/src/schema_v2.rs b/crates/diesel_models/src/schema_v2.rs index 04f001d166..af71e45fe1 100644 --- a/crates/diesel_models/src/schema_v2.rs +++ b/crates/diesel_models/src/schema_v2.rs @@ -1077,6 +1077,7 @@ diesel::table! { #[max_length = 64] tokenization -> Nullable, encrypted_payment_method_data -> Nullable, + error_details -> Nullable, payment_method_type_v2 -> Nullable, #[max_length = 128] connector_payment_id -> Nullable, diff --git a/crates/diesel_models/src/user/sample_data.rs b/crates/diesel_models/src/user/sample_data.rs index 8c0101f3d3..917521bf33 100644 --- a/crates/diesel_models/src/user/sample_data.rs +++ b/crates/diesel_models/src/user/sample_data.rs @@ -319,6 +319,7 @@ impl PaymentAttemptBatchNew { is_stored_credential: self.is_stored_credential, authorized_amount: self.authorized_amount, tokenization: self.tokenization, + error_details: None, } } } diff --git a/crates/hyperswitch_domain_models/src/payments/payment_attempt.rs b/crates/hyperswitch_domain_models/src/payments/payment_attempt.rs index bdd8b2445e..c594e33de1 100644 --- a/crates/hyperswitch_domain_models/src/payments/payment_attempt.rs +++ b/crates/hyperswitch_domain_models/src/payments/payment_attempt.rs @@ -1927,6 +1927,7 @@ impl PaymentAttemptUpdate { is_overcapture_enabled, authorized_amount, encrypted_payment_method_data: encrypted_payment_method_data.map(Encryption::from), + error_details: Box::new(None), }, Self::UnresolvedResponseUpdate { status, @@ -1948,6 +1949,7 @@ impl PaymentAttemptUpdate { error_reason, connector_response_reference_id, updated_by, + error_details: Box::new(None), }, Self::StatusUpdate { status, updated_by } => { DieselPaymentAttemptUpdate::StatusUpdate { status, updated_by } @@ -1986,6 +1988,7 @@ impl PaymentAttemptUpdate { issuer_error_message, network_details, encrypted_payment_method_data: encrypted_payment_method_data.map(Encryption::from), + error_details: Box::new(None), }, Self::CaptureUpdate { multiple_capture_count, @@ -2323,6 +2326,7 @@ impl behaviour::Conversion for PaymentAttempt { is_stored_credential: self.is_stored_credential, authorized_amount: self.authorized_amount, encrypted_payment_method_data: self.encrypted_payment_method_data.map(Encryption::from), + error_details: None, }) } @@ -2546,6 +2550,7 @@ impl behaviour::Conversion for PaymentAttempt { is_stored_credential: self.is_stored_credential, authorized_amount: self.authorized_amount, encrypted_payment_method_data: self.encrypted_payment_method_data.map(Encryption::from), + error_details: None, }) } } @@ -2727,6 +2732,7 @@ impl behaviour::Conversion for PaymentAttempt { tokenization: None, amount_captured, encrypted_payment_method_data: None, + error_details: None, }) } @@ -3017,6 +3023,7 @@ impl behaviour::Conversion for PaymentAttempt { authorized_amount, amount_captured: amount_details.amount_captured, encrypted_payment_method_data: None, + error_details: None, }) } } diff --git a/migrations/2025-12-12-120000_add_error_details_to_payment_attempt/down.sql b/migrations/2025-12-12-120000_add_error_details_to_payment_attempt/down.sql new file mode 100644 index 0000000000..7a4ac58bfa --- /dev/null +++ b/migrations/2025-12-12-120000_add_error_details_to_payment_attempt/down.sql @@ -0,0 +1,2 @@ +-- This file should undo anything in `up.sql` +ALTER TABLE payment_attempt DROP COLUMN IF EXISTS error_details; diff --git a/migrations/2025-12-12-120000_add_error_details_to_payment_attempt/up.sql b/migrations/2025-12-12-120000_add_error_details_to_payment_attempt/up.sql new file mode 100644 index 0000000000..7e34b41c07 --- /dev/null +++ b/migrations/2025-12-12-120000_add_error_details_to_payment_attempt/up.sql @@ -0,0 +1,2 @@ +-- Your SQL goes here +ALTER TABLE payment_attempt ADD COLUMN IF NOT EXISTS error_details JSONB;