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")]
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;

View File

@ -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 {}

View File

@ -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()

View File

@ -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,

View File

@ -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 {

View File

@ -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,
}
}
}

View File

@ -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(

View File

@ -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()))
}

View File

@ -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,

View File

@ -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)),
)

View File

@ -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,

View File

@ -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
}

View File

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