Skip to main content

max / makenotwork

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