Skip to main content

commerce_theory/
validation.rs

1//! Executable validators for converting raw boundary data into safe records.
2
3use crate::*;
4
5#[derive(Clone, Copy, Debug, PartialEq, Eq)]
6#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
7pub struct RawCartLine {
8    pub sku: Sku,
9    pub price: Money,
10    pub cost: Money,
11    pub quantity: Quantity,
12    pub discount: Money,
13    pub weight: Weight,
14}
15
16pub fn validate_cart_line(raw: RawCartLine) -> Result<CartLine, ValidationError> {
17    CartLine::try_new(
18        raw.sku,
19        raw.price,
20        raw.cost,
21        raw.quantity,
22        raw.discount,
23        raw.weight,
24    )
25    .map_err(|_| ValidationError::LineDiscountExceedsGross)
26}
27
28#[must_use]
29pub fn cart_line_matches_raw(raw: &RawCartLine, line: &CartLine) -> bool {
30    line.sku() == raw.sku
31        && line.price() == raw.price
32        && line.cost() == raw.cost
33        && line.quantity() == raw.quantity
34        && line.discount() == raw.discount
35        && line.weight() == raw.weight
36}
37
38pub fn validate_cart_lines(raw: Vec<RawCartLine>) -> Result<Vec<CartLine>, ValidationError> {
39    raw.into_iter().map(validate_cart_line).collect()
40}
41
42#[must_use]
43pub fn cart_lines_match_raw(raw: &[RawCartLine], lines: &[CartLine]) -> bool {
44    raw.len() == lines.len()
45        && raw
46            .iter()
47            .zip(lines)
48            .all(|(raw, line)| cart_line_matches_raw(raw, line))
49}
50
51#[derive(Clone, Debug, PartialEq, Eq)]
52#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
53pub struct RawOrder {
54    pub id: OrderId,
55    pub items: Vec<RawCartLine>,
56    pub coupon_amount: Money,
57    pub shipping_method: ShippingMethod,
58    pub tax: Money,
59    pub currency: Currency,
60    pub status: OrderStatus,
61    pub total: Money,
62}
63
64pub fn validate_order(raw: RawOrder) -> Result<Order, ValidationError> {
65    let items = validate_cart_lines(raw.items)?;
66    if raw.coupon_amount > cart_net_total(&items)? {
67        return Err(ValidationError::CouponExceedsSubtotal);
68    }
69    if !shipping_available(&raw.shipping_method, cart_weight_total(&items)?) {
70        return Err(ValidationError::ShippingUnavailable);
71    }
72    let expected = order_total(&raw.shipping_method, raw.coupon_amount, raw.tax, &items)?;
73    if raw.total != expected {
74        return Err(ValidationError::OrderTotalMismatch);
75    }
76    Order::try_new(
77        raw.id,
78        items,
79        raw.coupon_amount,
80        raw.shipping_method,
81        raw.tax,
82        raw.currency,
83        raw.status,
84        raw.total,
85    )
86}
87
88#[must_use]
89pub fn order_matches_raw(raw: &RawOrder, order: &Order) -> bool {
90    order.id() == raw.id
91        && cart_lines_match_raw(&raw.items, order.items())
92        && order.coupon_amount() == raw.coupon_amount
93        && order.shipping_method() == &raw.shipping_method
94        && order.tax() == raw.tax
95        && order.currency() == raw.currency
96        && order.status() == raw.status
97        && order.total() == raw.total
98}
99
100#[derive(Clone, Copy, Debug, PartialEq, Eq)]
101#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
102pub struct RawStockState {
103    pub sku: Sku,
104    pub total: Quantity,
105    pub reserved: Quantity,
106}
107
108pub fn validate_stock_state(raw: RawStockState) -> Result<StockState, ValidationError> {
109    StockState::try_new(raw.sku, raw.total, raw.reserved)
110        .map_err(|_| ValidationError::StockReservedExceedsTotal)
111}
112
113#[derive(Clone, Copy, Debug, PartialEq, Eq)]
114#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
115pub struct RawChannelPricePolicy {
116    pub min_price: Money,
117    pub max_price: Money,
118}
119
120pub fn validate_channel_price_policy(
121    raw: RawChannelPricePolicy,
122) -> Result<ChannelPricePolicy, ValidationError> {
123    ChannelPricePolicy::try_new(raw.min_price, raw.max_price)
124        .map_err(|_| ValidationError::PricePolicyInvalid)
125}
126
127#[derive(Clone, Copy, Debug, PartialEq, Eq)]
128#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
129pub struct RawProductFeedLine {
130    pub sku: Sku,
131    pub channel: SalesChannel,
132    pub price: Money,
133    pub currency: Currency,
134    pub stock: Quantity,
135    pub stock_state: RawStockState,
136    pub price_policy: RawChannelPricePolicy,
137}
138
139pub fn validate_feed_line(raw: RawProductFeedLine) -> Result<SafeProductFeedLine, ValidationError> {
140    let stock_state = validate_stock_state(raw.stock_state)?;
141    let price_policy = validate_channel_price_policy(raw.price_policy)?;
142    if raw.sku != stock_state.sku() {
143        return Err(ValidationError::FeedSkuMismatch);
144    }
145    if !valid_channel_price(&price_policy, raw.price) {
146        return Err(ValidationError::FeedPriceOutOfPolicy);
147    }
148    if raw.stock > available_stock(&stock_state) {
149        return Err(ValidationError::FeedStockUnavailable);
150    }
151    SafeProductFeedLine::try_new(
152        raw.sku,
153        raw.channel,
154        raw.price,
155        raw.currency,
156        raw.stock,
157        stock_state,
158        price_policy,
159    )
160}
161
162#[derive(Clone, Copy, Debug, PartialEq, Eq)]
163#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
164pub struct RawPaymentLedger {
165    pub captured: Money,
166    pub refunded: Money,
167}
168
169pub fn validate_payment_ledger(raw: RawPaymentLedger) -> Result<PaymentLedger, ValidationError> {
170    PaymentLedger::try_new(raw.captured, raw.refunded)
171        .map_err(|_| ValidationError::LedgerRefundedExceedsCaptured)
172}
173
174#[derive(Clone, Copy, Debug, PartialEq, Eq)]
175#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
176pub struct RawRefund {
177    pub amount: Money,
178}
179
180#[derive(Clone, Debug, PartialEq, Eq)]
181#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
182pub struct ValidRefund {
183    pub(crate) ledger: PaymentLedger,
184    pub(crate) amount: Money,
185}
186
187impl ValidRefund {
188    #[must_use]
189    pub const fn ledger(&self) -> &PaymentLedger {
190        &self.ledger
191    }
192
193    #[must_use]
194    pub const fn amount(&self) -> Money {
195        self.amount
196    }
197}
198
199pub fn validate_refund(
200    raw: RawRefund,
201    ledger: PaymentLedger,
202) -> Result<ValidRefund, ValidationError> {
203    if !can_refund(&ledger, raw.amount) {
204        return Err(ValidationError::RefundExceedsRemaining);
205    }
206    Ok(ValidRefund {
207        ledger,
208        amount: raw.amount,
209    })
210}
211
212pub fn issue_valid_refund(refund: &ValidRefund) -> DomainResult<PaymentLedger> {
213    issue_refund(&refund.ledger, refund.amount)
214}
215
216pub fn validate_basis_points(value: Nat) -> Result<BasisPoints, ValidationError> {
217    BasisPoints::try_new(value).map_err(|_| ValidationError::BasisPointsOutOfRange)
218}
219
220pub fn validate_product_catalog_entry(
221    product: Product,
222    variant: ProductVariant,
223) -> Result<ProductCatalogEntry, ValidationError> {
224    ProductCatalogEntry::try_new(product, variant)
225        .map_err(|_| ValidationError::CatalogInvariantFailed)
226}
227
228pub fn validate_listing_content(
229    content: ListingContent,
230    policy: MarketplaceContentPolicy,
231) -> Result<ValidListingContent, ValidationError> {
232    ValidListingContent::try_new(content, policy)
233        .map_err(|_| ValidationError::CatalogInvariantFailed)
234}
235
236pub fn validate_versioned_stock(
237    raw: RawStockState,
238    version: Nat,
239) -> Result<VersionedStock, ValidationError> {
240    let stock = validate_stock_state(raw)?;
241    Ok(VersionedStock::from_stock(stock, version))
242}
243
244pub fn validate_pick_task(
245    sku: Sku,
246    requested: Quantity,
247    bin: BinStock,
248) -> Result<PickTask, ValidationError> {
249    PickTask::try_new(sku, requested, bin).map_err(|_| ValidationError::InventoryInvariantFailed)
250}
251
252pub fn validate_pack_task(
253    source_quantity: Quantity,
254    packed_quantity: Quantity,
255) -> Result<PackTask, ValidationError> {
256    PackTask::try_new(source_quantity, packed_quantity)
257        .map_err(|_| ValidationError::InventoryInvariantFailed)
258}
259
260pub fn validate_warehouse_shipment(
261    packed: Quantity,
262    shipped: Quantity,
263) -> Result<WarehouseShipment, ValidationError> {
264    WarehouseShipment::try_new(packed, shipped)
265        .map_err(|_| ValidationError::InventoryInvariantFailed)
266}
267
268pub fn validate_allocation(
269    node: InventoryNode,
270    quantity: Quantity,
271) -> Result<Allocation, ValidationError> {
272    Allocation::try_new(node, quantity).map_err(|_| ValidationError::InventoryInvariantFailed)
273}
274
275pub fn validate_fulfillment_plan(
276    requested: Quantity,
277    allocations: Vec<Allocation>,
278) -> Result<FulfillmentPlan, ValidationError> {
279    FulfillmentPlan::try_new(requested, allocations)
280        .map_err(|_| ValidationError::InventoryInvariantFailed)
281}
282
283pub fn validate_distinct_fulfillment_plan(
284    requested: Quantity,
285    allocations: Vec<Allocation>,
286) -> Result<DistinctFulfillmentPlan, ValidationError> {
287    DistinctFulfillmentPlan::try_new(requested, allocations)
288        .map_err(|_| ValidationError::InventoryInvariantFailed)
289}
290
291#[derive(Clone, Copy, Debug, PartialEq, Eq)]
292#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
293pub struct RawReservationAttempt {
294    pub stock: RawStockState,
295    pub version: Nat,
296    pub quantity: Quantity,
297    pub expected_version: Nat,
298}
299
300pub fn validate_raw_reservation_attempt(
301    raw: RawReservationAttempt,
302) -> Result<ReservationAttempt, ValidationError> {
303    Ok(ReservationAttempt::new(
304        validate_versioned_stock(raw.stock, raw.version)?,
305        raw.quantity,
306        raw.expected_version,
307    ))
308}
309
310pub fn validate_compare_and_swap_reservation(
311    stock: VersionedStock,
312    quantity: Quantity,
313    expected_version: Nat,
314) -> Result<VersionedStock, ValidationError> {
315    compare_and_swap_reserve(&stock, quantity, expected_version)
316        .ok_or(ValidationError::InventoryInvariantFailed)
317}
318
319pub fn validate_raw_compare_and_swap_reservation(
320    raw: RawReservationAttempt,
321) -> Result<VersionedStock, ValidationError> {
322    let attempt = validate_raw_reservation_attempt(raw)?;
323    validate_compare_and_swap_reservation(
324        attempt.stock(),
325        attempt.quantity(),
326        attempt.expected_version(),
327    )
328}
329
330pub fn validate_release_reserved_stock(
331    stock: StockState,
332    quantity: Quantity,
333) -> Result<StockState, ValidationError> {
334    release_reserved_stock(&stock, quantity).map_err(|_| ValidationError::InventoryInvariantFailed)
335}
336
337pub fn validate_confirm_reserved_shipment(
338    stock: StockState,
339    quantity: Quantity,
340) -> Result<StockState, ValidationError> {
341    confirm_reserved_shipment(&stock, quantity)
342        .map_err(|_| ValidationError::InventoryInvariantFailed)
343}
344
345pub fn validate_timed_reservation(
346    stock: StockState,
347    quantity: Quantity,
348    reserved_at: Timestamp,
349    expires_at: Timestamp,
350    status: ReservationStatus,
351) -> Result<TimedReservation, ValidationError> {
352    TimedReservation::try_new(stock, quantity, reserved_at, expires_at, status)
353        .map_err(|_| ValidationError::InventoryInvariantFailed)
354}
355
356pub fn validate_release_expired_reservation(
357    reservation: TimedReservation,
358    now: Timestamp,
359) -> Result<StockState, ValidationError> {
360    release_expired_reservation(&reservation, now)
361        .map_err(|_| ValidationError::InventoryInvariantFailed)
362}
363
364pub fn validate_backorder_request(
365    sku: Sku,
366    requested: Quantity,
367    available_now: Quantity,
368    backordered: Quantity,
369) -> Result<BackorderRequest, ValidationError> {
370    BackorderRequest::try_new(sku, requested, available_now, backordered)
371        .map_err(|_| ValidationError::InventoryInvariantFailed)
372}
373
374pub fn validate_preorder_window(
375    sku: Sku,
376    opens_at: Timestamp,
377    closes_at: Timestamp,
378    capacity: Quantity,
379) -> Result<PreorderWindow, ValidationError> {
380    PreorderWindow::try_new(sku, opens_at, closes_at, capacity)
381        .map_err(|_| ValidationError::InventoryInvariantFailed)
382}
383
384pub fn validate_preorder_reservation(
385    window: PreorderWindow,
386    quantity: Quantity,
387    reserved_at: Timestamp,
388) -> Result<PreorderReservation, ValidationError> {
389    PreorderReservation::try_new(window, quantity, reserved_at)
390        .map_err(|_| ValidationError::InventoryInvariantFailed)
391}
392
393pub fn validate_serialized_inventory_set(
394    units: Vec<SerializedInventoryUnit>,
395) -> Result<SerializedInventorySet, ValidationError> {
396    SerializedInventorySet::try_new(units).map_err(|_| ValidationError::InventoryInvariantFailed)
397}
398
399pub fn validate_usable_inventory_lot(
400    lot: InventoryLot,
401    now: Timestamp,
402) -> Result<InventoryLot, ValidationError> {
403    if lot_usable_at(now, &lot) {
404        Ok(lot)
405    } else {
406        Err(ValidationError::InventoryInvariantFailed)
407    }
408}
409
410pub fn validate_sku_substitution(
411    requested_sku: Sku,
412    substitute_sku: Sku,
413    substitute_stock: StockState,
414    max_substitute_qty: Quantity,
415) -> Result<SkuSubstitution, ValidationError> {
416    SkuSubstitution::try_new(
417        requested_sku,
418        substitute_sku,
419        substitute_stock,
420        max_substitute_qty,
421    )
422    .map_err(|_| ValidationError::InventoryInvariantFailed)
423}
424
425pub fn validate_split_fulfillment_plan(
426    plan: DistinctFulfillmentPlan,
427    first_warehouse: Warehouse,
428    second_warehouse: Warehouse,
429) -> Result<SplitFulfillmentPlan, ValidationError> {
430    SplitFulfillmentPlan::try_new(plan, first_warehouse, second_warehouse)
431        .map_err(|_| ValidationError::InventoryInvariantFailed)
432}
433
434pub const fn validate_typed_order<S: OrderStatusMarker>(
435    id: OrderId,
436    total: Money,
437    currency: Currency,
438) -> Result<TypedOrder<S>, ValidationError> {
439    TypedOrder::try_new(id, total, currency)
440}
441
442pub const fn validate_typed_payment<S: PaymentStateMarker>(
443    id: PaymentId,
444    order_id: OrderId,
445    amount: Money,
446    currency: Currency,
447) -> Result<TypedPayment<S>, ValidationError> {
448    TypedPayment::try_new(id, order_id, amount, currency)
449}
450
451pub fn validate_balanced_journal_entry(
452    postings: Vec<Posting>,
453) -> Result<BalancedJournalEntry, ValidationError> {
454    BalancedJournalEntry::try_new(postings).map_err(|_| ValidationError::AccountingInvariantFailed)
455}
456
457pub fn validate_synced_marketplace_listing(
458    listing: MarketplaceListing,
459    stock: StockState,
460) -> Result<SyncedMarketplaceListing, ValidationError> {
461    SyncedMarketplaceListing::try_new(listing, stock)
462        .map_err(|_| ValidationError::MarketplaceInvariantFailed)
463}
464
465pub fn validate_marketplace_fee_ledger(
466    gross: Money,
467    fee_rate: BasisPoints,
468    fee_rounding_mode: RoundingMode,
469    fee: Money,
470    payout: Money,
471) -> Result<MarketplaceFeeLedger, ValidationError> {
472    MarketplaceFeeLedger::try_new(gross, fee_rate, fee_rounding_mode, fee, payout)
473        .map_err(|_| ValidationError::MarketplaceInvariantFailed)
474}
475
476pub fn validate_marketplace_payout_calculation(
477    gross: Money,
478    payout_rate: BasisPoints,
479    payout_rounding_mode: RoundingMode,
480    payout: Money,
481) -> Result<MarketplacePayoutCalculation, ValidationError> {
482    MarketplacePayoutCalculation::try_new(gross, payout_rate, payout_rounding_mode, payout)
483        .map_err(|_| ValidationError::MarketplaceInvariantFailed)
484}
485
486pub fn validate_marketplace_order(
487    marketplace: Marketplace,
488    external_order_id: MarketplaceOrderId,
489    internal_order: Order,
490    gross_from_marketplace: Money,
491    fee_ledger: MarketplaceFeeLedger,
492) -> Result<MarketplaceOrder, ValidationError> {
493    MarketplaceOrder::try_new(
494        marketplace,
495        external_order_id,
496        internal_order,
497        gross_from_marketplace,
498        fee_ledger,
499    )
500    .map_err(|_| ValidationError::MarketplaceInvariantFailed)
501}
502
503#[allow(clippy::too_many_arguments)]
504pub fn validate_marketing_campaign(
505    id: CampaignId,
506    platform: AdPlatform,
507    ad_type: AdType,
508    destination: AdDestination,
509    status: CampaignStatus,
510    budget: Money,
511    spend: Money,
512    impressions: Nat,
513    clicks: Nat,
514    conversions: Nat,
515    attributed_revenue: Money,
516) -> Result<MarketingCampaign, ValidationError> {
517    MarketingCampaign::try_new(
518        id,
519        platform,
520        ad_type,
521        destination,
522        status,
523        budget,
524        spend,
525        impressions,
526        clicks,
527        conversions,
528        attributed_revenue,
529    )
530    .map_err(|_| ValidationError::MarketingInvariantFailed)
531}
532
533pub fn validate_click_attributed_campaign(
534    campaign: MarketingCampaign,
535) -> Result<ClickAttributedCampaign, ValidationError> {
536    ClickAttributedCampaign::try_new(campaign)
537        .map_err(|_| ValidationError::MarketingInvariantFailed)
538}
539
540pub fn validate_funnel(
541    visitors: Nat,
542    add_to_cart: Nat,
543    checkout_started: Nat,
544    purchases: Nat,
545) -> Result<Funnel, ValidationError> {
546    Funnel::try_new(visitors, add_to_cart, checkout_started, purchases)
547        .map_err(|_| ValidationError::MarketingInvariantFailed)
548}
549
550pub fn validate_order_attribution_ledger(
551    order: Order,
552    credits: Vec<AttributionCredit>,
553) -> Result<OrderAttributionLedger, ValidationError> {
554    OrderAttributionLedger::try_new(order, credits)
555        .map_err(|_| ValidationError::MarketingInvariantFailed)
556}
557
558pub fn validate_experiment_variant(
559    id: Id,
560    traffic_weight: Nat,
561    visitors: Nat,
562    conversions: Nat,
563) -> Result<ExperimentVariant, ValidationError> {
564    ExperimentVariant::try_new(id, traffic_weight, visitors, conversions)
565        .map_err(|_| ValidationError::MarketingInvariantFailed)
566}
567
568pub fn validate_experiment(
569    id: Id,
570    variants: Vec<ExperimentVariant>,
571) -> Result<Experiment, ValidationError> {
572    Experiment::try_new(id, variants).map_err(|_| ValidationError::MarketingInvariantFailed)
573}
574
575#[allow(clippy::too_many_arguments)]
576pub fn validate_trade_price_book_entry(
577    sku: Sku,
578    currency: Currency,
579    unit_cost: Money,
580    retail_unit_price: Money,
581    wholesale_unit_price: Money,
582    retail_margin: Money,
583    wholesale_margin: Money,
584    wholesale_min_qty: Quantity,
585) -> Result<TradePriceBookEntry, ValidationError> {
586    TradePriceBookEntry::try_new(
587        sku,
588        currency,
589        unit_cost,
590        retail_unit_price,
591        wholesale_unit_price,
592        retail_margin,
593        wholesale_margin,
594        wholesale_min_qty,
595    )
596    .map_err(|_| ValidationError::B2BInvariantFailed)
597}
598
599pub fn validate_retail_line(
600    entry: TradePriceBookEntry,
601    quantity: Quantity,
602    discount: Money,
603) -> Result<RetailLine, ValidationError> {
604    RetailLine::try_new(entry, quantity, discount).map_err(|_| ValidationError::B2BInvariantFailed)
605}
606
607pub fn validate_wholesale_line(
608    entry: TradePriceBookEntry,
609    quantity: Quantity,
610    discount: Money,
611) -> Result<WholesaleLine, ValidationError> {
612    WholesaleLine::try_new(entry, quantity, discount)
613        .map_err(|_| ValidationError::B2BInvariantFailed)
614}
615
616pub fn validate_wholesale_credit_account(
617    customer: Customer,
618    credit_limit: Money,
619    outstanding: Money,
620) -> Result<WholesaleCreditAccount, ValidationError> {
621    WholesaleCreditAccount::try_new(customer, credit_limit, outstanding)
622        .map_err(|_| ValidationError::B2BInvariantFailed)
623}
624
625pub fn validate_supplier_daily_capacity(
626    supplier: DropshipSupplier,
627    daily_order_capacity: Nat,
628    accepted_orders: Nat,
629) -> Result<SupplierDailyCapacity, ValidationError> {
630    SupplierDailyCapacity::try_new(supplier, daily_order_capacity, accepted_orders)
631        .map_err(|_| ValidationError::DropshippingInvariantFailed)
632}
633
634#[allow(clippy::too_many_arguments)]
635pub fn validate_dropship_offer(
636    sku: Sku,
637    supplier: DropshipSupplier,
638    supplier_unit_cost: Money,
639    sale_unit_price: Money,
640    supplier_shipping_per_unit: Money,
641    available_qty: Quantity,
642    currency: Currency,
643    active: bool,
644) -> Result<DropshipOffer, ValidationError> {
645    DropshipOffer::try_new(
646        sku,
647        supplier,
648        supplier_unit_cost,
649        sale_unit_price,
650        supplier_shipping_per_unit,
651        available_qty,
652        currency,
653        active,
654    )
655    .map_err(|_| ValidationError::DropshippingInvariantFailed)
656}
657
658pub fn validate_supplier_reservation(
659    offer: DropshipOffer,
660    supplier: DropshipSupplier,
661    quantity: Quantity,
662    status: SupplierReservationStatus,
663) -> Result<SupplierReservation, ValidationError> {
664    SupplierReservation::try_new(offer, supplier, quantity, status)
665        .map_err(|_| ValidationError::DropshippingInvariantFailed)
666}
667
668pub fn validate_dropship_line(
669    offer: DropshipOffer,
670    quantity: Quantity,
671    discount: Money,
672) -> Result<DropshipLine, ValidationError> {
673    DropshipLine::try_new(offer, quantity, discount)
674        .map_err(|_| ValidationError::DropshippingInvariantFailed)
675}
676
677pub fn validate_reserved_dropship_line(
678    line: DropshipLine,
679    reservation: SupplierReservation,
680) -> Result<ReservedDropshipLine, ValidationError> {
681    ReservedDropshipLine::try_new(line, reservation)
682        .map_err(|_| ValidationError::DropshippingInvariantFailed)
683}
684
685pub fn validate_dropship_purchase_order(
686    supplier: DropshipSupplier,
687    lines: Vec<DropshipLine>,
688    quote: DropshipShippingQuote,
689    status: DropshipPOStatus,
690    total_supplier_cost: Money,
691) -> Result<DropshipPurchaseOrder, ValidationError> {
692    DropshipPurchaseOrder::try_new(supplier, lines, quote, status, total_supplier_cost)
693        .map_err(|_| ValidationError::DropshippingInvariantFailed)
694}
695
696pub fn validate_dropship_fulfillment(
697    customer_order: Order,
698    purchase_order: DropshipPurchaseOrder,
699    segment_revenue: Money,
700) -> Result<DropshipFulfillment, ValidationError> {
701    DropshipFulfillment::try_new(customer_order, purchase_order, segment_revenue)
702        .map_err(|_| ValidationError::DropshippingInvariantFailed)
703}
704
705pub fn validate_dropship_return_request(
706    line: DropshipLine,
707    return_qty: Quantity,
708    customer_refund: Money,
709    supplier_credit: Money,
710) -> Result<DropshipReturnRequest, ValidationError> {
711    DropshipReturnRequest::try_new(line, return_qty, customer_refund, supplier_credit)
712        .map_err(|_| ValidationError::DropshippingInvariantFailed)
713}
714
715pub fn validate_guaranteed_dropship_profit_quote(
716    revenue: Money,
717    costs: DropshipProfitCosts,
718    min_profit: Money,
719    profit: Money,
720    signed_profit: SignedMoney,
721) -> Result<GuaranteedDropshipProfitQuote, ValidationError> {
722    GuaranteedDropshipProfitQuote::try_new(revenue, costs, min_profit, profit, signed_profit)
723        .map_err(|_| ValidationError::ProfitInvariantFailed)
724}
725
726pub fn validate_dropship_cost_upper_bounds(
727    actual: DropshipProfitCosts,
728    upper: DropshipProfitCosts,
729) -> Result<DropshipCostUpperBounds, ValidationError> {
730    DropshipCostUpperBounds::try_new(actual, upper)
731        .map_err(|_| ValidationError::ProfitInvariantFailed)
732}
733
734pub fn validate_singleton_competitor_price_benchmark(
735    sku: Sku,
736    currency: Currency,
737    offer: CompetitorOffer,
738) -> Result<CompetitorPriceBenchmark, ValidationError> {
739    CompetitorPriceBenchmark::try_new(sku, currency, vec![offer.clone()], offer)
740        .map_err(|_| ValidationError::CompetitorInvariantFailed)
741}
742
743pub fn validate_competitor_aware_dropship_offer(
744    offer: DropshipOffer,
745    benchmark: CompetitorPriceBenchmark,
746    discount: Money,
747    costs: DropshipProfitCosts,
748    min_profit: Money,
749) -> Result<CompetitorAwareDropshipOffer, ValidationError> {
750    CompetitorAwareDropshipOffer::try_new(offer, benchmark, discount, costs, min_profit)
751        .map_err(|_| ValidationError::CompetitorInvariantFailed)
752}
753
754pub fn validate_brand_pricing_policy(
755    map_price: Money,
756    msrp: Money,
757) -> Result<BrandPricingPolicy, ValidationError> {
758    BrandPricingPolicy::try_new(map_price, msrp)
759        .map_err(|_| ValidationError::MerchandisingInvariantFailed)
760}
761
762pub fn validate_bundle_component(
763    sku: Sku,
764    units_per_bundle: Quantity,
765    stock_available: Quantity,
766) -> Result<BundleComponent, ValidationError> {
767    BundleComponent::try_new(sku, units_per_bundle, stock_available)
768        .map_err(|_| ValidationError::MerchandisingInvariantFailed)
769}
770
771pub fn bundle_components_can_fulfill_all(
772    bundle_qty: Quantity,
773    components: &[BundleComponent],
774) -> DomainResult<bool> {
775    for component in components {
776        if !component_can_fulfill_bundles(bundle_qty, component)? {
777            return Ok(false);
778        }
779    }
780    Ok(true)
781}
782
783pub fn validate_bundle_reservation(
784    bundle_qty: Quantity,
785    components: Vec<BundleComponent>,
786) -> Result<BundleReservation, ValidationError> {
787    if !bundle_components_can_fulfill_all(bundle_qty, &components)
788        .map_err(|_| ValidationError::MerchandisingInvariantFailed)?
789    {
790        return Err(ValidationError::MerchandisingInvariantFailed);
791    }
792    BundleReservation::try_new(bundle_qty, components)
793        .map_err(|_| ValidationError::MerchandisingInvariantFailed)
794}
795
796pub fn validate_accepted_promotion_set(
797    resulting_price: Money,
798    total_discount: Money,
799    discount_cap: Money,
800    profit_floor: Money,
801) -> Result<AcceptedPromotionSet, ValidationError> {
802    AcceptedPromotionSet::try_new(resulting_price, total_discount, discount_cap, profit_floor)
803        .map_err(|_| ValidationError::MerchandisingInvariantFailed)
804}
805
806pub fn validate_search_result_item(
807    item: SearchResultItem,
808) -> Result<ValidSearchResultItem, ValidationError> {
809    ValidSearchResultItem::try_new(item).map_err(|_| ValidationError::MerchandisingInvariantFailed)
810}
811
812pub fn validate_exchange_rate(
813    source: Currency,
814    target: Currency,
815    numerator: Nat,
816    denominator: Nat,
817    observed_at: Timestamp,
818) -> Result<ExchangeRate, ValidationError> {
819    ExchangeRate::try_new(source, target, numerator, denominator, observed_at)
820        .map_err(|_| ValidationError::FinanceInvariantFailed)
821}
822
823pub fn validate_tax_calculation(
824    taxable_amount: Money,
825    rate: TaxRate,
826    rounding_mode: RoundingMode,
827    tax: Money,
828    total: Money,
829) -> Result<TaxCalculation, ValidationError> {
830    TaxCalculation::try_new(taxable_amount, rate, rounding_mode, tax, total)
831        .map_err(|_| ValidationError::FinanceInvariantFailed)
832}
833
834pub fn validate_tax_inclusive_price(
835    gross: Money,
836    net: Money,
837    tax: Money,
838) -> Result<TaxInclusivePrice, ValidationError> {
839    TaxInclusivePrice::try_new(gross, net, tax).map_err(|_| ValidationError::TaxInvariantFailed)
840}
841
842pub fn validate_tax_exclusive_price(
843    net: Money,
844    tax: Money,
845    total: Money,
846) -> Result<TaxExclusivePrice, ValidationError> {
847    TaxExclusivePrice::try_new(net, tax, total).map_err(|_| ValidationError::TaxInvariantFailed)
848}
849
850#[derive(Clone, Debug, PartialEq, Eq)]
851#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
852pub struct RawTaxInvoiceLine {
853    pub sku: Sku,
854    pub quantity: Quantity,
855    pub unit_price: Money,
856    pub discount: Money,
857    pub treatment: TaxTreatment,
858    pub rate: TaxRate,
859    pub rounding_mode: RoundingMode,
860    pub taxable_amount: Money,
861    pub tax: Money,
862    pub total: Money,
863}
864
865pub fn validate_tax_invoice_line(
866    raw: RawTaxInvoiceLine,
867) -> Result<TaxInvoiceLine, ValidationError> {
868    TaxInvoiceLine::try_new(
869        raw.sku,
870        raw.quantity,
871        raw.unit_price,
872        raw.discount,
873        raw.treatment,
874        raw.rate,
875        raw.rounding_mode,
876        raw.taxable_amount,
877        raw.tax,
878        raw.total,
879    )
880    .map_err(|_| ValidationError::TaxInvariantFailed)
881}
882
883pub fn validate_tax_invoice_lines(
884    raw: Vec<RawTaxInvoiceLine>,
885) -> Result<Vec<TaxInvoiceLine>, ValidationError> {
886    raw.into_iter().map(validate_tax_invoice_line).collect()
887}
888
889#[derive(Clone, Debug, PartialEq, Eq)]
890#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
891pub struct RawTaxInvoice {
892    pub id: Id,
893    pub issued_at: Timestamp,
894    pub seller_id: Id,
895    pub buyer_id: CustomerId,
896    pub jurisdiction: TaxJurisdiction,
897    pub currency: Currency,
898    pub lines: Vec<RawTaxInvoiceLine>,
899    pub subtotal: Money,
900    pub tax: Money,
901    pub shipping: Money,
902    pub discount: Money,
903    pub total: Money,
904}
905
906pub fn validate_tax_invoice(raw: RawTaxInvoice) -> Result<TaxInvoice, ValidationError> {
907    let lines = validate_tax_invoice_lines(raw.lines)?;
908    TaxInvoice::try_new(
909        raw.id,
910        raw.issued_at,
911        raw.seller_id,
912        raw.buyer_id,
913        raw.jurisdiction,
914        raw.currency,
915        lines,
916        raw.subtotal,
917        raw.tax,
918        raw.shipping,
919        raw.discount,
920        raw.total,
921    )
922    .map_err(|_| ValidationError::TaxInvariantFailed)
923}
924
925pub fn validate_order_tax_invoice_link(
926    order: Order,
927    invoice: TaxInvoice,
928) -> Result<OrderTaxInvoiceLink, ValidationError> {
929    OrderTaxInvoiceLink::try_new(order, invoice).map_err(|_| ValidationError::TaxInvariantFailed)
930}
931
932pub fn validate_tax_exemption_certificate(
933    customer_id: CustomerId,
934    jurisdiction_id: Id,
935    valid_from: Timestamp,
936    valid_until: Timestamp,
937) -> Result<TaxExemptionCertificate, ValidationError> {
938    TaxExemptionCertificate::try_new(customer_id, jurisdiction_id, valid_from, valid_until)
939        .map_err(|_| ValidationError::TaxInvariantFailed)
940}
941
942pub fn validate_b2b_tax_exemption(
943    customer: Customer,
944    jurisdiction: TaxJurisdiction,
945    certificate: TaxExemptionCertificate,
946    checked_at: Timestamp,
947) -> Result<B2BTaxExemption, ValidationError> {
948    B2BTaxExemption::try_new(customer, jurisdiction, certificate, checked_at)
949        .map_err(|_| ValidationError::TaxInvariantFailed)
950}
951
952#[allow(clippy::too_many_arguments)]
953pub fn validate_marketplace_facilitator_tax(
954    marketplace: Marketplace,
955    jurisdiction: TaxJurisdiction,
956    taxable_amount: Money,
957    rate: TaxRate,
958    rounding_mode: RoundingMode,
959    tax: Money,
960    facilitator_collects: bool,
961    seller_tax_due: Money,
962) -> Result<MarketplaceFacilitatorTax, ValidationError> {
963    MarketplaceFacilitatorTax::try_new(
964        marketplace,
965        jurisdiction,
966        taxable_amount,
967        rate,
968        rounding_mode,
969        tax,
970        facilitator_collects,
971        seller_tax_due,
972    )
973    .map_err(|_| ValidationError::TaxInvariantFailed)
974}
975
976pub fn validate_carrier_quote(
977    service: CarrierService,
978    package: Package,
979    price: Money,
980) -> Result<CarrierQuote, ValidationError> {
981    CarrierQuote::try_new(service, package, price)
982        .map_err(|_| ValidationError::FinanceInvariantFailed)
983}
984
985pub fn validate_reconciliation_within_tolerance(
986    expected: Money,
987    actual: Money,
988    tolerance: Money,
989) -> Result<ReconciliationWithinTolerance, ValidationError> {
990    ReconciliationWithinTolerance::try_new(expected, actual, tolerance)
991        .map_err(|_| ValidationError::FinanceInvariantFailed)
992}
993
994pub fn validate_subscription_plan(
995    price: Money,
996    period_days: Days,
997) -> Result<SubscriptionPlan, ValidationError> {
998    SubscriptionPlan::try_new(price, period_days)
999        .map_err(|_| ValidationError::PostPurchaseInvariantFailed)
1000}
1001
1002pub fn validate_recurring_subscription(
1003    customer: CustomerId,
1004    plan: SubscriptionPlan,
1005    status: SubscriptionLifecycleStatus,
1006    current_billing_date: Timestamp,
1007    next_billing_date: Timestamp,
1008) -> Result<RecurringSubscription, ValidationError> {
1009    RecurringSubscription::try_new(
1010        customer,
1011        plan,
1012        status,
1013        current_billing_date,
1014        next_billing_date,
1015    )
1016    .map_err(|_| ValidationError::PostPurchaseInvariantFailed)
1017}
1018
1019pub fn validate_gift_card_redemption(
1020    card: GiftCard,
1021    amount: Money,
1022) -> Result<GiftCardRedemption, ValidationError> {
1023    GiftCardRedemption::try_new(card, amount)
1024        .map_err(|_| ValidationError::PostPurchaseInvariantFailed)
1025}
1026
1027pub fn validate_chargeback(
1028    payment_amount: Money,
1029    chargeback_amount: Money,
1030) -> Result<Chargeback, ValidationError> {
1031    Chargeback::try_new(payment_amount, chargeback_amount)
1032        .map_err(|_| ValidationError::PostPurchaseInvariantFailed)
1033}
1034
1035pub fn validate_cashflow_plan(
1036    starting_cash: Money,
1037    required_reserve: Money,
1038    expected_inflows: Money,
1039    expected_outflows: Money,
1040) -> Result<CashflowPlan, ValidationError> {
1041    CashflowPlan::try_new(
1042        starting_cash,
1043        required_reserve,
1044        expected_inflows,
1045        expected_outflows,
1046    )
1047    .map_err(|_| ValidationError::PostPurchaseInvariantFailed)
1048}
1049
1050pub fn validate_event_backed_cashflow_plan(
1051    starting_cash: Money,
1052    required_reserve: Money,
1053    events: Vec<CashflowEvent>,
1054) -> Result<EventBackedCashflowPlan, ValidationError> {
1055    EventBackedCashflowPlan::try_new(starting_cash, required_reserve, events)
1056        .map_err(|_| ValidationError::PostPurchaseInvariantFailed)
1057}
1058
1059pub fn validate_audited_command(
1060    actor: Role,
1061    action: Action,
1062    order_id: OrderId,
1063    event: AuditEvent,
1064) -> Result<AuditedCommand, ValidationError> {
1065    AuditedCommand::try_new(actor, action, order_id, event)
1066        .map_err(|_| ValidationError::AuditPermissionDenied)
1067}
1068
1069pub fn validate_audited_entity_command(
1070    actor: Role,
1071    action: Action,
1072    subject_id: Id,
1073    event: EntityAuditEvent,
1074) -> Result<AuditedEntityCommand, ValidationError> {
1075    AuditedEntityCommand::try_new(actor, action, subject_id, event)
1076        .map_err(|_| ValidationError::AuditPermissionDenied)
1077}
1078
1079pub fn validate_event_stream(stream: EventStream) -> Result<ValidEventStream, ValidationError> {
1080    ValidEventStream::try_new(stream).map_err(|_| ValidationError::EventStreamInvalid)
1081}
1082
1083pub fn validate_approved_supplier_quality(
1084    supplier: DropshipSupplier,
1085    metrics: SupplierQualityMetrics,
1086    policy: SupplierRiskPolicy,
1087) -> Result<ApprovedSupplierQuality, ValidationError> {
1088    ApprovedSupplierQuality::try_new(supplier, metrics, policy)
1089        .map_err(|_| ValidationError::SupplierQualityInvalid)
1090}
1091
1092#[allow(clippy::too_many_arguments)]
1093pub fn validate_dropship_opportunity_candidate(
1094    sku: Sku,
1095    units: Quantity,
1096    target_price: Money,
1097    required_capital: Money,
1098    expected_profit: Money,
1099    min_profit: Money,
1100    competitor_price: Money,
1101    costs: DropshipProfitCosts,
1102) -> Result<DropshipOpportunityCandidate, ValidationError> {
1103    DropshipOpportunityCandidate::try_new(
1104        sku,
1105        units,
1106        target_price,
1107        required_capital,
1108        expected_profit,
1109        min_profit,
1110        competitor_price,
1111        costs,
1112    )
1113    .map_err(|_| ValidationError::OpportunityInvariantFailed)
1114}
1115
1116pub fn validate_dropship_opportunity_portfolio(
1117    selected: Vec<DropshipOpportunityCandidate>,
1118    investment_fund: Money,
1119) -> Result<DropshipOpportunityPortfolio, ValidationError> {
1120    DropshipOpportunityPortfolio::try_new(selected, investment_fund)
1121        .map_err(|_| ValidationError::OpportunityInvariantFailed)
1122}
1123
1124pub fn validate_crm_account(
1125    id: AccountId,
1126    customer: Customer,
1127    tier: AccountTier,
1128    status: CRMAccountStatus,
1129    lifetime_value: Money,
1130    open_balance: Money,
1131) -> Result<CRMAccount, ValidationError> {
1132    CRMAccount::try_new(id, customer, tier, status, lifetime_value, open_balance)
1133        .map_err(|_| ValidationError::CrmInvariantFailed)
1134}
1135
1136pub fn validate_active_crm_account(
1137    account: CRMAccount,
1138) -> Result<ActiveCRMAccount, ValidationError> {
1139    ActiveCRMAccount::try_new(account).map_err(|_| ValidationError::CrmInvariantFailed)
1140}
1141
1142pub fn validate_crm_account_contact(
1143    account: CRMAccount,
1144    contact: CRMContact,
1145) -> Result<CRMAccountContact, ValidationError> {
1146    CRMAccountContact::try_new(account, contact).map_err(|_| ValidationError::CrmInvariantFailed)
1147}
1148
1149pub fn validate_permitted_customer_message(
1150    interaction_id: InteractionId,
1151    contact: CRMContact,
1152    sent_at: Timestamp,
1153) -> Result<PermittedCustomerMessage, ValidationError> {
1154    PermittedCustomerMessage::try_new(interaction_id, contact, sent_at)
1155        .map_err(|_| ValidationError::CrmInvariantFailed)
1156}
1157
1158pub fn validate_permitted_account_message(
1159    account_contact: CRMAccountContact,
1160    message: PermittedCustomerMessage,
1161) -> Result<PermittedAccountMessage, ValidationError> {
1162    PermittedAccountMessage::try_new(account_contact, message)
1163        .map_err(|_| ValidationError::CrmInvariantFailed)
1164}
1165
1166pub fn validate_crm_interaction(
1167    id: InteractionId,
1168    account_id: AccountId,
1169    contact_id: ContactId,
1170    kind: InteractionKind,
1171    occurred_at: Timestamp,
1172    follow_up_due_at: Timestamp,
1173) -> Result<CRMInteraction, ValidationError> {
1174    CRMInteraction::try_new(
1175        id,
1176        account_id,
1177        contact_id,
1178        kind,
1179        occurred_at,
1180        follow_up_due_at,
1181    )
1182    .map_err(|_| ValidationError::CrmInvariantFailed)
1183}
1184
1185pub fn validate_crm_interaction_for_contact(
1186    account_contact: CRMAccountContact,
1187    interaction: CRMInteraction,
1188) -> Result<CRMInteractionForContact, ValidationError> {
1189    CRMInteractionForContact::try_new(account_contact, interaction)
1190        .map_err(|_| ValidationError::CrmInvariantFailed)
1191}
1192
1193#[allow(clippy::too_many_arguments)]
1194pub fn validate_lead(
1195    id: LeadId,
1196    account_id: AccountId,
1197    contact_id: ContactId,
1198    source_campaign: Option<CampaignId>,
1199    status: LeadStatus,
1200    estimated_value: Money,
1201    currency: Currency,
1202    created_at: Timestamp,
1203    updated_at: Timestamp,
1204) -> Result<Lead, ValidationError> {
1205    Lead::try_new(
1206        id,
1207        account_id,
1208        contact_id,
1209        source_campaign,
1210        status,
1211        estimated_value,
1212        currency,
1213        created_at,
1214        updated_at,
1215    )
1216    .map_err(|_| ValidationError::CrmInvariantFailed)
1217}
1218
1219pub fn validate_lead_for_contact(
1220    account_contact: CRMAccountContact,
1221    lead: Lead,
1222) -> Result<LeadForContact, ValidationError> {
1223    LeadForContact::try_new(account_contact, lead).map_err(|_| ValidationError::CrmInvariantFailed)
1224}
1225
1226#[allow(clippy::too_many_arguments)]
1227pub fn validate_sales_opportunity(
1228    id: OpportunityId,
1229    account_id: AccountId,
1230    contact_id: ContactId,
1231    source_lead: Option<LeadId>,
1232    stage: OpportunityStage,
1233    amount: Money,
1234    currency: Currency,
1235    probability: BasisPoints,
1236    opened_at: Timestamp,
1237    updated_at: Timestamp,
1238    expected_close_at: Timestamp,
1239) -> Result<SalesOpportunity, ValidationError> {
1240    SalesOpportunity::try_new(
1241        id,
1242        account_id,
1243        contact_id,
1244        source_lead,
1245        stage,
1246        amount,
1247        currency,
1248        probability,
1249        opened_at,
1250        updated_at,
1251        expected_close_at,
1252    )
1253    .map_err(|_| ValidationError::CrmInvariantFailed)
1254}
1255
1256pub fn validate_opportunity_for_contact(
1257    account_contact: CRMAccountContact,
1258    opportunity: SalesOpportunity,
1259) -> Result<OpportunityForContact, ValidationError> {
1260    OpportunityForContact::try_new(account_contact, opportunity)
1261        .map_err(|_| ValidationError::CrmInvariantFailed)
1262}
1263
1264pub fn validate_sales_pipeline(
1265    currency: Currency,
1266    opportunities: Vec<SalesOpportunity>,
1267) -> Result<SalesPipeline, ValidationError> {
1268    SalesPipeline::try_new(currency, opportunities).map_err(|_| ValidationError::CrmInvariantFailed)
1269}
1270
1271pub fn validate_customer_segment(
1272    id: SegmentId,
1273    name: String,
1274    member_count: Nat,
1275    min_lifetime_value: Money,
1276    max_retention_discount: Money,
1277) -> Result<CustomerSegment, ValidationError> {
1278    CustomerSegment::try_new(
1279        id,
1280        name,
1281        member_count,
1282        min_lifetime_value,
1283        max_retention_discount,
1284    )
1285    .map_err(|_| ValidationError::CrmInvariantFailed)
1286}
1287
1288pub fn validate_segment_membership(
1289    account: CRMAccount,
1290    segment: CustomerSegment,
1291) -> Result<SegmentMembership, ValidationError> {
1292    SegmentMembership::try_new(account, segment).map_err(|_| ValidationError::CrmInvariantFailed)
1293}
1294
1295#[allow(clippy::too_many_arguments)]
1296pub fn validate_support_case(
1297    id: SupportCaseId,
1298    account_id: AccountId,
1299    contact_id: ContactId,
1300    order_id: Option<OrderId>,
1301    status: SupportCaseStatus,
1302    priority: SupportPriority,
1303    opened_at: Timestamp,
1304    last_updated_at: Timestamp,
1305    sla_due_at: Timestamp,
1306) -> Result<SupportCase, ValidationError> {
1307    SupportCase::try_new(
1308        id,
1309        account_id,
1310        contact_id,
1311        order_id,
1312        status,
1313        priority,
1314        opened_at,
1315        last_updated_at,
1316        sla_due_at,
1317    )
1318    .map_err(|_| ValidationError::CrmInvariantFailed)
1319}
1320
1321pub fn validate_support_case_for_contact(
1322    account_contact: CRMAccountContact,
1323    case_: SupportCase,
1324) -> Result<SupportCaseForContact, ValidationError> {
1325    SupportCaseForContact::try_new(account_contact, case_)
1326        .map_err(|_| ValidationError::CrmInvariantFailed)
1327}
1328
1329pub fn validate_resolved_support_case(
1330    case_: SupportCase,
1331    resolved_at: Timestamp,
1332) -> Result<ResolvedSupportCase, ValidationError> {
1333    ResolvedSupportCase::try_new(case_, resolved_at)
1334        .map_err(|_| ValidationError::CrmInvariantFailed)
1335}
1336
1337pub fn validate_retention_offer(
1338    account: CRMAccount,
1339    segment: CustomerSegment,
1340    coupon: Coupon,
1341    uses_before: Nat,
1342    discount: Money,
1343) -> Result<RetentionOffer, ValidationError> {
1344    RetentionOffer::try_new(account, segment, coupon, uses_before, discount)
1345        .map_err(|_| ValidationError::CrmInvariantFailed)
1346}
1347
1348#[allow(clippy::too_many_arguments)]
1349pub fn validate_logistics_shipment_plan(
1350    id: ShipmentId,
1351    order: Order,
1352    fulfillment: DistinctFulfillmentPlan,
1353    quote: CarrierQuote,
1354    warehouse: Warehouse,
1355    destination: ShippingDestination,
1356    planned_ship_at: Timestamp,
1357    promised_delivery_at: Timestamp,
1358) -> Result<LogisticsShipmentPlan, ValidationError> {
1359    let package = quote.package().clone();
1360    LogisticsShipmentPlan::try_new(
1361        id,
1362        order,
1363        fulfillment,
1364        package,
1365        quote,
1366        warehouse,
1367        destination,
1368        planned_ship_at,
1369        promised_delivery_at,
1370    )
1371    .map_err(|_| ValidationError::LogisticsInvariantFailed)
1372}
1373
1374pub fn validate_logistics_shipment(
1375    id: ShipmentId,
1376    plan: LogisticsShipmentPlan,
1377    status: ShipmentStatus,
1378    created_at: Timestamp,
1379    updated_at: Timestamp,
1380) -> Result<LogisticsShipment, ValidationError> {
1381    LogisticsShipment::try_new(id, plan, status, created_at, updated_at)
1382        .map_err(|_| ValidationError::LogisticsInvariantFailed)
1383}
1384
1385pub fn validate_carrier_handoff(
1386    plan: LogisticsShipmentPlan,
1387    tracking_number: Id,
1388    handed_off_at: Timestamp,
1389    acceptance_scan_at: Timestamp,
1390) -> Result<CarrierHandoff, ValidationError> {
1391    let service = plan.quote().service().clone();
1392    CarrierHandoff::try_new(
1393        plan,
1394        service,
1395        tracking_number,
1396        handed_off_at,
1397        acceptance_scan_at,
1398    )
1399    .map_err(|_| ValidationError::LogisticsInvariantFailed)
1400}
1401
1402#[allow(clippy::too_many_arguments)]
1403pub fn validate_warehouse_transfer(
1404    id: TransferId,
1405    sku: Sku,
1406    from_warehouse: Warehouse,
1407    to_warehouse: Warehouse,
1408    source_stock: StockState,
1409    requested: Quantity,
1410    in_transit: Quantity,
1411    received: Quantity,
1412) -> Result<WarehouseTransfer, ValidationError> {
1413    WarehouseTransfer::try_new(
1414        id,
1415        sku,
1416        from_warehouse,
1417        to_warehouse,
1418        source_stock,
1419        requested,
1420        in_transit,
1421        received,
1422    )
1423    .map_err(|_| ValidationError::LogisticsInvariantFailed)
1424}
1425
1426#[allow(clippy::too_many_arguments)]
1427pub fn validate_return_authorization(
1428    id: ReturnAuthorizationId,
1429    support_case: SupportCase,
1430    order: Order,
1431    ledger: PaymentLedger,
1432    status: ReturnAuthorizationStatus,
1433    lines: Vec<ReturnLine>,
1434    quantity: Quantity,
1435    refund_amount: Money,
1436    requested_at: Timestamp,
1437    decided_at: Timestamp,
1438) -> Result<ReturnAuthorization, ValidationError> {
1439    ReturnAuthorization::try_new(
1440        id,
1441        support_case,
1442        order,
1443        ledger,
1444        status,
1445        lines,
1446        quantity,
1447        refund_amount,
1448        requested_at,
1449        decided_at,
1450    )
1451    .map_err(|_| ValidationError::LogisticsInvariantFailed)
1452}
1453
1454pub fn validate_return_receipt(
1455    authorization: ReturnAuthorization,
1456    received_quantity: Quantity,
1457    refund_issued: Money,
1458    received_at: Timestamp,
1459) -> Result<ReturnReceipt, ValidationError> {
1460    ReturnReceipt::try_new(authorization, received_quantity, refund_issued, received_at)
1461        .map_err(|_| ValidationError::LogisticsInvariantFailed)
1462}
1463
1464pub fn validate_bounded_coupon_application(
1465    coupon: Coupon,
1466    subtotal: Money,
1467    uses_before: Nat,
1468) -> Result<BoundedCouponApplication, ValidationError> {
1469    BoundedCouponApplication::try_new(coupon, subtotal, uses_before)
1470        .map_err(|_| ValidationError::ImplicitInvariantFailed)
1471}
1472
1473pub fn validate_captured_payment_matches_order(
1474    order: Order,
1475    payment: CapturedPayment,
1476) -> Result<CapturedPaymentMatchesOrder, ValidationError> {
1477    CapturedPaymentMatchesOrder::try_new(order, payment)
1478        .map_err(|_| ValidationError::ImplicitInvariantFailed)
1479}
1480
1481pub fn validate_sellable_catalog_entry(
1482    entry: ProductCatalogEntry,
1483) -> Result<SellableCatalogEntry, ValidationError> {
1484    SellableCatalogEntry::try_new(entry).map_err(|_| ValidationError::ImplicitInvariantFailed)
1485}
1486
1487pub fn validate_publishable_feed_line(
1488    line: SafeProductFeedLine,
1489) -> Result<PublishableFeedLine, ValidationError> {
1490    PublishableFeedLine::try_new(line).map_err(|_| ValidationError::ImplicitInvariantFailed)
1491}
1492
1493pub fn validate_sourceable_distributor_product(
1494    product: DistributorProduct,
1495    units: Quantity,
1496) -> Result<SourceableDistributorProduct, ValidationError> {
1497    SourceableDistributorProduct::try_new(product, units)
1498        .map_err(|_| ValidationError::ImplicitInvariantFailed)
1499}
1500
1501pub fn validate_fraud_checked_coupon_application(
1502    application: BoundedCouponApplication,
1503    policy: FraudPolicy,
1504) -> Result<FraudCheckedCouponApplication, ValidationError> {
1505    FraudCheckedCouponApplication::try_new(application, policy)
1506        .map_err(|_| ValidationError::ImplicitInvariantFailed)
1507}
1508
1509pub fn validate_captured_payment_journal_projection(
1510    accounts: AccountingAccounts,
1511    payment: CapturedPayment,
1512) -> Result<CapturedPaymentJournalProjection, ValidationError> {
1513    let journal = payment_captured_journal(&accounts, payment.amount())
1514        .map_err(|_| ValidationError::ImplicitInvariantFailed)?;
1515    CapturedPaymentJournalProjection::try_new(accounts, payment, journal)
1516        .map_err(|_| ValidationError::ImplicitInvariantFailed)
1517}
1518
1519pub fn validate_refund_journal_projection(
1520    accounts: AccountingAccounts,
1521    ledger: PaymentLedger,
1522    amount: Money,
1523) -> Result<RefundJournalProjection, ValidationError> {
1524    let journal = refund_issued_journal(&accounts, amount)
1525        .map_err(|_| ValidationError::ImplicitInvariantFailed)?;
1526    RefundJournalProjection::try_new(accounts, ledger, amount, journal)
1527        .map_err(|_| ValidationError::ImplicitInvariantFailed)
1528}
1529
1530pub fn validate_advertisable_synced_marketplace_listing(
1531    synced: SyncedMarketplaceListing,
1532) -> Result<AdvertisableSyncedMarketplaceListing, ValidationError> {
1533    AdvertisableSyncedMarketplaceListing::try_new(synced)
1534        .map_err(|_| ValidationError::ImplicitInvariantFailed)
1535}
1536
1537pub fn validate_wholesale_credit_checkout(
1538    account: WholesaleCreditAccount,
1539    lines: Vec<WholesaleLine>,
1540    terms: PaymentTerms,
1541    order_total: Money,
1542) -> Result<WholesaleCreditCheckout, ValidationError> {
1543    WholesaleCreditCheckout::try_new(account, lines, terms, order_total)
1544        .map_err(|_| ValidationError::ImplicitInvariantFailed)
1545}
1546
1547pub fn validate_trusted_fresh_competitor_benchmark(
1548    benchmark: CompetitorPriceBenchmark,
1549    now: Timestamp,
1550    max_age: Duration,
1551    trust: TrustLevel,
1552) -> Result<TrustedFreshCompetitorBenchmark, ValidationError> {
1553    TrustedFreshCompetitorBenchmark::try_new(benchmark, now, max_age, trust)
1554        .map_err(|_| ValidationError::ImplicitInvariantFailed)
1555}
1556
1557pub fn validate_map_compliant_competitor_aware_offer(
1558    offer: CompetitorAwareDropshipOffer,
1559    policy: BrandPricingPolicy,
1560) -> Result<MapCompliantCompetitorAwareOffer, ValidationError> {
1561    MapCompliantCompetitorAwareOffer::try_new(offer, policy)
1562        .map_err(|_| ValidationError::ImplicitInvariantFailed)
1563}
1564
1565pub fn validate_fresh_currency_conversion(
1566    source_amount: MoneyAmount,
1567    rate: ExchangeRate,
1568    target_amount: MoneyAmount,
1569    now: Timestamp,
1570    max_age: Duration,
1571) -> Result<FreshCurrencyConversion, ValidationError> {
1572    FreshCurrencyConversion::try_new(source_amount, rate, target_amount, now, max_age)
1573        .map_err(|_| ValidationError::ImplicitInvariantFailed)
1574}
1575
1576pub fn validate_valid_gift_card_redemption_at(
1577    now: Timestamp,
1578    redemption: GiftCardRedemption,
1579) -> Result<ValidGiftCardRedemptionAt, ValidationError> {
1580    ValidGiftCardRedemptionAt::try_new(now, redemption)
1581        .map_err(|_| ValidationError::ImplicitInvariantFailed)
1582}
1583
1584pub fn validate_chargeback_for_captured_payment(
1585    payment: CapturedPayment,
1586    chargeback: Chargeback,
1587) -> Result<ChargebackForCapturedPayment, ValidationError> {
1588    ChargebackForCapturedPayment::try_new(payment, chargeback)
1589        .map_err(|_| ValidationError::ImplicitInvariantFailed)
1590}
1591
1592pub fn validate_actionable_demand_forecast(
1593    forecast: DemandForecast,
1594) -> Result<ActionableDemandForecast, ValidationError> {
1595    ActionableDemandForecast::try_new(forecast)
1596        .map_err(|_| ValidationError::ImplicitInvariantFailed)
1597}
1598
1599pub fn validate_approved_orderable_supplier_quality(
1600    quality: ApprovedSupplierQuality,
1601) -> Result<ApprovedOrderableSupplierQuality, ValidationError> {
1602    ApprovedOrderableSupplierQuality::try_new(quality)
1603        .map_err(|_| ValidationError::ImplicitInvariantFailed)
1604}
1605
1606pub fn validate_converted_lead_opportunity(
1607    lead: Lead,
1608    opportunity: SalesOpportunity,
1609) -> Result<ConvertedLeadOpportunity, ValidationError> {
1610    ConvertedLeadOpportunity::try_new(lead, opportunity)
1611        .map_err(|_| ValidationError::ImplicitInvariantFailed)
1612}
1613
1614pub fn validate_crm_order_contact(
1615    account: CRMAccount,
1616    contact: CRMContact,
1617    order: Order,
1618) -> Result<CRMOrderContact, ValidationError> {
1619    CRMOrderContact::try_new(account, contact, order)
1620        .map_err(|_| ValidationError::ImplicitInvariantFailed)
1621}
1622
1623pub fn validate_shipment_for_crm_order(
1624    crm_order: CRMOrderContact,
1625    plan: LogisticsShipmentPlan,
1626) -> Result<ShipmentForCRMOrder, ValidationError> {
1627    ShipmentForCRMOrder::try_new(crm_order, plan)
1628        .map_err(|_| ValidationError::ImplicitInvariantFailed)
1629}
1630
1631pub fn validate_logistics_exception_support_case(
1632    exception: LogisticsException,
1633    shipment: LogisticsShipmentPlan,
1634    support_case: SupportCase,
1635) -> Result<LogisticsExceptionSupportCase, ValidationError> {
1636    LogisticsExceptionSupportCase::try_new(exception, shipment, support_case)
1637        .map_err(|_| ValidationError::ImplicitInvariantFailed)
1638}
1639
1640pub fn validate_crm_approved_return_handling(
1641    authorization: ReturnAuthorization,
1642    receipt: ReturnReceipt,
1643) -> Result<CRMApprovedReturnHandling, ValidationError> {
1644    CRMApprovedReturnHandling::try_new(authorization, receipt)
1645        .map_err(|_| ValidationError::ImplicitInvariantFailed)
1646}