use chrono::NaiveDate;
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum CustomerValueSegment {
Enterprise,
#[default]
MidMarket,
Smb,
Consumer,
}
impl CustomerValueSegment {
pub fn customer_share(&self) -> f64 {
match self {
Self::Enterprise => 0.05,
Self::MidMarket => 0.20,
Self::Smb => 0.50,
Self::Consumer => 0.25,
}
}
pub fn revenue_share(&self) -> f64 {
match self {
Self::Enterprise => 0.40,
Self::MidMarket => 0.35,
Self::Smb => 0.20,
Self::Consumer => 0.05,
}
}
pub fn order_value_range(&self) -> (Decimal, Decimal) {
match self {
Self::Enterprise => (Decimal::from(50000), Decimal::from(5000000)),
Self::MidMarket => (Decimal::from(5000), Decimal::from(50000)),
Self::Smb => (Decimal::from(500), Decimal::from(5000)),
Self::Consumer => (Decimal::from(50), Decimal::from(500)),
}
}
pub fn code(&self) -> &'static str {
match self {
Self::Enterprise => "ENT",
Self::MidMarket => "MID",
Self::Smb => "SMB",
Self::Consumer => "CON",
}
}
pub fn service_level(&self) -> &'static str {
match self {
Self::Enterprise => "dedicated_team",
Self::MidMarket => "named_account_manager",
Self::Smb => "shared_support",
Self::Consumer => "self_service",
}
}
pub fn typical_payment_terms_days(&self) -> u16 {
match self {
Self::Enterprise => 60,
Self::MidMarket => 45,
Self::Smb => 30,
Self::Consumer => 0, }
}
pub fn importance_score(&self) -> f64 {
match self {
Self::Enterprise => 1.0,
Self::MidMarket => 0.7,
Self::Smb => 0.4,
Self::Consumer => 0.2,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RiskTrigger {
DecliningOrderFrequency,
DecliningOrderValue,
PaymentIssues,
Complaints,
ReducedEngagement,
CompetitorMention,
ContractExpiring,
ContactDeparture,
BudgetCuts,
Restructuring,
Other(String),
}
impl RiskTrigger {
pub fn severity(&self) -> f64 {
match self {
Self::DecliningOrderFrequency => 0.6,
Self::DecliningOrderValue => 0.5,
Self::PaymentIssues => 0.8,
Self::Complaints => 0.7,
Self::ReducedEngagement => 0.4,
Self::CompetitorMention => 0.9,
Self::ContractExpiring => 0.5,
Self::ContactDeparture => 0.6,
Self::BudgetCuts => 0.7,
Self::Restructuring => 0.5,
Self::Other(_) => 0.5,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ChurnReason {
Price,
Competitor,
ServiceQuality,
ProductFit,
BusinessClosed,
BudgetConstraints,
Consolidation,
Acquisition,
ProjectCompleted,
Unknown,
Other(String),
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CustomerLifecycleStage {
Prospect {
conversion_probability: f64,
source: Option<String>,
first_contact_date: NaiveDate,
},
New {
first_order_date: NaiveDate,
onboarding_complete: bool,
},
Growth {
since: NaiveDate,
growth_rate: f64,
},
Mature {
stable_since: NaiveDate,
#[serde(with = "crate::serde_decimal")]
avg_annual_spend: Decimal,
},
AtRisk {
triggers: Vec<RiskTrigger>,
flagged_date: NaiveDate,
churn_probability: f64,
},
Churned {
last_activity: NaiveDate,
win_back_probability: f64,
reason: Option<ChurnReason>,
},
WonBack {
churned_date: NaiveDate,
won_back_date: NaiveDate,
},
}
impl CustomerLifecycleStage {
pub fn is_active(&self) -> bool {
!matches!(self, Self::Prospect { .. } | Self::Churned { .. })
}
pub fn is_good_standing(&self) -> bool {
matches!(
self,
Self::New { .. } | Self::Growth { .. } | Self::Mature { .. } | Self::WonBack { .. }
)
}
pub fn stage_name(&self) -> &'static str {
match self {
Self::Prospect { .. } => "prospect",
Self::New { .. } => "new",
Self::Growth { .. } => "growth",
Self::Mature { .. } => "mature",
Self::AtRisk { .. } => "at_risk",
Self::Churned { .. } => "churned",
Self::WonBack { .. } => "won_back",
}
}
pub fn retention_priority(&self) -> u8 {
match self {
Self::AtRisk { .. } => 1,
Self::Growth { .. } => 2,
Self::Mature { .. } => 3,
Self::New { .. } => 4,
Self::WonBack { .. } => 5,
Self::Churned { .. } => 6,
Self::Prospect { .. } => 7,
}
}
}
impl Default for CustomerLifecycleStage {
fn default() -> Self {
Self::Mature {
stable_since: NaiveDate::from_ymd_opt(2020, 1, 1).expect("valid default date"),
avg_annual_spend: Decimal::from(50000),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CustomerNetworkPosition {
pub customer_id: String,
pub referred_by: Option<String>,
pub referrals_made: Vec<String>,
pub parent_customer: Option<String>,
pub child_customers: Vec<String>,
pub billing_consolidation: bool,
pub industry_cluster_id: Option<String>,
pub region: Option<String>,
pub network_join_date: Option<NaiveDate>,
}
impl CustomerNetworkPosition {
pub fn new(customer_id: impl Into<String>) -> Self {
Self {
customer_id: customer_id.into(),
referred_by: None,
referrals_made: Vec::new(),
parent_customer: None,
child_customers: Vec::new(),
billing_consolidation: false,
industry_cluster_id: None,
region: None,
network_join_date: None,
}
}
pub fn with_referral(mut self, referrer_id: impl Into<String>) -> Self {
self.referred_by = Some(referrer_id.into());
self
}
pub fn with_parent(mut self, parent_id: impl Into<String>) -> Self {
self.parent_customer = Some(parent_id.into());
self
}
pub fn add_referral(&mut self, referred_id: impl Into<String>) {
self.referrals_made.push(referred_id.into());
}
pub fn add_child(&mut self, child_id: impl Into<String>) {
self.child_customers.push(child_id.into());
}
pub fn network_influence(&self) -> usize {
self.referrals_made.len() + self.child_customers.len()
}
pub fn is_root(&self) -> bool {
self.parent_customer.is_none()
}
pub fn was_referred(&self) -> bool {
self.referred_by.is_some()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CustomerEngagement {
pub total_orders: u32,
pub orders_last_12_months: u32,
#[serde(with = "crate::serde_decimal")]
pub lifetime_revenue: Decimal,
#[serde(with = "crate::serde_decimal")]
pub revenue_last_12_months: Decimal,
#[serde(with = "crate::serde_decimal")]
pub average_order_value: Decimal,
pub days_since_last_order: u32,
pub last_order_date: Option<NaiveDate>,
pub first_order_date: Option<NaiveDate>,
pub products_purchased: u32,
pub support_tickets: u32,
pub nps_score: Option<i8>,
}
impl Default for CustomerEngagement {
fn default() -> Self {
Self {
total_orders: 0,
orders_last_12_months: 0,
lifetime_revenue: Decimal::ZERO,
revenue_last_12_months: Decimal::ZERO,
average_order_value: Decimal::ZERO,
days_since_last_order: 0,
last_order_date: None,
first_order_date: None,
products_purchased: 0,
support_tickets: 0,
nps_score: None,
}
}
}
impl CustomerEngagement {
pub fn record_order(&mut self, amount: Decimal, order_date: NaiveDate, product_count: u32) {
self.total_orders += 1;
self.lifetime_revenue += amount;
self.products_purchased += product_count;
if self.total_orders > 0 {
self.average_order_value = self.lifetime_revenue / Decimal::from(self.total_orders);
}
if self.first_order_date.is_none() {
self.first_order_date = Some(order_date);
}
self.last_order_date = Some(order_date);
self.days_since_last_order = 0;
}
pub fn update_days_since_last_order(&mut self, current_date: NaiveDate) {
if let Some(last_order) = self.last_order_date {
self.days_since_last_order = (current_date - last_order).num_days().max(0) as u32;
}
}
pub fn health_score(&self) -> f64 {
let mut score = 0.0;
let order_freq_score = if self.orders_last_12_months > 0 {
(self.orders_last_12_months as f64 / 12.0).min(1.0)
} else {
0.0
};
score += 0.30 * order_freq_score;
let recency_score = if self.days_since_last_order == 0 {
1.0
} else {
(1.0 - (self.days_since_last_order as f64 / 365.0)).max(0.0)
};
score += 0.30 * recency_score;
let value_score = if self.average_order_value > Decimal::ZERO {
let aov_f64 = self
.average_order_value
.to_string()
.parse::<f64>()
.unwrap_or(0.0);
(aov_f64 / 10000.0).min(1.0) } else {
0.0
};
score += 0.25 * value_score;
if let Some(nps) = self.nps_score {
let nps_normalized = ((nps as i32 + 100) as f64 / 200.0).clamp(0.0, 1.0);
score += 0.15 * nps_normalized;
} else {
score += 0.15 * 0.5; }
score.clamp(0.0, 1.0)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SegmentedCustomer {
pub customer_id: String,
pub name: String,
pub segment: CustomerValueSegment,
pub lifecycle_stage: CustomerLifecycleStage,
pub network_position: CustomerNetworkPosition,
pub engagement: CustomerEngagement,
pub segment_assigned_date: NaiveDate,
pub previous_segment: Option<CustomerValueSegment>,
pub segment_change_date: Option<NaiveDate>,
pub industry: Option<String>,
#[serde(with = "crate::serde_decimal")]
pub annual_contract_value: Decimal,
pub churn_risk_score: f64,
pub upsell_potential: f64,
pub account_manager: Option<String>,
}
impl SegmentedCustomer {
pub fn new(
customer_id: impl Into<String>,
name: impl Into<String>,
segment: CustomerValueSegment,
assignment_date: NaiveDate,
) -> Self {
let customer_id = customer_id.into();
Self {
customer_id: customer_id.clone(),
name: name.into(),
segment,
lifecycle_stage: CustomerLifecycleStage::default(),
network_position: CustomerNetworkPosition::new(customer_id),
engagement: CustomerEngagement::default(),
segment_assigned_date: assignment_date,
previous_segment: None,
segment_change_date: None,
industry: None,
annual_contract_value: Decimal::ZERO,
churn_risk_score: 0.0,
upsell_potential: 0.5,
account_manager: None,
}
}
pub fn with_lifecycle_stage(mut self, stage: CustomerLifecycleStage) -> Self {
self.lifecycle_stage = stage;
self
}
pub fn with_industry(mut self, industry: impl Into<String>) -> Self {
self.industry = Some(industry.into());
self
}
pub fn with_annual_contract_value(mut self, value: Decimal) -> Self {
self.annual_contract_value = value;
self
}
pub fn change_segment(&mut self, new_segment: CustomerValueSegment, change_date: NaiveDate) {
if self.segment != new_segment {
self.previous_segment = Some(self.segment);
self.segment = new_segment;
self.segment_change_date = Some(change_date);
}
}
pub fn calculate_churn_risk(&mut self) {
let mut risk = 0.0;
match &self.lifecycle_stage {
CustomerLifecycleStage::AtRisk {
churn_probability, ..
} => {
risk += 0.4 * churn_probability;
}
CustomerLifecycleStage::New { .. } => risk += 0.15,
CustomerLifecycleStage::WonBack { .. } => risk += 0.25,
CustomerLifecycleStage::Growth { .. } => risk += 0.05,
CustomerLifecycleStage::Mature { .. } => risk += 0.10,
_ => {}
}
let health = self.engagement.health_score();
risk += 0.4 * (1.0 - health);
let recency_risk = (self.engagement.days_since_last_order as f64 / 180.0).min(1.0);
risk += 0.2 * recency_risk;
self.churn_risk_score = risk.clamp(0.0, 1.0);
}
pub fn estimated_lifetime_value(&self) -> Decimal {
let expected_years = match self.segment {
CustomerValueSegment::Enterprise => Decimal::from(8),
CustomerValueSegment::MidMarket => Decimal::from(5),
CustomerValueSegment::Smb => Decimal::from(3),
CustomerValueSegment::Consumer => Decimal::from(2),
};
let retention_factor =
Decimal::from_f64_retain(1.0 - self.churn_risk_score).unwrap_or(Decimal::ONE);
self.annual_contract_value * expected_years * retention_factor
}
pub fn is_high_value(&self) -> bool {
matches!(
self.segment,
CustomerValueSegment::Enterprise | CustomerValueSegment::MidMarket
)
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct SegmentedCustomerPool {
pub customers: Vec<SegmentedCustomer>,
#[serde(skip)]
segment_index: HashMap<CustomerValueSegment, Vec<usize>>,
#[serde(skip)]
lifecycle_index: HashMap<String, Vec<usize>>,
pub statistics: SegmentStatistics,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct SegmentStatistics {
pub customers_by_segment: HashMap<String, usize>,
pub revenue_by_segment: HashMap<String, Decimal>,
#[serde(with = "crate::serde_decimal")]
pub total_revenue: Decimal,
pub avg_churn_risk: f64,
pub referral_rate: f64,
pub at_risk_count: usize,
}
impl SegmentedCustomerPool {
pub fn new() -> Self {
Self {
customers: Vec::new(),
segment_index: HashMap::new(),
lifecycle_index: HashMap::new(),
statistics: SegmentStatistics::default(),
}
}
pub fn add_customer(&mut self, customer: SegmentedCustomer) {
let idx = self.customers.len();
let segment = customer.segment;
let stage_name = customer.lifecycle_stage.stage_name().to_string();
self.customers.push(customer);
self.segment_index.entry(segment).or_default().push(idx);
self.lifecycle_index
.entry(stage_name)
.or_default()
.push(idx);
}
pub fn by_segment(&self, segment: CustomerValueSegment) -> Vec<&SegmentedCustomer> {
self.segment_index
.get(&segment)
.map(|indices| indices.iter().map(|&idx| &self.customers[idx]).collect())
.unwrap_or_default()
}
pub fn by_lifecycle_stage(&self, stage_name: &str) -> Vec<&SegmentedCustomer> {
self.lifecycle_index
.get(stage_name)
.map(|indices| indices.iter().map(|&idx| &self.customers[idx]).collect())
.unwrap_or_default()
}
pub fn at_risk_customers(&self) -> Vec<&SegmentedCustomer> {
self.customers
.iter()
.filter(|c| matches!(c.lifecycle_stage, CustomerLifecycleStage::AtRisk { .. }))
.collect()
}
pub fn high_value_customers(&self) -> Vec<&SegmentedCustomer> {
self.customers
.iter()
.filter(|c| c.is_high_value())
.collect()
}
pub fn rebuild_indexes(&mut self) {
self.segment_index.clear();
self.lifecycle_index.clear();
for (idx, customer) in self.customers.iter().enumerate() {
self.segment_index
.entry(customer.segment)
.or_default()
.push(idx);
self.lifecycle_index
.entry(customer.lifecycle_stage.stage_name().to_string())
.or_default()
.push(idx);
}
}
pub fn calculate_statistics(&mut self) {
let mut customers_by_segment: HashMap<String, usize> = HashMap::new();
let mut revenue_by_segment: HashMap<String, Decimal> = HashMap::new();
let mut total_revenue = Decimal::ZERO;
let mut total_churn_risk = 0.0;
let mut referral_count = 0usize;
let mut at_risk_count = 0usize;
for customer in &self.customers {
let segment_name = format!("{:?}", customer.segment);
*customers_by_segment
.entry(segment_name.clone())
.or_insert(0) += 1;
*revenue_by_segment
.entry(segment_name)
.or_insert(Decimal::ZERO) += customer.annual_contract_value;
total_revenue += customer.annual_contract_value;
total_churn_risk += customer.churn_risk_score;
if customer.network_position.was_referred() {
referral_count += 1;
}
if matches!(
customer.lifecycle_stage,
CustomerLifecycleStage::AtRisk { .. }
) {
at_risk_count += 1;
}
}
let avg_churn_risk = if self.customers.is_empty() {
0.0
} else {
total_churn_risk / self.customers.len() as f64
};
let referral_rate = if self.customers.is_empty() {
0.0
} else {
referral_count as f64 / self.customers.len() as f64
};
self.statistics = SegmentStatistics {
customers_by_segment,
revenue_by_segment,
total_revenue,
avg_churn_risk,
referral_rate,
at_risk_count,
};
}
pub fn check_segment_distribution(&self) -> Vec<String> {
let mut issues = Vec::new();
let total = self.customers.len() as f64;
if total == 0.0 {
return issues;
}
for segment in [
CustomerValueSegment::Enterprise,
CustomerValueSegment::MidMarket,
CustomerValueSegment::Smb,
CustomerValueSegment::Consumer,
] {
let expected = segment.customer_share();
let actual = self
.segment_index
.get(&segment)
.map(std::vec::Vec::len)
.unwrap_or(0) as f64
/ total;
if (actual - expected).abs() > expected * 0.2 {
issues.push(format!(
"Segment {:?} distribution {:.1}% deviates from expected {:.1}%",
segment,
actual * 100.0,
expected * 100.0
));
}
}
issues
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
#[test]
fn test_customer_value_segment() {
let total_customer_share: f64 = [
CustomerValueSegment::Enterprise,
CustomerValueSegment::MidMarket,
CustomerValueSegment::Smb,
CustomerValueSegment::Consumer,
]
.iter()
.map(|s| s.customer_share())
.sum();
assert!((total_customer_share - 1.0).abs() < 0.01);
let total_revenue_share: f64 = [
CustomerValueSegment::Enterprise,
CustomerValueSegment::MidMarket,
CustomerValueSegment::Smb,
CustomerValueSegment::Consumer,
]
.iter()
.map(|s| s.revenue_share())
.sum();
assert!((total_revenue_share - 1.0).abs() < 0.01);
}
#[test]
fn test_lifecycle_stage() {
let stage = CustomerLifecycleStage::Growth {
since: NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
growth_rate: 0.15,
};
assert!(stage.is_active());
assert!(stage.is_good_standing());
assert_eq!(stage.stage_name(), "growth");
}
#[test]
fn test_at_risk_lifecycle() {
let stage = CustomerLifecycleStage::AtRisk {
triggers: vec![
RiskTrigger::DecliningOrderFrequency,
RiskTrigger::Complaints,
],
flagged_date: NaiveDate::from_ymd_opt(2024, 6, 1).unwrap(),
churn_probability: 0.6,
};
assert!(stage.is_active());
assert!(!stage.is_good_standing());
assert_eq!(stage.retention_priority(), 1);
}
#[test]
fn test_customer_network_position() {
let mut pos = CustomerNetworkPosition::new("C-001")
.with_referral("C-000")
.with_parent("C-PARENT");
pos.add_referral("C-002");
pos.add_referral("C-003");
pos.add_child("C-SUB-001");
assert!(pos.was_referred());
assert!(!pos.is_root());
assert_eq!(pos.network_influence(), 3);
}
#[test]
fn test_customer_engagement() {
let mut engagement = CustomerEngagement::default();
engagement.record_order(
Decimal::from(5000),
NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
3,
);
engagement.record_order(
Decimal::from(7500),
NaiveDate::from_ymd_opt(2024, 3, 1).unwrap(),
5,
);
assert_eq!(engagement.total_orders, 2);
assert_eq!(engagement.lifetime_revenue, Decimal::from(12500));
assert_eq!(engagement.products_purchased, 8);
assert!(engagement.average_order_value > Decimal::ZERO);
}
#[test]
fn test_segmented_customer() {
let customer = SegmentedCustomer::new(
"C-001",
"Acme Corp",
CustomerValueSegment::Enterprise,
NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
)
.with_annual_contract_value(Decimal::from(500000))
.with_industry("Technology");
assert!(customer.is_high_value());
assert_eq!(customer.segment.code(), "ENT");
assert!(customer.estimated_lifetime_value() > Decimal::ZERO);
}
#[test]
fn test_segment_change() {
let mut customer = SegmentedCustomer::new(
"C-001",
"Growing Inc",
CustomerValueSegment::Smb,
NaiveDate::from_ymd_opt(2023, 1, 1).unwrap(),
);
customer.change_segment(
CustomerValueSegment::MidMarket,
NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
);
assert_eq!(customer.segment, CustomerValueSegment::MidMarket);
assert_eq!(customer.previous_segment, Some(CustomerValueSegment::Smb));
assert!(customer.segment_change_date.is_some());
}
#[test]
fn test_segmented_customer_pool() {
let mut pool = SegmentedCustomerPool::new();
pool.add_customer(SegmentedCustomer::new(
"C-001",
"Enterprise Corp",
CustomerValueSegment::Enterprise,
NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
));
pool.add_customer(SegmentedCustomer::new(
"C-002",
"SMB Inc",
CustomerValueSegment::Smb,
NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
));
assert_eq!(pool.customers.len(), 2);
assert_eq!(pool.by_segment(CustomerValueSegment::Enterprise).len(), 1);
assert_eq!(pool.high_value_customers().len(), 1);
}
#[test]
fn test_churn_risk_calculation() {
let mut customer = SegmentedCustomer::new(
"C-001",
"At Risk Corp",
CustomerValueSegment::MidMarket,
NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
)
.with_lifecycle_stage(CustomerLifecycleStage::AtRisk {
triggers: vec![RiskTrigger::DecliningOrderFrequency],
flagged_date: NaiveDate::from_ymd_opt(2024, 6, 1).unwrap(),
churn_probability: 0.7,
});
customer.engagement.days_since_last_order = 90;
customer.calculate_churn_risk();
assert!(customer.churn_risk_score > 0.3);
}
}