Skip to main content

max / makenotwork

6.1 KB · 142 lines History Blame Raw
1 //! Stripe Connect Account Links flow for creator onboarding.
2
3 use axum::{
4 Json,
5 extract::State,
6 response::{IntoResponse, Response},
7 };
8 use serde::Serialize;
9 use tower_sessions::Session;
10
11 use crate::{
12 Billing,
13 auth::AuthUser,
14 config::Config,
15 csrf, db,
16 error::{AppError, Result},
17 templates::StripeConnectDisclaimerTemplate,
18 };
19 use sqlx::PgPool;
20
21 /// GET /stripe/connect: Show disclaimer page before Stripe onboarding.
22 #[tracing::instrument(skip_all, name = "stripe::connect_disclaimer")]
23 pub(super) async fn stripe_connect_disclaimer(
24 session: Session,
25 AuthUser(_user): AuthUser,
26 ) -> Result<Response> {
27 let csrf_token = csrf::get_or_create_token(&session).await.ok();
28 Ok(StripeConnectDisclaimerTemplate { csrf_token }.into_response())
29 }
30
31 /// POST /stripe/connect/proceed: Create connected account (if needed) and
32 /// return the Stripe-hosted onboarding URL.
33 ///
34 /// Returns JSON with the URL instead of a redirect because `fetch()` cannot
35 /// follow cross-origin redirects (Stripe doesn't send CORS headers).
36 #[tracing::instrument(skip_all, name = "stripe::connect_proceed")]
37 pub(super) async fn stripe_connect_proceed(
38 State(db): State<PgPool>,
39 State(payments): State<Billing>,
40 State(config): State<Config>,
41 AuthUser(user): AuthUser,
42 ) -> Result<Response> {
43 user.check_not_sandbox()?;
44 let stripe = payments
45 .stripe
46 .as_ref()
47 .ok_or_else(|| AppError::BadRequest("Stripe is not configured".to_string()))?;
48
49 // If the user already has a stripe_account_id (incomplete onboarding),
50 // reuse it instead of creating a new account.
51 let existing = db::users::get_user_by_id(&db, user.id)
52 .await?
53 .ok_or_else(|| AppError::BadRequest("User not found".to_string()))?;
54 let stripe_account_id = if let Some(acct_id) =
55 existing.stripe_account_id.filter(|s| !s.is_empty())
56 {
57 acct_id
58 } else {
59 let acct_id = stripe.create_connect_account(&user.email).await?;
60 tracing::info!(user_id = %user.id, stripe_account_id = %acct_id, "created stripe connected account");
61
62 // Atomically claim the stripe_account_id slot. The WHERE clause
63 // ensures only one concurrent request can set it; a second request
64 // that races past the NULL-check above will get None back instead
65 // of creating a duplicate Stripe account entry.
66 if let Some(_updated) = db::users::try_set_stripe_account(&db, user.id, &acct_id).await? {
67 acct_id
68 } else {
69 // Another request won the race, the Stripe account we just
70 // created is orphaned. Standard accounts are NOT auto-cleaned
71 // by Stripe, so log at error level for manual cleanup via the
72 // Stripe dashboard.
73 tracing::error!(
74 user_id = %user.id,
75 orphaned_account = %acct_id,
76 "stripe connect race: orphaned account created, delete manually in Stripe dashboard"
77 );
78 db::users::get_user_by_id(&db, user.id)
79 .await?
80 .and_then(|u| u.stripe_account_id.filter(|s| !s.is_empty()))
81 .ok_or_else(|| {
82 AppError::Internal(anyhow::anyhow!("stripe_account_id disappeared after race"))
83 })?
84 }
85 };
86
87 let return_url = format!("{}/stripe/connect/return", config.host_url);
88 let refresh_url = format!("{}/stripe/connect/refresh", config.host_url);
89
90 let link_url = stripe
91 .create_account_link(&stripe_account_id, &return_url, &refresh_url)
92 .await?;
93
94 Ok(Json(ConnectProceedResponse { url: link_url }).into_response())
95 }
96
97 #[derive(Serialize)]
98 struct ConnectProceedResponse {
99 url: String,
100 }
101
102 /// GET /stripe/connect/return: Creator finished (or left) Stripe onboarding.
103 ///
104 /// The actual onboarding status is determined by the `account.updated` webhook,
105 /// not by the user landing here. The dashboard payments tab shows the real
106 /// status (complete, pending review, action required) once it loads.
107 ///
108 /// No `AuthUser` guard: the browser arrives here via cross-site navigation from
109 /// Stripe, so the handler must render for a request it cannot assume is
110 /// authenticated. It returns a minimal HTML page that does a client-side
111 /// `window.location`, landing the creator on the dashboard.
112 #[tracing::instrument(skip_all, name = "stripe::connect_return")]
113 pub(super) async fn stripe_connect_return() -> axum::response::Html<&'static str> {
114 axum::response::Html(concat!(
115 r#"<!DOCTYPE html><html><head><meta charset="utf-8"><title>Stripe Setup</title></head>"#,
116 r#"<body style="font-family:system-ui,sans-serif;margin:2rem;color:#666;">"#,
117 r#"<p>Stripe setup complete. Redirecting to your dashboard&hellip;</p>"#,
118 r#"<p style="font-size:0.9rem;color:#999;">Your payment status will appear on the Payments tab. "#,
119 r#"If Stripe needs additional information, you'll see instructions there.</p>"#,
120 r#"<script>window.location.replace("/dashboard?tab=payments&stripe_connected=true");</script>"#,
121 r#"</body></html>"#,
122 ))
123 }
124
125 /// GET /stripe/connect/refresh: Account Link expired or was already used.
126 ///
127 /// Stripe redirects here cross-site. Uses the same shape as `connect_return`:
128 /// minimal HTML that does `window.location.replace()` back into `/stripe/connect`
129 /// to restart setup.
130 #[tracing::instrument(skip_all, name = "stripe::connect_refresh")]
131 pub(super) async fn stripe_connect_refresh() -> axum::response::Html<&'static str> {
132 axum::response::Html(concat!(
133 r#"<!DOCTYPE html><html><head><meta charset="utf-8"><title>Redirecting...</title></head><body>"#,
134 r#"<p style="font-family:system-ui,sans-serif;margin:2rem;color:#666;">"#,
135 r#"Your Stripe session expired or was interrupted. Redirecting to retry&hellip;</p>"#,
136 r#"<p style="font-family:system-ui,sans-serif;margin:2rem;color:#999;font-size:0.9rem;">"#,
137 r#"If you keep seeing this, <a href="/stripe/connect">click here</a> to restart setup.</p>"#,
138 r#"<script>window.location.replace("/stripe/connect");</script>"#,
139 r#"</body></html>"#,
140 ))
141 }
142