Compare commits
29
Commits
ff9a859d0b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
20ac306c26 | ||
|
|
1a8e4aad26 | ||
|
|
ab696a910b | ||
|
|
fb670cbb08 | ||
|
|
48244e8390 | ||
|
|
d51de3fc63 | ||
|
|
446617a728 | ||
|
|
c4f31743f0 | ||
|
|
e6e41fe5c6 | ||
|
|
2532b83a32 | ||
|
|
e7c9663621 | ||
|
|
0485272080 | ||
|
|
a7d9a2d9d6 | ||
|
|
46a6ea1322 | ||
|
|
83270e8262 | ||
|
|
f616da08db | ||
|
|
bd2d34cae9 | ||
|
|
0002d32197 | ||
|
|
9e7d81307d | ||
|
|
e4f2373b53 | ||
|
|
355cf08324 | ||
|
|
a7d8300ce9 | ||
|
|
fbde9a9bb7 | ||
|
|
935690c8f0 | ||
|
|
aa802bc40c | ||
|
|
ad9553516e | ||
|
|
8ff947455e | ||
|
|
23c660d2f9 | ||
|
|
9976249210 |
@@ -4,19 +4,65 @@ on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
schedule:
|
||||
# Mondays 06:00 UTC. A new Rust release lands on its own schedule, so
|
||||
# without this the advisory `stable` job below only runs when someone
|
||||
# happens to push, and breakage can sit unnoticed for weeks.
|
||||
- cron: '0 6 * * 1'
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
# Nothing it covers can change on a timer: the toolchain is pinned and
|
||||
# the dependencies are locked. The weekly run is for `stable` only.
|
||||
if: github.event_name != 'schedule'
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- 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:
|
||||
components: rustfmt, clippy
|
||||
- run: cargo build --all-targets
|
||||
- run: cargo test
|
||||
- run: cargo fmt --check
|
||||
- run: cargo clippy --all-targets -- -D warnings
|
||||
|
||||
msrv:
|
||||
name: Build on the declared MSRV
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name != 'schedule'
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
# Must match `rust-version` in Cargo.toml 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
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
name: Smoke test
|
||||
|
||||
# Runs `examples/smoke_test.rs` against a real Invoice Ninja instance. Manual
|
||||
# only: it needs an instance the runner can reach and credentials for it, and
|
||||
# the offline suite in `ci.yml` is what gates merges. This is for catching the
|
||||
# places where the real server disagrees with the upstream source the mocks
|
||||
# were written from.
|
||||
#
|
||||
# Repository secrets required: IN_BASE_URL (the API root, ending /api/v1) and
|
||||
# IN_API_TOKEN. Point them at a disposable instance, never at production.
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
write_host:
|
||||
description: >-
|
||||
Leave empty for the read-only tier (GET requests only). To also run
|
||||
the write tier, type the host of IN_BASE_URL (e.g. `localhost`). It
|
||||
creates and deletes records, so the instance must be disposable.
|
||||
required: false
|
||||
default: ''
|
||||
type: string
|
||||
runner:
|
||||
description: Runner label; use a self-hosted one if the instance is not public.
|
||||
required: false
|
||||
default: ubuntu-latest
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
smoke:
|
||||
runs-on: ${{ inputs.runner }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
# Same pin as `ci.yml` and `devVersion` in flake.nix.
|
||||
- uses: dtolnay/rust-toolchain@1.99.1
|
||||
- run: cargo run --example smoke_test
|
||||
env:
|
||||
IN_BASE_URL: ${{ secrets.IN_BASE_URL }}
|
||||
IN_API_TOKEN: ${{ secrets.IN_API_TOKEN }}
|
||||
# The program refuses to write unless this equals the host in
|
||||
# IN_BASE_URL, so a mistyped host skips the write tier.
|
||||
IN_SMOKE_WRITE_HOST: ${{ inputs.write_host }}
|
||||
# Optional. Only used inside the write tier, where it enables the
|
||||
# password tier (merge two throwaway clients, purge the survivor).
|
||||
IN_API_PASSWORD: ${{ secrets.IN_API_PASSWORD }}
|
||||
+222
@@ -0,0 +1,222 @@
|
||||
# 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
|
||||
|
||||
- Logout and passkey management: `logout`, `list_passkeys`, and
|
||||
`delete_passkey`. `logout` regenerates every system-issued token on the
|
||||
whole company, not just the caller's, so it ends every session sharing
|
||||
it. Passkey *registration* is not wrapped — it needs a WebAuthn ceremony
|
||||
only a browser can drive. Adds `Passkey`.
|
||||
- `InvoiceNinjaClient::login`, an associated function (not a method, since
|
||||
there is no token yet to build a client with) that exchanges an email and
|
||||
password for an API token, optionally with a one-time password for
|
||||
two-factor accounts. Adds `LoginResponse` and `LoginCompanyUser`; the
|
||||
nested company/user/account data the server also returns is left in
|
||||
`extra` rather than modelled. Does not cover passkey login, SSO, or
|
||||
`/login/precheck`.
|
||||
|
||||
## [0.2.0] - 2026-10-25
|
||||
|
||||
### Added
|
||||
|
||||
- E-invoicing, seventeen methods: `validate_einvoice`,
|
||||
`einvoice_configurations`, `einvoice_quota`, `einvoice_health_check`,
|
||||
`einvoice_delivery_map`, `update_einvoice_token`, and eleven `peppol_*`.
|
||||
Three behave unlike the rest of the API, and the docs on each say so: a
|
||||
failing `validate_einvoice` is a `422`, so an ordinary "not Peppol-ready"
|
||||
result arrives as `Error::Api`; the Peppol handlers return the error
|
||||
message as the whole body rather than `{"message": …}`, so `Error::Api`'s
|
||||
`errors` map is empty for them; and `peppol_setup` has two success shapes,
|
||||
hence `Option<Value>`. `peppol_remove_legal_identifier` is the crate's
|
||||
only `DELETE` with a request body.
|
||||
- Integrations, sixteen methods: four `quickbooks_*`, four `stripe_*` (all
|
||||
password-protected), six `calendar_connection` methods, plus
|
||||
`yodlee_account_status` and `nordigen_institutions`. These operate a
|
||||
connection that already exists — establishing one is an OAuth browser flow
|
||||
whose routes take no API token and cannot be wrapped.
|
||||
- Import and export, five methods: `export`, `preimport`, `import`,
|
||||
`import_json`, and `upload_edocument`. A CSV import is two calls —
|
||||
`preimport` uploads the files as `files[<entity_type>]` and returns the
|
||||
column mapping the server suggests, then `import` starts the ingest from
|
||||
the returned `hash`. The route's one-shot form (files and mapping in a
|
||||
single multipart request) is deliberately not wrapped: a mapping is nested,
|
||||
and bracket-encoding nested structures is what this crate avoids by sending
|
||||
JSON. `upload_edocument` imports expenses from e-documents and is not a
|
||||
document attachment, which is why it is not an `upload_*` method. Adds
|
||||
`ExportJob` (the export's collection URL expires in 1–5 hours) and
|
||||
`ImportPreview`. All of these queue work, so a success means accepted, not
|
||||
done.
|
||||
- Invitation-key downloads, eight methods:
|
||||
`download_invoice_by_invitation_key` and
|
||||
`download_e_invoice_by_invitation_key`, plus the same pair for quotes,
|
||||
credits, and purchase orders. These take the per-contact invitation key
|
||||
from a client-portal link rather than a record id, and return the PDF or
|
||||
the XML e-document as bytes. Prefer them over the id-based `download_*`
|
||||
when the document is for a particular contact: the server renders in that
|
||||
contact's locale, so a client with contacts in two languages gets a
|
||||
different document per key. An unknown key is a `400` carrying `no record
|
||||
found`. `COVERAGE.md` previously listed these as out of scope on the
|
||||
grounds that they need no auth token; that was a misreading — they are
|
||||
ordinary authenticated `/api/v1` routes.
|
||||
- Document uploads: `upload_client`, `upload_invoice`, `upload_expense`, and
|
||||
thirteen more, one for each resource with an `/{id}/upload` route. They
|
||||
take a `Vec<FileUpload>` and an optional `is_public`, and return the
|
||||
refreshed record. Uploads are never retried, since a replay could attach
|
||||
the file twice. This is the only way the API creates a document, so there
|
||||
is no `create_document`. Enables reqwest's `multipart` feature, which adds
|
||||
`mime`, `mime_guess`, and `unicase` and leaves the MSRV at 1.88.
|
||||
- Per-resource extras, nineteen methods: `retry_webhook`, `sort_tasks`,
|
||||
`set_default_design`, `set_default_client_gateway_token`,
|
||||
`subscription_steps`, `check_subscription_steps`, the company-gateway
|
||||
`test`/`clone`/`import_customers` trio, four bank-integration and
|
||||
bank-transaction routes, and six client routes (`update_client_tax_data`,
|
||||
`client_show_settings`, `client_peppol_discovery`, `check_client_vat`,
|
||||
`client_vat_status`, `client_documents`). Each is a `POST` unless noted in
|
||||
`COVERAGE.md`, so none is retried on a 5xx. Adds `MessageResponse` for the
|
||||
`{"message": ...}` replies and `SubscriptionStep`.
|
||||
- Password-protected actions, eight methods: `purge_client`, `merge_clients`,
|
||||
`merge_vendors`, `purge_company`, `purge_company_save_settings`,
|
||||
`invite_user`, `purge_user`, and `new_user_template`. They need
|
||||
`with_api_password`, most cannot be undone, and none is retried on a 5xx.
|
||||
A merge keeps the first id and folds the second into it. Checked against
|
||||
the upstream controllers and mock servers; not yet against a real instance.
|
||||
- Activity log, email history and previews, fourteen methods:
|
||||
`list_activities`, `list_all_activities`, `activity_feed`,
|
||||
`entity_activity`, `add_activity_note`, `download_activity_entity`,
|
||||
`client_email_history`, `entity_email_history`, and the previews `preview`,
|
||||
`preview_html`, `live_preview`, `live_design`, `preview_purchase_order` and
|
||||
`live_preview_purchase_order`. Adds `Activity`, `ActivityFeedEntry` and
|
||||
`ActivityRef`. Email history is returned as raw JSON. See `COVERAGE.md` for
|
||||
which were confirmed against a real server and the easy mistakes (a
|
||||
singular entity name in one route, a plural in the next).
|
||||
- `FileUpload` and `Error::InvalidUpload`. An upload with no files, a nameless
|
||||
file, or an unparseable MIME type is refused locally, because the server
|
||||
answers an empty upload with a success that changed nothing.
|
||||
- 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` — 325 methods (including document uploads, per-resource extras, password-protected actions, and activity, email-history and preview routes). 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
|
||||
|
||||
- **The crate is renamed from `invoiceninjars` to `invoiceninja-sdk`**, to
|
||||
match the repository it has always lived in
|
||||
(`gitea.awkawb.cloud/awkawb/invoiceninja-sdk`). Depend on it as
|
||||
`invoiceninja-sdk` in `Cargo.toml`, and import it as `invoiceninja_sdk`
|
||||
(Cargo lowercases a package name's hyphens to underscores for the crate
|
||||
identifier) — every `use invoiceninjars::...` becomes
|
||||
`use invoiceninja_sdk::...`. Not yet published to crates.io, so this
|
||||
lands before the name was ever claimed there.
|
||||
- `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.
|
||||
- `Error::Http` now renders its whole cause chain. `reqwest`'s own message
|
||||
stops at "error sending request for url (...)", leaving out the part that
|
||||
says why — "Connection refused", a DNS failure, a TLS handshake error — so
|
||||
anyone printing the error had no way to tell which it was.
|
||||
|
||||
### 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.
|
||||
- Creating a recurring invoice or recurring expense failed after the record
|
||||
had been created: a real server returns `frequency_id` as the string `"5"`
|
||||
and the field was a strict integer. It now accepts a number or a numeric
|
||||
string.
|
||||
- Creating a purchase order, location, or webhook from a default model was
|
||||
rejected by a real server. An unset `PurchaseOrder.status_id`,
|
||||
`Location.country_id`, or `Webhook.format` was sent as `""`, which the
|
||||
server turns into `NULL` and then refuses (a 500 for the purchase order and
|
||||
webhook, a 422 for the location). They are now left out of the request when
|
||||
empty.
|
||||
- `with_api_password` refused a password containing a control character (a
|
||||
newline, say) with a misleading `InvalidToken` error. Such a password is
|
||||
now sent base64-encoded as `X-API-PASSWORD-BASE64`, the form the server's
|
||||
middleware accepts for exactly this.
|
||||
- A payment whose `invoices` entry had a `null` or missing `invoice_id` or
|
||||
`amount` failed to decode the whole payment. Both now default.
|
||||
- `list_system_logs` failed to decode against a real server. `SystemLog`'s
|
||||
`created_at` and `updated_at` were typed as strings, following the OpenAPI
|
||||
spec, but the server sends integer timestamps. They are now `i64`, like
|
||||
every other model.
|
||||
- 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.
|
||||
|
||||
**`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
|
||||
|
||||
This project uses Nix for toolchain management (Rust via `rust-overlay`):
|
||||
@@ -14,6 +22,61 @@ This project uses Nix for toolchain management (Rust via `rust-overlay`):
|
||||
nix develop
|
||||
```
|
||||
|
||||
`flake.nix` also has `packages.default`: `nix build` compiles with the
|
||||
pinned dev toolchain and runs the full test suite, including doctests,
|
||||
inside the Nix sandbox. The crate has no `[[bin]]` target (see the "No
|
||||
CLI" non-goal in `GOALS.md`), so the resulting store path is empty —
|
||||
`cargoInstallHook` has no binary to copy out of `target/release`. Treat
|
||||
`nix build` as a build-and-test gate, not a source of an installable
|
||||
artifact.
|
||||
|
||||
### 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
|
||||
|
||||
```sh
|
||||
@@ -29,44 +92,323 @@ IN_API_TOKEN=... cargo run --example list_clients
|
||||
```
|
||||
|
||||
Tests live in `tests/` (integration-style, no network access required);
|
||||
`src/` has no `#[test]` modules.
|
||||
`src/` has no `#[test]` modules. Four files:
|
||||
|
||||
- `model_roundtrip.rs` — the serde layer, from hand-written payloads.
|
||||
- `client_requests.rs` — the request plumbing (pagination, path escaping,
|
||||
headers, timeouts, retry, error mapping, one test per endpoint extra)
|
||||
against a `wiremock` server on loopback.
|
||||
- `decode_tolerance.rs` — for every model, serialises `Default`, then nulls
|
||||
and removes each field in turn and requires the result to still decode.
|
||||
A new model belongs in its list; a new non-`Option` field without
|
||||
`null_to_default` fails it.
|
||||
- `real_shapes.rs` — decodes `tests/fixtures/*.json`, which are the *shapes*
|
||||
(keys and JSON types, no values) of responses captured from a real server,
|
||||
and checks a decode + re-serialise loses no key. It also refuses fixtures
|
||||
that hold any real value, and fixtures with no test.
|
||||
|
||||
No credentials or internet needed for any of them.
|
||||
|
||||
**Why the fixtures matter:** twice, mocks written from the upstream source
|
||||
passed while a real server disagreed (system-log timestamps; the
|
||||
`/subscriptions/steps` shape), and `frequency_id` and several empty-string
|
||||
fields were wrong the same way. When the smoke test finds a divergence,
|
||||
refresh the fixtures so it stays caught:
|
||||
|
||||
```sh
|
||||
# 1. Fill a DISPOSABLE instance with one row of each kind (writes!).
|
||||
IN_SMOKE_WRITE_HOST=localhost IN_BASE_URL=... IN_API_TOKEN=... \
|
||||
cargo run --example seed_sample_data
|
||||
# 2. Capture shapes (GET only). Review `git diff tests/fixtures` before committing.
|
||||
IN_BASE_URL=... IN_API_TOKEN=... cargo run --example capture_shapes
|
||||
```
|
||||
|
||||
A resource with no rows is skipped, so the fixture set only covers what the
|
||||
instance held. Bank integrations, company gateways, client gateway tokens,
|
||||
subscriptions, task schedulers, and recurring quotes are not captured: they
|
||||
need external accounts, or (recurring quotes on 5.13.50) the server's own
|
||||
insert fails.
|
||||
|
||||
**Fields omitted when empty.** The server runs `ConvertEmptyStringsToNull`,
|
||||
so an unset `String` serialised as `""` becomes `NULL`, which a non-null
|
||||
column or an `integer` rule then rejects. `Location.country_id`,
|
||||
`PurchaseOrder.status_id` and `Webhook.format` therefore carry
|
||||
`skip_serializing_if = "String::is_empty"`. If a create from a `Default`
|
||||
model 500s or 422s on an empty field, do the same — and add the path to
|
||||
`OMITTED_WHEN_EMPTY` in `tests/real_shapes.rs` and `examples/smoke_test.rs`,
|
||||
which is deliberately a list of decisions rather than a blanket exemption.
|
||||
|
||||
### Testing against a real server
|
||||
|
||||
Everything above is checked against mock servers written from the upstream
|
||||
source, so it cannot catch a place where the real server disagrees with that
|
||||
reading. `examples/smoke_test.rs` can:
|
||||
|
||||
```sh
|
||||
# Read-only (GET requests only). IN_BASE_URL has no default, on purpose.
|
||||
IN_BASE_URL=http://localhost:8080/api/v1 IN_API_TOKEN=... \
|
||||
cargo run --example smoke_test
|
||||
|
||||
# Adds a write tier: create a throwaway client, upload files to it, delete it.
|
||||
IN_SMOKE_WRITE_HOST=localhost IN_BASE_URL=... IN_API_TOKEN=... \
|
||||
cargo run --example smoke_test
|
||||
```
|
||||
|
||||
**Never point the write tier at a production server.** The guard is that
|
||||
`IN_SMOKE_WRITE_HOST` must equal the host in `IN_BASE_URL`, so a write only
|
||||
reaches a server that was named explicitly. Even after its delete the write
|
||||
tier leaves a gap in the client number sequence and a soft-deleted row (a
|
||||
real purge needs the account password), and it may fire webhooks. Use a
|
||||
disposable instance — a local VM or container — for it.
|
||||
|
||||
Beyond "does it decode", the read tier fetches the same rows raw and fails
|
||||
if a typed row lost a key (`keys kept: <resource>`, names only, never
|
||||
values), and the write tier creates a purchase order, location, webhook and
|
||||
recurring invoice from bare `Default` models, since those are what broke.
|
||||
The read tier also downloads an invoice by invitation key, which is there to
|
||||
re-check the claim that those routes are token-authenticated; the matching
|
||||
`download_e_*` is left out, since it renders from e-invoice settings a test
|
||||
instance need not have.
|
||||
With `IN_API_PASSWORD` set as well, a password tier merges two throwaway
|
||||
clients and purges the survivor (a real, permanent delete); it was run
|
||||
against a disposable 5.13.50 instance on 2026-10-24 and all four checks
|
||||
pass. `.github/workflows/smoke.yml` runs it on demand (`workflow_dispatch` only,
|
||||
needing `IN_BASE_URL` and `IN_API_TOKEN` secrets); the write tier runs only if
|
||||
the `write_host` input names the instance's host.
|
||||
|
||||
It prints PASS/FAIL per check and never the contents of a record. The upload
|
||||
wire format (`POST` plus `_method=PUT`) was read from the framework source,
|
||||
then confirmed by running the write tier against a disposable Invoice Ninja
|
||||
5.13.50 instance (2026-10-24): single and multi-file uploads both attached.
|
||||
|
||||
## 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
|
||||
|
||||
- `src/client.rs` — `InvoiceNinjaClient` holds a `reqwest::Client` (with the
|
||||
`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
|
||||
dispatch and decode `ListResponse<T>` / `ItemResponse<T>` envelopes, mapping
|
||||
non-2xx responses through `api_error` into `Error::Api`.
|
||||
(`get_list`, `get_one`, `post_one`, `put_one`, `delete_one`, `post_list`)
|
||||
handle request dispatch and decode `ListResponse<T>` / `ItemResponse<T>`
|
||||
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.)
|
||||
are generated by the `resource_methods!` macro at the bottom of
|
||||
`client.rs`, which expands to
|
||||
`list`/`list_all`/`get`/`create`/`update`/`delete` for a given REST path and
|
||||
model type. **To add a new resource**: add a model under `src/models/`,
|
||||
re-export it from `src/models/mod.rs`, and add one `resource_methods!`
|
||||
invocation in `client.rs`. Note the macro takes six method names before the
|
||||
path and model — adding a resource means passing all six.
|
||||
`list`/`list_all`/`get`/`create`/`update`/`delete`/`bulk` for a given REST
|
||||
path and model type. **To add a new resource**: add a model under
|
||||
`src/models/`, re-export it from `src/models/mod.rs`, and add one
|
||||
`resource_methods!` invocation in `client.rs`. Note the macro takes seven
|
||||
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.
|
||||
- `invitation_download_methods!` generates `download_{x}_by_invitation_key`
|
||||
and `download_e_{x}_by_invitation_key` for the same four resources. Two
|
||||
traps, both pinned by tests: the entity segment is **singular**
|
||||
(`invoice/{key}/download`, the only route family in the API that is), and
|
||||
the routes are **authenticated** despite being the link a client follows —
|
||||
they sit in `routes/api.php`'s `api/v1` group under `token_auth`. The
|
||||
genuinely token-less invitation downloads are the separate client-portal
|
||||
routes in `routes/client.php`, which are not under `/api/v1` and are not
|
||||
wrapped. `COVERAGE.md` used to list the `/api/v1` eight as unwrapped for
|
||||
that reason, having conflated the two families; don't reintroduce the
|
||||
claim.
|
||||
- `upload_methods!` generates `upload_{x}` for the 16 resources with a
|
||||
`/{id}/upload` route; documents can only be created this way, because
|
||||
`DocumentController::store` is an empty stub upstream (hence no
|
||||
`create_document`). Three things are easy to get wrong, all verified
|
||||
against the upstream source: the request is a **`POST` with
|
||||
`_method=PUT`** (PHP does not parse multipart on a real `PUT`, and the
|
||||
spec's `_method=POST` would 405); `is_public` goes out as **`1`/`0`**
|
||||
(Laravel's strict `boolean` rule rejects the string `"true"`); and uploads
|
||||
are **never retried**, because reqwest's multipart body is not cloneable
|
||||
and `execute` gives an un-cloneable request exactly one attempt — a test
|
||||
pins this, since nothing in the types would notice if it changed. Don't
|
||||
"fix" any of the three.
|
||||
- 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.
|
||||
- **The import, e-invoicing and integration blocks return
|
||||
`serde_json::Value` on purpose.** They are pass-throughs from Storecove,
|
||||
Stripe, Yodlee or GoCardless, or arrays built for Invoice Ninja's React
|
||||
client; none has a documented schema and they move on the provider's
|
||||
release schedule. Don't "finish the job" by typing them — the same
|
||||
reasoning that keeps `chart` generic applies, and `COVERAGE.md` records it
|
||||
as the crate's largest deliberate untyped surface. `ExportJob` and
|
||||
`ImportPreview` are typed because their one useful field (the URL, the
|
||||
hash) is the whole point of the call.
|
||||
- Three dispatch helpers exist only for that surface: `send_unwrapped` for a
|
||||
`PUT` or `DELETE` that carries a body (`peppol_remove_legal_identifier` is
|
||||
the crate's only `DELETE` with one), `post_unwrapped_opt` for a route that
|
||||
answers either JSON or a bare `204` (`peppol_setup`), and
|
||||
`multipart_unwrapped` for the import uploads, whose form field is
|
||||
`files[<entity_type>]` rather than `documents[]` and whose reply has no
|
||||
`data` envelope. All three go through `req`/`execute` like everything else.
|
||||
- `list_*` returns one page; `list_all_*` goes through `get_all_pages`, which
|
||||
follows `page`/`per_page` until a page returns fewer rows than requested.
|
||||
- `src/models/` — one file per resource (`client.rs`, `invoice.rs`,
|
||||
`payment.rs`, `product.rs`, `expense.rs`, `project.rs`, `task.rs`,
|
||||
`vendor.rs`), plus `common.rs` for shared envelope/pagination types
|
||||
(`ListParams`, `ListResponse`, `ItemResponse`, `Meta`, `Pagination`).
|
||||
Invoice Ninja stores quotes and credits in the same table as invoices (an
|
||||
`entity_type` field distinguishes them), so `Quote` and `Credit` are type
|
||||
aliases for `Invoice` (`src/models/invoice.rs`) and share the
|
||||
`/quotes`, `/credits`, and `/invoices` paths via the macro.
|
||||
terminates on `meta.pagination.total_pages` and only falls back to the
|
||||
short-page heuristic when the server sends no metadata. **The heuristic is
|
||||
not sound on its own**: `BaseController` caps `per_page` at
|
||||
`min(abs(per_page), 5000)`, so a page can be shorter than requested while
|
||||
records remain — which is why `per_page` is clamped to 5000 before the
|
||||
walk starts. `per_page(0)` falls back to the default size (it would make
|
||||
the short-page check `got < 0`, which never terminates), the page counter
|
||||
advances locally rather than trusting the echoed `current_page`, an empty
|
||||
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>`
|
||||
field. This preserves any API fields not explicitly modeled, so a `get`
|
||||
followed by an `update` round-trips without silently dropping data. Keep
|
||||
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
|
||||
decode, API error responses (with per-field validation `errors` map), and
|
||||
client construction errors. `ApiErrorBody` mirrors Invoice Ninja's error
|
||||
JSON shape.
|
||||
- **A missing record is a `400`, not a `404`.** `Handler.php` maps Laravel's
|
||||
`ModelNotFoundException` to a 400 carrying `No query results for model
|
||||
[App\Models\X].`, and keeps 404 for a route that does not exist or a method
|
||||
it does not support. So don't write a not-found check against 404 — the
|
||||
smoke test's post-purge assertion did, and passed only because it had
|
||||
never been run. Match the status together with the message.
|
||||
|
||||
## Coverage
|
||||
|
||||
Clients, Invoices, Quotes, Credits, Payments, Products, Expenses, Projects,
|
||||
Tasks, and Vendors, each with `list`, `list_all`, `get`, `create`, `update`,
|
||||
and `delete`.
|
||||
**All 34 of the API's `Route::resource` groups are wrapped**, plus `users`
|
||||
(which isn't one). 375 methods: 30 resources with CRUD + bulk, 3 with CRUD
|
||||
only, 4 with entity actions, 4 with invitation-key downloads, 16 with
|
||||
document uploads, and 111 hand-written.
|
||||
|
||||
Plus the non-CRUD surface: documents, entity actions and PDF downloads for
|
||||
the four action-capable resources, their invitation-key PDF and e-document
|
||||
downloads, `report`, `chart`, `search`, `statics`, `send_email`,
|
||||
`refund_payment`, `ping`, `health_check`, import/export, the 17 e-invoicing
|
||||
routes, the QuickBooks, Stripe, calendar, Yodlee and GoCardless
|
||||
integrations, `logout`, passkey list/delete, and a standalone `login`.
|
||||
|
||||
**`GOALS.md`'s roadmap is closed** — every route an API-token client can
|
||||
reach is wrapped. What is left out is left out for a reason, not pending:
|
||||
passkey registration and passkey login (WebAuthn ceremonies only a browser
|
||||
can drive), SSO (`oauth_login`, `oidc/*`) and `/login/precheck`,
|
||||
`/refresh`/`/refresh_react` (the bulk sync-dump shape parked as its own open
|
||||
question in `GOALS.md`, not an auth concept), `/connected_account` (Gmail
|
||||
OAuth linking — an integration, not login/logout), the client-portal family
|
||||
and the OAuth and provider-webhook routes (all outside `/api/v1`, so they
|
||||
don't fit the client's `base_url` or its token), `POST /quickbooks/settings`
|
||||
(a registered route with no controller method, so it 500s), and a few
|
||||
company and user extras.
|
||||
|
||||
**`COVERAGE.md` is the detailed inventory** — every wrapped endpoint, every
|
||||
gap, and the reason for each gap.
|
||||
|
||||
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.
|
||||
|
||||
+668
@@ -0,0 +1,668 @@
|
||||
# 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). 375 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` |
|
||||
|
||||
`create_recurring_quote` currently 500s against Invoice Ninja 5.13.50: the
|
||||
server's own insert names a `design_id` column its `recurring_quotes` table
|
||||
does not have. The request is well-formed; nothing here can work around it.
|
||||
|
||||
`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`.
|
||||
|
||||
## Invitation-key downloads
|
||||
|
||||
The same four resources also answer two downloads keyed by **invitation key**
|
||||
rather than record id — the per-contact token that appears in a client-portal
|
||||
link. Eight routes, all wrapped:
|
||||
|
||||
| Route | Method | Returns |
|
||||
| --- | --- | --- |
|
||||
| `GET /invoice/{key}/download` | `download_invoice_by_invitation_key` | `application/pdf` |
|
||||
| `GET /invoice/{key}/download_e_invoice` | `download_e_invoice_by_invitation_key` | `application/xml` |
|
||||
| `GET /quote/{key}/download` | `download_quote_by_invitation_key` | `application/pdf` |
|
||||
| `GET /quote/{key}/download_e_quote` | `download_e_quote_by_invitation_key` | `application/xml` |
|
||||
| `GET /credit/{key}/download` | `download_credit_by_invitation_key` | `application/pdf` |
|
||||
| `GET /credit/{key}/download_e_credit` | `download_e_credit_by_invitation_key` | `application/xml` |
|
||||
| `GET /purchase_order/{key}/download` | `download_purchase_order_by_invitation_key` | `application/pdf` |
|
||||
| `GET /purchase_order/{key}/download_e_purchase_order` | `download_e_purchase_order_by_invitation_key` | `application/xml` |
|
||||
|
||||
Two things about these are easy to get wrong, and both are pinned by tests.
|
||||
The entity segment is **singular** — `invoice/…`, not `invoices/…` — which
|
||||
is the only place in the API that departs from the plural CRUD path. And
|
||||
despite being the download a *client* follows, they are **authenticated**:
|
||||
the routes sit inside `routes/api.php`'s `api/v1` group under its
|
||||
`token_auth` middleware, so they need the API token like any other call.
|
||||
|
||||
This file previously listed them as unwrapped on the grounds that they take
|
||||
no auth token. That was wrong — it conflated them with the client-portal
|
||||
routes in `routes/client.php` (`/client/invoice/{key}/download_pdf` and
|
||||
friends), which are a separate route family, are not under `/api/v1`, and
|
||||
remain unwrapped for that reason.
|
||||
|
||||
The smoke test's read tier exercises the invoice PDF, taking a key from the
|
||||
`invitations` array the list route already returns. The `download_e_*` four
|
||||
are not exercised: they render from the company's e-invoice settings, which a
|
||||
test instance need not have configured.
|
||||
|
||||
An unknown or deleted key is a `400` carrying `no record found`. The
|
||||
controllers return that directly rather than raising
|
||||
`ModelNotFoundException`, so the message differs from the `No query results
|
||||
for model` text a missing record produces elsewhere, but the status is the
|
||||
same 400.
|
||||
|
||||
## Import and export
|
||||
|
||||
| Route | Method | Returns |
|
||||
| --- | --- | --- |
|
||||
| `POST /export` | `export` | `ExportJob` |
|
||||
| `POST /preimport` | `preimport` | `ImportPreview` |
|
||||
| `POST /import` | `import` | `MessageResponse` |
|
||||
| `POST /import_json` | `import_json` | `MessageResponse` |
|
||||
| `PUT /edocument/upload` | `upload_edocument` | `MessageResponse` |
|
||||
|
||||
A CSV import is two calls. `preimport` uploads the files — one per entity
|
||||
type, as `files[<entity_type>]` — and answers with a `hash` plus the column
|
||||
mapping the server suggests; `import` then starts the ingest from that hash.
|
||||
The server holds the files against the hash for 20 minutes.
|
||||
|
||||
The route also accepts the files and the mapping together in one multipart
|
||||
request. That form is deliberately not wrapped: a mapping is a nested
|
||||
structure, and bracket-encoding nested structures into form fields is exactly
|
||||
what this crate avoids by sending JSON (see [the PHP SDK
|
||||
comparison](#compared-with-the-official-php-sdk)). With a `hash` present the
|
||||
controller never looks at uploaded files, so `import` is plain JSON.
|
||||
|
||||
`upload_edocument` is *not* a document attachment, which is why it is not one
|
||||
of the `upload_*` methods: `ExpenseController::edocument` parses each file and
|
||||
creates **expenses**. Like the `/{id}/upload` routes it is registered as `PUT`
|
||||
and so goes out as a `POST` with `_method=PUT`; unlike them the reply has no
|
||||
`data` envelope.
|
||||
|
||||
All five queue background work, so a 200 means accepted, not finished. Rows
|
||||
that fail to import are reported by email. `import_json` restores a company
|
||||
from an `export` backup and **overwrites the target company**.
|
||||
|
||||
## E-invoicing
|
||||
|
||||
Seventeen routes, all wrapped. Mostly pass-throughs to Storecove, Invoice
|
||||
Ninja's Peppol access point, so they return `serde_json::Value` — see
|
||||
[Typed response gaps](#typed-response-gaps) for why.
|
||||
|
||||
| Route | Method | Returns |
|
||||
| --- | --- | --- |
|
||||
| `POST /einvoice/validateEntity` | `validate_einvoice` | `Value` |
|
||||
| `POST /einvoice/configurations` | `einvoice_configurations` | `Value` |
|
||||
| `GET /einvoice/quota` | `einvoice_quota` | `Value` |
|
||||
| `GET /einvoice/health_check` | `einvoice_health_check` | `()` |
|
||||
| `GET /einvoice/delivery_map` | `einvoice_delivery_map` | `Value` |
|
||||
| `POST /einvoice/token/update` | `update_einvoice_token` | `()` (204) |
|
||||
| `POST /einvoice/peppol/discovery` | `peppol_discovery` | `Value` |
|
||||
| `POST /einvoice/peppol/legal_entity` | `peppol_legal_entity` | `Value` |
|
||||
| `POST /einvoice/peppol/setup` | `peppol_setup` | `Option<Value>` |
|
||||
| `POST /einvoice/peppol/disconnect` | `peppol_disconnect` | `Value` |
|
||||
| `PUT /einvoice/peppol/update` | `peppol_update_legal_entity` | `Value` |
|
||||
| `POST /einvoice/peppol/add_additional_legal_identifier` | `peppol_add_legal_identifier` | `MessageResponse` |
|
||||
| `DELETE /einvoice/peppol/remove_additional_legal_identifier` | `peppol_remove_legal_identifier` | `Value` |
|
||||
| `POST /einvoice/peppol/send` | `peppol_retry_send` | `MessageResponse` |
|
||||
| `POST /einvoice/peppol/sg/c5/activate` | `peppol_sg_c5_activate` | `MessageResponse` |
|
||||
| `POST /einvoice/peppol/sg/c5/deactivate` | `peppol_sg_c5_deactivate` | `MessageResponse` |
|
||||
| `POST /einvoice/peppol/sg/c5/cancel` | `peppol_sg_c5_cancel` | `MessageResponse` |
|
||||
|
||||
Three things here behave unlike the rest of the API:
|
||||
|
||||
- **A failing check is a non-2xx.** `validate_einvoice` answers `422` when
|
||||
validation finds problems, so "this invoice is not Peppol-ready" — an
|
||||
ordinary result — arrives as `Error::Api`, and the per-entity detail in the
|
||||
body is not reachable through the typed error. An `Ok` means it passed.
|
||||
- **Error bodies are not the usual shape.** The Peppol handlers return
|
||||
`data_get($response, 'message')` as the entire body, which can be a bare
|
||||
string. `Error::Api`'s `message` then falls back to an excerpt and `errors`
|
||||
is empty.
|
||||
- **`peppol_setup` has two success shapes.** A `corppass_url` when the
|
||||
connection needs a further browser step (Singapore's CorpPass), or a bare
|
||||
`204` when it completed. Hence `Option<Value>` rather than treating the
|
||||
empty body as a decode failure.
|
||||
|
||||
`peppol_remove_legal_identifier` is the only `DELETE` in the crate that
|
||||
carries a request body.
|
||||
|
||||
## Integrations
|
||||
|
||||
| Route | Method | Returns |
|
||||
| --- | --- | --- |
|
||||
| `POST /quickbooks/sync` | `quickbooks_sync` | `()` (204) |
|
||||
| `POST /quickbooks/action` | `quickbooks_action` | `()` (204) |
|
||||
| `POST /quickbooks/disconnect` | `quickbooks_disconnect` | `()` (204) |
|
||||
| `POST /quickbooks/reconnect_url` | `quickbooks_reconnect_url` | `Value` |
|
||||
| `POST /stripe/update_payment_methods` | `stripe_update_payment_methods` | `MessageResponse` |
|
||||
| `POST /stripe/import_customers` | `stripe_import_customers` | `MessageResponse` |
|
||||
| `POST /stripe/verify` | `stripe_verify` | `Value` |
|
||||
| `POST /stripe/disconnect/{id}` | `stripe_disconnect` | `Value` |
|
||||
| `GET /calendar_connection` | `calendar_connection` | `Value` |
|
||||
| `POST /calendar_connection/{provider}/complete` | `complete_calendar_connection` | `Value` |
|
||||
| `GET /calendar_connection/calendars` | `calendar_connection_calendars` | `Value` |
|
||||
| `GET /calendar_connection/events` | `calendar_connection_events` | `Value` |
|
||||
| `PUT /calendar_connection/calendars` | `update_calendar_connection_calendars` | `Value` |
|
||||
| `DELETE /calendar_connection` | `delete_calendar_connection` | `()` |
|
||||
| `POST /yodlee/status/{account_number}` | `yodlee_account_status` | `Value` |
|
||||
| `GET /nordigen/institutions` | `nordigen_institutions` | `Value` |
|
||||
|
||||
All four Stripe routes are **password-protected** — supply
|
||||
`with_api_password` — and answer `403` for a non-admin.
|
||||
|
||||
These operate a connection that already exists. **Establishing one is a
|
||||
browser flow and is not wrappable**: the OAuth authorize and callback legs
|
||||
live outside the `api/v1` group, take no API token, and answer with
|
||||
redirects. See [the gap list](#individual-endpoints-not-wrapped).
|
||||
|
||||
`yodlee_account_status` takes Yodlee's own `account_id`, as stored on the
|
||||
matching `BankIntegration` — not an Invoice Ninja id. Its upstream route
|
||||
carries a `@todo` questioning its own path, so treat it as liable to move.
|
||||
`nordigen_institutions` covers GoCardless, which was Nordigen when the route
|
||||
was named; an instance without GoCardless credentials answers `400` rather
|
||||
than an empty list.
|
||||
|
||||
## Document uploads
|
||||
|
||||
`PUT /{resource}/{id}/upload` exists for 16 resources, each getting
|
||||
`upload_{singular}(id, files, is_public)` that returns the refreshed record:
|
||||
|
||||
| Resource | Method |
|
||||
| --- | --- |
|
||||
| Clients | `upload_client` |
|
||||
| Companies | `upload_company` |
|
||||
| Credits | `upload_credit` |
|
||||
| Expenses | `upload_expense` |
|
||||
| Group Settings | `upload_group_setting` |
|
||||
| Invoices | `upload_invoice` |
|
||||
| Payments | `upload_payment` |
|
||||
| Products | `upload_product` |
|
||||
| Projects | `upload_project` |
|
||||
| Purchase Orders | `upload_purchase_order` |
|
||||
| Quotes | `upload_quote` |
|
||||
| Recurring Expenses | `upload_recurring_expense` |
|
||||
| Recurring Invoices | `upload_recurring_invoice` |
|
||||
| Recurring Quotes | `upload_recurring_quote` |
|
||||
| Tasks | `upload_task` |
|
||||
| Vendors | `upload_vendor` |
|
||||
|
||||
This is the *only* way the API creates a document:
|
||||
`DocumentController::store` is an empty stub upstream, so `Document` has
|
||||
`list`, `get`, `update`, `delete`, `download`, and `bulk`, but no
|
||||
`create_document`, and there will not be one.
|
||||
|
||||
Three details that are easy to get wrong, all verified against the upstream
|
||||
source rather than the published spec:
|
||||
|
||||
- **The request is a `POST` carrying `_method=PUT`.** The route is
|
||||
registered as `PUT`, but PHP does not parse a multipart body on a `PUT`, so
|
||||
the files would never arrive. Laravel's kernel turns the spoofed method back
|
||||
into a `PUT` before routing. The OpenAPI spec documents `POST` with
|
||||
`_method=POST`, which would 405 against these routes.
|
||||
- **`is_public` is sent as `1`/`0`.** Laravel's `boolean` rule accepts only
|
||||
`true`, `false`, `0`, `1`, `'0'`, and `'1'` under strict comparison, so the
|
||||
string `"true"` fails validation.
|
||||
- **Uploads are never retried**, even on a 429. An upload adds a document, so
|
||||
it is not idempotent whatever its verb, and a replay after a lost response
|
||||
would attach the file twice.
|
||||
|
||||
The server enforces file type and size limits; a refusal comes back as a 422
|
||||
`Error::Api` with the per-field `errors` map. Files are held in memory while
|
||||
uploading.
|
||||
|
||||
## 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.
|
||||
|
||||
## Activities, email history and previews
|
||||
|
||||
Each has a response shape of its own. Marked *observed* where confirmed
|
||||
against a 5.13.50 server.
|
||||
|
||||
| Endpoint | Method | Returns |
|
||||
| --- | --- | --- |
|
||||
| `GET /activities` | `list_activities`, `list_all_activities` | `Vec<Activity>` (*observed*) |
|
||||
| `GET /activities?reactv2=1` | `activity_feed(rows)` | `Vec<ActivityFeedEntry>` (*observed*) |
|
||||
| `POST /activities/entity` | `entity_activity(entity, id, rows)` | `Vec<ActivityFeedEntry>` (*observed*) |
|
||||
| `POST /activities/notes` | `add_activity_note(entity, id, notes)` | `Activity` (*observed*) |
|
||||
| `GET /activities/download_entity/{id}` | `download_activity_entity` | PDF bytes |
|
||||
| `POST /emails/clientHistory/{client}` | `client_email_history` | `Vec<serde_json::Value>` |
|
||||
| `POST /emails/entityHistory` | `entity_email_history` | `Vec<serde_json::Value>` |
|
||||
| `POST /preview` | `preview`, `preview_html` | PDF bytes / HTML |
|
||||
| `POST /live_preview` | `live_preview` | PDF bytes (*observed*) |
|
||||
| `POST /live_design` | `live_design` | PDF bytes |
|
||||
| `POST /preview/purchase_order` | `preview_purchase_order` | PDF bytes |
|
||||
| `POST /live_preview/purchase_order` | `live_preview_purchase_order` | PDF bytes |
|
||||
|
||||
Things that are easy to get wrong:
|
||||
|
||||
- **The entity name is singular in one route and plural in the next.**
|
||||
`entity_activity` takes `invoice`; `add_activity_note` takes `invoices`.
|
||||
- **`rows`, not `per_page`, caps the activity log** (default 75). Pass it
|
||||
through `ListParams::param`. `entity_activity` leaves `rows` out when not
|
||||
given rather than sending `null`, because the server tests for the key and
|
||||
a null would count as given and remove the cap.
|
||||
- **The two activity shapes differ.** `Activity` carries hashed ids and a
|
||||
string `activity_type_id`; the feed's `ActivityFeedEntry` carries a numeric
|
||||
one and labelled references (`invoice`, `client`, … each `{label,
|
||||
hashed_id}`).
|
||||
- **Email history is raw JSON.** The objects mirror what the mail provider
|
||||
reported, and the instance available had never sent mail, so there was no
|
||||
shape to take.
|
||||
- **Not yet run against a real server:** `preview`, `preview_html`, and both
|
||||
purchase-order previews (they need a design object or a purchase order and a
|
||||
PDF backend), and `live_design`, which answered a bare minimal request with
|
||||
a 500 from the server on 5.13.50.
|
||||
|
||||
## Password-protected actions
|
||||
|
||||
Behind the `password_protected` middleware, so each needs
|
||||
`with_api_password` (the server also lets a recent password-authenticated
|
||||
session through without the header). Most are **irreversible**, and all are
|
||||
`POST`s except one, so none is retried on a 5xx.
|
||||
|
||||
| Endpoint | Method | Returns |
|
||||
| --- | --- | --- |
|
||||
| `POST /clients/{id}/purge` | `purge_client` | `MessageResponse` |
|
||||
| `POST /clients/{id}/{other}/merge` | `merge_clients(id, other)` | the surviving `Client` |
|
||||
| `POST /vendors/{id}/{other}/merge` | `merge_vendors(id, other)` | the surviving `Vendor` |
|
||||
| `POST /companies/purge/{id}` | `purge_company` | `MessageResponse` |
|
||||
| `POST /companies/purge_save_settings/{id}` | `purge_company_save_settings` | `MessageResponse` |
|
||||
| `POST /users/{id}/invite` | `invite_user` | `MessageResponse` |
|
||||
| `POST /users/{id}/purge` | `purge_user` | `()` (the server answers 204) |
|
||||
| `GET /users/create` | `new_user_template` | a blank `User`, nothing stored |
|
||||
|
||||
A merge keeps the first id and folds the second into it. The smoke test's
|
||||
optional password tier (merge two throwaway clients, purge the survivor) was
|
||||
run against a disposable 5.13.50 instance on 2026-10-24 and passes; it also
|
||||
established that a purged record reads back as a **400**, not a 404. A
|
||||
password containing a control character cannot be a header value and goes out
|
||||
as `X-API-PASSWORD-BASE64` instead.
|
||||
|
||||
## Logout, passkeys, and login
|
||||
|
||||
`/login`, `/logout`, and the passkey routes used to sit together under one
|
||||
"Auth" gap. They don't share a shape: `/logout` and the passkey *settings*
|
||||
routes are plain `token_auth` endpoints like everything else this crate
|
||||
wraps, while `/login` sits in a different, unauthenticated middleware group
|
||||
because the caller has no token yet to prove who they are.
|
||||
|
||||
| Endpoint | Method | Needs an existing client? |
|
||||
| --- | --- | --- |
|
||||
| `POST /logout` | `logout` | yes |
|
||||
| `GET /settings/passkeys` | `list_passkeys` | yes |
|
||||
| `DELETE /settings/passkeys/{id}` | `delete_passkey` | yes |
|
||||
| `POST /login` | `InvoiceNinjaClient::login` (associated fn, not a method) | **no** |
|
||||
|
||||
**`logout` has a bigger blast radius than its name suggests.** The
|
||||
controller doesn't revoke the caller's token — it regenerates *every*
|
||||
`is_system` token belonging to the whole company, logging out every user
|
||||
and device that shares it, including the one making the call.
|
||||
|
||||
**Passkey registration is not wrapped.** `POST /settings/passkeys/options`
|
||||
and `POST /settings/passkeys` exist to drive a WebAuthn ceremony that only a
|
||||
browser talking to a platform authenticator can complete — there is no
|
||||
attestation object for this crate to produce standalone. Listing and
|
||||
deleting an existing credential need no such ceremony, so those are the two
|
||||
operations offered.
|
||||
|
||||
**`login` returns the server's full "first load" payload** — nested
|
||||
company, clients, invoices, designs, and more under each `CompanyUser`
|
||||
entry — because it reuses the same transformer as a browser's initial page
|
||||
load. This crate does not model that nested tree; [`LoginCompanyUser`]
|
||||
types only `is_owner`, `is_admin`, and the `token` it exists to produce,
|
||||
and leaves the rest in `extra`, losslessly. [`LoginCompanyUser::token`]
|
||||
is what a caller actually needs — feed it to
|
||||
[`InvoiceNinjaClient::new`] or [`InvoiceNinjaClient::hosted`].
|
||||
|
||||
`login` optionally takes a one-time password for accounts with two-factor
|
||||
auth enabled. It does not support passkey login, SSO (`oauth_login`,
|
||||
`oidc/*`), `/login/precheck`, or a self-hosted instance's optional
|
||||
`X-API-SECRET` header — each needs a ceremony, a redirect, or a header this
|
||||
single email/password call does not send.
|
||||
|
||||
`/refresh` and `/refresh_react` were in the old "Auth" bucket too, but
|
||||
they're not an authentication concept: `BaseController::refreshResponse`
|
||||
is the same bulk, `updated_at`-filtered account dump as `/login`'s first
|
||||
load. That's the sync-engine shape [`GOALS.md`](GOALS.md) already parks as
|
||||
a separate open question, not something to bolt onto auth — not
|
||||
implemented, for the same reason.
|
||||
|
||||
`/connected_account` (linking a Gmail OAuth account) was also filed under
|
||||
"Auth". It's an integration-linking endpoint, not login/logout, so it
|
||||
belongs with Stripe/QuickBooks under [Integrations](#integrations) above —
|
||||
also not implemented.
|
||||
|
||||
## Per-resource extras
|
||||
|
||||
Routes that sit beside a resource's CRUD set, each with its own response
|
||||
shape (read from the upstream controllers; the ones marked *observed* were
|
||||
also confirmed against a 5.13.50 server). All are `POST` unless noted, so none
|
||||
is retried on a 5xx or a timeout.
|
||||
|
||||
| Endpoint | Method | Returns |
|
||||
| --- | --- | --- |
|
||||
| `POST /webhooks/{id}/retry` | `retry_webhook` | `Webhook` |
|
||||
| `POST /tasks/sort` | `sort_tasks` | `MessageResponse` |
|
||||
| `POST /designs/set/default` | `set_default_design` | `MessageResponse` |
|
||||
| `POST /client_gateway_tokens/{id}/setAsDefault` | `set_default_client_gateway_token` | `ClientGatewayToken` |
|
||||
| `GET /subscriptions/steps` | `subscription_steps` | `Vec<SubscriptionStep>` (*observed*: an object keyed by class name, not the array the source suggests) |
|
||||
| `POST /subscriptions/steps/check` | `check_subscription_steps` | `()` |
|
||||
| `POST /company_gateways/{id}/test` | `test_company_gateway` | `MessageResponse` |
|
||||
| `POST /company_gateways/{id}/clone` | `clone_company_gateway` | `CompanyGateway` |
|
||||
| `POST /company_gateways/{id}/import_customers` | `import_company_gateway_customers` | `MessageResponse` |
|
||||
| `POST /bank_integrations/refresh_accounts` | `refresh_bank_accounts` | `()` (the body is a serialised query builder) |
|
||||
| `POST /bank_integrations/remove_account/{id}` | `remove_bank_account` | `BankIntegration` |
|
||||
| `POST /bank_integrations/get_transactions/{id}` | `fetch_bank_transactions` | `MessageResponse` (the server ignores the id) |
|
||||
| `POST /bank_transactions/match` | `match_bank_transactions` | `Vec<BankTransaction>` |
|
||||
| `POST /clients/{id}/updateTaxData` | `update_client_tax_data` | `Client` |
|
||||
| `POST /clients/{id}/show_settings` | `client_show_settings` | `serde_json::Value` (*observed*) |
|
||||
| `POST /clients/{id}/peppol_discovery` | `client_peppol_discovery` | `serde_json::Value` |
|
||||
| `POST /clients/{id}/check_vat` | `check_client_vat` | `MessageResponse` |
|
||||
| `GET /clients/{id}/vat_status` | `client_vat_status` | `MessageResponse` (*observed*) |
|
||||
| `POST /clients/{id}/documents` | `client_documents` | `Vec<Document>` (*observed*; one page) |
|
||||
|
||||
`updateTaxData` and `check_vat` call out to external tax services, so the
|
||||
smoke test does not exercise them.
|
||||
|
||||
## Not covered
|
||||
|
||||
### Nothing is blocked on a dependency
|
||||
|
||||
Multipart uploads used to be, and are now wrapped — see [Document
|
||||
uploads](#document-uploads) and [Import and
|
||||
export](#import-and-export).
|
||||
|
||||
### 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`).
|
||||
|
||||
### Individual endpoints not wrapped
|
||||
|
||||
- **Company extras** — `GET /companies/{id}/logo`,
|
||||
`POST /companies/{id}/default`, `updateOriginTaxData`,
|
||||
`POST /companies/current`
|
||||
- **User extras** (not password-protected, unlike the rest of `/users`) —
|
||||
`POST /users/{id}/disconnect_mailer`, `POST /users/{id}/disconnect_oauth`,
|
||||
`POST /user/{id}/reconfirm`
|
||||
- **Auth** — see [Logout, passkeys, and login](#logout-passkeys-and-login)
|
||||
above for what's covered (`login`, `logout`, passkey list/delete) and
|
||||
what isn't: `/login/precheck`, `oauth_login`, `oidc/*`, passkey
|
||||
registration and passkey login, `/refresh`, `/refresh_react`, and
|
||||
`/connected_account`
|
||||
- **Migration** — `/migration/start`. Moves a company between instances and
|
||||
is driven by the v4 migration tool, not by an SDK caller.
|
||||
- **Invoice extras** — `delivery_note`, `payment_schedule`,
|
||||
`update_reminders`
|
||||
- **Client-portal invitation downloads** — the `routes/client.php` family:
|
||||
`/client/invoice/{key}/download_pdf`, the `download_e_*` variants for
|
||||
quotes and credits, `/client/{entity}/{key}/download`, and
|
||||
`/client/documents/{hash}/hashed`. These are not under `/api/v1`, so they
|
||||
don't fit a `base_url` that ends there. Note these are *not* the
|
||||
invitation-key downloads documented above, which are authenticated
|
||||
`/api/v1` routes and are wrapped.
|
||||
- **Integration routes that cannot take an API token** — the OAuth legs of
|
||||
the QuickBooks and calendar flows (`/quickbooks/authorize/{token}`,
|
||||
`/quickbooks/reconnect/{token}`, `/quickbooks/authorized`,
|
||||
`/calendar_connection/{provider}/authorize/{hash}`) and the provider
|
||||
webhooks (`/yodlee/refresh`, `/yodlee/data_updates`,
|
||||
`/yodlee/refresh_updates`, `/yodlee/balance`, `/postmark_webhook`,
|
||||
`/sns_webhook`, `/postmark_inbound_webhook`, `/payment_webhook/…`). The
|
||||
OAuth routes sit outside the `api/v1` group and answer with browser
|
||||
redirects; the webhooks are things a provider calls, not things a client
|
||||
calls. Connecting an account is a browser flow — see
|
||||
[Integrations](#integrations) for the methods that operate a connection
|
||||
once it exists.
|
||||
- **`POST /quickbooks/settings`** — the route is registered, but
|
||||
`QuickbooksController` has no `settings` method, so it raises
|
||||
`BadMethodCallException` and 500s. Nothing to wrap until upstream adds the
|
||||
handler.
|
||||
- **Other integrations** — Apple in-app purchase, PostMark, self-update,
|
||||
templates, hooks (commented out upstream)
|
||||
- **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`, `/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.
|
||||
|
||||
The [e-invoicing](#e-invoicing) and [integration](#integrations) methods
|
||||
return `serde_json::Value` as a rule, which is the largest untyped surface in
|
||||
the crate and is deliberate. Their replies are pass-throughs from an external
|
||||
service — Storecove, Stripe's gateway driver, Yodlee, GoCardless — or arrays
|
||||
assembled for Invoice Ninja's own React client. None has a documented schema,
|
||||
several differ by provider, and they change on the provider's release
|
||||
schedule rather than Invoice Ninja's. The precedent is
|
||||
`client_peppol_discovery`, which has returned `Value` since it landed. Where
|
||||
a shape *is* stable and is the point of the call it is still typed, as with
|
||||
`ExportJob` and `ImportPreview`.
|
||||
|
||||
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 by id and by
|
||||
invitation key, the XML e-documents, 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
+265
-2
@@ -2,6 +2,25 @@
|
||||
# It is not intended for manual editing.
|
||||
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]]
|
||||
name = "atomic-waker"
|
||||
version = "1.1.2"
|
||||
@@ -70,6 +89,24 @@ version = "0.8.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "displaydoc"
|
||||
version = "0.2.7"
|
||||
@@ -81,6 +118,12 @@ dependencies = [
|
||||
"syn 3.0.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826"
|
||||
|
||||
[[package]]
|
||||
name = "errno"
|
||||
version = "0.3.14"
|
||||
@@ -103,6 +146,12 @@ version = "0.1.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48"
|
||||
|
||||
[[package]]
|
||||
name = "fnv"
|
||||
version = "1.0.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
|
||||
|
||||
[[package]]
|
||||
name = "foreign-types"
|
||||
version = "0.3.2"
|
||||
@@ -127,6 +176,21 @@ dependencies = [
|
||||
"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]]
|
||||
name = "futures-channel"
|
||||
version = "0.3.34"
|
||||
@@ -134,6 +198,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -142,6 +207,40 @@ version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "futures-task"
|
||||
version = "0.3.34"
|
||||
@@ -154,8 +253,13 @@ version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
|
||||
dependencies = [
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"futures-io",
|
||||
"futures-macro",
|
||||
"futures-sink",
|
||||
"futures-task",
|
||||
"memchr",
|
||||
"pin-project-lite",
|
||||
"slab",
|
||||
]
|
||||
@@ -171,6 +275,37 @@ dependencies = [
|
||||
"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]]
|
||||
name = "http"
|
||||
version = "1.5.0"
|
||||
@@ -210,6 +345,12 @@ version = "1.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
|
||||
|
||||
[[package]]
|
||||
name = "httpdate"
|
||||
version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
|
||||
|
||||
[[package]]
|
||||
name = "hyper"
|
||||
version = "1.12.0"
|
||||
@@ -218,10 +359,13 @@ checksum = "2c3e324da4c95177d6291d4c8730197c0d1822f8a9766814a4a44fa5ab797c9c"
|
||||
dependencies = [
|
||||
"atomic-waker",
|
||||
"bytes",
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"h2",
|
||||
"http",
|
||||
"http-body",
|
||||
"httparse",
|
||||
"httpdate",
|
||||
"itoa",
|
||||
"pin-project-lite",
|
||||
"smallvec",
|
||||
@@ -374,14 +518,27 @@ dependencies = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "invoiceninjars"
|
||||
version = "0.1.0"
|
||||
name = "indexmap"
|
||||
version = "2.14.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
|
||||
dependencies = [
|
||||
"equivalent",
|
||||
"hashbrown",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "invoiceninja-sdk"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"percent-encoding",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_urlencoded",
|
||||
"thiserror",
|
||||
"tokio",
|
||||
"wiremock",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -407,6 +564,12 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lazy_static"
|
||||
version = "1.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb"
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.191"
|
||||
@@ -437,6 +600,22 @@ version = "2.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||
|
||||
[[package]]
|
||||
name = "mime"
|
||||
version = "0.3.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
||||
|
||||
[[package]]
|
||||
name = "mime_guess"
|
||||
version = "2.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
|
||||
dependencies = [
|
||||
"mime",
|
||||
"unicase",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mio"
|
||||
version = "1.2.4"
|
||||
@@ -465,6 +644,16 @@ dependencies = [
|
||||
"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]]
|
||||
name = "once_cell"
|
||||
version = "1.21.4"
|
||||
@@ -565,6 +754,35 @@ version = "6.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "reqwest"
|
||||
version = "0.12.28"
|
||||
@@ -574,6 +792,7 @@ dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
@@ -582,6 +801,7 @@ dependencies = [
|
||||
"hyper-util",
|
||||
"js-sys",
|
||||
"log",
|
||||
"mime_guess",
|
||||
"native-tls",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
@@ -877,6 +1097,20 @@ dependencies = [
|
||||
"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]]
|
||||
name = "tower"
|
||||
version = "0.5.3"
|
||||
@@ -947,6 +1181,12 @@ version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
||||
|
||||
[[package]]
|
||||
name = "unicase"
|
||||
version = "2.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "357cc3acc6a036009fd6c973ed009037c732d60d0b4f6c673e9041497482a28f"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.27"
|
||||
@@ -1073,6 +1313,29 @@ dependencies = [
|
||||
"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]]
|
||||
name = "writeable"
|
||||
version = "0.6.4"
|
||||
|
||||
+24
-4
@@ -1,16 +1,36 @@
|
||||
[package]
|
||||
name = "invoiceninjars"
|
||||
version = "0.1.0"
|
||||
name = "invoiceninja-sdk"
|
||||
version = "0.2.0"
|
||||
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"
|
||||
license = "MIT"
|
||||
repository = "https://github.com/bryant/invoiceninjars"
|
||||
repository = "https://github.com/awkawb1/invoiceninja-sdk"
|
||||
# Slugs checked against https://crates.io/api/v1/category_slugs — crates.io
|
||||
# rejects a publish with an unrecognised one, so these aren't freeform.
|
||||
categories = ["api-bindings", "web-programming::http-client", "asynchronous"]
|
||||
keywords = ["invoiceninja", "invoicing", "billing", "async", "sdk"]
|
||||
|
||||
[dependencies]
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "native-tls"] }
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "native-tls", "multipart"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
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]
|
||||
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,326 @@
|
||||
# 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 CLI, and no binary at all, in this package.** A command-line client
|
||||
over this library is a reasonable thing to want — it is the obvious way to
|
||||
ask "what does this endpoint actually return", which matters here because
|
||||
the e-invoicing and integration methods are typed as
|
||||
`serde_json::Value` and were verified against mocks rather than a live
|
||||
service. It belongs in a sibling crate (`invoiceninja-sdk-cli`) in a
|
||||
workspace, not in this one, for three reasons:
|
||||
|
||||
- *Dependencies.* Every entry in `Cargo.toml` is there because the library
|
||||
needs it, and three of the five are already in the tree via `reqwest`.
|
||||
An argument parser would be the first dependency that is not
|
||||
load-bearing for the library, along with the project's first optional
|
||||
dependency and first `[features]` section.
|
||||
- *MSRV.* Not a break today — `clap` 4.6.7 declares 1.85, under this
|
||||
crate's 1.88 (checked 2026-10-25). The cost is the coupling: a second
|
||||
floor that moves on someone else's release schedule, in a project where
|
||||
three places must agree on the number and a documented procedure exists
|
||||
for rechecking it. A separate crate keeps it out of the library's graph.
|
||||
- *Versioning.* Pre-1.0 this crate tells callers to pin an exact version
|
||||
and expect breaking changes in minor releases. A CLI's flags are a
|
||||
compatibility surface too, and one version number would force the two to
|
||||
move in lockstep. `cargo install invoiceninja-sdk` silently needing
|
||||
`--features cli` is a smaller version of the same problem.
|
||||
|
||||
For ad-hoc probing, `examples/` already covers it and costs nothing:
|
||||
`capture_shapes`, `seed_sample_data` and `smoke_test` are CLI-shaped
|
||||
programs configured from environment variables, with the
|
||||
`IN_SMOKE_WRITE_HOST` guard to copy for anything that writes.
|
||||
|
||||
Same shape of decision as the `updated_at` sync engine in the open
|
||||
questions: a different product from this crate, recorded so the option is
|
||||
not forgotten.
|
||||
- **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.
|
||||
|
||||
**Gitea is the source of truth; GitHub is a one-way mirror.** The canonical
|
||||
repository is `gitea.awkawb.cloud/awkawb/invoiceninja-sdk`. A Gitea push mirror
|
||||
copies every commit to <https://github.com/awkawb1/invoiceninja-sdk>, which
|
||||
is what `Cargo.toml` points at. Nothing should be written to the GitHub
|
||||
copy: the next sync overwrites it, so a merged pull request there would
|
||||
simply disappear. Contributions go through Gitea.
|
||||
|
||||
`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
|
||||
|
||||
**This roadmap is now closed** — every item below is done, and every route an
|
||||
API-token client can reach is wrapped. It is kept rather than deleted because
|
||||
several entries record what an item's original reasoning got wrong, which is
|
||||
worth more than the fact that it shipped. What is still unwrapped is in
|
||||
`COVERAGE.md`'s gap list, with a reason per entry, and none of it is a
|
||||
sequenced to-do any more.
|
||||
|
||||
The items ran roughly in order of value over effort:
|
||||
|
||||
**Done:** multipart document uploads, for the 16 resources that have the
|
||||
route (`upload_client`, `upload_invoice`, …). This earlier claimed it would
|
||||
also make `create_document` possible, which was wrong: upstream
|
||||
`DocumentController::store` is an empty stub, and an upload is the *only*
|
||||
way a document is created. There is no `create_document` and there will not
|
||||
be one. `edocument/upload` was left out because it imports an expense from
|
||||
an e-document rather than attaching a file, so it belongs with item 6.
|
||||
|
||||
**Done:** the per-resource extras — webhook retry, task sort, default
|
||||
design, default gateway token, subscription steps, and the client,
|
||||
bank-integration, and company-gateway routes (19 methods). The numbering
|
||||
below keeps its original item numbers. Running the smoke test against a real
|
||||
server overturned one reading of the source here: `/subscriptions/steps`
|
||||
answers with an object keyed by class name, not the array the controller
|
||||
suggests. That is the case for keeping the real-server tier.
|
||||
|
||||
**Done:** the password-protected actions — client purge and merge, vendor
|
||||
merge, company purge, user invite and purge, and the blank-user template (8
|
||||
methods). The smoke test's password tier (`IN_API_PASSWORD`) has since been
|
||||
run against a disposable 5.13.50 instance, on 2026-10-24, with all four
|
||||
checks passing — but only after its own post-purge assertion was fixed, which
|
||||
had checked for a 404 and passed solely because it had never been run. The
|
||||
claim here that `with_api_password` already made them reachable was not quite
|
||||
true: a password with a control character was refused locally.
|
||||
|
||||
**Done:** activities, email history and previews (14 methods, two new
|
||||
models). The email history stays raw JSON, since no instance here had sent
|
||||
mail to take a shape from; promoting it to a model needs a sample. Several
|
||||
previews are unverified against a real server (see `COVERAGE.md`).
|
||||
|
||||
**Done:** the invitation-key downloads — the PDF and the XML e-document for
|
||||
invoices, quotes, credits and purchase orders, keyed by a contact's
|
||||
invitation key (8 methods). This item used to read "eight routes that take no
|
||||
auth token, so they do not fit the authenticated request path and need a
|
||||
different entry point." That was wrong on every count, and wrong in the way
|
||||
goal 6 exists to catch: the premise came from reading the route *names*
|
||||
rather than the route file. All eight sit inside `routes/api.php`'s `api/v1`
|
||||
group under its `token_auth` middleware — the controllers' own OpenAPI blocks
|
||||
declare `X-API-TOKEN` — so they needed no new entry point and were two dozen
|
||||
lines of macro. What the item had actually described is the client-portal
|
||||
family in `routes/client.php`, which is genuinely token-less, is not under
|
||||
`/api/v1`, and stays unwrapped for that reason.
|
||||
|
||||
**Done:** import/export and the integrations — `/import`, `/preimport`,
|
||||
`/import_json`, `/export`, `edocument/upload`, einvoice (17 routes),
|
||||
QuickBooks, Stripe, calendar, Yodlee and Nordigen (38 methods). This was the
|
||||
last item, so **the roadmap is closed**: every route in `routes/api.php` that
|
||||
an API-token client can reach is now wrapped. It did land in pieces, as the
|
||||
item predicted — import/export first, then the integrations.
|
||||
|
||||
**Done:** the auth routes, once split apart — the single "auth routes"
|
||||
label had quietly merged four unrelated things. `logout` and the passkey
|
||||
list/delete routes sit behind the same `token_auth` middleware as every
|
||||
other wrapped endpoint, so they needed no new client type, just methods
|
||||
(and a documented warning that `logout` rotates every token on the whole
|
||||
company, not just the caller's). `login` *does* need a different entry
|
||||
point — there is no token yet to build an `InvoiceNinjaClient` with — but
|
||||
that turned out to be a standalone associated function, not a second
|
||||
client type: `InvoiceNinjaClient::login` trades an email and password for
|
||||
a `LoginResponse` carrying a token, which then goes to `new` or `hosted`
|
||||
as normal. Passkey *registration* and passkey *login* stay unwrapped
|
||||
because they're WebAuthn ceremonies a browser has to drive; `/refresh` and
|
||||
`/refresh_react` turned out to be the bulk `updated_at` sync dump already
|
||||
parked below, not an auth concept, so they stay out for that reason
|
||||
instead; and `/connected_account` (Gmail OAuth linking) was filed under
|
||||
the wrong heading — it belongs with the Integrations done item above. See
|
||||
`COVERAGE.md`'s "Logout, passkeys, and login" section for the detail.
|
||||
|
||||
As the item also predicted, it is the least SDK-shaped part of the API, and
|
||||
the typing reflects that: most of these return `serde_json::Value`, because
|
||||
their replies are pass-throughs from Storecove, Stripe, Yodlee or GoCardless,
|
||||
or arrays assembled for Invoice Ninja's React client. That is a deliberate
|
||||
application of the rule in goal 2 — a wrong concrete type is a hard decode
|
||||
error, where `Value` is lossless — and not a shortcut to revisit without a
|
||||
reason. `COVERAGE.md` records it as the crate's largest untyped surface.
|
||||
|
||||
Two things in this block are *not* wrapped, and cannot be. The OAuth legs of
|
||||
the QuickBooks and calendar flows take no API token and answer with browser
|
||||
redirects, so connecting an account stays a browser flow; the methods here
|
||||
operate a connection that already exists. And `POST /quickbooks/settings` is
|
||||
a registered route with no controller method behind it, so it 500s — there is
|
||||
nothing to wrap until upstream adds the handler.
|
||||
|
||||
What remains unwrapped is now only what each entry in `COVERAGE.md`'s gap
|
||||
list explains: the passkey and SSO ceremonies and `/login/precheck`
|
||||
(each needs something — a browser, a redirect — this client doesn't have),
|
||||
`/refresh`/`/refresh_react` (the sync-engine shape parked below), the
|
||||
client-portal family, the provider webhooks, and a few company and user
|
||||
extras. Keep that file current — it names exact counts, so it goes stale
|
||||
quietly.
|
||||
|
||||
**Decided: `chart` stays generic.** The premise in the old open question —
|
||||
"eleven chart endpoints share no response shape" — undersold the problem.
|
||||
Reading `ChartController`/`ChartService` directly: the eleven routes are
|
||||
nine distinct bodies (`totals`/`totalsV2` and `chart_summary`/
|
||||
`chart_summaryV2` are pairs of the same implementation), and six of those
|
||||
nine *do* share an outer idiom — an object keyed by a company's own
|
||||
currency ids, plus a sentinel `999` key for the cross-currency aggregate.
|
||||
But the leaf rows inside that shape are raw `DB::select` results with no
|
||||
model behind them, and they don't agree with each other: within `totals()`
|
||||
alone, the `invoices` leaf carries `invoiced_amount`, `revenue` carries
|
||||
`paid_to_date`, `outstanding` carries `amount` *and* `outstanding_count`,
|
||||
and `chart_summary`'s four leaves differ again. That is goal 2's
|
||||
"ambiguous upstream type" problem — compounded by `SUM()` often coming
|
||||
back from the DB driver as a numeric string — repeated dozens of times
|
||||
over. The remaining three bodies (`cashflow_forecast`,
|
||||
`client_payment_analytics`, `project_analytics`/`project_burnup`) delegate
|
||||
to standalone service classes with no shape in common with each other or
|
||||
with the currency-keyed family, and `calculated_fields` returns whichever
|
||||
scalar or array type its ~20-way `field` match picks, so there is no fixed
|
||||
shape to type at all. Modeling the shared outer envelope would still leave
|
||||
dozens of hand-maintained, DB-column-shaped leaf structs for marginal
|
||||
benefit over a caller defining their own `T` for the one endpoint they
|
||||
use — which `chart::<T>` already lets them do.
|
||||
|
||||
## Open questions
|
||||
|
||||
These are genuinely undecided. Deciding one is a change to this file.
|
||||
|
||||
- **Large bodies are buffered entirely into memory.** PDF downloads and CSV
|
||||
report exports come back as `Vec<u8>`, and an uploaded `FileUpload` holds
|
||||
its whole contents until the request finishes. Streaming would be better
|
||||
for big files in both directions but changes those signatures — and for
|
||||
uploads it would also change the retry story, which currently depends on
|
||||
the multipart body being un-cloneable.
|
||||
- **`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.
|
||||
@@ -1,7 +1,19 @@
|
||||
# invoiceninjars
|
||||
# invoiceninja-sdk
|
||||
|
||||
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 happens on
|
||||
> [Gitea](https://gitea.awkawb.cloud/awkawb/invoiceninja-sdk); the
|
||||
> [GitHub copy](https://github.com/awkawb1/invoiceninja-sdk) is a read-only
|
||||
> mirror.** Anything pushed to GitHub is overwritten by the next sync, so
|
||||
> please open issues and pull requests on Gitea.
|
||||
|
||||
## Development
|
||||
|
||||
Enter the dev shell (Rust toolchain via `rust-overlay`):
|
||||
@@ -10,19 +22,46 @@ Enter the dev shell (Rust toolchain via `rust-overlay`):
|
||||
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
|
||||
|
||||
```rust
|
||||
use invoiceninjars::{InvoiceNinjaClient, models::ListParams};
|
||||
use invoiceninja_sdk::{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(
|
||||
"https://invoicing.co/api/v1", // or your self-hosted base URL
|
||||
"https://your-domain.example/api/v1",
|
||||
std::env::var("IN_API_TOKEN")?,
|
||||
)?;
|
||||
|
||||
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_*`
|
||||
follows pagination and returns every matching record:
|
||||
|
||||
@@ -30,6 +69,29 @@ follows pagination and returns every matching record:
|
||||
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 invoiceninja_sdk::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`):
|
||||
|
||||
```sh
|
||||
@@ -38,7 +100,95 @@ IN_API_TOKEN=... cargo run --example list_clients
|
||||
|
||||
## Coverage
|
||||
|
||||
Clients, Invoices, Quotes, Credits, Payments, Products, Expenses, Projects,
|
||||
Tasks, and Vendors, each with `list`, `list_all`, `get`, `create`, `update`,
|
||||
and `delete`. Add more resources by adding a model under `src/models/` and a
|
||||
Every one of the API's 34 resource groups, plus users — 375 methods. Each
|
||||
resource has `list`, `list_all`, `get`, `create`, `update`, `delete`, and
|
||||
(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 — by record id, or by
|
||||
invitation key for the PDF and XML a given contact receives — reports,
|
||||
charts, search, statics, email sending, refunds, health checks, logout,
|
||||
passkey management, and a standalone `login` to exchange credentials for
|
||||
a token.
|
||||
|
||||
**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?;
|
||||
```
|
||||
|
||||
Documents are attached through the record they belong to, since that is the
|
||||
only way the API creates one. Sixteen resources support it, as
|
||||
`upload_client`, `upload_invoice`, `upload_expense`, and so on:
|
||||
|
||||
```rust
|
||||
use invoiceninja_sdk::FileUpload;
|
||||
|
||||
let receipt = FileUpload::new("receipt.pdf", std::fs::read("receipt.pdf")?)
|
||||
.mime("application/pdf");
|
||||
|
||||
// `Some(true)` makes the document visible to the client in the portal.
|
||||
let expense = client.upload_expense("abc123", vec![receipt], Some(false)).await?;
|
||||
```
|
||||
|
||||
Files are held in memory while uploading, and an upload is never retried,
|
||||
since a replay after a lost response would attach the file twice.
|
||||
|
||||
Reports return their raw export body (CSV for most of them). `statics`,
|
||||
`search`, and `health_check` return typed results; `chart` has no common
|
||||
response shape, so it decodes 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 = client.statics().await?;
|
||||
```
|
||||
|
||||
Add more resources by adding a model under `src/models/` and a
|
||||
`resource_methods!` invocation in `src/client.rs`.
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
//! Records the *shape* of real Invoice Ninja responses as test fixtures.
|
||||
//!
|
||||
//! Mock servers written from the upstream source have been wrong about the
|
||||
//! real thing before (system-log timestamps, `/subscriptions/steps`), so the
|
||||
//! offline suite benefits from fixtures that came from a real server. This
|
||||
//! writes them without writing any data: every string becomes `""`, every
|
||||
//! number `0`, every bool `false`, and an array keeps only its first element.
|
||||
//! What survives is the key names and the JSON types, which is what decoding
|
||||
//! depends on. `tests/real_shapes.rs` refuses to pass if a fixture holds
|
||||
//! anything else.
|
||||
//!
|
||||
//! GET requests only, so it is safe against any instance, but it still reads
|
||||
//! real records in memory. Review the diff before committing.
|
||||
//!
|
||||
//! ```sh
|
||||
//! IN_BASE_URL=http://localhost:8080/api/v1 IN_API_TOKEN=... \
|
||||
//! cargo run --example capture_shapes
|
||||
//! ```
|
||||
//!
|
||||
//! A resource with no rows on the instance writes nothing and is reported as
|
||||
//! skipped, since an empty list says nothing about a row's shape. Populate a
|
||||
//! disposable instance and re-run to capture more.
|
||||
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
use serde_json::{json, Value};
|
||||
|
||||
/// Resources captured as a one-row list. The name doubles as the fixture
|
||||
/// filename.
|
||||
const LISTS: &[&str] = &[
|
||||
"activities",
|
||||
"bank_integrations",
|
||||
"bank_transaction_rules",
|
||||
"bank_transactions",
|
||||
"client_gateway_tokens",
|
||||
"clients",
|
||||
"companies",
|
||||
"company_gateways",
|
||||
"credits",
|
||||
"designs",
|
||||
"documents",
|
||||
"expense_categories",
|
||||
"expenses",
|
||||
"group_settings",
|
||||
"invoices",
|
||||
"locations",
|
||||
"payment_terms",
|
||||
"payments",
|
||||
"products",
|
||||
"projects",
|
||||
"purchase_orders",
|
||||
"quotes",
|
||||
"recurring_expenses",
|
||||
"recurring_invoices",
|
||||
"recurring_quotes",
|
||||
"subscriptions",
|
||||
"system_logs",
|
||||
"tags",
|
||||
"task_schedulers",
|
||||
"task_statuses",
|
||||
"tasks",
|
||||
"tax_rates",
|
||||
"tokens",
|
||||
"users",
|
||||
"vendors",
|
||||
"webhooks",
|
||||
];
|
||||
|
||||
/// Endpoints with no `data` envelope, captured whole.
|
||||
const BARE: &[(&str, &str)] = &[
|
||||
("statics", "statics"),
|
||||
("health_check", "health_check"),
|
||||
("subscription_steps", "subscriptions/steps"),
|
||||
("activity_feed", "activities?rows=1&reactv2=1"),
|
||||
];
|
||||
|
||||
/// Replaces every value with a placeholder of the same JSON type.
|
||||
fn shape(value: &Value) -> Value {
|
||||
match value {
|
||||
Value::Null => Value::Null,
|
||||
Value::Bool(_) => json!(false),
|
||||
Value::Number(n) if n.is_f64() => json!(0.0),
|
||||
Value::Number(_) => json!(0),
|
||||
Value::String(_) => json!(""),
|
||||
// Only the first element: a list's rows share a shape, and keeping
|
||||
// all of them would just be size.
|
||||
Value::Array(items) => Value::Array(items.first().map(shape).into_iter().collect()),
|
||||
Value::Object(map) => {
|
||||
Value::Object(map.iter().map(|(k, v)| (k.clone(), shape(v))).collect())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
let base = std::env::var("IN_BASE_URL").expect("set IN_BASE_URL");
|
||||
let token = std::env::var("IN_API_TOKEN").expect("set IN_API_TOKEN");
|
||||
let base = base.trim_end_matches('/');
|
||||
let out = Path::new("tests/fixtures");
|
||||
fs::create_dir_all(out).expect("create tests/fixtures");
|
||||
|
||||
let http = reqwest::Client::new();
|
||||
let get = |path: String| {
|
||||
let http = http.clone();
|
||||
let token = token.clone();
|
||||
let url = format!("{base}/{path}");
|
||||
async move {
|
||||
let response = http
|
||||
.get(url)
|
||||
.header("X-Api-Token", token)
|
||||
.header("Accept", "application/json")
|
||||
.header("X-Requested-With", "XMLHttpRequest")
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
let status = response.status();
|
||||
let body = response.text().await.map_err(|e| e.to_string())?;
|
||||
if !status.is_success() {
|
||||
return Err(format!("HTTP {status}"));
|
||||
}
|
||||
serde_json::from_str::<Value>(&body).map_err(|e| e.to_string())
|
||||
}
|
||||
};
|
||||
|
||||
for name in LISTS {
|
||||
// Deleted and archived rows included: on a disposable instance they
|
||||
// are often all there is.
|
||||
// `system_logs` answers the status filter with a 500, so fall back to
|
||||
// the plain list when the filtered one fails.
|
||||
let filtered = format!("{name}?per_page=1&status=active,archived,deleted");
|
||||
let result = match get(filtered).await {
|
||||
Ok(body) => Ok(body),
|
||||
Err(_) => get(format!("{name}?per_page=1")).await,
|
||||
};
|
||||
match result {
|
||||
Ok(body) => match body
|
||||
.get("data")
|
||||
.and_then(Value::as_array)
|
||||
.and_then(|d| d.first())
|
||||
{
|
||||
Some(row) => {
|
||||
write(out, name, &json!({ "data": [shape(row)] }));
|
||||
println!(" captured {name}");
|
||||
}
|
||||
None => println!(" skipped {name}: no rows on this instance"),
|
||||
},
|
||||
Err(e) => println!(" FAILED {name}: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
for (name, path) in BARE {
|
||||
match get((*path).to_string()).await {
|
||||
Ok(body) => {
|
||||
write(out, name, &shape(&body));
|
||||
println!(" captured {name}");
|
||||
}
|
||||
Err(e) => println!(" FAILED {name}: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn write(dir: &Path, name: &str, value: &Value) {
|
||||
let mut text = serde_json::to_string_pretty(value).expect("serialise fixture");
|
||||
text.push('\n');
|
||||
fs::write(dir.join(format!("{name}.json")), text).expect("write fixture");
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
use invoiceninjars::models::ListParams;
|
||||
use invoiceninjars::InvoiceNinjaClient;
|
||||
use invoiceninja_sdk::models::ListParams;
|
||||
use invoiceninja_sdk::InvoiceNinjaClient;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> invoiceninjars::Result<()> {
|
||||
async fn main() -> invoiceninja_sdk::Result<()> {
|
||||
let base_url =
|
||||
std::env::var("IN_BASE_URL").unwrap_or_else(|_| "https://invoicing.co/api/v1".into());
|
||||
let token = std::env::var("IN_API_TOKEN").expect("set IN_API_TOKEN");
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
//! Fills a **disposable** Invoice Ninja instance with one record of each
|
||||
//! common kind, so `capture_shapes` has real rows to take shapes from.
|
||||
//!
|
||||
//! ```sh
|
||||
//! IN_SMOKE_WRITE_HOST=localhost IN_BASE_URL=http://localhost:8080/api/v1 \
|
||||
//! IN_API_TOKEN=... cargo run --example seed_sample_data
|
||||
//! ```
|
||||
//!
|
||||
//! **Never run this against production.** It creates records, advances
|
||||
//! number sequences, and may fire webhooks. It uses the smoke test's guard:
|
||||
//! `IN_SMOKE_WRITE_HOST` must equal the host in `IN_BASE_URL`. Nothing is
|
||||
//! deleted afterwards; the instance is meant to be thrown away.
|
||||
//!
|
||||
//! Each step is independent: one that fails (a validation rule this script
|
||||
//! did not anticipate) is reported and the rest carry on, with anything that
|
||||
//! needed it skipped.
|
||||
|
||||
use std::future::Future;
|
||||
use std::process::ExitCode;
|
||||
|
||||
use invoiceninja_sdk::{Error, InvoiceNinjaClient};
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
fn model<T: DeserializeOwned>(value: Value) -> T {
|
||||
serde_json::from_value(value).expect("seed body matches the model")
|
||||
}
|
||||
|
||||
fn describe(error: &Error) -> String {
|
||||
match error {
|
||||
Error::Api { errors, .. } if !errors.is_empty() => format!("{error} {errors:?}"),
|
||||
_ => error.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs one creation, reporting the outcome and returning the new id.
|
||||
async fn step<T>(name: &str, call: impl Future<Output = invoiceninja_sdk::Result<T>>) -> Option<T> {
|
||||
match call.await {
|
||||
Ok(created) => {
|
||||
println!(" created {name}");
|
||||
Some(created)
|
||||
}
|
||||
Err(e) => {
|
||||
println!(" FAILED {name}: {}", describe(&e));
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> ExitCode {
|
||||
let base_url = std::env::var("IN_BASE_URL").expect("set IN_BASE_URL");
|
||||
let token = std::env::var("IN_API_TOKEN").expect("set IN_API_TOKEN");
|
||||
let host = reqwest::Url::parse(&base_url)
|
||||
.ok()
|
||||
.and_then(|u| u.host_str().map(str::to_string))
|
||||
.unwrap_or_default();
|
||||
if std::env::var("IN_SMOKE_WRITE_HOST").as_deref() != Ok(host.as_str()) {
|
||||
eprintln!("refusing to write: set IN_SMOKE_WRITE_HOST={host} to confirm this instance is disposable");
|
||||
return ExitCode::FAILURE;
|
||||
}
|
||||
let c = InvoiceNinjaClient::new(&base_url, token).expect("client");
|
||||
println!("seeding {host}");
|
||||
|
||||
let line =
|
||||
json!([{ "product_key": "seed-product", "quantity": 1, "cost": 10.0, "notes": "seed" }]);
|
||||
|
||||
let client_id = step(
|
||||
"client",
|
||||
c.create_client(&model(json!({
|
||||
"name": "seed-client",
|
||||
"contacts": [{ "first_name": "Seed", "last_name": "Contact", "email": "seed@example.com" }],
|
||||
}))),
|
||||
)
|
||||
.await
|
||||
.and_then(|x| x.id);
|
||||
let vendor_id = step(
|
||||
"vendor",
|
||||
c.create_vendor(&model(json!({ "name": "seed-vendor" }))),
|
||||
)
|
||||
.await
|
||||
.and_then(|x| x.id);
|
||||
|
||||
step(
|
||||
"product",
|
||||
c.create_product(&model(
|
||||
json!({ "product_key": "seed-product", "price": 10.0 }),
|
||||
)),
|
||||
)
|
||||
.await;
|
||||
step(
|
||||
"tax_rate",
|
||||
c.create_tax_rate(&model(json!({ "name": "seed-tax", "rate": 5.0 }))),
|
||||
)
|
||||
.await;
|
||||
step(
|
||||
"payment_term",
|
||||
c.create_payment_term(&model(json!({ "name": "seed-term", "num_days": 15 }))),
|
||||
)
|
||||
.await;
|
||||
step(
|
||||
"expense_category",
|
||||
c.create_expense_category(&model(json!({ "name": "seed-category" }))),
|
||||
)
|
||||
.await;
|
||||
step(
|
||||
"tag",
|
||||
c.create_tag(&model(
|
||||
json!({ "name": "seed-tag", "entity_type": "client" }),
|
||||
)),
|
||||
)
|
||||
.await;
|
||||
step(
|
||||
"group_setting",
|
||||
c.create_group_setting(&model(json!({ "name": "seed-group" }))),
|
||||
)
|
||||
.await;
|
||||
step(
|
||||
"webhook",
|
||||
c.create_webhook(&model(
|
||||
json!({ "event_id": "1", "target_url": "https://example.com/seed-hook" }),
|
||||
)),
|
||||
)
|
||||
.await;
|
||||
step(
|
||||
"expense",
|
||||
c.create_expense(&model(
|
||||
json!({ "amount": 5.0, "public_notes": "seed", "vendor_id": vendor_id }),
|
||||
)),
|
||||
)
|
||||
.await;
|
||||
step(
|
||||
"recurring_expense",
|
||||
c.create_recurring_expense(&model(
|
||||
json!({ "amount": 5.0, "frequency_id": 5, "vendor_id": vendor_id }),
|
||||
)),
|
||||
)
|
||||
.await;
|
||||
|
||||
if let Some(vendor_id) = &vendor_id {
|
||||
step(
|
||||
"purchase_order",
|
||||
c.create_purchase_order(&model(
|
||||
json!({ "vendor_id": vendor_id, "line_items": line }),
|
||||
)),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
let Some(client_id) = client_id else {
|
||||
println!("no client, so the client-dependent records are skipped");
|
||||
return ExitCode::FAILURE;
|
||||
};
|
||||
|
||||
step(
|
||||
"location",
|
||||
c.create_location(&model(
|
||||
json!({ "name": "seed-location", "client_id": client_id, "address1": "1 Seed St" }),
|
||||
)),
|
||||
)
|
||||
.await;
|
||||
step(
|
||||
"project",
|
||||
c.create_project(&model(
|
||||
json!({ "name": "seed-project", "client_id": client_id }),
|
||||
)),
|
||||
)
|
||||
.await;
|
||||
step(
|
||||
"task",
|
||||
c.create_task(&model(
|
||||
json!({ "description": "seed task", "client_id": client_id }),
|
||||
)),
|
||||
)
|
||||
.await;
|
||||
step(
|
||||
"quote",
|
||||
c.create_quote(&model(
|
||||
json!({ "client_id": client_id, "line_items": line }),
|
||||
)),
|
||||
)
|
||||
.await;
|
||||
step(
|
||||
"credit",
|
||||
c.create_credit(&model(
|
||||
json!({ "client_id": client_id, "line_items": line }),
|
||||
)),
|
||||
)
|
||||
.await;
|
||||
step(
|
||||
"recurring_invoice",
|
||||
c.create_recurring_invoice(&model(
|
||||
json!({ "client_id": client_id, "frequency_id": 5, "line_items": line }),
|
||||
)),
|
||||
)
|
||||
.await;
|
||||
// Expected to 500 on 5.13.50: the `recurring_quotes` table has no
|
||||
// `design_id` column, so the server's own insert fails. Not an SDK bug.
|
||||
step(
|
||||
"recurring_quote",
|
||||
c.create_recurring_quote(&model(
|
||||
json!({ "client_id": client_id, "frequency_id": 5, "line_items": line }),
|
||||
)),
|
||||
)
|
||||
.await;
|
||||
|
||||
let invoice = step(
|
||||
"invoice",
|
||||
c.create_invoice(&model(
|
||||
json!({ "client_id": client_id, "line_items": line }),
|
||||
)),
|
||||
)
|
||||
.await;
|
||||
if let Some(id) = invoice.and_then(|i| i.id) {
|
||||
// A payment can only be applied to an invoice that has been sent.
|
||||
if step("invoice mark_sent", c.mark_invoice_sent(&id))
|
||||
.await
|
||||
.is_some()
|
||||
{
|
||||
step(
|
||||
"payment",
|
||||
c.create_payment(&model(json!({
|
||||
"client_id": client_id,
|
||||
"amount": 5.0,
|
||||
"invoices": [{ "invoice_id": id, "amount": 5.0 }],
|
||||
}))),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
ExitCode::SUCCESS
|
||||
}
|
||||
@@ -0,0 +1,898 @@
|
||||
//! Smoke test against a real Invoice Ninja instance.
|
||||
//!
|
||||
//! The rest of the suite runs against mock servers written from the upstream
|
||||
//! source, so it cannot catch a place where the real server disagrees with
|
||||
//! that reading. This can. It prints PASS/FAIL per check, never the contents
|
||||
//! of any record, and exits non-zero if anything failed.
|
||||
//!
|
||||
//! ```sh
|
||||
//! # Read-only: GET requests only.
|
||||
//! IN_BASE_URL=http://localhost:8080/api/v1 IN_API_TOKEN=... \
|
||||
//! cargo run --example smoke_test
|
||||
//!
|
||||
//! # Adds the write tier. Naming the host is the guard: it must equal the
|
||||
//! # host in IN_BASE_URL, so a write cannot reach a server you did not name.
|
||||
//! IN_SMOKE_WRITE_HOST=localhost IN_BASE_URL=... IN_API_TOKEN=... \
|
||||
//! cargo run --example smoke_test
|
||||
//! ```
|
||||
//!
|
||||
//! The read tier also fetches the same rows raw and fails if a typed row lost
|
||||
//! a key. The write tier creates a client, attaches files to it, creates a
|
||||
//! purchase order, location, webhook, and recurring invoice from default
|
||||
//! models, and deletes what it made.
|
||||
//!
|
||||
//! With `IN_API_PASSWORD` also set, a password tier runs inside the write
|
||||
//! tier: it merges two throwaway clients and purges the survivor, which
|
||||
//! really removes the row. Take the password from the environment, never from
|
||||
//! a command line or chat.
|
||||
//!
|
||||
//! **Do not point the write tier at a production server.** Even after the delete that
|
||||
//! leaves a gap in the client number sequence and a soft-deleted row (a real
|
||||
//! purge needs the account password), and it may fire webhooks.
|
||||
//!
|
||||
//! `IN_BASE_URL` has no default, unlike the other example, because defaulting
|
||||
//! to the hosted app is the wrong failure for a tool that can write.
|
||||
|
||||
use std::fmt::Display;
|
||||
use std::process::ExitCode;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use invoiceninja_sdk::models::{
|
||||
Client, ListParams, Location, PurchaseOrder, RecurringInvoice, Vendor, Webhook,
|
||||
};
|
||||
use invoiceninja_sdk::{Error, FileUpload, InvoiceNinjaClient};
|
||||
use serde::Serialize;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
#[derive(Default)]
|
||||
struct Report {
|
||||
passed: u32,
|
||||
failed: u32,
|
||||
}
|
||||
|
||||
impl Report {
|
||||
fn pass(&mut self, name: &str, detail: impl Display) {
|
||||
println!(" PASS {name:<34} {detail}");
|
||||
self.passed += 1;
|
||||
}
|
||||
|
||||
fn fail(&mut self, name: &str, error: impl Display) {
|
||||
println!(" FAIL {name:<34} {error}");
|
||||
self.failed += 1;
|
||||
}
|
||||
|
||||
fn check(&mut self, name: &str, ok: bool, detail: impl Display) {
|
||||
if ok {
|
||||
self.pass(name, detail);
|
||||
} else {
|
||||
self.fail(name, detail);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Renders an error with its per-field validation map, which is usually what
|
||||
/// explains a 422.
|
||||
fn describe(error: &Error) -> String {
|
||||
match error {
|
||||
Error::Api { errors, .. } if !errors.is_empty() => format!("{error} {errors:?}"),
|
||||
_ => error.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Plain GETs for the raw JSON, so a typed result can be compared with what
|
||||
/// the server actually sent. Decoding successfully proves little: a model
|
||||
/// that quietly dropped a field would still decode.
|
||||
struct Raw {
|
||||
http: reqwest::Client,
|
||||
base: String,
|
||||
token: String,
|
||||
}
|
||||
|
||||
impl Raw {
|
||||
async fn rows(&self, path: &str) -> Option<Vec<Value>> {
|
||||
let body: Value = self
|
||||
.http
|
||||
.get(format!("{}/{path}?per_page=2", self.base))
|
||||
.header("X-Api-Token", &self.token)
|
||||
.header("Accept", "application/json")
|
||||
.header("X-Requested-With", "XMLHttpRequest")
|
||||
.send()
|
||||
.await
|
||||
.ok()?
|
||||
.json()
|
||||
.await
|
||||
.ok()?;
|
||||
body.get("data")?.as_array().cloned()
|
||||
}
|
||||
}
|
||||
|
||||
/// Keys that are left out of a request when empty on purpose (the server
|
||||
/// rejects the empty value), so an empty one is not a lost key.
|
||||
const OMITTED_WHEN_EMPTY: &[&str] = &["country_id", "status_id", "format"];
|
||||
|
||||
/// Key paths in `raw` that `typed` no longer has.
|
||||
fn lost_keys(raw: &Value, typed: &Value, prefix: &str, into: &mut Vec<String>) {
|
||||
match (raw, typed) {
|
||||
(Value::Object(raw), Value::Object(typed)) => {
|
||||
for (key, raw_value) in raw {
|
||||
match typed.get(key) {
|
||||
Some(typed_value) => {
|
||||
lost_keys(raw_value, typed_value, &format!("{prefix}.{key}"), into)
|
||||
}
|
||||
None if raw_value == "" && OMITTED_WHEN_EMPTY.contains(&key.as_str()) => {}
|
||||
None => into.push(format!("{prefix}.{key}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
(Value::Array(raw), Value::Array(typed)) => {
|
||||
if let (Some(raw), Some(typed)) = (raw.first(), typed.first()) {
|
||||
lost_keys(raw, typed, &format!("{prefix}[]"), into);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetches the same rows raw and requires the typed ones to keep every key.
|
||||
/// Only key names are printed, never values.
|
||||
async fn key_loss(report: &mut Report, raw: &Raw, path: &str, typed: &[impl Serialize]) {
|
||||
let Some(raw_rows) = raw.rows(path).await else {
|
||||
report.fail(
|
||||
&format!("keys kept: {path}"),
|
||||
"could not fetch the raw rows",
|
||||
);
|
||||
return;
|
||||
};
|
||||
let mut lost = Vec::new();
|
||||
for (raw_row, typed_row) in raw_rows.iter().zip(typed) {
|
||||
let typed_row = serde_json::to_value(typed_row).unwrap_or(Value::Null);
|
||||
lost_keys(raw_row, &typed_row, path, &mut lost);
|
||||
}
|
||||
lost.sort();
|
||||
lost.dedup();
|
||||
report.check(
|
||||
&format!("keys kept: {path}"),
|
||||
lost.is_empty(),
|
||||
if lost.is_empty() {
|
||||
format!("{} row(s), no key lost", raw_rows.len().min(typed.len()))
|
||||
} else {
|
||||
format!("lost {}", lost.join(", "))
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> ExitCode {
|
||||
let base_url = std::env::var("IN_BASE_URL").expect("set IN_BASE_URL (no default, on purpose)");
|
||||
let token = std::env::var("IN_API_TOKEN").expect("set IN_API_TOKEN");
|
||||
|
||||
let host = host_of(&base_url);
|
||||
let raw = Raw {
|
||||
http: reqwest::Client::new(),
|
||||
base: base_url.trim_end_matches('/').to_string(),
|
||||
token: token.clone(),
|
||||
};
|
||||
let client = match InvoiceNinjaClient::new(&base_url, token) {
|
||||
Ok(client) => client,
|
||||
Err(e) => {
|
||||
eprintln!("could not build client: {e}");
|
||||
return ExitCode::FAILURE;
|
||||
}
|
||||
};
|
||||
|
||||
println!("target: {host}");
|
||||
let mut report = Report::default();
|
||||
|
||||
println!("\nread-only checks");
|
||||
read_checks(&client, &raw, &mut report).await;
|
||||
|
||||
// Empty counts as unset: CI passes the variable through even when blank.
|
||||
match std::env::var("IN_SMOKE_WRITE_HOST")
|
||||
.ok()
|
||||
.filter(|v| !v.is_empty())
|
||||
{
|
||||
Some(allowed) if allowed == host => {
|
||||
println!("\nwrite checks (IN_SMOKE_WRITE_HOST matches {host})");
|
||||
write_checks(&client, &mut report).await;
|
||||
|
||||
match std::env::var("IN_API_PASSWORD")
|
||||
.ok()
|
||||
.filter(|v| !v.is_empty())
|
||||
{
|
||||
Some(password) => {
|
||||
println!("\npassword checks (IN_API_PASSWORD is set)");
|
||||
password_checks(&client, &mut report, password).await;
|
||||
}
|
||||
None => println!("\npassword checks skipped (set IN_API_PASSWORD to enable)"),
|
||||
}
|
||||
}
|
||||
Some(allowed) => {
|
||||
println!(
|
||||
"\nwrite checks SKIPPED: IN_SMOKE_WRITE_HOST is `{allowed}` but the target is `{host}`"
|
||||
);
|
||||
}
|
||||
None => println!("\nwrite checks skipped (set IN_SMOKE_WRITE_HOST to enable)"),
|
||||
}
|
||||
|
||||
println!("\n{} passed, {} failed", report.passed, report.failed);
|
||||
if report.failed == 0 {
|
||||
ExitCode::SUCCESS
|
||||
} else {
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
}
|
||||
|
||||
fn host_of(base_url: &str) -> String {
|
||||
reqwest::Url::parse(base_url)
|
||||
.ok()
|
||||
.and_then(|url| url.host_str().map(str::to_string))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
async fn read_checks(client: &InvoiceNinjaClient, raw: &Raw, report: &mut Report) {
|
||||
let small = ListParams::new().per_page(2);
|
||||
|
||||
// Decodes one page, then (if there are rows) checks none of their keys
|
||||
// were lost. The REST path is the method name without `list_`.
|
||||
macro_rules! list {
|
||||
($report:expr, $name:literal, $call:expr) => {
|
||||
match $call.await {
|
||||
Ok(rows) => {
|
||||
$report.pass($name, format!("{} row(s) decoded", rows.len()));
|
||||
if !rows.is_empty() {
|
||||
key_loss($report, raw, $name.trim_start_matches("list_"), &rows).await;
|
||||
}
|
||||
}
|
||||
Err(e) => $report.fail($name, describe(&e)),
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
match client.ping().await {
|
||||
Ok(()) => report.pass("ping", "authenticated"),
|
||||
Err(e) => report.fail("ping", describe(&e)),
|
||||
}
|
||||
|
||||
// Hosted returns a bare message instead of a report; both must decode.
|
||||
match client.health_check().await {
|
||||
Ok(health) => report.pass(
|
||||
"health_check",
|
||||
match health.system_health {
|
||||
Some(ok) => format!("self-hosted report, system_health={ok}"),
|
||||
None => "bare message (no report on this deployment)".to_string(),
|
||||
},
|
||||
),
|
||||
Err(e) => report.fail("health_check", describe(&e)),
|
||||
}
|
||||
|
||||
match client.statics().await {
|
||||
Ok(s) => report.check(
|
||||
"statics",
|
||||
!s.currencies.is_empty() && !s.countries.is_empty(),
|
||||
format!(
|
||||
"{} currencies, {} countries, {} unmodeled key(s) kept in extra",
|
||||
s.currencies.len(),
|
||||
s.countries.len(),
|
||||
s.extra.len()
|
||||
),
|
||||
),
|
||||
Err(e) => report.fail("statics", describe(&e)),
|
||||
}
|
||||
|
||||
match client.search(&json!({ "search": "a" })).await {
|
||||
Ok(results) => report.pass(
|
||||
"search",
|
||||
format!(
|
||||
"{} client hit(s), {} unmodeled bucket(s)",
|
||||
results.clients.len(),
|
||||
results.extra.len()
|
||||
),
|
||||
),
|
||||
Err(e) => report.fail("search", describe(&e)),
|
||||
}
|
||||
|
||||
match client.subscription_steps().await {
|
||||
Ok(steps) => report.check(
|
||||
"subscription_steps",
|
||||
!steps.is_empty() && steps.iter().all(|s| !s.id.is_empty()),
|
||||
format!("{} step(s) decoded", steps.len()),
|
||||
),
|
||||
Err(e) => report.fail("subscription_steps", describe(&e)),
|
||||
}
|
||||
|
||||
match client.activity_feed(Some(5)).await {
|
||||
Ok(feed) => report.check(
|
||||
"activity_feed",
|
||||
feed.iter().all(|e| e.activity_type_id > 0),
|
||||
format!("{} entr(ies), numeric type ids", feed.len()),
|
||||
),
|
||||
Err(e) => report.fail("activity_feed", describe(&e)),
|
||||
}
|
||||
|
||||
// A POST, but it only renders: nothing is stored. Needs an invoice to
|
||||
// render, so it is skipped on an empty instance.
|
||||
match client.list_invoices(&small).await {
|
||||
Ok(rows) => match rows
|
||||
.first()
|
||||
.and_then(|i| i.id.clone().map(|id| (id, i.client_id.clone())))
|
||||
{
|
||||
Some((id, client_id)) => {
|
||||
let body = json!({ "entity": "invoice", "entity_id": id, "client_id": client_id });
|
||||
match client.live_preview(&body).await {
|
||||
Ok(pdf) => report.check(
|
||||
"live_preview",
|
||||
pdf.starts_with(b"%PDF"),
|
||||
format!("{} byte(s), starts with the PDF magic", pdf.len()),
|
||||
),
|
||||
Err(e) => report.fail("live_preview", describe(&e)),
|
||||
}
|
||||
}
|
||||
None => report.pass("live_preview", "skipped: no invoices on this instance"),
|
||||
},
|
||||
Err(e) => report.fail("live_preview", describe(&e)),
|
||||
}
|
||||
|
||||
// The invitation-key download, on the singular `invoice/{key}/download`
|
||||
// route. Worth a real-server check for one specific reason: this was
|
||||
// listed as unwrappable for months on the belief that it takes no auth
|
||||
// token, and the route file says otherwise. If that reading is still
|
||||
// wrong, the token we send is either ignored or rejected, and this is
|
||||
// where it shows. The key comes from the invoice's own `invitations`
|
||||
// array, which the list route returns without an `include`.
|
||||
//
|
||||
// Only the PDF, not the e-document: `download_e_invoice` renders from
|
||||
// the company's e-invoice settings, so an instance with none configured
|
||||
// would fail here for a reason that is not the client's fault.
|
||||
match client.list_invoices(&small).await {
|
||||
Ok(rows) => match rows.iter().find_map(|i| {
|
||||
i.extra
|
||||
.get("invitations")?
|
||||
.as_array()?
|
||||
.first()?
|
||||
.get("key")?
|
||||
.as_str()
|
||||
}) {
|
||||
Some(key) => match client.download_invoice_by_invitation_key(key).await {
|
||||
Ok(pdf) => report.check(
|
||||
"download_invoice_by_invitation_key",
|
||||
pdf.starts_with(b"%PDF"),
|
||||
format!("{} byte(s), starts with the PDF magic", pdf.len()),
|
||||
),
|
||||
Err(e) => report.fail("download_invoice_by_invitation_key", describe(&e)),
|
||||
},
|
||||
None => report.pass(
|
||||
"download_invoice_by_invitation_key",
|
||||
"skipped: no invoice with an invitation on this instance",
|
||||
),
|
||||
},
|
||||
Err(e) => report.fail("download_invoice_by_invitation_key", describe(&e)),
|
||||
}
|
||||
|
||||
list!(report, "list_clients", client.list_clients(&small));
|
||||
list!(report, "list_invoices", client.list_invoices(&small));
|
||||
list!(report, "list_quotes", client.list_quotes(&small));
|
||||
list!(report, "list_credits", client.list_credits(&small));
|
||||
list!(report, "list_payments", client.list_payments(&small));
|
||||
list!(report, "list_products", client.list_products(&small));
|
||||
list!(report, "list_expenses", client.list_expenses(&small));
|
||||
list!(report, "list_projects", client.list_projects(&small));
|
||||
list!(report, "list_tasks", client.list_tasks(&small));
|
||||
list!(report, "list_vendors", client.list_vendors(&small));
|
||||
list!(
|
||||
report,
|
||||
"list_recurring_invoices",
|
||||
client.list_recurring_invoices(&small)
|
||||
);
|
||||
list!(
|
||||
report,
|
||||
"list_recurring_quotes",
|
||||
client.list_recurring_quotes(&small)
|
||||
);
|
||||
list!(
|
||||
report,
|
||||
"list_recurring_expenses",
|
||||
client.list_recurring_expenses(&small)
|
||||
);
|
||||
list!(
|
||||
report,
|
||||
"list_purchase_orders",
|
||||
client.list_purchase_orders(&small)
|
||||
);
|
||||
list!(report, "list_tax_rates", client.list_tax_rates(&small));
|
||||
list!(
|
||||
report,
|
||||
"list_expense_categories",
|
||||
client.list_expense_categories(&small)
|
||||
);
|
||||
list!(
|
||||
report,
|
||||
"list_task_statuses",
|
||||
client.list_task_statuses(&small)
|
||||
);
|
||||
list!(
|
||||
report,
|
||||
"list_payment_terms",
|
||||
client.list_payment_terms(&small)
|
||||
);
|
||||
list!(
|
||||
report,
|
||||
"list_subscriptions",
|
||||
client.list_subscriptions(&small)
|
||||
);
|
||||
list!(
|
||||
report,
|
||||
"list_bank_transactions",
|
||||
client.list_bank_transactions(&small)
|
||||
);
|
||||
list!(report, "list_webhooks", client.list_webhooks(&small));
|
||||
list!(
|
||||
report,
|
||||
"list_bank_integrations",
|
||||
client.list_bank_integrations(&small)
|
||||
);
|
||||
list!(
|
||||
report,
|
||||
"list_bank_transaction_rules",
|
||||
client.list_bank_transaction_rules(&small)
|
||||
);
|
||||
list!(
|
||||
report,
|
||||
"list_company_gateways",
|
||||
client.list_company_gateways(&small)
|
||||
);
|
||||
list!(report, "list_designs", client.list_designs(&small));
|
||||
list!(
|
||||
report,
|
||||
"list_group_settings",
|
||||
client.list_group_settings(&small)
|
||||
);
|
||||
list!(report, "list_locations", client.list_locations(&small));
|
||||
list!(report, "list_tags", client.list_tags(&small));
|
||||
list!(
|
||||
report,
|
||||
"list_task_schedulers",
|
||||
client.list_task_schedulers(&small)
|
||||
);
|
||||
list!(report, "list_tokens", client.list_tokens(&small));
|
||||
list!(
|
||||
report,
|
||||
"list_client_gateway_tokens",
|
||||
client.list_client_gateway_tokens(&small)
|
||||
);
|
||||
list!(report, "list_companies", client.list_companies(&small));
|
||||
list!(report, "list_system_logs", client.list_system_logs(&small));
|
||||
list!(report, "list_documents", client.list_documents(&small));
|
||||
list!(report, "list_users", client.list_users(&small));
|
||||
list!(report, "list_activities", client.list_activities(&small));
|
||||
|
||||
// The walk follows `meta.pagination.total_pages`, which only a real server
|
||||
// can show we are reading correctly. Compare it to one oversized request.
|
||||
let everything = ListParams::new().per_page(5000);
|
||||
match (
|
||||
client
|
||||
.list_all_clients(&ListParams::new().per_page(2))
|
||||
.await,
|
||||
client.list_clients(&everything).await,
|
||||
) {
|
||||
(Ok(walked), Ok(single)) if single.len() < 5000 => report.check(
|
||||
"pagination walk == single request",
|
||||
walked.len() == single.len(),
|
||||
format!(
|
||||
"walked {} at per_page=2, single request returned {}",
|
||||
walked.len(),
|
||||
single.len()
|
||||
),
|
||||
),
|
||||
(Ok(_), Ok(_)) => report.pass("pagination walk", "skipped: 5000+ clients"),
|
||||
(Err(e), _) | (_, Err(e)) => report.fail("pagination walk", describe(&e)),
|
||||
}
|
||||
|
||||
// The server caps per_page. Ask for far more than the cap and see how many
|
||||
// rows come back, which is the fact the clamp in `get_all_pages` rests on.
|
||||
match client
|
||||
.list_clients(&ListParams::new().per_page(100_000))
|
||||
.await
|
||||
{
|
||||
Ok(rows) => report.pass(
|
||||
"per_page cap",
|
||||
format!("asked for 100000, got {} row(s) (cap is 5000)", rows.len()),
|
||||
),
|
||||
Err(e) => report.fail("per_page cap", describe(&e)),
|
||||
}
|
||||
|
||||
// A single fetch by id, to exercise `get_one` and `seg`.
|
||||
match client.list_clients(&small).await {
|
||||
Ok(rows) => match rows.first().and_then(|c| c.id.clone()) {
|
||||
Some(id) => match client.get_client(&id).await {
|
||||
Ok(c) => report.pass(
|
||||
"get_client",
|
||||
format!("fetched one, {} unmodeled field(s) in extra", c.extra.len()),
|
||||
),
|
||||
Err(e) => report.fail("get_client", describe(&e)),
|
||||
},
|
||||
None => report.pass("get_client", "skipped: no clients on this instance"),
|
||||
},
|
||||
Err(e) => report.fail("get_client", describe(&e)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn write_checks(client: &InvoiceNinjaClient, report: &mut Report) {
|
||||
let nanos = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_nanos())
|
||||
.unwrap_or(0);
|
||||
let name = format!("sdk-smoke-{nanos}");
|
||||
|
||||
let created = match client
|
||||
.create_client(&Client {
|
||||
name: name.clone(),
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(created) => created,
|
||||
Err(e) => {
|
||||
report.fail("create_client", describe(&e));
|
||||
return;
|
||||
}
|
||||
};
|
||||
let Some(id) = created.id.clone() else {
|
||||
report.fail("create_client", "response carried no id");
|
||||
return;
|
||||
};
|
||||
report.pass("create_client", format!("created `{name}`"));
|
||||
|
||||
upload_checks(client, report, &id).await;
|
||||
round_trip_checks(client, report, &id, created.extra.len()).await;
|
||||
extras_checks(client, report, &id).await;
|
||||
creation_checks(client, report, &id).await;
|
||||
activity_checks(client, report, &id).await;
|
||||
|
||||
// Always runs, including after a failed check above.
|
||||
match client.delete_client(&id).await {
|
||||
Ok(()) => report.pass(
|
||||
"delete_client",
|
||||
"soft-deleted (a purge needs the account password)",
|
||||
),
|
||||
Err(e) => report.fail(
|
||||
"delete_client",
|
||||
format!("{} -- remove `{name}` by hand", describe(&e)),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
async fn upload_checks(client: &InvoiceNinjaClient, report: &mut Report, id: &str) {
|
||||
let text = |label: &str| {
|
||||
FileUpload::new(
|
||||
format!("{label}.txt"),
|
||||
format!("smoke {label}").into_bytes(),
|
||||
)
|
||||
.mime("text/plain")
|
||||
};
|
||||
let count_documents = |c: &Client| {
|
||||
c.extra
|
||||
.get("documents")
|
||||
.and_then(|d| d.as_array())
|
||||
.map(Vec::len)
|
||||
};
|
||||
|
||||
// POST + `_method=PUT`: read from the framework source, then observed
|
||||
// working on 5.13.50.
|
||||
match client
|
||||
.upload_client(id, vec![text("first")], Some(false))
|
||||
.await
|
||||
{
|
||||
Ok(c) => report.check(
|
||||
"upload_client (1 file, private)",
|
||||
count_documents(&c) == Some(1),
|
||||
format!("documents on record: {:?}", count_documents(&c)),
|
||||
),
|
||||
Err(e) => report.fail("upload_client (1 file, private)", describe(&e)),
|
||||
}
|
||||
|
||||
match client
|
||||
.upload_client(id, vec![text("second"), text("third")], Some(true))
|
||||
.await
|
||||
{
|
||||
Ok(c) => report.check(
|
||||
"upload_client (2 files, public)",
|
||||
count_documents(&c) == Some(3),
|
||||
format!(
|
||||
"documents on record: {:?} (expected 3)",
|
||||
count_documents(&c)
|
||||
),
|
||||
),
|
||||
Err(e) => report.fail("upload_client (2 files, public)", describe(&e)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn round_trip_checks(
|
||||
client: &InvoiceNinjaClient,
|
||||
report: &mut Report,
|
||||
id: &str,
|
||||
extra_keys_at_create: usize,
|
||||
) {
|
||||
// `get` then `update` must not drop fields the crate does not model.
|
||||
let fetched = match client.get_client(id).await {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
report.fail("get_client (round trip)", describe(&e));
|
||||
return;
|
||||
}
|
||||
};
|
||||
let before = fetched.extra.len();
|
||||
|
||||
let mut edited = fetched;
|
||||
edited.private_notes = "edited by smoke test".into();
|
||||
let updated = match client.update_client(id, &edited).await {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
report.fail("update_client", describe(&e));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
report.check(
|
||||
"update_client preserves extra",
|
||||
updated.extra.len() >= before.min(extra_keys_at_create),
|
||||
format!(
|
||||
"extra keys: {extra_keys_at_create} at create, {before} on get, {} after update",
|
||||
updated.extra.len()
|
||||
),
|
||||
);
|
||||
report.check(
|
||||
"update_client applied the edit",
|
||||
updated.private_notes == "edited by smoke test",
|
||||
"private_notes round-tripped",
|
||||
);
|
||||
}
|
||||
|
||||
/// Per-client extras that only read or reshape data already on the record.
|
||||
/// `updateTaxData` and `check_vat` are left out: they call external services.
|
||||
async fn extras_checks(client: &InvoiceNinjaClient, report: &mut Report, id: &str) {
|
||||
match client.client_show_settings(id).await {
|
||||
Ok(settings) => report.check(
|
||||
"client_show_settings",
|
||||
settings.is_object(),
|
||||
format!("{} setting(s)", settings.as_object().map_or(0, |m| m.len())),
|
||||
),
|
||||
Err(e) => report.fail("client_show_settings", describe(&e)),
|
||||
}
|
||||
|
||||
match client.client_vat_status(id).await {
|
||||
Ok(status) => report.pass(
|
||||
"client_vat_status",
|
||||
format!("message of {} char(s)", status.message.len()),
|
||||
),
|
||||
Err(e) => report.fail("client_vat_status", describe(&e)),
|
||||
}
|
||||
|
||||
// The two uploads in `upload_checks` should be visible here.
|
||||
match client.client_documents(id, &json!({})).await {
|
||||
Ok(docs) => report.check(
|
||||
"client_documents",
|
||||
docs.len() == 3,
|
||||
format!("{} document(s) (expected 3)", docs.len()),
|
||||
),
|
||||
Err(e) => report.fail("client_documents", describe(&e)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates from bare `Default` models the records that broke against a real
|
||||
/// server: an empty `status_id`, `country_id` or `format` was rejected, and a
|
||||
/// string `frequency_id` failed to decode after the record existed. Each is
|
||||
/// deleted again straight away.
|
||||
async fn creation_checks(client: &InvoiceNinjaClient, report: &mut Report, client_id: &str) {
|
||||
let stamp = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_nanos())
|
||||
.unwrap_or(0);
|
||||
|
||||
let vendor = match client
|
||||
.create_vendor(&Vendor {
|
||||
name: format!("sdk-smoke-vendor-{stamp}"),
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(vendor) => vendor,
|
||||
Err(e) => {
|
||||
report.fail("create_vendor", describe(&e));
|
||||
return;
|
||||
}
|
||||
};
|
||||
let vendor_id = vendor.id.clone().unwrap_or_default();
|
||||
|
||||
match client
|
||||
.create_purchase_order(&PurchaseOrder {
|
||||
vendor_id: vendor_id.clone(),
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(po) => {
|
||||
report.pass("create_purchase_order (default)", "empty status_id omitted");
|
||||
if let Some(id) = po.id {
|
||||
let _ = client.delete_purchase_order(&id).await;
|
||||
}
|
||||
}
|
||||
Err(e) => report.fail("create_purchase_order (default)", describe(&e)),
|
||||
}
|
||||
|
||||
match client
|
||||
.create_location(&Location {
|
||||
name: format!("sdk-smoke-location-{stamp}"),
|
||||
client_id: client_id.to_string(),
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(location) => {
|
||||
report.pass("create_location (default)", "empty country_id omitted");
|
||||
if let Some(id) = location.id {
|
||||
let _ = client.delete_location(&id).await;
|
||||
}
|
||||
}
|
||||
Err(e) => report.fail("create_location (default)", describe(&e)),
|
||||
}
|
||||
|
||||
match client
|
||||
.create_webhook(&Webhook {
|
||||
event_id: "1".into(),
|
||||
target_url: "https://example.com/sdk-smoke".into(),
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(hook) => {
|
||||
report.pass("create_webhook (default)", "empty format omitted");
|
||||
if let Some(id) = hook.id {
|
||||
let _ = client.delete_webhook(&id).await;
|
||||
}
|
||||
}
|
||||
Err(e) => report.fail("create_webhook (default)", describe(&e)),
|
||||
}
|
||||
|
||||
// The create succeeds either way; decoding the reply is what failed.
|
||||
match client
|
||||
.create_recurring_invoice(&RecurringInvoice {
|
||||
client_id: client_id.to_string(),
|
||||
frequency_id: 5,
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(recurring) => {
|
||||
report.check(
|
||||
"create_recurring_invoice",
|
||||
recurring.frequency_id == 5,
|
||||
format!("frequency_id decoded as {}", recurring.frequency_id),
|
||||
);
|
||||
if let Some(id) = recurring.id {
|
||||
let _ = client.delete_recurring_invoice(&id).await;
|
||||
}
|
||||
}
|
||||
Err(e) => report.fail("create_recurring_invoice", describe(&e)),
|
||||
}
|
||||
|
||||
if let Err(e) = client.delete_vendor(&vendor_id).await {
|
||||
report.fail(
|
||||
"delete_vendor",
|
||||
format!("{} -- remove the vendor by hand", describe(&e)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Password tier: needs `IN_API_PASSWORD`, and only runs inside the write
|
||||
/// tier's host guard. Merges two throwaway clients, then purges the survivor,
|
||||
/// which unlike `delete_client` really removes the row. Users, companies and
|
||||
/// vendors are left alone: inviting a user sends mail, and a company purge
|
||||
/// takes data that was not created here.
|
||||
async fn password_checks(client: &InvoiceNinjaClient, report: &mut Report, password: String) {
|
||||
let client = match client.clone().with_api_password(password) {
|
||||
Ok(client) => client,
|
||||
Err(e) => {
|
||||
report.fail("with_api_password", describe(&e));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
match client.new_user_template().await {
|
||||
Ok(user) => report.check(
|
||||
"new_user_template",
|
||||
user.id.as_deref().unwrap_or("").is_empty(),
|
||||
"blank user, nothing stored",
|
||||
),
|
||||
Err(e) => report.fail("new_user_template", describe(&e)),
|
||||
}
|
||||
|
||||
let stamp = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_nanos())
|
||||
.unwrap_or(0);
|
||||
let mut ids = Vec::new();
|
||||
for label in ["keep", "drop"] {
|
||||
match client
|
||||
.create_client(&Client {
|
||||
name: format!("sdk-smoke-merge-{label}-{stamp}"),
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(created) => ids.extend(created.id),
|
||||
Err(e) => {
|
||||
report.fail("create_client (merge)", describe(&e));
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
let [keep, drop] = [ids[0].clone(), ids[1].clone()];
|
||||
|
||||
match client.merge_clients(&keep, &drop).await {
|
||||
Ok(merged) => report.check(
|
||||
"merge_clients",
|
||||
merged.id.as_deref() == Some(keep.as_str()),
|
||||
"the first id is the survivor",
|
||||
),
|
||||
Err(e) => report.fail("merge_clients", describe(&e)),
|
||||
}
|
||||
|
||||
match client.purge_client(&keep).await {
|
||||
Ok(message) => report.pass("purge_client", format!("`{}`", message.message)),
|
||||
Err(e) => report.fail(
|
||||
"purge_client",
|
||||
format!(
|
||||
"{} -- remove the sdk-smoke-merge clients by hand",
|
||||
describe(&e)
|
||||
),
|
||||
),
|
||||
}
|
||||
|
||||
// A purge is permanent, so the record must now be gone, not just flagged.
|
||||
// A missing record answers 400, not 404: the server maps Laravel's
|
||||
// ModelNotFoundException to a 400 and keeps 404 for a missing route. The
|
||||
// message is checked too, so a 400 raised for some other reason fails.
|
||||
match client.get_client(&keep).await {
|
||||
Err(Error::Api {
|
||||
status, message, ..
|
||||
}) if status.as_u16() == 400 && message.contains("No query results") => report.pass(
|
||||
"get_client after purge",
|
||||
"400 no query results, the row is gone",
|
||||
),
|
||||
Err(e) => report.fail("get_client after purge", describe(&e)),
|
||||
Ok(_) => report.fail("get_client after purge", "still readable after a purge"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Notes, the per-record feed and email history, against the throwaway
|
||||
/// client. Nothing here sends mail: the history is read, and is expected to
|
||||
/// be empty because no mail has gone out for this client.
|
||||
async fn activity_checks(client: &InvoiceNinjaClient, report: &mut Report, id: &str) {
|
||||
let note = "sdk smoke note";
|
||||
match client.add_activity_note("clients", id, note).await {
|
||||
Ok(activity) => report.check(
|
||||
"add_activity_note",
|
||||
activity.notes == note && activity.client_id == id,
|
||||
"note stored against the client",
|
||||
),
|
||||
Err(e) => report.fail("add_activity_note", describe(&e)),
|
||||
}
|
||||
|
||||
match client.entity_activity("client", id, Some(10)).await {
|
||||
Ok(feed) => report.check(
|
||||
"entity_activity",
|
||||
feed.iter().any(|e| e.notes == note),
|
||||
format!("{} entr(ies), including the note", feed.len()),
|
||||
),
|
||||
Err(e) => report.fail("entity_activity", describe(&e)),
|
||||
}
|
||||
|
||||
match client.client_email_history(id).await {
|
||||
Ok(history) => report.check(
|
||||
"client_email_history",
|
||||
history.is_empty(),
|
||||
"no mail sent, so no history",
|
||||
),
|
||||
Err(e) => report.fail("client_email_history", describe(&e)),
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
description = "invoiceninjars: a Rust client for the Invoice Ninja v5 API";
|
||||
description = "invoiceninja-sdk: a Rust client for the Invoice Ninja v5 API";
|
||||
|
||||
inputs = {
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||
@@ -16,19 +16,67 @@
|
||||
overlays = [ rust-overlay.overlays.default ];
|
||||
};
|
||||
|
||||
rustToolchain = pkgs.rust-bin.stable.latest.default.override {
|
||||
extensions = [ "rust-src" "rust-analyzer" "clippy" "rustfmt" ];
|
||||
# Pinned rather than `stable.latest`, so the toolchain only moves
|
||||
# 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 {
|
||||
# jq is for the MSRV recheck in CLAUDE.md, which pipes `cargo
|
||||
# metadata` through it; the rest is the toolchain plus what
|
||||
# openssl-sys needs to link.
|
||||
packages = [ toolchain pkgs.pkg-config pkgs.openssl pkgs.jq ];
|
||||
RUST_SRC_PATH = "${toolchain}/lib/rustlib/src/rust/library";
|
||||
};
|
||||
|
||||
# Built with the pinned dev toolchain, not nixpkgs' own rustc, so a
|
||||
# `nix build` matches what `nix develop` and CI compile with rather
|
||||
# than whatever stable nixpkgs happens to carry.
|
||||
rustPlatform = pkgs.makeRustPlatform {
|
||||
cargo = rustToolchain;
|
||||
rustc = rustToolchain;
|
||||
};
|
||||
in
|
||||
{
|
||||
devShells.default = pkgs.mkShell {
|
||||
packages = [
|
||||
rustToolchain
|
||||
pkgs.pkg-config
|
||||
pkgs.openssl
|
||||
];
|
||||
devShells.default = shellFor rustToolchain;
|
||||
|
||||
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;
|
||||
|
||||
# This is a library with no `[[bin]]` target (see the "No CLI, and
|
||||
# no binary at all" non-goal in GOALS.md), so the output directory
|
||||
# ends up empty: `cargoInstallHook` only copies binaries matching a
|
||||
# `[[bin]]` target out of `target/release`, and there are none to
|
||||
# copy. `nix build` is still meaningful as a build-and-test gate —
|
||||
# it compiles with the pinned toolchain and runs the full suite,
|
||||
# including doctests, inside the Nix sandbox (no network, matching
|
||||
# the "no credentials needed" property in CLAUDE.md) — just not as
|
||||
# a source of an installable artifact.
|
||||
packages.default = rustPlatform.buildRustPackage {
|
||||
pname = "invoiceninja-sdk";
|
||||
version = (builtins.fromTOML (builtins.readFile ./Cargo.toml)).package.version;
|
||||
src = self;
|
||||
cargoLock.lockFile = ./Cargo.lock;
|
||||
nativeBuildInputs = [ pkgs.pkg-config ];
|
||||
buildInputs = [ pkgs.openssl ];
|
||||
# The test suite needs no network (see CLAUDE.md), so the default
|
||||
# `cargo test` checkPhase runs unmodified in the sandboxed build.
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
+2347
-32
File diff suppressed because it is too large
Load Diff
+77
-3
@@ -10,14 +10,40 @@ pub struct ApiErrorBody {
|
||||
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)]
|
||||
#[non_exhaustive]
|
||||
pub enum Error {
|
||||
#[error("http transport error: {0}")]
|
||||
/// Rendered with its whole cause chain, because `reqwest`'s own message
|
||||
/// stops at "error sending request for url (...)" and leaves out the part
|
||||
/// that says why: "Connection refused", a DNS failure, a TLS handshake
|
||||
/// error, a timeout.
|
||||
#[error("http transport error: {}", cause_chain(.0))]
|
||||
Http(#[from] reqwest::Error),
|
||||
|
||||
#[error("failed to decode JSON response: {0}")]
|
||||
Json(#[from] serde_json::Error),
|
||||
/// The response was not the JSON this endpoint is documented to return.
|
||||
///
|
||||
/// 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,
|
||||
},
|
||||
|
||||
/// A non-2xx response: the server's message, plus any per-field
|
||||
/// validation errors.
|
||||
///
|
||||
/// **A missing record is `400`, not `404`.** Invoice Ninja's exception
|
||||
/// handler maps Laravel's `ModelNotFoundException` to a 400 whose message
|
||||
/// is `No query results for model [App\Models\Client].`, and reserves 404
|
||||
/// for a route that does not exist or a method that route does not
|
||||
/// support. Matching on 404 to mean "not found" will never fire — match
|
||||
/// the status together with the message.
|
||||
#[error("api returned {status}: {message}")]
|
||||
Api {
|
||||
status: reqwest::StatusCode,
|
||||
@@ -30,6 +56,54 @@ pub enum Error {
|
||||
|
||||
#[error("invalid api token: {0}")]
|
||||
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),
|
||||
|
||||
/// An upload was refused before any request was sent: no files, a file
|
||||
/// with no name, or a MIME type that does not parse.
|
||||
///
|
||||
/// Raised locally because the server would otherwise answer an empty
|
||||
/// upload with a success that changed nothing.
|
||||
#[error("invalid upload: {0}")]
|
||||
InvalidUpload(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>;
|
||||
|
||||
/// Joins an error and every error beneath it with `: `.
|
||||
///
|
||||
/// `reqwest::Error`'s `Display` prints only the outermost layer, so the
|
||||
/// reason a request failed lives in `source()` and is otherwise invisible to
|
||||
/// anyone who just prints the error.
|
||||
fn cause_chain(error: &(dyn std::error::Error + 'static)) -> String {
|
||||
let mut message = error.to_string();
|
||||
let mut source = error.source();
|
||||
while let Some(cause) = source {
|
||||
let text = cause.to_string();
|
||||
// Some layers repeat their cause's text; don't print it twice.
|
||||
if !message.contains(&text) {
|
||||
message.push_str(": ");
|
||||
message.push_str(&text);
|
||||
}
|
||||
source = cause.source();
|
||||
}
|
||||
message
|
||||
}
|
||||
|
||||
+7
-3
@@ -1,9 +1,9 @@
|
||||
//! An async Rust client for the [Invoice Ninja](https://www.invoiceninja.com/) v5 API.
|
||||
//!
|
||||
//! ```no_run
|
||||
//! use invoiceninjars::{InvoiceNinjaClient, models::ListParams};
|
||||
//! use invoiceninja_sdk::{InvoiceNinjaClient, models::ListParams};
|
||||
//!
|
||||
//! # async fn run() -> invoiceninjars::Result<()> {
|
||||
//! # async fn run() -> invoiceninja_sdk::Result<()> {
|
||||
//! let client = InvoiceNinjaClient::new(
|
||||
//! "https://invoicing.co/api/v1",
|
||||
//! std::env::var("IN_API_TOKEN").unwrap(),
|
||||
@@ -17,6 +17,10 @@
|
||||
mod client;
|
||||
mod error;
|
||||
pub mod models;
|
||||
mod retry;
|
||||
mod upload;
|
||||
|
||||
pub use client::InvoiceNinjaClient;
|
||||
pub use client::{InvoiceNinjaClient, DEFAULT_TIMEOUT, HOSTED_BASE_URL};
|
||||
pub use error::{ApiErrorBody, Error, Result};
|
||||
pub use retry::RetryPolicy;
|
||||
pub use upload::FileUpload;
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
/// One row of the activity log, as `GET /activities` returns it.
|
||||
///
|
||||
/// Every reference is a hashed id, or an empty string when the activity does
|
||||
/// not concern that kind of record. `activity_type_id` arrives as a string
|
||||
/// here but as a number in the [feed](ActivityFeedEntry).
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct Activity {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub id: Option<String>,
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub activity_type_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 company_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 contact_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 vendor_contact_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 quote_id: String,
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub credit_id: String,
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub payment_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 recurring_expense_id: String,
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub purchase_order_id: String,
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub expense_id: String,
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub task_id: String,
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub token_id: String,
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub is_system: bool,
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub notes: String,
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub ip: String,
|
||||
#[serde(default)]
|
||||
pub created_at: Option<i64>,
|
||||
#[serde(default)]
|
||||
pub updated_at: Option<i64>,
|
||||
|
||||
#[serde(flatten)]
|
||||
pub extra: Map<String, Value>,
|
||||
}
|
||||
|
||||
/// A named record an activity refers to: the display label and its hashed id.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct ActivityRef {
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub label: String,
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub hashed_id: String,
|
||||
|
||||
#[serde(flatten)]
|
||||
pub extra: Map<String, Value>,
|
||||
}
|
||||
|
||||
/// One entry of the activity feed (`activity_feed`, `entity_activity`).
|
||||
///
|
||||
/// A different shape from [`Activity`]: the server resolves the records an
|
||||
/// activity mentions into labelled references. Which references are present
|
||||
/// depends on the activity type, so each is optional, and any the server adds
|
||||
/// that this does not name lands in `extra`.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct ActivityFeedEntry {
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub id: String,
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub hashed_id: String,
|
||||
#[serde(default, deserialize_with = "super::common::int_or_numeric_string")]
|
||||
pub activity_type_id: i64,
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub notes: String,
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub ip: String,
|
||||
#[serde(default, deserialize_with = "super::common::int_or_numeric_string")]
|
||||
pub created_at: i64,
|
||||
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub user: Option<ActivityRef>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub client: Option<ActivityRef>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub vendor: Option<ActivityRef>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub invoice: Option<ActivityRef>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub quote: Option<ActivityRef>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub credit: Option<ActivityRef>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub payment: Option<ActivityRef>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub task: Option<ActivityRef>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub expense: Option<ActivityRef>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub purchase_order: Option<ActivityRef>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub recurring_invoice: Option<ActivityRef>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub recurring_expense: Option<ActivityRef>,
|
||||
|
||||
#[serde(flatten)]
|
||||
pub extra: Map<String, Value>,
|
||||
}
|
||||
@@ -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 {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub id: Option<String>,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub id_number: String,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub vat_number: String,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub address1: String,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub address2: String,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub city: String,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub state: String,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub postal_code: String,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub country_id: String,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub phone: String,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub website: String,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub private_notes: String,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub balance: f64,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub paid_to_date: f64,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub credit_balance: f64,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub is_deleted: bool,
|
||||
#[serde(default)]
|
||||
pub archived_at: Option<i64>,
|
||||
@@ -43,7 +43,7 @@ pub struct Client {
|
||||
pub created_at: Option<i64>,
|
||||
#[serde(default)]
|
||||
pub updated_at: Option<i64>,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub contacts: Vec<ClientContact>,
|
||||
|
||||
/// Any fields returned by the API that aren't modeled above are preserved here,
|
||||
@@ -56,15 +56,15 @@ pub struct Client {
|
||||
pub struct ClientContact {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub id: Option<String>,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub first_name: String,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub last_name: String,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub email: String,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub phone: String,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub is_primary: bool,
|
||||
|
||||
#[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>,
|
||||
}
|
||||
+132
-1
@@ -1,7 +1,13 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
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>,
|
||||
#[serde(default)]
|
||||
pub meta: Option<Meta>,
|
||||
@@ -44,6 +50,19 @@ pub struct ListParams {
|
||||
/// Set to 1 to include archived/deleted records.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
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 {
|
||||
@@ -80,4 +99,116 @@ impl ListParams {
|
||||
self.status = Some(status.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Adds an arbitrary query parameter, for filters the typed setters do
|
||||
/// not cover.
|
||||
///
|
||||
/// ```
|
||||
/// use invoiceninja_sdk::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}"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Deserializes an integer that may arrive as a JSON number or as a numeric
|
||||
/// string.
|
||||
///
|
||||
/// The OpenAPI spec calls `frequency_id` a number on recurring invoices and
|
||||
/// expenses, but a real 5.13.50 server returns the string `"5"`. A strict
|
||||
/// `i64` made that a hard decode error *after* the record had already been
|
||||
/// created. A string that is not a whole number is still an error.
|
||||
pub(crate) fn int_or_numeric_string<'de, D>(deserializer: D) -> Result<i64, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
match Option::<Value>::deserialize(deserializer)? {
|
||||
None | Some(Value::Null) => Ok(0),
|
||||
Some(Value::Number(n)) => n
|
||||
.as_i64()
|
||||
.ok_or_else(|| serde::de::Error::custom(format!("expected a whole number, got {n}"))),
|
||||
Some(Value::String(s)) if s.is_empty() => Ok(0),
|
||||
Some(Value::String(s)) => s.trim().parse().map_err(|_| {
|
||||
serde::de::Error::custom(format!("expected a whole number, got string `{s}`"))
|
||||
}),
|
||||
Some(other) => Err(serde::de::Error::custom(format!(
|
||||
"expected a whole number or numeric string, 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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The `{"message": "..."}` body that action endpoints answer with.
|
||||
///
|
||||
/// `message` is defaulted so a reply that carries none still decodes.
|
||||
/// Anything else the server adds is kept in `extra`.
|
||||
#[derive(Debug, Clone, Default, Deserialize)]
|
||||
pub struct MessageResponse {
|
||||
#[serde(default, deserialize_with = "null_to_default")]
|
||||
pub message: String,
|
||||
#[serde(flatten)]
|
||||
pub extra: serde_json::Map<String, Value>,
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub id: Option<String>,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub vendor_id: String,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub client_id: String,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub invoice_id: String,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub amount: f64,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub exchange_rate: f64,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub public_notes: String,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub private_notes: String,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub date: String,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub payment_date: String,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub transaction_reference: String,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub category_id: String,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub tax_name1: String,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub tax_rate1: f64,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub tax_name2: String,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub tax_rate2: f64,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub should_be_invoiced: bool,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub is_deleted: bool,
|
||||
#[serde(default)]
|
||||
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>,
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
//! The company import and export jobs.
|
||||
//!
|
||||
//! Both endpoints queue work rather than doing it, so neither reply carries
|
||||
//! the result — an export answers with the URL to fetch later, and an import
|
||||
//! answers with nothing but an acknowledgement (see `MessageResponse`).
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
/// The reply to `POST /export`.
|
||||
///
|
||||
/// The export itself is queued, so `url` is a promise rather than a ready
|
||||
/// file: fetching it before the job finishes gets nothing. The link points at
|
||||
/// the `protected_download` route and expires — in one hour, or five if the
|
||||
/// company has more than 10,000 activities — so it is worth following
|
||||
/// promptly rather than storing.
|
||||
///
|
||||
/// `url` is the only reason to call this endpoint, which is why it is typed
|
||||
/// rather than left in `extra`.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
|
||||
pub struct ExportJob {
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub message: String,
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub url: String,
|
||||
#[serde(flatten)]
|
||||
pub extra: Map<String, Value>,
|
||||
}
|
||||
|
||||
/// The reply to `POST /preimport` — the first half of a CSV import.
|
||||
///
|
||||
/// `hash` is the handle to the uploaded files, which the server caches for 20
|
||||
/// minutes. Pass it back in the `import` body to start the ingest; without it
|
||||
/// the files would have to be uploaded again.
|
||||
///
|
||||
/// `mappings` is keyed by entity type (`client`, `invoice`, …), one entry per
|
||||
/// file uploaded. Each value describes what that file's columns could map to:
|
||||
/// `available` lists the importable fields, `headers` holds the first two
|
||||
/// rows, and `hints` is the server's guess at a column mapping. Those three
|
||||
/// stay `Value` because the upstream shapes come from each entity's own
|
||||
/// `importable()` map and are not documented; a wrong concrete type here
|
||||
/// would be a hard decode error, where `Value` is lossless.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
|
||||
pub struct ImportPreview {
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub hash: String,
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub mappings: Map<String, Value>,
|
||||
#[serde(flatten)]
|
||||
pub extra: Map<String, Value>,
|
||||
}
|
||||
+26
-26
@@ -5,35 +5,35 @@ use serde_json::{Map, Value};
|
||||
pub struct Invoice {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub id: Option<String>,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub client_id: String,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub number: String,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub date: String,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub due_date: String,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub po_number: String,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub status_id: String,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub amount: f64,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub balance: f64,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub discount: f64,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub is_amount_discount: bool,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub public_notes: String,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub private_notes: String,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub terms: String,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub footer: String,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub is_deleted: bool,
|
||||
#[serde(default)]
|
||||
pub archived_at: Option<i64>,
|
||||
@@ -41,7 +41,7 @@ pub struct Invoice {
|
||||
pub created_at: Option<i64>,
|
||||
#[serde(default)]
|
||||
pub updated_at: Option<i64>,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub line_items: Vec<InvoiceItem>,
|
||||
|
||||
#[serde(flatten)]
|
||||
@@ -50,25 +50,25 @@ pub struct Invoice {
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct InvoiceItem {
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub product_key: String,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub notes: String,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub cost: f64,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub quantity: f64,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub discount: f64,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub tax_name1: String,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub tax_rate1: f64,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub tax_name2: String,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub tax_rate2: f64,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub line_total: f64,
|
||||
|
||||
#[serde(flatten)]
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
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,
|
||||
/// Omitted when empty: the server validates it as an integer, so an unset
|
||||
/// `""` is rejected with a 422 and a default `Location` could not be
|
||||
/// created.
|
||||
#[serde(
|
||||
default,
|
||||
deserialize_with = "super::common::null_to_default",
|
||||
skip_serializing_if = "String::is_empty"
|
||||
)]
|
||||
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>,
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
use super::CompanyToken;
|
||||
|
||||
/// Response of `POST /login` (via [`crate::InvoiceNinjaClient::login`]).
|
||||
///
|
||||
/// One entry per company the account can access. The full upstream payload
|
||||
/// also nests the whole company (clients, invoices, designs, ...), the
|
||||
/// user, and the account under each entry — this crate does not model that;
|
||||
/// it stays reachable, losslessly, through [`LoginCompanyUser::extra`].
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct LoginResponse {
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub data: Vec<LoginCompanyUser>,
|
||||
}
|
||||
|
||||
/// One company membership returned by a login, carrying that company's API
|
||||
/// token.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct LoginCompanyUser {
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub is_owner: bool,
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub is_admin: bool,
|
||||
/// The token to pass to [`InvoiceNinjaClient::new`](crate::InvoiceNinjaClient::new)
|
||||
/// for this company. Only absent if the server ever omits the `token`
|
||||
/// include; every server-side `first_load` request requests it.
|
||||
#[serde(default)]
|
||||
pub token: Option<CompanyToken>,
|
||||
|
||||
#[serde(flatten)]
|
||||
pub extra: Map<String, Value>,
|
||||
}
|
||||
+65
-1
@@ -1,19 +1,83 @@
|
||||
mod activity;
|
||||
mod bank_integration;
|
||||
mod bank_transaction;
|
||||
mod bank_transaction_rule;
|
||||
mod client;
|
||||
mod client_gateway_token;
|
||||
mod common;
|
||||
mod company;
|
||||
mod company_gateway;
|
||||
mod company_token;
|
||||
mod design;
|
||||
mod document;
|
||||
mod expense;
|
||||
mod expense_category;
|
||||
mod group_setting;
|
||||
mod health;
|
||||
mod import_export;
|
||||
mod invoice;
|
||||
mod location;
|
||||
mod login;
|
||||
mod passkey;
|
||||
mod payment;
|
||||
mod payment_term;
|
||||
mod product;
|
||||
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_scheduler;
|
||||
mod task_status;
|
||||
mod tax_rate;
|
||||
mod user;
|
||||
mod vendor;
|
||||
mod webhook;
|
||||
|
||||
pub use activity::{Activity, ActivityFeedEntry, ActivityRef};
|
||||
pub use bank_integration::BankIntegration;
|
||||
pub use bank_transaction::BankTransaction;
|
||||
pub use bank_transaction_rule::BankTransactionRule;
|
||||
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, MessageResponse, 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_category::ExpenseCategory;
|
||||
pub use group_setting::GroupSetting;
|
||||
pub use health::{HealthCheck, PhpVersion};
|
||||
pub use import_export::{ExportJob, ImportPreview};
|
||||
pub use invoice::{Credit, Invoice, InvoiceItem, Quote};
|
||||
pub use location::Location;
|
||||
pub use login::{LoginCompanyUser, LoginResponse};
|
||||
pub use passkey::Passkey;
|
||||
pub use payment::{Payment, PaymentInvoice};
|
||||
pub use payment_term::PaymentTerm;
|
||||
pub use product::Product;
|
||||
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, SubscriptionStep};
|
||||
pub use system_log::SystemLog;
|
||||
pub use tag::Tag;
|
||||
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 webhook::Webhook;
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
/// A WebAuthn passkey credential registered against the current user.
|
||||
/// Served from `GET /settings/passkeys`.
|
||||
///
|
||||
/// Registering a new passkey is not wrapped: the server's `/settings/
|
||||
/// passkeys/options` and `/settings/passkeys` routes exist to drive a
|
||||
/// WebAuthn ceremony that only a browser, talking to its platform
|
||||
/// authenticator, can complete — there is no attestation for this crate to
|
||||
/// produce on its own. Listing and deleting an existing credential need no
|
||||
/// such ceremony, so those are the two passkey operations this crate
|
||||
/// offers.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct Passkey {
|
||||
/// Hashed id — pass to [`crate::InvoiceNinjaClient::delete_passkey`] to
|
||||
/// remove this credential.
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub id: String,
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub name: String,
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub created_at: String,
|
||||
/// Unix timestamp of the credential's last successful authentication,
|
||||
/// or `None` if it has never been used to log in.
|
||||
#[serde(default)]
|
||||
pub last_used_at: Option<i64>,
|
||||
|
||||
#[serde(flatten)]
|
||||
pub extra: Map<String, Value>,
|
||||
}
|
||||
+12
-10
@@ -5,23 +5,23 @@ use serde_json::{Map, Value};
|
||||
pub struct Payment {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub id: Option<String>,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub client_id: String,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub amount: f64,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub applied: f64,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub refunded: f64,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub date: String,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub transaction_reference: String,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub type_id: String,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub private_notes: String,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub is_deleted: bool,
|
||||
#[serde(default)]
|
||||
pub archived_at: Option<i64>,
|
||||
@@ -29,7 +29,7 @@ pub struct Payment {
|
||||
pub created_at: Option<i64>,
|
||||
#[serde(default)]
|
||||
pub updated_at: Option<i64>,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub invoices: Vec<PaymentInvoice>,
|
||||
|
||||
#[serde(flatten)]
|
||||
@@ -38,7 +38,9 @@ pub struct Payment {
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct PaymentInvoice {
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub invoice_id: String,
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub amount: f64,
|
||||
|
||||
#[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 {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub id: Option<String>,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub product_key: String,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub notes: String,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub cost: f64,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub price: f64,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub quantity: f64,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub tax_name1: String,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub tax_rate1: f64,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub tax_name2: String,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub tax_rate2: f64,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub is_deleted: bool,
|
||||
#[serde(default)]
|
||||
pub archived_at: Option<i64>,
|
||||
|
||||
+10
-10
@@ -5,25 +5,25 @@ use serde_json::{Map, Value};
|
||||
pub struct Project {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub id: Option<String>,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub client_id: String,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub number: String,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub task_rate: f64,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub due_date: String,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub budgeted_hours: f64,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub color: String,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub public_notes: String,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub private_notes: String,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub is_deleted: bool,
|
||||
#[serde(default)]
|
||||
pub archived_at: Option<i64>,
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
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,
|
||||
/// Omitted when empty: the server turns `""` into `NULL` and then fails
|
||||
/// the insert on a non-null column with a 500, so a new purchase order
|
||||
/// must leave it out and take the server's default.
|
||||
#[serde(
|
||||
default,
|
||||
deserialize_with = "super::common::null_to_default",
|
||||
skip_serializing_if = "String::is_empty"
|
||||
)]
|
||||
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::int_or_numeric_string")]
|
||||
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,93 @@
|
||||
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. A real
|
||||
/// server sends it as a numeric string; both decode.
|
||||
#[serde(default, deserialize_with = "super::common::int_or_numeric_string")]
|
||||
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,70 @@
|
||||
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>,
|
||||
}
|
||||
|
||||
/// One step of the subscription purchase flow and the steps it depends on,
|
||||
/// as returned by `GET /subscriptions/steps`.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct SubscriptionStep {
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub id: String,
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub dependencies: Vec<String>,
|
||||
|
||||
#[serde(flatten)]
|
||||
pub extra: Map<String, Value>,
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
/// A server-side log entry (gateway responses, mail failures, and so on).
|
||||
///
|
||||
/// The OpenAPI spec declares `created_at` and `updated_at` as date strings,
|
||||
/// but a real server (5.13.50) sends integer timestamps like every other
|
||||
/// resource.
|
||||
#[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: i64,
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub updated_at: i64,
|
||||
|
||||
#[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 {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub id: Option<String>,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub client_id: String,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub invoice_id: String,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub project_id: String,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub description: String,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub rate: f64,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub status_id: String,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub is_running: bool,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub is_deleted: bool,
|
||||
/// 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,
|
||||
#[serde(default)]
|
||||
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 {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub id: Option<String>,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub id_number: String,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub vat_number: String,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub address1: String,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub address2: String,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub city: String,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub state: String,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub postal_code: String,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub country_id: String,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub phone: String,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub website: String,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub private_notes: String,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub is_deleted: bool,
|
||||
#[serde(default)]
|
||||
pub archived_at: Option<i64>,
|
||||
@@ -37,7 +37,7 @@ pub struct Vendor {
|
||||
pub created_at: Option<i64>,
|
||||
#[serde(default)]
|
||||
pub updated_at: Option<i64>,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub contacts: Vec<VendorContact>,
|
||||
|
||||
/// Any fields returned by the API that aren't modeled above are preserved here,
|
||||
@@ -50,15 +50,15 @@ pub struct Vendor {
|
||||
pub struct VendorContact {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub id: Option<String>,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub first_name: String,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub last_name: String,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub email: String,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub phone: String,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub is_primary: bool,
|
||||
|
||||
#[serde(flatten)]
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
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`. Omitted when empty: the server turns
|
||||
/// `""` into `NULL` and fails the insert on a non-null column (a 500).
|
||||
#[serde(
|
||||
default,
|
||||
deserialize_with = "super::common::null_to_default",
|
||||
skip_serializing_if = "String::is_empty"
|
||||
)]
|
||||
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))
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
use std::fmt;
|
||||
|
||||
use reqwest::multipart::Part;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
|
||||
/// A file to attach to a record with the `upload_*` methods.
|
||||
///
|
||||
/// The contents are held in memory, so a large file costs its full size in
|
||||
/// RAM for as long as the upload takes. Streaming uploads are an open
|
||||
/// question in `GOALS.md`.
|
||||
///
|
||||
/// ```
|
||||
/// use invoiceninja_sdk::FileUpload;
|
||||
///
|
||||
/// let file = FileUpload::new("receipt.pdf", b"%PDF-1.4 ...".to_vec())
|
||||
/// .mime("application/pdf");
|
||||
/// ```
|
||||
#[derive(Clone)]
|
||||
pub struct FileUpload {
|
||||
file_name: String,
|
||||
bytes: Vec<u8>,
|
||||
mime: Option<String>,
|
||||
}
|
||||
|
||||
impl FileUpload {
|
||||
/// `file_name` is what the server records and shows against the
|
||||
/// document, so it should carry the right extension.
|
||||
pub fn new(file_name: impl Into<String>, bytes: impl Into<Vec<u8>>) -> Self {
|
||||
Self {
|
||||
file_name: file_name.into(),
|
||||
bytes: bytes.into(),
|
||||
mime: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets the part's `Content-Type`. Optional: without it the part carries
|
||||
/// none, and the server decides from the file's contents.
|
||||
pub fn mime(mut self, mime: impl Into<String>) -> Self {
|
||||
self.mime = Some(mime.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub(crate) fn into_part(self) -> Result<Part> {
|
||||
if self.file_name.trim().is_empty() {
|
||||
return Err(Error::InvalidUpload(
|
||||
"a file needs a non-empty file name".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let part = Part::bytes(self.bytes).file_name(self.file_name);
|
||||
match self.mime {
|
||||
Some(mime) => part
|
||||
.mime_str(&mime)
|
||||
.map_err(|_| Error::InvalidUpload(format!("`{mime}` is not a valid MIME type"))),
|
||||
None => Ok(part),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Elides the contents: the derived impl would print the entire file.
|
||||
impl fmt::Debug for FileUpload {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.debug_struct("FileUpload")
|
||||
.field("file_name", &self.file_name)
|
||||
.field("len", &self.bytes.len())
|
||||
.field("mime", &self.mime)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,132 @@
|
||||
//! Goal 2, checked mechanically: every model must absorb a field that is
|
||||
//! `null` or absent.
|
||||
//!
|
||||
//! Invoice Ninja returns `null` for many nominally-string fields and omits
|
||||
//! others, so a model that fails on either takes the whole response down with
|
||||
//! it. Rather than trusting each field to carry the right serde attribute,
|
||||
//! this serialises `Default` (which emits every non-optional field), then for
|
||||
//! each field in turn nulls it and, separately, removes it, and requires the
|
||||
//! result to still decode.
|
||||
//!
|
||||
//! A wrong *type* is deliberately not covered: that is meant to be a hard
|
||||
//! error, so it is not something to absorb.
|
||||
|
||||
use invoiceninja_sdk::models::*;
|
||||
use serde::{de::DeserializeOwned, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
/// Returns one line per mutation that failed to decode.
|
||||
fn failures<T: Serialize + DeserializeOwned + Default>(name: &str) -> Vec<String> {
|
||||
let base = serde_json::to_value(T::default()).expect("default serialises");
|
||||
let object = base
|
||||
.as_object()
|
||||
.unwrap_or_else(|| panic!("{name}: default did not serialise to an object"));
|
||||
|
||||
let mut out = Vec::new();
|
||||
for key in object.keys() {
|
||||
let mut nulled = base.clone();
|
||||
nulled[key] = Value::Null;
|
||||
if let Err(e) = serde_json::from_value::<T>(nulled) {
|
||||
out.push(format!("{name}.{key} = null: {e}"));
|
||||
}
|
||||
|
||||
let mut removed = base.clone();
|
||||
removed.as_object_mut().unwrap().remove(key);
|
||||
if let Err(e) = serde_json::from_value::<T>(removed) {
|
||||
out.push(format!("{name}.{key} absent: {e}"));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
macro_rules! tolerant {
|
||||
($($ty:ty),+ $(,)?) => {{
|
||||
let mut all = Vec::new();
|
||||
$( all.extend(failures::<$ty>(stringify!($ty))); )+
|
||||
all
|
||||
}};
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_model_absorbs_a_null_or_missing_field() {
|
||||
let all = tolerant![
|
||||
Activity,
|
||||
ActivityFeedEntry,
|
||||
ActivityRef,
|
||||
BankIntegration,
|
||||
BankTransaction,
|
||||
BankTransactionRule,
|
||||
Client,
|
||||
ClientContact,
|
||||
ClientGatewayToken,
|
||||
Company,
|
||||
CompanyGateway,
|
||||
CompanyToken,
|
||||
Design,
|
||||
Document,
|
||||
Expense,
|
||||
ExpenseCategory,
|
||||
ExportJob,
|
||||
GroupSetting,
|
||||
ImportPreview,
|
||||
Invoice,
|
||||
InvoiceItem,
|
||||
Location,
|
||||
LoginCompanyUser,
|
||||
LoginResponse,
|
||||
Passkey,
|
||||
Payment,
|
||||
PaymentInvoice,
|
||||
PaymentTerm,
|
||||
Product,
|
||||
Project,
|
||||
PurchaseOrder,
|
||||
RecurringExpense,
|
||||
RecurringInvoice,
|
||||
Subscription,
|
||||
SubscriptionStep,
|
||||
SystemLog,
|
||||
Tag,
|
||||
Task,
|
||||
TaskScheduler,
|
||||
TaskStatus,
|
||||
TaxRate,
|
||||
User,
|
||||
Vendor,
|
||||
VendorContact,
|
||||
Webhook,
|
||||
];
|
||||
|
||||
assert!(
|
||||
all.is_empty(),
|
||||
"{} field(s) break decoding on null/absent:\n{}",
|
||||
all.len(),
|
||||
all.join("\n")
|
||||
);
|
||||
}
|
||||
|
||||
/// A guard on the test itself: if `Default` stopped emitting fields, the loop
|
||||
/// above would pass vacuously.
|
||||
#[test]
|
||||
fn the_tolerance_test_actually_exercises_fields() {
|
||||
let client = serde_json::to_value(Client::default()).unwrap();
|
||||
assert!(
|
||||
client.as_object().unwrap().len() > 10,
|
||||
"Client::default() should serialise many fields"
|
||||
);
|
||||
}
|
||||
|
||||
/// The mutation really does bite: a field without `null_to_default` fails.
|
||||
#[test]
|
||||
fn a_bare_non_option_field_would_be_caught() {
|
||||
#[derive(serde::Serialize, serde::Deserialize, Default)]
|
||||
struct Naive {
|
||||
#[serde(default)]
|
||||
name: String,
|
||||
}
|
||||
assert_eq!(
|
||||
failures::<Naive>("Naive").len(),
|
||||
1,
|
||||
"only the null case fails"
|
||||
);
|
||||
}
|
||||
Vendored
+29
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"activity_type_id": "",
|
||||
"client_id": "",
|
||||
"company_id": "",
|
||||
"contact_id": "",
|
||||
"created_at": 0,
|
||||
"credit_id": "",
|
||||
"expense_id": "",
|
||||
"id": "",
|
||||
"invoice_id": "",
|
||||
"ip": "",
|
||||
"is_system": false,
|
||||
"notes": "",
|
||||
"payment_id": "",
|
||||
"purchase_order_id": "",
|
||||
"quote_id": "",
|
||||
"recurring_expense_id": "",
|
||||
"recurring_invoice_id": "",
|
||||
"task_id": "",
|
||||
"token_id": "",
|
||||
"updated_at": 0,
|
||||
"user_id": "",
|
||||
"vendor_contact_id": "",
|
||||
"vendor_id": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
+24
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"activity_type_id": 0,
|
||||
"created_at": 0,
|
||||
"hashed_id": "",
|
||||
"id": "",
|
||||
"ip": "",
|
||||
"notes": "",
|
||||
"purchase_order": {
|
||||
"hashed_id": "",
|
||||
"label": ""
|
||||
},
|
||||
"user": {
|
||||
"hashed_id": "",
|
||||
"label": ""
|
||||
},
|
||||
"vendor": {
|
||||
"hashed_id": "",
|
||||
"label": ""
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
+114
@@ -0,0 +1,114 @@
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"address1": "",
|
||||
"address2": "",
|
||||
"archived_at": 0,
|
||||
"assigned_user_id": "",
|
||||
"balance": 0,
|
||||
"city": "",
|
||||
"classification": "",
|
||||
"client_hash": "",
|
||||
"contacts": [
|
||||
{
|
||||
"archived_at": 0,
|
||||
"can_sign": false,
|
||||
"cc_only": false,
|
||||
"contact_key": "",
|
||||
"created_at": 0,
|
||||
"custom_value1": "",
|
||||
"custom_value2": "",
|
||||
"custom_value3": "",
|
||||
"custom_value4": "",
|
||||
"email": "",
|
||||
"first_name": "",
|
||||
"id": "",
|
||||
"is_locked": false,
|
||||
"is_primary": false,
|
||||
"last_login": 0,
|
||||
"last_name": "",
|
||||
"link": "",
|
||||
"password": "",
|
||||
"phone": "",
|
||||
"send_email": false,
|
||||
"updated_at": 0
|
||||
}
|
||||
],
|
||||
"country_id": "",
|
||||
"created_at": 0,
|
||||
"credit_balance": 0,
|
||||
"custom_value1": "",
|
||||
"custom_value2": "",
|
||||
"custom_value3": "",
|
||||
"custom_value4": "",
|
||||
"display_name": "",
|
||||
"documents": [
|
||||
{
|
||||
"archived_at": 0,
|
||||
"assigned_user_id": "",
|
||||
"created_at": 0,
|
||||
"disk": "",
|
||||
"documentable_id": "",
|
||||
"documentable_type": "",
|
||||
"hash": "",
|
||||
"height": 0,
|
||||
"id": "",
|
||||
"is_default": false,
|
||||
"is_deleted": false,
|
||||
"is_public": false,
|
||||
"link": "",
|
||||
"name": "",
|
||||
"preview": "",
|
||||
"project_id": "",
|
||||
"size": 0,
|
||||
"type": "",
|
||||
"updated_at": 0,
|
||||
"url": "",
|
||||
"user_id": "",
|
||||
"vendor_id": "",
|
||||
"width": 0
|
||||
}
|
||||
],
|
||||
"e_invoice": {},
|
||||
"gateway_tokens": [],
|
||||
"group_settings_id": "",
|
||||
"has_valid_vat_number": false,
|
||||
"id": "",
|
||||
"id_number": "",
|
||||
"industry_id": "",
|
||||
"is_deleted": false,
|
||||
"is_tax_exempt": false,
|
||||
"last_login": 0,
|
||||
"locations": [],
|
||||
"name": "",
|
||||
"number": "",
|
||||
"paid_to_date": 0,
|
||||
"payment_balance": 0,
|
||||
"phone": "",
|
||||
"postal_code": "",
|
||||
"private_notes": "",
|
||||
"public_notes": "",
|
||||
"routing_id": "",
|
||||
"settings": {
|
||||
"currency_id": "",
|
||||
"entity": "",
|
||||
"industry_id": null,
|
||||
"size_id": null
|
||||
},
|
||||
"shipping_address1": "",
|
||||
"shipping_address2": "",
|
||||
"shipping_city": "",
|
||||
"shipping_country_id": "",
|
||||
"shipping_postal_code": "",
|
||||
"shipping_state": "",
|
||||
"size_id": "",
|
||||
"state": "",
|
||||
"tags": [],
|
||||
"tax_info": {},
|
||||
"updated_at": 0,
|
||||
"user_id": "",
|
||||
"vat_number": "",
|
||||
"website": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
+1059
File diff suppressed because it is too large
Load Diff
Vendored
+139
@@ -0,0 +1,139 @@
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"amount": 0,
|
||||
"archived_at": 0,
|
||||
"assigned_user_id": "",
|
||||
"backup": {
|
||||
"adjustable_amount": 0,
|
||||
"cancellation": {
|
||||
"adjustment": 0,
|
||||
"status_id": 0
|
||||
},
|
||||
"child_invoice_ids": [],
|
||||
"document_type": null,
|
||||
"e_invoice_cleared_at": null,
|
||||
"e_invoice_status": null,
|
||||
"guid": "",
|
||||
"notes": null,
|
||||
"parent_invoice_id": null,
|
||||
"parent_invoice_number": null,
|
||||
"redirect": null
|
||||
},
|
||||
"balance": 0,
|
||||
"client_id": "",
|
||||
"created_at": 0,
|
||||
"custom_surcharge1": 0,
|
||||
"custom_surcharge2": 0,
|
||||
"custom_surcharge3": 0,
|
||||
"custom_surcharge4": 0,
|
||||
"custom_surcharge_tax1": false,
|
||||
"custom_surcharge_tax2": false,
|
||||
"custom_surcharge_tax3": false,
|
||||
"custom_surcharge_tax4": false,
|
||||
"custom_value1": "",
|
||||
"custom_value2": "",
|
||||
"custom_value3": "",
|
||||
"custom_value4": "",
|
||||
"date": "",
|
||||
"design_id": "",
|
||||
"discount": 0,
|
||||
"documents": [],
|
||||
"due_date": "",
|
||||
"e_invoice": {},
|
||||
"entity_type": "",
|
||||
"exchange_rate": 0,
|
||||
"footer": "",
|
||||
"has_expenses": false,
|
||||
"has_tasks": false,
|
||||
"id": "",
|
||||
"invitations": [
|
||||
{
|
||||
"archived_at": 0,
|
||||
"can_sign": false,
|
||||
"client_contact_id": "",
|
||||
"created_at": 0,
|
||||
"email_error": "",
|
||||
"email_status": "",
|
||||
"id": "",
|
||||
"key": "",
|
||||
"link": "",
|
||||
"message_id": "",
|
||||
"opened_date": "",
|
||||
"sent_date": "",
|
||||
"updated_at": 0,
|
||||
"viewed_date": ""
|
||||
}
|
||||
],
|
||||
"invoice_id": "",
|
||||
"is_amount_discount": false,
|
||||
"is_deleted": false,
|
||||
"last_sent_date": "",
|
||||
"line_items": [
|
||||
{
|
||||
"cost": 0,
|
||||
"custom_value1": "",
|
||||
"custom_value2": "",
|
||||
"custom_value3": "",
|
||||
"custom_value4": "",
|
||||
"date": "",
|
||||
"discount": 0,
|
||||
"expense_id": "",
|
||||
"gross_line_total": 0,
|
||||
"income_account_id": "",
|
||||
"is_amount_discount": false,
|
||||
"line_total": 0,
|
||||
"net_cost": 0,
|
||||
"notes": "",
|
||||
"product_cost": 0,
|
||||
"product_key": "",
|
||||
"quantity": 0,
|
||||
"sort_id": "",
|
||||
"tags": "",
|
||||
"task_id": "",
|
||||
"tax_amount": 0,
|
||||
"tax_id": "",
|
||||
"tax_name1": "",
|
||||
"tax_name2": "",
|
||||
"tax_name3": "",
|
||||
"tax_rate1": 0,
|
||||
"tax_rate2": 0,
|
||||
"tax_rate3": 0,
|
||||
"type_id": "",
|
||||
"unit_code": ""
|
||||
}
|
||||
],
|
||||
"location_id": "",
|
||||
"next_send_date": "",
|
||||
"number": "",
|
||||
"paid_to_date": 0,
|
||||
"partial": 0,
|
||||
"partial_due_date": "",
|
||||
"po_number": "",
|
||||
"private_notes": "",
|
||||
"project_id": "",
|
||||
"public_notes": "",
|
||||
"reminder1_sent": "",
|
||||
"reminder2_sent": "",
|
||||
"reminder3_sent": "",
|
||||
"reminder_last_sent": "",
|
||||
"status_id": "",
|
||||
"subscription_id": "",
|
||||
"sync": null,
|
||||
"tags": [],
|
||||
"tax_info": {},
|
||||
"tax_name1": "",
|
||||
"tax_name2": "",
|
||||
"tax_name3": "",
|
||||
"tax_rate1": 0,
|
||||
"tax_rate2": 0,
|
||||
"tax_rate3": 0,
|
||||
"terms": "",
|
||||
"total_taxes": 0,
|
||||
"updated_at": 0,
|
||||
"user_id": "",
|
||||
"uses_inclusive_taxes": false,
|
||||
"vendor_id": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
+25
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"archived_at": 0,
|
||||
"created_at": 0,
|
||||
"design": {
|
||||
"body": "",
|
||||
"footer": "",
|
||||
"header": "",
|
||||
"includes": "",
|
||||
"product": "",
|
||||
"task": ""
|
||||
},
|
||||
"entities": "",
|
||||
"id": "",
|
||||
"is_active": false,
|
||||
"is_custom": false,
|
||||
"is_deleted": false,
|
||||
"is_free": false,
|
||||
"is_template": false,
|
||||
"name": "",
|
||||
"updated_at": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
+29
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"archived_at": 0,
|
||||
"assigned_user_id": "",
|
||||
"created_at": 0,
|
||||
"disk": "",
|
||||
"documentable_id": "",
|
||||
"documentable_type": "",
|
||||
"hash": "",
|
||||
"height": 0,
|
||||
"id": "",
|
||||
"is_default": false,
|
||||
"is_deleted": false,
|
||||
"is_public": false,
|
||||
"link": "",
|
||||
"name": "",
|
||||
"preview": "",
|
||||
"project_id": "",
|
||||
"size": 0,
|
||||
"type": "",
|
||||
"updated_at": 0,
|
||||
"url": "",
|
||||
"user_id": "",
|
||||
"vendor_id": "",
|
||||
"width": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"archived_at": 0,
|
||||
"color": "",
|
||||
"created_at": 0,
|
||||
"id": "",
|
||||
"is_deleted": false,
|
||||
"name": "",
|
||||
"updated_at": 0,
|
||||
"user_id": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
+55
@@ -0,0 +1,55 @@
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"amount": 0,
|
||||
"archived_at": 0,
|
||||
"assigned_user_id": "",
|
||||
"bank_id": "",
|
||||
"calculate_tax_by_amount": false,
|
||||
"category_id": "",
|
||||
"client_id": "",
|
||||
"created_at": 0,
|
||||
"currency_id": "",
|
||||
"custom_value1": "",
|
||||
"custom_value2": "",
|
||||
"custom_value3": "",
|
||||
"custom_value4": "",
|
||||
"date": "",
|
||||
"documents": [],
|
||||
"e_invoice": {},
|
||||
"entity_type": "",
|
||||
"exchange_rate": 0,
|
||||
"expense_currency_id": "",
|
||||
"foreign_amount": 0,
|
||||
"id": "",
|
||||
"invoice_currency_id": "",
|
||||
"invoice_documents": false,
|
||||
"invoice_id": "",
|
||||
"is_deleted": false,
|
||||
"number": "",
|
||||
"payment_date": "",
|
||||
"payment_type_id": "",
|
||||
"private_notes": "",
|
||||
"project_id": "",
|
||||
"public_notes": "",
|
||||
"recurring_expense_id": "",
|
||||
"should_be_invoiced": false,
|
||||
"tags": [],
|
||||
"tax_amount1": 0,
|
||||
"tax_amount2": 0,
|
||||
"tax_amount3": 0,
|
||||
"tax_name1": "",
|
||||
"tax_name2": "",
|
||||
"tax_name3": "",
|
||||
"tax_rate1": 0,
|
||||
"tax_rate2": 0,
|
||||
"tax_rate3": 0,
|
||||
"transaction_id": "",
|
||||
"transaction_reference": "",
|
||||
"updated_at": 0,
|
||||
"user_id": "",
|
||||
"uses_inclusive_taxes": false,
|
||||
"vendor_id": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
+16
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"archived_at": 0,
|
||||
"created_at": 0,
|
||||
"documents": [],
|
||||
"id": "",
|
||||
"is_deleted": false,
|
||||
"name": "",
|
||||
"settings": {
|
||||
"entity": ""
|
||||
},
|
||||
"updated_at": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
+37
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"api_version": "",
|
||||
"cache_enabled": false,
|
||||
"env_writable": false,
|
||||
"exchange_rate_api_not_configured": false,
|
||||
"exec": false,
|
||||
"extensions": [
|
||||
{
|
||||
"gd": false
|
||||
}
|
||||
],
|
||||
"file_permissions": "",
|
||||
"flutter_renderer": "",
|
||||
"is_docker": false,
|
||||
"jobs_pending": 0,
|
||||
"mail_mailer": "",
|
||||
"open_basedir": false,
|
||||
"pdf_engine": "",
|
||||
"pending_migrations": false,
|
||||
"phantom_enabled": false,
|
||||
"php_version": {
|
||||
"current_php_cli_version": "",
|
||||
"current_php_version": "",
|
||||
"is_okay": false,
|
||||
"memory_limit": "",
|
||||
"minimum_php_version": ""
|
||||
},
|
||||
"queue": "",
|
||||
"queue_data": {
|
||||
"failed": 0,
|
||||
"last_error": "",
|
||||
"pending": 0
|
||||
},
|
||||
"simple_db_check": false,
|
||||
"system_health": false,
|
||||
"trailing_slash": false
|
||||
}
|
||||
Vendored
+140
@@ -0,0 +1,140 @@
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"amount": 0,
|
||||
"archived_at": 0,
|
||||
"assigned_user_id": "",
|
||||
"auto_bill_enabled": false,
|
||||
"backup": {
|
||||
"adjustable_amount": 0,
|
||||
"cancellation": {
|
||||
"adjustment": 0,
|
||||
"status_id": 0
|
||||
},
|
||||
"child_invoice_ids": [],
|
||||
"document_type": null,
|
||||
"e_invoice_cleared_at": null,
|
||||
"e_invoice_status": null,
|
||||
"guid": "",
|
||||
"notes": null,
|
||||
"parent_invoice_id": null,
|
||||
"parent_invoice_number": null,
|
||||
"redirect": null
|
||||
},
|
||||
"balance": 0,
|
||||
"client_id": "",
|
||||
"created_at": 0,
|
||||
"custom_surcharge1": 0,
|
||||
"custom_surcharge2": 0,
|
||||
"custom_surcharge3": 0,
|
||||
"custom_surcharge4": 0,
|
||||
"custom_surcharge_tax1": false,
|
||||
"custom_surcharge_tax2": false,
|
||||
"custom_surcharge_tax3": false,
|
||||
"custom_surcharge_tax4": false,
|
||||
"custom_value1": "",
|
||||
"custom_value2": "",
|
||||
"custom_value3": "",
|
||||
"custom_value4": "",
|
||||
"date": "",
|
||||
"design_id": "",
|
||||
"discount": 0,
|
||||
"documents": [],
|
||||
"due_date": "",
|
||||
"e_invoice": {},
|
||||
"entity_type": "",
|
||||
"exchange_rate": 0,
|
||||
"footer": "",
|
||||
"has_expenses": false,
|
||||
"has_tasks": false,
|
||||
"id": "",
|
||||
"invitations": [
|
||||
{
|
||||
"archived_at": 0,
|
||||
"can_sign": false,
|
||||
"client_contact_id": "",
|
||||
"created_at": 0,
|
||||
"email_error": "",
|
||||
"email_status": "",
|
||||
"id": "",
|
||||
"key": "",
|
||||
"link": "",
|
||||
"message_id": "",
|
||||
"opened_date": "",
|
||||
"sent_date": "",
|
||||
"updated_at": 0,
|
||||
"viewed_date": ""
|
||||
}
|
||||
],
|
||||
"is_amount_discount": false,
|
||||
"is_deleted": false,
|
||||
"last_sent_date": "",
|
||||
"line_items": [
|
||||
{
|
||||
"cost": 0,
|
||||
"custom_value1": "",
|
||||
"custom_value2": "",
|
||||
"custom_value3": "",
|
||||
"custom_value4": "",
|
||||
"date": "",
|
||||
"discount": 0,
|
||||
"expense_id": "",
|
||||
"gross_line_total": 0,
|
||||
"income_account_id": "",
|
||||
"is_amount_discount": false,
|
||||
"line_total": 0,
|
||||
"net_cost": 0,
|
||||
"notes": "",
|
||||
"product_cost": 0,
|
||||
"product_key": "",
|
||||
"quantity": 0,
|
||||
"sort_id": "",
|
||||
"tags": "",
|
||||
"task_id": "",
|
||||
"tax_amount": 0,
|
||||
"tax_id": "",
|
||||
"tax_name1": "",
|
||||
"tax_name2": "",
|
||||
"tax_name3": "",
|
||||
"tax_rate1": 0,
|
||||
"tax_rate2": 0,
|
||||
"tax_rate3": 0,
|
||||
"type_id": "",
|
||||
"unit_code": ""
|
||||
}
|
||||
],
|
||||
"location_id": "",
|
||||
"next_send_date": "",
|
||||
"number": "",
|
||||
"paid_to_date": 0,
|
||||
"partial": 0,
|
||||
"partial_due_date": "",
|
||||
"po_number": "",
|
||||
"private_notes": "",
|
||||
"project_id": "",
|
||||
"public_notes": "",
|
||||
"recurring_id": "",
|
||||
"reminder1_sent": "",
|
||||
"reminder2_sent": "",
|
||||
"reminder3_sent": "",
|
||||
"reminder_last_sent": "",
|
||||
"status_id": "",
|
||||
"subscription_id": "",
|
||||
"sync": null,
|
||||
"tags": [],
|
||||
"tax_info": {},
|
||||
"tax_name1": "",
|
||||
"tax_name2": "",
|
||||
"tax_name3": "",
|
||||
"tax_rate1": 0,
|
||||
"tax_rate2": 0,
|
||||
"tax_rate3": 0,
|
||||
"terms": "",
|
||||
"total_taxes": 0,
|
||||
"updated_at": 0,
|
||||
"user_id": "",
|
||||
"uses_inclusive_taxes": false,
|
||||
"vendor_id": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
+27
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"address1": "",
|
||||
"address2": "",
|
||||
"archived_at": 0,
|
||||
"city": "",
|
||||
"client_id": "",
|
||||
"country_id": "",
|
||||
"created_at": 0,
|
||||
"custom_value1": "",
|
||||
"custom_value2": "",
|
||||
"custom_value3": "",
|
||||
"custom_value4": "",
|
||||
"id": "",
|
||||
"is_deleted": false,
|
||||
"is_shipping_location": false,
|
||||
"name": "",
|
||||
"postal_code": "",
|
||||
"state": "",
|
||||
"tax_data": {},
|
||||
"updated_at": 0,
|
||||
"user_id": "",
|
||||
"vendor_id": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
+13
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"archived_at": 0,
|
||||
"created_at": 0,
|
||||
"id": "",
|
||||
"is_deleted": false,
|
||||
"name": "",
|
||||
"num_days": 0,
|
||||
"updated_at": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
+52
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"amount": 0,
|
||||
"applied": 0,
|
||||
"archived_at": 0,
|
||||
"assigned_user_id": "",
|
||||
"client_contact_id": "",
|
||||
"client_id": "",
|
||||
"company_gateway_id": "",
|
||||
"created_at": 0,
|
||||
"currency_id": "",
|
||||
"custom_value1": "",
|
||||
"custom_value2": "",
|
||||
"custom_value3": "",
|
||||
"custom_value4": "",
|
||||
"date": "",
|
||||
"documents": [],
|
||||
"exchange_currency_id": "",
|
||||
"exchange_rate": 0,
|
||||
"gateway_type_id": "",
|
||||
"id": "",
|
||||
"invitation_id": "",
|
||||
"is_deleted": false,
|
||||
"is_manual": false,
|
||||
"number": "",
|
||||
"paymentables": [
|
||||
{
|
||||
"amount": 0,
|
||||
"archived_at": 0,
|
||||
"created_at": 0,
|
||||
"id": "",
|
||||
"invoice_id": "",
|
||||
"payment_id": "",
|
||||
"refunded": 0,
|
||||
"updated_at": 0
|
||||
}
|
||||
],
|
||||
"private_notes": "",
|
||||
"project_id": "",
|
||||
"refunded": 0,
|
||||
"status_id": "",
|
||||
"tags": [],
|
||||
"transaction_id": "",
|
||||
"transaction_reference": "",
|
||||
"type_id": "",
|
||||
"updated_at": 0,
|
||||
"user_id": "",
|
||||
"vendor_id": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
+37
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"archived_at": 0,
|
||||
"assigned_user_id": "",
|
||||
"cost": 0,
|
||||
"created_at": 0,
|
||||
"custom_value1": "",
|
||||
"custom_value2": "",
|
||||
"custom_value3": "",
|
||||
"custom_value4": "",
|
||||
"documents": [],
|
||||
"id": "",
|
||||
"in_stock_quantity": 0,
|
||||
"income_account_id": "",
|
||||
"is_deleted": false,
|
||||
"max_quantity": 0,
|
||||
"notes": "",
|
||||
"price": 0,
|
||||
"product_image": "",
|
||||
"product_key": "",
|
||||
"quantity": 0,
|
||||
"stock_notification": false,
|
||||
"stock_notification_threshold": 0,
|
||||
"tags": [],
|
||||
"tax_id": "",
|
||||
"tax_name1": "",
|
||||
"tax_name2": "",
|
||||
"tax_name3": "",
|
||||
"tax_rate1": 0,
|
||||
"tax_rate2": 0,
|
||||
"tax_rate3": 0,
|
||||
"updated_at": 0,
|
||||
"user_id": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
+30
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"archived_at": 0,
|
||||
"assigned_user_id": "",
|
||||
"budgeted_amount": 0,
|
||||
"budgeted_hours": 0,
|
||||
"client_id": "",
|
||||
"color": "",
|
||||
"created_at": 0,
|
||||
"current_hours": 0,
|
||||
"custom_value1": "",
|
||||
"custom_value2": "",
|
||||
"custom_value3": "",
|
||||
"custom_value4": "",
|
||||
"documents": [],
|
||||
"due_date": "",
|
||||
"id": "",
|
||||
"is_deleted": false,
|
||||
"name": "",
|
||||
"number": "",
|
||||
"private_notes": "",
|
||||
"public_notes": "",
|
||||
"tags": [],
|
||||
"task_rate": 0,
|
||||
"updated_at": 0,
|
||||
"user_id": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
+93
@@ -0,0 +1,93 @@
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"amount": 0,
|
||||
"archived_at": 0,
|
||||
"assigned_user_id": "",
|
||||
"balance": 0,
|
||||
"client_id": "",
|
||||
"created_at": 0,
|
||||
"currency_id": "",
|
||||
"custom_surcharge1": 0,
|
||||
"custom_surcharge2": 0,
|
||||
"custom_surcharge3": 0,
|
||||
"custom_surcharge4": 0,
|
||||
"custom_surcharge_tax1": false,
|
||||
"custom_surcharge_tax2": false,
|
||||
"custom_surcharge_tax3": false,
|
||||
"custom_surcharge_tax4": false,
|
||||
"custom_value1": "",
|
||||
"custom_value2": "",
|
||||
"custom_value3": "",
|
||||
"custom_value4": "",
|
||||
"date": "",
|
||||
"design_id": "",
|
||||
"discount": 0,
|
||||
"documents": [],
|
||||
"due_date": "",
|
||||
"e_invoice": {},
|
||||
"entity_type": "",
|
||||
"exchange_rate": 0,
|
||||
"expense_id": "",
|
||||
"footer": "",
|
||||
"has_expenses": false,
|
||||
"has_tasks": false,
|
||||
"id": "",
|
||||
"invitations": [
|
||||
{
|
||||
"archived_at": 0,
|
||||
"can_sign": false,
|
||||
"created_at": 0,
|
||||
"email_error": "",
|
||||
"email_status": "",
|
||||
"id": "",
|
||||
"key": "",
|
||||
"link": "",
|
||||
"message_id": "",
|
||||
"opened_date": "",
|
||||
"sent_date": "",
|
||||
"updated_at": 0,
|
||||
"vendor_contact_id": "",
|
||||
"viewed_date": ""
|
||||
}
|
||||
],
|
||||
"invoice_id": "",
|
||||
"is_amount_discount": false,
|
||||
"is_deleted": false,
|
||||
"last_sent_date": "",
|
||||
"line_items": [],
|
||||
"location_id": "",
|
||||
"next_send_date": "",
|
||||
"number": "",
|
||||
"paid_to_date": 0,
|
||||
"partial": 0,
|
||||
"partial_due_date": "",
|
||||
"po_number": "",
|
||||
"private_notes": "",
|
||||
"project_id": "",
|
||||
"public_notes": "",
|
||||
"quote_id": "",
|
||||
"reminder1_sent": "",
|
||||
"reminder2_sent": "",
|
||||
"reminder3_sent": "",
|
||||
"reminder_last_sent": "",
|
||||
"status_id": "",
|
||||
"subscription_id": "",
|
||||
"sync": null,
|
||||
"tags": [],
|
||||
"tax_info": {},
|
||||
"tax_name1": "",
|
||||
"tax_name2": "",
|
||||
"tax_name3": "",
|
||||
"tax_rate1": 0,
|
||||
"tax_rate2": 0,
|
||||
"tax_rate3": 0,
|
||||
"terms": "",
|
||||
"total_taxes": 0,
|
||||
"updated_at": 0,
|
||||
"user_id": "",
|
||||
"uses_inclusive_taxes": false,
|
||||
"vendor_id": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
+123
@@ -0,0 +1,123 @@
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"amount": 0,
|
||||
"archived_at": 0,
|
||||
"assigned_user_id": "",
|
||||
"balance": 0,
|
||||
"client_id": "",
|
||||
"created_at": 0,
|
||||
"custom_surcharge1": 0,
|
||||
"custom_surcharge2": 0,
|
||||
"custom_surcharge3": 0,
|
||||
"custom_surcharge4": 0,
|
||||
"custom_surcharge_tax1": false,
|
||||
"custom_surcharge_tax2": false,
|
||||
"custom_surcharge_tax3": false,
|
||||
"custom_surcharge_tax4": false,
|
||||
"custom_value1": "",
|
||||
"custom_value2": "",
|
||||
"custom_value3": "",
|
||||
"custom_value4": "",
|
||||
"date": "",
|
||||
"design_id": "",
|
||||
"discount": 0,
|
||||
"documents": [],
|
||||
"due_date": "",
|
||||
"e_invoice": {},
|
||||
"entity_type": "",
|
||||
"exchange_rate": 0,
|
||||
"footer": "",
|
||||
"has_expenses": false,
|
||||
"has_tasks": false,
|
||||
"id": "",
|
||||
"invitations": [
|
||||
{
|
||||
"archived_at": 0,
|
||||
"can_sign": false,
|
||||
"client_contact_id": "",
|
||||
"created_at": 0,
|
||||
"email_error": "",
|
||||
"email_status": "",
|
||||
"id": "",
|
||||
"key": "",
|
||||
"link": "",
|
||||
"message_id": "",
|
||||
"opened_date": "",
|
||||
"sent_date": "",
|
||||
"updated_at": 0,
|
||||
"viewed_date": ""
|
||||
}
|
||||
],
|
||||
"invoice_id": "",
|
||||
"is_amount_discount": false,
|
||||
"is_deleted": false,
|
||||
"last_sent_date": "",
|
||||
"line_items": [
|
||||
{
|
||||
"cost": 0,
|
||||
"custom_value1": "",
|
||||
"custom_value2": "",
|
||||
"custom_value3": "",
|
||||
"custom_value4": "",
|
||||
"date": "",
|
||||
"discount": 0,
|
||||
"expense_id": "",
|
||||
"gross_line_total": 0,
|
||||
"income_account_id": "",
|
||||
"is_amount_discount": false,
|
||||
"line_total": 0,
|
||||
"net_cost": 0,
|
||||
"notes": "",
|
||||
"product_cost": 0,
|
||||
"product_key": "",
|
||||
"quantity": 0,
|
||||
"sort_id": "",
|
||||
"tags": "",
|
||||
"task_id": "",
|
||||
"tax_amount": 0,
|
||||
"tax_id": "",
|
||||
"tax_name1": "",
|
||||
"tax_name2": "",
|
||||
"tax_name3": "",
|
||||
"tax_rate1": 0,
|
||||
"tax_rate2": 0,
|
||||
"tax_rate3": 0,
|
||||
"type_id": "",
|
||||
"unit_code": ""
|
||||
}
|
||||
],
|
||||
"location_id": "",
|
||||
"next_send_date": "",
|
||||
"number": "",
|
||||
"paid_to_date": 0,
|
||||
"partial": 0,
|
||||
"partial_due_date": "",
|
||||
"po_number": "",
|
||||
"private_notes": "",
|
||||
"project_id": "",
|
||||
"public_notes": "",
|
||||
"reminder1_sent": "",
|
||||
"reminder2_sent": "",
|
||||
"reminder3_sent": "",
|
||||
"reminder_last_sent": "",
|
||||
"status_id": "",
|
||||
"subscription_id": "",
|
||||
"sync": null,
|
||||
"tags": [],
|
||||
"tax_info": {},
|
||||
"tax_name1": "",
|
||||
"tax_name2": "",
|
||||
"tax_name3": "",
|
||||
"tax_rate1": 0,
|
||||
"tax_rate2": 0,
|
||||
"tax_rate3": 0,
|
||||
"terms": "",
|
||||
"total_taxes": 0,
|
||||
"updated_at": 0,
|
||||
"user_id": "",
|
||||
"uses_inclusive_taxes": false,
|
||||
"vendor_id": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"amount": 0,
|
||||
"archived_at": 0,
|
||||
"assigned_user_id": "",
|
||||
"bank_id": "",
|
||||
"calculate_tax_by_amount": false,
|
||||
"category_id": "",
|
||||
"client_id": "",
|
||||
"created_at": 0,
|
||||
"currency_id": "",
|
||||
"custom_value1": "",
|
||||
"custom_value2": "",
|
||||
"custom_value3": "",
|
||||
"custom_value4": "",
|
||||
"date": "",
|
||||
"documents": [],
|
||||
"entity_type": "",
|
||||
"exchange_rate": 0,
|
||||
"foreign_amount": 0,
|
||||
"frequency_id": "",
|
||||
"id": "",
|
||||
"invoice_currency_id": "",
|
||||
"invoice_documents": false,
|
||||
"invoice_id": "",
|
||||
"is_deleted": false,
|
||||
"last_sent_date": "",
|
||||
"next_send_date": "",
|
||||
"number": "",
|
||||
"payment_date": "",
|
||||
"payment_type_id": "",
|
||||
"private_notes": "",
|
||||
"project_id": "",
|
||||
"public_notes": "",
|
||||
"recurring_dates": [],
|
||||
"recurring_expense_currency_id": "",
|
||||
"remaining_cycles": 0,
|
||||
"should_be_invoiced": false,
|
||||
"status_id": "",
|
||||
"tags": [],
|
||||
"tax_amount1": 0,
|
||||
"tax_amount2": 0,
|
||||
"tax_amount3": 0,
|
||||
"tax_name1": "",
|
||||
"tax_name2": "",
|
||||
"tax_name3": "",
|
||||
"tax_rate1": 0,
|
||||
"tax_rate2": 0,
|
||||
"tax_rate3": 0,
|
||||
"transaction_id": "",
|
||||
"transaction_reference": "",
|
||||
"updated_at": 0,
|
||||
"user_id": "",
|
||||
"uses_inclusive_taxes": false,
|
||||
"vendor_id": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
+90
@@ -0,0 +1,90 @@
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"amount": 0,
|
||||
"archived_at": 0,
|
||||
"assigned_user_id": "",
|
||||
"auto_bill": "",
|
||||
"auto_bill_enabled": false,
|
||||
"balance": 0,
|
||||
"client_id": "",
|
||||
"created_at": 0,
|
||||
"custom_surcharge1": 0,
|
||||
"custom_surcharge2": 0,
|
||||
"custom_surcharge3": 0,
|
||||
"custom_surcharge4": 0,
|
||||
"custom_surcharge_tax1": false,
|
||||
"custom_surcharge_tax2": false,
|
||||
"custom_surcharge_tax3": false,
|
||||
"custom_surcharge_tax4": false,
|
||||
"custom_value1": "",
|
||||
"custom_value2": "",
|
||||
"custom_value3": "",
|
||||
"custom_value4": "",
|
||||
"date": "",
|
||||
"design_id": "",
|
||||
"discount": 0,
|
||||
"documents": [],
|
||||
"due_date": "",
|
||||
"due_date_days": "",
|
||||
"e_invoice": {},
|
||||
"entity_type": "",
|
||||
"exchange_rate": 0,
|
||||
"footer": "",
|
||||
"frequency_id": "",
|
||||
"has_expenses": false,
|
||||
"has_tasks": false,
|
||||
"id": "",
|
||||
"invitations": [
|
||||
{
|
||||
"archived_at": 0,
|
||||
"can_sign": false,
|
||||
"client_contact_id": "",
|
||||
"created_at": 0,
|
||||
"email_error": "",
|
||||
"email_status": "",
|
||||
"id": "",
|
||||
"key": "",
|
||||
"link": "",
|
||||
"message_id": "",
|
||||
"opened_date": "",
|
||||
"sent_date": "",
|
||||
"updated_at": 0,
|
||||
"viewed_date": ""
|
||||
}
|
||||
],
|
||||
"is_amount_discount": false,
|
||||
"is_deleted": false,
|
||||
"last_sent_date": "",
|
||||
"line_items": [],
|
||||
"location_id": "",
|
||||
"next_send_date": "",
|
||||
"next_send_datetime": "",
|
||||
"number": "",
|
||||
"paid_to_date": 0,
|
||||
"partial": 0,
|
||||
"partial_due_date": "",
|
||||
"po_number": "",
|
||||
"private_notes": "",
|
||||
"project_id": "",
|
||||
"public_notes": "",
|
||||
"recurring_dates": [],
|
||||
"remaining_cycles": 0,
|
||||
"status_id": "",
|
||||
"subscription_id": "",
|
||||
"tags": [],
|
||||
"tax_name1": "",
|
||||
"tax_name2": "",
|
||||
"tax_name3": "",
|
||||
"tax_rate1": 0,
|
||||
"tax_rate2": 0,
|
||||
"tax_rate3": 0,
|
||||
"terms": "",
|
||||
"total_taxes": 0,
|
||||
"updated_at": 0,
|
||||
"user_id": "",
|
||||
"uses_inclusive_taxes": false,
|
||||
"vendor_id": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
+185
@@ -0,0 +1,185 @@
|
||||
{
|
||||
"bulk_updates": {
|
||||
"client": [
|
||||
""
|
||||
],
|
||||
"expense": [
|
||||
""
|
||||
],
|
||||
"recurring_invoice": [
|
||||
""
|
||||
],
|
||||
"task": [
|
||||
""
|
||||
]
|
||||
},
|
||||
"countries": [
|
||||
{
|
||||
"capital": "",
|
||||
"citizenship": "",
|
||||
"country_code": "",
|
||||
"currency": "",
|
||||
"currency_code": "",
|
||||
"currency_sub_unit": "",
|
||||
"decimal_separator": "",
|
||||
"eea": false,
|
||||
"full_name": "",
|
||||
"id": "",
|
||||
"iso_3166_2": "",
|
||||
"iso_3166_3": "",
|
||||
"name": "",
|
||||
"region_code": "",
|
||||
"sub_region_code": "",
|
||||
"swap_currency_symbol": false,
|
||||
"swap_postal_code": false,
|
||||
"thousand_separator": ""
|
||||
}
|
||||
],
|
||||
"currencies": [
|
||||
{
|
||||
"code": "",
|
||||
"decimal_separator": "",
|
||||
"exchange_rate": 0,
|
||||
"id": "",
|
||||
"name": "",
|
||||
"precision": 0,
|
||||
"swap_currency_symbol": false,
|
||||
"symbol": "",
|
||||
"thousand_separator": ""
|
||||
}
|
||||
],
|
||||
"date_formats": [
|
||||
{
|
||||
"format": "",
|
||||
"format_dart": "",
|
||||
"format_moment": "",
|
||||
"id": ""
|
||||
}
|
||||
],
|
||||
"datetime_formats": [
|
||||
{
|
||||
"format": "",
|
||||
"format_dart": "",
|
||||
"format_moment": "",
|
||||
"id": ""
|
||||
}
|
||||
],
|
||||
"gateways": [
|
||||
{
|
||||
"created_at": 0,
|
||||
"default_gateway_type_id": "",
|
||||
"fields": "",
|
||||
"id": "",
|
||||
"is_offsite": false,
|
||||
"is_secure": false,
|
||||
"key": "",
|
||||
"name": "",
|
||||
"options": {
|
||||
"1": {
|
||||
"refund": false,
|
||||
"token_billing": false,
|
||||
"webhooks": [
|
||||
""
|
||||
]
|
||||
},
|
||||
"2": {
|
||||
"refund": false,
|
||||
"token_billing": false,
|
||||
"webhooks": [
|
||||
""
|
||||
]
|
||||
}
|
||||
},
|
||||
"provider": "",
|
||||
"site_url": "",
|
||||
"sort_order": 0,
|
||||
"updated_at": 0,
|
||||
"visible": false
|
||||
}
|
||||
],
|
||||
"industries": [
|
||||
{
|
||||
"id": "",
|
||||
"name": ""
|
||||
}
|
||||
],
|
||||
"languages": [
|
||||
{
|
||||
"id": "",
|
||||
"locale": "",
|
||||
"name": ""
|
||||
}
|
||||
],
|
||||
"license_key": false,
|
||||
"payment_types": [
|
||||
{
|
||||
"gateway_type_id": 0,
|
||||
"id": "",
|
||||
"name": ""
|
||||
}
|
||||
],
|
||||
"sizes": [
|
||||
{
|
||||
"id": "",
|
||||
"name": ""
|
||||
}
|
||||
],
|
||||
"templates": {
|
||||
"invoice": {
|
||||
"body": "",
|
||||
"subject": ""
|
||||
},
|
||||
"partial_payment": {
|
||||
"body": "",
|
||||
"subject": ""
|
||||
},
|
||||
"payment": {
|
||||
"body": "",
|
||||
"subject": ""
|
||||
},
|
||||
"payment_failed": {
|
||||
"body": "",
|
||||
"subject": ""
|
||||
},
|
||||
"purchase_order": {
|
||||
"body": "",
|
||||
"subject": ""
|
||||
},
|
||||
"quote": {
|
||||
"body": "",
|
||||
"subject": ""
|
||||
},
|
||||
"quote_reminder1": {
|
||||
"body": "",
|
||||
"subject": ""
|
||||
},
|
||||
"reminder1": {
|
||||
"body": "",
|
||||
"subject": ""
|
||||
},
|
||||
"reminder2": {
|
||||
"body": "",
|
||||
"subject": ""
|
||||
},
|
||||
"reminder3": {
|
||||
"body": "",
|
||||
"subject": ""
|
||||
},
|
||||
"reminder_endless": {
|
||||
"body": "",
|
||||
"subject": ""
|
||||
},
|
||||
"statement": {
|
||||
"body": "",
|
||||
"subject": ""
|
||||
}
|
||||
},
|
||||
"timezones": [
|
||||
{
|
||||
"id": "",
|
||||
"location": "",
|
||||
"name": "",
|
||||
"utc_offset": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"App\\Livewire\\BillingPortal\\Authentication\\Login": {
|
||||
"dependencies": [],
|
||||
"id": ""
|
||||
},
|
||||
"App\\Livewire\\BillingPortal\\Authentication\\Register": {
|
||||
"dependencies": [],
|
||||
"id": ""
|
||||
},
|
||||
"App\\Livewire\\BillingPortal\\Authentication\\RegisterOrLogin": {
|
||||
"dependencies": [],
|
||||
"id": ""
|
||||
},
|
||||
"App\\Livewire\\BillingPortal\\Cart\\Cart": {
|
||||
"dependencies": [],
|
||||
"id": ""
|
||||
}
|
||||
}
|
||||
Vendored
+16
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"category_id": 0,
|
||||
"client_id": "",
|
||||
"company_id": "",
|
||||
"created_at": 0,
|
||||
"event_id": 0,
|
||||
"id": "",
|
||||
"log": "",
|
||||
"type_id": 0,
|
||||
"updated_at": 0,
|
||||
"user_id": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
+14
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"archived_at": 0,
|
||||
"color": null,
|
||||
"created_at": 0,
|
||||
"entity_type": "",
|
||||
"id": "",
|
||||
"is_deleted": false,
|
||||
"name": "",
|
||||
"updated_at": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
+14
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"archived_at": 0,
|
||||
"color": "",
|
||||
"created_at": 0,
|
||||
"id": "",
|
||||
"is_deleted": false,
|
||||
"name": "",
|
||||
"status_order": 0,
|
||||
"updated_at": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
+36
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"archived_at": 0,
|
||||
"assigned_user_id": "",
|
||||
"client_id": "",
|
||||
"created_at": 0,
|
||||
"custom_value1": "",
|
||||
"custom_value2": "",
|
||||
"custom_value3": "",
|
||||
"custom_value4": "",
|
||||
"date": "",
|
||||
"description": "",
|
||||
"documents": [],
|
||||
"due_date": "",
|
||||
"duration": 0,
|
||||
"estimated_duration": null,
|
||||
"id": "",
|
||||
"invoice_id": "",
|
||||
"is_date_based": false,
|
||||
"is_deleted": false,
|
||||
"is_running": false,
|
||||
"meta": {},
|
||||
"number": "",
|
||||
"project_id": "",
|
||||
"rate": 0,
|
||||
"status_id": "",
|
||||
"status_order": null,
|
||||
"status_sort_order": 0,
|
||||
"tags": [],
|
||||
"time_log": "",
|
||||
"updated_at": 0,
|
||||
"user_id": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
+13
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"archived_at": 0,
|
||||
"created_at": 0,
|
||||
"id": "",
|
||||
"is_deleted": false,
|
||||
"name": "",
|
||||
"rate": 0,
|
||||
"updated_at": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"archived_at": 0,
|
||||
"created_at": 0,
|
||||
"id": "",
|
||||
"is_deleted": false,
|
||||
"is_system": false,
|
||||
"name": "",
|
||||
"token": "",
|
||||
"updated_at": 0,
|
||||
"user_id": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
+44
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"archived_at": 0,
|
||||
"created_at": 0,
|
||||
"custom_value1": "",
|
||||
"custom_value2": "",
|
||||
"custom_value3": "",
|
||||
"custom_value4": "",
|
||||
"email": "",
|
||||
"email_verified_at": 0,
|
||||
"first_name": "",
|
||||
"google_2fa_secret": false,
|
||||
"has_password": false,
|
||||
"id": "",
|
||||
"is_deleted": false,
|
||||
"language_id": "",
|
||||
"last_confirmed_email_address": "",
|
||||
"last_login": 0,
|
||||
"last_name": "",
|
||||
"oauth_provider_id": "",
|
||||
"oauth_user_token": "",
|
||||
"passkey_count": 0,
|
||||
"passkey_enabled": false,
|
||||
"phone": "",
|
||||
"referral_code": "",
|
||||
"referral_meta": {
|
||||
"enterprise": 0,
|
||||
"free": 0,
|
||||
"pro": 0
|
||||
},
|
||||
"settings": {
|
||||
"calendar_connection": {
|
||||
"email": "",
|
||||
"status": ""
|
||||
}
|
||||
},
|
||||
"signature": "",
|
||||
"updated_at": 0,
|
||||
"user_logged_in_notification": false,
|
||||
"verified_phone_number": false
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
+63
@@ -0,0 +1,63 @@
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"address1": "",
|
||||
"address2": "",
|
||||
"archived_at": 0,
|
||||
"assigned_user_id": "",
|
||||
"city": "",
|
||||
"classification": "",
|
||||
"contacts": [
|
||||
{
|
||||
"archived_at": 0,
|
||||
"can_sign": false,
|
||||
"cc_only": false,
|
||||
"created_at": 0,
|
||||
"custom_value1": "",
|
||||
"custom_value2": "",
|
||||
"custom_value3": "",
|
||||
"custom_value4": "",
|
||||
"email": "",
|
||||
"first_name": "",
|
||||
"id": "",
|
||||
"is_primary": false,
|
||||
"last_login": 0,
|
||||
"last_name": "",
|
||||
"link": "",
|
||||
"password": "",
|
||||
"phone": "",
|
||||
"send_email": false,
|
||||
"updated_at": 0
|
||||
}
|
||||
],
|
||||
"country_id": "",
|
||||
"created_at": 0,
|
||||
"currency_id": "",
|
||||
"custom_value1": "",
|
||||
"custom_value2": "",
|
||||
"custom_value3": "",
|
||||
"custom_value4": "",
|
||||
"display_name": "",
|
||||
"documents": [],
|
||||
"id": "",
|
||||
"id_number": "",
|
||||
"is_deleted": false,
|
||||
"is_tax_exempt": false,
|
||||
"language_id": "",
|
||||
"last_login": 0,
|
||||
"name": "",
|
||||
"number": "",
|
||||
"phone": "",
|
||||
"postal_code": "",
|
||||
"private_notes": "",
|
||||
"public_notes": "",
|
||||
"routing_id": "",
|
||||
"state": "",
|
||||
"tags": [],
|
||||
"updated_at": 0,
|
||||
"user_id": "",
|
||||
"vat_number": "",
|
||||
"website": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
+18
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"archived_at": 0,
|
||||
"company_id": "",
|
||||
"created_at": 0,
|
||||
"event_id": "",
|
||||
"format": "",
|
||||
"headers": [],
|
||||
"id": "",
|
||||
"is_deleted": false,
|
||||
"rest_method": "",
|
||||
"target_url": "",
|
||||
"updated_at": 0,
|
||||
"user_id": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
+654
-2
@@ -1,5 +1,10 @@
|
||||
use invoiceninjars::models::{
|
||||
Client, Credit, Expense, Invoice, ListParams, Project, Quote, Task, Vendor,
|
||||
use invoiceninja_sdk::models::{
|
||||
BankIntegration, BankTransaction, BankTransactionRule, BulkRequest, Client, ClientGatewayToken,
|
||||
Company, CompanyGateway, CompanyToken, Credit, Design, Document, Expense, ExpenseCategory,
|
||||
GroupSetting, HealthCheck, Invoice, ListParams, ListResponse, Location, LoginResponse, Passkey,
|
||||
PaymentTerm, Project, PurchaseOrder, Quote, RecurringExpense, RecurringInvoice, RecurringQuote,
|
||||
SearchResults, Statics, Subscription, SystemLog, Tag, Task, TaskScheduler, TaskStatus, TaxRate,
|
||||
User, Vendor, Webhook,
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
@@ -150,3 +155,650 @@ fn quote_and_credit_share_the_invoice_shape() {
|
||||
let credit: Credit = serde_json::from_value(payload).unwrap();
|
||||
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");
|
||||
}
|
||||
|
||||
/// The spec says system-log timestamps are date strings; a real server sends
|
||||
/// integers, as it does everywhere else.
|
||||
#[test]
|
||||
fn system_log_timestamps_are_integers() {
|
||||
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": 1792869488,
|
||||
"updated_at": 1792869489,
|
||||
}))
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(log.event_id, 30);
|
||||
assert_eq!(log.created_at, 1792869488);
|
||||
assert_eq!(log.updated_at, 1792869489);
|
||||
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");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn login_response_types_the_token_and_preserves_the_rest() {
|
||||
let payload = json!({
|
||||
"data": [{
|
||||
"is_owner": true,
|
||||
"is_admin": true,
|
||||
"token": { "id": "t1", "token": "abc" },
|
||||
"company": { "id": "c1", "settings": {} },
|
||||
"user": { "id": "u1", "email": "me@example.com" }
|
||||
}]
|
||||
});
|
||||
|
||||
let login: LoginResponse = serde_json::from_value(payload).unwrap();
|
||||
assert!(login.data[0].is_owner);
|
||||
let token = login.data[0].token.as_ref().unwrap();
|
||||
assert_eq!(token.token, "abc");
|
||||
|
||||
let back = serde_json::to_value(&login).unwrap();
|
||||
assert_eq!(back["data"][0]["company"]["id"], "c1");
|
||||
assert_eq!(back["data"][0]["user"]["email"], "me@example.com");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn passkey_round_trip_preserves_last_used_at() {
|
||||
let used = json!({
|
||||
"id": "p1", "name": "Laptop", "created_at": "2026-01-01T00:00:00.000000Z",
|
||||
"last_used_at": 1700000000
|
||||
});
|
||||
let never_used = json!({
|
||||
"id": "p2", "name": "Phone", "created_at": "2026-02-01T00:00:00.000000Z",
|
||||
"last_used_at": null
|
||||
});
|
||||
|
||||
let used: Passkey = serde_json::from_value(used).unwrap();
|
||||
let never_used: Passkey = serde_json::from_value(never_used).unwrap();
|
||||
assert_eq!(used.last_used_at, Some(1700000000));
|
||||
assert_eq!(never_used.last_used_at, None);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
//! Decodes fixtures captured from a real server.
|
||||
//!
|
||||
//! `tests/fixtures/*.json` are written by `examples/capture_shapes.rs` and
|
||||
//! hold only key names and JSON types (strings are `""`, numbers `0`, bools
|
||||
//! `false`). Mock responses written from the upstream source have been wrong
|
||||
//! about the real thing before, so these pin the shapes a server actually
|
||||
//! sent. To refresh them, seed a disposable instance with
|
||||
//! `examples/seed_sample_data.rs` and re-run the capture.
|
||||
//!
|
||||
//! Three properties are checked: every fixture decodes into its model
|
||||
//! (goal 2), a decode followed by a re-serialise loses no key (goal 1), and no
|
||||
//! fixture contains a real value, so a careless capture cannot leak data.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use invoiceninja_sdk::models::*;
|
||||
use serde::{de::DeserializeOwned, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
fn dir() -> PathBuf {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures")
|
||||
}
|
||||
|
||||
fn load(name: &str) -> Value {
|
||||
let path = dir().join(format!("{name}.json"));
|
||||
let text = fs::read_to_string(&path).unwrap_or_else(|e| panic!("{}: {e}", path.display()));
|
||||
serde_json::from_str(&text).unwrap_or_else(|e| panic!("{}: {e}", path.display()))
|
||||
}
|
||||
|
||||
/// The captured row of a list fixture.
|
||||
fn row(name: &str) -> Value {
|
||||
load(name)["data"][0].clone()
|
||||
}
|
||||
|
||||
/// Key paths present in `raw` but absent from `out`. Arrays are compared
|
||||
/// through their first element, which is all a fixture keeps.
|
||||
fn lost_keys(raw: &Value, out: &Value, prefix: &str, into: &mut Vec<String>) {
|
||||
match (raw, out) {
|
||||
(Value::Object(raw), Value::Object(out)) => {
|
||||
for (key, raw_value) in raw {
|
||||
match out.get(key) {
|
||||
Some(out_value) => {
|
||||
lost_keys(raw_value, out_value, &format!("{prefix}.{key}"), into)
|
||||
}
|
||||
None => into.push(format!("{prefix}.{key}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
(Value::Array(raw), Value::Array(out)) => {
|
||||
if let (Some(raw), Some(out)) = (raw.first(), out.first()) {
|
||||
lost_keys(raw, out, &format!("{prefix}[]"), into);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Decodes `raw` as `T`, re-serialises it, and returns problems.
|
||||
fn check<T: DeserializeOwned + Serialize>(name: &str, raw: &Value) -> Vec<String> {
|
||||
let typed: T = match serde_json::from_value(raw.clone()) {
|
||||
Ok(typed) => typed,
|
||||
Err(e) => return vec![format!("{name}: does not decode: {e}")],
|
||||
};
|
||||
let out = serde_json::to_value(&typed).expect("serialises");
|
||||
let mut lost = Vec::new();
|
||||
lost_keys(raw, &out, name, &mut lost);
|
||||
lost.into_iter()
|
||||
.filter(|path| !OMITTED_WHEN_EMPTY.contains(&path.as_str()))
|
||||
.map(|path| format!("{path}: key lost on a decode/re-serialise"))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Fields that are deliberately left out of a request when empty, because the
|
||||
/// server rejects the empty value (`""` becomes `NULL` on a non-null column,
|
||||
/// or fails an `integer` rule). A fixture holds `""` placeholders, so these
|
||||
/// show up as absent after a re-serialise. Adding to this list is a decision.
|
||||
const OMITTED_WHEN_EMPTY: &[&str] = &[
|
||||
"locations.country_id",
|
||||
"purchase_orders.status_id",
|
||||
"webhooks.format",
|
||||
];
|
||||
|
||||
/// One fixture per `(file, type)`; the macro also records which files it
|
||||
/// covered so a new capture cannot sit untested.
|
||||
macro_rules! list_fixtures {
|
||||
($($file:literal => $ty:ty),+ $(,)?) => {
|
||||
const COVERED_LISTS: &[&str] = &[$($file),+];
|
||||
|
||||
fn check_lists() -> Vec<String> {
|
||||
let mut problems = Vec::new();
|
||||
$( problems.extend(check::<$ty>($file, &row($file))); )+
|
||||
problems
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
list_fixtures! {
|
||||
"activities" => Activity,
|
||||
"clients" => Client,
|
||||
"companies" => Company,
|
||||
"credits" => Credit,
|
||||
"designs" => Design,
|
||||
"documents" => Document,
|
||||
"expense_categories" => ExpenseCategory,
|
||||
"expenses" => Expense,
|
||||
"group_settings" => GroupSetting,
|
||||
"invoices" => Invoice,
|
||||
"locations" => Location,
|
||||
"payment_terms" => PaymentTerm,
|
||||
"payments" => Payment,
|
||||
"products" => Product,
|
||||
"projects" => Project,
|
||||
"purchase_orders" => PurchaseOrder,
|
||||
"quotes" => Quote,
|
||||
"recurring_expenses" => RecurringExpense,
|
||||
"recurring_invoices" => RecurringInvoice,
|
||||
"system_logs" => SystemLog,
|
||||
"tags" => Tag,
|
||||
"task_statuses" => TaskStatus,
|
||||
"tasks" => Task,
|
||||
"tax_rates" => TaxRate,
|
||||
"tokens" => CompanyToken,
|
||||
"users" => User,
|
||||
"vendors" => Vendor,
|
||||
"webhooks" => Webhook,
|
||||
}
|
||||
|
||||
/// Fixtures that are not a one-row list.
|
||||
const COVERED_OTHER: &[&str] = &[
|
||||
"statics",
|
||||
"health_check",
|
||||
"subscription_steps",
|
||||
"activity_feed",
|
||||
];
|
||||
|
||||
#[test]
|
||||
fn every_real_row_decodes_and_loses_no_key() {
|
||||
let problems = check_lists();
|
||||
assert!(
|
||||
problems.is_empty(),
|
||||
"{} problem(s):\n{}",
|
||||
problems.len(),
|
||||
problems.join("\n")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bare_endpoint_fixtures_decode() {
|
||||
let mut problems = Vec::new();
|
||||
problems.extend(check::<Statics>("statics", &load("statics")));
|
||||
problems.extend(check::<HealthCheck>("health_check", &load("health_check")));
|
||||
problems.extend(check::<ActivityFeedEntry>(
|
||||
"activity_feed",
|
||||
&load("activity_feed")["data"][0],
|
||||
));
|
||||
|
||||
// Keyed by class name on a real server; see `subscription_steps`.
|
||||
let steps: Result<BTreeMap<String, SubscriptionStep>, _> =
|
||||
serde_json::from_value(load("subscription_steps"));
|
||||
match steps {
|
||||
Ok(steps) => assert!(!steps.is_empty(), "the captured steps object is empty"),
|
||||
Err(e) => problems.push(format!("subscription_steps: {e}")),
|
||||
}
|
||||
|
||||
assert!(problems.is_empty(), "{}", problems.join("\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_fixture_file_is_covered_by_a_test() {
|
||||
let mut on_disk: Vec<String> = fs::read_dir(dir())
|
||||
.expect("tests/fixtures exists")
|
||||
.filter_map(|entry| {
|
||||
let path = entry.ok()?.path();
|
||||
(path.extension()? == "json").then(|| path.file_stem()?.to_str().map(String::from))?
|
||||
})
|
||||
.collect();
|
||||
on_disk.sort();
|
||||
|
||||
let mut covered: Vec<String> = COVERED_LISTS
|
||||
.iter()
|
||||
.chain(COVERED_OTHER)
|
||||
.map(|s| s.to_string())
|
||||
.collect();
|
||||
covered.sort();
|
||||
|
||||
assert_eq!(
|
||||
on_disk, covered,
|
||||
"a captured fixture has no test (or a test has no fixture)"
|
||||
);
|
||||
}
|
||||
|
||||
/// The capture tool replaces every value; this refuses to pass if any
|
||||
/// survived, so real data cannot be committed by accident.
|
||||
#[test]
|
||||
fn fixtures_hold_shapes_and_never_values() {
|
||||
fn walk(value: &Value, at: &str, bad: &mut Vec<String>) {
|
||||
match value {
|
||||
Value::String(s) if !s.is_empty() => bad.push(format!("{at}: string value")),
|
||||
Value::Number(n) if n.as_f64() != Some(0.0) => bad.push(format!("{at}: number value")),
|
||||
Value::Bool(true) => bad.push(format!("{at}: true")),
|
||||
Value::Array(items) => {
|
||||
assert!(items.len() <= 1, "{at}: a fixture keeps one array element");
|
||||
items.iter().for_each(|v| walk(v, &format!("{at}[]"), bad));
|
||||
}
|
||||
Value::Object(map) => map
|
||||
.iter()
|
||||
.for_each(|(k, v)| walk(v, &format!("{at}.{k}"), bad)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
let mut bad = Vec::new();
|
||||
for entry in fs::read_dir(dir()).expect("tests/fixtures exists") {
|
||||
let path = entry.unwrap().path();
|
||||
if path.extension().is_some_and(|e| e == "json") {
|
||||
let name = path.file_stem().unwrap().to_string_lossy().to_string();
|
||||
walk(&load(&name), &name, &mut bad);
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
bad.is_empty(),
|
||||
"fixtures contain values:\n{}",
|
||||
bad.join("\n")
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user