Files
invoiceninja-cli/src/commands/credits.rs
T
awkawbandClaude Sonnet 5 1236a15ebb
CI / test (push) Canceled after 0s
CI / Build on the declared MSRV (push) Canceled after 0s
CI / Latest stable (advisory) (push) Canceled after 0s
Add entity action and PDF download to invoices/quotes/credits/purchase-orders
These four resources are the only ones with the SDK's
entity_action_methods! route. Adds:

- `action <id> <action>` - mark_sent, mark_paid, archive, restore,
  clone_to_invoice, clone_to_quote, auto_bill, delete (routed), returning
  the updated record.
- `download <id>...` - the rendered PDF, to stdout or `--out <path>`;
  more than one id streams a single merged PDF (Invoice Ninja's bulk
  download action).

New EntityResourceArgs/EntityAction in cli.rs rather than extending the
existing ResourceArgs/Action: Rust enums don't compose, and every
resource's `run` match on Action needs to stay exhaustive without a
catch-all arm for variants it doesn't support.

Found and fixed along the way: the Download variant's --output <path>
field collided by name with the global --output table|json flag,
causing clap to panic at runtime trying to downcast the wrong type.
Renamed to --out.

Extended tests/nixos-test.nix to exercise action/download for real
against the VM. That caught a test-ordering bug of its own - asserting
mark_sent on an invoice *after* paying it off, which correctly doesn't
revert status_id from paid back to sent - fixed by reordering the
subtests. nix flake check passes clean.

Also backfills two CHANGELOG.md gaps from earlier sessions: the VM test
and CI additions had never actually been logged there.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-26 17:29:00 -04:00

173 lines
5.1 KiB
Rust

//! `credits` subcommand. Same shape as `clients.rs` — see the "Adding a
//! resource" section in CLAUDE.md. `Credit` is a type alias for `Invoice`
//! (Invoice Ninja stores credits in the same table/schema, distinguished by
//! an `entity_type`), same as `quotes.rs`.
use std::io::Write;
use std::path::PathBuf;
use invoiceninja_sdk::models::{Credit, ListParams};
use invoiceninja_sdk::InvoiceNinjaClient;
use crate::body;
use crate::cli::{BodyArgs, EntityAction, EntityResourceArgs, ListArgs, OutputFormat};
use crate::output;
pub async fn run(
client: &InvoiceNinjaClient,
args: EntityResourceArgs,
format: OutputFormat,
) -> Result<(), Box<dyn std::error::Error>> {
match args.action {
EntityAction::List(list_args) => list(client, list_args, format).await,
EntityAction::Get { id } => get(client, &id, format).await,
EntityAction::Create(body_args) => create(client, body_args, format).await,
EntityAction::Update { id, body } => update(client, &id, body, format).await,
EntityAction::Delete { id } => delete(client, &id).await,
EntityAction::Action { id, action } => do_action(client, &id, &action, format).await,
EntityAction::Download { ids, out } => download(client, &ids, out).await,
}
}
async fn list(
client: &InvoiceNinjaClient,
args: ListArgs,
format: OutputFormat,
) -> Result<(), Box<dyn std::error::Error>> {
let mut params = ListParams::new();
if let Some(page) = args.page {
params = params.page(page);
}
if let Some(per_page) = args.per_page {
params = params.per_page(per_page);
}
if let Some(sort) = args.sort {
params = params.sort(sort);
}
if let Some(filter) = args.filter {
params = params.filter(filter);
}
if let Some(status) = args.status {
params = params.status(status);
}
if let Some(include) = args.include {
params = params.include(include);
}
for (k, v) in args.params {
params = params.param(k, v);
}
let credits = if args.all {
client.list_all_credits(&params).await?
} else {
client.list_credits(&params).await?
};
render(&credits, format)
}
async fn get(
client: &InvoiceNinjaClient,
id: &str,
format: OutputFormat,
) -> Result<(), Box<dyn std::error::Error>> {
let found = client.get_credit(id).await?;
render(std::slice::from_ref(&found), format)
}
async fn create(
client: &InvoiceNinjaClient,
body_args: BodyArgs,
format: OutputFormat,
) -> Result<(), Box<dyn std::error::Error>> {
let value = body::load(&body_args)?;
let model: Credit = serde_json::from_value(value)?;
let created = client.create_credit(&model).await?;
render(std::slice::from_ref(&created), format)
}
async fn update(
client: &InvoiceNinjaClient,
id: &str,
body_args: BodyArgs,
format: OutputFormat,
) -> Result<(), Box<dyn std::error::Error>> {
let patch = body::load(&body_args)?;
let current = client.get_credit(id).await?;
let merged = body::merge_shallow(serde_json::to_value(&current)?, patch);
let model: Credit = serde_json::from_value(merged)?;
let updated = client.update_credit(id, &model).await?;
render(std::slice::from_ref(&updated), format)
}
async fn delete(client: &InvoiceNinjaClient, id: &str) -> Result<(), Box<dyn std::error::Error>> {
client.delete_credit(id).await?;
println!("deleted {id}");
Ok(())
}
async fn do_action(
client: &InvoiceNinjaClient,
id: &str,
action: &str,
format: OutputFormat,
) -> Result<(), Box<dyn std::error::Error>> {
let updated = client.credit_action(id, action).await?;
render(std::slice::from_ref(&updated), format)
}
async fn download(
client: &InvoiceNinjaClient,
ids: &[String],
out: Option<PathBuf>,
) -> Result<(), Box<dyn std::error::Error>> {
let bytes = if ids.len() == 1 {
client.download_credit(&ids[0]).await?
} else {
client.download_credits(ids).await?
};
match out {
Some(path) => std::fs::write(path, bytes)?,
None => std::io::stdout().write_all(&bytes)?,
}
Ok(())
}
fn render(credits: &[Credit], format: OutputFormat) -> Result<(), Box<dyn std::error::Error>> {
match format {
OutputFormat::Json => output::print_json(credits)?,
OutputFormat::Table => print_table(credits),
}
Ok(())
}
fn print_table(credits: &[Credit]) {
if credits.is_empty() {
println!("(no records)");
return;
}
println!(
"{:<20} {:<15} {:<20} {:<8} {:>12} {:>12}",
"id", "number", "client_id", "status", "amount", "balance"
);
for c in credits {
println!(
"{:<20} {:<15} {:<20} {:<8} {:>12.2} {:>12.2}",
c.id.as_deref().unwrap_or(""),
truncate(&c.number, 15),
truncate(&c.client_id, 20),
c.status_id,
c.amount,
c.balance
);
}
}
fn truncate(s: &str, max: usize) -> String {
if s.chars().count() <= max {
s.to_string()
} else {
s.chars().take(max.saturating_sub(1)).collect::<String>() + "…"
}
}