max / makenotwork
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
- Claude-Session
- https://claude.ai/code/session_01MptwXZ8k65v19rFmdGAyki
6 files changed,
+301 insertions,
-21 deletions
| @@ -232,6 +232,24 @@ | |||
| 232 | 232 | None | |
| 233 | 233 | } | |
| 234 | 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 | + | ||
| 235 | 253 | /// What checkout flow this pricing requires. | |
| 236 | 254 | fn checkout_type(&self) -> CheckoutType; | |
| 237 | 255 | ||
| @@ -355,7 +373,13 @@ | |||
| 355 | 373 | ||
| 356 | 374 | fn price_display(&self, currency: SettlementCurrency) -> String { | |
| 357 | 375 | match self.min_cents { | |
| 358 | - | Some(min) if min > 0 => format!("From {}", helpers::format_price(min, currency)), | |
| 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 | + | ), | |
| 359 | 383 | _ => "Pay what you want".to_string(), | |
| 360 | 384 | } | |
| 361 | 385 | } | |
| @@ -377,11 +401,20 @@ | |||
| 377 | 401 | amount_cents: i32, | |
| 378 | 402 | currency: SettlementCurrency, | |
| 379 | 403 | ) -> Result<(), String> { | |
| 380 | - | let min = self.min_cents.unwrap_or(0); | |
| 381 | - | if amount_cents < min { | |
| 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 { | |
| 382 | 415 | return Err(format!( | |
| 383 | 416 | "Amount must be at least {}", | |
| 384 | - | crate::formatting::format_revenue(min as i64, currency) | |
| 417 | + | crate::formatting::format_revenue(i64::from(floor), currency) | |
| 385 | 418 | )); | |
| 386 | 419 | } | |
| 387 | 420 | // Cap at $10,000 (same ceiling as tips) to prevent accidental mega-charges | |
| @@ -716,6 +749,71 @@ | |||
| 716 | 749 | assert!(p.validate_amount(0, SettlementCurrency::Usd).is_ok()); | |
| 717 | 750 | } | |
| 718 | 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 | + | ||
| 719 | 817 | #[test] | |
| 720 | 818 | fn pwyw_minimum_cents() { | |
| 721 | 819 | let p = PwywPricing { | |
| @@ -918,12 +1016,15 @@ | |||
| 918 | 1016 | ||
| 919 | 1017 | #[test] | |
| 920 | 1018 | fn pwyw_negative_min_cents() { | |
| 921 | - | // Negative min_cents is semantically wrong but PwywPricing doesn't validate | |
| 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. | |
| 922 | 1023 | let p = PwywPricing { | |
| 923 | 1024 | min_cents: Some(-100), | |
| 924 | 1025 | }; | |
| 925 | - | // Negative amount still above negative min | |
| 926 | - | assert!(p.validate_amount(-50, SettlementCurrency::Usd).is_ok()); | |
| 1026 | + | assert!(p.validate_amount(-50, SettlementCurrency::Usd).is_err()); | |
| 1027 | + | assert!(p.validate_amount(500, SettlementCurrency::Usd).is_ok()); | |
| 927 | 1028 | } | |
| 928 | 1029 | ||
| 929 | 1030 | #[test] |
| @@ -61,8 +61,11 @@ | |||
| 61 | 61 | <div class="form-group form-group--centered-narrow"> | |
| 62 | 62 | <label for="amount">Your price</label> | |
| 63 | 63 | <input type="number" name="amount_dollars" id="amount" min="{{ pwyw_min_dollars }}" step="0.01" placeholder="0.00" | |
| 64 | - | aria-describedby="amount-unit"> | |
| 65 | - | <span class="form-unit" id="amount-unit">USD</span> | |
| 64 | + | aria-describedby="amount-unit{% if pwyw_min_note.is_some() %} amount-min{% endif %}"> | |
| 65 | + | <span class="form-unit" id="amount-unit">{{ pwyw_currency_code }}</span> | |
| 66 | + | {% if let Some(note) = pwyw_min_note %} | |
| 67 | + | <p class="form-hint" id="amount-min">{{ note }}</p> | |
| 68 | + | {% endif %} | |
| 66 | 69 | </div> | |
| 67 | 70 | <button type="submit" class="btn-primary" data-loading-text="Redirecting to Stripe...">Purchase Access</button> | |
| 68 | 71 | </form> |
| @@ -111,6 +111,8 @@ | |||
| 111 | 111 | </div> | |
| 112 | 112 | {% if pwyw_min_cents > 0 %} | |
| 113 | 113 | <p class="pwyw-min-note">Minimum: {{ currency_symbol }}{{ pwyw_min_dollars }}</p> | |
| 114 | + | {% else if let Some(note) = pwyw_min_note %} | |
| 115 | + | <p class="pwyw-min-note">{{ note }}</p> | |
| 114 | 116 | {% endif %} | |
| 115 | 117 | </div> | |
| 116 | 118 | {% endif %} | |
| @@ -140,7 +142,9 @@ | |||
| 140 | 142 | <input type="number" id="guest_pwyw_amount" class="pwyw-input" value="{{ suggested_price }}" min="{{ pwyw_min_dollars }}" step="0.01"> | |
| 141 | 143 | </div> | |
| 142 | 144 | {% if pwyw_min_cents > 0 %} | |
| 143 | - | <p class="pwyw-min-note">Minimum: ${{ pwyw_min_dollars }}</p> | |
| 145 | + | <p class="pwyw-min-note">Minimum: {{ currency_symbol }}{{ pwyw_min_dollars }}</p> | |
| 146 | + | {% else if let Some(note) = pwyw_min_note %} | |
| 147 | + | <p class="pwyw-min-note">{{ note }}</p> | |
| 144 | 148 | {% endif %} | |
| 145 | 149 | </div> | |
| 146 | 150 | {% endif %} |
| @@ -398,9 +398,23 @@ | |||
| 398 | 398 | pub price_display: String, | |
| 399 | 399 | /// What kind of checkout flow is needed. | |
| 400 | 400 | pub checkout_type: crate::pricing::CheckoutType, | |
| 401 | - | /// The pay-what-you-want minimum as a plain decimal ("0.00", "9.99"), for | |
| 402 | - | /// the `min` on the dollars input. Zero for every other checkout type. | |
| 401 | + | /// The lowest amount the dollars input will accept, as a plain decimal | |
| 402 | + | /// ("0.00", "9.99"). This is the *chargeable* minimum | |
| 403 | + | /// ([`crate::pricing::PricingModel::chargeable_minimum_cents`]), so a | |
| 404 | + | /// creator's sub-floor minimum is raised to what Stripe will settle rather | |
| 405 | + | /// than being promised to the buyer and refused after they submit. Zero | |
| 406 | + | /// for every other checkout type. | |
| 403 | 407 | pub pwyw_min_dollars: String, | |
| 408 | + | /// The line under the box, when the field's `min` cannot state the whole | |
| 409 | + | /// rule on its own: a project with no minimum accepts $0 or the floor and | |
| 410 | + | /// up, and nothing in between, which is a hole `min` has no way to draw. | |
| 411 | + | /// `None` when `min` says it all. | |
| 412 | + | pub pwyw_min_note: Option<String>, | |
| 413 | + | /// Uppercase ISO code for the unit beside the amount. The creator's | |
| 414 | + | /// settlement currency, not a hardcoded USD: the charge lands in their | |
| 415 | + | /// currency, so labelling a GBP creator's box USD misstates what the buyer | |
| 416 | + | /// is about to pay. | |
| 417 | + | pub pwyw_currency_code: &'static str, | |
| 404 | 418 | /// Available subscription tiers (for subscription-model projects). | |
| 405 | 419 | pub subscription_tiers: Vec<SubscriptionTier>, | |
| 406 | 420 | /// Base URL for OG meta tags. | |
| @@ -714,12 +728,21 @@ | |||
| 714 | 728 | pub promo_code: String, | |
| 715 | 729 | /// Whether PWYW pricing is enabled for this item. | |
| 716 | 730 | pub pwyw_enabled: bool, | |
| 717 | - | /// Minimum price in cents when PWYW is enabled. | |
| 731 | + | /// The `min` on the amount box, in cents, and the hidden field's starting | |
| 732 | + | /// value. The chargeable floor | |
| 733 | + | /// ([`crate::pricing::PricingModel::chargeable_minimum_cents`]) rather than | |
| 734 | + | /// the creator's raw minimum, so the box never accepts an amount checkout | |
| 735 | + | /// will refuse; zero when the creator set no minimum, where a $0 claim is | |
| 736 | + | /// a real outcome. | |
| 718 | 737 | pub pwyw_min_cents: i32, | |
| 719 | 738 | /// Formatted suggested price in dollars (e.g. "9.99"). | |
| 720 | 739 | pub suggested_price: String, | |
| 721 | - | /// Formatted minimum price in dollars (e.g. "1.00"). | |
| 740 | + | /// `pwyw_min_cents` as a plain decimal, for the `min` attribute. | |
| 722 | 741 | pub pwyw_min_dollars: String, | |
| 742 | + | /// The line under the box when `min` cannot state the whole rule: an item | |
| 743 | + | /// with no minimum takes $0 or the currency's floor and up, with a hole | |
| 744 | + | /// between. `None` when `min` says it all. | |
| 745 | + | pub pwyw_min_note: Option<String>, | |
| 723 | 746 | /// Whether the creator has Stripe Tax enabled. | |
| 724 | 747 | pub stripe_tax_enabled: bool, | |
| 725 | 748 | /// Whether the current visitor is logged in (show guest checkout if not). |
| @@ -213,8 +213,13 @@ | |||
| 213 | 213 | ); | |
| 214 | 214 | ||
| 215 | 215 | let suggested_price = crate::formatting::format_dollars_plain(db_item.price_cents); | |
| 216 | - | let pwyw_min = db_item.pwyw_min_cents.unwrap_or(0); | |
| 217 | - | let pwyw_min_dollars = crate::formatting::format_dollars_plain(pwyw_min); | |
| 216 | + | // The floor the charge path will accept, not the creator's raw minimum: | |
| 217 | + | // a sub-floor minimum here would put an amount in the box that Stripe | |
| 218 | + | // refuses after the buyer has typed it. Same rule the project paywall uses. | |
| 219 | + | let item_pricing = crate::pricing::for_item(&db_item); | |
| 220 | + | let (pwyw_min_dollars, pwyw_min_note) = | |
| 221 | + | pwyw_field_bounds(item_pricing.as_ref(), db_user.settlement_currency); | |
| 222 | + | let pwyw_min = pwyw_field_min_cents(item_pricing.as_ref(), db_user.settlement_currency); | |
| 218 | 223 | ||
| 219 | 224 | let pending_started = if let Some(ref u) = maybe_user { | |
| 220 | 225 | match db::transactions::get_pending_item_purchase(&db, u.id, id).await? { | |
| @@ -238,6 +243,7 @@ | |||
| 238 | 243 | pwyw_min_cents: pwyw_min, | |
| 239 | 244 | suggested_price, | |
| 240 | 245 | pwyw_min_dollars, | |
| 246 | + | pwyw_min_note, | |
| 241 | 247 | stripe_tax_enabled: db_user.stripe_tax_enabled, | |
| 242 | 248 | is_logged_in, | |
| 243 | 249 | pending_started, | |
| @@ -381,8 +387,10 @@ | |||
| 381 | 387 | ); | |
| 382 | 388 | ||
| 383 | 389 | let suggested_price = crate::formatting::format_dollars_plain(db_item.price_cents); | |
| 384 | - | let pwyw_min = db_item.pwyw_min_cents.unwrap_or(0); | |
| 385 | - | let pwyw_min_dollars = crate::formatting::format_dollars_plain(pwyw_min); | |
| 390 | + | let pwyw_min_dollars = crate::formatting::format_dollars_plain(pwyw_field_min_cents( | |
| 391 | + | crate::pricing::for_item(&db_item).as_ref(), | |
| 392 | + | db_user.settlement_currency, | |
| 393 | + | )); | |
| 386 | 394 | ||
| 387 | 395 | Ok(BuyPageTemplate { | |
| 388 | 396 | item, | |
| @@ -395,3 +403,142 @@ | |||
| 395 | 403 | host_url: config.host_url.clone(), | |
| 396 | 404 | }) | |
| 397 | 405 | } | |
| 406 | + | ||
| 407 | + | /// What the paywall's amount box will accept: the `min` attribute, and the line | |
| 408 | + | /// under it when `min` cannot say the whole rule. | |
| 409 | + | /// | |
| 410 | + | /// The domain is not always an interval. A project whose creator set no | |
| 411 | + | /// minimum accepts a $0 claim *or* a real charge at the settlement currency's | |
| 412 | + | /// floor and up, with a hole between them: 25c is neither free nor chargeable. | |
| 413 | + | /// `min="0"` alone would wave that through to a server-side refusal after the | |
| 414 | + | /// buyer has typed an amount, and `min="0.50"` alone would hide the free claim | |
| 415 | + | /// the creator is offering. So the attribute keeps the low end and the note | |
| 416 | + | /// carries the hole. | |
| 417 | + | /// | |
| 418 | + | /// A creator who did set a minimum has no hole: the floor is | |
| 419 | + | /// `max(their minimum, the currency's)`, one interval, and the `min` attribute | |
| 420 | + | /// states it without help. | |
| 421 | + | pub(super) fn pwyw_field_bounds( | |
| 422 | + | model: &dyn crate::pricing::PricingModel, | |
| 423 | + | currency: crate::currency::SettlementCurrency, | |
| 424 | + | ) -> (String, Option<String>) { | |
| 425 | + | use crate::formatting::{format_dollars_plain, format_revenue}; | |
| 426 | + | ||
| 427 | + | let field_min = pwyw_field_min_cents(model, currency); | |
| 428 | + | if field_min > 0 || model.checkout_type() != crate::pricing::CheckoutType::PayWhatYouWant { | |
| 429 | + | return (format_dollars_plain(field_min), None); | |
| 430 | + | } | |
| 431 | + | ||
| 432 | + | ( | |
| 433 | + | format_dollars_plain(0), | |
| 434 | + | Some(format!( | |
| 435 | + | "Pay {}, or {} and up.", | |
| 436 | + | format_revenue(0, currency), | |
| 437 | + | format_revenue( | |
| 438 | + | i64::from(model.chargeable_minimum_cents(currency)), | |
| 439 | + | currency | |
| 440 | + | ) | |
| 441 | + | )), | |
| 442 | + | ) | |
| 443 | + | } | |
| 444 | + | ||
| 445 | + | /// The `min` attribute for a pay-what-you-want amount box, in cents. | |
| 446 | + | /// | |
| 447 | + | /// Zero for a project or item whose creator set no minimum, because a $0 claim | |
| 448 | + | /// is a real outcome there and the attribute is the only thing standing between | |
| 449 | + | /// the buyer and it. Otherwise the chargeable floor, which is the creator's | |
| 450 | + | /// minimum raised to what the settlement currency will settle. | |
| 451 | + | pub(super) fn pwyw_field_min_cents( | |
| 452 | + | model: &dyn crate::pricing::PricingModel, | |
| 453 | + | currency: crate::currency::SettlementCurrency, | |
| 454 | + | ) -> i32 { | |
| 455 | + | if model.checkout_type() != crate::pricing::CheckoutType::PayWhatYouWant | |
| 456 | + | || model.minimum_cents().unwrap_or(0) <= 0 | |
| 457 | + | { | |
| 458 | + | return 0; | |
| 459 | + | } | |
| 460 | + | model.chargeable_minimum_cents(currency) | |
| 461 | + | } | |
| 462 | + | ||
| 463 | + | #[cfg(test)] | |
| 464 | + | mod tests { | |
| 465 | + | //! The paywall's amount box, whose bounds have to match what the charge | |
| 466 | + | //! path will actually accept. A box that accepts an amount checkout then | |
| 467 | + | //! refuses spends the buyer's typing and hands back an error that reads as | |
| 468 | + | //! the creator's price being wrong. | |
| 469 | + | ||
| 470 | + | use super::*; | |
| 471 | + | use crate::currency::SettlementCurrency::{Gbp, Usd}; | |
| 472 | + | use crate::pricing::{PricingModel, PwywPricing}; | |
| 473 | + | ||
| 474 | + | fn bounds( | |
| 475 | + | min_cents: Option<i32>, | |
| 476 | + | currency: crate::currency::SettlementCurrency, | |
| 477 | + | ) -> (String, Option<String>) { | |
| 478 | + | pwyw_field_bounds(&PwywPricing { min_cents }, currency) | |
| 479 | + | } | |
| 480 | + | ||
| 481 | + | #[test] | |
| 482 | + | fn a_sub_floor_creator_minimum_is_raised_to_what_stripe_settles() { | |
| 483 | + | // 25c is a price no card network will move. The box says 50c. | |
| 484 | + | assert_eq!(bounds(Some(25), Usd).0, "0.50"); | |
| 485 | + | // And the floor is the currency's, not a flat 50: GBP settles at 30p. | |
| 486 | + | assert_eq!(bounds(Some(25), Gbp).0, "0.30"); | |
| 487 | + | } | |
| 488 | + | ||
| 489 | + | #[test] | |
| 490 | + | fn a_creator_minimum_above_the_floor_is_left_alone() { | |
| 491 | + | assert_eq!(bounds(Some(999), Usd).0, "9.99"); | |
| 492 | + | assert_eq!(bounds(Some(999), Gbp).0, "9.99"); | |
| 493 | + | } | |
| 494 | + | ||
| 495 | + | #[test] | |
| 496 | + | fn a_stated_minimum_needs_no_note_because_min_says_it_all() { | |
| 497 | + | assert_eq!(bounds(Some(999), Usd).1, None); | |
| 498 | + | assert_eq!(bounds(Some(25), Usd).1, None); | |
| 499 | + | } | |
| 500 | + | ||
| 501 | + | #[test] | |
| 502 | + | fn no_minimum_keeps_the_free_claim_and_names_the_hole() { | |
| 503 | + | let (min, note) = bounds(None, Usd); | |
| 504 | + | assert_eq!(min, "0.00", "a $0 claim must stay reachable"); | |
| 505 | + | assert_eq!(note.as_deref(), Some("Pay $0.00, or $0.50 and up.")); | |
| 506 | + | } | |
| 507 | + | ||
| 508 | + | #[test] | |
| 509 | + | fn the_note_is_denominated_in_the_creators_currency() { | |
| 510 | + | let (_, note) = bounds(Some(0), Gbp); | |
| 511 | + | assert_eq!(note.as_deref(), Some("Pay £0.00, or £0.30 and up.")); | |
| 512 | + | } | |
| 513 | + | ||
| 514 | + | #[test] | |
| 515 | + | fn a_non_pwyw_project_gets_no_bounds_at_all() { | |
| 516 | + | // The box is not drawn for these, so the fields carry inert values | |
| 517 | + | // rather than a floor that would be wrong if a template ever read it. | |
| 518 | + | let fixed = crate::pricing::FixedPricing { price_cents: 1999 }; | |
| 519 | + | assert_eq!(pwyw_field_bounds(&fixed, Usd), ("0.00".to_string(), None)); | |
| 520 | + | } | |
| 521 | + | ||
| 522 | + | #[test] | |
| 523 | + | fn the_box_and_the_charge_path_agree_on_every_amount() { | |
| 524 | + | // The property the whole fix exists for: the rule the buyer is shown | |
| 525 | + | // and the rule the charge path enforces are the same rule. "Shown" | |
| 526 | + | // means the attribute plus the note, because the free-claim hole | |
| 527 | + | // cannot live in a `min`. Walked in cents either side of both floors. | |
| 528 | + | for min_cents in [None, Some(0), Some(1), Some(25), Some(50), Some(120)] { | |
| 529 | + | let model = PwywPricing { min_cents }; | |
| 530 | + | let (field_min, note) = pwyw_field_bounds(&model, Usd); | |
| 531 | + | let field_min_cents = (field_min.parse::<f64>().unwrap() * 100.0).round() as i32; | |
| 532 | + | let free_claim_offered = note.is_some(); | |
| 533 | + | for amount in 0..=200 { | |
| 534 | + | let field_accepts = | |
| 535 | + | amount >= field_min_cents && !(free_claim_offered && (1..50).contains(&amount)); | |
| 536 | + | let charge_accepts = model.validate_amount(amount, Usd).is_ok(); | |
| 537 | + | assert_eq!( | |
| 538 | + | field_accepts, charge_accepts, | |
| 539 | + | "min_cents {min_cents:?}, amount {amount}: box says {field_accepts}, charge path says {charge_accepts}" | |
| 540 | + | ); | |
| 541 | + | } | |
| 542 | + | } | |
| 543 | + | } | |
| 544 | + | } |
| @@ -106,6 +106,8 @@ | |||
| 106 | 106 | 0 | |
| 107 | 107 | }); | |
| 108 | 108 | let project = Project::from_db(db_project, item_count.max(0) as u32); | |
| 109 | + | let (pwyw_field_min, pwyw_note) = | |
| 110 | + | super::pwyw_field_bounds(project_pricing.as_ref(), db_user.settlement_currency); | |
| 109 | 111 | return Ok(ProjectPaywallTemplate { | |
| 110 | 112 | csrf_token, | |
| 111 | 113 | session_user: maybe_user, | |
| @@ -113,9 +115,9 @@ | |||
| 113 | 115 | creator_username: db_user.username.to_string(), | |
| 114 | 116 | price_display: project_pricing.price_display(db_user.settlement_currency), | |
| 115 | 117 | checkout_type: project_pricing.checkout_type(), | |
| 116 | - | pwyw_min_dollars: crate::formatting::format_dollars_plain( | |
| 117 | - | project_pricing.minimum_cents().unwrap_or(0), | |
| 118 | - | ), | |
| 118 | + | pwyw_min_dollars: pwyw_field_min, | |
| 119 | + | pwyw_min_note: pwyw_note, | |
| 120 | + | pwyw_currency_code: db_user.settlement_currency.code_upper(), | |
| 119 | 121 | subscription_tiers, | |
| 120 | 122 | host_url: config.host_url.clone(), | |
| 121 | 123 | } |