//! SyncKit v2 developer billing routes. //! //! All routes use session auth. They walk the developer through: //! 1. setup; create the Stripe customer for this app //! 2. activate; set knobs, create subscription //! 3. PATCH; change knobs (and re-price the subscription) //! 4. DELETE; cancel //! 5. GET; current status + usage + computed price //! //! See `synckit_billing.rs` (pricing) and `migrations/117_synckit_v2_billing.sql` //! for the schema. use axum::{ extract::{Path, State}, response::IntoResponse, Json, }; use crate::{ auth::AuthUser, db::{self, SyncAppId}, error::{AppError, Result}, synckit_billing::monthly_price_cents, AppState, }; use super::{ BillingActivateRequest, BillingPatchRequest, BillingSetupResponse, BillingStatusResponse, BillingUpdatedResponse, }; /// Set up Stripe billing for a draft app: create a Customer, return the /// billing-portal URL so the developer can add a payment method. /// /// `POST /api/sync/apps/{id}/billing/setup` #[tracing::instrument(skip_all, name = "synckit::billing::setup")] pub(super) async fn setup( State(state): State, AuthUser(user): AuthUser, Path(app_id): Path, ) -> Result { user.check_not_sandbox()?; user.check_not_suspended()?; let app = db::synckit_billing::get_app_with_billing(&state.db, app_id) .await? .ok_or(AppError::NotFound)?; if app.creator_id != user.id { return Err(AppError::Forbidden); } if app.billing_status != "draft" { return Err(AppError::Conflict(format!( "App is already {}; billing setup is only valid in draft status", app.billing_status ))); } let stripe = state .stripe .as_ref() .ok_or_else(|| AppError::ServiceUnavailable("Stripe is not configured".to_string()))?; // Reuse existing customer if it was already created (idempotent retries). let customer_id = match app.stripe_customer_id.as_deref() { Some(id) => id.to_string(), None => { let developer = db::users::get_user_by_id(&state.db, user.id) .await? .ok_or(AppError::Unauthorized)?; let id = stripe .create_synckit_customer(user.id, developer.email.as_str(), &app.name) .await?; db::synckit_billing::set_stripe_customer(&state.db, app_id, &id).await?; id } }; let return_url = synckit_return_url(&state, &app); let portal_url = stripe .create_synckit_billing_portal(&customer_id, &return_url) .await?; Ok(Json(BillingSetupResponse { stripe_customer_id: customer_id, billing_portal_url: portal_url, })) } /// Activate billing on a draft app: validates knobs, computes price, creates /// the Stripe subscription, and stamps the local sync_apps row. /// /// `POST /api/sync/apps/{id}/billing/activate` #[tracing::instrument(skip_all, name = "synckit::billing::activate")] pub(super) async fn activate( State(state): State, AuthUser(user): AuthUser, Path(app_id): Path, Json(req): Json, ) -> Result { user.check_not_sandbox()?; user.check_not_suspended()?; validate_knobs(&req.enforcement_mode, req.storage_gb_cap, req.key_cap, req.gb_per_key)?; let app = db::synckit_billing::get_app_with_billing(&state.db, app_id) .await? .ok_or(AppError::NotFound)?; if app.creator_id != user.id { return Err(AppError::Forbidden); } if app.billing_status != "draft" { return Err(AppError::Conflict(format!( "App is already {}; activate is only valid in draft status", app.billing_status ))); } let customer_id = app.stripe_customer_id.as_deref().ok_or_else(|| { AppError::BadRequest( "Must POST /billing/setup before activating — no Stripe customer".to_string(), ) })?; let price_cents = monthly_price_cents( &req.enforcement_mode, req.storage_gb_cap, req.key_cap, req.gb_per_key, ); let stripe = state .stripe .as_ref() .ok_or_else(|| AppError::ServiceUnavailable("Stripe is not configured".to_string()))?; let sub = stripe .create_synckit_subscription(customer_id, app_id, &app.name, price_cents) .await?; let period_start = chrono::DateTime::::from_timestamp(sub.current_period_start, 0) .ok_or_else(|| AppError::Internal(anyhow::anyhow!("Invalid period_start from Stripe")))?; let period_end = chrono::DateTime::::from_timestamp(sub.current_period_end, 0) .ok_or_else(|| AppError::Internal(anyhow::anyhow!("Invalid period_end from Stripe")))?; db::synckit_billing::activate_billing( &state.db, app_id, &req.enforcement_mode, req.storage_gb_cap.map(|v| v as i32), req.key_cap.map(|v| v as i32), req.gb_per_key.map(|v| v as i32), &sub.subscription_id, period_start, period_end, ) .await?; Ok(Json(BillingUpdatedResponse { monthly_price_cents: price_cents, billing_status: "active".to_string(), stripe_subscription_id: Some(sub.subscription_id), })) } /// Change billing knobs on an active subscription (re-prices via proration). /// /// `PATCH /api/sync/apps/{id}/billing` #[tracing::instrument(skip_all, name = "synckit::billing::patch")] pub(super) async fn patch( State(state): State, AuthUser(user): AuthUser, Path(app_id): Path, Json(req): Json, ) -> Result { user.check_not_sandbox()?; user.check_not_suspended()?; validate_knobs(&req.enforcement_mode, req.storage_gb_cap, req.key_cap, req.gb_per_key)?; let app = db::synckit_billing::get_app_with_billing(&state.db, app_id) .await? .ok_or(AppError::NotFound)?; if app.creator_id != user.id { return Err(AppError::Forbidden); } if app.billing_status != "active" { return Err(AppError::Conflict(format!( "App is {}; PATCH is only valid when active", app.billing_status ))); } let sub_id = app.stripe_subscription_id.as_deref().ok_or_else(|| { AppError::Internal(anyhow::anyhow!( "Active app has no stripe_subscription_id (data inconsistency)" )) })?; let new_price = monthly_price_cents( &req.enforcement_mode, req.storage_gb_cap, req.key_cap, req.gb_per_key, ); let stripe = state .stripe .as_ref() .ok_or_else(|| AppError::ServiceUnavailable("Stripe is not configured".to_string()))?; stripe .update_synckit_subscription_price(sub_id, new_price, &app.name) .await?; db::synckit_billing::update_knobs( &state.db, app_id, &req.enforcement_mode, req.storage_gb_cap.map(|v| v as i32), req.key_cap.map(|v| v as i32), req.gb_per_key.map(|v| v as i32), ) .await?; Ok(Json(BillingUpdatedResponse { monthly_price_cents: new_price, billing_status: "active".to_string(), stripe_subscription_id: Some(sub_id.to_string()), })) } /// Cancel billing for this app. /// /// `DELETE /api/sync/apps/{id}/billing` #[tracing::instrument(skip_all, name = "synckit::billing::cancel")] pub(super) async fn cancel( State(state): State, AuthUser(user): AuthUser, Path(app_id): Path, ) -> Result { user.check_not_sandbox()?; let app = db::synckit_billing::get_app_with_billing(&state.db, app_id) .await? .ok_or(AppError::NotFound)?; if app.creator_id != user.id { return Err(AppError::Forbidden); } if app.billing_status == "canceled" { return Ok(axum::http::StatusCode::NO_CONTENT); } if let Some(sub_id) = app.stripe_subscription_id.as_deref() { let stripe = state .stripe .as_ref() .ok_or_else(|| AppError::ServiceUnavailable("Stripe is not configured".to_string()))?; stripe.cancel_synckit_subscription(sub_id).await?; } db::synckit_billing::apply_billing_update(&state.db, app_id, Some("canceled"), None).await?; Ok(axum::http::StatusCode::NO_CONTENT) } /// Current billing status, knobs, usage counters, and computed price. /// /// `GET /api/sync/apps/{id}/billing` #[tracing::instrument(skip_all, name = "synckit::billing::get")] pub(super) async fn get( State(state): State, AuthUser(user): AuthUser, Path(app_id): Path, ) -> Result { let app = db::synckit_billing::get_app_with_billing(&state.db, app_id) .await? .ok_or(AppError::NotFound)?; if app.creator_id != user.id { return Err(AppError::Forbidden); } let knobs_set = match app.enforcement_mode.as_str() { "bulk" => app.storage_gb_cap.is_some(), "per_key" => app.key_cap.is_some() && app.gb_per_key.is_some(), _ => false, }; let monthly_price_cents = knobs_set.then(|| monthly_price_cents( &app.enforcement_mode, app.storage_gb_cap.map(|v| v as u32), app.key_cap.map(|v| v as u32), app.gb_per_key.map(|v| v as u32), )); Ok(Json(BillingStatusResponse { app_id, billing_status: app.billing_status, is_internal: app.is_internal, enforcement_mode: app.enforcement_mode, storage_gb_cap: app.storage_gb_cap.map(|v| v as u32), key_cap: app.key_cap.map(|v| v as u32), gb_per_key: app.gb_per_key.map(|v| v as u32), bytes_stored: app.bytes_stored.unwrap_or(0), bytes_egress_period: app.bytes_egress_period.unwrap_or(0), keys_claimed: app.keys_claimed.unwrap_or(0) as u32, last_warning_pct: app.last_warning_pct.unwrap_or(0) as u8, current_period_start: app.current_period_start, current_period_end: app.current_period_end, monthly_price_cents, })) } /// Return a fresh Stripe billing portal URL for the app's developer. Portals /// are single-use, so the dashboard hits this on demand rather than caching /// the URL. /// /// `GET /api/sync/apps/{id}/billing/portal` #[tracing::instrument(skip_all, name = "synckit::billing::portal")] pub(super) async fn portal( State(state): State, AuthUser(user): AuthUser, Path(app_id): Path, ) -> Result { user.check_not_sandbox()?; let app = db::synckit_billing::get_app_with_billing(&state.db, app_id) .await? .ok_or(AppError::NotFound)?; if app.creator_id != user.id { return Err(AppError::Forbidden); } let customer_id = app.stripe_customer_id.as_deref().ok_or_else(|| { AppError::BadRequest( "No Stripe customer for this app yet — POST /billing/setup first".to_string(), ) })?; let stripe = state .stripe .as_ref() .ok_or_else(|| AppError::ServiceUnavailable("Stripe is not configured".to_string()))?; let return_url = synckit_return_url(&state, &app); let portal_url = stripe .create_synckit_billing_portal(customer_id, &return_url) .await?; Ok(Json(serde_json::json!({ "billing_portal_url": portal_url }))) } // ── Helpers ── /// Build the Stripe `return_url` for billing portal sessions. Sends the /// developer back to the SyncKit tab on the project dashboard when the app is /// linked to a project, or the user-dashboard SyncKit tab otherwise. fn synckit_return_url( state: &AppState, app: &crate::db::DbSyncAppBilling, ) -> String { match app.project_slug.as_deref() { Some(slug) => format!( "{}/dashboard/project/{}#tab-synckit", state.config.host_url, slug ), None => format!("{}/dashboard#tab-synckit", state.config.host_url), } } fn validate_knobs( enforcement_mode: &str, storage_gb_cap: Option, key_cap: Option, gb_per_key: Option, ) -> Result<()> { match enforcement_mode { "bulk" => { match storage_gb_cap { Some(v) if v > 0 => {} _ => return Err(AppError::BadRequest( "storage_gb_cap (> 0) is required when enforcement_mode = bulk".to_string(), )), } if key_cap.is_some() || gb_per_key.is_some() { return Err(AppError::BadRequest( "key_cap and gb_per_key must be omitted when enforcement_mode = bulk".to_string(), )); } } "per_key" => { match key_cap { Some(v) if v > 0 => {} _ => return Err(AppError::BadRequest( "key_cap (> 0) is required when enforcement_mode = per_key".to_string(), )), } match gb_per_key { Some(v) if v > 0 => {} _ => return Err(AppError::BadRequest( "gb_per_key (> 0) is required when enforcement_mode = per_key".to_string(), )), } if storage_gb_cap.is_some() { return Err(AppError::BadRequest( "storage_gb_cap must be omitted when enforcement_mode = per_key".to_string(), )); } } other => { return Err(AppError::BadRequest(format!( "enforcement_mode must be 'bulk' or 'per_key', got {other:?}" ))); } } Ok(()) }