1use chrono::{DateTime, Utc};
14use rust_decimal::Decimal;
15use serde::{Deserialize, Serialize};
16use stateset_primitives::{CartId, CurrencyCode, CustomerId, OrderId, ProductId, PromotionId};
17use strum::{Display, EnumString};
18use uuid::Uuid;
19
20#[derive(
26 Debug, Clone, Copy, PartialEq, Eq, Display, EnumString, Serialize, Deserialize, Default,
27)]
28#[strum(serialize_all = "snake_case", ascii_case_insensitive)]
29#[serde(rename_all = "snake_case")]
30#[non_exhaustive]
31pub enum PromotionType {
32 #[default]
34 #[strum(serialize = "percentage_off", serialize = "percentageoff")]
35 PercentageOff,
36 #[strum(serialize = "fixed_amount_off", serialize = "fixedamountoff")]
38 FixedAmountOff,
39 #[strum(serialize = "buy_x_get_y", serialize = "buyxgety")]
41 BuyXGetY,
42 #[strum(serialize = "free_shipping", serialize = "freeshipping")]
44 FreeShipping,
45 #[strum(serialize = "tiered_discount", serialize = "tiereddiscount")]
47 TieredDiscount,
48 #[strum(serialize = "bundle_discount", serialize = "bundlediscount")]
50 BundleDiscount,
51 #[strum(serialize = "first_order_discount", serialize = "firstorderdiscount")]
53 FirstOrderDiscount,
54 #[strum(serialize = "gift_with_purchase", serialize = "giftwithpurchase")]
56 GiftWithPurchase,
57}
58
59#[derive(
61 Debug, Clone, Copy, PartialEq, Eq, Display, EnumString, Serialize, Deserialize, Default,
62)]
63#[strum(serialize_all = "snake_case", ascii_case_insensitive)]
64#[serde(rename_all = "snake_case")]
65#[non_exhaustive]
66pub enum PromotionStatus {
67 #[default]
69 Draft,
70 Scheduled,
72 Active,
74 Paused,
76 Expired,
78 Exhausted,
80 Archived,
82}
83
84#[derive(
86 Debug, Clone, Copy, PartialEq, Eq, Display, EnumString, Serialize, Deserialize, Default,
87)]
88#[strum(serialize_all = "snake_case", ascii_case_insensitive)]
89#[serde(rename_all = "snake_case")]
90#[non_exhaustive]
91pub enum PromotionTrigger {
92 #[default]
94 Automatic,
95 #[strum(serialize = "coupon_code", serialize = "couponcode")]
97 CouponCode,
98 Both,
100}
101
102#[derive(
104 Debug, Clone, Copy, PartialEq, Eq, Display, EnumString, Serialize, Deserialize, Default,
105)]
106#[strum(serialize_all = "snake_case", ascii_case_insensitive)]
107#[serde(rename_all = "snake_case")]
108#[non_exhaustive]
109pub enum PromotionTarget {
110 #[default]
112 Order,
113 Product,
115 Category,
117 Shipping,
119 #[strum(serialize = "line_item", serialize = "lineitem")]
121 LineItem,
122}
123
124#[derive(
126 Debug, Clone, Copy, PartialEq, Eq, Display, EnumString, Serialize, Deserialize, Default,
127)]
128#[strum(serialize_all = "snake_case", ascii_case_insensitive)]
129#[serde(rename_all = "snake_case")]
130#[non_exhaustive]
131pub enum StackingBehavior {
132 #[default]
134 Stackable,
135 Exclusive,
137 #[strum(serialize = "selective_stack", serialize = "selectivestack")]
139 SelectiveStack,
140}
141
142#[derive(
144 Debug, Clone, Copy, PartialEq, Eq, Display, EnumString, Serialize, Deserialize, Default,
145)]
146#[strum(serialize_all = "snake_case", ascii_case_insensitive)]
147#[serde(rename_all = "snake_case")]
148#[non_exhaustive]
149pub enum ConditionOperator {
150 #[default]
151 Equals,
152 #[strum(serialize = "not_equals", serialize = "notequals")]
153 NotEquals,
154 #[strum(serialize = "greater_than", serialize = "greaterthan")]
155 GreaterThan,
156 #[strum(serialize = "greater_than_or_equal", serialize = "greaterthanorequal")]
157 GreaterThanOrEqual,
158 #[strum(serialize = "less_than", serialize = "lessthan")]
159 LessThan,
160 #[strum(serialize = "less_than_or_equal", serialize = "lessthanorequal")]
161 LessThanOrEqual,
162 Contains,
163 #[strum(serialize = "not_contains", serialize = "notcontains")]
164 NotContains,
165 In,
166 #[strum(serialize = "not_in", serialize = "notin")]
167 NotIn,
168}
169
170#[derive(
172 Debug, Clone, Copy, PartialEq, Eq, Display, EnumString, Serialize, Deserialize, Default,
173)]
174#[strum(serialize_all = "snake_case", ascii_case_insensitive)]
175#[serde(rename_all = "snake_case")]
176#[non_exhaustive]
177pub enum ConditionType {
178 #[default]
180 #[strum(serialize = "minimum_subtotal", serialize = "minimumsubtotal")]
181 MinimumSubtotal,
182 #[strum(serialize = "minimum_quantity", serialize = "minimumquantity")]
184 MinimumQuantity,
185 #[strum(serialize = "product_in_cart", serialize = "productincart")]
187 ProductInCart,
188 #[strum(serialize = "category_in_cart", serialize = "categoryincart")]
190 CategoryInCart,
191 #[strum(serialize = "sku_in_cart", serialize = "skuincart")]
193 SkuInCart,
194 #[strum(serialize = "customer_group", serialize = "customergroup")]
196 CustomerGroup,
197 #[strum(serialize = "first_order", serialize = "firstorder")]
199 FirstOrder,
200 #[strum(serialize = "customer_email_domain", serialize = "customeremaildomain")]
202 CustomerEmailDomain,
203 #[strum(serialize = "shipping_country", serialize = "shippingcountry")]
205 ShippingCountry,
206 #[strum(serialize = "shipping_state", serialize = "shippingstate")]
208 ShippingState,
209 #[strum(serialize = "payment_method", serialize = "paymentmethod")]
211 PaymentMethod,
212 #[strum(serialize = "cart_item_count", serialize = "cartitemcount")]
214 CartItemCount,
215 #[strum(serialize = "customer_id", serialize = "customerid")]
217 CustomerId,
218}
219
220#[derive(Debug, Clone, Serialize, Deserialize)]
226pub struct Promotion {
227 pub id: PromotionId,
228 pub code: String,
230 pub name: String,
232 pub description: Option<String>,
234 pub internal_notes: Option<String>,
236
237 pub promotion_type: PromotionType,
239 pub trigger: PromotionTrigger,
240 pub target: PromotionTarget,
241 pub stacking: StackingBehavior,
242 pub status: PromotionStatus,
243
244 pub percentage_off: Option<Decimal>,
247 pub fixed_amount_off: Option<Decimal>,
249 pub max_discount_amount: Option<Decimal>,
251
252 pub buy_quantity: Option<i32>,
255 pub get_quantity: Option<i32>,
257 pub get_discount_percent: Option<Decimal>,
259
260 pub tiers: Option<Vec<DiscountTier>>,
263
264 pub bundle_product_ids: Option<Vec<ProductId>>,
267 pub bundle_discount: Option<Decimal>,
269
270 pub starts_at: DateTime<Utc>,
272 pub ends_at: Option<DateTime<Utc>>,
273
274 pub total_usage_limit: Option<i32>,
277 pub per_customer_limit: Option<i32>,
279 pub usage_count: i32,
281
282 pub conditions: Vec<PromotionCondition>,
284
285 pub applicable_product_ids: Vec<ProductId>,
288 pub applicable_category_ids: Vec<Uuid>,
290 pub applicable_skus: Vec<String>,
292 pub excluded_product_ids: Vec<ProductId>,
294 pub excluded_category_ids: Vec<Uuid>,
296
297 pub eligible_customer_ids: Vec<CustomerId>,
300 pub eligible_customer_groups: Vec<String>,
302
303 pub currency: CurrencyCode,
305
306 pub priority: i32,
308
309 pub metadata: Option<serde_json::Value>,
311 pub created_at: DateTime<Utc>,
312 pub updated_at: DateTime<Utc>,
313}
314
315#[derive(Debug, Clone, Serialize, Deserialize)]
317pub struct DiscountTier {
318 pub min_value: Decimal,
320 pub max_value: Option<Decimal>,
322 pub percentage_off: Option<Decimal>,
324 pub fixed_amount_off: Option<Decimal>,
326}
327
328#[derive(Debug, Clone, Serialize, Deserialize)]
330pub struct PromotionCondition {
331 pub id: Uuid,
332 pub promotion_id: PromotionId,
333 pub condition_type: ConditionType,
334 pub operator: ConditionOperator,
335 pub value: String,
337 pub is_required: bool,
339}
340
341#[derive(Debug, Clone, Serialize, Deserialize)]
347pub struct CouponCode {
348 pub id: Uuid,
349 pub promotion_id: PromotionId,
350 pub code: String,
352 pub status: CouponStatus,
353
354 pub usage_limit: Option<i32>,
356 pub per_customer_limit: Option<i32>,
357 pub usage_count: i32,
358
359 pub starts_at: Option<DateTime<Utc>>,
361 pub ends_at: Option<DateTime<Utc>>,
362
363 pub metadata: Option<serde_json::Value>,
365 pub created_at: DateTime<Utc>,
366 pub updated_at: DateTime<Utc>,
367}
368
369#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
371#[serde(rename_all = "snake_case")]
372#[non_exhaustive]
373pub enum CouponStatus {
374 #[default]
375 Active,
376 Disabled,
377 Exhausted,
378 Expired,
379}
380
381impl std::fmt::Display for CouponStatus {
382 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
383 match self {
384 Self::Active => write!(f, "active"),
385 Self::Disabled => write!(f, "disabled"),
386 Self::Exhausted => write!(f, "exhausted"),
387 Self::Expired => write!(f, "expired"),
388 }
389 }
390}
391
392impl std::str::FromStr for CouponStatus {
393 type Err = String;
394
395 fn from_str(s: &str) -> Result<Self, Self::Err> {
396 match s.trim().to_ascii_lowercase().as_str() {
397 "active" => Ok(Self::Active),
398 "disabled" => Ok(Self::Disabled),
399 "exhausted" => Ok(Self::Exhausted),
400 "expired" => Ok(Self::Expired),
401 _ => Err(format!("Unknown coupon status: {s}")),
402 }
403 }
404}
405
406#[derive(Debug, Clone, Serialize, Deserialize)]
412pub struct PromotionUsage {
413 pub id: Uuid,
414 pub promotion_id: PromotionId,
415 pub coupon_id: Option<Uuid>,
416 pub customer_id: Option<CustomerId>,
417 pub order_id: Option<OrderId>,
418 pub cart_id: Option<CartId>,
419
420 pub discount_amount: Decimal,
422 pub currency: CurrencyCode,
423
424 pub used_at: DateTime<Utc>,
425}
426
427#[derive(Debug, Clone, Serialize, Deserialize, Default)]
433pub struct ApplyPromotionsRequest {
434 pub cart_id: Option<CartId>,
435 pub customer_id: Option<CustomerId>,
436 pub coupon_codes: Vec<String>,
437 pub line_items: Vec<PromotionLineItem>,
438 pub subtotal: Decimal,
439 pub shipping_amount: Decimal,
440 pub shipping_country: Option<String>,
441 pub shipping_state: Option<String>,
442 pub currency: CurrencyCode,
443 pub is_first_order: bool,
444}
445
446#[derive(Debug, Clone, Serialize, Deserialize)]
448pub struct PromotionLineItem {
449 pub id: String,
450 pub product_id: Option<ProductId>,
451 pub variant_id: Option<Uuid>,
452 pub sku: Option<String>,
453 pub category_ids: Vec<Uuid>,
454 pub quantity: i32,
455 pub unit_price: Decimal,
456 pub line_total: Decimal,
457}
458
459#[derive(Debug, Clone, Serialize, Deserialize)]
461pub struct ApplyPromotionsResult {
462 pub original_subtotal: Decimal,
464 pub total_discount: Decimal,
466 pub discounted_subtotal: Decimal,
468 pub original_shipping: Decimal,
470 pub shipping_discount: Decimal,
472 pub final_shipping: Decimal,
474 pub grand_total: Decimal,
476 pub applied_promotions: Vec<AppliedPromotion>,
478 pub rejected_promotions: Vec<RejectedPromotion>,
480 pub line_item_discounts: Vec<LineItemDiscount>,
482}
483
484#[derive(Debug, Clone, Serialize, Deserialize)]
486pub struct AppliedPromotion {
487 pub promotion_id: PromotionId,
488 pub promotion_code: String,
489 pub promotion_name: String,
490 pub coupon_code: Option<String>,
491 pub discount_amount: Decimal,
492 pub discount_type: PromotionType,
493 pub target: PromotionTarget,
494 pub description: String,
496}
497
498#[derive(Debug, Clone, Serialize, Deserialize)]
500pub struct RejectedPromotion {
501 pub promotion_id: Option<PromotionId>,
502 pub coupon_code: Option<String>,
503 pub reason: String,
504 pub reason_code: RejectionReason,
505}
506
507#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
509#[serde(rename_all = "snake_case")]
510#[non_exhaustive]
511pub enum RejectionReason {
512 InvalidCode,
513 Expired,
514 NotYetActive,
515 UsageLimitReached,
516 CustomerLimitReached,
517 MinimumNotMet,
518 ProductNotEligible,
519 CustomerNotEligible,
520 NotStackable,
521 AlreadyApplied,
522 InternalError,
523}
524
525#[derive(Debug, Clone, Serialize, Deserialize)]
527pub struct LineItemDiscount {
528 pub line_item_id: String,
529 pub promotion_id: PromotionId,
530 pub original_price: Decimal,
531 pub discount_amount: Decimal,
532 pub final_price: Decimal,
533}
534
535#[derive(Debug, Clone, Serialize, Deserialize, Default)]
541pub struct CreatePromotion {
542 pub code: Option<String>,
543 pub name: String,
544 pub description: Option<String>,
545 pub internal_notes: Option<String>,
546
547 pub promotion_type: PromotionType,
548 pub trigger: PromotionTrigger,
549 pub target: PromotionTarget,
550 pub stacking: StackingBehavior,
551
552 pub percentage_off: Option<Decimal>,
554 pub fixed_amount_off: Option<Decimal>,
555 pub max_discount_amount: Option<Decimal>,
556
557 pub buy_quantity: Option<i32>,
559 pub get_quantity: Option<i32>,
560 pub get_discount_percent: Option<Decimal>,
561
562 pub tiers: Option<Vec<DiscountTier>>,
564
565 pub bundle_product_ids: Option<Vec<ProductId>>,
567 pub bundle_discount: Option<Decimal>,
568
569 pub starts_at: Option<DateTime<Utc>>,
571 pub ends_at: Option<DateTime<Utc>>,
572
573 pub total_usage_limit: Option<i32>,
575 pub per_customer_limit: Option<i32>,
576
577 pub conditions: Option<Vec<CreatePromotionCondition>>,
579
580 pub applicable_product_ids: Option<Vec<ProductId>>,
582 pub applicable_category_ids: Option<Vec<Uuid>>,
583 pub applicable_skus: Option<Vec<String>>,
584 pub excluded_product_ids: Option<Vec<ProductId>>,
585 pub excluded_category_ids: Option<Vec<Uuid>>,
586
587 pub eligible_customer_ids: Option<Vec<CustomerId>>,
589 pub eligible_customer_groups: Option<Vec<String>>,
590
591 pub currency: Option<CurrencyCode>,
592 pub priority: Option<i32>,
593 pub metadata: Option<serde_json::Value>,
594}
595
596#[derive(Debug, Clone, Serialize, Deserialize)]
598pub struct CreatePromotionCondition {
599 pub condition_type: ConditionType,
600 pub operator: ConditionOperator,
601 pub value: String,
602 pub is_required: bool,
603}
604
605#[derive(Debug, Clone, Serialize, Deserialize, Default)]
607pub struct UpdatePromotion {
608 pub name: Option<String>,
609 pub description: Option<String>,
610 pub internal_notes: Option<String>,
611 pub status: Option<PromotionStatus>,
612
613 pub percentage_off: Option<Decimal>,
614 pub fixed_amount_off: Option<Decimal>,
615 pub max_discount_amount: Option<Decimal>,
616
617 pub starts_at: Option<DateTime<Utc>>,
618 pub ends_at: Option<DateTime<Utc>>,
619
620 pub total_usage_limit: Option<i32>,
621 pub per_customer_limit: Option<i32>,
622
623 pub priority: Option<i32>,
624 pub metadata: Option<serde_json::Value>,
625}
626
627#[derive(Debug, Clone, Serialize, Deserialize)]
629pub struct CreateCouponCode {
630 pub promotion_id: PromotionId,
631 pub code: String,
632 pub usage_limit: Option<i32>,
633 pub per_customer_limit: Option<i32>,
634 pub starts_at: Option<DateTime<Utc>>,
635 pub ends_at: Option<DateTime<Utc>>,
636 pub metadata: Option<serde_json::Value>,
637}
638
639#[derive(Debug, Clone, Serialize, Deserialize, Default)]
641pub struct PromotionFilter {
642 pub status: Option<PromotionStatus>,
643 pub promotion_type: Option<PromotionType>,
644 pub trigger: Option<PromotionTrigger>,
645 pub is_active: Option<bool>,
646 pub search: Option<String>,
647 pub limit: Option<u32>,
648 pub offset: Option<u32>,
649}
650
651#[derive(Debug, Clone, Serialize, Deserialize, Default)]
653pub struct CouponFilter {
654 pub promotion_id: Option<PromotionId>,
655 pub status: Option<CouponStatus>,
656 pub search: Option<String>,
657 pub limit: Option<u32>,
658 pub offset: Option<u32>,
659}
660
661#[must_use]
667pub fn generate_promotion_code() -> String {
668 let id = Uuid::new_v4();
669 let bytes = id.as_bytes();
670 let random = u32::from_be_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]) % 10000;
671 let timestamp = chrono::Utc::now().timestamp_millis();
672 format!("PROMO-{}-{:04}", timestamp % 1000000, random)
673}
674
675#[must_use]
677pub fn generate_coupon_code(prefix: Option<&str>) -> String {
678 let chars: Vec<char> = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789".chars().collect();
679 let id = Uuid::new_v4();
680 let bytes = id.as_bytes();
681
682 let code: String = bytes[0..8]
683 .iter()
684 .map(|b| {
685 let idx = (*b as usize) % chars.len();
686 chars[idx]
687 })
688 .collect();
689
690 match prefix {
691 Some(p) => format!("{}{}", p.to_uppercase(), code),
692 None => code,
693 }
694}
695
696impl Promotion {
697 #[must_use]
700 pub fn has_product_scoping(&self) -> bool {
701 !self.applicable_product_ids.is_empty()
702 || !self.applicable_category_ids.is_empty()
703 || !self.applicable_skus.is_empty()
704 || !self.excluded_product_ids.is_empty()
705 || !self.excluded_category_ids.is_empty()
706 }
707
708 #[must_use]
714 pub fn item_in_scope(&self, item: &PromotionLineItem) -> bool {
715 if item.product_id.is_some_and(|p| self.excluded_product_ids.contains(&p)) {
716 return false;
717 }
718 if item.category_ids.iter().any(|c| self.excluded_category_ids.contains(c)) {
719 return false;
720 }
721
722 let has_applicability = !self.applicable_product_ids.is_empty()
723 || !self.applicable_category_ids.is_empty()
724 || !self.applicable_skus.is_empty();
725 if has_applicability {
726 let by_product =
727 item.product_id.is_some_and(|p| self.applicable_product_ids.contains(&p));
728 let by_category =
729 item.category_ids.iter().any(|c| self.applicable_category_ids.contains(c));
730 let by_sku =
731 item.sku.as_deref().is_some_and(|s| self.applicable_skus.iter().any(|a| a == s));
732 return by_product || by_category || by_sku;
733 }
734
735 true
736 }
737
738 #[must_use]
740 pub fn is_active(&self) -> bool {
741 if self.status != PromotionStatus::Active {
742 return false;
743 }
744
745 let now = Utc::now();
746 if now < self.starts_at {
747 return false;
748 }
749
750 if let Some(ends_at) = self.ends_at {
751 if now > ends_at {
752 return false;
753 }
754 }
755
756 if let Some(limit) = self.total_usage_limit {
758 if self.usage_count >= limit {
759 return false;
760 }
761 }
762
763 true
764 }
765
766 #[must_use]
768 pub fn discount_description(&self) -> String {
769 match self.promotion_type {
770 PromotionType::PercentageOff => {
771 if let Some(pct) = self.percentage_off {
772 format!("{}% off", (pct * Decimal::from(100)).round())
773 } else {
774 "Percentage discount".to_string()
775 }
776 }
777 PromotionType::FixedAmountOff => {
778 if let Some(amt) = self.fixed_amount_off {
779 format!("${amt} off")
780 } else {
781 "Fixed discount".to_string()
782 }
783 }
784 PromotionType::BuyXGetY => {
785 let buy = self.buy_quantity.unwrap_or(1);
786 let get = self.get_quantity.unwrap_or(1);
787 let discount = self.get_discount_percent.unwrap_or(Decimal::ONE);
788 if discount == Decimal::ONE {
789 format!("Buy {buy} get {get} free")
790 } else {
791 format!(
792 "Buy {} get {} at {}% off",
793 buy,
794 get,
795 (discount * Decimal::from(100)).round()
796 )
797 }
798 }
799 PromotionType::FreeShipping => "Free shipping".to_string(),
800 PromotionType::TieredDiscount => "Tiered discount".to_string(),
801 PromotionType::BundleDiscount => "Bundle discount".to_string(),
802 PromotionType::FirstOrderDiscount => {
803 if let Some(pct) = self.percentage_off {
804 format!("{}% off first order", (pct * Decimal::from(100)).round())
805 } else if let Some(amt) = self.fixed_amount_off {
806 format!("${amt} off first order")
807 } else {
808 "First order discount".to_string()
809 }
810 }
811 PromotionType::GiftWithPurchase => "Gift with purchase".to_string(),
812 }
813 }
814}
815
816impl Default for ApplyPromotionsResult {
817 fn default() -> Self {
818 Self {
819 original_subtotal: Decimal::ZERO,
820 total_discount: Decimal::ZERO,
821 discounted_subtotal: Decimal::ZERO,
822 original_shipping: Decimal::ZERO,
823 shipping_discount: Decimal::ZERO,
824 final_shipping: Decimal::ZERO,
825 grand_total: Decimal::ZERO,
826 applied_promotions: Vec::new(),
827 rejected_promotions: Vec::new(),
828 line_item_discounts: Vec::new(),
829 }
830 }
831}
832
833#[cfg(test)]
834mod tests {
835 use super::*;
836 use std::str::FromStr;
837
838 #[test]
839 fn test_promotion_type_from_str() {
840 assert_eq!(
841 PromotionType::from_str("percentage_off").unwrap(),
842 PromotionType::PercentageOff
843 );
844 assert_eq!(PromotionType::from_str("buyxgety").unwrap(), PromotionType::BuyXGetY);
845 }
846
847 #[test]
848 fn test_promotion_trigger_from_str() {
849 assert_eq!(
850 PromotionTrigger::from_str("coupon_code").unwrap(),
851 PromotionTrigger::CouponCode
852 );
853 assert_eq!(PromotionTrigger::from_str("couponcode").unwrap(), PromotionTrigger::CouponCode);
854 }
855
856 #[test]
857 fn test_condition_operator_from_str() {
858 assert_eq!(
859 ConditionOperator::from_str("greater_than_or_equal").unwrap(),
860 ConditionOperator::GreaterThanOrEqual
861 );
862 assert_eq!(
863 ConditionOperator::from_str("greaterthanorequal").unwrap(),
864 ConditionOperator::GreaterThanOrEqual
865 );
866 }
867
868 #[test]
869 fn test_condition_type_from_str() {
870 assert_eq!(
871 ConditionType::from_str("minimum_subtotal").unwrap(),
872 ConditionType::MinimumSubtotal
873 );
874 assert_eq!(
875 ConditionType::from_str("minimumsubtotal").unwrap(),
876 ConditionType::MinimumSubtotal
877 );
878 }
879
880 #[test]
881 fn test_coupon_status_from_str() {
882 assert_eq!(CouponStatus::from_str("active").unwrap(), CouponStatus::Active);
883 assert_eq!(CouponStatus::from_str("exhausted").unwrap(), CouponStatus::Exhausted);
884 }
885}