Skip to main content

max / makenotwork

25.4 KB · 716 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 headers: axum::http::HeaderMap,
392 Path(tier_id): Path<String>,
393 Form(form): Form<SubscribeForm>,
394 ) -> Result<Response> {
395 user.check_not_suspended()?;
396 user.check_not_sandbox()?;
397
398 let tier_uuid: SubscriptionTierId = tier_id.parse().map_err(|_| AppError::NotFound)?;
399
400 // Get the tier (must be active and have Stripe IDs)
401 let tier = db::subscriptions::get_subscription_tier_by_id(&db, tier_uuid)
402 .await?
403 .ok_or(AppError::NotFound)?;
404
405 if !tier.is_active {
406 return Err(AppError::BadRequest(
407 "This subscription tier is not available".to_string(),
408 ));
409 }
410
411 let stripe_price_id = tier.stripe_price_id.as_ref().ok_or_else(|| {
412 AppError::BadRequest("Subscription tier is not configured for payments".to_string())
413 })?;
414
415 // Get the project and creator
416 let tier_project_id = tier.project_id.ok_or_else(|| {
417 AppError::BadRequest("This tier is not a project subscription".to_string())
418 })?;
419 let project = db::projects::get_project_by_id(&db, tier_project_id)
420 .await?
421 .ok_or(AppError::NotFound)?;
422
423 let creator = db::users::get_user_by_id(&db, project.user_id)
424 .await?
425 .ok_or(AppError::NotFound)?;
426
427 if creator.is_suspended() || creator.is_deactivated() || creator.is_creator_paused() {
428 return Err(AppError::BadRequest(
429 "This creator's account is not active".to_string(),
430 ));
431 }
432
433 // Sandbox creators have fake Stripe IDs, reject before calling Stripe API
434 if creator.is_sandbox {
435 return Err(AppError::NotFound);
436 }
437
438 // Verify creator has Stripe connected
439 let stripe_account_id = creator
440 .stripe_account_id
441 .as_deref()
442 .ok_or_else(|| AppError::BadRequest("Creator hasn't set up payments yet".to_string()))?;
443
444 if !creator.stripe_charges_enabled {
445 return Err(AppError::BadRequest(
446 "Creator's payment account is not ready".to_string(),
447 ));
448 }
449
450 // A user cannot subscribe to their own project
451 if user.id == project.user_id {
452 return Err(AppError::BadRequest(
453 "You cannot subscribe to your own project".to_string(),
454 ));
455 }
456
457 // Check if user already has an active subscription to this project
458 if db::subscriptions::has_access(
459 &db,
460 user.id,
461 db::subscriptions::SubscriptionScope::Project(tier_project_id),
462 )
463 .await?
464 {
465 return Ok(crate::helpers::redirect_to(
466 &headers,
467 &format!("/p/{}", project.slug),
468 ));
469 }
470
471 let stripe = payments
472 .payments
473 .as_ref()
474 .ok_or_else(|| AppError::BadRequest("Stripe is not configured".to_string()))?;
475
476 // Validate optional promo code for free trial
477 let mut trial_days: Option<i32> = None;
478 let mut promo_code_id: Option<PromoCodeId> = None;
479
480 if let Some(code_str) = form.promo_code.as_deref() {
481 let code_str = code_str.trim().to_uppercase();
482 if !code_str.is_empty() {
483 let pc = db::promo_codes::get_promo_code_by_creator_and_code(
484 &db,
485 project.user_id,
486 &code_str,
487 )
488 .await?
489 .ok_or_else(|| AppError::BadRequest("Invalid promo code".to_string()))?;
490
491 if pc.code_purpose != CodePurpose::FreeTrial {
492 return Err(AppError::BadRequest(
493 "This code is not a free trial code".to_string(),
494 ));
495 }
496
497 // Check start date
498 if let Some(starts) = pc.starts_at
499 && starts > chrono::Utc::now()
500 {
501 return Err(AppError::BadRequest(
502 "This code is not yet active".to_string(),
503 ));
504 }
505
506 // Check expiry
507 if let Some(expires) = pc.expires_at
508 && expires < chrono::Utc::now()
509 {
510 return Err(AppError::BadRequest("This code has expired".to_string()));
511 }
512
513 // Check max uses
514 if let Some(max) = pc.max_uses
515 && pc.use_count >= max
516 {
517 return Err(AppError::BadRequest(
518 "This code has reached its usage limit".to_string(),
519 ));
520 }
521
522 // Check tier scope
523 if let Some(scoped_tier) = pc.tier_id
524 && scoped_tier != tier_uuid
525 {
526 return Err(AppError::BadRequest(
527 "This code is not valid for this tier".to_string(),
528 ));
529 }
530
531 // Check project scope
532 if let Some(scoped_project) = pc.project_id
533 && tier_project_id != scoped_project
534 {
535 return Err(AppError::BadRequest(
536 "This code is not valid for this project".to_string(),
537 ));
538 }
539
540 trial_days = pc.trial_days;
541 promo_code_id = Some(pc.id);
542 }
543 }
544
545 // Reserve promo code use_count at checkout time to prevent concurrent over-use
546 if let Some(pc_id) = promo_code_id {
547 let reserved = db::promo_codes::try_increment_use_count(&db, pc_id)
548 .await
549 .context("reserve promo code use at subscription checkout")?;
550 if !reserved {
551 return Err(AppError::BadRequest(
552 "This promo code has reached its usage limit".to_string(),
553 ));
554 }
555 }
556
557 // Build URLs
558 let success_url = format!(
559 "{}/stripe/success?session_id={{CHECKOUT_SESSION_ID}}",
560 config.host_url
561 );
562 let cancel_url = format!("{}/p/{}", config.host_url, project.slug);
563
564 // Create the subscription checkout session on the connected account.
565 // If this fails, release the promo code reservation.
566 let session = match stripe
567 .create_subscription_checkout_session(&crate::payments::SubscriptionCheckoutParams {
568 connected_account_id: stripe_account_id,
569 stripe_price_id,
570 subscriber_id: user.id,
571 project_id: tier_project_id,
572 tier_id: tier_uuid,
573 success_url: &success_url,
574 cancel_url: &cancel_url,
575 trial_days,
576 promo_code_id,
577 enable_stripe_tax: creator.stripe_tax_enabled,
578 currency: creator.settlement_currency,
579 conversion: user.conversion_preference,
580 })
581 .await
582 {
583 Ok(s) => s,
584 Err(e) => {
585 if let Some(pc_id) = promo_code_id {
586 db::promo_codes::release_use_count_and_detach(&db, pc_id, user.id)
587 .await
588 .ok();
589 }
590 return Err(e);
591 }
592 };
593
594 // Create a pending transaction so that `cleanup_stale_pending_transactions`
595 // can release the promo code reservation if the buyer abandons checkout.
596 // This row is deleted (not completed) when the subscription webhook fires.
597 if let Some(pc_id) = promo_code_id
598 && let Err(e) = db::transactions::create_subscription_pending_transaction(
599 &db,
600 user.id,
601 project.user_id,
602 tier_project_id,
603 &session.id,
604 pc_id,
605 )
606 .await
607 {
608 // If we can't create the pending row, release the reservation and fail.
609 db::promo_codes::release_use_count_and_detach(&db, pc_id, user.id)
610 .await
611 .ok();
612 return Err(e).context("create subscription pending transaction for promo code");
613 }
614
615 // Redirect to Stripe Checkout
616 let checkout_url = session
617 .url
618 .ok_or_else(|| AppError::BadRequest("No checkout URL returned".to_string()))?;
619
620 Ok(crate::helpers::redirect_to(&headers, &checkout_url))
621 }
622
623 #[cfg(test)]
624 mod tests {
625 //! The two pure decisions on the Fan+ subscription-change path: which
626 //! cross-origin requests are allowed to reach a billing mutation, and how a
627 //! billing interval is read off a form. Both are one-line functions whose
628 //! wrong answer costs money, and neither was covered.
629
630 use super::*;
631
632 fn headers_with(site: &str) -> axum::http::HeaderMap {
633 let mut h = axum::http::HeaderMap::new();
634 h.insert("sec-fetch-site", site.parse().expect("valid header value"));
635 h
636 }
637
638 // --- Sec-Fetch-Site ---
639
640 #[test]
641 fn a_click_from_our_own_dashboard_is_allowed() {
642 assert!(check_sec_fetch_site(&headers_with("same-origin")).is_ok());
643 }
644
645 #[test]
646 fn a_browser_that_sends_no_header_is_allowed() {
647 // Older browsers omit it entirely; refusing them would break real users.
648 assert!(check_sec_fetch_site(&axum::http::HeaderMap::new()).is_ok());
649 }
650
651 #[test]
652 fn every_other_origin_is_forbidden() {
653 // `same-site` is deliberately refused too: a subdomain is not us.
654 for site in ["cross-site", "same-site", "none"] {
655 assert!(
656 matches!(
657 check_sec_fetch_site(&headers_with(site)),
658 Err(AppError::Forbidden)
659 ),
660 "{site} must not be able to change a subscription"
661 );
662 }
663 }
664
665 #[test]
666 fn an_unparseable_header_does_not_open_the_gate() {
667 // A non-UTF-8 value falls into the `None` arm and is allowed, matching
668 // the missing-header case. Pinned so the fallback is a decision rather
669 // than an accident.
670 let mut h = axum::http::HeaderMap::new();
671 h.insert(
672 "sec-fetch-site",
673 axum::http::HeaderValue::from_bytes(&[0xff, 0xfe]).expect("bytes"),
674 );
675 assert!(check_sec_fetch_site(&h).is_ok());
676 }
677
678 // --- billing interval ---
679
680 #[test]
681 fn the_interval_defaults_to_monthly() {
682 // The cheaper commitment is the safe default: defaulting to annual
683 // would charge a year for a form that forgot the field.
684 assert_eq!(BillingInterval::from_form(None), BillingInterval::Monthly);
685 assert_eq!(
686 BillingInterval::from_form(Some("")),
687 BillingInterval::Monthly
688 );
689 assert_eq!(
690 BillingInterval::from_form(Some("nonsense")),
691 BillingInterval::Monthly
692 );
693 }
694
695 #[test]
696 fn every_spelling_of_annual_is_accepted() {
697 for s in ["annual", "yearly", "year"] {
698 assert_eq!(
699 BillingInterval::from_form(Some(s)),
700 BillingInterval::Annual,
701 "{s} should select the annual price"
702 );
703 }
704 }
705
706 #[test]
707 fn interval_matching_is_case_sensitive() {
708 // Documents the current contract: "Annual" falls through to Monthly.
709 // If a form ever sends a capitalised value it will silently downgrade.
710 assert_eq!(
711 BillingInterval::from_form(Some("Annual")),
712 BillingInterval::Monthly
713 );
714 }
715 }
716