Skip to main content

max / makenotwork

State the minimum a PWYW buyer can actually pay, not the creator's A creator can set a pay-what-you-want minimum below Stripe's floor for their settlement currency, and every screen repeated that number: the paywall box's min, the "From $0.25" card, the item purchase and buy pages. Typing 0.25 cleared the browser and was refused after submit by check_min_charge, whose message names a minimum the buyer was never shown and reads as the creator having mispriced the project. PricingModel::chargeable_minimum_cents is the one floor now: the larger of the creator's minimum and the settlement currency's, which is 50c in USD and 30p in GBP rather than a flat 50. price_display, validate_amount, and the three amount boxes all read it, so the number on the screen is the number the charge path enforces. A $0 claim is untouched, since it never reaches Stripe. Where the creator set no minimum the domain has a hole -- $0 or the floor and up, nothing between -- which a min attribute cannot draw, so a note under the box carries it. A property test walks every amount either side of both floors and asserts the box and validate_amount agree. The paywall's unit span said USD for every creator; it now says the creator's own settlement currency, as does the purchase page's guest branch.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session
https://claude.ai/code/session_01MptwXZ8k65v19rFmdGAyki
Author: Max Johnson <me@maxj.phd> · 2026-09-01 14:16 UTC
Signed with PGP, not checked
Commit: 1d0f0941c97c0296a9a32eff58cb5ce439d848a9
Parent: 834c82f
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 }