From 80f0235c506cc7e18e023cbc331cda9fb581fab3 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Tue, 2 Sep 2025 00:14:11 -0400 Subject: [PATCH] wip --- Cargo.lock | 2 + .../upsert_user_subscription_by_stripe_id.rs | 8 +- .../plugins/billing_artcraft/Cargo.toml | 6 +- ...> get_artcraft_product_by_slug_and_env.rs} | 2 +- ...t_artcraft_product_by_stripe_id_and_env.rs | 41 ++ .../billing_artcraft/src/configs/mod.rs | 4 +- .../configs/stripe_artcraft_product_info.rs | 1 + ...e_subscription_checkout_session_handler.rs | 4 +- .../src/endpoints/webhook/mod.rs | 2 +- .../stripe_artcraft_webhook_handler.rs | 203 ++++----- .../charge/charge_succeeded_handler.rs | 30 ++ .../webhook_event_handlers/charge/mod.rs | 1 + .../checkout_session_completed_handler.rs | 32 ++ .../checkout_session/mod.rs | 1 + .../customer/customer_created_handler.rs | 25 + .../customer/customer_deleted_handler.rs | 24 + .../customer/customer_updated_handler.rs | 24 + .../webhook_event_handlers/customer/mod.rs | 3 + .../calculate_subscription_end_date.rs | 32 ++ .../customer_subscription/common.rs | 8 + .../customer_subscription_created_handler.rs | 119 +++++ .../customer_subscription_deleted_handler.rs | 116 +++++ .../customer_subscription_updated_handler.rs | 127 ++++++ .../customer_subscription/mod.rs | 6 + .../subscription_event_extractor.rs | 428 ++++++++++++++++++ .../invoice/invoice_paid_handler.rs | 236 ++++++++++ .../invoice/invoice_payment_failed.rs | 31 ++ .../invoice_payment_succeeded_handler.rs | 31 ++ .../invoice/invoice_updated_handler.rs | 35 ++ .../webhook_event_handlers/invoice/mod.rs | 4 + .../webhook/webhook_event_handlers/mod.rs | 9 + .../payment_intent/mod.rs | 1 + .../payment_intent_succeeded_handler.rs | 30 ++ .../stripe_artcraft_webhook_error.rs | 34 ++ .../stripe_artcraft_webhook_summary.rs | 0 .../src/utils/expand_customer_id.rs | 9 + .../src/utils/expand_product_id.rs | 8 + .../src/utils/expand_subscription_id.rs | 8 + .../plugins/billing_artcraft/src/utils/mod.rs | 5 +- 39 files changed, 1573 insertions(+), 117 deletions(-) rename crates/service/plugins/billing_artcraft/src/configs/{get_artcraft_product_by_slug.rs => get_artcraft_product_by_slug_and_env.rs} (89%) create mode 100644 crates/service/plugins/billing_artcraft/src/configs/get_artcraft_product_by_stripe_id_and_env.rs create mode 100644 crates/service/plugins/billing_artcraft/src/endpoints/webhook/webhook_event_handlers/charge/charge_succeeded_handler.rs create mode 100644 crates/service/plugins/billing_artcraft/src/endpoints/webhook/webhook_event_handlers/charge/mod.rs create mode 100644 crates/service/plugins/billing_artcraft/src/endpoints/webhook/webhook_event_handlers/checkout_session/checkout_session_completed_handler.rs create mode 100644 crates/service/plugins/billing_artcraft/src/endpoints/webhook/webhook_event_handlers/checkout_session/mod.rs create mode 100644 crates/service/plugins/billing_artcraft/src/endpoints/webhook/webhook_event_handlers/customer/customer_created_handler.rs create mode 100644 crates/service/plugins/billing_artcraft/src/endpoints/webhook/webhook_event_handlers/customer/customer_deleted_handler.rs create mode 100644 crates/service/plugins/billing_artcraft/src/endpoints/webhook/webhook_event_handlers/customer/customer_updated_handler.rs create mode 100644 crates/service/plugins/billing_artcraft/src/endpoints/webhook/webhook_event_handlers/customer/mod.rs create mode 100644 crates/service/plugins/billing_artcraft/src/endpoints/webhook/webhook_event_handlers/customer_subscription/calculate_subscription_end_date.rs create mode 100644 crates/service/plugins/billing_artcraft/src/endpoints/webhook/webhook_event_handlers/customer_subscription/common.rs create mode 100644 crates/service/plugins/billing_artcraft/src/endpoints/webhook/webhook_event_handlers/customer_subscription/customer_subscription_created_handler.rs create mode 100644 crates/service/plugins/billing_artcraft/src/endpoints/webhook/webhook_event_handlers/customer_subscription/customer_subscription_deleted_handler.rs create mode 100644 crates/service/plugins/billing_artcraft/src/endpoints/webhook/webhook_event_handlers/customer_subscription/customer_subscription_updated_handler.rs create mode 100644 crates/service/plugins/billing_artcraft/src/endpoints/webhook/webhook_event_handlers/customer_subscription/mod.rs create mode 100644 crates/service/plugins/billing_artcraft/src/endpoints/webhook/webhook_event_handlers/customer_subscription/subscription_event_extractor.rs create mode 100644 crates/service/plugins/billing_artcraft/src/endpoints/webhook/webhook_event_handlers/invoice/invoice_paid_handler.rs create mode 100644 crates/service/plugins/billing_artcraft/src/endpoints/webhook/webhook_event_handlers/invoice/invoice_payment_failed.rs create mode 100644 crates/service/plugins/billing_artcraft/src/endpoints/webhook/webhook_event_handlers/invoice/invoice_payment_succeeded_handler.rs create mode 100644 crates/service/plugins/billing_artcraft/src/endpoints/webhook/webhook_event_handlers/invoice/invoice_updated_handler.rs create mode 100644 crates/service/plugins/billing_artcraft/src/endpoints/webhook/webhook_event_handlers/invoice/mod.rs create mode 100644 crates/service/plugins/billing_artcraft/src/endpoints/webhook/webhook_event_handlers/mod.rs create mode 100644 crates/service/plugins/billing_artcraft/src/endpoints/webhook/webhook_event_handlers/payment_intent/mod.rs create mode 100644 crates/service/plugins/billing_artcraft/src/endpoints/webhook/webhook_event_handlers/payment_intent/payment_intent_succeeded_handler.rs create mode 100644 crates/service/plugins/billing_artcraft/src/endpoints/webhook/webhook_event_handlers/stripe_artcraft_webhook_error.rs rename crates/service/plugins/billing_artcraft/src/endpoints/webhook/{ => webhook_event_handlers}/stripe_artcraft_webhook_summary.rs (100%) create mode 100644 crates/service/plugins/billing_artcraft/src/utils/expand_customer_id.rs create mode 100644 crates/service/plugins/billing_artcraft/src/utils/expand_product_id.rs create mode 100644 crates/service/plugins/billing_artcraft/src/utils/expand_subscription_id.rs diff --git a/Cargo.lock b/Cargo.lock index c3a58bd84e..3ca65f1602 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1340,6 +1340,8 @@ dependencies = [ "async-stripe 1.0.0-alpha.2", "async-stripe-checkout", "async-stripe-core", + "async-stripe-shared", + "async-stripe-types", "async-stripe-webhook", "chrono", "component_traits", diff --git a/crates/schema/database/mysql_queries/src/queries/users/user_subscriptions/upsert_user_subscription_by_stripe_id.rs b/crates/schema/database/mysql_queries/src/queries/users/user_subscriptions/upsert_user_subscription_by_stripe_id.rs index 3dc76a6c71..2f1f0418ea 100644 --- a/crates/schema/database/mysql_queries/src/queries/users/user_subscriptions/upsert_user_subscription_by_stripe_id.rs +++ b/crates/schema/database/mysql_queries/src/queries/users/user_subscriptions/upsert_user_subscription_by_stripe_id.rs @@ -1,7 +1,7 @@ use anyhow::anyhow; use chrono::NaiveDateTime; use sqlx::MySqlPool; - +use enums::common::subscription_namespace::SubscriptionNamespace; use errors::AnyhowResult; use reusable_types::stripe::stripe_recurring_interval::StripeRecurringInterval; use reusable_types::stripe::stripe_subscription_status::StripeSubscriptionStatus; @@ -17,8 +17,8 @@ pub struct UpsertUserSubscription<'a> { /// Internal user token pub user_token: &'a str, - /// The platform key, eg. "fakeyou", "storyteller_stream", "symphonia", etc. - pub subscription_namespace: &'a str, + /// The platform key, eg. "artcraft", "fakeyou", "storyteller_stream", "symphonia", etc. + pub subscription_namespace: SubscriptionNamespace, /// The name of the product the user is subscribing to within the category. pub subscription_product_slug: &'a str, @@ -112,7 +112,7 @@ ON DUPLICATE KEY UPDATE // Insert token, self.user_token, - self.subscription_namespace, + self.subscription_namespace.to_str(), self.subscription_product_slug, self.stripe_subscription_id, diff --git a/crates/service/plugins/billing_artcraft/Cargo.toml b/crates/service/plugins/billing_artcraft/Cargo.toml index 286c4275f8..49837368b6 100644 --- a/crates/service/plugins/billing_artcraft/Cargo.toml +++ b/crates/service/plugins/billing_artcraft/Cargo.toml @@ -46,9 +46,11 @@ sqlx = { version = "0.7.4", features = [ "mysql", "runtime-tokio", "tls-rustls", # Money #async-stripe = { version = "1.0.0-alpha.2", features = ["runtime-tokio-hyper"] } async-stripe = { version = "1.0.0-alpha.2", features = [] } -async-stripe-core = { version = "1.0.0-alpha.2", features = ["customer"] } -async-stripe-webhook = { version = "1.0.0-alpha.2", features = [] } async-stripe-checkout = { version = "1.0.0-alpha.2", features = ["checkout_session"] } +async-stripe-core = { version = "1.0.0-alpha.2", features = ["customer"] } +async-stripe-shared= { version = "1.0.0-alpha.2", features = [] } +async-stripe-types = { version = "1.0.0-alpha.2", features = [] } +async-stripe-webhook = { version = "1.0.0-alpha.2", features = [] } #sqlx = { version = "0.7.4", features = [ "mysql", "runtime-tokio", "tls-rustls", "chrono" ] } #utoipa = { version = "4", features = ["chrono","actix_extras"] } diff --git a/crates/service/plugins/billing_artcraft/src/configs/get_artcraft_product_by_slug.rs b/crates/service/plugins/billing_artcraft/src/configs/get_artcraft_product_by_slug_and_env.rs similarity index 89% rename from crates/service/plugins/billing_artcraft/src/configs/get_artcraft_product_by_slug.rs rename to crates/service/plugins/billing_artcraft/src/configs/get_artcraft_product_by_slug_and_env.rs index 1fb578dbee..99ac2cd318 100644 --- a/crates/service/plugins/billing_artcraft/src/configs/get_artcraft_product_by_slug.rs +++ b/crates/service/plugins/billing_artcraft/src/configs/get_artcraft_product_by_slug_and_env.rs @@ -3,7 +3,7 @@ use crate::configs::stripe_artcraft_product_info_list::{ARTCRAFT_BASIC_PRODUCTIO use enums::common::artcraft_subscription_slug::ArtcraftSubscriptionSlug; use reusable_types::server_environment::ServerEnvironment; -pub fn get_artcraft_product_by_slug(slug: ArtcraftSubscriptionSlug, env: ServerEnvironment) -> StripeArtcraftProductInfo { +pub fn get_artcraft_product_by_slug_and_env(slug: ArtcraftSubscriptionSlug, env: ServerEnvironment) -> StripeArtcraftProductInfo { match (env, slug) { // Development sandbox (ServerEnvironment::Development, ArtcraftSubscriptionSlug::ArtcraftBasic) => ARTCRAFT_BASIC_SANDBOX, diff --git a/crates/service/plugins/billing_artcraft/src/configs/get_artcraft_product_by_stripe_id_and_env.rs b/crates/service/plugins/billing_artcraft/src/configs/get_artcraft_product_by_stripe_id_and_env.rs new file mode 100644 index 0000000000..0c1dc13e7a --- /dev/null +++ b/crates/service/plugins/billing_artcraft/src/configs/get_artcraft_product_by_stripe_id_and_env.rs @@ -0,0 +1,41 @@ +use crate::configs::stripe_artcraft_product_info::StripeArtcraftProductInfo; +use crate::configs::stripe_artcraft_product_info_list::{ARTCRAFT_BASIC_PRODUCTION, ARTCRAFT_BASIC_SANDBOX, ARTCRAFT_MAX_PRODUCTION, ARTCRAFT_MAX_SANDBOX, ARTCRAFT_PRO_PRODUCTION, ARTCRAFT_PRO_SANDBOX}; +use once_cell::sync::Lazy; +use reusable_types::server_environment::ServerEnvironment; +use std::collections::HashMap; + + +static PRODUCTION_PRODUCTS_BY_STRIPE_ID : Lazy> = Lazy::new(|| { + let mut plans = HashMap::new(); + + fn add_plan(plans: &mut HashMap, product: &StripeArtcraftProductInfo) { + plans.insert(product.product_id.to_string(), product.clone()); + } + + add_plan(&mut plans, &ARTCRAFT_BASIC_PRODUCTION); + add_plan(&mut plans, &ARTCRAFT_PRO_PRODUCTION); + add_plan(&mut plans, &ARTCRAFT_MAX_PRODUCTION); + + plans +}); + +static SANDBOX_PRODUCTS_BY_STRIPE_ID : Lazy> = Lazy::new(|| { + let mut plans = HashMap::new(); + + fn add_plan(plans: &mut HashMap, product: &StripeArtcraftProductInfo) { + plans.insert(product.product_id.to_string(), product.clone()); + } + + add_plan(&mut plans, &ARTCRAFT_BASIC_SANDBOX); + add_plan(&mut plans, &ARTCRAFT_PRO_SANDBOX); + add_plan(&mut plans, &ARTCRAFT_MAX_SANDBOX); + + plans +}); + +pub fn get_artcraft_product_by_stripe_id_and_env(stripe_id: &str, env: ServerEnvironment) -> Option<&StripeArtcraftProductInfo> { + match env { + ServerEnvironment::Development => SANDBOX_PRODUCTS_BY_STRIPE_ID.get(stripe_id), + ServerEnvironment::Production => PRODUCTION_PRODUCTS_BY_STRIPE_ID.get(stripe_id), + } +} diff --git a/crates/service/plugins/billing_artcraft/src/configs/mod.rs b/crates/service/plugins/billing_artcraft/src/configs/mod.rs index a908c4d1ad..6b2ef562b3 100644 --- a/crates/service/plugins/billing_artcraft/src/configs/mod.rs +++ b/crates/service/plugins/billing_artcraft/src/configs/mod.rs @@ -1,5 +1,5 @@ -pub mod get_artcraft_product_by_slug; +pub mod get_artcraft_product_by_slug_and_env; +pub mod get_artcraft_product_by_stripe_id_and_env; pub mod stripe_artcraft_metadata_keys; pub mod stripe_artcraft_product_info; pub mod stripe_artcraft_product_info_list; - diff --git a/crates/service/plugins/billing_artcraft/src/configs/stripe_artcraft_product_info.rs b/crates/service/plugins/billing_artcraft/src/configs/stripe_artcraft_product_info.rs index 5fa97f61a9..4687e39e33 100644 --- a/crates/service/plugins/billing_artcraft/src/configs/stripe_artcraft_product_info.rs +++ b/crates/service/plugins/billing_artcraft/src/configs/stripe_artcraft_product_info.rs @@ -1,5 +1,6 @@ use enums::common::artcraft_subscription_slug::ArtcraftSubscriptionSlug; +#[derive(Debug, Clone)] pub struct StripeArtcraftProductInfo { pub slug: ArtcraftSubscriptionSlug, pub product_id: &'static str, diff --git a/crates/service/plugins/billing_artcraft/src/endpoints/checkout/stripe_artcraft_create_subscription_checkout_session_handler.rs b/crates/service/plugins/billing_artcraft/src/endpoints/checkout/stripe_artcraft_create_subscription_checkout_session_handler.rs index a75990f02a..47e093f290 100644 --- a/crates/service/plugins/billing_artcraft/src/endpoints/checkout/stripe_artcraft_create_subscription_checkout_session_handler.rs +++ b/crates/service/plugins/billing_artcraft/src/endpoints/checkout/stripe_artcraft_create_subscription_checkout_session_handler.rs @@ -1,4 +1,4 @@ -use crate::configs::get_artcraft_product_by_slug::get_artcraft_product_by_slug; +use crate::configs::get_artcraft_product_by_slug_and_env::get_artcraft_product_by_slug_and_env; use crate::configs::stripe_artcraft_metadata_keys::{STRIPE_ARTCRAFT_METADATA_EMAIL, STRIPE_ARTCRAFT_METADATA_USERNAME, STRIPE_ARTCRAFT_METADATA_USER_TOKEN}; use crate::utils::artcraft_stripe_config::ArtcraftStripeConfigWithClient; use crate::utils::common_web_error::CommonWebError; @@ -51,7 +51,7 @@ pub async fn stripe_artcraft_create_checkout_session_handler( Some(cadence) => cadence, }; - let plan = get_artcraft_product_by_slug(slug, **server_environment); + let plan = get_artcraft_product_by_slug_and_env(slug, **server_environment); let price_id = match cadence { PlanBillingCadence::Monthly => plan.monthly_price_id.clone(), 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 2c46362786..71d148e5b6 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,2 @@ pub mod stripe_artcraft_webhook_handler; -pub mod stripe_artcraft_webhook_summary; \ No newline at end of file +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 9663cc3014..fe9d49e61c 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,20 +1,32 @@ use actix_web::web::{Bytes, Data, Json}; use actix_web::{web, HttpRequest, HttpResponse}; -use crate::endpoints::webhook::stripe_artcraft_webhook_summary::StripeArtcraftWebhookSummary; +use crate::endpoints::webhook::webhook_event_handlers::charge::charge_succeeded_handler::charge_succeeded_handler; +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::customer_created_handler::customer_created_handler; +use crate::endpoints::webhook::webhook_event_handlers::customer::customer_deleted_handler::customer_deleted_handler; +use crate::endpoints::webhook::webhook_event_handlers::customer::customer_updated_handler::customer_updated_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::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::invoice::invoice_payment_succeeded_handler::invoice_payment_succeeded_handler; +use crate::endpoints::webhook::webhook_event_handlers::invoice::invoice_updated_handler::invoice_updated_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::stripe_artcraft_webhook_error::StripeArtcraftWebhookError; +use crate::endpoints::webhook::webhook_event_handlers::stripe_artcraft_webhook_summary::StripeArtcraftWebhookSummary; use crate::utils::artcraft_stripe_config::ArtcraftStripeConfigWithClient; -use crate::utils::common_web_error::CommonWebError; use crate::utils::verify_stripe_webhook_ip_address::verify_stripe_webhook_ip_address; use chrono::NaiveDateTime; use http_server_common::request::get_request_header_optional::get_request_header_optional; use log::{error, info, warn}; use mysql_queries::queries::billing::stripe::get_stripe_webhook_event_log_by_id::{get_stripe_webhook_event_log_by_id, get_stripe_webhook_event_log_by_id_with_connection}; use mysql_queries::queries::billing::stripe::insert_stripe_webhook_event_log::InsertStripeWebhookEventLog; +use reusable_types::server_environment::ServerEnvironment; use serde_derive::Serialize; use sqlx::pool::PoolConnection; use sqlx::{MySql, MySqlConnection, MySqlPool}; use std::sync::Arc; -use stripe_webhook::{Event, Webhook}; +use stripe_webhook::{Event, EventObject, Webhook}; #[derive(Serialize)] pub struct StripeArtcraftWebhookSuccessResponse { @@ -33,17 +45,17 @@ pub struct StripeArtcraftWebhookSuccessResponse { // )] pub async fn stripe_artcraft_webhook_handler( http_request: HttpRequest, - //server_state: Data>, + server_environment: Data, stripe_config: Data, request_body_bytes: Bytes, mysql_pool: Data, -) -> Result, CommonWebError> +) -> Result, StripeArtcraftWebhookError> { verify_stripe_webhook_ip_address(&http_request) .map_err(|e| { let reason = format!("Improper client IP address. Error: {:?}", e); error!("{}", &reason); - CommonWebError::BadInputWithSimpleMessage("bad request".to_string()) + StripeArtcraftWebhookError::BadRequest("bad request".to_string()) })?; let secret_signing_key = &stripe_config.secret_webhook_signing_key; @@ -56,7 +68,7 @@ pub async fn stripe_artcraft_webhook_handler( .map_err(|err| { let reason = format!("Could not decode request body to UTF-8: {:?}", err); error!("{}", &reason); - CommonWebError::BadInputWithSimpleMessage(reason) + StripeArtcraftWebhookError::BadRequest(reason) })?; let webhook_payload = Webhook::construct_event(&webhook_payload, &stripe_signature, secret_signing_key) @@ -64,7 +76,7 @@ pub async fn stripe_artcraft_webhook_handler( let reason = format!("Could not construct Stripe webhook event: {:?}", e); error!("{}", &reason); println!("{:?}", webhook_payload); - CommonWebError::BadInputWithSimpleMessage(reason) + StripeArtcraftWebhookError::BadRequest(reason) })?; // Events can be re-sent, so we need to make handling them idempotent. @@ -73,13 +85,13 @@ pub async fn stripe_artcraft_webhook_handler( let stripe_event_created_at = NaiveDateTime::from_timestamp(webhook_payload.created, 0); let stripe_event_type = webhook_payload.type_.as_str().to_string(); - + //let stripe_event_type = serde_json::to_string(&webhook_payload.type_) // .map(|s| s.replace("\"", "")) // .map_err(|err| { // let reason = format!("Could not serialize webhook type: {:?}", err); // error!("{}", &reason); - // CommonWebError::BadInputWithSimpleMessage(reason) + // StripeArtcraftWebhookError::BadInputWithSimpleMessage(reason) // })?; // NB: Whether this was test data or live data @@ -94,14 +106,18 @@ pub async fn stripe_artcraft_webhook_handler( let mut mysql_connection = mysql_pool .acquire() - .await?; + .await + .map_err(|err| { + error!("Could not acquire mysql connection: {:?}", err); + StripeArtcraftWebhookError::ServerError + })?; let maybe_previously_played_event = get_stripe_webhook_event_log_by_id_with_connection(&stripe_event_id, &mut mysql_connection) .await .map_err(|err| { let reason = format!("Could not query previous event by ID ({}): {:?}", &stripe_event_id, err); error!("{}", &reason); - CommonWebError::ServerError + Err(StripeArtcraftWebhookError::ServerError) })?; if let Some(event) = maybe_previously_played_event { @@ -117,7 +133,12 @@ pub async fn stripe_artcraft_webhook_handler( } } - let webhook_summary = handle_webhook_payload(&mut mysql_connection, webhook_payload, &stripe_event_type).await?; + let webhook_summary = handle_webhook_payload( + &mut mysql_connection, + **server_environment, + webhook_payload, + &stripe_event_type + ).await?; let query = InsertStripeWebhookEventLog { stripe_event_id, @@ -136,7 +157,7 @@ pub async fn stripe_artcraft_webhook_handler( .map_err(|err| { let reason = format!("Could not insert Stripe webhook event log: {:?}", err); error!("{}", &reason); - CommonWebError::ServerError + StripeArtcraftWebhookError::ServerError })?; Ok(Json(StripeArtcraftWebhookSuccessResponse { @@ -146,9 +167,10 @@ pub async fn stripe_artcraft_webhook_handler( async fn handle_webhook_payload( mysql_connection: &mut PoolConnection, + server_environment: ServerEnvironment, webhook_payload: Event, stripe_event_type: &String -) -> Result { +) -> Result { let mut unhandled_event_type = false; let mut webhook_summary = StripeArtcraftWebhookSummary { @@ -159,119 +181,96 @@ async fn handle_webhook_payload( should_ignore_retry: false, }; - // NB: - // - "Webhook endpoints might occasionally receive the same event more than once." - // - "Stripe does not guarantee delivery of events in the order in which they are generated." - /* - match webhook_payload.type_ { + + match webhook_payload.data.object { // =============== CHECKOUT SESSIONS =============== - EventType::CheckoutSessionCompleted => { - if let EventObject::CheckoutSession(checkout_session) = webhook_payload.data.object { - webhook_summary = checkout_session_completed_handler(checkout_session)?; - } + EventObject::CheckoutSessionCompleted(checkout_session) => { + webhook_summary = checkout_session_completed_handler(checkout_session)?; } - // EventType::CheckoutSessionExpired => {} - // EventType::CheckoutSessionAsyncPaymentFailed => {} - // EventType::CheckoutSessionAsyncPaymentSucceeded => {} + // EventObject::CheckoutSessionAsyncPaymentFailed(_) => {} + // EventObject::CheckoutSessionAsyncPaymentSucceeded(_) => {} + // EventObject::CheckoutSessionExpired(_) => {} // =============== CUSTOMERS =============== - EventType::CustomerCreated => { - if let EventObject::Customer(customer) = webhook_payload.data.object { - webhook_summary = customer_created_handler(&customer)?; - } + EventObject::CustomerCreated(customer) => { + webhook_summary = customer_created_handler(&customer)?; } - EventType::CustomerUpdated => { - if let EventObject::Customer(customer) = webhook_payload.data.object { - webhook_summary = customer_updated_handler(&customer)?; - } + + EventObject::CustomerUpdated(customer) => { + webhook_summary = customer_updated_handler(&customer)?; } - EventType::CustomerDeleted => { - if let EventObject::Customer(customer) = webhook_payload.data.object { - webhook_summary = customer_deleted_handler(&customer)?; - } + + EventObject::CustomerDeleted(customer) => { + webhook_summary = customer_deleted_handler(&customer)?; } // =============== CUSTOMER SUBSCRIPTIONS =============== - EventType::CustomerSubscriptionCreated => { - if let EventObject::Subscription(subscription) = webhook_payload.data.object { - webhook_summary = customer_subscription_created_handler( - &subscription, - internal_subscription_product_lookup.get_ref(), - &mysql_pool).await?; - } + EventObject::CustomerSubscriptionCreated(subscription) => { + webhook_summary = customer_subscription_created_handler( + &subscription, + server_environment, + &mysql_pool).await?; } - EventType::CustomerSubscriptionUpdated => { - if let EventObject::Subscription(subscription) = webhook_payload.data.object { - webhook_summary = customer_subscription_updated_handler( - &subscription, - internal_subscription_product_lookup.get_ref(), - &mysql_pool).await?; - } + + EventObject::CustomerSubscriptionUpdated(subscription) => { + webhook_summary = customer_subscription_updated_handler( + &subscription, + server_environment, + &mysql_pool).await?; } - EventType::CustomerSubscriptionDeleted => { - if let EventObject::Subscription(subscription) = webhook_payload.data.object { - webhook_summary = customer_subscription_deleted_handler( - &subscription, - internal_subscription_product_lookup.get_ref(), - &mysql_pool).await?; - } + + EventObject::CustomerSubscriptionDeleted(subscription) => { + webhook_summary = customer_subscription_deleted_handler( + &subscription, + internal_subscription_product_lookup.get_ref(), + &mysql_pool).await?; } - // EventType::CustomerSubscriptionPendingUpdateApplied => {} - // EventType::CustomerSubscriptionPendingUpdateExpired => {} - // EventType::CustomerSubscriptionTrialWillEnd => {} + // EventObject::CustomerSubscriptionPendingUpdateApplied(_) => {} + // EventObject::CustomerSubscriptionPendingUpdateExpired(_) => {} + // EventObject::CustomerSubscriptionTrialWillEnd(_) => {} // =============== INVOICES =============== - EventType::InvoiceCreated => { + 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 } - EventType::InvoiceUpdated => { - if let EventObject::Invoice(invoice) = webhook_payload.data.object { - webhook_summary = invoice_updated_handler(&invoice)?; - } + EventObject::InvoiceUpdated(invoice) => { + webhook_summary = invoice_updated_handler(&invoice)?; } - EventType::InvoicePaid => { - if let EventObject::Invoice(invoice) = webhook_payload.data.object { - webhook_summary = invoice_paid_handler(&invoice)?; - } + EventObject::InvoicePaid(invoice) => { + webhook_summary = invoice_paid_handler(&invoice)?; } - EventType::InvoicePaymentSucceeded => { - if let EventObject::Invoice(invoice) = webhook_payload.data.object { - webhook_summary = invoice_payment_succeeded_handler(&invoice)?; - } + EventObject::InvoicePaymentSucceeded(invoice) => { + webhook_summary = invoice_payment_succeeded_handler(&invoice)?; } - EventType::InvoicePaymentFailed => { - if let EventObject::Invoice(invoice) = webhook_payload.data.object { - webhook_summary = invoice_payment_failed_handler(&invoice)?; - } + EventObject::InvoicePaymentFailed(invoice) => { + webhook_summary = invoice_payment_failed_handler(&invoice)?; } - // EventType::InvoiceDeleted => {} - // EventType::InvoiceFinalizationFailed => {} - // EventType::InvoiceFinalized => {} - // EventType::InvoiceItemCreated => {} - // EventType::InvoiceItemDeleted => {} - // EventType::InvoiceItemUpdated => {} - // EventType::InvoiceMarkedUncollectible => {} - // EventType::InvoicePaymentActionRequired => {} - // EventType::InvoiceSent => {} - // EventType::InvoiceUpcoming => {} - // EventType::InvoiceUpdated => {} - // EventType::InvoiceVoided => {} + // EventObject::InvoiceDeleted(_) => {} + // EventObject::InvoiceFinalizationFailed(_) => {} + // EventObject::InvoiceFinalized(_) => {} + // EventObject::InvoiceItemCreated(_) => {} + // EventObject::InvoiceItemDeleted(_) => {} + // EventObject::InvoiceItemUpdated(_) => {} + // EventObject::InvoiceMarkedUncollectible(_) => {} + // EventObject::InvoicePaymentActionRequired(_) => {} + // EventObject::InvoiceSent(_) => {} + // EventObject::InvoiceUpcoming(_) => {} + // EventObject::InvoiceUpdated(_) => {} + // EventObject::InvoiceVoided(_) => {} // =============== PAYMENT INTENTS =============== - EventType::PaymentIntentSucceeded => { - if let EventObject::PaymentIntent(payment_intent) = webhook_payload.data.object { - webhook_summary = payment_intent_succeeded_handler(&payment_intent)?; - } + EventObject::PaymentIntentSucceeded(payment_intent) => { + webhook_summary = payment_intent_succeeded_handler(&payment_intent)?; } // EventType::PaymentIntentAmountCapturableUpdated => {} @@ -285,16 +284,13 @@ async fn handle_webhook_payload( // =============== CHARGES =============== - EventType::ChargeSucceeded => { - if let EventObject::Charge(charge) = webhook_payload.data.object { - webhook_summary = charge_succeeded_handler(&charge)?; - } + EventObject::ChargeSucceeded(charge) => { + webhook_summary = charge_succeeded_handler(&charge)?; } - // =============== Ignored =============== - // Ignoring these types: + // Ignoring these types (NOTE: This is from the old async-stripe version): // Account* (6), // ApplicationFee* (2), // BalanceAvailable (1), @@ -342,12 +338,11 @@ async fn handle_webhook_payload( // TestHelpersTestClock* (5), // Topup* (5), // Transfer* (5), - + _ => { unhandled_event_type = true; }, } - */ // To play with the payload contents as JSON: // let json = serde_json::ser::to_string(&event_payload).unwrap(); diff --git a/crates/service/plugins/billing_artcraft/src/endpoints/webhook/webhook_event_handlers/charge/charge_succeeded_handler.rs b/crates/service/plugins/billing_artcraft/src/endpoints/webhook/webhook_event_handlers/charge/charge_succeeded_handler.rs new file mode 100644 index 0000000000..1fd13a193e --- /dev/null +++ b/crates/service/plugins/billing_artcraft/src/endpoints/webhook/webhook_event_handlers/charge/charge_succeeded_handler.rs @@ -0,0 +1,30 @@ +use crate::configs::stripe_artcraft_metadata_keys::STRIPE_ARTCRAFT_METADATA_USER_TOKEN; +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::utils::expand_customer_id::expand_customer_id; +use stripe_shared::Charge; + +// Handle event type: 'charge.succeeded' +pub fn charge_succeeded_handler(charge: &Charge) -> Result { + let charge_id = charge.id.to_string(); + + let charge_status = charge.status; + + // 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 = charge.customer + .as_ref() + .map(|c| expand_customer_id(c)); + + // NB: Our internal user token. + let maybe_user_token = charge.metadata.get(STRIPE_ARTCRAFT_METADATA_USER_TOKEN) + .map(|t| t.to_string()); + + Ok(StripeArtcraftWebhookSummary { + maybe_user_token, + maybe_event_entity_id: Some(charge_id), + maybe_stripe_customer_id, + action_was_taken: false, + should_ignore_retry: false, + }) +} diff --git a/crates/service/plugins/billing_artcraft/src/endpoints/webhook/webhook_event_handlers/charge/mod.rs b/crates/service/plugins/billing_artcraft/src/endpoints/webhook/webhook_event_handlers/charge/mod.rs new file mode 100644 index 0000000000..688ad14f1c --- /dev/null +++ b/crates/service/plugins/billing_artcraft/src/endpoints/webhook/webhook_event_handlers/charge/mod.rs @@ -0,0 +1 @@ +pub(crate) mod charge_succeeded_handler; \ No newline at end of file diff --git a/crates/service/plugins/billing_artcraft/src/endpoints/webhook/webhook_event_handlers/checkout_session/checkout_session_completed_handler.rs b/crates/service/plugins/billing_artcraft/src/endpoints/webhook/webhook_event_handlers/checkout_session/checkout_session_completed_handler.rs new file mode 100644 index 0000000000..c00e3de1cb --- /dev/null +++ b/crates/service/plugins/billing_artcraft/src/endpoints/webhook/webhook_event_handlers/checkout_session/checkout_session_completed_handler.rs @@ -0,0 +1,32 @@ +use crate::configs::stripe_artcraft_metadata_keys::STRIPE_ARTCRAFT_METADATA_USER_TOKEN; +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::utils::expand_customer_id::expand_customer_id; +use stripe_checkout::CheckoutSession; + +// 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. +pub fn checkout_session_completed_handler(checkout_session: CheckoutSession) -> Result { + let stripe_checkout_id = checkout_session.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 = checkout_session.customer + .as_ref() + .map(|c| expand_customer_id(c)); + + // NB: Our internal user token. + let maybe_user_token = checkout_session.metadata + .and_then(|m| m.get(STRIPE_ARTCRAFT_METADATA_USER_TOKEN).map(|t| t.to_string())); + + Ok(StripeArtcraftWebhookSummary { + maybe_user_token, + maybe_event_entity_id: Some(stripe_checkout_id), + maybe_stripe_customer_id, + action_was_taken: false, + should_ignore_retry: false, + }) +} diff --git a/crates/service/plugins/billing_artcraft/src/endpoints/webhook/webhook_event_handlers/checkout_session/mod.rs b/crates/service/plugins/billing_artcraft/src/endpoints/webhook/webhook_event_handlers/checkout_session/mod.rs new file mode 100644 index 0000000000..524729c738 --- /dev/null +++ b/crates/service/plugins/billing_artcraft/src/endpoints/webhook/webhook_event_handlers/checkout_session/mod.rs @@ -0,0 +1 @@ +pub(crate) mod checkout_session_completed_handler; \ No newline at end of file diff --git a/crates/service/plugins/billing_artcraft/src/endpoints/webhook/webhook_event_handlers/customer/customer_created_handler.rs b/crates/service/plugins/billing_artcraft/src/endpoints/webhook/webhook_event_handlers/customer/customer_created_handler.rs new file mode 100644 index 0000000000..b357ff3569 --- /dev/null +++ b/crates/service/plugins/billing_artcraft/src/endpoints/webhook/webhook_event_handlers/customer/customer_created_handler.rs @@ -0,0 +1,25 @@ +use crate::configs::stripe_artcraft_metadata_keys::STRIPE_ARTCRAFT_METADATA_USER_TOKEN; +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 stripe_core::Customer; + +// Handle event type: 'customer.created' +pub fn customer_created_handler(customer: &Customer) -> Result { + + // NB: We'll need this to send them to the "customer portal", which is how they can modify + // or cancel their subscriptions. + let customer_id = customer.id.to_string(); + + // NB: Our internal user token. + let maybe_user_token = customer.metadata + .as_ref() + .and_then(|m| m.get(STRIPE_ARTCRAFT_METADATA_USER_TOKEN).map(|t| t.to_string())); + + Ok(StripeArtcraftWebhookSummary { + maybe_user_token, + maybe_event_entity_id: Some(customer_id.clone()), + maybe_stripe_customer_id: Some(customer_id.clone()), + action_was_taken: false, + should_ignore_retry: false, + }) +} diff --git a/crates/service/plugins/billing_artcraft/src/endpoints/webhook/webhook_event_handlers/customer/customer_deleted_handler.rs b/crates/service/plugins/billing_artcraft/src/endpoints/webhook/webhook_event_handlers/customer/customer_deleted_handler.rs new file mode 100644 index 0000000000..02a3451cc7 --- /dev/null +++ b/crates/service/plugins/billing_artcraft/src/endpoints/webhook/webhook_event_handlers/customer/customer_deleted_handler.rs @@ -0,0 +1,24 @@ +use crate::configs::stripe_artcraft_metadata_keys::STRIPE_ARTCRAFT_METADATA_USER_TOKEN; +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 stripe_core::Customer; + +// Handle event type: 'customer.deleted' +pub fn customer_deleted_handler(customer: &Customer) -> Result { + // NB: We'll need this to send them to the "customer portal", which is how they can modify + // or cancel their subscriptions. + let customer_id = customer.id.to_string(); + + // NB: Our internal user token. + let maybe_user_token = customer.metadata + .as_ref() + .and_then(|m| m.get(STRIPE_ARTCRAFT_METADATA_USER_TOKEN).map(|t| t.to_string())); + + Ok(StripeArtcraftWebhookSummary { + maybe_user_token, + maybe_event_entity_id: Some(customer_id.clone()), + maybe_stripe_customer_id: Some(customer_id.clone()), + action_was_taken: false, + should_ignore_retry: false, + }) +} diff --git a/crates/service/plugins/billing_artcraft/src/endpoints/webhook/webhook_event_handlers/customer/customer_updated_handler.rs b/crates/service/plugins/billing_artcraft/src/endpoints/webhook/webhook_event_handlers/customer/customer_updated_handler.rs new file mode 100644 index 0000000000..eb9b1f72dc --- /dev/null +++ b/crates/service/plugins/billing_artcraft/src/endpoints/webhook/webhook_event_handlers/customer/customer_updated_handler.rs @@ -0,0 +1,24 @@ +use crate::configs::stripe_artcraft_metadata_keys::STRIPE_ARTCRAFT_METADATA_USER_TOKEN; +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 stripe_core::Customer; + +// Handle event type: 'customer.updated' +pub fn customer_updated_handler(customer: &Customer) -> Result { + // NB: We'll need this to send them to the "customer portal", which is how they can modify + // or cancel their subscriptions. + let customer_id = customer.id.to_string(); + + // NB: Our internal user token. + let maybe_user_token = customer.metadata + .as_ref() + .and_then(|m| m.get(STRIPE_ARTCRAFT_METADATA_USER_TOKEN).map(|t| t.to_string())); + + Ok(StripeArtcraftWebhookSummary { + maybe_user_token, + maybe_event_entity_id: Some(customer_id.clone()), + maybe_stripe_customer_id: Some(customer_id.clone()), + action_was_taken: false, + should_ignore_retry: false, + }) +} diff --git a/crates/service/plugins/billing_artcraft/src/endpoints/webhook/webhook_event_handlers/customer/mod.rs b/crates/service/plugins/billing_artcraft/src/endpoints/webhook/webhook_event_handlers/customer/mod.rs new file mode 100644 index 0000000000..f10c065e63 --- /dev/null +++ b/crates/service/plugins/billing_artcraft/src/endpoints/webhook/webhook_event_handlers/customer/mod.rs @@ -0,0 +1,3 @@ +pub(crate) mod customer_created_handler; +pub(crate) mod customer_deleted_handler; +pub(crate) mod customer_updated_handler; diff --git a/crates/service/plugins/billing_artcraft/src/endpoints/webhook/webhook_event_handlers/customer_subscription/calculate_subscription_end_date.rs b/crates/service/plugins/billing_artcraft/src/endpoints/webhook/webhook_event_handlers/customer_subscription/calculate_subscription_end_date.rs new file mode 100644 index 0000000000..13da52696c --- /dev/null +++ b/crates/service/plugins/billing_artcraft/src/endpoints/webhook/webhook_event_handlers/customer_subscription/calculate_subscription_end_date.rs @@ -0,0 +1,32 @@ +use std::ops::{Add, Sub}; + +use chrono::{Duration, NaiveDateTime}; +use once_cell::sync::Lazy; + +use crate::endpoints::webhook::webhook_event_handlers::customer_subscription::subscription_event_extractor::SubscriptionSummary; +use reusable_types::stripe::stripe_subscription_status::StripeSubscriptionStatus; + +// Push back the expiration date. +static SUBSCRIPTION_GRACE_DAYS : Lazy = Lazy::new(|| Duration::days(2)); + +// Set canceled subscriptions back in time over a year, which is the maximum billing period + grace. +static BACKDATE_TERMINATION_DAYS : Lazy = Lazy::new(|| Duration::days(370)); + +#[inline] +pub fn calculate_subscription_end_date(subscription_summary: &SubscriptionSummary) -> NaiveDateTime { + if subscription_summary.stripe_subscription_status == StripeSubscriptionStatus::Canceled { + // If it's cancelled, the user is done and the service should be removed. + // NB: Subscriptions set to cancel in the future (from the admin UI) are still "active". + return subscription_summary.maybe_canceled_at + .unwrap_or(subscription_summary.current_billing_period_end) + .sub(*BACKDATE_TERMINATION_DAYS); // Just to be sure. + } + + let mut subscription_end = subscription_summary.current_billing_period_end; + + if !subscription_summary.cancel_at_period_end { + subscription_end = subscription_end.add(*SUBSCRIPTION_GRACE_DAYS); + } + + subscription_end +} diff --git a/crates/service/plugins/billing_artcraft/src/endpoints/webhook/webhook_event_handlers/customer_subscription/common.rs b/crates/service/plugins/billing_artcraft/src/endpoints/webhook/webhook_event_handlers/customer_subscription/common.rs new file mode 100644 index 0000000000..965be186bc --- /dev/null +++ b/crates/service/plugins/billing_artcraft/src/endpoints/webhook/webhook_event_handlers/customer_subscription/common.rs @@ -0,0 +1,8 @@ + +/// Subscription categories are used to namespace subscriptions. +/// Integrations should always provide a default even if this isn't used. +pub const UNKNOWN_SUBSCRIPTION_NAMESPACE: &str = "unknown"; + +/// The integrated system has internal unique string key identifiers for +/// each subscription plan. +pub const UNKNOWN_SUBSCRIPTION_PRODUCT_SLUG: &str = "unknown"; diff --git a/crates/service/plugins/billing_artcraft/src/endpoints/webhook/webhook_event_handlers/customer_subscription/customer_subscription_created_handler.rs b/crates/service/plugins/billing_artcraft/src/endpoints/webhook/webhook_event_handlers/customer_subscription/customer_subscription_created_handler.rs new file mode 100644 index 0000000000..2c738a14e1 --- /dev/null +++ b/crates/service/plugins/billing_artcraft/src/endpoints/webhook/webhook_event_handlers/customer_subscription/customer_subscription_created_handler.rs @@ -0,0 +1,119 @@ +use crate::configs::get_artcraft_product_by_slug_and_env::get_artcraft_product_by_slug_and_env; +use crate::configs::get_artcraft_product_by_stripe_id_and_env::get_artcraft_product_by_stripe_id_and_env; +use crate::endpoints::webhook::webhook_event_handlers::customer_subscription::calculate_subscription_end_date::calculate_subscription_end_date; +use crate::endpoints::webhook::webhook_event_handlers::customer_subscription::subscription_event_extractor::subscription_summary_extractor; +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 enums::common::subscription_namespace::SubscriptionNamespace; +use log::error; +use mysql_queries::queries::users::user::update::update_user_record_with_new_stripe_customer_id::update_user_record_with_new_stripe_customer_id; +use mysql_queries::queries::users::user_subscriptions::get_user_subscription_by_stripe_subscription_id::get_user_subscription_by_stripe_subscription_id; +use mysql_queries::queries::users::user_subscriptions::upsert_user_subscription_by_stripe_id::UpsertUserSubscription; +use reusable_types::server_environment::ServerEnvironment; +use sqlx::MySqlPool; +use stripe_shared::Subscription; + +/// 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 async fn customer_subscription_created_handler( + subscription: &Subscription, + server_environment: ServerEnvironment, + //internal_subscription_product_lookup: &dyn InternalSubscriptionProductLookup, + mysql_pool: &MySqlPool, +) -> 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 result = StripeArtcraftWebhookSummary { + 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, + }; + + // NB: It's possible to receive events out of order. + // We won't want to play a `create` event on top. + let maybe_existing_subscription = get_user_subscription_by_stripe_subscription_id(&summary.stripe_subscription_id, &mysql_pool) + .await + .map_err(|err| { + let reason = format!("Mysql error: {:?}", err); + error!("{}", reason); + StripeArtcraftWebhookError::ServerError(reason) + })?; + + if maybe_existing_subscription.is_some() { + result.should_ignore_retry = true; + return Ok(result); + } + + let maybe_product = get_artcraft_product_by_stripe_id_and_env( + &summary.stripe_product_id, server_environment); + + let product = match maybe_product { + None => { + error!("No matching product for stripe product ID: {}", &summary.stripe_product_id); + result.should_ignore_retry = true; + return Ok(result); + } + Some(product) => product, + }; + + if let Some(user_token) = summary.user_token.as_deref() { + // TODO: record cancel_at (future_cancel_at), canceled_at, ended_at (if subscription ended, when it ended), start_date + + let upsert = UpsertUserSubscription { + stripe_subscription_id: &summary.stripe_subscription_id, + user_token, + 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(mysql_pool) + .await + .map_err(|err| { + let reason = format!("Mysql error: {:?}", err); + error!("{}", reason); + StripeArtcraftWebhookError::ServerError(reason) + })?; + + + // 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( + mysql_pool, + 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) +} diff --git a/crates/service/plugins/billing_artcraft/src/endpoints/webhook/webhook_event_handlers/customer_subscription/customer_subscription_deleted_handler.rs b/crates/service/plugins/billing_artcraft/src/endpoints/webhook/webhook_event_handlers/customer_subscription/customer_subscription_deleted_handler.rs new file mode 100644 index 0000000000..937c3d67c5 --- /dev/null +++ b/crates/service/plugins/billing_artcraft/src/endpoints/webhook/webhook_event_handlers/customer_subscription/customer_subscription_deleted_handler.rs @@ -0,0 +1,116 @@ +use log::error; +use sqlx::MySqlPool; +use stripe::Subscription; + +use mysql_queries::queries::users::user_subscriptions::get_user_subscription_by_stripe_subscription_id::get_user_subscription_by_stripe_subscription_id; +use mysql_queries::queries::users::user_subscriptions::upsert_user_subscription_by_stripe_id::UpsertUserSubscription; +use reusable_types::stripe::stripe_subscription_status::StripeSubscriptionStatus; + +use crate::stripe::http_endpoints::webhook::webhook_event_handlers::customer_subscription::calculate_subscription_end_date::calculate_subscription_end_date; +use crate::stripe::http_endpoints::webhook::webhook_event_handlers::customer_subscription::common::{UNKNOWN_SUBSCRIPTION_NAMESPACE, UNKNOWN_SUBSCRIPTION_PRODUCT_SLUG}; +use crate::stripe::http_endpoints::webhook::webhook_event_handlers::customer_subscription::subscription_event_extractor::subscription_summary_extractor; +use crate::stripe::http_endpoints::webhook::webhook_event_handlers::stripe_webhook_error::StripeWebhookError; +use crate::stripe::http_endpoints::webhook::webhook_event_handlers::stripe_webhook_summary::StripeWebhookSummary; +use crate::stripe::traits::internal_subscription_product_lookup::InternalSubscriptionProductLookup; + +/// Handle event type: 'customer.subscription.deleted' +/// Sent when a customer’s subscription ends. +pub async fn customer_subscription_deleted_handler( + subscription: &Subscription, + internal_subscription_product_lookup: &dyn InternalSubscriptionProductLookup, + mysql_pool: &MySqlPool, +) -> Result { + let summary = subscription_summary_extractor(subscription) + .map_err(|err| { + let reason = format!("Error extracting subscription from 'customer.subscription.deleted' payload: {:?}", err); + error!("{}", reason); + StripeWebhookError::ServerError(reason) // NB: This was probably *our* fault. + })?; + + let mut should_process_update = true; + + let mut action_was_taken = false; + let mut should_ignore_retry = false; + + let maybe_internal_subscription_product = + internal_subscription_product_lookup.lookup_internal_product_from_stripe_product_id(&summary.stripe_product_id) + .map_err(|err| { + let reason = format!("Error mapping to internal product: {:?}", err); + error!("{}", reason); + StripeWebhookError::ServerError(reason) // NB: This was probably *our* fault. + })?; + + let mut subscription_namespace = UNKNOWN_SUBSCRIPTION_NAMESPACE; + let mut subscription_product_slug = UNKNOWN_SUBSCRIPTION_PRODUCT_SLUG; + + if let Some(ref internal_product) = maybe_internal_subscription_product { + subscription_namespace = &internal_product.subscription_category; + subscription_product_slug = &internal_product.subscription_product_key; + } + + // NB: It's possible to receive events out of order. + let maybe_existing_subscription = get_user_subscription_by_stripe_subscription_id(&summary.stripe_subscription_id, &mysql_pool) + .await + .map_err(|err| { + let reason = format!("Mysql error: {:?}", err); + error!("{}", reason); + StripeWebhookError::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; + 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_deref() { + + let upsert = UpsertUserSubscription { + stripe_subscription_id: &summary.stripe_subscription_id, + user_token, + subscription_namespace, + subscription_product_slug, + 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(mysql_pool) + .await + .map_err(|err| { + let reason = format!("Mysql error: {:?}", err); + error!("{}", reason); + StripeWebhookError::ServerError(reason) + })?; + + action_was_taken = true; + } + + should_ignore_retry = true; + } + + 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), + action_was_taken, + should_ignore_retry, + }) +} diff --git a/crates/service/plugins/billing_artcraft/src/endpoints/webhook/webhook_event_handlers/customer_subscription/customer_subscription_updated_handler.rs b/crates/service/plugins/billing_artcraft/src/endpoints/webhook/webhook_event_handlers/customer_subscription/customer_subscription_updated_handler.rs new file mode 100644 index 0000000000..66bffb473f --- /dev/null +++ b/crates/service/plugins/billing_artcraft/src/endpoints/webhook/webhook_event_handlers/customer_subscription/customer_subscription_updated_handler.rs @@ -0,0 +1,127 @@ +use crate::endpoints::webhook::webhook_event_handlers::customer_subscription::calculate_subscription_end_date::calculate_subscription_end_date; +use crate::endpoints::webhook::webhook_event_handlers::customer_subscription::subscription_event_extractor::subscription_summary_extractor; +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::{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; +use mysql_queries::queries::users::user_subscriptions::get_user_subscription_by_stripe_subscription_id::get_user_subscription_by_stripe_subscription_id; +use mysql_queries::queries::users::user_subscriptions::upsert_user_subscription_by_stripe_id::UpsertUserSubscription; +use reusable_types::stripe::stripe_subscription_status::StripeSubscriptionStatus; +use sqlx::MySqlPool; +use stripe_shared::Subscription; + +/// Handle event type: 'customer.subscription.updated' +pub async fn customer_subscription_updated_handler( + subscription: &Subscription, + internal_subscription_product_lookup: &dyn InternalSubscriptionProductLookup, + mysql_pool: &MySqlPool, +) -> 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 mut should_process_update = true; + + let mut action_was_taken = false; + let mut should_ignore_retry = false; + + let maybe_internal_subscription_product = + internal_subscription_product_lookup.lookup_internal_product_from_stripe_product_id(&summary.stripe_product_id) + .map_err(|err| { + let reason = format!("Error mapping to internal product: {:?}", err); + error!("{}", reason); + StripeArtcraftWebhookError::ServerError(reason) // NB: This was probably *our* fault. + })?; + + let mut subscription_namespace = UNKNOWN_SUBSCRIPTION_NAMESPACE; + let mut subscription_product_slug = UNKNOWN_SUBSCRIPTION_PRODUCT_SLUG; + + if let Some(ref internal_product) = maybe_internal_subscription_product { + subscription_namespace = &internal_product.subscription_category; + subscription_product_slug = &internal_product.subscription_product_key; + } + + // NB: It's possible to receive events out of order. + let maybe_existing_subscription = get_user_subscription_by_stripe_subscription_id(&summary.stripe_subscription_id, &mysql_pool) + .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; + should_ignore_retry = true; + } + _ => {} + } + } + + if should_process_update { + if let Some(user_token) = summary.user_token.as_deref() { + info!("Upserting subscription"); + + let upsert = UpsertUserSubscription { + stripe_subscription_id: &summary.stripe_subscription_id, + user_token: &user_token, + subscription_namespace, + subscription_product_slug, + 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(mysql_pool) + .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( + mysql_pool, + user_token, + Some(&summary.stripe_customer_id)) + .await + .map_err(|err| { + let reason = format!("Mysql error: {:?}", err); + error!("{}", reason); + StripeArtcraftWebhookError::ServerError(reason) + })?; + + action_was_taken = true; + } + } + + should_ignore_retry = true; + + Ok(StripeArtcraftWebhookSummary { + maybe_user_token: summary.user_token, + maybe_event_entity_id: Some(summary.stripe_subscription_id), + maybe_stripe_customer_id: Some(summary.stripe_customer_id), + action_was_taken, + should_ignore_retry, + }) +} diff --git a/crates/service/plugins/billing_artcraft/src/endpoints/webhook/webhook_event_handlers/customer_subscription/mod.rs b/crates/service/plugins/billing_artcraft/src/endpoints/webhook/webhook_event_handlers/customer_subscription/mod.rs new file mode 100644 index 0000000000..5bee8b26e9 --- /dev/null +++ b/crates/service/plugins/billing_artcraft/src/endpoints/webhook/webhook_event_handlers/customer_subscription/mod.rs @@ -0,0 +1,6 @@ +pub mod calculate_subscription_end_date; +pub mod common; +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 diff --git a/crates/service/plugins/billing_artcraft/src/endpoints/webhook/webhook_event_handlers/customer_subscription/subscription_event_extractor.rs b/crates/service/plugins/billing_artcraft/src/endpoints/webhook/webhook_event_handlers/customer_subscription/subscription_event_extractor.rs new file mode 100644 index 0000000000..aec5dc46ec --- /dev/null +++ b/crates/service/plugins/billing_artcraft/src/endpoints/webhook/webhook_event_handlers/customer_subscription/subscription_event_extractor.rs @@ -0,0 +1,428 @@ +use crate::configs::stripe_artcraft_metadata_keys::STRIPE_ARTCRAFT_METADATA_USER_TOKEN; +use crate::utils::expand_customer_id::expand_customer_id; +use crate::utils::expand_product_id::expand_product_id; +use anyhow::anyhow; +use chrono::NaiveDateTime; +use errors::AnyhowResult; +use log::error; +use reusable_types::stripe::stripe_recurring_interval::StripeRecurringInterval; +use reusable_types::stripe::stripe_subscription_status::StripeSubscriptionStatus; +use stripe_shared::{Subscription, SubscriptionStatus}; + +#[derive(Clone, Debug)] +pub struct SubscriptionSummary { + /// Our own internal user token. + pub user_token: Option, + + /// Stripe production flag. + pub stripe_is_production: bool, + + pub stripe_subscription_id: String, + pub stripe_customer_id: String, + pub stripe_product_id: String, + pub stripe_price_id: String, + + /// The state of the subscription: active, cancelled, and other states. + pub stripe_subscription_status: StripeSubscriptionStatus, + + /// We really only care about this when subscriptions are scheduled to be terminated. + /// Do we let the user continue receiving service in a grace period, or cut it off immediately + /// at the scheduled service end date? + /// (This avoids adding extra service days as a grace period.) + pub cancel_at_period_end: bool, + + pub subscription_is_active: bool, + pub subscription_interval: StripeRecurringInterval, + + /// When the subscription was "created" in Stripe (including any backdating) + pub subscription_start_date: NaiveDateTime, + + // The updated billing period for the subscription + pub current_billing_period_start: NaiveDateTime, + pub current_billing_period_end: NaiveDateTime, + + // When the subscription will be (or was) canceled + pub maybe_cancel_at: Option, + pub maybe_canceled_at: Option, +} + +/// Extract only the subscription details we care about +/// This should be unit testable against raw webhook JSON. +pub fn subscription_summary_extractor(subscription: &Subscription) -> AnyhowResult { + let subscription_id = subscription.id.to_string(); + // NB: Our internal user token. + let maybe_user_token = subscription.metadata.get(STRIPE_ARTCRAFT_METADATA_USER_TOKEN) + .map(|t| t.to_string()); + + error!("SUBSCRIPTION EVENT USER TOKEN: {:?}", maybe_user_token); + + if subscription.items.data.len() != 1 { + return Err(anyhow!("Too many items in subscription {} : {}", + subscription_id, subscription.items.data.len())); + } + + let item = match subscription.items.data.first() { + None => return Err(anyhow!("Could not get first item in subscription {}", subscription_id)), + Some(line_item) => line_item, + }; + + let price = match &item.price { + None => return Err(anyhow!("Could not get item price in subscription {}", subscription_id)), + Some(price) => price, + }; + + let product = match &price.product { + None => return Err(anyhow!("Could not get product in subscription {}", subscription_id)), + Some(product) => product, + }; + + let recurring = match &price.recurring { + None => return Err(anyhow!("Could not get interval in subscription {}", subscription_id)), + Some(recurring) => recurring, + }; + + let start_date = NaiveDateTime::from_timestamp(subscription.start_date, 0); + let period_start = NaiveDateTime::from_timestamp(subscription.current_period_start, 0); + let period_end = NaiveDateTime::from_timestamp(subscription.current_period_end, 0); + + let maybe_cancel_at = subscription.cancel_at.map(|t| NaiveDateTime::from_timestamp(t, 0)); + let maybe_canceled_at = subscription.canceled_at.map(|t| NaiveDateTime::from_timestamp(t, 0)); + + Ok(SubscriptionSummary { + user_token: maybe_user_token, + stripe_subscription_id: subscription_id, + stripe_is_production: subscription.livemode, + stripe_customer_id: expand_customer_id(&subscription.customer), + stripe_subscription_status: subscription_status_to_reusable_type(subscription.status), + cancel_at_period_end: subscription.cancel_at_period_end, + stripe_product_id: expand_product_id(product), + stripe_price_id: price.id.to_string(), + subscription_is_active: subscription.status == SubscriptionStatus::Active, + subscription_interval: recurring_interval_to_reusable_type(recurring.interval), + subscription_start_date: start_date, + current_billing_period_start: period_start, + current_billing_period_end: period_end, + maybe_cancel_at, + maybe_canceled_at, + }) +} + +#[cfg(test)] +mod tests { + use stripe::Subscription; + + use reusable_types::stripe::stripe_recurring_interval::StripeRecurringInterval; + use reusable_types::stripe::stripe_subscription_status::StripeSubscriptionStatus; + + use crate::stripe::http_endpoints::webhook::webhook_event_handlers::customer_subscription::subscription_event_extractor::subscription_summary_extractor; + + #[test] + fn test_subscription_summary_extractor_on_create_event() { + // NB: Actual raw test data from Stripe to our webhook + let json = r#" + {"id":"sub_1LhA3MEU5se17MekeWvmTNyk","automatic_tax":{"enabled":false}, + "billing_cycle_anchor":1662979188,"cancel_at_period_end":false, + "collection_method":"charge_automatically","created":1662979188,"current_period_end":1665571188, + "current_period_start":1662979188,"customer":"cus_MQ03py0gWUh0Ox","default_tax_rates":[], + "items":{"data":[{"id":"si_MQ03hadVNfRnaS","created":1662979188,"deleted":false,"metadata":{}, + "price":{"id":"price_1LeDnKEU5se17MekVr1iYYNf","active":true,"billing_scheme":"per_unit", + "created":1662278586,"currency":"usd","deleted":false,"livemode":false,"metadata":{}, + "product":"prod_MMxi2J5y69VPbO","recurring":{"interval":"month","interval_count":1, + "usage_type":"licensed"},"tax_behavior":"exclusive","type":"recurring","unit_amount":700, + "unit_amount_decimal":"700"},"quantity":1,"subscription":"sub_1LhA3MEU5se17MekeWvmTNyk", + "tax_rates":[]}],"has_more":false,"total_count":1, + "url":"/v1/subscription_items?subscription=sub_1LhA3MEU5se17MekeWvmTNyk"}, + "latest_invoice":"in_1LhA3MEU5se17MekUqOhZ9cu","livemode":false, + "metadata":{"email":"email@email.com","username":"username","user_token":"U:TOKEN"}, + "payment_settings":{"save_default_payment_method":"off"},"start_date":1662979188, + "status":"incomplete"} + "#; + + let subscription = serde_json::from_str::(json).unwrap(); + + let summary= subscription_summary_extractor(&subscription).unwrap(); + + assert_eq!(summary.user_token, Some("U:TOKEN".to_string())); + assert_eq!(summary.stripe_subscription_id, "sub_1LhA3MEU5se17MekeWvmTNyk".to_string()); + assert_eq!(summary.stripe_subscription_status, StripeSubscriptionStatus::Incomplete); + assert_eq!(summary.cancel_at_period_end, false); + assert_eq!(summary.subscription_is_active, false); + assert_eq!(summary.stripe_customer_id, "cus_MQ03py0gWUh0Ox".to_string()); + assert_eq!(summary.stripe_product_id, "prod_MMxi2J5y69VPbO".to_string()); + assert_eq!(summary.stripe_price_id, "price_1LeDnKEU5se17MekVr1iYYNf".to_string()); + assert_eq!(summary.subscription_interval, StripeRecurringInterval::Month); + assert_eq!(summary.stripe_is_production, false); + assert_eq!(summary.subscription_start_date.to_string(), "2022-09-12 10:39:48".to_string()); + assert_eq!(summary.current_billing_period_start.to_string(), "2022-09-12 10:39:48".to_string()); + assert_eq!(summary.current_billing_period_end.to_string(), "2022-10-12 10:39:48".to_string()); + assert_eq!(summary.maybe_cancel_at, None); + assert_eq!(summary.maybe_canceled_at, None); + } + + #[test] + fn test_subscription_summary_extractor_on_update_event() { + // NB: Actual raw test data from Stripe to our webhook + let json = r#" + {"id":"sub_1Lh1wvEU5se17Mekx72OzAzs","automatic_tax":{"enabled":false}, + "billing_cycle_anchor":1662948037, + "cancel_at_period_end":false,"collection_method":"charge_automatically","created":1662948037, + "current_period_end":1665540037,"current_period_start":1662948037, + "customer":"cus_MPrgIen5Wh6QKG", + "default_payment_method":"pm_1Lh1wtEU5se17MekpirKtMJm","default_tax_rates":[], + "items":{"data":[{"id":"si_MPrgTAV333Nq7c","created":1662948037,"deleted":false,"metadata":{}, + "price":{"id":"price_1LeDnKEU5se17MekVr1iYYNf","active":true,"billing_scheme":"per_unit", + "created":1662278586,"currency":"usd","deleted":false,"livemode":false,"metadata":{}, + "product":"prod_MMxi2J5y69VPbO","recurring":{"interval":"month","interval_count":1, + "usage_type":"licensed"},"tax_behavior":"exclusive","type":"recurring","unit_amount":700, + "unit_amount_decimal":"700"},"quantity":1,"subscription":"sub_1Lh1wvEU5se17Mekx72OzAzs", + "tax_rates":[]}],"has_more":false,"total_count":1, + "url":"/v1/subscription_items?subscription=sub_1Lh1wvEU5se17Mekx72OzAzs"}, + "latest_invoice":"in_1Lh1wvEU5se17MekU99NMi1W","livemode":false, + "metadata":{"username":"echelon","user_token":"U:token","email":"email@address.com"}, + "payment_settings":{"save_default_payment_method":"off"}, + "start_date":1662948037,"status":"active"} + "#; + + let subscription = serde_json::from_str::(json).unwrap(); + + let summary= subscription_summary_extractor(&subscription).unwrap(); + + assert_eq!(summary.user_token, Some("U:token".to_string())); + assert_eq!(summary.stripe_subscription_id, "sub_1Lh1wvEU5se17Mekx72OzAzs".to_string()); + assert_eq!(summary.stripe_subscription_status, StripeSubscriptionStatus::Active); + assert_eq!(summary.cancel_at_period_end, false); + assert_eq!(summary.subscription_is_active, true); + assert_eq!(summary.stripe_customer_id, "cus_MPrgIen5Wh6QKG".to_string()); + assert_eq!(summary.stripe_product_id, "prod_MMxi2J5y69VPbO".to_string()); + assert_eq!(summary.stripe_price_id, "price_1LeDnKEU5se17MekVr1iYYNf".to_string()); + assert_eq!(summary.subscription_interval, StripeRecurringInterval::Month); + assert_eq!(summary.stripe_is_production, false); + assert_eq!(summary.subscription_start_date.to_string(), "2022-09-12 02:00:37".to_string()); + assert_eq!(summary.current_billing_period_start.to_string(), "2022-09-12 02:00:37".to_string()); + assert_eq!(summary.current_billing_period_end.to_string(), "2022-10-12 02:00:37".to_string()); + assert_eq!(summary.maybe_cancel_at, None); + assert_eq!(summary.maybe_canceled_at, None); + } + + #[test] + fn test_subscription_summary_extractor_on_control_panel_immediate_delete_event() { + // NB: Actual raw test data from Stripe to our webhook + let json = r#" + {"id":"sub_1LhA3MEU5se17MekeWvmTNyk","automatic_tax":{"enabled":false}, + "billing_cycle_anchor":1662979188,"cancel_at_period_end":false,"canceled_at":1662979740, + "collection_method":"charge_automatically","created":1662979188,"current_period_end":1665571188, + "current_period_start":1662979188,"customer":"cus_MQ03py0gWUh0Ox", + "default_payment_method":"pm_1LhA3LEU5se17MekViNvoYAx","default_tax_rates":[], + "ended_at":1662979740,"items":{"data":[{"id":"si_MQ03hadVNfRnaS","created":1662979188, + "deleted":false,"metadata":{},"price":{"id":"price_1LeDnKEU5se17MekVr1iYYNf","active":true, + "billing_scheme":"per_unit","created":1662278586,"currency":"usd","deleted":false, + "livemode":false,"metadata":{},"product":"prod_MMxi2J5y69VPbO", + "recurring":{"interval":"month","interval_count":1,"usage_type":"licensed"}, + "tax_behavior":"exclusive","type":"recurring","unit_amount":700,"unit_amount_decimal":"700"}, + "quantity":1,"subscription":"sub_1LhA3MEU5se17MekeWvmTNyk","tax_rates":[]}],"has_more":false, + "total_count":1, + "url":"/v1/subscription_items?subscription=sub_1LhA3MEU5se17MekeWvmTNyk"}, + "latest_invoice":"in_1LhA3MEU5se17MekUqOhZ9cu","livemode":false, + "metadata":{"email":"email@email.com","username":"username","user_token":"U:TOKEN"}, + "payment_settings":{"save_default_payment_method":"off"},"start_date":1662979188, + "status":"canceled"} + "#; + + let subscription = serde_json::from_str::(json).unwrap(); + + let summary= subscription_summary_extractor(&subscription).unwrap(); + + assert_eq!(summary.user_token, Some("U:TOKEN".to_string())); + assert_eq!(summary.stripe_subscription_id, "sub_1LhA3MEU5se17MekeWvmTNyk".to_string()); + assert_eq!(summary.stripe_subscription_status, StripeSubscriptionStatus::Canceled); + assert_eq!(summary.cancel_at_period_end, false); + assert_eq!(summary.subscription_is_active, false); + assert_eq!(summary.stripe_customer_id, "cus_MQ03py0gWUh0Ox".to_string()); + assert_eq!(summary.stripe_product_id, "prod_MMxi2J5y69VPbO".to_string()); + assert_eq!(summary.stripe_price_id, "price_1LeDnKEU5se17MekVr1iYYNf".to_string()); + assert_eq!(summary.subscription_interval, StripeRecurringInterval::Month); + assert_eq!(summary.stripe_is_production, false); + assert_eq!(summary.subscription_start_date.to_string(), "2022-09-12 10:39:48".to_string()); + assert_eq!(summary.current_billing_period_start.to_string(), "2022-09-12 10:39:48".to_string()); + assert_eq!(summary.current_billing_period_end.to_string(), "2022-10-12 10:39:48".to_string()); + assert_eq!(summary.maybe_cancel_at, None); + assert_eq!(summary.maybe_canceled_at.map(|t| t.to_string()), Some("2022-09-12 10:49:00".to_string())); + } + + #[test] + fn test_subscription_summary_extractor_on_control_panel_cancel_at_end_of_billing_period() { + // NB: This test data was taken from the Stripe control panel + let json = r#" + { + "id": "sub_1Lj3PKEU5se17MekHrr1isFH", + "object": "subscription", + "application": null, + "application_fee_percent": null, + "automatic_tax": { + "enabled": false + }, + "billing_cycle_anchor": 1663430298, + "billing_thresholds": null, + "cancel_at": 1666022298, + "cancel_at_period_end": true, + "canceled_at": 1663575681, + "collection_method": "charge_automatically", + "created": 1663430298, + "currency": "usd", + "current_period_end": 1666022298, + "current_period_start": 1663430298, + "customer": "cus_MRxJT3KvFcAN6Z", + "days_until_due": null, + "default_payment_method": "pm_1Lj3PJEU5se17MekuWoSWsXu", + "default_source": null, + "default_tax_rates": [ + ], + "description": null, + "discount": null, + "ended_at": null, + "items": { + "object": "list", + "data": [ + { + "id": "si_MRxJErFpEt6eYS", + "object": "subscription_item", + "billing_thresholds": null, + "created": 1663430298, + "metadata": { + }, + "plan": { + "id": "price_1LeDnKEU5se17MekVr1iYYNf", + "object": "plan", + "active": true, + "aggregate_usage": null, + "amount": 700, + "amount_decimal": "700", + "billing_scheme": "per_unit", + "created": 1662278586, + "currency": "usd", + "interval": "month", + "interval_count": 1, + "livemode": false, + "metadata": { + }, + "nickname": null, + "product": "prod_MMxi2J5y69VPbO", + "tiers_mode": null, + "transform_usage": null, + "trial_period_days": null, + "usage_type": "licensed" + }, + "price": { + "id": "price_1LeDnKEU5se17MekVr1iYYNf", + "object": "price", + "active": true, + "billing_scheme": "per_unit", + "created": 1662278586, + "currency": "usd", + "custom_unit_amount": null, + "livemode": false, + "lookup_key": null, + "metadata": { + }, + "nickname": null, + "product": "prod_MMxi2J5y69VPbO", + "recurring": { + "aggregate_usage": null, + "interval": "month", + "interval_count": 1, + "trial_period_days": null, + "usage_type": "licensed" + }, + "tax_behavior": "exclusive", + "tiers_mode": null, + "transform_quantity": null, + "type": "recurring", + "unit_amount": 700, + "unit_amount_decimal": "700" + }, + "quantity": 1, + "subscription": "sub_1Lj3PKEU5se17MekHrr1isFH", + "tax_rates": [ + ] + } + ], + "has_more": false, + "total_count": 1, + "url": "/v1/subscription_items?subscription=sub_1Lj3PKEU5se17MekHrr1isFH" + }, + "latest_invoice": "in_1Lj3PKEU5se17MekkCjCtx0X", + "livemode": false, + "metadata": { + "email": "e@mail.com", + "user_token": "U:USER_TOKEN_VALUE", + "username": "rust_test" + }, + "next_pending_invoice_item_invoice": null, + "pause_collection": null, + "payment_settings": { + "payment_method_options": null, + "payment_method_types": null, + "save_default_payment_method": "off" + }, + "pending_invoice_item_interval": null, + "pending_setup_intent": null, + "pending_update": null, + "plan": { + "id": "price_1LeDnKEU5se17MekVr1iYYNf", + "object": "plan", + "active": true, + "aggregate_usage": null, + "amount": 700, + "amount_decimal": "700", + "billing_scheme": "per_unit", + "created": 1662278586, + "currency": "usd", + "interval": "month", + "interval_count": 1, + "livemode": false, + "metadata": { + }, + "nickname": null, + "product": "prod_MMxi2J5y69VPbO", + "tiers_mode": null, + "transform_usage": null, + "trial_period_days": null, + "usage_type": "licensed" + }, + "quantity": 1, + "schedule": null, + "start_date": 1663430298, + "status": "active", + "test_clock": null, + "transfer_data": null, + "trial_end": null, + "trial_start": null + } + "#; + + let subscription = serde_json::from_str::(json).unwrap(); + + let summary= subscription_summary_extractor(&subscription).unwrap(); + + // NB: It was scheduled to cancel at the end of the billing period and remains active for now + // These are the fields we don't expect to be impacted as the subscription remains active. + assert_eq!(summary.user_token, Some("U:USER_TOKEN_VALUE".to_string())); + assert_eq!(summary.stripe_subscription_id, "sub_1Lj3PKEU5se17MekHrr1isFH".to_string()); + assert_eq!(summary.stripe_customer_id, "cus_MRxJT3KvFcAN6Z".to_string()); + assert_eq!(summary.stripe_product_id, "prod_MMxi2J5y69VPbO".to_string()); + assert_eq!(summary.stripe_price_id, "price_1LeDnKEU5se17MekVr1iYYNf".to_string()); + assert_eq!(summary.subscription_interval, StripeRecurringInterval::Month); + assert_eq!(summary.stripe_is_production, false); + assert_eq!(summary.subscription_start_date.to_string(), "2022-09-17 15:58:18".to_string()); + assert_eq!(summary.current_billing_period_start.to_string(), "2022-09-17 15:58:18".to_string()); + assert_eq!(summary.current_billing_period_end.to_string(), "2022-10-17 15:58:18".to_string()); + + // NB: It was scheduled to cancel at the end of the billing period and remains active for now + // These are the updated fields of interest pertinent to this kind of update. + assert_eq!(summary.stripe_subscription_status, StripeSubscriptionStatus::Active); + assert_eq!(summary.cancel_at_period_end, true); + assert_eq!(summary.subscription_is_active, true); + assert_eq!(summary.maybe_cancel_at.map(|t| t.to_string()), Some("2022-10-17 15:58:18".to_string())); + assert_eq!(summary.maybe_canceled_at.map(|t| t.to_string()), Some("2022-09-19 08:21:21".to_string())); + } +} \ No newline at end of file diff --git a/crates/service/plugins/billing_artcraft/src/endpoints/webhook/webhook_event_handlers/invoice/invoice_paid_handler.rs b/crates/service/plugins/billing_artcraft/src/endpoints/webhook/webhook_event_handlers/invoice/invoice_paid_handler.rs new file mode 100644 index 0000000000..e7311ec208 --- /dev/null +++ b/crates/service/plugins/billing_artcraft/src/endpoints/webhook/webhook_event_handlers/invoice/invoice_paid_handler.rs @@ -0,0 +1,236 @@ +use std::any::Any; +use chrono::{DateTime, Utc}; +use log::error; +use stripe_shared::{Invoice, InvoiceStatus}; +use errors::AnyhowResult; +use crate::configs::stripe_artcraft_metadata_keys::STRIPE_ARTCRAFT_METADATA_USER_TOKEN; +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::utils::expand_customer_id::expand_customer_id; +use crate::utils::expand_product_id::expand_product_id; +use crate::utils::expand_subscription_id::expand_subscription_id; + +/// If we determine a subscription needs update, this is the struct we produce. +/// This should be easily unit testable. +pub struct SubscriptionDetails { + // Internal user token (not Stripe) + pub user_token: Option, + + pub stripe_is_production: bool, + + pub stripe_invoice_id: String, + + pub stripe_customer_id: Option, + pub stripe_subscription_id: Option, + pub stripe_product_id: Option, + pub stripe_price_id: Option, + + pub subscription_is_active: bool, + + pub billed_at: DateTime, + + /// Tell the update handler how many days in the future to set the plan. + pub subscription_interval: SubscriptionInterval, + + /// Calculated from interval + pub subscription_expires_at: DateTime, +} + +/// If we determine a product was paid for, this is the struct we produce. +/// This should be easily unit testable. +pub struct ProductDetails { + pub user_token: Option, + + pub stripe_customer_id: Option, + pub stripe_product_id: Option, +} + +/// For now we only handle one singular item at a time. +/// (Technically this can handle many products and subscriptions at once.) +pub enum InvoicePaidDetails { + Subscription(SubscriptionDetails), + Product(ProductDetails), +} + +// Handle event type: 'invoice.paid' +// // https://stripe.com/docs/billing/subscriptions/webhooks : +// +// Sent when the invoice is successfully paid. You can provision access to your product when you +// receive this event and the subscription `status` is `active`. +// +// https://stripe.com/docs/billing/subscriptions/webhooks#active-subscriptions : +// +// 1. A few days prior to renewal, your site receives an invoice.upcoming event at the webhook +// endpoint. You can listen for this event to add extra invoice items to the upcoming invoice. +// 2. Your site receives an invoice.paid event. +// 3. Your webhook endpoint finds the customer the payment was made for. +// 4. Your webhook endpoint updates the customer’s access expiration date in your database to the +// appropriate date in the future (plus a day or two for leeway). +// +pub fn invoice_paid_handler(invoice: &Invoice) -> Result { + let maybe_invoice_details = invoice_paid_extractor(invoice) + .map_err(|err| { + let reason = format!("Error extracting invoice details from 'invoice.paid' payload: {:?}", err); + error!("{}", reason); + StripeArtcraftWebhookError::ServerError(reason) + })?; + + // TODO + Ok(StripeArtcraftWebhookSummary { + maybe_user_token: None, + maybe_event_entity_id: None, + maybe_stripe_customer_id: None, + action_was_taken: false, + should_ignore_retry: false, + }) +} + +fn invoice_paid_extractor(invoice: &Invoice) -> AnyhowResult> { + match invoice.status { + Some(InvoiceStatus::Paid) => {} + _ => { + return Ok(None); + }, + } + + let invoice_id = invoice.id.to_string(); + let is_production= invoice.livemode; + + let paid_status = invoice.status; + + let maybe_stripe_customer_id = invoice.customer + .as_ref() + .map(|c| expand_customer_id(c)); + + let maybe_stripe_subscription_id = invoice.subscription + .as_ref() + .map(|s| expand_subscription_id(s)); + + // TODO: We only handle a single line item for now. + let line_item = match invoice.lines.data.first() { + None => return Ok(None), + Some(line_item) => line_item, + }; + + //let paid_details = match line_item.type_ { + // InvoiceLineItemType::InvoiceItem => { + // return Ok(None); // TODO: Handle one-time payments. + // } + // InvoiceLineItemType::Subscription => { + // let maybe_subscription_id = line_item.subscription + // .as_ref().map(|s| expand_subscription_id(s)); + // let maybe_product_id = line_item.price + // .as_ref() + // .and_then(|price| price.product.as_ref()) + // .map(|product| expand_product_id(product)); + // let maybe_price_id = line_item.price + // .as_ref() + // .map(|price| price.id.to_string()); + // // NB: Internal user token (non-stripe) + // let maybe_user_token = line_item.metadata.get(STRIPE_ARTCRAFT_METADATA_USER_TOKEN) + // .map(|d| d.to_string()); + // InvoicePaidDetails::Subscription(SubscriptionDetails { + // user_token: maybe_user_token, + // stripe_is_production: is_production, + // stripe_invoice_id: invoice_id, + // stripe_customer_id: maybe_stripe_customer_id, + // stripe_subscription_id: maybe_subscription_id, + // stripe_product_id: maybe_product_id, + // stripe_price_id: maybe_price_id, + // subscription_is_active: false, + // billed_at: Utc::now(), + // subscription_interval: Default::default(), + // subscription_expires_at: Utc::now(), + // }) + // } + //}; + + + /* + let maybe_subscription_id = line_item.subscription + .as_ref().map(|s| expand_subscription_id(s)); + + let maybe_product_id = line_item.price + .as_ref() + .and_then(|price| price.product.as_ref()) + .map(|product| expand_product_id(product)); + + let maybe_price_id = line_item.price + .as_ref() + .map(|price| price.id.to_string()); + + // NB: Internal user token (non-stripe) + let maybe_user_token = line_item.metadata.get(STRIPE_ARTCRAFT_METADATA_USER_TOKEN) + .map(|d| d.to_string()); + + let paid_details = InvoicePaidDetails::Subscription(SubscriptionDetails { + user_token: maybe_user_token, + stripe_is_production: is_production, + stripe_invoice_id: invoice_id, + stripe_customer_id: maybe_stripe_customer_id, + stripe_subscription_id: maybe_subscription_id, + stripe_product_id: maybe_product_id, + stripe_price_id: maybe_price_id, + subscription_is_active: false, + billed_at: Utc::now(), + subscription_interval: Default::default(), + subscription_expires_at: Utc::now(), + }); + + Ok(Some(paid_details)) + */ + + Ok(None) // TODO +} + + +/*#[cfg(test)] +mod tests { + use anyhow::bail; + use stripe::Invoice; + use errors::AnyhowResult; + + use crate::stripe::http_endpoints::webhook::webhook_event_handlers::invoice::invoice_paid_handler::invoice_paid_extractor; + use crate::stripe::http_endpoints::webhook::webhook_event_handlers::invoice::invoice_paid_handler::InvoicePaidDetails; + + #[test] + fn test_invoice_paid_extractor() -> AnyhowResult<()> { + // NB: Not the prettiest, but this is a dev mode capture of a subscription "invoice.paid" event: + let invoice = r#" + {"id":"in_1Lh1R6EU5se17Mekksuvq20H","account_country":"US","account_name":"Storyteller","amount_due":700,"amount_paid":700,"amount_remaining":0, + "attempt_count":1,"attempted":true,"auto_advance":false,"automatic_tax":{"enabled":false},"billing_reason":"subscription_create","charge":"ch_3Lh1R7EU5se17Mek1StoPTgz", + "collection_method":"charge_automatically","created":1662946064,"currency":"usd","customer":"cus_MPr9UxT6b4SR5v","customer_address":{"country":"US", + "postal_code":"44444"},"customer_email":"foo@bar.com","customer_name":"Name","customer_tax_exempt":"none","customer_tax_ids":[], + "default_tax_rates":[],"deleted":false,"discounts":[],"ending_balance":0, + "hosted_invoice_url":"https://invoice.stripe.com/i/acct_1KZvWXEU5se17Mek/test_YWNjdF8xS1p2V1hFVTVzZTE3TWVrLF9NUHI5YjVkM0FrWExVQTVUQ1J1bFNaRWhqcHFlTU9VLDUzNDg2ODY402005IZTB0v6?s=ap", + "invoice_pdf":"https://pay.stripe.com/invoice/acct_1KZvWXEU5se17Mek/test_YWNjdF8xS1p2V1hFVTVzZTE3TWVrLF9NUHI5YjVkM0FrWExVQTVUQ1J1bFNaRWhqcHFlTU9VLDUzNDg2ODY402005IZTB0v6/pdf?s=ap", + "lines":{"data":[{"id":"il_1Lh1R6EU5se17MekXq9iJCZi", + "amount":700,"currency":"usd","description":"1 × FakeYou basic (at $7.00 / month)","discount_amounts":[],"discountable":true,"discounts":[],"livemode":false, + "metadata":{"username":"_username_","user_token":"U:TOKEN","email":"user@name.com"},"period":{"end":1665538064,"start":1662946064},"price":{"id":"price_1LeDnKEU5se17MekVr1iYYNf", + "active":true,"billing_scheme":"per_unit","created":1662278586,"currency":"usd","deleted":false,"livemode":false,"metadata":{},"product":"prod_MMxi2J5y69VPbO", + "recurring":{"interval":"month","interval_count":1,"usage_type":"licensed"},"tax_behavior":"exclusive","type":"recurring","unit_amount":700,"unit_amount_decimal":"700"}, + "proration":false,"proration_details":{},"quantity":1,"subscription":"sub_1Lh1R6EU5se17Mekq9Ahj0gi", + "subscription_item":"si_MPr9vVB80UAGwp","tax_amounts":[],"tax_rates":[],"type":"subscription"}],"has_more":false,"total_count":1, + "url":"/v1/invoices/in_1Lh1R6EU5se17Mekksuvq20H/lines"}, + "livemode":false,"metadata":{},"number":"4F52B2F7-0001","paid":true,"paid_out_of_band":false,"payment_intent":"pi_3Lh1R7EU5se17Mek1Bby1wL8", + "payment_settings":{},"period_end":1662946064,"period_start":1662946064,"post_payment_credit_notes_amount":0, + "pre_payment_credit_notes_amount":0,"starting_balance":0,"status":"paid", + "status_transitions":{"finalized_at":1662946064,"paid_at":1662946066},"subscription":"sub_1Lh1R6EU5se17Mekq9Ahj0gi", + "subtotal":700,"total":700,"total_discount_amounts":[],"total_tax_amounts":[]} + "#; + + let invoice = serde_json::from_str::(invoice).unwrap(); + + let details = invoice_paid_extractor(&invoice).unwrap().unwrap(); + + match details { + InvoicePaidDetails::Product(_) => { bail!("This is supposed to be a subscription!"); } + InvoicePaidDetails::Subscription(subscription) => { + assert_eq!(subscription.stripe_invoice_id, "in_1Lh1R6EU5se17Mekksuvq20H".to_string()); + assert_eq!(subscription.user_token, Some("U:TOKEN".to_string())); + } + } + + Ok(()) + } +}*/ \ No newline at end of file diff --git a/crates/service/plugins/billing_artcraft/src/endpoints/webhook/webhook_event_handlers/invoice/invoice_payment_failed.rs b/crates/service/plugins/billing_artcraft/src/endpoints/webhook/webhook_event_handlers/invoice/invoice_payment_failed.rs new file mode 100644 index 0000000000..3e62cce675 --- /dev/null +++ b/crates/service/plugins/billing_artcraft/src/endpoints/webhook/webhook_event_handlers/invoice/invoice_payment_failed.rs @@ -0,0 +1,31 @@ +use crate::configs::stripe_artcraft_metadata_keys::STRIPE_ARTCRAFT_METADATA_USER_TOKEN; +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::utils::expand_customer_id::expand_customer_id; +use stripe_shared::Invoice; + +// Handle event type: 'invoice.payment_failed' +pub fn invoice_payment_failed_handler(invoice: &Invoice) -> Result { + let invoice_id = invoice.id.to_string(); + + let paid_status = invoice.status; + + // 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 = invoice.customer + .as_ref() + .map(|c| expand_customer_id(c)); + + // NB: Our internal user token. + let maybe_user_token = invoice.metadata + .as_ref() + .and_then(|m| m.get(STRIPE_ARTCRAFT_METADATA_USER_TOKEN).map(|t| t.to_string())); + + Ok(StripeArtcraftWebhookSummary { + maybe_user_token, + maybe_event_entity_id: Some(invoice_id), + maybe_stripe_customer_id, + action_was_taken: false, + should_ignore_retry: false, + }) +} diff --git a/crates/service/plugins/billing_artcraft/src/endpoints/webhook/webhook_event_handlers/invoice/invoice_payment_succeeded_handler.rs b/crates/service/plugins/billing_artcraft/src/endpoints/webhook/webhook_event_handlers/invoice/invoice_payment_succeeded_handler.rs new file mode 100644 index 0000000000..49c8428cf2 --- /dev/null +++ b/crates/service/plugins/billing_artcraft/src/endpoints/webhook/webhook_event_handlers/invoice/invoice_payment_succeeded_handler.rs @@ -0,0 +1,31 @@ +use crate::configs::stripe_artcraft_metadata_keys::STRIPE_ARTCRAFT_METADATA_USER_TOKEN; +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::utils::expand_customer_id::expand_customer_id; +use stripe_shared::Invoice; + +// Handle event type: 'invoice.payment_succeeded' +pub fn invoice_payment_succeeded_handler(invoice: &Invoice) -> Result { + let invoice_id = invoice.id.to_string(); + + let paid_status = invoice.status; + + // 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 = invoice.customer + .as_ref() + .map(|c| expand_customer_id(c)); + + // NB: Our internal user token. + let maybe_user_token = invoice.metadata + .as_ref() + .and_then(|m| m.get(STRIPE_ARTCRAFT_METADATA_USER_TOKEN).map(|t| t.to_string())); + + Ok(StripeArtcraftWebhookSummary { + maybe_user_token, + maybe_event_entity_id: Some(invoice_id), + maybe_stripe_customer_id, + action_was_taken: false, + should_ignore_retry: false, + }) +} diff --git a/crates/service/plugins/billing_artcraft/src/endpoints/webhook/webhook_event_handlers/invoice/invoice_updated_handler.rs b/crates/service/plugins/billing_artcraft/src/endpoints/webhook/webhook_event_handlers/invoice/invoice_updated_handler.rs new file mode 100644 index 0000000000..06c49b4360 --- /dev/null +++ b/crates/service/plugins/billing_artcraft/src/endpoints/webhook/webhook_event_handlers/invoice/invoice_updated_handler.rs @@ -0,0 +1,35 @@ +use crate::configs::stripe_artcraft_metadata_keys::STRIPE_ARTCRAFT_METADATA_USER_TOKEN; +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::utils::expand_customer_id::expand_customer_id; + +// Handle event type: 'invoice.updated' +// Sent when a payment succeeds or fails. +// If payment is successful the `paid` attribute is set to true and the `status` is `paid`. +// If payment fails, `paid` is set to false and the `status` remains `open`. +// Payment failures also trigger a invoice.payment_failed event. +pub fn invoice_updated_handler(invoice: &Invoice) -> Result { + let invoice_id = invoice.id.to_string(); + + let is_paid = invoice.paid; + let invoice_status = invoice.status; + + // 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 = invoice.customer + .as_ref() + .map(|c| expand_customer_id(c)); + + // NB: Our internal user token. + let maybe_user_token = invoice.metadata + .as_ref() + .and_then(|m| m.get(STRIPE_ARTCRAFT_METADATA_USER_TOKEN).map(|t| t.to_string())); + + Ok(StripeArtcraftWebhookSummary { + maybe_user_token, + maybe_event_entity_id: Some(invoice_id), + maybe_stripe_customer_id, + action_was_taken: false, + should_ignore_retry: false, + }) +} diff --git a/crates/service/plugins/billing_artcraft/src/endpoints/webhook/webhook_event_handlers/invoice/mod.rs b/crates/service/plugins/billing_artcraft/src/endpoints/webhook/webhook_event_handlers/invoice/mod.rs new file mode 100644 index 0000000000..7da8481afc --- /dev/null +++ b/crates/service/plugins/billing_artcraft/src/endpoints/webhook/webhook_event_handlers/invoice/mod.rs @@ -0,0 +1,4 @@ +pub(crate) mod invoice_paid_handler; +pub(crate) mod invoice_payment_failed; +pub(crate) mod invoice_payment_succeeded_handler; +pub(crate) mod invoice_updated_handler; 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 new file mode 100644 index 0000000000..c0262f9f3b --- /dev/null +++ b/crates/service/plugins/billing_artcraft/src/endpoints/webhook/webhook_event_handlers/mod.rs @@ -0,0 +1,9 @@ +pub mod stripe_artcraft_webhook_error; + +pub(crate) mod charge; +pub(crate) mod checkout_session; +pub(crate) mod customer; +pub(crate) mod customer_subscription; +pub(crate) mod invoice; +pub(crate) mod payment_intent; +pub(crate) mod stripe_artcraft_webhook_summary; 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 new file mode 100644 index 0000000000..273aa88b9d --- /dev/null +++ b/crates/service/plugins/billing_artcraft/src/endpoints/webhook/webhook_event_handlers/payment_intent/mod.rs @@ -0,0 +1 @@ +pub(crate) mod payment_intent_succeeded_handler; \ 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.rs b/crates/service/plugins/billing_artcraft/src/endpoints/webhook/webhook_event_handlers/payment_intent/payment_intent_succeeded_handler.rs new file mode 100644 index 0000000000..a65f46df53 --- /dev/null +++ b/crates/service/plugins/billing_artcraft/src/endpoints/webhook/webhook_event_handlers/payment_intent/payment_intent_succeeded_handler.rs @@ -0,0 +1,30 @@ +use crate::configs::stripe_artcraft_metadata_keys::STRIPE_ARTCRAFT_METADATA_USER_TOKEN; +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::utils::expand_customer_id::expand_customer_id; +use stripe_shared::PaymentIntent; + +// Handle event type: 'payment_intent.succeeded' +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; + + // 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 user token. + let maybe_user_token = payment_intent.metadata.get(STRIPE_ARTCRAFT_METADATA_USER_TOKEN) + .map(|t| t.to_string()); + + Ok(StripeArtcraftWebhookSummary { + maybe_user_token, + maybe_event_entity_id: Some(payment_intent_id), + maybe_stripe_customer_id, + action_was_taken: false, + should_ignore_retry: false, + }) +} diff --git a/crates/service/plugins/billing_artcraft/src/endpoints/webhook/webhook_event_handlers/stripe_artcraft_webhook_error.rs b/crates/service/plugins/billing_artcraft/src/endpoints/webhook/webhook_event_handlers/stripe_artcraft_webhook_error.rs new file mode 100644 index 0000000000..e79d9cdebe --- /dev/null +++ b/crates/service/plugins/billing_artcraft/src/endpoints/webhook/webhook_event_handlers/stripe_artcraft_webhook_error.rs @@ -0,0 +1,34 @@ +//! Returned by the webhook endpoint, but also dispatched event handler functions. + +use std::fmt; + +use actix_web::http::StatusCode; +use actix_web::{HttpResponse, ResponseError}; +use http_server_common::response::serialize_as_json_error::serialize_as_json_error; +use serde_derive::Serialize; + +#[derive(Debug, Serialize)] +pub enum StripeArtcraftWebhookError { + BadRequest(String), + ServerError(String), +} + +impl ResponseError for StripeArtcraftWebhookError { + fn status_code(&self) -> StatusCode { + match self { + StripeArtcraftWebhookError::BadRequest(String) => StatusCode::BAD_REQUEST, + StripeArtcraftWebhookError::ServerError(String) => StatusCode::INTERNAL_SERVER_ERROR, + } + } + + fn error_response(&self) -> HttpResponse { + serialize_as_json_error(self) + } +} + +// NB: Not using derive_more::Display since Clion doesn't understand it. +impl fmt::Display for StripeArtcraftWebhookError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{:?}", self) + } +} diff --git a/crates/service/plugins/billing_artcraft/src/endpoints/webhook/stripe_artcraft_webhook_summary.rs b/crates/service/plugins/billing_artcraft/src/endpoints/webhook/webhook_event_handlers/stripe_artcraft_webhook_summary.rs similarity index 100% rename from crates/service/plugins/billing_artcraft/src/endpoints/webhook/stripe_artcraft_webhook_summary.rs rename to crates/service/plugins/billing_artcraft/src/endpoints/webhook/webhook_event_handlers/stripe_artcraft_webhook_summary.rs diff --git a/crates/service/plugins/billing_artcraft/src/utils/expand_customer_id.rs b/crates/service/plugins/billing_artcraft/src/utils/expand_customer_id.rs new file mode 100644 index 0000000000..b3340d8206 --- /dev/null +++ b/crates/service/plugins/billing_artcraft/src/utils/expand_customer_id.rs @@ -0,0 +1,9 @@ +use stripe_core::Customer; +use stripe_types::Expandable; + +pub fn expand_customer_id(expandable_customer: &Expandable) -> String { + match expandable_customer { + Expandable::Id(id) => id.to_string(), + Expandable::Object(customer) => customer.id.to_string(), + } +} diff --git a/crates/service/plugins/billing_artcraft/src/utils/expand_product_id.rs b/crates/service/plugins/billing_artcraft/src/utils/expand_product_id.rs new file mode 100644 index 0000000000..a575695623 --- /dev/null +++ b/crates/service/plugins/billing_artcraft/src/utils/expand_product_id.rs @@ -0,0 +1,8 @@ +use stripe_types::Expandable; + +pub fn expand_product_id(expandable_product: &Expandable) -> String { + match expandable_product { + Expandable::Id(id) => id.to_string(), + Expandable::Object(product) => product.id.to_string(), + } +} diff --git a/crates/service/plugins/billing_artcraft/src/utils/expand_subscription_id.rs b/crates/service/plugins/billing_artcraft/src/utils/expand_subscription_id.rs new file mode 100644 index 0000000000..eec34f87f8 --- /dev/null +++ b/crates/service/plugins/billing_artcraft/src/utils/expand_subscription_id.rs @@ -0,0 +1,8 @@ +use stripe_types::Expandable; + +pub fn expand_subscription_id(expandable_subscription: &Expandable) -> String { + match expandable_subscription { + Expandable::Id(id) => id.to_string(), + Expandable::Object(subscription) => subscription.id.to_string(), + } +} diff --git a/crates/service/plugins/billing_artcraft/src/utils/mod.rs b/crates/service/plugins/billing_artcraft/src/utils/mod.rs index 340eab5f2e..982137ac70 100644 --- a/crates/service/plugins/billing_artcraft/src/utils/mod.rs +++ b/crates/service/plugins/billing_artcraft/src/utils/mod.rs @@ -1,3 +1,6 @@ pub mod artcraft_stripe_config; pub mod common_web_error; -pub mod verify_stripe_webhook_ip_address; +pub mod expand_customer_id; +pub mod expand_product_id; +pub mod expand_subscription_id; +pub mod verify_stripe_webhook_ip_address; \ No newline at end of file