Skip to main content

max / makenotwork

39.4 KB · 1290 lines History Blame Raw
1 //! Pricing model trait and concrete implementations.
2 //!
3 //! Centralizes all pricing/access logic into a single interface. Each pricing
4 //! strategy (free, fixed, PWYW, subscription) is a concrete struct implementing
5 //! the `PricingModel` trait. Routes pre-fetch an `AccessContext` from the DB,
6 //! then call `pricing.can_access(&ctx)` for uniform access control.
7 //!
8 //! See also: `/docs/guide/pricing`
9
10 use crate::currency::SettlementCurrency;
11 use crate::db;
12 use crate::error::AppError;
13 use crate::helpers;
14
15 /// Parse a dollar-amount form input into `i32` cents.
16 ///
17 /// Single canonical conversion for every dollars-to-cents form parse in the
18 /// codebase. Rejects NaN, infinities, negatives, and amounts that would
19 /// overflow `i32` cents. Empty/whitespace/missing input returns `Ok(0)`.
20 ///
21 /// `field` is the user-visible field name used in error messages. Use this
22 /// helper for every form field that takes a dollar amount, bypassing it has
23 /// historically introduced silent NaN→$0 and saturating-overflow bugs.
24 pub fn parse_dollars_to_cents(field: &str, raw: Option<&str>) -> crate::error::Result<i32> {
25 let s = raw.map_or("", str::trim);
26 if s.is_empty() {
27 return Ok(0);
28 }
29 // Strip clipboard-paste decoration so pastes from invoices / price lists
30 // ("$5", "1,000.00", " $1,250 ") don't 422. The validator below still
31 // rejects anything that doesn't parse as a finite, non-negative number.
32 let cleaned: String = s
33 .chars()
34 .filter(|c| *c != '$' && *c != ',' && !c.is_whitespace())
35 .collect();
36 let parse_src = if cleaned.is_empty() {
37 s
38 } else {
39 cleaned.as_str()
40 };
41 let dollars: f64 = parse_src
42 .parse()
43 .map_err(|_| AppError::validation(format!("{field} must be a number")))?;
44 validate_dollars_f64(field, dollars)
45 }
46
47 /// Validate an already-parsed `f64` dollar amount and convert to `i32` cents.
48 ///
49 /// The single place NaN, infinities, negatives and `i32`-cent overflow are
50 /// rejected: [`parse_dollars_to_cents`] parses the string and then hands the
51 /// `f64` here, so a JSON handler that already has the number gets the same
52 /// answer as a form post of the same amount.
53 pub fn validate_dollars_f64(field: &str, dollars: f64) -> crate::error::Result<i32> {
54 if !dollars.is_finite() {
55 return Err(AppError::validation(format!(
56 "{field} must be a finite number"
57 )));
58 }
59 if dollars < 0.0 {
60 return Err(AppError::validation(format!("{field} cannot be negative")));
61 }
62 let cents_f = (dollars * 100.0).round();
63 if cents_f > i32::MAX as f64 {
64 return Err(AppError::validation(format!("{field} is too large")));
65 }
66 Ok(cents_f as i32)
67 }
68
69 #[cfg(test)]
70 mod parse_dollars_tests {
71 use super::*;
72
73 #[test]
74 fn empty_or_missing_is_zero() {
75 assert_eq!(parse_dollars_to_cents("Price", None).unwrap(), 0);
76 assert_eq!(parse_dollars_to_cents("Price", Some("")).unwrap(), 0);
77 assert_eq!(parse_dollars_to_cents("Price", Some(" ")).unwrap(), 0);
78 }
79
80 #[test]
81 fn rounds_to_nearest_cent() {
82 assert_eq!(parse_dollars_to_cents("Price", Some("9.99")).unwrap(), 999);
83 assert_eq!(parse_dollars_to_cents("Price", Some("1.234")).unwrap(), 123);
84 assert_eq!(parse_dollars_to_cents("Price", Some("1.236")).unwrap(), 124);
85 }
86
87 #[test]
88 fn rejects_nan() {
89 assert!(parse_dollars_to_cents("Price", Some("NaN")).is_err());
90 assert!(parse_dollars_to_cents("Price", Some("nan")).is_err());
91 }
92
93 #[test]
94 fn rejects_infinity() {
95 assert!(parse_dollars_to_cents("Price", Some("inf")).is_err());
96 assert!(parse_dollars_to_cents("Price", Some("Infinity")).is_err());
97 }
98
99 #[test]
100 fn rejects_negative() {
101 assert!(parse_dollars_to_cents("Price", Some("-1")).is_err());
102 assert!(parse_dollars_to_cents("Price", Some("-0.01")).is_err());
103 }
104
105 #[test]
106 fn rejects_overflow() {
107 assert!(parse_dollars_to_cents("Price", Some("100000000000")).is_err());
108 assert!(parse_dollars_to_cents("Price", Some("1e20")).is_err());
109 }
110
111 #[test]
112 fn rejects_garbage() {
113 assert!(parse_dollars_to_cents("Price", Some("abc")).is_err());
114 assert!(parse_dollars_to_cents("Price", Some("free")).is_err());
115 }
116
117 #[test]
118 fn strips_clipboard_decoration() {
119 // Clipboard pastes from invoices / price lists shouldn't 422.
120 assert_eq!(parse_dollars_to_cents("Price", Some("$5")).unwrap(), 500);
121 assert_eq!(
122 parse_dollars_to_cents("Price", Some("1,000")).unwrap(),
123 100_000
124 );
125 assert_eq!(
126 parse_dollars_to_cents("Price", Some("$ 1,250.00")).unwrap(),
127 125_000
128 );
129 assert_eq!(
130 parse_dollars_to_cents("Price", Some(" $9.99 ")).unwrap(),
131 999
132 );
133 // Decoration-only input still parses as garbage (no digits → fails)
134 assert!(parse_dollars_to_cents("Price", Some("$$")).is_err());
135 }
136
137 // `validate_dollars_f64` is the JSON-handler entry point and had no test of
138 // its own: every case above reached it only through the string parser, so
139 // mutation testing found all twelve of its mutants alive (Phase 0 run,
140 // 2026-08-16). The boundary cases are the point — an off-by-one on the
141 // overflow guard is the difference between a $21,474,836.47 price and a
142 // wrapped negative one.
143
144 #[test]
145 fn validate_f64_rejects_non_finite() {
146 assert!(validate_dollars_f64("Price", f64::NAN).is_err());
147 assert!(validate_dollars_f64("Price", f64::INFINITY).is_err());
148 assert!(validate_dollars_f64("Price", f64::NEG_INFINITY).is_err());
149 }
150
151 #[test]
152 fn validate_f64_rejects_negative_but_accepts_zero() {
153 assert!(validate_dollars_f64("Price", -0.01).is_err());
154 assert!(validate_dollars_f64("Price", -1.0).is_err());
155 assert_eq!(validate_dollars_f64("Price", 0.0).unwrap(), 0);
156 }
157
158 #[test]
159 fn validate_f64_multiplies_by_a_hundred_and_rounds() {
160 assert_eq!(validate_dollars_f64("Price", 9.99).unwrap(), 999);
161 assert_eq!(validate_dollars_f64("Price", 1.234).unwrap(), 123);
162 assert_eq!(validate_dollars_f64("Price", 1.236).unwrap(), 124);
163 assert_eq!(validate_dollars_f64("Price", 2.0).unwrap(), 200);
164 }
165
166 #[test]
167 fn validate_f64_overflow_guard_is_inclusive_at_i32_max_cents() {
168 // Exactly `i32::MAX` cents is the largest representable price and must
169 // be accepted; one cent more must not be.
170 let at_max = f64::from(i32::MAX) / 100.0;
171 assert_eq!(validate_dollars_f64("Price", at_max).unwrap(), i32::MAX);
172 assert!(validate_dollars_f64("Price", at_max + 0.01).is_err());
173 assert!(validate_dollars_f64("Price", 1e20).is_err());
174 }
175 }
176
177 /// Pre-fetched access state for a user viewing a priced resource.
178 ///
179 /// Routes populate this from DB lookups, then pass it to `PricingModel::can_access()`.
180 ///
181 /// `subscription` holds a [`SubscriptionGate`](db::subscriptions::SubscriptionGate)
182 /// witness rather than a bool: the only way to set "has an active subscription"
183 /// is to present a gate, which can only be minted by running the canonical
184 /// access predicate (see `db::subscriptions::gate`). This makes it impossible to
185 /// grant subscription access here without having actually checked it, the
186 /// consumption-point counterpart to the sealed gate.
187 #[derive(Debug, Clone, Default)]
188 pub struct AccessContext {
189 pub is_creator: bool,
190 pub has_purchased: bool,
191 pub subscription: Option<db::subscriptions::SubscriptionGate>,
192 }
193
194 impl AccessContext {
195 /// True iff a subscription currently grants access, only possible when a
196 /// real [`SubscriptionGate`](db::subscriptions::SubscriptionGate) proof is
197 /// present.
198 pub fn has_active_subscription(&self) -> bool {
199 self.subscription.is_some()
200 }
201 }
202
203 /// What kind of checkout flow a pricing model requires.
204 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
205 pub enum CheckoutType {
206 /// Free content, no checkout needed.
207 None,
208 /// Standard one-time purchase.
209 OneTime,
210 /// Buyer chooses the amount.
211 PayWhatYouWant,
212 /// Recurring via subscription tiers.
213 Subscription,
214 }
215
216 /// Unified pricing interface for items and projects.
217 pub trait PricingModel: Send + Sync + std::fmt::Debug {
218 /// Whether this content is free (no payment of any kind).
219 fn is_free(&self) -> bool;
220
221 /// Whether the given access context grants access to this content.
222 fn can_access(&self, ctx: &AccessContext) -> bool;
223
224 /// Human-readable price string for display (e.g. "$9.99", "Free", "PWYW").
225 fn price_display(&self, currency: SettlementCurrency) -> String;
226
227 /// Raw price in cents (0 for free/subscription).
228 fn price_cents(&self) -> i32;
229
230 /// Minimum amount in cents for PWYW; `None` for other models.
231 fn minimum_cents(&self) -> Option<i32> {
232 None
233 }
234
235 /// What checkout flow this pricing requires.
236 fn checkout_type(&self) -> CheckoutType;
237
238 /// Validate a buyer-submitted amount in cents. Returns `Ok(())` or an error message.
239 fn validate_amount(
240 &self,
241 amount_cents: i32,
242 currency: SettlementCurrency,
243 ) -> Result<(), String>;
244
245 /// The DB discriminant for this pricing model.
246 fn kind(&self) -> db::PricingKind;
247 }
248
249 // Concrete implementations
250
251 /// Free content, always accessible, no checkout.
252 #[derive(Debug)]
253 pub struct FreePricing;
254
255 impl PricingModel for FreePricing {
256 fn is_free(&self) -> bool {
257 true
258 }
259
260 fn can_access(&self, _ctx: &AccessContext) -> bool {
261 true
262 }
263
264 fn price_display(&self, _currency: SettlementCurrency) -> String {
265 "Free".to_string()
266 }
267
268 fn price_cents(&self) -> i32 {
269 0
270 }
271
272 fn checkout_type(&self) -> CheckoutType {
273 CheckoutType::None
274 }
275
276 fn validate_amount(
277 &self,
278 _amount_cents: i32,
279 _currency: SettlementCurrency,
280 ) -> Result<(), String> {
281 Ok(())
282 }
283
284 fn kind(&self) -> db::PricingKind {
285 db::PricingKind::Free
286 }
287 }
288
289 /// Fixed-price one-time purchase.
290 ///
291 /// `can_access` also checks `has_active_subscription` to handle hybrid items
292 /// that are both buy-once and subscribable.
293 #[derive(Debug)]
294 pub struct FixedPricing {
295 pub price_cents: i32,
296 }
297
298 impl PricingModel for FixedPricing {
299 fn is_free(&self) -> bool {
300 false
301 }
302
303 fn can_access(&self, ctx: &AccessContext) -> bool {
304 ctx.is_creator || ctx.has_purchased || ctx.has_active_subscription()
305 }
306
307 fn price_display(&self, currency: SettlementCurrency) -> String {
308 helpers::format_price(self.price_cents, currency)
309 }
310
311 fn price_cents(&self) -> i32 {
312 self.price_cents
313 }
314
315 fn checkout_type(&self) -> CheckoutType {
316 CheckoutType::OneTime
317 }
318
319 fn validate_amount(
320 &self,
321 amount_cents: i32,
322 currency: SettlementCurrency,
323 ) -> Result<(), String> {
324 if amount_cents < self.price_cents {
325 Err(format!(
326 "Amount must be at least {}",
327 helpers::format_price(self.price_cents, currency)
328 ))
329 } else {
330 Ok(())
331 }
332 }
333
334 fn kind(&self) -> db::PricingKind {
335 db::PricingKind::BuyOnce
336 }
337 }
338
339 /// Pay-what-you-want pricing with optional minimum.
340 ///
341 /// Always shows checkout even with $0 min, `is_free()` returns false.
342 #[derive(Debug)]
343 pub struct PwywPricing {
344 pub min_cents: Option<i32>,
345 }
346
347 impl PricingModel for PwywPricing {
348 fn is_free(&self) -> bool {
349 false
350 }
351
352 fn can_access(&self, ctx: &AccessContext) -> bool {
353 ctx.is_creator || ctx.has_purchased || ctx.has_active_subscription()
354 }
355
356 fn price_display(&self, currency: SettlementCurrency) -> String {
357 match self.min_cents {
358 Some(min) if min > 0 => format!("From {}", helpers::format_price(min, currency)),
359 _ => "Pay what you want".to_string(),
360 }
361 }
362
363 fn price_cents(&self) -> i32 {
364 self.min_cents.unwrap_or(0)
365 }
366
367 fn minimum_cents(&self) -> Option<i32> {
368 self.min_cents
369 }
370
371 fn checkout_type(&self) -> CheckoutType {
372 CheckoutType::PayWhatYouWant
373 }
374
375 fn validate_amount(
376 &self,
377 amount_cents: i32,
378 currency: SettlementCurrency,
379 ) -> Result<(), String> {
380 let min = self.min_cents.unwrap_or(0);
381 if amount_cents < min {
382 return Err(format!(
383 "Amount must be at least {}",
384 crate::formatting::format_revenue(min as i64, currency)
385 ));
386 }
387 // Cap at $10,000 (same ceiling as tips) to prevent accidental mega-charges
388 if amount_cents > 1_000_000 {
389 return Err("Amount cannot exceed $10,000".to_string());
390 }
391 Ok(())
392 }
393
394 fn kind(&self) -> db::PricingKind {
395 db::PricingKind::Pwyw
396 }
397 }
398
399 /// Subscription-only pricing.
400 ///
401 /// `can_access` does NOT check `has_purchased`, subscribing is recurring, not one-time.
402 /// Creator access still works.
403 #[derive(Debug)]
404 pub struct SubscriptionPricing;
405
406 impl PricingModel for SubscriptionPricing {
407 fn is_free(&self) -> bool {
408 false
409 }
410
411 fn can_access(&self, ctx: &AccessContext) -> bool {
412 ctx.is_creator || ctx.has_active_subscription()
413 }
414
415 fn price_display(&self, _currency: SettlementCurrency) -> String {
416 // The tiers carry the prices; this label names the model, not an amount.
417 "Subscription".to_string()
418 }
419
420 fn price_cents(&self) -> i32 {
421 0
422 }
423
424 fn checkout_type(&self) -> CheckoutType {
425 CheckoutType::Subscription
426 }
427
428 fn validate_amount(
429 &self,
430 _amount_cents: i32,
431 _currency: SettlementCurrency,
432 ) -> Result<(), String> {
433 Err("Subscription items cannot be purchased directly".to_string())
434 }
435
436 fn kind(&self) -> db::PricingKind {
437 db::PricingKind::Subscription
438 }
439 }
440
441 // Constructors
442
443 /// Build a pricing model from a project's DB row.
444 pub fn for_project(project: &db::DbProject) -> Box<dyn PricingModel> {
445 match project.pricing_model {
446 db::PricingKind::Free => Box::new(FreePricing),
447 db::PricingKind::BuyOnce => Box::new(FixedPricing {
448 price_cents: project.price_cents,
449 }),
450 db::PricingKind::Pwyw => Box::new(PwywPricing {
451 min_cents: project.pwyw_min_cents,
452 }),
453 db::PricingKind::Subscription => Box::new(SubscriptionPricing),
454 }
455 }
456
457 /// Build a pricing model from an item's DB row.
458 ///
459 /// Items derive pricing from existing fields (`price_cents`, `pwyw_enabled`,
460 /// `pwyw_min_cents`). No new column needed.
461 pub fn for_item(item: &db::DbItem) -> Box<dyn PricingModel> {
462 if item.pwyw_enabled {
463 Box::new(PwywPricing {
464 min_cents: item.pwyw_min_cents,
465 })
466 } else if item.price_cents == 0 {
467 Box::new(FreePricing)
468 } else {
469 Box::new(FixedPricing {
470 price_cents: item.price_cents,
471 })
472 }
473 }
474
475 /// Build an access context for a project, fetching purchase/subscription state from DB.
476 pub async fn build_project_access_context(
477 pool: &sqlx::PgPool,
478 maybe_user_id: Option<db::UserId>,
479 project_id: db::ProjectId,
480 creator_user_id: db::UserId,
481 ) -> crate::error::Result<AccessContext> {
482 let Some(user_id) = maybe_user_id else {
483 return Ok(AccessContext::default());
484 };
485
486 let is_creator = user_id == creator_user_id;
487 let has_purchased = db::transactions::has_purchased_project(pool, user_id, project_id).await?;
488 let subscription = db::subscriptions::SubscriptionGate::check(
489 pool,
490 user_id,
491 db::subscriptions::SubscriptionScope::Project(project_id),
492 )
493 .await?;
494
495 Ok(AccessContext {
496 is_creator,
497 has_purchased,
498 subscription,
499 })
500 }
501
502 // Tests
503
504 #[cfg(test)]
505 mod tests {
506 use super::*;
507
508 // ── FreePricing ──
509
510 #[test]
511 fn free_is_free() {
512 assert!(FreePricing.is_free());
513 }
514
515 #[test]
516 fn free_always_accessible() {
517 assert!(FreePricing.can_access(&AccessContext::default()));
518 }
519
520 #[test]
521 fn free_price_display() {
522 assert_eq!(FreePricing.price_display(SettlementCurrency::Usd), "Free");
523 }
524
525 #[test]
526 fn free_price_cents() {
527 assert_eq!(FreePricing.price_cents(), 0);
528 }
529
530 #[test]
531 fn free_checkout_type() {
532 assert_eq!(FreePricing.checkout_type(), CheckoutType::None);
533 }
534
535 #[test]
536 fn free_validate_amount() {
537 assert!(
538 FreePricing
539 .validate_amount(0, SettlementCurrency::Usd)
540 .is_ok()
541 );
542 assert!(
543 FreePricing
544 .validate_amount(100, SettlementCurrency::Usd)
545 .is_ok()
546 );
547 }
548
549 #[test]
550 fn free_kind() {
551 assert_eq!(FreePricing.kind(), db::PricingKind::Free);
552 }
553
554 // ── FixedPricing ──
555
556 #[test]
557 fn fixed_not_free() {
558 let p = FixedPricing { price_cents: 999 };
559 assert!(!p.is_free());
560 }
561
562 #[test]
563 fn fixed_access_creator() {
564 let p = FixedPricing { price_cents: 999 };
565 assert!(p.can_access(&AccessContext {
566 is_creator: true,
567 ..Default::default()
568 }));
569 }
570
571 #[test]
572 fn fixed_access_purchased() {
573 let p = FixedPricing { price_cents: 999 };
574 assert!(p.can_access(&AccessContext {
575 has_purchased: true,
576 ..Default::default()
577 }));
578 }
579
580 #[test]
581 fn fixed_access_subscribed() {
582 let p = FixedPricing { price_cents: 999 };
583 assert!(p.can_access(&AccessContext {
584 subscription: Some(crate::db::subscriptions::SubscriptionGate::test_witness()),
585 ..Default::default()
586 }));
587 }
588
589 #[test]
590 fn fixed_access_denied() {
591 let p = FixedPricing { price_cents: 999 };
592 assert!(!p.can_access(&AccessContext::default()));
593 }
594
595 #[test]
596 fn fixed_price_display_whole() {
597 let p = FixedPricing { price_cents: 1000 };
598 assert_eq!(p.price_display(SettlementCurrency::Usd), "$10");
599 }
600
601 #[test]
602 fn fixed_price_display_cents() {
603 let p = FixedPricing { price_cents: 999 };
604 assert_eq!(p.price_display(SettlementCurrency::Usd), "$9.99");
605 }
606
607 #[test]
608 fn fixed_validate_amount_ok() {
609 let p = FixedPricing { price_cents: 999 };
610 assert!(p.validate_amount(999, SettlementCurrency::Usd).is_ok());
611 assert!(p.validate_amount(1500, SettlementCurrency::Usd).is_ok());
612 }
613
614 #[test]
615 fn fixed_validate_amount_too_low() {
616 let p = FixedPricing { price_cents: 999 };
617 assert!(p.validate_amount(500, SettlementCurrency::Usd).is_err());
618 }
619
620 #[test]
621 fn fixed_kind() {
622 let p = FixedPricing { price_cents: 999 };
623 assert_eq!(p.kind(), db::PricingKind::BuyOnce);
624 }
625
626 // ── PwywPricing ──
627
628 #[test]
629 fn pwyw_not_free() {
630 let p = PwywPricing { min_cents: Some(0) };
631 assert!(!p.is_free());
632 }
633
634 #[test]
635 fn pwyw_not_free_even_zero_min() {
636 let p = PwywPricing { min_cents: None };
637 assert!(!p.is_free());
638 }
639
640 #[test]
641 fn pwyw_access_creator() {
642 let p = PwywPricing {
643 min_cents: Some(500),
644 };
645 assert!(p.can_access(&AccessContext {
646 is_creator: true,
647 ..Default::default()
648 }));
649 }
650
651 #[test]
652 fn pwyw_access_purchased() {
653 let p = PwywPricing {
654 min_cents: Some(500),
655 };
656 assert!(p.can_access(&AccessContext {
657 has_purchased: true,
658 ..Default::default()
659 }));
660 }
661
662 #[test]
663 fn pwyw_access_denied() {
664 let p = PwywPricing {
665 min_cents: Some(500),
666 };
667 assert!(!p.can_access(&AccessContext::default()));
668 }
669
670 #[test]
671 fn pwyw_price_display_with_min() {
672 let p = PwywPricing {
673 min_cents: Some(500),
674 };
675 assert_eq!(p.price_display(SettlementCurrency::Usd), "From $5");
676 }
677
678 #[test]
679 fn pwyw_price_display_no_min() {
680 let p = PwywPricing { min_cents: None };
681 assert_eq!(
682 p.price_display(SettlementCurrency::Usd),
683 "Pay what you want"
684 );
685 }
686
687 #[test]
688 fn pwyw_price_display_zero_min() {
689 let p = PwywPricing { min_cents: Some(0) };
690 assert_eq!(
691 p.price_display(SettlementCurrency::Usd),
692 "Pay what you want"
693 );
694 }
695
696 #[test]
697 fn pwyw_validate_amount_ok() {
698 let p = PwywPricing {
699 min_cents: Some(500),
700 };
701 assert!(p.validate_amount(500, SettlementCurrency::Usd).is_ok());
702 assert!(p.validate_amount(1000, SettlementCurrency::Usd).is_ok());
703 }
704
705 #[test]
706 fn pwyw_validate_amount_too_low() {
707 let p = PwywPricing {
708 min_cents: Some(500),
709 };
710 assert!(p.validate_amount(400, SettlementCurrency::Usd).is_err());
711 }
712
713 #[test]
714 fn pwyw_validate_amount_zero_min() {
715 let p = PwywPricing { min_cents: Some(0) };
716 assert!(p.validate_amount(0, SettlementCurrency::Usd).is_ok());
717 }
718
719 #[test]
720 fn pwyw_minimum_cents() {
721 let p = PwywPricing {
722 min_cents: Some(500),
723 };
724 assert_eq!(p.minimum_cents(), Some(500));
725 }
726
727 #[test]
728 fn pwyw_price_cents_with_min() {
729 let p = PwywPricing {
730 min_cents: Some(500),
731 };
732 assert_eq!(p.price_cents(), 500);
733 }
734
735 #[test]
736 fn pwyw_price_cents_no_min() {
737 let p = PwywPricing { min_cents: None };
738 assert_eq!(p.price_cents(), 0);
739 }
740
741 #[test]
742 fn pwyw_kind() {
743 let p = PwywPricing { min_cents: None };
744 assert_eq!(p.kind(), db::PricingKind::Pwyw);
745 }
746
747 // ── SubscriptionPricing ──
748
749 #[test]
750 fn subscription_not_free() {
751 assert!(!SubscriptionPricing.is_free());
752 }
753
754 #[test]
755 fn subscription_access_creator() {
756 assert!(SubscriptionPricing.can_access(&AccessContext {
757 is_creator: true,
758 ..Default::default()
759 }));
760 }
761
762 #[test]
763 fn subscription_access_subscribed() {
764 assert!(SubscriptionPricing.can_access(&AccessContext {
765 subscription: Some(crate::db::subscriptions::SubscriptionGate::test_witness()),
766 ..Default::default()
767 }));
768 }
769
770 #[test]
771 fn subscription_access_purchased_not_enough() {
772 assert!(!SubscriptionPricing.can_access(&AccessContext {
773 has_purchased: true,
774 ..Default::default()
775 }));
776 }
777
778 #[test]
779 fn subscription_access_denied() {
780 assert!(!SubscriptionPricing.can_access(&AccessContext::default()));
781 }
782
783 #[test]
784 fn subscription_price_cents_is_zero() {
785 assert_eq!(SubscriptionPricing.price_cents(), 0);
786 }
787
788 #[test]
789 fn subscription_price_display() {
790 assert_eq!(
791 SubscriptionPricing.price_display(SettlementCurrency::Usd),
792 "Subscription"
793 );
794 }
795
796 #[test]
797 fn subscription_checkout_type() {
798 assert_eq!(
799 SubscriptionPricing.checkout_type(),
800 CheckoutType::Subscription
801 );
802 }
803
804 #[test]
805 fn subscription_validate_amount() {
806 assert!(
807 SubscriptionPricing
808 .validate_amount(100, SettlementCurrency::Usd)
809 .is_err()
810 );
811 }
812
813 #[test]
814 fn subscription_kind() {
815 assert_eq!(SubscriptionPricing.kind(), db::PricingKind::Subscription);
816 }
817
818 // ── Constructors ──
819
820 #[test]
821 fn for_item_free() {
822 let item = make_test_item(0, false, None);
823 let p = for_item(&item);
824 assert!(p.is_free());
825 assert_eq!(p.checkout_type(), CheckoutType::None);
826 }
827
828 #[test]
829 fn for_item_fixed() {
830 let item = make_test_item(999, false, None);
831 let p = for_item(&item);
832 assert!(!p.is_free());
833 assert_eq!(p.checkout_type(), CheckoutType::OneTime);
834 assert_eq!(p.price_cents(), 999);
835 }
836
837 #[test]
838 fn for_item_pwyw() {
839 let mut item = make_test_item(500, false, Some(100));
840 item.pwyw_enabled = true;
841 let p = for_item(&item);
842 assert!(!p.is_free());
843 assert_eq!(p.checkout_type(), CheckoutType::PayWhatYouWant);
844 assert_eq!(p.minimum_cents(), Some(100));
845 }
846
847 #[test]
848 fn for_project_free() {
849 let project = make_test_project(db::PricingKind::Free, 0, None);
850 let p = for_project(&project);
851 assert!(p.is_free());
852 }
853
854 #[test]
855 fn for_project_buy_once() {
856 let project = make_test_project(db::PricingKind::BuyOnce, 1999, None);
857 let p = for_project(&project);
858 assert!(!p.is_free());
859 assert_eq!(p.checkout_type(), CheckoutType::OneTime);
860 assert_eq!(p.price_cents(), 1999);
861 }
862
863 #[test]
864 fn for_project_pwyw() {
865 let project = make_test_project(db::PricingKind::Pwyw, 0, Some(500));
866 let p = for_project(&project);
867 assert!(!p.is_free());
868 assert_eq!(p.checkout_type(), CheckoutType::PayWhatYouWant);
869 }
870
871 #[test]
872 fn for_project_subscription() {
873 let project = make_test_project(db::PricingKind::Subscription, 0, None);
874 let p = for_project(&project);
875 assert!(!p.is_free());
876 assert_eq!(p.checkout_type(), CheckoutType::Subscription);
877 }
878
879 // ── Edge cases (test-fuzz) ──
880
881 #[test]
882 fn fixed_zero_cents_still_not_free() {
883 // FixedPricing with 0 cents: is_free is hardcoded false
884 let p = FixedPricing { price_cents: 0 };
885 assert!(!p.is_free());
886 assert_eq!(p.price_cents(), 0);
887 }
888
889 #[test]
890 fn fixed_negative_price_validate_amount() {
891 // Negative price_cents is semantically wrong but FixedPricing doesn't validate construction
892 let p = FixedPricing { price_cents: -100 };
893 // amount >= price_cents (-100), so 0 and -50 pass, but -200 fails
894 assert!(p.validate_amount(0, SettlementCurrency::Usd).is_ok());
895 assert!(p.validate_amount(-50, SettlementCurrency::Usd).is_ok());
896 assert!(p.validate_amount(-200, SettlementCurrency::Usd).is_err()); // -200 < -100
897 }
898
899 #[test]
900 fn pwyw_validate_amount_at_cap() {
901 let p = PwywPricing { min_cents: Some(0) };
902 assert!(
903 p.validate_amount(1_000_000, SettlementCurrency::Usd)
904 .is_ok()
905 ); // exactly $10,000
906 assert!(
907 p.validate_amount(1_000_001, SettlementCurrency::Usd)
908 .is_err()
909 ); // $10,000.01
910 }
911
912 #[test]
913 fn pwyw_validate_amount_negative() {
914 let p = PwywPricing { min_cents: Some(0) };
915 // Negative amount is below min (0), should fail
916 assert!(p.validate_amount(-1, SettlementCurrency::Usd).is_err());
917 }
918
919 #[test]
920 fn pwyw_negative_min_cents() {
921 // Negative min_cents is semantically wrong but PwywPricing doesn't validate
922 let p = PwywPricing {
923 min_cents: Some(-100),
924 };
925 // Negative amount still above negative min
926 assert!(p.validate_amount(-50, SettlementCurrency::Usd).is_ok());
927 }
928
929 #[test]
930 fn fixed_validate_amount_no_upper_cap() {
931 // FixedPricing has no $10k cap like PWYW does
932 let p = FixedPricing { price_cents: 100 };
933 assert!(
934 p.validate_amount(99_999_999, SettlementCurrency::Usd)
935 .is_ok()
936 );
937 }
938
939 #[test]
940 fn for_item_pwyw_zero_price_still_pwyw() {
941 // pwyw_enabled=true with price_cents=0 → PWYW, not Free
942 let mut item = make_test_item(0, false, None);
943 item.pwyw_enabled = true;
944 let p = for_item(&item);
945 assert!(!p.is_free());
946 assert_eq!(p.checkout_type(), CheckoutType::PayWhatYouWant);
947 }
948
949 #[test]
950 fn subscription_purchased_user_cannot_access() {
951 // Subscription items don't honor has_purchased (by design)
952 assert!(!SubscriptionPricing.can_access(&AccessContext {
953 has_purchased: true,
954 subscription: None,
955 is_creator: false,
956 }));
957 }
958
959 #[test]
960 fn free_minimum_cents_is_none() {
961 assert_eq!(FreePricing.minimum_cents(), None);
962 }
963
964 #[test]
965 fn fixed_minimum_cents_is_none() {
966 let p = FixedPricing { price_cents: 999 };
967 assert_eq!(p.minimum_cents(), None);
968 }
969
970 #[test]
971 fn subscription_minimum_cents_is_none() {
972 assert_eq!(SubscriptionPricing.minimum_cents(), None);
973 }
974
975 // ── Adversarial (test-fuzz) ──
976
977 #[test]
978 fn adversarial_pwyw_max_i32_amount() {
979 let p = PwywPricing { min_cents: Some(0) };
980 // i32::MAX = 2,147,483,647 cents = ~$21.4M, should be rejected by $10k cap
981 assert!(
982 p.validate_amount(i32::MAX, SettlementCurrency::Usd)
983 .is_err()
984 );
985 }
986
987 #[test]
988 fn adversarial_pwyw_min_i32_amount() {
989 let p = PwywPricing { min_cents: Some(0) };
990 assert!(
991 p.validate_amount(i32::MIN, SettlementCurrency::Usd)
992 .is_err()
993 );
994 }
995
996 #[test]
997 fn adversarial_fixed_price_i32_max() {
998 let p = FixedPricing {
999 price_cents: i32::MAX,
1000 };
1001 // validate_amount with exactly i32::MAX should pass
1002 assert!(p.validate_amount(i32::MAX, SettlementCurrency::Usd).is_ok());
1003 // Any amount below should fail
1004 assert!(
1005 p.validate_amount(i32::MAX - 1, SettlementCurrency::Usd)
1006 .is_err()
1007 );
1008 }
1009
1010 #[test]
1011 fn adversarial_all_access_flags_false() {
1012 let ctx = AccessContext {
1013 is_creator: false,
1014 has_purchased: false,
1015 subscription: None,
1016 };
1017 // Only FreePricing should grant access with no flags
1018 assert!(FreePricing.can_access(&ctx));
1019 assert!(!FixedPricing { price_cents: 100 }.can_access(&ctx));
1020 assert!(!PwywPricing { min_cents: None }.can_access(&ctx));
1021 assert!(!SubscriptionPricing.can_access(&ctx));
1022 }
1023
1024 #[test]
1025 fn adversarial_all_access_flags_true() {
1026 let ctx = AccessContext {
1027 is_creator: true,
1028 has_purchased: true,
1029 subscription: Some(crate::db::subscriptions::SubscriptionGate::test_witness()),
1030 };
1031 // All pricing models should grant access with all flags
1032 assert!(FreePricing.can_access(&ctx));
1033 assert!(FixedPricing { price_cents: 100 }.can_access(&ctx));
1034 assert!(PwywPricing { min_cents: None }.can_access(&ctx));
1035 assert!(SubscriptionPricing.can_access(&ctx));
1036 }
1037
1038 // ── Test helpers ──
1039
1040 fn make_test_item(
1041 price_cents: i32,
1042 pwyw_enabled: bool,
1043 pwyw_min_cents: Option<i32>,
1044 ) -> db::DbItem {
1045 db::DbItem {
1046 id: db::ItemId::nil(),
1047 project_id: db::ProjectId::nil(),
1048 title: "test".to_string(),
1049 description: None,
1050 price_cents,
1051 item_type: db::ItemType::Digital,
1052 thumbnail_url: None,
1053 is_public: true,
1054 sort_order: 0,
1055 created_at: chrono::Utc::now(),
1056 updated_at: chrono::Utc::now(),
1057 body: None,
1058 word_count: None,
1059 reading_time_minutes: None,
1060 audio_url: None,
1061 duration_seconds: None,
1062 cover_image_url: None,
1063 episode_number: None,
1064 audio_s3_key: None,
1065 cover_s3_key: None,
1066 enable_license_keys: false,
1067 default_max_activations: None,
1068 sales_count: 0,
1069 play_count: 0,
1070 unique_play_count: 0,
1071 download_count: 0,
1072 pwyw_enabled,
1073 pwyw_min_cents,
1074 scan_status: db::FileScanStatus::Clean,
1075 cover_scan_status: "clean".to_string(),
1076 release_announced_at: None,
1077 publish_at: None,
1078 mt_thread_id: None,
1079 web_only: false,
1080 audio_file_size_bytes: None,
1081 cover_file_size_bytes: None,
1082 video_s3_key: None,
1083 video_file_size_bytes: None,
1084 video_duration_seconds: None,
1085 video_width: None,
1086 video_height: None,
1087 slug: "test".to_string(),
1088 listed: true,
1089 license_preset: None,
1090 custom_license_text: None,
1091 ai_tier: db::AiTier::Handmade,
1092 ai_disclosure: None,
1093 removed_by_admin: false,
1094 removal_reason: None,
1095 removed_at: None,
1096 deleted_at: None,
1097 }
1098 }
1099
1100 fn make_test_project(
1101 pricing_model: db::PricingKind,
1102 price_cents: i32,
1103 pwyw_min_cents: Option<i32>,
1104 ) -> db::DbProject {
1105 db::DbProject {
1106 id: db::ProjectId::nil(),
1107 user_id: db::UserId::nil(),
1108 slug: db::Slug::from_trusted("test".to_string()),
1109 title: "Test Project".to_string(),
1110 description: None,
1111 project_type: db::ProjectType::General,
1112 cover_image_url: None,
1113 cover_scan_status: "clean".to_string(),
1114 theme_id: None,
1115 is_public: true,
1116 created_at: chrono::Utc::now(),
1117 updated_at: chrono::Utc::now(),
1118 cache_generation: 0,
1119 mt_community_id: None,
1120 features: vec![],
1121 pricing_model,
1122 price_cents,
1123 pwyw_min_cents,
1124 license_verification_enabled: false,
1125 ai_tier: db::AiTier::Handmade,
1126 ai_disclosure: None,
1127 custom_html: String::new(),
1128 custom_css: String::new(),
1129 custom_pages_updated_at: None,
1130 }
1131 }
1132
1133 // ── Edge cases: PWYW min exceeds cap (test-fuzz) ──
1134
1135 #[test]
1136 fn pwyw_min_above_cap_creates_impossible_range() {
1137 // If min_cents > 1_000_000, no valid amount exists:
1138 // amount must be >= min (1_000_001) AND <= 1_000_000, empty set.
1139 let p = PwywPricing {
1140 min_cents: Some(1_000_001),
1141 };
1142 // Any amount below min fails the min check
1143 assert!(
1144 p.validate_amount(1_000_000, SettlementCurrency::Usd)
1145 .is_err()
1146 );
1147 // Any amount at/above min fails the cap check
1148 assert!(
1149 p.validate_amount(1_000_001, SettlementCurrency::Usd)
1150 .is_err()
1151 );
1152 // Even i32::MAX fails
1153 assert!(
1154 p.validate_amount(i32::MAX, SettlementCurrency::Usd)
1155 .is_err()
1156 );
1157 }
1158
1159 #[test]
1160 fn pwyw_min_exactly_at_cap_allows_single_value() {
1161 // min_cents == 1_000_000: only amount == 1_000_000 should work
1162 let p = PwywPricing {
1163 min_cents: Some(1_000_000),
1164 };
1165 assert!(
1166 p.validate_amount(1_000_000, SettlementCurrency::Usd)
1167 .is_ok()
1168 );
1169 assert!(p.validate_amount(999_999, SettlementCurrency::Usd).is_err());
1170 assert!(
1171 p.validate_amount(1_000_001, SettlementCurrency::Usd)
1172 .is_err()
1173 );
1174 }
1175
1176 #[test]
1177 fn pwyw_none_min_allows_zero() {
1178 // min_cents = None → unwrap_or(0) → amount >= 0 required
1179 let p = PwywPricing { min_cents: None };
1180 assert!(p.validate_amount(0, SettlementCurrency::Usd).is_ok());
1181 assert!(p.validate_amount(-1, SettlementCurrency::Usd).is_err());
1182 assert!(
1183 p.validate_amount(1_000_000, SettlementCurrency::Usd)
1184 .is_ok()
1185 );
1186 assert!(
1187 p.validate_amount(1_000_001, SettlementCurrency::Usd)
1188 .is_err()
1189 );
1190 }
1191
1192 #[test]
1193 fn fixed_validate_amount_at_exact_boundary() {
1194 // Amount exactly equal to price should pass (not off-by-one)
1195 let p = FixedPricing { price_cents: 1 };
1196 assert!(p.validate_amount(1, SettlementCurrency::Usd).is_ok());
1197 assert!(p.validate_amount(0, SettlementCurrency::Usd).is_err());
1198 }
1199
1200 #[test]
1201 fn pwyw_access_subscribed() {
1202 // PwywPricing should grant access to subscribers (like FixedPricing)
1203 let p = PwywPricing {
1204 min_cents: Some(500),
1205 };
1206 assert!(p.can_access(&AccessContext {
1207 subscription: Some(crate::db::subscriptions::SubscriptionGate::test_witness()),
1208 ..Default::default()
1209 }));
1210 }
1211
1212 // ── Property-based tests (proptest) ──
1213
1214 proptest::proptest! {
1215 #[test]
1216 fn prop_free_always_accessible(
1217 is_creator in proptest::bool::ANY,
1218 has_purchased in proptest::bool::ANY,
1219 has_active_subscription in proptest::bool::ANY,
1220 ) {
1221 let ctx = AccessContext { is_creator, has_purchased, subscription: has_active_subscription.then(crate::db::subscriptions::SubscriptionGate::test_witness) };
1222 proptest::prop_assert!(FreePricing.can_access(&ctx));
1223 proptest::prop_assert_eq!(FreePricing.price_cents(), 0);
1224 }
1225
1226 #[test]
1227 fn prop_fixed_access_requires_flag(
1228 is_creator in proptest::bool::ANY,
1229 has_purchased in proptest::bool::ANY,
1230 has_active_subscription in proptest::bool::ANY,
1231 ) {
1232 let ctx = AccessContext { is_creator, has_purchased, subscription: has_active_subscription.then(crate::db::subscriptions::SubscriptionGate::test_witness) };
1233 let p = FixedPricing { price_cents: 999 };
1234 let expected = is_creator || has_purchased || has_active_subscription;
1235 proptest::prop_assert_eq!(p.can_access(&ctx), expected);
1236 }
1237
1238 #[test]
1239 fn prop_pwyw_access_requires_flag(
1240 is_creator in proptest::bool::ANY,
1241 has_purchased in proptest::bool::ANY,
1242 has_active_subscription in proptest::bool::ANY,
1243 ) {
1244 let ctx = AccessContext { is_creator, has_purchased, subscription: has_active_subscription.then(crate::db::subscriptions::SubscriptionGate::test_witness) };
1245 let p = PwywPricing { min_cents: Some(500) };
1246 let expected = is_creator || has_purchased || has_active_subscription;
1247 proptest::prop_assert_eq!(p.can_access(&ctx), expected);
1248 }
1249
1250 #[test]
1251 fn prop_subscription_ignores_purchased(
1252 is_creator in proptest::bool::ANY,
1253 has_purchased in proptest::bool::ANY,
1254 has_active_subscription in proptest::bool::ANY,
1255 ) {
1256 let ctx = AccessContext { is_creator, has_purchased, subscription: has_active_subscription.then(crate::db::subscriptions::SubscriptionGate::test_witness) };
1257 // Subscription only honors is_creator and has_active_subscription
1258 let expected = is_creator || has_active_subscription;
1259 proptest::prop_assert_eq!(SubscriptionPricing.can_access(&ctx), expected);
1260 }
1261
1262 #[test]
1263 fn prop_fixed_validate_amount_consistent(price in 0..=1_000_000i32, amount in -100_000..=2_000_000i32) {
1264 let p = FixedPricing { price_cents: price };
1265 let result = p.validate_amount(amount, SettlementCurrency::Usd);
1266 if amount >= price {
1267 proptest::prop_assert!(result.is_ok());
1268 } else {
1269 proptest::prop_assert!(result.is_err());
1270 }
1271 }
1272
1273 #[test]
1274 fn prop_pwyw_validate_enforces_min_and_cap(min in 0..=1_100_000i32, amount in -1_000..=1_100_000i32) {
1275 let p = PwywPricing { min_cents: Some(min) };
1276 let result = p.validate_amount(amount, SettlementCurrency::Usd);
1277 if amount >= min && amount <= 1_000_000 {
1278 proptest::prop_assert!(result.is_ok());
1279 } else {
1280 proptest::prop_assert!(result.is_err());
1281 }
1282 }
1283
1284 #[test]
1285 fn prop_subscription_never_direct_purchase(amount in proptest::num::i32::ANY) {
1286 proptest::prop_assert!(SubscriptionPricing.validate_amount(amount, SettlementCurrency::Usd).is_err());
1287 }
1288 }
1289 }
1290