syrup-rail 0.5.0

Validated domain types and lifecycle policy for Syrup Rail billing
Documentation
use thiserror::Error;

#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub enum PaymentResolutionCode {
    SubscriptionInitialCurrentSubscriptionConflict,
    SubscriptionInitialCurrentGrantConflict,
    SubscriptionInitialExternallyRefunded,
    SubscriptionInitialExternallyVoided,
    ProcessorChargeExternallyRefunded,
    ProcessorChargeExternallyVoided,
    SubscriptionInitialPreparedAttemptExpired,
    SubscriptionRenewalRetryStateChangedBeforeCharge,
    GatewayLiveReadinessFailedBeforeSubmission,
    GatewayTestReadinessFailedBeforeSubmission,
    GatewayMalformedBeforeSubmission,
    GatewayRequestRejectedBeforeSubmission,
    GatewayConfigurationBeforeSubmission,
    GatewayUnavailableBeforeSubmission,
    GatewayProviderRateLimitedBeforeSubmission,
    GatewayAccountRateLimitedBeforeSubmission,
    GatewayAccountMutationCooldownBeforeSubmission,
    HostChargeApprovedStaleState,
    SubscriptionApprovedRenewalStaleState,
    SubscriptionApprovedRecoveryStaleState,
    SubscriptionApprovedRecoveryInactiveReplacementMethod,
    SubscriptionApprovedPaymentMethodUpdateSubscriptionIneligible,
    SubscriptionApprovedPaymentMethodUpdateStaleState,
    SubscriptionApprovedPaymentMethodUpdateInactiveReplacementMethod,
}

impl PaymentResolutionCode {
    pub const ALL: &'static [Self] = &[
        Self::SubscriptionInitialCurrentSubscriptionConflict,
        Self::SubscriptionInitialCurrentGrantConflict,
        Self::SubscriptionInitialExternallyRefunded,
        Self::SubscriptionInitialExternallyVoided,
        Self::ProcessorChargeExternallyRefunded,
        Self::ProcessorChargeExternallyVoided,
        Self::SubscriptionInitialPreparedAttemptExpired,
        Self::SubscriptionRenewalRetryStateChangedBeforeCharge,
        Self::GatewayLiveReadinessFailedBeforeSubmission,
        Self::GatewayTestReadinessFailedBeforeSubmission,
        Self::GatewayMalformedBeforeSubmission,
        Self::GatewayRequestRejectedBeforeSubmission,
        Self::GatewayConfigurationBeforeSubmission,
        Self::GatewayUnavailableBeforeSubmission,
        Self::GatewayProviderRateLimitedBeforeSubmission,
        Self::GatewayAccountRateLimitedBeforeSubmission,
        Self::GatewayAccountMutationCooldownBeforeSubmission,
        Self::HostChargeApprovedStaleState,
        Self::SubscriptionApprovedRenewalStaleState,
        Self::SubscriptionApprovedRecoveryStaleState,
        Self::SubscriptionApprovedRecoveryInactiveReplacementMethod,
        Self::SubscriptionApprovedPaymentMethodUpdateSubscriptionIneligible,
        Self::SubscriptionApprovedPaymentMethodUpdateStaleState,
        Self::SubscriptionApprovedPaymentMethodUpdateInactiveReplacementMethod,
    ];

    pub const RENEWAL_INFRASTRUCTURE_RETRY_CODES: &'static [Self] = &[
        Self::GatewayLiveReadinessFailedBeforeSubmission,
        Self::GatewayTestReadinessFailedBeforeSubmission,
        Self::GatewayMalformedBeforeSubmission,
        Self::GatewayRequestRejectedBeforeSubmission,
        Self::GatewayConfigurationBeforeSubmission,
        Self::GatewayUnavailableBeforeSubmission,
        Self::GatewayProviderRateLimitedBeforeSubmission,
        Self::GatewayAccountRateLimitedBeforeSubmission,
        Self::GatewayAccountMutationCooldownBeforeSubmission,
    ];

    pub const RENEWAL_INFRASTRUCTURE_PACING_CODES: &'static [Self] = &[
        Self::GatewayLiveReadinessFailedBeforeSubmission,
        Self::GatewayTestReadinessFailedBeforeSubmission,
        Self::GatewayMalformedBeforeSubmission,
        Self::GatewayRequestRejectedBeforeSubmission,
        Self::GatewayConfigurationBeforeSubmission,
        Self::GatewayUnavailableBeforeSubmission,
    ];

    /// Rate-limit outcomes that share the renewal fast-to-slow retry curve.
    /// Their durable codes remain distinct because their global cooldown
    /// scopes differ.
    pub const RENEWAL_RATE_LIMIT_PACING_CODES: &'static [Self] = &[
        Self::GatewayProviderRateLimitedBeforeSubmission,
        Self::GatewayAccountRateLimitedBeforeSubmission,
    ];

    pub const fn as_str(self) -> &'static str {
        match self {
            Self::SubscriptionInitialCurrentSubscriptionConflict => {
                "subscription_initial_current_subscription_conflict"
            }
            Self::SubscriptionInitialCurrentGrantConflict => {
                "subscription_initial_current_grant_conflict"
            }
            Self::SubscriptionInitialExternallyRefunded => {
                "subscription_initial_externally_refunded"
            }
            Self::SubscriptionInitialExternallyVoided => "subscription_initial_externally_voided",
            Self::ProcessorChargeExternallyRefunded => "processor_charge_externally_refunded",
            Self::ProcessorChargeExternallyVoided => "processor_charge_externally_voided",
            Self::SubscriptionInitialPreparedAttemptExpired => {
                "subscription_initial_prepared_attempt_expired"
            }
            Self::SubscriptionRenewalRetryStateChangedBeforeCharge => {
                "subscription_renewal_retry_state_changed_before_charge"
            }
            Self::GatewayLiveReadinessFailedBeforeSubmission => {
                "gateway_live_readiness_failed_before_submission"
            }
            Self::GatewayTestReadinessFailedBeforeSubmission => {
                "gateway_test_readiness_failed_before_submission"
            }
            Self::GatewayMalformedBeforeSubmission => "gateway_malformed_before_submission",
            Self::GatewayRequestRejectedBeforeSubmission => {
                "gateway_request_rejected_before_submission"
            }
            Self::GatewayConfigurationBeforeSubmission => "gateway_configuration_before_submission",
            Self::GatewayUnavailableBeforeSubmission => "gateway_unavailable_before_submission",
            Self::GatewayProviderRateLimitedBeforeSubmission => {
                "gateway_provider_rate_limited_before_submission"
            }
            Self::GatewayAccountRateLimitedBeforeSubmission => {
                "gateway_account_rate_limited_before_submission"
            }
            Self::GatewayAccountMutationCooldownBeforeSubmission => {
                "gateway_account_mutation_cooldown_before_submission"
            }
            Self::HostChargeApprovedStaleState => "host_charge_approved_stale_state",
            Self::SubscriptionApprovedRenewalStaleState => {
                "subscription_approved_renewal_stale_state"
            }
            Self::SubscriptionApprovedRecoveryStaleState => {
                "subscription_approved_recovery_stale_state"
            }
            Self::SubscriptionApprovedRecoveryInactiveReplacementMethod => {
                "subscription_approved_recovery_inactive_replacement_method"
            }
            Self::SubscriptionApprovedPaymentMethodUpdateSubscriptionIneligible => {
                "subscription_approved_payment_method_update_subscription_ineligible"
            }
            Self::SubscriptionApprovedPaymentMethodUpdateStaleState => {
                "subscription_approved_payment_method_update_stale_state"
            }
            Self::SubscriptionApprovedPaymentMethodUpdateInactiveReplacementMethod => {
                "subscription_approved_payment_method_update_inactive_replacement_method"
            }
        }
    }
}

#[derive(Clone, Copy, Debug, Error, Eq, PartialEq)]
#[error("unknown payment resolution code")]
pub struct PaymentResolutionCodeParseError;

impl TryFrom<&str> for PaymentResolutionCode {
    type Error = PaymentResolutionCodeParseError;

    fn try_from(value: &str) -> Result<Self, Self::Error> {
        match value {
            "subscription_initial_current_subscription_conflict" => {
                Ok(Self::SubscriptionInitialCurrentSubscriptionConflict)
            }
            "subscription_initial_current_grant_conflict" => {
                Ok(Self::SubscriptionInitialCurrentGrantConflict)
            }
            "subscription_initial_externally_refunded" => {
                Ok(Self::SubscriptionInitialExternallyRefunded)
            }
            "subscription_initial_externally_voided" => {
                Ok(Self::SubscriptionInitialExternallyVoided)
            }
            "processor_charge_externally_refunded" => Ok(Self::ProcessorChargeExternallyRefunded),
            "processor_charge_externally_voided" => Ok(Self::ProcessorChargeExternallyVoided),
            "subscription_initial_prepared_attempt_expired" => {
                Ok(Self::SubscriptionInitialPreparedAttemptExpired)
            }
            "subscription_renewal_retry_state_changed_before_charge" => {
                Ok(Self::SubscriptionRenewalRetryStateChangedBeforeCharge)
            }
            "gateway_live_readiness_failed_before_submission" => {
                Ok(Self::GatewayLiveReadinessFailedBeforeSubmission)
            }
            "gateway_test_readiness_failed_before_submission" => {
                Ok(Self::GatewayTestReadinessFailedBeforeSubmission)
            }
            "gateway_malformed_before_submission" => Ok(Self::GatewayMalformedBeforeSubmission),
            "gateway_request_rejected_before_submission" => {
                Ok(Self::GatewayRequestRejectedBeforeSubmission)
            }
            "gateway_configuration_before_submission" => {
                Ok(Self::GatewayConfigurationBeforeSubmission)
            }
            "gateway_unavailable_before_submission" => Ok(Self::GatewayUnavailableBeforeSubmission),
            "gateway_provider_rate_limited_before_submission" => {
                Ok(Self::GatewayProviderRateLimitedBeforeSubmission)
            }
            "gateway_account_rate_limited_before_submission" => {
                Ok(Self::GatewayAccountRateLimitedBeforeSubmission)
            }
            "gateway_account_mutation_cooldown_before_submission" => {
                Ok(Self::GatewayAccountMutationCooldownBeforeSubmission)
            }
            "host_charge_approved_stale_state" => Ok(Self::HostChargeApprovedStaleState),
            "subscription_approved_renewal_stale_state" => {
                Ok(Self::SubscriptionApprovedRenewalStaleState)
            }
            "subscription_approved_recovery_stale_state" => {
                Ok(Self::SubscriptionApprovedRecoveryStaleState)
            }
            "subscription_approved_recovery_inactive_replacement_method" => {
                Ok(Self::SubscriptionApprovedRecoveryInactiveReplacementMethod)
            }
            "subscription_approved_payment_method_update_subscription_ineligible" => {
                Ok(Self::SubscriptionApprovedPaymentMethodUpdateSubscriptionIneligible)
            }
            "subscription_approved_payment_method_update_stale_state" => {
                Ok(Self::SubscriptionApprovedPaymentMethodUpdateStaleState)
            }
            "subscription_approved_payment_method_update_inactive_replacement_method" => {
                Ok(Self::SubscriptionApprovedPaymentMethodUpdateInactiveReplacementMethod)
            }
            _ => Err(PaymentResolutionCodeParseError),
        }
    }
}

impl TryFrom<String> for PaymentResolutionCode {
    type Error = PaymentResolutionCodeParseError;

    fn try_from(value: String) -> Result<Self, Self::Error> {
        Self::try_from(value.as_str())
    }
}

#[cfg(test)]
mod tests {
    use std::collections::HashSet;

    use super::*;

    #[test]
    fn all_twenty_four_canonical_values_round_trip_exhaustively() {
        assert_eq!(PaymentResolutionCode::ALL.len(), 24);
        let values = PaymentResolutionCode::ALL
            .iter()
            .map(|code| code.as_str())
            .collect::<HashSet<_>>();
        assert_eq!(values.len(), 24);
        for code in PaymentResolutionCode::ALL {
            assert_eq!(PaymentResolutionCode::try_from(code.as_str()), Ok(*code));
        }
        assert_eq!(
            PaymentResolutionCode::try_from("base_subscription_initial_prepared_attempt_expired"),
            Err(PaymentResolutionCodeParseError)
        );
        assert_eq!(
            PaymentResolutionCode::try_from("nmi_provider_rate_limited_before_submission"),
            Err(PaymentResolutionCodeParseError)
        );
    }

    #[test]
    fn retry_policy_sets_preserve_the_characterized_membership() {
        assert_eq!(
            PaymentResolutionCode::RENEWAL_INFRASTRUCTURE_RETRY_CODES.len(),
            9
        );
        assert_eq!(
            PaymentResolutionCode::RENEWAL_INFRASTRUCTURE_PACING_CODES.len(),
            6
        );
        assert_eq!(
            PaymentResolutionCode::RENEWAL_RATE_LIMIT_PACING_CODES,
            &[
                PaymentResolutionCode::GatewayProviderRateLimitedBeforeSubmission,
                PaymentResolutionCode::GatewayAccountRateLimitedBeforeSubmission,
            ]
        );
        let pacing = PaymentResolutionCode::RENEWAL_INFRASTRUCTURE_PACING_CODES;
        assert!(
            !pacing.contains(&PaymentResolutionCode::GatewayProviderRateLimitedBeforeSubmission)
        );
        assert!(
            !pacing.contains(&PaymentResolutionCode::GatewayAccountRateLimitedBeforeSubmission)
        );
        assert!(
            !pacing
                .contains(&PaymentResolutionCode::GatewayAccountMutationCooldownBeforeSubmission)
        );
        for code in pacing {
            assert!(PaymentResolutionCode::RENEWAL_INFRASTRUCTURE_RETRY_CODES.contains(code));
        }
    }
}