Skip to main content

stateset_embedded/
orders.rs

1//! Order operations
2
3use rust_decimal::prelude::ToPrimitive;
4use stateset_core::{
5    CreateOrder, CreateOrderItem, CustomerId, Order, OrderFilter, OrderId, OrderItem, OrderItemId,
6    OrderStatus, PaymentStatus, Result, UpdateOrder,
7};
8use stateset_db::Database;
9use stateset_observability::Metrics;
10use std::sync::Arc;
11
12#[cfg(feature = "events")]
13use crate::events::EventSystem;
14#[cfg(feature = "events")]
15use chrono::Utc;
16#[cfg(feature = "events")]
17use stateset_core::CommerceEvent;
18
19/// Order operations interface.
20pub struct Orders {
21    db: Arc<dyn Database>,
22    metrics: Metrics,
23    #[cfg(feature = "events")]
24    event_system: Arc<EventSystem>,
25}
26
27impl std::fmt::Debug for Orders {
28    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
29        f.debug_struct("Orders").finish_non_exhaustive()
30    }
31}
32
33impl Orders {
34    #[cfg(feature = "events")]
35    pub(crate) fn new(
36        db: Arc<dyn Database>,
37        event_system: Arc<EventSystem>,
38        metrics: Metrics,
39    ) -> Self {
40        Self { db, metrics, event_system }
41    }
42
43    #[cfg(not(feature = "events"))]
44    pub(crate) fn new(db: Arc<dyn Database>, metrics: Metrics) -> Self {
45        Self { db, metrics }
46    }
47
48    #[cfg(feature = "events")]
49    fn emit(&self, event: CommerceEvent) {
50        self.event_system.emit(event);
51    }
52
53    #[cfg(feature = "events")]
54    fn emit_order_change_events(&self, previous: &Order, updated: &Order) {
55        if previous.status != updated.status {
56            self.emit(CommerceEvent::OrderStatusChanged {
57                order_id: updated.id,
58                from_status: previous.status,
59                to_status: updated.status,
60                timestamp: updated.updated_at,
61            });
62            if updated.status == OrderStatus::Cancelled {
63                self.emit(CommerceEvent::OrderCancelled {
64                    order_id: updated.id,
65                    reason: updated.notes.clone(),
66                    timestamp: updated.updated_at,
67                });
68            }
69        }
70
71        if previous.payment_status != updated.payment_status {
72            self.emit(CommerceEvent::OrderPaymentStatusChanged {
73                order_id: updated.id,
74                from_status: previous.payment_status,
75                to_status: updated.payment_status,
76                timestamp: updated.updated_at,
77            });
78        }
79
80        if previous.fulfillment_status != updated.fulfillment_status {
81            self.emit(CommerceEvent::OrderFulfillmentStatusChanged {
82                order_id: updated.id,
83                from_status: previous.fulfillment_status,
84                to_status: updated.fulfillment_status,
85                timestamp: updated.updated_at,
86            });
87        }
88    }
89
90    /// Create a new order.
91    ///
92    /// # Example
93    ///
94    /// ```rust,no_run
95    /// # use stateset_embedded::*;
96    /// # use rust_decimal_macros::dec;
97    /// # let commerce = Commerce::new(":memory:")?;
98    /// let order = commerce.orders().create(CreateOrder {
99    ///     customer_id: stateset_core::CustomerId::new(),
100    ///     items: vec![CreateOrderItem {
101    ///         product_id: stateset_core::ProductId::new(),
102    ///         sku: "SKU-001".into(),
103    ///         name: "Widget".into(),
104    ///         quantity: 2,
105    ///         unit_price: dec!(29.99),
106    ///         ..Default::default()
107    ///     }],
108    ///     ..Default::default()
109    /// })?;
110    /// # Ok::<(), CommerceError>(())
111    /// ```
112    #[tracing::instrument(skip(self, input), fields(customer_id = %input.customer_id, items = input.items.len()))]
113    pub fn create(&self, input: CreateOrder) -> Result<Order> {
114        tracing::info!("creating order");
115
116        // Validate pricing: compute expected total via pricing engine
117        let pricing_items: Vec<stateset_pricing::LineItem> = input
118            .items
119            .iter()
120            .map(|item| stateset_pricing::LineItem {
121                sku: item.sku.clone(),
122                name: item.name.clone(),
123                unit_price: item.unit_price,
124                quantity: item.quantity as u32,
125                discount: item.discount.map(stateset_pricing::LineDiscount::FixedAmount),
126                tax_rate: None,
127            })
128            .collect();
129        let pricing_input = stateset_pricing::OrderTotalInput {
130            items: pricing_items,
131            shipping_cost: rust_decimal::Decimal::ZERO,
132            shipping_tax_rate: None,
133            order_discount: None,
134            fees: vec![],
135            rounding: {
136                let minor = stateset_pricing::minor_units_for_currency(
137                    &input.currency.unwrap_or_default().to_string(),
138                );
139                stateset_pricing::RoundingPolicy::new(stateset_pricing::RoundingMode::HalfUp, minor)
140            },
141        };
142        let pricing_total = stateset_pricing::try_compute_order_total(&pricing_input).ok();
143        if let Some(ref computed) = pricing_total {
144            tracing::debug!(
145                computed_total = %computed.grand_total,
146                subtotal = %computed.subtotal,
147                discount = %computed.total_discount,
148                "Pricing engine computed order total"
149            );
150        }
151
152        let order = self.db.orders().create(input)?;
153
154        // Detect pricing drift: warn if DB total diverges from pricing engine
155        if let Some(computed) = pricing_total {
156            let diff = (order.total_amount - computed.grand_total).abs();
157            if diff > rust_decimal::Decimal::new(1, 2) {
158                // Divergence > $0.01
159                tracing::warn!(
160                    order_id = %order.id,
161                    db_total = %order.total_amount,
162                    engine_total = %computed.grand_total,
163                    diff = %diff,
164                    "Pricing drift detected: DB total differs from pricing engine"
165                );
166            }
167        }
168        self.metrics.record_order_created(
169            &order.customer_id.to_string(),
170            order.total_amount.to_f64().unwrap_or(0.0),
171        );
172
173        #[cfg(feature = "events")]
174        {
175            self.emit(CommerceEvent::OrderCreated {
176                order_id: order.id,
177                customer_id: order.customer_id,
178                total_amount: order.total_amount,
179                item_count: order.items.len(),
180                timestamp: order.created_at,
181            });
182        }
183        Ok(order)
184    }
185
186    /// Get an order by ID.
187    pub fn get(&self, id: OrderId) -> Result<Option<Order>> {
188        self.db.orders().get(id)
189    }
190
191    /// Get an order by order number.
192    pub fn get_by_number(&self, order_number: &str) -> Result<Option<Order>> {
193        self.db.orders().get_by_number(order_number)
194    }
195
196    /// Update an order.
197    pub fn update(&self, id: OrderId, input: UpdateOrder) -> Result<Order> {
198        #[cfg(feature = "events")]
199        let previous = self.db.orders().get(id)?;
200
201        let updated = self.db.orders().update(id, input)?;
202
203        #[cfg(feature = "events")]
204        if let Some(previous) = previous {
205            self.emit_order_change_events(&previous, &updated);
206        }
207
208        Ok(updated)
209    }
210
211    /// Update order status.
212    #[tracing::instrument(skip(self), fields(order_id = %id, status = ?status))]
213    pub fn update_status(&self, id: OrderId, status: OrderStatus) -> Result<Order> {
214        tracing::info!("updating order status");
215        let mut tracking_number = None;
216        let mut payment_status = None;
217        if status == OrderStatus::Shipped {
218            if let Some(order) = self.get(id)? {
219                if order.tracking_number.is_none() {
220                    tracking_number = Some(format!("AUTO-{id}"));
221                }
222            }
223        }
224        if status == OrderStatus::Refunded {
225            payment_status = Some(PaymentStatus::Refunded);
226        }
227        self.update(
228            id,
229            UpdateOrder {
230                status: Some(status),
231                payment_status,
232                tracking_number,
233                ..Default::default()
234            },
235        )
236    }
237
238    /// List orders with optional filtering.
239    pub fn list(&self, filter: OrderFilter) -> Result<Vec<Order>> {
240        self.db.orders().list(filter)
241    }
242
243    /// List orders for a specific customer.
244    pub fn list_for_customer(&self, customer_id: CustomerId) -> Result<Vec<Order>> {
245        self.db.orders().list(OrderFilter { customer_id: Some(customer_id), ..Default::default() })
246    }
247
248    /// Delete an order.
249    pub fn delete(&self, id: OrderId) -> Result<()> {
250        self.db.orders().delete(id)
251    }
252
253    /// Add an item to an order.
254    pub fn add_item(&self, order_id: OrderId, item: CreateOrderItem) -> Result<OrderItem> {
255        let order_item = self.db.orders().add_item(order_id, item)?;
256        #[cfg(feature = "events")]
257        {
258            self.emit(CommerceEvent::OrderItemAdded {
259                order_id,
260                item_id: order_item.id,
261                sku: order_item.sku.clone(),
262                quantity: order_item.quantity,
263                timestamp: Utc::now(),
264            });
265        }
266        Ok(order_item)
267    }
268
269    /// Remove an item from an order.
270    pub fn remove_item(&self, order_id: OrderId, item_id: OrderItemId) -> Result<()> {
271        self.db.orders().remove_item(order_id, item_id)?;
272        #[cfg(feature = "events")]
273        {
274            self.emit(CommerceEvent::OrderItemRemoved { order_id, item_id, timestamp: Utc::now() });
275        }
276        Ok(())
277    }
278
279    /// Count orders matching a filter.
280    pub fn count(&self, filter: OrderFilter) -> Result<u64> {
281        self.db.orders().count(filter)
282    }
283
284    /// Cancel an order.
285    #[tracing::instrument(skip(self), fields(order_id = %id))]
286    pub fn cancel(&self, id: OrderId) -> Result<Order> {
287        tracing::info!("cancelling order");
288        self.update_status(id, OrderStatus::Cancelled)
289    }
290
291    /// Mark an order as shipped.
292    #[tracing::instrument(skip(self), fields(order_id = %id, has_tracking = tracking_number.is_some()))]
293    pub fn ship(&self, id: OrderId, tracking_number: Option<&str>) -> Result<Order> {
294        tracing::info!("shipping order");
295        if let Some(order) = self.get(id)? {
296            match order.status {
297                OrderStatus::Pending => {
298                    self.update_status(id, OrderStatus::Confirmed)?;
299                    self.update_status(id, OrderStatus::Processing)?;
300                }
301                OrderStatus::Confirmed => {
302                    self.update_status(id, OrderStatus::Processing)?;
303                }
304                _ => {}
305            }
306        }
307        self.update(
308            id,
309            UpdateOrder {
310                status: Some(OrderStatus::Shipped),
311                tracking_number: tracking_number.map(std::string::ToString::to_string),
312                ..Default::default()
313            },
314        )
315    }
316
317    /// Mark an order as delivered.
318    #[tracing::instrument(skip(self), fields(order_id = %id))]
319    pub fn deliver(&self, id: OrderId) -> Result<Order> {
320        tracing::info!("marking order as delivered");
321        self.update_status(id, OrderStatus::Delivered)
322    }
323}