use chrono::{DateTime, Utc};
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use stateset_primitives::{CartId, CurrencyCode, CustomerId, OrderId, ProductId, PromotionId};
use strum::{Display, EnumString};
use uuid::Uuid;
#[derive(
Debug, Clone, Copy, PartialEq, Eq, Display, EnumString, Serialize, Deserialize, Default,
)]
#[strum(serialize_all = "snake_case", ascii_case_insensitive)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum PromotionType {
#[default]
#[strum(serialize = "percentage_off", serialize = "percentageoff")]
PercentageOff,
#[strum(serialize = "fixed_amount_off", serialize = "fixedamountoff")]
FixedAmountOff,
#[strum(serialize = "buy_x_get_y", serialize = "buyxgety")]
BuyXGetY,
#[strum(serialize = "free_shipping", serialize = "freeshipping")]
FreeShipping,
#[strum(serialize = "tiered_discount", serialize = "tiereddiscount")]
TieredDiscount,
#[strum(serialize = "bundle_discount", serialize = "bundlediscount")]
BundleDiscount,
#[strum(serialize = "first_order_discount", serialize = "firstorderdiscount")]
FirstOrderDiscount,
#[strum(serialize = "gift_with_purchase", serialize = "giftwithpurchase")]
GiftWithPurchase,
}
#[derive(
Debug, Clone, Copy, PartialEq, Eq, Display, EnumString, Serialize, Deserialize, Default,
)]
#[strum(serialize_all = "snake_case", ascii_case_insensitive)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum PromotionStatus {
#[default]
Draft,
Scheduled,
Active,
Paused,
Expired,
Exhausted,
Archived,
}
#[derive(
Debug, Clone, Copy, PartialEq, Eq, Display, EnumString, Serialize, Deserialize, Default,
)]
#[strum(serialize_all = "snake_case", ascii_case_insensitive)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum PromotionTrigger {
#[default]
Automatic,
#[strum(serialize = "coupon_code", serialize = "couponcode")]
CouponCode,
Both,
}
#[derive(
Debug, Clone, Copy, PartialEq, Eq, Display, EnumString, Serialize, Deserialize, Default,
)]
#[strum(serialize_all = "snake_case", ascii_case_insensitive)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum PromotionTarget {
#[default]
Order,
Product,
Category,
Shipping,
#[strum(serialize = "line_item", serialize = "lineitem")]
LineItem,
}
#[derive(
Debug, Clone, Copy, PartialEq, Eq, Display, EnumString, Serialize, Deserialize, Default,
)]
#[strum(serialize_all = "snake_case", ascii_case_insensitive)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum StackingBehavior {
#[default]
Stackable,
Exclusive,
#[strum(serialize = "selective_stack", serialize = "selectivestack")]
SelectiveStack,
}
#[derive(
Debug, Clone, Copy, PartialEq, Eq, Display, EnumString, Serialize, Deserialize, Default,
)]
#[strum(serialize_all = "snake_case", ascii_case_insensitive)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum ConditionOperator {
#[default]
Equals,
#[strum(serialize = "not_equals", serialize = "notequals")]
NotEquals,
#[strum(serialize = "greater_than", serialize = "greaterthan")]
GreaterThan,
#[strum(serialize = "greater_than_or_equal", serialize = "greaterthanorequal")]
GreaterThanOrEqual,
#[strum(serialize = "less_than", serialize = "lessthan")]
LessThan,
#[strum(serialize = "less_than_or_equal", serialize = "lessthanorequal")]
LessThanOrEqual,
Contains,
#[strum(serialize = "not_contains", serialize = "notcontains")]
NotContains,
In,
#[strum(serialize = "not_in", serialize = "notin")]
NotIn,
}
#[derive(
Debug, Clone, Copy, PartialEq, Eq, Display, EnumString, Serialize, Deserialize, Default,
)]
#[strum(serialize_all = "snake_case", ascii_case_insensitive)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum ConditionType {
#[default]
#[strum(serialize = "minimum_subtotal", serialize = "minimumsubtotal")]
MinimumSubtotal,
#[strum(serialize = "minimum_quantity", serialize = "minimumquantity")]
MinimumQuantity,
#[strum(serialize = "product_in_cart", serialize = "productincart")]
ProductInCart,
#[strum(serialize = "category_in_cart", serialize = "categoryincart")]
CategoryInCart,
#[strum(serialize = "sku_in_cart", serialize = "skuincart")]
SkuInCart,
#[strum(serialize = "customer_group", serialize = "customergroup")]
CustomerGroup,
#[strum(serialize = "first_order", serialize = "firstorder")]
FirstOrder,
#[strum(serialize = "customer_email_domain", serialize = "customeremaildomain")]
CustomerEmailDomain,
#[strum(serialize = "shipping_country", serialize = "shippingcountry")]
ShippingCountry,
#[strum(serialize = "shipping_state", serialize = "shippingstate")]
ShippingState,
#[strum(serialize = "payment_method", serialize = "paymentmethod")]
PaymentMethod,
#[strum(serialize = "cart_item_count", serialize = "cartitemcount")]
CartItemCount,
#[strum(serialize = "customer_id", serialize = "customerid")]
CustomerId,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Promotion {
pub id: PromotionId,
pub code: String,
pub name: String,
pub description: Option<String>,
pub internal_notes: Option<String>,
pub promotion_type: PromotionType,
pub trigger: PromotionTrigger,
pub target: PromotionTarget,
pub stacking: StackingBehavior,
pub status: PromotionStatus,
pub percentage_off: Option<Decimal>,
pub fixed_amount_off: Option<Decimal>,
pub max_discount_amount: Option<Decimal>,
pub buy_quantity: Option<i32>,
pub get_quantity: Option<i32>,
pub get_discount_percent: Option<Decimal>,
pub tiers: Option<Vec<DiscountTier>>,
pub bundle_product_ids: Option<Vec<ProductId>>,
pub bundle_discount: Option<Decimal>,
pub starts_at: DateTime<Utc>,
pub ends_at: Option<DateTime<Utc>>,
pub total_usage_limit: Option<i32>,
pub per_customer_limit: Option<i32>,
pub usage_count: i32,
pub conditions: Vec<PromotionCondition>,
pub applicable_product_ids: Vec<ProductId>,
pub applicable_category_ids: Vec<Uuid>,
pub applicable_skus: Vec<String>,
pub excluded_product_ids: Vec<ProductId>,
pub excluded_category_ids: Vec<Uuid>,
pub eligible_customer_ids: Vec<CustomerId>,
pub eligible_customer_groups: Vec<String>,
pub currency: CurrencyCode,
pub priority: i32,
pub metadata: Option<serde_json::Value>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DiscountTier {
pub min_value: Decimal,
pub max_value: Option<Decimal>,
pub percentage_off: Option<Decimal>,
pub fixed_amount_off: Option<Decimal>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PromotionCondition {
pub id: Uuid,
pub promotion_id: PromotionId,
pub condition_type: ConditionType,
pub operator: ConditionOperator,
pub value: String,
pub is_required: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CouponCode {
pub id: Uuid,
pub promotion_id: PromotionId,
pub code: String,
pub status: CouponStatus,
pub usage_limit: Option<i32>,
pub per_customer_limit: Option<i32>,
pub usage_count: i32,
pub starts_at: Option<DateTime<Utc>>,
pub ends_at: Option<DateTime<Utc>>,
pub metadata: Option<serde_json::Value>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum CouponStatus {
#[default]
Active,
Disabled,
Exhausted,
Expired,
}
impl std::fmt::Display for CouponStatus {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Active => write!(f, "active"),
Self::Disabled => write!(f, "disabled"),
Self::Exhausted => write!(f, "exhausted"),
Self::Expired => write!(f, "expired"),
}
}
}
impl std::str::FromStr for CouponStatus {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.trim().to_ascii_lowercase().as_str() {
"active" => Ok(Self::Active),
"disabled" => Ok(Self::Disabled),
"exhausted" => Ok(Self::Exhausted),
"expired" => Ok(Self::Expired),
_ => Err(format!("Unknown coupon status: {s}")),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PromotionUsage {
pub id: Uuid,
pub promotion_id: PromotionId,
pub coupon_id: Option<Uuid>,
pub customer_id: Option<CustomerId>,
pub order_id: Option<OrderId>,
pub cart_id: Option<CartId>,
pub discount_amount: Decimal,
pub currency: CurrencyCode,
pub used_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ApplyPromotionsRequest {
pub cart_id: Option<CartId>,
pub customer_id: Option<CustomerId>,
pub coupon_codes: Vec<String>,
pub line_items: Vec<PromotionLineItem>,
pub subtotal: Decimal,
pub shipping_amount: Decimal,
pub shipping_country: Option<String>,
pub shipping_state: Option<String>,
pub currency: CurrencyCode,
pub is_first_order: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PromotionLineItem {
pub id: String,
pub product_id: Option<ProductId>,
pub variant_id: Option<Uuid>,
pub sku: Option<String>,
pub category_ids: Vec<Uuid>,
pub quantity: i32,
pub unit_price: Decimal,
pub line_total: Decimal,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ApplyPromotionsResult {
pub original_subtotal: Decimal,
pub total_discount: Decimal,
pub discounted_subtotal: Decimal,
pub original_shipping: Decimal,
pub shipping_discount: Decimal,
pub final_shipping: Decimal,
pub grand_total: Decimal,
pub applied_promotions: Vec<AppliedPromotion>,
pub rejected_promotions: Vec<RejectedPromotion>,
pub line_item_discounts: Vec<LineItemDiscount>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AppliedPromotion {
pub promotion_id: PromotionId,
pub promotion_code: String,
pub promotion_name: String,
pub coupon_code: Option<String>,
pub discount_amount: Decimal,
pub discount_type: PromotionType,
pub target: PromotionTarget,
pub description: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RejectedPromotion {
pub promotion_id: Option<PromotionId>,
pub coupon_code: Option<String>,
pub reason: String,
pub reason_code: RejectionReason,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum RejectionReason {
InvalidCode,
Expired,
NotYetActive,
UsageLimitReached,
CustomerLimitReached,
MinimumNotMet,
ProductNotEligible,
CustomerNotEligible,
NotStackable,
AlreadyApplied,
InternalError,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LineItemDiscount {
pub line_item_id: String,
pub promotion_id: PromotionId,
pub original_price: Decimal,
pub discount_amount: Decimal,
pub final_price: Decimal,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct CreatePromotion {
pub code: Option<String>,
pub name: String,
pub description: Option<String>,
pub internal_notes: Option<String>,
pub promotion_type: PromotionType,
pub trigger: PromotionTrigger,
pub target: PromotionTarget,
pub stacking: StackingBehavior,
pub percentage_off: Option<Decimal>,
pub fixed_amount_off: Option<Decimal>,
pub max_discount_amount: Option<Decimal>,
pub buy_quantity: Option<i32>,
pub get_quantity: Option<i32>,
pub get_discount_percent: Option<Decimal>,
pub tiers: Option<Vec<DiscountTier>>,
pub bundle_product_ids: Option<Vec<ProductId>>,
pub bundle_discount: Option<Decimal>,
pub starts_at: Option<DateTime<Utc>>,
pub ends_at: Option<DateTime<Utc>>,
pub total_usage_limit: Option<i32>,
pub per_customer_limit: Option<i32>,
pub conditions: Option<Vec<CreatePromotionCondition>>,
pub applicable_product_ids: Option<Vec<ProductId>>,
pub applicable_category_ids: Option<Vec<Uuid>>,
pub applicable_skus: Option<Vec<String>>,
pub excluded_product_ids: Option<Vec<ProductId>>,
pub excluded_category_ids: Option<Vec<Uuid>>,
pub eligible_customer_ids: Option<Vec<CustomerId>>,
pub eligible_customer_groups: Option<Vec<String>>,
pub currency: Option<CurrencyCode>,
pub priority: Option<i32>,
pub metadata: Option<serde_json::Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CreatePromotionCondition {
pub condition_type: ConditionType,
pub operator: ConditionOperator,
pub value: String,
pub is_required: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct UpdatePromotion {
pub name: Option<String>,
pub description: Option<String>,
pub internal_notes: Option<String>,
pub status: Option<PromotionStatus>,
pub percentage_off: Option<Decimal>,
pub fixed_amount_off: Option<Decimal>,
pub max_discount_amount: Option<Decimal>,
pub starts_at: Option<DateTime<Utc>>,
pub ends_at: Option<DateTime<Utc>>,
pub total_usage_limit: Option<i32>,
pub per_customer_limit: Option<i32>,
pub priority: Option<i32>,
pub metadata: Option<serde_json::Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CreateCouponCode {
pub promotion_id: PromotionId,
pub code: String,
pub usage_limit: Option<i32>,
pub per_customer_limit: Option<i32>,
pub starts_at: Option<DateTime<Utc>>,
pub ends_at: Option<DateTime<Utc>>,
pub metadata: Option<serde_json::Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct PromotionFilter {
pub status: Option<PromotionStatus>,
pub promotion_type: Option<PromotionType>,
pub trigger: Option<PromotionTrigger>,
pub is_active: Option<bool>,
pub search: Option<String>,
pub limit: Option<u32>,
pub offset: Option<u32>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct CouponFilter {
pub promotion_id: Option<PromotionId>,
pub status: Option<CouponStatus>,
pub search: Option<String>,
pub limit: Option<u32>,
pub offset: Option<u32>,
}
#[must_use]
pub fn generate_promotion_code() -> String {
let id = Uuid::new_v4();
let bytes = id.as_bytes();
let random = u32::from_be_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]) % 10000;
let timestamp = chrono::Utc::now().timestamp_millis();
format!("PROMO-{}-{:04}", timestamp % 1000000, random)
}
#[must_use]
pub fn generate_coupon_code(prefix: Option<&str>) -> String {
let chars: Vec<char> = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789".chars().collect();
let id = Uuid::new_v4();
let bytes = id.as_bytes();
let code: String = bytes[0..8]
.iter()
.map(|b| {
let idx = (*b as usize) % chars.len();
chars[idx]
})
.collect();
match prefix {
Some(p) => format!("{}{}", p.to_uppercase(), code),
None => code,
}
}
impl Promotion {
#[must_use]
pub fn has_product_scoping(&self) -> bool {
!self.applicable_product_ids.is_empty()
|| !self.applicable_category_ids.is_empty()
|| !self.applicable_skus.is_empty()
|| !self.excluded_product_ids.is_empty()
|| !self.excluded_category_ids.is_empty()
}
#[must_use]
pub fn item_in_scope(&self, item: &PromotionLineItem) -> bool {
if item.product_id.is_some_and(|p| self.excluded_product_ids.contains(&p)) {
return false;
}
if item.category_ids.iter().any(|c| self.excluded_category_ids.contains(c)) {
return false;
}
let has_applicability = !self.applicable_product_ids.is_empty()
|| !self.applicable_category_ids.is_empty()
|| !self.applicable_skus.is_empty();
if has_applicability {
let by_product =
item.product_id.is_some_and(|p| self.applicable_product_ids.contains(&p));
let by_category =
item.category_ids.iter().any(|c| self.applicable_category_ids.contains(c));
let by_sku =
item.sku.as_deref().is_some_and(|s| self.applicable_skus.iter().any(|a| a == s));
return by_product || by_category || by_sku;
}
true
}
#[must_use]
pub fn is_active(&self) -> bool {
if self.status != PromotionStatus::Active {
return false;
}
let now = Utc::now();
if now < self.starts_at {
return false;
}
if let Some(ends_at) = self.ends_at {
if now > ends_at {
return false;
}
}
if let Some(limit) = self.total_usage_limit {
if self.usage_count >= limit {
return false;
}
}
true
}
#[must_use]
pub fn discount_description(&self) -> String {
match self.promotion_type {
PromotionType::PercentageOff => {
if let Some(pct) = self.percentage_off {
format!("{}% off", (pct * Decimal::from(100)).round())
} else {
"Percentage discount".to_string()
}
}
PromotionType::FixedAmountOff => {
if let Some(amt) = self.fixed_amount_off {
format!("${amt} off")
} else {
"Fixed discount".to_string()
}
}
PromotionType::BuyXGetY => {
let buy = self.buy_quantity.unwrap_or(1);
let get = self.get_quantity.unwrap_or(1);
let discount = self.get_discount_percent.unwrap_or(Decimal::ONE);
if discount == Decimal::ONE {
format!("Buy {buy} get {get} free")
} else {
format!(
"Buy {} get {} at {}% off",
buy,
get,
(discount * Decimal::from(100)).round()
)
}
}
PromotionType::FreeShipping => "Free shipping".to_string(),
PromotionType::TieredDiscount => "Tiered discount".to_string(),
PromotionType::BundleDiscount => "Bundle discount".to_string(),
PromotionType::FirstOrderDiscount => {
if let Some(pct) = self.percentage_off {
format!("{}% off first order", (pct * Decimal::from(100)).round())
} else if let Some(amt) = self.fixed_amount_off {
format!("${amt} off first order")
} else {
"First order discount".to_string()
}
}
PromotionType::GiftWithPurchase => "Gift with purchase".to_string(),
}
}
}
impl Default for ApplyPromotionsResult {
fn default() -> Self {
Self {
original_subtotal: Decimal::ZERO,
total_discount: Decimal::ZERO,
discounted_subtotal: Decimal::ZERO,
original_shipping: Decimal::ZERO,
shipping_discount: Decimal::ZERO,
final_shipping: Decimal::ZERO,
grand_total: Decimal::ZERO,
applied_promotions: Vec::new(),
rejected_promotions: Vec::new(),
line_item_discounts: Vec::new(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::str::FromStr;
#[test]
fn test_promotion_type_from_str() {
assert_eq!(
PromotionType::from_str("percentage_off").unwrap(),
PromotionType::PercentageOff
);
assert_eq!(PromotionType::from_str("buyxgety").unwrap(), PromotionType::BuyXGetY);
}
#[test]
fn test_promotion_trigger_from_str() {
assert_eq!(
PromotionTrigger::from_str("coupon_code").unwrap(),
PromotionTrigger::CouponCode
);
assert_eq!(PromotionTrigger::from_str("couponcode").unwrap(), PromotionTrigger::CouponCode);
}
#[test]
fn test_condition_operator_from_str() {
assert_eq!(
ConditionOperator::from_str("greater_than_or_equal").unwrap(),
ConditionOperator::GreaterThanOrEqual
);
assert_eq!(
ConditionOperator::from_str("greaterthanorequal").unwrap(),
ConditionOperator::GreaterThanOrEqual
);
}
#[test]
fn test_condition_type_from_str() {
assert_eq!(
ConditionType::from_str("minimum_subtotal").unwrap(),
ConditionType::MinimumSubtotal
);
assert_eq!(
ConditionType::from_str("minimumsubtotal").unwrap(),
ConditionType::MinimumSubtotal
);
}
#[test]
fn test_coupon_status_from_str() {
assert_eq!(CouponStatus::from_str("active").unwrap(), CouponStatus::Active);
assert_eq!(CouponStatus::from_str("exhausted").unwrap(), CouponStatus::Exhausted);
}
}