Compare commits

...
8 Commits
Author SHA1 Message Date
awkawbandClaude Opus 5 a7d8300ce9 Narrow the ORM/caching non-goal to what is actually ruled out
CI / test (push) Canceled after 0s
CI / Build on the declared MSRV (push) Canceled after 0s
CI / Latest stable (advisory) (push) Canceled after 0s
"No ORM, session, or caching layer" bundled several decisions with very
different costs, and read as refusing things that are in fact fine.

Split into the three that genuinely conflict, each with its reason:
lazy loading (models would have to hold a client handle, breaking
`Default`-constructibility and the `extra` round trip), dirty tracking
(cannot diff a flattened map of unmodelled fields), and transparent
entity caching.

The caching reason is the one worth recording: `BaseController`'s shared
response path sends no `ETag`, `If-None-Match`, or `Cache-Control`, so a
cached entity cannot be cheaply revalidated. On an API that handles money
that is a correctness hazard rather than a missed optimisation. Writing
the evidence down so the question is not reopened without it.

Two things the old wording excluded by accident are now open questions
instead: typed `include` helpers, since eager loading is a stringly
parameter where a typo is a silent no-op; and an opt-in cache for
`/statics`, which is near-constant and not money. Also parks the
`updated_at` delta-sync parameter as the shape a local mirror would
need, noted as a different product rather than a plan.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-23 19:43:19 -04:00
awkawbandClaude Opus 5 fbde9a9bb7 Write down the project's goals, non-goals, and roadmap
Several decisions in here look like oversights until you know the
reasoning: the curated model subset with everything else in `extra`, the
still-generic `chart`, not retrying `POST`, the deliberate divergences
from the official PHP SDK. That reasoning was spread across code
comments and commit messages, where it is easy to miss and easy to
"fix".

GOALS.md states:

- What the crate is for: complete coverage, and not quietly losing data,
  truncating results, or succeeding when it did not.
- Seven goals, each tied to a decision visible in the code, so the
  rationale survives the next refactor.
- Non-goals, including no sync API, no caching or ORM layer, no
  modelling of every field, and no retrying of non-idempotent requests.
- Publication posture: on crates.io, pre-1.0, no stability promise, pin
  an exact version. MSRV may rise with dependencies.
- A roadmap ordering the COVERAGE.md gaps by value over effort, starting
  with multipart uploads, which is the only thing blocking
  `create_document`.
- Open questions, left open rather than papered over: whether auth routes
  belong here at all given they are a different auth model, whether
  per-chart types earn their keep, streaming for large exports, and the
  untested `Error::PaginationLimit`.

README, COVERAGE.md, and CLAUDE.md link to it. No changelog entry: this
adds no behaviour, per the rule in CLAUDE.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-23 19:25:59 -04:00
awkawbandClaude Opus 5 935690c8f0 Make the stable-toolchain signal visible
The advisory job was marked `continue-on-error`, which GitHub renders as
a green check with an annotation — a signal nobody reads. Removed, so a
failure shows as a real red X.

What now keeps it from blocking merges is branch protection: it has to
stay out of the required status checks. That is a repo setting rather
than anything in this file, so it is called out in ci.yml and CLAUDE.md.
Expect the job red occasionally through no fault of the change under
test; that is the cost of it being visible.

Also runs weekly (Mondays 06:00 UTC). A Rust release lands on its own
schedule, so on push/PR alone breakage could sit unnoticed for weeks.
The pinned `test` and `msrv` jobs skip scheduled runs — a pinned
toolchain against a locked dependency graph cannot change on a timer.

Adds a top-level `permissions: contents: read`, since the workflow needs
nothing more.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-23 19:14:44 -04:00
awkawbandClaude Opus 5 aa802bc40c Add an advisory CI job on latest stable
Pinning CI meant a future Rust release could break the build without
anyone noticing until the pin moved. This job tracks latest stable and
runs the test suite and clippy, so that breakage is visible early.

Marked `continue-on-error`, so it reports rather than gates: a new
clippy lint landing in stable should not fail a pull request that did
not cause it. The pinned `test` job remains the gate.

Note that GitHub renders a `continue-on-error` job as passing with an
annotation rather than as failed. If it should be more visible, make it
a required check and drop the flag.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-23 19:01:42 -04:00
awkawbandClaude Opus 5 ad9553516e Pin the Rust toolchain and declare an MSRV of 1.88
The dev shell tracked `stable.latest` and CI used `@stable`, so nothing
pinned a version and nothing declared a floor. A consumer on an older
toolchain hit a confusing error deep in a dependency rather than cargo
saying the version was too old, and a new stable release could turn a
lint into an unrelated red build.

- `rust-version = "1.88"` in Cargo.toml. The floor comes from the
  dependency graph, not this crate's code: reqwest pulls in url -> idna
  -> idna_adapter -> icu_*, and the icu crates require 1.88. Verified by
  running the full suite against 1.88, not inferred.
- flake.nix pins the dev toolchain to 1.99.1 and adds an `msrv` devShell,
  so the floor can be rechecked with
  `nix develop .#msrv --command cargo test`.
- CI pins the main job to the same 1.99.1 and adds a job building on
  1.88. Lint and format stay on the pinned toolchain only, since their
  output varies by release.

Note the tradeoff: with CI pinned, a future stable that breaks the build
will not be noticed until the pin moves. Worth adding a non-blocking
`stable` job if that matters.

CLAUDE.md records the three places that must agree when either version
moves, and how to recheck the floor after a dependency bump.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-23 18:46:32 -04:00
awkawbandClaude Opus 5 8ff947455e Harden error reporting, retry bounds, and credential handling
Second hardening pass, over the code added in 23c660d.

Error reporting:
- Decode failures now carry an excerpt of the body that arrived. The
  serde message alone ("expected value at line 1 column 1") gives no hint
  that the response was an HTML error page from a proxy or a login
  redirect from a wrong base URL. The excerpt is collapsed to one line
  and truncated so a large body cannot become a large error.
- API errors with an empty or message-less body report the status reason
  ("Not Found") rather than rendering blank.
- `Error` is `#[non_exhaustive]`, so future variants are not breaking.

Retry bounds:
- `RetryPolicy::max_elapsed` (120s default) bounds the time one call may
  spend across attempts. The request timeout is per attempt, so the
  previous defaults could spend four minutes on a call that kept timing
  out — a surprise for anyone reading `with_timeout(60)` as a bound on
  the call.
- `backoff` uses saturating arithmetic; a large `max_delay` could
  overflow the jitter span and panic in a debug build.
- Response bodies are drained before a retry so the connection returns to
  the pool instead of the next attempt opening a fresh one.

Credentials:
- Test pinning that neither the token nor the password appears in `Debug`
  output. `reqwest`'s `Client` prints its default headers, so the
  sensitive flag set when building them is the only thing keeping the
  token out of logs, and nothing enforced that.

Also drops `Option::is_none_or`, which raised the minimum toolchain to
1.82 for no benefit over a match.

91 tests, up from 80.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-23 18:33:43 -04:00
awkawbandClaude Opus 5 23c660d2f9 Add retry with backoff, and narrow the untyped responses
Retry:
- `RetryPolicy` (3 retries, full-jitter exponential backoff) applied in a
  new `execute` dispatch chokepoint, configurable via
  `with_retry_policy`. `Retry-After` is honoured, and one longer than
  `max_delay` is surfaced rather than slept through.
- What may be replayed depends on the method, not just the status. A 429
  means the server refused the request without processing it, so any
  method is safe. A 5xx or timeout gives no such guarantee, so only
  idempotent methods are retried: this is a billing API, and replaying a
  POST the server had already applied would duplicate an invoice,
  payment, or email.

Response types:
- `statics`, `search`, and `health_check` return `Statics`,
  `SearchResults`, and `HealthCheck` instead of being generic.
  `HealthCheck` covers both deployments — hosted returns a bare message
  rather than a report — and `SearchResults` covers both the
  Elasticsearch and non-Elasticsearch bucket sets.
- Each keeps the `extra` convention, and `statics_as` / `search_as` /
  `health_check_as` remain for pinning shapes left untyped. Nested config
  blobs (gateways, templates, extensions) stay `Value`, same reasoning as
  `Company::settings`.
- `send_email` previously decoded the response as a bare body, but the
  controller returns `itemResponse(...)` — a `data`-wrapped entity. It now
  unwraps, so `T` is the entity. Still generic, since the endpoint serves
  invoices, quotes, credits, and purchase orders.
- `chart` stays generic on purpose: the eleven charts share no response
  shape.
- Adds `id_as_string`, since `/statics` returns `payment_types.id` as a
  string and the other tables return numbers.

Adds CHANGELOG.md (Keep a Changelog), and a CLAUDE.md note to keep it
current. 17 new tests, 43 in client_requests.rs total.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-23 18:18:12 -04:00
awkawbandClaude Opus 5 9976249210 Wrap all 34 resource groups, then harden the request plumbing
Expands coverage to every `Route::resource` group in the v5 API plus
`users` (265 methods), and fixes several defects in the shared request
path that the new breadth made more exposed.

Coverage:
- 25 new models, each keeping the `extra` flatten map and the
  `null_to_default` convention so unmodeled fields round-trip.
- `crud_methods!` split out of `resource_methods!` for the three groups
  with no `/bulk` route, where a generated `bulk_*` would only 405.
- COVERAGE.md added as the detailed inventory, verified against
  `routes/api.php` on v5-stable.

Fixes, each confirmed against upstream source or an observed request:
- `list_all_*` silently truncated. `BaseController` caps page size at
  `min(abs(per_page), 5000)`, so the "short page means last page" rule
  stopped early and returned a partial list as if complete. Termination
  now prefers `meta.pagination.total_pages` (decoded but previously
  discarded), and `per_page` is clamped so the fallback stays sound.
- `per_page(0)` paged forever: the check `got < 0` has no solution. It
  now falls back to the default size, with a MAX_PAGES backstop that
  raises `Error::PaginationLimit` rather than quietly truncating.
- Ids were interpolated into paths raw, so a `/` or `?` in an id
  retargeted the request. All sites now use `seg(id)?`.
  Escaping alone is insufficient: the WHATWG rules `url` implements
  decode `%2E` to `.` before resolving dot segments, so an escaped `..`
  still collapses the path. Empty and dot-only ids are rejected.
- No request timeout. reqwest applies none, so a stalled connection hung
  the caller indefinitely. Adds a 60s default and `with_timeout`.
- Base URL is validated at construction; a missing scheme used to 404
  on every call instead of failing once.

Adds tests/client_requests.rs: 26 tests over the request plumbing
against a wiremock loopback server, including regressions for the
truncation and non-termination bugs. No network or credentials needed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-23 18:08:38 -04:00
52 changed files with 5886 additions and 194 deletions
+47 -1
View File
@@ -4,19 +4,65 @@ on:
push: push:
branches: [main] branches: [main]
pull_request: 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: env:
CARGO_TERM_COLOR: always CARGO_TERM_COLOR: always
permissions:
contents: read
jobs: jobs:
test: test:
runs-on: ubuntu-latest 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: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable # Pinned, and kept in step with `devVersion` in flake.nix, 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: with:
components: rustfmt, clippy components: rustfmt, clippy
- run: cargo build --all-targets - run: cargo build --all-targets
- run: cargo test - run: cargo test
- run: cargo fmt --check - run: cargo fmt --check
- run: cargo clippy --all-targets -- -D warnings - 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 and `msrvVersion` in
# flake.nix. Builds 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
+100
View File
@@ -0,0 +1,100 @@
# 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).
## [Unreleased]
### Added
- A declared minimum supported Rust version of **1.88**, verified by running
the test suite against it. The floor comes from the dependency graph
(`reqwest` → `url` → `idna` → `icu_*`), not from this crate's own code.
CI builds on both the MSRV and the pinned dev toolchain, plus a weekly
advisory job on latest stable that reports breakage from a new Rust
release. The advisory job fails visibly rather than silently; it is kept
non-blocking through branch protection rather than `continue-on-error`.
- Wrappers for every one of the API's 34 `Route::resource` groups, plus
`users` — 265 methods. See `COVERAGE.md` for the per-endpoint inventory.
- `COVERAGE.md`, a detailed record of what is wrapped, what is not, and why,
checked against `routes/api.php` on the `v5-stable` branch.
- Retry with full-jitter exponential backoff, configurable through
`RetryPolicy` and `InvoiceNinjaClient::with_retry_policy`. Defaults to 3
retries. `Retry-After` is honoured when the server sends it.
- `InvoiceNinjaClient::with_timeout` and a `DEFAULT_TIMEOUT` of 60 seconds
applied to every request.
- Typed responses for previously untyped endpoints: `Statics`, `StaticItem`,
`Currency`, `DateFormat`, `SearchResults`, `SearchHit`, `HealthCheck`, and
`PhpVersion`.
- `statics_as`, `search_as`, and `health_check_as` as generic escape hatches
for callers who need to pin a shape the typed models leave in `extra`.
- `tests/client_requests.rs`: 50 tests covering the request plumbing against
a loopback `wiremock` server. No network or credentials required.
- `RetryPolicy::max_elapsed`, a ceiling on the time one call may spend across
all attempts, defaulting to 120 seconds. The request timeout is per
attempt, so without this the default policy could spend four minutes on a
call that times out repeatedly.
### Changed
- `statics`, `search`, and `health_check` return concrete types instead of
being generic over `T: DeserializeOwned`.
- `send_email` now unwraps the `data` envelope, so `T` is the entity
(`Invoice`, `Quote`, …) rather than a wrapper the caller had to model. It
stays generic because the endpoint serves all four entity types.
- `chart` remains generic, deliberately: the eleven chart endpoints share no
common response shape, so there is nothing to narrow to.
- The base URL is parsed and validated when the client is constructed. A
missing scheme used to produce a 404 on every call instead of one error.
- `list_all_*` terminates on `meta.pagination.total_pages` when the server
sends it, falling back to the short-page heuristic only when it does not.
- `Error` is now `#[non_exhaustive]`, so later variants are not a breaking
change. Matches on it need a catch-all arm.
- `Error::Json` is now a struct variant carrying an `excerpt` of the body
that failed to decode. The serde message alone ("expected value at line 1
column 1") does not reveal that the response was, say, an HTML error page
from a proxy or a login redirect from a wrong base URL.
- API errors with an empty or message-less body now report the status reason
("Not Found") instead of rendering blank.
### Fixed
- `list_all_*` silently truncated results. The server caps page size at
`min(abs(per_page), 5000)`, so a page shorter than requested did not mean
the last page — the walk stopped early and returned a partial list as
though it were complete. `per_page` is now clamped to the server maximum so
the fallback heuristic holds.
- `list_all_*` with `per_page(0)` never terminated: the check `got < 0` has
no solution. It now falls back to the default page size, and a `MAX_PAGES`
backstop raises `Error::PaginationLimit` rather than quietly truncating.
- Requests could hang indefinitely. `reqwest` applies no timeout of its own
and none was configured.
- Ids were interpolated into request paths unescaped, so a `/`, `?`, or `#`
in an id changed which endpoint was addressed.
### Security
- Ids used as URL path segments are escaped, and ids that are empty or
consist only of dots are rejected. Escaping alone is insufficient: the
WHATWG rules the `url` crate implements decode `%2E` back to `.` before
resolving dot segments, so an escaped `..` still collapsed the path. An
empty id would have addressed the collection route, making
`delete_client("")` mean `DELETE /clients`.
- `POST` is never replayed on a 5xx or timeout, since the server may have
applied it before the response was lost and a replay would duplicate an
invoice, payment, or email. A 429 is the exception — the server states it
refused the request, so no work was done.
- The API token and password are held as headers marked sensitive, so
neither appears in `Debug` output. `reqwest`'s own `Client` debug prints
its default headers, so this is the only thing keeping the token out of
logs; there is now a regression test pinning it.
## [0.1.0] - 2026-10-23
### Added
- Initial release: async client for the Invoice Ninja v5 API covering
clients, invoices, quotes, credits, payments, products, expenses,
projects, tasks, and vendors.
+213 -21
View File
@@ -6,6 +6,14 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
An async Rust client for the [Invoice Ninja](https://www.invoiceninja.com/) v5 API. An async Rust client for the [Invoice Ninja](https://www.invoiceninja.com/) v5 API.
**`GOALS.md` states what the project is for, what it deliberately is not,
and what is still undecided.** Read it before making a design call — several
choices in here look like oversights until you know the reasoning (the
curated model subset, the untyped `chart`, not retrying `POST`, the
divergences from the official PHP SDK). If a change would contradict a
stated goal or settle an open question, update `GOALS.md` in the same
change.
## Development environment ## Development environment
This project uses Nix for toolchain management (Rust via `rust-overlay`): This project uses Nix for toolchain management (Rust via `rust-overlay`):
@@ -14,6 +22,53 @@ This project uses Nix for toolchain management (Rust via `rust-overlay`):
nix develop nix develop
``` ```
### Rust version
Two versions are pinned, and **three places must agree** when either moves:
| | Version | Declared in |
| --- | --- | --- |
| Dev toolchain | 1.99.1 | `devVersion` in `flake.nix`, `test` job in `ci.yml` |
| MSRV | 1.88 | `msrvVersion` in `flake.nix`, `rust-version` in `Cargo.toml`, `msrv` job in `ci.yml` |
CI has a third job, `stable`, which tracks latest stable and runs weekly
(Mondays, via `schedule`) as well as on push and PR. It exists so a new
Rust release that breaks the build or fires a new clippy lint shows up
before the pin moves. Check it when bumping `devVersion` — if it is red,
the bump needs work first. Nothing needs to stay in sync with it.
**It is advisory, but it reports red.** There is deliberately no
`continue-on-error`, because a green check with an annotation is a signal
nobody reads. That means **it must stay out of the required status checks**
in branch protection — that repo setting, not anything in `ci.yml`, is what
keeps it from blocking merges. Expect it red occasionally through no fault
of the change under test; that is the tradeoff for it being visible.
The pinned `test` and `msrv` jobs skip scheduled runs (`if:
github.event_name != 'schedule'`), since a pinned toolchain against a
locked dependency graph cannot change on a timer.
The MSRV is set by the **dependency graph**, not by this crate's code:
`reqwest` pulls in `url` → `idna` → `idna_adapter` → `icu_*`, and the `icu`
crates require 1.88. Recheck the floor after a dependency bump with:
```sh
cargo metadata --format-version 1 \
| jq -r '.packages[] | select(.rust_version) | "\(.rust_version)\t\(.name)"' \
| sort -V | tail -5
```
Verify a candidate MSRV actually builds before declaring it — the number
above was confirmed by running the suite, not inferred:
```sh
CARGO_TARGET_DIR=/tmp/msrv-target nix develop .#msrv --command cargo test
```
Don't reach for a newer std API without checking it against the MSRV; a
convenience like `Option::is_none_or` (1.82) is rarely worth raising the
floor for.
## Common commands ## Common commands
```sh ```sh
@@ -29,37 +84,138 @@ IN_API_TOKEN=... cargo run --example list_clients
``` ```
Tests live in `tests/` (integration-style, no network access required); Tests live in `tests/` (integration-style, no network access required);
`src/` has no `#[test]` modules. `src/` has no `#[test]` modules. Two files: `model_roundtrip.rs` covers the
serde layer, and `client_requests.rs` covers the request plumbing
(pagination, path escaping, headers, timeouts, error mapping) against a
`wiremock` server on loopback — no credentials or internet needed.
## Changelog
`CHANGELOG.md` follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
and semantic versioning. **Update it in the same change that alters
behaviour** — add an entry under `## [Unreleased]`, in the right section
(`Added`, `Changed`, `Deprecated`, `Removed`, `Fixed`, `Security`), creating
the section if it is not there yet. Entries describe the effect on a user of
the crate, not the implementation: say what broke or changed for a caller and
why, not which function was edited. Purely internal work (refactors, test-only
changes, doc tweaks) does not need an entry.
## Architecture ## Architecture
- `src/client.rs` — `InvoiceNinjaClient` holds a `reqwest::Client` (with the - `src/client.rs` — `InvoiceNinjaClient` holds a `reqwest::Client` (with the
`X-Api-Token` auth header baked in) and a `base_url`. Low-level helpers `X-Api-Token` auth header baked in) and a `base_url`. Low-level helpers
(`get_list`, `get_one`, `post_one`, `put_one`, `delete_one`) handle request (`get_list`, `get_one`, `post_one`, `put_one`, `delete_one`, `post_list`)
dispatch and decode `ListResponse<T>` / `ItemResponse<T>` envelopes, mapping handle request dispatch and decode `ListResponse<T>` / `ItemResponse<T>`
non-2xx responses through `api_error` into `Error::Api`. envelopes, mapping non-2xx responses through `api_error` into `Error::Api`.
- Not every endpoint answers with a JSON envelope, so there are three other
helper families: `get_raw`/`post_raw` return the body as bytes (PDF streams
and CSV report exports), `get_unwrapped`/`post_unwrapped` decode a bare
JSON body with no `data` wrapper (`/statics`, `/charts/*`, `/search`), and
`get_discard` just checks the status (`/ping`).
- Per-resource methods (`list_clients`, `get_invoice`, `create_product`, etc.) - Per-resource methods (`list_clients`, `get_invoice`, `create_product`, etc.)
are generated by the `resource_methods!` macro at the bottom of are generated by the `resource_methods!` macro at the bottom of
`client.rs`, which expands to `client.rs`, which expands to
`list`/`list_all`/`get`/`create`/`update`/`delete` for a given REST path and `list`/`list_all`/`get`/`create`/`update`/`delete`/`bulk` for a given REST
model type. **To add a new resource**: add a model under `src/models/`, path and model type. **To add a new resource**: add a model under
re-export it from `src/models/mod.rs`, and add one `resource_methods!` `src/models/`, re-export it from `src/models/mod.rs`, and add one
invocation in `client.rs`. Note the macro takes six method names before the `resource_methods!` invocation in `client.rs`. Note the macro takes seven
path and model — adding a resource means passing all six. method names before the path and model — adding a resource means passing
all seven.
- `resource_methods!` delegates the six CRUD methods to `crud_methods!` and
adds `bulk` on top. **Use `crud_methods!` directly for resources with no
`/bulk` route** — currently `client_gateway_tokens`, `companies`, and
`system_logs`. Check `routes/api.php` before assuming a resource has one;
a generated `bulk_*` against a missing route just 405s.
- Requests go through two chokepoints: `req(method, path)` builds them and is
the single place the optional `X-API-PASSWORD` header and the per-request
timeout are attached, and `execute(builder)` dispatches them and is the
single place retrying happens. **A new helper must use both** — calling
`self.http` directly would skip the header, the timeout, and the retry
policy. Only `req` and `execute` should touch `self.http`.
- **The request timeout is per attempt, not per call.** `RetryPolicy::
max_elapsed` is what bounds a call overall; without it, four attempts at
the 60s default would take four minutes. Keep both in mind when changing
either.
- **Both credentials are stored as `HeaderValue`s with
`set_sensitive(true)`.** `reqwest`'s `Client` Debug prints its default
headers, so that flag is the only thing keeping the API token out of any
`{:?}` log. Don't build either header without it; a test pins this.
- `src/retry.rs` — `RetryPolicy` plus the rules for what may be replayed.
**The method matters, not just the status.** A 429 means the server
refused the request without processing it, so any method is safe to
retry; a 5xx or timeout gives no such guarantee, so only idempotent
methods are. **Don't extend retrying to `POST` on 5xx** — this is a
billing API, and a replayed create would duplicate an invoice or payment.
- **Interpolate ids into paths with `seg(id)?`, never raw.** It escapes the
value into a single path segment and rejects ids that are empty or
dot-only. Both halves matter: escaping stops a `/` or `?` in an id from
retargeting the request, but escaping *alone* is not enough, because the
WHATWG rules the `url` crate follows decode `%2E` back to `.` before
resolving dot segments — so an escaped `..` still collapses the path. The
exceptions are `report` and `chart`, which take a multi-segment tail by
design (`chart("project_burnup/<id>", …)`) and must stay unescaped.
- `entity_action_methods!` generates three methods — `{x}_action` and
`download_{x}` for the `GET /{resource}/{id}/{action}` route, plus
`download_{plural}` for the bulk merged-PDF action. **Only invoices,
quotes, credits, and purchase orders have the action route** — don't add it
elsewhere without checking `routes/api.php`. In particular the official PHP
SDK puts bulk `download` on *every* resource, which is wrong; only
PDF-rendering entities support it.
- Non-CRUD endpoints (documents, reports, charts, search, statics, emails,
refunds, ping/health) are hand-written in the last `impl` block rather than
macro-generated, because their request and response shapes are all
different.
- `list_*` returns one page; `list_all_*` goes through `get_all_pages`, which - `list_*` returns one page; `list_all_*` goes through `get_all_pages`, which
follows `page`/`per_page` until a page returns fewer rows than requested. terminates on `meta.pagination.total_pages` and only falls back to the
- `src/models/` — one file per resource (`client.rs`, `invoice.rs`, short-page heuristic when the server sends no metadata. **The heuristic is
`payment.rs`, `product.rs`, `expense.rs`, `project.rs`, `task.rs`, not sound on its own**: `BaseController` caps `per_page` at
`vendor.rs`), plus `common.rs` for shared envelope/pagination types `min(abs(per_page), 5000)`, so a page can be shorter than requested while
(`ListParams`, `ListResponse`, `ItemResponse`, `Meta`, `Pagination`). records remain — which is why `per_page` is clamped to 5000 before the
Invoice Ninja stores quotes and credits in the same table as invoices (an walk starts. `per_page(0)` falls back to the default size (it would make
`entity_type` field distinguishes them), so `Quote` and `Credit` are type the short-page check `got < 0`, which never terminates), the page counter
aliases for `Invoice` (`src/models/invoice.rs`) and share the advances locally rather than trusting the echoed `current_page`, an empty
`/quotes`, `/credits`, and `/invoices` paths via the macro. page always ends the walk, and a `MAX_PAGES` backstop raises
`Error::PaginationLimit` rather than quietly returning a truncated list.
- `src/models/` — one file per resource, plus `common.rs` for shared
envelope/pagination types (`ListParams`, `ListResponse`, `ItemResponse`,
`Meta`, `Pagination`).
- Several endpoints share a schema, so some models are type aliases rather
than separate structs: Invoice Ninja keeps quotes and credits in the
invoices table (an `entity_type` field distinguishes them), so `Quote` and
`Credit` alias `Invoice` (`src/models/invoice.rs`); likewise
`RecurringQuote` aliases `RecurringInvoice`
(`src/models/recurring_invoice.rs`), whose fields are a strict superset.
Each alias still gets its own path via the macro.
- Every model struct has a `#[serde(flatten)] pub extra: Map<String, Value>` - Every model struct has a `#[serde(flatten)] pub extra: Map<String, Value>`
field. This preserves any API fields not explicitly modeled, so a `get` field. This preserves any API fields not explicitly modeled, so a `get`
followed by an `update` round-trips without silently dropping data. Keep followed by an `update` round-trips without silently dropping data. Keep
this pattern when adding fields or new models. this pattern when adding fields or new models.
- **Every non-`Option` field carries
`#[serde(default, deserialize_with = "super::common::null_to_default")]`,
not a bare `#[serde(default)]`.** `default` alone only covers a *missing*
key; a key present with an explicit `null` fails with `invalid type: null,
expected a string`. Invoice Ninja returns `null` for many
nominally-string fields, so a bare `default` would break decoding of the
whole response. `Option<T>` fields keep the plain `#[serde(default)]` —
they already map `null` to `None`, and that distinction matters (an unset
`archived_at` should stay `None`, not become `Some(0)`).
- Models deliberately cover a curated subset of each schema and let `extra`
carry the rest — `custom_value1..4` and `custom_surcharge1..4` are
intentionally unmodeled. Fields whose JSON type is ambiguous or
inconsistent in the upstream spec (e.g. `Subscription.currency_id`,
`webhook_configuration`, `due_date_days`) are also left to `extra`, since a
wrong concrete type is a hard deserialize error whereas `extra` is lossless.
- Field names and types come from Invoice Ninja's own OpenAPI spec
(`openapi/api-docs.yaml` on the `v5-stable` branch of the invoiceninja
repo). Check it before adding or retyping a field; the same name can differ
in type across resources (`frequency_id` is a number on recurring invoices
and expenses but a string on subscriptions).
- **The published spec is incomplete for routes**: it documents `/{id}` and
action paths only for clients, locations, and tags, and omits them for
invoices and everything else. `routes/api.php` in the same repo is the
authoritative route list, and the controllers are authoritative for what a
given action returns. Use the spec for *schemas*, the routes and
controllers for *endpoints*.
- `src/error.rs` — `Error` enum (`thiserror`) covering transport, JSON - `src/error.rs` — `Error` enum (`thiserror`) covering transport, JSON
decode, API error responses (with per-field validation `errors` map), and decode, API error responses (with per-field validation `errors` map), and
client construction errors. `ApiErrorBody` mirrors Invoice Ninja's error client construction errors. `ApiErrorBody` mirrors Invoice Ninja's error
@@ -67,6 +223,42 @@ Tests live in `tests/` (integration-style, no network access required);
## Coverage ## Coverage
Clients, Invoices, Quotes, Credits, Payments, Products, Expenses, Projects, **All 34 of the API's `Route::resource` groups are wrapped**, plus `users`
Tasks, and Vendors, each with `list`, `list_all`, `get`, `create`, `update`, (which isn't one). 265 methods: 30 resources with CRUD + bulk, 3 with CRUD
and `delete`. only, 4 with entity actions, and 25 hand-written.
Plus the non-CRUD surface: documents, entity actions and PDF downloads for
the four action-capable resources, `report`, `chart`, `search`, `statics`,
`send_email`, `refund_payment`, `ping`, and `health_check`.
**`COVERAGE.md` is the detailed inventory** — every wrapped endpoint, every
gap, and the reason for each gap. The main remaining gaps are multipart
uploads (needs a reqwest feature), a handful of per-resource extras
(`clients/{id}/purge`, `company_gateways/{id}/test`, …), and the
integration/auth routes.
Keep `COVERAGE.md` current when adding or removing an endpoint: it names
exact counts and route groups, so it goes stale quietly. The three coverage
summaries — here, `README.md`, and `COVERAGE.md` — should agree;
`COVERAGE.md` is the one with the specifics, the other two summarize and
link to it.
## Relationship to the official PHP SDK
[invoiceninja/sdk-php](https://github.com/invoiceninja/sdk-php) is the
first-party wrapper and a useful cross-check, but it is thinner than this
crate — each endpoint class is just a URI over a shared `BaseEntity`. See the
comparison table in `COVERAGE.md` for what each side covers.
Two divergences are deliberate and shouldn't be "fixed" to match it:
- **We send JSON; it sends `form_params`.** Form encoding bracket-encodes
nested structures, which is bad for payloads like an invoice's `line_items`.
The API accepts both.
- **We keep the whole `errors` map on `Error::Api`;** it surfaces only the
first validation message.
And two of its behaviours are bugs not to copy: it sends `stream: false` in
download payloads (no controller reads a `stream` input), and it exposes
`download` on every resource even though only PDF-rendering entities support
that action.
+312
View File
@@ -0,0 +1,312 @@
# API coverage
What this crate wraps of the Invoice Ninja v5 API, and what it doesn't.
Full coverage is a stated goal, so the gaps below are a to-do list rather
than a permanent boundary — [`GOALS.md`](GOALS.md) puts them in rough
priority order.
Checked against `routes/api.php` on the `v5-stable` branch of the
[invoiceninja repo](https://github.com/invoiceninja/invoiceninja), which
declares 34 `Route::resource` groups and 274 explicit route declarations
(272 single-verb, plus two `Route::match` pairs registering two verbs each).
> Note on sources: Invoice Ninja's published `openapi/api-docs.yaml` is
> reliable for **schemas** but incomplete for **routes** — it documents
> `/{id}` and action paths only for clients, locations, and tags. Use
> `routes/api.php` and the controllers when checking whether an endpoint
> exists or what it returns.
**All 34 `Route::resource` groups are now wrapped**, plus `users` (which is
not a resource group). 265 methods in total.
## Resources with full CRUD + bulk
30 resources, each with `list_*`, `list_all_*`, `get_*`, `create_*`,
`update_*`, `delete_*`, and `bulk_*`:
| Resource | Path | Model |
| --- | --- | --- |
| Clients | `/clients` | `Client` (+ `ClientContact`) |
| Invoices | `/invoices` | `Invoice` (+ `InvoiceItem`) |
| Quotes | `/quotes` | `Quote` (alias of `Invoice`) |
| Credits | `/credits` | `Credit` (alias of `Invoice`) |
| Payments | `/payments` | `Payment` (+ `PaymentInvoice`) |
| Products | `/products` | `Product` |
| Purchase Orders | `/purchase_orders` | `PurchaseOrder` |
| Recurring Invoices | `/recurring_invoices` | `RecurringInvoice` |
| Recurring Quotes | `/recurring_quotes` | `RecurringQuote` (alias of `RecurringInvoice`) |
| Recurring Expenses | `/recurring_expenses` | `RecurringExpense` |
| Subscriptions | `/subscriptions` | `Subscription` |
| Projects | `/projects` | `Project` |
| Tasks | `/tasks` | `Task` |
| Task Statuses | `/task_statuses` | `TaskStatus` |
| Expenses | `/expenses` | `Expense` |
| Expense Categories | `/expense_categories` | `ExpenseCategory` |
| Vendors | `/vendors` | `Vendor` (+ `VendorContact`) |
| Bank Transactions | `/bank_transactions` | `BankTransaction` |
| Tax Rates | `/tax_rates` | `TaxRate` |
| Payment Terms | `/payment_terms` | `PaymentTerm` |
| Webhooks | `/webhooks` | `Webhook` |
| Bank Integrations | `/bank_integrations` | `BankIntegration` |
| Bank Transaction Rules | `/bank_transaction_rules` | `BankTransactionRule` |
| Company Gateways | `/company_gateways` | `CompanyGateway` |
| Designs | `/designs` | `Design` |
| Group Settings | `/group_settings` | `GroupSetting` |
| Locations | `/locations` | `Location` |
| Tags | `/tags` | `Tag` |
| Task Schedulers | `/task_schedulers` | `TaskScheduler` |
| Tokens | `/tokens` | `CompanyToken` |
`bulk_*` accepts any action the endpoint supports; `archive`, `restore`, and
`delete` work on all of them. An `ids` list matching nothing returns an empty
`Vec`, not an error.
## Resources without a bulk route
Three `Route::resource` groups have no `/bulk` route, so they get the six
CRUD methods but no `bulk_*` (which would just 405). These use
`crud_methods!` rather than `resource_methods!`:
| Resource | Path | Model |
| --- | --- | --- |
| Client Gateway Tokens | `/client_gateway_tokens` | `ClientGatewayToken` |
| Companies | `/companies` | `Company` |
| System Logs | `/system_logs` | `SystemLog` |
`Company` and `GroupSetting` keep their `settings` blob as
`serde_json::Value`: `CompanySettings` is the largest object in the API, and
`Company` itself returns over a hundred fields, so only the structural ones
are modeled and `extra` carries the feature toggles.
## Users
`/users` is not a `Route::resource` and has no `DELETE /users/{id}`, so it is
hand-written:
| Endpoint | Method | Needs password? |
| --- | --- | --- |
| `GET /users` | `list_users`, `list_all_users` | no |
| `GET /users/{id}` | `get_user` | **yes** |
| `POST /users` | `create_user` | **yes** |
| `PUT /users/{id}` | `update_user` | **yes** |
| `DELETE /users/{id}/detach_from_company` | `detach_user_from_company` | **yes** |
| `POST /users/bulk` | `bulk_users` | **yes** |
"Needs password" means the route sits behind the API's `password_protected`
middleware and requires an `X-API-PASSWORD` header on top of a valid token.
Supply it with `InvoiceNinjaClient::with_api_password`, which also unlocks
client and vendor merge, and client/company/user purge. Note that even
*reading* a single user is gated this way.
## Entity actions
`GET /{resource}/{id}/{action}` exists for **only four** resources — invoices,
quotes, credits, and purchase orders. Each gets `{singular}_action(id, action)`
and `download_{singular}(id)`.
Actions returning the entity: `auto_bill`, `clone_to_invoice`,
`clone_to_quote`, `mark_paid`, `mark_sent`, `archive`, `restore`, `delete`.
Actions that do **not** return the entity, and so are not usable through
`*_action`:
| Action | Returns | Use instead |
| --- | --- | --- |
| `download` | `application/pdf` stream | `download_invoice(id)` etc. |
| `convert_to_purchase_order` | a purchase order | `convert_invoice_to_purchase_order(id)` |
| `cancel`, `history`, `delivery_note` | empty body (upstream quirk) | — not wrapped |
Each of the four also gets `download_{plural}(&ids)` — the bulk `download`
action, which streams one merged PDF. It can't go through `bulk_*` for the
same reason: the response isn't JSON.
Convenience wrappers: `mark_invoice_sent` and `mark_invoice_paid`.
## Other wrapped endpoints
| Endpoint | Method |
| --- | --- |
| `GET /documents`, `/documents/{id}` | `list_documents`, `list_all_documents`, `get_document` |
| `PUT/DELETE /documents/{id}` | `update_document`, `delete_document` |
| `GET /documents/{id}/download` | `download_document` → bytes |
| `POST /documents/bulk` | `bulk_documents` |
| `POST /reports/{name}` | `report(name, body)` → raw bytes (CSV for most) |
| `POST /charts/{name}` | `chart(name, body)` → caller-chosen `T` |
| `POST /search` | `search(body)` |
| `GET /statics` | `statics()` |
| `POST /emails` | `send_email(body)` |
| `POST /payments/refund` | `refund_payment(body)` |
| `GET /ping` | `ping()` |
| `GET /health_check` | `health_check()` |
`report` and `chart` take the name as a string, so all 32 report and 11 chart
endpoints are reachable without a per-endpoint method:
- **Reports** — `activities`, `ar_detail_report`, `ar_summary_report`,
`client_balance_report`, `client_contacts`, `client_locations`, `clients`,
`client_sales_report`, `contacts`, `credits`, `documents`, `expenses`,
`invoice_items`, `invoices`, `locations`, `payments`, `preview`, `products`,
`product_sales`, `profitloss`, `projects`, `purchase_order_items`,
`purchase_orders`, `quote_items`, `quotes`, `recurring_invoice_items`,
`recurring_invoices`, `tasks`, `tax_period_report`, `tax_summary_report`,
`user_sales_report`, `vendors`
- **Charts** — `totals`, `totals_v2`, `chart_summary`, `chart_summary_v2`,
`calculated_fields`, `analytics_summary`, `analytics_totals`,
`cashflow_forecast`, `client_payment_analytics`, `project_analytics`,
`project_burnup`
Three of these take a further path segment rather than carrying the id in the
body: `charts/project_analytics/{project}`, `charts/project_burnup/{project}`,
and `reports/preview/{hash}`. The name argument is interpolated as-is, so pass
the whole tail — `chart("project_burnup/<id>", &body)`.
Large report exports are queued and emailed rather than returned inline; in
that case the body is a short JSON message instead of CSV.
## Not covered
### Blocked on a dependency change
**Multipart uploads** — `PUT /{resource}/{id}/upload` exists for clients,
companies, credits, expenses, group_settings, invoices, payments, products,
projects, purchase_orders, quotes, recurring_expenses, recurring_invoices,
recurring_quotes, tasks, and vendors. These need reqwest's `multipart`
feature, which isn't currently enabled. This is the main reason `Document`
has no `create_document`.
### Resource groups not wrapped
None — all 34 `Route::resource` groups are covered.
Adding a new one is mechanical: add a model under `src/models/`, re-export it
from `src/models/mod.rs`, and add a `resource_methods!` invocation in
`src/client.rs` (or `crud_methods!` if the resource has no `/bulk` route —
check `routes/api.php`).
### Password-protected endpoints still unwrapped
`with_api_password` makes these reachable, but the methods themselves aren't
written yet:
- `POST /clients/{id}/purge`
- `POST /clients/{id}/{other}/merge`
- `POST /vendors/{id}/{other}/merge`
- `POST /companies/purge/{company}`,
`POST /companies/purge_save_settings/{company}`
- `POST /users/{id}/invite`, `POST /users/{id}/purge`, `GET /users/create`
### Individual endpoints not wrapped
- **Client extras** — `updateTaxData`, `show_settings`, `peppol_discovery`,
`check_vat`, `vat_status`, `documents`
- **Bank integration extras** — `refresh_accounts`, `remove_account/{id}`,
`get_transactions/{id}`, and `POST /bank_transactions/match`
- **Company gateway extras** — `test`, `clone`, `import_customers`
- **Company extras** — `GET /companies/{id}/logo`,
`POST /companies/{id}/default`, `updateOriginTaxData`,
`POST /companies/current`
- **Design extras** — `POST /designs/set/default`
- **Client gateway token extras** — `setAsDefault`
- **Task extras** — `POST /tasks/sort`
- **Webhook extras** — `POST /webhooks/{id}/retry`
- **Subscription extras** — `GET /subscriptions/steps`,
`POST /subscriptions/steps/check`
- **User extras** (not password-protected, unlike the rest of `/users`) —
`POST /users/{id}/disconnect_mailer`, `POST /users/{id}/disconnect_oauth`,
`POST /user/{id}/reconfirm`
- **Preview** — `POST /preview`, `POST /preview/purchase_order`, live preview
- **Activities** — `GET /activities`, `POST /activities/entity`,
`POST /activities/notes`, `download_entity/{activity}`
- **Email history** — `POST /emails/clientHistory/{client}`,
`POST /emails/entityHistory`
- **Auth** — `/login`, `/logout`, `/refresh`, `/connected_account`,
passkey routes
- **Import/export** — `/import`, `/preimport`, `/import_json`, `/export`,
`/migration/start`
- **Invoice extras** — `delivery_note`, `payment_schedule`,
`update_reminders`
- **Public invitation-key downloads** — `GET /{entity}/{invitation_key}/download`
for `invoice`, `quote`, `credit`, and `purchase_order` (note the singular
path segment), plus the matching e-invoice variants `download_e_invoice`,
`download_e_quote`, `download_e_credit`,
`download_e_purchase_order`. These are invitation-key based and take no
auth token, so they don't fit the client's authenticated request path.
- **Integrations** — Stripe, QuickBooks, Apple in-app purchase, PostMark,
einvoice (17 routes), calendar_connection, self-update, templates, hooks,
Yodlee (`/yodlee/*`), Nordigen (`/nordigen/institutions`)
- **Misc** — `/company_users`, `/company_ledger`, `/client_statement`,
`/claim_license`, `/check_license`, `/check_subdomain`, `/one_time_token`,
`/last_error`, `/support/messages/send`, `/verify/*`, `/accounts/{id}`,
`/feedback`, `/filters/{entity}`, `/scheduler`, `/mailer/check`,
`/smtp/check`, `/password_timeout`, `/refresh_react`, `/webcron`,
`/token_hash_router`, `/reactivate_email/{bounce_id}`,
`/protected_download/{hash}`, `/confirm_purchase`,
`/exports/preview/{hash}`, `/templates/preview/{hash}`
### Typed response gaps
`statics`, `search`, and `health_check` return concrete types (`Statics`,
`SearchResults`, `HealthCheck`). Each keeps the crate's `extra` convention,
so fields they don't model stay reachable rather than being dropped, and
each has a generic counterpart — `statics_as`, `search_as`,
`health_check_as` — for pinning a shape they leave untyped.
Two remain generic on purpose:
- **`chart`** — the eleven chart endpoints share no common response shape
(`totals_v2` returns per-currency maps, `cashflow_forecast` a date series,
`calculated_fields` scalars), so there is nothing to narrow to.
- **`send_email`** — the endpoint serves invoices, quotes, credits, and
purchase orders, and answers with whichever entity was emailed. The `data`
envelope *is* unwrapped, so `T` is the entity itself.
Within the typed models, a few fields are deliberately left as
`serde_json::Value`: `Statics::gateways`, `Statics::templates`,
`Statics::bulk_updates`, `HealthCheck::extensions`, and
`HealthCheck::queue_data`. These are nested configuration blobs rather than
uniform lookup rows, and the same reasoning applies as for
`Company::settings` — a wrong concrete type is a hard decode error, whereas
`Value` is lossless.
## Compared with the official PHP SDK
[invoiceninja/sdk-php](https://github.com/invoiceninja/sdk-php) is the
first-party wrapper. It's worth checking against, but it is deliberately
thinner than this crate: every one of its endpoint classes is just a URI on
top of a shared `BaseEntity` offering `all`, `get`, `create`, `update`,
`bulk`, `archive`, `delete`, `restore`, and `download`. Only `Companies` and
`Statics` add anything of their own.
**It covers nothing we don't.** The five resources it used to have on us —
`users`, `companies`, `group_settings`, `bank_integrations`,
`client_gateway_tokens` — are all wrapped now.
**We cover a lot it doesn't:** `payment_terms`, `task_statuses`,
`recurring_quotes`, `recurring_expenses`, `bank_transaction_rules`,
`company_gateways`, `designs`, `locations`, `tags`, `task_schedulers`,
`tokens`, `system_logs`, documents, per-entity actions (`mark_sent`,
`mark_paid`, `clone_to_*`, …), per-entity PDF download, reports, charts,
search, `send_email`, `refund_payment`, `ping`, `health_check`, automatic
pagination (`list_all_*`), and `X-API-PASSWORD` support for the
password-protected routes.
Deliberate differences in behaviour:
| | PHP SDK | This crate |
| --- | --- | --- |
| Request bodies | `form_params` (urlencoded) | JSON |
| Base URL | host only; appends `/api/v1` itself | full API root, or `hosted()` |
| Single-entity delete | none — `delete()` is bulk | `delete_*(id)` via HTTP `DELETE`, plus `bulk_*` |
| Validation errors | surfaces only the first message | whole per-field `errors` map on `Error::Api` |
| List filters | arbitrary array | typed setters plus `ListParams::param` |
The urlencoded-vs-JSON difference is the one that matters: form encoding
bracket-encodes nested structures, so a payload like an invoice's
`line_items` array of objects is far cleaner as JSON. Both are accepted by
the API.
Two things in the PHP SDK are *not* worth copying: it sends
`stream: false` in download payloads, but no controller reads a `stream`
input, and its `BaseEntity::download` is available on every resource even
though only PDF-rendering entities support the action.
Generated
+239
View File
@@ -2,6 +2,25 @@
# It is not intended for manual editing. # It is not intended for manual editing.
version = 4 version = 4
[[package]]
name = "aho-corasick"
version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba"
dependencies = [
"memchr",
]
[[package]]
name = "assert-json-diff"
version = "2.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12"
dependencies = [
"serde",
"serde_json",
]
[[package]] [[package]]
name = "atomic-waker" name = "atomic-waker"
version = "1.1.2" version = "1.1.2"
@@ -70,6 +89,24 @@ version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
[[package]]
name = "deadpool"
version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0be2b1d1d6ec8d846f05e137292d0b89133caf95ef33695424c09568bdd39b1b"
dependencies = [
"deadpool-runtime",
"lazy_static",
"num_cpus",
"tokio",
]
[[package]]
name = "deadpool-runtime"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b"
[[package]] [[package]]
name = "displaydoc" name = "displaydoc"
version = "0.2.7" version = "0.2.7"
@@ -81,6 +118,12 @@ dependencies = [
"syn 3.0.9", "syn 3.0.9",
] ]
[[package]]
name = "equivalent"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826"
[[package]] [[package]]
name = "errno" name = "errno"
version = "0.3.14" version = "0.3.14"
@@ -103,6 +146,12 @@ version = "0.1.15"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48" checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48"
[[package]]
name = "fnv"
version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
[[package]] [[package]]
name = "foreign-types" name = "foreign-types"
version = "0.3.2" version = "0.3.2"
@@ -127,6 +176,21 @@ dependencies = [
"percent-encoding", "percent-encoding",
] ]
[[package]]
name = "futures"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3"
dependencies = [
"futures-channel",
"futures-core",
"futures-executor",
"futures-io",
"futures-sink",
"futures-task",
"futures-util",
]
[[package]] [[package]]
name = "futures-channel" name = "futures-channel"
version = "0.3.34" version = "0.3.34"
@@ -134,6 +198,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4"
dependencies = [ dependencies = [
"futures-core", "futures-core",
"futures-sink",
] ]
[[package]] [[package]]
@@ -142,6 +207,40 @@ version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
[[package]]
name = "futures-executor"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432"
dependencies = [
"futures-core",
"futures-task",
"futures-util",
]
[[package]]
name = "futures-io"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed"
[[package]]
name = "futures-macro"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.9",
]
[[package]]
name = "futures-sink"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d"
[[package]] [[package]]
name = "futures-task" name = "futures-task"
version = "0.3.34" version = "0.3.34"
@@ -154,8 +253,13 @@ version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
dependencies = [ dependencies = [
"futures-channel",
"futures-core", "futures-core",
"futures-io",
"futures-macro",
"futures-sink",
"futures-task", "futures-task",
"memchr",
"pin-project-lite", "pin-project-lite",
"slab", "slab",
] ]
@@ -171,6 +275,37 @@ dependencies = [
"r-efi", "r-efi",
] ]
[[package]]
name = "h2"
version = "0.4.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d29020232d6aa3fb1daca64c1127cf662cf97f254ae16c18c05b8ab635fc118"
dependencies = [
"atomic-waker",
"bytes",
"fnv",
"futures-core",
"futures-sink",
"http",
"indexmap",
"slab",
"tokio",
"tokio-util",
"tracing",
]
[[package]]
name = "hashbrown"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
[[package]]
name = "hermit-abi"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284"
[[package]] [[package]]
name = "http" name = "http"
version = "1.5.0" version = "1.5.0"
@@ -210,6 +345,12 @@ version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
[[package]]
name = "httpdate"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
[[package]] [[package]]
name = "hyper" name = "hyper"
version = "1.12.0" version = "1.12.0"
@@ -218,10 +359,13 @@ checksum = "2c3e324da4c95177d6291d4c8730197c0d1822f8a9766814a4a44fa5ab797c9c"
dependencies = [ dependencies = [
"atomic-waker", "atomic-waker",
"bytes", "bytes",
"futures-channel",
"futures-core", "futures-core",
"h2",
"http", "http",
"http-body", "http-body",
"httparse", "httparse",
"httpdate",
"itoa", "itoa",
"pin-project-lite", "pin-project-lite",
"smallvec", "smallvec",
@@ -373,15 +517,28 @@ dependencies = [
"icu_properties", "icu_properties",
] ]
[[package]]
name = "indexmap"
version = "2.14.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
dependencies = [
"equivalent",
"hashbrown",
]
[[package]] [[package]]
name = "invoiceninjars" name = "invoiceninjars"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"percent-encoding",
"reqwest", "reqwest",
"serde", "serde",
"serde_json", "serde_json",
"serde_urlencoded",
"thiserror", "thiserror",
"tokio", "tokio",
"wiremock",
] ]
[[package]] [[package]]
@@ -407,6 +564,12 @@ dependencies = [
"wasm-bindgen", "wasm-bindgen",
] ]
[[package]]
name = "lazy_static"
version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb"
[[package]] [[package]]
name = "libc" name = "libc"
version = "0.2.191" version = "0.2.191"
@@ -465,6 +628,16 @@ dependencies = [
"tempfile", "tempfile",
] ]
[[package]]
name = "num_cpus"
version = "1.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b"
dependencies = [
"hermit-abi",
"libc",
]
[[package]] [[package]]
name = "once_cell" name = "once_cell"
version = "1.21.4" version = "1.21.4"
@@ -565,6 +738,35 @@ version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "regex"
version = "1.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d"
dependencies = [
"aho-corasick",
"memchr",
"regex-automata",
"regex-syntax",
]
[[package]]
name = "regex-automata"
version = "0.4.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2"
dependencies = [
"aho-corasick",
"memchr",
"regex-syntax",
]
[[package]]
name = "regex-syntax"
version = "0.8.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
[[package]] [[package]]
name = "reqwest" name = "reqwest"
version = "0.12.28" version = "0.12.28"
@@ -877,6 +1079,20 @@ dependencies = [
"tokio", "tokio",
] ]
[[package]]
name = "tokio-util"
version = "0.7.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e464cf451ba96ebfc6f9b6542f17ee8b8956e33f1e40d9690624e59d7a7f8a4b"
dependencies = [
"bytes",
"futures-core",
"futures-sink",
"libc",
"pin-project-lite",
"tokio",
]
[[package]] [[package]]
name = "tower" name = "tower"
version = "0.5.3" version = "0.5.3"
@@ -1073,6 +1289,29 @@ dependencies = [
"windows-link", "windows-link",
] ]
[[package]]
name = "wiremock"
version = "0.6.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08db1edfb05d9b3c1542e521aea074442088292f00b5f28e435c714a98f85031"
dependencies = [
"assert-json-diff",
"base64 0.22.1",
"deadpool",
"futures",
"http",
"http-body-util",
"hyper",
"hyper-util",
"log",
"once_cell",
"regex",
"serde",
"serde_json",
"tokio",
"url",
]
[[package]] [[package]]
name = "writeable" name = "writeable"
version = "0.6.4" version = "0.6.4"
+16
View File
@@ -2,6 +2,11 @@
name = "invoiceninjars" name = "invoiceninjars"
version = "0.1.0" version = "0.1.0"
edition = "2021" edition = "2021"
# Set by the dependency graph rather than by this crate's own code: reqwest
# pulls in url -> idna -> idna_adapter -> icu_*, and the icu crates require
# 1.88. Verified by running the test suite against it; see the `msrv`
# devShell in flake.nix.
rust-version = "1.88"
description = "An async Rust client for the Invoice Ninja v5 API" description = "An async Rust client for the Invoice Ninja v5 API"
license = "MIT" license = "MIT"
repository = "https://github.com/bryant/invoiceninjars" repository = "https://github.com/bryant/invoiceninjars"
@@ -11,6 +16,17 @@ reqwest = { version = "0.12", default-features = false, features = ["json", "nat
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
thiserror = "2" thiserror = "2"
# Already in the tree via reqwest's `url`; used to escape ids before they are
# interpolated into request paths.
percent-encoding = "2"
# Already in the tree as reqwest's runtime; only `time` is needed, for the
# sleep between retries.
tokio = { version = "1", features = ["time"] }
[dev-dependencies] [dev-dependencies]
tokio = { version = "1", features = ["rt-multi-thread", "macros"] } tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
# Used to assert that ListParams urlencodes the way reqwest will send it.
serde_urlencoded = "0.7"
# Loopback HTTP server for exercising the request plumbing (pagination, path
# escaping, headers, error mapping) without reaching the network.
wiremock = "0.6"
+197
View File
@@ -0,0 +1,197 @@
# Goals
What this crate is trying to be, what it is deliberately not, and what is
still open. The point of writing this down is that several decisions here
look like oversights until you know the reasoning — this is where the
reasoning lives.
## What this is for
A complete, honest async Rust client for the Invoice Ninja v5 API.
*Complete* in that every endpoint the API exposes should eventually be
reachable through it. *Honest* in that it should not quietly lose data,
quietly truncate results, or quietly succeed when it did not.
## Goals
### 1. Never lose data in a round trip
A `get` followed by an `update` must not drop fields the crate does not know
about. Every model carries `#[serde(flatten)] extra`, and the modelled
fields are a curated subset rather than an attempt at the whole schema.
This is why `custom_value1..4`, `custom_surcharge1..4`, and fields with
ambiguous upstream types are intentionally unmodelled — `extra` carries them
losslessly, whereas a wrong concrete type is a hard decode error that breaks
the entire response.
### 2. Tolerate upstream inconsistency, fail on real problems
Invoice Ninja returns `null` for many nominally-string fields, and the same
field name can differ in type across resources (`frequency_id` is a number on
recurring invoices and a string on subscriptions; `/statics` returns
`payment_types.id` as a string and every other table's as a number).
Decoding should absorb that — hence `null_to_default` and `id_as_string` —
without becoming so permissive that genuine errors pass silently.
### 3. Prefer a loud failure to a quiet wrong answer
The bug worth remembering: `list_all_*` used to stop when a page came back
shorter than requested, which the server's `per_page` cap made wrong, so it
returned a partial list indistinguishable from a complete one. Silent
truncation is the worst failure mode this crate can have.
Concretely: pagination terminates on the paginator's own `total_pages`, a
runaway walk raises `Error::PaginationLimit` rather than returning what it
has, and an id that cannot safely be a path segment is rejected instead of
being mangled into a request against a different endpoint.
### 4. Be safe by default where the API handles money
`POST` is never replayed on a 5xx or a timeout, because the server may have
applied it before the response was lost and a retry would create a second
invoice, payment, or email. A 429 is retried for any method, because the
server states it refused the request without processing it.
When a safety default and a convenience conflict here, safety wins, and the
reasoning goes in a comment so it is not later optimised away.
### 5. Make failures diagnosable
An error should say enough to act on. Decode failures carry an excerpt of
the body that actually arrived, because "expected value at line 1 column 1"
does not reveal that a proxy returned an HTML error page. API errors keep
the whole per-field `errors` map, not just the first message.
### 6. Verify against the source, not against assumptions
Field names and types come from Invoice Ninja's `openapi/api-docs.yaml`; the
set of endpoints comes from `routes/api.php` and the controllers, because
the published spec is incomplete for routes. Claims in `COVERAGE.md` are
checked against the upstream repository rather than inferred.
### 7. Stay testable without credentials
The whole suite runs offline: model decoding against fixtures, request
plumbing against a `wiremock` server on loopback. Nobody should need an
Invoice Ninja account to contribute.
## Non-goals
- **Not a port of the official PHP SDK.** It is a useful cross-check, but
thinner, and two of its behaviours are bugs not to copy. See the
comparison in `COVERAGE.md`. The divergences — JSON instead of
`form_params`, keeping the whole validation `errors` map, exposing bulk
`download` only on PDF-rendering entities — are deliberate.
- **No sync/blocking API.** Async only.
- **No identity map, lazy relationship loading, or transparent entity
caching.** Three separate refusals with one theme — the client stays
stateless and the models stay plain data:
- *Lazy loading.* An `Invoice` that could fetch its own client would have
to hold a client handle, so it would stop being a `Deserialize` struct.
That breaks `Default`-constructibility, which every `create_*` relies
on, and it breaks the `extra` round-trip model.
- *Dirty tracking.* The same problem from the other side: you cannot
meaningfully diff a flattened `Map` of fields the crate does not model,
and carrying those fields is the whole point of `extra` (goal 1).
- *Entity caching.* The shared response path in `BaseController` sends no
`ETag`, `If-None-Match`, or `Cache-Control`, so a cached entity cannot
be cheaply revalidated — every hit is a gamble. Serving a stale invoice
balance is a correctness hazard, not a missed optimisation.
An identity map would also mean shared mutable state; `InvoiceNinjaClient`
is currently cheap to `Clone` across tasks, and a lock would change that.
Server-side eager loading via `include` already covers the common case in
one round trip.
Note this does **not** rule out typed `include` helpers or an opt-in cache
for the static lookup tables — see the open questions.
- **No modelling of every field.** See goal 1; breadth of endpoints is a
goal, breadth of typed fields is not.
- **No retrying of non-idempotent requests.** See goal 4.
- **No API stability before 1.0.** See below.
## Publication and compatibility
Published on crates.io so it is installable, with **no stability promise
before 1.0**. Pin an exact version; expect breaking changes in minor
releases. `CHANGELOG.md` records them, and breaking changes should be
legible there rather than discovered at compile time.
`Error` is `#[non_exhaustive]` so that adding variants is not itself a
breaking change.
**MSRV is 1.88**, set by the dependency graph rather than by this crate's
code. It may rise when dependencies require it; that is a changelog entry,
not a blocker. It should never rise because a convenience API was used
without checking — see the note in `CLAUDE.md`.
## Roadmap: closing the coverage gaps
All 34 `Route::resource` groups are wrapped. Full coverage is the target, so
`COVERAGE.md`'s gap list is a to-do list, not a permanent boundary. Roughly
in order of value over effort:
1. **Multipart uploads** — 16 `PUT /{resource}/{id}/upload` routes plus
`edocument/upload`. Blocked only on enabling reqwest's `multipart`
feature. This is also what makes `create_document` possible, so it
unblocks the one obviously missing piece of an otherwise complete
resource.
2. **Per-resource extras** — mechanical, and they reuse existing patterns:
`webhooks/{id}/retry`, `tasks/sort`, `designs/set/default`,
`client_gateway_tokens/{id}/setAsDefault`, `subscriptions/steps`, the
client extras (`updateTaxData`, `show_settings`, `check_vat`, …), the
bank-integration and company-gateway extras.
3. **Password-protected actions** — `with_api_password` already makes these
reachable; only the methods are missing. Client purge and merge, vendor
merge, company purge, user invite and purge.
4. **Preview, activities, and email history** — distinct response shapes, so
each needs its own hand-written method and probably its own model.
5. **Public invitation-key downloads** — eight routes that take no auth
token, so they do not fit the authenticated request path and need a
different entry point.
6. **Import/export and the integrations** — Stripe, QuickBooks, einvoice
(17 routes), Yodlee, Nordigen, calendar. The largest block and the least
SDK-shaped; worth doing last and possibly in pieces.
Keep `COVERAGE.md` current as these land — it names exact counts, so it goes
stale quietly.
## Open questions
These are genuinely undecided. Deciding one is a change to this file.
- **Auth routes** (`/login`, `/logout`, `/refresh`, passkeys) are in the gap
list, but they are a different authentication model from the API token
this client is built around. Including them may mean a second client type
rather than more methods on this one. Not obviously in scope.
- **`chart` is still generic** over `T: DeserializeOwned`, because the
eleven chart endpoints share no response shape. Per-chart structs would
be eleven small models of unstable payloads; unclear whether that earns
its keep.
- **Large responses are buffered entirely into memory.** PDF downloads and
CSV report exports go through `Vec<u8>`. Streaming would be better for
big exports but changes those signatures.
- **`Error::PaginationLimit` has no test**, because reaching it needs
10,001 mock round trips. Making `MAX_PAGES` injectable purely for a test
may be worse than the gap.
- **Typed `include` helpers.** Eager loading is a stringly parameter today
(`.include("client,payments")`), so a typo is a silent no-op rather than a
compile error. An enum-backed builder per resource would fix that, and it
conflicts with nothing above — it is just not done. The cost is knowing
which relationships each resource actually accepts, which the spec does
not reliably document.
- **An opt-in cache for `/statics`.** The payload is large and near-constant
and the docs already say to cache it, but the crate offers no hook. A
small trait the caller implements would stay clear of the entity-caching
hazard above, since the lookup tables are not money. Whether that beats
"cache it yourself" is genuinely unclear.
Related, and deliberately parked: the API accepts an `updated_at` query
parameter meaning "everything changed since T", which is the shape a local
mirror would need. That is a sync engine rather than a cache, and a
different product from this crate — recorded so the option is not
forgotten rather than because it is planned.
- **When to go 1.0**, and what the API needs to settle first.
+127 -4
View File
@@ -2,6 +2,12 @@
An async Rust client for the [Invoice Ninja](https://www.invoiceninja.com/) v5 API. An async Rust client for the [Invoice Ninja](https://www.invoiceninja.com/) v5 API.
Requires Rust 1.88 or newer.
> **Pre-1.0: no API stability promise.** Pin an exact version and expect
> breaking changes in minor releases; `CHANGELOG.md` records them.
> [`GOALS.md`](GOALS.md) covers what the crate is for and what it is not.
## Development ## Development
Enter the dev shell (Rust toolchain via `rust-overlay`): Enter the dev shell (Rust toolchain via `rust-overlay`):
@@ -10,19 +16,46 @@ Enter the dev shell (Rust toolchain via `rust-overlay`):
nix develop nix develop
``` ```
The dev toolchain is pinned in `flake.nix` rather than tracking latest
stable, so it only moves deliberately. To check the crate still builds on
the declared minimum of 1.88:
```sh
nix develop .#msrv --command cargo test
```
## Usage ## Usage
```rust ```rust
use invoiceninjars::{InvoiceNinjaClient, models::ListParams}; use invoiceninjars::{InvoiceNinjaClient, models::ListParams};
// Hosted app:
let client = InvoiceNinjaClient::hosted(std::env::var("IN_API_TOKEN")?)?;
// Self-hosted — note the base URL must include the `/api/v1` suffix:
let client = InvoiceNinjaClient::new( let client = InvoiceNinjaClient::new(
"https://invoicing.co/api/v1", // or your self-hosted base URL "https://your-domain.example/api/v1",
std::env::var("IN_API_TOKEN")?, std::env::var("IN_API_TOKEN")?,
)?; )?;
let clients = client.list_clients(&ListParams::new().per_page(50)).await?; let clients = client.list_clients(&ListParams::new().per_page(50)).await?;
``` ```
`ListParams` has typed setters for the parameters every list endpoint
accepts, plus `param` for the per-resource filters:
```rust
let unpaid = client
.list_invoices(
&ListParams::new()
.per_page(100)
.sort("date|desc")
.param("client_id", "abc123")
.param("client_status", "unpaid"),
)
.await?;
```
List endpoints are paginated. `list_*` returns a single page; `list_all_*` List endpoints are paginated. `list_*` returns a single page; `list_all_*`
follows pagination and returns every matching record: follows pagination and returns every matching record:
@@ -30,6 +63,29 @@ follows pagination and returns every matching record:
let all_clients = client.list_all_clients(&ListParams::new()).await?; let all_clients = client.list_all_clients(&ListParams::new()).await?;
``` ```
Every request carries a 60-second deadline by default, since `reqwest`
applies none of its own, and transient failures are retried up to 3 times
with exponential backoff. Both are configurable:
```rust
use std::time::Duration;
use invoiceninjars::RetryPolicy;
let client = InvoiceNinjaClient::hosted(token)?
.with_timeout(Duration::from_secs(300))
.with_retry_policy(RetryPolicy::new(5));
```
Note that the timeout applies to each attempt, not to the call as a whole;
`RetryPolicy::max_elapsed` (120 seconds by default) is what bounds the total.
Retrying is deliberately conservative about `POST`. A `POST` that fails with
a 5xx or times out is **not** replayed, because the server may have applied
it before the response was lost and a repeat would create a second invoice
or payment. A 429 is retried for any method, since the server states it
refused the request without processing it. Pass `RetryPolicy::none()` to
disable retrying entirely.
Run the example (requires `IN_API_TOKEN`, optionally `IN_BASE_URL`): Run the example (requires `IN_API_TOKEN`, optionally `IN_BASE_URL`):
```sh ```sh
@@ -38,7 +94,74 @@ IN_API_TOKEN=... cargo run --example list_clients
## Coverage ## Coverage
Clients, Invoices, Quotes, Credits, Payments, Products, Expenses, Projects, Every one of the API's 34 resource groups, plus users — 265 methods. Each
Tasks, and Vendors, each with `list`, `list_all`, `get`, `create`, `update`, resource has `list`, `list_all`, `get`, `create`, `update`, `delete`, and
and `delete`. Add more resources by adding a model under `src/models/` and a (where the API offers one) `bulk`:
| Area | Resources |
| --- | --- |
| Billing | Clients, Invoices, Quotes, Credits, Payments, Products, Purchase Orders |
| Recurring | Recurring Invoices, Recurring Quotes, Recurring Expenses, Subscriptions |
| Work | Projects, Tasks, Task Statuses, Task Schedulers |
| Spend | Expenses, Expense Categories, Vendors, Bank Transactions |
| Banking | Bank Integrations, Bank Transaction Rules, Company Gateways, Client Gateway Tokens |
| Admin | Companies, Users, Group Settings, Tokens, System Logs |
| Settings | Tax Rates, Payment Terms, Webhooks, Designs, Locations, Tags |
Plus documents, entity actions and PDF downloads, reports, charts, search,
statics, email sending, refunds, and health checks.
**See [COVERAGE.md](COVERAGE.md) for the full endpoint-by-endpoint
inventory**, including everything that is deliberately *not* wrapped and why.
Models cover a curated subset of each schema; any field the API returns that
isn't modeled explicitly (including `custom_value1..4` and
`custom_surcharge1..4`) is preserved in that model's `extra` map, so a `get`
followed by an `update` round-trips without dropping data.
### Beyond CRUD
Bulk actions return the affected records. They're available on every
resource the API gives a `/bulk` route — all but companies, system logs, and
client gateway tokens:
```rust
let archived = client.bulk_invoices("archive", &["abc123", "def456"]).await?;
```
Some endpoints — reading or writing an individual user, `/users/bulk`,
purging a client or company — sit behind the API's `password_protected`
middleware and need the account password on top of a valid token:
```rust
let client = InvoiceNinjaClient::hosted(std::env::var("IN_API_TOKEN")?)?
.with_api_password(std::env::var("IN_API_PASSWORD")?)?;
let user = client.get_user("abc123").await?;
```
Invoices, quotes, credits, and purchase orders also support per-entity
actions and PDF rendering:
```rust
let invoice = client.mark_invoice_sent("abc123").await?;
let pdf: Vec<u8> = client.download_invoice("abc123").await?;
// The bulk `download` action streams one merged PDF. Available for quotes,
// credits, and purchase orders too.
let merged: Vec<u8> = client.download_invoices(&["abc123", "def456"]).await?;
```
Reports return their raw export body (CSV for most of them), and the
schemaless endpoints decode into any type you choose:
```rust
use serde_json::{json, Value};
let csv: Vec<u8> = client.report("invoices", &json!({"date_range": "this_year"})).await?;
let totals: Value = client.chart("totals_v2", &json!({})).await?;
let statics: Value = client.statics().await?;
```
Add more resources by adding a model under `src/models/` and a
`resource_methods!` invocation in `src/client.rs`. `resource_methods!` invocation in `src/client.rs`.
+27 -10
View File
@@ -16,19 +16,36 @@
overlays = [ rust-overlay.overlays.default ]; overlays = [ rust-overlay.overlays.default ];
}; };
rustToolchain = pkgs.rust-bin.stable.latest.default.override { # Pinned rather than `stable.latest`, so the toolchain only moves
extensions = [ "rust-src" "rust-analyzer" "clippy" "rustfmt" ]; # when this line does. `latest` drifts with the flake inputs, which
# means a lint can start failing on an unrelated change.
devVersion = "1.99.1";
# Must match `rust-version` in Cargo.toml. The floor comes from the
# dependency graph, not from this crate's own code: reqwest pulls in
# url -> idna -> idna_adapter -> icu_*, and the icu crates require
# 1.88. Check with:
# cargo metadata --format-version 1 \
# | jq -r '.packages[] | select(.rust_version) | .rust_version' \
# | sort -V | tail -1
msrvVersion = "1.88.0";
toolchainFor = version: extensions:
pkgs.rust-bin.stable.${version}.default.override { inherit extensions; };
rustToolchain = toolchainFor devVersion [ "rust-src" "rust-analyzer" "clippy" "rustfmt" ];
msrvToolchain = toolchainFor msrvVersion [ ];
shellFor = toolchain: pkgs.mkShell {
packages = [ toolchain pkgs.pkg-config pkgs.openssl ];
RUST_SRC_PATH = "${toolchain}/lib/rustlib/src/rust/library";
}; };
in in
{ {
devShells.default = pkgs.mkShell { devShells.default = shellFor rustToolchain;
packages = [
rustToolchain
pkgs.pkg-config
pkgs.openssl
];
RUST_SRC_PATH = "${rustToolchain}/lib/rustlib/src/rust/library"; # Verifies the declared MSRV still builds:
}; # nix develop .#msrv --command cargo test
devShells.msrv = shellFor msrvToolchain;
}); });
} }
+1048 -29
View File
File diff suppressed because it is too large Load Diff
+35 -2
View File
@@ -10,13 +10,26 @@ pub struct ApiErrorBody {
pub errors: HashMap<String, Vec<String>>, pub errors: HashMap<String, Vec<String>>,
} }
/// Marked `#[non_exhaustive]`: matching on it must include a catch-all arm,
/// so that new variants can be added without breaking callers.
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum Error { pub enum Error {
#[error("http transport error: {0}")] #[error("http transport error: {0}")]
Http(#[from] reqwest::Error), Http(#[from] reqwest::Error),
#[error("failed to decode JSON response: {0}")] /// The response was not the JSON this endpoint is documented to return.
Json(#[from] serde_json::Error), ///
/// Carries an excerpt of what actually arrived. Without it the inner
/// serde message ("expected value at line 1 column 1") gives no clue
/// that the body was, say, an HTML error page from a reverse proxy or a
/// login redirect from a misconfigured base URL.
#[error("failed to decode JSON response: {source}; body began: {excerpt}")]
Json {
#[source]
source: serde_json::Error,
excerpt: String,
},
#[error("api returned {status}: {message}")] #[error("api returned {status}: {message}")]
Api { Api {
@@ -30,6 +43,26 @@ pub enum Error {
#[error("invalid api token: {0}")] #[error("invalid api token: {0}")]
InvalidToken(String), InvalidToken(String),
/// An id or action could not be used as a single URL path segment.
///
/// Raised for values that would change which endpoint is addressed rather
/// than identifying a record — an empty id (which would hit the
/// collection route) or one made only of dots (which the URL parser
/// resolves away). Invoice Ninja ids are hashids, so this only rejects
/// input that was never a valid id.
#[error("`{0}` cannot be used as a url path segment")]
InvalidPathSegment(String),
/// A `list_all_*` call kept receiving full pages past the point where any
/// plausible result set would have ended.
///
/// This means the server is not paginating the way the client expects —
/// typically because it ignores the `page` parameter and keeps replaying
/// the same page. Raised instead of returning the partial result, so the
/// truncation cannot be mistaken for a complete answer.
#[error("pagination did not terminate for {path} after {pages} pages")]
PaginationLimit { path: String, pages: u64 },
} }
pub type Result<T> = std::result::Result<T, Error>; pub type Result<T> = std::result::Result<T, Error>;
+3 -1
View File
@@ -17,6 +17,8 @@
mod client; mod client;
mod error; mod error;
pub mod models; pub mod models;
mod retry;
pub use client::InvoiceNinjaClient; pub use client::{InvoiceNinjaClient, DEFAULT_TIMEOUT, HOSTED_BASE_URL};
pub use error::{ApiErrorBody, Error, Result}; pub use error::{ApiErrorBody, Error, Result};
pub use retry::RetryPolicy;
+39
View File
@@ -0,0 +1,39 @@
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
/// A connected bank account feed.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct BankIntegration {
#[serde(skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub user_id: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub provider_bank_name: String,
/// Returned as an integer rather than a hashed id.
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub bank_account_id: i64,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub bank_account_name: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub bank_account_number: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub bank_account_status: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub bank_account_type: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub balance: f64,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub currency: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub is_deleted: bool,
#[serde(default)]
pub archived_at: Option<i64>,
#[serde(default)]
pub created_at: Option<i64>,
#[serde(default)]
pub updated_at: Option<i64>,
#[serde(flatten)]
pub extra: Map<String, Value>,
}
+43
View File
@@ -0,0 +1,43 @@
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
/// A transaction imported from a connected bank account.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct BankTransaction {
#[serde(skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
/// The upstream transaction identifier, returned as an integer rather
/// than a hashed id.
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub transaction_id: i64,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub bank_account_id: i64,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub amount: f64,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub currency_id: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub account_type: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub description: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub category_id: i64,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub category_type: String,
/// Either `CREDIT` or `DEBIT`.
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub base_type: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub date: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub is_deleted: bool,
#[serde(default)]
pub archived_at: Option<i64>,
#[serde(default)]
pub created_at: Option<i64>,
#[serde(default)]
pub updated_at: Option<i64>,
#[serde(flatten)]
pub extra: Map<String, Value>,
}
+40
View File
@@ -0,0 +1,40 @@
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
/// A rule for auto-categorising imported bank transactions.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct BankTransactionRule {
#[serde(skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub user_id: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub name: String,
/// Match clauses. Left untyped: the shape is a nested
/// `search_key`/`operator`/`value` object the spec does not define.
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub rules: Vec<Value>,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub auto_convert: bool,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub matches_on_all: bool,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub applies_to: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub client_id: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub vendor_id: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub category_id: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub is_deleted: bool,
#[serde(default)]
pub archived_at: Option<i64>,
#[serde(default)]
pub created_at: Option<i64>,
#[serde(default)]
pub updated_at: Option<i64>,
#[serde(flatten)]
pub extra: Map<String, Value>,
}
+22 -22
View File
@@ -5,37 +5,37 @@ use serde_json::{Map, Value};
pub struct Client { pub struct Client {
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub id: Option<String>, pub id: Option<String>,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub name: String, pub name: String,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub id_number: String, pub id_number: String,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub vat_number: String, pub vat_number: String,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub address1: String, pub address1: String,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub address2: String, pub address2: String,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub city: String, pub city: String,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub state: String, pub state: String,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub postal_code: String, pub postal_code: String,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub country_id: String, pub country_id: String,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub phone: String, pub phone: String,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub website: String, pub website: String,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub private_notes: String, pub private_notes: String,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub balance: f64, pub balance: f64,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub paid_to_date: f64, pub paid_to_date: f64,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub credit_balance: f64, pub credit_balance: f64,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub is_deleted: bool, pub is_deleted: bool,
#[serde(default)] #[serde(default)]
pub archived_at: Option<i64>, pub archived_at: Option<i64>,
@@ -43,7 +43,7 @@ pub struct Client {
pub created_at: Option<i64>, pub created_at: Option<i64>,
#[serde(default)] #[serde(default)]
pub updated_at: Option<i64>, pub updated_at: Option<i64>,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub contacts: Vec<ClientContact>, pub contacts: Vec<ClientContact>,
/// Any fields returned by the API that aren't modeled above are preserved here, /// Any fields returned by the API that aren't modeled above are preserved here,
@@ -56,15 +56,15 @@ pub struct Client {
pub struct ClientContact { pub struct ClientContact {
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub id: Option<String>, pub id: Option<String>,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub first_name: String, pub first_name: String,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub last_name: String, pub last_name: String,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub email: String, pub email: String,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub phone: String, pub phone: String,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub is_primary: bool, pub is_primary: bool,
#[serde(flatten)] #[serde(flatten)]
+30
View File
@@ -0,0 +1,30 @@
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
/// A stored payment method for a client.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ClientGatewayToken {
#[serde(skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub client_id: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub token: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub routing_number: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub company_gateway_id: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub is_default: bool,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub is_deleted: bool,
#[serde(default)]
pub archived_at: Option<i64>,
#[serde(default)]
pub created_at: Option<i64>,
#[serde(default)]
pub updated_at: Option<i64>,
#[serde(flatten)]
pub extra: Map<String, Value>,
}
+94 -1
View File
@@ -1,7 +1,13 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Deserializer, Serialize};
use serde_json::Value;
#[derive(Debug, Clone, Deserialize)] #[derive(Debug, Clone, Deserialize)]
pub struct ListResponse<T> { pub struct ListResponse<T> {
/// Defaulted because not every list-shaped response carries a `data` key:
/// a bulk action whose `ids` match nothing returns HTTP 200 with just
/// `{"message": "No Invoices Found"}`, which should read as "no results"
/// rather than a decode failure.
#[serde(default = "Vec::new")]
pub data: Vec<T>, pub data: Vec<T>,
#[serde(default)] #[serde(default)]
pub meta: Option<Meta>, pub meta: Option<Meta>,
@@ -44,6 +50,19 @@ pub struct ListParams {
/// Set to 1 to include archived/deleted records. /// Set to 1 to include archived/deleted records.
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub status: Option<String>, pub status: Option<String>,
/// Extra query parameters appended verbatim.
///
/// The typed fields above are only the parameters common to every list
/// endpoint; individual resources accept many more (`client_id`,
/// `vendor_id`, `number`, `created_at`, `is_deleted`, per-resource
/// filters). Use [`param`](Self::param) to set those.
///
/// Skipped by serde and applied as a separate query pass, because
/// `serde_urlencoded` — which backs `reqwest`'s query serialization —
/// does not handle `#[serde(flatten)]`ed maps.
#[serde(skip)]
pub custom: Vec<(String, String)>,
} }
impl ListParams { impl ListParams {
@@ -80,4 +99,78 @@ impl ListParams {
self.status = Some(status.into()); self.status = Some(status.into());
self self
} }
/// Adds an arbitrary query parameter, for filters the typed setters do
/// not cover.
///
/// ```
/// use invoiceninjars::models::ListParams;
///
/// let params = ListParams::new()
/// .per_page(100)
/// .param("client_id", "abc123")
/// .param("is_deleted", "false");
/// ```
///
/// Can be called more than once with the same key; both pairs are sent.
pub fn param(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.custom.push((key.into(), value.into()));
self
}
}
/// Deserializes `null` as `T::default()` instead of failing.
///
/// `#[serde(default)]` alone only covers a *missing* key — a key that is
/// present with an explicit `null` still errors for non-`Option` types.
/// Invoice Ninja returns `null` for plenty of nominally-string fields, so the
/// models pair `default` with this to tolerate both shapes.
pub(crate) fn null_to_default<'de, D, T>(deserializer: D) -> Result<T, D::Error>
where
D: Deserializer<'de>,
T: Deserialize<'de> + Default,
{
Ok(Option::<T>::deserialize(deserializer)?.unwrap_or_default())
}
/// Deserializes an id that may arrive as either a JSON string or a number.
///
/// The `/statics` tables are inconsistent about this: `payment_types` casts
/// its id to a string server-side while the other lookup tables leave theirs
/// as integers. Rather than guess per table — where a wrong choice is a hard
/// decode error that breaks the whole response — both forms are accepted and
/// normalised to `String`.
pub(crate) fn id_as_string<'de, D>(deserializer: D) -> Result<String, D::Error>
where
D: Deserializer<'de>,
{
match Option::<Value>::deserialize(deserializer)? {
None | Some(Value::Null) => Ok(String::new()),
Some(Value::String(s)) => Ok(s),
Some(Value::Number(n)) => Ok(n.to_string()),
Some(other) => Err(serde::de::Error::custom(format!(
"expected a string or number id, got {other}"
))),
}
}
/// Body for a bulk action (`POST /{resource}/bulk`).
///
/// The action set varies by resource; `archive`, `restore`, and `delete` work
/// everywhere, and are what the generated `bulk_*` methods are typed for.
/// See [`crate::InvoiceNinjaClient::bulk_invoices`] for the actions whose
/// responses are *not* a list of entities.
#[derive(Debug, Clone, Serialize)]
pub struct BulkRequest {
pub action: String,
pub ids: Vec<String>,
}
impl BulkRequest {
pub fn new(action: impl Into<String>, ids: &[impl AsRef<str>]) -> Self {
Self {
action: action.into(),
ids: ids.iter().map(|id| id.as_ref().to_string()).collect(),
}
}
} }
+77
View File
@@ -0,0 +1,77 @@
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
/// A company (tenant).
///
/// The API returns well over a hundred fields here; this models the
/// identifying and structural ones and leaves the rest — including every
/// `enable_*`/`show_*` feature toggle — to [`extra`](Self::extra), which
/// still round-trips them on update.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Company {
#[serde(skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub company_key: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub size_id: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub industry_id: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub portal_mode: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub subdomain: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub portal_domain: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub logo: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub db: String,
/// The company settings blob. Left untyped — `CompanySettings` is the
/// largest object in the API.
#[serde(default)]
pub settings: Value,
/// Custom field label definitions.
#[serde(default)]
pub custom_fields: Value,
/// Bitmask of enabled modules.
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub enabled_modules: i64,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub enabled_tax_rates: i64,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub enabled_item_tax_rates: i64,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub enabled_expense_tax_rates: i64,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub first_day_of_week: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub first_month_of_year: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub slack_webhook_url: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub google_analytics_key: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub session_timeout: i64,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub markdown_enabled: bool,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub client_can_register: bool,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub is_large: bool,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub is_disabled: bool,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub calculate_taxes: bool,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub is_deleted: bool,
#[serde(default)]
pub archived_at: Option<i64>,
#[serde(default)]
pub created_at: Option<i64>,
#[serde(default)]
pub updated_at: Option<i64>,
#[serde(flatten)]
pub extra: Map<String, Value>,
}
+38
View File
@@ -0,0 +1,38 @@
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
/// A configured payment gateway.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct CompanyGateway {
#[serde(skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub gateway_key: String,
/// Bitmask of accepted card types.
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub accepted_credit_cards: i64,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub require_billing_address: bool,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub require_shipping_address: bool,
/// An encrypted JSON *string*, not an object — decrypted server-side, so
/// it is opaque here.
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub config: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub update_details: bool,
/// Per-card fee and limit rules; shape varies by gateway.
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub fees_and_limits: Vec<Value>,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub is_deleted: bool,
#[serde(default)]
pub archived_at: Option<i64>,
#[serde(default)]
pub created_at: Option<i64>,
#[serde(default)]
pub updated_at: Option<i64>,
#[serde(flatten)]
pub extra: Map<String, Value>,
}
+27
View File
@@ -0,0 +1,27 @@
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
/// An API token. Served from `/tokens`.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct CompanyToken {
#[serde(skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub name: String,
/// Only returned in full when the token is first created.
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub token: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub is_system: bool,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub is_deleted: bool,
#[serde(default)]
pub archived_at: Option<i64>,
#[serde(default)]
pub created_at: Option<i64>,
#[serde(default)]
pub updated_at: Option<i64>,
#[serde(flatten)]
pub extra: Map<String, Value>,
}
+32
View File
@@ -0,0 +1,32 @@
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
/// A PDF template.
///
/// The `design` field itself (the HTML section bodies) is left to
/// [`extra`](Self::extra): the spec types it as a string, but the API
/// returns a nested object of `header`/`body`/`footer` sections.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Design {
#[serde(skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub name: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub is_custom: bool,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub is_active: bool,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub is_deleted: bool,
#[serde(default)]
pub archived_at: Option<i64>,
#[serde(default)]
pub created_at: Option<i64>,
#[serde(default)]
pub updated_at: Option<i64>,
#[serde(default)]
pub deleted_at: Option<i64>,
#[serde(flatten)]
pub extra: Map<String, Value>,
}
+48
View File
@@ -0,0 +1,48 @@
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
/// A document (file) attached to another entity.
///
/// Documents are created by uploading to the owning entity
/// (`PUT /{resource}/{id}/upload`, multipart) rather than by posting to
/// `/documents`, so this crate exposes read, update, delete, and download
/// but no `create_document`.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Document {
#[serde(skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub user_id: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub assigned_user_id: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub project_id: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub vendor_id: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub name: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub url: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub preview: String,
/// File extension, e.g. `pdf`.
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub r#type: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub disk: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub hash: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub is_deleted: bool,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub is_default: bool,
#[serde(default)]
pub created_at: Option<i64>,
#[serde(default)]
pub updated_at: Option<i64>,
#[serde(default)]
pub deleted_at: Option<i64>,
#[serde(flatten)]
pub extra: Map<String, Value>,
}
+17 -17
View File
@@ -5,39 +5,39 @@ use serde_json::{Map, Value};
pub struct Expense { pub struct Expense {
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub id: Option<String>, pub id: Option<String>,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub vendor_id: String, pub vendor_id: String,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub client_id: String, pub client_id: String,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub invoice_id: String, pub invoice_id: String,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub amount: f64, pub amount: f64,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub exchange_rate: f64, pub exchange_rate: f64,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub public_notes: String, pub public_notes: String,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub private_notes: String, pub private_notes: String,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub date: String, pub date: String,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub payment_date: String, pub payment_date: String,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub transaction_reference: String, pub transaction_reference: String,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub category_id: String, pub category_id: String,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub tax_name1: String, pub tax_name1: String,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub tax_rate1: f64, pub tax_rate1: f64,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub tax_name2: String, pub tax_name2: String,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub tax_rate2: f64, pub tax_rate2: f64,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub should_be_invoiced: bool, pub should_be_invoiced: bool,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub is_deleted: bool, pub is_deleted: bool,
#[serde(default)] #[serde(default)]
pub archived_at: Option<i64>, pub archived_at: Option<i64>,
+23
View File
@@ -0,0 +1,23 @@
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ExpenseCategory {
#[serde(skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub name: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub user_id: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub is_deleted: bool,
#[serde(default)]
pub archived_at: Option<i64>,
#[serde(default)]
pub created_at: Option<i64>,
#[serde(default)]
pub updated_at: Option<i64>,
#[serde(flatten)]
pub extra: Map<String, Value>,
}
+28
View File
@@ -0,0 +1,28 @@
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
/// A named settings group that clients can be assigned to.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct GroupSetting {
#[serde(skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub user_id: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub name: String,
/// The settings override blob. Left untyped — Invoice Ninja's
/// `CompanySettings` object has well over a hundred fields.
#[serde(default)]
pub settings: Value,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub is_deleted: bool,
#[serde(default)]
pub archived_at: Option<i64>,
#[serde(default)]
pub created_at: Option<i64>,
#[serde(default)]
pub updated_at: Option<i64>,
#[serde(flatten)]
pub extra: Map<String, Value>,
}
+79
View File
@@ -0,0 +1,79 @@
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
/// Response of `GET /health_check`.
///
/// Two different payloads arrive here depending on the deployment. A
/// self-hosted install returns the full `SystemHealth::check()` report
/// modelled below; the hosted app returns only `{"message": "", "errors":
/// []}`, since the check is not meaningful there. Every field defaults, so
/// both decode — test [`system_health`](Self::system_health) rather than
/// assuming the detail fields are populated.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct HealthCheck {
/// Overall verdict. Absent on hosted, where no check runs.
#[serde(default)]
pub system_health: Option<bool>,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub api_version: String,
#[serde(default)]
pub php_version: Option<PhpVersion>,
/// Required PHP extensions and whether each was found.
#[serde(default)]
pub extensions: Value,
#[serde(default)]
pub env_writable: Option<bool>,
#[serde(default)]
pub simple_db_check: Option<bool>,
#[serde(default)]
pub cache_enabled: Option<bool>,
#[serde(default)]
pub exec: Option<bool>,
#[serde(default)]
pub open_basedir: Option<bool>,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub mail_mailer: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub pdf_engine: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub queue: String,
/// Queue depth and the most recent failure, when the queue is readable.
#[serde(default)]
pub queue_data: Value,
#[serde(default)]
pub trailing_slash: Option<bool>,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub file_permissions: String,
#[serde(default)]
pub exchange_rate_api_not_configured: Option<bool>,
#[serde(default)]
pub is_docker: Option<bool>,
#[serde(default)]
pub pending_migrations: Option<bool>,
/// Set on the hosted app, which returns a bare message instead of a
/// report.
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub message: String,
#[serde(flatten)]
pub extra: Map<String, Value>,
}
/// The PHP version sub-report of a health check.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct PhpVersion {
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub current_php_version: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub minimum_php_version: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub current_php_cli_version: String,
#[serde(default)]
pub is_okay: Option<bool>,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub memory_limit: String,
#[serde(flatten)]
pub extra: Map<String, Value>,
}
+26 -26
View File
@@ -5,35 +5,35 @@ use serde_json::{Map, Value};
pub struct Invoice { pub struct Invoice {
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub id: Option<String>, pub id: Option<String>,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub client_id: String, pub client_id: String,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub number: String, pub number: String,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub date: String, pub date: String,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub due_date: String, pub due_date: String,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub po_number: String, pub po_number: String,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub status_id: String, pub status_id: String,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub amount: f64, pub amount: f64,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub balance: f64, pub balance: f64,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub discount: f64, pub discount: f64,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub is_amount_discount: bool, pub is_amount_discount: bool,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub public_notes: String, pub public_notes: String,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub private_notes: String, pub private_notes: String,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub terms: String, pub terms: String,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub footer: String, pub footer: String,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub is_deleted: bool, pub is_deleted: bool,
#[serde(default)] #[serde(default)]
pub archived_at: Option<i64>, pub archived_at: Option<i64>,
@@ -41,7 +41,7 @@ pub struct Invoice {
pub created_at: Option<i64>, pub created_at: Option<i64>,
#[serde(default)] #[serde(default)]
pub updated_at: Option<i64>, pub updated_at: Option<i64>,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub line_items: Vec<InvoiceItem>, pub line_items: Vec<InvoiceItem>,
#[serde(flatten)] #[serde(flatten)]
@@ -50,25 +50,25 @@ pub struct Invoice {
#[derive(Debug, Clone, Default, Serialize, Deserialize)] #[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct InvoiceItem { pub struct InvoiceItem {
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub product_key: String, pub product_key: String,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub notes: String, pub notes: String,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub cost: f64, pub cost: f64,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub quantity: f64, pub quantity: f64,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub discount: f64, pub discount: f64,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub tax_name1: String, pub tax_name1: String,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub tax_rate1: f64, pub tax_rate1: f64,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub tax_name2: String, pub tax_name2: String,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub tax_rate2: f64, pub tax_rate2: f64,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub line_total: f64, pub line_total: f64,
#[serde(flatten)] #[serde(flatten)]
+44
View File
@@ -0,0 +1,44 @@
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
/// A physical address attached to a client or vendor.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Location {
#[serde(skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub name: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub address1: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub address2: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub city: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub state: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub postal_code: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub country_id: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub user_id: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub assigned_user_id: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub client_id: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub vendor_id: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub is_shipping_location: bool,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub is_deleted: bool,
#[serde(default)]
pub archived_at: Option<i64>,
#[serde(default)]
pub created_at: Option<i64>,
#[serde(default)]
pub updated_at: Option<i64>,
#[serde(flatten)]
pub extra: Map<String, Value>,
}
+55 -1
View File
@@ -1,19 +1,73 @@
mod bank_integration;
mod bank_transaction;
mod bank_transaction_rule;
mod client; mod client;
mod client_gateway_token;
mod common; mod common;
mod company;
mod company_gateway;
mod company_token;
mod design;
mod document;
mod expense; mod expense;
mod expense_category;
mod group_setting;
mod health;
mod invoice; mod invoice;
mod location;
mod payment; mod payment;
mod payment_term;
mod product; mod product;
mod project; mod project;
mod purchase_order;
mod recurring_expense;
mod recurring_invoice;
mod search;
mod statics;
mod subscription;
mod system_log;
mod tag;
mod task; mod task;
mod task_scheduler;
mod task_status;
mod tax_rate;
mod user;
mod vendor; mod vendor;
mod webhook;
pub use bank_integration::BankIntegration;
pub use bank_transaction::BankTransaction;
pub use bank_transaction_rule::BankTransactionRule;
pub use client::{Client, ClientContact}; pub use client::{Client, ClientContact};
pub use common::{ItemResponse, ListParams, ListResponse, Meta, Pagination}; pub use client_gateway_token::ClientGatewayToken;
pub use common::{BulkRequest, ItemResponse, ListParams, ListResponse, Meta, Pagination};
pub use company::Company;
pub use company_gateway::CompanyGateway;
pub use company_token::CompanyToken;
pub use design::Design;
pub use document::Document;
pub use expense::Expense; pub use expense::Expense;
pub use expense_category::ExpenseCategory;
pub use group_setting::GroupSetting;
pub use health::{HealthCheck, PhpVersion};
pub use invoice::{Credit, Invoice, InvoiceItem, Quote}; pub use invoice::{Credit, Invoice, InvoiceItem, Quote};
pub use location::Location;
pub use payment::{Payment, PaymentInvoice}; pub use payment::{Payment, PaymentInvoice};
pub use payment_term::PaymentTerm;
pub use product::Product; pub use product::Product;
pub use project::Project; pub use project::Project;
pub use purchase_order::PurchaseOrder;
pub use recurring_expense::RecurringExpense;
pub use recurring_invoice::{RecurringInvoice, RecurringQuote};
pub use search::{SearchHit, SearchResults};
pub use statics::{Currency, DateFormat, StaticItem, Statics};
pub use subscription::Subscription;
pub use system_log::SystemLog;
pub use tag::Tag;
pub use task::Task; pub use task::Task;
pub use task_scheduler::TaskScheduler;
pub use task_status::TaskStatus;
pub use tax_rate::TaxRate;
pub use user::User;
pub use vendor::{Vendor, VendorContact}; pub use vendor::{Vendor, VendorContact};
pub use webhook::Webhook;
+10 -10
View File
@@ -5,23 +5,23 @@ use serde_json::{Map, Value};
pub struct Payment { pub struct Payment {
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub id: Option<String>, pub id: Option<String>,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub client_id: String, pub client_id: String,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub amount: f64, pub amount: f64,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub applied: f64, pub applied: f64,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub refunded: f64, pub refunded: f64,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub date: String, pub date: String,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub transaction_reference: String, pub transaction_reference: String,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub type_id: String, pub type_id: String,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub private_notes: String, pub private_notes: String,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub is_deleted: bool, pub is_deleted: bool,
#[serde(default)] #[serde(default)]
pub archived_at: Option<i64>, pub archived_at: Option<i64>,
@@ -29,7 +29,7 @@ pub struct Payment {
pub created_at: Option<i64>, pub created_at: Option<i64>,
#[serde(default)] #[serde(default)]
pub updated_at: Option<i64>, pub updated_at: Option<i64>,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub invoices: Vec<PaymentInvoice>, pub invoices: Vec<PaymentInvoice>,
#[serde(flatten)] #[serde(flatten)]
+22
View File
@@ -0,0 +1,22 @@
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct PaymentTerm {
#[serde(skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
/// Human-readable form of the term, e.g. `NET 30`.
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub name: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub num_days: i64,
#[serde(default)]
pub archived_at: Option<i64>,
#[serde(default)]
pub created_at: Option<i64>,
#[serde(default)]
pub updated_at: Option<i64>,
#[serde(flatten)]
pub extra: Map<String, Value>,
}
+10 -10
View File
@@ -5,25 +5,25 @@ use serde_json::{Map, Value};
pub struct Product { pub struct Product {
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub id: Option<String>, pub id: Option<String>,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub product_key: String, pub product_key: String,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub notes: String, pub notes: String,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub cost: f64, pub cost: f64,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub price: f64, pub price: f64,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub quantity: f64, pub quantity: f64,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub tax_name1: String, pub tax_name1: String,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub tax_rate1: f64, pub tax_rate1: f64,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub tax_name2: String, pub tax_name2: String,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub tax_rate2: f64, pub tax_rate2: f64,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub is_deleted: bool, pub is_deleted: bool,
#[serde(default)] #[serde(default)]
pub archived_at: Option<i64>, pub archived_at: Option<i64>,
+10 -10
View File
@@ -5,25 +5,25 @@ use serde_json::{Map, Value};
pub struct Project { pub struct Project {
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub id: Option<String>, pub id: Option<String>,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub client_id: String, pub client_id: String,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub name: String, pub name: String,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub number: String, pub number: String,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub task_rate: f64, pub task_rate: f64,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub due_date: String, pub due_date: String,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub budgeted_hours: f64, pub budgeted_hours: f64,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub color: String, pub color: String,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub public_notes: String, pub public_notes: String,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub private_notes: String, pub private_notes: String,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub is_deleted: bool, pub is_deleted: bool,
#[serde(default)] #[serde(default)]
pub archived_at: Option<i64>, pub archived_at: Option<i64>,
+79
View File
@@ -0,0 +1,79 @@
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
use super::invoice::InvoiceItem;
/// A purchase order. Structurally close to an invoice, but addressed to a
/// vendor rather than a client.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct PurchaseOrder {
#[serde(skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub vendor_id: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub client_id: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub invoice_id: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub project_id: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub number: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub po_number: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub quote_number: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub status_id: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub date: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub due_date: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub partial_due_date: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub last_sent_date: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub amount: f64,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub balance: f64,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub paid_to_date: f64,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub discount: f64,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub partial: f64,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub exchange_rate: f64,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub is_amount_discount: bool,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub uses_inclusive_taxes: bool,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub public_notes: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub private_notes: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub terms: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub footer: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub currency_id: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub design_id: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub is_deleted: bool,
#[serde(default)]
pub last_viewed: Option<i64>,
#[serde(default)]
pub archived_at: Option<i64>,
#[serde(default)]
pub created_at: Option<i64>,
#[serde(default)]
pub updated_at: Option<i64>,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub line_items: Vec<InvoiceItem>,
#[serde(flatten)]
pub extra: Map<String, Value>,
}
+77
View File
@@ -0,0 +1,77 @@
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
/// A recurring expense template: an expense plus the recurrence fields
/// (`frequency_id`, `remaining_cycles`, `next_send_date`).
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct RecurringExpense {
#[serde(skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub client_id: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub vendor_id: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub invoice_id: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub project_id: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub category_id: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub number: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub amount: f64,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub foreign_amount: f64,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub exchange_rate: f64,
/// Integer enum matching [`RecurringInvoice::frequency_id`].
///
/// [`RecurringInvoice::frequency_id`]: super::RecurringInvoice::frequency_id
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub frequency_id: i64,
/// Number of cycles left; `-1` means it recurs indefinitely.
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub remaining_cycles: i64,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub date: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub payment_date: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub last_sent_date: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub next_send_date: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub public_notes: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub private_notes: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub transaction_reference: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub tax_name1: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub tax_rate1: f64,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub tax_name2: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub tax_rate2: f64,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub tax_name3: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub tax_rate3: f64,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub should_be_invoiced: bool,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub uses_inclusive_taxes: bool,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub is_deleted: bool,
#[serde(default)]
pub archived_at: Option<i64>,
#[serde(default)]
pub created_at: Option<i64>,
#[serde(default)]
pub updated_at: Option<i64>,
#[serde(flatten)]
pub extra: Map<String, Value>,
}
+92
View File
@@ -0,0 +1,92 @@
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
use super::invoice::InvoiceItem;
/// A recurring invoice template. Shares the invoices table with [`Invoice`],
/// plus the recurrence fields (`frequency_id`, `remaining_cycles`,
/// `next_send_date`, auto-billing).
///
/// [`Invoice`]: super::Invoice
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct RecurringInvoice {
#[serde(skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub client_id: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub vendor_id: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub project_id: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub number: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub po_number: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub status_id: String,
/// How often the invoice recurs. Invoice Ninja returns this as an integer
/// enum (1 = daily, 2 = weekly, … ), not a human-readable string.
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub frequency_id: i64,
/// Number of cycles left to send; `-1` means it recurs indefinitely.
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub remaining_cycles: i64,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub auto_bill: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub auto_bill_enabled: bool,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub date: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub due_date: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub next_send_date: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub last_sent_date: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub amount: f64,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub balance: f64,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub paid_to_date: f64,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub discount: f64,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub partial: f64,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub exchange_rate: f64,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub is_amount_discount: bool,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub uses_inclusive_taxes: bool,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub public_notes: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub private_notes: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub terms: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub footer: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub design_id: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub is_deleted: bool,
#[serde(default)]
pub last_viewed: Option<i64>,
#[serde(default)]
pub archived_at: Option<i64>,
#[serde(default)]
pub created_at: Option<i64>,
#[serde(default)]
pub updated_at: Option<i64>,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub line_items: Vec<InvoiceItem>,
#[serde(flatten)]
pub extra: Map<String, Value>,
}
/// Recurring quotes share the recurring-invoice schema (the API's
/// `RecurringQuote` fields are a strict subset of `RecurringInvoice`), so the
/// `/recurring_quotes` endpoint returns the same JSON shape.
pub type RecurringQuote = RecurringInvoice;
+65
View File
@@ -0,0 +1,65 @@
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
/// Results of `POST /search`.
///
/// Which buckets are populated depends on how the server is configured: with
/// Elasticsearch enabled the controller fills every entity bucket, and
/// without it only `clients`, `client_contacts`, `invoices`, `projects`, and
/// `settings` are returned. Every field therefore defaults to empty rather
/// than being required, so both shapes decode.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct SearchResults {
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub clients: Vec<SearchHit>,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub client_contacts: Vec<SearchHit>,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub invoices: Vec<SearchHit>,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub quotes: Vec<SearchHit>,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub credits: Vec<SearchHit>,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub recurring_invoices: Vec<SearchHit>,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub purchase_orders: Vec<SearchHit>,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub expenses: Vec<SearchHit>,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub vendors: Vec<SearchHit>,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub vendor_contacts: Vec<SearchHit>,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub projects: Vec<SearchHit>,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub tasks: Vec<SearchHit>,
/// Matching settings pages rather than records.
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub settings: Vec<SearchHit>,
#[serde(flatten)]
pub extra: Map<String, Value>,
}
/// One search result.
///
/// Every bucket uses the same row shape, so this is not entity-specific —
/// use `id` to fetch the full record through the matching `get_*` method.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct SearchHit {
/// Hashed id of the matched record.
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub id: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub name: String,
/// Entity kind, as a leading-slash tag like `/client` or `/invoice`.
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub r#type: String,
/// Front-end route for the record, e.g. `/clients/Wpmbk5ezJn`.
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub path: String,
#[serde(flatten)]
pub extra: Map<String, Value>,
}
+112
View File
@@ -0,0 +1,112 @@
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
/// The static lookup tables returned by `GET /statics`.
///
/// This is a large, near-constant payload — cache it rather than fetching it
/// per request.
///
/// Only the tables with a stable, uniform row shape are typed. `gateways`,
/// `templates`, and `bulk_updates` are left as `Value`: they are nested
/// configuration blobs rather than lookup lists, and the same reasoning
/// applies as for [`Company::settings`](super::Company). Anything not named
/// here — including `einvoice_schema` and the self-host-only `license_key` —
/// is preserved in `extra`.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Statics {
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub currencies: Vec<Currency>,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub countries: Vec<StaticItem>,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub industries: Vec<StaticItem>,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub languages: Vec<StaticItem>,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub payment_types: Vec<StaticItem>,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub sizes: Vec<StaticItem>,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub timezones: Vec<StaticItem>,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub date_formats: Vec<DateFormat>,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub datetime_formats: Vec<DateFormat>,
/// Payment gateway definitions. A nested configuration blob, left
/// untyped.
#[serde(default)]
pub gateways: Value,
/// Email/PDF template defaults. Left untyped for the same reason.
#[serde(default)]
pub templates: Value,
/// Columns that each entity accepts in a bulk update, keyed by entity.
#[serde(default)]
pub bulk_updates: Value,
#[serde(flatten)]
pub extra: Map<String, Value>,
}
/// A row in one of the simple lookup tables.
///
/// `name` arrives already translated into the requesting user's locale.
/// Per-table extras — a country's ISO codes, a language's locale, a
/// timezone's UTC offset — stay in `extra`.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct StaticItem {
/// Normalised to a string: most tables return a number here, but
/// `payment_types` returns a string.
#[serde(default, deserialize_with = "super::common::id_as_string")]
pub id: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub name: String,
#[serde(flatten)]
pub extra: Map<String, Value>,
}
/// A currency, which carries formatting detail the other lookup tables do
/// not.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Currency {
#[serde(default, deserialize_with = "super::common::id_as_string")]
pub id: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub name: String,
/// ISO 4217 code, e.g. `USD`.
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub code: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub symbol: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub thousand_separator: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub decimal_separator: String,
/// Number of decimal places. `Option` rather than defaulted, since 0 is a
/// meaningful precision (JPY) and must not be confused with "absent".
#[serde(default)]
pub precision: Option<i64>,
#[serde(default)]
pub swap_currency_symbol: Option<bool>,
#[serde(default)]
pub exchange_rate: Option<f64>,
#[serde(flatten)]
pub extra: Map<String, Value>,
}
/// A date or datetime format option.
///
/// `format` is the PHP format string; the `format_moment` and `format_dart`
/// variants for other client libraries stay in `extra`.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct DateFormat {
#[serde(default, deserialize_with = "super::common::id_as_string")]
pub id: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub format: String,
#[serde(flatten)]
pub extra: Map<String, Value>,
}
+57
View File
@@ -0,0 +1,57 @@
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
/// A subscription (billing plan) definition.
///
/// Note that unlike [`RecurringInvoice::frequency_id`], the API returns this
/// resource's `frequency_id` as a string.
///
/// [`RecurringInvoice::frequency_id`]: super::RecurringInvoice::frequency_id
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Subscription {
#[serde(skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub product_id: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub recurring_invoice_id: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub frequency_id: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub auto_bill: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub promo_code: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub promo_discount: f64,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub is_recurring: bool,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub is_amount_discount: bool,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub allow_cancellation: bool,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub per_seat_enabled: bool,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub max_seats_limit: i64,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub trial_enabled: bool,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub trial_duration: i64,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub allow_query_overrides: bool,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub allow_plan_changes: bool,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub refund_period: i64,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub is_deleted: bool,
#[serde(default)]
pub archived_at: Option<i64>,
#[serde(default)]
pub created_at: Option<i64>,
#[serde(default)]
pub updated_at: Option<i64>,
#[serde(flatten)]
pub extra: Map<String, Value>,
}
+32
View File
@@ -0,0 +1,32 @@
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
/// A server-side log entry (gateway responses, mail failures, and so on).
///
/// Note that unlike every other resource, this one's `created_at` and
/// `updated_at` are returned as date **strings**, not integer timestamps.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct SystemLog {
#[serde(skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub user_id: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub client_id: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub event_id: i64,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub category_id: i64,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub type_id: i64,
/// The logged payload; shape depends on the event.
#[serde(default)]
pub log: Value,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub created_at: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub updated_at: String,
#[serde(flatten)]
pub extra: Map<String, Value>,
}
+26
View File
@@ -0,0 +1,26 @@
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Tag {
#[serde(skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub entity_type: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub name: String,
/// Hex colour value.
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub color: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub is_deleted: bool,
#[serde(default)]
pub archived_at: Option<i64>,
#[serde(default)]
pub created_at: Option<i64>,
#[serde(default)]
pub updated_at: Option<i64>,
#[serde(flatten)]
pub extra: Map<String, Value>,
}
+9 -9
View File
@@ -5,24 +5,24 @@ use serde_json::{Map, Value};
pub struct Task { pub struct Task {
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub id: Option<String>, pub id: Option<String>,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub client_id: String, pub client_id: String,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub invoice_id: String, pub invoice_id: String,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub project_id: String, pub project_id: String,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub description: String, pub description: String,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub rate: f64, pub rate: f64,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub status_id: String, pub status_id: String,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub is_running: bool, pub is_running: bool,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub is_deleted: bool, pub is_deleted: bool,
/// JSON-encoded array of `[start_timestamp, end_timestamp]` pairs, as returned by the API. /// JSON-encoded array of `[start_timestamp, end_timestamp]` pairs, as returned by the API.
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub time_log: String, pub time_log: String,
#[serde(default)] #[serde(default)]
pub archived_at: Option<i64>, pub archived_at: Option<i64>,
+40
View File
@@ -0,0 +1,40 @@
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
/// A scheduled job (recurring report emails, and similar).
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct TaskScheduler {
#[serde(skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub paused: bool,
/// Frequency, e.g. `DAY`, `WEEK`, `MONTH`.
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub repeat_every: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub start_from: i64,
/// The job to run, e.g. `email_report`.
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub job: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub date_range: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub date_key: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub start_date: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub end_date: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub report_keys: Vec<String>,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub is_deleted: bool,
#[serde(default)]
pub archived_at: Option<i64>,
#[serde(default)]
pub created_at: Option<i64>,
#[serde(default)]
pub updated_at: Option<i64>,
#[serde(flatten)]
pub extra: Map<String, Value>,
}
+27
View File
@@ -0,0 +1,27 @@
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct TaskStatus {
#[serde(skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub name: String,
/// Hex colour value, e.g. `#000000`.
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub color: String,
/// Position of this status in the kanban board ordering.
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub task_status_order: i64,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub is_deleted: bool,
#[serde(default)]
pub archived_at: Option<i64>,
#[serde(default)]
pub created_at: Option<i64>,
#[serde(default)]
pub updated_at: Option<i64>,
#[serde(flatten)]
pub extra: Map<String, Value>,
}
+24
View File
@@ -0,0 +1,24 @@
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct TaxRate {
#[serde(skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub name: String,
/// The rate as a percentage, e.g. `10.0` for 10%.
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub rate: f64,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub is_deleted: bool,
#[serde(default)]
pub archived_at: Option<i64>,
#[serde(default)]
pub created_at: Option<i64>,
#[serde(default)]
pub updated_at: Option<i64>,
#[serde(flatten)]
pub extra: Map<String, Value>,
}
+55
View File
@@ -0,0 +1,55 @@
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
/// A user account.
///
/// Every `/users` endpoint except the index listing sits behind the API's
/// `password_protected` middleware, so reads of a single user and all writes
/// need an `X-API-PASSWORD` header — see
/// [`InvoiceNinjaClient::with_api_password`](crate::InvoiceNinjaClient::with_api_password).
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct User {
#[serde(skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub first_name: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub last_name: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub email: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub phone: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub signature: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub avatar: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub language_id: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub accepted_terms_version: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub oauth_user_id: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub oauth_provider_id: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub verified_phone_number: bool,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub has_password: bool,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub last_confirmed_email_address: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub passkey_enabled: bool,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub passkey_count: i64,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub is_deleted: bool,
#[serde(default)]
pub archived_at: Option<i64>,
#[serde(default)]
pub created_at: Option<i64>,
#[serde(default)]
pub updated_at: Option<i64>,
#[serde(flatten)]
pub extra: Map<String, Value>,
}
+19 -19
View File
@@ -5,31 +5,31 @@ use serde_json::{Map, Value};
pub struct Vendor { pub struct Vendor {
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub id: Option<String>, pub id: Option<String>,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub name: String, pub name: String,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub id_number: String, pub id_number: String,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub vat_number: String, pub vat_number: String,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub address1: String, pub address1: String,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub address2: String, pub address2: String,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub city: String, pub city: String,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub state: String, pub state: String,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub postal_code: String, pub postal_code: String,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub country_id: String, pub country_id: String,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub phone: String, pub phone: String,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub website: String, pub website: String,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub private_notes: String, pub private_notes: String,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub is_deleted: bool, pub is_deleted: bool,
#[serde(default)] #[serde(default)]
pub archived_at: Option<i64>, pub archived_at: Option<i64>,
@@ -37,7 +37,7 @@ pub struct Vendor {
pub created_at: Option<i64>, pub created_at: Option<i64>,
#[serde(default)] #[serde(default)]
pub updated_at: Option<i64>, pub updated_at: Option<i64>,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub contacts: Vec<VendorContact>, pub contacts: Vec<VendorContact>,
/// Any fields returned by the API that aren't modeled above are preserved here, /// Any fields returned by the API that aren't modeled above are preserved here,
@@ -50,15 +50,15 @@ pub struct Vendor {
pub struct VendorContact { pub struct VendorContact {
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub id: Option<String>, pub id: Option<String>,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub first_name: String, pub first_name: String,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub last_name: String, pub last_name: String,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub email: String, pub email: String,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub phone: String, pub phone: String,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub is_primary: bool, pub is_primary: bool,
#[serde(flatten)] #[serde(flatten)]
+22
View File
@@ -0,0 +1,22 @@
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
/// An outbound webhook registration (the API calls these "subscriptions"
/// internally, but they are served from `/webhooks`).
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Webhook {
#[serde(skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
/// The event this hook fires on, as an integer-enum string.
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub event_id: String,
/// The URL Invoice Ninja posts to.
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub target_url: String,
/// Payload format: `JSON` or `UBL`.
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub format: String,
#[serde(flatten)]
pub extra: Map<String, Value>,
}
+181
View File
@@ -0,0 +1,181 @@
//! Retry policy for transient failures.
//!
//! The rules here are deliberately conservative about which requests may be
//! replayed. This is a billing API: replaying a `POST` that the server
//! already processed but whose response was lost would create a second
//! invoice or payment, which is far worse than surfacing an error.
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use reqwest::{header, Method, StatusCode};
/// How transient failures are retried.
///
/// The default retries up to 3 times with full-jitter exponential backoff
/// starting at 500ms. Use [`RetryPolicy::none`] to disable retrying.
#[derive(Debug, Clone)]
pub struct RetryPolicy {
/// Retries *after* the initial attempt, so 3 means up to 4 requests.
pub max_retries: u32,
/// Backoff for the first retry; doubles each attempt.
pub base_delay: Duration,
/// Ceiling on any single wait, including one asked for by `Retry-After`.
pub max_delay: Duration,
/// Ceiling on the time spent across *all* attempts for one call.
///
/// This exists because the request timeout is per attempt, not per call:
/// with the defaults, four attempts that each time out would otherwise
/// take four minutes. Once this much has elapsed no further retry
/// starts, and the last error is returned. `None` bounds a call only by
/// `max_retries`.
pub max_elapsed: Option<Duration>,
}
impl Default for RetryPolicy {
fn default() -> Self {
Self {
max_retries: 3,
base_delay: Duration::from_millis(500),
max_delay: Duration::from_secs(10),
max_elapsed: Some(Duration::from_secs(120)),
}
}
}
impl RetryPolicy {
/// Retries `max_retries` times with the default delays.
pub fn new(max_retries: u32) -> Self {
Self {
max_retries,
..Self::default()
}
}
/// Disables retrying; every request is attempted exactly once.
pub fn none() -> Self {
Self::new(0)
}
pub fn base_delay(mut self, base_delay: Duration) -> Self {
self.base_delay = base_delay;
self
}
pub fn max_delay(mut self, max_delay: Duration) -> Self {
self.max_delay = max_delay;
self
}
/// Sets the ceiling on total time across all attempts; `None` removes
/// it.
pub fn max_elapsed(mut self, max_elapsed: Option<Duration>) -> Self {
self.max_elapsed = max_elapsed;
self
}
/// Whether another attempt may start after `elapsed` spent so far.
///
/// Spelled as a match rather than `is_none_or`, which would raise the
/// minimum toolchain to 1.82 for no benefit.
pub(crate) fn within_budget(&self, elapsed: Duration) -> bool {
match self.max_elapsed {
Some(budget) => elapsed < budget,
None => true,
}
}
/// Full-jitter exponential backoff: a uniform pick from
/// `[0, min(max_delay, base_delay * 2^attempt)]`.
///
/// Jittering the whole interval rather than a fraction of it is what
/// stops a set of clients that failed together from retrying together.
pub(crate) fn backoff(&self, attempt: u32) -> Duration {
let exponential = self
.base_delay
// `checked_shl` yields None past 31 places, and the multiply can
// overflow well before that; either way the cap applies.
.checked_mul(1u32.checked_shl(attempt).unwrap_or(u32::MAX))
.unwrap_or(self.max_delay)
.min(self.max_delay);
let millis = u64::try_from(exponential.as_millis()).unwrap_or(u64::MAX);
Duration::from_millis(pseudo_random_millis(millis))
}
}
/// Picks a value in `[0, upper]` without pulling in a random-number crate.
///
/// Decorrelating retries is all this needs to do — it is not used for
/// anything requiring unpredictability. The counter is mixed in so that
/// calls landing in the same nanosecond still diverge.
fn pseudo_random_millis(upper: u64) -> u64 {
if upper == 0 {
return 0;
}
static COUNTER: AtomicU64 = AtomicU64::new(0);
let seq = COUNTER.fetch_add(1, Ordering::Relaxed);
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos() as u64)
.unwrap_or(0);
// splitmix64 finalizer, enough to spread adjacent seeds.
let mut x = nanos ^ seq.wrapping_mul(0x9E37_79B9_7F4A_7C15);
x ^= x >> 30;
x = x.wrapping_mul(0xBF58_476D_1CE4_E5B9);
x ^= x >> 27;
x = x.wrapping_mul(0x94D0_49BB_1331_11EB);
x ^= x >> 31;
// Saturating, so an enormous `max_delay` cannot overflow the span and
// panic in a debug build.
x % upper.saturating_add(1)
}
/// Whether replaying the request can be assumed not to double an effect.
///
/// `POST` is excluded: it creates records, runs bulk actions, issues refunds
/// and sends email, none of which is safe to repeat blindly.
fn is_idempotent(method: &Method) -> bool {
matches!(
*method,
Method::GET | Method::HEAD | Method::PUT | Method::DELETE | Method::OPTIONS
)
}
/// Whether a response status should be retried.
pub(crate) fn status_is_retryable(status: StatusCode, method: &Method) -> bool {
match status.as_u16() {
// Rate limited. The server states it did not process the request, so
// replaying cannot duplicate anything — safe even for POST.
429 => true,
// The request may or may not have been applied before the error: a
// 502 or 504 can mean the response was lost rather than the work
// undone. Only replay when repeating is harmless.
500 | 502 | 503 | 504 => is_idempotent(method),
_ => false,
}
}
/// Whether a transport-level failure should be retried.
///
/// Same reasoning as a 5xx: a timeout gives no evidence about whether the
/// server processed the request.
pub(crate) fn transport_error_is_retryable(error: &reqwest::Error, method: &Method) -> bool {
(error.is_timeout() || error.is_connect()) && is_idempotent(method)
}
/// Reads a `Retry-After` delay, which Invoice Ninja sends on throttled
/// responses.
///
/// Only the delta-seconds form is parsed; the HTTP-date form falls through to
/// normal backoff rather than pulling in a date parser.
pub(crate) fn retry_after(headers: &header::HeaderMap) -> Option<Duration> {
let seconds: u64 = headers
.get(header::RETRY_AFTER)?
.to_str()
.ok()?
.trim()
.parse()
.ok()?;
Some(Duration::from_secs(seconds))
}
File diff suppressed because it is too large Load Diff
+613 -1
View File
@@ -1,5 +1,10 @@
use invoiceninjars::models::{ use invoiceninjars::models::{
Client, Credit, Expense, Invoice, ListParams, Project, Quote, Task, Vendor, BankIntegration, BankTransaction, BankTransactionRule, BulkRequest, Client, ClientGatewayToken,
Company, CompanyGateway, CompanyToken, Credit, Design, Document, Expense, ExpenseCategory,
GroupSetting, HealthCheck, Invoice, ListParams, ListResponse, Location, PaymentTerm, Project,
PurchaseOrder, Quote, RecurringExpense, RecurringInvoice, RecurringQuote, SearchResults,
Statics, Subscription, SystemLog, Tag, Task, TaskScheduler, TaskStatus, TaxRate, User, Vendor,
Webhook,
}; };
use serde_json::json; use serde_json::json;
@@ -150,3 +155,610 @@ fn quote_and_credit_share_the_invoice_shape() {
let credit: Credit = serde_json::from_value(payload).unwrap(); let credit: Credit = serde_json::from_value(payload).unwrap();
assert_eq!(credit.number, "QUO-0001"); assert_eq!(credit.number, "QUO-0001");
} }
#[test]
fn recurring_invoice_decodes_recurrence_fields() {
// frequency_id / remaining_cycles come back as JSON numbers, and
// remaining_cycles is -1 for an indefinite schedule.
let payload = json!({
"id": "ri1",
"client_id": "abc123",
"frequency_id": 5,
"remaining_cycles": -1,
"auto_bill": "off",
"next_send_date": "2026-11-01",
"line_items": [{"product_key": "widget", "cost": 9.5, "quantity": 2.0}],
"custom_value1": "unmodeled field",
});
let ri: RecurringInvoice = serde_json::from_value(payload).unwrap();
assert_eq!(ri.frequency_id, 5);
assert_eq!(ri.remaining_cycles, -1);
assert_eq!(ri.next_send_date, "2026-11-01");
assert_eq!(ri.line_items.len(), 1);
assert_eq!(ri.line_items[0].product_key, "widget");
let round_tripped = serde_json::to_value(&ri).unwrap();
assert_eq!(round_tripped["custom_value1"], "unmodeled field");
}
/// `RecurringQuote` aliases `RecurringInvoice`; the API's RecurringQuote
/// fields are a strict subset, so a quote payload must decode without the
/// invoice-only fields present.
#[test]
fn recurring_quote_decodes_without_invoice_only_fields() {
let payload = json!({
"id": "rq1",
"client_id": "abc123",
"frequency_id": 2,
"remaining_cycles": 12,
});
let rq: RecurringQuote = serde_json::from_value(payload).unwrap();
assert_eq!(rq.frequency_id, 2);
assert_eq!(rq.remaining_cycles, 12);
// Invoice-only fields fall back to their defaults rather than failing.
assert_eq!(rq.auto_bill, "");
assert!(!rq.auto_bill_enabled);
}
#[test]
fn purchase_order_round_trip_preserves_unknown_fields() {
let payload = json!({
"id": "po1",
"vendor_id": "ven1",
"number": "PO-0001",
"amount": 250.0,
"custom_surcharge1": 7.25,
});
let po: PurchaseOrder = serde_json::from_value(payload).unwrap();
assert_eq!(po.vendor_id, "ven1");
assert_eq!(po.number, "PO-0001");
assert_eq!(po.amount, 250.0);
let round_tripped = serde_json::to_value(&po).unwrap();
assert_eq!(round_tripped["custom_surcharge1"], 7.25);
}
#[test]
fn recurring_expense_round_trip_preserves_unknown_fields() {
let payload = json!({
"id": "rex1",
"vendor_id": "ven1",
"frequency_id": 4,
"remaining_cycles": 6,
"amount": 19.99,
"custom_value2": "unmodeled field",
});
let rex: RecurringExpense = serde_json::from_value(payload).unwrap();
assert_eq!(rex.frequency_id, 4);
assert_eq!(rex.remaining_cycles, 6);
assert_eq!(rex.amount, 19.99);
let round_tripped = serde_json::to_value(&rex).unwrap();
assert_eq!(round_tripped["custom_value2"], "unmodeled field");
}
/// `Subscription.frequency_id` is a string even though the recurring-invoice
/// field of the same name is numeric; this pins that asymmetry.
#[test]
fn subscription_frequency_id_is_a_string() {
let payload = json!({
"id": "sub1",
"product_id": "prod1",
"frequency_id": "5",
"trial_enabled": true,
"trial_duration": 14,
"promo_discount": 10.0,
});
let sub: Subscription = serde_json::from_value(payload).unwrap();
assert_eq!(sub.frequency_id, "5");
assert!(sub.trial_enabled);
assert_eq!(sub.trial_duration, 14);
assert_eq!(sub.promo_discount, 10.0);
}
#[test]
fn small_lookup_resources_decode() {
let tax_rate: TaxRate = serde_json::from_value(json!({
"id": "tr1", "name": "GST", "rate": 10.0
}))
.unwrap();
assert_eq!(tax_rate.name, "GST");
assert_eq!(tax_rate.rate, 10.0);
let category: ExpenseCategory = serde_json::from_value(json!({
"id": "ec1", "name": "Accounting"
}))
.unwrap();
assert_eq!(category.name, "Accounting");
let status: TaskStatus = serde_json::from_value(json!({
"id": "ts1", "name": "Backlog", "color": "#000000", "task_status_order": 4
}))
.unwrap();
assert_eq!(status.task_status_order, 4);
assert_eq!(status.color, "#000000");
let term: PaymentTerm = serde_json::from_value(json!({
"id": "pt1", "name": "NET 30", "num_days": 30
}))
.unwrap();
assert_eq!(term.num_days, 30);
let webhook: Webhook = serde_json::from_value(json!({
"id": "wh1", "event_id": "1", "target_url": "https://example.com/hook", "format": "JSON"
}))
.unwrap();
assert_eq!(webhook.target_url, "https://example.com/hook");
assert_eq!(webhook.format, "JSON");
}
/// Bank transactions are the one resource whose ids come back as integers
/// rather than hashed strings.
#[test]
fn bank_transaction_decodes_integer_ids() {
let payload = json!({
"id": "bt1",
"transaction_id": 9876543210i64,
"bank_account_id": 42,
"category_id": 7,
"amount": 123.45,
"base_type": "CREDIT",
"date": "2026-10-01",
});
let bt: BankTransaction = serde_json::from_value(payload).unwrap();
assert_eq!(bt.transaction_id, 9876543210);
assert_eq!(bt.bank_account_id, 42);
assert_eq!(bt.category_id, 7);
assert_eq!(bt.base_type, "CREDIT");
}
/// A newly constructed model must not serialize an `id`, so `create_*` calls
/// do not send `"id": null` to the API.
#[test]
fn default_models_omit_id_on_serialize() {
let body = serde_json::to_value(RecurringInvoice::default()).unwrap();
assert!(body.get("id").is_none());
let body = serde_json::to_value(TaxRate::default()).unwrap();
assert!(body.get("id").is_none());
}
// --- explicit-null tolerance -------------------------------------------
//
// Invoice Ninja returns `null` for plenty of nominally-string fields.
// `#[serde(default)]` only covers a *missing* key, so these payloads used to
// fail to decode entirely; the models pair `default` with a
// null-to-default deserializer.
#[test]
fn null_strings_numbers_and_bools_decode_as_defaults() {
let payload = json!({
"id": "abc123",
"name": null,
"address1": null,
"balance": null,
"is_deleted": null,
"contacts": null,
});
let client: Client = serde_json::from_value(payload).unwrap();
assert_eq!(client.name, "");
assert_eq!(client.address1, "");
assert_eq!(client.balance, 0.0);
assert!(!client.is_deleted);
assert!(client.contacts.is_empty());
}
#[test]
fn null_line_items_and_nested_nulls_decode() {
let payload = json!({
"id": "inv1",
"number": null,
"public_notes": null,
"line_items": [{
"product_key": null,
"notes": null,
"cost": null,
"quantity": 3.0,
}],
});
let invoice: Invoice = serde_json::from_value(payload).unwrap();
assert_eq!(invoice.number, "");
assert_eq!(invoice.public_notes, "");
assert_eq!(invoice.line_items.len(), 1);
assert_eq!(invoice.line_items[0].product_key, "");
assert_eq!(invoice.line_items[0].cost, 0.0);
assert_eq!(invoice.line_items[0].quantity, 3.0);
}
#[test]
fn null_integer_fields_decode_across_resources() {
let ri: RecurringInvoice = serde_json::from_value(json!({
"id": "ri1", "frequency_id": null, "remaining_cycles": null, "auto_bill": null
}))
.unwrap();
assert_eq!(ri.frequency_id, 0);
assert_eq!(ri.remaining_cycles, 0);
assert_eq!(ri.auto_bill, "");
let status: TaskStatus = serde_json::from_value(json!({
"id": "ts1", "name": null, "task_status_order": null
}))
.unwrap();
assert_eq!(status.task_status_order, 0);
let bt: BankTransaction = serde_json::from_value(json!({
"id": "bt1", "transaction_id": null, "bank_account_id": null, "base_type": null
}))
.unwrap();
assert_eq!(bt.transaction_id, 0);
assert_eq!(bt.bank_account_id, 0);
}
/// Nullable timestamps stay `None` rather than collapsing to `Some(0)`, so
/// "never archived" is still distinguishable from "archived at epoch".
#[test]
fn null_timestamps_remain_none() {
let client: Client = serde_json::from_value(json!({
"id": "abc123", "archived_at": null, "created_at": null, "updated_at": 1700000000i64
}))
.unwrap();
assert_eq!(client.archived_at, None);
assert_eq!(client.created_at, None);
assert_eq!(client.updated_at, Some(1700000000));
}
/// A null value for an *unmodeled* key still round-trips through `extra`.
#[test]
fn null_unknown_fields_round_trip_through_extra() {
let payload = json!({"id": "abc123", "name": "Acme Inc", "custom_value1": null});
let client: Client = serde_json::from_value(payload).unwrap();
assert!(client.extra.get("custom_value1").unwrap().is_null());
let round_tripped = serde_json::to_value(&client).unwrap();
assert!(round_tripped["custom_value1"].is_null());
}
// --- non-CRUD endpoint plumbing ----------------------------------------
#[test]
fn bulk_request_serializes_action_and_ids() {
let body = BulkRequest::new("archive", &["abc123", "def456"]);
assert_eq!(
serde_json::to_value(&body).unwrap(),
json!({"action": "archive", "ids": ["abc123", "def456"]})
);
}
/// `BulkRequest::new` takes anything string-like, so ids pulled off models
/// (which are `Option<String>`) don't need converting at the call site.
#[test]
fn bulk_request_accepts_owned_and_borrowed_ids() {
let owned: Vec<String> = vec!["abc123".to_string(), "def456".to_string()];
let from_owned = BulkRequest::new("delete", &owned);
let from_borrowed = BulkRequest::new("delete", &["abc123", "def456"]);
assert_eq!(from_owned.ids, from_borrowed.ids);
assert_eq!(from_owned.action, "delete");
}
/// A bulk action whose `ids` match nothing answers HTTP 200 with
/// `{"message": "No Invoices Found"}` — no `data` key at all. That has to
/// read as an empty result, not a decode error.
#[test]
fn list_envelope_without_data_key_decodes_as_empty() {
let body: ListResponse<Invoice> =
serde_json::from_value(json!({"message": "No Invoices Found"})).unwrap();
assert!(body.data.is_empty());
assert!(body.meta.is_none());
}
#[test]
fn list_envelope_still_decodes_data_and_pagination() {
let body: ListResponse<Invoice> = serde_json::from_value(json!({
"data": [{"id": "inv1", "number": "INV-0001"}],
"meta": {"pagination": {
"total": 1, "count": 1, "per_page": 50, "current_page": 1, "total_pages": 1
}},
}))
.unwrap();
assert_eq!(body.data.len(), 1);
assert_eq!(body.data[0].number, "INV-0001");
let pagination = body.meta.unwrap().pagination.unwrap();
assert_eq!(pagination.total, 1);
assert_eq!(pagination.per_page, 50);
}
#[test]
fn document_round_trip_preserves_unknown_fields() {
// `type` is a Rust keyword, so the field is `r#type` but must still map
// to the plain JSON key.
let payload = json!({
"id": "doc1",
"name": "contract.pdf",
"type": "pdf",
"hash": "abc",
"is_default": false,
"custom_value1": "unmodeled field",
});
let doc: Document = serde_json::from_value(payload).unwrap();
assert_eq!(doc.name, "contract.pdf");
assert_eq!(doc.r#type, "pdf");
let round_tripped = serde_json::to_value(&doc).unwrap();
assert_eq!(round_tripped["type"], "pdf");
assert_eq!(round_tripped["custom_value1"], "unmodeled field");
}
// --- list params ------------------------------------------------------
/// The typed fields serialize; `custom` is skipped here because it is
/// applied as a separate query pass (serde_urlencoded can't flatten a map).
#[test]
fn list_params_skips_custom_in_struct_serialization() {
let params = ListParams::new().per_page(25).param("client_id", "abc123");
assert_eq!(
serde_json::to_value(&params).unwrap(),
json!({"per_page": 25})
);
assert_eq!(
params.custom,
vec![("client_id".to_string(), "abc123".to_string())]
);
}
#[test]
fn list_params_custom_pairs_accumulate_and_allow_repeats() {
let params = ListParams::new()
.param("client_id", "abc123")
.param("status", "active")
.param("status", "paid");
assert_eq!(params.custom.len(), 3);
assert_eq!(
params.custom,
vec![
("client_id".to_string(), "abc123".to_string()),
("status".to_string(), "active".to_string()),
("status".to_string(), "paid".to_string()),
]
);
}
/// Both halves of the query must survive urlencoding, since the client
/// serializes the typed struct and the custom pairs in two passes.
#[test]
fn list_params_both_halves_urlencode() {
let params = ListParams::new().per_page(25).sort("created_at|desc");
let typed = serde_urlencoded::to_string(&params).unwrap();
assert!(typed.contains("per_page=25"));
assert!(typed.contains("sort=created_at%7Cdesc"));
let custom = serde_urlencoded::to_string(
&ListParams::new()
.param("client_id", "abc123")
.param("is_deleted", "false")
.custom,
)
.unwrap();
assert_eq!(custom, "client_id=abc123&is_deleted=false");
}
// --- newly added resource groups ---------------------------------------
#[test]
fn company_keeps_settings_untyped_and_preserves_toggles() {
// Company has well over a hundred fields; only the structural ones are
// modeled, and `settings` stays untyped.
let payload = json!({
"id": "co1",
"company_key": "abc",
"settings": {"currency_id": "1", "timezone_id": "15"},
"enabled_modules": 32767,
"show_product_cost": true,
"markdown_enabled": true,
});
let company: Company = serde_json::from_value(payload).unwrap();
assert_eq!(company.company_key, "abc");
assert_eq!(company.enabled_modules, 32767);
assert!(company.markdown_enabled);
assert_eq!(company.settings["currency_id"], "1");
// An unmodeled feature toggle still round-trips.
let round_tripped = serde_json::to_value(&company).unwrap();
assert_eq!(round_tripped["show_product_cost"], true);
assert_eq!(round_tripped["settings"]["timezone_id"], "15");
}
/// System logs are the one resource whose timestamps are date strings
/// rather than integers.
#[test]
fn system_log_timestamps_are_strings() {
let log: SystemLog = serde_json::from_value(json!({
"id": "sl1",
"event_id": 30,
"category_id": 1,
"type_id": 300,
"log": {"message": "gateway declined"},
"created_at": "2026-10-01 12:00:00",
"updated_at": "2026-10-01 12:00:00",
}))
.unwrap();
assert_eq!(log.event_id, 30);
assert_eq!(log.created_at, "2026-10-01 12:00:00");
assert_eq!(log.log["message"], "gateway declined");
}
#[test]
fn new_resource_models_decode() {
let integration: BankIntegration = serde_json::from_value(json!({
"id": "bi1", "bank_account_id": 7788, "balance": 1234.56, "currency": "USD"
}))
.unwrap();
assert_eq!(integration.bank_account_id, 7788);
assert_eq!(integration.balance, 1234.56);
let rule: BankTransactionRule = serde_json::from_value(json!({
"id": "btr1",
"name": "Coffee",
"matches_on_all": true,
"rules": [{"search_key": "description", "operator": "contains", "value": "cafe"}],
}))
.unwrap();
assert_eq!(rule.rules.len(), 1);
assert_eq!(rule.rules[0]["operator"], "contains");
let gateway: CompanyGateway = serde_json::from_value(json!({
"id": "cg1", "gateway_key": "d14dd26a", "accepted_credit_cards": 32, "config": "encrypted"
}))
.unwrap();
assert_eq!(gateway.accepted_credit_cards, 32);
assert_eq!(gateway.config, "encrypted");
let tag: Tag = serde_json::from_value(json!({
"id": "t1", "name": "urgent", "color": "#ff0000", "entity_type": "invoice"
}))
.unwrap();
assert_eq!(tag.name, "urgent");
let location: Location = serde_json::from_value(json!({
"id": "loc1", "name": "Warehouse", "city": "Brisbane", "is_shipping_location": true
}))
.unwrap();
assert!(location.is_shipping_location);
let scheduler: TaskScheduler = serde_json::from_value(json!({
"id": "ts1", "job": "email_report", "repeat_every": "WEEK",
"start_from": 1700000000i64, "report_keys": ["date", "amount"],
}))
.unwrap();
assert_eq!(scheduler.report_keys, vec!["date", "amount"]);
let token: CompanyToken = serde_json::from_value(json!({
"id": "tok1", "name": "CI token", "token": "secret", "is_system": false
}))
.unwrap();
assert_eq!(token.name, "CI token");
let gw_token: ClientGatewayToken = serde_json::from_value(json!({
"id": "cgt1", "client_id": "abc123", "token": "card_x", "is_default": true
}))
.unwrap();
assert!(gw_token.is_default);
let group: GroupSetting = serde_json::from_value(json!({
"id": "gs1", "name": "Wholesale", "settings": {"currency_id": "2"}
}))
.unwrap();
assert_eq!(group.settings["currency_id"], "2");
let design: Design = serde_json::from_value(json!({
"id": "d1", "name": "Clean", "is_custom": false, "is_active": true
}))
.unwrap();
assert!(design.is_active);
let user: User = serde_json::from_value(json!({
"id": "u1", "first_name": "Ada", "email": "ada@example.com", "passkey_count": 2
}))
.unwrap();
assert_eq!(user.first_name, "Ada");
assert_eq!(user.passkey_count, 2);
}
/// `Design.design` is typed as a string upstream but returned as a nested
/// object, so it is left unmodeled and must survive in `extra`.
#[test]
fn design_body_round_trips_through_extra() {
let payload = json!({
"id": "d1",
"name": "Custom",
"design": {"header": "<h1>", "body": "<div>", "footer": "<p>"},
});
let design: Design = serde_json::from_value(payload).unwrap();
let round_tripped = serde_json::to_value(&design).unwrap();
assert_eq!(round_tripped["design"]["header"], "<h1>");
}
#[test]
fn statics_round_trip_preserves_unmodeled_tables_and_fields() {
let payload = json!({
"currencies": [{ "id": 1, "name": "US Dollar", "code": "USD", "precision": 2 }],
"countries": [{ "id": 840, "name": "United States", "iso_3166_3": "USA" }],
"gateways": [{ "id": 1, "name": "Stripe" }],
"some_future_table": [{ "x": 1 }]
});
let statics: Statics = serde_json::from_value(payload).unwrap();
let back = serde_json::to_value(&statics).unwrap();
assert_eq!(back["countries"][0]["iso_3166_3"], "USA");
assert_eq!(back["some_future_table"][0]["x"], 1);
assert_eq!(back["gateways"][0]["name"], "Stripe");
assert_eq!(
back["currencies"][0]["id"], "1",
"ids are normalised to strings on the way back out"
);
}
#[test]
fn search_results_round_trip_preserves_unknown_buckets() {
let payload = json!({
"clients": [{ "name": "Acme", "type": "/client", "id": "a1", "path": "/clients/a1" }],
"future_bucket": [{ "name": "x" }]
});
let results: SearchResults = serde_json::from_value(payload).unwrap();
assert_eq!(results.clients[0].r#type, "/client");
let back = serde_json::to_value(&results).unwrap();
assert_eq!(back["future_bucket"][0]["name"], "x");
assert_eq!(back["clients"][0]["type"], "/client");
}
#[test]
fn health_check_round_trip_preserves_unknown_fields() {
let payload = json!({
"system_health": false,
"api_version": "5.8.56",
"queue_data": { "pending": 3 },
"brand_new_probe": true
});
let health: HealthCheck = serde_json::from_value(payload).unwrap();
assert_eq!(health.system_health, Some(false));
let back = serde_json::to_value(&health).unwrap();
assert_eq!(back["brand_new_probe"], true);
assert_eq!(back["queue_data"]["pending"], 3);
}
#[test]
fn statics_ids_accept_both_strings_and_numbers() {
let payload = json!({
"payment_types": [{ "id": "1", "name": "Bank Transfer" }],
"industries": [{ "id": 3, "name": "Advertising" }],
"sizes": [{ "id": null, "name": "Unknown" }]
});
let statics: Statics = serde_json::from_value(payload).unwrap();
assert_eq!(statics.payment_types[0].id, "1");
assert_eq!(statics.industries[0].id, "3");
assert_eq!(statics.sizes[0].id, "", "a null id decodes as empty");
}