Skip to main content

max / makenotwork

Fix promo code race and duplicate checkout: reserve at checkout, not webhook Promo code use_count is now reserved at checkout creation time (not in the webhook handler), preventing concurrent checkouts from exceeding max_uses. Stale reservations are released by the scheduler when pending transactions expire after 25 hours. Duplicate pending checkout prevention: partial unique index (migration 073) causes INSERT to fail on concurrent checkouts for the same buyer+item or buyer+project. Handlers catch the 23505 unique violation and redirect. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-04-26 19:44 UTC
Commit: 34f43e12bdce17276e3b77792b76264b58c4d84a
Parent: cb20647
3 files changed, +47 insertions, -19 deletions
@@ -27,6 +27,7 @@ pub(in crate::routes::stripe) async fn create_checkout(
27 27 ) -> Result<Response> {
28 28 tracing::Span::current().record("item_id", tracing::field::display(&item_id));
29 29 user.check_not_suspended()?;
30 + user.check_not_sandbox()?;
30 31
31 32 let item_uuid: ItemId = item_id.parse()
32 33 .map_err(|_| AppError::NotFound)?;
@@ -262,6 +263,18 @@ pub(in crate::routes::stripe) async fn create_checkout(
262 263 return Ok(Redirect::to("/library?purchase=success").into_response());
263 264 }
264 265
266 + // Reserve promo code use_count at checkout time (not webhook time) to prevent
267 + // concurrent checkouts from exceeding max_uses. If the buyer abandons checkout,
268 + // the scheduler releases the reservation when cleaning up stale pending transactions.
269 + if let Some(pc_id) = promo_code_id {
270 + let reserved = db::promo_codes::try_increment_use_count(&state.db, pc_id)
271 + .await
272 + .context("reserve promo code use at checkout")?;
273 + if !reserved {
274 + return Err(AppError::BadRequest("This promo code has reached its usage limit".to_string()));
275 + }
276 + }
277 +
265 278 // Check if creator has Stripe connected and charges enabled
266 279 let stripe_account_id = seller.stripe_account_id.as_ref()
267 280 .ok_or_else(|| AppError::BadRequest("Creator hasn't set up payments yet".to_string()))?;
@@ -294,8 +307,11 @@ pub(in crate::routes::stripe) async fn create_checkout(
294 307 .await
295 308 .with_context(|| format!("create Stripe checkout for item {item_uuid}"))?;
296 309
297 - // Create a pending transaction
298 - db::transactions::create_transaction(
310 + // Create a pending transaction. The partial unique index on
311 + // (buyer_id, item_id) WHERE status = 'pending' prevents concurrent
312 + // duplicate checkouts — if another checkout is already in progress,
313 + // the INSERT fails and we redirect back to the item page.
314 + match db::transactions::create_transaction(
299 315 &state.db,
300 316 &db::transactions::CreateTransactionParams {
301 317 buyer_id: user.id,
@@ -308,9 +324,18 @@ pub(in crate::routes::stripe) async fn create_checkout(
308 324 seller_username: &seller.username,
309 325 share_contact: form.share_contact,
310 326 project_id: None,
327 + promo_code_id,
311 328 },
312 - ).await
313 - .context("create pending transaction")?;
329 + ).await {
330 + Ok(_) => {}
331 + Err(AppError::Database(sqlx::Error::Database(ref db_err)))
332 + if db_err.code().as_deref() == Some("23505") =>
333 + {
334 + tracing::info!(buyer_id = %user.id, item_id = %item_uuid, "duplicate pending checkout blocked");
335 + return Ok(Redirect::to(&format!("/i/{}", item_id)).into_response());
336 + }
337 + Err(e) => return Err(e).context("create pending transaction"),
338 + }
314 339
315 340 // Redirect to Stripe Checkout
316 341 let checkout_url = session.url
@@ -33,6 +33,7 @@ pub(in crate::routes::stripe) async fn create_project_checkout(
33 33 Form(form): Form<ProjectCheckoutForm>,
34 34 ) -> Result<Response> {
35 35 user.check_not_suspended()?;
36 + user.check_not_sandbox()?;
36 37
37 38 let project_uuid: db::ProjectId = project_id
38 39 .parse()
@@ -144,7 +145,7 @@ pub(in crate::routes::stripe) async fn create_project_checkout(
144 145 };
145 146 let session = stripe.create_checkout_session(&checkout_params).await?;
146 147
147 - db::transactions::create_transaction(
148 + match db::transactions::create_transaction(
148 149 &state.db,
149 150 &db::transactions::CreateTransactionParams {
150 151 buyer_id: user.id,
@@ -157,9 +158,19 @@ pub(in crate::routes::stripe) async fn create_project_checkout(
157 158 seller_username: &seller.username,
158 159 share_contact: form.share_contact,
159 160 project_id: Some(project_uuid),
161 + promo_code_id: None,
160 162 },
161 163 )
162 - .await?;
164 + .await {
165 + Ok(_) => {}
166 + Err(AppError::Database(sqlx::Error::Database(ref db_err)))
167 + if db_err.code().as_deref() == Some("23505") =>
168 + {
169 + tracing::info!(buyer_id = %user.id, project_id = %project_uuid, "duplicate pending project checkout blocked");
170 + return Ok(Redirect::to(&format!("/p/{}", project_id)).into_response());
171 + }
172 + Err(e) => return Err(e),
173 + }
163 174
164 175 let checkout_url = session
165 176 .url
@@ -23,7 +23,7 @@ pub(super) async fn handle_purchase_checkout_completed(
23 23 let buyer_id = raw_metadata.buyer_id;
24 24 let seller_id = raw_metadata.seller_id;
25 25 let item_id = raw_metadata.item_id;
26 - let promo_code_id = raw_metadata.promo_code_id;
26 + let _promo_code_id = raw_metadata.promo_code_id;
27 27
28 28 // Get the payment intent ID
29 29 let payment_intent_id = session.payment_intent
@@ -48,12 +48,8 @@ pub(super) async fn handle_purchase_checkout_completed(
48 48 .await
49 49 .with_context(|| format!("increment sales count for item {item_id}"))?;
50 50
51 - // Increment promo code use_count if one was used (inside transaction).
52 - if let Some(pc_id) = promo_code_id {
53 - db::promo_codes::try_increment_use_count(&mut *db_tx, pc_id)
54 - .await
55 - .with_context(|| format!("increment promo code {pc_id} use count"))?;
56 - }
51 + // Promo code use_count is reserved at checkout time (not here) to prevent
52 + // concurrent checkouts from exceeding max_uses. No increment needed in webhook.
57 53
58 54 // Commit the critical data integrity operations
59 55 db_tx.commit().await.context("commit purchase webhook transaction")?;
@@ -258,12 +254,8 @@ pub(super) async fn handle_subscription_checkout_completed(
258 254 }
259 255 };
260 256
261 - // Increment promo code use_count if one was used
262 - if let Some(pc_id) = raw_metadata.promo_code_id {
263 - db::promo_codes::try_increment_use_count(&mut *tx, pc_id)
264 - .await
265 - .context("increment promo code use count")?;
266 - }
257 + // Promo code use_count is reserved at checkout time (not here) to prevent
258 + // concurrent checkouts from exceeding max_uses. No increment needed in webhook.
267 259
268 260 tx.commit().await.context("commit subscription webhook transaction")?;
269 261