From 0637095f60035b9057a590971425db67feddf4a2 Mon Sep 17 00:00:00 2001 From: Anurag Thakur Date: Tue, 28 Oct 2025 15:13:34 +0530 Subject: [PATCH] feat(router): Add /apply-payment-method-data endpoint (v2) (#9868) Co-authored-by: hyperswitch-bot[bot] <148525504+hyperswitch-bot[bot]@users.noreply.github.com> --- api-reference/docs.json | 4 +- api-reference/v2/openapi_spec_v2.json | 121 ++++++++++++++++++ .../v2/payments/payments--apply-pm-data.mdx | 3 + crates/api_models/src/events/payment.rs | 7 + crates/api_models/src/payments.rs | 27 +++- crates/common_utils/src/types.rs | 1 + .../src/payment_methods.rs | 2 +- crates/openapi/src/openapi_v2.rs | 4 + crates/openapi/src/routes/payments.rs | 27 ++++ crates/router/src/core.rs | 4 +- ...gift_card.rs => payment_method_balance.rs} | 108 +++++++++++++++- crates/router/src/routes/app.rs | 4 + crates/router/src/routes/lock_utils.rs | 1 + crates/router/src/routes/payments.rs | 64 ++++++++- crates/router_env/src/logger/types.rs | 2 + 15 files changed, 366 insertions(+), 13 deletions(-) create mode 100644 api-reference/v2/payments/payments--apply-pm-data.mdx rename crates/router/src/core/{gift_card.rs => payment_method_balance.rs} (66%) diff --git a/api-reference/docs.json b/api-reference/docs.json index 23868956fa..b04e109108 100644 --- a/api-reference/docs.json +++ b/api-reference/docs.json @@ -281,7 +281,9 @@ "v2/payments/payments--get", "v2/payments/payments--create-and-confirm-intent", "v2/payments/payments--list", - "v2/payments/payments--gift-card-balance-check" + "v2/payments/payments--gift-card-balance-check", + "v2/payments/payments--apply-pm-data" + ] }, { diff --git a/api-reference/v2/openapi_spec_v2.json b/api-reference/v2/openapi_spec_v2.json index bd466e9570..6da1caf747 100644 --- a/api-reference/v2/openapi_spec_v2.json +++ b/api-reference/v2/openapi_spec_v2.json @@ -2651,6 +2651,64 @@ ] } }, + "/v2/payments/{id}/apply-payment-method-data": { + "post": { + "tags": [ + "Payments" + ], + "summary": "Payments - Apply PM Data", + "description": "Apply the payment method data and recalculate surcharge", + "operationId": "Apply Payment Method Data", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "The global payment id", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "X-Profile-Id", + "in": "header", + "description": "Profile ID associated to the payment intent", + "required": true, + "schema": { + "type": "string" + }, + "example": "pro_abcdefghijklmnop" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApplyPaymentMethodDataRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Apply the Payment Method Data", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApplyPaymentMethodDataResponse" + } + } + } + } + }, + "security": [ + { + "publishable_key": [] + } + ] + } + }, "/v2/payment-methods": { "post": { "tags": [ @@ -5273,6 +5331,69 @@ } } }, + "ApplyPaymentMethodDataRequest": { + "type": "object", + "required": [ + "payment_methods" + ], + "properties": { + "payment_methods": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BalanceCheckPaymentMethodData" + } + } + } + }, + "ApplyPaymentMethodDataResponse": { + "type": "object", + "required": [ + "remaining_amount", + "currency", + "requires_additional_pm_data" + ], + "properties": { + "remaining_amount": { + "$ref": "#/components/schemas/MinorUnit" + }, + "currency": { + "$ref": "#/components/schemas/Currency" + }, + "requires_additional_pm_data": { + "type": "boolean" + }, + "surcharge_details": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ApplyPaymentMethodDataSurchargeResponseItem" + }, + "nullable": true + } + } + }, + "ApplyPaymentMethodDataSurchargeResponseItem": { + "type": "object", + "required": [ + "payment_method_type", + "payment_method_subtype" + ], + "properties": { + "payment_method_type": { + "$ref": "#/components/schemas/PaymentMethod" + }, + "payment_method_subtype": { + "$ref": "#/components/schemas/PaymentMethodType" + }, + "surcharge_details": { + "allOf": [ + { + "$ref": "#/components/schemas/SurchargeDetailsResponse" + } + ], + "nullable": true + } + } + }, "AttemptStatus": { "type": "string", "description": "The status of the attempt", diff --git a/api-reference/v2/payments/payments--apply-pm-data.mdx b/api-reference/v2/payments/payments--apply-pm-data.mdx new file mode 100644 index 0000000000..1472e4333c --- /dev/null +++ b/api-reference/v2/payments/payments--apply-pm-data.mdx @@ -0,0 +1,3 @@ +--- +openapi: post /v2/payments/{id}/apply-payment-method-data +--- \ No newline at end of file diff --git a/crates/api_models/src/events/payment.rs b/crates/api_models/src/events/payment.rs index 5368842d2f..aa60e8f7fc 100644 --- a/crates/api_models/src/events/payment.rs +++ b/crates/api_models/src/events/payment.rs @@ -215,6 +215,13 @@ impl ApiEventMetric for payments::PaymentMethodBalanceCheckResponse { } } +#[cfg(feature = "v2")] +impl ApiEventMetric for payments::ApplyPaymentMethodDataResponse { + fn get_api_event_type(&self) -> Option { + None + } +} + #[cfg(feature = "v2")] impl ApiEventMetric for PaymentsRequest { fn get_api_event_type(&self) -> Option { diff --git a/crates/api_models/src/payments.rs b/crates/api_models/src/payments.rs index 4390b638f3..0f5752c622 100644 --- a/crates/api_models/src/payments.rs +++ b/crates/api_models/src/payments.rs @@ -71,12 +71,11 @@ use utoipa::ToSchema; #[cfg(feature = "v2")] use crate::mandates; -#[cfg(feature = "v2")] -use crate::payment_methods; use crate::{ admin::{self, MerchantConnectorInfo}, enums as api_enums, mandates::RecurringDetails, + payment_methods, }; #[cfg(feature = "v1")] use crate::{disputes, ephemeral_key::EphemeralKeyCreateResponse, refunds, ValidateFieldAndGet}; @@ -3174,6 +3173,30 @@ pub enum BalanceCheckPaymentMethodData { GiftCard(GiftCardData), } +#[derive(Debug, serde::Deserialize, serde::Serialize, Clone, ToSchema)] +pub struct ApplyPaymentMethodDataRequest { + pub payment_methods: Vec, +} + +#[derive(Debug, serde::Serialize, Clone, ToSchema)] +pub struct ApplyPaymentMethodDataResponse { + pub remaining_amount: MinorUnit, + #[schema(value_type = Currency)] + pub currency: common_enums::Currency, + pub requires_additional_pm_data: bool, + pub surcharge_details: Option>, +} + +#[derive(Debug, serde::Serialize, Clone, ToSchema)] +pub struct ApplyPaymentMethodDataSurchargeResponseItem { + #[schema(value_type = PaymentMethod)] + pub payment_method_type: api_enums::PaymentMethod, + #[schema(value_type = PaymentMethodType)] + pub payment_method_subtype: api_enums::PaymentMethodType, + #[schema(value_type = Option)] + pub surcharge_details: Option, +} + #[derive(serde::Deserialize, serde::Serialize, Debug, Clone, ToSchema, Eq, PartialEq)] #[serde(rename_all = "snake_case")] pub struct BHNGiftCardDetails { diff --git a/crates/common_utils/src/types.rs b/crates/common_utils/src/types.rs index 0cc1cf797e..f31b1f0f52 100644 --- a/crates/common_utils/src/types.rs +++ b/crates/common_utils/src/types.rs @@ -382,6 +382,7 @@ impl AmountConvertor for MinorUnitForConnector { Hash, ToSchema, PartialOrd, + Ord, )] #[diesel(sql_type = sql_types::BigInt)] pub struct MinorUnit(i64); diff --git a/crates/hyperswitch_domain_models/src/payment_methods.rs b/crates/hyperswitch_domain_models/src/payment_methods.rs index 9c37fe084b..30d8e8ef97 100644 --- a/crates/hyperswitch_domain_models/src/payment_methods.rs +++ b/crates/hyperswitch_domain_models/src/payment_methods.rs @@ -1203,7 +1203,7 @@ impl PaymentMethodBalanceKey { /// This struct stores the balance and currency information for a specific /// payment method to be stored in the HashMap in Redis #[cfg(feature = "v2")] -#[derive(Clone, Debug, serde::Serialize)] +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] pub struct PaymentMethodBalance { pub balance: common_utils::types::MinorUnit, pub currency: common_enums::Currency, diff --git a/crates/openapi/src/openapi_v2.rs b/crates/openapi/src/openapi_v2.rs index d5dce59d44..d1c78498e2 100644 --- a/crates/openapi/src/openapi_v2.rs +++ b/crates/openapi/src/openapi_v2.rs @@ -129,6 +129,7 @@ Never share your secret api keys. Keep them guarded and secure. routes::payments::list_payment_methods, routes::payments::payments_list, routes::payments::payment_check_gift_card_balance, + routes::payments::payments_apply_pm_data, //Routes for payment methods routes::payment_method::create_payment_method_api, @@ -560,6 +561,9 @@ Never share your secret api keys. Keep them guarded and secure. api_models::payments::BillingConnectorPaymentDetails, api_models::payments::PaymentMethodBalanceCheckResponse, api_models::payments::PaymentMethodBalanceCheckRequest, + api_models::payments::ApplyPaymentMethodDataRequest, + api_models::payments::ApplyPaymentMethodDataResponse, + api_models::payments::ApplyPaymentMethodDataSurchargeResponseItem, api_models::enums::PaymentConnectorTransmission, api_models::enums::TriggeredBy, api_models::payments::PaymentAttemptResponse, diff --git a/crates/openapi/src/routes/payments.rs b/crates/openapi/src/routes/payments.rs index 2fd535cd6a..de122d2950 100644 --- a/crates/openapi/src/routes/payments.rs +++ b/crates/openapi/src/routes/payments.rs @@ -1333,3 +1333,30 @@ pub fn payments_list() {} security(("publishable_key" = [])) )] pub fn payment_check_gift_card_balance() {} + +/// Payments - Apply PM Data +/// +/// Apply the payment method data and recalculate surcharge +#[cfg(feature = "v2")] +#[utoipa::path( + post, + path = "/v2/payments/{id}/apply-payment-method-data", + params( + ("id" = String, Path, description = "The global payment id"), + ( + "X-Profile-Id" = String, Header, + description = "Profile ID associated to the payment intent", + example = "pro_abcdefghijklmnop" + ), + ), + request_body( + content = ApplyPaymentMethodDataRequest, + ), + responses( + (status = 200, description = "Apply the Payment Method Data", body = ApplyPaymentMethodDataResponse), + ), + tag = "Payments", + operation_id = "Apply Payment Method Data", + security(("publishable_key" = [])) +)] +pub fn payments_apply_pm_data() {} diff --git a/crates/router/src/core.rs b/crates/router/src/core.rs index 2bbad32e49..863577cd8a 100644 --- a/crates/router/src/core.rs +++ b/crates/router/src/core.rs @@ -27,8 +27,6 @@ pub mod external_service_auth; pub mod files; #[cfg(feature = "frm")] pub mod fraud_check; -#[cfg(feature = "v2")] -pub mod gift_card; pub mod gsm; pub mod health_check; #[cfg(feature = "v1")] @@ -36,6 +34,8 @@ pub mod locker_migration; pub mod mandate; pub mod metrics; pub mod payment_link; +#[cfg(feature = "v2")] +pub mod payment_method_balance; pub mod payment_methods; pub mod payments; #[cfg(feature = "payouts")] diff --git a/crates/router/src/core/gift_card.rs b/crates/router/src/core/payment_method_balance.rs similarity index 66% rename from crates/router/src/core/gift_card.rs rename to crates/router/src/core/payment_method_balance.rs index 76d1d8788f..ca51024346 100644 --- a/crates/router/src/core/gift_card.rs +++ b/crates/router/src/core/payment_method_balance.rs @@ -1,10 +1,15 @@ -use std::marker::PhantomData; +use std::{collections::HashMap, marker::PhantomData}; use api_models::payments::{ - GetPaymentMethodType, PaymentMethodBalanceCheckRequest, PaymentMethodBalanceCheckResponse, + ApplyPaymentMethodDataRequest, ApplyPaymentMethodDataResponse, GetPaymentMethodType, + PaymentMethodBalanceCheckRequest, PaymentMethodBalanceCheckResponse, }; use common_enums::CallConnectorAction; -use common_utils::{ext_traits::Encode, id_type, types::MinorUnit}; +use common_utils::{ + ext_traits::{Encode, StringExt}, + id_type, + types::MinorUnit, +}; use error_stack::ResultExt; use hyperswitch_domain_models::{ payments::HeaderPayload, @@ -197,6 +202,49 @@ pub async fn payments_check_gift_card_balance_core( Ok(services::ApplicationResponse::Json(resp)) } +#[allow(clippy::too_many_arguments)] +pub async fn payments_apply_pm_data_core( + state: SessionState, + merchant_context: domain::MerchantContext, + _req_state: ReqState, + req: ApplyPaymentMethodDataRequest, + payment_id: id_type::GlobalPaymentId, +) -> RouterResponse { + let db = state.store.as_ref(); + let key_manager_state = &(&state).into(); + let storage_scheme = merchant_context.get_merchant_account().storage_scheme; + let payment_intent = db + .find_payment_intent_by_id( + key_manager_state, + &payment_id, + merchant_context.get_merchant_key_store(), + storage_scheme, + ) + .await + .to_not_found_response(errors::ApiErrorResponse::PaymentNotFound)?; + + let balances = + fetch_payment_methods_balances_from_redis(&state, &payment_intent.id, &req.payment_methods) + .await + .attach_printable("Failed to retrieve payment method balances from redis")?; + + let total_balance: MinorUnit = balances.values().map(|value| value.balance).sum(); + + // remaining_amount cannot be negative, hence using max with 0. This situation can arise when + // the gift card balance exceeds the order amount + let remaining_amount = + (payment_intent.amount_details.order_amount - total_balance).max(MinorUnit::zero()); + + let resp = ApplyPaymentMethodDataResponse { + remaining_amount, + currency: payment_intent.amount_details.currency, + requires_additional_pm_data: remaining_amount.is_greater_than(0), + surcharge_details: None, // TODO: Implement surcharge recalculation logic + }; + + Ok(services::ApplicationResponse::Json(resp)) +} + #[instrument(skip_all)] pub async fn persist_individual_pm_balance_details_in_redis<'a>( state: &SessionState, @@ -236,7 +284,61 @@ pub async fn persist_individual_pm_balance_details_in_redis<'a>( .await .change_context(errors::ApiErrorResponse::InternalServerError) .attach_printable("Failed to write to redis")?; + logger::debug!("Surcharge results stored in redis with key = {}", redis_key); } Ok(()) } + +pub async fn fetch_payment_methods_balances_from_redis( + state: &SessionState, + payment_intent_id: &id_type::GlobalPaymentId, + payment_methods: &[api_models::payments::BalanceCheckPaymentMethodData], +) -> errors::RouterResult> { + let redis_conn = state + .store + .get_redis_conn() + .change_context(errors::ApiErrorResponse::InternalServerError) + .attach_printable("Failed to get redis connection")?; + + let balance_data = domain::PaymentMethodBalanceData::new(payment_intent_id); + + let balance_values: HashMap = redis_conn + .get_hash_fields::>(&balance_data.get_pm_balance_redis_key().into()) + .await + .change_context(errors::ApiErrorResponse::InternalServerError) + .attach_printable("Failed to read payment method balance data from redis")? + .into_iter() + .map(|(key, value)| { + value + .parse_struct("PaymentMethodBalance") + .change_context(errors::ApiErrorResponse::InternalServerError) + .attach_printable("Failed to parse PaymentMethodBalance") + .map(|parsed| (key, parsed)) + }) + .collect::>>()?; + + payment_methods + .iter() + .map(|pm| { + let api_models::payments::BalanceCheckPaymentMethodData::GiftCard(gift_card_data) = pm; + let pm_balance_key = domain::PaymentMethodBalanceKey { + payment_method_type: common_enums::PaymentMethod::GiftCard, + payment_method_subtype: gift_card_data.get_payment_method_type(), + payment_method_key: domain::GiftCardData::from(gift_card_data.clone()) + .get_payment_method_key() + .change_context(errors::ApiErrorResponse::InvalidRequestData { + message: "Unable to get unique key for payment method".to_string(), + })? + .expose(), + }; + let redis_key = pm_balance_key.get_redis_key(); + let balance_value = balance_values.get(&redis_key).cloned().ok_or( + errors::ApiErrorResponse::GenericNotFoundError { + message: "Balance not found for one or more payment methods".to_string(), + }, + )?; + Ok((pm_balance_key, balance_value)) + }) + .collect::>>() +} diff --git a/crates/router/src/routes/app.rs b/crates/router/src/routes/app.rs index 223b385ddd..17f9422624 100644 --- a/crates/router/src/routes/app.rs +++ b/crates/router/src/routes/app.rs @@ -797,6 +797,10 @@ impl Payments { .route(web::post().to(payments::payment_check_gift_card_balance)), ), ) + .service( + web::resource("/apply-payment-method-data") + .route(web::post().to(payments::payments_apply_pm_data)), + ) .service( web::resource("/finish-redirection/{publishable_key}/{profile_id}") .route(web::get().to(payments::payments_finish_redirection)), diff --git a/crates/router/src/routes/lock_utils.rs b/crates/router/src/routes/lock_utils.rs index c6ca2f6a02..da222c0957 100644 --- a/crates/router/src/routes/lock_utils.rs +++ b/crates/router/src/routes/lock_utils.rs @@ -164,6 +164,7 @@ impl From for ApiIdentifier { | Flow::PaymentsCreateIntent | Flow::PaymentsGetIntent | Flow::PaymentMethodBalanceCheck + | Flow::ApplyPaymentMethodData | Flow::PaymentsPostSessionTokens | Flow::PaymentsUpdateMetadata | Flow::PaymentsUpdateIntent diff --git a/crates/router/src/routes/payments.rs b/crates/router/src/routes/payments.rs index 21043d93b3..7bbd2bf1f5 100644 --- a/crates/router/src/routes/payments.rs +++ b/crates/router/src/routes/payments.rs @@ -11,7 +11,7 @@ use router_env::{env, instrument, logger, tracing, types, Flow}; use super::app::ReqState; #[cfg(feature = "v2")] -use crate::core::gift_card; +use crate::core::payment_method_balance; #[cfg(feature = "v2")] use crate::core::revenue_recovery::api as recovery; use crate::{ @@ -3232,13 +3232,69 @@ pub async fn payment_check_gift_card_balance( let merchant_context = domain::MerchantContext::NormalMerchant(Box::new( domain::Context(auth.merchant_account, auth.key_store), )); - Box::pin(gift_card::payments_check_gift_card_balance_core( + Box::pin( + payment_method_balance::payments_check_gift_card_balance_core( + state, + merchant_context, + auth.profile, + req_state, + request, + header_payload.clone(), + payment_id, + ), + ) + .await + }, + auth::api_or_client_auth( + &auth::V2ApiKeyAuth { + is_connected_allowed: false, + is_platform_allowed: false, + }, + &auth::V2ClientAuth(common_utils::types::authentication::ResourceId::Payment( + global_payment_id, + )), + req.headers(), + ), + api_locking::LockAction::NotApplicable, + )) + .await +} + +#[cfg(feature = "v2")] +#[instrument(skip_all, fields(flow = ?Flow::ApplyPaymentMethodData, payment_id))] +pub async fn payments_apply_pm_data( + state: web::Data, + req: actix_web::HttpRequest, + json_payload: web::Json, + path: web::Path, +) -> impl Responder { + let flow = Flow::ApplyPaymentMethodData; + + let global_payment_id = path.into_inner(); + tracing::Span::current().record("payment_id", global_payment_id.get_string_repr()); + + let internal_payload = internal_payload_types::PaymentsGenericRequestWithResourceId { + global_payment_id: global_payment_id.clone(), + payload: json_payload.into_inner(), + }; + + Box::pin(api::server_wrap( + flow, + state, + &req, + internal_payload, + |state, auth: auth::AuthenticationData, req, req_state| async { + let payment_id = req.global_payment_id; + let request = req.payload; + + let merchant_context = domain::MerchantContext::NormalMerchant(Box::new( + domain::Context(auth.merchant_account, auth.key_store), + )); + Box::pin(payment_method_balance::payments_apply_pm_data_core( state, merchant_context, - auth.profile, req_state, request, - header_payload.clone(), payment_id, )) .await diff --git a/crates/router_env/src/logger/types.rs b/crates/router_env/src/logger/types.rs index 2a6ff58237..14369bcc1a 100644 --- a/crates/router_env/src/logger/types.rs +++ b/crates/router_env/src/logger/types.rs @@ -684,6 +684,8 @@ pub enum Flow { PaymentMethodBalanceCheck, /// Payments Submit Eligibility flow PaymentsSubmitEligibility, + /// Apply payment method data flow + ApplyPaymentMethodData, } /// Trait for providing generic behaviour to flow metric