Skip to main content

max / makenotwork

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