generalbots/botserver/src/billing/plans.rs
Rodrigo Rodriguez (Pragmatismo) 037db5c381 feat: Major workspace reorganization and documentation update
- Add comprehensive documentation in botbook/ with 12 chapters
- Add botapp/ Tauri desktop application
- Add botdevice/ IoT device support
- Add botlib/ shared library crate
- Add botmodels/ Python ML models service
- Add botplugin/ browser extension
- Add botserver/ reorganized server code
- Add bottemplates/ bot templates
- Add bottest/ integration tests
- Add botui/ web UI server
- Add CI/CD workflows in .forgejo/workflows/
- Add AGENTS.md and PROD.md documentation
- Add dependency management scripts (DEPENDENCIES.sh/ps1)
- Remove legacy src/ structure and migrations
- Clean up temporary and backup files
2026-04-19 08:14:25 -03:00

444 lines
14 KiB
Rust

use crate::billing::{BillingError, LimitValue, PlanConfig, PlanPrice, UsageMetric};
use std::collections::HashMap;
use uuid::Uuid;
pub struct PlanManager {
plans: HashMap<String, PlanConfig>,
}
impl PlanManager {
pub fn new(plans: HashMap<String, PlanConfig>) -> Self {
Self { plans }
}
pub fn get_plan(&self, plan_id: &str) -> Option<&PlanConfig> {
self.plans.get(plan_id)
}
pub fn list_plans(&self) -> Vec<(&String, &PlanConfig)> {
self.plans.iter().collect()
}
pub fn list_public_plans(&self) -> Vec<(&String, &PlanConfig)> {
self.plans
.iter()
.filter(|(id, _)| *id != "enterprise")
.collect()
}
pub fn get_limit(&self, plan_id: &str, metric: UsageMetric) -> Option<LimitValue> {
self.plans.get(plan_id).map(|plan| match metric {
UsageMetric::Messages => plan.limits.messages_per_day,
UsageMetric::StorageBytes => {
match plan.limits.storage_mb {
LimitValue::Limited(mb) => LimitValue::Limited(mb * 1024 * 1024),
LimitValue::Unlimited => LimitValue::Unlimited,
}
}
UsageMetric::ApiCalls => plan.limits.api_calls_per_day,
UsageMetric::Bots => plan.limits.bots,
UsageMetric::Users => plan.limits.users,
UsageMetric::KbDocuments => plan.limits.kb_documents,
UsageMetric::Apps => plan.limits.apps,
})
}
pub fn check_limit(
&self,
plan_id: &str,
metric: UsageMetric,
current_usage: u64,
) -> Result<LimitCheckResult, BillingError> {
let limit = self
.get_limit(plan_id, metric)
.ok_or_else(|| BillingError::PlanNotFound(plan_id.to_string()))?;
match limit {
LimitValue::Unlimited => Ok(LimitCheckResult::Allowed {
remaining: None,
percentage_used: 0.0,
}),
LimitValue::Limited(max) => {
if current_usage >= max {
Ok(LimitCheckResult::Exceeded {
limit: max,
current: current_usage,
})
} else {
let remaining = max - current_usage;
let percentage = (current_usage as f64 / max as f64) * 100.0;
Ok(LimitCheckResult::Allowed {
remaining: Some(remaining),
percentage_used: percentage,
})
}
}
}
}
pub fn can_upgrade(&self, from_plan: &str, to_plan: &str) -> bool {
let plan_order = ["free", "personal", "business", "enterprise"];
let from_idx = plan_order.iter().position(|p| *p == from_plan);
let to_idx = plan_order.iter().position(|p| *p == to_plan);
match (from_idx, to_idx) {
(Some(from), Some(to)) => to > from,
_ => false,
}
}
pub fn can_downgrade(&self, from_plan: &str, to_plan: &str) -> bool {
let plan_order = ["free", "personal", "business", "enterprise"];
let from_idx = plan_order.iter().position(|p| *p == from_plan);
let to_idx = plan_order.iter().position(|p| *p == to_plan);
match (from_idx, to_idx) {
(Some(from), Some(to)) => to < from,
_ => false,
}
}
pub fn get_upgrade_options(&self, current_plan: &str) -> Vec<(&String, &PlanConfig)> {
let plan_order = ["free", "personal", "business", "enterprise"];
let current_idx = plan_order.iter().position(|p| *p == current_plan);
match current_idx {
Some(idx) => self
.plans
.iter()
.filter(|(id, _)| {
plan_order
.iter()
.position(|p| *p == id.as_str())
.map(|plan_idx| plan_idx > idx)
.unwrap_or(false)
})
.collect(),
None => vec![],
}
}
pub fn calculate_proration(
&self,
from_plan: &str,
to_plan: &str,
days_remaining: u32,
days_in_period: u32,
) -> Option<ProrationType> {
let from_config = self.plans.get(from_plan)?;
let to_config = self.plans.get(to_plan)?;
let from_price = match &from_config.price {
PlanPrice::Fixed { amount, .. } => *amount,
PlanPrice::Free => 0,
PlanPrice::Custom => return None,
};
let to_price = match &to_config.price {
PlanPrice::Fixed { amount, .. } => *amount,
PlanPrice::Free => 0,
PlanPrice::Custom => return None,
};
let daily_from = from_price as f64 / days_in_period as f64;
let daily_to = to_price as f64 / days_in_period as f64;
let remaining_value_from = daily_from * days_remaining as f64;
let remaining_cost_to = daily_to * days_remaining as f64;
let difference = remaining_cost_to - remaining_value_from;
if difference > 0.0 {
Some(ProrationType::ChargeNow(difference.ceil() as u64))
} else if difference < 0.0 {
Some(ProrationType::Credit((-difference).ceil() as u64))
} else {
Some(ProrationType::NoChange)
}
}
pub fn validate_downgrade(
&self,
to_plan: &str,
current_usage: &OrganizationUsage,
) -> DowngradeValidation {
let plan = match self.plans.get(to_plan) {
Some(p) => p,
None => {
return DowngradeValidation {
allowed: false,
blockers: vec![DowngradeBlocker::PlanNotFound],
}
}
};
let mut blockers = Vec::new();
if let LimitValue::Limited(max) = plan.limits.bots {
if current_usage.bots > max {
blockers.push(DowngradeBlocker::TooManyBots {
current: current_usage.bots,
limit: max,
});
}
}
if let LimitValue::Limited(max) = plan.limits.users {
if current_usage.users > max {
blockers.push(DowngradeBlocker::TooManyUsers {
current: current_usage.users,
limit: max,
});
}
}
if let LimitValue::Limited(max) = plan.limits.storage_mb {
let max_bytes = max * 1024 * 1024;
if current_usage.storage_bytes > max_bytes {
blockers.push(DowngradeBlocker::TooMuchStorage {
current_mb: current_usage.storage_bytes / 1024 / 1024,
limit_mb: max,
});
}
}
if let LimitValue::Limited(max) = plan.limits.apps {
if current_usage.apps > max {
blockers.push(DowngradeBlocker::TooManyApps {
current: current_usage.apps,
limit: max,
});
}
}
DowngradeValidation {
allowed: blockers.is_empty(),
blockers,
}
}
}
#[derive(Debug, Clone)]
pub enum LimitCheckResult {
Allowed {
remaining: Option<u64>,
percentage_used: f64,
},
Exceeded {
limit: u64,
current: u64,
},
}
impl LimitCheckResult {
pub fn is_allowed(&self) -> bool {
matches!(self, Self::Allowed { .. })
}
pub fn is_warning_threshold(&self, threshold: f64) -> bool {
match self {
Self::Allowed { percentage_used, .. } => *percentage_used >= threshold,
Self::Exceeded { .. } => true,
}
}
}
#[derive(Debug, Clone)]
pub enum ProrationType {
ChargeNow(u64),
Credit(u64),
NoChange,
}
#[derive(Debug, Clone)]
pub struct OrganizationUsage {
pub organization_id: Uuid,
pub bots: u64,
pub users: u64,
pub storage_bytes: u64,
pub apps: u64,
pub kb_documents: u64,
pub messages_today: u64,
pub api_calls_today: u64,
}
impl Default for OrganizationUsage {
fn default() -> Self {
Self {
organization_id: Uuid::nil(),
bots: 0,
users: 0,
storage_bytes: 0,
apps: 0,
kb_documents: 0,
messages_today: 0,
api_calls_today: 0,
}
}
}
#[derive(Debug, Clone)]
pub struct DowngradeValidation {
pub allowed: bool,
pub blockers: Vec<DowngradeBlocker>,
}
#[derive(Debug, Clone)]
pub enum DowngradeBlocker {
PlanNotFound,
TooManyBots { current: u64, limit: u64 },
TooManyUsers { current: u64, limit: u64 },
TooMuchStorage { current_mb: u64, limit_mb: u64 },
TooManyApps { current: u64, limit: u64 },
TooManyKbDocuments { current: u64, limit: u64 },
}
impl std::fmt::Display for DowngradeBlocker {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::PlanNotFound => write!(f, "Target plan not found"),
Self::TooManyBots { current, limit } => {
write!(f, "Too many bots: {current} (limit: {limit}). Please delete some bots.")
}
Self::TooManyUsers { current, limit } => {
write!(f, "Too many users: {current} (limit: {limit}). Please remove some users.")
}
Self::TooMuchStorage { current_mb, limit_mb } => {
write!(f, "Too much storage: {current_mb}MB (limit: {limit_mb}MB). Please delete some files.")
}
Self::TooManyApps { current, limit } => {
write!(f, "Too many apps: {current} (limit: {limit}). Please delete some apps.")
}
Self::TooManyKbDocuments { current, limit } => {
write!(f, "Too many KB documents: {current} (limit: {limit}). Please delete some documents.")
}
}
}
}
pub struct PlanComparison {
pub from_plan: String,
pub to_plan: String,
pub limit_changes: Vec<LimitChange>,
pub feature_changes: FeatureChanges,
pub price_change: PriceChange,
}
#[derive(Debug, Clone)]
pub struct LimitChange {
pub metric: UsageMetric,
pub from: LimitValue,
pub to: LimitValue,
pub direction: ChangeDirection,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ChangeDirection {
Increase,
Decrease,
NoChange,
}
#[derive(Debug, Clone)]
pub struct FeatureChanges {
pub added: Vec<String>,
pub removed: Vec<String>,
}
#[derive(Debug, Clone)]
pub enum PriceChange {
Increase { from: u64, to: u64 },
Decrease { from: u64, to: u64 },
NoChange,
ToCustom,
FromCustom,
}
impl PlanManager {
pub fn compare_plans(&self, from_plan_id: &str, to_plan_id: &str) -> Option<PlanComparison> {
let from = self.plans.get(from_plan_id)?;
let to = self.plans.get(to_plan_id)?;
let limit_changes = vec![
Self::compare_limit(UsageMetric::Messages, from.limits.messages_per_day, to.limits.messages_per_day),
Self::compare_limit(UsageMetric::StorageBytes, from.limits.storage_mb, to.limits.storage_mb),
Self::compare_limit(UsageMetric::Bots, from.limits.bots, to.limits.bots),
Self::compare_limit(UsageMetric::Users, from.limits.users, to.limits.users),
Self::compare_limit(UsageMetric::ApiCalls, from.limits.api_calls_per_day, to.limits.api_calls_per_day),
Self::compare_limit(UsageMetric::Apps, from.limits.apps, to.limits.apps),
];
let added: Vec<String> = to
.features
.iter()
.filter(|f| !from.features.contains(f))
.cloned()
.collect();
let removed: Vec<String> = from
.features
.iter()
.filter(|f| !to.features.contains(f))
.cloned()
.collect();
let price_change = Self::compare_price(&from.price, &to.price);
Some(PlanComparison {
from_plan: from_plan_id.to_string(),
to_plan: to_plan_id.to_string(),
limit_changes,
feature_changes: FeatureChanges { added, removed },
price_change,
})
}
fn compare_limit(metric: UsageMetric, from: LimitValue, to: LimitValue) -> LimitChange {
let direction = match (from, to) {
(LimitValue::Unlimited, LimitValue::Limited(_)) => ChangeDirection::Decrease,
(LimitValue::Limited(_), LimitValue::Unlimited) => ChangeDirection::Increase,
(LimitValue::Limited(f), LimitValue::Limited(t)) => {
if t > f {
ChangeDirection::Increase
} else if t < f {
ChangeDirection::Decrease
} else {
ChangeDirection::NoChange
}
}
(LimitValue::Unlimited, LimitValue::Unlimited) => ChangeDirection::NoChange,
};
LimitChange {
metric,
from,
to,
direction,
}
}
fn compare_price(from: &PlanPrice, to: &PlanPrice) -> PriceChange {
match (from, to) {
(PlanPrice::Free, PlanPrice::Free) => PriceChange::NoChange,
(PlanPrice::Free, PlanPrice::Fixed { amount, .. }) => {
PriceChange::Increase { from: 0, to: *amount }
}
(PlanPrice::Fixed { amount, .. }, PlanPrice::Free) => {
PriceChange::Decrease { from: *amount, to: 0 }
}
(PlanPrice::Fixed { amount: from_amt, .. }, PlanPrice::Fixed { amount: to_amt, .. }) => {
if to_amt > from_amt {
PriceChange::Increase { from: *from_amt, to: *to_amt }
} else if to_amt < from_amt {
PriceChange::Decrease { from: *from_amt, to: *to_amt }
} else {
PriceChange::NoChange
}
}
(_, PlanPrice::Custom) => PriceChange::ToCustom,
(PlanPrice::Custom, _) => PriceChange::FromCustom,
}
}
}