| 1 |
|
| 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 |
|
| 19 |
#[derive(Debug, Deserialize)] |
| 20 |
pub(in crate::routes::stripe) struct ProjectCheckoutForm { |
| 21 |
#[serde(default)] |
| 22 |
share_contact: bool, |
| 23 |
|
| 24 |
amount_cents: Option<i32>, |
| 25 |
} |
| 26 |
|
| 27 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 104 |
|
| 105 |
|
| 106 |
|
| 107 |
|
| 108 |
|
| 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 |
|
| 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, |
| 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 |
|