Skip to main content

max / makenotwork

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