Skip to main content

max / makenotwork

20.3 KB · 505 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, PromoCodeId, UserId},
13 error::{AppError, Result, ResultExt},
14 helpers,
15 AppState,
16 };
17
18 use super::grant_bundle_items;
19
20 /// Release a promo reservation on a checkout-abort path, logging on failure.
21 ///
22 /// These releases run only after another error has already aborted the
23 /// checkout, so the caller can't surface a failure to the user — but a silent
24 /// drop leaves the promo's use-count incremented (a stuck reservation). Log it
25 /// so an orphaned reservation is traceable rather than invisible.
26 async fn release_promo_quietly(state: &AppState, pc_id: PromoCodeId, user_id: UserId) {
27 if let Err(e) = db::promo_codes::release_use_count_and_detach(&state.db, pc_id, user_id).await {
28 tracing::warn!(
29 promo_code_id = %pc_id,
30 %user_id,
31 error = %e,
32 "failed to release promo reservation on checkout abort; use-count may be stuck"
33 );
34 }
35 }
36
37 /// Form data for cart checkout.
38 #[derive(Debug, Deserialize)]
39 pub(in crate::routes::stripe) struct CartCheckoutForm {
40 pub seller_id: String,
41 #[serde(default)]
42 pub share_contact: bool,
43 pub promo_code: Option<String>,
44 }
45
46 /// POST /stripe/checkout/cart - Checkout all cart items from one seller.
47 ///
48 /// Thin wrapper over [`checkout_seller_cart`]: enforces the buyer-side
49 /// preconditions (suspended/sandbox/self-purchase) the chained path doesn't
50 /// need, then maps the core's `Option<url>` onto a redirect.
51 #[tracing::instrument(skip_all, name = "stripe::cart_checkout", fields(user_id = %user.id))]
52 pub(in crate::routes::stripe) async fn create_cart_checkout(
53 State(state): State<AppState>,
54 AuthUser(user): AuthUser,
55 Form(form): Form<CartCheckoutForm>,
56 ) -> Result<Response> {
57 user.check_not_suspended()?;
58 user.check_not_sandbox()?;
59
60 let seller_id: UserId = form.seller_id.parse()
61 .map_err(|_| AppError::BadRequest("Invalid seller ID".to_string()))?;
62
63 if user.id == seller_id {
64 return Err(AppError::BadRequest("You cannot purchase your own items".to_string()));
65 }
66
67 match checkout_seller_cart(&state, &user, seller_id, form.share_contact, form.promo_code.as_deref()).await? {
68 Some(url) => Ok(Redirect::to(&url).into_response()),
69 None => Ok(Redirect::to("/library?purchase=success").into_response()),
70 }
71 }
72
73 /// Form data for checkout-all (cross-seller).
74 #[derive(Debug, Deserialize)]
75 pub(in crate::routes::stripe) struct CartCheckoutAllForm {
76 #[serde(default)]
77 pub share_contact: bool,
78 }
79
80 /// POST /stripe/checkout/cart/all - Checkout all cart items across all sellers.
81 ///
82 /// Queues seller IDs in the session, processes the first seller, then chains
83 /// through the rest via checkout_success redirects.
84 #[tracing::instrument(skip_all, name = "stripe::cart_checkout_all", fields(user_id = %user.id))]
85 pub(in crate::routes::stripe) async fn create_cart_checkout_all(
86 State(state): State<AppState>,
87 AuthUser(user): AuthUser,
88 session: tower_sessions::Session,
89 Form(form): Form<CartCheckoutAllForm>,
90 ) -> Result<Response> {
91 user.check_not_suspended()?;
92 user.check_not_sandbox()?;
93
94 let cart_items = db::cart::get_cart_items(&state.db, user.id).await
95 .context("fetch all cart items")?;
96
97 if cart_items.is_empty() {
98 return Ok(Redirect::to("/cart").into_response());
99 }
100
101 // Group by seller, collect unique seller IDs in order
102 let mut seen = std::collections::HashSet::new();
103 let mut seller_ids: Vec<String> = Vec::new();
104 for item in &cart_items {
105 let sid = item.seller_id.to_string();
106 if seen.insert(sid.clone()) {
107 seller_ids.push(sid);
108 }
109 }
110
111 if seller_ids.is_empty() {
112 return Ok(Redirect::to("/cart").into_response());
113 }
114
115 // Queue remaining sellers (all except the first) in session
116 let first_seller = seller_ids.remove(0);
117 if !seller_ids.is_empty() {
118 session.insert("cart_queue", seller_ids).await
119 .map_err(|e| AppError::BadRequest(format!("session error: {e}")))?;
120 session.insert("cart_share_contact", form.share_contact).await
121 .map_err(|e| AppError::BadRequest(format!("session error: {e}")))?;
122 }
123
124 // Process the first seller and chain through the queue until we hit a
125 // paid seller (return its Stripe URL) or exhaust everything as free.
126 match drain_to_paid(&state, &user, first_seller, form.share_contact, &session).await? {
127 Some(url) => Ok(Redirect::to(&url).into_response()),
128 None => Ok(Redirect::to("/library?purchase=success").into_response()),
129 }
130 }
131
132 /// Claim a set of free (or discount-zeroed) cart items: insert the free
133 /// transaction, bump the sales count, and grant bundle items + a license key
134 /// when applicable, then bulk-remove the claimed rows from the cart.
135 ///
136 /// Bundle/license fields come from `CartItem`, so this does no per-item
137 /// `get_item_by_id`, and the cart rows are removed in one bulk DELETE after the
138 /// loop (Run #8 perf MED). Shared by the free-by-price and discount-zeroed
139 /// passes so the claim logic exists in exactly one place.
140 async fn claim_free_cart_items(
141 state: &AppState,
142 user_id: UserId,
143 seller_id: UserId,
144 items: &[&db::cart::CartItem],
145 share_contact: bool,
146 ) -> Result<()> {
147 if items.is_empty() {
148 return Ok(());
149 }
150 let mut to_remove: Vec<db::ItemId> = Vec::with_capacity(items.len());
151 for item in items {
152 let claim = db::transactions::ClaimParams {
153 buyer_id: user_id,
154 item_id: item.item_id,
155 seller_id,
156 item_title: &item.title,
157 seller_username: &item.creator_username,
158 share_contact,
159 parent_transaction_id: None,
160 };
161
162 let mut tx = state.db.begin().await.context("begin free-claim transaction")?;
163 let claimed = db::transactions::claim_free_item(&mut *tx, &claim)
164 .await
165 .context("claim free item")?;
166 if claimed {
167 db::items::increment_sales_count(&mut *tx, item.item_id)
168 .await
169 .context("increment sales count")?;
170 }
171 tx.commit().await.context("commit free-claim transaction")?;
172
173 if claimed {
174 if item.item_type == "bundle" {
175 grant_bundle_items(state, item.item_id, user_id, seller_id, None).await;
176 }
177 if item.enable_license_keys {
178 let key_code = helpers::generate_key_code();
179 db::license_keys::create_license_key(
180 &state.db, item.item_id, user_id, None, &key_code,
181 item.default_max_activations,
182 ).await.ok();
183 }
184 }
185
186 to_remove.push(item.item_id);
187 }
188 db::cart::remove_from_cart_bulk(&state.db, user_id, &to_remove).await.ok();
189 Ok(())
190 }
191
192 /// Create the pending transactions for every paid item in one DB transaction,
193 /// so the buyer gets all items or none (no partial delivery on a mid-loop
194 /// failure).
195 ///
196 /// A 23505 means another tab raced past the pre-check; abort the whole cart
197 /// rather than leave a paid Stripe line item with no pending row to fulfill. On
198 /// any error the promo reservation (if any) is released, since the Stripe
199 /// session was already created but no fulfilling rows landed.
200 async fn create_cart_pending_transactions(
201 state: &AppState,
202 user_id: UserId,
203 seller_id: UserId,
204 session_id: &str,
205 items: &[(&db::cart::CartItem, i32)],
206 share_contact: bool,
207 promo_code_id: Option<PromoCodeId>,
208 ) -> Result<()> {
209 let mut db_tx = state.db.begin().await.context("begin cart transaction creation")?;
210 for (item, final_price) in items {
211 match db::transactions::create_transaction(
212 &mut *db_tx,
213 &db::transactions::CreateTransactionParams {
214 buyer_id: Some(user_id),
215 seller_id,
216 item_id: Some(item.item_id),
217 amount_cents: Cents::new(*final_price as i64),
218 platform_fee_cents: Cents::ZERO,
219 stripe_checkout_session_id: session_id,
220 item_title: &item.title,
221 seller_username: &item.creator_username,
222 share_contact,
223 project_id: None,
224 promo_code_id,
225 guest_email: None,
226 },
227 )
228 .await
229 {
230 Ok(_) => {}
231 Err(AppError::Database(sqlx::Error::Database(ref db_err)))
232 if db_err.code().as_deref() == Some("23505") =>
233 {
234 tracing::warn!(
235 buyer_id = %user_id, item_id = %item.item_id,
236 "23505 raced past pre-check during cart pending insert"
237 );
238 if let Some(pc_id) = promo_code_id {
239 release_promo_quietly(state, pc_id, user_id).await;
240 }
241 return Err(AppError::BadRequest(
242 "Another checkout for one of these items started while this one was loading. \
243 Please refresh and try again.".to_string(),
244 ));
245 }
246 Err(e) => {
247 // Transaction auto-rolls back on drop.
248 if let Some(pc_id) = promo_code_id {
249 release_promo_quietly(state, pc_id, user_id).await;
250 }
251 return Err(e).context("create pending transaction for cart item");
252 }
253 }
254 }
255 db_tx.commit().await.context("commit cart pending transactions")?;
256 Ok(())
257 }
258
259 /// Core per-seller cart checkout, shared by the single-seller form
260 /// ([`create_cart_checkout`]) and the cross-seller chain ([`drain_to_paid`]).
261 ///
262 /// Returns `Ok(None)` when every item for this seller was free (claimed inline,
263 /// no Stripe session needed — the chain advances to the next seller), or
264 /// `Ok(Some(url))` with the Stripe Checkout URL for the paid remainder.
265 ///
266 /// Ordering matters: the promo reservation is taken as late as possible — after
267 /// the Stripe-ready, minimum-charge, and pending-collision checks — so an abort
268 /// on any of those can't burn a single-use code. The previous chained copy
269 /// reserved early and leaked the reservation on the Stripe-ready and min-charge
270 /// rejects (inert only because the chain never passed a promo); folding the two
271 /// copies into one removes that divergence.
272 #[tracing::instrument(skip_all, name = "stripe::checkout_seller_cart", fields(user_id = %user.id, %seller_id))]
273 async fn checkout_seller_cart(
274 state: &AppState,
275 user: &crate::auth::SessionUser,
276 seller_id: UserId,
277 share_contact: bool,
278 promo_code: Option<&str>,
279 ) -> Result<Option<String>> {
280 let cart_items = db::cart::get_cart_items_for_seller(&state.db, user.id, seller_id).await
281 .context("fetch cart items for seller")?;
282 if cart_items.is_empty() {
283 return Err(AppError::BadRequest("No items in cart for this creator".to_string()));
284 }
285
286 let seller = db::users::get_user_by_id(&state.db, seller_id)
287 .await
288 .context("fetch seller")?
289 .ok_or(AppError::NotFound)?;
290 if seller.is_suspended() {
291 return Err(AppError::BadRequest("This creator's account is currently unavailable".to_string()));
292 }
293
294 // Bulk-check ownership in a single query instead of N sequential roundtrips.
295 let cart_item_ids: Vec<db::ItemId> = cart_items.iter().map(|c| c.item_id).collect();
296 let already_owned = db::transactions::purchased_subset(&state.db, user.id, &cart_item_ids)
297 .await
298 .context("bulk check existing purchases")?;
299
300 let mut free_items: Vec<&db::cart::CartItem> = Vec::new();
301 let mut paid_items: Vec<&db::cart::CartItem> = Vec::new();
302 for item in &cart_items {
303 if already_owned.contains(&item.item_id) {
304 if let Err(e) = db::cart::remove_from_cart(&state.db, user.id, item.item_id).await {
305 tracing::warn!(
306 user_id = %user.id, item_id = %item.item_id, error = ?e,
307 "failed to remove already-purchased item from cart; buyer will see it lingering on /cart"
308 );
309 }
310 continue;
311 }
312 if item.is_free() {
313 free_items.push(item);
314 } else {
315 paid_items.push(item);
316 }
317 }
318
319 // Claim free-by-price items immediately.
320 claim_free_cart_items(state, user.id, seller_id, &free_items, share_contact).await?;
321
322 // Validate an optional promo code and compute per-item discounted prices.
323 let mut promo_code_id: Option<PromoCodeId> = None;
324 let mut discounted_prices: std::collections::HashMap<db::ItemId, i32> = std::collections::HashMap::new();
325 if let Some(code_str) = promo_code.map(str::trim).filter(|s| !s.is_empty())
326 && let Some(validated) =
327 db::promo_codes::lookup_and_validate_promo(&state.db, seller_id, Some(user.id), code_str).await?
328 {
329 use db::promo_codes::PromoApplication;
330 // Apply to each eligible paid item; ineligible items (scope/min-price)
331 // are skipped so the rest of the cart can still qualify.
332 for item in &paid_items {
333 if item.pwyw_enabled {
334 continue; // PWYW items can't take a promo (single-item behavior)
335 }
336 if let PromoApplication::Apply(price) = db::promo_codes::apply_promo_to_item(
337 &validated, item.item_id, item.project_id, item.effective_price_cents(),
338 )? {
339 discounted_prices.insert(item.item_id, price);
340 }
341 }
342 promo_code_id = Some(validated.id());
343 }
344
345 // Re-classify after discount: some paid items may now be free.
346 let mut newly_free: Vec<&db::cart::CartItem> = Vec::new();
347 let mut still_paid: Vec<(&db::cart::CartItem, i32)> = Vec::new();
348 for item in &paid_items {
349 let final_price = discounted_prices.get(&item.item_id).copied()
350 .unwrap_or_else(|| item.effective_price_cents());
351 if final_price == 0 {
352 newly_free.push(item);
353 } else {
354 still_paid.push((item, final_price));
355 }
356 }
357 let claimed_any_free = !free_items.is_empty() || !newly_free.is_empty();
358 claim_free_cart_items(state, user.id, seller_id, &newly_free, share_contact).await?;
359
360 // No paid items remain after discounts: clear any contact revocation and
361 // signal "all free" so the caller redirects / advances the chain.
362 if still_paid.is_empty() {
363 if share_contact && claimed_any_free {
364 db::transactions::clear_contact_revocation(&state.db, user.id, seller_id)
365 .await
366 .context("clear contact revocation")?;
367 }
368 return Ok(None);
369 }
370
371 // Verify Stripe is ready and the total clears the minimum BEFORE reserving
372 // the promo, so neither reject burns a single-use code.
373 let stripe_account_id = seller.stripe_account_id.as_ref()
374 .ok_or_else(|| AppError::BadRequest("Creator hasn't set up payments yet".to_string()))?;
375 if !seller.stripe_charges_enabled {
376 return Err(AppError::BadRequest("Creator's payment account is not ready".to_string()));
377 }
378 let stripe = state.stripe.as_ref()
379 .ok_or_else(|| AppError::BadRequest("Stripe is not configured".to_string()))?;
380
381 let line_items: Vec<crate::payments::CartLineItem> = still_paid
382 .iter()
383 .map(|(item, final_price)| crate::payments::CartLineItem {
384 title: &item.title,
385 amount_cents: *final_price as i64,
386 })
387 .collect();
388
389 // Reject sub-Stripe-minimum totals before calling Stripe: chained promo+PWYW
390 // combinations can land between 1¢ and 49¢, and Stripe's own error for that
391 // is not user-friendly.
392 let cart_total: i64 = line_items.iter().map(|li| li.amount_cents).sum();
393 if cart_total > 0 && cart_total < crate::constants::STRIPE_MINIMUM_CHARGE_CENTS {
394 return Err(AppError::BadRequest(format!(
395 "Minimum cart total is ${:.2}",
396 crate::constants::STRIPE_MINIMUM_CHARGE_CENTS as f64 / 100.0
397 )));
398 }
399
400 // Pre-check the partial unique index `(buyer_id, item_id) WHERE status='pending'`
401 // BEFORE creating the Stripe session, so we never charge for an item that
402 // can't get a pending row (and would therefore never be fulfilled).
403 let paid_item_ids: Vec<db::ItemId> = still_paid.iter().map(|(it, _)| it.item_id).collect();
404 let pending_collisions = db::transactions::pending_subset(&state.db, user.id, &paid_item_ids)
405 .await
406 .context("pre-check pending cart purchases")?;
407 if !pending_collisions.is_empty() {
408 return Err(AppError::BadRequest(
409 "You already have a checkout in progress for one or more of these items. \
410 Complete or cancel that checkout before starting a new one.".to_string(),
411 ));
412 }
413
414 // Reserve the promo use only now that every cheap reject is behind us.
415 if let Some(pc_id) = promo_code_id {
416 let reserved = db::promo_codes::try_increment_use_count(&state.db, pc_id)
417 .await
418 .context("reserve promo code use at cart checkout")?;
419 if !reserved {
420 return Err(AppError::BadRequest("This promo code has reached its usage limit".to_string()));
421 }
422 }
423
424 let success_url = format!(
425 "{}/stripe/success?session_id={{CHECKOUT_SESSION_ID}}",
426 state.config.host_url
427 );
428 let cancel_url = format!("{}/cart", state.config.host_url);
429 let cart_params = crate::payments::CartCheckoutParams {
430 connected_account_id: stripe_account_id,
431 line_items: &line_items,
432 buyer_id: user.id,
433 seller_id,
434 success_url: &success_url,
435 cancel_url: &cancel_url,
436 enable_stripe_tax: seller.stripe_tax_enabled,
437 };
438
439 let result = match stripe.create_cart_checkout_session(&cart_params).await {
440 Ok(r) => r,
441 Err(e) => {
442 if let Some(pc_id) = promo_code_id {
443 release_promo_quietly(state, pc_id, user.id).await;
444 }
445 return Err(e).context("create cart checkout session");
446 }
447 };
448
449 create_cart_pending_transactions(
450 state, user.id, seller_id, &result.id, &still_paid, share_contact, promo_code_id,
451 )
452 .await?;
453
454 // Cart items are removed by the webhook handler on successful payment, so a
455 // canceled Stripe checkout leaves the cart intact.
456 result.url
457 .map(Some)
458 .ok_or_else(|| AppError::BadRequest("No checkout URL returned".to_string()))
459 }
460
461 /// Process the cart queue starting with `first_seller_id`. Loops while
462 /// [`checkout_seller_cart`] returns `Ok(None)` (all items for that seller were
463 /// free), draining the session queue. Returns the Stripe checkout URL the
464 /// moment a paid seller is reached, or `None` when the queue is exhausted with
465 /// every item claimed free.
466 ///
467 /// Chained checkout never carries a promo (`promo_code = None`); discounts are
468 /// only applied on direct single-seller form submissions.
469 #[tracing::instrument(skip_all, name = "stripe::drain_to_paid", fields(user_id = %user.id, first_seller_id = %first_seller_id))]
470 pub(super) async fn drain_to_paid(
471 state: &AppState,
472 user: &crate::auth::SessionUser,
473 first_seller_id: String,
474 share_contact: bool,
475 session: &tower_sessions::Session,
476 ) -> Result<Option<String>> {
477 let mut current = first_seller_id;
478 loop {
479 let seller_id: UserId = current.parse()
480 .map_err(|_| AppError::BadRequest("Invalid seller ID".to_string()))?;
481 if let Some(url) = checkout_seller_cart(state, user, seller_id, share_contact, None).await? {
482 return Ok(Some(url));
483 }
484 // All items for `current` were free. Pop the next queued seller and
485 // try again; on empty queue, signal "everything claimed".
486 let next: Option<String> = match session.get::<Vec<String>>("cart_queue").await {
487 Ok(Some(mut queue)) if !queue.is_empty() => {
488 let n = queue.remove(0);
489 if queue.is_empty() {
490 session.remove::<Vec<String>>("cart_queue").await.ok();
491 session.remove::<bool>("cart_share_contact").await.ok();
492 } else {
493 session.insert("cart_queue", queue).await.ok();
494 }
495 Some(n)
496 }
497 _ => None,
498 };
499 match next {
500 Some(n) => current = n,
501 None => return Ok(None),
502 }
503 }
504 }
505