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>
This commit is contained in:
@@ -43,3 +43,13 @@ command line.
|
|||||||
- CI on GitHub Actions, mirroring `invoiceninja-sdk`'s own `ci.yml`: a
|
- 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
|
pinned-toolchain `test` job, a build-only `msrv` job on the declared 1.88
|
||||||
floor, and an advisory `stable` job.
|
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 <id>
|
||||||
|
<action>` (entity actions — `mark_sent`, `mark_paid`, `archive`,
|
||||||
|
`restore`, `clone_to_invoice`, `clone_to_quote`, `auto_bill`, `delete`)
|
||||||
|
and `download <id>...` (the rendered PDF, to stdout or `--out <path>`;
|
||||||
|
more than one id streams a single merged PDF). These four are the only
|
||||||
|
resources with the SDK's `entity_action_methods!` route.
|
||||||
|
|||||||
@@ -242,6 +242,22 @@ Each resource beyond `clients` should follow the same shape:
|
|||||||
either way; that's a separate addition.
|
either way; that's a separate addition.
|
||||||
|
|
||||||
Resources with entity actions, document uploads, or non-CRUD endpoints
|
Resources with entity actions, document uploads, or non-CRUD endpoints
|
||||||
(reports, charts, search, statics, PDF downloads, …) will need more than
|
(reports, charts, search, statics, …) will need more than this template
|
||||||
this template once they're added — the SDK's own `CLAUDE.md` describes which
|
once they're added — the SDK's own `CLAUDE.md` describes which resources
|
||||||
resources have which extras.
|
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 <id> <action>` and `download <id>... [--out <path>]`. 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_<resource>` — GOALS.md's open question
|
||||||
|
on this covers why a plain pass-through isn't obviously right for bulk
|
||||||
|
`delete`), document uploads (`upload_<resource>`), and the one-off
|
||||||
|
non-CRUD endpoints (`statics`, `search`, `report`, `download_document`).
|
||||||
|
|||||||
+59
-4
@@ -54,11 +54,11 @@ pub enum Command {
|
|||||||
/// Manage clients
|
/// Manage clients
|
||||||
Clients(ResourceArgs),
|
Clients(ResourceArgs),
|
||||||
/// Manage invoices
|
/// Manage invoices
|
||||||
Invoices(ResourceArgs),
|
Invoices(EntityResourceArgs),
|
||||||
/// Manage quotes
|
/// Manage quotes
|
||||||
Quotes(ResourceArgs),
|
Quotes(EntityResourceArgs),
|
||||||
/// Manage credits
|
/// Manage credits
|
||||||
Credits(ResourceArgs),
|
Credits(EntityResourceArgs),
|
||||||
/// Manage payments
|
/// Manage payments
|
||||||
Payments(ResourceArgs),
|
Payments(ResourceArgs),
|
||||||
/// Manage products
|
/// Manage products
|
||||||
@@ -72,7 +72,7 @@ pub enum Command {
|
|||||||
/// Manage vendors
|
/// Manage vendors
|
||||||
Vendors(ResourceArgs),
|
Vendors(ResourceArgs),
|
||||||
/// Manage purchase orders
|
/// Manage purchase orders
|
||||||
PurchaseOrders(ResourceArgs),
|
PurchaseOrders(EntityResourceArgs),
|
||||||
/// Manage recurring invoices
|
/// Manage recurring invoices
|
||||||
RecurringInvoices(ResourceArgs),
|
RecurringInvoices(ResourceArgs),
|
||||||
/// Manage recurring quotes
|
/// Manage recurring quotes
|
||||||
@@ -149,6 +149,61 @@ pub enum Action {
|
|||||||
Delete { id: String },
|
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<String>,
|
||||||
|
/// Not `--output`: that's the global table/json flag, and clap
|
||||||
|
/// cannot have two differently-typed args share that identifier.
|
||||||
|
#[arg(long)]
|
||||||
|
out: Option<PathBuf>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
/// Mirrors the parameters every `ListParams` accepts in the SDK — see
|
/// Mirrors the parameters every `ListParams` accepts in the SDK — see
|
||||||
/// `invoiceninja_sdk::models::ListParams`.
|
/// `invoiceninja_sdk::models::ListParams`.
|
||||||
#[derive(Args)]
|
#[derive(Args)]
|
||||||
|
|||||||
+39
-7
@@ -3,24 +3,29 @@
|
|||||||
//! (Invoice Ninja stores credits in the same table/schema, distinguished by
|
//! (Invoice Ninja stores credits in the same table/schema, distinguished by
|
||||||
//! an `entity_type`), same as `quotes.rs`.
|
//! an `entity_type`), same as `quotes.rs`.
|
||||||
|
|
||||||
|
use std::io::Write;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
use invoiceninja_sdk::models::{Credit, ListParams};
|
use invoiceninja_sdk::models::{Credit, ListParams};
|
||||||
use invoiceninja_sdk::InvoiceNinjaClient;
|
use invoiceninja_sdk::InvoiceNinjaClient;
|
||||||
|
|
||||||
use crate::body;
|
use crate::body;
|
||||||
use crate::cli::{Action, BodyArgs, ListArgs, OutputFormat, ResourceArgs};
|
use crate::cli::{BodyArgs, EntityAction, EntityResourceArgs, ListArgs, OutputFormat};
|
||||||
use crate::output;
|
use crate::output;
|
||||||
|
|
||||||
pub async fn run(
|
pub async fn run(
|
||||||
client: &InvoiceNinjaClient,
|
client: &InvoiceNinjaClient,
|
||||||
args: ResourceArgs,
|
args: EntityResourceArgs,
|
||||||
format: OutputFormat,
|
format: OutputFormat,
|
||||||
) -> Result<(), Box<dyn std::error::Error>> {
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
match args.action {
|
match args.action {
|
||||||
Action::List(list_args) => list(client, list_args, format).await,
|
EntityAction::List(list_args) => list(client, list_args, format).await,
|
||||||
Action::Get { id } => get(client, &id, format).await,
|
EntityAction::Get { id } => get(client, &id, format).await,
|
||||||
Action::Create(body_args) => create(client, body_args, format).await,
|
EntityAction::Create(body_args) => create(client, body_args, format).await,
|
||||||
Action::Update { id, body } => update(client, &id, body, format).await,
|
EntityAction::Update { id, body } => update(client, &id, body, format).await,
|
||||||
Action::Delete { id } => delete(client, &id).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<dyn std
|
|||||||
Ok(())
|
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>> {
|
fn render(credits: &[Credit], format: OutputFormat) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
match format {
|
match format {
|
||||||
OutputFormat::Json => output::print_json(credits)?,
|
OutputFormat::Json => output::print_json(credits)?,
|
||||||
|
|||||||
@@ -1,24 +1,29 @@
|
|||||||
//! `invoices` subcommand. Same shape as `clients.rs` — see the "Adding a
|
//! `invoices` subcommand. Same shape as `clients.rs` — see the "Adding a
|
||||||
//! resource" section in CLAUDE.md.
|
//! resource" section in CLAUDE.md.
|
||||||
|
|
||||||
|
use std::io::Write;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
use invoiceninja_sdk::models::{Invoice, ListParams};
|
use invoiceninja_sdk::models::{Invoice, ListParams};
|
||||||
use invoiceninja_sdk::InvoiceNinjaClient;
|
use invoiceninja_sdk::InvoiceNinjaClient;
|
||||||
|
|
||||||
use crate::body;
|
use crate::body;
|
||||||
use crate::cli::{Action, BodyArgs, ListArgs, OutputFormat, ResourceArgs};
|
use crate::cli::{BodyArgs, EntityAction, EntityResourceArgs, ListArgs, OutputFormat};
|
||||||
use crate::output;
|
use crate::output;
|
||||||
|
|
||||||
pub async fn run(
|
pub async fn run(
|
||||||
client: &InvoiceNinjaClient,
|
client: &InvoiceNinjaClient,
|
||||||
args: ResourceArgs,
|
args: EntityResourceArgs,
|
||||||
format: OutputFormat,
|
format: OutputFormat,
|
||||||
) -> Result<(), Box<dyn std::error::Error>> {
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
match args.action {
|
match args.action {
|
||||||
Action::List(list_args) => list(client, list_args, format).await,
|
EntityAction::List(list_args) => list(client, list_args, format).await,
|
||||||
Action::Get { id } => get(client, &id, format).await,
|
EntityAction::Get { id } => get(client, &id, format).await,
|
||||||
Action::Create(body_args) => create(client, body_args, format).await,
|
EntityAction::Create(body_args) => create(client, body_args, format).await,
|
||||||
Action::Update { id, body } => update(client, &id, body, format).await,
|
EntityAction::Update { id, body } => update(client, &id, body, format).await,
|
||||||
Action::Delete { id } => delete(client, &id).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<dyn std
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn do_action(
|
||||||
|
client: &InvoiceNinjaClient,
|
||||||
|
id: &str,
|
||||||
|
action: &str,
|
||||||
|
format: OutputFormat,
|
||||||
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let updated = client.invoice_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_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<dyn std::error::Error>> {
|
fn render(invoices: &[Invoice], format: OutputFormat) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
match format {
|
match format {
|
||||||
OutputFormat::Json => output::print_json(invoices)?,
|
OutputFormat::Json => output::print_json(invoices)?,
|
||||||
|
|||||||
@@ -1,24 +1,29 @@
|
|||||||
//! `purchase-orders` subcommand. Same shape as `clients.rs` — see the
|
//! `purchase-orders` subcommand. Same shape as `clients.rs` — see the
|
||||||
//! "Adding a resource" section in CLAUDE.md.
|
//! "Adding a resource" section in CLAUDE.md.
|
||||||
|
|
||||||
|
use std::io::Write;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
use invoiceninja_sdk::models::{ListParams, PurchaseOrder};
|
use invoiceninja_sdk::models::{ListParams, PurchaseOrder};
|
||||||
use invoiceninja_sdk::InvoiceNinjaClient;
|
use invoiceninja_sdk::InvoiceNinjaClient;
|
||||||
|
|
||||||
use crate::body;
|
use crate::body;
|
||||||
use crate::cli::{Action, BodyArgs, ListArgs, OutputFormat, ResourceArgs};
|
use crate::cli::{BodyArgs, EntityAction, EntityResourceArgs, ListArgs, OutputFormat};
|
||||||
use crate::output;
|
use crate::output;
|
||||||
|
|
||||||
pub async fn run(
|
pub async fn run(
|
||||||
client: &InvoiceNinjaClient,
|
client: &InvoiceNinjaClient,
|
||||||
args: ResourceArgs,
|
args: EntityResourceArgs,
|
||||||
format: OutputFormat,
|
format: OutputFormat,
|
||||||
) -> Result<(), Box<dyn std::error::Error>> {
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
match args.action {
|
match args.action {
|
||||||
Action::List(list_args) => list(client, list_args, format).await,
|
EntityAction::List(list_args) => list(client, list_args, format).await,
|
||||||
Action::Get { id } => get(client, &id, format).await,
|
EntityAction::Get { id } => get(client, &id, format).await,
|
||||||
Action::Create(body_args) => create(client, body_args, format).await,
|
EntityAction::Create(body_args) => create(client, body_args, format).await,
|
||||||
Action::Update { id, body } => update(client, &id, body, format).await,
|
EntityAction::Update { id, body } => update(client, &id, body, format).await,
|
||||||
Action::Delete { id } => delete(client, &id).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<dyn std
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn do_action(
|
||||||
|
client: &InvoiceNinjaClient,
|
||||||
|
id: &str,
|
||||||
|
action: &str,
|
||||||
|
format: OutputFormat,
|
||||||
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
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<PathBuf>,
|
||||||
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
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(
|
fn render(
|
||||||
purchase_orders: &[PurchaseOrder],
|
purchase_orders: &[PurchaseOrder],
|
||||||
format: OutputFormat,
|
format: OutputFormat,
|
||||||
|
|||||||
+39
-7
@@ -4,24 +4,29 @@
|
|||||||
//! an `entity_type`), so this module is otherwise identical to
|
//! an `entity_type`), so this module is otherwise identical to
|
||||||
//! `invoices.rs` apart from which SDK methods and CLI subcommand it wires up.
|
//! `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::models::{ListParams, Quote};
|
||||||
use invoiceninja_sdk::InvoiceNinjaClient;
|
use invoiceninja_sdk::InvoiceNinjaClient;
|
||||||
|
|
||||||
use crate::body;
|
use crate::body;
|
||||||
use crate::cli::{Action, BodyArgs, ListArgs, OutputFormat, ResourceArgs};
|
use crate::cli::{BodyArgs, EntityAction, EntityResourceArgs, ListArgs, OutputFormat};
|
||||||
use crate::output;
|
use crate::output;
|
||||||
|
|
||||||
pub async fn run(
|
pub async fn run(
|
||||||
client: &InvoiceNinjaClient,
|
client: &InvoiceNinjaClient,
|
||||||
args: ResourceArgs,
|
args: EntityResourceArgs,
|
||||||
format: OutputFormat,
|
format: OutputFormat,
|
||||||
) -> Result<(), Box<dyn std::error::Error>> {
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
match args.action {
|
match args.action {
|
||||||
Action::List(list_args) => list(client, list_args, format).await,
|
EntityAction::List(list_args) => list(client, list_args, format).await,
|
||||||
Action::Get { id } => get(client, &id, format).await,
|
EntityAction::Get { id } => get(client, &id, format).await,
|
||||||
Action::Create(body_args) => create(client, body_args, format).await,
|
EntityAction::Create(body_args) => create(client, body_args, format).await,
|
||||||
Action::Update { id, body } => update(client, &id, body, format).await,
|
EntityAction::Update { id, body } => update(client, &id, body, format).await,
|
||||||
Action::Delete { id } => delete(client, &id).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<dyn std
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn do_action(
|
||||||
|
client: &InvoiceNinjaClient,
|
||||||
|
id: &str,
|
||||||
|
action: &str,
|
||||||
|
format: OutputFormat,
|
||||||
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let updated = client.quote_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_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<dyn std::error::Error>> {
|
fn render(quotes: &[Quote], format: OutputFormat) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
match format {
|
match format {
|
||||||
OutputFormat::Json => output::print_json(quotes)?,
|
OutputFormat::Json => output::print_json(quotes)?,
|
||||||
|
|||||||
+18
-1
@@ -68,7 +68,7 @@ pkgs.testers.nixosTest {
|
|||||||
assert updated["city"] == "Springfield", f"city not updated: {updated}"
|
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}"
|
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(
|
invoice_out = cli(
|
||||||
machine, token, "invoices", "create", "--data",
|
machine, token, "invoices", "create", "--data",
|
||||||
json.dumps({
|
json.dumps({
|
||||||
@@ -79,6 +79,23 @@ pkgs.testers.nixosTest {
|
|||||||
invoice = json.loads(invoice_out)[0]
|
invoice = json.loads(invoice_out)[0]
|
||||||
assert invoice["amount"] == 100, f"unexpected invoice amount: {invoice}"
|
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(
|
payment_out = cli(
|
||||||
machine, token, "payments", "create", "--data",
|
machine, token, "payments", "create", "--data",
|
||||||
json.dumps({
|
json.dumps({
|
||||||
|
|||||||
Reference in New Issue
Block a user