Skip to main content

max / makenotwork

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