max / makenotwork
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 |