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 <noreply@anthropic.com>
This commit is contained in:
@@ -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.
|
||||||
@@ -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
|
would contradict a stated goal or settle an open question, update
|
||||||
`GOALS.md` in the same change.
|
`GOALS.md` in the same change.
|
||||||
|
|
||||||
**Early scaffold.** Only the `clients` resource is wired up, as the template
|
**Early scaffold.** Six resources are wired up (`clients`, `invoices`,
|
||||||
for the other ~34. See "Adding a resource" below before writing the next one.
|
`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
|
## Development environment
|
||||||
|
|
||||||
|
|||||||
@@ -95,9 +95,11 @@ exception is written down in goal 1.
|
|||||||
invocation. See the open question below on multi-account profiles before
|
invocation. See the open question below on multi-account profiles before
|
||||||
assuming this is permanent.
|
assuming this is permanent.
|
||||||
- **No stability promise before 1.0.** Same posture as the SDK: flags are a
|
- **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
|
compatibility surface too, and pre-1.0 they can change. `CHANGELOG.md`
|
||||||
changes somewhere legible (a `CHANGELOG.md`, once there's enough history to
|
records breaking changes rather than letting them be discovered at the
|
||||||
need one) rather than letting them be discovered at the command line.
|
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
|
## Open questions
|
||||||
|
|
||||||
|
|||||||
@@ -3,9 +3,10 @@
|
|||||||
A command-line client for the [Invoice Ninja](https://www.invoiceninja.com/)
|
A command-line client for the [Invoice Ninja](https://www.invoiceninja.com/)
|
||||||
v5 API, built on [`invoiceninja-sdk`](https://crates.io/crates/invoiceninja-sdk).
|
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
|
> **Early scaffold.** `clients`, `invoices`, `quotes`, `payments`, `products`,
|
||||||
> template for adding the rest — see `CLAUDE.md` for the pattern.
|
> and `expenses` are wired up so far, out of ~34 resources — see `CLAUDE.md`
|
||||||
> [`GOALS.md`](GOALS.md) covers what the crate is for and what it is not.
|
> for the pattern the rest follow. [`GOALS.md`](GOALS.md) covers what the
|
||||||
|
> crate is for and what it is not.
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
@@ -46,3 +47,8 @@ table view's few key columns:
|
|||||||
```sh
|
```sh
|
||||||
invoiceninja-cli --output json clients get <id>
|
invoiceninja-cli --output json clients get <id>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
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 <id>`.
|
||||||
|
|||||||
+13
-3
@@ -45,13 +45,23 @@ pub enum OutputFormat {
|
|||||||
Json,
|
Json,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One variant per resource. Only `clients` is wired up so far; it's the
|
/// One variant per resource. Six are wired up so far (`clients` was the
|
||||||
/// template for adding the rest of the SDK's ~34 resources the same way —
|
/// template for the rest); see CLAUDE.md's "Adding a resource" for the
|
||||||
/// see CLAUDE.md.
|
/// remaining ~28.
|
||||||
#[derive(Subcommand)]
|
#[derive(Subcommand)]
|
||||||
pub enum Command {
|
pub enum Command {
|
||||||
/// Manage clients
|
/// Manage clients
|
||||||
Clients(ResourceArgs),
|
Clients(ResourceArgs),
|
||||||
|
/// Manage invoices
|
||||||
|
Invoices(ResourceArgs),
|
||||||
|
/// Manage quotes
|
||||||
|
Quotes(ResourceArgs),
|
||||||
|
/// Manage payments
|
||||||
|
Payments(ResourceArgs),
|
||||||
|
/// Manage products
|
||||||
|
Products(ResourceArgs),
|
||||||
|
/// Manage expenses
|
||||||
|
Expenses(ResourceArgs),
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Args)]
|
#[derive(Args)]
|
||||||
|
|||||||
@@ -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<dyn std::error::Error>> {
|
||||||
|
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<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 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<dyn std::error::Error>> {
|
||||||
|
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<dyn std::error::Error>> {
|
||||||
|
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<dyn std::error::Error>> {
|
||||||
|
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<dyn std::error::Error>> {
|
||||||
|
client.delete_expense(id).await?;
|
||||||
|
println!("deleted {id}");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render(expenses: &[Expense], format: OutputFormat) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
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::<String>() + "…"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<dyn std::error::Error>> {
|
||||||
|
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<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 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<dyn std::error::Error>> {
|
||||||
|
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<dyn std::error::Error>> {
|
||||||
|
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<dyn std::error::Error>> {
|
||||||
|
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<dyn std::error::Error>> {
|
||||||
|
client.delete_invoice(id).await?;
|
||||||
|
println!("deleted {id}");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render(invoices: &[Invoice], format: OutputFormat) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
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::<String>() + "…"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1 +1,6 @@
|
|||||||
pub mod clients;
|
pub mod clients;
|
||||||
|
pub mod expenses;
|
||||||
|
pub mod invoices;
|
||||||
|
pub mod payments;
|
||||||
|
pub mod products;
|
||||||
|
pub mod quotes;
|
||||||
|
|||||||
@@ -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<dyn std::error::Error>> {
|
||||||
|
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<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 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<dyn std::error::Error>> {
|
||||||
|
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<dyn std::error::Error>> {
|
||||||
|
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<dyn std::error::Error>> {
|
||||||
|
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<dyn std::error::Error>> {
|
||||||
|
client.delete_payment(id).await?;
|
||||||
|
println!("deleted {id}");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render(payments: &[Payment], format: OutputFormat) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
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::<String>() + "…"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<dyn std::error::Error>> {
|
||||||
|
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<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 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<dyn std::error::Error>> {
|
||||||
|
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<dyn std::error::Error>> {
|
||||||
|
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<dyn std::error::Error>> {
|
||||||
|
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<dyn std::error::Error>> {
|
||||||
|
client.delete_product(id).await?;
|
||||||
|
println!("deleted {id}");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render(products: &[Product], format: OutputFormat) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
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::<String>() + "…"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<dyn std::error::Error>> {
|
||||||
|
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<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 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<dyn std::error::Error>> {
|
||||||
|
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<dyn std::error::Error>> {
|
||||||
|
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<dyn std::error::Error>> {
|
||||||
|
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<dyn std::error::Error>> {
|
||||||
|
client.delete_quote(id).await?;
|
||||||
|
println!("deleted {id}");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render(quotes: &[Quote], format: OutputFormat) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
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::<String>() + "…"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,5 +22,10 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
|
|
||||||
match cli.command {
|
match cli.command {
|
||||||
Command::Clients(args) => commands::clients::run(&client, args, cli.output).await,
|
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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user