Skip to main content

max / makenotwork

28.6 KB · 735 lines History Blame Raw
1 //! Cart checkout: multi-item purchase from one seller in a single Stripe session.
2
3 use axum::{
4 Form,
5 extract::State,
6 response::{IntoResponse, Redirect, Response},
7 };
8 use serde::Deserialize;
9
10 use crate::{
11 Billing, Integrations,
12 auth::AuthUser,
13 config::Config,
14 db::{self, Cents, PromoCodeId, UserId},
15 error::{AppError, Result, ResultExt},
16 helpers,
17 wam_client::WamClient,
18 };
19 use sqlx::PgPool;
20
21 use super::grant_bundle_items;
22
23 /// Release a promo reservation on a checkout-abort path, logging on failure.
24 ///
25 /// These releases run only after another error has already aborted the
26 /// checkout, so the caller can't surface a failure to the user, but a silent
27 /// drop leaves the promo's use-count incremented (a stuck reservation). Log it
28 /// so an orphaned reservation is traceable rather than invisible.
29 async fn release_promo_quietly(db: &PgPool, pc_id: PromoCodeId, user_id: UserId) {
30 if let Err(e) = db::promo_codes::release_use_count_and_detach(db, pc_id, user_id).await {
31 tracing::warn!(
32 promo_code_id = %pc_id,
33 %user_id,
34 error = %e,
35 "failed to release promo reservation on checkout abort; use-count may be stuck"
36 );
37 }
38 }
39
40 /// Form data for cart checkout.
41 #[derive(Debug, Deserialize)]
42 pub(in crate::routes::stripe) struct CartCheckoutForm {
43 pub seller_id: String,
44 #[serde(default)]
45 pub share_contact: bool,
46 pub promo_code: Option<String>,
47 }
48
49 /// POST /stripe/checkout/cart - Checkout all cart items from one seller.
50 ///
51 /// Thin wrapper over [`checkout_seller_cart`]: enforces the buyer-side
52 /// preconditions (suspended/sandbox/self-purchase) the chained path doesn't
53 /// need, then maps the core's `Option<url>` onto a redirect.
54 #[tracing::instrument(skip_all, name = "stripe::cart_checkout", fields(user_id = %user.id))]
55 pub(in crate::routes::stripe) async fn create_cart_checkout(
56 State(db): State<PgPool>,
57 State(integrations): State<Integrations>,
58 State(payments): State<Billing>,
59 State(config): State<Config>,
60 AuthUser(user): AuthUser,
61 Form(form): Form<CartCheckoutForm>,
62 ) -> Result<Response> {
63 user.check_not_suspended()?;
64 user.check_not_sandbox()?;
65
66 let seller_id: UserId = form
67 .seller_id
68 .parse()
69 .map_err(|_| AppError::BadRequest("Invalid seller ID".to_string()))?;
70
71 if user.id == seller_id {
72 return Err(AppError::BadRequest(
73 "You cannot purchase your own items".to_string(),
74 ));
75 }
76
77 match checkout_seller_cart(
78 &db,
79 integrations.wam.as_ref(),
80 &payments,
81 &config,
82 &user,
83 seller_id,
84 form.share_contact,
85 form.promo_code.as_deref(),
86 )
87 .await?
88 {
89 Some(url) => Ok(Redirect::to(&url).into_response()),
90 None => Ok(Redirect::to("/library?purchase=success").into_response()),
91 }
92 }
93
94 /// Form data for checkout-all (cross-seller).
95 #[derive(Debug, Deserialize)]
96 pub(in crate::routes::stripe) struct CartCheckoutAllForm {
97 #[serde(default)]
98 pub share_contact: bool,
99 }
100
101 /// POST /stripe/checkout/cart/all - Checkout all cart items across all sellers.
102 ///
103 /// Queues seller IDs in the session, processes the first seller, then chains
104 /// through the rest via checkout_success redirects.
105 #[tracing::instrument(skip_all, name = "stripe::cart_checkout_all", fields(user_id = %user.id))]
106 pub(in crate::routes::stripe) async fn create_cart_checkout_all(
107 State(db): State<PgPool>,
108 State(integrations): State<Integrations>,
109 State(payments): State<Billing>,
110 State(config): State<Config>,
111 AuthUser(user): AuthUser,
112 session: tower_sessions::Session,
113 Form(form): Form<CartCheckoutAllForm>,
114 ) -> Result<Response> {
115 user.check_not_suspended()?;
116 user.check_not_sandbox()?;
117
118 let cart_items = db::cart::get_cart_items(&db, user.id)
119 .await
120 .context("fetch all cart items")?;
121
122 if cart_items.is_empty() {
123 return Ok(Redirect::to("/cart").into_response());
124 }
125
126 // Group by seller, collect unique seller IDs in order
127 let mut seen = std::collections::HashSet::new();
128 let mut seller_ids: Vec<String> = Vec::new();
129 for item in &cart_items {
130 let sid = item.seller_id.to_string();
131 if seen.insert(sid.clone()) {
132 seller_ids.push(sid);
133 }
134 }
135
136 if seller_ids.is_empty() {
137 return Ok(Redirect::to("/cart").into_response());
138 }
139
140 // Queue remaining sellers (all except the first) in session
141 let first_seller = seller_ids.remove(0);
142 if !seller_ids.is_empty() {
143 session
144 .insert("cart_queue", seller_ids)
145 .await
146 .map_err(|e| AppError::BadRequest(format!("session error: {e}")))?;
147 session
148 .insert("cart_share_contact", form.share_contact)
149 .await
150 .map_err(|e| AppError::BadRequest(format!("session error: {e}")))?;
151 }
152
153 // Process the first seller and chain through the queue until we hit a
154 // paid seller (return its Stripe URL) or exhaust everything as free.
155 match drain_to_paid(
156 &db,
157 integrations.wam.as_ref(),
158 &payments,
159 &config,
160 &user,
161 first_seller,
162 form.share_contact,
163 &session,
164 )
165 .await?
166 {
167 Some(url) => Ok(Redirect::to(&url).into_response()),
168 None => Ok(Redirect::to("/library?purchase=success").into_response()),
169 }
170 }
171
172 /// Claim a set of free (or discount-zeroed) cart items: insert the free
173 /// transaction, bump the sales count, and grant bundle items + a license key
174 /// when applicable, then bulk-remove the claimed rows from the cart.
175 ///
176 /// Bundle/license fields come from `CartItem`, so this does no per-item
177 /// `get_item_by_id`, and the cart rows are removed in one bulk DELETE after the
178 /// loop (Run #8 perf MED). Shared by the free-by-price and discount-zeroed
179 /// passes so the claim logic exists in exactly one place.
180 async fn claim_free_cart_items(
181 db: &PgPool,
182 wam: Option<&WamClient>,
183 user_id: UserId,
184 seller_id: UserId,
185 items: &[(&db::cart::CartItem, i64)],
186 share_contact: bool,
187 ) -> Result<()> {
188 if items.is_empty() {
189 return Ok(());
190 }
191 // Claim every free item in ONE transaction instead of a begin/commit per item
192 // (Run 11 Perf MOD tail), collapses N round-trips to one and makes the cart
193 // claim atomic (all free items or none). Post-commit side effects (bundle
194 // grants, license-key minting) run after, only for the newly-claimed items.
195 let mut to_remove: Vec<db::ItemId> = Vec::with_capacity(items.len());
196 let mut claimed_items: Vec<&db::cart::CartItem> = Vec::with_capacity(items.len());
197
198 let mut tx = db.begin().await.context("begin free-claim transaction")?;
199 for (item, platform_credit_cents) in items {
200 let claim = db::transactions::ClaimParams {
201 buyer_id: user_id,
202 item_id: item.item_id,
203 seller_id,
204 item_title: &item.title,
205 seller_username: &item.creator_username,
206 share_contact,
207 parent_transaction_id: None,
208 platform_credit_cents: *platform_credit_cents,
209 };
210
211 let claimed = db::transactions::claim_free_item(&mut *tx, &claim)
212 .await
213 .context("claim free item")?;
214 if claimed {
215 db::items::increment_sales_count(&mut *tx, item.item_id)
216 .await
217 .context("increment sales count")?;
218 claimed_items.push(*item);
219 }
220 to_remove.push(item.item_id);
221 }
222 tx.commit().await.context("commit free-claim transaction")?;
223
224 for item in claimed_items {
225 if item.item_type == "bundle" {
226 grant_bundle_items(db, item.item_id, user_id, seller_id, None).await;
227 }
228 if item.enable_license_keys {
229 let key_code = helpers::generate_key_code();
230 if let Err(e) = db::license_keys::create_license_key(
231 db,
232 item.item_id,
233 user_id,
234 None,
235 &key_code,
236 item.default_max_activations,
237 )
238 .await
239 {
240 // Mirror the paid path (webhook/checkout_helpers.rs): a buyer who
241 // claimed the item but got no key is silent data loss, so escalate
242 // to WAM for manual issuance rather than swallowing it (audit Run
243 // 17 Observability). Free claims have no transaction id, so key the
244 // ticket on the item.
245 tracing::error!(user_id = %user_id, item_id = %item.item_id, error = ?e, "failed to generate license key for free claim");
246 if let Some(wam) = wam {
247 let title =
248 format!("License key not issued (free claim): item {}", item.item_id);
249 let body = format!(
250 "User {user_id} claimed free item {} but license key generation \
251 failed: {e}\n\nManually issue a key.",
252 item.item_id,
253 );
254 wam.create_ticket(
255 &title,
256 Some(&body),
257 "critical",
258 "license-key-gen-failed",
259 Some(&item.item_id.to_string()),
260 )
261 .await;
262 }
263 }
264 }
265 }
266 if let Err(e) = db::cart::remove_from_cart_bulk(db, user_id, &to_remove).await {
267 // Non-fatal: the items were claimed; a failed cart cleanup just leaves
268 // stale rows the user can remove. Log rather than drop silently.
269 tracing::warn!(user_id = %user_id, error = ?e, "failed to clear claimed free items from cart");
270 }
271 Ok(())
272 }
273
274 /// Create the pending transactions for every paid item in one DB transaction,
275 /// so the buyer gets all items or none (no partial delivery on a mid-loop
276 /// failure).
277 ///
278 /// A 23505 means another tab raced past the pre-check; abort the whole cart
279 /// rather than leave a paid Stripe line item with no pending row to fulfill. On
280 /// any error the promo reservation (if any) is released, since the Stripe
281 /// session was already created but no fulfilling rows landed.
282 async fn create_cart_pending_transactions(
283 db: &PgPool,
284 user_id: UserId,
285 seller_id: UserId,
286 session_id: &str,
287 items: &[(&db::cart::CartItem, i32, i64)],
288 share_contact: bool,
289 promo_code_id: Option<PromoCodeId>,
290 ) -> Result<()> {
291 let mut db_tx = db
292 .begin()
293 .await
294 .context("begin cart transaction creation")?;
295 for (item, final_price, platform_credit_cents) in items {
296 match db::transactions::create_transaction(
297 &mut *db_tx,
298 &db::transactions::CreateTransactionParams {
299 buyer_id: Some(user_id),
300 seller_id,
301 item_id: Some(item.item_id),
302 amount_cents: Cents::new(*final_price as i64),
303 platform_fee_cents: Cents::ZERO,
304 stripe_checkout_session_id: session_id,
305 item_title: &item.title,
306 seller_username: &item.creator_username,
307 share_contact,
308 project_id: None,
309 promo_code_id,
310 guest_email: None,
311 platform_credit_cents: *platform_credit_cents,
312 },
313 )
314 .await
315 {
316 Ok(_) => {}
317 Err(AppError::Database(sqlx::Error::Database(ref db_err)))
318 if db_err.code().as_deref() == Some("23505") =>
319 {
320 tracing::warn!(
321 buyer_id = %user_id, item_id = %item.item_id,
322 "23505 raced past pre-check during cart pending insert"
323 );
324 if let Some(pc_id) = promo_code_id {
325 release_promo_quietly(db, pc_id, user_id).await;
326 }
327 return Err(AppError::BadRequest(
328 "Another checkout for one of these items started while this one was loading. \
329 Please refresh and try again."
330 .to_string(),
331 ));
332 }
333 Err(e) => {
334 // Transaction auto-rolls back on drop.
335 if let Some(pc_id) = promo_code_id {
336 release_promo_quietly(db, pc_id, user_id).await;
337 }
338 return Err(e).context("create pending transaction for cart item");
339 }
340 }
341 }
342 db_tx
343 .commit()
344 .await
345 .context("commit cart pending transactions")?;
346 Ok(())
347 }
348
349 /// Core per-seller cart checkout, shared by the single-seller form
350 /// ([`create_cart_checkout`]) and the cross-seller chain ([`drain_to_paid`]).
351 ///
352 /// Returns `Ok(None)` when every item for this seller was free (claimed inline,
353 /// no Stripe session needed, the chain advances to the next seller), or
354 /// `Ok(Some(url))` with the Stripe Checkout URL for the paid remainder.
355 ///
356 /// Ordering matters: the promo reservation is taken as late as possible, after
357 /// the Stripe-ready, minimum-charge, and pending-collision checks, so an abort
358 /// on any of those can't burn a single-use code. The previous chained copy
359 /// reserved early and leaked the reservation on the Stripe-ready and min-charge
360 /// rejects (inert only because the chain never passed a promo); folding the two
361 /// copies into one removes that divergence.
362 #[tracing::instrument(skip_all, name = "stripe::checkout_seller_cart", fields(user_id = %user.id, %seller_id))]
363 #[allow(clippy::too_many_arguments)]
364 async fn checkout_seller_cart(
365 db: &PgPool,
366 wam: Option<&WamClient>,
367 payments: &Billing,
368 config: &Config,
369 user: &crate::auth::SessionUser,
370 seller_id: UserId,
371 share_contact: bool,
372 promo_code: Option<&str>,
373 ) -> Result<Option<String>> {
374 let cart_items = db::cart::get_cart_items_for_seller(db, user.id, seller_id)
375 .await
376 .context("fetch cart items for seller")?;
377 if cart_items.is_empty() {
378 return Err(AppError::BadRequest(
379 "No items in cart for this creator".to_string(),
380 ));
381 }
382
383 let seller = db::users::get_user_by_id(db, seller_id)
384 .await
385 .context("fetch seller")?
386 .ok_or(AppError::NotFound)?;
387 if seller.is_suspended() || seller.is_deactivated() || seller.is_creator_paused() {
388 return Err(AppError::BadRequest(
389 "This creator's account is not active".to_string(),
390 ));
391 }
392
393 // Bulk-check ownership in a single query instead of N sequential roundtrips.
394 let cart_item_ids: Vec<db::ItemId> = cart_items.iter().map(|c| c.item_id).collect();
395 let already_owned = db::transactions::purchased_subset(db, user.id, &cart_item_ids)
396 .await
397 .context("bulk check existing purchases")?;
398
399 let mut free_items: Vec<&db::cart::CartItem> = Vec::new();
400 let mut paid_items: Vec<&db::cart::CartItem> = Vec::new();
401 for item in &cart_items {
402 if already_owned.contains(&item.item_id) {
403 if let Err(e) = db::cart::remove_from_cart(db, user.id, item.item_id).await {
404 tracing::warn!(
405 user_id = %user.id, item_id = %item.item_id, error = ?e,
406 "failed to remove already-purchased item from cart; buyer will see it lingering on /cart"
407 );
408 }
409 continue;
410 }
411 if item.is_free() {
412 free_items.push(item);
413 } else {
414 paid_items.push(item);
415 }
416 }
417
418 // Validate an optional promo code and compute per-item discounted prices plus
419 // the platform credit MNW owes the seller when the code is a platform-wide
420 // (Fan+) credit, so the creator is reimbursed the discount and still nets the
421 // full price (same invariant as the single-item path; both destructure the
422 // shared `AppliedDiscount`).
423 let mut promo_code_id: Option<PromoCodeId> = None;
424 let mut discounted_prices: std::collections::HashMap<db::ItemId, i32> =
425 std::collections::HashMap::new();
426 let mut platform_credits: std::collections::HashMap<db::ItemId, i64> =
427 std::collections::HashMap::new();
428 if let Some(code_str) = promo_code.map(str::trim).filter(|s| !s.is_empty())
429 && let Some(validated) =
430 db::promo_codes::lookup_and_validate_promo(db, seller_id, Some(user.id), code_str)
431 .await?
432 {
433 use db::promo_codes::PromoApplication;
434 // Apply to each eligible paid item; ineligible items (scope/min-price)
435 // are skipped so the rest of the cart can still qualify.
436 //
437 // A platform-wide credit (the $5 Fan+ renewal credit) is a monetary
438 // BALANCE spent at most once across the whole cart, NOT a per-line
439 // coupon: without a budget it would discount the buyer and reimburse the
440 // seller once per eligible line, an N-times payout from a single-use
441 // credit (ultra-fuzz Run 13 Payments SERIOUS). `credit_budget` is the
442 // code's face value (`None` for seller-funded/percentage codes, which
443 // have no balance to over-spend and stay per-line); each line's credit is
444 // capped to the remainder and the uncovered discount reverts to the buyer.
445 let mut credit_budget = validated.platform_credit_budget_cents();
446 for item in &paid_items {
447 if item.pwyw_enabled {
448 continue; // PWYW items can't take a promo (single-item behavior)
449 }
450 if let PromoApplication::Apply(applied) = db::promo_codes::apply_promo_to_item(
451 &validated,
452 item.item_id,
453 item.project_id,
454 item.effective_price_cents(),
455 )? {
456 let (final_price, credit) =
457 db::promo_codes::cap_line_to_credit_budget(applied, &mut credit_budget);
458 discounted_prices.insert(item.item_id, final_price);
459 if credit > 0 {
460 platform_credits.insert(item.item_id, credit);
461 }
462 }
463 }
464 promo_code_id = Some(validated.id());
465 }
466
467 // Re-classify after discount: some paid items may now be free. Each carries the
468 // platform credit (0 for creator-funded discounts) so the seller is reimbursed
469 // whether the item ends up paid-at-a-discount or free.
470 let mut newly_free: Vec<(&db::cart::CartItem, i64)> = Vec::new();
471 let mut still_paid: Vec<(&db::cart::CartItem, i32, i64)> = Vec::new();
472 for item in &paid_items {
473 let final_price = discounted_prices
474 .get(&item.item_id)
475 .copied()
476 .unwrap_or_else(|| item.effective_price_cents());
477 let credit = platform_credits.get(&item.item_id).copied().unwrap_or(0);
478 if final_price == 0 {
479 newly_free.push((item, credit));
480 } else {
481 still_paid.push((item, final_price, credit));
482 }
483 }
484 let claimed_any_free = !free_items.is_empty() || !newly_free.is_empty();
485
486 // When the whole cart is free after discounts, reserve the promo use BEFORE
487 // claiming anything. An all-free cart still consumes exactly ONE use of the
488 // code (Run 10 Pay S1: one checkout = one use), and a reached-limit code must
489 // reject before any free item is granted, mirroring the paid path's
490 // reserve-before-fulfil discipline at the Stripe branch below. Without this,
491 // a max_uses-limited or 100%-off code would be redeemable unlimited times via
492 // an all-free cart, which returns early past the paid-path reservation.
493 if still_paid.is_empty()
494 && let Some(pc_id) = promo_code_id
495 {
496 let reserved = db::promo_codes::try_increment_use_count(db, pc_id)
497 .await
498 .context("reserve promo code use at free cart checkout")?;
499 if !reserved {
500 return Err(AppError::BadRequest(
501 "This promo code has reached its usage limit".to_string(),
502 ));
503 }
504 }
505
506 // Genuinely-free items carry no platform credit and no promo dependency, so
507 // they're safe to grant now on either path.
508 let free_with_credit: Vec<(&db::cart::CartItem, i64)> =
509 free_items.iter().map(|it| (*it, 0i64)).collect();
510 claim_free_cart_items(
511 db,
512 wam,
513 user.id,
514 seller_id,
515 &free_with_credit,
516 share_contact,
517 )
518 .await?;
519
520 // No paid items remain after discounts: the single promo use is already
521 // reserved above, so grant the promo-freed lines and finish.
522 if still_paid.is_empty() {
523 claim_free_cart_items(db, wam, user.id, seller_id, &newly_free, share_contact).await?;
524 if share_contact && claimed_any_free {
525 db::transactions::clear_contact_revocation(db, user.id, seller_id)
526 .await
527 .context("clear contact revocation")?;
528 }
529 return Ok(None);
530 }
531
532 // Mixed cart: the promo-freed lines (`newly_free`) are deliberately NOT
533 // granted yet. They must wait until the single promo use is reserved below,
534 // otherwise two concurrent single-use checkouts could both claim the freed
535 // items before either reserves, and the reservation loser keeps them for free
536 // (plus the platform-credit obligation on those lines) (Run 21 payments).
537
538 // Verify Stripe is ready and the total clears the minimum BEFORE reserving
539 // the promo, so neither reject burns a single-use code.
540 let stripe_account_id = seller
541 .stripe_account_id
542 .as_deref()
543 .ok_or_else(|| AppError::BadRequest("Creator hasn't set up payments yet".to_string()))?;
544 if !seller.stripe_charges_enabled {
545 return Err(AppError::BadRequest(
546 "Creator's payment account is not ready".to_string(),
547 ));
548 }
549 let stripe = payments
550 .stripe
551 .as_ref()
552 .ok_or_else(|| AppError::BadRequest("Stripe is not configured".to_string()))?;
553
554 let line_items: Vec<crate::payments::CartLineItem> = still_paid
555 .iter()
556 .map(
557 |(item, final_price, _credit)| crate::payments::CartLineItem {
558 title: &item.title,
559 amount_cents: *final_price as i64,
560 },
561 )
562 .collect();
563
564 // Reject sub-Stripe-minimum totals before calling Stripe: chained promo+PWYW
565 // combinations can land between 1¢ and 49¢, and Stripe's own error for that
566 // is not user-friendly.
567 let cart_total: i64 = line_items.iter().map(|li| li.amount_cents).sum();
568 if cart_total > 0 && cart_total < crate::constants::STRIPE_MINIMUM_CHARGE_CENTS {
569 return Err(AppError::BadRequest(format!(
570 "Minimum cart total is {}",
571 crate::formatting::format_revenue(crate::constants::STRIPE_MINIMUM_CHARGE_CENTS)
572 )));
573 }
574
575 // Pre-check the partial unique index `(buyer_id, item_id) WHERE status='pending'`
576 // BEFORE creating the Stripe session, so we never charge for an item that
577 // can't get a pending row (and would therefore never be fulfilled).
578 let paid_item_ids: Vec<db::ItemId> = still_paid.iter().map(|(it, _, _)| it.item_id).collect();
579 let pending_collisions = db::transactions::pending_subset(db, user.id, &paid_item_ids)
580 .await
581 .context("pre-check pending cart purchases")?;
582 if !pending_collisions.is_empty() {
583 return Err(AppError::BadRequest(
584 "You already have a checkout in progress for one or more of these items. \
585 Complete or cancel that checkout before starting a new one."
586 .to_string(),
587 ));
588 }
589
590 // Reserve the promo use only now that every cheap reject is behind us.
591 //
592 // Semantics (ultra-fuzz Run 10 Pay S1, decided): one cart checkout consumes
593 // exactly ONE use of the code, even when the code discounts multiple eligible
594 // lines above. One redemption = one use, the same accounting as a
595 // single-item checkout (one item-checkout = one use). Reserving per
596 // discounted line would be a different product, not a bug fix; keep this a
597 // single increment.
598 if let Some(pc_id) = promo_code_id {
599 let reserved = db::promo_codes::try_increment_use_count(db, pc_id)
600 .await
601 .context("reserve promo code use at cart checkout")?;
602 if !reserved {
603 return Err(AppError::BadRequest(
604 "This promo code has reached its usage limit".to_string(),
605 ));
606 }
607 }
608
609 // Reservation succeeded (or there's no promo), now safe to grant the
610 // promo-freed lines. Doing this AFTER the atomic reserve closes the
611 // concurrent double-spend: a checkout that loses the single-use reservation
612 // errored above and never reaches here.
613 if !newly_free.is_empty() {
614 claim_free_cart_items(db, wam, user.id, seller_id, &newly_free, share_contact).await?;
615 }
616
617 let success_url = format!(
618 "{}/stripe/success?session_id={{CHECKOUT_SESSION_ID}}",
619 config.host_url
620 );
621 let cancel_url = format!("{}/cart", config.host_url);
622 let cart_params = crate::payments::CartCheckoutParams {
623 connected_account_id: stripe_account_id,
624 line_items: &line_items,
625 buyer_id: user.id,
626 seller_id,
627 success_url: &success_url,
628 cancel_url: &cancel_url,
629 enable_stripe_tax: seller.stripe_tax_enabled,
630 };
631
632 let result = match stripe.create_cart_checkout_session(&cart_params).await {
633 Ok(r) => r,
634 Err(e) => {
635 if let Some(pc_id) = promo_code_id {
636 release_promo_quietly(db, pc_id, user.id).await;
637 }
638 return Err(e).context("create cart checkout session");
639 }
640 };
641
642 if let Err(e) = create_cart_pending_transactions(
643 db,
644 user.id,
645 seller_id,
646 &result.id,
647 &still_paid,
648 share_contact,
649 promo_code_id,
650 )
651 .await
652 {
653 // The Stripe session is already live and cannot be un-created here. If the
654 // buyer pays it, the cart-completion webhook finds no pending rows and
655 // escalates it as an orphaned paid session (Run #2 Payments SERIOUS).
656 // Release the promo reservation so it isn't stuck held by a dead session.
657 if let Some(pc_id) = promo_code_id {
658 release_promo_quietly(db, pc_id, user.id).await;
659 }
660 tracing::error!(
661 session_id = %result.id, error = ?e,
662 "failed to create cart pending transactions after opening Stripe session; session is orphaned if paid"
663 );
664 return Err(e).context("create cart pending transactions");
665 }
666
667 // Cart items are removed by the webhook handler on successful payment, so a
668 // canceled Stripe checkout leaves the cart intact.
669 result
670 .url
671 .map(Some)
672 .ok_or_else(|| AppError::BadRequest("No checkout URL returned".to_string()))
673 }
674
675 /// Process the cart queue starting with `first_seller_id`. Loops while
676 /// [`checkout_seller_cart`] returns `Ok(None)` (all items for that seller were
677 /// free), draining the session queue. Returns the Stripe checkout URL the
678 /// moment a paid seller is reached, or `None` when the queue is exhausted with
679 /// every item claimed free.
680 ///
681 /// Chained checkout never carries a promo (`promo_code = None`); discounts are
682 /// only applied on direct single-seller form submissions.
683 #[tracing::instrument(skip_all, name = "stripe::drain_to_paid", fields(user_id = %user.id, first_seller_id = %first_seller_id))]
684 #[allow(clippy::too_many_arguments)]
685 pub(super) async fn drain_to_paid(
686 db: &PgPool,
687 wam: Option<&WamClient>,
688 payments: &Billing,
689 config: &Config,
690 user: &crate::auth::SessionUser,
691 first_seller_id: String,
692 share_contact: bool,
693 session: &tower_sessions::Session,
694 ) -> Result<Option<String>> {
695 let mut current = first_seller_id;
696 loop {
697 let seller_id: UserId = current
698 .parse()
699 .map_err(|_| AppError::BadRequest("Invalid seller ID".to_string()))?;
700 if let Some(url) = checkout_seller_cart(
701 db,
702 wam,
703 payments,
704 config,
705 user,
706 seller_id,
707 share_contact,
708 None,
709 )
710 .await?
711 {
712 return Ok(Some(url));
713 }
714 // All items for `current` were free. Pop the next queued seller and
715 // try again; on empty queue, signal "everything claimed".
716 let next: Option<String> = match session.get::<Vec<String>>("cart_queue").await {
717 Ok(Some(mut queue)) if !queue.is_empty() => {
718 let n = queue.remove(0);
719 if queue.is_empty() {
720 session.remove::<Vec<String>>("cart_queue").await.ok();
721 session.remove::<bool>("cart_share_contact").await.ok();
722 } else {
723 session.insert("cart_queue", queue).await.ok();
724 }
725 Some(n)
726 }
727 _ => None,
728 };
729 match next {
730 Some(n) => current = n,
731 None => return Ok(None),
732 }
733 }
734 }
735