use serde::{Deserialize, Serialize};
use crate::core::server_capabilities::LOCAL_ALWAYS_ON_FEATURES;
pub const UNBOUNDED: u32 = u32::MAX;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Plan {
Free,
Supporter,
Pro,
Team,
Enterprise,
}
impl Plan {
#[must_use]
pub fn all() -> &'static [Plan] {
&[
Plan::Free,
Plan::Supporter,
Plan::Pro,
Plan::Team,
Plan::Enterprise,
]
}
#[must_use]
pub fn rank(self) -> usize {
Plan::all().iter().position(|&p| p == self).unwrap_or(0)
}
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Plan::Free => "free",
Plan::Supporter => "supporter",
Plan::Pro => "pro",
Plan::Team => "team",
Plan::Enterprise => "enterprise",
}
}
#[must_use]
pub fn parse(s: &str) -> Plan {
match s.trim().to_ascii_lowercase().as_str() {
"supporter" | "sponsor" => Plan::Supporter,
"pro" => Plan::Pro,
"team" | "business" | "biz" => Plan::Team,
"enterprise" | "ent" => Plan::Enterprise,
_ => Plan::Free,
}
}
#[must_use]
pub fn entitlements(self) -> Entitlements {
match self {
Plan::Free => Entitlements {
plan: self,
seats: 1,
hosted_index_mb: 0,
managed_connectors: 0,
private_registry: false,
sso_oidc: false,
sso_scim: false,
audit_retention_days: 0,
revenue_share: false,
supporter: false,
cloud_sync: false,
},
Plan::Supporter => Entitlements {
plan: self,
seats: 1,
hosted_index_mb: 0,
managed_connectors: 0,
private_registry: false,
sso_oidc: false,
sso_scim: false,
audit_retention_days: 0,
revenue_share: false,
supporter: true,
cloud_sync: false,
},
Plan::Pro => Entitlements {
plan: self,
seats: 1,
hosted_index_mb: 1_000,
managed_connectors: 0,
private_registry: false,
sso_oidc: false,
sso_scim: false,
audit_retention_days: 0,
revenue_share: false,
supporter: true,
cloud_sync: true,
},
Plan::Team => Entitlements {
plan: self,
seats: UNBOUNDED,
hosted_index_mb: 20_000,
managed_connectors: 10,
private_registry: true,
sso_oidc: true,
sso_scim: false,
audit_retention_days: 365,
revenue_share: true,
supporter: true,
cloud_sync: true,
},
Plan::Enterprise => Entitlements {
plan: self,
seats: UNBOUNDED,
hosted_index_mb: UNBOUNDED,
managed_connectors: UNBOUNDED,
private_registry: true,
sso_oidc: true,
sso_scim: true,
audit_retention_days: 3650,
revenue_share: true,
supporter: true,
cloud_sync: true,
},
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct Entitlements {
pub plan: Plan,
pub seats: u32,
pub hosted_index_mb: u32,
pub managed_connectors: u32,
pub private_registry: bool,
pub sso_oidc: bool,
pub sso_scim: bool,
pub audit_retention_days: u32,
pub revenue_share: bool,
pub supporter: bool,
pub cloud_sync: bool,
}
#[must_use]
pub fn entitlement_allows(plan: Plan, feature: &str) -> bool {
if LOCAL_ALWAYS_ON_FEATURES.contains(&feature) {
return true;
}
let e = plan.entitlements();
match feature {
"private_registry" => e.private_registry,
"sso_oidc" => e.sso_oidc,
"sso_scim" => e.sso_scim,
"revenue_share" => e.revenue_share,
"supporter" => e.supporter,
"cloud_sync" => e.cloud_sync,
"managed_connectors" => e.managed_connectors > 0,
"hosted_index" => e.hosted_index_mb > 0,
"audit_retention" => e.audit_retention_days > 0,
_ => true,
}
}
#[must_use]
pub fn min_plan_for(feature: &str) -> Option<Plan> {
if entitlement_allows(Plan::Free, feature) {
return None;
}
Plan::all()
.iter()
.copied()
.find(|p| entitlement_allows(*p, feature))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn min_plan_for_returns_cheapest_unlocking_plan() {
assert_eq!(min_plan_for("cloud_sync"), Some(Plan::Pro));
assert_eq!(min_plan_for("private_registry"), Some(Plan::Team));
assert_eq!(min_plan_for("revenue_share"), Some(Plan::Team));
assert_eq!(min_plan_for("sso_oidc"), Some(Plan::Team));
assert_eq!(min_plan_for("sso_scim"), Some(Plan::Enterprise));
assert_eq!(min_plan_for("supporter"), Some(Plan::Supporter));
assert_eq!(min_plan_for("read"), None);
assert_eq!(min_plan_for("some_unknown_local_thing"), None);
for feature in LOCAL_ALWAYS_ON_FEATURES {
assert_eq!(
min_plan_for(feature),
None,
"local feature '{feature}' must never require a plan"
);
}
}
#[test]
fn plan_roundtrips_through_wire_id() {
for p in Plan::all() {
assert_eq!(Plan::parse(p.as_str()), *p);
}
assert_eq!(Plan::parse("TEAM"), Plan::Team);
assert_eq!(Plan::parse("garbage"), Plan::Free);
assert_eq!(Plan::parse("pro"), Plan::Pro);
assert_eq!(Plan::parse("supporter"), Plan::Supporter);
assert_eq!(Plan::parse("Sponsor"), Plan::Supporter);
assert_eq!(Plan::parse("business"), Plan::Team);
assert_eq!(Plan::parse("biz"), Plan::Team);
}
#[test]
fn unknown_billing_plan_defaults_to_free_entitlements() {
let plan = Plan::parse("not-a-plan");
assert_eq!(plan, Plan::Free);
assert!(!plan.entitlements().sso_oidc);
}
#[test]
fn team_includes_self_serve_governance() {
let team = Plan::Team.entitlements();
let ent = Plan::Enterprise.entitlements();
assert!(team.sso_oidc && !team.sso_scim);
assert!(entitlement_allows(Plan::Team, "sso_oidc"));
assert!(!entitlement_allows(Plan::Team, "sso_scim"));
assert!(ent.sso_oidc && ent.sso_scim);
assert!(team.private_registry && team.revenue_share);
assert!(team.supporter && team.cloud_sync);
assert_eq!(team.hosted_index_mb, 20_000);
assert_eq!(team.managed_connectors, 10);
assert_eq!(team.audit_retention_days, 365);
assert!(team.audit_retention_days < ent.audit_retention_days);
}
#[test]
fn plan_ladder_is_ordered_by_seats() {
for plans in Plan::all().windows(2) {
let lower = plans[0].entitlements();
let upper = plans[1].entitlements();
assert!(
upper.seats >= lower.seats,
"{} should include at least as many seats as {}",
upper.plan.as_str(),
lower.plan.as_str()
);
}
}
#[test]
fn min_plan_for_sso_oidc_is_team() {
assert_eq!(min_plan_for("sso_oidc"), Some(Plan::Team));
}
#[test]
fn min_plan_for_sso_scim_is_enterprise() {
assert_eq!(min_plan_for("sso_scim"), Some(Plan::Enterprise));
}
#[test]
fn supporter_adds_only_recognition_never_a_capability() {
let e = Plan::Supporter.entitlements();
assert!(e.supporter);
assert!(entitlement_allows(Plan::Supporter, "supporter"));
assert!(!entitlement_allows(Plan::Free, "supporter"));
assert!(!e.cloud_sync);
assert!(!entitlement_allows(Plan::Supporter, "cloud_sync"));
assert_eq!(e.seats, 1);
assert_eq!(e.hosted_index_mb, 0);
assert!(!e.private_registry && !e.sso_scim && !e.revenue_share);
assert!(!entitlement_allows(Plan::Supporter, "private_registry"));
assert!(!entitlement_allows(Plan::Supporter, "sso_scim"));
for feature in LOCAL_ALWAYS_ON_FEATURES {
assert!(entitlement_allows(Plan::Supporter, feature));
}
}
#[test]
fn local_features_are_allowed_on_every_plan() {
for plan in Plan::all() {
for feature in LOCAL_ALWAYS_ON_FEATURES {
assert!(
entitlement_allows(*plan, feature),
"local feature '{feature}' must never be gated (plan {plan:?})"
);
}
}
}
#[test]
fn free_plan_grants_no_commercial_entitlements() {
let e = Plan::Free.entitlements();
assert_eq!(e.seats, 1);
assert!(!e.private_registry);
assert!(!e.sso_scim);
assert!(!e.revenue_share);
assert!(!e.supporter);
assert!(!e.cloud_sync);
assert!(!entitlement_allows(Plan::Free, "sso_scim"));
assert!(!entitlement_allows(Plan::Free, "private_registry"));
assert!(!entitlement_allows(Plan::Free, "cloud_sync"));
}
#[test]
fn pro_grants_cloud_sync_plus_recognition_but_no_team_capability() {
let e = Plan::Pro.entitlements();
assert!(e.supporter);
assert!(e.cloud_sync);
assert!(entitlement_allows(Plan::Pro, "cloud_sync"));
assert!(entitlement_allows(Plan::Pro, "supporter"));
assert_eq!(e.hosted_index_mb, 1_000);
assert!(entitlement_allows(Plan::Pro, "hosted_index"));
assert_eq!(e.seats, 1);
assert!(e.hosted_index_mb < Plan::Team.entitlements().hosted_index_mb);
assert!(!e.private_registry && !e.sso_scim && !e.revenue_share);
assert!(!entitlement_allows(Plan::Pro, "private_registry"));
assert!(!entitlement_allows(Plan::Pro, "sso_scim"));
for feature in LOCAL_ALWAYS_ON_FEATURES {
assert!(entitlement_allows(Plan::Pro, feature));
}
}
#[test]
fn cloud_sync_is_additive_supporter_subset_pro_subset_team() {
assert!(!Plan::Free.entitlements().cloud_sync);
assert!(!Plan::Supporter.entitlements().cloud_sync);
assert!(Plan::Pro.entitlements().cloud_sync);
assert!(Plan::Team.entitlements().cloud_sync);
assert!(Plan::Enterprise.entitlements().cloud_sync);
}
#[test]
fn higher_plans_strictly_add_capabilities() {
let team = Plan::Team.entitlements();
let ent = Plan::Enterprise.entitlements();
assert!(team.private_registry && team.revenue_share);
assert!(team.sso_oidc && !team.sso_scim);
assert!(ent.sso_scim && ent.private_registry);
assert!(entitlement_allows(Plan::Enterprise, "sso_scim"));
assert!(!entitlement_allows(Plan::Team, "sso_scim"));
assert!(!Plan::Free.entitlements().supporter);
assert!(Plan::Supporter.entitlements().supporter);
assert!(team.supporter && ent.supporter);
}
#[test]
fn catalog_matches_golden_fixture() {
let catalog: Vec<Entitlements> = Plan::all().iter().map(|p| p.entitlements()).collect();
let rendered = serde_json::to_string_pretty(&catalog).expect("catalog serializes") + "\n";
let golden = include_str!("../../../../docs/contracts/billing-plane-v1-catalog.json")
.replace("\r\n", "\n");
assert_eq!(
rendered, golden,
"billing-plane-v1 catalog drifted from docs/contracts/billing-plane-v1-catalog.json \
— regenerate the fixture from this catalog and copy it to \
lean-ctx-cloud/contracts/billing-plane-v1-catalog.json"
);
}
}