//! 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::{ Json, extract::{Path, State}, response::IntoResponse, }; use sqlx::PgPool; use crate::{ auth::AuthUser, config::Config, db::{self, SyncAppId}, error::{AppError, Result}, synckit_billing::monthly_price_cents, }; 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(db): State, State(payments): State, State(config): 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(&db, app_id) .await? .ok_or(AppError::NotFound)?; if app.creator_id != user.id { return Err(AppError::Forbidden); } if app.billing_status != crate::db::SyncBillingStatus::Draft { return Err(AppError::Conflict(format!( "App is already {}; billing setup is only valid in draft status", app.billing_status ))); } let stripe = payments .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(&db, user.id) .await? .ok_or(AppError::Unauthorized)?; let id = stripe .create_synckit_customer(user.id, app_id, developer.email.as_str(), &app.name) .await?; db::synckit_billing::set_stripe_customer(&db, app_id, &id).await?; id } }; let return_url = synckit_return_url(&config, &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(db): State, State(payments): State, AuthUser(user): AuthUser, Path(app_id): Path, Json(req): Json, ) -> Result { user.check_not_sandbox()?; user.check_not_suspended()?; let mode = 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(&db, app_id) .await? .ok_or(AppError::NotFound)?; if app.creator_id != user.id { return Err(AppError::Forbidden); } if app.billing_status != crate::db::SyncBillingStatus::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(mode, req.storage_gb_cap, req.key_cap, req.gb_per_key); let stripe = payments .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( &db, app_id, 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(db): State, State(payments): State, AuthUser(user): AuthUser, Path(app_id): Path, Json(req): Json, ) -> Result { user.check_not_sandbox()?; user.check_not_suspended()?; let mode = 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(&db, app_id) .await? .ok_or(AppError::NotFound)?; if app.creator_id != user.id { return Err(AppError::Forbidden); } if app.billing_status != crate::db::SyncBillingStatus::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(mode, req.storage_gb_cap, req.key_cap, req.gb_per_key); let stripe = payments .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( &db, app_id, 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(db): State, State(payments): State, AuthUser(user): AuthUser, Path(app_id): Path, ) -> Result { user.check_not_sandbox()?; let app = db::synckit_billing::get_app_with_billing(&db, app_id) .await? .ok_or(AppError::NotFound)?; if app.creator_id != user.id { return Err(AppError::Forbidden); } if app.billing_status == crate::db::SyncBillingStatus::Canceled { return Ok(axum::http::StatusCode::NO_CONTENT); } if let Some(sub_id) = app.stripe_subscription_id.as_deref() { let stripe = payments .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(&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(db): State, AuthUser(user): AuthUser, Path(app_id): Path, ) -> Result { let app = db::synckit_billing::get_app_with_billing(&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 { crate::db::SyncEnforcementMode::Bulk => app.storage_gb_cap.is_some(), crate::db::SyncEnforcementMode::PerKey => app.key_cap.is_some() && app.gb_per_key.is_some(), }; 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.to_string(), is_internal: app.is_internal, enforcement_mode: app.enforcement_mode.to_string(), 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(db): State, State(payments): State, State(config): State, AuthUser(user): AuthUser, Path(app_id): Path, ) -> Result { user.check_not_sandbox()?; let app = db::synckit_billing::get_app_with_billing(&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 = payments .stripe .as_ref() .ok_or_else(|| AppError::ServiceUnavailable("Stripe is not configured".to_string()))?; let return_url = synckit_return_url(&config, &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 Cloud Sync settings section otherwise. /// /// Both halves are queries rather than hashes (`6b24f2df`): each strip is /// described, so the destination is chosen server-side and arrives already /// rendered. /// /// The unlinked half is `§ion=synckit` since `47e67540`. It was /// `?tab=settings`, which opened the settings tab on Profile and left the /// developer to find the app they had just been billed for; before that it was /// `/dashboard#tab-synckit`, an id that page has never had, which did nothing at /// all. The section is where an orphan app can be linked to a project after the /// fact, which is the case this return exists to serve. fn synckit_return_url(config: &Config, app: &crate::db::DbSyncAppBilling) -> String { match app.project_slug.as_deref() { Some(slug) => format!("{}/dashboard/project/{}?tab=synckit", config.host_url, slug), None => format!("{}/dashboard?tab=settings§ion=synckit", config.host_url), } } /// Validate the knob set for a billing request and parse the mode into the sealed /// [`SyncEnforcementMode`]. This is the single parse point for untrusted /// `enforcement_mode` input: downstream pricing and persistence take the enum, so /// an invalid mode is rejected here rather than silently mispriced (Pay-S2). fn validate_knobs( enforcement_mode: &str, storage_gb_cap: Option, key_cap: Option, gb_per_key: Option, ) -> Result { use crate::db::SyncEnforcementMode; // Upper bound on the priced storage so a developer can't provision an // absurd Stripe subscription. Bulk: `storage_gb_cap`. Per-key: the // `key_cap × gb_per_key` product (the value the price is computed from). let max = crate::synckit_billing::MAX_STORAGE_GB; match enforcement_mode { "bulk" => { match storage_gb_cap { Some(v) if v > 0 && i64::from(v) <= max => {} _ => { return Err(AppError::BadRequest(format!( "storage_gb_cap must be between 1 and {max} GB for enforcement_mode = bulk" ))); } } 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(), )); } Ok(SyncEnforcementMode::Bulk) } "per_key" => { let k = match key_cap { Some(v) if v > 0 => v, _ => { return Err(AppError::BadRequest( "key_cap (> 0) is required when enforcement_mode = per_key".to_string(), )); } }; let g = match gb_per_key { Some(v) if v > 0 => v, _ => { return Err(AppError::BadRequest( "gb_per_key (> 0) is required when enforcement_mode = per_key".to_string(), )); } }; // u64 to avoid overflow before the bound check. if u64::from(k) * u64::from(g) > max as u64 { return Err(AppError::BadRequest(format!( "key_cap × gb_per_key must not exceed {max} GB total" ))); } if storage_gb_cap.is_some() { return Err(AppError::BadRequest( "storage_gb_cap must be omitted when enforcement_mode = per_key".to_string(), )); } Ok(SyncEnforcementMode::PerKey) } other => Err(AppError::BadRequest(format!( "enforcement_mode must be 'bulk' or 'per_key', got {other:?}" ))), } } #[cfg(test)] mod tests { use super::validate_knobs; use crate::synckit_billing::MAX_STORAGE_GB; #[test] fn bulk_cap_is_bounded() { assert!(validate_knobs("bulk", Some(100), None, None).is_ok()); assert!(validate_knobs("bulk", Some(MAX_STORAGE_GB as u32), None, None).is_ok()); assert!(validate_knobs("bulk", Some(0), None, None).is_err()); // One past the ceiling, and the absurd value the report flagged. assert!(validate_knobs("bulk", Some(MAX_STORAGE_GB as u32 + 1), None, None).is_err()); assert!(validate_knobs("bulk", Some(u32::MAX), None, None).is_err()); } #[test] fn per_key_product_is_bounded() { assert!(validate_knobs("per_key", None, Some(100), Some(1)).is_ok()); // key_cap × gb_per_key exactly at the ceiling. assert!(validate_knobs("per_key", None, Some(MAX_STORAGE_GB as u32), Some(1)).is_ok()); // Product over the ceiling, including the u32-overflow case. assert!(validate_knobs("per_key", None, Some(MAX_STORAGE_GB as u32), Some(2)).is_err()); assert!(validate_knobs("per_key", None, Some(u32::MAX), Some(u32::MAX)).is_err()); assert!(validate_knobs("per_key", None, Some(0), Some(1)).is_err()); } }