Skip to main content

max / makenotwork

17.3 KB · 425 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, 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().map(str::trim).filter(|s| !s.is_empty()) {
102 if item.pwyw_enabled {
103 return Err(AppError::BadRequest("Promo codes cannot be applied to pay-what-you-want items".to_string()));
104 }
105 // Buyer's platform-wide Fan+ credit is the fallback when no seller code matches.
106 if let Some(validated) =
107 db::promo_codes::lookup_and_validate_promo(&state.db, seller_id, Some(user.id), code_str).await?
108 {
109 use db::promo_codes::{PromoApplication, PromoIneligible};
110 match db::promo_codes::apply_promo_to_item(&validated, item_uuid, item.project_id, item.price_cents)? {
111 PromoApplication::Apply(price) => final_price_cents = price,
112 PromoApplication::Ineligible(PromoIneligible::ScopeMismatch) => {
113 return Err(AppError::BadRequest("This promo code is not valid for this item".to_string()));
114 }
115 PromoApplication::Ineligible(PromoIneligible::BelowMinPrice) => {
116 return Err(AppError::BadRequest("This item does not meet the minimum price for this code".to_string()));
117 }
118 }
119 promo_code_id = Some(validated.id());
120 }
121 }
122
123 // If discount makes it free, use claim_free_item flow
124 if final_price_cents == 0 {
125 let claim = db::transactions::ClaimParams {
126 buyer_id: user.id,
127 item_id: item_uuid,
128 seller_id,
129 item_title: &item.title,
130 seller_username: &seller.username,
131 share_contact: form.share_contact,
132 parent_transaction_id: None,
133 };
134
135 // Pre-generate license key params so the promo path can include them in
136 // the same transaction as the claim.
137 let key_code = if item.enable_license_keys {
138 Some(helpers::generate_key_code())
139 } else {
140 None
141 };
142 let lk_params = key_code.as_ref().map(|kc| db::transactions::LicenseKeyParams {
143 key_code: kc,
144 max_activations: item.default_max_activations,
145 });
146
147 let (claimed, license_key_created) = if let Some(pc_id) = promo_code_id {
148 // Wrap promo code increment + claim + license key in a single transaction
149 let (code_accepted, claimed) = db::transactions::claim_free_with_promo_code(
150 &state.db,
151 pc_id,
152 &claim,
153 lk_params.as_ref(),
154 ).await
155 .context("claim free item with promo code")?;
156
157 if !code_accepted {
158 return Err(AppError::BadRequest("This code has reached its usage limit".to_string()));
159 }
160 // License key was created inside the transaction if claimed + keys enabled
161 (claimed, claimed && item.enable_license_keys)
162 } else {
163 // Wrap claim + sales count increment in a single transaction
164 let mut tx = state.db.begin().await.context("begin free-claim transaction")?;
165 let claimed = db::transactions::claim_free_item(&mut *tx, &claim)
166 .await
167 .context("claim free item")?;
168 if claimed {
169 db::items::increment_sales_count(&mut *tx, item_uuid)
170 .await
171 .context("increment sales count")?;
172 }
173 tx.commit().await.context("commit free-claim transaction")?;
174 (claimed, false)
175 };
176
177 if claimed {
178 // Grant access to bundle child items (if this is a bundle)
179 if item.item_type == db::ItemType::Bundle {
180 grant_bundle_items(&state, item_uuid, user.id, seller_id, None).await;
181 }
182
183 // Clear any prior contact revocation if fan is re-sharing
184 if form.share_contact {
185 db::transactions::clear_contact_revocation(&state.db, user.id, seller_id)
186 .await
187 .context("clear contact revocation")?;
188 }
189
190 // Generate license key if enabled (skip if already created in promo transaction)
191 if item.enable_license_keys && !license_key_created {
192 let key_code = helpers::generate_key_code();
193 match db::license_keys::create_license_key(
194 &state.db,
195 item_uuid,
196 user.id,
197 None, // no transaction ID for free claims
198 &key_code,
199 item.default_max_activations,
200 ).await {
201 Ok(key) => {
202 tracing::info!(
203 key_id = %key.id, buyer_id = %user.id, item_id = %item_uuid,
204 "license key generated for free claim"
205 );
206 }
207 Err(e) => {
208 tracing::error!(
209 buyer_id = %user.id, item_id = %item_uuid, error = ?e,
210 "failed to generate license key for free claim"
211 );
212 }
213 }
214 }
215
216 // Notify seller of free claim (fire-and-forget)
217 if seller.notify_sale {
218 let buyer_user = db::users::get_user_by_id(&state.db, user.id).await.ok().flatten();
219 let buyer_username = buyer_user.as_ref()
220 .map(|b| b.username.to_string())
221 .unwrap_or_else(|| "Someone".to_string());
222 let item_title = item.title.clone();
223 let seller_email = seller.email.clone();
224 let seller_name = seller.display_name.clone();
225 let unsub_url = crate::email::generate_unsubscribe_url(
226 &state.config.host_url, seller.id, crate::email::UnsubscribeAction::Sale, &seller.id.to_string(), &state.config.signing_secret,
227 );
228 spawn_email!(state, "sale notification", |email| {
229 email.send_sale_notification(
230 &seller_email,
231 seller_name.as_deref(),
232 &buyer_username,
233 &item_title,
234 "Free",
235 Some(&unsub_url),
236 )
237 });
238 }
239 }
240
241 return Ok(Redirect::to(&format!("/l/{}?purchase=success", item_id)).into_response());
242 }
243
244 // Reject sub-Stripe-minimum charges (a Discount promo can land a fixed item
245 // at 1–49¢) using the shared `check_min_charge` the Stripe session call
246 // enforces internally. Gating here, before the promo reservation, means a
247 // rejection doesn't burn a use of the code.
248 crate::payments::check_min_charge(final_price_cents as i64)?;
249
250 // Validate Stripe-readiness BEFORE reserving the promo code use_count.
251 // The original order (reserve → readiness checks) burned a use of a
252 // single-use code every time a buyer hit a creator who lost charges_enabled.
253 let stripe_account_id = seller.stripe_account_id.as_ref()
254 .ok_or_else(|| AppError::BadRequest("Creator hasn't set up payments yet".to_string()))?;
255
256 if !seller.stripe_charges_enabled {
257 return Err(AppError::BadRequest("Creator's payment account is not ready".to_string()));
258 }
259
260 let stripe = state.stripe.as_ref()
261 .ok_or_else(|| AppError::BadRequest("Stripe is not configured".to_string()))?;
262
263 // Reserve promo code use_count at checkout time (not webhook time) to prevent
264 // concurrent checkouts from exceeding max_uses. If the buyer abandons checkout,
265 // the scheduler releases the reservation when cleaning up stale pending transactions.
266 if let Some(pc_id) = promo_code_id {
267 let reserved = db::promo_codes::try_increment_use_count(&state.db, pc_id)
268 .await
269 .context("reserve promo code use at checkout")?;
270 if !reserved {
271 return Err(AppError::BadRequest("This promo code has reached its usage limit".to_string()));
272 }
273 }
274
275 // Build URLs
276 let success_url = format!("{}/stripe/success?session_id={{CHECKOUT_SESSION_ID}}&item_id={}", state.config.host_url, item_id);
277 let cancel_url = format!("{}/stripe/cancel?item_id={}", state.config.host_url, item_id);
278
279 // Create the checkout session with the (possibly discounted) price.
280 // If this or the transaction INSERT fails, release the promo code reservation.
281 let checkout_params = crate::payments::CheckoutParams {
282 connected_account_id: stripe_account_id,
283 item_title: &item.title,
284 amount_cents: Cents::new(final_price_cents as i64),
285 buyer_id: user.id,
286 seller_id,
287 item_id: Some(item_uuid),
288 success_url: &success_url,
289 cancel_url: &cancel_url,
290 promo_code_id,
291 enable_stripe_tax: seller.stripe_tax_enabled,
292 };
293 let session = match stripe.create_checkout_session(&checkout_params).await {
294 Ok(s) => s,
295 Err(e) => {
296 if let Some(pc_id) = promo_code_id {
297 db::promo_codes::release_use_count_and_detach(&state.db, pc_id, user.id).await.ok();
298 }
299 return Err(e).with_context(|| format!("create Stripe checkout for item {item_uuid}"));
300 }
301 };
302
303 // Create a pending transaction. The partial unique index on
304 // (buyer_id, item_id) WHERE status = 'pending' prevents concurrent
305 // duplicate checkouts — if another checkout is already in progress,
306 // the INSERT fails and we redirect back to the item page.
307 match db::transactions::create_transaction(
308 &state.db,
309 &db::transactions::CreateTransactionParams {
310 buyer_id: Some(user.id),
311 seller_id,
312 item_id: Some(item_uuid),
313 amount_cents: final_price_cents.into(),
314 platform_fee_cents: Cents::ZERO, // 0% platform fee
315 stripe_checkout_session_id: &session.id,
316 item_title: &item.title,
317 seller_username: &seller.username,
318 share_contact: form.share_contact,
319 project_id: None,
320 promo_code_id,
321 guest_email: None,
322 },
323 ).await {
324 Ok(_) => {}
325 Err(AppError::Database(sqlx::Error::Database(ref db_err)))
326 if db_err.code().as_deref() == Some("23505") =>
327 {
328 if let Some(pc_id) = promo_code_id {
329 db::promo_codes::release_use_count_and_detach(&state.db, pc_id, user.id).await.ok();
330 }
331 tracing::info!(buyer_id = %user.id, item_id = %item_uuid, "duplicate pending checkout blocked");
332 return Ok(Redirect::to(&format!("/purchase/{}", item_id)).into_response());
333 }
334 Err(e) => {
335 if let Some(pc_id) = promo_code_id {
336 db::promo_codes::release_use_count_and_detach(&state.db, pc_id, user.id).await.ok();
337 }
338 return Err(e).context("create pending transaction");
339 }
340 }
341
342 // Redirect to Stripe Checkout
343 let checkout_url = session.url
344 .ok_or_else(|| AppError::BadRequest("No checkout URL returned".to_string()))?;
345
346 Ok(Redirect::to(&checkout_url).into_response())
347 }
348
349 /// POST /stripe/checkout/{item_id}/cancel-pending: delete the buyer's
350 /// in-progress checkout for this item so they can start a fresh one.
351 ///
352 /// Safe to call when no pending row exists (no-op). Releases any reserved
353 /// promo code use_count.
354 #[tracing::instrument(skip_all, name = "stripe::cancel_pending", fields(item_id))]
355 pub(in crate::routes::stripe) async fn cancel_pending_item_checkout(
356 State(state): State<AppState>,
357 AuthUser(user): AuthUser,
358 Path(item_id): Path<String>,
359 ) -> Result<Response> {
360 tracing::Span::current().record("item_id", tracing::field::display(&item_id));
361 let item_uuid: ItemId = item_id.parse().map_err(|_| AppError::NotFound)?;
362
363 if let Some(promo_id) =
364 db::transactions::delete_pending_item_purchase(&state.db, user.id, item_uuid)
365 .await
366 .context("delete pending item checkout")?
367 {
368 db::promo_codes::release_use_count(&state.db, promo_id).await.ok();
369 }
370
371 Ok(Redirect::to(&format!("/purchase/{}", item_id)).into_response())
372 }
373
374 /// Grant access to all child items of a purchased bundle.
375 ///
376 /// For each child item, creates a completed $0 transaction (idempotent via
377 /// ON CONFLICT DO NOTHING). Does NOT increment child item sales_count --
378 /// the bundle sale is what counts.
379 pub(crate) async fn grant_bundle_items(
380 state: &AppState,
381 bundle_id: db::ItemId,
382 buyer_id: db::UserId,
383 seller_id: db::UserId,
384 parent_transaction_id: Option<db::TransactionId>,
385 ) {
386 let child_items = match db::bundles::get_bundle_items(&state.db, bundle_id).await {
387 Ok(items) => items,
388 Err(e) => {
389 tracing::error!(bundle_id = %bundle_id, error = ?e, "failed to load bundle items for granting");
390 return;
391 }
392 };
393
394 let seller = match db::users::get_user_by_id(&state.db, seller_id).await {
395 Ok(Some(u)) => u,
396 _ => return,
397 };
398
399 for child in &child_items {
400 let claim = db::transactions::ClaimParams {
401 buyer_id,
402 item_id: child.id,
403 seller_id,
404 item_title: &child.title,
405 seller_username: &seller.username,
406 share_contact: false,
407 parent_transaction_id,
408 };
409 // Idempotent: ON CONFLICT DO NOTHING if already claimed
410 if let Err(e) = db::transactions::claim_free_item(&state.db, &claim).await {
411 tracing::warn!(
412 child_item_id = %child.id, bundle_id = %bundle_id,
413 error = ?e, "failed to grant bundle child item"
414 );
415 }
416 // Deliberately NOT incrementing sales_count for child items
417 }
418
419 tracing::info!(
420 bundle_id = %bundle_id, buyer_id = %buyer_id,
421 child_count = child_items.len(),
422 "granted bundle child items"
423 );
424 }
425