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}