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> =
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
143pub 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 }
170 }
171 }
172 Ok(())
173}
174
175pub 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}