feat(subscription): get plans for subscription (#9251)

Co-authored-by: hyperswitch-bot[bot] <148525504+hyperswitch-bot[bot]@users.noreply.github.com>
Co-authored-by: Sarthak Soni <sarthakasoni@gmail.com>
Co-authored-by: Jagan <jaganelavarasan@gmail.com>
This commit is contained in:
Prajjwal Kumar
2025-10-07 13:18:59 +05:30
committed by GitHub
parent 8375629c62
commit b3beda7d71
15 changed files with 424 additions and 21 deletions

View File

@ -141,8 +141,58 @@ impl SubscriptionResponse {
}
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct GetPlansResponse {
pub plan_id: String,
pub name: String,
pub description: Option<String>,
pub price_id: Vec<SubscriptionPlanPrices>,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct SubscriptionPlanPrices {
pub price_id: String,
pub plan_id: Option<String>,
pub amount: MinorUnit,
pub currency: api_enums::Currency,
pub interval: PeriodUnit,
pub interval_count: i64,
pub trial_period: Option<i64>,
pub trial_period_unit: Option<PeriodUnit>,
}
#[derive(Debug, Clone, serde::Serialize)]
pub enum PeriodUnit {
Day,
Week,
Month,
Year,
}
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct ClientSecret(String);
impl ClientSecret {
pub fn new(secret: String) -> Self {
Self(secret)
}
pub fn as_str(&self) -> &str {
&self.0
}
}
#[derive(serde::Deserialize, serde::Serialize, Debug)]
pub struct GetPlansQuery {
pub client_secret: Option<ClientSecret>,
pub limit: Option<u32>,
pub offset: Option<u32>,
}
impl ApiEventMetric for SubscriptionResponse {}
impl ApiEventMetric for CreateSubscriptionRequest {}
impl ApiEventMetric for GetPlansQuery {}
impl ApiEventMetric for GetPlansResponse {}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, ToSchema)]
pub struct ConfirmSubscriptionPaymentDetails {
@ -227,6 +277,7 @@ pub struct PaymentResponseData {
pub error_code: Option<String>,
pub error_message: Option<String>,
pub payment_method_type: Option<api_enums::PaymentMethodType>,
pub client_secret: Option<String>,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, ToSchema)]
pub struct ConfirmSubscriptionRequest {

View File

@ -843,7 +843,13 @@ fn get_chargebee_plans_query_params(
) -> CustomResult<String, errors::ConnectorError> {
// Try to get limit from request, else default to 10
let limit = _req.request.limit.unwrap_or(10);
let param = format!("?limit={}&type[is]={}", limit, constants::PLAN_ITEM_TYPE);
let offset = _req.request.offset.unwrap_or(0);
let param = format!(
"?limit={}&offset={}&type[is]={}",
limit,
offset,
constants::PLAN_ITEM_TYPE
);
Ok(param)
}

View File

@ -1248,12 +1248,13 @@ pub struct ChargebeePlanPriceItem {
pub period: i64,
pub period_unit: ChargebeePeriodUnit,
pub trial_period: Option<i64>,
pub trial_period_unit: ChargebeeTrialPeriodUnit,
pub trial_period_unit: Option<ChargebeeTrialPeriodUnit>,
pub price: MinorUnit,
pub pricing_model: ChargebeePricingModel,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ChargebeePricingModel {
FlatFee,
PerUnit,
@ -1263,6 +1264,7 @@ pub enum ChargebeePricingModel {
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ChargebeePeriodUnit {
Day,
Week,
@ -1271,6 +1273,7 @@ pub enum ChargebeePeriodUnit {
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ChargebeeTrialPeriodUnit {
Day,
Month,
@ -1308,8 +1311,9 @@ impl<F, T>
interval_count: prices.item_price.period,
trial_period: prices.item_price.trial_period,
trial_period_unit: match prices.item_price.trial_period_unit {
ChargebeeTrialPeriodUnit::Day => Some(subscriptions::PeriodUnit::Day),
ChargebeeTrialPeriodUnit::Month => Some(subscriptions::PeriodUnit::Month),
Some(ChargebeeTrialPeriodUnit::Day) => Some(subscriptions::PeriodUnit::Day),
Some(ChargebeeTrialPeriodUnit::Month) => Some(subscriptions::PeriodUnit::Month),
None => None,
},
})
.collect();

View File

@ -37,6 +37,7 @@ pub mod router_flow_types;
pub mod router_request_types;
pub mod router_response_types;
pub mod routing;
pub mod subscription;
#[cfg(feature = "tokenization_v2")]
pub mod tokenization;
pub mod transformers;

View File

@ -29,6 +29,21 @@ pub struct GetSubscriptionPlansRequest {
pub offset: Option<u32>,
}
impl GetSubscriptionPlansRequest {
pub fn new(limit: Option<u32>, offset: Option<u32>) -> Self {
Self { limit, offset }
}
}
impl Default for GetSubscriptionPlansRequest {
fn default() -> Self {
Self {
limit: Some(10),
offset: Some(0),
}
}
}
#[derive(Debug, Clone)]
pub struct GetSubscriptionPlanPricesRequest {
pub plan_price_id: String,

View File

@ -33,6 +33,7 @@ pub enum SubscriptionStatus {
Onetime,
Cancelled,
Failed,
Created,
}
#[cfg(feature = "v1")]
@ -47,6 +48,7 @@ impl From<SubscriptionStatus> for api_models::subscription::SubscriptionStatus {
SubscriptionStatus::Onetime => Self::Onetime,
SubscriptionStatus::Cancelled => Self::Cancelled,
SubscriptionStatus::Failed => Self::Failed,
SubscriptionStatus::Created => Self::Created,
}
}
}
@ -80,6 +82,22 @@ 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 {
price_id: item.price_id,
plan_id: item.plan_id,
amount: item.amount,
currency: item.currency,
interval: item.interval.into(),
interval_count: item.interval_count,
trial_period: item.trial_period,
trial_period_unit: item.trial_period_unit.map(Into::into),
}
}
}
#[derive(Debug, Clone)]
pub enum PeriodUnit {
Day,
@ -88,6 +106,18 @@ pub enum PeriodUnit {
Year,
}
#[cfg(feature = "v1")]
impl From<PeriodUnit> for api_models::subscription::PeriodUnit {
fn from(unit: PeriodUnit) -> Self {
match unit {
PeriodUnit::Day => Self::Day,
PeriodUnit::Week => Self::Week,
PeriodUnit::Month => Self::Month,
PeriodUnit::Year => Self::Year,
}
}
}
#[derive(Debug, Clone)]
pub struct GetSubscriptionEstimateResponse {
pub sub_total: MinorUnit,

View File

@ -0,0 +1,50 @@
use common_utils::events::ApiEventMetric;
use error_stack::ResultExt;
use crate::errors::api_error_response::ApiErrorResponse;
const SECRET_SPLIT: &str = "_secret";
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct ClientSecret(String);
impl ClientSecret {
pub fn new(secret: String) -> Self {
Self(secret)
}
pub fn get_subscription_id(&self) -> error_stack::Result<String, ApiErrorResponse> {
let sub_id = self
.0
.split(SECRET_SPLIT)
.next()
.ok_or(ApiErrorResponse::MissingRequiredField {
field_name: "client_secret",
})
.attach_printable("Failed to extract subscription_id from client_secret")?;
Ok(sub_id.to_string())
}
}
impl std::fmt::Display for ClientSecret {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
impl ApiEventMetric for ClientSecret {}
#[cfg(feature = "v1")]
impl From<api_models::subscription::ClientSecret> for ClientSecret {
fn from(api_secret: api_models::subscription::ClientSecret) -> Self {
Self::new(api_secret.as_str().to_string())
}
}
#[cfg(feature = "v1")]
impl From<ClientSecret> for api_models::subscription::ClientSecret {
fn from(domain_secret: ClientSecret) -> Self {
Self::new(domain_secret.to_string())
}
}

View File

@ -4,7 +4,10 @@ use api_models::subscription::{
use common_enums::connector_enums;
use common_utils::id_type::GenerateId;
use error_stack::ResultExt;
use hyperswitch_domain_models::{api::ApplicationResponse, merchant_context::MerchantContext};
use hyperswitch_domain_models::{
api::ApplicationResponse, merchant_context::MerchantContext,
router_response_types::subscriptions as subscription_response_types,
};
use super::errors::{self, RouterResponse};
use crate::{
@ -28,7 +31,7 @@ pub async fn create_subscription(
merchant_context: MerchantContext,
profile_id: common_utils::id_type::ProfileId,
request: subscription_types::CreateSubscriptionRequest,
) -> RouterResponse<SubscriptionResponse> {
) -> RouterResponse<subscription_types::ConfirmSubscriptionResponse> {
let subscription_id = common_utils::id_type::SubscriptionId::generate();
let profile =
@ -43,7 +46,7 @@ pub async fn create_subscription(
&state,
merchant_context.get_merchant_account(),
merchant_context.get_merchant_key_store(),
customer,
Some(customer),
profile.clone(),
)
.await?;
@ -65,7 +68,7 @@ pub async fn create_subscription(
.create_payment_with_confirm_false(subscription.handler.state, &request)
.await
.attach_printable("subscriptions: failed to create payment")?;
invoice_handler
let invoice_entry = invoice_handler
.create_invoice_entry(
&state,
billing_handler.merchant_connector_id,
@ -88,9 +91,67 @@ pub async fn create_subscription(
.await
.attach_printable("subscriptions: failed to update subscription")?;
Ok(ApplicationResponse::Json(
subscription.to_subscription_response(),
))
let response = subscription.generate_response(
&invoice_entry,
&payment,
subscription_response_types::SubscriptionStatus::Created,
)?;
Ok(ApplicationResponse::Json(response))
}
pub async fn get_subscription_plans(
state: SessionState,
merchant_context: MerchantContext,
profile_id: common_utils::id_type::ProfileId,
query: subscription_types::GetPlansQuery,
) -> RouterResponse<Vec<subscription_types::GetPlansResponse>> {
let profile =
SubscriptionHandler::find_business_profile(&state, &merchant_context, &profile_id)
.await
.attach_printable("subscriptions: failed to find business profile")?;
let subscription_handler = SubscriptionHandler::new(&state, &merchant_context);
if let Some(client_secret) = query.client_secret {
subscription_handler
.find_and_validate_subscription(&client_secret.into())
.await?
};
let billing_handler = BillingHandler::create(
&state,
merchant_context.get_merchant_account(),
merchant_context.get_merchant_key_store(),
None,
profile.clone(),
)
.await?;
let get_plans_response = billing_handler
.get_subscription_plans(&state, query.limit, query.offset)
.await?;
let mut response = Vec::new();
for plan in &get_plans_response.list {
let plan_price_response = billing_handler
.get_subscription_plan_prices(&state, plan.subscription_provider_plan_id.clone())
.await?;
response.push(subscription_types::GetPlansResponse {
plan_id: plan.subscription_provider_plan_id.clone(),
name: plan.name.clone(),
description: plan.description.clone(),
price_id: plan_price_response
.list
.into_iter()
.map(subscription_types::SubscriptionPlanPrices::from)
.collect::<Vec<_>>(),
})
}
Ok(ApplicationResponse::Json(response))
}
/// Creates and confirms a subscription in one operation.
@ -116,7 +177,7 @@ pub async fn create_and_confirm_subscription(
&state,
merchant_context.get_merchant_account(),
merchant_context.get_merchant_key_store(),
customer,
Some(customer),
profile.clone(),
)
.await?;
@ -259,7 +320,7 @@ pub async fn confirm_subscription(
&state,
merchant_context.get_merchant_account(),
merchant_context.get_merchant_key_store(),
customer,
Some(customer),
profile.clone(),
)
.await?;

View File

@ -5,7 +5,8 @@ use common_utils::{ext_traits::ValueExt, pii};
use error_stack::ResultExt;
use hyperswitch_domain_models::{
router_data_v2::flow_common_types::{
InvoiceRecordBackData, SubscriptionCreateData, SubscriptionCustomerData,
GetSubscriptionPlanPricesData, GetSubscriptionPlansData, InvoiceRecordBackData,
SubscriptionCreateData, SubscriptionCustomerData,
},
router_request_types::{
revenue_recovery::InvoiceRecordBackRequest, subscriptions as subscription_request_types,
@ -27,7 +28,7 @@ pub struct BillingHandler {
pub connector_data: api_types::ConnectorData,
pub connector_params: hyperswitch_domain_models::connector_endpoints::ConnectorParams,
pub connector_metadata: Option<pii::SecretSerdeValue>,
pub customer: hyperswitch_domain_models::customer::Customer,
pub customer: Option<hyperswitch_domain_models::customer::Customer>,
pub merchant_connector_id: common_utils::id_type::MerchantConnectorAccountId,
}
@ -37,7 +38,7 @@ impl BillingHandler {
state: &SessionState,
merchant_account: &hyperswitch_domain_models::merchant_account::MerchantAccount,
key_store: &hyperswitch_domain_models::merchant_key_store::MerchantKeyStore,
customer: hyperswitch_domain_models::customer::Customer,
customer: Option<hyperswitch_domain_models::customer::Customer>,
profile: hyperswitch_domain_models::business_profile::Profile,
) -> errors::RouterResult<Self> {
let merchant_connector_id = profile.get_billing_processor_id()?;
@ -109,8 +110,15 @@ impl BillingHandler {
billing_address: Option<api_models::payments::Address>,
payment_method_data: Option<api_models::payments::PaymentMethodData>,
) -> errors::RouterResult<ConnectorCustomerResponseData> {
let customer =
self.customer
.as_ref()
.ok_or(errors::ApiErrorResponse::MissingRequiredField {
field_name: "customer",
})?;
let customer_req = ConnectorCustomerData {
email: self.customer.email.clone().map(pii::Email::from),
email: customer.email.clone().map(pii::Email::from),
payment_method_data: payment_method_data.clone().map(|pmd| pmd.into()),
description: None,
phone: None,
@ -275,6 +283,87 @@ impl BillingHandler {
}
}
pub async fn get_subscription_plans(
&self,
state: &SessionState,
limit: Option<u32>,
offset: Option<u32>,
) -> errors::RouterResult<subscription_response_types::GetSubscriptionPlansResponse> {
let get_plans_request =
subscription_request_types::GetSubscriptionPlansRequest::new(limit, offset);
let router_data = self.build_router_data(
state,
get_plans_request,
GetSubscriptionPlansData {
connector_meta_data: self.connector_metadata.clone(),
},
)?;
let connector_integration = self.connector_data.connector.get_connector_integration();
let response = self
.call_connector(
state,
router_data,
"get subscription plans",
connector_integration,
)
.await?;
match response {
Ok(resp) => Ok(resp),
Err(err) => Err(errors::ApiErrorResponse::ExternalConnectorError {
code: err.code,
message: err.message,
connector: self.connector_data.connector_name.to_string().clone(),
status_code: err.status_code,
reason: err.reason,
}
.into()),
}
}
pub async fn get_subscription_plan_prices(
&self,
state: &SessionState,
plan_price_id: String,
) -> errors::RouterResult<subscription_response_types::GetSubscriptionPlanPricesResponse> {
let get_plan_prices_request =
subscription_request_types::GetSubscriptionPlanPricesRequest { plan_price_id };
let router_data = self.build_router_data(
state,
get_plan_prices_request,
GetSubscriptionPlanPricesData {
connector_meta_data: self.connector_metadata.clone(),
},
)?;
let connector_integration = self.connector_data.connector.get_connector_integration();
let response = self
.call_connector(
state,
router_data,
"get subscription plan prices",
connector_integration,
)
.await?;
match response {
Ok(resp) => Ok(resp),
Err(err) => Err(errors::ApiErrorResponse::ExternalConnectorError {
code: err.code,
message: err.message,
connector: self.connector_data.connector_name.to_string().clone(),
status_code: err.status_code,
reason: err.reason,
}
.into()),
}
}
async fn call_connector<F, ResourceCommonData, Req, Resp>(
&self,
state: &SessionState,

View File

@ -5,6 +5,7 @@ use api_models::{
subscription::{self as subscription_types, SubscriptionResponse, SubscriptionStatus},
};
use common_enums::connector_enums;
use common_utils::{consts, ext_traits::OptionExt};
use diesel_models::subscription::SubscriptionNew;
use error_stack::ResultExt;
use hyperswitch_domain_models::{
@ -122,6 +123,60 @@ impl<'a> SubscriptionHandler<'a> {
})
}
pub async fn find_and_validate_subscription(
&self,
client_secret: &hyperswitch_domain_models::subscription::ClientSecret,
) -> errors::RouterResult<()> {
let subscription_id = client_secret.get_subscription_id()?;
let subscription = self
.state
.store
.find_by_merchant_id_subscription_id(
self.merchant_context.get_merchant_account().get_id(),
subscription_id.to_string(),
)
.await
.change_context(errors::ApiErrorResponse::GenericNotFoundError {
message: format!("Subscription not found for id: {subscription_id}"),
})
.attach_printable("Unable to find subscription")?;
self.validate_client_secret(client_secret, &subscription)?;
Ok(())
}
pub fn validate_client_secret(
&self,
client_secret: &hyperswitch_domain_models::subscription::ClientSecret,
subscription: &diesel_models::subscription::Subscription,
) -> errors::RouterResult<()> {
let stored_client_secret = subscription
.client_secret
.clone()
.get_required_value("client_secret")
.change_context(errors::ApiErrorResponse::MissingRequiredField {
field_name: "client_secret",
})
.attach_printable("client secret not found in db")?;
if client_secret.to_string() != stored_client_secret {
Err(errors::ApiErrorResponse::ClientSecretInvalid.into())
} else {
let current_timestamp = common_utils::date_time::now();
let session_expiry = subscription
.created_at
.saturating_add(time::Duration::seconds(consts::DEFAULT_SESSION_EXPIRY));
if current_timestamp > session_expiry {
Err(errors::ApiErrorResponse::ClientSecretExpired.into())
} else {
Ok(())
}
}
}
pub async fn find_subscription(
&self,
subscription_id: common_utils::id_type::SubscriptionId,

View File

@ -1187,6 +1187,9 @@ impl Subscription {
subscription::create_subscription(state, req, payload)
}),
))
.service(
web::resource("/plans").route(web::get().to(subscription::get_subscription_plans)),
)
.service(
web::resource("/{subscription_id}/confirm").route(web::post().to(
|state, req, id, payload| {

View File

@ -87,12 +87,11 @@ impl From<Flow> for ApiIdentifier {
| Flow::VolumeSplitOnRoutingType
| Flow::DecisionEngineDecideGatewayCall
| Flow::DecisionEngineGatewayFeedbackCall => Self::Routing,
Flow::CreateSubscription
| Flow::ConfirmSubscription
| Flow::CreateAndConfirmSubscription
| Flow::GetSubscription => Self::Subscription,
| Flow::GetSubscription
| Flow::GetPlansForSubscription => Self::Subscription,
Flow::RetrieveForexFlow => Self::Forex,
Flow::AddToBlocklist => Self::Blocklist,
Flow::DeleteFromBlocklist => Self::Blocklist,

View File

@ -60,6 +60,7 @@ pub async fn create_subscription(
Ok(id) => id,
Err(response) => return response,
};
Box::pin(oss_api::server_wrap(
flow,
state,
@ -104,6 +105,7 @@ pub async fn confirm_subscription(
Ok(id) => id,
Err(response) => return response,
};
Box::pin(oss_api::server_wrap(
flow,
state,
@ -136,6 +138,41 @@ pub async fn confirm_subscription(
.await
}
#[instrument(skip_all)]
pub async fn get_subscription_plans(
state: web::Data<AppState>,
req: HttpRequest,
query: web::Query<subscription_types::GetPlansQuery>,
) -> impl Responder {
let flow = Flow::GetPlansForSubscription;
let api_auth = auth::ApiKeyAuth::default();
let profile_id = match extract_profile_id(&req) {
Ok(profile_id) => profile_id,
Err(response) => return response,
};
let auth_data = match auth::is_ephemeral_auth(req.headers(), api_auth) {
Ok(auth) => auth,
Err(err) => return crate::services::api::log_and_return_error_response(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_subscription_plans(state, merchant_context, profile_id.clone(), query)
},
&*auth_data,
api_locking::LockAction::NotApplicable,
))
.await
}
/// Add support for get subscription by id
#[instrument(skip_all)]
pub async fn get_subscription(

View File

@ -171,7 +171,7 @@ impl<'a> InvoiceSyncHandler<'a> {
self.state,
&self.merchant_account,
&self.key_store,
self.customer.clone(),
Some(self.customer.clone()),
self.profile.clone(),
)
.await

View File

@ -265,6 +265,8 @@ pub enum Flow {
RoutingDeleteConfig,
/// Subscription create flow,
CreateSubscription,
/// Subscription get plans flow,
GetPlansForSubscription,
/// Subscription confirm flow,
ConfirmSubscription,
/// Subscription create and confirm flow,