max / makenotwork
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
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, |