Skip to main content

max / makenotwork

Tighten checkout guards and document promo use semantics (ultra-fuzz Run 10 Payments) - S1: cart promo reservation is one-use-per-checkout by design (one redemption = one use, matching single-item checkout). Document the rule at the reservation site and add a guard test pinning that a single-use code discounts every eligible cart line while use_count is reserved once. - M1: project checkout now calls check_min_charge, matching item/cart paths, so a sub-50c non-zero price returns a friendly error instead of Stripe's raw one. - M2: cart seller-state guard now rejects deactivated/paused creators, not just suspended, aligning with the item and project checkout guards. - M3: clamp license key remaining_activations at 0 so lowering max_activations below the current count can't surface a negative count publicly. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-06-30 13:49 UTC
Commit: 111861a31349f3b5aafe8f4dc113128310ea93be
Parent: ea22104
4 files changed, +66 insertions, -3 deletions
@@ -1064,4 +1064,52 @@ mod tests {
1064 1064 proptest::prop_assert_eq!(apply_discount(price, DiscountType::Percentage, 0), price);
1065 1065 }
1066 1066 }
1067 +
1068 + // -- Cart promo semantics: one redemption = one use (ultra-fuzz Run 10 Pay S1) --
1069 +
1070 + /// Build a percentage-discount promo with no scope/min-price gating.
1071 + fn unscoped_discount_promo(max_uses: Option<i32>) -> ValidatedPromo {
1072 + ValidatedPromo {
1073 + code: DbPromoCode {
1074 + id: PromoCodeId::new(),
1075 + creator_id: UserId::new(),
1076 + code: "SAVE10".to_string(),
1077 + code_purpose: CodePurpose::Discount,
1078 + discount_type: Some(DiscountType::Percentage),
1079 + discount_value: Some(10),
1080 + min_price_cents: 0,
1081 + trial_days: None,
1082 + item_id: None,
1083 + project_id: None,
1084 + tier_id: None,
1085 + max_uses,
1086 + use_count: 0,
1087 + expires_at: None,
1088 + starts_at: None,
1089 + created_at: chrono::Utc::now(),
1090 + is_platform_wide: false,
1091 + },
1092 + is_platform_wide: false,
1093 + }
1094 + }
1095 +
1096 + #[test]
1097 + fn single_use_code_discounts_every_eligible_cart_line() {
1098 + // A max_uses=1 code applied across a multi-item cart discounts EVERY
1099 + // eligible line. This is intentional: the handler reserves exactly one
1100 + // use per cart checkout (one redemption = one use), so the per-line
1101 + // discounting below is not a use-count leak. Pin it so a future change
1102 + // can't silently turn cart promos into per-line reservation.
1103 + let promo = unscoped_discount_promo(Some(1));
1104 + for base in [1000, 2000, 4999] {
1105 + let result = apply_promo_to_item(&promo, ItemId::new(), ProjectId::new(), base).unwrap();
1106 + let PromoApplication::Apply(price) = result else {
1107 + panic!("expected Apply for an eligible cart line at base {base}");
1108 + };
1109 + assert_eq!(price, base - base / 10);
1110 + }
1111 + // apply_promo_to_item never touches use_count; reservation is the
1112 + // handler's once-per-checkout concern.
1113 + assert_eq!(promo.code.use_count, 0);
1114 + }
1067 1115 }
@@ -272,7 +272,11 @@ pub(super) async fn key_status(
272 272 }));
273 273 }
274 274
275 - let remaining = key.max_activations.map(|max| max - key.activation_count);
275 + // Clamp at 0: an admin can lower max_activations below the current activation
276 + // count, which would otherwise surface a negative remaining count publicly.
277 + let remaining = key
278 + .max_activations
279 + .map(|max| (max - key.activation_count).max(0));
276 280
277 281 Ok(Json(KeyStatusResponse {
278 282 valid: true,
@@ -287,8 +287,8 @@ async fn checkout_seller_cart(
287 287 .await
288 288 .context("fetch seller")?
289 289 .ok_or(AppError::NotFound)?;
290 - if seller.is_suspended() {
291 - return Err(AppError::BadRequest("This creator's account is currently unavailable".to_string()));
290 + if seller.is_suspended() || seller.is_deactivated() || seller.is_creator_paused() {
291 + return Err(AppError::BadRequest("This creator's account is not active".to_string()));
292 292 }
293 293
294 294 // Bulk-check ownership in a single query instead of N sequential roundtrips.
@@ -412,6 +412,13 @@ async fn checkout_seller_cart(
412 412 }
413 413
414 414 // Reserve the promo use only now that every cheap reject is behind us.
415 + //
416 + // Semantics (ultra-fuzz Run 10 Pay S1, decided): one cart checkout consumes
417 + // exactly ONE use of the code, even when the code discounts multiple eligible
418 + // lines above. One redemption = one use — the same accounting as a
419 + // single-item checkout (one item-checkout = one use). Reserving per
420 + // discounted line would be a different product, not a bug fix; keep this a
421 + // single increment.
415 422 if let Some(pc_id) = promo_code_id {
416 423 let reserved = db::promo_codes::try_increment_use_count(&state.db, pc_id)
417 424 .await
@@ -115,6 +115,10 @@ pub(in crate::routes::stripe) async fn create_project_checkout(
115 115 return Ok(Redirect::to(&format!("/p/{}", project.slug)).into_response());
116 116 }
117 117
118 + // Reject sub-minimum non-zero charges (Stripe rejects <50¢) with a friendly
119 + // error before the session call, matching the item and cart checkout paths.
120 + crate::payments::check_min_charge(base_price_cents as i64)?;
121 +
118 122 // Stripe checkout
119 123 let stripe_account_id = seller
120 124 .stripe_account_id