Compare commits

...
7 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
48 changed files with 5462 additions and 26 deletions
+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
+3
View File
@@ -5,3 +5,6 @@
/.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.
+114 -7
View File
@@ -20,8 +20,12 @@ design call, the same way its counterpart in the SDK works. If a change
would contradict a stated goal or settle an open question, update
`GOALS.md` in the same change.
**Early scaffold.** Only the `clients` resource is wired up, as the template
for the other ~34. See "Adding a resource" below before writing the next one.
**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
@@ -71,6 +75,92 @@ matching package named `invoiceninja-sdk` found" or an unresolved
`../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
@@ -104,8 +194,9 @@ SDK call.
- `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 one resource implemented so far, and the
template for the rest.
- `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
@@ -151,6 +242,22 @@ Each resource beyond `clients` should follow the same shape:
either way; that's a separate addition.
Resources with entity actions, document uploads, or non-CRUD endpoints
(reports, charts, search, statics, PDF downloads, …) will need more than
this template once they're added — the SDK's own `CLAUDE.md` describes which
resources have which extras.
(reports, charts, search, statics, …) will need more than this template
once they're added — the SDK's own `CLAUDE.md` describes which resources
have which extras.
**Entity actions and PDF download are done** for the four resources that
have the SDK's `entity_action_methods!` route (`invoices`, `quotes`,
`credits`, `purchase_orders`): `EntityResourceArgs`/`EntityAction` in
`src/cli.rs` is a second, parallel `ResourceArgs`/`Action` that adds
`action <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`).
+15 -9
View File
@@ -67,6 +67,16 @@ 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
@@ -85,9 +95,11 @@ applies here unchanged — the CLI is not a place to relax it for convenience.
invocation. See the open question below on multi-account profiles before
assuming this is permanent.
- **No stability promise before 1.0.** Same posture as the SDK: flags are a
compatibility surface too, and pre-1.0 they can change. Record breaking
changes somewhere legible (a `CHANGELOG.md`, once there's enough history to
need one) rather than letting them be discovered at the command line.
compatibility surface too, and pre-1.0 they can change. `CHANGELOG.md`
records breaking changes rather than letting them be discovered at the
command line — keep its `[Unreleased]` section current as changes land,
and move it to a dated, versioned section at the same time `Cargo.toml`'s
version bumps, not after.
## Open questions
@@ -111,12 +123,6 @@ These are genuinely undecided; deciding one is a change to this file.
`--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 this crate gets its own CI.** No longer blocked on needing a
sibling checkout — the SDK dependency switched from a path to a crates.io
version requirement (`invoiceninja-sdk = "0.2.0"`) once 0.2.0 was published
on 2026-10-25. A workflow here can now just `cargo build` against the
registry like any other Rust project; it doesn't need to check out
`invoiceninjars-sdk` at all.
- **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
+11 -3
View File
@@ -3,9 +3,11 @@
A command-line client for the [Invoice Ninja](https://www.invoiceninja.com/)
v5 API, built on [`invoiceninja-sdk`](https://crates.io/crates/invoiceninja-sdk).
> **Early scaffold.** Only the `clients` resource is wired up so far, as the
> template for adding the rest — see `CLAUDE.md` for the pattern.
> [`GOALS.md`](GOALS.md) covers what the crate is for and what it is not.
> **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
@@ -46,3 +48,9 @@ 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
+168 -1
View File
@@ -1,5 +1,43 @@
{
"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"
@@ -18,7 +56,135 @@
"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=",
@@ -37,7 +203,8 @@
"root": {
"inputs": {
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs",
"invoiceninja-nixos": "invoiceninja-nixos",
"nixpkgs": "nixpkgs_3",
"rust-overlay": "rust-overlay"
}
},
+59 -2
View File
@@ -1,14 +1,32 @@
{
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, ... }:
outputs = { self, nixpkgs, rust-overlay, flake-utils, invoiceninja-nixos, ... }:
let
cargoToml = builtins.fromTOML (builtins.readFile ./Cargo.toml);
@@ -60,7 +78,10 @@
in
{
devShells.default = pkgs.mkShell {
packages = [ rustToolchain pkgs.pkg-config pkgs.openssl ];
# 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";
};
@@ -73,6 +94,30 @@
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
@@ -85,5 +130,17 @@
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;
};
};
}
+129 -4
View File
@@ -45,13 +45,78 @@ pub enum OutputFormat {
Json,
}
/// One variant per resource. Only `clients` is wired up so far; it's the
/// template for adding the rest of the SDK's ~34 resources the same way —
/// see CLAUDE.md.
/// One variant per resource. 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)]
@@ -84,6 +149,61 @@ pub enum Action {
Delete { id: String },
}
/// Like `ResourceArgs`, but for the four resources with an entity-action
/// route (`invoices`, `quotes`, `credits`, `purchase_orders` — the SDK's
/// `entity_action_methods!`): adds `action` and `download`.
#[derive(Args)]
pub struct EntityResourceArgs {
#[command(subcommand)]
pub action: EntityAction,
}
/// `Action` plus the two entity-action-route subcommands. Duplicates
/// `Action`'s five variants rather than extending it, since Rust enums
/// don't compose that way and every resource's `run` match on `Action`
/// needs to stay exhaustive without a catch-all arm for variants it
/// doesn't support.
#[derive(Subcommand)]
pub enum EntityAction {
/// List records (one page by default; pass --all to follow pagination)
List(ListArgs),
/// Fetch one record by id
Get { id: String },
/// Create a record from a JSON body
Create(BodyArgs),
/// Update a record from a JSON body, keeping fields you don't mention.
Update {
id: String,
#[command(flatten)]
body: BodyArgs,
},
/// Delete (soft-delete) a record by id
Delete { id: String },
/// Perform an entity action, returning the updated record.
///
/// Known to work (returns the entity): auto_bill, clone_to_invoice,
/// clone_to_quote, mark_paid, mark_sent, archive, restore, delete (same
/// effect as the `delete` subcommand, just routed differently). Three
/// others on this route do *not* work here and will fail to decode:
/// `download` (use the `download` subcommand instead),
/// `convert_to_purchase_order` (not wrapped by the SDK yet), and
/// `history`/`delivery_note`/`cancel` (Invoice Ninja answers those with
/// an empty body, not the record).
Action { id: String, action: String },
/// Download the rendered PDF for one or more records, to stdout unless
/// `--output` is given.
///
/// With more than one id, Invoice Ninja streams a single PDF covering
/// all of them (the bulk `download` action) rather than one per id.
Download {
ids: Vec<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)]
@@ -96,7 +216,12 @@ pub struct ListArgs {
pub sort: Option<String>,
#[arg(long)]
pub filter: Option<String>,
/// e.g. "active,archived,deleted" to include archived/deleted records
/// 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"
+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>() + "…"
}
}
+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>() + "…"
}
}
+32
View File
@@ -1 +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>() + "…"
}
}
+62
View File
@@ -22,5 +22,67 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
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,
}
}
+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