Skip to main content

max / makenotwork

20.0 KB · 526 lines History Blame Raw
1 //! Item checkout and bundle grant logic.
2
3 use axum::{
4 Form,
5 extract::{Path, State},
6 response::{IntoResponse, Redirect, Response},
7 };
8
9 use crate::{
10 Billing, Integrations,
11 auth::AuthUser,
12 config::Config,
13 db::{self, Cents, ItemId, PromoCodeId},
14 email::EmailClient,
15 error::{AppError, Result, ResultExt},
16 helpers,
17 pricing::{self, CheckoutType},
18 };
19 use sqlx::PgPool;
20
21 use super::CheckoutForm;
22
23 /// POST /stripe/checkout/{item_id} - Create a checkout session and redirect
24 #[tracing::instrument(skip_all, name = "stripe::checkout", fields(item_id))]
25 #[allow(clippy::too_many_arguments)]
26 pub(in crate::routes::stripe) async fn create_checkout(
27 State(db): State<PgPool>,
28 State(bg): State<crate::background::BackgroundTx>,
29 State(email): State<EmailClient>,
30 State(integrations): State<Integrations>,
31 State(payments): State<Billing>,
32 State(config): State<Config>,
33 AuthUser(user): AuthUser,
34 Path(item_id): Path<String>,
35 Form(form): Form<CheckoutForm>,
36 ) -> Result<Response> {
37 tracing::Span::current().record("item_id", tracing::field::display(&item_id));
38 user.check_not_suspended()?;
39 user.check_not_sandbox()?;
40
41 let item_uuid: ItemId = item_id.parse().map_err(|_| AppError::NotFound)?;
42
43 let item = db::items::get_item_by_id(&db, item_uuid)
44 .await
45 .with_context(|| format!("fetch item {item_uuid} for checkout"))?
46 .ok_or(AppError::NotFound)?;
47
48 // Draft items cannot be purchased
49 if !item.is_public {
50 return Err(AppError::BadRequest(
51 "This item is not available for purchase".to_string(),
52 ));
53 }
54
55 // Unlisted items can only be obtained through their bundle
56 if !item.listed {
57 return Err(AppError::BadRequest(
58 "This item is only available as part of a bundle".to_string(),
59 ));
60 }
61
62 // Free items don't need checkout
63 let item_pricing = pricing::for_item(&item);
64 if item_pricing.checkout_type() == CheckoutType::None {
65 return Err(AppError::BadRequest("This item is free".to_string()));
66 }
67
68 // Check if already purchased
69 if db::transactions::has_purchased_item(&db, user.id, item_uuid)
70 .await
71 .context("check existing purchase")?
72 {
73 return Ok(Redirect::to(&format!("/l/{item_id}")).into_response());
74 }
75
76 // Get the seller (creator)
77 let seller_id = db::items::get_item_owner(&db, item_uuid)
78 .await
79 .with_context(|| format!("fetch item owner for {item_uuid}"))?
80 .ok_or(AppError::NotFound)?;
81
82 // A user cannot purchase their own items
83 if user.id == seller_id {
84 return Err(AppError::BadRequest(
85 "You cannot purchase your own items".to_string(),
86 ));
87 }
88
89 let seller = db::users::get_user_by_id(&db, seller_id)
90 .await
91 .with_context(|| format!("fetch seller {seller_id}"))?
92 .ok_or(AppError::NotFound)?;
93
94 if seller.is_suspended() || seller.is_deactivated() || seller.is_creator_paused() {
95 return Err(AppError::BadRequest(
96 "This creator's account is not active".to_string(),
97 ));
98 }
99
100 // Determine base price: PWYW uses buyer's chosen amount, otherwise item price
101 let base_price_cents = if item_pricing.checkout_type() == CheckoutType::PayWhatYouWant {
102 let amount = form.amount_cents.ok_or_else(|| {
103 AppError::BadRequest("Amount is required for pay-what-you-want items".to_string())
104 })?;
105 item_pricing
106 .validate_amount(amount)
107 .map_err(AppError::BadRequest)?;
108 // PWYW with $0 is valid when min is $0, falls through to the
109 // free-claim path at `final_price_cents == 0` below.
110 amount
111 } else {
112 item.price_cents
113 };
114
115 // Validate optional promo code (discount or free_access)
116 let mut final_price_cents = base_price_cents;
117 let mut promo_code_id: Option<PromoCodeId> = None;
118 // Cents MNW owes the creator when a platform-wide (Fan+) credit is applied, so
119 // the creator nets the full price. Non-zero only for platform-funded discounts.
120 let mut platform_credit_cents: i64 = 0;
121
122 if let Some(code_str) = form
123 .promo_code
124 .as_deref()
125 .map(str::trim)
126 .filter(|s| !s.is_empty())
127 {
128 if item.pwyw_enabled {
129 return Err(AppError::BadRequest(
130 "Promo codes cannot be applied to pay-what-you-want items".to_string(),
131 ));
132 }
133 // Buyer's platform-wide Fan+ credit is the fallback when no seller code matches.
134 if let Some(validated) =
135 db::promo_codes::lookup_and_validate_promo(&db, seller_id, Some(user.id), code_str)
136 .await?
137 {
138 use db::promo_codes::{PromoApplication, PromoIneligible};
139 match db::promo_codes::apply_promo_to_item(
140 &validated,
141 item_uuid,
142 item.project_id,
143 base_price_cents,
144 )? {
145 PromoApplication::Apply(applied) => {
146 final_price_cents = applied.price_cents;
147 platform_credit_cents = applied.funding.platform_credit_cents() as i64;
148 }
149 PromoApplication::Ineligible(PromoIneligible::ScopeMismatch) => {
150 return Err(AppError::BadRequest(
151 "This promo code is not valid for this item".to_string(),
152 ));
153 }
154 PromoApplication::Ineligible(PromoIneligible::BelowMinPrice) => {
155 return Err(AppError::BadRequest(
156 "This item does not meet the minimum price for this code".to_string(),
157 ));
158 }
159 }
160 promo_code_id = Some(validated.id());
161 }
162 }
163
164 // If discount makes it free, use claim_free_item flow
165 if final_price_cents == 0 {
166 let claim = db::transactions::ClaimParams {
167 buyer_id: user.id,
168 item_id: item_uuid,
169 seller_id,
170 item_title: &item.title,
171 seller_username: &seller.username,
172 share_contact: form.share_contact,
173 parent_transaction_id: None,
174 platform_credit_cents,
175 };
176
177 // Pre-generate license key params so the promo path can include them in
178 // the same transaction as the claim.
179 let key_code = if item.enable_license_keys {
180 Some(helpers::generate_key_code())
181 } else {
182 None
183 };
184 let lk_params = key_code
185 .as_ref()
186 .map(|kc| db::transactions::LicenseKeyParams {
187 key_code: kc,
188 max_activations: item.default_max_activations,
189 });
190
191 let (claimed, license_key_created) = if let Some(pc_id) = promo_code_id {
192 // Wrap promo code increment + claim + license key in a single transaction
193 let (code_accepted, claimed) = db::transactions::claim_free_with_promo_code(
194 &db,
195 pc_id,
196 &claim,
197 lk_params.as_ref(),
198 )
199 .await
200 .context("claim free item with promo code")?;
201
202 if !code_accepted {
203 return Err(AppError::BadRequest(
204 "This code has reached its usage limit".to_string(),
205 ));
206 }
207 // License key was created inside the transaction if claimed + keys enabled
208 (claimed, claimed && item.enable_license_keys)
209 } else {
210 // Wrap claim + sales count increment in a single transaction
211 let mut tx = db.begin().await.context("begin free-claim transaction")?;
212 let claimed = db::transactions::claim_free_item(&mut *tx, &claim)
213 .await
214 .context("claim free item")?;
215 if claimed {
216 db::items::increment_sales_count(&mut *tx, item_uuid)
217 .await
218 .context("increment sales count")?;
219 }
220 tx.commit().await.context("commit free-claim transaction")?;
221 (claimed, false)
222 };
223
224 if claimed {
225 // Grant access to bundle child items (if this is a bundle)
226 if item.item_type == db::ItemType::Bundle {
227 grant_bundle_items(&db, item_uuid, user.id, seller_id, None).await;
228 }
229
230 // Clear any prior contact revocation if fan is re-sharing
231 if form.share_contact {
232 db::transactions::clear_contact_revocation(&db, user.id, seller_id)
233 .await
234 .context("clear contact revocation")?;
235 }
236
237 // Generate license key if enabled (skip if already created in promo transaction)
238 if item.enable_license_keys && !license_key_created {
239 let key_code = helpers::generate_key_code();
240 match db::license_keys::create_license_key(
241 &db,
242 item_uuid,
243 user.id,
244 None, // no transaction ID for free claims
245 &key_code,
246 item.default_max_activations,
247 )
248 .await
249 {
250 Ok(key) => {
251 tracing::info!(
252 key_id = %key.id, buyer_id = %user.id, item_id = %item_uuid,
253 "license key generated for free claim"
254 );
255 }
256 Err(e) => {
257 // Escalate, don't just log: a claimed item with no key is
258 // silent data loss. Mirror the paid path and cart free-claim
259 // (audit Run 17 Observability). No transaction id on a free
260 // claim, so key the ticket on the item.
261 tracing::error!(
262 buyer_id = %user.id, item_id = %item_uuid, error = ?e,
263 "failed to generate license key for free claim"
264 );
265 if let Some(wam) = integrations.wam.as_ref() {
266 let title =
267 format!("License key not issued (free claim): item {item_uuid}");
268 let body = format!(
269 "User {} claimed free item {item_uuid} but license key \
270 generation failed: {e}\n\nManually issue a key.",
271 user.id,
272 );
273 wam.create_ticket(
274 &title,
275 Some(&body),
276 "critical",
277 "license-key-gen-failed",
278 Some(&item_uuid.to_string()),
279 )
280 .await;
281 }
282 }
283 }
284 }
285
286 // Notify seller of free claim (fire-and-forget)
287 if seller.notify_sale {
288 let buyer_user = db::users::get_user_by_id(&db, user.id).await.ok().flatten();
289 let buyer_username = buyer_user
290 .as_ref()
291 .map_or_else(|| "Someone".to_string(), |b| b.username.to_string());
292 let item_title = item.title.clone();
293 let seller_email = seller.email.clone();
294 let seller_name = seller.display_name.clone();
295 let unsub_url = crate::email::generate_unsubscribe_url(
296 &config.host_url,
297 seller.id,
298 crate::email::UnsubscribeAction::Sale,
299 &seller.id.to_string(),
300 &config.signing_secret,
301 );
302 let email = email.clone();
303 bg.spawn("sale notification", async move {
304 if let Err(e) = email
305 .send_sale_notification(
306 &seller_email,
307 seller_name.as_deref(),
308 &buyer_username,
309 &item_title,
310 "Free",
311 Some(&unsub_url),
312 )
313 .await
314 {
315 tracing::error!(error = ?e, "failed to send sale notification");
316 }
317 });
318 }
319 }
320
321 return Ok(Redirect::to(&format!("/l/{item_id}?purchase=success")).into_response());
322 }
323
324 // Reject sub-Stripe-minimum charges (a Discount promo can land a fixed item
325 // at 1–49¢) using the shared `check_min_charge` the Stripe session call
326 // enforces internally. Gating here, before the promo reservation, means a
327 // rejection doesn't burn a use of the code.
328 crate::payments::check_min_charge(final_price_cents as i64)?;
329
330 // Validate Stripe-readiness BEFORE reserving the promo code use_count.
331 // The original order (reserve → readiness checks) burned a use of a
332 // single-use code every time a buyer hit a creator who lost charges_enabled.
333 let stripe_account_id = seller
334 .stripe_account_id
335 .as_deref()
336 .ok_or_else(|| AppError::BadRequest("Creator hasn't set up payments yet".to_string()))?;
337
338 if !seller.stripe_charges_enabled {
339 return Err(AppError::BadRequest(
340 "Creator's payment account is not ready".to_string(),
341 ));
342 }
343
344 let stripe = payments
345 .stripe
346 .as_ref()
347 .ok_or_else(|| AppError::BadRequest("Stripe is not configured".to_string()))?;
348
349 // Reserve promo code use_count at checkout time (not webhook time) to prevent
350 // concurrent checkouts from exceeding max_uses. If the buyer abandons checkout,
351 // the scheduler releases the reservation when cleaning up stale pending transactions.
352 if let Some(pc_id) = promo_code_id {
353 let reserved = db::promo_codes::try_increment_use_count(&db, pc_id)
354 .await
355 .context("reserve promo code use at checkout")?;
356 if !reserved {
357 return Err(AppError::BadRequest(
358 "This promo code has reached its usage limit".to_string(),
359 ));
360 }
361 }
362
363 // Build URLs
364 let success_url = format!(
365 "{}/stripe/success?session_id={{CHECKOUT_SESSION_ID}}&item_id={}",
366 config.host_url, item_id
367 );
368 let cancel_url = format!("{}/stripe/cancel?item_id={}", config.host_url, item_id);
369
370 // Create the checkout session with the (possibly discounted) price.
371 // If this or the transaction INSERT fails, release the promo code reservation.
372 let checkout_params = crate::payments::CheckoutParams {
373 connected_account_id: stripe_account_id,
374 item_title: &item.title,
375 amount_cents: Cents::new(final_price_cents as i64),
376 buyer_id: user.id,
377 seller_id,
378 item_id: Some(item_uuid),
379 success_url: &success_url,
380 cancel_url: &cancel_url,
381 promo_code_id,
382 enable_stripe_tax: seller.stripe_tax_enabled,
383 };
384 let session = match stripe.create_checkout_session(&checkout_params).await {
385 Ok(s) => s,
386 Err(e) => {
387 if let Some(pc_id) = promo_code_id {
388 db::promo_codes::release_use_count_and_detach(&db, pc_id, user.id)
389 .await
390 .ok();
391 }
392 return Err(e).with_context(|| format!("create Stripe checkout for item {item_uuid}"));
393 }
394 };
395
396 // Create a pending transaction. The partial unique index on
397 // (buyer_id, item_id) WHERE status = 'pending' prevents concurrent
398 // duplicate checkouts, if another checkout is already in progress,
399 // the INSERT fails and we redirect back to the item page.
400 match db::transactions::create_transaction(
401 &db,
402 &db::transactions::CreateTransactionParams {
403 buyer_id: Some(user.id),
404 seller_id,
405 item_id: Some(item_uuid),
406 amount_cents: final_price_cents.into(),
407 platform_fee_cents: Cents::ZERO, // 0% platform fee
408 stripe_checkout_session_id: &session.id,
409 item_title: &item.title,
410 seller_username: &seller.username,
411 share_contact: form.share_contact,
412 project_id: None,
413 promo_code_id,
414 guest_email: None,
415 platform_credit_cents,
416 },
417 )
418 .await
419 {
420 Ok(_) => {}
421 Err(AppError::Database(sqlx::Error::Database(ref db_err)))
422 if db_err.code().as_deref() == Some("23505") =>
423 {
424 if let Some(pc_id) = promo_code_id {
425 db::promo_codes::release_use_count_and_detach(&db, pc_id, user.id)
426 .await
427 .ok();
428 }
429 tracing::info!(buyer_id = %user.id, item_id = %item_uuid, "duplicate pending checkout blocked");
430 return Ok(Redirect::to(&format!("/purchase/{item_id}")).into_response());
431 }
432 Err(e) => {
433 if let Some(pc_id) = promo_code_id {
434 db::promo_codes::release_use_count_and_detach(&db, pc_id, user.id)
435 .await
436 .ok();
437 }
438 return Err(e).context("create pending transaction");
439 }
440 }
441
442 // Redirect to Stripe Checkout
443 let checkout_url = session
444 .url
445 .ok_or_else(|| AppError::BadRequest("No checkout URL returned".to_string()))?;
446
447 Ok(Redirect::to(&checkout_url).into_response())
448 }
449
450 /// POST /stripe/checkout/{item_id}/cancel-pending: delete the buyer's
451 /// in-progress checkout for this item so they can start a fresh one.
452 ///
453 /// Safe to call when no pending row exists (no-op). Releases any reserved
454 /// promo code use_count.
455 #[tracing::instrument(skip_all, name = "stripe::cancel_pending", fields(item_id))]
456 pub(in crate::routes::stripe) async fn cancel_pending_item_checkout(
457 State(db): State<PgPool>,
458 AuthUser(user): AuthUser,
459 Path(item_id): Path<String>,
460 ) -> Result<Response> {
461 tracing::Span::current().record("item_id", tracing::field::display(&item_id));
462 let item_uuid: ItemId = item_id.parse().map_err(|_| AppError::NotFound)?;
463
464 if let Some(promo_id) = db::transactions::delete_pending_item_purchase(&db, user.id, item_uuid)
465 .await
466 .context("delete pending item checkout")?
467 {
468 db::promo_codes::release_use_count(&db, promo_id).await.ok();
469 }
470
471 Ok(Redirect::to(&format!("/purchase/{item_id}")).into_response())
472 }
473
474 /// Grant access to all child items of a purchased bundle.
475 ///
476 /// For each child item, creates a completed $0 transaction (idempotent via
477 /// ON CONFLICT DO NOTHING). Does NOT increment child item sales_count --
478 /// the bundle sale is what counts.
479 pub(crate) async fn grant_bundle_items(
480 db: &PgPool,
481 bundle_id: db::ItemId,
482 buyer_id: db::UserId,
483 seller_id: db::UserId,
484 parent_transaction_id: Option<db::TransactionId>,
485 ) {
486 let child_items = match db::bundles::get_bundle_items(db, bundle_id).await {
487 Ok(items) => items,
488 Err(e) => {
489 tracing::error!(bundle_id = %bundle_id, error = ?e, "failed to load bundle items for granting");
490 return;
491 }
492 };
493
494 let Ok(Some(seller)) = db::users::get_user_by_id(db, seller_id).await else {
495 return;
496 };
497
498 // Claim all children in one INSERT instead of N per-child round-trips (each
499 // re-acquiring a pool connection) on the webhook hot path (Perf-S4, Run 9).
500 // Idempotent via ON CONFLICT; child items deliberately do not bump sales_count.
501 let items: Vec<(db::ItemId, &str)> = child_items
502 .iter()
503 .map(|c| (c.id, c.title.as_str()))
504 .collect();
505 match db::transactions::claim_free_items_batch(
506 db,
507 buyer_id,
508 seller_id,
509 &seller.username,
510 parent_transaction_id,
511 &items,
512 )
513 .await
514 {
515 Ok(granted) => tracing::info!(
516 bundle_id = %bundle_id, buyer_id = %buyer_id,
517 child_count = child_items.len(), granted,
518 "granted bundle child items"
519 ),
520 Err(e) => tracing::warn!(
521 bundle_id = %bundle_id, error = ?e,
522 "failed to grant bundle child items"
523 ),
524 }
525 }
526