Compare commits

...
8 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
23 changed files with 2004 additions and 109 deletions
+52
View File
@@ -9,6 +9,50 @@ 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,
@@ -85,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
+42 -7
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:
@@ -272,6 +280,22 @@ changes, doc tweaks) does not need an entry.
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
@@ -337,20 +361,31 @@ changes, doc tweaks) does not need an entry.
## Coverage
**All 34 of the API's `Route::resource` groups are wrapped**, plus `users`
(which isn't one). 333 methods: 30 resources with CRUD + bulk, 3 with CRUD
(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 69 hand-written.
document uploads, and 111 hand-written.
Plus the non-CRUD surface: documents, entity actions and PDF downloads for
the four action-capable resources, their invitation-key PDF and e-document
downloads, `report`, `chart`, `search`, `statics`, `send_email`,
`refund_payment`, `ping`, and `health_check`.
`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 the
integration and auth routes, import/export, the client-portal routes (which
are not under `/api/v1`, so they don't fit the client's `base_url`), and a
few company and user extras.
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
+206 -13
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). 333 methods in total.
not a resource group). 375 methods in total.
## Resources with full CRUD + bulk
@@ -168,6 +168,118 @@ controllers return that directly rather than raising
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
@@ -322,6 +434,60 @@ 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
@@ -359,10 +525,8 @@ smoke test does not exercise them.
### Nothing is blocked on a dependency
Multipart uploads used to be, and are now wrapped — see [Document
uploads](#document-uploads). One upload route is deliberately left out:
`PUT /edocument/upload` is not a document attachment. It imports an expense
from an e-document (`ExpenseController::edocument`), and is better treated as
an import endpoint alongside the others under *Import/export* below.
uploads](#document-uploads) and [Import and
export](#import-and-export).
### Resource groups not wrapped
@@ -381,10 +545,13 @@ check `routes/api.php`).
- **User extras** (not password-protected, unlike the rest of `/users`) —
`POST /users/{id}/disconnect_mailer`, `POST /users/{id}/disconnect_oauth`,
`POST /user/{id}/reconfirm`
- **Auth** — `/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`
- **Client-portal invitation downloads** — the `routes/client.php` family:
@@ -394,14 +561,29 @@ check `routes/api.php`).
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.
- **Integrations** — Stripe, QuickBooks, Apple in-app purchase, PostMark,
einvoice (17 routes), calendar_connection, self-update, templates, hooks,
Yodlee (`/yodlee/*`), Nordigen (`/nordigen/institutions`)
- **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}`
@@ -414,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
Generated
+2 -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",
+6 -2
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,6 +10,10 @@ 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", "multipart"] }
+109 -18
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
@@ -137,9 +169,14 @@ 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.
The items ran roughly in order of value over effort:
**Done:** multipart document uploads, for the 16 resources that have the
route (`upload_client`, `upload_invoice`, …). This earlier claimed it would
@@ -184,28 +221,82 @@ 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.
Still to do:
**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.
6. **Import/export and the integrations** — `/import`, `/export`,
`edocument/upload`, Stripe, QuickBooks, einvoice (17 routes), Yodlee,
Nordigen, calendar. The largest block and the least SDK-shaped; worth
doing last and possibly in pieces.
**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.
Keep `COVERAGE.md` current as these land — it names exact counts, so it goes
stale quietly.
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 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
+8 -6
View File
@@ -1,4 +1,4 @@
# invoiceninjars
# invoiceninja-sdk
An async Rust client for the [Invoice Ninja](https://www.invoiceninja.com/) v5 API.
@@ -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 — 333 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`:
@@ -116,7 +116,9 @@ resource has `list`, `list_all`, `get`, `create`, `update`, `delete`, and
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, and health checks.
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.
@@ -164,7 +166,7 @@ only way the API creates one. Sixteen resources support it, as
`upload_client`, `upload_invoice`, `upload_expense`, and so on:
```rust
use invoiceninjars::FileUpload;
use invoiceninja_sdk::FileUpload;
let receipt = FileUpload::new("receipt.pdf", std::fs::read("receipt.pdf")?)
.mime("application/pdf");
+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");
+2 -2
View File
@@ -18,7 +18,7 @@
use std::future::Future;
use std::process::ExitCode;
use invoiceninjars::{Error, InvoiceNinjaClient};
use invoiceninja_sdk::{Error, InvoiceNinjaClient};
use serde::de::DeserializeOwned;
use serde_json::{json, Value};
@@ -34,7 +34,7 @@ fn describe(error: &Error) -> String {
}
/// Runs one creation, reporting the outcome and returning the new id.
async fn step<T>(name: &str, call: impl Future<Output = invoiceninjars::Result<T>>) -> Option<T> {
async fn step<T>(name: &str, call: impl Future<Output = invoiceninja_sdk::Result<T>>) -> Option<T> {
match call.await {
Ok(created) => {
println!(" created {name}");
+2 -2
View File
@@ -37,10 +37,10 @@ use std::fmt::Display;
use std::process::ExitCode;
use std::time::{SystemTime, UNIX_EPOCH};
use invoiceninjars::models::{
use invoiceninja_sdk::models::{
Client, ListParams, Location, PurchaseOrder, RecurringInvoice, Vendor, Webhook,
};
use invoiceninjars::{Error, FileUpload, InvoiceNinjaClient};
use invoiceninja_sdk::{Error, FileUpload, InvoiceNinjaClient};
use serde::Serialize;
use serde_json::{json, Value};
+29 -1
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";
@@ -43,6 +43,14 @@
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;
@@ -50,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.
};
});
}
+669 -37
View File
@@ -3,16 +3,18 @@ use std::time::Duration;
use percent_encoding::{utf8_percent_encode, AsciiSet, NON_ALPHANUMERIC};
use reqwest::{header, multipart, Method, StatusCode};
use serde::{de::DeserializeOwned, Serialize};
use serde_json::Value;
use crate::error::{ApiErrorBody, Error, Result};
use crate::models::{
Activity, ActivityFeedEntry, BankIntegration, BankTransaction, BankTransactionRule,
BulkRequest, Client as ClientModel, ClientGatewayToken, Company, CompanyGateway, CompanyToken,
Credit, Design, Document, Expense, ExpenseCategory, GroupSetting, HealthCheck, Invoice,
ItemResponse, ListParams, ListResponse, Location, MessageResponse, Payment, PaymentTerm,
Product, Project, PurchaseOrder, Quote, RecurringExpense, RecurringInvoice, RecurringQuote,
SearchResults, Statics, Subscription, SubscriptionStep, SystemLog, Tag, Task, TaskScheduler,
TaskStatus, TaxRate, User, Vendor, Webhook,
Credit, Design, Document, Expense, ExpenseCategory, ExportJob, GroupSetting, HealthCheck,
ImportPreview, Invoice, ItemResponse, ListParams, ListResponse, Location, LoginResponse,
MessageResponse, Passkey, Payment, PaymentTerm, Product, Project, PurchaseOrder, Quote,
RecurringExpense, RecurringInvoice, RecurringQuote, SearchResults, Statics, Subscription,
SubscriptionStep, SystemLog, Tag, Task, TaskScheduler, TaskStatus, TaxRate, User, Vendor,
Webhook,
};
use crate::retry::{self, RetryPolicy};
use crate::upload::FileUpload;
@@ -62,6 +64,32 @@ fn seg(value: &str) -> Result<impl std::fmt::Display + '_> {
Ok(utf8_percent_encode(value, PATH_SEGMENT))
}
/// Validates a base URL and strips its trailing slash.
///
/// Shared by [`InvoiceNinjaClient::new`] and
/// [`InvoiceNinjaClient::login`], the one other place a base URL arrives
/// from a caller rather than from an existing client. Parsing up front
/// means a malformed URL — most often a missing scheme, as in
/// `invoicing.co/api/v1` — fails here instead of as a confusing per-request
/// error.
fn validate_base_url(base_url: String) -> Result<String> {
let trimmed = base_url.trim();
if trimmed.is_empty() {
return Err(Error::InvalidBaseUrl(base_url));
}
let parsed = reqwest::Url::parse(trimmed)
.map_err(|e| Error::InvalidBaseUrl(format!("{trimmed}: {e}")))?;
if !matches!(parsed.scheme(), "http" | "https") {
return Err(Error::InvalidBaseUrl(format!(
"{trimmed}: expected an http or https url, got scheme `{}`",
parsed.scheme()
)));
}
Ok(trimmed.trim_end_matches('/').to_string())
}
/// Standard base64 with padding. Only used for the one header that needs it,
/// which is not worth a dependency.
fn base64(bytes: &[u8]) -> String {
@@ -134,25 +162,7 @@ pub struct InvoiceNinjaClient {
impl InvoiceNinjaClient {
pub fn new(base_url: impl Into<String>, api_token: impl Into<String>) -> Result<Self> {
let base_url = base_url.into();
let trimmed = base_url.trim();
if trimmed.is_empty() {
return Err(Error::InvalidBaseUrl(base_url));
}
// Parse up front rather than letting a malformed base URL surface as a
// confusing per-request failure. A missing scheme is the common typo
// (`invoicing.co/api/v1`), and it fails here instead of on every call.
let parsed = reqwest::Url::parse(trimmed)
.map_err(|e| Error::InvalidBaseUrl(format!("{trimmed}: {e}")))?;
if !matches!(parsed.scheme(), "http" | "https") {
return Err(Error::InvalidBaseUrl(format!(
"{trimmed}: expected an http or https url, got scheme `{}`",
parsed.scheme()
)));
}
let base_url = trimmed.trim_end_matches('/').to_string();
let base_url = validate_base_url(base_url.into())?;
let mut headers = header::HeaderMap::new();
let mut token_value = header::HeaderValue::from_str(&api_token.into())
@@ -218,8 +228,8 @@ impl InvoiceNinjaClient {
/// It used to be refused locally with a misleading `InvalidToken`.
///
/// ```no_run
/// # fn main() -> invoiceninjars::Result<()> {
/// use invoiceninjars::InvoiceNinjaClient;
/// # fn main() -> invoiceninja_sdk::Result<()> {
/// use invoiceninja_sdk::InvoiceNinjaClient;
///
/// let client = InvoiceNinjaClient::hosted(std::env::var("IN_API_TOKEN").unwrap())?
/// .with_api_password(std::env::var("IN_API_PASSWORD").unwrap())?;
@@ -337,6 +347,76 @@ impl InvoiceNinjaClient {
Self::new(HOSTED_BASE_URL, api_token)
}
/// Exchanges an email and password for an API token, for the one case
/// `new` cannot cover: there is no token yet to build a client with.
///
/// `POST /login` sits outside the `token_auth` middleware every other
/// wrapped endpoint uses, so this is a standalone async function rather
/// than a method — nothing in `self` could be used to make the request.
/// It builds its own one-shot `reqwest::Client` and does not go through
/// `req` or `execute`, and, like every other `POST` in this crate, is
/// not retried on a 5xx or a timeout.
///
/// On success the server answers with one entry per company the account
/// has access to, each carrying that company's token
/// ([`LoginCompanyUser::token`](crate::models::LoginCompanyUser::token))
/// — feed one into [`new`](Self::new) (or [`hosted`](Self::hosted)) to
/// get a working client:
///
/// ```no_run
/// # async fn run() -> invoiceninja_sdk::Result<()> {
/// use invoiceninja_sdk::InvoiceNinjaClient;
///
/// let login = InvoiceNinjaClient::login(
/// "https://invoicing.co/api/v1",
/// "me@example.com",
/// "correct-horse-battery-staple",
/// None,
/// )
/// .await?;
/// let token = &login.data[0].token.as_ref().expect("token include").token;
/// let client = InvoiceNinjaClient::hosted(token)?;
/// # let _ = client;
/// # Ok(())
/// # }
/// ```
///
/// `one_time_password` is only consulted when the account has
/// two-factor authentication enabled; pass `None` otherwise. Passkey
/// login, SSO (`oauth_login`, `oidc/*`), and a self-hosted instance's
/// optional `X-API-SECRET` are not handled — each needs a ceremony or a
/// header this function does not send, and none fits a single
/// email/password call.
pub async fn login(
base_url: &str,
email: &str,
password: &str,
one_time_password: Option<&str>,
) -> Result<LoginResponse> {
let base_url = validate_base_url(base_url.to_string())?;
let mut body = serde_json::json!({ "email": email, "password": password });
if let Some(otp) = one_time_password {
body["one_time_password"] = serde_json::Value::String(otp.to_string());
}
let http = reqwest::Client::builder()
.connect_timeout(CONNECT_TIMEOUT)
.build()?;
let response = http
.post(format!("{base_url}/login"))
.timeout(DEFAULT_TIMEOUT)
.header(
header::ACCEPT,
header::HeaderValue::from_static("application/json"),
)
.header("X-Requested-With", "XMLHttpRequest")
.json(&body)
.send()
.await?;
Self::decode(response).await
}
fn url(&self, path: &str) -> String {
format!("{}/{}", self.base_url, path.trim_start_matches('/'))
}
@@ -629,6 +709,80 @@ impl InvoiceNinjaClient {
Ok(body.data)
}
/// `post_unwrapped` for the other body-carrying verbs.
///
/// The e-invoice routes use all three: `PUT einvoice/peppol/update` and
/// `DELETE einvoice/peppol/remove_additional_legal_identifier` both take
/// a payload, and a `DELETE` with a body is unusual enough to be worth
/// naming rather than hiding behind a generic request builder.
async fn send_unwrapped<B: Serialize, T: DeserializeOwned>(
&self,
method: Method,
path: &str,
payload: &B,
) -> Result<T> {
let response = self.execute(self.req(method, path).json(payload)).await?;
Self::decode(response).await
}
/// `post_unwrapped` for a route that answers either a JSON body or a
/// `204 No Content`.
///
/// `einvoice/peppol/setup` does exactly that: it returns a
/// `corppass_url` when one is needed to finish the connection, and an
/// empty 204 when the setup completed outright. Decoding the 204 into
/// `T` would fail on an empty body, so the absence is returned as `None`
/// instead of being an error — the two outcomes are both successes and
/// the caller has to branch on them anyway.
async fn post_unwrapped_opt<B: Serialize, T: DeserializeOwned>(
&self,
path: &str,
payload: &B,
) -> Result<Option<T>> {
let response = self
.execute(self.req(Method::POST, path).json(payload))
.await?;
let status = response.status();
let bytes = response.bytes().await?;
if !status.is_success() {
return Err(Self::api_error(status, &bytes));
}
if bytes.is_empty() {
return Ok(None);
}
serde_json::from_slice(&bytes)
.map(Some)
.map_err(|source| Error::Json {
source,
excerpt: excerpt(&bytes),
})
}
/// Sends a caller-built multipart form and decodes a body with no `data`
/// wrapper.
///
/// [`upload_files`](Self::upload_files) covers the 16 `/{id}/upload`
/// routes, which all take `documents[]` and answer with an envelope. The
/// import and e-document routes share none of that: the field name is
/// `files`, keyed by entity type for the CSV importers, and the reply is
/// a bare `{"message": …}`. So the form is built at the call site, where
/// the route's own shape is visible, and only the dispatch is shared.
///
/// Always a `POST` on the wire. A route registered as `PUT` needs a
/// `_method=PUT` text field instead, for the reason spelled out on
/// `upload_files`. Like every multipart request, these are never retried:
/// the body is a stream, so `execute` gives it exactly one attempt.
async fn multipart_unwrapped<T: DeserializeOwned>(
&self,
path: &str,
form: multipart::Form,
) -> Result<T> {
let response = self
.execute(self.req(Method::POST, path).multipart(form))
.await?;
Self::decode(response).await
}
/// Issues a request whose body is discarded, failing on non-2xx.
async fn get_discard(&self, path: &str) -> Result<()> {
self.get_raw(path).await.map(|_| ())
@@ -1242,7 +1396,7 @@ impl InvoiceNinjaClient {
/// eleven charts share no common response shape — `totals_v2` answers
/// with per-currency maps, `cashflow_forecast` with a date series,
/// `calculated_fields` with scalars — so there is nothing to narrow to.
/// Pass `serde_json::Value`, or your own struct for the chart you are
/// Pass `Value`, or your own struct for the chart you are
/// calling.
///
/// `project_analytics` and `project_burnup` take a project id as a
@@ -1292,8 +1446,8 @@ impl InvoiceNinjaClient {
/// for — [`Invoice`], [`Quote`], [`Credit`], or [`PurchaseOrder`]:
///
/// ```no_run
/// # async fn run(client: &invoiceninjars::InvoiceNinjaClient) -> invoiceninjars::Result<()> {
/// use invoiceninjars::models::Invoice;
/// # async fn run(client: &invoiceninja_sdk::InvoiceNinjaClient) -> invoiceninja_sdk::Result<()> {
/// use invoiceninja_sdk::models::Invoice;
/// use serde_json::json;
///
/// let invoice: Invoice = client
@@ -1694,7 +1848,7 @@ impl InvoiceNinjaClient {
/// The client's effective settings, each tagged with the level it came
/// from. Left as raw JSON: the map's keys are the settings themselves.
pub async fn client_show_settings(&self, id: &str) -> Result<serde_json::Value> {
pub async fn client_show_settings(&self, id: &str) -> Result<Value> {
self.post_unwrapped(
&format!("clients/{}/show_settings", seg(id)?),
&serde_json::json!({}),
@@ -1704,7 +1858,7 @@ impl InvoiceNinjaClient {
/// Looks the client up on the Peppol network. Left as raw JSON: the
/// controller returns whatever the discovery service produced.
pub async fn client_peppol_discovery(&self, id: &str) -> Result<serde_json::Value> {
pub async fn client_peppol_discovery(&self, id: &str) -> Result<Value> {
self.post_unwrapped(
&format!("clients/{}/peppol_discovery", seg(id)?),
&serde_json::json!({}),
@@ -1834,6 +1988,41 @@ impl InvoiceNinjaClient {
}
}
/// Logout and passkey management. Unlike the credential exchange in
/// [`login`](Self::login), these sit behind the normal `token_auth`
/// middleware — the same one every other wrapped endpoint uses — so they
/// are plain methods here rather than a standalone function.
impl InvoiceNinjaClient {
/// Regenerates every system-issued API token on the **whole company**,
/// not just the one this client is using.
///
/// `LogoutController::index` looks up the company behind the caller's
/// token and replaces every `is_system` token belonging to it with a
/// fresh random value — one call logs out every user and every device
/// sharing that company. The token this client was built with is among
/// them, so a subsequent call through it will fail; building a new
/// client with a fresh token is the only way back in.
pub async fn logout(&self) -> Result<MessageResponse> {
self.post_unwrapped("logout", &serde_json::json!({})).await
}
/// Lists the WebAuthn passkeys registered to the current user.
pub async fn list_passkeys(&self) -> Result<Vec<Passkey>> {
self.get_list("settings/passkeys", &ListParams::default())
.await
}
/// Deletes a passkey credential. The server refuses with a 403 if
/// `id` belongs to a different user than the one the token
/// authenticates.
///
/// Registering a new passkey is not wrapped — see [`Passkey`] for why.
pub async fn delete_passkey(&self, id: &str) -> Result<()> {
self.delete_one(&format!("settings/passkeys/{}", seg(id)?))
.await
}
}
/// Activity log, email history, and document previews. Each has a response
/// shape of its own (read from the upstream controllers, and for the activity
/// routes confirmed against a 5.13.50 server), so none is macro-generated.
@@ -1918,7 +2107,7 @@ impl InvoiceNinjaClient {
/// message that has delivery events. Left as raw JSON, since the objects
/// mirror whatever the mail provider reported and no instance available
/// here had sent mail to take a shape from.
pub async fn client_email_history(&self, client_id: &str) -> Result<Vec<serde_json::Value>> {
pub async fn client_email_history(&self, client_id: &str) -> Result<Vec<Value>> {
self.post_unwrapped(
&format!("emails/clientHistory/{}", seg(client_id)?),
&serde_json::json!({}),
@@ -1929,11 +2118,7 @@ impl InvoiceNinjaClient {
/// Email delivery history for one record. `entity` is `invoice`, `quote`,
/// `credit`, `recurring_invoice`, or `purchase_order`. Raw JSON, as for
/// [`client_email_history`](Self::client_email_history).
pub async fn entity_email_history(
&self,
entity: &str,
entity_id: &str,
) -> Result<Vec<serde_json::Value>> {
pub async fn entity_email_history(&self, entity: &str, entity_id: &str) -> Result<Vec<Value>> {
self.post_unwrapped(
"emails/entityHistory",
&serde_json::json!({ "entity": entity, "entity_id": entity_id }),
@@ -1981,3 +2166,450 @@ impl InvoiceNinjaClient {
self.post_raw("live_preview/purchase_order", body).await
}
}
/// Import, export, and the third-party integrations.
///
/// These are the least SDK-shaped part of the API, and they are typed
/// accordingly. Most return `Value` rather than a model: their
/// replies are pass-throughs from an external service (Storecove, Stripe,
/// Yodlee, GoCardless) or ad-hoc arrays assembled for Invoice Ninja's own
/// React client, so they have no stable documented schema. The convention
/// matches `client_peppol_discovery`, which has returned `Value` since it
/// landed. Where a shape *is* stable and is the point of the call, it is
/// typed: [`ExportJob`] and [`ImportPreview`].
///
/// Every write here is a `POST`, `PUT`, or `DELETE`, so none is retried on a
/// 5xx. Several queue background work and answer `{"message": "Processing"}`
/// before anything has happened, so a success here means accepted, not done.
impl InvoiceNinjaClient {
/// Queues a full company export and returns the link to collect it from.
///
/// The work happens in a queued job, so this returns as soon as it is
/// accepted. See [`ExportJob`] for the expiry on the URL.
///
/// The body selects the format and date range (`send_email`, `report_keys`,
/// `start_date`, `end_date`); an empty object exports everything.
pub async fn export<B: Serialize>(&self, body: &B) -> Result<ExportJob> {
self.post_unwrapped("export", body).await
}
/// Uploads CSVs and returns the column mapping the server suggests —
/// the first half of a CSV import.
///
/// Each entry pairs an entity type (`client`, `invoice`, `product`,
/// `payment`, `expense`, `vendor`, …) with that entity's CSV, and goes
/// out as `files[<entity_type>]`. Take the `hash` off the result and pass
/// it to [`import`](Self::import) to start the ingest.
///
/// The server caches the uploaded files against that hash for 20 minutes,
/// so the second call has to follow reasonably promptly.
pub async fn preimport(&self, files: Vec<(String, FileUpload)>) -> Result<ImportPreview> {
if files.is_empty() {
return Err(Error::InvalidUpload("no files to import".into()));
}
let mut form = multipart::Form::new();
for (entity_type, file) in files {
form = form.part(format!("files[{entity_type}]"), file.into_part()?);
}
self.multipart_unwrapped("preimport", form).await
}
/// Starts a CSV import from a [`preimport`](Self::preimport) hash.
///
/// The body carries the `hash` plus the column mapping to use — the
/// shape `preimport` described. With a hash present the server never
/// looks at uploaded files, so this goes out as ordinary JSON.
///
/// Only this two-phase form is wrapped. The route also accepts the files
/// and the mapping together in one multipart request, but 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.
/// Call `preimport` first instead.
///
/// The ingest is queued, so a success means accepted. Rows that fail to
/// import are reported by email, not here.
pub async fn import<B: Serialize>(&self, body: &B) -> Result<MessageResponse> {
self.post_unwrapped("import", body).await
}
/// Restores a company from a `.zip` backup produced by
/// [`export`](Self::export).
///
/// One file, sent as `files`. The reply's `success` flag and any
/// `metadata` the server attaches arrive in
/// [`MessageResponse::extra`](crate::models::MessageResponse).
///
/// **This overwrites the target company.** The route exists for
/// migrations, not for merging two datasets.
pub async fn import_json(&self, file: FileUpload) -> Result<MessageResponse> {
let form = multipart::Form::new().part("files", file.into_part()?);
self.multipart_unwrapped("import_json", form).await
}
/// Imports expenses from e-documents (an XML invoice, or a PDF with one
/// embedded).
///
/// Not a document attachment, which is why it is not one of the
/// `upload_*` methods: the files are parsed and become *expenses*, and
/// nothing is attached to the record you might expect. The route is
/// registered as `PUT`, so this goes out as a `POST` with `_method=PUT`
/// for the usual multipart reason.
///
/// Parsing is queued per file, so the reply says `Processing....` and a
/// file that fails to parse is not reported here.
pub async fn upload_edocument(&self, files: Vec<FileUpload>) -> Result<MessageResponse> {
if files.is_empty() {
return Err(Error::InvalidUpload("no files to upload".into()));
}
let mut form = multipart::Form::new().text("_method", "PUT");
for file in files {
form = form.part("documents[]", file.into_part()?);
}
self.multipart_unwrapped("edocument/upload", form).await
}
}
/// The e-invoicing surface: Peppol, Verifactu, and the token and quota the
/// hosted service meters them with.
///
/// Almost everything here is a pass-through to Storecove, Invoice Ninja's
/// Peppol access point, so the replies are whatever that service said and
/// are returned as `Value`. Two upstream habits shape these
/// signatures:
///
/// - **A failing check is a non-2xx.** `validate_einvoice` answers `422`
/// when validation finds problems, and the Peppol routes pass Storecove's
/// status straight through. So an ordinary "this invoice is not
/// Peppol-ready" result arrives as [`Error::Api`](crate::Error::Api), not
/// as an `Ok` carrying failures.
/// - **Error bodies are not the usual shape.** The Peppol handlers return
/// `data_get($response, 'message')` as the whole body, which may be a bare
/// string or an object rather than `{"message": …, "errors": {…}}`. When
/// it is, `Error::Api`'s `message` falls back to an excerpt of the body
/// and `errors` is empty.
impl InvoiceNinjaClient {
/// Checks one entity against the company's e-invoice rules.
///
/// The body names what to check: `{"entity": "invoices", "entity_id":
/// "…"}`, where `entity` is one of `invoices`, `recurring_invoices`,
/// `credits`, `clients`, or `companies`.
///
/// **A failed check is a `422`**, so it comes back as `Error::Api` and
/// the per-entity detail in the body is not reachable through the typed
/// error. An `Ok` means the entity passed. A company whose
/// `e_invoice_type` is neither `VERIFACTU` nor `PEPPOL` always passes,
/// with every list empty.
pub async fn validate_einvoice<B: Serialize>(&self, body: &B) -> Result<Value> {
self.post_unwrapped("einvoice/validateEntity", body).await
}
/// Updates the company's e-invoice payment means and other Peppol
/// configuration.
pub async fn einvoice_configurations<B: Serialize>(&self, body: &B) -> Result<Value> {
self.post_unwrapped("einvoice/configurations", body).await
}
/// Returns the account's remaining e-invoice quota as `{"quota": n}`.
///
/// Hosted only in practice: it asks the hosted Invoice Ninja service,
/// authenticated with the account's e-invoicing token, and refreshes the
/// local count from the answer. Without a token the server answers
/// `422`.
pub async fn einvoice_quota(&self) -> Result<Value> {
self.get_unwrapped("einvoice/quota").await
}
/// Checks that the e-invoicing service is reachable and the account's
/// token is good.
///
/// The body is an empty object on success, so there is nothing to
/// return; a missing token or an unreachable service is a `422` or
/// `400`.
pub async fn einvoice_health_check(&self) -> Result<()> {
self.get_discard("einvoice/health_check").await
}
/// Returns the static Peppol delivery map: per country, which
/// classifications are routable and which client identifiers each
/// requires.
///
/// Constant per release, so it is a reasonable thing to cache.
pub async fn einvoice_delivery_map(&self) -> Result<Value> {
self.get_unwrapped("einvoice/delivery_map").await
}
/// Rotates the account's e-invoicing token.
///
/// Answers `204` on success, so there is nothing to decode. The new
/// token is stored server-side against the account and is never sent
/// back.
pub async fn update_einvoice_token<B: Serialize>(&self, body: &B) -> Result<()> {
self.post_discard("einvoice/token/update", body).await
}
/// Looks up whether a Peppol participant is reachable, as
/// `{"discovered": bool}`.
///
/// This is the company-level lookup, which takes the identifier in the
/// body. [`client_peppol_discovery`](Self::client_peppol_discovery) is
/// the per-client route and takes a client id instead.
pub async fn peppol_discovery<B: Serialize>(&self, body: &B) -> Result<Value> {
self.post_unwrapped("einvoice/peppol/discovery", body).await
}
/// Fetches the company's registered Peppol legal entity.
///
/// A `POST` despite being a read — that is how the route is registered.
pub async fn peppol_legal_entity<B: Serialize>(&self, body: &B) -> Result<Value> {
self.post_unwrapped("einvoice/peppol/legal_entity", body)
.await
}
/// Registers the company as a Peppol participant.
///
/// Returns `Some({"corppass_url": …})` when the connection needs a
/// further authorisation step the caller has to send the user through
/// (Singapore's CorpPass), and `None` when the setup completed on its
/// own — upstream answers `204` in that case. Both are successes.
pub async fn peppol_setup<B: Serialize>(&self, body: &B) -> Result<Option<Value>> {
self.post_unwrapped_opt("einvoice/peppol/setup", body).await
}
/// Deregisters the company from Peppol. Answers an empty array.
pub async fn peppol_disconnect<B: Serialize>(&self, body: &B) -> Result<Value> {
self.post_unwrapped("einvoice/peppol/disconnect", body)
.await
}
/// Updates the registered Peppol legal entity. Answers an empty array.
pub async fn peppol_update_legal_entity<B: Serialize>(&self, body: &B) -> Result<Value> {
self.send_unwrapped(Method::PUT, "einvoice/peppol/update", body)
.await
}
/// Adds an additional tax identifier to the Peppol registration.
///
/// An identifier that already exists for the region is a `400` rather
/// than a no-op.
pub async fn peppol_add_legal_identifier<B: Serialize>(
&self,
body: &B,
) -> Result<MessageResponse> {
self.post_unwrapped("einvoice/peppol/add_additional_legal_identifier", body)
.await
}
/// Removes an additional tax identifier from the Peppol registration.
///
/// A `DELETE` that carries a body, which is why it does not look like
/// the other `delete_*` methods. Answers an empty array.
pub async fn peppol_remove_legal_identifier<B: Serialize>(&self, body: &B) -> Result<Value> {
self.send_unwrapped(
Method::DELETE,
"einvoice/peppol/remove_additional_legal_identifier",
body,
)
.await
}
/// Retries sending a document over Peppol.
///
/// Queued, so the reply is `{"message": "trying...."}` and says nothing
/// about whether the send eventually succeeded.
pub async fn peppol_retry_send<B: Serialize>(&self, body: &B) -> Result<MessageResponse> {
self.post_unwrapped("einvoice/peppol/send", body).await
}
/// Activates Singapore's C5 (InvoiceNow) peer.
pub async fn peppol_sg_c5_activate<B: Serialize>(&self, body: &B) -> Result<MessageResponse> {
self.post_unwrapped("einvoice/peppol/sg/c5/activate", body)
.await
}
/// Deactivates Singapore's C5 peer.
pub async fn peppol_sg_c5_deactivate<B: Serialize>(&self, body: &B) -> Result<MessageResponse> {
self.post_unwrapped("einvoice/peppol/sg/c5/deactivate", body)
.await
}
/// Cancels a pending Singapore C5 registration.
pub async fn peppol_sg_c5_cancel<B: Serialize>(&self, body: &B) -> Result<MessageResponse> {
self.post_unwrapped("einvoice/peppol/sg/c5/cancel", body)
.await
}
}
/// The third-party integrations: QuickBooks, Stripe Connect, calendar sync,
/// and the two bank-feed providers.
///
/// Replies are `Value` for the same reason as the e-invoice
/// block — they are pass-throughs from the provider's own SDK, or arrays
/// assembled for Invoice Ninja's React client. Several answer `204`, so
/// their methods return `()`.
///
/// The OAuth halves of these flows are **not** wrapped and cannot be: the
/// authorize and callback routes live outside the `api/v1` group, take no
/// API token, and answer with browser redirects. Connecting an account is a
/// thing a user does in a browser; these methods operate a connection that
/// already exists.
impl InvoiceNinjaClient {
/// Starts a QuickBooks sync. Answers `204`; the work is queued.
pub async fn quickbooks_sync<B: Serialize>(&self, body: &B) -> Result<()> {
self.post_discard("quickbooks/sync", body).await
}
/// Runs a single QuickBooks action. Answers `204`.
pub async fn quickbooks_action<B: Serialize>(&self, body: &B) -> Result<()> {
self.post_discard("quickbooks/action", body).await
}
/// Disconnects the QuickBooks integration. Answers `204`.
pub async fn quickbooks_disconnect<B: Serialize>(&self, body: &B) -> Result<()> {
self.post_discard("quickbooks/disconnect", body).await
}
/// Mints a one-time URL for re-authorising an expired QuickBooks
/// connection.
///
/// Answers `reconnect_url`, `requires_reconnect`, and
/// `refresh_token_expires_at`. The URL embeds a token good for 30
/// minutes and has to be opened in a browser. With no connection
/// configured the server answers `400`.
pub async fn quickbooks_reconnect_url(&self) -> Result<Value> {
self.post_unwrapped("quickbooks/reconnect_url", &serde_json::json!({}))
.await
}
/// Queues a refresh of every stored Stripe payment method.
///
/// Password-protected: needs
/// [`with_api_password`](Self::with_api_password), and answers `403` for
/// a non-admin.
pub async fn stripe_update_payment_methods(&self) -> Result<MessageResponse> {
self.post_unwrapped("stripe/update_payment_methods", &serde_json::json!({}))
.await
}
/// Queues an import of existing Stripe customers into clients.
///
/// Password-protected. Matching is by email, so running it against an
/// instance that already has those clients can create duplicates.
pub async fn stripe_import_customers(&self) -> Result<MessageResponse> {
self.post_unwrapped("stripe/import_customers", &serde_json::json!({}))
.await
}
/// Verifies the Stripe Connect connection.
///
/// Password-protected. The body is whatever the gateway driver's
/// `verifyConnect` returned, which is not a documented shape.
pub async fn stripe_verify(&self) -> Result<Value> {
self.post_unwrapped("stripe/verify", &serde_json::json!({}))
.await
}
/// Disconnects a Stripe Connect account from one company gateway.
///
/// Password-protected. The reply comes from the driver's `disconnect`.
pub async fn stripe_disconnect(&self, company_gateway_id: &str) -> Result<Value> {
self.post_unwrapped(
&format!("stripe/disconnect/{}", seg(company_gateway_id)?),
&serde_json::json!({}),
)
.await
}
/// Returns the company's calendar connection, or a null one if there is
/// none.
pub async fn calendar_connection(&self) -> Result<Value> {
self.get_one("calendar_connection").await
}
/// Finishes a calendar connection the user has authorised in a browser.
///
/// `provider` is the OAuth provider that was authorised. The redirect
/// and callback legs are not wrappable — see the note on this `impl`
/// block — so this is the first step an API caller can take, using
/// whatever the callback handed back.
pub async fn complete_calendar_connection<B: Serialize>(
&self,
provider: &str,
body: &B,
) -> Result<Value> {
self.post_one(
&format!("calendar_connection/{}/complete", seg(provider)?),
body,
)
.await
}
/// Lists the calendars the connected account exposes, as
/// `{"calendars": …}`.
pub async fn calendar_connection_calendars(&self) -> Result<Value> {
self.get_one("calendar_connection/calendars").await
}
/// Lists events from the selected calendars between two dates, as
/// `{"events": …}`.
///
/// `from` and `to` are required by the server and are dates, not
/// timestamps.
pub async fn calendar_connection_events(&self, from: &str, to: &str) -> Result<Value> {
// Encoded with the path-segment set rather than through `seg`: these
// are query values, not segments, so `seg`'s refusal of empty and
// dot-only input would be checking the wrong thing. The set still
// escapes `&`, `=` and `?`, which is what matters here.
self.get_one(&format!(
"calendar_connection/events?from={}&to={}",
utf8_percent_encode(from, PATH_SEGMENT),
utf8_percent_encode(to, PATH_SEGMENT)
))
.await
}
/// Chooses which of the connected account's calendars to sync.
///
/// The body is `{"calendar_ids": [...]}`. It replaces the selection
/// rather than adding to it, so an empty list syncs nothing.
pub async fn update_calendar_connection_calendars<B: Serialize>(
&self,
body: &B,
) -> Result<Value> {
self.put_one("calendar_connection/calendars", body).await
}
/// Disconnects the calendar connection.
///
/// Answers `{"data": {"calendar_connection": null}}`, which carries no
/// information beyond the success, so it is discarded.
pub async fn delete_calendar_connection(&self) -> Result<()> {
self.delete_one("calendar_connection").await
}
/// Returns the Yodlee account summary for one bank account number.
///
/// The number is Yodlee's `account_id`, as stored on the matching
/// [`BankIntegration`](crate::models::BankIntegration) — not an Invoice
/// Ninja id. An account the company does not hold is a `400`.
///
/// A `POST` despite being a read; the upstream route carries a `@todo`
/// questioning its own path, so treat it as liable to move.
pub async fn yodlee_account_status(&self, account_number: &str) -> Result<Value> {
self.post_unwrapped(
&format!("yodlee/status/{}", seg(account_number)?),
&serde_json::json!({}),
)
.await
}
/// Lists the GoCardless (formerly Nordigen) institutions available for
/// bank feeds.
///
/// Needs the instance to be configured with GoCardless credentials;
/// without them the server answers `400` rather than an empty list.
pub async fn nordigen_institutions(&self) -> Result<Value> {
self.get_unwrapped("nordigen/institutions").await
}
}
+2 -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(),
+1 -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)
+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>,
}
+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>,
}
+6
View File
@@ -14,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;
@@ -54,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;
+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>,
}
+1 -1
View File
@@ -11,7 +11,7 @@ use crate::error::{Error, Result};
/// question in `GOALS.md`.
///
/// ```
/// use invoiceninjars::FileUpload;
/// use invoiceninja_sdk::FileUpload;
///
/// let file = FileUpload::new("receipt.pdf", b"%PDF-1.4 ...".to_vec())
/// .mime("application/pdf");
+697 -5
View File
@@ -8,7 +8,7 @@
use std::time::Duration;
use invoiceninjars::{models::ListParams, Error, InvoiceNinjaClient};
use invoiceninja_sdk::{models::ListParams, Error, InvoiceNinjaClient};
use serde_json::json;
use wiremock::matchers::{header, method, path, query_param};
use wiremock::{Mock, MockServer, ResponseTemplate};
@@ -547,7 +547,7 @@ async fn ping_accepts_an_empty_success_body() {
// --- retry and backoff --------------------------------------------------
use invoiceninjars::RetryPolicy;
use invoiceninja_sdk::RetryPolicy;
/// Fast policy so the tests do not actually sleep for seconds.
fn quick_retry() -> RetryPolicy {
@@ -751,7 +751,7 @@ async fn retried_requests_keep_their_body_and_headers() {
.mount(&server)
.await;
let body = invoiceninjars::models::Client {
let body = invoiceninja_sdk::models::Client {
name: "Acme".into(),
..Default::default()
};
@@ -965,7 +965,7 @@ async fn send_email_unwraps_the_data_envelope() {
.mount(&server)
.await;
let invoice: invoiceninjars::models::Invoice = client(&server)
let invoice: invoiceninja_sdk::models::Invoice = client(&server)
.send_email(&json!({ "entity": "invoice", "entity_id": "inv1" }))
.await
.expect("should return the refreshed entity");
@@ -1179,7 +1179,7 @@ async fn no_budget_means_only_max_retries_bounds_the_call() {
// --- document uploads ---------------------------------------------------
use invoiceninjars::FileUpload;
use invoiceninja_sdk::FileUpload;
fn pdf(name: &str) -> FileUpload {
FileUpload::new(name, b"%PDF-1.4 fake".to_vec()).mime("application/pdf")
@@ -2365,3 +2365,695 @@ async fn an_empty_invitation_key_is_refused_before_the_request() {
assert!(matches!(err, Error::InvalidPathSegment(_)), "got {err:?}");
assert!(server.received_requests().await.unwrap().is_empty());
}
// --- import and export --------------------------------------------------
#[tokio::test]
async fn export_posts_and_returns_the_collection_url() {
let server = route_server(
"POST",
"/export",
json!({ "message": "Processing", "url": "https://x.test/protected_download/abc" }),
)
.await;
let job = client(&server)
.export(&json!({ "send_email": false }))
.await
.expect("export should be accepted");
assert_eq!(job.message, "Processing");
assert_eq!(job.url, "https://x.test/protected_download/abc");
}
#[tokio::test]
async fn preimport_sends_each_csv_under_its_entity_type() {
let server = MockServer::start().await;
Mock::given(path("/preimport"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"hash": "h1",
"mappings": {
"client": { "available": { "name": "Name" }, "headers": [["name"]], "hints": [0] }
}
})))
.mount(&server)
.await;
let preview = client(&server)
.preimport(vec![
("client".to_string(), pdf("clients.csv")),
("invoice".to_string(), pdf("invoices.csv")),
])
.await
.expect("preimport should succeed");
assert_eq!(preview.hash, "h1");
assert!(preview.mappings.contains_key("client"));
let sent = &server.received_requests().await.unwrap()[0];
assert_eq!(sent.method.as_str(), "POST");
let body = body_text(sent);
// Keyed by entity type, not `documents[]` — a different field name from
// the `/{id}/upload` routes.
assert!(
body.contains("name=\"files[client]\"; filename=\"clients.csv\""),
"body: {body}"
);
assert!(
body.contains("name=\"files[invoice]\"; filename=\"invoices.csv\""),
"body: {body}"
);
assert!(!body.contains("_method"), "preimport is a real POST");
}
#[tokio::test]
async fn preimport_with_no_files_is_refused_before_the_request() {
let server = MockServer::start().await;
Mock::given(path("/preimport"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({ "hash": "h1" })))
.mount(&server)
.await;
let err = client(&server)
.preimport(vec![])
.await
.expect_err("an empty import must not be sent");
assert!(matches!(err, Error::InvalidUpload(_)), "got {err:?}");
assert!(server.received_requests().await.unwrap().is_empty());
}
/// The second phase is ordinary JSON, not multipart: the hash stands in for
/// the files, so there is nothing to encode as a form.
#[tokio::test]
async fn import_sends_json_rather_than_a_form() {
let server = route_server("POST", "/import", json!({ "message": "Import started" })).await;
let got = client(&server)
.import(&json!({ "hash": "h1", "column_map": { "client": { "mapping": {} } } }))
.await
.expect("import should be accepted");
assert_eq!(got.message, "Import started");
let sent = &server.received_requests().await.unwrap()[0];
let content_type = sent.headers.get("content-type").unwrap().to_str().unwrap();
assert_eq!(content_type, "application/json");
assert_eq!(
sent.body_json::<serde_json::Value>().unwrap()["hash"],
"h1",
"the hash is what stands in for the upload"
);
}
#[tokio::test]
async fn import_json_sends_one_file_named_files() {
let server = route_server(
"POST",
"/import_json",
json!({ "message": "Processing", "success": true }),
)
.await;
let got = client(&server)
.import_json(pdf("backup.zip"))
.await
.expect("import_json should be accepted");
assert_eq!(got.message, "Processing");
assert_eq!(
got.extra["success"], true,
"the success flag is kept in extra"
);
let body = body_text(&server.received_requests().await.unwrap()[0]);
assert!(
body.contains("name=\"files\"; filename=\"backup.zip\""),
"singular `files`, not `files[]`: body: {body}"
);
}
#[tokio::test]
async fn upload_edocument_is_a_post_that_spoofs_put() {
let server = route_server(
"POST",
"/edocument/upload",
json!({ "message": "Processing...." }),
)
.await;
let got = client(&server)
.upload_edocument(vec![pdf("einvoice.xml")])
.await
.expect("edocument upload should be accepted");
assert_eq!(got.message, "Processing....");
let sent = &server.received_requests().await.unwrap()[0];
assert_eq!(
sent.method.as_str(),
"POST",
"the route is a PUT, but a real PUT drops the files"
);
let body = body_text(sent);
assert!(body.contains("name=\"_method\"\r\n\r\nPUT"), "body: {body}");
assert!(
body.contains("name=\"documents[]\"; filename=\"einvoice.xml\""),
"body: {body}"
);
}
#[tokio::test]
async fn edocument_uploads_are_never_replayed() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(500))
.mount(&server)
.await;
client(&server)
.with_retry_policy(quick_retry())
.upload_edocument(vec![pdf("einvoice.xml")])
.await
.expect_err("500");
assert_eq!(
server.received_requests().await.unwrap().len(),
1,
"a multipart body is not cloneable, so it gets one attempt"
);
}
// --- e-invoicing --------------------------------------------------------
fn discovered() -> serde_json::Value {
json!({ "discovered": true })
}
extra_test!(
validate_einvoice_posts_to_validate_entity,
"POST",
"/einvoice/validateEntity",
json!({ "passes": true, "invoices": [], "clients": [] }),
|c| c.validate_einvoice(&json!({ "entity": "invoices", "entity_id": "i1" })),
|got| assert_eq!(got["passes"], true)
);
/// A failed check is a 422, so it arrives as an error rather than an `Ok`
/// carrying the failures. Pinned because it is surprising.
#[tokio::test]
async fn a_failing_einvoice_check_is_an_error_not_a_result() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/einvoice/validateEntity"))
.respond_with(
ResponseTemplate::new(422).set_body_json(json!({ "passes": false, "invoices": ["x"] })),
)
.mount(&server)
.await;
let err = client(&server)
.validate_einvoice(&json!({ "entity": "invoices" }))
.await
.expect_err("a failed validation is a 422");
assert!(
matches!(&err, Error::Api { status, .. } if status.as_u16() == 422),
"{err:?}"
);
}
extra_test!(
einvoice_configurations_posts_the_settings,
"POST",
"/einvoice/configurations",
json!({}),
|c| c.einvoice_configurations(&json!({ "payment_means": [] })),
|got| assert!(got.is_object())
);
extra_test!(
einvoice_quota_reads_the_remaining_count,
"GET",
"/einvoice/quota",
json!({ "quota": 42 }),
|c| c.einvoice_quota(),
|got| assert_eq!(got["quota"], 42)
);
extra_test!(
einvoice_delivery_map_reads_the_static_map,
"GET",
"/einvoice/delivery_map",
json!({ "DE": { "routable": true } }),
|c| c.einvoice_delivery_map(),
|got| assert_eq!(got["DE"]["routable"], true)
);
extra_test!(
peppol_discovery_posts_the_identifier,
"POST",
"/einvoice/peppol/discovery",
discovered(),
|c| c.peppol_discovery(&json!({ "identifier": "0088:123" })),
|got| assert_eq!(got["discovered"], true)
);
extra_test!(
peppol_legal_entity_is_a_post_despite_being_a_read,
"POST",
"/einvoice/peppol/legal_entity",
json!({ "id": 7, "party_name": "Acme" }),
|c| c.peppol_legal_entity(&json!({})),
|got| assert_eq!(got["id"], 7)
);
extra_test!(
peppol_disconnect_posts_and_tolerates_an_empty_array,
"POST",
"/einvoice/peppol/disconnect",
json!([]),
|c| c.peppol_disconnect(&json!({})),
|got| assert!(got.is_array())
);
extra_test!(
peppol_update_legal_entity_is_a_put,
"PUT",
"/einvoice/peppol/update",
json!([]),
|c| c.peppol_update_legal_entity(&json!({ "party_name": "Acme" })),
|got| assert!(got.is_array())
);
extra_test!(
peppol_add_legal_identifier_returns_the_ok_message,
"POST",
"/einvoice/peppol/add_additional_legal_identifier",
message(),
|c| c.peppol_add_legal_identifier(&json!({ "identifier": "0088:1" })),
|got| assert_eq!(got.message, "ok")
);
// A `DELETE` that carries a body, which no other method in the crate does.
extra_test!(
peppol_remove_legal_identifier_is_a_delete_with_a_body,
"DELETE",
"/einvoice/peppol/remove_additional_legal_identifier",
json!([]),
|c| c.peppol_remove_legal_identifier(&json!({ "identifier": "0088:1" })),
|got| assert!(got.is_array())
);
extra_test!(
peppol_retry_send_returns_the_queued_message,
"POST",
"/einvoice/peppol/send",
json!({ "message": "trying...." }),
|c| c.peppol_retry_send(&json!({ "entity_id": "i1" })),
|got| assert_eq!(got.message, "trying....")
);
extra_test!(
peppol_sg_c5_activate_posts_to_the_nested_path,
"POST",
"/einvoice/peppol/sg/c5/activate",
message(),
|c| c.peppol_sg_c5_activate(&json!({})),
|got| assert_eq!(got.message, "ok")
);
extra_test!(
peppol_sg_c5_deactivate_posts_to_the_nested_path,
"POST",
"/einvoice/peppol/sg/c5/deactivate",
message(),
|c| c.peppol_sg_c5_deactivate(&json!({})),
|got| assert_eq!(got.message, "ok")
);
extra_test!(
peppol_sg_c5_cancel_posts_to_the_nested_path,
"POST",
"/einvoice/peppol/sg/c5/cancel",
message(),
|c| c.peppol_sg_c5_cancel(&json!({})),
|got| assert_eq!(got.message, "ok")
);
/// `setup` answers either a URL to send the user to, or a bare 204 when the
/// registration completed outright. Both are successes, so the 204 is `None`
/// rather than a decode error.
#[tokio::test]
async fn peppol_setup_maps_a_204_to_none_and_a_body_to_some() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/einvoice/peppol/setup"))
.respond_with(ResponseTemplate::new(204))
.up_to_n_times(1)
.mount(&server)
.await;
assert_eq!(
client(&server).peppol_setup(&json!({})).await.unwrap(),
None,
"an empty 204 is a completed setup, not a failure"
);
let server = route_server(
"POST",
"/einvoice/peppol/setup",
json!({ "corppass_url": "https://x.test/corppass" }),
)
.await;
let got = client(&server).peppol_setup(&json!({})).await.unwrap();
assert_eq!(
got.unwrap()["corppass_url"],
"https://x.test/corppass",
"a body means a further authorisation step"
);
}
/// The token rotation answers 204 and keeps the new token server-side.
#[tokio::test]
async fn update_einvoice_token_discards_the_empty_body() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/einvoice/token/update"))
.respond_with(ResponseTemplate::new(204))
.expect(1)
.mount(&server)
.await;
client(&server)
.update_einvoice_token(&json!({}))
.await
.expect("a 204 is a success");
}
#[tokio::test]
async fn einvoice_health_check_accepts_an_empty_object() {
let server = route_server("GET", "/einvoice/health_check", json!({})).await;
client(&server)
.einvoice_health_check()
.await
.expect("an empty object is a pass");
}
// --- quickbooks, stripe, calendar, bank feeds ---------------------------
/// The three 204 routes. A JSON decode attempt on an empty body would fail,
/// so these must go through the discarding helper.
#[tokio::test]
async fn the_quickbooks_204_routes_discard_their_bodies() {
for (route, call) in ["sync", "action", "disconnect"].iter().zip(0..3) {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path(format!("/quickbooks/{route}")))
.respond_with(ResponseTemplate::new(204))
.expect(1)
.mount(&server)
.await;
let c = client(&server);
let body = json!({});
let result = match call {
0 => c.quickbooks_sync(&body).await,
1 => c.quickbooks_action(&body).await,
_ => c.quickbooks_disconnect(&body).await,
};
result.unwrap_or_else(|e| panic!("quickbooks/{route} should succeed: {e:?}"));
}
}
extra_test!(
quickbooks_reconnect_url_returns_the_one_time_link,
"POST",
"/quickbooks/reconnect_url",
json!({ "reconnect_url": "https://x.test/r", "requires_reconnect": true }),
|c| c.quickbooks_reconnect_url(),
|got| assert_eq!(got["reconnect_url"], "https://x.test/r")
);
extra_test!(
stripe_update_payment_methods_queues_the_refresh,
"POST",
"/stripe/update_payment_methods",
json!({ "message": "Processing" }),
|c| c.stripe_update_payment_methods(),
|got| assert_eq!(got.message, "Processing")
);
extra_test!(
stripe_import_customers_queues_the_import,
"POST",
"/stripe/import_customers",
json!({ "message": "Processing" }),
|c| c.stripe_import_customers(),
|got| assert_eq!(got.message, "Processing")
);
extra_test!(
stripe_verify_passes_the_driver_response_through,
"POST",
"/stripe/verify",
json!({ "verified": true }),
|c| c.stripe_verify(),
|got| assert_eq!(got["verified"], true)
);
extra_test!(
stripe_disconnect_escapes_the_gateway_id,
"POST",
"/stripe/disconnect/cg1",
json!({ "disconnected": true }),
|c| c.stripe_disconnect("cg1"),
|got| assert_eq!(got["disconnected"], true)
);
/// The stripe routes are password-protected, so the header has to reach them.
#[tokio::test]
async fn the_stripe_routes_send_the_api_password() {
let server = route_server(
"POST",
"/stripe/import_customers",
json!({ "message": "Processing" }),
)
.await;
client(&server)
.with_api_password("secret")
.unwrap()
.stripe_import_customers()
.await
.unwrap();
let headers = &server.received_requests().await.unwrap()[0].headers;
assert_eq!(headers["X-API-PASSWORD"], "secret");
}
extra_test!(
calendar_connection_unwraps_the_data_envelope,
"GET",
"/calendar_connection",
json!({ "data": { "provider": "google" } }),
|c| c.calendar_connection(),
|got| assert_eq!(got["provider"], "google")
);
extra_test!(
complete_calendar_connection_escapes_the_provider,
"POST",
"/calendar_connection/google/complete",
json!({ "data": { "provider": "google" } }),
|c| c.complete_calendar_connection("google", &json!({ "code": "x" })),
|got| assert_eq!(got["provider"], "google")
);
extra_test!(
calendar_connection_calendars_lists_the_available_ones,
"GET",
"/calendar_connection/calendars",
json!({ "data": { "calendars": [{ "id": "c1" }] } }),
|c| c.calendar_connection_calendars(),
|got| assert_eq!(got["calendars"][0]["id"], "c1")
);
extra_test!(
update_calendar_connection_calendars_is_a_put,
"PUT",
"/calendar_connection/calendars",
json!({ "data": { "calendar_connection": { "id": "x" } } }),
|c| c.update_calendar_connection_calendars(&json!({ "calendar_ids": ["c1"] })),
|got| assert_eq!(got["calendar_connection"]["id"], "x")
);
#[tokio::test]
async fn calendar_connection_events_sends_both_dates_as_query_params() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/calendar_connection/events"))
.and(query_param("from", "2026-01-01"))
.and(query_param("to", "2026-01-31"))
.respond_with(
ResponseTemplate::new(200)
.set_body_json(json!({ "data": { "events": [{ "id": "e1" }] } })),
)
.expect(1)
.mount(&server)
.await;
let got = client(&server)
.calendar_connection_events("2026-01-01", "2026-01-31")
.await
.expect("events should decode");
assert_eq!(got["events"][0]["id"], "e1");
}
#[tokio::test]
async fn delete_calendar_connection_discards_the_null_payload() {
let server = route_server(
"DELETE",
"/calendar_connection",
json!({ "data": { "calendar_connection": null } }),
)
.await;
client(&server)
.delete_calendar_connection()
.await
.expect("the disconnect should succeed");
}
extra_test!(
yodlee_account_status_escapes_the_account_number,
"POST",
"/yodlee/status/acc%2F1",
json!({ "account_status": "ok" }),
|c| c.yodlee_account_status("acc/1"),
|got| assert_eq!(got["account_status"], "ok")
);
extra_test!(
nordigen_institutions_returns_the_bare_array,
"GET",
"/nordigen/institutions",
json!([{ "id": "BANK_X" }]),
|c| c.nordigen_institutions(),
|got| assert_eq!(got[0]["id"], "BANK_X")
);
// --- logout, passkeys, login --------------------------------------------
extra_test!(
logout_posts_and_returns_the_message,
"POST",
"/logout",
message(),
|c| c.logout(),
|got| assert_eq!(got.message, "ok")
);
extra_test!(
list_passkeys_decodes_the_data_envelope,
"GET",
"/settings/passkeys",
json!({ "data": [
{ "id": "p1", "name": "Work laptop", "created_at": "2026-01-01T00:00:00.000000Z", "last_used_at": 1700000000 },
{ "id": "p2", "name": "Phone", "created_at": "2026-02-01T00:00:00.000000Z", "last_used_at": null },
] }),
|c| c.list_passkeys(),
|got| {
assert_eq!(got.len(), 2);
assert_eq!(got[0].last_used_at, Some(1700000000));
assert_eq!(got[1].last_used_at, None);
}
);
/// The server answers a delete with `{"message": ...}`, not an empty body,
/// but `delete_passkey` discards it like every other `delete_*`.
#[tokio::test]
async fn delete_passkey_discards_the_message_body() {
let server = MockServer::start().await;
Mock::given(method("DELETE"))
.and(path("/settings/passkeys/p1"))
.respond_with(ResponseTemplate::new(200).set_body_json(message()))
.expect(1)
.mount(&server)
.await;
client(&server).delete_passkey("p1").await.unwrap();
}
/// `login` has no client yet, so it is an associated function taking the
/// base url directly rather than a method.
#[tokio::test]
async fn login_posts_credentials_and_decodes_the_company_token() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/login"))
.and(wiremock::matchers::body_json(json!({
"email": "me@example.com",
"password": "correct-horse-battery-staple",
})))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"data": [
{
"is_owner": true,
"is_admin": true,
"token": { "id": "t1", "token": "new-api-token" },
"company": { "id": "c1" },
}
]
})))
.expect(1)
.mount(&server)
.await;
let login = InvoiceNinjaClient::login(
&server.uri(),
"me@example.com",
"correct-horse-battery-staple",
None,
)
.await
.expect("login should succeed");
assert_eq!(login.data.len(), 1);
let token = login.data[0].token.as_ref().expect("token include present");
assert_eq!(token.token, "new-api-token");
// The nested company blob is not modelled, but it is not dropped either.
assert!(login.data[0].extra.contains_key("company"));
}
/// An optional one-time password goes out alongside the credentials when
/// the caller supplies one, and is left out entirely otherwise.
#[tokio::test]
async fn login_sends_one_time_password_only_when_given() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/login"))
.and(wiremock::matchers::body_json(json!({
"email": "me@example.com",
"password": "pw",
"one_time_password": "123456",
})))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({ "data": [] })))
.expect(1)
.mount(&server)
.await;
InvoiceNinjaClient::login(&server.uri(), "me@example.com", "pw", Some("123456"))
.await
.expect("login should succeed");
}
/// A failed login surfaces the server's validation message like any other
/// non-2xx response.
#[tokio::test]
async fn login_failure_is_an_api_error() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/login"))
.respond_with(ResponseTemplate::new(400).set_body_json(json!({
"message": "These credentials do not match our records."
})))
.mount(&server)
.await;
let err = InvoiceNinjaClient::login(&server.uri(), "me@example.com", "wrong", None)
.await
.expect_err("400");
assert!(
matches!(&err, Error::Api { status, .. } if status.as_u16() == 400),
"{err:?}"
);
}
+6 -1
View File
@@ -11,7 +11,7 @@
//! A wrong *type* is deliberately not covered: that is meant to be a hard
//! error, so it is not something to absorb.
use invoiceninjars::models::*;
use invoiceninja_sdk::models::*;
use serde::{de::DeserializeOwned, Serialize};
use serde_json::Value;
@@ -66,10 +66,15 @@ fn every_model_absorbs_a_null_or_missing_field() {
Document,
Expense,
ExpenseCategory,
ExportJob,
GroupSetting,
ImportPreview,
Invoice,
InvoiceItem,
Location,
LoginCompanyUser,
LoginResponse,
Passkey,
Payment,
PaymentInvoice,
PaymentTerm,
+44 -5
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;
@@ -763,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);
}
+1 -1
View File
@@ -15,7 +15,7 @@ use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};
use invoiceninjars::models::*;
use invoiceninja_sdk::models::*;
use serde::{de::DeserializeOwned, Serialize};
use serde_json::Value;