Skip to main content

max / makenotwork

6.3 KB · 146 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 and no server-side redirect; the browser arrives here
109 /// via cross-site navigation from Stripe, and `SameSite=Strict` cookies are not
110 /// sent on cross-site navigations (including server redirects that follow one).
111 /// Instead, we return a minimal HTML page that does a client-side
112 /// `window.location`; this initiates a fresh same-site navigation where the
113 /// browser will include the session cookie.
114 #[tracing::instrument(skip_all, name = "stripe::connect_return")]
115 pub(super) async fn stripe_connect_return() -> axum::response::Html<&'static str> {
116 axum::response::Html(concat!(
117 r#"<!DOCTYPE html><html><head><meta charset="utf-8"><title>Stripe Setup</title></head>"#,
118 r#"<body style="font-family:system-ui,sans-serif;margin:2rem;color:#666;">"#,
119 r#"<p>Stripe setup complete. Redirecting to your dashboard&hellip;</p>"#,
120 r#"<p style="font-size:0.9rem;color:#999;">Your payment status will appear on the Payments tab. "#,
121 r#"If Stripe needs additional information, you'll see instructions there.</p>"#,
122 r#"<script>window.location.replace("/dashboard?tab=payments&stripe_connected=true");</script>"#,
123 r#"</body></html>"#,
124 ))
125 }
126
127 /// GET /stripe/connect/refresh: Account Link expired or was already used.
128 ///
129 /// Stripe redirects here cross-site, and `SameSite=Strict` cookies won't be
130 /// present on a server-side redirect. Use the same client-side redirect
131 /// pattern as `connect_return`; return minimal HTML that does
132 /// `window.location.replace()` to initiate a fresh same-site navigation
133 /// where the browser will include the session cookie.
134 #[tracing::instrument(skip_all, name = "stripe::connect_refresh")]
135 pub(super) async fn stripe_connect_refresh() -> axum::response::Html<&'static str> {
136 axum::response::Html(concat!(
137 r#"<!DOCTYPE html><html><head><meta charset="utf-8"><title>Redirecting...</title></head><body>"#,
138 r#"<p style="font-family:system-ui,sans-serif;margin:2rem;color:#666;">"#,
139 r#"Your Stripe session expired or was interrupted. Redirecting to retry&hellip;</p>"#,
140 r#"<p style="font-family:system-ui,sans-serif;margin:2rem;color:#999;font-size:0.9rem;">"#,
141 r#"If you keep seeing this, <a href="/stripe/connect">click here</a> to restart setup.</p>"#,
142 r#"<script>window.location.replace("/stripe/connect");</script>"#,
143 r#"</body></html>"#,
144 ))
145 }
146