//! Checkout session creation. //! //! Direct Charges pattern: payment goes directly to the connected account. //! No `application_fee_amount` is set: the 0% platform fee promise. //! //! # Currency //! //! A session is always created in the **seller's** settlement currency, never //! the buyer's. On a direct charge Stripe converts the presentment currency to //! the connected account's default currency, so denominating in the seller's //! currency is what makes the creator receive the amount they set. //! //! The buyer's conversion choice does not change that. It sets //! `adaptive_pricing.enabled`: with it on, Stripe presents and converts in the //! buyer's local currency; with it off, the buyer's card issuer converts. Both //! sessions are denominated identically. **Always set the flag explicitly** — //! left unset, Stripe falls back to the Connect dashboard setting, and the //! buyer's choice silently stops meaning anything. //! //! MNW's own billing (Fan+, creator tiers, SyncKit) is USD regardless of //! creator, and those builders take a pre-made Stripe Price, so no currency //! literal appears in them. use std::collections::HashMap; use stripe::StripeRequest; use stripe_checkout::checkout_session::{ CreateCheckoutSession, CreateCheckoutSessionAdaptivePricing, CreateCheckoutSessionAutomaticTax, CreateCheckoutSessionLineItems, CreateCheckoutSessionLineItemsPriceData, CreateCheckoutSessionLineItemsPriceDataRecurring, CreateCheckoutSessionLineItemsPriceDataRecurringInterval, CreateCheckoutSessionPaymentMethodCollection, CreateCheckoutSessionSubscriptionData, ProductData, }; use stripe_shared::CheckoutSessionMode; use stripe_types::Currency; use super::StripeClient; use crate::currency::{ConversionChoice, SettlementCurrency}; use crate::db::{ Cents, CheckoutType, ItemId, ProjectId, PromoCodeId, SubscriptionTierId, SyncAppId, UserId, }; use crate::error::{AppError, Result}; /// 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, /// The seller's settlement currency. The session is denominated in it. pub currency: SettlementCurrency, /// How the buyer chose to handle conversion, if their currency differs. pub conversion: ConversionChoice, } /// 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, /// The seller's settlement currency. The session is denominated in it. pub currency: SettlementCurrency, /// How the buyer chose to handle conversion, if their currency differs. pub conversion: ConversionChoice, } /// 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, /// The creator's settlement currency. The Stripe Price named by /// `stripe_price_id` was minted in it; this is carried for the conversion /// flag and for the minimum-charge check, not to re-denominate the price. pub currency: SettlementCurrency, /// How the buyer chose to handle conversion, if their currency differs. pub conversion: ConversionChoice, } /// 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, /// The recipient's settlement currency. The session is denominated in it. pub currency: SettlementCurrency, /// How the tipper chose to handle conversion, if their currency differs. pub conversion: ConversionChoice, } /// 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, /// The seller's settlement currency. The session is denominated in it. pub currency: SettlementCurrency, /// How the buyer chose to handle conversion, if their currency differs. pub conversion: ConversionChoice, } /// 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 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, currency: SettlementCurrency) -> Result<()> { let minimum = currency.minimum_charge_cents(); if amount_cents > 0 && amount_cents < minimum { return Err(AppError::BadRequest(format!( "Minimum purchase amount is {}", crate::formatting::format_revenue(minimum, currency) ))); } Ok(()) } fn build_inline_line_item( title: &str, amount_cents: i64, currency: SettlementCurrency, ) -> CreateCheckoutSessionLineItems { CreateCheckoutSessionLineItems { price_data: Some(CreateCheckoutSessionLineItemsPriceData { product_data: Some(ProductData::new(title.to_string())), unit_amount: Some(amount_cents), // `new` takes the currency; restating it here only cost a clone. ..CreateCheckoutSessionLineItemsPriceData::new(currency.to_stripe()) }), 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 in USD. /// /// SyncKit developer billing only: Make Creative bills developers in USD /// regardless of where they are, same as the creator tiers. Nothing a creator /// sells goes through here, so there is no settlement currency to read. fn build_inline_recurring_line_item_usd( product_name: &str, amount_cents: i64, interval: CreateCheckoutSessionLineItemsPriceDataRecurringInterval, ) -> CreateCheckoutSessionLineItems { CreateCheckoutSessionLineItems { price_data: Some(CreateCheckoutSessionLineItemsPriceData { 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() } } /// The Adaptive Pricing flag for a buyer's conversion choice. /// /// Always sent, never omitted. Omitting it hands the decision to the Connect /// dashboard setting, which would override the buyer's choice without any /// evidence in the code that a choice was ever made. fn adaptive_pricing(conversion: ConversionChoice) -> CreateCheckoutSessionAdaptivePricing { CreateCheckoutSessionAdaptivePricing { enabled: Some(conversion.adaptive_pricing_enabled()), } } 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(), checkout.currency)?; 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(), checkout.currency, )]) .adaptive_pricing(adaptive_pricing(checkout.conversion)) .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(), checkout.currency)?; 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(), checkout.currency, )]) .adaptive_pricing(adaptive_pricing(checkout.conversion)) .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, cart.currency)?; let line_items: Vec = cart .line_items .iter() .map(|li| build_inline_line_item(li.title, li.amount_cents, cart.currency)) .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) .adaptive_pricing(adaptive_pricing(cart.conversion)) .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)]) .adaptive_pricing(adaptive_pricing(sub.conversion)) .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(), tip.currency, )]) .adaptive_pricing(adaptive_pricing(tip.conversion)) .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, trial_days: Option, ) -> 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 mut 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); // A comp code grants a free trial: don't collect a card up front // (`if_required` skips card collection when no charge is due yet), and // delay the first charge by `trial_days`. With no payment method on // file, the subscription lapses at trial end unless the creator // adds one, continuing is an explicit opt-in, never a silent charge. // The price stays the one chosen by the caller (founder price during // the founder window), so opting in renews at that rate. if let Some(days) = trial_days { let days: u32 = days .try_into() .map_err(|_| AppError::BadRequest("Invalid trial period".to_string()))?; builder = builder .payment_method_collection(CreateCheckoutSessionPaymentMethodCollection::IfRequired) .subscription_data(CreateCheckoutSessionSubscriptionData { trial_period_days: Some(days), ..CreateCheckoutSessionSubscriptionData::new() }); } 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("interval".to_string(), p.interval.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_usd(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 } } #[cfg(test)] mod tests { //! The shape of what we ask Stripe to charge. Everything here is pure and //! sits directly on the money path: a wrong `unit_amount`, a missing //! `quantity`, or a minimum-charge boundary off by one cent is a real //! charge that is wrong, and none of it was covered. use super::*; // ── the Stripe per-transaction minimum ── const USD: SettlementCurrency = SettlementCurrency::Usd; #[test] fn the_minimum_is_per_currency_and_gbp_is_lower() { // GBP's floor is 30, not 50. Applying the USD floor to a British // creator would refuse charges Stripe would have accepted. assert!(check_min_charge(30, SettlementCurrency::Gbp).is_ok()); assert!(matches!( check_min_charge(30, SettlementCurrency::Usd), Err(AppError::BadRequest(_)) )); } #[test] fn the_rejection_names_the_currency_it_refused_in() { let Err(AppError::BadRequest(msg)) = check_min_charge(1, SettlementCurrency::Gbp) else { panic!("1 penny should be rejected"); }; assert!( msg.contains("\u{a3}0.30"), "a GBP rejection must not quote a dollar minimum: {msg}" ); } #[test] fn a_free_item_is_allowed_through() { // $0 items are legitimate; callers gate them before they reach Stripe. assert!(check_min_charge(0, USD).is_ok()); } #[test] fn the_minimum_itself_is_allowed_and_one_cent_under_is_not() { let min = USD.minimum_charge_cents(); assert!( check_min_charge(min, USD).is_ok(), "the boundary is inclusive" ); assert!( matches!(check_min_charge(min - 1, USD), Err(AppError::BadRequest(_))), "one cent under the minimum must be refused before Stripe refuses it" ); assert!(check_min_charge(min + 1, USD).is_ok()); } #[test] fn the_rejection_names_the_minimum_in_dollars() { // The message reaches a buyer, so it must not say "50". let Err(AppError::BadRequest(msg)) = check_min_charge(1, USD) else { panic!("1 cent should be rejected"); }; assert!( msg.contains("$0.50"), "buyer-facing message should format the minimum as currency: {msg}" ); } #[test] fn a_negative_amount_is_not_rejected_here() { // Documenting the current contract rather than endorsing it: the guard // is `> 0 && < minimum`, so negatives pass. Every caller computes its // amount from a price and a discount, and none is proven non-negative // here. If a discount is ever allowed to exceed a price, this is the // gate that will not catch it. assert!(check_min_charge(-1, USD).is_ok()); } // ── line items ── #[test] fn an_inline_line_item_charges_the_given_amount_once_in_usd() { let item = build_inline_line_item("A Record", 2500, USD); let price = item.price_data.expect("inline items carry price_data"); assert_eq!(price.unit_amount, Some(2500)); assert_eq!(price.currency, Currency::USD); assert_eq!( item.quantity, Some(1), "quantity must be pinned: None would let Stripe default and charge differently" ); assert!( item.price.is_none(), "an inline item must not also reference a Stripe Price" ); assert_eq!( price.product_data.map(|p| p.name).as_deref(), Some("A Record"), "without product_data the buyer sees an unnamed line on the Stripe page" ); } #[test] fn a_price_line_item_references_stripe_and_sets_no_amount_of_its_own() { let item = build_price_line_item("price_123"); assert_eq!(item.price.as_deref(), Some("price_123")); assert_eq!(item.quantity, Some(1)); assert!( item.price_data.is_none(), "a Price-backed item that also carries price_data would charge the inline amount" ); } #[test] fn a_recurring_item_carries_its_interval_and_a_recurring_price() { let item = build_inline_recurring_line_item_usd( "SyncKit Pro", 900, CreateCheckoutSessionLineItemsPriceDataRecurringInterval::Month, ); let price = item.price_data.expect("recurring items carry price_data"); assert_eq!(price.unit_amount, Some(900)); assert_eq!(price.currency, Currency::USD); assert!( price.recurring.is_some(), "without `recurring` Stripe bills this once instead of every period" ); assert_eq!(item.quantity, Some(1)); assert_eq!( price.product_data.map(|p| p.name).as_deref(), Some("SyncKit Pro") ); } // ── adaptive pricing ── #[test] fn adaptive_pricing_states_the_buyers_choice_rather_than_omitting_it() { // `None` is not a neutral value: it hands the decision to the Connect // dashboard setting, which is the failure the always-send rule exists // to prevent. assert_eq!( adaptive_pricing(ConversionChoice::AtCheckout).enabled, Some(true) ); assert_eq!( adaptive_pricing(ConversionChoice::ByBuyersBank).enabled, Some(false) ); } // ── automatic tax ── #[test] fn automatic_tax_is_absent_rather_than_disabled_when_off() { assert!( automatic_tax(false).is_none(), "sending an explicit disabled block is not the same as omitting it" ); assert!(automatic_tax(true).is_some()); } }