//! Project-level checkout handler. use axum::{ extract::{Path, State}, response::{IntoResponse, Redirect, Response}, Form, }; use serde::Deserialize; use crate::{ auth::AuthUser, db::{self, Cents}, error::{AppError, Result, ResultExt}, pricing::{self, CheckoutType}, AppState, }; /// Form data for project checkout. #[derive(Debug, Deserialize)] pub(in crate::routes::stripe) struct ProjectCheckoutForm { #[serde(default)] share_contact: bool, /// PWYW: buyer-chosen amount in cents. amount_cents: Option, } /// POST /stripe/checkout/project/{project_id}: Purchase project-level access. #[tracing::instrument(skip_all, name = "stripe::project_checkout")] pub(in crate::routes::stripe) async fn create_project_checkout( State(state): State, AuthUser(user): AuthUser, Path(project_id): Path, Form(form): Form, ) -> Result { user.check_not_suspended()?; user.check_not_sandbox()?; let project_uuid: db::ProjectId = project_id .parse() .map_err(|_| AppError::NotFound)?; let project = db::projects::get_project_by_id(&state.db, project_uuid) .await? .ok_or(AppError::NotFound)?; if !project.is_public { return Err(AppError::BadRequest( "This project is not available for purchase".to_string(), )); } let project_pricing = pricing::for_project(&project); if project_pricing.checkout_type() == CheckoutType::None { return Err(AppError::BadRequest("This project is free".to_string())); } // Check if already purchased if db::transactions::has_purchased_project(&state.db, user.id, project_uuid).await? { return Ok(Redirect::to(&format!("/p/{}", project.slug)).into_response()); } let seller_id = project.user_id; if user.id == seller_id { return Err(AppError::BadRequest( "You cannot purchase your own project".to_string(), )); } let seller = db::users::get_user_by_id(&state.db, seller_id) .await? .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 price let base_price_cents = if project_pricing.checkout_type() == CheckoutType::PayWhatYouWant { let amount = form.amount_cents.ok_or_else(|| { AppError::BadRequest("Amount is required for pay-what-you-want projects".to_string()) })?; project_pricing .validate_amount(amount) .map_err(AppError::BadRequest)?; amount } else { project_pricing.price_cents() }; // If price is $0 (PWYW with $0 min), record a free claim if base_price_cents == 0 { let claimed = db::transactions::claim_free_project( &state.db, user.id, seller_id, project_uuid, &project.title, &seller.username, form.share_contact, ) .await?; // Gate downstream side-effects on the winner of a concurrent-claim race. // Without this, two concurrent free-project claims both fire the contact // clear (and any future sale-notification email / split recording). // Wire the same downstream effects paid project checkouts get — free // PWYW purchases were previously silently un-instrumented (no contact // revocation clear, no sale notification email). if claimed && form.share_contact { db::transactions::clear_contact_revocation(&state.db, user.id, seller_id) .await .context("clear contact revocation on free project claim")?; } return Ok(Redirect::to(&format!("/p/{}", project.slug)).into_response()); } // Stripe checkout 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 success_url = format!( "{}/stripe/success?session_id={{CHECKOUT_SESSION_ID}}", state.config.host_url ); let cancel_url = format!("{}/p/{}", state.config.host_url, project.slug); let checkout_params = crate::payments::CheckoutParams { connected_account_id: stripe_account_id, item_title: &project.title, amount_cents: Cents::new(base_price_cents as i64), buyer_id: user.id, seller_id, item_id: None, // project-level purchase, no specific item success_url: &success_url, cancel_url: &cancel_url, promo_code_id: None, enable_stripe_tax: seller.stripe_tax_enabled, }; let session = stripe.create_checkout_session(&checkout_params).await?; match db::transactions::create_transaction( &state.db, &db::transactions::CreateTransactionParams { buyer_id: Some(user.id), seller_id, item_id: None, amount_cents: base_price_cents.into(), platform_fee_cents: Cents::ZERO, stripe_checkout_session_id: &session.id, item_title: &project.title, seller_username: &seller.username, share_contact: form.share_contact, project_id: Some(project_uuid), promo_code_id: None, guest_email: None, }, ) .await { Ok(_) => {} Err(AppError::Database(sqlx::Error::Database(ref db_err))) if db_err.code().as_deref() == Some("23505") => { tracing::info!(buyer_id = %user.id, project_id = %project_uuid, "duplicate pending project checkout blocked"); return Ok(Redirect::to(&format!("/p/{}", project_id)).into_response()); } Err(e) => return Err(e), } let checkout_url = session .url .ok_or_else(|| AppError::BadRequest("No checkout URL returned".to_string()))?; Ok(Redirect::to(&checkout_url).into_response()) }