//! 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::currency::SettlementCurrency; 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 /// reintroduces 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_or("", str::trim); 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")))?; validate_dollars_f64(field, dollars) } /// Validate an already-parsed `f64` dollar amount and convert to `i32` cents. /// /// The single place NaN, infinities, negatives and `i32`-cent overflow are /// rejected: [`parse_dollars_to_cents`] parses the string and then hands the /// `f64` here, so a JSON handler that already has the number gets the same /// answer as a form post of the same amount. 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()); } // `validate_dollars_f64` is the JSON-handler entry point and had no test of // its own: every case above reached it only through the string parser, so // mutation testing found all twelve of its mutants alive (Phase 0 run, // 2026-08-16). The boundary cases are the point — an off-by-one on the // overflow guard is the difference between a $21,474,836.47 price and a // wrapped negative one. #[test] fn validate_f64_rejects_non_finite() { assert!(validate_dollars_f64("Price", f64::NAN).is_err()); assert!(validate_dollars_f64("Price", f64::INFINITY).is_err()); assert!(validate_dollars_f64("Price", f64::NEG_INFINITY).is_err()); } #[test] fn validate_f64_rejects_negative_but_accepts_zero() { assert!(validate_dollars_f64("Price", -0.01).is_err()); assert!(validate_dollars_f64("Price", -1.0).is_err()); assert_eq!(validate_dollars_f64("Price", 0.0).unwrap(), 0); } #[test] fn validate_f64_multiplies_by_a_hundred_and_rounds() { assert_eq!(validate_dollars_f64("Price", 9.99).unwrap(), 999); assert_eq!(validate_dollars_f64("Price", 1.234).unwrap(), 123); assert_eq!(validate_dollars_f64("Price", 1.236).unwrap(), 124); assert_eq!(validate_dollars_f64("Price", 2.0).unwrap(), 200); } #[test] fn validate_f64_overflow_guard_is_inclusive_at_i32_max_cents() { // Exactly `i32::MAX` cents is the largest representable price and must // be accepted; one cent more must not be. let at_max = f64::from(i32::MAX) / 100.0; assert_eq!(validate_dollars_f64("Price", at_max).unwrap(), i32::MAX); assert!(validate_dollars_f64("Price", at_max + 0.01).is_err()); assert!(validate_dollars_f64("Price", 1e20).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, currency: SettlementCurrency) -> 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 } /// The smallest non-zero amount a buyer can actually be charged, in the /// creator's settlement currency. /// /// Two floors apply and the binding one is the larger. A creator can set a /// PWYW minimum of 25c, but Stripe refuses any charge under the settlement /// currency's floor (50c in USD, 30p in GBP), so 25c is not a price anyone /// can pay. Screens that state a minimum and forms that constrain one both /// read this rather than [`PricingModel::minimum_cents`], or the page /// promises an amount the charge path rejects after the buyer has typed it. /// /// Zero is unaffected: a PWYW model with no minimum still accepts a $0 /// claim, which never reaches Stripe. This is the floor on a charge, not on /// the field. fn chargeable_minimum_cents(&self, currency: SettlementCurrency) -> i32 { let stripe_floor = i32::try_from(currency.minimum_charge_cents()).unwrap_or(i32::MAX); self.minimum_cents().unwrap_or(0).max(stripe_floor) } /// 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, currency: SettlementCurrency, ) -> 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, _currency: SettlementCurrency) -> String { "Free".to_string() } fn price_cents(&self) -> i32 { 0 } fn checkout_type(&self) -> CheckoutType { CheckoutType::None } fn validate_amount( &self, _amount_cents: i32, _currency: SettlementCurrency, ) -> 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, currency: SettlementCurrency) -> String { helpers::format_price(self.price_cents, currency) } fn price_cents(&self) -> i32 { self.price_cents } fn checkout_type(&self) -> CheckoutType { CheckoutType::OneTime } fn validate_amount( &self, amount_cents: i32, currency: SettlementCurrency, ) -> Result<(), String> { if amount_cents < self.price_cents { Err(format!( "Amount must be at least {}", helpers::format_price(self.price_cents, currency) )) } 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, currency: SettlementCurrency) -> String { match self.min_cents { // The chargeable floor, not the creator's raw one: a 25c minimum // under USD reads "From $0.50", because $0.50 is the smallest // amount checkout will accept and the card is a promise about that. Some(min) if min > 0 => format!( "From {}", helpers::format_price(self.chargeable_minimum_cents(currency), currency) ), _ => "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, currency: SettlementCurrency, ) -> Result<(), String> { // One number for the whole PWYW path: the same floor the paywall's // `min` attribute and the "From ..." card carry. Checking Stripe's // floor here rather than leaving it to `check_min_charge` downstream // means a buyer who types 25c is told the minimum is 50c, instead of // reading a generic "minimum purchase amount" line that sounds like // the creator priced the project wrong. let floor = self.chargeable_minimum_cents(currency); // A creator with no minimum is offering the project free to anyone who // asks, and a $0 claim never reaches Stripe, so no floor applies to it. let free_claim = amount_cents == 0 && self.min_cents.unwrap_or(0) == 0; if amount_cents < floor && !free_claim { return Err(format!( "Amount must be at least {}", crate::formatting::format_revenue(i64::from(floor), currency) )); } // 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, _currency: SettlementCurrency) -> String { // The tiers carry the prices; this label names the model, not an amount. "Subscription".to_string() } fn price_cents(&self) -> i32 { 0 } fn checkout_type(&self) -> CheckoutType { CheckoutType::Subscription } fn validate_amount( &self, _amount_cents: i32, _currency: SettlementCurrency, ) -> 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(SettlementCurrency::Usd), "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, SettlementCurrency::Usd) .is_ok() ); assert!( FreePricing .validate_amount(100, SettlementCurrency::Usd) .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(SettlementCurrency::Usd), "$10"); } #[test] fn fixed_price_display_cents() { let p = FixedPricing { price_cents: 999 }; assert_eq!(p.price_display(SettlementCurrency::Usd), "$9.99"); } #[test] fn fixed_validate_amount_ok() { let p = FixedPricing { price_cents: 999 }; assert!(p.validate_amount(999, SettlementCurrency::Usd).is_ok()); assert!(p.validate_amount(1500, SettlementCurrency::Usd).is_ok()); } #[test] fn fixed_validate_amount_too_low() { let p = FixedPricing { price_cents: 999 }; assert!(p.validate_amount(500, SettlementCurrency::Usd).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(SettlementCurrency::Usd), "From $5"); } #[test] fn pwyw_price_display_no_min() { let p = PwywPricing { min_cents: None }; assert_eq!( p.price_display(SettlementCurrency::Usd), "Pay what you want" ); } #[test] fn pwyw_price_display_zero_min() { let p = PwywPricing { min_cents: Some(0) }; assert_eq!( p.price_display(SettlementCurrency::Usd), "Pay what you want" ); } #[test] fn pwyw_validate_amount_ok() { let p = PwywPricing { min_cents: Some(500), }; assert!(p.validate_amount(500, SettlementCurrency::Usd).is_ok()); assert!(p.validate_amount(1000, SettlementCurrency::Usd).is_ok()); } #[test] fn pwyw_validate_amount_too_low() { let p = PwywPricing { min_cents: Some(500), }; assert!(p.validate_amount(400, SettlementCurrency::Usd).is_err()); } #[test] fn pwyw_validate_amount_zero_min() { let p = PwywPricing { min_cents: Some(0) }; assert!(p.validate_amount(0, SettlementCurrency::Usd).is_ok()); } #[test] fn pwyw_chargeable_minimum_is_the_larger_of_the_two_floors() { // Stripe refuses a charge under the settlement currency's floor, so a // creator minimum below it is not a price anyone can pay. let low = PwywPricing { min_cents: Some(25), }; assert_eq!(low.chargeable_minimum_cents(SettlementCurrency::Usd), 50); assert_eq!(low.chargeable_minimum_cents(SettlementCurrency::Gbp), 30); let high = PwywPricing { min_cents: Some(999), }; assert_eq!(high.chargeable_minimum_cents(SettlementCurrency::Usd), 999); // No minimum at all still has a chargeable floor: $0 is a free claim, // not a charge, and every charge clears Stripe's floor. let none = PwywPricing { min_cents: None }; assert_eq!(none.chargeable_minimum_cents(SettlementCurrency::Usd), 50); } #[test] fn pwyw_price_display_states_the_chargeable_minimum() { // The card used to promise "From $0.25" against a charge path that // refused anything under $0.50. let p = PwywPricing { min_cents: Some(25), }; assert_eq!(p.price_display(SettlementCurrency::Usd), "From $0.50"); assert_eq!(p.price_display(SettlementCurrency::Gbp), "From \u{a3}0.30"); } #[test] fn pwyw_sub_floor_amount_is_refused_by_the_model_not_by_stripe() { // 25c against a 25c minimum: the model itself now names $0.50, so the // buyer is not told the "minimum purchase amount" by a downstream // guard that sounds like the creator mispriced the project. let p = PwywPricing { min_cents: Some(25), }; let Err(msg) = p.validate_amount(25, SettlementCurrency::Usd) else { panic!("25c must not reach a charge"); }; assert_eq!(msg, "Amount must be at least $0.50"); assert!(p.validate_amount(50, SettlementCurrency::Usd).is_ok()); } #[test] fn pwyw_free_claim_survives_the_floor() { // The floor is on a charge. A creator offering the project for nothing // still gets $0 claims, which never reach Stripe. let free = PwywPricing { min_cents: Some(0) }; assert!(free.validate_amount(0, SettlementCurrency::Usd).is_ok()); assert!( PwywPricing { min_cents: None } .validate_amount(0, SettlementCurrency::Usd) .is_ok() ); // But a creator who set a real minimum is not offering it free. let paid = PwywPricing { min_cents: Some(500), }; assert!(paid.validate_amount(0, SettlementCurrency::Usd).is_err()); } #[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(SettlementCurrency::Usd), "Subscription" ); } #[test] fn subscription_checkout_type() { assert_eq!( SubscriptionPricing.checkout_type(), CheckoutType::Subscription ); } #[test] fn subscription_validate_amount() { assert!( SubscriptionPricing .validate_amount(100, SettlementCurrency::Usd) .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, SettlementCurrency::Usd).is_ok()); assert!(p.validate_amount(-50, SettlementCurrency::Usd).is_ok()); assert!(p.validate_amount(-200, SettlementCurrency::Usd).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, SettlementCurrency::Usd) .is_ok() ); // exactly $10,000 assert!( p.validate_amount(1_000_001, SettlementCurrency::Usd) .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, SettlementCurrency::Usd).is_err()); } #[test] fn pwyw_negative_min_cents() { // A negative minimum is a corrupt row, and it used to let a negative // amount through on the "still above the minimum" reading. The floor // is now the larger of the creator's minimum and the currency's, so a // corrupt row cannot open a path to a negative charge. let p = PwywPricing { min_cents: Some(-100), }; assert!(p.validate_amount(-50, SettlementCurrency::Usd).is_err()); assert!(p.validate_amount(500, SettlementCurrency::Usd).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, SettlementCurrency::Usd) .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, SettlementCurrency::Usd) .is_err() ); } #[test] fn adversarial_pwyw_min_i32_amount() { let p = PwywPricing { min_cents: Some(0) }; assert!( p.validate_amount(i32::MIN, SettlementCurrency::Usd) .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, SettlementCurrency::Usd).is_ok()); // Any amount below should fail assert!( p.validate_amount(i32::MAX - 1, SettlementCurrency::Usd) .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, cover_scan_status: "clean".to_string(), 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, cover_scan_status: "clean".to_string(), theme_id: 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, custom_html: String::new(), custom_css: String::new(), custom_pages_updated_at: 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, SettlementCurrency::Usd) .is_err() ); // Any amount at/above min fails the cap check assert!( p.validate_amount(1_000_001, SettlementCurrency::Usd) .is_err() ); // Even i32::MAX fails assert!( p.validate_amount(i32::MAX, SettlementCurrency::Usd) .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, SettlementCurrency::Usd) .is_ok() ); assert!(p.validate_amount(999_999, SettlementCurrency::Usd).is_err()); assert!( p.validate_amount(1_000_001, SettlementCurrency::Usd) .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, SettlementCurrency::Usd).is_ok()); assert!(p.validate_amount(-1, SettlementCurrency::Usd).is_err()); assert!( p.validate_amount(1_000_000, SettlementCurrency::Usd) .is_ok() ); assert!( p.validate_amount(1_000_001, SettlementCurrency::Usd) .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, SettlementCurrency::Usd).is_ok()); assert!(p.validate_amount(0, SettlementCurrency::Usd).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, SettlementCurrency::Usd); 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, SettlementCurrency::Usd); 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, SettlementCurrency::Usd).is_err()); } } }