Compare commits
8
Commits
e6e41fe5c6
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
20ac306c26 | ||
|
|
1a8e4aad26 | ||
|
|
ab696a910b | ||
|
|
fb670cbb08 | ||
|
|
48244e8390 | ||
|
|
d51de3fc63 | ||
|
|
446617a728 | ||
|
|
c4f31743f0 |
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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"] }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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}");
|
||||
|
||||
@@ -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};
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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(),
|
||||
|
||||
@@ -104,7 +104,7 @@ impl ListParams {
|
||||
/// not cover.
|
||||
///
|
||||
/// ```
|
||||
/// use invoiceninjars::models::ListParams;
|
||||
/// use invoiceninja_sdk::models::ListParams;
|
||||
///
|
||||
/// let params = ListParams::new()
|
||||
/// .per_page(100)
|
||||
|
||||
@@ -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>,
|
||||
}
|
||||
@@ -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>,
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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:?}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user