Skip to main content

commerce_theory/
marketing.rs

1use crate::foundation::*;
2use crate::marketplace::*;
3use crate::orders::*;
4
5#[derive(Clone, Copy, Debug, PartialEq, Eq)]
6#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
7pub enum AdPlatform {
8    GoogleLike,
9    MetaLike,
10    TikTokLike,
11    MarketplaceAds,
12    EmailProvider,
13    SmsProvider,
14    AffiliateNetwork,
15    Custom,
16}
17
18#[derive(Clone, Copy, Debug, PartialEq, Eq)]
19#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
20pub enum AdType {
21    Search,
22    Shopping,
23    Display,
24    Social,
25    Video,
26    Retargeting,
27    EmailMarketing,
28    SmsMarketing,
29    MarketplaceSponsoredProducts,
30    Affiliate,
31}
32
33#[derive(Clone, Copy, Debug, PartialEq, Eq)]
34#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
35pub enum CampaignStatus {
36    Draft,
37    Active,
38    Paused,
39    Archived,
40}
41
42#[derive(Clone, Copy, Debug, PartialEq, Eq)]
43#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
44pub enum AdDestination {
45    Website,
46    MarketplaceStore(Marketplace),
47    MarketplaceListing(Marketplace, Nat),
48}
49
50#[must_use]
51pub fn destination_matches_marketplace(
52    destination: AdDestination,
53    marketplace: Marketplace,
54) -> bool {
55    match destination {
56        AdDestination::Website => false,
57        AdDestination::MarketplaceStore(m) | AdDestination::MarketplaceListing(m, _) => {
58            m == marketplace
59        }
60    }
61}
62
63#[derive(Clone, Debug, PartialEq, Eq)]
64#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
65pub struct MarketingCampaign {
66    pub(crate) id: CampaignId,
67    pub(crate) platform: AdPlatform,
68    pub(crate) ad_type: AdType,
69    pub(crate) destination: AdDestination,
70    pub(crate) status: CampaignStatus,
71    pub(crate) budget: Money,
72    pub(crate) spend: Money,
73    pub(crate) impressions: Nat,
74    pub(crate) clicks: Nat,
75    pub(crate) conversions: Nat,
76    pub(crate) attributed_revenue: Money,
77}
78
79impl MarketingCampaign {
80    #[allow(clippy::too_many_arguments)]
81    pub const fn try_new(
82        id: CampaignId,
83        platform: AdPlatform,
84        ad_type: AdType,
85        destination: AdDestination,
86        status: CampaignStatus,
87        budget: Money,
88        spend: Money,
89        impressions: Nat,
90        clicks: Nat,
91        conversions: Nat,
92        attributed_revenue: Money,
93    ) -> DomainResult<Self> {
94        if spend > budget {
95            return Err(ValidationError::Invariant("campaign spend exceeds budget"));
96        }
97        if clicks > impressions {
98            return Err(ValidationError::Invariant("clicks exceed impressions"));
99        }
100        Ok(Self {
101            id,
102            platform,
103            ad_type,
104            destination,
105            status,
106            budget,
107            spend,
108            impressions,
109            clicks,
110            conversions,
111            attributed_revenue,
112        })
113    }
114}
115
116pub fn campaigns_spend_total(campaigns: &[MarketingCampaign]) -> DomainResult<Money> {
117    checked_sum(campaigns.iter().map(|c| c.spend), "campaigns_spend_total")
118}
119
120pub fn campaigns_budget_total(campaigns: &[MarketingCampaign]) -> DomainResult<Money> {
121    checked_sum(campaigns.iter().map(|c| c.budget), "campaigns_budget_total")
122}
123
124#[derive(Clone, Debug, PartialEq, Eq)]
125#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
126pub struct ClickAttributedCampaign {
127    pub(crate) campaign: MarketingCampaign,
128}
129
130impl ClickAttributedCampaign {
131    pub const fn try_new(campaign: MarketingCampaign) -> DomainResult<Self> {
132        if campaign.conversions > campaign.clicks {
133            return Err(ValidationError::Invariant("conversions exceed clicks"));
134        }
135        Ok(Self { campaign })
136    }
137}
138
139pub fn meets_roas_target(campaign: &MarketingCampaign, num: Nat, den: Nat) -> DomainResult<bool> {
140    Ok(
141        checked_mul(campaign.attributed_revenue, den, "ROAS revenue")?
142            >= checked_mul(campaign.spend, num, "ROAS spend")?,
143    )
144}
145
146pub fn meets_roi_target(profit: Money, ad_spend: Money, num: Nat, den: Nat) -> DomainResult<bool> {
147    Ok(checked_mul(profit, den, "ROI profit")? >= checked_mul(ad_spend, num, "ROI spend")?)
148}
149
150#[derive(Clone, Debug, PartialEq, Eq)]
151#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
152pub struct Funnel {
153    pub(crate) visitors: Nat,
154    pub(crate) add_to_cart: Nat,
155    pub(crate) checkout_started: Nat,
156    pub(crate) purchases: Nat,
157}
158
159impl Funnel {
160    pub const fn try_new(
161        visitors: Nat,
162        add_to_cart: Nat,
163        checkout_started: Nat,
164        purchases: Nat,
165    ) -> DomainResult<Self> {
166        if add_to_cart > visitors || checkout_started > add_to_cart || purchases > checkout_started
167        {
168            return Err(ValidationError::Invariant("funnel counts are not monotone"));
169        }
170        Ok(Self {
171            visitors,
172            add_to_cart,
173            checkout_started,
174            purchases,
175        })
176    }
177}
178
179#[derive(Clone, Copy, Debug, PartialEq, Eq)]
180#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
181pub enum ConsentStatus {
182    Granted,
183    Denied,
184    Unknown,
185}
186
187#[must_use]
188pub fn can_retarget(consent: ConsentStatus) -> bool {
189    consent == ConsentStatus::Granted
190}
191
192#[derive(Clone, Copy, Debug, PartialEq, Eq)]
193#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
194pub enum SubscriptionStatus {
195    Subscribed,
196    Unsubscribed,
197}
198
199#[must_use]
200pub fn can_send_marketing_message(status: SubscriptionStatus) -> bool {
201    status == SubscriptionStatus::Subscribed
202}
203
204domain_struct! {
205    pub struct AttributionCredit {
206        campaign_id: CampaignId,
207        order_id: OrderId,
208        amount: Money,
209    }
210}
211
212pub fn attribution_credit_total(credits: &[AttributionCredit]) -> DomainResult<Money> {
213    checked_sum(
214        credits.iter().map(|credit| credit.amount),
215        "attribution_credit_total",
216    )
217}
218
219#[must_use]
220pub fn attribution_credits_match_order(order: &Order, credits: &[AttributionCredit]) -> bool {
221    credits.iter().all(|credit| credit.order_id == order.id())
222}
223
224#[derive(Clone, Debug, PartialEq, Eq)]
225#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
226pub struct OrderAttributionLedger {
227    pub(crate) order: Order,
228    pub(crate) credits: Vec<AttributionCredit>,
229}
230
231impl OrderAttributionLedger {
232    pub fn try_new(order: Order, credits: Vec<AttributionCredit>) -> DomainResult<Self> {
233        if attribution_credit_total(&credits)? > order.total() {
234            return Err(ValidationError::Invariant(
235                "attribution credits exceed order total",
236            ));
237        }
238        Ok(Self { order, credits })
239    }
240}
241
242#[derive(Clone, Debug, PartialEq, Eq)]
243#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
244pub struct MatchedOrderAttributionLedger {
245    pub(crate) ledger: OrderAttributionLedger,
246}
247
248impl MatchedOrderAttributionLedger {
249    pub fn try_new(ledger: OrderAttributionLedger) -> DomainResult<Self> {
250        if !attribution_credits_match_order(&ledger.order, &ledger.credits) {
251            return Err(ValidationError::Invariant("credit order ids must match"));
252        }
253        Ok(Self { ledger })
254    }
255}
256
257#[derive(Clone, Debug, PartialEq, Eq)]
258#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
259pub struct ExperimentVariant {
260    pub(crate) id: Id,
261    pub(crate) traffic_weight: Nat,
262    pub(crate) visitors: Nat,
263    pub(crate) conversions: Nat,
264}
265
266impl ExperimentVariant {
267    pub const fn try_new(
268        id: Id,
269        traffic_weight: Nat,
270        visitors: Nat,
271        conversions: Nat,
272    ) -> DomainResult<Self> {
273        if conversions > visitors {
274            return Err(ValidationError::Invariant(
275                "experiment conversions exceed visitors",
276            ));
277        }
278        Ok(Self {
279            id,
280            traffic_weight,
281            visitors,
282            conversions,
283        })
284    }
285}
286
287pub fn experiment_traffic_total(variants: &[ExperimentVariant]) -> DomainResult<Nat> {
288    checked_sum(
289        variants.iter().map(|v| v.traffic_weight),
290        "experiment_traffic_total",
291    )
292}
293
294#[derive(Clone, Debug, PartialEq, Eq)]
295#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
296pub struct Experiment {
297    pub(crate) id: Id,
298    pub(crate) variants: Vec<ExperimentVariant>,
299}
300
301impl Experiment {
302    pub fn try_new(id: Id, variants: Vec<ExperimentVariant>) -> DomainResult<Self> {
303        if experiment_traffic_total(&variants)? != 100 {
304            return Err(ValidationError::Invariant(
305                "experiment traffic must total 100",
306            ));
307        }
308        Ok(Self { id, variants })
309    }
310}
311
312impl_getters!(MarketingCampaign {
313    id: CampaignId,
314    platform: AdPlatform,
315    ad_type: AdType,
316    destination: AdDestination,
317    status: CampaignStatus,
318    budget: Money,
319    spend: Money,
320    impressions: Nat,
321    clicks: Nat,
322    conversions: Nat,
323    attributed_revenue: Money,
324});
325
326impl_getters!(ClickAttributedCampaign {
327    campaign: MarketingCampaign,
328});
329
330impl_getters!(Funnel {
331    visitors: Nat,
332    add_to_cart: Nat,
333    checkout_started: Nat,
334    purchases: Nat,
335});
336
337impl_getters!(OrderAttributionLedger {
338    order: Order,
339    credits: Vec<AttributionCredit>,
340});
341
342impl_getters!(MatchedOrderAttributionLedger {
343    ledger: OrderAttributionLedger,
344});
345
346impl_getters!(ExperimentVariant {
347    id: Id,
348    traffic_weight: Nat,
349    visitors: Nat,
350    conversions: Nat,
351});
352
353impl_getters!(Experiment {
354    id: Id,
355    variants: Vec<ExperimentVariant>,
356});