//! Pricing model trait and concrete implementations. //! //! Centralizes all pricing/access logic into a single interface. Each pricing //! strategy (free, fixed, PWYW, subscription) is a concrete struct implementing //! the `PricingModel` trait. Routes pre-fetch an `AccessContext` from the DB, //! then call `pricing.can_access(&ctx)` for uniform access control. //! //! See also: `/docs/guide/pricing` use crate::db; use crate::error::AppError; use crate::helpers; /// Parse a dollar-amount form input into `i32` cents. /// /// Single canonical conversion for every dollars-to-cents form parse in the /// codebase. Rejects NaN, infinities, negatives, and amounts that would /// overflow `i32` cents. Empty/whitespace/missing input returns `Ok(0)`. /// /// `field` is the user-visible field name used in error messages. Use this /// helper for every form field that takes a dollar amount — bypassing it has /// historically introduced silent NaN→$0 and saturating-overflow bugs. pub fn parse_dollars_to_cents(field: &str, raw: Option<&str>) -> crate::error::Result { let s = raw.map(str::trim).unwrap_or(""); if s.is_empty() { return Ok(0); } // Strip clipboard-paste decoration so pastes from invoices / price lists // ("$5", "1,000.00", " $1,250 ") don't 422. The validator below still // rejects anything that doesn't parse as a finite, non-negative number. let cleaned: String = s .chars() .filter(|c| *c != '$' && *c != ',' && !c.is_whitespace()) .collect(); let parse_src = if cleaned.is_empty() { s } else { cleaned.as_str() }; let dollars: f64 = parse_src.parse().map_err(|_| { AppError::validation(format!("{field} must be a number")) })?; if !dollars.is_finite() { return Err(AppError::validation(format!("{field} must be a finite number"))); } if dollars < 0.0 { return Err(AppError::validation(format!("{field} cannot be negative"))); } let cents_f = (dollars * 100.0).round(); if cents_f > i32::MAX as f64 { return Err(AppError::validation(format!("{field} is too large"))); } Ok(cents_f as i32) } /// Validate an already-parsed `f64` dollar amount and convert to `i32` cents. /// /// For JSON API handlers where serde has already deserialized the dollars /// field. Same NaN/Inf/negative/overflow rejection as [`parse_dollars_to_cents`]. pub fn validate_dollars_f64(field: &str, dollars: f64) -> crate::error::Result { if !dollars.is_finite() { return Err(AppError::validation(format!("{field} must be a finite number"))); } if dollars < 0.0 { return Err(AppError::validation(format!("{field} cannot be negative"))); } let cents_f = (dollars * 100.0).round(); if cents_f > i32::MAX as f64 { return Err(AppError::validation(format!("{field} is too large"))); } Ok(cents_f as i32) } #[cfg(test)] mod parse_dollars_tests { use super::*; #[test] fn empty_or_missing_is_zero() { assert_eq!(parse_dollars_to_cents("Price", None).unwrap(), 0); assert_eq!(parse_dollars_to_cents("Price", Some("")).unwrap(), 0); assert_eq!(parse_dollars_to_cents("Price", Some(" ")).unwrap(), 0); } #[test] fn rounds_to_nearest_cent() { assert_eq!(parse_dollars_to_cents("Price", Some("9.99")).unwrap(), 999); assert_eq!(parse_dollars_to_cents("Price", Some("1.234")).unwrap(), 123); assert_eq!(parse_dollars_to_cents("Price", Some("1.236")).unwrap(), 124); } #[test] fn rejects_nan() { assert!(parse_dollars_to_cents("Price", Some("NaN")).is_err()); assert!(parse_dollars_to_cents("Price", Some("nan")).is_err()); } #[test] fn rejects_infinity() { assert!(parse_dollars_to_cents("Price", Some("inf")).is_err()); assert!(parse_dollars_to_cents("Price", Some("Infinity")).is_err()); } #[test] fn rejects_negative() { assert!(parse_dollars_to_cents("Price", Some("-1")).is_err()); assert!(parse_dollars_to_cents("Price", Some("-0.01")).is_err()); } #[test] fn rejects_overflow() { assert!(parse_dollars_to_cents("Price", Some("100000000000")).is_err()); assert!(parse_dollars_to_cents("Price", Some("1e20")).is_err()); } #[test] fn rejects_garbage() { assert!(parse_dollars_to_cents("Price", Some("abc")).is_err()); assert!(parse_dollars_to_cents("Price", Some("free")).is_err()); } #[test] fn strips_clipboard_decoration() { // Clipboard pastes from invoices / price lists shouldn't 422. assert_eq!(parse_dollars_to_cents("Price", Some("$5")).unwrap(), 500); assert_eq!(parse_dollars_to_cents("Price", Some("1,000")).unwrap(), 100_000); assert_eq!(parse_dollars_to_cents("Price", Some("$ 1,250.00")).unwrap(), 125_000); assert_eq!(parse_dollars_to_cents("Price", Some(" $9.99 ")).unwrap(), 999); // Decoration-only input still parses as garbage (no digits → fails) assert!(parse_dollars_to_cents("Price", Some("$$")).is_err()); } } /// Pre-fetched access state for a user viewing a priced resource. /// /// Routes populate this from DB lookups, then pass it to `PricingModel::can_access()`. /// /// `subscription` holds a [`SubscriptionGate`](db::subscriptions::SubscriptionGate) /// witness rather than a bool: the only way to set "has an active subscription" /// is to present a gate, which can only be minted by running the canonical /// access predicate (see `db::subscriptions::gate`). This makes it impossible to /// grant subscription access here without having actually checked it — the /// consumption-point counterpart to the sealed gate. #[derive(Debug, Clone, Default)] pub struct AccessContext { pub is_creator: bool, pub has_purchased: bool, pub subscription: Option, } impl AccessContext { /// True iff a subscription currently grants access — only possible when a /// real [`SubscriptionGate`](db::subscriptions::SubscriptionGate) proof is /// present. pub fn has_active_subscription(&self) -> bool { self.subscription.is_some() } } /// What kind of checkout flow a pricing model requires. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum CheckoutType { /// Free content, no checkout needed. None, /// Standard one-time purchase. OneTime, /// Buyer chooses the amount. PayWhatYouWant, /// Recurring via subscription tiers. Subscription, } /// Unified pricing interface for items and projects. pub trait PricingModel: Send + Sync + std::fmt::Debug { /// Whether this content is free (no payment of any kind). fn is_free(&self) -> bool; /// Whether the given access context grants access to this content. fn can_access(&self, ctx: &AccessContext) -> bool; /// Human-readable price string for display (e.g. "$9.99", "Free", "PWYW"). fn price_display(&self) -> String; /// Raw price in cents (0 for free/subscription). fn price_cents(&self) -> i32; /// Minimum amount in cents for PWYW; `None` for other models. fn minimum_cents(&self) -> Option { None } /// What checkout flow this pricing requires. fn checkout_type(&self) -> CheckoutType; /// Validate a buyer-submitted amount in cents. Returns `Ok(())` or an error message. fn validate_amount(&self, amount_cents: i32) -> Result<(), String>; /// The DB discriminant for this pricing model. fn kind(&self) -> db::PricingKind; } // ============================================================================ // Concrete implementations // ============================================================================ /// Free content — always accessible, no checkout. #[derive(Debug)] pub struct FreePricing; impl PricingModel for FreePricing { fn is_free(&self) -> bool { true } fn can_access(&self, _ctx: &AccessContext) -> bool { true } fn price_display(&self) -> String { "Free".to_string() } fn price_cents(&self) -> i32 { 0 } fn checkout_type(&self) -> CheckoutType { CheckoutType::None } fn validate_amount(&self, _amount_cents: i32) -> Result<(), String> { Ok(()) } fn kind(&self) -> db::PricingKind { db::PricingKind::Free } } /// Fixed-price one-time purchase. /// /// `can_access` also checks `has_active_subscription` to handle hybrid items /// that are both buy-once and subscribable. #[derive(Debug)] pub struct FixedPricing { pub price_cents: i32, } impl PricingModel for FixedPricing { fn is_free(&self) -> bool { false } fn can_access(&self, ctx: &AccessContext) -> bool { ctx.is_creator || ctx.has_purchased || ctx.has_active_subscription() } fn price_display(&self) -> String { helpers::format_price(self.price_cents) } fn price_cents(&self) -> i32 { self.price_cents } fn checkout_type(&self) -> CheckoutType { CheckoutType::OneTime } fn validate_amount(&self, amount_cents: i32) -> Result<(), String> { if amount_cents < self.price_cents { Err(format!( "Amount must be at least {}", helpers::format_price(self.price_cents) )) } else { Ok(()) } } fn kind(&self) -> db::PricingKind { db::PricingKind::BuyOnce } } /// Pay-what-you-want pricing with optional minimum. /// /// Always shows checkout even with $0 min — `is_free()` returns false. #[derive(Debug)] pub struct PwywPricing { pub min_cents: Option, } impl PricingModel for PwywPricing { fn is_free(&self) -> bool { false } fn can_access(&self, ctx: &AccessContext) -> bool { ctx.is_creator || ctx.has_purchased || ctx.has_active_subscription() } fn price_display(&self) -> String { match self.min_cents { Some(min) if min > 0 => format!("From {}", helpers::format_price(min)), _ => "Pay what you want".to_string(), } } fn price_cents(&self) -> i32 { self.min_cents.unwrap_or(0) } fn minimum_cents(&self) -> Option { self.min_cents } fn checkout_type(&self) -> CheckoutType { CheckoutType::PayWhatYouWant } fn validate_amount(&self, amount_cents: i32) -> Result<(), String> { let min = self.min_cents.unwrap_or(0); if amount_cents < min { return Err(format!( "Amount must be at least ${:.2}", min as f64 / 100.0 )); } // Cap at $10,000 (same ceiling as tips) to prevent accidental mega-charges if amount_cents > 1_000_000 { return Err("Amount cannot exceed $10,000".to_string()); } Ok(()) } fn kind(&self) -> db::PricingKind { db::PricingKind::Pwyw } } /// Subscription-only pricing. /// /// `can_access` does NOT check `has_purchased` — subscribing is recurring, not one-time. /// Creator access still works. #[derive(Debug)] pub struct SubscriptionPricing; impl PricingModel for SubscriptionPricing { fn is_free(&self) -> bool { false } fn can_access(&self, ctx: &AccessContext) -> bool { ctx.is_creator || ctx.has_active_subscription() } fn price_display(&self) -> String { "Subscription".to_string() } fn price_cents(&self) -> i32 { 0 } fn checkout_type(&self) -> CheckoutType { CheckoutType::Subscription } fn validate_amount(&self, _amount_cents: i32) -> Result<(), String> { Err("Subscription items cannot be purchased directly".to_string()) } fn kind(&self) -> db::PricingKind { db::PricingKind::Subscription } } // ============================================================================ // Constructors // ============================================================================ /// Build a pricing model from a project's DB row. pub fn for_project(project: &db::DbProject) -> Box { match project.pricing_model { db::PricingKind::Free => Box::new(FreePricing), db::PricingKind::BuyOnce => Box::new(FixedPricing { price_cents: project.price_cents, }), db::PricingKind::Pwyw => Box::new(PwywPricing { min_cents: project.pwyw_min_cents, }), db::PricingKind::Subscription => Box::new(SubscriptionPricing), } } /// Build a pricing model from an item's DB row. /// /// Items derive pricing from existing fields (`price_cents`, `pwyw_enabled`, /// `pwyw_min_cents`). No new column needed. pub fn for_item(item: &db::DbItem) -> Box { if item.pwyw_enabled { Box::new(PwywPricing { min_cents: item.pwyw_min_cents, }) } else if item.price_cents == 0 { Box::new(FreePricing) } else { Box::new(FixedPricing { price_cents: item.price_cents, }) } } /// Build an access context for a project, fetching purchase/subscription state from DB. pub async fn build_project_access_context( pool: &sqlx::PgPool, maybe_user_id: Option, project_id: db::ProjectId, creator_user_id: db::UserId, ) -> crate::error::Result { let Some(user_id) = maybe_user_id else { return Ok(AccessContext::default()); }; let is_creator = user_id == creator_user_id; let has_purchased = db::transactions::has_purchased_project(pool, user_id, project_id).await?; let subscription = db::subscriptions::SubscriptionGate::check( pool, user_id, db::subscriptions::SubscriptionScope::Project(project_id), ) .await?; Ok(AccessContext { is_creator, has_purchased, subscription, }) } // ============================================================================ // Tests // ============================================================================ #[cfg(test)] mod tests { use super::*; // ── FreePricing ── #[test] fn free_is_free() { assert!(FreePricing.is_free()); } #[test] fn free_always_accessible() { assert!(FreePricing.can_access(&AccessContext::default())); } #[test] fn free_price_display() { assert_eq!(FreePricing.price_display(), "Free"); } #[test] fn free_price_cents() { assert_eq!(FreePricing.price_cents(), 0); } #[test] fn free_checkout_type() { assert_eq!(FreePricing.checkout_type(), CheckoutType::None); } #[test] fn free_validate_amount() { assert!(FreePricing.validate_amount(0).is_ok()); assert!(FreePricing.validate_amount(100).is_ok()); } #[test] fn free_kind() { assert_eq!(FreePricing.kind(), db::PricingKind::Free); } // ── FixedPricing ── #[test] fn fixed_not_free() { let p = FixedPricing { price_cents: 999 }; assert!(!p.is_free()); } #[test] fn fixed_access_creator() { let p = FixedPricing { price_cents: 999 }; assert!(p.can_access(&AccessContext { is_creator: true, ..Default::default() })); } #[test] fn fixed_access_purchased() { let p = FixedPricing { price_cents: 999 }; assert!(p.can_access(&AccessContext { has_purchased: true, ..Default::default() })); } #[test] fn fixed_access_subscribed() { let p = FixedPricing { price_cents: 999 }; assert!(p.can_access(&AccessContext { subscription: Some(crate::db::subscriptions::SubscriptionGate::test_witness()), ..Default::default() })); } #[test] fn fixed_access_denied() { let p = FixedPricing { price_cents: 999 }; assert!(!p.can_access(&AccessContext::default())); } #[test] fn fixed_price_display_whole() { let p = FixedPricing { price_cents: 1000 }; assert_eq!(p.price_display(), "$10"); } #[test] fn fixed_price_display_cents() { let p = FixedPricing { price_cents: 999 }; assert_eq!(p.price_display(), "$9.99"); } #[test] fn fixed_validate_amount_ok() { let p = FixedPricing { price_cents: 999 }; assert!(p.validate_amount(999).is_ok()); assert!(p.validate_amount(1500).is_ok()); } #[test] fn fixed_validate_amount_too_low() { let p = FixedPricing { price_cents: 999 }; assert!(p.validate_amount(500).is_err()); } #[test] fn fixed_kind() { let p = FixedPricing { price_cents: 999 }; assert_eq!(p.kind(), db::PricingKind::BuyOnce); } // ── PwywPricing ── #[test] fn pwyw_not_free() { let p = PwywPricing { min_cents: Some(0) }; assert!(!p.is_free()); } #[test] fn pwyw_not_free_even_zero_min() { let p = PwywPricing { min_cents: None }; assert!(!p.is_free()); } #[test] fn pwyw_access_creator() { let p = PwywPricing { min_cents: Some(500) }; assert!(p.can_access(&AccessContext { is_creator: true, ..Default::default() })); } #[test] fn pwyw_access_purchased() { let p = PwywPricing { min_cents: Some(500) }; assert!(p.can_access(&AccessContext { has_purchased: true, ..Default::default() })); } #[test] fn pwyw_access_denied() { let p = PwywPricing { min_cents: Some(500) }; assert!(!p.can_access(&AccessContext::default())); } #[test] fn pwyw_price_display_with_min() { let p = PwywPricing { min_cents: Some(500), }; assert_eq!(p.price_display(), "From $5"); } #[test] fn pwyw_price_display_no_min() { let p = PwywPricing { min_cents: None }; assert_eq!(p.price_display(), "Pay what you want"); } #[test] fn pwyw_price_display_zero_min() { let p = PwywPricing { min_cents: Some(0) }; assert_eq!(p.price_display(), "Pay what you want"); } #[test] fn pwyw_validate_amount_ok() { let p = PwywPricing { min_cents: Some(500), }; assert!(p.validate_amount(500).is_ok()); assert!(p.validate_amount(1000).is_ok()); } #[test] fn pwyw_validate_amount_too_low() { let p = PwywPricing { min_cents: Some(500), }; assert!(p.validate_amount(400).is_err()); } #[test] fn pwyw_validate_amount_zero_min() { let p = PwywPricing { min_cents: Some(0) }; assert!(p.validate_amount(0).is_ok()); } #[test] fn pwyw_minimum_cents() { let p = PwywPricing { min_cents: Some(500), }; assert_eq!(p.minimum_cents(), Some(500)); } #[test] fn pwyw_price_cents_with_min() { let p = PwywPricing { min_cents: Some(500) }; assert_eq!(p.price_cents(), 500); } #[test] fn pwyw_price_cents_no_min() { let p = PwywPricing { min_cents: None }; assert_eq!(p.price_cents(), 0); } #[test] fn pwyw_kind() { let p = PwywPricing { min_cents: None }; assert_eq!(p.kind(), db::PricingKind::Pwyw); } // ── SubscriptionPricing ── #[test] fn subscription_not_free() { assert!(!SubscriptionPricing.is_free()); } #[test] fn subscription_access_creator() { assert!(SubscriptionPricing.can_access(&AccessContext { is_creator: true, ..Default::default() })); } #[test] fn subscription_access_subscribed() { assert!(SubscriptionPricing.can_access(&AccessContext { subscription: Some(crate::db::subscriptions::SubscriptionGate::test_witness()), ..Default::default() })); } #[test] fn subscription_access_purchased_not_enough() { assert!(!SubscriptionPricing.can_access(&AccessContext { has_purchased: true, ..Default::default() })); } #[test] fn subscription_access_denied() { assert!(!SubscriptionPricing.can_access(&AccessContext::default())); } #[test] fn subscription_price_cents_is_zero() { assert_eq!(SubscriptionPricing.price_cents(), 0); } #[test] fn subscription_price_display() { assert_eq!(SubscriptionPricing.price_display(), "Subscription"); } #[test] fn subscription_checkout_type() { assert_eq!(SubscriptionPricing.checkout_type(), CheckoutType::Subscription); } #[test] fn subscription_validate_amount() { assert!(SubscriptionPricing.validate_amount(100).is_err()); } #[test] fn subscription_kind() { assert_eq!(SubscriptionPricing.kind(), db::PricingKind::Subscription); } // ── Constructors ── #[test] fn for_item_free() { let item = make_test_item(0, false, None); let p = for_item(&item); assert!(p.is_free()); assert_eq!(p.checkout_type(), CheckoutType::None); } #[test] fn for_item_fixed() { let item = make_test_item(999, false, None); let p = for_item(&item); assert!(!p.is_free()); assert_eq!(p.checkout_type(), CheckoutType::OneTime); assert_eq!(p.price_cents(), 999); } #[test] fn for_item_pwyw() { let mut item = make_test_item(500, false, Some(100)); item.pwyw_enabled = true; let p = for_item(&item); assert!(!p.is_free()); assert_eq!(p.checkout_type(), CheckoutType::PayWhatYouWant); assert_eq!(p.minimum_cents(), Some(100)); } #[test] fn for_project_free() { let project = make_test_project(db::PricingKind::Free, 0, None); let p = for_project(&project); assert!(p.is_free()); } #[test] fn for_project_buy_once() { let project = make_test_project(db::PricingKind::BuyOnce, 1999, None); let p = for_project(&project); assert!(!p.is_free()); assert_eq!(p.checkout_type(), CheckoutType::OneTime); assert_eq!(p.price_cents(), 1999); } #[test] fn for_project_pwyw() { let project = make_test_project(db::PricingKind::Pwyw, 0, Some(500)); let p = for_project(&project); assert!(!p.is_free()); assert_eq!(p.checkout_type(), CheckoutType::PayWhatYouWant); } #[test] fn for_project_subscription() { let project = make_test_project(db::PricingKind::Subscription, 0, None); let p = for_project(&project); assert!(!p.is_free()); assert_eq!(p.checkout_type(), CheckoutType::Subscription); } // ── Edge cases (test-fuzz) ── #[test] fn fixed_zero_cents_still_not_free() { // FixedPricing with 0 cents: is_free is hardcoded false let p = FixedPricing { price_cents: 0 }; assert!(!p.is_free()); assert_eq!(p.price_cents(), 0); } #[test] fn fixed_negative_price_validate_amount() { // Negative price_cents is semantically wrong but FixedPricing doesn't validate construction let p = FixedPricing { price_cents: -100 }; // amount >= price_cents (-100), so 0 and -50 pass, but -200 fails assert!(p.validate_amount(0).is_ok()); assert!(p.validate_amount(-50).is_ok()); assert!(p.validate_amount(-200).is_err()); // -200 < -100 } #[test] fn pwyw_validate_amount_at_cap() { let p = PwywPricing { min_cents: Some(0) }; assert!(p.validate_amount(1_000_000).is_ok()); // exactly $10,000 assert!(p.validate_amount(1_000_001).is_err()); // $10,000.01 } #[test] fn pwyw_validate_amount_negative() { let p = PwywPricing { min_cents: Some(0) }; // Negative amount is below min (0), should fail assert!(p.validate_amount(-1).is_err()); } #[test] fn pwyw_negative_min_cents() { // Negative min_cents is semantically wrong but PwywPricing doesn't validate let p = PwywPricing { min_cents: Some(-100) }; // Negative amount still above negative min assert!(p.validate_amount(-50).is_ok()); } #[test] fn fixed_validate_amount_no_upper_cap() { // FixedPricing has no $10k cap like PWYW does let p = FixedPricing { price_cents: 100 }; assert!(p.validate_amount(99_999_999).is_ok()); } #[test] fn for_item_pwyw_zero_price_still_pwyw() { // pwyw_enabled=true with price_cents=0 → PWYW, not Free let mut item = make_test_item(0, false, None); item.pwyw_enabled = true; let p = for_item(&item); assert!(!p.is_free()); assert_eq!(p.checkout_type(), CheckoutType::PayWhatYouWant); } #[test] fn subscription_purchased_user_cannot_access() { // Subscription items don't honor has_purchased (by design) assert!(!SubscriptionPricing.can_access(&AccessContext { has_purchased: true, subscription: None, is_creator: false, })); } #[test] fn free_minimum_cents_is_none() { assert_eq!(FreePricing.minimum_cents(), None); } #[test] fn fixed_minimum_cents_is_none() { let p = FixedPricing { price_cents: 999 }; assert_eq!(p.minimum_cents(), None); } #[test] fn subscription_minimum_cents_is_none() { assert_eq!(SubscriptionPricing.minimum_cents(), None); } // ── Adversarial (test-fuzz) ── #[test] fn adversarial_pwyw_max_i32_amount() { let p = PwywPricing { min_cents: Some(0) }; // i32::MAX = 2,147,483,647 cents = ~$21.4M — should be rejected by $10k cap assert!(p.validate_amount(i32::MAX).is_err()); } #[test] fn adversarial_pwyw_min_i32_amount() { let p = PwywPricing { min_cents: Some(0) }; assert!(p.validate_amount(i32::MIN).is_err()); } #[test] fn adversarial_fixed_price_i32_max() { let p = FixedPricing { price_cents: i32::MAX }; // validate_amount with exactly i32::MAX should pass assert!(p.validate_amount(i32::MAX).is_ok()); // Any amount below should fail assert!(p.validate_amount(i32::MAX - 1).is_err()); } #[test] fn adversarial_all_access_flags_false() { let ctx = AccessContext { is_creator: false, has_purchased: false, subscription: None, }; // Only FreePricing should grant access with no flags assert!(FreePricing.can_access(&ctx)); assert!(!FixedPricing { price_cents: 100 }.can_access(&ctx)); assert!(!PwywPricing { min_cents: None }.can_access(&ctx)); assert!(!SubscriptionPricing.can_access(&ctx)); } #[test] fn adversarial_all_access_flags_true() { let ctx = AccessContext { is_creator: true, has_purchased: true, subscription: Some(crate::db::subscriptions::SubscriptionGate::test_witness()), }; // All pricing models should grant access with all flags assert!(FreePricing.can_access(&ctx)); assert!(FixedPricing { price_cents: 100 }.can_access(&ctx)); assert!(PwywPricing { min_cents: None }.can_access(&ctx)); assert!(SubscriptionPricing.can_access(&ctx)); } // ── Test helpers ── fn make_test_item(price_cents: i32, pwyw_enabled: bool, pwyw_min_cents: Option) -> db::DbItem { db::DbItem { id: db::ItemId::nil(), project_id: db::ProjectId::nil(), title: "test".to_string(), description: None, price_cents, item_type: db::ItemType::Digital, thumbnail_url: None, is_public: true, sort_order: 0, created_at: chrono::Utc::now(), updated_at: chrono::Utc::now(), body: None, word_count: None, reading_time_minutes: None, audio_url: None, duration_seconds: None, cover_image_url: None, episode_number: None, audio_s3_key: None, cover_s3_key: None, enable_license_keys: false, default_max_activations: None, sales_count: 0, play_count: 0, unique_play_count: 0, download_count: 0, pwyw_enabled, pwyw_min_cents, scan_status: db::FileScanStatus::Clean, release_announced_at: None, publish_at: None, mt_thread_id: None, web_only: false, audio_file_size_bytes: None, cover_file_size_bytes: None, video_s3_key: None, video_file_size_bytes: None, video_duration_seconds: None, video_width: None, video_height: None, slug: "test".to_string(), listed: true, license_preset: None, custom_license_text: None, ai_tier: db::AiTier::Handmade, ai_disclosure: None, removed_by_admin: false, removal_reason: None, removed_at: None, deleted_at: None, } } fn make_test_project( pricing_model: db::PricingKind, price_cents: i32, pwyw_min_cents: Option, ) -> db::DbProject { db::DbProject { id: db::ProjectId::nil(), user_id: db::UserId::nil(), slug: db::Slug::from_trusted("test".to_string()), title: "Test Project".to_string(), description: None, project_type: db::ProjectType::General, cover_image_url: None, is_public: true, created_at: chrono::Utc::now(), updated_at: chrono::Utc::now(), cache_generation: 0, mt_community_id: None, features: vec![], pricing_model, price_cents, pwyw_min_cents, license_verification_enabled: false, ai_tier: db::AiTier::Handmade, ai_disclosure: None, } } // ── Edge cases: PWYW min exceeds cap (test-fuzz) ── #[test] fn pwyw_min_above_cap_creates_impossible_range() { // If min_cents > 1_000_000, no valid amount exists: // amount must be >= min (1_000_001) AND <= 1_000_000 — empty set. let p = PwywPricing { min_cents: Some(1_000_001), }; // Any amount below min fails the min check assert!(p.validate_amount(1_000_000).is_err()); // Any amount at/above min fails the cap check assert!(p.validate_amount(1_000_001).is_err()); // Even i32::MAX fails assert!(p.validate_amount(i32::MAX).is_err()); } #[test] fn pwyw_min_exactly_at_cap_allows_single_value() { // min_cents == 1_000_000: only amount == 1_000_000 should work let p = PwywPricing { min_cents: Some(1_000_000), }; assert!(p.validate_amount(1_000_000).is_ok()); assert!(p.validate_amount(999_999).is_err()); assert!(p.validate_amount(1_000_001).is_err()); } #[test] fn pwyw_none_min_allows_zero() { // min_cents = None → unwrap_or(0) → amount >= 0 required let p = PwywPricing { min_cents: None }; assert!(p.validate_amount(0).is_ok()); assert!(p.validate_amount(-1).is_err()); assert!(p.validate_amount(1_000_000).is_ok()); assert!(p.validate_amount(1_000_001).is_err()); } #[test] fn fixed_validate_amount_at_exact_boundary() { // Amount exactly equal to price should pass (not off-by-one) let p = FixedPricing { price_cents: 1 }; assert!(p.validate_amount(1).is_ok()); assert!(p.validate_amount(0).is_err()); } #[test] fn pwyw_access_subscribed() { // PwywPricing should grant access to subscribers (like FixedPricing) let p = PwywPricing { min_cents: Some(500), }; assert!(p.can_access(&AccessContext { subscription: Some(crate::db::subscriptions::SubscriptionGate::test_witness()), ..Default::default() })); } // ── Property-based tests (proptest) ── proptest::proptest! { #[test] fn prop_free_always_accessible( is_creator in proptest::bool::ANY, has_purchased in proptest::bool::ANY, has_active_subscription in proptest::bool::ANY, ) { let ctx = AccessContext { is_creator, has_purchased, subscription: has_active_subscription.then(crate::db::subscriptions::SubscriptionGate::test_witness) }; proptest::prop_assert!(FreePricing.can_access(&ctx)); proptest::prop_assert_eq!(FreePricing.price_cents(), 0); } #[test] fn prop_fixed_access_requires_flag( is_creator in proptest::bool::ANY, has_purchased in proptest::bool::ANY, has_active_subscription in proptest::bool::ANY, ) { let ctx = AccessContext { is_creator, has_purchased, subscription: has_active_subscription.then(crate::db::subscriptions::SubscriptionGate::test_witness) }; let p = FixedPricing { price_cents: 999 }; let expected = is_creator || has_purchased || has_active_subscription; proptest::prop_assert_eq!(p.can_access(&ctx), expected); } #[test] fn prop_pwyw_access_requires_flag( is_creator in proptest::bool::ANY, has_purchased in proptest::bool::ANY, has_active_subscription in proptest::bool::ANY, ) { let ctx = AccessContext { is_creator, has_purchased, subscription: has_active_subscription.then(crate::db::subscriptions::SubscriptionGate::test_witness) }; let p = PwywPricing { min_cents: Some(500) }; let expected = is_creator || has_purchased || has_active_subscription; proptest::prop_assert_eq!(p.can_access(&ctx), expected); } #[test] fn prop_subscription_ignores_purchased( is_creator in proptest::bool::ANY, has_purchased in proptest::bool::ANY, has_active_subscription in proptest::bool::ANY, ) { let ctx = AccessContext { is_creator, has_purchased, subscription: has_active_subscription.then(crate::db::subscriptions::SubscriptionGate::test_witness) }; // Subscription only honors is_creator and has_active_subscription let expected = is_creator || has_active_subscription; proptest::prop_assert_eq!(SubscriptionPricing.can_access(&ctx), expected); } #[test] fn prop_fixed_validate_amount_consistent(price in 0..=1_000_000i32, amount in -100_000..=2_000_000i32) { let p = FixedPricing { price_cents: price }; let result = p.validate_amount(amount); if amount >= price { proptest::prop_assert!(result.is_ok()); } else { proptest::prop_assert!(result.is_err()); } } #[test] fn prop_pwyw_validate_enforces_min_and_cap(min in 0..=1_100_000i32, amount in -1_000..=1_100_000i32) { let p = PwywPricing { min_cents: Some(min) }; let result = p.validate_amount(amount); if amount >= min && amount <= 1_000_000 { proptest::prop_assert!(result.is_ok()); } else { proptest::prop_assert!(result.is_err()); } } #[test] fn prop_subscription_never_direct_purchase(amount in proptest::num::i32::ANY) { proptest::prop_assert!(SubscriptionPricing.validate_amount(amount).is_err()); } } }