Skip to main content

commerce_theory/
foundation.rs

1use core::fmt;
2use core::marker::PhantomData;
3use time::{Date as TimeDate, Duration as TimeDuration, Month, PrimitiveDateTime, Time};
4
5pub type Nat = u128;
6pub type MinorUnit = Nat;
7pub type NonNegMoney = MinorUnit;
8pub type Money = NonNegMoney;
9pub type SignedMoney = i128;
10pub type Quantity = u128;
11pub type Weight = u128;
12pub type Date = TimeDate;
13pub type Timestamp = PrimitiveDateTime;
14pub type Duration = TimeDuration;
15pub type Days = Duration;
16pub type Id = u128;
17
18#[derive(Clone, Copy, Debug, PartialEq, Eq)]
19#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
20pub struct DecimalMoney {
21    pub(crate) coefficient: SignedMoney,
22    pub(crate) scale: Nat,
23}
24
25impl DecimalMoney {
26    #[must_use]
27    pub const fn new(coefficient: SignedMoney, scale: Nat) -> Self {
28        Self { coefficient, scale }
29    }
30
31    #[must_use]
32    pub const fn coefficient(&self) -> SignedMoney {
33        self.coefficient
34    }
35
36    #[must_use]
37    pub const fn scale(&self) -> Nat {
38        self.scale
39    }
40}
41
42impl crate::FieldAccess for DecimalMoney {
43    type Output<'a> = Self;
44
45    fn access(&self) -> Self::Output<'_> {
46        *self
47    }
48}
49
50#[derive(Clone, Copy, Debug, PartialEq, Eq)]
51#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
52pub enum ValidationError {
53    Invariant(&'static str),
54    Overflow(&'static str),
55    DivisionByZero(&'static str),
56    LineDiscountExceedsGross,
57    CouponExceedsSubtotal,
58    ShippingUnavailable,
59    OrderTotalMismatch,
60    StockReservedExceedsTotal,
61    PricePolicyInvalid,
62    FeedSkuMismatch,
63    FeedPriceOutOfPolicy,
64    FeedStockUnavailable,
65    LedgerRefundedExceedsCaptured,
66    RefundExceedsRemaining,
67    BasisPointsOutOfRange,
68    CatalogInvariantFailed,
69    InventoryInvariantFailed,
70    AccountingInvariantFailed,
71    MarketplaceInvariantFailed,
72    MarketingInvariantFailed,
73    B2BInvariantFailed,
74    DropshippingInvariantFailed,
75    ProfitInvariantFailed,
76    CompetitorInvariantFailed,
77    MerchandisingInvariantFailed,
78    FinanceInvariantFailed,
79    AuditPermissionDenied,
80    EventStreamInvalid,
81    PostPurchaseInvariantFailed,
82    SupplierQualityInvalid,
83    OpportunityInvariantFailed,
84    CrmInvariantFailed,
85    LogisticsInvariantFailed,
86    ImplicitInvariantFailed,
87    TaxInvariantFailed,
88}
89
90impl fmt::Display for ValidationError {
91    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
92        match self {
93            Self::Invariant(message) => write!(f, "invariant failed: {message}"),
94            Self::Overflow(message) => write!(f, "arithmetic overflow: {message}"),
95            Self::DivisionByZero(message) => write!(f, "division by zero: {message}"),
96            Self::LineDiscountExceedsGross => write!(f, "line discount exceeds gross"),
97            Self::CouponExceedsSubtotal => write!(f, "coupon exceeds cart net subtotal"),
98            Self::ShippingUnavailable => write!(f, "shipping unavailable"),
99            Self::OrderTotalMismatch => write!(f, "order total mismatch"),
100            Self::StockReservedExceedsTotal => write!(f, "reserved stock exceeds total"),
101            Self::PricePolicyInvalid => write!(f, "price policy invalid"),
102            Self::FeedSkuMismatch => write!(f, "feed SKU mismatch"),
103            Self::FeedPriceOutOfPolicy => write!(f, "feed price out of policy"),
104            Self::FeedStockUnavailable => write!(f, "feed stock unavailable"),
105            Self::LedgerRefundedExceedsCaptured => write!(f, "ledger refunded exceeds captured"),
106            Self::RefundExceedsRemaining => write!(f, "refund exceeds remaining amount"),
107            Self::BasisPointsOutOfRange => write!(f, "basis points out of range"),
108            Self::CatalogInvariantFailed => write!(f, "catalog invariant failed"),
109            Self::InventoryInvariantFailed => write!(f, "inventory invariant failed"),
110            Self::AccountingInvariantFailed => write!(f, "accounting invariant failed"),
111            Self::MarketplaceInvariantFailed => write!(f, "marketplace invariant failed"),
112            Self::MarketingInvariantFailed => write!(f, "marketing invariant failed"),
113            Self::B2BInvariantFailed => write!(f, "B2B invariant failed"),
114            Self::DropshippingInvariantFailed => write!(f, "dropshipping invariant failed"),
115            Self::ProfitInvariantFailed => write!(f, "profit invariant failed"),
116            Self::CompetitorInvariantFailed => write!(f, "competitor invariant failed"),
117            Self::MerchandisingInvariantFailed => write!(f, "merchandising invariant failed"),
118            Self::FinanceInvariantFailed => write!(f, "finance invariant failed"),
119            Self::AuditPermissionDenied => write!(f, "audit permission denied"),
120            Self::EventStreamInvalid => write!(f, "event stream invalid"),
121            Self::PostPurchaseInvariantFailed => write!(f, "post-purchase invariant failed"),
122            Self::SupplierQualityInvalid => write!(f, "supplier quality invalid"),
123            Self::OpportunityInvariantFailed => write!(f, "opportunity invariant failed"),
124            Self::CrmInvariantFailed => write!(f, "CRM invariant failed"),
125            Self::LogisticsInvariantFailed => write!(f, "logistics invariant failed"),
126            Self::ImplicitInvariantFailed => write!(f, "implicit invariant failed"),
127            Self::TaxInvariantFailed => write!(f, "tax invariant failed"),
128        }
129    }
130}
131
132impl std::error::Error for ValidationError {}
133
134pub type DomainResult<T> = Result<T, ValidationError>;
135
136pub fn checked_add(a: Nat, b: Nat, context: &'static str) -> DomainResult<Nat> {
137    a.checked_add(b).ok_or(ValidationError::Overflow(context))
138}
139
140pub fn checked_mul(a: Nat, b: Nat, context: &'static str) -> DomainResult<Nat> {
141    a.checked_mul(b).ok_or(ValidationError::Overflow(context))
142}
143
144pub fn checked_div(a: Nat, b: Nat, context: &'static str) -> DomainResult<Nat> {
145    a.checked_div(b)
146        .ok_or(ValidationError::DivisionByZero(context))
147}
148
149pub fn checked_sum<I>(items: I, context: &'static str) -> DomainResult<Nat>
150where
151    I: IntoIterator<Item = Nat>,
152{
153    items
154        .into_iter()
155        .try_fold(0, |acc, item| checked_add(acc, item, context))
156}
157
158pub(crate) fn checked_result_sum<I>(items: I, context: &'static str) -> DomainResult<Nat>
159where
160    I: IntoIterator<Item = DomainResult<Nat>>,
161{
162    items
163        .into_iter()
164        .try_fold(0, |acc, item| checked_add(acc, item?, context))
165}
166
167#[must_use]
168pub const fn nat_sub(a: Nat, b: Nat) -> Nat {
169    a.saturating_sub(b)
170}
171
172#[must_use]
173pub fn timestamp_from_ymdhms(
174    year: i32,
175    month: u8,
176    day: u8,
177    hour: u8,
178    minute: u8,
179    second: u8,
180) -> Option<Timestamp> {
181    let month = Month::try_from(month).ok()?;
182    let date = Date::from_calendar_date(year, month, day).ok()?;
183    let time = Time::from_hms(hour, minute, second).ok()?;
184    Some(PrimitiveDateTime::new(date, time))
185}
186
187/// Returns the Unix epoch timestamp.
188///
189/// # Panics
190///
191/// Panics only if the literal Unix epoch date is invalid in `time`, which would indicate a
192/// broken timestamp construction invariant.
193#[must_use]
194pub fn unix_epoch_timestamp() -> Timestamp {
195    timestamp_from_ymdhms(1970, 1, 1, 0, 0, 0).expect("valid unix epoch timestamp")
196}
197
198#[must_use]
199pub fn timestamp_age(now: Timestamp, observed_at: Timestamp) -> Duration {
200    now - observed_at
201}
202
203#[must_use]
204pub fn days(n: Nat) -> Days {
205    Duration::days(i64::try_from(n).unwrap_or(i64::MAX))
206}
207
208#[derive(Clone, Copy, Debug, PartialEq, Eq)]
209#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
210pub enum RoundingMode {
211    Floor,
212    Ceiling,
213    HalfUp,
214}
215
216pub fn round_div(mode: RoundingMode, numerator: Nat, denominator: Nat) -> DomainResult<Nat> {
217    if denominator == 0 {
218        return Err(ValidationError::DivisionByZero("round_div denominator"));
219    }
220    match mode {
221        RoundingMode::Floor => Ok(numerator / denominator),
222        RoundingMode::Ceiling => {
223            let quotient = numerator / denominator;
224            if numerator.is_multiple_of(denominator) {
225                Ok(quotient)
226            } else {
227                checked_add(quotient, 1, "round_div ceiling")
228            }
229        }
230        RoundingMode::HalfUp => {
231            let half = denominator / 2;
232            checked_div(
233                checked_add(numerator, half, "round_div half-up")?,
234                denominator,
235                "round_div half-up",
236            )
237        }
238    }
239}
240
241pub fn round_money(mode: RoundingMode, numerator: Nat, denominator: Nat) -> DomainResult<Money> {
242    round_div(mode, numerator, denominator)
243}
244
245pub const fn floor_rounding_remainder(numerator: Nat, denominator: Nat) -> DomainResult<Nat> {
246    if denominator == 0 {
247        Err(ValidationError::DivisionByZero(
248            "floor_rounding_remainder denominator",
249        ))
250    } else {
251        Ok(numerator % denominator)
252    }
253}
254
255pub fn floor_rounded_lines_remainder_total(
256    denominator: Nat,
257    numerators: &[Nat],
258) -> DomainResult<Nat> {
259    checked_result_sum(
260        numerators
261            .iter()
262            .map(|numerator| floor_rounding_remainder(*numerator, denominator)),
263        "floor_rounded_lines_remainder_total",
264    )
265}
266
267macro_rules! id_type {
268    ($name:ident) => {
269        #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
270        #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
271        pub struct $name {
272            value: Nat,
273        }
274
275        impl $name {
276            #[must_use]
277            pub const fn new(value: Nat) -> Self {
278                Self { value }
279            }
280
281            pub const fn try_new(value: Nat) -> DomainResult<Self> {
282                Ok(Self::new(value))
283            }
284
285            #[must_use]
286            pub const fn value(self) -> Nat {
287                self.value
288            }
289        }
290
291        impl $crate::FieldAccess for $name {
292            type Output<'a> = Self;
293
294            fn access(&self) -> Self::Output<'_> {
295                *self
296            }
297        }
298    };
299}
300
301id_type!(Sku);
302id_type!(ProductId);
303id_type!(VariantId);
304id_type!(CustomerId);
305id_type!(OrderId);
306id_type!(PaymentId);
307id_type!(SupplierId);
308id_type!(MarketplaceOrderId);
309id_type!(CampaignId);
310id_type!(CompetitorId);
311id_type!(IdempotencyKey);
312id_type!(AccountId);
313id_type!(ContactId);
314id_type!(LeadId);
315id_type!(OpportunityId);
316id_type!(InteractionId);
317id_type!(SegmentId);
318id_type!(SupportCaseId);
319id_type!(ShipmentId);
320id_type!(TrackingEventId);
321id_type!(TransferId);
322id_type!(ReturnAuthorizationId);
323
324#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
325#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
326pub enum Currency {
327    UAH,
328    USD,
329    EUR,
330    GBP,
331    PLN,
332}
333
334pub trait CurrencyMarker: Clone + Copy + fmt::Debug + PartialEq + Eq {
335    const CURRENCY: Currency;
336}
337
338macro_rules! currency_marker {
339    ($name:ident, $currency:ident) => {
340        #[derive(Clone, Copy, Debug, PartialEq, Eq)]
341        pub struct $name;
342        impl CurrencyMarker for $name {
343            const CURRENCY: Currency = Currency::$currency;
344        }
345    };
346}
347
348currency_marker!(Uah, UAH);
349currency_marker!(Usd, USD);
350currency_marker!(Eur, EUR);
351currency_marker!(Gbp, GBP);
352currency_marker!(Pln, PLN);
353
354#[derive(Clone, Copy, Debug, PartialEq, Eq)]
355pub struct MoneyIn<C: CurrencyMarker> {
356    amount: Money,
357    _currency: PhantomData<C>,
358}
359
360impl<C: CurrencyMarker> MoneyIn<C> {
361    #[must_use]
362    pub const fn new(amount: Money) -> Self {
363        Self {
364            amount,
365            _currency: PhantomData,
366        }
367    }
368
369    #[must_use]
370    pub const fn zero() -> Self {
371        Self::new(0)
372    }
373
374    #[must_use]
375    pub const fn amount(self) -> Money {
376        self.amount
377    }
378
379    #[must_use]
380    pub const fn currency(self) -> Currency {
381        C::CURRENCY
382    }
383
384    pub fn checked_add(self, other: Self) -> DomainResult<Self> {
385        Ok(Self::new(checked_add(
386            self.amount,
387            other.amount,
388            "MoneyIn::add",
389        )?))
390    }
391
392    #[must_use]
393    pub const fn saturating_sub(self, other: Self) -> Self {
394        Self::new(nat_sub(self.amount, other.amount))
395    }
396}
397
398impl<C: CurrencyMarker> crate::FieldAccess for MoneyIn<C> {
399    type Output<'a>
400        = Self
401    where
402        C: 'a;
403
404    fn access(&self) -> Self::Output<'_> {
405        *self
406    }
407}
408
409domain_struct! {
410    pub struct MoneyAmount {
411        amount: Money,
412        currency: Currency,
413    }
414}
415
416#[must_use]
417pub fn same_currency(a: &MoneyAmount, b: &MoneyAmount) -> bool {
418    a.currency == b.currency
419}
420
421#[derive(Clone, Copy, Debug, PartialEq, Eq)]
422#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
423pub struct BasisPoints {
424    value: Nat,
425}
426
427impl BasisPoints {
428    pub const fn try_new(value: Nat) -> DomainResult<Self> {
429        if value <= 10_000 {
430            Ok(Self { value })
431        } else {
432            Err(ValidationError::Invariant("basis points must be <= 10000"))
433        }
434    }
435
436    #[must_use]
437    pub const fn value(self) -> Nat {
438        self.value
439    }
440}
441
442impl crate::FieldAccess for BasisPoints {
443    type Output<'a> = Self;
444
445    fn access(&self) -> Self::Output<'_> {
446        *self
447    }
448}
449
450pub fn apply_bps(bp: BasisPoints, amount: Money) -> DomainResult<Money> {
451    checked_div(
452        checked_mul(amount, bp.value, "apply_bps multiplication")?,
453        10_000,
454        "apply_bps division",
455    )
456}
457
458pub fn round_bps_amount(mode: RoundingMode, amount: Money, bp: BasisPoints) -> DomainResult<Money> {
459    round_money(
460        mode,
461        checked_mul(amount, bp.value, "round_bps_amount multiplication")?,
462        10_000,
463    )
464}
465
466#[must_use]
467pub const fn profit_amount(revenue: Money, total_costs: Money) -> Money {
468    nat_sub(revenue, total_costs)
469}
470
471pub fn profit_loss_amount(revenue: Money, total_costs: Money) -> DomainResult<SignedMoney> {
472    let revenue =
473        SignedMoney::try_from(revenue).map_err(|_| ValidationError::Overflow("profit revenue"))?;
474    let total_costs = SignedMoney::try_from(total_costs)
475        .map_err(|_| ValidationError::Overflow("profit costs"))?;
476    revenue
477        .checked_sub(total_costs)
478        .ok_or(ValidationError::Overflow("profit subtraction"))
479}