use anyhow::{bail, Context, Result};
use serde_json::{json, Value};
use crate::api::client::ApiClient;
use crate::api::error::ApiError;
use crate::config::credentials::resolve_api_key;
use crate::config::manager::ConfigManager;
use crate::model::loader::Models;
use super::dispatch::{print_output_with_opts, OutputOpts};
use super::formatter;
fn resolve_base_url(models: &Models) -> String {
models
.control_plane
.endpoint
.clone()
.unwrap_or_else(|| "https://api.cloud.zilliz.com".to_string())
}
pub async fn usage(
models: &Models,
config_mgr: &ConfigManager,
raw_args: &[String],
output_opts: &OutputOpts<'_>,
) -> Result<()> {
if raw_args.iter().any(|a| a == "-h" || a == "--help") {
print!(
"Show billing usage summary.\n\n\
Usage: zz billing usage [OPTIONS]\n\n\
Options:\n\
\x20 {:24}{:30}Month: YYYY-MM, 'this', or 'last' [default: today]\n\
\x20 {:24}{:30}Relative period: 7d, 1m, 1y\n\
\x20 {:24}{:30}Start time (ISO 8601 or YYYY-MM-DD)\n\
\x20 {:24}{:30}End time (ISO 8601 or YYYY-MM-DD)\n",
"--month", "<string>",
"--last", "<string>",
"--start", "<string>",
"--end", "<string>",
);
return Ok(());
}
let mut month: Option<String> = None;
let mut last: Option<String> = None;
let mut start: Option<String> = None;
let mut end: Option<String> = None;
let mut i = 0;
while i < raw_args.len() {
match raw_args[i].as_str() {
"--month" => {
i += 1;
month = raw_args.get(i).cloned();
}
"--last" => {
i += 1;
last = raw_args.get(i).cloned();
}
"--start" => {
i += 1;
start = raw_args.get(i).cloned();
}
"--end" => {
i += 1;
end = raw_args.get(i).cloned();
}
_ => {}
}
i += 1;
}
let (start_ts, end_ts) = resolve_time_range(month, last, start, end)?;
let api_key = resolve_api_key(output_opts.api_key, config_mgr).ok_or(ApiError::NoApiKey)?;
let client = ApiClient::new(api_key, resolve_base_url(models));
let body = json!({
"start": start_ts,
"end": end_ts,
});
let result = client.call("POST", "/v2/usage/query", None, Some(&body)).await?;
print_output_with_opts(&result, output_opts, None);
Ok(())
}
pub async fn invoices(
models: &Models,
config_mgr: &ConfigManager,
raw_args: &[String],
output_opts: &OutputOpts<'_>,
) -> Result<()> {
if raw_args.iter().any(|a| a == "-h" || a == "--help") {
print!(
"List invoices.\n\n\
Usage: zz billing invoices [OPTIONS]\n\n\
Options:\n\
\x20 {:24}{:30}Number of invoices per page [default: 20]\n\
\x20 {:24}{:30}Page number [default: 1]\n",
"--page-size", "<integer>",
"--page", "<integer>",
);
return Ok(());
}
let mut page_size = 20;
let mut page = 1;
let mut i = 0;
while i < raw_args.len() {
match raw_args[i].as_str() {
"--page-size" => {
i += 1;
if let Some(v) = raw_args.get(i) {
page_size = v.parse().unwrap_or(20);
}
}
"--page" => {
i += 1;
if let Some(v) = raw_args.get(i) {
page = v.parse().unwrap_or(1);
}
}
_ => {}
}
i += 1;
}
let api_key = resolve_api_key(output_opts.api_key, config_mgr).ok_or(ApiError::NoApiKey)?;
let client = ApiClient::new(api_key, resolve_base_url(models));
let body = json!({
"pageSize": page_size,
"currentPage": page,
});
let result = client.call("GET", "/v2/invoices", None, Some(&body)).await?;
if output_opts.format == "table" && output_opts.query.is_none() {
print_invoice_table(&result, output_opts.no_header);
} else {
print_output_with_opts(&result, output_opts, None);
}
Ok(())
}
fn print_invoice_table(result: &Value, no_header: bool) {
let invoices = result
.get("invoices")
.and_then(|v| v.as_array())
.cloned()
.unwrap_or_default();
if invoices.is_empty() {
println!("No invoices found.");
return;
}
let columns = &["Invoice ID", "Period", "Status", "Amount Due", "Due Date"];
let is_tty = super::formatter::terminal_width().is_some();
let rows: Vec<Vec<String>> = invoices
.iter()
.map(|inv| {
let id = inv.get("id").and_then(|v| v.as_str()).unwrap_or("").to_string();
let period_start = inv
.get("periodStart")
.and_then(|v| v.as_str())
.unwrap_or("");
let period_end = inv
.get("periodEnd")
.and_then(|v| v.as_str())
.unwrap_or("");
let start_month = period_start.get(..7).unwrap_or("");
let end_month = period_end.get(..7).unwrap_or("");
let period = if start_month == end_month || end_month.is_empty() {
start_month.to_string()
} else {
format!("{} ~ {}", start_month, end_month)
};
let status_raw = inv.get("status").and_then(|v| v.as_str()).unwrap_or("");
let status = if is_tty && status_raw == "paid" {
format!("\x1b[32m{}\x1b[0m", status_raw)
} else {
status_raw.to_string()
};
let amount_cents = inv
.get("amountDue")
.and_then(|v| v.as_f64())
.unwrap_or(0.0);
let amount = if is_tty {
format!("\x1b[32m${:.2}\x1b[0m", amount_cents / 100.0)
} else {
format!("${:.2}", amount_cents / 100.0)
};
let due_date = inv
.get("dueDate")
.and_then(|v| v.as_str())
.unwrap_or("");
let due_date_short = due_date.get(..10).unwrap_or(due_date).to_string();
vec![id, period, status, amount, due_date_short]
})
.collect();
let mut table = formatter::create_table(columns, no_header);
for row in rows {
table.add_row(row);
}
println!("{}", table);
}
fn resolve_time_range(
month: Option<String>,
last: Option<String>,
start: Option<String>,
end: Option<String>,
) -> Result<(String, String)> {
use chrono::{Datelike, Local, NaiveDate};
let now = Local::now();
if let Some(ref l) = last {
let (num, unit) = parse_relative_duration(l)?;
let days = match unit {
'd' => num,
'm' => num * 30,
'y' => num * 365,
_ => bail!("Invalid unit in --last. Use d (days), m (months), y (years)"),
};
let start_date = now - chrono::Duration::days(days as i64);
return Ok((
start_date.format("%Y-%m-%dT00:00:00Z").to_string(),
now.format("%Y-%m-%dT23:59:59Z").to_string(),
));
}
if let Some(ref m) = month {
let (year, mon) = match m.as_str() {
"this" => (now.year(), now.month()),
"last" => {
if now.month() == 1 {
(now.year() - 1, 12)
} else {
(now.year(), now.month() - 1)
}
}
_ => {
let parts: Vec<&str> = m.split('-').collect();
if parts.len() != 2 {
bail!("Invalid --month format. Use YYYY-MM, 'this', or 'last'");
}
let y: i32 = parts[0].parse().context("Invalid year")?;
let mo: u32 = parts[1].parse().context("Invalid month")?;
(y, mo)
}
};
let start_date = NaiveDate::from_ymd_opt(year, mon, 1).context("Invalid date")?;
let end_date = if mon == 12 {
NaiveDate::from_ymd_opt(year + 1, 1, 1)
} else {
NaiveDate::from_ymd_opt(year, mon + 1, 1)
}
.context("Invalid date")?
- chrono::Duration::days(1);
return Ok((
format!("{}T00:00:00Z", start_date),
format!("{}T23:59:59Z", end_date),
));
}
if let (Some(s), Some(e)) = (start, end) {
let s = normalize_ts(&s);
let e = normalize_ts(&e);
return Ok((s, e));
}
Ok((
now.format("%Y-%m-%dT00:00:00Z").to_string(),
now.format("%Y-%m-%dT23:59:59Z").to_string(),
))
}
fn normalize_ts(ts: &str) -> String {
let ts = ts.trim();
if ts.len() == 10 {
format!("{}T00:00:00Z", ts)
} else if ts.ends_with('Z') {
ts.to_string()
} else {
format!("{}Z", ts)
}
}
fn parse_relative_duration(s: &str) -> Result<(u64, char)> {
let s = s.trim();
if s.is_empty() {
bail!("Empty duration");
}
let unit = s.chars().last().unwrap().to_ascii_lowercase();
let num: u64 = s[..s.len() - 1]
.parse()
.context("Invalid number in duration")?;
Ok((num, unit))
}