Skip to main content

commerce_theory/
competitor_pricing.rs

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