From 1fbd7a7ce52b4290812d73048cbaee8372f95a0c Mon Sep 17 00:00:00 2001 From: Andrew Bryant Date: Mon, 26 Oct 2026 15:34:46 -0400 Subject: [PATCH] Add invoices, quotes, payments, products, and expenses Five more resources following clients.rs's template exactly: the same list/get/create/update/delete shape, including the fetch-merge-write pattern for update. quotes.rs is otherwise identical to invoices.rs since Quote is a type alias for Invoice in the SDK (same table, distinguished by entity_type upstream). Verified against a live local server: full create/list/get/update/ delete round trip for all six resources, confirming update preserves fields the caller didn't mention. Also adds CHANGELOG.md (Keep a Changelog format, matching the SDK's) and updates README/CLAUDE.md's resource counts. Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 41 ++++++++++++ CLAUDE.md | 6 +- GOALS.md | 8 ++- README.md | 12 +++- src/cli.rs | 16 ++++- src/commands/expenses.rs | 137 +++++++++++++++++++++++++++++++++++++ src/commands/invoices.rs | 138 ++++++++++++++++++++++++++++++++++++++ src/commands/mod.rs | 5 ++ src/commands/payments.rs | 137 +++++++++++++++++++++++++++++++++++++ src/commands/products.rs | 137 +++++++++++++++++++++++++++++++++++++ src/commands/quotes.rs | 141 +++++++++++++++++++++++++++++++++++++++ src/main.rs | 5 ++ 12 files changed, 772 insertions(+), 11 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 src/commands/expenses.rs create mode 100644 src/commands/invoices.rs create mode 100644 src/commands/payments.rs create mode 100644 src/commands/products.rs create mode 100644 src/commands/quotes.rs diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..324e414 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,41 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +Pre-1.0, per `GOALS.md`, a minor release can still break flags or output — +this file is where that gets written down instead of discovered at the +command line. + +## [Unreleased] + +### Added + +- Six resources, each with `list` (with `--all` for every page, `--status`, + and `--param key=value` for anything `ListParams` doesn't have a typed + setter for), `get`, `create`, `update`, and `delete`: `clients`, + `invoices`, `quotes`, `payments`, `products`, `expenses`. `quotes` is + `invoices` under a different endpoint — Invoice Ninja stores both in the + same table, distinguished by `entity_type`, so `Quote` is a type alias for + `Invoice` in the SDK and `commands/quotes.rs` is otherwise identical to + `commands/invoices.rs`. +- `update` fetches the current record, shallow-merges the caller's + `--data`/`--file` JSON on top, and writes the merged result back, rather + than sending the patch alone — Invoice Ninja's `PUT` is a full replace. + See `CLAUDE.md` and the comment in `src/body.rs`. +- `--output json` on every command for the model's full `Serialize` output + (including `extra`), suitable as input to a later `update`/`create`; the + default table view is a convenience subset of columns. +- Global `--base-url`, `--token`, and `--api-password` flags, each with a + matching environment variable (`IN_BASE_URL`, `IN_API_TOKEN`, + `IN_API_PASSWORD`); both secrets are hidden from `--help` and any echoed + default. +- Depends on [`invoiceninja-sdk`](https://crates.io/crates/invoiceninja-sdk) + 0.2.0 from crates.io. +- A `flake.nix` with `devShells.default`, `packages.default` (built with a + pinned toolchain), and `overlays.default` (for pulling `invoiceninja-cli` + into another flake's own `pkgs`). +- 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. diff --git a/CLAUDE.md b/CLAUDE.md index 6c7ab13..a0aac6d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -20,8 +20,10 @@ design call, the same way its counterpart in the SDK works. If a change would contradict a stated goal or settle an open question, update `GOALS.md` in the same change. -**Early scaffold.** Only the `clients` resource is wired up, as the template -for the other ~34. See "Adding a resource" below before writing the next one. +**Early scaffold.** Six resources are wired up (`clients`, `invoices`, +`quotes`, `payments`, `products`, `expenses`) out of ~34; `clients` was the +template the other five followed. See "Adding a resource" below before +writing the next one. ## Development environment diff --git a/GOALS.md b/GOALS.md index f919be0..8eebfb2 100644 --- a/GOALS.md +++ b/GOALS.md @@ -95,9 +95,11 @@ exception is written down in goal 1. invocation. See the open question below on multi-account profiles before assuming this is permanent. - **No stability promise before 1.0.** Same posture as the SDK: flags are a - compatibility surface too, and pre-1.0 they can change. Record breaking - changes somewhere legible (a `CHANGELOG.md`, once there's enough history to - need one) rather than letting them be discovered at the command line. + compatibility surface too, and pre-1.0 they can change. `CHANGELOG.md` + records breaking changes rather than letting them be discovered at the + command line — keep its `[Unreleased]` section current as changes land, + and move it to a dated, versioned section at the same time `Cargo.toml`'s + version bumps, not after. ## Open questions diff --git a/README.md b/README.md index 2dc2fa4..eb486ce 100644 --- a/README.md +++ b/README.md @@ -3,9 +3,10 @@ A command-line client for the [Invoice Ninja](https://www.invoiceninja.com/) v5 API, built on [`invoiceninja-sdk`](https://crates.io/crates/invoiceninja-sdk). -> **Early scaffold.** Only the `clients` resource is wired up so far, as the -> template for adding the rest — see `CLAUDE.md` for the pattern. -> [`GOALS.md`](GOALS.md) covers what the crate is for and what it is not. +> **Early scaffold.** `clients`, `invoices`, `quotes`, `payments`, `products`, +> and `expenses` are wired up so far, out of ~34 resources — see `CLAUDE.md` +> for the pattern the rest follow. [`GOALS.md`](GOALS.md) covers what the +> crate is for and what it is not. ## Development @@ -46,3 +47,8 @@ table view's few key columns: ```sh invoiceninja-cli --output json clients get ``` + +Every other wired-up resource (`invoices`, `quotes`, `payments`, `products`, +`expenses`) follows the same `list`/`get`/`create`/`update`/`delete` shape, +e.g. `invoiceninja-cli invoices list --status active` or +`invoiceninja-cli payments get `. diff --git a/src/cli.rs b/src/cli.rs index d6bce8a..614fc1a 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -45,13 +45,23 @@ pub enum OutputFormat { Json, } -/// One variant per resource. Only `clients` is wired up so far; it's the -/// template for adding the rest of the SDK's ~34 resources the same way — -/// see CLAUDE.md. +/// One variant per resource. Six are wired up so far (`clients` was the +/// template for the rest); see CLAUDE.md's "Adding a resource" for the +/// remaining ~28. #[derive(Subcommand)] pub enum Command { /// Manage clients Clients(ResourceArgs), + /// Manage invoices + Invoices(ResourceArgs), + /// Manage quotes + Quotes(ResourceArgs), + /// Manage payments + Payments(ResourceArgs), + /// Manage products + Products(ResourceArgs), + /// Manage expenses + Expenses(ResourceArgs), } #[derive(Args)] diff --git a/src/commands/expenses.rs b/src/commands/expenses.rs new file mode 100644 index 0000000..5ad43cb --- /dev/null +++ b/src/commands/expenses.rs @@ -0,0 +1,137 @@ +//! `expenses` subcommand. Same shape as `clients.rs` — see the "Adding a +//! resource" section in CLAUDE.md. + +use invoiceninja_sdk::models::{Expense, ListParams}; +use invoiceninja_sdk::InvoiceNinjaClient; + +use crate::body; +use crate::cli::{Action, BodyArgs, ListArgs, OutputFormat, ResourceArgs}; +use crate::output; + +pub async fn run( + client: &InvoiceNinjaClient, + args: ResourceArgs, + 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, + } +} + +async fn list( + client: &InvoiceNinjaClient, + args: ListArgs, + format: OutputFormat, +) -> Result<(), Box> { + 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 expenses = if args.all { + client.list_all_expenses(¶ms).await? + } else { + client.list_expenses(¶ms).await? + }; + + render(&expenses, format) +} + +async fn get( + client: &InvoiceNinjaClient, + id: &str, + format: OutputFormat, +) -> Result<(), Box> { + let found = client.get_expense(id).await?; + render(std::slice::from_ref(&found), format) +} + +async fn create( + client: &InvoiceNinjaClient, + body_args: BodyArgs, + format: OutputFormat, +) -> Result<(), Box> { + let value = body::load(&body_args)?; + let model: Expense = serde_json::from_value(value)?; + let created = client.create_expense(&model).await?; + render(std::slice::from_ref(&created), format) +} + +async fn update( + client: &InvoiceNinjaClient, + id: &str, + body_args: BodyArgs, + format: OutputFormat, +) -> Result<(), Box> { + let patch = body::load(&body_args)?; + let current = client.get_expense(id).await?; + let merged = body::merge_shallow(serde_json::to_value(¤t)?, patch); + let model: Expense = serde_json::from_value(merged)?; + let updated = client.update_expense(id, &model).await?; + render(std::slice::from_ref(&updated), format) +} + +async fn delete(client: &InvoiceNinjaClient, id: &str) -> Result<(), Box> { + client.delete_expense(id).await?; + println!("deleted {id}"); + Ok(()) +} + +fn render(expenses: &[Expense], format: OutputFormat) -> Result<(), Box> { + match format { + OutputFormat::Json => output::print_json(expenses)?, + OutputFormat::Table => print_table(expenses), + } + Ok(()) +} + +fn print_table(expenses: &[Expense]) { + if expenses.is_empty() { + println!("(no records)"); + return; + } + println!( + "{:<20} {:<20} {:<20} {:<12} {:>12}", + "id", "vendor_id", "client_id", "date", "amount" + ); + for e in expenses { + println!( + "{:<20} {:<20} {:<20} {:<12} {:>12.2}", + e.id.as_deref().unwrap_or(""), + truncate(&e.vendor_id, 20), + truncate(&e.client_id, 20), + e.date, + e.amount + ); + } +} + +fn truncate(s: &str, max: usize) -> String { + if s.chars().count() <= max { + s.to_string() + } else { + s.chars().take(max.saturating_sub(1)).collect::() + "…" + } +} diff --git a/src/commands/invoices.rs b/src/commands/invoices.rs new file mode 100644 index 0000000..84c4e5c --- /dev/null +++ b/src/commands/invoices.rs @@ -0,0 +1,138 @@ +//! `invoices` subcommand. Same shape as `clients.rs` — see the "Adding a +//! resource" section in CLAUDE.md. + +use invoiceninja_sdk::models::{Invoice, ListParams}; +use invoiceninja_sdk::InvoiceNinjaClient; + +use crate::body; +use crate::cli::{Action, BodyArgs, ListArgs, OutputFormat, ResourceArgs}; +use crate::output; + +pub async fn run( + client: &InvoiceNinjaClient, + args: ResourceArgs, + 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, + } +} + +async fn list( + client: &InvoiceNinjaClient, + args: ListArgs, + format: OutputFormat, +) -> Result<(), Box> { + 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 invoices = if args.all { + client.list_all_invoices(¶ms).await? + } else { + client.list_invoices(¶ms).await? + }; + + render(&invoices, format) +} + +async fn get( + client: &InvoiceNinjaClient, + id: &str, + format: OutputFormat, +) -> Result<(), Box> { + let found = client.get_invoice(id).await?; + render(std::slice::from_ref(&found), format) +} + +async fn create( + client: &InvoiceNinjaClient, + body_args: BodyArgs, + format: OutputFormat, +) -> Result<(), Box> { + let value = body::load(&body_args)?; + let model: Invoice = serde_json::from_value(value)?; + let created = client.create_invoice(&model).await?; + render(std::slice::from_ref(&created), format) +} + +async fn update( + client: &InvoiceNinjaClient, + id: &str, + body_args: BodyArgs, + format: OutputFormat, +) -> Result<(), Box> { + let patch = body::load(&body_args)?; + let current = client.get_invoice(id).await?; + let merged = body::merge_shallow(serde_json::to_value(¤t)?, patch); + let model: Invoice = serde_json::from_value(merged)?; + let updated = client.update_invoice(id, &model).await?; + render(std::slice::from_ref(&updated), format) +} + +async fn delete(client: &InvoiceNinjaClient, id: &str) -> Result<(), Box> { + client.delete_invoice(id).await?; + println!("deleted {id}"); + Ok(()) +} + +fn render(invoices: &[Invoice], format: OutputFormat) -> Result<(), Box> { + match format { + OutputFormat::Json => output::print_json(invoices)?, + OutputFormat::Table => print_table(invoices), + } + Ok(()) +} + +fn print_table(invoices: &[Invoice]) { + if invoices.is_empty() { + println!("(no records)"); + return; + } + println!( + "{:<20} {:<15} {:<20} {:<8} {:>12} {:>12}", + "id", "number", "client_id", "status", "amount", "balance" + ); + for i in invoices { + println!( + "{:<20} {:<15} {:<20} {:<8} {:>12.2} {:>12.2}", + i.id.as_deref().unwrap_or(""), + truncate(&i.number, 15), + truncate(&i.client_id, 20), + i.status_id, + i.amount, + i.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::() + "…" + } +} diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 705f46d..dbad742 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -1 +1,6 @@ pub mod clients; +pub mod expenses; +pub mod invoices; +pub mod payments; +pub mod products; +pub mod quotes; diff --git a/src/commands/payments.rs b/src/commands/payments.rs new file mode 100644 index 0000000..9e32cf8 --- /dev/null +++ b/src/commands/payments.rs @@ -0,0 +1,137 @@ +//! `payments` subcommand. Same shape as `clients.rs` — see the "Adding a +//! resource" section in CLAUDE.md. + +use invoiceninja_sdk::models::{ListParams, Payment}; +use invoiceninja_sdk::InvoiceNinjaClient; + +use crate::body; +use crate::cli::{Action, BodyArgs, ListArgs, OutputFormat, ResourceArgs}; +use crate::output; + +pub async fn run( + client: &InvoiceNinjaClient, + args: ResourceArgs, + 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, + } +} + +async fn list( + client: &InvoiceNinjaClient, + args: ListArgs, + format: OutputFormat, +) -> Result<(), Box> { + 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 payments = if args.all { + client.list_all_payments(¶ms).await? + } else { + client.list_payments(¶ms).await? + }; + + render(&payments, format) +} + +async fn get( + client: &InvoiceNinjaClient, + id: &str, + format: OutputFormat, +) -> Result<(), Box> { + let found = client.get_payment(id).await?; + render(std::slice::from_ref(&found), format) +} + +async fn create( + client: &InvoiceNinjaClient, + body_args: BodyArgs, + format: OutputFormat, +) -> Result<(), Box> { + let value = body::load(&body_args)?; + let model: Payment = serde_json::from_value(value)?; + let created = client.create_payment(&model).await?; + render(std::slice::from_ref(&created), format) +} + +async fn update( + client: &InvoiceNinjaClient, + id: &str, + body_args: BodyArgs, + format: OutputFormat, +) -> Result<(), Box> { + let patch = body::load(&body_args)?; + let current = client.get_payment(id).await?; + let merged = body::merge_shallow(serde_json::to_value(¤t)?, patch); + let model: Payment = serde_json::from_value(merged)?; + let updated = client.update_payment(id, &model).await?; + render(std::slice::from_ref(&updated), format) +} + +async fn delete(client: &InvoiceNinjaClient, id: &str) -> Result<(), Box> { + client.delete_payment(id).await?; + println!("deleted {id}"); + Ok(()) +} + +fn render(payments: &[Payment], format: OutputFormat) -> Result<(), Box> { + match format { + OutputFormat::Json => output::print_json(payments)?, + OutputFormat::Table => print_table(payments), + } + Ok(()) +} + +fn print_table(payments: &[Payment]) { + if payments.is_empty() { + println!("(no records)"); + return; + } + println!( + "{:<20} {:<20} {:<12} {:>12} {:>12}", + "id", "client_id", "date", "amount", "applied" + ); + for p in payments { + println!( + "{:<20} {:<20} {:<12} {:>12.2} {:>12.2}", + p.id.as_deref().unwrap_or(""), + truncate(&p.client_id, 20), + p.date, + p.amount, + p.applied + ); + } +} + +fn truncate(s: &str, max: usize) -> String { + if s.chars().count() <= max { + s.to_string() + } else { + s.chars().take(max.saturating_sub(1)).collect::() + "…" + } +} diff --git a/src/commands/products.rs b/src/commands/products.rs new file mode 100644 index 0000000..8abc9cc --- /dev/null +++ b/src/commands/products.rs @@ -0,0 +1,137 @@ +//! `products` subcommand. Same shape as `clients.rs` — see the "Adding a +//! resource" section in CLAUDE.md. + +use invoiceninja_sdk::models::{ListParams, Product}; +use invoiceninja_sdk::InvoiceNinjaClient; + +use crate::body; +use crate::cli::{Action, BodyArgs, ListArgs, OutputFormat, ResourceArgs}; +use crate::output; + +pub async fn run( + client: &InvoiceNinjaClient, + args: ResourceArgs, + 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, + } +} + +async fn list( + client: &InvoiceNinjaClient, + args: ListArgs, + format: OutputFormat, +) -> Result<(), Box> { + 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 products = if args.all { + client.list_all_products(¶ms).await? + } else { + client.list_products(¶ms).await? + }; + + render(&products, format) +} + +async fn get( + client: &InvoiceNinjaClient, + id: &str, + format: OutputFormat, +) -> Result<(), Box> { + let found = client.get_product(id).await?; + render(std::slice::from_ref(&found), format) +} + +async fn create( + client: &InvoiceNinjaClient, + body_args: BodyArgs, + format: OutputFormat, +) -> Result<(), Box> { + let value = body::load(&body_args)?; + let model: Product = serde_json::from_value(value)?; + let created = client.create_product(&model).await?; + render(std::slice::from_ref(&created), format) +} + +async fn update( + client: &InvoiceNinjaClient, + id: &str, + body_args: BodyArgs, + format: OutputFormat, +) -> Result<(), Box> { + let patch = body::load(&body_args)?; + let current = client.get_product(id).await?; + let merged = body::merge_shallow(serde_json::to_value(¤t)?, patch); + let model: Product = serde_json::from_value(merged)?; + let updated = client.update_product(id, &model).await?; + render(std::slice::from_ref(&updated), format) +} + +async fn delete(client: &InvoiceNinjaClient, id: &str) -> Result<(), Box> { + client.delete_product(id).await?; + println!("deleted {id}"); + Ok(()) +} + +fn render(products: &[Product], format: OutputFormat) -> Result<(), Box> { + match format { + OutputFormat::Json => output::print_json(products)?, + OutputFormat::Table => print_table(products), + } + Ok(()) +} + +fn print_table(products: &[Product]) { + if products.is_empty() { + println!("(no records)"); + return; + } + println!( + "{:<20} {:<20} {:>10} {:>10} {:>10}", + "id", "product_key", "price", "cost", "quantity" + ); + for p in products { + println!( + "{:<20} {:<20} {:>10.2} {:>10.2} {:>10.2}", + p.id.as_deref().unwrap_or(""), + truncate(&p.product_key, 20), + p.price, + p.cost, + p.quantity + ); + } +} + +fn truncate(s: &str, max: usize) -> String { + if s.chars().count() <= max { + s.to_string() + } else { + s.chars().take(max.saturating_sub(1)).collect::() + "…" + } +} diff --git a/src/commands/quotes.rs b/src/commands/quotes.rs new file mode 100644 index 0000000..158e634 --- /dev/null +++ b/src/commands/quotes.rs @@ -0,0 +1,141 @@ +//! `quotes` subcommand. Same shape as `clients.rs` — see the "Adding a +//! resource" section in CLAUDE.md. `Quote` is a type alias for `Invoice` +//! (Invoice Ninja stores quotes in the same table/schema, distinguished by +//! an `entity_type`), so this module is otherwise identical to +//! `invoices.rs` apart from which SDK methods and CLI subcommand it wires up. + +use invoiceninja_sdk::models::{ListParams, Quote}; +use invoiceninja_sdk::InvoiceNinjaClient; + +use crate::body; +use crate::cli::{Action, BodyArgs, ListArgs, OutputFormat, ResourceArgs}; +use crate::output; + +pub async fn run( + client: &InvoiceNinjaClient, + args: ResourceArgs, + 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, + } +} + +async fn list( + client: &InvoiceNinjaClient, + args: ListArgs, + format: OutputFormat, +) -> Result<(), Box> { + 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 quotes = if args.all { + client.list_all_quotes(¶ms).await? + } else { + client.list_quotes(¶ms).await? + }; + + render("es, format) +} + +async fn get( + client: &InvoiceNinjaClient, + id: &str, + format: OutputFormat, +) -> Result<(), Box> { + let found = client.get_quote(id).await?; + render(std::slice::from_ref(&found), format) +} + +async fn create( + client: &InvoiceNinjaClient, + body_args: BodyArgs, + format: OutputFormat, +) -> Result<(), Box> { + let value = body::load(&body_args)?; + let model: Quote = serde_json::from_value(value)?; + let created = client.create_quote(&model).await?; + render(std::slice::from_ref(&created), format) +} + +async fn update( + client: &InvoiceNinjaClient, + id: &str, + body_args: BodyArgs, + format: OutputFormat, +) -> Result<(), Box> { + let patch = body::load(&body_args)?; + let current = client.get_quote(id).await?; + let merged = body::merge_shallow(serde_json::to_value(¤t)?, patch); + let model: Quote = serde_json::from_value(merged)?; + let updated = client.update_quote(id, &model).await?; + render(std::slice::from_ref(&updated), format) +} + +async fn delete(client: &InvoiceNinjaClient, id: &str) -> Result<(), Box> { + client.delete_quote(id).await?; + println!("deleted {id}"); + Ok(()) +} + +fn render(quotes: &[Quote], format: OutputFormat) -> Result<(), Box> { + match format { + OutputFormat::Json => output::print_json(quotes)?, + OutputFormat::Table => print_table(quotes), + } + Ok(()) +} + +fn print_table(quotes: &[Quote]) { + if quotes.is_empty() { + println!("(no records)"); + return; + } + println!( + "{:<20} {:<15} {:<20} {:<8} {:>12} {:>12}", + "id", "number", "client_id", "status", "amount", "balance" + ); + for q in quotes { + println!( + "{:<20} {:<15} {:<20} {:<8} {:>12.2} {:>12.2}", + q.id.as_deref().unwrap_or(""), + truncate(&q.number, 15), + truncate(&q.client_id, 20), + q.status_id, + q.amount, + q.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::() + "…" + } +} diff --git a/src/main.rs b/src/main.rs index 25e25e4..2bad49b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -22,5 +22,10 @@ async fn run() -> Result<(), Box> { match cli.command { Command::Clients(args) => commands::clients::run(&client, args, cli.output).await, + Command::Invoices(args) => commands::invoices::run(&client, args, cli.output).await, + Command::Quotes(args) => commands::quotes::run(&client, args, cli.output).await, + Command::Payments(args) => commands::payments::run(&client, args, cli.output).await, + Command::Products(args) => commands::products::run(&client, args, cli.output).await, + Command::Expenses(args) => commands::expenses::run(&client, args, cli.output).await, } }