//! 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, CodePurpose, PromoCodeId, UserId}, error::{AppError, Result, ResultExt}, helpers, AppState, }; use super::grant_bundle_items; /// 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 #[tracing::instrument(skip_all, name = "stripe::cart_checkout")] 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())); } // Get all cart items for this seller 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())); } // Verify seller has Stripe connected 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::new(); let mut paid_items = 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 items immediately. Bundle/license metadata is pulled // through CartItem so this loop doesn't need a per-item `get_item_by_id`; // cart rows are bulk-deleted at the end so this loop doesn't fire N // DELETEs either (Run #8 perf MED). let mut to_remove: Vec = Vec::with_capacity(free_items.len()); for item in &free_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: form.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(); // Validate 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) = form.promo_code.as_deref() { let code_str = code_str.trim().to_uppercase(); if !code_str.is_empty() { // Look up seller's code first, then platform-wide let pc = match db::promo_codes::get_promo_code_by_creator_and_code(&state.db, seller_id, &code_str) .await .context("lookup seller promo code")? { Some(pc) => pc, None => db::promo_codes::get_platform_promo_code_by_user_and_code(&state.db, user.id, &code_str) .await .context("lookup platform promo code")? .ok_or_else(|| AppError::BadRequest("Invalid promo code".to_string()))?, }; let is_platform_wide = pc.is_platform_wide; if pc.code_purpose == CodePurpose::FreeTrial { return Err(AppError::BadRequest("Trial codes can only be used for subscriptions".to_string())); } if let Some(starts) = pc.starts_at && starts > chrono::Utc::now() { return Err(AppError::BadRequest("This promo code is not yet active".to_string())); } if let Some(expires) = pc.expires_at && expires < chrono::Utc::now() { return Err(AppError::BadRequest("This promo code has expired".to_string())); } if let Some(max) = pc.max_uses && pc.use_count >= max { return Err(AppError::BadRequest("This promo code has reached its usage limit".to_string())); } // Apply to each eligible paid item for item in &paid_items { // Skip PWYW items (matching single-item behavior) if item.pwyw_enabled { continue; } // Scope checks (seller codes only) if !is_platform_wide { if let Some(scoped_item) = pc.item_id && scoped_item != item.item_id { continue; } if let Some(scoped_project) = pc.project_id && let Ok(Some(db_item)) = db::items::get_item_by_id(&state.db, item.item_id).await && db_item.project_id != scoped_project { continue; } } let base = item.effective_price_cents(); // Honor per-item min_price_cents floor for non-platform Discount // codes (single-item checkout rejects; cart skips this item so // others may still qualify). Run #8 caught this gap. if pc.code_purpose == CodePurpose::Discount && !is_platform_wide && base < pc.min_price_cents { continue; } let discounted = match pc.code_purpose { CodePurpose::FreeAccess => 0, CodePurpose::Discount => { // Reject misconfigured Discount-purpose codes; reserving the code // without applying the discount is the bug fixed here. let (dt, dv) = match (pc.discount_type, pc.discount_value) { (Some(dt), Some(dv)) => (dt, dv), _ => return Err(AppError::BadRequest( "This promo code is misconfigured. Please contact the creator.".to_string(), )), }; db::promo_codes::apply_discount(base, dt, dv) } CodePurpose::FreeTrial => base, // unreachable, guarded above }; discounted_prices.insert(item.item_id, discounted); } promo_code_id = Some(pc.id); } } // Re-classify items after discount: some paid items may now be free let mut newly_free = Vec::new(); let mut still_paid = 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)); } } // Claim discount-zeroed items as free. Same per-item-roundtrip discipline // as the free-by-price loop above: bundle/license fields come from CartItem, // cart rows are bulk-deleted after the loop. let mut to_remove_promo: Vec = Vec::with_capacity(newly_free.len()); for item in &newly_free { 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: form.share_contact, parent_transaction_id: None, }; let mut tx = state.db.begin().await.context("begin promo-free claim")?; let claimed = db::transactions::claim_free_item(&mut *tx, &claim) .await.context("claim promo-free item")?; if claimed { db::items::increment_sales_count(&mut *tx, item.item_id) .await.context("increment sales count")?; } tx.commit().await.context("commit promo-free claim")?; 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_promo.push(item.item_id); } db::cart::remove_from_cart_bulk(&state.db, user.id, &to_remove_promo).await.ok(); // If no paid items remain after discounts, redirect to library if still_paid.is_empty() { if form.share_contact && (!free_items.is_empty() || !newly_free.is_empty()) { db::transactions::clear_contact_revocation(&state.db, user.id, seller_id) .await .context("clear contact revocation")?; } return Ok(Redirect::to("/library?purchase=success").into_response()); } // Verify Stripe is ready for paid items BEFORE reserving the promo. The // previous order burned a use of single-use codes against creators with // no charges_enabled. 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()))?; // Reserve promo code use_count before creating session 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())); } } // Build Stripe line items with discounted prices 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; same rationale // as the per-seller path further down — chained promo+PWYW combinations // can land between 1¢ and 49¢, and Stripe's error message 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 { if let Some(pc_id) = promo_code_id { db::promo_codes::release_use_count_and_detach(&state.db, pc_id, user.id).await.ok(); } 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. The previous behavior swallowed a 23505 // collision per cart item silently, leaving the buyer charged for items that // never got a pending row — and therefore never got fulfilled by the webhook. 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() { if let Some(pc_id) = promo_code_id { db::promo_codes::release_use_count_and_detach(&state.db, pc_id, user.id).await.ok(); } 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(), )); } 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 { db::promo_codes::release_use_count_and_detach(&state.db, pc_id, user.id).await.ok(); } return Err(e).context("create cart checkout session"); } }; // Create pending transactions for all paid items atomically so the buyer // either gets all items or none (prevents partial delivery on mid-loop failure) let mut db_tx = state.db.begin().await.context("begin cart transaction creation")?; for (item, final_price) in &still_paid { 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: &result.id, item_title: &item.title, seller_username: &item.creator_username, share_contact: form.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") => { // A 23505 here means another tab raced past the pre-check. // Abort the whole cart rather than silently leaving a paid // Stripe line item without a pending DB row to fulfill. 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 { db::promo_codes::release_use_count_and_detach(&state.db, pc_id, user.id).await.ok(); } 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 { db::promo_codes::release_use_count_and_detach(&state.db, pc_id, user.id).await.ok(); } return Err(e).context("create pending transaction for cart item"); } } } db_tx.commit().await.context("commit cart pending transactions")?; // Cart items are removed by the webhook handler on successful payment, // so users keep their cart if they cancel the Stripe checkout. // Redirect to Stripe Checkout let checkout_url = result .url .ok_or_else(|| AppError::BadRequest("No checkout URL returned".to_string()))?; Ok(Redirect::to(&checkout_url).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")] 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()), } } /// Core logic: process cart checkout for one seller. /// /// Returns `Ok(Some(url))` for a Stripe-payable checkout, `Ok(None)` when /// every item for this seller was free (already claimed inline; no Stripe /// session needed — caller should advance to the next queued seller or /// redirect to the library). The chain-break bug fixed in Run #8 was caused /// by the previous shape returning `Err` on the all-free case, which broke /// out of `create_cart_checkout_all` mid-flow and left `cart_queue` stranded /// in the session. /// /// Called by create_cart_checkout (single-seller form) and create_cart_checkout_all /// (cross-seller chain). Does NOT handle promo codes when called from the chain /// (promo_code = None). pub(super) async fn process_seller_checkout( state: &AppState, user: &crate::auth::SessionUser, seller_id_str: &str, share_contact: bool, promo_code: Option, ) -> Result> { let seller_id: UserId = seller_id_str.parse() .map_err(|_| AppError::BadRequest("Invalid seller ID".to_string()))?; 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 — chained checkout path was // missed in Run #5; Run #6 audit caught the N+1. 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::new(); let mut paid_items = 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 items. Bundle/license fields come from CartItem; cart rows // bulk-deleted after the loop (Run #8 perf MED). let mut to_remove: Vec = Vec::with_capacity(free_items.len()); for item in &free_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")?; 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")?; 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(); if paid_items.is_empty() { if share_contact && !free_items.is_empty() { db::transactions::clear_contact_revocation(&state.db, user.id, seller_id) .await.context("clear contact revocation")?; } // All items free — no Stripe session. Caller advances the chain. return Ok(None); } // Promo code handling (only for direct form submissions, not chained) 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.as_deref() { let code_str = code_str.trim().to_uppercase(); if !code_str.is_empty() { let pc = match db::promo_codes::get_promo_code_by_creator_and_code(&state.db, seller_id, &code_str) .await.context("lookup promo code")? { Some(pc) => pc, None => db::promo_codes::get_platform_promo_code_by_user_and_code(&state.db, user.id, &code_str) .await.context("lookup platform promo code")? .ok_or_else(|| AppError::BadRequest("Invalid promo code".to_string()))?, }; if pc.code_purpose == CodePurpose::FreeTrial { return Err(AppError::BadRequest("Trial codes can only be used for subscriptions".to_string())); } if let Some(starts) = pc.starts_at && starts > chrono::Utc::now() { return Err(AppError::BadRequest("This promo code is not yet active".to_string())); } if let Some(expires) = pc.expires_at && expires < chrono::Utc::now() { return Err(AppError::BadRequest("This promo code has expired".to_string())); } if let Some(max) = pc.max_uses && pc.use_count >= max { return Err(AppError::BadRequest("This promo code has reached its usage limit".to_string())); } let is_platform_wide = pc.is_platform_wide; for item in &paid_items { if item.pwyw_enabled { continue; } if !is_platform_wide { if let Some(scoped_item) = pc.item_id && scoped_item != item.item_id { continue; } if let Some(scoped_project) = pc.project_id && let Ok(Some(db_item)) = db::items::get_item_by_id(&state.db, item.item_id).await && db_item.project_id != scoped_project { continue; } } let base = item.effective_price_cents(); // Honor per-item min_price_cents floor for non-platform Discount // codes (single-item checkout rejects; cart skips this item so // others may still qualify). Run #8 caught this gap. if pc.code_purpose == CodePurpose::Discount && !is_platform_wide && base < pc.min_price_cents { continue; } let discounted = match pc.code_purpose { CodePurpose::FreeAccess => 0, CodePurpose::Discount => { // Reject misconfigured Discount codes — Run #7 caught // that the cart-all chain path (this third copy in the // same file) missed the H3 fix applied to the other two. let (dt, dv) = match (pc.discount_type, pc.discount_value) { (Some(dt), Some(dv)) => (dt, dv), _ => return Err(AppError::BadRequest( "This promo code is misconfigured. Please contact the creator.".to_string(), )), }; db::promo_codes::apply_discount(base, dt, dv) } CodePurpose::FreeTrial => base, }; discounted_prices.insert(item.item_id, discounted); } promo_code_id = Some(pc.id); } } // Build final price list let final_items: Vec<(&db::cart::CartItem, i32)> = paid_items .iter() .map(|item| { let price = discounted_prices.get(&item.item_id).copied() .unwrap_or_else(|| item.effective_price_cents()); (*item, price) }) .filter(|(_, price)| *price > 0) .collect(); if final_items.is_empty() { return Err(AppError::BadRequest("All items are free after discount".to_string())); } // Reserve promo code 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")?; if !reserved { return Err(AppError::BadRequest("This promo code has reached its usage limit".to_string())); } } 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 = final_items .iter() .map(|(item, price)| crate::payments::CartLineItem { title: &item.title, amount_cents: *price as i64, }) .collect(); // Reject sub-Stripe-minimum totals here. The cart flow doesn't share // the same `check_min_charge` gate as item/subscription checkout, so a // chained promo+PWYW combination that lands between 1¢ and 49¢ would // be accepted here and then rejected by Stripe with a confusing error. 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 pending-purchase index BEFORE Stripe session — see // create_cart_checkout for the rationale. let paid_item_ids: Vec = final_items.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 purchases in chained cart")?; if !pending_collisions.is_empty() { // This path reserves the promo earlier; release on collision so the // use_count doesn't stay burned (no pending row was created, so the // stale-pending cleanup won't recover it). if let Some(pc_id) = promo_code_id { db::promo_codes::release_use_count_and_detach(&state.db, pc_id, user.id).await.ok(); } 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(), )); } 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 { db::promo_codes::release_use_count_and_detach(&state.db, pc_id, user.id).await.ok(); } return Err(e).context("create cart checkout session"); } }; let mut db_tx = state.db.begin().await.context("begin seller cart transaction creation")?; for (item, final_price) in &final_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: &result.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") => { // Race past the pre-check from another tab — abort rather than // silently leave a paid Stripe line item without a fulfilling row. tracing::warn!( buyer_id = %user.id, item_id = %item.item_id, "23505 raced past pre-check during seller-cart pending insert" ); if let Some(pc_id) = promo_code_id { db::promo_codes::release_use_count_and_detach(&state.db, pc_id, user.id).await.ok(); } 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) => { if let Some(pc_id) = promo_code_id { db::promo_codes::release_use_count_and_detach(&state.db, pc_id, user.id).await.ok(); } return Err(e).context("create pending transaction"); } } } db_tx.commit().await.context("commit seller cart pending transactions")?; // Cart items are removed by the webhook handler on successful payment. 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 /// `process_seller_checkout` 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. 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 { if let Some(url) = process_seller_checkout(state, user, ¤t, 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), } } }