Compare commits

...
20 Commits
Author SHA1 Message Date
awkawb 20ac306c26 Decide that chart stays generic
CI / test (push) Canceled after 0s
CI / Build on the declared MSRV (push) Canceled after 0s
CI / Latest stable (advisory) (push) Canceled after 0s
Investigated ChartController/ChartService directly rather than taking
the old open question at its word: the eleven routes share an outer
currency-keyed idiom in six of nine distinct bodies, but the leaf rows
are raw DB::select results with no shared columns, and the remaining
bodies are bespoke service outputs or a ~20-way scalar match. Typing
any of it would trade dozens of hand-maintained structs for marginal
benefit over the existing generic chart::<T>.
2026-10-30 15:53:45 -04:00
awkawbandClaude Sonnet 5 1a8e4aad26 Add logout, passkey management, and a standalone login
CI / test (push) Canceled after 0s
CI / Build on the declared MSRV (push) Canceled after 0s
CI / Latest stable (advisory) (push) Canceled after 0s
The "auth routes" open question in GOALS.md conflated four unrelated
things once the upstream routes and controllers were checked: logout and
the passkey settings routes sit behind the same token_auth middleware as
everything else this crate wraps, login needs a credential exchange since
there's no token yet, refresh/refresh_react turned out to be the bulk
sync-dump endpoint already parked as a separate open question, and
connected_account is Gmail OAuth linking that belongs with Integrations.

Adds logout, list_passkeys, delete_passkey (passkey registration is left
out — it needs a WebAuthn ceremony a browser has to drive), and
InvoiceNinjaClient::login as an associated function returning a lean
LoginResponse that types only is_owner/is_admin/token and leaves the
server's much larger nested payload in extra.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-30 14:38:48 -04:00
awkawbandClaude Sonnet 5 ab696a910b Bump to 0.2.0 and add crates.io keywords/categories
CI / test (push) Canceled after 0s
CI / Build on the declared MSRV (push) Canceled after 0s
CI / Latest stable (advisory) (push) Canceled after 0s
Resolves the version/changelog mismatch flagged while checking publish
readiness: Cargo.toml had sat at 0.1.0 through everything since the initial
commit, while CHANGELOG.md's `[0.1.0] - 2026-10-23` entry called itself
"Initial release" with no git tag behind it and nothing ever actually
published (crates.io 404s on the name). Rather than relabel that entry after
the fact, it stays as the historical record it is; `[Unreleased]` becomes
`[0.2.0] - 2026-10-25` instead, with a fresh empty `[Unreleased]` left above
it for whatever comes next.

`categories` and `keywords` were empty, which is legal but leaves the crate
unreachable from crates.io's own browse and search pages. Checked both
category slugs against `/api/v1/category_slugs` rather than guessing —
`web-programming::http-client` in particular is a subcategory that doesn't
show up in the plain `/categories` listing, only in the slug list.

Re-ran `cargo publish --dry-run` after both changes: packages, verifies in
an isolated build, and reaches the upload step with no manifest warnings.
Also re-checked the full suite, clippy, fmt, the MSRV shell, and
`nix flake check` — all pass under the bumped version.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-25 16:29:39 -04:00
awkawbandClaude Sonnet 5 fb670cbb08 Rename the crate from invoiceninjars to invoiceninja-sdk
CI / test (push) Canceled after 0s
CI / Build on the declared MSRV (push) Canceled after 0s
CI / Latest stable (advisory) (push) Canceled after 0s
The package name has never matched the repository it lives in
(gitea.awkawb.cloud/awkawb/invoiceninja-sdk, mirrored to
github.com/awkawb1/invoiceninja-sdk) — both pointed at `invoiceninja-sdk`
while `Cargo.toml` declared `invoiceninjars`. Lines everything up on the
name the repo has always used.

Cargo derives the crate's Rust identifier from the package name by
lowercasing hyphens to underscores, so every `use invoiceninjars::...`
becomes `use invoiceninja_sdk::...` — in doctests across `src/`, the four
examples, all four integration test files, and `README.md`. `Cargo.lock`'s
own self-entry picked up the new name on the next `cargo check`, which is
also when the rename gets its real check: every doctest and every test file
has to resolve the new path to compile at all, not just the ones a grep
happens to catch.

`flake.nix`'s `pname` and description follow, so `nix build` produces
`invoiceninja-sdk-0.1.0.drv` rather than a name that no longer matches
anything. `GOALS.md`'s two mentions — the hypothetical CLI's name and the
`cargo install` example — follow too, for the same reason.

Verified clean on both the dev toolchain and the 1.88 MSRV shell: full test
suite (209 tests), clippy, fmt, and `nix build`/`nix flake check`.

Not yet published to crates.io (checked: the name 404s there), so this
lands before the old name was ever claimed — no one's `Cargo.toml` breaks
today, but it's recorded as a Changed entry since `GOALS.md` asks for
breaking changes to be legible in the changelog rather than discovered at
compile time, and the crate is reachable as a git dependency regardless of
crates.io status.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-25 09:43:05 -04:00
awkawbandClaude Sonnet 5 48244e8390 Add a flake package output for the library
CI / test (push) Canceled after 0s
CI / Build on the declared MSRV (push) Canceled after 0s
CI / Latest stable (advisory) (push) Canceled after 0s
`nix build` now works, using `rustPlatform.buildRustPackage` built with the
pinned dev toolchain rather than whatever rustc nixpkgs happens to carry, so
it compiles with the same compiler `nix develop` and CI do. `cargoLock`
points at the repo's own `Cargo.lock` rather than a separately maintained
hash.

The crate has no `[[bin]]` target — see the "No CLI, and no binary at all"
non-goal just added to `GOALS.md` — so the resulting store path comes out
empty: `cargoInstallHook` only copies files matching a `[[bin]]` target out
of `target/release`, and there is nothing to copy. Verified rather than
assumed; the first version of this comment claimed the output held "an rlib
and docs", which is not what actually lands in the store path.

What `nix build` is good for regardless: it compiles with the pinned
toolchain and runs the full suite, doctests included, inside the Nix
sandbox with no network — matching the "no credentials needed" property
`CLAUDE.md` already claims for `cargo test`, now exercised a second way.
Treat it as a build-and-test gate, not a source of an installable artifact.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-25 09:29:11 -04:00
awkawbandClaude Opus 5 d51de3fc63 Rule out a CLI in this package
CI / test (push) Canceled after 0s
CI / Build on the declared MSRV (push) Canceled after 0s
CI / Latest stable (advisory) (push) Canceled after 0s
A command-line client over this library is a reasonable thing to want, and
the reason is sharper than it was a week ago: the e-invoicing and
integration methods return `serde_json::Value` and were verified against
mocks rather than a live service, so "what does this endpoint actually
return" is now a question the crate cannot answer without a new example per
call.

It still belongs in a sibling crate rather than this one. An argument parser
would be the first dependency here that the library does not itself need,
along with the first optional dependency and the first `[features]` section,
in a `Cargo.toml` where every line currently justifies itself. It would
couple the library's MSRV to a floor that moves on someone else's schedule —
not a break today, `clap` 4.6.7 declares 1.85 against this crate's 1.88, so
the entry records the figure with the date it was checked. And a CLI's flags
are a compatibility surface, which one version number would force to move in
lockstep with a library that tells callers to expect breaks in minor
releases.

For probing, `examples/` already covers it at no cost: `capture_shapes`,
`seed_sample_data` and `smoke_test` are CLI-shaped programs configured from
the environment, with the `IN_SMOKE_WRITE_HOST` guard to copy for anything
that writes.

Recorded the same way as the `updated_at` sync engine in the open questions —
a different product, written down so the option is not forgotten rather than
because it is rejected outright.

No changelog entry: nothing about crate behaviour changed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-25 09:04:41 -04:00
awkawbandClaude Opus 5 446617a728 Wrap the e-invoicing and integration routes
CI / test (push) Canceled after 0s
CI / Build on the declared MSRV (push) Canceled after 0s
CI / Latest stable (advisory) (push) Canceled after 0s
Closes the last roadmap item, and with it the roadmap: every route in
`routes/api.php` that an API-token client can reach is now wrapped. 33
methods — 17 e-invoicing, 4 QuickBooks, 4 Stripe, 6 calendar, and one each
for Yodlee and GoCardless.

Most return `serde_json::Value`, which is deliberate and is the crate's
largest untyped surface. These replies are pass-throughs from Storecove,
Stripe's gateway driver, Yodlee or GoCardless, or arrays assembled for
Invoice Ninja's own React client. None has a documented schema, several
differ by provider, and they move on the provider's release schedule rather
than Invoice Ninja's — so the rule that keeps `chart` generic applies here
with more force, not less. `client_peppol_discovery` set the precedent.

Three upstream behaviours needed accommodating, each now pinned by a test:

- `validate_einvoice` answers 422 when validation finds problems, so an
  ordinary "this invoice is not Peppol-ready" result arrives as `Error::Api`
  rather than an `Ok` carrying the failures. The per-entity detail is not
  reachable through the typed error. Documented rather than worked around.
- `einvoice/peppol/setup` has two success shapes: a `corppass_url` when the
  connection needs a further browser step, or a bare 204 when it completed.
  Hence `post_unwrapped_opt` and `Option<Value>`, since decoding the 204
  would otherwise be a spurious error.
- `einvoice/peppol/remove_additional_legal_identifier` is a DELETE carrying
  a body — the only one in the crate — and `einvoice/peppol/update` a PUT
  with one, hence `send_unwrapped`.

The Peppol handlers also return `data_get($response, 'message')` as the
entire error body, which may be a bare string rather than
`{"message": …, "errors": {…}}`. `Error::Api` then falls back to an excerpt
with an empty `errors` map; the docs say so on the impl block.

Two things in this block stay unwrapped because they cannot be wrapped. The
OAuth legs of the QuickBooks and calendar flows sit outside the `api/v1`
group, take no token, and answer with redirects, so connecting an account
remains a browser flow — these methods operate a connection that already
exists. And `POST /quickbooks/settings` is a registered route whose
controller has no `settings` method, so it raises `BadMethodCallException`
and 500s; there is nothing to wrap until upstream adds the handler. Both are
now entries in `COVERAGE.md`'s gap list rather than silent omissions.

`GOALS.md` keeps the roadmap rather than deleting it: several entries record
what an item's original reasoning got wrong, which outlasts the fact that it
shipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-24 23:24:10 -04:00
awkawbandClaude Opus 5 c4f31743f0 Wrap import, export, and the e-document importer
First piece of the last roadmap item. Five routes: `POST /export`,
`POST /preimport`, `POST /import`, `POST /import_json`, and
`PUT /edocument/upload`.

A CSV import is two calls, and only that form is wrapped. `preimport`
uploads one file per entity type as `files[<entity_type>]` — a different
field name from the `documents[]` the `/{id}/upload` routes take — and
answers with a hash plus the column mapping the server suggests. `import`
then starts the ingest from that hash, as plain JSON: with a hash present
`ImportController::import` never looks at uploaded files.

The route does also accept the files and the mapping together in one
multipart request. That form is left out on purpose. A mapping is a nested
structure, and bracket-encoding nested structures into form fields is the
thing this crate avoids by sending JSON everywhere else — the same reason
`COVERAGE.md` gives for diverging from the PHP SDK's `form_params`.

`upload_edocument` is not a document attachment, which is why it is not an
`upload_*` method and why the roadmap had already split it out:
`ExpenseController::edocument` parses each file and creates *expenses*. It
shares the `_method=PUT` spoof with the upload routes, since it is likewise
registered as a `PUT`, but its reply has no `data` envelope — hence the new
`multipart_unwrapped` helper, which dispatches a form built at the call site
rather than imposing one shape on all four import routes.

Two new models, for the two shapes that are stable and that are the point of
the call: `ExportJob` (the collection URL, which expires in one hour, or
five past 10,000 activities) and `ImportPreview` (the hash). Their
`available`/`headers`/`hints` payloads stay `Value` — those come from each
entity's own `importable()` map and are undocumented, so a concrete type
would be a hard decode error where `Value` is lossless.

All five queue work and answer before it happens, so a 200 means accepted.
Failed rows are reported by email, not in the response, and `import_json`
overwrites the company it is pointed at.

`/migration/start` stays unwrapped and is now listed on its own: it is
driven by the v4 migration tool rather than by an SDK caller.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-24 23:17:36 -04:00
awkawbandClaude Opus 5 e6e41fe5c6 Wrap the eight invitation-key downloads
CI / test (push) Canceled after 0s
CI / Build on the declared MSRV (push) Canceled after 0s
CI / Latest stable (advisory) (push) Canceled after 0s
`GOALS.md` listed these as "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 the route names rather than the route file.

All eight sit inside `routes/api.php`'s `api/v1` group, under its
`token_auth` middleware, and the controllers' own OpenAPI blocks declare
`X-API-TOKEN`. So they needed no new entry point — just a macro, alongside
`entity_action_methods!` and for the same four entities. 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; it is now the gap entry in `COVERAGE.md`.

`download_{invoice,quote,credit,purchase_order}_by_invitation_key` returns
the PDF and `download_e_*_by_invitation_key` the XML e-document. These take
a contact's invitation key rather than a record id, so the server renders in
that contact's locale — a client with contacts in two languages gets a
different document per key, which the id-based `download_*` cannot express.

Two things here are easy to get wrong, so both are pinned by tests. The
entity segment is singular (`invoice/{key}/download`), the only route family
in the API that departs from the plural CRUD path. And an unknown key is a
400 carrying `no record found`: the controllers return that directly instead
of raising `ModelNotFoundException`, so the status matches the rule for a
missing record but the message does not.

The smoke test's read tier now downloads an invoice by invitation key,
taking the key from the `invitations` array the list route already returns.
It is there to re-check the token claim against a real server rather than
against a second reading of the source. The `download_e_*` four are left
out: they render from e-invoice settings a test instance need not have.

Also in here, unrelated: a rustfmt fix to a `match` arm in
`examples/smoke_test.rs` that was already off-format on main, so
`cargo fmt --check` — and with it CI's `test` job — was failing before this
change. It could not be split out cleanly, being in a file this change also
touches.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-24 23:00:54 -04:00
awkawbandClaude Opus 5 2532b83a32 Point the Gitea links at the renamed repository
CI / test (push) Canceled after 0s
CI / Build on the declared MSRV (push) Canceled after 0s
CI / Latest stable (advisory) (push) Canceled after 0s
The canonical repo is now `awkawb/invoiceninja-sdk`; the old
`awkawb/invoiceninja` path only resolves through a redirect.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-24 22:07:47 -04:00
awkawbandClaude Opus 5 e7c9663621 Expect a 400, not a 404, for a record that is gone
Running the password tier against a disposable 5.13.50 instance for the
first time failed its last check. The purge itself was fine — the row really
was gone — but the assertion looked for a 404 and the server answers 400.

`Handler.php` maps Laravel's `ModelNotFoundException` to a 400 carrying
`No query results for model [App\Models\X].` and reserves 404 for a route
that does not exist, so a not-found check written against 404 never fires.
The assertion now matches the status together with the message, so a 400
raised for some other reason still fails, and `Error::Api` says so for
callers, who would otherwise write the same dead branch.

Also adds jq to the devshell, which the MSRV recheck in CLAUDE.md pipes
through but which was only ever resolving from the host.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-24 21:57:12 -04:00
awkawbandClaude Opus 5 0485272080 Document the new endpoints and the fixture workflow
Updates the three coverage summaries to 325 methods so they agree, moves the
per-resource extras out of the gap list in `COVERAGE.md` now that they are
wrapped, and records why the fixtures exist and how to refresh them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-24 21:56:46 -04:00
awkawbandClaude Opus 5 a7d9a2d9d6 Extend the smoke test with write and password tiers
The read tier now also fetches each row raw and fails if a typed row lost a
key, which is what catches a field the models drop.

The write tier creates a purchase order, location, webhook and recurring
invoice from bare `Default` models, since those are exactly what broke
against a real server, and exercises the uploads and the client extras. The
password tier merges two throwaway clients and purges the survivor.

Writes stay behind the existing guard: `IN_SMOKE_WRITE_HOST` must equal the
host in `IN_BASE_URL`, so a write only ever reaches a server that was named
explicitly. It prints PASS/FAIL per check and never the contents of a
record. `smoke.yml` runs it on demand only (`workflow_dispatch`), with the
write tier gated on the `write_host` input naming the instance.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-24 21:56:31 -04:00
awkawbandClaude Opus 5 46a6ea1322 Pin the response shapes captured from a real server
Twice now a mock written from the upstream source passed while a real server
disagreed — the system-log timestamps and the `/subscriptions/steps` shape —
and `frequency_id` and several empty-string fields were wrong the same way.
Reading the source harder was not going to find the next one.

`tests/fixtures/*.json` hold the *shapes* of real responses: every key, with
its JSON type, and no values at all. `real_shapes.rs` decodes each one and
checks that a decode plus re-serialise loses no key, so a field the models
silently drop now fails a test. It also refuses a fixture holding any real
value, and refuses a fixture that no test reads.

`seed_sample_data.rs` fills a disposable instance with one row of each kind
and `capture_shapes.rs` captures the shapes back out with GET requests only,
so the set can be refreshed when a divergence turns up.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-24 21:56:17 -04:00
awkawbandClaude Opus 5 83270e8262 Check every model absorbs a null or missing field, mechanically
Invoice Ninja returns `null` for many nominally-string fields and omits
others, so one 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`, then nulls and separately removes each field in turn,
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.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-24 21:56:09 -04:00
awkawbandClaude Opus 5 f616da08db Wrap the per-resource extras, activities, email history and previews
Forty-one hand-written endpoints, taking the crate from 284 methods to 325:
the per-resource extras (webhook retry, task sorting, default design,
subscription steps, the company gateway and bank integration actions, and
the client extras), the password-protected destructive actions (client and
company purges, client and vendor merges, user invite and purge), the
activity list, feed, per-entity feed and note, email history, and the six
preview routes.

The password-protected actions send `X-API-PASSWORD`, falling back to
`X-API-PASSWORD-BASE64` when the password holds a control character, which
cannot go in a header value. None of the destructive actions are replayed
on a 5xx, and neither are the notes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-24 21:55:54 -04:00
awkawbandClaude Opus 5 bd2d34cae9 Correct the models against what a real server actually sends
Every one of these was wrong in a way the mock tests could not see, because
the mocks were written from the same upstream source the models were.

- `SystemLog.created_at`/`updated_at` are integer timestamps, not the date
  strings the OpenAPI spec declares.
- `frequency_id` arrives as a numeric string on recurring invoices and
  expenses, so both now decode through `int_or_numeric_string`.
- `Location.country_id`, `PurchaseOrder.status_id` and `Webhook.format` are
  omitted when empty. The server runs `ConvertEmptyStringsToNull`, so an
  unset `String` sent as `""` becomes NULL and then fails a non-null column
  or an `integer` rule — which meant a create from a `Default` model could
  not succeed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-24 21:55:46 -04:00
awkawbandClaude Opus 5 0002d32197 Add the Activity, SubscriptionStep and MessageResponse models
These are the types the per-resource extras and the activity endpoints
need, split out so they land before the code and tests that use them.

`MessageResponse` covers the `{"message": "..."}` body that the action
endpoints answer with, defaulting `message` so a reply carrying none still
decodes. `int_or_numeric_string` goes in alongside them: the spec calls
`frequency_id` a number, but a real 5.13.50 server sends the string `"5"`,
and a strict `i64` turned that into a hard decode error *after* the record
had already been created.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-24 21:55:39 -04:00
awkawbandClaude Opus 5 9e7d81307d Add a smoke test for real servers, and show why transport errors happen
The suite runs against mock servers written from the upstream source, so
it cannot catch a place where a real server disagrees with that reading.
`examples/smoke_test.rs` can: it lists every resource, checks the
pagination walk against a single oversized request, probes the per_page
cap, and exercises statics, search and health_check. The upload wire
format (POST plus `_method=PUT`) was read from the framework source
rather than observed, so its write tier is the real verification of it.

It prints PASS/FAIL per check and never the contents of a record.

Writes are guarded. `IN_SMOKE_WRITE_HOST` must equal the host in
`IN_BASE_URL`, so a write can only reach a server that was named
explicitly. `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. The write tier leaves a gap in the client number
sequence and a soft-deleted row even after cleanup, so it is documented
as disposable-instance only.

Running it against a dead port exposed a diagnosability gap:
`Error::Http` printed "error sending request for url (...)" and nothing
else. reqwest's Display stops at the outermost layer, and the reason
("Connection refused", a DNS failure, a TLS error) lives in `source()`.
The error now renders its whole cause chain.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-24 14:35:46 -04:00
awkawbandClaude Opus 5 e4f2373b53 Add multipart document uploads for the 16 resources that have the route
First item on the GOALS.md roadmap. `upload_{x}(id, files, is_public)`
attaches files through `/{resource}/{id}/upload` and returns the
refreshed record. Verified against upstream that all 16 controllers
answer with `itemResponse($x->fresh())`.

Three details that look wrong but are not, each checked in the upstream
source rather than taken from the published spec:

- The request is a POST carrying `_method=PUT`. The route is registered
  PUT, but PHP does not parse a multipart body on a real PUT, so the
  files never arrive. Laravel's kernel calls
  `enableHttpMethodParameterOverride()`, turning the spoofed method back
  into a PUT before routing. The 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 "true"
  fails validation.
- Uploads are never retried. 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. This falls out of reqwest's multipart body being
  un-cloneable, which nothing in the types enforces, so a test pins it
  for 500, 503 and 429.

An upload with no files, a nameless file or an unparseable MIME type is
refused locally (`Error::InvalidUpload`), since the server answers an
empty upload with a success that changed nothing.

Corrects two earlier claims:

- GOALS.md and COVERAGE.md said uploads would make `create_document`
  possible. They do not: `DocumentController::store` is an empty stub,
  and an upload is the only way a document is created. There is no
  `create_document`.
- The documented method count (265) had been stale since the typed
  `statics`/`search`/`health_check` commit, which added three `_as`
  methods without updating it. Recounted from the source: 284 = 210 +
  18 + 12 + 16 uploads + 28 hand-written.

Also fixes a README example, `let statics: Value = client.statics()`,
that stopped compiling when `statics` lost its type parameter.

`edocument/upload` is left out: it imports an expense from an
e-document rather than attaching a file, so it moves to the
import/export roadmap item.

Enabling `multipart` adds mime, mime_guess and unicase; MSRV stays 1.88,
re-verified by running the suite on it. 101 tests, up from 91.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-24 14:26:41 -04:00
67 changed files with 9021 additions and 195 deletions
+47
View File
@@ -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 }}
+123 -1
View File
@@ -9,6 +9,96 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### 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.
@@ -17,7 +107,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
release. The advisory job fails visibly rather than silently; it is kept
non-blocking through branch protection rather than `continue-on-error`.
- Wrappers for every one of the API's 34 `Route::resource` groups, plus
`users` — 265 methods. See `COVERAGE.md` for the per-endpoint inventory.
`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
@@ -39,6 +129,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### 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
@@ -58,6 +156,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
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
@@ -69,6 +171,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- `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 `#`
+162 -12
View File
@@ -22,6 +22,14 @@ 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:
@@ -84,10 +92,94 @@ IN_API_TOKEN=... cargo run --example list_clients
```
Tests live in `tests/` (integration-style, no network access required);
`src/` has no `#[test]` modules. Two files: `model_roundtrip.rs` covers the
serde layer, and `client_requests.rs` covers the request plumbing
(pagination, path escaping, headers, timeouts, error mapping) against a
`wiremock` server on loopback — no credentials or internet needed.
`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
@@ -161,10 +253,49 @@ changes, doc tweaks) does not need an entry.
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
terminates on `meta.pagination.total_pages` and only falls back to the
short-page heuristic when the server sends no metadata. **The heuristic is
@@ -220,22 +351,41 @@ changes, doc tweaks) does not need an entry.
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
**All 34 of the API's `Route::resource` groups are wrapped**, plus `users`
(which isn't one). 265 methods: 30 resources with CRUD + bulk, 3 with CRUD
only, 4 with entity actions, and 25 hand-written.
(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, `report`, `chart`, `search`, `statics`,
`send_email`, `refund_payment`, `ping`, and `health_check`.
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. The main remaining gaps are multipart
uploads (needs a reqwest feature), a handful of per-resource extras
(`clients/{id}/purge`, `company_gateways/{id}/test`, …), and the
integration/auth routes.
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
+407 -51
View File
@@ -18,7 +18,7 @@ declares 34 `Route::resource` groups and 274 explicit route declarations
> exists or what it returns.
**All 34 `Route::resource` groups are now wrapped**, plus `users` (which is
not a resource group). 265 methods in total.
not a resource group). 375 methods in total.
## Resources with full CRUD + bulk
@@ -58,6 +58,10 @@ not a resource group). 265 methods in total.
| 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.
@@ -123,6 +127,207 @@ 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 |
@@ -164,16 +369,164 @@ 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
### Blocked on a dependency change
### Nothing is blocked on a dependency
**Multipart uploads** — `PUT /{resource}/{id}/upload` exists for clients,
companies, credits, expenses, group_settings, invoices, payments, products,
projects, purchase_orders, quotes, recurring_expenses, recurring_invoices,
recurring_quotes, tasks, and vendors. These need reqwest's `multipart`
feature, which isn't currently enabled. This is the main reason `Document`
has no `create_document`.
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
@@ -184,62 +537,53 @@ from `src/models/mod.rs`, and add a `resource_methods!` invocation in
`src/client.rs` (or `crud_methods!` if the resource has no `/bulk` route —
check `routes/api.php`).
### Password-protected endpoints still unwrapped
`with_api_password` makes these reachable, but the methods themselves aren't
written yet:
- `POST /clients/{id}/purge`
- `POST /clients/{id}/{other}/merge`
- `POST /vendors/{id}/{other}/merge`
- `POST /companies/purge/{company}`,
`POST /companies/purge_save_settings/{company}`
- `POST /users/{id}/invite`, `POST /users/{id}/purge`, `GET /users/create`
### Individual endpoints not wrapped
- **Client extras** — `updateTaxData`, `show_settings`, `peppol_discovery`,
`check_vat`, `vat_status`, `documents`
- **Bank integration extras** — `refresh_accounts`, `remove_account/{id}`,
`get_transactions/{id}`, and `POST /bank_transactions/match`
- **Company gateway extras** — `test`, `clone`, `import_customers`
- **Company extras** — `GET /companies/{id}/logo`,
`POST /companies/{id}/default`, `updateOriginTaxData`,
`POST /companies/current`
- **Design extras** — `POST /designs/set/default`
- **Client gateway token extras** — `setAsDefault`
- **Task extras** — `POST /tasks/sort`
- **Webhook extras** — `POST /webhooks/{id}/retry`
- **Subscription extras** — `GET /subscriptions/steps`,
`POST /subscriptions/steps/check`
- **User extras** (not password-protected, unlike the rest of `/users`) —
`POST /users/{id}/disconnect_mailer`, `POST /users/{id}/disconnect_oauth`,
`POST /user/{id}/reconfirm`
- **Preview** — `POST /preview`, `POST /preview/purchase_order`, live preview
- **Activities** — `GET /activities`, `POST /activities/entity`,
`POST /activities/notes`, `download_entity/{activity}`
- **Email history** — `POST /emails/clientHistory/{client}`,
`POST /emails/entityHistory`
- **Auth** — `/login`, `/logout`, `/refresh`, `/connected_account`,
passkey routes
- **Import/export** — `/import`, `/preimport`, `/import_json`, `/export`,
`/migration/start`
- **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`
- **Public invitation-key downloads** — `GET /{entity}/{invitation_key}/download`
for `invoice`, `quote`, `credit`, and `purchase_order` (note the singular
path segment), plus the matching e-invoice variants `download_e_invoice`,
`download_e_quote`, `download_e_credit`,
`download_e_purchase_order`. These are invitation-key based and take no
auth token, so they don't fit the client's authenticated request path.
- **Integrations** — Stripe, QuickBooks, Apple in-app purchase, PostMark,
einvoice (17 routes), calendar_connection, self-update, templates, hooks,
Yodlee (`/yodlee/*`), Nordigen (`/nordigen/institutions`)
- **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`, `/refresh_react`, `/webcron`,
`/smtp/check`, `/password_timeout`, `/webcron`,
`/token_hash_router`, `/reactivate_email/{bounce_id}`,
`/protected_download/{hash}`, `/confirm_purchase`,
`/exports/preview/{hash}`, `/templates/preview/{hash}`
@@ -252,6 +596,17 @@ 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
@@ -286,7 +641,8 @@ top of a shared `BaseEntity` offering `all`, `get`, `create`, `update`,
`recurring_quotes`, `recurring_expenses`, `bank_transaction_rules`,
`company_gateways`, `designs`, `locations`, `tags`, `task_schedulers`,
`tokens`, `system_logs`, documents, per-entity actions (`mark_sent`,
`mark_paid`, `clone_to_*`, …), per-entity PDF download, reports, charts,
`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.
Generated
+26 -2
View File
@@ -528,8 +528,8 @@ dependencies = [
]
[[package]]
name = "invoiceninjars"
version = "0.1.0"
name = "invoiceninja-sdk"
version = "0.2.0"
dependencies = [
"percent-encoding",
"reqwest",
@@ -600,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"
@@ -776,6 +792,7 @@ dependencies = [
"base64 0.22.1",
"bytes",
"futures-core",
"futures-util",
"http",
"http-body",
"http-body-util",
@@ -784,6 +801,7 @@ dependencies = [
"hyper-util",
"js-sys",
"log",
"mime_guess",
"native-tls",
"percent-encoding",
"pin-project-lite",
@@ -1163,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"
+7 -3
View File
@@ -1,6 +1,6 @@
[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
@@ -10,9 +10,13 @@ rust-version = "1.88"
description = "An async Rust client for the Invoice Ninja v5 API"
license = "MIT"
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"
+160 -38
View File
@@ -85,6 +85,38 @@ Invoice Ninja account to contribute.
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
@@ -121,7 +153,7 @@ 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`. A Gitea push mirror
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
@@ -137,50 +169,140 @@ without checking — see the note in `CLAUDE.md`.
## Roadmap: closing the coverage gaps
All 34 `Route::resource` groups are wrapped. Full coverage is the target, so
`COVERAGE.md`'s gap list is a to-do list, not a permanent boundary. Roughly
in order of value over effort:
**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.
1. **Multipart uploads** — 16 `PUT /{resource}/{id}/upload` routes plus
`edocument/upload`. Blocked only on enabling reqwest's `multipart`
feature. This is also what makes `create_document` possible, so it
unblocks the one obviously missing piece of an otherwise complete
resource.
2. **Per-resource extras** — mechanical, and they reuse existing patterns:
`webhooks/{id}/retry`, `tasks/sort`, `designs/set/default`,
`client_gateway_tokens/{id}/setAsDefault`, `subscriptions/steps`, the
client extras (`updateTaxData`, `show_settings`, `check_vat`, …), the
bank-integration and company-gateway extras.
3. **Password-protected actions** — `with_api_password` already makes these
reachable; only the methods are missing. Client purge and merge, vendor
merge, company purge, user invite and purge.
4. **Preview, activities, and email history** — distinct response shapes, so
each needs its own hand-written method and probably its own model.
5. **Public invitation-key downloads** — eight routes that take no auth
token, so they do not fit the authenticated request path and need a
different entry point.
6. **Import/export and the integrations** — Stripe, QuickBooks, einvoice
(17 routes), Yodlee, Nordigen, calendar. The largest block and the least
SDK-shaped; worth doing last and possibly in pieces.
The items ran roughly in order of value over effort:
Keep `COVERAGE.md` current as these land — it names exact counts, so it goes
stale quietly.
**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.
- **Auth routes** (`/login`, `/logout`, `/refresh`, passkeys) are in the gap
list, but they are a different authentication model from the API token
this client is built around. Including them may mean a second client type
rather than more methods on this one. Not obviously in scope.
- **`chart` is still generic** over `T: DeserializeOwned`, because the
eleven chart endpoints share no response shape. Per-chart structs would
be eleven small models of unstable payloads; unclear whether that earns
its keep.
- **Large responses are buffered entirely into memory.** PDF downloads and
CSV report exports go through `Vec<u8>`. Streaming would be better for
big exports but changes those signatures.
- **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.
+31 -10
View File
@@ -1,4 +1,4 @@
# invoiceninjars
# invoiceninja-sdk
An async Rust client for the [Invoice Ninja](https://www.invoiceninja.com/) v5 API.
@@ -9,7 +9,7 @@ Requires Rust 1.88 or newer.
> [`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); the
> [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.
@@ -33,7 +33,7 @@ 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")?)?;
@@ -75,7 +75,7 @@ with exponential backoff. Both are configurable:
```rust
use std::time::Duration;
use invoiceninjars::RetryPolicy;
use invoiceninja_sdk::RetryPolicy;
let client = InvoiceNinjaClient::hosted(token)?
.with_timeout(Duration::from_secs(300))
@@ -100,7 +100,7 @@ IN_API_TOKEN=... cargo run --example list_clients
## Coverage
Every one of the API's 34 resource groups, plus users — 265 methods. Each
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`:
@@ -114,8 +114,11 @@ resource has `list`, `list_all`, `get`, `create`, `update`, `delete`, and
| Admin | Companies, Users, Group Settings, Tokens, System Logs |
| Settings | Tax Rates, Payment Terms, Webhooks, Designs, Locations, Tags |
Plus documents, entity actions and PDF downloads, reports, charts, search,
statics, email sending, refunds, and health checks.
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.
@@ -158,15 +161,33 @@ let pdf: Vec<u8> = client.download_invoice("abc123").await?;
let merged: Vec<u8> = client.download_invoices(&["abc123", "def456"]).await?;
```
Reports return their raw export body (CSV for most of them), and the
schemaless endpoints decode into any type you choose:
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: Value = client.statics().await?;
let statics = client.statics().await?;
```
Add more resources by adding a model under `src/models/` and a
+167
View File
@@ -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");
}
+3 -3
View File
@@ -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");
+232
View File
@@ -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
}
+898
View File
@@ -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)),
}
}
+33 -2
View File
@@ -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";
@@ -37,9 +37,20 @@
msrvToolchain = toolchainFor msrvVersion [ ];
shellFor = toolchain: pkgs.mkShell {
packages = [ toolchain pkgs.pkg-config pkgs.openssl ];
# 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 = shellFor rustToolchain;
@@ -47,5 +58,25 @@
# 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.
};
});
}
+1337 -41
View File
File diff suppressed because it is too large Load Diff
+42 -1
View File
@@ -15,7 +15,11 @@ pub struct ApiErrorBody {
#[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),
/// The response was not the JSON this endpoint is documented to return.
@@ -31,6 +35,15 @@ pub enum 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,
@@ -54,6 +67,14 @@ pub enum Error {
#[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.
///
@@ -66,3 +87,23 @@ pub enum Error {
}
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
}
+4 -2
View File
@@ -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(),
@@ -18,7 +18,9 @@ mod client;
mod error;
pub mod models;
mod retry;
mod upload;
pub use client::{InvoiceNinjaClient, DEFAULT_TIMEOUT, HOSTED_BASE_URL};
pub use error::{ApiErrorBody, Error, Result};
pub use retry::RetryPolicy;
pub use upload::FileUpload;
+122
View File
@@ -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>,
}
+39 -1
View File
@@ -104,7 +104,7 @@ impl ListParams {
/// not cover.
///
/// ```
/// use invoiceninjars::models::ListParams;
/// use invoiceninja_sdk::models::ListParams;
///
/// let params = ListParams::new()
/// .per_page(100)
@@ -154,6 +154,32 @@ where
}
}
/// 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
@@ -174,3 +200,15 @@ impl BulkRequest {
}
}
}
/// 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>,
}
+51
View File
@@ -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>,
}
+8 -1
View File
@@ -18,7 +18,14 @@ pub struct Location {
pub state: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub postal_code: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
/// 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,
+34
View File
@@ -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>,
}
+12 -2
View File
@@ -1,3 +1,4 @@
mod activity;
mod bank_integration;
mod bank_transaction;
mod bank_transaction_rule;
@@ -13,8 +14,11 @@ 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;
@@ -35,12 +39,15 @@ 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 client_gateway_token::ClientGatewayToken;
pub use common::{BulkRequest, ItemResponse, ListParams, ListResponse, Meta, Pagination};
pub use common::{
BulkRequest, ItemResponse, ListParams, ListResponse, MessageResponse, Meta, Pagination,
};
pub use company::Company;
pub use company_gateway::CompanyGateway;
pub use company_token::CompanyToken;
@@ -50,8 +57,11 @@ 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;
@@ -61,7 +71,7 @@ pub use recurring_expense::RecurringExpense;
pub use recurring_invoice::{RecurringInvoice, RecurringQuote};
pub use search::{SearchHit, SearchResults};
pub use statics::{Currency, DateFormat, StaticItem, Statics};
pub use subscription::Subscription;
pub use subscription::{Subscription, SubscriptionStep};
pub use system_log::SystemLog;
pub use tag::Tag;
pub use task::Task;
+31
View File
@@ -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>,
}
+2
View File
@@ -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)]
+8 -1
View File
@@ -23,7 +23,14 @@ pub struct PurchaseOrder {
pub po_number: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub quote_number: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
/// 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,
+1 -1
View File
@@ -28,7 +28,7 @@ pub struct RecurringExpense {
/// Integer enum matching [`RecurringInvoice::frequency_id`].
///
/// [`RecurringInvoice::frequency_id`]: super::RecurringInvoice::frequency_id
#[serde(default, deserialize_with = "super::common::null_to_default")]
#[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")]
+3 -2
View File
@@ -25,8 +25,9 @@ pub struct RecurringInvoice {
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub status_id: String,
/// How often the invoice recurs. Invoice Ninja returns this as an integer
/// enum (1 = daily, 2 = weekly, … ), not a human-readable string.
#[serde(default, deserialize_with = "super::common::null_to_default")]
/// 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")]
+13
View File
@@ -55,3 +55,16 @@ pub struct Subscription {
#[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>,
}
+5 -4
View File
@@ -3,8 +3,9 @@ use serde_json::{Map, Value};
/// A server-side log entry (gateway responses, mail failures, and so on).
///
/// Note that unlike every other resource, this one's `created_at` and
/// `updated_at` are returned as date **strings**, not integer timestamps.
/// 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")]
@@ -23,9 +24,9 @@ pub struct SystemLog {
#[serde(default)]
pub log: Value,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub created_at: String,
pub created_at: i64,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub updated_at: String,
pub updated_at: i64,
#[serde(flatten)]
pub extra: Map<String, Value>,
+7 -2
View File
@@ -13,8 +13,13 @@ pub struct Webhook {
/// The URL Invoice Ninja posts to.
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub target_url: String,
/// Payload format: `JSON` or `UBL`.
#[serde(default, deserialize_with = "super::common::null_to_default")]
/// 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)]
+70
View File
@@ -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()
}
}
+1885 -4
View File
File diff suppressed because it is too large Load Diff
+132
View File
@@ -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"
);
}
+29
View File
@@ -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": ""
}
]
}
+24
View File
@@ -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": ""
}
}
]
}
+114
View File
@@ -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": ""
}
]
}
+1059
View File
File diff suppressed because it is too large Load Diff
+139
View File
@@ -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": ""
}
]
}
+25
View File
@@ -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
}
]
}
+29
View File
@@ -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
View File
@@ -0,0 +1,14 @@
{
"data": [
{
"archived_at": 0,
"color": "",
"created_at": 0,
"id": "",
"is_deleted": false,
"name": "",
"updated_at": 0,
"user_id": ""
}
]
}
+55
View File
@@ -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": ""
}
]
}
+16
View File
@@ -0,0 +1,16 @@
{
"data": [
{
"archived_at": 0,
"created_at": 0,
"documents": [],
"id": "",
"is_deleted": false,
"name": "",
"settings": {
"entity": ""
},
"updated_at": 0
}
]
}
+37
View File
@@ -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
}
+140
View File
@@ -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": ""
}
]
}
+27
View File
@@ -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": ""
}
]
}
+13
View File
@@ -0,0 +1,13 @@
{
"data": [
{
"archived_at": 0,
"created_at": 0,
"id": "",
"is_deleted": false,
"name": "",
"num_days": 0,
"updated_at": 0
}
]
}
+52
View File
@@ -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": ""
}
]
}
+37
View File
@@ -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": ""
}
]
}
+30
View File
@@ -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": ""
}
]
}
+93
View File
@@ -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": ""
}
]
}
+123
View File
@@ -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
View File
@@ -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
View File
@@ -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": ""
}
]
}
+185
View File
@@ -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
View File
@@ -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": ""
}
}
+16
View File
@@ -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": ""
}
]
}
+14
View File
@@ -0,0 +1,14 @@
{
"data": [
{
"archived_at": 0,
"color": null,
"created_at": 0,
"entity_type": "",
"id": "",
"is_deleted": false,
"name": "",
"updated_at": 0
}
]
}
+14
View File
@@ -0,0 +1,14 @@
{
"data": [
{
"archived_at": 0,
"color": "",
"created_at": 0,
"id": "",
"is_deleted": false,
"name": "",
"status_order": 0,
"updated_at": 0
}
]
}
+36
View File
@@ -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": ""
}
]
}
+13
View File
@@ -0,0 +1,13 @@
{
"data": [
{
"archived_at": 0,
"created_at": 0,
"id": "",
"is_deleted": false,
"name": "",
"rate": 0,
"updated_at": 0
}
]
}
+15
View File
@@ -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": ""
}
]
}
+44
View File
@@ -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
}
]
}
+63
View File
@@ -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": ""
}
]
}
+18
View File
@@ -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": ""
}
]
}
+51 -11
View File
@@ -1,10 +1,10 @@
use invoiceninjars::models::{
use invoiceninja_sdk::models::{
BankIntegration, BankTransaction, BankTransactionRule, BulkRequest, Client, ClientGatewayToken,
Company, CompanyGateway, CompanyToken, Credit, Design, Document, Expense, ExpenseCategory,
GroupSetting, HealthCheck, Invoice, ListParams, ListResponse, Location, PaymentTerm, Project,
PurchaseOrder, Quote, RecurringExpense, RecurringInvoice, RecurringQuote, SearchResults,
Statics, Subscription, SystemLog, Tag, Task, TaskScheduler, TaskStatus, TaxRate, User, Vendor,
Webhook,
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;
@@ -584,23 +584,24 @@ fn company_keeps_settings_untyped_and_preserves_toggles() {
assert_eq!(round_tripped["settings"]["timezone_id"], "15");
}
/// System logs are the one resource whose timestamps are date strings
/// rather than integers.
/// 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_strings() {
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": "2026-10-01 12:00:00",
"updated_at": "2026-10-01 12:00:00",
"created_at": 1792869488,
"updated_at": 1792869489,
}))
.unwrap();
assert_eq!(log.event_id, 30);
assert_eq!(log.created_at, "2026-10-01 12:00:00");
assert_eq!(log.created_at, 1792869488);
assert_eq!(log.updated_at, 1792869489);
assert_eq!(log.log["message"], "gateway declined");
}
@@ -762,3 +763,42 @@ fn statics_ids_accept_both_strings_and_numbers() {
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);
}
+227
View File
@@ -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")
);
}