Skip to main content

max / makenotwork

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