Skip to main content

stateset_embedded/
carts.rs

1//! Cart and Checkout operations for shopping cart management
2//!
3//! Based on the Agentic Commerce Protocol (ACP) checkout system.
4//!
5//! # Example
6//!
7//! ```ignore
8//! use stateset_embedded::{Commerce, CreateCart, AddCartItem};
9//! use rust_decimal_macros::dec;
10//! use uuid::Uuid;
11//!
12//! let commerce = Commerce::new("./store.db")?;
13//!
14//! // Create a cart
15//! let cart = commerce.carts().create(CreateCart {
16//!     customer_email: Some("alice@example.com".into()),
17//!     customer_name: Some("Alice Smith".into()),
18//!     ..Default::default()
19//! })?;
20//!
21//! // Add items to cart
22//! commerce.carts().add_item(cart.id, AddCartItem {
23//!     sku: "SKU-001".into(),
24//!     name: "Premium Widget".into(),
25//!     quantity: 2,
26//!     unit_price: dec!(49.99),
27//!     ..Default::default()
28//! })?;
29//!
30//! // Set shipping address
31//! commerce.carts().set_shipping_address(cart.id, stateset_embedded::CartAddress {
32//!     first_name: "Alice".into(),
33//!     last_name: "Smith".into(),
34//!     line1: "123 Main St".into(),
35//!     city: "Anytown".into(),
36//!     postal_code: "12345".into(),
37//!     country: "US".into(),
38//!     ..Default::default()
39//! })?;
40//!
41//! // Complete checkout
42//! let result = commerce.carts().complete(cart.id)?;
43//! println!("Order created: {}", result.order_number);
44//! # Ok::<(), stateset_embedded::CommerceError>(())
45//! ```
46
47use crate::Database;
48use rust_decimal::Decimal;
49use stateset_core::{
50    AddCartItem, Cart, CartAddress, CartFilter, CartId, CartItem, CheckoutResult, CreateCart,
51    CustomerId, Result, SetCartPayment, SetCartShipping, ShippingRate, UpdateCart, UpdateCartItem,
52    Validate,
53};
54use stateset_observability::Metrics;
55use std::sync::Arc;
56use uuid::Uuid;
57
58/// Cart and Checkout operations
59pub struct Carts {
60    db: Arc<dyn Database>,
61    metrics: Metrics,
62}
63
64impl std::fmt::Debug for Carts {
65    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
66        f.debug_struct("Carts").finish_non_exhaustive()
67    }
68}
69
70impl Carts {
71    pub(crate) fn new(db: Arc<dyn Database>, metrics: Metrics) -> Self {
72        Self { db, metrics }
73    }
74
75    /// Create a new cart/checkout session
76    ///
77    /// # Example
78    ///
79    /// ```rust,no_run
80    /// use stateset_embedded::{Commerce, CreateCart, AddCartItem, CurrencyCode};
81    /// use rust_decimal_macros::dec;
82    /// use uuid::Uuid;
83    ///
84    /// let commerce = Commerce::new("./store.db")?;
85    ///
86    /// // Guest checkout
87    /// let cart = commerce.carts().create(CreateCart {
88    ///     customer_email: Some("guest@example.com".into()),
89    ///     items: Some(vec![AddCartItem {
90    ///         sku: "SKU-001".into(),
91    ///         name: "Widget".into(),
92    ///         quantity: 1,
93    ///         unit_price: dec!(19.99),
94    ///         ..Default::default()
95    ///     }]),
96    ///     ..Default::default()
97    /// })?;
98    ///
99    /// // Authenticated customer checkout
100    /// let cart = commerce.carts().create(CreateCart {
101    ///     customer_id: Some(Uuid::new_v4().into()),
102    ///     currency: Some(CurrencyCode::USD),
103    ///     expires_in_minutes: Some(60),
104    ///     ..Default::default()
105    /// })?;
106    /// # Ok::<(), stateset_embedded::CommerceError>(())
107    /// ```
108    pub fn create(&self, input: CreateCart) -> Result<Cart> {
109        // Reject obviously-invalid input (negative prices, non-positive
110        // quantities) before persisting any line items.
111        input.validate()?;
112        let cart = self.db.carts().create(input)?;
113        self.metrics.record_cart_created(&cart.id.to_string());
114        Ok(cart)
115    }
116
117    /// Get a cart by ID
118    pub fn get(&self, id: CartId) -> Result<Option<Cart>> {
119        self.db.carts().get(id)
120    }
121
122    /// Get a cart by cart number (e.g., "CART-1234567890-0001")
123    pub fn get_by_number(&self, cart_number: &str) -> Result<Option<Cart>> {
124        self.db.carts().get_by_number(cart_number)
125    }
126
127    /// Update a cart
128    pub fn update(&self, id: CartId, input: UpdateCart) -> Result<Cart> {
129        self.db.carts().update(id, input)
130    }
131
132    /// List carts with optional filtering
133    pub fn list(&self, filter: CartFilter) -> Result<Vec<Cart>> {
134        self.db.carts().list(filter)
135    }
136
137    /// Get all carts for a customer
138    pub fn for_customer(&self, customer_id: CustomerId) -> Result<Vec<Cart>> {
139        self.db.carts().for_customer(customer_id)
140    }
141
142    /// Delete a cart
143    pub fn delete(&self, id: CartId) -> Result<()> {
144        self.db.carts().delete(id)
145    }
146
147    // === Item Operations ===
148
149    /// Add an item to the cart
150    ///
151    /// # Example
152    ///
153    /// ```rust,no_run
154    /// use stateset_embedded::{Commerce, AddCartItem, ProductId};
155    /// use rust_decimal_macros::dec;
156    /// use uuid::Uuid;
157    ///
158    /// let commerce = Commerce::new("./store.db")?;
159    ///
160    /// commerce.carts().add_item(Uuid::new_v4().into(), AddCartItem {
161    ///     product_id: Some(ProductId::new()),
162    ///     sku: "SKU-001".into(),
163    ///     name: "Premium Widget".into(),
164    ///     description: Some("A high-quality widget".into()),
165    ///     image_url: Some("https://example.com/widget.jpg".into()),
166    ///     quantity: 2,
167    ///     unit_price: dec!(49.99),
168    ///     original_price: Some(dec!(59.99)),
169    ///     ..Default::default()
170    /// })?;
171    /// # Ok::<(), stateset_embedded::CommerceError>(())
172    /// ```
173    pub fn add_item(&self, cart_id: CartId, item: AddCartItem) -> Result<CartItem> {
174        // Reject a negative price or non-positive quantity before persisting.
175        item.validate()?;
176        self.db.carts().add_item(cart_id, item)
177    }
178
179    /// Update a cart item (quantity, etc.)
180    pub fn update_item(&self, item_id: Uuid, input: UpdateCartItem) -> Result<CartItem> {
181        self.db.carts().update_item(item_id, input)
182    }
183
184    /// Remove an item from the cart
185    pub fn remove_item(&self, item_id: Uuid) -> Result<()> {
186        self.db.carts().remove_item(item_id)
187    }
188
189    /// Get all items in the cart
190    pub fn get_items(&self, cart_id: CartId) -> Result<Vec<CartItem>> {
191        self.db.carts().get_items(cart_id)
192    }
193
194    /// Clear all items from the cart
195    pub fn clear_items(&self, cart_id: CartId) -> Result<()> {
196        self.db.carts().clear_items(cart_id)
197    }
198
199    // === Address Operations ===
200
201    /// Set the shipping address
202    ///
203    /// # Example
204    ///
205    /// ```rust,no_run
206    /// use stateset_embedded::{Commerce, CartAddress};
207    /// use uuid::Uuid;
208    ///
209    /// let commerce = Commerce::new("./store.db")?;
210    ///
211    /// let cart = commerce.carts().set_shipping_address(Uuid::new_v4().into(), CartAddress {
212    ///     first_name: "Alice".into(),
213    ///     last_name: "Smith".into(),
214    ///     company: Some("Acme Corp".into()),
215    ///     line1: "123 Main St".into(),
216    ///     line2: Some("Suite 100".into()),
217    ///     city: "Anytown".into(),
218    ///     state: Some("CA".into()),
219    ///     postal_code: "12345".into(),
220    ///     country: "US".into(),
221    ///     phone: Some("555-1234".into()),
222    ///     email: Some("alice@example.com".into()),
223    /// })?;
224    /// # Ok::<(), stateset_embedded::CommerceError>(())
225    /// ```
226    pub fn set_shipping_address(&self, id: CartId, address: CartAddress) -> Result<Cart> {
227        self.db.carts().set_shipping_address(id, address)
228    }
229
230    /// Set the billing address
231    pub fn set_billing_address(&self, id: CartId, address: CartAddress) -> Result<Cart> {
232        self.db.carts().set_billing_address(id, address)
233    }
234
235    // === Shipping Operations ===
236
237    /// Set shipping method and address
238    pub fn set_shipping(&self, id: CartId, shipping: SetCartShipping) -> Result<Cart> {
239        self.db.carts().set_shipping(id, shipping)
240    }
241
242    /// Get available shipping rates for the cart
243    ///
244    /// Returns available shipping options based on cart contents and shipping address.
245    pub fn get_shipping_rates(&self, id: CartId) -> Result<Vec<ShippingRate>> {
246        self.db.carts().get_shipping_rates(id)
247    }
248
249    // === Payment Operations ===
250
251    /// Set payment method and token
252    ///
253    /// # Example
254    ///
255    /// ```rust,no_run
256    /// use stateset_embedded::{Commerce, SetCartPayment};
257    /// use uuid::Uuid;
258    ///
259    /// let commerce = Commerce::new("./store.db")?;
260    ///
261    /// let cart = commerce.carts().set_payment(Uuid::new_v4().into(), SetCartPayment {
262    ///     payment_method: "credit_card".into(),
263    ///     payment_token: Some("tok_visa".into()),
264    ///     ..Default::default()
265    /// })?;
266    /// # Ok::<(), stateset_embedded::CommerceError>(())
267    /// ```
268    pub fn set_payment(&self, id: CartId, payment: SetCartPayment) -> Result<Cart> {
269        self.db.carts().set_payment(id, payment)
270    }
271
272    // === Discount Operations ===
273
274    /// Apply a coupon/discount code to the cart
275    pub fn apply_discount(&self, id: CartId, coupon_code: &str) -> Result<Cart> {
276        self.db.carts().apply_discount(id, coupon_code)
277    }
278
279    /// Remove the discount from the cart
280    pub fn remove_discount(&self, id: CartId) -> Result<Cart> {
281        self.db.carts().remove_discount(id)
282    }
283
284    // === Checkout Flow ===
285
286    /// Mark the cart as ready for payment
287    ///
288    /// This validates that all required information is present (shipping address, etc.)
289    pub fn mark_ready_for_payment(&self, id: CartId) -> Result<Cart> {
290        self.db.carts().mark_ready_for_payment(id)
291    }
292
293    /// Begin the checkout process (payment pending)
294    pub fn begin_checkout(&self, id: CartId) -> Result<Cart> {
295        self.db.carts().begin_checkout(id)
296    }
297
298    /// Complete the checkout and create an order.
299    ///
300    /// The minted order is `Confirmed` with payment left `Pending` — record
301    /// the payment through [`Payments`](crate::Payments), or use
302    /// [`complete_settled_externally`](Self::complete_settled_externally) when
303    /// settlement genuinely happened outside the engine.
304    ///
305    /// # Example
306    ///
307    /// ```rust,no_run
308    /// use stateset_embedded::Commerce;
309    /// use uuid::Uuid;
310    ///
311    /// let commerce = Commerce::new("./store.db")?;
312    ///
313    /// let result = commerce.carts().complete(Uuid::new_v4().into())?;
314    /// println!("Order ID: {}", result.order_id);
315    /// println!("Order Number: {}", result.order_number);
316    /// println!("Total Charged: {} {}", result.total_charged, result.currency);
317    /// # Ok::<(), stateset_embedded::CommerceError>(())
318    /// ```
319    pub fn complete(&self, id: CartId) -> Result<CheckoutResult> {
320        let result = self.db.carts().complete(id)?;
321        self.metrics.record_cart_checkout_completed(
322            &result.cart_id.to_string(),
323            &result.order_id.to_string(),
324        );
325        Ok(result)
326    }
327
328    /// Complete the checkout for a cart settled outside the engine (ACP,
329    /// external PSP): the minted order is `Confirmed` + `Paid` with no
330    /// engine-side payment record. This is an explicit opt-in — prefer
331    /// [`complete`](Self::complete) plus a payment record when the engine
332    /// processes the payment.
333    pub fn complete_settled_externally(&self, id: CartId) -> Result<CheckoutResult> {
334        let result = self.db.carts().complete_settled_externally(id)?;
335        self.metrics.record_cart_checkout_completed(
336            &result.cart_id.to_string(),
337            &result.order_id.to_string(),
338        );
339        Ok(result)
340    }
341
342    /// Cancel the cart
343    pub fn cancel(&self, id: CartId) -> Result<Cart> {
344        self.db.carts().cancel(id)
345    }
346
347    /// Mark the cart as abandoned
348    pub fn abandon(&self, id: CartId) -> Result<Cart> {
349        self.db.carts().abandon(id)
350    }
351
352    /// Expire the cart
353    pub fn expire(&self, id: CartId) -> Result<Cart> {
354        self.db.carts().expire(id)
355    }
356
357    // === Inventory Operations ===
358
359    /// Reserve inventory for cart items
360    ///
361    /// Creates inventory reservations for all items in the cart.
362    /// Reservations typically expire after 15 minutes.
363    pub fn reserve_inventory(&self, id: CartId) -> Result<Cart> {
364        self.db.carts().reserve_inventory(id)
365    }
366
367    /// Release inventory reservations for the cart
368    pub fn release_inventory(&self, id: CartId) -> Result<Cart> {
369        self.db.carts().release_inventory(id)
370    }
371
372    // === Totals Operations ===
373
374    /// Recalculate cart totals
375    pub fn recalculate(&self, id: CartId) -> Result<Cart> {
376        self.db.carts().recalculate(id)
377    }
378
379    /// Set the tax amount for the cart
380    pub fn set_tax(&self, id: CartId, tax_amount: Decimal) -> Result<Cart> {
381        self.db.carts().set_tax(id, tax_amount)
382    }
383
384    // === Query Operations ===
385
386    /// Get abandoned carts (for recovery campaigns)
387    pub fn get_abandoned(&self) -> Result<Vec<Cart>> {
388        self.db.carts().get_abandoned()
389    }
390
391    /// Get expired carts
392    pub fn get_expired(&self) -> Result<Vec<Cart>> {
393        self.db.carts().get_expired()
394    }
395
396    /// Count carts matching a filter
397    pub fn count(&self, filter: CartFilter) -> Result<u64> {
398        self.db.carts().count(filter)
399    }
400}
401
402// The test helpers build a `Commerce::in_memory()`, which only exists with the
403// `sqlite` feature — gate the module so `--all-targets` builds (e.g. the
404// postgres-only compatibility matrix) don't try to compile it without sqlite.
405#[cfg(all(test, feature = "sqlite"))]
406mod tests {
407    use super::*;
408    use crate::Commerce;
409    use rust_decimal_macros::dec;
410    use stateset_core::CommerceError;
411
412    fn carts() -> Carts {
413        Commerce::in_memory().expect("in-memory commerce").carts()
414    }
415
416    #[test]
417    fn create_accepts_valid_cart_with_items() {
418        let carts = carts();
419        let cart = carts
420            .create(CreateCart {
421                customer_email: Some("buyer@example.com".into()),
422                items: Some(vec![AddCartItem {
423                    sku: "SKU-OK".into(),
424                    name: "Widget".into(),
425                    quantity: 2,
426                    unit_price: dec!(9.99),
427                    ..Default::default()
428                }]),
429                ..Default::default()
430            })
431            .expect("valid cart should be created");
432        assert!(!cart.cart_number.is_empty());
433    }
434
435    #[test]
436    fn create_rejects_negative_price_item() {
437        let carts = carts();
438        let err = carts
439            .create(CreateCart {
440                items: Some(vec![AddCartItem {
441                    sku: "SKU-NEG".into(),
442                    name: "Widget".into(),
443                    quantity: 1,
444                    unit_price: dec!(-1.00),
445                    ..Default::default()
446                }]),
447                ..Default::default()
448            })
449            .expect_err("negative price must be rejected");
450        assert!(matches!(err, CommerceError::InvalidInput { .. }), "got {err:?}");
451    }
452
453    #[test]
454    fn create_rejects_non_positive_quantity_item() {
455        let carts = carts();
456        let err = carts
457            .create(CreateCart {
458                items: Some(vec![AddCartItem {
459                    sku: "SKU-ZERO".into(),
460                    name: "Widget".into(),
461                    quantity: 0,
462                    unit_price: dec!(5.00),
463                    ..Default::default()
464                }]),
465                ..Default::default()
466            })
467            .expect_err("zero quantity must be rejected");
468        assert!(matches!(err, CommerceError::InvalidInput { .. }), "got {err:?}");
469    }
470
471    #[test]
472    fn add_item_rejects_negative_quantity() {
473        let carts = carts();
474        let cart = carts.create(CreateCart::default()).expect("create");
475        let err = carts
476            .add_item(
477                cart.id,
478                AddCartItem {
479                    sku: "SKU-NEG".into(),
480                    name: "Widget".into(),
481                    quantity: -1,
482                    unit_price: dec!(5.00),
483                    ..Default::default()
484                },
485            )
486            .expect_err("negative quantity must be rejected");
487        assert!(matches!(err, CommerceError::InvalidInput { .. }), "got {err:?}");
488    }
489
490    #[test]
491    fn add_item_accepts_free_item() {
492        let carts = carts();
493        let cart = carts.create(CreateCart::default()).expect("create");
494        let item = carts
495            .add_item(
496                cart.id,
497                AddCartItem {
498                    sku: "FREE".into(),
499                    name: "Free Gift".into(),
500                    quantity: 1,
501                    unit_price: dec!(0),
502                    ..Default::default()
503                },
504            )
505            .expect("free item should be accepted");
506        assert_eq!(item.unit_price, dec!(0));
507    }
508}