diff --git a/crates/api_models/src/lib.rs b/crates/api_models/src/lib.rs index 16ad859f69..a2d643ca10 100644 --- a/crates/api_models/src/lib.rs +++ b/crates/api_models/src/lib.rs @@ -44,6 +44,8 @@ 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; #[cfg(feature = "tokenization_v2")] diff --git a/crates/api_models/src/subscription.rs b/crates/api_models/src/subscription.rs new file mode 100644 index 0000000000..7dff21205a --- /dev/null +++ b/crates/api_models/src/subscription.rs @@ -0,0 +1,109 @@ +use common_utils::events::ApiEventMetric; +use masking::Secret; +use utoipa::ToSchema; + +// use crate::{ +// customers::{CustomerRequest, CustomerResponse}, +// payments::CustomerDetailsResponse, +// }; + +/// Request payload for creating a subscription. +/// +/// This struct captures details required to create a subscription, +/// including plan, profile, merchant connector, and optional customer info. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, ToSchema)] +pub struct CreateSubscriptionRequest { + /// Merchant specific Unique identifier. + pub merchant_reference_id: Option, + + /// Identifier for the subscription plan. + pub plan_id: Option, + + /// Optional coupon code applied to the subscription. + pub coupon_code: Option, + + /// customer ID associated with this subscription. + pub customer_id: common_utils::id_type::CustomerId, +} + +/// Response payload returned after successfully creating a subscription. +/// +/// Includes details such as subscription ID, status, plan, merchant, and customer info. +#[derive(Debug, Clone, serde::Serialize, ToSchema)] +pub struct CreateSubscriptionResponse { + /// Unique identifier for the subscription. + pub id: common_utils::id_type::SubscriptionId, + + /// Merchant specific Unique identifier. + pub merchant_reference_id: Option, + + /// Current status of the subscription. + pub status: SubscriptionStatus, + + /// Identifier for the associated subscription plan. + pub plan_id: Option, + + /// Associated profile ID. + pub profile_id: common_utils::id_type::ProfileId, + + /// Optional client secret used for secure client-side interactions. + pub client_secret: Option>, + + /// Merchant identifier owning this subscription. + pub merchant_id: common_utils::id_type::MerchantId, + + /// Optional coupon code applied to this subscription. + pub coupon_code: Option, + + /// Optional customer ID associated with this subscription. + pub customer_id: common_utils::id_type::CustomerId, +} + +/// Possible states of a subscription lifecycle. +/// +/// - `Created`: Subscription was created but not yet activated. +/// - `Active`: Subscription is currently active. +/// - `InActive`: Subscription is inactive (e.g., cancelled or expired). +#[derive(Debug, Clone, serde::Serialize, strum::EnumString, strum::Display, ToSchema)] +pub enum SubscriptionStatus { + /// Subscription is active. + Active, + /// Subscription is created but not yet active. + Created, + /// Subscription is inactive. + InActive, + /// Subscription is in pending state. + Pending, +} + +impl CreateSubscriptionResponse { + /// Creates a new [`CreateSubscriptionResponse`] with the given identifiers. + /// + /// By default, `client_secret`, `coupon_code`, and `customer` fields are `None`. + #[allow(clippy::too_many_arguments)] + pub fn new( + id: common_utils::id_type::SubscriptionId, + merchant_reference_id: Option, + status: SubscriptionStatus, + plan_id: Option, + profile_id: common_utils::id_type::ProfileId, + merchant_id: common_utils::id_type::MerchantId, + client_secret: Option>, + customer_id: common_utils::id_type::CustomerId, + ) -> Self { + Self { + id, + merchant_reference_id, + status, + plan_id, + profile_id, + client_secret, + merchant_id, + coupon_code: None, + customer_id, + } + } +} + +impl ApiEventMetric for CreateSubscriptionResponse {} +impl ApiEventMetric for CreateSubscriptionRequest {} diff --git a/crates/common_enums/src/enums.rs b/crates/common_enums/src/enums.rs index bbb1be10b8..03ceab3f9a 100644 --- a/crates/common_enums/src/enums.rs +++ b/crates/common_enums/src/enums.rs @@ -8412,6 +8412,7 @@ pub enum Resource { RunRecon, ReconConfig, RevenueRecovery, + Subscription, InternalConnector, Theme, } diff --git a/crates/common_utils/src/events.rs b/crates/common_utils/src/events.rs index 49be22fc93..dc8f6e333a 100644 --- a/crates/common_utils/src/events.rs +++ b/crates/common_utils/src/events.rs @@ -85,6 +85,7 @@ pub enum ApiEventsType { payment_id: Option, }, Routing, + Subscription, ResourceListAPI, #[cfg(feature = "v1")] PaymentRedirectionResponse { diff --git a/crates/common_utils/src/id_type.rs b/crates/common_utils/src/id_type.rs index 6d73a90eab..237ca661c1 100644 --- a/crates/common_utils/src/id_type.rs +++ b/crates/common_utils/src/id_type.rs @@ -17,6 +17,7 @@ mod profile_acquirer; mod refunds; mod relay; mod routing; +mod subscription; mod tenant; use std::{borrow::Cow, fmt::Debug}; @@ -55,6 +56,7 @@ pub use self::{ refunds::RefundReferenceId, relay::RelayId, routing::RoutingId, + subscription::SubscriptionId, tenant::TenantId, }; use crate::{fp_utils::when, generate_id_with_default_len}; diff --git a/crates/common_utils/src/id_type/subscription.rs b/crates/common_utils/src/id_type/subscription.rs new file mode 100644 index 0000000000..20f0c483fa --- /dev/null +++ b/crates/common_utils/src/id_type/subscription.rs @@ -0,0 +1,21 @@ +crate::id_type!( + SubscriptionId, + " A type for subscription_id that can be used for subscription ids" +); + +crate::impl_id_type_methods!(SubscriptionId, "subscription_id"); + +// This is to display the `SubscriptionId` as SubscriptionId(subs) +crate::impl_debug_id_type!(SubscriptionId); +crate::impl_try_from_cow_str_id_type!(SubscriptionId, "subscription_id"); + +crate::impl_generate_id_id_type!(SubscriptionId, "subscription"); +crate::impl_serializable_secret_id_type!(SubscriptionId); +crate::impl_queryable_id_type!(SubscriptionId); +crate::impl_to_sql_from_sql_id_type!(SubscriptionId); + +impl crate::events::ApiEventMetric for SubscriptionId { + fn get_api_event_type(&self) -> Option { + Some(crate::events::ApiEventsType::Subscription) + } +} diff --git a/crates/diesel_models/src/query/subscription.rs b/crates/diesel_models/src/query/subscription.rs index e25f161787..7a6bdd19c1 100644 --- a/crates/diesel_models/src/query/subscription.rs +++ b/crates/diesel_models/src/query/subscription.rs @@ -19,13 +19,13 @@ impl Subscription { pub async fn find_by_merchant_id_subscription_id( conn: &PgPooledConn, merchant_id: &common_utils::id_type::MerchantId, - subscription_id: String, + id: String, ) -> StorageResult { generics::generic_find_one::<::Table, _, _>( conn, dsl::merchant_id .eq(merchant_id.to_owned()) - .and(dsl::subscription_id.eq(subscription_id.to_owned())), + .and(dsl::id.eq(id.to_owned())), ) .await } @@ -33,7 +33,7 @@ impl Subscription { pub async fn update_subscription_entry( conn: &PgPooledConn, merchant_id: &common_utils::id_type::MerchantId, - subscription_id: String, + id: String, subscription_update: SubscriptionUpdate, ) -> StorageResult { generics::generic_update_with_results::< @@ -43,8 +43,8 @@ impl Subscription { _, >( conn, - dsl::subscription_id - .eq(subscription_id.to_owned()) + dsl::id + .eq(id.to_owned()) .and(dsl::merchant_id.eq(merchant_id.to_owned())), subscription_update, ) diff --git a/crates/diesel_models/src/schema.rs b/crates/diesel_models/src/schema.rs index 271d2a79ba..c02a9748f8 100644 --- a/crates/diesel_models/src/schema.rs +++ b/crates/diesel_models/src/schema.rs @@ -1530,9 +1530,9 @@ diesel::table! { use diesel::sql_types::*; use crate::enums::diesel_exports::*; - subscription (subscription_id, merchant_id) { + subscription (id) { #[max_length = 128] - subscription_id -> Varchar, + id -> Varchar, #[max_length = 128] status -> Varchar, #[max_length = 128] @@ -1554,6 +1554,8 @@ diesel::table! { modified_at -> Timestamp, #[max_length = 64] profile_id -> Varchar, + #[max_length = 128] + merchant_reference_id -> Nullable, } } diff --git a/crates/diesel_models/src/schema_v2.rs b/crates/diesel_models/src/schema_v2.rs index 4195475f88..4648eed49f 100644 --- a/crates/diesel_models/src/schema_v2.rs +++ b/crates/diesel_models/src/schema_v2.rs @@ -1465,9 +1465,9 @@ diesel::table! { use diesel::sql_types::*; use crate::enums::diesel_exports::*; - subscription (subscription_id, merchant_id) { + subscription (id) { #[max_length = 128] - subscription_id -> Varchar, + id -> Varchar, #[max_length = 128] status -> Varchar, #[max_length = 128] @@ -1489,6 +1489,8 @@ diesel::table! { modified_at -> Timestamp, #[max_length = 64] profile_id -> Varchar, + #[max_length = 128] + merchant_reference_id -> Nullable, } } diff --git a/crates/diesel_models/src/subscription.rs b/crates/diesel_models/src/subscription.rs index b2cb6e4847..f49fef4a7b 100644 --- a/crates/diesel_models/src/subscription.rs +++ b/crates/diesel_models/src/subscription.rs @@ -1,5 +1,6 @@ -use common_utils::pii::SecretSerdeValue; +use common_utils::{generate_id_with_default_len, pii::SecretSerdeValue}; use diesel::{AsChangeset, Identifiable, Insertable, Queryable, Selectable}; +use masking::Secret; use serde::{Deserialize, Serialize}; use crate::schema::subscription; @@ -7,7 +8,7 @@ use crate::schema::subscription; #[derive(Clone, Debug, Eq, Insertable, PartialEq, Serialize, Deserialize)] #[diesel(table_name = subscription)] pub struct SubscriptionNew { - subscription_id: String, + id: common_utils::id_type::SubscriptionId, status: String, billing_processor: Option, payment_method_id: Option, @@ -20,14 +21,15 @@ pub struct SubscriptionNew { created_at: time::PrimitiveDateTime, modified_at: time::PrimitiveDateTime, profile_id: common_utils::id_type::ProfileId, + merchant_reference_id: Option, } #[derive( Clone, Debug, Eq, PartialEq, Identifiable, Queryable, Selectable, Deserialize, Serialize, )] -#[diesel(table_name = subscription, primary_key(subscription_id, merchant_id), check_for_backend(diesel::pg::Pg))] +#[diesel(table_name = subscription, primary_key(id), check_for_backend(diesel::pg::Pg))] pub struct Subscription { - pub subscription_id: String, + pub id: common_utils::id_type::SubscriptionId, pub status: String, pub billing_processor: Option, pub payment_method_id: Option, @@ -40,6 +42,7 @@ pub struct Subscription { pub created_at: time::PrimitiveDateTime, pub modified_at: time::PrimitiveDateTime, pub profile_id: common_utils::id_type::ProfileId, + pub merchant_reference_id: Option, } #[derive(Clone, Debug, Eq, PartialEq, AsChangeset, router_derive::DebugAsDisplay, Deserialize)] @@ -53,7 +56,7 @@ pub struct SubscriptionUpdate { impl SubscriptionNew { #[allow(clippy::too_many_arguments)] pub fn new( - subscription_id: String, + id: common_utils::id_type::SubscriptionId, status: String, billing_processor: Option, payment_method_id: Option, @@ -64,10 +67,11 @@ impl SubscriptionNew { customer_id: common_utils::id_type::CustomerId, metadata: Option, profile_id: common_utils::id_type::ProfileId, + merchant_reference_id: Option, ) -> Self { let now = common_utils::date_time::now(); Self { - subscription_id, + id, status, billing_processor, payment_method_id, @@ -80,8 +84,16 @@ impl SubscriptionNew { created_at: now, modified_at: now, profile_id, + merchant_reference_id, } } + + pub fn generate_and_set_client_secret(&mut self) -> Secret { + let client_secret = + generate_id_with_default_len(&format!("{}_secret", self.id.get_string_repr())); + self.client_secret = Some(client_secret.clone()); + Secret::new(client_secret) + } } impl SubscriptionUpdate { diff --git a/crates/router/src/core.rs b/crates/router/src/core.rs index 6a7e77f6be..cef4e6f900 100644 --- a/crates/router/src/core.rs +++ b/crates/router/src/core.rs @@ -51,6 +51,8 @@ pub mod refunds_v2; #[cfg(feature = "v1")] pub mod debit_routing; pub mod routing; +#[cfg(feature = "v1")] +pub mod subscription; pub mod surcharge_decision_config; pub mod three_ds_decision_rule; #[cfg(feature = "olap")] diff --git a/crates/router/src/core/subscription.rs b/crates/router/src/core/subscription.rs new file mode 100644 index 0000000000..32c6754e2f --- /dev/null +++ b/crates/router/src/core/subscription.rs @@ -0,0 +1,65 @@ +use std::str::FromStr; + +use api_models::subscription::{ + self as subscription_types, CreateSubscriptionResponse, SubscriptionStatus, +}; +use common_utils::id_type::GenerateId; +use diesel_models::subscription::SubscriptionNew; +use error_stack::ResultExt; +use hyperswitch_domain_models::{api::ApplicationResponse, merchant_context::MerchantContext}; +use masking::Secret; + +use super::errors::{self, RouterResponse}; +use crate::routes::SessionState; + +pub async fn create_subscription( + state: SessionState, + merchant_context: MerchantContext, + profile_id: String, + request: subscription_types::CreateSubscriptionRequest, +) -> RouterResponse { + let store = state.store.clone(); + let db = store.as_ref(); + let id = common_utils::id_type::SubscriptionId::generate(); + let profile_id = common_utils::id_type::ProfileId::from_str(&profile_id).change_context( + errors::ApiErrorResponse::InvalidDataValue { + field_name: "X-Profile-Id", + }, + )?; + + let mut subscription = SubscriptionNew::new( + id, + SubscriptionStatus::Created.to_string(), + None, + None, + None, + None, + None, + merchant_context.get_merchant_account().get_id().clone(), + request.customer_id.clone(), + None, + profile_id, + request.merchant_reference_id, + ); + + subscription.generate_and_set_client_secret(); + let subscription_response = db + .insert_subscription_entry(subscription) + .await + .change_context(errors::ApiErrorResponse::InternalServerError) + .attach_printable("subscriptions: unable to insert subscription entry to database")?; + + let response = CreateSubscriptionResponse::new( + subscription_response.id.clone(), + subscription_response.merchant_reference_id, + SubscriptionStatus::from_str(&subscription_response.status) + .unwrap_or(SubscriptionStatus::Created), + None, + subscription_response.profile_id, + subscription_response.merchant_id, + subscription_response.client_secret.map(Secret::new), + request.customer_id, + ); + + Ok(ApplicationResponse::Json(response)) +} diff --git a/crates/router/src/lib.rs b/crates/router/src/lib.rs index a1ec5bd508..c746410aa0 100644 --- a/crates/router/src/lib.rs +++ b/crates/router/src/lib.rs @@ -211,6 +211,7 @@ pub fn mk_app( .service(routes::Files::server(state.clone())) .service(routes::Disputes::server(state.clone())) .service(routes::Blocklist::server(state.clone())) + .service(routes::Subscription::server(state.clone())) .service(routes::Gsm::server(state.clone())) .service(routes::ApplePayCertificatesMigration::server(state.clone())) .service(routes::PaymentLink::server(state.clone())) diff --git a/crates/router/src/routes.rs b/crates/router/src/routes.rs index d49f81d9d9..faefaffa54 100644 --- a/crates/router/src/routes.rs +++ b/crates/router/src/routes.rs @@ -52,6 +52,8 @@ pub mod refunds; pub mod revenue_recovery_data_backfill; #[cfg(feature = "olap")] pub mod routing; +#[cfg(feature = "v1")] +pub mod subscription; pub mod three_ds_decision_rule; pub mod tokenization; #[cfg(feature = "olap")] @@ -96,7 +98,7 @@ pub use self::app::{ User, UserDeprecated, Webhooks, }; #[cfg(feature = "olap")] -pub use self::app::{Blocklist, Organization, Routing, Verify, WebhookEvents}; +pub use self::app::{Blocklist, Organization, Routing, Subscription, Verify, WebhookEvents}; #[cfg(feature = "payouts")] pub use self::app::{PayoutLink, Payouts}; #[cfg(feature = "v2")] diff --git a/crates/router/src/routes/app.rs b/crates/router/src/routes/app.rs index 9f27455c75..11d90e66f1 100644 --- a/crates/router/src/routes/app.rs +++ b/crates/router/src/routes/app.rs @@ -65,7 +65,9 @@ use super::{ profiles, relay, user, user_role, }; #[cfg(feature = "v1")] -use super::{apple_pay_certificates_migration, blocklist, payment_link, webhook_events}; +use super::{ + apple_pay_certificates_migration, blocklist, payment_link, subscription, webhook_events, +}; #[cfg(any(feature = "olap", feature = "oltp"))] use super::{configs::*, customers, payments}; #[cfg(all(any(feature = "olap", feature = "oltp"), feature = "v1"))] @@ -1163,6 +1165,22 @@ impl Routing { } } +#[cfg(feature = "oltp")] +pub struct Subscription; + +#[cfg(all(feature = "oltp", feature = "v1"))] +impl Subscription { + pub fn server(state: AppState) -> Scope { + web::scope("/subscription/create") + .app_data(web::Data::new(state.clone())) + .service(web::resource("").route( + web::post().to(|state, req, payload| { + subscription::create_subscription(state, req, payload) + }), + )) + } +} + pub struct Customers; #[cfg(all(feature = "v2", any(feature = "olap", feature = "oltp")))] diff --git a/crates/router/src/routes/lock_utils.rs b/crates/router/src/routes/lock_utils.rs index 76a8839a91..63972a938f 100644 --- a/crates/router/src/routes/lock_utils.rs +++ b/crates/router/src/routes/lock_utils.rs @@ -26,6 +26,7 @@ pub enum ApiIdentifier { ApiKeys, PaymentLink, Routing, + Subscription, Blocklist, Forex, RustLockerMigration, @@ -89,6 +90,8 @@ impl From for ApiIdentifier { | Flow::DecisionEngineDecideGatewayCall | Flow::DecisionEngineGatewayFeedbackCall => Self::Routing, + Flow::CreateSubscription => 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 new file mode 100644 index 0000000000..d4b716f63c --- /dev/null +++ b/crates/router/src/routes/subscription.rs @@ -0,0 +1,69 @@ +//! Analysis for usage of Subscription in Payment flows +//! +//! Functions that are used to perform the api level configuration and retrieval +//! of various types under Subscriptions. + +use actix_web::{web, HttpRequest, HttpResponse, Responder}; +use api_models::subscription as subscription_types; +use hyperswitch_domain_models::errors; +use router_env::{ + tracing::{self, instrument}, + Flow, +}; + +use crate::{ + core::{api_locking, subscription}, + headers::X_PROFILE_ID, + routes::AppState, + services::{api as oss_api, authentication as auth, authorization::permissions::Permission}, + types::domain, +}; + +#[cfg(all(feature = "oltp", feature = "v1"))] +#[instrument(skip_all)] +pub async fn create_subscription( + state: web::Data, + req: HttpRequest, + json_payload: web::Json, +) -> impl Responder { + let flow = Flow::CreateSubscription; + let profile_id = match req.headers().get(X_PROFILE_ID) { + Some(val) => val.to_str().unwrap_or_default().to_string(), + None => { + return HttpResponse::BadRequest().json( + errors::api_error_response::ApiErrorResponse::MissingRequiredField { + field_name: "x-profile-id", + }, + ); + } + }; + Box::pin(oss_api::server_wrap( + flow, + state, + &req, + json_payload.into_inner(), + move |state, auth: auth::AuthenticationData, payload, _| { + let merchant_context = domain::MerchantContext::NormalMerchant(Box::new( + domain::Context(auth.merchant_account, auth.key_store), + )); + subscription::create_subscription( + state, + merchant_context, + profile_id.clone(), + payload.clone(), + ) + }, + auth::auth_type( + &auth::HeaderAuth(auth::ApiKeyAuth { + is_connected_allowed: false, + is_platform_allowed: false, + }), + &auth::JWTAuth { + permission: Permission::ProfileSubscriptionWrite, + }, + req.headers(), + ), + api_locking::LockAction::NotApplicable, + )) + .await +} diff --git a/crates/router/src/services/authorization/permissions.rs b/crates/router/src/services/authorization/permissions.rs index 3860d343b6..6fb4b09cd5 100644 --- a/crates/router/src/services/authorization/permissions.rs +++ b/crates/router/src/services/authorization/permissions.rs @@ -43,6 +43,10 @@ generate_permissions! { scopes: [Read, Write], entities: [Profile, Merchant] }, + Subscription: { + scopes: [Read, Write], + entities: [Profile, Merchant] + }, ThreeDsDecisionManager: { scopes: [Read, Write], entities: [Merchant, Profile] @@ -123,6 +127,7 @@ pub fn get_resource_name(resource: Resource, entity_type: EntityType) -> Option< Some("Payment Processors, Payout Processors, Fraud & Risk Managers") } (Resource::Routing, _) => Some("Routing"), + (Resource::Subscription, _) => Some("Subscription"), (Resource::RevenueRecovery, _) => Some("Revenue Recovery"), (Resource::ThreeDsDecisionManager, _) => Some("3DS Decision Manager"), (Resource::SurchargeDecisionManager, _) => Some("Surcharge Decision Manager"), diff --git a/crates/router_env/src/logger/types.rs b/crates/router_env/src/logger/types.rs index 0f03e26a5c..ace7635bf4 100644 --- a/crates/router_env/src/logger/types.rs +++ b/crates/router_env/src/logger/types.rs @@ -263,6 +263,8 @@ pub enum Flow { RoutingUpdateDefaultConfig, /// Routing delete config RoutingDeleteConfig, + /// Subscription create flow, + CreateSubscription, /// Create dynamic routing CreateDynamicRoutingConfig, /// Toggle dynamic routing diff --git a/migrations/2025-09-17-165505_update_subscription_table_with_merchant_ref_id/down.sql b/migrations/2025-09-17-165505_update_subscription_table_with_merchant_ref_id/down.sql new file mode 100644 index 0000000000..cc002a5e7e --- /dev/null +++ b/migrations/2025-09-17-165505_update_subscription_table_with_merchant_ref_id/down.sql @@ -0,0 +1,9 @@ +ALTER TABLE subscription + DROP CONSTRAINT subscription_pkey, + DROP COLUMN merchant_reference_id; + +ALTER TABLE subscription + RENAME COLUMN id TO subscription_id; + +ALTER TABLE subscription + ADD PRIMARY KEY (subscription_id, merchant_id); diff --git a/migrations/2025-09-17-165505_update_subscription_table_with_merchant_ref_id/up.sql b/migrations/2025-09-17-165505_update_subscription_table_with_merchant_ref_id/up.sql new file mode 100644 index 0000000000..1d454442b8 --- /dev/null +++ b/migrations/2025-09-17-165505_update_subscription_table_with_merchant_ref_id/up.sql @@ -0,0 +1,9 @@ +ALTER TABLE subscription + DROP CONSTRAINT subscription_pkey, + ADD COLUMN merchant_reference_id VARCHAR(128); + +ALTER TABLE subscription + RENAME COLUMN subscription_id TO id; + +ALTER TABLE subscription + ADD PRIMARY KEY (id);