max / makenotwork
10 files changed,
+99 insertions,
-42 deletions
| @@ -1,14 +0,0 @@ | |||
| 1 | - | { | |
| 2 | - | "db_name": "PostgreSQL", | |
| 3 | - | "query": "UPDATE promo_codes SET use_count = use_count + 1 WHERE id = $1 AND (max_uses IS NULL OR use_count < max_uses)", | |
| 4 | - | "describe": { | |
| 5 | - | "columns": [], | |
| 6 | - | "parameters": { | |
| 7 | - | "Left": [ | |
| 8 | - | "Uuid" | |
| 9 | - | ] | |
| 10 | - | }, | |
| 11 | - | "nullable": [] | |
| 12 | - | }, | |
| 13 | - | "hash": "299eb04864518d489b5800a5364a742eea334b17b81aedef1a896b6300b1f47f" | |
| 14 | - | } |
| @@ -0,0 +1,14 @@ | |||
| 1 | + | { | |
| 2 | + | "db_name": "PostgreSQL", | |
| 3 | + | "query": "\n UPDATE promo_codes SET use_count = use_count + 1\n WHERE id = $1\n AND (max_uses IS NULL OR use_count < max_uses)\n AND (starts_at IS NULL OR starts_at <= NOW())\n AND (expires_at IS NULL OR expires_at > NOW())\n ", | |
| 4 | + | "describe": { | |
| 5 | + | "columns": [], | |
| 6 | + | "parameters": { | |
| 7 | + | "Left": [ | |
| 8 | + | "Uuid" | |
| 9 | + | ] | |
| 10 | + | }, | |
| 11 | + | "nullable": [] | |
| 12 | + | }, | |
| 13 | + | "hash": "3cb85697e35c902c7fc0f237ba2ef747f5337ff93c4a201409dcd86e0517f686" | |
| 14 | + | } |
| @@ -4291,7 +4291,7 @@ dependencies = [ | |||
| 4291 | 4291 | ||
| 4292 | 4292 | [[package]] | |
| 4293 | 4293 | name = "makenotwork" | |
| 4294 | - | version = "0.10.5" | |
| 4294 | + | version = "0.10.6" | |
| 4295 | 4295 | dependencies = [ | |
| 4296 | 4296 | "ammonia", | |
| 4297 | 4297 | "anyhow", |
| @@ -360,6 +360,8 @@ pub const CHANGELOG_PROJECT_SLUG: &str = "changelog"; | |||
| 360 | 360 | pub const USER_AGENT_MAX_LENGTH: usize = 512; | |
| 361 | 361 | pub const SYNCKIT_MAX_KEY_ENVELOPE_BYTES: usize = 4096; | |
| 362 | 362 | pub const MAX_PRICE_CENTS: i32 = 1_000_000; // $10,000 | |
| 363 | + | /// Minimum for a non-zero buy-once price — Stripe rejects charges under $0.50. | |
| 364 | + | pub const MIN_BUY_ONCE_PRICE_CENTS: i32 = 50; // $0.50 | |
| 363 | 365 | ||
| 364 | 366 | // -- Sandbox accounts -- | |
| 365 | 367 | /// How long a sandbox session lasts before auto-cleanup. | |
| @@ -391,6 +393,8 @@ const _: () = assert!(MAX_PRICE_CENTS > 0); | |||
| 391 | 393 | const _: () = assert!(MAX_PRICE_CENTS <= 10_000_000); // <= $100,000 | |
| 392 | 394 | const _: () = assert!(MIN_SUBSCRIPTION_PRICE_CENTS > 0); | |
| 393 | 395 | const _: () = assert!(MIN_SUBSCRIPTION_PRICE_CENTS < MAX_PRICE_CENTS); | |
| 396 | + | const _: () = assert!(MIN_BUY_ONCE_PRICE_CENTS > 0); | |
| 397 | + | const _: () = assert!(MIN_BUY_ONCE_PRICE_CENTS < MAX_PRICE_CENTS); | |
| 394 | 398 | ||
| 395 | 399 | // Stripe fee constants | |
| 396 | 400 | const _: () = assert!(STRIPE_FEE_PERCENTAGE > 0.0 && STRIPE_FEE_PERCENTAGE < 0.5); |
| @@ -479,14 +479,20 @@ pub async fn update_project_ai_tier( | |||
| 479 | 479 | } | |
| 480 | 480 | ||
| 481 | 481 | /// Update a project's pricing model, price, and PWYW minimum. | |
| 482 | + | /// | |
| 483 | + | /// Takes [`PriceCents`](super::PriceCents) (not raw `i32`) so the `$10k` cap and | |
| 484 | + | /// non-negative floor are enforced by the type at every call site — the only way | |
| 485 | + | /// to obtain a `PriceCents` is the cap-checking `new`/`buy_once` constructors. | |
| 486 | + | /// This is the structural fix for the price-cap-per-writer chronic: a writer | |
| 487 | + | /// cannot pass an uncapped value (ultra-fuzz Run 11 UX F1). | |
| 482 | 488 | #[tracing::instrument(skip_all)] | |
| 483 | 489 | pub async fn update_project_pricing( | |
| 484 | 490 | pool: &PgPool, | |
| 485 | 491 | id: ProjectId, | |
| 486 | 492 | user_id: UserId, | |
| 487 | 493 | pricing_model: super::PricingKind, | |
| 488 | - | price_cents: i32, | |
| 489 | - | pwyw_min_cents: Option<i32>, | |
| 494 | + | price_cents: super::PriceCents, | |
| 495 | + | pwyw_min_cents: Option<super::PriceCents>, | |
| 490 | 496 | ) -> Result<()> { | |
| 491 | 497 | sqlx::query( | |
| 492 | 498 | r#" | |
| @@ -498,8 +504,8 @@ pub async fn update_project_pricing( | |||
| 498 | 504 | .bind(id) | |
| 499 | 505 | .bind(user_id) | |
| 500 | 506 | .bind(pricing_model) | |
| 501 | - | .bind(price_cents) | |
| 502 | - | .bind(pwyw_min_cents) | |
| 507 | + | .bind(price_cents.as_i32()) | |
| 508 | + | .bind(pwyw_min_cents.map(|p| p.as_i32())) | |
| 503 | 509 | .execute(pool) | |
| 504 | 510 | .await?; | |
| 505 | 511 |
| @@ -614,9 +614,19 @@ pub async fn claim_free_with_promo_code( | |||
| 614 | 614 | return Ok((true, false)); | |
| 615 | 615 | } | |
| 616 | 616 | ||
| 617 | - | // Step 3: Increment the promo code use count | |
| 617 | + | // Step 3: Increment the promo code use count. Re-check the full validity | |
| 618 | + | // window (max_uses AND starts_at/expires_at) inside the atomic UPDATE — the | |
| 619 | + | // pre-flight check ran outside this transaction, so a code expiring in the | |
| 620 | + | // sub-ms gap must still be rejected here, not just on use-count (Run 11 Pay | |
| 621 | + | // MINOR / TOCTOU). | |
| 618 | 622 | let code_result = sqlx::query!( | |
| 619 | - | "UPDATE promo_codes SET use_count = use_count + 1 WHERE id = $1 AND (max_uses IS NULL OR use_count < max_uses)", | |
| 623 | + | r#" | |
| 624 | + | UPDATE promo_codes SET use_count = use_count + 1 | |
| 625 | + | WHERE id = $1 | |
| 626 | + | AND (max_uses IS NULL OR use_count < max_uses) | |
| 627 | + | AND (starts_at IS NULL OR starts_at <= NOW()) | |
| 628 | + | AND (expires_at IS NULL OR expires_at > NOW()) | |
| 629 | + | "#, | |
| 620 | 630 | promo_code_id as PromoCodeId, | |
| 621 | 631 | ) | |
| 622 | 632 | .execute(&mut *tx) |
| @@ -435,6 +435,22 @@ impl PriceCents { | |||
| 435 | 435 | Ok(Self(cents)) | |
| 436 | 436 | } | |
| 437 | 437 | ||
| 438 | + | /// Zero — always a valid price (free / non-buy-once pricing models). | |
| 439 | + | pub const ZERO: PriceCents = PriceCents(0); | |
| 440 | + | ||
| 441 | + | /// Validate a non-zero buy-once price: enforces the `$0.50` Stripe floor in | |
| 442 | + | /// addition to the non-negative + `$10k` cap that [`PriceCents::new`] checks. | |
| 443 | + | /// Use this at every buy-once price write so the floor can't drift per-site. | |
| 444 | + | pub fn buy_once(cents: i32) -> std::result::Result<Self, crate::error::AppError> { | |
| 445 | + | let pc = Self::new(cents)?; | |
| 446 | + | if cents < crate::constants::MIN_BUY_ONCE_PRICE_CENTS { | |
| 447 | + | return Err(crate::error::AppError::validation( | |
| 448 | + | "Price must be at least $0.50".to_string(), | |
| 449 | + | )); | |
| 450 | + | } | |
| 451 | + | Ok(pc) | |
| 452 | + | } | |
| 453 | + | ||
| 438 | 454 | /// Wrap a value from the database without validation. | |
| 439 | 455 | pub fn from_db(cents: i32) -> Self { | |
| 440 | 456 | Self(cents) |
| @@ -272,24 +272,18 @@ pub(super) async fn update_project( | |||
| 272 | 272 | // `(dollars * 100.0).round() as i32` form silently turns NaN into 0 | |
| 273 | 273 | // and saturates large values to i32::MAX. | |
| 274 | 274 | let cents = crate::pricing::validate_dollars_f64("price_dollars", dollars)?; | |
| 275 | - | // Enforce the $10k cap (and non-negative) the same way the form | |
| 276 | - | // wizard does — without PriceCents::new the JSON API bypasses the cap | |
| 277 | - | // and a creator can set a price up to ~$21.4M (ultra-fuzz Run 10 UX S1). | |
| 278 | - | let cents = db::PriceCents::new(cents)?.as_i32(); | |
| 279 | - | if cents < 50 { | |
| 280 | - | return Err(AppError::validation( | |
| 281 | - | "price_dollars must be at least 0.50", | |
| 282 | - | )); | |
| 283 | - | } | |
| 284 | - | cents | |
| 275 | + | // `buy_once` enforces the $10k cap, non-negative, and the $0.50 floor | |
| 276 | + | // in one place shared with the wizard writer. update_project_pricing | |
| 277 | + | // takes PriceCents, so a bare i32 can't reach the DB (Run 11 UX F1). | |
| 278 | + | db::PriceCents::buy_once(cents)? | |
| 285 | 279 | } else { | |
| 286 | - | 0 | |
| 280 | + | db::PriceCents::ZERO | |
| 287 | 281 | }; | |
| 288 | 282 | ||
| 289 | 283 | let pwyw_min_cents = if kind == db::PricingKind::Pwyw { | |
| 290 | 284 | let dollars = req.pwyw_min_dollars.unwrap_or(0.0); | |
| 291 | 285 | let cents = crate::pricing::validate_dollars_f64("pwyw_min_dollars", dollars)?; | |
| 292 | - | Some(db::PriceCents::new(cents)?.as_i32()) | |
| 286 | + | Some(db::PriceCents::new(cents)?) | |
| 293 | 287 | } else { | |
| 294 | 288 | None | |
| 295 | 289 | }; |
| @@ -340,14 +340,19 @@ async fn save_monetization( | |||
| 340 | 340 | .parse() | |
| 341 | 341 | .map_err(|_| AppError::validation(format!("Unknown pricing model: {pricing_model_str}")))?; | |
| 342 | 342 | ||
| 343 | + | // Construct cap-enforcing PriceCents at the boundary. `buy_once` also applies | |
| 344 | + | // the $0.50 floor. Passing a bare i32 to update_project_pricing is now a | |
| 345 | + | // compile error, so this writer can't bypass the $10k cap (Run 11 UX F1). | |
| 343 | 346 | let price_cents = if pricing_kind == db::PricingKind::BuyOnce { | |
| 344 | - | parse_dollars_to_cents("Price", form.get("price_dollars").map(String::as_str))? | |
| 347 | + | let raw = parse_dollars_to_cents("Price", form.get("price_dollars").map(String::as_str))?; | |
| 348 | + | db::PriceCents::buy_once(raw)? | |
| 345 | 349 | } else { | |
| 346 | - | 0 | |
| 350 | + | db::PriceCents::ZERO | |
| 347 | 351 | }; | |
| 348 | 352 | ||
| 349 | 353 | let pwyw_min_cents = if pricing_kind == db::PricingKind::Pwyw { | |
| 350 | - | Some(parse_dollars_to_cents("Minimum price", form.get("pwyw_min_dollars").map(String::as_str))?) | |
| 354 | + | let raw = parse_dollars_to_cents("Minimum price", form.get("pwyw_min_dollars").map(String::as_str))?; | |
| 355 | + | Some(db::PriceCents::new(raw)?) | |
| 351 | 356 | } else { | |
| 352 | 357 | None | |
| 353 | 358 | }; |
| @@ -330,10 +330,16 @@ fn compute_splits( | |||
| 330 | 330 | ) -> Vec<(db::UserId, i64, i16)> { | |
| 331 | 331 | let amount = amount_cents.as_i64(); | |
| 332 | 332 | ||
| 333 | - | // If members sum past 100%, scale each split proportionally so the total | |
| 334 | - | // matches `amount`. The previous "Defensive clamp" only capped | |
| 335 | - | // `expected_total`; per-member amounts were left at literal percent, | |
| 336 | - | // crediting >100% of revenue (e.g. two members at 60%+60% on $10 → $12 paid out). | |
| 333 | + | // One basis for BOTH the per-member share and the payout total, so the | |
| 334 | + | // remainder is provably the sum of the floor truncations (in 0..members.len) | |
| 335 | + | // rather than correct only by a max/min clamp coincidence (Run 11 surprise). | |
| 336 | + | // | |
| 337 | + | // `denom = max(sum, 100)`: | |
| 338 | + | // - members summing to <= 100%: each is paid their literal fraction and the | |
| 339 | + | // platform keeps the rest (denom is 100). | |
| 340 | + | // - members summing to > 100%: each is scaled down proportionally so the | |
| 341 | + | // whole `amount` is distributed and no one is over-credited (denom is the | |
| 342 | + | // sum) — e.g. 60%+60% on $10 pays $10, not $12. | |
| 337 | 343 | let raw_total_pct: i64 = members.iter().map(|m| m.split_percent as i64).sum(); | |
| 338 | 344 | let denom = raw_total_pct.max(100); | |
| 339 | 345 | ||
| @@ -345,9 +351,11 @@ fn compute_splits( | |||
| 345 | 351 | }) | |
| 346 | 352 | .collect(); | |
| 347 | 353 | ||
| 348 | - | let expected_total = (amount * raw_total_pct.min(100) / 100).min(amount); | |
| 354 | + | // Exact (un-floored) members' share over the same denom — manifestly the sum | |
| 355 | + | // of the per-member shares before flooring, so the remainder reconciles. | |
| 356 | + | let payout_total = amount * raw_total_pct / denom; | |
| 349 | 357 | let actual_total: i64 = splits.iter().map(|(_, amt, _)| *amt).sum(); | |
| 350 | - | let mut remainder = expected_total - actual_total; | |
| 358 | + | let mut remainder = payout_total - actual_total; | |
| 351 | 359 | for split in &mut splits { | |
| 352 | 360 | if remainder <= 0 { | |
| 353 | 361 | break; | |
| @@ -546,6 +554,20 @@ mod tests { | |||
| 546 | 554 | } | |
| 547 | 555 | ||
| 548 | 556 | #[test] | |
| 557 | + | fn under_100_percent_platform_keeps_remainder() { | |
| 558 | + | // Members sum to 70% — they receive exactly 70% of the amount and the | |
| 559 | + | // platform keeps the other 30%. Pins the single-basis payout_total so the | |
| 560 | + | // denom(max)/total(min) asymmetry can't drift back in (Run 11 surprise). | |
| 561 | + | let u1 = db::UserId::new(); | |
| 562 | + | let u2 = db::UserId::new(); | |
| 563 | + | let members = vec![member(u1, 30), member(u2, 40)]; | |
| 564 | + | let splits = compute_splits(db::Cents::new(1000), &members); | |
| 565 | + | let total: i64 = splits.iter().map(|(_, amt, _)| *amt).sum(); | |
| 566 | + | assert_eq!(splits, vec![(u1, 300, 30), (u2, 400, 40)]); | |
| 567 | + | assert_eq!(total, 700, "members get 70%, platform keeps 300"); | |
| 568 | + | } | |
| 569 | + | ||
| 570 | + | #[test] | |
| 549 | 571 | fn single_cent_three_members_no_panic() { | |
| 550 | 572 | let u1 = db::UserId::new(); | |
| 551 | 573 | let u2 = db::UserId::new(); |