Skip to main content

max / makenotwork

6.3 KB · 147 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 .payments
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 = payments
94 .payment_caps
95 .require_connect_onboarding()?
96 .create_account_link(&stripe_account_id, &return_url, &refresh_url)
97 .await?;
98
99 Ok(Json(ConnectProceedResponse { url: link_url }).into_response())
100 }
101
102 #[derive(Serialize)]
103 struct ConnectProceedResponse {
104 url: String,
105 }
106
107 /// GET /stripe/connect/return: Creator finished (or left) Stripe onboarding.
108 ///
109 /// The actual onboarding status is determined by the `account.updated` webhook,
110 /// not by the user landing here. The dashboard payments tab shows the real
111 /// status (complete, pending review, action required) once it loads.
112 ///
113 /// No `AuthUser` guard: the browser arrives here via cross-site navigation from
114 /// Stripe, so the handler must render for a request it cannot assume is
115 /// authenticated. It returns a minimal HTML page that does a client-side
116 /// `window.location`, landing the creator on the dashboard.
117 #[tracing::instrument(skip_all, name = "stripe::connect_return")]
118 pub(super) async fn stripe_connect_return() -> axum::response::Html<&'static str> {
119 axum::response::Html(concat!(
120 r#"<!DOCTYPE html><html><head><meta charset="utf-8"><title>Stripe Setup</title></head>"#,
121 r#"<body style="font-family:system-ui,sans-serif;margin:2rem;color:#666;">"#,
122 r#"<p>Stripe setup complete. Redirecting to your dashboard&hellip;</p>"#,
123 r#"<p style="font-size:0.9rem;color:#999;">Your payment status will appear on the Payments tab. "#,
124 r#"If Stripe needs additional information, you'll see instructions there.</p>"#,
125 r#"<script>window.location.replace("/dashboard?tab=payments&stripe_connected=true");</script>"#,
126 r#"</body></html>"#,
127 ))
128 }
129
130 /// GET /stripe/connect/refresh: Account Link expired or was already used.
131 ///
132 /// Stripe redirects here cross-site. Uses the same shape as `connect_return`:
133 /// minimal HTML that does `window.location.replace()` back into `/stripe/connect`
134 /// to restart setup.
135 #[tracing::instrument(skip_all, name = "stripe::connect_refresh")]
136 pub(super) async fn stripe_connect_refresh() -> axum::response::Html<&'static str> {
137 axum::response::Html(concat!(
138 r#"<!DOCTYPE html><html><head><meta charset="utf-8"><title>Redirecting...</title></head><body>"#,
139 r#"<p style="font-family:system-ui,sans-serif;margin:2rem;color:#666;">"#,
140 r#"Your Stripe session expired or was interrupted. Redirecting to retry&hellip;</p>"#,
141 r#"<p style="font-family:system-ui,sans-serif;margin:2rem;color:#999;font-size:0.9rem;">"#,
142 r#"If you keep seeing this, <a href="/stripe/connect">click here</a> to restart setup.</p>"#,
143 r#"<script>window.location.replace("/stripe/connect");</script>"#,
144 r#"</body></html>"#,
145 ))
146 }
147