use chrono::{DateTime, Utc};
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use stateset_primitives::{CurrencyCode, ProductId, PurchaseOrderId};
use strum::{Display, EnumString};
use uuid::Uuid;
use crate::errors::Result;
use crate::validation::{Validate, ValidationBuilder};
#[derive(Debug, Clone, Copy, PartialEq, Eq, strum::Display, Serialize, Deserialize, Default)]
#[strum(serialize_all = "snake_case")]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum PurchaseOrderStatus {
#[default]
Draft,
PendingApproval,
Approved,
Sent,
Acknowledged,
PartiallyReceived,
Received,
Completed,
Cancelled,
OnHold,
}
impl PurchaseOrderStatus {
#[must_use]
pub fn can_transition_to(self, next: Self) -> bool {
if self == next {
return true;
}
match self {
Self::Draft => matches!(next, Self::PendingApproval | Self::Cancelled),
Self::PendingApproval => {
matches!(next, Self::Approved | Self::OnHold | Self::Cancelled)
}
Self::Approved => matches!(next, Self::Sent | Self::OnHold | Self::Cancelled),
Self::Sent => matches!(
next,
Self::Acknowledged
| Self::PartiallyReceived
| Self::Received
| Self::OnHold
| Self::Cancelled
),
Self::Acknowledged => matches!(
next,
Self::PartiallyReceived | Self::Received | Self::OnHold | Self::Cancelled
),
Self::PartiallyReceived => matches!(next, Self::Received | Self::Cancelled),
Self::Received => matches!(next, Self::Completed),
Self::OnHold => matches!(
next,
Self::PendingApproval
| Self::Approved
| Self::Sent
| Self::Acknowledged
| Self::Cancelled
),
Self::Completed | Self::Cancelled => false,
}
}
#[must_use]
pub const fn is_terminal(self) -> bool {
matches!(self, Self::Completed | Self::Cancelled)
}
}
impl std::str::FromStr for PurchaseOrderStatus {
type Err = String;
fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"draft" => Ok(Self::Draft),
"pending_approval" => Ok(Self::PendingApproval),
"approved" => Ok(Self::Approved),
"sent" => Ok(Self::Sent),
"acknowledged" => Ok(Self::Acknowledged),
"partially_received" => Ok(Self::PartiallyReceived),
"received" => Ok(Self::Received),
"completed" => Ok(Self::Completed),
"cancelled" | "canceled" => Ok(Self::Cancelled),
"on_hold" => Ok(Self::OnHold),
_ => Err(format!("Unknown purchase order status: {s}")),
}
}
}
#[derive(
Debug, Clone, Copy, PartialEq, Eq, Display, EnumString, Serialize, Deserialize, Default,
)]
#[strum(serialize_all = "snake_case", ascii_case_insensitive)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum PaymentTerms {
#[default]
DueOnReceipt,
#[strum(serialize = "net_15", serialize = "net15")]
Net15,
#[strum(serialize = "net_30", serialize = "net30")]
Net30,
#[strum(serialize = "net_45", serialize = "net45")]
Net45,
#[strum(serialize = "net_60", serialize = "net60")]
Net60,
#[strum(serialize = "net_90", serialize = "net90")]
Net90,
#[strum(serialize = "2_10_net_30", serialize = "2/10_net_30")]
TwoTenNet30,
Prepaid,
#[strum(serialize = "cash_on_delivery", serialize = "cod")]
CashOnDelivery,
#[strum(serialize = "letter_of_credit", serialize = "lc")]
LetterOfCredit,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Supplier {
pub id: Uuid,
pub supplier_code: String,
pub name: String,
pub contact_name: Option<String>,
pub email: Option<String>,
pub phone: Option<String>,
pub website: Option<String>,
pub address: Option<String>,
pub city: Option<String>,
pub state: Option<String>,
pub postal_code: Option<String>,
pub country: Option<String>,
pub tax_id: Option<String>,
pub payment_terms: PaymentTerms,
pub currency: CurrencyCode,
pub lead_time_days: Option<i32>,
pub minimum_order: Option<Decimal>,
pub is_active: bool,
pub notes: Option<String>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct CreateSupplier {
pub name: String,
pub supplier_code: Option<String>,
pub contact_name: Option<String>,
pub email: Option<String>,
pub phone: Option<String>,
pub website: Option<String>,
pub address: Option<String>,
pub city: Option<String>,
pub state: Option<String>,
pub postal_code: Option<String>,
pub country: Option<String>,
pub tax_id: Option<String>,
pub payment_terms: Option<PaymentTerms>,
pub currency: Option<CurrencyCode>,
pub lead_time_days: Option<i32>,
pub minimum_order: Option<Decimal>,
pub notes: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct UpdateSupplier {
pub name: Option<String>,
pub contact_name: Option<String>,
pub email: Option<String>,
pub phone: Option<String>,
pub website: Option<String>,
pub address: Option<String>,
pub city: Option<String>,
pub state: Option<String>,
pub postal_code: Option<String>,
pub country: Option<String>,
pub tax_id: Option<String>,
pub payment_terms: Option<PaymentTerms>,
pub currency: Option<CurrencyCode>,
pub lead_time_days: Option<i32>,
pub minimum_order: Option<Decimal>,
pub is_active: Option<bool>,
pub notes: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct SupplierFilter {
pub name: Option<String>,
pub country: Option<String>,
pub active_only: Option<bool>,
pub limit: Option<u32>,
pub offset: Option<u32>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PurchaseOrder {
pub id: PurchaseOrderId,
pub po_number: String,
pub supplier_id: Uuid,
pub status: PurchaseOrderStatus,
pub order_date: DateTime<Utc>,
pub expected_date: Option<DateTime<Utc>>,
pub delivered_date: Option<DateTime<Utc>>,
pub ship_to_address: Option<String>,
pub ship_to_city: Option<String>,
pub ship_to_state: Option<String>,
pub ship_to_postal_code: Option<String>,
pub ship_to_country: Option<String>,
pub payment_terms: PaymentTerms,
pub currency: CurrencyCode,
pub subtotal: Decimal,
pub tax_amount: Decimal,
pub shipping_cost: Decimal,
pub discount_amount: Decimal,
pub total: Decimal,
pub amount_paid: Decimal,
pub supplier_reference: Option<String>,
pub notes: Option<String>,
pub supplier_notes: Option<String>,
pub approved_by: Option<String>,
pub approved_at: Option<DateTime<Utc>>,
pub items: Vec<PurchaseOrderItem>,
pub sent_at: Option<DateTime<Utc>>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
impl Validate for PurchaseOrder {
fn validate(&self) -> Result<()> {
ValidationBuilder::new()
.required("po_number", &self.po_number)
.uuid_not_nil("supplier_id", self.supplier_id)
.currency_code("currency", self.currency.as_str())
.non_negative("subtotal", self.subtotal)
.non_negative("tax_amount", self.tax_amount)
.non_negative("shipping_cost", self.shipping_cost)
.non_negative("discount_amount", self.discount_amount)
.non_negative("total", self.total)
.non_negative("amount_paid", self.amount_paid)
.build()?;
for item in &self.items {
item.validate()?;
}
Ok(())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PurchaseOrderItem {
pub id: Uuid,
pub purchase_order_id: PurchaseOrderId,
pub product_id: Option<ProductId>,
pub sku: String,
pub name: String,
pub supplier_sku: Option<String>,
pub quantity_ordered: Decimal,
pub quantity_received: Decimal,
pub unit_of_measure: Option<String>,
pub unit_cost: Decimal,
pub line_total: Decimal,
pub tax_amount: Decimal,
pub discount_amount: Decimal,
pub expected_date: Option<DateTime<Utc>>,
pub notes: Option<String>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
impl Validate for PurchaseOrderItem {
fn validate(&self) -> Result<()> {
ValidationBuilder::new()
.required("sku", &self.sku)
.required("name", &self.name)
.positive("quantity_ordered", self.quantity_ordered)
.non_negative("quantity_received", self.quantity_received)
.non_negative("unit_cost", self.unit_cost)
.non_negative("tax_amount", self.tax_amount)
.non_negative("discount_amount", self.discount_amount)
.build()
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct CreatePurchaseOrder {
pub supplier_id: Uuid,
pub order_date: Option<DateTime<Utc>>,
pub expected_date: Option<DateTime<Utc>>,
pub ship_to_address: Option<String>,
pub ship_to_city: Option<String>,
pub ship_to_state: Option<String>,
pub ship_to_postal_code: Option<String>,
pub ship_to_country: Option<String>,
pub payment_terms: Option<PaymentTerms>,
pub currency: Option<CurrencyCode>,
pub tax_amount: Option<Decimal>,
pub shipping_cost: Option<Decimal>,
pub discount_amount: Option<Decimal>,
pub notes: Option<String>,
pub supplier_notes: Option<String>,
pub items: Vec<CreatePurchaseOrderItem>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct CreatePurchaseOrderItem {
pub product_id: Option<ProductId>,
pub sku: String,
pub name: String,
pub supplier_sku: Option<String>,
pub quantity: Decimal,
pub unit_of_measure: Option<String>,
pub unit_cost: Decimal,
pub tax_amount: Option<Decimal>,
pub discount_amount: Option<Decimal>,
pub expected_date: Option<DateTime<Utc>>,
pub notes: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct UpdatePurchaseOrder {
pub expected_date: Option<DateTime<Utc>>,
pub ship_to_address: Option<String>,
pub ship_to_city: Option<String>,
pub ship_to_state: Option<String>,
pub ship_to_postal_code: Option<String>,
pub ship_to_country: Option<String>,
pub payment_terms: Option<PaymentTerms>,
pub tax_amount: Option<Decimal>,
pub shipping_cost: Option<Decimal>,
pub discount_amount: Option<Decimal>,
pub notes: Option<String>,
pub supplier_notes: Option<String>,
pub supplier_reference: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ReceivePurchaseOrderItems {
pub items: Vec<ReceivePurchaseOrderItem>,
pub notes: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ReceivePurchaseOrderItem {
pub item_id: Uuid,
pub quantity_received: Decimal,
pub notes: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct PurchaseOrderFilter {
pub supplier_id: Option<Uuid>,
pub status: Option<PurchaseOrderStatus>,
pub from_date: Option<DateTime<Utc>>,
pub to_date: Option<DateTime<Utc>>,
pub min_total: Option<Decimal>,
pub max_total: Option<Decimal>,
pub limit: Option<u32>,
pub offset: Option<u32>,
pub after_cursor: Option<(String, String)>,
}
#[must_use]
pub fn generate_supplier_code() -> String {
let now = chrono::Utc::now();
let short_id = &uuid::Uuid::new_v4().simple().to_string()[..8];
format!("SUP-{}-{short_id}", now.format("%Y%m%d%H%M%S"))
}
#[must_use]
pub fn generate_po_number() -> String {
let now = chrono::Utc::now();
let short_id = &uuid::Uuid::new_v4().to_string()[..8];
format!("PO-{}-{}", now.format("%Y%m%d%H%M%S"), short_id)
}
#[cfg(test)]
mod tests {
use super::PurchaseOrderStatus as S;
use super::*;
use rust_decimal_macros::dec;
fn create_test_item(quantity: Decimal, unit_cost: Decimal) -> PurchaseOrderItem {
let now = Utc::now();
PurchaseOrderItem {
id: Uuid::new_v4(),
purchase_order_id: PurchaseOrderId::new(),
product_id: Some(ProductId::new()),
sku: "WIDGET-001".to_string(),
name: "Widget".to_string(),
supplier_sku: None,
quantity_ordered: quantity,
quantity_received: Decimal::ZERO,
unit_of_measure: None,
unit_cost,
line_total: quantity * unit_cost,
tax_amount: Decimal::ZERO,
discount_amount: Decimal::ZERO,
expected_date: None,
notes: None,
created_at: now,
updated_at: now,
}
}
fn create_test_po() -> PurchaseOrder {
let now = Utc::now();
let items = vec![create_test_item(dec!(10), dec!(4.50))];
let subtotal: Decimal = items.iter().map(|i| i.line_total).sum();
PurchaseOrder {
id: PurchaseOrderId::new(),
po_number: generate_po_number(),
supplier_id: Uuid::new_v4(),
status: S::Draft,
order_date: now,
expected_date: None,
delivered_date: None,
ship_to_address: None,
ship_to_city: None,
ship_to_state: None,
ship_to_postal_code: None,
ship_to_country: None,
payment_terms: PaymentTerms::Net30,
currency: CurrencyCode::USD,
subtotal,
tax_amount: Decimal::ZERO,
shipping_cost: Decimal::ZERO,
discount_amount: Decimal::ZERO,
total: subtotal,
amount_paid: Decimal::ZERO,
supplier_reference: None,
notes: None,
supplier_notes: None,
approved_by: None,
approved_at: None,
items,
sent_at: None,
created_at: now,
updated_at: now,
}
}
#[test]
fn generated_supplier_codes_include_entropy_suffix() {
let first = generate_supplier_code();
let second = generate_supplier_code();
assert!(first.starts_with("SUP-"));
assert!(first.len() > "SUP-20260101120000".len());
assert_ne!(first, second);
}
#[test]
fn happy_path_lifecycle_transitions_are_allowed() {
assert!(S::Draft.can_transition_to(S::PendingApproval));
assert!(S::PendingApproval.can_transition_to(S::Approved));
assert!(S::Approved.can_transition_to(S::Sent));
assert!(S::Sent.can_transition_to(S::Acknowledged));
assert!(S::Acknowledged.can_transition_to(S::PartiallyReceived));
assert!(S::PartiallyReceived.can_transition_to(S::Received));
assert!(S::Received.can_transition_to(S::Completed));
}
#[test]
fn receiving_can_skip_partial_and_acknowledgement() {
assert!(S::Sent.can_transition_to(S::PartiallyReceived));
assert!(S::Sent.can_transition_to(S::Received));
assert!(S::Acknowledged.can_transition_to(S::Received));
}
#[test]
fn hold_is_reachable_from_active_states() {
assert!(S::PendingApproval.can_transition_to(S::OnHold));
assert!(S::Approved.can_transition_to(S::OnHold));
assert!(S::Sent.can_transition_to(S::OnHold));
assert!(S::Acknowledged.can_transition_to(S::OnHold));
}
#[test]
fn hold_can_release_back_to_active_states() {
assert!(S::OnHold.can_transition_to(S::PendingApproval));
assert!(S::OnHold.can_transition_to(S::Approved));
assert!(S::OnHold.can_transition_to(S::Sent));
assert!(S::OnHold.can_transition_to(S::Acknowledged));
assert!(S::OnHold.can_transition_to(S::Cancelled));
}
#[test]
fn cancel_is_allowed_from_pre_received_states() {
assert!(S::Draft.can_transition_to(S::Cancelled));
assert!(S::PendingApproval.can_transition_to(S::Cancelled));
assert!(S::Approved.can_transition_to(S::Cancelled));
assert!(S::Sent.can_transition_to(S::Cancelled));
assert!(S::Acknowledged.can_transition_to(S::Cancelled));
assert!(S::PartiallyReceived.can_transition_to(S::Cancelled));
}
#[test]
fn idempotent_transitions_are_allowed() {
for status in [S::Draft, S::Sent, S::OnHold, S::Completed, S::Cancelled] {
assert!(status.can_transition_to(status), "{status} -> {status} should be allowed");
}
}
#[test]
fn draft_cannot_skip_ahead_or_hold() {
assert!(!S::Draft.can_transition_to(S::Approved));
assert!(!S::Draft.can_transition_to(S::Sent));
assert!(!S::Draft.can_transition_to(S::Received));
assert!(!S::Draft.can_transition_to(S::OnHold));
}
#[test]
fn lifecycle_cannot_move_backwards() {
assert!(!S::Approved.can_transition_to(S::PendingApproval));
assert!(!S::Sent.can_transition_to(S::Draft));
assert!(!S::Acknowledged.can_transition_to(S::Sent));
assert!(!S::Received.can_transition_to(S::PartiallyReceived));
}
#[test]
fn received_cannot_be_cancelled_or_held() {
assert!(!S::Received.can_transition_to(S::Cancelled));
assert!(!S::Received.can_transition_to(S::OnHold));
assert!(!S::PartiallyReceived.can_transition_to(S::OnHold));
}
#[test]
fn terminal_states_reject_all_transitions() {
for terminal in [S::Completed, S::Cancelled] {
assert!(terminal.is_terminal());
for next in [
S::Draft,
S::PendingApproval,
S::Approved,
S::Sent,
S::Acknowledged,
S::PartiallyReceived,
S::Received,
S::OnHold,
] {
assert!(!terminal.can_transition_to(next), "{terminal} -> {next} should be denied");
}
}
assert!(!S::Completed.can_transition_to(S::Cancelled));
assert!(!S::Cancelled.can_transition_to(S::Completed));
}
#[test]
fn hold_cannot_release_into_receiving_or_terminal_states() {
assert!(!S::OnHold.can_transition_to(S::Draft));
assert!(!S::OnHold.can_transition_to(S::PartiallyReceived));
assert!(!S::OnHold.can_transition_to(S::Received));
assert!(!S::OnHold.can_transition_to(S::Completed));
}
#[test]
fn non_terminal_states_are_not_terminal() {
for status in [
S::Draft,
S::PendingApproval,
S::Approved,
S::Sent,
S::Acknowledged,
S::PartiallyReceived,
S::Received,
S::OnHold,
] {
assert!(!status.is_terminal(), "{status} should not be terminal");
}
}
#[test]
fn purchase_order_status_round_trips_through_strings() {
for status in [
S::Draft,
S::PendingApproval,
S::Approved,
S::Sent,
S::Acknowledged,
S::PartiallyReceived,
S::Received,
S::Completed,
S::Cancelled,
S::OnHold,
] {
let parsed: S = status.to_string().parse().expect("status should round-trip");
assert_eq!(parsed, status);
}
assert_eq!("canceled".parse::<S>(), Ok(S::Cancelled));
assert!("bogus".parse::<S>().is_err());
}
#[test]
fn payment_terms_round_trip_through_strings() {
for terms in [
PaymentTerms::DueOnReceipt,
PaymentTerms::Net15,
PaymentTerms::Net30,
PaymentTerms::Net90,
PaymentTerms::TwoTenNet30,
PaymentTerms::CashOnDelivery,
] {
let parsed: PaymentTerms = terms.to_string().parse().expect("terms should round-trip");
assert_eq!(parsed, terms);
}
assert_eq!("cod".parse::<PaymentTerms>(), Ok(PaymentTerms::CashOnDelivery));
assert_eq!("net30".parse::<PaymentTerms>(), Ok(PaymentTerms::Net30));
}
#[test]
fn valid_purchase_order_passes_validation() {
assert!(create_test_po().validate().is_ok());
}
#[test]
fn purchase_order_with_nil_supplier_fails_validation() {
let mut po = create_test_po();
po.supplier_id = Uuid::nil();
assert!(po.validate().is_err());
}
#[test]
fn purchase_order_with_empty_po_number_fails_validation() {
let mut po = create_test_po();
po.po_number = String::new();
assert!(po.validate().is_err());
}
#[test]
fn purchase_order_with_negative_amounts_fails_validation() {
let mut po = create_test_po();
po.total = dec!(-1.00);
assert!(po.validate().is_err());
let mut po = create_test_po();
po.shipping_cost = dec!(-5.00);
assert!(po.validate().is_err());
}
#[test]
fn purchase_order_item_requires_positive_quantity() {
let mut po = create_test_po();
po.items[0].quantity_ordered = Decimal::ZERO;
assert!(po.validate().is_err());
po.items[0].quantity_ordered = dec!(-3);
assert!(po.validate().is_err());
}
#[test]
fn purchase_order_item_rejects_negative_cost_and_empty_sku() {
let mut po = create_test_po();
po.items[0].unit_cost = dec!(-0.01);
assert!(po.validate().is_err());
let mut po = create_test_po();
po.items[0].sku = String::new();
assert!(po.validate().is_err());
}
}