Skip to main content

max / makenotwork

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