diff --git a/_sql/migrations_squashed/stripe_webhook_event_logs.sql b/_sql/migrations_squashed/stripe_webhook_event_logs.sql index 903ec73044..7f2416e2c8 100644 --- a/_sql/migrations_squashed/stripe_webhook_event_logs.sql +++ b/_sql/migrations_squashed/stripe_webhook_event_logs.sql @@ -43,6 +43,11 @@ CREATE TABLE stripe_webhook_event_logs ( -- Some event types may attach a user token maybe_user_token VARCHAR(32) DEFAULT NULL, + -- TODO: Add an internal optional entity token this resolves to. + -- eg. `wallets.token` or `user_subscriptions.token`. + -- NB: This field does not exist yet ! + maybe_internal_entity_token VARCHAR(32) DEFAULT NULL, + -- ========== TIMESTAMPS ========== created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, diff --git a/_sql/migrations_squashed/user_tables/user_subscriptions.sql b/_sql/migrations_squashed/user_tables/user_subscriptions.sql index df01df111e..2f6d2c30a1 100644 --- a/_sql/migrations_squashed/user_tables/user_subscriptions.sql +++ b/_sql/migrations_squashed/user_tables/user_subscriptions.sql @@ -59,6 +59,8 @@ CREATE TABLE user_subscriptions ( maybe_stripe_is_production BOOLEAN DEFAULT NULL, + -- TODO: add `maybe_billing_cycle_anchor` + -- ========== VECTOR CLOCK ========== -- Incremented with every update. diff --git a/crates/service/plugins/billing_artcraft/src/billing_action_fulfillment/artcraft_billing_action.rs b/crates/service/plugins/billing_artcraft/src/billing_action_fulfillment/artcraft_billing_action.rs index af26a2cdc7..7296137091 100644 --- a/crates/service/plugins/billing_artcraft/src/billing_action_fulfillment/artcraft_billing_action.rs +++ b/crates/service/plugins/billing_artcraft/src/billing_action_fulfillment/artcraft_billing_action.rs @@ -9,15 +9,13 @@ use tokens::tokens::wallets::WalletToken; pub enum ArtcraftBillingAction { /// An ignorable event. IgnorableEvent, - + /// A user purchased wallet credits. WalletCreditsPurchase(WalletCreditsPurchaseEvent), - + SubscriptionCreated(UpsertableSubscriptionDetails), SubscriptionUpdated(UpsertableSubscriptionDetails), - - // TODO - SubscriptionDeleted, + SubscriptionDeleted(UpsertableSubscriptionDetails), // TODO: SubscriptionRenewalBillingFailed, @@ -27,32 +25,32 @@ pub enum ArtcraftBillingAction { pub struct WalletCreditsPurchaseEvent { pub owner_user_token: UserToken, - - // We might have sent the wallet to stripe. + + // We might have sent the wallet to stripe. // If not, we'll need to look it up or create one. pub maybe_wallet_token: Option, - + pub pack: StripeArtcraftCreditsPackInfo, - + // NB: This is a multiplier on the pack's base value. pub quantity: u64, } pub struct UpsertableSubscriptionDetails { - /// Stripe's subscription_id is a unique foreign key in the + /// Stripe's subscription_id is a unique foreign key in the /// `users_subscriptions` table! pub stripe_subscription_id: String, - // Other stripe foreign keys ... - + // Other stripe foreign keys ... + pub stripe_customer_id: String, pub stripe_product_id: String, pub stripe_price_id: String, /// Artcraft subscription product pub subscription: StripeArtcraftSubscriptionInfo, - + /// Artcraft user pub owner_user_token: UserToken, @@ -60,7 +58,7 @@ pub struct UpsertableSubscriptionDetails { pub stripe_subscription_status: StripeSubscriptionStatus, pub stripe_recurring_interval : StripeRecurringInterval, - + pub stripe_is_production: bool, /// When the subscription was "created" in Stripe (including any backdating) diff --git a/crates/service/plugins/billing_artcraft/src/billing_action_fulfillment/subscriptions/upsert_subscription_details.rs b/crates/service/plugins/billing_artcraft/src/billing_action_fulfillment/subscriptions/upsert_subscription_details.rs index a560c5f3b9..677211647b 100644 --- a/crates/service/plugins/billing_artcraft/src/billing_action_fulfillment/subscriptions/upsert_subscription_details.rs +++ b/crates/service/plugins/billing_artcraft/src/billing_action_fulfillment/subscriptions/upsert_subscription_details.rs @@ -1,13 +1,50 @@ use crate::billing_action_fulfillment::artcraft_billing_action::UpsertableSubscriptionDetails; use enums::common::subscription_namespace::SubscriptionNamespace; +use log::info; +use mysql_queries::queries::users::user_subscriptions::get_user_subscription_by_stripe_subscription_id_transactional::get_user_subscription_by_stripe_subscription_id_transactional; use mysql_queries::queries::users::user_subscriptions::upsert_user_subscription_by_stripe_id::UpsertUserSubscription; +use reusable_types::stripe::stripe_subscription_status::StripeSubscriptionStatus; + +#[derive(Copy, Clone)] +pub enum CrudType { + Create, + Update, + Delete, +} /// Record the credits pack purchase pub async fn upsert_subscription_details( details: &UpsertableSubscriptionDetails, + crud_type: CrudType, transaction: &mut sqlx::Transaction<'_, sqlx::MySql>, ) -> anyhow::Result<()> { + let maybe_existing_subscription = get_user_subscription_by_stripe_subscription_id_transactional( + &details.stripe_subscription_id, + transaction, + ).await?; + + // NB: It's possible to receive events out of order. + // We can get an 'update' after a 'create', etc. + if let Some(existing_sub) = maybe_existing_subscription { + match crud_type { + CrudType::Create => { + // We won't want to play a `create` event on top of something already existing. + info!("Subscription record already exists. Skipping insert."); + return Ok(()); // Turn this into a no-op. This is stale info. + } + CrudType::Update | CrudType::Delete => { + match existing_sub.maybe_stripe_subscription_status { + Some(StripeSubscriptionStatus::Canceled) => { + // NB: The stored subscription already had a terminal status and the subscription cannot be updated any further. + info!("Existing subscription record already in terminal state. Skipping updates."); + return Ok(()); // Turn this into a no-op. This is stale info. + } + _ => {} // Fall-through + } + } + } + } let upsert = UpsertUserSubscription { // This is the primary key diff --git a/crates/service/plugins/billing_artcraft/src/billing_action_fulfillment/transactionally_fulfill_artcraft_billing_action.rs b/crates/service/plugins/billing_artcraft/src/billing_action_fulfillment/transactionally_fulfill_artcraft_billing_action.rs index e246fa44a0..9d7129dc8f 100644 --- a/crates/service/plugins/billing_artcraft/src/billing_action_fulfillment/transactionally_fulfill_artcraft_billing_action.rs +++ b/crates/service/plugins/billing_artcraft/src/billing_action_fulfillment/transactionally_fulfill_artcraft_billing_action.rs @@ -4,7 +4,7 @@ use anyhow::anyhow; use errors::AnyhowResult; use log::{info, warn}; use sqlx::Transaction; -use crate::billing_action_fulfillment::subscriptions::upsert_subscription_details::upsert_subscription_details; +use crate::billing_action_fulfillment::subscriptions::upsert_subscription_details::{upsert_subscription_details, CrudType}; pub async fn transactionally_fulfill_artcraft_billing_action( event: &ArtcraftBillingAction, @@ -26,8 +26,13 @@ pub async fn transactionally_fulfill_artcraft_billing_action( ).await?; } ArtcraftBillingAction::SubscriptionCreated(subscription_details) => { - upsert_subscription_details(subscription_details, transaction).await?; - + upsert_subscription_details(subscription_details, CrudType::Create, transaction).await?; + } + ArtcraftBillingAction::SubscriptionUpdated(subscription_details) => { + upsert_subscription_details(subscription_details, CrudType::Update, transaction).await?; + } + ArtcraftBillingAction::SubscriptionDeleted(subscription_details) => { + upsert_subscription_details(subscription_details, CrudType::Delete, transaction).await?; } _ => { return Err(anyhow!("Unhandled billing action in fulfillment")); diff --git a/crates/service/plugins/billing_artcraft/src/endpoints/webhook/webhook_event_enrichment/customer_subscription/customer_subscription_created_handler.rs b/crates/service/plugins/billing_artcraft/src/endpoints/webhook/webhook_event_enrichment/customer_subscription/customer_subscription_created_handler.rs index 2c5b2e7e31..1b43a5de06 100644 --- a/crates/service/plugins/billing_artcraft/src/endpoints/webhook/webhook_event_enrichment/customer_subscription/customer_subscription_created_handler.rs +++ b/crates/service/plugins/billing_artcraft/src/endpoints/webhook/webhook_event_enrichment/customer_subscription/customer_subscription_created_handler.rs @@ -1,22 +1,8 @@ -use crate::billing_action_fulfillment::artcraft_billing_action::{ArtcraftBillingAction, UpsertableSubscriptionDetails, WalletCreditsPurchaseEvent}; -use crate::configs::get_artcraft_product_by_stripe_id_and_env::get_artcraft_product_by_stripe_id_and_env; -use crate::configs::stripe_artcraft_generic_product_info::StripeArtcraftGenericProductInfo; +use crate::billing_action_fulfillment::artcraft_billing_action::ArtcraftBillingAction; use crate::endpoints::webhook::common::enriched_webhook_event::EnrichedWebhookEvent; use crate::endpoints::webhook::common::stripe_artcraft_webhook_error::StripeArtcraftWebhookError; -use crate::endpoints::webhook::common::webhook_event_log_summary::WebhookEventLogSummary; -use crate::endpoints::webhook::webhook_event_enrichment::customer_subscription::calculate_subscription_end_date::calculate_subscription_end_date; -use crate::endpoints::webhook::webhook_event_enrichment::customer_subscription::subscription_event_extractor::subscription_summary_extractor; -use crate::utils::metadata::get_metadata_user_token::get_metadata_user_token; -use crate::utils::metadata::get_metadata_wallet_token::get_metadata_wallet_token; -use enums::common::subscription_namespace::SubscriptionNamespace; -use log::{error, warn}; -use mysql_queries::queries::users::user::update::update_user_record_with_new_stripe_customer_id::{update_user_record_with_new_stripe_customer_id, update_user_record_with_new_stripe_customer_id_with_connection}; -use mysql_queries::queries::users::user_subscriptions::get_user_subscription_by_stripe_subscription_id::{get_user_subscription_by_stripe_subscription_id, get_user_subscription_by_stripe_subscription_id_with_connection}; -use mysql_queries::queries::users::user_subscriptions::get_user_subscription_by_stripe_subscription_id_transactional::get_user_subscription_by_stripe_subscription_id_transactional; -use mysql_queries::queries::users::user_subscriptions::upsert_user_subscription_by_stripe_id::UpsertUserSubscription; +use crate::endpoints::webhook::webhook_event_enrichment::customer_subscription::extract_subscription_details::extract_subscription_details; use reusable_types::server_environment::ServerEnvironment; -use sqlx::pool::PoolConnection; -use sqlx::{MySql, MySqlConnection, MySqlPool, Transaction}; use stripe_shared::Subscription; /// Handle event type: 'customer.subscription.created' @@ -26,101 +12,12 @@ use stripe_shared::Subscription; pub async fn customer_subscription_created_handler( subscription: &Subscription, server_environment: ServerEnvironment, - transaction: &mut Transaction<'_, MySql>, ) -> Result { - let summary = subscription_summary_extractor(subscription) - .map_err(|err| { - let reason = format!("Error extracting subscription from 'customer.subscription.created' payload: {:?}", err); - error!("{}", reason); - StripeArtcraftWebhookError::ServerError(reason) // NB: This was probably *our* fault. - })?; - - let mut event_log_summary = WebhookEventLogSummary { - maybe_user_token: summary.user_token.clone(), - maybe_event_entity_id: Some(summary.stripe_subscription_id.clone()), - maybe_stripe_customer_id: Some(summary.stripe_customer_id.clone()), - action_was_taken: false, - should_ignore_retry: false, - }; - - let maybe_existing_subscription = get_user_subscription_by_stripe_subscription_id_transactional( - &summary.stripe_subscription_id, transaction) - .await - .map_err(|err| { - let reason = format!("Mysql error: {:?}", err); - error!("{}", reason); - StripeArtcraftWebhookError::ServerError(reason) - })?; - - // NB: It's possible to receive events out of order. - // We won't want to play a `create` event on top. - if maybe_existing_subscription.is_some() { - event_log_summary.should_ignore_retry = true; - return Ok(EnrichedWebhookEvent::from_actionless_log(event_log_summary)); - } - - let maybe_product = get_artcraft_product_by_stripe_id_and_env( - &summary.stripe_product_id, server_environment); - - - let product = match maybe_product { - Some(StripeArtcraftGenericProductInfo::Subscription(subscription)) => subscription, - Some(StripeArtcraftGenericProductInfo::CreditsPack(credits_pack)) => { - error!("Received 'customer.subscription.updated' for a credits pack product ({}). This should not happen.", &credits_pack.slug.to_str()); - event_log_summary.should_ignore_retry = true; - return Ok(EnrichedWebhookEvent::from_actionless_log(event_log_summary)); - } - None => { - error!("No matching product for stripe product ID: {}", &summary.stripe_product_id); - event_log_summary.should_ignore_retry = true; - return Ok(EnrichedWebhookEvent::from_actionless_log(event_log_summary)); - } - }; - - // TODO: Multiple ways to get this; better ways to get this - let user_token = match &summary.user_token { - Some(token) => token.clone(), - None => { - warn!("No user token found in `customer.subscription.created` metadata. Cannot proceed."); - return Err(StripeArtcraftWebhookError::BadRequest("no user token in customer.subscription.created".to_string())); - } - }; - - event_log_summary.should_ignore_retry = true; - - let calculated_end_date = calculate_subscription_end_date(&summary); + let summary = extract_subscription_details(subscription, server_environment)?; Ok(EnrichedWebhookEvent { - maybe_billing_action: Some(ArtcraftBillingAction::SubscriptionCreated( - UpsertableSubscriptionDetails { - // Unique Stripe foreign key - stripe_customer_id: summary.stripe_customer_id, - - // Other Stripe foreign keys - stripe_subscription_id: summary.stripe_subscription_id, - stripe_price_id: summary.stripe_price_id, - stripe_product_id: summary.stripe_product_id.clone(), // TODO: Avoid clone. - - // Artcraft info - subscription: product.clone(), // TODO: Avoid clone - owner_user_token: user_token, - - // Status - stripe_subscription_status: summary.stripe_subscription_status, - - // Timing data - stripe_recurring_interval: summary.subscription_interval, - calculated_subscription_expires_at: calculated_end_date, - subscription_start_at: summary.subscription_start_date, - current_billing_period_start_at: summary.current_billing_period_start, - current_billing_period_end_at: summary.current_billing_period_end, - maybe_cancel_at: summary.maybe_cancel_at, - maybe_canceled_at: summary.maybe_canceled_at, - - // Misc - stripe_is_production: summary.stripe_is_production, - })), - webhook_event_log_summary: event_log_summary, + maybe_billing_action: Some(ArtcraftBillingAction::SubscriptionCreated(summary.subscription_details)), + webhook_event_log_summary: summary.event_log_summary, }) } diff --git a/crates/service/plugins/billing_artcraft/src/endpoints/webhook/webhook_event_enrichment/customer_subscription/customer_subscription_deleted_handler.rs b/crates/service/plugins/billing_artcraft/src/endpoints/webhook/webhook_event_enrichment/customer_subscription/customer_subscription_deleted_handler.rs index 5725083741..e6e53f95fa 100644 --- a/crates/service/plugins/billing_artcraft/src/endpoints/webhook/webhook_event_enrichment/customer_subscription/customer_subscription_deleted_handler.rs +++ b/crates/service/plugins/billing_artcraft/src/endpoints/webhook/webhook_event_enrichment/customer_subscription/customer_subscription_deleted_handler.rs @@ -1,18 +1,8 @@ -use crate::configs::get_artcraft_product_by_stripe_id_and_env::get_artcraft_product_by_stripe_id_and_env; -use crate::configs::stripe_artcraft_generic_product_info::StripeArtcraftGenericProductInfo; +use crate::billing_action_fulfillment::artcraft_billing_action::ArtcraftBillingAction; +use crate::endpoints::webhook::common::enriched_webhook_event::EnrichedWebhookEvent; use crate::endpoints::webhook::common::stripe_artcraft_webhook_error::StripeArtcraftWebhookError; -use crate::endpoints::webhook::common::webhook_event_log_summary::WebhookEventLogSummary; -use crate::endpoints::webhook::webhook_event_enrichment::customer_subscription::calculate_subscription_end_date::calculate_subscription_end_date; -use crate::endpoints::webhook::webhook_event_enrichment::customer_subscription::subscription_event_extractor::subscription_summary_extractor; -use enums::common::subscription_namespace::SubscriptionNamespace; -use log::error; -use mysql_queries::queries::users::user_subscriptions::get_user_subscription_by_stripe_subscription_id::{get_user_subscription_by_stripe_subscription_id, get_user_subscription_by_stripe_subscription_id_with_connection}; -use mysql_queries::queries::users::user_subscriptions::get_user_subscription_by_stripe_subscription_id_transactional::get_user_subscription_by_stripe_subscription_id_transactional; -use mysql_queries::queries::users::user_subscriptions::upsert_user_subscription_by_stripe_id::UpsertUserSubscription; +use crate::endpoints::webhook::webhook_event_enrichment::customer_subscription::extract_subscription_details::extract_subscription_details; use reusable_types::server_environment::ServerEnvironment; -use reusable_types::stripe::stripe_subscription_status::StripeSubscriptionStatus; -use sqlx::pool::PoolConnection; -use sqlx::{MySql, MySqlPool, Transaction}; use stripe_shared::Subscription; /// Handle event type: 'customer.subscription.deleted' @@ -20,101 +10,12 @@ use stripe_shared::Subscription; pub async fn customer_subscription_deleted_handler( subscription: &Subscription, server_environment: ServerEnvironment, - transaction: &mut Transaction<'_, MySql>, -) -> Result { +) -> Result { - let summary = subscription_summary_extractor(subscription) - .map_err(|err| { - let reason = format!("Error extracting subscription from 'customer.subscription.deleted' payload: {:?}", err); - error!("{}", reason); - StripeArtcraftWebhookError::ServerError(reason) // NB: This was probably *our* fault. - })?; + let summary = extract_subscription_details(subscription, server_environment)?; - let mut should_process_update = true; - - let mut result = WebhookEventLogSummary { - maybe_user_token: summary.user_token.clone(), - maybe_event_entity_id: Some(summary.stripe_subscription_id.clone()), - maybe_stripe_customer_id: Some(summary.stripe_customer_id.clone()), - action_was_taken: false, - should_ignore_retry: false, - }; - - let maybe_product = get_artcraft_product_by_stripe_id_and_env( - &summary.stripe_product_id, server_environment); - - let product = match maybe_product { - Some(StripeArtcraftGenericProductInfo::Subscription(subscription)) => subscription, - Some(StripeArtcraftGenericProductInfo::CreditsPack(credits_pack)) => { - error!("Received 'customer.subscription.updated' for a credits pack product ({}). This should not happen.", &credits_pack.slug.to_str()); - result.should_ignore_retry = true; - return Ok(result); - } - None => { - error!("No matching product for stripe product ID: {}", &summary.stripe_product_id); - result.should_ignore_retry = true; - return Ok(result); - } - }; - - // NB: It's possible to receive events out of order. - let maybe_existing_subscription = get_user_subscription_by_stripe_subscription_id_transactional( - &summary.stripe_subscription_id, transaction) - .await - .map_err(|err| { - let reason = format!("Mysql error: {:?}", err); - error!("{}", reason); - StripeArtcraftWebhookError::ServerError(reason) - })?; - - if let Some(existing_subscription) = maybe_existing_subscription { - match existing_subscription.maybe_stripe_subscription_status { - Some(StripeSubscriptionStatus::Canceled) => { - // NB: The stored subscription already had a terminal status and the subscription cannot be updated any further. - should_process_update = false; - result.should_ignore_retry = true; - } - _ => {} - } - } - - // NB: Even if we haven't received a record before, we should still be able to "tombstone" it - // once we detect the deletion. - if should_process_update { - if let Some(user_token) = summary.user_token.as_ref() { - - let upsert = UpsertUserSubscription { - stripe_subscription_id: &summary.stripe_subscription_id, - user_token: user_token.as_str(), - subscription_namespace: SubscriptionNamespace::Artcraft, - subscription_product_slug: &product.slug.to_str(), - maybe_stripe_customer_id: Some(&summary.stripe_customer_id), - maybe_stripe_product_id: Some(&summary.stripe_product_id), - maybe_stripe_price_id: Some(&summary.stripe_price_id), - maybe_stripe_recurring_interval: Some(summary.subscription_interval), - maybe_stripe_subscription_status: Some(summary.stripe_subscription_status), - maybe_stripe_is_production: Some(summary.stripe_is_production), - subscription_start_at: summary.subscription_start_date, - current_billing_period_start_at: summary.current_billing_period_start, - current_billing_period_end_at: summary.current_billing_period_end, - subscription_expires_at: calculate_subscription_end_date(&summary), - maybe_cancel_at: summary.maybe_cancel_at, - maybe_canceled_at: summary.maybe_canceled_at, - }; - - let _r = upsert.upsert_with_transaction(transaction) - .await - .map_err(|err| { - let reason = format!("Mysql error: {:?}", err); - error!("{}", reason); - StripeArtcraftWebhookError::ServerError(reason) - })?; - - result.action_was_taken = true; - } - - result.should_ignore_retry = true; - } - - Ok(result) + Ok(EnrichedWebhookEvent { + maybe_billing_action: Some(ArtcraftBillingAction::SubscriptionDeleted(summary.subscription_details)), + webhook_event_log_summary: summary.event_log_summary, + }) } diff --git a/crates/service/plugins/billing_artcraft/src/endpoints/webhook/webhook_event_enrichment/customer_subscription/customer_subscription_updated_handler.rs b/crates/service/plugins/billing_artcraft/src/endpoints/webhook/webhook_event_enrichment/customer_subscription/customer_subscription_updated_handler.rs index e926f18058..29e728d541 100644 --- a/crates/service/plugins/billing_artcraft/src/endpoints/webhook/webhook_event_enrichment/customer_subscription/customer_subscription_updated_handler.rs +++ b/crates/service/plugins/billing_artcraft/src/endpoints/webhook/webhook_event_enrichment/customer_subscription/customer_subscription_updated_handler.rs @@ -1,135 +1,20 @@ -use crate::configs::get_artcraft_product_by_stripe_id_and_env::get_artcraft_product_by_stripe_id_and_env; -use crate::configs::stripe_artcraft_generic_product_info::StripeArtcraftGenericProductInfo; -use crate::configs::subscriptions::get_artcraft_subscription_by_slug_and_env::get_artcraft_subscription_by_slug_and_env; +use crate::billing_action_fulfillment::artcraft_billing_action::ArtcraftBillingAction; +use crate::endpoints::webhook::common::enriched_webhook_event::EnrichedWebhookEvent; use crate::endpoints::webhook::common::stripe_artcraft_webhook_error::StripeArtcraftWebhookError; -use crate::endpoints::webhook::common::webhook_event_log_summary::WebhookEventLogSummary; -use crate::endpoints::webhook::webhook_event_enrichment::customer_subscription::calculate_subscription_end_date::calculate_subscription_end_date; -use crate::endpoints::webhook::webhook_event_enrichment::customer_subscription::subscription_event_extractor::subscription_summary_extractor; -use enums::common::subscription_namespace::SubscriptionNamespace; -use log::{error, info}; -use mysql_queries::queries::users::user::update::update_user_record_with_new_stripe_customer_id::{update_user_record_with_new_stripe_customer_id, update_user_record_with_new_stripe_customer_id_with_connection}; -use mysql_queries::queries::users::user_subscriptions::get_user_subscription_by_stripe_subscription_id::{get_user_subscription_by_stripe_subscription_id, get_user_subscription_by_stripe_subscription_id_with_connection}; -use mysql_queries::queries::users::user_subscriptions::get_user_subscription_by_stripe_subscription_id_transactional::get_user_subscription_by_stripe_subscription_id_transactional; -use mysql_queries::queries::users::user_subscriptions::upsert_user_subscription_by_stripe_id::UpsertUserSubscription; +use crate::endpoints::webhook::webhook_event_enrichment::customer_subscription::extract_subscription_details::extract_subscription_details; use reusable_types::server_environment::ServerEnvironment; -use reusable_types::stripe::stripe_subscription_status::StripeSubscriptionStatus; -use sqlx::pool::PoolConnection; -use sqlx::{MySql, MySqlPool, Transaction}; use stripe_shared::Subscription; /// Handle event type: 'customer.subscription.updated' pub async fn customer_subscription_updated_handler( subscription: &Subscription, server_environment: ServerEnvironment, - transaction: &mut Transaction<'_, MySql>, -) -> Result { +) -> Result { - let summary = subscription_summary_extractor(subscription) - .map_err(|err| { - let reason = format!("Error extracting subscription from 'customer.subscription.updated' payload: {:?}", err); - error!("{}", reason); - StripeArtcraftWebhookError::ServerError(reason) // NB: This was probably *our* fault. - })?; + let summary = extract_subscription_details(subscription, server_environment)?; - let mut result = WebhookEventLogSummary { - maybe_user_token: summary.user_token.clone(), - maybe_event_entity_id: Some(summary.stripe_subscription_id.clone()), - maybe_stripe_customer_id: Some(summary.stripe_customer_id.clone()), - action_was_taken: false, - should_ignore_retry: false, - }; - - let mut should_process_update = true; - - let maybe_product = get_artcraft_product_by_stripe_id_and_env( - &summary.stripe_product_id, server_environment); - - let product = match maybe_product { - Some(StripeArtcraftGenericProductInfo::Subscription(subscription)) => subscription, - Some(StripeArtcraftGenericProductInfo::CreditsPack(credits_pack)) => { - error!("Received 'customer.subscription.updated' for a credits pack product ({}). This should not happen.", &credits_pack.slug.to_str()); - result.should_ignore_retry = true; - return Ok(result); - } - None => { - error!("No matching product for stripe product ID: {}", &summary.stripe_product_id); - result.should_ignore_retry = true; - return Ok(result); - } - }; - - // NB: It's possible to receive events out of order. - let maybe_existing_subscription = get_user_subscription_by_stripe_subscription_id_transactional( - &summary.stripe_subscription_id, transaction) - .await - .map_err(|err| { - let reason = format!("Mysql error: {:?}", err); - error!("{}", reason); - StripeArtcraftWebhookError::ServerError(reason) - })?; - - if let Some(existing_subscription) = maybe_existing_subscription { - match existing_subscription.maybe_stripe_subscription_status { - Some(StripeSubscriptionStatus::Canceled) => { - // NB: The stored subscription already had a terminal status and the subscription cannot be updated any further. - should_process_update = false; - result.should_ignore_retry = true; - } - _ => {} - } - } - - if should_process_update { - if let Some(user_token) = summary.user_token.as_ref() { - info!("Upserting subscription"); - - let upsert = UpsertUserSubscription { - stripe_subscription_id: &summary.stripe_subscription_id, - user_token: user_token.as_str(), - subscription_namespace: SubscriptionNamespace::Artcraft, - subscription_product_slug: &product.slug.to_str(), - maybe_stripe_customer_id: Some(&summary.stripe_customer_id), - maybe_stripe_product_id: Some(&summary.stripe_product_id), - maybe_stripe_price_id: Some(&summary.stripe_price_id), - maybe_stripe_recurring_interval: Some(summary.subscription_interval), - maybe_stripe_subscription_status: Some(summary.stripe_subscription_status), - maybe_stripe_is_production: Some(summary.stripe_is_production), - subscription_start_at: summary.subscription_start_date, - current_billing_period_start_at: summary.current_billing_period_start, - current_billing_period_end_at: summary.current_billing_period_end, - subscription_expires_at: calculate_subscription_end_date(&summary), - maybe_cancel_at: summary.maybe_cancel_at, - maybe_canceled_at: summary.maybe_canceled_at, - }; - - let _r = upsert.upsert_with_transaction(transaction) - .await - .map_err(|err| { - let reason = format!("Mysql error: {:?}", err); - error!("{}", reason); - StripeArtcraftWebhookError::ServerError(reason) - })?; - - info!("Updating user record with stripe customer ID"); - - // // TODO: Should we care if a user accidentally gets two stripe customer IDs and this - // // overwrites one of them? - // update_user_record_with_new_stripe_customer_id_with_connection( - // transaction, - // user_token, - // Some(&summary.stripe_customer_id)) - // .await - // .map_err(|err| { - // let reason = format!("Mysql error: {:?}", err); - // error!("{}", reason); - // StripeArtcraftWebhookError::ServerError(reason) - // })?; - - result.action_was_taken = true; - } - } - - result.should_ignore_retry = true; - - Ok(result) + Ok(EnrichedWebhookEvent { + maybe_billing_action: Some(ArtcraftBillingAction::SubscriptionUpdated(summary.subscription_details)), + webhook_event_log_summary: summary.event_log_summary, + }) } diff --git a/crates/service/plugins/billing_artcraft/src/endpoints/webhook/webhook_event_enrichment/customer_subscription/extract_subscription_details.rs b/crates/service/plugins/billing_artcraft/src/endpoints/webhook/webhook_event_enrichment/customer_subscription/extract_subscription_details.rs new file mode 100644 index 0000000000..204d65497e --- /dev/null +++ b/crates/service/plugins/billing_artcraft/src/endpoints/webhook/webhook_event_enrichment/customer_subscription/extract_subscription_details.rs @@ -0,0 +1,96 @@ +use crate::billing_action_fulfillment::artcraft_billing_action::{ArtcraftBillingAction, UpsertableSubscriptionDetails}; +use crate::configs::get_artcraft_product_by_stripe_id_and_env::get_artcraft_product_by_stripe_id_and_env; +use crate::configs::stripe_artcraft_generic_product_info::StripeArtcraftGenericProductInfo; +use crate::endpoints::webhook::common::enriched_webhook_event::EnrichedWebhookEvent; +use crate::endpoints::webhook::common::stripe_artcraft_webhook_error::StripeArtcraftWebhookError; +use crate::endpoints::webhook::common::webhook_event_log_summary::WebhookEventLogSummary; +use crate::endpoints::webhook::webhook_event_enrichment::customer_subscription::calculate_subscription_end_date::calculate_subscription_end_date; +use crate::endpoints::webhook::webhook_event_enrichment::customer_subscription::subscription_event_extractor::subscription_summary_extractor; +use log::{error, warn}; +use reusable_types::server_environment::ServerEnvironment; +use stripe_shared::Subscription; + +pub struct EventLogAndSubscriptionDetails { + pub event_log_summary: WebhookEventLogSummary, + pub subscription_details: UpsertableSubscriptionDetails, +} + +pub fn extract_subscription_details( + subscription: &Subscription, + server_environment: ServerEnvironment, +) -> Result { + let summary = subscription_summary_extractor(subscription) + .map_err(|err| { + let reason = format!("Error extracting subscription from payload: {:?}", err); + error!("{}", reason); + StripeArtcraftWebhookError::ServerError(reason) // NB: This was probably *our* fault. + })?; + + let event_log_summary = WebhookEventLogSummary { + maybe_user_token: summary.user_token.clone(), + maybe_event_entity_id: Some(summary.stripe_subscription_id.clone()), + maybe_stripe_customer_id: Some(summary.stripe_customer_id.clone()), + action_was_taken: false, + should_ignore_retry: false, + }; + + let maybe_product = get_artcraft_product_by_stripe_id_and_env( + &summary.stripe_product_id, server_environment); + + let product = match maybe_product { + Some(StripeArtcraftGenericProductInfo::Subscription(subscription)) => subscription, + Some(StripeArtcraftGenericProductInfo::CreditsPack(credits_pack)) => { + error!("Received a non-subscription credits pack product ({}). This should not happen.", &credits_pack.slug.to_str()); + return Err(StripeArtcraftWebhookError::BadRequest("wrong product type".to_string())); + } + None => { + error!("No matching product for stripe product ID: {}", &summary.stripe_product_id); + return Err(StripeArtcraftWebhookError::BadRequest("no matching product".to_string())); + } + }; + + // TODO: Multiple ways to get this; better ways to get this + let user_token = match &summary.user_token { + Some(token) => token.clone(), + None => { + warn!("No user token found in subscription metadata. Cannot proceed."); + return Err(StripeArtcraftWebhookError::BadRequest("no user token in subscription event".to_string())); + } + }; + + let calculated_end_date = calculate_subscription_end_date(&summary); + + let subscription_details = UpsertableSubscriptionDetails { + // Unique Stripe foreign key + stripe_customer_id: summary.stripe_customer_id, + + // Other Stripe foreign keys + stripe_subscription_id: summary.stripe_subscription_id, + stripe_price_id: summary.stripe_price_id, + stripe_product_id: summary.stripe_product_id.clone(), // TODO: Avoid clone. + + // Artcraft info + subscription: product.clone(), // TODO: Avoid clone + owner_user_token: user_token, + + // Status + stripe_subscription_status: summary.stripe_subscription_status, + + // Timing data + stripe_recurring_interval: summary.subscription_interval, + calculated_subscription_expires_at: calculated_end_date, + subscription_start_at: summary.subscription_start_date, + current_billing_period_start_at: summary.current_billing_period_start, + current_billing_period_end_at: summary.current_billing_period_end, + maybe_cancel_at: summary.maybe_cancel_at, + maybe_canceled_at: summary.maybe_canceled_at, + + // Misc + stripe_is_production: summary.stripe_is_production, + }; + + Ok(EventLogAndSubscriptionDetails { + event_log_summary, + subscription_details, + }) +} \ No newline at end of file diff --git a/crates/service/plugins/billing_artcraft/src/endpoints/webhook/webhook_event_enrichment/customer_subscription/mod.rs b/crates/service/plugins/billing_artcraft/src/endpoints/webhook/webhook_event_enrichment/customer_subscription/mod.rs index 6c146c2c42..89b2427ef0 100644 --- a/crates/service/plugins/billing_artcraft/src/endpoints/webhook/webhook_event_enrichment/customer_subscription/mod.rs +++ b/crates/service/plugins/billing_artcraft/src/endpoints/webhook/webhook_event_enrichment/customer_subscription/mod.rs @@ -2,4 +2,5 @@ pub mod calculate_subscription_end_date; pub mod customer_subscription_created_handler; pub mod customer_subscription_deleted_handler; pub mod customer_subscription_updated_handler; -pub mod subscription_event_extractor; \ No newline at end of file +pub mod subscription_event_extractor; +mod extract_subscription_details; \ No newline at end of file diff --git a/crates/service/plugins/billing_artcraft/src/endpoints/webhook/webhook_event_enrichment/handle_webhook_event_enrichment.rs b/crates/service/plugins/billing_artcraft/src/endpoints/webhook/webhook_event_enrichment/handle_webhook_event_enrichment.rs index 053878a8ae..183ae50f3e 100644 --- a/crates/service/plugins/billing_artcraft/src/endpoints/webhook/webhook_event_enrichment/handle_webhook_event_enrichment.rs +++ b/crates/service/plugins/billing_artcraft/src/endpoints/webhook/webhook_event_enrichment/handle_webhook_event_enrichment.rs @@ -109,7 +109,6 @@ pub async fn handle_webhook_event_enrichment( // =============== CUSTOMER SUBSCRIPTIONS =============== - /* TODO TEMP - EventObject::CustomerSubscriptionCreated(subscription) => { info!("Event: {}, data: {:?}", webhook_payload.type_, subscription); // DO NOT USE TO PROVISION SERVICE. @@ -131,31 +130,27 @@ pub async fn handle_webhook_event_enrichment( // // TODO: add `maybe_billing_cycle_anchor` // - webhook_summary = customer_subscription_created_handler( + return customer_subscription_created_handler( &subscription, server_environment, - transaction, - ).await?; + ).await; } EventObject::CustomerSubscriptionUpdated(subscription) => { info!("Event: {}, data: {:?}", webhook_payload.type_, subscription); - webhook_summary = customer_subscription_updated_handler( + return customer_subscription_updated_handler( &subscription, server_environment, - transaction, - ).await?; + ).await; } EventObject::CustomerSubscriptionDeleted(subscription) => { info!("Event: {}, data: {:?}", webhook_payload.type_, subscription); - webhook_summary = customer_subscription_deleted_handler( + return customer_subscription_deleted_handler( &subscription, server_environment, - transaction, - ).await?; + ).await; } - */ // =============== INVOICES ===============