diff --git a/crates/api_models/src/subscription.rs b/crates/api_models/src/subscription.rs index f7c33bd37c..45e32a31c6 100644 --- a/crates/api_models/src/subscription.rs +++ b/crates/api_models/src/subscription.rs @@ -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, + + /// Invoice Details for the subscription. + pub invoice: Option, } /// Possible states of a subscription lifecycle. @@ -127,6 +133,8 @@ impl SubscriptionResponse { merchant_id: common_utils::id_type::MerchantId, client_secret: Option>, customer_id: common_utils::id_type::CustomerId, + payment: Option, + invoice: Option, ) -> 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
, pub payment_method: api_enums::PaymentMethod, pub payment_method_type: Option, pub payment_method_data: PaymentMethodDataRequest, @@ -278,7 +293,7 @@ pub struct PaymentResponseData { pub error_code: Option, pub error_message: Option, pub payment_method_type: Option, - pub client_secret: Option, + pub client_secret: Option>, } #[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, + pub client_secret: Option, /// Identifier for the associated plan_id. pub plan_id: Option, @@ -305,15 +320,6 @@ pub struct ConfirmSubscriptionRequest { /// Idenctifier for the coupon code for the subscription. pub coupon_code: Option, - /// Identifier for customer. - pub customer_id: common_utils::id_type::CustomerId, - - /// Billing address for the subscription. - pub billing: Option
, - - /// Shipping address for the subscription. - pub shipping: Option
, - /// Payment details for the invoice. pub payment_details: ConfirmSubscriptionPaymentDetails, } @@ -328,11 +334,15 @@ impl ConfirmSubscriptionRequest { } pub fn get_billing_address(&self) -> Result> { - 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() + } + )) } } diff --git a/crates/diesel_models/src/invoice.rs b/crates/diesel_models/src/invoice.rs index 59ef0b272d..2b93b0b4a6 100644 --- a/crates/diesel_models/src/invoice.rs +++ b/crates/diesel_models/src/invoice.rs @@ -23,6 +23,7 @@ pub struct InvoiceNew { pub metadata: Option, pub created_at: time::PrimitiveDateTime, pub modified_at: time::PrimitiveDateTime, + pub connector_invoice_id: Option, } #[derive( @@ -49,6 +50,7 @@ pub struct Invoice { pub metadata: Option, pub created_at: time::PrimitiveDateTime, pub modified_at: time::PrimitiveDateTime, + pub connector_invoice_id: Option, } #[derive(Clone, Debug, Eq, PartialEq, AsChangeset, Deserialize)] @@ -56,6 +58,7 @@ pub struct Invoice { pub struct InvoiceUpdate { pub status: Option, pub payment_method_id: Option, + pub connector_invoice_id: Option, pub modified_at: time::PrimitiveDateTime, pub payment_intent_id: Option, } @@ -75,6 +78,7 @@ impl InvoiceNew { status: InvoiceStatus, provider_name: Connector, metadata: Option, + connector_invoice_id: Option, ) -> 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, status: Option, + connector_invoice_id: Option, payment_intent_id: Option, ) -> 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(), } diff --git a/crates/diesel_models/src/schema.rs b/crates/diesel_models/src/schema.rs index bd91c7843e..e3cd4336b4 100644 --- a/crates/diesel_models/src/schema.rs +++ b/crates/diesel_models/src/schema.rs @@ -750,6 +750,8 @@ diesel::table! { metadata -> Nullable, created_at -> Timestamp, modified_at -> Timestamp, + #[max_length = 64] + connector_invoice_id -> Nullable, } } diff --git a/crates/diesel_models/src/schema_v2.rs b/crates/diesel_models/src/schema_v2.rs index 560d1fc646..07f9683e83 100644 --- a/crates/diesel_models/src/schema_v2.rs +++ b/crates/diesel_models/src/schema_v2.rs @@ -762,6 +762,8 @@ diesel::table! { metadata -> Nullable, created_at -> Timestamp, modified_at -> Timestamp, + #[max_length = 64] + connector_invoice_id -> Nullable, } } diff --git a/crates/router/src/core/subscription.rs b/crates/router/src/core/subscription.rs index 8066d48d35..636b960fed 100644 --- a/crates/router/src/core/subscription.rs +++ b/crates/router/src/core/subscription.rs @@ -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 { +) -> RouterResponse { 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::>(), - }) + }); } - 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 { 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( diff --git a/crates/router/src/core/subscription/invoice_handler.rs b/crates/router/src/core/subscription/invoice_handler.rs index 8cc026fb37..b7d5cb328f 100644 --- a/crates/router/src/core/subscription/invoice_handler.rs +++ b/crates/router/src/core/subscription/invoice_handler.rs @@ -44,6 +44,7 @@ impl InvoiceHandler { status: connector_enums::InvoiceStatus, provider_name: connector_enums::Connector, metadata: Option, + connector_invoice_id: Option, ) -> errors::RouterResult { 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>, payment_intent_id: Option, status: connector_enums::InvoiceStatus, + connector_invoice_id: Option, ) -> errors::RouterResult { 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 { 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(), diff --git a/crates/router/src/core/subscription/subscription_handler.rs b/crates/router/src/core/subscription/subscription_handler.rs index b11cf517b0..b608faca5d 100644 --- a/crates/router/src/core/subscription/subscription_handler.rs +++ b/crates/router/src/core/subscription/subscription_handler.rs @@ -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, + invoice: Option<&diesel_models::invoice::Invoice>, + ) -> errors::RouterResult { + 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::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; + + fn foreign_try_from(invoice: &diesel_models::invoice::Invoice) -> Result { + 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(), + }) + } +} diff --git a/crates/router/src/core/webhooks/incoming.rs b/crates/router/src/core/webhooks/incoming.rs index 657655c25b..ce18300283 100644 --- a/crates/router/src/core/webhooks/incoming.rs +++ b/crates/router/src/core/webhooks/incoming.rs @@ -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?; diff --git a/crates/router/src/routes/subscription.rs b/crates/router/src/routes/subscription.rs index 735f530d3c..a0d8fe531b 100644 --- a/crates/router/src/routes/subscription.rs +++ b/crates/router/src/routes/subscription.rs @@ -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 diff --git a/crates/router/src/services/authentication.rs b/crates/router/src/services/authentication.rs index 13d224a1cd..4bbb181383 100644 --- a/crates/router/src/services/authentication.rs +++ b/crates/router/src/services/authentication.rs @@ -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> { diff --git a/crates/router/src/workflows/invoice_sync.rs b/crates/router/src/workflows/invoice_sync.rs index c6daab0211..ec71af2b70 100644 --- a/crates/router/src/workflows/invoice_sync.rs +++ b/crates/router/src/workflows/invoice_sync.rs @@ -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")?; diff --git a/migrations/2025-10-07-130304_add_connector_invoice_id/down.sql b/migrations/2025-10-07-130304_add_connector_invoice_id/down.sql new file mode 100644 index 0000000000..85f8bcf59e --- /dev/null +++ b/migrations/2025-10-07-130304_add_connector_invoice_id/down.sql @@ -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; \ No newline at end of file diff --git a/migrations/2025-10-07-130304_add_connector_invoice_id/up.sql b/migrations/2025-10-07-130304_add_connector_invoice_id/up.sql new file mode 100644 index 0000000000..d17ca57da7 --- /dev/null +++ b/migrations/2025-10-07-130304_add_connector_invoice_id/up.sql @@ -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); \ No newline at end of file