Skip to main content

max / makenotwork

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