Compare commits
8
Commits
ff9a859d0b
...
a7d8300ce9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a7d8300ce9 | ||
|
|
fbde9a9bb7 | ||
|
|
935690c8f0 | ||
|
|
aa802bc40c | ||
|
|
ad9553516e | ||
|
|
8ff947455e | ||
|
|
23c660d2f9 | ||
|
|
9976249210 |
@@ -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
@@ -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.
|
||||||
@@ -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
@@ -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
@@ -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
@@ -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"
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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`.
|
||||||
|
|||||||
@@ -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
File diff suppressed because it is too large
Load Diff
+35
-2
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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>,
|
||||||
|
}
|
||||||
@@ -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>,
|
||||||
|
}
|
||||||
@@ -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
@@ -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)]
|
||||||
|
|||||||
@@ -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
@@ -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(),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>,
|
||||||
|
}
|
||||||
@@ -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>,
|
||||||
|
}
|
||||||
@@ -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>,
|
||||||
|
}
|
||||||
@@ -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>,
|
||||||
|
}
|
||||||
@@ -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
@@ -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>,
|
||||||
|
|||||||
@@ -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>,
|
||||||
|
}
|
||||||
@@ -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>,
|
||||||
|
}
|
||||||
@@ -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
@@ -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)]
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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)]
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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>,
|
||||||
|
|||||||
@@ -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>,
|
||||||
|
}
|
||||||
@@ -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>,
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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>,
|
||||||
|
}
|
||||||
@@ -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>,
|
||||||
|
}
|
||||||
@@ -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>,
|
||||||
|
}
|
||||||
@@ -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>,
|
||||||
|
}
|
||||||
@@ -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
@@ -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>,
|
||||||
|
|||||||
@@ -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>,
|
||||||
|
}
|
||||||
@@ -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>,
|
||||||
|
}
|
||||||
@@ -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>,
|
||||||
|
}
|
||||||
@@ -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
@@ -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)]
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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(¶ms).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(¶ms).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");
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user