Skip to main content

max / makenotwork

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