1use serde::{Deserialize, Serialize};
4use uuid::Uuid;
5
6#[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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
18#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
19pub enum BundleType {
20 Mandatory,
22 Optional,
24 Exclusive,
26}
27
28#[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#[derive(Debug, Clone, Serialize, Deserialize)]
38pub struct BundlePrice {
39 pub discount_type: BundleDiscountType,
40 pub value: f64,
41}
42
43#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
45#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
46pub enum BundleDiscountType {
47 PercentageOff,
49 FixedAmountOff,
51 FixedPrice,
53}
54
55#[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
73pub 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
96pub 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
142pub 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 }
169 }
170 }
171 Ok(())
172}
173
174pub 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}