synor/crates/synor-economics/src/pricing/discounts.rs

399 lines
12 KiB
Rust

//! 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<SynorDecimal>,
/// Maximum discount amount
pub max_discount: Option<SynorDecimal>,
/// Applicable service types (None = all)
pub service_types: Option<Vec<ServiceType>>,
/// Applicable account IDs (None = all)
pub account_ids: Option<Vec<String>>,
/// Start date
pub start_date: DateTime<Utc>,
/// End date (None = no expiry)
pub end_date: Option<DateTime<Utc>>,
/// Maximum uses (None = unlimited)
pub max_uses: Option<u32>,
/// 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<String>, name: impl Into<String>, 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<String>, name: impl Into<String>, 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<String>) -> 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<Utc>) -> 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<ServiceType>) -> Self {
self.service_types = Some(services);
self
}
/// Limit to specific accounts
pub fn for_accounts(mut self, accounts: Vec<String>) -> 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<ServiceType>) -> 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<Discount> {
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<Discount> {
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+)
}
}