syrup-rail 0.5.0

Validated domain types and lifecycle policy for Syrup Rail billing
Documentation
use std::{fmt, str::FromStr};

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

#[derive(Clone, Copy, Debug, Error, Eq, PartialEq)]
pub enum CurrencyCodeError {
    #[error("currency code must contain exactly three uppercase ASCII characters")]
    Invalid,
}

#[derive(Clone, Copy, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct CurrencyCode([u8; 3]);

impl CurrencyCode {
    pub fn new(value: &str) -> Result<Self, CurrencyCodeError> {
        let bytes: [u8; 3] = value
            .as_bytes()
            .try_into()
            .map_err(|_| CurrencyCodeError::Invalid)?;
        if !bytes.iter().all(u8::is_ascii_uppercase) {
            return Err(CurrencyCodeError::Invalid);
        }
        Ok(Self(bytes))
    }

    pub fn as_str(&self) -> &str {
        std::str::from_utf8(&self.0).expect("validated ASCII currency code is UTF-8")
    }
}

impl fmt::Debug for CurrencyCode {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        formatter
            .debug_tuple("CurrencyCode")
            .field(&self.as_str())
            .finish()
    }
}

impl fmt::Display for CurrencyCode {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        formatter.write_str(self.as_str())
    }
}

impl FromStr for CurrencyCode {
    type Err = CurrencyCodeError;

    fn from_str(value: &str) -> Result<Self, Self::Err> {
        Self::new(value)
    }
}

#[derive(Clone, Copy, Debug, Error, Eq, PartialEq)]
pub enum MoneyError {
    #[error("money amount cannot be negative")]
    Negative,
    #[error("charge amount must be positive")]
    NonPositiveCharge,
    #[error("cumulative refund amount must be positive")]
    NonPositiveRefund,
}

#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub struct Money {
    cents: i32,
    currency: CurrencyCode,
}

impl Money {
    pub fn new(cents: i32, currency: CurrencyCode) -> Result<Self, MoneyError> {
        if cents < 0 {
            return Err(MoneyError::Negative);
        }
        Ok(Self { cents, currency })
    }

    pub const fn cents(self) -> i32 {
        self.cents
    }

    pub const fn currency(self) -> CurrencyCode {
        self.currency
    }
}

#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub struct ChargeAmount(Money);

impl ChargeAmount {
    pub fn new(cents: i32, currency: CurrencyCode) -> Result<Self, MoneyError> {
        if cents <= 0 {
            return Err(MoneyError::NonPositiveCharge);
        }
        Ok(Self(Money { cents, currency }))
    }

    pub const fn money(self) -> Money {
        self.0
    }

    pub const fn cents(self) -> i32 {
        self.0.cents()
    }

    pub const fn currency(self) -> CurrencyCode {
        self.0.currency()
    }

    pub const fn currency_code(&self) -> &CurrencyCode {
        &self.0.currency
    }
}

impl TryFrom<Money> for ChargeAmount {
    type Error = MoneyError;

    fn try_from(value: Money) -> Result<Self, Self::Error> {
        if value.cents() <= 0 {
            return Err(MoneyError::NonPositiveCharge);
        }
        Ok(Self(value))
    }
}

#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct CumulativeRefundCents(i32);

impl CumulativeRefundCents {
    pub fn new(value: i32) -> Result<Self, MoneyError> {
        if value <= 0 {
            return Err(MoneyError::NonPositiveRefund);
        }
        Ok(Self(value))
    }

    pub const fn get(self) -> i32 {
        self.0
    }
}

#[derive(Clone, Copy, Debug, Error, Eq, PartialEq)]
pub enum BillingPeriodError {
    #[error("billing period end must be after its start")]
    EndNotAfterStart,
}

#[derive(Clone, Debug, Eq, PartialEq)]
pub struct BillingPeriod {
    start_at: DateTime<Utc>,
    end_at: DateTime<Utc>,
}

impl BillingPeriod {
    pub fn new(start_at: DateTime<Utc>, end_at: DateTime<Utc>) -> Result<Self, BillingPeriodError> {
        if end_at <= start_at {
            return Err(BillingPeriodError::EndNotAfterStart);
        }
        Ok(Self { start_at, end_at })
    }

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

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

#[cfg(test)]
mod tests {
    use chrono::TimeZone;

    use super::*;

    #[test]
    fn currency_is_exactly_three_uppercase_ascii_bytes() {
        assert_eq!(CurrencyCode::new("USD").unwrap().as_str(), "USD");
        for invalid in ["", "US", "USDD", "usd", "U1D", "ÉUR"] {
            assert!(CurrencyCode::new(invalid).is_err(), "accepted {invalid:?}");
        }
    }

    #[test]
    fn ledger_money_and_charge_amount_have_distinct_zero_rules() {
        let usd = CurrencyCode::new("USD").unwrap();
        assert_eq!(Money::new(0, usd).unwrap().cents(), 0);
        assert_eq!(ChargeAmount::new(1, usd).unwrap().cents(), 1);
        assert_eq!(
            ChargeAmount::new(0, usd),
            Err(MoneyError::NonPositiveCharge)
        );
        assert_eq!(Money::new(-1, usd), Err(MoneyError::Negative));
    }

    #[test]
    fn billing_period_requires_a_strictly_later_end() {
        let start = Utc.with_ymd_and_hms(2026, 8, 1, 0, 0, 0).unwrap();
        let end = Utc.with_ymd_and_hms(2026, 9, 1, 0, 0, 0).unwrap();
        assert!(BillingPeriod::new(start, end).is_ok());
        assert_eq!(
            BillingPeriod::new(start, start),
            Err(BillingPeriodError::EndNotAfterStart)
        );
    }
}