use axum::Json;
use axum::extract::{Path, Query, State};
use axum::http::{HeaderMap, StatusCode, header};
use axum::response::{IntoResponse, Response};
use serde::Deserialize;
use serde_json::{Value, json};
use uuid::Uuid;
use crate::core::billing::Plan;
use super::auth::{AppState, auth_user};
use super::config::Config;
pub(super) async fn resolve_plan(cfg: &Config, user_id: Uuid) -> Plan {
resolve_entitlements_raw(cfg, user_id)
.await
.and_then(|v| v.get("plan").and_then(Value::as_str).map(Plan::parse))
.unwrap_or(Plan::Free)
}
async fn resolve_entitlements_raw(cfg: &Config, user_id: Uuid) -> Option<Value> {
let (Some(base), Some(key)) = (
cfg.billing_base_url.clone(),
cfg.billing_internal_key.clone(),
) else {
return None;
};
let url = format!("{base}/api/billing/entitlements/{user_id}");
let body = tokio::task::spawn_blocking(move || {
ureq::get(&url)
.header("X-Internal-Key", &key)
.call()
.ok()?
.into_body()
.read_to_string()
.ok()
})
.await
.ok()
.flatten()?;
serde_json::from_str::<Value>(&body).ok()
}
pub(super) async fn billing_delete_account(
cfg: &Config,
user_id: Uuid,
) -> Result<Option<Value>, (StatusCode, String)> {
let (Some(base), Some(key)) = (
cfg.billing_base_url.clone(),
cfg.billing_internal_key.clone(),
) else {
return Ok(None);
};
let url = format!("{base}/api/billing/account/{user_id}");
let body = tokio::task::spawn_blocking(move || {
ureq::delete(&url)
.header("X-Internal-Key", &key)
.call()
.map_err(|e| e.to_string())?
.into_body()
.read_to_string()
.map_err(|e| e.to_string())
})
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("join: {e}")))?
.map_err(|e| {
(
StatusCode::BAD_GATEWAY,
format!("billing deletion failed — account NOT deleted, please retry: {e}"),
)
})?;
Ok(Some(serde_json::from_str(&body).unwrap_or(Value::Null)))
}
fn sync_is_open(cfg: &Config) -> bool {
cfg.sync_open || cfg.billing_base_url.is_none()
}
pub(super) fn cloud_sync_allowed(cfg: &Config, plan: Plan) -> bool {
sync_is_open(cfg) || plan.entitlements().cloud_sync
}
pub(super) async fn require_cloud_sync(
state: &AppState,
headers: &HeaderMap,
) -> Result<(Uuid, String), (StatusCode, String)> {
let (user_id, email) = auth_user(state, headers).await?;
let plan = if sync_is_open(&state.cfg) {
Plan::Free
} else {
resolve_plan(&state.cfg, user_id).await
};
if cloud_sync_allowed(&state.cfg, plan) {
return Ok((user_id, email));
}
Err((
StatusCode::PAYMENT_REQUIRED,
format!(
"cloud sync requires lean-ctx Pro (current plan: {}). \
Run `lean-ctx upgrade` to enable hosted cross-device sync.",
plan.as_str()
),
))
}
pub(super) async fn hosted_index_quota_mb(state: &AppState, user_id: Uuid) -> u32 {
if !sync_is_open(&state.cfg) {
let quota = resolve_plan(&state.cfg, user_id)
.await
.entitlements()
.hosted_index_mb;
if quota > 0 {
return quota;
}
}
1_000
}
pub(super) async fn get_account_entitlements(
State(state): State<AppState>,
headers: HeaderMap,
) -> Result<Json<Value>, (StatusCode, String)> {
let (user_id, _email) = auth_user(&state, &headers).await?;
let raw = resolve_entitlements_raw(&state.cfg, user_id).await;
let plan = raw
.as_ref()
.and_then(|v| v.get("plan").and_then(Value::as_str).map(Plan::parse))
.unwrap_or(Plan::Free);
let org = raw
.as_ref()
.and_then(|v| v.get("org").cloned())
.unwrap_or(Value::Null);
let subscription = raw
.as_ref()
.and_then(|v| v.get("subscription").cloned())
.unwrap_or(Value::Null);
Ok(Json(json!({
"plan": plan.as_str(),
"entitlements": plan.entitlements(),
"org": org,
"subscription": subscription,
})))
}
async fn billing_post(
cfg: &Config,
path: &str,
payload: Value,
) -> Result<Value, (StatusCode, String)> {
let (Some(base), Some(key)) = (
cfg.billing_base_url.clone(),
cfg.billing_internal_key.clone(),
) else {
return Err((
StatusCode::SERVICE_UNAVAILABLE,
"billing is not enabled on this deployment".to_string(),
));
};
let url = format!("{base}{path}");
let bytes = serde_json::to_vec(&payload)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("encode: {e}")))?;
let text = tokio::task::spawn_blocking(move || {
ureq::post(&url)
.header("X-Internal-Key", &key)
.header("Content-Type", "application/json")
.send(&bytes)
.map_err(|e| e.to_string())?
.into_body()
.read_to_string()
.map_err(|e| e.to_string())
})
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("join: {e}")))?
.map_err(|e| (StatusCode::BAD_GATEWAY, format!("billing upstream: {e}")))?;
serde_json::from_str::<Value>(&text).map_err(|e| {
(
StatusCode::BAD_GATEWAY,
format!("billing returned non-JSON: {e}"),
)
})
}
#[derive(Deserialize)]
pub(super) struct CheckoutBody {
plan: String,
#[serde(default)]
interval: Option<String>,
}
pub(super) async fn post_account_checkout(
State(state): State<AppState>,
headers: HeaderMap,
Json(body): Json<CheckoutBody>,
) -> Result<Json<Value>, (StatusCode, String)> {
let (user_id, email) = auth_user(&state, &headers).await?;
let payload = json!({
"user_id": user_id,
"email": email,
"plan": body.plan,
"interval": body.interval,
});
Ok(Json(
billing_post(&state.cfg, "/api/billing/checkout", payload).await?,
))
}
pub(super) async fn post_account_portal(
State(state): State<AppState>,
headers: HeaderMap,
) -> Result<Json<Value>, (StatusCode, String)> {
let (user_id, _email) = auth_user(&state, &headers).await?;
let payload = json!({ "user_id": user_id });
Ok(Json(
billing_post(&state.cfg, "/api/billing/portal", payload).await?,
))
}
async fn billing_forward(
cfg: &Config,
method: &'static str,
path: String,
payload: Option<Value>,
) -> Result<(StatusCode, Value), (StatusCode, String)> {
let (Some(base), Some(key)) = (
cfg.billing_base_url.clone(),
cfg.billing_internal_key.clone(),
) else {
return Err((
StatusCode::SERVICE_UNAVAILABLE,
"billing is not enabled on this deployment".to_string(),
));
};
let url = format!("{base}{path}");
let (code, text) = tokio::task::spawn_blocking(move || -> Result<(u16, String), String> {
let agent: ureq::Agent = ureq::Agent::config_builder()
.http_status_as_error(false)
.build()
.into();
let resp = match method {
"GET" => agent.get(&url).header("X-Internal-Key", &key).call(),
"DELETE" => agent.delete(&url).header("X-Internal-Key", &key).call(),
_ => {
let bytes = serde_json::to_vec(&payload.unwrap_or_else(|| json!({})))
.map_err(|e| e.to_string())?;
let builder = match method {
"PATCH" => agent.patch(&url),
"PUT" => agent.put(&url),
_ => agent.post(&url),
};
builder
.header("X-Internal-Key", &key)
.header("Content-Type", "application/json")
.send(&bytes)
}
}
.map_err(|e| e.to_string())?;
let code = resp.status().as_u16();
let body = resp
.into_body()
.read_to_string()
.map_err(|e| e.to_string())?;
Ok((code, body))
})
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("join: {e}")))?
.map_err(|e| (StatusCode::BAD_GATEWAY, format!("billing upstream: {e}")))?;
let json = serde_json::from_str::<Value>(&text).unwrap_or(Value::Null);
let status = StatusCode::from_u16(code).unwrap_or(StatusCode::BAD_GATEWAY);
Ok((status, json))
}
fn finish(status: StatusCode, json: Value) -> Result<Json<Value>, (StatusCode, String)> {
if status.is_success() {
return Ok(Json(json));
}
let msg = json
.get("error")
.and_then(Value::as_str)
.or_else(|| json.get("message").and_then(Value::as_str))
.unwrap_or("team request failed")
.to_string();
Err((status, msg))
}
#[derive(Deserialize)]
pub(super) struct MemberBody {
#[serde(default)]
role: Option<String>,
#[serde(default)]
label: Option<String>,
}
pub(super) async fn get_account_team(
State(state): State<AppState>,
headers: HeaderMap,
) -> Result<Json<Value>, (StatusCode, String)> {
let (user_id, _email) = auth_user(&state, &headers).await?;
let (status, json) = billing_forward(
&state.cfg,
"GET",
format!("/api/billing/team/{user_id}"),
None,
)
.await?;
finish(status, json)
}
pub(super) async fn get_account_team_savings(
State(state): State<AppState>,
headers: HeaderMap,
) -> Result<Json<Value>, (StatusCode, String)> {
let (user_id, _email) = auth_user(&state, &headers).await?;
let (status, json) = billing_forward(
&state.cfg,
"GET",
format!("/api/billing/team/{user_id}/savings"),
None,
)
.await?;
finish(status, json)
}
pub(super) async fn get_account_team_savings_member(
State(state): State<AppState>,
headers: HeaderMap,
axum::extract::Path(signer): axum::extract::Path<String>,
) -> Result<Json<Value>, (StatusCode, String)> {
let (user_id, _email) = auth_user(&state, &headers).await?;
if signer.is_empty()
|| signer.len() > 64
|| !signer
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-')
{
return Err((StatusCode::BAD_REQUEST, "invalid signer id".into()));
}
let (status, json) = billing_forward(
&state.cfg,
"GET",
format!("/api/billing/team/{user_id}/savings/member/{signer}"),
None,
)
.await?;
finish(status, json)
}
pub(super) async fn forward_for_digest(cfg: &Config, path: String) -> Option<(u16, Value)> {
match billing_forward(cfg, "GET", path, None).await {
Ok((status, json)) => Some((status.as_u16(), json)),
Err(_) => None,
}
}
#[derive(Deserialize)]
pub(super) struct TeamSettingsBody {
#[serde(default, rename = "roiWebhookUrl")]
roi_webhook_url: Option<String>,
}
pub(super) async fn put_account_team_settings(
State(state): State<AppState>,
headers: HeaderMap,
Json(body): Json<TeamSettingsBody>,
) -> Result<Json<Value>, (StatusCode, String)> {
let (user_id, _email) = auth_user(&state, &headers).await?;
let (status, json) = billing_forward(
&state.cfg,
"PUT",
format!("/api/billing/team/{user_id}/settings"),
Some(json!({ "roiWebhookUrl": body.roi_webhook_url })),
)
.await?;
finish(status, json)
}
pub(super) async fn post_account_team_owner_token(
State(state): State<AppState>,
headers: HeaderMap,
) -> Result<Json<Value>, (StatusCode, String)> {
let (user_id, _email) = auth_user(&state, &headers).await?;
let (status, json) = billing_forward(
&state.cfg,
"POST",
format!("/api/billing/team/{user_id}/owner-token"),
Some(json!({})),
)
.await?;
finish(status, json)
}
pub(super) async fn post_account_team_member(
State(state): State<AppState>,
headers: HeaderMap,
Json(body): Json<MemberBody>,
) -> Result<Json<Value>, (StatusCode, String)> {
let (user_id, _email) = auth_user(&state, &headers).await?;
let (status, json) = billing_forward(
&state.cfg,
"POST",
format!("/api/billing/team/{user_id}/tokens"),
Some(json!({ "role": body.role, "label": body.label })),
)
.await?;
finish(status, json)
}
#[derive(Deserialize)]
pub(super) struct InviteBody {
#[serde(default)]
label: Option<String>,
#[serde(default)]
role: Option<String>,
}
pub(super) async fn post_account_team_invite(
State(state): State<AppState>,
headers: HeaderMap,
Json(body): Json<InviteBody>,
) -> Result<Json<Value>, (StatusCode, String)> {
let (user_id, _email) = auth_user(&state, &headers).await?;
let (status, json) = billing_forward(
&state.cfg,
"POST",
format!("/api/billing/team/{user_id}/invites"),
Some(json!({ "label": body.label, "role": body.role })),
)
.await?;
finish(status, json)
}
pub(super) async fn get_account_team_invites(
State(state): State<AppState>,
headers: HeaderMap,
) -> Result<Json<Value>, (StatusCode, String)> {
let (user_id, _email) = auth_user(&state, &headers).await?;
let (status, json) = billing_forward(
&state.cfg,
"GET",
format!("/api/billing/team/{user_id}/invites"),
None,
)
.await?;
finish(status, json)
}
pub(super) async fn delete_account_team_invite(
State(state): State<AppState>,
headers: HeaderMap,
Path(invite_id): Path<Uuid>,
) -> Result<Json<Value>, (StatusCode, String)> {
let (user_id, _email) = auth_user(&state, &headers).await?;
let (status, json) = billing_forward(
&state.cfg,
"DELETE",
format!("/api/billing/team/{user_id}/invites/{invite_id}"),
None,
)
.await?;
if status == StatusCode::NO_CONTENT {
return Ok(Json(json!({ "revoked": true })));
}
finish(status, json)
}
pub(super) async fn forward_invite_redeem(
cfg: &Config,
code: &str,
) -> Result<(StatusCode, Value), (StatusCode, String)> {
billing_forward(
cfg,
"POST",
"/api/billing/invites/redeem".to_string(),
Some(json!({ "code": code })),
)
.await
}
#[derive(Deserialize)]
pub(super) struct OrgSsoBody {
email_domain: String,
issuer: String,
client_id: String,
#[serde(default)]
client_secret: Option<String>,
}
pub(super) async fn get_account_org_sso(
State(state): State<AppState>,
headers: HeaderMap,
) -> Result<Json<Value>, (StatusCode, String)> {
let (user_id, _email) = auth_user(&state, &headers).await?;
let (status, json) = billing_forward(
&state.cfg,
"GET",
format!("/api/billing/org/{user_id}/sso"),
None,
)
.await?;
finish(status, json)
}
pub(super) async fn put_account_org_sso(
State(state): State<AppState>,
headers: HeaderMap,
Json(body): Json<OrgSsoBody>,
) -> Result<Json<Value>, (StatusCode, String)> {
let (user_id, _email) = auth_user(&state, &headers).await?;
let (status, json) = billing_forward(
&state.cfg,
"PUT",
format!("/api/billing/org/{user_id}/sso"),
Some(json!({
"email_domain": body.email_domain,
"issuer": body.issuer,
"client_id": body.client_id,
"client_secret": body.client_secret,
})),
)
.await?;
finish(status, json)
}
pub(super) async fn post_account_org_sso_verify(
State(state): State<AppState>,
headers: HeaderMap,
) -> Result<Json<Value>, (StatusCode, String)> {
let (user_id, _email) = auth_user(&state, &headers).await?;
let (status, json) = billing_forward(
&state.cfg,
"POST",
format!("/api/billing/org/{user_id}/sso/verify"),
Some(json!({})),
)
.await?;
finish(status, json)
}
#[derive(Deserialize)]
pub(super) struct OrgSsoRequiredBody {
sso_required: bool,
}
pub(super) async fn put_account_org_sso_required(
State(state): State<AppState>,
headers: HeaderMap,
Json(body): Json<OrgSsoRequiredBody>,
) -> Result<Json<Value>, (StatusCode, String)> {
let (user_id, _email) = auth_user(&state, &headers).await?;
let (status, json) = billing_forward(
&state.cfg,
"PUT",
format!("/api/billing/org/{user_id}/sso/required"),
Some(json!({ "sso_required": body.sso_required })),
)
.await?;
finish(status, json)
}
pub(super) async fn delete_account_org_sso(
State(state): State<AppState>,
headers: HeaderMap,
) -> Result<Json<Value>, (StatusCode, String)> {
let (user_id, _email) = auth_user(&state, &headers).await?;
let (status, json) = billing_forward(
&state.cfg,
"DELETE",
format!("/api/billing/org/{user_id}/sso"),
None,
)
.await?;
if status == StatusCode::NO_CONTENT {
return Ok(Json(json!({ "removed": true })));
}
finish(status, json)
}
#[derive(Deserialize)]
pub(super) struct AuditQuery {
#[serde(default)]
before: Option<i64>,
#[serde(default)]
limit: Option<i64>,
#[serde(default)]
event: Option<String>,
}
fn build_audit_query(q: &AuditQuery) -> String {
let mut parts: Vec<String> = Vec::new();
if let Some(b) = q.before
&& b > 0
{
parts.push(format!("before={b}"));
}
if let Some(l) = q.limit {
parts.push(format!("limit={}", l.clamp(1, 200)));
}
if let Some(ev) = q.event.as_deref() {
let ev = ev.trim();
if !ev.is_empty()
&& ev.len() <= 48
&& ev.bytes().all(|b| b.is_ascii_lowercase() || b == b'_')
{
parts.push(format!("event={ev}"));
}
}
if parts.is_empty() {
String::new()
} else {
format!("?{}", parts.join("&"))
}
}
pub(super) async fn get_account_org_audit(
State(state): State<AppState>,
headers: HeaderMap,
Query(q): Query<AuditQuery>,
) -> Result<Json<Value>, (StatusCode, String)> {
let (user_id, _email) = auth_user(&state, &headers).await?;
let qs = build_audit_query(&q);
let (status, json) = billing_forward(
&state.cfg,
"GET",
format!("/api/billing/org/{user_id}/audit{qs}"),
None,
)
.await?;
finish(status, json)
}
pub(super) async fn get_account_org_audit_export(
State(state): State<AppState>,
headers: HeaderMap,
) -> Result<Response, (StatusCode, String)> {
let (user_id, _email) = auth_user(&state, &headers).await?;
let (status, body) = billing_forward_text(
&state.cfg,
format!("/api/billing/org/{user_id}/audit/export.csv"),
)
.await?;
if !status.is_success() {
return Err((status, "audit export failed".to_string()));
}
Ok((
[
(header::CONTENT_TYPE, "text/csv; charset=utf-8"),
(
header::CONTENT_DISPOSITION,
"attachment; filename=\"leanctx-audit-log.csv\"",
),
],
body,
)
.into_response())
}
#[derive(Deserialize)]
pub(super) struct RegistryNamespaceBody {
namespace: String,
#[serde(default)]
org_id: Option<String>,
}
pub(super) async fn put_account_registry_namespace(
State(state): State<AppState>,
headers: HeaderMap,
Json(body): Json<RegistryNamespaceBody>,
) -> Result<Json<Value>, (StatusCode, String)> {
let (user_id, _email) = auth_user(&state, &headers).await?;
let (status, json) = billing_forward(
&state.cfg,
"PUT",
format!("/api/billing/registry/{user_id}/namespace"),
Some(json!({ "namespace": body.namespace, "org_id": body.org_id })),
)
.await?;
finish(status, json)
}
pub(super) async fn get_account_registry(
State(state): State<AppState>,
headers: HeaderMap,
) -> Result<Json<Value>, (StatusCode, String)> {
let (user_id, _email) = auth_user(&state, &headers).await?;
let (status, json) = billing_forward(
&state.cfg,
"GET",
format!("/api/billing/registry/{user_id}"),
None,
)
.await?;
finish(status, json)
}
#[derive(Deserialize, Default)]
pub(super) struct RegistryTokenBody {
#[serde(default)]
label: Option<String>,
#[serde(default)]
scope: Option<String>,
}
pub(super) async fn post_account_registry_token(
State(state): State<AppState>,
headers: HeaderMap,
Json(body): Json<RegistryTokenBody>,
) -> Result<Json<Value>, (StatusCode, String)> {
let (user_id, _email) = auth_user(&state, &headers).await?;
let (status, json) = billing_forward(
&state.cfg,
"POST",
format!("/api/billing/registry/{user_id}/tokens"),
Some(json!({ "label": body.label, "scope": body.scope })),
)
.await?;
finish(status, json)
}
pub(super) async fn delete_account_registry_token(
State(state): State<AppState>,
headers: HeaderMap,
Path(token_id): Path<i64>,
) -> Result<Json<Value>, (StatusCode, String)> {
let (user_id, _email) = auth_user(&state, &headers).await?;
let (status, json) = billing_forward(
&state.cfg,
"DELETE",
format!("/api/billing/registry/{user_id}/tokens/{token_id}"),
None,
)
.await?;
finish(status, json)
}
#[derive(Deserialize)]
pub(super) struct RegistryPriceBody {
name: String,
#[serde(default)]
price_cents: Option<i32>,
}
pub(super) async fn put_account_registry_price(
State(state): State<AppState>,
headers: HeaderMap,
Json(body): Json<RegistryPriceBody>,
) -> Result<Json<Value>, (StatusCode, String)> {
let (user_id, _email) = auth_user(&state, &headers).await?;
let (status, json) = billing_forward(
&state.cfg,
"PUT",
format!("/api/billing/registry/{user_id}/price"),
Some(json!({ "name": body.name, "price_cents": body.price_cents })),
)
.await?;
finish(status, json)
}
#[derive(Deserialize)]
pub(super) struct RegistryBuyBody {
namespace: String,
name: String,
}
pub(super) async fn post_account_registry_buy(
State(state): State<AppState>,
headers: HeaderMap,
Json(body): Json<RegistryBuyBody>,
) -> Result<Json<Value>, (StatusCode, String)> {
let (user_id, email) = auth_user(&state, &headers).await?;
let (status, json) = billing_forward(
&state.cfg,
"POST",
format!("/api/billing/registry/{user_id}/buy"),
Some(json!({
"namespace": body.namespace,
"name": body.name,
"email": email,
})),
)
.await?;
finish(status, json)
}
#[derive(Deserialize)]
pub(super) struct RegistryDomainBody {
domain: String,
}
pub(super) async fn post_account_registry_domain(
State(state): State<AppState>,
headers: HeaderMap,
Json(body): Json<RegistryDomainBody>,
) -> Result<Json<Value>, (StatusCode, String)> {
let (user_id, _email) = auth_user(&state, &headers).await?;
let (status, json) = billing_forward(
&state.cfg,
"POST",
format!("/api/billing/registry/{user_id}/domains"),
Some(json!({ "domain": body.domain })),
)
.await?;
finish(status, json)
}
pub(super) async fn post_account_registry_domain_verify(
State(state): State<AppState>,
headers: HeaderMap,
Path(domain_id): Path<i64>,
) -> Result<Json<Value>, (StatusCode, String)> {
let (user_id, _email) = auth_user(&state, &headers).await?;
let (status, json) = billing_forward(
&state.cfg,
"POST",
format!("/api/billing/registry/{user_id}/domains/{domain_id}/verify"),
None,
)
.await?;
finish(status, json)
}
pub(super) async fn delete_account_registry_domain(
State(state): State<AppState>,
headers: HeaderMap,
Path(domain_id): Path<i64>,
) -> Result<Json<Value>, (StatusCode, String)> {
let (user_id, _email) = auth_user(&state, &headers).await?;
let (status, json) = billing_forward(
&state.cfg,
"DELETE",
format!("/api/billing/registry/{user_id}/domains/{domain_id}"),
None,
)
.await?;
finish(status, json)
}
async fn billing_forward_text(
cfg: &Config,
path: String,
) -> Result<(StatusCode, String), (StatusCode, String)> {
let (Some(base), Some(key)) = (
cfg.billing_base_url.clone(),
cfg.billing_internal_key.clone(),
) else {
return Err((
StatusCode::SERVICE_UNAVAILABLE,
"billing is not enabled on this deployment".to_string(),
));
};
let url = format!("{base}{path}");
let (code, text) = tokio::task::spawn_blocking(move || -> Result<(u16, String), String> {
let agent: ureq::Agent = ureq::Agent::config_builder()
.http_status_as_error(false)
.build()
.into();
let resp = agent
.get(&url)
.header("X-Internal-Key", &key)
.call()
.map_err(|e| e.to_string())?;
let code = resp.status().as_u16();
let body = resp
.into_body()
.read_to_string()
.map_err(|e| e.to_string())?;
Ok((code, body))
})
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("join: {e}")))?
.map_err(|e| (StatusCode::BAD_GATEWAY, format!("billing upstream: {e}")))?;
let status = StatusCode::from_u16(code).unwrap_or(StatusCode::BAD_GATEWAY);
Ok((status, text))
}
pub(super) async fn get_supporters(State(state): State<AppState>) -> Json<Value> {
let empty = || json!({ "supporters": [], "count": 0 });
let (Some(base), Some(key)) = (
state.cfg.billing_base_url.clone(),
state.cfg.billing_internal_key.clone(),
) else {
return Json(empty());
};
let url = format!("{base}/api/billing/supporters");
let body = tokio::task::spawn_blocking(move || {
ureq::get(&url)
.header("X-Internal-Key", &key)
.call()
.ok()?
.into_body()
.read_to_string()
.ok()
})
.await
.ok()
.flatten();
match body.and_then(|b| serde_json::from_str::<Value>(&b).ok()) {
Some(v) => Json(v),
None => Json(empty()),
}
}
#[derive(Deserialize)]
pub(super) struct SupporterCheckoutBody {
#[serde(default)]
amount_cents: i64,
}
pub(super) async fn post_supporter_checkout(
State(state): State<AppState>,
Json(body): Json<SupporterCheckoutBody>,
) -> Result<Json<Value>, (StatusCode, String)> {
let amount = body.amount_cents.clamp(100, 100_000);
Ok(Json(
billing_post(
&state.cfg,
"/api/billing/supporters/checkout",
json!({ "amount_cents": amount }),
)
.await?,
))
}
pub(super) async fn delete_account_team_member(
State(state): State<AppState>,
headers: HeaderMap,
Path(token_id): Path<String>,
) -> Result<Json<Value>, (StatusCode, String)> {
let (user_id, _email) = auth_user(&state, &headers).await?;
let (status, json) = billing_forward(
&state.cfg,
"DELETE",
format!("/api/billing/team/{user_id}/tokens/{token_id}"),
None,
)
.await?;
if status.is_success() {
return Ok(Json(json!({ "revoked": true })));
}
finish(status, json)
}
pub(super) async fn post_account_team_seats(
State(state): State<AppState>,
headers: HeaderMap,
Json(body): Json<Value>,
) -> Result<Json<Value>, (StatusCode, String)> {
let (user_id, _email) = auth_user(&state, &headers).await?;
let (status, json) = billing_forward(
&state.cfg,
"POST",
format!("/api/billing/team/{user_id}/seats"),
Some(body),
)
.await?;
finish(status, json)
}
pub(super) async fn get_account_team_storage(
State(state): State<AppState>,
headers: HeaderMap,
) -> Result<Json<Value>, (StatusCode, String)> {
let (user_id, _email) = auth_user(&state, &headers).await?;
let (status, json) = billing_forward(
&state.cfg,
"GET",
format!("/api/billing/team/{user_id}/storage"),
None,
)
.await?;
finish(status, json)
}
pub(super) async fn get_account_team_connectors(
State(state): State<AppState>,
headers: HeaderMap,
) -> Result<Json<Value>, (StatusCode, String)> {
let (user_id, _email) = auth_user(&state, &headers).await?;
let (status, json) = billing_forward(
&state.cfg,
"GET",
format!("/api/billing/team/{user_id}/connectors"),
None,
)
.await?;
finish(status, json)
}
pub(super) async fn post_account_team_connector(
State(state): State<AppState>,
headers: HeaderMap,
Json(body): Json<Value>,
) -> Result<Json<Value>, (StatusCode, String)> {
let (user_id, _email) = auth_user(&state, &headers).await?;
let (status, json) = billing_forward(
&state.cfg,
"POST",
format!("/api/billing/team/{user_id}/connectors"),
Some(body),
)
.await?;
finish(status, json)
}
pub(super) async fn patch_account_team_connector(
State(state): State<AppState>,
headers: HeaderMap,
Path(connector_id): Path<String>,
Json(body): Json<Value>,
) -> Result<Json<Value>, (StatusCode, String)> {
let (user_id, _email) = auth_user(&state, &headers).await?;
let (status, json) = billing_forward(
&state.cfg,
"PATCH",
format!("/api/billing/team/{user_id}/connectors/{connector_id}"),
Some(body),
)
.await?;
if status.is_success() {
return Ok(Json(json!({ "updated": true })));
}
finish(status, json)
}
pub(super) async fn delete_account_team_connector(
State(state): State<AppState>,
headers: HeaderMap,
Path(connector_id): Path<String>,
) -> Result<Json<Value>, (StatusCode, String)> {
let (user_id, _email) = auth_user(&state, &headers).await?;
let (status, json) = billing_forward(
&state.cfg,
"DELETE",
format!("/api/billing/team/{user_id}/connectors/{connector_id}"),
None,
)
.await?;
if status.is_success() {
return Ok(Json(json!({ "deleted": true })));
}
finish(status, json)
}
#[cfg(test)]
mod tests {
use super::*;
fn cfg(billing: bool, sync_open: bool) -> Config {
Config {
bind_host: "127.0.0.1".into(),
bind_port: 8088,
public_base_url: String::new(),
api_base_url: String::new(),
database_url: String::new(),
ip_hash_salt: String::new(),
smtp_host: None,
smtp_port: None,
smtp_username: None,
smtp_password: None,
smtp_from: None,
billing_base_url: billing.then(|| "https://billing.example".to_string()),
billing_internal_key: billing.then(|| "internal-key".to_string()),
sync_open,
}
}
#[test]
fn gated_deployment_blocks_free_and_supporter_only() {
let gated = cfg(true, false);
assert!(!cloud_sync_allowed(&gated, Plan::Free));
assert!(!cloud_sync_allowed(&gated, Plan::Supporter));
assert!(cloud_sync_allowed(&gated, Plan::Pro));
assert!(cloud_sync_allowed(&gated, Plan::Team));
assert!(cloud_sync_allowed(&gated, Plan::Enterprise));
}
#[test]
fn no_billing_plane_never_gates_sync() {
let open = cfg(false, false);
assert!(sync_is_open(&open));
assert!(cloud_sync_allowed(&open, Plan::Free));
}
#[test]
fn operator_opt_out_opens_sync_even_with_billing() {
let opt_out = cfg(true, true);
assert!(sync_is_open(&opt_out));
assert!(cloud_sync_allowed(&opt_out, Plan::Free));
}
}