1use crate::bundling::{
4 calculate_bundle_price, validate_bundle, validate_bundle_selection, validate_relationships,
5 Bundle, ProductRelationship,
6};
7use crate::complex_pricing::{
8 calculate_complex_price, ComplexPricingModel, PricingContext as ComplexPricingContext,
9};
10use crate::eligibility::{
11 evaluate_eligibility, EligibilityContext, EligibilityOutcome, EligibilityRule,
12};
13use crate::pricing::{calculate_best_price, Money, PricingContext, PricingRule};
14use crate::rules::{evaluate_rule, CatalogRule, RuleContext};
15use crate::versioning::{CatalogSnapshot, CatalogVersion, VersionManager};
16use uuid::Uuid;
17
18#[derive(Debug, Clone)]
20pub struct QualifyAndPriceResult {
21 pub eligible: bool,
22 pub eligibility: EligibilityOutcome,
23 pub price: Option<Money>,
24 pub relationship_errors: Vec<String>,
25}
26
27pub struct CatalogEngine {
29 pricing_rules: Vec<PricingRule>,
30 eligibility_rules: Vec<EligibilityRule>,
31 bundles: Vec<Bundle>,
32 catalog_rules: Vec<CatalogRule>,
33 relationships: Vec<ProductRelationship>,
34 complex_models: Vec<(Uuid, ComplexPricingModel)>,
35 versions: VersionManager,
36}
37
38impl CatalogEngine {
39 pub fn new() -> Self {
41 Self {
42 pricing_rules: Vec::new(),
43 eligibility_rules: Vec::new(),
44 bundles: Vec::new(),
45 catalog_rules: Vec::new(),
46 relationships: Vec::new(),
47 complex_models: Vec::new(),
48 versions: VersionManager::new(),
49 }
50 }
51
52 pub fn add_pricing_rule(&mut self, rule: PricingRule) {
54 self.pricing_rules.push(rule);
55 }
56
57 pub fn add_eligibility_rule(&mut self, rule: EligibilityRule) {
59 self.eligibility_rules.push(rule);
60 }
61
62 pub fn add_bundle(&mut self, bundle: Bundle) -> Result<(), String> {
64 validate_bundle(&bundle)?;
65 self.bundles.push(bundle);
66 Ok(())
67 }
68
69 pub fn add_catalog_rule(&mut self, rule: CatalogRule) {
71 self.catalog_rules.push(rule);
72 }
73
74 pub fn add_relationship(&mut self, relationship: ProductRelationship) {
76 self.relationships.push(relationship);
77 }
78
79 pub fn add_complex_pricing_model(
81 &mut self,
82 product_offering_id: Uuid,
83 model: ComplexPricingModel,
84 ) {
85 self.complex_models.push((product_offering_id, model));
86 }
87
88 pub fn current_snapshot(&self) -> CatalogSnapshot {
90 CatalogSnapshot {
91 pricing_rules: self.pricing_rules.clone(),
92 eligibility_rules: self.eligibility_rules.clone(),
93 bundles: self.bundles.clone(),
94 catalog_rules: self.catalog_rules.clone(),
95 relationships: self.relationships.clone(),
96 }
97 }
98
99 pub fn create_version(
101 &mut self,
102 catalog_id: Uuid,
103 version: String,
104 description: Option<String>,
105 created_by: Option<Uuid>,
106 ) -> CatalogVersion {
107 let snapshot = self.current_snapshot();
108 self.versions
109 .create_version(catalog_id, version, description, created_by, snapshot)
110 }
111
112 pub fn publish_version(&mut self, version_id: Uuid) -> Result<(), String> {
114 let published = self.versions.publish_version(version_id)?.clone();
115 self.load_snapshot(&published.snapshot);
116 Ok(())
117 }
118
119 pub fn rollback_to_version(&mut self, version_id: Uuid) -> Result<(), String> {
121 self.publish_version(version_id)
122 }
123
124 pub fn versions(&self) -> &VersionManager {
126 &self.versions
127 }
128
129 fn load_snapshot(&mut self, snapshot: &CatalogSnapshot) {
130 self.pricing_rules = snapshot.pricing_rules.clone();
131 self.eligibility_rules = snapshot.eligibility_rules.clone();
132 self.bundles = snapshot.bundles.clone();
133 self.catalog_rules = snapshot.catalog_rules.clone();
134 self.relationships = snapshot.relationships.clone();
135 }
136
137 pub fn check_eligibility(
139 &self,
140 product_offering_id: Uuid,
141 context: &EligibilityContext,
142 ) -> bool {
143 self.explain_eligibility(product_offering_id, context)
144 .eligible
145 }
146
147 pub fn explain_eligibility(
149 &self,
150 product_offering_id: Uuid,
151 context: &EligibilityContext,
152 ) -> EligibilityOutcome {
153 let mut failed = Vec::new();
154 let mut any_rule = false;
155 for rule in self
156 .eligibility_rules
157 .iter()
158 .filter(|rule| rule.product_offering_id == product_offering_id)
159 {
160 any_rule = true;
161 let outcome = evaluate_eligibility(rule, context);
162 if !outcome.eligible {
163 failed.extend(outcome.failed_conditions);
164 }
165 }
166 EligibilityOutcome {
167 eligible: !any_rule || failed.is_empty(),
168 failed_conditions: failed,
169 }
170 }
171
172 pub fn calculate_price(
174 &self,
175 product_offering_id: Uuid,
176 context: &PricingContext,
177 ) -> Option<Money> {
178 if let Some((_, model)) = self
179 .complex_models
180 .iter()
181 .find(|(id, _)| *id == product_offering_id)
182 {
183 let complex_ctx = ComplexPricingContext {
184 quantity: context.quantity,
185 customer_id: None,
186 timestamp: context.as_of,
187 demand_level: None,
188 inventory_level: None,
189 existing_subscriptions: context.existing_products.clone(),
190 };
191 return Some(calculate_complex_price(
192 model,
193 context.quantity,
194 &complex_ctx,
195 ));
196 }
197 calculate_best_price(&self.pricing_rules, product_offering_id, context)
198 }
199
200 pub fn qualify_and_price(
202 &self,
203 product_offering_id: Uuid,
204 eligibility: &EligibilityContext,
205 pricing: &PricingContext,
206 cart: &[Uuid],
207 ) -> QualifyAndPriceResult {
208 let eligibility_outcome = self.explain_eligibility(product_offering_id, eligibility);
209 let mut relationship_errors = Vec::new();
210 if let Err(e) = validate_relationships(&self.relationships, cart) {
211 relationship_errors.push(e);
212 }
213 let eligible = eligibility_outcome.eligible && relationship_errors.is_empty();
214 let price = if eligible {
215 self.calculate_price(product_offering_id, pricing)
216 } else {
217 None
218 };
219 QualifyAndPriceResult {
220 eligible,
221 eligibility: eligibility_outcome,
222 price,
223 relationship_errors,
224 }
225 }
226
227 pub fn validate_bundle_selection(
229 &self,
230 bundle_id: Uuid,
231 selected: &[Uuid],
232 ) -> Result<(), String> {
233 let bundle = self
234 .bundles
235 .iter()
236 .find(|b| b.id == bundle_id)
237 .ok_or_else(|| "Bundle not found".to_string())?;
238 validate_bundle_selection(bundle, selected)
239 }
240
241 pub fn calculate_bundle_price(
243 &self,
244 bundle_id: Uuid,
245 individual_prices: &[(Uuid, f64)],
246 ) -> Result<f64, String> {
247 let bundle = self
248 .bundles
249 .iter()
250 .find(|b| b.id == bundle_id)
251 .ok_or_else(|| "Bundle not found".to_string())?;
252 calculate_bundle_price(bundle, individual_prices)
253 }
254
255 pub fn get_bundles_for_product(&self, product_offering_id: Uuid) -> Vec<&Bundle> {
257 self.bundles
258 .iter()
259 .filter(|bundle| {
260 bundle
261 .products
262 .iter()
263 .any(|bp| bp.product_offering_id == product_offering_id)
264 })
265 .collect()
266 }
267
268 pub fn evaluate_rules(&self, context: &RuleContext) -> Vec<&CatalogRule> {
270 self.catalog_rules
271 .iter()
272 .filter(|rule| {
273 matches!(
274 evaluate_rule(rule, context),
275 crate::rules::RuleResult::Matched { .. }
276 )
277 })
278 .collect()
279 }
280}
281
282impl Default for CatalogEngine {
283 fn default() -> Self {
284 Self::new()
285 }
286}
287
288#[cfg(test)]
289mod tests {
290 use super::*;
291 use crate::eligibility::{
292 EligibilityCondition, EligibilityConditionOperator, EligibilityRuleType,
293 };
294 use crate::pricing::{PriceType, PricingRule};
295
296 #[test]
297 fn qualify_and_price_happy_path() {
298 let offering = Uuid::new_v4();
299 let mut engine = CatalogEngine::new();
300 engine.add_pricing_rule(PricingRule {
301 id: Uuid::new_v4(),
302 product_offering_id: offering,
303 price_type: PriceType::OneTime,
304 base_price: Money {
305 value: 25.0,
306 unit: "USD".into(),
307 },
308 priority: 10,
309 discount_rules: None,
310 valid_for: None,
311 });
312 engine.add_eligibility_rule(EligibilityRule {
313 id: Uuid::new_v4(),
314 product_offering_id: offering,
315 rule_type: EligibilityRuleType::All,
316 conditions: vec![EligibilityCondition {
317 field: "customer_segment".into(),
318 operator: EligibilityConditionOperator::Equals,
319 value: "premium".into(),
320 }],
321 });
322
323 let mut elig = EligibilityContext::new();
324 elig.customer_segment = Some("premium".into());
325 let result =
326 engine.qualify_and_price(offering, &elig, &PricingContext::new(1), &[offering]);
327 assert!(result.eligible);
328 assert_eq!(result.price.unwrap().value, 25.0);
329 }
330}