Compare commits
10
Commits
e7c9663621
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
20ac306c26 | ||
|
|
1a8e4aad26 | ||
|
|
ab696a910b | ||
|
|
fb670cbb08 | ||
|
|
48244e8390 | ||
|
|
d51de3fc63 | ||
|
|
446617a728 | ||
|
|
c4f31743f0 | ||
|
|
e6e41fe5c6 | ||
|
|
2532b83a32 |
@@ -9,6 +9,62 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
|
- Logout and passkey management: `logout`, `list_passkeys`, and
|
||||||
|
`delete_passkey`. `logout` regenerates every system-issued token on the
|
||||||
|
whole company, not just the caller's, so it ends every session sharing
|
||||||
|
it. Passkey *registration* is not wrapped — it needs a WebAuthn ceremony
|
||||||
|
only a browser can drive. Adds `Passkey`.
|
||||||
|
- `InvoiceNinjaClient::login`, an associated function (not a method, since
|
||||||
|
there is no token yet to build a client with) that exchanges an email and
|
||||||
|
password for an API token, optionally with a one-time password for
|
||||||
|
two-factor accounts. Adds `LoginResponse` and `LoginCompanyUser`; the
|
||||||
|
nested company/user/account data the server also returns is left in
|
||||||
|
`extra` rather than modelled. Does not cover passkey login, SSO, or
|
||||||
|
`/login/precheck`.
|
||||||
|
|
||||||
|
## [0.2.0] - 2026-10-25
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- E-invoicing, seventeen methods: `validate_einvoice`,
|
||||||
|
`einvoice_configurations`, `einvoice_quota`, `einvoice_health_check`,
|
||||||
|
`einvoice_delivery_map`, `update_einvoice_token`, and eleven `peppol_*`.
|
||||||
|
Three behave unlike the rest of the API, and the docs on each say so: a
|
||||||
|
failing `validate_einvoice` is a `422`, so an ordinary "not Peppol-ready"
|
||||||
|
result arrives as `Error::Api`; the Peppol handlers return the error
|
||||||
|
message as the whole body rather than `{"message": …}`, so `Error::Api`'s
|
||||||
|
`errors` map is empty for them; and `peppol_setup` has two success shapes,
|
||||||
|
hence `Option<Value>`. `peppol_remove_legal_identifier` is the crate's
|
||||||
|
only `DELETE` with a request body.
|
||||||
|
- Integrations, sixteen methods: four `quickbooks_*`, four `stripe_*` (all
|
||||||
|
password-protected), six `calendar_connection` methods, plus
|
||||||
|
`yodlee_account_status` and `nordigen_institutions`. These operate a
|
||||||
|
connection that already exists — establishing one is an OAuth browser flow
|
||||||
|
whose routes take no API token and cannot be wrapped.
|
||||||
|
- Import and export, five methods: `export`, `preimport`, `import`,
|
||||||
|
`import_json`, and `upload_edocument`. A CSV import is two calls —
|
||||||
|
`preimport` uploads the files as `files[<entity_type>]` and returns the
|
||||||
|
column mapping the server suggests, then `import` starts the ingest from
|
||||||
|
the returned `hash`. The route's one-shot form (files and mapping in a
|
||||||
|
single multipart request) is deliberately not wrapped: a mapping is nested,
|
||||||
|
and bracket-encoding nested structures is what this crate avoids by sending
|
||||||
|
JSON. `upload_edocument` imports expenses from e-documents and is not a
|
||||||
|
document attachment, which is why it is not an `upload_*` method. Adds
|
||||||
|
`ExportJob` (the export's collection URL expires in 1–5 hours) and
|
||||||
|
`ImportPreview`. All of these queue work, so a success means accepted, not
|
||||||
|
done.
|
||||||
|
- Invitation-key downloads, eight methods:
|
||||||
|
`download_invoice_by_invitation_key` and
|
||||||
|
`download_e_invoice_by_invitation_key`, plus the same pair for quotes,
|
||||||
|
credits, and purchase orders. These take the per-contact invitation key
|
||||||
|
from a client-portal link rather than a record id, and return the PDF or
|
||||||
|
the XML e-document as bytes. Prefer them over the id-based `download_*`
|
||||||
|
when the document is for a particular contact: the server renders in that
|
||||||
|
contact's locale, so a client with contacts in two languages gets a
|
||||||
|
different document per key. An unknown key is a `400` carrying `no record
|
||||||
|
found`. `COVERAGE.md` previously listed these as out of scope on the
|
||||||
|
grounds that they need no auth token; that was a misreading — they are
|
||||||
|
ordinary authenticated `/api/v1` routes.
|
||||||
- Document uploads: `upload_client`, `upload_invoice`, `upload_expense`, and
|
- Document uploads: `upload_client`, `upload_invoice`, `upload_expense`, and
|
||||||
thirteen more, one for each resource with an `/{id}/upload` route. They
|
thirteen more, one for each resource with an `/{id}/upload` route. They
|
||||||
take a `Vec<FileUpload>` and an optional `is_public`, and return the
|
take a `Vec<FileUpload>` and an optional `is_public`, and return the
|
||||||
@@ -73,6 +129,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
|
- **The crate is renamed from `invoiceninjars` to `invoiceninja-sdk`**, to
|
||||||
|
match the repository it has always lived in
|
||||||
|
(`gitea.awkawb.cloud/awkawb/invoiceninja-sdk`). Depend on it as
|
||||||
|
`invoiceninja-sdk` in `Cargo.toml`, and import it as `invoiceninja_sdk`
|
||||||
|
(Cargo lowercases a package name's hyphens to underscores for the crate
|
||||||
|
identifier) — every `use invoiceninjars::...` becomes
|
||||||
|
`use invoiceninja_sdk::...`. Not yet published to crates.io, so this
|
||||||
|
lands before the name was ever claimed there.
|
||||||
- `statics`, `search`, and `health_check` return concrete types instead of
|
- `statics`, `search`, and `health_check` return concrete types instead of
|
||||||
being generic over `T: DeserializeOwned`.
|
being generic over `T: DeserializeOwned`.
|
||||||
- `send_email` now unwraps the `data` envelope, so `T` is the entity
|
- `send_email` now unwraps the `data` envelope, so `T` is the entity
|
||||||
|
|||||||
@@ -22,6 +22,14 @@ This project uses Nix for toolchain management (Rust via `rust-overlay`):
|
|||||||
nix develop
|
nix develop
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`flake.nix` also has `packages.default`: `nix build` compiles with the
|
||||||
|
pinned dev toolchain and runs the full test suite, including doctests,
|
||||||
|
inside the Nix sandbox. The crate has no `[[bin]]` target (see the "No
|
||||||
|
CLI" non-goal in `GOALS.md`), so the resulting store path is empty —
|
||||||
|
`cargoInstallHook` has no binary to copy out of `target/release`. Treat
|
||||||
|
`nix build` as a build-and-test gate, not a source of an installable
|
||||||
|
artifact.
|
||||||
|
|
||||||
### Rust version
|
### Rust version
|
||||||
|
|
||||||
Two versions are pinned, and **three places must agree** when either moves:
|
Two versions are pinned, and **three places must agree** when either moves:
|
||||||
@@ -157,6 +165,10 @@ Beyond "does it decode", the read tier fetches the same rows raw and fails
|
|||||||
if a typed row lost a key (`keys kept: <resource>`, names only, never
|
if a typed row lost a key (`keys kept: <resource>`, names only, never
|
||||||
values), and the write tier creates a purchase order, location, webhook and
|
values), and the write tier creates a purchase order, location, webhook and
|
||||||
recurring invoice from bare `Default` models, since those are what broke.
|
recurring invoice from bare `Default` models, since those are what broke.
|
||||||
|
The read tier also downloads an invoice by invitation key, which is there to
|
||||||
|
re-check the claim that those routes are token-authenticated; the matching
|
||||||
|
`download_e_*` is left out, since it renders from e-invoice settings a test
|
||||||
|
instance need not have.
|
||||||
With `IN_API_PASSWORD` set as well, a password tier merges two throwaway
|
With `IN_API_PASSWORD` set as well, a password tier merges two throwaway
|
||||||
clients and purges the survivor (a real, permanent delete); it was run
|
clients and purges the survivor (a real, permanent delete); it was run
|
||||||
against a disposable 5.13.50 instance on 2026-10-24 and all four checks
|
against a disposable 5.13.50 instance on 2026-10-24 and all four checks
|
||||||
@@ -241,6 +253,17 @@ changes, doc tweaks) does not need an entry.
|
|||||||
elsewhere without checking `routes/api.php`. In particular the official PHP
|
elsewhere without checking `routes/api.php`. In particular the official PHP
|
||||||
SDK puts bulk `download` on *every* resource, which is wrong; only
|
SDK puts bulk `download` on *every* resource, which is wrong; only
|
||||||
PDF-rendering entities support it.
|
PDF-rendering entities support it.
|
||||||
|
- `invitation_download_methods!` generates `download_{x}_by_invitation_key`
|
||||||
|
and `download_e_{x}_by_invitation_key` for the same four resources. Two
|
||||||
|
traps, both pinned by tests: the entity segment is **singular**
|
||||||
|
(`invoice/{key}/download`, the only route family in the API that is), and
|
||||||
|
the routes are **authenticated** despite being the link a client follows —
|
||||||
|
they sit in `routes/api.php`'s `api/v1` group under `token_auth`. The
|
||||||
|
genuinely token-less invitation downloads are the separate client-portal
|
||||||
|
routes in `routes/client.php`, which are not under `/api/v1` and are not
|
||||||
|
wrapped. `COVERAGE.md` used to list the `/api/v1` eight as unwrapped for
|
||||||
|
that reason, having conflated the two families; don't reintroduce the
|
||||||
|
claim.
|
||||||
- `upload_methods!` generates `upload_{x}` for the 16 resources with a
|
- `upload_methods!` generates `upload_{x}` for the 16 resources with a
|
||||||
`/{id}/upload` route; documents can only be created this way, because
|
`/{id}/upload` route; documents can only be created this way, because
|
||||||
`DocumentController::store` is an empty stub upstream (hence no
|
`DocumentController::store` is an empty stub upstream (hence no
|
||||||
@@ -257,6 +280,22 @@ changes, doc tweaks) does not need an entry.
|
|||||||
refunds, ping/health) are hand-written in the last `impl` block rather than
|
refunds, ping/health) are hand-written in the last `impl` block rather than
|
||||||
macro-generated, because their request and response shapes are all
|
macro-generated, because their request and response shapes are all
|
||||||
different.
|
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
|
- `list_*` returns one page; `list_all_*` goes through `get_all_pages`, which
|
||||||
terminates on `meta.pagination.total_pages` and only falls back to the
|
terminates on `meta.pagination.total_pages` and only falls back to the
|
||||||
short-page heuristic when the server sends no metadata. **The heuristic is
|
short-page heuristic when the server sends no metadata. **The heuristic is
|
||||||
@@ -322,18 +361,31 @@ changes, doc tweaks) does not need an entry.
|
|||||||
## Coverage
|
## Coverage
|
||||||
|
|
||||||
**All 34 of the API's `Route::resource` groups are wrapped**, plus `users`
|
**All 34 of the API's `Route::resource` groups are wrapped**, plus `users`
|
||||||
(which isn't one). 325 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, 16 with document uploads, and 69 hand-written.
|
only, 4 with entity actions, 4 with invitation-key downloads, 16 with
|
||||||
|
document uploads, and 111 hand-written.
|
||||||
|
|
||||||
Plus the non-CRUD surface: documents, entity actions and PDF downloads for
|
Plus the non-CRUD surface: documents, entity actions and PDF downloads for
|
||||||
the four action-capable resources, `report`, `chart`, `search`, `statics`,
|
the four action-capable resources, their invitation-key PDF and e-document
|
||||||
`send_email`, `refund_payment`, `ping`, and `health_check`.
|
downloads, `report`, `chart`, `search`, `statics`, `send_email`,
|
||||||
|
`refund_payment`, `ping`, `health_check`, import/export, the 17 e-invoicing
|
||||||
|
routes, the QuickBooks, Stripe, calendar, Yodlee and GoCardless
|
||||||
|
integrations, `logout`, passkey list/delete, and a standalone `login`.
|
||||||
|
|
||||||
|
**`GOALS.md`'s roadmap is closed** — every route an API-token client can
|
||||||
|
reach is wrapped. What is left out is left out for a reason, not pending:
|
||||||
|
passkey registration and passkey login (WebAuthn ceremonies only a browser
|
||||||
|
can drive), SSO (`oauth_login`, `oidc/*`) and `/login/precheck`,
|
||||||
|
`/refresh`/`/refresh_react` (the bulk sync-dump shape parked as its own open
|
||||||
|
question in `GOALS.md`, not an auth concept), `/connected_account` (Gmail
|
||||||
|
OAuth linking — an integration, not login/logout), the client-portal family
|
||||||
|
and the OAuth and provider-webhook routes (all outside `/api/v1`, so they
|
||||||
|
don't fit the client's `base_url` or its token), `POST /quickbooks/settings`
|
||||||
|
(a registered route with no controller method, so it 500s), and a few
|
||||||
|
company and user extras.
|
||||||
|
|
||||||
**`COVERAGE.md` is the detailed inventory** — every wrapped endpoint, every
|
**`COVERAGE.md` is the detailed inventory** — every wrapped endpoint, every
|
||||||
gap, and the reason for each gap. The main remaining gaps are the
|
gap, and the reason for each gap.
|
||||||
integration and auth routes, import/export, the public invitation-key
|
|
||||||
downloads (which take no auth token, so they don't fit the client's
|
|
||||||
request path), and a few company and user extras.
|
|
||||||
|
|
||||||
Keep `COVERAGE.md` current when adding or removing an endpoint: it names
|
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
|
exact counts and route groups, so it goes stale quietly. The three coverage
|
||||||
|
|||||||
+256
-20
@@ -18,7 +18,7 @@ declares 34 `Route::resource` groups and 274 explicit route declarations
|
|||||||
> exists or what it returns.
|
> exists or what it returns.
|
||||||
|
|
||||||
**All 34 `Route::resource` groups are now wrapped**, plus `users` (which is
|
**All 34 `Route::resource` groups are now wrapped**, plus `users` (which is
|
||||||
not a resource group). 325 methods in total.
|
not a resource group). 375 methods in total.
|
||||||
|
|
||||||
## Resources with full CRUD + bulk
|
## Resources with full CRUD + bulk
|
||||||
|
|
||||||
@@ -127,6 +127,159 @@ same reason: the response isn't JSON.
|
|||||||
|
|
||||||
Convenience wrappers: `mark_invoice_sent` and `mark_invoice_paid`.
|
Convenience wrappers: `mark_invoice_sent` and `mark_invoice_paid`.
|
||||||
|
|
||||||
|
## Invitation-key downloads
|
||||||
|
|
||||||
|
The same four resources also answer two downloads keyed by **invitation key**
|
||||||
|
rather than record id — the per-contact token that appears in a client-portal
|
||||||
|
link. Eight routes, all wrapped:
|
||||||
|
|
||||||
|
| Route | Method | Returns |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `GET /invoice/{key}/download` | `download_invoice_by_invitation_key` | `application/pdf` |
|
||||||
|
| `GET /invoice/{key}/download_e_invoice` | `download_e_invoice_by_invitation_key` | `application/xml` |
|
||||||
|
| `GET /quote/{key}/download` | `download_quote_by_invitation_key` | `application/pdf` |
|
||||||
|
| `GET /quote/{key}/download_e_quote` | `download_e_quote_by_invitation_key` | `application/xml` |
|
||||||
|
| `GET /credit/{key}/download` | `download_credit_by_invitation_key` | `application/pdf` |
|
||||||
|
| `GET /credit/{key}/download_e_credit` | `download_e_credit_by_invitation_key` | `application/xml` |
|
||||||
|
| `GET /purchase_order/{key}/download` | `download_purchase_order_by_invitation_key` | `application/pdf` |
|
||||||
|
| `GET /purchase_order/{key}/download_e_purchase_order` | `download_e_purchase_order_by_invitation_key` | `application/xml` |
|
||||||
|
|
||||||
|
Two things about these are easy to get wrong, and both are pinned by tests.
|
||||||
|
The entity segment is **singular** — `invoice/…`, not `invoices/…` — which
|
||||||
|
is the only place in the API that departs from the plural CRUD path. And
|
||||||
|
despite being the download a *client* follows, they are **authenticated**:
|
||||||
|
the routes sit inside `routes/api.php`'s `api/v1` group under its
|
||||||
|
`token_auth` middleware, so they need the API token like any other call.
|
||||||
|
|
||||||
|
This file previously listed them as unwrapped on the grounds that they take
|
||||||
|
no auth token. That was wrong — it conflated them with the client-portal
|
||||||
|
routes in `routes/client.php` (`/client/invoice/{key}/download_pdf` and
|
||||||
|
friends), which are a separate route family, are not under `/api/v1`, and
|
||||||
|
remain unwrapped for that reason.
|
||||||
|
|
||||||
|
The smoke test's read tier exercises the invoice PDF, taking a key from the
|
||||||
|
`invitations` array the list route already returns. The `download_e_*` four
|
||||||
|
are not exercised: they render from the company's e-invoice settings, which a
|
||||||
|
test instance need not have configured.
|
||||||
|
|
||||||
|
An unknown or deleted key is a `400` carrying `no record found`. The
|
||||||
|
controllers return that directly rather than raising
|
||||||
|
`ModelNotFoundException`, so the message differs from the `No query results
|
||||||
|
for model` text a missing record produces elsewhere, but the status is the
|
||||||
|
same 400.
|
||||||
|
|
||||||
|
## Import and export
|
||||||
|
|
||||||
|
| Route | Method | Returns |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `POST /export` | `export` | `ExportJob` |
|
||||||
|
| `POST /preimport` | `preimport` | `ImportPreview` |
|
||||||
|
| `POST /import` | `import` | `MessageResponse` |
|
||||||
|
| `POST /import_json` | `import_json` | `MessageResponse` |
|
||||||
|
| `PUT /edocument/upload` | `upload_edocument` | `MessageResponse` |
|
||||||
|
|
||||||
|
A CSV import is two calls. `preimport` uploads the files — one per entity
|
||||||
|
type, as `files[<entity_type>]` — and answers with a `hash` plus the column
|
||||||
|
mapping the server suggests; `import` then starts the ingest from that hash.
|
||||||
|
The server holds the files against the hash for 20 minutes.
|
||||||
|
|
||||||
|
The route also accepts the files and the mapping together in one multipart
|
||||||
|
request. That form is deliberately not wrapped: a mapping is a nested
|
||||||
|
structure, and bracket-encoding nested structures into form fields is exactly
|
||||||
|
what this crate avoids by sending JSON (see [the PHP SDK
|
||||||
|
comparison](#compared-with-the-official-php-sdk)). With a `hash` present the
|
||||||
|
controller never looks at uploaded files, so `import` is plain JSON.
|
||||||
|
|
||||||
|
`upload_edocument` is *not* a document attachment, which is why it is not one
|
||||||
|
of the `upload_*` methods: `ExpenseController::edocument` parses each file and
|
||||||
|
creates **expenses**. Like the `/{id}/upload` routes it is registered as `PUT`
|
||||||
|
and so goes out as a `POST` with `_method=PUT`; unlike them the reply has no
|
||||||
|
`data` envelope.
|
||||||
|
|
||||||
|
All five queue background work, so a 200 means accepted, not finished. Rows
|
||||||
|
that fail to import are reported by email. `import_json` restores a company
|
||||||
|
from an `export` backup and **overwrites the target company**.
|
||||||
|
|
||||||
|
## E-invoicing
|
||||||
|
|
||||||
|
Seventeen routes, all wrapped. Mostly pass-throughs to Storecove, Invoice
|
||||||
|
Ninja's Peppol access point, so they return `serde_json::Value` — see
|
||||||
|
[Typed response gaps](#typed-response-gaps) for why.
|
||||||
|
|
||||||
|
| Route | Method | Returns |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `POST /einvoice/validateEntity` | `validate_einvoice` | `Value` |
|
||||||
|
| `POST /einvoice/configurations` | `einvoice_configurations` | `Value` |
|
||||||
|
| `GET /einvoice/quota` | `einvoice_quota` | `Value` |
|
||||||
|
| `GET /einvoice/health_check` | `einvoice_health_check` | `()` |
|
||||||
|
| `GET /einvoice/delivery_map` | `einvoice_delivery_map` | `Value` |
|
||||||
|
| `POST /einvoice/token/update` | `update_einvoice_token` | `()` (204) |
|
||||||
|
| `POST /einvoice/peppol/discovery` | `peppol_discovery` | `Value` |
|
||||||
|
| `POST /einvoice/peppol/legal_entity` | `peppol_legal_entity` | `Value` |
|
||||||
|
| `POST /einvoice/peppol/setup` | `peppol_setup` | `Option<Value>` |
|
||||||
|
| `POST /einvoice/peppol/disconnect` | `peppol_disconnect` | `Value` |
|
||||||
|
| `PUT /einvoice/peppol/update` | `peppol_update_legal_entity` | `Value` |
|
||||||
|
| `POST /einvoice/peppol/add_additional_legal_identifier` | `peppol_add_legal_identifier` | `MessageResponse` |
|
||||||
|
| `DELETE /einvoice/peppol/remove_additional_legal_identifier` | `peppol_remove_legal_identifier` | `Value` |
|
||||||
|
| `POST /einvoice/peppol/send` | `peppol_retry_send` | `MessageResponse` |
|
||||||
|
| `POST /einvoice/peppol/sg/c5/activate` | `peppol_sg_c5_activate` | `MessageResponse` |
|
||||||
|
| `POST /einvoice/peppol/sg/c5/deactivate` | `peppol_sg_c5_deactivate` | `MessageResponse` |
|
||||||
|
| `POST /einvoice/peppol/sg/c5/cancel` | `peppol_sg_c5_cancel` | `MessageResponse` |
|
||||||
|
|
||||||
|
Three things here behave unlike the rest of the API:
|
||||||
|
|
||||||
|
- **A failing check is a non-2xx.** `validate_einvoice` answers `422` when
|
||||||
|
validation finds problems, so "this invoice is not Peppol-ready" — an
|
||||||
|
ordinary result — arrives as `Error::Api`, and the per-entity detail in the
|
||||||
|
body is not reachable through the typed error. An `Ok` means it passed.
|
||||||
|
- **Error bodies are not the usual shape.** The Peppol handlers return
|
||||||
|
`data_get($response, 'message')` as the entire body, which can be a bare
|
||||||
|
string. `Error::Api`'s `message` then falls back to an excerpt and `errors`
|
||||||
|
is empty.
|
||||||
|
- **`peppol_setup` has two success shapes.** A `corppass_url` when the
|
||||||
|
connection needs a further browser step (Singapore's CorpPass), or a bare
|
||||||
|
`204` when it completed. Hence `Option<Value>` rather than treating the
|
||||||
|
empty body as a decode failure.
|
||||||
|
|
||||||
|
`peppol_remove_legal_identifier` is the only `DELETE` in the crate that
|
||||||
|
carries a request body.
|
||||||
|
|
||||||
|
## Integrations
|
||||||
|
|
||||||
|
| Route | Method | Returns |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `POST /quickbooks/sync` | `quickbooks_sync` | `()` (204) |
|
||||||
|
| `POST /quickbooks/action` | `quickbooks_action` | `()` (204) |
|
||||||
|
| `POST /quickbooks/disconnect` | `quickbooks_disconnect` | `()` (204) |
|
||||||
|
| `POST /quickbooks/reconnect_url` | `quickbooks_reconnect_url` | `Value` |
|
||||||
|
| `POST /stripe/update_payment_methods` | `stripe_update_payment_methods` | `MessageResponse` |
|
||||||
|
| `POST /stripe/import_customers` | `stripe_import_customers` | `MessageResponse` |
|
||||||
|
| `POST /stripe/verify` | `stripe_verify` | `Value` |
|
||||||
|
| `POST /stripe/disconnect/{id}` | `stripe_disconnect` | `Value` |
|
||||||
|
| `GET /calendar_connection` | `calendar_connection` | `Value` |
|
||||||
|
| `POST /calendar_connection/{provider}/complete` | `complete_calendar_connection` | `Value` |
|
||||||
|
| `GET /calendar_connection/calendars` | `calendar_connection_calendars` | `Value` |
|
||||||
|
| `GET /calendar_connection/events` | `calendar_connection_events` | `Value` |
|
||||||
|
| `PUT /calendar_connection/calendars` | `update_calendar_connection_calendars` | `Value` |
|
||||||
|
| `DELETE /calendar_connection` | `delete_calendar_connection` | `()` |
|
||||||
|
| `POST /yodlee/status/{account_number}` | `yodlee_account_status` | `Value` |
|
||||||
|
| `GET /nordigen/institutions` | `nordigen_institutions` | `Value` |
|
||||||
|
|
||||||
|
All four Stripe routes are **password-protected** — supply
|
||||||
|
`with_api_password` — and answer `403` for a non-admin.
|
||||||
|
|
||||||
|
These operate a connection that already exists. **Establishing one is a
|
||||||
|
browser flow and is not wrappable**: the OAuth authorize and callback legs
|
||||||
|
live outside the `api/v1` group, take no API token, and answer with
|
||||||
|
redirects. See [the gap list](#individual-endpoints-not-wrapped).
|
||||||
|
|
||||||
|
`yodlee_account_status` takes Yodlee's own `account_id`, as stored on the
|
||||||
|
matching `BankIntegration` — not an Invoice Ninja id. Its upstream route
|
||||||
|
carries a `@todo` questioning its own path, so treat it as liable to move.
|
||||||
|
`nordigen_institutions` covers GoCardless, which was Nordigen when the route
|
||||||
|
was named; an instance without GoCardless credentials answers `400` rather
|
||||||
|
than an empty list.
|
||||||
|
|
||||||
## Document uploads
|
## Document uploads
|
||||||
|
|
||||||
`PUT /{resource}/{id}/upload` exists for 16 resources, each getting
|
`PUT /{resource}/{id}/upload` exists for 16 resources, each getting
|
||||||
@@ -281,6 +434,60 @@ established that a purged record reads back as a **400**, not a 404. A
|
|||||||
password containing a control character cannot be a header value and goes out
|
password containing a control character cannot be a header value and goes out
|
||||||
as `X-API-PASSWORD-BASE64` instead.
|
as `X-API-PASSWORD-BASE64` instead.
|
||||||
|
|
||||||
|
## Logout, passkeys, and login
|
||||||
|
|
||||||
|
`/login`, `/logout`, and the passkey routes used to sit together under one
|
||||||
|
"Auth" gap. They don't share a shape: `/logout` and the passkey *settings*
|
||||||
|
routes are plain `token_auth` endpoints like everything else this crate
|
||||||
|
wraps, while `/login` sits in a different, unauthenticated middleware group
|
||||||
|
because the caller has no token yet to prove who they are.
|
||||||
|
|
||||||
|
| Endpoint | Method | Needs an existing client? |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `POST /logout` | `logout` | yes |
|
||||||
|
| `GET /settings/passkeys` | `list_passkeys` | yes |
|
||||||
|
| `DELETE /settings/passkeys/{id}` | `delete_passkey` | yes |
|
||||||
|
| `POST /login` | `InvoiceNinjaClient::login` (associated fn, not a method) | **no** |
|
||||||
|
|
||||||
|
**`logout` has a bigger blast radius than its name suggests.** The
|
||||||
|
controller doesn't revoke the caller's token — it regenerates *every*
|
||||||
|
`is_system` token belonging to the whole company, logging out every user
|
||||||
|
and device that shares it, including the one making the call.
|
||||||
|
|
||||||
|
**Passkey registration is not wrapped.** `POST /settings/passkeys/options`
|
||||||
|
and `POST /settings/passkeys` exist to drive a WebAuthn ceremony that only a
|
||||||
|
browser talking to a platform authenticator can complete — there is no
|
||||||
|
attestation object for this crate to produce standalone. Listing and
|
||||||
|
deleting an existing credential need no such ceremony, so those are the two
|
||||||
|
operations offered.
|
||||||
|
|
||||||
|
**`login` returns the server's full "first load" payload** — nested
|
||||||
|
company, clients, invoices, designs, and more under each `CompanyUser`
|
||||||
|
entry — because it reuses the same transformer as a browser's initial page
|
||||||
|
load. This crate does not model that nested tree; [`LoginCompanyUser`]
|
||||||
|
types only `is_owner`, `is_admin`, and the `token` it exists to produce,
|
||||||
|
and leaves the rest in `extra`, losslessly. [`LoginCompanyUser::token`]
|
||||||
|
is what a caller actually needs — feed it to
|
||||||
|
[`InvoiceNinjaClient::new`] or [`InvoiceNinjaClient::hosted`].
|
||||||
|
|
||||||
|
`login` optionally takes a one-time password for accounts with two-factor
|
||||||
|
auth enabled. It does not support passkey login, SSO (`oauth_login`,
|
||||||
|
`oidc/*`), `/login/precheck`, or a self-hosted instance's optional
|
||||||
|
`X-API-SECRET` header — each needs a ceremony, a redirect, or a header this
|
||||||
|
single email/password call does not send.
|
||||||
|
|
||||||
|
`/refresh` and `/refresh_react` were in the old "Auth" bucket too, but
|
||||||
|
they're not an authentication concept: `BaseController::refreshResponse`
|
||||||
|
is the same bulk, `updated_at`-filtered account dump as `/login`'s first
|
||||||
|
load. That's the sync-engine shape [`GOALS.md`](GOALS.md) already parks as
|
||||||
|
a separate open question, not something to bolt onto auth — not
|
||||||
|
implemented, for the same reason.
|
||||||
|
|
||||||
|
`/connected_account` (linking a Gmail OAuth account) was also filed under
|
||||||
|
"Auth". It's an integration-linking endpoint, not login/logout, so it
|
||||||
|
belongs with Stripe/QuickBooks under [Integrations](#integrations) above —
|
||||||
|
also not implemented.
|
||||||
|
|
||||||
## Per-resource extras
|
## Per-resource extras
|
||||||
|
|
||||||
Routes that sit beside a resource's CRUD set, each with its own response
|
Routes that sit beside a resource's CRUD set, each with its own response
|
||||||
@@ -318,10 +525,8 @@ smoke test does not exercise them.
|
|||||||
### Nothing is blocked on a dependency
|
### Nothing is blocked on a dependency
|
||||||
|
|
||||||
Multipart uploads used to be, and are now wrapped — see [Document
|
Multipart uploads used to be, and are now wrapped — see [Document
|
||||||
uploads](#document-uploads). One upload route is deliberately left out:
|
uploads](#document-uploads) and [Import and
|
||||||
`PUT /edocument/upload` is not a document attachment. It imports an expense
|
export](#import-and-export).
|
||||||
from an e-document (`ExpenseController::edocument`), and is better treated as
|
|
||||||
an import endpoint alongside the others under *Import/export* below.
|
|
||||||
|
|
||||||
### Resource groups not wrapped
|
### Resource groups not wrapped
|
||||||
|
|
||||||
@@ -340,26 +545,45 @@ check `routes/api.php`).
|
|||||||
- **User extras** (not password-protected, unlike the rest of `/users`) —
|
- **User extras** (not password-protected, unlike the rest of `/users`) —
|
||||||
`POST /users/{id}/disconnect_mailer`, `POST /users/{id}/disconnect_oauth`,
|
`POST /users/{id}/disconnect_mailer`, `POST /users/{id}/disconnect_oauth`,
|
||||||
`POST /user/{id}/reconfirm`
|
`POST /user/{id}/reconfirm`
|
||||||
- **Auth** — `/login`, `/logout`, `/refresh`, `/connected_account`,
|
- **Auth** — see [Logout, passkeys, and login](#logout-passkeys-and-login)
|
||||||
passkey routes
|
above for what's covered (`login`, `logout`, passkey list/delete) and
|
||||||
- **Import/export** — `/import`, `/preimport`, `/import_json`, `/export`,
|
what isn't: `/login/precheck`, `oauth_login`, `oidc/*`, passkey
|
||||||
`/migration/start`
|
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`,
|
- **Invoice extras** — `delivery_note`, `payment_schedule`,
|
||||||
`update_reminders`
|
`update_reminders`
|
||||||
- **Public invitation-key downloads** — `GET /{entity}/{invitation_key}/download`
|
- **Client-portal invitation downloads** — the `routes/client.php` family:
|
||||||
for `invoice`, `quote`, `credit`, and `purchase_order` (note the singular
|
`/client/invoice/{key}/download_pdf`, the `download_e_*` variants for
|
||||||
path segment), plus the matching e-invoice variants `download_e_invoice`,
|
quotes and credits, `/client/{entity}/{key}/download`, and
|
||||||
`download_e_quote`, `download_e_credit`,
|
`/client/documents/{hash}/hashed`. These are not under `/api/v1`, so they
|
||||||
`download_e_purchase_order`. These are invitation-key based and take no
|
don't fit a `base_url` that ends there. Note these are *not* the
|
||||||
auth token, so they don't fit the client's authenticated request path.
|
invitation-key downloads documented above, which are authenticated
|
||||||
- **Integrations** — Stripe, QuickBooks, Apple in-app purchase, PostMark,
|
`/api/v1` routes and are wrapped.
|
||||||
einvoice (17 routes), calendar_connection, self-update, templates, hooks,
|
- **Integration routes that cannot take an API token** — the OAuth legs of
|
||||||
Yodlee (`/yodlee/*`), Nordigen (`/nordigen/institutions`)
|
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`,
|
- **Misc** — `/company_users`, `/company_ledger`, `/client_statement`,
|
||||||
`/claim_license`, `/check_license`, `/check_subdomain`, `/one_time_token`,
|
`/claim_license`, `/check_license`, `/check_subdomain`, `/one_time_token`,
|
||||||
`/last_error`, `/support/messages/send`, `/verify/*`, `/accounts/{id}`,
|
`/last_error`, `/support/messages/send`, `/verify/*`, `/accounts/{id}`,
|
||||||
`/feedback`, `/filters/{entity}`, `/scheduler`, `/mailer/check`,
|
`/feedback`, `/filters/{entity}`, `/scheduler`, `/mailer/check`,
|
||||||
`/smtp/check`, `/password_timeout`, `/refresh_react`, `/webcron`,
|
`/smtp/check`, `/password_timeout`, `/webcron`,
|
||||||
`/token_hash_router`, `/reactivate_email/{bounce_id}`,
|
`/token_hash_router`, `/reactivate_email/{bounce_id}`,
|
||||||
`/protected_download/{hash}`, `/confirm_purchase`,
|
`/protected_download/{hash}`, `/confirm_purchase`,
|
||||||
`/exports/preview/{hash}`, `/templates/preview/{hash}`
|
`/exports/preview/{hash}`, `/templates/preview/{hash}`
|
||||||
@@ -372,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`,
|
each has a generic counterpart — `statics_as`, `search_as`,
|
||||||
`health_check_as` — for pinning a shape they leave untyped.
|
`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:
|
Two remain generic on purpose:
|
||||||
|
|
||||||
- **`chart`** — the eleven chart endpoints share no common response shape
|
- **`chart`** — the eleven chart endpoints share no common response shape
|
||||||
@@ -406,7 +641,8 @@ top of a shared `BaseEntity` offering `all`, `get`, `create`, `update`,
|
|||||||
`recurring_quotes`, `recurring_expenses`, `bank_transaction_rules`,
|
`recurring_quotes`, `recurring_expenses`, `bank_transaction_rules`,
|
||||||
`company_gateways`, `designs`, `locations`, `tags`, `task_schedulers`,
|
`company_gateways`, `designs`, `locations`, `tags`, `task_schedulers`,
|
||||||
`tokens`, `system_logs`, documents, per-entity actions (`mark_sent`,
|
`tokens`, `system_logs`, documents, per-entity actions (`mark_sent`,
|
||||||
`mark_paid`, `clone_to_*`, …), per-entity PDF download, reports, charts,
|
`mark_paid`, `clone_to_*`, …), per-entity PDF download by id and by
|
||||||
|
invitation key, the XML e-documents, reports, charts,
|
||||||
search, `send_email`, `refund_payment`, `ping`, `health_check`, automatic
|
search, `send_email`, `refund_payment`, `ping`, `health_check`, automatic
|
||||||
pagination (`list_all_*`), and `X-API-PASSWORD` support for the
|
pagination (`list_all_*`), and `X-API-PASSWORD` support for the
|
||||||
password-protected routes.
|
password-protected routes.
|
||||||
|
|||||||
Generated
+2
-2
@@ -528,8 +528,8 @@ dependencies = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "invoiceninjars"
|
name = "invoiceninja-sdk"
|
||||||
version = "0.1.0"
|
version = "0.2.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
|
|||||||
+6
-2
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "invoiceninjars"
|
name = "invoiceninja-sdk"
|
||||||
version = "0.1.0"
|
version = "0.2.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
# Set by the dependency graph rather than by this crate's own code: reqwest
|
# Set by the dependency graph rather than by this crate's own code: reqwest
|
||||||
# pulls in url -> idna -> idna_adapter -> icu_*, and the icu crates require
|
# pulls in url -> idna -> idna_adapter -> icu_*, and the icu crates require
|
||||||
@@ -10,6 +10,10 @@ rust-version = "1.88"
|
|||||||
description = "An async Rust client for the Invoice Ninja v5 API"
|
description = "An async Rust client for the Invoice Ninja v5 API"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
repository = "https://github.com/awkawb1/invoiceninja-sdk"
|
repository = "https://github.com/awkawb1/invoiceninja-sdk"
|
||||||
|
# Slugs checked against https://crates.io/api/v1/category_slugs — crates.io
|
||||||
|
# rejects a publish with an unrecognised one, so these aren't freeform.
|
||||||
|
categories = ["api-bindings", "web-programming::http-client", "asynchronous"]
|
||||||
|
keywords = ["invoiceninja", "invoicing", "billing", "async", "sdk"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
reqwest = { version = "0.12", default-features = false, features = ["json", "native-tls", "multipart"] }
|
reqwest = { version = "0.12", default-features = false, features = ["json", "native-tls", "multipart"] }
|
||||||
|
|||||||
@@ -85,6 +85,38 @@ Invoice Ninja account to contribute.
|
|||||||
comparison in `COVERAGE.md`. The divergences — JSON instead of
|
comparison in `COVERAGE.md`. The divergences — JSON instead of
|
||||||
`form_params`, keeping the whole validation `errors` map, exposing bulk
|
`form_params`, keeping the whole validation `errors` map, exposing bulk
|
||||||
`download` only on PDF-rendering entities — are deliberate.
|
`download` only on PDF-rendering entities — are deliberate.
|
||||||
|
- **No CLI, and no binary at all, in this package.** A command-line client
|
||||||
|
over this library is a reasonable thing to want — it is the obvious way to
|
||||||
|
ask "what does this endpoint actually return", which matters here because
|
||||||
|
the e-invoicing and integration methods are typed as
|
||||||
|
`serde_json::Value` and were verified against mocks rather than a live
|
||||||
|
service. It belongs in a sibling crate (`invoiceninja-sdk-cli`) in a
|
||||||
|
workspace, not in this one, for three reasons:
|
||||||
|
|
||||||
|
- *Dependencies.* Every entry in `Cargo.toml` is there because the library
|
||||||
|
needs it, and three of the five are already in the tree via `reqwest`.
|
||||||
|
An argument parser would be the first dependency that is not
|
||||||
|
load-bearing for the library, along with the project's first optional
|
||||||
|
dependency and first `[features]` section.
|
||||||
|
- *MSRV.* Not a break today — `clap` 4.6.7 declares 1.85, under this
|
||||||
|
crate's 1.88 (checked 2026-10-25). The cost is the coupling: a second
|
||||||
|
floor that moves on someone else's release schedule, in a project where
|
||||||
|
three places must agree on the number and a documented procedure exists
|
||||||
|
for rechecking it. A separate crate keeps it out of the library's graph.
|
||||||
|
- *Versioning.* Pre-1.0 this crate tells callers to pin an exact version
|
||||||
|
and expect breaking changes in minor releases. A CLI's flags are a
|
||||||
|
compatibility surface too, and one version number would force the two to
|
||||||
|
move in lockstep. `cargo install invoiceninja-sdk` silently needing
|
||||||
|
`--features cli` is a smaller version of the same problem.
|
||||||
|
|
||||||
|
For ad-hoc probing, `examples/` already covers it and costs nothing:
|
||||||
|
`capture_shapes`, `seed_sample_data` and `smoke_test` are CLI-shaped
|
||||||
|
programs configured from environment variables, with the
|
||||||
|
`IN_SMOKE_WRITE_HOST` guard to copy for anything that writes.
|
||||||
|
|
||||||
|
Same shape of decision as the `updated_at` sync engine in the open
|
||||||
|
questions: a different product from this crate, recorded so the option is
|
||||||
|
not forgotten.
|
||||||
- **No sync/blocking API.** Async only.
|
- **No sync/blocking API.** Async only.
|
||||||
- **No identity map, lazy relationship loading, or transparent entity
|
- **No identity map, lazy relationship loading, or transparent entity
|
||||||
caching.** Three separate refusals with one theme — the client stays
|
caching.** Three separate refusals with one theme — the client stays
|
||||||
@@ -121,7 +153,7 @@ releases. `CHANGELOG.md` records them, and breaking changes should be
|
|||||||
legible there rather than discovered at compile time.
|
legible there rather than discovered at compile time.
|
||||||
|
|
||||||
**Gitea is the source of truth; GitHub is a one-way mirror.** The canonical
|
**Gitea is the source of truth; GitHub is a one-way mirror.** The canonical
|
||||||
repository is `gitea.awkawb.cloud/awkawb/invoiceninja`. A Gitea push mirror
|
repository is `gitea.awkawb.cloud/awkawb/invoiceninja-sdk`. A Gitea push mirror
|
||||||
copies every commit to <https://github.com/awkawb1/invoiceninja-sdk>, which
|
copies every commit to <https://github.com/awkawb1/invoiceninja-sdk>, which
|
||||||
is what `Cargo.toml` points at. Nothing should be written to the GitHub
|
is what `Cargo.toml` points at. Nothing should be written to the GitHub
|
||||||
copy: the next sync overwrites it, so a merged pull request there would
|
copy: the next sync overwrites it, so a merged pull request there would
|
||||||
@@ -137,9 +169,14 @@ without checking — see the note in `CLAUDE.md`.
|
|||||||
|
|
||||||
## Roadmap: closing the coverage gaps
|
## Roadmap: closing the coverage gaps
|
||||||
|
|
||||||
All 34 `Route::resource` groups are wrapped. Full coverage is the target, so
|
**This roadmap is now closed** — every item below is done, and every route an
|
||||||
`COVERAGE.md`'s gap list is a to-do list, not a permanent boundary. Roughly
|
API-token client can reach is wrapped. It is kept rather than deleted because
|
||||||
in order of value over effort:
|
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
|
**Done:** multipart document uploads, for the 16 resources that have the
|
||||||
route (`upload_client`, `upload_invoice`, …). This earlier claimed it would
|
route (`upload_client`, `upload_invoice`, …). This earlier claimed it would
|
||||||
@@ -159,42 +196,107 @@ suggests. That is the case for keeping the real-server tier.
|
|||||||
|
|
||||||
**Done:** the password-protected actions — client purge and merge, vendor
|
**Done:** the password-protected actions — client purge and merge, vendor
|
||||||
merge, company purge, user invite and purge, and the blank-user template (8
|
merge, company purge, user invite and purge, and the blank-user template (8
|
||||||
methods). They are verified against the upstream controllers and mocks only;
|
methods). The smoke test's password tier (`IN_API_PASSWORD`) has since been
|
||||||
the smoke test has a password tier for the first real-server run
|
run against a disposable 5.13.50 instance, on 2026-10-24, with all four
|
||||||
(`IN_API_PASSWORD`), which has not happened yet. The claim here that
|
checks passing — but only after its own post-purge assertion was fixed, which
|
||||||
`with_api_password` already made them reachable was not quite true: a
|
had checked for a 404 and passed solely because it had never been run. The
|
||||||
password with a control character was refused locally.
|
claim here that `with_api_password` already made them reachable was not quite
|
||||||
|
true: a password with a control character was refused locally.
|
||||||
|
|
||||||
**Done:** activities, email history and previews (14 methods, two new
|
**Done:** activities, email history and previews (14 methods, two new
|
||||||
models). The email history stays raw JSON, since no instance here had sent
|
models). The email history stays raw JSON, since no instance here had sent
|
||||||
mail to take a shape from; promoting it to a model needs a sample. Several
|
mail to take a shape from; promoting it to a model needs a sample. Several
|
||||||
previews are unverified against a real server (see `COVERAGE.md`).
|
previews are unverified against a real server (see `COVERAGE.md`).
|
||||||
|
|
||||||
Still to do:
|
**Done:** the invitation-key downloads — the PDF and the XML e-document for
|
||||||
|
invoices, quotes, credits and purchase orders, keyed by a contact's
|
||||||
|
invitation key (8 methods). This item used to read "eight routes that take no
|
||||||
|
auth token, so they do not fit the authenticated request path and need a
|
||||||
|
different entry point." That was wrong on every count, and wrong in the way
|
||||||
|
goal 6 exists to catch: the premise came from reading the route *names*
|
||||||
|
rather than the route file. All eight sit inside `routes/api.php`'s `api/v1`
|
||||||
|
group under its `token_auth` middleware — the controllers' own OpenAPI blocks
|
||||||
|
declare `X-API-TOKEN` — so they needed no new entry point and were two dozen
|
||||||
|
lines of macro. What the item had actually described is the client-portal
|
||||||
|
family in `routes/client.php`, which is genuinely token-less, is not under
|
||||||
|
`/api/v1`, and stays unwrapped for that reason.
|
||||||
|
|
||||||
5. **Public invitation-key downloads** — eight routes that take no auth
|
**Done:** import/export and the integrations — `/import`, `/preimport`,
|
||||||
token, so they do not fit the authenticated request path and need a
|
`/import_json`, `/export`, `edocument/upload`, einvoice (17 routes),
|
||||||
different entry point.
|
QuickBooks, Stripe, calendar, Yodlee and Nordigen (38 methods). This was the
|
||||||
6. **Import/export and the integrations** — `/import`, `/export`,
|
last item, so **the roadmap is closed**: every route in `routes/api.php` that
|
||||||
`edocument/upload`, Stripe, QuickBooks, einvoice (17 routes), Yodlee,
|
an API-token client can reach is now wrapped. It did land in pieces, as the
|
||||||
Nordigen, calendar. The largest block and the least SDK-shaped; worth
|
item predicted — import/export first, then the integrations.
|
||||||
doing last and possibly in pieces.
|
|
||||||
|
|
||||||
Keep `COVERAGE.md` current as these land — it names exact counts, so it goes
|
**Done:** the auth routes, once split apart — the single "auth routes"
|
||||||
stale quietly.
|
label had quietly merged four unrelated things. `logout` and the passkey
|
||||||
|
list/delete routes sit behind the same `token_auth` middleware as every
|
||||||
|
other wrapped endpoint, so they needed no new client type, just methods
|
||||||
|
(and a documented warning that `logout` rotates every token on the whole
|
||||||
|
company, not just the caller's). `login` *does* need a different entry
|
||||||
|
point — there is no token yet to build an `InvoiceNinjaClient` with — but
|
||||||
|
that turned out to be a standalone associated function, not a second
|
||||||
|
client type: `InvoiceNinjaClient::login` trades an email and password for
|
||||||
|
a `LoginResponse` carrying a token, which then goes to `new` or `hosted`
|
||||||
|
as normal. Passkey *registration* and passkey *login* stay unwrapped
|
||||||
|
because they're WebAuthn ceremonies a browser has to drive; `/refresh` and
|
||||||
|
`/refresh_react` turned out to be the bulk `updated_at` sync dump already
|
||||||
|
parked below, not an auth concept, so they stay out for that reason
|
||||||
|
instead; and `/connected_account` (Gmail OAuth linking) was filed under
|
||||||
|
the wrong heading — it belongs with the Integrations done item above. See
|
||||||
|
`COVERAGE.md`'s "Logout, passkeys, and login" section for the detail.
|
||||||
|
|
||||||
|
As the item also predicted, it is the least SDK-shaped part of the API, and
|
||||||
|
the typing reflects that: most of these return `serde_json::Value`, because
|
||||||
|
their replies are pass-throughs from Storecove, Stripe, Yodlee or GoCardless,
|
||||||
|
or arrays assembled for Invoice Ninja's React client. That is a deliberate
|
||||||
|
application of the rule in goal 2 — a wrong concrete type is a hard decode
|
||||||
|
error, where `Value` is lossless — and not a shortcut to revisit without a
|
||||||
|
reason. `COVERAGE.md` records it as the crate's largest untyped surface.
|
||||||
|
|
||||||
|
Two things in this block are *not* wrapped, and cannot be. The OAuth legs of
|
||||||
|
the QuickBooks and calendar flows take no API token and answer with browser
|
||||||
|
redirects, so connecting an account stays a browser flow; the methods here
|
||||||
|
operate a connection that already exists. And `POST /quickbooks/settings` is
|
||||||
|
a registered route with no controller method behind it, so it 500s — there is
|
||||||
|
nothing to wrap until upstream adds the handler.
|
||||||
|
|
||||||
|
What remains unwrapped is now only what each entry in `COVERAGE.md`'s gap
|
||||||
|
list explains: the passkey and SSO ceremonies and `/login/precheck`
|
||||||
|
(each needs something — a browser, a redirect — this client doesn't have),
|
||||||
|
`/refresh`/`/refresh_react` (the sync-engine shape parked below), the
|
||||||
|
client-portal family, the provider webhooks, and a few company and user
|
||||||
|
extras. Keep that file current — it names exact counts, so it goes stale
|
||||||
|
quietly.
|
||||||
|
|
||||||
|
**Decided: `chart` stays generic.** The premise in the old open question —
|
||||||
|
"eleven chart endpoints share no response shape" — undersold the problem.
|
||||||
|
Reading `ChartController`/`ChartService` directly: the eleven routes are
|
||||||
|
nine distinct bodies (`totals`/`totalsV2` and `chart_summary`/
|
||||||
|
`chart_summaryV2` are pairs of the same implementation), and six of those
|
||||||
|
nine *do* share an outer idiom — an object keyed by a company's own
|
||||||
|
currency ids, plus a sentinel `999` key for the cross-currency aggregate.
|
||||||
|
But the leaf rows inside that shape are raw `DB::select` results with no
|
||||||
|
model behind them, and they don't agree with each other: within `totals()`
|
||||||
|
alone, the `invoices` leaf carries `invoiced_amount`, `revenue` carries
|
||||||
|
`paid_to_date`, `outstanding` carries `amount` *and* `outstanding_count`,
|
||||||
|
and `chart_summary`'s four leaves differ again. That is goal 2's
|
||||||
|
"ambiguous upstream type" problem — compounded by `SUM()` often coming
|
||||||
|
back from the DB driver as a numeric string — repeated dozens of times
|
||||||
|
over. The remaining three bodies (`cashflow_forecast`,
|
||||||
|
`client_payment_analytics`, `project_analytics`/`project_burnup`) delegate
|
||||||
|
to standalone service classes with no shape in common with each other or
|
||||||
|
with the currency-keyed family, and `calculated_fields` returns whichever
|
||||||
|
scalar or array type its ~20-way `field` match picks, so there is no fixed
|
||||||
|
shape to type at all. Modeling the shared outer envelope would still leave
|
||||||
|
dozens of hand-maintained, DB-column-shaped leaf structs for marginal
|
||||||
|
benefit over a caller defining their own `T` for the one endpoint they
|
||||||
|
use — which `chart::<T>` already lets them do.
|
||||||
|
|
||||||
## Open questions
|
## Open questions
|
||||||
|
|
||||||
These are genuinely undecided. Deciding one is a change to this file.
|
These are genuinely undecided. Deciding one is a change to this file.
|
||||||
|
|
||||||
- **Auth routes** (`/login`, `/logout`, `/refresh`, passkeys) are in the gap
|
|
||||||
list, but they are a different authentication model from the API token
|
|
||||||
this client is built around. Including them may mean a second client type
|
|
||||||
rather than more methods on this one. Not obviously in scope.
|
|
||||||
- **`chart` is still generic** over `T: DeserializeOwned`, because the
|
|
||||||
eleven chart endpoints share no response shape. Per-chart structs would
|
|
||||||
be eleven small models of unstable payloads; unclear whether that earns
|
|
||||||
its keep.
|
|
||||||
- **Large bodies are buffered entirely into memory.** PDF downloads and CSV
|
- **Large bodies are buffered entirely into memory.** PDF downloads and CSV
|
||||||
report exports come back as `Vec<u8>`, and an uploaded `FileUpload` holds
|
report exports come back as `Vec<u8>`, and an uploaded `FileUpload` holds
|
||||||
its whole contents until the request finishes. Streaming would be better
|
its whole contents until the request finishes. Streaming would be better
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# invoiceninjars
|
# invoiceninja-sdk
|
||||||
|
|
||||||
An async Rust client for the [Invoice Ninja](https://www.invoiceninja.com/) v5 API.
|
An async Rust client for the [Invoice Ninja](https://www.invoiceninja.com/) v5 API.
|
||||||
|
|
||||||
@@ -9,7 +9,7 @@ Requires Rust 1.88 or newer.
|
|||||||
> [`GOALS.md`](GOALS.md) covers what the crate is for and what it is not.
|
> [`GOALS.md`](GOALS.md) covers what the crate is for and what it is not.
|
||||||
|
|
||||||
> **Development happens on
|
> **Development happens on
|
||||||
> [Gitea](https://gitea.awkawb.cloud/awkawb/invoiceninja); the
|
> [Gitea](https://gitea.awkawb.cloud/awkawb/invoiceninja-sdk); the
|
||||||
> [GitHub copy](https://github.com/awkawb1/invoiceninja-sdk) is a read-only
|
> [GitHub copy](https://github.com/awkawb1/invoiceninja-sdk) is a read-only
|
||||||
> mirror.** Anything pushed to GitHub is overwritten by the next sync, so
|
> mirror.** Anything pushed to GitHub is overwritten by the next sync, so
|
||||||
> please open issues and pull requests on Gitea.
|
> please open issues and pull requests on Gitea.
|
||||||
@@ -33,7 +33,7 @@ nix develop .#msrv --command cargo test
|
|||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
use invoiceninjars::{InvoiceNinjaClient, models::ListParams};
|
use invoiceninja_sdk::{InvoiceNinjaClient, models::ListParams};
|
||||||
|
|
||||||
// Hosted app:
|
// Hosted app:
|
||||||
let client = InvoiceNinjaClient::hosted(std::env::var("IN_API_TOKEN")?)?;
|
let client = InvoiceNinjaClient::hosted(std::env::var("IN_API_TOKEN")?)?;
|
||||||
@@ -75,7 +75,7 @@ with exponential backoff. Both are configurable:
|
|||||||
|
|
||||||
```rust
|
```rust
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use invoiceninjars::RetryPolicy;
|
use invoiceninja_sdk::RetryPolicy;
|
||||||
|
|
||||||
let client = InvoiceNinjaClient::hosted(token)?
|
let client = InvoiceNinjaClient::hosted(token)?
|
||||||
.with_timeout(Duration::from_secs(300))
|
.with_timeout(Duration::from_secs(300))
|
||||||
@@ -100,7 +100,7 @@ IN_API_TOKEN=... cargo run --example list_clients
|
|||||||
|
|
||||||
## Coverage
|
## Coverage
|
||||||
|
|
||||||
Every one of the API's 34 resource groups, plus users — 325 methods. Each
|
Every one of the API's 34 resource groups, plus users — 375 methods. Each
|
||||||
resource has `list`, `list_all`, `get`, `create`, `update`, `delete`, and
|
resource has `list`, `list_all`, `get`, `create`, `update`, `delete`, and
|
||||||
(where the API offers one) `bulk`:
|
(where the API offers one) `bulk`:
|
||||||
|
|
||||||
@@ -114,8 +114,11 @@ resource has `list`, `list_all`, `get`, `create`, `update`, `delete`, and
|
|||||||
| Admin | Companies, Users, Group Settings, Tokens, System Logs |
|
| Admin | Companies, Users, Group Settings, Tokens, System Logs |
|
||||||
| Settings | Tax Rates, Payment Terms, Webhooks, Designs, Locations, Tags |
|
| Settings | Tax Rates, Payment Terms, Webhooks, Designs, Locations, Tags |
|
||||||
|
|
||||||
Plus documents, entity actions and PDF downloads, reports, charts, search,
|
Plus documents, entity actions and PDF downloads — by record id, or by
|
||||||
statics, email sending, refunds, and health checks.
|
invitation key for the PDF and XML a given contact receives — reports,
|
||||||
|
charts, search, statics, email sending, refunds, health checks, logout,
|
||||||
|
passkey management, and a standalone `login` to exchange credentials for
|
||||||
|
a token.
|
||||||
|
|
||||||
**See [COVERAGE.md](COVERAGE.md) for the full endpoint-by-endpoint
|
**See [COVERAGE.md](COVERAGE.md) for the full endpoint-by-endpoint
|
||||||
inventory**, including everything that is deliberately *not* wrapped and why.
|
inventory**, including everything that is deliberately *not* wrapped and why.
|
||||||
@@ -163,7 +166,7 @@ only way the API creates one. Sixteen resources support it, as
|
|||||||
`upload_client`, `upload_invoice`, `upload_expense`, and so on:
|
`upload_client`, `upload_invoice`, `upload_expense`, and so on:
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
use invoiceninjars::FileUpload;
|
use invoiceninja_sdk::FileUpload;
|
||||||
|
|
||||||
let receipt = FileUpload::new("receipt.pdf", std::fs::read("receipt.pdf")?)
|
let receipt = FileUpload::new("receipt.pdf", std::fs::read("receipt.pdf")?)
|
||||||
.mime("application/pdf");
|
.mime("application/pdf");
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
use invoiceninjars::models::ListParams;
|
use invoiceninja_sdk::models::ListParams;
|
||||||
use invoiceninjars::InvoiceNinjaClient;
|
use invoiceninja_sdk::InvoiceNinjaClient;
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> invoiceninjars::Result<()> {
|
async fn main() -> invoiceninja_sdk::Result<()> {
|
||||||
let base_url =
|
let base_url =
|
||||||
std::env::var("IN_BASE_URL").unwrap_or_else(|_| "https://invoicing.co/api/v1".into());
|
std::env::var("IN_BASE_URL").unwrap_or_else(|_| "https://invoicing.co/api/v1".into());
|
||||||
let token = std::env::var("IN_API_TOKEN").expect("set IN_API_TOKEN");
|
let token = std::env::var("IN_API_TOKEN").expect("set IN_API_TOKEN");
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
use std::future::Future;
|
use std::future::Future;
|
||||||
use std::process::ExitCode;
|
use std::process::ExitCode;
|
||||||
|
|
||||||
use invoiceninjars::{Error, InvoiceNinjaClient};
|
use invoiceninja_sdk::{Error, InvoiceNinjaClient};
|
||||||
use serde::de::DeserializeOwned;
|
use serde::de::DeserializeOwned;
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
@@ -34,7 +34,7 @@ fn describe(error: &Error) -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Runs one creation, reporting the outcome and returning the new id.
|
/// Runs one creation, reporting the outcome and returning the new id.
|
||||||
async fn step<T>(name: &str, call: impl Future<Output = invoiceninjars::Result<T>>) -> Option<T> {
|
async fn step<T>(name: &str, call: impl Future<Output = invoiceninja_sdk::Result<T>>) -> Option<T> {
|
||||||
match call.await {
|
match call.await {
|
||||||
Ok(created) => {
|
Ok(created) => {
|
||||||
println!(" created {name}");
|
println!(" created {name}");
|
||||||
|
|||||||
+44
-7
@@ -37,10 +37,10 @@ use std::fmt::Display;
|
|||||||
use std::process::ExitCode;
|
use std::process::ExitCode;
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
use invoiceninjars::models::{
|
use invoiceninja_sdk::models::{
|
||||||
Client, ListParams, Location, PurchaseOrder, RecurringInvoice, Vendor, Webhook,
|
Client, ListParams, Location, PurchaseOrder, RecurringInvoice, Vendor, Webhook,
|
||||||
};
|
};
|
||||||
use invoiceninjars::{Error, FileUpload, InvoiceNinjaClient};
|
use invoiceninja_sdk::{Error, FileUpload, InvoiceNinjaClient};
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
@@ -332,6 +332,42 @@ async fn read_checks(client: &InvoiceNinjaClient, raw: &Raw, report: &mut Report
|
|||||||
Err(e) => report.fail("live_preview", describe(&e)),
|
Err(e) => report.fail("live_preview", describe(&e)),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The invitation-key download, on the singular `invoice/{key}/download`
|
||||||
|
// route. Worth a real-server check for one specific reason: this was
|
||||||
|
// listed as unwrappable for months on the belief that it takes no auth
|
||||||
|
// token, and the route file says otherwise. If that reading is still
|
||||||
|
// wrong, the token we send is either ignored or rejected, and this is
|
||||||
|
// where it shows. The key comes from the invoice's own `invitations`
|
||||||
|
// array, which the list route returns without an `include`.
|
||||||
|
//
|
||||||
|
// Only the PDF, not the e-document: `download_e_invoice` renders from
|
||||||
|
// the company's e-invoice settings, so an instance with none configured
|
||||||
|
// would fail here for a reason that is not the client's fault.
|
||||||
|
match client.list_invoices(&small).await {
|
||||||
|
Ok(rows) => match rows.iter().find_map(|i| {
|
||||||
|
i.extra
|
||||||
|
.get("invitations")?
|
||||||
|
.as_array()?
|
||||||
|
.first()?
|
||||||
|
.get("key")?
|
||||||
|
.as_str()
|
||||||
|
}) {
|
||||||
|
Some(key) => match client.download_invoice_by_invitation_key(key).await {
|
||||||
|
Ok(pdf) => report.check(
|
||||||
|
"download_invoice_by_invitation_key",
|
||||||
|
pdf.starts_with(b"%PDF"),
|
||||||
|
format!("{} byte(s), starts with the PDF magic", pdf.len()),
|
||||||
|
),
|
||||||
|
Err(e) => report.fail("download_invoice_by_invitation_key", describe(&e)),
|
||||||
|
},
|
||||||
|
None => report.pass(
|
||||||
|
"download_invoice_by_invitation_key",
|
||||||
|
"skipped: no invoice with an invitation on this instance",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
Err(e) => report.fail("download_invoice_by_invitation_key", describe(&e)),
|
||||||
|
}
|
||||||
|
|
||||||
list!(report, "list_clients", client.list_clients(&small));
|
list!(report, "list_clients", client.list_clients(&small));
|
||||||
list!(report, "list_invoices", client.list_invoices(&small));
|
list!(report, "list_invoices", client.list_invoices(&small));
|
||||||
list!(report, "list_quotes", client.list_quotes(&small));
|
list!(report, "list_quotes", client.list_quotes(&small));
|
||||||
@@ -817,11 +853,12 @@ async fn password_checks(client: &InvoiceNinjaClient, report: &mut Report, passw
|
|||||||
// ModelNotFoundException to a 400 and keeps 404 for a missing route. The
|
// ModelNotFoundException to a 400 and keeps 404 for a missing route. The
|
||||||
// message is checked too, so a 400 raised for some other reason fails.
|
// message is checked too, so a 400 raised for some other reason fails.
|
||||||
match client.get_client(&keep).await {
|
match client.get_client(&keep).await {
|
||||||
Err(Error::Api { status, message, .. })
|
Err(Error::Api {
|
||||||
if status.as_u16() == 400 && message.contains("No query results") =>
|
status, message, ..
|
||||||
{
|
}) if status.as_u16() == 400 && message.contains("No query results") => report.pass(
|
||||||
report.pass("get_client after purge", "400 no query results, the row is gone")
|
"get_client after purge",
|
||||||
}
|
"400 no query results, the row is gone",
|
||||||
|
),
|
||||||
Err(e) => report.fail("get_client after purge", describe(&e)),
|
Err(e) => report.fail("get_client after purge", describe(&e)),
|
||||||
Ok(_) => report.fail("get_client after purge", "still readable after a purge"),
|
Ok(_) => report.fail("get_client after purge", "still readable after a purge"),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
description = "invoiceninjars: a Rust client for the Invoice Ninja v5 API";
|
description = "invoiceninja-sdk: a Rust client for the Invoice Ninja v5 API";
|
||||||
|
|
||||||
inputs = {
|
inputs = {
|
||||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||||
@@ -43,6 +43,14 @@
|
|||||||
packages = [ toolchain pkgs.pkg-config pkgs.openssl pkgs.jq ];
|
packages = [ toolchain pkgs.pkg-config pkgs.openssl pkgs.jq ];
|
||||||
RUST_SRC_PATH = "${toolchain}/lib/rustlib/src/rust/library";
|
RUST_SRC_PATH = "${toolchain}/lib/rustlib/src/rust/library";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
# Built with the pinned dev toolchain, not nixpkgs' own rustc, so a
|
||||||
|
# `nix build` matches what `nix develop` and CI compile with rather
|
||||||
|
# than whatever stable nixpkgs happens to carry.
|
||||||
|
rustPlatform = pkgs.makeRustPlatform {
|
||||||
|
cargo = rustToolchain;
|
||||||
|
rustc = rustToolchain;
|
||||||
|
};
|
||||||
in
|
in
|
||||||
{
|
{
|
||||||
devShells.default = shellFor rustToolchain;
|
devShells.default = shellFor rustToolchain;
|
||||||
@@ -50,5 +58,25 @@
|
|||||||
# Verifies the declared MSRV still builds:
|
# Verifies the declared MSRV still builds:
|
||||||
# nix develop .#msrv --command cargo test
|
# nix develop .#msrv --command cargo test
|
||||||
devShells.msrv = shellFor msrvToolchain;
|
devShells.msrv = shellFor msrvToolchain;
|
||||||
|
|
||||||
|
# This is a library with no `[[bin]]` target (see the "No CLI, and
|
||||||
|
# no binary at all" non-goal in GOALS.md), so the output directory
|
||||||
|
# ends up empty: `cargoInstallHook` only copies binaries matching a
|
||||||
|
# `[[bin]]` target out of `target/release`, and there are none to
|
||||||
|
# copy. `nix build` is still meaningful as a build-and-test gate —
|
||||||
|
# it compiles with the pinned toolchain and runs the full suite,
|
||||||
|
# including doctests, inside the Nix sandbox (no network, matching
|
||||||
|
# the "no credentials needed" property in CLAUDE.md) — just not as
|
||||||
|
# a source of an installable artifact.
|
||||||
|
packages.default = rustPlatform.buildRustPackage {
|
||||||
|
pname = "invoiceninja-sdk";
|
||||||
|
version = (builtins.fromTOML (builtins.readFile ./Cargo.toml)).package.version;
|
||||||
|
src = self;
|
||||||
|
cargoLock.lockFile = ./Cargo.lock;
|
||||||
|
nativeBuildInputs = [ pkgs.pkg-config ];
|
||||||
|
buildInputs = [ pkgs.openssl ];
|
||||||
|
# The test suite needs no network (see CLAUDE.md), so the default
|
||||||
|
# `cargo test` checkPhase runs unmodified in the sandboxed build.
|
||||||
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
+743
-37
@@ -3,16 +3,18 @@ use std::time::Duration;
|
|||||||
use percent_encoding::{utf8_percent_encode, AsciiSet, NON_ALPHANUMERIC};
|
use percent_encoding::{utf8_percent_encode, AsciiSet, NON_ALPHANUMERIC};
|
||||||
use reqwest::{header, multipart, Method, StatusCode};
|
use reqwest::{header, multipart, Method, StatusCode};
|
||||||
use serde::{de::DeserializeOwned, Serialize};
|
use serde::{de::DeserializeOwned, Serialize};
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
use crate::error::{ApiErrorBody, Error, Result};
|
use crate::error::{ApiErrorBody, Error, Result};
|
||||||
use crate::models::{
|
use crate::models::{
|
||||||
Activity, ActivityFeedEntry, BankIntegration, BankTransaction, BankTransactionRule,
|
Activity, ActivityFeedEntry, BankIntegration, BankTransaction, BankTransactionRule,
|
||||||
BulkRequest, Client as ClientModel, ClientGatewayToken, Company, CompanyGateway, CompanyToken,
|
BulkRequest, Client as ClientModel, ClientGatewayToken, Company, CompanyGateway, CompanyToken,
|
||||||
Credit, Design, Document, Expense, ExpenseCategory, GroupSetting, HealthCheck, Invoice,
|
Credit, Design, Document, Expense, ExpenseCategory, ExportJob, GroupSetting, HealthCheck,
|
||||||
ItemResponse, ListParams, ListResponse, Location, MessageResponse, Payment, PaymentTerm,
|
ImportPreview, Invoice, ItemResponse, ListParams, ListResponse, Location, LoginResponse,
|
||||||
Product, Project, PurchaseOrder, Quote, RecurringExpense, RecurringInvoice, RecurringQuote,
|
MessageResponse, Passkey, Payment, PaymentTerm, Product, Project, PurchaseOrder, Quote,
|
||||||
SearchResults, Statics, Subscription, SubscriptionStep, SystemLog, Tag, Task, TaskScheduler,
|
RecurringExpense, RecurringInvoice, RecurringQuote, SearchResults, Statics, Subscription,
|
||||||
TaskStatus, TaxRate, User, Vendor, Webhook,
|
SubscriptionStep, SystemLog, Tag, Task, TaskScheduler, TaskStatus, TaxRate, User, Vendor,
|
||||||
|
Webhook,
|
||||||
};
|
};
|
||||||
use crate::retry::{self, RetryPolicy};
|
use crate::retry::{self, RetryPolicy};
|
||||||
use crate::upload::FileUpload;
|
use crate::upload::FileUpload;
|
||||||
@@ -62,6 +64,32 @@ fn seg(value: &str) -> Result<impl std::fmt::Display + '_> {
|
|||||||
Ok(utf8_percent_encode(value, PATH_SEGMENT))
|
Ok(utf8_percent_encode(value, PATH_SEGMENT))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Validates a base URL and strips its trailing slash.
|
||||||
|
///
|
||||||
|
/// Shared by [`InvoiceNinjaClient::new`] and
|
||||||
|
/// [`InvoiceNinjaClient::login`], the one other place a base URL arrives
|
||||||
|
/// from a caller rather than from an existing client. Parsing up front
|
||||||
|
/// means a malformed URL — most often a missing scheme, as in
|
||||||
|
/// `invoicing.co/api/v1` — fails here instead of as a confusing per-request
|
||||||
|
/// error.
|
||||||
|
fn validate_base_url(base_url: String) -> Result<String> {
|
||||||
|
let trimmed = base_url.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
return Err(Error::InvalidBaseUrl(base_url));
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsed = reqwest::Url::parse(trimmed)
|
||||||
|
.map_err(|e| Error::InvalidBaseUrl(format!("{trimmed}: {e}")))?;
|
||||||
|
if !matches!(parsed.scheme(), "http" | "https") {
|
||||||
|
return Err(Error::InvalidBaseUrl(format!(
|
||||||
|
"{trimmed}: expected an http or https url, got scheme `{}`",
|
||||||
|
parsed.scheme()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(trimmed.trim_end_matches('/').to_string())
|
||||||
|
}
|
||||||
|
|
||||||
/// Standard base64 with padding. Only used for the one header that needs it,
|
/// Standard base64 with padding. Only used for the one header that needs it,
|
||||||
/// which is not worth a dependency.
|
/// which is not worth a dependency.
|
||||||
fn base64(bytes: &[u8]) -> String {
|
fn base64(bytes: &[u8]) -> String {
|
||||||
@@ -134,25 +162,7 @@ pub struct InvoiceNinjaClient {
|
|||||||
|
|
||||||
impl InvoiceNinjaClient {
|
impl InvoiceNinjaClient {
|
||||||
pub fn new(base_url: impl Into<String>, api_token: impl Into<String>) -> Result<Self> {
|
pub fn new(base_url: impl Into<String>, api_token: impl Into<String>) -> Result<Self> {
|
||||||
let base_url = base_url.into();
|
let base_url = validate_base_url(base_url.into())?;
|
||||||
let trimmed = base_url.trim();
|
|
||||||
if trimmed.is_empty() {
|
|
||||||
return Err(Error::InvalidBaseUrl(base_url));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse up front rather than letting a malformed base URL surface as a
|
|
||||||
// confusing per-request failure. A missing scheme is the common typo
|
|
||||||
// (`invoicing.co/api/v1`), and it fails here instead of on every call.
|
|
||||||
let parsed = reqwest::Url::parse(trimmed)
|
|
||||||
.map_err(|e| Error::InvalidBaseUrl(format!("{trimmed}: {e}")))?;
|
|
||||||
if !matches!(parsed.scheme(), "http" | "https") {
|
|
||||||
return Err(Error::InvalidBaseUrl(format!(
|
|
||||||
"{trimmed}: expected an http or https url, got scheme `{}`",
|
|
||||||
parsed.scheme()
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
|
|
||||||
let base_url = trimmed.trim_end_matches('/').to_string();
|
|
||||||
|
|
||||||
let mut headers = header::HeaderMap::new();
|
let mut headers = header::HeaderMap::new();
|
||||||
let mut token_value = header::HeaderValue::from_str(&api_token.into())
|
let mut token_value = header::HeaderValue::from_str(&api_token.into())
|
||||||
@@ -218,8 +228,8 @@ impl InvoiceNinjaClient {
|
|||||||
/// It used to be refused locally with a misleading `InvalidToken`.
|
/// It used to be refused locally with a misleading `InvalidToken`.
|
||||||
///
|
///
|
||||||
/// ```no_run
|
/// ```no_run
|
||||||
/// # fn main() -> invoiceninjars::Result<()> {
|
/// # fn main() -> invoiceninja_sdk::Result<()> {
|
||||||
/// use invoiceninjars::InvoiceNinjaClient;
|
/// use invoiceninja_sdk::InvoiceNinjaClient;
|
||||||
///
|
///
|
||||||
/// let client = InvoiceNinjaClient::hosted(std::env::var("IN_API_TOKEN").unwrap())?
|
/// let client = InvoiceNinjaClient::hosted(std::env::var("IN_API_TOKEN").unwrap())?
|
||||||
/// .with_api_password(std::env::var("IN_API_PASSWORD").unwrap())?;
|
/// .with_api_password(std::env::var("IN_API_PASSWORD").unwrap())?;
|
||||||
@@ -337,6 +347,76 @@ impl InvoiceNinjaClient {
|
|||||||
Self::new(HOSTED_BASE_URL, api_token)
|
Self::new(HOSTED_BASE_URL, api_token)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Exchanges an email and password for an API token, for the one case
|
||||||
|
/// `new` cannot cover: there is no token yet to build a client with.
|
||||||
|
///
|
||||||
|
/// `POST /login` sits outside the `token_auth` middleware every other
|
||||||
|
/// wrapped endpoint uses, so this is a standalone async function rather
|
||||||
|
/// than a method — nothing in `self` could be used to make the request.
|
||||||
|
/// It builds its own one-shot `reqwest::Client` and does not go through
|
||||||
|
/// `req` or `execute`, and, like every other `POST` in this crate, is
|
||||||
|
/// not retried on a 5xx or a timeout.
|
||||||
|
///
|
||||||
|
/// On success the server answers with one entry per company the account
|
||||||
|
/// has access to, each carrying that company's token
|
||||||
|
/// ([`LoginCompanyUser::token`](crate::models::LoginCompanyUser::token))
|
||||||
|
/// — feed one into [`new`](Self::new) (or [`hosted`](Self::hosted)) to
|
||||||
|
/// get a working client:
|
||||||
|
///
|
||||||
|
/// ```no_run
|
||||||
|
/// # async fn run() -> invoiceninja_sdk::Result<()> {
|
||||||
|
/// use invoiceninja_sdk::InvoiceNinjaClient;
|
||||||
|
///
|
||||||
|
/// let login = InvoiceNinjaClient::login(
|
||||||
|
/// "https://invoicing.co/api/v1",
|
||||||
|
/// "me@example.com",
|
||||||
|
/// "correct-horse-battery-staple",
|
||||||
|
/// None,
|
||||||
|
/// )
|
||||||
|
/// .await?;
|
||||||
|
/// let token = &login.data[0].token.as_ref().expect("token include").token;
|
||||||
|
/// let client = InvoiceNinjaClient::hosted(token)?;
|
||||||
|
/// # let _ = client;
|
||||||
|
/// # Ok(())
|
||||||
|
/// # }
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// `one_time_password` is only consulted when the account has
|
||||||
|
/// two-factor authentication enabled; pass `None` otherwise. Passkey
|
||||||
|
/// login, SSO (`oauth_login`, `oidc/*`), and a self-hosted instance's
|
||||||
|
/// optional `X-API-SECRET` are not handled — each needs a ceremony or a
|
||||||
|
/// header this function does not send, and none fits a single
|
||||||
|
/// email/password call.
|
||||||
|
pub async fn login(
|
||||||
|
base_url: &str,
|
||||||
|
email: &str,
|
||||||
|
password: &str,
|
||||||
|
one_time_password: Option<&str>,
|
||||||
|
) -> Result<LoginResponse> {
|
||||||
|
let base_url = validate_base_url(base_url.to_string())?;
|
||||||
|
|
||||||
|
let mut body = serde_json::json!({ "email": email, "password": password });
|
||||||
|
if let Some(otp) = one_time_password {
|
||||||
|
body["one_time_password"] = serde_json::Value::String(otp.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let http = reqwest::Client::builder()
|
||||||
|
.connect_timeout(CONNECT_TIMEOUT)
|
||||||
|
.build()?;
|
||||||
|
let response = http
|
||||||
|
.post(format!("{base_url}/login"))
|
||||||
|
.timeout(DEFAULT_TIMEOUT)
|
||||||
|
.header(
|
||||||
|
header::ACCEPT,
|
||||||
|
header::HeaderValue::from_static("application/json"),
|
||||||
|
)
|
||||||
|
.header("X-Requested-With", "XMLHttpRequest")
|
||||||
|
.json(&body)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
Self::decode(response).await
|
||||||
|
}
|
||||||
|
|
||||||
fn url(&self, path: &str) -> String {
|
fn url(&self, path: &str) -> String {
|
||||||
format!("{}/{}", self.base_url, path.trim_start_matches('/'))
|
format!("{}/{}", self.base_url, path.trim_start_matches('/'))
|
||||||
}
|
}
|
||||||
@@ -629,6 +709,80 @@ impl InvoiceNinjaClient {
|
|||||||
Ok(body.data)
|
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.
|
/// Issues a request whose body is discarded, failing on non-2xx.
|
||||||
async fn get_discard(&self, path: &str) -> Result<()> {
|
async fn get_discard(&self, path: &str) -> Result<()> {
|
||||||
self.get_raw(path).await.map(|_| ())
|
self.get_raw(path).await.map(|_| ())
|
||||||
@@ -767,6 +921,52 @@ macro_rules! entity_action_methods {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Generates the two invitation-key downloads for one entity.
|
||||||
|
///
|
||||||
|
/// A different route family from [`entity_action_methods`], in two ways that
|
||||||
|
/// are easy to trip over. The path segment is **singular** — `invoice/…`,
|
||||||
|
/// not `invoices/…`, the only place in the API that does this. And the value
|
||||||
|
/// in it is an *invitation key*, the per-contact token that appears in a
|
||||||
|
/// client-portal link, not a record id; passing a record id gets the 400
|
||||||
|
/// below, since no invitation has that key.
|
||||||
|
///
|
||||||
|
/// They are still authenticated. The routes sit inside `routes/api.php`'s
|
||||||
|
/// `api/v1` group, under its `token_auth` middleware, so they go through the
|
||||||
|
/// client's ordinary request path like any other call. The invitation
|
||||||
|
/// downloads that genuinely take no token are the separate client-portal
|
||||||
|
/// routes in `routes/client.php`, which are not wrapped — they are not under
|
||||||
|
/// `/api/v1`, so they do not fit a `base_url` that ends there.
|
||||||
|
macro_rules! invitation_download_methods {
|
||||||
|
($download:ident, $download_e:ident, $path:literal, $e_action:literal) => {
|
||||||
|
/// Downloads the rendered PDF for the record an invitation key
|
||||||
|
/// points at.
|
||||||
|
///
|
||||||
|
/// This renders for one *contact* rather than one record, which is
|
||||||
|
/// the reason to prefer it over the id-based `download_*` method:
|
||||||
|
/// upstream sets the locale from the contact the invitation belongs
|
||||||
|
/// to, so a client with two contacts in different languages gets a
|
||||||
|
/// different document per key.
|
||||||
|
///
|
||||||
|
/// An unknown or already-deleted key is a `400` carrying
|
||||||
|
/// `no record found`, not a `404`.
|
||||||
|
pub async fn $download(&self, invitation_key: &str) -> Result<Vec<u8>> {
|
||||||
|
self.get_raw(&format!("{}/{}/download", $path, seg(invitation_key)?))
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Downloads the XML e-document for the record an invitation key
|
||||||
|
/// points at.
|
||||||
|
///
|
||||||
|
/// The bytes are `application/xml`, not a PDF; which standard they
|
||||||
|
/// follow (Factur-X, ZUGFeRD, Peppol, …) depends on the company's
|
||||||
|
/// e-invoice settings. Errors are as above.
|
||||||
|
pub async fn $download_e(&self, invitation_key: &str) -> Result<Vec<u8>> {
|
||||||
|
self.get_raw(&format!("{}/{}/{}", $path, seg(invitation_key)?, $e_action))
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
impl InvoiceNinjaClient {
|
impl InvoiceNinjaClient {
|
||||||
resource_methods!(
|
resource_methods!(
|
||||||
list_clients,
|
list_clients,
|
||||||
@@ -1051,6 +1251,34 @@ impl InvoiceNinjaClient {
|
|||||||
"purchase_orders",
|
"purchase_orders",
|
||||||
PurchaseOrder
|
PurchaseOrder
|
||||||
);
|
);
|
||||||
|
|
||||||
|
invitation_download_methods!(
|
||||||
|
download_invoice_by_invitation_key,
|
||||||
|
download_e_invoice_by_invitation_key,
|
||||||
|
"invoice",
|
||||||
|
"download_e_invoice"
|
||||||
|
);
|
||||||
|
|
||||||
|
invitation_download_methods!(
|
||||||
|
download_quote_by_invitation_key,
|
||||||
|
download_e_quote_by_invitation_key,
|
||||||
|
"quote",
|
||||||
|
"download_e_quote"
|
||||||
|
);
|
||||||
|
|
||||||
|
invitation_download_methods!(
|
||||||
|
download_credit_by_invitation_key,
|
||||||
|
download_e_credit_by_invitation_key,
|
||||||
|
"credit",
|
||||||
|
"download_e_credit"
|
||||||
|
);
|
||||||
|
|
||||||
|
invitation_download_methods!(
|
||||||
|
download_purchase_order_by_invitation_key,
|
||||||
|
download_e_purchase_order_by_invitation_key,
|
||||||
|
"purchase_order",
|
||||||
|
"download_e_purchase_order"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Documents, reports, and the remaining endpoints that do not follow the
|
/// Documents, reports, and the remaining endpoints that do not follow the
|
||||||
@@ -1168,7 +1396,7 @@ impl InvoiceNinjaClient {
|
|||||||
/// eleven charts share no common response shape — `totals_v2` answers
|
/// eleven charts share no common response shape — `totals_v2` answers
|
||||||
/// with per-currency maps, `cashflow_forecast` with a date series,
|
/// with per-currency maps, `cashflow_forecast` with a date series,
|
||||||
/// `calculated_fields` with scalars — so there is nothing to narrow to.
|
/// `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.
|
/// calling.
|
||||||
///
|
///
|
||||||
/// `project_analytics` and `project_burnup` take a project id as a
|
/// `project_analytics` and `project_burnup` take a project id as a
|
||||||
@@ -1218,8 +1446,8 @@ impl InvoiceNinjaClient {
|
|||||||
/// for — [`Invoice`], [`Quote`], [`Credit`], or [`PurchaseOrder`]:
|
/// for — [`Invoice`], [`Quote`], [`Credit`], or [`PurchaseOrder`]:
|
||||||
///
|
///
|
||||||
/// ```no_run
|
/// ```no_run
|
||||||
/// # async fn run(client: &invoiceninjars::InvoiceNinjaClient) -> invoiceninjars::Result<()> {
|
/// # async fn run(client: &invoiceninja_sdk::InvoiceNinjaClient) -> invoiceninja_sdk::Result<()> {
|
||||||
/// use invoiceninjars::models::Invoice;
|
/// use invoiceninja_sdk::models::Invoice;
|
||||||
/// use serde_json::json;
|
/// use serde_json::json;
|
||||||
///
|
///
|
||||||
/// let invoice: Invoice = client
|
/// let invoice: Invoice = client
|
||||||
@@ -1620,7 +1848,7 @@ impl InvoiceNinjaClient {
|
|||||||
|
|
||||||
/// The client's effective settings, each tagged with the level it came
|
/// 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.
|
/// 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(
|
self.post_unwrapped(
|
||||||
&format!("clients/{}/show_settings", seg(id)?),
|
&format!("clients/{}/show_settings", seg(id)?),
|
||||||
&serde_json::json!({}),
|
&serde_json::json!({}),
|
||||||
@@ -1630,7 +1858,7 @@ impl InvoiceNinjaClient {
|
|||||||
|
|
||||||
/// Looks the client up on the Peppol network. Left as raw JSON: the
|
/// Looks the client up on the Peppol network. Left as raw JSON: the
|
||||||
/// controller returns whatever the discovery service produced.
|
/// 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(
|
self.post_unwrapped(
|
||||||
&format!("clients/{}/peppol_discovery", seg(id)?),
|
&format!("clients/{}/peppol_discovery", seg(id)?),
|
||||||
&serde_json::json!({}),
|
&serde_json::json!({}),
|
||||||
@@ -1760,6 +1988,41 @@ impl InvoiceNinjaClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Logout and passkey management. Unlike the credential exchange in
|
||||||
|
/// [`login`](Self::login), these sit behind the normal `token_auth`
|
||||||
|
/// middleware — the same one every other wrapped endpoint uses — so they
|
||||||
|
/// are plain methods here rather than a standalone function.
|
||||||
|
impl InvoiceNinjaClient {
|
||||||
|
/// Regenerates every system-issued API token on the **whole company**,
|
||||||
|
/// not just the one this client is using.
|
||||||
|
///
|
||||||
|
/// `LogoutController::index` looks up the company behind the caller's
|
||||||
|
/// token and replaces every `is_system` token belonging to it with a
|
||||||
|
/// fresh random value — one call logs out every user and every device
|
||||||
|
/// sharing that company. The token this client was built with is among
|
||||||
|
/// them, so a subsequent call through it will fail; building a new
|
||||||
|
/// client with a fresh token is the only way back in.
|
||||||
|
pub async fn logout(&self) -> Result<MessageResponse> {
|
||||||
|
self.post_unwrapped("logout", &serde_json::json!({})).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lists the WebAuthn passkeys registered to the current user.
|
||||||
|
pub async fn list_passkeys(&self) -> Result<Vec<Passkey>> {
|
||||||
|
self.get_list("settings/passkeys", &ListParams::default())
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deletes a passkey credential. The server refuses with a 403 if
|
||||||
|
/// `id` belongs to a different user than the one the token
|
||||||
|
/// authenticates.
|
||||||
|
///
|
||||||
|
/// Registering a new passkey is not wrapped — see [`Passkey`] for why.
|
||||||
|
pub async fn delete_passkey(&self, id: &str) -> Result<()> {
|
||||||
|
self.delete_one(&format!("settings/passkeys/{}", seg(id)?))
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Activity log, email history, and document previews. Each has a response
|
/// Activity log, email history, and document previews. Each has a response
|
||||||
/// shape of its own (read from the upstream controllers, and for the activity
|
/// shape of its own (read from the upstream controllers, and for the activity
|
||||||
/// routes confirmed against a 5.13.50 server), so none is macro-generated.
|
/// routes confirmed against a 5.13.50 server), so none is macro-generated.
|
||||||
@@ -1844,7 +2107,7 @@ impl InvoiceNinjaClient {
|
|||||||
/// message that has delivery events. Left as raw JSON, since the objects
|
/// message that has delivery events. Left as raw JSON, since the objects
|
||||||
/// mirror whatever the mail provider reported and no instance available
|
/// mirror whatever the mail provider reported and no instance available
|
||||||
/// here had sent mail to take a shape from.
|
/// 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(
|
self.post_unwrapped(
|
||||||
&format!("emails/clientHistory/{}", seg(client_id)?),
|
&format!("emails/clientHistory/{}", seg(client_id)?),
|
||||||
&serde_json::json!({}),
|
&serde_json::json!({}),
|
||||||
@@ -1855,11 +2118,7 @@ impl InvoiceNinjaClient {
|
|||||||
/// Email delivery history for one record. `entity` is `invoice`, `quote`,
|
/// Email delivery history for one record. `entity` is `invoice`, `quote`,
|
||||||
/// `credit`, `recurring_invoice`, or `purchase_order`. Raw JSON, as for
|
/// `credit`, `recurring_invoice`, or `purchase_order`. Raw JSON, as for
|
||||||
/// [`client_email_history`](Self::client_email_history).
|
/// [`client_email_history`](Self::client_email_history).
|
||||||
pub async fn entity_email_history(
|
pub async fn entity_email_history(&self, entity: &str, entity_id: &str) -> Result<Vec<Value>> {
|
||||||
&self,
|
|
||||||
entity: &str,
|
|
||||||
entity_id: &str,
|
|
||||||
) -> Result<Vec<serde_json::Value>> {
|
|
||||||
self.post_unwrapped(
|
self.post_unwrapped(
|
||||||
"emails/entityHistory",
|
"emails/entityHistory",
|
||||||
&serde_json::json!({ "entity": entity, "entity_id": entity_id }),
|
&serde_json::json!({ "entity": entity, "entity_id": entity_id }),
|
||||||
@@ -1907,3 +2166,450 @@ impl InvoiceNinjaClient {
|
|||||||
self.post_raw("live_preview/purchase_order", body).await
|
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.
|
//! An async Rust client for the [Invoice Ninja](https://www.invoiceninja.com/) v5 API.
|
||||||
//!
|
//!
|
||||||
//! ```no_run
|
//! ```no_run
|
||||||
//! use invoiceninjars::{InvoiceNinjaClient, models::ListParams};
|
//! use invoiceninja_sdk::{InvoiceNinjaClient, models::ListParams};
|
||||||
//!
|
//!
|
||||||
//! # async fn run() -> invoiceninjars::Result<()> {
|
//! # async fn run() -> invoiceninja_sdk::Result<()> {
|
||||||
//! let client = InvoiceNinjaClient::new(
|
//! let client = InvoiceNinjaClient::new(
|
||||||
//! "https://invoicing.co/api/v1",
|
//! "https://invoicing.co/api/v1",
|
||||||
//! std::env::var("IN_API_TOKEN").unwrap(),
|
//! std::env::var("IN_API_TOKEN").unwrap(),
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ impl ListParams {
|
|||||||
/// not cover.
|
/// not cover.
|
||||||
///
|
///
|
||||||
/// ```
|
/// ```
|
||||||
/// use invoiceninjars::models::ListParams;
|
/// use invoiceninja_sdk::models::ListParams;
|
||||||
///
|
///
|
||||||
/// let params = ListParams::new()
|
/// let params = ListParams::new()
|
||||||
/// .per_page(100)
|
/// .per_page(100)
|
||||||
|
|||||||
@@ -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 expense_category;
|
||||||
mod group_setting;
|
mod group_setting;
|
||||||
mod health;
|
mod health;
|
||||||
|
mod import_export;
|
||||||
mod invoice;
|
mod invoice;
|
||||||
mod location;
|
mod location;
|
||||||
|
mod login;
|
||||||
|
mod passkey;
|
||||||
mod payment;
|
mod payment;
|
||||||
mod payment_term;
|
mod payment_term;
|
||||||
mod product;
|
mod product;
|
||||||
@@ -54,8 +57,11 @@ pub use expense::Expense;
|
|||||||
pub use expense_category::ExpenseCategory;
|
pub use expense_category::ExpenseCategory;
|
||||||
pub use group_setting::GroupSetting;
|
pub use group_setting::GroupSetting;
|
||||||
pub use health::{HealthCheck, PhpVersion};
|
pub use health::{HealthCheck, PhpVersion};
|
||||||
|
pub use import_export::{ExportJob, ImportPreview};
|
||||||
pub use invoice::{Credit, Invoice, InvoiceItem, Quote};
|
pub use invoice::{Credit, Invoice, InvoiceItem, Quote};
|
||||||
pub use location::Location;
|
pub use location::Location;
|
||||||
|
pub use login::{LoginCompanyUser, LoginResponse};
|
||||||
|
pub use passkey::Passkey;
|
||||||
pub use payment::{Payment, PaymentInvoice};
|
pub use payment::{Payment, PaymentInvoice};
|
||||||
pub use payment_term::PaymentTerm;
|
pub use payment_term::PaymentTerm;
|
||||||
pub use product::Product;
|
pub use product::Product;
|
||||||
|
|||||||
@@ -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`.
|
/// question in `GOALS.md`.
|
||||||
///
|
///
|
||||||
/// ```
|
/// ```
|
||||||
/// use invoiceninjars::FileUpload;
|
/// use invoiceninja_sdk::FileUpload;
|
||||||
///
|
///
|
||||||
/// let file = FileUpload::new("receipt.pdf", b"%PDF-1.4 ...".to_vec())
|
/// let file = FileUpload::new("receipt.pdf", b"%PDF-1.4 ...".to_vec())
|
||||||
/// .mime("application/pdf");
|
/// .mime("application/pdf");
|
||||||
|
|||||||
+841
-5
@@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use invoiceninjars::{models::ListParams, Error, InvoiceNinjaClient};
|
use invoiceninja_sdk::{models::ListParams, Error, InvoiceNinjaClient};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use wiremock::matchers::{header, method, path, query_param};
|
use wiremock::matchers::{header, method, path, query_param};
|
||||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||||
@@ -547,7 +547,7 @@ async fn ping_accepts_an_empty_success_body() {
|
|||||||
|
|
||||||
// --- retry and backoff --------------------------------------------------
|
// --- retry and backoff --------------------------------------------------
|
||||||
|
|
||||||
use invoiceninjars::RetryPolicy;
|
use invoiceninja_sdk::RetryPolicy;
|
||||||
|
|
||||||
/// Fast policy so the tests do not actually sleep for seconds.
|
/// Fast policy so the tests do not actually sleep for seconds.
|
||||||
fn quick_retry() -> RetryPolicy {
|
fn quick_retry() -> RetryPolicy {
|
||||||
@@ -751,7 +751,7 @@ async fn retried_requests_keep_their_body_and_headers() {
|
|||||||
.mount(&server)
|
.mount(&server)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
let body = invoiceninjars::models::Client {
|
let body = invoiceninja_sdk::models::Client {
|
||||||
name: "Acme".into(),
|
name: "Acme".into(),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
@@ -965,7 +965,7 @@ async fn send_email_unwraps_the_data_envelope() {
|
|||||||
.mount(&server)
|
.mount(&server)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
let invoice: invoiceninjars::models::Invoice = client(&server)
|
let invoice: invoiceninja_sdk::models::Invoice = client(&server)
|
||||||
.send_email(&json!({ "entity": "invoice", "entity_id": "inv1" }))
|
.send_email(&json!({ "entity": "invoice", "entity_id": "inv1" }))
|
||||||
.await
|
.await
|
||||||
.expect("should return the refreshed entity");
|
.expect("should return the refreshed entity");
|
||||||
@@ -1179,7 +1179,7 @@ async fn no_budget_means_only_max_retries_bounds_the_call() {
|
|||||||
|
|
||||||
// --- document uploads ---------------------------------------------------
|
// --- document uploads ---------------------------------------------------
|
||||||
|
|
||||||
use invoiceninjars::FileUpload;
|
use invoiceninja_sdk::FileUpload;
|
||||||
|
|
||||||
fn pdf(name: &str) -> FileUpload {
|
fn pdf(name: &str) -> FileUpload {
|
||||||
FileUpload::new(name, b"%PDF-1.4 fake".to_vec()).mime("application/pdf")
|
FileUpload::new(name, b"%PDF-1.4 fake".to_vec()).mime("application/pdf")
|
||||||
@@ -2221,3 +2221,839 @@ async fn notes_are_not_replayed_on_5xx() {
|
|||||||
.expect_err("500");
|
.expect_err("500");
|
||||||
assert_eq!(server.received_requests().await.unwrap().len(), 1);
|
assert_eq!(server.received_requests().await.unwrap().len(), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- invitation-key downloads -------------------------------------------
|
||||||
|
|
||||||
|
/// Mounts one GET route answering 200 with raw bytes. Expects exactly one
|
||||||
|
/// request, so a call that built a different path fails when the server drops.
|
||||||
|
async fn raw_route_server(route: &str, body: &'static [u8]) -> MockServer {
|
||||||
|
let server = MockServer::start().await;
|
||||||
|
Mock::given(method("GET"))
|
||||||
|
.and(path(route))
|
||||||
|
.respond_with(ResponseTemplate::new(200).set_body_bytes(body.to_vec()))
|
||||||
|
.expect(1)
|
||||||
|
.mount(&server)
|
||||||
|
.await;
|
||||||
|
server
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One test per invitation-key download. The route each mounts is the one
|
||||||
|
/// `routes/api.php` registers — note the singular entity segment, which is
|
||||||
|
/// what these would get wrong if the plural CRUD path were reused.
|
||||||
|
macro_rules! invitation_download_test {
|
||||||
|
($name:ident, $route:literal, $body:literal, |$c:ident| $call:expr) => {
|
||||||
|
#[tokio::test]
|
||||||
|
async fn $name() {
|
||||||
|
let server = raw_route_server($route, $body).await;
|
||||||
|
let $c = client(&server);
|
||||||
|
let got = $call.await.expect("the download should succeed");
|
||||||
|
assert_eq!(got, $body);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
invitation_download_test!(
|
||||||
|
download_invoice_by_invitation_key_hits_the_singular_route,
|
||||||
|
"/invoice/key1/download",
|
||||||
|
b"%PDF-1.4",
|
||||||
|
|c| c.download_invoice_by_invitation_key("key1")
|
||||||
|
);
|
||||||
|
|
||||||
|
invitation_download_test!(
|
||||||
|
download_e_invoice_by_invitation_key_hits_the_singular_route,
|
||||||
|
"/invoice/key1/download_e_invoice",
|
||||||
|
b"<?xml version=\"1.0\"?>",
|
||||||
|
|c| c.download_e_invoice_by_invitation_key("key1")
|
||||||
|
);
|
||||||
|
|
||||||
|
invitation_download_test!(
|
||||||
|
download_quote_by_invitation_key_hits_the_singular_route,
|
||||||
|
"/quote/key1/download",
|
||||||
|
b"%PDF-1.4",
|
||||||
|
|c| c.download_quote_by_invitation_key("key1")
|
||||||
|
);
|
||||||
|
|
||||||
|
invitation_download_test!(
|
||||||
|
download_e_quote_by_invitation_key_hits_the_singular_route,
|
||||||
|
"/quote/key1/download_e_quote",
|
||||||
|
b"<?xml version=\"1.0\"?>",
|
||||||
|
|c| c.download_e_quote_by_invitation_key("key1")
|
||||||
|
);
|
||||||
|
|
||||||
|
invitation_download_test!(
|
||||||
|
download_credit_by_invitation_key_hits_the_singular_route,
|
||||||
|
"/credit/key1/download",
|
||||||
|
b"%PDF-1.4",
|
||||||
|
|c| c.download_credit_by_invitation_key("key1")
|
||||||
|
);
|
||||||
|
|
||||||
|
invitation_download_test!(
|
||||||
|
download_e_credit_by_invitation_key_hits_the_singular_route,
|
||||||
|
"/credit/key1/download_e_credit",
|
||||||
|
b"<?xml version=\"1.0\"?>",
|
||||||
|
|c| c.download_e_credit_by_invitation_key("key1")
|
||||||
|
);
|
||||||
|
|
||||||
|
invitation_download_test!(
|
||||||
|
download_purchase_order_by_invitation_key_hits_the_singular_route,
|
||||||
|
"/purchase_order/key1/download",
|
||||||
|
b"%PDF-1.4",
|
||||||
|
|c| c.download_purchase_order_by_invitation_key("key1")
|
||||||
|
);
|
||||||
|
|
||||||
|
invitation_download_test!(
|
||||||
|
download_e_purchase_order_by_invitation_key_hits_the_singular_route,
|
||||||
|
"/purchase_order/key1/download_e_purchase_order",
|
||||||
|
b"<?xml version=\"1.0\"?>",
|
||||||
|
|c| c.download_e_purchase_order_by_invitation_key("key1")
|
||||||
|
);
|
||||||
|
|
||||||
|
/// These are authenticated like any other call, which is the whole reason
|
||||||
|
/// they fit the client at all. A token-less variant would need a separate
|
||||||
|
/// entry point.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn an_invitation_download_still_sends_the_api_token() {
|
||||||
|
let server = raw_route_server("/invoice/key1/download", b"%PDF-1.4").await;
|
||||||
|
client(&server)
|
||||||
|
.download_invoice_by_invitation_key("key1")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let headers = &server.received_requests().await.unwrap()[0].headers;
|
||||||
|
assert_eq!(headers["X-Api-Token"], "test-token");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An unknown key is a `400` carrying `no record found`, not a `404`: the
|
||||||
|
/// controllers return that directly rather than raising
|
||||||
|
/// `ModelNotFoundException`.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn an_unknown_invitation_key_is_a_400_not_a_404() {
|
||||||
|
let server = MockServer::start().await;
|
||||||
|
Mock::given(method("GET"))
|
||||||
|
.and(path("/invoice/gone/download"))
|
||||||
|
.respond_with(
|
||||||
|
ResponseTemplate::new(400).set_body_json(json!({ "message": "no record found" })),
|
||||||
|
)
|
||||||
|
.mount(&server)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let err = client(&server)
|
||||||
|
.download_invoice_by_invitation_key("gone")
|
||||||
|
.await
|
||||||
|
.expect_err("an unknown key should fail");
|
||||||
|
assert!(
|
||||||
|
matches!(&err, Error::Api { status, message, .. }
|
||||||
|
if status.as_u16() == 400 && message == "no record found"),
|
||||||
|
"{err:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `invoice//download` is a different route, so an empty key must not reach
|
||||||
|
/// the server.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn an_empty_invitation_key_is_refused_before_the_request() {
|
||||||
|
let server = MockServer::start().await;
|
||||||
|
Mock::given(method("GET"))
|
||||||
|
.respond_with(ResponseTemplate::new(200).set_body_bytes(b"%PDF-1.4".to_vec()))
|
||||||
|
.mount(&server)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let err = client(&server)
|
||||||
|
.download_invoice_by_invitation_key("")
|
||||||
|
.await
|
||||||
|
.expect_err("an empty key must not be sent");
|
||||||
|
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
|
//! A wrong *type* is deliberately not covered: that is meant to be a hard
|
||||||
//! error, so it is not something to absorb.
|
//! error, so it is not something to absorb.
|
||||||
|
|
||||||
use invoiceninjars::models::*;
|
use invoiceninja_sdk::models::*;
|
||||||
use serde::{de::DeserializeOwned, Serialize};
|
use serde::{de::DeserializeOwned, Serialize};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
@@ -66,10 +66,15 @@ fn every_model_absorbs_a_null_or_missing_field() {
|
|||||||
Document,
|
Document,
|
||||||
Expense,
|
Expense,
|
||||||
ExpenseCategory,
|
ExpenseCategory,
|
||||||
|
ExportJob,
|
||||||
GroupSetting,
|
GroupSetting,
|
||||||
|
ImportPreview,
|
||||||
Invoice,
|
Invoice,
|
||||||
InvoiceItem,
|
InvoiceItem,
|
||||||
Location,
|
Location,
|
||||||
|
LoginCompanyUser,
|
||||||
|
LoginResponse,
|
||||||
|
Passkey,
|
||||||
Payment,
|
Payment,
|
||||||
PaymentInvoice,
|
PaymentInvoice,
|
||||||
PaymentTerm,
|
PaymentTerm,
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
use invoiceninjars::models::{
|
use invoiceninja_sdk::models::{
|
||||||
BankIntegration, BankTransaction, BankTransactionRule, BulkRequest, Client, ClientGatewayToken,
|
BankIntegration, BankTransaction, BankTransactionRule, BulkRequest, Client, ClientGatewayToken,
|
||||||
Company, CompanyGateway, CompanyToken, Credit, Design, Document, Expense, ExpenseCategory,
|
Company, CompanyGateway, CompanyToken, Credit, Design, Document, Expense, ExpenseCategory,
|
||||||
GroupSetting, HealthCheck, Invoice, ListParams, ListResponse, Location, PaymentTerm, Project,
|
GroupSetting, HealthCheck, Invoice, ListParams, ListResponse, Location, LoginResponse, Passkey,
|
||||||
PurchaseOrder, Quote, RecurringExpense, RecurringInvoice, RecurringQuote, SearchResults,
|
PaymentTerm, Project, PurchaseOrder, Quote, RecurringExpense, RecurringInvoice, RecurringQuote,
|
||||||
Statics, Subscription, SystemLog, Tag, Task, TaskScheduler, TaskStatus, TaxRate, User, Vendor,
|
SearchResults, Statics, Subscription, SystemLog, Tag, Task, TaskScheduler, TaskStatus, TaxRate,
|
||||||
Webhook,
|
User, Vendor, Webhook,
|
||||||
};
|
};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
||||||
@@ -763,3 +763,42 @@ fn statics_ids_accept_both_strings_and_numbers() {
|
|||||||
assert_eq!(statics.industries[0].id, "3");
|
assert_eq!(statics.industries[0].id, "3");
|
||||||
assert_eq!(statics.sizes[0].id, "", "a null id decodes as empty");
|
assert_eq!(statics.sizes[0].id, "", "a null id decodes as empty");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn login_response_types_the_token_and_preserves_the_rest() {
|
||||||
|
let payload = json!({
|
||||||
|
"data": [{
|
||||||
|
"is_owner": true,
|
||||||
|
"is_admin": true,
|
||||||
|
"token": { "id": "t1", "token": "abc" },
|
||||||
|
"company": { "id": "c1", "settings": {} },
|
||||||
|
"user": { "id": "u1", "email": "me@example.com" }
|
||||||
|
}]
|
||||||
|
});
|
||||||
|
|
||||||
|
let login: LoginResponse = serde_json::from_value(payload).unwrap();
|
||||||
|
assert!(login.data[0].is_owner);
|
||||||
|
let token = login.data[0].token.as_ref().unwrap();
|
||||||
|
assert_eq!(token.token, "abc");
|
||||||
|
|
||||||
|
let back = serde_json::to_value(&login).unwrap();
|
||||||
|
assert_eq!(back["data"][0]["company"]["id"], "c1");
|
||||||
|
assert_eq!(back["data"][0]["user"]["email"], "me@example.com");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn passkey_round_trip_preserves_last_used_at() {
|
||||||
|
let used = json!({
|
||||||
|
"id": "p1", "name": "Laptop", "created_at": "2026-01-01T00:00:00.000000Z",
|
||||||
|
"last_used_at": 1700000000
|
||||||
|
});
|
||||||
|
let never_used = json!({
|
||||||
|
"id": "p2", "name": "Phone", "created_at": "2026-02-01T00:00:00.000000Z",
|
||||||
|
"last_used_at": null
|
||||||
|
});
|
||||||
|
|
||||||
|
let used: Passkey = serde_json::from_value(used).unwrap();
|
||||||
|
let never_used: Passkey = serde_json::from_value(never_used).unwrap();
|
||||||
|
assert_eq!(used.last_used_at, Some(1700000000));
|
||||||
|
assert_eq!(never_used.last_used_at, None);
|
||||||
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ use std::collections::BTreeMap;
|
|||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
use invoiceninjars::models::*;
|
use invoiceninja_sdk::models::*;
|
||||||
use serde::{de::DeserializeOwned, Serialize};
|
use serde::{de::DeserializeOwned, Serialize};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user