Skip to main content

max / makenotwork

Type-seal project price writes and tighten money paths (ultra-fuzz Run 11 Payments) Drive the Payments axis to A+: - update_project_pricing now takes PriceCents/Option<PriceCents>, not raw i32. Passing a bare i32 is a compile error, so no writer can bypass the $10k cap. Fixes UX F1 (the creation-wizard price-cap bypass) structurally and resolves the price-cap-per-writer chronic. New PriceCents::buy_once centralizes the $0.50 floor shared by the wizard and JSON-API writers; PriceCents::ZERO for non-buy-once models. - claim_free_with_promo_code re-checks the full validity window (starts_at / expires_at) inside the atomic increment, not just max_uses, closing the sub-ms TOCTOU. - compute_splits computes the payout total over the same denom as the per-member share, so the remainder provably reconciles instead of relying on a max/min clamp coincidence. New regression test pins the under-100% case. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-06-30 17:14 UTC
Commit: 803b50d48028f6b6265630d94fd93336f848bd37
Parent: 947d6f6
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();