//! Project-level checkout handler. use axum::{ Form, extract::{Path, State}, response::{IntoResponse, Redirect, Response}, }; use serde::Deserialize; use crate::{ Billing, auth::AuthUser, config::Config, db::{self, Cents}, error::{AppError, Result, ResultExt}, pricing::{self, CheckoutType}, }; use sqlx::PgPool; /// 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 dollars, exactly as typed into the paywall /// box. The wire unit is dollars because the label the buyer reads says /// dollars; the conversion to cents happens once, in /// [`ProjectCheckoutForm::amount_cents`], right before the amount is /// validated and charged. amount_dollars: Option, } impl ProjectCheckoutForm { /// The buyer-chosen PWYW amount in cents, or `None` when the field was not /// submitted at all. /// /// Goes through `pricing::parse_dollars_to_cents`, the one canonical /// dollars-to-cents conversion, so "5" is 500 cents rather than 5. fn amount_cents(&self) -> Result> { self.amount_dollars .as_deref() .map(|raw| pricing::parse_dollars_to_cents("Amount", Some(raw))) .transpose() } } /// 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(db): State, State(payments): State, State(config): 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(&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(&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(&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, seller.settlement_currency) .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( &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(&db, user.id, seller_id) .await .context("clear contact revocation on free project claim")?; } return Ok(Redirect::to(&format!("/p/{}", project.slug)).into_response()); } // Reject sub-minimum non-zero charges (Stripe rejects <50ยข) with a friendly // error before the session call, matching the item and cart checkout paths. crate::payments::check_min_charge(base_price_cents as i64, seller.settlement_currency)?; // Stripe checkout 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 .payments .as_ref() .ok_or_else(|| AppError::BadRequest("Stripe is not configured".to_string()))?; let success_url = format!( "{}/stripe/success?session_id={{CHECKOUT_SESSION_ID}}", config.host_url ); let cancel_url = format!("{}/p/{}", 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, currency: seller.settlement_currency, conversion: user.conversion_preference, }; let session = stripe.create_checkout_session(&checkout_params).await?; match db::transactions::create_transaction( &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, platform_credit_cents: 0, // project subscriptions carry no platform-wide credit }, ) .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()) } #[cfg(test)] mod tests { //! The unit conversion on the PWYW paywall. The box is labelled dollars and //! the wire carries dollars, so the handler owns the one multiplication that //! turns what the buyer typed into what Stripe charges. Cents on the wire //! under a dollars label charges a buyer who types 100 a single dollar. use super::*; fn form(amount: Option<&str>) -> ProjectCheckoutForm { ProjectCheckoutForm { share_contact: false, amount_dollars: amount.map(str::to_string), } } #[test] fn a_whole_dollar_figure_becomes_cents() { assert_eq!(form(Some("100")).amount_cents().unwrap(), Some(10_000)); assert_eq!(form(Some("5")).amount_cents().unwrap(), Some(500)); assert_eq!(form(Some("1")).amount_cents().unwrap(), Some(100)); } #[test] fn cents_typed_after_the_point_survive() { assert_eq!(form(Some("9.99")).amount_cents().unwrap(), Some(999)); assert_eq!(form(Some("0.50")).amount_cents().unwrap(), Some(50)); assert_eq!(form(Some("1250.05")).amount_cents().unwrap(), Some(125_005)); } #[test] fn a_missing_field_is_distinct_from_an_empty_one() { // Absent: the caller raises "Amount is required". Empty: zero, which the // pricing model accepts only when the minimum is $0. assert_eq!(form(None).amount_cents().unwrap(), None); assert_eq!(form(Some("")).amount_cents().unwrap(), Some(0)); } #[test] fn junk_is_refused_rather_than_charged() { for raw in ["abc", "-5", "NaN", "inf"] { assert!( form(Some(raw)).amount_cents().is_err(), "{raw} must not reach the charge" ); } } }