diff --git a/crates/schema/database/mysql_queries/src/errors/select_optional_record_error.rs b/crates/schema/database/mysql_queries/src/errors/select_optional_record_error.rs index 16f7706cf9..2497aef807 100644 --- a/crates/schema/database/mysql_queries/src/errors/select_optional_record_error.rs +++ b/crates/schema/database/mysql_queries/src/errors/select_optional_record_error.rs @@ -4,6 +4,11 @@ use std::fmt::{Display, Formatter}; #[derive(Debug)] pub enum SelectOptionalRecordError { DatabaseError(sqlx::Error), + + /// Manually constructed error from mapping raw results into a final struct + /// Specifically, this results from manually mapping an Option to T, where T should have been non-nullable, + /// but sqlx couldn't enforce that at compile time. + RequiredFieldWasNull(&'static str), } impl Error for SelectOptionalRecordError {} @@ -12,6 +17,7 @@ impl Display for SelectOptionalRecordError { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { match self { SelectOptionalRecordError::DatabaseError(e) => write!(f, "Database error: {}", e), + SelectOptionalRecordError::RequiredFieldWasNull(field_name) => write!(f, "Required field was null: {}", field_name), } } } diff --git a/crates/schema/database/mysql_queries/src/queries/users/user_subscriptions/find_artcraft_subscription_for_owner_user.rs b/crates/schema/database/mysql_queries/src/queries/users/user_subscriptions/find_artcraft_subscription_for_owner_user.rs new file mode 100644 index 0000000000..07dff49007 --- /dev/null +++ b/crates/schema/database/mysql_queries/src/queries/users/user_subscriptions/find_artcraft_subscription_for_owner_user.rs @@ -0,0 +1,122 @@ +use crate::errors::select_optional_record_error::SelectOptionalRecordError; +use chrono::{DateTime, Utc}; +use enums::common::artcraft_subscription_slug::ArtcraftSubscriptionSlug; +use enums::common::subscription_namespace::SubscriptionNamespace; +use sqlx; +use sqlx::mysql::{MySqlArguments, MySqlRow}; +use sqlx::pool::PoolConnection; +use sqlx::MySql; +use tokens::tokens::users::UserToken; + + +pub struct ArtcraftSubscription { + pub user_token: UserToken, + + pub subscription_namespace: SubscriptionNamespace, + pub subscription_product_slug: ArtcraftSubscriptionSlug, + + pub stripe_customer_id: String, + pub stripe_subscription_id: String, + pub stripe_subscription_status: String, + + /// This is the authoritative timestamp for when the subscription expires. + pub subscription_expires_at: DateTime, +} + +pub async fn find_artcraft_subscription_for_owner_user_using_connection( + user_token: &UserToken, + connection: &mut PoolConnection, +) -> Result, SelectOptionalRecordError> { + + let query = query(user_token); + + let result = query + .fetch_optional(&mut **connection) + .await; + + map_result(result) +} + + +pub async fn find_artcraft_subscription_for_owner_user_using_transaction( + user_token: &UserToken, + transaction: &mut sqlx::Transaction<'_, MySql>, +) -> Result, SelectOptionalRecordError> { + + let query = query(user_token); + + let result = query + .fetch_optional(&mut **transaction) + .await; + + map_result(result) +} + +fn map_result(result: Result, sqlx::Error>) -> Result, SelectOptionalRecordError> { + match result { + Ok(Some(record)) => Ok(Some(ArtcraftSubscription { + user_token: record.user_token, + subscription_namespace: record.subscription_namespace, + subscription_product_slug: record.subscription_product_slug, + stripe_customer_id: record.maybe_stripe_customer_id + .ok_or_else(|| SelectOptionalRecordError::RequiredFieldWasNull("maybe_stripe_customer_id"))?, + stripe_subscription_id: record.maybe_stripe_subscription_id + .ok_or_else(|| SelectOptionalRecordError::RequiredFieldWasNull("maybe_stripe_subscription_id"))?, + stripe_subscription_status: record.maybe_stripe_subscription_status + .ok_or_else(|| SelectOptionalRecordError::RequiredFieldWasNull("maybe_stripe_subscription_status"))?, + subscription_expires_at: record.subscription_expires_at, + })), + Ok(None) => Ok(None), + Err(e) => Err(e.into()), + } +} + +// QueryAs<'q, Postgres, O, PgArguments> +fn query<'q>(user_token: &UserToken) + -> sqlx::query::Map<'static, MySql, impl Send + FnMut(MySqlRow) -> Result, MySqlArguments> { + // NB: We want to eventually support multiple wallets per user (eg. company use case), + // so we do not have a unique key on user token. In the meantime, to ensure we use the + // same wallet each time, we order by id and take the first one. + sqlx::query_as!( + RawArtcraftSubscription, + r#" +SELECT + user_token as `user_token: tokens::tokens::users::UserToken`, + subscription_namespace as `subscription_namespace: enums::common::subscription_namespace::SubscriptionNamespace`, + subscription_product_slug as `subscription_product_slug: enums::common::artcraft_subscription_slug::ArtcraftSubscriptionSlug`, + maybe_stripe_customer_id, + maybe_stripe_subscription_id, + maybe_stripe_subscription_status, + subscription_expires_at + +FROM user_subscriptions + +WHERE + user_token = ? + AND subscription_namespace = 'artcraft' + AND maybe_stripe_customer_id IS NOT NULL + AND maybe_stripe_subscription_id IS NOT NULL + AND maybe_stripe_subscription_status IS NOT NULL + AND maybe_stripe_subscription_status NOT IN ('canceled', 'incomplete_expired') + AND subscription_expires_at > CURRENT_TIMESTAMP + ORDER BY id ASC + LIMIT 1 + "#, + user_token.as_str() + ) +} + + +#[derive(sqlx::FromRow)] +struct RawArtcraftSubscription { + user_token: UserToken, + + subscription_namespace: SubscriptionNamespace, + subscription_product_slug: ArtcraftSubscriptionSlug, + + maybe_stripe_customer_id: Option, + maybe_stripe_subscription_id: Option, + maybe_stripe_subscription_status: Option, + + subscription_expires_at: DateTime, +} diff --git a/crates/schema/database/mysql_queries/src/queries/users/user_subscriptions/mod.rs b/crates/schema/database/mysql_queries/src/queries/users/user_subscriptions/mod.rs index b8ca68ee25..4dae909cfc 100644 --- a/crates/schema/database/mysql_queries/src/queries/users/user_subscriptions/mod.rs +++ b/crates/schema/database/mysql_queries/src/queries/users/user_subscriptions/mod.rs @@ -1,3 +1,4 @@ +pub mod find_artcraft_subscription_for_owner_user; pub mod get_user_subscription_by_stripe_subscription_id; pub mod get_user_subscription_by_stripe_subscription_id_transactional; pub mod list_active_user_subscriptions; diff --git a/crates/schema/public/api/artcraft_api_defs/src/stripe_artcraft/create_customer_portal_session.rs b/crates/schema/public/api/artcraft_api_defs/src/stripe_artcraft/create_customer_portal_session.rs new file mode 100644 index 0000000000..dbe3bb1f8d --- /dev/null +++ b/crates/schema/public/api/artcraft_api_defs/src/stripe_artcraft/create_customer_portal_session.rs @@ -0,0 +1,18 @@ +use enums::common::artcraft_credits_pack_slug::ArtcraftCreditsPackSlug; +use serde_derive::{Deserialize, Serialize}; +use utoipa::ToSchema; + +pub const CREATE_CUSTOMER_PORTAL_URL_PATH: &str = "/v1/stripe_artcraft/portal/create_session"; + +#[derive(Serialize, Deserialize, ToSchema)] +pub struct StripeArtcraftCreateCustomerPortalSessionRequest { + // TODO: Not sure if this is needed + pub portal_config_id: Option, +} + +#[derive(Serialize, Deserialize, ToSchema)] +pub struct StripeArtcraftCreateCustomerPortalSessionResponse { + pub success: bool, + pub stripe_portal_url: String, +} + diff --git a/crates/schema/public/api/artcraft_api_defs/src/stripe_artcraft/mod.rs b/crates/schema/public/api/artcraft_api_defs/src/stripe_artcraft/mod.rs index 7463c08653..02d6de6f75 100644 --- a/crates/schema/public/api/artcraft_api_defs/src/stripe_artcraft/mod.rs +++ b/crates/schema/public/api/artcraft_api_defs/src/stripe_artcraft/mod.rs @@ -1,2 +1,3 @@ pub mod create_credits_pack_checkout; +pub mod create_customer_portal_session; pub mod create_subscription_checkout; \ No newline at end of file diff --git a/crates/service/plugins/billing_artcraft/Cargo.toml b/crates/service/plugins/billing_artcraft/Cargo.toml index 36c0ec316e..b1e56ad52a 100644 --- a/crates/service/plugins/billing_artcraft/Cargo.toml +++ b/crates/service/plugins/billing_artcraft/Cargo.toml @@ -50,7 +50,7 @@ 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 = [] } -async-stripe-billing = { version = "1.0.0-alpha.2", features = ["subscription"] } +async-stripe-billing = { version = "1.0.0-alpha.2", features = ["subscription", "billing_portal_session", "billing_portal_configuration"] } #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/endpoints/customer_portal/mod.rs b/crates/service/plugins/billing_artcraft/src/endpoints/customer_portal/mod.rs new file mode 100644 index 0000000000..81070921bc --- /dev/null +++ b/crates/service/plugins/billing_artcraft/src/endpoints/customer_portal/mod.rs @@ -0,0 +1 @@ +pub mod stripe_artcraft_create_customer_portal_session_handler; \ No newline at end of file diff --git a/crates/service/plugins/billing_artcraft/src/endpoints/customer_portal/stripe_artcraft_create_customer_portal_session_handler.rs b/crates/service/plugins/billing_artcraft/src/endpoints/customer_portal/stripe_artcraft_create_customer_portal_session_handler.rs new file mode 100644 index 0000000000..27faa2bdfb --- /dev/null +++ b/crates/service/plugins/billing_artcraft/src/endpoints/customer_portal/stripe_artcraft_create_customer_portal_session_handler.rs @@ -0,0 +1,108 @@ +use crate::configs::stripe_artcraft_metadata_keys::{STRIPE_ARTCRAFT_METADATA_EMAIL, STRIPE_ARTCRAFT_METADATA_USERNAME, STRIPE_ARTCRAFT_METADATA_USER_TOKEN}; +use crate::configs::subscriptions::get_artcraft_subscription_by_slug_and_env::get_artcraft_subscription_by_slug_and_env; +use crate::endpoints::webhook::common::stripe_artcraft_webhook_error::StripeArtcraftWebhookError; +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_customer_portal_session::{StripeArtcraftCreateCustomerPortalSessionRequest, StripeArtcraftCreateCustomerPortalSessionResponse}; +use artcraft_api_defs::stripe_artcraft::create_subscription_checkout::{PlanBillingCadence, StripeArtcraftCreateSubscriptionCheckoutRequest, StripeArtcraftCreateSubscriptionCheckoutResponse}; +use component_traits::traits::internal_user_lookup::InternalUserLookup; +use enums::common::artcraft_subscription_slug::ArtcraftSubscriptionSlug; +use enums::common::subscription_namespace::SubscriptionNamespace; +use log::{error, info, warn}; +use mysql_queries::queries::users::user_subscriptions::find_artcraft_subscription_for_owner_user::find_artcraft_subscription_for_owner_user_using_connection; +use reusable_types::server_environment::ServerEnvironment; +use sqlx::MySqlPool; +use std::collections::HashMap; +use std::str::FromStr; +use std::sync::Arc; +use stripe_billing::billing_portal_session::CreateBillingPortalSession; +use stripe_billing::BillingPortalSession; +use stripe_checkout::checkout_session::{CreateCheckoutSession, CreateCheckoutSessionAutomaticTax, CreateCheckoutSessionLineItems, CreateCheckoutSessionSubscriptionData}; +use stripe_checkout::CheckoutSessionMode; +use stripe_core::CustomerId; +use tokens::tokens::users::UserToken; +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/portal/create_session", +// params( +// ("request" = CreatePortalSessionRequest, description = "Payload for Request"), +// ), +// responses( +// (status = 200, description = "Success", body = CreatePortalSessionSuccessResponse), +// ), +// )] +pub async fn stripe_artcraft_create_customer_portal_session_handler( + http_request: HttpRequest, + request: Json, + stripe_config: Data, + server_environment: Data, + internal_user_lookup: Data, + mysql_pool: Data, +) -> Result, CommonWebError> +{ + let mut mysql_connection = mysql_pool + .acquire() + .await + .map_err(|err| { + error!("Could not acquire mysql connection: {:?}", err); + CommonWebError::ServerError + })?; + + 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, + }; + + let user_token = UserToken::new_from_str(&user_metadata.user_token); + + let result = find_artcraft_subscription_for_owner_user_using_connection( + &user_token, + &mut mysql_connection + ).await; + + let subscription = match result { + Err(err) => { + error!("Error looking up user's ({}) existing subscription: {:?}", &user_metadata.user_token, err); + return Err(CommonWebError::ServerError); // NB: This was probably *our* fault. + } + Ok(None) => { + return Err(CommonWebError::BadInputWithSimpleMessage("user has no active subscription".to_string())) + } + Ok(Some(subscription)) => subscription, + }; + + // TODO: Set the configuration id. + + let portal_builder = CreateBillingPortalSession::new(subscription.stripe_customer_id) + .return_url(stripe_config.portal_return_url.clone()); + + let portal_session = portal_builder + .send(&stripe_config.client) + .await + .map_err(|err| { + error!("Stripe Error: {:?}", err); + CommonWebError::ServerError + })?; + + Ok(Json(StripeArtcraftCreateCustomerPortalSessionResponse { + success: true, + stripe_portal_url: portal_session.url, + })) +} diff --git a/crates/service/plugins/billing_artcraft/src/endpoints/mod.rs b/crates/service/plugins/billing_artcraft/src/endpoints/mod.rs index 2b9206e829..7cb14865dc 100644 --- a/crates/service/plugins/billing_artcraft/src/endpoints/mod.rs +++ b/crates/service/plugins/billing_artcraft/src/endpoints/mod.rs @@ -1,2 +1,3 @@ -pub mod webhook; -pub mod checkout; \ No newline at end of file +pub mod checkout; +pub mod customer_portal; +pub mod webhook; \ No newline at end of file diff --git a/crates/service/plugins/billing_artcraft/src/utils/artcraft_stripe_config.rs b/crates/service/plugins/billing_artcraft/src/utils/artcraft_stripe_config.rs index 715ec0552f..91bb3c83c9 100644 --- a/crates/service/plugins/billing_artcraft/src/utils/artcraft_stripe_config.rs +++ b/crates/service/plugins/billing_artcraft/src/utils/artcraft_stripe_config.rs @@ -6,6 +6,7 @@ pub struct ArtcraftStripeConfig { pub secret_webhook_signing_key: String, pub checkout_success_url: String, pub checkout_cancel_url: String, + pub portal_return_url: String, } #[derive(Clone)] @@ -14,6 +15,7 @@ pub struct ArtcraftStripeConfigWithClient { pub secret_webhook_signing_key: String, pub checkout_success_url: String, pub checkout_cancel_url: String, + pub portal_return_url: String, pub client: Client, } @@ -24,6 +26,7 @@ impl ArtcraftStripeConfig { secret_webhook_signing_key: self.secret_webhook_signing_key.clone(), checkout_success_url: self.checkout_success_url.clone(), checkout_cancel_url: self.checkout_cancel_url.clone(), + portal_return_url: self.portal_return_url.clone(), client: Client::new(self.secret_key.clone()), } } diff --git a/crates/service/web/storyteller_web/config/storyteller-web.development.env b/crates/service/web/storyteller_web/config/storyteller-web.development.env index 78c3089646..392fab1ea0 100644 --- a/crates/service/web/storyteller_web/config/storyteller-web.development.env +++ b/crates/service/web/storyteller_web/config/storyteller-web.development.env @@ -47,4 +47,5 @@ FAL_WEBHOOK_URL="https://macaroni-1.tailce84f.ts.net/v1/webhooks/fal" STRIPE_ARTCRAFT_CHECKOUT_SUCCESS_URL="https://getartcraft.com/checkout_success" STRIPE_ARTCRAFT_CHECKOUT_CANCEL_URL="https://getartcraft.com/checkout_cancel" +STRIPE_ARTCRAFT_PORTAL_RETURN_URL="https://getartcraft.com/portal_closed" 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 eb0801c065..85f8a81835 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 @@ -4,6 +4,7 @@ use actix_web::dev::{ServiceRequest, ServiceResponse}; use actix_web::{web, App, Error, HttpResponse}; use billing_artcraft_component::endpoints::checkout::stripe_artcraft_create_credits_pack_checkout_handler::stripe_artcraft_create_credits_pack_checkout_handler; use billing_artcraft_component::endpoints::checkout::stripe_artcraft_create_subscription_checkout_handler::stripe_artcraft_create_subscription_session_handler; +use billing_artcraft_component::endpoints::customer_portal::stripe_artcraft_create_customer_portal_session_handler::stripe_artcraft_create_customer_portal_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 @@ -30,5 +31,9 @@ where .route(web::post().to(stripe_artcraft_create_subscription_session_handler)) .route(web::head().to(|| HttpResponse::Ok())) ) + .service(web::resource("/portal/create_session") + .route(web::post().to(stripe_artcraft_create_customer_portal_session_handler)) + .route(web::head().to(|| HttpResponse::Ok())) + ) ) } diff --git a/crates/service/web/storyteller_web/src/main.rs b/crates/service/web/storyteller_web/src/main.rs index 21734616a8..dea71b8c90 100644 --- a/crates/service/web/storyteller_web/src/main.rs +++ b/crates/service/web/storyteller_web/src/main.rs @@ -419,6 +419,7 @@ async fn main() -> AnyhowResult<()> { secret_webhook_signing_key: easyenv::get_env_string_required("STRIPE_ARTCRAFT_SECRET_WEBHOOK_KEY")?, checkout_success_url: easyenv::get_env_string_required("STRIPE_ARTCRAFT_CHECKOUT_SUCCESS_URL")?, checkout_cancel_url: easyenv::get_env_string_required("STRIPE_ARTCRAFT_CHECKOUT_CANCEL_URL")?, + portal_return_url: easyenv::get_env_string_required("STRIPE_ARTCRAFT_PORTAL_RETURN_URL")?, }.to_config_with_client() }, hostname: server_hostname,