syrup-rail 0.4.0

Validated domain types and lifecycle policy for Syrup Rail billing
Documentation
use super::*;

#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub enum SubscriptionGrantKind {
    Testing,
    Promotion,
}

impl SubscriptionGrantKind {
    pub const fn as_str(self) -> &'static str {
        match self {
            Self::Testing => "testing",
            Self::Promotion => "promotion",
        }
    }
}

#[derive(Clone, Copy, Debug, Error, Eq, PartialEq)]
#[error("unknown subscription grant kind")]
pub struct SubscriptionGrantKindParseError;

impl FromStr for SubscriptionGrantKind {
    type Err = SubscriptionGrantKindParseError;

    fn from_str(value: &str) -> Result<Self, Self::Err> {
        match value {
            "testing" => Ok(Self::Testing),
            "promotion" => Ok(Self::Promotion),
            _ => Err(SubscriptionGrantKindParseError),
        }
    }
}

#[derive(Clone, Debug, Error, Eq, PartialEq)]
pub enum SubscriptionGrantError {
    #[error("subscription grant end must be after its start")]
    InvalidPeriod,
}

#[derive(Clone, Debug, Eq, PartialEq)]
pub struct SubscriptionGrant {
    id: SubscriptionGrantId,
    plan_key: PlanKey,
    kind: SubscriptionGrantKind,
    starts_at: DateTime<Utc>,
    ends_at: DateTime<Utc>,
    granted_by_actor_id: ActorId,
}

#[derive(Clone, Debug, Error, Eq, PartialEq)]
pub enum SubscriptionGrantReasonError {
    #[error("subscription grant reason is empty")]
    Empty,
    #[error("subscription grant reason exceeds 500 characters")]
    TooLong,
    #[error("subscription grant reason contains raw payment card data")]
    ContainsRawCardData,
}

#[derive(Clone, Debug, Eq, PartialEq)]
pub struct SubscriptionGrantReason(String);

impl SubscriptionGrantReason {
    pub fn new(value: impl Into<String>) -> Result<Self, SubscriptionGrantReasonError> {
        let value = value.into();
        let value = value.trim();
        let length = value.chars().count();
        if length == 0 {
            return Err(SubscriptionGrantReasonError::Empty);
        }
        if length > 500 {
            return Err(SubscriptionGrantReasonError::TooLong);
        }
        if crate::string_contains_raw_card_data(value) {
            return Err(SubscriptionGrantReasonError::ContainsRawCardData);
        }
        Ok(Self(value.to_owned()))
    }

    pub fn as_str(&self) -> &str {
        &self.0
    }
}

#[derive(Clone, Debug, Error, Eq, PartialEq)]
pub enum SubscriptionGrantRecordError {
    #[error("subscription grant revocation fields must be all present or all absent")]
    InvalidRevocation,
    #[error("subscription grant revocation cannot precede the grant start")]
    RevocationBeforeStart,
    #[error("subscription grant update cannot precede creation")]
    UpdateBeforeCreation,
}

#[derive(Clone, Debug, Eq, PartialEq)]
pub struct SubscriptionGrantRecord {
    billing_scope_id: BillingScopeId,
    subscriber_id: SubscriberId,
    grant: SubscriptionGrant,
    reason: SubscriptionGrantReason,
    revoked_at: Option<DateTime<Utc>>,
    revoked_by_actor_id: Option<ActorId>,
    revocation_reason: Option<SubscriptionGrantReason>,
    created_at: DateTime<Utc>,
    updated_at: DateTime<Utc>,
}

impl SubscriptionGrantRecord {
    #[allow(clippy::too_many_arguments)]
    pub fn new(
        billing_scope_id: BillingScopeId,
        subscriber_id: SubscriberId,
        grant: SubscriptionGrant,
        reason: SubscriptionGrantReason,
        revoked_at: Option<DateTime<Utc>>,
        revoked_by_actor_id: Option<ActorId>,
        revocation_reason: Option<SubscriptionGrantReason>,
        created_at: DateTime<Utc>,
        updated_at: DateTime<Utc>,
    ) -> Result<Self, SubscriptionGrantRecordError> {
        if !matches!(
            (&revoked_at, &revoked_by_actor_id, &revocation_reason),
            (None, None, None) | (Some(_), Some(_), Some(_))
        ) {
            return Err(SubscriptionGrantRecordError::InvalidRevocation);
        }
        if revoked_at.is_some_and(|revoked_at| revoked_at < *grant.starts_at()) {
            return Err(SubscriptionGrantRecordError::RevocationBeforeStart);
        }
        if updated_at < created_at {
            return Err(SubscriptionGrantRecordError::UpdateBeforeCreation);
        }
        Ok(Self {
            billing_scope_id,
            subscriber_id,
            grant,
            reason,
            revoked_at,
            revoked_by_actor_id,
            revocation_reason,
            created_at,
            updated_at,
        })
    }

    pub const fn billing_scope_id(&self) -> BillingScopeId {
        self.billing_scope_id
    }

    pub const fn subscriber_id(&self) -> SubscriberId {
        self.subscriber_id
    }

    pub const fn grant(&self) -> &SubscriptionGrant {
        &self.grant
    }

    pub const fn reason(&self) -> &SubscriptionGrantReason {
        &self.reason
    }

    pub const fn revoked_at(&self) -> Option<&DateTime<Utc>> {
        self.revoked_at.as_ref()
    }

    pub const fn revoked_by_actor_id(&self) -> Option<ActorId> {
        self.revoked_by_actor_id
    }

    pub const fn revocation_reason(&self) -> Option<&SubscriptionGrantReason> {
        self.revocation_reason.as_ref()
    }

    pub const fn created_at(&self) -> &DateTime<Utc> {
        &self.created_at
    }

    pub const fn updated_at(&self) -> &DateTime<Utc> {
        &self.updated_at
    }
}

#[derive(Clone, Debug, Eq, PartialEq)]
pub struct SubscriptionGrantCreation {
    id: SubscriptionGrantId,
    billing_scope_id: BillingScopeId,
    subscriber_id: SubscriberId,
    plan_key: PlanKey,
    kind: SubscriptionGrantKind,
    reason: SubscriptionGrantReason,
    ends_at: DateTime<Utc>,
    granted_by_actor_id: ActorId,
}

impl SubscriptionGrantCreation {
    #[allow(clippy::too_many_arguments)]
    pub const fn new(
        id: SubscriptionGrantId,
        billing_scope_id: BillingScopeId,
        subscriber_id: SubscriberId,
        plan_key: PlanKey,
        kind: SubscriptionGrantKind,
        reason: SubscriptionGrantReason,
        ends_at: DateTime<Utc>,
        granted_by_actor_id: ActorId,
    ) -> Self {
        Self {
            id,
            billing_scope_id,
            subscriber_id,
            plan_key,
            kind,
            reason,
            ends_at,
            granted_by_actor_id,
        }
    }

    pub const fn id(&self) -> SubscriptionGrantId {
        self.id
    }

    pub const fn billing_scope_id(&self) -> BillingScopeId {
        self.billing_scope_id
    }

    pub const fn subscriber_id(&self) -> SubscriberId {
        self.subscriber_id
    }

    pub const fn plan_key(&self) -> &PlanKey {
        &self.plan_key
    }

    pub const fn kind(&self) -> SubscriptionGrantKind {
        self.kind
    }

    pub const fn reason(&self) -> &SubscriptionGrantReason {
        &self.reason
    }

    pub const fn ends_at(&self) -> &DateTime<Utc> {
        &self.ends_at
    }

    pub const fn granted_by_actor_id(&self) -> ActorId {
        self.granted_by_actor_id
    }
}

#[derive(Clone, Debug, Eq, PartialEq)]
pub struct SubscriptionGrantRevocation {
    id: SubscriptionGrantId,
    billing_scope_id: BillingScopeId,
    subscriber_id: SubscriberId,
    plan_key: PlanKey,
    revoked_by_actor_id: ActorId,
    reason: SubscriptionGrantReason,
}

impl SubscriptionGrantRevocation {
    pub const fn new(
        id: SubscriptionGrantId,
        billing_scope_id: BillingScopeId,
        subscriber_id: SubscriberId,
        plan_key: PlanKey,
        revoked_by_actor_id: ActorId,
        reason: SubscriptionGrantReason,
    ) -> Self {
        Self {
            id,
            billing_scope_id,
            subscriber_id,
            plan_key,
            revoked_by_actor_id,
            reason,
        }
    }

    pub const fn id(&self) -> SubscriptionGrantId {
        self.id
    }

    pub const fn billing_scope_id(&self) -> BillingScopeId {
        self.billing_scope_id
    }

    pub const fn subscriber_id(&self) -> SubscriberId {
        self.subscriber_id
    }

    pub const fn plan_key(&self) -> &PlanKey {
        &self.plan_key
    }

    pub const fn revoked_by_actor_id(&self) -> ActorId {
        self.revoked_by_actor_id
    }

    pub const fn reason(&self) -> &SubscriptionGrantReason {
        &self.reason
    }
}

#[derive(Clone, Debug, Eq, PartialEq)]
pub enum SubscriptionGrantCreationOutcome {
    Created(Box<SubscriptionGrantRecord>),
    EndsAtNotFuture,
    CurrentPaidSubscription,
    ActiveGrant,
    BlockingInitialAttempt,
    PendingApprovedProcessorEvidence,
}

#[derive(Clone, Debug, Eq, PartialEq)]
pub enum SubscriptionGrantRevocationOutcome {
    Revoked(Box<SubscriptionGrantRecord>),
    AlreadyRevoked(Box<SubscriptionGrantRecord>),
    NotFound,
    Expired,
}

impl SubscriptionGrant {
    pub fn new(
        id: SubscriptionGrantId,
        plan_key: PlanKey,
        kind: SubscriptionGrantKind,
        starts_at: DateTime<Utc>,
        ends_at: DateTime<Utc>,
        granted_by_actor_id: ActorId,
    ) -> Result<Self, SubscriptionGrantError> {
        if ends_at <= starts_at {
            return Err(SubscriptionGrantError::InvalidPeriod);
        }
        Ok(Self {
            id,
            plan_key,
            kind,
            starts_at,
            ends_at,
            granted_by_actor_id,
        })
    }

    pub const fn id(&self) -> SubscriptionGrantId {
        self.id
    }

    pub const fn plan_key(&self) -> &PlanKey {
        &self.plan_key
    }

    pub const fn kind(&self) -> SubscriptionGrantKind {
        self.kind
    }

    pub const fn starts_at(&self) -> &DateTime<Utc> {
        &self.starts_at
    }

    pub const fn ends_at(&self) -> &DateTime<Utc> {
        &self.ends_at
    }

    pub const fn granted_by_actor_id(&self) -> ActorId {
        self.granted_by_actor_id
    }
}