1mod customer;
64mod inventory;
65mod order;
66mod payment;
67mod product;
68mod returns;
69mod shipping;
70pub mod transition;
71
72pub use customer::CustomerError;
73pub use inventory::InventoryError;
74pub use order::OrderError;
75pub use payment::PaymentError;
76pub use product::ProductError;
77pub use returns::ReturnError;
78pub use shipping::ShippingError;
79pub use transition::{GotExpected, StateTransitionError};
80
81use serde::{Deserialize, Serialize};
82use thiserror::Error;
83use uuid::Uuid;
84
85#[cfg(target_pointer_width = "64")]
94macro_rules! static_assert_size {
95 ($ty:ty, $size:expr) => {
96 const _: [(); $size] = [(); ::std::mem::size_of::<$ty>()];
97 };
98}
99
100#[cfg(target_pointer_width = "64")]
103mod _size_assertions {
104 use super::{
105 CommerceError, CustomerError, DbError, InventoryError, OrderError, PaymentError,
106 ProductError, ReturnError, ShippingError,
107 };
108 static_assert_size!(CommerceError, 80);
109 static_assert_size!(DbError, 64);
110 static_assert_size!(OrderError, 48);
111 static_assert_size!(InventoryError, 72);
112 static_assert_size!(CustomerError, 24);
113 static_assert_size!(ProductError, 24);
114 static_assert_size!(ReturnError, 48);
115 static_assert_size!(PaymentError, 56);
116 static_assert_size!(ShippingError, 48);
117}
118
119#[derive(Error, Debug, Clone)]
129#[non_exhaustive]
130pub enum DbError {
131 #[error("Connection failed to {url}: {message}")]
133 ConnectionFailed {
134 url: String,
136 message: String,
138 },
139
140 #[error("Query failed on {table}: {message}")]
142 QueryFailed {
143 table: &'static str,
145 operation: &'static str,
147 message: String,
149 },
150
151 #[error("Constraint violation on {table}: {constraint} - {message}")]
153 ConstraintViolation {
154 table: &'static str,
156 constraint: String,
158 message: String,
160 },
161
162 #[error("Migration {version} failed: {message}")]
164 MigrationFailed {
165 version: i32,
167 message: String,
169 },
170
171 #[error("Transaction failed: {message}")]
173 TransactionFailed {
174 message: String,
176 },
177
178 #[error("Connection pool exhausted after {timeout_ms}ms")]
180 PoolExhausted {
181 timeout_ms: u64,
183 },
184
185 #[error("Serialization error for {field}: {message}")]
187 SerializationError {
188 field: String,
190 message: String,
192 },
193
194 #[error("Database error: {0}")]
196 Other(String),
197}
198
199impl DbError {
200 #[track_caller]
202 pub fn query_failed(
203 table: &'static str,
204 operation: &'static str,
205 message: impl Into<String>,
206 ) -> Self {
207 Self::QueryFailed { table, operation, message: message.into() }
208 }
209
210 #[track_caller]
212 pub fn constraint_violation(
213 table: &'static str,
214 constraint: impl Into<String>,
215 message: impl Into<String>,
216 ) -> Self {
217 Self::ConstraintViolation { table, constraint: constraint.into(), message: message.into() }
218 }
219
220 #[track_caller]
222 pub fn connection_failed(url: impl Into<String>, message: impl Into<String>) -> Self {
223 Self::ConnectionFailed { url: url.into(), message: message.into() }
224 }
225
226 #[track_caller]
228 pub fn transaction_failed(message: impl Into<String>) -> Self {
229 Self::TransactionFailed { message: message.into() }
230 }
231
232 #[track_caller]
234 pub fn serialization_error(field: impl Into<String>, message: impl Into<String>) -> Self {
235 Self::SerializationError { field: field.into(), message: message.into() }
236 }
237}
238
239#[derive(Error, Debug)]
250#[non_exhaustive]
251pub enum CommerceError {
252 #[error("Order not found: {0}")]
257 OrderNotFound(Uuid),
258
259 #[error("Order cannot be cancelled in status: {0}")]
261 OrderCannotBeCancelled(String),
262
263 #[error("Order cannot be refunded: {0}")]
265 OrderCannotBeRefunded(String),
266
267 #[error("Invalid order status transition from {from} to {to}")]
269 InvalidOrderStatusTransition {
270 from: String,
272 to: String,
274 },
275
276 #[error("Inventory item not found: {0}")]
281 InventoryItemNotFound(String),
282
283 #[error("Insufficient stock for SKU {sku}: requested {requested}, available {available}")]
285 InsufficientStock {
286 sku: String,
288 requested: String,
290 available: String,
292 },
293
294 #[error("Inventory reservation not found: {0}")]
296 ReservationNotFound(Uuid),
297
298 #[error("Inventory reservation expired: {0}")]
300 ReservationExpired(Uuid),
301
302 #[error("Duplicate SKU: {0}")]
304 DuplicateSku(String),
305
306 #[error("Customer not found: {0}")]
311 CustomerNotFound(Uuid),
312
313 #[error("Email already exists: {0}")]
315 EmailAlreadyExists(String),
316
317 #[error("Customer is not active")]
319 CustomerNotActive,
320
321 #[error("Product not found: {0}")]
326 ProductNotFound(Uuid),
327
328 #[error("Product variant not found: {0}")]
330 ProductVariantNotFound(Uuid),
331
332 #[error("Duplicate product slug: {0}")]
334 DuplicateSlug(String),
335
336 #[error("Product is not purchasable")]
338 ProductNotPurchasable,
339
340 #[error("Return not found: {0}")]
345 ReturnNotFound(Uuid),
346
347 #[error("Return cannot be approved in status: {0}")]
349 ReturnCannotBeApproved(String),
350
351 #[error("Return period expired")]
353 ReturnPeriodExpired,
354
355 #[error("Item not eligible for return")]
357 ItemNotEligibleForReturn,
358
359 #[error("Validation error: {0}")]
364 ValidationError(String),
365
366 #[error("Invalid input: {field} - {message}")]
368 InvalidInput {
369 field: String,
371 message: String,
373 },
374
375 #[error("Database error: {0}")]
380 DatabaseError(String),
381
382 #[error(transparent)]
384 Database(#[from] DbError),
385
386 #[error("Record not found")]
388 NotFound,
389
390 #[error("Conflict: {0}")]
392 Conflict(String),
393
394 #[error("Optimistic lock failure: record was modified")]
396 OptimisticLockFailure,
397
398 #[error("Version conflict on {entity} {id}: expected version {expected_version}")]
400 VersionConflict {
401 entity: String,
403 id: String,
405 expected_version: i32,
407 },
408
409 #[error("External service error: {0}")]
414 ExternalServiceError(String),
415
416 #[error(transparent)]
421 Order(#[from] OrderError),
422
423 #[error(transparent)]
425 Inventory(#[from] InventoryError),
426
427 #[error(transparent)]
429 Customer(#[from] CustomerError),
430
431 #[error(transparent)]
433 Product(#[from] ProductError),
434
435 #[error(transparent)]
437 Return(#[from] ReturnError),
438
439 #[error(transparent)]
441 Payment(#[from] PaymentError),
442
443 #[error(transparent)]
445 Shipping(#[from] ShippingError),
446
447 #[error("Internal error: {0}")]
452 Internal(String),
453
454 #[error("Operation not permitted: {0}")]
456 NotPermitted(String),
457}
458
459pub type Result<T> = std::result::Result<T, CommerceError>;
461
462pub mod result {
464 pub type OrderResult<T> = std::result::Result<T, super::OrderError>;
466 pub type InventoryResult<T> = std::result::Result<T, super::InventoryError>;
468 pub type CustomerResult<T> = std::result::Result<T, super::CustomerError>;
470 pub type ProductResult<T> = std::result::Result<T, super::ProductError>;
472 pub type ReturnResult<T> = std::result::Result<T, super::ReturnError>;
474 pub type PaymentResult<T> = std::result::Result<T, super::PaymentError>;
476 pub type ShippingResult<T> = std::result::Result<T, super::ShippingError>;
478 pub type DbResult<T> = std::result::Result<T, super::DbError>;
480}
481
482impl CommerceError {
483 #[must_use]
485 pub const fn is_not_found(&self) -> bool {
486 match self {
487 Self::NotFound
488 | Self::OrderNotFound(_)
489 | Self::CustomerNotFound(_)
490 | Self::ProductNotFound(_)
491 | Self::ProductVariantNotFound(_)
492 | Self::ReturnNotFound(_)
493 | Self::InventoryItemNotFound(_)
494 | Self::ReservationNotFound(_) => true,
495 Self::Order(OrderError::NotFound(_)) => true,
497 Self::Inventory(
498 InventoryError::ItemNotFound(_) | InventoryError::ReservationNotFound(_),
499 ) => true,
500 Self::Customer(CustomerError::NotFound(_)) => true,
501 Self::Product(ProductError::NotFound(_) | ProductError::VariantNotFound(_)) => true,
502 Self::Return(ReturnError::NotFound(_)) => true,
503 Self::Payment(PaymentError::NotFound(_)) => true,
504 Self::Shipping(ShippingError::NotFound(_)) => true,
505 _ => false,
506 }
507 }
508
509 #[must_use]
511 pub const fn is_validation(&self) -> bool {
512 matches!(self, Self::ValidationError(_) | Self::InvalidInput { .. })
513 }
514
515 #[must_use]
517 pub const fn is_conflict(&self) -> bool {
518 match self {
519 Self::Conflict(_)
520 | Self::OptimisticLockFailure
521 | Self::VersionConflict { .. }
522 | Self::DuplicateSku(_)
523 | Self::DuplicateSlug(_)
524 | Self::EmailAlreadyExists(_) => true,
525 Self::Inventory(InventoryError::DuplicateSku(_)) => true,
527 Self::Customer(CustomerError::EmailAlreadyExists(_)) => true,
528 Self::Product(ProductError::DuplicateSlug(_)) => true,
529 _ => false,
530 }
531 }
532
533 #[must_use]
535 pub const fn is_database(&self) -> bool {
536 matches!(self, Self::DatabaseError(_) | Self::Database(_))
537 }
538
539 #[must_use]
541 pub const fn is_external_service(&self) -> bool {
542 matches!(self, Self::ExternalServiceError(_))
543 }
544
545 #[must_use]
553 pub const fn is_retryable(&self) -> bool {
554 match self {
555 Self::OptimisticLockFailure => true,
556 Self::Database(db_err) => matches!(
557 db_err,
558 DbError::ConnectionFailed { .. }
559 | DbError::PoolExhausted { .. }
560 | DbError::TransactionFailed { .. }
561 ),
562 _ => false,
563 }
564 }
565
566 #[must_use]
571 pub const fn is_transient(&self) -> bool {
572 self.is_retryable() || self.is_external_service()
573 }
574
575 #[must_use]
579 pub const fn is_client_error(&self) -> bool {
580 self.is_not_found() || self.is_validation() || self.is_conflict() || self.is_not_permitted()
581 }
582
583 #[must_use]
588 pub const fn is_server_error(&self) -> bool {
589 self.is_database() || matches!(self, Self::Internal(_)) || self.is_external_service()
590 }
591
592 #[must_use]
594 pub const fn is_not_permitted(&self) -> bool {
595 matches!(self, Self::NotPermitted(_))
596 }
597
598 #[must_use]
614 pub const fn suggested_status_code(&self) -> u16 {
615 if self.is_not_found() {
616 404
617 } else if self.is_validation() {
618 400
619 } else if self.is_conflict() {
620 409
621 } else if self.is_not_permitted() {
622 403
623 } else if self.is_external_service() {
624 502
625 } else {
626 500
627 }
628 }
629
630 #[must_use]
632 pub const fn as_db_error(&self) -> Option<&DbError> {
633 match self {
634 Self::Database(e) => Some(e),
635 _ => None,
636 }
637 }
638
639 #[must_use]
641 pub const fn as_order_error(&self) -> Option<&OrderError> {
642 match self {
643 Self::Order(e) => Some(e),
644 _ => None,
645 }
646 }
647
648 #[must_use]
650 pub const fn as_inventory_error(&self) -> Option<&InventoryError> {
651 match self {
652 Self::Inventory(e) => Some(e),
653 _ => None,
654 }
655 }
656
657 #[must_use]
659 pub const fn as_customer_error(&self) -> Option<&CustomerError> {
660 match self {
661 Self::Customer(e) => Some(e),
662 _ => None,
663 }
664 }
665
666 #[must_use]
668 pub const fn as_product_error(&self) -> Option<&ProductError> {
669 match self {
670 Self::Product(e) => Some(e),
671 _ => None,
672 }
673 }
674
675 #[track_caller]
677 #[must_use]
678 pub const fn db(error: DbError) -> Self {
679 Self::Database(error)
680 }
681
682 #[track_caller]
684 pub fn query_failed(
685 table: &'static str,
686 operation: &'static str,
687 message: impl Into<String>,
688 ) -> Self {
689 Self::Database(DbError::query_failed(table, operation, message))
690 }
691
692 #[track_caller]
694 pub fn constraint_violation(
695 table: &'static str,
696 constraint: impl Into<String>,
697 message: impl Into<String>,
698 ) -> Self {
699 Self::Database(DbError::constraint_violation(table, constraint, message))
700 }
701
702 #[track_caller]
704 pub fn connection_failed(url: impl Into<String>, message: impl Into<String>) -> Self {
705 Self::Database(DbError::connection_failed(url, message))
706 }
707}
708
709pub const MAX_BATCH_SIZE: usize = 1000;
715
716#[derive(Debug, Clone, Copy, PartialEq, Eq, strum::Display, Serialize, Deserialize)]
718#[strum(serialize_all = "snake_case")]
719#[serde(rename_all = "snake_case")]
720#[non_exhaustive]
721pub enum BatchErrorCode {
722 NotFound,
724 ValidationError,
726 DuplicateKey,
728 VersionConflict,
730 DatabaseError,
732 InternalError,
734}
735
736impl From<&CommerceError> for BatchErrorCode {
737 fn from(err: &CommerceError) -> Self {
738 if err.is_not_found() {
739 return Self::NotFound;
740 }
741 if err.is_validation() {
742 return Self::ValidationError;
743 }
744 if err.is_conflict() {
745 return Self::DuplicateKey;
746 }
747
748 match err {
749 CommerceError::VersionConflict { .. } | CommerceError::OptimisticLockFailure => {
750 Self::VersionConflict
751 }
752
753 CommerceError::DatabaseError(_) | CommerceError::Database(_) => Self::DatabaseError,
754
755 _ if matches!(err.as_db_error(), Some(DbError::ConstraintViolation { .. })) => {
757 Self::DuplicateKey
758 }
759
760 _ => Self::InternalError,
761 }
762 }
763}
764
765#[derive(Debug, Clone, Serialize, Deserialize)]
767pub struct BatchError {
768 pub index: usize,
770 pub id: Option<String>,
772 pub error: String,
774 pub code: BatchErrorCode,
776}
777
778impl BatchError {
779 #[must_use]
781 pub fn from_error(index: usize, id: Option<String>, err: &CommerceError) -> Self {
782 Self { index, id, error: err.to_string(), code: BatchErrorCode::from(err) }
783 }
784}
785
786#[derive(Debug, Clone, Serialize, Deserialize)]
788pub struct BatchResult<T> {
789 pub succeeded: Vec<T>,
791 pub failed: Vec<BatchError>,
793 pub total_attempted: usize,
795 pub success_count: usize,
797 pub failure_count: usize,
799}
800
801impl<T> BatchResult<T> {
802 #[must_use]
804 pub const fn new() -> Self {
805 Self {
806 succeeded: Vec::new(),
807 failed: Vec::new(),
808 total_attempted: 0,
809 success_count: 0,
810 failure_count: 0,
811 }
812 }
813
814 #[must_use]
816 pub fn with_capacity(capacity: usize) -> Self {
817 Self {
818 succeeded: Vec::with_capacity(capacity),
819 failed: Vec::new(),
820 total_attempted: 0,
821 success_count: 0,
822 failure_count: 0,
823 }
824 }
825
826 pub fn record_success(&mut self, item: T) {
828 self.succeeded.push(item);
829 self.success_count += 1;
830 self.total_attempted += 1;
831 }
832
833 pub fn record_failure(&mut self, index: usize, id: Option<String>, err: &CommerceError) {
835 self.failed.push(BatchError::from_error(index, id, err));
836 self.failure_count += 1;
837 self.total_attempted += 1;
838 }
839
840 #[must_use]
842 pub const fn all_succeeded(&self) -> bool {
843 self.failure_count == 0
844 }
845
846 #[must_use]
848 pub const fn all_failed(&self) -> bool {
849 self.success_count == 0 && self.total_attempted > 0
850 }
851
852 #[must_use]
854 pub const fn partial_success(&self) -> bool {
855 self.success_count > 0 && self.failure_count > 0
856 }
857
858 #[must_use]
860 pub const fn is_empty(&self) -> bool {
861 self.total_attempted == 0
862 }
863}
864
865impl<T> Default for BatchResult<T> {
866 fn default() -> Self {
867 Self::new()
868 }
869}
870
871pub fn validate_batch_size<T>(items: &[T]) -> Result<()> {
873 if items.len() > MAX_BATCH_SIZE {
874 return Err(CommerceError::ValidationError(format!(
875 "Batch size {} exceeds maximum of {}",
876 items.len(),
877 MAX_BATCH_SIZE
878 )));
879 }
880 Ok(())
881}
882
883pub fn validate_required_text(field: &str, value: &str, max_len: usize) -> Result<()> {
885 let trimmed = value.trim();
886 if trimmed.is_empty() {
887 return Err(CommerceError::InvalidInput {
888 field: field.to_string(),
889 message: "cannot be empty".into(),
890 });
891 }
892
893 if trimmed.len() > max_len {
894 return Err(CommerceError::InvalidInput {
895 field: field.to_string(),
896 message: format!("cannot exceed {max_len} characters"),
897 });
898 }
899
900 Ok(())
901}
902
903pub fn validate_required_uuid(field: &str, value: Uuid) -> Result<()> {
905 if value.is_nil() {
906 return Err(CommerceError::InvalidInput {
907 field: field.to_string(),
908 message: "cannot be nil".into(),
909 });
910 }
911
912 Ok(())
913}
914
915pub fn validate_email(email: &str) -> Result<()> {
934 let email = email.trim();
935
936 if email.is_empty() {
937 return Err(CommerceError::ValidationError("Email cannot be empty".into()));
938 }
939
940 if email.len() > 254 {
941 return Err(CommerceError::ValidationError("Email cannot exceed 254 characters".into()));
942 }
943
944 if email.contains(char::is_whitespace) {
945 return Err(CommerceError::ValidationError("Email cannot contain whitespace".into()));
946 }
947
948 if email.chars().any(char::is_control) {
949 return Err(CommerceError::ValidationError(
950 "Email cannot contain control characters".into(),
951 ));
952 }
953
954 if !email.is_ascii() {
955 return Err(CommerceError::ValidationError(
956 "Email must use ASCII or punycode characters".into(),
957 ));
958 }
959
960 let Some((local, domain)) = email.rsplit_once('@') else {
961 return Err(CommerceError::ValidationError(
962 "Email must contain exactly one @ symbol".into(),
963 ));
964 };
965 if local.contains('@') || domain.contains('@') {
966 return Err(CommerceError::ValidationError(
967 "Email must contain exactly one @ symbol".into(),
968 ));
969 }
970
971 if local.is_empty() {
972 return Err(CommerceError::ValidationError(
973 "Email local part (before @) cannot be empty".into(),
974 ));
975 }
976
977 if domain.is_empty() {
978 return Err(CommerceError::ValidationError(
979 "Email domain (after @) cannot be empty".into(),
980 ));
981 }
982
983 if local.len() > 64 {
984 return Err(CommerceError::ValidationError(
985 "Email local part cannot exceed 64 characters".into(),
986 ));
987 }
988
989 if domain.len() > 253 {
990 return Err(CommerceError::ValidationError(
991 "Email domain cannot exceed 253 characters".into(),
992 ));
993 }
994
995 if local.starts_with('.') || local.ends_with('.') {
996 return Err(CommerceError::ValidationError(
997 "Email local part cannot start or end with a dot".into(),
998 ));
999 }
1000
1001 if local.contains("..") {
1002 return Err(CommerceError::ValidationError(
1003 "Email local part cannot contain consecutive dots".into(),
1004 ));
1005 }
1006
1007 if !local.chars().all(|ch| {
1008 ch.is_ascii_alphanumeric()
1009 || matches!(
1010 ch,
1011 '!' | '#'
1012 | '$'
1013 | '%'
1014 | '&'
1015 | '\''
1016 | '*'
1017 | '+'
1018 | '-'
1019 | '/'
1020 | '='
1021 | '?'
1022 | '^'
1023 | '_'
1024 | '`'
1025 | '{'
1026 | '|'
1027 | '}'
1028 | '~'
1029 | '.'
1030 )
1031 }) {
1032 return Err(CommerceError::ValidationError(
1033 "Email local part contains unsupported characters".into(),
1034 ));
1035 }
1036
1037 if !domain.contains('.') {
1038 return Err(CommerceError::ValidationError(
1039 "Email domain must contain at least one dot".into(),
1040 ));
1041 }
1042
1043 if domain.starts_with('.') || domain.ends_with('.') {
1045 return Err(CommerceError::ValidationError(
1046 "Email domain cannot start or end with a dot".into(),
1047 ));
1048 }
1049
1050 if domain.contains("..") {
1051 return Err(CommerceError::ValidationError(
1052 "Email domain cannot contain consecutive dots".into(),
1053 ));
1054 }
1055
1056 for label in domain.split('.') {
1057 if label.is_empty() {
1058 return Err(CommerceError::ValidationError(
1059 "Email domain cannot contain empty labels".into(),
1060 ));
1061 }
1062 if label.len() > 63 {
1063 return Err(CommerceError::ValidationError(
1064 "Email domain labels cannot exceed 63 characters".into(),
1065 ));
1066 }
1067 if label.starts_with('-') || label.ends_with('-') {
1068 return Err(CommerceError::ValidationError(
1069 "Email domain labels cannot start or end with a hyphen".into(),
1070 ));
1071 }
1072 if !label.chars().all(|ch| ch.is_ascii_alphanumeric() || ch == '-') {
1073 return Err(CommerceError::ValidationError(
1074 "Email domain contains unsupported characters".into(),
1075 ));
1076 }
1077 }
1078
1079 Ok(())
1080}
1081
1082pub fn validate_sku(sku: &str) -> Result<()> {
1100 let sku = sku.trim();
1101
1102 if sku.is_empty() {
1103 return Err(CommerceError::ValidationError("SKU cannot be empty".into()));
1104 }
1105
1106 if sku.len() > 100 {
1107 return Err(CommerceError::ValidationError("SKU cannot exceed 100 characters".into()));
1108 }
1109
1110 if !sku.chars().all(|c| c.is_alphanumeric() || c == '-' || c == '_') {
1111 return Err(CommerceError::ValidationError(
1112 "SKU can only contain alphanumeric characters, hyphens, and underscores".into(),
1113 ));
1114 }
1115
1116 Ok(())
1117}
1118
1119pub fn validate_phone(phone: &str) -> Result<()> {
1138 let phone = phone.trim();
1139
1140 if phone.is_empty() {
1141 return Err(CommerceError::ValidationError("Phone number cannot be empty".into()));
1142 }
1143
1144 if !phone
1146 .chars()
1147 .all(|c| c.is_ascii_digit() || c == ' ' || c == '-' || c == '(' || c == ')' || c == '+')
1148 {
1149 return Err(CommerceError::ValidationError(
1150 "Phone number contains invalid characters".into(),
1151 ));
1152 }
1153
1154 let digit_count = phone.chars().filter(char::is_ascii_digit).count();
1156
1157 if digit_count < 7 {
1158 return Err(CommerceError::ValidationError(
1159 "Phone number must have at least 7 digits".into(),
1160 ));
1161 }
1162
1163 if digit_count > 15 {
1164 return Err(CommerceError::ValidationError("Phone number cannot exceed 15 digits".into()));
1165 }
1166
1167 Ok(())
1168}
1169
1170pub fn validate_currency_code(code: &str) -> Result<()> {
1186 if code.len() != 3 {
1187 return Err(CommerceError::ValidationError(
1188 "Currency code must be exactly 3 characters".into(),
1189 ));
1190 }
1191
1192 if !code.chars().all(|c| c.is_ascii_uppercase()) {
1193 return Err(CommerceError::ValidationError(
1194 "Currency code must be uppercase letters only".into(),
1195 ));
1196 }
1197
1198 Ok(())
1199}
1200
1201pub fn validate_postal_code(code: &str) -> Result<()> {
1222 let code = code.trim();
1223
1224 if code.is_empty() {
1225 return Err(CommerceError::ValidationError("Postal code cannot be empty".into()));
1226 }
1227
1228 if code.len() < 3 {
1229 return Err(CommerceError::ValidationError(
1230 "Postal code must be at least 3 characters".into(),
1231 ));
1232 }
1233
1234 if code.len() > 10 {
1235 return Err(CommerceError::ValidationError(
1236 "Postal code cannot exceed 10 characters".into(),
1237 ));
1238 }
1239
1240 if !code.chars().all(|c| c.is_alphanumeric() || c == ' ' || c == '-') {
1241 return Err(CommerceError::ValidationError(
1242 "Postal code contains invalid characters".into(),
1243 ));
1244 }
1245
1246 Ok(())
1247}
1248
1249pub fn validate_quantity(qty: rust_decimal::Decimal) -> Result<()> {
1265 if qty <= rust_decimal::Decimal::ZERO {
1266 return Err(CommerceError::ValidationError("Quantity must be greater than zero".into()));
1267 }
1268 Ok(())
1269}
1270
1271pub fn validate_price(price: rust_decimal::Decimal) -> Result<()> {
1286 if price < rust_decimal::Decimal::ZERO {
1287 return Err(CommerceError::ValidationError("Price cannot be negative".into()));
1288 }
1289 Ok(())
1290}
1291
1292#[cfg(test)]
1293mod tests {
1294 use super::*;
1295
1296 #[test]
1297 fn validate_required_text_rejects_empty() {
1298 let result = validate_required_text("field", " ", 10);
1299 assert!(result.is_err());
1300 }
1301
1302 #[test]
1303 fn validate_required_text_rejects_too_long() {
1304 let result = validate_required_text("field", "toolong", 3);
1305 assert!(result.is_err());
1306 }
1307
1308 #[test]
1309 fn validate_required_text_accepts_trimmed() {
1310 let result = validate_required_text("field", " ok ", 10);
1311 assert!(result.is_ok());
1312 }
1313
1314 #[test]
1315 fn validate_required_uuid_rejects_nil() {
1316 let result = validate_required_uuid("id", Uuid::nil());
1317 assert!(result.is_err());
1318 }
1319
1320 #[test]
1321 fn validate_required_uuid_accepts_non_nil() {
1322 let result = validate_required_uuid("id", Uuid::new_v4());
1323 assert!(result.is_ok());
1324 }
1325
1326 #[test]
1327 fn validate_email_accepts_common_production_formats() {
1328 assert!(validate_email("user@example.com").is_ok());
1329 assert!(validate_email("user.name+tag@example.co.uk").is_ok());
1330 assert!(validate_email("ops_team-42@xn--bcher-kva.example").is_ok());
1331 }
1332
1333 #[test]
1334 fn validate_email_rejects_common_invalid_formats() {
1335 assert!(validate_email("alice..bob@example.com").is_err());
1336 assert!(validate_email(".alice@example.com").is_err());
1337 assert!(validate_email("alice@example..com").is_err());
1338 assert!(validate_email("alice@-example.com").is_err());
1339 assert!(validate_email("alice@example-.com").is_err());
1340 assert!(validate_email("alice@[127.0.0.1]").is_err());
1341 }
1342
1343 #[test]
1348 fn order_error_converts_to_commerce_error() {
1349 let err: CommerceError = OrderError::not_found(Uuid::nil()).into();
1350 assert!(err.is_not_found());
1351 assert!(err.as_order_error().is_some());
1352 }
1353
1354 #[test]
1355 fn inventory_error_converts_to_commerce_error() {
1356 let err: CommerceError = InventoryError::item_not_found("SKU").into();
1357 assert!(err.is_not_found());
1358 assert!(err.as_inventory_error().is_some());
1359 }
1360
1361 #[test]
1362 fn customer_error_converts_to_commerce_error() {
1363 let err: CommerceError = CustomerError::not_found(Uuid::nil()).into();
1364 assert!(err.is_not_found());
1365 assert!(err.as_customer_error().is_some());
1366 }
1367
1368 #[test]
1369 fn product_error_converts_to_commerce_error() {
1370 let err: CommerceError = ProductError::not_found(Uuid::nil()).into();
1371 assert!(err.is_not_found());
1372 assert!(err.as_product_error().is_some());
1373 }
1374
1375 #[test]
1376 fn return_error_converts_to_commerce_error() {
1377 let err: CommerceError = ReturnError::not_found(Uuid::nil()).into();
1378 assert!(err.is_not_found());
1379 }
1380
1381 #[test]
1382 fn payment_error_converts_to_commerce_error() {
1383 let err: CommerceError = PaymentError::not_found(Uuid::nil()).into();
1384 assert!(err.is_not_found());
1385 }
1386
1387 #[test]
1388 fn shipping_error_converts_to_commerce_error() {
1389 let err: CommerceError = ShippingError::not_found(Uuid::nil()).into();
1390 assert!(err.is_not_found());
1391 }
1392
1393 #[test]
1394 fn conflict_detection_domain_errors() {
1395 let err: CommerceError = InventoryError::duplicate_sku("X").into();
1396 assert!(err.is_conflict());
1397
1398 let err: CommerceError = CustomerError::email_already_exists("x@y.com").into();
1399 assert!(err.is_conflict());
1400
1401 let err: CommerceError = ProductError::duplicate_slug("slug").into();
1402 assert!(err.is_conflict());
1403 }
1404
1405 #[test]
1406 fn batch_error_code_from_domain_errors() {
1407 let err: CommerceError = OrderError::not_found(Uuid::nil()).into();
1408 assert_eq!(BatchErrorCode::from(&err), BatchErrorCode::NotFound);
1409
1410 let err: CommerceError = InventoryError::duplicate_sku("X").into();
1411 assert_eq!(BatchErrorCode::from(&err), BatchErrorCode::DuplicateKey);
1412 }
1413
1414 #[test]
1415 fn state_transition_error_in_order() {
1416 let err = OrderError::invalid_transition("pending", "delivered");
1417 let commerce_err: CommerceError = err.into();
1418 let msg = commerce_err.to_string();
1419 assert!(msg.contains("pending"));
1420 assert!(msg.contains("delivered"));
1421 }
1422
1423 #[test]
1424 fn got_expected_basic() {
1425 let ge = GotExpected { got: 2_i32, expected: 5_i32 };
1426 assert_eq!(ge.to_string(), "expected 5, got 2");
1427 }
1428
1429 #[test]
1430 fn non_exhaustive_allows_future_variants() {
1431 let err = CommerceError::NotFound;
1434 let _ = match err {
1435 CommerceError::NotFound => "ok",
1436 _ => "other",
1437 };
1438 }
1439
1440 #[test]
1445 fn print_error_enum_sizes() {
1446 use std::mem::size_of;
1447 println!("--- Error Enum Sizes (bytes) ---");
1448 println!("CommerceError: {}", size_of::<CommerceError>());
1449 println!("DbError: {}", size_of::<DbError>());
1450 println!("OrderError: {}", size_of::<OrderError>());
1451 println!("InventoryError: {}", size_of::<InventoryError>());
1452 println!("CustomerError: {}", size_of::<CustomerError>());
1453 println!("ProductError: {}", size_of::<ProductError>());
1454 println!("ReturnError: {}", size_of::<ReturnError>());
1455 println!("PaymentError: {}", size_of::<PaymentError>());
1456 println!("ShippingError: {}", size_of::<ShippingError>());
1457 }
1458
1459 #[test]
1464 fn is_transient_includes_retryable() {
1465 let err = CommerceError::OptimisticLockFailure;
1466 assert!(err.is_transient());
1467 assert!(err.is_retryable());
1468 }
1469
1470 #[test]
1471 fn is_transient_includes_external_service() {
1472 let err = CommerceError::ExternalServiceError("timeout".into());
1473 assert!(err.is_transient());
1474 assert!(!err.is_retryable()); }
1476
1477 #[test]
1478 fn is_client_error_variants() {
1479 assert!(CommerceError::NotFound.is_client_error());
1480 assert!(CommerceError::ValidationError("bad".into()).is_client_error());
1481 assert!(CommerceError::DuplicateSku("X".into()).is_client_error());
1482 assert!(CommerceError::NotPermitted("denied".into()).is_client_error());
1483 }
1484
1485 #[test]
1486 fn is_server_error_variants() {
1487 assert!(CommerceError::Internal("oops".into()).is_server_error());
1488 assert!(CommerceError::DatabaseError("fail".into()).is_server_error());
1489 assert!(CommerceError::ExternalServiceError("timeout".into()).is_server_error());
1490 }
1491
1492 #[test]
1493 fn is_not_permitted() {
1494 assert!(CommerceError::NotPermitted("admin only".into()).is_not_permitted());
1495 assert!(!CommerceError::NotFound.is_not_permitted());
1496 }
1497
1498 #[test]
1499 fn suggested_status_codes() {
1500 assert_eq!(CommerceError::NotFound.suggested_status_code(), 404);
1501 assert_eq!(CommerceError::OrderNotFound(Uuid::nil()).suggested_status_code(), 404);
1502 assert_eq!(CommerceError::ValidationError("bad".into()).suggested_status_code(), 400);
1503 assert_eq!(CommerceError::DuplicateSku("X".into()).suggested_status_code(), 409);
1504 assert_eq!(CommerceError::NotPermitted("no".into()).suggested_status_code(), 403);
1505 assert_eq!(CommerceError::ExternalServiceError("t".into()).suggested_status_code(), 502);
1506 assert_eq!(CommerceError::Internal("x".into()).suggested_status_code(), 500);
1507 }
1508
1509 #[test]
1510 fn client_vs_server_mutually_exclusive_for_not_found() {
1511 let err = CommerceError::NotFound;
1512 assert!(err.is_client_error());
1513 assert!(!err.is_server_error());
1514 }
1515
1516 #[test]
1517 fn got_expected_const_new() {
1518 let a = GotExpected::new(2, 5);
1520 let b = GotExpected { got: 2, expected: 5 };
1521 assert_eq!(a, b);
1522 }
1523}