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#[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}