Skip to main content

max / makenotwork

6.3 KB · 189 lines History Blame Raw
1 //! Project-level checkout handler.
2
3 use axum::{
4 extract::{Path, State},
5 response::{IntoResponse, Redirect, Response},
6 Form,
7 };
8 use serde::Deserialize;
9
10 use crate::{
11 auth::AuthUser,
12 db::{self, Cents},
13 error::{AppError, Result, ResultExt},
14 pricing::{self, CheckoutType},
15 AppState,
16 };
17
18 /// Form data for project checkout.
19 #[derive(Debug, Deserialize)]
20 pub(in crate::routes::stripe) struct ProjectCheckoutForm {
21 #[serde(default)]
22 share_contact: bool,
23 /// PWYW: buyer-chosen amount in cents.
24 amount_cents: Option<i32>,
25 }
26
27 /// POST /stripe/checkout/project/{project_id}: Purchase project-level access.
28 #[tracing::instrument(skip_all, name = "stripe::project_checkout")]
29 pub(in crate::routes::stripe) async fn create_project_checkout(
30 State(state): State<AppState>,
31 AuthUser(user): AuthUser,
32 Path(project_id): Path<String>,
33 Form(form): Form<ProjectCheckoutForm>,
34 ) -> Result<Response> {
35 user.check_not_suspended()?;
36 user.check_not_sandbox()?;
37
38 let project_uuid: db::ProjectId = project_id
39 .parse()
40 .map_err(|_| AppError::NotFound)?;
41
42 let project = db::projects::get_project_by_id(&state.db, project_uuid)
43 .await?
44 .ok_or(AppError::NotFound)?;
45
46 if !project.is_public {
47 return Err(AppError::BadRequest(
48 "This project is not available for purchase".to_string(),
49 ));
50 }
51
52 let project_pricing = pricing::for_project(&project);
53 if project_pricing.checkout_type() == CheckoutType::None {
54 return Err(AppError::BadRequest("This project is free".to_string()));
55 }
56
57 // Check if already purchased
58 if db::transactions::has_purchased_project(&state.db, user.id, project_uuid).await? {
59 return Ok(Redirect::to(&format!("/p/{}", project.slug)).into_response());
60 }
61
62 let seller_id = project.user_id;
63 if user.id == seller_id {
64 return Err(AppError::BadRequest(
65 "You cannot purchase your own project".to_string(),
66 ));
67 }
68
69 let seller = db::users::get_user_by_id(&state.db, seller_id)
70 .await?
71 .ok_or(AppError::NotFound)?;
72
73 if seller.is_suspended() || seller.is_deactivated() || seller.is_creator_paused() {
74 return Err(AppError::BadRequest("This creator's account is not active".to_string()));
75 }
76
77 // Determine price
78 let base_price_cents = if project_pricing.checkout_type() == CheckoutType::PayWhatYouWant {
79 let amount = form.amount_cents.ok_or_else(|| {
80 AppError::BadRequest("Amount is required for pay-what-you-want projects".to_string())
81 })?;
82 project_pricing
83 .validate_amount(amount)
84 .map_err(AppError::BadRequest)?;
85 amount
86 } else {
87 project_pricing.price_cents()
88 };
89
90 // If price is $0 (PWYW with $0 min), record a free claim
91 if base_price_cents == 0 {
92 let claimed = db::transactions::claim_free_project(
93 &state.db,
94 user.id,
95 seller_id,
96 project_uuid,
97 &project.title,
98 &seller.username,
99 form.share_contact,
100 )
101 .await?;
102
103 // Gate downstream side-effects on the winner of a concurrent-claim race.
104 // Without this, two concurrent free-project claims both fire the contact
105 // clear (and any future sale-notification email / split recording).
106 // Wire the same downstream effects paid project checkouts get — free
107 // PWYW purchases were previously silently un-instrumented (no contact
108 // revocation clear, no sale notification email).
109 if claimed && form.share_contact {
110 db::transactions::clear_contact_revocation(&state.db, user.id, seller_id)
111 .await
112 .context("clear contact revocation on free project claim")?;
113 }
114
115 return Ok(Redirect::to(&format!("/p/{}", project.slug)).into_response());
116 }
117
118 // Stripe checkout
119 let stripe_account_id = seller
120 .stripe_account_id
121 .as_ref()
122 .ok_or_else(|| AppError::BadRequest("Creator hasn't set up payments yet".to_string()))?;
123
124 if !seller.stripe_charges_enabled {
125 return Err(AppError::BadRequest(
126 "Creator's payment account is not ready".to_string(),
127 ));
128 }
129
130 let stripe = state
131 .stripe
132 .as_ref()
133 .ok_or_else(|| AppError::BadRequest("Stripe is not configured".to_string()))?;
134
135 let success_url = format!(
136 "{}/stripe/success?session_id={{CHECKOUT_SESSION_ID}}",
137 state.config.host_url
138 );
139 let cancel_url = format!("{}/p/{}", state.config.host_url, project.slug);
140
141 let checkout_params = crate::payments::CheckoutParams {
142 connected_account_id: stripe_account_id,
143 item_title: &project.title,
144 amount_cents: Cents::new(base_price_cents as i64),
145 buyer_id: user.id,
146 seller_id,
147 item_id: None, // project-level purchase, no specific item
148 success_url: &success_url,
149 cancel_url: &cancel_url,
150 promo_code_id: None,
151 enable_stripe_tax: seller.stripe_tax_enabled,
152 };
153 let session = stripe.create_checkout_session(&checkout_params).await?;
154
155 match db::transactions::create_transaction(
156 &state.db,
157 &db::transactions::CreateTransactionParams {
158 buyer_id: Some(user.id),
159 seller_id,
160 item_id: None,
161 amount_cents: base_price_cents.into(),
162 platform_fee_cents: Cents::ZERO,
163 stripe_checkout_session_id: &session.id,
164 item_title: &project.title,
165 seller_username: &seller.username,
166 share_contact: form.share_contact,
167 project_id: Some(project_uuid),
168 promo_code_id: None,
169 guest_email: None,
170 },
171 )
172 .await {
173 Ok(_) => {}
174 Err(AppError::Database(sqlx::Error::Database(ref db_err)))
175 if db_err.code().as_deref() == Some("23505") =>
176 {
177 tracing::info!(buyer_id = %user.id, project_id = %project_uuid, "duplicate pending project checkout blocked");
178 return Ok(Redirect::to(&format!("/p/{}", project_id)).into_response());
179 }
180 Err(e) => return Err(e),
181 }
182
183 let checkout_url = session
184 .url
185 .ok_or_else(|| AppError::BadRequest("No checkout URL returned".to_string()))?;
186
187 Ok(Redirect::to(&checkout_url).into_response())
188 }
189