Skip to main content

max / makenotwork

7.3 KB · 238 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(), user.settlement_currency)?;
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 creator.settlement_currency,
117 )
118 .await?;
119
120 db::subscriptions::update_tier_stripe_ids(&db, tier.id, &product_id, &price_id).await?;
121 }
122
123 db::projects::bump_cache_generation(&db, project_id).await?;
124
125 tracing::info!(
126 "Subscription tier created: id={}, project={}, name={}, price={}",
127 tier.id,
128 project.slug,
129 req.name,
130 req.price_cents.as_i32()
131 );
132
133 Ok(Json(TierResponse {
134 id: tier.id,
135 name: tier.name,
136 description: tier.description,
137 price_cents: tier.price_cents,
138 is_active: tier.is_active,
139 }))
140 }
141
142 /// GET /api/projects/{id}/tiers: list tiers for a project
143 #[tracing::instrument(skip_all, name = "subscriptions::list_tiers")]
144 pub(super) async fn list_tiers(
145 State(db): State<PgPool>,
146 AuthUser(user): AuthUser,
147 Path(project_id): Path<ProjectId>,
148 ) -> Result<impl IntoResponse> {
149 // Verify ownership (only creator can see all tiers including inactive)
150 verify_project_ownership(&db, project_id, user.id).await?;
151
152 let tiers = db::subscriptions::get_all_tiers_by_project(&db, project_id).await?;
153
154 let data: Vec<TierResponse> = tiers
155 .into_iter()
156 .map(|t| TierResponse {
157 id: t.id,
158 name: t.name,
159 description: t.description,
160 price_cents: t.price_cents,
161 is_active: t.is_active,
162 })
163 .collect();
164
165 Ok(Json(ListResponse { data }))
166 }
167
168 /// PUT /api/tiers/{id}: update a tier's name, description, and active status
169 #[tracing::instrument(skip_all, name = "subscriptions::update_tier")]
170 pub(super) async fn update_tier(
171 State(db): State<PgPool>,
172 AuthUser(user): AuthUser,
173 Path(tier_id): Path<SubscriptionTierId>,
174 ValidatedJson(req): ValidatedJson<UpdateTierRequest>,
175 ) -> Result<impl IntoResponse> {
176 user.check_not_suspended()?;
177 // Get tier and verify project ownership
178 let tier = db::subscriptions::get_subscription_tier_by_id(&db, tier_id)
179 .await?
180 .ok_or(AppError::NotFound)?;
181
182 let tier_project_id = tier.project_id.ok_or(AppError::NotFound)?;
183 verify_project_ownership(&db, tier_project_id, user.id).await?;
184
185 // Validate input
186 validation::validate_tier_name(&req.name)?;
187 if let Some(ref desc) = req.description {
188 validation::validate_tier_description(desc)?;
189 }
190
191 let updated = db::subscriptions::update_subscription_tier(
192 &db,
193 tier_id,
194 &req.name,
195 req.description.as_deref(),
196 req.is_active,
197 )
198 .await?;
199
200 db::projects::bump_cache_generation(&db, tier_project_id).await?;
201
202 Ok(Json(TierResponse {
203 id: updated.id,
204 name: updated.name,
205 description: updated.description,
206 price_cents: updated.price_cents,
207 is_active: updated.is_active,
208 }))
209 }
210
211 /// DELETE /api/tiers/{id}: soft-delete a tier (set is_active=false)
212 #[tracing::instrument(skip_all, name = "subscriptions::delete_tier")]
213 pub(super) async fn delete_tier(
214 State(db): State<PgPool>,
215 headers: HeaderMap,
216 AuthUser(user): AuthUser,
217 Path(tier_id): Path<SubscriptionTierId>,
218 ) -> Result<Response> {
219 user.check_not_suspended()?;
220 // Get tier and verify project ownership
221 let tier = db::subscriptions::get_subscription_tier_by_id(&db, tier_id)
222 .await?
223 .ok_or(AppError::NotFound)?;
224
225 let tier_project_id = tier.project_id.ok_or(AppError::NotFound)?;
226 verify_project_ownership(&db, tier_project_id, user.id).await?;
227
228 db::subscriptions::delete_subscription_tier(&db, tier_id).await?;
229
230 db::projects::bump_cache_generation(&db, tier_project_id).await?;
231
232 if is_htmx_request(&headers) {
233 return Ok(htmx_toast_response("Tier deleted", "success").into_response());
234 }
235
236 Ok(StatusCode::NO_CONTENT.into_response())
237 }
238