diff --git a/api-reference/docs.json b/api-reference/docs.json index c0418b1123..3081ef29c6 100644 --- a/api-reference/docs.json +++ b/api-reference/docs.json @@ -268,7 +268,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 fa97cf86f4..c656b067c1 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": "Get the Payment Method Balance", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApplyPaymentMethodDataResponse" + } + } + } + } + }, + "security": [ + { + "publishable_key": [] + } + ] + } + }, "/v2/payment-methods": { "post": { "tags": [ @@ -5262,6 +5320,65 @@ } } }, + "ApplyPaymentMethodDataRequest": { + "type": "object", + "required": [ + "payment_methods" + ], + "properties": { + "payment_methods": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BalanceCheckPaymentMethodData" + } + } + } + }, + "ApplyPaymentMethodDataResponse": { + "type": "object", + "required": [ + "remaining_amount", + "requires_additional_pm_data" + ], + "properties": { + "remaining_amount": { + "$ref": "#/components/schemas/MinorUnit" + }, + "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 aa6674d4f3..e93f3f7326 100644 --- a/crates/api_models/src/events/payment.rs +++ b/crates/api_models/src/events/payment.rs @@ -206,6 +206,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 e453f37e00..7460ef7489 100644 --- a/crates/api_models/src/payments.rs +++ b/crates/api_models/src/payments.rs @@ -3156,6 +3156,28 @@ 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, + 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/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 e5f44d5d33..d3663d766b 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, @@ -559,6 +560,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 80c98c8152..6d94263a4b 100644 --- a/crates/openapi/src/routes/payments.rs +++ b/crates/openapi/src/routes/payments.rs @@ -1313,3 +1313,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 = "Get the Payment Method Balance", 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/gift_card.rs b/crates/router/src/core/gift_card.rs index 76d1d8788f..23a3a380b3 100644 --- a/crates/router/src/core/gift_card.rs +++ b/crates/router/src/core/gift_card.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,52 @@ 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: i64 = balances + .values() + .map(|value| value.balance.get_amount_as_i64()) + .sum(); + + let remaining_amount = payment_intent + .amount_details + .order_amount + .get_amount_as_i64() + .saturating_sub(total_balance); + + let resp = ApplyPaymentMethodDataResponse { + remaining_amount: MinorUnit::new(remaining_amount), + requires_additional_pm_data: remaining_amount > 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 +287,59 @@ 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("PaymentMethodBalanceValue") + .change_context(errors::ApiErrorResponse::InternalServerError) + .attach_printable("Failed to parse PaymentMethodBalanceValue") + .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() + .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 83c1a77d5a..a5e1e548df 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 32b2ee2089..b1cb9f759a 100644 --- a/crates/router/src/routes/lock_utils.rs +++ b/crates/router/src/routes/lock_utils.rs @@ -163,6 +163,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 007ea9f9d7..bdb8b87c5a 100644 --- a/crates/router/src/routes/payments.rs +++ b/crates/router/src/routes/payments.rs @@ -3189,6 +3189,60 @@ pub async fn payment_check_gift_card_balance( .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(gift_card::payments_apply_pm_data_core( + state, + merchant_context, + req_state, + request, + 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::ProxyConfirmIntent, payment_id))] pub async fn proxy_confirm_intent( diff --git a/crates/router_env/src/logger/types.rs b/crates/router_env/src/logger/types.rs index 112cdecaec..a5fba495ae 100644 --- a/crates/router_env/src/logger/types.rs +++ b/crates/router_env/src/logger/types.rs @@ -682,6 +682,8 @@ pub enum Flow { PaymentMethodBalanceCheck, /// Payments Submit Eligibility flow PaymentsSubmitEligibility, + /// Apply payment method data flow + ApplyPaymentMethodData, } /// Trait for providing generic behaviour to flow metric