Skip to main content

pcm_engine/
bundling.rs

1//! Product bundling and relationship management
2
3use serde::{Deserialize, Serialize};
4use uuid::Uuid;
5
6/// Bundle definition
7#[derive(Debug, Clone, Serialize, Deserialize)]
8pub struct Bundle {
9    pub id: Uuid,
10    pub name: String,
11    pub bundle_type: BundleType,
12    pub products: Vec<BundleProduct>,
13    pub bundle_price: Option<BundlePrice>,
14}
15
16/// Bundle type
17#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
18#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
19pub enum BundleType {
20    /// All products must be included
21    Mandatory,
22    /// At least one product must be included
23    Optional,
24    /// Products are mutually exclusive
25    Exclusive,
26}
27
28/// Product in a bundle
29#[derive(Debug, Clone, Serialize, Deserialize)]
30pub struct BundleProduct {
31    pub product_offering_id: Uuid,
32    pub quantity: u32,
33    pub is_required: bool,
34}
35
36/// Bundle pricing
37#[derive(Debug, Clone, Serialize, Deserialize)]
38pub struct BundlePrice {
39    pub discount_type: BundleDiscountType,
40    pub value: f64,
41}
42
43/// Bundle discount type
44#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
45#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
46pub enum BundleDiscountType {
47    /// Percentage discount on total
48    PercentageOff,
49    /// Fixed amount discount
50    FixedAmountOff,
51    /// Fixed price for the bundle
52    FixedPrice,
53}
54
55/// Product relationship between offerings (TMF-style graph edge).
56#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
57#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
58pub enum ProductRelationshipType {
59    DependsOn,
60    Excludes,
61    Requires,
62    MigratesTo,
63}
64
65#[derive(Debug, Clone, Serialize, Deserialize)]
66pub struct ProductRelationship {
67    pub id: Uuid,
68    pub from_offering_id: Uuid,
69    pub to_offering_id: Uuid,
70    pub relationship_type: ProductRelationshipType,
71}
72
73/// Validate bundle configuration
74pub fn validate_bundle(bundle: &Bundle) -> Result<(), String> {
75    if bundle.products.is_empty() {
76        return Err("Bundle must contain at least one product".to_string());
77    }
78
79    match bundle.bundle_type {
80        BundleType::Mandatory => {
81            if bundle.products.iter().any(|p| !p.is_required) {
82                return Err("Mandatory bundles cannot have optional products".to_string());
83            }
84        }
85        BundleType::Exclusive => {
86            if bundle.products.len() < 2 {
87                return Err("Exclusive bundles must have at least 2 products".to_string());
88            }
89        }
90        _ => {}
91    }
92
93    Ok(())
94}
95
96/// Validate a runtime selection of offerings against a bundle definition.
97pub fn validate_bundle_selection(
98    bundle: &Bundle,
99    selected_offering_ids: &[Uuid],
100) -> Result<(), String> {
101    validate_bundle(bundle)?;
102
103    let selected: std::collections::HashSet<Uuid> = selected_offering_ids.iter().copied().collect();
104
105    for bp in &bundle.products {
106        if bp.is_required && !selected.contains(&bp.product_offering_id) {
107            return Err(format!(
108                "Required product {} missing from selection",
109                bp.product_offering_id
110            ));
111        }
112    }
113
114    let selected_in_bundle: Vec<Uuid> = bundle
115        .products
116        .iter()
117        .map(|p| p.product_offering_id)
118        .filter(|id| selected.contains(id))
119        .collect();
120
121    match bundle.bundle_type {
122        BundleType::Mandatory => {
123            if selected_in_bundle.len() != bundle.products.len() {
124                return Err("Mandatory bundle requires all products".into());
125            }
126        }
127        BundleType::Optional => {
128            if selected_in_bundle.is_empty() {
129                return Err("Optional bundle requires at least one product".into());
130            }
131        }
132        BundleType::Exclusive => {
133            if selected_in_bundle.len() != 1 {
134                return Err("Exclusive bundle requires exactly one product".into());
135            }
136        }
137    }
138
139    Ok(())
140}
141
142/// Validate product relationship constraints for a cart selection.
143pub fn validate_relationships(
144    relationships: &[ProductRelationship],
145    selected: &[Uuid],
146) -> Result<(), String> {
147    let set: std::collections::HashSet<Uuid> = selected.iter().copied().collect();
148    for rel in relationships {
149        match rel.relationship_type {
150            ProductRelationshipType::DependsOn | ProductRelationshipType::Requires => {
151                if set.contains(&rel.from_offering_id) && !set.contains(&rel.to_offering_id) {
152                    return Err(format!(
153                        "{:?}: {} requires {}",
154                        rel.relationship_type, rel.from_offering_id, rel.to_offering_id
155                    ));
156                }
157            }
158            ProductRelationshipType::Excludes => {
159                if set.contains(&rel.from_offering_id) && set.contains(&rel.to_offering_id) {
160                    return Err(format!(
161                        "Excludes: {} cannot be combined with {}",
162                        rel.from_offering_id, rel.to_offering_id
163                    ));
164                }
165            }
166            ProductRelationshipType::MigratesTo => {
167                // Informational for catalog; no cart-time hard fail.
168            }
169        }
170    }
171    Ok(())
172}
173
174/// Calculate bundle price
175pub fn calculate_bundle_price(
176    bundle: &Bundle,
177    individual_prices: &[(Uuid, f64)],
178) -> Result<f64, String> {
179    let total_individual_price: f64 = bundle
180        .products
181        .iter()
182        .map(|bp| {
183            individual_prices
184                .iter()
185                .find(|(id, _)| *id == bp.product_offering_id)
186                .map(|(_, price)| *price * bp.quantity as f64)
187                .unwrap_or(0.0)
188        })
189        .sum();
190
191    match &bundle.bundle_price {
192        Some(bp) => match bp.discount_type {
193            BundleDiscountType::PercentageOff => {
194                Ok(((total_individual_price * (1.0 - bp.value / 100.0)) * 100.0).round() / 100.0)
195            }
196            BundleDiscountType::FixedAmountOff => {
197                Ok(((total_individual_price - bp.value).max(0.0) * 100.0).round() / 100.0)
198            }
199            BundleDiscountType::FixedPrice => Ok(bp.value),
200        },
201        None => Ok((total_individual_price * 100.0).round() / 100.0),
202    }
203}
204
205#[cfg(test)]
206mod tests {
207    use super::*;
208
209    #[test]
210    fn exclusive_selection_must_be_exactly_one() {
211        let a = Uuid::new_v4();
212        let b = Uuid::new_v4();
213        let bundle = Bundle {
214            id: Uuid::new_v4(),
215            name: "xor".into(),
216            bundle_type: BundleType::Exclusive,
217            products: vec![
218                BundleProduct {
219                    product_offering_id: a,
220                    quantity: 1,
221                    is_required: false,
222                },
223                BundleProduct {
224                    product_offering_id: b,
225                    quantity: 1,
226                    is_required: false,
227                },
228            ],
229            bundle_price: None,
230        };
231        assert!(validate_bundle_selection(&bundle, &[a]).is_ok());
232        assert!(validate_bundle_selection(&bundle, &[a, b]).is_err());
233    }
234}