Skip to main content

commerce_theory/
crm.rs

1use crate::b2b::*;
2use crate::foundation::*;
3use crate::marketing::*;
4use crate::pricing::*;
5use crate::risk_privacy::*;
6
7#[derive(Clone, Copy, Debug, PartialEq, Eq)]
8#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
9pub enum AccountTier {
10    Standard,
11    Preferred,
12    Strategic,
13}
14
15#[derive(Clone, Copy, Debug, PartialEq, Eq)]
16#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
17pub enum CRMAccountStatus {
18    Prospect,
19    Active,
20    Paused,
21    Closed,
22}
23
24#[must_use]
25pub const fn can_crm_account_transition(
26    source: CRMAccountStatus,
27    target: CRMAccountStatus,
28) -> bool {
29    matches!(
30        (source, target),
31        (
32            CRMAccountStatus::Prospect | CRMAccountStatus::Paused,
33            CRMAccountStatus::Active
34        ) | (
35            CRMAccountStatus::Prospect | CRMAccountStatus::Active | CRMAccountStatus::Paused,
36            CRMAccountStatus::Closed
37        ) | (CRMAccountStatus::Active, CRMAccountStatus::Paused)
38    )
39}
40
41#[derive(Clone, Debug, PartialEq, Eq)]
42#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
43pub struct CRMAccount {
44    pub(crate) id: AccountId,
45    pub(crate) customer: Customer,
46    pub(crate) tier: AccountTier,
47    pub(crate) status: CRMAccountStatus,
48    pub(crate) lifetime_value: Money,
49    pub(crate) open_balance: Money,
50}
51
52impl CRMAccount {
53    pub const fn try_new(
54        id: AccountId,
55        customer: Customer,
56        tier: AccountTier,
57        status: CRMAccountStatus,
58        lifetime_value: Money,
59        open_balance: Money,
60    ) -> DomainResult<Self> {
61        if open_balance > lifetime_value {
62            return Err(ValidationError::CrmInvariantFailed);
63        }
64        Ok(Self {
65            id,
66            customer,
67            tier,
68            status,
69            lifetime_value,
70            open_balance,
71        })
72    }
73}
74
75#[must_use]
76pub fn crm_account_active(account: &CRMAccount) -> bool {
77    account.status == CRMAccountStatus::Active
78}
79
80#[derive(Clone, Debug, PartialEq, Eq)]
81#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
82pub struct ActiveCRMAccount {
83    pub(crate) account: CRMAccount,
84}
85
86impl ActiveCRMAccount {
87    pub fn try_new(account: CRMAccount) -> DomainResult<Self> {
88        if !crm_account_active(&account) {
89            return Err(ValidationError::CrmInvariantFailed);
90        }
91        Ok(Self { account })
92    }
93}
94
95pub const fn transition_crm_account(
96    account: CRMAccount,
97    next: CRMAccountStatus,
98) -> DomainResult<CRMAccount> {
99    if !can_crm_account_transition(account.status, next) {
100        return Err(ValidationError::CrmInvariantFailed);
101    }
102    Ok(CRMAccount {
103        status: next,
104        ..account
105    })
106}
107
108#[derive(Clone, Copy, Debug, PartialEq, Eq)]
109#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
110pub enum ContactKind {
111    Primary,
112    Billing,
113    Shipping,
114    Buyer,
115    Support,
116}
117
118domain_struct! {
119    pub struct CRMContact {
120        id: ContactId,
121        account_id: AccountId,
122        customer_id: CustomerId,
123        kind: ContactKind,
124        owner_role: Role,
125        subscription: SubscriptionStatus,
126        retargeting_consent: ConsentStatus,
127        data_permission: DataProcessingPermission,
128    }
129}
130
131#[must_use]
132pub fn contact_can_receive_marketing(contact: &CRMContact) -> bool {
133    can_send_marketing_message(contact.subscription)
134        && can_retarget(contact.retargeting_consent)
135        && data_processing_allowed(&contact.data_permission)
136        && contact.data_permission.purpose() == ConsentPurpose::Marketing
137        && contact.data_permission.basis() == ProcessingBasis::Consent
138}
139
140#[derive(Clone, Debug, PartialEq, Eq)]
141#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
142pub struct CRMAccountContact {
143    pub(crate) account: CRMAccount,
144    pub(crate) contact: CRMContact,
145}
146
147impl CRMAccountContact {
148    pub fn try_new(account: CRMAccount, contact: CRMContact) -> DomainResult<Self> {
149        if contact.account_id != account.id || contact.customer_id != account.customer.id {
150            return Err(ValidationError::CrmInvariantFailed);
151        }
152        Ok(Self { account, contact })
153    }
154}
155
156#[derive(Clone, Debug, PartialEq, Eq)]
157#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
158pub struct PermittedCustomerMessage {
159    pub(crate) interaction_id: InteractionId,
160    pub(crate) contact: CRMContact,
161    pub(crate) sent_at: Timestamp,
162}
163
164impl PermittedCustomerMessage {
165    pub fn try_new(
166        interaction_id: InteractionId,
167        contact: CRMContact,
168        sent_at: Timestamp,
169    ) -> DomainResult<Self> {
170        if !contact_can_receive_marketing(&contact) {
171            return Err(ValidationError::CrmInvariantFailed);
172        }
173        Ok(Self {
174            interaction_id,
175            contact,
176            sent_at,
177        })
178    }
179}
180
181#[derive(Clone, Debug, PartialEq, Eq)]
182#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
183pub struct PermittedAccountMessage {
184    pub(crate) account_contact: CRMAccountContact,
185    pub(crate) message: PermittedCustomerMessage,
186}
187
188impl PermittedAccountMessage {
189    pub fn try_new(
190        account_contact: CRMAccountContact,
191        message: PermittedCustomerMessage,
192    ) -> DomainResult<Self> {
193        if message.contact != account_contact.contact {
194            return Err(ValidationError::CrmInvariantFailed);
195        }
196        Ok(Self {
197            account_contact,
198            message,
199        })
200    }
201}
202
203#[derive(Clone, Copy, Debug, PartialEq, Eq)]
204#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
205pub enum InteractionKind {
206    Email,
207    Call,
208    Meeting,
209    Chat,
210    SupportNote,
211    OrderNote,
212}
213
214#[derive(Clone, Debug, PartialEq, Eq)]
215#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
216pub struct CRMInteraction {
217    pub(crate) id: InteractionId,
218    pub(crate) account_id: AccountId,
219    pub(crate) contact_id: ContactId,
220    pub(crate) kind: InteractionKind,
221    pub(crate) occurred_at: Timestamp,
222    pub(crate) follow_up_due_at: Timestamp,
223}
224
225impl CRMInteraction {
226    pub fn try_new(
227        id: InteractionId,
228        account_id: AccountId,
229        contact_id: ContactId,
230        kind: InteractionKind,
231        occurred_at: Timestamp,
232        follow_up_due_at: Timestamp,
233    ) -> DomainResult<Self> {
234        if follow_up_due_at < occurred_at {
235            return Err(ValidationError::CrmInvariantFailed);
236        }
237        Ok(Self {
238            id,
239            account_id,
240            contact_id,
241            kind,
242            occurred_at,
243            follow_up_due_at,
244        })
245    }
246}
247
248#[derive(Clone, Debug, PartialEq, Eq)]
249#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
250pub struct CRMInteractionForContact {
251    pub(crate) account_contact: CRMAccountContact,
252    pub(crate) interaction: CRMInteraction,
253}
254
255impl CRMInteractionForContact {
256    pub fn try_new(
257        account_contact: CRMAccountContact,
258        interaction: CRMInteraction,
259    ) -> DomainResult<Self> {
260        if interaction.account_id != account_contact.account.id
261            || interaction.contact_id != account_contact.contact.id
262        {
263            return Err(ValidationError::CrmInvariantFailed);
264        }
265        Ok(Self {
266            account_contact,
267            interaction,
268        })
269    }
270}
271
272#[derive(Clone, Copy, Debug, PartialEq, Eq)]
273#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
274pub enum LeadStatus {
275    New,
276    Working,
277    Qualified,
278    Disqualified,
279    Converted,
280}
281
282#[must_use]
283pub const fn can_lead_transition(source: LeadStatus, target: LeadStatus) -> bool {
284    matches!(
285        (source, target),
286        (
287            LeadStatus::New,
288            LeadStatus::Working | LeadStatus::Disqualified
289        ) | (
290            LeadStatus::Working,
291            LeadStatus::Qualified | LeadStatus::Disqualified
292        ) | (
293            LeadStatus::Qualified,
294            LeadStatus::Converted | LeadStatus::Disqualified
295        )
296    )
297}
298
299#[derive(Clone, Copy, Debug, PartialEq, Eq)]
300#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
301pub struct Lead {
302    pub(crate) id: LeadId,
303    pub(crate) account_id: AccountId,
304    pub(crate) contact_id: ContactId,
305    pub(crate) source_campaign: Option<CampaignId>,
306    pub(crate) status: LeadStatus,
307    pub(crate) estimated_value: Money,
308    pub(crate) currency: Currency,
309    pub(crate) created_at: Timestamp,
310    pub(crate) updated_at: Timestamp,
311}
312
313impl Lead {
314    #[allow(clippy::too_many_arguments)]
315    pub fn try_new(
316        id: LeadId,
317        account_id: AccountId,
318        contact_id: ContactId,
319        source_campaign: Option<CampaignId>,
320        status: LeadStatus,
321        estimated_value: Money,
322        currency: Currency,
323        created_at: Timestamp,
324        updated_at: Timestamp,
325    ) -> DomainResult<Self> {
326        if updated_at < created_at {
327            return Err(ValidationError::CrmInvariantFailed);
328        }
329        Ok(Self {
330            id,
331            account_id,
332            contact_id,
333            source_campaign,
334            status,
335            estimated_value,
336            currency,
337            created_at,
338            updated_at,
339        })
340    }
341}
342
343pub fn transition_lead(lead: Lead, next: LeadStatus, updated_at: Timestamp) -> DomainResult<Lead> {
344    if !can_lead_transition(lead.status, next) || updated_at < lead.created_at {
345        return Err(ValidationError::CrmInvariantFailed);
346    }
347    Ok(Lead {
348        status: next,
349        updated_at,
350        ..lead
351    })
352}
353
354#[derive(Clone, Debug, PartialEq, Eq)]
355#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
356pub struct LeadForContact {
357    pub(crate) account_contact: CRMAccountContact,
358    pub(crate) lead: Lead,
359}
360
361impl LeadForContact {
362    pub fn try_new(account_contact: CRMAccountContact, lead: Lead) -> DomainResult<Self> {
363        if lead.account_id != account_contact.account.id
364            || lead.contact_id != account_contact.contact.id
365        {
366            return Err(ValidationError::CrmInvariantFailed);
367        }
368        Ok(Self {
369            account_contact,
370            lead,
371        })
372    }
373}
374
375#[derive(Clone, Copy, Debug, PartialEq, Eq)]
376#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
377pub enum OpportunityStage {
378    Prospecting,
379    Qualified,
380    Proposal,
381    Negotiation,
382    Won,
383    Lost,
384}
385
386#[must_use]
387pub const fn can_opportunity_transition(
388    source: OpportunityStage,
389    target: OpportunityStage,
390) -> bool {
391    matches!(
392        (source, target),
393        (
394            OpportunityStage::Prospecting,
395            OpportunityStage::Qualified | OpportunityStage::Lost
396        ) | (
397            OpportunityStage::Qualified,
398            OpportunityStage::Proposal | OpportunityStage::Lost
399        ) | (
400            OpportunityStage::Proposal,
401            OpportunityStage::Negotiation | OpportunityStage::Lost
402        ) | (
403            OpportunityStage::Negotiation,
404            OpportunityStage::Won | OpportunityStage::Lost
405        )
406    )
407}
408
409#[must_use]
410pub const fn opportunity_stage_probability_allowed(
411    stage: OpportunityStage,
412    probability: BasisPoints,
413) -> bool {
414    match stage {
415        OpportunityStage::Won => probability.value() == 10_000,
416        OpportunityStage::Lost => probability.value() == 0,
417        _ => true,
418    }
419}
420
421#[derive(Clone, Copy, Debug, PartialEq, Eq)]
422#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
423pub struct SalesOpportunity {
424    pub(crate) id: OpportunityId,
425    pub(crate) account_id: AccountId,
426    pub(crate) contact_id: ContactId,
427    pub(crate) source_lead: Option<LeadId>,
428    pub(crate) stage: OpportunityStage,
429    pub(crate) amount: Money,
430    pub(crate) currency: Currency,
431    pub(crate) probability: BasisPoints,
432    pub(crate) opened_at: Timestamp,
433    pub(crate) updated_at: Timestamp,
434    pub(crate) expected_close_at: Timestamp,
435}
436
437impl SalesOpportunity {
438    #[allow(clippy::too_many_arguments)]
439    pub fn try_new(
440        id: OpportunityId,
441        account_id: AccountId,
442        contact_id: ContactId,
443        source_lead: Option<LeadId>,
444        stage: OpportunityStage,
445        amount: Money,
446        currency: Currency,
447        probability: BasisPoints,
448        opened_at: Timestamp,
449        updated_at: Timestamp,
450        expected_close_at: Timestamp,
451    ) -> DomainResult<Self> {
452        if updated_at < opened_at
453            || expected_close_at < opened_at
454            || !opportunity_stage_probability_allowed(stage, probability)
455        {
456            return Err(ValidationError::CrmInvariantFailed);
457        }
458        Ok(Self {
459            id,
460            account_id,
461            contact_id,
462            source_lead,
463            stage,
464            amount,
465            currency,
466            probability,
467            opened_at,
468            updated_at,
469            expected_close_at,
470        })
471    }
472}
473
474pub fn opportunity_weighted_value(opportunity: &SalesOpportunity) -> DomainResult<Money> {
475    apply_bps(opportunity.probability, opportunity.amount)
476}
477
478pub fn transition_opportunity(
479    opportunity: SalesOpportunity,
480    next: OpportunityStage,
481    probability: BasisPoints,
482    updated_at: Timestamp,
483    expected_close_at: Timestamp,
484) -> DomainResult<SalesOpportunity> {
485    if !can_opportunity_transition(opportunity.stage, next)
486        || !opportunity_stage_probability_allowed(next, probability)
487        || updated_at < opportunity.opened_at
488        || expected_close_at < opportunity.opened_at
489    {
490        return Err(ValidationError::CrmInvariantFailed);
491    }
492    Ok(SalesOpportunity {
493        stage: next,
494        probability,
495        updated_at,
496        expected_close_at,
497        ..opportunity
498    })
499}
500
501#[derive(Clone, Debug, PartialEq, Eq)]
502#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
503pub struct OpportunityForContact {
504    pub(crate) account_contact: CRMAccountContact,
505    pub(crate) opportunity: SalesOpportunity,
506}
507
508impl OpportunityForContact {
509    pub fn try_new(
510        account_contact: CRMAccountContact,
511        opportunity: SalesOpportunity,
512    ) -> DomainResult<Self> {
513        if opportunity.account_id != account_contact.account.id
514            || opportunity.contact_id != account_contact.contact.id
515        {
516            return Err(ValidationError::CrmInvariantFailed);
517        }
518        Ok(Self {
519            account_contact,
520            opportunity,
521        })
522    }
523}
524
525pub fn opportunity_gross_value(opportunities: &[SalesOpportunity]) -> DomainResult<Money> {
526    checked_sum(
527        opportunities.iter().map(|opportunity| opportunity.amount),
528        "opportunity_gross_value",
529    )
530}
531
532pub fn opportunity_weighted_value_total(opportunities: &[SalesOpportunity]) -> DomainResult<Money> {
533    checked_result_sum(
534        opportunities.iter().map(opportunity_weighted_value),
535        "opportunity_weighted_value_total",
536    )
537}
538
539#[must_use]
540pub fn opportunities_use_currency(currency: Currency, opportunities: &[SalesOpportunity]) -> bool {
541    opportunities
542        .iter()
543        .all(|opportunity| opportunity.currency == currency)
544}
545
546#[derive(Clone, Debug, PartialEq, Eq)]
547#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
548pub struct SalesPipeline {
549    pub(crate) currency: Currency,
550    pub(crate) opportunities: Vec<SalesOpportunity>,
551}
552
553impl SalesPipeline {
554    pub fn try_new(currency: Currency, opportunities: Vec<SalesOpportunity>) -> DomainResult<Self> {
555        if !opportunities_use_currency(currency, &opportunities) {
556            return Err(ValidationError::CrmInvariantFailed);
557        }
558        Ok(Self {
559            currency,
560            opportunities,
561        })
562    }
563}
564
565#[derive(Clone, Debug, PartialEq, Eq)]
566#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
567pub struct CustomerSegment {
568    pub(crate) id: SegmentId,
569    pub(crate) name: String,
570    pub(crate) member_count: Nat,
571    pub(crate) min_lifetime_value: Money,
572    pub(crate) max_retention_discount: Money,
573}
574
575impl CustomerSegment {
576    pub fn try_new(
577        id: SegmentId,
578        name: String,
579        member_count: Nat,
580        min_lifetime_value: Money,
581        max_retention_discount: Money,
582    ) -> DomainResult<Self> {
583        if max_retention_discount > min_lifetime_value {
584            return Err(ValidationError::CrmInvariantFailed);
585        }
586        Ok(Self {
587            id,
588            name,
589            member_count,
590            min_lifetime_value,
591            max_retention_discount,
592        })
593    }
594}
595
596#[derive(Clone, Debug, PartialEq, Eq)]
597#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
598pub struct SegmentMembership {
599    pub(crate) account: CRMAccount,
600    pub(crate) segment: CustomerSegment,
601}
602
603impl SegmentMembership {
604    pub fn try_new(account: CRMAccount, segment: CustomerSegment) -> DomainResult<Self> {
605        if account.lifetime_value < segment.min_lifetime_value {
606            return Err(ValidationError::CrmInvariantFailed);
607        }
608        Ok(Self { account, segment })
609    }
610}
611
612#[derive(Clone, Copy, Debug, PartialEq, Eq)]
613#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
614pub enum SupportPriority {
615    Low,
616    Normal,
617    High,
618    Urgent,
619}
620
621#[derive(Clone, Copy, Debug, PartialEq, Eq)]
622#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
623pub enum SupportCaseStatus {
624    Opened,
625    WaitingOnCustomer,
626    WaitingOnInternal,
627    Escalated,
628    Resolved,
629    Closed,
630}
631
632#[must_use]
633pub const fn can_support_case_transition(
634    source: SupportCaseStatus,
635    target: SupportCaseStatus,
636) -> bool {
637    matches!(
638        (source, target),
639        (
640            SupportCaseStatus::Opened,
641            SupportCaseStatus::WaitingOnCustomer
642                | SupportCaseStatus::WaitingOnInternal
643                | SupportCaseStatus::Escalated
644                | SupportCaseStatus::Resolved
645        ) | (
646            SupportCaseStatus::WaitingOnCustomer
647                | SupportCaseStatus::WaitingOnInternal
648                | SupportCaseStatus::Escalated,
649            SupportCaseStatus::Resolved
650        ) | (
651            SupportCaseStatus::WaitingOnCustomer | SupportCaseStatus::WaitingOnInternal,
652            SupportCaseStatus::Escalated
653        ) | (SupportCaseStatus::Resolved, SupportCaseStatus::Closed)
654    )
655}
656
657#[derive(Clone, Copy, Debug, PartialEq, Eq)]
658#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
659pub struct SupportCase {
660    pub(crate) id: SupportCaseId,
661    pub(crate) account_id: AccountId,
662    pub(crate) contact_id: ContactId,
663    pub(crate) order_id: Option<OrderId>,
664    pub(crate) status: SupportCaseStatus,
665    pub(crate) priority: SupportPriority,
666    pub(crate) opened_at: Timestamp,
667    pub(crate) last_updated_at: Timestamp,
668    pub(crate) sla_due_at: Timestamp,
669}
670
671impl SupportCase {
672    #[allow(clippy::too_many_arguments)]
673    pub fn try_new(
674        id: SupportCaseId,
675        account_id: AccountId,
676        contact_id: ContactId,
677        order_id: Option<OrderId>,
678        status: SupportCaseStatus,
679        priority: SupportPriority,
680        opened_at: Timestamp,
681        last_updated_at: Timestamp,
682        sla_due_at: Timestamp,
683    ) -> DomainResult<Self> {
684        if last_updated_at < opened_at || sla_due_at < opened_at {
685            return Err(ValidationError::CrmInvariantFailed);
686        }
687        Ok(Self {
688            id,
689            account_id,
690            contact_id,
691            order_id,
692            status,
693            priority,
694            opened_at,
695            last_updated_at,
696            sla_due_at,
697        })
698    }
699}
700
701pub fn transition_support_case(
702    case_: SupportCase,
703    next: SupportCaseStatus,
704    updated_at: Timestamp,
705) -> DomainResult<SupportCase> {
706    if !can_support_case_transition(case_.status, next) || updated_at < case_.opened_at {
707        return Err(ValidationError::CrmInvariantFailed);
708    }
709    Ok(SupportCase {
710        status: next,
711        last_updated_at: updated_at,
712        ..case_
713    })
714}
715
716#[derive(Clone, Debug, PartialEq, Eq)]
717#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
718pub struct SupportCaseForContact {
719    pub(crate) account_contact: CRMAccountContact,
720    pub(crate) case_: SupportCase,
721}
722
723impl SupportCaseForContact {
724    pub fn try_new(account_contact: CRMAccountContact, case_: SupportCase) -> DomainResult<Self> {
725        if case_.account_id != account_contact.account.id
726            || case_.contact_id != account_contact.contact.id
727        {
728            return Err(ValidationError::CrmInvariantFailed);
729        }
730        Ok(Self {
731            account_contact,
732            case_,
733        })
734    }
735}
736
737#[derive(Clone, Debug, PartialEq, Eq)]
738#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
739pub struct ResolvedSupportCase {
740    pub(crate) case_: SupportCase,
741    pub(crate) resolved_at: Timestamp,
742}
743
744impl ResolvedSupportCase {
745    pub fn try_new(case_: SupportCase, resolved_at: Timestamp) -> DomainResult<Self> {
746        if case_.status != SupportCaseStatus::Resolved
747            || resolved_at < case_.opened_at
748            || resolved_at < case_.last_updated_at
749            || resolved_at > case_.sla_due_at
750        {
751            return Err(ValidationError::CrmInvariantFailed);
752        }
753        Ok(Self { case_, resolved_at })
754    }
755}
756
757#[derive(Clone, Debug, PartialEq, Eq)]
758#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
759pub struct RetentionOffer {
760    pub(crate) account: CRMAccount,
761    pub(crate) segment: CustomerSegment,
762    pub(crate) coupon: Coupon,
763    pub(crate) uses_before: Nat,
764    pub(crate) discount: Money,
765}
766
767impl RetentionOffer {
768    pub fn try_new(
769        account: CRMAccount,
770        segment: CustomerSegment,
771        coupon: Coupon,
772        uses_before: Nat,
773        discount: Money,
774    ) -> DomainResult<Self> {
775        if !crm_account_active(&account)
776            || !coupon_can_be_applied(&coupon, account.lifetime_value, uses_before)
777            || account.lifetime_value < segment.min_lifetime_value
778            || discount > coupon.amount()
779            || coupon.amount() > account.lifetime_value
780            || discount > segment.max_retention_discount
781        {
782            return Err(ValidationError::CrmInvariantFailed);
783        }
784        Ok(Self {
785            account,
786            segment,
787            coupon,
788            uses_before,
789            discount,
790        })
791    }
792}
793
794impl_getters!(CRMAccount {
795    id: AccountId,
796    customer: Customer,
797    tier: AccountTier,
798    status: CRMAccountStatus,
799    lifetime_value: Money,
800    open_balance: Money,
801});
802
803impl_getters!(ActiveCRMAccount {
804    account: CRMAccount
805});
806
807impl_getters!(CRMAccountContact {
808    account: CRMAccount,
809    contact: CRMContact,
810});
811
812impl_getters!(PermittedCustomerMessage {
813    interaction_id: InteractionId,
814    contact: CRMContact,
815    sent_at: Timestamp,
816});
817
818impl_getters!(PermittedAccountMessage {
819    account_contact: CRMAccountContact,
820    message: PermittedCustomerMessage,
821});
822
823impl_getters!(CRMInteraction {
824    id: InteractionId,
825    account_id: AccountId,
826    contact_id: ContactId,
827    kind: InteractionKind,
828    occurred_at: Timestamp,
829    follow_up_due_at: Timestamp,
830});
831
832impl_getters!(CRMInteractionForContact {
833    account_contact: CRMAccountContact,
834    interaction: CRMInteraction,
835});
836
837impl_getters!(Lead {
838    id: LeadId,
839    account_id: AccountId,
840    contact_id: ContactId,
841    source_campaign: Option<CampaignId>,
842    status: LeadStatus,
843    estimated_value: Money,
844    currency: Currency,
845    created_at: Timestamp,
846    updated_at: Timestamp,
847});
848
849impl_getters!(LeadForContact {
850    account_contact: CRMAccountContact,
851    lead: Lead,
852});
853
854impl_getters!(SalesOpportunity {
855    id: OpportunityId,
856    account_id: AccountId,
857    contact_id: ContactId,
858    source_lead: Option<LeadId>,
859    stage: OpportunityStage,
860    amount: Money,
861    currency: Currency,
862    probability: BasisPoints,
863    opened_at: Timestamp,
864    updated_at: Timestamp,
865    expected_close_at: Timestamp,
866});
867
868impl_getters!(OpportunityForContact {
869    account_contact: CRMAccountContact,
870    opportunity: SalesOpportunity,
871});
872
873impl_getters!(SalesPipeline {
874    currency: Currency,
875    opportunities: Vec<SalesOpportunity>,
876});
877
878impl_getters!(CustomerSegment {
879    id: SegmentId,
880    name: String,
881    member_count: Nat,
882    min_lifetime_value: Money,
883    max_retention_discount: Money,
884});
885
886impl_getters!(SegmentMembership {
887    account: CRMAccount,
888    segment: CustomerSegment,
889});
890
891impl_getters!(SupportCase {
892    id: SupportCaseId,
893    account_id: AccountId,
894    contact_id: ContactId,
895    order_id: Option<OrderId>,
896    status: SupportCaseStatus,
897    priority: SupportPriority,
898    opened_at: Timestamp,
899    last_updated_at: Timestamp,
900    sla_due_at: Timestamp,
901});
902
903impl_getters!(SupportCaseForContact {
904    account_contact: CRMAccountContact,
905    case_: SupportCase,
906});
907
908impl_getters!(ResolvedSupportCase {
909    case_: SupportCase,
910    resolved_at: Timestamp,
911});
912
913impl_getters!(RetentionOffer {
914    account: CRMAccount,
915    segment: CustomerSegment,
916    coupon: Coupon,
917    uses_before: Nat,
918    discount: Money,
919});