Compare commits

..
9 Commits
Author SHA1 Message Date
awkawbandClaude Sonnet 5 e283fa949f Date the 0.1.0 CHANGELOG entry now that it's actually published
CI / test (push) Canceled after 0s
CI / Build on the declared MSRV (push) Canceled after 0s
CI / Latest stable (advisory) (push) Canceled after 0s
Published to crates.io today; the Unreleased content from earlier
sessions becomes this release's entry instead of staying provisional.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-26 17:33:50 -04:00
awkawbandClaude Sonnet 5 1236a15ebb Add entity action and PDF download to invoices/quotes/credits/purchase-orders
CI / test (push) Canceled after 0s
CI / Build on the declared MSRV (push) Canceled after 0s
CI / Latest stable (advisory) (push) Canceled after 0s
These four resources are the only ones with the SDK's
entity_action_methods! route. Adds:

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

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

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

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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-26 17:29:00 -04:00
awkawbandClaude Sonnet 5 c8a67b1208 Add an automated VM test against a real Invoice Ninja instance
CI / test (push) Canceled after 0s
CI / Build on the declared MSRV (push) Canceled after 0s
CI / Latest stable (advisory) (push) Canceled after 0s
Adds an invoiceninja-nixos flake input and two things built from it:

- nixosConfigurations.invoiceninja-test-nginx/-caddy: manual test
  VMs, re-exported as-is from that repo rather than redefined here —
  same principle as this crate being a client of the SDK, not a
  second implementation of it.
- checks.invoiceninja-cli: an automated nixosTest (tests/, adapted
  from invoiceninja-nixos's own test scaffolding) that boots a real
  Invoice Ninja instance and runs the actual invoiceninja-cli binary
  against it - create/list/get/update/invoice/payment/delete across
  clients, invoices, and payments - rather than raw curl.

tests/common.nix's module import had to change from a relative
../nixos-module/invoiceninja.nix (which doesn't exist here) to a
parameter threaded from the flake input's nixosModules.invoiceninja.

Building the VM pulls from the invoiceninja-nixos cachix cache via a
nixConfig block, mirroring that repo's own.

Also fixes a wrong assumption the first version of this test's final
assertion made, caught by actually running it against a real server:
`clients list` with no `--status` does not exclude soft-deleted
records - confirmed empirically (Invoice Ninja returns everything
regardless of is_deleted unless status=active is passed explicitly).
Corrected the test to check is_deleted via `clients get` instead, and
fixed the misleadingly-incomplete --status doc comment in cli.rs that
had the same wrong assumption baked in.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-26 16:57:46 -04:00
awkawbandClaude Sonnet 5 96fba9a303 Add the remaining 22 simple-CRUD resources
Finishes the mechanical rollout: every resource using
resource_methods!/crud_methods! in the SDK (list/get/create/update/
delete, no bulk/uploads/entity-actions) is now wired up, 33 in
total. recurring_quotes.rs mirrors recurring_invoices.rs the same
way quotes.rs mirrors invoices.rs, since RecurringQuote is also a
type alias for RecurringInvoice in the SDK.

client_gateway_tokens, companies, and system_logs use the SDK's
crud_methods! rather than resource_methods! (no /bulk route in
Invoice Ninja for these) - doesn't change anything here, since this
CLI doesn't expose bulk actions yet either way.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-26 16:51:51 -04:00
awkawbandClaude Sonnet 5 a0a629cbfe Add credits, projects, tasks, vendors, and purchase orders
Five more resources following clients.rs's template exactly, same as
the previous batch. credits.rs is otherwise identical to quotes.rs
since Credit is also 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 five.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-26 16:49:39 -04:00
awkawbandClaude Sonnet 5 1fbd7a7ce5 Add invoices, quotes, payments, products, and expenses
CI / test (push) Canceled after 0s
CI / Build on the declared MSRV (push) Canceled after 0s
CI / Latest stable (advisory) (push) Canceled after 0s
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>
2026-10-26 15:34:46 -04:00
awkawbandClaude Sonnet 5 2292239fd7 Add a CI workflow, mirroring invoiceninja-sdk's job for job
CI / test (push) Canceled after 0s
CI / Build on the declared MSRV (push) Canceled after 0s
CI / Latest stable (advisory) (push) Canceled after 0s
Now that the SDK dependency resolves from crates.io instead of a
sibling checkout, a workflow here needs nothing special to build.
Same three jobs as the SDK's own ci.yml: pinned-toolchain test, a
build-only msrv job on the declared 1.88 floor, and an advisory
stable job that reports red but stays out of required status checks.

Verified locally: all four `test`-job commands pass with the pinned
1.99.1 toolchain, and a build+test against an actual rustc 1.88.0
toolchain confirms the msrv job will be green too.

Resolves the "own CI" open question in GOALS.md into goal 6; adds the
matching "Rust version and CI" section to CLAUDE.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-26 15:05:45 -04:00
awkawbandClaude Sonnet 5 f8e08af810 Set repository in Cargo.toml now that there's a remote
Gitea (origin) is canonical with a push mirror to GitHub, same setup
as invoiceninja-sdk. Points at the GitHub mirror to match that
crate's Cargo.toml and clear cargo's "no documentation, homepage or
repository" publish warning.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-26 14:49:50 -04:00
awkawbandClaude Sonnet 5 dde10e522a Initial commit: clients resource scaffold over invoiceninja-sdk
A command-line client for the Invoice Ninja v5 API, built on the
invoiceninja-sdk crate (consumed from crates.io, 0.2.0). Only the
clients resource is wired up so far, as the template the rest follow
- see CLAUDE.md and GOALS.md for the reasoning behind the shape of
things (the update fetch-merge-write pattern, why there's no Cargo
workspace with the SDK, the local-checkout override for
co-development, and the Nix flake packaging via packages.default and
overlays.default).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-26 11:47:15 -04:00
57 changed files with 7743 additions and 0 deletions
+14
View File
@@ -0,0 +1,14 @@
# Copy this file to `.cargo/config.toml` (gitignored, so this stays
# per-developer) to build against a local `../invoiceninjars-sdk` checkout
# instead of the published crates.io release of `invoiceninja-sdk`. Useful
# for testing an SDK change here before it's published.
#
# Cargo's `paths` override requires the local checkout's `[package] version`
# in its Cargo.toml to match the version requirement in this crate's
# Cargo.toml (currently "0.2.0") — if you bump the SDK's version locally
# while working on the next release, this override stops matching until
# this crate's `Cargo.toml` is bumped to match (or the SDK version is reset).
#
# Delete `.cargo/config.toml` (or just this line) to go back to the
# published version.
paths = ["../invoiceninjars-sdk"]
+18
View File
@@ -0,0 +1,18 @@
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
[*.rs]
indent_style = space
indent_size = 4
[*.{yml,yaml,toml,nix}]
indent_style = space
indent_size = 2
[*.md]
trim_trailing_whitespace = false
+70
View File
@@ -0,0 +1,70 @@
name: CI
on:
push:
branches: [main]
pull_request:
schedule:
# Mondays 06:00 UTC. A new Rust release lands on its own schedule, so
# without this the advisory `stable` job below only runs when someone
# happens to push, and breakage can sit unnoticed for weeks.
- cron: '0 6 * * 1'
env:
CARGO_TERM_COLOR: always
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-latest
# Nothing it covers can change on a timer: the toolchain is pinned and
# the dependencies are locked. The weekly run is for `stable` only.
if: github.event_name != 'schedule'
steps:
- uses: actions/checkout@v4
# Pinned, and kept in step with `devVersion` in flake.nix (which in
# turn tracks invoiceninja-sdk's own `devVersion`), so CI and the dev
# shell agree and a new stable release cannot turn a lint failure
# into an unrelated red build.
- uses: dtolnay/rust-toolchain@1.99.1
with:
components: rustfmt, clippy
- run: cargo build --all-targets
- run: cargo test
- run: cargo fmt --check
- run: cargo clippy --all-targets -- -D warnings
msrv:
name: Build on the declared MSRV
runs-on: ubuntu-latest
if: github.event_name != 'schedule'
steps:
- uses: actions/checkout@v4
# Must match `rust-version` in Cargo.toml, which in turn is pinned to
# invoiceninja-sdk's own MSRV (see the comment there). Build only —
# lint and format output varies by release, and pinning those to the
# oldest supported toolchain is not useful.
- uses: dtolnay/rust-toolchain@1.88.0
- run: cargo test
stable:
name: Latest stable (advisory)
runs-on: ubuntu-latest
# Deliberately NOT `continue-on-error`: a failure here should render as
# a real red X, not a green check with an annotation nobody reads.
#
# This job is advisory all the same — it tracks a moving toolchain, so a
# new clippy lint can break it through no fault of the change under
# test. **Keep it out of the required status checks** in branch
# protection; that, rather than a flag in this file, is what stops it
# blocking merges. Expect it red sometimes, and fix it when bumping
# `devVersion`.
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
components: clippy
- run: cargo test
- run: cargo clippy --all-targets -- -D warnings
+10
View File
@@ -0,0 +1,10 @@
/target
.devenv/
.direnv/
*.swp
/.cargo/config.toml
/result
/result-*
# The test VM's disk image — created in the invocation directory by
# run-nixos-vm (see CLAUDE.md's "Testing against a real server").
/nixos.qcow2
+56
View File
@@ -0,0 +1,56 @@
# 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]
## [0.1.0] - 2026-10-26
### Added
- 33 resources, every one of them with a simple `list`/`get`/`create`/
`update`/`delete` API in the SDK (`resource_methods!`/`crud_methods!`) —
see `invoiceninja-cli --help` for the full list. `list` takes `--all`
for every page, `--status`, and `--param key=value` for anything
`ListParams` doesn't have a typed setter for. `quotes` and `credits` are
`invoices` under different endpoints — Invoice Ninja stores all three in
the same table, distinguished by `entity_type`, so `Quote` and `Credit`
are type aliases for `Invoice` in the SDK and `commands/quotes.rs`/
`commands/credits.rs` are otherwise identical to `commands/invoices.rs`.
Likewise `recurring_quotes`/`RecurringQuote` mirror
`recurring_invoices`/`RecurringInvoice`.
- `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.
- `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.
+263
View File
@@ -0,0 +1,263 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## What this is
A command-line client for the [Invoice Ninja](https://www.invoiceninja.com/)
v5 API, built on [`invoiceninja-sdk`](https://crates.io/crates/invoiceninja-sdk),
consumed as a normal crates.io dependency (not a path dependency on a sibling
checkout — see "Development environment" below for how that changed).
That crate's own `GOALS.md` (at `../invoiceninjars-sdk/GOALS.md`, if you have
a checkout for reference) explains why it's a separate crate rather than a
feature of the library — dependencies, MSRV, and versioning, all pre-1.0.
Read that `GOALS.md` before making a design call here; this project exists
downstream of decisions made there.
**This project's own [`GOALS.md`](GOALS.md) states what it is for, what it
deliberately is not, and what is still undecided** — read it before making a
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.
**Scaffold status.** Every resource that fits the simple CRUD template
(`resource_methods!`/`crud_methods!` in the SDK — list/get/create/update/
delete, no bulk/uploads/entity-actions) is wired up, 33 in total; `clients`
was the template the rest followed. See "Adding a resource" below for what
a resource needing more than the template (bulk actions, uploads, entity
actions, non-CRUD endpoints) will need beyond this.
## Development environment
Nix-managed, with its own `flake.nix` (not shared with the SDK's):
```sh
nix develop
```
The dev toolchain was originally pinned to match `invoiceninjars-sdk`'s own
(`devVersion` in each `flake.nix`), from back when this crate needed a sibling
checkout to build at all. That's no longer a hard requirement now that the
SDK is on crates.io, but the pin hasn't been revisited — if the two drift,
nothing here forces them back in sync, since the two flakes are independent
files.
**As of 0.2.0 (2026-10-25), the SDK is published on crates.io** and this
crate depends on it as a normal version requirement
(`invoiceninja-sdk = "0.2.0"` in `Cargo.toml`), not a path dependency. That
means:
- No `../invoiceninjars-sdk` checkout is needed to build this crate anymore.
`cargo build` fetches `invoiceninja-sdk` from the registry like any other
dependency.
- Picking up a change made in the SDK's working copy now requires that
change to be published as a new crates.io release first by default — there
is no more "edit the sibling checkout, rebuild here" loop out of the box.
For active co-development across both crates, copy
`.cargo/config.toml.example` to `.cargo/config.toml` (gitignored, so it
stays per-developer and never affects CI or anyone else's build) to get
a `paths` override back onto `../invoiceninjars-sdk`. It requires that
checkout's `[package] version` to match this crate's `invoiceninja-sdk`
requirement in `Cargo.toml` — bump both together if the SDK's working
copy moves to an unreleased version. Delete `.cargo/config.toml` to go
back to the published release.
- Pre-1.0, a y-bump (`0.2.x` → `0.3.0`) in the SDK is a breaking release per
that crate's own `GOALS.md`. The default caret requirement already blocks
picking that up automatically; bumping the version in `Cargo.toml` is a
deliberate, reviewed step, not something `cargo update` should do on its
own.
**Watch the SDK's own package/crate name.** It has changed at least once
already (`invoiceninjars` → `invoiceninja-sdk`, crate path `invoiceninja_sdk`)
without a corresponding change here landing first. If a build fails with "no
matching package named `invoiceninja-sdk` found" or an unresolved
`invoiceninja_sdk::` path, check the crates.io listing (or
`../invoiceninjars-sdk/Cargo.toml`'s `[package] name`, if you have a
checkout) first.
### Rust version and CI
| | Version | Declared in |
| --- | --- | --- |
| Dev toolchain | 1.99.1 | `devVersion` in `flake.nix`, `test` job in `.github/workflows/ci.yml` |
| MSRV | 1.88 | `rust-version` in `Cargo.toml` (inherited from the SDK's own floor — see the comment there), `msrv` job in `ci.yml` |
Unlike `invoiceninjars-sdk`'s `flake.nix`, this crate's has no separate
`msrvVersion`/toolchain — the `msrv` CI job is the only place 1.88 actually
gets built against, not a Nix devShell. If that ever earns its keep, mirror
the SDK's `msrvToolchain`/`shellFor` pattern rather than inventing a new one.
CI runs on GitHub Actions, against the GitHub push mirror (Gitea is
canonical — see `.gitea`/remote config — but Actions run on GitHub, the
same arrangement as `invoiceninjars-sdk`'s own CI). Three jobs, mirroring
the SDK's `ci.yml` job-for-job:
- **`test`** — the pinned dev toolchain; `cargo build --all-targets`,
`cargo test`, `cargo fmt --check`, `cargo clippy --all-targets -- -D
warnings`.
- **`msrv`** — build and test only, on the declared 1.88 floor. No lint or
format check: those vary by release, and pinning them to the oldest
supported toolchain isn't useful.
- **`stable`** (advisory) — latest stable `rustc`, runs on push/PR and
weekly via `schedule` (Mondays 06:00 UTC) so a new Rust release that
breaks the build or fires a new clippy lint doesn't sit unnoticed for
weeks between pushes. Deliberately **not** `continue-on-error` — a real
red X, not a silent annotation — so if branch protection ever requires
status checks, keep `stable` **out** of the required list; that setting,
not anything in `ci.yml`, is what should stop it blocking merges. Expect
it red occasionally through no fault of the change under test, and fix it
when bumping `devVersion`.
The `test` and `msrv` jobs skip scheduled runs (`if: github.event_name !=
'schedule'`) — a pinned toolchain against a locked dependency graph can't
change on a timer, so the weekly run is for `stable` only.
### Testing against a real server
`flake.nix` has an `invoiceninja-nixos` input (Gitea, same host as this
repo's own remote) and re-exports two of its `nixosConfigurations` as
`invoiceninja-test-nginx`/`invoiceninja-test-caddy` — real NixOS VMs
running actual Invoice Ninja (PHP/Laravel + MariaDB + Redis + chromium for
PDF generation), not a mock. Reused as-is rather than redefined here, same
principle as this crate being a client of the SDK rather than a second
implementation of it — see that repo's own `CLAUDE.md` for what's actually
inside.
```sh
nix build --accept-flake-config '.#nixosConfigurations.invoiceninja-test-nginx.config.system.build.vm'
QEMU_NET_OPTS="hostfwd=tcp::8080-:80" ./result/bin/run-nixos-vm
```
`--accept-flake-config` is what lets the `nixConfig` cachix substituter
declared at the top of this file actually get used — without it (or if the
daemon's `trusted-users` rejects it anyway, which it did when scripted
through an agent sandbox in practice — built fine regardless, just slower)
Nix silently falls back to building from source. `QEMU_NET_OPTS` forwards
the guest's port 80 to host `8080`; it's a runtime flag to the generated
`run-nixos-vm` script, not baked into the VM config, so it has to be set on
every boot.
The VM has one fixed admin account baked in by the upstream repo's own test
fixtures (`admin@email.com` / `password`, from its `tests/invoice_ninja_test_password`) —
not meant to be secret, since it only ever exists inside a throwaway local
VM. There's no standing API token to copy anywhere; get one fresh each boot
via `/api/v1/login` (the SDK and CLI only ever use API-token auth, never
this login endpoint themselves — it's purely a test-fixture bootstrap
step):
```sh
TOKEN=$(curl -s -X POST http://localhost:8080/api/v1/login \
-H 'Content-Type: application/json' \
-d '{"email":"admin@email.com","password":"password"}' \
| jq -r '.data[0].token.token')
IN_API_TOKEN="$TOKEN" IN_BASE_URL=http://localhost:8080/api/v1 \
cargo run -- clients list
```
`jq` is in this flake's `devShells.default` for exactly this. There's also
an automated check (`checks.invoiceninja-cli` in `flake.nix`, backed by
`tests/`) that boots the same kind of VM and runs this CLI's own binary
against it non-interactively — see `tests/nixos-test.nix` for exactly what
it exercises. Verified working end to end (boot → login → token →
`clients create`/`list`) as of writing.
## Common commands
```sh
cargo build
cargo run -- clients list # needs IN_API_TOKEN, or --token
cargo fmt --check
cargo clippy --all-targets
```
No test suite yet — nothing here has behavior worth testing beyond what
`clap`'s derive and the SDK's own test suite already cover. Add one (clap's
`Cli::try_parse_from` is the usual way to test argument parsing without
spawning a process) when a command does more than translate flags into an
SDK call.
## Architecture
- `src/cli.rs` — all `clap` derive types. `Cli` holds the global
options (`--base-url`, `--token`, `--api-password`, `--output`) and the
top-level `Command` subcommand enum. **`--token` can't be clap-`required`**
because clap rejects `required` combined with `global` (global is what
lets `--token` appear after the resource subcommand too, not just before
it) — the missing case is caught in `config::build_client` instead, with a
message naming both the flag and the env var. Don't try to make it
`required` again; the comment on the field explains the panic you'd get.
- `src/config.rs` — `build_client` turns a parsed `Cli` into an
`InvoiceNinjaClient`, including the manual token check above.
- `src/body.rs` — how `create`/`update` get their JSON body (`--data`
inline, `--file` from disk or `-` for stdin, or stdin by default), plus
`merge_shallow`, a one-level-deep object overlay.
- `src/output.rs` — `print_json`, shared by every resource's `--output
json`. Table rendering is **not** here; it's per-resource (see below),
since there's no generic notion of which columns matter.
- `src/commands/clients.rs` — the first resource implemented, and the
template every other `src/commands/<resource>.rs` follows (33 of them now;
see "Adding a resource" below).
### The `update` fetch-merge-write pattern
`update` is not a thin wrapper over the SDK's `update_client`. It:
1. Fetches the current record with `get_client`.
2. Serializes it to `serde_json::Value` and shallow-merges the caller's
`--data`/`--file` JSON on top (`body::merge_shallow`).
3. Deserializes the merged value back into the model and calls
`update_client`.
This exists because Invoice Ninja's `PUT` is a full replace — there's no
PATCH route — and every non-`Option` field on an SDK model decodes a missing
key as that type's default (`""`, `0`, `false`; see
`invoiceninja_sdk`'s `null_to_default`). Sending the caller's patch alone
would silently blank out every field they didn't mention. **Keep this
pattern for every resource's `update`** — it's the CLI-level version of the
SDK's own "never lose data in a round trip" goal, not a one-off for clients.
`create` has no such step: a partial JSON body decodes straight into the
model, since the same defaulting that makes a bare patch dangerous for
`update` is exactly what you want when creating a record whose fields you
haven't all thought about (the API does the equivalent server-side).
### Adding a resource
Each resource beyond `clients` should follow the same shape:
1. Add a variant to `Command` in `src/cli.rs` (e.g. `Invoices(ResourceArgs)`)
— `ResourceArgs` and `Action` are already generic across resources, reuse
them as-is.
2. Add `src/commands/<resource>.rs` mirroring `clients.rs`: a `run` dispatcher
over `Action`, `list`/`get`/`create`/`update`/`delete` functions calling
the matching `list_<resource>`/`get_<resource>`/… methods on
`InvoiceNinjaClient`, and a `print_table` choosing that resource's own key
columns.
3. Register the module in `src/commands/mod.rs` and the dispatch arm in
`main.rs`'s `match cli.command`.
4. Check `../invoiceninjars-sdk/src/client.rs` for whether the resource has a
`/bulk` route (most do; a few — `client_gateway_tokens`, `companies`,
`system_logs` — don't, per that crate's `crud_methods!` vs
`resource_methods!` split). This CLI doesn't expose bulk actions yet
either way; that's a separate addition.
Resources with entity actions, document uploads, or non-CRUD endpoints
(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 <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`).
Generated
+1333
View File
File diff suppressed because it is too large Load Diff
+27
View File
@@ -0,0 +1,27 @@
[package]
name = "invoiceninja-cli"
version = "0.1.0"
edition = "2021"
# Matches the SDK's own MSRV. clap 4.6.7 declares 1.85 (see GOALS.md in
# invoiceninjars-sdk), which is under this, so the SDK's floor is still the
# binding one. Recheck both together if either dependency tree moves.
rust-version = "1.88"
description = "A command-line client for the Invoice Ninja v5 API, built on invoiceninja-sdk"
license = "MIT"
repository = "https://github.com/awkawb1/invoiceninja-cli"
# Slugs checked against https://crates.io/api/v1/category_slugs, same as
# invoiceninja-sdk's own Cargo.toml.
categories = ["command-line-utilities"]
keywords = ["invoiceninja", "invoicing", "billing", "cli"]
[dependencies]
# Published on crates.io as of 0.2.0 (2026-10-25); no longer a path
# dependency on a sibling checkout. Pre-1.0, a y-bump (0.2.x -> 0.3.0) is a
# breaking release per that crate's own GOALS.md, so stick with the default
# caret requirement rather than widening it — it already blocks that
# automatically while still taking 0.2.x patch releases.
invoiceninja-sdk = "0.2.0"
clap = { version = "4", features = ["derive", "env"] }
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
+136
View File
@@ -0,0 +1,136 @@
# Goals
What this crate is trying to be, what it is deliberately not, and what is
still open. Same purpose as `invoiceninjars-sdk`'s own `GOALS.md`: several
choices here look like oversights until you know the reasoning, and that
reasoning should live here rather than only in commit messages.
## What this is for
A command-line way to drive the Invoice Ninja v5 API through
`invoiceninja-sdk`, for the cases Rust code is the wrong tool for: a quick
"what does this endpoint actually return", a one-off fix from a terminal, or
a shell script that doesn't want to be a Rust program. The SDK's own
`GOALS.md` names this exact need — its `examples/` cover ad-hoc probing
today, and this crate is the standalone version of that.
It is a client over the SDK, not a second implementation of anything the SDK
already does. If a command needs logic beyond "turn flags and JSON into one
SDK call", that is usually a sign the logic belongs in the SDK instead.
## Goals
### 1. Every command is a thin translation, with one documented exception
`list`, `get`, `create`, and `delete` do nothing but turn CLI flags into a
`ListParams`/model/id and call the matching SDK method. **`update` is the
one exception**, and it stays an exception rather than a pattern to extend
casually: it fetches the record, overlays the caller's JSON on top, and
writes the merged result back, because Invoice Ninja's `PUT` is a full
replace and the SDK's models default an omitted field rather than leaving it
alone. See the comment in `src/commands/clients.rs` and the matching section
in `CLAUDE.md`. Any other deviation from "thin translation" should be
written down the same way.
### 2. `--output json` is always the full, lossless record
The table view is a convenience subset — whatever columns a resource's
`print_table` picks — and it is allowed to leave things out. `--output json`
is not: it is always the model's own `Serialize` output, including
`extra`, so it is a drop-in input to `--data`/`--file` for a later `update`
or `create` and a reliable thing to pipe into `jq`. This is the CLI-level
version of the SDK's "never lose data in a round trip" goal.
### 3. One pattern across resources
Adding a resource should be mechanical, not a new design each time: a
`Command` variant, a `src/commands/<resource>.rs` with the same
`run`/`list`/`get`/`create`/`update`/`delete`/`print_table` shape as
`clients.rs`, and a registration line in two places. `CLAUDE.md`'s "Adding a
resource" section is the checklist; if a resource needs something the
template doesn't cover (bulk, uploads, entity actions, non-CRUD endpoints),
that is scope for a deliberate addition to the template, not a one-off.
### 4. Scriptable by default
Errors go to stderr and a failure exits non-zero; nothing is printed to
stdout on an error path that a script could mistake for a result. Secrets
(`--token`, `--api-password`) never appear in `--help` or in any echoed
default — see `hide_env_values` on each in `src/cli.rs`. The SDK already
keeps both out of its own `Debug` output; this crate's job is not to undo
that by printing them itself.
### 5. Inherit the SDK's safety defaults rather than re-deciding them
This crate does not expose its own retry/timeout policy or add a write path
the SDK doesn't already consider safe. If the SDK decides `POST` isn't
replayed on a 5xx because it might double-create an invoice, that reasoning
applies here unchanged — the CLI is not a place to relax it for convenience.
### 6. CI mirrors the SDK's, job for job
`.github/workflows/ci.yml` has the same three jobs as `invoiceninjars-sdk`'s
own — pinned-toolchain `test`, build-only `msrv` on the declared floor, and
an advisory `stable` that reports red but stays out of required status
checks — rather than inventing a different shape for this crate. See
`CLAUDE.md`'s "Rust version and CI" for the per-job reasoning; a deviation
from this shape should be written down there the same way `update`'s
exception is written down in goal 1.
## Non-goals
- **No Cargo workspace with the SDK.** Explained in `CLAUDE.md`: the SDK is
its own git repo with its own CI and crates.io release, mirrored read-only
to GitHub. Now that the SDK is published (0.2.0, 2026-10-25), the
dependency is a plain crates.io version requirement — there isn't even a
path dependency linking the two build graphs anymore, let alone a
workspace. This doesn't mean co-development across both crates is gone:
`.cargo/config.toml.example` documents a per-developer `paths` override
back onto a local `../invoiceninjars-sdk` checkout, gitignored so it never
becomes the default for anyone else or for CI. See `CLAUDE.md`.
- **No interactive mode or TUI.** Every command should work non-interactively
from a script; a prompt-driven mode would be a different tool.
- **No local state, cache, or config file — yet.** Credentials and the base
URL come from flags or environment variables only, resolved fresh on every
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. `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
These are genuinely undecided; deciding one is a change to this file.
- **Bulk actions.** The SDK exposes `bulk_<resource>` (archive/restore/delete
plus resource-specific actions) on every resource with a `/bulk` route.
Nothing here calls it yet. Worth having, but a bulk `delete` from a CLI
invites a worse mistake than a one-record `delete` — whether that needs a
confirmation prompt, a `--yes` flag, or a dry-run preview is unresolved.
- **`--dry-run` for `create`/`update`/`delete`.** Would print what the merged
body or the target of a delete would be without sending the request.
Cheap for `create`/`delete`; for `update` it would mean exposing the
fetch-merge result for inspection before the write half runs.
- **Multi-account profiles.** Flags and env vars cover one account per
invocation. A config file keyed by profile name (base URL + token) would
help anyone working against more than one instance regularly, at the cost
of being the first place this crate stores anything on disk.
- **How closely should list flags track `ListParams`?** Right now `clients
list` exposes exactly what `ListParams` has typed setters for, plus
`--param key=value` for the rest. A per-resource convenience filter (e.g.
a `--unpaid` shorthand for `client_status=unpaid`) would read nicer but
means hand-picking which filters earn a dedicated flag per resource.
- **Whether the Nix packaging goes further than this repo's own flake.**
`flake.nix` now has `packages.default`/`devShells.default` (built with a
pinned `rust-overlay` toolchain, for parity between `nix build` and
`nix develop`) and `overlays.default` (for another flake to pull
`invoiceninja-cli` into its own `pkgs`, built with *that* nixpkgs's own
`rustPlatform` instead of the pin). The overlay path means this crate's
`rust-version = "1.88"` has to be met by whatever rustc the consuming
channel ships — there's no guard here if it isn't, just a build failure
over there. Whether that's worth submitting to nixpkgs proper (and how an
MSRV bump would then need to be communicated beyond this repo) is
unresolved.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 bryant
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+54
View File
@@ -1,2 +1,56 @@
# invoiceninja-cli
A command-line client for the [Invoice Ninja](https://www.invoiceninja.com/)
v5 API, built on [`invoiceninja-sdk`](https://crates.io/crates/invoiceninja-sdk).
> **Scaffold status.** Every resource with a simple list/get/create/update/
> delete API (33 of them) is wired up — see `CLAUDE.md` for what's still
> missing (bulk actions, uploads, entity actions, non-CRUD endpoints) and
> the pattern those will follow. [`GOALS.md`](GOALS.md) covers what the
> crate is for and what it is not.
## Development
Enter the dev shell (Rust toolchain via `rust-overlay`, matching the SDK's):
```sh
nix develop
```
## Usage
Every subcommand needs an API token and, for a self-hosted install, a base
URL. Both can be passed as flags or environment variables (the same names
the SDK's own examples use):
```sh
export IN_API_TOKEN=...
# export IN_BASE_URL=https://your-domain.example/api/v1 # self-hosted only
invoiceninja-cli clients list
invoiceninja-cli clients list --all --status active,archived
invoiceninja-cli clients get <id>
invoiceninja-cli clients create --data '{"name": "Acme Inc"}'
invoiceninja-cli clients update <id> --data '{"city": "Springfield"}'
invoiceninja-cli clients delete <id>
```
`create` and `update` read the record body as JSON, from `--data` inline,
`--file <path>` (`-` for stdin), or stdin if neither flag is given.
`update` fetches the current record first and overlays the given fields on
top of it, so you only need to mention what's changing — see the comment in
`src/body.rs` for why that matters (Invoice Ninja's `PUT` is a full replace,
not a patch).
Add `--output json` to any command for the full record instead of the
table view's few key columns:
```sh
invoiceninja-cli --output json clients get <id>
```
Every other wired-up resource follows the same
`list`/`get`/`create`/`update`/`delete` shape, e.g.
`invoiceninja-cli invoices list --status active` or
`invoiceninja-cli purchase-orders get <id>`. Run `invoiceninja-cli --help`
for the full list.
Generated
+249
View File
@@ -0,0 +1,249 @@
{
"nodes": {
"flake-compat": {
"flake": false,
"locked": {
"lastModified": 1767039857,
"narHash": "sha256-vNpUSpF5Nuw8xvDLj2KCwwksIbjua2LZCqhV1LNRDns=",
"owner": "NixOS",
"repo": "flake-compat",
"rev": "5edf11c44bc78a0d334f6334cdaf7d60d732daab",
"type": "github"
},
"original": {
"owner": "NixOS",
"repo": "flake-compat",
"type": "github"
}
},
"flake-parts": {
"inputs": {
"nixpkgs-lib": [
"invoiceninja-nixos",
"nix",
"nixpkgs"
]
},
"locked": {
"lastModified": 1782949081,
"narHash": "sha256-vp6Y/Grm98ESt6ceOkWiHWyZRDV3J1RID4w+6NWK9yA=",
"owner": "hercules-ci",
"repo": "flake-parts",
"rev": "17c9d6cdfc60c64f4ee8d306f9bc0b4ccb51481e",
"type": "github"
},
"original": {
"owner": "hercules-ci",
"repo": "flake-parts",
"type": "github"
}
},
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1791914995,
"narHash": "sha256-Zf/xfQEAasnwnmEfHqW/4hjDE/s44yn2hD4RuVIZRjA=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "b7a79d3fea1e214f6d668d521cecec0c01e26544",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"git-hooks-nix": {
"inputs": {
"flake-compat": [
"invoiceninja-nixos",
"nix"
],
"nixpkgs": [
"invoiceninja-nixos",
"nix",
"nixpkgs"
]
},
"locked": {
"lastModified": 1783008725,
"narHash": "sha256-jGiy6+sxjNWXSjp25uoJuNfyH9zBK1PEDY0lVoL4ibQ=",
"owner": "cachix",
"repo": "git-hooks.nix",
"rev": "bca82caa46d5ec0f5d422c61fb1e30bc51313cbe",
"type": "github"
},
"original": {
"owner": "cachix",
"repo": "git-hooks.nix",
"type": "github"
}
},
"invoiceninja-nixos": {
"inputs": {
"nix": "nix",
"nixpkgs": "nixpkgs_2"
},
"locked": {
"lastModified": 1792704906,
"narHash": "sha256-RnfRh7mVgwqiTA+lbBCq35SllrINUu1I+klov26Aiq0=",
"ref": "refs/heads/master",
"rev": "badcdd25666d5a59d221fc4331f8691ce3167755",
"revCount": 71,
"type": "git",
"url": "ssh://gitea@gitea.awkawb.cloud/awkawb/invoiceninja-nixos.git"
},
"original": {
"type": "git",
"url": "ssh://gitea@gitea.awkawb.cloud/awkawb/invoiceninja-nixos.git"
}
},
"nix": {
"inputs": {
"flake-compat": "flake-compat",
"flake-parts": "flake-parts",
"git-hooks-nix": "git-hooks-nix",
"nixpkgs": "nixpkgs",
"nixpkgs-23-11": "nixpkgs-23-11",
"nixpkgs-regression": "nixpkgs-regression"
},
"locked": {
"lastModified": 1786478010,
"narHash": "sha256-/9wXGp+aLObxqQuOGhDOnbdV0QCRDHH8uSh/4URKVAg=",
"owner": "NixOS",
"repo": "nix",
"rev": "24b65e35d9d4c9d64c1bd5844a35f2a779356d63",
"type": "github"
},
"original": {
"id": "nix",
"type": "indirect"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1783148766,
"narHash": "sha256-H9+N+GFtsbVC8ZniHliChM7ndizxtqVZs6bnGOLM3WQ=",
"rev": "a50de1b7d8a586adc18d2395c19de7d6058e6030",
"type": "tarball",
"url": "https://releases.nixos.org/nixos/26.05/nixos-26.05.4193.a50de1b7d8a5/nixexprs.tar.xz"
},
"original": {
"type": "tarball",
"url": "https://channels.nixos.org/nixos-26.05/nixexprs.tar.xz"
}
},
"nixpkgs-23-11": {
"locked": {
"lastModified": 1717159533,
"narHash": "sha256-oamiKNfr2MS6yH64rUn99mIZjc45nGJlj9eGth/3Xuw=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "a62e6edd6d5e1fa0329b8653c801147986f8d446",
"type": "github"
},
"original": {
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "a62e6edd6d5e1fa0329b8653c801147986f8d446",
"type": "github"
}
},
"nixpkgs-regression": {
"locked": {
"lastModified": 1643052045,
"narHash": "sha256-uGJ0VXIhWKGXxkeNnq4TvV3CIOkUJ3PAoLZ3HMzNVMw=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "215d4d0fd80ca5163643b03a33fde804a29cc1e2",
"type": "github"
},
"original": {
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "215d4d0fd80ca5163643b03a33fde804a29cc1e2",
"type": "github"
}
},
"nixpkgs_2": {
"locked": {
"lastModified": 1786430034,
"narHash": "sha256-Vux08kA5PICwS2sViCMfwVLAHNoH8TkKAeBo25LjpMI=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "70cc4559b10a6062b05ff1af17e0add065ccaed9",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-26.05",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs_3": {
"locked": {
"lastModified": 1792773339,
"narHash": "sha256-rZLrzrC6i+jnTAlvPZHogKDulY7WWDEv9hJeHieO7Zw=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "3ec636362e1816bc2f0888fa0fa2c69f1bc9e504",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"flake-utils": "flake-utils",
"invoiceninja-nixos": "invoiceninja-nixos",
"nixpkgs": "nixpkgs_3",
"rust-overlay": "rust-overlay"
}
},
"rust-overlay": {
"inputs": {
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1792913333,
"narHash": "sha256-wjoZGhxH/Yr+OsFRpNyV9DpMGQIdNEjjAOinfzQvadk=",
"owner": "oxalica",
"repo": "rust-overlay",
"rev": "fad80eb74614f7dc37c1c83df8caae64361eda22",
"type": "github"
},
"original": {
"owner": "oxalica",
"repo": "rust-overlay",
"type": "github"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
}
},
"root": "root",
"version": 7
}
+146
View File
@@ -0,0 +1,146 @@
{
description = "invoiceninja-cli: a command-line client for the Invoice Ninja v5 API";
# Mirrors invoiceninja-nixos's own nixConfig: without this, building
# `nixosConfigurations.invoiceninja-test-*` here rebuilds the whole PHP/UI
# app (and its chromium-headless-shell closure) from source instead of
# pulling the cached result.
nixConfig = {
extra-substituters = [ "https://invoiceninja-nixos.cachix.org" ];
extra-trusted-public-keys = [
"invoiceninja-nixos.cachix.org-1:TmRZaji8FFVjjIw9KZmvLrxMxfP+JDThOyOX04z526A="
];
};
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
rust-overlay.url = "github:oxalica/rust-overlay";
rust-overlay.inputs.nixpkgs.follows = "nixpkgs";
flake-utils.url = "github:numtide/flake-utils";
# For a real Invoice Ninja instance to test the CLI against — see
# CLAUDE.md's "Testing against a real server". Not `follows`-ed to this
# flake's own `nixpkgs`: it pins its own (nixos-26.05, as of writing),
# and `nixosConfigurations.test-nginx`/`test-caddy` below are consumed
# as already-built flake outputs, not re-evaluated against our
# nixos-unstable — the two never need to agree.
invoiceninja-nixos.url = "git+ssh://gitea@gitea.awkawb.cloud/awkawb/invoiceninja-nixos.git";
};
outputs = { self, nixpkgs, rust-overlay, flake-utils, invoiceninja-nixos, ... }:
let
cargoToml = builtins.fromTOML (builtins.readFile ./Cargo.toml);
# Shared between `packages.default` (built below with the pinned
# rust-overlay toolchain, for parity with `nix develop`) and
# `overlays.default` (built with whatever `rustPlatform` the
# *consuming* nixpkgs provides, per the usual nixpkgs packaging
# convention — see the note on `overlays.default` below). Takes its
# inputs as a plain attrset so both call sites, and `callPackage`,
# can hand it the pieces however they're sourced.
mkInvoiceninjaCli = { lib, rustPlatform, pkg-config, openssl }:
rustPlatform.buildRustPackage {
pname = "invoiceninja-cli";
version = cargoToml.package.version;
src = self;
cargoLock.lockFile = ./Cargo.lock;
nativeBuildInputs = [ pkg-config ];
buildInputs = [ openssl ];
meta = {
description = cargoToml.package.description;
license = lib.licenses.mit;
mainProgram = "invoiceninja-cli";
platforms = lib.platforms.unix;
};
};
in
flake-utils.lib.eachDefaultSystem (system:
let
pkgs = import nixpkgs {
inherit system;
overlays = [ rust-overlay.overlays.default ];
};
# Kept in step with invoiceninjars-sdk's own `devVersion`, so the two
# sibling crates build with the same toolchain during development.
devVersion = "1.99.1";
rustToolchain = pkgs.rust-bin.stable.${devVersion}.default.override {
extensions = [ "rust-src" "rust-analyzer" "clippy" "rustfmt" ];
};
# Built with the pinned dev toolchain, not nixpkgs' own rustc, so a
# `nix build` matches what `nix develop` compiles with.
rustPlatform = pkgs.makeRustPlatform {
cargo = rustToolchain;
rustc = rustToolchain;
};
in
{
devShells.default = pkgs.mkShell {
# jq is for parsing the /api/v1/login response when testing
# against a real server — see CLAUDE.md's "Testing against a
# real server".
packages = [ rustToolchain pkgs.pkg-config pkgs.openssl pkgs.jq ];
RUST_SRC_PATH = "${rustToolchain}/lib/rustlib/src/rust/library";
};
# Unlike invoiceninjars-sdk, this crate has a `[[bin]]` target, so
# `nix build` produces an installable binary, not just a test gate.
# `invoiceninja-sdk` resolves from crates.io (see Cargo.toml), so
# unlike before this no longer needs a `../invoiceninjars-sdk`
# checkout to be present in the sandbox.
packages.default = mkInvoiceninjaCli {
inherit (pkgs) lib pkg-config openssl;
inherit rustPlatform;
};
# Automated: boots a real Invoice Ninja instance (invoiceninja-nixos's
# module, not a mock) and runs actual `invoiceninja-cli` commands
# against it — create/list/get/update/delete across clients,
# invoices, and payments, including that `update` preserves fields
# it wasn't told about. `tests/` is a vendored, adapted copy of
# invoiceninja-nixos's own test scaffolding; see its
# nixos-test.nix for what it does and doesn't check (it
# deliberately skips things like PDF generation and the language-
# list regression, which are that repo's own concern, not this
# CLI's). Uses invoiceninja-nixos's own pinned nixpkgs (26.05), not
# this flake's nixos-unstable, to build+evaluate the VM/module —
# matches what that module was actually tested against. The CLI
# binary itself (`self.packages.${system}.default`, built against
# our own nixos-unstable) runs fine inside it regardless: Nix
# binaries carry their own closure, so differing nixpkgs revisions
# in the same system closure is an explicitly supported case, not a
# version mismatch to work around.
checks.invoiceninja-cli = import ./tests/nixos-test.nix {
nixpkgsPath = invoiceninja-nixos.inputs.nixpkgs;
inherit system;
invoiceninjaModule = invoiceninja-nixos.nixosModules.invoiceninja;
invoiceninjaCliPkg = self.packages.${system}.default;
};
}) // {
# For a flake that wants `invoiceninja-cli` in its own `pkgs`, not for
# this flake's own `packages.default`/`devShells.default` above (those
# keep the pinned rust-overlay toolchain). `callPackage` resolves
# `rustPlatform` from the *consuming* nixpkgs, which means it builds
# with whatever rustc that channel ships — this crate's `rust-version
# = "1.88"` in Cargo.toml has to be met there too, same as any other
# nixpkgs Rust package; this overlay doesn't carry the rust-overlay
# pin along with it.
overlays.default = final: _prev: {
invoiceninja-cli = final.callPackage mkInvoiceninjaCli { };
};
# Real Invoice Ninja instances to test this CLI against, reusing
# invoiceninja-nixos's own test VMs as-is rather than redefining them
# here — same principle as this crate being a client of the SDK, not
# a second implementation of it. Known test credentials baked into
# that repo's tests/common.nix: admin@email.com / password (from
# tests/invoice_ninja_test_password). See CLAUDE.md for how to boot
# one and get an API token from it.
nixosConfigurations = {
invoiceninja-test-nginx = invoiceninja-nixos.nixosConfigurations.test-nginx;
invoiceninja-test-caddy = invoiceninja-nixos.nixosConfigurations.test-caddy;
};
};
}
+50
View File
@@ -0,0 +1,50 @@
use std::fs;
use std::io::Read;
use serde_json::Value;
use crate::cli::BodyArgs;
/// Reads the JSON body a `create`/`update` command was given: `--data`
/// inline, `--file` from disk (`-` for stdin), or stdin if neither flag was
/// passed. Errors if the result isn't a JSON object — every create/update
/// endpoint here takes a record, not a bare array or scalar.
pub fn load(body: &BodyArgs) -> Result<Value, Box<dyn std::error::Error>> {
let text = match (&body.data, &body.file) {
(Some(data), None) => data.clone(),
(None, Some(path)) if path.as_os_str() == "-" => read_stdin()?,
(None, Some(path)) => fs::read_to_string(path)?,
(None, None) => read_stdin()?,
// clap's `conflicts_with` on both fields rules this out before we get here.
(Some(_), Some(_)) => unreachable!("--data and --file are mutually exclusive"),
};
let value: Value = serde_json::from_str(&text)?;
if !value.is_object() {
return Err(format!("expected a JSON object, got {value}").into());
}
Ok(value)
}
fn read_stdin() -> Result<String, Box<dyn std::error::Error>> {
let mut buf = String::new();
std::io::stdin().read_to_string(&mut buf)?;
Ok(buf)
}
/// Overlays `patch`'s keys onto `base`, one level deep.
///
/// `update` uses this so a caller can send just the fields they're changing
/// instead of retyping the whole record. That matters because Invoice
/// Ninja's `PUT` is a full replace — there's no PATCH route — and the SDK's
/// models default any field the caller omits to `""` / `0` / `false` rather
/// than leaving it alone. Sending the patch by itself would blank out every
/// field it didn't mention; merging onto a freshly-fetched copy keeps them.
pub fn merge_shallow(mut base: Value, patch: Value) -> Value {
if let (Some(base_map), Value::Object(patch_map)) = (base.as_object_mut(), patch) {
for (k, v) in patch_map {
base_map.insert(k, v);
}
}
base
}
+255
View File
@@ -0,0 +1,255 @@
use std::path::PathBuf;
use clap::{Args, Parser, Subcommand, ValueEnum};
/// A command-line client for the Invoice Ninja v5 API, built on the
/// `invoiceninja-sdk` crate.
#[derive(Parser)]
#[command(name = "invoiceninja-cli", version, about)]
pub struct Cli {
/// API root, including the `/api/v1` suffix. Defaults to the hosted app
/// (`invoicing.co`); a self-hosted install must pass this.
#[arg(long, env = "IN_BASE_URL", global = true)]
pub base_url: Option<String>,
/// API token. Required for every call.
///
/// Typed `Option` rather than clap-`required`: clap refuses to combine
/// `required` with `global` (needed so this can also be passed after the
/// subcommand), so the missing case is checked in `config::build_client`
/// instead, with a message that names both ways to supply it.
/// `hide_env_values` keeps it out of `--help`, which otherwise echoes
/// env-sourced defaults back verbatim.
#[arg(long, env = "IN_API_TOKEN", global = true, hide_env_values = true)]
pub token: Option<String>,
/// Account password, needed only for the endpoints Invoice Ninja marks
/// `password_protected` (reading or writing a user, purging a client).
#[arg(long, env = "IN_API_PASSWORD", global = true, hide_env_values = true)]
pub api_password: Option<String>,
#[arg(long, global = true, value_enum, default_value_t = OutputFormat::Table)]
pub output: OutputFormat,
#[command(subcommand)]
pub command: Command,
}
#[derive(Clone, Copy, Debug, ValueEnum)]
pub enum OutputFormat {
/// A few key columns, one record per line. Which columns count as "key"
/// is a per-resource choice, made in that resource's command module.
Table,
/// The full record(s), pretty-printed. Always lossless — use this to see
/// every field a table view leaves out, including `extra`.
Json,
}
/// One variant per resource. Every resource that fits the simple CRUD
/// template (`resource_methods!`/`crud_methods!` in the SDK) is wired up;
/// see CLAUDE.md's "Adding a resource" for what's deliberately left out
/// (bulk actions, uploads, entity actions, non-CRUD endpoints).
#[derive(Subcommand)]
pub enum Command {
/// Manage clients
Clients(ResourceArgs),
/// Manage invoices
Invoices(EntityResourceArgs),
/// Manage quotes
Quotes(EntityResourceArgs),
/// Manage credits
Credits(EntityResourceArgs),
/// Manage payments
Payments(ResourceArgs),
/// Manage products
Products(ResourceArgs),
/// Manage expenses
Expenses(ResourceArgs),
/// Manage projects
Projects(ResourceArgs),
/// Manage tasks
Tasks(ResourceArgs),
/// Manage vendors
Vendors(ResourceArgs),
/// Manage purchase orders
PurchaseOrders(EntityResourceArgs),
/// Manage recurring invoices
RecurringInvoices(ResourceArgs),
/// Manage recurring quotes
RecurringQuotes(ResourceArgs),
/// Manage recurring expenses
RecurringExpenses(ResourceArgs),
/// Manage tax rates
TaxRates(ResourceArgs),
/// Manage expense categories
ExpenseCategories(ResourceArgs),
/// Manage task statuses
TaskStatuses(ResourceArgs),
/// Manage payment terms
PaymentTerms(ResourceArgs),
/// Manage subscriptions
Subscriptions(ResourceArgs),
/// Manage bank transactions
BankTransactions(ResourceArgs),
/// Manage webhooks
Webhooks(ResourceArgs),
/// Manage bank integrations
BankIntegrations(ResourceArgs),
/// Manage bank transaction rules
BankTransactionRules(ResourceArgs),
/// Manage company gateways
CompanyGateways(ResourceArgs),
/// Manage designs
Designs(ResourceArgs),
/// Manage group settings
GroupSettings(ResourceArgs),
/// Manage locations
Locations(ResourceArgs),
/// Manage tags
Tags(ResourceArgs),
/// Manage task schedulers
TaskSchedulers(ResourceArgs),
/// Manage API tokens
Tokens(ResourceArgs),
/// Manage client gateway tokens
ClientGatewayTokens(ResourceArgs),
/// Manage companies
Companies(ResourceArgs),
/// Manage system logs
SystemLogs(ResourceArgs),
}
#[derive(Args)]
pub struct ResourceArgs {
#[command(subcommand)]
pub action: Action,
}
/// The five actions every resource exposes. `Create`/`Update` share
/// `BodyArgs` because both take a JSON record; see `body::load`.
#[derive(Subcommand)]
pub enum Action {
/// 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.
///
/// Fetches the record, overlays the given JSON on top, and writes the
/// merged result back — see the comment in `commands::clients::update`
/// for why a plain PUT of just the patch would blank out the rest.
Update {
id: String,
#[command(flatten)]
body: BodyArgs,
},
/// Delete (soft-delete) a record by id
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
/// `invoiceninja_sdk::models::ListParams`.
#[derive(Args)]
pub struct ListArgs {
#[arg(long)]
pub page: Option<u64>,
#[arg(long)]
pub per_page: Option<u64>,
#[arg(long)]
pub sort: Option<String>,
#[arg(long)]
pub filter: Option<String>,
/// Restricts which records come back, e.g. "active" to exclude
/// archived/deleted ones. **Omitting this flag does not do that
/// filtering for you** — confirmed against a real server, `list` with
/// no `--status` returns every record regardless of `is_deleted`/
/// `archived_at`. Pass "active,archived,deleted" (or any subset) to
/// narrow it.
#[arg(long)]
pub status: Option<String>,
/// Comma-separated relationships to eager-load, e.g. "contacts"
#[arg(long)]
pub include: Option<String>,
/// Extra query parameter, as key=value. Repeatable, for the per-resource
/// filters `ListParams::param` covers (`client_id`, `client_status`, …).
#[arg(long = "param", value_parser = parse_key_val)]
pub params: Vec<(String, String)>,
/// Follow pagination and return every matching record instead of one
/// page (`list_all_*` in the SDK rather than `list_*`).
#[arg(long)]
pub all: bool,
}
#[derive(Args)]
pub struct BodyArgs {
/// The record body, as a JSON object, given inline.
#[arg(long, conflicts_with = "file")]
pub data: Option<String>,
/// The record body, as a JSON object, read from a file ("-" for stdin).
#[arg(long, conflicts_with = "data")]
pub file: Option<PathBuf>,
}
fn parse_key_val(s: &str) -> Result<(String, String), String> {
let (k, v) = s
.split_once('=')
.ok_or_else(|| format!("expected key=value, got `{s}`"))?;
Ok((k.to_string(), v.to_string()))
}
+140
View File
@@ -0,0 +1,140 @@
//! `bank-integrations` subcommand. Same shape as `clients.rs` — see the
//! "Adding a resource" section in CLAUDE.md.
use invoiceninja_sdk::models::{BankIntegration, 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 bank_integrations = if args.all {
client.list_all_bank_integrations(&params).await?
} else {
client.list_bank_integrations(&params).await?
};
render(&bank_integrations, format)
}
async fn get(
client: &InvoiceNinjaClient,
id: &str,
format: OutputFormat,
) -> Result<(), Box<dyn std::error::Error>> {
let found = client.get_bank_integration(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: BankIntegration = serde_json::from_value(value)?;
let created = client.create_bank_integration(&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_bank_integration(id).await?;
let merged = body::merge_shallow(serde_json::to_value(&current)?, patch);
let model: BankIntegration = serde_json::from_value(merged)?;
let updated = client.update_bank_integration(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_bank_integration(id).await?;
println!("deleted {id}");
Ok(())
}
fn render(
bank_integrations: &[BankIntegration],
format: OutputFormat,
) -> Result<(), Box<dyn std::error::Error>> {
match format {
OutputFormat::Json => output::print_json(bank_integrations)?,
OutputFormat::Table => print_table(bank_integrations),
}
Ok(())
}
fn print_table(bank_integrations: &[BankIntegration]) {
if bank_integrations.is_empty() {
println!("(no records)");
return;
}
println!(
"{:<20} {:<20} {:<15} {:>12} {:<8}",
"id", "bank_account_name", "type", "balance", "currency"
);
for b in bank_integrations {
println!(
"{:<20} {:<20} {:<15} {:>12.2} {:<8}",
b.id.as_deref().unwrap_or(""),
truncate(&b.bank_account_name, 20),
truncate(&b.bank_account_type, 15),
b.balance,
b.currency
);
}
}
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>() + "…"
}
}
+139
View File
@@ -0,0 +1,139 @@
//! `bank-transaction-rules` subcommand. Same shape as `clients.rs` — see
//! the "Adding a resource" section in CLAUDE.md.
use invoiceninja_sdk::models::{BankTransactionRule, 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 bank_transaction_rules = if args.all {
client.list_all_bank_transaction_rules(&params).await?
} else {
client.list_bank_transaction_rules(&params).await?
};
render(&bank_transaction_rules, format)
}
async fn get(
client: &InvoiceNinjaClient,
id: &str,
format: OutputFormat,
) -> Result<(), Box<dyn std::error::Error>> {
let found = client.get_bank_transaction_rule(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: BankTransactionRule = serde_json::from_value(value)?;
let created = client.create_bank_transaction_rule(&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_bank_transaction_rule(id).await?;
let merged = body::merge_shallow(serde_json::to_value(&current)?, patch);
let model: BankTransactionRule = serde_json::from_value(merged)?;
let updated = client.update_bank_transaction_rule(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_bank_transaction_rule(id).await?;
println!("deleted {id}");
Ok(())
}
fn render(
bank_transaction_rules: &[BankTransactionRule],
format: OutputFormat,
) -> Result<(), Box<dyn std::error::Error>> {
match format {
OutputFormat::Json => output::print_json(bank_transaction_rules)?,
OutputFormat::Table => print_table(bank_transaction_rules),
}
Ok(())
}
fn print_table(bank_transaction_rules: &[BankTransactionRule]) {
if bank_transaction_rules.is_empty() {
println!("(no records)");
return;
}
println!(
"{:<20} {:<30} {:<15} {:<8}",
"id", "name", "applies_to", "auto"
);
for r in bank_transaction_rules {
println!(
"{:<20} {:<30} {:<15} {:<8}",
r.id.as_deref().unwrap_or(""),
truncate(&r.name, 30),
truncate(&r.applies_to, 15),
r.auto_convert
);
}
}
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>() + "…"
}
}
+140
View File
@@ -0,0 +1,140 @@
//! `bank-transactions` subcommand. Same shape as `clients.rs` — see the
//! "Adding a resource" section in CLAUDE.md.
use invoiceninja_sdk::models::{BankTransaction, 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 bank_transactions = if args.all {
client.list_all_bank_transactions(&params).await?
} else {
client.list_bank_transactions(&params).await?
};
render(&bank_transactions, format)
}
async fn get(
client: &InvoiceNinjaClient,
id: &str,
format: OutputFormat,
) -> Result<(), Box<dyn std::error::Error>> {
let found = client.get_bank_transaction(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: BankTransaction = serde_json::from_value(value)?;
let created = client.create_bank_transaction(&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_bank_transaction(id).await?;
let merged = body::merge_shallow(serde_json::to_value(&current)?, patch);
let model: BankTransaction = serde_json::from_value(merged)?;
let updated = client.update_bank_transaction(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_bank_transaction(id).await?;
println!("deleted {id}");
Ok(())
}
fn render(
bank_transactions: &[BankTransaction],
format: OutputFormat,
) -> Result<(), Box<dyn std::error::Error>> {
match format {
OutputFormat::Json => output::print_json(bank_transactions)?,
OutputFormat::Table => print_table(bank_transactions),
}
Ok(())
}
fn print_table(bank_transactions: &[BankTransaction]) {
if bank_transactions.is_empty() {
println!("(no records)");
return;
}
println!(
"{:<20} {:<12} {:<30} {:>12} {:<8}",
"id", "date", "description", "amount", "type"
);
for t in bank_transactions {
println!(
"{:<20} {:<12} {:<30} {:>12.2} {:<8}",
t.id.as_deref().unwrap_or(""),
t.date,
truncate(&t.description, 30),
t.amount,
t.base_type
);
}
}
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>() + "…"
}
}
+142
View File
@@ -0,0 +1,142 @@
//! `client-gateway-tokens` subcommand. Same shape as `clients.rs` — see
//! the "Adding a resource" section in CLAUDE.md. Uses the SDK's
//! `crud_methods!` rather than `resource_methods!` (no `/bulk` route), which
//! doesn't change anything here — this CLI doesn't expose bulk actions yet
//! either way.
use invoiceninja_sdk::models::{ClientGatewayToken, 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 client_gateway_tokens = if args.all {
client.list_all_client_gateway_tokens(&params).await?
} else {
client.list_client_gateway_tokens(&params).await?
};
render(&client_gateway_tokens, format)
}
async fn get(
client: &InvoiceNinjaClient,
id: &str,
format: OutputFormat,
) -> Result<(), Box<dyn std::error::Error>> {
let found = client.get_client_gateway_token(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: ClientGatewayToken = serde_json::from_value(value)?;
let created = client.create_client_gateway_token(&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_client_gateway_token(id).await?;
let merged = body::merge_shallow(serde_json::to_value(&current)?, patch);
let model: ClientGatewayToken = serde_json::from_value(merged)?;
let updated = client.update_client_gateway_token(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_client_gateway_token(id).await?;
println!("deleted {id}");
Ok(())
}
fn render(
client_gateway_tokens: &[ClientGatewayToken],
format: OutputFormat,
) -> Result<(), Box<dyn std::error::Error>> {
match format {
OutputFormat::Json => output::print_json(client_gateway_tokens)?,
OutputFormat::Table => print_table(client_gateway_tokens),
}
Ok(())
}
fn print_table(client_gateway_tokens: &[ClientGatewayToken]) {
if client_gateway_tokens.is_empty() {
println!("(no records)");
return;
}
println!(
"{:<20} {:<20} {:<20} {:<10}",
"id", "client_id", "company_gateway_id", "is_default"
);
for t in client_gateway_tokens {
println!(
"{:<20} {:<20} {:<20} {:<10}",
t.id.as_deref().unwrap_or(""),
truncate(&t.client_id, 20),
truncate(&t.company_gateway_id, 20),
t.is_default
);
}
}
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>() + "…"
}
}
+143
View File
@@ -0,0 +1,143 @@
//! `clients` subcommand. First resource wired up, and the template for
//! adding the rest — see the "Adding a resource" section in CLAUDE.md.
use invoiceninja_sdk::models::{Client, 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 clients = if args.all {
client.list_all_clients(&params).await?
} else {
client.list_clients(&params).await?
};
render(&clients, format)
}
async fn get(
client: &InvoiceNinjaClient,
id: &str,
format: OutputFormat,
) -> Result<(), Box<dyn std::error::Error>> {
let found = client.get_client(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)?;
// Missing fields in `value` decode fine: every non-Option field on
// `Client` is `#[serde(default, deserialize_with = "null_to_default")]`,
// so a partial object (e.g. just `{"name": "..."}`) fills the rest with
// the model's defaults, same as the API does server-side on create.
let model: Client = serde_json::from_value(value)?;
let created = client.create_client(&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_client(id).await?;
let merged = body::merge_shallow(serde_json::to_value(&current)?, patch);
let model: Client = serde_json::from_value(merged)?;
let updated = client.update_client(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_client(id).await?;
println!("deleted {id}");
Ok(())
}
fn render(clients: &[Client], format: OutputFormat) -> Result<(), Box<dyn std::error::Error>> {
match format {
OutputFormat::Json => output::print_json(clients)?,
OutputFormat::Table => print_table(clients),
}
Ok(())
}
/// The columns considered "key" for a client. Every resource module picks
/// its own this way — there's no generic notion of which fields matter
/// enough for a table view.
fn print_table(clients: &[Client]) {
if clients.is_empty() {
println!("(no records)");
return;
}
println!(
"{:<20} {:<30} {:>12} {:>12}",
"id", "name", "balance", "paid_to_date"
);
for c in clients {
println!(
"{:<20} {:<30} {:>12.2} {:>12.2}",
c.id.as_deref().unwrap_or(""),
truncate(&c.name, 30),
c.balance,
c.paid_to_date
);
}
}
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>() + "…"
}
}
+138
View File
@@ -0,0 +1,138 @@
//! `companies` subcommand. Same shape as `clients.rs` — see the "Adding a
//! resource" section in CLAUDE.md. Uses the SDK's `crud_methods!` rather
//! than `resource_methods!` (no `/bulk` route), which doesn't change
//! anything here — this CLI doesn't expose bulk actions yet either way.
use invoiceninja_sdk::models::{Company, 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 companies = if args.all {
client.list_all_companies(&params).await?
} else {
client.list_companies(&params).await?
};
render(&companies, format)
}
async fn get(
client: &InvoiceNinjaClient,
id: &str,
format: OutputFormat,
) -> Result<(), Box<dyn std::error::Error>> {
let found = client.get_company(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: Company = serde_json::from_value(value)?;
let created = client.create_company(&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_company(id).await?;
let merged = body::merge_shallow(serde_json::to_value(&current)?, patch);
let model: Company = serde_json::from_value(merged)?;
let updated = client.update_company(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_company(id).await?;
println!("deleted {id}");
Ok(())
}
fn render(companies: &[Company], format: OutputFormat) -> Result<(), Box<dyn std::error::Error>> {
match format {
OutputFormat::Json => output::print_json(companies)?,
OutputFormat::Table => print_table(companies),
}
Ok(())
}
fn print_table(companies: &[Company]) {
if companies.is_empty() {
println!("(no records)");
return;
}
println!(
"{:<20} {:<20} {:<20} {:<10}",
"id", "subdomain", "portal_domain", "disabled"
);
for c in companies {
println!(
"{:<20} {:<20} {:<20} {:<10}",
c.id.as_deref().unwrap_or(""),
truncate(&c.subdomain, 20),
truncate(&c.portal_domain, 20),
c.is_disabled
);
}
}
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>() + "…"
}
}
+139
View File
@@ -0,0 +1,139 @@
//! `company-gateways` subcommand. Same shape as `clients.rs` — see the
//! "Adding a resource" section in CLAUDE.md.
use invoiceninja_sdk::models::{CompanyGateway, 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 company_gateways = if args.all {
client.list_all_company_gateways(&params).await?
} else {
client.list_company_gateways(&params).await?
};
render(&company_gateways, format)
}
async fn get(
client: &InvoiceNinjaClient,
id: &str,
format: OutputFormat,
) -> Result<(), Box<dyn std::error::Error>> {
let found = client.get_company_gateway(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: CompanyGateway = serde_json::from_value(value)?;
let created = client.create_company_gateway(&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_company_gateway(id).await?;
let merged = body::merge_shallow(serde_json::to_value(&current)?, patch);
let model: CompanyGateway = serde_json::from_value(merged)?;
let updated = client.update_company_gateway(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_company_gateway(id).await?;
println!("deleted {id}");
Ok(())
}
fn render(
company_gateways: &[CompanyGateway],
format: OutputFormat,
) -> Result<(), Box<dyn std::error::Error>> {
match format {
OutputFormat::Json => output::print_json(company_gateways)?,
OutputFormat::Table => print_table(company_gateways),
}
Ok(())
}
fn print_table(company_gateways: &[CompanyGateway]) {
if company_gateways.is_empty() {
println!("(no records)");
return;
}
println!(
"{:<20} {:<20} {:>12} {:<10}",
"id", "gateway_key", "cards_bitmask", "req_billing"
);
for g in company_gateways {
println!(
"{:<20} {:<20} {:>12} {:<10}",
g.id.as_deref().unwrap_or(""),
truncate(&g.gateway_key, 20),
g.accepted_credit_cards,
g.require_billing_address
);
}
}
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>() + "…"
}
}
+172
View File
@@ -0,0 +1,172 @@
//! `credits` subcommand. Same shape as `clients.rs` — see the "Adding a
//! resource" section in CLAUDE.md. `Credit` is a type alias for `Invoice`
//! (Invoice Ninja stores credits in the same table/schema, distinguished by
//! an `entity_type`), same as `quotes.rs`.
use std::io::Write;
use std::path::PathBuf;
use invoiceninja_sdk::models::{Credit, ListParams};
use invoiceninja_sdk::InvoiceNinjaClient;
use crate::body;
use crate::cli::{BodyArgs, EntityAction, EntityResourceArgs, ListArgs, OutputFormat};
use crate::output;
pub async fn run(
client: &InvoiceNinjaClient,
args: EntityResourceArgs,
format: OutputFormat,
) -> Result<(), Box<dyn std::error::Error>> {
match args.action {
EntityAction::List(list_args) => list(client, list_args, format).await,
EntityAction::Get { id } => get(client, &id, format).await,
EntityAction::Create(body_args) => create(client, body_args, format).await,
EntityAction::Update { id, body } => update(client, &id, body, format).await,
EntityAction::Delete { id } => delete(client, &id).await,
EntityAction::Action { id, action } => do_action(client, &id, &action, format).await,
EntityAction::Download { ids, out } => download(client, &ids, out).await,
}
}
async fn list(
client: &InvoiceNinjaClient,
args: ListArgs,
format: OutputFormat,
) -> Result<(), Box<dyn std::error::Error>> {
let mut params = ListParams::new();
if let Some(page) = args.page {
params = params.page(page);
}
if let Some(per_page) = args.per_page {
params = params.per_page(per_page);
}
if let Some(sort) = args.sort {
params = params.sort(sort);
}
if let Some(filter) = args.filter {
params = params.filter(filter);
}
if let Some(status) = args.status {
params = params.status(status);
}
if let Some(include) = args.include {
params = params.include(include);
}
for (k, v) in args.params {
params = params.param(k, v);
}
let credits = if args.all {
client.list_all_credits(&params).await?
} else {
client.list_credits(&params).await?
};
render(&credits, format)
}
async fn get(
client: &InvoiceNinjaClient,
id: &str,
format: OutputFormat,
) -> Result<(), Box<dyn std::error::Error>> {
let found = client.get_credit(id).await?;
render(std::slice::from_ref(&found), format)
}
async fn create(
client: &InvoiceNinjaClient,
body_args: BodyArgs,
format: OutputFormat,
) -> Result<(), Box<dyn std::error::Error>> {
let value = body::load(&body_args)?;
let model: Credit = serde_json::from_value(value)?;
let created = client.create_credit(&model).await?;
render(std::slice::from_ref(&created), format)
}
async fn update(
client: &InvoiceNinjaClient,
id: &str,
body_args: BodyArgs,
format: OutputFormat,
) -> Result<(), Box<dyn std::error::Error>> {
let patch = body::load(&body_args)?;
let current = client.get_credit(id).await?;
let merged = body::merge_shallow(serde_json::to_value(&current)?, patch);
let model: Credit = serde_json::from_value(merged)?;
let updated = client.update_credit(id, &model).await?;
render(std::slice::from_ref(&updated), format)
}
async fn delete(client: &InvoiceNinjaClient, id: &str) -> Result<(), Box<dyn std::error::Error>> {
client.delete_credit(id).await?;
println!("deleted {id}");
Ok(())
}
async fn do_action(
client: &InvoiceNinjaClient,
id: &str,
action: &str,
format: OutputFormat,
) -> Result<(), Box<dyn std::error::Error>> {
let updated = client.credit_action(id, action).await?;
render(std::slice::from_ref(&updated), format)
}
async fn download(
client: &InvoiceNinjaClient,
ids: &[String],
out: Option<PathBuf>,
) -> Result<(), Box<dyn std::error::Error>> {
let bytes = if ids.len() == 1 {
client.download_credit(&ids[0]).await?
} else {
client.download_credits(ids).await?
};
match out {
Some(path) => std::fs::write(path, bytes)?,
None => std::io::stdout().write_all(&bytes)?,
}
Ok(())
}
fn render(credits: &[Credit], format: OutputFormat) -> Result<(), Box<dyn std::error::Error>> {
match format {
OutputFormat::Json => output::print_json(credits)?,
OutputFormat::Table => print_table(credits),
}
Ok(())
}
fn print_table(credits: &[Credit]) {
if credits.is_empty() {
println!("(no records)");
return;
}
println!(
"{:<20} {:<15} {:<20} {:<8} {:>12} {:>12}",
"id", "number", "client_id", "status", "amount", "balance"
);
for c in credits {
println!(
"{:<20} {:<15} {:<20} {:<8} {:>12.2} {:>12.2}",
c.id.as_deref().unwrap_or(""),
truncate(&c.number, 15),
truncate(&c.client_id, 20),
c.status_id,
c.amount,
c.balance
);
}
}
fn truncate(s: &str, max: usize) -> String {
if s.chars().count() <= max {
s.to_string()
} else {
s.chars().take(max.saturating_sub(1)).collect::<String>() + "…"
}
}
+136
View File
@@ -0,0 +1,136 @@
//! `designs` subcommand. Same shape as `clients.rs` — see the "Adding a
//! resource" section in CLAUDE.md.
use invoiceninja_sdk::models::{Design, 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 designs = if args.all {
client.list_all_designs(&params).await?
} else {
client.list_designs(&params).await?
};
render(&designs, format)
}
async fn get(
client: &InvoiceNinjaClient,
id: &str,
format: OutputFormat,
) -> Result<(), Box<dyn std::error::Error>> {
let found = client.get_design(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: Design = serde_json::from_value(value)?;
let created = client.create_design(&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_design(id).await?;
let merged = body::merge_shallow(serde_json::to_value(&current)?, patch);
let model: Design = serde_json::from_value(merged)?;
let updated = client.update_design(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_design(id).await?;
println!("deleted {id}");
Ok(())
}
fn render(designs: &[Design], format: OutputFormat) -> Result<(), Box<dyn std::error::Error>> {
match format {
OutputFormat::Json => output::print_json(designs)?,
OutputFormat::Table => print_table(designs),
}
Ok(())
}
fn print_table(designs: &[Design]) {
if designs.is_empty() {
println!("(no records)");
return;
}
println!(
"{:<20} {:<30} {:<10} {:<10}",
"id", "name", "custom", "active"
);
for d in designs {
println!(
"{:<20} {:<30} {:<10} {:<10}",
d.id.as_deref().unwrap_or(""),
truncate(&d.name, 30),
d.is_custom,
d.is_active
);
}
}
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>() + "…"
}
}
+135
View File
@@ -0,0 +1,135 @@
//! `expense-categories` subcommand. Same shape as `clients.rs` — see the
//! "Adding a resource" section in CLAUDE.md.
use invoiceninja_sdk::models::{ExpenseCategory, 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 expense_categories = if args.all {
client.list_all_expense_categories(&params).await?
} else {
client.list_expense_categories(&params).await?
};
render(&expense_categories, format)
}
async fn get(
client: &InvoiceNinjaClient,
id: &str,
format: OutputFormat,
) -> Result<(), Box<dyn std::error::Error>> {
let found = client.get_expense_category(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: ExpenseCategory = serde_json::from_value(value)?;
let created = client.create_expense_category(&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_category(id).await?;
let merged = body::merge_shallow(serde_json::to_value(&current)?, patch);
let model: ExpenseCategory = serde_json::from_value(merged)?;
let updated = client.update_expense_category(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_category(id).await?;
println!("deleted {id}");
Ok(())
}
fn render(
expense_categories: &[ExpenseCategory],
format: OutputFormat,
) -> Result<(), Box<dyn std::error::Error>> {
match format {
OutputFormat::Json => output::print_json(expense_categories)?,
OutputFormat::Table => print_table(expense_categories),
}
Ok(())
}
fn print_table(expense_categories: &[ExpenseCategory]) {
if expense_categories.is_empty() {
println!("(no records)");
return;
}
println!("{:<20} {:<30} {:<20}", "id", "name", "user_id");
for c in expense_categories {
println!(
"{:<20} {:<30} {:<20}",
c.id.as_deref().unwrap_or(""),
truncate(&c.name, 30),
truncate(&c.user_id, 20)
);
}
}
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>() + "…"
}
}
+137
View File
@@ -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(&params).await?
} else {
client.list_expenses(&params).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(&current)?, 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>() + "…"
}
}
+135
View File
@@ -0,0 +1,135 @@
//! `group-settings` subcommand. Same shape as `clients.rs` — see the
//! "Adding a resource" section in CLAUDE.md.
use invoiceninja_sdk::models::{GroupSetting, 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 group_settings = if args.all {
client.list_all_group_settings(&params).await?
} else {
client.list_group_settings(&params).await?
};
render(&group_settings, format)
}
async fn get(
client: &InvoiceNinjaClient,
id: &str,
format: OutputFormat,
) -> Result<(), Box<dyn std::error::Error>> {
let found = client.get_group_setting(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: GroupSetting = serde_json::from_value(value)?;
let created = client.create_group_setting(&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_group_setting(id).await?;
let merged = body::merge_shallow(serde_json::to_value(&current)?, patch);
let model: GroupSetting = serde_json::from_value(merged)?;
let updated = client.update_group_setting(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_group_setting(id).await?;
println!("deleted {id}");
Ok(())
}
fn render(
group_settings: &[GroupSetting],
format: OutputFormat,
) -> Result<(), Box<dyn std::error::Error>> {
match format {
OutputFormat::Json => output::print_json(group_settings)?,
OutputFormat::Table => print_table(group_settings),
}
Ok(())
}
fn print_table(group_settings: &[GroupSetting]) {
if group_settings.is_empty() {
println!("(no records)");
return;
}
println!("{:<20} {:<30} {:<20}", "id", "name", "user_id");
for g in group_settings {
println!(
"{:<20} {:<30} {:<20}",
g.id.as_deref().unwrap_or(""),
truncate(&g.name, 30),
truncate(&g.user_id, 20)
);
}
}
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>() + "…"
}
}
+170
View File
@@ -0,0 +1,170 @@
//! `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::{BodyArgs, EntityAction, EntityResourceArgs, ListArgs, OutputFormat};
use crate::output;
pub async fn run(
client: &InvoiceNinjaClient,
args: EntityResourceArgs,
format: OutputFormat,
) -> Result<(), Box<dyn std::error::Error>> {
match args.action {
EntityAction::List(list_args) => list(client, list_args, format).await,
EntityAction::Get { id } => get(client, &id, format).await,
EntityAction::Create(body_args) => create(client, body_args, format).await,
EntityAction::Update { id, body } => update(client, &id, body, format).await,
EntityAction::Delete { id } => delete(client, &id).await,
EntityAction::Action { id, action } => do_action(client, &id, &action, format).await,
EntityAction::Download { ids, out } => download(client, &ids, out).await,
}
}
async fn list(
client: &InvoiceNinjaClient,
args: ListArgs,
format: OutputFormat,
) -> Result<(), Box<dyn std::error::Error>> {
let mut params = ListParams::new();
if let Some(page) = args.page {
params = params.page(page);
}
if let Some(per_page) = args.per_page {
params = params.per_page(per_page);
}
if let Some(sort) = args.sort {
params = params.sort(sort);
}
if let Some(filter) = args.filter {
params = params.filter(filter);
}
if let Some(status) = args.status {
params = params.status(status);
}
if let Some(include) = args.include {
params = params.include(include);
}
for (k, v) in args.params {
params = params.param(k, v);
}
let invoices = if args.all {
client.list_all_invoices(&params).await?
} else {
client.list_invoices(&params).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(&current)?, 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(())
}
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>> {
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>() + "…"
}
}
+137
View File
@@ -0,0 +1,137 @@
//! `locations` subcommand. Same shape as `clients.rs` — see the "Adding a
//! resource" section in CLAUDE.md.
use invoiceninja_sdk::models::{ListParams, Location};
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 locations = if args.all {
client.list_all_locations(&params).await?
} else {
client.list_locations(&params).await?
};
render(&locations, format)
}
async fn get(
client: &InvoiceNinjaClient,
id: &str,
format: OutputFormat,
) -> Result<(), Box<dyn std::error::Error>> {
let found = client.get_location(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: Location = serde_json::from_value(value)?;
let created = client.create_location(&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_location(id).await?;
let merged = body::merge_shallow(serde_json::to_value(&current)?, patch);
let model: Location = serde_json::from_value(merged)?;
let updated = client.update_location(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_location(id).await?;
println!("deleted {id}");
Ok(())
}
fn render(locations: &[Location], format: OutputFormat) -> Result<(), Box<dyn std::error::Error>> {
match format {
OutputFormat::Json => output::print_json(locations)?,
OutputFormat::Table => print_table(locations),
}
Ok(())
}
fn print_table(locations: &[Location]) {
if locations.is_empty() {
println!("(no records)");
return;
}
println!(
"{:<20} {:<20} {:<20} {:<10} {:<10}",
"id", "name", "city", "state", "country_id"
);
for l in locations {
println!(
"{:<20} {:<20} {:<20} {:<10} {:<10}",
l.id.as_deref().unwrap_or(""),
truncate(&l.name, 20),
truncate(&l.city, 20),
truncate(&l.state, 10),
l.country_id
);
}
}
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>() + "…"
}
}
+33
View File
@@ -0,0 +1,33 @@
pub mod bank_integrations;
pub mod bank_transaction_rules;
pub mod bank_transactions;
pub mod client_gateway_tokens;
pub mod clients;
pub mod companies;
pub mod company_gateways;
pub mod credits;
pub mod designs;
pub mod expense_categories;
pub mod expenses;
pub mod group_settings;
pub mod invoices;
pub mod locations;
pub mod payment_terms;
pub mod payments;
pub mod products;
pub mod projects;
pub mod purchase_orders;
pub mod quotes;
pub mod recurring_expenses;
pub mod recurring_invoices;
pub mod recurring_quotes;
pub mod subscriptions;
pub mod system_logs;
pub mod tags;
pub mod task_schedulers;
pub mod task_statuses;
pub mod tasks;
pub mod tax_rates;
pub mod tokens;
pub mod vendors;
pub mod webhooks;
+135
View File
@@ -0,0 +1,135 @@
//! `payment-terms` subcommand. Same shape as `clients.rs` — see the
//! "Adding a resource" section in CLAUDE.md.
use invoiceninja_sdk::models::{ListParams, PaymentTerm};
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 payment_terms = if args.all {
client.list_all_payment_terms(&params).await?
} else {
client.list_payment_terms(&params).await?
};
render(&payment_terms, format)
}
async fn get(
client: &InvoiceNinjaClient,
id: &str,
format: OutputFormat,
) -> Result<(), Box<dyn std::error::Error>> {
let found = client.get_payment_term(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: PaymentTerm = serde_json::from_value(value)?;
let created = client.create_payment_term(&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_term(id).await?;
let merged = body::merge_shallow(serde_json::to_value(&current)?, patch);
let model: PaymentTerm = serde_json::from_value(merged)?;
let updated = client.update_payment_term(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_term(id).await?;
println!("deleted {id}");
Ok(())
}
fn render(
payment_terms: &[PaymentTerm],
format: OutputFormat,
) -> Result<(), Box<dyn std::error::Error>> {
match format {
OutputFormat::Json => output::print_json(payment_terms)?,
OutputFormat::Table => print_table(payment_terms),
}
Ok(())
}
fn print_table(payment_terms: &[PaymentTerm]) {
if payment_terms.is_empty() {
println!("(no records)");
return;
}
println!("{:<20} {:<30} {:>10}", "id", "name", "num_days");
for t in payment_terms {
println!(
"{:<20} {:<30} {:>10}",
t.id.as_deref().unwrap_or(""),
truncate(&t.name, 30),
t.num_days
);
}
}
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>() + "…"
}
}
+137
View File
@@ -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(&params).await?
} else {
client.list_payments(&params).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(&current)?, 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>() + "…"
}
}
+137
View File
@@ -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(&params).await?
} else {
client.list_products(&params).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(&current)?, 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>() + "…"
}
}
+137
View File
@@ -0,0 +1,137 @@
//! `projects` subcommand. Same shape as `clients.rs` — see the "Adding a
//! resource" section in CLAUDE.md.
use invoiceninja_sdk::models::{ListParams, Project};
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 projects = if args.all {
client.list_all_projects(&params).await?
} else {
client.list_projects(&params).await?
};
render(&projects, format)
}
async fn get(
client: &InvoiceNinjaClient,
id: &str,
format: OutputFormat,
) -> Result<(), Box<dyn std::error::Error>> {
let found = client.get_project(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: Project = serde_json::from_value(value)?;
let created = client.create_project(&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_project(id).await?;
let merged = body::merge_shallow(serde_json::to_value(&current)?, patch);
let model: Project = serde_json::from_value(merged)?;
let updated = client.update_project(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_project(id).await?;
println!("deleted {id}");
Ok(())
}
fn render(projects: &[Project], format: OutputFormat) -> Result<(), Box<dyn std::error::Error>> {
match format {
OutputFormat::Json => output::print_json(projects)?,
OutputFormat::Table => print_table(projects),
}
Ok(())
}
fn print_table(projects: &[Project]) {
if projects.is_empty() {
println!("(no records)");
return;
}
println!(
"{:<20} {:<30} {:<20} {:<10} {:>14}",
"id", "name", "client_id", "number", "budgeted_hours"
);
for p in projects {
println!(
"{:<20} {:<30} {:<20} {:<10} {:>14.2}",
p.id.as_deref().unwrap_or(""),
truncate(&p.name, 30),
truncate(&p.client_id, 20),
p.number,
p.budgeted_hours
);
}
}
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>() + "…"
}
}
+173
View File
@@ -0,0 +1,173 @@
//! `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::{BodyArgs, EntityAction, EntityResourceArgs, ListArgs, OutputFormat};
use crate::output;
pub async fn run(
client: &InvoiceNinjaClient,
args: EntityResourceArgs,
format: OutputFormat,
) -> Result<(), Box<dyn std::error::Error>> {
match args.action {
EntityAction::List(list_args) => list(client, list_args, format).await,
EntityAction::Get { id } => get(client, &id, format).await,
EntityAction::Create(body_args) => create(client, body_args, format).await,
EntityAction::Update { id, body } => update(client, &id, body, format).await,
EntityAction::Delete { id } => delete(client, &id).await,
EntityAction::Action { id, action } => do_action(client, &id, &action, format).await,
EntityAction::Download { ids, out } => download(client, &ids, out).await,
}
}
async fn list(
client: &InvoiceNinjaClient,
args: ListArgs,
format: OutputFormat,
) -> Result<(), Box<dyn std::error::Error>> {
let mut params = ListParams::new();
if let Some(page) = args.page {
params = params.page(page);
}
if let Some(per_page) = args.per_page {
params = params.per_page(per_page);
}
if let Some(sort) = args.sort {
params = params.sort(sort);
}
if let Some(filter) = args.filter {
params = params.filter(filter);
}
if let Some(status) = args.status {
params = params.status(status);
}
if let Some(include) = args.include {
params = params.include(include);
}
for (k, v) in args.params {
params = params.param(k, v);
}
let purchase_orders = if args.all {
client.list_all_purchase_orders(&params).await?
} else {
client.list_purchase_orders(&params).await?
};
render(&purchase_orders, format)
}
async fn get(
client: &InvoiceNinjaClient,
id: &str,
format: OutputFormat,
) -> Result<(), Box<dyn std::error::Error>> {
let found = client.get_purchase_order(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: PurchaseOrder = serde_json::from_value(value)?;
let created = client.create_purchase_order(&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_purchase_order(id).await?;
let merged = body::merge_shallow(serde_json::to_value(&current)?, patch);
let model: PurchaseOrder = serde_json::from_value(merged)?;
let updated = client.update_purchase_order(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_purchase_order(id).await?;
println!("deleted {id}");
Ok(())
}
async fn do_action(
client: &InvoiceNinjaClient,
id: &str,
action: &str,
format: OutputFormat,
) -> Result<(), Box<dyn std::error::Error>> {
let updated = client.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(
purchase_orders: &[PurchaseOrder],
format: OutputFormat,
) -> Result<(), Box<dyn std::error::Error>> {
match format {
OutputFormat::Json => output::print_json(purchase_orders)?,
OutputFormat::Table => print_table(purchase_orders),
}
Ok(())
}
fn print_table(purchase_orders: &[PurchaseOrder]) {
if purchase_orders.is_empty() {
println!("(no records)");
return;
}
println!(
"{:<20} {:<15} {:<20} {:<8} {:>12} {:>12}",
"id", "number", "vendor_id", "status", "amount", "balance"
);
for po in purchase_orders {
println!(
"{:<20} {:<15} {:<20} {:<8} {:>12.2} {:>12.2}",
po.id.as_deref().unwrap_or(""),
truncate(&po.number, 15),
truncate(&po.vendor_id, 20),
po.status_id,
po.amount,
po.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>() + "…"
}
}
+173
View File
@@ -0,0 +1,173 @@
//! `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 std::io::Write;
use std::path::PathBuf;
use invoiceninja_sdk::models::{ListParams, Quote};
use invoiceninja_sdk::InvoiceNinjaClient;
use crate::body;
use crate::cli::{BodyArgs, EntityAction, EntityResourceArgs, ListArgs, OutputFormat};
use crate::output;
pub async fn run(
client: &InvoiceNinjaClient,
args: EntityResourceArgs,
format: OutputFormat,
) -> Result<(), Box<dyn std::error::Error>> {
match args.action {
EntityAction::List(list_args) => list(client, list_args, format).await,
EntityAction::Get { id } => get(client, &id, format).await,
EntityAction::Create(body_args) => create(client, body_args, format).await,
EntityAction::Update { id, body } => update(client, &id, body, format).await,
EntityAction::Delete { id } => delete(client, &id).await,
EntityAction::Action { id, action } => do_action(client, &id, &action, format).await,
EntityAction::Download { ids, out } => download(client, &ids, out).await,
}
}
async fn list(
client: &InvoiceNinjaClient,
args: ListArgs,
format: OutputFormat,
) -> Result<(), Box<dyn std::error::Error>> {
let mut params = ListParams::new();
if let Some(page) = args.page {
params = params.page(page);
}
if let Some(per_page) = args.per_page {
params = params.per_page(per_page);
}
if let Some(sort) = args.sort {
params = params.sort(sort);
}
if let Some(filter) = args.filter {
params = params.filter(filter);
}
if let Some(status) = args.status {
params = params.status(status);
}
if let Some(include) = args.include {
params = params.include(include);
}
for (k, v) in args.params {
params = params.param(k, v);
}
let quotes = if args.all {
client.list_all_quotes(&params).await?
} else {
client.list_quotes(&params).await?
};
render(&quotes, 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(&current)?, 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(())
}
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>> {
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>() + "…"
}
}
+140
View File
@@ -0,0 +1,140 @@
//! `recurring-expenses` subcommand. Same shape as `clients.rs` — see the
//! "Adding a resource" section in CLAUDE.md.
use invoiceninja_sdk::models::{ListParams, RecurringExpense};
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 recurring_expenses = if args.all {
client.list_all_recurring_expenses(&params).await?
} else {
client.list_recurring_expenses(&params).await?
};
render(&recurring_expenses, format)
}
async fn get(
client: &InvoiceNinjaClient,
id: &str,
format: OutputFormat,
) -> Result<(), Box<dyn std::error::Error>> {
let found = client.get_recurring_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: RecurringExpense = serde_json::from_value(value)?;
let created = client.create_recurring_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_recurring_expense(id).await?;
let merged = body::merge_shallow(serde_json::to_value(&current)?, patch);
let model: RecurringExpense = serde_json::from_value(merged)?;
let updated = client.update_recurring_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_recurring_expense(id).await?;
println!("deleted {id}");
Ok(())
}
fn render(
recurring_expenses: &[RecurringExpense],
format: OutputFormat,
) -> Result<(), Box<dyn std::error::Error>> {
match format {
OutputFormat::Json => output::print_json(recurring_expenses)?,
OutputFormat::Table => print_table(recurring_expenses),
}
Ok(())
}
fn print_table(recurring_expenses: &[RecurringExpense]) {
if recurring_expenses.is_empty() {
println!("(no records)");
return;
}
println!(
"{:<20} {:<20} {:<20} {:<12} {:>12}",
"id", "vendor_id", "client_id", "date", "amount"
);
for e in recurring_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>() + "…"
}
}
+141
View File
@@ -0,0 +1,141 @@
//! `recurring-invoices` subcommand. Same shape as `clients.rs` — see the
//! "Adding a resource" section in CLAUDE.md.
use invoiceninja_sdk::models::{ListParams, RecurringInvoice};
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 recurring_invoices = if args.all {
client.list_all_recurring_invoices(&params).await?
} else {
client.list_recurring_invoices(&params).await?
};
render(&recurring_invoices, format)
}
async fn get(
client: &InvoiceNinjaClient,
id: &str,
format: OutputFormat,
) -> Result<(), Box<dyn std::error::Error>> {
let found = client.get_recurring_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: RecurringInvoice = serde_json::from_value(value)?;
let created = client.create_recurring_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_recurring_invoice(id).await?;
let merged = body::merge_shallow(serde_json::to_value(&current)?, patch);
let model: RecurringInvoice = serde_json::from_value(merged)?;
let updated = client.update_recurring_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_recurring_invoice(id).await?;
println!("deleted {id}");
Ok(())
}
fn render(
recurring_invoices: &[RecurringInvoice],
format: OutputFormat,
) -> Result<(), Box<dyn std::error::Error>> {
match format {
OutputFormat::Json => output::print_json(recurring_invoices)?,
OutputFormat::Table => print_table(recurring_invoices),
}
Ok(())
}
fn print_table(recurring_invoices: &[RecurringInvoice]) {
if recurring_invoices.is_empty() {
println!("(no records)");
return;
}
println!(
"{:<20} {:<15} {:<20} {:<8} {:>12} {:>12}",
"id", "number", "client_id", "status", "amount", "balance"
);
for i in recurring_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>() + "…"
}
}
+143
View File
@@ -0,0 +1,143 @@
//! `recurring-quotes` subcommand. Same shape as `clients.rs` — see the
//! "Adding a resource" section in CLAUDE.md. `RecurringQuote` is a type
//! alias for `RecurringInvoice` (same schema, distinguished by
//! `entity_type` upstream), same relationship as `quotes.rs`/`invoices.rs`.
use invoiceninja_sdk::models::{ListParams, RecurringQuote};
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 recurring_quotes = if args.all {
client.list_all_recurring_quotes(&params).await?
} else {
client.list_recurring_quotes(&params).await?
};
render(&recurring_quotes, format)
}
async fn get(
client: &InvoiceNinjaClient,
id: &str,
format: OutputFormat,
) -> Result<(), Box<dyn std::error::Error>> {
let found = client.get_recurring_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: RecurringQuote = serde_json::from_value(value)?;
let created = client.create_recurring_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_recurring_quote(id).await?;
let merged = body::merge_shallow(serde_json::to_value(&current)?, patch);
let model: RecurringQuote = serde_json::from_value(merged)?;
let updated = client.update_recurring_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_recurring_quote(id).await?;
println!("deleted {id}");
Ok(())
}
fn render(
recurring_quotes: &[RecurringQuote],
format: OutputFormat,
) -> Result<(), Box<dyn std::error::Error>> {
match format {
OutputFormat::Json => output::print_json(recurring_quotes)?,
OutputFormat::Table => print_table(recurring_quotes),
}
Ok(())
}
fn print_table(recurring_quotes: &[RecurringQuote]) {
if recurring_quotes.is_empty() {
println!("(no records)");
return;
}
println!(
"{:<20} {:<15} {:<20} {:<8} {:>12} {:>12}",
"id", "number", "client_id", "status", "amount", "balance"
);
for q in recurring_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>() + "…"
}
}
+140
View File
@@ -0,0 +1,140 @@
//! `subscriptions` subcommand. Same shape as `clients.rs` — see the
//! "Adding a resource" section in CLAUDE.md.
use invoiceninja_sdk::models::{ListParams, Subscription};
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 subscriptions = if args.all {
client.list_all_subscriptions(&params).await?
} else {
client.list_subscriptions(&params).await?
};
render(&subscriptions, format)
}
async fn get(
client: &InvoiceNinjaClient,
id: &str,
format: OutputFormat,
) -> Result<(), Box<dyn std::error::Error>> {
let found = client.get_subscription(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: Subscription = serde_json::from_value(value)?;
let created = client.create_subscription(&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_subscription(id).await?;
let merged = body::merge_shallow(serde_json::to_value(&current)?, patch);
let model: Subscription = serde_json::from_value(merged)?;
let updated = client.update_subscription(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_subscription(id).await?;
println!("deleted {id}");
Ok(())
}
fn render(
subscriptions: &[Subscription],
format: OutputFormat,
) -> Result<(), Box<dyn std::error::Error>> {
match format {
OutputFormat::Json => output::print_json(subscriptions)?,
OutputFormat::Table => print_table(subscriptions),
}
Ok(())
}
fn print_table(subscriptions: &[Subscription]) {
if subscriptions.is_empty() {
println!("(no records)");
return;
}
println!(
"{:<20} {:<20} {:<20} {:<10} {:<10}",
"id", "product_id", "recurring_invoice_id", "frequency", "auto_bill"
);
for s in subscriptions {
println!(
"{:<20} {:<20} {:<20} {:<10} {:<10}",
s.id.as_deref().unwrap_or(""),
truncate(&s.product_id, 20),
truncate(&s.recurring_invoice_id, 20),
s.frequency_id,
s.auto_bill
);
}
}
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>() + "…"
}
}
+142
View File
@@ -0,0 +1,142 @@
//! `system-logs` subcommand. Same shape as `clients.rs` — see the "Adding a
//! resource" section in CLAUDE.md. Uses the SDK's `crud_methods!` rather
//! than `resource_methods!` (no `/bulk` route), which doesn't change
//! anything here — this CLI doesn't expose bulk actions yet either way.
use invoiceninja_sdk::models::{ListParams, SystemLog};
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 system_logs = if args.all {
client.list_all_system_logs(&params).await?
} else {
client.list_system_logs(&params).await?
};
render(&system_logs, format)
}
async fn get(
client: &InvoiceNinjaClient,
id: &str,
format: OutputFormat,
) -> Result<(), Box<dyn std::error::Error>> {
let found = client.get_system_log(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: SystemLog = serde_json::from_value(value)?;
let created = client.create_system_log(&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_system_log(id).await?;
let merged = body::merge_shallow(serde_json::to_value(&current)?, patch);
let model: SystemLog = serde_json::from_value(merged)?;
let updated = client.update_system_log(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_system_log(id).await?;
println!("deleted {id}");
Ok(())
}
fn render(
system_logs: &[SystemLog],
format: OutputFormat,
) -> Result<(), Box<dyn std::error::Error>> {
match format {
OutputFormat::Json => output::print_json(system_logs)?,
OutputFormat::Table => print_table(system_logs),
}
Ok(())
}
fn print_table(system_logs: &[SystemLog]) {
if system_logs.is_empty() {
println!("(no records)");
return;
}
println!(
"{:<20} {:<20} {:>10} {:>10} {:>10}",
"id", "client_id", "event_id", "category_id", "type_id"
);
for l in system_logs {
println!(
"{:<20} {:<20} {:>10} {:>10} {:>10}",
l.id.as_deref().unwrap_or(""),
truncate(&l.client_id, 20),
l.event_id,
l.category_id,
l.type_id
);
}
}
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>() + "…"
}
}
+136
View File
@@ -0,0 +1,136 @@
//! `tags` subcommand. Same shape as `clients.rs` — see the "Adding a
//! resource" section in CLAUDE.md.
use invoiceninja_sdk::models::{ListParams, Tag};
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 tags = if args.all {
client.list_all_tags(&params).await?
} else {
client.list_tags(&params).await?
};
render(&tags, format)
}
async fn get(
client: &InvoiceNinjaClient,
id: &str,
format: OutputFormat,
) -> Result<(), Box<dyn std::error::Error>> {
let found = client.get_tag(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: Tag = serde_json::from_value(value)?;
let created = client.create_tag(&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_tag(id).await?;
let merged = body::merge_shallow(serde_json::to_value(&current)?, patch);
let model: Tag = serde_json::from_value(merged)?;
let updated = client.update_tag(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_tag(id).await?;
println!("deleted {id}");
Ok(())
}
fn render(tags: &[Tag], format: OutputFormat) -> Result<(), Box<dyn std::error::Error>> {
match format {
OutputFormat::Json => output::print_json(tags)?,
OutputFormat::Table => print_table(tags),
}
Ok(())
}
fn print_table(tags: &[Tag]) {
if tags.is_empty() {
println!("(no records)");
return;
}
println!(
"{:<20} {:<30} {:<15} {:<10}",
"id", "name", "entity_type", "color"
);
for t in tags {
println!(
"{:<20} {:<30} {:<15} {:<10}",
t.id.as_deref().unwrap_or(""),
truncate(&t.name, 30),
truncate(&t.entity_type, 15),
t.color
);
}
}
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>() + "…"
}
}
+139
View File
@@ -0,0 +1,139 @@
//! `task-schedulers` subcommand. Same shape as `clients.rs` — see the
//! "Adding a resource" section in CLAUDE.md.
use invoiceninja_sdk::models::{ListParams, TaskScheduler};
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 task_schedulers = if args.all {
client.list_all_task_schedulers(&params).await?
} else {
client.list_task_schedulers(&params).await?
};
render(&task_schedulers, format)
}
async fn get(
client: &InvoiceNinjaClient,
id: &str,
format: OutputFormat,
) -> Result<(), Box<dyn std::error::Error>> {
let found = client.get_task_scheduler(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: TaskScheduler = serde_json::from_value(value)?;
let created = client.create_task_scheduler(&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_task_scheduler(id).await?;
let merged = body::merge_shallow(serde_json::to_value(&current)?, patch);
let model: TaskScheduler = serde_json::from_value(merged)?;
let updated = client.update_task_scheduler(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_task_scheduler(id).await?;
println!("deleted {id}");
Ok(())
}
fn render(
task_schedulers: &[TaskScheduler],
format: OutputFormat,
) -> Result<(), Box<dyn std::error::Error>> {
match format {
OutputFormat::Json => output::print_json(task_schedulers)?,
OutputFormat::Table => print_table(task_schedulers),
}
Ok(())
}
fn print_table(task_schedulers: &[TaskScheduler]) {
if task_schedulers.is_empty() {
println!("(no records)");
return;
}
println!(
"{:<20} {:<20} {:<12} {:<8}",
"id", "job", "repeat_every", "paused"
);
for s in task_schedulers {
println!(
"{:<20} {:<20} {:<12} {:<8}",
s.id.as_deref().unwrap_or(""),
truncate(&s.job, 20),
s.repeat_every,
s.paused
);
}
}
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>() + "…"
}
}
+139
View File
@@ -0,0 +1,139 @@
//! `task-statuses` subcommand. Same shape as `clients.rs` — see the
//! "Adding a resource" section in CLAUDE.md.
use invoiceninja_sdk::models::{ListParams, TaskStatus};
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 task_statuses = if args.all {
client.list_all_task_statuses(&params).await?
} else {
client.list_task_statuses(&params).await?
};
render(&task_statuses, format)
}
async fn get(
client: &InvoiceNinjaClient,
id: &str,
format: OutputFormat,
) -> Result<(), Box<dyn std::error::Error>> {
let found = client.get_task_status(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: TaskStatus = serde_json::from_value(value)?;
let created = client.create_task_status(&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_task_status(id).await?;
let merged = body::merge_shallow(serde_json::to_value(&current)?, patch);
let model: TaskStatus = serde_json::from_value(merged)?;
let updated = client.update_task_status(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_task_status(id).await?;
println!("deleted {id}");
Ok(())
}
fn render(
task_statuses: &[TaskStatus],
format: OutputFormat,
) -> Result<(), Box<dyn std::error::Error>> {
match format {
OutputFormat::Json => output::print_json(task_statuses)?,
OutputFormat::Table => print_table(task_statuses),
}
Ok(())
}
fn print_table(task_statuses: &[TaskStatus]) {
if task_statuses.is_empty() {
println!("(no records)");
return;
}
println!(
"{:<20} {:<30} {:<10} {:>8}",
"id", "name", "color", "order"
);
for t in task_statuses {
println!(
"{:<20} {:<30} {:<10} {:>8}",
t.id.as_deref().unwrap_or(""),
truncate(&t.name, 30),
t.color,
t.task_status_order
);
}
}
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>() + "…"
}
}
+137
View File
@@ -0,0 +1,137 @@
//! `tasks` subcommand. Same shape as `clients.rs` — see the "Adding a
//! resource" section in CLAUDE.md.
use invoiceninja_sdk::models::{ListParams, Task};
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 tasks = if args.all {
client.list_all_tasks(&params).await?
} else {
client.list_tasks(&params).await?
};
render(&tasks, format)
}
async fn get(
client: &InvoiceNinjaClient,
id: &str,
format: OutputFormat,
) -> Result<(), Box<dyn std::error::Error>> {
let found = client.get_task(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: Task = serde_json::from_value(value)?;
let created = client.create_task(&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_task(id).await?;
let merged = body::merge_shallow(serde_json::to_value(&current)?, patch);
let model: Task = serde_json::from_value(merged)?;
let updated = client.update_task(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_task(id).await?;
println!("deleted {id}");
Ok(())
}
fn render(tasks: &[Task], format: OutputFormat) -> Result<(), Box<dyn std::error::Error>> {
match format {
OutputFormat::Json => output::print_json(tasks)?,
OutputFormat::Table => print_table(tasks),
}
Ok(())
}
fn print_table(tasks: &[Task]) {
if tasks.is_empty() {
println!("(no records)");
return;
}
println!(
"{:<20} {:<30} {:<20} {:<20} {:<8}",
"id", "description", "client_id", "project_id", "status"
);
for t in tasks {
println!(
"{:<20} {:<30} {:<20} {:<20} {:<8}",
t.id.as_deref().unwrap_or(""),
truncate(&t.description, 30),
truncate(&t.client_id, 20),
truncate(&t.project_id, 20),
t.status_id
);
}
}
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>() + "…"
}
}
+132
View File
@@ -0,0 +1,132 @@
//! `tax-rates` subcommand. Same shape as `clients.rs` — see the "Adding a
//! resource" section in CLAUDE.md.
use invoiceninja_sdk::models::{ListParams, TaxRate};
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 tax_rates = if args.all {
client.list_all_tax_rates(&params).await?
} else {
client.list_tax_rates(&params).await?
};
render(&tax_rates, format)
}
async fn get(
client: &InvoiceNinjaClient,
id: &str,
format: OutputFormat,
) -> Result<(), Box<dyn std::error::Error>> {
let found = client.get_tax_rate(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: TaxRate = serde_json::from_value(value)?;
let created = client.create_tax_rate(&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_tax_rate(id).await?;
let merged = body::merge_shallow(serde_json::to_value(&current)?, patch);
let model: TaxRate = serde_json::from_value(merged)?;
let updated = client.update_tax_rate(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_tax_rate(id).await?;
println!("deleted {id}");
Ok(())
}
fn render(tax_rates: &[TaxRate], format: OutputFormat) -> Result<(), Box<dyn std::error::Error>> {
match format {
OutputFormat::Json => output::print_json(tax_rates)?,
OutputFormat::Table => print_table(tax_rates),
}
Ok(())
}
fn print_table(tax_rates: &[TaxRate]) {
if tax_rates.is_empty() {
println!("(no records)");
return;
}
println!("{:<20} {:<30} {:>10}", "id", "name", "rate");
for t in tax_rates {
println!(
"{:<20} {:<30} {:>10.2}",
t.id.as_deref().unwrap_or(""),
truncate(&t.name, 30),
t.rate
);
}
}
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>() + "…"
}
}
+132
View File
@@ -0,0 +1,132 @@
//! `tokens` subcommand (API tokens, model `CompanyToken`). Same shape as
//! `clients.rs` — see the "Adding a resource" section in CLAUDE.md.
use invoiceninja_sdk::models::{CompanyToken, 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 tokens = if args.all {
client.list_all_tokens(&params).await?
} else {
client.list_tokens(&params).await?
};
render(&tokens, format)
}
async fn get(
client: &InvoiceNinjaClient,
id: &str,
format: OutputFormat,
) -> Result<(), Box<dyn std::error::Error>> {
let found = client.get_token(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: CompanyToken = serde_json::from_value(value)?;
let created = client.create_token(&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_token(id).await?;
let merged = body::merge_shallow(serde_json::to_value(&current)?, patch);
let model: CompanyToken = serde_json::from_value(merged)?;
let updated = client.update_token(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_token(id).await?;
println!("deleted {id}");
Ok(())
}
fn render(tokens: &[CompanyToken], format: OutputFormat) -> Result<(), Box<dyn std::error::Error>> {
match format {
OutputFormat::Json => output::print_json(tokens)?,
OutputFormat::Table => print_table(tokens),
}
Ok(())
}
fn print_table(tokens: &[CompanyToken]) {
if tokens.is_empty() {
println!("(no records)");
return;
}
println!("{:<20} {:<30} {:<10}", "id", "name", "is_system");
for t in tokens {
println!(
"{:<20} {:<30} {:<10}",
t.id.as_deref().unwrap_or(""),
truncate(&t.name, 30),
t.is_system
);
}
}
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>() + "…"
}
}
+136
View File
@@ -0,0 +1,136 @@
//! `vendors` subcommand. Same shape as `clients.rs` — see the "Adding a
//! resource" section in CLAUDE.md.
use invoiceninja_sdk::models::{ListParams, Vendor};
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 vendors = if args.all {
client.list_all_vendors(&params).await?
} else {
client.list_vendors(&params).await?
};
render(&vendors, format)
}
async fn get(
client: &InvoiceNinjaClient,
id: &str,
format: OutputFormat,
) -> Result<(), Box<dyn std::error::Error>> {
let found = client.get_vendor(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: Vendor = serde_json::from_value(value)?;
let created = client.create_vendor(&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_vendor(id).await?;
let merged = body::merge_shallow(serde_json::to_value(&current)?, patch);
let model: Vendor = serde_json::from_value(merged)?;
let updated = client.update_vendor(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_vendor(id).await?;
println!("deleted {id}");
Ok(())
}
fn render(vendors: &[Vendor], format: OutputFormat) -> Result<(), Box<dyn std::error::Error>> {
match format {
OutputFormat::Json => output::print_json(vendors)?,
OutputFormat::Table => print_table(vendors),
}
Ok(())
}
fn print_table(vendors: &[Vendor]) {
if vendors.is_empty() {
println!("(no records)");
return;
}
println!(
"{:<20} {:<30} {:<20} {:<10}",
"id", "name", "city", "country_id"
);
for v in vendors {
println!(
"{:<20} {:<30} {:<20} {:<10}",
v.id.as_deref().unwrap_or(""),
truncate(&v.name, 30),
truncate(&v.city, 20),
v.country_id
);
}
}
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>() + "…"
}
}
+136
View File
@@ -0,0 +1,136 @@
//! `webhooks` subcommand. Same shape as `clients.rs` — see the "Adding a
//! resource" section in CLAUDE.md.
use invoiceninja_sdk::models::{ListParams, Webhook};
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 webhooks = if args.all {
client.list_all_webhooks(&params).await?
} else {
client.list_webhooks(&params).await?
};
render(&webhooks, format)
}
async fn get(
client: &InvoiceNinjaClient,
id: &str,
format: OutputFormat,
) -> Result<(), Box<dyn std::error::Error>> {
let found = client.get_webhook(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: Webhook = serde_json::from_value(value)?;
let created = client.create_webhook(&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_webhook(id).await?;
let merged = body::merge_shallow(serde_json::to_value(&current)?, patch);
let model: Webhook = serde_json::from_value(merged)?;
let updated = client.update_webhook(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_webhook(id).await?;
println!("deleted {id}");
Ok(())
}
fn render(webhooks: &[Webhook], format: OutputFormat) -> Result<(), Box<dyn std::error::Error>> {
match format {
OutputFormat::Json => output::print_json(webhooks)?,
OutputFormat::Table => print_table(webhooks),
}
Ok(())
}
fn print_table(webhooks: &[Webhook]) {
if webhooks.is_empty() {
println!("(no records)");
return;
}
println!(
"{:<20} {:<10} {:<40} {:<8}",
"id", "event_id", "target_url", "format"
);
for w in webhooks {
println!(
"{:<20} {:<10} {:<40} {:<8}",
w.id.as_deref().unwrap_or(""),
w.event_id,
truncate(&w.target_url, 40),
w.format
);
}
}
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>() + "…"
}
}
+25
View File
@@ -0,0 +1,25 @@
use invoiceninja_sdk::{InvoiceNinjaClient, HOSTED_BASE_URL};
use crate::cli::Cli;
/// Builds the SDK client from the parsed CLI args: the base URL (defaulting
/// to the hosted app), the token, and the optional account password for the
/// `password_protected` endpoints.
///
/// Checks for a missing token itself rather than leaning on clap, since
/// `token` can't be marked clap-`required` — see the comment on `Cli::token`.
pub fn build_client(cli: &Cli) -> Result<InvoiceNinjaClient, Box<dyn std::error::Error>> {
let token = cli
.token
.clone()
.ok_or("missing API token: pass --token or set IN_API_TOKEN")?;
let base_url = cli
.base_url
.clone()
.unwrap_or_else(|| HOSTED_BASE_URL.to_string());
let client = InvoiceNinjaClient::new(base_url, token)?;
match &cli.api_password {
Some(password) => Ok(client.with_api_password(password.clone())?),
None => Ok(client),
}
}
+88
View File
@@ -0,0 +1,88 @@
mod body;
mod cli;
mod commands;
mod config;
mod output;
use clap::Parser;
use cli::{Cli, Command};
#[tokio::main]
async fn main() {
if let Err(err) = run().await {
eprintln!("error: {err}");
std::process::exit(1);
}
}
async fn run() -> Result<(), Box<dyn std::error::Error>> {
let cli = Cli::parse();
let client = config::build_client(&cli)?;
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::Credits(args) => commands::credits::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,
Command::Projects(args) => commands::projects::run(&client, args, cli.output).await,
Command::Tasks(args) => commands::tasks::run(&client, args, cli.output).await,
Command::Vendors(args) => commands::vendors::run(&client, args, cli.output).await,
Command::PurchaseOrders(args) => {
commands::purchase_orders::run(&client, args, cli.output).await
}
Command::RecurringInvoices(args) => {
commands::recurring_invoices::run(&client, args, cli.output).await
}
Command::RecurringQuotes(args) => {
commands::recurring_quotes::run(&client, args, cli.output).await
}
Command::RecurringExpenses(args) => {
commands::recurring_expenses::run(&client, args, cli.output).await
}
Command::TaxRates(args) => commands::tax_rates::run(&client, args, cli.output).await,
Command::ExpenseCategories(args) => {
commands::expense_categories::run(&client, args, cli.output).await
}
Command::TaskStatuses(args) => {
commands::task_statuses::run(&client, args, cli.output).await
}
Command::PaymentTerms(args) => {
commands::payment_terms::run(&client, args, cli.output).await
}
Command::Subscriptions(args) => {
commands::subscriptions::run(&client, args, cli.output).await
}
Command::BankTransactions(args) => {
commands::bank_transactions::run(&client, args, cli.output).await
}
Command::Webhooks(args) => commands::webhooks::run(&client, args, cli.output).await,
Command::BankIntegrations(args) => {
commands::bank_integrations::run(&client, args, cli.output).await
}
Command::BankTransactionRules(args) => {
commands::bank_transaction_rules::run(&client, args, cli.output).await
}
Command::CompanyGateways(args) => {
commands::company_gateways::run(&client, args, cli.output).await
}
Command::Designs(args) => commands::designs::run(&client, args, cli.output).await,
Command::GroupSettings(args) => {
commands::group_settings::run(&client, args, cli.output).await
}
Command::Locations(args) => commands::locations::run(&client, args, cli.output).await,
Command::Tags(args) => commands::tags::run(&client, args, cli.output).await,
Command::TaskSchedulers(args) => {
commands::task_schedulers::run(&client, args, cli.output).await
}
Command::Tokens(args) => commands::tokens::run(&client, args, cli.output).await,
Command::ClientGatewayTokens(args) => {
commands::client_gateway_tokens::run(&client, args, cli.output).await
}
Command::Companies(args) => commands::companies::run(&client, args, cli.output).await,
Command::SystemLogs(args) => commands::system_logs::run(&client, args, cli.output).await,
}
}
+9
View File
@@ -0,0 +1,9 @@
use serde::Serialize;
/// Prints a value as pretty JSON. Used for every resource's `--output json`
/// — the table view is per-resource (see `commands::clients::print_table`),
/// but JSON formatting isn't, since it's just the model's own `Serialize`.
pub fn print_json<T: Serialize + ?Sized>(value: &T) -> Result<(), Box<dyn std::error::Error>> {
println!("{}", serde_json::to_string_pretty(value)?);
Ok(())
}
+23
View File
@@ -0,0 +1,23 @@
{ proxyServer ? "nginx", invoiceninjaModule }:
{ ... }:
{
# Unlike the invoiceninja-nixos repo this was copied from, there's no
# sibling `nixos-module/` directory here — the module comes in through
# this parameter instead, threaded from flake.nix as
# `invoiceninja-nixos.nixosModules.invoiceninja` (the flake input). The
# module's own `../package.nix` relative import inside that file still
# resolves correctly regardless of who imports it, since it's relative to
# the module file's own location in the Nix store, not to this directory.
imports = [ invoiceninjaModule ];
services.invoiceninja = {
enable = true;
proxy.server = proxyServer;
adminAccount.passwordFile = ./invoice_ninja_test_password;
secretFile = ./test.env;
settings.APP_DEBUG = true;
};
networking.firewall.enable = false;
}
+52
View File
@@ -0,0 +1,52 @@
{ proxyServer ? "nginx", invoiceninjaModule }:
{ pkgs, modulesPath, ... }:
{
imports = [
(modulesPath + "/profiles/qemu-guest.nix")
# Not imported by default for a plain nixosSystem in this nixpkgs (only nixosTest nodes get it
# automatically) - needed explicitly so virtualisation.cores/memorySize below are real options.
(modulesPath + "/virtualisation/qemu-vm.nix")
(import ./common.nix { inherit proxyServer invoiceninjaModule; })
];
# `nix flake check` evaluates config.system.build.toplevel directly, which asserts a real root
# filesystem and bootloader are configured. `just build-vm` builds config.system.build.vm
# instead, where the qemu-vm module overrides both of these with a tmpfs root and direct
# kernel boot (virtualisation.useBootLoader defaults to false) - so these values just need to
# satisfy the assertions and are otherwise unused.
fileSystems."/" = {
device = "/dev/disk/by-label/nixos";
fsType = "ext4";
};
boot.loader.grub.enable = false;
users.users.root = {
initialPassword = "toor";
};
services.resolved.enable = true;
# `just boot-vm` forwards the guest's port 80 to host port 8080 (QEMU_NET_OPTS in the justfile),
# but the backend builds absolute URLs (e.g. the stored company logo path) from APP_URL, which
# defaults to plain "http://localhost" with no port. The SPA's own relative URLs are fine (they
# inherit whatever port the browser is already on), but any backend-generated absolute URL then
# points at host port 80 - where nothing is listening - instead of the forwarded 8080, giving a
# silent net::ERR_CONNECTION_REFUSED for things like the logo image. Override to match the actual
# forwarded port.
services.invoiceninja.settings.APP_URL = "http://localhost:8080";
# The qemu-vm module's defaults (1 core, 1024MB) are too tight for real interactive use: MySQL +
# Redis + PHP-FPM + chromium (PDF generation, spawned per request - each is a full multi-process
# browser, not a lightweight renderer) running concurrently can exhaust 1GB fast, and this VM
# boots with panic_on_oom - so it's not a graceful slowdown, the whole VM goes down. Confirmed by
# reproducing a "logo upload hangs" report this way: at the 1024MB default, even a couple of
# concurrent PDF-generation requests triggered `redis-server invoked oom-killer` and a kernel
# panic. 2 cores / 2560MB comfortably handles 2 concurrent PDF generations alongside normal
# traffic (verified: ~1.4GB still free afterward); a deliberate flood of many concurrent PDF
# requests can still exhaust even this, since each is a full chromium instance - if that's a
# concern for your deployment, consider capping `phpfpm.settings."pm.max_children"` below its
# default of 8, or provisioning more RAM.
virtualisation.cores = 2;
virtualisation.memorySize = 2560;
}
+1
View File
@@ -0,0 +1 @@
password
+120
View File
@@ -0,0 +1,120 @@
{ nixpkgsPath, system, invoiceninjaModule, invoiceninjaCliPkg }:
let
pkgs = import nixpkgsPath { inherit system; };
adminEmail = "admin@email.com";
adminPassword = pkgs.lib.strings.trim (builtins.readFile ./invoice_ninja_test_password);
in
pkgs.testers.nixosTest {
name = "invoiceninja-cli";
nodes = {
# Only one node/proxy backend: which proxy Invoice Ninja sits behind is
# invoiceninja-nixos's own concern (its checks exercise both nginx and
# caddy already). This test is about whether invoiceninja-cli works
# against a real server, which doesn't depend on which proxy fronts it.
machine = {
imports = [ (import ./common.nix { proxyServer = "nginx"; inherit invoiceninjaModule; }) ];
environment.systemPackages = [ invoiceninjaCliPkg ];
};
};
testScript = ''
import json
def api_post(machine, path, payload, token=None):
auth_header = f"-H 'X-API-TOKEN: {token}' " if token else ""
return json.loads(machine.succeed(
f"curl -sf -X POST http://localhost{path} "
+ auth_header
+ "-H 'Content-Type: application/json' "
+ f"-d '{json.dumps(payload)}'"
))
def cli(machine, token, *args):
quoted_args = " ".join(f"'{a}'" for a in args)
return machine.succeed(
f"IN_API_TOKEN={token} IN_BASE_URL=http://localhost/api/v1 "
f"invoiceninja-cli --output json {quoted_args}"
)
machine.start()
# phpfpm-invoiceninja/invoiceninja-queue-worker both Require= invoiceninja-data-setup,
# so systemd already waits for it to finish before either starts.
machine.wait_for_unit("mysql.service")
machine.wait_for_unit("redis-invoiceninja.service")
machine.wait_for_unit("phpfpm-invoiceninja.service")
machine.wait_for_unit("invoiceninja-queue-worker.service")
machine.wait_for_unit("nginx.service")
machine.wait_for_open_port(80)
with subtest("admin account can log in via the API"):
login_response = api_post(machine, "/api/v1/login", {"email": "${adminEmail}", "password": "${adminPassword}"})
assert "${adminEmail}" in json.dumps(login_response), f"login did not succeed: {login_response}"
token = login_response["data"][0]["token"]["token"]
with subtest("invoiceninja-cli can create and list a client"):
cli(machine, token, "clients", "create", "--data", '{"name": "CLI Test Client"}')
listing = cli(machine, token, "clients", "list")
clients = json.loads(listing)
assert any(c["name"] == "CLI Test Client" for c in clients), f"client not in list output: {listing}"
client_id = next(c["id"] for c in clients if c["name"] == "CLI Test Client")
with subtest("invoiceninja-cli update preserves fields it wasn't told about"):
cli(machine, token, "clients", "update", client_id, "--data", '{"city": "Springfield"}')
updated = json.loads(cli(machine, token, "clients", "get", client_id))[0]
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"):
invoice_out = cli(
machine, token, "invoices", "create", "--data",
json.dumps({
"client_id": client_id,
"line_items": [{"quantity": 1, "cost": 100, "product_key": "test-product"}],
}),
)
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({
"client_id": client_id,
"amount": invoice["amount"],
"invoices": [{"invoice_id": invoice["id"], "amount": invoice["amount"]}],
}),
)
payment = json.loads(payment_out)[0]
assert payment["applied"] == invoice["amount"], f"payment not fully applied: {payment}"
with subtest("invoiceninja-cli delete soft-deletes the client"):
cli(machine, token, "clients", "delete", client_id)
# Not `clients list`: Invoice Ninja's default /clients (no `status`
# param) returns every record regardless of is_deleted — confirmed
# against a real server, contrary to what CLAUDE.md's `--status`
# doc comment implies. Check the flag directly instead of relying
# on list-exclusion.
deleted = json.loads(cli(machine, token, "clients", "get", client_id))[0]
assert deleted["is_deleted"], f"delete did not set is_deleted: {deleted}"
'';
}
+2
View File
@@ -0,0 +1,2 @@
APP_KEY=base64:gJnNI8pOx4c0/Z6kl9mIjn0X9XkbJUWjtqnDFl9prvo=
API_KEY=test-api-token