Compare commits

...
3 Commits
Author SHA1 Message Date
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
41 changed files with 4365 additions and 28 deletions
+3
View File
@@ -5,3 +5,6 @@
/.cargo/config.toml /.cargo/config.toml
/result /result
/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
+12 -8
View File
@@ -12,14 +12,18 @@ command line.
### Added ### Added
- Six resources, each with `list` (with `--all` for every page, `--status`, - 33 resources, every one of them with a simple `list`/`get`/`create`/
and `--param key=value` for anything `ListParams` doesn't have a typed `update`/`delete` API in the SDK (`resource_methods!`/`crud_methods!`, no
setter for), `get`, `create`, `update`, and `delete`: `clients`, bulk/uploads/entity-actions) — see `invoiceninja-cli --help` for the
`invoices`, `quotes`, `payments`, `products`, `expenses`. `quotes` is full list. `list` takes `--all` for every page, `--status`, and
`invoices` under a different endpoint — Invoice Ninja stores both in the `--param key=value` for anything `ListParams` doesn't have a typed
same table, distinguished by `entity_type`, so `Quote` is a type alias for setter for. `quotes` and `credits` are `invoices` under different
`Invoice` in the SDK and `commands/quotes.rs` is otherwise identical to endpoints — Invoice Ninja stores all three in the same table,
`commands/invoices.rs`. 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 - `update` fetches the current record, shallow-merges the caller's
`--data`/`--file` JSON on top, and writes the merged result back, rather `--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. than sending the patch alone — Invoice Ninja's `PUT` is a full replace.
+58 -6
View File
@@ -20,10 +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 would contradict a stated goal or settle an open question, update
`GOALS.md` in the same change. `GOALS.md` in the same change.
**Early scaffold.** Six resources are wired up (`clients`, `invoices`, **Scaffold status.** Every resource that fits the simple CRUD template
`quotes`, `payments`, `products`, `expenses`) out of ~34; `clients` was the (`resource_methods!`/`crud_methods!` in the SDK — list/get/create/update/
template the other five followed. See "Adding a resource" below before delete, no bulk/uploads/entity-actions) is wired up, 33 in total; `clients`
writing the next one. 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 ## Development environment
@@ -110,6 +112,55 @@ The `test` and `msrv` jobs skip scheduled runs (`if: github.event_name !=
'schedule'`) — a pinned toolchain against a locked dependency graph can't 'schedule'`) — a pinned toolchain against a locked dependency graph can't
change on a timer, so the weekly run is for `stable` only. 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 ## Common commands
```sh ```sh
@@ -143,8 +194,9 @@ SDK call.
- `src/output.rs` — `print_json`, shared by every resource's `--output - `src/output.rs` — `print_json`, shared by every resource's `--output
json`. Table rendering is **not** here; it's per-resource (see below), json`. Table rendering is **not** here; it's per-resource (see below),
since there's no generic notion of which columns matter. since there's no generic notion of which columns matter.
- `src/commands/clients.rs` — the one resource implemented so far, and the - `src/commands/clients.rs` — the first resource implemented, and the
template for the rest. template every other `src/commands/<resource>.rs` follows (33 of them now;
see "Adding a resource" below).
### The `update` fetch-merge-write pattern ### The `update` fetch-merge-write pattern
+9 -7
View File
@@ -3,9 +3,10 @@
A command-line client for the [Invoice Ninja](https://www.invoiceninja.com/) A command-line client for the [Invoice Ninja](https://www.invoiceninja.com/)
v5 API, built on [`invoiceninja-sdk`](https://crates.io/crates/invoiceninja-sdk). v5 API, built on [`invoiceninja-sdk`](https://crates.io/crates/invoiceninja-sdk).
> **Early scaffold.** `clients`, `invoices`, `quotes`, `payments`, `products`, > **Scaffold status.** Every resource with a simple list/get/create/update/
> and `expenses` are wired up so far, out of ~34 resources — see `CLAUDE.md` > delete API (33 of them) is wired up — see `CLAUDE.md` for what's still
> for the pattern the rest follow. [`GOALS.md`](GOALS.md) covers what the > 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. > crate is for and what it is not.
## Development ## Development
@@ -48,7 +49,8 @@ table view's few key columns:
invoiceninja-cli --output json clients get <id> invoiceninja-cli --output json clients get <id>
``` ```
Every other wired-up resource (`invoices`, `quotes`, `payments`, `products`, Every other wired-up resource follows the same
`expenses`) follows the same `list`/`get`/`create`/`update`/`delete` shape, `list`/`get`/`create`/`update`/`delete` shape, e.g.
e.g. `invoiceninja-cli invoices list --status active` or `invoiceninja-cli invoices list --status active` or
`invoiceninja-cli payments get <id>`. `invoiceninja-cli purchase-orders get <id>`. Run `invoiceninja-cli --help`
for the full list.
Generated
+168 -1
View File
@@ -1,5 +1,43 @@
{ {
"nodes": { "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": { "flake-utils": {
"inputs": { "inputs": {
"systems": "systems" "systems": "systems"
@@ -18,7 +56,135 @@
"type": "github" "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": { "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": { "locked": {
"lastModified": 1792773339, "lastModified": 1792773339,
"narHash": "sha256-rZLrzrC6i+jnTAlvPZHogKDulY7WWDEv9hJeHieO7Zw=", "narHash": "sha256-rZLrzrC6i+jnTAlvPZHogKDulY7WWDEv9hJeHieO7Zw=",
@@ -37,7 +203,8 @@
"root": { "root": {
"inputs": { "inputs": {
"flake-utils": "flake-utils", "flake-utils": "flake-utils",
"nixpkgs": "nixpkgs", "invoiceninja-nixos": "invoiceninja-nixos",
"nixpkgs": "nixpkgs_3",
"rust-overlay": "rust-overlay" "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"; 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 = { inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
rust-overlay.url = "github:oxalica/rust-overlay"; rust-overlay.url = "github:oxalica/rust-overlay";
rust-overlay.inputs.nixpkgs.follows = "nixpkgs"; rust-overlay.inputs.nixpkgs.follows = "nixpkgs";
flake-utils.url = "github:numtide/flake-utils"; 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 let
cargoToml = builtins.fromTOML (builtins.readFile ./Cargo.toml); cargoToml = builtins.fromTOML (builtins.readFile ./Cargo.toml);
@@ -60,7 +78,10 @@
in in
{ {
devShells.default = pkgs.mkShell { 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"; RUST_SRC_PATH = "${rustToolchain}/lib/rustlib/src/rust/library";
}; };
@@ -73,6 +94,30 @@
inherit (pkgs) lib pkg-config openssl; inherit (pkgs) lib pkg-config openssl;
inherit rustPlatform; 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 # For a flake that wants `invoiceninja-cli` in its own `pkgs`, not for
# this flake's own `packages.default`/`devShells.default` above (those # this flake's own `packages.default`/`devShells.default` above (those
@@ -85,5 +130,17 @@
overlays.default = final: _prev: { overlays.default = final: _prev: {
invoiceninja-cli = final.callPackage mkInvoiceninjaCli { }; 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;
};
}; };
} }
+64 -4
View File
@@ -45,9 +45,10 @@ pub enum OutputFormat {
Json, Json,
} }
/// One variant per resource. Six are wired up so far (`clients` was the /// One variant per resource. Every resource that fits the simple CRUD
/// template for the rest); see CLAUDE.md's "Adding a resource" for the /// template (`resource_methods!`/`crud_methods!` in the SDK) is wired up;
/// remaining ~28. /// see CLAUDE.md's "Adding a resource" for what's deliberately left out
/// (bulk actions, uploads, entity actions, non-CRUD endpoints).
#[derive(Subcommand)] #[derive(Subcommand)]
pub enum Command { pub enum Command {
/// Manage clients /// Manage clients
@@ -56,12 +57,66 @@ pub enum Command {
Invoices(ResourceArgs), Invoices(ResourceArgs),
/// Manage quotes /// Manage quotes
Quotes(ResourceArgs), Quotes(ResourceArgs),
/// Manage credits
Credits(ResourceArgs),
/// Manage payments /// Manage payments
Payments(ResourceArgs), Payments(ResourceArgs),
/// Manage products /// Manage products
Products(ResourceArgs), Products(ResourceArgs),
/// Manage expenses /// Manage expenses
Expenses(ResourceArgs), Expenses(ResourceArgs),
/// Manage projects
Projects(ResourceArgs),
/// Manage tasks
Tasks(ResourceArgs),
/// Manage vendors
Vendors(ResourceArgs),
/// Manage purchase orders
PurchaseOrders(ResourceArgs),
/// 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)] #[derive(Args)]
@@ -106,7 +161,12 @@ pub struct ListArgs {
pub sort: Option<String>, pub sort: Option<String>,
#[arg(long)] #[arg(long)]
pub filter: Option<String>, 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)] #[arg(long)]
pub status: Option<String>, pub status: Option<String>,
/// Comma-separated relationships to eager-load, e.g. "contacts" /// 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>() + "…"
}
}
+140
View File
@@ -0,0 +1,140 @@
//! `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 invoiceninja_sdk::models::{Credit, 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 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(())
}
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>() + "…"
}
}
+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>() + "…"
}
}
+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>() + "…"
}
}
+27
View File
@@ -1,6 +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 clients;
pub mod companies;
pub mod company_gateways;
pub mod credits;
pub mod designs;
pub mod expense_categories;
pub mod expenses; pub mod expenses;
pub mod group_settings;
pub mod invoices; pub mod invoices;
pub mod locations;
pub mod payment_terms;
pub mod payments; pub mod payments;
pub mod products; pub mod products;
pub mod projects;
pub mod purchase_orders;
pub mod quotes; 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 @@
//! `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>() + "…"
}
}
+141
View File
@@ -0,0 +1,141 @@
//! `purchase-orders` subcommand. Same shape as `clients.rs` — see the
//! "Adding a resource" section in CLAUDE.md.
use invoiceninja_sdk::models::{ListParams, PurchaseOrder};
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 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(())
}
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>() + "…"
}
}
+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>() + "…"
}
}
+57
View File
@@ -24,8 +24,65 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
Command::Clients(args) => commands::clients::run(&client, args, cli.output).await, Command::Clients(args) => commands::clients::run(&client, args, cli.output).await,
Command::Invoices(args) => commands::invoices::run(&client, args, cli.output).await, Command::Invoices(args) => commands::invoices::run(&client, args, cli.output).await,
Command::Quotes(args) => commands::quotes::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::Payments(args) => commands::payments::run(&client, args, cli.output).await,
Command::Products(args) => commands::products::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::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
+103
View File
@@ -0,0 +1,103 @@
{ 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 and see it in a payment"):
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}"
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