Skip to main content

max / makenotwork

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