1use 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 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 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(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 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 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 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 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 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 {
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 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 #[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 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 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(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 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 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 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 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 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 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 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 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 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 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 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 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 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 if shipping_discount > request.shipping_amount {
789 shipping_discount = request.shipping_amount;
790 }
791
792 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 for cond in &required_conditions {
822 if !self.evaluate_condition(cond, request)? {
823 return Ok(false);
824 }
825 }
826
827 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), }
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 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 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 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 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 let discount = match promo.promotion_type {
1018 PromotionType::FreeShipping
1019 | PromotionType::FixedAmountOff
1020 | PromotionType::BundleDiscount => discount,
1021 _ => discount.min(applicable_amount),
1022 };
1023
1024 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 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 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 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 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 with_immediate_transaction(&self.pool, |tx| {
1118 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 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 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 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(), })
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
1389impl 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 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 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 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 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 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 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 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); 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 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 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 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 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 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 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 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 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 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 assert!(
2079 fetched.usage_count <= 5,
2080 "usage_count raced past the limit: {}",
2081 fetched.usage_count
2082 );
2083 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 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 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}