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