max / makenotwork
23 files changed,
+918 insertions,
-543 deletions
| @@ -1,4 +1,4 @@ | |||
| 1 | - | //! Embeddable widget routes — public, no auth, iframe-friendly. | |
| 1 | + | //! Embeddable widget routes; public, no auth, iframe-friendly. | |
| 2 | 2 | //! | |
| 3 | 3 | //! All routes under `/embed/` serve self-contained HTML pages designed to be | |
| 4 | 4 | //! loaded inside iframes on external sites. They set permissive frame headers |
| @@ -23,7 +23,7 @@ use super::{ | |||
| 23 | 23 | build_breadcrumbs, escape_html, fetch_linked_releases, format_size, parent_of, resolve_repo, | |
| 24 | 24 | }; | |
| 25 | 25 | ||
| 26 | - | /// `GET /git/{owner}/{repo}` — repo overview (tree at HEAD + README). | |
| 26 | + | /// `GET /git/{owner}/{repo}`: repo overview (tree at HEAD + README). | |
| 27 | 27 | #[tracing::instrument(skip_all, name = "git::repo_overview")] | |
| 28 | 28 | pub(super) async fn repo_overview( | |
| 29 | 29 | State(state): State<AppState>, | |
| @@ -71,7 +71,7 @@ pub(super) async fn repo_overview( | |||
| 71 | 71 | }) | |
| 72 | 72 | } | |
| 73 | 73 | ||
| 74 | - | /// `GET /git/{owner}/{repo}/tree/{ref}` — tree root at a specific ref. | |
| 74 | + | /// `GET /git/{owner}/{repo}/tree/{ref}`: tree root at a specific ref. | |
| 75 | 75 | #[tracing::instrument(skip_all, name = "git::tree_root")] | |
| 76 | 76 | pub(super) async fn tree_root( | |
| 77 | 77 | State(state): State<AppState>, | |
| @@ -117,7 +117,7 @@ pub(super) async fn tree_root( | |||
| 117 | 117 | }) | |
| 118 | 118 | } | |
| 119 | 119 | ||
| 120 | - | /// `GET /git/{owner}/{repo}/tree/{ref}/{*path}` — file or subdirectory. | |
| 120 | + | /// `GET /git/{owner}/{repo}/tree/{ref}/{*path}`: file or subdirectory. | |
| 121 | 121 | #[tracing::instrument(skip_all, name = "git::tree_or_file")] | |
| 122 | 122 | pub(super) async fn tree_or_file( | |
| 123 | 123 | State(state): State<AppState>, | |
| @@ -215,7 +215,7 @@ pub(super) struct CommitQuery { | |||
| 215 | 215 | page: Option<usize>, | |
| 216 | 216 | } | |
| 217 | 217 | ||
| 218 | - | /// `GET /git/{owner}/{repo}/commits/{ref}` — commit log with pagination. | |
| 218 | + | /// `GET /git/{owner}/{repo}/commits/{ref}`: commit log with pagination. | |
| 219 | 219 | #[tracing::instrument(skip_all, name = "git::commit_log")] | |
| 220 | 220 | pub(super) async fn commit_log( | |
| 221 | 221 | State(state): State<AppState>, | |
| @@ -257,7 +257,7 @@ pub(super) async fn commit_log( | |||
| 257 | 257 | }) | |
| 258 | 258 | } | |
| 259 | 259 | ||
| 260 | - | /// `GET /git/{owner}/{repo}/commit/{oid}` — commit detail with inline diffs. | |
| 260 | + | /// `GET /git/{owner}/{repo}/commit/{oid}`: commit detail with inline diffs. | |
| 261 | 261 | #[tracing::instrument(skip_all, name = "git::commit_detail_page")] | |
| 262 | 262 | pub(super) async fn commit_detail_page( | |
| 263 | 263 | State(state): State<AppState>, | |
| @@ -306,7 +306,7 @@ pub(super) async fn commit_detail_page( | |||
| 306 | 306 | }) | |
| 307 | 307 | } | |
| 308 | 308 | ||
| 309 | - | /// `GET /git/{owner}/{repo}/blame/{ref}/{*path}` — blame view. | |
| 309 | + | /// `GET /git/{owner}/{repo}/blame/{ref}/{*path}`: blame view. | |
| 310 | 310 | #[tracing::instrument(skip_all, name = "git::blame_view")] | |
| 311 | 311 | pub(super) async fn blame_view( | |
| 312 | 312 | State(state): State<AppState>, | |
| @@ -343,7 +343,7 @@ pub(super) async fn blame_view( | |||
| 343 | 343 | }) | |
| 344 | 344 | } | |
| 345 | 345 | ||
| 346 | - | /// `GET /git/{owner}` — user's repository listing. | |
| 346 | + | /// `GET /git/{owner}`: user's repository listing. | |
| 347 | 347 | #[tracing::instrument(skip_all, name = "git::user_repos")] | |
| 348 | 348 | pub(super) async fn user_repos( | |
| 349 | 349 | State(state): State<AppState>, | |
| @@ -381,7 +381,7 @@ pub(super) struct ExploreQuery { | |||
| 381 | 381 | page: Option<usize>, | |
| 382 | 382 | } | |
| 383 | 383 | ||
| 384 | - | /// `GET /git` — public explore page listing all public repos. | |
| 384 | + | /// `GET /git`: public explore page listing all public repos. | |
| 385 | 385 | #[tracing::instrument(skip_all, name = "git::git_explore")] | |
| 386 | 386 | pub(super) async fn git_landing( | |
| 387 | 387 | State(state): State<AppState>, | |
| @@ -410,7 +410,7 @@ pub(super) async fn git_landing( | |||
| 410 | 410 | }) | |
| 411 | 411 | } | |
| 412 | 412 | ||
| 413 | - | /// `GET /git/{owner}/{repo}/log/{ref}/{*path}` — per-file commit history. | |
| 413 | + | /// `GET /git/{owner}/{repo}/log/{ref}/{*path}`: per-file commit history. | |
| 414 | 414 | #[tracing::instrument(skip_all, name = "git::file_log")] | |
| 415 | 415 | pub(super) async fn file_log( | |
| 416 | 416 | State(state): State<AppState>, |
| @@ -1,4 +1,4 @@ | |||
| 1 | - | //! Git source browser routes — public browsing of bare repos on disk. | |
| 1 | + | //! Git source browser routes; public browsing of bare repos on disk. | |
| 2 | 2 | ||
| 3 | 3 | mod browsing; | |
| 4 | 4 | mod raw; |
| @@ -18,7 +18,7 @@ use crate::{ | |||
| 18 | 18 | ||
| 19 | 19 | use super::{repos_root, resolve_repo, resolve_repo_name}; | |
| 20 | 20 | ||
| 21 | - | /// `GET /git/{owner}/{repo}/raw/{ref}/{*path}` — raw file download. | |
| 21 | + | /// `GET /git/{owner}/{repo}/raw/{ref}/{*path}`: raw file download. | |
| 22 | 22 | #[tracing::instrument(skip_all, name = "git::raw_file")] | |
| 23 | 23 | pub(super) async fn raw_file( | |
| 24 | 24 | State(state): State<AppState>, |
| @@ -21,7 +21,7 @@ use crate::{ | |||
| 21 | 21 | ||
| 22 | 22 | use super::default_ref; | |
| 23 | 23 | ||
| 24 | - | /// `GET /git/{owner}/{repo}/settings` — settings form (owner only). | |
| 24 | + | /// `GET /git/{owner}/{repo}/settings`: settings form (owner only). | |
| 25 | 25 | #[tracing::instrument(skip_all, name = "git_issues::repo_settings_form")] | |
| 26 | 26 | pub(super) async fn repo_settings_form( | |
| 27 | 27 | State(state): State<AppState>, | |
| @@ -61,7 +61,7 @@ pub(super) struct RepoSettingsForm { | |||
| 61 | 61 | project_id: Option<String>, | |
| 62 | 62 | } | |
| 63 | 63 | ||
| 64 | - | /// `POST /git/{owner}/{repo}/settings` — save settings (owner only). | |
| 64 | + | /// `POST /git/{owner}/{repo}/settings`: save settings (owner only). | |
| 65 | 65 | #[tracing::instrument(skip_all, name = "git_issues::repo_settings_save")] | |
| 66 | 66 | pub(super) async fn repo_settings_save( | |
| 67 | 67 | State(state): State<AppState>, | |
| @@ -110,7 +110,7 @@ pub(super) async fn repo_settings_save( | |||
| 110 | 110 | Ok(Redirect::to(&format!("/git/{}/{}/settings", owner, repo_name))) | |
| 111 | 111 | } | |
| 112 | 112 | ||
| 113 | - | /// `POST /git/{owner}/{repo}/settings/delete` — delete repo (owner only). | |
| 113 | + | /// `POST /git/{owner}/{repo}/settings/delete`: delete repo (owner only). | |
| 114 | 114 | #[tracing::instrument(skip_all, name = "git_issues::repo_settings_delete")] | |
| 115 | 115 | pub(super) async fn repo_settings_delete( | |
| 116 | 116 | State(state): State<AppState>, |
| @@ -381,7 +381,7 @@ pub(in crate::routes::stripe) async fn create_checkout( | |||
| 381 | 381 | Ok(Redirect::to(&checkout_url).into_response()) | |
| 382 | 382 | } | |
| 383 | 383 | ||
| 384 | - | /// POST /stripe/checkout/{item_id}/cancel-pending — delete the buyer's | |
| 384 | + | /// POST /stripe/checkout/{item_id}/cancel-pending: delete the buyer's | |
| 385 | 385 | /// in-progress checkout for this item so they can start a fresh one. | |
| 386 | 386 | /// | |
| 387 | 387 | /// Safe to call when no pending row exists (no-op). Releases any reserved |
| @@ -24,7 +24,7 @@ pub(in crate::routes::stripe) struct ProjectCheckoutForm { | |||
| 24 | 24 | amount_cents: Option<i32>, | |
| 25 | 25 | } | |
| 26 | 26 | ||
| 27 | - | /// POST /stripe/checkout/project/{project_id} -- Purchase project-level access. | |
| 27 | + | /// POST /stripe/checkout/project/{project_id}: Purchase project-level access. | |
| 28 | 28 | #[tracing::instrument(skip_all, name = "stripe::project_checkout")] | |
| 29 | 29 | pub(in crate::routes::stripe) async fn create_project_checkout( | |
| 30 | 30 | State(state): State<AppState>, |
| @@ -14,7 +14,7 @@ use crate::{ | |||
| 14 | 14 | AppState, | |
| 15 | 15 | }; | |
| 16 | 16 | ||
| 17 | - | /// POST /stripe/fan-plus -- Create a Fan+ subscription checkout and redirect | |
| 17 | + | /// POST /stripe/fan-plus: Create a Fan+ subscription checkout and redirect | |
| 18 | 18 | #[tracing::instrument(skip_all, name = "stripe::fan_plus_checkout")] | |
| 19 | 19 | pub(in crate::routes::stripe) async fn create_fan_plus_checkout( | |
| 20 | 20 | State(state): State<AppState>, | |
| @@ -51,7 +51,7 @@ pub(in crate::routes::stripe) async fn create_fan_plus_checkout( | |||
| 51 | 51 | Ok(Redirect::to(&checkout_url).into_response()) | |
| 52 | 52 | } | |
| 53 | 53 | ||
| 54 | - | /// POST /stripe/fan-plus/cancel -- Schedule Fan+ to cancel at period end. | |
| 54 | + | /// POST /stripe/fan-plus/cancel: Schedule Fan+ to cancel at period end. | |
| 55 | 55 | /// | |
| 56 | 56 | /// Self-service: leaves the subscription active through the current paid | |
| 57 | 57 | /// period (no proration). The user can resume before period end to undo. | |
| @@ -77,7 +77,7 @@ pub(in crate::routes::stripe) async fn cancel_fan_plus( | |||
| 77 | 77 | Ok(Redirect::to("/dashboard?tab=account&toast=Fan%2B+cancellation+scheduled")) | |
| 78 | 78 | } | |
| 79 | 79 | ||
| 80 | - | /// POST /stripe/fan-plus/resume -- Undo a scheduled cancellation. | |
| 80 | + | /// POST /stripe/fan-plus/resume: Undo a scheduled cancellation. | |
| 81 | 81 | #[tracing::instrument(skip_all, name = "stripe::fan_plus_resume")] | |
| 82 | 82 | pub(in crate::routes::stripe) async fn resume_fan_plus( | |
| 83 | 83 | State(state): State<AppState>, | |
| @@ -98,7 +98,7 @@ pub(in crate::routes::stripe) async fn resume_fan_plus( | |||
| 98 | 98 | Ok(Redirect::to("/dashboard?tab=account&toast=Fan%2B+resumed")) | |
| 99 | 99 | } | |
| 100 | 100 | ||
| 101 | - | /// POST /stripe/billing-portal -- Open the Stripe customer portal. | |
| 101 | + | /// POST /stripe/billing-portal: Open the Stripe customer portal. | |
| 102 | 102 | /// | |
| 103 | 103 | /// Stripe-hosted: handles payment method updates, invoice history, and | |
| 104 | 104 | /// (if configured in the dashboard) subscription cancellation. Routes from | |
| @@ -151,7 +151,7 @@ impl BillingInterval { | |||
| 151 | 151 | } | |
| 152 | 152 | } | |
| 153 | 153 | ||
| 154 | - | /// POST /stripe/creator-tier -- Create a creator tier subscription checkout and redirect | |
| 154 | + | /// POST /stripe/creator-tier: Create a creator tier subscription checkout and redirect | |
| 155 | 155 | #[tracing::instrument(skip_all, name = "stripe::creator_tier_checkout")] | |
| 156 | 156 | pub(in crate::routes::stripe) async fn create_creator_tier_checkout( | |
| 157 | 157 | State(state): State<AppState>, |
| @@ -17,7 +17,7 @@ use crate::{ | |||
| 17 | 17 | AppState, | |
| 18 | 18 | }; | |
| 19 | 19 | ||
| 20 | - | /// GET /stripe/connect — Show disclaimer page before Stripe onboarding. | |
| 20 | + | /// GET /stripe/connect: Show disclaimer page before Stripe onboarding. | |
| 21 | 21 | #[tracing::instrument(skip_all, name = "stripe::connect_disclaimer")] | |
| 22 | 22 | pub(super) async fn stripe_connect_disclaimer( | |
| 23 | 23 | session: Session, | |
| @@ -27,7 +27,7 @@ pub(super) async fn stripe_connect_disclaimer( | |||
| 27 | 27 | Ok(StripeConnectDisclaimerTemplate { csrf_token }.into_response()) | |
| 28 | 28 | } | |
| 29 | 29 | ||
| 30 | - | /// POST /stripe/connect/proceed — Create connected account (if needed) and | |
| 30 | + | /// POST /stripe/connect/proceed: Create connected account (if needed) and | |
| 31 | 31 | /// return the Stripe-hosted onboarding URL. | |
| 32 | 32 | /// | |
| 33 | 33 | /// Returns JSON with the URL instead of a redirect because `fetch()` cannot | |
| @@ -96,17 +96,17 @@ struct ConnectProceedResponse { | |||
| 96 | 96 | url: String, | |
| 97 | 97 | } | |
| 98 | 98 | ||
| 99 | - | /// GET /stripe/connect/return — Creator finished (or left) Stripe onboarding. | |
| 99 | + | /// GET /stripe/connect/return: Creator finished (or left) Stripe onboarding. | |
| 100 | 100 | /// | |
| 101 | 101 | /// The actual onboarding status is determined by the `account.updated` webhook, | |
| 102 | 102 | /// not by the user landing here. The dashboard payments tab shows the real | |
| 103 | 103 | /// status (complete, pending review, action required) once it loads. | |
| 104 | 104 | /// | |
| 105 | - | /// No `AuthUser` guard and no server-side redirect — the browser arrives here | |
| 105 | + | /// No `AuthUser` guard and no server-side redirect; the browser arrives here | |
| 106 | 106 | /// via cross-site navigation from Stripe, and `SameSite=Strict` cookies are not | |
| 107 | 107 | /// sent on cross-site navigations (including server redirects that follow one). | |
| 108 | 108 | /// Instead, we return a minimal HTML page that does a client-side | |
| 109 | - | /// `window.location` — this initiates a fresh same-site navigation where the | |
| 109 | + | /// `window.location`; this initiates a fresh same-site navigation where the | |
| 110 | 110 | /// browser will include the session cookie. | |
| 111 | 111 | #[tracing::instrument(skip_all, name = "stripe::connect_return")] | |
| 112 | 112 | pub(super) async fn stripe_connect_return() -> axum::response::Html<&'static str> { | |
| @@ -121,11 +121,11 @@ pub(super) async fn stripe_connect_return() -> axum::response::Html<&'static str | |||
| 121 | 121 | )) | |
| 122 | 122 | } | |
| 123 | 123 | ||
| 124 | - | /// GET /stripe/connect/refresh — Account Link expired or was already used. | |
| 124 | + | /// GET /stripe/connect/refresh: Account Link expired or was already used. | |
| 125 | 125 | /// | |
| 126 | 126 | /// Stripe redirects here cross-site, and `SameSite=Strict` cookies won't be | |
| 127 | 127 | /// present on a server-side redirect. Use the same client-side redirect | |
| 128 | - | /// pattern as `connect_return` — return minimal HTML that does | |
| 128 | + | /// pattern as `connect_return`; return minimal HTML that does | |
| 129 | 129 | /// `window.location.replace()` to initiate a fresh same-site navigation | |
| 130 | 130 | /// where the browser will include the session cookie. | |
| 131 | 131 | #[tracing::instrument(skip_all, name = "stripe::connect_refresh")] |
| @@ -7,7 +7,7 @@ use crate::{ | |||
| 7 | 7 | AppState, | |
| 8 | 8 | }; | |
| 9 | 9 | ||
| 10 | - | /// Handle invoice.payment_succeeded — update period, send renewal email (not first invoice) | |
| 10 | + | /// Handle invoice.payment_succeeded; update period, send renewal email (not first invoice) | |
| 11 | 11 | pub(super) async fn handle_invoice_payment_succeeded( | |
| 12 | 12 | state: &AppState, | |
| 13 | 13 | invoice: &crate::payments::InvoiceView, | |
| @@ -22,6 +22,24 @@ pub(super) async fn handle_invoice_payment_succeeded( | |||
| 22 | 22 | ||
| 23 | 23 | let is_renewal = invoice.is_renewal(); | |
| 24 | 24 | ||
| 25 | + | // SyncKit v2 developer subscription? Identified by the local sync_apps row. | |
| 26 | + | if let Some(app_id) = db::synckit_billing::get_app_by_stripe_subscription(&state.db, &stripe_sub_id).await.context("fetch synckit app by stripe sub id")? { | |
| 27 | + | let period_start = stripe_timestamp(invoice.period_start); | |
| 28 | + | let period_end = stripe_timestamp(invoice.period_end); | |
| 29 | + | let mut tx = state.db.begin().await.context("begin synckit invoice.paid transaction")?; | |
| 30 | + | db::synckit_billing::set_billing_status(&mut *tx, app_id, "active").await.context("synckit billing -> active")?; | |
| 31 | + | db::synckit_billing::set_period(&mut *tx, app_id, period_start, period_end).await.context("synckit set_period")?; | |
| 32 | + | db::synckit_billing::reset_period_usage(&mut *tx, app_id).await.context("synckit reset_period_usage")?; | |
| 33 | + | tx.commit().await.context("commit synckit invoice.paid")?; | |
| 34 | + | if let Err(e) = db::subscriptions::log_subscription_event( | |
| 35 | + | &state.db, None, event_id, "invoice.payment_succeeded.synckit", | |
| 36 | + | &serde_json::json!({"stripe_sub_id": stripe_sub_id, "synckit_app_id": app_id.to_string()}), | |
| 37 | + | ).await { | |
| 38 | + | tracing::warn!(event_id = %event_id, error = ?e, "failed to log subscription event"); | |
| 39 | + | } | |
| 40 | + | return Ok(()); | |
| 41 | + | } | |
| 42 | + | ||
| 25 | 43 | // Check if this is a Fan+ subscription | |
| 26 | 44 | if let Some(fan_sub) = db::fan_plus::get_fan_plus_by_stripe_id(&state.db, &stripe_sub_id).await.context("fetch fan+ by stripe id")? { | |
| 27 | 45 | // Update period | |
| @@ -110,21 +128,6 @@ pub(super) async fn handle_invoice_payment_succeeded( | |||
| 110 | 128 | return Ok(()); | |
| 111 | 129 | } | |
| 112 | 130 | ||
| 113 | - | // Check if this is an app sync subscription | |
| 114 | - | if let Some(_app_sub) = db::app_sync::get_app_sync_sub_by_stripe_id(&state.db, &stripe_sub_id).await.context("fetch app sync sub by stripe id")? { | |
| 115 | - | let period_start = stripe_timestamp(invoice.period_start); | |
| 116 | - | let period_end = stripe_timestamp(invoice.period_end); | |
| 117 | - | db::app_sync::update_app_sync_sub_period(&state.db, &stripe_sub_id, period_start, period_end).await.context("update app sync sub period")?; | |
| 118 | - | ||
| 119 | - | if let Err(e) = db::subscriptions::log_subscription_event( | |
| 120 | - | &state.db, None, event_id, "invoice.payment_succeeded.app_sync", | |
| 121 | - | &serde_json::json!({"stripe_sub_id": stripe_sub_id, "is_renewal": is_renewal}), | |
| 122 | - | ).await { | |
| 123 | - | tracing::warn!(event_id = %event_id, error = ?e, "failed to log subscription event"); | |
| 124 | - | } | |
| 125 | - | return Ok(()); | |
| 126 | - | } | |
| 127 | - | ||
| 128 | 131 | // Update period for creator subscriptions | |
| 129 | 132 | let period_start = stripe_timestamp(invoice.period_start); | |
| 130 | 133 | let period_end = stripe_timestamp(invoice.period_end); | |
| @@ -166,7 +169,7 @@ pub(super) async fn handle_invoice_payment_succeeded( | |||
| 166 | 169 | Ok(()) | |
| 167 | 170 | } | |
| 168 | 171 | ||
| 169 | - | /// Handle invoice.payment_failed — set status to past_due | |
| 172 | + | /// Handle invoice.payment_failed; set status to past_due | |
| 170 | 173 | pub(super) async fn handle_invoice_payment_failed( | |
| 171 | 174 | state: &AppState, | |
| 172 | 175 | invoice: &crate::payments::InvoiceView, | |
| @@ -179,6 +182,22 @@ pub(super) async fn handle_invoice_payment_failed( | |||
| 179 | 182 | ||
| 180 | 183 | tracing::info!(stripe_sub_id = %stripe_sub_id, "processing invoice payment failed"); | |
| 181 | 184 | ||
| 185 | + | // SyncKit v2 developer subscription? Mark suspended_unpaid. | |
| 186 | + | if let Some(app_id) = db::synckit_billing::get_app_by_stripe_subscription(&state.db, &stripe_sub_id).await.context("fetch synckit app by stripe sub id")? { | |
| 187 | + | db::synckit_billing::set_billing_status(&state.db, app_id, "suspended_unpaid").await.context("synckit billing -> suspended_unpaid")?; | |
| 188 | + | if let Err(e) = db::subscriptions::log_subscription_event( | |
| 189 | + | &state.db, None, event_id, "invoice.payment_failed.synckit", | |
| 190 | + | &serde_json::json!({"stripe_sub_id": stripe_sub_id, "synckit_app_id": app_id.to_string()}), | |
| 191 | + | ).await { | |
| 192 | + | tracing::warn!(event_id = %event_id, error = ?e, "failed to log subscription event"); | |
| 193 | + | } | |
| 194 | + | if let Some(ref wam) = state.wam { | |
| 195 | + | let title = format!("SyncKit app payment failed: {app_id}"); | |
| 196 | + | wam.create_ticket(&title, None, "medium", "synckit-payment-failed", Some(&app_id.to_string())).await; | |
| 197 | + | } | |
| 198 | + | return Ok(()); | |
| 199 | + | } | |
| 200 | + | ||
| 182 | 201 | // Check if this is a Fan+ subscription | |
| 183 | 202 | if let Some(_fan_sub) = db::fan_plus::get_fan_plus_by_stripe_id(&state.db, &stripe_sub_id).await.context("fetch fan+ by stripe id")? { | |
| 184 | 203 | db::fan_plus::update_fan_plus_status(&state.db, &stripe_sub_id, SubscriptionStatus::PastDue).await.context("update fan+ status to past_due")?; | |
| @@ -206,19 +225,6 @@ pub(super) async fn handle_invoice_payment_failed( | |||
| 206 | 225 | return Ok(()); | |
| 207 | 226 | } | |
| 208 | 227 | ||
| 209 | - | // Check if this is an app sync subscription | |
| 210 | - | if let Some(_app_sub) = db::app_sync::get_app_sync_sub_by_stripe_id(&state.db, &stripe_sub_id).await.context("fetch app sync sub by stripe id")? { | |
| 211 | - | db::app_sync::update_app_sync_sub_status(&state.db, &stripe_sub_id, SubscriptionStatus::PastDue).await.context("update app sync sub status to past_due")?; | |
| 212 | - | ||
| 213 | - | if let Err(e) = db::subscriptions::log_subscription_event( | |
| 214 | - | &state.db, None, event_id, "invoice.payment_failed.app_sync", | |
| 215 | - | &serde_json::json!({"stripe_sub_id": stripe_sub_id}), | |
| 216 | - | ).await { | |
| 217 | - | tracing::warn!(event_id = %event_id, error = ?e, "failed to log subscription event"); | |
| 218 | - | } | |
| 219 | - | return Ok(()); | |
| 220 | - | } | |
| 221 | - | ||
| 222 | 228 | let updated = db::subscriptions::update_subscription_status(&state.db, &stripe_sub_id, SubscriptionStatus::PastDue).await.context("update subscription status to past_due")?; | |
| 223 | 229 | ||
| 224 | 230 | // Log event | |
| @@ -239,7 +245,7 @@ pub(super) async fn handle_invoice_payment_failed( | |||
| 239 | 245 | Ok(()) | |
| 240 | 246 | } | |
| 241 | 247 | ||
| 242 | - | /// Handle charge.refunded webhook — revoke license keys on full refund, | |
| 248 | + | /// Handle charge.refunded webhook; revoke license keys on full refund, | |
| 243 | 249 | /// log partial refunds without revoking access. | |
| 244 | 250 | pub(super) async fn handle_charge_refunded( | |
| 245 | 251 | state: &AppState, |
| @@ -487,70 +487,6 @@ pub(super) async fn handle_creator_tier_checkout_completed( | |||
| 487 | 487 | Ok(()) | |
| 488 | 488 | } | |
| 489 | 489 | ||
| 490 | - | /// Handle checkout.session.completed for app sync subscriptions | |
| 491 | - | #[tracing::instrument(skip_all, name = "stripe::handle_app_sync_checkout")] | |
| 492 | - | pub(super) async fn handle_app_sync_checkout_completed( | |
| 493 | - | state: &AppState, | |
| 494 | - | session: &crate::payments::CheckoutSessionView, | |
| 495 | - | event_id: &str, | |
| 496 | - | ) -> Result<()> { | |
| 497 | - | let session_id = session.id.clone(); | |
| 498 | - | tracing::info!(session_id = %session_id, "processing completed app sync checkout"); | |
| 499 | - | ||
| 500 | - | let metadata = crate::payments::AppSyncCheckoutMetadata::from_metadata(session.metadata.as_ref())?; | |
| 501 | - | let user_id = metadata.user_id; | |
| 502 | - | let app_id = metadata.app_id; | |
| 503 | - | let tier: db::AppSyncTier = metadata.tier.parse() | |
| 504 | - | .map_err(|_| AppError::BadRequest(format!("Invalid tier: {}", metadata.tier)))?; | |
| 505 | - | ||
| 506 | - | let stripe_subscription_id = session.subscription.clone() | |
| 507 | - | .ok_or_else(|| { | |
| 508 | - | tracing::error!("App sync checkout completed but no subscription ID on session"); | |
| 509 | - | AppError::BadRequest("Missing subscription ID on session".to_string()) | |
| 510 | - | })?; | |
| 511 | - | ||
| 512 | - | let stripe_customer_id = session.customer.clone() | |
| 513 | - | .ok_or_else(|| { | |
| 514 | - | tracing::error!("App sync checkout completed but no customer ID on session"); | |
| 515 | - | AppError::BadRequest("Missing customer ID on session".to_string()) | |
| 516 | - | })?; | |
| 517 | - | ||
| 518 | - | let storage_limit_bytes = tier.blob_storage_bytes(); | |
| 519 | - | ||
| 520 | - | match db::app_sync::create_app_sync_subscription( | |
| 521 | - | &state.db, user_id, app_id, &stripe_subscription_id, &stripe_customer_id, | |
| 522 | - | tier, storage_limit_bytes, | |
| 523 | - | ).await | |
| 524 | - | .with_context(|| format!("create app sync subscription for user {user_id} app {app_id}"))? { | |
| 525 | - | Some(_sub) => { | |
| 526 | - | tracing::info!( | |
| 527 | - | user_id = %user_id, app_id = %app_id, tier = %tier, | |
| 528 | - | app_name = %metadata.app_name, | |
| 529 | - | "app sync subscription created" | |
| 530 | - | ); | |
| 531 | - | } | |
| 532 | - | None => { | |
| 533 | - | tracing::info!(user_id = %user_id, app_id = %app_id, "app sync subscription already exists, ignoring duplicate"); | |
| 534 | - | return Ok(()); | |
| 535 | - | } | |
| 536 | - | } | |
| 537 | - | ||
| 538 | - | if let Err(e) = db::subscriptions::log_subscription_event( | |
| 539 | - | &state.db, None, event_id, "checkout.session.completed.app_sync", | |
| 540 | - | &serde_json::json!({ | |
| 541 | - | "session_id": session_id, | |
| 542 | - | "stripe_subscription_id": stripe_subscription_id, | |
| 543 | - | "app_id": app_id, | |
| 544 | - | "tier": tier, | |
| 545 | - | "app_name": metadata.app_name, | |
| 546 | - | }), | |
| 547 | - | ).await { | |
| 548 | - | tracing::warn!(event_id = %event_id, error = ?e, "failed to log subscription event"); | |
| 549 | - | } | |
| 550 | - | ||
| 551 | - | Ok(()) | |
| 552 | - | } | |
| 553 | - | ||
| 554 | 490 | /// Handle checkout.session.completed for tips | |
| 555 | 491 | #[tracing::instrument(skip_all, name = "stripe::handle_tip_checkout")] | |
| 556 | 492 | pub(super) async fn handle_tip_checkout_completed( |
| @@ -184,7 +184,7 @@ pub(super) async fn check_pending_refund(state: &AppState, payment_intent_id: &s | |||
| 184 | 184 | /// with split percentages, creates split records for each member. The owner | |
| 185 | 185 | /// receives the remainder (100% minus all member splits). | |
| 186 | 186 | /// | |
| 187 | - | /// Splits are recorded as obligations — actual payment transfer to members | |
| 187 | + | /// Splits are recorded as obligations; actual payment transfer to members | |
| 188 | 188 | /// is handled by the project owner outside the platform for now. | |
| 189 | 189 | pub(super) async fn record_transaction_splits( | |
| 190 | 190 | state: &AppState, |
| @@ -106,8 +106,6 @@ pub(crate) async fn process_webhook_event( | |||
| 106 | 106 | checkout::handle_fan_plus_checkout_completed(state, &session, event_id).await?; | |
| 107 | 107 | } else if payments::is_creator_tier_checkout(meta) { | |
| 108 | 108 | checkout::handle_creator_tier_checkout_completed(state, &session, event_id).await?; | |
| 109 | - | } else if payments::is_app_sync_checkout(meta) { | |
| 110 | - | checkout::handle_app_sync_checkout_completed(state, &session, event_id).await?; | |
| 111 | 109 | } else if payments::is_tip_checkout(meta) { | |
| 112 | 110 | checkout::handle_tip_checkout_completed(state, &session, event_id).await?; | |
| 113 | 111 | } else if payments::is_subscription_checkout(meta) { |
| @@ -7,7 +7,7 @@ use crate::{ | |||
| 7 | 7 | AppState, | |
| 8 | 8 | }; | |
| 9 | 9 | ||
| 10 | - | /// Handle customer.subscription.updated — update status + period | |
| 10 | + | /// Handle customer.subscription.updated; update status + period | |
| 11 | 11 | pub(super) async fn handle_subscription_updated( | |
| 12 | 12 | state: &AppState, | |
| 13 | 13 | sub: &crate::payments::SubscriptionView, | |
| @@ -16,6 +16,27 @@ pub(super) async fn handle_subscription_updated( | |||
| 16 | 16 | let stripe_sub_id = sub.id.clone(); | |
| 17 | 17 | tracing::info!(stripe_sub_id = %stripe_sub_id, "processing subscription updated"); | |
| 18 | 18 | ||
| 19 | + | // SyncKit v2 developer subscription? If Stripe moved it to past_due/unpaid, | |
| 20 | + | // mirror that as suspended_unpaid. Active or trialing → 'active'. | |
| 21 | + | if let Some(app_id) = db::synckit_billing::get_app_by_stripe_subscription(&state.db, &stripe_sub_id).await.context("fetch synckit app by stripe sub id")? { | |
| 22 | + | let new_status = match sub.status.as_str() { | |
| 23 | + | "past_due" | "unpaid" => Some("suspended_unpaid"), | |
| 24 | + | "canceled" => Some("canceled"), | |
| 25 | + | "active" | "trialing" => Some("active"), | |
| 26 | + | _ => None, | |
| 27 | + | }; | |
| 28 | + | if let Some(s) = new_status { | |
| 29 | + | db::synckit_billing::set_billing_status(&state.db, app_id, s).await.context("synckit set_billing_status")?; | |
| 30 | + | } | |
| 31 | + | if let Err(e) = db::subscriptions::log_subscription_event( | |
| 32 | + | &state.db, None, event_id, "customer.subscription.updated.synckit", | |
| 33 | + | &serde_json::json!({"status": sub.status, "synckit_app_id": app_id.to_string()}), | |
| 34 | + | ).await { | |
| 35 | + | tracing::warn!(event_id = %event_id, error = ?e, "failed to log subscription event"); | |
| 36 | + | } | |
| 37 | + | return Ok(()); | |
| 38 | + | } | |
| 39 | + | ||
| 19 | 40 | // Check if this is a Fan+ subscription | |
| 20 | 41 | if let Some(_fan_sub) = db::fan_plus::get_fan_plus_by_stripe_id(&state.db, &stripe_sub_id).await.context("fetch fan plus by stripe id")? { | |
| 21 | 42 | let status_str = sub.status.as_str(); | |
| @@ -67,27 +88,6 @@ pub(super) async fn handle_subscription_updated( | |||
| 67 | 88 | return Ok(()); | |
| 68 | 89 | } | |
| 69 | 90 | ||
| 70 | - | // Check if this is an app sync subscription | |
| 71 | - | if let Some(_app_sub) = db::app_sync::get_app_sync_sub_by_stripe_id(&state.db, &stripe_sub_id).await.context("fetch app sync sub by stripe id")? { | |
| 72 | - | let status_str = sub.status.as_str(); | |
| 73 | - | let status: SubscriptionStatus = status_str.parse() | |
| 74 | - | .map_err(|_| AppError::BadRequest(format!("Unknown subscription status: {}", status_str)))?; | |
| 75 | - | db::app_sync::update_app_sync_sub_status(&state.db, &stripe_sub_id, status).await.context("update app sync sub status")?; | |
| 76 | - | ||
| 77 | - | let (start_ts, end_ts) = sub.current_period().unwrap_or((0, 0)); | |
| 78 | - | let period_start = stripe_timestamp(start_ts); | |
| 79 | - | let period_end = stripe_timestamp(end_ts); | |
| 80 | - | db::app_sync::update_app_sync_sub_period(&state.db, &stripe_sub_id, period_start, period_end).await.context("update app sync sub period")?; | |
| 81 | - | ||
| 82 | - | if let Err(e) = db::subscriptions::log_subscription_event( | |
| 83 | - | &state.db, None, event_id, "customer.subscription.updated.app_sync", | |
| 84 | - | &serde_json::json!({"status": status_str}), | |
| 85 | - | ).await { | |
| 86 | - | tracing::warn!(event_id = %event_id, error = ?e, "failed to log subscription event"); | |
| 87 | - | } | |
| 88 | - | return Ok(()); | |
| 89 | - | } | |
| 90 | - | ||
| 91 | 91 | let status_str = sub.status.as_str(); | |
| 92 | 92 | let status: SubscriptionStatus = status_str.parse() | |
| 93 | 93 | .map_err(|_| { | |
| @@ -117,7 +117,7 @@ pub(super) async fn handle_subscription_updated( | |||
| 117 | 117 | Ok(()) | |
| 118 | 118 | } | |
| 119 | 119 | ||
| 120 | - | /// Handle customer.subscription.deleted — mark canceled, send email | |
| 120 | + | /// Handle customer.subscription.deleted; mark canceled, send email | |
| 121 | 121 | pub(super) async fn handle_subscription_deleted( | |
| 122 | 122 | state: &AppState, | |
| 123 | 123 | sub: &crate::payments::SubscriptionView, | |
| @@ -126,6 +126,18 @@ pub(super) async fn handle_subscription_deleted( | |||
| 126 | 126 | let stripe_sub_id = sub.id.clone(); | |
| 127 | 127 | tracing::info!(stripe_sub_id = %stripe_sub_id, "processing subscription deleted"); | |
| 128 | 128 | ||
| 129 | + | // SyncKit v2 developer subscription? Flip to 'canceled'. | |
| 130 | + | if let Some(app_id) = db::synckit_billing::get_app_by_stripe_subscription(&state.db, &stripe_sub_id).await.context("fetch synckit app by stripe sub id")? { | |
| 131 | + | db::synckit_billing::set_billing_status(&state.db, app_id, "canceled").await.context("synckit billing -> canceled")?; | |
| 132 | + | if let Err(e) = db::subscriptions::log_subscription_event( | |
| 133 | + | &state.db, None, event_id, "customer.subscription.deleted.synckit", | |
| 134 | + | &serde_json::json!({"stripe_sub_id": stripe_sub_id, "synckit_app_id": app_id.to_string()}), | |
| 135 | + | ).await { | |
| 136 | + | tracing::warn!(event_id = %event_id, error = ?e, "failed to log subscription event"); | |
| 137 | + | } | |
| 138 | + | return Ok(()); | |
| 139 | + | } | |
| 140 | + | ||
| 129 | 141 | // Check if this is a Fan+ subscription | |
| 130 | 142 | if let Some(fan_sub) = db::fan_plus::get_fan_plus_by_stripe_id(&state.db, &stripe_sub_id).await.context("fetch fan plus by stripe id")? { | |
| 131 | 143 | db::fan_plus::cancel_fan_plus(&state.db, &stripe_sub_id).await.context("cancel fan plus")?; | |
| @@ -168,24 +180,6 @@ pub(super) async fn handle_subscription_deleted( | |||
| 168 | 180 | return Ok(()); | |
| 169 | 181 | } | |
| 170 | 182 | ||
| 171 | - | // Check if this is an app sync subscription | |
| 172 | - | if let Some(app_sub) = db::app_sync::get_app_sync_sub_by_stripe_id(&state.db, &stripe_sub_id).await.context("fetch app sync sub by stripe id")? { | |
| 173 | - | db::app_sync::cancel_app_sync_sub(&state.db, &stripe_sub_id).await.context("cancel app sync sub")?; | |
| 174 | - | ||
| 175 | - | tracing::info!( | |
| 176 | - | user_id = %app_sub.user_id, app_id = %app_sub.app_id, tier = %app_sub.tier, | |
| 177 | - | "app sync subscription canceled" | |
| 178 | - | ); | |
| 179 | - | ||
| 180 | - | if let Err(e) = db::subscriptions::log_subscription_event( | |
| 181 | - | &state.db, None, event_id, "customer.subscription.deleted.app_sync", | |
| 182 | - | &serde_json::json!({"stripe_sub_id": stripe_sub_id}), | |
| 183 | - | ).await { | |
| 184 | - | tracing::warn!(event_id = %event_id, error = ?e, "failed to log subscription event"); | |
| 185 | - | } | |
| 186 | - | return Ok(()); | |
| 187 | - | } | |
| 188 | - | ||
| 189 | 183 | let canceled = db::subscriptions::cancel_subscription(&state.db, &stripe_sub_id).await.context("cancel subscription")?; | |
| 190 | 184 | ||
| 191 | 185 | if let Some(ref db_sub) = canceled { |
| @@ -19,7 +19,7 @@ use crate::{ | |||
| 19 | 19 | AppState, | |
| 20 | 20 | }; | |
| 21 | 21 | ||
| 22 | - | /// POST /stripe/webhook/v2 — Handle Stripe v2 thin events | |
| 22 | + | /// POST /stripe/webhook/v2: Handle Stripe v2 thin events | |
| 23 | 23 | #[tracing::instrument(skip_all, name = "stripe::webhook_v2")] | |
| 24 | 24 | pub(super) async fn webhook_v2( | |
| 25 | 25 | State(state): State<AppState>, |
| @@ -20,7 +20,7 @@ use super::{CreateAppRequest, UpdateAppLinkRequest}; | |||
| 20 | 20 | ||
| 21 | 21 | /// Create a new sync app and generate its API key. | |
| 22 | 22 | /// | |
| 23 | - | /// `POST /api/sync/apps` -- Session auth required. | |
| 23 | + | /// `POST /api/sync/apps`: Session auth required. | |
| 24 | 24 | /// Returns the app data plus the plaintext API key (shown only once). | |
| 25 | 25 | #[tracing::instrument(skip_all, name = "synckit::create_app")] | |
| 26 | 26 | pub(super) async fn create_app( | |
| @@ -44,7 +44,7 @@ pub(super) async fn create_app( | |||
| 44 | 44 | ||
| 45 | 45 | /// List all sync apps owned by the authenticated user. | |
| 46 | 46 | /// | |
| 47 | - | /// `GET /api/sync/apps` -- Session auth required. | |
| 47 | + | /// `GET /api/sync/apps`: Session auth required. | |
| 48 | 48 | #[tracing::instrument(skip_all, name = "synckit::list_apps")] | |
| 49 | 49 | pub(super) async fn list_apps( | |
| 50 | 50 | State(state): State<AppState>, | |
| @@ -57,7 +57,7 @@ pub(super) async fn list_apps( | |||
| 57 | 57 | ||
| 58 | 58 | /// Regenerate the API key for a sync app, invalidating the old one. | |
| 59 | 59 | /// | |
| 60 | - | /// `POST /api/sync/apps/{id}/regenerate-key` -- Session auth required. | |
| 60 | + | /// `POST /api/sync/apps/{id}/regenerate-key`: Session auth required. | |
| 61 | 61 | #[tracing::instrument(skip_all, name = "synckit::regenerate_app_key")] | |
| 62 | 62 | pub(super) async fn regenerate_app_key( | |
| 63 | 63 | State(state): State<AppState>, | |
| @@ -80,7 +80,7 @@ pub(super) async fn regenerate_app_key( | |||
| 80 | 80 | ||
| 81 | 81 | /// Delete a sync app and all its associated data. | |
| 82 | 82 | /// | |
| 83 | - | /// `DELETE /api/sync/apps/{id}` -- Session auth required. | |
| 83 | + | /// `DELETE /api/sync/apps/{id}`: Session auth required. | |
| 84 | 84 | #[tracing::instrument(skip_all, name = "synckit::delete_app")] | |
| 85 | 85 | pub(super) async fn delete_app( | |
| 86 | 86 | State(state): State<AppState>, | |
| @@ -102,7 +102,7 @@ pub(super) async fn delete_app( | |||
| 102 | 102 | ||
| 103 | 103 | /// Update the project and/or item link for a sync app. | |
| 104 | 104 | /// | |
| 105 | - | /// `PUT /api/sync/apps/{id}/link` -- Session auth required. | |
| 105 | + | /// `PUT /api/sync/apps/{id}/link`: Session auth required. | |
| 106 | 106 | #[tracing::instrument(skip_all, name = "synckit::update_app_link")] | |
| 107 | 107 | pub(super) async fn update_app_link( | |
| 108 | 108 | State(state): State<AppState>, | |
| @@ -129,7 +129,7 @@ pub(super) async fn update_app_link( | |||
| 129 | 129 | ||
| 130 | 130 | /// Set the OTA slug for a sync app. | |
| 131 | 131 | /// | |
| 132 | - | /// `PUT /api/sync/apps/{id}/slug` -- Session auth required. | |
| 132 | + | /// `PUT /api/sync/apps/{id}/slug`: Session auth required. | |
| 133 | 133 | #[tracing::instrument(skip_all, name = "synckit::update_app_slug")] | |
| 134 | 134 | pub(super) async fn update_app_slug( | |
| 135 | 135 | State(state): State<AppState>, |
| @@ -0,0 +1,386 @@ | |||
| 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 | + | ||
| 44 | + | let app = db::synckit_billing::get_app_with_billing(&state.db, app_id) | |
| 45 | + | .await? | |
| 46 | + | .ok_or(AppError::NotFound)?; | |
| 47 | + | if app.creator_id != user.id { | |
| 48 | + | return Err(AppError::Forbidden); | |
| 49 | + | } | |
| 50 | + | if app.billing_status != "draft" { | |
| 51 | + | return Err(AppError::Conflict(format!( | |
| 52 | + | "App is already {}; billing setup is only valid in draft status", | |
| 53 | + | app.billing_status | |
| 54 | + | ))); | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | let stripe = state | |
| 58 | + | .stripe | |
| 59 | + | .as_ref() | |
| 60 | + | .ok_or_else(|| AppError::ServiceUnavailable("Stripe is not configured".to_string()))?; | |
| 61 | + | ||
| 62 | + | // Reuse existing customer if it was already created (idempotent retries). | |
| 63 | + | let customer_id = match app.stripe_customer_id.as_deref() { | |
| 64 | + | Some(id) => id.to_string(), | |
| 65 | + | None => { | |
| 66 | + | let developer = db::users::get_user_by_id(&state.db, user.id) | |
| 67 | + | .await? | |
| 68 | + | .ok_or(AppError::Unauthorized)?; | |
| 69 | + | let id = stripe | |
| 70 | + | .create_synckit_customer(user.id, developer.email.as_str(), &app.name) | |
| 71 | + | .await?; | |
| 72 | + | db::synckit_billing::set_stripe_customer(&state.db, app_id, &id).await?; | |
| 73 | + | id | |
| 74 | + | } | |
| 75 | + | }; | |
| 76 | + | ||
| 77 | + | let return_url = synckit_return_url(&state, &app); | |
| 78 | + | let portal_url = stripe | |
| 79 | + | .create_synckit_billing_portal(&customer_id, &return_url) | |
| 80 | + | .await?; | |
| 81 | + | ||
| 82 | + | Ok(Json(BillingSetupResponse { | |
| 83 | + | stripe_customer_id: customer_id, | |
| 84 | + | billing_portal_url: portal_url, | |
| 85 | + | })) | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | /// Activate billing on a draft app: validates knobs, computes price, creates | |
| 89 | + | /// the Stripe subscription, and stamps the local sync_apps row. | |
| 90 | + | /// | |
| 91 | + | /// `POST /api/sync/apps/{id}/billing/activate` | |
| 92 | + | #[tracing::instrument(skip_all, name = "synckit::billing::activate")] | |
| 93 | + | pub(super) async fn activate( | |
| 94 | + | State(state): State<AppState>, | |
| 95 | + | AuthUser(user): AuthUser, | |
| 96 | + | Path(app_id): Path<SyncAppId>, | |
| 97 | + | Json(req): Json<BillingActivateRequest>, | |
| 98 | + | ) -> Result<impl IntoResponse> { | |
| 99 | + | user.check_not_sandbox()?; | |
| 100 | + | validate_knobs(&req.enforcement_mode, req.storage_gb_cap, req.egress_multiple, req.key_cap)?; | |
| 101 | + | ||
| 102 | + | let app = db::synckit_billing::get_app_with_billing(&state.db, app_id) | |
| 103 | + | .await? | |
| 104 | + | .ok_or(AppError::NotFound)?; | |
| 105 | + | if app.creator_id != user.id { | |
| 106 | + | return Err(AppError::Forbidden); | |
| 107 | + | } | |
| 108 | + | if app.billing_status != "draft" { | |
| 109 | + | return Err(AppError::Conflict(format!( | |
| 110 | + | "App is already {}; activate is only valid in draft status", | |
| 111 | + | app.billing_status | |
| 112 | + | ))); | |
| 113 | + | } | |
| 114 | + | let customer_id = app.stripe_customer_id.as_deref().ok_or_else(|| { | |
| 115 | + | AppError::BadRequest( | |
| 116 | + | "Must POST /billing/setup before activating — no Stripe customer".to_string(), | |
| 117 | + | ) | |
| 118 | + | })?; | |
| 119 | + | ||
| 120 | + | let price_cents = monthly_price_cents(req.storage_gb_cap, req.egress_multiple, req.key_cap); | |
| 121 | + | ||
| 122 | + | let stripe = state | |
| 123 | + | .stripe | |
| 124 | + | .as_ref() | |
| 125 | + | .ok_or_else(|| AppError::ServiceUnavailable("Stripe is not configured".to_string()))?; | |
| 126 | + | let sub = stripe | |
| 127 | + | .create_synckit_subscription(customer_id, app_id, &app.name, price_cents) | |
| 128 | + | .await?; | |
| 129 | + | ||
| 130 | + | let period_start = chrono::DateTime::<chrono::Utc>::from_timestamp(sub.current_period_start, 0) | |
| 131 | + | .ok_or_else(|| AppError::Internal(anyhow::anyhow!("Invalid period_start from Stripe")))?; | |
| 132 | + | let period_end = chrono::DateTime::<chrono::Utc>::from_timestamp(sub.current_period_end, 0) | |
| 133 | + | .ok_or_else(|| AppError::Internal(anyhow::anyhow!("Invalid period_end from Stripe")))?; | |
| 134 | + | ||
| 135 | + | db::synckit_billing::activate_billing( | |
| 136 | + | &state.db, | |
| 137 | + | app_id, | |
| 138 | + | req.storage_gb_cap as i32, | |
| 139 | + | req.egress_multiple, | |
| 140 | + | &req.enforcement_mode, | |
| 141 | + | req.key_cap.map(|c| c as i32), | |
| 142 | + | &sub.subscription_id, | |
| 143 | + | period_start, | |
| 144 | + | period_end, | |
| 145 | + | ) | |
| 146 | + | .await?; | |
| 147 | + | ||
| 148 | + | Ok(Json(BillingUpdatedResponse { | |
| 149 | + | monthly_price_cents: price_cents, | |
| 150 | + | billing_status: "active".to_string(), | |
| 151 | + | stripe_subscription_id: Some(sub.subscription_id), | |
| 152 | + | })) | |
| 153 | + | } | |
| 154 | + | ||
| 155 | + | /// Change billing knobs on an active subscription (re-prices via proration). | |
| 156 | + | /// | |
| 157 | + | /// `PATCH /api/sync/apps/{id}/billing` | |
| 158 | + | #[tracing::instrument(skip_all, name = "synckit::billing::patch")] | |
| 159 | + | pub(super) async fn patch( | |
| 160 | + | State(state): State<AppState>, | |
| 161 | + | AuthUser(user): AuthUser, | |
| 162 | + | Path(app_id): Path<SyncAppId>, | |
| 163 | + | Json(req): Json<BillingPatchRequest>, | |
| 164 | + | ) -> Result<impl IntoResponse> { | |
| 165 | + | user.check_not_sandbox()?; | |
| 166 | + | validate_knobs(&req.enforcement_mode, req.storage_gb_cap, req.egress_multiple, req.key_cap)?; | |
| 167 | + | ||
| 168 | + | let app = db::synckit_billing::get_app_with_billing(&state.db, app_id) | |
| 169 | + | .await? | |
| 170 | + | .ok_or(AppError::NotFound)?; | |
| 171 | + | if app.creator_id != user.id { | |
| 172 | + | return Err(AppError::Forbidden); | |
| 173 | + | } | |
| 174 | + | if app.billing_status != "active" { | |
| 175 | + | return Err(AppError::Conflict(format!( | |
| 176 | + | "App is {}; PATCH is only valid when active", | |
| 177 | + | app.billing_status | |
| 178 | + | ))); | |
| 179 | + | } | |
| 180 | + | let sub_id = app.stripe_subscription_id.as_deref().ok_or_else(|| { | |
| 181 | + | AppError::Internal(anyhow::anyhow!( | |
| 182 | + | "Active app has no stripe_subscription_id (data inconsistency)" | |
| 183 | + | )) | |
| 184 | + | })?; | |
| 185 | + | ||
| 186 | + | let new_price = monthly_price_cents(req.storage_gb_cap, req.egress_multiple, req.key_cap); | |
| 187 | + | ||
| 188 | + | let stripe = state | |
| 189 | + | .stripe | |
| 190 | + | .as_ref() | |
| 191 | + | .ok_or_else(|| AppError::ServiceUnavailable("Stripe is not configured".to_string()))?; | |
| 192 | + | stripe | |
| 193 | + | .update_synckit_subscription_price(sub_id, new_price, &app.name) | |
| 194 | + | .await?; | |
| 195 | + | ||
| 196 | + | db::synckit_billing::update_knobs( | |
| 197 | + | &state.db, | |
| 198 | + | app_id, | |
| 199 | + | req.storage_gb_cap as i32, | |
| 200 | + | req.egress_multiple, | |
| 201 | + | &req.enforcement_mode, | |
| 202 | + | req.key_cap.map(|c| c as i32), | |
| 203 | + | ) | |
| 204 | + | .await?; | |
| 205 | + | ||
| 206 | + | Ok(Json(BillingUpdatedResponse { | |
| 207 | + | monthly_price_cents: new_price, | |
| 208 | + | billing_status: "active".to_string(), | |
| 209 | + | stripe_subscription_id: Some(sub_id.to_string()), | |
| 210 | + | })) | |
| 211 | + | } | |
| 212 | + | ||
| 213 | + | /// Cancel billing for this app. | |
| 214 | + | /// | |
| 215 | + | /// `DELETE /api/sync/apps/{id}/billing` | |
| 216 | + | #[tracing::instrument(skip_all, name = "synckit::billing::cancel")] | |
| 217 | + | pub(super) async fn cancel( | |
| 218 | + | State(state): State<AppState>, | |
| 219 | + | AuthUser(user): AuthUser, | |
| 220 | + | Path(app_id): Path<SyncAppId>, | |
| 221 | + | ) -> Result<impl IntoResponse> { | |
| 222 | + | user.check_not_sandbox()?; | |
| 223 | + | ||
| 224 | + | let app = db::synckit_billing::get_app_with_billing(&state.db, app_id) | |
| 225 | + | .await? | |
| 226 | + | .ok_or(AppError::NotFound)?; | |
| 227 | + | if app.creator_id != user.id { | |
| 228 | + | return Err(AppError::Forbidden); | |
| 229 | + | } | |
| 230 | + | if app.billing_status == "canceled" { | |
| 231 | + | return Ok(axum::http::StatusCode::NO_CONTENT); | |
| 232 | + | } | |
| 233 | + | ||
| 234 | + | if let Some(sub_id) = app.stripe_subscription_id.as_deref() { | |
| 235 | + | let stripe = state | |
| 236 | + | .stripe | |
| 237 | + | .as_ref() | |
| 238 | + | .ok_or_else(|| AppError::ServiceUnavailable("Stripe is not configured".to_string()))?; | |
| 239 | + | stripe.cancel_synckit_subscription(sub_id).await?; | |
| 240 | + | } | |
| 241 | + | ||
| 242 | + | db::synckit_billing::set_billing_status(&state.db, app_id, "canceled").await?; | |
| 243 | + | ||
| 244 | + | Ok(axum::http::StatusCode::NO_CONTENT) | |
| 245 | + | } | |
| 246 | + | ||
| 247 | + | /// Current billing status, knobs, usage counters, and computed price. | |
| 248 | + | /// | |
| 249 | + | /// `GET /api/sync/apps/{id}/billing` | |
| 250 | + | #[tracing::instrument(skip_all, name = "synckit::billing::get")] | |
| 251 | + | pub(super) async fn get( | |
| 252 | + | State(state): State<AppState>, | |
| 253 | + | AuthUser(user): AuthUser, | |
| 254 | + | Path(app_id): Path<SyncAppId>, | |
| 255 | + | ) -> Result<impl IntoResponse> { | |
| 256 | + | let app = db::synckit_billing::get_app_with_billing(&state.db, app_id) | |
| 257 | + | .await? | |
| 258 | + | .ok_or(AppError::NotFound)?; | |
| 259 | + | if app.creator_id != user.id { | |
| 260 | + | return Err(AppError::Forbidden); | |
| 261 | + | } | |
| 262 | + | ||
| 263 | + | let monthly_price_cents = match (app.storage_gb_cap, app.egress_multiple) { | |
| 264 | + | (Some(gb), Some(mult)) => Some(monthly_price_cents( | |
| 265 | + | gb as u32, | |
| 266 | + | mult, | |
| 267 | + | app.key_cap.map(|c| c as u32), | |
| 268 | + | )), | |
| 269 | + | _ => None, | |
| 270 | + | }; | |
| 271 | + | ||
| 272 | + | Ok(Json(BillingStatusResponse { | |
| 273 | + | app_id, | |
| 274 | + | billing_status: app.billing_status, | |
| 275 | + | is_internal: app.is_internal, | |
| 276 | + | storage_gb_cap: app.storage_gb_cap.map(|v| v as u32), | |
| 277 | + | egress_multiple: app.egress_multiple, | |
| 278 | + | enforcement_mode: app.enforcement_mode, | |
| 279 | + | key_cap: app.key_cap.map(|v| v as u32), | |
| 280 | + | bytes_stored: app.bytes_stored.unwrap_or(0), | |
| 281 | + | bytes_egress_period: app.bytes_egress_period.unwrap_or(0), | |
| 282 | + | keys_claimed: app.keys_claimed.unwrap_or(0) as u32, | |
| 283 | + | last_warning_pct: app.last_warning_pct.unwrap_or(0) as u8, | |
| 284 | + | current_period_start: app.current_period_start, | |
| 285 | + | current_period_end: app.current_period_end, | |
| 286 | + | monthly_price_cents, | |
| 287 | + | })) | |
| 288 | + | } | |
| 289 | + | ||
| 290 | + | /// Return a fresh Stripe billing portal URL for the app's developer. Portals | |
| 291 | + | /// are single-use, so the dashboard hits this on demand rather than caching | |
| 292 | + | /// the URL. | |
| 293 | + | /// | |
| 294 | + | /// `GET /api/sync/apps/{id}/billing/portal` | |
| 295 | + | #[tracing::instrument(skip_all, name = "synckit::billing::portal")] | |
| 296 | + | pub(super) async fn portal( | |
| 297 | + | State(state): State<AppState>, | |
| 298 | + | AuthUser(user): AuthUser, | |
| 299 | + | Path(app_id): Path<SyncAppId>, | |
| 300 | + | ) -> Result<impl IntoResponse> { | |
| 301 | + | user.check_not_sandbox()?; | |
| 302 | + | ||
| 303 | + | let app = db::synckit_billing::get_app_with_billing(&state.db, app_id) | |
| 304 | + | .await? | |
| 305 | + | .ok_or(AppError::NotFound)?; | |
| 306 | + | if app.creator_id != user.id { | |
| 307 | + | return Err(AppError::Forbidden); | |
| 308 | + | } | |
| 309 | + | ||
| 310 | + | let customer_id = app.stripe_customer_id.as_deref().ok_or_else(|| { | |
| 311 | + | AppError::BadRequest( | |
| 312 | + | "No Stripe customer for this app yet — POST /billing/setup first".to_string(), | |
| 313 | + | ) | |
| 314 | + | })?; | |
| 315 | + | ||
| 316 | + | let stripe = state | |
| 317 | + | .stripe | |
| 318 | + | .as_ref() | |
| 319 | + | .ok_or_else(|| AppError::ServiceUnavailable("Stripe is not configured".to_string()))?; | |
| 320 | + | ||
| 321 | + | let return_url = synckit_return_url(&state, &app); | |
| 322 | + | let portal_url = stripe | |
| 323 | + | .create_synckit_billing_portal(customer_id, &return_url) | |
| 324 | + | .await?; | |
| 325 | + | ||
| 326 | + | Ok(Json(serde_json::json!({ "billing_portal_url": portal_url }))) | |
| 327 | + | } | |
| 328 | + | ||
| 329 | + | // ── Helpers ── | |
| 330 | + | ||
| 331 | + | /// Build the Stripe `return_url` for billing portal sessions. Sends the | |
| 332 | + | /// developer back to the SyncKit tab on the project dashboard when the app is | |
| 333 | + | /// linked to a project, or the user-dashboard SyncKit tab otherwise. | |
| 334 | + | fn synckit_return_url( | |
| 335 | + | state: &AppState, | |
| 336 | + | app: &crate::db::DbSyncAppBilling, | |
| 337 | + | ) -> String { | |
| 338 | + | match app.project_slug.as_deref() { | |
| 339 | + | Some(slug) => format!( | |
| 340 | + | "{}/dashboard/project/{}#tab-synckit", | |
| 341 | + | state.config.host_url, slug | |
| 342 | + | ), | |
| 343 | + | None => format!("{}/dashboard#tab-synckit", state.config.host_url), | |
| 344 | + | } | |
| 345 | + | } | |
| 346 | + | ||
| 347 | + | ||
| 348 | + | fn validate_knobs( | |
| 349 | + | enforcement_mode: &str, | |
| 350 | + | storage_gb_cap: u32, | |
| 351 | + | egress_multiple: f64, | |
| 352 | + | key_cap: Option<u32>, | |
| 353 | + | ) -> Result<()> { | |
| 354 | + | if storage_gb_cap == 0 { | |
| 355 | + | return Err(AppError::BadRequest( | |
| 356 | + | "storage_gb_cap must be > 0".to_string(), | |
| 357 | + | )); | |
| 358 | + | } | |
| 359 | + | if !(egress_multiple > 0.0 && egress_multiple.is_finite()) { | |
| 360 | + | return Err(AppError::BadRequest( | |
| 361 | + | "egress_multiple must be > 0".to_string(), | |
| 362 | + | )); | |
| 363 | + | } | |
| 364 | + | match enforcement_mode { | |
| 365 | + | "per_key" => { | |
| 366 | + | if key_cap.is_none_or(|c| c == 0) { | |
| 367 | + | return Err(AppError::BadRequest( | |
| 368 | + | "key_cap (> 0) is required when enforcement_mode = per_key".to_string(), | |
| 369 | + | )); | |
| 370 | + | } | |
| 371 | + | } | |
| 372 | + | "app_wide" => { | |
| 373 | + | if key_cap.is_some() { | |
| 374 | + | return Err(AppError::BadRequest( | |
| 375 | + | "key_cap must be omitted when enforcement_mode = app_wide".to_string(), | |
| 376 | + | )); | |
| 377 | + | } | |
| 378 | + | } | |
| 379 | + | other => { | |
| 380 | + | return Err(AppError::BadRequest(format!( | |
| 381 | + | "enforcement_mode must be 'per_key' or 'app_wide', got {other:?}" | |
| 382 | + | ))); | |
| 383 | + | } | |
| 384 | + | } | |
| 385 | + | Ok(()) | |
| 386 | + | } |
| @@ -2,13 +2,15 @@ | |||
| 2 | 2 | ||
| 3 | 3 | use axum::{ | |
| 4 | 4 | extract::State, | |
| 5 | - | response::IntoResponse, | |
| 5 | + | http::StatusCode, | |
| 6 | + | response::{IntoResponse, Response}, | |
| 6 | 7 | Json, | |
| 7 | 8 | }; | |
| 9 | + | use serde_json::json; | |
| 8 | 10 | ||
| 9 | 11 | use crate::{ | |
| 10 | 12 | constants, | |
| 11 | - | db, | |
| 13 | + | db::{self, synckit_billing}, | |
| 12 | 14 | error::{AppError, Result, ResultExt}, | |
| 13 | 15 | synckit_auth::SyncUser, | |
| 14 | 16 | validation, | |
| @@ -49,24 +51,10 @@ pub(super) async fn blob_upload_url( | |||
| 49 | 51 | ))); | |
| 50 | 52 | } | |
| 51 | 53 | ||
| 52 | - | // Enforce blob storage quota from subscription tier | |
| 53 | - | let storage_limit = db::app_sync::get_blob_storage_limit( | |
| 54 | - | &state.db, sync_user.user_id, sync_user.app_id, | |
| 55 | - | ).await?; | |
| 56 | - | let storage_limit = storage_limit.unwrap_or(0); | |
| 57 | - | if storage_limit == 0 { | |
| 58 | - | return Err(AppError::PaymentRequired( | |
| 59 | - | "Blob sync requires an active subscription. Subscribe in your app settings.".to_string(), | |
| 60 | - | )); | |
| 61 | - | } | |
| 62 | - | ||
| 63 | - | let used = db::synckit::get_blob_storage_used(&state.db, sync_user.app_id, sync_user.user_id).await?; | |
| 64 | - | if used + req.size_bytes > storage_limit { | |
| 65 | - | return Err(AppError::BadRequest(format!( | |
| 66 | - | "Blob storage quota exceeded ({} GB limit). Upgrade your tier for more storage.", | |
| 67 | - | storage_limit / (1024 * 1024 * 1024) | |
| 68 | - | ))); | |
| 69 | - | } | |
| 54 | + | // Cap enforcement happens at confirm time (when we know the upload | |
| 55 | + | // succeeded). Presign returns a URL even when the upload would later be | |
| 56 | + | // rejected — cheap, and avoids leaking cap state to unauthenticated | |
| 57 | + | // S3 calls. See blob_confirm_upload for the gate. | |
| 70 | 58 | ||
| 71 | 59 | // Check dedup — if this hash already exists, skip upload | |
| 72 | 60 | if let Some(_existing) = db::synckit::get_sync_blob_by_hash( | |
| @@ -118,7 +106,7 @@ pub(super) async fn blob_confirm_upload( | |||
| 118 | 106 | State(state): State<AppState>, | |
| 119 | 107 | sync_user: SyncUser, | |
| 120 | 108 | Json(req): Json<BlobConfirmRequest>, | |
| 121 | - | ) -> Result<impl IntoResponse> { | |
| 109 | + | ) -> Result<Response> { | |
| 122 | 110 | let synckit_s3 = state | |
| 123 | 111 | .synckit_s3 | |
| 124 | 112 | .as_ref() | |
| @@ -126,6 +114,34 @@ pub(super) async fn blob_confirm_upload( | |||
| 126 | 114 | ||
| 127 | 115 | validation::validate_sync_blob_hash(&req.hash)?; | |
| 128 | 116 | ||
| 117 | + | // Billing + cap enforcement (skipped entirely for internal apps). | |
| 118 | + | let billing = synckit_billing::get_app_with_billing(&state.db, sync_user.app_id) | |
| 119 | + | .await? | |
| 120 | + | .ok_or(AppError::NotFound)?; | |
| 121 | + | ||
| 122 | + | if !billing.is_internal { | |
| 123 | + | if billing.billing_status != "active" { | |
| 124 | + | return Ok(( | |
| 125 | + | StatusCode::PAYMENT_REQUIRED, | |
| 126 | + | Json(json!({ "reason": "billing_inactive" })), | |
| 127 | + | ) | |
| 128 | + | .into_response()); | |
| 129 | + | } | |
| 130 | + | if let Some(exceeded) = synckit_billing::would_exceed_storage( | |
| 131 | + | &state.db, sync_user.app_id, req.size_bytes, | |
| 132 | + | ).await? { | |
| 133 | + | return Ok(( | |
| 134 | + | StatusCode::PAYMENT_REQUIRED, | |
| 135 | + | Json(json!({ | |
| 136 | + | "reason": "storage_limit_reached", | |
| 137 | + | "used": exceeded.used, | |
| 138 | + | "limit": exceeded.limit, | |
| 139 | + | })), | |
| 140 | + | ) | |
| 141 | + | .into_response()); | |
| 142 | + | } | |
| 143 | + | } | |
| 144 | + | ||
| 129 | 145 | let s3_key = format!("{}/{}/{}", sync_user.app_id, sync_user.user_id, req.hash); | |
| 130 | 146 | ||
| 131 | 147 | // Verify the object actually exists in S3 | |
| @@ -149,7 +165,18 @@ pub(super) async fn blob_confirm_upload( | |||
| 149 | 165 | ) | |
| 150 | 166 | .await?; | |
| 151 | 167 | ||
| 152 | - | Ok(axum::http::StatusCode::NO_CONTENT) | |
| 168 | + | // Update the rolling storage counter. We don't fail the request if this | |
| 169 | + | // breaks — the weekly drift correction job will reconcile from sync_blobs. | |
| 170 | + | // Skip for internal apps to keep the counter at 0 there. | |
| 171 | + | if !billing.is_internal { | |
| 172 | + | if let Err(e) = synckit_billing::add_bytes_stored( | |
| 173 | + | &state.db, sync_user.app_id, req.size_bytes, | |
| 174 | + | ).await { | |
| 175 | + | tracing::error!(error = ?e, app_id = %sync_user.app_id, "failed to bump bytes_stored"); | |
| 176 | + | } | |
| 177 | + | } | |
| 178 | + | ||
| 179 | + | Ok(StatusCode::NO_CONTENT.into_response()) | |
| 153 | 180 | } | |
| 154 | 181 | ||
| 155 | 182 | /// Request a pre-signed S3 download URL for a blob by hash. | |
| @@ -163,7 +190,7 @@ pub(super) async fn blob_download_url( | |||
| 163 | 190 | State(state): State<AppState>, | |
| 164 | 191 | sync_user: SyncUser, | |
| 165 | 192 | Json(req): Json<BlobDownloadUrlRequest>, | |
| 166 | - | ) -> Result<impl IntoResponse> { | |
| 193 | + | ) -> Result<Response> { | |
| 167 | 194 | let synckit_s3 = state | |
| 168 | 195 | .synckit_s3 | |
| 169 | 196 | .as_ref() | |
| @@ -180,6 +207,42 @@ pub(super) async fn blob_download_url( | |||
| 180 | 207 | .await? | |
| 181 | 208 | .ok_or(AppError::NotFound)?; | |
| 182 | 209 | ||
| 210 | + | // Billing + egress cap enforcement (internal apps bypass). | |
| 211 | + | let billing = synckit_billing::get_app_with_billing(&state.db, sync_user.app_id) | |
| 212 | + | .await? | |
| 213 | + | .ok_or(AppError::NotFound)?; | |
| 214 | + | if !billing.is_internal { | |
| 215 | + | if billing.billing_status != "active" { | |
| 216 | + | return Ok(( | |
| 217 | + | StatusCode::PAYMENT_REQUIRED, | |
| 218 | + | Json(json!({ "reason": "billing_inactive" })), | |
| 219 | + | ) | |
| 220 | + | .into_response()); | |
| 221 | + | } | |
| 222 | + | if let Some(exceeded) = synckit_billing::would_exceed_egress( | |
| 223 | + | &state.db, sync_user.app_id, blob.size_bytes, | |
| 224 | + | ).await? { | |
| 225 | + | return Ok(( | |
| 226 | + | StatusCode::PAYMENT_REQUIRED, | |
| 227 | + | Json(json!({ | |
| 228 | + | "reason": "egress_limit_reached", | |
| 229 | + | "used": exceeded.used, | |
| 230 | + | "limit": exceeded.limit, | |
| 231 | + | })), | |
| 232 | + | ) | |
| 233 | + | .into_response()); | |
| 234 | + | } | |
| 235 | + | // Count egress optimistically at presign time. The client may not | |
| 236 | + | // actually download (especially if a retry hits dedup-cached content), | |
| 237 | + | // so this overcounts slightly. Trade-off: simpler than streaming | |
| 238 | + | // bytes-out from S3 access logs, and conservative on the cap side. | |
| 239 | + | if let Err(e) = synckit_billing::add_bytes_egress( | |
| 240 | + | &state.db, sync_user.app_id, blob.size_bytes, | |
| 241 | + | ).await { | |
| 242 | + | tracing::error!(error = ?e, app_id = %sync_user.app_id, "failed to bump bytes_egress_period"); | |
| 243 | + | } | |
| 244 | + | } | |
| 245 | + | ||
| 183 | 246 | let download_url = synckit_s3 | |
| 184 | 247 | .presign_download( | |
| 185 | 248 | &blob.s3_key, | |
| @@ -188,5 +251,12 @@ pub(super) async fn blob_download_url( | |||
| 188 | 251 | .await | |
| 189 | 252 | .context("presign download for sync blob")?; | |
| 190 | 253 | ||
| 191 | - | Ok(Json(BlobDownloadUrlResponse { download_url })) | |
| 254 | + | Ok(Json(BlobDownloadUrlResponse { download_url }).into_response()) | |
| 192 | 255 | } | |
| 256 | + | ||
| 257 | + | // NOTE: No active blob-delete API path was found in v1. Storage counters can | |
| 258 | + | // only grow (until period rollover or manual reset via reset_period_usage, | |
| 259 | + | // which only resets egress, not storage). The weekly drift correction job in | |
| 260 | + | // `db::synckit_billing::recalculate_synckit_app_storage` reconciles | |
| 261 | + | // `bytes_stored` against `sync_blobs` and is the only way storage can shrink | |
| 262 | + | // without DB intervention. Known limitation; revisit when blob deletion ships. |
| @@ -0,0 +1,206 @@ | |||
| 1 | + | //! SyncKit SDK key claim / release / list endpoints. | |
| 2 | + | //! | |
| 3 | + | //! Server-to-server: the developer's backend sends the SyncKit app's | |
| 4 | + | //! `api_key` in the JSON body (no JWT, no session). Each call looks up the | |
| 5 | + | //! app via `db::synckit::get_sync_app_by_api_key`, enforces billing status | |
| 6 | + | //! and (for `per_key` apps) the key cap, then performs the operation. | |
| 7 | + | //! | |
| 8 | + | //! See migration 117 for the underlying `sync_app_keys` schema (active claim | |
| 9 | + | //! is a row with `released_at IS NULL`; the unique index is partial). | |
| 10 | + | // | |
| 11 | + | // TODO Phase 4 integration tests: blocked on migration 117 applied to test DB | |
| 12 | + | ||
| 13 | + | use axum::{ | |
| 14 | + | extract::State, | |
| 15 | + | http::StatusCode, | |
| 16 | + | response::IntoResponse, | |
| 17 | + | Json, | |
| 18 | + | }; | |
| 19 | + | use serde_json::json; | |
| 20 | + | ||
| 21 | + | use crate::{ | |
| 22 | + | db::{self, synckit_billing}, | |
| 23 | + | error::{AppError, Result}, | |
| 24 | + | AppState, | |
| 25 | + | }; | |
| 26 | + | ||
| 27 | + | use super::{ | |
| 28 | + | ClaimKeyRequest, ClaimKeyResponse, KeyInfo, ListKeysRequest, ListKeysResponse, | |
| 29 | + | ReleaseKeyRequest, ReleaseKeyResponse, | |
| 30 | + | }; | |
| 31 | + | ||
| 32 | + | /// `POST /api/sync/keys/claim`: server-to-server SDK key claim. | |
| 33 | + | /// | |
| 34 | + | /// Looks up the app by `api_key`, then: | |
| 35 | + | /// - Internal apps bypass all billing checks. | |
| 36 | + | /// - Returns 402 `{ reason: "billing_inactive" }` when billing isn't active. | |
| 37 | + | /// - In `per_key` mode, returns 402 | |
| 38 | + | /// `{ reason: "key_limit_reached", key_cap, keys_claimed }` if the cap is | |
| 39 | + | /// reached and the key is not already actively claimed (re-claims are | |
| 40 | + | /// always idempotent OK). | |
| 41 | + | #[tracing::instrument(skip_all, name = "synckit::keys::claim")] | |
| 42 | + | pub(super) async fn claim( | |
| 43 | + | State(state): State<AppState>, | |
| 44 | + | Json(req): Json<ClaimKeyRequest>, | |
| 45 | + | ) -> Result<axum::response::Response> { | |
| 46 | + | let app = db::synckit::get_sync_app_by_api_key(&state.db, &req.api_key) | |
| 47 | + | .await? | |
| 48 | + | .ok_or(AppError::Unauthorized)?; | |
| 49 | + | ||
| 50 | + | let billing = synckit_billing::get_app_with_billing(&state.db, app.id) | |
| 51 | + | .await? | |
| 52 | + | .ok_or(AppError::NotFound)?; | |
| 53 | + | ||
| 54 | + | if !billing.is_internal { | |
| 55 | + | if billing.billing_status != "active" { | |
| 56 | + | return Ok(( | |
| 57 | + | StatusCode::PAYMENT_REQUIRED, | |
| 58 | + | Json(json!({ "reason": "billing_inactive" })), | |
| 59 | + | ) | |
| 60 | + | .into_response()); | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | if billing.enforcement_mode == "per_key" { | |
| 64 | + | let key_cap = billing.key_cap.unwrap_or(0); | |
| 65 | + | let keys_claimed = billing.keys_claimed.unwrap_or(0); | |
| 66 | + | if keys_claimed >= key_cap { | |
| 67 | + | // Re-claim of an already-active key is always OK — it doesn't | |
| 68 | + | // consume a new slot. | |
| 69 | + | let already_active = synckit_billing::is_key_actively_claimed( | |
| 70 | + | &state.db, app.id, &req.key, | |
| 71 | + | ) | |
| 72 | + | .await?; | |
| 73 | + | if !already_active { | |
| 74 | + | return Ok(( | |
| 75 | + | StatusCode::PAYMENT_REQUIRED, | |
| 76 | + | Json(json!({ | |
| 77 | + | "reason": "key_limit_reached", | |
| 78 | + | "key_cap": key_cap, | |
| 79 | + | "keys_claimed": keys_claimed, | |
| 80 | + | })), | |
| 81 | + | ) | |
| 82 | + | .into_response()); | |
| 83 | + | } | |
| 84 | + | } | |
| 85 | + | } | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | let result = synckit_billing::claim_key(&state.db, app.id, &req.key).await?; | |
| 89 | + | Ok(Json(ClaimKeyResponse { | |
| 90 | + | newly_claimed: result.newly_claimed, | |
| 91 | + | total_claimed: result.total_claimed, | |
| 92 | + | }) | |
| 93 | + | .into_response()) | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | /// `POST /api/sync/keys/release`: server-to-server SDK key release. | |
| 97 | + | /// | |
| 98 | + | /// Always permitted (even when the app is canceled or suspended) so that | |
| 99 | + | /// cleanup paths can drain stale claims. | |
| 100 | + | #[tracing::instrument(skip_all, name = "synckit::keys::release")] | |
| 101 | + | pub(super) async fn release( | |
| 102 | + | State(state): State<AppState>, | |
| 103 | + | Json(req): Json<ReleaseKeyRequest>, | |
| 104 | + | ) -> Result<impl IntoResponse> { | |
| 105 | + | let app = db::synckit::get_sync_app_by_api_key(&state.db, &req.api_key) | |
| 106 | + | .await? | |
| 107 | + | .ok_or(AppError::Unauthorized)?; | |
| 108 | + | ||
| 109 | + | let result = synckit_billing::release_key(&state.db, app.id, &req.key).await?; | |
| 110 | + | Ok(Json(ReleaseKeyResponse { | |
| 111 | + | newly_released: result.newly_released, | |
| 112 | + | total_claimed: result.total_claimed, | |
| 113 | + | })) | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | /// `POST /api/sync/keys/list`: paginated list of active key claims. | |
| 117 | + | /// | |
| 118 | + | /// Uses POST + body (not GET + query) for consistency with `/validate-app`, | |
| 119 | + | /// keeping the api_key out of access logs. | |
| 120 | + | #[tracing::instrument(skip_all, name = "synckit::keys::list")] | |
| 121 | + | pub(super) async fn list( | |
| 122 | + | State(state): State<AppState>, | |
| 123 | + | Json(req): Json<ListKeysRequest>, | |
| 124 | + | ) -> Result<impl IntoResponse> { | |
| 125 | + | let app = db::synckit::get_sync_app_by_api_key(&state.db, &req.api_key) | |
| 126 | + | .await? | |
| 127 | + | .ok_or(AppError::Unauthorized)?; | |
| 128 | + | ||
| 129 | + | let limit = req.limit.unwrap_or(100).clamp(1, 1000) as i64; | |
| 130 | + | let offset = req.offset.unwrap_or(0) as i64; | |
| 131 | + | ||
| 132 | + | let rows = synckit_billing::list_active_keys(&state.db, app.id, limit, offset).await?; | |
| 133 | + | ||
| 134 | + | let keys = rows | |
| 135 | + | .into_iter() | |
| 136 | + | .map(|r| KeyInfo { | |
| 137 | + | id: r.id, | |
| 138 | + | key: r.key, | |
| 139 | + | claimed_at: r.claimed_at, | |
| 140 | + | }) | |
| 141 | + | .collect(); | |
| 142 | + | ||
| 143 | + | Ok(Json(ListKeysResponse { keys })) | |
| 144 | + | } | |
| 145 | + | ||
| 146 | + | #[cfg(test)] | |
| 147 | + | mod tests { | |
| 148 | + | use super::super::{ | |
| 149 | + | ClaimKeyRequest, ClaimKeyResponse, ListKeysRequest, ListKeysResponse, | |
| 150 | + | ReleaseKeyRequest, ReleaseKeyResponse, | |
| 151 | + | }; | |
| 152 | + | ||
| 153 | + | #[test] | |
| 154 | + | fn claim_request_roundtrips() { | |
| 155 | + | let json = r#"{"api_key":"abc","key":"dev-1"}"#; | |
| 156 | + | let req: ClaimKeyRequest = serde_json::from_str(json).unwrap(); | |
| 157 | + | assert_eq!(req.api_key, "abc"); | |
| 158 | + | assert_eq!(req.key, "dev-1"); | |
| 159 | + | } | |
| 160 | + | ||
| 161 | + | #[test] | |
| 162 | + | fn claim_response_roundtrips() { | |
| 163 | + | let resp = ClaimKeyResponse { | |
| 164 | + | newly_claimed: true, | |
| 165 | + | total_claimed: 7, | |
| 166 | + | }; | |
| 167 | + | let s = serde_json::to_string(&resp).unwrap(); | |
| 168 | + | assert!(s.contains("\"newly_claimed\":true")); | |
| 169 | + | assert!(s.contains("\"total_claimed\":7")); | |
| 170 | + | } | |
| 171 | + | ||
| 172 | + | #[test] | |
| 173 | + | fn release_request_roundtrips() { | |
| 174 | + | let json = r#"{"api_key":"abc","key":"dev-1"}"#; | |
| 175 | + | let req: ReleaseKeyRequest = serde_json::from_str(json).unwrap(); | |
| 176 | + | assert_eq!(req.api_key, "abc"); | |
| 177 | + | assert_eq!(req.key, "dev-1"); | |
| 178 | + | } | |
| 179 | + | ||
| 180 | + | #[test] | |
| 181 | + | fn release_response_roundtrips() { | |
| 182 | + | let resp = ReleaseKeyResponse { | |
| 183 | + | newly_released: false, | |
| 184 | + | total_claimed: 3, | |
| 185 | + | }; | |
| 186 | + | let s = serde_json::to_string(&resp).unwrap(); | |
| 187 | + | assert!(s.contains("\"newly_released\":false")); | |
| 188 | + | assert!(s.contains("\"total_claimed\":3")); | |
| 189 | + | } | |
| 190 | + | ||
| 191 | + | #[test] | |
| 192 | + | fn list_request_defaults() { | |
| 193 | + | let json = r#"{"api_key":"abc"}"#; | |
| 194 | + | let req: ListKeysRequest = serde_json::from_str(json).unwrap(); | |
| 195 | + | assert_eq!(req.api_key, "abc"); | |
| 196 | + | assert!(req.limit.is_none()); | |
| 197 | + | assert!(req.offset.is_none()); | |
| 198 | + | } | |
| 199 | + | ||
| 200 | + | #[test] | |
| 201 | + | fn list_response_empty_roundtrips() { | |
| 202 | + | let resp = ListKeysResponse { keys: vec![] }; | |
| 203 | + | let s = serde_json::to_string(&resp).unwrap(); | |
| 204 | + | assert_eq!(s, r#"{"keys":[]}"#); | |
| 205 | + | } | |
| 206 | + | } |
| @@ -16,9 +16,10 @@ | |||
| 16 | 16 | ||
| 17 | 17 | pub(crate) mod apps; | |
| 18 | 18 | pub(crate) mod auth; | |
| 19 | + | pub(crate) mod billing; | |
| 19 | 20 | pub(crate) mod blobs; | |
| 21 | + | pub(crate) mod keys; | |
| 20 | 22 | mod subscribe; | |
| 21 | - | mod subscription; | |
| 22 | 23 | pub(crate) mod sync; | |
| 23 | 24 | ||
| 24 | 25 | use axum::{ | |
| @@ -95,10 +96,10 @@ pub(crate) struct PullRequest { | |||
| 95 | 96 | #[schema(value_type = String)] | |
| 96 | 97 | pub device_id: SyncDeviceId, | |
| 97 | 98 | pub cursor: i64, | |
| 98 | - | /// Optional table name filter — only return entries for these tables. | |
| 99 | + | /// Optional table name filter; only return entries for these tables. | |
| 99 | 100 | #[serde(default)] | |
| 100 | 101 | pub tables: Option<Vec<String>>, | |
| 101 | - | /// Optional timestamp filter — only return entries at or after this time. | |
| 102 | + | /// Optional timestamp filter; only return entries at or after this time. | |
| 102 | 103 | #[serde(default)] | |
| 103 | 104 | #[schema(value_type = Option<String>)] | |
| 104 | 105 | pub since: Option<DateTime<Utc>>, | |
| @@ -295,71 +296,122 @@ pub(crate) struct BlobDownloadUrlResponse { | |||
| 295 | 296 | download_url: String, | |
| 296 | 297 | } | |
| 297 | 298 | ||
| 298 | - | /// Response for create/regenerate that includes the plaintext API key (shown only once). | |
| 299 | + | // ── Developer billing types ── | |
| 300 | + | ||
| 301 | + | /// Request body for `POST /api/sync/apps/{id}/billing/activate`. Knob shape | |
| 302 | + | /// matches the columns added in migration 117. `key_cap` is required iff | |
| 303 | + | /// `enforcement_mode = "per_key"`. | |
| 304 | + | #[derive(Deserialize)] | |
| 305 | + | pub(crate) struct BillingActivateRequest { | |
| 306 | + | pub storage_gb_cap: u32, | |
| 307 | + | pub egress_multiple: f64, | |
| 308 | + | pub enforcement_mode: String, | |
| 309 | + | pub key_cap: Option<u32>, | |
| 310 | + | } | |
| 311 | + | ||
| 312 | + | /// Request body for `PATCH /api/sync/apps/{id}/billing`; same shape as | |
| 313 | + | /// activate. (Reused via alias for clarity at call sites.) | |
| 314 | + | pub(crate) type BillingPatchRequest = BillingActivateRequest; | |
| 315 | + | ||
| 316 | + | /// Response from `POST /api/sync/apps/{id}/billing/setup`. | |
| 299 | 317 | #[derive(Serialize)] | |
| 300 | - | pub(super) struct AppWithKey { | |
| 301 | - | #[serde(flatten)] | |
| 302 | - | pub app: db::DbSyncApp, | |
| 303 | - | /// The plaintext API key. Only returned on create and regenerate — not stored. | |
| 304 | - | pub api_key: String, | |
| 318 | + | pub(crate) struct BillingSetupResponse { | |
| 319 | + | pub stripe_customer_id: String, | |
| 320 | + | pub billing_portal_url: String, | |
| 321 | + | } | |
| 322 | + | ||
| 323 | + | /// Response from `POST /api/sync/apps/{id}/billing/activate` and | |
| 324 | + | /// `PATCH /api/sync/apps/{id}/billing`. | |
| 325 | + | #[derive(Serialize)] | |
| 326 | + | pub(crate) struct BillingUpdatedResponse { | |
| 327 | + | pub monthly_price_cents: i64, | |
| 328 | + | pub billing_status: String, | |
| 329 | + | pub stripe_subscription_id: Option<String>, | |
| 330 | + | } | |
| 331 | + | ||
| 332 | + | /// Response from `GET /api/sync/apps/{id}/billing`. | |
| 333 | + | #[derive(Serialize)] | |
| 334 | + | pub(crate) struct BillingStatusResponse { | |
| 335 | + | pub app_id: SyncAppId, | |
| 336 | + | pub billing_status: String, | |
| 337 | + | pub is_internal: bool, | |
| 338 | + | pub storage_gb_cap: Option<u32>, | |
| 339 | + | pub egress_multiple: Option<f64>, | |
| 340 | + | pub enforcement_mode: String, | |
| 341 | + | pub key_cap: Option<u32>, | |
| 342 | + | pub bytes_stored: i64, | |
| 343 | + | pub bytes_egress_period: i64, | |
| 344 | + | pub keys_claimed: u32, | |
| 345 | + | pub last_warning_pct: u8, | |
| 346 | + | pub current_period_start: Option<DateTime<Utc>>, | |
| 347 | + | pub current_period_end: Option<DateTime<Utc>>, | |
| 348 | + | /// Monthly price as computed by `synckit_billing::monthly_price_cents`. | |
| 349 | + | /// `None` while in draft (knobs not yet set). | |
| 350 | + | pub monthly_price_cents: Option<i64>, | |
| 305 | 351 | } | |
| 306 | 352 | ||
| 307 | - | // ── Subscription types ── | |
| 353 | + | // ── Key claim types ── | |
| 308 | 354 | ||
| 355 | + | /// Request body for `POST /api/sync/keys/claim`. Server-to-server: developer's | |
| 356 | + | /// backend sends the SyncKit app's `api_key` alongside the SDK key being | |
| 357 | + | /// claimed. | |
| 309 | 358 | #[derive(Deserialize)] | |
| 310 | - | pub(super) struct SubscriptionCheckoutRequest { | |
| 311 | - | /// Tier to subscribe to: "standard" for GO/BB, "light"/"standard"/"large" for AF | |
| 312 | - | pub tier: String, | |
| 313 | - | /// Billing interval: "monthly" or "annual" | |
| 314 | - | pub interval: String, | |
| 359 | + | pub(crate) struct ClaimKeyRequest { | |
| 360 | + | pub api_key: String, | |
| 361 | + | pub key: String, | |
| 315 | 362 | } | |
| 316 | 363 | ||
| 317 | - | /// Individual tier info returned by the tiers endpoint. | |
| 364 | + | /// Response body for `POST /api/sync/keys/claim`. | |
| 318 | 365 | #[derive(Serialize)] | |
| 319 | - | pub(super) struct TierInfo { | |
| 320 | - | pub id: String, | |
| 321 | - | pub label: String, | |
| 322 | - | pub description: String, | |
| 323 | - | pub storage_bytes: Option<i64>, | |
| 324 | - | pub monthly_price_cents: i64, | |
| 325 | - | pub annual_price_cents: i64, | |
| 366 | + | pub(crate) struct ClaimKeyResponse { | |
| 367 | + | pub newly_claimed: bool, | |
| 368 | + | pub total_claimed: i32, | |
| 326 | 369 | } | |
| 327 | 370 | ||
| 328 | - | /// Response from the app tiers endpoint. | |
| 371 | + | /// Request body for `POST /api/sync/keys/release`. | |
| 372 | + | #[derive(Deserialize)] | |
| 373 | + | pub(crate) struct ReleaseKeyRequest { | |
| 374 | + | pub api_key: String, | |
| 375 | + | pub key: String, | |
| 376 | + | } | |
| 377 | + | ||
| 378 | + | /// Response body for `POST /api/sync/keys/release`. | |
| 329 | 379 | #[derive(Serialize)] | |
| 330 | - | pub(super) struct AppTiersResponse { | |
| 331 | - | pub app_name: String, | |
| 332 | - | pub tiers: Vec<TierInfo>, | |
| 380 | + | pub(crate) struct ReleaseKeyResponse { | |
| 381 | + | pub newly_released: bool, | |
| 382 | + | pub total_claimed: i32, | |
| 333 | 383 | } | |
| 334 | 384 | ||
| 385 | + | /// Request body for `POST /api/sync/keys/list`. POST + body (not GET + query) | |
| 386 | + | /// to keep the api_key out of access logs. | |
| 335 | 387 | #[derive(Deserialize)] | |
| 336 | - | pub(super) struct SubscriptionChangeTierRequest { | |
| 337 | - | /// New tier: "light", "standard", or "large" | |
| 338 | - | pub tier: String, | |
| 339 | - | /// Billing interval: "monthly" or "annual" | |
| 340 | - | pub interval: String, | |
| 388 | + | pub(crate) struct ListKeysRequest { | |
| 389 | + | pub api_key: String, | |
| 390 | + | pub limit: Option<u32>, | |
| 391 | + | pub offset: Option<u32>, | |
| 341 | 392 | } | |
| 342 | 393 | ||
| 394 | + | /// One row in the active-key list returned by `POST /api/sync/keys/list`. | |
| 343 | 395 | #[derive(Serialize)] | |
| 344 | - | pub(super) struct SubscriptionCheckoutResponse { | |
| 345 | - | /// Stripe Checkout URL to redirect the user to | |
| 346 | - | pub checkout_url: String, | |
| 396 | + | pub(crate) struct KeyInfo { | |
| 397 | + | pub id: uuid::Uuid, | |
| 398 | + | pub key: String, | |
| 399 | + | pub claimed_at: DateTime<Utc>, | |
| 347 | 400 | } | |
| 348 | 401 | ||
| 402 | + | /// Response body for `POST /api/sync/keys/list`. | |
| 349 | 403 | #[derive(Serialize)] | |
| 350 | - | pub(super) struct SubscriptionStatusResponse { | |
| 351 | - | /// Whether the user has an active sync subscription for this app | |
| 352 | - | pub active: bool, | |
| 353 | - | /// Subscription tier (if active) | |
| 354 | - | pub tier: Option<String>, | |
| 355 | - | /// Subscription status string | |
| 356 | - | pub status: Option<String>, | |
| 357 | - | /// Blob storage limit in bytes (AF only) | |
| 358 | - | pub storage_limit_bytes: Option<i64>, | |
| 359 | - | /// Blob storage used in bytes | |
| 360 | - | pub storage_used_bytes: Option<i64>, | |
| 361 | - | /// End of current billing period | |
| 362 | - | pub current_period_end: Option<DateTime<Utc>>, | |
| 404 | + | pub(crate) struct ListKeysResponse { | |
| 405 | + | pub keys: Vec<KeyInfo>, | |
| 406 | + | } | |
| 407 | + | ||
| 408 | + | /// Response for create/regenerate that includes the plaintext API key (shown only once). | |
| 409 | + | #[derive(Serialize)] | |
| 410 | + | pub(super) struct AppWithKey { | |
| 411 | + | #[serde(flatten)] | |
| 412 | + | pub app: db::DbSyncApp, | |
| 413 | + | /// The plaintext API key. Only returned on create and regenerate; not stored. | |
| 414 | + | pub api_key: String, | |
| 363 | 415 | } | |
| 364 | 416 | ||
| 365 | 417 | // ── Helper ── | |
| @@ -394,8 +446,13 @@ pub fn synckit_routes() -> Router<AppState> { | |||
| 394 | 446 | .route("/api/v1/sync/auth", post(auth::sync_auth)) | |
| 395 | 447 | .route("/api/sync/validate-app", post(auth::validate_app)) | |
| 396 | 448 | .route("/api/v1/sync/validate-app", post(auth::validate_app)) | |
| 397 | - | .route("/api/sync/app/tiers", post(subscription::get_app_tiers)) | |
| 398 | - | .route("/api/v1/sync/app/tiers", post(subscription::get_app_tiers)) | |
| 449 | + | // Server-to-server SDK key claim/release/list (api_key in body, no JWT). | |
| 450 | + | .route("/api/sync/keys/claim", post(keys::claim)) | |
| 451 | + | .route("/api/v1/sync/keys/claim", post(keys::claim)) | |
| 452 | + | .route("/api/sync/keys/release", post(keys::release)) | |
| 453 | + | .route("/api/v1/sync/keys/release", post(keys::release)) | |
| 454 | + | .route("/api/sync/keys/list", post(keys::list)) | |
| 455 | + | .route("/api/v1/sync/keys/list", post(keys::list)) | |
| 399 | 456 | .route_layer(GovernorLayer { | |
| 400 | 457 | config: auth_rate_limit, | |
| 401 | 458 | }); | |
| @@ -440,12 +497,6 @@ pub fn synckit_routes() -> Router<AppState> { | |||
| 440 | 497 | .route("/api/v1/sync/blobs/confirm", post(blobs::blob_confirm_upload)) | |
| 441 | 498 | .route("/api/sync/blobs/download", post(blobs::blob_download_url)) | |
| 442 | 499 | .route("/api/v1/sync/blobs/download", post(blobs::blob_download_url)) | |
| 443 | - | .route("/api/sync/subscription", get(subscription::get_subscription_status)) | |
| 444 | - | .route("/api/v1/sync/subscription", get(subscription::get_subscription_status)) | |
| 445 | - | .route("/api/sync/subscription/checkout", post(subscription::create_checkout)) | |
| 446 | - | .route("/api/v1/sync/subscription/checkout", post(subscription::create_checkout)) | |
| 447 | - | .route("/api/sync/subscription/change", post(subscription::change_tier)) | |
| 448 | - | .route("/api/v1/sync/subscription/change", post(subscription::change_tier)) | |
| 449 | 500 | // Per-app rate limit (inner layer runs first): prevents one developer's | |
| 450 | 501 | // app from starving other apps. Extracts app ID from JWT payload. | |
| 451 | 502 | .route_layer(GovernorLayer { | |
| @@ -470,7 +521,20 @@ pub fn synckit_routes() -> Router<AppState> { | |||
| 470 | 521 | .route("/api/sync/apps/{id}/slug", put(apps::update_app_slug)) | |
| 471 | 522 | .route("/api/v1/sync/apps/{id}/slug", put(apps::update_app_slug)) | |
| 472 | 523 | .route("/api/sync/apps/{id}", delete(apps::delete_app)) | |
| 473 | - | .route("/api/v1/sync/apps/{id}", delete(apps::delete_app)); | |
| 524 | + | .route("/api/v1/sync/apps/{id}", delete(apps::delete_app)) | |
| 525 | + | // Developer billing (session auth, dashboard-driven). | |
| 526 | + | .route("/api/sync/apps/{id}/billing/setup", post(billing::setup)) | |
| 527 | + | .route("/api/v1/sync/apps/{id}/billing/setup", post(billing::setup)) | |
| 528 | + | .route("/api/sync/apps/{id}/billing/activate", post(billing::activate)) | |
| 529 | + | .route("/api/v1/sync/apps/{id}/billing/activate", post(billing::activate)) | |
| 530 | + | .route("/api/sync/apps/{id}/billing", axum::routing::patch(billing::patch)) | |
| 531 | + | .route("/api/v1/sync/apps/{id}/billing", axum::routing::patch(billing::patch)) | |
| 532 | + | .route("/api/sync/apps/{id}/billing", delete(billing::cancel)) | |
| 533 | + | .route("/api/v1/sync/apps/{id}/billing", delete(billing::cancel)) | |
| 534 | + | .route("/api/sync/apps/{id}/billing", get(billing::get)) | |
| 535 | + | .route("/api/v1/sync/apps/{id}/billing", get(billing::get)) | |
| 536 | + | .route("/api/sync/apps/{id}/billing/portal", get(billing::portal)) | |
| 537 | + | .route("/api/v1/sync/apps/{id}/billing/portal", get(billing::portal)); | |
| 474 | 538 | ||
| 475 | 539 | auth_routes.merge(sync_routes).merge(app_routes) | |
| 476 | 540 | } |