Skip to main content

max / makenotwork

25.4 KB · 715 lines History Blame Raw
1 //! Subscription checkout handlers: Fan+, creator tiers, and project subscriptions.
2
3 use axum::{
4 Form,
5 extract::{Path, State},
6 response::{IntoResponse, Redirect, Response},
7 };
8 use serde::Deserialize;
9
10 use crate::{
11 Billing,
12 auth::AuthUser,
13 config::Config,
14 db::{self, CodePurpose, PromoCodeId, SubscriptionTierId},
15 error::{AppError, Result, ResultExt},
16 };
17 use sqlx::PgPool;
18
19 /// POST /stripe/fan-plus: Create a Fan+ subscription checkout and redirect
20 #[tracing::instrument(skip_all, name = "stripe::fan_plus_checkout")]
21 pub(in crate::routes::stripe) async fn create_fan_plus_checkout(
22 State(db): State<PgPool>,
23 State(payments): State<Billing>,
24 State(config): State<Config>,
25 AuthUser(user): AuthUser,
26 ) -> Result<Response> {
27 user.check_not_suspended()?;
28 user.check_not_sandbox()?;
29
30 // Check Fan+ price is configured
31 let price_id = config
32 .creator_pricing
33 .fan_plus_price_id
34 .as_ref()
35 .ok_or_else(|| AppError::BadRequest("Fan+ is not configured".to_string()))?;
36
37 // Check not already a Fan+ subscriber
38 if db::fan_plus::is_fan_plus_active(&db, user.id).await? {
39 return Ok(Redirect::to("/fan-plus").into_response());
40 }
41
42 let stripe = payments
43 .stripe
44 .as_ref()
45 .ok_or_else(|| AppError::BadRequest("Stripe is not configured".to_string()))?;
46
47 let success_url = format!("{}/fan-plus?subscribed=true", config.host_url);
48 let cancel_url = format!("{}/fan-plus", config.host_url);
49
50 let session = stripe
51 .create_fan_plus_checkout_session(price_id, user.id, &success_url, &cancel_url)
52 .await?;
53
54 let checkout_url = session
55 .url
56 .ok_or_else(|| AppError::BadRequest("No checkout URL returned".to_string()))?;
57
58 Ok(Redirect::to(&checkout_url).into_response())
59 }
60
61 /// Reject the request if its `Sec-Fetch-Site` doesn't look like a real
62 /// click from our own dashboard.
63 ///
64 /// These two endpoints (`fan-plus/cancel` and `resume`) are exempted from
65 /// the global CSRF middleware because they're vanilla form posts that
66 /// redirect back to the dashboard, there's no place to attach a header
67 /// token. SameSite=Lax cookies block cross-site form posts already, but
68 /// Sec-Fetch-Site is the explicit second check we promise in the
69 /// CSRF-exempt rationale (see `csrf.rs`).
70 ///
71 /// Allow:
72 /// - `same-origin`, click from our own dashboard, exactly what we want
73 /// - missing, older browsers that don't send the header at all
74 ///
75 /// Reject everything else (`cross-site`, `same-site`, `none`/typed-URL).
76 fn check_sec_fetch_site(headers: &axum::http::HeaderMap) -> Result<()> {
77 let Some(value) = headers.get("sec-fetch-site").and_then(|v| v.to_str().ok()) else {
78 return Ok(());
79 };
80 if value == "same-origin" {
81 return Ok(());
82 }
83 tracing::warn!(
84 sec_fetch_site = value,
85 "fan-plus subscription change rejected: bad Sec-Fetch-Site"
86 );
87 Err(AppError::Forbidden)
88 }
89
90 /// POST /stripe/fan-plus/cancel: Schedule Fan+ to cancel at period end.
91 ///
92 /// Self-service: leaves the subscription active through the current paid
93 /// period (no proration). The user can resume before period end to undo.
94 /// Stripe's `customer.subscription.updated` webhook keeps the local flag in
95 /// sync if the user later cancels via the customer portal instead.
96 #[tracing::instrument(skip_all, name = "stripe::fan_plus_cancel")]
97 pub(in crate::routes::stripe) async fn cancel_fan_plus(
98 State(db): State<PgPool>,
99 State(payments): State<Billing>,
100 headers: axum::http::HeaderMap,
101 AuthUser(user): AuthUser,
102 ) -> Result<Redirect> {
103 check_sec_fetch_site(&headers)?;
104 let sub = db::fan_plus::get_fan_plus_by_user(&db, user.id)
105 .await?
106 .ok_or_else(|| AppError::BadRequest("No active Fan+ subscription".to_string()))?;
107
108 let stripe = payments
109 .stripe
110 .as_ref()
111 .ok_or_else(|| AppError::BadRequest("Stripe is not configured".to_string()))?;
112
113 stripe
114 .set_platform_cancel_at_period_end(&sub.stripe_subscription_id, true)
115 .await?;
116 db::fan_plus::set_cancel_at_period_end(&db, &sub.stripe_subscription_id, true).await?;
117
118 Ok(Redirect::to(
119 "/dashboard?tab=account&toast=Fan%2B+cancellation+scheduled",
120 ))
121 }
122
123 /// POST /stripe/fan-plus/resume: Undo a scheduled cancellation.
124 #[tracing::instrument(skip_all, name = "stripe::fan_plus_resume")]
125 pub(in crate::routes::stripe) async fn resume_fan_plus(
126 State(db): State<PgPool>,
127 State(payments): State<Billing>,
128 headers: axum::http::HeaderMap,
129 AuthUser(user): AuthUser,
130 ) -> Result<Redirect> {
131 check_sec_fetch_site(&headers)?;
132 let sub = db::fan_plus::get_fan_plus_by_user(&db, user.id)
133 .await?
134 .ok_or_else(|| AppError::BadRequest("No Fan+ subscription".to_string()))?;
135
136 let stripe = payments
137 .stripe
138 .as_ref()
139 .ok_or_else(|| AppError::BadRequest("Stripe is not configured".to_string()))?;
140
141 stripe
142 .set_platform_cancel_at_period_end(&sub.stripe_subscription_id, false)
143 .await?;
144 db::fan_plus::set_cancel_at_period_end(&db, &sub.stripe_subscription_id, false).await?;
145
146 Ok(Redirect::to("/dashboard?tab=account&toast=Fan%2B+resumed"))
147 }
148
149 /// POST /stripe/billing-portal: Open the Stripe customer portal.
150 ///
151 /// Stripe-hosted: handles payment method updates, invoice history, and
152 /// (if configured in the dashboard) subscription cancellation. Routes from
153 /// the dashboard Fan+ pane.
154 #[tracing::instrument(skip_all, name = "stripe::billing_portal")]
155 pub(in crate::routes::stripe) async fn open_billing_portal(
156 State(db): State<PgPool>,
157 State(payments): State<Billing>,
158 State(config): State<Config>,
159 AuthUser(user): AuthUser,
160 ) -> Result<Redirect> {
161 let sub = db::fan_plus::get_fan_plus_by_user(&db, user.id)
162 .await?
163 .ok_or_else(|| AppError::BadRequest("No Fan+ subscription".to_string()))?;
164
165 let stripe = payments
166 .stripe
167 .as_ref()
168 .ok_or_else(|| AppError::BadRequest("Stripe is not configured".to_string()))?;
169
170 let return_url = format!("{}/dashboard?tab=account", config.host_url);
171 let url = stripe
172 .create_billing_portal_session(&sub.stripe_customer_id, &return_url)
173 .await?;
174 Ok(Redirect::to(&url))
175 }
176
177 /// Form data for creator tier checkout.
178 #[derive(Debug, Deserialize)]
179 pub(in crate::routes::stripe) struct CreatorTierForm {
180 tier: String,
181 /// Billing cadence: "monthly" or "annual". Defaults to "monthly" when
182 /// absent so older clients and the existing form post (no interval input)
183 /// keep working.
184 #[serde(default)]
185 interval: Option<String>,
186 /// Optional comp code: a platform-wide free-trial code that grants N free
187 /// months before the subscription rolls to the chosen (founder) price.
188 #[serde(default)]
189 promo_code: Option<String>,
190 }
191
192 /// Billing cadence requested by the checkout form. We try (founder|sticker)
193 /// × (annual|monthly) in priority order and fall back to the closest
194 /// configured price rather than erroring on a missing combination.
195 #[derive(Copy, Clone, Debug, PartialEq, Eq)]
196 enum BillingInterval {
197 Monthly,
198 Annual,
199 }
200
201 impl BillingInterval {
202 fn from_form(s: Option<&str>) -> Self {
203 match s.unwrap_or("monthly") {
204 "annual" | "yearly" | "year" => Self::Annual,
205 _ => Self::Monthly,
206 }
207 }
208 }
209
210 /// POST /stripe/creator-tier: Create a creator tier subscription checkout and redirect
211 #[tracing::instrument(skip_all, name = "stripe::creator_tier_checkout")]
212 pub(in crate::routes::stripe) async fn create_creator_tier_checkout(
213 State(db): State<PgPool>,
214 State(payments): State<Billing>,
215 State(config): State<Config>,
216 AuthUser(user): AuthUser,
217 Form(form): Form<CreatorTierForm>,
218 ) -> Result<Response> {
219 user.check_not_suspended()?;
220 user.check_not_sandbox()?;
221
222 // Parse and validate the tier
223 let tier: db::CreatorTier = form
224 .tier
225 .parse()
226 .map_err(|_| AppError::BadRequest("Invalid tier".to_string()))?;
227
228 // Pick the price ID across two axes: (founder vs sticker) × (annual vs
229 // monthly). Founder applies when the window is open OR this account is
230 // already locked in. We only ever degrade the founder→sticker axis, NEVER
231 // the cadence the buyer chose:
232 //
233 // annual request → founder_annual → sticker_annual
234 // monthly request → founder_monthly → sticker_monthly
235 //
236 // Crossing cadence would silently charge the wrong amount and interval, a
237 // monthly buyer routed to sticker_annual pays the full year up front at a
238 // ~21× larger first charge (Run 21 payments). If the requested cadence
239 // isn't configured for a tier we error clearly rather than mischarge.
240 let db_user = db::users::get_user_by_id(&db, user.id)
241 .await?
242 .ok_or(AppError::NotFound)?;
243 let founder_eligible =
244 config.creator_pricing.founder_window_open || db_user.is_founder_locked();
245 let interval = BillingInterval::from_form(form.interval.as_deref());
246
247 let founder_annual = config.creator_pricing.tier_founder_annual_prices.get(&tier);
248 let founder_monthly = config.creator_pricing.tier_founder_prices.get(&tier);
249 let sticker_annual = config.creator_pricing.tier_annual_prices.get(&tier);
250 let sticker_monthly = config.creator_pricing.tier_prices.get(&tier);
251
252 let price_id = match (founder_eligible, interval) {
253 (true, BillingInterval::Annual) => founder_annual.or(sticker_annual),
254 (true, BillingInterval::Monthly) => founder_monthly.or(sticker_monthly),
255 (false, BillingInterval::Annual) => sticker_annual,
256 (false, BillingInterval::Monthly) => sticker_monthly,
257 }
258 .ok_or_else(|| AppError::BadRequest("Creator tiers are not configured".to_string()))?;
259
260 // Check not already subscribed
261 if db::creator_tiers::get_active_creator_tier(&db, user.id)
262 .await?
263 .is_some()
264 {
265 return Ok(Redirect::to("/dashboard?tab=creator").into_response());
266 }
267
268 let stripe = payments
269 .stripe
270 .as_ref()
271 .ok_or_else(|| AppError::BadRequest("Stripe is not configured".to_string()))?;
272
273 let success_url = format!("{}/dashboard?tab=creator&subscribed=true", config.host_url);
274 let cancel_url = format!("{}/dashboard?tab=creator", config.host_url);
275
276 // Validate an optional comp code (platform-wide free-trial). Unlike the
277 // project-subscription path there's no creator/project scope to check,
278 // these are operator-minted codes redeemable at creator-tier checkout.
279 let mut trial_days: Option<i32> = None;
280 let mut promo_code_id: Option<PromoCodeId> = None;
281 if let Some(code_str) = form.promo_code.as_deref() {
282 let code_str = code_str.trim().to_uppercase();
283 if !code_str.is_empty() {
284 let pc = db::promo_codes::get_platform_trial_code_by_code(&db, &code_str)
285 .await?
286 .ok_or_else(|| AppError::BadRequest("Invalid comp code".to_string()))?;
287
288 if let Some(starts) = pc.starts_at
289 && starts > chrono::Utc::now()
290 {
291 return Err(AppError::BadRequest(
292 "This code is not yet active".to_string(),
293 ));
294 }
295 if let Some(expires) = pc.expires_at
296 && expires < chrono::Utc::now()
297 {
298 return Err(AppError::BadRequest("This code has expired".to_string()));
299 }
300 if let Some(max) = pc.max_uses
301 && pc.use_count >= max
302 {
303 return Err(AppError::BadRequest(
304 "This code has reached its usage limit".to_string(),
305 ));
306 }
307
308 trial_days = pc.trial_days;
309 promo_code_id = Some(pc.id);
310 }
311 }
312
313 // Reserve the code: per-individual first (once per person), then the global
314 // usage cap. Both are rolled back if the Stripe call below fails.
315 if let Some(pc_id) = promo_code_id {
316 // Once-per-individual: a repeat redemption by the same user is rejected,
317 // so a reusable code shared by a creator grants each person one trial.
318 let first_time = db::promo_codes::try_record_redemption(&db, pc_id, user.id)
319 .await
320 .context("record comp code redemption at creator-tier checkout")?;
321 if !first_time {
322 return Err(AppError::BadRequest(
323 "You have already used this code.".to_string(),
324 ));
325 }
326 // Global usage cap (max_uses). The WHERE clause re-checks the limit.
327 let reserved = db::promo_codes::try_increment_use_count(&db, pc_id)
328 .await
329 .context("reserve comp code use at creator-tier checkout")?;
330 if !reserved {
331 db::promo_codes::remove_redemption(&db, pc_id, user.id)
332 .await
333 .ok();
334 return Err(AppError::BadRequest(
335 "This code has reached its usage limit".to_string(),
336 ));
337 }
338 }
339
340 let session = match stripe
341 .create_creator_tier_checkout_session(
342 price_id,
343 user.id,
344 &tier.to_string(),
345 &success_url,
346 &cancel_url,
347 trial_days,
348 )
349 .await
350 {
351 Ok(s) => s,
352 Err(e) => {
353 if let Some(pc_id) = promo_code_id {
354 db::promo_codes::release_use_count(&db, pc_id).await.ok();
355 db::promo_codes::remove_redemption(&db, pc_id, user.id)
356 .await
357 .ok();
358 }
359 return Err(e);
360 }
361 };
362
363 // Mark the user as a founder eagerly on first checkout-session creation
364 // during the open window. We don't gate on actual payment completion
365 // because the webhook handler is the source of truth for the subscription
366 // row; this flag just records "tried to sign up during the window," which
367 // is the correct grain for the snapshot at close-time (the close sweep
368 // only locks users with an active subscription, so abandoned checkouts
369 // don't get locked in regardless).
370 if config.creator_pricing.founder_window_open && !db_user.is_founder {
371 db::users::mark_user_as_founder(&db, user.id).await?;
372 }
373
374 let checkout_url = session
375 .url
376 .ok_or_else(|| AppError::BadRequest("No checkout URL returned".to_string()))?;
377
378 Ok(Redirect::to(&checkout_url).into_response())
379 }
380
381 /// Form data for subscription checkout (supports optional promo code).
382 #[derive(Debug, Deserialize)]
383 pub(in crate::routes::stripe) struct SubscribeForm {
384 promo_code: Option<String>,
385 }
386
387 /// POST /stripe/subscribe/{tier_id} - Create a subscription checkout and redirect
388 #[tracing::instrument(skip_all, name = "stripe::subscribe")]
389 pub(in crate::routes::stripe) async fn create_subscription_checkout(
390 State(db): State<PgPool>,
391 State(payments): State<Billing>,
392 State(config): State<Config>,
393 AuthUser(user): AuthUser,
394 Path(tier_id): Path<String>,
395 Form(form): Form<SubscribeForm>,
396 ) -> Result<Response> {
397 user.check_not_suspended()?;
398 user.check_not_sandbox()?;
399
400 let tier_uuid: SubscriptionTierId = tier_id.parse().map_err(|_| AppError::NotFound)?;
401
402 // Get the tier (must be active and have Stripe IDs)
403 let tier = db::subscriptions::get_subscription_tier_by_id(&db, tier_uuid)
404 .await?
405 .ok_or(AppError::NotFound)?;
406
407 if !tier.is_active {
408 return Err(AppError::BadRequest(
409 "This subscription tier is not available".to_string(),
410 ));
411 }
412
413 let stripe_price_id = tier.stripe_price_id.as_ref().ok_or_else(|| {
414 AppError::BadRequest("Subscription tier is not configured for payments".to_string())
415 })?;
416
417 // Get the project and creator
418 let tier_project_id = tier.project_id.ok_or_else(|| {
419 AppError::BadRequest("This tier is not a project subscription".to_string())
420 })?;
421 let project = db::projects::get_project_by_id(&db, tier_project_id)
422 .await?
423 .ok_or(AppError::NotFound)?;
424
425 let creator = db::users::get_user_by_id(&db, project.user_id)
426 .await?
427 .ok_or(AppError::NotFound)?;
428
429 if creator.is_suspended() || creator.is_deactivated() || creator.is_creator_paused() {
430 return Err(AppError::BadRequest(
431 "This creator's account is not active".to_string(),
432 ));
433 }
434
435 // Sandbox creators have fake Stripe IDs, reject before calling Stripe API
436 if creator.is_sandbox {
437 return Err(AppError::NotFound);
438 }
439
440 // Verify creator has Stripe connected
441 let stripe_account_id = creator
442 .stripe_account_id
443 .as_deref()
444 .ok_or_else(|| AppError::BadRequest("Creator hasn't set up payments yet".to_string()))?;
445
446 if !creator.stripe_charges_enabled {
447 return Err(AppError::BadRequest(
448 "Creator's payment account is not ready".to_string(),
449 ));
450 }
451
452 // A user cannot subscribe to their own project
453 if user.id == project.user_id {
454 return Err(AppError::BadRequest(
455 "You cannot subscribe to your own project".to_string(),
456 ));
457 }
458
459 // Check if user already has an active subscription to this project
460 if db::subscriptions::has_access(
461 &db,
462 user.id,
463 db::subscriptions::SubscriptionScope::Project(tier_project_id),
464 )
465 .await?
466 {
467 return Ok(Redirect::to(&format!("/p/{}", project.slug)).into_response());
468 }
469
470 let stripe = payments
471 .stripe
472 .as_ref()
473 .ok_or_else(|| AppError::BadRequest("Stripe is not configured".to_string()))?;
474
475 // Validate optional promo code for free trial
476 let mut trial_days: Option<i32> = None;
477 let mut promo_code_id: Option<PromoCodeId> = None;
478
479 if let Some(code_str) = form.promo_code.as_deref() {
480 let code_str = code_str.trim().to_uppercase();
481 if !code_str.is_empty() {
482 let pc = db::promo_codes::get_promo_code_by_creator_and_code(
483 &db,
484 project.user_id,
485 &code_str,
486 )
487 .await?
488 .ok_or_else(|| AppError::BadRequest("Invalid promo code".to_string()))?;
489
490 if pc.code_purpose != CodePurpose::FreeTrial {
491 return Err(AppError::BadRequest(
492 "This code is not a free trial code".to_string(),
493 ));
494 }
495
496 // Check start date
497 if let Some(starts) = pc.starts_at
498 && starts > chrono::Utc::now()
499 {
500 return Err(AppError::BadRequest(
501 "This code is not yet active".to_string(),
502 ));
503 }
504
505 // Check expiry
506 if let Some(expires) = pc.expires_at
507 && expires < chrono::Utc::now()
508 {
509 return Err(AppError::BadRequest("This code has expired".to_string()));
510 }
511
512 // Check max uses
513 if let Some(max) = pc.max_uses
514 && pc.use_count >= max
515 {
516 return Err(AppError::BadRequest(
517 "This code has reached its usage limit".to_string(),
518 ));
519 }
520
521 // Check tier scope
522 if let Some(scoped_tier) = pc.tier_id
523 && scoped_tier != tier_uuid
524 {
525 return Err(AppError::BadRequest(
526 "This code is not valid for this tier".to_string(),
527 ));
528 }
529
530 // Check project scope
531 if let Some(scoped_project) = pc.project_id
532 && tier_project_id != scoped_project
533 {
534 return Err(AppError::BadRequest(
535 "This code is not valid for this project".to_string(),
536 ));
537 }
538
539 trial_days = pc.trial_days;
540 promo_code_id = Some(pc.id);
541 }
542 }
543
544 // Reserve promo code use_count at checkout time to prevent concurrent over-use
545 if let Some(pc_id) = promo_code_id {
546 let reserved = db::promo_codes::try_increment_use_count(&db, pc_id)
547 .await
548 .context("reserve promo code use at subscription checkout")?;
549 if !reserved {
550 return Err(AppError::BadRequest(
551 "This promo code has reached its usage limit".to_string(),
552 ));
553 }
554 }
555
556 // Build URLs
557 let success_url = format!(
558 "{}/stripe/success?session_id={{CHECKOUT_SESSION_ID}}",
559 config.host_url
560 );
561 let cancel_url = format!("{}/p/{}", config.host_url, project.slug);
562
563 // Create the subscription checkout session on the connected account.
564 // If this fails, release the promo code reservation.
565 let session = match stripe
566 .create_subscription_checkout_session(&crate::payments::SubscriptionCheckoutParams {
567 connected_account_id: stripe_account_id,
568 stripe_price_id,
569 subscriber_id: user.id,
570 project_id: tier_project_id,
571 tier_id: tier_uuid,
572 success_url: &success_url,
573 cancel_url: &cancel_url,
574 trial_days,
575 promo_code_id,
576 enable_stripe_tax: creator.stripe_tax_enabled,
577 currency: creator.settlement_currency,
578 conversion: user.conversion_preference,
579 })
580 .await
581 {
582 Ok(s) => s,
583 Err(e) => {
584 if let Some(pc_id) = promo_code_id {
585 db::promo_codes::release_use_count_and_detach(&db, pc_id, user.id)
586 .await
587 .ok();
588 }
589 return Err(e);
590 }
591 };
592
593 // Create a pending transaction so that `cleanup_stale_pending_transactions`
594 // can release the promo code reservation if the buyer abandons checkout.
595 // This row is deleted (not completed) when the subscription webhook fires.
596 if let Some(pc_id) = promo_code_id
597 && let Err(e) = db::transactions::create_subscription_pending_transaction(
598 &db,
599 user.id,
600 project.user_id,
601 tier_project_id,
602 &session.id,
603 pc_id,
604 )
605 .await
606 {
607 // If we can't create the pending row, release the reservation and fail.
608 db::promo_codes::release_use_count_and_detach(&db, pc_id, user.id)
609 .await
610 .ok();
611 return Err(e).context("create subscription pending transaction for promo code");
612 }
613
614 // Redirect to Stripe Checkout
615 let checkout_url = session
616 .url
617 .ok_or_else(|| AppError::BadRequest("No checkout URL returned".to_string()))?;
618
619 Ok(Redirect::to(&checkout_url).into_response())
620 }
621
622 #[cfg(test)]
623 mod tests {
624 //! The two pure decisions on the Fan+ subscription-change path: which
625 //! cross-origin requests are allowed to reach a billing mutation, and how a
626 //! billing interval is read off a form. Both are one-line functions whose
627 //! wrong answer costs money, and neither was covered.
628
629 use super::*;
630
631 fn headers_with(site: &str) -> axum::http::HeaderMap {
632 let mut h = axum::http::HeaderMap::new();
633 h.insert("sec-fetch-site", site.parse().expect("valid header value"));
634 h
635 }
636
637 // ── Sec-Fetch-Site ──
638
639 #[test]
640 fn a_click_from_our_own_dashboard_is_allowed() {
641 assert!(check_sec_fetch_site(&headers_with("same-origin")).is_ok());
642 }
643
644 #[test]
645 fn a_browser_that_sends_no_header_is_allowed() {
646 // Older browsers omit it entirely; refusing them would break real users.
647 assert!(check_sec_fetch_site(&axum::http::HeaderMap::new()).is_ok());
648 }
649
650 #[test]
651 fn every_other_origin_is_forbidden() {
652 // `same-site` is deliberately refused too: a subdomain is not us.
653 for site in ["cross-site", "same-site", "none"] {
654 assert!(
655 matches!(
656 check_sec_fetch_site(&headers_with(site)),
657 Err(AppError::Forbidden)
658 ),
659 "{site} must not be able to change a subscription"
660 );
661 }
662 }
663
664 #[test]
665 fn an_unparseable_header_does_not_open_the_gate() {
666 // A non-UTF-8 value falls into the `None` arm and is allowed, matching
667 // the missing-header case. Pinned so the fallback is a decision rather
668 // than an accident.
669 let mut h = axum::http::HeaderMap::new();
670 h.insert(
671 "sec-fetch-site",
672 axum::http::HeaderValue::from_bytes(&[0xff, 0xfe]).expect("bytes"),
673 );
674 assert!(check_sec_fetch_site(&h).is_ok());
675 }
676
677 // ── billing interval ──
678
679 #[test]
680 fn the_interval_defaults_to_monthly() {
681 // The cheaper commitment is the safe default: defaulting to annual
682 // would charge a year for a form that forgot the field.
683 assert_eq!(BillingInterval::from_form(None), BillingInterval::Monthly);
684 assert_eq!(
685 BillingInterval::from_form(Some("")),
686 BillingInterval::Monthly
687 );
688 assert_eq!(
689 BillingInterval::from_form(Some("nonsense")),
690 BillingInterval::Monthly
691 );
692 }
693
694 #[test]
695 fn every_spelling_of_annual_is_accepted() {
696 for s in ["annual", "yearly", "year"] {
697 assert_eq!(
698 BillingInterval::from_form(Some(s)),
699 BillingInterval::Annual,
700 "{s} should select the annual price"
701 );
702 }
703 }
704
705 #[test]
706 fn interval_matching_is_case_sensitive() {
707 // Documents the current contract: "Annual" falls through to Monthly.
708 // If a form ever sends a capitalised value it will silently downgrade.
709 assert_eq!(
710 BillingInterval::from_form(Some("Annual")),
711 BillingInterval::Monthly
712 );
713 }
714 }
715