Skip to main content

max / makenotwork

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