From e0f4451ec9244a5a5e053927c8c0371425802050 Mon Sep 17 00:00:00 2001 From: kipavy Date: Sat, 12 Sep 2026 22:14:32 +0000 Subject: [PATCH 1/2] feat(billing): give Business a 3-seat floor and its own annual variant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Business had no seat minimum, so one seat at $30 undercut the cheapest Teams plan at three seats for $54 while including everything Teams has. Both per-seat plans now share a 3-seat floor, which keeps the ladder monotonic: Teams starts at $54/month, Business at $90. The owner occupies a seat, so three seats means the owner plus two. Business also resolved a single monthly variant for both intervals. It now has LS_VARIANT_BUSINESS_YEARLY of its own, priced at $25/user/month against $30 monthly — the same 17% annual discount Teams carries. Pro and Teams already discounted annually and Business did not, which was the odd one out. Neither variant exists in LemonSqueezy yet, so the plan stays unsellable until both environment variables are set. --- src/routes/billing.rs | 37 +++++++++++++++++++++++++++---------- 1 file changed, 27 insertions(+), 10 deletions(-) diff --git a/src/routes/billing.rs b/src/routes/billing.rs index 80180a1..8e47146 100644 --- a/src/routes/billing.rs +++ b/src/routes/billing.rs @@ -67,16 +67,20 @@ impl CheckoutRequest { } } +/// Plans billed per seat, and the seat floor they share. +const PER_SEAT_PLANS: [&str; 2] = ["teams", "business"]; +const MIN_SEATS: u32 = 3; + /// Variant id for a plan/interval pair, or `None` when the plan is unknown or -/// its variant is unconfigured. Business is sold monthly only, so a yearly -/// request resolves the same variant rather than failing. +/// its variant is unconfigured. fn variant_id_for(plan: &str, yearly: bool) -> Option { let key = match (plan, yearly) { ("pro", false) => "LS_VARIANT_PRO_MONTHLY", ("pro", true) => "LS_VARIANT_PRO_YEARLY", ("teams", false) => "LS_VARIANT_TEAMS_MONTHLY", ("teams", true) => "LS_VARIANT_TEAMS_YEARLY", - ("business", _) => "LS_VARIANT_BUSINESS_MONTHLY", + ("business", false) => "LS_VARIANT_BUSINESS_MONTHLY", + ("business", true) => "LS_VARIANT_BUSINESS_YEARLY", _ => return None, }; std::env::var(key).ok().filter(|v| !v.is_empty()) @@ -280,10 +284,11 @@ pub async fn create_checkout( return Err(status_response(StatusCode::SERVICE_UNAVAILABLE)); } - // Teams requires at least 3 seats; default to 3 if not specified - let seats = if body.plan == "teams" { - let s = body.seats.unwrap_or(3); - if s < 3 { + // Per-seat plans bill on quantity and share a 3-seat floor, so Business can + // never undercut Teams. Pro is single-seat and sends no quantity at all. + let seats = if PER_SEAT_PLANS.contains(&body.plan.as_str()) { + let s = body.seats.unwrap_or(MIN_SEATS); + if s < MIN_SEATS { return Err(status_response(StatusCode::UNPROCESSABLE_ENTITY)); } Some(s) @@ -622,14 +627,26 @@ mod tests { } #[test] - fn business_is_monthly_only_and_unconfigured_until_its_variant_exists() { + fn business_resolves_both_intervals_once_configured() { let _env = set_variant_env(); assert_eq!(variant_id_for("business", false), None); + assert_eq!(variant_id_for("business", true), None); std::env::set_var("LS_VARIANT_BUSINESS_MONTHLY", "301"); + std::env::set_var("LS_VARIANT_BUSINESS_YEARLY", "302"); assert_eq!(variant_id_for("business", false).as_deref(), Some("301")); - // A yearly request resolves the same variant rather than failing. - assert_eq!(variant_id_for("business", true).as_deref(), Some("301")); + assert_eq!(variant_id_for("business", true).as_deref(), Some("302")); std::env::remove_var("LS_VARIANT_BUSINESS_MONTHLY"); + std::env::remove_var("LS_VARIANT_BUSINESS_YEARLY"); + } + + #[test] + fn every_per_seat_plan_shares_the_three_seat_floor() { + // Business must never undercut Teams: both floor at 3 seats, so the + // cheapest Business plan is always dearer than the cheapest Teams plan. + assert_eq!(MIN_SEATS, 3); + assert!(PER_SEAT_PLANS.contains(&"teams")); + assert!(PER_SEAT_PLANS.contains(&"business")); + assert!(!PER_SEAT_PLANS.contains(&"pro")); } fn set_variant_env() -> std::sync::MutexGuard<'static, ()> { From 04c04185d795e9050d9e111f95bc336625fe4899 Mon Sep 17 00:00:00 2001 From: kipavy Date: Sat, 12 Sep 2026 22:18:42 +0000 Subject: [PATCH 2/2] fix(deploy): pass the Business variant ids through to the container compose.prod.yml enumerates every environment variable explicitly, so adding LS_VARIANT_BUSINESS_MONTHLY and _YEARLY to .env.dockhand alone would not reach the server: variant_id_for would read an unset variable, return None, and the checkout handler would answer 503 for a plan the site was advertising. --- compose.prod.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/compose.prod.yml b/compose.prod.yml index 6608d3a..d56eea0 100644 --- a/compose.prod.yml +++ b/compose.prod.yml @@ -24,6 +24,8 @@ services: LS_VARIANT_PRO_YEARLY: ${LS_VARIANT_PRO_YEARLY} LS_VARIANT_TEAMS_MONTHLY: ${LS_VARIANT_TEAMS_MONTHLY} LS_VARIANT_TEAMS_YEARLY: ${LS_VARIANT_TEAMS_YEARLY} + LS_VARIANT_BUSINESS_MONTHLY: ${LS_VARIANT_BUSINESS_MONTHLY} + LS_VARIANT_BUSINESS_YEARLY: ${LS_VARIANT_BUSINESS_YEARLY} LS_TEST_MODE: ${LS_TEST_MODE:-false} RESEND_API_KEY: ${RESEND_API_KEY} healthcheck: