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