Skip to main content

max / makenotwork

36.2 KB · 862 lines History Blame Raw
1 //! Cart checkout: multi-item purchase from one seller in a single Stripe session.
2
3 use axum::{
4 extract::State,
5 response::{IntoResponse, Redirect, Response},
6 Form,
7 };
8 use serde::Deserialize;
9
10 use crate::{
11 auth::AuthUser,
12 db::{self, Cents, CodePurpose, PromoCodeId, UserId},
13 error::{AppError, Result, ResultExt},
14 helpers,
15 AppState,
16 };
17
18 use super::grant_bundle_items;
19
20 /// Form data for cart checkout.
21 #[derive(Debug, Deserialize)]
22 pub(in crate::routes::stripe) struct CartCheckoutForm {
23 pub seller_id: String,
24 #[serde(default)]
25 pub share_contact: bool,
26 pub promo_code: Option<String>,
27 }
28
29 /// POST /stripe/checkout/cart - Checkout all cart items from one seller
30 #[tracing::instrument(skip_all, name = "stripe::cart_checkout")]
31 pub(in crate::routes::stripe) async fn create_cart_checkout(
32 State(state): State<AppState>,
33 AuthUser(user): AuthUser,
34 Form(form): Form<CartCheckoutForm>,
35 ) -> Result<Response> {
36 user.check_not_suspended()?;
37 user.check_not_sandbox()?;
38
39 let seller_id: UserId = form.seller_id.parse()
40 .map_err(|_| AppError::BadRequest("Invalid seller ID".to_string()))?;
41
42 if user.id == seller_id {
43 return Err(AppError::BadRequest("You cannot purchase your own items".to_string()));
44 }
45
46 // Get all cart items for this seller
47 let cart_items = db::cart::get_cart_items_for_seller(&state.db, user.id, seller_id).await
48 .context("fetch cart items for seller")?;
49
50 if cart_items.is_empty() {
51 return Err(AppError::BadRequest("No items in cart for this creator".to_string()));
52 }
53
54 // Verify seller has Stripe connected
55 let seller = db::users::get_user_by_id(&state.db, seller_id)
56 .await
57 .context("fetch seller")?
58 .ok_or(AppError::NotFound)?;
59
60 if seller.is_suspended() {
61 return Err(AppError::BadRequest("This creator's account is currently unavailable".to_string()));
62 }
63
64 // Bulk-check ownership in a single query instead of N sequential roundtrips.
65 let cart_item_ids: Vec<db::ItemId> = cart_items.iter().map(|c| c.item_id).collect();
66 let already_owned = db::transactions::purchased_subset(&state.db, user.id, &cart_item_ids)
67 .await
68 .context("bulk check existing purchases")?;
69
70 let mut free_items = Vec::new();
71 let mut paid_items = Vec::new();
72 for item in &cart_items {
73 if already_owned.contains(&item.item_id) {
74 if let Err(e) = db::cart::remove_from_cart(&state.db, user.id, item.item_id).await {
75 tracing::warn!(
76 user_id = %user.id, item_id = %item.item_id, error = ?e,
77 "failed to remove already-purchased item from cart; buyer will see it lingering on /cart"
78 );
79 }
80 continue;
81 }
82 if item.is_free() {
83 free_items.push(item);
84 } else {
85 paid_items.push(item);
86 }
87 }
88
89 // Claim free items immediately. Bundle/license metadata is pulled
90 // through CartItem so this loop doesn't need a per-item `get_item_by_id`;
91 // cart rows are bulk-deleted at the end so this loop doesn't fire N
92 // DELETEs either (Run #8 perf MED).
93 let mut to_remove: Vec<db::ItemId> = Vec::with_capacity(free_items.len());
94 for item in &free_items {
95 let claim = db::transactions::ClaimParams {
96 buyer_id: user.id,
97 item_id: item.item_id,
98 seller_id,
99 item_title: &item.title,
100 seller_username: &item.creator_username,
101 share_contact: form.share_contact,
102 parent_transaction_id: None,
103 };
104
105 let mut tx = state.db.begin().await.context("begin free-claim transaction")?;
106 let claimed = db::transactions::claim_free_item(&mut *tx, &claim)
107 .await
108 .context("claim free item")?;
109 if claimed {
110 db::items::increment_sales_count(&mut *tx, item.item_id)
111 .await
112 .context("increment sales count")?;
113 }
114 tx.commit().await.context("commit free-claim transaction")?;
115
116 if claimed {
117 if item.item_type == "bundle" {
118 grant_bundle_items(&state, item.item_id, user.id, seller_id, None).await;
119 }
120 if item.enable_license_keys {
121 let key_code = helpers::generate_key_code();
122 db::license_keys::create_license_key(
123 &state.db, item.item_id, user.id, None, &key_code,
124 item.default_max_activations,
125 ).await.ok();
126 }
127 }
128
129 to_remove.push(item.item_id);
130 }
131 db::cart::remove_from_cart_bulk(&state.db, user.id, &to_remove).await.ok();
132
133 // Validate optional promo code and compute per-item discounted prices
134 let mut promo_code_id: Option<PromoCodeId> = None;
135 let mut discounted_prices: std::collections::HashMap<db::ItemId, i32> = std::collections::HashMap::new();
136
137 if let Some(code_str) = form.promo_code.as_deref() {
138 let code_str = code_str.trim().to_uppercase();
139 if !code_str.is_empty() {
140 // Look up seller's code first, then platform-wide
141 let pc = match db::promo_codes::get_promo_code_by_creator_and_code(&state.db, seller_id, &code_str)
142 .await
143 .context("lookup seller promo code")?
144 {
145 Some(pc) => pc,
146 None => db::promo_codes::get_platform_promo_code_by_user_and_code(&state.db, user.id, &code_str)
147 .await
148 .context("lookup platform promo code")?
149 .ok_or_else(|| AppError::BadRequest("Invalid promo code".to_string()))?,
150 };
151 let is_platform_wide = pc.is_platform_wide;
152
153 if pc.code_purpose == CodePurpose::FreeTrial {
154 return Err(AppError::BadRequest("Trial codes can only be used for subscriptions".to_string()));
155 }
156 if let Some(starts) = pc.starts_at
157 && starts > chrono::Utc::now()
158 {
159 return Err(AppError::BadRequest("This promo code is not yet active".to_string()));
160 }
161 if let Some(expires) = pc.expires_at
162 && expires < chrono::Utc::now()
163 {
164 return Err(AppError::BadRequest("This promo code has expired".to_string()));
165 }
166 if let Some(max) = pc.max_uses
167 && pc.use_count >= max
168 {
169 return Err(AppError::BadRequest("This promo code has reached its usage limit".to_string()));
170 }
171
172 // Apply to each eligible paid item
173 for item in &paid_items {
174 // Skip PWYW items (matching single-item behavior)
175 if item.pwyw_enabled {
176 continue;
177 }
178
179 // Scope checks (seller codes only)
180 if !is_platform_wide {
181 if let Some(scoped_item) = pc.item_id
182 && scoped_item != item.item_id { continue; }
183 if let Some(scoped_project) = pc.project_id
184 && let Ok(Some(db_item)) = db::items::get_item_by_id(&state.db, item.item_id).await
185 && db_item.project_id != scoped_project { continue; }
186 }
187
188 let base = item.effective_price_cents();
189 // Honor per-item min_price_cents floor for non-platform Discount
190 // codes (single-item checkout rejects; cart skips this item so
191 // others may still qualify). Run #8 caught this gap.
192 if pc.code_purpose == CodePurpose::Discount
193 && !is_platform_wide
194 && base < pc.min_price_cents
195 {
196 continue;
197 }
198 let discounted = match pc.code_purpose {
199 CodePurpose::FreeAccess => 0,
200 CodePurpose::Discount => {
201 // Reject misconfigured Discount-purpose codes; reserving the code
202 // without applying the discount is the bug fixed here.
203 let (dt, dv) = match (pc.discount_type, pc.discount_value) {
204 (Some(dt), Some(dv)) => (dt, dv),
205 _ => return Err(AppError::BadRequest(
206 "This promo code is misconfigured. Please contact the creator.".to_string(),
207 )),
208 };
209 db::promo_codes::apply_discount(base, dt, dv)
210 }
211 CodePurpose::FreeTrial => base, // unreachable, guarded above
212 };
213 discounted_prices.insert(item.item_id, discounted);
214 }
215
216 promo_code_id = Some(pc.id);
217 }
218 }
219
220 // Re-classify items after discount: some paid items may now be free
221 let mut newly_free = Vec::new();
222 let mut still_paid = Vec::new();
223 for item in &paid_items {
224 let final_price = discounted_prices.get(&item.item_id).copied()
225 .unwrap_or_else(|| item.effective_price_cents());
226 if final_price == 0 {
227 newly_free.push(item);
228 } else {
229 still_paid.push((item, final_price));
230 }
231 }
232
233 // Claim discount-zeroed items as free. Same per-item-roundtrip discipline
234 // as the free-by-price loop above: bundle/license fields come from CartItem,
235 // cart rows are bulk-deleted after the loop.
236 let mut to_remove_promo: Vec<db::ItemId> = Vec::with_capacity(newly_free.len());
237 for item in &newly_free {
238 let claim = db::transactions::ClaimParams {
239 buyer_id: user.id,
240 item_id: item.item_id,
241 seller_id,
242 item_title: &item.title,
243 seller_username: &item.creator_username,
244 share_contact: form.share_contact,
245 parent_transaction_id: None,
246 };
247 let mut tx = state.db.begin().await.context("begin promo-free claim")?;
248 let claimed = db::transactions::claim_free_item(&mut *tx, &claim)
249 .await.context("claim promo-free item")?;
250 if claimed {
251 db::items::increment_sales_count(&mut *tx, item.item_id)
252 .await.context("increment sales count")?;
253 }
254 tx.commit().await.context("commit promo-free claim")?;
255 if claimed {
256 if item.item_type == "bundle" {
257 grant_bundle_items(&state, item.item_id, user.id, seller_id, None).await;
258 }
259 if item.enable_license_keys {
260 let key_code = helpers::generate_key_code();
261 db::license_keys::create_license_key(
262 &state.db, item.item_id, user.id, None, &key_code,
263 item.default_max_activations,
264 ).await.ok();
265 }
266 }
267 to_remove_promo.push(item.item_id);
268 }
269 db::cart::remove_from_cart_bulk(&state.db, user.id, &to_remove_promo).await.ok();
270
271 // If no paid items remain after discounts, redirect to library
272 if still_paid.is_empty() {
273 if form.share_contact && (!free_items.is_empty() || !newly_free.is_empty()) {
274 db::transactions::clear_contact_revocation(&state.db, user.id, seller_id)
275 .await
276 .context("clear contact revocation")?;
277 }
278 return Ok(Redirect::to("/library?purchase=success").into_response());
279 }
280
281 // Verify Stripe is ready for paid items BEFORE reserving the promo. The
282 // previous order burned a use of single-use codes against creators with
283 // no charges_enabled.
284 let stripe_account_id = seller.stripe_account_id.as_ref()
285 .ok_or_else(|| AppError::BadRequest("Creator hasn't set up payments yet".to_string()))?;
286
287 if !seller.stripe_charges_enabled {
288 return Err(AppError::BadRequest("Creator's payment account is not ready".to_string()));
289 }
290
291 let stripe = state.stripe.as_ref()
292 .ok_or_else(|| AppError::BadRequest("Stripe is not configured".to_string()))?;
293
294 // Reserve promo code use_count before creating session
295 if let Some(pc_id) = promo_code_id {
296 let reserved = db::promo_codes::try_increment_use_count(&state.db, pc_id)
297 .await
298 .context("reserve promo code use at cart checkout")?;
299 if !reserved {
300 return Err(AppError::BadRequest("This promo code has reached its usage limit".to_string()));
301 }
302 }
303
304 // Build Stripe line items with discounted prices
305 let line_items: Vec<crate::payments::CartLineItem> = still_paid
306 .iter()
307 .map(|(item, final_price)| crate::payments::CartLineItem {
308 title: &item.title,
309 amount_cents: *final_price as i64,
310 })
311 .collect();
312
313 // Reject sub-Stripe-minimum totals before calling Stripe; same rationale
314 // as the per-seller path further down — chained promo+PWYW combinations
315 // can land between 1¢ and 49¢, and Stripe's error message for that is
316 // not user-friendly.
317 let cart_total: i64 = line_items.iter().map(|li| li.amount_cents).sum();
318 if cart_total > 0 && cart_total < crate::constants::STRIPE_MINIMUM_CHARGE_CENTS {
319 if let Some(pc_id) = promo_code_id {
320 db::promo_codes::release_use_count_and_detach(&state.db, pc_id, user.id).await.ok();
321 }
322 return Err(AppError::BadRequest(format!(
323 "Minimum cart total is ${:.2}",
324 crate::constants::STRIPE_MINIMUM_CHARGE_CENTS as f64 / 100.0
325 )));
326 }
327
328 // Pre-check the partial unique index `(buyer_id, item_id) WHERE status='pending'`
329 // BEFORE creating the Stripe session. The previous behavior swallowed a 23505
330 // collision per cart item silently, leaving the buyer charged for items that
331 // never got a pending row — and therefore never got fulfilled by the webhook.
332 let paid_item_ids: Vec<db::ItemId> = still_paid.iter().map(|(it, _)| it.item_id).collect();
333 let pending_collisions = db::transactions::pending_subset(&state.db, user.id, &paid_item_ids)
334 .await.context("pre-check pending cart purchases")?;
335 if !pending_collisions.is_empty() {
336 if let Some(pc_id) = promo_code_id {
337 db::promo_codes::release_use_count_and_detach(&state.db, pc_id, user.id).await.ok();
338 }
339 return Err(AppError::BadRequest(
340 "You already have a checkout in progress for one or more of these items. \
341 Complete or cancel that checkout before starting a new one.".to_string(),
342 ));
343 }
344
345 let success_url = format!(
346 "{}/stripe/success?session_id={{CHECKOUT_SESSION_ID}}",
347 state.config.host_url
348 );
349 let cancel_url = format!("{}/cart", state.config.host_url);
350
351 let cart_params = crate::payments::CartCheckoutParams {
352 connected_account_id: stripe_account_id,
353 line_items: &line_items,
354 buyer_id: user.id,
355 seller_id,
356 success_url: &success_url,
357 cancel_url: &cancel_url,
358 enable_stripe_tax: seller.stripe_tax_enabled,
359 };
360
361 let result = match stripe.create_cart_checkout_session(&cart_params).await {
362 Ok(r) => r,
363 Err(e) => {
364 if let Some(pc_id) = promo_code_id {
365 db::promo_codes::release_use_count_and_detach(&state.db, pc_id, user.id).await.ok();
366 }
367 return Err(e).context("create cart checkout session");
368 }
369 };
370
371 // Create pending transactions for all paid items atomically so the buyer
372 // either gets all items or none (prevents partial delivery on mid-loop failure)
373 let mut db_tx = state.db.begin().await.context("begin cart transaction creation")?;
374 for (item, final_price) in &still_paid {
375 match db::transactions::create_transaction(
376 &mut *db_tx,
377 &db::transactions::CreateTransactionParams {
378 buyer_id: Some(user.id),
379 seller_id,
380 item_id: Some(item.item_id),
381 amount_cents: Cents::new(*final_price as i64),
382 platform_fee_cents: Cents::ZERO,
383 stripe_checkout_session_id: &result.id,
384 item_title: &item.title,
385 seller_username: &item.creator_username,
386 share_contact: form.share_contact,
387 project_id: None,
388 promo_code_id,
389 guest_email: None,
390 },
391 )
392 .await
393 {
394 Ok(_) => {}
395 Err(AppError::Database(sqlx::Error::Database(ref db_err)))
396 if db_err.code().as_deref() == Some("23505") =>
397 {
398 // A 23505 here means another tab raced past the pre-check.
399 // Abort the whole cart rather than silently leaving a paid
400 // Stripe line item without a pending DB row to fulfill.
401 tracing::warn!(
402 buyer_id = %user.id, item_id = %item.item_id,
403 "23505 raced past pre-check during cart pending insert"
404 );
405 if let Some(pc_id) = promo_code_id {
406 db::promo_codes::release_use_count_and_detach(&state.db, pc_id, user.id).await.ok();
407 }
408 return Err(AppError::BadRequest(
409 "Another checkout for one of these items started while this one was loading. \
410 Please refresh and try again.".to_string(),
411 ));
412 }
413 Err(e) => {
414 // Transaction auto-rolls back on drop
415 if let Some(pc_id) = promo_code_id {
416 db::promo_codes::release_use_count_and_detach(&state.db, pc_id, user.id).await.ok();
417 }
418 return Err(e).context("create pending transaction for cart item");
419 }
420 }
421 }
422 db_tx.commit().await.context("commit cart pending transactions")?;
423
424 // Cart items are removed by the webhook handler on successful payment,
425 // so users keep their cart if they cancel the Stripe checkout.
426
427 // Redirect to Stripe Checkout
428 let checkout_url = result
429 .url
430 .ok_or_else(|| AppError::BadRequest("No checkout URL returned".to_string()))?;
431
432 Ok(Redirect::to(&checkout_url).into_response())
433 }
434
435 /// Form data for checkout-all (cross-seller).
436 #[derive(Debug, Deserialize)]
437 pub(in crate::routes::stripe) struct CartCheckoutAllForm {
438 #[serde(default)]
439 pub share_contact: bool,
440 }
441
442 /// POST /stripe/checkout/cart/all - Checkout all cart items across all sellers.
443 ///
444 /// Queues seller IDs in the session, processes the first seller, then chains
445 /// through the rest via checkout_success redirects.
446 #[tracing::instrument(skip_all, name = "stripe::cart_checkout_all")]
447 pub(in crate::routes::stripe) async fn create_cart_checkout_all(
448 State(state): State<AppState>,
449 AuthUser(user): AuthUser,
450 session: tower_sessions::Session,
451 Form(form): Form<CartCheckoutAllForm>,
452 ) -> Result<Response> {
453 user.check_not_suspended()?;
454 user.check_not_sandbox()?;
455
456 let cart_items = db::cart::get_cart_items(&state.db, user.id).await
457 .context("fetch all cart items")?;
458
459 if cart_items.is_empty() {
460 return Ok(Redirect::to("/cart").into_response());
461 }
462
463 // Group by seller, collect unique seller IDs in order
464 let mut seen = std::collections::HashSet::new();
465 let mut seller_ids: Vec<String> = Vec::new();
466 for item in &cart_items {
467 let sid = item.seller_id.to_string();
468 if seen.insert(sid.clone()) {
469 seller_ids.push(sid);
470 }
471 }
472
473 if seller_ids.is_empty() {
474 return Ok(Redirect::to("/cart").into_response());
475 }
476
477 // Queue remaining sellers (all except the first) in session
478 let first_seller = seller_ids.remove(0);
479 if !seller_ids.is_empty() {
480 session.insert("cart_queue", seller_ids).await
481 .map_err(|e| AppError::BadRequest(format!("session error: {e}")))?;
482 session.insert("cart_share_contact", form.share_contact).await
483 .map_err(|e| AppError::BadRequest(format!("session error: {e}")))?;
484 }
485
486 // Process the first seller and chain through the queue until we hit a
487 // paid seller (return its Stripe URL) or exhaust everything as free.
488 match drain_to_paid(&state, &user, first_seller, form.share_contact, &session).await? {
489 Some(url) => Ok(Redirect::to(&url).into_response()),
490 None => Ok(Redirect::to("/library?purchase=success").into_response()),
491 }
492 }
493
494 /// Core logic: process cart checkout for one seller.
495 ///
496 /// Returns `Ok(Some(url))` for a Stripe-payable checkout, `Ok(None)` when
497 /// every item for this seller was free (already claimed inline; no Stripe
498 /// session needed — caller should advance to the next queued seller or
499 /// redirect to the library). The chain-break bug fixed in Run #8 was caused
500 /// by the previous shape returning `Err` on the all-free case, which broke
501 /// out of `create_cart_checkout_all` mid-flow and left `cart_queue` stranded
502 /// in the session.
503 ///
504 /// Called by create_cart_checkout (single-seller form) and create_cart_checkout_all
505 /// (cross-seller chain). Does NOT handle promo codes when called from the chain
506 /// (promo_code = None).
507 pub(super) async fn process_seller_checkout(
508 state: &AppState,
509 user: &crate::auth::SessionUser,
510 seller_id_str: &str,
511 share_contact: bool,
512 promo_code: Option<String>,
513 ) -> Result<Option<String>> {
514 let seller_id: UserId = seller_id_str.parse()
515 .map_err(|_| AppError::BadRequest("Invalid seller ID".to_string()))?;
516
517 let cart_items = db::cart::get_cart_items_for_seller(&state.db, user.id, seller_id).await
518 .context("fetch cart items for seller")?;
519
520 if cart_items.is_empty() {
521 return Err(AppError::BadRequest("No items in cart for this creator".to_string()));
522 }
523
524 let seller = db::users::get_user_by_id(&state.db, seller_id)
525 .await.context("fetch seller")?.ok_or(AppError::NotFound)?;
526
527 if seller.is_suspended() {
528 return Err(AppError::BadRequest("This creator's account is currently unavailable".to_string()));
529 }
530
531 // Bulk-check ownership in a single query — chained checkout path was
532 // missed in Run #5; Run #6 audit caught the N+1.
533 let cart_item_ids: Vec<db::ItemId> = cart_items.iter().map(|c| c.item_id).collect();
534 let already_owned = db::transactions::purchased_subset(&state.db, user.id, &cart_item_ids)
535 .await.context("bulk check existing purchases")?;
536
537 let mut free_items = Vec::new();
538 let mut paid_items = Vec::new();
539 for item in &cart_items {
540 if already_owned.contains(&item.item_id) {
541 if let Err(e) = db::cart::remove_from_cart(&state.db, user.id, item.item_id).await {
542 tracing::warn!(
543 user_id = %user.id, item_id = %item.item_id, error = ?e,
544 "failed to remove already-purchased item from cart; buyer will see it lingering on /cart"
545 );
546 }
547 continue;
548 }
549 if item.is_free() {
550 free_items.push(item);
551 } else {
552 paid_items.push(item);
553 }
554 }
555
556 // Claim free items. Bundle/license fields come from CartItem; cart rows
557 // bulk-deleted after the loop (Run #8 perf MED).
558 let mut to_remove: Vec<db::ItemId> = Vec::with_capacity(free_items.len());
559 for item in &free_items {
560 let claim = db::transactions::ClaimParams {
561 buyer_id: user.id,
562 item_id: item.item_id,
563 seller_id,
564 item_title: &item.title,
565 seller_username: &item.creator_username,
566 share_contact,
567 parent_transaction_id: None,
568 };
569 let mut tx = state.db.begin().await.context("begin free-claim")?;
570 let claimed = db::transactions::claim_free_item(&mut *tx, &claim).await.context("claim free item")?;
571 if claimed {
572 db::items::increment_sales_count(&mut *tx, item.item_id).await.context("increment sales count")?;
573 }
574 tx.commit().await.context("commit free-claim")?;
575 if claimed {
576 if item.item_type == "bundle" {
577 grant_bundle_items(state, item.item_id, user.id, seller_id, None).await;
578 }
579 if item.enable_license_keys {
580 let key_code = helpers::generate_key_code();
581 db::license_keys::create_license_key(
582 &state.db, item.item_id, user.id, None, &key_code,
583 item.default_max_activations,
584 ).await.ok();
585 }
586 }
587 to_remove.push(item.item_id);
588 }
589 db::cart::remove_from_cart_bulk(&state.db, user.id, &to_remove).await.ok();
590
591 if paid_items.is_empty() {
592 if share_contact && !free_items.is_empty() {
593 db::transactions::clear_contact_revocation(&state.db, user.id, seller_id)
594 .await.context("clear contact revocation")?;
595 }
596 // All items free — no Stripe session. Caller advances the chain.
597 return Ok(None);
598 }
599
600 // Promo code handling (only for direct form submissions, not chained)
601 let mut promo_code_id: Option<PromoCodeId> = None;
602 let mut discounted_prices: std::collections::HashMap<db::ItemId, i32> = std::collections::HashMap::new();
603
604 if let Some(code_str) = promo_code.as_deref() {
605 let code_str = code_str.trim().to_uppercase();
606 if !code_str.is_empty() {
607 let pc = match db::promo_codes::get_promo_code_by_creator_and_code(&state.db, seller_id, &code_str)
608 .await.context("lookup promo code")?
609 {
610 Some(pc) => pc,
611 None => db::promo_codes::get_platform_promo_code_by_user_and_code(&state.db, user.id, &code_str)
612 .await.context("lookup platform promo code")?
613 .ok_or_else(|| AppError::BadRequest("Invalid promo code".to_string()))?,
614 };
615
616 if pc.code_purpose == CodePurpose::FreeTrial {
617 return Err(AppError::BadRequest("Trial codes can only be used for subscriptions".to_string()));
618 }
619 if let Some(starts) = pc.starts_at
620 && starts > chrono::Utc::now()
621 {
622 return Err(AppError::BadRequest("This promo code is not yet active".to_string()));
623 }
624 if let Some(expires) = pc.expires_at
625 && expires < chrono::Utc::now()
626 {
627 return Err(AppError::BadRequest("This promo code has expired".to_string()));
628 }
629 if let Some(max) = pc.max_uses
630 && pc.use_count >= max
631 {
632 return Err(AppError::BadRequest("This promo code has reached its usage limit".to_string()));
633 }
634
635 let is_platform_wide = pc.is_platform_wide;
636 for item in &paid_items {
637 if item.pwyw_enabled { continue; }
638 if !is_platform_wide {
639 if let Some(scoped_item) = pc.item_id
640 && scoped_item != item.item_id { continue; }
641 if let Some(scoped_project) = pc.project_id
642 && let Ok(Some(db_item)) = db::items::get_item_by_id(&state.db, item.item_id).await
643 && db_item.project_id != scoped_project { continue; }
644 }
645 let base = item.effective_price_cents();
646 // Honor per-item min_price_cents floor for non-platform Discount
647 // codes (single-item checkout rejects; cart skips this item so
648 // others may still qualify). Run #8 caught this gap.
649 if pc.code_purpose == CodePurpose::Discount
650 && !is_platform_wide
651 && base < pc.min_price_cents
652 {
653 continue;
654 }
655 let discounted = match pc.code_purpose {
656 CodePurpose::FreeAccess => 0,
657 CodePurpose::Discount => {
658 // Reject misconfigured Discount codes — Run #7 caught
659 // that the cart-all chain path (this third copy in the
660 // same file) missed the H3 fix applied to the other two.
661 let (dt, dv) = match (pc.discount_type, pc.discount_value) {
662 (Some(dt), Some(dv)) => (dt, dv),
663 _ => return Err(AppError::BadRequest(
664 "This promo code is misconfigured. Please contact the creator.".to_string(),
665 )),
666 };
667 db::promo_codes::apply_discount(base, dt, dv)
668 }
669 CodePurpose::FreeTrial => base,
670 };
671 discounted_prices.insert(item.item_id, discounted);
672 }
673 promo_code_id = Some(pc.id);
674 }
675 }
676
677 // Build final price list
678 let final_items: Vec<(&db::cart::CartItem, i32)> = paid_items
679 .iter()
680 .map(|item| {
681 let price = discounted_prices.get(&item.item_id).copied()
682 .unwrap_or_else(|| item.effective_price_cents());
683 (*item, price)
684 })
685 .filter(|(_, price)| *price > 0)
686 .collect();
687
688 if final_items.is_empty() {
689 return Err(AppError::BadRequest("All items are free after discount".to_string()));
690 }
691
692 // Reserve promo code
693 if let Some(pc_id) = promo_code_id {
694 let reserved = db::promo_codes::try_increment_use_count(&state.db, pc_id)
695 .await.context("reserve promo code")?;
696 if !reserved {
697 return Err(AppError::BadRequest("This promo code has reached its usage limit".to_string()));
698 }
699 }
700
701 let stripe_account_id = seller.stripe_account_id.as_ref()
702 .ok_or_else(|| AppError::BadRequest("Creator hasn't set up payments yet".to_string()))?;
703 if !seller.stripe_charges_enabled {
704 return Err(AppError::BadRequest("Creator's payment account is not ready".to_string()));
705 }
706 let stripe = state.stripe.as_ref()
707 .ok_or_else(|| AppError::BadRequest("Stripe is not configured".to_string()))?;
708
709 let line_items: Vec<crate::payments::CartLineItem> = final_items
710 .iter()
711 .map(|(item, price)| crate::payments::CartLineItem {
712 title: &item.title,
713 amount_cents: *price as i64,
714 })
715 .collect();
716
717 // Reject sub-Stripe-minimum totals here. The cart flow doesn't share
718 // the same `check_min_charge` gate as item/subscription checkout, so a
719 // chained promo+PWYW combination that lands between 1¢ and 49¢ would
720 // be accepted here and then rejected by Stripe with a confusing error.
721 let cart_total: i64 = line_items.iter().map(|li| li.amount_cents).sum();
722 if cart_total > 0 && cart_total < crate::constants::STRIPE_MINIMUM_CHARGE_CENTS {
723 return Err(AppError::BadRequest(format!(
724 "Minimum cart total is ${:.2}",
725 crate::constants::STRIPE_MINIMUM_CHARGE_CENTS as f64 / 100.0
726 )));
727 }
728
729 // Pre-check pending-purchase index BEFORE Stripe session — see
730 // create_cart_checkout for the rationale.
731 let paid_item_ids: Vec<db::ItemId> = final_items.iter().map(|(it, _)| it.item_id).collect();
732 let pending_collisions = db::transactions::pending_subset(&state.db, user.id, &paid_item_ids)
733 .await.context("pre-check pending purchases in chained cart")?;
734 if !pending_collisions.is_empty() {
735 // This path reserves the promo earlier; release on collision so the
736 // use_count doesn't stay burned (no pending row was created, so the
737 // stale-pending cleanup won't recover it).
738 if let Some(pc_id) = promo_code_id {
739 db::promo_codes::release_use_count_and_detach(&state.db, pc_id, user.id).await.ok();
740 }
741 return Err(AppError::BadRequest(
742 "You already have a checkout in progress for one or more of these items. \
743 Complete or cancel that checkout before starting a new one.".to_string(),
744 ));
745 }
746
747 let success_url = format!("{}/stripe/success?session_id={{CHECKOUT_SESSION_ID}}", state.config.host_url);
748 let cancel_url = format!("{}/cart", state.config.host_url);
749
750 let cart_params = crate::payments::CartCheckoutParams {
751 connected_account_id: stripe_account_id,
752 line_items: &line_items,
753 buyer_id: user.id,
754 seller_id,
755 success_url: &success_url,
756 cancel_url: &cancel_url,
757 enable_stripe_tax: seller.stripe_tax_enabled,
758 };
759
760 let result = match stripe.create_cart_checkout_session(&cart_params).await {
761 Ok(r) => r,
762 Err(e) => {
763 if let Some(pc_id) = promo_code_id {
764 db::promo_codes::release_use_count_and_detach(&state.db, pc_id, user.id).await.ok();
765 }
766 return Err(e).context("create cart checkout session");
767 }
768 };
769
770 let mut db_tx = state.db.begin().await.context("begin seller cart transaction creation")?;
771 for (item, final_price) in &final_items {
772 match db::transactions::create_transaction(
773 &mut *db_tx,
774 &db::transactions::CreateTransactionParams {
775 buyer_id: Some(user.id),
776 seller_id,
777 item_id: Some(item.item_id),
778 amount_cents: Cents::new(*final_price as i64),
779 platform_fee_cents: Cents::ZERO,
780 stripe_checkout_session_id: &result.id,
781 item_title: &item.title,
782 seller_username: &item.creator_username,
783 share_contact,
784 project_id: None,
785 promo_code_id,
786 guest_email: None,
787 },
788 ).await {
789 Ok(_) => {}
790 Err(AppError::Database(sqlx::Error::Database(ref db_err)))
791 if db_err.code().as_deref() == Some("23505") =>
792 {
793 // Race past the pre-check from another tab — abort rather than
794 // silently leave a paid Stripe line item without a fulfilling row.
795 tracing::warn!(
796 buyer_id = %user.id, item_id = %item.item_id,
797 "23505 raced past pre-check during seller-cart pending insert"
798 );
799 if let Some(pc_id) = promo_code_id {
800 db::promo_codes::release_use_count_and_detach(&state.db, pc_id, user.id).await.ok();
801 }
802 return Err(AppError::BadRequest(
803 "Another checkout for one of these items started while this one was loading. \
804 Please refresh and try again.".to_string(),
805 ));
806 }
807 Err(e) => {
808 if let Some(pc_id) = promo_code_id {
809 db::promo_codes::release_use_count_and_detach(&state.db, pc_id, user.id).await.ok();
810 }
811 return Err(e).context("create pending transaction");
812 }
813 }
814 }
815 db_tx.commit().await.context("commit seller cart pending transactions")?;
816
817 // Cart items are removed by the webhook handler on successful payment.
818
819 result.url
820 .map(Some)
821 .ok_or_else(|| AppError::BadRequest("No checkout URL returned".to_string()))
822 }
823
824 /// Process the cart queue starting with `first_seller_id`. Loops while
825 /// `process_seller_checkout` returns `Ok(None)` (all items for that seller
826 /// were free), draining the session queue. Returns the Stripe checkout URL
827 /// the moment a paid seller is reached, or `None` when the queue is exhausted
828 /// with every item claimed free.
829 pub(super) async fn drain_to_paid(
830 state: &AppState,
831 user: &crate::auth::SessionUser,
832 first_seller_id: String,
833 share_contact: bool,
834 session: &tower_sessions::Session,
835 ) -> Result<Option<String>> {
836 let mut current = first_seller_id;
837 loop {
838 if let Some(url) = process_seller_checkout(state, user, &current, share_contact, None).await? {
839 return Ok(Some(url));
840 }
841 // All items for `current` were free. Pop the next queued seller and
842 // try again; on empty queue, signal "everything claimed".
843 let next: Option<String> = match session.get::<Vec<String>>("cart_queue").await {
844 Ok(Some(mut queue)) if !queue.is_empty() => {
845 let n = queue.remove(0);
846 if queue.is_empty() {
847 session.remove::<Vec<String>>("cart_queue").await.ok();
848 session.remove::<bool>("cart_share_contact").await.ok();
849 } else {
850 session.insert("cart_queue", queue).await.ok();
851 }
852 Some(n)
853 }
854 _ => None,
855 };
856 match next {
857 Some(n) => current = n,
858 None => return Ok(None),
859 }
860 }
861 }
862