Skip to main content

stateset_core/models/
cart.rs

1//! Cart and Checkout domain models
2//!
3//! Based on the Agentic Commerce Protocol (ACP) checkout system.
4//! Supports full checkout flow with items, totals, addresses, and fulfillment.
5
6use chrono::{DateTime, Utc};
7use rust_decimal::Decimal;
8use serde::{Deserialize, Serialize};
9use stateset_primitives::{CartId, CurrencyCode, CustomerId, OrderId, PaymentId, ProductId};
10use strum::{Display, EnumString};
11use uuid::Uuid;
12
13use super::Address;
14use super::x402::{X402Asset, X402IntentStatus, X402Network};
15use crate::errors::Result;
16use crate::validation::{Validate, ValidationBuilder};
17
18/// Cart/Checkout Session aggregate
19#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
20pub struct Cart {
21    pub id: CartId,
22    pub cart_number: String,
23    pub customer_id: Option<CustomerId>,
24    pub status: CartStatus,
25    pub currency: CurrencyCode,
26
27    // Items
28    pub items: Vec<CartItem>,
29
30    // Totals
31    pub subtotal: Decimal,
32    pub tax_amount: Decimal,
33    pub shipping_amount: Decimal,
34    pub discount_amount: Decimal,
35    pub grand_total: Decimal,
36
37    // Customer info (for guest checkout)
38    pub customer_email: Option<String>,
39    pub customer_phone: Option<String>,
40    pub customer_name: Option<String>,
41
42    // Addresses
43    pub shipping_address: Option<CartAddress>,
44    pub billing_address: Option<CartAddress>,
45    pub billing_same_as_shipping: bool,
46
47    // Fulfillment
48    pub fulfillment_type: Option<FulfillmentType>,
49    pub shipping_method: Option<String>,
50    pub shipping_carrier: Option<String>,
51    pub estimated_delivery: Option<DateTime<Utc>>,
52
53    // Payment
54    pub payment_method: Option<String>,
55    pub payment_token: Option<String>,
56    pub payment_status: CartPaymentStatus,
57
58    // Discount/Promo
59    pub coupon_code: Option<String>,
60    pub discount_description: Option<String>,
61
62    // Order reference (after checkout completes)
63    pub order_id: Option<OrderId>,
64    pub order_number: Option<String>,
65
66    // Metadata
67    pub notes: Option<String>,
68    pub metadata: Option<serde_json::Value>,
69
70    // Inventory reservations
71    pub inventory_reserved: bool,
72    pub reservation_expires_at: Option<DateTime<Utc>>,
73
74    // x402 Payment (for stablecoin checkout)
75    pub x402_payment: Option<CartX402Payment>,
76
77    // Timestamps
78    pub expires_at: Option<DateTime<Utc>>,
79    pub completed_at: Option<DateTime<Utc>>,
80    pub created_at: DateTime<Utc>,
81    pub updated_at: DateTime<Utc>,
82}
83
84/// Cart line item
85#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
86pub struct CartItem {
87    pub id: Uuid,
88    pub cart_id: CartId,
89    pub product_id: Option<ProductId>,
90    pub variant_id: Option<Uuid>,
91    pub sku: String,
92    pub name: String,
93    pub description: Option<String>,
94    pub image_url: Option<String>,
95    pub quantity: i32,
96    pub unit_price: Decimal,
97    pub original_price: Option<Decimal>,
98    pub discount_amount: Decimal,
99    pub tax_amount: Decimal,
100    pub total: Decimal,
101    pub weight: Option<Decimal>,
102    pub requires_shipping: bool,
103    pub metadata: Option<serde_json::Value>,
104    pub created_at: DateTime<Utc>,
105    pub updated_at: DateTime<Utc>,
106}
107
108/// Cart address (detailed for checkout)
109#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
110pub struct CartAddress {
111    pub first_name: String,
112    pub last_name: String,
113    pub company: Option<String>,
114    pub line1: String,
115    pub line2: Option<String>,
116    pub city: String,
117    pub state: Option<String>,
118    pub postal_code: String,
119    pub country: String,
120    pub phone: Option<String>,
121    pub email: Option<String>,
122}
123
124impl From<CartAddress> for Address {
125    fn from(addr: CartAddress) -> Self {
126        Self {
127            line1: addr.line1,
128            line2: addr.line2,
129            city: addr.city,
130            state: addr.state,
131            postal_code: addr.postal_code,
132            country: addr.country,
133        }
134    }
135}
136
137impl From<Address> for CartAddress {
138    fn from(addr: Address) -> Self {
139        Self {
140            first_name: String::new(),
141            last_name: String::new(),
142            company: None,
143            line1: addr.line1,
144            line2: addr.line2,
145            city: addr.city,
146            state: addr.state,
147            postal_code: addr.postal_code,
148            country: addr.country,
149            phone: None,
150            email: None,
151        }
152    }
153}
154
155/// x402 payment configuration for cart checkout
156#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
157pub struct CartX402Payment {
158    /// x402 payment intent ID (if created)
159    pub intent_id: Option<Uuid>,
160    /// Payer wallet address
161    pub payer_address: String,
162    /// Target blockchain network
163    pub network: X402Network,
164    /// Payment asset (stablecoin)
165    pub asset: X402Asset,
166    /// Current status of the x402 payment
167    pub status: X402IntentStatus,
168}
169
170/// Input for setting x402 payment on cart
171#[derive(Debug, Clone, Serialize, Deserialize)]
172pub struct SetCartX402Payment {
173    /// Payer wallet address
174    pub payer_address: String,
175    /// Target blockchain network
176    pub network: X402Network,
177    /// Payment asset (stablecoin)
178    pub asset: X402Asset,
179}
180
181/// Result of x402 checkout attempt
182#[derive(Debug, Clone, Serialize, Deserialize)]
183#[non_exhaustive]
184pub enum X402CheckoutResult {
185    /// Payment is required - return HTTP 402 with this data
186    PaymentRequired(X402PaymentRequiredData),
187    /// Intent was created and is awaiting signature
188    IntentCreated(X402IntentCreatedData),
189    /// Intent is signed and awaiting settlement
190    AwaitingSettlement(X402AwaitingSettlementData),
191    /// Checkout completed successfully
192    Completed(CheckoutResult),
193}
194
195/// Data for HTTP 402 Payment Required response
196#[derive(Debug, Clone, Serialize, Deserialize)]
197pub struct X402PaymentRequiredData {
198    pub cart_id: CartId,
199    pub payee_address: String,
200    pub amount: u64,
201    pub amount_display: String,
202    pub asset: X402Asset,
203    pub network: X402Network,
204    pub chain_id: u64,
205    pub valid_seconds: u64,
206}
207
208/// Data when intent is created and awaiting signature
209#[derive(Debug, Clone, Serialize, Deserialize)]
210pub struct X402IntentCreatedData {
211    pub cart_id: CartId,
212    pub intent_id: Uuid,
213    pub signing_hash: String,
214    pub amount: u64,
215    pub amount_display: String,
216    pub asset: X402Asset,
217    pub network: X402Network,
218    pub payee_address: String,
219    pub valid_until: u64,
220    pub nonce: u64,
221}
222
223/// Data when awaiting on-chain settlement
224#[derive(Debug, Clone, Serialize, Deserialize)]
225pub struct X402AwaitingSettlementData {
226    pub cart_id: CartId,
227    pub intent_id: Uuid,
228    pub status: X402IntentStatus,
229    pub sequence_number: Option<u64>,
230    pub batch_id: Option<Uuid>,
231}
232
233/// Cart status enumeration
234#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Display, EnumString)]
235#[serde(rename_all = "snake_case")]
236#[strum(serialize_all = "snake_case", ascii_case_insensitive)]
237#[non_exhaustive]
238pub enum CartStatus {
239    /// Cart is active and being modified
240    Active,
241    /// Ready for payment (all required info collected)
242    #[strum(serialize = "ready_for_payment", serialize = "readyforpayment")]
243    ReadyForPayment,
244    /// Payment processing
245    #[strum(serialize = "payment_pending", serialize = "paymentpending")]
246    PaymentPending,
247    /// Checkout completed, order created
248    Completed,
249    /// Cart abandoned by customer
250    Abandoned,
251    /// Cart cancelled
252    #[strum(serialize = "cancelled", serialize = "canceled")]
253    Cancelled,
254    /// Cart expired
255    Expired,
256}
257
258impl Default for CartStatus {
259    fn default() -> Self {
260        Self::Active
261    }
262}
263
264/// Cart payment status
265#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Display, EnumString)]
266#[serde(rename_all = "snake_case")]
267#[strum(serialize_all = "snake_case", ascii_case_insensitive)]
268#[non_exhaustive]
269pub enum CartPaymentStatus {
270    /// No payment attempted
271    None,
272    /// Payment method selected
273    #[strum(serialize = "method_selected", serialize = "methodselected")]
274    MethodSelected,
275    /// Payment authorized but not captured
276    Authorized,
277    /// Payment captured/completed
278    Captured,
279    /// Payment failed
280    Failed,
281    /// Payment refunded
282    Refunded,
283}
284
285impl Default for CartPaymentStatus {
286    fn default() -> Self {
287        Self::None
288    }
289}
290
291/// Fulfillment type
292#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Display, EnumString)]
293#[serde(rename_all = "snake_case")]
294#[strum(serialize_all = "snake_case", ascii_case_insensitive)]
295#[non_exhaustive]
296pub enum FulfillmentType {
297    /// Ship to address
298    Shipping,
299    /// Store/local pickup
300    #[strum(serialize = "pickup", serialize = "pick_up", serialize = "pick-up")]
301    Pickup,
302    /// Digital delivery
303    Digital,
304}
305
306impl Default for FulfillmentType {
307    fn default() -> Self {
308        Self::Shipping
309    }
310}
311
312/// Shipping rate/option
313#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
314pub struct ShippingRate {
315    pub id: String,
316    pub carrier: String,
317    pub service: String,
318    pub description: Option<String>,
319    pub price: Decimal,
320    pub currency: CurrencyCode,
321    pub estimated_days: Option<i32>,
322    pub estimated_delivery: Option<DateTime<Utc>>,
323}
324
325/// Input for creating a new cart
326#[derive(Debug, Clone, Default, Serialize, Deserialize)]
327pub struct CreateCart {
328    pub customer_id: Option<CustomerId>,
329    pub customer_email: Option<String>,
330    pub customer_name: Option<String>,
331    pub currency: Option<CurrencyCode>,
332    pub items: Option<Vec<AddCartItem>>,
333    pub shipping_address: Option<CartAddress>,
334    pub billing_address: Option<CartAddress>,
335    pub notes: Option<String>,
336    pub metadata: Option<serde_json::Value>,
337    pub expires_in_minutes: Option<i64>,
338}
339
340/// Input for adding an item to cart
341#[derive(Debug, Clone, Serialize, Deserialize)]
342pub struct AddCartItem {
343    pub product_id: Option<ProductId>,
344    pub variant_id: Option<Uuid>,
345    pub sku: String,
346    pub name: String,
347    pub description: Option<String>,
348    pub image_url: Option<String>,
349    pub quantity: i32,
350    pub unit_price: Decimal,
351    pub original_price: Option<Decimal>,
352    pub weight: Option<Decimal>,
353    pub requires_shipping: Option<bool>,
354    pub metadata: Option<serde_json::Value>,
355}
356
357impl Default for AddCartItem {
358    fn default() -> Self {
359        Self {
360            product_id: None,
361            variant_id: None,
362            sku: String::new(),
363            name: String::new(),
364            description: None,
365            image_url: None,
366            quantity: 1,
367            unit_price: Decimal::ZERO,
368            original_price: None,
369            weight: None,
370            requires_shipping: Some(true),
371            metadata: None,
372        }
373    }
374}
375
376/// Input for updating a cart item
377#[derive(Debug, Clone, Default, Serialize, Deserialize)]
378pub struct UpdateCartItem {
379    pub quantity: Option<i32>,
380    pub unit_price: Option<Decimal>,
381    pub discount_amount: Option<Decimal>,
382    pub metadata: Option<serde_json::Value>,
383}
384
385/// Input for updating a cart
386#[derive(Debug, Clone, Default, Serialize, Deserialize)]
387pub struct UpdateCart {
388    pub customer_id: Option<CustomerId>,
389    pub customer_email: Option<String>,
390    pub customer_phone: Option<String>,
391    pub customer_name: Option<String>,
392    pub shipping_address: Option<CartAddress>,
393    pub billing_address: Option<CartAddress>,
394    pub billing_same_as_shipping: Option<bool>,
395    pub fulfillment_type: Option<FulfillmentType>,
396    pub shipping_method: Option<String>,
397    pub shipping_carrier: Option<String>,
398    pub coupon_code: Option<String>,
399    pub discount_amount: Option<Decimal>,
400    pub discount_description: Option<String>,
401    pub notes: Option<String>,
402    pub metadata: Option<serde_json::Value>,
403}
404
405/// Input for setting shipping on cart
406#[derive(Debug, Clone, Serialize, Deserialize)]
407pub struct SetCartShipping {
408    pub shipping_address: CartAddress,
409    pub shipping_method: Option<String>,
410    pub shipping_carrier: Option<String>,
411    pub shipping_amount: Option<Decimal>,
412}
413
414/// Input for setting payment on cart
415#[derive(Debug, Clone, Default, Serialize, Deserialize)]
416pub struct SetCartPayment {
417    pub payment_method: String,
418    pub payment_token: Option<String>,
419    pub billing_address: Option<CartAddress>,
420}
421
422/// Input for applying discount/coupon
423#[derive(Debug, Clone, Serialize, Deserialize)]
424pub struct ApplyCartDiscount {
425    pub coupon_code: String,
426}
427
428/// Cart filter for querying
429#[derive(Debug, Clone, Default, Serialize, Deserialize)]
430pub struct CartFilter {
431    pub customer_id: Option<CustomerId>,
432    pub customer_email: Option<String>,
433    pub status: Option<CartStatus>,
434    pub has_items: Option<bool>,
435    pub is_abandoned: Option<bool>,
436    pub created_after: Option<DateTime<Utc>>,
437    pub created_before: Option<DateTime<Utc>>,
438    pub limit: Option<u32>,
439    pub offset: Option<u32>,
440}
441
442/// Checkout result after completing a cart
443#[derive(Debug, Clone, Serialize, Deserialize)]
444pub struct CheckoutResult {
445    pub cart_id: CartId,
446    pub order_id: OrderId,
447    pub order_number: String,
448    pub payment_id: Option<PaymentId>,
449    pub total_charged: Decimal,
450    pub currency: CurrencyCode,
451}
452
453impl Cart {
454    /// Check if cart has items
455    #[must_use]
456    pub fn has_items(&self) -> bool {
457        !self.items.is_empty()
458    }
459
460    /// Get total item count
461    #[must_use]
462    pub fn item_count(&self) -> i32 {
463        self.items.iter().map(|i| i.quantity).sum()
464    }
465
466    /// Check if cart requires shipping
467    #[must_use]
468    pub fn requires_shipping(&self) -> bool {
469        self.items.iter().any(|i| i.requires_shipping)
470    }
471
472    /// Check if the cart's lifecycle status permits it to be checked out into
473    /// an order.
474    ///
475    /// A cart in a terminal/non-checkoutable state ([`CartStatus::Cancelled`],
476    /// [`CartStatus::Abandoned`], or [`CartStatus::Expired`]) can never be
477    /// completed into a real order. [`CartStatus::Completed`] is excluded here
478    /// too — completion of an already-completed cart is handled separately as an
479    /// idempotent short-circuit, not via this readiness check.
480    #[must_use]
481    pub const fn is_checkoutable_status(&self) -> bool {
482        matches!(
483            self.status,
484            CartStatus::Active | CartStatus::ReadyForPayment | CartStatus::PaymentPending
485        )
486    }
487
488    /// Check if cart is ready for checkout
489    #[must_use]
490    pub fn is_ready_for_checkout(&self) -> bool {
491        // A cancelled/abandoned/expired (or already-completed) cart must never be
492        // minted into a new order, regardless of whether its items/customer/address
493        // fields happen to be populated.
494        if !self.is_checkoutable_status() {
495            return false;
496        }
497
498        if self.items.is_empty() {
499            return false;
500        }
501
502        // Must have customer info (either customer_id or email)
503        if self.customer_id.is_none() && self.customer_email.is_none() {
504            return false;
505        }
506
507        // Must have shipping address if requires shipping
508        if self.requires_shipping() && self.shipping_address.is_none() {
509            return false;
510        }
511
512        true
513    }
514
515    /// Check if cart can be modified
516    #[must_use]
517    pub const fn can_modify(&self) -> bool {
518        matches!(self.status, CartStatus::Active)
519    }
520
521    /// Check if cart can be completed
522    #[must_use]
523    pub const fn can_complete(&self) -> bool {
524        matches!(self.status, CartStatus::ReadyForPayment | CartStatus::PaymentPending)
525    }
526
527    /// Check if cart can be cancelled
528    #[must_use]
529    pub const fn can_cancel(&self) -> bool {
530        matches!(
531            self.status,
532            CartStatus::Active | CartStatus::ReadyForPayment | CartStatus::PaymentPending
533        )
534    }
535
536    /// Check if cart is abandoned
537    #[must_use]
538    pub fn is_abandoned(&self) -> bool {
539        self.status == CartStatus::Abandoned
540    }
541
542    /// Check if cart is expired
543    #[must_use]
544    pub fn is_expired(&self) -> bool {
545        if let Some(expires_at) = self.expires_at { Utc::now() > expires_at } else { false }
546    }
547
548    /// Recalculate totals from items
549    pub fn recalculate_totals(&mut self) {
550        self.subtotal = self
551            .items
552            .iter()
553            .map(|i| (i.unit_price * Decimal::from(i.quantity)) - i.discount_amount)
554            .sum();
555        self.grand_total =
556            self.subtotal + self.tax_amount + self.shipping_amount - self.discount_amount;
557    }
558}
559
560impl CartItem {
561    /// Calculate a line item's money total, rounded to the currency minor unit
562    /// (2 dp) so the stored/returned line total is a real money amount rather
563    /// than a sub-cent value like `9.999`. This matches the Postgres
564    /// `cart_items.total DECIMAL(12,2)` column (which coerces to 2 dp) and the
565    /// order pipeline's `OrderItem::calculate_total`.
566    #[must_use]
567    pub fn calculate_total(
568        quantity: i32,
569        unit_price: Decimal,
570        discount: Decimal,
571        tax: Decimal,
572    ) -> Decimal {
573        let subtotal = unit_price * Decimal::from(quantity);
574        (subtotal - discount + tax).round_dp(2)
575    }
576
577    /// Recalculate this item's total
578    pub fn recalculate(&mut self) {
579        self.total = Self::calculate_total(
580            self.quantity,
581            self.unit_price,
582            self.discount_amount,
583            self.tax_amount,
584        );
585    }
586}
587
588impl Validate for AddCartItem {
589    /// Validate an item being added to a cart.
590    ///
591    /// Rejects empty SKU/name, a non-positive quantity, and a negative unit
592    /// or original price. A zero unit price is allowed (e.g. a free/gift item),
593    /// but a negative price never is.
594    fn validate(&self) -> Result<()> {
595        ValidationBuilder::new()
596            .required("sku", &self.sku)
597            .required("name", &self.name)
598            .positive_i32("quantity", self.quantity)
599            .non_negative("unit_price", self.unit_price)
600            .non_negative("original_price", self.original_price.unwrap_or(Decimal::ZERO))
601            .build()
602    }
603}
604
605impl Validate for CreateCart {
606    /// Validate a cart create request.
607    ///
608    /// Validates each provided line item; an empty/no-items cart is allowed at
609    /// create time since items are commonly added afterward.
610    fn validate(&self) -> Result<()> {
611        if let Some(items) = &self.items {
612            for item in items {
613                item.validate()?;
614            }
615        }
616        Ok(())
617    }
618}
619
620#[cfg(test)]
621mod tests {
622    use super::*;
623    use rust_decimal_macros::dec;
624
625    #[test]
626    fn test_cart_item_total_calculation() {
627        let total = CartItem::calculate_total(2, dec!(10.00), dec!(0), dec!(1.60));
628        assert_eq!(total, dec!(21.60));
629    }
630
631    #[test]
632    fn test_cart_is_ready_for_checkout() {
633        let mut cart = Cart {
634            id: CartId::new(),
635            cart_number: "CART-001".to_string(),
636            customer_id: Some(CustomerId::new()),
637            status: CartStatus::Active,
638            currency: CurrencyCode::USD,
639            items: vec![CartItem {
640                id: Uuid::new_v4(),
641                cart_id: CartId::new(),
642                product_id: None,
643                variant_id: None,
644                sku: "SKU-001".to_string(),
645                name: "Test Item".to_string(),
646                description: None,
647                image_url: None,
648                quantity: 1,
649                unit_price: dec!(10.00),
650                original_price: None,
651                discount_amount: dec!(0),
652                tax_amount: dec!(0.80),
653                total: dec!(10.80),
654                weight: None,
655                requires_shipping: true,
656                metadata: None,
657                created_at: Utc::now(),
658                updated_at: Utc::now(),
659            }],
660            subtotal: dec!(10.00),
661            tax_amount: dec!(0.80),
662            shipping_amount: dec!(5.00),
663            discount_amount: dec!(0),
664            grand_total: dec!(15.80),
665            customer_email: None,
666            customer_phone: None,
667            customer_name: None,
668            shipping_address: None,
669            billing_address: None,
670            billing_same_as_shipping: true,
671            fulfillment_type: Some(FulfillmentType::Shipping),
672            shipping_method: None,
673            shipping_carrier: None,
674            estimated_delivery: None,
675            payment_method: None,
676            payment_token: None,
677            payment_status: CartPaymentStatus::None,
678            coupon_code: None,
679            discount_description: None,
680            order_id: None,
681            order_number: None,
682            notes: None,
683            metadata: None,
684            inventory_reserved: false,
685            reservation_expires_at: None,
686            x402_payment: None,
687            expires_at: None,
688            completed_at: None,
689            created_at: Utc::now(),
690            updated_at: Utc::now(),
691        };
692
693        // Not ready - no shipping address
694        assert!(!cart.is_ready_for_checkout());
695
696        // Add shipping address
697        cart.shipping_address = Some(CartAddress {
698            first_name: "John".to_string(),
699            last_name: "Doe".to_string(),
700            company: None,
701            line1: "123 Main St".to_string(),
702            line2: None,
703            city: "Anytown".to_string(),
704            state: Some("CA".to_string()),
705            postal_code: "12345".to_string(),
706            country: "US".to_string(),
707            phone: None,
708            email: None,
709        });
710
711        // Now ready
712        assert!(cart.is_ready_for_checkout());
713    }
714
715    /// Build a fully-populated cart that satisfies the item/customer/address
716    /// requirements of [`Cart::is_ready_for_checkout`], with the given status.
717    fn ready_cart_with_status(status: CartStatus) -> Cart {
718        Cart {
719            id: CartId::new(),
720            cart_number: "CART-READY".to_string(),
721            customer_id: Some(CustomerId::new()),
722            status,
723            currency: CurrencyCode::USD,
724            items: vec![CartItem {
725                id: Uuid::new_v4(),
726                cart_id: CartId::new(),
727                product_id: None,
728                variant_id: None,
729                sku: "SKU-001".to_string(),
730                name: "Test Item".to_string(),
731                description: None,
732                image_url: None,
733                quantity: 1,
734                unit_price: dec!(10.00),
735                original_price: None,
736                discount_amount: dec!(0),
737                tax_amount: dec!(0.80),
738                total: dec!(10.80),
739                weight: None,
740                requires_shipping: true,
741                metadata: None,
742                created_at: Utc::now(),
743                updated_at: Utc::now(),
744            }],
745            subtotal: dec!(10.00),
746            tax_amount: dec!(0.80),
747            shipping_amount: dec!(5.00),
748            discount_amount: dec!(0),
749            grand_total: dec!(15.80),
750            customer_email: None,
751            customer_phone: None,
752            customer_name: None,
753            shipping_address: Some(CartAddress {
754                first_name: "John".to_string(),
755                last_name: "Doe".to_string(),
756                company: None,
757                line1: "123 Main St".to_string(),
758                line2: None,
759                city: "Anytown".to_string(),
760                state: Some("CA".to_string()),
761                postal_code: "12345".to_string(),
762                country: "US".to_string(),
763                phone: None,
764                email: None,
765            }),
766            billing_address: None,
767            billing_same_as_shipping: true,
768            fulfillment_type: Some(FulfillmentType::Shipping),
769            shipping_method: None,
770            shipping_carrier: None,
771            estimated_delivery: None,
772            payment_method: None,
773            payment_token: None,
774            payment_status: CartPaymentStatus::None,
775            coupon_code: None,
776            discount_description: None,
777            order_id: None,
778            order_number: None,
779            notes: None,
780            metadata: None,
781            inventory_reserved: false,
782            reservation_expires_at: None,
783            x402_payment: None,
784            expires_at: None,
785            completed_at: None,
786            created_at: Utc::now(),
787            updated_at: Utc::now(),
788        }
789    }
790
791    #[test]
792    fn test_is_checkoutable_status() {
793        // Active/in-flight statuses are checkoutable.
794        assert!(ready_cart_with_status(CartStatus::Active).is_checkoutable_status());
795        assert!(ready_cart_with_status(CartStatus::ReadyForPayment).is_checkoutable_status());
796        assert!(ready_cart_with_status(CartStatus::PaymentPending).is_checkoutable_status());
797
798        // Terminal/non-checkoutable statuses are not.
799        assert!(!ready_cart_with_status(CartStatus::Completed).is_checkoutable_status());
800        assert!(!ready_cart_with_status(CartStatus::Cancelled).is_checkoutable_status());
801        assert!(!ready_cart_with_status(CartStatus::Abandoned).is_checkoutable_status());
802        assert!(!ready_cart_with_status(CartStatus::Expired).is_checkoutable_status());
803    }
804
805    #[test]
806    fn test_ready_for_checkout_rejects_terminal_status() {
807        // A fully-populated Active cart is ready for checkout.
808        assert!(ready_cart_with_status(CartStatus::Active).is_ready_for_checkout());
809
810        // The same cart is NOT ready once it is cancelled/abandoned/expired,
811        // even though its items/customer/address are all still populated.
812        for status in [CartStatus::Cancelled, CartStatus::Abandoned, CartStatus::Expired] {
813            let cart = ready_cart_with_status(status);
814            assert!(
815                !cart.is_ready_for_checkout(),
816                "cart in status {status:?} must not be ready for checkout"
817            );
818        }
819    }
820
821    #[test]
822    fn test_add_cart_item_validate_rejects_negative_and_zero_quantity() {
823        // Negative unit price is rejected.
824        let mut item = AddCartItem { sku: "SKU".into(), name: "Item".into(), ..Default::default() };
825        item.unit_price = dec!(-1.00);
826        assert!(item.validate().is_err());
827
828        // Zero quantity is rejected (quantity must be positive).
829        let mut item = AddCartItem { sku: "SKU".into(), name: "Item".into(), ..Default::default() };
830        item.quantity = 0;
831        assert!(item.validate().is_err());
832
833        // Negative quantity is rejected.
834        let mut item = AddCartItem { sku: "SKU".into(), name: "Item".into(), ..Default::default() };
835        item.quantity = -3;
836        assert!(item.validate().is_err());
837
838        // A free item ($0 unit price) with positive quantity is valid.
839        let item = AddCartItem {
840            sku: "FREE".into(),
841            name: "Free Gift".into(),
842            quantity: 1,
843            unit_price: dec!(0),
844            ..Default::default()
845        };
846        assert!(item.validate().is_ok());
847    }
848
849    #[test]
850    fn test_create_cart_validate() {
851        // No-items cart is allowed at create time.
852        assert!(CreateCart::default().validate().is_ok());
853
854        // A cart carrying an invalid item is rejected.
855        let bad = CreateCart {
856            items: Some(vec![AddCartItem {
857                sku: "SKU".into(),
858                name: "Item".into(),
859                quantity: -1,
860                unit_price: dec!(5.00),
861                ..Default::default()
862            }]),
863            ..Default::default()
864        };
865        assert!(bad.validate().is_err());
866
867        // A cart carrying a valid item is accepted.
868        let good = CreateCart {
869            items: Some(vec![AddCartItem {
870                sku: "SKU".into(),
871                name: "Item".into(),
872                quantity: 2,
873                unit_price: dec!(5.00),
874                ..Default::default()
875            }]),
876            ..Default::default()
877        };
878        assert!(good.validate().is_ok());
879    }
880
881    #[test]
882    fn test_cart_status_from_str() {
883        use std::str::FromStr;
884
885        assert_eq!(CartStatus::from_str("active").unwrap(), CartStatus::Active);
886        assert_eq!(CartStatus::from_str("ready_for_payment").unwrap(), CartStatus::ReadyForPayment);
887        assert_eq!(CartStatus::from_str("paymentpending").unwrap(), CartStatus::PaymentPending);
888        assert_eq!(CartStatus::from_str("canceled").unwrap(), CartStatus::Cancelled);
889    }
890
891    #[test]
892    fn test_cart_payment_status_from_str() {
893        use std::str::FromStr;
894
895        assert_eq!(
896            CartPaymentStatus::from_str("method_selected").unwrap(),
897            CartPaymentStatus::MethodSelected
898        );
899        assert_eq!(
900            CartPaymentStatus::from_str("methodselected").unwrap(),
901            CartPaymentStatus::MethodSelected
902        );
903        assert_eq!(
904            CartPaymentStatus::from_str("authorized").unwrap(),
905            CartPaymentStatus::Authorized
906        );
907        assert_eq!(CartPaymentStatus::from_str("captured").unwrap(), CartPaymentStatus::Captured);
908    }
909
910    #[test]
911    fn test_fulfillment_type_from_str() {
912        use std::str::FromStr;
913
914        assert_eq!(FulfillmentType::from_str("shipping").unwrap(), FulfillmentType::Shipping);
915        assert_eq!(FulfillmentType::from_str("pick_up").unwrap(), FulfillmentType::Pickup);
916        assert_eq!(FulfillmentType::from_str("digital").unwrap(), FulfillmentType::Digital);
917    }
918
919    #[test]
920    fn test_recalculate_totals_does_not_double_count_tax() {
921        let mut cart = Cart {
922            id: CartId::new(),
923            cart_number: "CART-002".to_string(),
924            customer_id: None,
925            status: CartStatus::Active,
926            currency: CurrencyCode::USD,
927            items: vec![CartItem {
928                id: Uuid::new_v4(),
929                cart_id: CartId::new(),
930                product_id: None,
931                variant_id: None,
932                sku: "SKU-TAX".to_string(),
933                name: "Taxed Item".to_string(),
934                description: None,
935                image_url: None,
936                quantity: 1,
937                unit_price: dec!(10.00),
938                original_price: None,
939                discount_amount: Decimal::ZERO,
940                tax_amount: dec!(0.80),
941                total: dec!(10.80),
942                weight: None,
943                requires_shipping: false,
944                metadata: None,
945                created_at: Utc::now(),
946                updated_at: Utc::now(),
947            }],
948            subtotal: Decimal::ZERO,
949            tax_amount: dec!(0.80),
950            shipping_amount: Decimal::ZERO,
951            discount_amount: Decimal::ZERO,
952            grand_total: Decimal::ZERO,
953            customer_email: None,
954            customer_phone: None,
955            customer_name: None,
956            shipping_address: None,
957            billing_address: None,
958            billing_same_as_shipping: true,
959            fulfillment_type: None,
960            shipping_method: None,
961            shipping_carrier: None,
962            estimated_delivery: None,
963            payment_method: None,
964            payment_token: None,
965            payment_status: CartPaymentStatus::None,
966            coupon_code: None,
967            discount_description: None,
968            order_id: None,
969            order_number: None,
970            notes: None,
971            metadata: None,
972            inventory_reserved: false,
973            reservation_expires_at: None,
974            x402_payment: None,
975            expires_at: None,
976            completed_at: None,
977            created_at: Utc::now(),
978            updated_at: Utc::now(),
979        };
980
981        cart.recalculate_totals();
982
983        assert_eq!(cart.subtotal, dec!(10.00));
984        assert_eq!(cart.grand_total, dec!(10.80));
985    }
986}