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});