//! Item checkout and bundle grant logic. use axum::{ Form, extract::{Path, State}, response::{IntoResponse, Redirect, Response}, }; use crate::{ Billing, Integrations, auth::AuthUser, config::Config, db::{self, Cents, ItemId, PromoCodeId}, email::EmailClient, error::{AppError, Result, ResultExt}, helpers, pricing::{self, CheckoutType}, }; use sqlx::PgPool; use super::CheckoutForm; /// POST /stripe/checkout/{item_id} - Create a checkout session and redirect #[tracing::instrument(skip_all, name = "stripe::checkout", fields(item_id))] #[allow(clippy::too_many_arguments)] pub(in crate::routes::stripe) async fn create_checkout( State(db): State, State(bg): State, State(email): State, State(integrations): State, State(payments): State, State(config): 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)?; let item = db::items::get_item_by_id(&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(&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(&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(&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, seller.settlement_currency) .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; // Cents MNW owes the creator when a platform-wide (Fan+) credit is applied, so // the creator nets the full price. Non-zero only for platform-funded discounts. let mut platform_credit_cents: i64 = 0; 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(&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, base_price_cents, )? { PromoApplication::Apply(applied) => { final_price_cents = applied.price_cents; platform_credit_cents = applied.funding.platform_credit_cents() as i64; } 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, platform_credit_cents, }; // 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( &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 = 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(&db, 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(&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( &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) => { // Escalate, don't just log: a claimed item with no key is // silent data loss. Mirror the paid path and cart free-claim // (audit Run 17 Observability). No transaction id on a free // claim, so key the ticket on the item. tracing::error!( buyer_id = %user.id, item_id = %item_uuid, error = ?e, "failed to generate license key for free claim" ); if let Some(wam) = integrations.wam.as_ref() { let title = format!("License key not issued (free claim): item {item_uuid}"); let body = format!( "User {} claimed free item {item_uuid} but license key \ generation failed: {e}\n\nManually issue a key.", user.id, ); wam.create_ticket( &title, Some(&body), "critical", "license-key-gen-failed", Some(&item_uuid.to_string()), ) .await; } } } } // Notify seller of free claim (fire-and-forget). The Sale // preference is checked by the send path. { let buyer_user = db::users::get_user_by_id(&db, user.id).await.ok().flatten(); let buyer_username = buyer_user .as_ref() .map_or_else(|| "Someone".to_string(), |b| b.username.to_string()); let item_title = item.title.clone(); let seller_user_id = seller.id; let seller_email = seller.email.clone(); let seller_name = seller.display_name.clone(); let unsub_url = crate::email::generate_unsubscribe_url( &config.host_url, seller.id, crate::email::UnsubscribeAction::Sale, &seller.id.to_string(), &config.signing_secret, ); let email = email.clone(); bg.spawn("sale notification", async move { if let Err(e) = email .send_sale_notification( seller_user_id, &seller_email, seller_name.as_deref(), &buyer_username, &item_title, "Free", Some(&unsub_url), ) .await { tracing::error!(error = ?e, "failed to send sale notification"); } }); } } return Ok(Redirect::to(&format!("/l/{item_id}?purchase=success")).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, seller.settlement_currency)?; // 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_deref() .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 = payments .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(&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={}", config.host_url, item_id ); let cancel_url = format!("{}/stripe/cancel?item_id={}", 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, currency: seller.settlement_currency, conversion: user.conversion_preference, }; 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(&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( &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, platform_credit_cents, }, ) .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(&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(&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(db): 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(&db, user.id, item_uuid) .await .context("delete pending item checkout")? { db::promo_codes::release_use_count(&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( db: &PgPool, 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(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 Ok(Some(seller)) = db::users::get_user_by_id(db, seller_id).await else { return; }; // Claim all children in one INSERT instead of N per-child round-trips (each // re-acquiring a pool connection) on the webhook hot path (Perf-S4, Run 9). // Idempotent via ON CONFLICT; child items deliberately do not bump sales_count. let items: Vec<(db::ItemId, &str)> = child_items .iter() .map(|c| (c.id, c.title.as_str())) .collect(); match db::transactions::claim_free_items_batch( db, buyer_id, seller_id, &seller.username, parent_transaction_id, &items, ) .await { Ok(granted) => tracing::info!( bundle_id = %bundle_id, buyer_id = %buyer_id, child_count = child_items.len(), granted, "granted bundle child items" ), Err(e) => tracing::warn!( bundle_id = %bundle_id, error = ?e, "failed to grant bundle child items" ), } }