Skip to main content

commerce_theory/
orders.rs

1use core::marker::PhantomData;
2
3use crate::foundation::*;
4use crate::pricing::*;
5
6#[derive(Clone, Copy, Debug, PartialEq, Eq)]
7#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
8pub enum OrderStatus {
9    New,
10    Paid,
11    Packed,
12    Shipped,
13    Delivered,
14    Cancelled,
15    Refunded,
16    Backordered,
17}
18
19#[derive(Clone, Debug, PartialEq, Eq)]
20#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
21pub struct Order {
22    pub(crate) id: OrderId,
23    pub(crate) items: Vec<CartLine>,
24    pub(crate) coupon_amount: Money,
25    pub(crate) shipping_method: ShippingMethod,
26    pub(crate) tax: Money,
27    pub(crate) currency: Currency,
28    pub(crate) status: OrderStatus,
29    pub(crate) total: Money,
30}
31
32impl Order {
33    pub fn try_new(
34        id: OrderId,
35        items: Vec<CartLine>,
36        coupon_amount: Money,
37        shipping_method: ShippingMethod,
38        tax: Money,
39        currency: Currency,
40        status: OrderStatus,
41        total: Money,
42    ) -> DomainResult<Self> {
43        if coupon_amount > cart_net_total(&items)? {
44            return Err(ValidationError::CouponExceedsSubtotal);
45        }
46        if !shipping_available(&shipping_method, cart_weight_total(&items)?) {
47            return Err(ValidationError::Invariant(
48                "shipping method cannot carry cart",
49            ));
50        }
51        let expected_total = order_total(&shipping_method, coupon_amount, tax, &items)?;
52        if total != expected_total {
53            return Err(ValidationError::Invariant(
54                "stored order total is incorrect",
55            ));
56        }
57        Ok(Self {
58            id,
59            items,
60            coupon_amount,
61            shipping_method,
62            tax,
63            currency,
64            status,
65            total,
66        })
67    }
68
69    #[must_use]
70    pub const fn id(&self) -> OrderId {
71        self.id
72    }
73
74    #[must_use]
75    pub fn items(&self) -> &[CartLine] {
76        &self.items
77    }
78
79    #[must_use]
80    pub const fn total(&self) -> Money {
81        self.total
82    }
83
84    #[must_use]
85    pub const fn currency(&self) -> Currency {
86        self.currency
87    }
88
89    #[must_use]
90    pub const fn shipping_method(&self) -> &ShippingMethod {
91        &self.shipping_method
92    }
93
94    #[must_use]
95    pub const fn tax(&self) -> Money {
96        self.tax
97    }
98}
99
100#[must_use]
101pub const fn can_order_transition(source: OrderStatus, target: OrderStatus) -> bool {
102    matches!(
103        (source, target),
104        (
105            OrderStatus::New | OrderStatus::Backordered,
106            OrderStatus::Paid | OrderStatus::Cancelled
107        ) | (OrderStatus::New, OrderStatus::Backordered)
108            | (
109                OrderStatus::Paid,
110                OrderStatus::Packed | OrderStatus::Refunded
111            )
112            | (OrderStatus::Packed, OrderStatus::Shipped)
113            | (OrderStatus::Shipped, OrderStatus::Delivered)
114            | (OrderStatus::Delivered, OrderStatus::Refunded)
115    )
116}
117
118#[derive(Clone, Copy, Debug, PartialEq, Eq)]
119#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
120pub enum CanOrderTransition {
121    NewPaid,
122    NewCancelled,
123    NewBackordered,
124    PaidPacked,
125    PaidRefunded,
126    PackedShipped,
127    ShippedDelivered,
128    DeliveredRefunded,
129    BackorderedPaid,
130    BackorderedCancelled,
131}
132
133impl CanOrderTransition {
134    #[must_use]
135    pub const fn source(self) -> OrderStatus {
136        match self {
137            Self::NewPaid | Self::NewCancelled | Self::NewBackordered => OrderStatus::New,
138            Self::PaidPacked | Self::PaidRefunded => OrderStatus::Paid,
139            Self::PackedShipped => OrderStatus::Packed,
140            Self::ShippedDelivered => OrderStatus::Shipped,
141            Self::DeliveredRefunded => OrderStatus::Delivered,
142            Self::BackorderedPaid | Self::BackorderedCancelled => OrderStatus::Backordered,
143        }
144    }
145
146    #[must_use]
147    pub const fn target(self) -> OrderStatus {
148        match self {
149            Self::NewPaid | Self::BackorderedPaid => OrderStatus::Paid,
150            Self::NewCancelled | Self::BackorderedCancelled => OrderStatus::Cancelled,
151            Self::NewBackordered => OrderStatus::Backordered,
152            Self::PaidPacked => OrderStatus::Packed,
153            Self::PaidRefunded | Self::DeliveredRefunded => OrderStatus::Refunded,
154            Self::PackedShipped => OrderStatus::Shipped,
155            Self::ShippedDelivered => OrderStatus::Delivered,
156        }
157    }
158
159    #[must_use]
160    pub const fn from_statuses(source: OrderStatus, target: OrderStatus) -> Option<Self> {
161        match (source, target) {
162            (OrderStatus::New, OrderStatus::Paid) => Some(Self::NewPaid),
163            (OrderStatus::New, OrderStatus::Cancelled) => Some(Self::NewCancelled),
164            (OrderStatus::New, OrderStatus::Backordered) => Some(Self::NewBackordered),
165            (OrderStatus::Paid, OrderStatus::Packed) => Some(Self::PaidPacked),
166            (OrderStatus::Paid, OrderStatus::Refunded) => Some(Self::PaidRefunded),
167            (OrderStatus::Packed, OrderStatus::Shipped) => Some(Self::PackedShipped),
168            (OrderStatus::Shipped, OrderStatus::Delivered) => Some(Self::ShippedDelivered),
169            (OrderStatus::Delivered, OrderStatus::Refunded) => Some(Self::DeliveredRefunded),
170            (OrderStatus::Backordered, OrderStatus::Paid) => Some(Self::BackorderedPaid),
171            (OrderStatus::Backordered, OrderStatus::Cancelled) => Some(Self::BackorderedCancelled),
172            _ => None,
173        }
174    }
175}
176
177pub trait OrderStatusMarker: Clone + Copy + core::fmt::Debug + PartialEq + Eq {
178    const STATUS: OrderStatus;
179}
180
181macro_rules! order_marker {
182    ($name:ident, $status:ident) => {
183        #[derive(Clone, Copy, Debug, PartialEq, Eq)]
184        #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
185        pub struct $name;
186        impl OrderStatusMarker for $name {
187            const STATUS: OrderStatus = OrderStatus::$status;
188        }
189    };
190}
191
192order_marker!(NewOrder, New);
193order_marker!(PaidOrder, Paid);
194order_marker!(PackedOrder, Packed);
195order_marker!(ShippedOrder, Shipped);
196order_marker!(DeliveredOrder, Delivered);
197order_marker!(CancelledOrder, Cancelled);
198order_marker!(RefundedOrder, Refunded);
199order_marker!(BackorderedOrder, Backordered);
200
201#[derive(Clone, Copy, Debug, PartialEq, Eq)]
202#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
203pub struct TypedOrder<S: OrderStatusMarker> {
204    pub(crate) id: OrderId,
205    pub(crate) total: Money,
206    pub(crate) currency: Currency,
207    _state: PhantomData<S>,
208}
209
210impl<S: OrderStatusMarker> TypedOrder<S> {
211    pub const fn try_new(id: OrderId, total: Money, currency: Currency) -> DomainResult<Self> {
212        if total == 0 {
213            return Err(ValidationError::Invariant(
214                "typed order total must be positive",
215            ));
216        }
217        Ok(Self {
218            id,
219            total,
220            currency,
221            _state: PhantomData,
222        })
223    }
224
225    #[must_use]
226    pub const fn id(&self) -> OrderId {
227        self.id
228    }
229
230    #[must_use]
231    pub const fn total(&self) -> Money {
232        self.total
233    }
234
235    #[must_use]
236    pub const fn currency(&self) -> Currency {
237        self.currency
238    }
239}
240
241domain_struct! {
242    pub struct CapturedPayment {
243        order_id: OrderId,
244        amount: Money,
245        currency: Currency,
246    }
247}
248
249#[derive(Clone, Copy, Debug, PartialEq, Eq)]
250#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
251pub enum PaymentState {
252    Created,
253    Authorized,
254    Captured,
255    Failed,
256    Voided,
257    Refunded,
258}
259
260pub trait PaymentStateMarker: Clone + Copy + core::fmt::Debug + PartialEq + Eq {
261    const STATE: PaymentState;
262}
263
264macro_rules! payment_marker {
265    ($name:ident, $state:ident) => {
266        #[derive(Clone, Copy, Debug, PartialEq, Eq)]
267        #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
268        pub struct $name;
269        impl PaymentStateMarker for $name {
270            const STATE: PaymentState = PaymentState::$state;
271        }
272    };
273}
274
275payment_marker!(CreatedPayment, Created);
276payment_marker!(AuthorizedPayment, Authorized);
277payment_marker!(CapturedPaymentState, Captured);
278payment_marker!(FailedPayment, Failed);
279payment_marker!(VoidedPayment, Voided);
280payment_marker!(RefundedPayment, Refunded);
281
282#[derive(Clone, Copy, Debug, PartialEq, Eq)]
283#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
284pub struct TypedPayment<S: PaymentStateMarker> {
285    pub(crate) id: PaymentId,
286    pub(crate) order_id: OrderId,
287    pub(crate) amount: Money,
288    pub(crate) currency: Currency,
289    _state: PhantomData<S>,
290}
291
292impl<S: PaymentStateMarker> TypedPayment<S> {
293    pub const fn try_new(
294        id: PaymentId,
295        order_id: OrderId,
296        amount: Money,
297        currency: Currency,
298    ) -> DomainResult<Self> {
299        if amount == 0 {
300            return Err(ValidationError::Invariant(
301                "payment amount must be positive",
302            ));
303        }
304        Ok(Self {
305            id,
306            order_id,
307            amount,
308            currency,
309            _state: PhantomData,
310        })
311    }
312}
313
314#[must_use]
315pub const fn authorize_payment(p: TypedPayment<CreatedPayment>) -> TypedPayment<AuthorizedPayment> {
316    TypedPayment {
317        id: p.id,
318        order_id: p.order_id,
319        amount: p.amount,
320        currency: p.currency,
321        _state: PhantomData,
322    }
323}
324
325#[must_use]
326pub const fn capture_payment(
327    p: TypedPayment<AuthorizedPayment>,
328) -> (TypedPayment<CapturedPaymentState>, CapturedPayment) {
329    let receipt = CapturedPayment::new(p.order_id, p.amount, p.currency);
330    (
331        TypedPayment {
332            id: p.id,
333            order_id: p.order_id,
334            amount: p.amount,
335            currency: p.currency,
336            _state: PhantomData,
337        },
338        receipt,
339    )
340}
341
342pub fn mark_paid(
343    order: TypedOrder<NewOrder>,
344    payment: &CapturedPayment,
345) -> DomainResult<TypedOrder<PaidOrder>> {
346    if payment.order_id != order.id {
347        return Err(ValidationError::Invariant("payment order id mismatch"));
348    }
349    if payment.amount != order.total {
350        return Err(ValidationError::Invariant("payment amount mismatch"));
351    }
352    if payment.currency != order.currency {
353        return Err(ValidationError::Invariant("payment currency mismatch"));
354    }
355    Ok(TypedOrder {
356        id: order.id,
357        total: order.total,
358        currency: order.currency,
359        _state: PhantomData,
360    })
361}
362
363#[derive(Clone, Debug, PartialEq, Eq)]
364#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
365pub struct PaymentLedger {
366    pub(crate) captured: Money,
367    pub(crate) refunded: Money,
368}
369
370impl PaymentLedger {
371    pub const fn try_new(captured: Money, refunded: Money) -> DomainResult<Self> {
372        if refunded > captured {
373            return Err(ValidationError::Invariant("refunded exceeds captured"));
374        }
375        Ok(Self { captured, refunded })
376    }
377
378    #[must_use]
379    pub const fn captured(&self) -> Money {
380        self.captured
381    }
382
383    #[must_use]
384    pub const fn refunded(&self) -> Money {
385        self.refunded
386    }
387}
388
389#[must_use]
390pub const fn remaining_refund_amount(ledger: &PaymentLedger) -> Money {
391    nat_sub(ledger.captured, ledger.refunded)
392}
393
394#[must_use]
395pub fn can_refund(ledger: &PaymentLedger, amount: Money) -> bool {
396    ledger
397        .refunded
398        .checked_add(amount)
399        .is_some_and(|total| total <= ledger.captured)
400}
401
402pub fn issue_refund(ledger: &PaymentLedger, amount: Money) -> DomainResult<PaymentLedger> {
403    if !can_refund(ledger, amount) {
404        return Err(ValidationError::Invariant("refund exceeds captured amount"));
405    }
406    PaymentLedger::try_new(
407        ledger.captured,
408        checked_add(ledger.refunded, amount, "issue_refund")?,
409    )
410}
411
412impl_getters!(Order {
413    coupon_amount: Money,
414    status: OrderStatus,
415});