//! Item checkout and bundle grant logic. use axum::{ extract::{Path, State}, response::{IntoResponse, Redirect, Response}, Form, }; use crate::{ auth::AuthUser, db::{self, Cents, ItemId, PromoCodeId}, error::{AppError, Result, ResultExt}, helpers::{self, spawn_email}, pricing::{self, CheckoutType}, AppState, }; use super::CheckoutForm; /// POST /stripe/checkout/{item_id} - Create a checkout session and redirect #[tracing::instrument(skip_all, name = "stripe::checkout", fields(item_id))] pub(in crate::routes::stripe) async fn create_checkout( State(state): State, AuthUser(user): AuthUser, Path(item_id): Path, Form(form): Form, ) -> Result { tracing::Span::current().record("item_id", tracing::field::display(&item_id)); user.check_not_suspended()?; user.check_not_sandbox()?; let item_uuid: ItemId = item_id.parse() .map_err(|_| AppError::NotFound)?; // Get the item let item = db::items::get_item_by_id(&state.db, item_uuid) .await .with_context(|| format!("fetch item {item_uuid} for checkout"))? .ok_or(AppError::NotFound)?; // Draft items cannot be purchased if !item.is_public { return Err(AppError::BadRequest("This item is not available for purchase".to_string())); } // Unlisted items can only be obtained through their bundle if !item.listed { return Err(AppError::BadRequest("This item is only available as part of a bundle".to_string())); } // Free items don't need checkout let item_pricing = pricing::for_item(&item); if item_pricing.checkout_type() == CheckoutType::None { return Err(AppError::BadRequest("This item is free".to_string())); } // Check if already purchased if db::transactions::has_purchased_item(&state.db, user.id, item_uuid) .await .context("check existing purchase")? { return Ok(Redirect::to(&format!("/l/{}", item_id)).into_response()); } // Get the seller (creator) let seller_id = db::items::get_item_owner(&state.db, item_uuid) .await .with_context(|| format!("fetch item owner for {item_uuid}"))? .ok_or(AppError::NotFound)?; // A user cannot purchase their own items if user.id == seller_id { return Err(AppError::BadRequest("You cannot purchase your own items".to_string())); } let seller = db::users::get_user_by_id(&state.db, seller_id) .await .with_context(|| format!("fetch seller {seller_id}"))? .ok_or(AppError::NotFound)?; if seller.is_suspended() || seller.is_deactivated() || seller.is_creator_paused() { return Err(AppError::BadRequest("This creator's account is not active".to_string())); } // Determine base price: PWYW uses buyer's chosen amount, otherwise item price let base_price_cents = if item_pricing.checkout_type() == CheckoutType::PayWhatYouWant { let amount = form.amount_cents .ok_or_else(|| AppError::BadRequest("Amount is required for pay-what-you-want items".to_string()))?; item_pricing.validate_amount(amount) .map_err(AppError::BadRequest)?; // PWYW with $0 is valid when min is $0 — falls through to the // free-claim path at `final_price_cents == 0` below. amount } else { item.price_cents }; // Validate optional promo code (discount or free_access) let mut final_price_cents = base_price_cents; let mut promo_code_id: Option = None; if let Some(code_str) = form.promo_code.as_deref().map(str::trim).filter(|s| !s.is_empty()) { if item.pwyw_enabled { return Err(AppError::BadRequest("Promo codes cannot be applied to pay-what-you-want items".to_string())); } // Buyer's platform-wide Fan+ credit is the fallback when no seller code matches. if 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, PromoIneligible}; match db::promo_codes::apply_promo_to_item(&validated, item_uuid, item.project_id, item.price_cents)? { PromoApplication::Apply(price) => final_price_cents = price, PromoApplication::Ineligible(PromoIneligible::ScopeMismatch) => { return Err(AppError::BadRequest("This promo code is not valid for this item".to_string())); } PromoApplication::Ineligible(PromoIneligible::BelowMinPrice) => { return Err(AppError::BadRequest("This item does not meet the minimum price for this code".to_string())); } } promo_code_id = Some(validated.id()); } } // If discount makes it free, use claim_free_item flow if final_price_cents == 0 { let claim = db::transactions::ClaimParams { buyer_id: user.id, item_id: item_uuid, seller_id, item_title: &item.title, seller_username: &seller.username, share_contact: form.share_contact, parent_transaction_id: None, }; // Pre-generate license key params so the promo path can include them in // the same transaction as the claim. let key_code = if item.enable_license_keys { Some(helpers::generate_key_code()) } else { None }; let lk_params = key_code.as_ref().map(|kc| db::transactions::LicenseKeyParams { key_code: kc, max_activations: item.default_max_activations, }); let (claimed, license_key_created) = if let Some(pc_id) = promo_code_id { // Wrap promo code increment + claim + license key in a single transaction let (code_accepted, claimed) = db::transactions::claim_free_with_promo_code( &state.db, pc_id, &claim, lk_params.as_ref(), ).await .context("claim free item with promo code")?; if !code_accepted { return Err(AppError::BadRequest("This code has reached its usage limit".to_string())); } // License key was created inside the transaction if claimed + keys enabled (claimed, claimed && item.enable_license_keys) } else { // Wrap claim + sales count increment in a single transaction 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_uuid) .await .context("increment sales count")?; } tx.commit().await.context("commit free-claim transaction")?; (claimed, false) }; if claimed { // Grant access to bundle child items (if this is a bundle) if item.item_type == db::ItemType::Bundle { grant_bundle_items(&state, item_uuid, user.id, seller_id, None).await; } // Clear any prior contact revocation if fan is re-sharing if form.share_contact { db::transactions::clear_contact_revocation(&state.db, user.id, seller_id) .await .context("clear contact revocation")?; } // Generate license key if enabled (skip if already created in promo transaction) if item.enable_license_keys && !license_key_created { let key_code = helpers::generate_key_code(); match db::license_keys::create_license_key( &state.db, item_uuid, user.id, None, // no transaction ID for free claims &key_code, item.default_max_activations, ).await { Ok(key) => { tracing::info!( key_id = %key.id, buyer_id = %user.id, item_id = %item_uuid, "license key generated for free claim" ); } Err(e) => { tracing::error!( buyer_id = %user.id, item_id = %item_uuid, error = ?e, "failed to generate license key for free claim" ); } } } // Notify seller of free claim (fire-and-forget) if seller.notify_sale { let buyer_user = db::users::get_user_by_id(&state.db, user.id).await.ok().flatten(); let buyer_username = buyer_user.as_ref() .map(|b| b.username.to_string()) .unwrap_or_else(|| "Someone".to_string()); let item_title = item.title.clone(); let seller_email = seller.email.clone(); let seller_name = seller.display_name.clone(); let unsub_url = crate::email::generate_unsubscribe_url( &state.config.host_url, seller.id, crate::email::UnsubscribeAction::Sale, &seller.id.to_string(), &state.config.signing_secret, ); spawn_email!(state, "sale notification", |email| { email.send_sale_notification( &seller_email, seller_name.as_deref(), &buyer_username, &item_title, "Free", Some(&unsub_url), ) }); } } return Ok(Redirect::to(&format!("/l/{}?purchase=success", item_id)).into_response()); } // Reject sub-Stripe-minimum charges (a Discount promo can land a fixed item // at 1–49¢) using the shared `check_min_charge` the Stripe session call // enforces internally. Gating here, before the promo reservation, means a // rejection doesn't burn a use of the code. crate::payments::check_min_charge(final_price_cents as i64)?; // Validate Stripe-readiness BEFORE reserving the promo code use_count. // The original order (reserve → readiness checks) burned a use of a // single-use code every time a buyer hit a creator who lost 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 at checkout time (not webhook time) to prevent // concurrent checkouts from exceeding max_uses. If the buyer abandons checkout, // the scheduler releases the reservation when cleaning up stale pending transactions. 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 checkout")?; if !reserved { return Err(AppError::BadRequest("This promo code has reached its usage limit".to_string())); } } // Build URLs let success_url = format!("{}/stripe/success?session_id={{CHECKOUT_SESSION_ID}}&item_id={}", state.config.host_url, item_id); let cancel_url = format!("{}/stripe/cancel?item_id={}", state.config.host_url, item_id); // Create the checkout session with the (possibly discounted) price. // If this or the transaction INSERT fails, release the promo code reservation. let checkout_params = crate::payments::CheckoutParams { connected_account_id: stripe_account_id, item_title: &item.title, amount_cents: Cents::new(final_price_cents as i64), buyer_id: user.id, seller_id, item_id: Some(item_uuid), success_url: &success_url, cancel_url: &cancel_url, promo_code_id, enable_stripe_tax: seller.stripe_tax_enabled, }; let session = match stripe.create_checkout_session(&checkout_params).await { Ok(s) => s, 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).with_context(|| format!("create Stripe checkout for item {item_uuid}")); } }; // Create a pending transaction. The partial unique index on // (buyer_id, item_id) WHERE status = 'pending' prevents concurrent // duplicate checkouts — if another checkout is already in progress, // the INSERT fails and we redirect back to the item page. match db::transactions::create_transaction( &state.db, &db::transactions::CreateTransactionParams { buyer_id: Some(user.id), seller_id, item_id: Some(item_uuid), amount_cents: final_price_cents.into(), platform_fee_cents: Cents::ZERO, // 0% platform fee stripe_checkout_session_id: &session.id, item_title: &item.title, seller_username: &seller.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") => { if let Some(pc_id) = promo_code_id { db::promo_codes::release_use_count_and_detach(&state.db, pc_id, user.id).await.ok(); } tracing::info!(buyer_id = %user.id, item_id = %item_uuid, "duplicate pending checkout blocked"); return Ok(Redirect::to(&format!("/purchase/{}", item_id)).into_response()); } 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"); } } // Redirect to Stripe Checkout let checkout_url = session.url .ok_or_else(|| AppError::BadRequest("No checkout URL returned".to_string()))?; Ok(Redirect::to(&checkout_url).into_response()) } /// POST /stripe/checkout/{item_id}/cancel-pending: delete the buyer's /// in-progress checkout for this item so they can start a fresh one. /// /// Safe to call when no pending row exists (no-op). Releases any reserved /// promo code use_count. #[tracing::instrument(skip_all, name = "stripe::cancel_pending", fields(item_id))] pub(in crate::routes::stripe) async fn cancel_pending_item_checkout( State(state): State, AuthUser(user): AuthUser, Path(item_id): Path, ) -> Result { tracing::Span::current().record("item_id", tracing::field::display(&item_id)); let item_uuid: ItemId = item_id.parse().map_err(|_| AppError::NotFound)?; if let Some(promo_id) = db::transactions::delete_pending_item_purchase(&state.db, user.id, item_uuid) .await .context("delete pending item checkout")? { db::promo_codes::release_use_count(&state.db, promo_id).await.ok(); } Ok(Redirect::to(&format!("/purchase/{}", item_id)).into_response()) } /// Grant access to all child items of a purchased bundle. /// /// For each child item, creates a completed $0 transaction (idempotent via /// ON CONFLICT DO NOTHING). Does NOT increment child item sales_count -- /// the bundle sale is what counts. pub(crate) async fn grant_bundle_items( state: &AppState, bundle_id: db::ItemId, buyer_id: db::UserId, seller_id: db::UserId, parent_transaction_id: Option, ) { let child_items = match db::bundles::get_bundle_items(&state.db, bundle_id).await { Ok(items) => items, Err(e) => { tracing::error!(bundle_id = %bundle_id, error = ?e, "failed to load bundle items for granting"); return; } }; let seller = match db::users::get_user_by_id(&state.db, seller_id).await { Ok(Some(u)) => u, _ => return, }; for child in &child_items { let claim = db::transactions::ClaimParams { buyer_id, item_id: child.id, seller_id, item_title: &child.title, seller_username: &seller.username, share_contact: false, parent_transaction_id, }; // Idempotent: ON CONFLICT DO NOTHING if already claimed if let Err(e) = db::transactions::claim_free_item(&state.db, &claim).await { tracing::warn!( child_item_id = %child.id, bundle_id = %bundle_id, error = ?e, "failed to grant bundle child item" ); } // Deliberately NOT incrementing sales_count for child items } tracing::info!( bundle_id = %bundle_id, buyer_id = %buyer_id, child_count = child_items.len(), "granted bundle child items" ); }