Compare commits

...
6 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
22 changed files with 633 additions and 85 deletions
+25
View File
@@ -9,6 +9,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added ### 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`, - E-invoicing, seventeen methods: `validate_einvoice`,
`einvoice_configurations`, `einvoice_quota`, `einvoice_health_check`, `einvoice_configurations`, `einvoice_quota`, `einvoice_health_check`,
`einvoice_delivery_map`, `update_einvoice_token`, and eleven `peppol_*`. `einvoice_delivery_map`, `update_einvoice_token`, and eleven `peppol_*`.
@@ -112,6 +129,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed ### 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 - `statics`, `search`, and `health_check` return concrete types instead of
being generic over `T: DeserializeOwned`. being generic over `T: DeserializeOwned`.
- `send_email` now unwraps the `data` envelope, so `T` is the entity - `send_email` now unwraps the `data` envelope, so `T` is the entity
+21 -9
View File
@@ -22,6 +22,14 @@ This project uses Nix for toolchain management (Rust via `rust-overlay`):
nix develop 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 ### Rust version
Two versions are pinned, and **three places must agree** when either moves: Two versions are pinned, and **three places must agree** when either moves:
@@ -353,24 +361,28 @@ changes, doc tweaks) does not need an entry.
## Coverage ## Coverage
**All 34 of the API's `Route::resource` groups are wrapped**, plus `users` **All 34 of the API's `Route::resource` groups are wrapped**, plus `users`
(which isn't one). 371 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 only, 4 with entity actions, 4 with invitation-key downloads, 16 with
document uploads, and 107 hand-written. document uploads, and 111 hand-written.
Plus the non-CRUD surface: documents, entity actions and PDF downloads for Plus the non-CRUD surface: documents, entity actions and PDF downloads for
the four action-capable resources, their invitation-key PDF and e-document the four action-capable resources, their invitation-key PDF and e-document
downloads, `report`, `chart`, `search`, `statics`, `send_email`, downloads, `report`, `chart`, `search`, `statics`, `send_email`,
`refund_payment`, `ping`, `health_check`, import/export, the 17 e-invoicing `refund_payment`, `ping`, `health_check`, import/export, the 17 e-invoicing
routes, and the QuickBooks, Stripe, calendar, Yodlee and GoCardless routes, the QuickBooks, Stripe, calendar, Yodlee and GoCardless
integrations. integrations, `logout`, passkey list/delete, and a standalone `login`.
**`GOALS.md`'s roadmap is closed** — every route an API-token client can **`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: reach is wrapped. What is left out is left out for a reason, not pending:
the auth routes (a different auth model, and an open question in `GOALS.md`), passkey registration and passkey login (WebAuthn ceremonies only a browser
the client-portal family and the OAuth and provider-webhook routes (all can drive), SSO (`oauth_login`, `oidc/*`) and `/login/precheck`,
outside `/api/v1`, so they don't fit the client's `base_url` or its token), `/refresh`/`/refresh_react` (the bulk sync-dump shape parked as its own open
`POST /quickbooks/settings` (a registered route with no controller method, question in `GOALS.md`, not an auth concept), `/connected_account` (Gmail
so it 500s), and a few company and user extras. 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 **`COVERAGE.md` is the detailed inventory** — every wrapped endpoint, every
gap, and the reason for each gap. gap, and the reason for each gap.
+61 -4
View File
@@ -18,7 +18,7 @@ declares 34 `Route::resource` groups and 274 explicit route declarations
> exists or what it returns. > exists or what it returns.
**All 34 `Route::resource` groups are now wrapped**, plus `users` (which is **All 34 `Route::resource` groups are now wrapped**, plus `users` (which is
not a resource group). 371 methods in total. not a resource group). 375 methods in total.
## Resources with full CRUD + bulk ## Resources with full CRUD + bulk
@@ -434,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 password containing a control character cannot be a header value and goes out
as `X-API-PASSWORD-BASE64` instead. 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 ## Per-resource extras
Routes that sit beside a resource's CRUD set, each with its own response Routes that sit beside a resource's CRUD set, each with its own response
@@ -491,8 +545,11 @@ check `routes/api.php`).
- **User extras** (not password-protected, unlike the rest of `/users`) — - **User extras** (not password-protected, unlike the rest of `/users`) —
`POST /users/{id}/disconnect_mailer`, `POST /users/{id}/disconnect_oauth`, `POST /users/{id}/disconnect_mailer`, `POST /users/{id}/disconnect_oauth`,
`POST /user/{id}/reconfirm` `POST /user/{id}/reconfirm`
- **Auth** — `/login`, `/logout`, `/refresh`, `/connected_account`, - **Auth** — see [Logout, passkeys, and login](#logout-passkeys-and-login)
passkey routes 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 - **Migration** — `/migration/start`. Moves a company between instances and
is driven by the v4 migration tool, not by an SDK caller. is driven by the v4 migration tool, not by an SDK caller.
- **Invoice extras** — `delivery_note`, `payment_schedule`, - **Invoice extras** — `delivery_note`, `payment_schedule`,
@@ -526,7 +583,7 @@ check `routes/api.php`).
`/claim_license`, `/check_license`, `/check_subdomain`, `/one_time_token`, `/claim_license`, `/check_license`, `/check_subdomain`, `/one_time_token`,
`/last_error`, `/support/messages/send`, `/verify/*`, `/accounts/{id}`, `/last_error`, `/support/messages/send`, `/verify/*`, `/accounts/{id}`,
`/feedback`, `/filters/{entity}`, `/scheduler`, `/mailer/check`, `/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}`, `/token_hash_router`, `/reactivate_email/{bounce_id}`,
`/protected_download/{hash}`, `/confirm_purchase`, `/protected_download/{hash}`, `/confirm_purchase`,
`/exports/preview/{hash}`, `/templates/preview/{hash}` `/exports/preview/{hash}`, `/templates/preview/{hash}`
Generated
+2 -2
View File
@@ -528,8 +528,8 @@ dependencies = [
] ]
[[package]] [[package]]
name = "invoiceninjars" name = "invoiceninja-sdk"
version = "0.1.0" version = "0.2.0"
dependencies = [ dependencies = [
"percent-encoding", "percent-encoding",
"reqwest", "reqwest",
+6 -2
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "invoiceninjars" name = "invoiceninja-sdk"
version = "0.1.0" version = "0.2.0"
edition = "2021" edition = "2021"
# Set by the dependency graph rather than by this crate's own code: reqwest # 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 # 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" description = "An async Rust client for the Invoice Ninja v5 API"
license = "MIT" license = "MIT"
repository = "https://github.com/awkawb1/invoiceninja-sdk" 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] [dependencies]
reqwest = { version = "0.12", default-features = false, features = ["json", "native-tls", "multipart"] } reqwest = { version = "0.12", default-features = false, features = ["json", "native-tls", "multipart"] }
+80 -11
View File
@@ -85,6 +85,38 @@ Invoice Ninja account to contribute.
comparison in `COVERAGE.md`. The divergences — JSON instead of comparison in `COVERAGE.md`. The divergences — JSON instead of
`form_params`, keeping the whole validation `errors` map, exposing bulk `form_params`, keeping the whole validation `errors` map, exposing bulk
`download` only on PDF-rendering entities — are deliberate. `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 sync/blocking API.** Async only.
- **No identity map, lazy relationship loading, or transparent entity - **No identity map, lazy relationship loading, or transparent entity
caching.** Three separate refusals with one theme — the client stays caching.** Three separate refusals with one theme — the client stays
@@ -196,6 +228,24 @@ 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 an API-token client can reach is now wrapped. It did land in pieces, as the
item predicted — import/export first, then the integrations. item predicted — import/export first, then the integrations.
**Done:** the auth routes, once split apart — the single "auth routes"
label had quietly merged four unrelated things. `logout` and the passkey
list/delete routes sit behind the same `token_auth` middleware as every
other wrapped endpoint, so they needed no new client type, just methods
(and a documented warning that `logout` rotates every token on the whole
company, not just the caller's). `login` *does* need a different entry
point — there is no token yet to build an `InvoiceNinjaClient` with — but
that turned out to be a standalone associated function, not a second
client type: `InvoiceNinjaClient::login` trades an email and password for
a `LoginResponse` carrying a token, which then goes to `new` or `hosted`
as normal. Passkey *registration* and passkey *login* stay unwrapped
because they're WebAuthn ceremonies a browser has to drive; `/refresh` and
`/refresh_react` turned out to be the bulk `updated_at` sync dump already
parked below, not an auth concept, so they stay out for that reason
instead; and `/connected_account` (Gmail OAuth linking) was filed under
the wrong heading — it belongs with the Integrations done item above. See
`COVERAGE.md`'s "Logout, passkeys, and login" section for the detail.
As the item also predicted, it is the least SDK-shaped part of the API, and 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 the typing reflects that: most of these return `serde_json::Value`, because
their replies are pass-throughs from Storecove, Stripe, Yodlee or GoCardless, their replies are pass-throughs from Storecove, Stripe, Yodlee or GoCardless,
@@ -212,22 +262,41 @@ a registered route with no controller method behind it, so it 500s — there is
nothing to wrap until upstream adds the handler. nothing to wrap until upstream adds the handler.
What remains unwrapped is now only what each entry in `COVERAGE.md`'s gap What remains unwrapped is now only what each entry in `COVERAGE.md`'s gap
list explains: the auth routes (an open question below), the client-portal list explains: the passkey and SSO ceremonies and `/login/precheck`
family, the provider webhooks, and a few company and user extras. Keep that (each needs something — a browser, a redirect — this client doesn't have),
file current — it names exact counts, so it goes stale quietly. `/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 ## Open questions
These are genuinely undecided. Deciding one is a change to this file. 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 - **Large bodies are buffered entirely into memory.** PDF downloads and CSV
report exports come back as `Vec<u8>`, and an uploaded `FileUpload` holds report exports come back as `Vec<u8>`, and an uploaded `FileUpload` holds
its whole contents until the request finishes. Streaming would be better 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. 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 ## Usage
```rust ```rust
use invoiceninjars::{InvoiceNinjaClient, models::ListParams}; use invoiceninja_sdk::{InvoiceNinjaClient, models::ListParams};
// Hosted app: // Hosted app:
let client = InvoiceNinjaClient::hosted(std::env::var("IN_API_TOKEN")?)?; let client = InvoiceNinjaClient::hosted(std::env::var("IN_API_TOKEN")?)?;
@@ -75,7 +75,7 @@ with exponential backoff. Both are configurable:
```rust ```rust
use std::time::Duration; use std::time::Duration;
use invoiceninjars::RetryPolicy; use invoiceninja_sdk::RetryPolicy;
let client = InvoiceNinjaClient::hosted(token)? let client = InvoiceNinjaClient::hosted(token)?
.with_timeout(Duration::from_secs(300)) .with_timeout(Duration::from_secs(300))
@@ -100,7 +100,7 @@ IN_API_TOKEN=... cargo run --example list_clients
## Coverage ## Coverage
Every one of the API's 34 resource groups, plus users — 371 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 resource has `list`, `list_all`, `get`, `create`, `update`, `delete`, and
(where the API offers one) `bulk`: (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 Plus documents, entity actions and PDF downloads — by record id, or by
invitation key for the PDF and XML a given contact receives — reports, 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 **See [COVERAGE.md](COVERAGE.md) for the full endpoint-by-endpoint
inventory**, including everything that is deliberately *not* wrapped and why. 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: `upload_client`, `upload_invoice`, `upload_expense`, and so on:
```rust ```rust
use invoiceninjars::FileUpload; use invoiceninja_sdk::FileUpload;
let receipt = FileUpload::new("receipt.pdf", std::fs::read("receipt.pdf")?) let receipt = FileUpload::new("receipt.pdf", std::fs::read("receipt.pdf")?)
.mime("application/pdf"); .mime("application/pdf");
+3 -3
View File
@@ -1,8 +1,8 @@
use invoiceninjars::models::ListParams; use invoiceninja_sdk::models::ListParams;
use invoiceninjars::InvoiceNinjaClient; use invoiceninja_sdk::InvoiceNinjaClient;
#[tokio::main] #[tokio::main]
async fn main() -> invoiceninjars::Result<()> { async fn main() -> invoiceninja_sdk::Result<()> {
let base_url = let base_url =
std::env::var("IN_BASE_URL").unwrap_or_else(|_| "https://invoicing.co/api/v1".into()); 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"); 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::future::Future;
use std::process::ExitCode; use std::process::ExitCode;
use invoiceninjars::{Error, InvoiceNinjaClient}; use invoiceninja_sdk::{Error, InvoiceNinjaClient};
use serde::de::DeserializeOwned; use serde::de::DeserializeOwned;
use serde_json::{json, Value}; 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. /// 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 { match call.await {
Ok(created) => { Ok(created) => {
println!(" created {name}"); println!(" created {name}");
+2 -2
View File
@@ -37,10 +37,10 @@ use std::fmt::Display;
use std::process::ExitCode; use std::process::ExitCode;
use std::time::{SystemTime, UNIX_EPOCH}; use std::time::{SystemTime, UNIX_EPOCH};
use invoiceninjars::models::{ use invoiceninja_sdk::models::{
Client, ListParams, Location, PurchaseOrder, RecurringInvoice, Vendor, Webhook, Client, ListParams, Location, PurchaseOrder, RecurringInvoice, Vendor, Webhook,
}; };
use invoiceninjars::{Error, FileUpload, InvoiceNinjaClient}; use invoiceninja_sdk::{Error, FileUpload, InvoiceNinjaClient};
use serde::Serialize; use serde::Serialize;
use serde_json::{json, Value}; 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 = { inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
@@ -43,6 +43,14 @@
packages = [ toolchain pkgs.pkg-config pkgs.openssl pkgs.jq ]; packages = [ toolchain pkgs.pkg-config pkgs.openssl pkgs.jq ];
RUST_SRC_PATH = "${toolchain}/lib/rustlib/src/rust/library"; 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 in
{ {
devShells.default = shellFor rustToolchain; devShells.default = shellFor rustToolchain;
@@ -50,5 +58,25 @@
# Verifies the declared MSRV still builds: # Verifies the declared MSRV still builds:
# nix develop .#msrv --command cargo test # nix develop .#msrv --command cargo test
devShells.msrv = shellFor msrvToolchain; 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.
};
}); });
} }
+141 -27
View File
@@ -10,10 +10,11 @@ use crate::models::{
Activity, ActivityFeedEntry, BankIntegration, BankTransaction, BankTransactionRule, Activity, ActivityFeedEntry, BankIntegration, BankTransaction, BankTransactionRule,
BulkRequest, Client as ClientModel, ClientGatewayToken, Company, CompanyGateway, CompanyToken, BulkRequest, Client as ClientModel, ClientGatewayToken, Company, CompanyGateway, CompanyToken,
Credit, Design, Document, Expense, ExpenseCategory, ExportJob, GroupSetting, HealthCheck, Credit, Design, Document, Expense, ExpenseCategory, ExportJob, GroupSetting, HealthCheck,
ImportPreview, Invoice, ItemResponse, ListParams, ListResponse, Location, MessageResponse, ImportPreview, Invoice, ItemResponse, ListParams, ListResponse, Location, LoginResponse,
Payment, PaymentTerm, Product, Project, PurchaseOrder, Quote, RecurringExpense, MessageResponse, Passkey, Payment, PaymentTerm, Product, Project, PurchaseOrder, Quote,
RecurringInvoice, RecurringQuote, SearchResults, Statics, Subscription, SubscriptionStep, RecurringExpense, RecurringInvoice, RecurringQuote, SearchResults, Statics, Subscription,
SystemLog, Tag, Task, TaskScheduler, TaskStatus, TaxRate, User, Vendor, Webhook, SubscriptionStep, SystemLog, Tag, Task, TaskScheduler, TaskStatus, TaxRate, User, Vendor,
Webhook,
}; };
use crate::retry::{self, RetryPolicy}; use crate::retry::{self, RetryPolicy};
use crate::upload::FileUpload; use crate::upload::FileUpload;
@@ -63,6 +64,32 @@ fn seg(value: &str) -> Result<impl std::fmt::Display + '_> {
Ok(utf8_percent_encode(value, PATH_SEGMENT)) 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, /// Standard base64 with padding. Only used for the one header that needs it,
/// which is not worth a dependency. /// which is not worth a dependency.
fn base64(bytes: &[u8]) -> String { fn base64(bytes: &[u8]) -> String {
@@ -135,25 +162,7 @@ pub struct InvoiceNinjaClient {
impl InvoiceNinjaClient { impl InvoiceNinjaClient {
pub fn new(base_url: impl Into<String>, api_token: impl Into<String>) -> Result<Self> { pub fn new(base_url: impl Into<String>, api_token: impl Into<String>) -> Result<Self> {
let base_url = base_url.into(); let base_url = validate_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 mut headers = header::HeaderMap::new(); let mut headers = header::HeaderMap::new();
let mut token_value = header::HeaderValue::from_str(&api_token.into()) let mut token_value = header::HeaderValue::from_str(&api_token.into())
@@ -219,8 +228,8 @@ impl InvoiceNinjaClient {
/// It used to be refused locally with a misleading `InvalidToken`. /// It used to be refused locally with a misleading `InvalidToken`.
/// ///
/// ```no_run /// ```no_run
/// # fn main() -> invoiceninjars::Result<()> { /// # fn main() -> invoiceninja_sdk::Result<()> {
/// use invoiceninjars::InvoiceNinjaClient; /// use invoiceninja_sdk::InvoiceNinjaClient;
/// ///
/// let client = InvoiceNinjaClient::hosted(std::env::var("IN_API_TOKEN").unwrap())? /// let client = InvoiceNinjaClient::hosted(std::env::var("IN_API_TOKEN").unwrap())?
/// .with_api_password(std::env::var("IN_API_PASSWORD").unwrap())?; /// .with_api_password(std::env::var("IN_API_PASSWORD").unwrap())?;
@@ -338,6 +347,76 @@ impl InvoiceNinjaClient {
Self::new(HOSTED_BASE_URL, api_token) 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 { fn url(&self, path: &str) -> String {
format!("{}/{}", self.base_url, path.trim_start_matches('/')) format!("{}/{}", self.base_url, path.trim_start_matches('/'))
} }
@@ -1367,8 +1446,8 @@ impl InvoiceNinjaClient {
/// for — [`Invoice`], [`Quote`], [`Credit`], or [`PurchaseOrder`]: /// for — [`Invoice`], [`Quote`], [`Credit`], or [`PurchaseOrder`]:
/// ///
/// ```no_run /// ```no_run
/// # async fn run(client: &invoiceninjars::InvoiceNinjaClient) -> invoiceninjars::Result<()> { /// # async fn run(client: &invoiceninja_sdk::InvoiceNinjaClient) -> invoiceninja_sdk::Result<()> {
/// use invoiceninjars::models::Invoice; /// use invoiceninja_sdk::models::Invoice;
/// use serde_json::json; /// use serde_json::json;
/// ///
/// let invoice: Invoice = client /// let invoice: Invoice = client
@@ -1909,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 /// Activity log, email history, and document previews. Each has a response
/// shape of its own (read from the upstream controllers, and for the activity /// 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. /// routes confirmed against a 5.13.50 server), so none is macro-generated.
+2 -2
View File
@@ -1,9 +1,9 @@
//! An async Rust client for the [Invoice Ninja](https://www.invoiceninja.com/) v5 API. //! An async Rust client for the [Invoice Ninja](https://www.invoiceninja.com/) v5 API.
//! //!
//! ```no_run //! ```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( //! let client = InvoiceNinjaClient::new(
//! "https://invoicing.co/api/v1", //! "https://invoicing.co/api/v1",
//! std::env::var("IN_API_TOKEN").unwrap(), //! std::env::var("IN_API_TOKEN").unwrap(),
+1 -1
View File
@@ -104,7 +104,7 @@ impl ListParams {
/// not cover. /// not cover.
/// ///
/// ``` /// ```
/// use invoiceninjars::models::ListParams; /// use invoiceninja_sdk::models::ListParams;
/// ///
/// let params = ListParams::new() /// let params = ListParams::new()
/// .per_page(100) /// .per_page(100)
+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>,
}
+4
View File
@@ -17,6 +17,8 @@ mod health;
mod import_export; mod import_export;
mod invoice; mod invoice;
mod location; mod location;
mod login;
mod passkey;
mod payment; mod payment;
mod payment_term; mod payment_term;
mod product; mod product;
@@ -58,6 +60,8 @@ pub use health::{HealthCheck, PhpVersion};
pub use import_export::{ExportJob, ImportPreview}; pub use import_export::{ExportJob, ImportPreview};
pub use invoice::{Credit, Invoice, InvoiceItem, Quote}; pub use invoice::{Credit, Invoice, InvoiceItem, Quote};
pub use location::Location; pub use location::Location;
pub use login::{LoginCompanyUser, LoginResponse};
pub use passkey::Passkey;
pub use payment::{Payment, PaymentInvoice}; pub use payment::{Payment, PaymentInvoice};
pub use payment_term::PaymentTerm; pub use payment_term::PaymentTerm;
pub use product::Product; 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`. /// question in `GOALS.md`.
/// ///
/// ``` /// ```
/// use invoiceninjars::FileUpload; /// use invoiceninja_sdk::FileUpload;
/// ///
/// let file = FileUpload::new("receipt.pdf", b"%PDF-1.4 ...".to_vec()) /// let file = FileUpload::new("receipt.pdf", b"%PDF-1.4 ...".to_vec())
/// .mime("application/pdf"); /// .mime("application/pdf");
+131 -5
View File
@@ -8,7 +8,7 @@
use std::time::Duration; use std::time::Duration;
use invoiceninjars::{models::ListParams, Error, InvoiceNinjaClient}; use invoiceninja_sdk::{models::ListParams, Error, InvoiceNinjaClient};
use serde_json::json; use serde_json::json;
use wiremock::matchers::{header, method, path, query_param}; use wiremock::matchers::{header, method, path, query_param};
use wiremock::{Mock, MockServer, ResponseTemplate}; use wiremock::{Mock, MockServer, ResponseTemplate};
@@ -547,7 +547,7 @@ async fn ping_accepts_an_empty_success_body() {
// --- retry and backoff -------------------------------------------------- // --- retry and backoff --------------------------------------------------
use invoiceninjars::RetryPolicy; use invoiceninja_sdk::RetryPolicy;
/// Fast policy so the tests do not actually sleep for seconds. /// Fast policy so the tests do not actually sleep for seconds.
fn quick_retry() -> RetryPolicy { fn quick_retry() -> RetryPolicy {
@@ -751,7 +751,7 @@ async fn retried_requests_keep_their_body_and_headers() {
.mount(&server) .mount(&server)
.await; .await;
let body = invoiceninjars::models::Client { let body = invoiceninja_sdk::models::Client {
name: "Acme".into(), name: "Acme".into(),
..Default::default() ..Default::default()
}; };
@@ -965,7 +965,7 @@ async fn send_email_unwraps_the_data_envelope() {
.mount(&server) .mount(&server)
.await; .await;
let invoice: invoiceninjars::models::Invoice = client(&server) let invoice: invoiceninja_sdk::models::Invoice = client(&server)
.send_email(&json!({ "entity": "invoice", "entity_id": "inv1" })) .send_email(&json!({ "entity": "invoice", "entity_id": "inv1" }))
.await .await
.expect("should return the refreshed entity"); .expect("should return the refreshed entity");
@@ -1179,7 +1179,7 @@ async fn no_budget_means_only_max_retries_bounds_the_call() {
// --- document uploads --------------------------------------------------- // --- document uploads ---------------------------------------------------
use invoiceninjars::FileUpload; use invoiceninja_sdk::FileUpload;
fn pdf(name: &str) -> FileUpload { fn pdf(name: &str) -> FileUpload {
FileUpload::new(name, b"%PDF-1.4 fake".to_vec()).mime("application/pdf") FileUpload::new(name, b"%PDF-1.4 fake".to_vec()).mime("application/pdf")
@@ -2931,3 +2931,129 @@ extra_test!(
|c| c.nordigen_institutions(), |c| c.nordigen_institutions(),
|got| assert_eq!(got[0]["id"], "BANK_X") |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:?}"
);
}
+4 -1
View File
@@ -11,7 +11,7 @@
//! A wrong *type* is deliberately not covered: that is meant to be a hard //! A wrong *type* is deliberately not covered: that is meant to be a hard
//! error, so it is not something to absorb. //! error, so it is not something to absorb.
use invoiceninjars::models::*; use invoiceninja_sdk::models::*;
use serde::{de::DeserializeOwned, Serialize}; use serde::{de::DeserializeOwned, Serialize};
use serde_json::Value; use serde_json::Value;
@@ -72,6 +72,9 @@ fn every_model_absorbs_a_null_or_missing_field() {
Invoice, Invoice,
InvoiceItem, InvoiceItem,
Location, Location,
LoginCompanyUser,
LoginResponse,
Passkey,
Payment, Payment,
PaymentInvoice, PaymentInvoice,
PaymentTerm, PaymentTerm,
+44 -5
View File
@@ -1,10 +1,10 @@
use invoiceninjars::models::{ use invoiceninja_sdk::models::{
BankIntegration, BankTransaction, BankTransactionRule, BulkRequest, Client, ClientGatewayToken, BankIntegration, BankTransaction, BankTransactionRule, BulkRequest, Client, ClientGatewayToken,
Company, CompanyGateway, CompanyToken, Credit, Design, Document, Expense, ExpenseCategory, Company, CompanyGateway, CompanyToken, Credit, Design, Document, Expense, ExpenseCategory,
GroupSetting, HealthCheck, Invoice, ListParams, ListResponse, Location, PaymentTerm, Project, GroupSetting, HealthCheck, Invoice, ListParams, ListResponse, Location, LoginResponse, Passkey,
PurchaseOrder, Quote, RecurringExpense, RecurringInvoice, RecurringQuote, SearchResults, PaymentTerm, Project, PurchaseOrder, Quote, RecurringExpense, RecurringInvoice, RecurringQuote,
Statics, Subscription, SystemLog, Tag, Task, TaskScheduler, TaskStatus, TaxRate, User, Vendor, SearchResults, Statics, Subscription, SystemLog, Tag, Task, TaskScheduler, TaskStatus, TaxRate,
Webhook, User, Vendor, Webhook,
}; };
use serde_json::json; 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.industries[0].id, "3");
assert_eq!(statics.sizes[0].id, "", "a null id decodes as empty"); 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::fs;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use invoiceninjars::models::*; use invoiceninja_sdk::models::*;
use serde::{de::DeserializeOwned, Serialize}; use serde::{de::DeserializeOwned, Serialize};
use serde_json::Value; use serde_json::Value;