Bug: Add estimate endpoint




diff --git a/crates/api_models/src/lib.rs b/crates/api_models/src/lib.rs
index a2d643ca109..2c2eefe9259 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 2829d58b883..f7c33bd37ca 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<String>,
+
+    /// 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<String>,
+}
+
+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<String>,
+    /// Identifier for the associated item_price_id for the subscription.
+    pub item_price_id: Option<String>,
+    /// Idenctifier for the coupon code for the subscription.
+    pub coupon_code: Option<String>,
+    /// Identifier for customer.
+    pub customer_id: Option<common_utils::id_type::CustomerId>,
+    pub line_items: Vec<SubscriptionLineItem>,
+}
+
+#[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 f3e935c6dd5..70e14870ffb 100644
--- a/crates/hyperswitch_connectors/src/connectors/chargebee.rs
+++ b/crates/hyperswitch_connectors/src/connectors/chargebee.rs
@@ -1212,11 +1212,26 @@ impl
     ) -> CustomResult<String, errors::ConnectorError> {
         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 324418e22b7..cbc5c4ab45b 100644
--- a/crates/hyperswitch_connectors/src/connectors/chargebee/transformers.rs
+++ b/crates/hyperswitch_connectors/src/connectors/chargebee/transformers.rs
@@ -1036,6 +1036,7 @@ impl<F, T>
                 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<PrimitiveDateTime>,
     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<LineItem>,
     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<PrimitiveDateTime>,
+    #[serde(default, with = "common_utils::custom_serde::timestamp::option")]
+    pub date_to: Option<PrimitiveDateTime>,
     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 7280a2f3cca..722d627cb35 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<pii::SecretSerdeValue>,
+}
 
 #[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 183b2f7ed38..4e5f58a8907 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<SubscriptionStatus> for api_models::subscription::SubscriptionStatus {
     fn from(status: SubscriptionStatus) -> Self {
         match status {
@@ -82,7 +81,6 @@ pub struct SubscriptionPlanPrices {
     pub trial_period_unit: Option<PeriodUnit>,
 }
 
-#[cfg(feature = "v1")]
 impl From<SubscriptionPlanPrices> for api_models::subscription::SubscriptionPlanPrices {
     fn from(item: SubscriptionPlanPrices) -> Self {
         Self {
@@ -106,7 +104,6 @@ pub enum PeriodUnit {
     Year,
 }
 
-#[cfg(feature = "v1")]
 impl From<PeriodUnit> 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<PrimitiveDateTime>,
     pub line_items: Vec<SubscriptionLineItem>,
+    pub customer_id: Option<id_type::CustomerId>,
 }
 
+impl From<GetSubscriptionEstimateResponse>
+    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<String>,
 }
+
+impl From<SubscriptionLineItem> 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 9d27c6daa75..f1c70fe1f62 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<T, Req: Clone, Resp: Clone> RouterDataConversion<T, Req, Resp> for UasFlowData {
     fn from_old_router_data(
diff --git a/crates/router/src/core/subscription.rs b/crates/router/src/core/subscription.rs
index 2d3fd649669..8066d48d356 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<subscription_types::EstimateSubscriptionResponse> {
+    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 2e1d3b7ff7e..aa361ef8ce9 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<subscription_response_types::GetSubscriptionEstimateResponse> {
+        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 20ae7ddce02..7fdf59c2718 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 87513e6e23a..da8a2a23ec2 100644
--- a/crates/router/src/routes/lock_utils.rs
+++ b/crates/router/src/routes/lock_utils.rs
@@ -91,6 +91,7 @@ impl From<Flow> 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 46432810450..735f530d3c2 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<AppState>,
+    req: HttpRequest,
+    query: web::Query<subscription_types::EstimateSubscriptionQuery>,
+) -> 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 5200047db57..5366efcf365 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

## Type of Change
<!-- Put an `x` in the boxes that apply -->

- [ ] Bugfix
- [ ] New feature
- [ ] Enhancement
- [ ] Refactoring
- [ ] Dependency updates
- [ ] Documentation
- [ ] CI/CD

## Description
<!-- Describe your changes in detail -->
Add support in subscription to get an estimate for the provided plan_price

### Additional Changes

- [ ] This PR modifies the API contract
- [ ] This PR modifies the database schema
- [ ] This PR modifies application configuration/environment variables

<!--
Provide links to the files with corresponding changes.

Following are the paths where you can find config files:
1. `config`
2. `crates/router/src/configs`
3. `loadtest/config`
-->


## Motivation and Context
<!--
Why is this change required? What problem does it solve?
If it fixes an open issue, please link to the issue here.

If you don't have an issue, we'd recommend starting with one first so the PR
can focus on the implementation (unless it is an obvious bug or documentation fix
that will have little conversation).
-->


## How did you test it?
<!--
Did you write an integration/unit/API test to verify the code changes?
Or did you test this change manually (provide relevant screenshots)?
-->
1. Create merchant, API Key, billing processor
2. GET subscription estimate
Request
```
curl --location 'http://localhost:8080/subscriptions/estimate?item_price_id=standard-plan-USD-Monthly' \
--header 'Content-Type: application/json' \
--header 'Accept: application/json' \
--header 'X-Profile-Id: pro_BSjmSsJE4oqVfDdiJViI' \
--header 'api-key: dev_4IM0uzCEoKQ9lkSaTUEceuErgHTSnZhcc0CsjiSc1P2jVv5W4p9nYumBWwnJLqLb'
```
Response
```
{
    "amount": 1200,
    "currency": "USD",
    "plan_id": null,
    "item_price_id": null,
    "coupon_code": null,
    "customer_id": "Azyd6UUz25MpkdBC",
    "line_items": [
        {
            "item_id": "standard-plan-USD-Monthly",
            "item_type": "plan_item_price",
            "description": "Standard Plan",
            "amount": 1200,
            "currency": "USD",
            "quantity": 1
        }
    ]
}
```

## Checklist
<!-- Put an `x` in the boxes that apply -->

- [ ] I formatted the code `cargo +nightly fmt --all`
- [ ] I addressed lints thrown by `cargo clippy`
- [ ] I reviewed the submitted code
- [ ] I added unit tests for my changes where possible
