Skip to main content

max / makenotwork

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