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>
This commit is contained in:
Jagan
2025-10-08 18:46:38 +05:30
committed by GitHub
parent 76da7b28bc
commit 15bc0a3b35
13 changed files with 231 additions and 24 deletions

View File

@ -44,7 +44,6 @@ pub mod relay;
#[cfg(feature = "v2")] #[cfg(feature = "v2")]
pub mod revenue_recovery_data_backfill; pub mod revenue_recovery_data_backfill;
pub mod routing; pub mod routing;
#[cfg(feature = "v1")]
pub mod subscription; pub mod subscription;
pub mod surcharge_decision_configs; pub mod surcharge_decision_configs;
pub mod three_ds_decision_rule; pub mod three_ds_decision_rule;

View File

@ -446,3 +446,52 @@ pub struct Invoice {
} }
impl ApiEventMetric for ConfirmSubscriptionResponse {} 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 {}

View File

@ -1212,11 +1212,26 @@ impl
) -> CustomResult<String, errors::ConnectorError> { ) -> CustomResult<String, errors::ConnectorError> {
let metadata: chargebee::ChargebeeMetadata = let metadata: chargebee::ChargebeeMetadata =
utils::to_connector_meta_from_secret(req.connector_meta_data.clone())?; utils::to_connector_meta_from_secret(req.connector_meta_data.clone())?;
let url = self
.base_url(connectors) let site = metadata.site.peek();
.to_string()
.replace("{{merchant_endpoint_prefix}}", metadata.site.peek()); let mut base = self.base_url(connectors).to_string();
Ok(format!("{url}v2/estimates/create_subscription_for_items"))
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 { fn get_content_type(&self) -> &'static str {
self.common_get_content_type() self.common_get_content_type()

View File

@ -1036,6 +1036,7 @@ impl<F, T>
currency: estimate.subscription_estimate.currency_code, currency: estimate.subscription_estimate.currency_code,
next_billing_at: estimate.subscription_estimate.next_billing_at, next_billing_at: estimate.subscription_estimate.next_billing_at,
credits_applied: Some(estimate.invoice_estimate.credits_applied), credits_applied: Some(estimate.invoice_estimate.credits_applied),
customer_id: Some(estimate.invoice_estimate.customer_id),
line_items: estimate line_items: estimate
.invoice_estimate .invoice_estimate
.line_items .line_items
@ -1215,6 +1216,7 @@ impl
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChargebeeSubscriptionEstimateRequest { pub struct ChargebeeSubscriptionEstimateRequest {
#[serde(rename = "subscription_items[item_price_id][0]")]
pub price_id: String, pub price_id: String,
} }
@ -1351,8 +1353,8 @@ pub struct SubscriptionEstimate {
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InvoiceEstimate { pub struct InvoiceEstimate {
pub recurring: bool, pub recurring: bool,
#[serde(with = "common_utils::custom_serde::iso8601")] #[serde(default, with = "common_utils::custom_serde::timestamp::option")]
pub date: PrimitiveDateTime, pub date: Option<PrimitiveDateTime>,
pub price_type: String, pub price_type: String,
pub sub_total: MinorUnit, pub sub_total: MinorUnit,
pub total: MinorUnit, pub total: MinorUnit,
@ -1361,7 +1363,7 @@ pub struct InvoiceEstimate {
pub amount_due: MinorUnit, pub amount_due: MinorUnit,
/// type of the object will be `invoice_estimate` /// type of the object will be `invoice_estimate`
pub object: String, pub object: String,
pub customer_id: String, pub customer_id: CustomerId,
pub line_items: Vec<LineItem>, pub line_items: Vec<LineItem>,
pub currency_code: enums::Currency, pub currency_code: enums::Currency,
pub round_off_amount: MinorUnit, pub round_off_amount: MinorUnit,
@ -1370,10 +1372,10 @@ pub struct InvoiceEstimate {
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LineItem { pub struct LineItem {
pub id: String, pub id: String,
#[serde(with = "common_utils::custom_serde::iso8601")] #[serde(default, with = "common_utils::custom_serde::timestamp::option")]
pub date_from: PrimitiveDateTime, pub date_from: Option<PrimitiveDateTime>,
#[serde(with = "common_utils::custom_serde::iso8601")] #[serde(default, with = "common_utils::custom_serde::timestamp::option")]
pub date_to: PrimitiveDateTime, pub date_to: Option<PrimitiveDateTime>,
pub unit_amount: MinorUnit, pub unit_amount: MinorUnit,
pub quantity: i64, pub quantity: i64,
pub amount: MinorUnit, pub amount: MinorUnit,

View File

@ -174,7 +174,9 @@ pub struct GetSubscriptionPlanPricesData {
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct GetSubscriptionEstimateData; pub struct GetSubscriptionEstimateData {
pub connector_meta_data: Option<pii::SecretSerdeValue>,
}
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct UasFlowData { pub struct UasFlowData {

View File

@ -36,7 +36,6 @@ pub enum SubscriptionStatus {
Created, Created,
} }
#[cfg(feature = "v1")]
impl From<SubscriptionStatus> for api_models::subscription::SubscriptionStatus { impl From<SubscriptionStatus> for api_models::subscription::SubscriptionStatus {
fn from(status: SubscriptionStatus) -> Self { fn from(status: SubscriptionStatus) -> Self {
match status { match status {
@ -82,7 +81,6 @@ pub struct SubscriptionPlanPrices {
pub trial_period_unit: Option<PeriodUnit>, pub trial_period_unit: Option<PeriodUnit>,
} }
#[cfg(feature = "v1")]
impl From<SubscriptionPlanPrices> for api_models::subscription::SubscriptionPlanPrices { impl From<SubscriptionPlanPrices> for api_models::subscription::SubscriptionPlanPrices {
fn from(item: SubscriptionPlanPrices) -> Self { fn from(item: SubscriptionPlanPrices) -> Self {
Self { Self {
@ -106,7 +104,6 @@ pub enum PeriodUnit {
Year, Year,
} }
#[cfg(feature = "v1")]
impl From<PeriodUnit> for api_models::subscription::PeriodUnit { impl From<PeriodUnit> for api_models::subscription::PeriodUnit {
fn from(unit: PeriodUnit) -> Self { fn from(unit: PeriodUnit) -> Self {
match unit { match unit {
@ -128,8 +125,28 @@ pub struct GetSubscriptionEstimateResponse {
pub currency: Currency, pub currency: Currency,
pub next_billing_at: Option<PrimitiveDateTime>, pub next_billing_at: Option<PrimitiveDateTime>,
pub line_items: Vec<SubscriptionLineItem>, 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)] #[derive(Debug, Clone)]
pub struct SubscriptionLineItem { pub struct SubscriptionLineItem {
pub item_id: String, pub item_id: String,
@ -141,3 +158,16 @@ pub struct SubscriptionLineItem {
pub quantity: i64, pub quantity: i64,
pub pricing_model: Option<String>, 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,
}
}
}

View File

@ -11,10 +11,11 @@ use hyperswitch_domain_models::{
flow_common_types::{ flow_common_types::{
AccessTokenFlowData, AuthenticationTokenFlowData, BillingConnectorInvoiceSyncFlowData, AccessTokenFlowData, AuthenticationTokenFlowData, BillingConnectorInvoiceSyncFlowData,
BillingConnectorPaymentsSyncFlowData, DisputesFlowData, ExternalAuthenticationFlowData, BillingConnectorPaymentsSyncFlowData, DisputesFlowData, ExternalAuthenticationFlowData,
ExternalVaultProxyFlowData, FilesFlowData, GetSubscriptionPlanPricesData, ExternalVaultProxyFlowData, FilesFlowData, GetSubscriptionEstimateData,
GetSubscriptionPlansData, GiftCardBalanceCheckFlowData, InvoiceRecordBackData, GetSubscriptionPlanPricesData, GetSubscriptionPlansData, GiftCardBalanceCheckFlowData,
MandateRevokeFlowData, PaymentFlowData, RefundFlowData, SubscriptionCreateData, InvoiceRecordBackData, MandateRevokeFlowData, PaymentFlowData, RefundFlowData,
SubscriptionCustomerData, UasFlowData, VaultConnectorFlowData, WebhookSourceVerifyData, SubscriptionCreateData, SubscriptionCustomerData, UasFlowData, VaultConnectorFlowData,
WebhookSourceVerifyData,
}, },
RouterDataV2, RouterDataV2,
}, },
@ -895,6 +896,7 @@ default_router_data_conversion!(GetSubscriptionPlansData);
default_router_data_conversion!(GetSubscriptionPlanPricesData); default_router_data_conversion!(GetSubscriptionPlanPricesData);
default_router_data_conversion!(SubscriptionCreateData); default_router_data_conversion!(SubscriptionCreateData);
default_router_data_conversion!(SubscriptionCustomerData); default_router_data_conversion!(SubscriptionCustomerData);
default_router_data_conversion!(GetSubscriptionEstimateData);
impl<T, Req: Clone, Resp: Clone> RouterDataConversion<T, Req, Resp> for UasFlowData { impl<T, Req: Clone, Resp: Clone> RouterDataConversion<T, Req, Resp> for UasFlowData {
fn from_old_router_data( fn from_old_router_data(

View File

@ -422,3 +422,27 @@ pub async fn get_subscription(
subscription.to_subscription_response(), 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()))
}

View File

@ -5,8 +5,8 @@ use common_utils::{ext_traits::ValueExt, pii};
use error_stack::ResultExt; use error_stack::ResultExt;
use hyperswitch_domain_models::{ use hyperswitch_domain_models::{
router_data_v2::flow_common_types::{ router_data_v2::flow_common_types::{
GetSubscriptionPlanPricesData, GetSubscriptionPlansData, InvoiceRecordBackData, GetSubscriptionEstimateData, GetSubscriptionPlanPricesData, GetSubscriptionPlansData,
SubscriptionCreateData, SubscriptionCustomerData, InvoiceRecordBackData, SubscriptionCreateData, SubscriptionCustomerData,
}, },
router_request_types::{ router_request_types::{
revenue_recovery::InvoiceRecordBackRequest, subscriptions as subscription_request_types, revenue_recovery::InvoiceRecordBackRequest, subscriptions as subscription_request_types,
@ -20,7 +20,10 @@ use hyperswitch_domain_models::{
use super::errors; use super::errors;
use crate::{ 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 { 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( pub async fn get_subscription_plans(
&self, &self,
state: &SessionState, state: &SessionState,

View File

@ -1187,6 +1187,7 @@ impl Subscription {
subscription::create_subscription(state, req, payload) subscription::create_subscription(state, req, payload)
}), }),
)) ))
.service(web::resource("/estimate").route(web::get().to(subscription::get_estimate)))
.service( .service(
web::resource("/plans").route(web::get().to(subscription::get_subscription_plans)), web::resource("/plans").route(web::get().to(subscription::get_subscription_plans)),
) )

View File

@ -91,6 +91,7 @@ impl From<Flow> for ApiIdentifier {
| Flow::ConfirmSubscription | Flow::ConfirmSubscription
| Flow::CreateAndConfirmSubscription | Flow::CreateAndConfirmSubscription
| Flow::GetSubscription | Flow::GetSubscription
| Flow::GetSubscriptionEstimate
| Flow::GetPlansForSubscription => Self::Subscription, | Flow::GetPlansForSubscription => Self::Subscription,
Flow::RetrieveForexFlow => Self::Forex, Flow::RetrieveForexFlow => Self::Forex,
Flow::AddToBlocklist => Self::Blocklist, Flow::AddToBlocklist => Self::Blocklist,

View File

@ -7,6 +7,7 @@ use std::str::FromStr;
use actix_web::{web, HttpRequest, HttpResponse, Responder}; use actix_web::{web, HttpRequest, HttpResponse, Responder};
use api_models::subscription as subscription_types; use api_models::subscription as subscription_types;
use error_stack::report;
use hyperswitch_domain_models::errors; use hyperswitch_domain_models::errors;
use router_env::{ use router_env::{
tracing::{self, instrument}, tracing::{self, instrument},
@ -252,3 +253,40 @@ pub async fn create_and_confirm_subscription(
)) ))
.await .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
}

View File

@ -273,6 +273,8 @@ pub enum Flow {
CreateAndConfirmSubscription, CreateAndConfirmSubscription,
/// Get Subscription flow /// Get Subscription flow
GetSubscription, GetSubscription,
/// Get Subscription estimate flow
GetSubscriptionEstimate,
/// Create dynamic routing /// Create dynamic routing
CreateDynamicRoutingConfig, CreateDynamicRoutingConfig,
/// Toggle dynamic routing /// Toggle dynamic routing