//! Guest checkout: purchase items without an MNW account. //! //! These endpoints are public (no auth required) and CORS-enabled for use from //! embedded widgets on external sites. use axum::{ Json, extract::{Path, State}, http::{HeaderValue, StatusCode, header}, response::{IntoResponse, Redirect, Response}, }; use serde::{Deserialize, Serialize}; use crate::background::BackgroundTx; use crate::config::Config; use crate::email::EmailClient; use crate::{AppStorage, Billing, Integrations}; use sqlx::PgPool; use crate::{ db::{self, Cents, ItemId}, error::{AppError, Result, ResultExt}, }; /// Request body for creating a guest checkout session. #[derive(Debug, Deserialize)] pub(super) struct GuestCheckoutRequest { /// Buyer-chosen amount in cents (only for PWYW items). pub amount_cents: Option, /// Optional promo/discount code. pub promo_code: Option, } /// Response from creating a guest checkout session. #[derive(Serialize)] struct CheckoutResponse { checkout_url: String, } /// POST /api/checkout/guest/{item_id} /// /// Creates a Stripe Checkout Session for a guest purchase (no account required). /// Returns the Stripe checkout URL. The embed or item page opens this in a popup /// or redirects to it. #[tracing::instrument(skip_all, name = "guest_checkout::create")] pub(super) async fn create_guest_checkout( State(db): State, State(config): State, State(payments): State, Path(item_id): Path, Json(body): Json, ) -> Result { // Fetch item let item = db::items::get_item_by_id(&db, item_id) .await? .ok_or(AppError::NotFound)?; if !item.is_public || !item.listed { return Err(AppError::NotFound); } // Fetch seller via project let project = db::projects::get_project_by_id(&db, item.project_id) .await? .ok_or(AppError::NotFound)?; let seller = db::users::get_user_by_id(&db, project.user_id) .await? .ok_or(AppError::NotFound)?; if seller.is_suspended() || seller.is_deactivated() || seller.is_creator_paused() { return Err(AppError::NotFound); } let seller_id = seller.id; // Determine price, use the same pricing model as the authenticated checkout let pricing = crate::pricing::for_item(&item); let mut final_price_cents = if item.pwyw_enabled { let buyer_amount = body.amount_cents.unwrap_or(item.price_cents); pricing .validate_amount(buyer_amount, seller.settlement_currency) .map_err(AppError::BadRequest)?; buyer_amount } else { item.price_cents }; // Free items: skip Stripe entirely, redirect to the free claim endpoint if final_price_cents == 0 { return Err(AppError::BadRequest( "Free items use /api/checkout/guest-free/{item_id} instead".to_string(), )); } // Resolve and validate promo code with the same checks as authenticated checkout let mut promo_code_id = None; if let Some(code_str) = body .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(), )); } // Guests have no account, so no platform-wide Fan+ credit fallback (None). if let Some(validated) = db::promo_codes::lookup_and_validate_promo(&db, seller_id, None, code_str).await? { use db::promo_codes::{PromoApplication, PromoIneligible}; match db::promo_codes::apply_promo_to_item( &validated, item_id, item.project_id, item.price_cents, )? { PromoApplication::Apply(applied) => { // Guests have no account and thus no platform-wide Fan+ credit, so a // guest discount is always creator-funded. Guard the invariant rather // than silently dropping a reimbursement obligation. if applied.funding.platform_credit_cents() > 0 { return Err(AppError::BadRequest( "This code cannot be used for guest checkout".to_string(), )); } final_price_cents = applied.price_cents; } 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 a promo code brought the price to zero, redirect to the free claim flow if final_price_cents == 0 { return Err(AppError::BadRequest( "Free items use /api/checkout/guest-free/{item_id} instead".to_string(), )); } // 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)?; // Verify seller has Stripe configured 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 the promo code BEFORE creating the Stripe session or pending row, // mirroring the authenticated item-checkout path. The old order (reserve // last) meant a swallowed 23505 returned a live checkout URL with NO pending // row for the webhook to complete, the buyer paid and the sale vanished, // and a failed reservation could orphan the pending row's promo. Every // failure path below now releases this reservation. 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 guest checkout")?; if !reserved { return Err(AppError::BadRequest( "This promo code has reached its usage limit".to_string(), )); } } // Release the reservation above on any failure path below (no-op if no promo). let release_promo = || async { if let Some(pc_id) = promo_code_id { db::promo_codes::release_use_count(&db, pc_id).await.ok(); } }; // Build URLs let success_url = format!( "{}/stripe/success?session_id={{CHECKOUT_SESSION_ID}}", config.host_url ); let cancel_url = format!("{}/i/{}", config.host_url, item_id); // Create guest checkout session let checkout_params = crate::payments::GuestCheckoutParams { connected_account_id: stripe_account_id, item_title: &item.title, amount_cents: Cents::new(final_price_cents as i64), seller_id, item_id, success_url: &success_url, cancel_url: &cancel_url, promo_code_id, enable_stripe_tax: seller.stripe_tax_enabled, currency: seller.settlement_currency, // A guest has no stored preference, so they get the default: the path // where Stripe shows the converted total before they commit. conversion: crate::currency::ConversionChoice::default(), }; let result = match stripe.create_guest_checkout_session(&checkout_params).await { Ok(r) => r, Err(e) => { release_promo().await; return Err(e) .with_context(|| format!("create guest Stripe checkout for item {item_id}")); } }; // Create pending transaction (buyer_id = None for guest). If the insert // fails we must NOT return the live Stripe URL: there'd be no pending row // for the webhook to complete, so the buyer would be charged and the sale // silently lost. Release the promo and return an error (guests have no // purchase page to redirect to, unlike the authenticated path). match db::transactions::create_transaction( &db, &db::transactions::CreateTransactionParams { buyer_id: None, seller_id, item_id: Some(item_id), amount_cents: final_price_cents.into(), platform_fee_cents: Cents::ZERO, stripe_checkout_session_id: &result.id, item_title: &item.title, seller_username: &seller.username, share_contact: false, project_id: Some(item.project_id), promo_code_id, guest_email: None, // Set by webhook when Stripe provides it platform_credit_cents: 0, // guests have no platform-wide credit (guarded above) }, ) .await { Ok(_) => {} // 23505 backstop. Note: the pending-checkout partial unique index is // `(buyer_id, item_id) WHERE status = 'pending'`, and a guest row always // has `buyer_id = NULL` (NULLS DISTINCT), so this branch does NOT dedup // rapid guest re-submits the way it does for the authenticated path, // guests have no stable identity at creation time (guest_email arrives // later, from the Stripe webhook). Guest checkout flooding is instead // bounded by the endpoint's per-IP rate limit (GUEST_CHECKOUT_RATE_LIMIT_* // in api/mod.rs). This branch therefore only fires on the effectively // impossible stripe_checkout_session_id collision; true per-guest dedup // would need a client-supplied idempotency key (a protocol change). Err(AppError::Database(sqlx::Error::Database(ref db_err))) if db_err.code().as_deref() == Some("23505") => { release_promo().await; tracing::info!(item_id = %item_id, "duplicate pending guest checkout blocked"); return Err(AppError::BadRequest( "A checkout for this item is already in progress. Please complete or cancel it before starting another.".to_string(), )); } Err(e) => { release_promo().await; return Err(e).context("create pending guest transaction"); } } let checkout_url = result .url .ok_or_else(|| AppError::BadRequest("No checkout URL returned".to_string()))?; let mut response = Json(CheckoutResponse { checkout_url }).into_response(); // CORS headers for cross-origin embed usage let headers = response.headers_mut(); headers.insert( header::ACCESS_CONTROL_ALLOW_ORIGIN, HeaderValue::from_static("*"), ); headers.insert( header::ACCESS_CONTROL_ALLOW_METHODS, HeaderValue::from_static("POST, OPTIONS"), ); headers.insert( header::ACCESS_CONTROL_ALLOW_HEADERS, HeaderValue::from_static("content-type"), ); Ok(response) } /// OPTIONS /api/checkout/guest/{item_id}: CORS preflight pub(super) async fn guest_checkout_preflight() -> Response { let mut response = StatusCode::NO_CONTENT.into_response(); let headers = response.headers_mut(); headers.insert( header::ACCESS_CONTROL_ALLOW_ORIGIN, HeaderValue::from_static("*"), ); headers.insert( header::ACCESS_CONTROL_ALLOW_METHODS, HeaderValue::from_static("POST, OPTIONS"), ); headers.insert( header::ACCESS_CONTROL_ALLOW_HEADERS, HeaderValue::from_static("content-type"), ); headers.insert( header::ACCESS_CONTROL_MAX_AGE, HeaderValue::from_static("86400"), ); response } /// GET /download/{download_token} /// /// Download a purchased item using a token from the guest purchase email. /// No authentication required; the token is the proof of purchase. #[tracing::instrument(skip_all, name = "guest_checkout::download")] pub(super) async fn guest_download( State(db): State, State(storage): State, Path(token): Path, ) -> Result { let tx = db::transactions::get_transaction_by_download_token(&db, token) .await? .ok_or(AppError::NotFound)?; let item_id = tx.item_id.ok_or(AppError::NotFound)?; let item = db::items::get_item_by_id(&db, item_id) .await? .ok_or(AppError::NotFound)?; // Resolve the downloadable file and the scan status that gates it. Items // deliver either via an inline audio/video key or via a version file // (software / digital-file items). A guest download token is item-scoped, so // a version-delivered item resolves to its current file, the same file an // authenticated buyer gets from the item page. Previously this path checked // only the inline keys, so a guest who bought a version-delivered item got a // token that 404'd despite having paid (Run 15 fulfillment gap). let (s3_key, scan_status) = match item.audio_s3_key.clone().or(item.video_s3_key.clone()) { Some(key) => (key, item.scan_status), None => db::versions::get_versions_by_item(&db, item_id) .await? .into_iter() .filter_map(|v| { v.s3_key .map(|k| (k, v.scan_status, v.is_current, v.created_at)) }) // Prefer the current version, then the most recent, matching what the // item page hands an authenticated buyer. .max_by(|a, b| a.2.cmp(&b.2).then(a.3.cmp(&b.3))) .map(|(key, status, _, _)| (key, status)) .ok_or(AppError::NotFound)?, }; // Gate on scan status, exactly as the authenticated download path does // (downloads.rs). A guest is never the creator, so there is no preview // exemption: only Clean content is downloadable, Pending/HeldForReview/ // Quarantined are withheld until the scan clears (Sec NOTE, Run 7; the guest // path previously skipped this quarantine check entirely). if scan_status != db::FileScanStatus::Clean { return Err(AppError::NotFound); } let s3 = storage.s3.as_ref().ok_or_else(|| { AppError::ServiceUnavailable("File storage is not configured".to_string()) })?; let download_url = s3 .presign_download(&crate::storage::S3Key::from_stored(&s3_key), Some(3600)) .await?; Ok(Redirect::temporary(&download_url).into_response()) } /// POST /api/purchases/claim /// /// Attach a guest purchase to the authenticated user's account using a claim token. #[tracing::instrument(skip_all, name = "guest_checkout::claim")] pub(super) async fn claim_purchase( State(db): State, State(integrations): State, crate::auth::AuthUser(user): crate::auth::AuthUser, Json(body): Json, ) -> Result { user.check_not_sandbox()?; let tx = db::transactions::claim_guest_purchase(&db, body.claim_token, user.id) .await? .ok_or_else(|| AppError::BadRequest("Invalid or already-claimed token".to_string()))?; tracing::info!( user_id = %user.id, transaction_id = %tx.id, "guest purchase claimed" ); // Issue the license key at claim time. Guest purchases of key-enabled items // are no longer auto-attached on an email match (the buyer always claims via // the emailed link), so the key must be minted here, the claim is the point // at which a real, authenticated buyer is known. `claim_guest_purchase`'s // `buyer_id IS NULL` guard makes the claim single-use, so this runs at most // once per purchase. if let Some(item_id) = tx.item_id { crate::routes::stripe::webhook::checkout_helpers::maybe_generate_license_key( &db, integrations.wam.as_ref(), item_id, user.id, tx.id, ) .await; } Ok(StatusCode::OK.into_response()) } #[derive(Debug, Deserialize)] pub(super) struct ClaimRequest { pub claim_token: db::ClaimToken, } /// Request body for free guest claim. #[derive(Debug, Deserialize)] pub(super) struct FreeGuestClaimRequest { pub email: String, } /// POST /api/checkout/guest-free/{item_id} /// /// Claim a free item as a guest. Collects email, creates a completed transaction, /// and sends download + claim links via email. No Stripe involved. #[tracing::instrument(skip_all, name = "guest_checkout::claim_free")] pub(super) async fn claim_free_guest( State(db): State, State(config): State, State(email_client): State, State(bg): State, Path(item_id): Path, Json(body): Json, ) -> Result { let email = db::Email::new(&body.email) .map_err(|_| AppError::BadRequest("Invalid email address".to_string()))?; let item = db::items::get_item_by_id(&db, item_id) .await? .ok_or(AppError::NotFound)?; if !item.is_public || !item.listed || item.price_cents != 0 { return Err(AppError::NotFound); } // Fetch seller let project = db::projects::get_project_by_id(&db, item.project_id) .await? .ok_or(AppError::NotFound)?; let seller = db::users::get_user_by_id(&db, project.user_id) .await? .ok_or(AppError::NotFound)?; if seller.is_suspended() || seller.is_deactivated() || seller.is_creator_paused() { return Err(AppError::NotFound); } // Never auto-attach on an email match, a typed address isn't proof the // claimant controls it. The buyer always claims via the emailed link, // consistent with the paid guest path (Run #21 / Max's call 2026-06-15). let claim_token = db::ClaimToken::new(); let download_token = db::DownloadToken::new(); let checkout_session_id = format!("free-guest-{email}-{item_id}"); // Create completed transaction let result = db::transactions::create_free_guest_transaction( &db, None, seller.id, item_id, &checkout_session_id, &item.title, &seller.username, email.as_str(), Some(claim_token), download_token, ) .await; match result { Ok(0) => { // Already claimed, still send the email with download link } Ok(_) => { let _ = db::items::increment_sales_count(&db, item_id).await; } Err(e) => { // Unique violation (already in library for existing user) if let sqlx::Error::Database(ref db_err) = e { if db_err.code().as_deref() == Some("23505") { // Already claimed, continue to send email } else { return Err(AppError::Database(e)); } } else { return Err(AppError::Database(e)); } } } // Send download email let host_url = &config.host_url; let download_url = format!("{host_url}/download/{download_token}"); let claim_url = format!("{host_url}/claim?token={claim_token}"); // Always send the claim link, there's no auto-attach to skip it for. { let email_client = email_client.clone(); let email_addr = email.clone().into_inner(); let item_title = item.title.clone(); let dl_url = download_url.clone(); let cl_url = claim_url; bg.spawn("free guest claim email", async move { if let Err(e) = email_client .send_guest_purchase_confirmation( &email_addr, &item_title, "Free", &dl_url, &cl_url, ) .await { tracing::error!(error = ?e, "failed to send free guest claim email"); } }); } let mut response = Json(serde_json::json!({ "status": "claimed", "download_url": download_url, })) .into_response(); // CORS headers let headers = response.headers_mut(); headers.insert( header::ACCESS_CONTROL_ALLOW_ORIGIN, HeaderValue::from_static("*"), ); Ok(response) }