commerce-theory 0.1.2

Runtime Rust mirror of the CommerceTheory Lean package
Documentation
use crate::dropship_profit::*;
use crate::dropshipping::*;
use crate::foundation::*;

domain_struct! {
    pub struct CompetitorOffer {
        competitor_id: CompetitorId,
        sku: Sku,
        price: Money,
        currency: Currency,
        active: bool,
        in_stock: bool,
        observed_at: Timestamp,
    }
}

#[must_use]
pub fn competitor_offer_relevant(offer: &CompetitorOffer, sku: Sku, currency: Currency) -> bool {
    offer.sku == sku && offer.currency == currency && offer.active && offer.in_stock
}

#[must_use]
pub fn price_snapshot_fresh(now: Timestamp, max_age: Duration, observed_at: Timestamp) -> bool {
    observed_at <= now && timestamp_age(now, observed_at) <= max_age
}

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum TrustLevel {
    Low,
    Medium,
    High,
}

#[must_use]
pub const fn trust_allows_auto_repricing(trust: TrustLevel) -> bool {
    matches!(trust, TrustLevel::Medium | TrustLevel::High)
}

#[derive(Clone, Debug, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct CompetitorPriceBenchmark {
    pub(crate) sku: Sku,
    pub(crate) currency: Currency,
    pub(crate) offers: Vec<CompetitorOffer>,
    pub(crate) best_offer: CompetitorOffer,
}

impl CompetitorPriceBenchmark {
    pub fn try_new(
        sku: Sku,
        currency: Currency,
        offers: Vec<CompetitorOffer>,
        best_offer: CompetitorOffer,
    ) -> DomainResult<Self> {
        if !offers.contains(&best_offer) {
            return Err(ValidationError::Invariant("best offer must be in offers"));
        }
        if !competitor_offer_relevant(&best_offer, sku, currency) {
            return Err(ValidationError::Invariant("best offer must be relevant"));
        }
        if offers
            .iter()
            .filter(|offer| competitor_offer_relevant(offer, sku, currency))
            .any(|offer| best_offer.price > offer.price)
        {
            return Err(ValidationError::Invariant(
                "best offer must be the lowest relevant offer",
            ));
        }
        Ok(Self {
            sku,
            currency,
            offers,
            best_offer,
        })
    }

    #[must_use]
    pub const fn best_offer(&self) -> &CompetitorOffer {
        &self.best_offer
    }
}

#[must_use]
pub const fn customer_net_at_offer_price(price: Money, discount: Money) -> Money {
    nat_sub(price, discount)
}

pub fn profit_at_offer_price(
    price: Money,
    discount: Money,
    costs: &DropshipProfitCosts,
) -> DomainResult<Money> {
    Ok(profit_amount(
        customer_net_at_offer_price(price, discount),
        dropship_profit_costs_total(costs)?,
    ))
}

pub fn profitable_price_floor(
    costs: &DropshipProfitCosts,
    min_profit: Money,
    discount: Money,
) -> DomainResult<Money> {
    checked_add(
        checked_add(
            dropship_profit_costs_total(costs)?,
            min_profit,
            "profitable_price_floor profit",
        )?,
        discount,
        "profitable_price_floor discount",
    )
}

pub fn price_profitable_for_min_profit(
    price: Money,
    discount: Money,
    costs: &DropshipProfitCosts,
    min_profit: Money,
) -> DomainResult<bool> {
    Ok(profitable_price_floor(costs, min_profit, discount)? <= price)
}

#[must_use]
pub const fn price_at_or_below_competitor(own_price: Money, competitor_price: Money) -> bool {
    own_price <= competitor_price
}

#[must_use]
pub const fn undercut_price(competitor_price: Money, delta: Money) -> Money {
    nat_sub(competitor_price, delta)
}

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum CompetitivePricingStrategy {
    Match,
    Undercut(Money),
    Premium(Money),
}

pub fn target_price_from_strategy(
    strategy: CompetitivePricingStrategy,
    reference_price: Money,
) -> DomainResult<Money> {
    match strategy {
        CompetitivePricingStrategy::Match => Ok(reference_price),
        CompetitivePricingStrategy::Undercut(delta) => Ok(undercut_price(reference_price, delta)),
        CompetitivePricingStrategy::Premium(premium) => {
            checked_add(reference_price, premium, "target_price_from_strategy")
        }
    }
}

#[derive(Clone, Debug, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct CompetitorAwareDropshipOffer {
    pub(crate) offer: DropshipOffer,
    pub(crate) benchmark: CompetitorPriceBenchmark,
    pub(crate) discount: Money,
    pub(crate) costs: DropshipProfitCosts,
    pub(crate) min_profit: Money,
}

impl CompetitorAwareDropshipOffer {
    pub fn try_new(
        offer: DropshipOffer,
        benchmark: CompetitorPriceBenchmark,
        discount: Money,
        costs: DropshipProfitCosts,
        min_profit: Money,
    ) -> DomainResult<Self> {
        if benchmark.sku != offer.sku() {
            return Err(ValidationError::Invariant("benchmark SKU mismatch"));
        }
        if benchmark.currency != offer.currency() {
            return Err(ValidationError::Invariant("benchmark currency mismatch"));
        }
        if !price_profitable_for_min_profit(offer.sale_unit_price(), discount, &costs, min_profit)?
        {
            return Err(ValidationError::Invariant("offer price below profit floor"));
        }
        if offer.sale_unit_price() > benchmark.best_offer.price {
            return Err(ValidationError::Invariant(
                "offer price exceeds best competitor price",
            ));
        }
        Ok(Self {
            offer,
            benchmark,
            discount,
            costs,
            min_profit,
        })
    }
}

impl_getters!(CompetitorPriceBenchmark {
    sku: Sku,
    currency: Currency,
    offers: Vec<CompetitorOffer>,
});

impl_getters!(CompetitorAwareDropshipOffer {
    offer: DropshipOffer,
    benchmark: CompetitorPriceBenchmark,
    discount: Money,
    costs: DropshipProfitCosts,
    min_profit: Money,
});