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}