Skip to main content

commerce_theory/
logistics.rs

1use std::collections::HashSet;
2
3use crate::crm::*;
4use crate::foundation::*;
5use crate::fulfillment_finance::*;
6use crate::inventory::*;
7use crate::orders::*;
8use crate::pricing::*;
9
10#[derive(Clone, Copy, Debug, PartialEq, Eq)]
11#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
12pub enum ShipmentStatus {
13    Planned,
14    Allocated,
15    Packed,
16    InTransit,
17    OutForDelivery,
18    Delivered,
19    Exception,
20    Returned,
21    Cancelled,
22}
23
24#[must_use]
25pub const fn can_shipment_transition(source: ShipmentStatus, target: ShipmentStatus) -> bool {
26    matches!(
27        (source, target),
28        (
29            ShipmentStatus::Planned,
30            ShipmentStatus::Allocated | ShipmentStatus::Cancelled
31        ) | (
32            ShipmentStatus::Allocated,
33            ShipmentStatus::Packed | ShipmentStatus::Cancelled
34        ) | (
35            ShipmentStatus::Packed | ShipmentStatus::Exception,
36            ShipmentStatus::InTransit
37        ) | (
38            ShipmentStatus::InTransit,
39            ShipmentStatus::OutForDelivery | ShipmentStatus::Exception
40        ) | (
41            ShipmentStatus::OutForDelivery,
42            ShipmentStatus::Delivered | ShipmentStatus::Exception
43        ) | (ShipmentStatus::Exception, ShipmentStatus::Returned)
44    )
45}
46
47#[must_use]
48pub fn order_eligible_for_logistics(order: &Order) -> bool {
49    matches!(order.status(), OrderStatus::Paid | OrderStatus::Packed)
50}
51
52domain_struct! {
53    pub struct ShippingDestination {
54        id: Id,
55        zone: ShippingZone,
56        postal_code: Nat,
57    }
58}
59
60#[must_use]
61pub fn cart_contains_sku(sku: Sku, items: &[CartLine]) -> bool {
62    items.iter().any(|line| line.sku() == sku)
63}
64
65#[must_use]
66pub fn allocations_match_cart_skus(items: &[CartLine], allocations: &[Allocation]) -> bool {
67    allocations
68        .iter()
69        .all(|allocation| cart_contains_sku(allocation.node.stock.sku, items))
70}
71
72pub fn cart_sku_quantity_total(sku: Sku, items: &[CartLine]) -> DomainResult<Quantity> {
73    checked_sum(
74        items
75            .iter()
76            .filter(|line| line.sku() == sku)
77            .map(CartLine::quantity),
78        "cart_sku_quantity_total",
79    )
80}
81
82pub fn allocation_sku_quantity_total(
83    sku: Sku,
84    allocations: &[Allocation],
85) -> DomainResult<Quantity> {
86    checked_sum(
87        allocations
88            .iter()
89            .filter(|allocation| allocation.node.stock.sku == sku)
90            .map(|allocation| allocation.quantity),
91        "allocation_sku_quantity_total",
92    )
93}
94
95pub fn cart_sku_support(items: &[CartLine]) -> Vec<Sku> {
96    items.iter().map(CartLine::sku).collect()
97}
98
99#[must_use]
100pub fn allocation_sku_support(allocations: &[Allocation]) -> Vec<Sku> {
101    allocations
102        .iter()
103        .map(|allocation| allocation.node.stock.sku)
104        .collect()
105}
106
107#[must_use]
108pub fn shipment_sku_quantity_support(items: &[CartLine], allocations: &[Allocation]) -> Vec<Sku> {
109    let mut support = cart_sku_support(items);
110    support.extend(allocation_sku_support(allocations));
111    support
112}
113
114pub fn shipment_sku_quantities_match_keys(
115    items: &[CartLine],
116    allocations: &[Allocation],
117    keys: &[Sku],
118) -> DomainResult<bool> {
119    for sku in keys {
120        if cart_sku_quantity_total(*sku, items)?
121            != allocation_sku_quantity_total(*sku, allocations)?
122        {
123            return Ok(false);
124        }
125    }
126    Ok(true)
127}
128
129pub fn allocation_quantities_match_cart_skus(
130    items: &[CartLine],
131    allocations: &[Allocation],
132) -> DomainResult<bool> {
133    shipment_sku_quantities_match_keys(
134        items,
135        allocations,
136        &shipment_sku_quantity_support(items, allocations),
137    )
138}
139
140#[must_use]
141pub fn allocations_use_warehouse(warehouse: &Warehouse, allocations: &[Allocation]) -> bool {
142    allocations
143        .iter()
144        .all(|allocation| allocation.node.warehouse.id == warehouse.id())
145}
146
147#[derive(Clone, Debug, PartialEq, Eq)]
148#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
149pub struct LogisticsShipmentPlan {
150    pub(crate) id: ShipmentId,
151    pub(crate) order: Order,
152    pub(crate) fulfillment: DistinctFulfillmentPlan,
153    pub(crate) package: Package,
154    pub(crate) quote: CarrierQuote,
155    pub(crate) warehouse: Warehouse,
156    pub(crate) destination: ShippingDestination,
157    pub(crate) planned_ship_at: Timestamp,
158    pub(crate) promised_delivery_at: Timestamp,
159}
160
161impl LogisticsShipmentPlan {
162    #[allow(clippy::too_many_arguments)]
163    pub fn try_new(
164        id: ShipmentId,
165        order: Order,
166        fulfillment: DistinctFulfillmentPlan,
167        package: Package,
168        quote: CarrierQuote,
169        warehouse: Warehouse,
170        destination: ShippingDestination,
171        planned_ship_at: Timestamp,
172        promised_delivery_at: Timestamp,
173    ) -> DomainResult<Self> {
174        if !order_eligible_for_logistics(&order) {
175            return Err(ValidationError::LogisticsInvariantFailed);
176        }
177        if fulfillment.requested() != cart_quantity_total(order.items())? {
178            return Err(ValidationError::LogisticsInvariantFailed);
179        }
180        if !allocations_match_cart_skus(order.items(), fulfillment.allocations()) {
181            return Err(ValidationError::LogisticsInvariantFailed);
182        }
183        if !allocation_quantities_match_cart_skus(order.items(), fulfillment.allocations())? {
184            return Err(ValidationError::LogisticsInvariantFailed);
185        }
186        if !allocations_use_warehouse(&warehouse, fulfillment.allocations()) {
187            return Err(ValidationError::LogisticsInvariantFailed);
188        }
189        if quote.package != package || quote.service.zone != destination.zone {
190            return Err(ValidationError::LogisticsInvariantFailed);
191        }
192        if cart_weight_total(order.items())? > package.weight {
193            return Err(ValidationError::LogisticsInvariantFailed);
194        }
195        if promised_delivery_at < planned_ship_at {
196            return Err(ValidationError::LogisticsInvariantFailed);
197        }
198        Ok(Self {
199            id,
200            order,
201            fulfillment,
202            package,
203            quote,
204            warehouse,
205            destination,
206            planned_ship_at,
207            promised_delivery_at,
208        })
209    }
210}
211
212#[derive(Clone, Debug, PartialEq, Eq)]
213#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
214pub struct LogisticsShipment {
215    pub(crate) id: ShipmentId,
216    pub(crate) plan: LogisticsShipmentPlan,
217    pub(crate) status: ShipmentStatus,
218    pub(crate) created_at: Timestamp,
219    pub(crate) updated_at: Timestamp,
220}
221
222impl LogisticsShipment {
223    pub fn try_new(
224        id: ShipmentId,
225        plan: LogisticsShipmentPlan,
226        status: ShipmentStatus,
227        created_at: Timestamp,
228        updated_at: Timestamp,
229    ) -> DomainResult<Self> {
230        if id != plan.id || updated_at < created_at {
231            return Err(ValidationError::LogisticsInvariantFailed);
232        }
233        Ok(Self {
234            id,
235            plan,
236            status,
237            created_at,
238            updated_at,
239        })
240    }
241}
242
243pub fn transition_shipment(
244    shipment: LogisticsShipment,
245    next: ShipmentStatus,
246    updated_at: Timestamp,
247) -> DomainResult<LogisticsShipment> {
248    if !can_shipment_transition(shipment.status, next) || updated_at < shipment.created_at {
249        return Err(ValidationError::LogisticsInvariantFailed);
250    }
251    Ok(LogisticsShipment {
252        status: next,
253        updated_at,
254        ..shipment
255    })
256}
257
258#[derive(Clone, Debug, PartialEq, Eq)]
259#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
260pub struct CarrierHandoff {
261    pub(crate) plan: LogisticsShipmentPlan,
262    pub(crate) service: CarrierService,
263    pub(crate) tracking_number: Id,
264    pub(crate) handed_off_at: Timestamp,
265    pub(crate) acceptance_scan_at: Timestamp,
266}
267
268impl CarrierHandoff {
269    pub fn try_new(
270        plan: LogisticsShipmentPlan,
271        service: CarrierService,
272        tracking_number: Id,
273        handed_off_at: Timestamp,
274        acceptance_scan_at: Timestamp,
275    ) -> DomainResult<Self> {
276        if service != plan.quote.service
277            || handed_off_at < plan.planned_ship_at
278            || acceptance_scan_at < handed_off_at
279        {
280            return Err(ValidationError::LogisticsInvariantFailed);
281        }
282        Ok(Self {
283            plan,
284            service,
285            tracking_number,
286            handed_off_at,
287            acceptance_scan_at,
288        })
289    }
290}
291
292#[derive(Clone, Copy, Debug, PartialEq, Eq)]
293#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
294pub enum TrackingEventKind {
295    LabelCreated,
296    PickupScan,
297    InTransitScan,
298    OutForDeliveryScan,
299    DeliveredScan,
300    ExceptionScan,
301    ReturnScan,
302}
303
304#[must_use]
305pub const fn can_tracking_progress(source: TrackingEventKind, target: TrackingEventKind) -> bool {
306    matches!(
307        (source, target),
308        (
309            TrackingEventKind::LabelCreated,
310            TrackingEventKind::LabelCreated | TrackingEventKind::PickupScan
311        ) | (
312            TrackingEventKind::PickupScan
313                | TrackingEventKind::InTransitScan
314                | TrackingEventKind::ExceptionScan,
315            TrackingEventKind::InTransitScan
316        ) | (
317            TrackingEventKind::InTransitScan,
318            TrackingEventKind::OutForDeliveryScan | TrackingEventKind::ExceptionScan
319        ) | (
320            TrackingEventKind::OutForDeliveryScan,
321            TrackingEventKind::DeliveredScan | TrackingEventKind::ExceptionScan
322        ) | (
323            TrackingEventKind::ExceptionScan,
324            TrackingEventKind::ReturnScan
325        )
326    )
327}
328
329domain_struct! {
330    pub struct TrackingEvent {
331        id: TrackingEventId,
332        shipment_id: ShipmentId,
333        carrier_id: Id,
334        tracking_number: Id,
335        kind: TrackingEventKind,
336        occurred_at: Timestamp,
337    }
338}
339
340#[must_use]
341pub fn tracking_events_monotone_from(last: Timestamp, events: &[TrackingEvent]) -> bool {
342    let mut cursor = last;
343    for event in events {
344        if event.occurred_at < cursor {
345            return false;
346        }
347        cursor = event.occurred_at;
348    }
349    true
350}
351
352#[must_use]
353pub fn tracking_events_for_shipment(shipment_id: ShipmentId, events: &[TrackingEvent]) -> bool {
354    events.iter().all(|event| event.shipment_id == shipment_id)
355}
356
357#[must_use]
358pub fn tracking_events_for_carrier(
359    carrier_id: Id,
360    tracking_number: Id,
361    events: &[TrackingEvent],
362) -> bool {
363    events
364        .iter()
365        .all(|event| event.carrier_id == carrier_id && event.tracking_number == tracking_number)
366}
367
368#[must_use]
369pub fn tracking_last_observed_from(last: Timestamp, events: &[TrackingEvent]) -> Timestamp {
370    events.last().map_or(last, |event| event.occurred_at)
371}
372
373#[must_use]
374pub fn tracking_event_ids_distinct(events: &[TrackingEvent]) -> bool {
375    let mut seen = HashSet::new();
376    events.iter().all(|event| seen.insert(event.id.value()))
377}
378
379#[must_use]
380pub fn tracking_events_progress_from(
381    last_kind: TrackingEventKind,
382    events: &[TrackingEvent],
383) -> bool {
384    let mut cursor = last_kind;
385    for event in events {
386        if !can_tracking_progress(cursor, event.kind) {
387            return false;
388        }
389        cursor = event.kind;
390    }
391    true
392}
393
394#[derive(Clone, Debug, PartialEq, Eq)]
395#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
396pub struct TrackingHistory {
397    pub(crate) shipment_id: ShipmentId,
398    pub(crate) carrier_id: Id,
399    pub(crate) tracking_number: Id,
400    pub(crate) events: Vec<TrackingEvent>,
401    pub(crate) last_observed_at: Timestamp,
402}
403
404impl TrackingHistory {
405    pub fn try_new(
406        shipment_id: ShipmentId,
407        carrier_id: Id,
408        tracking_number: Id,
409        events: Vec<TrackingEvent>,
410        last_observed_at: Timestamp,
411    ) -> DomainResult<Self> {
412        if !tracking_events_monotone_from(unix_epoch_timestamp(), &events)
413            || !tracking_events_for_shipment(shipment_id, &events)
414            || !tracking_events_for_carrier(carrier_id, tracking_number, &events)
415            || !tracking_event_ids_distinct(&events)
416            || !tracking_events_progress_from(TrackingEventKind::LabelCreated, &events)
417            || last_observed_at != tracking_last_observed_from(unix_epoch_timestamp(), &events)
418        {
419            return Err(ValidationError::LogisticsInvariantFailed);
420        }
421        Ok(Self {
422            shipment_id,
423            carrier_id,
424            tracking_number,
425            events,
426            last_observed_at,
427        })
428    }
429}
430
431#[derive(Clone, Debug, PartialEq, Eq)]
432#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
433pub struct DeliveryPromise {
434    pub(crate) plan: LogisticsShipmentPlan,
435    pub(crate) promised_by: Timestamp,
436}
437
438impl DeliveryPromise {
439    pub fn try_new(plan: LogisticsShipmentPlan, promised_by: Timestamp) -> DomainResult<Self> {
440        if promised_by != plan.promised_delivery_at {
441            return Err(ValidationError::LogisticsInvariantFailed);
442        }
443        Ok(Self { plan, promised_by })
444    }
445}
446
447#[must_use]
448pub fn delivered_by_promise(promise: &DeliveryPromise, delivered_at: Timestamp) -> bool {
449    delivered_at <= promise.promised_by
450}
451
452#[derive(Clone, Debug, PartialEq, Eq)]
453#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
454pub struct DeliveredShipment {
455    pub(crate) promise: DeliveryPromise,
456    pub(crate) history: TrackingHistory,
457    pub(crate) delivery_event: TrackingEvent,
458    pub(crate) delivered_at: Timestamp,
459}
460
461impl DeliveredShipment {
462    pub fn try_new(
463        promise: DeliveryPromise,
464        history: TrackingHistory,
465        delivery_event: TrackingEvent,
466        delivered_at: Timestamp,
467    ) -> DomainResult<Self> {
468        if history.shipment_id != promise.plan.id
469            || history.carrier_id != promise.plan.quote.service.carrier_id
470            || !history.events.contains(&delivery_event)
471            || delivery_event.kind != TrackingEventKind::DeliveredScan
472            || delivery_event.occurred_at != delivered_at
473            || delivery_event.shipment_id != promise.plan.id
474            || delivery_event.carrier_id != history.carrier_id
475            || delivery_event.tracking_number != history.tracking_number
476            || delivered_at < promise.plan.planned_ship_at
477            || !delivered_by_promise(&promise, delivered_at)
478        {
479            return Err(ValidationError::LogisticsInvariantFailed);
480        }
481        Ok(Self {
482            promise,
483            history,
484            delivery_event,
485            delivered_at,
486        })
487    }
488}
489
490#[derive(Clone, Copy, Debug, PartialEq, Eq)]
491#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
492pub enum LogisticsExceptionKind {
493    CarrierDelay,
494    WeatherDelay,
495    AddressIssue,
496    LostPackage,
497    DamagedPackage,
498    CustomerUnavailable,
499}
500
501domain_struct! {
502    pub struct LogisticsException {
503        shipment_id: ShipmentId,
504        kind: LogisticsExceptionKind,
505        raised_at: Timestamp,
506        customer_visible: bool,
507    }
508}
509
510#[derive(Clone, Debug, PartialEq, Eq)]
511#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
512pub struct WarehouseTransfer {
513    pub(crate) id: TransferId,
514    pub(crate) sku: Sku,
515    pub(crate) from_warehouse: Warehouse,
516    pub(crate) to_warehouse: Warehouse,
517    pub(crate) source_stock: StockState,
518    pub(crate) requested: Quantity,
519    pub(crate) in_transit: Quantity,
520    pub(crate) received: Quantity,
521}
522
523impl WarehouseTransfer {
524    #[allow(clippy::too_many_arguments)]
525    pub fn try_new(
526        id: TransferId,
527        sku: Sku,
528        from_warehouse: Warehouse,
529        to_warehouse: Warehouse,
530        source_stock: StockState,
531        requested: Quantity,
532        in_transit: Quantity,
533        received: Quantity,
534    ) -> DomainResult<Self> {
535        if source_stock.sku() != sku
536            || from_warehouse.id() == to_warehouse.id()
537            || requested > available_stock(&source_stock)
538            || in_transit > requested
539            || received > in_transit
540        {
541            return Err(ValidationError::LogisticsInvariantFailed);
542        }
543        Ok(Self {
544            id,
545            sku,
546            from_warehouse,
547            to_warehouse,
548            source_stock,
549            requested,
550            in_transit,
551            received,
552        })
553    }
554}
555
556#[derive(Clone, Copy, Debug, PartialEq, Eq)]
557#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
558pub enum ReturnAuthorizationStatus {
559    Requested,
560    Approved,
561    Rejected,
562    Received,
563    Refunded,
564    Closed,
565}
566
567#[must_use]
568pub const fn can_return_authorization_transition(
569    source: ReturnAuthorizationStatus,
570    target: ReturnAuthorizationStatus,
571) -> bool {
572    matches!(
573        (source, target),
574        (
575            ReturnAuthorizationStatus::Requested,
576            ReturnAuthorizationStatus::Approved | ReturnAuthorizationStatus::Rejected
577        ) | (
578            ReturnAuthorizationStatus::Approved,
579            ReturnAuthorizationStatus::Received
580        ) | (
581            ReturnAuthorizationStatus::Received,
582            ReturnAuthorizationStatus::Refunded
583        ) | (
584            ReturnAuthorizationStatus::Refunded,
585            ReturnAuthorizationStatus::Closed
586        )
587    )
588}
589
590domain_struct! {
591    pub struct ReturnLine {
592        sku: Sku,
593        quantity: Quantity,
594        refund_amount: Money,
595    }
596}
597
598pub fn return_lines_quantity_total(lines: &[ReturnLine]) -> DomainResult<Quantity> {
599    checked_sum(
600        lines.iter().map(|line| line.quantity),
601        "return_lines_quantity_total",
602    )
603}
604
605pub fn return_lines_refund_total(lines: &[ReturnLine]) -> DomainResult<Money> {
606    checked_sum(
607        lines.iter().map(|line| line.refund_amount),
608        "return_lines_refund_total",
609    )
610}
611
612#[must_use]
613pub fn return_lines_match_order_skus(items: &[CartLine], lines: &[ReturnLine]) -> bool {
614    lines.iter().all(|line| cart_contains_sku(line.sku, items))
615}
616
617#[derive(Clone, Debug, PartialEq, Eq)]
618#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
619pub struct ReturnAuthorization {
620    pub(crate) id: ReturnAuthorizationId,
621    pub(crate) support_case: SupportCase,
622    pub(crate) order: Order,
623    pub(crate) ledger: PaymentLedger,
624    pub(crate) status: ReturnAuthorizationStatus,
625    pub(crate) lines: Vec<ReturnLine>,
626    pub(crate) quantity: Quantity,
627    pub(crate) refund_amount: Money,
628    pub(crate) requested_at: Timestamp,
629    pub(crate) decided_at: Timestamp,
630}
631
632impl ReturnAuthorization {
633    #[allow(clippy::too_many_arguments)]
634    pub fn try_new(
635        id: ReturnAuthorizationId,
636        support_case: SupportCase,
637        order: Order,
638        ledger: PaymentLedger,
639        status: ReturnAuthorizationStatus,
640        lines: Vec<ReturnLine>,
641        quantity: Quantity,
642        refund_amount: Money,
643        requested_at: Timestamp,
644        decided_at: Timestamp,
645    ) -> DomainResult<Self> {
646        if support_case.order_id != Some(order.id())
647            || !return_lines_match_order_skus(order.items(), &lines)
648            || return_lines_quantity_total(&lines)? != quantity
649            || return_lines_refund_total(&lines)? != refund_amount
650            || quantity > cart_quantity_total(order.items())?
651            || !can_refund(&ledger, refund_amount)
652            || ledger.captured() != order.total()
653            || decided_at < requested_at
654        {
655            return Err(ValidationError::LogisticsInvariantFailed);
656        }
657        Ok(Self {
658            id,
659            support_case,
660            order,
661            ledger,
662            status,
663            lines,
664            quantity,
665            refund_amount,
666            requested_at,
667            decided_at,
668        })
669    }
670}
671
672#[must_use]
673pub fn return_authorization_approved(authorization: &ReturnAuthorization) -> bool {
674    authorization.status == ReturnAuthorizationStatus::Approved
675}
676
677pub fn transition_return_authorization(
678    authorization: ReturnAuthorization,
679    next: ReturnAuthorizationStatus,
680    decided_at: Timestamp,
681) -> DomainResult<ReturnAuthorization> {
682    if !can_return_authorization_transition(authorization.status, next)
683        || decided_at < authorization.requested_at
684    {
685        return Err(ValidationError::LogisticsInvariantFailed);
686    }
687    Ok(ReturnAuthorization {
688        status: next,
689        decided_at,
690        ..authorization
691    })
692}
693
694#[derive(Clone, Debug, PartialEq, Eq)]
695#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
696pub struct ReturnReceipt {
697    pub(crate) authorization: ReturnAuthorization,
698    pub(crate) received_quantity: Quantity,
699    pub(crate) refund_issued: Money,
700    pub(crate) received_at: Timestamp,
701}
702
703impl ReturnReceipt {
704    pub fn try_new(
705        authorization: ReturnAuthorization,
706        received_quantity: Quantity,
707        refund_issued: Money,
708        received_at: Timestamp,
709    ) -> DomainResult<Self> {
710        if !return_authorization_approved(&authorization)
711            || received_quantity > authorization.quantity
712            || refund_issued > authorization.refund_amount
713            || received_at < authorization.decided_at
714        {
715            return Err(ValidationError::LogisticsInvariantFailed);
716        }
717        Ok(Self {
718            authorization,
719            received_quantity,
720            refund_issued,
721            received_at,
722        })
723    }
724}
725
726impl_getters!(LogisticsShipmentPlan {
727    id: ShipmentId,
728    order: Order,
729    fulfillment: DistinctFulfillmentPlan,
730    package: Package,
731    quote: CarrierQuote,
732    warehouse: Warehouse,
733    destination: ShippingDestination,
734    planned_ship_at: Timestamp,
735    promised_delivery_at: Timestamp,
736});
737
738impl_getters!(LogisticsShipment {
739    id: ShipmentId,
740    plan: LogisticsShipmentPlan,
741    status: ShipmentStatus,
742    created_at: Timestamp,
743    updated_at: Timestamp,
744});
745
746impl_getters!(CarrierHandoff {
747    plan: LogisticsShipmentPlan,
748    service: CarrierService,
749    tracking_number: Id,
750    handed_off_at: Timestamp,
751    acceptance_scan_at: Timestamp,
752});
753
754impl_getters!(TrackingHistory {
755    shipment_id: ShipmentId,
756    carrier_id: Id,
757    tracking_number: Id,
758    events: Vec<TrackingEvent>,
759    last_observed_at: Timestamp,
760});
761
762impl_getters!(DeliveryPromise {
763    plan: LogisticsShipmentPlan,
764    promised_by: Timestamp,
765});
766
767impl_getters!(DeliveredShipment {
768    promise: DeliveryPromise,
769    history: TrackingHistory,
770    delivery_event: TrackingEvent,
771    delivered_at: Timestamp,
772});
773
774impl_getters!(WarehouseTransfer {
775    id: TransferId,
776    sku: Sku,
777    from_warehouse: Warehouse,
778    to_warehouse: Warehouse,
779    source_stock: StockState,
780    requested: Quantity,
781    in_transit: Quantity,
782    received: Quantity,
783});
784
785impl_getters!(ReturnAuthorization {
786    id: ReturnAuthorizationId,
787    support_case: SupportCase,
788    order: Order,
789    ledger: PaymentLedger,
790    status: ReturnAuthorizationStatus,
791    lines: Vec<ReturnLine>,
792    quantity: Quantity,
793    refund_amount: Money,
794    requested_at: Timestamp,
795    decided_at: Timestamp,
796});
797
798impl_getters!(ReturnReceipt {
799    authorization: ReturnAuthorization,
800    received_quantity: Quantity,
801    refund_issued: Money,
802    received_at: Timestamp,
803});