Skip to main content

pcm_engine/
eligibility.rs

1//! Product eligibility validation
2
3use serde::{Deserialize, Serialize};
4use uuid::Uuid;
5
6/// Eligibility rule for a product offering
7#[derive(Debug, Clone, Serialize, Deserialize)]
8pub struct EligibilityRule {
9    pub id: Uuid,
10    pub product_offering_id: Uuid,
11    pub conditions: Vec<EligibilityCondition>,
12    pub rule_type: EligibilityRuleType,
13}
14
15/// Eligibility rule type
16#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
17#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
18pub enum EligibilityRuleType {
19    /// All conditions must be met
20    All,
21    /// At least one condition must be met
22    Any,
23}
24
25/// Eligibility condition
26#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct EligibilityCondition {
28    pub field: String,
29    pub operator: EligibilityConditionOperator,
30    pub value: String,
31}
32
33/// Eligibility condition operator
34#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
35#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
36pub enum EligibilityConditionOperator {
37    Equals,
38    NotEquals,
39    GreaterThan,
40    LessThan,
41    Contains,
42    NotContains,
43    In,
44    NotIn,
45}
46
47/// Eligibility context for validation
48#[derive(Debug, Clone)]
49pub struct EligibilityContext {
50    pub customer_id: Option<Uuid>,
51    pub customer_segment: Option<String>,
52    pub existing_products: Vec<Uuid>,
53    pub customer_attributes: std::collections::HashMap<String, String>,
54}
55
56impl EligibilityContext {
57    pub fn new() -> Self {
58        Self {
59            customer_id: None,
60            customer_segment: None,
61            existing_products: Vec::new(),
62            customer_attributes: std::collections::HashMap::new(),
63        }
64    }
65}
66
67impl Default for EligibilityContext {
68    fn default() -> Self {
69        Self::new()
70    }
71}
72
73/// Structured eligibility outcome (reason codes for ineligible results).
74#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
75pub struct EligibilityOutcome {
76    pub eligible: bool,
77    pub failed_conditions: Vec<String>,
78}
79
80/// Check if a product offering is eligible for a customer
81pub fn is_eligible(rule: &EligibilityRule, context: &EligibilityContext) -> bool {
82    evaluate_eligibility(rule, context).eligible
83}
84
85/// Evaluate eligibility with failure reasons.
86pub fn evaluate_eligibility(
87    rule: &EligibilityRule,
88    context: &EligibilityContext,
89) -> EligibilityOutcome {
90    let mut failed = Vec::new();
91    for condition in &rule.conditions {
92        if !evaluate_condition(condition, context) {
93            failed.push(format!(
94                "{} {:?} {}",
95                condition.field, condition.operator, condition.value
96            ));
97        }
98    }
99
100    let eligible = match rule.rule_type {
101        EligibilityRuleType::All => failed.is_empty(),
102        EligibilityRuleType::Any => failed.len() < rule.conditions.len(),
103    };
104
105    EligibilityOutcome {
106        eligible,
107        failed_conditions: if eligible { Vec::new() } else { failed },
108    }
109}
110
111fn evaluate_condition(condition: &EligibilityCondition, context: &EligibilityContext) -> bool {
112    let field_value = get_field_value(&condition.field, condition, context);
113
114    match condition.operator {
115        EligibilityConditionOperator::Equals => field_value == condition.value,
116        EligibilityConditionOperator::NotEquals => field_value != condition.value,
117        EligibilityConditionOperator::GreaterThan => {
118            if let (Ok(field_num), Ok(cond_num)) =
119                (field_value.parse::<f64>(), condition.value.parse::<f64>())
120            {
121                field_num > cond_num
122            } else {
123                false
124            }
125        }
126        EligibilityConditionOperator::LessThan => {
127            if let (Ok(field_num), Ok(cond_num)) =
128                (field_value.parse::<f64>(), condition.value.parse::<f64>())
129            {
130                field_num < cond_num
131            } else {
132                false
133            }
134        }
135        EligibilityConditionOperator::Contains => field_value.contains(&condition.value),
136        EligibilityConditionOperator::NotContains => !field_value.contains(&condition.value),
137        EligibilityConditionOperator::In => {
138            condition.value.split(',').any(|v| v.trim() == field_value)
139        }
140        EligibilityConditionOperator::NotIn => {
141            !condition.value.split(',').any(|v| v.trim() == field_value)
142        }
143    }
144}
145
146fn get_field_value(
147    field: &str,
148    condition: &EligibilityCondition,
149    context: &EligibilityContext,
150) -> String {
151    match field {
152        "customer_segment" => context.customer_segment.clone().unwrap_or_default(),
153        "has_product" => {
154            // Condition value is the product offering UUID the customer must own.
155            match Uuid::parse_str(&condition.value) {
156                Ok(id) => {
157                    if context.existing_products.contains(&id) {
158                        condition.value.clone()
159                    } else {
160                        String::new()
161                    }
162                }
163                Err(_) => String::new(),
164            }
165        }
166        "product_count" => context.existing_products.len().to_string(),
167        _ => context
168            .customer_attributes
169            .get(field)
170            .cloned()
171            .unwrap_or_default(),
172    }
173}
174
175#[cfg(test)]
176mod tests {
177    use super::*;
178
179    #[test]
180    fn has_product_condition_works() {
181        let owned = Uuid::new_v4();
182        let rule = EligibilityRule {
183            id: Uuid::new_v4(),
184            product_offering_id: Uuid::new_v4(),
185            rule_type: EligibilityRuleType::All,
186            conditions: vec![EligibilityCondition {
187                field: "has_product".into(),
188                operator: EligibilityConditionOperator::Equals,
189                value: owned.to_string(),
190            }],
191        };
192        let mut ctx = EligibilityContext::new();
193        assert!(!is_eligible(&rule, &ctx));
194        ctx.existing_products.push(owned);
195        assert!(is_eligible(&rule, &ctx));
196    }
197}