diff --git a/CHANGELOG.md b/CHANGELOG.md index 0bf8cc3..9bc2127 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,3 +43,13 @@ command line. - CI on GitHub Actions, mirroring `invoiceninja-sdk`'s own `ci.yml`: a pinned-toolchain `test` job, a build-only `msrv` job on the declared 1.88 floor, and an advisory `stable` job. +- `nixosConfigurations.invoiceninja-test-nginx`/`-caddy` (re-exported from + `invoiceninja-nixos`) and an automated `checks.invoiceninja-cli` nixosTest + that boots a real Invoice Ninja instance and runs this CLI's own binary + against it. See `CLAUDE.md`'s "Testing against a real server". +- `invoices`/`quotes`/`credits`/`purchase-orders` gain `action + ` (entity actions — `mark_sent`, `mark_paid`, `archive`, + `restore`, `clone_to_invoice`, `clone_to_quote`, `auto_bill`, `delete`) + and `download ...` (the rendered PDF, to stdout or `--out `; + more than one id streams a single merged PDF). These four are the only + resources with the SDK's `entity_action_methods!` route. diff --git a/CLAUDE.md b/CLAUDE.md index e3262d5..cc53bc7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -242,6 +242,22 @@ Each resource beyond `clients` should follow the same shape: either way; that's a separate addition. Resources with entity actions, document uploads, or non-CRUD endpoints -(reports, charts, search, statics, PDF downloads, …) will need more than -this template once they're added — the SDK's own `CLAUDE.md` describes which -resources have which extras. +(reports, charts, search, statics, …) will need more than this template +once they're added — the SDK's own `CLAUDE.md` describes which resources +have which extras. + +**Entity actions and PDF download are done** for the four resources that +have the SDK's `entity_action_methods!` route (`invoices`, `quotes`, +`credits`, `purchase_orders`): `EntityResourceArgs`/`EntityAction` in +`src/cli.rs` is a second, parallel `ResourceArgs`/`Action` that adds +`action ` and `download ... [--out ]`. It +duplicates `Action`'s five base variants rather than extending it — 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. +If a fifth resource ever gains this route, give it `EntityResourceArgs` +too rather than writing a third near-duplicate enum. + +Still not done: bulk actions (`bulk_` — GOALS.md's open question +on this covers why a plain pass-through isn't obviously right for bulk +`delete`), document uploads (`upload_`), and the one-off +non-CRUD endpoints (`statics`, `search`, `report`, `download_document`). diff --git a/src/cli.rs b/src/cli.rs index 989a62f..47637d3 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -54,11 +54,11 @@ pub enum Command { /// Manage clients Clients(ResourceArgs), /// Manage invoices - Invoices(ResourceArgs), + Invoices(EntityResourceArgs), /// Manage quotes - Quotes(ResourceArgs), + Quotes(EntityResourceArgs), /// Manage credits - Credits(ResourceArgs), + Credits(EntityResourceArgs), /// Manage payments Payments(ResourceArgs), /// Manage products @@ -72,7 +72,7 @@ pub enum Command { /// Manage vendors Vendors(ResourceArgs), /// Manage purchase orders - PurchaseOrders(ResourceArgs), + PurchaseOrders(EntityResourceArgs), /// Manage recurring invoices RecurringInvoices(ResourceArgs), /// Manage recurring quotes @@ -149,6 +149,61 @@ pub enum Action { Delete { id: String }, } +/// Like `ResourceArgs`, but for the four resources with an entity-action +/// route (`invoices`, `quotes`, `credits`, `purchase_orders` — the SDK's +/// `entity_action_methods!`): adds `action` and `download`. +#[derive(Args)] +pub struct EntityResourceArgs { + #[command(subcommand)] + pub action: EntityAction, +} + +/// `Action` plus the two entity-action-route subcommands. Duplicates +/// `Action`'s five variants rather than extending it, since Rust enums +/// don't compose that way and every resource's `run` match on `Action` +/// needs to stay exhaustive without a catch-all arm for variants it +/// doesn't support. +#[derive(Subcommand)] +pub enum EntityAction { + /// List records (one page by default; pass --all to follow pagination) + List(ListArgs), + /// Fetch one record by id + Get { id: String }, + /// Create a record from a JSON body + Create(BodyArgs), + /// Update a record from a JSON body, keeping fields you don't mention. + Update { + id: String, + #[command(flatten)] + body: BodyArgs, + }, + /// Delete (soft-delete) a record by id + Delete { id: String }, + /// Perform an entity action, returning the updated record. + /// + /// Known to work (returns the entity): auto_bill, clone_to_invoice, + /// clone_to_quote, mark_paid, mark_sent, archive, restore, delete (same + /// effect as the `delete` subcommand, just routed differently). Three + /// others on this route do *not* work here and will fail to decode: + /// `download` (use the `download` subcommand instead), + /// `convert_to_purchase_order` (not wrapped by the SDK yet), and + /// `history`/`delivery_note`/`cancel` (Invoice Ninja answers those with + /// an empty body, not the record). + Action { id: String, action: String }, + /// Download the rendered PDF for one or more records, to stdout unless + /// `--output` is given. + /// + /// With more than one id, Invoice Ninja streams a single PDF covering + /// all of them (the bulk `download` action) rather than one per id. + Download { + ids: Vec, + /// Not `--output`: that's the global table/json flag, and clap + /// cannot have two differently-typed args share that identifier. + #[arg(long)] + out: Option, + }, +} + /// Mirrors the parameters every `ListParams` accepts in the SDK — see /// `invoiceninja_sdk::models::ListParams`. #[derive(Args)] diff --git a/src/commands/credits.rs b/src/commands/credits.rs index 1e5dfd3..37cfab1 100644 --- a/src/commands/credits.rs +++ b/src/commands/credits.rs @@ -3,24 +3,29 @@ //! (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::{Action, BodyArgs, ListArgs, OutputFormat, ResourceArgs}; +use crate::cli::{BodyArgs, EntityAction, EntityResourceArgs, ListArgs, OutputFormat}; use crate::output; pub async fn run( client: &InvoiceNinjaClient, - args: ResourceArgs, + args: EntityResourceArgs, format: OutputFormat, ) -> Result<(), Box> { match args.action { - Action::List(list_args) => list(client, list_args, format).await, - Action::Get { id } => get(client, &id, format).await, - Action::Create(body_args) => create(client, body_args, format).await, - Action::Update { id, body } => update(client, &id, body, format).await, - Action::Delete { id } => delete(client, &id).await, + 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, } } @@ -101,6 +106,33 @@ async fn delete(client: &InvoiceNinjaClient, id: &str) -> Result<(), Box Result<(), Box> { + let updated = client.credit_action(id, action).await?; + render(std::slice::from_ref(&updated), format) +} + +async fn download( + client: &InvoiceNinjaClient, + ids: &[String], + out: Option, +) -> Result<(), Box> { + 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> { match format { OutputFormat::Json => output::print_json(credits)?, diff --git a/src/commands/invoices.rs b/src/commands/invoices.rs index 84c4e5c..8522ff4 100644 --- a/src/commands/invoices.rs +++ b/src/commands/invoices.rs @@ -1,24 +1,29 @@ //! `invoices` subcommand. Same shape as `clients.rs` — see the "Adding a //! resource" section in CLAUDE.md. +use std::io::Write; +use std::path::PathBuf; + use invoiceninja_sdk::models::{Invoice, ListParams}; use invoiceninja_sdk::InvoiceNinjaClient; use crate::body; -use crate::cli::{Action, BodyArgs, ListArgs, OutputFormat, ResourceArgs}; +use crate::cli::{BodyArgs, EntityAction, EntityResourceArgs, ListArgs, OutputFormat}; use crate::output; pub async fn run( client: &InvoiceNinjaClient, - args: ResourceArgs, + args: EntityResourceArgs, format: OutputFormat, ) -> Result<(), Box> { match args.action { - Action::List(list_args) => list(client, list_args, format).await, - Action::Get { id } => get(client, &id, format).await, - Action::Create(body_args) => create(client, body_args, format).await, - Action::Update { id, body } => update(client, &id, body, format).await, - Action::Delete { id } => delete(client, &id).await, + 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, } } @@ -99,6 +104,33 @@ async fn delete(client: &InvoiceNinjaClient, id: &str) -> Result<(), Box Result<(), Box> { + let updated = client.invoice_action(id, action).await?; + render(std::slice::from_ref(&updated), format) +} + +async fn download( + client: &InvoiceNinjaClient, + ids: &[String], + out: Option, +) -> Result<(), Box> { + let bytes = if ids.len() == 1 { + client.download_invoice(&ids[0]).await? + } else { + client.download_invoices(ids).await? + }; + match out { + Some(path) => std::fs::write(path, bytes)?, + None => std::io::stdout().write_all(&bytes)?, + } + Ok(()) +} + fn render(invoices: &[Invoice], format: OutputFormat) -> Result<(), Box> { match format { OutputFormat::Json => output::print_json(invoices)?, diff --git a/src/commands/purchase_orders.rs b/src/commands/purchase_orders.rs index b10e806..e90525d 100644 --- a/src/commands/purchase_orders.rs +++ b/src/commands/purchase_orders.rs @@ -1,24 +1,29 @@ //! `purchase-orders` subcommand. Same shape as `clients.rs` — see the //! "Adding a resource" section in CLAUDE.md. +use std::io::Write; +use std::path::PathBuf; + use invoiceninja_sdk::models::{ListParams, PurchaseOrder}; use invoiceninja_sdk::InvoiceNinjaClient; use crate::body; -use crate::cli::{Action, BodyArgs, ListArgs, OutputFormat, ResourceArgs}; +use crate::cli::{BodyArgs, EntityAction, EntityResourceArgs, ListArgs, OutputFormat}; use crate::output; pub async fn run( client: &InvoiceNinjaClient, - args: ResourceArgs, + args: EntityResourceArgs, format: OutputFormat, ) -> Result<(), Box> { match args.action { - Action::List(list_args) => list(client, list_args, format).await, - Action::Get { id } => get(client, &id, format).await, - Action::Create(body_args) => create(client, body_args, format).await, - Action::Update { id, body } => update(client, &id, body, format).await, - Action::Delete { id } => delete(client, &id).await, + 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, } } @@ -99,6 +104,33 @@ async fn delete(client: &InvoiceNinjaClient, id: &str) -> Result<(), Box Result<(), Box> { + let updated = client.purchase_order_action(id, action).await?; + render(std::slice::from_ref(&updated), format) +} + +async fn download( + client: &InvoiceNinjaClient, + ids: &[String], + out: Option, +) -> Result<(), Box> { + let bytes = if ids.len() == 1 { + client.download_purchase_order(&ids[0]).await? + } else { + client.download_purchase_orders(ids).await? + }; + match out { + Some(path) => std::fs::write(path, bytes)?, + None => std::io::stdout().write_all(&bytes)?, + } + Ok(()) +} + fn render( purchase_orders: &[PurchaseOrder], format: OutputFormat, diff --git a/src/commands/quotes.rs b/src/commands/quotes.rs index 158e634..2692307 100644 --- a/src/commands/quotes.rs +++ b/src/commands/quotes.rs @@ -4,24 +4,29 @@ //! an `entity_type`), so this module is otherwise identical to //! `invoices.rs` apart from which SDK methods and CLI subcommand it wires up. +use std::io::Write; +use std::path::PathBuf; + use invoiceninja_sdk::models::{ListParams, Quote}; use invoiceninja_sdk::InvoiceNinjaClient; use crate::body; -use crate::cli::{Action, BodyArgs, ListArgs, OutputFormat, ResourceArgs}; +use crate::cli::{BodyArgs, EntityAction, EntityResourceArgs, ListArgs, OutputFormat}; use crate::output; pub async fn run( client: &InvoiceNinjaClient, - args: ResourceArgs, + args: EntityResourceArgs, format: OutputFormat, ) -> Result<(), Box> { match args.action { - Action::List(list_args) => list(client, list_args, format).await, - Action::Get { id } => get(client, &id, format).await, - Action::Create(body_args) => create(client, body_args, format).await, - Action::Update { id, body } => update(client, &id, body, format).await, - Action::Delete { id } => delete(client, &id).await, + 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, } } @@ -102,6 +107,33 @@ async fn delete(client: &InvoiceNinjaClient, id: &str) -> Result<(), Box Result<(), Box> { + let updated = client.quote_action(id, action).await?; + render(std::slice::from_ref(&updated), format) +} + +async fn download( + client: &InvoiceNinjaClient, + ids: &[String], + out: Option, +) -> Result<(), Box> { + let bytes = if ids.len() == 1 { + client.download_quote(&ids[0]).await? + } else { + client.download_quotes(ids).await? + }; + match out { + Some(path) => std::fs::write(path, bytes)?, + None => std::io::stdout().write_all(&bytes)?, + } + Ok(()) +} + fn render(quotes: &[Quote], format: OutputFormat) -> Result<(), Box> { match format { OutputFormat::Json => output::print_json(quotes)?, diff --git a/tests/nixos-test.nix b/tests/nixos-test.nix index c471de8..f8d9428 100644 --- a/tests/nixos-test.nix +++ b/tests/nixos-test.nix @@ -68,7 +68,7 @@ pkgs.testers.nixosTest { assert updated["city"] == "Springfield", f"city not updated: {updated}" assert updated["name"] == "CLI Test Client", f"update blanked a field it wasn't told about: {updated}" - with subtest("invoiceninja-cli can create an invoice against that client and see it in a payment"): + with subtest("invoiceninja-cli can create an invoice against that client"): invoice_out = cli( machine, token, "invoices", "create", "--data", json.dumps({ @@ -79,6 +79,23 @@ pkgs.testers.nixosTest { invoice = json.loads(invoice_out)[0] assert invoice["amount"] == 100, f"unexpected invoice amount: {invoice}" + with subtest("invoiceninja-cli action marks the invoice sent, and download fetches a PDF"): + # Must run before the invoice is paid below: mark_sent on an + # already-paid invoice does not revert its status (status_id stays + # 4/paid, not 2/sent) - confirmed by this test failing that way + # the first time it was written in this order. + action_out = cli(machine, token, "invoices", "action", invoice["id"], "mark_sent") + acted = json.loads(action_out)[0] + assert acted["status_id"] == "2", f"mark_sent did not update status_id: {acted}" + + machine.succeed( + f"IN_API_TOKEN={token} IN_BASE_URL=http://localhost/api/v1 " + f"invoiceninja-cli invoices download {invoice['id']} --out /tmp/invoice.pdf" + ) + magic_bytes = machine.succeed("head -c 4 /tmp/invoice.pdf") + assert magic_bytes == "%PDF", f"expected a PDF, got: {magic_bytes!r}" + + with subtest("invoiceninja-cli can pay the invoice in full"): payment_out = cli( machine, token, "payments", "create", "--data", json.dumps({