1use serde::{Deserialize, Serialize};
4use thiserror::Error;
5use uuid::Uuid;
6
7#[derive(Error, Debug)]
9pub enum CommerceError {
10 #[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 #[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 #[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 #[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 #[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 #[error("Validation error: {0}")]
81 ValidationError(String),
82
83 #[error("Invalid input: {field} - {message}")]
84 InvalidInput { field: String, message: String },
85
86 #[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 #[error("Internal error: {0}")]
108 Internal(String),
109
110 #[error("Operation not permitted: {0}")]
111 NotPermitted(String),
112}
113
114pub type Result<T> = std::result::Result<T, CommerceError>;
116
117impl CommerceError {
118 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 pub fn is_validation(&self) -> bool {
135 matches!(self, Self::ValidationError(_) | Self::InvalidInput { .. })
136 }
137
138 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
152pub const MAX_BATCH_SIZE: usize = 1000;
158
159#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
161#[serde(rename_all = "snake_case")]
162pub enum BatchErrorCode {
163 NotFound,
165 ValidationError,
167 DuplicateKey,
169 VersionConflict,
171 DatabaseError,
173 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#[derive(Debug, Clone, Serialize, Deserialize)]
210pub struct BatchError {
211 pub index: usize,
213 pub id: Option<String>,
215 pub error: String,
217 pub code: BatchErrorCode,
219}
220
221impl BatchError {
222 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#[derive(Debug, Clone, Serialize, Deserialize)]
235pub struct BatchResult<T> {
236 pub succeeded: Vec<T>,
238 pub failed: Vec<BatchError>,
240 pub total_attempted: usize,
242 pub success_count: usize,
244 pub failure_count: usize,
246}
247
248impl<T> BatchResult<T> {
249 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 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 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 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 pub fn all_succeeded(&self) -> bool {
287 self.failure_count == 0
288 }
289
290 pub fn all_failed(&self) -> bool {
292 self.success_count == 0 && self.total_attempted > 0
293 }
294
295 pub fn partial_success(&self) -> bool {
297 self.success_count > 0 && self.failure_count > 0
298 }
299
300 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
312pub 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
324pub 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
344pub 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
356pub 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 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
422pub 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
461pub 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 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 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
511pub 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
542pub 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
590pub 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
614pub 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}