use std::collections::HashMap;
use std::sync::{LazyLock, Mutex, PoisonError};
use std::time::{Duration, Instant};
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)
}
const ENTITLEMENTS_CACHE_TTL: Duration = Duration::from_mins(1);
const ENTITLEMENTS_CACHE_MAX: usize = 50_000;
const ENTITLEMENTS_STALE_RETAIN: Duration = Duration::from_hours(1);
struct CachedEntitlements {
at: Instant,
value: Value,
}
type EntitlementsCacheSlot = Mutex<HashMap<Uuid, CachedEntitlements>>;
static ENTITLEMENTS_CACHE: LazyLock<EntitlementsCacheSlot> =
LazyLock::new(|| Mutex::new(HashMap::new()));
fn entitlements_cache_fresh(
slot: &EntitlementsCacheSlot,
user_id: Uuid,
now: Instant,
) -> Option<Value> {
let guard = slot.lock().unwrap_or_else(PoisonError::into_inner);
guard
.get(&user_id)
.filter(|e| now.duration_since(e.at) < ENTITLEMENTS_CACHE_TTL)
.map(|e| e.value.clone())
}
fn entitlements_cache_any(slot: &EntitlementsCacheSlot, user_id: Uuid) -> Option<Value> {
let guard = slot.lock().unwrap_or_else(PoisonError::into_inner);
guard.get(&user_id).map(|e| e.value.clone())
}
fn entitlements_cache_store(
slot: &EntitlementsCacheSlot,
user_id: Uuid,
now: Instant,
value: &Value,
) {
let mut guard = slot.lock().unwrap_or_else(PoisonError::into_inner);
guard.insert(
user_id,
CachedEntitlements {
at: now,
value: value.clone(),
},
);
if guard.len() > ENTITLEMENTS_CACHE_MAX {
prune_entitlements_cache(&mut guard, now);
}
}
fn prune_entitlements_cache(map: &mut HashMap<Uuid, CachedEntitlements>, now: Instant) {
map.retain(|_, e| now.duration_since(e.at) < ENTITLEMENTS_STALE_RETAIN);
}
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 now = Instant::now();
if let Some(cached) = entitlements_cache_fresh(&ENTITLEMENTS_CACHE, user_id, now) {
return Some(cached);
}
let url = format!("{base}/api/billing/entitlements/{user_id}");
let fetched = tokio::task::spawn_blocking(move || {
ureq::get(&url)
.header("X-Internal-Key", &key)
.call()
.ok()?
.into_body()
.read_to_string()
.ok()
})
.await
.ok()
.flatten()
.and_then(|body| serde_json::from_str::<Value>(&body).ok());
match fetched {
Some(value) => {
entitlements_cache_store(&ENTITLEMENTS_CACHE, user_id, now, &value);
Some(value)
}
None => entitlements_cache_any(&ENTITLEMENTS_CACHE, user_id),
}
}
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::config::Config::builder()
.tls_config(crate::core::http_client::platform_tls_config())
.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))
}
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::config::Config::builder()
.tls_config(crate::core::http_client::platform_tls_config())
.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))
}
mod org;
pub(crate) use org::*;
mod registry;
pub(crate) use registry::*;
mod supporters;
pub(crate) use supporters::*;
mod team;
pub(crate) use team::*;
#[cfg(test)]
mod tests;