Skip to main content

max / makenotwork

20.3 KB · 531 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, seller.settlement_currency)
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). The Sale
287 // preference is checked by the send path.
288 {
289 let buyer_user = db::users::get_user_by_id(&db, user.id).await.ok().flatten();
290 let buyer_username = buyer_user
291 .as_ref()
292 .map_or_else(|| "Someone".to_string(), |b| b.username.to_string());
293 let item_title = item.title.clone();
294 let seller_user_id = seller.id;
295 let seller_email = seller.email.clone();
296 let seller_name = seller.display_name.clone();
297 let unsub_url = crate::email::generate_unsubscribe_url(
298 &config.host_url,
299 seller.id,
300 crate::email::UnsubscribeAction::Sale,
301 &seller.id.to_string(),
302 &config.signing_secret,
303 );
304 let email = email.clone();
305 bg.spawn("sale notification", async move {
306 if let Err(e) = email
307 .send_sale_notification(
308 seller_user_id,
309 &seller_email,
310 seller_name.as_deref(),
311 &buyer_username,
312 &item_title,
313 "Free",
314 Some(&unsub_url),
315 )
316 .await
317 {
318 tracing::error!(error = ?e, "failed to send sale notification");
319 }
320 });
321 }
322 }
323
324 return Ok(Redirect::to(&format!("/l/{item_id}?purchase=success")).into_response());
325 }
326
327 // Reject sub-Stripe-minimum charges (a Discount promo can land a fixed item
328 // at 1–49¢) using the shared `check_min_charge` the Stripe session call
329 // enforces internally. Gating here, before the promo reservation, means a
330 // rejection doesn't burn a use of the code.
331 crate::payments::check_min_charge(final_price_cents as i64, seller.settlement_currency)?;
332
333 // Validate Stripe-readiness BEFORE reserving the promo code use_count.
334 // The original order (reserve → readiness checks) burned a use of a
335 // single-use code every time a buyer hit a creator who lost charges_enabled.
336 let stripe_account_id = seller
337 .stripe_account_id
338 .as_deref()
339 .ok_or_else(|| AppError::BadRequest("Creator hasn't set up payments yet".to_string()))?;
340
341 if !seller.stripe_charges_enabled {
342 return Err(AppError::BadRequest(
343 "Creator's payment account is not ready".to_string(),
344 ));
345 }
346
347 let stripe = payments
348 .stripe
349 .as_ref()
350 .ok_or_else(|| AppError::BadRequest("Stripe is not configured".to_string()))?;
351
352 // Reserve promo code use_count at checkout time (not webhook time) to prevent
353 // concurrent checkouts from exceeding max_uses. If the buyer abandons checkout,
354 // the scheduler releases the reservation when cleaning up stale pending transactions.
355 if let Some(pc_id) = promo_code_id {
356 let reserved = db::promo_codes::try_increment_use_count(&db, pc_id)
357 .await
358 .context("reserve promo code use at checkout")?;
359 if !reserved {
360 return Err(AppError::BadRequest(
361 "This promo code has reached its usage limit".to_string(),
362 ));
363 }
364 }
365
366 // Build URLs
367 let success_url = format!(
368 "{}/stripe/success?session_id={{CHECKOUT_SESSION_ID}}&item_id={}",
369 config.host_url, item_id
370 );
371 let cancel_url = format!("{}/stripe/cancel?item_id={}", config.host_url, item_id);
372
373 // Create the checkout session with the (possibly discounted) price.
374 // If this or the transaction INSERT fails, release the promo code reservation.
375 let checkout_params = crate::payments::CheckoutParams {
376 connected_account_id: stripe_account_id,
377 item_title: &item.title,
378 amount_cents: Cents::new(final_price_cents as i64),
379 buyer_id: user.id,
380 seller_id,
381 item_id: Some(item_uuid),
382 success_url: &success_url,
383 cancel_url: &cancel_url,
384 promo_code_id,
385 enable_stripe_tax: seller.stripe_tax_enabled,
386 currency: seller.settlement_currency,
387 conversion: user.conversion_preference,
388 };
389 let session = match stripe.create_checkout_session(&checkout_params).await {
390 Ok(s) => s,
391 Err(e) => {
392 if let Some(pc_id) = promo_code_id {
393 db::promo_codes::release_use_count_and_detach(&db, pc_id, user.id)
394 .await
395 .ok();
396 }
397 return Err(e).with_context(|| format!("create Stripe checkout for item {item_uuid}"));
398 }
399 };
400
401 // Create a pending transaction. The partial unique index on
402 // (buyer_id, item_id) WHERE status = 'pending' prevents concurrent
403 // duplicate checkouts, if another checkout is already in progress,
404 // the INSERT fails and we redirect back to the item page.
405 match db::transactions::create_transaction(
406 &db,
407 &db::transactions::CreateTransactionParams {
408 buyer_id: Some(user.id),
409 seller_id,
410 item_id: Some(item_uuid),
411 amount_cents: final_price_cents.into(),
412 platform_fee_cents: Cents::ZERO, // 0% platform fee
413 stripe_checkout_session_id: &session.id,
414 item_title: &item.title,
415 seller_username: &seller.username,
416 share_contact: form.share_contact,
417 project_id: None,
418 promo_code_id,
419 guest_email: None,
420 platform_credit_cents,
421 },
422 )
423 .await
424 {
425 Ok(_) => {}
426 Err(AppError::Database(sqlx::Error::Database(ref db_err)))
427 if db_err.code().as_deref() == Some("23505") =>
428 {
429 if let Some(pc_id) = promo_code_id {
430 db::promo_codes::release_use_count_and_detach(&db, pc_id, user.id)
431 .await
432 .ok();
433 }
434 tracing::info!(buyer_id = %user.id, item_id = %item_uuid, "duplicate pending checkout blocked");
435 return Ok(Redirect::to(&format!("/purchase/{item_id}")).into_response());
436 }
437 Err(e) => {
438 if let Some(pc_id) = promo_code_id {
439 db::promo_codes::release_use_count_and_detach(&db, pc_id, user.id)
440 .await
441 .ok();
442 }
443 return Err(e).context("create pending transaction");
444 }
445 }
446
447 // Redirect to Stripe Checkout
448 let checkout_url = session
449 .url
450 .ok_or_else(|| AppError::BadRequest("No checkout URL returned".to_string()))?;
451
452 Ok(Redirect::to(&checkout_url).into_response())
453 }
454
455 /// POST /stripe/checkout/{item_id}/cancel-pending: delete the buyer's
456 /// in-progress checkout for this item so they can start a fresh one.
457 ///
458 /// Safe to call when no pending row exists (no-op). Releases any reserved
459 /// promo code use_count.
460 #[tracing::instrument(skip_all, name = "stripe::cancel_pending", fields(item_id))]
461 pub(in crate::routes::stripe) async fn cancel_pending_item_checkout(
462 State(db): State<PgPool>,
463 AuthUser(user): AuthUser,
464 Path(item_id): Path<String>,
465 ) -> Result<Response> {
466 tracing::Span::current().record("item_id", tracing::field::display(&item_id));
467 let item_uuid: ItemId = item_id.parse().map_err(|_| AppError::NotFound)?;
468
469 if let Some(promo_id) = db::transactions::delete_pending_item_purchase(&db, user.id, item_uuid)
470 .await
471 .context("delete pending item checkout")?
472 {
473 db::promo_codes::release_use_count(&db, promo_id).await.ok();
474 }
475
476 Ok(Redirect::to(&format!("/purchase/{item_id}")).into_response())
477 }
478
479 /// Grant access to all child items of a purchased bundle.
480 ///
481 /// For each child item, creates a completed $0 transaction (idempotent via
482 /// ON CONFLICT DO NOTHING). Does NOT increment child item sales_count --
483 /// the bundle sale is what counts.
484 pub(crate) async fn grant_bundle_items(
485 db: &PgPool,
486 bundle_id: db::ItemId,
487 buyer_id: db::UserId,
488 seller_id: db::UserId,
489 parent_transaction_id: Option<db::TransactionId>,
490 ) {
491 let child_items = match db::bundles::get_bundle_items(db, bundle_id).await {
492 Ok(items) => items,
493 Err(e) => {
494 tracing::error!(bundle_id = %bundle_id, error = ?e, "failed to load bundle items for granting");
495 return;
496 }
497 };
498
499 let Ok(Some(seller)) = db::users::get_user_by_id(db, seller_id).await else {
500 return;
501 };
502
503 // Claim all children in one INSERT instead of N per-child round-trips (each
504 // re-acquiring a pool connection) on the webhook hot path (Perf-S4, Run 9).
505 // Idempotent via ON CONFLICT; child items deliberately do not bump sales_count.
506 let items: Vec<(db::ItemId, &str)> = child_items
507 .iter()
508 .map(|c| (c.id, c.title.as_str()))
509 .collect();
510 match db::transactions::claim_free_items_batch(
511 db,
512 buyer_id,
513 seller_id,
514 &seller.username,
515 parent_transaction_id,
516 &items,
517 )
518 .await
519 {
520 Ok(granted) => tracing::info!(
521 bundle_id = %bundle_id, buyer_id = %buyer_id,
522 child_count = child_items.len(), granted,
523 "granted bundle child items"
524 ),
525 Err(e) => tracing::warn!(
526 bundle_id = %bundle_id, error = ?e,
527 "failed to grant bundle child items"
528 ),
529 }
530 }
531