Compare commits
3
Commits
1fbd7a7ce5
...
c8a67b1208
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c8a67b1208 | ||
|
|
96fba9a303 | ||
|
|
a0a629cbfe |
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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
@@ -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"
|
||||||
|
|||||||
@@ -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(¶ms).await?
|
||||||
|
} else {
|
||||||
|
client.list_bank_integrations(¶ms).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(¤t)?, 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>() + "…"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(¶ms).await?
|
||||||
|
} else {
|
||||||
|
client.list_bank_transaction_rules(¶ms).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(¤t)?, 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>() + "…"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(¶ms).await?
|
||||||
|
} else {
|
||||||
|
client.list_bank_transactions(¶ms).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(¤t)?, 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>() + "…"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(¶ms).await?
|
||||||
|
} else {
|
||||||
|
client.list_client_gateway_tokens(¶ms).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(¤t)?, 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>() + "…"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(¶ms).await?
|
||||||
|
} else {
|
||||||
|
client.list_companies(¶ms).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(¤t)?, 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>() + "…"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(¶ms).await?
|
||||||
|
} else {
|
||||||
|
client.list_company_gateways(¶ms).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(¤t)?, 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>() + "…"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(¶ms).await?
|
||||||
|
} else {
|
||||||
|
client.list_credits(¶ms).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(¤t)?, 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>() + "…"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(¶ms).await?
|
||||||
|
} else {
|
||||||
|
client.list_designs(¶ms).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(¤t)?, 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>() + "…"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(¶ms).await?
|
||||||
|
} else {
|
||||||
|
client.list_expense_categories(¶ms).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(¤t)?, 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>() + "…"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(¶ms).await?
|
||||||
|
} else {
|
||||||
|
client.list_group_settings(¶ms).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(¤t)?, 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>() + "…"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(¶ms).await?
|
||||||
|
} else {
|
||||||
|
client.list_locations(¶ms).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(¤t)?, 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>() + "…"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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(¶ms).await?
|
||||||
|
} else {
|
||||||
|
client.list_payment_terms(¶ms).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(¤t)?, 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>() + "…"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(¶ms).await?
|
||||||
|
} else {
|
||||||
|
client.list_projects(¶ms).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(¤t)?, 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>() + "…"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(¶ms).await?
|
||||||
|
} else {
|
||||||
|
client.list_purchase_orders(¶ms).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(¤t)?, 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>() + "…"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(¶ms).await?
|
||||||
|
} else {
|
||||||
|
client.list_recurring_expenses(¶ms).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(¤t)?, 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>() + "…"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(¶ms).await?
|
||||||
|
} else {
|
||||||
|
client.list_recurring_invoices(¶ms).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(¤t)?, 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>() + "…"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(¶ms).await?
|
||||||
|
} else {
|
||||||
|
client.list_recurring_quotes(¶ms).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(¤t)?, 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>() + "…"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(¶ms).await?
|
||||||
|
} else {
|
||||||
|
client.list_subscriptions(¶ms).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(¤t)?, 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>() + "…"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(¶ms).await?
|
||||||
|
} else {
|
||||||
|
client.list_system_logs(¶ms).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(¤t)?, 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>() + "…"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(¶ms).await?
|
||||||
|
} else {
|
||||||
|
client.list_tags(¶ms).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(¤t)?, 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>() + "…"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(¶ms).await?
|
||||||
|
} else {
|
||||||
|
client.list_task_schedulers(¶ms).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(¤t)?, 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>() + "…"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(¶ms).await?
|
||||||
|
} else {
|
||||||
|
client.list_task_statuses(¶ms).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(¤t)?, 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>() + "…"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(¶ms).await?
|
||||||
|
} else {
|
||||||
|
client.list_tasks(¶ms).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(¤t)?, 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>() + "…"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(¶ms).await?
|
||||||
|
} else {
|
||||||
|
client.list_tax_rates(¶ms).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(¤t)?, 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>() + "…"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(¶ms).await?
|
||||||
|
} else {
|
||||||
|
client.list_tokens(¶ms).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(¤t)?, 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>() + "…"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(¶ms).await?
|
||||||
|
} else {
|
||||||
|
client.list_vendors(¶ms).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(¤t)?, 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>() + "…"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(¶ms).await?
|
||||||
|
} else {
|
||||||
|
client.list_webhooks(¶ms).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(¤t)?, 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
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
password
|
||||||
@@ -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}"
|
||||||
|
'';
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
APP_KEY=base64:gJnNI8pOx4c0/Z6kl9mIjn0X9XkbJUWjtqnDFl9prvo=
|
||||||
|
API_KEY=test-api-token
|
||||||
Reference in New Issue
Block a user