Skip to main content

max / makenotwork

Hold a price to its own currency's Stripe minimum, not to USD's PriceCents still enforced a flat 50-cent floor and a $10,000 cap with dollar-signed messages, so a GBP creator could not price at 35p (Stripe's GBP minimum is 30) and read every rejection in someone else's money. buy_once and the new new_in take the creator's settlement currency, take the floor from SettlementCurrency::minimum_charge_cents and format both errors through format_revenue. new stays as the currency-blind boundary for serde and the import pipeline, which see a number before they see whose it is. The item wizard's fixed price went through new and so carried no floor at all: an under-minimum item saved and failed at checkout instead. It takes buy_once now, the same constructor the project wizard and the projects API use. STRIPE_MINIMUM_CHARGE_CENTS was dead and MIN_BUY_ONCE_PRICE_CENTS is now the currency's business; both are gone, and MAX_PRICE_CENTS is the one place the ceiling lives.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-16 16:10 UTC
Signed with PGP, not checked
Commit: 2beeb212172f06243781076352ad240001b21e14
Parent: 4265599
8 files changed, +98 insertions, -37 deletions
@@ -79,8 +79,8 @@
79 79 // Stripe fees (for display only; actual fees set by Stripe)
80 80 pub const STRIPE_FEE_PERCENTAGE: f64 = 0.029; // 2.9%
81 81 pub const STRIPE_FEE_FIXED_CENTS: f64 = 30.0; // $0.30
82 - /// Stripe minimum charge amount in cents (USD). Charges below this are rejected.
83 - pub const STRIPE_MINIMUM_CHARGE_CENTS: i64 = 50; // $0.50
82 + // Stripe's minimum charge is per settlement currency (GBP is 30, the rest 50),
83 + // so it lives on SettlementCurrency::minimum_charge_cents rather than here.
84 84
85 85 // Page / query limits
86 86 pub const DASHBOARD_TRANSACTION_LIMIT: i64 = 100;
@@ -460,9 +460,10 @@
460 460 pub const SYNCKIT_INVITE_MAX_HOURS: i64 = 720; // 30 days
461 461 /// Shortest usable life. Below this the link expires before it can be delivered.
462 462 pub const SYNCKIT_INVITE_MIN_HOURS: i64 = 1;
463 - pub const MAX_PRICE_CENTS: i32 = 1_000_000; // $10,000
464 - /// Minimum for a non-zero buy-once price, Stripe rejects charges under $0.50.
465 - pub const MIN_BUY_ONCE_PRICE_CENTS: i32 = 50; // $0.50
463 + /// Ceiling on a single price, in the creator's settlement currency. Read it
464 + /// through [`crate::currency::SettlementCurrency::max_price_cents`], which is
465 + /// what price writers call; this is where the number lives.
466 + pub const MAX_PRICE_CENTS: i32 = 1_000_000; // 10,000
466 467
467 468 // Sandbox accounts
468 469 /// How long a sandbox session lasts before auto-cleanup.
@@ -542,8 +543,6 @@
542 543 const _: () = assert!(MAX_PRICE_CENTS <= 10_000_000); // <= $100,000
543 544 const _: () = assert!(MIN_SUBSCRIPTION_PRICE_CENTS > 0);
544 545 const _: () = assert!(MIN_SUBSCRIPTION_PRICE_CENTS < MAX_PRICE_CENTS);
545 - const _: () = assert!(MIN_BUY_ONCE_PRICE_CENTS > 0);
546 - const _: () = assert!(MIN_BUY_ONCE_PRICE_CENTS < MAX_PRICE_CENTS);
547 546
548 547 // Stripe fee constants
549 548 const _: () = assert!(STRIPE_FEE_PERCENTAGE > 0.0 && STRIPE_FEE_PERCENTAGE < 0.5);
@@ -110,7 +110,9 @@
110 110 /// pound would be worse than one a creator can state.
111 111 pub fn max_price_cents(self) -> i32 {
112 112 match self {
113 - Self::Usd | Self::Cad | Self::Gbp | Self::Aud | Self::Nzd | Self::Eur => 1_000_000,
113 + Self::Usd | Self::Cad | Self::Gbp | Self::Aud | Self::Nzd | Self::Eur => {
114 + crate::constants::MAX_PRICE_CENTS
115 + }
114 116 }
115 117 }
116 118
@@ -440,33 +440,63 @@
440 440 pub struct PriceCents(i32);
441 441
442 442 impl PriceCents {
443 - /// Validate and construct. Rejects negative values and values exceeding the cap.
444 - pub fn new(cents: i32) -> std::result::Result<Self, crate::error::AppError> {
443 + /// Validate and construct against the creator's settlement currency.
444 + ///
445 + /// Rejects negative values and values above that currency's ceiling. The
446 + /// ceiling is the same round number in every supported currency, so what
447 + /// the currency actually buys here is an error message denominated in the
448 + /// money the creator is setting the price in.
449 + pub fn new_in(
450 + cents: i32,
451 + currency: crate::currency::SettlementCurrency,
452 + ) -> std::result::Result<Self, crate::error::AppError> {
445 453 if cents < 0 {
446 454 return Err(crate::error::AppError::validation(
447 455 "Price cannot be negative".to_string(),
448 456 ));
449 457 }
450 - if cents > crate::constants::MAX_PRICE_CENTS {
451 - return Err(crate::error::AppError::validation(
452 - "Price cannot exceed $10,000".to_string(),
453 - ));
458 + let ceiling = currency.max_price_cents();
459 + if cents > ceiling {
460 + return Err(crate::error::AppError::validation(format!(
461 + "Price cannot exceed {}",
462 + crate::formatting::format_revenue(ceiling as i64, currency)
463 + )));
454 464 }
455 465 Ok(Self(cents))
456 466 }
457 467
468 + /// Validate and construct where no settlement currency is in hand.
469 + ///
470 + /// The currency-blind boundary: serde deserialization and the import
471 + /// pipeline, which see a number before they see whose it is. Every writer
472 + /// that knows the creator should call [`PriceCents::new_in`] or
473 + /// [`PriceCents::buy_once`] instead, so the error reads in their money.
474 + pub fn new(cents: i32) -> std::result::Result<Self, crate::error::AppError> {
475 + Self::new_in(cents, crate::currency::SettlementCurrency::Usd)
476 + }
477 +
458 478 /// Zero, always a valid price (free / non-buy-once pricing models).
459 479 pub const ZERO: PriceCents = PriceCents(0);
460 480
461 - /// Validate a non-zero buy-once price: enforces the `$0.50` Stripe floor in
462 - /// addition to the non-negative + `$10k` cap that [`PriceCents::new`] checks.
463 - /// Use this at every buy-once price write so the floor can't drift per-site.
464 - pub fn buy_once(cents: i32) -> std::result::Result<Self, crate::error::AppError> {
465 - let pc = Self::new(cents)?;
466 - if cents < crate::constants::MIN_BUY_ONCE_PRICE_CENTS {
467 - return Err(crate::error::AppError::validation(
468 - "Price must be at least $0.50".to_string(),
469 - ));
481 + /// Validate a non-zero buy-once price: enforces Stripe's minimum charge for
482 + /// the creator's settlement currency in addition to the non-negative + cap
483 + /// checks [`PriceCents::new_in`] makes. Use this at every buy-once price
484 + /// write so the floor can't drift per-site.
485 + ///
486 + /// The floor is not a flat 50: Stripe's GBP minimum is 30, and the session
487 + /// is always created in the creator's settlement currency, so that is the
488 + /// minimum a sale of this price will be held to.
489 + pub fn buy_once(
490 + cents: i32,
491 + currency: crate::currency::SettlementCurrency,
492 + ) -> std::result::Result<Self, crate::error::AppError> {
493 + let pc = Self::new_in(cents, currency)?;
494 + let floor = currency.minimum_charge_cents();
495 + if i64::from(cents) < floor {
496 + return Err(crate::error::AppError::validation(format!(
497 + "Price must be at least {}",
498 + crate::formatting::format_revenue(floor, currency)
499 + )));
470 500 }
471 501 Ok(pc)
472 502 }
@@ -619,6 +649,30 @@
619 649 assert!(PriceCents::new(99_999_999).is_err());
620 650 }
621 651
652 + #[test]
653 + fn buy_once_floor_follows_the_settlement_currency() {
654 + use crate::currency::SettlementCurrency as C;
655 + // Stripe's GBP minimum is 30, every other supported currency is 50, so
656 + // a 35p price is sellable and 35 cents is not.
657 + assert!(PriceCents::buy_once(35, C::Gbp).is_ok());
658 + for c in C::ALL.into_iter().filter(|c| *c != C::Gbp) {
659 + assert!(PriceCents::buy_once(35, c).is_err(), "{c}");
660 + assert!(PriceCents::buy_once(50, c).is_ok(), "{c}");
661 + }
662 + assert!(PriceCents::buy_once(29, C::Gbp).is_err());
663 + }
664 +
665 + #[test]
666 + fn price_errors_are_denominated_in_the_creators_currency() {
667 + use crate::currency::SettlementCurrency as C;
668 + let floor = PriceCents::buy_once(10, C::Eur).unwrap_err().to_string();
669 + assert!(floor.contains('\u{20ac}'), "{floor}");
670 + let cap = PriceCents::new_in(1_000_001, C::Gbp)
671 + .unwrap_err()
672 + .to_string();
673 + assert!(cap.contains('£'), "{cap}");
674 + }
675 +
622 676 #[test]
623 677 fn price_cents_deref() {
624 678 let p = PriceCents::new(1500).unwrap();
@@ -86,7 +86,7 @@
86 86
87 87 ### Minimum Charge
88 88
89 - Stripe enforces a minimum per transaction and it is not the same everywhere: $0.50 USD, CAD, AUD, NZD and EUR 0.50, but GBP 0.30. We block sub-minimum charges before they reach Stripe, for single items and cart totals alike. Free items (price 0) are unaffected.
89 + Stripe enforces a minimum per transaction and it is not the same everywhere: $0.50 USD, CAD, AUD, NZD and EUR 0.50, but GBP 0.30. We block sub-minimum charges before they reach Stripe, for single items and cart totals alike, and the price form rejects a buy-once price below your currency's minimum when you set it rather than letting the sale fail later. Free items (price 0) are unaffected.
90 90
91 91 ---
92 92
@@ -275,10 +275,11 @@
275 275 // `(dollars * 100.0).round() as i32` form silently turns NaN into 0
276 276 // and saturates large values to i32::MAX.
277 277 let cents = crate::pricing::validate_dollars_f64("price_dollars", dollars)?;
278 - // `buy_once` enforces the $10k cap, non-negative, and the $0.50 floor
279 - // in one place shared with the wizard writer. update_project_pricing
280 - // takes PriceCents, so a bare i32 can't reach the DB (Run 11 UX F1).
281 - db::PriceCents::buy_once(cents)?
278 + // `buy_once` enforces the cap, non-negative, and Stripe's minimum
279 + // charge for the creator's settlement currency, in one place shared
280 + // with the wizard writer. update_project_pricing takes PriceCents,
281 + // so a bare i32 can't reach the DB (Run 11 UX F1).
282 + db::PriceCents::buy_once(cents, user.settlement_currency)?
282 283 } else {
283 284 db::PriceCents::ZERO
284 285 };
@@ -328,7 +328,7 @@
328 328
329 329 async fn save_monetization(
330 330 db: &PgPool,
331 - _user: &crate::auth::SessionUser,
331 + user: &crate::auth::SessionUser,
332 332 project: &db::DbProject,
333 333 form: &HashMap<String, String>,
334 334 ) -> Result<()> {
@@ -345,11 +345,12 @@
345 345 .map_err(|_| AppError::validation(format!("Unknown pricing model: {pricing_model_str}")))?;
346 346
347 347 // Construct cap-enforcing PriceCents at the boundary. `buy_once` also applies
348 - // the $0.50 floor. Passing a bare i32 to update_project_pricing is now a
349 - // compile error, so this writer can't bypass the $10k cap (Run 11 UX F1).
348 + // Stripe's minimum charge for the creator's settlement currency. Passing a
349 + // bare i32 to update_project_pricing is now a compile error, so this writer
350 + // can't bypass the cap (Run 11 UX F1).
350 351 let price_cents = if pricing_kind == db::PricingKind::BuyOnce {
351 352 let raw = parse_dollars_to_cents("Price", form.get("price_dollars").map(String::as_str))?;
352 - db::PriceCents::buy_once(raw)?
353 + db::PriceCents::buy_once(raw, user.settlement_currency)?
353 354 } else {
354 355 db::PriceCents::ZERO
355 356 };
@@ -263,7 +263,7 @@
263 263 "basics" => save::save_basics(&db, &item, &form, user.id).await,
264 264 "content" => save::save_content(&db, &item, &form, user.id).await,
265 265 "sections" => Ok(()), // Sections managed via HTMX API; pass-through
266 - "pricing" => save::save_pricing(&db, &item, &form, user.id).await,
266 + "pricing" => save::save_pricing(&db, &item, &form, &user).await,
267 267 "preview" => {
268 268 return save::save_preview(
269 269 &db,
@@ -155,8 +155,9 @@
155 155 db: &PgPool,
156 156 item: &db::DbItem,
157 157 form: &HashMap<String, String>,
158 - user_id: UserId,
158 + user: &crate::auth::SessionUser,
159 159 ) -> Result<()> {
160 + let user_id = user.id;
160 161 // Reject missing/malformed pricing_model rather than silently defaulting
161 162 // to "free", a typo or future variant would otherwise demote the item to
162 163 // free on submit. Same disease class as the tier-row silent-drop bug
@@ -189,7 +190,10 @@
189 190 "fixed" => {
190 191 let price_cents =
191 192 parse_dollars_to_cents("Price", form.get("price").map(String::as_str))?;
192 - let price = PriceCents::new(price_cents)?;
193 + // A fixed item price is a buy-once price, so it carries Stripe's
194 + // minimum charge for the creator's settlement currency. Without the
195 + // floor here the item saves and the sale fails at checkout instead.
196 + let price = PriceCents::buy_once(price_cents, user.settlement_currency)?;
193 197 db::items::update_item(
194 198 db,
195 199 item.id,
@@ -220,8 +224,8 @@
220 224 "Minimum price cannot exceed the suggested price",
221 225 ));
222 226 }
223 - let suggested = PriceCents::new(suggested_cents)?;
224 - let min = PriceCents::new(min_cents)?;
227 + let suggested = PriceCents::new_in(suggested_cents, user.settlement_currency)?;
228 + let min = PriceCents::new_in(min_cents, user.settlement_currency)?;
225 229 db::items::update_item(
226 230 db,
227 231 item.id,