//! Stripe Connect Account Links flow for creator onboarding. use axum::{ extract::State, response::{IntoResponse, Response}, Json, }; use serde::Serialize; use tower_sessions::Session; use crate::{ auth::AuthUser, csrf, db, error::{AppError, Result}, templates::StripeConnectDisclaimerTemplate, AppState, }; /// GET /stripe/connect: Show disclaimer page before Stripe onboarding. #[tracing::instrument(skip_all, name = "stripe::connect_disclaimer")] pub(super) async fn stripe_connect_disclaimer( session: Session, AuthUser(_user): AuthUser, ) -> Result { let csrf_token = csrf::get_or_create_token(&session).await.ok(); Ok(StripeConnectDisclaimerTemplate { csrf_token }.into_response()) } /// POST /stripe/connect/proceed: Create connected account (if needed) and /// return the Stripe-hosted onboarding URL. /// /// Returns JSON with the URL instead of a redirect because `fetch()` cannot /// follow cross-origin redirects (Stripe doesn't send CORS headers). #[tracing::instrument(skip_all, name = "stripe::connect_proceed")] pub(super) async fn stripe_connect_proceed( State(state): State, AuthUser(user): AuthUser, ) -> Result { user.check_not_sandbox()?; let stripe = state.stripe.as_ref() .ok_or_else(|| AppError::BadRequest("Stripe is not configured".to_string()))?; // If the user already has a stripe_account_id (incomplete onboarding), // reuse it instead of creating a new account. let existing = db::users::get_user_by_id(&state.db, user.id) .await? .ok_or_else(|| AppError::BadRequest("User not found".to_string()))?; let stripe_account_id = if let Some(acct_id) = existing.stripe_account_id.filter(|s| !s.is_empty()) { acct_id } else { let acct_id = stripe.create_connect_account(&user.email).await?; tracing::info!(user_id = %user.id, stripe_account_id = %acct_id, "created stripe connected account"); // Atomically claim the stripe_account_id slot. The WHERE clause // ensures only one concurrent request can set it; a second request // that races past the NULL-check above will get None back instead // of creating a duplicate Stripe account entry. if let Some(_updated) = db::users::try_set_stripe_account( &state.db, user.id, &acct_id, ).await? { acct_id } else { // Another request won the race — the Stripe account we just // created is orphaned. Standard accounts are NOT auto-cleaned // by Stripe, so log at error level for manual cleanup via the // Stripe dashboard. tracing::error!( user_id = %user.id, orphaned_account = %acct_id, "stripe connect race: orphaned account created — delete manually in Stripe dashboard" ); db::users::get_user_by_id(&state.db, user.id) .await? .and_then(|u| u.stripe_account_id.filter(|s| !s.is_empty())) .ok_or_else(|| AppError::Internal( anyhow::anyhow!("stripe_account_id disappeared after race"), ))? } }; let return_url = format!("{}/stripe/connect/return", state.config.host_url); let refresh_url = format!("{}/stripe/connect/refresh", state.config.host_url); let link_url = stripe .create_account_link(&stripe_account_id, &return_url, &refresh_url) .await?; Ok(Json(ConnectProceedResponse { url: link_url }).into_response()) } #[derive(Serialize)] struct ConnectProceedResponse { url: String, } /// GET /stripe/connect/return: Creator finished (or left) Stripe onboarding. /// /// The actual onboarding status is determined by the `account.updated` webhook, /// not by the user landing here. The dashboard payments tab shows the real /// status (complete, pending review, action required) once it loads. /// /// No `AuthUser` guard and no server-side redirect; the browser arrives here /// via cross-site navigation from Stripe, and `SameSite=Strict` cookies are not /// sent on cross-site navigations (including server redirects that follow one). /// Instead, we return a minimal HTML page that does a client-side /// `window.location`; this initiates a fresh same-site navigation where the /// browser will include the session cookie. #[tracing::instrument(skip_all, name = "stripe::connect_return")] pub(super) async fn stripe_connect_return() -> axum::response::Html<&'static str> { axum::response::Html(concat!( r#"Stripe Setup"#, r#""#, r#"

Stripe setup complete. Redirecting to your dashboard…

"#, r#"

Your payment status will appear on the Payments tab. "#, r#"If Stripe needs additional information, you'll see instructions there.

"#, r#""#, r#""#, )) } /// GET /stripe/connect/refresh: Account Link expired or was already used. /// /// Stripe redirects here cross-site, and `SameSite=Strict` cookies won't be /// present on a server-side redirect. Use the same client-side redirect /// pattern as `connect_return`; return minimal HTML that does /// `window.location.replace()` to initiate a fresh same-site navigation /// where the browser will include the session cookie. #[tracing::instrument(skip_all, name = "stripe::connect_refresh")] pub(super) async fn stripe_connect_refresh() -> axum::response::Html<&'static str> { axum::response::Html(concat!( r#"Redirecting..."#, r#"

"#, r#"Your Stripe session expired or was interrupted. Redirecting to retry…

"#, r#"

"#, r#"If you keep seeing this, click here to restart setup.

"#, r#""#, r#""#, )) }