diff --git a/crates/component_subsystem/billing/src/stripe/http_endpoints/stripe_webhook_handler.rs b/crates/component_subsystem/billing/src/stripe/http_endpoints/stripe_webhook_handler.rs index 935d7c7f89..ec390a5257 100644 --- a/crates/component_subsystem/billing/src/stripe/http_endpoints/stripe_webhook_handler.rs +++ b/crates/component_subsystem/billing/src/stripe/http_endpoints/stripe_webhook_handler.rs @@ -140,17 +140,17 @@ pub async fn stripe_webhook_handler( EventType::CustomerSubscriptionCreated => { if let EventObject::Subscription(subscription) = webhook_payload.data.object { - webhook_summary = customer_subscription_created_handler(&subscription)?; + webhook_summary = customer_subscription_created_handler(&subscription, &mysql_pool).await?; } } EventType::CustomerSubscriptionUpdated => { if let EventObject::Subscription(subscription) = webhook_payload.data.object { - webhook_summary = customer_subscription_updated_handler(&subscription)?; + webhook_summary = customer_subscription_updated_handler(&subscription, &mysql_pool).await?; } } EventType::CustomerSubscriptionDeleted => { if let EventObject::Subscription(subscription) = webhook_payload.data.object { - webhook_summary = customer_subscription_deleted_handler(&subscription)?; + webhook_summary = customer_subscription_deleted_handler(&subscription, &mysql_pool).await?; } } @@ -202,7 +202,7 @@ pub async fn stripe_webhook_handler( EventType::PaymentIntentSucceeded => { if let EventObject::PaymentIntent(payment_intent) = webhook_payload.data.object { - let _r = payment_intent_succeeded_handler(&payment_intent)?; + webhook_summary = payment_intent_succeeded_handler(&payment_intent)?; } } @@ -219,7 +219,7 @@ pub async fn stripe_webhook_handler( EventType::ChargeSucceeded => { if let EventObject::Charge(charge) = webhook_payload.data.object { - let _r = charge_succeeded_handler(&charge)?; + webhook_summary = charge_succeeded_handler(&charge)?; } } diff --git a/crates/component_subsystem/billing/src/stripe/webhook_event_handlers/customer_subscription/customer_subscription_created_handler.rs b/crates/component_subsystem/billing/src/stripe/webhook_event_handlers/customer_subscription/customer_subscription_created_handler.rs index a56465e3c8..ace827c19d 100644 --- a/crates/component_subsystem/billing/src/stripe/webhook_event_handlers/customer_subscription/customer_subscription_created_handler.rs +++ b/crates/component_subsystem/billing/src/stripe/webhook_event_handlers/customer_subscription/customer_subscription_created_handler.rs @@ -2,23 +2,56 @@ use crate::stripe::webhook_event_handlers::customer_subscription::subscription_e use crate::stripe::webhook_event_handlers::stripe_webhook_error::StripeWebhookError; use crate::stripe::webhook_event_handlers::stripe_webhook_summary::StripeWebhookSummary; use log::{error, warn}; +use sqlx::MySqlPool; use stripe::Subscription; +use database_queries::queries::billing::subscriptions::upsert_subscription_by_stripe_id::UpsertSubscriptionByStripeId; /// Handle event type: 'customer.subscription.created' /// Sent when the subscription is created. The subscription status may be incomplete if customer /// authentication is required to complete the payment or if you set payment_behavior to /// default_incomplete. For more details, read about subscription payment behavior. -pub fn customer_subscription_created_handler(subscription: &Subscription) -> Result { +pub async fn customer_subscription_created_handler( + subscription: &Subscription, + mysql_pool: &MySqlPool, +) -> Result { + let summary = subscription_summary_extractor(subscription) .map_err(|err| { error!("Error extracting subscription from 'customer.subscription.created' payload: {:?}", err); StripeWebhookError::ServerError // NB: This was probably *our* fault. })?; + match summary.user_token.as_deref() { + None => { + error!("Subscription does not have a user token associated with it: {}", + &summary.stripe_subscription_id); + } + Some(user_token) => { + let upsert = UpsertSubscriptionByStripeId { + stripe_subscription_id: &summary.stripe_subscription_id, + user_token: user_token, + subscription_category: "todo", + subscription_product_key: "todo", + maybe_stripe_product_id: Some(&summary.stripe_product_id), + maybe_stripe_customer_id: Some(&summary.stripe_customer_id), + maybe_stripe_is_production: Some(summary.stripe_is_production), + subscription_created_at: summary.subscription_period_start, + subscription_expires_at: summary.subscription_period_end, + }; + + let _r = upsert.upsert(mysql_pool) + .await + .map_err(|err| { + error!("Mysql error: {:?}", err); + StripeWebhookError::ServerError + })?; + } + } + Ok(StripeWebhookSummary { maybe_user_token: summary.user_token, maybe_event_entity_id: Some(summary.stripe_subscription_id), maybe_stripe_customer_id: Some(summary.stripe_customer_id), - event_was_handled: false, + event_was_handled: true, }) } diff --git a/crates/component_subsystem/billing/src/stripe/webhook_event_handlers/customer_subscription/customer_subscription_deleted_handler.rs b/crates/component_subsystem/billing/src/stripe/webhook_event_handlers/customer_subscription/customer_subscription_deleted_handler.rs index 22277e9581..38b53974a2 100644 --- a/crates/component_subsystem/billing/src/stripe/webhook_event_handlers/customer_subscription/customer_subscription_deleted_handler.rs +++ b/crates/component_subsystem/billing/src/stripe/webhook_event_handlers/customer_subscription/customer_subscription_deleted_handler.rs @@ -2,11 +2,15 @@ use crate::stripe::webhook_event_handlers::customer_subscription::subscription_e use crate::stripe::webhook_event_handlers::stripe_webhook_error::StripeWebhookError; use crate::stripe::webhook_event_handlers::stripe_webhook_summary::StripeWebhookSummary; use log::{error, warn}; +use sqlx::MySqlPool; use stripe::Subscription; /// Handle event type: 'customer.subscription.deleted' /// Sent when a customer’s subscription ends. -pub fn customer_subscription_deleted_handler(subscription: &Subscription) -> Result { +pub async fn customer_subscription_deleted_handler( + subscription: &Subscription, + mysql_pool: &MySqlPool, +) -> Result { let summary = subscription_summary_extractor(subscription) .map_err(|err| { error!("Error extracting subscription from 'customer.subscription.deleted' payload: {:?}", err); diff --git a/crates/component_subsystem/billing/src/stripe/webhook_event_handlers/customer_subscription/customer_subscription_updated_handler.rs b/crates/component_subsystem/billing/src/stripe/webhook_event_handlers/customer_subscription/customer_subscription_updated_handler.rs index 48d76c11a3..2cb323601a 100644 --- a/crates/component_subsystem/billing/src/stripe/webhook_event_handlers/customer_subscription/customer_subscription_updated_handler.rs +++ b/crates/component_subsystem/billing/src/stripe/webhook_event_handlers/customer_subscription/customer_subscription_updated_handler.rs @@ -1,21 +1,54 @@ use crate::stripe::webhook_event_handlers::customer_subscription::subscription_event_extractor::subscription_summary_extractor; use crate::stripe::webhook_event_handlers::stripe_webhook_error::StripeWebhookError; use crate::stripe::webhook_event_handlers::stripe_webhook_summary::StripeWebhookSummary; +use database_queries::queries::billing::subscriptions::upsert_subscription_by_stripe_id::UpsertSubscriptionByStripeId; use log::{error, warn}; +use sqlx::MySqlPool; use stripe::Subscription; /// Handle event type: 'customer.subscription.updated' -pub fn customer_subscription_updated_handler(subscription: &Subscription) -> Result { +pub async fn customer_subscription_updated_handler( + subscription: &Subscription, + mysql_pool: &MySqlPool, +) -> Result { + let summary = subscription_summary_extractor(subscription) .map_err(|err| { error!("Error extracting subscription from 'customer.subscription.updated' payload: {:?}", err); StripeWebhookError::ServerError // NB: This was probably *our* fault. })?; + match summary.user_token.as_deref() { + None => { + error!("Subscription does not have a user token associated with it: {}", + &summary.stripe_subscription_id); + } + Some(user_token) => { + let upsert = UpsertSubscriptionByStripeId { + stripe_subscription_id: &summary.stripe_subscription_id, + user_token: user_token, + subscription_category: "todo", + subscription_product_key: "todo", + maybe_stripe_product_id: Some(&summary.stripe_product_id), + maybe_stripe_customer_id: Some(&summary.stripe_customer_id), + maybe_stripe_is_production: Some(summary.stripe_is_production), + subscription_created_at: summary.subscription_period_start, + subscription_expires_at: summary.subscription_period_end, + }; + + let _r = upsert.upsert(mysql_pool) + .await + .map_err(|err| { + error!("Mysql error: {:?}", err); + StripeWebhookError::ServerError + })?; + } + } + Ok(StripeWebhookSummary { maybe_user_token: summary.user_token, maybe_event_entity_id: Some(summary.stripe_subscription_id), maybe_stripe_customer_id: Some(summary.stripe_customer_id), - event_was_handled: false, + event_was_handled: true, }) } diff --git a/crates/component_subsystem/billing/src/stripe/webhook_event_handlers/payment_intent/payment_intent_succeeded_handler.rs b/crates/component_subsystem/billing/src/stripe/webhook_event_handlers/payment_intent/payment_intent_succeeded_handler.rs index 10fbcd6d63..cf7cc8f7ce 100644 --- a/crates/component_subsystem/billing/src/stripe/webhook_event_handlers/payment_intent/payment_intent_succeeded_handler.rs +++ b/crates/component_subsystem/billing/src/stripe/webhook_event_handlers/payment_intent/payment_intent_succeeded_handler.rs @@ -2,11 +2,13 @@ use container_common::anyhow_result::AnyhowResult; use crate::stripe::helpers::common_metadata_keys::METADATA_USER_TOKEN; use crate::stripe::helpers::expand_customer_id::expand_customer_id; use crate::stripe::webhook_event_handlers::stripe_webhook_error::StripeWebhookError; +use crate::stripe::webhook_event_handlers::stripe_webhook_summary::StripeWebhookSummary; use log::warn; use stripe::{Invoice, PaymentIntent}; // Handle event type: 'payment_intent.succeeded' -pub fn payment_intent_succeeded_handler(payment_intent: &PaymentIntent) -> Result<(), StripeWebhookError> { +pub fn payment_intent_succeeded_handler(payment_intent: &PaymentIntent) -> Result { + let payment_intent_id = payment_intent.id.to_string(); let payment_intent_status = payment_intent.status; @@ -20,8 +22,10 @@ pub fn payment_intent_succeeded_handler(payment_intent: &PaymentIntent) -> Resul let maybe_user_token = payment_intent.metadata.get(METADATA_USER_TOKEN) .map(|t| t.to_string()); - warn!(">>> payment_intent.succeeded: {:?}, {:?}, {:?}", - payment_intent_status, maybe_stripe_customer_id, maybe_user_token); - - Ok(()) + Ok(StripeWebhookSummary { + maybe_user_token: maybe_user_token, + maybe_event_entity_id: Some(payment_intent_id), + maybe_stripe_customer_id: maybe_stripe_customer_id, + event_was_handled: false, + }) } diff --git a/crates/lib/database_queries/src/queries/billing/mod.rs b/crates/lib/database_queries/src/queries/billing/mod.rs index f6fa48673a..14c559d1a3 100644 --- a/crates/lib/database_queries/src/queries/billing/mod.rs +++ b/crates/lib/database_queries/src/queries/billing/mod.rs @@ -1 +1,2 @@ -pub mod stripe; \ No newline at end of file +pub mod stripe; +pub mod subscriptions; \ No newline at end of file diff --git a/crates/lib/database_queries/src/queries/billing/subscriptions/mod.rs b/crates/lib/database_queries/src/queries/billing/subscriptions/mod.rs new file mode 100644 index 0000000000..9d00401b56 --- /dev/null +++ b/crates/lib/database_queries/src/queries/billing/subscriptions/mod.rs @@ -0,0 +1 @@ +pub mod upsert_subscription_by_stripe_id; \ No newline at end of file diff --git a/crates/lib/database_queries/src/queries/billing/subscriptions/upsert_subscription_by_stripe_id.rs b/crates/lib/database_queries/src/queries/billing/subscriptions/upsert_subscription_by_stripe_id.rs new file mode 100644 index 0000000000..2d1ad196c7 --- /dev/null +++ b/crates/lib/database_queries/src/queries/billing/subscriptions/upsert_subscription_by_stripe_id.rs @@ -0,0 +1,96 @@ +use anyhow::anyhow; +use chrono::NaiveDateTime; +use container_common::anyhow_result::AnyhowResult; +use sqlx::MySqlPool; +use crate::tokens::Tokens; + +// TODO: Make a trait with default impls to handle common query concerns. + +pub struct UpsertSubscriptionByStripeId <'a> { + /// Stripe's assigned ID for the subscription + /// This acts as an externally-provided unique key for records in this table. + pub stripe_subscription_id: &'a str, + + /// Internal user token + pub user_token: &'a str, + + /// The platform key, eg. "fakeyou", "storyteller_stream", "symphonia", etc. + pub subscription_category: &'a str, + + /// The name of the product the user is subscribing to within the category. + pub subscription_product_key: &'a str, + + pub maybe_stripe_product_id: Option<&'a str>, + pub maybe_stripe_customer_id: Option<&'a str>, + pub maybe_stripe_is_production: Option, + + pub subscription_created_at: NaiveDateTime, + + /// When the subscription is set to expire. + /// This controls whether it is active or not. + pub subscription_expires_at: NaiveDateTime, +} + +impl <'a> UpsertSubscriptionByStripeId <'a> { + + pub async fn upsert(&'a self, mysql_pool: &MySqlPool) -> AnyhowResult<()> { + let token = Tokens::new_subscription_token()?; + + // NB: The following behaviors are intentional + // - We only set the token initially; changing it obviously breaks. + // - We only set the user token the first time. Stripe entities may remain coherent, + // but it isn't our business to attempt changes on webhooks. + // - Other "static" fields do not need to change on update, either. + let query = sqlx::query!( + r#" +INSERT INTO user_subscriptions +SET + token = ?, + user_token = ?, + subscription_category = ?, + subscription_product_key = ?, + + maybe_stripe_subscription_id = ?, + + maybe_stripe_product_id = ?, + maybe_stripe_customer_id = ?, + maybe_stripe_is_production = ?, + + subscription_created_at = ?, + subscription_expires_at = ?, + + version = version + 1 + +ON DUPLICATE KEY UPDATE + subscription_expires_at = ?, + + version = version + 1 + "#, + token, + self.user_token, + self.subscription_category, + self.subscription_product_key, + + self.stripe_subscription_id, + + self.maybe_stripe_product_id, + self.maybe_stripe_customer_id, + self.maybe_stripe_is_production, + + self.subscription_created_at, + self.subscription_expires_at, + + // Upsert + self.subscription_expires_at, + ); + + let query_result = query.execute(mysql_pool).await; + + let _record_id = match query_result { + Ok(res) => res.last_insert_id(), + Err(err) => return Err(anyhow!("Error upserting subscription record: {:?}", err)), + }; + + Ok(()) + } +} diff --git a/crates/lib/database_queries/src/tokens.rs b/crates/lib/database_queries/src/tokens.rs index 2b8ed58e04..200a462cda 100644 --- a/crates/lib/database_queries/src/tokens.rs +++ b/crates/lib/database_queries/src/tokens.rs @@ -92,4 +92,10 @@ impl Tokens { pub fn new_twitch_event_rule() -> AnyhowResult { random_prefix_crockford_token("TER:", 32, false) } + + // ========== [Billing] Tokens not typically visible to users ========== + + pub fn new_subscription_token() -> AnyhowResult { + random_prefix_crockford_token("SUB:", 32, false) + } } diff --git a/crates/service/storyteller_web/src/main.rs b/crates/service/storyteller_web/src/main.rs index aa416ebd76..18438499bc 100644 --- a/crates/service/storyteller_web/src/main.rs +++ b/crates/service/storyteller_web/src/main.rs @@ -23,6 +23,7 @@ pub mod configs; pub mod http_server; pub mod routes; pub mod server_state; +pub mod subscriptions; pub mod threads; pub mod util; pub mod validations; diff --git a/crates/service/storyteller_web/src/subscriptions/mod.rs b/crates/service/storyteller_web/src/subscriptions/mod.rs new file mode 100644 index 0000000000..8accc2414b --- /dev/null +++ b/crates/service/storyteller_web/src/subscriptions/mod.rs @@ -0,0 +1,2 @@ +pub mod subscription_plan; +pub mod subscription_plan_list; \ No newline at end of file diff --git a/crates/service/storyteller_web/src/subscriptions/subscription_plan.rs b/crates/service/storyteller_web/src/subscriptions/subscription_plan.rs new file mode 100644 index 0000000000..1cc190cb37 --- /dev/null +++ b/crates/service/storyteller_web/src/subscriptions/subscription_plan.rs @@ -0,0 +1,33 @@ + +/** + + User has: + + fakeyou, pro + fakeyou, pro + stream, basic + +*/ + +#[derive(Clone)] +pub struct SubscriptionPlan { + /// Slug is the unique identifier for the subscription + /// It must be human-readability friendly, consist of only lowercase alphanumerics, + /// underscores, and hyphens, and be 32 characters or fewer. + pub slug: String, +} + +impl SubscriptionPlan { + + pub fn base_tts_priority_level(&self) -> u32 { + 0 + } + + pub fn base_api_tts_priority_level(&self) -> Option { + Some(0) + } + + pub fn has_feature_foo(&self) -> bool { + false + } +} diff --git a/crates/service/storyteller_web/src/subscriptions/subscription_plan_list.rs b/crates/service/storyteller_web/src/subscriptions/subscription_plan_list.rs new file mode 100644 index 0000000000..e69de29bb2