1use crate::dropship_profit::*;
2use crate::dropshipping::*;
3use crate::foundation::*;
4
5domain_struct! {
6 pub struct CompetitorOffer {
7 competitor_id: CompetitorId,
8 sku: Sku,
9 price: Money,
10 currency: Currency,
11 active: bool,
12 in_stock: bool,
13 observed_at: Timestamp,
14 }
15}
16
17#[must_use]
18pub fn competitor_offer_relevant(offer: &CompetitorOffer, sku: Sku, currency: Currency) -> bool {
19 offer.sku == sku && offer.currency == currency && offer.active && offer.in_stock
20}
21
22#[must_use]
23pub fn price_snapshot_fresh(now: Timestamp, max_age: Duration, observed_at: Timestamp) -> bool {
24 observed_at <= now && timestamp_age(now, observed_at) <= max_age
25}
26
27#[derive(Clone, Copy, Debug, PartialEq, Eq)]
28#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
29pub enum TrustLevel {
30 Low,
31 Medium,
32 High,
33}
34
35#[must_use]
36pub const fn trust_allows_auto_repricing(trust: TrustLevel) -> bool {
37 matches!(trust, TrustLevel::Medium | TrustLevel::High)
38}
39
40#[derive(Clone, Debug, PartialEq, Eq)]
41#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
42pub struct CompetitorPriceBenchmark {
43 pub(crate) sku: Sku,
44 pub(crate) currency: Currency,
45 pub(crate) offers: Vec<CompetitorOffer>,
46 pub(crate) best_offer: CompetitorOffer,
47}
48
49impl CompetitorPriceBenchmark {
50 pub fn try_new(
51 sku: Sku,
52 currency: Currency,
53 offers: Vec<CompetitorOffer>,
54 best_offer: CompetitorOffer,
55 ) -> DomainResult<Self> {
56 if !offers.contains(&best_offer) {
57 return Err(ValidationError::Invariant("best offer must be in offers"));
58 }
59 if !competitor_offer_relevant(&best_offer, sku, currency) {
60 return Err(ValidationError::Invariant("best offer must be relevant"));
61 }
62 if offers
63 .iter()
64 .filter(|offer| competitor_offer_relevant(offer, sku, currency))
65 .any(|offer| best_offer.price > offer.price)
66 {
67 return Err(ValidationError::Invariant(
68 "best offer must be the lowest relevant offer",
69 ));
70 }
71 Ok(Self {
72 sku,
73 currency,
74 offers,
75 best_offer,
76 })
77 }
78
79 #[must_use]
80 pub const fn best_offer(&self) -> &CompetitorOffer {
81 &self.best_offer
82 }
83}
84
85#[must_use]
86pub const fn customer_net_at_offer_price(price: Money, discount: Money) -> Money {
87 nat_sub(price, discount)
88}
89
90pub fn profit_at_offer_price(
91 price: Money,
92 discount: Money,
93 costs: &DropshipProfitCosts,
94) -> DomainResult<Money> {
95 Ok(profit_amount(
96 customer_net_at_offer_price(price, discount),
97 dropship_profit_costs_total(costs)?,
98 ))
99}
100
101pub fn profitable_price_floor(
102 costs: &DropshipProfitCosts,
103 min_profit: Money,
104 discount: Money,
105) -> DomainResult<Money> {
106 checked_add(
107 checked_add(
108 dropship_profit_costs_total(costs)?,
109 min_profit,
110 "profitable_price_floor profit",
111 )?,
112 discount,
113 "profitable_price_floor discount",
114 )
115}
116
117pub fn price_profitable_for_min_profit(
118 price: Money,
119 discount: Money,
120 costs: &DropshipProfitCosts,
121 min_profit: Money,
122) -> DomainResult<bool> {
123 Ok(profitable_price_floor(costs, min_profit, discount)? <= price)
124}
125
126#[must_use]
127pub const fn price_at_or_below_competitor(own_price: Money, competitor_price: Money) -> bool {
128 own_price <= competitor_price
129}
130
131#[must_use]
132pub const fn undercut_price(competitor_price: Money, delta: Money) -> Money {
133 nat_sub(competitor_price, delta)
134}
135
136#[derive(Clone, Copy, Debug, PartialEq, Eq)]
137#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
138pub enum CompetitivePricingStrategy {
139 Match,
140 Undercut(Money),
141 Premium(Money),
142}
143
144pub fn target_price_from_strategy(
145 strategy: CompetitivePricingStrategy,
146 reference_price: Money,
147) -> DomainResult<Money> {
148 match strategy {
149 CompetitivePricingStrategy::Match => Ok(reference_price),
150 CompetitivePricingStrategy::Undercut(delta) => Ok(undercut_price(reference_price, delta)),
151 CompetitivePricingStrategy::Premium(premium) => {
152 checked_add(reference_price, premium, "target_price_from_strategy")
153 }
154 }
155}
156
157#[derive(Clone, Debug, PartialEq, Eq)]
158#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
159pub struct CompetitorAwareDropshipOffer {
160 pub(crate) offer: DropshipOffer,
161 pub(crate) benchmark: CompetitorPriceBenchmark,
162 pub(crate) discount: Money,
163 pub(crate) costs: DropshipProfitCosts,
164 pub(crate) min_profit: Money,
165}
166
167impl CompetitorAwareDropshipOffer {
168 pub fn try_new(
169 offer: DropshipOffer,
170 benchmark: CompetitorPriceBenchmark,
171 discount: Money,
172 costs: DropshipProfitCosts,
173 min_profit: Money,
174 ) -> DomainResult<Self> {
175 if benchmark.sku != offer.sku() {
176 return Err(ValidationError::Invariant("benchmark SKU mismatch"));
177 }
178 if benchmark.currency != offer.currency() {
179 return Err(ValidationError::Invariant("benchmark currency mismatch"));
180 }
181 if !price_profitable_for_min_profit(offer.sale_unit_price(), discount, &costs, min_profit)?
182 {
183 return Err(ValidationError::Invariant("offer price below profit floor"));
184 }
185 if offer.sale_unit_price() > benchmark.best_offer.price {
186 return Err(ValidationError::Invariant(
187 "offer price exceeds best competitor price",
188 ));
189 }
190 Ok(Self {
191 offer,
192 benchmark,
193 discount,
194 costs,
195 min_profit,
196 })
197 }
198}
199
200impl_getters!(CompetitorPriceBenchmark {
201 sku: Sku,
202 currency: Currency,
203 offers: Vec<CompetitorOffer>,
204});
205
206impl_getters!(CompetitorAwareDropshipOffer {
207 offer: DropshipOffer,
208 benchmark: CompetitorPriceBenchmark,
209 discount: Money,
210 costs: DropshipProfitCosts,
211 min_profit: Money,
212});