feat(subscriptions): Add client secret auth support in subscriptions APIs (#9713)

Co-authored-by: Prajjwal kumar <write2prajjwal@gmail.com>
Co-authored-by: hyperswitch-bot[bot] <148525504+hyperswitch-bot[bot]@users.noreply.github.com>
Co-authored-by: Prajjwal Kumar <prajjwal.kumar@juspay.in>
Co-authored-by: Jagan <jaganelavarasan@gmail.com>
This commit is contained in:
Sarthak Soni
2025-10-08 20:02:17 +05:30
committed by GitHub
parent dab0851601
commit 01b4d6ae7c
13 changed files with 177 additions and 81 deletions

View File

@ -74,6 +74,12 @@ pub struct SubscriptionResponse {
/// Optional customer ID associated with this subscription.
pub customer_id: common_utils::id_type::CustomerId,
/// Payment details for the invoice.
pub payment: Option<PaymentResponseData>,
/// Invoice Details for the subscription.
pub invoice: Option<Invoice>,
}
/// Possible states of a subscription lifecycle.
@ -127,6 +133,8 @@ impl SubscriptionResponse {
merchant_id: common_utils::id_type::MerchantId,
client_secret: Option<Secret<String>>,
customer_id: common_utils::id_type::CustomerId,
payment: Option<PaymentResponseData>,
invoice: Option<Invoice>,
) -> Self {
Self {
id,
@ -138,6 +146,8 @@ impl SubscriptionResponse {
merchant_id,
coupon_code: None,
customer_id,
payment,
invoice,
}
}
}
@ -181,6 +191,10 @@ impl ClientSecret {
pub fn as_str(&self) -> &str {
&self.0
}
pub fn as_string(&self) -> &String {
&self.0
}
}
#[derive(serde::Deserialize, serde::Serialize, Debug)]
@ -197,6 +211,7 @@ impl ApiEventMetric for GetPlansResponse {}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, ToSchema)]
pub struct ConfirmSubscriptionPaymentDetails {
pub shipping: Option<Address>,
pub payment_method: api_enums::PaymentMethod,
pub payment_method_type: Option<api_enums::PaymentMethodType>,
pub payment_method_data: PaymentMethodDataRequest,
@ -278,7 +293,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>,
pub client_secret: Option<Secret<String>>,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, ToSchema)]
@ -294,7 +309,7 @@ pub struct CreateMitPaymentRequestData {
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, ToSchema)]
pub struct ConfirmSubscriptionRequest {
/// Client secret for SDK based interaction.
pub client_secret: Option<String>,
pub client_secret: Option<ClientSecret>,
/// Identifier for the associated plan_id.
pub plan_id: Option<String>,
@ -305,15 +320,6 @@ pub struct ConfirmSubscriptionRequest {
/// Idenctifier for the coupon code for the subscription.
pub coupon_code: Option<String>,
/// Identifier for customer.
pub customer_id: common_utils::id_type::CustomerId,
/// Billing address for the subscription.
pub billing: Option<Address>,
/// Shipping address for the subscription.
pub shipping: Option<Address>,
/// Payment details for the invoice.
pub payment_details: ConfirmSubscriptionPaymentDetails,
}
@ -328,11 +334,15 @@ impl ConfirmSubscriptionRequest {
}
pub fn get_billing_address(&self) -> Result<Address, error_stack::Report<ValidationError>> {
self.billing.clone().ok_or(error_stack::report!(
ValidationError::MissingRequiredField {
field_name: "billing".to_string()
}
))
self.payment_details
.payment_method_data
.billing
.clone()
.ok_or(error_stack::report!(
ValidationError::MissingRequiredField {
field_name: "billing".to_string()
}
))
}
}

View File

@ -23,6 +23,7 @@ pub struct InvoiceNew {
pub metadata: Option<SecretSerdeValue>,
pub created_at: time::PrimitiveDateTime,
pub modified_at: time::PrimitiveDateTime,
pub connector_invoice_id: Option<String>,
}
#[derive(
@ -49,6 +50,7 @@ pub struct Invoice {
pub metadata: Option<SecretSerdeValue>,
pub created_at: time::PrimitiveDateTime,
pub modified_at: time::PrimitiveDateTime,
pub connector_invoice_id: Option<String>,
}
#[derive(Clone, Debug, Eq, PartialEq, AsChangeset, Deserialize)]
@ -56,6 +58,7 @@ pub struct Invoice {
pub struct InvoiceUpdate {
pub status: Option<String>,
pub payment_method_id: Option<String>,
pub connector_invoice_id: Option<String>,
pub modified_at: time::PrimitiveDateTime,
pub payment_intent_id: Option<common_utils::id_type::PaymentId>,
}
@ -75,6 +78,7 @@ impl InvoiceNew {
status: InvoiceStatus,
provider_name: Connector,
metadata: Option<SecretSerdeValue>,
connector_invoice_id: Option<String>,
) -> Self {
let id = common_utils::id_type::InvoiceId::generate();
let now = common_utils::date_time::now();
@ -94,6 +98,7 @@ impl InvoiceNew {
metadata,
created_at: now,
modified_at: now,
connector_invoice_id,
}
}
}
@ -102,11 +107,13 @@ impl InvoiceUpdate {
pub fn new(
payment_method_id: Option<String>,
status: Option<InvoiceStatus>,
connector_invoice_id: Option<String>,
payment_intent_id: Option<common_utils::id_type::PaymentId>,
) -> Self {
Self {
payment_method_id,
status: status.map(|status| status.to_string()),
connector_invoice_id,
payment_intent_id,
modified_at: common_utils::date_time::now(),
}

View File

@ -750,6 +750,8 @@ diesel::table! {
metadata -> Nullable<Jsonb>,
created_at -> Timestamp,
modified_at -> Timestamp,
#[max_length = 64]
connector_invoice_id -> Nullable<Varchar>,
}
}

View File

@ -762,6 +762,8 @@ diesel::table! {
metadata -> Nullable<Jsonb>,
created_at -> Timestamp,
modified_at -> Timestamp,
#[max_length = 64]
connector_invoice_id -> Nullable<Varchar>,
}
}

View File

@ -4,10 +4,7 @@ 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,
router_response_types::subscriptions as subscription_response_types,
};
use hyperswitch_domain_models::{api::ApplicationResponse, merchant_context::MerchantContext};
use super::errors::{self, RouterResponse};
use crate::{
@ -31,7 +28,7 @@ pub async fn create_subscription(
merchant_context: MerchantContext,
profile_id: common_utils::id_type::ProfileId,
request: subscription_types::CreateSubscriptionRequest,
) -> RouterResponse<subscription_types::ConfirmSubscriptionResponse> {
) -> RouterResponse<SubscriptionResponse> {
let subscription_id = common_utils::id_type::SubscriptionId::generate();
let profile =
@ -68,7 +65,7 @@ pub async fn create_subscription(
.create_payment_with_confirm_false(subscription.handler.state, &request)
.await
.attach_printable("subscriptions: failed to create payment")?;
let invoice_entry = invoice_handler
let invoice = invoice_handler
.create_invoice_entry(
&state,
billing_handler.merchant_connector_id,
@ -78,6 +75,7 @@ pub async fn create_subscription(
connector_enums::InvoiceStatus::InvoiceCreated,
billing_handler.connector_data.connector_name,
None,
None,
)
.await
.attach_printable("subscriptions: failed to create invoice")?;
@ -91,11 +89,7 @@ pub async fn create_subscription(
.await
.attach_printable("subscriptions: failed to update subscription")?;
let response = subscription.generate_response(
&invoice_entry,
&payment,
subscription_response_types::SubscriptionStatus::Created,
)?;
let response = subscription.to_subscription_response(Some(payment), Some(&invoice))?;
Ok(ApplicationResponse::Json(response))
}
@ -148,14 +142,12 @@ pub async fn get_subscription_plans(
.into_iter()
.map(subscription_types::SubscriptionPlanPrices::from)
.collect::<Vec<_>>(),
})
});
}
Ok(ApplicationResponse::Json(response))
}
/// Creates and confirms a subscription in one operation.
/// This method combines the creation and confirmation flow to reduce API calls
pub async fn create_and_confirm_subscription(
state: SessionState,
merchant_context: MerchantContext,
@ -163,7 +155,6 @@ pub async fn create_and_confirm_subscription(
request: subscription_types::CreateAndConfirmSubscriptionRequest,
) -> RouterResponse<subscription_types::ConfirmSubscriptionResponse> {
let subscription_id = common_utils::id_type::SubscriptionId::generate();
let profile =
SubscriptionHandler::find_business_profile(&state, &merchant_context, &profile_id)
.await
@ -240,6 +231,9 @@ pub async fn create_and_confirm_subscription(
.unwrap_or(connector_enums::InvoiceStatus::InvoiceCreated),
billing_handler.connector_data.connector_name,
None,
invoice_details
.clone()
.map(|invoice| invoice.id.get_string_repr().to_string()),
)
.await?;
@ -292,13 +286,23 @@ pub async fn confirm_subscription(
SubscriptionHandler::find_business_profile(&state, &merchant_context, &profile_id)
.await
.attach_printable("subscriptions: failed to find business profile")?;
let customer =
SubscriptionHandler::find_customer(&state, &merchant_context, &request.customer_id)
.await
.attach_printable("subscriptions: failed to find customer")?;
let handler = SubscriptionHandler::new(&state, &merchant_context);
if let Some(client_secret) = request.client_secret.clone() {
handler
.find_and_validate_subscription(&client_secret.into())
.await?
};
let mut subscription_entry = handler.find_subscription(subscription_id).await?;
let customer = SubscriptionHandler::find_customer(
&state,
&merchant_context,
&subscription_entry.subscription.customer_id,
)
.await
.attach_printable("subscriptions: failed to find customer")?;
let invoice_handler = subscription_entry.get_invoice_handler(profile.clone());
let invoice = invoice_handler
.get_latest_invoice(&state)
@ -331,7 +335,7 @@ pub async fn confirm_subscription(
.create_customer_on_connector(
&state,
subscription.customer_id.clone(),
request.billing.clone(),
request.payment_details.payment_method_data.billing.clone(),
request
.payment_details
.payment_method_data
@ -344,7 +348,7 @@ pub async fn confirm_subscription(
&state,
subscription,
request.item_price_id,
request.billing,
request.payment_details.payment_method_data.billing,
)
.await?;
@ -359,6 +363,9 @@ pub async fn confirm_subscription(
.clone()
.and_then(|invoice| invoice.status)
.unwrap_or(connector_enums::InvoiceStatus::InvoiceCreated),
invoice_details
.clone()
.map(|invoice| invoice.id.get_string_repr().to_string()),
)
.await?;
@ -418,9 +425,9 @@ pub async fn get_subscription(
.await
.attach_printable("subscriptions: failed to get subscription entry in get_subscription")?;
Ok(ApplicationResponse::Json(
subscription.to_subscription_response(),
))
let response = subscription.to_subscription_response(None, None)?;
Ok(ApplicationResponse::Json(response))
}
pub async fn get_estimate(

View File

@ -44,6 +44,7 @@ impl InvoiceHandler {
status: connector_enums::InvoiceStatus,
provider_name: connector_enums::Connector,
metadata: Option<pii::SecretSerdeValue>,
connector_invoice_id: Option<String>,
) -> errors::RouterResult<diesel_models::invoice::Invoice> {
let invoice_new = diesel_models::invoice::InvoiceNew::new(
self.subscription.id.to_owned(),
@ -58,6 +59,7 @@ impl InvoiceHandler {
status,
provider_name,
metadata,
connector_invoice_id,
);
let invoice = state
@ -79,10 +81,12 @@ impl InvoiceHandler {
payment_method_id: Option<Secret<String>>,
payment_intent_id: Option<common_utils::id_type::PaymentId>,
status: connector_enums::InvoiceStatus,
connector_invoice_id: Option<String>,
) -> errors::RouterResult<diesel_models::invoice::Invoice> {
let update_invoice = diesel_models::invoice::InvoiceUpdate::new(
payment_method_id.as_ref().map(|id| id.peek()).cloned(),
Some(status),
connector_invoice_id,
payment_intent_id,
);
state
@ -189,8 +193,8 @@ impl InvoiceHandler {
) -> errors::RouterResult<subscription_types::PaymentResponseData> {
let payment_details = &request.payment_details;
let cit_payment_request = subscription_types::ConfirmPaymentsRequestData {
billing: request.billing.clone(),
shipping: request.shipping.clone(),
billing: request.payment_details.payment_method_data.billing.clone(),
shipping: request.payment_details.shipping.clone(),
payment_method: payment_details.payment_method,
payment_method_type: payment_details.payment_method_type,
payment_method_data: payment_details.payment_method_data.clone(),

View File

@ -19,7 +19,7 @@ use crate::{
core::{errors::StorageErrorExt, subscription::invoice_handler::InvoiceHandler},
db::CustomResult,
routes::SessionState,
types::domain,
types::{domain, transformers::ForeignTryFrom},
};
pub struct SubscriptionHandler<'a> {
@ -227,31 +227,16 @@ impl SubscriptionWithHandler<'_> {
price_id: None,
coupon: None,
billing_processor_subscription_id: self.subscription.connector_subscription_id.clone(),
invoice: Some(subscription_types::Invoice {
id: invoice.id.clone(),
subscription_id: invoice.subscription_id.clone(),
merchant_id: invoice.merchant_id.clone(),
profile_id: invoice.profile_id.clone(),
merchant_connector_id: invoice.merchant_connector_id.clone(),
payment_intent_id: invoice.payment_intent_id.clone(),
payment_method_id: invoice.payment_method_id.clone(),
customer_id: invoice.customer_id.clone(),
amount: invoice.amount,
currency: api_enums::Currency::from_str(invoice.currency.as_str())
.change_context(errors::ApiErrorResponse::InvalidDataValue {
field_name: "currency",
})
.attach_printable(format!(
"unable to parse currency name {currency:?}",
currency = invoice.currency
))?,
status: invoice.status.clone(),
}),
invoice: Some(subscription_types::Invoice::foreign_try_from(invoice)?),
})
}
pub fn to_subscription_response(&self) -> SubscriptionResponse {
SubscriptionResponse::new(
pub fn to_subscription_response(
&self,
payment: Option<subscription_types::PaymentResponseData>,
invoice: Option<&diesel_models::invoice::Invoice>,
) -> errors::RouterResult<SubscriptionResponse> {
Ok(SubscriptionResponse::new(
self.subscription.id.clone(),
self.subscription.merchant_reference_id.clone(),
SubscriptionStatus::from_str(&self.subscription.status)
@ -261,7 +246,15 @@ impl SubscriptionWithHandler<'_> {
self.subscription.merchant_id.to_owned(),
self.subscription.client_secret.clone().map(Secret::new),
self.subscription.customer_id.clone(),
)
payment,
invoice
.map(
|invoice| -> errors::RouterResult<subscription_types::Invoice> {
subscription_types::Invoice::foreign_try_from(invoice)
},
)
.transpose()?,
))
}
pub async fn update_subscription(
@ -369,3 +362,30 @@ impl SubscriptionWithHandler<'_> {
}
}
}
impl ForeignTryFrom<&diesel_models::invoice::Invoice> for subscription_types::Invoice {
type Error = error_stack::Report<errors::ApiErrorResponse>;
fn foreign_try_from(invoice: &diesel_models::invoice::Invoice) -> Result<Self, Self::Error> {
Ok(Self {
id: invoice.id.clone(),
subscription_id: invoice.subscription_id.clone(),
merchant_id: invoice.merchant_id.clone(),
profile_id: invoice.profile_id.clone(),
merchant_connector_id: invoice.merchant_connector_id.clone(),
payment_intent_id: invoice.payment_intent_id.clone(),
payment_method_id: invoice.payment_method_id.clone(),
customer_id: invoice.customer_id.clone(),
amount: invoice.amount,
currency: api_enums::Currency::from_str(invoice.currency.as_str())
.change_context(errors::ApiErrorResponse::InvalidDataValue {
field_name: "currency",
})
.attach_printable(format!(
"unable to parse currency name {currency:?}",
currency = invoice.currency
))?,
status: invoice.status.clone(),
})
}
}

View File

@ -2645,6 +2645,7 @@ async fn subscription_incoming_webhook_flow(
InvoiceStatus::PaymentPending,
connector,
None,
None,
)
.await?;
@ -2664,6 +2665,7 @@ async fn subscription_incoming_webhook_flow(
payment_response.payment_method_id.clone(),
Some(payment_response.payment_id.clone()),
InvoiceStatus::from(payment_response.status),
Some(mit_payment_data.invoice_id.get_string_repr().to_string()),
)
.await?;

View File

@ -102,16 +102,25 @@ pub async fn confirm_subscription(
) -> impl Responder {
let flow = Flow::ConfirmSubscription;
let subscription_id = subscription_id.into_inner();
let payload = json_payload.into_inner();
let profile_id = match extract_profile_id(&req) {
Ok(id) => id,
Err(response) => return response,
};
let api_auth = auth::ApiKeyAuth::default();
let (auth_type, _) =
match auth::check_client_secret_and_get_auth(req.headers(), &payload, api_auth) {
Ok(auth) => auth,
Err(err) => return oss_api::log_and_return_error_response(error_stack::report!(err)),
};
Box::pin(oss_api::server_wrap(
flow,
state,
&req,
json_payload.into_inner(),
payload,
|state, auth: auth::AuthenticationData, payload, _| {
let merchant_context = domain::MerchantContext::NormalMerchant(Box::new(
domain::Context(auth.merchant_account, auth.key_store),
@ -125,10 +134,7 @@ pub async fn confirm_subscription(
)
},
auth::auth_type(
&auth::HeaderAuth(auth::ApiKeyAuth {
is_connected_allowed: false,
is_platform_allowed: false,
}),
&*auth_type,
&auth::JWTAuth {
permission: Permission::ProfileSubscriptionWrite,
},
@ -147,28 +153,36 @@ pub async fn get_subscription_plans(
) -> impl Responder {
let flow = Flow::GetPlansForSubscription;
let api_auth = auth::ApiKeyAuth::default();
let payload = query.into_inner();
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),
};
let (auth_type, _) =
match auth::check_client_secret_and_get_auth(req.headers(), &payload, api_auth) {
Ok(auth) => auth,
Err(err) => return oss_api::log_and_return_error_response(error_stack::report!(err)),
};
Box::pin(oss_api::server_wrap(
flow,
state,
&req,
query.into_inner(),
payload,
|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,
auth::auth_type(
&*auth_type,
&auth::JWTAuth {
permission: Permission::ProfileSubscriptionRead,
},
req.headers(),
),
api_locking::LockAction::NotApplicable,
))
.await
@ -187,6 +201,7 @@ pub async fn get_subscription(
Ok(id) => id,
Err(response) => return response,
};
Box::pin(oss_api::server_wrap(
flow,
state,
@ -245,10 +260,16 @@ pub async fn create_and_confirm_subscription(
payload.clone(),
)
},
&auth::HeaderAuth(auth::ApiKeyAuth {
is_connected_allowed: false,
is_platform_allowed: false,
}),
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

View File

@ -4326,6 +4326,20 @@ impl ClientSecretFetch for api_models::payment_methods::PaymentMethodUpdate {
}
}
#[cfg(feature = "v1")]
impl ClientSecretFetch for api_models::subscription::ConfirmSubscriptionRequest {
fn get_client_secret(&self) -> Option<&String> {
self.client_secret.as_ref().map(|s| s.as_string())
}
}
#[cfg(feature = "v1")]
impl ClientSecretFetch for api_models::subscription::GetPlansQuery {
fn get_client_secret(&self) -> Option<&String> {
self.client_secret.as_ref().map(|s| s.as_string())
}
}
#[cfg(feature = "v1")]
impl ClientSecretFetch for api_models::authentication::AuthenticationEligibilityRequest {
fn get_client_secret(&self) -> Option<&String> {

View File

@ -204,6 +204,7 @@ impl<'a> InvoiceSyncHandler<'a> {
None,
None,
common_enums::connector_enums::InvoiceStatus::from(invoice_sync_status),
Some(connector_invoice_id),
)
.await
.attach_printable("Failed to update invoice in DB")?;

View File

@ -0,0 +1,3 @@
-- This file should undo anything in `up.sql`
ALTER TABLE invoice DROP COLUMN IF EXISTS connector_invoice_id;
DROP INDEX IF EXISTS invoice_subscription_id_connector_invoice_id_index;

View File

@ -0,0 +1,3 @@
-- Your SQL goes here
ALTER TABLE invoice ADD COLUMN IF NOT EXISTS connector_invoice_id VARCHAR(64);
CREATE INDEX invoice_subscription_id_connector_invoice_id_index ON invoice (subscription_id, connector_invoice_id);