Skip to main content

max / makenotwork

5.2 KB · 146 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 match cart::drain_to_paid(
92 &db,
93 integrations.wam.as_ref(),
94 &payments,
95 &config,
96 &user,
97 next_seller_id.clone(),
98 share_contact,
99 &session,
100 )
101 .await
102 {
103 Ok(Some(redirect_url)) => return Redirect::to(&redirect_url),
104 Ok(None) => {
105 // Queue drained with everything claimed free; fall through to
106 // the library redirect below.
107 session.remove::<Vec<String>>("cart_queue").await.ok();
108 session.remove::<bool>("cart_share_contact").await.ok();
109 }
110 Err(e) => {
111 tracing::error!(error = ?e, seller_id = %next_seller_id, "failed to process next cart seller");
112 session.remove::<Vec<String>>("cart_queue").await.ok();
113 session.remove::<bool>("cart_share_contact").await.ok();
114 // Previous sellers' purchases succeeded but this one failed.
115 // Redirect to cart where remaining items are still present.
116 return Redirect::to("/cart?checkout=partial");
117 }
118 }
119 }
120
121 session.remove::<Vec<String>>("cart_queue").await.ok();
122 session.remove::<bool>("cart_share_contact").await.ok();
123
124 // Single-item purchase: land on the item's library view so the buyer sees
125 // their downloads/player immediately. Cart purchases land on the library
126 // index (no single item to deep-link to).
127 match query.item_id.as_deref() {
128 Some(id) if uuid::Uuid::parse_str(id).is_ok() => {
129 Redirect::to(&format!("/l/{id}?purchase=success"))
130 }
131 _ => Redirect::to("/library?purchase=success"),
132 }
133 }
134
135 /// GET /stripe/cancel - Handle cancelled payment
136 #[tracing::instrument(skip_all, name = "stripe_checkout::checkout_cancel")]
137 pub(super) async fn checkout_cancel(Query(query): Query<CancelQuery>) -> impl IntoResponse {
138 // Redirect back to the item page (validate as UUID to prevent path traversal)
139 let redirect_url = match query.item_id {
140 Some(ref id) if uuid::Uuid::parse_str(id).is_ok() => format!("/i/{id}"),
141 _ => "/discover".to_string(),
142 };
143
144 Redirect::to(&redirect_url)
145 }
146