mirror of
https://github.com/storytold/artcraft.git
synced 2026-11-04 09:28:11 +00:00
create billing portal session
This commit is contained in:
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),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+122
@@ -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;
|
||||
|
||||
+18
@@ -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;
|
||||
+108
@@ -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"
|
||||
|
||||
+5
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user