Skip to main content

max / makenotwork

15.9 KB · 429 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(ref code_str) = body.promo_code {
92 let code_str = code_str.trim().to_uppercase();
93 if !code_str.is_empty() {
94 if item.pwyw_enabled {
95 return Err(AppError::BadRequest("Promo codes cannot be applied to pay-what-you-want items".to_string()));
96 }
97
98 let pc = db::promo_codes::get_promo_code_by_creator_and_code(&state.db, seller_id, &code_str)
99 .await?
100 .ok_or_else(|| AppError::BadRequest("Invalid promo code".to_string()))?;
101
102 if pc.code_purpose == db::CodePurpose::FreeTrial {
103 return Err(AppError::BadRequest("Trial codes can only be used for subscriptions".to_string()));
104 }
105 if let Some(starts) = pc.starts_at
106 && starts > chrono::Utc::now()
107 {
108 return Err(AppError::BadRequest("This promo code is not yet active".to_string()));
109 }
110 if let Some(expires) = pc.expires_at
111 && expires < chrono::Utc::now()
112 {
113 return Err(AppError::BadRequest("This promo code has expired".to_string()));
114 }
115 if let Some(max) = pc.max_uses
116 && pc.use_count >= max
117 {
118 return Err(AppError::BadRequest("This promo code has reached its usage limit".to_string()));
119 }
120 if let Some(scoped_item) = pc.item_id
121 && scoped_item != item_id
122 {
123 return Err(AppError::BadRequest("This promo code is not valid for this item".to_string()));
124 }
125 if let Some(scoped_project) = pc.project_id
126 && item.project_id != scoped_project
127 {
128 return Err(AppError::BadRequest("This promo code is not valid for this item".to_string()));
129 }
130
131 // Apply discount to final price
132 if pc.code_purpose == db::CodePurpose::FreeAccess {
133 final_price_cents = 0;
134 } else if pc.code_purpose == db::CodePurpose::Discount {
135 if item.price_cents < pc.min_price_cents {
136 return Err(AppError::BadRequest("This item does not meet the minimum price for this code".to_string()));
137 }
138 // Fourth copy of this pattern in the codebase; the prior three
139 // (item.rs, cart.rs ×2) reject NULL discount_type/value to avoid
140 // burning a promo use against full price. Same treatment here.
141 let (dt, dv) = match (pc.discount_type, pc.discount_value) {
142 (Some(dt), Some(dv)) => (dt, dv),
143 _ => return Err(AppError::BadRequest(
144 "This promo code is misconfigured. Please contact the creator.".to_string(),
145 )),
146 };
147 final_price_cents = db::promo_codes::apply_discount(item.price_cents, dt, dv);
148 }
149
150 promo_code_id = Some(pc.id);
151 }
152 }
153
154 // If a promo code brought the price to zero, redirect to the free claim flow
155 if final_price_cents == 0 {
156 return Err(AppError::BadRequest(
157 "Free items use /api/checkout/guest-free/{item_id} instead".to_string(),
158 ));
159 }
160
161 // Verify seller has Stripe configured
162 let stripe_account_id = seller.stripe_account_id.as_ref()
163 .ok_or_else(|| AppError::BadRequest("Creator hasn't set up payments yet".to_string()))?;
164 if !seller.stripe_charges_enabled {
165 return Err(AppError::BadRequest("Creator's payment account is not ready".to_string()));
166 }
167
168 let stripe = state.stripe.as_ref()
169 .ok_or_else(|| AppError::BadRequest("Stripe is not configured".to_string()))?;
170
171 // Build URLs
172 let success_url = format!("{}/stripe/success?session_id={{CHECKOUT_SESSION_ID}}", state.config.host_url);
173 let cancel_url = format!("{}/i/{}", state.config.host_url, item_id);
174
175 // Create guest checkout session
176 let checkout_params = crate::payments::GuestCheckoutParams {
177 connected_account_id: stripe_account_id,
178 item_title: &item.title,
179 amount_cents: Cents::new(final_price_cents as i64),
180 seller_id,
181 item_id,
182 success_url: &success_url,
183 cancel_url: &cancel_url,
184 promo_code_id,
185 enable_stripe_tax: seller.stripe_tax_enabled,
186 };
187 let result = stripe.create_guest_checkout_session(&checkout_params)
188 .await
189 .with_context(|| format!("create guest Stripe checkout for item {item_id}"))?;
190
191 // Create pending transaction (buyer_id = None for guest)
192 match db::transactions::create_transaction(
193 &state.db,
194 &db::transactions::CreateTransactionParams {
195 buyer_id: None,
196 seller_id,
197 item_id: Some(item_id),
198 amount_cents: final_price_cents.into(),
199 platform_fee_cents: Cents::ZERO,
200 stripe_checkout_session_id: &result.id,
201 item_title: &item.title,
202 seller_username: &seller.username,
203 share_contact: false,
204 project_id: Some(item.project_id),
205 promo_code_id,
206 guest_email: None, // Set by webhook when Stripe provides it
207 },
208 ).await {
209 Ok(_) => {}
210 Err(AppError::Database(sqlx::Error::Database(ref db_err)))
211 if db_err.code().as_deref() == Some("23505") =>
212 {
213 tracing::info!(item_id = %item_id, "duplicate pending guest checkout blocked");
214 }
215 Err(e) => return Err(e).context("create pending guest transaction"),
216 }
217
218 // Reserve promo code use AFTER pending transaction exists, so the cleanup
219 // scheduler can find and release it if the checkout is abandoned.
220 if let Some(pc_id) = promo_code_id {
221 let reserved = db::promo_codes::try_increment_use_count(&state.db, pc_id)
222 .await
223 .context("reserve promo code use at guest checkout")?;
224 if !reserved {
225 return Err(AppError::BadRequest("This promo code has reached its usage limit".to_string()));
226 }
227 }
228
229 let checkout_url = result.url
230 .ok_or_else(|| AppError::BadRequest("No checkout URL returned".to_string()))?;
231
232 let mut response = Json(CheckoutResponse { checkout_url }).into_response();
233
234 // CORS headers for cross-origin embed usage
235 let headers = response.headers_mut();
236 headers.insert(header::ACCESS_CONTROL_ALLOW_ORIGIN, HeaderValue::from_static("*"));
237 headers.insert(header::ACCESS_CONTROL_ALLOW_METHODS, HeaderValue::from_static("POST, OPTIONS"));
238 headers.insert(header::ACCESS_CONTROL_ALLOW_HEADERS, HeaderValue::from_static("content-type"));
239
240 Ok(response)
241 }
242
243 /// OPTIONS /api/checkout/guest/{item_id}: CORS preflight
244 pub(super) async fn guest_checkout_preflight() -> Response {
245 let mut response = StatusCode::NO_CONTENT.into_response();
246 let headers = response.headers_mut();
247 headers.insert(header::ACCESS_CONTROL_ALLOW_ORIGIN, HeaderValue::from_static("*"));
248 headers.insert(header::ACCESS_CONTROL_ALLOW_METHODS, HeaderValue::from_static("POST, OPTIONS"));
249 headers.insert(header::ACCESS_CONTROL_ALLOW_HEADERS, HeaderValue::from_static("content-type"));
250 headers.insert(header::ACCESS_CONTROL_MAX_AGE, HeaderValue::from_static("86400"));
251 response
252 }
253
254 /// GET /download/{download_token}
255 ///
256 /// Download a purchased item using a token from the guest purchase email.
257 /// No authentication required; the token is the proof of purchase.
258 #[tracing::instrument(skip_all, name = "guest_checkout::download")]
259 pub(super) async fn guest_download(
260 State(state): State<AppState>,
261 Path(token): Path<db::DownloadToken>,
262 ) -> Result<Response> {
263 let tx = db::transactions::get_transaction_by_download_token(&state.db, token)
264 .await?
265 .ok_or(AppError::NotFound)?;
266
267 let item_id = tx.item_id.ok_or(AppError::NotFound)?;
268 let item = db::items::get_item_by_id(&state.db, item_id)
269 .await?
270 .ok_or(AppError::NotFound)?;
271
272 // Get the S3 key for the content
273 let s3_key = item.audio_s3_key.as_deref()
274 .or(item.video_s3_key.as_deref())
275 .ok_or_else(|| AppError::NotFound)?;
276
277 let s3 = state.s3.as_ref()
278 .ok_or_else(|| AppError::ServiceUnavailable("File storage is not configured".to_string()))?;
279
280 let download_url = s3.presign_download(s3_key, Some(3600)).await?;
281
282 Ok(Redirect::temporary(&download_url).into_response())
283 }
284
285 /// POST /api/purchases/claim
286 ///
287 /// Attach a guest purchase to the authenticated user's account using a claim token.
288 #[tracing::instrument(skip_all, name = "guest_checkout::claim")]
289 pub(super) async fn claim_purchase(
290 State(state): State<AppState>,
291 crate::auth::AuthUser(user): crate::auth::AuthUser,
292 Json(body): Json<ClaimRequest>,
293 ) -> Result<Response> {
294 user.check_not_sandbox()?;
295 let tx = db::transactions::claim_guest_purchase(&state.db, body.claim_token, user.id)
296 .await?
297 .ok_or_else(|| AppError::BadRequest(
298 "Invalid or already-claimed token".to_string()
299 ))?;
300
301 tracing::info!(
302 user_id = %user.id,
303 transaction_id = %tx.id,
304 "guest purchase claimed"
305 );
306
307 Ok(StatusCode::OK.into_response())
308 }
309
310 #[derive(Debug, Deserialize)]
311 pub(super) struct ClaimRequest {
312 pub claim_token: db::ClaimToken,
313 }
314
315 /// Request body for free guest claim.
316 #[derive(Debug, Deserialize)]
317 pub(super) struct FreeGuestClaimRequest {
318 pub email: String,
319 }
320
321 /// POST /api/checkout/guest-free/{item_id}
322 ///
323 /// Claim a free item as a guest. Collects email, creates a completed transaction,
324 /// and sends download + claim links via email. No Stripe involved.
325 #[tracing::instrument(skip_all, name = "guest_checkout::claim_free")]
326 pub(super) async fn claim_free_guest(
327 State(state): State<AppState>,
328 Path(item_id): Path<ItemId>,
329 Json(body): Json<FreeGuestClaimRequest>,
330 ) -> Result<Response> {
331 let email = db::Email::new(&body.email)
332 .map_err(|_| AppError::BadRequest("Invalid email address".to_string()))?;
333
334 let item = db::items::get_item_by_id(&state.db, item_id)
335 .await?
336 .ok_or(AppError::NotFound)?;
337
338 if !item.is_public || !item.listed || item.price_cents != 0 {
339 return Err(AppError::NotFound);
340 }
341
342 // Fetch seller
343 let project = db::projects::get_project_by_id(&state.db, item.project_id)
344 .await?
345 .ok_or(AppError::NotFound)?;
346 let seller = db::users::get_user_by_id(&state.db, project.user_id)
347 .await?
348 .ok_or(AppError::NotFound)?;
349
350 if seller.is_suspended() || seller.is_deactivated() || seller.is_creator_paused() {
351 return Err(AppError::NotFound);
352 }
353
354 // Check if email matches an existing user — auto-attach
355 let existing_user_id = db::users::get_verified_user_id_by_email(&state.db, &email).await?;
356
357 let claim_token = if existing_user_id.is_some() { None } else { Some(db::ClaimToken::new()) };
358 let download_token = db::DownloadToken::new();
359 let checkout_session_id = format!("free-guest-{}-{}", email, item_id);
360
361 // Create completed transaction
362 let result = db::transactions::create_free_guest_transaction(
363 &state.db,
364 existing_user_id,
365 seller.id,
366 item_id,
367 &checkout_session_id,
368 &item.title,
369 &seller.username,
370 email.as_str(),
371 claim_token,
372 download_token,
373 )
374 .await;
375
376 match result {
377 Ok(0) => {
378 // Already claimed — still send the email with download link
379 }
380 Ok(_) => {
381 // Increment sales count
382 let _ = db::items::increment_sales_count(&state.db, item_id).await;
383 }
384 Err(e) => {
385 // Unique violation (already in library for existing user)
386 if let sqlx::Error::Database(ref db_err) = e {
387 if db_err.code().as_deref() == Some("23505") {
388 // Already claimed, continue to send email
389 } else {
390 return Err(AppError::Database(e));
391 }
392 } else {
393 return Err(AppError::Database(e));
394 }
395 }
396 }
397
398 // Send download email
399 let host_url = &state.config.host_url;
400 let download_url = format!("{}/download/{}", host_url, download_token);
401 let claim_url = format!("{}/claim?token={}", host_url, claim_token.unwrap_or(db::ClaimToken::from_uuid(Uuid::nil())));
402
403 if existing_user_id.is_none() {
404 let email_client = state.email.clone();
405 let email_addr = email.clone().into_inner();
406 let item_title = item.title.clone();
407 let dl_url = download_url.clone();
408 let cl_url = claim_url.clone();
409 state.bg.spawn("free guest claim email", async move {
410 if let Err(e) = email_client.send_guest_purchase_confirmation(
411 &email_addr, &item_title, "Free", &dl_url, &cl_url,
412 ).await {
413 tracing::error!(error = ?e, "failed to send free guest claim email");
414 }
415 });
416 }
417
418 let mut response = Json(serde_json::json!({
419 "status": "claimed",
420 "download_url": download_url,
421 })).into_response();
422
423 // CORS headers
424 let headers = response.headers_mut();
425 headers.insert(header::ACCESS_CONTROL_ALLOW_ORIGIN, HeaderValue::from_static("*"));
426
427 Ok(response)
428 }
429