Skip to main content

max / makenotwork

13.5 KB · 411 lines History Blame Raw
1 //! SyncKit v2 developer billing routes.
2 //!
3 //! All routes use session auth. They walk the developer through:
4 //! 1. setup; create the Stripe customer for this app
5 //! 2. activate; set knobs, create subscription
6 //! 3. PATCH; change knobs (and re-price the subscription)
7 //! 4. DELETE; cancel
8 //! 5. GET; current status + usage + computed price
9 //!
10 //! See `synckit_billing.rs` (pricing) and `migrations/117_synckit_v2_billing.sql`
11 //! for the schema.
12
13 use axum::{
14 extract::{Path, State},
15 response::IntoResponse,
16 Json,
17 };
18
19 use crate::{
20 auth::AuthUser,
21 db::{self, SyncAppId},
22 error::{AppError, Result},
23 synckit_billing::monthly_price_cents,
24 AppState,
25 };
26
27 use super::{
28 BillingActivateRequest, BillingPatchRequest, BillingSetupResponse, BillingStatusResponse,
29 BillingUpdatedResponse,
30 };
31
32 /// Set up Stripe billing for a draft app: create a Customer, return the
33 /// billing-portal URL so the developer can add a payment method.
34 ///
35 /// `POST /api/sync/apps/{id}/billing/setup`
36 #[tracing::instrument(skip_all, name = "synckit::billing::setup")]
37 pub(super) async fn setup(
38 State(state): State<AppState>,
39 AuthUser(user): AuthUser,
40 Path(app_id): Path<SyncAppId>,
41 ) -> Result<impl IntoResponse> {
42 user.check_not_sandbox()?;
43 user.check_not_suspended()?;
44
45 let app = db::synckit_billing::get_app_with_billing(&state.db, app_id)
46 .await?
47 .ok_or(AppError::NotFound)?;
48 if app.creator_id != user.id {
49 return Err(AppError::Forbidden);
50 }
51 if app.billing_status != "draft" {
52 return Err(AppError::Conflict(format!(
53 "App is already {}; billing setup is only valid in draft status",
54 app.billing_status
55 )));
56 }
57
58 let stripe = state
59 .stripe
60 .as_ref()
61 .ok_or_else(|| AppError::ServiceUnavailable("Stripe is not configured".to_string()))?;
62
63 // Reuse existing customer if it was already created (idempotent retries).
64 let customer_id = match app.stripe_customer_id.as_deref() {
65 Some(id) => id.to_string(),
66 None => {
67 let developer = db::users::get_user_by_id(&state.db, user.id)
68 .await?
69 .ok_or(AppError::Unauthorized)?;
70 let id = stripe
71 .create_synckit_customer(user.id, developer.email.as_str(), &app.name)
72 .await?;
73 db::synckit_billing::set_stripe_customer(&state.db, app_id, &id).await?;
74 id
75 }
76 };
77
78 let return_url = synckit_return_url(&state, &app);
79 let portal_url = stripe
80 .create_synckit_billing_portal(&customer_id, &return_url)
81 .await?;
82
83 Ok(Json(BillingSetupResponse {
84 stripe_customer_id: customer_id,
85 billing_portal_url: portal_url,
86 }))
87 }
88
89 /// Activate billing on a draft app: validates knobs, computes price, creates
90 /// the Stripe subscription, and stamps the local sync_apps row.
91 ///
92 /// `POST /api/sync/apps/{id}/billing/activate`
93 #[tracing::instrument(skip_all, name = "synckit::billing::activate")]
94 pub(super) async fn activate(
95 State(state): State<AppState>,
96 AuthUser(user): AuthUser,
97 Path(app_id): Path<SyncAppId>,
98 Json(req): Json<BillingActivateRequest>,
99 ) -> Result<impl IntoResponse> {
100 user.check_not_sandbox()?;
101 user.check_not_suspended()?;
102 validate_knobs(&req.enforcement_mode, req.storage_gb_cap, req.key_cap, req.gb_per_key)?;
103
104 let app = db::synckit_billing::get_app_with_billing(&state.db, app_id)
105 .await?
106 .ok_or(AppError::NotFound)?;
107 if app.creator_id != user.id {
108 return Err(AppError::Forbidden);
109 }
110 if app.billing_status != "draft" {
111 return Err(AppError::Conflict(format!(
112 "App is already {}; activate is only valid in draft status",
113 app.billing_status
114 )));
115 }
116 let customer_id = app.stripe_customer_id.as_deref().ok_or_else(|| {
117 AppError::BadRequest(
118 "Must POST /billing/setup before activating — no Stripe customer".to_string(),
119 )
120 })?;
121
122 let price_cents = monthly_price_cents(
123 &req.enforcement_mode,
124 req.storage_gb_cap,
125 req.key_cap,
126 req.gb_per_key,
127 );
128
129 let stripe = state
130 .stripe
131 .as_ref()
132 .ok_or_else(|| AppError::ServiceUnavailable("Stripe is not configured".to_string()))?;
133 let sub = stripe
134 .create_synckit_subscription(customer_id, app_id, &app.name, price_cents)
135 .await?;
136
137 let period_start = chrono::DateTime::<chrono::Utc>::from_timestamp(sub.current_period_start, 0)
138 .ok_or_else(|| AppError::Internal(anyhow::anyhow!("Invalid period_start from Stripe")))?;
139 let period_end = chrono::DateTime::<chrono::Utc>::from_timestamp(sub.current_period_end, 0)
140 .ok_or_else(|| AppError::Internal(anyhow::anyhow!("Invalid period_end from Stripe")))?;
141
142 db::synckit_billing::activate_billing(
143 &state.db,
144 app_id,
145 &req.enforcement_mode,
146 req.storage_gb_cap.map(|v| v as i32),
147 req.key_cap.map(|v| v as i32),
148 req.gb_per_key.map(|v| v as i32),
149 &sub.subscription_id,
150 period_start,
151 period_end,
152 )
153 .await?;
154
155 Ok(Json(BillingUpdatedResponse {
156 monthly_price_cents: price_cents,
157 billing_status: "active".to_string(),
158 stripe_subscription_id: Some(sub.subscription_id),
159 }))
160 }
161
162 /// Change billing knobs on an active subscription (re-prices via proration).
163 ///
164 /// `PATCH /api/sync/apps/{id}/billing`
165 #[tracing::instrument(skip_all, name = "synckit::billing::patch")]
166 pub(super) async fn patch(
167 State(state): State<AppState>,
168 AuthUser(user): AuthUser,
169 Path(app_id): Path<SyncAppId>,
170 Json(req): Json<BillingPatchRequest>,
171 ) -> Result<impl IntoResponse> {
172 user.check_not_sandbox()?;
173 user.check_not_suspended()?;
174 validate_knobs(&req.enforcement_mode, req.storage_gb_cap, req.key_cap, req.gb_per_key)?;
175
176 let app = db::synckit_billing::get_app_with_billing(&state.db, app_id)
177 .await?
178 .ok_or(AppError::NotFound)?;
179 if app.creator_id != user.id {
180 return Err(AppError::Forbidden);
181 }
182 if app.billing_status != "active" {
183 return Err(AppError::Conflict(format!(
184 "App is {}; PATCH is only valid when active",
185 app.billing_status
186 )));
187 }
188 let sub_id = app.stripe_subscription_id.as_deref().ok_or_else(|| {
189 AppError::Internal(anyhow::anyhow!(
190 "Active app has no stripe_subscription_id (data inconsistency)"
191 ))
192 })?;
193
194 let new_price = monthly_price_cents(
195 &req.enforcement_mode,
196 req.storage_gb_cap,
197 req.key_cap,
198 req.gb_per_key,
199 );
200
201 let stripe = state
202 .stripe
203 .as_ref()
204 .ok_or_else(|| AppError::ServiceUnavailable("Stripe is not configured".to_string()))?;
205 stripe
206 .update_synckit_subscription_price(sub_id, new_price, &app.name)
207 .await?;
208
209 db::synckit_billing::update_knobs(
210 &state.db,
211 app_id,
212 &req.enforcement_mode,
213 req.storage_gb_cap.map(|v| v as i32),
214 req.key_cap.map(|v| v as i32),
215 req.gb_per_key.map(|v| v as i32),
216 )
217 .await?;
218
219 Ok(Json(BillingUpdatedResponse {
220 monthly_price_cents: new_price,
221 billing_status: "active".to_string(),
222 stripe_subscription_id: Some(sub_id.to_string()),
223 }))
224 }
225
226 /// Cancel billing for this app.
227 ///
228 /// `DELETE /api/sync/apps/{id}/billing`
229 #[tracing::instrument(skip_all, name = "synckit::billing::cancel")]
230 pub(super) async fn cancel(
231 State(state): State<AppState>,
232 AuthUser(user): AuthUser,
233 Path(app_id): Path<SyncAppId>,
234 ) -> Result<impl IntoResponse> {
235 user.check_not_sandbox()?;
236
237 let app = db::synckit_billing::get_app_with_billing(&state.db, app_id)
238 .await?
239 .ok_or(AppError::NotFound)?;
240 if app.creator_id != user.id {
241 return Err(AppError::Forbidden);
242 }
243 if app.billing_status == "canceled" {
244 return Ok(axum::http::StatusCode::NO_CONTENT);
245 }
246
247 if let Some(sub_id) = app.stripe_subscription_id.as_deref() {
248 let stripe = state
249 .stripe
250 .as_ref()
251 .ok_or_else(|| AppError::ServiceUnavailable("Stripe is not configured".to_string()))?;
252 stripe.cancel_synckit_subscription(sub_id).await?;
253 }
254
255 db::synckit_billing::apply_billing_update(&state.db, app_id, Some("canceled"), None).await?;
256
257 Ok(axum::http::StatusCode::NO_CONTENT)
258 }
259
260 /// Current billing status, knobs, usage counters, and computed price.
261 ///
262 /// `GET /api/sync/apps/{id}/billing`
263 #[tracing::instrument(skip_all, name = "synckit::billing::get")]
264 pub(super) async fn get(
265 State(state): State<AppState>,
266 AuthUser(user): AuthUser,
267 Path(app_id): Path<SyncAppId>,
268 ) -> Result<impl IntoResponse> {
269 let app = db::synckit_billing::get_app_with_billing(&state.db, app_id)
270 .await?
271 .ok_or(AppError::NotFound)?;
272 if app.creator_id != user.id {
273 return Err(AppError::Forbidden);
274 }
275
276 let knobs_set = match app.enforcement_mode.as_str() {
277 "bulk" => app.storage_gb_cap.is_some(),
278 "per_key" => app.key_cap.is_some() && app.gb_per_key.is_some(),
279 _ => false,
280 };
281 let monthly_price_cents = knobs_set.then(|| monthly_price_cents(
282 &app.enforcement_mode,
283 app.storage_gb_cap.map(|v| v as u32),
284 app.key_cap.map(|v| v as u32),
285 app.gb_per_key.map(|v| v as u32),
286 ));
287
288 Ok(Json(BillingStatusResponse {
289 app_id,
290 billing_status: app.billing_status,
291 is_internal: app.is_internal,
292 enforcement_mode: app.enforcement_mode,
293 storage_gb_cap: app.storage_gb_cap.map(|v| v as u32),
294 key_cap: app.key_cap.map(|v| v as u32),
295 gb_per_key: app.gb_per_key.map(|v| v as u32),
296 bytes_stored: app.bytes_stored.unwrap_or(0),
297 bytes_egress_period: app.bytes_egress_period.unwrap_or(0),
298 keys_claimed: app.keys_claimed.unwrap_or(0) as u32,
299 last_warning_pct: app.last_warning_pct.unwrap_or(0) as u8,
300 current_period_start: app.current_period_start,
301 current_period_end: app.current_period_end,
302 monthly_price_cents,
303 }))
304 }
305
306 /// Return a fresh Stripe billing portal URL for the app's developer. Portals
307 /// are single-use, so the dashboard hits this on demand rather than caching
308 /// the URL.
309 ///
310 /// `GET /api/sync/apps/{id}/billing/portal`
311 #[tracing::instrument(skip_all, name = "synckit::billing::portal")]
312 pub(super) async fn portal(
313 State(state): State<AppState>,
314 AuthUser(user): AuthUser,
315 Path(app_id): Path<SyncAppId>,
316 ) -> Result<impl IntoResponse> {
317 user.check_not_sandbox()?;
318
319 let app = db::synckit_billing::get_app_with_billing(&state.db, app_id)
320 .await?
321 .ok_or(AppError::NotFound)?;
322 if app.creator_id != user.id {
323 return Err(AppError::Forbidden);
324 }
325
326 let customer_id = app.stripe_customer_id.as_deref().ok_or_else(|| {
327 AppError::BadRequest(
328 "No Stripe customer for this app yet — POST /billing/setup first".to_string(),
329 )
330 })?;
331
332 let stripe = state
333 .stripe
334 .as_ref()
335 .ok_or_else(|| AppError::ServiceUnavailable("Stripe is not configured".to_string()))?;
336
337 let return_url = synckit_return_url(&state, &app);
338 let portal_url = stripe
339 .create_synckit_billing_portal(customer_id, &return_url)
340 .await?;
341
342 Ok(Json(serde_json::json!({ "billing_portal_url": portal_url })))
343 }
344
345 // ── Helpers ──
346
347 /// Build the Stripe `return_url` for billing portal sessions. Sends the
348 /// developer back to the SyncKit tab on the project dashboard when the app is
349 /// linked to a project, or the user-dashboard SyncKit tab otherwise.
350 fn synckit_return_url(
351 state: &AppState,
352 app: &crate::db::DbSyncAppBilling,
353 ) -> String {
354 match app.project_slug.as_deref() {
355 Some(slug) => format!(
356 "{}/dashboard/project/{}#tab-synckit",
357 state.config.host_url, slug
358 ),
359 None => format!("{}/dashboard#tab-synckit", state.config.host_url),
360 }
361 }
362
363
364 fn validate_knobs(
365 enforcement_mode: &str,
366 storage_gb_cap: Option<u32>,
367 key_cap: Option<u32>,
368 gb_per_key: Option<u32>,
369 ) -> Result<()> {
370 match enforcement_mode {
371 "bulk" => {
372 match storage_gb_cap {
373 Some(v) if v > 0 => {}
374 _ => return Err(AppError::BadRequest(
375 "storage_gb_cap (> 0) is required when enforcement_mode = bulk".to_string(),
376 )),
377 }
378 if key_cap.is_some() || gb_per_key.is_some() {
379 return Err(AppError::BadRequest(
380 "key_cap and gb_per_key must be omitted when enforcement_mode = bulk".to_string(),
381 ));
382 }
383 }
384 "per_key" => {
385 match key_cap {
386 Some(v) if v > 0 => {}
387 _ => return Err(AppError::BadRequest(
388 "key_cap (> 0) is required when enforcement_mode = per_key".to_string(),
389 )),
390 }
391 match gb_per_key {
392 Some(v) if v > 0 => {}
393 _ => return Err(AppError::BadRequest(
394 "gb_per_key (> 0) is required when enforcement_mode = per_key".to_string(),
395 )),
396 }
397 if storage_gb_cap.is_some() {
398 return Err(AppError::BadRequest(
399 "storage_gb_cap must be omitted when enforcement_mode = per_key".to_string(),
400 ));
401 }
402 }
403 other => {
404 return Err(AppError::BadRequest(format!(
405 "enforcement_mode must be 'bulk' or 'per_key', got {other:?}"
406 )));
407 }
408 }
409 Ok(())
410 }
411