//! Tip checkout session creation. use axum::{ extract::{Path, State}, http::HeaderMap, response::{IntoResponse, Redirect}, Form, }; use serde::Deserialize; use tower_sessions::Session; use crate::{ auth::AuthUser, csrf, db::{self, Cents}, error::{AppError, Result}, payments, AppState, }; /// Form data for tip checkout. #[derive(Debug, Deserialize)] pub(in crate::routes::stripe) struct TipForm { /// Tip amount in whole dollars (converted to cents internally). pub amount_dollars: i32, /// Optional short message (max 280 chars). pub message: Option, /// Project ID if tipping from a project page. pub project_id: Option, /// CSRF token. The `/stripe/checkout` prefix is broadly exempt because /// most routes there only construct a Stripe session URL (state lives /// post-webhook), but this tip route inserts a `pending_tip` row BEFORE /// the Stripe call, so it gets the explicit check the rest of the /// family doesn't. The tip form already renders this field. #[serde(rename = "_csrf")] pub csrf: Option, } /// POST /stripe/checkout/tip/{recipient_id} - Create a tip checkout session #[tracing::instrument(skip_all, name = "stripe_checkout::create_tip_checkout")] pub(in crate::routes::stripe) async fn create_tip_checkout( State(state): State, AuthUser(user): AuthUser, session: Session, headers: HeaderMap, Path(recipient_id): Path, Form(form): Form, ) -> Result { // Registered with `post_csrf_manual` because this handler inserts a // `pending_tip` row before the Stripe call — the broad `/stripe/checkout` // skip would let an attacker plant rows. Match the standard validator's // header-then-form precedence so HTMX callers and vanilla form posts // both pass. `validate_token_consuming` returns the sealed witness on // success; the binding is `_` because the witness exists only to prove // the check happened, not to be passed downstream. let token = csrf::extract_token_from_request(&headers, form.csrf.as_deref()) .unwrap_or_default(); let _validated = csrf::validate_token_consuming(&session, &token).await?; user.check_not_sandbox()?; user.check_not_suspended()?; let stripe = state.stripe.as_ref() .ok_or_else(|| AppError::BadRequest("Stripe is not configured".to_string()))?; // Parse recipient ID let recipient_id: db::UserId = recipient_id.parse::() .map(db::UserId::from) .map_err(|_| AppError::BadRequest("Invalid recipient ID".to_string()))?; // Can't tip yourself if recipient_id == user.id { return Err(AppError::BadRequest("You cannot tip yourself".to_string())); } // Convert dollars to cents and validate ($1 minimum, $10,000 maximum) if form.amount_dollars < 1 { return Err(AppError::BadRequest("Minimum tip amount is $1.00".to_string())); } if form.amount_dollars > 10_000 { return Err(AppError::BadRequest("Maximum tip amount is $10,000".to_string())); } let amount_cents = form.amount_dollars * 100; // Get recipient let recipient = db::users::get_user_by_id(&state.db, recipient_id) .await? .ok_or(AppError::NotFound)?; if recipient.is_suspended() || recipient.is_deactivated() || recipient.is_creator_paused() { return Err(AppError::BadRequest("This creator's account is not active".to_string())); } // Check tips are enabled if !recipient.tips_enabled { return Err(AppError::BadRequest("This creator is not accepting tips".to_string())); } // Check recipient has Stripe connected let stripe_account_id = recipient.stripe_account_id.as_deref() .ok_or_else(|| AppError::BadRequest("Creator has not connected payments".to_string()))?; if !recipient.stripe_charges_enabled { return Err(AppError::BadRequest("Creator's payment account is not active".to_string())); } // Parse project_id if present, then verify the project actually belongs to // the tip recipient. Otherwise an attacker tipping creator A can pass an // unrelated project B's UUID; B's project_members would be credited splits // against A's tip on the webhook side. let project_id: Option = match form.project_id.as_deref() .and_then(|s| s.parse::().ok().map(db::ProjectId::from)) { Some(pid) => { let project = db::projects::get_project_by_id(&state.db, pid) .await? .ok_or_else(|| AppError::BadRequest("Project not found".to_string()))?; if project.user_id != recipient_id { return Err(AppError::BadRequest( "Project does not belong to this creator".to_string(), )); } Some(pid) } None => None, }; // Truncate message let message = form.message.as_deref() .map(|m| m.chars().take(280).collect::()); let display_name = recipient.display_name.as_deref() .unwrap_or(&recipient.username); let success_url = format!( "{}/stripe/success?session_id={{CHECKOUT_SESSION_ID}}", state.config.host_url ); let cancel_url = format!("{}/u/{}", state.config.host_url, recipient.username); // Create checkout session let session = stripe.create_tip_checkout_session(&payments::TipCheckoutParams { connected_account_id: stripe_account_id, recipient_display_name: display_name, amount_cents: Cents::new(amount_cents as i64), tipper_id: user.id, recipient_id, project_id, message: message.as_deref(), success_url: &success_url, cancel_url: &cancel_url, enable_stripe_tax: recipient.stripe_tax_enabled, }).await?; // Record pending tip let session_id = session.id; db::tips::create_tip( &state.db, user.id, recipient_id, project_id, amount_cents, message.as_deref(), &session_id, ).await?; // 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)) }