Skip to main content

max / makenotwork

Refuse a PWYW minimum no buyer could pay, at the point it is set 1d0f0941 clamped a sub-floor minimum wherever one was read, which keeps the existing rows honest but lets a new one be written. This is the other half: PriceCents::pwyw_minimum, the sibling of buy_once, applied at every path that writes a minimum -- the project wizard, the item wizard, the project pricing API, the item update API, and the internal creator API. Zero stays valid, because it is the "no minimum" setting rather than a price: the project is offered free to whoever asks and a $0 claim never reaches Stripe. A non-zero minimum is a promise the buyer is held to, so it has to clear the settlement currency's floor. The two API routes were validating through PriceCents::new, which is currency-blind by design -- serde sees a number before it sees whose it is -- so both re-check against the creator's currency where the creator is known. The internal route reads the owner's currency only when a minimum is present, since its actor is a service rather than a session. A creator whose stored minimum is already below the floor will be refused on their next save of that form until they change the field. The message names both ways out in their own currency, and the price was uncollectable already.
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:27 UTC
Signed with PGP, not checked
Commit: 51a008763f85f8d0fd5aba6c17ad8c500decc7f1
Parent: 889992d
6 files changed, +94 insertions, -6 deletions
@@ -501,6 +501,39 @@
501 501 Ok(pc)
502 502 }
503 503
504 + /// Validate a pay-what-you-want minimum against the creator's settlement
505 + /// currency.
506 + ///
507 + /// Zero is the "no minimum" setting and always valid: a project or item
508 + /// with no minimum is offered free to whoever asks, and a $0 claim never
509 + /// reaches Stripe. A *non-zero* minimum is a promise the buyer will be held
510 + /// to, so it has to be an amount a card can actually move, which is
511 + /// [`crate::currency::SettlementCurrency::minimum_charge_cents`].
512 + ///
513 + /// Without this a creator could save a 25c minimum, and every screen would
514 + /// repeat 25c at a buyer whose 25c payment checkout then refused. Read-time
515 + /// clamping keeps those rows honest
516 + /// ([`crate::pricing::PricingModel::chargeable_minimum_cents`]); this is
517 + /// the other half, so no new one is written.
518 + pub fn pwyw_minimum(
519 + cents: i32,
520 + currency: crate::currency::SettlementCurrency,
521 + ) -> std::result::Result<Self, crate::error::AppError> {
522 + if cents == 0 {
523 + return Ok(Self::ZERO);
524 + }
525 + let pc = Self::new_in(cents, currency)?;
526 + let floor = currency.minimum_charge_cents();
527 + if i64::from(cents) < floor {
528 + return Err(crate::error::AppError::validation(format!(
529 + "A minimum of {} cannot be charged. Use 0 for no minimum, or {} and up.",
530 + crate::formatting::format_revenue(i64::from(cents), currency),
531 + crate::formatting::format_revenue(floor, currency)
532 + )));
533 + }
534 + Ok(pc)
535 + }
536 +
504 537 /// Wrap a value from the database without validation.
505 538 pub fn from_db(cents: i32) -> Self {
506 539 Self(cents)
@@ -662,6 +695,38 @@
662 695 assert!(PriceCents::buy_once(29, C::Gbp).is_err());
663 696 }
664 697
698 + #[test]
699 + fn pwyw_minimum_keeps_zero_and_refuses_an_uncollectable_floor() {
700 + use crate::currency::SettlementCurrency as C;
701 + // Zero is "no minimum", not a price, so no floor applies to it.
702 + for c in C::ALL {
703 + assert!(PriceCents::pwyw_minimum(0, c).is_ok(), "{c}");
704 + }
705 + // A non-zero minimum is a promise the buyer is held to, so it has to be
706 + // collectable. 35p is; 35 cents is not.
707 + assert!(PriceCents::pwyw_minimum(35, C::Gbp).is_ok());
708 + for c in C::ALL.into_iter().filter(|c| *c != C::Gbp) {
709 + assert!(PriceCents::pwyw_minimum(35, c).is_err(), "{c}");
710 + assert!(PriceCents::pwyw_minimum(50, c).is_ok(), "{c}");
711 + }
712 + // And the cap and the sign checks still apply.
713 + assert!(PriceCents::pwyw_minimum(-1, C::Usd).is_err());
714 + assert!(PriceCents::pwyw_minimum(1_000_001, C::Usd).is_err());
715 + }
716 +
717 + #[test]
718 + fn a_refused_pwyw_minimum_says_what_to_type_instead() {
719 + use crate::currency::SettlementCurrency as C;
720 + let msg = PriceCents::pwyw_minimum(25, C::Usd)
721 + .unwrap_err()
722 + .to_string();
723 + // Both ways out, in the creator's own money: the setting that means no
724 + // minimum, and the smallest one that can be charged.
725 + assert!(msg.contains("$0.25"), "{msg}");
726 + assert!(msg.contains("Use 0 for no minimum"), "{msg}");
727 + assert!(msg.contains("$0.50"), "{msg}");
728 + }
729 +
665 730 #[test]
666 731 fn price_errors_are_denominated_in_the_creators_currency() {
667 732 use crate::currency::SettlementCurrency as C;
@@ -287,7 +287,10 @@
287 287 let pwyw_min_cents = if kind == db::PricingKind::Pwyw {
288 288 let dollars = req.pwyw_min_dollars.unwrap_or(0.0);
289 289 let cents = crate::pricing::validate_dollars_f64("pwyw_min_dollars", dollars)?;
290 - Some(db::PriceCents::new(cents)?)
290 + Some(db::PriceCents::pwyw_minimum(
291 + cents,
292 + user.settlement_currency,
293 + )?)
291 294 } else {
292 295 None
293 296 };
@@ -218,6 +218,19 @@
218 218 validation::validate_item_title(title)?;
219 219 }
220 220
221 + // A PWYW minimum's floor is the owner's settlement currency's, and this
222 + // route's actor is a service rather than a session, so the currency is not
223 + // already in hand. Read it only when a minimum is actually being set.
224 + let pwyw_min_cents = match req.pwyw_min_cents {
225 + Some(cents) => {
226 + let owner = db::users::get_user_by_id(&db, project.user_id)
227 + .await?
228 + .ok_or(AppError::NotFound)?;
229 + Some(PriceCents::pwyw_minimum(cents, owner.settlement_currency)?)
230 + }
231 + None => None,
232 + };
233 +
221 234 // Build ai_disclosure double-Option
222 235 let ai_disclosure: Option<Option<&str>> = if let Some(ai_tier) = req.ai_tier {
223 236 match ai_tier {
@@ -240,7 +253,7 @@
240 253 None, // item_type
241 254 req.is_public,
242 255 req.pwyw_enabled,
243 - req.pwyw_min_cents.map(PriceCents::new).transpose()?,
256 + pwyw_min_cents,
244 257 None, // publish_at
245 258 None, // web_only
246 259 req.ai_tier,
@@ -196,7 +196,14 @@
196 196 if let Some(ref desc) = req.description {
197 197 validation::validate_item_description(desc)?;
198 198 }
199 - // price_cents and pwyw_min_cents validated on deserialization via PriceCents
199 + // price_cents is validated on deserialization via PriceCents, which is
200 + // currency-blind: it sees a number before it sees whose it is. A PWYW
201 + // minimum has a floor that depends on the creator's settlement currency, so
202 + // it is re-checked here, where the creator is known.
203 + let pwyw_min_cents = req
204 + .pwyw_min_cents
205 + .map(|c| PriceCents::pwyw_minimum(c.as_i32(), user.settlement_currency))
206 + .transpose()?;
200 207
201 208 // Convert checkbox value: "on" = enabled, "off" = disabled, absent = no change
202 209 let pwyw_enabled = req.pwyw_enabled.as_deref().map(|v| v == "on");
@@ -251,7 +258,7 @@
251 258 req.item_type,
252 259 is_public,
253 260 pwyw_enabled,
254 - req.pwyw_min_cents,
261 + pwyw_min_cents,
255 262 publish_at,
256 263 req.web_only,
257 264 req.ai_tier,
@@ -360,7 +360,7 @@
360 360 "Minimum price",
361 361 form.get("pwyw_min_dollars").map(String::as_str),
362 362 )?;
363 - Some(db::PriceCents::new(raw)?)
363 + Some(db::PriceCents::pwyw_minimum(raw, user.settlement_currency)?)
364 364 } else {
365 365 None
366 366 };
@@ -225,7 +225,7 @@
225 225 ));
226 226 }
227 227 let suggested = PriceCents::new_in(suggested_cents, user.settlement_currency)?;
228 - let min = PriceCents::new_in(min_cents, user.settlement_currency)?;
228 + let min = PriceCents::pwyw_minimum(min_cents, user.settlement_currency)?;
229 229 db::items::update_item(
230 230 db,
231 231 item.id,