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