Skip to main content

commerce_theory/
b2b.rs

1use crate::foundation::*;
2use crate::marketing::*;
3
4#[derive(Clone, Copy, Debug, PartialEq, Eq)]
5#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
6pub enum TradeMode {
7    Retail,
8    Wholesale,
9}
10
11#[derive(Clone, Copy, Debug, PartialEq, Eq)]
12#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
13pub enum CustomerKind {
14    Guest,
15    Registered,
16    WholesaleAccount,
17}
18
19domain_struct! {
20    pub struct Customer {
21        id: CustomerId,
22        kind: CustomerKind,
23        wholesale_approved: bool,
24    }
25}
26
27#[must_use]
28pub fn customer_can_buy_wholesale(customer: &Customer) -> bool {
29    customer.kind == CustomerKind::WholesaleAccount && customer.wholesale_approved
30}
31
32#[derive(Clone, Copy, Debug, PartialEq, Eq)]
33#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
34pub enum PaymentTerms {
35    Prepaid,
36    NetDays(Nat),
37}
38
39#[must_use]
40pub const fn payment_terms_allowed(mode: TradeMode, terms: PaymentTerms) -> bool {
41    !matches!((mode, terms), (TradeMode::Retail, PaymentTerms::NetDays(_)))
42}
43
44#[derive(Clone, Debug, PartialEq, Eq)]
45#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
46pub struct TradePriceBookEntry {
47    pub(crate) sku: Sku,
48    pub(crate) currency: Currency,
49    pub(crate) unit_cost: Money,
50    pub(crate) retail_unit_price: Money,
51    pub(crate) wholesale_unit_price: Money,
52    pub(crate) retail_margin: Money,
53    pub(crate) wholesale_margin: Money,
54    pub(crate) wholesale_min_qty: Quantity,
55}
56
57impl TradePriceBookEntry {
58    #[allow(clippy::too_many_arguments)]
59    pub fn try_new(
60        sku: Sku,
61        currency: Currency,
62        unit_cost: Money,
63        retail_unit_price: Money,
64        wholesale_unit_price: Money,
65        retail_margin: Money,
66        wholesale_margin: Money,
67        wholesale_min_qty: Quantity,
68    ) -> DomainResult<Self> {
69        if checked_add(unit_cost, retail_margin, "retail margin")? > retail_unit_price {
70            return Err(ValidationError::Invariant("retail margin is unsafe"));
71        }
72        if checked_add(unit_cost, wholesale_margin, "wholesale margin")? > wholesale_unit_price {
73            return Err(ValidationError::Invariant("wholesale margin is unsafe"));
74        }
75        if wholesale_unit_price > retail_unit_price {
76            return Err(ValidationError::Invariant(
77                "wholesale price exceeds retail price",
78            ));
79        }
80        if wholesale_min_qty == 0 {
81            return Err(ValidationError::Invariant(
82                "wholesale minimum quantity must be positive",
83            ));
84        }
85        Ok(Self {
86            sku,
87            currency,
88            unit_cost,
89            retail_unit_price,
90            wholesale_unit_price,
91            retail_margin,
92            wholesale_margin,
93            wholesale_min_qty,
94        })
95    }
96}
97
98#[must_use]
99pub const fn unit_price_for_trade_mode(mode: TradeMode, entry: &TradePriceBookEntry) -> Money {
100    match mode {
101        TradeMode::Retail => entry.retail_unit_price,
102        TradeMode::Wholesale => entry.wholesale_unit_price,
103    }
104}
105
106#[derive(Clone, Debug, PartialEq, Eq)]
107#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
108pub struct RetailLine {
109    pub(crate) entry: TradePriceBookEntry,
110    pub(crate) quantity: Quantity,
111    pub(crate) discount: Money,
112}
113
114impl RetailLine {
115    pub fn try_new(
116        entry: TradePriceBookEntry,
117        quantity: Quantity,
118        discount: Money,
119    ) -> DomainResult<Self> {
120        if discount > checked_mul(entry.retail_unit_price, quantity, "retail line gross")? {
121            return Err(ValidationError::Invariant("retail discount exceeds gross"));
122        }
123        Ok(Self {
124            entry,
125            quantity,
126            discount,
127        })
128    }
129}
130
131pub fn retail_line_gross_total(line: &RetailLine) -> DomainResult<Money> {
132    checked_mul(
133        line.entry.retail_unit_price,
134        line.quantity,
135        "retail_line_gross_total",
136    )
137}
138
139pub fn retail_line_net_total(line: &RetailLine) -> DomainResult<Money> {
140    Ok(nat_sub(retail_line_gross_total(line)?, line.discount))
141}
142
143#[derive(Clone, Debug, PartialEq, Eq)]
144#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
145pub struct WholesaleLine {
146    pub(crate) entry: TradePriceBookEntry,
147    pub(crate) quantity: Quantity,
148    pub(crate) discount: Money,
149}
150
151impl WholesaleLine {
152    pub fn try_new(
153        entry: TradePriceBookEntry,
154        quantity: Quantity,
155        discount: Money,
156    ) -> DomainResult<Self> {
157        if quantity < entry.wholesale_min_qty {
158            return Err(ValidationError::Invariant(
159                "wholesale quantity below minimum",
160            ));
161        }
162        if discount > checked_mul(entry.wholesale_unit_price, quantity, "wholesale line gross")? {
163            return Err(ValidationError::Invariant(
164                "wholesale discount exceeds gross",
165            ));
166        }
167        Ok(Self {
168            entry,
169            quantity,
170            discount,
171        })
172    }
173}
174
175pub fn wholesale_line_gross_total(line: &WholesaleLine) -> DomainResult<Money> {
176    checked_mul(
177        line.entry.wholesale_unit_price,
178        line.quantity,
179        "wholesale_line_gross_total",
180    )
181}
182
183pub fn wholesale_line_retail_equivalent_total(line: &WholesaleLine) -> DomainResult<Money> {
184    checked_mul(
185        line.entry.retail_unit_price,
186        line.quantity,
187        "wholesale_line_retail_equivalent_total",
188    )
189}
190
191pub fn wholesale_line_net_total(line: &WholesaleLine) -> DomainResult<Money> {
192    Ok(nat_sub(wholesale_line_gross_total(line)?, line.discount))
193}
194
195pub fn wholesale_order_net_total(lines: &[WholesaleLine]) -> DomainResult<Money> {
196    checked_result_sum(
197        lines.iter().map(wholesale_line_net_total),
198        "wholesale_order_net_total",
199    )
200}
201
202pub fn wholesale_retail_equivalent_total(lines: &[WholesaleLine]) -> DomainResult<Money> {
203    checked_result_sum(
204        lines.iter().map(wholesale_line_retail_equivalent_total),
205        "wholesale_retail_equivalent_total",
206    )
207}
208
209#[derive(Clone, Debug, PartialEq, Eq)]
210#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
211pub struct WholesaleCreditAccount {
212    pub(crate) customer: Customer,
213    pub(crate) credit_limit: Money,
214    pub(crate) outstanding: Money,
215}
216
217impl WholesaleCreditAccount {
218    pub fn try_new(
219        customer: Customer,
220        credit_limit: Money,
221        outstanding: Money,
222    ) -> DomainResult<Self> {
223        if !customer_can_buy_wholesale(&customer) {
224            return Err(ValidationError::Invariant("customer cannot buy wholesale"));
225        }
226        if outstanding > credit_limit {
227            return Err(ValidationError::Invariant(
228                "outstanding exceeds credit limit",
229            ));
230        }
231        Ok(Self {
232            customer,
233            credit_limit,
234            outstanding,
235        })
236    }
237}
238
239#[must_use]
240pub fn can_place_wholesale_credit_order(
241    account: &WholesaleCreditAccount,
242    order_total: Money,
243) -> bool {
244    account
245        .outstanding
246        .checked_add(order_total)
247        .is_some_and(|total| total <= account.credit_limit)
248}
249
250pub(crate) const fn _marketing_anchor(_: Option<ConsentStatus>) {}
251
252impl_getters!(TradePriceBookEntry {
253    sku: Sku,
254    currency: Currency,
255    unit_cost: Money,
256    retail_unit_price: Money,
257    wholesale_unit_price: Money,
258    retail_margin: Money,
259    wholesale_margin: Money,
260    wholesale_min_qty: Quantity,
261});
262
263impl_getters!(RetailLine {
264    entry: TradePriceBookEntry,
265    quantity: Quantity,
266    discount: Money,
267});
268
269impl_getters!(WholesaleLine {
270    entry: TradePriceBookEntry,
271    quantity: Quantity,
272    discount: Money,
273});
274
275impl_getters!(WholesaleCreditAccount {
276    customer: Customer,
277    credit_limit: Money,
278    outstanding: Money,
279});