use std::path::{Path, PathBuf};
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, config_mgr: &ConfigManager) -> String {
super::endpoint::resolve_control_plane_url(config_mgr, &models.control_plane, None)
}
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: zilliz 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, config_mgr));
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: zilliz 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, config_mgr));
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_pdf_path(
invoice_id: &str,
output_file: Option<&str>,
output_dir: Option<&str>,
) -> Result<PathBuf> {
if output_file.is_some() && output_dir.is_some() {
bail!("--output-file and --dir are mutually exclusive.");
}
let mut dest = if let Some(f) = output_file {
let p = PathBuf::from(f);
if p.extension()
.is_none_or(|ext| !ext.eq_ignore_ascii_case("pdf"))
{
p.with_extension(format!(
"{}pdf",
p.extension()
.map_or(String::new(), |e| format!("{}.", e.to_string_lossy()))
))
} else {
p
}
} else if let Some(d) = output_dir {
Path::new(d).join(format!("{}.pdf", invoice_id))
} else {
PathBuf::from(format!("{}.pdf", invoice_id))
};
if let Some(parent) = dest.parent() {
if !parent.as_os_str().is_empty() && !parent.exists() {
std::fs::create_dir_all(parent)
.with_context(|| format!("Failed to create directory: {}", parent.display()))?;
}
}
if dest.exists() {
let stem = dest
.file_stem()
.unwrap_or_default()
.to_string_lossy()
.to_string();
let parent = dest.parent().unwrap_or(Path::new("."));
let mut counter = 1;
loop {
let candidate = parent.join(format!("{}({}).pdf", stem, counter));
if !candidate.exists() {
dest = candidate;
break;
}
counter += 1;
}
}
Ok(dest)
}
pub async fn download_invoice(
models: &Models,
config_mgr: &ConfigManager,
raw_args: &[String],
output_opts: &OutputOpts<'_>,
) -> Result<()> {
if raw_args.iter().any(|a| a == "-h" || a == "--help") {
print!(
"Download an invoice PDF.\n\n\
Usage: zilliz billing download-invoice [OPTIONS]\n\n\
Options:\n\
\x20 {:24}{:30}The invoice ID (required)\n\
\x20 {:24}{:30}Output file path (auto-appends .pdf)\n\
\x20 {:24}{:30}Directory to save the PDF\n\
\n\
Examples:\n\
\x20 zilliz billing download-invoice --invoice-id inv-xxxx\n\
\x20 zilliz billing download-invoice --invoice-id inv-xxxx -d ~/Downloads\n\
\x20 zilliz billing download-invoice --invoice-id inv-xxxx -o ~/Downloads/march.pdf\n",
"--invoice-id", "<string>", "-o, --output-file", "<path>", "-d, --dir", "<path>",
);
return Ok(());
}
let mut invoice_id: Option<String> = None;
let mut output_file: Option<String> = None;
let mut output_dir: Option<String> = None;
let mut i = 0;
while i < raw_args.len() {
match raw_args[i].as_str() {
"--invoice-id" => {
i += 1;
invoice_id = raw_args.get(i).cloned();
}
"-o" | "--output-file" => {
i += 1;
output_file = raw_args.get(i).cloned();
}
"-d" | "--dir" => {
i += 1;
output_dir = raw_args.get(i).cloned();
}
_ => {}
}
i += 1;
}
let invoice_id = invoice_id.ok_or_else(|| {
anyhow::anyhow!(
"--invoice-id is required. Use 'zilliz billing invoices' to list available IDs."
)
})?;
let dest = resolve_pdf_path(&invoice_id, output_file.as_deref(), output_dir.as_deref())?;
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, config_mgr));
let path = format!(
"/v2/invoices/{}/downloadPdf",
urlencoding::encode(&invoice_id)
);
let result = client.call("GET", &path, None, None).await?;
let b64_str = result
.as_str()
.ok_or_else(|| anyhow::anyhow!("Empty response from server -- no PDF data received."))?;
if b64_str.is_empty() {
bail!("Empty response from server -- no PDF data received.");
}
use base64::Engine;
let pdf_bytes = base64::engine::general_purpose::STANDARD
.decode(b64_str)
.context("Failed to decode invoice PDF data.")?;
std::fs::write(&dest, &pdf_bytes)
.with_context(|| format!("Failed to write PDF to {}", dest.display()))?;
println!("Saved to {}", dest.display());
Ok(())
}
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))
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
#[test]
fn test_resolve_pdf_path_default() {
let dir = TempDir::new().unwrap();
let cwd = dir.path();
let result = resolve_pdf_path("inv-abc123", None, None).unwrap();
assert_eq!(result, PathBuf::from("inv-abc123.pdf"));
let _ = cwd; }
#[test]
fn test_resolve_pdf_path_output_file_with_pdf() {
let dir = TempDir::new().unwrap();
let dest = dir.path().join("report.pdf");
let result = resolve_pdf_path("inv-abc123", Some(dest.to_str().unwrap()), None).unwrap();
assert_eq!(result, dest);
}
#[test]
fn test_resolve_pdf_path_output_file_without_pdf() {
let dir = TempDir::new().unwrap();
let dest = dir.path().join("report");
let result = resolve_pdf_path("inv-abc123", Some(dest.to_str().unwrap()), None).unwrap();
assert_eq!(result, dir.path().join("report.pdf"));
}
#[test]
fn test_resolve_pdf_path_dir() {
let dir = TempDir::new().unwrap();
let result =
resolve_pdf_path("inv-abc123", None, Some(dir.path().to_str().unwrap())).unwrap();
assert_eq!(result, dir.path().join("inv-abc123.pdf"));
}
#[test]
fn test_resolve_pdf_path_mutual_exclusivity() {
let result = resolve_pdf_path("inv-abc123", Some("file.pdf"), Some("/tmp"));
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("mutually exclusive"));
}
#[test]
fn test_resolve_pdf_path_collision_avoidance() {
let dir = TempDir::new().unwrap();
let original = dir.path().join("inv-abc123.pdf");
fs::write(&original, b"existing").unwrap();
let result =
resolve_pdf_path("inv-abc123", None, Some(dir.path().to_str().unwrap())).unwrap();
assert_eq!(result, dir.path().join("inv-abc123(1).pdf"));
fs::write(&result, b"existing").unwrap();
let result2 =
resolve_pdf_path("inv-abc123", None, Some(dir.path().to_str().unwrap())).unwrap();
assert_eq!(result2, dir.path().join("inv-abc123(2).pdf"));
}
#[test]
fn test_resolve_pdf_path_creates_parent_dir() {
let dir = TempDir::new().unwrap();
let dest = dir.path().join("new-subdir").join("invoice.pdf");
let result = resolve_pdf_path("inv-abc123", Some(dest.to_str().unwrap()), None).unwrap();
assert_eq!(result, dest);
assert!(dest.parent().unwrap().exists());
}
}