syrup-rail 0.5.0

Validated domain types and lifecycle policy for Syrup Rail billing
Documentation
use std::fmt;

use chrono::{DateTime, Utc};
use thiserror::Error;

use crate::{CumulativeRefundCents, GatewayDiagnostic, GatewayOrderId, GatewayTransactionId};

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum PaymentReversalKind {
    Refunded,
    Voided,
    Chargeback,
}

#[derive(Clone, Debug, Eq, PartialEq)]
pub enum GatewayLifecycleState {
    Unknown,
    PendingSettlement,
    Settled {
        cumulative_refunded_cents: Option<CumulativeRefundCents>,
    },
    Voided,
    Refunded {
        cumulative_refunded_cents: CumulativeRefundCents,
    },
    Chargeback {
        cumulative_refunded_cents: Option<CumulativeRefundCents>,
    },
}

impl GatewayLifecycleState {
    pub const fn full_reversal_kind(&self) -> Option<PaymentReversalKind> {
        match self {
            Self::Voided => Some(PaymentReversalKind::Voided),
            Self::Refunded { .. } => Some(PaymentReversalKind::Refunded),
            Self::Chargeback { .. } => Some(PaymentReversalKind::Chargeback),
            Self::Unknown | Self::PendingSettlement | Self::Settled { .. } => None,
        }
    }
}

#[derive(Clone, Debug, Error, Eq, PartialEq)]
pub enum GatewayLifecycleEvidenceError {
    #[error("gateway lifecycle evidence requires a transaction ID or order ID")]
    MissingLocator,
}

#[derive(Clone, Debug, Eq, PartialEq)]
pub struct GatewayLifecycleEvidence {
    transaction_id: Option<GatewayTransactionId>,
    order_id: Option<GatewayOrderId>,
    state: GatewayLifecycleState,
    condition: Option<GatewayDiagnostic>,
    action: Option<GatewayDiagnostic>,
    effective_at: Option<DateTime<Utc>>,
}

impl GatewayLifecycleEvidence {
    pub fn new(
        transaction_id: Option<GatewayTransactionId>,
        order_id: Option<GatewayOrderId>,
        state: GatewayLifecycleState,
        condition: Option<GatewayDiagnostic>,
        action: Option<GatewayDiagnostic>,
        effective_at: Option<DateTime<Utc>>,
    ) -> Result<Self, GatewayLifecycleEvidenceError> {
        if transaction_id.is_none() && order_id.is_none() {
            return Err(GatewayLifecycleEvidenceError::MissingLocator);
        }
        Ok(Self {
            transaction_id,
            order_id,
            state,
            condition,
            action,
            effective_at,
        })
    }

    pub const fn transaction_id(&self) -> Option<&GatewayTransactionId> {
        self.transaction_id.as_ref()
    }

    pub const fn order_id(&self) -> Option<&GatewayOrderId> {
        self.order_id.as_ref()
    }

    pub const fn state(&self) -> &GatewayLifecycleState {
        &self.state
    }

    pub const fn condition(&self) -> Option<&GatewayDiagnostic> {
        self.condition.as_ref()
    }

    pub const fn action(&self) -> Option<&GatewayDiagnostic> {
        self.action.as_ref()
    }

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

#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub enum GatewayLifecycleQuarantineReason {
    AmbiguousReversalSuccess,
    InvalidRefundEconomics,
    MalformedReportStructure,
}

impl GatewayLifecycleQuarantineReason {
    pub const fn as_str(self) -> &'static str {
        match self {
            Self::AmbiguousReversalSuccess => "ambiguous_reversal_success",
            Self::InvalidRefundEconomics => "invalid_refund_economics",
            Self::MalformedReportStructure => "malformed_report_structure",
        }
    }
}

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

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

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

impl fmt::Debug for GatewayLifecycleQuarantineResolutionReason {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        formatter.write_str("GatewayLifecycleQuarantineResolutionReason([redacted])")
    }
}

#[derive(Clone, Copy, Debug, Error, Eq, PartialEq)]
pub enum GatewayLifecycleQuarantineResolutionReasonError {
    #[error("gateway lifecycle quarantine resolution reason is empty")]
    Empty,
    #[error("gateway lifecycle quarantine resolution reason exceeds 500 characters")]
    TooLong,
    #[error("gateway lifecycle quarantine resolution reason contains raw payment card data")]
    ContainsRawCardData,
}

#[derive(Clone, Debug, Error, Eq, PartialEq)]
pub enum GatewayLifecycleQuarantineError {
    #[error("gateway lifecycle quarantine requires a locator unless the report is malformed")]
    MissingLocator,
}

#[derive(Clone, Debug, Eq, PartialEq)]
pub struct GatewayLifecycleQuarantine {
    transaction_id: Option<GatewayTransactionId>,
    order_id: Option<GatewayOrderId>,
    reason: GatewayLifecycleQuarantineReason,
}

impl GatewayLifecycleQuarantine {
    pub fn new(
        transaction_id: Option<GatewayTransactionId>,
        order_id: Option<GatewayOrderId>,
        reason: GatewayLifecycleQuarantineReason,
    ) -> Result<Self, GatewayLifecycleQuarantineError> {
        if transaction_id.is_none()
            && order_id.is_none()
            && reason != GatewayLifecycleQuarantineReason::MalformedReportStructure
        {
            return Err(GatewayLifecycleQuarantineError::MissingLocator);
        }
        Ok(Self {
            transaction_id,
            order_id,
            reason,
        })
    }

    pub const fn transaction_id(&self) -> Option<&GatewayTransactionId> {
        self.transaction_id.as_ref()
    }

    pub const fn order_id(&self) -> Option<&GatewayOrderId> {
        self.order_id.as_ref()
    }

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

#[derive(Clone, Debug, Eq, PartialEq)]
pub enum GatewayTransactionReport {
    Ignore,
    Evidence(GatewayLifecycleEvidence),
    Quarantine(GatewayLifecycleQuarantine),
}