Skip to main content

max / makenotwork

Fix all-free cart promo reservation and CSV negative-subdollar parse An all-free cart (100%-off promo zeroing every line) returned before the promo-use reservation, so a max_uses-limited or 100%-off code was redeemable unlimited times via cart checkout. Reserve the use in the free-cart branch before claiming, mirroring the paid path's reserve-before-fulfil discipline. parse_amount_cents dropped the sign on sub-dollar negatives ("-0.50" parsed the whole-dollar part "-0" to 0, so the dollars<0 branch never fired, yielding +50). Read negativity from the string and apply it to the magnitude. Regression tests: cart_checkout_all_free_promo_consumes_one_use; negative sub-dollar CSV cases. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-01 16:34 UTC
Commit: 7045240ff5dc234cbd29e2ad7349f958ac5d0a33
Parent: 8a9ca19
3 files changed, +105 insertions, -3 deletions
@@ -158,8 +158,13 @@ fn parse_amount_cents(s: &str) -> Option<i64> {
158 158 }
159 159
160 160 // Decimal point means dollars.cents
161 - if let Some((dollars, cents_str)) = cleaned.split_once('.') {
162 - let dollars: i64 = dollars.trim().parse().ok()?;
161 + if let Some((dollars_str, cents_str)) = cleaned.split_once('.') {
162 + let dollars_str = dollars_str.trim();
163 + // A leading '-' carries the sign even when the whole-dollar part is zero:
164 + // "-0.50" is -50 cents, not +50. `"-0".parse::<i64>()` yields 0 and
165 + // silently drops the sign, so read negativity from the string itself.
166 + let negative = dollars_str.starts_with('-');
167 + let dollars: i64 = dollars_str.parse().ok()?;
163 168 // Pad or truncate to exactly 2 decimal places
164 169 let cents_str = if cents_str.len() >= 2 {
165 170 &cents_str[..2]
@@ -171,7 +176,8 @@ fn parse_amount_cents(s: &str) -> Option<i64> {
171 176 } else {
172 177 cents_str.parse().ok()?
173 178 };
174 - return Some(if dollars < 0 { dollars * 100 - cents } else { dollars * 100 + cents });
179 + let magnitude = dollars.abs() * 100 + cents;
180 + return Some(if negative { -magnitude } else { magnitude });
175 181 }
176 182
177 183 // Whole number — already in cents
@@ -528,6 +534,12 @@ mod tests {
528 534 fn parse_amount_negative_parses_as_negative_cents() {
529 535 assert_eq!(parse_amount_cents("-10.00"), Some(-1000));
530 536 assert_eq!(parse_amount_cents("-"), None);
537 + // Sub-dollar negatives: the whole-dollar part is zero, so the sign must
538 + // come from the string, not from `dollars < 0` ("-0" parses to 0).
539 + assert_eq!(parse_amount_cents("-0.50"), Some(-50));
540 + assert_eq!(parse_amount_cents("-$0.99"), Some(-99));
541 + assert_eq!(parse_amount_cents("-0.5"), Some(-50));
542 + assert_eq!(parse_amount_cents("-12.50"), Some(-1250));
531 543 }
532 544
533 545 // ── Date parsing edge cases ──
@@ -372,6 +372,25 @@ async fn checkout_seller_cart(
372 372 }
373 373 }
374 374 let claimed_any_free = !free_items.is_empty() || !newly_free.is_empty();
375 +
376 + // When the whole cart is free after discounts, reserve the promo use BEFORE
377 + // claiming anything. An all-free cart still consumes exactly ONE use of the
378 + // code (Run 10 Pay S1: one checkout = one use), and a reached-limit code must
379 + // reject before any free item is granted — mirroring the paid path's
380 + // reserve-before-fulfil discipline at the Stripe branch below. Without this,
381 + // a max_uses-limited or 100%-off code would be redeemable unlimited times via
382 + // an all-free cart, which returns early past the paid-path reservation.
383 + if still_paid.is_empty()
384 + && let Some(pc_id) = promo_code_id
385 + {
386 + let reserved = db::promo_codes::try_increment_use_count(&state.db, pc_id)
387 + .await
388 + .context("reserve promo code use at free cart checkout")?;
389 + if !reserved {
390 + return Err(AppError::BadRequest("This promo code has reached its usage limit".to_string()));
391 + }
392 + }
393 +
375 394 // Already-free items (genuinely free) carry no platform credit.
376 395 let free_with_credit: Vec<(&db::cart::CartItem, i64)> =
377 396 free_items.iter().map(|it| (*it, 0i64)).collect();
@@ -1178,6 +1178,77 @@ async fn cart_checkout_promo_discount_applied() {
1178 1178 assert_eq!(use_count, 1, "a completed cart checkout reserves exactly one promo use");
1179 1179 }
1180 1180
1181 + /// An ALL-FREE cart (a 100%-off promo zeroes every line) must still consume
1182 + /// exactly one promo use, and a max_uses-limited code must be exhausted after
1183 + /// it. Regression for the audit finding where the free-cart early-return fired
1184 + /// before the promo reservation, letting a limited/100%-off code be redeemed
1185 + /// unlimited times via cart checkout.
1186 + #[tokio::test]
1187 + async fn cart_checkout_all_free_promo_consumes_one_use() {
1188 + let mut h = TestHarness::with_mocks().await;
1189 + let (seller_id, _project_id, item_id) = setup_paid_item(&mut h, 500).await;
1190 +
1191 + // 100%-off, single-use code (direct SQL — the API form doesn't take max_uses).
1192 + sqlx::query(
1193 + "INSERT INTO promo_codes (creator_id, code, code_purpose, discount_type, discount_value, min_price_cents, max_uses) \
1194 + VALUES ($1, 'CARTFREE', 'discount', 'percentage', 100, 0, 1)",
1195 + )
1196 + .bind(seller_id)
1197 + .execute(&h.db)
1198 + .await
1199 + .unwrap();
1200 + h.client.post_form("/logout", "").await;
1201 +
1202 + // First buyer: all-free cart checkout claims the item and burns the one use.
1203 + let buyer1 = h.signup("cartfree1", "cartfree1@test.com", "pass1234").await;
1204 + h.client.post_form(&format!("/api/cart/{}", item_id), "").await;
1205 + let resp = h.client.post_form(
1206 + "/stripe/checkout/cart",
1207 + &format!("seller_id={}&share_contact=false&promo_code=CARTFREE", seller_id),
1208 + ).await;
1209 + assert!(
1210 + resp.status.is_redirection() || resp.status.is_success(),
1211 + "all-free cart checkout should succeed: {} {}", resp.status, resp.text
1212 + );
1213 + // Free claim recorded (a $0 completed transaction), no Stripe session.
1214 + let amount: i32 = sqlx::query_scalar(
1215 + "SELECT amount_cents FROM transactions WHERE buyer_id = $1",
1216 + )
1217 + .bind(buyer1)
1218 + .fetch_one(&h.db)
1219 + .await
1220 + .unwrap();
1221 + assert_eq!(amount, 0, "all-free cart should record a $0 claim");
1222 +
1223 + let use_count: i32 = sqlx::query_scalar("SELECT use_count FROM promo_codes WHERE code = 'CARTFREE'")
1224 + .fetch_one(&h.db)
1225 + .await
1226 + .unwrap();
1227 + assert_eq!(use_count, 1, "an all-free cart checkout must consume exactly one promo use");
1228 +
1229 + // Second buyer: the code is now exhausted; the all-free cart must be rejected
1230 + // and the use_count must not move past its max.
1231 + h.client.post_form("/logout", "").await;
1232 + let _buyer2 = h.signup("cartfree2", "cartfree2@test.com", "pass1234").await;
1233 + h.client.post_form(&format!("/api/cart/{}", item_id), "").await;
1234 + let resp = h.client.post_form(
1235 + "/stripe/checkout/cart",
1236 + &format!("seller_id={}&share_contact=false&promo_code=CARTFREE", seller_id),
1237 + ).await;
1238 + assert_eq!(
1239 + resp.status.as_u16(), 400,
1240 + "exhausted code on an all-free cart should be rejected: {}", resp.text
1241 + );
1242 + let use_count: i32 = sqlx::query_scalar("SELECT use_count FROM promo_codes WHERE code = 'CARTFREE'")
1243 + .fetch_one(&h.db)
1244 + .await
1245 + .unwrap();
1246 + assert_eq!(use_count, 1, "exhausted code must not exceed its max_uses");
1247 +
1248 + let mock_stripe = h.mock_stripe.as_ref().unwrap();
1249 + assert_eq!(mock_stripe.checkouts().len(), 0, "no Stripe session for an all-free cart");
1250 + }
1251 +
1181 1252 /// A promo that drops the cart total below the Stripe minimum is rejected and
1182 1253 /// must NOT burn a promo use. Pins the cart core's "reserve only after the
1183 1254 /// min-charge gate" ordering (the gate runs before reservation).