//! Validators for subscription tiers and payment-related fields. use super::limits; use crate::error::AppError; /// Validate a Stripe Connect account ID. /// /// Stripe Connect account IDs are `acct_` followed by an alphanumeric token. /// We only ever store values Stripe minted for us (via `create_connect_account` /// or the account webhook), so this is a shape check that fences off obviously /// wrong values entering the [`crate::db::validated_types::StripeAccountId`] /// newtype at an input boundary, not a claim of Stripe-side existence. pub fn validate_stripe_account_id(id: &str) -> Result<(), AppError> { if !id.starts_with("acct_") || id.len() < 6 || id.len() > 255 || !id["acct_".len()..] .chars() .all(|c| c.is_ascii_alphanumeric()) { return Err(AppError::validation( "Invalid Stripe account ID".to_string(), )); } Ok(()) } /// Validate a subscription tier name pub fn validate_tier_name(name: &str) -> Result<(), AppError> { if name.is_empty() { return Err(AppError::validation("Tier name is required".to_string())); } if name.chars().count() > limits::TIER_NAME_MAX { return Err(AppError::validation(format!( "Tier name must be {} characters or less", limits::TIER_NAME_MAX ))); } // Single-line: a tier name can reach an email subject (Postmark rejects // CRLF, but reject at source), so no line breaks or control chars. super::reject_control_chars("Tier name", name)?; Ok(()) } /// Validate a subscription tier description pub fn validate_tier_description(description: &str) -> Result<(), AppError> { if description.chars().count() > limits::TIER_DESCRIPTION_MAX { return Err(AppError::validation(format!( "Tier description must be {} characters or less", limits::TIER_DESCRIPTION_MAX ))); } super::reject_control_chars_multiline("Tier description", description)?; Ok(()) } /// Validate a subscription tier price in cents (must be at least $1.00) pub fn validate_tier_price( price_cents: i32, currency: crate::currency::SettlementCurrency, ) -> Result<(), AppError> { if price_cents < crate::constants::MIN_SUBSCRIPTION_PRICE_CENTS { return Err(AppError::validation(format!( "Subscription price must be at least {}", crate::formatting::format_revenue( crate::constants::MIN_SUBSCRIPTION_PRICE_CENTS as i64, currency ) ))); } // Currency-relative: the ceiling is a round 10,000 in the creator's own // currency, not a USD equivalence (which would need an FX table). let ceiling = currency.max_price_cents(); if price_cents > ceiling { return Err(AppError::validation(format!( "Subscription price cannot exceed {}", crate::formatting::format_revenue(ceiling as i64, currency) ))); } Ok(()) } #[cfg(test)] mod tests { use super::*; #[test] fn test_validate_tier_name() { assert!(validate_tier_name("Basic Tier").is_ok()); assert!(validate_tier_name("X").is_ok()); assert!(validate_tier_name("").is_err()); // empty assert!(validate_tier_name(&"a".repeat(100)).is_ok()); // at limit assert!(validate_tier_name(&"a".repeat(101)).is_err()); // over limit } #[test] fn test_validate_tier_description() { assert!(validate_tier_description("Access to all content").is_ok()); assert!(validate_tier_description("").is_ok()); // empty is valid assert!(validate_tier_description(&"a".repeat(2000)).is_ok()); // at limit assert!(validate_tier_description(&"a".repeat(2001)).is_err()); // over limit } #[test] fn test_validate_tier_price() { assert!(validate_tier_price(100, crate::currency::SettlementCurrency::Usd).is_ok()); // $1.00 minimum assert!(validate_tier_price(999, crate::currency::SettlementCurrency::Usd).is_ok()); assert!(validate_tier_price(1_000_000, crate::currency::SettlementCurrency::Usd).is_ok()); // $10,000 assert!(validate_tier_price(99, crate::currency::SettlementCurrency::Usd).is_err()); // below minimum assert!(validate_tier_price(0, crate::currency::SettlementCurrency::Usd).is_err()); // zero assert!(validate_tier_price(-1, crate::currency::SettlementCurrency::Usd).is_err()); // negative assert!(validate_tier_price(1_000_001, crate::currency::SettlementCurrency::Usd).is_err()); // over cap } }