1use chrono::{DateTime, Utc};
6use rust_decimal::Decimal;
7use serde::{Deserialize, Serialize};
8use stateset_primitives::{CurrencyCode, GiftCardId, GiftCardTransactionId};
9use strum::{Display, EnumString};
10
11#[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 #[default]
21 Active,
22 Depleted,
24 Expired,
26 Disabled,
28}
29
30#[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 #[default]
40 Charge,
41 Refund,
43 Adjustment,
45}
46
47#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct GiftCard {
50 pub id: GiftCardId,
52 pub code: String,
54 pub initial_balance: Decimal,
56 pub current_balance: Decimal,
58 pub currency: CurrencyCode,
60 pub status: GiftCardStatus,
62 pub recipient_email: Option<String>,
64 pub sender_name: Option<String>,
66 pub message: Option<String>,
68 pub expires_at: Option<DateTime<Utc>>,
70 pub created_at: DateTime<Utc>,
72 pub updated_at: DateTime<Utc>,
74}
75
76impl GiftCard {
77 #[must_use]
79 pub fn is_active(&self) -> bool {
80 self.status == GiftCardStatus::Active
81 }
82
83 #[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 #[must_use]
91 pub fn can_charge(&self, amount: Decimal) -> bool {
92 self.is_active() && self.current_balance >= amount
93 }
94}
95
96#[derive(Debug, Clone, Serialize, Deserialize)]
98pub struct GiftCardTransaction {
99 pub id: GiftCardTransactionId,
101 pub gift_card_id: GiftCardId,
103 pub amount: Decimal,
105 pub balance_after: Decimal,
107 pub transaction_type: GiftCardTransactionType,
109 pub reference_id: Option<String>,
111 pub created_at: DateTime<Utc>,
113}
114
115#[derive(Debug, Clone, Serialize, Deserialize)]
117pub struct CreateGiftCard {
118 pub code: Option<String>,
120 pub initial_balance: Decimal,
122 pub currency: CurrencyCode,
124 pub recipient_email: Option<String>,
126 pub sender_name: Option<String>,
128 pub message: Option<String>,
130 pub expires_at: Option<DateTime<Utc>>,
132}
133
134#[derive(Debug, Clone, Default, Serialize, Deserialize)]
136pub struct UpdateGiftCard {
137 pub status: Option<GiftCardStatus>,
139 pub recipient_email: Option<String>,
141 pub expires_at: Option<Option<DateTime<Utc>>>,
143}
144
145#[derive(Debug, Clone, Default, Serialize, Deserialize)]
147pub struct GiftCardFilter {
148 pub status: Option<GiftCardStatus>,
150 pub code: Option<String>,
152 pub limit: Option<u32>,
154 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 #[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 #[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 #[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 #[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 #[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}