Skip to main content

max / makenotwork

20.7 KB · 565 lines History Blame Raw
1 //! Guest checkout: purchase items without an MNW account.
2 //!
3 //! These endpoints are public (no auth required) and CORS-enabled for use from
4 //! embedded widgets on external sites.
5
6 use axum::{
7 Json,
8 extract::{Path, State},
9 http::{HeaderValue, StatusCode, header},
10 response::{IntoResponse, Redirect, Response},
11 };
12 use serde::{Deserialize, Serialize};
13
14 use crate::background::BackgroundTx;
15 use crate::config::Config;
16 use crate::email::EmailClient;
17 use crate::{AppStorage, Billing, Integrations};
18 use sqlx::PgPool;
19
20 use crate::{
21 db::{self, Cents, ItemId},
22 error::{AppError, Result, ResultExt},
23 };
24
25 /// Request body for creating a guest checkout session.
26 #[derive(Debug, Deserialize)]
27 pub(super) struct GuestCheckoutRequest {
28 /// Buyer-chosen amount in cents (only for PWYW items).
29 pub amount_cents: Option<i32>,
30 /// Optional promo/discount code.
31 pub promo_code: Option<String>,
32 }
33
34 /// Response from creating a guest checkout session.
35 #[derive(Serialize)]
36 struct CheckoutResponse {
37 checkout_url: String,
38 }
39
40 /// POST /api/checkout/guest/{item_id}
41 ///
42 /// Creates a Stripe Checkout Session for a guest purchase (no account required).
43 /// Returns the Stripe checkout URL. The embed or item page opens this in a popup
44 /// or redirects to it.
45 #[tracing::instrument(skip_all, name = "guest_checkout::create")]
46 pub(super) async fn create_guest_checkout(
47 State(db): State<PgPool>,
48 State(config): State<Config>,
49 State(payments): State<Billing>,
50 Path(item_id): Path<ItemId>,
51 Json(body): Json<GuestCheckoutRequest>,
52 ) -> Result<Response> {
53 // Fetch item
54 let item = db::items::get_item_by_id(&db, item_id)
55 .await?
56 .ok_or(AppError::NotFound)?;
57
58 if !item.is_public || !item.listed {
59 return Err(AppError::NotFound);
60 }
61
62 // Fetch seller via project
63 let project = db::projects::get_project_by_id(&db, item.project_id)
64 .await?
65 .ok_or(AppError::NotFound)?;
66 let seller = db::users::get_user_by_id(&db, project.user_id)
67 .await?
68 .ok_or(AppError::NotFound)?;
69
70 if seller.is_suspended() || seller.is_deactivated() || seller.is_creator_paused() {
71 return Err(AppError::NotFound);
72 }
73
74 let seller_id = seller.id;
75
76 // Determine price, use the same pricing model as the authenticated checkout
77 let pricing = crate::pricing::for_item(&item);
78 let mut final_price_cents = if item.pwyw_enabled {
79 let buyer_amount = body.amount_cents.unwrap_or(item.price_cents);
80 pricing
81 .validate_amount(buyer_amount, seller.settlement_currency)
82 .map_err(AppError::BadRequest)?;
83 buyer_amount
84 } else {
85 item.price_cents
86 };
87
88 // Free items: skip Stripe entirely, redirect to the free claim endpoint
89 if final_price_cents == 0 {
90 return Err(AppError::BadRequest(
91 "Free items use /api/checkout/guest-free/{item_id} instead".to_string(),
92 ));
93 }
94
95 // Resolve and validate promo code with the same checks as authenticated checkout
96 let mut promo_code_id = None;
97 if let Some(code_str) = body
98 .promo_code
99 .as_deref()
100 .map(str::trim)
101 .filter(|s| !s.is_empty())
102 {
103 if item.pwyw_enabled {
104 return Err(AppError::BadRequest(
105 "Promo codes cannot be applied to pay-what-you-want items".to_string(),
106 ));
107 }
108 // Guests have no account, so no platform-wide Fan+ credit fallback (None).
109 if let Some(validated) =
110 db::promo_codes::lookup_and_validate_promo(&db, seller_id, None, code_str).await?
111 {
112 use db::promo_codes::{PromoApplication, PromoIneligible};
113 match db::promo_codes::apply_promo_to_item(
114 &validated,
115 item_id,
116 item.project_id,
117 item.price_cents,
118 )? {
119 PromoApplication::Apply(applied) => {
120 // Guests have no account and thus no platform-wide Fan+ credit, so a
121 // guest discount is always creator-funded. Guard the invariant rather
122 // than silently dropping a reimbursement obligation.
123 if applied.funding.platform_credit_cents() > 0 {
124 return Err(AppError::BadRequest(
125 "This code cannot be used for guest checkout".to_string(),
126 ));
127 }
128 final_price_cents = applied.price_cents;
129 }
130 PromoApplication::Ineligible(PromoIneligible::ScopeMismatch) => {
131 return Err(AppError::BadRequest(
132 "This promo code is not valid for this item".to_string(),
133 ));
134 }
135 PromoApplication::Ineligible(PromoIneligible::BelowMinPrice) => {
136 return Err(AppError::BadRequest(
137 "This item does not meet the minimum price for this code".to_string(),
138 ));
139 }
140 }
141 promo_code_id = Some(validated.id());
142 }
143 }
144
145 // If a promo code brought the price to zero, redirect to the free claim flow
146 if final_price_cents == 0 {
147 return Err(AppError::BadRequest(
148 "Free items use /api/checkout/guest-free/{item_id} instead".to_string(),
149 ));
150 }
151
152 // Reject sub-Stripe-minimum charges (a Discount promo can land a fixed item
153 // at 1–49¢) using the shared `check_min_charge` the Stripe session call
154 // enforces internally. Gating here, before the promo reservation, means a
155 // rejection doesn't burn a use of the code.
156 crate::payments::check_min_charge(final_price_cents as i64, seller.settlement_currency)?;
157
158 // Verify seller has Stripe configured
159 let stripe_account_id = seller
160 .stripe_account_id
161 .as_deref()
162 .ok_or_else(|| AppError::BadRequest("Creator hasn't set up payments yet".to_string()))?;
163 if !seller.stripe_charges_enabled {
164 return Err(AppError::BadRequest(
165 "Creator's payment account is not ready".to_string(),
166 ));
167 }
168
169 let stripe = payments
170 .stripe
171 .as_ref()
172 .ok_or_else(|| AppError::BadRequest("Stripe is not configured".to_string()))?;
173
174 // Reserve the promo code BEFORE creating the Stripe session or pending row,
175 // mirroring the authenticated item-checkout path. The old order (reserve
176 // last) meant a swallowed 23505 returned a live checkout URL with NO pending
177 // row for the webhook to complete, the buyer paid and the sale vanished,
178 // and a failed reservation could orphan the pending row's promo. Every
179 // failure path below now releases this reservation.
180 if let Some(pc_id) = promo_code_id {
181 let reserved = db::promo_codes::try_increment_use_count(&db, pc_id)
182 .await
183 .context("reserve promo code use at guest checkout")?;
184 if !reserved {
185 return Err(AppError::BadRequest(
186 "This promo code has reached its usage limit".to_string(),
187 ));
188 }
189 }
190
191 // Release the reservation above on any failure path below (no-op if no promo).
192 let release_promo = || async {
193 if let Some(pc_id) = promo_code_id {
194 db::promo_codes::release_use_count(&db, pc_id).await.ok();
195 }
196 };
197
198 // Build URLs
199 let success_url = format!(
200 "{}/stripe/success?session_id={{CHECKOUT_SESSION_ID}}",
201 config.host_url
202 );
203 let cancel_url = format!("{}/i/{}", config.host_url, item_id);
204
205 // Create guest checkout session
206 let checkout_params = crate::payments::GuestCheckoutParams {
207 connected_account_id: stripe_account_id,
208 item_title: &item.title,
209 amount_cents: Cents::new(final_price_cents as i64),
210 seller_id,
211 item_id,
212 success_url: &success_url,
213 cancel_url: &cancel_url,
214 promo_code_id,
215 enable_stripe_tax: seller.stripe_tax_enabled,
216 currency: seller.settlement_currency,
217 // A guest has no stored preference, so they get the default: the path
218 // where Stripe shows the converted total before they commit.
219 conversion: crate::currency::ConversionChoice::default(),
220 };
221 let result = match stripe.create_guest_checkout_session(&checkout_params).await {
222 Ok(r) => r,
223 Err(e) => {
224 release_promo().await;
225 return Err(e)
226 .with_context(|| format!("create guest Stripe checkout for item {item_id}"));
227 }
228 };
229
230 // Create pending transaction (buyer_id = None for guest). On a unique
231 // violation (a checkout for this item is already in progress) we must NOT
232 // return the live Stripe URL: there'd be no pending row for the webhook to
233 // complete, so the buyer would be charged and the sale silently lost.
234 // Release the promo and return an error (guests have no purchase page to
235 // redirect to, unlike the authenticated path).
236 match db::transactions::create_transaction(
237 &db,
238 &db::transactions::CreateTransactionParams {
239 buyer_id: None,
240 seller_id,
241 item_id: Some(item_id),
242 amount_cents: final_price_cents.into(),
243 platform_fee_cents: Cents::ZERO,
244 stripe_checkout_session_id: &result.id,
245 item_title: &item.title,
246 seller_username: &seller.username,
247 share_contact: false,
248 project_id: Some(item.project_id),
249 promo_code_id,
250 guest_email: None, // Set by webhook when Stripe provides it
251 platform_credit_cents: 0, // guests have no platform-wide credit (guarded above)
252 },
253 )
254 .await
255 {
256 Ok(_) => {}
257 // 23505 backstop. Note: the pending-checkout partial unique index is
258 // `(buyer_id, item_id) WHERE status = 'pending'`, and a guest row always
259 // has `buyer_id = NULL` (NULLS DISTINCT), so this branch does NOT dedup
260 // rapid guest re-submits the way it does for the authenticated path,
261 // guests have no stable identity at creation time (guest_email arrives
262 // later, from the Stripe webhook). Guest checkout flooding is instead
263 // bounded by the endpoint's per-IP rate limit (GUEST_CHECKOUT_RATE_LIMIT_*
264 // in api/mod.rs). This branch therefore only fires on the effectively
265 // impossible stripe_checkout_session_id collision; true per-guest dedup
266 // would need a client-supplied idempotency key (a protocol change).
267 Err(AppError::Database(sqlx::Error::Database(ref db_err)))
268 if db_err.code().as_deref() == Some("23505") =>
269 {
270 release_promo().await;
271 tracing::info!(item_id = %item_id, "duplicate pending guest checkout blocked");
272 return Err(AppError::BadRequest(
273 "A checkout for this item is already in progress. Please complete or cancel it before starting another.".to_string(),
274 ));
275 }
276 Err(e) => {
277 release_promo().await;
278 return Err(e).context("create pending guest transaction");
279 }
280 }
281
282 let checkout_url = result
283 .url
284 .ok_or_else(|| AppError::BadRequest("No checkout URL returned".to_string()))?;
285
286 let mut response = Json(CheckoutResponse { checkout_url }).into_response();
287
288 // CORS headers for cross-origin embed usage
289 let headers = response.headers_mut();
290 headers.insert(
291 header::ACCESS_CONTROL_ALLOW_ORIGIN,
292 HeaderValue::from_static("*"),
293 );
294 headers.insert(
295 header::ACCESS_CONTROL_ALLOW_METHODS,
296 HeaderValue::from_static("POST, OPTIONS"),
297 );
298 headers.insert(
299 header::ACCESS_CONTROL_ALLOW_HEADERS,
300 HeaderValue::from_static("content-type"),
301 );
302
303 Ok(response)
304 }
305
306 /// OPTIONS /api/checkout/guest/{item_id}: CORS preflight
307 pub(super) async fn guest_checkout_preflight() -> Response {
308 let mut response = StatusCode::NO_CONTENT.into_response();
309 let headers = response.headers_mut();
310 headers.insert(
311 header::ACCESS_CONTROL_ALLOW_ORIGIN,
312 HeaderValue::from_static("*"),
313 );
314 headers.insert(
315 header::ACCESS_CONTROL_ALLOW_METHODS,
316 HeaderValue::from_static("POST, OPTIONS"),
317 );
318 headers.insert(
319 header::ACCESS_CONTROL_ALLOW_HEADERS,
320 HeaderValue::from_static("content-type"),
321 );
322 headers.insert(
323 header::ACCESS_CONTROL_MAX_AGE,
324 HeaderValue::from_static("86400"),
325 );
326 response
327 }
328
329 /// GET /download/{download_token}
330 ///
331 /// Download a purchased item using a token from the guest purchase email.
332 /// No authentication required; the token is the proof of purchase.
333 #[tracing::instrument(skip_all, name = "guest_checkout::download")]
334 pub(super) async fn guest_download(
335 State(db): State<PgPool>,
336 State(storage): State<AppStorage>,
337 Path(token): Path<db::DownloadToken>,
338 ) -> Result<Response> {
339 let tx = db::transactions::get_transaction_by_download_token(&db, token)
340 .await?
341 .ok_or(AppError::NotFound)?;
342
343 let item_id = tx.item_id.ok_or(AppError::NotFound)?;
344 let item = db::items::get_item_by_id(&db, item_id)
345 .await?
346 .ok_or(AppError::NotFound)?;
347
348 // Resolve the downloadable file and the scan status that gates it. Items
349 // deliver either via an inline audio/video key or via a version file
350 // (software / digital-file items). A guest download token is item-scoped, so
351 // a version-delivered item resolves to its current file, the same file an
352 // authenticated buyer gets from the item page. Previously this path checked
353 // only the inline keys, so a guest who bought a version-delivered item got a
354 // token that 404'd despite having paid (Run 15 fulfillment gap).
355 let (s3_key, scan_status) = match item.audio_s3_key.clone().or(item.video_s3_key.clone()) {
356 Some(key) => (key, item.scan_status),
357 None => db::versions::get_versions_by_item(&db, item_id)
358 .await?
359 .into_iter()
360 .filter_map(|v| {
361 v.s3_key
362 .map(|k| (k, v.scan_status, v.is_current, v.created_at))
363 })
364 // Prefer the current version, then the most recent, matching what the
365 // item page hands an authenticated buyer.
366 .max_by(|a, b| a.2.cmp(&b.2).then(a.3.cmp(&b.3)))
367 .map(|(key, status, _, _)| (key, status))
368 .ok_or(AppError::NotFound)?,
369 };
370
371 // Gate on scan status, exactly as the authenticated download path does
372 // (downloads.rs). A guest is never the creator, so there is no preview
373 // exemption: only Clean content is downloadable, Pending/HeldForReview/
374 // Quarantined are withheld until the scan clears (Sec NOTE, Run 7; the guest
375 // path previously skipped this quarantine check entirely).
376 if scan_status != db::FileScanStatus::Clean {
377 return Err(AppError::NotFound);
378 }
379
380 let s3 = storage.s3.as_ref().ok_or_else(|| {
381 AppError::ServiceUnavailable("File storage is not configured".to_string())
382 })?;
383
384 let download_url = s3
385 .presign_download(&crate::storage::S3Key::from_stored(&s3_key), Some(3600))
386 .await?;
387
388 Ok(Redirect::temporary(&download_url).into_response())
389 }
390
391 /// POST /api/purchases/claim
392 ///
393 /// Attach a guest purchase to the authenticated user's account using a claim token.
394 #[tracing::instrument(skip_all, name = "guest_checkout::claim")]
395 pub(super) async fn claim_purchase(
396 State(db): State<PgPool>,
397 State(integrations): State<Integrations>,
398 crate::auth::AuthUser(user): crate::auth::AuthUser,
399 Json(body): Json<ClaimRequest>,
400 ) -> Result<Response> {
401 user.check_not_sandbox()?;
402 let tx = db::transactions::claim_guest_purchase(&db, body.claim_token, user.id)
403 .await?
404 .ok_or_else(|| AppError::BadRequest("Invalid or already-claimed token".to_string()))?;
405
406 tracing::info!(
407 user_id = %user.id,
408 transaction_id = %tx.id,
409 "guest purchase claimed"
410 );
411
412 // Issue the license key at claim time. Guest purchases of key-enabled items
413 // are no longer auto-attached on an email match (the buyer always claims via
414 // the emailed link), so the key must be minted here, the claim is the point
415 // at which a real, authenticated buyer is known. `claim_guest_purchase`'s
416 // `buyer_id IS NULL` guard makes the claim single-use, so this runs at most
417 // once per purchase.
418 if let Some(item_id) = tx.item_id {
419 crate::routes::stripe::webhook::checkout_helpers::maybe_generate_license_key(
420 &db,
421 integrations.wam.as_ref(),
422 item_id,
423 user.id,
424 tx.id,
425 )
426 .await;
427 }
428
429 Ok(StatusCode::OK.into_response())
430 }
431
432 #[derive(Debug, Deserialize)]
433 pub(super) struct ClaimRequest {
434 pub claim_token: db::ClaimToken,
435 }
436
437 /// Request body for free guest claim.
438 #[derive(Debug, Deserialize)]
439 pub(super) struct FreeGuestClaimRequest {
440 pub email: String,
441 }
442
443 /// POST /api/checkout/guest-free/{item_id}
444 ///
445 /// Claim a free item as a guest. Collects email, creates a completed transaction,
446 /// and sends download + claim links via email. No Stripe involved.
447 #[tracing::instrument(skip_all, name = "guest_checkout::claim_free")]
448 pub(super) async fn claim_free_guest(
449 State(db): State<PgPool>,
450 State(config): State<Config>,
451 State(email_client): State<EmailClient>,
452 State(bg): State<BackgroundTx>,
453 Path(item_id): Path<ItemId>,
454 Json(body): Json<FreeGuestClaimRequest>,
455 ) -> Result<Response> {
456 let email = db::Email::new(&body.email)
457 .map_err(|_| AppError::BadRequest("Invalid email address".to_string()))?;
458
459 let item = db::items::get_item_by_id(&db, item_id)
460 .await?
461 .ok_or(AppError::NotFound)?;
462
463 if !item.is_public || !item.listed || item.price_cents != 0 {
464 return Err(AppError::NotFound);
465 }
466
467 // Fetch seller
468 let project = db::projects::get_project_by_id(&db, item.project_id)
469 .await?
470 .ok_or(AppError::NotFound)?;
471 let seller = db::users::get_user_by_id(&db, project.user_id)
472 .await?
473 .ok_or(AppError::NotFound)?;
474
475 if seller.is_suspended() || seller.is_deactivated() || seller.is_creator_paused() {
476 return Err(AppError::NotFound);
477 }
478
479 // Never auto-attach on an email match, a typed address isn't proof the
480 // claimant controls it. The buyer always claims via the emailed link,
481 // consistent with the paid guest path (Run #21 / Max's call 2026-06-15).
482 let claim_token = db::ClaimToken::new();
483 let download_token = db::DownloadToken::new();
484 let checkout_session_id = format!("free-guest-{email}-{item_id}");
485
486 // Create completed transaction
487 let result = db::transactions::create_free_guest_transaction(
488 &db,
489 None,
490 seller.id,
491 item_id,
492 &checkout_session_id,
493 &item.title,
494 &seller.username,
495 email.as_str(),
496 Some(claim_token),
497 download_token,
498 )
499 .await;
500
501 match result {
502 Ok(0) => {
503 // Already claimed, still send the email with download link
504 }
505 Ok(_) => {
506 let _ = db::items::increment_sales_count(&db, item_id).await;
507 }
508 Err(e) => {
509 // Unique violation (already in library for existing user)
510 if let sqlx::Error::Database(ref db_err) = e {
511 if db_err.code().as_deref() == Some("23505") {
512 // Already claimed, continue to send email
513 } else {
514 return Err(AppError::Database(e));
515 }
516 } else {
517 return Err(AppError::Database(e));
518 }
519 }
520 }
521
522 // Send download email
523 let host_url = &config.host_url;
524 let download_url = format!("{host_url}/download/{download_token}");
525 let claim_url = format!("{host_url}/claim?token={claim_token}");
526
527 // Always send the claim link, there's no auto-attach to skip it for.
528 {
529 let email_client = email_client.clone();
530 let email_addr = email.clone().into_inner();
531 let item_title = item.title.clone();
532 let dl_url = download_url.clone();
533 let cl_url = claim_url;
534 bg.spawn("free guest claim email", async move {
535 if let Err(e) = email_client
536 .send_guest_purchase_confirmation(
537 &email_addr,
538 &item_title,
539 "Free",
540 &dl_url,
541 &cl_url,
542 )
543 .await
544 {
545 tracing::error!(error = ?e, "failed to send free guest claim email");
546 }
547 });
548 }
549
550 let mut response = Json(serde_json::json!({
551 "status": "claimed",
552 "download_url": download_url,
553 }))
554 .into_response();
555
556 // CORS headers
557 let headers = response.headers_mut();
558 headers.insert(
559 header::ACCESS_CONTROL_ALLOW_ORIGIN,
560 HeaderValue::from_static("*"),
561 );
562
563 Ok(response)
564 }
565