Skip to main content

max / makenotwork

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