Files
Aether/apps/aether-gateway/src/handlers/admin/billing/provider_accounts.rs
T

160 lines
6.6 KiB
Rust
Raw Normal View History

//! Current provider finance snapshots. This endpoint never calls upstream services.
use super::build_admin_billing_data_unavailable_response;
use crate::handlers::admin::request::{AdminAppState, AdminRequestContext};
use crate::GatewayError;
use axum::{
body::Body,
http,
response::{IntoResponse, Response},
Json,
};
use serde_json::{json, Value};
fn finite(value: Option<&Value>) -> Option<f64> {
value
.and_then(|v| {
v.as_f64()
.or_else(|| v.as_str().and_then(|v| v.parse::<f64>().ok()))
})
.filter(|v| v.is_finite())
}
fn text(value: Option<&Value>) -> Option<&str> {
value
.and_then(Value::as_str)
.map(str::trim)
.filter(|v| !v.is_empty() && v.len() <= 256 && !v.chars().any(char::is_control))
}
fn timestamp(value: Option<&Value>) -> Option<String> {
let value = value?;
if let Some(raw) = value.as_str() {
if let Ok(date) = chrono::DateTime::parse_from_rfc3339(raw) {
return Some(date.to_rfc3339_opts(chrono::SecondsFormat::Millis, true));
}
}
let secs = finite(Some(value))?;
if !(0.0..=253_402_300_799.0).contains(&secs) {
return None;
}
chrono::DateTime::from_timestamp(secs as i64, 0)
.map(|v| v.to_rfc3339_opts(chrono::SecondsFormat::Millis, true))
}
fn subscription(value: &Value) -> Value {
json!({
"group_name": text(value.get("group_name")),
"status": text(value.get("status")),
"daily_used_usd": finite(value.get("daily_used_usd")),
"daily_limit_usd": finite(value.get("daily_limit_usd")),
"weekly_used_usd": finite(value.get("weekly_used_usd")),
"weekly_limit_usd": finite(value.get("weekly_limit_usd")),
"monthly_used_usd": finite(value.get("monthly_used_usd")),
"monthly_limit_usd": finite(value.get("monthly_limit_usd")),
"expires_at": timestamp(value.get("expires_at")),
})
}
fn balance(value: &Value) -> Option<Value> {
if value.get("action_type").and_then(Value::as_str) != Some("query_balance") {
return None;
}
let status = text(value.get("status"))?;
if !matches!(status, "success" | "auth_expired" | "auth_failed") {
return None;
}
let data = value
.get("data")
.filter(|_| matches!(status, "success" | "auth_expired"));
let extra = data.and_then(|d| d.get("extra"));
let subscriptions = extra
.and_then(|e| e.get("subscriptions"))
.and_then(Value::as_array)
.map(|items| {
items
.iter()
.filter(|v| v.is_object())
.take(128)
.map(subscription)
.collect::<Vec<_>>()
})
.unwrap_or_default();
Some(json!({
"status": status,
"observed_at": timestamp(value.get("executed_at")),
"currency": data.and_then(|d| text(d.get("currency"))),
"available": data.and_then(|d| finite(d.get("total_available"))),
"used": data.and_then(|d| finite(d.get("total_used"))),
"granted": data.and_then(|d| finite(d.get("total_granted"))),
"plan_name": extra.and_then(|e| text(e.get("plan_name"))),
"subscriptions": subscriptions,
}))
}
pub(super) async fn response(
state: &AdminAppState<'_>,
context: &AdminRequestContext<'_>,
) -> Result<Option<Response<Body>>, GatewayError> {
if context.method() != http::Method::GET
|| context.path().trim_end_matches('/') != "/api/admin/billing/provider-accounts"
|| context.route_family() != Some("billing_manage")
{
return Ok(None);
}
if !state.has_provider_catalog_data_reader() {
return Ok(Some(build_admin_billing_data_unavailable_response()));
}
let mut providers = state.list_provider_catalog_providers(false).await?;
providers.sort_by(|a, b| a.name.cmp(&b.name).then_with(|| a.id.cmp(&b.id)));
let keys = providers
.iter()
.map(|p| format!("provider_ops:balance:{}", p.id))
.collect::<Vec<_>>();
let (cached, unavailable) = if keys.is_empty() {
(Vec::new(), false)
} else {
match state.runtime_state().kv_get_many(&keys).await {
Ok(v) => (v, false),
Err(_) => (vec![None; keys.len()], true),
}
};
let items = providers.iter().enumerate().map(|(index, p)| {
let limit = p.monthly_quota_usd.filter(|v| v.is_finite() && *v >= 0.0);
let used = p.monthly_used_usd.filter(|v| v.is_finite() && *v >= 0.0);
let quota = if p.billing_type.as_deref() == Some("monthly_quota") || limit.is_some() {
json!({
"limit": limit, "used": used,
"remaining": limit.zip(used).map(|(l,u)| (l-u).max(0.0)),
"currency": "USD",
"period_start": p.quota_last_reset_at_unix_secs.and_then(|v| timestamp(Some(&json!(v)))),
"expires_at": p.quota_expires_at_unix_secs.and_then(|v| timestamp(Some(&json!(v)))),
})
} else { Value::Null };
let balance = cached.get(index).and_then(|v| v.as_deref())
.and_then(|v| serde_json::from_str::<Value>(v).ok()).and_then(|v| balance(&v));
json!({
"provider_id": p.id, "provider_name": p.name, "is_active": p.is_active,
"billing_type": p.billing_type, "quota": quota, "balance": balance,
})
}).collect::<Vec<_>>();
Ok(Some((
[(http::header::CACHE_CONTROL, "private, no-store")],
Json(json!({
"observed_at": chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis,true),
"items": items, "balance_snapshot_unavailable": unavailable,
})),
).into_response()))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn provider_accounts_only_expose_finance_allowlist_and_preserve_unknown() {
let snapshot=balance(&json!({"status":"success","action_type":"query_balance","executed_at":"2026-09-20T00:00:00Z","data":{"currency":"USD","total_available":null,"extra":{"access_token":"secret","plan_name":"Pro","subscriptions":[{"group_name":"Team","monthly_used_usd":"12.25","expires_at":1800000000,"private_token":"secret"}]}}})).unwrap();
assert!(snapshot["available"].is_null());
assert_eq!(
snapshot["subscriptions"][0]["monthly_used_usd"],
json!(12.25)
);
assert!(!snapshot.to_string().contains("secret"));
assert!(!snapshot.to_string().contains("access_token"));
let failed=balance(&json!({"status":"auth_failed","action_type":"query_balance","data":{"total_available":999}})).unwrap();
assert!(failed["available"].is_null());
}
}