From 15bc0a3b35b8ff37f68cc46a34d6cbf23e237dd1 Mon Sep 17 00:00:00 2001 From: Jagan Date: Wed, 8 Oct 2025 18:46:38 +0530 Subject: [PATCH] feat(subscription): Add endpoint to get Subscription estimate (#9637) Co-authored-by: hyperswitch-bot[bot] <148525504+hyperswitch-bot[bot]@users.noreply.github.com> Co-authored-by: Gaurav Rawat <104276743+GauravRawat369@users.noreply.github.com> --- crates/api_models/src/lib.rs | 1 - crates/api_models/src/subscription.rs | 49 +++++++++++++++++++ .../src/connectors/chargebee.rs | 25 ++++++++-- .../src/connectors/chargebee/transformers.rs | 16 +++--- .../src/router_data_v2/flow_common_types.rs | 4 +- .../router_response_types/subscriptions.rs | 36 ++++++++++++-- .../src/conversion_impls.rs | 10 ++-- crates/router/src/core/subscription.rs | 24 +++++++++ .../subscription/billing_processor_handler.rs | 48 ++++++++++++++++-- crates/router/src/routes/app.rs | 1 + crates/router/src/routes/lock_utils.rs | 1 + crates/router/src/routes/subscription.rs | 38 ++++++++++++++ crates/router_env/src/logger/types.rs | 2 + 13 files changed, 231 insertions(+), 24 deletions(-) diff --git a/crates/api_models/src/lib.rs b/crates/api_models/src/lib.rs index a2d643ca10..2c2eefe925 100644 --- a/crates/api_models/src/lib.rs +++ b/crates/api_models/src/lib.rs @@ -44,7 +44,6 @@ pub mod relay; #[cfg(feature = "v2")] pub mod revenue_recovery_data_backfill; pub mod routing; -#[cfg(feature = "v1")] pub mod subscription; pub mod surcharge_decision_configs; pub mod three_ds_decision_rule; diff --git a/crates/api_models/src/subscription.rs b/crates/api_models/src/subscription.rs index 2829d58b88..f7c33bd37c 100644 --- a/crates/api_models/src/subscription.rs +++ b/crates/api_models/src/subscription.rs @@ -446,3 +446,52 @@ pub struct Invoice { } impl ApiEventMetric for ConfirmSubscriptionResponse {} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, ToSchema)] +pub struct EstimateSubscriptionQuery { + /// Identifier for the associated subscription plan. + pub plan_id: Option, + + /// Identifier for the associated item_price_id for the subscription. + pub item_price_id: String, + + /// Idenctifier for the coupon code for the subscription. + pub coupon_code: Option, +} + +impl ApiEventMetric for EstimateSubscriptionQuery {} + +#[derive(Debug, Clone, serde::Serialize, ToSchema)] +pub struct EstimateSubscriptionResponse { + /// Estimated amount to be charged for the invoice. + pub amount: MinorUnit, + /// Currency for the amount. + pub currency: api_enums::Currency, + /// Identifier for the associated plan_id. + pub plan_id: Option, + /// Identifier for the associated item_price_id for the subscription. + pub item_price_id: Option, + /// Idenctifier for the coupon code for the subscription. + pub coupon_code: Option, + /// Identifier for customer. + pub customer_id: Option, + pub line_items: Vec, +} + +#[derive(Debug, Clone, serde::Serialize, ToSchema)] +pub struct SubscriptionLineItem { + /// Unique identifier for the line item. + pub item_id: String, + /// Type of the line item. + pub item_type: String, + /// Description of the line item. + pub description: String, + /// Amount for the line item. + pub amount: MinorUnit, + /// Currency for the line item + pub currency: common_enums::Currency, + /// Quantity of the line item. + pub quantity: i64, +} + +impl ApiEventMetric for EstimateSubscriptionResponse {} diff --git a/crates/hyperswitch_connectors/src/connectors/chargebee.rs b/crates/hyperswitch_connectors/src/connectors/chargebee.rs index f3e935c6dd..70e14870ff 100644 --- a/crates/hyperswitch_connectors/src/connectors/chargebee.rs +++ b/crates/hyperswitch_connectors/src/connectors/chargebee.rs @@ -1212,11 +1212,26 @@ impl ) -> CustomResult { let metadata: chargebee::ChargebeeMetadata = utils::to_connector_meta_from_secret(req.connector_meta_data.clone())?; - let url = self - .base_url(connectors) - .to_string() - .replace("{{merchant_endpoint_prefix}}", metadata.site.peek()); - Ok(format!("{url}v2/estimates/create_subscription_for_items")) + + let site = metadata.site.peek(); + + let mut base = self.base_url(connectors).to_string(); + + base = base.replace("{{merchant_endpoint_prefix}}", site); + base = base.replace("$", site); + + if base.contains("{{merchant_endpoint_prefix}}") || base.contains('$') { + return Err(errors::ConnectorError::InvalidConnectorConfig { + config: "Chargebee base_url has an unresolved placeholder (expected `$` or `{{merchant_endpoint_prefix}}`).", + } + .into()); + } + + if !base.ends_with('/') { + base.push('/'); + } + + Ok(format!("{base}v2/estimates/create_subscription_for_items")) } fn get_content_type(&self) -> &'static str { self.common_get_content_type() diff --git a/crates/hyperswitch_connectors/src/connectors/chargebee/transformers.rs b/crates/hyperswitch_connectors/src/connectors/chargebee/transformers.rs index 324418e22b..cbc5c4ab45 100644 --- a/crates/hyperswitch_connectors/src/connectors/chargebee/transformers.rs +++ b/crates/hyperswitch_connectors/src/connectors/chargebee/transformers.rs @@ -1036,6 +1036,7 @@ impl currency: estimate.subscription_estimate.currency_code, next_billing_at: estimate.subscription_estimate.next_billing_at, credits_applied: Some(estimate.invoice_estimate.credits_applied), + customer_id: Some(estimate.invoice_estimate.customer_id), line_items: estimate .invoice_estimate .line_items @@ -1215,6 +1216,7 @@ impl #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ChargebeeSubscriptionEstimateRequest { + #[serde(rename = "subscription_items[item_price_id][0]")] pub price_id: String, } @@ -1351,8 +1353,8 @@ pub struct SubscriptionEstimate { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct InvoiceEstimate { pub recurring: bool, - #[serde(with = "common_utils::custom_serde::iso8601")] - pub date: PrimitiveDateTime, + #[serde(default, with = "common_utils::custom_serde::timestamp::option")] + pub date: Option, pub price_type: String, pub sub_total: MinorUnit, pub total: MinorUnit, @@ -1361,7 +1363,7 @@ pub struct InvoiceEstimate { pub amount_due: MinorUnit, /// type of the object will be `invoice_estimate` pub object: String, - pub customer_id: String, + pub customer_id: CustomerId, pub line_items: Vec, pub currency_code: enums::Currency, pub round_off_amount: MinorUnit, @@ -1370,10 +1372,10 @@ pub struct InvoiceEstimate { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct LineItem { pub id: String, - #[serde(with = "common_utils::custom_serde::iso8601")] - pub date_from: PrimitiveDateTime, - #[serde(with = "common_utils::custom_serde::iso8601")] - pub date_to: PrimitiveDateTime, + #[serde(default, with = "common_utils::custom_serde::timestamp::option")] + pub date_from: Option, + #[serde(default, with = "common_utils::custom_serde::timestamp::option")] + pub date_to: Option, pub unit_amount: MinorUnit, pub quantity: i64, pub amount: MinorUnit, diff --git a/crates/hyperswitch_domain_models/src/router_data_v2/flow_common_types.rs b/crates/hyperswitch_domain_models/src/router_data_v2/flow_common_types.rs index 7280a2f3cc..722d627cb3 100644 --- a/crates/hyperswitch_domain_models/src/router_data_v2/flow_common_types.rs +++ b/crates/hyperswitch_domain_models/src/router_data_v2/flow_common_types.rs @@ -174,7 +174,9 @@ pub struct GetSubscriptionPlanPricesData { } #[derive(Debug, Clone)] -pub struct GetSubscriptionEstimateData; +pub struct GetSubscriptionEstimateData { + pub connector_meta_data: Option, +} #[derive(Debug, Clone)] pub struct UasFlowData { diff --git a/crates/hyperswitch_domain_models/src/router_response_types/subscriptions.rs b/crates/hyperswitch_domain_models/src/router_response_types/subscriptions.rs index 183b2f7ed3..4e5f58a890 100644 --- a/crates/hyperswitch_domain_models/src/router_response_types/subscriptions.rs +++ b/crates/hyperswitch_domain_models/src/router_response_types/subscriptions.rs @@ -36,7 +36,6 @@ pub enum SubscriptionStatus { Created, } -#[cfg(feature = "v1")] impl From for api_models::subscription::SubscriptionStatus { fn from(status: SubscriptionStatus) -> Self { match status { @@ -82,7 +81,6 @@ pub struct SubscriptionPlanPrices { pub trial_period_unit: Option, } -#[cfg(feature = "v1")] impl From for api_models::subscription::SubscriptionPlanPrices { fn from(item: SubscriptionPlanPrices) -> Self { Self { @@ -106,7 +104,6 @@ pub enum PeriodUnit { Year, } -#[cfg(feature = "v1")] impl From for api_models::subscription::PeriodUnit { fn from(unit: PeriodUnit) -> Self { match unit { @@ -128,8 +125,28 @@ pub struct GetSubscriptionEstimateResponse { pub currency: Currency, pub next_billing_at: Option, pub line_items: Vec, + pub customer_id: Option, } +impl From + for api_models::subscription::EstimateSubscriptionResponse +{ + fn from(value: GetSubscriptionEstimateResponse) -> Self { + Self { + amount: value.total, + currency: value.currency, + plan_id: None, + item_price_id: None, + coupon_code: None, + customer_id: value.customer_id, + line_items: value + .line_items + .into_iter() + .map(api_models::subscription::SubscriptionLineItem::from) + .collect(), + } + } +} #[derive(Debug, Clone)] pub struct SubscriptionLineItem { pub item_id: String, @@ -141,3 +158,16 @@ pub struct SubscriptionLineItem { pub quantity: i64, pub pricing_model: Option, } + +impl From for api_models::subscription::SubscriptionLineItem { + fn from(value: SubscriptionLineItem) -> Self { + Self { + item_id: value.item_id, + description: value.description, + item_type: value.item_type, + amount: value.amount, + currency: value.currency, + quantity: value.quantity, + } + } +} diff --git a/crates/hyperswitch_interfaces/src/conversion_impls.rs b/crates/hyperswitch_interfaces/src/conversion_impls.rs index 9d27c6daa7..f1c70fe1f6 100644 --- a/crates/hyperswitch_interfaces/src/conversion_impls.rs +++ b/crates/hyperswitch_interfaces/src/conversion_impls.rs @@ -11,10 +11,11 @@ use hyperswitch_domain_models::{ flow_common_types::{ AccessTokenFlowData, AuthenticationTokenFlowData, BillingConnectorInvoiceSyncFlowData, BillingConnectorPaymentsSyncFlowData, DisputesFlowData, ExternalAuthenticationFlowData, - ExternalVaultProxyFlowData, FilesFlowData, GetSubscriptionPlanPricesData, - GetSubscriptionPlansData, GiftCardBalanceCheckFlowData, InvoiceRecordBackData, - MandateRevokeFlowData, PaymentFlowData, RefundFlowData, SubscriptionCreateData, - SubscriptionCustomerData, UasFlowData, VaultConnectorFlowData, WebhookSourceVerifyData, + ExternalVaultProxyFlowData, FilesFlowData, GetSubscriptionEstimateData, + GetSubscriptionPlanPricesData, GetSubscriptionPlansData, GiftCardBalanceCheckFlowData, + InvoiceRecordBackData, MandateRevokeFlowData, PaymentFlowData, RefundFlowData, + SubscriptionCreateData, SubscriptionCustomerData, UasFlowData, VaultConnectorFlowData, + WebhookSourceVerifyData, }, RouterDataV2, }, @@ -895,6 +896,7 @@ default_router_data_conversion!(GetSubscriptionPlansData); default_router_data_conversion!(GetSubscriptionPlanPricesData); default_router_data_conversion!(SubscriptionCreateData); default_router_data_conversion!(SubscriptionCustomerData); +default_router_data_conversion!(GetSubscriptionEstimateData); impl RouterDataConversion for UasFlowData { fn from_old_router_data( diff --git a/crates/router/src/core/subscription.rs b/crates/router/src/core/subscription.rs index 2d3fd64966..8066d48d35 100644 --- a/crates/router/src/core/subscription.rs +++ b/crates/router/src/core/subscription.rs @@ -422,3 +422,27 @@ pub async fn get_subscription( subscription.to_subscription_response(), )) } + +pub async fn get_estimate( + state: SessionState, + merchant_context: MerchantContext, + profile_id: common_utils::id_type::ProfileId, + query: subscription_types::EstimateSubscriptionQuery, +) -> RouterResponse { + let profile = + SubscriptionHandler::find_business_profile(&state, &merchant_context, &profile_id) + .await + .attach_printable("subscriptions: failed to find business profile in get_estimate")?; + let billing_handler = BillingHandler::create( + &state, + merchant_context.get_merchant_account(), + merchant_context.get_merchant_key_store(), + None, + profile, + ) + .await?; + let estimate = billing_handler + .get_subscription_estimate(&state, query) + .await?; + Ok(ApplicationResponse::Json(estimate.into())) +} diff --git a/crates/router/src/core/subscription/billing_processor_handler.rs b/crates/router/src/core/subscription/billing_processor_handler.rs index 2e1d3b7ff7..aa361ef8ce 100644 --- a/crates/router/src/core/subscription/billing_processor_handler.rs +++ b/crates/router/src/core/subscription/billing_processor_handler.rs @@ -5,8 +5,8 @@ use common_utils::{ext_traits::ValueExt, pii}; use error_stack::ResultExt; use hyperswitch_domain_models::{ router_data_v2::flow_common_types::{ - GetSubscriptionPlanPricesData, GetSubscriptionPlansData, InvoiceRecordBackData, - SubscriptionCreateData, SubscriptionCustomerData, + GetSubscriptionEstimateData, GetSubscriptionPlanPricesData, GetSubscriptionPlansData, + InvoiceRecordBackData, SubscriptionCreateData, SubscriptionCustomerData, }, router_request_types::{ revenue_recovery::InvoiceRecordBackRequest, subscriptions as subscription_request_types, @@ -20,7 +20,10 @@ use hyperswitch_domain_models::{ use super::errors; use crate::{ - core::payments as payments_core, routes::SessionState, services, types::api as api_types, + core::{payments as payments_core, subscription::subscription_types}, + routes::SessionState, + services, + types::api as api_types, }; pub struct BillingHandler { @@ -283,6 +286,45 @@ impl BillingHandler { } } + pub async fn get_subscription_estimate( + &self, + state: &SessionState, + estimate_request: subscription_types::EstimateSubscriptionQuery, + ) -> errors::RouterResult { + let estimate_req = subscription_request_types::GetSubscriptionEstimateRequest { + price_id: estimate_request.item_price_id.clone(), + }; + + let router_data = self.build_router_data( + state, + estimate_req, + GetSubscriptionEstimateData { + connector_meta_data: self.connector_metadata.clone(), + }, + )?; + let connector_integration = self.connector_data.connector.get_connector_integration(); + + let response = Box::pin(self.call_connector( + state, + router_data, + "get subscription estimate from connector", + connector_integration, + )) + .await?; + + match response { + Ok(response_data) => Ok(response_data), + Err(err) => Err(errors::ApiErrorResponse::ExternalConnectorError { + code: err.code, + message: err.message, + connector: self.connector_data.connector_name.to_string(), + status_code: err.status_code, + reason: err.reason, + } + .into()), + } + } + pub async fn get_subscription_plans( &self, state: &SessionState, diff --git a/crates/router/src/routes/app.rs b/crates/router/src/routes/app.rs index 20ae7ddce0..7fdf59c271 100644 --- a/crates/router/src/routes/app.rs +++ b/crates/router/src/routes/app.rs @@ -1187,6 +1187,7 @@ impl Subscription { subscription::create_subscription(state, req, payload) }), )) + .service(web::resource("/estimate").route(web::get().to(subscription::get_estimate))) .service( web::resource("/plans").route(web::get().to(subscription::get_subscription_plans)), ) diff --git a/crates/router/src/routes/lock_utils.rs b/crates/router/src/routes/lock_utils.rs index 87513e6e23..da8a2a23ec 100644 --- a/crates/router/src/routes/lock_utils.rs +++ b/crates/router/src/routes/lock_utils.rs @@ -91,6 +91,7 @@ impl From for ApiIdentifier { | Flow::ConfirmSubscription | Flow::CreateAndConfirmSubscription | Flow::GetSubscription + | Flow::GetSubscriptionEstimate | Flow::GetPlansForSubscription => Self::Subscription, Flow::RetrieveForexFlow => Self::Forex, Flow::AddToBlocklist => Self::Blocklist, diff --git a/crates/router/src/routes/subscription.rs b/crates/router/src/routes/subscription.rs index 4643281045..735f530d3c 100644 --- a/crates/router/src/routes/subscription.rs +++ b/crates/router/src/routes/subscription.rs @@ -7,6 +7,7 @@ use std::str::FromStr; use actix_web::{web, HttpRequest, HttpResponse, Responder}; use api_models::subscription as subscription_types; +use error_stack::report; use hyperswitch_domain_models::errors; use router_env::{ tracing::{self, instrument}, @@ -252,3 +253,40 @@ pub async fn create_and_confirm_subscription( )) .await } + +/// add support for get subscription estimate +#[instrument(skip_all)] +pub async fn get_estimate( + state: web::Data, + req: HttpRequest, + query: web::Query, +) -> impl Responder { + let flow = Flow::GetSubscriptionEstimate; + let profile_id = match extract_profile_id(&req) { + Ok(id) => id, + Err(response) => return response, + }; + let api_auth = auth::ApiKeyAuth { + is_connected_allowed: false, + is_platform_allowed: false, + }; + let (auth_type, _auth_flow) = match auth::get_auth_type_and_flow(req.headers(), api_auth) { + Ok(auth) => auth, + Err(err) => return oss_api::log_and_return_error_response(report!(err)), + }; + Box::pin(oss_api::server_wrap( + flow, + state, + &req, + query.into_inner(), + |state, auth: auth::AuthenticationData, query, _| { + let merchant_context = domain::MerchantContext::NormalMerchant(Box::new( + domain::Context(auth.merchant_account, auth.key_store), + )); + subscription::get_estimate(state, merchant_context, profile_id.clone(), query) + }, + &*auth_type, + api_locking::LockAction::NotApplicable, + )) + .await +} diff --git a/crates/router_env/src/logger/types.rs b/crates/router_env/src/logger/types.rs index 5200047db5..5366efcf36 100644 --- a/crates/router_env/src/logger/types.rs +++ b/crates/router_env/src/logger/types.rs @@ -273,6 +273,8 @@ pub enum Flow { CreateAndConfirmSubscription, /// Get Subscription flow GetSubscription, + /// Get Subscription estimate flow + GetSubscriptionEstimate, /// Create dynamic routing CreateDynamicRoutingConfig, /// Toggle dynamic routing