Skip to main content

max / makenotwork

16.8 KB · 475 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 Json,
15 extract::{Path, State},
16 response::IntoResponse,
17 };
18
19 use sqlx::PgPool;
20
21 use crate::{
22 auth::AuthUser,
23 config::Config,
24 db::{self, SyncAppId},
25 error::{AppError, Result},
26 synckit_billing::monthly_price_cents,
27 };
28
29 use super::{
30 BillingActivateRequest, BillingPatchRequest, BillingSetupResponse, BillingStatusResponse,
31 BillingUpdatedResponse,
32 };
33
34 /// Set up Stripe billing for a draft app: create a Customer, return the
35 /// billing-portal URL so the developer can add a payment method.
36 ///
37 /// `POST /api/sync/apps/{id}/billing/setup`
38 #[tracing::instrument(skip_all, name = "synckit::billing::setup")]
39 pub(super) async fn setup(
40 State(db): State<PgPool>,
41 State(payments): State<crate::Billing>,
42 State(config): State<Config>,
43 AuthUser(user): AuthUser,
44 Path(app_id): Path<SyncAppId>,
45 ) -> Result<impl IntoResponse> {
46 user.check_not_sandbox()?;
47 user.check_not_suspended()?;
48
49 let app = db::synckit_billing::get_app_with_billing(&db, app_id)
50 .await?
51 .ok_or(AppError::NotFound)?;
52 if app.creator_id != user.id {
53 return Err(AppError::Forbidden);
54 }
55 if app.billing_status != crate::db::SyncBillingStatus::Draft {
56 return Err(AppError::Conflict(format!(
57 "App is already {}; billing setup is only valid in draft status",
58 app.billing_status
59 )));
60 }
61
62 let stripe = payments
63 .stripe
64 .as_ref()
65 .ok_or_else(|| AppError::ServiceUnavailable("Stripe is not configured".to_string()))?;
66
67 // Reuse existing customer if it was already created (idempotent retries).
68 let customer_id = match app.stripe_customer_id.as_deref() {
69 Some(id) => id.to_string(),
70 None => {
71 let developer = db::users::get_user_by_id(&db, user.id)
72 .await?
73 .ok_or(AppError::Unauthorized)?;
74 let id = stripe
75 .create_synckit_customer(user.id, app_id, developer.email.as_str(), &app.name)
76 .await?;
77 db::synckit_billing::set_stripe_customer(&db, app_id, &id).await?;
78 id
79 }
80 };
81
82 let return_url = synckit_return_url(&config, &app);
83 let portal_url = stripe
84 .create_synckit_billing_portal(&customer_id, &return_url)
85 .await?;
86
87 Ok(Json(BillingSetupResponse {
88 stripe_customer_id: customer_id,
89 billing_portal_url: portal_url,
90 }))
91 }
92
93 /// Activate billing on a draft app: validates knobs, computes price, creates
94 /// the Stripe subscription, and stamps the local sync_apps row.
95 ///
96 /// `POST /api/sync/apps/{id}/billing/activate`
97 #[tracing::instrument(skip_all, name = "synckit::billing::activate")]
98 pub(super) async fn activate(
99 State(db): State<PgPool>,
100 State(payments): State<crate::Billing>,
101 AuthUser(user): AuthUser,
102 Path(app_id): Path<SyncAppId>,
103 Json(req): Json<BillingActivateRequest>,
104 ) -> Result<impl IntoResponse> {
105 user.check_not_sandbox()?;
106 user.check_not_suspended()?;
107 let mode = validate_knobs(
108 &req.enforcement_mode,
109 req.storage_gb_cap,
110 req.key_cap,
111 req.gb_per_key,
112 )?;
113
114 let app = db::synckit_billing::get_app_with_billing(&db, app_id)
115 .await?
116 .ok_or(AppError::NotFound)?;
117 if app.creator_id != user.id {
118 return Err(AppError::Forbidden);
119 }
120 if app.billing_status != crate::db::SyncBillingStatus::Draft {
121 return Err(AppError::Conflict(format!(
122 "App is already {}; activate is only valid in draft status",
123 app.billing_status
124 )));
125 }
126 let customer_id = app.stripe_customer_id.as_deref().ok_or_else(|| {
127 AppError::BadRequest(
128 "Must POST /billing/setup before activating, no Stripe customer".to_string(),
129 )
130 })?;
131
132 let price_cents = monthly_price_cents(mode, req.storage_gb_cap, req.key_cap, req.gb_per_key);
133
134 let stripe = payments
135 .stripe
136 .as_ref()
137 .ok_or_else(|| AppError::ServiceUnavailable("Stripe is not configured".to_string()))?;
138 let sub = stripe
139 .create_synckit_subscription(customer_id, app_id, &app.name, price_cents)
140 .await?;
141
142 let period_start = chrono::DateTime::<chrono::Utc>::from_timestamp(sub.current_period_start, 0)
143 .ok_or_else(|| AppError::Internal(anyhow::anyhow!("Invalid period_start from Stripe")))?;
144 let period_end = chrono::DateTime::<chrono::Utc>::from_timestamp(sub.current_period_end, 0)
145 .ok_or_else(|| AppError::Internal(anyhow::anyhow!("Invalid period_end from Stripe")))?;
146
147 db::synckit_billing::activate_billing(
148 &db,
149 app_id,
150 mode,
151 req.storage_gb_cap.map(|v| v as i32),
152 req.key_cap.map(|v| v as i32),
153 req.gb_per_key.map(|v| v as i32),
154 &sub.subscription_id,
155 period_start,
156 period_end,
157 )
158 .await?;
159
160 Ok(Json(BillingUpdatedResponse {
161 monthly_price_cents: price_cents,
162 billing_status: "active".to_string(),
163 stripe_subscription_id: Some(sub.subscription_id),
164 }))
165 }
166
167 /// Change billing knobs on an active subscription (re-prices via proration).
168 ///
169 /// `PATCH /api/sync/apps/{id}/billing`
170 #[tracing::instrument(skip_all, name = "synckit::billing::patch")]
171 pub(super) async fn patch(
172 State(db): State<PgPool>,
173 State(payments): State<crate::Billing>,
174 AuthUser(user): AuthUser,
175 Path(app_id): Path<SyncAppId>,
176 Json(req): Json<BillingPatchRequest>,
177 ) -> Result<impl IntoResponse> {
178 user.check_not_sandbox()?;
179 user.check_not_suspended()?;
180 let mode = validate_knobs(
181 &req.enforcement_mode,
182 req.storage_gb_cap,
183 req.key_cap,
184 req.gb_per_key,
185 )?;
186
187 let app = db::synckit_billing::get_app_with_billing(&db, app_id)
188 .await?
189 .ok_or(AppError::NotFound)?;
190 if app.creator_id != user.id {
191 return Err(AppError::Forbidden);
192 }
193 if app.billing_status != crate::db::SyncBillingStatus::Active {
194 return Err(AppError::Conflict(format!(
195 "App is {}; PATCH is only valid when active",
196 app.billing_status
197 )));
198 }
199 let sub_id = app.stripe_subscription_id.as_deref().ok_or_else(|| {
200 AppError::Internal(anyhow::anyhow!(
201 "Active app has no stripe_subscription_id (data inconsistency)"
202 ))
203 })?;
204
205 let new_price = monthly_price_cents(mode, req.storage_gb_cap, req.key_cap, req.gb_per_key);
206
207 let stripe = payments
208 .stripe
209 .as_ref()
210 .ok_or_else(|| AppError::ServiceUnavailable("Stripe is not configured".to_string()))?;
211 stripe
212 .update_synckit_subscription_price(sub_id, new_price, &app.name)
213 .await?;
214
215 db::synckit_billing::update_knobs(
216 &db,
217 app_id,
218 mode,
219 req.storage_gb_cap.map(|v| v as i32),
220 req.key_cap.map(|v| v as i32),
221 req.gb_per_key.map(|v| v as i32),
222 )
223 .await?;
224
225 Ok(Json(BillingUpdatedResponse {
226 monthly_price_cents: new_price,
227 billing_status: "active".to_string(),
228 stripe_subscription_id: Some(sub_id.to_string()),
229 }))
230 }
231
232 /// Cancel billing for this app.
233 ///
234 /// `DELETE /api/sync/apps/{id}/billing`
235 #[tracing::instrument(skip_all, name = "synckit::billing::cancel")]
236 pub(super) async fn cancel(
237 State(db): State<PgPool>,
238 State(payments): State<crate::Billing>,
239 AuthUser(user): AuthUser,
240 Path(app_id): Path<SyncAppId>,
241 ) -> Result<impl IntoResponse> {
242 user.check_not_sandbox()?;
243
244 let app = db::synckit_billing::get_app_with_billing(&db, app_id)
245 .await?
246 .ok_or(AppError::NotFound)?;
247 if app.creator_id != user.id {
248 return Err(AppError::Forbidden);
249 }
250 if app.billing_status == crate::db::SyncBillingStatus::Canceled {
251 return Ok(axum::http::StatusCode::NO_CONTENT);
252 }
253
254 if let Some(sub_id) = app.stripe_subscription_id.as_deref() {
255 let stripe = payments
256 .stripe
257 .as_ref()
258 .ok_or_else(|| AppError::ServiceUnavailable("Stripe is not configured".to_string()))?;
259 stripe.cancel_synckit_subscription(sub_id).await?;
260 }
261
262 db::synckit_billing::apply_billing_update(&db, app_id, Some("canceled"), None).await?;
263
264 Ok(axum::http::StatusCode::NO_CONTENT)
265 }
266
267 /// Current billing status, knobs, usage counters, and computed price.
268 ///
269 /// `GET /api/sync/apps/{id}/billing`
270 #[tracing::instrument(skip_all, name = "synckit::billing::get")]
271 pub(super) async fn get(
272 State(db): State<PgPool>,
273 AuthUser(user): AuthUser,
274 Path(app_id): Path<SyncAppId>,
275 ) -> Result<impl IntoResponse> {
276 let app = db::synckit_billing::get_app_with_billing(&db, app_id)
277 .await?
278 .ok_or(AppError::NotFound)?;
279 if app.creator_id != user.id {
280 return Err(AppError::Forbidden);
281 }
282
283 let knobs_set = match app.enforcement_mode {
284 crate::db::SyncEnforcementMode::Bulk => app.storage_gb_cap.is_some(),
285 crate::db::SyncEnforcementMode::PerKey => app.key_cap.is_some() && app.gb_per_key.is_some(),
286 };
287 let monthly_price_cents = knobs_set.then(|| {
288 monthly_price_cents(
289 app.enforcement_mode,
290 app.storage_gb_cap.map(|v| v as u32),
291 app.key_cap.map(|v| v as u32),
292 app.gb_per_key.map(|v| v as u32),
293 )
294 });
295
296 Ok(Json(BillingStatusResponse {
297 app_id,
298 billing_status: app.billing_status.to_string(),
299 is_internal: app.is_internal,
300 enforcement_mode: app.enforcement_mode.to_string(),
301 storage_gb_cap: app.storage_gb_cap.map(|v| v as u32),
302 key_cap: app.key_cap.map(|v| v as u32),
303 gb_per_key: app.gb_per_key.map(|v| v as u32),
304 bytes_stored: app.bytes_stored.unwrap_or(0),
305 bytes_egress_period: app.bytes_egress_period.unwrap_or(0),
306 keys_claimed: app.keys_claimed.unwrap_or(0) as u32,
307 last_warning_pct: app.last_warning_pct.unwrap_or(0) as u8,
308 current_period_start: app.current_period_start,
309 current_period_end: app.current_period_end,
310 monthly_price_cents,
311 }))
312 }
313
314 /// Return a fresh Stripe billing portal URL for the app's developer. Portals
315 /// are single-use, so the dashboard hits this on demand rather than caching
316 /// the URL.
317 ///
318 /// `GET /api/sync/apps/{id}/billing/portal`
319 #[tracing::instrument(skip_all, name = "synckit::billing::portal")]
320 pub(super) async fn portal(
321 State(db): State<PgPool>,
322 State(payments): State<crate::Billing>,
323 State(config): State<Config>,
324 AuthUser(user): AuthUser,
325 Path(app_id): Path<SyncAppId>,
326 ) -> Result<impl IntoResponse> {
327 user.check_not_sandbox()?;
328
329 let app = db::synckit_billing::get_app_with_billing(&db, app_id)
330 .await?
331 .ok_or(AppError::NotFound)?;
332 if app.creator_id != user.id {
333 return Err(AppError::Forbidden);
334 }
335
336 let customer_id = app.stripe_customer_id.as_deref().ok_or_else(|| {
337 AppError::BadRequest(
338 "No Stripe customer for this app yet, POST /billing/setup first".to_string(),
339 )
340 })?;
341
342 let stripe = payments
343 .stripe
344 .as_ref()
345 .ok_or_else(|| AppError::ServiceUnavailable("Stripe is not configured".to_string()))?;
346
347 let return_url = synckit_return_url(&config, &app);
348 let portal_url = stripe
349 .create_synckit_billing_portal(customer_id, &return_url)
350 .await?;
351
352 Ok(Json(
353 serde_json::json!({ "billing_portal_url": portal_url }),
354 ))
355 }
356
357 // --- Helpers ---
358
359 /// Build the Stripe `return_url` for billing portal sessions. Sends the
360 /// developer back to the SyncKit tab on the project dashboard when the app is
361 /// linked to a project, or the Cloud Sync settings section otherwise.
362 ///
363 /// Both halves are queries rather than hashes (`6b24f2df`): each strip is
364 /// described, so the destination is chosen server-side and arrives already
365 /// rendered.
366 ///
367 /// The unlinked half is `&section=synckit` since `47e67540`. It was
368 /// `?tab=settings`, which opened the settings tab on Profile and left the
369 /// developer to find the app they had just been billed for; before that it was
370 /// `/dashboard#tab-synckit`, an id that page has never had, which did nothing at
371 /// all. The section is where an orphan app can be linked to a project after the
372 /// fact, which is the case this return exists to serve.
373 fn synckit_return_url(config: &Config, app: &crate::db::DbSyncAppBilling) -> String {
374 match app.project_slug.as_deref() {
375 Some(slug) => format!("{}/dashboard/project/{}?tab=synckit", config.host_url, slug),
376 None => format!("{}/dashboard?tab=settings&section=synckit", config.host_url),
377 }
378 }
379
380 /// Validate the knob set for a billing request and parse the mode into the sealed
381 /// [`SyncEnforcementMode`]. This is the single parse point for untrusted
382 /// `enforcement_mode` input: downstream pricing and persistence take the enum, so
383 /// an invalid mode is rejected here rather than silently mispriced (Pay-S2).
384 fn validate_knobs(
385 enforcement_mode: &str,
386 storage_gb_cap: Option<u32>,
387 key_cap: Option<u32>,
388 gb_per_key: Option<u32>,
389 ) -> Result<crate::db::SyncEnforcementMode> {
390 use crate::db::SyncEnforcementMode;
391 // Upper bound on the priced storage so a developer can't provision an
392 // absurd Stripe subscription. Bulk: `storage_gb_cap`. Per-key: the
393 // `key_cap × gb_per_key` product (the value the price is computed from).
394 let max = crate::synckit_billing::MAX_STORAGE_GB;
395 match enforcement_mode {
396 "bulk" => {
397 match storage_gb_cap {
398 Some(v) if v > 0 && i64::from(v) <= max => {}
399 _ => {
400 return Err(AppError::BadRequest(format!(
401 "storage_gb_cap must be between 1 and {max} GB for enforcement_mode = bulk"
402 )));
403 }
404 }
405 if key_cap.is_some() || gb_per_key.is_some() {
406 return Err(AppError::BadRequest(
407 "key_cap and gb_per_key must be omitted when enforcement_mode = bulk"
408 .to_string(),
409 ));
410 }
411 Ok(SyncEnforcementMode::Bulk)
412 }
413 "per_key" => {
414 let k = match key_cap {
415 Some(v) if v > 0 => v,
416 _ => {
417 return Err(AppError::BadRequest(
418 "key_cap (> 0) is required when enforcement_mode = per_key".to_string(),
419 ));
420 }
421 };
422 let g = match gb_per_key {
423 Some(v) if v > 0 => v,
424 _ => {
425 return Err(AppError::BadRequest(
426 "gb_per_key (> 0) is required when enforcement_mode = per_key".to_string(),
427 ));
428 }
429 };
430 // u64 to avoid overflow before the bound check.
431 if u64::from(k) * u64::from(g) > max as u64 {
432 return Err(AppError::BadRequest(format!(
433 "key_cap × gb_per_key must not exceed {max} GB total"
434 )));
435 }
436 if storage_gb_cap.is_some() {
437 return Err(AppError::BadRequest(
438 "storage_gb_cap must be omitted when enforcement_mode = per_key".to_string(),
439 ));
440 }
441 Ok(SyncEnforcementMode::PerKey)
442 }
443 other => Err(AppError::BadRequest(format!(
444 "enforcement_mode must be 'bulk' or 'per_key', got {other:?}"
445 ))),
446 }
447 }
448
449 #[cfg(test)]
450 mod tests {
451 use super::validate_knobs;
452 use crate::synckit_billing::MAX_STORAGE_GB;
453
454 #[test]
455 fn bulk_cap_is_bounded() {
456 assert!(validate_knobs("bulk", Some(100), None, None).is_ok());
457 assert!(validate_knobs("bulk", Some(MAX_STORAGE_GB as u32), None, None).is_ok());
458 assert!(validate_knobs("bulk", Some(0), None, None).is_err());
459 // One past the ceiling, and the absurd value the report flagged.
460 assert!(validate_knobs("bulk", Some(MAX_STORAGE_GB as u32 + 1), None, None).is_err());
461 assert!(validate_knobs("bulk", Some(u32::MAX), None, None).is_err());
462 }
463
464 #[test]
465 fn per_key_product_is_bounded() {
466 assert!(validate_knobs("per_key", None, Some(100), Some(1)).is_ok());
467 // key_cap × gb_per_key exactly at the ceiling.
468 assert!(validate_knobs("per_key", None, Some(MAX_STORAGE_GB as u32), Some(1)).is_ok());
469 // Product over the ceiling, including the u32-overflow case.
470 assert!(validate_knobs("per_key", None, Some(MAX_STORAGE_GB as u32), Some(2)).is_err());
471 assert!(validate_knobs("per_key", None, Some(u32::MAX), Some(u32::MAX)).is_err());
472 assert!(validate_knobs("per_key", None, Some(0), Some(1)).is_err());
473 }
474 }
475