Skip to main content

max / makenotwork

9.3 KB · 268 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 dollars, exactly as typed into the paywall
26 /// box. The wire unit is dollars because the label the buyer reads says
27 /// dollars; the conversion to cents happens once, in
28 /// [`ProjectCheckoutForm::amount_cents`], right before the amount is
29 /// validated and charged.
30 amount_dollars: Option<String>,
31 }
32
33 impl ProjectCheckoutForm {
34 /// The buyer-chosen PWYW amount in cents, or `None` when the field was not
35 /// submitted at all.
36 ///
37 /// Goes through `pricing::parse_dollars_to_cents`, the one canonical
38 /// dollars-to-cents conversion, so "5" is 500 cents rather than 5.
39 fn amount_cents(&self) -> Result<Option<i32>> {
40 self.amount_dollars
41 .as_deref()
42 .map(|raw| pricing::parse_dollars_to_cents("Amount", Some(raw)))
43 .transpose()
44 }
45 }
46
47 /// POST /stripe/checkout/project/{project_id}: Purchase project-level access.
48 #[tracing::instrument(skip_all, name = "stripe::project_checkout")]
49 pub(in crate::routes::stripe) async fn create_project_checkout(
50 State(db): State<PgPool>,
51 State(payments): State<Billing>,
52 State(config): State<Config>,
53 AuthUser(user): AuthUser,
54 Path(project_id): Path<String>,
55 Form(form): Form<ProjectCheckoutForm>,
56 ) -> Result<Response> {
57 user.check_not_suspended()?;
58 user.check_not_sandbox()?;
59
60 let project_uuid: db::ProjectId = project_id.parse().map_err(|_| AppError::NotFound)?;
61
62 let project = db::projects::get_project_by_id(&db, project_uuid)
63 .await?
64 .ok_or(AppError::NotFound)?;
65
66 if !project.is_public {
67 return Err(AppError::BadRequest(
68 "This project is not available for purchase".to_string(),
69 ));
70 }
71
72 let project_pricing = pricing::for_project(&project);
73 if project_pricing.checkout_type() == CheckoutType::None {
74 return Err(AppError::BadRequest("This project is free".to_string()));
75 }
76
77 // Check if already purchased
78 if db::transactions::has_purchased_project(&db, user.id, project_uuid).await? {
79 return Ok(Redirect::to(&format!("/p/{}", project.slug)).into_response());
80 }
81
82 let seller_id = project.user_id;
83 if user.id == seller_id {
84 return Err(AppError::BadRequest(
85 "You cannot purchase your own project".to_string(),
86 ));
87 }
88
89 let seller = db::users::get_user_by_id(&db, seller_id)
90 .await?
91 .ok_or(AppError::NotFound)?;
92
93 if seller.is_suspended() || seller.is_deactivated() || seller.is_creator_paused() {
94 return Err(AppError::BadRequest(
95 "This creator's account is not active".to_string(),
96 ));
97 }
98
99 // Determine price
100 let base_price_cents = if project_pricing.checkout_type() == CheckoutType::PayWhatYouWant {
101 let amount = form.amount_cents()?.ok_or_else(|| {
102 AppError::BadRequest("Amount is required for pay-what-you-want projects".to_string())
103 })?;
104 project_pricing
105 .validate_amount(amount, seller.settlement_currency)
106 .map_err(AppError::BadRequest)?;
107 amount
108 } else {
109 project_pricing.price_cents()
110 };
111
112 // If price is $0 (PWYW with $0 min), record a free claim
113 if base_price_cents == 0 {
114 let claimed = db::transactions::claim_free_project(
115 &db,
116 user.id,
117 seller_id,
118 project_uuid,
119 &project.title,
120 &seller.username,
121 form.share_contact,
122 )
123 .await?;
124
125 // Gate downstream side-effects on the winner of a concurrent-claim race.
126 // Without this, two concurrent free-project claims both fire the contact
127 // clear (and any future sale-notification email / split recording).
128 // Wire the same downstream effects paid project checkouts get, free
129 // PWYW purchases were previously silently un-instrumented (no contact
130 // revocation clear, no sale notification email).
131 if claimed && form.share_contact {
132 db::transactions::clear_contact_revocation(&db, user.id, seller_id)
133 .await
134 .context("clear contact revocation on free project claim")?;
135 }
136
137 return Ok(Redirect::to(&format!("/p/{}", project.slug)).into_response());
138 }
139
140 // Reject sub-minimum non-zero charges (Stripe rejects <50¢) with a friendly
141 // error before the session call, matching the item and cart checkout paths.
142 crate::payments::check_min_charge(base_price_cents as i64, seller.settlement_currency)?;
143
144 // Stripe checkout
145 let stripe_account_id = seller
146 .stripe_account_id
147 .as_deref()
148 .ok_or_else(|| AppError::BadRequest("Creator hasn't set up payments yet".to_string()))?;
149
150 if !seller.stripe_charges_enabled {
151 return Err(AppError::BadRequest(
152 "Creator's payment account is not ready".to_string(),
153 ));
154 }
155
156 let stripe = payments
157 .payments
158 .as_ref()
159 .ok_or_else(|| AppError::BadRequest("Stripe is not configured".to_string()))?;
160
161 let success_url = format!(
162 "{}/stripe/success?session_id={{CHECKOUT_SESSION_ID}}",
163 config.host_url
164 );
165 let cancel_url = format!("{}/p/{}", config.host_url, project.slug);
166
167 let checkout_params = crate::payments::CheckoutParams {
168 connected_account_id: stripe_account_id,
169 item_title: &project.title,
170 amount_cents: Cents::new(base_price_cents as i64),
171 buyer_id: user.id,
172 seller_id,
173 item_id: None, // project-level purchase, no specific item
174 success_url: &success_url,
175 cancel_url: &cancel_url,
176 promo_code_id: None,
177 enable_stripe_tax: seller.stripe_tax_enabled,
178 currency: seller.settlement_currency,
179 conversion: user.conversion_preference,
180 };
181 let session = stripe.create_checkout_session(&checkout_params).await?;
182
183 match db::transactions::create_transaction(
184 &db,
185 &db::transactions::CreateTransactionParams {
186 buyer_id: Some(user.id),
187 seller_id,
188 item_id: None,
189 amount_cents: base_price_cents.into(),
190 platform_fee_cents: Cents::ZERO,
191 stripe_checkout_session_id: &session.id,
192 item_title: &project.title,
193 seller_username: &seller.username,
194 share_contact: form.share_contact,
195 project_id: Some(project_uuid),
196 promo_code_id: None,
197 guest_email: None,
198 platform_credit_cents: 0, // project subscriptions carry no platform-wide credit
199 },
200 )
201 .await
202 {
203 Ok(_) => {}
204 Err(AppError::Database(sqlx::Error::Database(ref db_err)))
205 if db_err.code().as_deref() == Some("23505") =>
206 {
207 tracing::info!(buyer_id = %user.id, project_id = %project_uuid, "duplicate pending project checkout blocked");
208 return Ok(Redirect::to(&format!("/p/{project_id}")).into_response());
209 }
210 Err(e) => return Err(e),
211 }
212
213 let checkout_url = session
214 .url
215 .ok_or_else(|| AppError::BadRequest("No checkout URL returned".to_string()))?;
216
217 Ok(Redirect::to(&checkout_url).into_response())
218 }
219
220 #[cfg(test)]
221 mod tests {
222 //! The unit conversion on the PWYW paywall. The box is labelled dollars and
223 //! the wire carries dollars, so the handler owns the one multiplication that
224 //! turns what the buyer typed into what Stripe charges. Cents on the wire
225 //! under a dollars label charges a buyer who types 100 a single dollar.
226
227 use super::*;
228
229 fn form(amount: Option<&str>) -> ProjectCheckoutForm {
230 ProjectCheckoutForm {
231 share_contact: false,
232 amount_dollars: amount.map(str::to_string),
233 }
234 }
235
236 #[test]
237 fn a_whole_dollar_figure_becomes_cents() {
238 assert_eq!(form(Some("100")).amount_cents().unwrap(), Some(10_000));
239 assert_eq!(form(Some("5")).amount_cents().unwrap(), Some(500));
240 assert_eq!(form(Some("1")).amount_cents().unwrap(), Some(100));
241 }
242
243 #[test]
244 fn cents_typed_after_the_point_survive() {
245 assert_eq!(form(Some("9.99")).amount_cents().unwrap(), Some(999));
246 assert_eq!(form(Some("0.50")).amount_cents().unwrap(), Some(50));
247 assert_eq!(form(Some("1250.05")).amount_cents().unwrap(), Some(125_005));
248 }
249
250 #[test]
251 fn a_missing_field_is_distinct_from_an_empty_one() {
252 // Absent: the caller raises "Amount is required". Empty: zero, which the
253 // pricing model accepts only when the minimum is $0.
254 assert_eq!(form(None).amount_cents().unwrap(), None);
255 assert_eq!(form(Some("")).amount_cents().unwrap(), Some(0));
256 }
257
258 #[test]
259 fn junk_is_refused_rather_than_charged() {
260 for raw in ["abc", "-5", "NaN", "inf"] {
261 assert!(
262 form(Some(raw)).amount_cents().is_err(),
263 "{raw} must not reach the charge"
264 );
265 }
266 }
267 }
268