From 89046b18e1198eb2498f7b985798a74b91a7e62f Mon Sep 17 00:00:00 2001 From: kipavy Date: Sat, 12 Sep 2026 21:47:11 +0000 Subject: [PATCH] fix(billing): honour annual checkouts, and let Business be sold MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Annual was unbuyable from every surface. The portal sends `billing_period: "annual"` and the desktop client sends nothing, but the handler only read `interval == "yearly"`. CheckoutRequest derives Deserialize without deny_unknown_fields, so billing_period was silently dropped, yearly evaluated false, and every checkout resolved the monthly variant — including for customers who picked annual, which is the price the pricing page leads with. wants_yearly() now reads interval, falls back to billing_period, and accepts "annual" alongside "yearly"; an explicit interval still wins. LS_VARIANT_PRO_YEARLY and LS_VARIANT_TEAMS_YEARLY were therefore dead configuration. They are now reachable. Business could not be sold at all: create_checkout rejected the plan with 400, and tier_from_variant_id resolved only pro and teams, so a Business purchase would have been charged while the webhook logged "variant is not configured; skipping tier update" and granted nothing. Both now cover business via LS_VARIANT_BUSINESS_MONTHLY/_YEARLY, and the plan resolves monthly-only — a yearly request returns the same variant rather than failing, matching a pricing card that advertises no annual discount. This deliberately retires the invariant that variant mapping can never grant business. The test locking it kept passing only because it never set the business variables, so it is rewritten to lock the half that still holds: no pro, teams, or unknown variant may reach business, and only the configured business variant may. Business intentionally has no seat minimum; Teams keeps its 3-seat floor. --- src/lemonsqueezy.rs | 6 +++ src/routes/billing.rs | 111 +++++++++++++++++++++++++++++++++++++----- 2 files changed, 104 insertions(+), 13 deletions(-) diff --git a/src/lemonsqueezy.rs b/src/lemonsqueezy.rs index 792a583..d9e086f 100644 --- a/src/lemonsqueezy.rs +++ b/src/lemonsqueezy.rs @@ -393,6 +393,8 @@ pub fn tier_from_variant_id(variant_id: &str) -> Option<&'static str> { let pro_yearly = std::env::var("LS_VARIANT_PRO_YEARLY").ok(); let teams_monthly = std::env::var("LS_VARIANT_TEAMS_MONTHLY").ok(); let teams_yearly = std::env::var("LS_VARIANT_TEAMS_YEARLY").ok(); + let business_monthly = std::env::var("LS_VARIANT_BUSINESS_MONTHLY").ok(); + let business_yearly = std::env::var("LS_VARIANT_BUSINESS_YEARLY").ok(); if pro_monthly.as_deref() == Some(variant_id) || pro_yearly.as_deref() == Some(variant_id) { Some("pro") @@ -400,6 +402,10 @@ pub fn tier_from_variant_id(variant_id: &str) -> Option<&'static str> { || teams_yearly.as_deref() == Some(variant_id) { Some("teams") + } else if business_monthly.as_deref() == Some(variant_id) + || business_yearly.as_deref() == Some(variant_id) + { + Some("business") } else { None } diff --git a/src/routes/billing.rs b/src/routes/billing.rs index b1dd95e..80180a1 100644 --- a/src/routes/billing.rs +++ b/src/routes/billing.rs @@ -47,9 +47,39 @@ pub struct UpdateSeatsRequest { #[derive(Deserialize)] pub struct CheckoutRequest { - pub plan: String, // "pro" | "teams" + pub plan: String, // "pro" | "teams" | "business" pub seats: Option, pub interval: Option, // "monthly" | "yearly", defaults to "monthly" + // The portal sends `billing_period: "annual"`; only `interval` was ever read, + // so every annual checkout silently resolved the monthly variant. + #[serde(default)] + pub billing_period: Option, +} + +impl CheckoutRequest { + fn wants_yearly(&self) -> bool { + let raw = self + .interval + .as_deref() + .or(self.billing_period.as_deref()) + .unwrap_or("monthly"); + matches!(raw, "yearly" | "annual" | "annually" | "year") + } +} + +/// 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. +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", + _ => return None, + }; + std::env::var(key).ok().filter(|v| !v.is_empty()) } #[derive(Serialize)] @@ -240,14 +270,11 @@ pub async fn create_checkout( let store_id = std::env::var("LEMONSQUEEZY_STORE_ID").unwrap_or_default(); let api_key = std::env::var("LEMONSQUEEZY_API_KEY").unwrap_or_default(); - let yearly = body.interval.as_deref().unwrap_or("monthly") == "yearly"; - let variant_id = match (body.plan.as_str(), yearly) { - ("pro", false) => std::env::var("LS_VARIANT_PRO_MONTHLY").unwrap_or_default(), - ("pro", true) => std::env::var("LS_VARIANT_PRO_YEARLY").unwrap_or_default(), - ("teams", false) => std::env::var("LS_VARIANT_TEAMS_MONTHLY").unwrap_or_default(), - ("teams", true) => std::env::var("LS_VARIANT_TEAMS_YEARLY").unwrap_or_default(), - _ => return Err(status_response(StatusCode::BAD_REQUEST)), - }; + let yearly = body.wants_yearly(); + if !matches!(body.plan.as_str(), "pro" | "teams" | "business") { + return Err(status_response(StatusCode::BAD_REQUEST)); + } + let variant_id = variant_id_for(&body.plan, yearly).unwrap_or_default(); if store_id.is_empty() || api_key.is_empty() || variant_id.is_empty() { return Err(status_response(StatusCode::SERVICE_UNAVAILABLE)); @@ -551,6 +578,60 @@ mod tests { /// Set the variant env vars and return the held env lock; keep the guard /// alive (`let _env = set_variant_env();`) so the test runs serially. + fn req(plan: &str, interval: Option<&str>, billing_period: Option<&str>) -> CheckoutRequest { + CheckoutRequest { + plan: plan.to_string(), + seats: None, + interval: interval.map(ToOwned::to_owned), + billing_period: billing_period.map(ToOwned::to_owned), + } + } + + #[test] + fn interval_yearly_selects_the_yearly_variant() { + assert!(req("teams", Some("yearly"), None).wants_yearly()); + } + + #[test] + fn portals_billing_period_annual_is_honoured() { + // The portal sends billing_period: "annual"; before this it was dropped + // and every annual checkout resolved the monthly variant. + assert!(req("teams", None, Some("annual")).wants_yearly()); + } + + #[test] + fn monthly_and_absent_both_mean_monthly() { + assert!(!req("teams", None, Some("monthly")).wants_yearly()); + assert!(!req("teams", Some("monthly"), None).wants_yearly()); + assert!(!req("teams", None, None).wants_yearly()); + } + + #[test] + fn explicit_interval_wins_over_billing_period() { + assert!(req("pro", Some("yearly"), Some("monthly")).wants_yearly()); + } + + #[test] + fn variant_lookup_covers_every_sellable_plan() { + let _env = set_variant_env(); + assert_eq!(variant_id_for("pro", false).as_deref(), Some("101")); + assert_eq!(variant_id_for("pro", true).as_deref(), Some("102")); + assert_eq!(variant_id_for("teams", false).as_deref(), Some("201")); + assert_eq!(variant_id_for("teams", true).as_deref(), Some("202")); + assert_eq!(variant_id_for("nope", false), None); + } + + #[test] + fn business_is_monthly_only_and_unconfigured_until_its_variant_exists() { + let _env = set_variant_env(); + assert_eq!(variant_id_for("business", false), None); + std::env::set_var("LS_VARIANT_BUSINESS_MONTHLY", "301"); + 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")); + std::env::remove_var("LS_VARIANT_BUSINESS_MONTHLY"); + } + fn set_variant_env() -> std::sync::MutexGuard<'static, ()> { let guard = crate::test_support::env_lock(); std::env::set_var("LS_VARIANT_PRO_MONTHLY", "101"); @@ -604,10 +685,10 @@ mod tests { } #[test] - fn tier_from_variant_id_never_grants_business_tier() { - // "teams" is the ceiling this map can grant: there is no LS_VARIANT_BUSINESS_*, - // so a Business plan is never provisioned by variant mapping. Locks that - // invariant against a future variant being wired to "business" here by mistake. + fn business_tier_is_granted_only_by_its_own_configured_variant() { + // Business became self-serve, so variant mapping may now grant it — but only + // via LS_VARIANT_BUSINESS_*. No pro/teams/unknown variant may ever reach it, + // which is the half of the old invariant still worth locking. let _env = set_variant_env(); for id in ["101", "102", "201", "202", "999", ""] { assert_ne!( @@ -616,6 +697,10 @@ mod tests { "variant {id} must not map to business", ); } + std::env::set_var("LS_VARIANT_BUSINESS_MONTHLY", "301"); + assert_eq!(tier_from_variant_id("301"), Some("business")); + assert_ne!(tier_from_variant_id("999"), Some("business")); + std::env::remove_var("LS_VARIANT_BUSINESS_MONTHLY"); } #[test]