//! Checkout session creation. //! //! Direct Charges pattern: payment goes directly to the connected account. //! No `application_fee_amount` is set: the 0% platform fee promise. use std::collections::HashMap; use stripe::StripeRequest; use stripe_checkout::checkout_session::{ CreateCheckoutSession, CreateCheckoutSessionAutomaticTax, CreateCheckoutSessionLineItems, CreateCheckoutSessionLineItemsPriceData, CreateCheckoutSessionLineItemsPriceDataRecurring, CreateCheckoutSessionLineItemsPriceDataRecurringInterval, CreateCheckoutSessionSubscriptionData, ProductData, }; use stripe_shared::CheckoutSessionMode; use stripe_types::Currency; use crate::constants; use crate::db::{Cents, CheckoutType, ItemId, ProjectId, PromoCodeId, SubscriptionTierId, SyncAppId, UserId}; use crate::error::{AppError, Result}; use super::StripeClient; /// Parameters for creating a one-time purchase Checkout Session. pub struct CheckoutParams<'a> { pub connected_account_id: &'a str, pub item_title: &'a str, pub amount_cents: Cents, pub buyer_id: UserId, pub seller_id: UserId, /// `None` for project-level purchases (no specific item). pub item_id: Option, pub success_url: &'a str, pub cancel_url: &'a str, pub promo_code_id: Option, pub enable_stripe_tax: bool, } /// A single line item in a cart checkout. pub struct CartLineItem<'a> { pub title: &'a str, pub amount_cents: i64, } /// Parameters for creating a multi-item cart Checkout Session. pub struct CartCheckoutParams<'a> { pub connected_account_id: &'a str, pub line_items: &'a [CartLineItem<'a>], pub buyer_id: UserId, pub seller_id: UserId, pub success_url: &'a str, pub cancel_url: &'a str, pub enable_stripe_tax: bool, } /// Parameters for creating a subscription Checkout Session. pub struct SubscriptionCheckoutParams<'a> { pub connected_account_id: &'a str, pub stripe_price_id: &'a str, pub subscriber_id: UserId, pub project_id: ProjectId, pub tier_id: SubscriptionTierId, pub success_url: &'a str, pub cancel_url: &'a str, pub trial_days: Option, pub promo_code_id: Option, pub enable_stripe_tax: bool, } /// Parameters for creating a tip Checkout Session. pub struct TipCheckoutParams<'a> { pub connected_account_id: &'a str, pub recipient_display_name: &'a str, pub amount_cents: Cents, pub tipper_id: UserId, pub recipient_id: UserId, pub project_id: Option, pub message: Option<&'a str>, pub success_url: &'a str, pub cancel_url: &'a str, pub enable_stripe_tax: bool, } /// Parameters for creating a guest (no-account) purchase Checkout Session. pub struct GuestCheckoutParams<'a> { pub connected_account_id: &'a str, pub item_title: &'a str, pub amount_cents: Cents, pub seller_id: UserId, pub item_id: ItemId, pub success_url: &'a str, pub cancel_url: &'a str, pub promo_code_id: Option, pub enable_stripe_tax: bool, } /// Parameters for a SyncKit developer app-subscription Checkout Session. The /// price (`amount_cents` / `interval`) rides inline via `price_data`, so no /// Stripe Product/Price needs pre-configuring. `interval` is `"monthly"` or /// `"annual"`. pub struct SynckitAppSubCheckoutParams<'a> { pub product_name: &'a str, pub amount_cents: i64, pub interval: &'a str, pub user_id: UserId, pub app_id: SyncAppId, pub tier: &'a str, pub storage_limit_bytes: Option, pub success_url: &'a str, pub cancel_url: &'a str, } /// Reject a charge below Stripe's per-transaction minimum (Stripe hard-rejects /// sub-minimum amounts with an unfriendly error). Free ($0) items are allowed; /// callers gate those separately. Shared by the Stripe session builders here and /// by the checkout routes, which call it before reserving a promo so a rejected /// sub-minimum checkout doesn't burn a use of the code. pub(crate) fn check_min_charge(amount_cents: i64) -> Result<()> { if amount_cents > 0 && amount_cents < constants::STRIPE_MINIMUM_CHARGE_CENTS { return Err(AppError::BadRequest(format!( "Minimum purchase amount is ${:.2}", constants::STRIPE_MINIMUM_CHARGE_CENTS as f64 / 100.0 ))); } Ok(()) } fn build_inline_line_item(title: &str, amount_cents: i64) -> CreateCheckoutSessionLineItems { CreateCheckoutSessionLineItems { price_data: Some(CreateCheckoutSessionLineItemsPriceData { currency: Currency::USD, product_data: Some(ProductData::new(title.to_string())), unit_amount: Some(amount_cents), ..CreateCheckoutSessionLineItemsPriceData::new(Currency::USD) }), quantity: Some(1), ..CreateCheckoutSessionLineItems::new() } } fn build_price_line_item(price_id: &str) -> CreateCheckoutSessionLineItems { CreateCheckoutSessionLineItems { price: Some(price_id.to_string()), quantity: Some(1), ..CreateCheckoutSessionLineItems::new() } } /// Build an inline recurring line item — used by SyncKit app subscriptions /// so we don't have to pre-provision Stripe Products and Prices for every /// (app, tier, interval) combination. fn build_inline_recurring_line_item( product_name: &str, amount_cents: i64, interval: CreateCheckoutSessionLineItemsPriceDataRecurringInterval, ) -> CreateCheckoutSessionLineItems { CreateCheckoutSessionLineItems { price_data: Some(CreateCheckoutSessionLineItemsPriceData { currency: Currency::USD, product_data: Some(ProductData::new(product_name.to_string())), unit_amount: Some(amount_cents), recurring: Some(CreateCheckoutSessionLineItemsPriceDataRecurring::new(interval)), ..CreateCheckoutSessionLineItemsPriceData::new(Currency::USD) }), quantity: Some(1), ..CreateCheckoutSessionLineItems::new() } } fn automatic_tax(enable: bool) -> Option { if enable { Some(CreateCheckoutSessionAutomaticTax::new(true)) } else { None } } impl StripeClient { async fn send_on_connected_account( &self, builder: CreateCheckoutSession, connected_account_id: &str, log_label: &str, ) -> Result { let account_id = Self::parse_account_id(connected_account_id)?; builder .customize() .account_id(account_id) .send(&self.client) .await .map_err(|e| { tracing::error!(error = ?e, label = %log_label, "failed to create checkout session"); AppError::BadRequest("Failed to create checkout session".to_string()) }) } async fn send_on_platform( &self, builder: CreateCheckoutSession, log_label: &str, ) -> Result { builder .send(&self.client) .await .map_err(|e| { tracing::error!(error = ?e, label = %log_label, "failed to create checkout session"); AppError::BadRequest("Failed to create checkout session".to_string()) }) } /// Build a one-time payment checkout session for a guest purchase. #[tracing::instrument(skip_all, name = "payments::create_guest_checkout_session")] pub async fn create_guest_checkout_session( &self, checkout: &GuestCheckoutParams<'_>, ) -> Result { check_min_charge(checkout.amount_cents.as_i64())?; let mut metadata = HashMap::new(); metadata.insert("checkout_type".to_string(), CheckoutType::Guest.to_string()); metadata.insert("seller_id".to_string(), checkout.seller_id.to_string()); metadata.insert("item_id".to_string(), checkout.item_id.to_string()); if let Some(pc_id) = checkout.promo_code_id { metadata.insert("promo_code_id".to_string(), pc_id.to_string()); } let mut builder = CreateCheckoutSession::new() .mode(CheckoutSessionMode::Payment) .success_url(checkout.success_url.to_string()) .cancel_url(checkout.cancel_url.to_string()) .line_items(vec![build_inline_line_item(checkout.item_title, checkout.amount_cents.as_i64())]) .metadata(metadata); if let Some(tax) = automatic_tax(checkout.enable_stripe_tax) { builder = builder.automatic_tax(tax); } self.send_on_connected_account(builder, checkout.connected_account_id, "guest_checkout").await } /// Build a one-time payment checkout session for a purchase by a logged-in user. #[tracing::instrument(skip_all, name = "payments::create_checkout_session")] pub async fn create_checkout_session( &self, checkout: &CheckoutParams<'_>, ) -> Result { check_min_charge(checkout.amount_cents.as_i64())?; let mut metadata = HashMap::new(); metadata.insert("buyer_id".to_string(), checkout.buyer_id.to_string()); metadata.insert("seller_id".to_string(), checkout.seller_id.to_string()); if let Some(item_id) = checkout.item_id { metadata.insert("item_id".to_string(), item_id.to_string()); } if let Some(pc_id) = checkout.promo_code_id { metadata.insert("promo_code_id".to_string(), pc_id.to_string()); } let mut builder = CreateCheckoutSession::new() .mode(CheckoutSessionMode::Payment) .success_url(checkout.success_url.to_string()) .cancel_url(checkout.cancel_url.to_string()) .line_items(vec![build_inline_line_item(checkout.item_title, checkout.amount_cents.as_i64())]) .metadata(metadata); if let Some(tax) = automatic_tax(checkout.enable_stripe_tax) { builder = builder.automatic_tax(tax); } self.send_on_connected_account(builder, checkout.connected_account_id, "checkout").await } /// Build a multi-line-item Checkout Session for a cart purchase. #[tracing::instrument(skip_all, name = "payments::create_cart_checkout_session")] pub async fn create_cart_checkout_session( &self, cart: &CartCheckoutParams<'_>, ) -> Result { let total_cents: i64 = cart.line_items.iter().map(|li| li.amount_cents).sum(); check_min_charge(total_cents)?; let line_items: Vec = cart .line_items .iter() .map(|li| build_inline_line_item(li.title, li.amount_cents)) .collect(); let mut metadata = HashMap::new(); metadata.insert("checkout_type".to_string(), CheckoutType::Cart.to_string()); metadata.insert("buyer_id".to_string(), cart.buyer_id.to_string()); metadata.insert("seller_id".to_string(), cart.seller_id.to_string()); let mut builder = CreateCheckoutSession::new() .mode(CheckoutSessionMode::Payment) .success_url(cart.success_url.to_string()) .cancel_url(cart.cancel_url.to_string()) .line_items(line_items) .metadata(metadata); if let Some(tax) = automatic_tax(cart.enable_stripe_tax) { builder = builder.automatic_tax(tax); } self.send_on_connected_account(builder, cart.connected_account_id, "cart_checkout").await } /// Build a subscription Checkout Session on a connected account. #[tracing::instrument(skip_all, name = "payments::create_subscription_checkout_session")] pub async fn create_subscription_checkout_session( &self, sub: &SubscriptionCheckoutParams<'_>, ) -> Result { let mut metadata = HashMap::new(); metadata.insert("subscriber_id".to_string(), sub.subscriber_id.to_string()); metadata.insert("project_id".to_string(), sub.project_id.to_string()); metadata.insert("tier_id".to_string(), sub.tier_id.to_string()); metadata.insert("checkout_type".to_string(), CheckoutType::Subscription.to_string()); if let Some(pc_id) = sub.promo_code_id { metadata.insert("promo_code_id".to_string(), pc_id.to_string()); } let mut builder = CreateCheckoutSession::new() .mode(CheckoutSessionMode::Subscription) .success_url(sub.success_url.to_string()) .cancel_url(sub.cancel_url.to_string()) .line_items(vec![build_price_line_item(sub.stripe_price_id)]) .metadata(metadata); if let Some(tax) = automatic_tax(sub.enable_stripe_tax) { builder = builder.automatic_tax(tax); } if let Some(days) = sub.trial_days { let trial_days: u32 = days.try_into().map_err(|_| { AppError::BadRequest("Invalid trial period".to_string()) })?; builder = builder.subscription_data(CreateCheckoutSessionSubscriptionData { trial_period_days: Some(trial_days), ..CreateCheckoutSessionSubscriptionData::new() }); } self.send_on_connected_account(builder, sub.connected_account_id, "subscription_checkout").await } /// Build a Checkout Session for a tip to a creator. #[tracing::instrument(skip_all, name = "payments::create_tip_checkout_session")] pub async fn create_tip_checkout_session( &self, tip: &TipCheckoutParams<'_>, ) -> Result { let product_name = format!("Tip for {}", tip.recipient_display_name); let mut metadata = HashMap::new(); metadata.insert("checkout_type".to_string(), CheckoutType::Tip.to_string()); metadata.insert("tipper_id".to_string(), tip.tipper_id.to_string()); metadata.insert("recipient_id".to_string(), tip.recipient_id.to_string()); if let Some(project_id) = tip.project_id { metadata.insert("project_id".to_string(), project_id.to_string()); } if let Some(msg) = tip.message { metadata.insert("message".to_string(), msg.chars().take(500).collect()); } let mut builder = CreateCheckoutSession::new() .mode(CheckoutSessionMode::Payment) .success_url(tip.success_url.to_string()) .cancel_url(tip.cancel_url.to_string()) .line_items(vec![build_inline_line_item(&product_name, tip.amount_cents.as_i64())]) .metadata(metadata); if let Some(tax) = automatic_tax(tip.enable_stripe_tax) { builder = builder.automatic_tax(tax); } self.send_on_connected_account(builder, tip.connected_account_id, "tip_checkout").await } /// Build a Checkout Session for a Fan+ subscription on MNW's own Stripe account. #[tracing::instrument(skip_all, name = "payments::create_fan_plus_checkout_session")] pub async fn create_fan_plus_checkout_session( &self, price_id: &str, user_id: UserId, success_url: &str, cancel_url: &str, ) -> Result { let mut metadata = HashMap::new(); metadata.insert("checkout_type".to_string(), CheckoutType::FanPlus.to_string()); metadata.insert("user_id".to_string(), user_id.to_string()); let builder = CreateCheckoutSession::new() .mode(CheckoutSessionMode::Subscription) .success_url(success_url.to_string()) .cancel_url(cancel_url.to_string()) .line_items(vec![build_price_line_item(price_id)]) .metadata(metadata); self.send_on_platform(builder, "fan_plus_checkout").await } /// Build a Checkout Session for a creator tier subscription on MNW's own Stripe account. #[tracing::instrument(skip_all, name = "payments::create_creator_tier_checkout_session")] pub async fn create_creator_tier_checkout_session( &self, price_id: &str, user_id: UserId, tier: &str, success_url: &str, cancel_url: &str, ) -> Result { let mut metadata = HashMap::new(); metadata.insert("checkout_type".to_string(), CheckoutType::CreatorTier.to_string()); metadata.insert("user_id".to_string(), user_id.to_string()); metadata.insert("tier".to_string(), tier.to_string()); let builder = CreateCheckoutSession::new() .mode(CheckoutSessionMode::Subscription) .success_url(success_url.to_string()) .cancel_url(cancel_url.to_string()) .line_items(vec![build_price_line_item(price_id)]) .metadata(metadata); self.send_on_platform(builder, "creator_tier_checkout").await } /// Build a Checkout Session for an end-user subscribing to an app's cloud /// sync (SyncKit). Runs on MNW's own Stripe account. Uses inline /// `price_data` so no Stripe Products/Prices need to be pre-configured — /// the tier name and cents come from the `sync_app_tiers` row. #[tracing::instrument(skip_all, name = "payments::create_synckit_app_sub_checkout_session")] pub async fn create_synckit_app_sub_checkout_session( &self, p: &SynckitAppSubCheckoutParams<'_>, ) -> Result { use CreateCheckoutSessionLineItemsPriceDataRecurringInterval as Recurring; let interval = match p.interval { "monthly" => Recurring::Month, "annual" => Recurring::Year, other => return Err(AppError::BadRequest(format!("Invalid interval '{other}'"))), }; let mut metadata = HashMap::new(); metadata.insert("checkout_type".to_string(), CheckoutType::SynckitAppSub.to_string()); metadata.insert("user_id".to_string(), p.user_id.to_string()); metadata.insert("app_id".to_string(), p.app_id.to_string()); metadata.insert("tier".to_string(), p.tier.to_string()); if let Some(bytes) = p.storage_limit_bytes { metadata.insert("storage_limit_bytes".to_string(), bytes.to_string()); } let line_item = build_inline_recurring_line_item(p.product_name, p.amount_cents, interval); let builder = CreateCheckoutSession::new() .mode(CheckoutSessionMode::Subscription) .success_url(p.success_url.to_string()) .cancel_url(p.cancel_url.to_string()) .line_items(vec![line_item]) .metadata(metadata); self.send_on_platform(builder, "synckit_app_sub_checkout").await } }