Skip to main content

commerce_theory/
implicit_invariants.rs

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