//! 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::{ extract::{Path, State}, http::{header, HeaderValue, StatusCode}, response::{IntoResponse, Redirect, Response}, Json, }; use serde::{Deserialize, Serialize}; use uuid::Uuid; use crate::{ db::{self, Cents, ItemId}, error::{AppError, Result, ResultExt}, AppState, }; /// 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(state): State, Path(item_id): Path, Json(body): Json, ) -> Result { // Fetch item let item = db::items::get_item_by_id(&state.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(&state.db, item.project_id) .await? .ok_or(AppError::NotFound)?; let seller = db::users::get_user_by_id(&state.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) .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(ref code_str) = body.promo_code { let code_str = code_str.trim().to_uppercase(); if !code_str.is_empty() { if item.pwyw_enabled { return Err(AppError::BadRequest("Promo codes cannot be applied to pay-what-you-want items".to_string())); } let pc = db::promo_codes::get_promo_code_by_creator_and_code(&state.db, seller_id, &code_str) .await? .ok_or_else(|| AppError::BadRequest("Invalid promo code".to_string()))?; if pc.code_purpose == db::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())); } if let Some(scoped_item) = pc.item_id && scoped_item != item_id { return Err(AppError::BadRequest("This promo code is not valid for this item".to_string())); } if let Some(scoped_project) = pc.project_id && item.project_id != scoped_project { return Err(AppError::BadRequest("This promo code is not valid for this item".to_string())); } // Apply discount to final price if pc.code_purpose == db::CodePurpose::FreeAccess { final_price_cents = 0; } else if pc.code_purpose == db::CodePurpose::Discount { if item.price_cents < pc.min_price_cents { return Err(AppError::BadRequest("This item does not meet the minimum price for this code".to_string())); } // Fourth copy of this pattern in the codebase; the prior three // (item.rs, cart.rs ×2) reject NULL discount_type/value to avoid // burning a promo use against full price. Same treatment 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(), )), }; final_price_cents = db::promo_codes::apply_discount(item.price_cents, dt, dv); } promo_code_id = Some(pc.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(), )); } // Verify seller has Stripe configured 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()))?; // Build URLs let success_url = format!("{}/stripe/success?session_id={{CHECKOUT_SESSION_ID}}", state.config.host_url); let cancel_url = format!("{}/i/{}", state.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, }; let result = stripe.create_guest_checkout_session(&checkout_params) .await .with_context(|| format!("create guest Stripe checkout for item {item_id}"))?; // Create pending transaction (buyer_id = None for guest) match db::transactions::create_transaction( &state.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 }, ).await { Ok(_) => {} Err(AppError::Database(sqlx::Error::Database(ref db_err))) if db_err.code().as_deref() == Some("23505") => { tracing::info!(item_id = %item_id, "duplicate pending guest checkout blocked"); } Err(e) => return Err(e).context("create pending guest transaction"), } // Reserve promo code use AFTER pending transaction exists, so the cleanup // scheduler can find and release it if the checkout is abandoned. 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 guest checkout")?; if !reserved { return Err(AppError::BadRequest("This promo code has reached its usage limit".to_string())); } } 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(state): State, Path(token): Path, ) -> Result { let tx = db::transactions::get_transaction_by_download_token(&state.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(&state.db, item_id) .await? .ok_or(AppError::NotFound)?; // Get the S3 key for the content let s3_key = item.audio_s3_key.as_deref() .or(item.video_s3_key.as_deref()) .ok_or_else(|| AppError::NotFound)?; let s3 = state.s3.as_ref() .ok_or_else(|| AppError::ServiceUnavailable("File storage is not configured".to_string()))?; let download_url = s3.presign_download(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(state): State, crate::auth::AuthUser(user): crate::auth::AuthUser, Json(body): Json, ) -> Result { user.check_not_sandbox()?; let tx = db::transactions::claim_guest_purchase(&state.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" ); 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(state): 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(&state.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(&state.db, item.project_id) .await? .ok_or(AppError::NotFound)?; let seller = db::users::get_user_by_id(&state.db, project.user_id) .await? .ok_or(AppError::NotFound)?; if seller.is_suspended() || seller.is_deactivated() || seller.is_creator_paused() { return Err(AppError::NotFound); } // Check if email matches an existing user — auto-attach let existing_user_id = db::users::get_verified_user_id_by_email(&state.db, &email).await?; let claim_token = if existing_user_id.is_some() { None } else { Some(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( &state.db, existing_user_id, seller.id, item_id, &checkout_session_id, &item.title, &seller.username, email.as_str(), claim_token, download_token, ) .await; match result { Ok(0) => { // Already claimed — still send the email with download link } Ok(_) => { // Increment sales count let _ = db::items::increment_sales_count(&state.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 = &state.config.host_url; let download_url = format!("{}/download/{}", host_url, download_token); let claim_url = format!("{}/claim?token={}", host_url, claim_token.unwrap_or(db::ClaimToken::from_uuid(Uuid::nil()))); if existing_user_id.is_none() { let email_client = state.email.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.clone(); state.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) }