Skip to main content

max / makenotwork

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