Skip to main content

max / makenotwork

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