use std::collections::{HashMap, HashSet};
use super::{error::BillingError, storage::StoredPlan};
#[derive(Clone, Debug, Default)]
pub struct Plans {
plans: HashMap<String, PlanConfig>,
}
impl Plans {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn builder() -> PlansBuilder {
PlansBuilder::new()
}
#[must_use]
pub fn from_stored(stored: Vec<StoredPlan>) -> Self {
let plans = stored
.into_iter()
.map(|sp| {
let config = PlanConfig::from(sp);
(config.id.clone(), config)
})
.collect();
Self { plans }
}
pub fn merge(&mut self, other: Plans) -> Result<(), BillingError> {
for (id, config) in other.plans {
if self.plans.contains_key(&id) {
return Err(BillingError::DuplicatePlanId { plan_id: id });
}
self.plans.insert(id, config);
}
Ok(())
}
pub fn add(&mut self, config: PlanConfig) -> Result<(), BillingError> {
if self.plans.contains_key(&config.id) {
return Err(BillingError::DuplicatePlanId {
plan_id: config.id.clone(),
});
}
self.plans.insert(config.id.clone(), config);
Ok(())
}
#[must_use]
pub fn get(&self, plan_id: &str) -> Option<&PlanConfig> {
self.plans.get(plan_id)
}
#[must_use]
pub fn plan_ids(&self) -> Vec<&str> {
self.plans.keys().map(|s| s.as_str()).collect()
}
#[must_use]
pub fn contains(&self, plan_id: &str) -> bool {
self.plans.contains_key(plan_id)
}
#[must_use]
pub fn len(&self) -> usize {
self.plans.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.plans.is_empty()
}
pub fn iter(&self) -> impl Iterator<Item = (&str, &PlanConfig)> {
self.plans.iter().map(|(k, v)| (k.as_str(), v))
}
#[must_use]
pub fn find_by_stripe_price(&self, price_id: &str) -> Option<&PlanConfig> {
self.plans.values().find(|p| p.stripe_price_id == price_id)
}
#[must_use]
pub fn all_stripe_price_ids(&self) -> Vec<&str> {
let mut ids: Vec<&str> = self
.plans
.values()
.map(|p| p.stripe_price_id.as_str())
.collect();
for plan in self.plans.values() {
if let Some(ref seat_price) = plan.extra_seat_price_id {
ids.push(seat_price.as_str());
}
}
ids
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PlanConfig {
pub id: String,
pub stripe_price_id: String,
pub extra_seat_price_id: Option<String>,
pub included_seats: u32,
pub features: HashSet<String>,
pub limits: PlanLimits,
pub trial_days: Option<u32>,
pub display_name: Option<String>,
pub description: Option<String>,
pub currency: Option<String>,
}
impl PlanConfig {
#[must_use]
pub fn has_feature(&self, feature: &str) -> bool {
self.features.contains(feature)
}
#[must_use]
pub fn supports_extra_seats(&self) -> bool {
self.extra_seat_price_id.is_some()
}
#[must_use]
pub fn total_seats(&self, extra_seats: u32) -> u32 {
self.included_seats.saturating_add(extra_seats)
}
#[must_use]
pub fn check_limit(&self, resource: &str, current: u64) -> LimitCheckResult {
self.limits.check(resource, current)
}
}
impl From<StoredPlan> for PlanConfig {
fn from(stored: StoredPlan) -> Self {
let features = stored
.features
.as_object()
.map(|obj| {
obj.iter()
.filter_map(|(k, v)| {
if v.as_bool().unwrap_or(false) {
Some(k.clone())
} else {
None
}
})
.collect()
})
.unwrap_or_default();
let limits = PlanLimits::from_json(&stored.limits);
Self {
id: stored.id,
stripe_price_id: stored.stripe_price_id,
extra_seat_price_id: stored.stripe_seat_price_id,
included_seats: stored.included_seats,
features,
limits,
trial_days: stored.trial_days,
display_name: Some(stored.name),
description: stored.description,
currency: Some(stored.currency),
}
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct PlanLimits {
pub max_projects: Option<u32>,
pub max_storage_mb: Option<u64>,
pub max_api_calls_monthly: Option<u32>,
pub custom: HashMap<String, u64>,
}
impl PlanLimits {
#[must_use]
pub fn unlimited() -> Self {
Self::default()
}
#[must_use]
pub fn from_json(json: &serde_json::Value) -> Self {
let obj = match json.as_object() {
Some(o) => o,
None => return Self::default(),
};
let mut limits = Self::default();
let mut custom = HashMap::new();
for (key, value) in obj {
let num = value.as_i64().or_else(|| value.as_u64().map(|n| n as i64));
if let Some(n) = num {
match key.as_str() {
"projects" | "max_projects" => limits.max_projects = Some(n as u32),
"storage_mb" | "max_storage_mb" => limits.max_storage_mb = Some(n as u64),
"api_calls" | "max_api_calls" | "max_api_calls_monthly" => {
limits.max_api_calls_monthly = Some(n as u32)
}
_ => {
custom.insert(key.clone(), n as u64);
}
}
}
}
limits.custom = custom;
limits
}
#[must_use]
pub fn check(&self, resource: &str, current: u64) -> LimitCheckResult {
let limit = match resource {
"projects" => self.max_projects.map(|v| v as u64),
"storage_mb" => self.max_storage_mb,
"api_calls" => self.max_api_calls_monthly.map(|v| v as u64),
_ => self.custom.get(resource).copied(),
};
match limit {
None => LimitCheckResult::Unlimited,
Some(max) if current < max => LimitCheckResult::WithinLimit { current, max },
Some(max) => LimitCheckResult::AtLimit { current, max },
}
}
#[must_use]
pub fn get(&self, resource: &str) -> Option<u64> {
match resource {
"projects" => self.max_projects.map(|v| v as u64),
"storage_mb" => self.max_storage_mb,
"api_calls" => self.max_api_calls_monthly.map(|v| v as u64),
_ => self.custom.get(resource).copied(),
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum LimitCheckResult {
Unlimited,
WithinLimit { current: u64, max: u64 },
AtLimit { current: u64, max: u64 },
}
impl LimitCheckResult {
#[must_use]
pub fn is_allowed(&self) -> bool {
matches!(self, Self::Unlimited | Self::WithinLimit { .. })
}
#[must_use]
pub fn is_at_limit(&self) -> bool {
matches!(self, Self::AtLimit { .. })
}
}
#[derive(Debug, Default)]
pub struct PlansBuilder {
plans: HashMap<String, PlanConfig>,
}
impl PlansBuilder {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn plan(self, id: &str) -> PlanBuilder {
PlanBuilder {
parent: self,
id: id.to_string(),
stripe_price_id: None,
extra_seat_price_id: None,
included_seats: 1,
features: HashSet::new(),
limits: PlanLimits::default(),
trial_days: None,
display_name: None,
description: None,
currency: None,
}
}
pub fn build(self) -> Result<Plans, BillingError> {
Ok(Plans { plans: self.plans })
}
fn add_plan(mut self, config: PlanConfig) -> Result<Self, BillingError> {
if self.plans.contains_key(&config.id) {
return Err(BillingError::DuplicatePlanId { plan_id: config.id });
}
self.plans.insert(config.id.clone(), config);
Ok(self)
}
}
#[derive(Debug)]
pub struct PlanBuilder {
parent: PlansBuilder,
id: String,
stripe_price_id: Option<String>,
extra_seat_price_id: Option<String>,
included_seats: u32,
features: HashSet<String>,
limits: PlanLimits,
trial_days: Option<u32>,
display_name: Option<String>,
description: Option<String>,
currency: Option<String>,
}
impl PlanBuilder {
#[must_use]
pub fn stripe_price(mut self, price_id: &str) -> Self {
self.stripe_price_id = Some(price_id.to_string());
self
}
#[must_use]
pub fn extra_seat_price(mut self, price_id: &str) -> Self {
self.extra_seat_price_id = Some(price_id.to_string());
self
}
#[must_use]
pub fn included_seats(mut self, seats: u32) -> Self {
self.included_seats = seats;
self
}
#[must_use]
pub fn features<I, S>(mut self, features: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.features.extend(features.into_iter().map(Into::into));
self
}
#[must_use]
pub fn feature(mut self, feature: &str) -> Self {
self.features.insert(feature.to_string());
self
}
#[must_use]
pub fn max_projects(mut self, max: u32) -> Self {
self.limits.max_projects = Some(max);
self
}
#[must_use]
pub fn max_storage_mb(mut self, max: u64) -> Self {
self.limits.max_storage_mb = Some(max);
self
}
#[must_use]
pub fn max_api_calls(mut self, max: u32) -> Self {
self.limits.max_api_calls_monthly = Some(max);
self
}
#[must_use]
pub fn custom_limit(mut self, name: &str, max: u64) -> Self {
self.limits.custom.insert(name.to_string(), max);
self
}
#[must_use]
pub fn limits(mut self, limits: PlanLimits) -> Self {
self.limits = limits;
self
}
#[must_use]
pub fn trial_days(mut self, days: u32) -> Self {
self.trial_days = Some(days);
self
}
#[must_use]
pub fn display_name(mut self, name: &str) -> Self {
self.display_name = Some(name.to_string());
self
}
#[must_use]
pub fn description(mut self, desc: &str) -> Self {
self.description = Some(desc.to_string());
self
}
#[must_use]
pub fn currency(mut self, currency: &str) -> Self {
self.currency = Some(currency.to_lowercase());
self
}
pub fn done(self) -> Result<PlansBuilder, BillingError> {
let id = self.id;
let stripe_price_id = self
.stripe_price_id
.ok_or(BillingError::MissingStripePrice {
plan_id: id.clone(),
})?;
let config = PlanConfig {
id,
stripe_price_id,
extra_seat_price_id: self.extra_seat_price_id,
included_seats: self.included_seats,
features: self.features,
limits: self.limits,
trial_days: self.trial_days,
display_name: self.display_name,
description: self.description,
currency: self.currency,
};
self.parent.add_plan(config)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PlanChangeType {
Upgrade,
Downgrade,
Lateral,
NoChange,
}
impl std::fmt::Display for PlanChangeType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Upgrade => write!(f, "upgrade"),
Self::Downgrade => write!(f, "downgrade"),
Self::Lateral => write!(f, "lateral"),
Self::NoChange => write!(f, "no_change"),
}
}
}
#[derive(Debug, Clone)]
pub struct PlanComparison {
pub change_type: PlanChangeType,
pub features_gained: HashSet<String>,
pub features_lost: HashSet<String>,
pub seat_difference: i32,
pub extra_seats_support_changed: bool,
pub warnings: Vec<String>,
}
impl PlanComparison {
#[must_use]
pub fn requires_confirmation(&self) -> bool {
!self.features_lost.is_empty() || !self.warnings.is_empty()
}
#[must_use]
pub fn is_safe(&self) -> bool {
self.features_lost.is_empty() && self.warnings.is_empty()
}
}
#[must_use]
pub fn compare_plans(from_plan: &PlanConfig, to_plan: &PlanConfig) -> PlanComparison {
if from_plan.id == to_plan.id {
return PlanComparison {
change_type: PlanChangeType::NoChange,
features_gained: HashSet::new(),
features_lost: HashSet::new(),
seat_difference: 0,
extra_seats_support_changed: false,
warnings: vec![],
};
}
let features_gained: HashSet<String> = to_plan
.features
.difference(&from_plan.features)
.cloned()
.collect();
let features_lost: HashSet<String> = from_plan
.features
.difference(&to_plan.features)
.cloned()
.collect();
let seat_difference = to_plan.included_seats as i32 - from_plan.included_seats as i32;
let extra_seats_support_changed =
from_plan.supports_extra_seats() != to_plan.supports_extra_seats();
let mut warnings = vec![];
if extra_seats_support_changed && from_plan.supports_extra_seats() {
warnings.push(
"New plan does not support extra seats. Extra seats will be removed.".to_string(),
);
}
if seat_difference < 0 {
warnings.push(format!(
"New plan has {} fewer included seats.",
-seat_difference
));
}
let feature_score = features_gained.len() as i32 - features_lost.len() as i32;
let change_type = if feature_score > 0 || seat_difference > 0 {
PlanChangeType::Upgrade
} else if feature_score < 0 || seat_difference < 0 || !features_lost.is_empty() {
PlanChangeType::Downgrade
} else {
PlanChangeType::Lateral
};
PlanComparison {
change_type,
features_gained,
features_lost,
seat_difference,
extra_seats_support_changed,
warnings,
}
}
pub fn can_downgrade(
_from_plan: &PlanConfig,
to_plan: &PlanConfig,
current_extra_seats: u32,
current_total_members: u32,
) -> Result<(), PlanDowngradeError> {
let new_total_seats = if to_plan.supports_extra_seats() {
to_plan.included_seats + current_extra_seats
} else {
to_plan.included_seats
};
if current_total_members > new_total_seats {
return Err(PlanDowngradeError::InsufficientSeats {
current_members: current_total_members,
new_seats: new_total_seats,
});
}
if current_extra_seats > 0 && !to_plan.supports_extra_seats() {
let min_needed = current_total_members.saturating_sub(to_plan.included_seats);
if min_needed > 0 {
return Err(PlanDowngradeError::ExtraSeatsRequired {
extra_needed: min_needed,
});
}
}
Ok(())
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PlanDowngradeError {
InsufficientSeats {
current_members: u32,
new_seats: u32,
},
ExtraSeatsRequired { extra_needed: u32 },
}
impl std::fmt::Display for PlanDowngradeError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::InsufficientSeats {
current_members,
new_seats,
} => {
write!(
f,
"Cannot downgrade: you have {} members but the new plan only supports {} seats",
current_members, new_seats
)
}
Self::ExtraSeatsRequired { extra_needed } => {
write!(
f,
"Cannot downgrade: you need {} extra seats but the new plan doesn't support extra seats",
extra_needed
)
}
}
}
}
impl std::error::Error for PlanDowngradeError {}
impl Plans {
#[must_use]
pub fn suggest_upgrades(&self, current_plan: &PlanConfig) -> Vec<&PlanConfig> {
let mut upgrades: Vec<(&PlanConfig, usize)> = self
.plans
.values()
.filter(|p| {
p.id != current_plan.id
&& p.features.is_superset(¤t_plan.features)
&& p.features.len() > current_plan.features.len()
})
.map(|p| {
let additional = p.features.len() - current_plan.features.len();
(p, additional)
})
.collect();
upgrades.sort_by(|a, b| b.1.cmp(&a.1));
upgrades.into_iter().map(|(p, _)| p).collect()
}
#[must_use]
pub fn next_tier_up(&self, current_plan: &PlanConfig) -> Option<&PlanConfig> {
self.plans
.values()
.filter(|p| {
p.id != current_plan.id
&& p.features.is_superset(¤t_plan.features)
&& p.features.len() > current_plan.features.len()
})
.min_by_key(|p| p.features.len())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_build_plans() {
let plans = Plans::builder()
.plan("starter")
.stripe_price("price_starter")
.included_seats(3)
.features(["reports", "email_support"])
.trial_days(14)
.done()
.unwrap()
.plan("pro")
.stripe_price("price_pro")
.extra_seat_price("price_seat")
.included_seats(5)
.features(["reports", "api_access", "priority_support"])
.max_projects(100)
.done()
.unwrap()
.build()
.unwrap();
assert_eq!(plans.len(), 2);
assert!(plans.contains("starter"));
assert!(plans.contains("pro"));
}
#[test]
fn test_plan_features() {
let plans = Plans::builder()
.plan("starter")
.stripe_price("price_starter")
.features(["reports"])
.done()
.unwrap()
.plan("pro")
.stripe_price("price_pro")
.features(["reports", "api_access"])
.done()
.unwrap()
.build()
.unwrap();
let starter = plans.get("starter").unwrap();
assert!(starter.has_feature("reports"));
assert!(!starter.has_feature("api_access"));
let pro = plans.get("pro").unwrap();
assert!(pro.has_feature("reports"));
assert!(pro.has_feature("api_access"));
}
#[test]
fn test_plan_seats() {
let plans = Plans::builder()
.plan("starter")
.stripe_price("price_starter")
.included_seats(3)
.done()
.unwrap()
.plan("pro")
.stripe_price("price_pro")
.extra_seat_price("price_seat")
.included_seats(5)
.done()
.unwrap()
.build()
.unwrap();
let starter = plans.get("starter").unwrap();
assert_eq!(starter.included_seats, 3);
assert!(!starter.supports_extra_seats());
assert_eq!(starter.total_seats(0), 3);
let pro = plans.get("pro").unwrap();
assert_eq!(pro.included_seats, 5);
assert!(pro.supports_extra_seats());
assert_eq!(pro.total_seats(3), 8);
}
#[test]
fn test_plan_limits() {
let plans = Plans::builder()
.plan("starter")
.stripe_price("price_starter")
.max_projects(10)
.max_storage_mb(1024)
.done()
.unwrap()
.plan("pro")
.stripe_price("price_pro")
.max_projects(100)
.custom_limit("widgets", 500)
.done()
.unwrap()
.build()
.unwrap();
let starter = plans.get("starter").unwrap();
assert!(starter.check_limit("projects", 5).is_allowed());
assert!(starter.check_limit("projects", 10).is_at_limit());
assert!(starter.check_limit("projects", 15).is_at_limit());
let pro = plans.get("pro").unwrap();
assert_eq!(
pro.check_limit("widgets", 400),
LimitCheckResult::WithinLimit {
current: 400,
max: 500
}
);
}
#[test]
fn test_unlimited_limits() {
let plans = Plans::builder()
.plan("enterprise")
.stripe_price("price_enterprise")
.done()
.unwrap()
.build()
.unwrap();
let enterprise = plans.get("enterprise").unwrap();
assert_eq!(
enterprise.check_limit("projects", 10000),
LimitCheckResult::Unlimited
);
}
#[test]
fn test_find_by_stripe_price() {
let plans = Plans::builder()
.plan("starter")
.stripe_price("price_abc123")
.done()
.unwrap()
.plan("pro")
.stripe_price("price_xyz789")
.done()
.unwrap()
.build()
.unwrap();
let found = plans.find_by_stripe_price("price_abc123");
assert!(found.is_some());
assert_eq!(found.unwrap().id, "starter");
let not_found = plans.find_by_stripe_price("price_unknown");
assert!(not_found.is_none());
}
#[test]
fn test_all_stripe_price_ids() {
let plans = Plans::builder()
.plan("starter")
.stripe_price("price_starter")
.done()
.unwrap()
.plan("pro")
.stripe_price("price_pro")
.extra_seat_price("price_seat")
.done()
.unwrap()
.build()
.unwrap();
let ids = plans.all_stripe_price_ids();
assert!(ids.contains(&"price_starter"));
assert!(ids.contains(&"price_pro"));
assert!(ids.contains(&"price_seat"));
}
#[test]
fn test_trial_days() {
let plans = Plans::builder()
.plan("starter")
.stripe_price("price_starter")
.trial_days(14)
.done()
.unwrap()
.plan("pro")
.stripe_price("price_pro")
.done()
.unwrap()
.build()
.unwrap();
let starter = plans.get("starter").unwrap();
assert_eq!(starter.trial_days, Some(14));
let pro = plans.get("pro").unwrap();
assert_eq!(pro.trial_days, None);
}
#[test]
fn test_compare_plans_upgrade() {
let plans = Plans::builder()
.plan("starter")
.stripe_price("price_starter")
.included_seats(3)
.features(["reports"])
.done()
.unwrap()
.plan("pro")
.stripe_price("price_pro")
.included_seats(5)
.features(["reports", "api_access", "priority_support"])
.done()
.unwrap()
.build()
.unwrap();
let starter = plans.get("starter").unwrap();
let pro = plans.get("pro").unwrap();
let comparison = compare_plans(starter, pro);
assert_eq!(comparison.change_type, PlanChangeType::Upgrade);
assert!(comparison.features_gained.contains("api_access"));
assert!(comparison.features_gained.contains("priority_support"));
assert!(comparison.features_lost.is_empty());
assert_eq!(comparison.seat_difference, 2);
assert!(comparison.is_safe());
}
#[test]
fn test_compare_plans_downgrade() {
let plans = Plans::builder()
.plan("starter")
.stripe_price("price_starter")
.included_seats(3)
.features(["reports"])
.done()
.unwrap()
.plan("pro")
.stripe_price("price_pro")
.included_seats(5)
.features(["reports", "api_access"])
.done()
.unwrap()
.build()
.unwrap();
let starter = plans.get("starter").unwrap();
let pro = plans.get("pro").unwrap();
let comparison = compare_plans(pro, starter);
assert_eq!(comparison.change_type, PlanChangeType::Downgrade);
assert!(comparison.features_lost.contains("api_access"));
assert!(comparison.features_gained.is_empty());
assert_eq!(comparison.seat_difference, -2);
assert!(comparison.requires_confirmation());
}
#[test]
fn test_compare_plans_no_change() {
let plans = Plans::builder()
.plan("starter")
.stripe_price("price_starter")
.features(["reports"])
.done()
.unwrap()
.build()
.unwrap();
let starter = plans.get("starter").unwrap();
let comparison = compare_plans(starter, starter);
assert_eq!(comparison.change_type, PlanChangeType::NoChange);
}
#[test]
fn test_compare_plans_lateral() {
let plans = Plans::builder()
.plan("monthly")
.stripe_price("price_monthly")
.included_seats(5)
.features(["reports", "api_access"])
.done()
.unwrap()
.plan("yearly")
.stripe_price("price_yearly")
.included_seats(5)
.features(["reports", "api_access"])
.done()
.unwrap()
.build()
.unwrap();
let monthly = plans.get("monthly").unwrap();
let yearly = plans.get("yearly").unwrap();
let comparison = compare_plans(monthly, yearly);
assert_eq!(comparison.change_type, PlanChangeType::Lateral);
}
#[test]
fn test_can_downgrade_allowed() {
let plans = Plans::builder()
.plan("pro")
.stripe_price("price_pro")
.extra_seat_price("price_seat")
.included_seats(5)
.done()
.unwrap()
.plan("starter")
.stripe_price("price_starter")
.included_seats(3)
.done()
.unwrap()
.build()
.unwrap();
let pro = plans.get("pro").unwrap();
let starter = plans.get("starter").unwrap();
assert!(can_downgrade(pro, starter, 0, 3).is_ok());
}
#[test]
fn test_can_downgrade_insufficient_seats() {
let plans = Plans::builder()
.plan("pro")
.stripe_price("price_pro")
.extra_seat_price("price_seat")
.included_seats(10)
.done()
.unwrap()
.plan("starter")
.stripe_price("price_starter")
.included_seats(3)
.done()
.unwrap()
.build()
.unwrap();
let pro = plans.get("pro").unwrap();
let starter = plans.get("starter").unwrap();
let result = can_downgrade(pro, starter, 0, 8);
assert!(result.is_err());
assert!(matches!(
result.unwrap_err(),
PlanDowngradeError::InsufficientSeats {
current_members: 8,
new_seats: 3
}
));
}
#[test]
fn test_can_downgrade_with_extra_seats_that_fit() {
let plans = Plans::builder()
.plan("pro")
.stripe_price("price_pro")
.extra_seat_price("price_seat")
.included_seats(5)
.done()
.unwrap()
.plan("basic")
.stripe_price("price_basic")
.included_seats(10) .done()
.unwrap()
.build()
.unwrap();
let pro = plans.get("pro").unwrap();
let basic = plans.get("basic").unwrap();
let result = can_downgrade(pro, basic, 2, 5);
assert!(result.is_ok());
let result = can_downgrade(pro, basic, 5, 10);
assert!(result.is_ok());
let result = can_downgrade(pro, basic, 6, 11);
assert!(result.is_err());
}
#[test]
fn test_suggest_upgrades() {
let plans = Plans::builder()
.plan("free")
.stripe_price("price_free")
.features(["basic"])
.done()
.unwrap()
.plan("starter")
.stripe_price("price_starter")
.features(["basic", "reports"])
.done()
.unwrap()
.plan("pro")
.stripe_price("price_pro")
.features(["basic", "reports", "api_access"])
.done()
.unwrap()
.plan("enterprise")
.stripe_price("price_enterprise")
.features(["basic", "reports", "api_access", "sso", "audit"])
.done()
.unwrap()
.build()
.unwrap();
let free = plans.get("free").unwrap();
let suggestions = plans.suggest_upgrades(free);
assert_eq!(suggestions.len(), 3);
assert_eq!(suggestions[0].id, "enterprise"); assert_eq!(suggestions[1].id, "pro");
assert_eq!(suggestions[2].id, "starter"); }
#[test]
fn test_next_tier_up() {
let plans = Plans::builder()
.plan("free")
.stripe_price("price_free")
.features(["basic"])
.done()
.unwrap()
.plan("starter")
.stripe_price("price_starter")
.features(["basic", "reports"])
.done()
.unwrap()
.plan("pro")
.stripe_price("price_pro")
.features(["basic", "reports", "api_access"])
.done()
.unwrap()
.build()
.unwrap();
let free = plans.get("free").unwrap();
let next = plans.next_tier_up(free);
assert!(next.is_some());
assert_eq!(next.unwrap().id, "starter"); }
#[test]
fn test_done_without_stripe_price_returns_error() {
let result = Plans::builder().plan("starter").done();
assert!(matches!(
result,
Err(BillingError::MissingStripePrice { plan_id }) if plan_id == "starter"
));
}
#[test]
fn test_duplicate_plan_id_returns_error() {
let result = Plans::builder()
.plan("starter")
.stripe_price("price_starter")
.done()
.unwrap()
.plan("starter")
.stripe_price("price_starter_v2")
.done();
assert!(matches!(
result,
Err(BillingError::DuplicatePlanId { plan_id }) if plan_id == "starter"
));
}
#[test]
fn test_add_duplicate_plan_id_returns_error() {
let mut plans = Plans::new();
let plan = PlanConfig {
id: "starter".to_string(),
stripe_price_id: "price_starter".to_string(),
extra_seat_price_id: None,
included_seats: 1,
features: HashSet::new(),
limits: PlanLimits::default(),
trial_days: None,
display_name: None,
description: None,
currency: None,
};
plans.add(plan.clone()).unwrap();
let result = plans.add(plan);
assert!(matches!(
result,
Err(BillingError::DuplicatePlanId { plan_id }) if plan_id == "starter"
));
}
#[test]
fn test_merge_duplicate_plan_id_returns_error() {
let mut existing = Plans::builder()
.plan("starter")
.stripe_price("price_starter")
.done()
.unwrap()
.build()
.unwrap();
let incoming = Plans::builder()
.plan("starter")
.stripe_price("price_starter_new")
.done()
.unwrap()
.build()
.unwrap();
let result = existing.merge(incoming);
assert!(matches!(
result,
Err(BillingError::DuplicatePlanId { plan_id }) if plan_id == "starter"
));
}
#[test]
fn test_plan_change_type_display() {
assert_eq!(PlanChangeType::Upgrade.to_string(), "upgrade");
assert_eq!(PlanChangeType::Downgrade.to_string(), "downgrade");
assert_eq!(PlanChangeType::Lateral.to_string(), "lateral");
assert_eq!(PlanChangeType::NoChange.to_string(), "no_change");
}
#[test]
fn test_plan_downgrade_error_display() {
let err = PlanDowngradeError::InsufficientSeats {
current_members: 10,
new_seats: 3,
};
assert!(err.to_string().contains("10 members"));
assert!(err.to_string().contains("3 seats"));
let err = PlanDowngradeError::ExtraSeatsRequired { extra_needed: 5 };
assert!(err.to_string().contains("5 extra seats"));
}
}