Skip to main content

pcm_engine/
engine.rs

1//! Main Catalog Engine
2
3use 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/// Result of a combined qualify + price evaluation.
19#[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
27/// Main Product Catalog Engine
28pub 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    /// Create a new catalog engine
40    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    /// Add a pricing rule
53    pub fn add_pricing_rule(&mut self, rule: PricingRule) {
54        self.pricing_rules.push(rule);
55    }
56
57    /// Add an eligibility rule
58    pub fn add_eligibility_rule(&mut self, rule: EligibilityRule) {
59        self.eligibility_rules.push(rule);
60    }
61
62    /// Add a bundle
63    pub fn add_bundle(&mut self, bundle: Bundle) -> Result<(), String> {
64        validate_bundle(&bundle)?;
65        self.bundles.push(bundle);
66        Ok(())
67    }
68
69    /// Add a catalog rule
70    pub fn add_catalog_rule(&mut self, rule: CatalogRule) {
71        self.catalog_rules.push(rule);
72    }
73
74    /// Add a product relationship
75    pub fn add_relationship(&mut self, relationship: ProductRelationship) {
76        self.relationships.push(relationship);
77    }
78
79    /// Register a complex pricing model for an offering.
80    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    /// Snapshot current in-memory catalog content.
89    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    /// Create a catalog version from the current engine state.
100    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    /// Publish a version and load its snapshot into the live engine.
113    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    /// Rollback to a version (re-publish + load snapshot).
120    pub fn rollback_to_version(&mut self, version_id: Uuid) -> Result<(), String> {
121        self.publish_version(version_id)
122    }
123
124    /// Access the version manager.
125    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    /// Check if a product is eligible for a customer
138    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    /// Eligibility with failure reasons across all rules for the offering.
148    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    /// Calculate price for a product offering (best matching simple rule).
173    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    /// Qualify (eligibility + relationships) and price in one call.
201    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    /// Validate a bundle selection against a stored bundle.
228    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    /// Calculate price for a named bundle given unit prices.
242    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    /// Get bundles for a product
256    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    /// Evaluate catalog rules for a given context
269    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}