Skip to main content

max / makenotwork

6.2 KB · 145 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 // The provider hands back an opaque id; the column is Stripe-shaped, so
60 // the `acct_` check runs here, where the two meet.
61 let acct_id =
62 db::StripeAccountId::new(stripe.create_connect_account(&user.email).await?.as_str())?;
63 tracing::info!(user_id = %user.id, stripe_account_id = %acct_id, "created stripe connected account");
64
65 // Atomically claim the stripe_account_id slot. The WHERE clause
66 // ensures only one concurrent request can set it; a second request
67 // that races past the NULL-check above will get None back instead
68 // of creating a duplicate Stripe account entry.
69 if let Some(_updated) = db::users::try_set_stripe_account(&db, user.id, &acct_id).await? {
70 acct_id
71 } else {
72 // Another request won the race, the Stripe account we just
73 // created is orphaned. Standard accounts are NOT auto-cleaned
74 // by Stripe, so log at error level for manual cleanup via the
75 // Stripe dashboard.
76 tracing::error!(
77 user_id = %user.id,
78 orphaned_account = %acct_id,
79 "stripe connect race: orphaned account created, delete manually in Stripe dashboard"
80 );
81 db::users::get_user_by_id(&db, user.id)
82 .await?
83 .and_then(|u| u.stripe_account_id.filter(|s| !s.is_empty()))
84 .ok_or_else(|| {
85 AppError::Internal(anyhow::anyhow!("stripe_account_id disappeared after race"))
86 })?
87 }
88 };
89
90 let return_url = format!("{}/stripe/connect/return", config.host_url);
91 let refresh_url = format!("{}/stripe/connect/refresh", config.host_url);
92
93 let link_url = stripe
94 .create_account_link(&stripe_account_id, &return_url, &refresh_url)
95 .await?;
96
97 Ok(Json(ConnectProceedResponse { url: link_url }).into_response())
98 }
99
100 #[derive(Serialize)]
101 struct ConnectProceedResponse {
102 url: String,
103 }
104
105 /// GET /stripe/connect/return: Creator finished (or left) Stripe onboarding.
106 ///
107 /// The actual onboarding status is determined by the `account.updated` webhook,
108 /// not by the user landing here. The dashboard payments tab shows the real
109 /// status (complete, pending review, action required) once it loads.
110 ///
111 /// No `AuthUser` guard: the browser arrives here via cross-site navigation from
112 /// Stripe, so the handler must render for a request it cannot assume is
113 /// authenticated. It returns a minimal HTML page that does a client-side
114 /// `window.location`, landing the creator on the dashboard.
115 #[tracing::instrument(skip_all, name = "stripe::connect_return")]
116 pub(super) async fn stripe_connect_return() -> axum::response::Html<&'static str> {
117 axum::response::Html(concat!(
118 r#"<!DOCTYPE html><html><head><meta charset="utf-8"><title>Stripe Setup</title></head>"#,
119 r#"<body style="font-family:system-ui,sans-serif;margin:2rem;color:#666;">"#,
120 r#"<p>Stripe setup complete. Redirecting to your dashboard&hellip;</p>"#,
121 r#"<p style="font-size:0.9rem;color:#999;">Your payment status will appear on the Payments tab. "#,
122 r#"If Stripe needs additional information, you'll see instructions there.</p>"#,
123 r#"<script>window.location.replace("/dashboard?tab=payments&stripe_connected=true");</script>"#,
124 r#"</body></html>"#,
125 ))
126 }
127
128 /// GET /stripe/connect/refresh: Account Link expired or was already used.
129 ///
130 /// Stripe redirects here cross-site. Uses the same shape as `connect_return`:
131 /// minimal HTML that does `window.location.replace()` back into `/stripe/connect`
132 /// to restart setup.
133 #[tracing::instrument(skip_all, name = "stripe::connect_refresh")]
134 pub(super) async fn stripe_connect_refresh() -> axum::response::Html<&'static str> {
135 axum::response::Html(concat!(
136 r#"<!DOCTYPE html><html><head><meta charset="utf-8"><title>Redirecting...</title></head><body>"#,
137 r#"<p style="font-family:system-ui,sans-serif;margin:2rem;color:#666;">"#,
138 r#"Your Stripe session expired or was interrupted. Redirecting to retry&hellip;</p>"#,
139 r#"<p style="font-family:system-ui,sans-serif;margin:2rem;color:#999;font-size:0.9rem;">"#,
140 r#"If you keep seeing this, <a href="/stripe/connect">click here</a> to restart setup.</p>"#,
141 r#"<script>window.location.replace("/stripe/connect");</script>"#,
142 r#"</body></html>"#,
143 ))
144 }
145