diff --git a/Cargo.lock b/Cargo.lock index 8f809dde07..72d8edc5d0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -973,6 +973,20 @@ dependencies = [ "tokio", ] +[[package]] +name = "async-stripe-checkout" +version = "1.0.0-alpha.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b115740c799730d4caac099d48be4908d2d99611d56a874d2d3555b5d4306a97" +dependencies = [ + "async-stripe-client-core", + "async-stripe-shared", + "async-stripe-types", + "miniserde", + "serde", + "smol_str 0.3.2", +] + [[package]] name = "async-stripe-client-core" version = "1.0.0-alpha.2" @@ -1322,21 +1336,26 @@ dependencies = [ "actix-http", "actix-web", "anyhow", + "artcraft_api_defs", "async-stripe 1.0.0-alpha.2", + "async-stripe-checkout", "async-stripe-core", "async-stripe-webhook", "chrono", + "enums", "errors", "http_server_common", "log", "mockall", "mysql_queries", "once_cell", + "reusable_types", "serde", "serde_derive", "serde_json", "sqlx", "tokio", + "user_traits_component", "workspace-build-acceleration", ] diff --git a/crates/service/plugins/billing_artcraft/Cargo.toml b/crates/service/plugins/billing_artcraft/Cargo.toml index b8e33c7969..f5ecfb06b5 100644 --- a/crates/service/plugins/billing_artcraft/Cargo.toml +++ b/crates/service/plugins/billing_artcraft/Cargo.toml @@ -21,12 +21,14 @@ normal = ["workspace-build-acceleration"] workspace-build-acceleration.workspace = true # Internal +artcraft_api_defs.workspace = true +enums.workspace = true errors.workspace = true http_server_common = { path = "../../../lib/deprecated/http_server_common" } mysql_queries = { path = "../../../schema/database/mysql_queries" } -#reusable_types = { path = "../../../schema/public/reusable_types" } +reusable_types = { path = "../../../schema/public/reusable_types" } +user_traits_component = { path = "../user_traits_component" } #url_config = { path = "../../../lib/url_config" } -#user_traits_component = { path = "../user_traits_component" } # External actix-http.workspace = true @@ -45,6 +47,7 @@ sqlx = { version = "0.7.4", features = [ "mysql", "runtime-tokio", "tls-rustls", 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"] } #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.rs new file mode 100644 index 0000000000..1fb578dbee --- /dev/null +++ b/crates/service/plugins/billing_artcraft/src/configs/get_artcraft_product_by_slug.rs @@ -0,0 +1,17 @@ +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 enums::common::artcraft_subscription_slug::ArtcraftSubscriptionSlug; +use reusable_types::server_environment::ServerEnvironment; + +pub fn get_artcraft_product_by_slug(slug: ArtcraftSubscriptionSlug, env: ServerEnvironment) -> StripeArtcraftProductInfo { + match (env, slug) { + // Development sandbox + (ServerEnvironment::Development, ArtcraftSubscriptionSlug::ArtcraftBasic) => ARTCRAFT_BASIC_SANDBOX, + (ServerEnvironment::Development, ArtcraftSubscriptionSlug::ArtcraftPro) => ARTCRAFT_PRO_SANDBOX, + (ServerEnvironment::Development, ArtcraftSubscriptionSlug::ArtcraftMax) => ARTCRAFT_MAX_SANDBOX, + // Production + (ServerEnvironment::Production, ArtcraftSubscriptionSlug::ArtcraftBasic) => ARTCRAFT_BASIC_PRODUCTION, + (ServerEnvironment::Production, ArtcraftSubscriptionSlug::ArtcraftPro) => ARTCRAFT_PRO_PRODUCTION, + (ServerEnvironment::Production, ArtcraftSubscriptionSlug::ArtcraftMax) => ARTCRAFT_MAX_PRODUCTION, + } +} diff --git a/crates/service/plugins/billing_artcraft/src/configs/mod.rs b/crates/service/plugins/billing_artcraft/src/configs/mod.rs new file mode 100644 index 0000000000..a908c4d1ad --- /dev/null +++ b/crates/service/plugins/billing_artcraft/src/configs/mod.rs @@ -0,0 +1,5 @@ +pub mod get_artcraft_product_by_slug; +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_metadata_keys.rs b/crates/service/plugins/billing_artcraft/src/configs/stripe_artcraft_metadata_keys.rs new file mode 100644 index 0000000000..3b1cd24e34 --- /dev/null +++ b/crates/service/plugins/billing_artcraft/src/configs/stripe_artcraft_metadata_keys.rs @@ -0,0 +1,6 @@ + +pub const STRIPE_ARTCRAFT_METADATA_USER_TOKEN : &str = "user_token"; + +pub const STRIPE_ARTCRAFT_METADATA_USERNAME : &str = "username"; + +pub const STRIPE_ARTCRAFT_METADATA_EMAIL : &str = "email"; 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 new file mode 100644 index 0000000000..5fa97f61a9 --- /dev/null +++ b/crates/service/plugins/billing_artcraft/src/configs/stripe_artcraft_product_info.rs @@ -0,0 +1,9 @@ +use enums::common::artcraft_subscription_slug::ArtcraftSubscriptionSlug; + +pub struct StripeArtcraftProductInfo { + pub slug: ArtcraftSubscriptionSlug, + pub product_id: &'static str, + pub monthly_price_id: &'static str, + pub yearly_price_id: &'static str, +} + diff --git a/crates/service/plugins/billing_artcraft/src/configs/stripe_artcraft_product_info_list.rs b/crates/service/plugins/billing_artcraft/src/configs/stripe_artcraft_product_info_list.rs new file mode 100644 index 0000000000..5d10283e61 --- /dev/null +++ b/crates/service/plugins/billing_artcraft/src/configs/stripe_artcraft_product_info_list.rs @@ -0,0 +1,53 @@ +use crate::configs::stripe_artcraft_product_info::StripeArtcraftProductInfo; +use enums::common::artcraft_subscription_slug::ArtcraftSubscriptionSlug; + +// +// SANDBOX +// + +pub const ARTCRAFT_BASIC_SANDBOX : StripeArtcraftProductInfo = StripeArtcraftProductInfo { + slug: ArtcraftSubscriptionSlug::ArtcraftBasic, + product_id: "prod_SyJURfbu2ixP7M", + monthly_price_id: "price_1S2MrSEobp4xy4Tlwit8aeNE", + yearly_price_id: "price_1S2MrsEobp4xy4TlDG2skTkc", +}; + +pub const ARTCRAFT_PRO_SANDBOX : StripeArtcraftProductInfo = StripeArtcraftProductInfo { + slug: ArtcraftSubscriptionSlug::ArtcraftPro, + product_id: "prod_SyJTy5arqKmaxT", + monthly_price_id: "price_1S2MqZEobp4xy4TlR9KLyitD", + yearly_price_id: "price_1S2MqyEobp4xy4Tl0ZXOK8Z0", +}; + +pub const ARTCRAFT_MAX_SANDBOX : StripeArtcraftProductInfo = StripeArtcraftProductInfo { + slug: ArtcraftSubscriptionSlug::ArtcraftMax, + product_id: "prod_SyJSoHNUU81BjU", + monthly_price_id: "price_1S2MpGEobp4xy4TldyO9lAwd", + yearly_price_id: "price_1S2MppEobp4xy4Tln6xjTKKD", +}; + +// +// PRODUCTION +// + +pub const ARTCRAFT_BASIC_PRODUCTION : StripeArtcraftProductInfo = StripeArtcraftProductInfo { + slug: ArtcraftSubscriptionSlug::ArtcraftBasic, + product_id: "prod_SyIXACAGqPbsoG", + monthly_price_id: "price_1S2LwbIaZEzwFveeYqYxgrV7", + yearly_price_id: "price_1S2Lz8IaZEzwFvee40D7PFoa", +}; + +pub const ARTCRAFT_PRO_PRODUCTION : StripeArtcraftProductInfo = StripeArtcraftProductInfo { + slug: ArtcraftSubscriptionSlug::ArtcraftPro, + product_id: "prod_SyIfqs9Rmv3Fdu", + monthly_price_id: "price_1S2M4FIaZEzwFveeRoqYDbDw", + yearly_price_id: "price_1S2M5VIaZEzwFveeLAXk3hL8", +}; + +pub const ARTCRAFT_MAX_PRODUCTION : StripeArtcraftProductInfo = StripeArtcraftProductInfo { + slug: ArtcraftSubscriptionSlug::ArtcraftMax, + product_id: "prod_SyJ6VIFnc3riPp", + monthly_price_id: "price_1S2MUSIaZEzwFveesCPf2XjP", + yearly_price_id: "price_1S2MVlIaZEzwFveeEtPV27kH", +}; + diff --git a/crates/service/plugins/billing_artcraft/src/endpoints/checkout/mod.rs b/crates/service/plugins/billing_artcraft/src/endpoints/checkout/mod.rs new file mode 100644 index 0000000000..814bc35488 --- /dev/null +++ b/crates/service/plugins/billing_artcraft/src/endpoints/checkout/mod.rs @@ -0,0 +1 @@ +pub mod stripe_artcraft_create_subscription_checkout_session_handler; \ No newline at end of file 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 new file mode 100644 index 0000000000..bc8ad8c9ab --- /dev/null +++ b/crates/service/plugins/billing_artcraft/src/endpoints/checkout/stripe_artcraft_create_subscription_checkout_session_handler.rs @@ -0,0 +1,225 @@ +use crate::configs::get_artcraft_product_by_slug::get_artcraft_product_by_slug; +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; +use actix_web::web::{Data, Json}; +use actix_web::{web, HttpRequest}; +use artcraft_api_defs::stripe_artcraft::create_subscription_checkout::{PlanBillingCadence, StripeArtcraftCreateCheckoutSessionRequest, StripeArtcraftCreateCheckoutSessionResponse}; +//use billing_component::stripe::traits::internal_user_lookup::InternalUserLookup; +use enums::common::artcraft_subscription_slug::ArtcraftSubscriptionSlug; +use log::{error, warn}; +use reusable_types::server_environment::ServerEnvironment; +use std::collections::HashMap; +use std::str::FromStr; +use std::sync::Arc; +use stripe_checkout::checkout_session::{CreateCheckoutSession, CreateCheckoutSessionAutomaticTax, CreateCheckoutSessionLineItems, CreateCheckoutSessionSubscriptionData}; +use stripe_checkout::CheckoutSessionMode; +use stripe_core::CustomerId; +use user_traits_component::traits::internal_session_cache_purge::InternalSessionCachePurge; + +//use utoipa::ToSchema; + +// /// Create a Stripe Checkout session and return the redirect URL in Json. +// #[utoipa::path( +// get, +// tag = "Stripe (Artcraft)", +// path = "/v1/stripe_artcraft/checkout/create_subscription", +// params( +// ("request" = CreateCheckoutSessionRequest, description = "Payload for Request"), +// ), +// responses( +// (status = 200, description = "Success Delete", body = CreateCheckoutSessionSuccessResponse), +// ), +// )] +pub async fn stripe_artcraft_create_checkout_session_handler( + http_request: HttpRequest, + request: Json, + //server_state: Data>, + stripe_config: Data, + server_environment: Data, + //internal_user_lookup: Data, + internal_session_cache_purge: Data, +) -> Result, CommonWebError> +{ + let slug = match request.plan { + None => return Err(CommonWebError::BadInputWithSimpleMessage("no plan supplied".to_string())), + Some(slug) => slug, + }; + + let cadence = match request.cadence { + None => return Err(CommonWebError::BadInputWithSimpleMessage("no cadence supplied".to_string())), + Some(cadence) => cadence, + }; + + let plan = get_artcraft_product_by_slug(slug, **server_environment); + + let price_id = match cadence { + PlanBillingCadence::Monthly => plan.monthly_price_id.clone(), + PlanBillingCadence::Yearly => plan.yearly_price_id.clone(), + }; + + //let maybe_user_metadata = internal_user_lookup + // .lookup_user_from_http_request(&http_request) + // .await + // .map_err(|err| { + // error!("Error looking up user: {:?}", err); + // CommonWebError::ServerError // NB: This was probably *our* fault. + // })?; + + //// NB: Our integration relies on an internal user token being present. + //let user_metadata = match maybe_user_metadata { + // None => return Err(CommonWebError::NotAuthorized), + // Some(user_metadata) => user_metadata, + //}; + + //error!("Subscriptions: {:?}", &user_metadata.existing_subscription_keys); + + //// TODO: This will not handle a future where we have multiple "namespaces" or can offer users more than one subscription. + //// It will actively block users from subscribing to two or more websites. + //if !user_metadata.existing_subscription_keys.is_empty() { + // return Err(CommonWebError::BadInputWithSimpleMessage("user already has plan".to_string())) + //} + + let success_url = stripe_config.checkout_success_url.clone(); + let cancel_url = stripe_config.checkout_cancel_url.clone(); + + let checkout_session = { + let mut params = CreateCheckoutSession::new(); + + let mut params = params.success_url(&success_url); + let mut params = params.cancel_url(&cancel_url); + + // `client_reference_id` + // Stripe Docs: + // A unique string to reference the Checkout Session. + // This can be a customer ID, a cart ID, or similar, and can be used to reconcile the session + // with your internal systems. + // + // Our Notes: + // This gets reported back in the Checkout Session (and related webhooks) as + // `client_reference_id`. Passing the same ID on multiple checkouts does not unify or + // cross-correlate customers and only seems to be metadata for the checkout session itself. + // This is probably only useful for tracking checkout session engagement. + //params.client_reference_id = Some("SOME_INTERNAL_ID"); + + // `customer_email` + // Stripe Docs: + // If provided, this value will be used when the Customer object is created. If not provided, + // customers will be asked to enter their email address. Use this parameter to prefill + // customer data if you already have an email on file. To access information about the + // customer once a session is complete, use the customer field. + // + // Our Notes: + // This does not look up previous customers with the same email and will not unify or + // cross-correlate customers. By default the field will be un-editable in the checkout flow + // if this is specified. + //params.customer_email = Some("email@example.com"); + + let mut metadata = HashMap::new(); + + //metadata.insert(STRIPE_ARTCRAFT_METADATA_USER_TOKEN.to_string(), user_metadata.user_token.to_string()); + + //if let Some(username) = user_metadata.username.as_deref() { + // metadata.insert(STRIPE_ARTCRAFT_METADATA_USERNAME.to_string(), username.to_string()); + //} + + //if let Some(user_email) = user_metadata.user_email.as_deref() { + // metadata.insert(STRIPE_ARTCRAFT_METADATA_EMAIL.to_string(), user_email.to_string()); + //} + + // NB: This metadata attaches to Stripe's Checkout Session object. + // This does not attach to the subscription or payment intent, which have their own metadata + // objects. (TODO: Confirm this.) + //let mut params = params.metadata(metadata.clone()); + + // Subscription mode: Use Stripe Billing to set up fixed-price subscriptions. + // TODO: params.mode = Some(CheckoutSessionMode::Subscription); + + // NB: This metadata attaches to the subscription entity itself. + // This cannot be used for non-subscription, one-off payments. + // https://support.stripe.com/questions/using-metadata-with-checkout-sessions + let mut params = params.subscription_data(CreateCheckoutSessionSubscriptionData { + metadata: Some(metadata), + ..Default::default() + }); + + // TODO: Buy extra credits. + // // Payment mode: Accept one-time payments for cards, iDEAL, and more. + // params.mode = Some(CheckoutSessionMode::Payment); + // // NB: This metadata attaches to the payment_intent entity itself. + // // This cannot be used for subscriptions. + // // https://support.stripe.com/questions/using-metadata-with-checkout-sessions + // params.payment_intent_data = Some(CreateCheckoutSessionPaymentIntentData { + // metadata: Some(metadata.clone()), + // ..Default::default() + // }); + + let mut params = params.automatic_tax(CreateCheckoutSessionAutomaticTax { + enabled: true, + liability: None, + }); + + let mut params = params.line_items(vec![ + CreateCheckoutSessionLineItems { + price: Some(price_id.to_string()), + quantity: Some(1), + ..Default::default() + } + ]); + + // If we already have a Stripe customer associated with the user account, we'll reuse it. + //if let Some(existing_stripe_customer_id) = user_metadata.maybe_existing_stripe_customer_id.as_deref() { + // match CustomerId::from_str(existing_stripe_customer_id) { + // Ok(customer_id) => { + // params = params.customer(customer_id); + // } + // Err(err) => { + // // NB: Don't block checkout. + // warn!("Error parsing user's ({}) supposed existing stripe customer id: {:?}", + // &user_metadata.user_token, + // err); + // } + // } + //} + + // TODO: THis is now broken + //CheckoutSession::create(&server_state.stripe_artcraft.client, params) + // .await + // .map_err(|e| { + // error!("Stripe Error: {:?}", e); + // CommonWebError::ServerError + // })? + + let checkout_session = CreateCheckoutSession::new() + .success_url(&success_url) + .cancel_url(&cancel_url) + //.customer(customer.id.as_str()) + .mode(CheckoutSessionMode::Subscription) + .line_items(vec![ + CreateCheckoutSessionLineItems { + price: Some(price_id.to_string()), + quantity: Some(1), + ..Default::default() + } + ]) + //.expand([String::from("line_items"), String::from("line_items.data.price.product")]) + .send(&stripe_config.client) + .await + .map_err(|err| { + error!("Stripe Error: {:?}", err); + CommonWebError::ServerError + })?; + + checkout_session + }; + + let url = checkout_session.url.ok_or(CommonWebError::ServerError)?; + + // Best effort to delete Redis session cache + internal_session_cache_purge.best_effort_purge_session_cache(&http_request); + + Ok(Json(StripeArtcraftCreateCheckoutSessionResponse { + success: true, + stripe_checkout_redirect_url: url, + })) +} diff --git a/crates/service/plugins/billing_artcraft/src/endpoints/mod.rs b/crates/service/plugins/billing_artcraft/src/endpoints/mod.rs index 2d3f703125..2b9206e829 100644 --- a/crates/service/plugins/billing_artcraft/src/endpoints/mod.rs +++ b/crates/service/plugins/billing_artcraft/src/endpoints/mod.rs @@ -1 +1,2 @@ -pub mod webhook; \ No newline at end of file +pub mod webhook; +pub mod checkout; \ No newline at end of file diff --git a/crates/service/plugins/billing_artcraft/src/lib.rs b/crates/service/plugins/billing_artcraft/src/lib.rs index f33a053e90..6c25061bef 100644 --- a/crates/service/plugins/billing_artcraft/src/lib.rs +++ b/crates/service/plugins/billing_artcraft/src/lib.rs @@ -1,2 +1,3 @@ +pub mod configs; pub mod endpoints; pub mod utils; \ No newline at end of file diff --git a/crates/service/web/storyteller_web/src/http_server/routes/add_stripe_artcraft_routes.rs b/crates/service/web/storyteller_web/src/http_server/routes/add_stripe_artcraft_routes.rs index 7f2d47ed69..c7ca0406ff 100644 --- a/crates/service/web/storyteller_web/src/http_server/routes/add_stripe_artcraft_routes.rs +++ b/crates/service/web/storyteller_web/src/http_server/routes/add_stripe_artcraft_routes.rs @@ -1,9 +1,9 @@ -use crate::http_server::endpoints::stripe_artcraft::checkout::stripe_artcraft_create_subscription_checkout_session_handler::stripe_artcraft_create_checkout_session_handler; -use crate::http_server::endpoints::stripe_artcraft::webhook::stripe_artcraft_webhook_handler::stripe_artcraft_webhook_handler; use actix_http::body::MessageBody; use actix_service::ServiceFactory; use actix_web::dev::{ServiceRequest, ServiceResponse}; use actix_web::{web, App, Error, HttpResponse}; +use billing_artcraft_component::endpoints::checkout::stripe_artcraft_create_subscription_checkout_session_handler::stripe_artcraft_create_checkout_session_handler; +use billing_artcraft_component::endpoints::webhook::stripe_artcraft_webhook_handler::stripe_artcraft_webhook_handler; pub fn add_stripe_artcraft_routes (app: App) -> App where