1use serde::{Deserialize, Serialize};
4use uuid::Uuid;
5
6#[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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
17#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
18pub enum EligibilityRuleType {
19 All,
21 Any,
23}
24
25#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct EligibilityCondition {
28 pub field: String,
29 pub operator: EligibilityConditionOperator,
30 pub value: String,
31}
32
33#[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#[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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
75pub struct EligibilityOutcome {
76 pub eligible: bool,
77 pub failed_conditions: Vec<String>,
78}
79
80pub fn is_eligible(rule: &EligibilityRule, context: &EligibilityContext) -> bool {
82 evaluate_eligibility(rule, context).eligible
83}
84
85pub 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 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}