use rust_decimal::prelude::ToPrimitive;
use stateset_core::{
CreateOrder, CreateOrderItem, CustomerId, Order, OrderFilter, OrderId, OrderItem, OrderItemId,
OrderStatus, PaymentStatus, Result, UpdateOrder,
};
use stateset_db::Database;
use stateset_observability::Metrics;
use std::sync::Arc;
#[cfg(feature = "events")]
use crate::events::EventSystem;
#[cfg(feature = "events")]
use chrono::Utc;
#[cfg(feature = "events")]
use stateset_core::CommerceEvent;
pub struct Orders {
db: Arc<dyn Database>,
metrics: Metrics,
#[cfg(feature = "events")]
event_system: Arc<EventSystem>,
}
impl std::fmt::Debug for Orders {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Orders").finish_non_exhaustive()
}
}
impl Orders {
#[cfg(feature = "events")]
pub(crate) fn new(
db: Arc<dyn Database>,
event_system: Arc<EventSystem>,
metrics: Metrics,
) -> Self {
Self { db, metrics, event_system }
}
#[cfg(not(feature = "events"))]
pub(crate) fn new(db: Arc<dyn Database>, metrics: Metrics) -> Self {
Self { db, metrics }
}
#[cfg(feature = "events")]
fn emit(&self, event: CommerceEvent) {
self.event_system.emit(event);
}
#[cfg(feature = "events")]
fn emit_order_change_events(&self, previous: &Order, updated: &Order) {
if previous.status != updated.status {
self.emit(CommerceEvent::OrderStatusChanged {
order_id: updated.id,
from_status: previous.status,
to_status: updated.status,
timestamp: updated.updated_at,
});
if updated.status == OrderStatus::Cancelled {
self.emit(CommerceEvent::OrderCancelled {
order_id: updated.id,
reason: updated.notes.clone(),
timestamp: updated.updated_at,
});
}
}
if previous.payment_status != updated.payment_status {
self.emit(CommerceEvent::OrderPaymentStatusChanged {
order_id: updated.id,
from_status: previous.payment_status,
to_status: updated.payment_status,
timestamp: updated.updated_at,
});
}
if previous.fulfillment_status != updated.fulfillment_status {
self.emit(CommerceEvent::OrderFulfillmentStatusChanged {
order_id: updated.id,
from_status: previous.fulfillment_status,
to_status: updated.fulfillment_status,
timestamp: updated.updated_at,
});
}
}
#[tracing::instrument(skip(self, input), fields(customer_id = %input.customer_id, items = input.items.len()))]
pub fn create(&self, input: CreateOrder) -> Result<Order> {
tracing::info!("creating order");
let pricing_items: Vec<stateset_pricing::LineItem> = input
.items
.iter()
.map(|item| stateset_pricing::LineItem {
sku: item.sku.clone(),
name: item.name.clone(),
unit_price: item.unit_price,
quantity: item.quantity as u32,
discount: item.discount.map(stateset_pricing::LineDiscount::FixedAmount),
tax_rate: None,
})
.collect();
let pricing_input = stateset_pricing::OrderTotalInput {
items: pricing_items,
shipping_cost: rust_decimal::Decimal::ZERO,
shipping_tax_rate: None,
order_discount: None,
fees: vec![],
rounding: {
let minor = stateset_pricing::minor_units_for_currency(
&input.currency.unwrap_or_default().to_string(),
);
stateset_pricing::RoundingPolicy::new(stateset_pricing::RoundingMode::HalfUp, minor)
},
};
let pricing_total = stateset_pricing::try_compute_order_total(&pricing_input).ok();
if let Some(ref computed) = pricing_total {
tracing::debug!(
computed_total = %computed.grand_total,
subtotal = %computed.subtotal,
discount = %computed.total_discount,
"Pricing engine computed order total"
);
}
let order = self.db.orders().create(input)?;
if let Some(computed) = pricing_total {
let diff = (order.total_amount - computed.grand_total).abs();
if diff > rust_decimal::Decimal::new(1, 2) {
tracing::warn!(
order_id = %order.id,
db_total = %order.total_amount,
engine_total = %computed.grand_total,
diff = %diff,
"Pricing drift detected: DB total differs from pricing engine"
);
}
}
self.metrics.record_order_created(
&order.customer_id.to_string(),
order.total_amount.to_f64().unwrap_or(0.0),
);
#[cfg(feature = "events")]
{
self.emit(CommerceEvent::OrderCreated {
order_id: order.id,
customer_id: order.customer_id,
total_amount: order.total_amount,
item_count: order.items.len(),
timestamp: order.created_at,
});
}
Ok(order)
}
pub fn get(&self, id: OrderId) -> Result<Option<Order>> {
self.db.orders().get(id)
}
pub fn get_by_number(&self, order_number: &str) -> Result<Option<Order>> {
self.db.orders().get_by_number(order_number)
}
pub fn update(&self, id: OrderId, input: UpdateOrder) -> Result<Order> {
#[cfg(feature = "events")]
let previous = self.db.orders().get(id)?;
let updated = self.db.orders().update(id, input)?;
#[cfg(feature = "events")]
if let Some(previous) = previous {
self.emit_order_change_events(&previous, &updated);
}
Ok(updated)
}
#[tracing::instrument(skip(self), fields(order_id = %id, status = ?status))]
pub fn update_status(&self, id: OrderId, status: OrderStatus) -> Result<Order> {
tracing::info!("updating order status");
let mut tracking_number = None;
let mut payment_status = None;
if status == OrderStatus::Shipped {
if let Some(order) = self.get(id)? {
if order.tracking_number.is_none() {
tracking_number = Some(format!("AUTO-{id}"));
}
}
}
if status == OrderStatus::Refunded {
payment_status = Some(PaymentStatus::Refunded);
}
self.update(
id,
UpdateOrder {
status: Some(status),
payment_status,
tracking_number,
..Default::default()
},
)
}
pub fn list(&self, filter: OrderFilter) -> Result<Vec<Order>> {
self.db.orders().list(filter)
}
pub fn list_for_customer(&self, customer_id: CustomerId) -> Result<Vec<Order>> {
self.db.orders().list(OrderFilter { customer_id: Some(customer_id), ..Default::default() })
}
pub fn delete(&self, id: OrderId) -> Result<()> {
self.db.orders().delete(id)
}
pub fn add_item(&self, order_id: OrderId, item: CreateOrderItem) -> Result<OrderItem> {
let order_item = self.db.orders().add_item(order_id, item)?;
#[cfg(feature = "events")]
{
self.emit(CommerceEvent::OrderItemAdded {
order_id,
item_id: order_item.id,
sku: order_item.sku.clone(),
quantity: order_item.quantity,
timestamp: Utc::now(),
});
}
Ok(order_item)
}
pub fn remove_item(&self, order_id: OrderId, item_id: OrderItemId) -> Result<()> {
self.db.orders().remove_item(order_id, item_id)?;
#[cfg(feature = "events")]
{
self.emit(CommerceEvent::OrderItemRemoved { order_id, item_id, timestamp: Utc::now() });
}
Ok(())
}
pub fn count(&self, filter: OrderFilter) -> Result<u64> {
self.db.orders().count(filter)
}
#[tracing::instrument(skip(self), fields(order_id = %id))]
pub fn cancel(&self, id: OrderId) -> Result<Order> {
tracing::info!("cancelling order");
self.update_status(id, OrderStatus::Cancelled)
}
#[tracing::instrument(skip(self), fields(order_id = %id, has_tracking = tracking_number.is_some()))]
pub fn ship(&self, id: OrderId, tracking_number: Option<&str>) -> Result<Order> {
tracing::info!("shipping order");
if let Some(order) = self.get(id)? {
match order.status {
OrderStatus::Pending => {
self.update_status(id, OrderStatus::Confirmed)?;
self.update_status(id, OrderStatus::Processing)?;
}
OrderStatus::Confirmed => {
self.update_status(id, OrderStatus::Processing)?;
}
_ => {}
}
}
self.update(
id,
UpdateOrder {
status: Some(OrderStatus::Shipped),
tracking_number: tracking_number.map(std::string::ToString::to_string),
..Default::default()
},
)
}
#[tracing::instrument(skip(self), fields(order_id = %id))]
pub fn deliver(&self, id: OrderId) -> Result<Order> {
tracing::info!("marking order as delivered");
self.update_status(id, OrderStatus::Delivered)
}
}