Skip to main content

stateset_db/sqlite/
promotions.rs

1//! SQLite repository for promotions and coupons
2
3use chrono::Utc;
4use r2d2::Pool;
5use r2d2_sqlite::SqliteConnectionManager;
6use rusqlite::OptionalExtension;
7use rust_decimal::Decimal;
8use stateset_core::{
9    AppliedPromotion, ApplyPromotionsRequest, ApplyPromotionsResult, CartId, CommerceError,
10    ConditionOperator, ConditionType, CouponCode, CouponFilter, CouponStatus, CreateCouponCode,
11    CreatePromotion, CreatePromotionCondition, CurrencyCode, CustomerId, DiscountTier, OrderId,
12    Promotion, PromotionCondition, PromotionFilter, PromotionId, PromotionRepository,
13    PromotionStatus, PromotionTarget, PromotionTrigger, PromotionType, PromotionUsage,
14    RejectedPromotion, RejectionReason, Result, StackingBehavior, UpdatePromotion,
15    generate_promotion_code,
16};
17use uuid::Uuid;
18
19use super::{
20    parse_datetime_opt_row, parse_datetime_row, parse_decimal_opt_row, parse_enum_row,
21    parse_json_opt_row, parse_uuid_row, with_immediate_transaction,
22};
23
24#[derive(Debug)]
25pub struct SqlitePromotionRepository {
26    pool: Pool<SqliteConnectionManager>,
27}
28
29impl SqlitePromotionRepository {
30    #[must_use]
31    pub const fn new(pool: Pool<SqliteConnectionManager>) -> Self {
32        Self { pool }
33    }
34
35    // ========================================================================
36    // Promotion CRUD
37    // ========================================================================
38
39    pub fn create(&self, input: CreatePromotion) -> Result<Promotion> {
40        let conditions = input.conditions.clone();
41
42        let conn = self.pool.get().map_err(|e| {
43            stateset_core::CommerceError::DatabaseError(format!("Connection error: {e}"))
44        })?;
45
46        let id = PromotionId::new();
47        let code = input.code.unwrap_or_else(generate_promotion_code);
48        let now = Utc::now();
49        let starts_at = input.starts_at.unwrap_or(now);
50
51        conn.execute(
52            "INSERT INTO promotions (
53                id, code, name, description, internal_notes,
54                promotion_type, trigger, target, stacking, status,
55                percentage_off, fixed_amount_off, max_discount_amount,
56                buy_quantity, get_quantity, get_discount_percent,
57                tiers, bundle_product_ids, bundle_discount,
58                starts_at, ends_at,
59                total_usage_limit, per_customer_limit, usage_count,
60                applicable_product_ids, applicable_category_ids, applicable_skus,
61                excluded_product_ids, excluded_category_ids,
62                eligible_customer_ids, eligible_customer_groups,
63                currency, priority, metadata, created_at, updated_at
64            ) VALUES (
65                ?1, ?2, ?3, ?4, ?5,
66                ?6, ?7, ?8, ?9, ?10,
67                ?11, ?12, ?13,
68                ?14, ?15, ?16,
69                ?17, ?18, ?19,
70                ?20, ?21,
71                ?22, ?23, 0,
72                ?24, ?25, ?26,
73                ?27, ?28,
74                ?29, ?30,
75                ?31, ?32, ?33, ?34, ?35
76            )",
77            rusqlite::params![
78                id.to_string(),
79                code,
80                input.name,
81                input.description,
82                input.internal_notes,
83                input.promotion_type.to_string(),
84                input.trigger.to_string(),
85                input.target.to_string(),
86                input.stacking.to_string(),
87                "draft",
88                input.percentage_off.map(|d| d.to_string()),
89                input.fixed_amount_off.map(|d| d.to_string()),
90                input.max_discount_amount.map(|d| d.to_string()),
91                input.buy_quantity,
92                input.get_quantity,
93                input.get_discount_percent.map(|d| d.to_string()),
94                input.tiers.as_ref().map(|t| serde_json::to_string(t).unwrap_or_default()),
95                input
96                    .bundle_product_ids
97                    .as_ref()
98                    .map(|ids| serde_json::to_string(ids).unwrap_or_default()),
99                input.bundle_discount.map(|d| d.to_string()),
100                starts_at.to_rfc3339(),
101                input.ends_at.map(|d| d.to_rfc3339()),
102                input.total_usage_limit,
103                input.per_customer_limit,
104                serde_json::to_string(&input.applicable_product_ids.unwrap_or_default())
105                    .unwrap_or_default(),
106                serde_json::to_string(&input.applicable_category_ids.unwrap_or_default())
107                    .unwrap_or_default(),
108                serde_json::to_string(&input.applicable_skus.unwrap_or_default())
109                    .unwrap_or_default(),
110                serde_json::to_string(&input.excluded_product_ids.unwrap_or_default())
111                    .unwrap_or_default(),
112                serde_json::to_string(&input.excluded_category_ids.unwrap_or_default())
113                    .unwrap_or_default(),
114                serde_json::to_string(&input.eligible_customer_ids.unwrap_or_default())
115                    .unwrap_or_default(),
116                serde_json::to_string(&input.eligible_customer_groups.unwrap_or_default())
117                    .unwrap_or_default(),
118                input.currency.unwrap_or_default(),
119                input.priority.unwrap_or(0),
120                input.metadata.as_ref().map(|m| serde_json::to_string(m).unwrap_or_default()),
121                now.to_rfc3339(),
122                now.to_rfc3339(),
123            ],
124        )
125        .map_err(|e| stateset_core::CommerceError::DatabaseError(format!("Insert error: {e}")))?;
126
127        // Create conditions inline using the same connection
128        if let Some(conditions) = conditions {
129            for cond in conditions {
130                let cond_id = Uuid::new_v4();
131                conn.execute(
132                    "INSERT INTO promotion_conditions (id, promotion_id, condition_type, operator, value, is_required)
133                     VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
134                    rusqlite::params![
135                        cond_id.to_string(),
136                        id.to_string(),
137                        cond.condition_type.to_string(),
138                        cond.operator.to_string(),
139                        cond.value,
140                        i32::from(cond.is_required),
141                    ],
142                ).map_err(|e| stateset_core::CommerceError::DatabaseError(format!("Insert condition error: {e}")))?;
143            }
144        }
145
146        // Drop the connection before calling get
147        drop(conn);
148
149        self.get(id)?.ok_or_else(|| {
150            stateset_core::CommerceError::DatabaseError(
151                "Failed to retrieve created promotion".into(),
152            )
153        })
154    }
155
156    pub fn get(&self, id: PromotionId) -> Result<Option<Promotion>> {
157        let conn = self.pool.get().map_err(|e| {
158            stateset_core::CommerceError::DatabaseError(format!("Connection error: {e}"))
159        })?;
160
161        // Scope the statement so we can safely reuse the same connection for follow-up queries
162        // (important when the pool size is 1).
163        let promotion = {
164            let mut stmt = conn
165                .prepare("SELECT * FROM promotions WHERE id = ?1")
166                .map_err(|e| stateset_core::CommerceError::DatabaseError(e.to_string()))?;
167
168            stmt.query_row([id.to_string()], |row| self.row_to_promotion(row))
169                .optional()
170                .map_err(|e| stateset_core::CommerceError::DatabaseError(e.to_string()))?
171        };
172
173        if let Some(mut promo) = promotion {
174            promo.conditions = self.get_conditions_with_conn(&conn, id)?;
175            Ok(Some(promo))
176        } else {
177            Ok(None)
178        }
179    }
180
181    pub fn get_by_code(&self, code: &str) -> Result<Option<Promotion>> {
182        let conn = self.pool.get().map_err(|e| {
183            stateset_core::CommerceError::DatabaseError(format!("Connection error: {e}"))
184        })?;
185
186        // Scope the statement so we can safely reuse the same connection for follow-up queries
187        // (important when the pool size is 1).
188        let promotion = {
189            let mut stmt = conn
190                .prepare("SELECT * FROM promotions WHERE code = ?1")
191                .map_err(|e| stateset_core::CommerceError::DatabaseError(e.to_string()))?;
192
193            stmt.query_row([code], |row| self.row_to_promotion(row))
194                .optional()
195                .map_err(|e| stateset_core::CommerceError::DatabaseError(e.to_string()))?
196        };
197
198        if let Some(mut promo) = promotion {
199            promo.conditions = self.get_conditions_with_conn(&conn, promo.id)?;
200            Ok(Some(promo))
201        } else {
202            Ok(None)
203        }
204    }
205
206    pub fn list(&self, filter: PromotionFilter) -> Result<Vec<Promotion>> {
207        let conn = self.pool.get().map_err(|e| {
208            stateset_core::CommerceError::DatabaseError(format!("Connection error: {e}"))
209        })?;
210
211        let mut sql = "SELECT * FROM promotions WHERE 1=1".to_string();
212        let mut params: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
213
214        if let Some(status) = &filter.status {
215            sql.push_str(" AND status = ?");
216            params.push(Box::new(status.to_string()));
217        }
218
219        if let Some(promo_type) = &filter.promotion_type {
220            sql.push_str(" AND promotion_type = ?");
221            params.push(Box::new(promo_type.to_string()));
222        }
223
224        if let Some(trigger) = &filter.trigger {
225            sql.push_str(" AND trigger = ?");
226            params.push(Box::new(trigger.to_string()));
227        }
228
229        if let Some(is_active) = filter.is_active {
230            if is_active {
231                // Use SQLite datetime() to normalize stored values. We store RFC3339 in most write paths,
232                // while some rows may use SQLite's default `datetime('now')` format.
233                sql.push_str(" AND status = 'active' AND datetime(starts_at) <= datetime('now') AND (ends_at IS NULL OR datetime(ends_at) >= datetime('now'))");
234            }
235        }
236
237        if let Some(search) = &filter.search {
238            sql.push_str(" AND (name LIKE ? OR code LIKE ? OR description LIKE ?)");
239            let pattern = format!("%{search}%");
240            params.push(Box::new(pattern.clone()));
241            params.push(Box::new(pattern.clone()));
242            params.push(Box::new(pattern));
243        }
244
245        sql.push_str(" ORDER BY priority ASC, created_at DESC");
246
247        crate::sqlite::append_limit_offset(&mut sql, filter.limit, filter.offset);
248
249        // Load promotions first, then load conditions using the same connection. This avoids
250        // nested pool checkouts (important for max_connections=1).
251        let mut promotions: Vec<Promotion> = {
252            let mut stmt = conn
253                .prepare(&sql)
254                .map_err(|e| stateset_core::CommerceError::DatabaseError(e.to_string()))?;
255
256            let param_refs: Vec<&dyn rusqlite::ToSql> =
257                params.iter().map(std::convert::AsRef::as_ref).collect();
258
259            let rows = stmt
260                .query_map(param_refs.as_slice(), |row| self.row_to_promotion(row))
261                .map_err(|e| stateset_core::CommerceError::DatabaseError(e.to_string()))?;
262
263            rows.collect::<std::result::Result<Vec<_>, _>>()
264                .map_err(|e| stateset_core::CommerceError::DatabaseError(e.to_string()))?
265        };
266
267        // Batch-load conditions for all listed promotions on the same connection.
268        let ids: Vec<PromotionId> = promotions.iter().map(|p| p.id).collect();
269        let mut conditions_by_id = Self::load_conditions_batch(&conn, &ids)?;
270        for promo in &mut promotions {
271            promo.conditions = conditions_by_id.remove(&promo.id).unwrap_or_default();
272        }
273
274        Ok(promotions)
275    }
276
277    pub fn update(&self, id: PromotionId, input: UpdatePromotion) -> Result<Promotion> {
278        // Scope the connection so we don't attempt to re-checkout from the pool while holding it.
279        {
280            let conn = self.pool.get().map_err(|e| {
281                stateset_core::CommerceError::DatabaseError(format!("Connection error: {e}"))
282            })?;
283
284            let now = Utc::now();
285
286            // Use a simple fixed update for common fields
287            conn.execute(
288                "UPDATE promotions SET
289                    name = COALESCE(?1, name),
290                    description = COALESCE(?2, description),
291                    internal_notes = COALESCE(?3, internal_notes),
292                    status = COALESCE(?4, status),
293                    percentage_off = COALESCE(?5, percentage_off),
294                    fixed_amount_off = COALESCE(?6, fixed_amount_off),
295                    max_discount_amount = COALESCE(?7, max_discount_amount),
296                    starts_at = COALESCE(?8, starts_at),
297                    ends_at = COALESCE(?9, ends_at),
298                    total_usage_limit = COALESCE(?10, total_usage_limit),
299                    per_customer_limit = COALESCE(?11, per_customer_limit),
300                    priority = COALESCE(?12, priority),
301                    updated_at = ?13
302                 WHERE id = ?14",
303                rusqlite::params![
304                    input.name,
305                    input.description,
306                    input.internal_notes,
307                    input.status.map(|s| s.to_string()),
308                    input.percentage_off.map(|d| d.to_string()),
309                    input.fixed_amount_off.map(|d| d.to_string()),
310                    input.max_discount_amount.map(|d| d.to_string()),
311                    input.starts_at.map(|d| d.to_rfc3339()),
312                    input.ends_at.map(|d| d.to_rfc3339()),
313                    input.total_usage_limit,
314                    input.per_customer_limit,
315                    input.priority,
316                    now.to_rfc3339(),
317                    id.to_string(),
318                ],
319            )
320            .map_err(|e| {
321                stateset_core::CommerceError::DatabaseError(format!("Update error: {e}"))
322            })?;
323        }
324
325        self.get(id)?.ok_or(stateset_core::CommerceError::NotFound)
326    }
327
328    pub fn delete(&self, id: PromotionId) -> Result<()> {
329        let conn = self.pool.get().map_err(|e| {
330            stateset_core::CommerceError::DatabaseError(format!("Connection error: {e}"))
331        })?;
332
333        conn.execute("DELETE FROM promotions WHERE id = ?1", [id.to_string()]).map_err(|e| {
334            stateset_core::CommerceError::DatabaseError(format!("Delete error: {e}"))
335        })?;
336
337        Ok(())
338    }
339
340    pub fn activate(&self, id: PromotionId) -> Result<Promotion> {
341        self.update(
342            id,
343            UpdatePromotion { status: Some(PromotionStatus::Active), ..Default::default() },
344        )
345    }
346
347    pub fn deactivate(&self, id: PromotionId) -> Result<Promotion> {
348        self.update(
349            id,
350            UpdatePromotion { status: Some(PromotionStatus::Paused), ..Default::default() },
351        )
352    }
353
354    // ========================================================================
355    // Conditions
356    // ========================================================================
357
358    #[allow(dead_code)]
359    fn create_condition(
360        &self,
361        promotion_id: PromotionId,
362        input: CreatePromotionCondition,
363    ) -> Result<PromotionCondition> {
364        let conn = self.pool.get().map_err(|e| {
365            stateset_core::CommerceError::DatabaseError(format!("Connection error: {e}"))
366        })?;
367
368        let id = Uuid::new_v4();
369
370        conn.execute(
371            "INSERT INTO promotion_conditions (id, promotion_id, condition_type, operator, value, is_required)
372             VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
373            rusqlite::params![
374                id.to_string(),
375                promotion_id.to_string(),
376                input.condition_type.to_string(),
377                input.operator.to_string(),
378                input.value,
379                i32::from(input.is_required),
380            ],
381        ).map_err(|e| stateset_core::CommerceError::DatabaseError(format!("Insert error: {e}")))?;
382
383        Ok(PromotionCondition {
384            id,
385            promotion_id,
386            condition_type: input.condition_type,
387            operator: input.operator,
388            value: input.value,
389            is_required: input.is_required,
390        })
391    }
392
393    fn row_to_condition(row: &rusqlite::Row<'_>) -> rusqlite::Result<PromotionCondition> {
394        Ok(PromotionCondition {
395            id: parse_uuid_row(&row.get::<_, String>(0)?, "promotion_condition", "id")?,
396            promotion_id: PromotionId::from(parse_uuid_row(
397                &row.get::<_, String>(1)?,
398                "promotion_condition",
399                "promotion_id",
400            )?),
401            condition_type: parse_enum_row(
402                &row.get::<_, String>(2)?,
403                "promotion_condition",
404                "condition_type",
405            )?,
406            operator: parse_enum_row(&row.get::<_, String>(3)?, "promotion_condition", "operator")?,
407            value: row.get(4)?,
408            is_required: row.get::<_, i32>(5)? != 0,
409        })
410    }
411
412    fn get_conditions_with_conn(
413        &self,
414        conn: &rusqlite::Connection,
415        promotion_id: PromotionId,
416    ) -> Result<Vec<PromotionCondition>> {
417        let mut stmt = conn
418            .prepare(
419                "SELECT id, promotion_id, condition_type, operator, value, is_required
420                 FROM promotion_conditions WHERE promotion_id = ?1",
421            )
422            .map_err(|e| stateset_core::CommerceError::DatabaseError(e.to_string()))?;
423
424        let rows = stmt
425            .query_map([promotion_id.to_string()], Self::row_to_condition)
426            .map_err(|e| stateset_core::CommerceError::DatabaseError(e.to_string()))?;
427
428        rows.collect::<std::result::Result<Vec<_>, _>>()
429            .map_err(|e| stateset_core::CommerceError::DatabaseError(e.to_string()))
430    }
431
432    /// Batch-load conditions for many promotions in chunked `IN`-clause
433    /// queries, grouped by promotion id. Uses the caller's connection.
434    fn load_conditions_batch(
435        conn: &rusqlite::Connection,
436        ids: &[PromotionId],
437    ) -> Result<std::collections::HashMap<PromotionId, Vec<PromotionCondition>>> {
438        let mut map: std::collections::HashMap<PromotionId, Vec<PromotionCondition>> =
439            std::collections::HashMap::with_capacity(ids.len());
440        let id_strings: Vec<String> = ids.iter().map(ToString::to_string).collect();
441        for chunk in id_strings.chunks(500) {
442            let placeholders = crate::sqlite::build_in_clause(chunk.len());
443            let sql = format!(
444                "SELECT id, promotion_id, condition_type, operator, value, is_required
445                 FROM promotion_conditions WHERE promotion_id IN ({placeholders})"
446            );
447            let mut stmt = conn
448                .prepare(&sql)
449                .map_err(|e| stateset_core::CommerceError::DatabaseError(e.to_string()))?;
450            let param_refs: Vec<&dyn rusqlite::ToSql> =
451                chunk.iter().map(|s| s as &dyn rusqlite::ToSql).collect();
452            let rows = stmt
453                .query_map(param_refs.as_slice(), |row| {
454                    let cond = Self::row_to_condition(row)?;
455                    Ok((cond.promotion_id, cond))
456                })
457                .map_err(|e| stateset_core::CommerceError::DatabaseError(e.to_string()))?;
458            for row in rows {
459                let (parent, cond) =
460                    row.map_err(|e| stateset_core::CommerceError::DatabaseError(e.to_string()))?;
461                map.entry(parent).or_default().push(cond);
462            }
463        }
464        Ok(map)
465    }
466
467    // ========================================================================
468    // Coupon Codes
469    // ========================================================================
470
471    pub fn create_coupon(&self, input: CreateCouponCode) -> Result<CouponCode> {
472        let conn = self.pool.get().map_err(|e| {
473            stateset_core::CommerceError::DatabaseError(format!("Connection error: {e}"))
474        })?;
475
476        let id = Uuid::new_v4();
477        let now = Utc::now();
478
479        conn.execute(
480            "INSERT INTO coupon_codes (id, promotion_id, code, status, usage_limit, per_customer_limit, usage_count, starts_at, ends_at, metadata, created_at, updated_at)
481             VALUES (?1, ?2, ?3, 'active', ?4, ?5, 0, ?6, ?7, ?8, ?9, ?10)",
482            rusqlite::params![
483                id.to_string(),
484                input.promotion_id.to_string(),
485                input.code.to_uppercase(),
486                input.usage_limit,
487                input.per_customer_limit,
488                input.starts_at.map(|d| d.to_rfc3339()),
489                input.ends_at.map(|d| d.to_rfc3339()),
490                input.metadata.as_ref().map(|m| serde_json::to_string(m).unwrap_or_default()),
491                now.to_rfc3339(),
492                now.to_rfc3339(),
493            ],
494        ).map_err(|e| stateset_core::CommerceError::DatabaseError(format!("Insert error: {e}")))?;
495
496        // Drop the connection before calling get_coupon to avoid nested pool checkouts
497        // (important for max_connections=1).
498        drop(conn);
499
500        self.get_coupon(id)?.ok_or_else(|| {
501            stateset_core::CommerceError::DatabaseError("Failed to retrieve created coupon".into())
502        })
503    }
504
505    pub fn get_coupon(&self, id: Uuid) -> Result<Option<CouponCode>> {
506        let conn = self.pool.get().map_err(|e| {
507            stateset_core::CommerceError::DatabaseError(format!("Connection error: {e}"))
508        })?;
509
510        let mut stmt = conn
511            .prepare("SELECT * FROM coupon_codes WHERE id = ?1")
512            .map_err(|e| stateset_core::CommerceError::DatabaseError(e.to_string()))?;
513
514        stmt.query_row([id.to_string()], |row| self.row_to_coupon(row))
515            .optional()
516            .map_err(|e| stateset_core::CommerceError::DatabaseError(e.to_string()))
517    }
518
519    pub fn get_coupon_by_code(&self, code: &str) -> Result<Option<CouponCode>> {
520        let conn = self.pool.get().map_err(|e| {
521            stateset_core::CommerceError::DatabaseError(format!("Connection error: {e}"))
522        })?;
523
524        let mut stmt = conn
525            .prepare("SELECT * FROM coupon_codes WHERE code = ?1")
526            .map_err(|e| stateset_core::CommerceError::DatabaseError(e.to_string()))?;
527
528        stmt.query_row([code.to_uppercase()], |row| self.row_to_coupon(row))
529            .optional()
530            .map_err(|e| stateset_core::CommerceError::DatabaseError(e.to_string()))
531    }
532
533    pub fn list_coupons(&self, filter: CouponFilter) -> Result<Vec<CouponCode>> {
534        let conn = self.pool.get().map_err(|e| {
535            stateset_core::CommerceError::DatabaseError(format!("Connection error: {e}"))
536        })?;
537
538        let mut sql = "SELECT * FROM coupon_codes WHERE 1=1".to_string();
539        let mut params: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
540
541        if let Some(promo_id) = &filter.promotion_id {
542            sql.push_str(" AND promotion_id = ?");
543            params.push(Box::new(promo_id.to_string()));
544        }
545
546        if let Some(status) = &filter.status {
547            sql.push_str(" AND status = ?");
548            params.push(Box::new(status.to_string()));
549        }
550
551        if let Some(search) = &filter.search {
552            sql.push_str(" AND code LIKE ?");
553            params.push(Box::new(format!("%{}%", search.to_uppercase())));
554        }
555
556        sql.push_str(" ORDER BY created_at DESC");
557
558        crate::sqlite::append_limit_offset(&mut sql, filter.limit, filter.offset);
559
560        let mut stmt = conn
561            .prepare(&sql)
562            .map_err(|e| stateset_core::CommerceError::DatabaseError(e.to_string()))?;
563
564        let param_refs: Vec<&dyn rusqlite::ToSql> =
565            params.iter().map(std::convert::AsRef::as_ref).collect();
566
567        let rows = stmt
568            .query_map(param_refs.as_slice(), |row| self.row_to_coupon(row))
569            .map_err(|e| stateset_core::CommerceError::DatabaseError(e.to_string()))?;
570
571        rows.collect::<std::result::Result<Vec<_>, _>>()
572            .map_err(|e| stateset_core::CommerceError::DatabaseError(e.to_string()))
573    }
574
575    // ========================================================================
576    // Apply Promotions
577    // ========================================================================
578
579    pub fn apply_promotions(
580        &self,
581        request: ApplyPromotionsRequest,
582    ) -> Result<ApplyPromotionsResult> {
583        let mut result = ApplyPromotionsResult {
584            original_subtotal: request.subtotal,
585            original_shipping: request.shipping_amount,
586            ..Default::default()
587        };
588
589        // Get active automatic promotions
590        let auto_promotions = self
591            .list(PromotionFilter { is_active: Some(true), ..Default::default() })?
592            .into_iter()
593            .filter(|p| {
594                p.trigger == PromotionTrigger::Automatic || p.trigger == PromotionTrigger::Both
595            })
596            .collect::<Vec<_>>();
597
598        // Get promotions from coupon codes
599        let mut coupon_promotions = Vec::new();
600        for code in &request.coupon_codes {
601            match self.get_coupon_by_code(code)? {
602                Some(coupon) => {
603                    if coupon.status != CouponStatus::Active {
604                        result.rejected_promotions.push(RejectedPromotion {
605                            promotion_id: None,
606                            coupon_code: Some(code.clone()),
607                            reason: "Coupon is not active".into(),
608                            reason_code: RejectionReason::Expired,
609                        });
610                        continue;
611                    }
612
613                    // Validity window (status alone may lag wall-clock expiry).
614                    let now = Utc::now();
615                    if coupon.starts_at.is_some_and(|s| s > now)
616                        || coupon.ends_at.is_some_and(|e| e < now)
617                    {
618                        result.rejected_promotions.push(RejectedPromotion {
619                            promotion_id: None,
620                            coupon_code: Some(code.clone()),
621                            reason: "Coupon is outside its validity window".into(),
622                            reason_code: RejectionReason::Expired,
623                        });
624                        continue;
625                    }
626
627                    // Coupon usage limits (record_usage re-checks these
628                    // transactionally; here they produce friendly rejections).
629                    if coupon.usage_limit.is_some_and(|l| coupon.usage_count >= l) {
630                        result.rejected_promotions.push(RejectedPromotion {
631                            promotion_id: None,
632                            coupon_code: Some(code.clone()),
633                            reason: "Coupon usage limit reached".into(),
634                            reason_code: RejectionReason::UsageLimitReached,
635                        });
636                        continue;
637                    }
638                    if let (Some(limit), Some(customer_id)) =
639                        (coupon.per_customer_limit, request.customer_id)
640                    {
641                        if self.coupon_customer_usage_count(coupon.id, customer_id)?
642                            >= i64::from(limit)
643                        {
644                            result.rejected_promotions.push(RejectedPromotion {
645                                promotion_id: None,
646                                coupon_code: Some(code.clone()),
647                                reason: "Per-customer coupon usage limit reached".into(),
648                                reason_code: RejectionReason::UsageLimitReached,
649                            });
650                            continue;
651                        }
652                    }
653
654                    if let Some(promo) = self.get(coupon.promotion_id)? {
655                        coupon_promotions.push((promo, Some(code.clone())));
656                    }
657                }
658                None => {
659                    result.rejected_promotions.push(RejectedPromotion {
660                        promotion_id: None,
661                        coupon_code: Some(code.clone()),
662                        reason: "Invalid coupon code".into(),
663                        reason_code: RejectionReason::InvalidCode,
664                    });
665                }
666            }
667        }
668
669        // Combine and sort by priority
670        let mut all_promotions: Vec<(Promotion, Option<String>)> =
671            auto_promotions.into_iter().map(|p| (p, None)).chain(coupon_promotions).collect();
672
673        all_promotions.sort_by_key(|(p, _)| p.priority);
674
675        let mut total_discount = Decimal::ZERO;
676        let mut shipping_discount = Decimal::ZERO;
677        let mut has_exclusive = false;
678
679        for (promo, coupon_code) in all_promotions {
680            // The promotion itself must be active and inside its validity
681            // window — coupon-linked promotions bypass the is_active list
682            // filter, so a coupon on a draft/expired promotion lands here.
683            if !promo.is_active() {
684                result.rejected_promotions.push(RejectedPromotion {
685                    promotion_id: Some(promo.id),
686                    coupon_code: coupon_code.clone(),
687                    reason: "Promotion is not active".into(),
688                    reason_code: RejectionReason::Expired,
689                });
690                continue;
691            }
692
693            // Customer targeting: when the promotion lists eligible
694            // customers (and no groups, which the request cannot resolve),
695            // only those customers — identified, not anonymous — may use it.
696            if !promo.eligible_customer_ids.is_empty()
697                && promo.eligible_customer_groups.is_empty()
698                && !request.customer_id.is_some_and(|c| promo.eligible_customer_ids.contains(&c))
699            {
700                result.rejected_promotions.push(RejectedPromotion {
701                    promotion_id: Some(promo.id),
702                    coupon_code: coupon_code.clone(),
703                    reason: "Customer is not eligible for this promotion".into(),
704                    reason_code: RejectionReason::CustomerNotEligible,
705                });
706                continue;
707            }
708
709            // Check if already applied exclusive promotion
710            if has_exclusive && promo.stacking == StackingBehavior::Exclusive {
711                result.rejected_promotions.push(RejectedPromotion {
712                    promotion_id: Some(promo.id),
713                    coupon_code: coupon_code.clone(),
714                    reason: "Cannot combine with other promotions".into(),
715                    reason_code: RejectionReason::NotStackable,
716                });
717                continue;
718            }
719
720            // Check conditions
721            if !self.check_conditions(&promo, &request)? {
722                result.rejected_promotions.push(RejectedPromotion {
723                    promotion_id: Some(promo.id),
724                    coupon_code: coupon_code.clone(),
725                    reason: "Promotion conditions not met".into(),
726                    reason_code: RejectionReason::MinimumNotMet,
727                });
728                continue;
729            }
730
731            // Check usage limits
732            if let Some(limit) = promo.total_usage_limit {
733                if promo.usage_count >= limit {
734                    result.rejected_promotions.push(RejectedPromotion {
735                        promotion_id: Some(promo.id),
736                        coupon_code: coupon_code.clone(),
737                        reason: "Promotion usage limit reached".into(),
738                        reason_code: RejectionReason::UsageLimitReached,
739                    });
740                    continue;
741                }
742            }
743
744            // Check per-customer usage limit (record_usage re-checks this
745            // transactionally; here it produces a friendly rejection).
746            if let (Some(limit), Some(customer_id)) =
747                (promo.per_customer_limit, request.customer_id)
748            {
749                if self.customer_usage_count(promo.id, customer_id)? >= i64::from(limit) {
750                    result.rejected_promotions.push(RejectedPromotion {
751                        promotion_id: Some(promo.id),
752                        coupon_code: coupon_code.clone(),
753                        reason: "Per-customer usage limit reached".into(),
754                        reason_code: RejectionReason::UsageLimitReached,
755                    });
756                    continue;
757                }
758            }
759
760            // Calculate discount
761            let discount = self.calculate_discount(&promo, &request, total_discount)?;
762
763            if discount > Decimal::ZERO {
764                if promo.target == PromotionTarget::Shipping {
765                    shipping_discount += discount;
766                } else {
767                    total_discount += discount;
768                }
769
770                result.applied_promotions.push(AppliedPromotion {
771                    promotion_id: promo.id,
772                    promotion_code: promo.code.clone(),
773                    promotion_name: promo.name.clone(),
774                    coupon_code,
775                    discount_amount: discount,
776                    discount_type: promo.promotion_type,
777                    target: promo.target,
778                    description: promo.discount_description(),
779                });
780
781                if promo.stacking == StackingBehavior::Exclusive {
782                    has_exclusive = true;
783                }
784            }
785        }
786
787        // Cap shipping discount
788        if shipping_discount > request.shipping_amount {
789            shipping_discount = request.shipping_amount;
790        }
791
792        // Cap total discount
793        if total_discount > request.subtotal {
794            total_discount = request.subtotal;
795        }
796
797        result.total_discount = total_discount;
798        result.discounted_subtotal = request.subtotal - total_discount;
799        result.shipping_discount = shipping_discount;
800        result.final_shipping = request.shipping_amount - shipping_discount;
801        result.grand_total = result.discounted_subtotal + result.final_shipping;
802
803        Ok(result)
804    }
805
806    fn check_conditions(
807        &self,
808        promo: &Promotion,
809        request: &ApplyPromotionsRequest,
810    ) -> Result<bool> {
811        if promo.conditions.is_empty() {
812            return Ok(true);
813        }
814
815        let required_conditions: Vec<_> =
816            promo.conditions.iter().filter(|c| c.is_required).collect();
817        let optional_conditions: Vec<_> =
818            promo.conditions.iter().filter(|c| !c.is_required).collect();
819
820        // All required conditions must be met
821        for cond in &required_conditions {
822            if !self.evaluate_condition(cond, request)? {
823                return Ok(false);
824            }
825        }
826
827        // At least one optional condition must be met (if any exist)
828        if !optional_conditions.is_empty() {
829            let mut any_met = false;
830            for cond in &optional_conditions {
831                if self.evaluate_condition(cond, request)? {
832                    any_met = true;
833                    break;
834                }
835            }
836            if !any_met {
837                return Ok(false);
838            }
839        }
840
841        Ok(true)
842    }
843
844    fn evaluate_condition(
845        &self,
846        cond: &PromotionCondition,
847        request: &ApplyPromotionsRequest,
848    ) -> Result<bool> {
849        match cond.condition_type {
850            ConditionType::MinimumSubtotal => {
851                let min = self.parse_condition_decimal(cond)?;
852                Ok(self.compare_decimal(request.subtotal, cond.operator, min))
853            }
854            ConditionType::MinimumQuantity => {
855                let min = self.parse_condition_i32(cond)?;
856                let total_qty: i32 = request.line_items.iter().map(|i| i.quantity).sum();
857                Ok(self.compare_i32(total_qty, cond.operator, min))
858            }
859            ConditionType::FirstOrder => Ok(request.is_first_order),
860            ConditionType::ShippingCountry => {
861                if let Some(country) = &request.shipping_country {
862                    Ok(self.compare_string(country, cond.operator, &cond.value))
863                } else {
864                    Ok(false)
865                }
866            }
867            ConditionType::ShippingState => {
868                if let Some(state) = &request.shipping_state {
869                    Ok(self.compare_string(state, cond.operator, &cond.value))
870                } else {
871                    Ok(false)
872                }
873            }
874            ConditionType::CartItemCount => {
875                let required = self.parse_condition_i32(cond)?;
876                let count = request.line_items.len() as i32;
877                Ok(self.compare_i32(count, cond.operator, required))
878            }
879            _ => Ok(true), // Default to true for unhandled conditions
880        }
881    }
882
883    fn parse_condition_decimal(&self, cond: &PromotionCondition) -> Result<Decimal> {
884        cond.value.parse::<Decimal>().map_err(|e| {
885            CommerceError::DatabaseError(format!(
886                "Invalid promotion condition value for {:?}: '{}' - {}",
887                cond.condition_type, cond.value, e
888            ))
889        })
890    }
891
892    fn parse_condition_i32(&self, cond: &PromotionCondition) -> Result<i32> {
893        cond.value.parse::<i32>().map_err(|e| {
894            CommerceError::DatabaseError(format!(
895                "Invalid promotion condition value for {:?}: '{}' - {}",
896                cond.condition_type, cond.value, e
897            ))
898        })
899    }
900
901    fn compare_decimal(&self, actual: Decimal, op: ConditionOperator, expected: Decimal) -> bool {
902        match op {
903            ConditionOperator::Equals => actual == expected,
904            ConditionOperator::NotEquals => actual != expected,
905            ConditionOperator::GreaterThan => actual > expected,
906            ConditionOperator::GreaterThanOrEqual => actual >= expected,
907            ConditionOperator::LessThan => actual < expected,
908            ConditionOperator::LessThanOrEqual => actual <= expected,
909            _ => false,
910        }
911    }
912
913    const fn compare_i32(&self, actual: i32, op: ConditionOperator, expected: i32) -> bool {
914        match op {
915            ConditionOperator::Equals => actual == expected,
916            ConditionOperator::NotEquals => actual != expected,
917            ConditionOperator::GreaterThan => actual > expected,
918            ConditionOperator::GreaterThanOrEqual => actual >= expected,
919            ConditionOperator::LessThan => actual < expected,
920            ConditionOperator::LessThanOrEqual => actual <= expected,
921            _ => false,
922        }
923    }
924
925    fn compare_string(&self, actual: &str, op: ConditionOperator, expected: &str) -> bool {
926        let actual_lower = actual.to_lowercase();
927        let expected_lower = expected.to_lowercase();
928
929        match op {
930            ConditionOperator::Equals => actual_lower == expected_lower,
931            ConditionOperator::NotEquals => actual_lower != expected_lower,
932            ConditionOperator::Contains => actual_lower.contains(&expected_lower),
933            ConditionOperator::NotContains => !actual_lower.contains(&expected_lower),
934            ConditionOperator::In => expected_lower.split(',').any(|v| v.trim() == actual_lower),
935            ConditionOperator::NotIn => {
936                !expected_lower.split(',').any(|v| v.trim() == actual_lower)
937            }
938            _ => false,
939        }
940    }
941
942    fn calculate_discount(
943        &self,
944        promo: &Promotion,
945        request: &ApplyPromotionsRequest,
946        already_discounted: Decimal,
947    ) -> Result<Decimal> {
948        // When the promotion scopes to specific products, the discount base is
949        // the eligible line items' worth, not the whole subtotal. A scoped
950        // promotion with no line-item data cannot verify eligibility and
951        // fails closed (zero base).
952        let scoped = promo.has_product_scoping();
953        let (eligible_subtotal, eligible_qty) = if scoped {
954            request
955                .line_items
956                .iter()
957                .filter(|item| promo.item_in_scope(item))
958                .fold((Decimal::ZERO, 0i32), |(total, qty), item| {
959                    (total + item.line_total, qty + item.quantity)
960                })
961        } else {
962            (request.subtotal, request.line_items.iter().map(|i| i.quantity).sum())
963        };
964        let applicable_amount =
965            (eligible_subtotal.min(request.subtotal - already_discounted)).max(Decimal::ZERO);
966
967        let discount = match promo.promotion_type {
968            PromotionType::PercentageOff | PromotionType::FirstOrderDiscount => {
969                if let Some(pct) = promo.percentage_off {
970                    applicable_amount * pct
971                } else {
972                    Decimal::ZERO
973                }
974            }
975            PromotionType::FixedAmountOff => {
976                let fixed = promo.fixed_amount_off.unwrap_or(Decimal::ZERO);
977                // A scoped fixed discount cannot exceed the eligible items'
978                // worth; unscoped keeps its historical whole-order semantics.
979                if scoped { fixed.min(applicable_amount) } else { fixed }
980            }
981            PromotionType::FreeShipping => request.shipping_amount,
982            PromotionType::TieredDiscount => {
983                if let Some(tiers) = &promo.tiers {
984                    self.calculate_tiered_discount(tiers, applicable_amount)
985                } else {
986                    Decimal::ZERO
987                }
988            }
989            PromotionType::BuyXGetY => {
990                // Simplified BOGO calculation over in-scope quantities
991                if let (Some(buy), Some(get), Some(discount_pct)) =
992                    (promo.buy_quantity, promo.get_quantity, promo.get_discount_percent)
993                {
994                    let total_qty: i32 = eligible_qty;
995                    let sets = total_qty / (buy + get);
996                    if sets > 0 && total_qty > 0 {
997                        // Find average in-scope item price for simplicity
998                        let avg_price = eligible_subtotal / Decimal::from(total_qty);
999                        avg_price * Decimal::from(sets * get) * discount_pct
1000                    } else {
1001                        Decimal::ZERO
1002                    }
1003                } else {
1004                    Decimal::ZERO
1005                }
1006            }
1007            PromotionType::BundleDiscount => promo.bundle_discount.unwrap_or(Decimal::ZERO),
1008            _ => Decimal::ZERO,
1009        };
1010
1011        // An item-value discount can never exceed the worth of the items it
1012        // applies to — this is what keeps a discount scoped to a set of items
1013        // from bleeding into out-of-scope value. It also fails safe on a
1014        // misconfigured percentage (>100%). FreeShipping is a shipping
1015        // discount, not an item discount, and is exempt; FixedAmountOff and
1016        // BundleDiscount are bounded/whole-order by design (matching Postgres).
1017        let discount = match promo.promotion_type {
1018            PromotionType::FreeShipping
1019            | PromotionType::FixedAmountOff
1020            | PromotionType::BundleDiscount => discount,
1021            _ => discount.min(applicable_amount),
1022        };
1023
1024        // Apply max discount cap
1025        let final_discount =
1026            if let Some(max) = promo.max_discount_amount { discount.min(max) } else { discount };
1027
1028        Ok(final_discount.round_dp(2))
1029    }
1030
1031    fn calculate_tiered_discount(&self, tiers: &[DiscountTier], amount: Decimal) -> Decimal {
1032        // Find the highest tier that applies
1033        let mut applicable_tier: Option<&DiscountTier> = None;
1034
1035        for tier in tiers {
1036            if amount >= tier.min_value {
1037                if let Some(max) = tier.max_value {
1038                    if amount <= max {
1039                        applicable_tier = Some(tier);
1040                    }
1041                } else {
1042                    // No max, check if this is better than current
1043                    let is_better = match applicable_tier {
1044                        Some(current) => tier.min_value > current.min_value,
1045                        None => true,
1046                    };
1047                    if is_better {
1048                        applicable_tier = Some(tier);
1049                    }
1050                }
1051            }
1052        }
1053
1054        if let Some(tier) = applicable_tier {
1055            if let Some(pct) = tier.percentage_off {
1056                return amount * pct;
1057            }
1058            if let Some(fixed) = tier.fixed_amount_off {
1059                return fixed;
1060            }
1061        }
1062
1063        Decimal::ZERO
1064    }
1065
1066    // ========================================================================
1067    // Usage Tracking
1068    // ========================================================================
1069
1070    /// Times a customer has used a specific coupon (from the usage ledger).
1071    fn coupon_customer_usage_count(&self, coupon_id: Uuid, customer_id: CustomerId) -> Result<i64> {
1072        let conn = self.pool.get().map_err(|e| {
1073            stateset_core::CommerceError::DatabaseError(format!("Connection error: {e}"))
1074        })?;
1075        conn.query_row(
1076            "SELECT COUNT(*) FROM promotion_usage WHERE coupon_id = ?1 AND customer_id = ?2",
1077            [coupon_id.to_string(), customer_id.to_string()],
1078            |row| row.get(0),
1079        )
1080        .map_err(|e| stateset_core::CommerceError::DatabaseError(format!("Query error: {e}")))
1081    }
1082
1083    /// Times a customer has used a promotion (from the usage ledger).
1084    fn customer_usage_count(
1085        &self,
1086        promotion_id: PromotionId,
1087        customer_id: CustomerId,
1088    ) -> Result<i64> {
1089        let conn = self.pool.get().map_err(|e| {
1090            stateset_core::CommerceError::DatabaseError(format!("Connection error: {e}"))
1091        })?;
1092        conn.query_row(
1093            "SELECT COUNT(*) FROM promotion_usage WHERE promotion_id = ?1 AND customer_id = ?2",
1094            [promotion_id.to_string(), customer_id.to_string()],
1095            |row| row.get(0),
1096        )
1097        .map_err(|e| stateset_core::CommerceError::DatabaseError(format!("Query error: {e}")))
1098    }
1099
1100    #[allow(clippy::too_many_arguments)]
1101    pub fn record_usage(
1102        &self,
1103        promotion_id: PromotionId,
1104        coupon_id: Option<Uuid>,
1105        customer_id: Option<CustomerId>,
1106        order_id: Option<OrderId>,
1107        cart_id: Option<CartId>,
1108        discount_amount: Decimal,
1109        currency: &str,
1110    ) -> Result<PromotionUsage> {
1111        let id = Uuid::new_v4();
1112        let now = Utc::now();
1113
1114        // One IMMEDIATE transaction so a rejected limit leaves no orphaned
1115        // usage row, and concurrent redemptions serialize (with retry) instead
1116        // of failing with SQLITE_BUSY.
1117        with_immediate_transaction(&self.pool, |tx| {
1118            // Per-customer limits (promotion and coupon), enforced against the
1119            // usage ledger inside the same transaction. Anonymous usage
1120            // (no customer_id) cannot be attributed and is not limited here.
1121            if let Some(customer_id) = customer_id {
1122                let limit: Option<i64> = tx.query_row(
1123                    "SELECT per_customer_limit FROM promotions WHERE id = ?1",
1124                    [promotion_id.to_string()],
1125                    |row| row.get(0),
1126                )?;
1127                if let Some(limit) = limit {
1128                    let used: i64 = tx.query_row(
1129                        "SELECT COUNT(*) FROM promotion_usage WHERE promotion_id = ?1 AND customer_id = ?2",
1130                        [promotion_id.to_string(), customer_id.to_string()],
1131                        |row| row.get(0),
1132                    )?;
1133                    if used >= limit {
1134                        return Err(rusqlite::Error::ToSqlConversionFailure(Box::new(
1135                            stateset_core::CommerceError::ValidationError(
1136                                "Per-customer promotion usage limit reached".to_string(),
1137                            ),
1138                        )));
1139                    }
1140                }
1141
1142                if let Some(coupon_id) = coupon_id {
1143                    let limit: Option<i64> = tx.query_row(
1144                        "SELECT per_customer_limit FROM coupon_codes WHERE id = ?1",
1145                        [coupon_id.to_string()],
1146                        |row| row.get(0),
1147                    )?;
1148                    if let Some(limit) = limit {
1149                        let used: i64 = tx.query_row(
1150                            "SELECT COUNT(*) FROM promotion_usage WHERE coupon_id = ?1 AND customer_id = ?2",
1151                            [coupon_id.to_string(), customer_id.to_string()],
1152                            |row| row.get(0),
1153                        )?;
1154                        if used >= limit {
1155                            return Err(rusqlite::Error::ToSqlConversionFailure(Box::new(
1156                                stateset_core::CommerceError::ValidationError(
1157                                    "Per-customer coupon usage limit reached".to_string(),
1158                                ),
1159                            )));
1160                        }
1161                    }
1162                }
1163            }
1164
1165            // Increment usage count on promotion. The limit is enforced here
1166            // in the UPDATE itself — the evaluation-time check reads a
1167            // snapshot, so concurrent redemptions would race past it
1168            // otherwise.
1169            let rows = tx.execute(
1170                "UPDATE promotions SET usage_count = usage_count + 1
1171                 WHERE id = ?1 AND (total_usage_limit IS NULL OR usage_count < total_usage_limit)",
1172                [promotion_id.to_string()],
1173            )?;
1174            if rows == 0 {
1175                return Err(rusqlite::Error::ToSqlConversionFailure(Box::new(
1176                    stateset_core::CommerceError::ValidationError(
1177                        "Promotion not found or usage limit reached".to_string(),
1178                    ),
1179                )));
1180            }
1181
1182            // Increment coupon usage if applicable, with the same limit guard.
1183            if let Some(coupon_id) = coupon_id {
1184                let rows = tx.execute(
1185                    "UPDATE coupon_codes SET usage_count = usage_count + 1
1186                     WHERE id = ?1 AND (usage_limit IS NULL OR usage_count < usage_limit)",
1187                    [coupon_id.to_string()],
1188                )?;
1189                if rows == 0 {
1190                    return Err(rusqlite::Error::ToSqlConversionFailure(Box::new(
1191                        stateset_core::CommerceError::ValidationError(
1192                            "Coupon not found or usage limit reached".to_string(),
1193                        ),
1194                    )));
1195                }
1196            }
1197
1198            tx.execute(
1199                "INSERT INTO promotion_usage (id, promotion_id, coupon_id, customer_id, order_id, cart_id, discount_amount, currency, used_at)
1200                 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
1201                rusqlite::params![
1202                    id.to_string(),
1203                    promotion_id.to_string(),
1204                    coupon_id.map(|i| i.to_string()),
1205                    customer_id.map(|i| i.to_string()),
1206                    order_id.map(|i| i.to_string()),
1207                    cart_id.map(|i| i.to_string()),
1208                    discount_amount.to_string(),
1209                    currency,
1210                    now.to_rfc3339(),
1211                ],
1212            )?;
1213
1214            Ok(())
1215        })?;
1216
1217        Ok(PromotionUsage {
1218            id,
1219            promotion_id,
1220            coupon_id,
1221            customer_id,
1222            order_id,
1223            cart_id,
1224            discount_amount,
1225            currency: currency.parse::<CurrencyCode>().unwrap_or_default(),
1226            used_at: now,
1227        })
1228    }
1229
1230    // ========================================================================
1231    // Helper Methods
1232    // ========================================================================
1233
1234    fn row_to_promotion(&self, row: &rusqlite::Row<'_>) -> rusqlite::Result<Promotion> {
1235        Ok(Promotion {
1236            id: PromotionId::from(parse_uuid_row(&row.get::<_, String>(0)?, "promotion", "id")?),
1237            code: row.get(1)?,
1238            name: row.get(2)?,
1239            description: row.get(3)?,
1240            internal_notes: row.get(4)?,
1241            promotion_type: parse_enum_row(
1242                &row.get::<_, String>(5)?,
1243                "promotion",
1244                "promotion_type",
1245            )?,
1246            trigger: parse_enum_row(&row.get::<_, String>(6)?, "promotion", "trigger")?,
1247            target: parse_enum_row(&row.get::<_, String>(7)?, "promotion", "target")?,
1248            stacking: parse_enum_row(&row.get::<_, String>(8)?, "promotion", "stacking")?,
1249            status: parse_enum_row(&row.get::<_, String>(9)?, "promotion", "status")?,
1250            percentage_off: parse_decimal_opt_row(
1251                row.get::<_, Option<String>>(10)?,
1252                "promotion",
1253                "percentage_off",
1254            )?,
1255            fixed_amount_off: parse_decimal_opt_row(
1256                row.get::<_, Option<String>>(11)?,
1257                "promotion",
1258                "fixed_amount_off",
1259            )?,
1260            max_discount_amount: parse_decimal_opt_row(
1261                row.get::<_, Option<String>>(12)?,
1262                "promotion",
1263                "max_discount_amount",
1264            )?,
1265            buy_quantity: row.get(13)?,
1266            get_quantity: row.get(14)?,
1267            get_discount_percent: parse_decimal_opt_row(
1268                row.get::<_, Option<String>>(15)?,
1269                "promotion",
1270                "get_discount_percent",
1271            )?,
1272            tiers: parse_json_opt_row(row.get::<_, Option<String>>(16)?, "promotion", "tiers")?,
1273            bundle_product_ids: parse_json_opt_row(
1274                row.get::<_, Option<String>>(17)?,
1275                "promotion",
1276                "bundle_product_ids",
1277            )?,
1278            bundle_discount: parse_decimal_opt_row(
1279                row.get::<_, Option<String>>(18)?,
1280                "promotion",
1281                "bundle_discount",
1282            )?,
1283            starts_at: parse_datetime_row(&row.get::<_, String>(19)?, "promotion", "starts_at")?,
1284            ends_at: parse_datetime_opt_row(
1285                row.get::<_, Option<String>>(20)?,
1286                "promotion",
1287                "ends_at",
1288            )?,
1289            total_usage_limit: row.get(21)?,
1290            per_customer_limit: row.get(22)?,
1291            usage_count: row.get(23)?,
1292            applicable_product_ids: parse_json_opt_row(
1293                row.get::<_, Option<String>>(24)?,
1294                "promotion",
1295                "applicable_product_ids",
1296            )?
1297            .unwrap_or_default(),
1298            applicable_category_ids: parse_json_opt_row(
1299                row.get::<_, Option<String>>(25)?,
1300                "promotion",
1301                "applicable_category_ids",
1302            )?
1303            .unwrap_or_default(),
1304            applicable_skus: parse_json_opt_row(
1305                row.get::<_, Option<String>>(26)?,
1306                "promotion",
1307                "applicable_skus",
1308            )?
1309            .unwrap_or_default(),
1310            excluded_product_ids: parse_json_opt_row(
1311                row.get::<_, Option<String>>(27)?,
1312                "promotion",
1313                "excluded_product_ids",
1314            )?
1315            .unwrap_or_default(),
1316            excluded_category_ids: parse_json_opt_row(
1317                row.get::<_, Option<String>>(28)?,
1318                "promotion",
1319                "excluded_category_ids",
1320            )?
1321            .unwrap_or_default(),
1322            eligible_customer_ids: parse_json_opt_row(
1323                row.get::<_, Option<String>>(29)?,
1324                "promotion",
1325                "eligible_customer_ids",
1326            )?
1327            .unwrap_or_default(),
1328            eligible_customer_groups: parse_json_opt_row(
1329                row.get::<_, Option<String>>(30)?,
1330                "promotion",
1331                "eligible_customer_groups",
1332            )?
1333            .unwrap_or_default(),
1334            currency: row.get(31)?,
1335            priority: row.get(32)?,
1336            metadata: parse_json_opt_row(
1337                row.get::<_, Option<String>>(33)?,
1338                "promotion",
1339                "metadata",
1340            )?,
1341            created_at: parse_datetime_row(&row.get::<_, String>(34)?, "promotion", "created_at")?,
1342            updated_at: parse_datetime_row(&row.get::<_, String>(35)?, "promotion", "updated_at")?,
1343            conditions: Vec::new(), // Loaded separately
1344        })
1345    }
1346
1347    fn row_to_coupon(&self, row: &rusqlite::Row<'_>) -> rusqlite::Result<CouponCode> {
1348        Ok(CouponCode {
1349            id: parse_uuid_row(&row.get::<_, String>(0)?, "coupon_code", "id")?,
1350            promotion_id: PromotionId::from(parse_uuid_row(
1351                &row.get::<_, String>(1)?,
1352                "coupon_code",
1353                "promotion_id",
1354            )?),
1355            code: row.get(2)?,
1356            status: parse_enum_row(&row.get::<_, String>(3)?, "coupon_code", "status")?,
1357            usage_limit: row.get(4)?,
1358            per_customer_limit: row.get(5)?,
1359            usage_count: row.get(6)?,
1360            starts_at: parse_datetime_opt_row(
1361                row.get::<_, Option<String>>(7)?,
1362                "coupon_code",
1363                "starts_at",
1364            )?,
1365            ends_at: parse_datetime_opt_row(
1366                row.get::<_, Option<String>>(8)?,
1367                "coupon_code",
1368                "ends_at",
1369            )?,
1370            metadata: parse_json_opt_row(
1371                row.get::<_, Option<String>>(9)?,
1372                "coupon_code",
1373                "metadata",
1374            )?,
1375            created_at: parse_datetime_row(
1376                &row.get::<_, String>(10)?,
1377                "coupon_code",
1378                "created_at",
1379            )?,
1380            updated_at: parse_datetime_row(
1381                &row.get::<_, String>(11)?,
1382                "coupon_code",
1383                "updated_at",
1384            )?,
1385        })
1386    }
1387}
1388
1389// ============================================================================
1390// Parsing Helpers
1391// ============================================================================
1392
1393impl PromotionRepository for SqlitePromotionRepository {
1394    fn create(&self, input: CreatePromotion) -> Result<Promotion> {
1395        Self::create(self, input)
1396    }
1397
1398    fn get(&self, id: PromotionId) -> Result<Option<Promotion>> {
1399        Self::get(self, id)
1400    }
1401
1402    fn get_by_code(&self, code: &str) -> Result<Option<Promotion>> {
1403        Self::get_by_code(self, code)
1404    }
1405
1406    fn list(&self, filter: PromotionFilter) -> Result<Vec<Promotion>> {
1407        Self::list(self, filter)
1408    }
1409
1410    fn update(&self, id: PromotionId, input: UpdatePromotion) -> Result<Promotion> {
1411        Self::update(self, id, input)
1412    }
1413
1414    fn delete(&self, id: PromotionId) -> Result<()> {
1415        Self::delete(self, id)
1416    }
1417
1418    fn activate(&self, id: PromotionId) -> Result<Promotion> {
1419        Self::activate(self, id)
1420    }
1421
1422    fn deactivate(&self, id: PromotionId) -> Result<Promotion> {
1423        Self::deactivate(self, id)
1424    }
1425
1426    fn create_coupon(&self, input: CreateCouponCode) -> Result<CouponCode> {
1427        Self::create_coupon(self, input)
1428    }
1429
1430    fn get_coupon(&self, id: Uuid) -> Result<Option<CouponCode>> {
1431        Self::get_coupon(self, id)
1432    }
1433
1434    fn get_coupon_by_code(&self, code: &str) -> Result<Option<CouponCode>> {
1435        Self::get_coupon_by_code(self, code)
1436    }
1437
1438    fn list_coupons(&self, filter: CouponFilter) -> Result<Vec<CouponCode>> {
1439        Self::list_coupons(self, filter)
1440    }
1441
1442    fn apply_promotions(&self, request: ApplyPromotionsRequest) -> Result<ApplyPromotionsResult> {
1443        Self::apply_promotions(self, request)
1444    }
1445
1446    fn record_usage(
1447        &self,
1448        promotion_id: PromotionId,
1449        coupon_id: Option<Uuid>,
1450        customer_id: Option<CustomerId>,
1451        order_id: Option<OrderId>,
1452        cart_id: Option<CartId>,
1453        discount_amount: Decimal,
1454        currency: &str,
1455    ) -> Result<PromotionUsage> {
1456        Self::record_usage(
1457            self,
1458            promotion_id,
1459            coupon_id,
1460            customer_id,
1461            order_id,
1462            cart_id,
1463            discount_amount,
1464            currency,
1465        )
1466    }
1467}
1468
1469#[cfg(test)]
1470mod tests {
1471    use super::*;
1472    use crate::SqliteDatabase;
1473    use rust_decimal_macros::dec;
1474    use stateset_core::{
1475        CouponFilter, CreateCouponCode, CreatePromotion, PromotionFilter, PromotionStatus,
1476        PromotionTarget, PromotionTrigger, PromotionType, StackingBehavior,
1477    };
1478
1479    fn fresh_repo() -> SqlitePromotionRepository {
1480        SqliteDatabase::in_memory().expect("in-memory").promotions()
1481    }
1482
1483    fn make_pct_promo(repo: &SqlitePromotionRepository, code: &str, pct: Decimal) -> Promotion {
1484        repo.create(CreatePromotion {
1485            code: Some(code.into()),
1486            name: format!("{code} promo"),
1487            description: None,
1488            internal_notes: None,
1489            promotion_type: PromotionType::PercentageOff,
1490            trigger: PromotionTrigger::CouponCode,
1491            target: PromotionTarget::Order,
1492            stacking: StackingBehavior::Stackable,
1493            percentage_off: Some(pct),
1494            fixed_amount_off: None,
1495            max_discount_amount: None,
1496            buy_quantity: None,
1497            get_quantity: None,
1498            get_discount_percent: None,
1499            tiers: None,
1500            bundle_product_ids: None,
1501            bundle_discount: None,
1502            starts_at: None,
1503            ends_at: None,
1504            total_usage_limit: None,
1505            per_customer_limit: None,
1506            conditions: None,
1507            applicable_product_ids: None,
1508            applicable_category_ids: None,
1509            applicable_skus: None,
1510            excluded_product_ids: None,
1511            excluded_category_ids: None,
1512            eligible_customer_ids: None,
1513            eligible_customer_groups: None,
1514            currency: None,
1515            priority: None,
1516            metadata: None,
1517        })
1518        .expect("create promo")
1519    }
1520
1521    #[test]
1522    fn record_usage_enforces_total_usage_limit() {
1523        let repo = fresh_repo();
1524        let mut promo = make_pct_promo(&repo, "ONCE-ONLY", dec!(0.10));
1525        promo = repo
1526            .update(promo.id, UpdatePromotion { total_usage_limit: Some(1), ..Default::default() })
1527            .expect("set limit");
1528        assert_eq!(promo.total_usage_limit, Some(1));
1529
1530        repo.record_usage(promo.id, None, None, None, None, dec!(5.00), "USD").expect("first use");
1531
1532        // The limit is enforced at record time, not just at evaluation time —
1533        // otherwise concurrent redemptions race past it.
1534        let err = repo
1535            .record_usage(promo.id, None, None, None, None, dec!(5.00), "USD")
1536            .expect_err("second use must be rejected");
1537        assert!(matches!(err, CommerceError::ValidationError(_)), "got {err:?}");
1538
1539        let fetched = repo.get(promo.id).expect("ok").expect("found");
1540        assert_eq!(fetched.usage_count, 1);
1541    }
1542
1543    fn make_customer(db: &SqliteDatabase) -> CustomerId {
1544        use stateset_core::CustomerRepository;
1545        db.customers()
1546            .create(stateset_core::CreateCustomer {
1547                email: format!("promo-{}@example.com", Uuid::new_v4()),
1548                first_name: "Promo".into(),
1549                last_name: "Tester".into(),
1550                phone: None,
1551                accepts_marketing: Some(false),
1552                tags: None,
1553                metadata: None,
1554            })
1555            .expect("create customer")
1556            .id
1557    }
1558
1559    #[test]
1560    fn record_usage_enforces_per_customer_limit() {
1561        let db = SqliteDatabase::in_memory().expect("in-memory");
1562        let repo = db.promotions();
1563        let promo = make_pct_promo(&repo, "PER-CUST", dec!(0.10));
1564        repo.update(
1565            promo.id,
1566            UpdatePromotion { per_customer_limit: Some(1), ..Default::default() },
1567        )
1568        .expect("set per-customer limit");
1569
1570        let alice = make_customer(&db);
1571        let bob = make_customer(&db);
1572
1573        repo.record_usage(promo.id, None, Some(alice), None, None, dec!(5.00), "USD")
1574            .expect("alice first use");
1575
1576        // per_customer_limit was previously stored but never enforced anywhere.
1577        let err = repo
1578            .record_usage(promo.id, None, Some(alice), None, None, dec!(5.00), "USD")
1579            .expect_err("alice second use must be rejected");
1580        assert!(matches!(err, CommerceError::ValidationError(_)), "got {err:?}");
1581
1582        // Other customers and anonymous usage are unaffected.
1583        repo.record_usage(promo.id, None, Some(bob), None, None, dec!(5.00), "USD")
1584            .expect("bob first use");
1585        repo.record_usage(promo.id, None, None, None, None, dec!(5.00), "USD")
1586            .expect("anonymous use");
1587    }
1588
1589    fn eval_request(code: &str, customer_id: Option<CustomerId>) -> ApplyPromotionsRequest {
1590        ApplyPromotionsRequest {
1591            cart_id: None,
1592            customer_id,
1593            coupon_codes: vec![code.to_string()],
1594            line_items: vec![],
1595            subtotal: dec!(100.00),
1596            shipping_amount: dec!(10.00),
1597            shipping_country: None,
1598            shipping_state: None,
1599            currency: CurrencyCode::USD,
1600            is_first_order: false,
1601        }
1602    }
1603
1604    fn line_item(
1605        sku: &str,
1606        product_id: Option<stateset_core::ProductId>,
1607        line_total: Decimal,
1608    ) -> stateset_core::PromotionLineItem {
1609        stateset_core::PromotionLineItem {
1610            id: sku.to_string(),
1611            product_id,
1612            variant_id: None,
1613            sku: Some(sku.to_string()),
1614            category_ids: vec![],
1615            quantity: 1,
1616            unit_price: line_total,
1617            line_total,
1618        }
1619    }
1620
1621    fn scoped_promo_with_coupon(
1622        repo: &SqlitePromotionRepository,
1623        code: &str,
1624        input: CreatePromotion,
1625    ) -> Promotion {
1626        let promo = repo.create(input).expect("create promo");
1627        repo.activate(promo.id).expect("activate");
1628        repo.create_coupon(CreateCouponCode {
1629            promotion_id: promo.id,
1630            code: code.into(),
1631            usage_limit: None,
1632            per_customer_limit: None,
1633            starts_at: None,
1634            ends_at: None,
1635            metadata: None,
1636        })
1637        .expect("create coupon");
1638        promo
1639    }
1640
1641    #[test]
1642    fn apply_promotions_scopes_discount_to_applicable_items() {
1643        let db = SqliteDatabase::in_memory().expect("in-memory");
1644        let repo = db.promotions();
1645        scoped_promo_with_coupon(
1646            &repo,
1647            "WIDGETS-ONLY",
1648            CreatePromotion {
1649                code: Some("WIDGET-10".into()),
1650                name: "10% off widgets".into(),
1651                promotion_type: PromotionType::PercentageOff,
1652                trigger: PromotionTrigger::CouponCode,
1653                target: PromotionTarget::Order,
1654                stacking: StackingBehavior::Stackable,
1655                percentage_off: Some(dec!(0.10)),
1656                applicable_skus: Some(vec!["WIDGET".into()]),
1657                ..Default::default()
1658            },
1659        );
1660
1661        let mut request = eval_request("WIDGETS-ONLY", None);
1662        request.line_items =
1663            vec![line_item("WIDGET", None, dec!(40.00)), line_item("GADGET", None, dec!(60.00))];
1664
1665        let result = repo.apply_promotions(request).expect("eval");
1666        assert_eq!(result.applied_promotions.len(), 1, "{result:?}");
1667        assert_eq!(
1668            result.total_discount,
1669            dec!(4.00),
1670            "10% must apply to the 40.00 of eligible items, not the 100.00 subtotal: {result:?}"
1671        );
1672    }
1673
1674    #[test]
1675    fn apply_promotions_applies_bundle_discount() {
1676        // A BundleDiscount promotion must apply its fixed `bundle_discount`
1677        // amount. SQLite previously had no BundleDiscount arm in
1678        // `calculate_discount`, so it silently produced $0 (and was dropped from
1679        // the result), while Postgres applied the full amount.
1680        let db = SqliteDatabase::in_memory().expect("in-memory");
1681        let repo = db.promotions();
1682        scoped_promo_with_coupon(
1683            &repo,
1684            "BUNDLE15",
1685            CreatePromotion {
1686                code: Some("BUNDLE-15".into()),
1687                name: "$15 off bundle".into(),
1688                promotion_type: PromotionType::BundleDiscount,
1689                trigger: PromotionTrigger::CouponCode,
1690                target: PromotionTarget::Order,
1691                stacking: StackingBehavior::Stackable,
1692                bundle_discount: Some(dec!(15.00)),
1693                ..Default::default()
1694            },
1695        );
1696
1697        let result = repo.apply_promotions(eval_request("BUNDLE15", None)).expect("eval");
1698        assert_eq!(result.applied_promotions.len(), 1, "bundle promo must apply: {result:?}");
1699        assert_eq!(
1700            result.total_discount,
1701            dec!(15.00),
1702            "BundleDiscount must apply its bundle_discount amount, matching Postgres: {result:?}"
1703        );
1704    }
1705
1706    #[test]
1707    fn apply_promotions_percentage_cannot_bleed_past_scoped_items() {
1708        // A scoped percentage discount must never exceed the eligible items'
1709        // worth, mirroring the scoped fixed-discount cap. A misconfigured
1710        // percentage (>100%, an admin data error) must not bleed the discount
1711        // into out-of-scope items.
1712        let db = SqliteDatabase::in_memory().expect("in-memory");
1713        let repo = db.promotions();
1714        scoped_promo_with_coupon(
1715            &repo,
1716            "WIDGETS-150",
1717            CreatePromotion {
1718                code: Some("WIDGET-150".into()),
1719                name: "150% off widgets (misconfigured)".into(),
1720                promotion_type: PromotionType::PercentageOff,
1721                trigger: PromotionTrigger::CouponCode,
1722                target: PromotionTarget::Order,
1723                stacking: StackingBehavior::Stackable,
1724                percentage_off: Some(dec!(1.5)),
1725                applicable_skus: Some(vec!["WIDGET".into()]),
1726                ..Default::default()
1727            },
1728        );
1729
1730        let mut request = eval_request("WIDGETS-150", None);
1731        request.line_items =
1732            vec![line_item("WIDGET", None, dec!(40.00)), line_item("GADGET", None, dec!(60.00))];
1733
1734        let result = repo.apply_promotions(request).expect("eval");
1735        assert_eq!(
1736            result.total_discount,
1737            dec!(40.00),
1738            "discount must cap at the 40.00 of eligible WIDGET items, not bleed into the GADGET: {result:?}"
1739        );
1740    }
1741
1742    #[test]
1743    fn apply_promotions_tiered_picks_highest_applicable_tier_regardless_of_order() {
1744        // The tiered discount must select the highest tier the amount
1745        // qualifies for, independent of the order tiers are listed in.
1746        let db = SqliteDatabase::in_memory().expect("in-memory");
1747        let repo = db.promotions();
1748        scoped_promo_with_coupon(
1749            &repo,
1750            "TIERED",
1751            CreatePromotion {
1752                code: Some("TIER".into()),
1753                name: "Spend more, save more".into(),
1754                promotion_type: PromotionType::TieredDiscount,
1755                trigger: PromotionTrigger::CouponCode,
1756                target: PromotionTarget::Order,
1757                stacking: StackingBehavior::Stackable,
1758                // Deliberately listed high-to-low: the $100+ tier must win for a
1759                // $100 order even though the $0 tier appears last.
1760                tiers: Some(vec![
1761                    DiscountTier {
1762                        min_value: dec!(100),
1763                        max_value: None,
1764                        percentage_off: Some(dec!(0.20)),
1765                        fixed_amount_off: None,
1766                    },
1767                    DiscountTier {
1768                        min_value: dec!(0),
1769                        max_value: None,
1770                        percentage_off: Some(dec!(0.05)),
1771                        fixed_amount_off: None,
1772                    },
1773                ]),
1774                ..Default::default()
1775            },
1776        );
1777
1778        let request = eval_request("TIERED", None); // subtotal 100.00
1779        let result = repo.apply_promotions(request).expect("eval");
1780        assert_eq!(
1781            result.total_discount,
1782            dec!(20.00),
1783            "$100 order must hit the $100+ tier (20% = 20.00), not the $0 tier (5%): {result:?}"
1784        );
1785    }
1786
1787    #[test]
1788    fn apply_promotions_respects_product_exclusions() {
1789        let db = SqliteDatabase::in_memory().expect("in-memory");
1790        let repo = db.promotions();
1791        let excluded_product = stateset_core::ProductId::new();
1792        scoped_promo_with_coupon(
1793            &repo,
1794            "NO-GIFTCARDS",
1795            CreatePromotion {
1796                code: Some("SITEWIDE-10".into()),
1797                name: "10% off except gift cards".into(),
1798                promotion_type: PromotionType::PercentageOff,
1799                trigger: PromotionTrigger::CouponCode,
1800                target: PromotionTarget::Order,
1801                stacking: StackingBehavior::Stackable,
1802                percentage_off: Some(dec!(0.10)),
1803                excluded_product_ids: Some(vec![excluded_product]),
1804                ..Default::default()
1805            },
1806        );
1807
1808        let mut request = eval_request("NO-GIFTCARDS", None);
1809        request.line_items = vec![
1810            line_item("GIFTCARD", Some(excluded_product), dec!(60.00)),
1811            line_item("MUG", Some(stateset_core::ProductId::new()), dec!(40.00)),
1812        ];
1813
1814        let result = repo.apply_promotions(request).expect("eval");
1815        assert_eq!(
1816            result.total_discount,
1817            dec!(4.00),
1818            "excluded products must not contribute to the discount base: {result:?}"
1819        );
1820    }
1821
1822    #[test]
1823    fn apply_promotions_caps_scoped_fixed_discount_and_fails_closed() {
1824        let db = SqliteDatabase::in_memory().expect("in-memory");
1825        let repo = db.promotions();
1826        scoped_promo_with_coupon(
1827            &repo,
1828            "WIDGET-20-OFF",
1829            CreatePromotion {
1830                code: Some("W20".into()),
1831                name: "20 off widgets".into(),
1832                promotion_type: PromotionType::FixedAmountOff,
1833                trigger: PromotionTrigger::CouponCode,
1834                target: PromotionTarget::Order,
1835                stacking: StackingBehavior::Stackable,
1836                fixed_amount_off: Some(dec!(20.00)),
1837                applicable_skus: Some(vec!["WIDGET".into()]),
1838                ..Default::default()
1839            },
1840        );
1841
1842        // Eligible items are worth less than the fixed discount.
1843        let mut request = eval_request("WIDGET-20-OFF", None);
1844        request.line_items =
1845            vec![line_item("WIDGET", None, dec!(15.00)), line_item("GADGET", None, dec!(85.00))];
1846        let result = repo.apply_promotions(request).expect("eval");
1847        assert_eq!(
1848            result.total_discount,
1849            dec!(15.00),
1850            "a scoped fixed discount must not exceed the eligible items' worth: {result:?}"
1851        );
1852
1853        // A scoped promotion with no line-item data cannot verify eligibility
1854        // and must fail closed (no discount).
1855        let result = repo.apply_promotions(eval_request("WIDGET-20-OFF", None)).expect("eval");
1856        assert!(
1857            result.applied_promotions.is_empty(),
1858            "scoped promo without line items must not discount: {result:?}"
1859        );
1860    }
1861
1862    #[test]
1863    fn apply_promotions_enforces_customer_eligibility_list() {
1864        let db = SqliteDatabase::in_memory().expect("in-memory");
1865        let repo = db.promotions();
1866        let alice = CustomerId::new();
1867        let bob = CustomerId::new();
1868
1869        let promo = repo
1870            .create(CreatePromotion {
1871                code: Some("VIP-ONLY".into()),
1872                name: "VIP only".into(),
1873                promotion_type: PromotionType::PercentageOff,
1874                trigger: PromotionTrigger::CouponCode,
1875                target: PromotionTarget::Order,
1876                stacking: StackingBehavior::Stackable,
1877                percentage_off: Some(dec!(0.10)),
1878                eligible_customer_ids: Some(vec![alice]),
1879                ..Default::default()
1880            })
1881            .expect("create promo");
1882        repo.activate(promo.id).expect("activate");
1883        repo.create_coupon(CreateCouponCode {
1884            promotion_id: promo.id,
1885            code: "VIP-CODE".into(),
1886            usage_limit: None,
1887            per_customer_limit: None,
1888            starts_at: None,
1889            ends_at: None,
1890            metadata: None,
1891        })
1892        .expect("create coupon");
1893
1894        // The listed customer gets the discount.
1895        let result = repo.apply_promotions(eval_request("VIP-CODE", Some(alice))).expect("eval");
1896        assert_eq!(result.applied_promotions.len(), 1, "alice is eligible: {result:?}");
1897
1898        // Everyone else — including anonymous carts — is rejected.
1899        for customer in [Some(bob), None] {
1900            let result = repo.apply_promotions(eval_request("VIP-CODE", customer)).expect("eval");
1901            assert!(
1902                result.applied_promotions.is_empty(),
1903                "non-listed customer must not get a targeted promotion: {result:?}"
1904            );
1905            assert!(
1906                result
1907                    .rejected_promotions
1908                    .iter()
1909                    .any(|r| r.reason_code == RejectionReason::CustomerNotEligible),
1910                "rejection must cite CustomerNotEligible: {result:?}"
1911            );
1912        }
1913    }
1914
1915    #[test]
1916    fn apply_promotions_rejects_coupon_on_inactive_promotion() {
1917        let db = SqliteDatabase::in_memory().expect("in-memory");
1918        let repo = db.promotions();
1919        // make_pct_promo leaves the promotion in 'draft' — a coupon on it must
1920        // not discount before the promotion is activated.
1921        let promo = make_pct_promo(&repo, "DRAFT-PROMO", dec!(0.10));
1922        let coupon = repo
1923            .create_coupon(CreateCouponCode {
1924                promotion_id: promo.id,
1925                code: "EARLY-BIRD".into(),
1926                usage_limit: None,
1927                per_customer_limit: None,
1928                starts_at: None,
1929                ends_at: None,
1930                metadata: None,
1931            })
1932            .expect("create coupon");
1933
1934        let result = repo.apply_promotions(eval_request(&coupon.code, None)).expect("eval");
1935        assert!(
1936            result.applied_promotions.is_empty(),
1937            "a draft promotion must not apply via its coupon: {result:?}"
1938        );
1939        assert!(
1940            result.rejected_promotions.iter().any(|r| r.promotion_id == Some(promo.id)),
1941            "the inactive promotion must be reported as rejected: {result:?}"
1942        );
1943
1944        // Once activated, the same coupon applies.
1945        repo.activate(promo.id).expect("activate");
1946        let result = repo.apply_promotions(eval_request(&coupon.code, None)).expect("eval");
1947        assert_eq!(result.applied_promotions.len(), 1, "activated promo applies: {result:?}");
1948    }
1949
1950    #[test]
1951    fn apply_promotions_rejects_exhausted_and_expired_coupons() {
1952        let db = SqliteDatabase::in_memory().expect("in-memory");
1953        let repo = db.promotions();
1954        let promo = make_pct_promo(&repo, "EVAL-CP", dec!(0.10));
1955
1956        // Coupon whose total usage limit is already exhausted.
1957        let exhausted = repo
1958            .create_coupon(CreateCouponCode {
1959                promotion_id: promo.id,
1960                code: "EXHAUSTED".into(),
1961                usage_limit: Some(0),
1962                per_customer_limit: None,
1963                starts_at: None,
1964                ends_at: None,
1965                metadata: None,
1966            })
1967            .expect("create coupon");
1968        let result = repo.apply_promotions(eval_request(&exhausted.code, None)).expect("eval");
1969        assert!(
1970            result
1971                .rejected_promotions
1972                .iter()
1973                .any(|r| r.reason_code == RejectionReason::UsageLimitReached),
1974            "exhausted coupon must be rejected at evaluation: {result:?}"
1975        );
1976        assert!(result.applied_promotions.is_empty());
1977
1978        // Coupon whose validity window has passed (status still Active).
1979        let expired = repo
1980            .create_coupon(CreateCouponCode {
1981                promotion_id: promo.id,
1982                code: "EXPIRED-WINDOW".into(),
1983                usage_limit: None,
1984                per_customer_limit: None,
1985                starts_at: None,
1986                ends_at: Some(Utc::now() - chrono::Duration::days(1)),
1987                metadata: None,
1988            })
1989            .expect("create coupon");
1990        let result = repo.apply_promotions(eval_request(&expired.code, None)).expect("eval");
1991        assert!(
1992            result.rejected_promotions.iter().any(|r| r.reason_code == RejectionReason::Expired),
1993            "date-expired coupon must be rejected at evaluation: {result:?}"
1994        );
1995
1996        // Per-customer limit already consumed by this customer.
1997        let alice = make_customer(&db);
1998        let per_cust = repo
1999            .create_coupon(CreateCouponCode {
2000                promotion_id: promo.id,
2001                code: "ONE-PER".into(),
2002                usage_limit: None,
2003                per_customer_limit: Some(1),
2004                starts_at: None,
2005                ends_at: None,
2006                metadata: None,
2007            })
2008            .expect("create coupon");
2009        repo.record_usage(promo.id, Some(per_cust.id), Some(alice), None, None, dec!(5.00), "USD")
2010            .expect("first use");
2011        let result =
2012            repo.apply_promotions(eval_request(&per_cust.code, Some(alice))).expect("eval");
2013        assert!(
2014            result
2015                .rejected_promotions
2016                .iter()
2017                .any(|r| r.reason_code == RejectionReason::UsageLimitReached),
2018            "per-customer-exhausted coupon must be rejected at evaluation: {result:?}"
2019        );
2020    }
2021
2022    #[test]
2023    fn record_usage_enforces_coupon_per_customer_limit() {
2024        let db = SqliteDatabase::in_memory().expect("in-memory");
2025        let repo = db.promotions();
2026        let promo = make_pct_promo(&repo, "CP-PER-CUST", dec!(0.10));
2027        let coupon = repo
2028            .create_coupon(CreateCouponCode {
2029                promotion_id: promo.id,
2030                code: "ONE-EACH".into(),
2031                usage_limit: None,
2032                per_customer_limit: Some(1),
2033                starts_at: None,
2034                ends_at: None,
2035                metadata: None,
2036            })
2037            .expect("create coupon");
2038
2039        let alice = make_customer(&db);
2040        repo.record_usage(promo.id, Some(coupon.id), Some(alice), None, None, dec!(5.00), "USD")
2041            .expect("alice first coupon use");
2042        let err = repo
2043            .record_usage(promo.id, Some(coupon.id), Some(alice), None, None, dec!(5.00), "USD")
2044            .expect_err("alice second coupon use must be rejected");
2045        assert!(matches!(err, CommerceError::ValidationError(_)), "got {err:?}");
2046    }
2047
2048    #[test]
2049    fn concurrent_redemptions_cannot_exceed_usage_limit() {
2050        use std::sync::{Arc, Barrier};
2051        use std::thread;
2052
2053        let db = Arc::new(SqliteDatabase::in_memory().expect("in-memory"));
2054        let repo = db.promotions();
2055        let promo = make_pct_promo(&repo, "RACE-LIMIT", dec!(0.10));
2056        repo.update(promo.id, UpdatePromotion { total_usage_limit: Some(5), ..Default::default() })
2057            .expect("set limit");
2058
2059        let thread_count = 10;
2060        let barrier = Arc::new(Barrier::new(thread_count));
2061        let mut handles = Vec::new();
2062        for _ in 0..thread_count {
2063            let db = Arc::clone(&db);
2064            let barrier = Arc::clone(&barrier);
2065            let promo_id = promo.id;
2066            handles.push(thread::spawn(move || {
2067                let repo = db.promotions();
2068                barrier.wait();
2069                repo.record_usage(promo_id, None, None, None, None, dec!(5.00), "USD")
2070            }));
2071        }
2072
2073        let results: Vec<_> = handles.into_iter().map(|h| h.join().expect("thread")).collect();
2074        let successes = results.iter().filter(|r| r.is_ok()).count();
2075
2076        let fetched = repo.get(promo.id).expect("ok").expect("found");
2077        // Safety invariant: redemptions can never race past the usage limit.
2078        assert!(
2079            fetched.usage_count <= 5,
2080            "usage_count raced past the limit: {}",
2081            fetched.usage_count
2082        );
2083        // usage_count equals the successful redemptions exactly — no lost
2084        // updates, no phantom increments. Under extreme lock contention a
2085        // redemption may fail with a retryable "table is locked" error (the
2086        // caller retries), so `successes` may be fewer than five.
2087        assert_eq!(
2088            i64::from(fetched.usage_count),
2089            successes as i64,
2090            "usage_count must equal the successful redemptions: {results:?}"
2091        );
2092        assert!((1..=5).contains(&successes), "between one and five redemptions fit: {results:?}");
2093    }
2094
2095    #[test]
2096    fn create_promotion_round_trips() {
2097        let repo = fresh_repo();
2098        let p = make_pct_promo(&repo, "SAVE10", dec!(0.10));
2099        assert_eq!(p.code, "SAVE10");
2100        assert_eq!(p.promotion_type, PromotionType::PercentageOff);
2101        assert_eq!(p.percentage_off, Some(dec!(0.10)));
2102
2103        let by_id = repo.get(p.id).expect("ok").expect("found");
2104        assert_eq!(by_id.id, p.id);
2105        let by_code = repo.get_by_code("SAVE10").expect("ok").expect("found");
2106        assert_eq!(by_code.id, p.id);
2107        assert!(repo.get_by_code("missing").expect("ok").is_none());
2108    }
2109
2110    #[test]
2111    fn list_promotions_filters_by_type() {
2112        let repo = fresh_repo();
2113        make_pct_promo(&repo, "PCT-1", dec!(0.10));
2114        make_pct_promo(&repo, "PCT-2", dec!(0.20));
2115        repo.create(CreatePromotion {
2116            code: Some("FIX-1".into()),
2117            name: "Fixed".into(),
2118            description: None,
2119            internal_notes: None,
2120            promotion_type: PromotionType::FixedAmountOff,
2121            trigger: PromotionTrigger::Automatic,
2122            target: PromotionTarget::Order,
2123            stacking: StackingBehavior::Stackable,
2124            percentage_off: None,
2125            fixed_amount_off: Some(dec!(5)),
2126            max_discount_amount: None,
2127            buy_quantity: None,
2128            get_quantity: None,
2129            get_discount_percent: None,
2130            tiers: None,
2131            bundle_product_ids: None,
2132            bundle_discount: None,
2133            starts_at: None,
2134            ends_at: None,
2135            total_usage_limit: None,
2136            per_customer_limit: None,
2137            conditions: None,
2138            applicable_product_ids: None,
2139            applicable_category_ids: None,
2140            applicable_skus: None,
2141            excluded_product_ids: None,
2142            excluded_category_ids: None,
2143            eligible_customer_ids: None,
2144            eligible_customer_groups: None,
2145            currency: None,
2146            priority: None,
2147            metadata: None,
2148        })
2149        .expect("fixed");
2150
2151        let pcts = repo
2152            .list(PromotionFilter {
2153                promotion_type: Some(PromotionType::PercentageOff),
2154                ..Default::default()
2155            })
2156            .expect("pcts");
2157        assert!(pcts.iter().all(|p| p.promotion_type == PromotionType::PercentageOff));
2158        assert!(pcts.len() >= 2);
2159    }
2160
2161    #[test]
2162    fn activate_and_deactivate_change_status() {
2163        let repo = fresh_repo();
2164        let p = make_pct_promo(&repo, "ACT-1", dec!(0.10));
2165        let activated = repo.activate(p.id).expect("activate");
2166        assert_eq!(activated.status, PromotionStatus::Active);
2167        let paused = repo.deactivate(p.id).expect("deactivate");
2168        assert_eq!(paused.status, PromotionStatus::Paused);
2169    }
2170
2171    #[test]
2172    fn delete_promotion_removes_it() {
2173        let repo = fresh_repo();
2174        let p = make_pct_promo(&repo, "DEL-1", dec!(0.10));
2175        repo.delete(p.id).expect("delete");
2176        assert!(repo.get(p.id).expect("ok").is_none());
2177    }
2178
2179    #[test]
2180    fn create_coupon_round_trips() {
2181        let repo = fresh_repo();
2182        let p = make_pct_promo(&repo, "PROMO-CP", dec!(0.10));
2183        let coupon = repo
2184            .create_coupon(CreateCouponCode {
2185                promotion_id: p.id,
2186                code: "WELCOME10".into(),
2187                usage_limit: Some(100),
2188                per_customer_limit: Some(1),
2189                starts_at: None,
2190                ends_at: None,
2191                metadata: None,
2192            })
2193            .expect("create coupon");
2194        assert_eq!(coupon.code, "WELCOME10");
2195        assert_eq!(coupon.promotion_id, p.id);
2196
2197        let by_id = repo.get_coupon(coupon.id).expect("ok").expect("found");
2198        assert_eq!(by_id.id, coupon.id);
2199        let by_code = repo.get_coupon_by_code("WELCOME10").expect("ok").expect("found");
2200        assert_eq!(by_code.id, coupon.id);
2201        assert!(repo.get_coupon_by_code("missing").expect("ok").is_none());
2202    }
2203
2204    #[test]
2205    fn list_coupons_filters_by_promotion() {
2206        let repo = fresh_repo();
2207        let p1 = make_pct_promo(&repo, "P1", dec!(0.10));
2208        let p2 = make_pct_promo(&repo, "P2", dec!(0.20));
2209        for code in ["A1", "A2", "A3"] {
2210            repo.create_coupon(CreateCouponCode {
2211                promotion_id: p1.id,
2212                code: code.into(),
2213                usage_limit: None,
2214                per_customer_limit: None,
2215                starts_at: None,
2216                ends_at: None,
2217                metadata: None,
2218            })
2219            .expect("c");
2220        }
2221        repo.create_coupon(CreateCouponCode {
2222            promotion_id: p2.id,
2223            code: "B1".into(),
2224            usage_limit: None,
2225            per_customer_limit: None,
2226            starts_at: None,
2227            ends_at: None,
2228            metadata: None,
2229        })
2230        .expect("c");
2231
2232        let p1_coupons = repo
2233            .list_coupons(CouponFilter { promotion_id: Some(p1.id), ..Default::default() })
2234            .expect("list");
2235        assert_eq!(p1_coupons.len(), 3);
2236    }
2237
2238    #[test]
2239    fn get_unknown_promotion_returns_none() {
2240        let repo = fresh_repo();
2241        assert!(repo.get(stateset_core::PromotionId::new()).expect("ok").is_none());
2242    }
2243
2244    #[test]
2245    fn get_unknown_coupon_returns_none() {
2246        let repo = fresh_repo();
2247        assert!(repo.get_coupon(Uuid::new_v4()).expect("ok").is_none());
2248    }
2249
2250    #[test]
2251    fn list_batched_condition_loading_preserves_per_promotion_conditions() {
2252        let repo = fresh_repo();
2253        let condition = |value: &str| stateset_core::CreatePromotionCondition {
2254            condition_type: ConditionType::MinimumSubtotal,
2255            operator: ConditionOperator::GreaterThanOrEqual,
2256            value: value.into(),
2257            is_required: true,
2258        };
2259        // Distinct condition counts/values per promotion so cross-wiring is caught.
2260        for (code, values) in [
2261            ("COND-A", vec!["10", "20"]),
2262            ("COND-B", vec!["30"]),
2263            ("COND-C", vec!["40", "50", "60"]),
2264        ] {
2265            let promo = make_pct_promo(&repo, code, dec!(0.10));
2266            // make_pct_promo creates without conditions; add them individually.
2267            for v in values {
2268                repo.create_condition(promo.id, condition(v)).expect("add condition");
2269            }
2270        }
2271
2272        let listed = repo.list(PromotionFilter::default()).expect("list");
2273        assert_eq!(listed.len(), 3);
2274        for promo in &listed {
2275            let direct = repo.get(promo.id).expect("get").expect("present").conditions;
2276            let mut listed_values: Vec<_> =
2277                promo.conditions.iter().map(|c| c.value.clone()).collect();
2278            let mut direct_values: Vec<_> = direct.iter().map(|c| c.value.clone()).collect();
2279            listed_values.sort();
2280            direct_values.sort();
2281            assert_eq!(listed_values, direct_values, "conditions must match for {}", promo.name);
2282            assert!(
2283                promo.conditions.iter().all(|c| c.promotion_id == promo.id),
2284                "conditions must belong to their own promotion"
2285            );
2286        }
2287    }
2288}