Skip to main content

max / makenotwork

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