Skip to main content

max / makenotwork

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