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