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