1use crate::accounting::*;
2use crate::b2b::*;
3use crate::catalog::*;
4use crate::competitor_pricing::*;
5use crate::crm::*;
6use crate::dropshipping::*;
7use crate::event_sourcing::*;
8use crate::forecasting::*;
9use crate::foundation::*;
10use crate::fulfillment_finance::*;
11use crate::logistics::*;
12use crate::marketplace::*;
13use crate::merchandising::*;
14use crate::opportunity_portfolio::*;
15use crate::orders::*;
16use crate::post_purchase::*;
17use crate::pricing::*;
18use crate::risk_privacy::*;
19
20#[derive(Clone, Debug, PartialEq, Eq)]
21#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
22pub struct BoundedCouponApplication {
23 pub(crate) coupon: Coupon,
24 pub(crate) subtotal: Money,
25 pub(crate) uses_before: Nat,
26}
27
28impl BoundedCouponApplication {
29 pub const fn try_new(coupon: Coupon, subtotal: Money, uses_before: Nat) -> DomainResult<Self> {
30 if !coupon_can_be_applied(&coupon, subtotal, uses_before) {
31 return Err(ValidationError::Invariant("coupon cannot be applied"));
32 }
33 if coupon.amount > subtotal {
34 return Err(ValidationError::Invariant("coupon amount exceeds subtotal"));
35 }
36 Ok(Self {
37 coupon,
38 subtotal,
39 uses_before,
40 })
41 }
42}
43
44#[derive(Clone, Debug, PartialEq, Eq)]
45#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
46pub struct CapturedPaymentMatchesOrder {
47 pub(crate) order: Order,
48 pub(crate) payment: CapturedPayment,
49}
50
51impl CapturedPaymentMatchesOrder {
52 pub fn try_new(order: Order, payment: CapturedPayment) -> DomainResult<Self> {
53 if payment.order_id != order.id()
54 || payment.amount != order.total()
55 || payment.currency != order.currency()
56 {
57 return Err(ValidationError::Invariant(
58 "captured payment does not match order",
59 ));
60 }
61 Ok(Self { order, payment })
62 }
63}
64
65#[must_use]
66pub fn event_stream_last_sequence_from(last: Nat, events: &[EventEnvelope]) -> Nat {
67 events.last().map_or(last, |event| event.sequence)
68}
69
70#[must_use]
71pub fn event_stream_computed_last_sequence(stream: &EventStream) -> Nat {
72 event_stream_last_sequence_from(0, &stream.events)
73}
74
75#[derive(Clone, Debug, PartialEq, Eq)]
76#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
77pub struct ValidEventStream {
78 pub(crate) stream: EventStream,
79}
80
81impl ValidEventStream {
82 pub fn try_new(stream: EventStream) -> DomainResult<Self> {
83 if !stream_sequences_strictly_increase(&stream) {
84 return Err(ValidationError::Invariant(
85 "event stream sequences must strictly increase",
86 ));
87 }
88 if stream.last_sequence != event_stream_computed_last_sequence(&stream) {
89 return Err(ValidationError::Invariant(
90 "event stream cursor does not match events",
91 ));
92 }
93 Ok(Self { stream })
94 }
95}
96
97#[must_use]
98pub fn product_active(product: &Product) -> bool {
99 product.status == ProductStatus::Active
100}
101
102#[must_use]
103pub const fn variant_active(variant: &ProductVariant) -> bool {
104 variant.active
105}
106
107#[derive(Clone, Debug, PartialEq, Eq)]
108#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
109pub struct SellableCatalogEntry {
110 pub(crate) entry: ProductCatalogEntry,
111}
112
113impl SellableCatalogEntry {
114 pub fn try_new(entry: ProductCatalogEntry) -> DomainResult<Self> {
115 if !product_active(&entry.product) || !variant_active(&entry.variant) {
116 return Err(ValidationError::Invariant("catalog entry is not sellable"));
117 }
118 Ok(Self { entry })
119 }
120}
121
122#[must_use]
123pub const fn feed_line_has_stock(line: &SafeProductFeedLine) -> bool {
124 line.stock > 0
125}
126
127#[derive(Clone, Debug, PartialEq, Eq)]
128#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
129pub struct PublishableFeedLine {
130 pub(crate) line: SafeProductFeedLine,
131}
132
133impl PublishableFeedLine {
134 pub const fn try_new(line: SafeProductFeedLine) -> DomainResult<Self> {
135 if !feed_line_has_stock(&line) {
136 return Err(ValidationError::Invariant("feed line has no stock"));
137 }
138 Ok(Self { line })
139 }
140}
141
142#[must_use]
143pub const fn distributor_product_active(product: &DistributorProduct) -> bool {
144 product.active
145}
146
147#[must_use]
148pub const fn distributor_product_can_source(product: &DistributorProduct, units: Quantity) -> bool {
149 distributor_product_active(product)
150 && product.min_order_qty <= units
151 && units <= product.available_qty
152}
153
154#[derive(Clone, Debug, PartialEq, Eq)]
155#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
156pub struct SourceableDistributorProduct {
157 pub(crate) product: DistributorProduct,
158 pub(crate) units: Quantity,
159}
160
161impl SourceableDistributorProduct {
162 pub const fn try_new(product: DistributorProduct, units: Quantity) -> DomainResult<Self> {
163 if !distributor_product_can_source(&product, units) {
164 return Err(ValidationError::Invariant(
165 "distributor product cannot source requested units",
166 ));
167 }
168 Ok(Self { product, units })
169 }
170}
171
172#[derive(Clone, Debug, PartialEq, Eq)]
173#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
174pub struct FraudCheckedCouponApplication {
175 pub(crate) application: BoundedCouponApplication,
176 pub(crate) policy: FraudPolicy,
177}
178
179impl FraudCheckedCouponApplication {
180 pub const fn try_new(
181 application: BoundedCouponApplication,
182 policy: FraudPolicy,
183 ) -> DomainResult<Self> {
184 if !coupon_uses_allowed(&policy, application.uses_before) {
185 return Err(ValidationError::Invariant("coupon use fails fraud policy"));
186 }
187 Ok(Self {
188 application,
189 policy,
190 })
191 }
192}
193
194#[derive(Clone, Debug, PartialEq, Eq)]
195#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
196pub struct CapturedPaymentJournalProjection {
197 pub(crate) accounts: AccountingAccounts,
198 pub(crate) payment: CapturedPayment,
199 pub(crate) journal: BalancedJournalEntry,
200}
201
202impl CapturedPaymentJournalProjection {
203 pub fn try_new(
204 accounts: AccountingAccounts,
205 payment: CapturedPayment,
206 journal: BalancedJournalEntry,
207 ) -> DomainResult<Self> {
208 if journal != payment_captured_journal(&accounts, payment.amount)? {
209 return Err(ValidationError::Invariant(
210 "payment-capture journal projection is incorrect",
211 ));
212 }
213 Ok(Self {
214 accounts,
215 payment,
216 journal,
217 })
218 }
219}
220
221#[derive(Clone, Debug, PartialEq, Eq)]
222#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
223pub struct RefundJournalProjection {
224 pub(crate) accounts: AccountingAccounts,
225 pub(crate) ledger: PaymentLedger,
226 pub(crate) amount: Money,
227 pub(crate) journal: BalancedJournalEntry,
228}
229
230impl RefundJournalProjection {
231 pub fn try_new(
232 accounts: AccountingAccounts,
233 ledger: PaymentLedger,
234 amount: Money,
235 journal: BalancedJournalEntry,
236 ) -> DomainResult<Self> {
237 if !can_refund(&ledger, amount) {
238 return Err(ValidationError::Invariant(
239 "refund amount is not refundable",
240 ));
241 }
242 if journal != refund_issued_journal(&accounts, amount)? {
243 return Err(ValidationError::Invariant(
244 "refund journal projection is incorrect",
245 ));
246 }
247 Ok(Self {
248 accounts,
249 ledger,
250 amount,
251 journal,
252 })
253 }
254}
255
256#[derive(Clone, Debug, PartialEq, Eq)]
257#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
258pub struct AdvertisableSyncedMarketplaceListing {
259 pub(crate) synced: SyncedMarketplaceListing,
260}
261
262impl AdvertisableSyncedMarketplaceListing {
263 pub fn try_new(synced: SyncedMarketplaceListing) -> DomainResult<Self> {
264 if !listing_can_be_advertised(&synced.listing) {
265 return Err(ValidationError::Invariant(
266 "synced listing cannot be advertised",
267 ));
268 }
269 Ok(Self { synced })
270 }
271}
272
273#[derive(Clone, Debug, PartialEq, Eq)]
274#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
275pub struct WholesaleCreditCheckout {
276 pub(crate) account: WholesaleCreditAccount,
277 pub(crate) lines: Vec<WholesaleLine>,
278 pub(crate) terms: PaymentTerms,
279 pub(crate) order_total: Money,
280}
281
282impl WholesaleCreditCheckout {
283 pub fn try_new(
284 account: WholesaleCreditAccount,
285 lines: Vec<WholesaleLine>,
286 terms: PaymentTerms,
287 order_total: Money,
288 ) -> DomainResult<Self> {
289 if order_total != wholesale_order_net_total(&lines)? {
290 return Err(ValidationError::Invariant(
291 "wholesale checkout total is incorrect",
292 ));
293 }
294 if !payment_terms_allowed(TradeMode::Wholesale, terms) {
295 return Err(ValidationError::Invariant(
296 "payment terms not allowed for wholesale",
297 ));
298 }
299 if !can_place_wholesale_credit_order(&account, order_total) {
300 return Err(ValidationError::Invariant(
301 "wholesale checkout exceeds credit limit",
302 ));
303 }
304 Ok(Self {
305 account,
306 lines,
307 terms,
308 order_total,
309 })
310 }
311}
312
313#[derive(Clone, Debug, PartialEq, Eq)]
314#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
315pub struct TrustedFreshCompetitorBenchmark {
316 pub(crate) benchmark: CompetitorPriceBenchmark,
317 pub(crate) now: Timestamp,
318 pub(crate) max_age: Duration,
319 pub(crate) trust: TrustLevel,
320}
321
322impl TrustedFreshCompetitorBenchmark {
323 pub fn try_new(
324 benchmark: CompetitorPriceBenchmark,
325 now: Timestamp,
326 max_age: Duration,
327 trust: TrustLevel,
328 ) -> DomainResult<Self> {
329 if !price_snapshot_fresh(now, max_age, benchmark.best_offer.observed_at) {
330 return Err(ValidationError::Invariant("benchmark best offer is stale"));
331 }
332 if !trust_allows_auto_repricing(trust) {
333 return Err(ValidationError::Invariant(
334 "trust level does not allow auto repricing",
335 ));
336 }
337 Ok(Self {
338 benchmark,
339 now,
340 max_age,
341 trust,
342 })
343 }
344}
345
346#[derive(Clone, Debug, PartialEq, Eq)]
347#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
348pub struct MapCompliantCompetitorAwareOffer {
349 pub(crate) offer: CompetitorAwareDropshipOffer,
350 pub(crate) policy: BrandPricingPolicy,
351}
352
353impl MapCompliantCompetitorAwareOffer {
354 pub fn try_new(
355 offer: CompetitorAwareDropshipOffer,
356 policy: BrandPricingPolicy,
357 ) -> DomainResult<Self> {
358 if !advertised_price_allowed(&policy, offer.offer.sale_unit_price()) {
359 return Err(ValidationError::Invariant("offer violates MAP policy"));
360 }
361 Ok(Self { offer, policy })
362 }
363}
364
365#[derive(Clone, Debug, PartialEq, Eq)]
366#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
367pub struct FreshCurrencyConversion {
368 pub(crate) source_amount: MoneyAmount,
369 pub(crate) rate: ExchangeRate,
370 pub(crate) target_amount: MoneyAmount,
371 pub(crate) now: Timestamp,
372 pub(crate) max_age: Duration,
373}
374
375impl FreshCurrencyConversion {
376 pub fn try_new(
377 source_amount: MoneyAmount,
378 rate: ExchangeRate,
379 target_amount: MoneyAmount,
380 now: Timestamp,
381 max_age: Duration,
382 ) -> DomainResult<Self> {
383 if source_amount.currency != rate.source || target_amount.currency != rate.target {
384 return Err(ValidationError::Invariant(
385 "currency conversion currencies do not match rate",
386 ));
387 }
388 if target_amount.amount != convert_money_floor(source_amount.amount, &rate)? {
389 return Err(ValidationError::Invariant(
390 "currency conversion amount is incorrect",
391 ));
392 }
393 if !fx_quote_fresh(now, max_age, &rate) {
394 return Err(ValidationError::Invariant("exchange rate is stale"));
395 }
396 Ok(Self {
397 source_amount,
398 rate,
399 target_amount,
400 now,
401 max_age,
402 })
403 }
404}
405
406#[derive(Clone, Debug, PartialEq, Eq)]
407#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
408pub struct ValidGiftCardRedemptionAt {
409 pub(crate) now: Timestamp,
410 pub(crate) redemption: GiftCardRedemption,
411}
412
413impl ValidGiftCardRedemptionAt {
414 pub fn try_new(now: Timestamp, redemption: GiftCardRedemption) -> DomainResult<Self> {
415 if !gift_card_valid_at(now, &redemption.card) {
416 return Err(ValidationError::Invariant("gift card has expired"));
417 }
418 Ok(Self { now, redemption })
419 }
420}
421
422#[derive(Clone, Debug, PartialEq, Eq)]
423#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
424pub struct ChargebackForCapturedPayment {
425 pub(crate) payment: CapturedPayment,
426 pub(crate) chargeback: Chargeback,
427}
428
429impl ChargebackForCapturedPayment {
430 pub const fn try_new(payment: CapturedPayment, chargeback: Chargeback) -> DomainResult<Self> {
431 if chargeback.payment_amount != payment.amount {
432 return Err(ValidationError::Invariant(
433 "chargeback payment amount mismatch",
434 ));
435 }
436 Ok(Self {
437 payment,
438 chargeback,
439 })
440 }
441}
442
443#[must_use]
444pub fn demand_forecast_actionable(forecast: &DemandForecast) -> bool {
445 confidence_allows_auto_replenish(forecast.confidence)
446 && forecast.expected_units > 0
447 && forecast.horizon_days > Duration::ZERO
448}
449
450#[derive(Clone, Debug, PartialEq, Eq)]
451#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
452pub struct ActionableDemandForecast {
453 pub(crate) forecast: DemandForecast,
454}
455
456impl ActionableDemandForecast {
457 pub fn try_new(forecast: DemandForecast) -> DomainResult<Self> {
458 if !demand_forecast_actionable(&forecast) {
459 return Err(ValidationError::Invariant(
460 "demand forecast is not actionable",
461 ));
462 }
463 Ok(Self { forecast })
464 }
465}
466
467#[derive(Clone, Debug, PartialEq, Eq)]
468#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
469pub struct ApprovedOrderableSupplierQuality {
470 pub(crate) quality: ApprovedSupplierQuality,
471}
472
473impl ApprovedOrderableSupplierQuality {
474 pub fn try_new(quality: ApprovedSupplierQuality) -> DomainResult<Self> {
475 if !supplier_can_receive_orders(&quality.supplier) {
476 return Err(ValidationError::Invariant(
477 "approved supplier cannot receive orders",
478 ));
479 }
480 Ok(Self { quality })
481 }
482}
483
484#[derive(Clone, Debug, PartialEq, Eq)]
485#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
486pub struct ConvertedLeadOpportunity {
487 pub(crate) lead: Lead,
488 pub(crate) opportunity: SalesOpportunity,
489}
490
491impl ConvertedLeadOpportunity {
492 pub fn try_new(lead: Lead, opportunity: SalesOpportunity) -> DomainResult<Self> {
493 if lead.status() != LeadStatus::Converted
494 || opportunity.source_lead() != Some(lead.id())
495 || opportunity.account_id() != lead.account_id()
496 || opportunity.contact_id() != lead.contact_id()
497 || opportunity.currency() != lead.currency()
498 || opportunity.amount() > lead.estimated_value()
499 {
500 return Err(ValidationError::ImplicitInvariantFailed);
501 }
502 Ok(Self { lead, opportunity })
503 }
504}
505
506#[derive(Clone, Debug, PartialEq, Eq)]
507#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
508pub struct CRMOrderContact {
509 pub(crate) account: CRMAccount,
510 pub(crate) contact: CRMContact,
511 pub(crate) order: Order,
512}
513
514impl CRMOrderContact {
515 pub fn try_new(account: CRMAccount, contact: CRMContact, order: Order) -> DomainResult<Self> {
516 if !crm_account_active(&account)
517 || contact.account_id() != account.id()
518 || contact.customer_id() != account.customer().id()
519 {
520 return Err(ValidationError::ImplicitInvariantFailed);
521 }
522 Ok(Self {
523 account,
524 contact,
525 order,
526 })
527 }
528}
529
530#[derive(Clone, Debug, PartialEq, Eq)]
531#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
532pub struct ShipmentForCRMOrder {
533 pub(crate) crm_order: CRMOrderContact,
534 pub(crate) plan: LogisticsShipmentPlan,
535}
536
537impl ShipmentForCRMOrder {
538 pub fn try_new(crm_order: CRMOrderContact, plan: LogisticsShipmentPlan) -> DomainResult<Self> {
539 if plan.order() != &crm_order.order {
540 return Err(ValidationError::ImplicitInvariantFailed);
541 }
542 Ok(Self { crm_order, plan })
543 }
544}
545
546#[derive(Clone, Debug, PartialEq, Eq)]
547#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
548pub struct LogisticsExceptionSupportCase {
549 pub(crate) exception: LogisticsException,
550 pub(crate) shipment: LogisticsShipmentPlan,
551 pub(crate) support_case: SupportCase,
552}
553
554impl LogisticsExceptionSupportCase {
555 pub fn try_new(
556 exception: LogisticsException,
557 shipment: LogisticsShipmentPlan,
558 support_case: SupportCase,
559 ) -> DomainResult<Self> {
560 if exception.shipment_id() != shipment.id()
561 || support_case.order_id() != Some(shipment.order().id())
562 || support_case.status() != SupportCaseStatus::Escalated
563 || support_case.opened_at() < exception.raised_at()
564 {
565 return Err(ValidationError::ImplicitInvariantFailed);
566 }
567 Ok(Self {
568 exception,
569 shipment,
570 support_case,
571 })
572 }
573}
574
575#[derive(Clone, Debug, PartialEq, Eq)]
576#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
577pub struct CRMApprovedReturnHandling {
578 pub(crate) authorization: ReturnAuthorization,
579 pub(crate) receipt: ReturnReceipt,
580}
581
582impl CRMApprovedReturnHandling {
583 pub fn try_new(
584 authorization: ReturnAuthorization,
585 receipt: ReturnReceipt,
586 ) -> DomainResult<Self> {
587 if !return_authorization_approved(&authorization)
588 || receipt.authorization() != &authorization
589 {
590 return Err(ValidationError::ImplicitInvariantFailed);
591 }
592 Ok(Self {
593 authorization,
594 receipt,
595 })
596 }
597}
598
599impl_getters!(BoundedCouponApplication {
600 coupon: Coupon,
601 subtotal: Money,
602 uses_before: Nat,
603});
604
605impl_getters!(CapturedPaymentMatchesOrder {
606 order: Order,
607 payment: CapturedPayment,
608});
609
610impl_getters!(ValidEventStream {
611 stream: EventStream,
612});
613
614impl_getters!(SellableCatalogEntry {
615 entry: ProductCatalogEntry,
616});
617
618impl_getters!(PublishableFeedLine {
619 line: SafeProductFeedLine,
620});
621
622impl_getters!(SourceableDistributorProduct {
623 product: DistributorProduct,
624 units: Quantity,
625});
626
627impl_getters!(FraudCheckedCouponApplication {
628 application: BoundedCouponApplication,
629 policy: FraudPolicy,
630});
631
632impl_getters!(CapturedPaymentJournalProjection {
633 accounts: AccountingAccounts,
634 payment: CapturedPayment,
635 journal: BalancedJournalEntry,
636});
637
638impl_getters!(RefundJournalProjection {
639 accounts: AccountingAccounts,
640 ledger: PaymentLedger,
641 amount: Money,
642 journal: BalancedJournalEntry,
643});
644
645impl_getters!(AdvertisableSyncedMarketplaceListing {
646 synced: SyncedMarketplaceListing,
647});
648
649impl_getters!(WholesaleCreditCheckout {
650 account: WholesaleCreditAccount,
651 lines: Vec<WholesaleLine>,
652 terms: PaymentTerms,
653 order_total: Money,
654});
655
656impl_getters!(TrustedFreshCompetitorBenchmark {
657 benchmark: CompetitorPriceBenchmark,
658 now: Timestamp,
659 max_age: Duration,
660 trust: TrustLevel,
661});
662
663impl_getters!(MapCompliantCompetitorAwareOffer {
664 offer: CompetitorAwareDropshipOffer,
665 policy: BrandPricingPolicy,
666});
667
668impl_getters!(FreshCurrencyConversion {
669 source_amount: MoneyAmount,
670 rate: ExchangeRate,
671 target_amount: MoneyAmount,
672 now: Timestamp,
673 max_age: Duration,
674});
675
676impl_getters!(ValidGiftCardRedemptionAt {
677 now: Timestamp,
678 redemption: GiftCardRedemption,
679});
680
681impl_getters!(ChargebackForCapturedPayment {
682 payment: CapturedPayment,
683 chargeback: Chargeback,
684});
685
686impl_getters!(ActionableDemandForecast {
687 forecast: DemandForecast,
688});
689
690impl_getters!(ApprovedOrderableSupplierQuality {
691 quality: ApprovedSupplierQuality,
692});
693
694impl_getters!(ConvertedLeadOpportunity {
695 lead: Lead,
696 opportunity: SalesOpportunity,
697});
698
699impl_getters!(CRMOrderContact {
700 account: CRMAccount,
701 contact: CRMContact,
702 order: Order,
703});
704
705impl_getters!(ShipmentForCRMOrder {
706 crm_order: CRMOrderContact,
707 plan: LogisticsShipmentPlan,
708});
709
710impl_getters!(LogisticsExceptionSupportCase {
711 exception: LogisticsException,
712 shipment: LogisticsShipmentPlan,
713 support_case: SupportCase,
714});
715
716impl_getters!(CRMApprovedReturnHandling {
717 authorization: ReturnAuthorization,
718 receipt: ReturnReceipt,
719});