Skip to main content

max / makenotwork

3.9 KB · 108 lines History Blame Raw
1 //! Validators for subscription tiers and payment-related fields.
2
3 use super::limits;
4 use crate::constants;
5 use crate::error::AppError;
6
7 /// Validate a Stripe Connect account ID.
8 ///
9 /// Stripe Connect account IDs are `acct_` followed by an alphanumeric token.
10 /// We only ever store values Stripe minted for us (via `create_connect_account`
11 /// or the account webhook), so this is a shape check that fences off obviously
12 /// wrong values entering the [`crate::db::validated_types::StripeAccountId`]
13 /// newtype at an input boundary, not a claim of Stripe-side existence.
14 pub fn validate_stripe_account_id(id: &str) -> Result<(), AppError> {
15 if !id.starts_with("acct_")
16 || id.len() < 6
17 || id.len() > 255
18 || !id["acct_".len()..]
19 .chars()
20 .all(|c| c.is_ascii_alphanumeric())
21 {
22 return Err(AppError::validation(
23 "Invalid Stripe account ID".to_string(),
24 ));
25 }
26 Ok(())
27 }
28
29 /// Validate a subscription tier name
30 pub fn validate_tier_name(name: &str) -> Result<(), AppError> {
31 if name.is_empty() {
32 return Err(AppError::validation("Tier name is required".to_string()));
33 }
34 if name.chars().count() > limits::TIER_NAME_MAX {
35 return Err(AppError::validation(format!(
36 "Tier name must be {} characters or less",
37 limits::TIER_NAME_MAX
38 )));
39 }
40 // Single-line: a tier name can reach an email subject (Postmark rejects
41 // CRLF, but reject at source), so no line breaks or control chars.
42 super::reject_control_chars("Tier name", name)?;
43 Ok(())
44 }
45
46 /// Validate a subscription tier description
47 pub fn validate_tier_description(description: &str) -> Result<(), AppError> {
48 if description.chars().count() > limits::TIER_DESCRIPTION_MAX {
49 return Err(AppError::validation(format!(
50 "Tier description must be {} characters or less",
51 limits::TIER_DESCRIPTION_MAX
52 )));
53 }
54 super::reject_control_chars_multiline("Tier description", description)?;
55 Ok(())
56 }
57
58 /// Validate a subscription tier price in cents (must be at least $1.00)
59 pub fn validate_tier_price(price_cents: i32) -> Result<(), AppError> {
60 if price_cents < crate::constants::MIN_SUBSCRIPTION_PRICE_CENTS {
61 return Err(AppError::validation(format!(
62 "Subscription price must be at least {}",
63 crate::formatting::format_revenue(
64 crate::constants::MIN_SUBSCRIPTION_PRICE_CENTS as i64
65 )
66 )));
67 }
68 if price_cents > constants::MAX_PRICE_CENTS {
69 return Err(AppError::validation(
70 "Subscription price cannot exceed $10,000".to_string(),
71 ));
72 }
73 Ok(())
74 }
75
76 #[cfg(test)]
77 mod tests {
78 use super::*;
79
80 #[test]
81 fn test_validate_tier_name() {
82 assert!(validate_tier_name("Basic Tier").is_ok());
83 assert!(validate_tier_name("X").is_ok());
84 assert!(validate_tier_name("").is_err()); // empty
85 assert!(validate_tier_name(&"a".repeat(100)).is_ok()); // at limit
86 assert!(validate_tier_name(&"a".repeat(101)).is_err()); // over limit
87 }
88
89 #[test]
90 fn test_validate_tier_description() {
91 assert!(validate_tier_description("Access to all content").is_ok());
92 assert!(validate_tier_description("").is_ok()); // empty is valid
93 assert!(validate_tier_description(&"a".repeat(2000)).is_ok()); // at limit
94 assert!(validate_tier_description(&"a".repeat(2001)).is_err()); // over limit
95 }
96
97 #[test]
98 fn test_validate_tier_price() {
99 assert!(validate_tier_price(100).is_ok()); // $1.00 minimum
100 assert!(validate_tier_price(999).is_ok());
101 assert!(validate_tier_price(1_000_000).is_ok()); // $10,000
102 assert!(validate_tier_price(99).is_err()); // below minimum
103 assert!(validate_tier_price(0).is_err()); // zero
104 assert!(validate_tier_price(-1).is_err()); // negative
105 assert!(validate_tier_price(1_000_001).is_err()); // over cap
106 }
107 }
108