Skip to main content

pcm_engine/
pricing.rs

1//! Pricing rules and calculations
2
3use chrono::{DateTime, Utc};
4use serde::{Deserialize, Serialize};
5use uuid::Uuid;
6
7/// Pricing rule for a product offering
8#[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    /// Higher values win when multiple rules match the same offering.
15    #[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/// Price type
24#[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/// Money representation
34#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
35pub struct Money {
36    pub value: f64,
37    pub unit: String,
38}
39
40/// Discount rule
41#[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/// Discount type
51#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
52#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
53pub enum DiscountType {
54    Percentage,
55    FixedAmount,
56}
57
58/// Discount condition
59#[derive(Debug, Clone, Serialize, Deserialize)]
60pub struct DiscountCondition {
61    pub field: String,
62    pub operator: PricingConditionOperator,
63    pub value: String,
64}
65
66/// Pricing condition operator
67#[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/// Time period
77#[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    /// Whether `at` falls within this period (inclusive start; exclusive end if set).
86    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
97/// Calculate final price after applying discounts (only if the rule is currently valid).
98pub 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
121/// Select the highest-priority currently valid rule and price it.
122pub 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/// Pricing context for discount evaluation
138#[derive(Debug, Clone)]
139pub struct PricingContext {
140    pub customer_segment: Option<String>,
141    pub quantity: u32,
142    pub existing_products: Vec<Uuid>,
143    /// Instant used for `valid_for` checks (defaults to now in helpers).
144    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}