Skip to main content

max / makenotwork

17.8 KB · 449 lines History Blame Raw
1 //! Subscription checkout handlers: Fan+, creator tiers, and project subscriptions.
2
3 use axum::{
4 extract::{Path, State},
5 response::{IntoResponse, Redirect, Response},
6 Form,
7 };
8 use serde::Deserialize;
9
10 use crate::{
11 auth::AuthUser,
12 db::{self, CodePurpose, PromoCodeId, SubscriptionTierId},
13 error::{AppError, Result, ResultExt},
14 AppState,
15 };
16
17 /// POST /stripe/fan-plus: Create a Fan+ subscription checkout and redirect
18 #[tracing::instrument(skip_all, name = "stripe::fan_plus_checkout")]
19 pub(in crate::routes::stripe) async fn create_fan_plus_checkout(
20 State(state): State<AppState>,
21 AuthUser(user): AuthUser,
22 ) -> Result<Response> {
23 user.check_not_suspended()?;
24 user.check_not_sandbox()?;
25
26 // Check Fan+ price is configured
27 let price_id = state.config.fan_plus_price_id.as_ref()
28 .ok_or_else(|| AppError::BadRequest("Fan+ is not configured".to_string()))?;
29
30 // Check not already a Fan+ subscriber
31 if db::fan_plus::is_fan_plus_active(&state.db, user.id).await? {
32 return Ok(Redirect::to("/fan-plus").into_response());
33 }
34
35 let stripe = state.stripe.as_ref()
36 .ok_or_else(|| AppError::BadRequest("Stripe is not configured".to_string()))?;
37
38 let success_url = format!("{}/fan-plus?subscribed=true", state.config.host_url);
39 let cancel_url = format!("{}/fan-plus", state.config.host_url);
40
41 let session = stripe.create_fan_plus_checkout_session(
42 price_id,
43 user.id,
44 &success_url,
45 &cancel_url,
46 ).await?;
47
48 let checkout_url = session.url
49 .ok_or_else(|| AppError::BadRequest("No checkout URL returned".to_string()))?;
50
51 Ok(Redirect::to(&checkout_url).into_response())
52 }
53
54 /// Reject the request if its `Sec-Fetch-Site` doesn't look like a real
55 /// click from our own dashboard.
56 ///
57 /// These two endpoints (`fan-plus/cancel` and `resume`) are exempted from
58 /// the global CSRF middleware because they're vanilla form posts that
59 /// redirect back to the dashboard — there's no place to attach a header
60 /// token. SameSite=Lax cookies block cross-site form posts already, but
61 /// Sec-Fetch-Site is the explicit second check we promise in the
62 /// CSRF-exempt rationale (see `csrf.rs`).
63 ///
64 /// Allow:
65 /// - `same-origin` — click from our own dashboard, exactly what we want
66 /// - missing — older browsers that don't send the header at all
67 /// Reject everything else (`cross-site`, `same-site`, `none`/typed-URL).
68 fn check_sec_fetch_site(headers: &axum::http::HeaderMap) -> Result<()> {
69 let Some(value) = headers.get("sec-fetch-site").and_then(|v| v.to_str().ok()) else {
70 return Ok(());
71 };
72 if value == "same-origin" {
73 return Ok(());
74 }
75 tracing::warn!(sec_fetch_site = value, "fan-plus subscription change rejected: bad Sec-Fetch-Site");
76 Err(AppError::Forbidden)
77 }
78
79 /// POST /stripe/fan-plus/cancel: Schedule Fan+ to cancel at period end.
80 ///
81 /// Self-service: leaves the subscription active through the current paid
82 /// period (no proration). The user can resume before period end to undo.
83 /// Stripe's `customer.subscription.updated` webhook keeps the local flag in
84 /// sync if the user later cancels via the customer portal instead.
85 #[tracing::instrument(skip_all, name = "stripe::fan_plus_cancel")]
86 pub(in crate::routes::stripe) async fn cancel_fan_plus(
87 State(state): State<AppState>,
88 headers: axum::http::HeaderMap,
89 AuthUser(user): AuthUser,
90 ) -> Result<Redirect> {
91 check_sec_fetch_site(&headers)?;
92 let sub = db::fan_plus::get_fan_plus_by_user(&state.db, user.id)
93 .await?
94 .ok_or_else(|| AppError::BadRequest("No active Fan+ subscription".to_string()))?;
95
96 let stripe = state.stripe.as_ref()
97 .ok_or_else(|| AppError::BadRequest("Stripe is not configured".to_string()))?;
98
99 stripe
100 .set_platform_cancel_at_period_end(&sub.stripe_subscription_id, true)
101 .await?;
102 db::fan_plus::set_cancel_at_period_end(&state.db, &sub.stripe_subscription_id, true).await?;
103
104 Ok(Redirect::to("/dashboard?tab=account&toast=Fan%2B+cancellation+scheduled"))
105 }
106
107 /// POST /stripe/fan-plus/resume: Undo a scheduled cancellation.
108 #[tracing::instrument(skip_all, name = "stripe::fan_plus_resume")]
109 pub(in crate::routes::stripe) async fn resume_fan_plus(
110 State(state): State<AppState>,
111 headers: axum::http::HeaderMap,
112 AuthUser(user): AuthUser,
113 ) -> Result<Redirect> {
114 check_sec_fetch_site(&headers)?;
115 let sub = db::fan_plus::get_fan_plus_by_user(&state.db, user.id)
116 .await?
117 .ok_or_else(|| AppError::BadRequest("No Fan+ subscription".to_string()))?;
118
119 let stripe = state.stripe.as_ref()
120 .ok_or_else(|| AppError::BadRequest("Stripe is not configured".to_string()))?;
121
122 stripe
123 .set_platform_cancel_at_period_end(&sub.stripe_subscription_id, false)
124 .await?;
125 db::fan_plus::set_cancel_at_period_end(&state.db, &sub.stripe_subscription_id, false).await?;
126
127 Ok(Redirect::to("/dashboard?tab=account&toast=Fan%2B+resumed"))
128 }
129
130 /// POST /stripe/billing-portal: Open the Stripe customer portal.
131 ///
132 /// Stripe-hosted: handles payment method updates, invoice history, and
133 /// (if configured in the dashboard) subscription cancellation. Routes from
134 /// the dashboard Fan+ pane.
135 #[tracing::instrument(skip_all, name = "stripe::billing_portal")]
136 pub(in crate::routes::stripe) async fn open_billing_portal(
137 State(state): State<AppState>,
138 AuthUser(user): AuthUser,
139 ) -> Result<Redirect> {
140 let sub = db::fan_plus::get_fan_plus_by_user(&state.db, user.id)
141 .await?
142 .ok_or_else(|| AppError::BadRequest("No Fan+ subscription".to_string()))?;
143
144 let stripe = state.stripe.as_ref()
145 .ok_or_else(|| AppError::BadRequest("Stripe is not configured".to_string()))?;
146
147 let return_url = format!("{}/dashboard?tab=account", state.config.host_url);
148 let url = stripe
149 .create_billing_portal_session(&sub.stripe_customer_id, &return_url)
150 .await?;
151 Ok(Redirect::to(&url))
152 }
153
154 /// Form data for creator tier checkout.
155 #[derive(Debug, Deserialize)]
156 pub(in crate::routes::stripe) struct CreatorTierForm {
157 tier: String,
158 /// Billing cadence: "monthly" or "annual". Defaults to "monthly" when
159 /// absent so older clients and the existing form post (no interval input)
160 /// keep working.
161 #[serde(default)]
162 interval: Option<String>,
163 }
164
165 /// Billing cadence requested by the checkout form. We try (founder|sticker)
166 /// × (annual|monthly) in priority order and fall back to the closest
167 /// configured price rather than erroring on a missing combination.
168 #[derive(Copy, Clone, Debug, PartialEq, Eq)]
169 enum BillingInterval {
170 Monthly,
171 Annual,
172 }
173
174 impl BillingInterval {
175 fn from_form(s: Option<&str>) -> Self {
176 match s.unwrap_or("monthly") {
177 "annual" | "yearly" | "year" => Self::Annual,
178 _ => Self::Monthly,
179 }
180 }
181 }
182
183 /// POST /stripe/creator-tier: Create a creator tier subscription checkout and redirect
184 #[tracing::instrument(skip_all, name = "stripe::creator_tier_checkout")]
185 pub(in crate::routes::stripe) async fn create_creator_tier_checkout(
186 State(state): State<AppState>,
187 AuthUser(user): AuthUser,
188 Form(form): Form<CreatorTierForm>,
189 ) -> Result<Response> {
190 user.check_not_suspended()?;
191 user.check_not_sandbox()?;
192
193 // Parse and validate the tier
194 let tier: db::CreatorTier = form.tier.parse()
195 .map_err(|_| AppError::BadRequest("Invalid tier".to_string()))?;
196
197 // Pick the price ID across two axes: (founder vs sticker) × (annual vs
198 // monthly). Founder applies when the window is open OR this account is
199 // already locked in. We try the requested combination, then degrade
200 // gracefully toward more conservative options rather than erroring on a
201 // missing env var:
202 //
203 // founder + annual → founder + monthly → sticker + annual → sticker + monthly
204 //
205 // This lets us roll founder/annual prices out per-tier without breaking
206 // checkout if one env var hasn't been set yet.
207 let db_user = db::users::get_user_by_id(&state.db, user.id)
208 .await?
209 .ok_or(AppError::NotFound)?;
210 let founder_eligible =
211 state.config.creator_founder_window_open || db_user.is_founder_locked();
212 let interval = BillingInterval::from_form(form.interval.as_deref());
213
214 let founder_annual = state.config.creator_tier_founder_annual_prices.get(&tier);
215 let founder_monthly = state.config.creator_tier_founder_prices.get(&tier);
216 let sticker_annual = state.config.creator_tier_annual_prices.get(&tier);
217 let sticker_monthly = state.config.creator_tier_prices.get(&tier);
218
219 let price_id = match (founder_eligible, interval) {
220 (true, BillingInterval::Annual) => founder_annual
221 .or(founder_monthly)
222 .or(sticker_annual)
223 .or(sticker_monthly),
224 (true, BillingInterval::Monthly) => founder_monthly
225 .or(sticker_annual)
226 .or(sticker_monthly),
227 (false, BillingInterval::Annual) => sticker_annual.or(sticker_monthly),
228 (false, BillingInterval::Monthly) => sticker_monthly,
229 }
230 .ok_or_else(|| AppError::BadRequest("Creator tiers are not configured".to_string()))?;
231
232 // Check not already subscribed
233 if db::creator_tiers::get_active_creator_tier(&state.db, user.id).await?.is_some() {
234 return Ok(Redirect::to("/dashboard?tab=creator").into_response());
235 }
236
237 let stripe = state.stripe.as_ref()
238 .ok_or_else(|| AppError::BadRequest("Stripe is not configured".to_string()))?;
239
240 let success_url = format!("{}/dashboard?tab=creator&subscribed=true", state.config.host_url);
241 let cancel_url = format!("{}/dashboard?tab=creator", state.config.host_url);
242
243 let session = stripe.create_creator_tier_checkout_session(
244 price_id,
245 user.id,
246 &tier.to_string(),
247 &success_url,
248 &cancel_url,
249 ).await?;
250
251 // Mark the user as a founder eagerly on first checkout-session creation
252 // during the open window. We don't gate on actual payment completion
253 // because the webhook handler is the source of truth for the subscription
254 // row; this flag just records "tried to sign up during the window," which
255 // is the correct grain for the snapshot at close-time (the close sweep
256 // only locks users with an active subscription, so abandoned checkouts
257 // don't get locked in regardless).
258 if state.config.creator_founder_window_open && !db_user.is_founder {
259 db::users::mark_user_as_founder(&state.db, user.id).await?;
260 }
261
262 let checkout_url = session.url
263 .ok_or_else(|| AppError::BadRequest("No checkout URL returned".to_string()))?;
264
265 Ok(Redirect::to(&checkout_url).into_response())
266 }
267
268 /// Form data for subscription checkout (supports optional promo code).
269 #[derive(Debug, Deserialize)]
270 pub(in crate::routes::stripe) struct SubscribeForm {
271 promo_code: Option<String>,
272 }
273
274 /// POST /stripe/subscribe/{tier_id} - Create a subscription checkout and redirect
275 #[tracing::instrument(skip_all, name = "stripe::subscribe")]
276 pub(in crate::routes::stripe) async fn create_subscription_checkout(
277 State(state): State<AppState>,
278 AuthUser(user): AuthUser,
279 Path(tier_id): Path<String>,
280 Form(form): Form<SubscribeForm>,
281 ) -> Result<Response> {
282 user.check_not_suspended()?;
283 user.check_not_sandbox()?;
284
285 let tier_uuid: SubscriptionTierId = tier_id.parse()
286 .map_err(|_| AppError::NotFound)?;
287
288 // Get the tier (must be active and have Stripe IDs)
289 let tier = db::subscriptions::get_subscription_tier_by_id(&state.db, tier_uuid)
290 .await?
291 .ok_or(AppError::NotFound)?;
292
293 if !tier.is_active {
294 return Err(AppError::BadRequest("This subscription tier is not available".to_string()));
295 }
296
297 let stripe_price_id = tier.stripe_price_id.as_ref()
298 .ok_or_else(|| AppError::BadRequest("Subscription tier is not configured for payments".to_string()))?;
299
300 // Get the project and creator
301 let tier_project_id = tier.project_id
302 .ok_or_else(|| AppError::BadRequest("This tier is not a project subscription".to_string()))?;
303 let project = db::projects::get_project_by_id(&state.db, tier_project_id)
304 .await?
305 .ok_or(AppError::NotFound)?;
306
307 let creator = db::users::get_user_by_id(&state.db, project.user_id)
308 .await?
309 .ok_or(AppError::NotFound)?;
310
311 if creator.is_suspended() || creator.is_deactivated() || creator.is_creator_paused() {
312 return Err(AppError::BadRequest("This creator's account is not active".to_string()));
313 }
314
315 // Sandbox creators have fake Stripe IDs — reject before calling Stripe API
316 if creator.is_sandbox {
317 return Err(AppError::NotFound);
318 }
319
320 // Verify creator has Stripe connected
321 let stripe_account_id = creator.stripe_account_id.as_ref()
322 .ok_or_else(|| AppError::BadRequest("Creator hasn't set up payments yet".to_string()))?;
323
324 if !creator.stripe_charges_enabled {
325 return Err(AppError::BadRequest("Creator's payment account is not ready".to_string()));
326 }
327
328 // A user cannot subscribe to their own project
329 if user.id == project.user_id {
330 return Err(AppError::BadRequest("You cannot subscribe to your own project".to_string()));
331 }
332
333 // Check if user already has an active subscription to this project
334 if db::subscriptions::has_active_subscription_to_project(&state.db, user.id, tier_project_id).await? {
335 return Ok(Redirect::to(&format!("/p/{}", project.slug)).into_response());
336 }
337
338 let stripe = state.stripe.as_ref()
339 .ok_or_else(|| AppError::BadRequest("Stripe is not configured".to_string()))?;
340
341 // Validate optional promo code for free trial
342 let mut trial_days: Option<i32> = None;
343 let mut promo_code_id: Option<PromoCodeId> = None;
344
345 if let Some(code_str) = form.promo_code.as_deref() {
346 let code_str = code_str.trim().to_uppercase();
347 if !code_str.is_empty() {
348 let pc = db::promo_codes::get_promo_code_by_creator_and_code(&state.db, project.user_id, &code_str)
349 .await?
350 .ok_or_else(|| AppError::BadRequest("Invalid promo code".to_string()))?;
351
352 if pc.code_purpose != CodePurpose::FreeTrial {
353 return Err(AppError::BadRequest("This code is not a free trial code".to_string()));
354 }
355
356 // Check start date
357 if let Some(starts) = pc.starts_at && starts > chrono::Utc::now() {
358 return Err(AppError::BadRequest("This code is not yet active".to_string()));
359 }
360
361 // Check expiry
362 if let Some(expires) = pc.expires_at && expires < chrono::Utc::now() {
363 return Err(AppError::BadRequest("This code has expired".to_string()));
364 }
365
366 // Check max uses
367 if let Some(max) = pc.max_uses && pc.use_count >= max {
368 return Err(AppError::BadRequest("This code has reached its usage limit".to_string()));
369 }
370
371 // Check tier scope
372 if let Some(scoped_tier) = pc.tier_id && scoped_tier != tier_uuid {
373 return Err(AppError::BadRequest("This code is not valid for this tier".to_string()));
374 }
375
376 // Check project scope
377 if let Some(scoped_project) = pc.project_id && tier_project_id != scoped_project {
378 return Err(AppError::BadRequest("This code is not valid for this project".to_string()));
379 }
380
381 trial_days = pc.trial_days;
382 promo_code_id = Some(pc.id);
383 }
384 }
385
386 // Reserve promo code use_count at checkout time to prevent concurrent over-use
387 if let Some(pc_id) = promo_code_id {
388 let reserved = db::promo_codes::try_increment_use_count(&state.db, pc_id)
389 .await
390 .context("reserve promo code use at subscription checkout")?;
391 if !reserved {
392 return Err(AppError::BadRequest("This promo code has reached its usage limit".to_string()));
393 }
394 }
395
396 // Build URLs
397 let success_url = format!("{}/stripe/success?session_id={{CHECKOUT_SESSION_ID}}", state.config.host_url);
398 let cancel_url = format!("{}/p/{}", state.config.host_url, project.slug);
399
400 // Create the subscription checkout session on the connected account.
401 // If this fails, release the promo code reservation.
402 let session = match stripe.create_subscription_checkout_session(
403 &crate::payments::SubscriptionCheckoutParams {
404 connected_account_id: stripe_account_id,
405 stripe_price_id,
406 subscriber_id: user.id,
407 project_id: tier_project_id,
408 tier_id: tier_uuid,
409 success_url: &success_url,
410 cancel_url: &cancel_url,
411 trial_days,
412 promo_code_id,
413 enable_stripe_tax: creator.stripe_tax_enabled,
414 },
415 ).await {
416 Ok(s) => s,
417 Err(e) => {
418 if let Some(pc_id) = promo_code_id {
419 db::promo_codes::release_use_count_and_detach(&state.db, pc_id, user.id).await.ok();
420 }
421 return Err(e);
422 }
423 };
424
425 // Create a pending transaction so that `cleanup_stale_pending_transactions`
426 // can release the promo code reservation if the buyer abandons checkout.
427 // This row is deleted (not completed) when the subscription webhook fires.
428 if let Some(pc_id) = promo_code_id
429 && let Err(e) = db::transactions::create_subscription_pending_transaction(
430 &state.db,
431 user.id,
432 project.user_id,
433 tier_project_id,
434 &session.id,
435 pc_id,
436 ).await
437 {
438 // If we can't create the pending row, release the reservation and fail.
439 db::promo_codes::release_use_count_and_detach(&state.db, pc_id, user.id).await.ok();
440 return Err(e).context("create subscription pending transaction for promo code");
441 }
442
443 // Redirect to Stripe Checkout
444 let checkout_url = session.url
445 .ok_or_else(|| AppError::BadRequest("No checkout URL returned".to_string()))?;
446
447 Ok(Redirect::to(&checkout_url).into_response())
448 }
449