From da2e7ae4b6025a507145e20e9dea5d6990f2deb0 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sun, 7 Sep 2025 22:16:15 -0400 Subject: [PATCH] big refactor of this to remove http during db transactions --- .../stripe_artcraft_credits_pack_info.rs | 3 + .../configs/stripe_artcraft_metadata_keys.rs | 3 + .../webhook/common/artcraft_billing_event.rs | 13 + .../webhook/common/billing_action.rs | 40 +++ .../webhook/common/event_log_summary.rs | 21 ++ .../src/endpoints/webhook/common/mod.rs | 3 + .../src/endpoints/webhook/mod.rs | 1 + .../stripe_artcraft_webhook_handler.rs | 46 ++-- .../webhook/webhook_event_handlers/mod.rs | 7 +- .../payment_intent/mod.rs | 3 +- .../payment_intent_succeeded_handler_2.rs | 130 ++++++++++ .../stripe_artcraft_webhook_summary.rs | 2 +- .../webhook_event_to_artcraft_event.rs | 227 ++++++++++++++++++ .../billing_artcraft/src/fulfillment/mod.rs | 5 +- ...tionally_fulfill_artcraft_billing_event.rs | 33 +++ .../utils/metadata/get_metadata_user_token.rs | 9 + .../metadata/get_metadata_wallet_token.rs | 9 + .../src/utils/metadata/mod.rs | 2 + .../plugins/billing_artcraft/src/utils/mod.rs | 1 + 19 files changed, 532 insertions(+), 26 deletions(-) create mode 100644 crates/service/plugins/billing_artcraft/src/endpoints/webhook/common/artcraft_billing_event.rs create mode 100644 crates/service/plugins/billing_artcraft/src/endpoints/webhook/common/billing_action.rs create mode 100644 crates/service/plugins/billing_artcraft/src/endpoints/webhook/common/event_log_summary.rs create mode 100644 crates/service/plugins/billing_artcraft/src/endpoints/webhook/common/mod.rs create mode 100644 crates/service/plugins/billing_artcraft/src/endpoints/webhook/webhook_event_handlers/payment_intent/payment_intent_succeeded_handler_2.rs create mode 100644 crates/service/plugins/billing_artcraft/src/endpoints/webhook/webhook_event_handlers/webhook_event_to_artcraft_event.rs create mode 100644 crates/service/plugins/billing_artcraft/src/fulfillment/transactionally_fulfill_artcraft_billing_event.rs create mode 100644 crates/service/plugins/billing_artcraft/src/utils/metadata/get_metadata_user_token.rs create mode 100644 crates/service/plugins/billing_artcraft/src/utils/metadata/get_metadata_wallet_token.rs create mode 100644 crates/service/plugins/billing_artcraft/src/utils/metadata/mod.rs diff --git a/crates/service/plugins/billing_artcraft/src/configs/credits_packs/stripe_artcraft_credits_pack_info.rs b/crates/service/plugins/billing_artcraft/src/configs/credits_packs/stripe_artcraft_credits_pack_info.rs index 9817237f22..f64342a8f2 100644 --- a/crates/service/plugins/billing_artcraft/src/configs/credits_packs/stripe_artcraft_credits_pack_info.rs +++ b/crates/service/plugins/billing_artcraft/src/configs/credits_packs/stripe_artcraft_credits_pack_info.rs @@ -5,6 +5,9 @@ pub struct StripeArtcraftCreditsPackInfo { pub slug: ArtcraftCreditsPackSlug, pub product_id: &'static str, pub price_id: &'static str, + + /// When a new credits pack purchase is made, this is how many credits + /// are added for "quantity=1" pub purchase_credits_amount: u64, } diff --git a/crates/service/plugins/billing_artcraft/src/configs/stripe_artcraft_metadata_keys.rs b/crates/service/plugins/billing_artcraft/src/configs/stripe_artcraft_metadata_keys.rs index 3b1cd24e34..bd3c682748 100644 --- a/crates/service/plugins/billing_artcraft/src/configs/stripe_artcraft_metadata_keys.rs +++ b/crates/service/plugins/billing_artcraft/src/configs/stripe_artcraft_metadata_keys.rs @@ -4,3 +4,6 @@ pub const STRIPE_ARTCRAFT_METADATA_USER_TOKEN : &str = "user_token"; pub const STRIPE_ARTCRAFT_METADATA_USERNAME : &str = "username"; pub const STRIPE_ARTCRAFT_METADATA_EMAIL : &str = "email"; + +pub const STRIPE_ARTCRAFT_METADATA_WALLET_TOKEN : &str = "wallet_token"; + diff --git a/crates/service/plugins/billing_artcraft/src/endpoints/webhook/common/artcraft_billing_event.rs b/crates/service/plugins/billing_artcraft/src/endpoints/webhook/common/artcraft_billing_event.rs new file mode 100644 index 0000000000..3615c19f23 --- /dev/null +++ b/crates/service/plugins/billing_artcraft/src/endpoints/webhook/common/artcraft_billing_event.rs @@ -0,0 +1,13 @@ +use crate::endpoints::webhook::common::billing_action::BillingAction; +use crate::endpoints::webhook::common::event_log_summary::WebhookEventLogSummary; + +pub struct ArtcraftBillingEvent { + /// Higher-level events that we care about handling. + /// Not all webhook requests have them. + pub action: BillingAction, + + /// Details of what we'll write to the webhook event logs. + /// In addition to important events, this includes some no-op events, + /// various irrelevant events, errors, etc. + pub webhook_event_log_summary: WebhookEventLogSummary, +} diff --git a/crates/service/plugins/billing_artcraft/src/endpoints/webhook/common/billing_action.rs b/crates/service/plugins/billing_artcraft/src/endpoints/webhook/common/billing_action.rs new file mode 100644 index 0000000000..86b40acc21 --- /dev/null +++ b/crates/service/plugins/billing_artcraft/src/endpoints/webhook/common/billing_action.rs @@ -0,0 +1,40 @@ +use crate::configs::credits_packs::stripe_artcraft_credits_pack_info::StripeArtcraftCreditsPackInfo; +use tokens::tokens::users::UserToken; +use tokens::tokens::wallets::WalletToken; + +pub enum BillingAction { + /// An ignorable event. + IgnorableEvent{ + event_type: IgnoreableEventType, + description: String + }, + + /// A user purchased wallet credits. + WalletCreditsPurchase(WalletCreditsPurchaseEvent), + + // TODO: + SubscriptionCreated, + SubscriptionRenewalBillingFailed, + SubscriptionRenewalBillingSucceeded, + SubscriptionCanceled, +} + +pub struct WalletCreditsPurchaseEvent { + pub owner_user_token: UserToken, + + // 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, +} + +#[derive(Copy,Clone,Debug)] +pub enum IgnoreableEventType { + Other, + PaymentIntentFailed, + PaymentIntentForSubscription, +} diff --git a/crates/service/plugins/billing_artcraft/src/endpoints/webhook/common/event_log_summary.rs b/crates/service/plugins/billing_artcraft/src/endpoints/webhook/common/event_log_summary.rs new file mode 100644 index 0000000000..9d73d19e3c --- /dev/null +++ b/crates/service/plugins/billing_artcraft/src/endpoints/webhook/common/event_log_summary.rs @@ -0,0 +1,21 @@ +use tokens::tokens::users::UserToken; + +pub struct WebhookEventLogSummary { + /// This is the stripe customer ID, if it was associated with the event. + pub maybe_stripe_customer_id: Option, + + /// If we recorded our own internal user ID as Stripe metadata, this passes it upstream. + pub maybe_user_token: Option, + + /// This is the core entity type associated with the webhook event. + /// We pass this upstream so we can record it and look it up later for debugging. + pub maybe_event_entity_id: Option, + + /// Whether we took any sort of action in response to the webhook. + /// Not all event types have handlers yet, and even so, sometimes we may choose to do nothing. + pub action_was_taken: bool, + + /// Whether any retries should be dropped. + /// This helps us handle event with idempotency in the event Stripe replays them (and it can!) + pub should_ignore_retry: bool, +} diff --git a/crates/service/plugins/billing_artcraft/src/endpoints/webhook/common/mod.rs b/crates/service/plugins/billing_artcraft/src/endpoints/webhook/common/mod.rs new file mode 100644 index 0000000000..adf000d2eb --- /dev/null +++ b/crates/service/plugins/billing_artcraft/src/endpoints/webhook/common/mod.rs @@ -0,0 +1,3 @@ +pub mod event_log_summary; +pub mod artcraft_billing_event; +pub mod billing_action; \ No newline at end of file diff --git a/crates/service/plugins/billing_artcraft/src/endpoints/webhook/mod.rs b/crates/service/plugins/billing_artcraft/src/endpoints/webhook/mod.rs index 71d148e5b6..31b682c5b6 100644 --- a/crates/service/plugins/billing_artcraft/src/endpoints/webhook/mod.rs +++ b/crates/service/plugins/billing_artcraft/src/endpoints/webhook/mod.rs @@ -1,2 +1,3 @@ +pub mod common; pub mod stripe_artcraft_webhook_handler; pub mod webhook_event_handlers; \ No newline at end of file diff --git a/crates/service/plugins/billing_artcraft/src/endpoints/webhook/stripe_artcraft_webhook_handler.rs b/crates/service/plugins/billing_artcraft/src/endpoints/webhook/stripe_artcraft_webhook_handler.rs index d087fc0e85..3b86d41d4a 100644 --- a/crates/service/plugins/billing_artcraft/src/endpoints/webhook/stripe_artcraft_webhook_handler.rs +++ b/crates/service/plugins/billing_artcraft/src/endpoints/webhook/stripe_artcraft_webhook_handler.rs @@ -1,6 +1,7 @@ use actix_web::web::{Bytes, Data, Json}; use actix_web::{web, HttpRequest, HttpResponse}; +use crate::endpoints::webhook::common::artcraft_billing_event::ArtcraftBillingEvent; use crate::endpoints::webhook::webhook_event_handlers::checkout_session::checkout_session_completed_handler::checkout_session_completed_handler; use crate::endpoints::webhook::webhook_event_handlers::customer_subscription::customer_subscription_created_handler::customer_subscription_created_handler; use crate::endpoints::webhook::webhook_event_handlers::customer_subscription::customer_subscription_updated_handler::customer_subscription_updated_handler; @@ -10,6 +11,8 @@ use crate::endpoints::webhook::webhook_event_handlers::invoice::invoice_payment_ use crate::endpoints::webhook::webhook_event_handlers::payment_intent::payment_intent_succeeded_handler::payment_intent_succeeded_handler; use crate::endpoints::webhook::webhook_event_handlers::stripe_artcraft_webhook_error::StripeArtcraftWebhookError; use crate::endpoints::webhook::webhook_event_handlers::stripe_artcraft_webhook_summary::StripeArtcraftWebhookSummary; +use crate::endpoints::webhook::webhook_event_handlers::webhook_event_to_artcraft_event::webhook_event_to_artcraft_event; +use crate::fulfillment::transactionally_fulfill_artcraft_billing_event::transactionally_fulfill_artcraft_billing_action; use crate::utils::artcraft_stripe_config::ArtcraftStripeConfigWithClient; use crate::utils::verify_stripe_webhook_ip_address::verify_stripe_webhook_ip_address; use chrono::NaiveDateTime; @@ -25,7 +28,6 @@ use std::sync::Arc; use stripe_webhook::{Event, EventObject, Webhook}; /* - For one-off payments (eg. credits packs), we see this typical sequence of events: - charge.succeeded (EventId("evt_3S3gutEobp4xy4Tl15tGHk88")) -- IGNORE @@ -33,7 +35,6 @@ For one-off payments (eg. credits packs), we see this typical sequence of events - payment_intent.created (EventId("evt_3S3gutEobp4xy4Tl1mg80Zgu")) -- IGNORE - checkout.session.completed (EventId("evt_1S3guvEobp4xy4TlxY7TVlUs")) -- Can use, but `payment_intent.succeeded` is better - charge.updated (EventId("evt_3S3gutEobp4xy4Tl1RgfG4NL")) -- IGNORE - */ @@ -134,19 +135,26 @@ pub async fn stripe_artcraft_webhook_handler( } } + let artcraft_event = webhook_event_to_artcraft_event( + &stripe_config.client, + **server_environment, + webhook_payload, + &stripe_event_type, + ).await?; + let mut transaction = mysql_connection.begin().await?; - let result = do_with_transaction( + let result = process_event( **server_environment, &stripe_config, - webhook_payload, + artcraft_event, stripe_event_id.clone(), stripe_event_created_at, stripe_event_type.clone(), stripe_is_production, &mut transaction ).await; - + match result { Ok(()) => { transaction.commit().await?; @@ -154,8 +162,10 @@ pub async fn stripe_artcraft_webhook_handler( Err(err) => { error!("Error handling Stripe webhook event `{}` ({}): {:?}", stripe_event_type,stripe_event_id, err); + transaction.rollback().await?; - return Err(err); + + return Err(err.into()); } } @@ -164,36 +174,34 @@ pub async fn stripe_artcraft_webhook_handler( })) } -async fn do_with_transaction( +async fn process_event( server_environment: ServerEnvironment, stripe_config: &ArtcraftStripeConfigWithClient, - webhook_payload: Event, + artcraft_event: ArtcraftBillingEvent, stripe_event_id: String, stripe_event_created_at: NaiveDateTime, stripe_event_type: String, stripe_is_production: bool, transaction: &mut Transaction<'_, MySql> ) -> Result<(), StripeArtcraftWebhookError> { - - let webhook_summary = handle_webhook_payload( + + // TODO: Maybe grab the primary key of the impacted entity? + transactionally_fulfill_artcraft_billing_action( + &artcraft_event.action, transaction, - &stripe_config.client, - server_environment, - webhook_payload, - &stripe_event_type, ).await?; // NB: These records are uniquely keyed by ID, so this only happens once. let query = InsertStripeWebhookEventLog { stripe_event_id, stripe_event_type, - maybe_stripe_event_entity_id: webhook_summary.maybe_event_entity_id, - maybe_stripe_customer_id: webhook_summary.maybe_stripe_customer_id, + maybe_stripe_event_entity_id: artcraft_event.webhook_event_log_summary.maybe_event_entity_id, + maybe_stripe_customer_id: artcraft_event.webhook_event_log_summary.maybe_stripe_customer_id, stripe_event_created_at, stripe_is_production, - maybe_user_token: webhook_summary.maybe_user_token, - action_was_taken: webhook_summary.action_was_taken, - should_ignore_retry: webhook_summary.should_ignore_retry, + maybe_user_token: artcraft_event.webhook_event_log_summary.maybe_user_token.map(|t| t.to_string()), + action_was_taken: artcraft_event.webhook_event_log_summary.action_was_taken, + should_ignore_retry: artcraft_event.webhook_event_log_summary.should_ignore_retry, }; query.insert_transactional(transaction) diff --git a/crates/service/plugins/billing_artcraft/src/endpoints/webhook/webhook_event_handlers/mod.rs b/crates/service/plugins/billing_artcraft/src/endpoints/webhook/webhook_event_handlers/mod.rs index 643d092d48..01444bbac8 100644 --- a/crates/service/plugins/billing_artcraft/src/endpoints/webhook/webhook_event_handlers/mod.rs +++ b/crates/service/plugins/billing_artcraft/src/endpoints/webhook/webhook_event_handlers/mod.rs @@ -1,9 +1,10 @@ -pub mod stripe_artcraft_webhook_error; +mod ignore_known_unwanted_events; +pub (crate) mod webhook_event_to_artcraft_event; +pub mod handle_webhook_payload; +pub mod stripe_artcraft_webhook_error; pub(crate) mod checkout_session; pub(crate) mod customer_subscription; pub(crate) mod invoice; pub(crate) mod payment_intent; pub(crate) mod stripe_artcraft_webhook_summary; -pub mod handle_webhook_payload; -mod ignore_known_unwanted_events; diff --git a/crates/service/plugins/billing_artcraft/src/endpoints/webhook/webhook_event_handlers/payment_intent/mod.rs b/crates/service/plugins/billing_artcraft/src/endpoints/webhook/webhook_event_handlers/payment_intent/mod.rs index 273aa88b9d..32a6eb4d98 100644 --- a/crates/service/plugins/billing_artcraft/src/endpoints/webhook/webhook_event_handlers/payment_intent/mod.rs +++ b/crates/service/plugins/billing_artcraft/src/endpoints/webhook/webhook_event_handlers/payment_intent/mod.rs @@ -1 +1,2 @@ -pub(crate) mod payment_intent_succeeded_handler; \ No newline at end of file +pub(crate) mod payment_intent_succeeded_handler; +pub(crate) mod payment_intent_succeeded_handler_2; \ No newline at end of file diff --git a/crates/service/plugins/billing_artcraft/src/endpoints/webhook/webhook_event_handlers/payment_intent/payment_intent_succeeded_handler_2.rs b/crates/service/plugins/billing_artcraft/src/endpoints/webhook/webhook_event_handlers/payment_intent/payment_intent_succeeded_handler_2.rs new file mode 100644 index 0000000000..de1a45c05c --- /dev/null +++ b/crates/service/plugins/billing_artcraft/src/endpoints/webhook/webhook_event_handlers/payment_intent/payment_intent_succeeded_handler_2.rs @@ -0,0 +1,130 @@ +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::stripe_artcraft_subscription_info::StripeArtcraftSubscriptionInfo; +use crate::endpoints::webhook::common::artcraft_billing_event::ArtcraftBillingEvent; +use crate::endpoints::webhook::common::billing_action::{BillingAction, IgnoreableEventType, WalletCreditsPurchaseEvent}; +use crate::endpoints::webhook::common::event_log_summary::WebhookEventLogSummary; +use crate::endpoints::webhook::webhook_event_handlers::stripe_artcraft_webhook_error::StripeArtcraftWebhookError; +use crate::endpoints::webhook::webhook_event_handlers::stripe_artcraft_webhook_summary::StripeArtcraftWebhookSummary; +use crate::fulfillment::credits_pack::complete_credits_pack_purchase::complete_credits_pack_purchase; +use crate::requests::lookup_purchase_from_payment_intent_success::lookup_purchase_from_payment_intent_success; +use crate::utils::expand_ids::expand_customer_id::expand_customer_id; +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 log::{error, info, warn}; +use reusable_types::server_environment::ServerEnvironment; +use sqlx::{MySql, Transaction}; +use stripe::Client; +use stripe_checkout::checkout_session::ListCheckoutSession; +use stripe_shared::PaymentIntent; +use tokens::tokens::users::UserToken; +use tokens::tokens::wallets::WalletToken; + +// Handle event type: 'payment_intent.succeeded' +pub async fn payment_intent_succeeded_handler_2( + payment_intent: &PaymentIntent, + server_environment: ServerEnvironment, + stripe_client: &Client, +) -> Result { + + let payment_intent_id = payment_intent.id.to_string(); + + // NB: We'll need this to send them to the "customer portal", which is how they can modify + // or cancel their subscriptions. + let maybe_stripe_customer_id = payment_intent.customer + .as_ref() + .map(|c| expand_customer_id(c)); + + // NB: Our internal tokens + let maybe_user_token = get_metadata_user_token(&payment_intent.metadata); + let maybe_wallet_token = get_metadata_wallet_token(&payment_intent.metadata); + + // TODO: Multiple ways to get this; better ways to get this + let user_token = match &maybe_user_token { + Some(token) => token.clone(), + None => { + warn!("No user token found in payment intent metadata. Cannot proceed."); + return Err(StripeArtcraftWebhookError::BadRequest("no user token in payment intent".to_string())); + } + }; + + let mut event_log_summary = WebhookEventLogSummary { + maybe_stripe_customer_id, + maybe_user_token, + maybe_event_entity_id: Some(payment_intent_id.clone()), + action_was_taken: false, + should_ignore_retry: true, + }; + + // Payment intent events are bare. They have absolutely no context about what they were for. + // No products, no checkout sessions, etc. We'll have to look them up on success. + + let payment_succeeded = match payment_intent.status { + stripe_shared::PaymentIntentStatus::Succeeded => true, + _ => false, + }; + + if !payment_succeeded { + event_log_summary.should_ignore_retry = true; + + return Ok(ArtcraftBillingEvent { + action: BillingAction::IgnorableEvent { + description: "payment_intent not succeeded".to_string(), + event_type: IgnoreableEventType::PaymentIntentFailed, + }, + webhook_event_log_summary: event_log_summary, + }); + } + + info!("Payment intent succeeded. Looking up payment..."); + + let purchase = lookup_purchase_from_payment_intent_success(&payment_intent_id, stripe_client) + .await + .map_err(|err| { + error!("Error looking up purchase from payment intent {}: {:?}", &payment_intent_id, err); + StripeArtcraftWebhookError::ServerError("error looking up purchase".to_string()) + })?; + + if !purchase.has_invoice { + // Subscription purchase. Let `invoice.paid` event handle this instead. + event_log_summary.should_ignore_retry = true; + + return Ok(ArtcraftBillingEvent { + action: BillingAction::IgnorableEvent { + event_type: IgnoreableEventType::PaymentIntentForSubscription, + description: "payment_intent for subscription; will handle via invoice.paid".to_string(), + }, + webhook_event_log_summary: event_log_summary, + }); + } + + let credits_pack = match get_artcraft_product_by_stripe_id_and_env(&purchase.product_id, server_environment) { + None => { + error!("Could not find product for ID: {}", &purchase.product_id); + return Err(StripeArtcraftWebhookError::ServerError("unknown product".to_string())); + } + Some(StripeArtcraftGenericProductInfo::Subscription(_subscription)) => { + info!("Do not handle subscriptions as one-off payments: {}", purchase.product_id); + event_log_summary.should_ignore_retry = true; + + return Ok(ArtcraftBillingEvent { + action: BillingAction::IgnorableEvent { + event_type: IgnoreableEventType::PaymentIntentForSubscription, + description: "payment_intent for subscription; will handle via invoice.paid".to_string(), + }, + webhook_event_log_summary: event_log_summary, + }); + } + Some(StripeArtcraftGenericProductInfo::CreditsPack(credits_pack)) => credits_pack, + }; + + Ok(ArtcraftBillingEvent { + action: BillingAction::WalletCreditsPurchase(WalletCreditsPurchaseEvent { + owner_user_token: user_token, + maybe_wallet_token: None, // TODO: Use the checkout session to set this ! + pack: credits_pack.clone(), + quantity: purchase.quantity, + }), + webhook_event_log_summary: event_log_summary, + }) +} diff --git a/crates/service/plugins/billing_artcraft/src/endpoints/webhook/webhook_event_handlers/stripe_artcraft_webhook_summary.rs b/crates/service/plugins/billing_artcraft/src/endpoints/webhook/webhook_event_handlers/stripe_artcraft_webhook_summary.rs index e58e882cbe..4968c63a17 100644 --- a/crates/service/plugins/billing_artcraft/src/endpoints/webhook/webhook_event_handlers/stripe_artcraft_webhook_summary.rs +++ b/crates/service/plugins/billing_artcraft/src/endpoints/webhook/webhook_event_handlers/stripe_artcraft_webhook_summary.rs @@ -18,4 +18,4 @@ pub struct StripeArtcraftWebhookSummary { /// Whether any retries should be dropped. /// This helps us handle event with idempotency in the event Stripe replays them (and it can!) pub should_ignore_retry: bool, -} +} \ No newline at end of file diff --git a/crates/service/plugins/billing_artcraft/src/endpoints/webhook/webhook_event_handlers/webhook_event_to_artcraft_event.rs b/crates/service/plugins/billing_artcraft/src/endpoints/webhook/webhook_event_handlers/webhook_event_to_artcraft_event.rs new file mode 100644 index 0000000000..67d9f7041a --- /dev/null +++ b/crates/service/plugins/billing_artcraft/src/endpoints/webhook/webhook_event_handlers/webhook_event_to_artcraft_event.rs @@ -0,0 +1,227 @@ +use crate::endpoints::webhook::common::artcraft_billing_event::ArtcraftBillingEvent; +use crate::endpoints::webhook::common::billing_action::BillingAction::IgnorableEvent; +use crate::endpoints::webhook::common::billing_action::{BillingAction, IgnoreableEventType}; +use crate::endpoints::webhook::common::event_log_summary::WebhookEventLogSummary; +use crate::endpoints::webhook::webhook_event_handlers::checkout_session::checkout_session_completed_handler::checkout_session_completed_handler; +use crate::endpoints::webhook::webhook_event_handlers::customer_subscription::customer_subscription_created_handler::customer_subscription_created_handler; +use crate::endpoints::webhook::webhook_event_handlers::customer_subscription::customer_subscription_deleted_handler::customer_subscription_deleted_handler; +use crate::endpoints::webhook::webhook_event_handlers::customer_subscription::customer_subscription_updated_handler::customer_subscription_updated_handler; +use crate::endpoints::webhook::webhook_event_handlers::ignore_known_unwanted_events::ignore_known_unwanted_events; +use crate::endpoints::webhook::webhook_event_handlers::invoice::invoice_paid_handler::invoice_paid_handler; +use crate::endpoints::webhook::webhook_event_handlers::invoice::invoice_payment_failed::invoice_payment_failed_handler; +use crate::endpoints::webhook::webhook_event_handlers::payment_intent::payment_intent_succeeded_handler::payment_intent_succeeded_handler; +use crate::endpoints::webhook::webhook_event_handlers::payment_intent::payment_intent_succeeded_handler_2::payment_intent_succeeded_handler_2; +use crate::endpoints::webhook::webhook_event_handlers::stripe_artcraft_webhook_error::StripeArtcraftWebhookError; +use crate::endpoints::webhook::webhook_event_handlers::stripe_artcraft_webhook_summary::StripeArtcraftWebhookSummary; +use log::{info, warn}; +use reusable_types::server_environment::ServerEnvironment; +use sqlx::pool::PoolConnection; +use sqlx::{MySql, Transaction}; +use stripe::Client; +use stripe_webhook::{Event, EventObject}; + +pub async fn webhook_event_to_artcraft_event( + stripe_client: &Client, + server_environment: ServerEnvironment, + webhook_payload: Event, + stripe_event_type: &str, +) -> Result { + + // TODO: Cleanup - + if let Some(_summary) = ignore_known_unwanted_events(&webhook_payload) { + return Ok(ArtcraftBillingEvent { + action: BillingAction::IgnorableEvent { + event_type: IgnoreableEventType::Other, + description: "TODO".to_string(), + + }, + webhook_event_log_summary: WebhookEventLogSummary { + maybe_user_token: None, + maybe_event_entity_id: None, + maybe_stripe_customer_id: None, + action_was_taken: false, + should_ignore_retry: true, + }, + }); + } + + let mut unhandled_event_type = false; + + let mut webhook_summary = WebhookEventLogSummary { + maybe_user_token: None, + maybe_event_entity_id: None, + maybe_stripe_customer_id: None, + action_was_taken: false, + should_ignore_retry: false, + }; + + /* + You usually need these: + 1. Subscription events: to track contract state (active, canceled, past_due, trialing, etc.). + 2. Invoice events: to track actual money flow and handle edge cases like failed payments, dunning, or refunds. + + Minimal Set to Cover Payment Lifecycle + - invoice.paid → confirm credits or entitlements + - invoice.payment_failed → trigger dunning or downgrade access + - customer.subscription.deleted → stop service when canceled + - customer.subscription.updated → react to upgrades, downgrades, trial end + - (Optionally) checkout.session.completed → provision on signup + */ + + match webhook_payload.data.object { + + // =============== CHECKOUT SESSIONS =============== + + // TODO: Provision the subscription here. + EventObject::CheckoutSessionCompleted(checkout_session) => { + info!("Event: {}, data: {:?}", webhook_payload.type_, checkout_session); + // TODO: DO NOT USE TO PROVISION SERVICE - USE `invoice.paid`. + // + // Checkout session completion is ideal for provisioning the service after checkout, + // but will not be used for monthly subscription renewals, which are handled by + // invoice.paid events. + // + // - `payment_status = {paid, unpaid, no_payment_required}` will let us know + // if the funds are in our account. NOTE (2): this will not be `paid` for free + // trials, but `invoice.paid` events will fire for free trials. (2): Async + // methods like ACH will have the status `unpaid` until the payment + // clears. + // + // - `subscription` will contain the ID of the subscription in subscription mode. + // + // - `metadata` - user token, etc. + // + // After the subscription signup succeeds, the customer returns to your website at the success_url, + // which initiates a checkout.session.completed webhooks. When you receive a checkout.session.completed + // event, you can provision the subscription. Continue to provision each month (if billing monthly) as + // you receive invoice.paid events. If you receive an invoice.payment_failed event, notify your customer + // and send them to the customer portal to update their payment method. + // + // Still need to ask about this. - does stripe say we can provision service here? + // Do we know if this happens for upgrades/downgrades/renewals? + // What about one-off payments? + /* TODO: Commented out for now + webhook_summary = checkout_session_completed_handler(checkout_session)?; + */ + } + + // =============== CUSTOMER SUBSCRIPTIONS =============== + + /* TODO TEMP - + EventObject::CustomerSubscriptionCreated(subscription) => { + info!("Event: {}, data: {:?}", webhook_payload.type_, subscription); + // DO NOT USE TO PROVISION SERVICE. + // + // This can be used to upsert the subscription record, but may be `incomplete` and unpaid. + // This is good for subscription state, renewal dates, etc. + // + // - `id` of the subscription object + // - `customer` id of the stripe customer object + // - `status` has a rich list of subscription states (active, etc.) + // - `items.data[0].price` + // - `items.data[0].plan` + // - billing_cycle_anchor + // - `metadata` - user token, etc. that were attached to the subscription + // + // 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. + // + // TODO: add `maybe_billing_cycle_anchor` + // + webhook_summary = customer_subscription_created_handler( + &subscription, + server_environment, + transaction, + ).await?; + } + + EventObject::CustomerSubscriptionUpdated(subscription) => { + info!("Event: {}, data: {:?}", webhook_payload.type_, subscription); + webhook_summary = customer_subscription_updated_handler( + &subscription, + server_environment, + transaction, + ).await?; + } + + EventObject::CustomerSubscriptionDeleted(subscription) => { + info!("Event: {}, data: {:?}", webhook_payload.type_, subscription); + webhook_summary = customer_subscription_deleted_handler( + &subscription, + server_environment, + transaction, + ).await?; + } + */ + + // =============== INVOICES =============== + + EventObject::InvoiceCreated(_invoice) => { + // TODO: We need to respond to this so we don't hold payments up by 72 hours! + // See: https://stripe.com/docs/billing/subscriptions/webhooks + } + + /* TODO: Commented out for now. + + EventObject::InvoicePaid(invoice) => { + // TODO: How do we handle multiple subscriptions? + // TODO: How do we handle inactive subscriptions vs. payment failed ones? + // 'invoice.paid' + // - 'customer' - the customer id + // - 'status = paid' + // - (optional) 'subscription_details.subscription' - the stripe subscription id (if subscription) + // - (optional) 'subscription_details.metadata' - Artcraft user token, etc. + info!("Event: {}, data: {:?}", webhook_payload.type_, invoice); + webhook_summary = invoice_paid_handler(&invoice)?; + } + + EventObject::InvoicePaymentFailed(invoice) => { + // TODO: Halt service. + // TODO: in wallet schema, hold a 'lifetime_total_credits_used' - no, that wastes space. `credits_used` then SUM() for reports + // When we detect invoice payment failures, we need to disable + // subscription services. + info!("Event: {}, data: {:?}", webhook_payload.type_, invoice); + webhook_summary = invoice_payment_failed_handler(&invoice)?; + } + + */ + + // =============== PAYMENT INTENTS =============== + + EventObject::PaymentIntentSucceeded(payment_intent) => { + info!("Event: {}, data: {:?}", webhook_payload.type_, payment_intent); + + // NB: This is responsible for enabling one-off payments (eg. credits packs). + return payment_intent_succeeded_handler_2( + &payment_intent, + server_environment, + stripe_client, + ).await; + } + + // =============== Ignored =============== + + _ => { + unhandled_event_type = true; + }, + } + + // To play with the payload contents as JSON: + // let json = serde_json::ser::to_string(&event_payload).unwrap(); + // info!("event payload as json: {}", json); + + if unhandled_event_type { + warn!("Unhandled Stripe webhook event type: {} ({:?})", + &stripe_event_type, + &webhook_payload.type_); + } + + Ok(ArtcraftBillingEvent { + action: IgnorableEvent { + event_type: IgnoreableEventType::Other, + description: "todo".to_string(), + }, + webhook_event_log_summary: webhook_summary, + }) +} diff --git a/crates/service/plugins/billing_artcraft/src/fulfillment/mod.rs b/crates/service/plugins/billing_artcraft/src/fulfillment/mod.rs index ce9ffe768b..56d0576725 100644 --- a/crates/service/plugins/billing_artcraft/src/fulfillment/mod.rs +++ b/crates/service/plugins/billing_artcraft/src/fulfillment/mod.rs @@ -1,2 +1,3 @@ -pub mod subscriptions; -pub mod credits_pack; \ No newline at end of file +pub (crate) mod subscriptions; +pub (crate) mod credits_pack; +pub (crate) mod transactionally_fulfill_artcraft_billing_event; \ No newline at end of file diff --git a/crates/service/plugins/billing_artcraft/src/fulfillment/transactionally_fulfill_artcraft_billing_event.rs b/crates/service/plugins/billing_artcraft/src/fulfillment/transactionally_fulfill_artcraft_billing_event.rs new file mode 100644 index 0000000000..62838a9a2f --- /dev/null +++ b/crates/service/plugins/billing_artcraft/src/fulfillment/transactionally_fulfill_artcraft_billing_event.rs @@ -0,0 +1,33 @@ +use crate::endpoints::webhook::common::billing_action::BillingAction; +use crate::fulfillment::credits_pack::complete_credits_pack_purchase::complete_credits_pack_purchase; +use anyhow::anyhow; +use errors::AnyhowResult; +use log::{info, warn}; +use sqlx::Transaction; + +pub async fn transactionally_fulfill_artcraft_billing_action( + event: &BillingAction, + transaction: &mut Transaction<'_, sqlx::MySql>, +) -> AnyhowResult<()> { + + match event { + BillingAction::IgnorableEvent { .. } => { + warn!("Received ignorable billing action; nothing to fulfill."); + return Ok(()) + } + BillingAction::WalletCreditsPurchase(purchase) => { + info!("Completing credits pack purchase for user: {} ... ", purchase.owner_user_token.as_str()); + complete_credits_pack_purchase( + &purchase.owner_user_token, + &purchase.pack, + purchase.quantity, + transaction, + ).await?; + } + _ => { + return Err(anyhow!("Unhandled billing action in fulfillment")); + } + } + + Ok(()) +} diff --git a/crates/service/plugins/billing_artcraft/src/utils/metadata/get_metadata_user_token.rs b/crates/service/plugins/billing_artcraft/src/utils/metadata/get_metadata_user_token.rs new file mode 100644 index 0000000000..dca28acded --- /dev/null +++ b/crates/service/plugins/billing_artcraft/src/utils/metadata/get_metadata_user_token.rs @@ -0,0 +1,9 @@ +use crate::configs::stripe_artcraft_metadata_keys::STRIPE_ARTCRAFT_METADATA_USER_TOKEN; +use tokens::tokens::users::UserToken; + +pub (crate) fn get_metadata_user_token( + metadata: &std::collections::HashMap, +) -> Option { + metadata.get(STRIPE_ARTCRAFT_METADATA_USER_TOKEN) + .map(|t| UserToken::new_from_str(&t)) +} diff --git a/crates/service/plugins/billing_artcraft/src/utils/metadata/get_metadata_wallet_token.rs b/crates/service/plugins/billing_artcraft/src/utils/metadata/get_metadata_wallet_token.rs new file mode 100644 index 0000000000..fcfbbee790 --- /dev/null +++ b/crates/service/plugins/billing_artcraft/src/utils/metadata/get_metadata_wallet_token.rs @@ -0,0 +1,9 @@ +use crate::configs::stripe_artcraft_metadata_keys::STRIPE_ARTCRAFT_METADATA_WALLET_TOKEN; +use tokens::tokens::wallets::WalletToken; + +pub (crate) fn get_metadata_wallet_token( + metadata: &std::collections::HashMap, +) -> Option { + metadata.get(STRIPE_ARTCRAFT_METADATA_WALLET_TOKEN) + .map(|t| WalletToken::new_from_str(&t)) +} diff --git a/crates/service/plugins/billing_artcraft/src/utils/metadata/mod.rs b/crates/service/plugins/billing_artcraft/src/utils/metadata/mod.rs new file mode 100644 index 0000000000..5ee1461ce9 --- /dev/null +++ b/crates/service/plugins/billing_artcraft/src/utils/metadata/mod.rs @@ -0,0 +1,2 @@ +pub (crate) mod get_metadata_user_token; +pub (crate) mod get_metadata_wallet_token; \ No newline at end of file diff --git a/crates/service/plugins/billing_artcraft/src/utils/mod.rs b/crates/service/plugins/billing_artcraft/src/utils/mod.rs index 9ecdcb4921..837c3ddaee 100644 --- a/crates/service/plugins/billing_artcraft/src/utils/mod.rs +++ b/crates/service/plugins/billing_artcraft/src/utils/mod.rs @@ -1,3 +1,4 @@ +pub (crate) mod metadata; pub mod artcraft_stripe_config; pub mod common_web_error; pub mod enum_conversion;