Skip to main content

stateset_embedded/
promotions.rs

1//! Promotions and discounts operations
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//! - Coupon codes
9//! - Automatic promotions
10//!
11//! # Example
12//!
13//! ```rust,ignore
14//! use stateset_embedded::{Commerce, CreatePromotion, PromotionType, PromotionTrigger};
15//! use rust_decimal_macros::dec;
16//!
17//! let commerce = Commerce::new("./store.db")?;
18//!
19//! // Create a 20% off promotion
20//! let promo = commerce.promotions().create(CreatePromotion {
21//!     name: "Summer Sale".into(),
22//!     promotion_type: PromotionType::PercentageOff,
23//!     percentage_off: Some(dec!(0.20)),
24//!     ..Default::default()
25//! })?;
26//!
27//! // Activate the promotion
28//! commerce.promotions().activate(promo.id)?;
29//!
30//! // Apply promotions to a cart
31//! let result = commerce.promotions().apply_to_cart(cart_id)?;
32//! println!("Discount: ${}", result.total_discount);
33//! # Ok::<(), stateset_embedded::CommerceError>(())
34//! ```
35
36use rust_decimal::Decimal;
37use stateset_core::{
38    ApplyPromotionsRequest, ApplyPromotionsResult, CartId, CouponCode, CouponFilter,
39    CreateCouponCode, CreatePromotion, CreatePromotionCondition, CustomerId, OrderId, Promotion,
40    PromotionFilter, PromotionId, PromotionUsage, Result, UpdatePromotion,
41};
42use stateset_db::Database;
43use std::sync::Arc;
44use uuid::Uuid;
45
46/// Promotions and discounts management interface.
47pub struct Promotions {
48    db: Arc<dyn Database>,
49}
50
51impl std::fmt::Debug for Promotions {
52    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
53        f.debug_struct("Promotions").finish_non_exhaustive()
54    }
55}
56
57impl Promotions {
58    pub(crate) fn new(db: Arc<dyn Database>) -> Self {
59        Self { db }
60    }
61
62    // ========================================================================
63    // Promotion CRUD
64    // ========================================================================
65
66    /// Create a new promotion.
67    ///
68    /// # Example
69    ///
70    /// ```rust,ignore
71    /// use stateset_embedded::{Commerce, CreatePromotion, PromotionType};
72    /// use rust_decimal_macros::dec;
73    ///
74    /// let commerce = Commerce::new(":memory:")?;
75    ///
76    /// // Create a percentage off promotion
77    /// let promo = commerce.promotions().create(CreatePromotion {
78    ///     name: "20% Off Everything".into(),
79    ///     promotion_type: PromotionType::PercentageOff,
80    ///     percentage_off: Some(dec!(0.20)),
81    ///     ..Default::default()
82    /// })?;
83    /// # Ok::<(), stateset_embedded::CommerceError>(())
84    /// ```
85    pub fn create(&self, input: CreatePromotion) -> Result<Promotion> {
86        self.db.promotions().create(input)
87    }
88
89    /// Get a promotion by ID.
90    pub fn get(&self, id: PromotionId) -> Result<Option<Promotion>> {
91        self.db.promotions().get(id)
92    }
93
94    /// Get a promotion by its internal code.
95    pub fn get_by_code(&self, code: &str) -> Result<Option<Promotion>> {
96        self.db.promotions().get_by_code(code)
97    }
98
99    /// List promotions with optional filtering.
100    ///
101    /// # Example
102    ///
103    /// ```rust,ignore
104    /// use stateset_embedded::{Commerce, PromotionFilter, PromotionStatus};
105    ///
106    /// let commerce = Commerce::new(":memory:")?;
107    ///
108    /// // List active promotions
109    /// let promos = commerce.promotions().list(PromotionFilter {
110    ///     is_active: Some(true),
111    ///     ..Default::default()
112    /// })?;
113    /// # Ok::<(), stateset_embedded::CommerceError>(())
114    /// ```
115    pub fn list(&self, filter: PromotionFilter) -> Result<Vec<Promotion>> {
116        self.db.promotions().list(filter)
117    }
118
119    /// Update a promotion.
120    pub fn update(&self, id: PromotionId, input: UpdatePromotion) -> Result<Promotion> {
121        self.db.promotions().update(id, input)
122    }
123
124    /// Delete a promotion.
125    pub fn delete(&self, id: PromotionId) -> Result<()> {
126        self.db.promotions().delete(id)
127    }
128
129    /// Activate a promotion (make it available for use).
130    ///
131    /// # Example
132    ///
133    /// ```rust,ignore
134    /// use stateset_embedded::Commerce;
135    /// use uuid::Uuid;
136    ///
137    /// let commerce = Commerce::new(":memory:")?;
138    /// commerce.promotions().activate(Uuid::new_v4())?;
139    /// # Ok::<(), stateset_embedded::CommerceError>(())
140    /// ```
141    pub fn activate(&self, id: PromotionId) -> Result<Promotion> {
142        self.db.promotions().activate(id)
143    }
144
145    /// Deactivate (pause) a promotion.
146    pub fn deactivate(&self, id: PromotionId) -> Result<Promotion> {
147        self.db.promotions().deactivate(id)
148    }
149
150    // ========================================================================
151    // Coupon Codes
152    // ========================================================================
153
154    /// Create a coupon code for a promotion.
155    ///
156    /// # Example
157    ///
158    /// ```rust,ignore
159    /// use stateset_embedded::{Commerce, CreateCouponCode};
160    /// use uuid::Uuid;
161    ///
162    /// let commerce = Commerce::new(":memory:")?;
163    ///
164    /// let coupon = commerce.promotions().create_coupon(CreateCouponCode {
165    ///     promotion_id: Uuid::new_v4(),
166    ///     code: "SUMMER25".into(),
167    ///     usage_limit: Some(100),
168    ///     ..Default::default()
169    /// })?;
170    /// # Ok::<(), stateset_embedded::CommerceError>(())
171    /// ```
172    pub fn create_coupon(&self, input: CreateCouponCode) -> Result<CouponCode> {
173        self.db.promotions().create_coupon(input)
174    }
175
176    /// Get a coupon by ID.
177    pub fn get_coupon(&self, id: Uuid) -> Result<Option<CouponCode>> {
178        self.db.promotions().get_coupon(id)
179    }
180
181    /// Get a coupon by its code (the code customers enter).
182    pub fn get_coupon_by_code(&self, code: &str) -> Result<Option<CouponCode>> {
183        self.db.promotions().get_coupon_by_code(code)
184    }
185
186    /// List coupons with optional filtering.
187    pub fn list_coupons(&self, filter: CouponFilter) -> Result<Vec<CouponCode>> {
188        self.db.promotions().list_coupons(filter)
189    }
190
191    /// Validate a coupon code (check if it's valid and can be used).
192    ///
193    /// # Example
194    ///
195    /// ```rust,ignore
196    /// use stateset_embedded::Commerce;
197    ///
198    /// let commerce = Commerce::new(":memory:")?;
199    ///
200    /// match commerce.promotions().validate_coupon("SUMMER25")? {
201    ///     Some(coupon) => println!("Valid coupon for promotion: {:?}", coupon.promotion_id),
202    ///     None => println!("Invalid or expired coupon"),
203    /// }
204    /// # Ok::<(), stateset_embedded::CommerceError>(())
205    /// ```
206    pub fn validate_coupon(&self, code: &str) -> Result<Option<CouponCode>> {
207        let coupon = self.db.promotions().get_coupon_by_code(code)?;
208
209        if let Some(c) = coupon {
210            // Check if coupon is active
211            if c.status != stateset_core::CouponStatus::Active {
212                return Ok(None);
213            }
214
215            // Check usage limits
216            if let Some(limit) = c.usage_limit {
217                if c.usage_count >= limit {
218                    return Ok(None);
219                }
220            }
221
222            // Check dates
223            let now = chrono::Utc::now();
224            if let Some(starts) = c.starts_at {
225                if now < starts {
226                    return Ok(None);
227                }
228            }
229            if let Some(ends) = c.ends_at {
230                if now > ends {
231                    return Ok(None);
232                }
233            }
234
235            Ok(Some(c))
236        } else {
237            Ok(None)
238        }
239    }
240
241    // ========================================================================
242    // Apply Promotions
243    // ========================================================================
244
245    /// Apply promotions to a request (cart or order).
246    ///
247    /// This is the main entry point for promotion calculation. It:
248    /// 1. Finds all applicable automatic promotions
249    /// 2. Validates any coupon codes provided
250    /// 3. Checks all promotion conditions
251    /// 4. Calculates discounts respecting stacking rules
252    /// 5. Returns detailed breakdown of applied discounts
253    ///
254    /// # Example
255    ///
256    /// ```rust,ignore
257    /// use stateset_embedded::{Commerce, ApplyPromotionsRequest, PromotionLineItem};
258    /// use rust_decimal_macros::dec;
259    ///
260    /// let commerce = Commerce::new(":memory:")?;
261    ///
262    /// let result = commerce.promotions().apply(ApplyPromotionsRequest {
263    ///     subtotal: dec!(150.00),
264    ///     shipping_amount: dec!(10.00),
265    ///     coupon_codes: vec!["SUMMER25".into()],
266    ///     line_items: vec![PromotionLineItem {
267    ///         id: "item-1".into(),
268    ///         quantity: 2,
269    ///         unit_price: dec!(75.00),
270    ///         line_total: dec!(150.00),
271    ///         ..Default::default()
272    ///     }],
273    ///     ..Default::default()
274    /// })?;
275    ///
276    /// println!("Total discount: ${}", result.total_discount);
277    /// println!("Final total: ${}", result.grand_total);
278    /// # Ok::<(), stateset_embedded::CommerceError>(())
279    /// ```
280    pub fn apply(&self, request: ApplyPromotionsRequest) -> Result<ApplyPromotionsResult> {
281        self.db.promotions().apply_promotions(request)
282    }
283
284    /// Record promotion usage (called after order completion).
285    ///
286    /// This increments usage counts and creates an audit trail.
287    #[allow(clippy::too_many_arguments)]
288    pub fn record_usage(
289        &self,
290        promotion_id: PromotionId,
291        coupon_id: Option<Uuid>,
292        customer_id: Option<CustomerId>,
293        order_id: Option<OrderId>,
294        cart_id: Option<CartId>,
295        discount_amount: Decimal,
296        currency: &str,
297    ) -> Result<PromotionUsage> {
298        self.db.promotions().record_usage(
299            promotion_id,
300            coupon_id,
301            customer_id,
302            order_id,
303            cart_id,
304            discount_amount,
305            currency,
306        )
307    }
308
309    // ========================================================================
310    // Convenience Methods
311    // ========================================================================
312
313    /// Get all active promotions.
314    pub fn get_active(&self) -> Result<Vec<Promotion>> {
315        self.list(PromotionFilter { is_active: Some(true), ..Default::default() })
316    }
317
318    /// Check if a promotion is currently valid.
319    pub fn is_valid(&self, id: PromotionId) -> Result<bool> {
320        if let Some(promo) = self.get(id)? { Ok(promo.is_active()) } else { Ok(false) }
321    }
322
323    /// Add a condition to an existing promotion.
324    pub fn add_condition(
325        &self,
326        promotion_id: PromotionId,
327        condition: CreatePromotionCondition,
328    ) -> Result<Promotion> {
329        // Get current promotion
330        let promo = self.get(promotion_id)?.ok_or(stateset_core::CommerceError::NotFound)?;
331
332        // Re-create with new condition
333        // Note: In a production system, you'd want a separate conditions API
334        // For now, this is a simplified approach
335        let mut conditions = promo.conditions.clone();
336        conditions.push(stateset_core::PromotionCondition {
337            id: Uuid::new_v4(),
338            promotion_id,
339            condition_type: condition.condition_type,
340            operator: condition.operator,
341            value: condition.value,
342            is_required: condition.is_required,
343        });
344
345        Ok(promo)
346    }
347}