//! Cart checkout: multi-item purchase from one seller in a single Stripe session. use axum::{ extract::State, response::{IntoResponse, Redirect, Response}, Form, }; use serde::Deserialize; use crate::{ auth::AuthUser, db::{self, Cents, PromoCodeId, UserId}, error::{AppError, Result, ResultExt}, helpers, AppState, }; use super::grant_bundle_items; /// Release a promo reservation on a checkout-abort path, logging on failure. /// /// These releases run only after another error has already aborted the /// checkout, so the caller can't surface a failure to the user — but a silent /// drop leaves the promo's use-count incremented (a stuck reservation). Log it /// so an orphaned reservation is traceable rather than invisible. async fn release_promo_quietly(state: &AppState, pc_id: PromoCodeId, user_id: UserId) { if let Err(e) = db::promo_codes::release_use_count_and_detach(&state.db, pc_id, user_id).await { tracing::warn!( promo_code_id = %pc_id, %user_id, error = %e, "failed to release promo reservation on checkout abort; use-count may be stuck" ); } } /// Form data for cart checkout. #[derive(Debug, Deserialize)] pub(in crate::routes::stripe) struct CartCheckoutForm { pub seller_id: String, #[serde(default)] pub share_contact: bool, pub promo_code: Option, } /// POST /stripe/checkout/cart - Checkout all cart items from one seller. /// /// Thin wrapper over [`checkout_seller_cart`]: enforces the buyer-side /// preconditions (suspended/sandbox/self-purchase) the chained path doesn't /// need, then maps the core's `Option` onto a redirect. #[tracing::instrument(skip_all, name = "stripe::cart_checkout", fields(user_id = %user.id))] pub(in crate::routes::stripe) async fn create_cart_checkout( State(state): State, AuthUser(user): AuthUser, Form(form): Form, ) -> Result { user.check_not_suspended()?; user.check_not_sandbox()?; let seller_id: UserId = form.seller_id.parse() .map_err(|_| AppError::BadRequest("Invalid seller ID".to_string()))?; if user.id == seller_id { return Err(AppError::BadRequest("You cannot purchase your own items".to_string())); } match checkout_seller_cart(&state, &user, seller_id, form.share_contact, form.promo_code.as_deref()).await? { Some(url) => Ok(Redirect::to(&url).into_response()), None => Ok(Redirect::to("/library?purchase=success").into_response()), } } /// Form data for checkout-all (cross-seller). #[derive(Debug, Deserialize)] pub(in crate::routes::stripe) struct CartCheckoutAllForm { #[serde(default)] pub share_contact: bool, } /// POST /stripe/checkout/cart/all - Checkout all cart items across all sellers. /// /// Queues seller IDs in the session, processes the first seller, then chains /// through the rest via checkout_success redirects. #[tracing::instrument(skip_all, name = "stripe::cart_checkout_all", fields(user_id = %user.id))] pub(in crate::routes::stripe) async fn create_cart_checkout_all( State(state): State, AuthUser(user): AuthUser, session: tower_sessions::Session, Form(form): Form, ) -> Result { user.check_not_suspended()?; user.check_not_sandbox()?; let cart_items = db::cart::get_cart_items(&state.db, user.id).await .context("fetch all cart items")?; if cart_items.is_empty() { return Ok(Redirect::to("/cart").into_response()); } // Group by seller, collect unique seller IDs in order let mut seen = std::collections::HashSet::new(); let mut seller_ids: Vec = Vec::new(); for item in &cart_items { let sid = item.seller_id.to_string(); if seen.insert(sid.clone()) { seller_ids.push(sid); } } if seller_ids.is_empty() { return Ok(Redirect::to("/cart").into_response()); } // Queue remaining sellers (all except the first) in session let first_seller = seller_ids.remove(0); if !seller_ids.is_empty() { session.insert("cart_queue", seller_ids).await .map_err(|e| AppError::BadRequest(format!("session error: {e}")))?; session.insert("cart_share_contact", form.share_contact).await .map_err(|e| AppError::BadRequest(format!("session error: {e}")))?; } // Process the first seller and chain through the queue until we hit a // paid seller (return its Stripe URL) or exhaust everything as free. match drain_to_paid(&state, &user, first_seller, form.share_contact, &session).await? { Some(url) => Ok(Redirect::to(&url).into_response()), None => Ok(Redirect::to("/library?purchase=success").into_response()), } } /// Claim a set of free (or discount-zeroed) cart items: insert the free /// transaction, bump the sales count, and grant bundle items + a license key /// when applicable, then bulk-remove the claimed rows from the cart. /// /// Bundle/license fields come from `CartItem`, so this does no per-item /// `get_item_by_id`, and the cart rows are removed in one bulk DELETE after the /// loop (Run #8 perf MED). Shared by the free-by-price and discount-zeroed /// passes so the claim logic exists in exactly one place. async fn claim_free_cart_items( state: &AppState, user_id: UserId, seller_id: UserId, items: &[&db::cart::CartItem], share_contact: bool, ) -> Result<()> { if items.is_empty() { return Ok(()); } let mut to_remove: Vec = Vec::with_capacity(items.len()); for item in items { let claim = db::transactions::ClaimParams { buyer_id: user_id, item_id: item.item_id, seller_id, item_title: &item.title, seller_username: &item.creator_username, share_contact, parent_transaction_id: None, }; let mut tx = state.db.begin().await.context("begin free-claim transaction")?; let claimed = db::transactions::claim_free_item(&mut *tx, &claim) .await .context("claim free item")?; if claimed { db::items::increment_sales_count(&mut *tx, item.item_id) .await .context("increment sales count")?; } tx.commit().await.context("commit free-claim transaction")?; if claimed { if item.item_type == "bundle" { grant_bundle_items(state, item.item_id, user_id, seller_id, None).await; } if item.enable_license_keys { let key_code = helpers::generate_key_code(); db::license_keys::create_license_key( &state.db, item.item_id, user_id, None, &key_code, item.default_max_activations, ).await.ok(); } } to_remove.push(item.item_id); } db::cart::remove_from_cart_bulk(&state.db, user_id, &to_remove).await.ok(); Ok(()) } /// Create the pending transactions for every paid item in one DB transaction, /// so the buyer gets all items or none (no partial delivery on a mid-loop /// failure). /// /// A 23505 means another tab raced past the pre-check; abort the whole cart /// rather than leave a paid Stripe line item with no pending row to fulfill. On /// any error the promo reservation (if any) is released, since the Stripe /// session was already created but no fulfilling rows landed. async fn create_cart_pending_transactions( state: &AppState, user_id: UserId, seller_id: UserId, session_id: &str, items: &[(&db::cart::CartItem, i32)], share_contact: bool, promo_code_id: Option, ) -> Result<()> { let mut db_tx = state.db.begin().await.context("begin cart transaction creation")?; for (item, final_price) in items { match db::transactions::create_transaction( &mut *db_tx, &db::transactions::CreateTransactionParams { buyer_id: Some(user_id), seller_id, item_id: Some(item.item_id), amount_cents: Cents::new(*final_price as i64), platform_fee_cents: Cents::ZERO, stripe_checkout_session_id: session_id, item_title: &item.title, seller_username: &item.creator_username, share_contact, project_id: None, promo_code_id, guest_email: None, }, ) .await { Ok(_) => {} Err(AppError::Database(sqlx::Error::Database(ref db_err))) if db_err.code().as_deref() == Some("23505") => { tracing::warn!( buyer_id = %user_id, item_id = %item.item_id, "23505 raced past pre-check during cart pending insert" ); if let Some(pc_id) = promo_code_id { release_promo_quietly(state, pc_id, user_id).await; } return Err(AppError::BadRequest( "Another checkout for one of these items started while this one was loading. \ Please refresh and try again.".to_string(), )); } Err(e) => { // Transaction auto-rolls back on drop. if let Some(pc_id) = promo_code_id { release_promo_quietly(state, pc_id, user_id).await; } return Err(e).context("create pending transaction for cart item"); } } } db_tx.commit().await.context("commit cart pending transactions")?; Ok(()) } /// Core per-seller cart checkout, shared by the single-seller form /// ([`create_cart_checkout`]) and the cross-seller chain ([`drain_to_paid`]). /// /// Returns `Ok(None)` when every item for this seller was free (claimed inline, /// no Stripe session needed — the chain advances to the next seller), or /// `Ok(Some(url))` with the Stripe Checkout URL for the paid remainder. /// /// Ordering matters: the promo reservation is taken as late as possible — after /// the Stripe-ready, minimum-charge, and pending-collision checks — so an abort /// on any of those can't burn a single-use code. The previous chained copy /// reserved early and leaked the reservation on the Stripe-ready and min-charge /// rejects (inert only because the chain never passed a promo); folding the two /// copies into one removes that divergence. #[tracing::instrument(skip_all, name = "stripe::checkout_seller_cart", fields(user_id = %user.id, %seller_id))] async fn checkout_seller_cart( state: &AppState, user: &crate::auth::SessionUser, seller_id: UserId, share_contact: bool, promo_code: Option<&str>, ) -> Result> { let cart_items = db::cart::get_cart_items_for_seller(&state.db, user.id, seller_id).await .context("fetch cart items for seller")?; if cart_items.is_empty() { return Err(AppError::BadRequest("No items in cart for this creator".to_string())); } let seller = db::users::get_user_by_id(&state.db, seller_id) .await .context("fetch seller")? .ok_or(AppError::NotFound)?; if seller.is_suspended() { return Err(AppError::BadRequest("This creator's account is currently unavailable".to_string())); } // Bulk-check ownership in a single query instead of N sequential roundtrips. let cart_item_ids: Vec = cart_items.iter().map(|c| c.item_id).collect(); let already_owned = db::transactions::purchased_subset(&state.db, user.id, &cart_item_ids) .await .context("bulk check existing purchases")?; let mut free_items: Vec<&db::cart::CartItem> = Vec::new(); let mut paid_items: Vec<&db::cart::CartItem> = Vec::new(); for item in &cart_items { if already_owned.contains(&item.item_id) { if let Err(e) = db::cart::remove_from_cart(&state.db, user.id, item.item_id).await { tracing::warn!( user_id = %user.id, item_id = %item.item_id, error = ?e, "failed to remove already-purchased item from cart; buyer will see it lingering on /cart" ); } continue; } if item.is_free() { free_items.push(item); } else { paid_items.push(item); } } // Claim free-by-price items immediately. claim_free_cart_items(state, user.id, seller_id, &free_items, share_contact).await?; // Validate an optional promo code and compute per-item discounted prices. let mut promo_code_id: Option = None; let mut discounted_prices: std::collections::HashMap = std::collections::HashMap::new(); if let Some(code_str) = promo_code.map(str::trim).filter(|s| !s.is_empty()) && let Some(validated) = db::promo_codes::lookup_and_validate_promo(&state.db, seller_id, Some(user.id), code_str).await? { use db::promo_codes::PromoApplication; // Apply to each eligible paid item; ineligible items (scope/min-price) // are skipped so the rest of the cart can still qualify. for item in &paid_items { if item.pwyw_enabled { continue; // PWYW items can't take a promo (single-item behavior) } if let PromoApplication::Apply(price) = db::promo_codes::apply_promo_to_item( &validated, item.item_id, item.project_id, item.effective_price_cents(), )? { discounted_prices.insert(item.item_id, price); } } promo_code_id = Some(validated.id()); } // Re-classify after discount: some paid items may now be free. let mut newly_free: Vec<&db::cart::CartItem> = Vec::new(); let mut still_paid: Vec<(&db::cart::CartItem, i32)> = Vec::new(); for item in &paid_items { let final_price = discounted_prices.get(&item.item_id).copied() .unwrap_or_else(|| item.effective_price_cents()); if final_price == 0 { newly_free.push(item); } else { still_paid.push((item, final_price)); } } let claimed_any_free = !free_items.is_empty() || !newly_free.is_empty(); claim_free_cart_items(state, user.id, seller_id, &newly_free, share_contact).await?; // No paid items remain after discounts: clear any contact revocation and // signal "all free" so the caller redirects / advances the chain. if still_paid.is_empty() { if share_contact && claimed_any_free { db::transactions::clear_contact_revocation(&state.db, user.id, seller_id) .await .context("clear contact revocation")?; } return Ok(None); } // Verify Stripe is ready and the total clears the minimum BEFORE reserving // the promo, so neither reject burns a single-use code. let stripe_account_id = seller.stripe_account_id.as_ref() .ok_or_else(|| AppError::BadRequest("Creator hasn't set up payments yet".to_string()))?; if !seller.stripe_charges_enabled { return Err(AppError::BadRequest("Creator's payment account is not ready".to_string())); } let stripe = state.stripe.as_ref() .ok_or_else(|| AppError::BadRequest("Stripe is not configured".to_string()))?; let line_items: Vec = still_paid .iter() .map(|(item, final_price)| crate::payments::CartLineItem { title: &item.title, amount_cents: *final_price as i64, }) .collect(); // Reject sub-Stripe-minimum totals before calling Stripe: chained promo+PWYW // combinations can land between 1¢ and 49¢, and Stripe's own error for that // is not user-friendly. let cart_total: i64 = line_items.iter().map(|li| li.amount_cents).sum(); if cart_total > 0 && cart_total < crate::constants::STRIPE_MINIMUM_CHARGE_CENTS { return Err(AppError::BadRequest(format!( "Minimum cart total is ${:.2}", crate::constants::STRIPE_MINIMUM_CHARGE_CENTS as f64 / 100.0 ))); } // Pre-check the partial unique index `(buyer_id, item_id) WHERE status='pending'` // BEFORE creating the Stripe session, so we never charge for an item that // can't get a pending row (and would therefore never be fulfilled). let paid_item_ids: Vec = still_paid.iter().map(|(it, _)| it.item_id).collect(); let pending_collisions = db::transactions::pending_subset(&state.db, user.id, &paid_item_ids) .await .context("pre-check pending cart purchases")?; if !pending_collisions.is_empty() { return Err(AppError::BadRequest( "You already have a checkout in progress for one or more of these items. \ Complete or cancel that checkout before starting a new one.".to_string(), )); } // Reserve the promo use only now that every cheap reject is behind us. if let Some(pc_id) = promo_code_id { let reserved = db::promo_codes::try_increment_use_count(&state.db, pc_id) .await .context("reserve promo code use at cart checkout")?; if !reserved { return Err(AppError::BadRequest("This promo code has reached its usage limit".to_string())); } } let success_url = format!( "{}/stripe/success?session_id={{CHECKOUT_SESSION_ID}}", state.config.host_url ); let cancel_url = format!("{}/cart", state.config.host_url); let cart_params = crate::payments::CartCheckoutParams { connected_account_id: stripe_account_id, line_items: &line_items, buyer_id: user.id, seller_id, success_url: &success_url, cancel_url: &cancel_url, enable_stripe_tax: seller.stripe_tax_enabled, }; let result = match stripe.create_cart_checkout_session(&cart_params).await { Ok(r) => r, Err(e) => { if let Some(pc_id) = promo_code_id { release_promo_quietly(state, pc_id, user.id).await; } return Err(e).context("create cart checkout session"); } }; create_cart_pending_transactions( state, user.id, seller_id, &result.id, &still_paid, share_contact, promo_code_id, ) .await?; // Cart items are removed by the webhook handler on successful payment, so a // canceled Stripe checkout leaves the cart intact. result.url .map(Some) .ok_or_else(|| AppError::BadRequest("No checkout URL returned".to_string())) } /// Process the cart queue starting with `first_seller_id`. Loops while /// [`checkout_seller_cart`] returns `Ok(None)` (all items for that seller were /// free), draining the session queue. Returns the Stripe checkout URL the /// moment a paid seller is reached, or `None` when the queue is exhausted with /// every item claimed free. /// /// Chained checkout never carries a promo (`promo_code = None`); discounts are /// only applied on direct single-seller form submissions. #[tracing::instrument(skip_all, name = "stripe::drain_to_paid", fields(user_id = %user.id, first_seller_id = %first_seller_id))] pub(super) async fn drain_to_paid( state: &AppState, user: &crate::auth::SessionUser, first_seller_id: String, share_contact: bool, session: &tower_sessions::Session, ) -> Result> { let mut current = first_seller_id; loop { let seller_id: UserId = current.parse() .map_err(|_| AppError::BadRequest("Invalid seller ID".to_string()))?; if let Some(url) = checkout_seller_cart(state, user, seller_id, share_contact, None).await? { return Ok(Some(url)); } // All items for `current` were free. Pop the next queued seller and // try again; on empty queue, signal "everything claimed". let next: Option = match session.get::>("cart_queue").await { Ok(Some(mut queue)) if !queue.is_empty() => { let n = queue.remove(0); if queue.is_empty() { session.remove::>("cart_queue").await.ok(); session.remove::("cart_share_contact").await.ok(); } else { session.insert("cart_queue", queue).await.ok(); } Some(n) } _ => None, }; match next { Some(n) => current = n, None => return Ok(None), } } }