Skip to main content

max / makenotwork

7.0 KB · 217 lines History Blame Raw
1 //! Subscription tier management API for creators.
2
3 use axum::{
4 extract::{Path, State},
5 http::{header::HeaderMap, StatusCode},
6 response::{IntoResponse, Response},
7 Json,
8 };
9 use serde::{Deserialize, Serialize};
10
11 use crate::{
12 auth::AuthUser,
13 db::{self, PriceCents, ProjectId, SubscriptionTierId},
14 error::{AppError, Result},
15 helpers::{htmx_toast_response, is_htmx_request},
16 types::ListResponse,
17 validation,
18 AppState,
19 };
20
21 use super::verify_project_ownership;
22
23 /// JSON response representing a subscription tier.
24 #[derive(Debug, Serialize)]
25 struct TierResponse {
26 id: SubscriptionTierId,
27 name: String,
28 description: Option<String>,
29 price_cents: i32,
30 is_active: bool,
31 }
32
33 /// JSON input for creating a subscription tier.
34 #[derive(Debug, Deserialize)]
35 pub(super) struct CreateTierRequest {
36 pub name: String,
37 pub description: Option<String>,
38 /// Price in cents. Validated non-negative on deserialization.
39 pub price_cents: PriceCents,
40 }
41
42 /// JSON input for updating a subscription tier.
43 #[derive(Debug, Deserialize)]
44 pub(super) struct UpdateTierRequest {
45 pub name: String,
46 pub description: Option<String>,
47 #[serde(default)]
48 pub is_active: bool,
49 }
50
51 /// POST /api/projects/{id}/tiers: create a subscription tier
52 #[tracing::instrument(skip_all, name = "subscriptions::create_tier")]
53 pub(super) async fn create_tier(
54 State(state): State<AppState>,
55 AuthUser(user): AuthUser,
56 Path(project_id): Path<ProjectId>,
57 Json(req): Json<CreateTierRequest>,
58 ) -> Result<impl IntoResponse> {
59 user.check_not_suspended()?;
60 // Verify ownership
61 let project = verify_project_ownership(&state, project_id, user.id).await?;
62
63 // Validate input
64 validation::validate_tier_name(&req.name)?;
65 if let Some(ref desc) = req.description {
66 validation::validate_tier_description(desc)?;
67 }
68 validation::validate_tier_price(req.price_cents.as_i32())?;
69
70 // Create tier in our database
71 let tier = db::subscriptions::create_subscription_tier(
72 &state.db,
73 project_id,
74 &req.name,
75 req.description.as_deref(),
76 req.price_cents,
77 ).await?;
78
79 // Sandbox users get fake Stripe IDs; real users create Stripe Product + Price
80 if user.is_sandbox {
81 let fake_product = format!("sandbox_prod_{}", tier.id);
82 let fake_price = format!("sandbox_price_{}", tier.id);
83 db::subscriptions::update_tier_stripe_ids(&state.db, tier.id, &fake_product, &fake_price).await?;
84 } else {
85 let creator = db::users::get_user_by_id(&state.db, user.id)
86 .await?
87 .ok_or(AppError::NotFound)?;
88
89 let stripe_account_id = creator.stripe_account_id.as_ref()
90 .ok_or_else(|| AppError::BadRequest("Connect your Stripe account before creating subscription tiers".to_string()))?;
91
92 if !creator.stripe_charges_enabled {
93 return Err(AppError::BadRequest("Your Stripe account is not ready for charges".to_string()));
94 }
95
96 let stripe = state.stripe.as_ref()
97 .ok_or_else(|| AppError::BadRequest("Stripe is not configured".to_string()))?;
98
99 let (product_id, price_id) = stripe.create_subscription_product_and_price(
100 stripe_account_id,
101 &req.name,
102 req.description.as_deref(),
103 req.price_cents.as_i32() as i64,
104 ).await?;
105
106 db::subscriptions::update_tier_stripe_ids(&state.db, tier.id, &product_id, &price_id).await?;
107 }
108
109 db::projects::bump_cache_generation(&state.db, project_id).await?;
110
111 tracing::info!(
112 "Subscription tier created: id={}, project={}, name={}, price={}",
113 tier.id, project.slug, req.name, req.price_cents.as_i32()
114 );
115
116 Ok(Json(TierResponse {
117 id: tier.id,
118 name: tier.name,
119 description: tier.description,
120 price_cents: tier.price_cents,
121 is_active: tier.is_active,
122 }))
123 }
124
125 /// GET /api/projects/{id}/tiers: list tiers for a project
126 #[tracing::instrument(skip_all, name = "subscriptions::list_tiers")]
127 pub(super) async fn list_tiers(
128 State(state): State<AppState>,
129 AuthUser(user): AuthUser,
130 Path(project_id): Path<ProjectId>,
131 ) -> Result<impl IntoResponse> {
132 // Verify ownership (only creator can see all tiers including inactive)
133 verify_project_ownership(&state, project_id, user.id).await?;
134
135 let tiers = db::subscriptions::get_all_tiers_by_project(&state.db, project_id).await?;
136
137 let data: Vec<TierResponse> = tiers.into_iter().map(|t| TierResponse {
138 id: t.id,
139 name: t.name,
140 description: t.description,
141 price_cents: t.price_cents,
142 is_active: t.is_active,
143 }).collect();
144
145 Ok(Json(ListResponse { data }))
146 }
147
148 /// PUT /api/tiers/{id}: update a tier's name, description, and active status
149 #[tracing::instrument(skip_all, name = "subscriptions::update_tier")]
150 pub(super) async fn update_tier(
151 State(state): State<AppState>,
152 AuthUser(user): AuthUser,
153 Path(tier_id): Path<SubscriptionTierId>,
154 Json(req): Json<UpdateTierRequest>,
155 ) -> Result<impl IntoResponse> {
156 user.check_not_suspended()?;
157 // Get tier and verify project ownership
158 let tier = db::subscriptions::get_subscription_tier_by_id(&state.db, tier_id)
159 .await?
160 .ok_or(AppError::NotFound)?;
161
162 let tier_project_id = tier.project_id.ok_or(AppError::NotFound)?;
163 verify_project_ownership(&state, tier_project_id, user.id).await?;
164
165 // Validate input
166 validation::validate_tier_name(&req.name)?;
167 if let Some(ref desc) = req.description {
168 validation::validate_tier_description(desc)?;
169 }
170
171 let updated = db::subscriptions::update_subscription_tier(
172 &state.db,
173 tier_id,
174 &req.name,
175 req.description.as_deref(),
176 req.is_active,
177 ).await?;
178
179 db::projects::bump_cache_generation(&state.db, tier_project_id).await?;
180
181 Ok(Json(TierResponse {
182 id: updated.id,
183 name: updated.name,
184 description: updated.description,
185 price_cents: updated.price_cents,
186 is_active: updated.is_active,
187 }))
188 }
189
190 /// DELETE /api/tiers/{id}: soft-delete a tier (set is_active=false)
191 #[tracing::instrument(skip_all, name = "subscriptions::delete_tier")]
192 pub(super) async fn delete_tier(
193 State(state): State<AppState>,
194 headers: HeaderMap,
195 AuthUser(user): AuthUser,
196 Path(tier_id): Path<SubscriptionTierId>,
197 ) -> Result<Response> {
198 user.check_not_suspended()?;
199 // Get tier and verify project ownership
200 let tier = db::subscriptions::get_subscription_tier_by_id(&state.db, tier_id)
201 .await?
202 .ok_or(AppError::NotFound)?;
203
204 let tier_project_id = tier.project_id.ok_or(AppError::NotFound)?;
205 verify_project_ownership(&state, tier_project_id, user.id).await?;
206
207 db::subscriptions::delete_subscription_tier(&state.db, tier_id).await?;
208
209 db::projects::bump_cache_generation(&state.db, tier_project_id).await?;
210
211 if is_htmx_request(&headers) {
212 return Ok(htmx_toast_response("Tier deleted", "success").into_response());
213 }
214
215 Ok(StatusCode::NO_CONTENT.into_response())
216 }
217