stateset_core/
errors.rs

1//! Error types for commerce operations
2
3use serde::{Deserialize, Serialize};
4use thiserror::Error;
5use uuid::Uuid;
6
7/// Main error type for commerce operations
8#[derive(Error, Debug)]
9pub enum CommerceError {
10    // Order errors
11    #[error("Order not found: {0}")]
12    OrderNotFound(Uuid),
13
14    #[error("Order cannot be cancelled in status: {0}")]
15    OrderCannotBeCancelled(String),
16
17    #[error("Order cannot be refunded: {0}")]
18    OrderCannotBeRefunded(String),
19
20    #[error("Invalid order status transition from {from} to {to}")]
21    InvalidOrderStatusTransition { from: String, to: String },
22
23    // Inventory errors
24    #[error("Inventory item not found: {0}")]
25    InventoryItemNotFound(String),
26
27    #[error("Insufficient stock for SKU {sku}: requested {requested}, available {available}")]
28    InsufficientStock {
29        sku: String,
30        requested: String,
31        available: String,
32    },
33
34    #[error("Inventory reservation not found: {0}")]
35    ReservationNotFound(Uuid),
36
37    #[error("Inventory reservation expired: {0}")]
38    ReservationExpired(Uuid),
39
40    #[error("Duplicate SKU: {0}")]
41    DuplicateSku(String),
42
43    // Customer errors
44    #[error("Customer not found: {0}")]
45    CustomerNotFound(Uuid),
46
47    #[error("Email already exists: {0}")]
48    EmailAlreadyExists(String),
49
50    #[error("Customer is not active")]
51    CustomerNotActive,
52
53    // Product errors
54    #[error("Product not found: {0}")]
55    ProductNotFound(Uuid),
56
57    #[error("Product variant not found: {0}")]
58    ProductVariantNotFound(Uuid),
59
60    #[error("Duplicate product slug: {0}")]
61    DuplicateSlug(String),
62
63    #[error("Product is not purchasable")]
64    ProductNotPurchasable,
65
66    // Return errors
67    #[error("Return not found: {0}")]
68    ReturnNotFound(Uuid),
69
70    #[error("Return cannot be approved in status: {0}")]
71    ReturnCannotBeApproved(String),
72
73    #[error("Return period expired")]
74    ReturnPeriodExpired,
75
76    #[error("Item not eligible for return")]
77    ItemNotEligibleForReturn,
78
79    // Validation errors
80    #[error("Validation error: {0}")]
81    ValidationError(String),
82
83    #[error("Invalid input: {field} - {message}")]
84    InvalidInput { field: String, message: String },
85
86    // Database/storage errors
87    #[error("Database error: {0}")]
88    DatabaseError(String),
89
90    #[error("Record not found")]
91    NotFound,
92
93    #[error("Conflict: {0}")]
94    Conflict(String),
95
96    #[error("Optimistic lock failure: record was modified")]
97    OptimisticLockFailure,
98
99    #[error("Version conflict on {entity} {id}: expected version {expected_version}")]
100    VersionConflict {
101        entity: String,
102        id: String,
103        expected_version: i32,
104    },
105
106    // General errors
107    #[error("Internal error: {0}")]
108    Internal(String),
109
110    #[error("Operation not permitted: {0}")]
111    NotPermitted(String),
112}
113
114/// Result type alias for commerce operations
115pub type Result<T> = std::result::Result<T, CommerceError>;
116
117impl CommerceError {
118    /// Check if error is a not found error
119    pub fn is_not_found(&self) -> bool {
120        matches!(
121            self,
122            Self::NotFound
123                | Self::OrderNotFound(_)
124                | Self::CustomerNotFound(_)
125                | Self::ProductNotFound(_)
126                | Self::ProductVariantNotFound(_)
127                | Self::ReturnNotFound(_)
128                | Self::InventoryItemNotFound(_)
129                | Self::ReservationNotFound(_)
130        )
131    }
132
133    /// Check if error is a validation error
134    pub fn is_validation(&self) -> bool {
135        matches!(self, Self::ValidationError(_) | Self::InvalidInput { .. })
136    }
137
138    /// Check if error is a conflict error
139    pub fn is_conflict(&self) -> bool {
140        matches!(
141            self,
142            Self::Conflict(_)
143                | Self::OptimisticLockFailure
144                | Self::VersionConflict { .. }
145                | Self::DuplicateSku(_)
146                | Self::DuplicateSlug(_)
147                | Self::EmailAlreadyExists(_)
148        )
149    }
150}
151
152// ============================================================================
153// Batch Operation Types
154// ============================================================================
155
156/// Maximum items allowed per batch operation
157pub const MAX_BATCH_SIZE: usize = 1000;
158
159/// Categorized batch error codes for programmatic handling
160#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
161#[serde(rename_all = "snake_case")]
162pub enum BatchErrorCode {
163    /// Entity was not found
164    NotFound,
165    /// Input validation failed
166    ValidationError,
167    /// Duplicate key constraint violation
168    DuplicateKey,
169    /// Optimistic locking version conflict
170    VersionConflict,
171    /// Database-level error
172    DatabaseError,
173    /// Unclassified internal error
174    InternalError,
175}
176
177impl From<&CommerceError> for BatchErrorCode {
178    fn from(err: &CommerceError) -> Self {
179        match err {
180            CommerceError::NotFound
181            | CommerceError::OrderNotFound(_)
182            | CommerceError::CustomerNotFound(_)
183            | CommerceError::ProductNotFound(_)
184            | CommerceError::ProductVariantNotFound(_)
185            | CommerceError::ReturnNotFound(_)
186            | CommerceError::InventoryItemNotFound(_)
187            | CommerceError::ReservationNotFound(_) => BatchErrorCode::NotFound,
188
189            CommerceError::ValidationError(_) | CommerceError::InvalidInput { .. } => {
190                BatchErrorCode::ValidationError
191            }
192
193            CommerceError::DuplicateSku(_)
194            | CommerceError::DuplicateSlug(_)
195            | CommerceError::EmailAlreadyExists(_) => BatchErrorCode::DuplicateKey,
196
197            CommerceError::VersionConflict { .. } | CommerceError::OptimisticLockFailure => {
198                BatchErrorCode::VersionConflict
199            }
200
201            CommerceError::DatabaseError(_) => BatchErrorCode::DatabaseError,
202
203            _ => BatchErrorCode::InternalError,
204        }
205    }
206}
207
208/// Error information for a single item in a batch operation
209#[derive(Debug, Clone, Serialize, Deserialize)]
210pub struct BatchError {
211    /// Index in the original batch (for create/update operations)
212    pub index: usize,
213    /// ID of the entity (for update/delete/get operations, if available)
214    pub id: Option<String>,
215    /// Human-readable error message
216    pub error: String,
217    /// Error code for programmatic handling
218    pub code: BatchErrorCode,
219}
220
221impl BatchError {
222    /// Create a new BatchError from an index and CommerceError
223    pub fn from_error(index: usize, id: Option<String>, err: &CommerceError) -> Self {
224        Self {
225            index,
226            id,
227            error: err.to_string(),
228            code: BatchErrorCode::from(err),
229        }
230    }
231}
232
233/// Result of a batch operation that allows partial success
234#[derive(Debug, Clone, Serialize, Deserialize)]
235pub struct BatchResult<T> {
236    /// Successfully processed items
237    pub succeeded: Vec<T>,
238    /// Failed operations with their errors
239    pub failed: Vec<BatchError>,
240    /// Total items attempted
241    pub total_attempted: usize,
242    /// Count of successful operations
243    pub success_count: usize,
244    /// Count of failed operations
245    pub failure_count: usize,
246}
247
248impl<T> BatchResult<T> {
249    /// Create a new empty BatchResult
250    pub fn new() -> Self {
251        Self {
252            succeeded: Vec::new(),
253            failed: Vec::new(),
254            total_attempted: 0,
255            success_count: 0,
256            failure_count: 0,
257        }
258    }
259
260    /// Create a BatchResult with pre-allocated capacity
261    pub fn with_capacity(capacity: usize) -> Self {
262        Self {
263            succeeded: Vec::with_capacity(capacity),
264            failed: Vec::new(),
265            total_attempted: 0,
266            success_count: 0,
267            failure_count: 0,
268        }
269    }
270
271    /// Record a successful operation
272    pub fn record_success(&mut self, item: T) {
273        self.succeeded.push(item);
274        self.success_count += 1;
275        self.total_attempted += 1;
276    }
277
278    /// Record a failed operation
279    pub fn record_failure(&mut self, index: usize, id: Option<String>, err: &CommerceError) {
280        self.failed.push(BatchError::from_error(index, id, err));
281        self.failure_count += 1;
282        self.total_attempted += 1;
283    }
284
285    /// Check if all operations succeeded
286    pub fn all_succeeded(&self) -> bool {
287        self.failure_count == 0
288    }
289
290    /// Check if all operations failed
291    pub fn all_failed(&self) -> bool {
292        self.success_count == 0 && self.total_attempted > 0
293    }
294
295    /// Check if some operations succeeded and some failed
296    pub fn partial_success(&self) -> bool {
297        self.success_count > 0 && self.failure_count > 0
298    }
299
300    /// Check if the batch was empty
301    pub fn is_empty(&self) -> bool {
302        self.total_attempted == 0
303    }
304}
305
306impl<T> Default for BatchResult<T> {
307    fn default() -> Self {
308        Self::new()
309    }
310}
311
312/// Validate batch size against maximum limit
313pub fn validate_batch_size<T>(items: &[T]) -> Result<()> {
314    if items.len() > MAX_BATCH_SIZE {
315        return Err(CommerceError::ValidationError(format!(
316            "Batch size {} exceeds maximum of {}",
317            items.len(),
318            MAX_BATCH_SIZE
319        )));
320    }
321    Ok(())
322}
323
324/// Validate a required text field for non-empty content and length.
325pub fn validate_required_text(field: &str, value: &str, max_len: usize) -> Result<()> {
326    let trimmed = value.trim();
327    if trimmed.is_empty() {
328        return Err(CommerceError::InvalidInput {
329            field: field.to_string(),
330            message: "cannot be empty".into(),
331        });
332    }
333
334    if trimmed.len() > max_len {
335        return Err(CommerceError::InvalidInput {
336            field: field.to_string(),
337            message: format!("cannot exceed {} characters", max_len),
338        });
339    }
340
341    Ok(())
342}
343
344/// Validate that a UUID is not the nil (all-zero) value.
345pub fn validate_required_uuid(field: &str, value: Uuid) -> Result<()> {
346    if value.is_nil() {
347        return Err(CommerceError::InvalidInput {
348            field: field.to_string(),
349            message: "cannot be nil".into(),
350        });
351    }
352
353    Ok(())
354}
355
356/// Validate an email address format
357///
358/// Performs basic email validation checking for:
359/// - Non-empty string
360/// - Contains exactly one @ symbol
361/// - Has non-empty local and domain parts
362/// - Domain contains at least one dot
363/// - No whitespace characters
364///
365/// # Example
366///
367/// ```
368/// use stateset_core::validate_email;
369///
370/// assert!(validate_email("user@example.com").is_ok());
371/// assert!(validate_email("invalid").is_err());
372/// assert!(validate_email("").is_err());
373/// ```
374pub fn validate_email(email: &str) -> Result<()> {
375    let email = email.trim();
376
377    if email.is_empty() {
378        return Err(CommerceError::ValidationError("Email cannot be empty".into()));
379    }
380
381    if email.contains(char::is_whitespace) {
382        return Err(CommerceError::ValidationError("Email cannot contain whitespace".into()));
383    }
384
385    let parts: Vec<&str> = email.split('@').collect();
386    if parts.len() != 2 {
387        return Err(CommerceError::ValidationError(
388            "Email must contain exactly one @ symbol".into()
389        ));
390    }
391
392    let (local, domain) = (parts[0], parts[1]);
393
394    if local.is_empty() {
395        return Err(CommerceError::ValidationError(
396            "Email local part (before @) cannot be empty".into()
397        ));
398    }
399
400    if domain.is_empty() {
401        return Err(CommerceError::ValidationError(
402            "Email domain (after @) cannot be empty".into()
403        ));
404    }
405
406    if !domain.contains('.') {
407        return Err(CommerceError::ValidationError(
408            "Email domain must contain at least one dot".into()
409        ));
410    }
411
412    // Check domain doesn't start or end with a dot
413    if domain.starts_with('.') || domain.ends_with('.') {
414        return Err(CommerceError::ValidationError(
415            "Email domain cannot start or end with a dot".into()
416        ));
417    }
418
419    Ok(())
420}
421
422/// Validate a SKU format
423///
424/// SKUs must:
425/// - Be non-empty
426/// - Be 1-100 characters
427/// - Contain only alphanumeric characters, hyphens, and underscores
428///
429/// # Example
430///
431/// ```
432/// use stateset_core::validate_sku;
433///
434/// assert!(validate_sku("SKU-001").is_ok());
435/// assert!(validate_sku("WIDGET_BLUE_XL").is_ok());
436/// assert!(validate_sku("").is_err());
437/// assert!(validate_sku("sku with spaces").is_err());
438/// ```
439pub fn validate_sku(sku: &str) -> Result<()> {
440    let sku = sku.trim();
441
442    if sku.is_empty() {
443        return Err(CommerceError::ValidationError("SKU cannot be empty".into()));
444    }
445
446    if sku.len() > 100 {
447        return Err(CommerceError::ValidationError(
448            "SKU cannot exceed 100 characters".into()
449        ));
450    }
451
452    if !sku.chars().all(|c| c.is_alphanumeric() || c == '-' || c == '_') {
453        return Err(CommerceError::ValidationError(
454            "SKU can only contain alphanumeric characters, hyphens, and underscores".into()
455        ));
456    }
457
458    Ok(())
459}
460
461/// Validate a phone number format (basic validation)
462///
463/// This performs basic phone number validation:
464/// - Non-empty
465/// - Contains only digits, spaces, parentheses, hyphens, and plus sign
466/// - Has at least 7 digits (minimum for local numbers)
467/// - Has at most 15 digits (ITU-T E.164 standard)
468///
469/// # Example
470///
471/// ```
472/// use stateset_core::validate_phone;
473///
474/// assert!(validate_phone("+1 (555) 123-4567").is_ok());
475/// assert!(validate_phone("5551234567").is_ok());
476/// assert!(validate_phone("123").is_err()); // Too short
477/// assert!(validate_phone("").is_err());
478/// ```
479pub fn validate_phone(phone: &str) -> Result<()> {
480    let phone = phone.trim();
481
482    if phone.is_empty() {
483        return Err(CommerceError::ValidationError("Phone number cannot be empty".into()));
484    }
485
486    // Check for valid characters
487    if !phone.chars().all(|c| c.is_ascii_digit() || c == ' ' || c == '-' || c == '(' || c == ')' || c == '+') {
488        return Err(CommerceError::ValidationError(
489            "Phone number contains invalid characters".into()
490        ));
491    }
492
493    // Count digits
494    let digit_count = phone.chars().filter(|c| c.is_ascii_digit()).count();
495
496    if digit_count < 7 {
497        return Err(CommerceError::ValidationError(
498            "Phone number must have at least 7 digits".into()
499        ));
500    }
501
502    if digit_count > 15 {
503        return Err(CommerceError::ValidationError(
504            "Phone number cannot exceed 15 digits".into()
505        ));
506    }
507
508    Ok(())
509}
510
511/// Validate a currency code (ISO 4217 format)
512///
513/// Currency codes must be exactly 3 uppercase letters.
514///
515/// # Example
516///
517/// ```
518/// use stateset_core::validate_currency_code;
519///
520/// assert!(validate_currency_code("USD").is_ok());
521/// assert!(validate_currency_code("EUR").is_ok());
522/// assert!(validate_currency_code("usd").is_err()); // lowercase
523/// assert!(validate_currency_code("US").is_err()); // too short
524/// assert!(validate_currency_code("USDD").is_err()); // too long
525/// ```
526pub fn validate_currency_code(code: &str) -> Result<()> {
527    if code.len() != 3 {
528        return Err(CommerceError::ValidationError(
529            "Currency code must be exactly 3 characters".into()
530        ));
531    }
532
533    if !code.chars().all(|c| c.is_ascii_uppercase()) {
534        return Err(CommerceError::ValidationError(
535            "Currency code must be uppercase letters only".into()
536        ));
537    }
538
539    Ok(())
540}
541
542/// Validate a postal/ZIP code format (basic validation)
543///
544/// This performs basic postal code validation:
545/// - Non-empty
546/// - 3-10 characters
547/// - Contains only alphanumeric characters, spaces, and hyphens
548///
549/// Note: This is a generic validator. For country-specific validation,
550/// use dedicated validators.
551///
552/// # Example
553///
554/// ```
555/// use stateset_core::validate_postal_code;
556///
557/// assert!(validate_postal_code("12345").is_ok());
558/// assert!(validate_postal_code("12345-6789").is_ok());
559/// assert!(validate_postal_code("SW1A 1AA").is_ok()); // UK format
560/// assert!(validate_postal_code("").is_err());
561/// ```
562pub fn validate_postal_code(code: &str) -> Result<()> {
563    let code = code.trim();
564
565    if code.is_empty() {
566        return Err(CommerceError::ValidationError("Postal code cannot be empty".into()));
567    }
568
569    if code.len() < 3 {
570        return Err(CommerceError::ValidationError(
571            "Postal code must be at least 3 characters".into()
572        ));
573    }
574
575    if code.len() > 10 {
576        return Err(CommerceError::ValidationError(
577            "Postal code cannot exceed 10 characters".into()
578        ));
579    }
580
581    if !code.chars().all(|c| c.is_alphanumeric() || c == ' ' || c == '-') {
582        return Err(CommerceError::ValidationError(
583            "Postal code contains invalid characters".into()
584        ));
585    }
586
587    Ok(())
588}
589
590/// Validate a quantity value
591///
592/// Quantities must be positive (greater than zero).
593///
594/// # Example
595///
596/// ```
597/// use stateset_core::validate_quantity;
598/// use rust_decimal_macros::dec;
599///
600/// assert!(validate_quantity(dec!(1)).is_ok());
601/// assert!(validate_quantity(dec!(0.5)).is_ok());
602/// assert!(validate_quantity(dec!(0)).is_err());
603/// assert!(validate_quantity(dec!(-1)).is_err());
604/// ```
605pub fn validate_quantity(qty: rust_decimal::Decimal) -> Result<()> {
606    if qty <= rust_decimal::Decimal::ZERO {
607        return Err(CommerceError::ValidationError(
608            "Quantity must be greater than zero".into()
609        ));
610    }
611    Ok(())
612}
613
614/// Validate a price/amount value
615///
616/// Prices must be non-negative (zero or greater).
617///
618/// # Example
619///
620/// ```
621/// use stateset_core::validate_price;
622/// use rust_decimal_macros::dec;
623///
624/// assert!(validate_price(dec!(0)).is_ok());
625/// assert!(validate_price(dec!(99.99)).is_ok());
626/// assert!(validate_price(dec!(-1)).is_err());
627/// ```
628pub fn validate_price(price: rust_decimal::Decimal) -> Result<()> {
629    if price < rust_decimal::Decimal::ZERO {
630        return Err(CommerceError::ValidationError(
631            "Price cannot be negative".into()
632        ));
633    }
634    Ok(())
635}
636
637#[cfg(test)]
638mod tests {
639    use super::*;
640
641    #[test]
642    fn validate_required_text_rejects_empty() {
643        let result = validate_required_text("field", "   ", 10);
644        assert!(result.is_err());
645    }
646
647    #[test]
648    fn validate_required_text_rejects_too_long() {
649        let result = validate_required_text("field", "toolong", 3);
650        assert!(result.is_err());
651    }
652
653    #[test]
654    fn validate_required_text_accepts_trimmed() {
655        let result = validate_required_text("field", "  ok  ", 10);
656        assert!(result.is_ok());
657    }
658
659    #[test]
660    fn validate_required_uuid_rejects_nil() {
661        let result = validate_required_uuid("id", Uuid::nil());
662        assert!(result.is_err());
663    }
664
665    #[test]
666    fn validate_required_uuid_accepts_non_nil() {
667        let result = validate_required_uuid("id", Uuid::new_v4());
668        assert!(result.is_ok());
669    }
670}