//! Discount System //! //! Promotional, volume, and loyalty discounts. use crate::{ServiceType, SynorDecimal}; use chrono::{DateTime, Utc}; use rust_decimal::Decimal; use serde::{Deserialize, Serialize}; /// Discount type #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum DiscountType { /// Percentage off Percentage, /// Fixed amount off FixedAmount, /// Volume-based (spend X, get Y% off) Volume, /// Loyalty reward Loyalty, /// Referral discount Referral, /// Promotional campaign Promotional, /// Partner/reseller discount Partner, } impl std::fmt::Display for DiscountType { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { DiscountType::Percentage => write!(f, "Percentage"), DiscountType::FixedAmount => write!(f, "Fixed Amount"), DiscountType::Volume => write!(f, "Volume"), DiscountType::Loyalty => write!(f, "Loyalty"), DiscountType::Referral => write!(f, "Referral"), DiscountType::Promotional => write!(f, "Promotional"), DiscountType::Partner => write!(f, "Partner"), } } } /// Discount definition #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Discount { /// Unique discount code/ID pub code: String, /// Discount name pub name: String, /// Description pub description: String, /// Discount type pub discount_type: DiscountType, /// Discount value (percentage or fixed amount) pub value: SynorDecimal, /// Minimum spend to qualify pub min_spend: Option, /// Maximum discount amount pub max_discount: Option, /// Applicable service types (None = all) pub service_types: Option>, /// Applicable account IDs (None = all) pub account_ids: Option>, /// Start date pub start_date: DateTime, /// End date (None = no expiry) pub end_date: Option>, /// Maximum uses (None = unlimited) pub max_uses: Option, /// Current uses pub current_uses: u32, /// Active status pub is_active: bool, } impl Discount { /// Create a new percentage discount pub fn percentage(code: impl Into, name: impl Into, percentage: SynorDecimal) -> Self { Self { code: code.into(), name: name.into(), description: format!("{}% off", percentage), discount_type: DiscountType::Percentage, value: percentage, min_spend: None, max_discount: None, service_types: None, account_ids: None, start_date: Utc::now(), end_date: None, max_uses: None, current_uses: 0, is_active: true, } } /// Create a fixed amount discount pub fn fixed_amount(code: impl Into, name: impl Into, amount: SynorDecimal) -> Self { Self { code: code.into(), name: name.into(), description: format!("{} SYNOR off", amount), discount_type: DiscountType::FixedAmount, value: amount, min_spend: None, max_discount: Some(amount), service_types: None, account_ids: None, start_date: Utc::now(), end_date: None, max_uses: None, current_uses: 0, is_active: true, } } /// Create a volume discount pub fn volume(min_spend: SynorDecimal, percentage: SynorDecimal) -> Self { Self { code: format!("VOLUME_{}", min_spend), name: format!("Volume Discount ({} SYNOR+)", min_spend), description: format!("{}% off when spending {} SYNOR or more", percentage, min_spend), discount_type: DiscountType::Volume, value: percentage, min_spend: Some(min_spend), max_discount: None, service_types: None, account_ids: None, start_date: Utc::now(), end_date: None, max_uses: None, current_uses: 0, is_active: true, } } /// Create a referral discount pub fn referral(referrer_id: impl Into) -> Self { let referrer = referrer_id.into(); Self { code: format!("REF_{}", &referrer[..8.min(referrer.len())].to_uppercase()), name: "Referral Discount".to_string(), description: "10% off for referred users".to_string(), discount_type: DiscountType::Referral, value: Decimal::new(10, 0), // 10% min_spend: None, max_discount: Some(Decimal::new(50, 0)), // Max 50 SYNOR service_types: None, account_ids: None, start_date: Utc::now(), end_date: Some(Utc::now() + chrono::Duration::days(90)), max_uses: Some(1), current_uses: 0, is_active: true, } } /// Set expiration pub fn with_expiry(mut self, end_date: DateTime) -> Self { self.end_date = Some(end_date); self } /// Set expiration days from now pub fn with_expiry_days(mut self, days: i64) -> Self { self.end_date = Some(Utc::now() + chrono::Duration::days(days)); self } /// Set minimum spend pub fn with_min_spend(mut self, min: SynorDecimal) -> Self { self.min_spend = Some(min); self } /// Set maximum discount pub fn with_max_discount(mut self, max: SynorDecimal) -> Self { self.max_discount = Some(max); self } /// Limit to specific service types pub fn for_services(mut self, services: Vec) -> Self { self.service_types = Some(services); self } /// Limit to specific accounts pub fn for_accounts(mut self, accounts: Vec) -> Self { self.account_ids = Some(accounts); self } /// Set maximum uses pub fn with_max_uses(mut self, max: u32) -> Self { self.max_uses = Some(max); self } /// Check if discount is currently valid pub fn is_valid(&self) -> bool { if !self.is_active { return false; } let now = Utc::now(); if now < self.start_date { return false; } if let Some(end) = self.end_date { if now > end { return false; } } if let Some(max) = self.max_uses { if self.current_uses >= max { return false; } } true } /// Check if discount is applicable to an account and service pub fn is_applicable(&self, account_id: &str, service_type: Option) -> bool { if !self.is_valid() { return false; } // Check account restriction if let Some(ref accounts) = self.account_ids { if !accounts.iter().any(|a| a == account_id) { return false; } } // Check service restriction if let Some(ref services) = self.service_types { if let Some(st) = service_type { if !services.contains(&st) { return false; } } } true } /// Calculate discount amount for a given spend pub fn calculate_discount(&self, amount: SynorDecimal) -> SynorDecimal { // Check minimum spend if let Some(min) = self.min_spend { if amount < min { return Decimal::ZERO; } } let discount = match self.discount_type { DiscountType::Percentage | DiscountType::Volume | DiscountType::Loyalty | DiscountType::Referral | DiscountType::Partner => { amount * (self.value / Decimal::ONE_HUNDRED) } DiscountType::FixedAmount | DiscountType::Promotional => { self.value.min(amount) // Can't discount more than amount } }; // Apply maximum discount cap if let Some(max) = self.max_discount { discount.min(max) } else { discount } } /// Use the discount (increment counter) pub fn use_discount(&mut self) -> bool { if !self.is_valid() { return false; } self.current_uses += 1; // Check if now exhausted if let Some(max) = self.max_uses { if self.current_uses >= max { self.is_active = false; } } true } } /// Volume discount tiers pub fn standard_volume_discounts() -> Vec { vec![ Discount::volume(Decimal::new(100, 0), Decimal::new(5, 0)), // 5% at 100+ SYNOR Discount::volume(Decimal::new(500, 0), Decimal::new(10, 0)), // 10% at 500+ SYNOR Discount::volume(Decimal::new(1000, 0), Decimal::new(15, 0)), // 15% at 1000+ SYNOR Discount::volume(Decimal::new(5000, 0), Decimal::new(20, 0)), // 20% at 5000+ SYNOR ] } /// Find best applicable volume discount pub fn find_best_volume_discount(amount: SynorDecimal) -> Option { standard_volume_discounts() .into_iter() .filter(|d| { d.min_spend .map(|min| amount >= min) .unwrap_or(false) }) .max_by(|a, b| a.value.cmp(&b.value)) } #[cfg(test)] mod tests { use super::*; use rust_decimal_macros::dec; #[test] fn test_percentage_discount() { let discount = Discount::percentage("TEST10", "Test Discount", dec!(10)); assert!(discount.is_valid()); let amount = discount.calculate_discount(dec!(100)); assert_eq!(amount, dec!(10)); // 10% of 100 } #[test] fn test_fixed_discount() { let discount = Discount::fixed_amount("FIXED50", "Fixed 50", dec!(50)); // Normal case let amount = discount.calculate_discount(dec!(100)); assert_eq!(amount, dec!(50)); // Amount less than discount let amount = discount.calculate_discount(dec!(30)); assert_eq!(amount, dec!(30)); // Can't exceed original amount } #[test] fn test_volume_discount() { let discount = Discount::volume(dec!(100), dec!(10)).with_min_spend(dec!(100)); // Below minimum let amount = discount.calculate_discount(dec!(50)); assert_eq!(amount, dec!(0)); // Above minimum let amount = discount.calculate_discount(dec!(200)); assert_eq!(amount, dec!(20)); // 10% of 200 } #[test] fn test_discount_max_cap() { let discount = Discount::percentage("CAPPED", "Capped Discount", dec!(50)) .with_max_discount(dec!(100)); // Would be 250 but capped at 100 let amount = discount.calculate_discount(dec!(500)); assert_eq!(amount, dec!(100)); } #[test] fn test_discount_expiry() { let discount = Discount::percentage("EXPIRED", "Expired", dec!(10)) .with_expiry(Utc::now() - chrono::Duration::days(1)); assert!(!discount.is_valid()); } #[test] fn test_discount_usage_limit() { let mut discount = Discount::percentage("LIMITED", "Limited Use", dec!(10)) .with_max_uses(2); assert!(discount.use_discount()); assert!(discount.use_discount()); assert!(!discount.use_discount()); // Third use should fail } #[test] fn test_find_best_volume_discount() { let best = find_best_volume_discount(dec!(750)); assert!(best.is_some()); assert_eq!(best.unwrap().value, dec!(10)); // 10% tier (500+) let best_high = find_best_volume_discount(dec!(6000)); assert!(best_high.is_some()); assert_eq!(best_high.unwrap().value, dec!(20)); // 20% tier (5000+) } }