diff --git a/crates/api_models/src/payment_methods.rs b/crates/api_models/src/payment_methods.rs index 44ff65b7b0..75c5f27df5 100644 --- a/crates/api_models/src/payment_methods.rs +++ b/crates/api_models/src/payment_methods.rs @@ -288,6 +288,14 @@ pub struct PaymentMethodMigrateResponse { pub network_transaction_id_migrated: Option, } +#[derive(Debug, serde::Serialize, ToSchema)] +pub struct PaymentMethodRecordUpdateResponse { + pub payment_method_id: String, + pub status: common_enums::PaymentMethodStatus, + pub network_transaction_id: Option, + pub connector_mandate_details: Option, +} + #[derive(Debug, Default, Clone, serde::Serialize, serde::Deserialize)] pub struct PaymentsMandateReference( pub HashMap, @@ -2646,6 +2654,28 @@ pub struct PaymentMethodRecord { pub network_token_requestor_ref_id: Option, } +#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)] +pub struct UpdatePaymentMethodRecord { + pub payment_method_id: String, + pub status: Option, + pub network_transaction_id: Option, + pub line_number: Option, + pub payment_instrument_id: Option>, + pub merchant_connector_id: Option, +} + +#[derive(Debug, serde::Serialize)] +pub struct PaymentMethodUpdateResponse { + pub payment_method_id: String, + pub status: Option, + pub network_transaction_id: Option, + pub connector_mandate_details: Option, + pub update_status: UpdateStatus, + #[serde(skip_serializing_if = "Option::is_none")] + pub update_error: Option, + pub line_number: Option, +} + #[derive(Debug, Default, serde::Serialize)] pub struct PaymentMethodMigrationResponse { pub line_number: Option, @@ -2673,6 +2703,13 @@ pub enum MigrationStatus { Failed, } +#[derive(Debug, Default, serde::Serialize)] +pub enum UpdateStatus { + Success, + #[default] + Failed, +} + impl PaymentMethodRecord { fn create_address(&self) -> Option { if self.billing_address_first_name.is_some() @@ -2731,6 +2768,12 @@ type PaymentMethodMigrationResponseType = ( PaymentMethodRecord, ); +#[cfg(feature = "v1")] +type PaymentMethodUpdateResponseType = ( + Result, + UpdatePaymentMethodRecord, +); + #[cfg(feature = "v1")] impl From for PaymentMethodMigrationResponse { fn from((response, record): PaymentMethodMigrationResponseType) -> Self { @@ -2761,6 +2804,32 @@ impl From for PaymentMethodMigrationResponse } } +#[cfg(feature = "v1")] +impl From for PaymentMethodUpdateResponse { + fn from((response, record): PaymentMethodUpdateResponseType) -> Self { + match response { + Ok(res) => Self { + payment_method_id: res.payment_method_id, + status: Some(res.status), + network_transaction_id: res.network_transaction_id, + connector_mandate_details: res.connector_mandate_details, + update_status: UpdateStatus::Success, + update_error: None, + line_number: record.line_number, + }, + Err(e) => Self { + payment_method_id: record.payment_method_id, + status: record.status, + network_transaction_id: record.network_transaction_id, + connector_mandate_details: None, + update_status: UpdateStatus::Failed, + update_error: Some(e), + line_number: record.line_number, + }, + } + } +} + impl TryFrom<( &PaymentMethodRecord, diff --git a/crates/diesel_models/src/payment_method.rs b/crates/diesel_models/src/payment_method.rs index 79d6865b72..34fff64e7d 100644 --- a/crates/diesel_models/src/payment_method.rs +++ b/crates/diesel_models/src/payment_method.rs @@ -245,6 +245,11 @@ pub enum PaymentMethodUpdate { connector_mandate_details: Option, network_transaction_id: Option>, }, + ConnectorNetworkTransactionIdStatusAndMandateDetailsUpdate { + connector_mandate_details: Option, + network_transaction_id: Option, + status: Option, + }, } #[cfg(feature = "v2")] @@ -673,6 +678,29 @@ impl From for PaymentMethodUpdateInternal { network_token_payment_method_data: None, scheme: None, }, + PaymentMethodUpdate::ConnectorNetworkTransactionIdStatusAndMandateDetailsUpdate { + connector_mandate_details, + network_transaction_id, + status, + } => Self { + metadata: None, + payment_method_data: None, + last_used_at: None, + status, + locker_id: None, + network_token_requestor_reference_id: None, + payment_method: None, + connector_mandate_details: connector_mandate_details + .map(|mandate_details| mandate_details.expose()), + network_transaction_id, + updated_by: None, + payment_method_issuer: None, + payment_method_type: None, + last_modified: common_utils::date_time::now(), + network_token_locker_id: None, + network_token_payment_method_data: None, + scheme: None, + }, } } } diff --git a/crates/hyperswitch_domain_models/src/payment_methods.rs b/crates/hyperswitch_domain_models/src/payment_methods.rs index 0fa3d5b4e0..003998d9cc 100644 --- a/crates/hyperswitch_domain_models/src/payment_methods.rs +++ b/crates/hyperswitch_domain_models/src/payment_methods.rs @@ -12,7 +12,7 @@ use common_utils::{ id_type, pii, type_name, types::keymanager, }; -use diesel_models::{enums as storage_enums, PaymentMethodUpdate}; +pub use diesel_models::{enums as storage_enums, PaymentMethodUpdate}; use error_stack::ResultExt; #[cfg(feature = "v1")] use masking::ExposeInterface; diff --git a/crates/router/src/core/payment_methods.rs b/crates/router/src/core/payment_methods.rs index 3a072e12cc..0662284228 100644 --- a/crates/router/src/core/payment_methods.rs +++ b/crates/router/src/core/payment_methods.rs @@ -1,4 +1,5 @@ pub mod cards; +pub mod migration; pub mod network_tokenization; pub mod surcharge_decision_configs; #[cfg(feature = "v1")] diff --git a/crates/router/src/core/payment_methods/migration.rs b/crates/router/src/core/payment_methods/migration.rs new file mode 100644 index 0000000000..d802e432ec --- /dev/null +++ b/crates/router/src/core/payment_methods/migration.rs @@ -0,0 +1,257 @@ +use actix_multipart::form::{self, bytes, text}; +use api_models::payment_methods as pm_api; +use common_utils::{errors::CustomResult, id_type}; +use csv::Reader; +use error_stack::ResultExt; +use hyperswitch_domain_models::{ + api::ApplicationResponse, errors::api_error_response as errors, merchant_context, + payment_methods::PaymentMethodUpdate, +}; +use masking::PeekInterface; +use rdkafka::message::ToBytes; +use router_env::logger; + +use crate::{core::errors::StorageErrorExt, routes::SessionState}; + +type PmMigrationResult = CustomResult, errors::ApiErrorResponse>; + +#[cfg(feature = "v1")] +pub async fn update_payment_methods( + state: &SessionState, + payment_methods: Vec, + merchant_id: &id_type::MerchantId, + merchant_context: &merchant_context::MerchantContext, +) -> PmMigrationResult> { + let mut result = Vec::with_capacity(payment_methods.len()); + + for record in payment_methods { + let update_res = + update_payment_method_record(state, record.clone(), merchant_id, merchant_context) + .await; + let res = match update_res { + Ok(ApplicationResponse::Json(response)) => Ok(response), + Err(e) => Err(e.to_string()), + _ => Err("Failed to update payment method".to_string()), + }; + + result.push(pm_api::PaymentMethodUpdateResponse::from((res, record))); + } + Ok(ApplicationResponse::Json(result)) +} + +#[cfg(feature = "v1")] +pub async fn update_payment_method_record( + state: &SessionState, + req: pm_api::UpdatePaymentMethodRecord, + merchant_id: &id_type::MerchantId, + merchant_context: &merchant_context::MerchantContext, +) -> CustomResult< + ApplicationResponse, + errors::ApiErrorResponse, +> { + use std::collections::HashMap; + + use common_enums::enums; + use common_utils::pii; + use hyperswitch_domain_models::mandates::{ + CommonMandateReference, PaymentsMandateReference, PaymentsMandateReferenceRecord, + PayoutsMandateReference, PayoutsMandateReferenceRecord, + }; + + let db = &*state.store; + let payment_method_id = req.payment_method_id.clone(); + let network_transaction_id = req.network_transaction_id.clone(); + let status = req.status; + + let payment_method = db + .find_payment_method( + &state.into(), + merchant_context.get_merchant_key_store(), + &payment_method_id, + merchant_context.get_merchant_account().storage_scheme, + ) + .await + .to_not_found_response(errors::ApiErrorResponse::PaymentMethodNotFound)?; + + if payment_method.merchant_id != *merchant_id { + return Err(errors::ApiErrorResponse::InvalidRequestData { + message: "Merchant ID in the request does not match the Merchant ID in the payment method record.".to_string(), + }.into()); + } + + // Process mandate details when both payment_instrument_id and merchant_connector_id are present + let pm_update = match (&req.payment_instrument_id, &req.merchant_connector_id) { + (Some(payment_instrument_id), Some(merchant_connector_id)) => { + let mandate_details = payment_method + .get_common_mandate_reference() + .change_context(errors::ApiErrorResponse::InternalServerError) + .attach_printable("Failed to deserialize to Payment Mandate Reference ")?; + + let mca = db + .find_by_merchant_connector_account_merchant_id_merchant_connector_id( + &state.into(), + merchant_context.get_merchant_account().get_id(), + merchant_connector_id, + merchant_context.get_merchant_key_store(), + ) + .await + .to_not_found_response( + errors::ApiErrorResponse::MerchantConnectorAccountNotFound { + id: merchant_connector_id.get_string_repr().to_string(), + }, + )?; + + let updated_connector_mandate_details = match mca.connector_type { + enums::ConnectorType::PayoutProcessor => { + // Handle PayoutsMandateReference + let mut existing_payouts_mandate = mandate_details + .payouts + .unwrap_or_else(|| PayoutsMandateReference(HashMap::new())); + + // Create new payout mandate record + let new_payout_record = PayoutsMandateReferenceRecord { + transfer_method_id: Some(payment_instrument_id.peek().to_string()), + }; + + // Check if record exists for this merchant_connector_id + if let Some(existing_record) = + existing_payouts_mandate.0.get_mut(merchant_connector_id) + { + if let Some(transfer_method_id) = &new_payout_record.transfer_method_id { + existing_record.transfer_method_id = Some(transfer_method_id.clone()); + } + } else { + // Insert new record in connector_mandate_details + existing_payouts_mandate + .0 + .insert(merchant_connector_id.clone(), new_payout_record); + } + + // Create updated CommonMandateReference preserving payments section + CommonMandateReference { + payments: mandate_details.payments, + payouts: Some(existing_payouts_mandate), + } + } + _ => { + // Handle PaymentsMandateReference + let mut existing_payments_mandate = mandate_details + .payments + .unwrap_or_else(|| PaymentsMandateReference(HashMap::new())); + + // Check if record exists for this merchant_connector_id + if let Some(existing_record) = + existing_payments_mandate.0.get_mut(merchant_connector_id) + { + existing_record.connector_mandate_id = + payment_instrument_id.peek().to_string(); + } else { + // Insert new record in connector_mandate_details + existing_payments_mandate.0.insert( + merchant_connector_id.clone(), + PaymentsMandateReferenceRecord { + connector_mandate_id: payment_instrument_id.peek().to_string(), + payment_method_type: None, + original_payment_authorized_amount: None, + original_payment_authorized_currency: None, + mandate_metadata: None, + connector_mandate_status: None, + connector_mandate_request_reference_id: None, + }, + ); + } + + // Create updated CommonMandateReference preserving payouts section + CommonMandateReference { + payments: Some(existing_payments_mandate), + payouts: mandate_details.payouts, + } + } + }; + + let connector_mandate_details_value = updated_connector_mandate_details + .get_mandate_details_value() + .map_err(|err| { + logger::error!("Failed to get get_mandate_details_value : {:?}", err); + errors::ApiErrorResponse::MandateUpdateFailed + })?; + + PaymentMethodUpdate::ConnectorNetworkTransactionIdStatusAndMandateDetailsUpdate { + connector_mandate_details: Some(pii::SecretSerdeValue::new( + connector_mandate_details_value, + )), + network_transaction_id, + status, + } + } + _ => { + // Update only network_transaction_id and status + PaymentMethodUpdate::NetworkTransactionIdAndStatusUpdate { + network_transaction_id, + status, + } + } + }; + + let response = db + .update_payment_method( + &state.into(), + merchant_context.get_merchant_key_store(), + payment_method, + pm_update, + merchant_context.get_merchant_account().storage_scheme, + ) + .await + .change_context(errors::ApiErrorResponse::InternalServerError) + .attach_printable(format!( + "Failed to update payment method for existing pm_id: {payment_method_id:?} in db", + ))?; + + logger::debug!("Payment method updated in db"); + + Ok(ApplicationResponse::Json( + pm_api::PaymentMethodRecordUpdateResponse { + payment_method_id: response.payment_method_id, + status: response.status, + network_transaction_id: response.network_transaction_id, + connector_mandate_details: response + .connector_mandate_details + .map(pii::SecretSerdeValue::new), + }, + )) +} + +#[derive(Debug, form::MultipartForm)] +pub struct PaymentMethodsUpdateForm { + #[multipart(limit = "1MB")] + pub file: bytes::Bytes, + + pub merchant_id: text::Text, +} + +fn parse_update_csv(data: &[u8]) -> csv::Result> { + let mut csv_reader = Reader::from_reader(data); + let mut records = Vec::new(); + let mut id_counter = 0; + for result in csv_reader.deserialize() { + let mut record: pm_api::UpdatePaymentMethodRecord = result?; + id_counter += 1; + record.line_number = Some(id_counter); + records.push(record); + } + Ok(records) +} + +type UpdateValidationResult = + Result<(id_type::MerchantId, Vec), errors::ApiErrorResponse>; + +impl PaymentMethodsUpdateForm { + pub fn validate_and_get_payment_method_records(self) -> UpdateValidationResult { + let records = parse_update_csv(self.file.data.to_bytes()).map_err(|e| { + errors::ApiErrorResponse::PreconditionFailed { + message: e.to_string(), + } + })?; + Ok((self.merchant_id.clone(), records)) + } +} diff --git a/crates/router/src/routes/app.rs b/crates/router/src/routes/app.rs index 690a433735..c48fcd1fec 100644 --- a/crates/router/src/routes/app.rs +++ b/crates/router/src/routes/app.rs @@ -1429,6 +1429,10 @@ impl PaymentMethods { web::resource("/migrate-batch") .route(web::post().to(payment_methods::migrate_payment_methods)), ) + .service( + web::resource("/update-batch") + .route(web::post().to(payment_methods::update_payment_methods)), + ) .service( web::resource("/tokenize-card") .route(web::post().to(payment_methods::tokenize_card_api)), diff --git a/crates/router/src/routes/lock_utils.rs b/crates/router/src/routes/lock_utils.rs index 27b66e5bb5..0c42909b45 100644 --- a/crates/router/src/routes/lock_utils.rs +++ b/crates/router/src/routes/lock_utils.rs @@ -121,6 +121,7 @@ impl From for ApiIdentifier { Flow::PaymentMethodsCreate | Flow::PaymentMethodsMigrate + | Flow::PaymentMethodsBatchUpdate | Flow::PaymentMethodsList | Flow::CustomerPaymentMethodsList | Flow::GetPaymentMethodTokenData diff --git a/crates/router/src/routes/payment_methods.rs b/crates/router/src/routes/payment_methods.rs index 08b84e4097..7aa1ba949b 100644 --- a/crates/router/src/routes/payment_methods.rs +++ b/crates/router/src/routes/payment_methods.rs @@ -21,7 +21,7 @@ use crate::{ core::{ api_locking, errors::{self, utils::StorageErrorExt}, - payment_methods::{self as payment_methods_routes, cards}, + payment_methods::{self as payment_methods_routes, cards, migration as update_migration}, }, services::{self, api, authentication as auth, authorization::permissions::Permission}, types::{ @@ -413,6 +413,46 @@ pub async fn migrate_payment_methods( .await } +#[cfg(all(feature = "v1", any(feature = "olap", feature = "oltp")))] +#[instrument(skip_all, fields(flow = ?Flow::PaymentMethodsBatchUpdate))] +pub async fn update_payment_methods( + state: web::Data, + req: HttpRequest, + MultipartForm(form): MultipartForm, +) -> HttpResponse { + let flow = Flow::PaymentMethodsBatchUpdate; + let (merchant_id, records) = match form.validate_and_get_payment_method_records() { + Ok((merchant_id, records)) => (merchant_id, records), + Err(e) => return api::log_and_return_error_response(e.into()), + }; + Box::pin(api::server_wrap( + flow, + state, + &req, + records, + |state, _, req, _| { + let merchant_id = merchant_id.clone(); + async move { + let (key_store, merchant_account) = + get_merchant_account(&state, &merchant_id).await?; + let merchant_context = domain::MerchantContext::NormalMerchant(Box::new( + domain::Context(merchant_account.clone(), key_store.clone()), + )); + Box::pin(update_migration::update_payment_methods( + &state, + req, + &merchant_id, + &merchant_context, + )) + .await + } + }, + &auth::AdminApiAuth, + api_locking::LockAction::NotApplicable, + )) + .await +} + #[cfg(feature = "v1")] #[instrument(skip_all, fields(flow = ?Flow::PaymentMethodSave))] pub async fn save_payment_method_api( diff --git a/crates/router_env/src/logger/types.rs b/crates/router_env/src/logger/types.rs index b758d98f49..ae5f34fd78 100644 --- a/crates/router_env/src/logger/types.rs +++ b/crates/router_env/src/logger/types.rs @@ -118,6 +118,8 @@ pub enum Flow { PaymentMethodsCreate, /// Payment methods migrate flow. PaymentMethodsMigrate, + /// Payment methods batch update flow. + PaymentMethodsBatchUpdate, /// Payment methods list flow. PaymentMethodsList, /// Payment method save flow