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> =
104        selected_offering_ids.iter().copied().collect();
105
106    for bp in &bundle.products {
107        if bp.is_required && !selected.contains(&bp.product_offering_id) {
108            return Err(format!(
109                "Required product {} missing from selection",
110                bp.product_offering_id
111            ));
112        }
113    }
114
115    let selected_in_bundle: Vec<Uuid> = bundle
116        .products
117        .iter()
118        .map(|p| p.product_offering_id)
119        .filter(|id| selected.contains(id))
120        .collect();
121
122    match bundle.bundle_type {
123        BundleType::Mandatory => {
124            if selected_in_bundle.len() != bundle.products.len() {
125                return Err("Mandatory bundle requires all products".into());
126            }
127        }
128        BundleType::Optional => {
129            if selected_in_bundle.is_empty() {
130                return Err("Optional bundle requires at least one product".into());
131            }
132        }
133        BundleType::Exclusive => {
134            if selected_in_bundle.len() != 1 {
135                return Err("Exclusive bundle requires exactly one product".into());
136            }
137        }
138    }
139
140    Ok(())
141}
142
143/// Validate product relationship constraints for a cart selection.
144pub fn validate_relationships(
145    relationships: &[ProductRelationship],
146    selected: &[Uuid],
147) -> Result<(), String> {
148    let set: std::collections::HashSet<Uuid> = selected.iter().copied().collect();
149    for rel in relationships {
150        match rel.relationship_type {
151            ProductRelationshipType::DependsOn | ProductRelationshipType::Requires => {
152                if set.contains(&rel.from_offering_id) && !set.contains(&rel.to_offering_id) {
153                    return Err(format!(
154                        "{:?}: {} requires {}",
155                        rel.relationship_type, rel.from_offering_id, rel.to_offering_id
156                    ));
157                }
158            }
159            ProductRelationshipType::Excludes => {
160                if set.contains(&rel.from_offering_id) && set.contains(&rel.to_offering_id) {
161                    return Err(format!(
162                        "Excludes: {} cannot be combined with {}",
163                        rel.from_offering_id, rel.to_offering_id
164                    ));
165                }
166            }
167            ProductRelationshipType::MigratesTo => {
168                // Informational for catalog; no cart-time hard fail.
169            }
170        }
171    }
172    Ok(())
173}
174
175/// Calculate bundle price
176pub fn calculate_bundle_price(
177    bundle: &Bundle,
178    individual_prices: &[(Uuid, f64)],
179) -> Result<f64, String> {
180    let total_individual_price: f64 = bundle
181        .products
182        .iter()
183        .map(|bp| {
184            individual_prices
185                .iter()
186                .find(|(id, _)| *id == bp.product_offering_id)
187                .map(|(_, price)| *price * bp.quantity as f64)
188                .unwrap_or(0.0)
189        })
190        .sum();
191
192    match &bundle.bundle_price {
193        Some(bp) => match bp.discount_type {
194            BundleDiscountType::PercentageOff => {
195                Ok(((total_individual_price * (1.0 - bp.value / 100.0)) * 100.0).round() / 100.0)
196            }
197            BundleDiscountType::FixedAmountOff => {
198                Ok(((total_individual_price - bp.value).max(0.0) * 100.0).round() / 100.0)
199            }
200            BundleDiscountType::FixedPrice => Ok(bp.value),
201        },
202        None => Ok((total_individual_price * 100.0).round() / 100.0),
203    }
204}
205
206#[cfg(test)]
207mod tests {
208    use super::*;
209
210    #[test]
211    fn exclusive_selection_must_be_exactly_one() {
212        let a = Uuid::new_v4();
213        let b = Uuid::new_v4();
214        let bundle = Bundle {
215            id: Uuid::new_v4(),
216            name: "xor".into(),
217            bundle_type: BundleType::Exclusive,
218            products: vec![
219                BundleProduct {
220                    product_offering_id: a,
221                    quantity: 1,
222                    is_required: false,
223                },
224                BundleProduct {
225                    product_offering_id: b,
226                    quantity: 1,
227                    is_required: false,
228                },
229            ],
230            bundle_price: None,
231        };
232        assert!(validate_bundle_selection(&bundle, &[a]).is_ok());
233        assert!(validate_bundle_selection(&bundle, &[a, b]).is_err());
234    }
235}