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