399 lines
12 KiB
Rust
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+)
|
|
}
|
|
}
|