create billing portal session

This commit is contained in:
Brandon Thomas committed 2025-09-12 01:00:05 -04:00
1 parent 07722766dd
commit 90032848ee
13 files changed
+271 -3

No files matched your search

@@ -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<T> 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),
}
}
}
@@ -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<Utc>,
}
pub async fn find_artcraft_subscription_for_owner_user_using_connection(
user_token: &UserToken,
connection: &mut PoolConnection<MySql>,
) -> Result<Option<ArtcraftSubscription>, 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<Option<ArtcraftSubscription>, SelectOptionalRecordError> {
let query = query(user_token);
let result = query
.fetch_optional(&mut **transaction)
.await;
map_result(result)
}
fn map_result(result: Result<Option<RawArtcraftSubscription>, sqlx::Error>) -> Result<Option<ArtcraftSubscription>, 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<RawArtcraftSubscription, sqlx::Error>, 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<String>,
maybe_stripe_subscription_id: Option<String>,
maybe_stripe_subscription_status: Option<String>,
subscription_expires_at: DateTime<Utc>,
}
@@ -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;
@@ -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<String>,
}
#[derive(Serialize, Deserialize, ToSchema)]
pub struct StripeArtcraftCreateCustomerPortalSessionResponse {
pub success: bool,
pub stripe_portal_url: String,
}
@@ -1,2 +1,3 @@
pub mod create_credits_pack_checkout;
pub mod create_customer_portal_session;
pub mod create_subscription_checkout;
@@ -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"] }
@@ -0,0 +1 @@
pub mod stripe_artcraft_create_customer_portal_session_handler;
@@ -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<StripeArtcraftCreateCustomerPortalSessionRequest>,
stripe_config: Data<ArtcraftStripeConfigWithClient>,
server_environment: Data<ServerEnvironment>,
internal_user_lookup: Data<dyn InternalUserLookup>,
mysql_pool: Data<MySqlPool>,
) -> Result<Json<StripeArtcraftCreateCustomerPortalSessionResponse>, 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,
}))
}
@@ -1,2 +1,3 @@
pub mod webhook;
pub mod checkout;
pub mod checkout;
pub mod customer_portal;
pub mod webhook;
@@ -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()),
}
}
@@ -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"
@@ -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<T, B> (app: App<T>) -> App<T>
@@ -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()))
)
)
}
@@ -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,