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,
Business,
Enterprise,
}
impl Plan {
#[must_use]
pub fn all() -> &'static [Plan] {
&[
Plan::Free,
Plan::Supporter,
Plan::Pro,
Plan::Team,
Plan::Business,
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::Business => "business",
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" => Plan::Team,
"business" | "biz" => Plan::Business,
"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: 25,
hosted_index_mb: 5_000,
managed_connectors: 5,
private_registry: true,
sso_oidc: false,
sso_scim: false,
audit_retention_days: 90,
revenue_share: true,
supporter: true,
cloud_sync: true,
},
Plan::Business => Entitlements {
plan: self,
seats: 50,
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::Business));
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::Business);
assert_eq!(Plan::parse("biz"), Plan::Business);
}
#[test]
fn business_is_team_plus_self_serve_governance() {
let team = Plan::Team.entitlements();
let biz = Plan::Business.entitlements();
let ent = Plan::Enterprise.entitlements();
assert!(biz.seats > team.seats && biz.seats < ent.seats);
assert!(biz.hosted_index_mb > team.hosted_index_mb);
assert!(biz.managed_connectors > team.managed_connectors);
assert!(biz.audit_retention_days > team.audit_retention_days);
assert!(biz.audit_retention_days < ent.audit_retention_days);
assert!(biz.sso_oidc && !biz.sso_scim);
assert!(entitlement_allows(Plan::Business, "sso_oidc"));
assert!(!entitlement_allows(Plan::Business, "sso_scim"));
assert!(!team.sso_oidc);
assert!(ent.sso_oidc && ent.sso_scim);
assert!(biz.private_registry && biz.revenue_share);
assert!(biz.supporter && biz.cloud_sync);
for feature in LOCAL_ALWAYS_ON_FEATURES {
assert!(entitlement_allows(Plan::Business, feature));
}
}
#[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!(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"
);
}
}