- Add shell_script_arg() method for bash/sh/cmd -c scripts - Allow > < redirects in shell scripts (blocked in regular args) - Allow && || command chaining in shell scripts - Update safe_sh_command functions to use shell_script_arg - Update run_commands, start, and LLM server commands - Block dangerous patterns: backticks, path traversal - Fix struct field mismatches and type errors
839 lines
26 KiB
Rust
839 lines
26 KiB
Rust
use chrono::{DateTime, Duration, Utc};
|
|
use serde::{Deserialize, Serialize};
|
|
use std::collections::HashMap;
|
|
use std::sync::Arc;
|
|
use tokio::sync::RwLock;
|
|
use uuid::Uuid;
|
|
|
|
use crate::billing::{BillingError, PlanConfig, Subscription, SubscriptionStatus};
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct SubscriptionLifecycleEvent {
|
|
pub id: Uuid,
|
|
pub subscription_id: Uuid,
|
|
pub organization_id: Uuid,
|
|
pub event_type: LifecycleEventType,
|
|
pub from_plan: Option<String>,
|
|
pub to_plan: Option<String>,
|
|
pub timestamp: DateTime<Utc>,
|
|
pub metadata: HashMap<String, String>,
|
|
pub processed: bool,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum LifecycleEventType {
|
|
Created,
|
|
Activated,
|
|
Upgraded,
|
|
Downgraded,
|
|
Renewed,
|
|
Paused,
|
|
Resumed,
|
|
CancellationRequested,
|
|
Cancelled,
|
|
Expired,
|
|
PaymentFailed,
|
|
PaymentRecovered,
|
|
TrialStarted,
|
|
TrialEnded,
|
|
GracePeriodStarted,
|
|
GracePeriodEnded,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct CreateSubscriptionRequest {
|
|
pub organization_id: Uuid,
|
|
pub plan_id: String,
|
|
pub payment_method_id: Option<String>,
|
|
pub trial_days: Option<u32>,
|
|
pub coupon_code: Option<String>,
|
|
pub metadata: Option<HashMap<String, String>>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct UpgradeDowngradeRequest {
|
|
pub subscription_id: Uuid,
|
|
pub new_plan_id: String,
|
|
pub prorate: bool,
|
|
pub immediate: bool,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct CancelSubscriptionRequest {
|
|
pub subscription_id: Uuid,
|
|
pub reason: Option<String>,
|
|
pub feedback: Option<String>,
|
|
pub cancel_immediately: bool,
|
|
pub offer_retention: bool,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct RetentionOffer {
|
|
pub id: Uuid,
|
|
pub subscription_id: Uuid,
|
|
pub offer_type: RetentionOfferType,
|
|
pub discount_percent: Option<u32>,
|
|
pub free_months: Option<u32>,
|
|
pub expires_at: DateTime<Utc>,
|
|
pub accepted: bool,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum RetentionOfferType {
|
|
Discount,
|
|
FreeMonth,
|
|
PlanDowngrade,
|
|
FeatureUnlock,
|
|
PersonalSupport,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct SubscriptionChange {
|
|
pub subscription_id: Uuid,
|
|
pub change_type: ChangeType,
|
|
pub effective_date: DateTime<Utc>,
|
|
pub from_plan: String,
|
|
pub to_plan: String,
|
|
pub proration_amount: Option<i64>,
|
|
pub status: ChangeStatus,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum ChangeType {
|
|
Upgrade,
|
|
Downgrade,
|
|
PlanChange,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum ChangeStatus {
|
|
Pending,
|
|
Scheduled,
|
|
Applied,
|
|
Cancelled,
|
|
Failed,
|
|
}
|
|
|
|
pub struct SubscriptionLifecycleService {
|
|
subscriptions: Arc<RwLock<HashMap<Uuid, Subscription>>>,
|
|
events: Arc<RwLock<Vec<SubscriptionLifecycleEvent>>>,
|
|
pending_changes: Arc<RwLock<HashMap<Uuid, SubscriptionChange>>>,
|
|
retention_offers: Arc<RwLock<HashMap<Uuid, RetentionOffer>>>,
|
|
plans: Arc<RwLock<HashMap<String, PlanConfig>>>,
|
|
}
|
|
|
|
impl SubscriptionLifecycleService {
|
|
pub fn new(plans: HashMap<String, PlanConfig>) -> Self {
|
|
Self {
|
|
subscriptions: Arc::new(RwLock::new(HashMap::new())),
|
|
events: Arc::new(RwLock::new(Vec::new())),
|
|
pending_changes: Arc::new(RwLock::new(HashMap::new())),
|
|
retention_offers: Arc::new(RwLock::new(HashMap::new())),
|
|
plans: Arc::new(RwLock::new(plans)),
|
|
}
|
|
}
|
|
|
|
pub async fn create_subscription(
|
|
&self,
|
|
request: CreateSubscriptionRequest,
|
|
) -> Result<Subscription, LifecycleError> {
|
|
let plans = self.plans.read().await;
|
|
let plan = plans
|
|
.get(&request.plan_id)
|
|
.ok_or_else(|| LifecycleError::PlanNotFound(request.plan_id.clone()))?;
|
|
|
|
let now = Utc::now();
|
|
let trial_days = request.trial_days.or(plan.trial_days).unwrap_or(0);
|
|
|
|
let (status, period_start, period_end) = if trial_days > 0 {
|
|
(
|
|
SubscriptionStatus::Trialing,
|
|
now,
|
|
now + Duration::days(trial_days as i64),
|
|
)
|
|
} else {
|
|
(
|
|
SubscriptionStatus::Active,
|
|
now,
|
|
now + Duration::days(30),
|
|
)
|
|
};
|
|
|
|
let subscription = Subscription {
|
|
id: Uuid::new_v4(),
|
|
organization_id: request.organization_id,
|
|
plan_id: request.plan_id.clone(),
|
|
status,
|
|
current_period_start: period_start,
|
|
current_period_end: period_end,
|
|
stripe_subscription_id: None,
|
|
stripe_customer_id: None,
|
|
created_at: now,
|
|
updated_at: now,
|
|
};
|
|
|
|
let mut subscriptions = self.subscriptions.write().await;
|
|
subscriptions.insert(subscription.id, subscription.clone());
|
|
|
|
drop(subscriptions);
|
|
|
|
let event_type = if trial_days > 0 {
|
|
LifecycleEventType::TrialStarted
|
|
} else {
|
|
LifecycleEventType::Created
|
|
};
|
|
|
|
self.record_event(
|
|
subscription.id,
|
|
request.organization_id,
|
|
event_type,
|
|
None,
|
|
Some(request.plan_id),
|
|
request.metadata.unwrap_or_default(),
|
|
)
|
|
.await;
|
|
|
|
Ok(subscription)
|
|
}
|
|
|
|
pub async fn get_subscription(&self, subscription_id: Uuid) -> Option<Subscription> {
|
|
let subscriptions = self.subscriptions.read().await;
|
|
subscriptions.get(&subscription_id).cloned()
|
|
}
|
|
|
|
pub async fn get_subscription_by_org(&self, organization_id: Uuid) -> Option<Subscription> {
|
|
let subscriptions = self.subscriptions.read().await;
|
|
subscriptions
|
|
.values()
|
|
.find(|s| s.organization_id == organization_id && s.status != SubscriptionStatus::Canceled)
|
|
.cloned()
|
|
}
|
|
|
|
pub async fn upgrade_subscription(
|
|
&self,
|
|
request: UpgradeDowngradeRequest,
|
|
) -> Result<SubscriptionChange, LifecycleError> {
|
|
let mut subscriptions = self.subscriptions.write().await;
|
|
let subscription = subscriptions
|
|
.get_mut(&request.subscription_id)
|
|
.ok_or(LifecycleError::SubscriptionNotFound)?;
|
|
|
|
let plans = self.plans.read().await;
|
|
let current_plan = plans
|
|
.get(&subscription.plan_id)
|
|
.ok_or_else(|| LifecycleError::PlanNotFound(subscription.plan_id.clone()))?;
|
|
let new_plan = plans
|
|
.get(&request.new_plan_id)
|
|
.ok_or_else(|| LifecycleError::PlanNotFound(request.new_plan_id.clone()))?;
|
|
|
|
let is_upgrade = self.is_upgrade(current_plan, new_plan);
|
|
if !is_upgrade {
|
|
return Err(LifecycleError::InvalidOperation(
|
|
"Use downgrade for moving to a lower plan".to_string(),
|
|
));
|
|
}
|
|
|
|
let proration_amount = if request.prorate {
|
|
self.calculate_proration(subscription, current_plan, new_plan)
|
|
} else {
|
|
None
|
|
};
|
|
|
|
let change = SubscriptionChange {
|
|
subscription_id: subscription.id,
|
|
change_type: ChangeType::Upgrade,
|
|
effective_date: if request.immediate {
|
|
Utc::now()
|
|
} else {
|
|
subscription.current_period_end
|
|
},
|
|
from_plan: subscription.plan_id.clone(),
|
|
to_plan: request.new_plan_id.clone(),
|
|
proration_amount,
|
|
status: if request.immediate {
|
|
ChangeStatus::Applied
|
|
} else {
|
|
ChangeStatus::Scheduled
|
|
},
|
|
};
|
|
|
|
if request.immediate {
|
|
let old_plan = subscription.plan_id.clone();
|
|
let org_id = subscription.organization_id;
|
|
subscription.plan_id = request.new_plan_id.clone();
|
|
subscription.updated_at = Utc::now();
|
|
|
|
drop(subscriptions);
|
|
drop(plans);
|
|
|
|
self.record_event(
|
|
change.subscription_id,
|
|
org_id,
|
|
LifecycleEventType::Upgraded,
|
|
Some(old_plan),
|
|
Some(request.new_plan_id),
|
|
HashMap::new(),
|
|
)
|
|
.await;
|
|
} else {
|
|
drop(subscriptions);
|
|
drop(plans);
|
|
|
|
let mut pending = self.pending_changes.write().await;
|
|
pending.insert(change.subscription_id, change.clone());
|
|
}
|
|
|
|
Ok(change)
|
|
}
|
|
|
|
pub async fn downgrade_subscription(
|
|
&self,
|
|
request: UpgradeDowngradeRequest,
|
|
) -> Result<SubscriptionChange, LifecycleError> {
|
|
let subscriptions = self.subscriptions.read().await;
|
|
let subscription = subscriptions
|
|
.get(&request.subscription_id)
|
|
.ok_or(LifecycleError::SubscriptionNotFound)?;
|
|
|
|
let plans = self.plans.read().await;
|
|
let current_plan = plans
|
|
.get(&subscription.plan_id)
|
|
.ok_or_else(|| LifecycleError::PlanNotFound(subscription.plan_id.clone()))?;
|
|
let new_plan = plans
|
|
.get(&request.new_plan_id)
|
|
.ok_or_else(|| LifecycleError::PlanNotFound(request.new_plan_id.clone()))?;
|
|
|
|
let is_upgrade = self.is_upgrade(current_plan, new_plan);
|
|
if is_upgrade {
|
|
return Err(LifecycleError::InvalidOperation(
|
|
"Use upgrade for moving to a higher plan".to_string(),
|
|
));
|
|
}
|
|
|
|
let change = SubscriptionChange {
|
|
subscription_id: subscription.id,
|
|
change_type: ChangeType::Downgrade,
|
|
effective_date: subscription.current_period_end,
|
|
from_plan: subscription.plan_id.clone(),
|
|
to_plan: request.new_plan_id.clone(),
|
|
proration_amount: None,
|
|
status: ChangeStatus::Scheduled,
|
|
};
|
|
|
|
let org_id = subscription.organization_id;
|
|
drop(subscriptions);
|
|
drop(plans);
|
|
|
|
let mut pending = self.pending_changes.write().await;
|
|
pending.insert(change.subscription_id, change.clone());
|
|
|
|
self.record_event(
|
|
change.subscription_id,
|
|
org_id,
|
|
LifecycleEventType::Downgraded,
|
|
Some(change.from_plan.clone()),
|
|
Some(change.to_plan.clone()),
|
|
HashMap::from([("scheduled".to_string(), "true".to_string())]),
|
|
)
|
|
.await;
|
|
|
|
Ok(change)
|
|
}
|
|
|
|
pub async fn cancel_subscription(
|
|
&self,
|
|
request: CancelSubscriptionRequest,
|
|
) -> Result<CancellationResult, LifecycleError> {
|
|
if request.offer_retention {
|
|
let offer = self.create_retention_offer(request.subscription_id).await?;
|
|
return Ok(CancellationResult::RetentionOffered(offer));
|
|
}
|
|
|
|
let mut subscriptions = self.subscriptions.write().await;
|
|
let subscription = subscriptions
|
|
.get_mut(&request.subscription_id)
|
|
.ok_or(LifecycleError::SubscriptionNotFound)?;
|
|
|
|
let org_id = subscription.organization_id;
|
|
let plan_id = subscription.plan_id.clone();
|
|
|
|
if request.cancel_immediately {
|
|
subscription.status = SubscriptionStatus::Canceled;
|
|
subscription.updated_at = Utc::now();
|
|
|
|
drop(subscriptions);
|
|
|
|
self.record_event(
|
|
request.subscription_id,
|
|
org_id,
|
|
LifecycleEventType::Cancelled,
|
|
Some(plan_id),
|
|
None,
|
|
HashMap::from([
|
|
("immediate".to_string(), "true".to_string()),
|
|
("reason".to_string(), request.reason.unwrap_or_default()),
|
|
]),
|
|
)
|
|
.await;
|
|
|
|
Ok(CancellationResult::CancelledImmediately)
|
|
} else {
|
|
drop(subscriptions);
|
|
|
|
self.record_event(
|
|
request.subscription_id,
|
|
org_id,
|
|
LifecycleEventType::CancellationRequested,
|
|
None,
|
|
None,
|
|
HashMap::from([
|
|
("reason".to_string(), request.reason.unwrap_or_default()),
|
|
("feedback".to_string(), request.feedback.unwrap_or_default()),
|
|
]),
|
|
)
|
|
.await;
|
|
|
|
let subscriptions = self.subscriptions.read().await;
|
|
let subscription = subscriptions.get(&request.subscription_id);
|
|
let end_date = subscription.map(|s| s.current_period_end).unwrap_or_else(Utc::now);
|
|
|
|
Ok(CancellationResult::ScheduledForEndOfPeriod { end_date })
|
|
}
|
|
}
|
|
|
|
pub async fn reactivate_subscription(
|
|
&self,
|
|
subscription_id: Uuid,
|
|
) -> Result<Subscription, LifecycleError> {
|
|
let mut subscriptions = self.subscriptions.write().await;
|
|
let subscription = subscriptions
|
|
.get_mut(&subscription_id)
|
|
.ok_or(LifecycleError::SubscriptionNotFound)?;
|
|
|
|
if subscription.status != SubscriptionStatus::Canceled {
|
|
return Err(LifecycleError::InvalidOperation(
|
|
"Subscription is not cancelled".to_string(),
|
|
));
|
|
}
|
|
|
|
let now = Utc::now();
|
|
subscription.status = SubscriptionStatus::Active;
|
|
subscription.current_period_start = now;
|
|
subscription.current_period_end = now + Duration::days(30);
|
|
subscription.updated_at = now;
|
|
|
|
let result = subscription.clone();
|
|
let org_id = subscription.organization_id;
|
|
let plan_id = subscription.plan_id.clone();
|
|
|
|
drop(subscriptions);
|
|
|
|
self.record_event(
|
|
subscription_id,
|
|
org_id,
|
|
LifecycleEventType::Resumed,
|
|
None,
|
|
Some(plan_id),
|
|
HashMap::new(),
|
|
)
|
|
.await;
|
|
|
|
Ok(result)
|
|
}
|
|
|
|
pub async fn pause_subscription(&self, subscription_id: Uuid) -> Result<Subscription, LifecycleError> {
|
|
let mut subscriptions = self.subscriptions.write().await;
|
|
let subscription = subscriptions
|
|
.get_mut(&subscription_id)
|
|
.ok_or(LifecycleError::SubscriptionNotFound)?;
|
|
|
|
if subscription.status != SubscriptionStatus::Active {
|
|
return Err(LifecycleError::InvalidOperation(
|
|
"Only active subscriptions can be paused".to_string(),
|
|
));
|
|
}
|
|
|
|
subscription.status = SubscriptionStatus::Paused;
|
|
subscription.updated_at = Utc::now();
|
|
|
|
let result = subscription.clone();
|
|
let org_id = subscription.organization_id;
|
|
|
|
drop(subscriptions);
|
|
|
|
self.record_event(
|
|
subscription_id,
|
|
org_id,
|
|
LifecycleEventType::Paused,
|
|
None,
|
|
None,
|
|
HashMap::new(),
|
|
)
|
|
.await;
|
|
|
|
Ok(result)
|
|
}
|
|
|
|
pub async fn resume_subscription(&self, subscription_id: Uuid) -> Result<Subscription, LifecycleError> {
|
|
let mut subscriptions = self.subscriptions.write().await;
|
|
let subscription = subscriptions
|
|
.get_mut(&subscription_id)
|
|
.ok_or(LifecycleError::SubscriptionNotFound)?;
|
|
|
|
if subscription.status != SubscriptionStatus::Paused {
|
|
return Err(LifecycleError::InvalidOperation(
|
|
"Only paused subscriptions can be resumed".to_string(),
|
|
));
|
|
}
|
|
|
|
subscription.status = SubscriptionStatus::Active;
|
|
subscription.updated_at = Utc::now();
|
|
|
|
let result = subscription.clone();
|
|
let org_id = subscription.organization_id;
|
|
|
|
drop(subscriptions);
|
|
|
|
self.record_event(
|
|
subscription_id,
|
|
org_id,
|
|
LifecycleEventType::Resumed,
|
|
None,
|
|
None,
|
|
HashMap::new(),
|
|
)
|
|
.await;
|
|
|
|
Ok(result)
|
|
}
|
|
|
|
pub async fn renew_subscription(&self, subscription_id: Uuid) -> Result<Subscription, LifecycleError> {
|
|
let mut subscriptions = self.subscriptions.write().await;
|
|
let subscription = subscriptions
|
|
.get_mut(&subscription_id)
|
|
.ok_or(LifecycleError::SubscriptionNotFound)?;
|
|
|
|
let now = Utc::now();
|
|
subscription.current_period_start = now;
|
|
subscription.current_period_end = now + Duration::days(30);
|
|
subscription.status = SubscriptionStatus::Active;
|
|
subscription.updated_at = now;
|
|
|
|
let result = subscription.clone();
|
|
let org_id = subscription.organization_id;
|
|
|
|
drop(subscriptions);
|
|
|
|
self.record_event(
|
|
subscription_id,
|
|
org_id,
|
|
LifecycleEventType::Renewed,
|
|
None,
|
|
None,
|
|
HashMap::new(),
|
|
)
|
|
.await;
|
|
|
|
Ok(result)
|
|
}
|
|
|
|
pub async fn handle_payment_failure(&self, subscription_id: Uuid) -> Result<(), LifecycleError> {
|
|
let mut subscriptions = self.subscriptions.write().await;
|
|
let subscription = subscriptions
|
|
.get_mut(&subscription_id)
|
|
.ok_or(LifecycleError::SubscriptionNotFound)?;
|
|
|
|
subscription.status = SubscriptionStatus::PastDue;
|
|
subscription.updated_at = Utc::now();
|
|
|
|
let org_id = subscription.organization_id;
|
|
|
|
drop(subscriptions);
|
|
|
|
self.record_event(
|
|
subscription_id,
|
|
org_id,
|
|
LifecycleEventType::PaymentFailed,
|
|
None,
|
|
None,
|
|
HashMap::new(),
|
|
)
|
|
.await;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn handle_payment_recovery(&self, subscription_id: Uuid) -> Result<Subscription, LifecycleError> {
|
|
let mut subscriptions = self.subscriptions.write().await;
|
|
let subscription = subscriptions
|
|
.get_mut(&subscription_id)
|
|
.ok_or(LifecycleError::SubscriptionNotFound)?;
|
|
|
|
subscription.status = SubscriptionStatus::Active;
|
|
subscription.updated_at = Utc::now();
|
|
|
|
let result = subscription.clone();
|
|
let org_id = subscription.organization_id;
|
|
|
|
drop(subscriptions);
|
|
|
|
self.record_event(
|
|
subscription_id,
|
|
org_id,
|
|
LifecycleEventType::PaymentRecovered,
|
|
None,
|
|
None,
|
|
HashMap::new(),
|
|
)
|
|
.await;
|
|
|
|
Ok(result)
|
|
}
|
|
|
|
pub async fn accept_retention_offer(&self, offer_id: Uuid) -> Result<Subscription, LifecycleError> {
|
|
let mut offers = self.retention_offers.write().await;
|
|
let offer = offers
|
|
.get_mut(&offer_id)
|
|
.ok_or(LifecycleError::OfferNotFound)?;
|
|
|
|
if offer.expires_at < Utc::now() {
|
|
return Err(LifecycleError::OfferExpired);
|
|
}
|
|
|
|
if offer.accepted {
|
|
return Err(LifecycleError::OfferAlreadyAccepted);
|
|
}
|
|
|
|
offer.accepted = true;
|
|
let subscription_id = offer.subscription_id;
|
|
|
|
drop(offers);
|
|
|
|
let subscriptions = self.subscriptions.read().await;
|
|
let subscription = subscriptions
|
|
.get(&subscription_id)
|
|
.ok_or(LifecycleError::SubscriptionNotFound)?
|
|
.clone();
|
|
|
|
Ok(subscription)
|
|
}
|
|
|
|
pub async fn get_events(&self, subscription_id: Uuid) -> Vec<SubscriptionLifecycleEvent> {
|
|
let events = self.events.read().await;
|
|
events
|
|
.iter()
|
|
.filter(|e| e.subscription_id == subscription_id)
|
|
.cloned()
|
|
.collect()
|
|
}
|
|
|
|
pub async fn get_pending_change(&self, subscription_id: Uuid) -> Option<SubscriptionChange> {
|
|
let pending = self.pending_changes.read().await;
|
|
pending.get(&subscription_id).cloned()
|
|
}
|
|
|
|
pub async fn apply_pending_changes(&self) -> Vec<SubscriptionChange> {
|
|
let now = Utc::now();
|
|
let mut applied = Vec::new();
|
|
|
|
let pending = self.pending_changes.read().await;
|
|
let due_changes: Vec<SubscriptionChange> = pending
|
|
.values()
|
|
.filter(|c| c.effective_date <= now && c.status == ChangeStatus::Scheduled)
|
|
.cloned()
|
|
.collect();
|
|
drop(pending);
|
|
|
|
for change in due_changes {
|
|
let mut subscriptions = self.subscriptions.write().await;
|
|
if let Some(subscription) = subscriptions.get_mut(&change.subscription_id) {
|
|
subscription.plan_id = change.to_plan.clone();
|
|
subscription.updated_at = now;
|
|
|
|
let mut pending = self.pending_changes.write().await;
|
|
if let Some(pending_change) = pending.get_mut(&change.subscription_id) {
|
|
pending_change.status = ChangeStatus::Applied;
|
|
}
|
|
|
|
applied.push(change.clone());
|
|
}
|
|
}
|
|
|
|
applied
|
|
}
|
|
|
|
async fn create_retention_offer(
|
|
&self,
|
|
subscription_id: Uuid,
|
|
) -> Result<RetentionOffer, LifecycleError> {
|
|
let subscriptions = self.subscriptions.read().await;
|
|
let _subscription = subscriptions
|
|
.get(&subscription_id)
|
|
.ok_or(LifecycleError::SubscriptionNotFound)?;
|
|
|
|
let offer = RetentionOffer {
|
|
id: Uuid::new_v4(),
|
|
subscription_id,
|
|
offer_type: RetentionOfferType::Discount,
|
|
discount_percent: Some(20),
|
|
free_months: None,
|
|
expires_at: Utc::now() + Duration::days(7),
|
|
accepted: false,
|
|
};
|
|
|
|
drop(subscriptions);
|
|
|
|
let mut offers = self.retention_offers.write().await;
|
|
offers.insert(offer.id, offer.clone());
|
|
|
|
Ok(offer)
|
|
}
|
|
|
|
async fn record_event(
|
|
&self,
|
|
subscription_id: Uuid,
|
|
organization_id: Uuid,
|
|
event_type: LifecycleEventType,
|
|
from_plan: Option<String>,
|
|
to_plan: Option<String>,
|
|
metadata: HashMap<String, String>,
|
|
) {
|
|
let event = SubscriptionLifecycleEvent {
|
|
id: Uuid::new_v4(),
|
|
subscription_id,
|
|
organization_id,
|
|
event_type,
|
|
from_plan,
|
|
to_plan,
|
|
timestamp: Utc::now(),
|
|
metadata,
|
|
processed: false,
|
|
};
|
|
|
|
let mut events = self.events.write().await;
|
|
events.push(event);
|
|
}
|
|
|
|
fn is_upgrade(&self, current: &PlanConfig, new: &PlanConfig) -> bool {
|
|
let current_value = self.plan_value(current);
|
|
let new_value = self.plan_value(new);
|
|
new_value > current_value
|
|
}
|
|
|
|
fn plan_value(&self, plan: &PlanConfig) -> u64 {
|
|
match &plan.price {
|
|
crate::billing::PlanPrice::Free => 0,
|
|
crate::billing::PlanPrice::Fixed { amount, .. } => *amount,
|
|
crate::billing::PlanPrice::Custom => u64::MAX,
|
|
}
|
|
}
|
|
|
|
fn calculate_proration(
|
|
&self,
|
|
subscription: &Subscription,
|
|
_current_plan: &PlanConfig,
|
|
_new_plan: &PlanConfig,
|
|
) -> Option<i64> {
|
|
let now = Utc::now();
|
|
let period_total = (subscription.current_period_end - subscription.current_period_start)
|
|
.num_days() as f64;
|
|
let days_remaining = (subscription.current_period_end - now).num_days() as f64;
|
|
|
|
if period_total > 0.0 {
|
|
let ratio = days_remaining / period_total;
|
|
Some((ratio * 100.0) as i64)
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Default for SubscriptionLifecycleService {
|
|
fn default() -> Self {
|
|
Self::new(HashMap::new())
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub enum CancellationResult {
|
|
CancelledImmediately,
|
|
ScheduledForEndOfPeriod { end_date: DateTime<Utc> },
|
|
RetentionOffered(RetentionOffer),
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub enum LifecycleError {
|
|
SubscriptionNotFound,
|
|
PlanNotFound(String),
|
|
InvalidOperation(String),
|
|
OfferNotFound,
|
|
OfferExpired,
|
|
OfferAlreadyAccepted,
|
|
BillingError(String),
|
|
}
|
|
|
|
impl std::fmt::Display for LifecycleError {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
match self {
|
|
Self::SubscriptionNotFound => write!(f, "Subscription not found"),
|
|
Self::PlanNotFound(id) => write!(f, "Plan not found: {id}"),
|
|
Self::InvalidOperation(msg) => write!(f, "Invalid operation: {msg}"),
|
|
Self::OfferNotFound => write!(f, "Retention offer not found"),
|
|
Self::OfferExpired => write!(f, "Retention offer has expired"),
|
|
Self::OfferAlreadyAccepted => write!(f, "Retention offer already accepted"),
|
|
Self::BillingError(msg) => write!(f, "Billing error: {msg}"),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl std::error::Error for LifecycleError {}
|
|
|
|
impl From<BillingError> for LifecycleError {
|
|
fn from(err: BillingError) -> Self {
|
|
Self::BillingError(err.to_string())
|
|
}
|
|
}
|
|
|
|
pub async fn process_expiring_subscriptions(
|
|
service: Arc<SubscriptionLifecycleService>,
|
|
) -> Vec<Uuid> {
|
|
let now = Utc::now();
|
|
let subscriptions = service.subscriptions.read().await;
|
|
|
|
let expiring: Vec<Uuid> = subscriptions
|
|
.values()
|
|
.filter(|s| {
|
|
s.status == SubscriptionStatus::Active
|
|
&& s.current_period_end <= now + Duration::days(3)
|
|
&& s.current_period_end > now
|
|
})
|
|
.map(|s| s.id)
|
|
.collect();
|
|
|
|
expiring
|
|
}
|
|
|
|
pub async fn process_expired_trials(service: Arc<SubscriptionLifecycleService>) -> Vec<Uuid> {
|
|
let now = Utc::now();
|
|
let mut subscriptions = service.subscriptions.write().await;
|
|
|
|
let mut expired = Vec::new();
|
|
|
|
for subscription in subscriptions.values_mut() {
|
|
if subscription.status == SubscriptionStatus::Trialing
|
|
&& subscription.current_period_end <= now
|
|
{
|
|
subscription.status = SubscriptionStatus::Active;
|
|
subscription.current_period_start = now;
|
|
subscription.current_period_end = now + Duration::days(30);
|
|
subscription.updated_at = now;
|
|
expired.push(subscription.id);
|
|
}
|
|
}
|
|
|
|
expired
|
|
}
|