Skip to main content

max / makenotwork

15.8 KB · 421 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 extract::{Path, State},
8 http::{header, HeaderValue, StatusCode},
9 response::{IntoResponse, Redirect, Response},
10 Json,
11 };
12 use serde::{Deserialize, Serialize};
13 use uuid::Uuid;
14
15 use crate::{
16 db::{self, Cents, ItemId},
17 error::{AppError, Result, ResultExt},
18 AppState,
19 };
20
21 /// Request body for creating a guest checkout session.
22 #[derive(Debug, Deserialize)]
23 pub(super) struct GuestCheckoutRequest {
24 /// Buyer-chosen amount in cents (only for PWYW items).
25 pub amount_cents: Option<i32>,
26 /// Optional promo/discount code.
27 pub promo_code: Option<String>,
28 }
29
30 /// Response from creating a guest checkout session.
31 #[derive(Serialize)]
32 struct CheckoutResponse {
33 checkout_url: String,
34 }
35
36 /// POST /api/checkout/guest/{item_id}
37 ///
38 /// Creates a Stripe Checkout Session for a guest purchase (no account required).
39 /// Returns the Stripe checkout URL. The embed or item page opens this in a popup
40 /// or redirects to it.
41 #[tracing::instrument(skip_all, name = "guest_checkout::create")]
42 pub(super) async fn create_guest_checkout(
43 State(state): State<AppState>,
44 Path(item_id): Path<ItemId>,
45 Json(body): Json<GuestCheckoutRequest>,
46 ) -> Result<Response> {
47 // Fetch item
48 let item = db::items::get_item_by_id(&state.db, item_id)
49 .await?
50 .ok_or(AppError::NotFound)?;
51
52 if !item.is_public || !item.listed {
53 return Err(AppError::NotFound);
54 }
55
56 // Fetch seller via project
57 let project = db::projects::get_project_by_id(&state.db, item.project_id)
58 .await?
59 .ok_or(AppError::NotFound)?;
60 let seller = db::users::get_user_by_id(&state.db, project.user_id)
61 .await?
62 .ok_or(AppError::NotFound)?;
63
64 if seller.is_suspended() || seller.is_deactivated() || seller.is_creator_paused() {
65 return Err(AppError::NotFound);
66 }
67
68 let seller_id = seller.id;
69
70 // Determine price — use the same pricing model as the authenticated checkout
71 let pricing = crate::pricing::for_item(&item);
72 let mut final_price_cents = if item.pwyw_enabled {
73 let buyer_amount = body.amount_cents
74 .unwrap_or(item.price_cents);
75 pricing.validate_amount(buyer_amount)
76 .map_err(AppError::BadRequest)?;
77 buyer_amount
78 } else {
79 item.price_cents
80 };
81
82 // Free items: skip Stripe entirely, redirect to the free claim endpoint
83 if final_price_cents == 0 {
84 return Err(AppError::BadRequest(
85 "Free items use /api/checkout/guest-free/{item_id} instead".to_string(),
86 ));
87 }
88
89 // Resolve and validate promo code with the same checks as authenticated checkout
90 let mut promo_code_id = None;
91 if let Some(code_str) = body.promo_code.as_deref().map(str::trim).filter(|s| !s.is_empty()) {
92 if item.pwyw_enabled {
93 return Err(AppError::BadRequest("Promo codes cannot be applied to pay-what-you-want items".to_string()));
94 }
95 // Guests have no account, so no platform-wide Fan+ credit fallback (None).
96 if let Some(validated) =
97 db::promo_codes::lookup_and_validate_promo(&state.db, seller_id, None, code_str).await?
98 {
99 use db::promo_codes::{PromoApplication, PromoIneligible};
100 match db::promo_codes::apply_promo_to_item(&validated, item_id, item.project_id, item.price_cents)? {
101 PromoApplication::Apply(price) => final_price_cents = price,
102 PromoApplication::Ineligible(PromoIneligible::ScopeMismatch) => {
103 return Err(AppError::BadRequest("This promo code is not valid for this item".to_string()));
104 }
105 PromoApplication::Ineligible(PromoIneligible::BelowMinPrice) => {
106 return Err(AppError::BadRequest("This item does not meet the minimum price for this code".to_string()));
107 }
108 }
109 promo_code_id = Some(validated.id());
110 }
111 }
112
113 // If a promo code brought the price to zero, redirect to the free claim flow
114 if final_price_cents == 0 {
115 return Err(AppError::BadRequest(
116 "Free items use /api/checkout/guest-free/{item_id} instead".to_string(),
117 ));
118 }
119
120 // Reject sub-Stripe-minimum charges (a Discount promo can land a fixed item
121 // at 1–49¢) using the shared `check_min_charge` the Stripe session call
122 // enforces internally. Gating here, before the promo reservation, means a
123 // rejection doesn't burn a use of the code.
124 crate::payments::check_min_charge(final_price_cents as i64)?;
125
126 // Verify seller has Stripe configured
127 let stripe_account_id = seller.stripe_account_id.as_ref()
128 .ok_or_else(|| AppError::BadRequest("Creator hasn't set up payments yet".to_string()))?;
129 if !seller.stripe_charges_enabled {
130 return Err(AppError::BadRequest("Creator's payment account is not ready".to_string()));
131 }
132
133 let stripe = state.stripe.as_ref()
134 .ok_or_else(|| AppError::BadRequest("Stripe is not configured".to_string()))?;
135
136 // Reserve the promo code BEFORE creating the Stripe session or pending row,
137 // mirroring the authenticated item-checkout path. The old order (reserve
138 // last) meant a swallowed 23505 returned a live checkout URL with NO pending
139 // row for the webhook to complete — the buyer paid and the sale vanished —
140 // and a failed reservation could orphan the pending row's promo. Every
141 // failure path below now releases this reservation.
142 if let Some(pc_id) = promo_code_id {
143 let reserved = db::promo_codes::try_increment_use_count(&state.db, pc_id)
144 .await
145 .context("reserve promo code use at guest checkout")?;
146 if !reserved {
147 return Err(AppError::BadRequest("This promo code has reached its usage limit".to_string()));
148 }
149 }
150
151 // Release the reservation above on any failure path below (no-op if no promo).
152 let release_promo = || async {
153 if let Some(pc_id) = promo_code_id {
154 db::promo_codes::release_use_count(&state.db, pc_id).await.ok();
155 }
156 };
157
158 // Build URLs
159 let success_url = format!("{}/stripe/success?session_id={{CHECKOUT_SESSION_ID}}", state.config.host_url);
160 let cancel_url = format!("{}/i/{}", state.config.host_url, item_id);
161
162 // Create guest checkout session
163 let checkout_params = crate::payments::GuestCheckoutParams {
164 connected_account_id: stripe_account_id,
165 item_title: &item.title,
166 amount_cents: Cents::new(final_price_cents as i64),
167 seller_id,
168 item_id,
169 success_url: &success_url,
170 cancel_url: &cancel_url,
171 promo_code_id,
172 enable_stripe_tax: seller.stripe_tax_enabled,
173 };
174 let result = match stripe.create_guest_checkout_session(&checkout_params).await {
175 Ok(r) => r,
176 Err(e) => {
177 release_promo().await;
178 return Err(e).with_context(|| format!("create guest Stripe checkout for item {item_id}"));
179 }
180 };
181
182 // Create pending transaction (buyer_id = None for guest). On a unique
183 // violation (a checkout for this item is already in progress) we must NOT
184 // return the live Stripe URL: there'd be no pending row for the webhook to
185 // complete, so the buyer would be charged and the sale silently lost.
186 // Release the promo and return an error (guests have no purchase page to
187 // redirect to, unlike the authenticated path).
188 match db::transactions::create_transaction(
189 &state.db,
190 &db::transactions::CreateTransactionParams {
191 buyer_id: None,
192 seller_id,
193 item_id: Some(item_id),
194 amount_cents: final_price_cents.into(),
195 platform_fee_cents: Cents::ZERO,
196 stripe_checkout_session_id: &result.id,
197 item_title: &item.title,
198 seller_username: &seller.username,
199 share_contact: false,
200 project_id: Some(item.project_id),
201 promo_code_id,
202 guest_email: None, // Set by webhook when Stripe provides it
203 },
204 ).await {
205 Ok(_) => {}
206 Err(AppError::Database(sqlx::Error::Database(ref db_err)))
207 if db_err.code().as_deref() == Some("23505") =>
208 {
209 release_promo().await;
210 tracing::info!(item_id = %item_id, "duplicate pending guest checkout blocked");
211 return Err(AppError::BadRequest(
212 "A checkout for this item is already in progress. Please complete or cancel it before starting another.".to_string(),
213 ));
214 }
215 Err(e) => {
216 release_promo().await;
217 return Err(e).context("create pending guest transaction");
218 }
219 }
220
221 let checkout_url = result.url
222 .ok_or_else(|| AppError::BadRequest("No checkout URL returned".to_string()))?;
223
224 let mut response = Json(CheckoutResponse { checkout_url }).into_response();
225
226 // CORS headers for cross-origin embed usage
227 let headers = response.headers_mut();
228 headers.insert(header::ACCESS_CONTROL_ALLOW_ORIGIN, HeaderValue::from_static("*"));
229 headers.insert(header::ACCESS_CONTROL_ALLOW_METHODS, HeaderValue::from_static("POST, OPTIONS"));
230 headers.insert(header::ACCESS_CONTROL_ALLOW_HEADERS, HeaderValue::from_static("content-type"));
231
232 Ok(response)
233 }
234
235 /// OPTIONS /api/checkout/guest/{item_id}: CORS preflight
236 pub(super) async fn guest_checkout_preflight() -> Response {
237 let mut response = StatusCode::NO_CONTENT.into_response();
238 let headers = response.headers_mut();
239 headers.insert(header::ACCESS_CONTROL_ALLOW_ORIGIN, HeaderValue::from_static("*"));
240 headers.insert(header::ACCESS_CONTROL_ALLOW_METHODS, HeaderValue::from_static("POST, OPTIONS"));
241 headers.insert(header::ACCESS_CONTROL_ALLOW_HEADERS, HeaderValue::from_static("content-type"));
242 headers.insert(header::ACCESS_CONTROL_MAX_AGE, HeaderValue::from_static("86400"));
243 response
244 }
245
246 /// GET /download/{download_token}
247 ///
248 /// Download a purchased item using a token from the guest purchase email.
249 /// No authentication required; the token is the proof of purchase.
250 #[tracing::instrument(skip_all, name = "guest_checkout::download")]
251 pub(super) async fn guest_download(
252 State(state): State<AppState>,
253 Path(token): Path<db::DownloadToken>,
254 ) -> Result<Response> {
255 let tx = db::transactions::get_transaction_by_download_token(&state.db, token)
256 .await?
257 .ok_or(AppError::NotFound)?;
258
259 let item_id = tx.item_id.ok_or(AppError::NotFound)?;
260 let item = db::items::get_item_by_id(&state.db, item_id)
261 .await?
262 .ok_or(AppError::NotFound)?;
263
264 // Get the S3 key for the content
265 let s3_key = item.audio_s3_key.as_deref()
266 .or(item.video_s3_key.as_deref())
267 .ok_or_else(|| AppError::NotFound)?;
268
269 let s3 = state.s3.as_ref()
270 .ok_or_else(|| AppError::ServiceUnavailable("File storage is not configured".to_string()))?;
271
272 let download_url = s3.presign_download(s3_key, Some(3600)).await?;
273
274 Ok(Redirect::temporary(&download_url).into_response())
275 }
276
277 /// POST /api/purchases/claim
278 ///
279 /// Attach a guest purchase to the authenticated user's account using a claim token.
280 #[tracing::instrument(skip_all, name = "guest_checkout::claim")]
281 pub(super) async fn claim_purchase(
282 State(state): State<AppState>,
283 crate::auth::AuthUser(user): crate::auth::AuthUser,
284 Json(body): Json<ClaimRequest>,
285 ) -> Result<Response> {
286 user.check_not_sandbox()?;
287 let tx = db::transactions::claim_guest_purchase(&state.db, body.claim_token, user.id)
288 .await?
289 .ok_or_else(|| AppError::BadRequest(
290 "Invalid or already-claimed token".to_string()
291 ))?;
292
293 tracing::info!(
294 user_id = %user.id,
295 transaction_id = %tx.id,
296 "guest purchase claimed"
297 );
298
299 Ok(StatusCode::OK.into_response())
300 }
301
302 #[derive(Debug, Deserialize)]
303 pub(super) struct ClaimRequest {
304 pub claim_token: db::ClaimToken,
305 }
306
307 /// Request body for free guest claim.
308 #[derive(Debug, Deserialize)]
309 pub(super) struct FreeGuestClaimRequest {
310 pub email: String,
311 }
312
313 /// POST /api/checkout/guest-free/{item_id}
314 ///
315 /// Claim a free item as a guest. Collects email, creates a completed transaction,
316 /// and sends download + claim links via email. No Stripe involved.
317 #[tracing::instrument(skip_all, name = "guest_checkout::claim_free")]
318 pub(super) async fn claim_free_guest(
319 State(state): State<AppState>,
320 Path(item_id): Path<ItemId>,
321 Json(body): Json<FreeGuestClaimRequest>,
322 ) -> Result<Response> {
323 let email = db::Email::new(&body.email)
324 .map_err(|_| AppError::BadRequest("Invalid email address".to_string()))?;
325
326 let item = db::items::get_item_by_id(&state.db, item_id)
327 .await?
328 .ok_or(AppError::NotFound)?;
329
330 if !item.is_public || !item.listed || item.price_cents != 0 {
331 return Err(AppError::NotFound);
332 }
333
334 // Fetch seller
335 let project = db::projects::get_project_by_id(&state.db, item.project_id)
336 .await?
337 .ok_or(AppError::NotFound)?;
338 let seller = db::users::get_user_by_id(&state.db, project.user_id)
339 .await?
340 .ok_or(AppError::NotFound)?;
341
342 if seller.is_suspended() || seller.is_deactivated() || seller.is_creator_paused() {
343 return Err(AppError::NotFound);
344 }
345
346 // Check if email matches an existing user — auto-attach
347 let existing_user_id = db::users::get_verified_user_id_by_email(&state.db, &email).await?;
348
349 let claim_token = if existing_user_id.is_some() { None } else { Some(db::ClaimToken::new()) };
350 let download_token = db::DownloadToken::new();
351 let checkout_session_id = format!("free-guest-{}-{}", email, item_id);
352
353 // Create completed transaction
354 let result = db::transactions::create_free_guest_transaction(
355 &state.db,
356 existing_user_id,
357 seller.id,
358 item_id,
359 &checkout_session_id,
360 &item.title,
361 &seller.username,
362 email.as_str(),
363 claim_token,
364 download_token,
365 )
366 .await;
367
368 match result {
369 Ok(0) => {
370 // Already claimed — still send the email with download link
371 }
372 Ok(_) => {
373 // Increment sales count
374 let _ = db::items::increment_sales_count(&state.db, item_id).await;
375 }
376 Err(e) => {
377 // Unique violation (already in library for existing user)
378 if let sqlx::Error::Database(ref db_err) = e {
379 if db_err.code().as_deref() == Some("23505") {
380 // Already claimed, continue to send email
381 } else {
382 return Err(AppError::Database(e));
383 }
384 } else {
385 return Err(AppError::Database(e));
386 }
387 }
388 }
389
390 // Send download email
391 let host_url = &state.config.host_url;
392 let download_url = format!("{}/download/{}", host_url, download_token);
393 let claim_url = format!("{}/claim?token={}", host_url, claim_token.unwrap_or(db::ClaimToken::from_uuid(Uuid::nil())));
394
395 if existing_user_id.is_none() {
396 let email_client = state.email.clone();
397 let email_addr = email.clone().into_inner();
398 let item_title = item.title.clone();
399 let dl_url = download_url.clone();
400 let cl_url = claim_url.clone();
401 state.bg.spawn("free guest claim email", async move {
402 if let Err(e) = email_client.send_guest_purchase_confirmation(
403 &email_addr, &item_title, "Free", &dl_url, &cl_url,
404 ).await {
405 tracing::error!(error = ?e, "failed to send free guest claim email");
406 }
407 });
408 }
409
410 let mut response = Json(serde_json::json!({
411 "status": "claimed",
412 "download_url": download_url,
413 })).into_response();
414
415 // CORS headers
416 let headers = response.headers_mut();
417 headers.insert(header::ACCESS_CONTROL_ALLOW_ORIGIN, HeaderValue::from_static("*"));
418
419 Ok(response)
420 }
421