1use 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
19pub 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 #[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 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 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 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 pub fn get(&self, id: OrderId) -> Result<Option<Order>> {
188 self.db.orders().get(id)
189 }
190
191 pub fn get_by_number(&self, order_number: &str) -> Result<Option<Order>> {
193 self.db.orders().get_by_number(order_number)
194 }
195
196 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 #[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 pub fn list(&self, filter: OrderFilter) -> Result<Vec<Order>> {
240 self.db.orders().list(filter)
241 }
242
243 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 pub fn delete(&self, id: OrderId) -> Result<()> {
250 self.db.orders().delete(id)
251 }
252
253 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 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 pub fn count(&self, filter: OrderFilter) -> Result<u64> {
281 self.db.orders().count(filter)
282 }
283
284 #[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 #[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 #[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}