Skip to main content

stateset_core/models/
promotion.rs

1//! Promotions and discount models
2//!
3//! Comprehensive promotions engine supporting:
4//! - Percentage and fixed amount discounts
5//! - Buy X Get Y (BOGO) promotions
6//! - Free shipping offers
7//! - Tiered discounts based on spend/quantity
8//! - Bundle discounts
9//! - Coupon codes
10//! - Automatic promotions
11//! - Customer-specific promotions
12
13use chrono::{DateTime, Utc};
14use rust_decimal::Decimal;
15use serde::{Deserialize, Serialize};
16use stateset_primitives::{CartId, CurrencyCode, CustomerId, OrderId, ProductId, PromotionId};
17use strum::{Display, EnumString};
18use uuid::Uuid;
19
20// ============================================================================
21// Promotion Types and Enums
22// ============================================================================
23
24/// Type of promotion
25#[derive(
26    Debug, Clone, Copy, PartialEq, Eq, Display, EnumString, Serialize, Deserialize, Default,
27)]
28#[strum(serialize_all = "snake_case", ascii_case_insensitive)]
29#[serde(rename_all = "snake_case")]
30#[non_exhaustive]
31pub enum PromotionType {
32    /// Percentage off (e.g., 20% off)
33    #[default]
34    #[strum(serialize = "percentage_off", serialize = "percentageoff")]
35    PercentageOff,
36    /// Fixed amount off (e.g., $10 off)
37    #[strum(serialize = "fixed_amount_off", serialize = "fixedamountoff")]
38    FixedAmountOff,
39    /// Buy X get Y free or discounted
40    #[strum(serialize = "buy_x_get_y", serialize = "buyxgety")]
41    BuyXGetY,
42    /// Free shipping
43    #[strum(serialize = "free_shipping", serialize = "freeshipping")]
44    FreeShipping,
45    /// Tiered discount based on cart value
46    #[strum(serialize = "tiered_discount", serialize = "tiereddiscount")]
47    TieredDiscount,
48    /// Bundle discount (buy together and save)
49    #[strum(serialize = "bundle_discount", serialize = "bundlediscount")]
50    BundleDiscount,
51    /// First-time customer discount
52    #[strum(serialize = "first_order_discount", serialize = "firstorderdiscount")]
53    FirstOrderDiscount,
54    /// Gift with purchase
55    #[strum(serialize = "gift_with_purchase", serialize = "giftwithpurchase")]
56    GiftWithPurchase,
57}
58
59/// Status of a promotion
60#[derive(
61    Debug, Clone, Copy, PartialEq, Eq, Display, EnumString, Serialize, Deserialize, Default,
62)]
63#[strum(serialize_all = "snake_case", ascii_case_insensitive)]
64#[serde(rename_all = "snake_case")]
65#[non_exhaustive]
66pub enum PromotionStatus {
67    /// Draft - not yet active
68    #[default]
69    Draft,
70    /// Scheduled - will become active in future
71    Scheduled,
72    /// Active - currently running
73    Active,
74    /// Paused - temporarily disabled
75    Paused,
76    /// Expired - past end date
77    Expired,
78    /// Exhausted - usage limit reached
79    Exhausted,
80    /// Archived - permanently disabled
81    Archived,
82}
83
84/// How the promotion is triggered
85#[derive(
86    Debug, Clone, Copy, PartialEq, Eq, Display, EnumString, Serialize, Deserialize, Default,
87)]
88#[strum(serialize_all = "snake_case", ascii_case_insensitive)]
89#[serde(rename_all = "snake_case")]
90#[non_exhaustive]
91pub enum PromotionTrigger {
92    /// Automatically applied when conditions are met
93    #[default]
94    Automatic,
95    /// Requires a coupon code
96    #[strum(serialize = "coupon_code", serialize = "couponcode")]
97    CouponCode,
98    /// Both - can be auto or with code
99    Both,
100}
101
102/// What the promotion applies to
103#[derive(
104    Debug, Clone, Copy, PartialEq, Eq, Display, EnumString, Serialize, Deserialize, Default,
105)]
106#[strum(serialize_all = "snake_case", ascii_case_insensitive)]
107#[serde(rename_all = "snake_case")]
108#[non_exhaustive]
109pub enum PromotionTarget {
110    /// Applies to entire order
111    #[default]
112    Order,
113    /// Applies to specific products
114    Product,
115    /// Applies to product categories
116    Category,
117    /// Applies to shipping
118    Shipping,
119    /// Applies to specific line items
120    #[strum(serialize = "line_item", serialize = "lineitem")]
121    LineItem,
122}
123
124/// Stacking behavior with other promotions
125#[derive(
126    Debug, Clone, Copy, PartialEq, Eq, Display, EnumString, Serialize, Deserialize, Default,
127)]
128#[strum(serialize_all = "snake_case", ascii_case_insensitive)]
129#[serde(rename_all = "snake_case")]
130#[non_exhaustive]
131pub enum StackingBehavior {
132    /// Can be combined with other promotions
133    #[default]
134    Stackable,
135    /// Cannot be combined with any other promotion
136    Exclusive,
137    /// Can only stack with specific promotions
138    #[strum(serialize = "selective_stack", serialize = "selectivestack")]
139    SelectiveStack,
140}
141
142/// Condition operator for rules
143#[derive(
144    Debug, Clone, Copy, PartialEq, Eq, Display, EnumString, Serialize, Deserialize, Default,
145)]
146#[strum(serialize_all = "snake_case", ascii_case_insensitive)]
147#[serde(rename_all = "snake_case")]
148#[non_exhaustive]
149pub enum ConditionOperator {
150    #[default]
151    Equals,
152    #[strum(serialize = "not_equals", serialize = "notequals")]
153    NotEquals,
154    #[strum(serialize = "greater_than", serialize = "greaterthan")]
155    GreaterThan,
156    #[strum(serialize = "greater_than_or_equal", serialize = "greaterthanorequal")]
157    GreaterThanOrEqual,
158    #[strum(serialize = "less_than", serialize = "lessthan")]
159    LessThan,
160    #[strum(serialize = "less_than_or_equal", serialize = "lessthanorequal")]
161    LessThanOrEqual,
162    Contains,
163    #[strum(serialize = "not_contains", serialize = "notcontains")]
164    NotContains,
165    In,
166    #[strum(serialize = "not_in", serialize = "notin")]
167    NotIn,
168}
169
170/// Type of condition
171#[derive(
172    Debug, Clone, Copy, PartialEq, Eq, Display, EnumString, Serialize, Deserialize, Default,
173)]
174#[strum(serialize_all = "snake_case", ascii_case_insensitive)]
175#[serde(rename_all = "snake_case")]
176#[non_exhaustive]
177pub enum ConditionType {
178    /// Minimum cart subtotal
179    #[default]
180    #[strum(serialize = "minimum_subtotal", serialize = "minimumsubtotal")]
181    MinimumSubtotal,
182    /// Minimum quantity of items
183    #[strum(serialize = "minimum_quantity", serialize = "minimumquantity")]
184    MinimumQuantity,
185    /// Specific products in cart
186    #[strum(serialize = "product_in_cart", serialize = "productincart")]
187    ProductInCart,
188    /// Specific categories in cart
189    #[strum(serialize = "category_in_cart", serialize = "categoryincart")]
190    CategoryInCart,
191    /// Specific SKUs in cart
192    #[strum(serialize = "sku_in_cart", serialize = "skuincart")]
193    SkuInCart,
194    /// Customer is in specific group
195    #[strum(serialize = "customer_group", serialize = "customergroup")]
196    CustomerGroup,
197    /// Customer's first order
198    #[strum(serialize = "first_order", serialize = "firstorder")]
199    FirstOrder,
200    /// Customer email domain
201    #[strum(serialize = "customer_email_domain", serialize = "customeremaildomain")]
202    CustomerEmailDomain,
203    /// Shipping destination country
204    #[strum(serialize = "shipping_country", serialize = "shippingcountry")]
205    ShippingCountry,
206    /// Shipping destination state
207    #[strum(serialize = "shipping_state", serialize = "shippingstate")]
208    ShippingState,
209    /// Payment method
210    #[strum(serialize = "payment_method", serialize = "paymentmethod")]
211    PaymentMethod,
212    /// Cart item count
213    #[strum(serialize = "cart_item_count", serialize = "cartitemcount")]
214    CartItemCount,
215    /// Specific customer IDs
216    #[strum(serialize = "customer_id", serialize = "customerid")]
217    CustomerId,
218}
219
220// ============================================================================
221// Main Promotion Model
222// ============================================================================
223
224/// A promotion/discount offer
225#[derive(Debug, Clone, Serialize, Deserialize)]
226pub struct Promotion {
227    pub id: PromotionId,
228    /// Unique code for the promotion (for internal reference)
229    pub code: String,
230    /// Display name
231    pub name: String,
232    /// Description for customers
233    pub description: Option<String>,
234    /// Internal notes
235    pub internal_notes: Option<String>,
236
237    // Type and behavior
238    pub promotion_type: PromotionType,
239    pub trigger: PromotionTrigger,
240    pub target: PromotionTarget,
241    pub stacking: StackingBehavior,
242    pub status: PromotionStatus,
243
244    // Discount values
245    /// Percentage off (0.0-1.0, e.g., 0.20 for 20%)
246    pub percentage_off: Option<Decimal>,
247    /// Fixed amount off
248    pub fixed_amount_off: Option<Decimal>,
249    /// Maximum discount amount (cap)
250    pub max_discount_amount: Option<Decimal>,
251
252    // Buy X Get Y specifics
253    /// Quantity to buy
254    pub buy_quantity: Option<i32>,
255    /// Quantity to get free/discounted
256    pub get_quantity: Option<i32>,
257    /// Discount on the "get" items (1.0 = free, 0.5 = 50% off)
258    pub get_discount_percent: Option<Decimal>,
259
260    // Tiered discount specifics
261    /// Tiered discount rules (JSON array of tiers)
262    pub tiers: Option<Vec<DiscountTier>>,
263
264    // Bundle specifics
265    /// Required product IDs for bundle
266    pub bundle_product_ids: Option<Vec<ProductId>>,
267    /// Bundle discount when all products purchased
268    pub bundle_discount: Option<Decimal>,
269
270    // Validity period
271    pub starts_at: DateTime<Utc>,
272    pub ends_at: Option<DateTime<Utc>>,
273
274    // Usage limits
275    /// Total uses allowed (None = unlimited)
276    pub total_usage_limit: Option<i32>,
277    /// Uses per customer (None = unlimited)
278    pub per_customer_limit: Option<i32>,
279    /// Current usage count
280    pub usage_count: i32,
281
282    // Conditions
283    pub conditions: Vec<PromotionCondition>,
284
285    // Targeting
286    /// Specific product IDs this applies to (empty = all)
287    pub applicable_product_ids: Vec<ProductId>,
288    /// Specific category IDs this applies to (empty = all)
289    pub applicable_category_ids: Vec<Uuid>,
290    /// Specific SKUs this applies to (empty = all)
291    pub applicable_skus: Vec<String>,
292    /// Excluded product IDs
293    pub excluded_product_ids: Vec<ProductId>,
294    /// Excluded category IDs
295    pub excluded_category_ids: Vec<Uuid>,
296
297    // Customer targeting
298    /// Specific customer IDs (empty = all customers)
299    pub eligible_customer_ids: Vec<CustomerId>,
300    /// Customer groups/segments
301    pub eligible_customer_groups: Vec<String>,
302
303    // Currency
304    pub currency: CurrencyCode,
305
306    // Priority (lower = applied first)
307    pub priority: i32,
308
309    // Metadata
310    pub metadata: Option<serde_json::Value>,
311    pub created_at: DateTime<Utc>,
312    pub updated_at: DateTime<Utc>,
313}
314
315/// A tiered discount level
316#[derive(Debug, Clone, Serialize, Deserialize)]
317pub struct DiscountTier {
318    /// Minimum value to qualify (subtotal or quantity)
319    pub min_value: Decimal,
320    /// Maximum value for this tier (optional)
321    pub max_value: Option<Decimal>,
322    /// Percentage discount at this tier
323    pub percentage_off: Option<Decimal>,
324    /// Fixed amount off at this tier
325    pub fixed_amount_off: Option<Decimal>,
326}
327
328/// A condition that must be met for promotion to apply
329#[derive(Debug, Clone, Serialize, Deserialize)]
330pub struct PromotionCondition {
331    pub id: Uuid,
332    pub promotion_id: PromotionId,
333    pub condition_type: ConditionType,
334    pub operator: ConditionOperator,
335    /// The value to compare against (string, number, or JSON array)
336    pub value: String,
337    /// Whether all conditions must be met (AND) or any (OR)
338    pub is_required: bool,
339}
340
341// ============================================================================
342// Coupon Code Model
343// ============================================================================
344
345/// A coupon code that activates a promotion
346#[derive(Debug, Clone, Serialize, Deserialize)]
347pub struct CouponCode {
348    pub id: Uuid,
349    pub promotion_id: PromotionId,
350    /// The code customers enter (e.g., "SAVE20")
351    pub code: String,
352    pub status: CouponStatus,
353
354    // Override limits (if different from promotion)
355    pub usage_limit: Option<i32>,
356    pub per_customer_limit: Option<i32>,
357    pub usage_count: i32,
358
359    // Validity
360    pub starts_at: Option<DateTime<Utc>>,
361    pub ends_at: Option<DateTime<Utc>>,
362
363    // Metadata
364    pub metadata: Option<serde_json::Value>,
365    pub created_at: DateTime<Utc>,
366    pub updated_at: DateTime<Utc>,
367}
368
369/// Status of an individual coupon code
370#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
371#[serde(rename_all = "snake_case")]
372#[non_exhaustive]
373pub enum CouponStatus {
374    #[default]
375    Active,
376    Disabled,
377    Exhausted,
378    Expired,
379}
380
381impl std::fmt::Display for CouponStatus {
382    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
383        match self {
384            Self::Active => write!(f, "active"),
385            Self::Disabled => write!(f, "disabled"),
386            Self::Exhausted => write!(f, "exhausted"),
387            Self::Expired => write!(f, "expired"),
388        }
389    }
390}
391
392impl std::str::FromStr for CouponStatus {
393    type Err = String;
394
395    fn from_str(s: &str) -> Result<Self, Self::Err> {
396        match s.trim().to_ascii_lowercase().as_str() {
397            "active" => Ok(Self::Active),
398            "disabled" => Ok(Self::Disabled),
399            "exhausted" => Ok(Self::Exhausted),
400            "expired" => Ok(Self::Expired),
401            _ => Err(format!("Unknown coupon status: {s}")),
402        }
403    }
404}
405
406// ============================================================================
407// Promotion Usage Tracking
408// ============================================================================
409
410/// Record of promotion usage
411#[derive(Debug, Clone, Serialize, Deserialize)]
412pub struct PromotionUsage {
413    pub id: Uuid,
414    pub promotion_id: PromotionId,
415    pub coupon_id: Option<Uuid>,
416    pub customer_id: Option<CustomerId>,
417    pub order_id: Option<OrderId>,
418    pub cart_id: Option<CartId>,
419
420    /// Discount amount applied
421    pub discount_amount: Decimal,
422    pub currency: CurrencyCode,
423
424    pub used_at: DateTime<Utc>,
425}
426
427// ============================================================================
428// Cart/Order Integration
429// ============================================================================
430
431/// Request to apply promotions to a cart
432#[derive(Debug, Clone, Serialize, Deserialize, Default)]
433pub struct ApplyPromotionsRequest {
434    pub cart_id: Option<CartId>,
435    pub customer_id: Option<CustomerId>,
436    pub coupon_codes: Vec<String>,
437    pub line_items: Vec<PromotionLineItem>,
438    pub subtotal: Decimal,
439    pub shipping_amount: Decimal,
440    pub shipping_country: Option<String>,
441    pub shipping_state: Option<String>,
442    pub currency: CurrencyCode,
443    pub is_first_order: bool,
444}
445
446/// Line item for promotion calculation
447#[derive(Debug, Clone, Serialize, Deserialize)]
448pub struct PromotionLineItem {
449    pub id: String,
450    pub product_id: Option<ProductId>,
451    pub variant_id: Option<Uuid>,
452    pub sku: Option<String>,
453    pub category_ids: Vec<Uuid>,
454    pub quantity: i32,
455    pub unit_price: Decimal,
456    pub line_total: Decimal,
457}
458
459/// Result of applying promotions
460#[derive(Debug, Clone, Serialize, Deserialize)]
461pub struct ApplyPromotionsResult {
462    /// Original subtotal
463    pub original_subtotal: Decimal,
464    /// Total discount amount
465    pub total_discount: Decimal,
466    /// Discounted subtotal
467    pub discounted_subtotal: Decimal,
468    /// Original shipping
469    pub original_shipping: Decimal,
470    /// Shipping discount
471    pub shipping_discount: Decimal,
472    /// Final shipping
473    pub final_shipping: Decimal,
474    /// Grand total after discounts
475    pub grand_total: Decimal,
476    /// Applied promotions
477    pub applied_promotions: Vec<AppliedPromotion>,
478    /// Rejected promotions (with reasons)
479    pub rejected_promotions: Vec<RejectedPromotion>,
480    /// Per-line-item discounts
481    pub line_item_discounts: Vec<LineItemDiscount>,
482}
483
484/// A promotion that was successfully applied
485#[derive(Debug, Clone, Serialize, Deserialize)]
486pub struct AppliedPromotion {
487    pub promotion_id: PromotionId,
488    pub promotion_code: String,
489    pub promotion_name: String,
490    pub coupon_code: Option<String>,
491    pub discount_amount: Decimal,
492    pub discount_type: PromotionType,
493    pub target: PromotionTarget,
494    /// Human-readable description of discount
495    pub description: String,
496}
497
498/// A promotion that could not be applied
499#[derive(Debug, Clone, Serialize, Deserialize)]
500pub struct RejectedPromotion {
501    pub promotion_id: Option<PromotionId>,
502    pub coupon_code: Option<String>,
503    pub reason: String,
504    pub reason_code: RejectionReason,
505}
506
507/// Reason a promotion or coupon was rejected during evaluation
508#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
509#[serde(rename_all = "snake_case")]
510#[non_exhaustive]
511pub enum RejectionReason {
512    InvalidCode,
513    Expired,
514    NotYetActive,
515    UsageLimitReached,
516    CustomerLimitReached,
517    MinimumNotMet,
518    ProductNotEligible,
519    CustomerNotEligible,
520    NotStackable,
521    AlreadyApplied,
522    InternalError,
523}
524
525/// Discount applied to a specific line item
526#[derive(Debug, Clone, Serialize, Deserialize)]
527pub struct LineItemDiscount {
528    pub line_item_id: String,
529    pub promotion_id: PromotionId,
530    pub original_price: Decimal,
531    pub discount_amount: Decimal,
532    pub final_price: Decimal,
533}
534
535// ============================================================================
536// CRUD DTOs
537// ============================================================================
538
539/// Create a new promotion
540#[derive(Debug, Clone, Serialize, Deserialize, Default)]
541pub struct CreatePromotion {
542    pub code: Option<String>,
543    pub name: String,
544    pub description: Option<String>,
545    pub internal_notes: Option<String>,
546
547    pub promotion_type: PromotionType,
548    pub trigger: PromotionTrigger,
549    pub target: PromotionTarget,
550    pub stacking: StackingBehavior,
551
552    // Discount values
553    pub percentage_off: Option<Decimal>,
554    pub fixed_amount_off: Option<Decimal>,
555    pub max_discount_amount: Option<Decimal>,
556
557    // Buy X Get Y
558    pub buy_quantity: Option<i32>,
559    pub get_quantity: Option<i32>,
560    pub get_discount_percent: Option<Decimal>,
561
562    // Tiers
563    pub tiers: Option<Vec<DiscountTier>>,
564
565    // Bundle
566    pub bundle_product_ids: Option<Vec<ProductId>>,
567    pub bundle_discount: Option<Decimal>,
568
569    // Validity
570    pub starts_at: Option<DateTime<Utc>>,
571    pub ends_at: Option<DateTime<Utc>>,
572
573    // Limits
574    pub total_usage_limit: Option<i32>,
575    pub per_customer_limit: Option<i32>,
576
577    // Conditions
578    pub conditions: Option<Vec<CreatePromotionCondition>>,
579
580    // Targeting
581    pub applicable_product_ids: Option<Vec<ProductId>>,
582    pub applicable_category_ids: Option<Vec<Uuid>>,
583    pub applicable_skus: Option<Vec<String>>,
584    pub excluded_product_ids: Option<Vec<ProductId>>,
585    pub excluded_category_ids: Option<Vec<Uuid>>,
586
587    // Customer targeting
588    pub eligible_customer_ids: Option<Vec<CustomerId>>,
589    pub eligible_customer_groups: Option<Vec<String>>,
590
591    pub currency: Option<CurrencyCode>,
592    pub priority: Option<i32>,
593    pub metadata: Option<serde_json::Value>,
594}
595
596/// Condition input used when creating a promotion
597#[derive(Debug, Clone, Serialize, Deserialize)]
598pub struct CreatePromotionCondition {
599    pub condition_type: ConditionType,
600    pub operator: ConditionOperator,
601    pub value: String,
602    pub is_required: bool,
603}
604
605/// Update a promotion
606#[derive(Debug, Clone, Serialize, Deserialize, Default)]
607pub struct UpdatePromotion {
608    pub name: Option<String>,
609    pub description: Option<String>,
610    pub internal_notes: Option<String>,
611    pub status: Option<PromotionStatus>,
612
613    pub percentage_off: Option<Decimal>,
614    pub fixed_amount_off: Option<Decimal>,
615    pub max_discount_amount: Option<Decimal>,
616
617    pub starts_at: Option<DateTime<Utc>>,
618    pub ends_at: Option<DateTime<Utc>>,
619
620    pub total_usage_limit: Option<i32>,
621    pub per_customer_limit: Option<i32>,
622
623    pub priority: Option<i32>,
624    pub metadata: Option<serde_json::Value>,
625}
626
627/// Create a coupon code
628#[derive(Debug, Clone, Serialize, Deserialize)]
629pub struct CreateCouponCode {
630    pub promotion_id: PromotionId,
631    pub code: String,
632    pub usage_limit: Option<i32>,
633    pub per_customer_limit: Option<i32>,
634    pub starts_at: Option<DateTime<Utc>>,
635    pub ends_at: Option<DateTime<Utc>>,
636    pub metadata: Option<serde_json::Value>,
637}
638
639/// Filter for listing promotions
640#[derive(Debug, Clone, Serialize, Deserialize, Default)]
641pub struct PromotionFilter {
642    pub status: Option<PromotionStatus>,
643    pub promotion_type: Option<PromotionType>,
644    pub trigger: Option<PromotionTrigger>,
645    pub is_active: Option<bool>,
646    pub search: Option<String>,
647    pub limit: Option<u32>,
648    pub offset: Option<u32>,
649}
650
651/// Filter for listing coupon codes
652#[derive(Debug, Clone, Serialize, Deserialize, Default)]
653pub struct CouponFilter {
654    pub promotion_id: Option<PromotionId>,
655    pub status: Option<CouponStatus>,
656    pub search: Option<String>,
657    pub limit: Option<u32>,
658    pub offset: Option<u32>,
659}
660
661// ============================================================================
662// Helper Functions
663// ============================================================================
664
665/// Generate a unique promotion code
666#[must_use]
667pub fn generate_promotion_code() -> String {
668    let id = Uuid::new_v4();
669    let bytes = id.as_bytes();
670    let random = u32::from_be_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]) % 10000;
671    let timestamp = chrono::Utc::now().timestamp_millis();
672    format!("PROMO-{}-{:04}", timestamp % 1000000, random)
673}
674
675/// Generate a unique coupon code (human-friendly)
676#[must_use]
677pub fn generate_coupon_code(prefix: Option<&str>) -> String {
678    let chars: Vec<char> = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789".chars().collect();
679    let id = Uuid::new_v4();
680    let bytes = id.as_bytes();
681
682    let code: String = bytes[0..8]
683        .iter()
684        .map(|b| {
685            let idx = (*b as usize) % chars.len();
686            chars[idx]
687        })
688        .collect();
689
690    match prefix {
691        Some(p) => format!("{}{}", p.to_uppercase(), code),
692        None => code,
693    }
694}
695
696impl Promotion {
697    /// Whether this promotion restricts which products it applies to
698    /// (any applicability or exclusion list is non-empty).
699    #[must_use]
700    pub fn has_product_scoping(&self) -> bool {
701        !self.applicable_product_ids.is_empty()
702            || !self.applicable_category_ids.is_empty()
703            || !self.applicable_skus.is_empty()
704            || !self.excluded_product_ids.is_empty()
705            || !self.excluded_category_ids.is_empty()
706    }
707
708    /// Whether a line item is in scope for this promotion's product lists.
709    ///
710    /// Exclusions win over applicability. When any applicability list is set,
711    /// the item must match at least one of them; otherwise every
712    /// non-excluded item is in scope.
713    #[must_use]
714    pub fn item_in_scope(&self, item: &PromotionLineItem) -> bool {
715        if item.product_id.is_some_and(|p| self.excluded_product_ids.contains(&p)) {
716            return false;
717        }
718        if item.category_ids.iter().any(|c| self.excluded_category_ids.contains(c)) {
719            return false;
720        }
721
722        let has_applicability = !self.applicable_product_ids.is_empty()
723            || !self.applicable_category_ids.is_empty()
724            || !self.applicable_skus.is_empty();
725        if has_applicability {
726            let by_product =
727                item.product_id.is_some_and(|p| self.applicable_product_ids.contains(&p));
728            let by_category =
729                item.category_ids.iter().any(|c| self.applicable_category_ids.contains(c));
730            let by_sku =
731                item.sku.as_deref().is_some_and(|s| self.applicable_skus.iter().any(|a| a == s));
732            return by_product || by_category || by_sku;
733        }
734
735        true
736    }
737
738    /// Check if promotion is currently active
739    #[must_use]
740    pub fn is_active(&self) -> bool {
741        if self.status != PromotionStatus::Active {
742            return false;
743        }
744
745        let now = Utc::now();
746        if now < self.starts_at {
747            return false;
748        }
749
750        if let Some(ends_at) = self.ends_at {
751            if now > ends_at {
752                return false;
753            }
754        }
755
756        // Check usage limits
757        if let Some(limit) = self.total_usage_limit {
758            if self.usage_count >= limit {
759                return false;
760            }
761        }
762
763        true
764    }
765
766    /// Get human-readable discount description
767    #[must_use]
768    pub fn discount_description(&self) -> String {
769        match self.promotion_type {
770            PromotionType::PercentageOff => {
771                if let Some(pct) = self.percentage_off {
772                    format!("{}% off", (pct * Decimal::from(100)).round())
773                } else {
774                    "Percentage discount".to_string()
775                }
776            }
777            PromotionType::FixedAmountOff => {
778                if let Some(amt) = self.fixed_amount_off {
779                    format!("${amt} off")
780                } else {
781                    "Fixed discount".to_string()
782                }
783            }
784            PromotionType::BuyXGetY => {
785                let buy = self.buy_quantity.unwrap_or(1);
786                let get = self.get_quantity.unwrap_or(1);
787                let discount = self.get_discount_percent.unwrap_or(Decimal::ONE);
788                if discount == Decimal::ONE {
789                    format!("Buy {buy} get {get} free")
790                } else {
791                    format!(
792                        "Buy {} get {} at {}% off",
793                        buy,
794                        get,
795                        (discount * Decimal::from(100)).round()
796                    )
797                }
798            }
799            PromotionType::FreeShipping => "Free shipping".to_string(),
800            PromotionType::TieredDiscount => "Tiered discount".to_string(),
801            PromotionType::BundleDiscount => "Bundle discount".to_string(),
802            PromotionType::FirstOrderDiscount => {
803                if let Some(pct) = self.percentage_off {
804                    format!("{}% off first order", (pct * Decimal::from(100)).round())
805                } else if let Some(amt) = self.fixed_amount_off {
806                    format!("${amt} off first order")
807                } else {
808                    "First order discount".to_string()
809                }
810            }
811            PromotionType::GiftWithPurchase => "Gift with purchase".to_string(),
812        }
813    }
814}
815
816impl Default for ApplyPromotionsResult {
817    fn default() -> Self {
818        Self {
819            original_subtotal: Decimal::ZERO,
820            total_discount: Decimal::ZERO,
821            discounted_subtotal: Decimal::ZERO,
822            original_shipping: Decimal::ZERO,
823            shipping_discount: Decimal::ZERO,
824            final_shipping: Decimal::ZERO,
825            grand_total: Decimal::ZERO,
826            applied_promotions: Vec::new(),
827            rejected_promotions: Vec::new(),
828            line_item_discounts: Vec::new(),
829        }
830    }
831}
832
833#[cfg(test)]
834mod tests {
835    use super::*;
836    use std::str::FromStr;
837
838    #[test]
839    fn test_promotion_type_from_str() {
840        assert_eq!(
841            PromotionType::from_str("percentage_off").unwrap(),
842            PromotionType::PercentageOff
843        );
844        assert_eq!(PromotionType::from_str("buyxgety").unwrap(), PromotionType::BuyXGetY);
845    }
846
847    #[test]
848    fn test_promotion_trigger_from_str() {
849        assert_eq!(
850            PromotionTrigger::from_str("coupon_code").unwrap(),
851            PromotionTrigger::CouponCode
852        );
853        assert_eq!(PromotionTrigger::from_str("couponcode").unwrap(), PromotionTrigger::CouponCode);
854    }
855
856    #[test]
857    fn test_condition_operator_from_str() {
858        assert_eq!(
859            ConditionOperator::from_str("greater_than_or_equal").unwrap(),
860            ConditionOperator::GreaterThanOrEqual
861        );
862        assert_eq!(
863            ConditionOperator::from_str("greaterthanorequal").unwrap(),
864            ConditionOperator::GreaterThanOrEqual
865        );
866    }
867
868    #[test]
869    fn test_condition_type_from_str() {
870        assert_eq!(
871            ConditionType::from_str("minimum_subtotal").unwrap(),
872            ConditionType::MinimumSubtotal
873        );
874        assert_eq!(
875            ConditionType::from_str("minimumsubtotal").unwrap(),
876            ConditionType::MinimumSubtotal
877        );
878    }
879
880    #[test]
881    fn test_coupon_status_from_str() {
882        assert_eq!(CouponStatus::from_str("active").unwrap(), CouponStatus::Active);
883        assert_eq!(CouponStatus::from_str("exhausted").unwrap(), CouponStatus::Exhausted);
884    }
885}