Skip to main content

max / makenotwork

4.8 KB · 129 lines History Blame Raw
1 //! Stripe Checkout session creation and redirect handlers.
2
3 mod item;
4 mod project;
5 mod subscriptions;
6 mod tips;
7 mod cart;
8
9 pub(in crate::routes::stripe) use item::{cancel_pending_item_checkout, create_checkout};
10 pub(crate) use item::grant_bundle_items;
11 pub(in crate::routes::stripe) use project::create_project_checkout;
12 pub(in crate::routes::stripe) use subscriptions::{
13 cancel_fan_plus, create_creator_tier_checkout, create_fan_plus_checkout,
14 create_subscription_checkout, open_billing_portal, resume_fan_plus,
15 };
16 pub(in crate::routes::stripe) use tips::create_tip_checkout;
17 pub(in crate::routes::stripe) use cart::{create_cart_checkout, create_cart_checkout_all};
18
19 use axum::{
20 extract::{Query, State},
21 response::{IntoResponse, Redirect},
22 };
23 use serde::Deserialize;
24 use tower_sessions::Session;
25
26 use crate::AppState;
27
28 /// Form data for checkout (supports optional promo code).
29 #[derive(Debug, Deserialize)]
30 pub(super) struct CheckoutForm {
31 pub promo_code: Option<String>,
32 #[serde(default)]
33 pub share_contact: bool,
34 /// PWYW: buyer-chosen amount in cents (only used when item has pwyw_enabled).
35 pub amount_cents: Option<i32>,
36 }
37
38 /// Query parameters for the checkout success redirect.
39 #[derive(Debug, Deserialize)]
40 pub struct SuccessQuery {
41 pub session_id: Option<String>,
42 /// Single-item checkout sets this so the success redirect lands on `/l/{id}`
43 /// instead of the library index. Cart checkouts leave it unset.
44 pub item_id: Option<String>,
45 }
46
47 /// Query parameters for the checkout cancellation redirect.
48 #[derive(Debug, Deserialize)]
49 pub struct CancelQuery {
50 pub item_id: Option<String>,
51 }
52
53 /// GET /stripe/success - Handle successful payment return
54 ///
55 /// If a cart checkout queue exists in the session (cross-seller cart),
56 /// processes the next seller automatically.
57 #[tracing::instrument(skip_all, name = "stripe_checkout::checkout_success")]
58 pub(super) async fn checkout_success(
59 State(state): State<AppState>,
60 session: Session,
61 crate::auth::MaybeUserVerified(maybe_user): crate::auth::MaybeUserVerified,
62 Query(query): Query<SuccessQuery>,
63 ) -> impl IntoResponse {
64 if let Some(session_id) = &query.session_id {
65 tracing::info!(session_id = %session_id, "checkout success return");
66 }
67
68 // Check if there's a cross-seller cart queue to continue
69 if let Some(user) = maybe_user
70 && let Ok(Some(mut queue)) = session.get::<Vec<String>>("cart_queue").await
71 && let Some(next_seller_id) = queue.first().cloned()
72 {
73 queue.remove(0);
74 if queue.is_empty() {
75 session.remove::<Vec<String>>("cart_queue").await.ok();
76 } else {
77 session.insert("cart_queue", queue).await.ok();
78 }
79
80 let share_contact = session.get::<bool>("cart_share_contact").await
81 .ok().flatten().unwrap_or(false);
82
83 match cart::drain_to_paid(&state, &user, next_seller_id.clone(), share_contact, &session).await {
84 Ok(Some(redirect_url)) => return Redirect::to(&redirect_url),
85 Ok(None) => {
86 // Queue drained with everything claimed free; fall through to
87 // the library redirect below.
88 session.remove::<Vec<String>>("cart_queue").await.ok();
89 session.remove::<bool>("cart_share_contact").await.ok();
90 }
91 Err(e) => {
92 tracing::error!(error = ?e, seller_id = %next_seller_id, "failed to process next cart seller");
93 session.remove::<Vec<String>>("cart_queue").await.ok();
94 session.remove::<bool>("cart_share_contact").await.ok();
95 // Previous sellers' purchases succeeded but this one failed.
96 // Redirect to cart where remaining items are still present.
97 return Redirect::to("/cart?checkout=partial");
98 }
99 }
100 }
101
102 session.remove::<Vec<String>>("cart_queue").await.ok();
103 session.remove::<bool>("cart_share_contact").await.ok();
104
105 // Single-item purchase: land on the item's library view so the buyer sees
106 // their downloads/player immediately. Cart purchases land on the library
107 // index (no single item to deep-link to).
108 match query.item_id.as_deref() {
109 Some(id) if uuid::Uuid::parse_str(id).is_ok() => {
110 Redirect::to(&format!("/l/{}?purchase=success", id))
111 }
112 _ => Redirect::to("/library?purchase=success"),
113 }
114 }
115
116 /// GET /stripe/cancel - Handle cancelled payment
117 #[tracing::instrument(skip_all, name = "stripe_checkout::checkout_cancel")]
118 pub(super) async fn checkout_cancel(
119 Query(query): Query<CancelQuery>,
120 ) -> impl IntoResponse {
121 // Redirect back to the item page (validate as UUID to prevent path traversal)
122 let redirect_url = match query.item_id {
123 Some(ref id) if uuid::Uuid::parse_str(id).is_ok() => format!("/i/{}", id),
124 _ => "/discover".to_string(),
125 };
126
127 Redirect::to(&redirect_url)
128 }
129