1use chrono::{DateTime, Utc};
4use serde::{Deserialize, Serialize};
5use uuid::Uuid;
6
7#[derive(Debug, Clone, Serialize, Deserialize)]
9pub struct PricingRule {
10 pub id: Uuid,
11 pub product_offering_id: Uuid,
12 pub price_type: PriceType,
13 pub base_price: Money,
14 #[serde(default)]
16 pub priority: u32,
17 #[serde(skip_serializing_if = "Option::is_none")]
18 pub discount_rules: Option<Vec<DiscountRule>>,
19 #[serde(skip_serializing_if = "Option::is_none")]
20 pub valid_for: Option<TimePeriod>,
21}
22
23#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
25#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
26pub enum PriceType {
27 Recurring,
28 OneTime,
29 Usage,
30 Tiered,
31}
32
33#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
35pub struct Money {
36 pub value: f64,
37 pub unit: String,
38}
39
40#[derive(Debug, Clone, Serialize, Deserialize)]
42pub struct DiscountRule {
43 pub name: String,
44 pub discount_type: DiscountType,
45 pub value: f64,
46 #[serde(skip_serializing_if = "Option::is_none")]
47 pub conditions: Option<Vec<DiscountCondition>>,
48}
49
50#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
52#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
53pub enum DiscountType {
54 Percentage,
55 FixedAmount,
56}
57
58#[derive(Debug, Clone, Serialize, Deserialize)]
60pub struct DiscountCondition {
61 pub field: String,
62 pub operator: PricingConditionOperator,
63 pub value: String,
64}
65
66#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
68#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
69pub enum PricingConditionOperator {
70 Equals,
71 GreaterThan,
72 LessThan,
73 Contains,
74}
75
76#[derive(Debug, Clone, Serialize, Deserialize)]
78pub struct TimePeriod {
79 pub start_date_time: DateTime<Utc>,
80 #[serde(skip_serializing_if = "Option::is_none")]
81 pub end_date_time: Option<DateTime<Utc>>,
82}
83
84impl TimePeriod {
85 pub fn contains(&self, at: DateTime<Utc>) -> bool {
87 if at < self.start_date_time {
88 return false;
89 }
90 match self.end_date_time {
91 Some(end) => at < end,
92 None => true,
93 }
94 }
95}
96
97pub fn calculate_final_price(rule: &PricingRule, context: &PricingContext) -> Option<Money> {
99 if let Some(ref window) = rule.valid_for {
100 if !window.contains(context.as_of) {
101 return None;
102 }
103 }
104
105 let mut final_price = rule.base_price.value;
106
107 if let Some(ref discounts) = rule.discount_rules {
108 for discount in discounts {
109 if is_discount_applicable(discount, context) {
110 final_price = apply_discount(final_price, discount);
111 }
112 }
113 }
114
115 Some(Money {
116 value: (final_price * 100.0).round() / 100.0,
117 unit: rule.base_price.unit.clone(),
118 })
119}
120
121pub fn calculate_best_price(
123 rules: &[PricingRule],
124 product_offering_id: Uuid,
125 context: &PricingContext,
126) -> Option<Money> {
127 let mut matched: Vec<&PricingRule> = rules
128 .iter()
129 .filter(|r| r.product_offering_id == product_offering_id)
130 .collect();
131 matched.sort_by(|a, b| b.priority.cmp(&a.priority));
132 matched
133 .into_iter()
134 .find_map(|rule| calculate_final_price(rule, context))
135}
136
137#[derive(Debug, Clone)]
139pub struct PricingContext {
140 pub customer_segment: Option<String>,
141 pub quantity: u32,
142 pub existing_products: Vec<Uuid>,
143 pub as_of: DateTime<Utc>,
145}
146
147impl PricingContext {
148 pub fn new(quantity: u32) -> Self {
149 Self {
150 customer_segment: None,
151 quantity,
152 existing_products: Vec::new(),
153 as_of: Utc::now(),
154 }
155 }
156}
157
158fn is_discount_applicable(discount: &DiscountRule, context: &PricingContext) -> bool {
159 if let Some(ref conditions) = discount.conditions {
160 conditions
161 .iter()
162 .all(|condition| evaluate_condition(condition, context))
163 } else {
164 true
165 }
166}
167
168fn evaluate_condition(condition: &DiscountCondition, context: &PricingContext) -> bool {
169 match condition.field.as_str() {
170 "customer_segment" => {
171 if let Some(ref segment) = context.customer_segment {
172 match condition.operator {
173 PricingConditionOperator::Equals => segment == &condition.value,
174 PricingConditionOperator::Contains => segment.contains(&condition.value),
175 _ => false,
176 }
177 } else {
178 false
179 }
180 }
181 "quantity" => {
182 let qty: u32 = condition.value.parse().unwrap_or(0);
183 match condition.operator {
184 PricingConditionOperator::GreaterThan => context.quantity > qty,
185 PricingConditionOperator::LessThan => context.quantity < qty,
186 PricingConditionOperator::Equals => context.quantity == qty,
187 _ => false,
188 }
189 }
190 "has_product" => {
191 let Ok(id) = Uuid::parse_str(&condition.value) else {
192 return false;
193 };
194 let owns = context.existing_products.contains(&id);
195 match condition.operator {
196 PricingConditionOperator::Equals => owns,
197 _ => false,
198 }
199 }
200 _ => false,
201 }
202}
203
204fn apply_discount(base_price: f64, discount: &DiscountRule) -> f64 {
205 match discount.discount_type {
206 DiscountType::Percentage => base_price * (1.0 - discount.value / 100.0),
207 DiscountType::FixedAmount => (base_price - discount.value).max(0.0),
208 }
209}
210
211#[cfg(test)]
212mod tests {
213 use super::*;
214
215 #[test]
216 fn applies_percentage_discount() {
217 let rule = PricingRule {
218 id: Uuid::new_v4(),
219 product_offering_id: Uuid::new_v4(),
220 price_type: PriceType::OneTime,
221 base_price: Money {
222 value: 100.0,
223 unit: "USD".into(),
224 },
225 priority: 1,
226 discount_rules: Some(vec![DiscountRule {
227 name: "promo".into(),
228 discount_type: DiscountType::Percentage,
229 value: 10.0,
230 conditions: None,
231 }]),
232 valid_for: None,
233 };
234 let price = calculate_final_price(&rule, &PricingContext::new(1)).unwrap();
235 assert!((price.value - 90.0).abs() < f64::EPSILON);
236 }
237
238 #[test]
239 fn respects_valid_for_window() {
240 let start = Utc::now() + chrono::Duration::days(1);
241 let rule = PricingRule {
242 id: Uuid::new_v4(),
243 product_offering_id: Uuid::new_v4(),
244 price_type: PriceType::OneTime,
245 base_price: Money {
246 value: 50.0,
247 unit: "USD".into(),
248 },
249 priority: 1,
250 discount_rules: None,
251 valid_for: Some(TimePeriod {
252 start_date_time: start,
253 end_date_time: None,
254 }),
255 };
256 assert!(calculate_final_price(&rule, &PricingContext::new(1)).is_none());
257 }
258}