Skip to main content

max / makenotwork

6.6 KB · 199 lines History Blame Raw
1 //! Tip checkout session creation.
2
3 use axum::{
4 Form,
5 extract::{Path, State},
6 http::HeaderMap,
7 response::{IntoResponse, Redirect},
8 };
9 use serde::Deserialize;
10 use tower_sessions::Session;
11
12 use crate::{
13 Billing,
14 auth::AuthUser,
15 config::Config,
16 csrf,
17 db::{self, Cents},
18 error::{AppError, Result},
19 payments,
20 };
21 use sqlx::PgPool;
22
23 /// Form data for tip checkout.
24 #[derive(Debug, Deserialize)]
25 pub(in crate::routes::stripe) struct TipForm {
26 /// Tip amount in whole dollars (converted to cents internally).
27 pub amount_dollars: i32,
28 /// Optional short message (max 280 chars).
29 pub message: Option<String>,
30 /// Project ID if tipping from a project page.
31 pub project_id: Option<String>,
32 /// CSRF token. The `/stripe/checkout` prefix is broadly exempt because
33 /// most routes there only construct a Stripe session URL (state lives
34 /// post-webhook), but this tip route inserts a `pending_tip` row BEFORE
35 /// the Stripe call, so it gets the explicit check the rest of the
36 /// family doesn't. The tip form already renders this field.
37 #[serde(rename = "_csrf")]
38 pub csrf: Option<String>,
39 }
40
41 /// POST /stripe/checkout/tip/{recipient_id} - Create a tip checkout session
42 #[tracing::instrument(skip_all, name = "stripe_checkout::create_tip_checkout")]
43 #[allow(clippy::too_many_arguments)]
44 pub(in crate::routes::stripe) async fn create_tip_checkout(
45 State(db): State<PgPool>,
46 State(payments): State<Billing>,
47 State(config): State<Config>,
48 AuthUser(user): AuthUser,
49 session: Session,
50 headers: HeaderMap,
51 Path(recipient_id): Path<String>,
52 Form(form): Form<TipForm>,
53 ) -> Result<impl IntoResponse> {
54 // Registered with `post_csrf_manual` because this handler inserts a
55 // `pending_tip` row before the Stripe call, the broad `/stripe/checkout`
56 // skip would let an attacker plant rows. Match the standard validator's
57 // header-then-form precedence so HTMX callers and vanilla form posts
58 // both pass. `validate_token_consuming` returns the sealed witness on
59 // success; the binding is `_` because the witness exists only to prove
60 // the check happened, not to be passed downstream.
61 let token =
62 csrf::extract_token_from_request(&headers, form.csrf.as_deref()).unwrap_or_default();
63 let _validated = csrf::validate_token_consuming(&session, &token).await?;
64 user.check_not_sandbox()?;
65 user.check_not_suspended()?;
66 let stripe = payments
67 .stripe
68 .as_ref()
69 .ok_or_else(|| AppError::BadRequest("Stripe is not configured".to_string()))?;
70
71 // Parse recipient ID
72 let recipient_id: db::UserId = recipient_id
73 .parse::<uuid::Uuid>()
74 .map(db::UserId::from)
75 .map_err(|_| AppError::BadRequest("Invalid recipient ID".to_string()))?;
76
77 // Can't tip yourself
78 if recipient_id == user.id {
79 return Err(AppError::BadRequest("You cannot tip yourself".to_string()));
80 }
81
82 // Convert dollars to cents and validate ($1 minimum, $10,000 maximum)
83 if form.amount_dollars < 1 {
84 return Err(AppError::BadRequest(
85 "Minimum tip amount is $1.00".to_string(),
86 ));
87 }
88 if form.amount_dollars > 10_000 {
89 return Err(AppError::BadRequest(
90 "Maximum tip amount is $10,000".to_string(),
91 ));
92 }
93 let amount_cents = form.amount_dollars * 100;
94
95 let recipient = db::users::get_user_by_id(&db, recipient_id)
96 .await?
97 .ok_or(AppError::NotFound)?;
98
99 if recipient.is_suspended() || recipient.is_deactivated() || recipient.is_creator_paused() {
100 return Err(AppError::BadRequest(
101 "This creator's account is not active".to_string(),
102 ));
103 }
104
105 // Check tips are enabled
106 if !recipient.tips_enabled {
107 return Err(AppError::BadRequest(
108 "This creator is not accepting tips".to_string(),
109 ));
110 }
111
112 // Check recipient has Stripe connected
113 let stripe_account_id = recipient
114 .stripe_account_id
115 .as_deref()
116 .ok_or_else(|| AppError::BadRequest("Creator has not connected payments".to_string()))?;
117 if !recipient.stripe_charges_enabled {
118 return Err(AppError::BadRequest(
119 "Creator's payment account is not active".to_string(),
120 ));
121 }
122
123 // Parse project_id if present, then verify the project actually belongs to
124 // the tip recipient. Otherwise an attacker tipping creator A can pass an
125 // unrelated project B's UUID; B's project_members would be credited splits
126 // against A's tip on the webhook side.
127 let project_id: Option<db::ProjectId> = match form
128 .project_id
129 .as_deref()
130 .and_then(|s| s.parse::<uuid::Uuid>().ok().map(db::ProjectId::from))
131 {
132 Some(pid) => {
133 let project = db::projects::get_project_by_id(&db, pid)
134 .await?
135 .ok_or_else(|| AppError::BadRequest("Project not found".to_string()))?;
136 if project.user_id != recipient_id {
137 return Err(AppError::BadRequest(
138 "Project does not belong to this creator".to_string(),
139 ));
140 }
141 Some(pid)
142 }
143 None => None,
144 };
145
146 // Truncate message
147 let message = form
148 .message
149 .as_deref()
150 .map(|m| m.chars().take(280).collect::<String>());
151
152 let display_name = recipient
153 .display_name
154 .as_deref()
155 .unwrap_or(&recipient.username);
156
157 let success_url = format!(
158 "{}/stripe/success?session_id={{CHECKOUT_SESSION_ID}}",
159 config.host_url
160 );
161 let cancel_url = format!("{}/u/{}", config.host_url, recipient.username);
162
163 // Create checkout session
164 let session = stripe
165 .create_tip_checkout_session(&payments::TipCheckoutParams {
166 connected_account_id: stripe_account_id,
167 recipient_display_name: display_name,
168 amount_cents: Cents::new(amount_cents as i64),
169 tipper_id: user.id,
170 recipient_id,
171 project_id,
172 message: message.as_deref(),
173 success_url: &success_url,
174 cancel_url: &cancel_url,
175 enable_stripe_tax: recipient.stripe_tax_enabled,
176 })
177 .await?;
178
179 // Record pending tip
180 let session_id = session.id;
181 db::tips::create_tip(
182 &db,
183 user.id,
184 recipient_id,
185 project_id,
186 amount_cents,
187 message.as_deref(),
188 &session_id,
189 )
190 .await?;
191
192 // Redirect to Stripe Checkout
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))
198 }
199