Skip to main content

stateset_core/models/
gift_card.rs

1//! Gift card domain models
2//!
3//! Handles gift card issuance, balance tracking, and transaction processing.
4
5use chrono::{DateTime, Utc};
6use rust_decimal::Decimal;
7use serde::{Deserialize, Serialize};
8use stateset_primitives::{CurrencyCode, GiftCardId, GiftCardTransactionId};
9use strum::{Display, EnumString};
10
11/// Gift card status
12#[derive(
13    Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, Display, EnumString,
14)]
15#[serde(rename_all = "snake_case")]
16#[strum(serialize_all = "snake_case", ascii_case_insensitive)]
17#[non_exhaustive]
18pub enum GiftCardStatus {
19    /// Gift card is active and can be used
20    #[default]
21    Active,
22    /// Gift card balance has been fully consumed
23    Depleted,
24    /// Gift card has passed its expiration date
25    Expired,
26    /// Gift card has been administratively disabled
27    Disabled,
28}
29
30/// Gift card transaction type
31#[derive(
32    Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, Display, EnumString,
33)]
34#[serde(rename_all = "snake_case")]
35#[strum(serialize_all = "snake_case", ascii_case_insensitive)]
36#[non_exhaustive]
37pub enum GiftCardTransactionType {
38    /// Charge against the gift card balance
39    #[default]
40    Charge,
41    /// Refund back to the gift card
42    Refund,
43    /// Manual balance adjustment
44    Adjustment,
45}
46
47/// A gift card
48#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct GiftCard {
50    /// Unique gift card ID
51    pub id: GiftCardId,
52    /// Redemption code
53    pub code: String,
54    /// Original balance when issued
55    pub initial_balance: Decimal,
56    /// Current remaining balance
57    pub current_balance: Decimal,
58    /// Currency code (e.g. "USD")
59    pub currency: CurrencyCode,
60    /// Gift card status
61    pub status: GiftCardStatus,
62    /// Recipient email address
63    pub recipient_email: Option<String>,
64    /// Name of the sender
65    pub sender_name: Option<String>,
66    /// Personal message from sender
67    pub message: Option<String>,
68    /// When the gift card expires
69    pub expires_at: Option<DateTime<Utc>>,
70    /// When the gift card was created
71    pub created_at: DateTime<Utc>,
72    /// When the gift card was last updated
73    pub updated_at: DateTime<Utc>,
74}
75
76impl GiftCard {
77    /// Check if the gift card is active
78    #[must_use]
79    pub fn is_active(&self) -> bool {
80        self.status == GiftCardStatus::Active
81    }
82
83    /// Check if the gift card has expired
84    #[must_use]
85    pub fn is_expired(&self) -> bool {
86        if let Some(expires_at) = self.expires_at { Utc::now() > expires_at } else { false }
87    }
88
89    /// Check if a charge of the given amount can be applied
90    #[must_use]
91    pub fn can_charge(&self, amount: Decimal) -> bool {
92        self.is_active() && self.current_balance >= amount
93    }
94}
95
96/// A gift card transaction
97#[derive(Debug, Clone, Serialize, Deserialize)]
98pub struct GiftCardTransaction {
99    /// Unique transaction ID
100    pub id: GiftCardTransactionId,
101    /// Associated gift card ID
102    pub gift_card_id: GiftCardId,
103    /// Transaction amount
104    pub amount: Decimal,
105    /// Balance after this transaction
106    pub balance_after: Decimal,
107    /// Type of transaction
108    pub transaction_type: GiftCardTransactionType,
109    /// External reference (e.g. order ID)
110    pub reference_id: Option<String>,
111    /// When the transaction was created
112    pub created_at: DateTime<Utc>,
113}
114
115/// Input for creating a gift card
116#[derive(Debug, Clone, Serialize, Deserialize)]
117pub struct CreateGiftCard {
118    /// Redemption code (auto-generated if not provided)
119    pub code: Option<String>,
120    /// Initial balance
121    pub initial_balance: Decimal,
122    /// Currency code
123    pub currency: CurrencyCode,
124    /// Recipient email address
125    pub recipient_email: Option<String>,
126    /// Name of the sender
127    pub sender_name: Option<String>,
128    /// Personal message from sender
129    pub message: Option<String>,
130    /// When the gift card expires
131    pub expires_at: Option<DateTime<Utc>>,
132}
133
134/// Input for updating a gift card
135#[derive(Debug, Clone, Default, Serialize, Deserialize)]
136pub struct UpdateGiftCard {
137    /// Update status
138    pub status: Option<GiftCardStatus>,
139    /// Update recipient email
140    pub recipient_email: Option<String>,
141    /// Update expiration (Some(None) to clear, Some(Some(dt)) to set)
142    pub expires_at: Option<Option<DateTime<Utc>>>,
143}
144
145/// Filter for listing gift cards
146#[derive(Debug, Clone, Default, Serialize, Deserialize)]
147pub struct GiftCardFilter {
148    /// Filter by status
149    pub status: Option<GiftCardStatus>,
150    /// Filter by redemption code
151    pub code: Option<String>,
152    /// Limit results
153    pub limit: Option<u32>,
154    /// Offset for pagination
155    pub offset: Option<u32>,
156}
157
158#[cfg(test)]
159mod tests {
160    use super::*;
161    use chrono::{Duration, Utc};
162    use rust_decimal_macros::dec;
163    use stateset_primitives::GiftCardId;
164
165    fn make_test_gift_card() -> GiftCard {
166        GiftCard {
167            id: GiftCardId::new(),
168            code: "TEST-1234".to_string(),
169            initial_balance: dec!(100.00),
170            current_balance: dec!(100.00),
171            currency: CurrencyCode::USD,
172            status: GiftCardStatus::Active,
173            recipient_email: None,
174            sender_name: None,
175            message: None,
176            expires_at: None,
177            created_at: Utc::now(),
178            updated_at: Utc::now(),
179        }
180    }
181
182    // ---- is_active ----
183
184    #[test]
185    fn is_active_returns_true_when_active() {
186        let gc = make_test_gift_card();
187        assert!(gc.is_active());
188    }
189
190    #[test]
191    fn is_active_returns_false_when_disabled() {
192        let gc = GiftCard { status: GiftCardStatus::Disabled, ..make_test_gift_card() };
193        assert!(!gc.is_active());
194    }
195
196    #[test]
197    fn is_active_returns_false_when_depleted() {
198        let gc = GiftCard { status: GiftCardStatus::Depleted, ..make_test_gift_card() };
199        assert!(!gc.is_active());
200    }
201
202    #[test]
203    fn is_active_returns_false_when_expired_status() {
204        let gc = GiftCard { status: GiftCardStatus::Expired, ..make_test_gift_card() };
205        assert!(!gc.is_active());
206    }
207
208    // ---- is_expired ----
209
210    #[test]
211    fn is_expired_returns_true_for_past_date() {
212        let gc =
213            GiftCard { expires_at: Some(Utc::now() - Duration::hours(1)), ..make_test_gift_card() };
214        assert!(gc.is_expired());
215    }
216
217    #[test]
218    fn is_expired_returns_false_for_future_date() {
219        let gc =
220            GiftCard { expires_at: Some(Utc::now() + Duration::days(30)), ..make_test_gift_card() };
221        assert!(!gc.is_expired());
222    }
223
224    #[test]
225    fn is_expired_returns_false_when_no_expiry() {
226        let gc = make_test_gift_card();
227        assert!(!gc.is_expired());
228    }
229
230    // ---- can_charge ----
231
232    #[test]
233    fn can_charge_succeeds_with_sufficient_balance() {
234        let gc = GiftCard {
235            status: GiftCardStatus::Active,
236            current_balance: dec!(50.00),
237            ..make_test_gift_card()
238        };
239        assert!(gc.can_charge(dec!(25.00)));
240    }
241
242    #[test]
243    fn can_charge_succeeds_with_exact_balance() {
244        let gc = GiftCard {
245            status: GiftCardStatus::Active,
246            current_balance: dec!(25.00),
247            ..make_test_gift_card()
248        };
249        assert!(gc.can_charge(dec!(25.00)));
250    }
251
252    #[test]
253    fn can_charge_fails_with_insufficient_balance() {
254        let gc = GiftCard {
255            status: GiftCardStatus::Active,
256            current_balance: dec!(10.00),
257            ..make_test_gift_card()
258        };
259        assert!(!gc.can_charge(dec!(25.00)));
260    }
261
262    #[test]
263    fn can_charge_fails_when_inactive() {
264        let gc = GiftCard {
265            status: GiftCardStatus::Disabled,
266            current_balance: dec!(100.00),
267            ..make_test_gift_card()
268        };
269        assert!(!gc.can_charge(dec!(10.00)));
270    }
271
272    // ---- enum Display / FromStr round-trips ----
273
274    #[test]
275    fn gift_card_status_display_fromstr_roundtrip() {
276        for status in [
277            GiftCardStatus::Active,
278            GiftCardStatus::Depleted,
279            GiftCardStatus::Expired,
280            GiftCardStatus::Disabled,
281        ] {
282            let s = status.to_string();
283            let parsed: GiftCardStatus = s.parse().unwrap();
284            assert_eq!(parsed, status, "round-trip failed for {s}");
285        }
286    }
287
288    #[test]
289    fn gift_card_transaction_type_display_fromstr_roundtrip() {
290        for tx_type in [
291            GiftCardTransactionType::Charge,
292            GiftCardTransactionType::Refund,
293            GiftCardTransactionType::Adjustment,
294        ] {
295            let s = tx_type.to_string();
296            let parsed: GiftCardTransactionType = s.parse().unwrap();
297            assert_eq!(parsed, tx_type, "round-trip failed for {s}");
298        }
299    }
300
301    // ---- Default ----
302
303    #[test]
304    fn gift_card_status_default_is_active() {
305        assert_eq!(GiftCardStatus::default(), GiftCardStatus::Active);
306    }
307
308    #[test]
309    fn gift_card_transaction_type_default_is_charge() {
310        assert_eq!(GiftCardTransactionType::default(), GiftCardTransactionType::Charge);
311    }
312}