diff --git a/crates/schema/database/mysql_queries/src/queries/users/user_subscriptions/find_possibly_inactive_first_subscription_for_owner_user.rs b/crates/schema/database/mysql_queries/src/queries/users/user_subscriptions/find_possibly_inactive_first_subscription_for_owner_user.rs new file mode 100644 index 0000000000..8865605087 --- /dev/null +++ b/crates/schema/database/mysql_queries/src/queries/users/user_subscriptions/find_possibly_inactive_first_subscription_for_owner_user.rs @@ -0,0 +1,198 @@ +use crate::errors::select_optional_record_error::SelectOptionalRecordError; +use crate::types::query_map::QueryMap; +use enums::common::payments_namespace::PaymentsNamespace; +use sqlx; +use sqlx::mysql::MySqlRow; +use sqlx::pool::PoolConnection; +use sqlx::MySql; +use tokens::tokens::user_subscriptions::UserSubscriptionToken; +use tokens::tokens::users::UserToken; + +pub struct PossiblyInactiveUserSubscription { + pub token: UserSubscriptionToken, + + pub user_token: UserToken, + + pub subscription_namespace: PaymentsNamespace, + pub subscription_product_slug: String, + + pub stripe_subscription_status: String, + + pub stripe_customer_id: String, + pub stripe_product_id: String, + pub stripe_subscription_id: String, +} + +/// This is for restarting subscriptions after they have expired. +/// +/// Technically, there may be more than one subscription record. +/// We sort the results and only return the first active artcraft subscription +/// by numeric ID so we should have a consistent view. +pub async fn find_possibly_inactive_first_subscription_for_owner_user_using_connection( + user_token: &UserToken, + namespace: PaymentsNamespace, + connection: &mut PoolConnection, +) -> Result, SelectOptionalRecordError> { + + let query = query_only_active(user_token, namespace); + + let result = query + .fetch_optional(&mut **connection) + .await; + + let maybe_result = map_result(result)?; + + if let Some(result) = maybe_result { + return Ok(Some(result)); + } + + let query = query_possibly_inactive(user_token, namespace); + + let result = query + .fetch_optional(&mut **connection) + .await; + + map_result(result) +} + + +/// This is for restarting subscriptions after they have expired. +/// (See notes above.) +pub async fn find_possibly_inactive_first_subscription_for_owner_user_using_transaction( + user_token: &UserToken, + namespace: PaymentsNamespace, + transaction: &mut sqlx::Transaction<'_, MySql>, +) -> Result, SelectOptionalRecordError> { + + let query = query_only_active(user_token, namespace); + + let result = query + .fetch_optional(&mut **transaction) + .await; + + let maybe_result = map_result(result)?; + + if let Some(result) = maybe_result { + return Ok(Some(result)); + } + + let query = query_possibly_inactive(user_token, namespace); + + let result = query + .fetch_optional(&mut **transaction) + .await; + + map_result(result) +} + +fn map_result(result: Result, sqlx::Error>) -> Result, SelectOptionalRecordError> { + match result { + Ok(Some(record)) => Ok(Some(PossiblyInactiveUserSubscription { + token: record.token, + user_token: record.user_token, + subscription_namespace: record.subscription_namespace, + subscription_product_slug: record.subscription_product_slug, + stripe_customer_id: record.maybe_stripe_customer_id + .ok_or_else(|| SelectOptionalRecordError::RequiredFieldWasNull("maybe_stripe_customer_id"))?, + stripe_product_id: record.maybe_stripe_product_id + .ok_or_else(|| SelectOptionalRecordError::RequiredFieldWasNull("maybe_stripe_product_id"))?, + stripe_subscription_id: record.maybe_stripe_subscription_id + .ok_or_else(|| SelectOptionalRecordError::RequiredFieldWasNull("maybe_stripe_subscription_id"))?, + stripe_subscription_status: record.maybe_stripe_subscription_status + .ok_or_else(|| SelectOptionalRecordError::RequiredFieldWasNull("maybe_stripe_subscription_status"))?, + })), + Ok(None) => Ok(None), + Err(e) => Err(e.into()), + } +} + +fn query_only_active(user_token: &UserToken, namespace: PaymentsNamespace) + -> QueryMap Result> +{ + // NB: We want to eventually support multiple subscriptions per user (eg. company use case), + // so we do not have a unique key on user token (but we do on stripe subscription id). + // In the meantime, to ensure we use the same subscription each time, we order by id and + // take the first one. + sqlx::query_as!( + RawUserSubscription, + r#" +SELECT + token as `token: tokens::tokens::user_subscriptions::UserSubscriptionToken`, + user_token as `user_token: tokens::tokens::users::UserToken`, + subscription_namespace as `subscription_namespace: enums::common::payments_namespace::PaymentsNamespace`, + subscription_product_slug, + maybe_stripe_subscription_status, + maybe_stripe_customer_id, + maybe_stripe_product_id, + maybe_stripe_subscription_id + +FROM user_subscriptions + +WHERE + user_token = ? + AND subscription_namespace = ? + AND maybe_stripe_customer_id IS NOT NULL + AND maybe_stripe_subscription_id IS NOT NULL + AND maybe_stripe_subscription_status IS NOT NULL + AND maybe_stripe_subscription_status NOT IN ('canceled', 'incomplete_expired') + AND subscription_expires_at > CURRENT_TIMESTAMP + ORDER BY id ASC + LIMIT 1 + "#, + user_token.as_str(), + namespace.to_str(), + ) +} + +fn query_possibly_inactive(user_token: &UserToken, namespace: PaymentsNamespace) + -> QueryMap Result> +{ + // NB: We want to eventually support multiple subscriptions per user (eg. company use case), + // so we do not have a unique key on user token (but we do on stripe subscription id). + // In the meantime, to ensure we use the same subscription each time, we order by id and + // take the first one. + sqlx::query_as!( + RawUserSubscription, + r#" +SELECT + token as `token: tokens::tokens::user_subscriptions::UserSubscriptionToken`, + user_token as `user_token: tokens::tokens::users::UserToken`, + subscription_namespace as `subscription_namespace: enums::common::payments_namespace::PaymentsNamespace`, + subscription_product_slug, + maybe_stripe_subscription_status, + maybe_stripe_customer_id, + maybe_stripe_product_id, + maybe_stripe_subscription_id + +FROM user_subscriptions + +WHERE + user_token = ? + AND subscription_namespace = ? + AND maybe_stripe_customer_id IS NOT NULL + AND maybe_stripe_subscription_id IS NOT NULL + AND maybe_stripe_subscription_status IS NOT NULL + ORDER BY id ASC + LIMIT 1 + "#, + user_token.as_str(), + namespace.to_str(), + ) +} + + +#[derive(sqlx::FromRow)] +struct RawUserSubscription { + token: UserSubscriptionToken, + + user_token: UserToken, + + subscription_namespace: PaymentsNamespace, + subscription_product_slug: String, + + maybe_stripe_subscription_status: Option, + + maybe_stripe_customer_id: Option, + maybe_stripe_product_id: Option, + maybe_stripe_subscription_id: Option, +} diff --git a/crates/schema/database/mysql_queries/src/queries/users/user_subscriptions/mod.rs b/crates/schema/database/mysql_queries/src/queries/users/user_subscriptions/mod.rs index 30702ddc73..2cb597ed1e 100644 --- a/crates/schema/database/mysql_queries/src/queries/users/user_subscriptions/mod.rs +++ b/crates/schema/database/mysql_queries/src/queries/users/user_subscriptions/mod.rs @@ -1,3 +1,4 @@ +pub mod find_possibly_inactive_first_subscription_for_owner_user; pub mod find_subscription_for_owner_user; pub mod get_user_subscription_by_stripe_subscription_id; pub mod list_active_user_subscriptions; diff --git a/crates/service/plugins/billing_artcraft/src/endpoints/checkout/stripe_artcraft_create_subscription_checkout_handler.rs b/crates/service/plugins/billing_artcraft/src/endpoints/checkout/stripe_artcraft_create_subscription_checkout_handler.rs index d560060dde..2e0c8d4e82 100644 --- a/crates/service/plugins/billing_artcraft/src/endpoints/checkout/stripe_artcraft_create_subscription_checkout_handler.rs +++ b/crates/service/plugins/billing_artcraft/src/endpoints/checkout/stripe_artcraft_create_subscription_checkout_handler.rs @@ -9,7 +9,10 @@ use component_traits::traits::internal_user_lookup::InternalUserLookup; use enums::common::artcraft_subscription_slug::ArtcraftSubscriptionSlug; use enums::common::payments_namespace::PaymentsNamespace; use log::{error, info, warn}; +use mysql_queries::queries::users::user_subscriptions::find_possibly_inactive_first_subscription_for_owner_user::find_possibly_inactive_first_subscription_for_owner_user_using_connection; +use mysql_queries::queries::users::user_subscriptions::find_subscription_for_owner_user::find_subscription_for_owner_user_using_connection; use reusable_types::server_environment::ServerEnvironment; +use sqlx::MySqlPool; use std::collections::HashMap; use std::str::FromStr; use std::sync::Arc; @@ -39,6 +42,7 @@ pub async fn stripe_artcraft_create_subscription_session_handler( server_environment: Data, internal_user_lookup: Data, internal_session_cache_purge: Data, + mysql_pool: Data, ) -> Result, CommonWebError> { let slug = match request.plan { @@ -53,13 +57,21 @@ pub async fn stripe_artcraft_create_subscription_session_handler( let plan = get_artcraft_subscription_by_slug_and_env(slug, **server_environment); + let mut mysql_connection = mysql_pool + .acquire() + .await + .map_err(|err| { + error!("Could not acquire mysql connection: {:?}", err); + CommonWebError::ServerError + })?; + let price_id = match cadence { PlanBillingCadence::Monthly => plan.monthly_price_id.clone(), PlanBillingCadence::Yearly => plan.yearly_price_id.clone(), }; let maybe_user_metadata = internal_user_lookup - .lookup_user_from_http_request(&http_request) + .lookup_user_from_http_request_and_mysql_connection(&http_request, &mut mysql_connection) .await .map_err(|err| { error!("Error looking up user: {:?}", err); @@ -72,20 +84,37 @@ pub async fn stripe_artcraft_create_subscription_session_handler( Some(user_metadata) => user_metadata, }; - info!("Subscriptions: {:?}", &user_metadata.existing_subscription_keys); + // NB: Currently the stripe customer id field in the `users` table is only for FakeYou subscriptions, + // so we need to look up any existing Artcraft subscription separately. This is needed to pre-fill + // the Stripe billing form. + let maybe_active_subscription = find_subscription_for_owner_user_using_connection( + &user_metadata.user_token_typed, + PaymentsNamespace::Artcraft, + &mut mysql_connection + ).await.map_err(|err| { + error!("Error looking up user's ({}) existing subscription: {:?}", &user_metadata.user_token_typed, err); + CommonWebError::ServerError // NB: This was probably *our* fault. + })?; - let artcraft_subscriptions = user_metadata - .existing_subscription_keys - .iter() - .filter(|it| it.internal_subscription_namespace == PaymentsNamespace::Artcraft) - .collect::>(); - - // TODO: This will not handle a future where we have multiple "namespaces" or can offer users more than one subscription. - // It will actively block users from subscribing to two or more websites. - if !artcraft_subscriptions.is_empty() { - return Err(CommonWebError::BadInputWithSimpleMessage("user already has plan".to_string())) + if maybe_active_subscription.is_some() { + return Err(CommonWebError::BadInputWithSimpleMessage( + "user already has an active subscription plan; use the portal to update the plan" + .to_string())) } + let maybe_inactive_subscription = find_possibly_inactive_first_subscription_for_owner_user_using_connection( + &user_metadata.user_token_typed, + PaymentsNamespace::Artcraft, + &mut mysql_connection + ).await.map_err(|err| { + error!("Error looking up user's ({}) possibly existing inactive subscription: {:?}", + &user_metadata.user_token_typed, err); + CommonWebError::ServerError // NB: This was probably *our* fault. + })?; + + let maybe_existing_inactive_stripe_customer_id = maybe_inactive_subscription.as_ref() + .map(|sub| sub.stripe_customer_id.as_str()); + let success_url = stripe_config.checkout_success_url.clone(); let cancel_url = stripe_config.checkout_cancel_url.clone(); @@ -150,8 +179,9 @@ pub async fn stripe_artcraft_create_subscription_session_handler( ..Default::default() }); - if let Some(existing_stripe_customer_id) = user_metadata.maybe_existing_stripe_customer_id.as_deref() { - match CustomerId::from_str(existing_stripe_customer_id) { + if let Some(existing_inactive_stripe_customer_id) = maybe_existing_inactive_stripe_customer_id{ + info!("Adding existing stripe customer id to checkout session: {}", existing_inactive_stripe_customer_id); + match CustomerId::from_str(existing_inactive_stripe_customer_id) { Ok(customer_id) => { checkout_builder = checkout_builder.customer(customer_id); }