Compare commits
20
Commits
355cf08324
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
20ac306c26 | ||
|
|
1a8e4aad26 | ||
|
|
ab696a910b | ||
|
|
fb670cbb08 | ||
|
|
48244e8390 | ||
|
|
d51de3fc63 | ||
|
|
446617a728 | ||
|
|
c4f31743f0 | ||
|
|
e6e41fe5c6 | ||
|
|
2532b83a32 | ||
|
|
e7c9663621 | ||
|
|
0485272080 | ||
|
|
a7d9a2d9d6 | ||
|
|
46a6ea1322 | ||
|
|
83270e8262 | ||
|
|
f616da08db | ||
|
|
bd2d34cae9 | ||
|
|
0002d32197 | ||
|
|
9e7d81307d | ||
|
|
e4f2373b53 |
@@ -0,0 +1,47 @@
|
||||
name: Smoke test
|
||||
|
||||
# Runs `examples/smoke_test.rs` against a real Invoice Ninja instance. Manual
|
||||
# only: it needs an instance the runner can reach and credentials for it, and
|
||||
# the offline suite in `ci.yml` is what gates merges. This is for catching the
|
||||
# places where the real server disagrees with the upstream source the mocks
|
||||
# were written from.
|
||||
#
|
||||
# Repository secrets required: IN_BASE_URL (the API root, ending /api/v1) and
|
||||
# IN_API_TOKEN. Point them at a disposable instance, never at production.
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
write_host:
|
||||
description: >-
|
||||
Leave empty for the read-only tier (GET requests only). To also run
|
||||
the write tier, type the host of IN_BASE_URL (e.g. `localhost`). It
|
||||
creates and deletes records, so the instance must be disposable.
|
||||
required: false
|
||||
default: ''
|
||||
type: string
|
||||
runner:
|
||||
description: Runner label; use a self-hosted one if the instance is not public.
|
||||
required: false
|
||||
default: ubuntu-latest
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
smoke:
|
||||
runs-on: ${{ inputs.runner }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
# Same pin as `ci.yml` and `devVersion` in flake.nix.
|
||||
- uses: dtolnay/rust-toolchain@1.99.1
|
||||
- run: cargo run --example smoke_test
|
||||
env:
|
||||
IN_BASE_URL: ${{ secrets.IN_BASE_URL }}
|
||||
IN_API_TOKEN: ${{ secrets.IN_API_TOKEN }}
|
||||
# The program refuses to write unless this equals the host in
|
||||
# IN_BASE_URL, so a mistyped host skips the write tier.
|
||||
IN_SMOKE_WRITE_HOST: ${{ inputs.write_host }}
|
||||
# Optional. Only used inside the write tier, where it enables the
|
||||
# password tier (merge two throwaway clients, purge the survivor).
|
||||
IN_API_PASSWORD: ${{ secrets.IN_API_PASSWORD }}
|
||||
+123
-1
@@ -9,6 +9,96 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Added
|
||||
|
||||
- Logout and passkey management: `logout`, `list_passkeys`, and
|
||||
`delete_passkey`. `logout` regenerates every system-issued token on the
|
||||
whole company, not just the caller's, so it ends every session sharing
|
||||
it. Passkey *registration* is not wrapped — it needs a WebAuthn ceremony
|
||||
only a browser can drive. Adds `Passkey`.
|
||||
- `InvoiceNinjaClient::login`, an associated function (not a method, since
|
||||
there is no token yet to build a client with) that exchanges an email and
|
||||
password for an API token, optionally with a one-time password for
|
||||
two-factor accounts. Adds `LoginResponse` and `LoginCompanyUser`; the
|
||||
nested company/user/account data the server also returns is left in
|
||||
`extra` rather than modelled. Does not cover passkey login, SSO, or
|
||||
`/login/precheck`.
|
||||
|
||||
## [0.2.0] - 2026-10-25
|
||||
|
||||
### Added
|
||||
|
||||
- E-invoicing, seventeen methods: `validate_einvoice`,
|
||||
`einvoice_configurations`, `einvoice_quota`, `einvoice_health_check`,
|
||||
`einvoice_delivery_map`, `update_einvoice_token`, and eleven `peppol_*`.
|
||||
Three behave unlike the rest of the API, and the docs on each say so: a
|
||||
failing `validate_einvoice` is a `422`, so an ordinary "not Peppol-ready"
|
||||
result arrives as `Error::Api`; the Peppol handlers return the error
|
||||
message as the whole body rather than `{"message": …}`, so `Error::Api`'s
|
||||
`errors` map is empty for them; and `peppol_setup` has two success shapes,
|
||||
hence `Option<Value>`. `peppol_remove_legal_identifier` is the crate's
|
||||
only `DELETE` with a request body.
|
||||
- Integrations, sixteen methods: four `quickbooks_*`, four `stripe_*` (all
|
||||
password-protected), six `calendar_connection` methods, plus
|
||||
`yodlee_account_status` and `nordigen_institutions`. These operate a
|
||||
connection that already exists — establishing one is an OAuth browser flow
|
||||
whose routes take no API token and cannot be wrapped.
|
||||
- Import and export, five methods: `export`, `preimport`, `import`,
|
||||
`import_json`, and `upload_edocument`. A CSV import is two calls —
|
||||
`preimport` uploads the files as `files[<entity_type>]` and returns the
|
||||
column mapping the server suggests, then `import` starts the ingest from
|
||||
the returned `hash`. The route's one-shot form (files and mapping in a
|
||||
single multipart request) is deliberately not wrapped: a mapping is nested,
|
||||
and bracket-encoding nested structures is what this crate avoids by sending
|
||||
JSON. `upload_edocument` imports expenses from e-documents and is not a
|
||||
document attachment, which is why it is not an `upload_*` method. Adds
|
||||
`ExportJob` (the export's collection URL expires in 1–5 hours) and
|
||||
`ImportPreview`. All of these queue work, so a success means accepted, not
|
||||
done.
|
||||
- Invitation-key downloads, eight methods:
|
||||
`download_invoice_by_invitation_key` and
|
||||
`download_e_invoice_by_invitation_key`, plus the same pair for quotes,
|
||||
credits, and purchase orders. These take the per-contact invitation key
|
||||
from a client-portal link rather than a record id, and return the PDF or
|
||||
the XML e-document as bytes. Prefer them over the id-based `download_*`
|
||||
when the document is for a particular contact: the server renders in that
|
||||
contact's locale, so a client with contacts in two languages gets a
|
||||
different document per key. An unknown key is a `400` carrying `no record
|
||||
found`. `COVERAGE.md` previously listed these as out of scope on the
|
||||
grounds that they need no auth token; that was a misreading — they are
|
||||
ordinary authenticated `/api/v1` routes.
|
||||
- Document uploads: `upload_client`, `upload_invoice`, `upload_expense`, and
|
||||
thirteen more, one for each resource with an `/{id}/upload` route. They
|
||||
take a `Vec<FileUpload>` and an optional `is_public`, and return the
|
||||
refreshed record. Uploads are never retried, since a replay could attach
|
||||
the file twice. This is the only way the API creates a document, so there
|
||||
is no `create_document`. Enables reqwest's `multipart` feature, which adds
|
||||
`mime`, `mime_guess`, and `unicase` and leaves the MSRV at 1.88.
|
||||
- Per-resource extras, nineteen methods: `retry_webhook`, `sort_tasks`,
|
||||
`set_default_design`, `set_default_client_gateway_token`,
|
||||
`subscription_steps`, `check_subscription_steps`, the company-gateway
|
||||
`test`/`clone`/`import_customers` trio, four bank-integration and
|
||||
bank-transaction routes, and six client routes (`update_client_tax_data`,
|
||||
`client_show_settings`, `client_peppol_discovery`, `check_client_vat`,
|
||||
`client_vat_status`, `client_documents`). Each is a `POST` unless noted in
|
||||
`COVERAGE.md`, so none is retried on a 5xx. Adds `MessageResponse` for the
|
||||
`{"message": ...}` replies and `SubscriptionStep`.
|
||||
- Password-protected actions, eight methods: `purge_client`, `merge_clients`,
|
||||
`merge_vendors`, `purge_company`, `purge_company_save_settings`,
|
||||
`invite_user`, `purge_user`, and `new_user_template`. They need
|
||||
`with_api_password`, most cannot be undone, and none is retried on a 5xx.
|
||||
A merge keeps the first id and folds the second into it. Checked against
|
||||
the upstream controllers and mock servers; not yet against a real instance.
|
||||
- Activity log, email history and previews, fourteen methods:
|
||||
`list_activities`, `list_all_activities`, `activity_feed`,
|
||||
`entity_activity`, `add_activity_note`, `download_activity_entity`,
|
||||
`client_email_history`, `entity_email_history`, and the previews `preview`,
|
||||
`preview_html`, `live_preview`, `live_design`, `preview_purchase_order` and
|
||||
`live_preview_purchase_order`. Adds `Activity`, `ActivityFeedEntry` and
|
||||
`ActivityRef`. Email history is returned as raw JSON. See `COVERAGE.md` for
|
||||
which were confirmed against a real server and the easy mistakes (a
|
||||
singular entity name in one route, a plural in the next).
|
||||
- `FileUpload` and `Error::InvalidUpload`. An upload with no files, a nameless
|
||||
file, or an unparseable MIME type is refused locally, because the server
|
||||
answers an empty upload with a success that changed nothing.
|
||||
- A declared minimum supported Rust version of **1.88**, verified by running
|
||||
the test suite against it. The floor comes from the dependency graph
|
||||
(`reqwest` → `url` → `idna` → `icu_*`), not from this crate's own code.
|
||||
@@ -17,7 +107,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
release. The advisory job fails visibly rather than silently; it is kept
|
||||
non-blocking through branch protection rather than `continue-on-error`.
|
||||
- Wrappers for every one of the API's 34 `Route::resource` groups, plus
|
||||
`users` — 265 methods. See `COVERAGE.md` for the per-endpoint inventory.
|
||||
`users` — 325 methods (including document uploads, per-resource extras, password-protected actions, and activity, email-history and preview routes). See `COVERAGE.md` for the per-endpoint inventory.
|
||||
- `COVERAGE.md`, a detailed record of what is wrapped, what is not, and why,
|
||||
checked against `routes/api.php` on the `v5-stable` branch.
|
||||
- Retry with full-jitter exponential backoff, configurable through
|
||||
@@ -39,6 +129,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Changed
|
||||
|
||||
- **The crate is renamed from `invoiceninjars` to `invoiceninja-sdk`**, to
|
||||
match the repository it has always lived in
|
||||
(`gitea.awkawb.cloud/awkawb/invoiceninja-sdk`). Depend on it as
|
||||
`invoiceninja-sdk` in `Cargo.toml`, and import it as `invoiceninja_sdk`
|
||||
(Cargo lowercases a package name's hyphens to underscores for the crate
|
||||
identifier) — every `use invoiceninjars::...` becomes
|
||||
`use invoiceninja_sdk::...`. Not yet published to crates.io, so this
|
||||
lands before the name was ever claimed there.
|
||||
- `statics`, `search`, and `health_check` return concrete types instead of
|
||||
being generic over `T: DeserializeOwned`.
|
||||
- `send_email` now unwraps the `data` envelope, so `T` is the entity
|
||||
@@ -58,6 +156,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
from a proxy or a login redirect from a wrong base URL.
|
||||
- API errors with an empty or message-less body now report the status reason
|
||||
("Not Found") instead of rendering blank.
|
||||
- `Error::Http` now renders its whole cause chain. `reqwest`'s own message
|
||||
stops at "error sending request for url (...)", leaving out the part that
|
||||
says why — "Connection refused", a DNS failure, a TLS handshake error — so
|
||||
anyone printing the error had no way to tell which it was.
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -69,6 +171,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- `list_all_*` with `per_page(0)` never terminated: the check `got < 0` has
|
||||
no solution. It now falls back to the default page size, and a `MAX_PAGES`
|
||||
backstop raises `Error::PaginationLimit` rather than quietly truncating.
|
||||
- Creating a recurring invoice or recurring expense failed after the record
|
||||
had been created: a real server returns `frequency_id` as the string `"5"`
|
||||
and the field was a strict integer. It now accepts a number or a numeric
|
||||
string.
|
||||
- Creating a purchase order, location, or webhook from a default model was
|
||||
rejected by a real server. An unset `PurchaseOrder.status_id`,
|
||||
`Location.country_id`, or `Webhook.format` was sent as `""`, which the
|
||||
server turns into `NULL` and then refuses (a 500 for the purchase order and
|
||||
webhook, a 422 for the location). They are now left out of the request when
|
||||
empty.
|
||||
- `with_api_password` refused a password containing a control character (a
|
||||
newline, say) with a misleading `InvalidToken` error. Such a password is
|
||||
now sent base64-encoded as `X-API-PASSWORD-BASE64`, the form the server's
|
||||
middleware accepts for exactly this.
|
||||
- A payment whose `invoices` entry had a `null` or missing `invoice_id` or
|
||||
`amount` failed to decode the whole payment. Both now default.
|
||||
- `list_system_logs` failed to decode against a real server. `SystemLog`'s
|
||||
`created_at` and `updated_at` were typed as strings, following the OpenAPI
|
||||
spec, but the server sends integer timestamps. They are now `i64`, like
|
||||
every other model.
|
||||
- Requests could hang indefinitely. `reqwest` applies no timeout of its own
|
||||
and none was configured.
|
||||
- Ids were interpolated into request paths unescaped, so a `/`, `?`, or `#`
|
||||
|
||||
@@ -22,6 +22,14 @@ This project uses Nix for toolchain management (Rust via `rust-overlay`):
|
||||
nix develop
|
||||
```
|
||||
|
||||
`flake.nix` also has `packages.default`: `nix build` compiles with the
|
||||
pinned dev toolchain and runs the full test suite, including doctests,
|
||||
inside the Nix sandbox. The crate has no `[[bin]]` target (see the "No
|
||||
CLI" non-goal in `GOALS.md`), so the resulting store path is empty —
|
||||
`cargoInstallHook` has no binary to copy out of `target/release`. Treat
|
||||
`nix build` as a build-and-test gate, not a source of an installable
|
||||
artifact.
|
||||
|
||||
### Rust version
|
||||
|
||||
Two versions are pinned, and **three places must agree** when either moves:
|
||||
@@ -84,10 +92,94 @@ IN_API_TOKEN=... cargo run --example list_clients
|
||||
```
|
||||
|
||||
Tests live in `tests/` (integration-style, no network access required);
|
||||
`src/` has no `#[test]` modules. Two files: `model_roundtrip.rs` covers the
|
||||
serde layer, and `client_requests.rs` covers the request plumbing
|
||||
(pagination, path escaping, headers, timeouts, error mapping) against a
|
||||
`wiremock` server on loopback — no credentials or internet needed.
|
||||
`src/` has no `#[test]` modules. Four files:
|
||||
|
||||
- `model_roundtrip.rs` — the serde layer, from hand-written payloads.
|
||||
- `client_requests.rs` — the request plumbing (pagination, path escaping,
|
||||
headers, timeouts, retry, error mapping, one test per endpoint extra)
|
||||
against a `wiremock` server on loopback.
|
||||
- `decode_tolerance.rs` — for every model, serialises `Default`, then nulls
|
||||
and removes each field in turn and requires the result to still decode.
|
||||
A new model belongs in its list; a new non-`Option` field without
|
||||
`null_to_default` fails it.
|
||||
- `real_shapes.rs` — decodes `tests/fixtures/*.json`, which are the *shapes*
|
||||
(keys and JSON types, no values) of responses captured from a real server,
|
||||
and checks a decode + re-serialise loses no key. It also refuses fixtures
|
||||
that hold any real value, and fixtures with no test.
|
||||
|
||||
No credentials or internet needed for any of them.
|
||||
|
||||
**Why the fixtures matter:** twice, mocks written from the upstream source
|
||||
passed while a real server disagreed (system-log timestamps; the
|
||||
`/subscriptions/steps` shape), and `frequency_id` and several empty-string
|
||||
fields were wrong the same way. When the smoke test finds a divergence,
|
||||
refresh the fixtures so it stays caught:
|
||||
|
||||
```sh
|
||||
# 1. Fill a DISPOSABLE instance with one row of each kind (writes!).
|
||||
IN_SMOKE_WRITE_HOST=localhost IN_BASE_URL=... IN_API_TOKEN=... \
|
||||
cargo run --example seed_sample_data
|
||||
# 2. Capture shapes (GET only). Review `git diff tests/fixtures` before committing.
|
||||
IN_BASE_URL=... IN_API_TOKEN=... cargo run --example capture_shapes
|
||||
```
|
||||
|
||||
A resource with no rows is skipped, so the fixture set only covers what the
|
||||
instance held. Bank integrations, company gateways, client gateway tokens,
|
||||
subscriptions, task schedulers, and recurring quotes are not captured: they
|
||||
need external accounts, or (recurring quotes on 5.13.50) the server's own
|
||||
insert fails.
|
||||
|
||||
**Fields omitted when empty.** The server runs `ConvertEmptyStringsToNull`,
|
||||
so an unset `String` serialised as `""` becomes `NULL`, which a non-null
|
||||
column or an `integer` rule then rejects. `Location.country_id`,
|
||||
`PurchaseOrder.status_id` and `Webhook.format` therefore carry
|
||||
`skip_serializing_if = "String::is_empty"`. If a create from a `Default`
|
||||
model 500s or 422s on an empty field, do the same — and add the path to
|
||||
`OMITTED_WHEN_EMPTY` in `tests/real_shapes.rs` and `examples/smoke_test.rs`,
|
||||
which is deliberately a list of decisions rather than a blanket exemption.
|
||||
|
||||
### Testing against a real server
|
||||
|
||||
Everything above is checked against mock servers written from the upstream
|
||||
source, so it cannot catch a place where the real server disagrees with that
|
||||
reading. `examples/smoke_test.rs` can:
|
||||
|
||||
```sh
|
||||
# Read-only (GET requests only). IN_BASE_URL has no default, on purpose.
|
||||
IN_BASE_URL=http://localhost:8080/api/v1 IN_API_TOKEN=... \
|
||||
cargo run --example smoke_test
|
||||
|
||||
# Adds a write tier: create a throwaway client, upload files to it, delete it.
|
||||
IN_SMOKE_WRITE_HOST=localhost IN_BASE_URL=... IN_API_TOKEN=... \
|
||||
cargo run --example smoke_test
|
||||
```
|
||||
|
||||
**Never point the write tier at a production server.** The guard is that
|
||||
`IN_SMOKE_WRITE_HOST` must equal the host in `IN_BASE_URL`, so a write only
|
||||
reaches a server that was named explicitly. Even after its delete the write
|
||||
tier leaves a gap in the client number sequence and a soft-deleted row (a
|
||||
real purge needs the account password), and it may fire webhooks. Use a
|
||||
disposable instance — a local VM or container — for it.
|
||||
|
||||
Beyond "does it decode", the read tier fetches the same rows raw and fails
|
||||
if a typed row lost a key (`keys kept: <resource>`, names only, never
|
||||
values), and the write tier creates a purchase order, location, webhook and
|
||||
recurring invoice from bare `Default` models, since those are what broke.
|
||||
The read tier also downloads an invoice by invitation key, which is there to
|
||||
re-check the claim that those routes are token-authenticated; the matching
|
||||
`download_e_*` is left out, since it renders from e-invoice settings a test
|
||||
instance need not have.
|
||||
With `IN_API_PASSWORD` set as well, a password tier merges two throwaway
|
||||
clients and purges the survivor (a real, permanent delete); it was run
|
||||
against a disposable 5.13.50 instance on 2026-10-24 and all four checks
|
||||
pass. `.github/workflows/smoke.yml` runs it on demand (`workflow_dispatch` only,
|
||||
needing `IN_BASE_URL` and `IN_API_TOKEN` secrets); the write tier runs only if
|
||||
the `write_host` input names the instance's host.
|
||||
|
||||
It prints PASS/FAIL per check and never the contents of a record. The upload
|
||||
wire format (`POST` plus `_method=PUT`) was read from the framework source,
|
||||
then confirmed by running the write tier against a disposable Invoice Ninja
|
||||
5.13.50 instance (2026-10-24): single and multi-file uploads both attached.
|
||||
|
||||
## Changelog
|
||||
|
||||
@@ -161,10 +253,49 @@ changes, doc tweaks) does not need an entry.
|
||||
elsewhere without checking `routes/api.php`. In particular the official PHP
|
||||
SDK puts bulk `download` on *every* resource, which is wrong; only
|
||||
PDF-rendering entities support it.
|
||||
- `invitation_download_methods!` generates `download_{x}_by_invitation_key`
|
||||
and `download_e_{x}_by_invitation_key` for the same four resources. Two
|
||||
traps, both pinned by tests: the entity segment is **singular**
|
||||
(`invoice/{key}/download`, the only route family in the API that is), and
|
||||
the routes are **authenticated** despite being the link a client follows —
|
||||
they sit in `routes/api.php`'s `api/v1` group under `token_auth`. The
|
||||
genuinely token-less invitation downloads are the separate client-portal
|
||||
routes in `routes/client.php`, which are not under `/api/v1` and are not
|
||||
wrapped. `COVERAGE.md` used to list the `/api/v1` eight as unwrapped for
|
||||
that reason, having conflated the two families; don't reintroduce the
|
||||
claim.
|
||||
- `upload_methods!` generates `upload_{x}` for the 16 resources with a
|
||||
`/{id}/upload` route; documents can only be created this way, because
|
||||
`DocumentController::store` is an empty stub upstream (hence no
|
||||
`create_document`). Three things are easy to get wrong, all verified
|
||||
against the upstream source: the request is a **`POST` with
|
||||
`_method=PUT`** (PHP does not parse multipart on a real `PUT`, and the
|
||||
spec's `_method=POST` would 405); `is_public` goes out as **`1`/`0`**
|
||||
(Laravel's strict `boolean` rule rejects the string `"true"`); and uploads
|
||||
are **never retried**, because reqwest's multipart body is not cloneable
|
||||
and `execute` gives an un-cloneable request exactly one attempt — a test
|
||||
pins this, since nothing in the types would notice if it changed. Don't
|
||||
"fix" any of the three.
|
||||
- Non-CRUD endpoints (documents, reports, charts, search, statics, emails,
|
||||
refunds, ping/health) are hand-written in the last `impl` block rather than
|
||||
macro-generated, because their request and response shapes are all
|
||||
different.
|
||||
- **The import, e-invoicing and integration blocks return
|
||||
`serde_json::Value` on purpose.** They are pass-throughs from Storecove,
|
||||
Stripe, Yodlee or GoCardless, or arrays built for Invoice Ninja's React
|
||||
client; none has a documented schema and they move on the provider's
|
||||
release schedule. Don't "finish the job" by typing them — the same
|
||||
reasoning that keeps `chart` generic applies, and `COVERAGE.md` records it
|
||||
as the crate's largest deliberate untyped surface. `ExportJob` and
|
||||
`ImportPreview` are typed because their one useful field (the URL, the
|
||||
hash) is the whole point of the call.
|
||||
- Three dispatch helpers exist only for that surface: `send_unwrapped` for a
|
||||
`PUT` or `DELETE` that carries a body (`peppol_remove_legal_identifier` is
|
||||
the crate's only `DELETE` with one), `post_unwrapped_opt` for a route that
|
||||
answers either JSON or a bare `204` (`peppol_setup`), and
|
||||
`multipart_unwrapped` for the import uploads, whose form field is
|
||||
`files[<entity_type>]` rather than `documents[]` and whose reply has no
|
||||
`data` envelope. All three go through `req`/`execute` like everything else.
|
||||
- `list_*` returns one page; `list_all_*` goes through `get_all_pages`, which
|
||||
terminates on `meta.pagination.total_pages` and only falls back to the
|
||||
short-page heuristic when the server sends no metadata. **The heuristic is
|
||||
@@ -220,22 +351,41 @@ changes, doc tweaks) does not need an entry.
|
||||
decode, API error responses (with per-field validation `errors` map), and
|
||||
client construction errors. `ApiErrorBody` mirrors Invoice Ninja's error
|
||||
JSON shape.
|
||||
- **A missing record is a `400`, not a `404`.** `Handler.php` maps Laravel's
|
||||
`ModelNotFoundException` to a 400 carrying `No query results for model
|
||||
[App\Models\X].`, and keeps 404 for a route that does not exist or a method
|
||||
it does not support. So don't write a not-found check against 404 — the
|
||||
smoke test's post-purge assertion did, and passed only because it had
|
||||
never been run. Match the status together with the message.
|
||||
|
||||
## Coverage
|
||||
|
||||
**All 34 of the API's `Route::resource` groups are wrapped**, plus `users`
|
||||
(which isn't one). 265 methods: 30 resources with CRUD + bulk, 3 with CRUD
|
||||
only, 4 with entity actions, and 25 hand-written.
|
||||
(which isn't one). 375 methods: 30 resources with CRUD + bulk, 3 with CRUD
|
||||
only, 4 with entity actions, 4 with invitation-key downloads, 16 with
|
||||
document uploads, and 111 hand-written.
|
||||
|
||||
Plus the non-CRUD surface: documents, entity actions and PDF downloads for
|
||||
the four action-capable resources, `report`, `chart`, `search`, `statics`,
|
||||
`send_email`, `refund_payment`, `ping`, and `health_check`.
|
||||
the four action-capable resources, their invitation-key PDF and e-document
|
||||
downloads, `report`, `chart`, `search`, `statics`, `send_email`,
|
||||
`refund_payment`, `ping`, `health_check`, import/export, the 17 e-invoicing
|
||||
routes, the QuickBooks, Stripe, calendar, Yodlee and GoCardless
|
||||
integrations, `logout`, passkey list/delete, and a standalone `login`.
|
||||
|
||||
**`GOALS.md`'s roadmap is closed** — every route an API-token client can
|
||||
reach is wrapped. What is left out is left out for a reason, not pending:
|
||||
passkey registration and passkey login (WebAuthn ceremonies only a browser
|
||||
can drive), SSO (`oauth_login`, `oidc/*`) and `/login/precheck`,
|
||||
`/refresh`/`/refresh_react` (the bulk sync-dump shape parked as its own open
|
||||
question in `GOALS.md`, not an auth concept), `/connected_account` (Gmail
|
||||
OAuth linking — an integration, not login/logout), the client-portal family
|
||||
and the OAuth and provider-webhook routes (all outside `/api/v1`, so they
|
||||
don't fit the client's `base_url` or its token), `POST /quickbooks/settings`
|
||||
(a registered route with no controller method, so it 500s), and a few
|
||||
company and user extras.
|
||||
|
||||
**`COVERAGE.md` is the detailed inventory** — every wrapped endpoint, every
|
||||
gap, and the reason for each gap. The main remaining gaps are multipart
|
||||
uploads (needs a reqwest feature), a handful of per-resource extras
|
||||
(`clients/{id}/purge`, `company_gateways/{id}/test`, …), and the
|
||||
integration/auth routes.
|
||||
gap, and the reason for each gap.
|
||||
|
||||
Keep `COVERAGE.md` current when adding or removing an endpoint: it names
|
||||
exact counts and route groups, so it goes stale quietly. The three coverage
|
||||
|
||||
+407
-51
@@ -18,7 +18,7 @@ declares 34 `Route::resource` groups and 274 explicit route declarations
|
||||
> exists or what it returns.
|
||||
|
||||
**All 34 `Route::resource` groups are now wrapped**, plus `users` (which is
|
||||
not a resource group). 265 methods in total.
|
||||
not a resource group). 375 methods in total.
|
||||
|
||||
## Resources with full CRUD + bulk
|
||||
|
||||
@@ -58,6 +58,10 @@ not a resource group). 265 methods in total.
|
||||
| Task Schedulers | `/task_schedulers` | `TaskScheduler` |
|
||||
| Tokens | `/tokens` | `CompanyToken` |
|
||||
|
||||
`create_recurring_quote` currently 500s against Invoice Ninja 5.13.50: the
|
||||
server's own insert names a `design_id` column its `recurring_quotes` table
|
||||
does not have. The request is well-formed; nothing here can work around it.
|
||||
|
||||
`bulk_*` accepts any action the endpoint supports; `archive`, `restore`, and
|
||||
`delete` work on all of them. An `ids` list matching nothing returns an empty
|
||||
`Vec`, not an error.
|
||||
@@ -123,6 +127,207 @@ same reason: the response isn't JSON.
|
||||
|
||||
Convenience wrappers: `mark_invoice_sent` and `mark_invoice_paid`.
|
||||
|
||||
## Invitation-key downloads
|
||||
|
||||
The same four resources also answer two downloads keyed by **invitation key**
|
||||
rather than record id — the per-contact token that appears in a client-portal
|
||||
link. Eight routes, all wrapped:
|
||||
|
||||
| Route | Method | Returns |
|
||||
| --- | --- | --- |
|
||||
| `GET /invoice/{key}/download` | `download_invoice_by_invitation_key` | `application/pdf` |
|
||||
| `GET /invoice/{key}/download_e_invoice` | `download_e_invoice_by_invitation_key` | `application/xml` |
|
||||
| `GET /quote/{key}/download` | `download_quote_by_invitation_key` | `application/pdf` |
|
||||
| `GET /quote/{key}/download_e_quote` | `download_e_quote_by_invitation_key` | `application/xml` |
|
||||
| `GET /credit/{key}/download` | `download_credit_by_invitation_key` | `application/pdf` |
|
||||
| `GET /credit/{key}/download_e_credit` | `download_e_credit_by_invitation_key` | `application/xml` |
|
||||
| `GET /purchase_order/{key}/download` | `download_purchase_order_by_invitation_key` | `application/pdf` |
|
||||
| `GET /purchase_order/{key}/download_e_purchase_order` | `download_e_purchase_order_by_invitation_key` | `application/xml` |
|
||||
|
||||
Two things about these are easy to get wrong, and both are pinned by tests.
|
||||
The entity segment is **singular** — `invoice/…`, not `invoices/…` — which
|
||||
is the only place in the API that departs from the plural CRUD path. And
|
||||
despite being the download a *client* follows, they are **authenticated**:
|
||||
the routes sit inside `routes/api.php`'s `api/v1` group under its
|
||||
`token_auth` middleware, so they need the API token like any other call.
|
||||
|
||||
This file previously listed them as unwrapped on the grounds that they take
|
||||
no auth token. That was wrong — it conflated them with the client-portal
|
||||
routes in `routes/client.php` (`/client/invoice/{key}/download_pdf` and
|
||||
friends), which are a separate route family, are not under `/api/v1`, and
|
||||
remain unwrapped for that reason.
|
||||
|
||||
The smoke test's read tier exercises the invoice PDF, taking a key from the
|
||||
`invitations` array the list route already returns. The `download_e_*` four
|
||||
are not exercised: they render from the company's e-invoice settings, which a
|
||||
test instance need not have configured.
|
||||
|
||||
An unknown or deleted key is a `400` carrying `no record found`. The
|
||||
controllers return that directly rather than raising
|
||||
`ModelNotFoundException`, so the message differs from the `No query results
|
||||
for model` text a missing record produces elsewhere, but the status is the
|
||||
same 400.
|
||||
|
||||
## Import and export
|
||||
|
||||
| Route | Method | Returns |
|
||||
| --- | --- | --- |
|
||||
| `POST /export` | `export` | `ExportJob` |
|
||||
| `POST /preimport` | `preimport` | `ImportPreview` |
|
||||
| `POST /import` | `import` | `MessageResponse` |
|
||||
| `POST /import_json` | `import_json` | `MessageResponse` |
|
||||
| `PUT /edocument/upload` | `upload_edocument` | `MessageResponse` |
|
||||
|
||||
A CSV import is two calls. `preimport` uploads the files — one per entity
|
||||
type, as `files[<entity_type>]` — and answers with a `hash` plus the column
|
||||
mapping the server suggests; `import` then starts the ingest from that hash.
|
||||
The server holds the files against the hash for 20 minutes.
|
||||
|
||||
The route also accepts the files and the mapping together in one multipart
|
||||
request. That form is deliberately not wrapped: a mapping is a nested
|
||||
structure, and bracket-encoding nested structures into form fields is exactly
|
||||
what this crate avoids by sending JSON (see [the PHP SDK
|
||||
comparison](#compared-with-the-official-php-sdk)). With a `hash` present the
|
||||
controller never looks at uploaded files, so `import` is plain JSON.
|
||||
|
||||
`upload_edocument` is *not* a document attachment, which is why it is not one
|
||||
of the `upload_*` methods: `ExpenseController::edocument` parses each file and
|
||||
creates **expenses**. Like the `/{id}/upload` routes it is registered as `PUT`
|
||||
and so goes out as a `POST` with `_method=PUT`; unlike them the reply has no
|
||||
`data` envelope.
|
||||
|
||||
All five queue background work, so a 200 means accepted, not finished. Rows
|
||||
that fail to import are reported by email. `import_json` restores a company
|
||||
from an `export` backup and **overwrites the target company**.
|
||||
|
||||
## E-invoicing
|
||||
|
||||
Seventeen routes, all wrapped. Mostly pass-throughs to Storecove, Invoice
|
||||
Ninja's Peppol access point, so they return `serde_json::Value` — see
|
||||
[Typed response gaps](#typed-response-gaps) for why.
|
||||
|
||||
| Route | Method | Returns |
|
||||
| --- | --- | --- |
|
||||
| `POST /einvoice/validateEntity` | `validate_einvoice` | `Value` |
|
||||
| `POST /einvoice/configurations` | `einvoice_configurations` | `Value` |
|
||||
| `GET /einvoice/quota` | `einvoice_quota` | `Value` |
|
||||
| `GET /einvoice/health_check` | `einvoice_health_check` | `()` |
|
||||
| `GET /einvoice/delivery_map` | `einvoice_delivery_map` | `Value` |
|
||||
| `POST /einvoice/token/update` | `update_einvoice_token` | `()` (204) |
|
||||
| `POST /einvoice/peppol/discovery` | `peppol_discovery` | `Value` |
|
||||
| `POST /einvoice/peppol/legal_entity` | `peppol_legal_entity` | `Value` |
|
||||
| `POST /einvoice/peppol/setup` | `peppol_setup` | `Option<Value>` |
|
||||
| `POST /einvoice/peppol/disconnect` | `peppol_disconnect` | `Value` |
|
||||
| `PUT /einvoice/peppol/update` | `peppol_update_legal_entity` | `Value` |
|
||||
| `POST /einvoice/peppol/add_additional_legal_identifier` | `peppol_add_legal_identifier` | `MessageResponse` |
|
||||
| `DELETE /einvoice/peppol/remove_additional_legal_identifier` | `peppol_remove_legal_identifier` | `Value` |
|
||||
| `POST /einvoice/peppol/send` | `peppol_retry_send` | `MessageResponse` |
|
||||
| `POST /einvoice/peppol/sg/c5/activate` | `peppol_sg_c5_activate` | `MessageResponse` |
|
||||
| `POST /einvoice/peppol/sg/c5/deactivate` | `peppol_sg_c5_deactivate` | `MessageResponse` |
|
||||
| `POST /einvoice/peppol/sg/c5/cancel` | `peppol_sg_c5_cancel` | `MessageResponse` |
|
||||
|
||||
Three things here behave unlike the rest of the API:
|
||||
|
||||
- **A failing check is a non-2xx.** `validate_einvoice` answers `422` when
|
||||
validation finds problems, so "this invoice is not Peppol-ready" — an
|
||||
ordinary result — arrives as `Error::Api`, and the per-entity detail in the
|
||||
body is not reachable through the typed error. An `Ok` means it passed.
|
||||
- **Error bodies are not the usual shape.** The Peppol handlers return
|
||||
`data_get($response, 'message')` as the entire body, which can be a bare
|
||||
string. `Error::Api`'s `message` then falls back to an excerpt and `errors`
|
||||
is empty.
|
||||
- **`peppol_setup` has two success shapes.** A `corppass_url` when the
|
||||
connection needs a further browser step (Singapore's CorpPass), or a bare
|
||||
`204` when it completed. Hence `Option<Value>` rather than treating the
|
||||
empty body as a decode failure.
|
||||
|
||||
`peppol_remove_legal_identifier` is the only `DELETE` in the crate that
|
||||
carries a request body.
|
||||
|
||||
## Integrations
|
||||
|
||||
| Route | Method | Returns |
|
||||
| --- | --- | --- |
|
||||
| `POST /quickbooks/sync` | `quickbooks_sync` | `()` (204) |
|
||||
| `POST /quickbooks/action` | `quickbooks_action` | `()` (204) |
|
||||
| `POST /quickbooks/disconnect` | `quickbooks_disconnect` | `()` (204) |
|
||||
| `POST /quickbooks/reconnect_url` | `quickbooks_reconnect_url` | `Value` |
|
||||
| `POST /stripe/update_payment_methods` | `stripe_update_payment_methods` | `MessageResponse` |
|
||||
| `POST /stripe/import_customers` | `stripe_import_customers` | `MessageResponse` |
|
||||
| `POST /stripe/verify` | `stripe_verify` | `Value` |
|
||||
| `POST /stripe/disconnect/{id}` | `stripe_disconnect` | `Value` |
|
||||
| `GET /calendar_connection` | `calendar_connection` | `Value` |
|
||||
| `POST /calendar_connection/{provider}/complete` | `complete_calendar_connection` | `Value` |
|
||||
| `GET /calendar_connection/calendars` | `calendar_connection_calendars` | `Value` |
|
||||
| `GET /calendar_connection/events` | `calendar_connection_events` | `Value` |
|
||||
| `PUT /calendar_connection/calendars` | `update_calendar_connection_calendars` | `Value` |
|
||||
| `DELETE /calendar_connection` | `delete_calendar_connection` | `()` |
|
||||
| `POST /yodlee/status/{account_number}` | `yodlee_account_status` | `Value` |
|
||||
| `GET /nordigen/institutions` | `nordigen_institutions` | `Value` |
|
||||
|
||||
All four Stripe routes are **password-protected** — supply
|
||||
`with_api_password` — and answer `403` for a non-admin.
|
||||
|
||||
These operate a connection that already exists. **Establishing one is a
|
||||
browser flow and is not wrappable**: the OAuth authorize and callback legs
|
||||
live outside the `api/v1` group, take no API token, and answer with
|
||||
redirects. See [the gap list](#individual-endpoints-not-wrapped).
|
||||
|
||||
`yodlee_account_status` takes Yodlee's own `account_id`, as stored on the
|
||||
matching `BankIntegration` — not an Invoice Ninja id. Its upstream route
|
||||
carries a `@todo` questioning its own path, so treat it as liable to move.
|
||||
`nordigen_institutions` covers GoCardless, which was Nordigen when the route
|
||||
was named; an instance without GoCardless credentials answers `400` rather
|
||||
than an empty list.
|
||||
|
||||
## Document uploads
|
||||
|
||||
`PUT /{resource}/{id}/upload` exists for 16 resources, each getting
|
||||
`upload_{singular}(id, files, is_public)` that returns the refreshed record:
|
||||
|
||||
| Resource | Method |
|
||||
| --- | --- |
|
||||
| Clients | `upload_client` |
|
||||
| Companies | `upload_company` |
|
||||
| Credits | `upload_credit` |
|
||||
| Expenses | `upload_expense` |
|
||||
| Group Settings | `upload_group_setting` |
|
||||
| Invoices | `upload_invoice` |
|
||||
| Payments | `upload_payment` |
|
||||
| Products | `upload_product` |
|
||||
| Projects | `upload_project` |
|
||||
| Purchase Orders | `upload_purchase_order` |
|
||||
| Quotes | `upload_quote` |
|
||||
| Recurring Expenses | `upload_recurring_expense` |
|
||||
| Recurring Invoices | `upload_recurring_invoice` |
|
||||
| Recurring Quotes | `upload_recurring_quote` |
|
||||
| Tasks | `upload_task` |
|
||||
| Vendors | `upload_vendor` |
|
||||
|
||||
This is the *only* way the API creates a document:
|
||||
`DocumentController::store` is an empty stub upstream, so `Document` has
|
||||
`list`, `get`, `update`, `delete`, `download`, and `bulk`, but no
|
||||
`create_document`, and there will not be one.
|
||||
|
||||
Three details that are easy to get wrong, all verified against the upstream
|
||||
source rather than the published spec:
|
||||
|
||||
- **The request is a `POST` carrying `_method=PUT`.** The route is
|
||||
registered as `PUT`, but PHP does not parse a multipart body on a `PUT`, so
|
||||
the files would never arrive. Laravel's kernel turns the spoofed method back
|
||||
into a `PUT` before routing. The OpenAPI spec documents `POST` with
|
||||
`_method=POST`, which would 405 against these routes.
|
||||
- **`is_public` is sent as `1`/`0`.** Laravel's `boolean` rule accepts only
|
||||
`true`, `false`, `0`, `1`, `'0'`, and `'1'` under strict comparison, so the
|
||||
string `"true"` fails validation.
|
||||
- **Uploads are never retried**, even on a 429. An upload adds a document, so
|
||||
it is not idempotent whatever its verb, and a replay after a lost response
|
||||
would attach the file twice.
|
||||
|
||||
The server enforces file type and size limits; a refusal comes back as a 422
|
||||
`Error::Api` with the per-field `errors` map. Files are held in memory while
|
||||
uploading.
|
||||
|
||||
## Other wrapped endpoints
|
||||
|
||||
| Endpoint | Method |
|
||||
@@ -164,16 +369,164 @@ the whole tail — `chart("project_burnup/<id>", &body)`.
|
||||
Large report exports are queued and emailed rather than returned inline; in
|
||||
that case the body is a short JSON message instead of CSV.
|
||||
|
||||
## Activities, email history and previews
|
||||
|
||||
Each has a response shape of its own. Marked *observed* where confirmed
|
||||
against a 5.13.50 server.
|
||||
|
||||
| Endpoint | Method | Returns |
|
||||
| --- | --- | --- |
|
||||
| `GET /activities` | `list_activities`, `list_all_activities` | `Vec<Activity>` (*observed*) |
|
||||
| `GET /activities?reactv2=1` | `activity_feed(rows)` | `Vec<ActivityFeedEntry>` (*observed*) |
|
||||
| `POST /activities/entity` | `entity_activity(entity, id, rows)` | `Vec<ActivityFeedEntry>` (*observed*) |
|
||||
| `POST /activities/notes` | `add_activity_note(entity, id, notes)` | `Activity` (*observed*) |
|
||||
| `GET /activities/download_entity/{id}` | `download_activity_entity` | PDF bytes |
|
||||
| `POST /emails/clientHistory/{client}` | `client_email_history` | `Vec<serde_json::Value>` |
|
||||
| `POST /emails/entityHistory` | `entity_email_history` | `Vec<serde_json::Value>` |
|
||||
| `POST /preview` | `preview`, `preview_html` | PDF bytes / HTML |
|
||||
| `POST /live_preview` | `live_preview` | PDF bytes (*observed*) |
|
||||
| `POST /live_design` | `live_design` | PDF bytes |
|
||||
| `POST /preview/purchase_order` | `preview_purchase_order` | PDF bytes |
|
||||
| `POST /live_preview/purchase_order` | `live_preview_purchase_order` | PDF bytes |
|
||||
|
||||
Things that are easy to get wrong:
|
||||
|
||||
- **The entity name is singular in one route and plural in the next.**
|
||||
`entity_activity` takes `invoice`; `add_activity_note` takes `invoices`.
|
||||
- **`rows`, not `per_page`, caps the activity log** (default 75). Pass it
|
||||
through `ListParams::param`. `entity_activity` leaves `rows` out when not
|
||||
given rather than sending `null`, because the server tests for the key and
|
||||
a null would count as given and remove the cap.
|
||||
- **The two activity shapes differ.** `Activity` carries hashed ids and a
|
||||
string `activity_type_id`; the feed's `ActivityFeedEntry` carries a numeric
|
||||
one and labelled references (`invoice`, `client`, … each `{label,
|
||||
hashed_id}`).
|
||||
- **Email history is raw JSON.** The objects mirror what the mail provider
|
||||
reported, and the instance available had never sent mail, so there was no
|
||||
shape to take.
|
||||
- **Not yet run against a real server:** `preview`, `preview_html`, and both
|
||||
purchase-order previews (they need a design object or a purchase order and a
|
||||
PDF backend), and `live_design`, which answered a bare minimal request with
|
||||
a 500 from the server on 5.13.50.
|
||||
|
||||
## Password-protected actions
|
||||
|
||||
Behind the `password_protected` middleware, so each needs
|
||||
`with_api_password` (the server also lets a recent password-authenticated
|
||||
session through without the header). Most are **irreversible**, and all are
|
||||
`POST`s except one, so none is retried on a 5xx.
|
||||
|
||||
| Endpoint | Method | Returns |
|
||||
| --- | --- | --- |
|
||||
| `POST /clients/{id}/purge` | `purge_client` | `MessageResponse` |
|
||||
| `POST /clients/{id}/{other}/merge` | `merge_clients(id, other)` | the surviving `Client` |
|
||||
| `POST /vendors/{id}/{other}/merge` | `merge_vendors(id, other)` | the surviving `Vendor` |
|
||||
| `POST /companies/purge/{id}` | `purge_company` | `MessageResponse` |
|
||||
| `POST /companies/purge_save_settings/{id}` | `purge_company_save_settings` | `MessageResponse` |
|
||||
| `POST /users/{id}/invite` | `invite_user` | `MessageResponse` |
|
||||
| `POST /users/{id}/purge` | `purge_user` | `()` (the server answers 204) |
|
||||
| `GET /users/create` | `new_user_template` | a blank `User`, nothing stored |
|
||||
|
||||
A merge keeps the first id and folds the second into it. The smoke test's
|
||||
optional password tier (merge two throwaway clients, purge the survivor) was
|
||||
run against a disposable 5.13.50 instance on 2026-10-24 and passes; it also
|
||||
established that a purged record reads back as a **400**, not a 404. A
|
||||
password containing a control character cannot be a header value and goes out
|
||||
as `X-API-PASSWORD-BASE64` instead.
|
||||
|
||||
## Logout, passkeys, and login
|
||||
|
||||
`/login`, `/logout`, and the passkey routes used to sit together under one
|
||||
"Auth" gap. They don't share a shape: `/logout` and the passkey *settings*
|
||||
routes are plain `token_auth` endpoints like everything else this crate
|
||||
wraps, while `/login` sits in a different, unauthenticated middleware group
|
||||
because the caller has no token yet to prove who they are.
|
||||
|
||||
| Endpoint | Method | Needs an existing client? |
|
||||
| --- | --- | --- |
|
||||
| `POST /logout` | `logout` | yes |
|
||||
| `GET /settings/passkeys` | `list_passkeys` | yes |
|
||||
| `DELETE /settings/passkeys/{id}` | `delete_passkey` | yes |
|
||||
| `POST /login` | `InvoiceNinjaClient::login` (associated fn, not a method) | **no** |
|
||||
|
||||
**`logout` has a bigger blast radius than its name suggests.** The
|
||||
controller doesn't revoke the caller's token — it regenerates *every*
|
||||
`is_system` token belonging to the whole company, logging out every user
|
||||
and device that shares it, including the one making the call.
|
||||
|
||||
**Passkey registration is not wrapped.** `POST /settings/passkeys/options`
|
||||
and `POST /settings/passkeys` exist to drive a WebAuthn ceremony that only a
|
||||
browser talking to a platform authenticator can complete — there is no
|
||||
attestation object for this crate to produce standalone. Listing and
|
||||
deleting an existing credential need no such ceremony, so those are the two
|
||||
operations offered.
|
||||
|
||||
**`login` returns the server's full "first load" payload** — nested
|
||||
company, clients, invoices, designs, and more under each `CompanyUser`
|
||||
entry — because it reuses the same transformer as a browser's initial page
|
||||
load. This crate does not model that nested tree; [`LoginCompanyUser`]
|
||||
types only `is_owner`, `is_admin`, and the `token` it exists to produce,
|
||||
and leaves the rest in `extra`, losslessly. [`LoginCompanyUser::token`]
|
||||
is what a caller actually needs — feed it to
|
||||
[`InvoiceNinjaClient::new`] or [`InvoiceNinjaClient::hosted`].
|
||||
|
||||
`login` optionally takes a one-time password for accounts with two-factor
|
||||
auth enabled. It does not support passkey login, SSO (`oauth_login`,
|
||||
`oidc/*`), `/login/precheck`, or a self-hosted instance's optional
|
||||
`X-API-SECRET` header — each needs a ceremony, a redirect, or a header this
|
||||
single email/password call does not send.
|
||||
|
||||
`/refresh` and `/refresh_react` were in the old "Auth" bucket too, but
|
||||
they're not an authentication concept: `BaseController::refreshResponse`
|
||||
is the same bulk, `updated_at`-filtered account dump as `/login`'s first
|
||||
load. That's the sync-engine shape [`GOALS.md`](GOALS.md) already parks as
|
||||
a separate open question, not something to bolt onto auth — not
|
||||
implemented, for the same reason.
|
||||
|
||||
`/connected_account` (linking a Gmail OAuth account) was also filed under
|
||||
"Auth". It's an integration-linking endpoint, not login/logout, so it
|
||||
belongs with Stripe/QuickBooks under [Integrations](#integrations) above —
|
||||
also not implemented.
|
||||
|
||||
## Per-resource extras
|
||||
|
||||
Routes that sit beside a resource's CRUD set, each with its own response
|
||||
shape (read from the upstream controllers; the ones marked *observed* were
|
||||
also confirmed against a 5.13.50 server). All are `POST` unless noted, so none
|
||||
is retried on a 5xx or a timeout.
|
||||
|
||||
| Endpoint | Method | Returns |
|
||||
| --- | --- | --- |
|
||||
| `POST /webhooks/{id}/retry` | `retry_webhook` | `Webhook` |
|
||||
| `POST /tasks/sort` | `sort_tasks` | `MessageResponse` |
|
||||
| `POST /designs/set/default` | `set_default_design` | `MessageResponse` |
|
||||
| `POST /client_gateway_tokens/{id}/setAsDefault` | `set_default_client_gateway_token` | `ClientGatewayToken` |
|
||||
| `GET /subscriptions/steps` | `subscription_steps` | `Vec<SubscriptionStep>` (*observed*: an object keyed by class name, not the array the source suggests) |
|
||||
| `POST /subscriptions/steps/check` | `check_subscription_steps` | `()` |
|
||||
| `POST /company_gateways/{id}/test` | `test_company_gateway` | `MessageResponse` |
|
||||
| `POST /company_gateways/{id}/clone` | `clone_company_gateway` | `CompanyGateway` |
|
||||
| `POST /company_gateways/{id}/import_customers` | `import_company_gateway_customers` | `MessageResponse` |
|
||||
| `POST /bank_integrations/refresh_accounts` | `refresh_bank_accounts` | `()` (the body is a serialised query builder) |
|
||||
| `POST /bank_integrations/remove_account/{id}` | `remove_bank_account` | `BankIntegration` |
|
||||
| `POST /bank_integrations/get_transactions/{id}` | `fetch_bank_transactions` | `MessageResponse` (the server ignores the id) |
|
||||
| `POST /bank_transactions/match` | `match_bank_transactions` | `Vec<BankTransaction>` |
|
||||
| `POST /clients/{id}/updateTaxData` | `update_client_tax_data` | `Client` |
|
||||
| `POST /clients/{id}/show_settings` | `client_show_settings` | `serde_json::Value` (*observed*) |
|
||||
| `POST /clients/{id}/peppol_discovery` | `client_peppol_discovery` | `serde_json::Value` |
|
||||
| `POST /clients/{id}/check_vat` | `check_client_vat` | `MessageResponse` |
|
||||
| `GET /clients/{id}/vat_status` | `client_vat_status` | `MessageResponse` (*observed*) |
|
||||
| `POST /clients/{id}/documents` | `client_documents` | `Vec<Document>` (*observed*; one page) |
|
||||
|
||||
`updateTaxData` and `check_vat` call out to external tax services, so the
|
||||
smoke test does not exercise them.
|
||||
|
||||
## Not covered
|
||||
|
||||
### Blocked on a dependency change
|
||||
### Nothing is blocked on a dependency
|
||||
|
||||
**Multipart uploads** — `PUT /{resource}/{id}/upload` exists for clients,
|
||||
companies, credits, expenses, group_settings, invoices, payments, products,
|
||||
projects, purchase_orders, quotes, recurring_expenses, recurring_invoices,
|
||||
recurring_quotes, tasks, and vendors. These need reqwest's `multipart`
|
||||
feature, which isn't currently enabled. This is the main reason `Document`
|
||||
has no `create_document`.
|
||||
Multipart uploads used to be, and are now wrapped — see [Document
|
||||
uploads](#document-uploads) and [Import and
|
||||
export](#import-and-export).
|
||||
|
||||
### Resource groups not wrapped
|
||||
|
||||
@@ -184,62 +537,53 @@ from `src/models/mod.rs`, and add a `resource_methods!` invocation in
|
||||
`src/client.rs` (or `crud_methods!` if the resource has no `/bulk` route —
|
||||
check `routes/api.php`).
|
||||
|
||||
### Password-protected endpoints still unwrapped
|
||||
|
||||
`with_api_password` makes these reachable, but the methods themselves aren't
|
||||
written yet:
|
||||
|
||||
- `POST /clients/{id}/purge`
|
||||
- `POST /clients/{id}/{other}/merge`
|
||||
- `POST /vendors/{id}/{other}/merge`
|
||||
- `POST /companies/purge/{company}`,
|
||||
`POST /companies/purge_save_settings/{company}`
|
||||
- `POST /users/{id}/invite`, `POST /users/{id}/purge`, `GET /users/create`
|
||||
|
||||
### Individual endpoints not wrapped
|
||||
|
||||
- **Client extras** — `updateTaxData`, `show_settings`, `peppol_discovery`,
|
||||
`check_vat`, `vat_status`, `documents`
|
||||
- **Bank integration extras** — `refresh_accounts`, `remove_account/{id}`,
|
||||
`get_transactions/{id}`, and `POST /bank_transactions/match`
|
||||
- **Company gateway extras** — `test`, `clone`, `import_customers`
|
||||
- **Company extras** — `GET /companies/{id}/logo`,
|
||||
`POST /companies/{id}/default`, `updateOriginTaxData`,
|
||||
`POST /companies/current`
|
||||
- **Design extras** — `POST /designs/set/default`
|
||||
- **Client gateway token extras** — `setAsDefault`
|
||||
- **Task extras** — `POST /tasks/sort`
|
||||
- **Webhook extras** — `POST /webhooks/{id}/retry`
|
||||
- **Subscription extras** — `GET /subscriptions/steps`,
|
||||
`POST /subscriptions/steps/check`
|
||||
- **User extras** (not password-protected, unlike the rest of `/users`) —
|
||||
`POST /users/{id}/disconnect_mailer`, `POST /users/{id}/disconnect_oauth`,
|
||||
`POST /user/{id}/reconfirm`
|
||||
- **Preview** — `POST /preview`, `POST /preview/purchase_order`, live preview
|
||||
- **Activities** — `GET /activities`, `POST /activities/entity`,
|
||||
`POST /activities/notes`, `download_entity/{activity}`
|
||||
- **Email history** — `POST /emails/clientHistory/{client}`,
|
||||
`POST /emails/entityHistory`
|
||||
- **Auth** — `/login`, `/logout`, `/refresh`, `/connected_account`,
|
||||
passkey routes
|
||||
- **Import/export** — `/import`, `/preimport`, `/import_json`, `/export`,
|
||||
`/migration/start`
|
||||
- **Auth** — see [Logout, passkeys, and login](#logout-passkeys-and-login)
|
||||
above for what's covered (`login`, `logout`, passkey list/delete) and
|
||||
what isn't: `/login/precheck`, `oauth_login`, `oidc/*`, passkey
|
||||
registration and passkey login, `/refresh`, `/refresh_react`, and
|
||||
`/connected_account`
|
||||
- **Migration** — `/migration/start`. Moves a company between instances and
|
||||
is driven by the v4 migration tool, not by an SDK caller.
|
||||
- **Invoice extras** — `delivery_note`, `payment_schedule`,
|
||||
`update_reminders`
|
||||
- **Public invitation-key downloads** — `GET /{entity}/{invitation_key}/download`
|
||||
for `invoice`, `quote`, `credit`, and `purchase_order` (note the singular
|
||||
path segment), plus the matching e-invoice variants `download_e_invoice`,
|
||||
`download_e_quote`, `download_e_credit`,
|
||||
`download_e_purchase_order`. These are invitation-key based and take no
|
||||
auth token, so they don't fit the client's authenticated request path.
|
||||
- **Integrations** — Stripe, QuickBooks, Apple in-app purchase, PostMark,
|
||||
einvoice (17 routes), calendar_connection, self-update, templates, hooks,
|
||||
Yodlee (`/yodlee/*`), Nordigen (`/nordigen/institutions`)
|
||||
- **Client-portal invitation downloads** — the `routes/client.php` family:
|
||||
`/client/invoice/{key}/download_pdf`, the `download_e_*` variants for
|
||||
quotes and credits, `/client/{entity}/{key}/download`, and
|
||||
`/client/documents/{hash}/hashed`. These are not under `/api/v1`, so they
|
||||
don't fit a `base_url` that ends there. Note these are *not* the
|
||||
invitation-key downloads documented above, which are authenticated
|
||||
`/api/v1` routes and are wrapped.
|
||||
- **Integration routes that cannot take an API token** — the OAuth legs of
|
||||
the QuickBooks and calendar flows (`/quickbooks/authorize/{token}`,
|
||||
`/quickbooks/reconnect/{token}`, `/quickbooks/authorized`,
|
||||
`/calendar_connection/{provider}/authorize/{hash}`) and the provider
|
||||
webhooks (`/yodlee/refresh`, `/yodlee/data_updates`,
|
||||
`/yodlee/refresh_updates`, `/yodlee/balance`, `/postmark_webhook`,
|
||||
`/sns_webhook`, `/postmark_inbound_webhook`, `/payment_webhook/…`). The
|
||||
OAuth routes sit outside the `api/v1` group and answer with browser
|
||||
redirects; the webhooks are things a provider calls, not things a client
|
||||
calls. Connecting an account is a browser flow — see
|
||||
[Integrations](#integrations) for the methods that operate a connection
|
||||
once it exists.
|
||||
- **`POST /quickbooks/settings`** — the route is registered, but
|
||||
`QuickbooksController` has no `settings` method, so it raises
|
||||
`BadMethodCallException` and 500s. Nothing to wrap until upstream adds the
|
||||
handler.
|
||||
- **Other integrations** — Apple in-app purchase, PostMark, self-update,
|
||||
templates, hooks (commented out upstream)
|
||||
- **Misc** — `/company_users`, `/company_ledger`, `/client_statement`,
|
||||
`/claim_license`, `/check_license`, `/check_subdomain`, `/one_time_token`,
|
||||
`/last_error`, `/support/messages/send`, `/verify/*`, `/accounts/{id}`,
|
||||
`/feedback`, `/filters/{entity}`, `/scheduler`, `/mailer/check`,
|
||||
`/smtp/check`, `/password_timeout`, `/refresh_react`, `/webcron`,
|
||||
`/smtp/check`, `/password_timeout`, `/webcron`,
|
||||
`/token_hash_router`, `/reactivate_email/{bounce_id}`,
|
||||
`/protected_download/{hash}`, `/confirm_purchase`,
|
||||
`/exports/preview/{hash}`, `/templates/preview/{hash}`
|
||||
@@ -252,6 +596,17 @@ so fields they don't model stay reachable rather than being dropped, and
|
||||
each has a generic counterpart — `statics_as`, `search_as`,
|
||||
`health_check_as` — for pinning a shape they leave untyped.
|
||||
|
||||
The [e-invoicing](#e-invoicing) and [integration](#integrations) methods
|
||||
return `serde_json::Value` as a rule, which is the largest untyped surface in
|
||||
the crate and is deliberate. Their replies are pass-throughs from an external
|
||||
service — Storecove, Stripe's gateway driver, Yodlee, GoCardless — or arrays
|
||||
assembled for Invoice Ninja's own React client. None has a documented schema,
|
||||
several differ by provider, and they change on the provider's release
|
||||
schedule rather than Invoice Ninja's. The precedent is
|
||||
`client_peppol_discovery`, which has returned `Value` since it landed. Where
|
||||
a shape *is* stable and is the point of the call it is still typed, as with
|
||||
`ExportJob` and `ImportPreview`.
|
||||
|
||||
Two remain generic on purpose:
|
||||
|
||||
- **`chart`** — the eleven chart endpoints share no common response shape
|
||||
@@ -286,7 +641,8 @@ top of a shared `BaseEntity` offering `all`, `get`, `create`, `update`,
|
||||
`recurring_quotes`, `recurring_expenses`, `bank_transaction_rules`,
|
||||
`company_gateways`, `designs`, `locations`, `tags`, `task_schedulers`,
|
||||
`tokens`, `system_logs`, documents, per-entity actions (`mark_sent`,
|
||||
`mark_paid`, `clone_to_*`, …), per-entity PDF download, reports, charts,
|
||||
`mark_paid`, `clone_to_*`, …), per-entity PDF download by id and by
|
||||
invitation key, the XML e-documents, reports, charts,
|
||||
search, `send_email`, `refund_payment`, `ping`, `health_check`, automatic
|
||||
pagination (`list_all_*`), and `X-API-PASSWORD` support for the
|
||||
password-protected routes.
|
||||
|
||||
Generated
+26
-2
@@ -528,8 +528,8 @@ dependencies = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "invoiceninjars"
|
||||
version = "0.1.0"
|
||||
name = "invoiceninja-sdk"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"percent-encoding",
|
||||
"reqwest",
|
||||
@@ -600,6 +600,22 @@ version = "2.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||
|
||||
[[package]]
|
||||
name = "mime"
|
||||
version = "0.3.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
||||
|
||||
[[package]]
|
||||
name = "mime_guess"
|
||||
version = "2.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
|
||||
dependencies = [
|
||||
"mime",
|
||||
"unicase",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mio"
|
||||
version = "1.2.4"
|
||||
@@ -776,6 +792,7 @@ dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
@@ -784,6 +801,7 @@ dependencies = [
|
||||
"hyper-util",
|
||||
"js-sys",
|
||||
"log",
|
||||
"mime_guess",
|
||||
"native-tls",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
@@ -1163,6 +1181,12 @@ version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
||||
|
||||
[[package]]
|
||||
name = "unicase"
|
||||
version = "2.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "357cc3acc6a036009fd6c973ed009037c732d60d0b4f6c673e9041497482a28f"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.27"
|
||||
|
||||
+7
-3
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "invoiceninjars"
|
||||
version = "0.1.0"
|
||||
name = "invoiceninja-sdk"
|
||||
version = "0.2.0"
|
||||
edition = "2021"
|
||||
# Set by the dependency graph rather than by this crate's own code: reqwest
|
||||
# pulls in url -> idna -> idna_adapter -> icu_*, and the icu crates require
|
||||
@@ -10,9 +10,13 @@ rust-version = "1.88"
|
||||
description = "An async Rust client for the Invoice Ninja v5 API"
|
||||
license = "MIT"
|
||||
repository = "https://github.com/awkawb1/invoiceninja-sdk"
|
||||
# Slugs checked against https://crates.io/api/v1/category_slugs — crates.io
|
||||
# rejects a publish with an unrecognised one, so these aren't freeform.
|
||||
categories = ["api-bindings", "web-programming::http-client", "asynchronous"]
|
||||
keywords = ["invoiceninja", "invoicing", "billing", "async", "sdk"]
|
||||
|
||||
[dependencies]
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "native-tls"] }
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "native-tls", "multipart"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
thiserror = "2"
|
||||
|
||||
@@ -85,6 +85,38 @@ Invoice Ninja account to contribute.
|
||||
comparison in `COVERAGE.md`. The divergences — JSON instead of
|
||||
`form_params`, keeping the whole validation `errors` map, exposing bulk
|
||||
`download` only on PDF-rendering entities — are deliberate.
|
||||
- **No CLI, and no binary at all, in this package.** A command-line client
|
||||
over this library is a reasonable thing to want — it is the obvious way to
|
||||
ask "what does this endpoint actually return", which matters here because
|
||||
the e-invoicing and integration methods are typed as
|
||||
`serde_json::Value` and were verified against mocks rather than a live
|
||||
service. It belongs in a sibling crate (`invoiceninja-sdk-cli`) in a
|
||||
workspace, not in this one, for three reasons:
|
||||
|
||||
- *Dependencies.* Every entry in `Cargo.toml` is there because the library
|
||||
needs it, and three of the five are already in the tree via `reqwest`.
|
||||
An argument parser would be the first dependency that is not
|
||||
load-bearing for the library, along with the project's first optional
|
||||
dependency and first `[features]` section.
|
||||
- *MSRV.* Not a break today — `clap` 4.6.7 declares 1.85, under this
|
||||
crate's 1.88 (checked 2026-10-25). The cost is the coupling: a second
|
||||
floor that moves on someone else's release schedule, in a project where
|
||||
three places must agree on the number and a documented procedure exists
|
||||
for rechecking it. A separate crate keeps it out of the library's graph.
|
||||
- *Versioning.* Pre-1.0 this crate tells callers to pin an exact version
|
||||
and expect breaking changes in minor releases. A CLI's flags are a
|
||||
compatibility surface too, and one version number would force the two to
|
||||
move in lockstep. `cargo install invoiceninja-sdk` silently needing
|
||||
`--features cli` is a smaller version of the same problem.
|
||||
|
||||
For ad-hoc probing, `examples/` already covers it and costs nothing:
|
||||
`capture_shapes`, `seed_sample_data` and `smoke_test` are CLI-shaped
|
||||
programs configured from environment variables, with the
|
||||
`IN_SMOKE_WRITE_HOST` guard to copy for anything that writes.
|
||||
|
||||
Same shape of decision as the `updated_at` sync engine in the open
|
||||
questions: a different product from this crate, recorded so the option is
|
||||
not forgotten.
|
||||
- **No sync/blocking API.** Async only.
|
||||
- **No identity map, lazy relationship loading, or transparent entity
|
||||
caching.** Three separate refusals with one theme — the client stays
|
||||
@@ -121,7 +153,7 @@ releases. `CHANGELOG.md` records them, and breaking changes should be
|
||||
legible there rather than discovered at compile time.
|
||||
|
||||
**Gitea is the source of truth; GitHub is a one-way mirror.** The canonical
|
||||
repository is `gitea.awkawb.cloud/awkawb/invoiceninja`. A Gitea push mirror
|
||||
repository is `gitea.awkawb.cloud/awkawb/invoiceninja-sdk`. A Gitea push mirror
|
||||
copies every commit to <https://github.com/awkawb1/invoiceninja-sdk>, which
|
||||
is what `Cargo.toml` points at. Nothing should be written to the GitHub
|
||||
copy: the next sync overwrites it, so a merged pull request there would
|
||||
@@ -137,50 +169,140 @@ without checking — see the note in `CLAUDE.md`.
|
||||
|
||||
## Roadmap: closing the coverage gaps
|
||||
|
||||
All 34 `Route::resource` groups are wrapped. Full coverage is the target, so
|
||||
`COVERAGE.md`'s gap list is a to-do list, not a permanent boundary. Roughly
|
||||
in order of value over effort:
|
||||
**This roadmap is now closed** — every item below is done, and every route an
|
||||
API-token client can reach is wrapped. It is kept rather than deleted because
|
||||
several entries record what an item's original reasoning got wrong, which is
|
||||
worth more than the fact that it shipped. What is still unwrapped is in
|
||||
`COVERAGE.md`'s gap list, with a reason per entry, and none of it is a
|
||||
sequenced to-do any more.
|
||||
|
||||
1. **Multipart uploads** — 16 `PUT /{resource}/{id}/upload` routes plus
|
||||
`edocument/upload`. Blocked only on enabling reqwest's `multipart`
|
||||
feature. This is also what makes `create_document` possible, so it
|
||||
unblocks the one obviously missing piece of an otherwise complete
|
||||
resource.
|
||||
2. **Per-resource extras** — mechanical, and they reuse existing patterns:
|
||||
`webhooks/{id}/retry`, `tasks/sort`, `designs/set/default`,
|
||||
`client_gateway_tokens/{id}/setAsDefault`, `subscriptions/steps`, the
|
||||
client extras (`updateTaxData`, `show_settings`, `check_vat`, …), the
|
||||
bank-integration and company-gateway extras.
|
||||
3. **Password-protected actions** — `with_api_password` already makes these
|
||||
reachable; only the methods are missing. Client purge and merge, vendor
|
||||
merge, company purge, user invite and purge.
|
||||
4. **Preview, activities, and email history** — distinct response shapes, so
|
||||
each needs its own hand-written method and probably its own model.
|
||||
5. **Public invitation-key downloads** — eight routes that take no auth
|
||||
token, so they do not fit the authenticated request path and need a
|
||||
different entry point.
|
||||
6. **Import/export and the integrations** — Stripe, QuickBooks, einvoice
|
||||
(17 routes), Yodlee, Nordigen, calendar. The largest block and the least
|
||||
SDK-shaped; worth doing last and possibly in pieces.
|
||||
The items ran roughly in order of value over effort:
|
||||
|
||||
Keep `COVERAGE.md` current as these land — it names exact counts, so it goes
|
||||
stale quietly.
|
||||
**Done:** multipart document uploads, for the 16 resources that have the
|
||||
route (`upload_client`, `upload_invoice`, …). This earlier claimed it would
|
||||
also make `create_document` possible, which was wrong: upstream
|
||||
`DocumentController::store` is an empty stub, and an upload is the *only*
|
||||
way a document is created. There is no `create_document` and there will not
|
||||
be one. `edocument/upload` was left out because it imports an expense from
|
||||
an e-document rather than attaching a file, so it belongs with item 6.
|
||||
|
||||
**Done:** the per-resource extras — webhook retry, task sort, default
|
||||
design, default gateway token, subscription steps, and the client,
|
||||
bank-integration, and company-gateway routes (19 methods). The numbering
|
||||
below keeps its original item numbers. Running the smoke test against a real
|
||||
server overturned one reading of the source here: `/subscriptions/steps`
|
||||
answers with an object keyed by class name, not the array the controller
|
||||
suggests. That is the case for keeping the real-server tier.
|
||||
|
||||
**Done:** the password-protected actions — client purge and merge, vendor
|
||||
merge, company purge, user invite and purge, and the blank-user template (8
|
||||
methods). The smoke test's password tier (`IN_API_PASSWORD`) has since been
|
||||
run against a disposable 5.13.50 instance, on 2026-10-24, with all four
|
||||
checks passing — but only after its own post-purge assertion was fixed, which
|
||||
had checked for a 404 and passed solely because it had never been run. The
|
||||
claim here that `with_api_password` already made them reachable was not quite
|
||||
true: a password with a control character was refused locally.
|
||||
|
||||
**Done:** activities, email history and previews (14 methods, two new
|
||||
models). The email history stays raw JSON, since no instance here had sent
|
||||
mail to take a shape from; promoting it to a model needs a sample. Several
|
||||
previews are unverified against a real server (see `COVERAGE.md`).
|
||||
|
||||
**Done:** the invitation-key downloads — the PDF and the XML e-document for
|
||||
invoices, quotes, credits and purchase orders, keyed by a contact's
|
||||
invitation key (8 methods). This item used to read "eight routes that take no
|
||||
auth token, so they do not fit the authenticated request path and need a
|
||||
different entry point." That was wrong on every count, and wrong in the way
|
||||
goal 6 exists to catch: the premise came from reading the route *names*
|
||||
rather than the route file. All eight sit inside `routes/api.php`'s `api/v1`
|
||||
group under its `token_auth` middleware — the controllers' own OpenAPI blocks
|
||||
declare `X-API-TOKEN` — so they needed no new entry point and were two dozen
|
||||
lines of macro. What the item had actually described is the client-portal
|
||||
family in `routes/client.php`, which is genuinely token-less, is not under
|
||||
`/api/v1`, and stays unwrapped for that reason.
|
||||
|
||||
**Done:** import/export and the integrations — `/import`, `/preimport`,
|
||||
`/import_json`, `/export`, `edocument/upload`, einvoice (17 routes),
|
||||
QuickBooks, Stripe, calendar, Yodlee and Nordigen (38 methods). This was the
|
||||
last item, so **the roadmap is closed**: every route in `routes/api.php` that
|
||||
an API-token client can reach is now wrapped. It did land in pieces, as the
|
||||
item predicted — import/export first, then the integrations.
|
||||
|
||||
**Done:** the auth routes, once split apart — the single "auth routes"
|
||||
label had quietly merged four unrelated things. `logout` and the passkey
|
||||
list/delete routes sit behind the same `token_auth` middleware as every
|
||||
other wrapped endpoint, so they needed no new client type, just methods
|
||||
(and a documented warning that `logout` rotates every token on the whole
|
||||
company, not just the caller's). `login` *does* need a different entry
|
||||
point — there is no token yet to build an `InvoiceNinjaClient` with — but
|
||||
that turned out to be a standalone associated function, not a second
|
||||
client type: `InvoiceNinjaClient::login` trades an email and password for
|
||||
a `LoginResponse` carrying a token, which then goes to `new` or `hosted`
|
||||
as normal. Passkey *registration* and passkey *login* stay unwrapped
|
||||
because they're WebAuthn ceremonies a browser has to drive; `/refresh` and
|
||||
`/refresh_react` turned out to be the bulk `updated_at` sync dump already
|
||||
parked below, not an auth concept, so they stay out for that reason
|
||||
instead; and `/connected_account` (Gmail OAuth linking) was filed under
|
||||
the wrong heading — it belongs with the Integrations done item above. See
|
||||
`COVERAGE.md`'s "Logout, passkeys, and login" section for the detail.
|
||||
|
||||
As the item also predicted, it is the least SDK-shaped part of the API, and
|
||||
the typing reflects that: most of these return `serde_json::Value`, because
|
||||
their replies are pass-throughs from Storecove, Stripe, Yodlee or GoCardless,
|
||||
or arrays assembled for Invoice Ninja's React client. That is a deliberate
|
||||
application of the rule in goal 2 — a wrong concrete type is a hard decode
|
||||
error, where `Value` is lossless — and not a shortcut to revisit without a
|
||||
reason. `COVERAGE.md` records it as the crate's largest untyped surface.
|
||||
|
||||
Two things in this block are *not* wrapped, and cannot be. The OAuth legs of
|
||||
the QuickBooks and calendar flows take no API token and answer with browser
|
||||
redirects, so connecting an account stays a browser flow; the methods here
|
||||
operate a connection that already exists. And `POST /quickbooks/settings` is
|
||||
a registered route with no controller method behind it, so it 500s — there is
|
||||
nothing to wrap until upstream adds the handler.
|
||||
|
||||
What remains unwrapped is now only what each entry in `COVERAGE.md`'s gap
|
||||
list explains: the passkey and SSO ceremonies and `/login/precheck`
|
||||
(each needs something — a browser, a redirect — this client doesn't have),
|
||||
`/refresh`/`/refresh_react` (the sync-engine shape parked below), the
|
||||
client-portal family, the provider webhooks, and a few company and user
|
||||
extras. Keep that file current — it names exact counts, so it goes stale
|
||||
quietly.
|
||||
|
||||
**Decided: `chart` stays generic.** The premise in the old open question —
|
||||
"eleven chart endpoints share no response shape" — undersold the problem.
|
||||
Reading `ChartController`/`ChartService` directly: the eleven routes are
|
||||
nine distinct bodies (`totals`/`totalsV2` and `chart_summary`/
|
||||
`chart_summaryV2` are pairs of the same implementation), and six of those
|
||||
nine *do* share an outer idiom — an object keyed by a company's own
|
||||
currency ids, plus a sentinel `999` key for the cross-currency aggregate.
|
||||
But the leaf rows inside that shape are raw `DB::select` results with no
|
||||
model behind them, and they don't agree with each other: within `totals()`
|
||||
alone, the `invoices` leaf carries `invoiced_amount`, `revenue` carries
|
||||
`paid_to_date`, `outstanding` carries `amount` *and* `outstanding_count`,
|
||||
and `chart_summary`'s four leaves differ again. That is goal 2's
|
||||
"ambiguous upstream type" problem — compounded by `SUM()` often coming
|
||||
back from the DB driver as a numeric string — repeated dozens of times
|
||||
over. The remaining three bodies (`cashflow_forecast`,
|
||||
`client_payment_analytics`, `project_analytics`/`project_burnup`) delegate
|
||||
to standalone service classes with no shape in common with each other or
|
||||
with the currency-keyed family, and `calculated_fields` returns whichever
|
||||
scalar or array type its ~20-way `field` match picks, so there is no fixed
|
||||
shape to type at all. Modeling the shared outer envelope would still leave
|
||||
dozens of hand-maintained, DB-column-shaped leaf structs for marginal
|
||||
benefit over a caller defining their own `T` for the one endpoint they
|
||||
use — which `chart::<T>` already lets them do.
|
||||
|
||||
## Open questions
|
||||
|
||||
These are genuinely undecided. Deciding one is a change to this file.
|
||||
|
||||
- **Auth routes** (`/login`, `/logout`, `/refresh`, passkeys) are in the gap
|
||||
list, but they are a different authentication model from the API token
|
||||
this client is built around. Including them may mean a second client type
|
||||
rather than more methods on this one. Not obviously in scope.
|
||||
- **`chart` is still generic** over `T: DeserializeOwned`, because the
|
||||
eleven chart endpoints share no response shape. Per-chart structs would
|
||||
be eleven small models of unstable payloads; unclear whether that earns
|
||||
its keep.
|
||||
- **Large responses are buffered entirely into memory.** PDF downloads and
|
||||
CSV report exports go through `Vec<u8>`. Streaming would be better for
|
||||
big exports but changes those signatures.
|
||||
- **Large bodies are buffered entirely into memory.** PDF downloads and CSV
|
||||
report exports come back as `Vec<u8>`, and an uploaded `FileUpload` holds
|
||||
its whole contents until the request finishes. Streaming would be better
|
||||
for big files in both directions but changes those signatures — and for
|
||||
uploads it would also change the retry story, which currently depends on
|
||||
the multipart body being un-cloneable.
|
||||
- **`Error::PaginationLimit` has no test**, because reaching it needs
|
||||
10,001 mock round trips. Making `MAX_PAGES` injectable purely for a test
|
||||
may be worse than the gap.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# invoiceninjars
|
||||
# invoiceninja-sdk
|
||||
|
||||
An async Rust client for the [Invoice Ninja](https://www.invoiceninja.com/) v5 API.
|
||||
|
||||
@@ -9,7 +9,7 @@ Requires Rust 1.88 or newer.
|
||||
> [`GOALS.md`](GOALS.md) covers what the crate is for and what it is not.
|
||||
|
||||
> **Development happens on
|
||||
> [Gitea](https://gitea.awkawb.cloud/awkawb/invoiceninja); the
|
||||
> [Gitea](https://gitea.awkawb.cloud/awkawb/invoiceninja-sdk); the
|
||||
> [GitHub copy](https://github.com/awkawb1/invoiceninja-sdk) is a read-only
|
||||
> mirror.** Anything pushed to GitHub is overwritten by the next sync, so
|
||||
> please open issues and pull requests on Gitea.
|
||||
@@ -33,7 +33,7 @@ nix develop .#msrv --command cargo test
|
||||
## Usage
|
||||
|
||||
```rust
|
||||
use invoiceninjars::{InvoiceNinjaClient, models::ListParams};
|
||||
use invoiceninja_sdk::{InvoiceNinjaClient, models::ListParams};
|
||||
|
||||
// Hosted app:
|
||||
let client = InvoiceNinjaClient::hosted(std::env::var("IN_API_TOKEN")?)?;
|
||||
@@ -75,7 +75,7 @@ with exponential backoff. Both are configurable:
|
||||
|
||||
```rust
|
||||
use std::time::Duration;
|
||||
use invoiceninjars::RetryPolicy;
|
||||
use invoiceninja_sdk::RetryPolicy;
|
||||
|
||||
let client = InvoiceNinjaClient::hosted(token)?
|
||||
.with_timeout(Duration::from_secs(300))
|
||||
@@ -100,7 +100,7 @@ IN_API_TOKEN=... cargo run --example list_clients
|
||||
|
||||
## Coverage
|
||||
|
||||
Every one of the API's 34 resource groups, plus users — 265 methods. Each
|
||||
Every one of the API's 34 resource groups, plus users — 375 methods. Each
|
||||
resource has `list`, `list_all`, `get`, `create`, `update`, `delete`, and
|
||||
(where the API offers one) `bulk`:
|
||||
|
||||
@@ -114,8 +114,11 @@ resource has `list`, `list_all`, `get`, `create`, `update`, `delete`, and
|
||||
| Admin | Companies, Users, Group Settings, Tokens, System Logs |
|
||||
| Settings | Tax Rates, Payment Terms, Webhooks, Designs, Locations, Tags |
|
||||
|
||||
Plus documents, entity actions and PDF downloads, reports, charts, search,
|
||||
statics, email sending, refunds, and health checks.
|
||||
Plus documents, entity actions and PDF downloads — by record id, or by
|
||||
invitation key for the PDF and XML a given contact receives — reports,
|
||||
charts, search, statics, email sending, refunds, health checks, logout,
|
||||
passkey management, and a standalone `login` to exchange credentials for
|
||||
a token.
|
||||
|
||||
**See [COVERAGE.md](COVERAGE.md) for the full endpoint-by-endpoint
|
||||
inventory**, including everything that is deliberately *not* wrapped and why.
|
||||
@@ -158,15 +161,33 @@ let pdf: Vec<u8> = client.download_invoice("abc123").await?;
|
||||
let merged: Vec<u8> = client.download_invoices(&["abc123", "def456"]).await?;
|
||||
```
|
||||
|
||||
Reports return their raw export body (CSV for most of them), and the
|
||||
schemaless endpoints decode into any type you choose:
|
||||
Documents are attached through the record they belong to, since that is the
|
||||
only way the API creates one. Sixteen resources support it, as
|
||||
`upload_client`, `upload_invoice`, `upload_expense`, and so on:
|
||||
|
||||
```rust
|
||||
use invoiceninja_sdk::FileUpload;
|
||||
|
||||
let receipt = FileUpload::new("receipt.pdf", std::fs::read("receipt.pdf")?)
|
||||
.mime("application/pdf");
|
||||
|
||||
// `Some(true)` makes the document visible to the client in the portal.
|
||||
let expense = client.upload_expense("abc123", vec![receipt], Some(false)).await?;
|
||||
```
|
||||
|
||||
Files are held in memory while uploading, and an upload is never retried,
|
||||
since a replay after a lost response would attach the file twice.
|
||||
|
||||
Reports return their raw export body (CSV for most of them). `statics`,
|
||||
`search`, and `health_check` return typed results; `chart` has no common
|
||||
response shape, so it decodes into any type you choose:
|
||||
|
||||
```rust
|
||||
use serde_json::{json, Value};
|
||||
|
||||
let csv: Vec<u8> = client.report("invoices", &json!({"date_range": "this_year"})).await?;
|
||||
let totals: Value = client.chart("totals_v2", &json!({})).await?;
|
||||
let statics: Value = client.statics().await?;
|
||||
let statics = client.statics().await?;
|
||||
```
|
||||
|
||||
Add more resources by adding a model under `src/models/` and a
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
//! Records the *shape* of real Invoice Ninja responses as test fixtures.
|
||||
//!
|
||||
//! Mock servers written from the upstream source have been wrong about the
|
||||
//! real thing before (system-log timestamps, `/subscriptions/steps`), so the
|
||||
//! offline suite benefits from fixtures that came from a real server. This
|
||||
//! writes them without writing any data: every string becomes `""`, every
|
||||
//! number `0`, every bool `false`, and an array keeps only its first element.
|
||||
//! What survives is the key names and the JSON types, which is what decoding
|
||||
//! depends on. `tests/real_shapes.rs` refuses to pass if a fixture holds
|
||||
//! anything else.
|
||||
//!
|
||||
//! GET requests only, so it is safe against any instance, but it still reads
|
||||
//! real records in memory. Review the diff before committing.
|
||||
//!
|
||||
//! ```sh
|
||||
//! IN_BASE_URL=http://localhost:8080/api/v1 IN_API_TOKEN=... \
|
||||
//! cargo run --example capture_shapes
|
||||
//! ```
|
||||
//!
|
||||
//! A resource with no rows on the instance writes nothing and is reported as
|
||||
//! skipped, since an empty list says nothing about a row's shape. Populate a
|
||||
//! disposable instance and re-run to capture more.
|
||||
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
use serde_json::{json, Value};
|
||||
|
||||
/// Resources captured as a one-row list. The name doubles as the fixture
|
||||
/// filename.
|
||||
const LISTS: &[&str] = &[
|
||||
"activities",
|
||||
"bank_integrations",
|
||||
"bank_transaction_rules",
|
||||
"bank_transactions",
|
||||
"client_gateway_tokens",
|
||||
"clients",
|
||||
"companies",
|
||||
"company_gateways",
|
||||
"credits",
|
||||
"designs",
|
||||
"documents",
|
||||
"expense_categories",
|
||||
"expenses",
|
||||
"group_settings",
|
||||
"invoices",
|
||||
"locations",
|
||||
"payment_terms",
|
||||
"payments",
|
||||
"products",
|
||||
"projects",
|
||||
"purchase_orders",
|
||||
"quotes",
|
||||
"recurring_expenses",
|
||||
"recurring_invoices",
|
||||
"recurring_quotes",
|
||||
"subscriptions",
|
||||
"system_logs",
|
||||
"tags",
|
||||
"task_schedulers",
|
||||
"task_statuses",
|
||||
"tasks",
|
||||
"tax_rates",
|
||||
"tokens",
|
||||
"users",
|
||||
"vendors",
|
||||
"webhooks",
|
||||
];
|
||||
|
||||
/// Endpoints with no `data` envelope, captured whole.
|
||||
const BARE: &[(&str, &str)] = &[
|
||||
("statics", "statics"),
|
||||
("health_check", "health_check"),
|
||||
("subscription_steps", "subscriptions/steps"),
|
||||
("activity_feed", "activities?rows=1&reactv2=1"),
|
||||
];
|
||||
|
||||
/// Replaces every value with a placeholder of the same JSON type.
|
||||
fn shape(value: &Value) -> Value {
|
||||
match value {
|
||||
Value::Null => Value::Null,
|
||||
Value::Bool(_) => json!(false),
|
||||
Value::Number(n) if n.is_f64() => json!(0.0),
|
||||
Value::Number(_) => json!(0),
|
||||
Value::String(_) => json!(""),
|
||||
// Only the first element: a list's rows share a shape, and keeping
|
||||
// all of them would just be size.
|
||||
Value::Array(items) => Value::Array(items.first().map(shape).into_iter().collect()),
|
||||
Value::Object(map) => {
|
||||
Value::Object(map.iter().map(|(k, v)| (k.clone(), shape(v))).collect())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
let base = std::env::var("IN_BASE_URL").expect("set IN_BASE_URL");
|
||||
let token = std::env::var("IN_API_TOKEN").expect("set IN_API_TOKEN");
|
||||
let base = base.trim_end_matches('/');
|
||||
let out = Path::new("tests/fixtures");
|
||||
fs::create_dir_all(out).expect("create tests/fixtures");
|
||||
|
||||
let http = reqwest::Client::new();
|
||||
let get = |path: String| {
|
||||
let http = http.clone();
|
||||
let token = token.clone();
|
||||
let url = format!("{base}/{path}");
|
||||
async move {
|
||||
let response = http
|
||||
.get(url)
|
||||
.header("X-Api-Token", token)
|
||||
.header("Accept", "application/json")
|
||||
.header("X-Requested-With", "XMLHttpRequest")
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
let status = response.status();
|
||||
let body = response.text().await.map_err(|e| e.to_string())?;
|
||||
if !status.is_success() {
|
||||
return Err(format!("HTTP {status}"));
|
||||
}
|
||||
serde_json::from_str::<Value>(&body).map_err(|e| e.to_string())
|
||||
}
|
||||
};
|
||||
|
||||
for name in LISTS {
|
||||
// Deleted and archived rows included: on a disposable instance they
|
||||
// are often all there is.
|
||||
// `system_logs` answers the status filter with a 500, so fall back to
|
||||
// the plain list when the filtered one fails.
|
||||
let filtered = format!("{name}?per_page=1&status=active,archived,deleted");
|
||||
let result = match get(filtered).await {
|
||||
Ok(body) => Ok(body),
|
||||
Err(_) => get(format!("{name}?per_page=1")).await,
|
||||
};
|
||||
match result {
|
||||
Ok(body) => match body
|
||||
.get("data")
|
||||
.and_then(Value::as_array)
|
||||
.and_then(|d| d.first())
|
||||
{
|
||||
Some(row) => {
|
||||
write(out, name, &json!({ "data": [shape(row)] }));
|
||||
println!(" captured {name}");
|
||||
}
|
||||
None => println!(" skipped {name}: no rows on this instance"),
|
||||
},
|
||||
Err(e) => println!(" FAILED {name}: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
for (name, path) in BARE {
|
||||
match get((*path).to_string()).await {
|
||||
Ok(body) => {
|
||||
write(out, name, &shape(&body));
|
||||
println!(" captured {name}");
|
||||
}
|
||||
Err(e) => println!(" FAILED {name}: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn write(dir: &Path, name: &str, value: &Value) {
|
||||
let mut text = serde_json::to_string_pretty(value).expect("serialise fixture");
|
||||
text.push('\n');
|
||||
fs::write(dir.join(format!("{name}.json")), text).expect("write fixture");
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
use invoiceninjars::models::ListParams;
|
||||
use invoiceninjars::InvoiceNinjaClient;
|
||||
use invoiceninja_sdk::models::ListParams;
|
||||
use invoiceninja_sdk::InvoiceNinjaClient;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> invoiceninjars::Result<()> {
|
||||
async fn main() -> invoiceninja_sdk::Result<()> {
|
||||
let base_url =
|
||||
std::env::var("IN_BASE_URL").unwrap_or_else(|_| "https://invoicing.co/api/v1".into());
|
||||
let token = std::env::var("IN_API_TOKEN").expect("set IN_API_TOKEN");
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
//! Fills a **disposable** Invoice Ninja instance with one record of each
|
||||
//! common kind, so `capture_shapes` has real rows to take shapes from.
|
||||
//!
|
||||
//! ```sh
|
||||
//! IN_SMOKE_WRITE_HOST=localhost IN_BASE_URL=http://localhost:8080/api/v1 \
|
||||
//! IN_API_TOKEN=... cargo run --example seed_sample_data
|
||||
//! ```
|
||||
//!
|
||||
//! **Never run this against production.** It creates records, advances
|
||||
//! number sequences, and may fire webhooks. It uses the smoke test's guard:
|
||||
//! `IN_SMOKE_WRITE_HOST` must equal the host in `IN_BASE_URL`. Nothing is
|
||||
//! deleted afterwards; the instance is meant to be thrown away.
|
||||
//!
|
||||
//! Each step is independent: one that fails (a validation rule this script
|
||||
//! did not anticipate) is reported and the rest carry on, with anything that
|
||||
//! needed it skipped.
|
||||
|
||||
use std::future::Future;
|
||||
use std::process::ExitCode;
|
||||
|
||||
use invoiceninja_sdk::{Error, InvoiceNinjaClient};
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
fn model<T: DeserializeOwned>(value: Value) -> T {
|
||||
serde_json::from_value(value).expect("seed body matches the model")
|
||||
}
|
||||
|
||||
fn describe(error: &Error) -> String {
|
||||
match error {
|
||||
Error::Api { errors, .. } if !errors.is_empty() => format!("{error} {errors:?}"),
|
||||
_ => error.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs one creation, reporting the outcome and returning the new id.
|
||||
async fn step<T>(name: &str, call: impl Future<Output = invoiceninja_sdk::Result<T>>) -> Option<T> {
|
||||
match call.await {
|
||||
Ok(created) => {
|
||||
println!(" created {name}");
|
||||
Some(created)
|
||||
}
|
||||
Err(e) => {
|
||||
println!(" FAILED {name}: {}", describe(&e));
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> ExitCode {
|
||||
let base_url = std::env::var("IN_BASE_URL").expect("set IN_BASE_URL");
|
||||
let token = std::env::var("IN_API_TOKEN").expect("set IN_API_TOKEN");
|
||||
let host = reqwest::Url::parse(&base_url)
|
||||
.ok()
|
||||
.and_then(|u| u.host_str().map(str::to_string))
|
||||
.unwrap_or_default();
|
||||
if std::env::var("IN_SMOKE_WRITE_HOST").as_deref() != Ok(host.as_str()) {
|
||||
eprintln!("refusing to write: set IN_SMOKE_WRITE_HOST={host} to confirm this instance is disposable");
|
||||
return ExitCode::FAILURE;
|
||||
}
|
||||
let c = InvoiceNinjaClient::new(&base_url, token).expect("client");
|
||||
println!("seeding {host}");
|
||||
|
||||
let line =
|
||||
json!([{ "product_key": "seed-product", "quantity": 1, "cost": 10.0, "notes": "seed" }]);
|
||||
|
||||
let client_id = step(
|
||||
"client",
|
||||
c.create_client(&model(json!({
|
||||
"name": "seed-client",
|
||||
"contacts": [{ "first_name": "Seed", "last_name": "Contact", "email": "seed@example.com" }],
|
||||
}))),
|
||||
)
|
||||
.await
|
||||
.and_then(|x| x.id);
|
||||
let vendor_id = step(
|
||||
"vendor",
|
||||
c.create_vendor(&model(json!({ "name": "seed-vendor" }))),
|
||||
)
|
||||
.await
|
||||
.and_then(|x| x.id);
|
||||
|
||||
step(
|
||||
"product",
|
||||
c.create_product(&model(
|
||||
json!({ "product_key": "seed-product", "price": 10.0 }),
|
||||
)),
|
||||
)
|
||||
.await;
|
||||
step(
|
||||
"tax_rate",
|
||||
c.create_tax_rate(&model(json!({ "name": "seed-tax", "rate": 5.0 }))),
|
||||
)
|
||||
.await;
|
||||
step(
|
||||
"payment_term",
|
||||
c.create_payment_term(&model(json!({ "name": "seed-term", "num_days": 15 }))),
|
||||
)
|
||||
.await;
|
||||
step(
|
||||
"expense_category",
|
||||
c.create_expense_category(&model(json!({ "name": "seed-category" }))),
|
||||
)
|
||||
.await;
|
||||
step(
|
||||
"tag",
|
||||
c.create_tag(&model(
|
||||
json!({ "name": "seed-tag", "entity_type": "client" }),
|
||||
)),
|
||||
)
|
||||
.await;
|
||||
step(
|
||||
"group_setting",
|
||||
c.create_group_setting(&model(json!({ "name": "seed-group" }))),
|
||||
)
|
||||
.await;
|
||||
step(
|
||||
"webhook",
|
||||
c.create_webhook(&model(
|
||||
json!({ "event_id": "1", "target_url": "https://example.com/seed-hook" }),
|
||||
)),
|
||||
)
|
||||
.await;
|
||||
step(
|
||||
"expense",
|
||||
c.create_expense(&model(
|
||||
json!({ "amount": 5.0, "public_notes": "seed", "vendor_id": vendor_id }),
|
||||
)),
|
||||
)
|
||||
.await;
|
||||
step(
|
||||
"recurring_expense",
|
||||
c.create_recurring_expense(&model(
|
||||
json!({ "amount": 5.0, "frequency_id": 5, "vendor_id": vendor_id }),
|
||||
)),
|
||||
)
|
||||
.await;
|
||||
|
||||
if let Some(vendor_id) = &vendor_id {
|
||||
step(
|
||||
"purchase_order",
|
||||
c.create_purchase_order(&model(
|
||||
json!({ "vendor_id": vendor_id, "line_items": line }),
|
||||
)),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
let Some(client_id) = client_id else {
|
||||
println!("no client, so the client-dependent records are skipped");
|
||||
return ExitCode::FAILURE;
|
||||
};
|
||||
|
||||
step(
|
||||
"location",
|
||||
c.create_location(&model(
|
||||
json!({ "name": "seed-location", "client_id": client_id, "address1": "1 Seed St" }),
|
||||
)),
|
||||
)
|
||||
.await;
|
||||
step(
|
||||
"project",
|
||||
c.create_project(&model(
|
||||
json!({ "name": "seed-project", "client_id": client_id }),
|
||||
)),
|
||||
)
|
||||
.await;
|
||||
step(
|
||||
"task",
|
||||
c.create_task(&model(
|
||||
json!({ "description": "seed task", "client_id": client_id }),
|
||||
)),
|
||||
)
|
||||
.await;
|
||||
step(
|
||||
"quote",
|
||||
c.create_quote(&model(
|
||||
json!({ "client_id": client_id, "line_items": line }),
|
||||
)),
|
||||
)
|
||||
.await;
|
||||
step(
|
||||
"credit",
|
||||
c.create_credit(&model(
|
||||
json!({ "client_id": client_id, "line_items": line }),
|
||||
)),
|
||||
)
|
||||
.await;
|
||||
step(
|
||||
"recurring_invoice",
|
||||
c.create_recurring_invoice(&model(
|
||||
json!({ "client_id": client_id, "frequency_id": 5, "line_items": line }),
|
||||
)),
|
||||
)
|
||||
.await;
|
||||
// Expected to 500 on 5.13.50: the `recurring_quotes` table has no
|
||||
// `design_id` column, so the server's own insert fails. Not an SDK bug.
|
||||
step(
|
||||
"recurring_quote",
|
||||
c.create_recurring_quote(&model(
|
||||
json!({ "client_id": client_id, "frequency_id": 5, "line_items": line }),
|
||||
)),
|
||||
)
|
||||
.await;
|
||||
|
||||
let invoice = step(
|
||||
"invoice",
|
||||
c.create_invoice(&model(
|
||||
json!({ "client_id": client_id, "line_items": line }),
|
||||
)),
|
||||
)
|
||||
.await;
|
||||
if let Some(id) = invoice.and_then(|i| i.id) {
|
||||
// A payment can only be applied to an invoice that has been sent.
|
||||
if step("invoice mark_sent", c.mark_invoice_sent(&id))
|
||||
.await
|
||||
.is_some()
|
||||
{
|
||||
step(
|
||||
"payment",
|
||||
c.create_payment(&model(json!({
|
||||
"client_id": client_id,
|
||||
"amount": 5.0,
|
||||
"invoices": [{ "invoice_id": id, "amount": 5.0 }],
|
||||
}))),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
ExitCode::SUCCESS
|
||||
}
|
||||
@@ -0,0 +1,898 @@
|
||||
//! Smoke test against a real Invoice Ninja instance.
|
||||
//!
|
||||
//! The rest of the suite runs against mock servers written from the upstream
|
||||
//! source, so it cannot catch a place where the real server disagrees with
|
||||
//! that reading. This can. It prints PASS/FAIL per check, never the contents
|
||||
//! of any record, and exits non-zero if anything failed.
|
||||
//!
|
||||
//! ```sh
|
||||
//! # Read-only: GET requests only.
|
||||
//! IN_BASE_URL=http://localhost:8080/api/v1 IN_API_TOKEN=... \
|
||||
//! cargo run --example smoke_test
|
||||
//!
|
||||
//! # Adds the write tier. Naming the host is the guard: it must equal the
|
||||
//! # host in IN_BASE_URL, so a write cannot reach a server you did not name.
|
||||
//! IN_SMOKE_WRITE_HOST=localhost IN_BASE_URL=... IN_API_TOKEN=... \
|
||||
//! cargo run --example smoke_test
|
||||
//! ```
|
||||
//!
|
||||
//! The read tier also fetches the same rows raw and fails if a typed row lost
|
||||
//! a key. The write tier creates a client, attaches files to it, creates a
|
||||
//! purchase order, location, webhook, and recurring invoice from default
|
||||
//! models, and deletes what it made.
|
||||
//!
|
||||
//! With `IN_API_PASSWORD` also set, a password tier runs inside the write
|
||||
//! tier: it merges two throwaway clients and purges the survivor, which
|
||||
//! really removes the row. Take the password from the environment, never from
|
||||
//! a command line or chat.
|
||||
//!
|
||||
//! **Do not point the write tier at a production server.** Even after the delete that
|
||||
//! leaves a gap in the client number sequence and a soft-deleted row (a real
|
||||
//! purge needs the account password), and it may fire webhooks.
|
||||
//!
|
||||
//! `IN_BASE_URL` has no default, unlike the other example, because defaulting
|
||||
//! to the hosted app is the wrong failure for a tool that can write.
|
||||
|
||||
use std::fmt::Display;
|
||||
use std::process::ExitCode;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use invoiceninja_sdk::models::{
|
||||
Client, ListParams, Location, PurchaseOrder, RecurringInvoice, Vendor, Webhook,
|
||||
};
|
||||
use invoiceninja_sdk::{Error, FileUpload, InvoiceNinjaClient};
|
||||
use serde::Serialize;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
#[derive(Default)]
|
||||
struct Report {
|
||||
passed: u32,
|
||||
failed: u32,
|
||||
}
|
||||
|
||||
impl Report {
|
||||
fn pass(&mut self, name: &str, detail: impl Display) {
|
||||
println!(" PASS {name:<34} {detail}");
|
||||
self.passed += 1;
|
||||
}
|
||||
|
||||
fn fail(&mut self, name: &str, error: impl Display) {
|
||||
println!(" FAIL {name:<34} {error}");
|
||||
self.failed += 1;
|
||||
}
|
||||
|
||||
fn check(&mut self, name: &str, ok: bool, detail: impl Display) {
|
||||
if ok {
|
||||
self.pass(name, detail);
|
||||
} else {
|
||||
self.fail(name, detail);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Renders an error with its per-field validation map, which is usually what
|
||||
/// explains a 422.
|
||||
fn describe(error: &Error) -> String {
|
||||
match error {
|
||||
Error::Api { errors, .. } if !errors.is_empty() => format!("{error} {errors:?}"),
|
||||
_ => error.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Plain GETs for the raw JSON, so a typed result can be compared with what
|
||||
/// the server actually sent. Decoding successfully proves little: a model
|
||||
/// that quietly dropped a field would still decode.
|
||||
struct Raw {
|
||||
http: reqwest::Client,
|
||||
base: String,
|
||||
token: String,
|
||||
}
|
||||
|
||||
impl Raw {
|
||||
async fn rows(&self, path: &str) -> Option<Vec<Value>> {
|
||||
let body: Value = self
|
||||
.http
|
||||
.get(format!("{}/{path}?per_page=2", self.base))
|
||||
.header("X-Api-Token", &self.token)
|
||||
.header("Accept", "application/json")
|
||||
.header("X-Requested-With", "XMLHttpRequest")
|
||||
.send()
|
||||
.await
|
||||
.ok()?
|
||||
.json()
|
||||
.await
|
||||
.ok()?;
|
||||
body.get("data")?.as_array().cloned()
|
||||
}
|
||||
}
|
||||
|
||||
/// Keys that are left out of a request when empty on purpose (the server
|
||||
/// rejects the empty value), so an empty one is not a lost key.
|
||||
const OMITTED_WHEN_EMPTY: &[&str] = &["country_id", "status_id", "format"];
|
||||
|
||||
/// Key paths in `raw` that `typed` no longer has.
|
||||
fn lost_keys(raw: &Value, typed: &Value, prefix: &str, into: &mut Vec<String>) {
|
||||
match (raw, typed) {
|
||||
(Value::Object(raw), Value::Object(typed)) => {
|
||||
for (key, raw_value) in raw {
|
||||
match typed.get(key) {
|
||||
Some(typed_value) => {
|
||||
lost_keys(raw_value, typed_value, &format!("{prefix}.{key}"), into)
|
||||
}
|
||||
None if raw_value == "" && OMITTED_WHEN_EMPTY.contains(&key.as_str()) => {}
|
||||
None => into.push(format!("{prefix}.{key}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
(Value::Array(raw), Value::Array(typed)) => {
|
||||
if let (Some(raw), Some(typed)) = (raw.first(), typed.first()) {
|
||||
lost_keys(raw, typed, &format!("{prefix}[]"), into);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetches the same rows raw and requires the typed ones to keep every key.
|
||||
/// Only key names are printed, never values.
|
||||
async fn key_loss(report: &mut Report, raw: &Raw, path: &str, typed: &[impl Serialize]) {
|
||||
let Some(raw_rows) = raw.rows(path).await else {
|
||||
report.fail(
|
||||
&format!("keys kept: {path}"),
|
||||
"could not fetch the raw rows",
|
||||
);
|
||||
return;
|
||||
};
|
||||
let mut lost = Vec::new();
|
||||
for (raw_row, typed_row) in raw_rows.iter().zip(typed) {
|
||||
let typed_row = serde_json::to_value(typed_row).unwrap_or(Value::Null);
|
||||
lost_keys(raw_row, &typed_row, path, &mut lost);
|
||||
}
|
||||
lost.sort();
|
||||
lost.dedup();
|
||||
report.check(
|
||||
&format!("keys kept: {path}"),
|
||||
lost.is_empty(),
|
||||
if lost.is_empty() {
|
||||
format!("{} row(s), no key lost", raw_rows.len().min(typed.len()))
|
||||
} else {
|
||||
format!("lost {}", lost.join(", "))
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> ExitCode {
|
||||
let base_url = std::env::var("IN_BASE_URL").expect("set IN_BASE_URL (no default, on purpose)");
|
||||
let token = std::env::var("IN_API_TOKEN").expect("set IN_API_TOKEN");
|
||||
|
||||
let host = host_of(&base_url);
|
||||
let raw = Raw {
|
||||
http: reqwest::Client::new(),
|
||||
base: base_url.trim_end_matches('/').to_string(),
|
||||
token: token.clone(),
|
||||
};
|
||||
let client = match InvoiceNinjaClient::new(&base_url, token) {
|
||||
Ok(client) => client,
|
||||
Err(e) => {
|
||||
eprintln!("could not build client: {e}");
|
||||
return ExitCode::FAILURE;
|
||||
}
|
||||
};
|
||||
|
||||
println!("target: {host}");
|
||||
let mut report = Report::default();
|
||||
|
||||
println!("\nread-only checks");
|
||||
read_checks(&client, &raw, &mut report).await;
|
||||
|
||||
// Empty counts as unset: CI passes the variable through even when blank.
|
||||
match std::env::var("IN_SMOKE_WRITE_HOST")
|
||||
.ok()
|
||||
.filter(|v| !v.is_empty())
|
||||
{
|
||||
Some(allowed) if allowed == host => {
|
||||
println!("\nwrite checks (IN_SMOKE_WRITE_HOST matches {host})");
|
||||
write_checks(&client, &mut report).await;
|
||||
|
||||
match std::env::var("IN_API_PASSWORD")
|
||||
.ok()
|
||||
.filter(|v| !v.is_empty())
|
||||
{
|
||||
Some(password) => {
|
||||
println!("\npassword checks (IN_API_PASSWORD is set)");
|
||||
password_checks(&client, &mut report, password).await;
|
||||
}
|
||||
None => println!("\npassword checks skipped (set IN_API_PASSWORD to enable)"),
|
||||
}
|
||||
}
|
||||
Some(allowed) => {
|
||||
println!(
|
||||
"\nwrite checks SKIPPED: IN_SMOKE_WRITE_HOST is `{allowed}` but the target is `{host}`"
|
||||
);
|
||||
}
|
||||
None => println!("\nwrite checks skipped (set IN_SMOKE_WRITE_HOST to enable)"),
|
||||
}
|
||||
|
||||
println!("\n{} passed, {} failed", report.passed, report.failed);
|
||||
if report.failed == 0 {
|
||||
ExitCode::SUCCESS
|
||||
} else {
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
}
|
||||
|
||||
fn host_of(base_url: &str) -> String {
|
||||
reqwest::Url::parse(base_url)
|
||||
.ok()
|
||||
.and_then(|url| url.host_str().map(str::to_string))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
async fn read_checks(client: &InvoiceNinjaClient, raw: &Raw, report: &mut Report) {
|
||||
let small = ListParams::new().per_page(2);
|
||||
|
||||
// Decodes one page, then (if there are rows) checks none of their keys
|
||||
// were lost. The REST path is the method name without `list_`.
|
||||
macro_rules! list {
|
||||
($report:expr, $name:literal, $call:expr) => {
|
||||
match $call.await {
|
||||
Ok(rows) => {
|
||||
$report.pass($name, format!("{} row(s) decoded", rows.len()));
|
||||
if !rows.is_empty() {
|
||||
key_loss($report, raw, $name.trim_start_matches("list_"), &rows).await;
|
||||
}
|
||||
}
|
||||
Err(e) => $report.fail($name, describe(&e)),
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
match client.ping().await {
|
||||
Ok(()) => report.pass("ping", "authenticated"),
|
||||
Err(e) => report.fail("ping", describe(&e)),
|
||||
}
|
||||
|
||||
// Hosted returns a bare message instead of a report; both must decode.
|
||||
match client.health_check().await {
|
||||
Ok(health) => report.pass(
|
||||
"health_check",
|
||||
match health.system_health {
|
||||
Some(ok) => format!("self-hosted report, system_health={ok}"),
|
||||
None => "bare message (no report on this deployment)".to_string(),
|
||||
},
|
||||
),
|
||||
Err(e) => report.fail("health_check", describe(&e)),
|
||||
}
|
||||
|
||||
match client.statics().await {
|
||||
Ok(s) => report.check(
|
||||
"statics",
|
||||
!s.currencies.is_empty() && !s.countries.is_empty(),
|
||||
format!(
|
||||
"{} currencies, {} countries, {} unmodeled key(s) kept in extra",
|
||||
s.currencies.len(),
|
||||
s.countries.len(),
|
||||
s.extra.len()
|
||||
),
|
||||
),
|
||||
Err(e) => report.fail("statics", describe(&e)),
|
||||
}
|
||||
|
||||
match client.search(&json!({ "search": "a" })).await {
|
||||
Ok(results) => report.pass(
|
||||
"search",
|
||||
format!(
|
||||
"{} client hit(s), {} unmodeled bucket(s)",
|
||||
results.clients.len(),
|
||||
results.extra.len()
|
||||
),
|
||||
),
|
||||
Err(e) => report.fail("search", describe(&e)),
|
||||
}
|
||||
|
||||
match client.subscription_steps().await {
|
||||
Ok(steps) => report.check(
|
||||
"subscription_steps",
|
||||
!steps.is_empty() && steps.iter().all(|s| !s.id.is_empty()),
|
||||
format!("{} step(s) decoded", steps.len()),
|
||||
),
|
||||
Err(e) => report.fail("subscription_steps", describe(&e)),
|
||||
}
|
||||
|
||||
match client.activity_feed(Some(5)).await {
|
||||
Ok(feed) => report.check(
|
||||
"activity_feed",
|
||||
feed.iter().all(|e| e.activity_type_id > 0),
|
||||
format!("{} entr(ies), numeric type ids", feed.len()),
|
||||
),
|
||||
Err(e) => report.fail("activity_feed", describe(&e)),
|
||||
}
|
||||
|
||||
// A POST, but it only renders: nothing is stored. Needs an invoice to
|
||||
// render, so it is skipped on an empty instance.
|
||||
match client.list_invoices(&small).await {
|
||||
Ok(rows) => match rows
|
||||
.first()
|
||||
.and_then(|i| i.id.clone().map(|id| (id, i.client_id.clone())))
|
||||
{
|
||||
Some((id, client_id)) => {
|
||||
let body = json!({ "entity": "invoice", "entity_id": id, "client_id": client_id });
|
||||
match client.live_preview(&body).await {
|
||||
Ok(pdf) => report.check(
|
||||
"live_preview",
|
||||
pdf.starts_with(b"%PDF"),
|
||||
format!("{} byte(s), starts with the PDF magic", pdf.len()),
|
||||
),
|
||||
Err(e) => report.fail("live_preview", describe(&e)),
|
||||
}
|
||||
}
|
||||
None => report.pass("live_preview", "skipped: no invoices on this instance"),
|
||||
},
|
||||
Err(e) => report.fail("live_preview", describe(&e)),
|
||||
}
|
||||
|
||||
// The invitation-key download, on the singular `invoice/{key}/download`
|
||||
// route. Worth a real-server check for one specific reason: this was
|
||||
// listed as unwrappable for months on the belief that it takes no auth
|
||||
// token, and the route file says otherwise. If that reading is still
|
||||
// wrong, the token we send is either ignored or rejected, and this is
|
||||
// where it shows. The key comes from the invoice's own `invitations`
|
||||
// array, which the list route returns without an `include`.
|
||||
//
|
||||
// Only the PDF, not the e-document: `download_e_invoice` renders from
|
||||
// the company's e-invoice settings, so an instance with none configured
|
||||
// would fail here for a reason that is not the client's fault.
|
||||
match client.list_invoices(&small).await {
|
||||
Ok(rows) => match rows.iter().find_map(|i| {
|
||||
i.extra
|
||||
.get("invitations")?
|
||||
.as_array()?
|
||||
.first()?
|
||||
.get("key")?
|
||||
.as_str()
|
||||
}) {
|
||||
Some(key) => match client.download_invoice_by_invitation_key(key).await {
|
||||
Ok(pdf) => report.check(
|
||||
"download_invoice_by_invitation_key",
|
||||
pdf.starts_with(b"%PDF"),
|
||||
format!("{} byte(s), starts with the PDF magic", pdf.len()),
|
||||
),
|
||||
Err(e) => report.fail("download_invoice_by_invitation_key", describe(&e)),
|
||||
},
|
||||
None => report.pass(
|
||||
"download_invoice_by_invitation_key",
|
||||
"skipped: no invoice with an invitation on this instance",
|
||||
),
|
||||
},
|
||||
Err(e) => report.fail("download_invoice_by_invitation_key", describe(&e)),
|
||||
}
|
||||
|
||||
list!(report, "list_clients", client.list_clients(&small));
|
||||
list!(report, "list_invoices", client.list_invoices(&small));
|
||||
list!(report, "list_quotes", client.list_quotes(&small));
|
||||
list!(report, "list_credits", client.list_credits(&small));
|
||||
list!(report, "list_payments", client.list_payments(&small));
|
||||
list!(report, "list_products", client.list_products(&small));
|
||||
list!(report, "list_expenses", client.list_expenses(&small));
|
||||
list!(report, "list_projects", client.list_projects(&small));
|
||||
list!(report, "list_tasks", client.list_tasks(&small));
|
||||
list!(report, "list_vendors", client.list_vendors(&small));
|
||||
list!(
|
||||
report,
|
||||
"list_recurring_invoices",
|
||||
client.list_recurring_invoices(&small)
|
||||
);
|
||||
list!(
|
||||
report,
|
||||
"list_recurring_quotes",
|
||||
client.list_recurring_quotes(&small)
|
||||
);
|
||||
list!(
|
||||
report,
|
||||
"list_recurring_expenses",
|
||||
client.list_recurring_expenses(&small)
|
||||
);
|
||||
list!(
|
||||
report,
|
||||
"list_purchase_orders",
|
||||
client.list_purchase_orders(&small)
|
||||
);
|
||||
list!(report, "list_tax_rates", client.list_tax_rates(&small));
|
||||
list!(
|
||||
report,
|
||||
"list_expense_categories",
|
||||
client.list_expense_categories(&small)
|
||||
);
|
||||
list!(
|
||||
report,
|
||||
"list_task_statuses",
|
||||
client.list_task_statuses(&small)
|
||||
);
|
||||
list!(
|
||||
report,
|
||||
"list_payment_terms",
|
||||
client.list_payment_terms(&small)
|
||||
);
|
||||
list!(
|
||||
report,
|
||||
"list_subscriptions",
|
||||
client.list_subscriptions(&small)
|
||||
);
|
||||
list!(
|
||||
report,
|
||||
"list_bank_transactions",
|
||||
client.list_bank_transactions(&small)
|
||||
);
|
||||
list!(report, "list_webhooks", client.list_webhooks(&small));
|
||||
list!(
|
||||
report,
|
||||
"list_bank_integrations",
|
||||
client.list_bank_integrations(&small)
|
||||
);
|
||||
list!(
|
||||
report,
|
||||
"list_bank_transaction_rules",
|
||||
client.list_bank_transaction_rules(&small)
|
||||
);
|
||||
list!(
|
||||
report,
|
||||
"list_company_gateways",
|
||||
client.list_company_gateways(&small)
|
||||
);
|
||||
list!(report, "list_designs", client.list_designs(&small));
|
||||
list!(
|
||||
report,
|
||||
"list_group_settings",
|
||||
client.list_group_settings(&small)
|
||||
);
|
||||
list!(report, "list_locations", client.list_locations(&small));
|
||||
list!(report, "list_tags", client.list_tags(&small));
|
||||
list!(
|
||||
report,
|
||||
"list_task_schedulers",
|
||||
client.list_task_schedulers(&small)
|
||||
);
|
||||
list!(report, "list_tokens", client.list_tokens(&small));
|
||||
list!(
|
||||
report,
|
||||
"list_client_gateway_tokens",
|
||||
client.list_client_gateway_tokens(&small)
|
||||
);
|
||||
list!(report, "list_companies", client.list_companies(&small));
|
||||
list!(report, "list_system_logs", client.list_system_logs(&small));
|
||||
list!(report, "list_documents", client.list_documents(&small));
|
||||
list!(report, "list_users", client.list_users(&small));
|
||||
list!(report, "list_activities", client.list_activities(&small));
|
||||
|
||||
// The walk follows `meta.pagination.total_pages`, which only a real server
|
||||
// can show we are reading correctly. Compare it to one oversized request.
|
||||
let everything = ListParams::new().per_page(5000);
|
||||
match (
|
||||
client
|
||||
.list_all_clients(&ListParams::new().per_page(2))
|
||||
.await,
|
||||
client.list_clients(&everything).await,
|
||||
) {
|
||||
(Ok(walked), Ok(single)) if single.len() < 5000 => report.check(
|
||||
"pagination walk == single request",
|
||||
walked.len() == single.len(),
|
||||
format!(
|
||||
"walked {} at per_page=2, single request returned {}",
|
||||
walked.len(),
|
||||
single.len()
|
||||
),
|
||||
),
|
||||
(Ok(_), Ok(_)) => report.pass("pagination walk", "skipped: 5000+ clients"),
|
||||
(Err(e), _) | (_, Err(e)) => report.fail("pagination walk", describe(&e)),
|
||||
}
|
||||
|
||||
// The server caps per_page. Ask for far more than the cap and see how many
|
||||
// rows come back, which is the fact the clamp in `get_all_pages` rests on.
|
||||
match client
|
||||
.list_clients(&ListParams::new().per_page(100_000))
|
||||
.await
|
||||
{
|
||||
Ok(rows) => report.pass(
|
||||
"per_page cap",
|
||||
format!("asked for 100000, got {} row(s) (cap is 5000)", rows.len()),
|
||||
),
|
||||
Err(e) => report.fail("per_page cap", describe(&e)),
|
||||
}
|
||||
|
||||
// A single fetch by id, to exercise `get_one` and `seg`.
|
||||
match client.list_clients(&small).await {
|
||||
Ok(rows) => match rows.first().and_then(|c| c.id.clone()) {
|
||||
Some(id) => match client.get_client(&id).await {
|
||||
Ok(c) => report.pass(
|
||||
"get_client",
|
||||
format!("fetched one, {} unmodeled field(s) in extra", c.extra.len()),
|
||||
),
|
||||
Err(e) => report.fail("get_client", describe(&e)),
|
||||
},
|
||||
None => report.pass("get_client", "skipped: no clients on this instance"),
|
||||
},
|
||||
Err(e) => report.fail("get_client", describe(&e)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn write_checks(client: &InvoiceNinjaClient, report: &mut Report) {
|
||||
let nanos = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_nanos())
|
||||
.unwrap_or(0);
|
||||
let name = format!("sdk-smoke-{nanos}");
|
||||
|
||||
let created = match client
|
||||
.create_client(&Client {
|
||||
name: name.clone(),
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(created) => created,
|
||||
Err(e) => {
|
||||
report.fail("create_client", describe(&e));
|
||||
return;
|
||||
}
|
||||
};
|
||||
let Some(id) = created.id.clone() else {
|
||||
report.fail("create_client", "response carried no id");
|
||||
return;
|
||||
};
|
||||
report.pass("create_client", format!("created `{name}`"));
|
||||
|
||||
upload_checks(client, report, &id).await;
|
||||
round_trip_checks(client, report, &id, created.extra.len()).await;
|
||||
extras_checks(client, report, &id).await;
|
||||
creation_checks(client, report, &id).await;
|
||||
activity_checks(client, report, &id).await;
|
||||
|
||||
// Always runs, including after a failed check above.
|
||||
match client.delete_client(&id).await {
|
||||
Ok(()) => report.pass(
|
||||
"delete_client",
|
||||
"soft-deleted (a purge needs the account password)",
|
||||
),
|
||||
Err(e) => report.fail(
|
||||
"delete_client",
|
||||
format!("{} -- remove `{name}` by hand", describe(&e)),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
async fn upload_checks(client: &InvoiceNinjaClient, report: &mut Report, id: &str) {
|
||||
let text = |label: &str| {
|
||||
FileUpload::new(
|
||||
format!("{label}.txt"),
|
||||
format!("smoke {label}").into_bytes(),
|
||||
)
|
||||
.mime("text/plain")
|
||||
};
|
||||
let count_documents = |c: &Client| {
|
||||
c.extra
|
||||
.get("documents")
|
||||
.and_then(|d| d.as_array())
|
||||
.map(Vec::len)
|
||||
};
|
||||
|
||||
// POST + `_method=PUT`: read from the framework source, then observed
|
||||
// working on 5.13.50.
|
||||
match client
|
||||
.upload_client(id, vec![text("first")], Some(false))
|
||||
.await
|
||||
{
|
||||
Ok(c) => report.check(
|
||||
"upload_client (1 file, private)",
|
||||
count_documents(&c) == Some(1),
|
||||
format!("documents on record: {:?}", count_documents(&c)),
|
||||
),
|
||||
Err(e) => report.fail("upload_client (1 file, private)", describe(&e)),
|
||||
}
|
||||
|
||||
match client
|
||||
.upload_client(id, vec![text("second"), text("third")], Some(true))
|
||||
.await
|
||||
{
|
||||
Ok(c) => report.check(
|
||||
"upload_client (2 files, public)",
|
||||
count_documents(&c) == Some(3),
|
||||
format!(
|
||||
"documents on record: {:?} (expected 3)",
|
||||
count_documents(&c)
|
||||
),
|
||||
),
|
||||
Err(e) => report.fail("upload_client (2 files, public)", describe(&e)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn round_trip_checks(
|
||||
client: &InvoiceNinjaClient,
|
||||
report: &mut Report,
|
||||
id: &str,
|
||||
extra_keys_at_create: usize,
|
||||
) {
|
||||
// `get` then `update` must not drop fields the crate does not model.
|
||||
let fetched = match client.get_client(id).await {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
report.fail("get_client (round trip)", describe(&e));
|
||||
return;
|
||||
}
|
||||
};
|
||||
let before = fetched.extra.len();
|
||||
|
||||
let mut edited = fetched;
|
||||
edited.private_notes = "edited by smoke test".into();
|
||||
let updated = match client.update_client(id, &edited).await {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
report.fail("update_client", describe(&e));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
report.check(
|
||||
"update_client preserves extra",
|
||||
updated.extra.len() >= before.min(extra_keys_at_create),
|
||||
format!(
|
||||
"extra keys: {extra_keys_at_create} at create, {before} on get, {} after update",
|
||||
updated.extra.len()
|
||||
),
|
||||
);
|
||||
report.check(
|
||||
"update_client applied the edit",
|
||||
updated.private_notes == "edited by smoke test",
|
||||
"private_notes round-tripped",
|
||||
);
|
||||
}
|
||||
|
||||
/// Per-client extras that only read or reshape data already on the record.
|
||||
/// `updateTaxData` and `check_vat` are left out: they call external services.
|
||||
async fn extras_checks(client: &InvoiceNinjaClient, report: &mut Report, id: &str) {
|
||||
match client.client_show_settings(id).await {
|
||||
Ok(settings) => report.check(
|
||||
"client_show_settings",
|
||||
settings.is_object(),
|
||||
format!("{} setting(s)", settings.as_object().map_or(0, |m| m.len())),
|
||||
),
|
||||
Err(e) => report.fail("client_show_settings", describe(&e)),
|
||||
}
|
||||
|
||||
match client.client_vat_status(id).await {
|
||||
Ok(status) => report.pass(
|
||||
"client_vat_status",
|
||||
format!("message of {} char(s)", status.message.len()),
|
||||
),
|
||||
Err(e) => report.fail("client_vat_status", describe(&e)),
|
||||
}
|
||||
|
||||
// The two uploads in `upload_checks` should be visible here.
|
||||
match client.client_documents(id, &json!({})).await {
|
||||
Ok(docs) => report.check(
|
||||
"client_documents",
|
||||
docs.len() == 3,
|
||||
format!("{} document(s) (expected 3)", docs.len()),
|
||||
),
|
||||
Err(e) => report.fail("client_documents", describe(&e)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates from bare `Default` models the records that broke against a real
|
||||
/// server: an empty `status_id`, `country_id` or `format` was rejected, and a
|
||||
/// string `frequency_id` failed to decode after the record existed. Each is
|
||||
/// deleted again straight away.
|
||||
async fn creation_checks(client: &InvoiceNinjaClient, report: &mut Report, client_id: &str) {
|
||||
let stamp = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_nanos())
|
||||
.unwrap_or(0);
|
||||
|
||||
let vendor = match client
|
||||
.create_vendor(&Vendor {
|
||||
name: format!("sdk-smoke-vendor-{stamp}"),
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(vendor) => vendor,
|
||||
Err(e) => {
|
||||
report.fail("create_vendor", describe(&e));
|
||||
return;
|
||||
}
|
||||
};
|
||||
let vendor_id = vendor.id.clone().unwrap_or_default();
|
||||
|
||||
match client
|
||||
.create_purchase_order(&PurchaseOrder {
|
||||
vendor_id: vendor_id.clone(),
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(po) => {
|
||||
report.pass("create_purchase_order (default)", "empty status_id omitted");
|
||||
if let Some(id) = po.id {
|
||||
let _ = client.delete_purchase_order(&id).await;
|
||||
}
|
||||
}
|
||||
Err(e) => report.fail("create_purchase_order (default)", describe(&e)),
|
||||
}
|
||||
|
||||
match client
|
||||
.create_location(&Location {
|
||||
name: format!("sdk-smoke-location-{stamp}"),
|
||||
client_id: client_id.to_string(),
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(location) => {
|
||||
report.pass("create_location (default)", "empty country_id omitted");
|
||||
if let Some(id) = location.id {
|
||||
let _ = client.delete_location(&id).await;
|
||||
}
|
||||
}
|
||||
Err(e) => report.fail("create_location (default)", describe(&e)),
|
||||
}
|
||||
|
||||
match client
|
||||
.create_webhook(&Webhook {
|
||||
event_id: "1".into(),
|
||||
target_url: "https://example.com/sdk-smoke".into(),
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(hook) => {
|
||||
report.pass("create_webhook (default)", "empty format omitted");
|
||||
if let Some(id) = hook.id {
|
||||
let _ = client.delete_webhook(&id).await;
|
||||
}
|
||||
}
|
||||
Err(e) => report.fail("create_webhook (default)", describe(&e)),
|
||||
}
|
||||
|
||||
// The create succeeds either way; decoding the reply is what failed.
|
||||
match client
|
||||
.create_recurring_invoice(&RecurringInvoice {
|
||||
client_id: client_id.to_string(),
|
||||
frequency_id: 5,
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(recurring) => {
|
||||
report.check(
|
||||
"create_recurring_invoice",
|
||||
recurring.frequency_id == 5,
|
||||
format!("frequency_id decoded as {}", recurring.frequency_id),
|
||||
);
|
||||
if let Some(id) = recurring.id {
|
||||
let _ = client.delete_recurring_invoice(&id).await;
|
||||
}
|
||||
}
|
||||
Err(e) => report.fail("create_recurring_invoice", describe(&e)),
|
||||
}
|
||||
|
||||
if let Err(e) = client.delete_vendor(&vendor_id).await {
|
||||
report.fail(
|
||||
"delete_vendor",
|
||||
format!("{} -- remove the vendor by hand", describe(&e)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Password tier: needs `IN_API_PASSWORD`, and only runs inside the write
|
||||
/// tier's host guard. Merges two throwaway clients, then purges the survivor,
|
||||
/// which unlike `delete_client` really removes the row. Users, companies and
|
||||
/// vendors are left alone: inviting a user sends mail, and a company purge
|
||||
/// takes data that was not created here.
|
||||
async fn password_checks(client: &InvoiceNinjaClient, report: &mut Report, password: String) {
|
||||
let client = match client.clone().with_api_password(password) {
|
||||
Ok(client) => client,
|
||||
Err(e) => {
|
||||
report.fail("with_api_password", describe(&e));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
match client.new_user_template().await {
|
||||
Ok(user) => report.check(
|
||||
"new_user_template",
|
||||
user.id.as_deref().unwrap_or("").is_empty(),
|
||||
"blank user, nothing stored",
|
||||
),
|
||||
Err(e) => report.fail("new_user_template", describe(&e)),
|
||||
}
|
||||
|
||||
let stamp = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_nanos())
|
||||
.unwrap_or(0);
|
||||
let mut ids = Vec::new();
|
||||
for label in ["keep", "drop"] {
|
||||
match client
|
||||
.create_client(&Client {
|
||||
name: format!("sdk-smoke-merge-{label}-{stamp}"),
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(created) => ids.extend(created.id),
|
||||
Err(e) => {
|
||||
report.fail("create_client (merge)", describe(&e));
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
let [keep, drop] = [ids[0].clone(), ids[1].clone()];
|
||||
|
||||
match client.merge_clients(&keep, &drop).await {
|
||||
Ok(merged) => report.check(
|
||||
"merge_clients",
|
||||
merged.id.as_deref() == Some(keep.as_str()),
|
||||
"the first id is the survivor",
|
||||
),
|
||||
Err(e) => report.fail("merge_clients", describe(&e)),
|
||||
}
|
||||
|
||||
match client.purge_client(&keep).await {
|
||||
Ok(message) => report.pass("purge_client", format!("`{}`", message.message)),
|
||||
Err(e) => report.fail(
|
||||
"purge_client",
|
||||
format!(
|
||||
"{} -- remove the sdk-smoke-merge clients by hand",
|
||||
describe(&e)
|
||||
),
|
||||
),
|
||||
}
|
||||
|
||||
// A purge is permanent, so the record must now be gone, not just flagged.
|
||||
// A missing record answers 400, not 404: the server maps Laravel's
|
||||
// ModelNotFoundException to a 400 and keeps 404 for a missing route. The
|
||||
// message is checked too, so a 400 raised for some other reason fails.
|
||||
match client.get_client(&keep).await {
|
||||
Err(Error::Api {
|
||||
status, message, ..
|
||||
}) if status.as_u16() == 400 && message.contains("No query results") => report.pass(
|
||||
"get_client after purge",
|
||||
"400 no query results, the row is gone",
|
||||
),
|
||||
Err(e) => report.fail("get_client after purge", describe(&e)),
|
||||
Ok(_) => report.fail("get_client after purge", "still readable after a purge"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Notes, the per-record feed and email history, against the throwaway
|
||||
/// client. Nothing here sends mail: the history is read, and is expected to
|
||||
/// be empty because no mail has gone out for this client.
|
||||
async fn activity_checks(client: &InvoiceNinjaClient, report: &mut Report, id: &str) {
|
||||
let note = "sdk smoke note";
|
||||
match client.add_activity_note("clients", id, note).await {
|
||||
Ok(activity) => report.check(
|
||||
"add_activity_note",
|
||||
activity.notes == note && activity.client_id == id,
|
||||
"note stored against the client",
|
||||
),
|
||||
Err(e) => report.fail("add_activity_note", describe(&e)),
|
||||
}
|
||||
|
||||
match client.entity_activity("client", id, Some(10)).await {
|
||||
Ok(feed) => report.check(
|
||||
"entity_activity",
|
||||
feed.iter().any(|e| e.notes == note),
|
||||
format!("{} entr(ies), including the note", feed.len()),
|
||||
),
|
||||
Err(e) => report.fail("entity_activity", describe(&e)),
|
||||
}
|
||||
|
||||
match client.client_email_history(id).await {
|
||||
Ok(history) => report.check(
|
||||
"client_email_history",
|
||||
history.is_empty(),
|
||||
"no mail sent, so no history",
|
||||
),
|
||||
Err(e) => report.fail("client_email_history", describe(&e)),
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
description = "invoiceninjars: a Rust client for the Invoice Ninja v5 API";
|
||||
description = "invoiceninja-sdk: a Rust client for the Invoice Ninja v5 API";
|
||||
|
||||
inputs = {
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||
@@ -37,9 +37,20 @@
|
||||
msrvToolchain = toolchainFor msrvVersion [ ];
|
||||
|
||||
shellFor = toolchain: pkgs.mkShell {
|
||||
packages = [ toolchain pkgs.pkg-config pkgs.openssl ];
|
||||
# jq is for the MSRV recheck in CLAUDE.md, which pipes `cargo
|
||||
# metadata` through it; the rest is the toolchain plus what
|
||||
# openssl-sys needs to link.
|
||||
packages = [ toolchain pkgs.pkg-config pkgs.openssl pkgs.jq ];
|
||||
RUST_SRC_PATH = "${toolchain}/lib/rustlib/src/rust/library";
|
||||
};
|
||||
|
||||
# Built with the pinned dev toolchain, not nixpkgs' own rustc, so a
|
||||
# `nix build` matches what `nix develop` and CI compile with rather
|
||||
# than whatever stable nixpkgs happens to carry.
|
||||
rustPlatform = pkgs.makeRustPlatform {
|
||||
cargo = rustToolchain;
|
||||
rustc = rustToolchain;
|
||||
};
|
||||
in
|
||||
{
|
||||
devShells.default = shellFor rustToolchain;
|
||||
@@ -47,5 +58,25 @@
|
||||
# Verifies the declared MSRV still builds:
|
||||
# nix develop .#msrv --command cargo test
|
||||
devShells.msrv = shellFor msrvToolchain;
|
||||
|
||||
# This is a library with no `[[bin]]` target (see the "No CLI, and
|
||||
# no binary at all" non-goal in GOALS.md), so the output directory
|
||||
# ends up empty: `cargoInstallHook` only copies binaries matching a
|
||||
# `[[bin]]` target out of `target/release`, and there are none to
|
||||
# copy. `nix build` is still meaningful as a build-and-test gate —
|
||||
# it compiles with the pinned toolchain and runs the full suite,
|
||||
# including doctests, inside the Nix sandbox (no network, matching
|
||||
# the "no credentials needed" property in CLAUDE.md) — just not as
|
||||
# a source of an installable artifact.
|
||||
packages.default = rustPlatform.buildRustPackage {
|
||||
pname = "invoiceninja-sdk";
|
||||
version = (builtins.fromTOML (builtins.readFile ./Cargo.toml)).package.version;
|
||||
src = self;
|
||||
cargoLock.lockFile = ./Cargo.lock;
|
||||
nativeBuildInputs = [ pkgs.pkg-config ];
|
||||
buildInputs = [ pkgs.openssl ];
|
||||
# The test suite needs no network (see CLAUDE.md), so the default
|
||||
# `cargo test` checkPhase runs unmodified in the sandboxed build.
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
+1337
-41
File diff suppressed because it is too large
Load Diff
+42
-1
@@ -15,7 +15,11 @@ pub struct ApiErrorBody {
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
#[non_exhaustive]
|
||||
pub enum Error {
|
||||
#[error("http transport error: {0}")]
|
||||
/// Rendered with its whole cause chain, because `reqwest`'s own message
|
||||
/// stops at "error sending request for url (...)" and leaves out the part
|
||||
/// that says why: "Connection refused", a DNS failure, a TLS handshake
|
||||
/// error, a timeout.
|
||||
#[error("http transport error: {}", cause_chain(.0))]
|
||||
Http(#[from] reqwest::Error),
|
||||
|
||||
/// The response was not the JSON this endpoint is documented to return.
|
||||
@@ -31,6 +35,15 @@ pub enum Error {
|
||||
excerpt: String,
|
||||
},
|
||||
|
||||
/// A non-2xx response: the server's message, plus any per-field
|
||||
/// validation errors.
|
||||
///
|
||||
/// **A missing record is `400`, not `404`.** Invoice Ninja's exception
|
||||
/// handler maps Laravel's `ModelNotFoundException` to a 400 whose message
|
||||
/// is `No query results for model [App\Models\Client].`, and reserves 404
|
||||
/// for a route that does not exist or a method that route does not
|
||||
/// support. Matching on 404 to mean "not found" will never fire — match
|
||||
/// the status together with the message.
|
||||
#[error("api returned {status}: {message}")]
|
||||
Api {
|
||||
status: reqwest::StatusCode,
|
||||
@@ -54,6 +67,14 @@ pub enum Error {
|
||||
#[error("`{0}` cannot be used as a url path segment")]
|
||||
InvalidPathSegment(String),
|
||||
|
||||
/// An upload was refused before any request was sent: no files, a file
|
||||
/// with no name, or a MIME type that does not parse.
|
||||
///
|
||||
/// Raised locally because the server would otherwise answer an empty
|
||||
/// upload with a success that changed nothing.
|
||||
#[error("invalid upload: {0}")]
|
||||
InvalidUpload(String),
|
||||
|
||||
/// A `list_all_*` call kept receiving full pages past the point where any
|
||||
/// plausible result set would have ended.
|
||||
///
|
||||
@@ -66,3 +87,23 @@ pub enum Error {
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, Error>;
|
||||
|
||||
/// Joins an error and every error beneath it with `: `.
|
||||
///
|
||||
/// `reqwest::Error`'s `Display` prints only the outermost layer, so the
|
||||
/// reason a request failed lives in `source()` and is otherwise invisible to
|
||||
/// anyone who just prints the error.
|
||||
fn cause_chain(error: &(dyn std::error::Error + 'static)) -> String {
|
||||
let mut message = error.to_string();
|
||||
let mut source = error.source();
|
||||
while let Some(cause) = source {
|
||||
let text = cause.to_string();
|
||||
// Some layers repeat their cause's text; don't print it twice.
|
||||
if !message.contains(&text) {
|
||||
message.push_str(": ");
|
||||
message.push_str(&text);
|
||||
}
|
||||
source = cause.source();
|
||||
}
|
||||
message
|
||||
}
|
||||
|
||||
+4
-2
@@ -1,9 +1,9 @@
|
||||
//! An async Rust client for the [Invoice Ninja](https://www.invoiceninja.com/) v5 API.
|
||||
//!
|
||||
//! ```no_run
|
||||
//! use invoiceninjars::{InvoiceNinjaClient, models::ListParams};
|
||||
//! use invoiceninja_sdk::{InvoiceNinjaClient, models::ListParams};
|
||||
//!
|
||||
//! # async fn run() -> invoiceninjars::Result<()> {
|
||||
//! # async fn run() -> invoiceninja_sdk::Result<()> {
|
||||
//! let client = InvoiceNinjaClient::new(
|
||||
//! "https://invoicing.co/api/v1",
|
||||
//! std::env::var("IN_API_TOKEN").unwrap(),
|
||||
@@ -18,7 +18,9 @@ mod client;
|
||||
mod error;
|
||||
pub mod models;
|
||||
mod retry;
|
||||
mod upload;
|
||||
|
||||
pub use client::{InvoiceNinjaClient, DEFAULT_TIMEOUT, HOSTED_BASE_URL};
|
||||
pub use error::{ApiErrorBody, Error, Result};
|
||||
pub use retry::RetryPolicy;
|
||||
pub use upload::FileUpload;
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
/// One row of the activity log, as `GET /activities` returns it.
|
||||
///
|
||||
/// Every reference is a hashed id, or an empty string when the activity does
|
||||
/// not concern that kind of record. `activity_type_id` arrives as a string
|
||||
/// here but as a number in the [feed](ActivityFeedEntry).
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct Activity {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub id: Option<String>,
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub activity_type_id: String,
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub user_id: String,
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub company_id: String,
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub client_id: String,
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub contact_id: String,
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub vendor_id: String,
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub vendor_contact_id: String,
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub invoice_id: String,
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub quote_id: String,
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub credit_id: String,
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub payment_id: String,
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub recurring_invoice_id: String,
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub recurring_expense_id: String,
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub purchase_order_id: String,
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub expense_id: String,
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub task_id: String,
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub token_id: String,
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub is_system: bool,
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub notes: String,
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub ip: String,
|
||||
#[serde(default)]
|
||||
pub created_at: Option<i64>,
|
||||
#[serde(default)]
|
||||
pub updated_at: Option<i64>,
|
||||
|
||||
#[serde(flatten)]
|
||||
pub extra: Map<String, Value>,
|
||||
}
|
||||
|
||||
/// A named record an activity refers to: the display label and its hashed id.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct ActivityRef {
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub label: String,
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub hashed_id: String,
|
||||
|
||||
#[serde(flatten)]
|
||||
pub extra: Map<String, Value>,
|
||||
}
|
||||
|
||||
/// One entry of the activity feed (`activity_feed`, `entity_activity`).
|
||||
///
|
||||
/// A different shape from [`Activity`]: the server resolves the records an
|
||||
/// activity mentions into labelled references. Which references are present
|
||||
/// depends on the activity type, so each is optional, and any the server adds
|
||||
/// that this does not name lands in `extra`.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct ActivityFeedEntry {
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub id: String,
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub hashed_id: String,
|
||||
#[serde(default, deserialize_with = "super::common::int_or_numeric_string")]
|
||||
pub activity_type_id: i64,
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub notes: String,
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub ip: String,
|
||||
#[serde(default, deserialize_with = "super::common::int_or_numeric_string")]
|
||||
pub created_at: i64,
|
||||
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub user: Option<ActivityRef>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub client: Option<ActivityRef>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub vendor: Option<ActivityRef>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub invoice: Option<ActivityRef>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub quote: Option<ActivityRef>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub credit: Option<ActivityRef>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub payment: Option<ActivityRef>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub task: Option<ActivityRef>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub expense: Option<ActivityRef>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub purchase_order: Option<ActivityRef>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub recurring_invoice: Option<ActivityRef>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub recurring_expense: Option<ActivityRef>,
|
||||
|
||||
#[serde(flatten)]
|
||||
pub extra: Map<String, Value>,
|
||||
}
|
||||
+39
-1
@@ -104,7 +104,7 @@ impl ListParams {
|
||||
/// not cover.
|
||||
///
|
||||
/// ```
|
||||
/// use invoiceninjars::models::ListParams;
|
||||
/// use invoiceninja_sdk::models::ListParams;
|
||||
///
|
||||
/// let params = ListParams::new()
|
||||
/// .per_page(100)
|
||||
@@ -154,6 +154,32 @@ where
|
||||
}
|
||||
}
|
||||
|
||||
/// Deserializes an integer that may arrive as a JSON number or as a numeric
|
||||
/// string.
|
||||
///
|
||||
/// The OpenAPI spec calls `frequency_id` a number on recurring invoices and
|
||||
/// expenses, but a real 5.13.50 server returns the string `"5"`. A strict
|
||||
/// `i64` made that a hard decode error *after* the record had already been
|
||||
/// created. A string that is not a whole number is still an error.
|
||||
pub(crate) fn int_or_numeric_string<'de, D>(deserializer: D) -> Result<i64, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
match Option::<Value>::deserialize(deserializer)? {
|
||||
None | Some(Value::Null) => Ok(0),
|
||||
Some(Value::Number(n)) => n
|
||||
.as_i64()
|
||||
.ok_or_else(|| serde::de::Error::custom(format!("expected a whole number, got {n}"))),
|
||||
Some(Value::String(s)) if s.is_empty() => Ok(0),
|
||||
Some(Value::String(s)) => s.trim().parse().map_err(|_| {
|
||||
serde::de::Error::custom(format!("expected a whole number, got string `{s}`"))
|
||||
}),
|
||||
Some(other) => Err(serde::de::Error::custom(format!(
|
||||
"expected a whole number or numeric string, got {other}"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Body for a bulk action (`POST /{resource}/bulk`).
|
||||
///
|
||||
/// The action set varies by resource; `archive`, `restore`, and `delete` work
|
||||
@@ -174,3 +200,15 @@ impl BulkRequest {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The `{"message": "..."}` body that action endpoints answer with.
|
||||
///
|
||||
/// `message` is defaulted so a reply that carries none still decodes.
|
||||
/// Anything else the server adds is kept in `extra`.
|
||||
#[derive(Debug, Clone, Default, Deserialize)]
|
||||
pub struct MessageResponse {
|
||||
#[serde(default, deserialize_with = "null_to_default")]
|
||||
pub message: String,
|
||||
#[serde(flatten)]
|
||||
pub extra: serde_json::Map<String, Value>,
|
||||
}
|
||||
|
||||
@@ -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>,
|
||||
}
|
||||
@@ -18,7 +18,14 @@ pub struct Location {
|
||||
pub state: String,
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub postal_code: String,
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
/// Omitted when empty: the server validates it as an integer, so an unset
|
||||
/// `""` is rejected with a 422 and a default `Location` could not be
|
||||
/// created.
|
||||
#[serde(
|
||||
default,
|
||||
deserialize_with = "super::common::null_to_default",
|
||||
skip_serializing_if = "String::is_empty"
|
||||
)]
|
||||
pub country_id: String,
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub user_id: String,
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
use super::CompanyToken;
|
||||
|
||||
/// Response of `POST /login` (via [`crate::InvoiceNinjaClient::login`]).
|
||||
///
|
||||
/// One entry per company the account can access. The full upstream payload
|
||||
/// also nests the whole company (clients, invoices, designs, ...), the
|
||||
/// user, and the account under each entry — this crate does not model that;
|
||||
/// it stays reachable, losslessly, through [`LoginCompanyUser::extra`].
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct LoginResponse {
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub data: Vec<LoginCompanyUser>,
|
||||
}
|
||||
|
||||
/// One company membership returned by a login, carrying that company's API
|
||||
/// token.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct LoginCompanyUser {
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub is_owner: bool,
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub is_admin: bool,
|
||||
/// The token to pass to [`InvoiceNinjaClient::new`](crate::InvoiceNinjaClient::new)
|
||||
/// for this company. Only absent if the server ever omits the `token`
|
||||
/// include; every server-side `first_load` request requests it.
|
||||
#[serde(default)]
|
||||
pub token: Option<CompanyToken>,
|
||||
|
||||
#[serde(flatten)]
|
||||
pub extra: Map<String, Value>,
|
||||
}
|
||||
+12
-2
@@ -1,3 +1,4 @@
|
||||
mod activity;
|
||||
mod bank_integration;
|
||||
mod bank_transaction;
|
||||
mod bank_transaction_rule;
|
||||
@@ -13,8 +14,11 @@ mod expense;
|
||||
mod expense_category;
|
||||
mod group_setting;
|
||||
mod health;
|
||||
mod import_export;
|
||||
mod invoice;
|
||||
mod location;
|
||||
mod login;
|
||||
mod passkey;
|
||||
mod payment;
|
||||
mod payment_term;
|
||||
mod product;
|
||||
@@ -35,12 +39,15 @@ mod user;
|
||||
mod vendor;
|
||||
mod webhook;
|
||||
|
||||
pub use activity::{Activity, ActivityFeedEntry, ActivityRef};
|
||||
pub use bank_integration::BankIntegration;
|
||||
pub use bank_transaction::BankTransaction;
|
||||
pub use bank_transaction_rule::BankTransactionRule;
|
||||
pub use client::{Client, ClientContact};
|
||||
pub use client_gateway_token::ClientGatewayToken;
|
||||
pub use common::{BulkRequest, ItemResponse, ListParams, ListResponse, Meta, Pagination};
|
||||
pub use common::{
|
||||
BulkRequest, ItemResponse, ListParams, ListResponse, MessageResponse, Meta, Pagination,
|
||||
};
|
||||
pub use company::Company;
|
||||
pub use company_gateway::CompanyGateway;
|
||||
pub use company_token::CompanyToken;
|
||||
@@ -50,8 +57,11 @@ pub use expense::Expense;
|
||||
pub use expense_category::ExpenseCategory;
|
||||
pub use group_setting::GroupSetting;
|
||||
pub use health::{HealthCheck, PhpVersion};
|
||||
pub use import_export::{ExportJob, ImportPreview};
|
||||
pub use invoice::{Credit, Invoice, InvoiceItem, Quote};
|
||||
pub use location::Location;
|
||||
pub use login::{LoginCompanyUser, LoginResponse};
|
||||
pub use passkey::Passkey;
|
||||
pub use payment::{Payment, PaymentInvoice};
|
||||
pub use payment_term::PaymentTerm;
|
||||
pub use product::Product;
|
||||
@@ -61,7 +71,7 @@ pub use recurring_expense::RecurringExpense;
|
||||
pub use recurring_invoice::{RecurringInvoice, RecurringQuote};
|
||||
pub use search::{SearchHit, SearchResults};
|
||||
pub use statics::{Currency, DateFormat, StaticItem, Statics};
|
||||
pub use subscription::Subscription;
|
||||
pub use subscription::{Subscription, SubscriptionStep};
|
||||
pub use system_log::SystemLog;
|
||||
pub use tag::Tag;
|
||||
pub use task::Task;
|
||||
|
||||
@@ -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>,
|
||||
}
|
||||
@@ -38,7 +38,9 @@ pub struct Payment {
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct PaymentInvoice {
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub invoice_id: String,
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub amount: f64,
|
||||
|
||||
#[serde(flatten)]
|
||||
|
||||
@@ -23,7 +23,14 @@ pub struct PurchaseOrder {
|
||||
pub po_number: String,
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub quote_number: String,
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
/// Omitted when empty: the server turns `""` into `NULL` and then fails
|
||||
/// the insert on a non-null column with a 500, so a new purchase order
|
||||
/// must leave it out and take the server's default.
|
||||
#[serde(
|
||||
default,
|
||||
deserialize_with = "super::common::null_to_default",
|
||||
skip_serializing_if = "String::is_empty"
|
||||
)]
|
||||
pub status_id: String,
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub date: String,
|
||||
|
||||
@@ -28,7 +28,7 @@ pub struct RecurringExpense {
|
||||
/// Integer enum matching [`RecurringInvoice::frequency_id`].
|
||||
///
|
||||
/// [`RecurringInvoice::frequency_id`]: super::RecurringInvoice::frequency_id
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
#[serde(default, deserialize_with = "super::common::int_or_numeric_string")]
|
||||
pub frequency_id: i64,
|
||||
/// Number of cycles left; `-1` means it recurs indefinitely.
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
|
||||
@@ -25,8 +25,9 @@ pub struct RecurringInvoice {
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub status_id: String,
|
||||
/// How often the invoice recurs. Invoice Ninja returns this as an integer
|
||||
/// enum (1 = daily, 2 = weekly, … ), not a human-readable string.
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
/// enum (1 = daily, 2 = weekly, … ), not a human-readable string. A real
|
||||
/// server sends it as a numeric string; both decode.
|
||||
#[serde(default, deserialize_with = "super::common::int_or_numeric_string")]
|
||||
pub frequency_id: i64,
|
||||
/// Number of cycles left to send; `-1` means it recurs indefinitely.
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
|
||||
@@ -55,3 +55,16 @@ pub struct Subscription {
|
||||
#[serde(flatten)]
|
||||
pub extra: Map<String, Value>,
|
||||
}
|
||||
|
||||
/// One step of the subscription purchase flow and the steps it depends on,
|
||||
/// as returned by `GET /subscriptions/steps`.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct SubscriptionStep {
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub id: String,
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub dependencies: Vec<String>,
|
||||
|
||||
#[serde(flatten)]
|
||||
pub extra: Map<String, Value>,
|
||||
}
|
||||
|
||||
@@ -3,8 +3,9 @@ use serde_json::{Map, Value};
|
||||
|
||||
/// A server-side log entry (gateway responses, mail failures, and so on).
|
||||
///
|
||||
/// Note that unlike every other resource, this one's `created_at` and
|
||||
/// `updated_at` are returned as date **strings**, not integer timestamps.
|
||||
/// The OpenAPI spec declares `created_at` and `updated_at` as date strings,
|
||||
/// but a real server (5.13.50) sends integer timestamps like every other
|
||||
/// resource.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct SystemLog {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
@@ -23,9 +24,9 @@ pub struct SystemLog {
|
||||
#[serde(default)]
|
||||
pub log: Value,
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub created_at: String,
|
||||
pub created_at: i64,
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub updated_at: String,
|
||||
pub updated_at: i64,
|
||||
|
||||
#[serde(flatten)]
|
||||
pub extra: Map<String, Value>,
|
||||
|
||||
@@ -13,8 +13,13 @@ pub struct Webhook {
|
||||
/// The URL Invoice Ninja posts to.
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub target_url: String,
|
||||
/// Payload format: `JSON` or `UBL`.
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
/// Payload format: `JSON` or `UBL`. Omitted when empty: the server turns
|
||||
/// `""` into `NULL` and fails the insert on a non-null column (a 500).
|
||||
#[serde(
|
||||
default,
|
||||
deserialize_with = "super::common::null_to_default",
|
||||
skip_serializing_if = "String::is_empty"
|
||||
)]
|
||||
pub format: String,
|
||||
|
||||
#[serde(flatten)]
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
use std::fmt;
|
||||
|
||||
use reqwest::multipart::Part;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
|
||||
/// A file to attach to a record with the `upload_*` methods.
|
||||
///
|
||||
/// The contents are held in memory, so a large file costs its full size in
|
||||
/// RAM for as long as the upload takes. Streaming uploads are an open
|
||||
/// question in `GOALS.md`.
|
||||
///
|
||||
/// ```
|
||||
/// use invoiceninja_sdk::FileUpload;
|
||||
///
|
||||
/// let file = FileUpload::new("receipt.pdf", b"%PDF-1.4 ...".to_vec())
|
||||
/// .mime("application/pdf");
|
||||
/// ```
|
||||
#[derive(Clone)]
|
||||
pub struct FileUpload {
|
||||
file_name: String,
|
||||
bytes: Vec<u8>,
|
||||
mime: Option<String>,
|
||||
}
|
||||
|
||||
impl FileUpload {
|
||||
/// `file_name` is what the server records and shows against the
|
||||
/// document, so it should carry the right extension.
|
||||
pub fn new(file_name: impl Into<String>, bytes: impl Into<Vec<u8>>) -> Self {
|
||||
Self {
|
||||
file_name: file_name.into(),
|
||||
bytes: bytes.into(),
|
||||
mime: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets the part's `Content-Type`. Optional: without it the part carries
|
||||
/// none, and the server decides from the file's contents.
|
||||
pub fn mime(mut self, mime: impl Into<String>) -> Self {
|
||||
self.mime = Some(mime.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub(crate) fn into_part(self) -> Result<Part> {
|
||||
if self.file_name.trim().is_empty() {
|
||||
return Err(Error::InvalidUpload(
|
||||
"a file needs a non-empty file name".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let part = Part::bytes(self.bytes).file_name(self.file_name);
|
||||
match self.mime {
|
||||
Some(mime) => part
|
||||
.mime_str(&mime)
|
||||
.map_err(|_| Error::InvalidUpload(format!("`{mime}` is not a valid MIME type"))),
|
||||
None => Ok(part),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Elides the contents: the derived impl would print the entire file.
|
||||
impl fmt::Debug for FileUpload {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.debug_struct("FileUpload")
|
||||
.field("file_name", &self.file_name)
|
||||
.field("len", &self.bytes.len())
|
||||
.field("mime", &self.mime)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
+1885
-4
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,132 @@
|
||||
//! Goal 2, checked mechanically: every model must absorb a field that is
|
||||
//! `null` or absent.
|
||||
//!
|
||||
//! Invoice Ninja returns `null` for many nominally-string fields and omits
|
||||
//! others, so a model that fails on either takes the whole response down with
|
||||
//! it. Rather than trusting each field to carry the right serde attribute,
|
||||
//! this serialises `Default` (which emits every non-optional field), then for
|
||||
//! each field in turn nulls it and, separately, removes it, and requires the
|
||||
//! result to still decode.
|
||||
//!
|
||||
//! A wrong *type* is deliberately not covered: that is meant to be a hard
|
||||
//! error, so it is not something to absorb.
|
||||
|
||||
use invoiceninja_sdk::models::*;
|
||||
use serde::{de::DeserializeOwned, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
/// Returns one line per mutation that failed to decode.
|
||||
fn failures<T: Serialize + DeserializeOwned + Default>(name: &str) -> Vec<String> {
|
||||
let base = serde_json::to_value(T::default()).expect("default serialises");
|
||||
let object = base
|
||||
.as_object()
|
||||
.unwrap_or_else(|| panic!("{name}: default did not serialise to an object"));
|
||||
|
||||
let mut out = Vec::new();
|
||||
for key in object.keys() {
|
||||
let mut nulled = base.clone();
|
||||
nulled[key] = Value::Null;
|
||||
if let Err(e) = serde_json::from_value::<T>(nulled) {
|
||||
out.push(format!("{name}.{key} = null: {e}"));
|
||||
}
|
||||
|
||||
let mut removed = base.clone();
|
||||
removed.as_object_mut().unwrap().remove(key);
|
||||
if let Err(e) = serde_json::from_value::<T>(removed) {
|
||||
out.push(format!("{name}.{key} absent: {e}"));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
macro_rules! tolerant {
|
||||
($($ty:ty),+ $(,)?) => {{
|
||||
let mut all = Vec::new();
|
||||
$( all.extend(failures::<$ty>(stringify!($ty))); )+
|
||||
all
|
||||
}};
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_model_absorbs_a_null_or_missing_field() {
|
||||
let all = tolerant![
|
||||
Activity,
|
||||
ActivityFeedEntry,
|
||||
ActivityRef,
|
||||
BankIntegration,
|
||||
BankTransaction,
|
||||
BankTransactionRule,
|
||||
Client,
|
||||
ClientContact,
|
||||
ClientGatewayToken,
|
||||
Company,
|
||||
CompanyGateway,
|
||||
CompanyToken,
|
||||
Design,
|
||||
Document,
|
||||
Expense,
|
||||
ExpenseCategory,
|
||||
ExportJob,
|
||||
GroupSetting,
|
||||
ImportPreview,
|
||||
Invoice,
|
||||
InvoiceItem,
|
||||
Location,
|
||||
LoginCompanyUser,
|
||||
LoginResponse,
|
||||
Passkey,
|
||||
Payment,
|
||||
PaymentInvoice,
|
||||
PaymentTerm,
|
||||
Product,
|
||||
Project,
|
||||
PurchaseOrder,
|
||||
RecurringExpense,
|
||||
RecurringInvoice,
|
||||
Subscription,
|
||||
SubscriptionStep,
|
||||
SystemLog,
|
||||
Tag,
|
||||
Task,
|
||||
TaskScheduler,
|
||||
TaskStatus,
|
||||
TaxRate,
|
||||
User,
|
||||
Vendor,
|
||||
VendorContact,
|
||||
Webhook,
|
||||
];
|
||||
|
||||
assert!(
|
||||
all.is_empty(),
|
||||
"{} field(s) break decoding on null/absent:\n{}",
|
||||
all.len(),
|
||||
all.join("\n")
|
||||
);
|
||||
}
|
||||
|
||||
/// A guard on the test itself: if `Default` stopped emitting fields, the loop
|
||||
/// above would pass vacuously.
|
||||
#[test]
|
||||
fn the_tolerance_test_actually_exercises_fields() {
|
||||
let client = serde_json::to_value(Client::default()).unwrap();
|
||||
assert!(
|
||||
client.as_object().unwrap().len() > 10,
|
||||
"Client::default() should serialise many fields"
|
||||
);
|
||||
}
|
||||
|
||||
/// The mutation really does bite: a field without `null_to_default` fails.
|
||||
#[test]
|
||||
fn a_bare_non_option_field_would_be_caught() {
|
||||
#[derive(serde::Serialize, serde::Deserialize, Default)]
|
||||
struct Naive {
|
||||
#[serde(default)]
|
||||
name: String,
|
||||
}
|
||||
assert_eq!(
|
||||
failures::<Naive>("Naive").len(),
|
||||
1,
|
||||
"only the null case fails"
|
||||
);
|
||||
}
|
||||
Vendored
+29
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"activity_type_id": "",
|
||||
"client_id": "",
|
||||
"company_id": "",
|
||||
"contact_id": "",
|
||||
"created_at": 0,
|
||||
"credit_id": "",
|
||||
"expense_id": "",
|
||||
"id": "",
|
||||
"invoice_id": "",
|
||||
"ip": "",
|
||||
"is_system": false,
|
||||
"notes": "",
|
||||
"payment_id": "",
|
||||
"purchase_order_id": "",
|
||||
"quote_id": "",
|
||||
"recurring_expense_id": "",
|
||||
"recurring_invoice_id": "",
|
||||
"task_id": "",
|
||||
"token_id": "",
|
||||
"updated_at": 0,
|
||||
"user_id": "",
|
||||
"vendor_contact_id": "",
|
||||
"vendor_id": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
+24
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"activity_type_id": 0,
|
||||
"created_at": 0,
|
||||
"hashed_id": "",
|
||||
"id": "",
|
||||
"ip": "",
|
||||
"notes": "",
|
||||
"purchase_order": {
|
||||
"hashed_id": "",
|
||||
"label": ""
|
||||
},
|
||||
"user": {
|
||||
"hashed_id": "",
|
||||
"label": ""
|
||||
},
|
||||
"vendor": {
|
||||
"hashed_id": "",
|
||||
"label": ""
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
+114
@@ -0,0 +1,114 @@
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"address1": "",
|
||||
"address2": "",
|
||||
"archived_at": 0,
|
||||
"assigned_user_id": "",
|
||||
"balance": 0,
|
||||
"city": "",
|
||||
"classification": "",
|
||||
"client_hash": "",
|
||||
"contacts": [
|
||||
{
|
||||
"archived_at": 0,
|
||||
"can_sign": false,
|
||||
"cc_only": false,
|
||||
"contact_key": "",
|
||||
"created_at": 0,
|
||||
"custom_value1": "",
|
||||
"custom_value2": "",
|
||||
"custom_value3": "",
|
||||
"custom_value4": "",
|
||||
"email": "",
|
||||
"first_name": "",
|
||||
"id": "",
|
||||
"is_locked": false,
|
||||
"is_primary": false,
|
||||
"last_login": 0,
|
||||
"last_name": "",
|
||||
"link": "",
|
||||
"password": "",
|
||||
"phone": "",
|
||||
"send_email": false,
|
||||
"updated_at": 0
|
||||
}
|
||||
],
|
||||
"country_id": "",
|
||||
"created_at": 0,
|
||||
"credit_balance": 0,
|
||||
"custom_value1": "",
|
||||
"custom_value2": "",
|
||||
"custom_value3": "",
|
||||
"custom_value4": "",
|
||||
"display_name": "",
|
||||
"documents": [
|
||||
{
|
||||
"archived_at": 0,
|
||||
"assigned_user_id": "",
|
||||
"created_at": 0,
|
||||
"disk": "",
|
||||
"documentable_id": "",
|
||||
"documentable_type": "",
|
||||
"hash": "",
|
||||
"height": 0,
|
||||
"id": "",
|
||||
"is_default": false,
|
||||
"is_deleted": false,
|
||||
"is_public": false,
|
||||
"link": "",
|
||||
"name": "",
|
||||
"preview": "",
|
||||
"project_id": "",
|
||||
"size": 0,
|
||||
"type": "",
|
||||
"updated_at": 0,
|
||||
"url": "",
|
||||
"user_id": "",
|
||||
"vendor_id": "",
|
||||
"width": 0
|
||||
}
|
||||
],
|
||||
"e_invoice": {},
|
||||
"gateway_tokens": [],
|
||||
"group_settings_id": "",
|
||||
"has_valid_vat_number": false,
|
||||
"id": "",
|
||||
"id_number": "",
|
||||
"industry_id": "",
|
||||
"is_deleted": false,
|
||||
"is_tax_exempt": false,
|
||||
"last_login": 0,
|
||||
"locations": [],
|
||||
"name": "",
|
||||
"number": "",
|
||||
"paid_to_date": 0,
|
||||
"payment_balance": 0,
|
||||
"phone": "",
|
||||
"postal_code": "",
|
||||
"private_notes": "",
|
||||
"public_notes": "",
|
||||
"routing_id": "",
|
||||
"settings": {
|
||||
"currency_id": "",
|
||||
"entity": "",
|
||||
"industry_id": null,
|
||||
"size_id": null
|
||||
},
|
||||
"shipping_address1": "",
|
||||
"shipping_address2": "",
|
||||
"shipping_city": "",
|
||||
"shipping_country_id": "",
|
||||
"shipping_postal_code": "",
|
||||
"shipping_state": "",
|
||||
"size_id": "",
|
||||
"state": "",
|
||||
"tags": [],
|
||||
"tax_info": {},
|
||||
"updated_at": 0,
|
||||
"user_id": "",
|
||||
"vat_number": "",
|
||||
"website": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
+1059
File diff suppressed because it is too large
Load Diff
Vendored
+139
@@ -0,0 +1,139 @@
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"amount": 0,
|
||||
"archived_at": 0,
|
||||
"assigned_user_id": "",
|
||||
"backup": {
|
||||
"adjustable_amount": 0,
|
||||
"cancellation": {
|
||||
"adjustment": 0,
|
||||
"status_id": 0
|
||||
},
|
||||
"child_invoice_ids": [],
|
||||
"document_type": null,
|
||||
"e_invoice_cleared_at": null,
|
||||
"e_invoice_status": null,
|
||||
"guid": "",
|
||||
"notes": null,
|
||||
"parent_invoice_id": null,
|
||||
"parent_invoice_number": null,
|
||||
"redirect": null
|
||||
},
|
||||
"balance": 0,
|
||||
"client_id": "",
|
||||
"created_at": 0,
|
||||
"custom_surcharge1": 0,
|
||||
"custom_surcharge2": 0,
|
||||
"custom_surcharge3": 0,
|
||||
"custom_surcharge4": 0,
|
||||
"custom_surcharge_tax1": false,
|
||||
"custom_surcharge_tax2": false,
|
||||
"custom_surcharge_tax3": false,
|
||||
"custom_surcharge_tax4": false,
|
||||
"custom_value1": "",
|
||||
"custom_value2": "",
|
||||
"custom_value3": "",
|
||||
"custom_value4": "",
|
||||
"date": "",
|
||||
"design_id": "",
|
||||
"discount": 0,
|
||||
"documents": [],
|
||||
"due_date": "",
|
||||
"e_invoice": {},
|
||||
"entity_type": "",
|
||||
"exchange_rate": 0,
|
||||
"footer": "",
|
||||
"has_expenses": false,
|
||||
"has_tasks": false,
|
||||
"id": "",
|
||||
"invitations": [
|
||||
{
|
||||
"archived_at": 0,
|
||||
"can_sign": false,
|
||||
"client_contact_id": "",
|
||||
"created_at": 0,
|
||||
"email_error": "",
|
||||
"email_status": "",
|
||||
"id": "",
|
||||
"key": "",
|
||||
"link": "",
|
||||
"message_id": "",
|
||||
"opened_date": "",
|
||||
"sent_date": "",
|
||||
"updated_at": 0,
|
||||
"viewed_date": ""
|
||||
}
|
||||
],
|
||||
"invoice_id": "",
|
||||
"is_amount_discount": false,
|
||||
"is_deleted": false,
|
||||
"last_sent_date": "",
|
||||
"line_items": [
|
||||
{
|
||||
"cost": 0,
|
||||
"custom_value1": "",
|
||||
"custom_value2": "",
|
||||
"custom_value3": "",
|
||||
"custom_value4": "",
|
||||
"date": "",
|
||||
"discount": 0,
|
||||
"expense_id": "",
|
||||
"gross_line_total": 0,
|
||||
"income_account_id": "",
|
||||
"is_amount_discount": false,
|
||||
"line_total": 0,
|
||||
"net_cost": 0,
|
||||
"notes": "",
|
||||
"product_cost": 0,
|
||||
"product_key": "",
|
||||
"quantity": 0,
|
||||
"sort_id": "",
|
||||
"tags": "",
|
||||
"task_id": "",
|
||||
"tax_amount": 0,
|
||||
"tax_id": "",
|
||||
"tax_name1": "",
|
||||
"tax_name2": "",
|
||||
"tax_name3": "",
|
||||
"tax_rate1": 0,
|
||||
"tax_rate2": 0,
|
||||
"tax_rate3": 0,
|
||||
"type_id": "",
|
||||
"unit_code": ""
|
||||
}
|
||||
],
|
||||
"location_id": "",
|
||||
"next_send_date": "",
|
||||
"number": "",
|
||||
"paid_to_date": 0,
|
||||
"partial": 0,
|
||||
"partial_due_date": "",
|
||||
"po_number": "",
|
||||
"private_notes": "",
|
||||
"project_id": "",
|
||||
"public_notes": "",
|
||||
"reminder1_sent": "",
|
||||
"reminder2_sent": "",
|
||||
"reminder3_sent": "",
|
||||
"reminder_last_sent": "",
|
||||
"status_id": "",
|
||||
"subscription_id": "",
|
||||
"sync": null,
|
||||
"tags": [],
|
||||
"tax_info": {},
|
||||
"tax_name1": "",
|
||||
"tax_name2": "",
|
||||
"tax_name3": "",
|
||||
"tax_rate1": 0,
|
||||
"tax_rate2": 0,
|
||||
"tax_rate3": 0,
|
||||
"terms": "",
|
||||
"total_taxes": 0,
|
||||
"updated_at": 0,
|
||||
"user_id": "",
|
||||
"uses_inclusive_taxes": false,
|
||||
"vendor_id": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
+25
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"archived_at": 0,
|
||||
"created_at": 0,
|
||||
"design": {
|
||||
"body": "",
|
||||
"footer": "",
|
||||
"header": "",
|
||||
"includes": "",
|
||||
"product": "",
|
||||
"task": ""
|
||||
},
|
||||
"entities": "",
|
||||
"id": "",
|
||||
"is_active": false,
|
||||
"is_custom": false,
|
||||
"is_deleted": false,
|
||||
"is_free": false,
|
||||
"is_template": false,
|
||||
"name": "",
|
||||
"updated_at": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
+29
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"archived_at": 0,
|
||||
"assigned_user_id": "",
|
||||
"created_at": 0,
|
||||
"disk": "",
|
||||
"documentable_id": "",
|
||||
"documentable_type": "",
|
||||
"hash": "",
|
||||
"height": 0,
|
||||
"id": "",
|
||||
"is_default": false,
|
||||
"is_deleted": false,
|
||||
"is_public": false,
|
||||
"link": "",
|
||||
"name": "",
|
||||
"preview": "",
|
||||
"project_id": "",
|
||||
"size": 0,
|
||||
"type": "",
|
||||
"updated_at": 0,
|
||||
"url": "",
|
||||
"user_id": "",
|
||||
"vendor_id": "",
|
||||
"width": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"archived_at": 0,
|
||||
"color": "",
|
||||
"created_at": 0,
|
||||
"id": "",
|
||||
"is_deleted": false,
|
||||
"name": "",
|
||||
"updated_at": 0,
|
||||
"user_id": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
+55
@@ -0,0 +1,55 @@
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"amount": 0,
|
||||
"archived_at": 0,
|
||||
"assigned_user_id": "",
|
||||
"bank_id": "",
|
||||
"calculate_tax_by_amount": false,
|
||||
"category_id": "",
|
||||
"client_id": "",
|
||||
"created_at": 0,
|
||||
"currency_id": "",
|
||||
"custom_value1": "",
|
||||
"custom_value2": "",
|
||||
"custom_value3": "",
|
||||
"custom_value4": "",
|
||||
"date": "",
|
||||
"documents": [],
|
||||
"e_invoice": {},
|
||||
"entity_type": "",
|
||||
"exchange_rate": 0,
|
||||
"expense_currency_id": "",
|
||||
"foreign_amount": 0,
|
||||
"id": "",
|
||||
"invoice_currency_id": "",
|
||||
"invoice_documents": false,
|
||||
"invoice_id": "",
|
||||
"is_deleted": false,
|
||||
"number": "",
|
||||
"payment_date": "",
|
||||
"payment_type_id": "",
|
||||
"private_notes": "",
|
||||
"project_id": "",
|
||||
"public_notes": "",
|
||||
"recurring_expense_id": "",
|
||||
"should_be_invoiced": false,
|
||||
"tags": [],
|
||||
"tax_amount1": 0,
|
||||
"tax_amount2": 0,
|
||||
"tax_amount3": 0,
|
||||
"tax_name1": "",
|
||||
"tax_name2": "",
|
||||
"tax_name3": "",
|
||||
"tax_rate1": 0,
|
||||
"tax_rate2": 0,
|
||||
"tax_rate3": 0,
|
||||
"transaction_id": "",
|
||||
"transaction_reference": "",
|
||||
"updated_at": 0,
|
||||
"user_id": "",
|
||||
"uses_inclusive_taxes": false,
|
||||
"vendor_id": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
+16
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"archived_at": 0,
|
||||
"created_at": 0,
|
||||
"documents": [],
|
||||
"id": "",
|
||||
"is_deleted": false,
|
||||
"name": "",
|
||||
"settings": {
|
||||
"entity": ""
|
||||
},
|
||||
"updated_at": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
+37
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"api_version": "",
|
||||
"cache_enabled": false,
|
||||
"env_writable": false,
|
||||
"exchange_rate_api_not_configured": false,
|
||||
"exec": false,
|
||||
"extensions": [
|
||||
{
|
||||
"gd": false
|
||||
}
|
||||
],
|
||||
"file_permissions": "",
|
||||
"flutter_renderer": "",
|
||||
"is_docker": false,
|
||||
"jobs_pending": 0,
|
||||
"mail_mailer": "",
|
||||
"open_basedir": false,
|
||||
"pdf_engine": "",
|
||||
"pending_migrations": false,
|
||||
"phantom_enabled": false,
|
||||
"php_version": {
|
||||
"current_php_cli_version": "",
|
||||
"current_php_version": "",
|
||||
"is_okay": false,
|
||||
"memory_limit": "",
|
||||
"minimum_php_version": ""
|
||||
},
|
||||
"queue": "",
|
||||
"queue_data": {
|
||||
"failed": 0,
|
||||
"last_error": "",
|
||||
"pending": 0
|
||||
},
|
||||
"simple_db_check": false,
|
||||
"system_health": false,
|
||||
"trailing_slash": false
|
||||
}
|
||||
Vendored
+140
@@ -0,0 +1,140 @@
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"amount": 0,
|
||||
"archived_at": 0,
|
||||
"assigned_user_id": "",
|
||||
"auto_bill_enabled": false,
|
||||
"backup": {
|
||||
"adjustable_amount": 0,
|
||||
"cancellation": {
|
||||
"adjustment": 0,
|
||||
"status_id": 0
|
||||
},
|
||||
"child_invoice_ids": [],
|
||||
"document_type": null,
|
||||
"e_invoice_cleared_at": null,
|
||||
"e_invoice_status": null,
|
||||
"guid": "",
|
||||
"notes": null,
|
||||
"parent_invoice_id": null,
|
||||
"parent_invoice_number": null,
|
||||
"redirect": null
|
||||
},
|
||||
"balance": 0,
|
||||
"client_id": "",
|
||||
"created_at": 0,
|
||||
"custom_surcharge1": 0,
|
||||
"custom_surcharge2": 0,
|
||||
"custom_surcharge3": 0,
|
||||
"custom_surcharge4": 0,
|
||||
"custom_surcharge_tax1": false,
|
||||
"custom_surcharge_tax2": false,
|
||||
"custom_surcharge_tax3": false,
|
||||
"custom_surcharge_tax4": false,
|
||||
"custom_value1": "",
|
||||
"custom_value2": "",
|
||||
"custom_value3": "",
|
||||
"custom_value4": "",
|
||||
"date": "",
|
||||
"design_id": "",
|
||||
"discount": 0,
|
||||
"documents": [],
|
||||
"due_date": "",
|
||||
"e_invoice": {},
|
||||
"entity_type": "",
|
||||
"exchange_rate": 0,
|
||||
"footer": "",
|
||||
"has_expenses": false,
|
||||
"has_tasks": false,
|
||||
"id": "",
|
||||
"invitations": [
|
||||
{
|
||||
"archived_at": 0,
|
||||
"can_sign": false,
|
||||
"client_contact_id": "",
|
||||
"created_at": 0,
|
||||
"email_error": "",
|
||||
"email_status": "",
|
||||
"id": "",
|
||||
"key": "",
|
||||
"link": "",
|
||||
"message_id": "",
|
||||
"opened_date": "",
|
||||
"sent_date": "",
|
||||
"updated_at": 0,
|
||||
"viewed_date": ""
|
||||
}
|
||||
],
|
||||
"is_amount_discount": false,
|
||||
"is_deleted": false,
|
||||
"last_sent_date": "",
|
||||
"line_items": [
|
||||
{
|
||||
"cost": 0,
|
||||
"custom_value1": "",
|
||||
"custom_value2": "",
|
||||
"custom_value3": "",
|
||||
"custom_value4": "",
|
||||
"date": "",
|
||||
"discount": 0,
|
||||
"expense_id": "",
|
||||
"gross_line_total": 0,
|
||||
"income_account_id": "",
|
||||
"is_amount_discount": false,
|
||||
"line_total": 0,
|
||||
"net_cost": 0,
|
||||
"notes": "",
|
||||
"product_cost": 0,
|
||||
"product_key": "",
|
||||
"quantity": 0,
|
||||
"sort_id": "",
|
||||
"tags": "",
|
||||
"task_id": "",
|
||||
"tax_amount": 0,
|
||||
"tax_id": "",
|
||||
"tax_name1": "",
|
||||
"tax_name2": "",
|
||||
"tax_name3": "",
|
||||
"tax_rate1": 0,
|
||||
"tax_rate2": 0,
|
||||
"tax_rate3": 0,
|
||||
"type_id": "",
|
||||
"unit_code": ""
|
||||
}
|
||||
],
|
||||
"location_id": "",
|
||||
"next_send_date": "",
|
||||
"number": "",
|
||||
"paid_to_date": 0,
|
||||
"partial": 0,
|
||||
"partial_due_date": "",
|
||||
"po_number": "",
|
||||
"private_notes": "",
|
||||
"project_id": "",
|
||||
"public_notes": "",
|
||||
"recurring_id": "",
|
||||
"reminder1_sent": "",
|
||||
"reminder2_sent": "",
|
||||
"reminder3_sent": "",
|
||||
"reminder_last_sent": "",
|
||||
"status_id": "",
|
||||
"subscription_id": "",
|
||||
"sync": null,
|
||||
"tags": [],
|
||||
"tax_info": {},
|
||||
"tax_name1": "",
|
||||
"tax_name2": "",
|
||||
"tax_name3": "",
|
||||
"tax_rate1": 0,
|
||||
"tax_rate2": 0,
|
||||
"tax_rate3": 0,
|
||||
"terms": "",
|
||||
"total_taxes": 0,
|
||||
"updated_at": 0,
|
||||
"user_id": "",
|
||||
"uses_inclusive_taxes": false,
|
||||
"vendor_id": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
+27
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"address1": "",
|
||||
"address2": "",
|
||||
"archived_at": 0,
|
||||
"city": "",
|
||||
"client_id": "",
|
||||
"country_id": "",
|
||||
"created_at": 0,
|
||||
"custom_value1": "",
|
||||
"custom_value2": "",
|
||||
"custom_value3": "",
|
||||
"custom_value4": "",
|
||||
"id": "",
|
||||
"is_deleted": false,
|
||||
"is_shipping_location": false,
|
||||
"name": "",
|
||||
"postal_code": "",
|
||||
"state": "",
|
||||
"tax_data": {},
|
||||
"updated_at": 0,
|
||||
"user_id": "",
|
||||
"vendor_id": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
+13
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"archived_at": 0,
|
||||
"created_at": 0,
|
||||
"id": "",
|
||||
"is_deleted": false,
|
||||
"name": "",
|
||||
"num_days": 0,
|
||||
"updated_at": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
+52
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"amount": 0,
|
||||
"applied": 0,
|
||||
"archived_at": 0,
|
||||
"assigned_user_id": "",
|
||||
"client_contact_id": "",
|
||||
"client_id": "",
|
||||
"company_gateway_id": "",
|
||||
"created_at": 0,
|
||||
"currency_id": "",
|
||||
"custom_value1": "",
|
||||
"custom_value2": "",
|
||||
"custom_value3": "",
|
||||
"custom_value4": "",
|
||||
"date": "",
|
||||
"documents": [],
|
||||
"exchange_currency_id": "",
|
||||
"exchange_rate": 0,
|
||||
"gateway_type_id": "",
|
||||
"id": "",
|
||||
"invitation_id": "",
|
||||
"is_deleted": false,
|
||||
"is_manual": false,
|
||||
"number": "",
|
||||
"paymentables": [
|
||||
{
|
||||
"amount": 0,
|
||||
"archived_at": 0,
|
||||
"created_at": 0,
|
||||
"id": "",
|
||||
"invoice_id": "",
|
||||
"payment_id": "",
|
||||
"refunded": 0,
|
||||
"updated_at": 0
|
||||
}
|
||||
],
|
||||
"private_notes": "",
|
||||
"project_id": "",
|
||||
"refunded": 0,
|
||||
"status_id": "",
|
||||
"tags": [],
|
||||
"transaction_id": "",
|
||||
"transaction_reference": "",
|
||||
"type_id": "",
|
||||
"updated_at": 0,
|
||||
"user_id": "",
|
||||
"vendor_id": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
+37
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"archived_at": 0,
|
||||
"assigned_user_id": "",
|
||||
"cost": 0,
|
||||
"created_at": 0,
|
||||
"custom_value1": "",
|
||||
"custom_value2": "",
|
||||
"custom_value3": "",
|
||||
"custom_value4": "",
|
||||
"documents": [],
|
||||
"id": "",
|
||||
"in_stock_quantity": 0,
|
||||
"income_account_id": "",
|
||||
"is_deleted": false,
|
||||
"max_quantity": 0,
|
||||
"notes": "",
|
||||
"price": 0,
|
||||
"product_image": "",
|
||||
"product_key": "",
|
||||
"quantity": 0,
|
||||
"stock_notification": false,
|
||||
"stock_notification_threshold": 0,
|
||||
"tags": [],
|
||||
"tax_id": "",
|
||||
"tax_name1": "",
|
||||
"tax_name2": "",
|
||||
"tax_name3": "",
|
||||
"tax_rate1": 0,
|
||||
"tax_rate2": 0,
|
||||
"tax_rate3": 0,
|
||||
"updated_at": 0,
|
||||
"user_id": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
+30
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"archived_at": 0,
|
||||
"assigned_user_id": "",
|
||||
"budgeted_amount": 0,
|
||||
"budgeted_hours": 0,
|
||||
"client_id": "",
|
||||
"color": "",
|
||||
"created_at": 0,
|
||||
"current_hours": 0,
|
||||
"custom_value1": "",
|
||||
"custom_value2": "",
|
||||
"custom_value3": "",
|
||||
"custom_value4": "",
|
||||
"documents": [],
|
||||
"due_date": "",
|
||||
"id": "",
|
||||
"is_deleted": false,
|
||||
"name": "",
|
||||
"number": "",
|
||||
"private_notes": "",
|
||||
"public_notes": "",
|
||||
"tags": [],
|
||||
"task_rate": 0,
|
||||
"updated_at": 0,
|
||||
"user_id": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
+93
@@ -0,0 +1,93 @@
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"amount": 0,
|
||||
"archived_at": 0,
|
||||
"assigned_user_id": "",
|
||||
"balance": 0,
|
||||
"client_id": "",
|
||||
"created_at": 0,
|
||||
"currency_id": "",
|
||||
"custom_surcharge1": 0,
|
||||
"custom_surcharge2": 0,
|
||||
"custom_surcharge3": 0,
|
||||
"custom_surcharge4": 0,
|
||||
"custom_surcharge_tax1": false,
|
||||
"custom_surcharge_tax2": false,
|
||||
"custom_surcharge_tax3": false,
|
||||
"custom_surcharge_tax4": false,
|
||||
"custom_value1": "",
|
||||
"custom_value2": "",
|
||||
"custom_value3": "",
|
||||
"custom_value4": "",
|
||||
"date": "",
|
||||
"design_id": "",
|
||||
"discount": 0,
|
||||
"documents": [],
|
||||
"due_date": "",
|
||||
"e_invoice": {},
|
||||
"entity_type": "",
|
||||
"exchange_rate": 0,
|
||||
"expense_id": "",
|
||||
"footer": "",
|
||||
"has_expenses": false,
|
||||
"has_tasks": false,
|
||||
"id": "",
|
||||
"invitations": [
|
||||
{
|
||||
"archived_at": 0,
|
||||
"can_sign": false,
|
||||
"created_at": 0,
|
||||
"email_error": "",
|
||||
"email_status": "",
|
||||
"id": "",
|
||||
"key": "",
|
||||
"link": "",
|
||||
"message_id": "",
|
||||
"opened_date": "",
|
||||
"sent_date": "",
|
||||
"updated_at": 0,
|
||||
"vendor_contact_id": "",
|
||||
"viewed_date": ""
|
||||
}
|
||||
],
|
||||
"invoice_id": "",
|
||||
"is_amount_discount": false,
|
||||
"is_deleted": false,
|
||||
"last_sent_date": "",
|
||||
"line_items": [],
|
||||
"location_id": "",
|
||||
"next_send_date": "",
|
||||
"number": "",
|
||||
"paid_to_date": 0,
|
||||
"partial": 0,
|
||||
"partial_due_date": "",
|
||||
"po_number": "",
|
||||
"private_notes": "",
|
||||
"project_id": "",
|
||||
"public_notes": "",
|
||||
"quote_id": "",
|
||||
"reminder1_sent": "",
|
||||
"reminder2_sent": "",
|
||||
"reminder3_sent": "",
|
||||
"reminder_last_sent": "",
|
||||
"status_id": "",
|
||||
"subscription_id": "",
|
||||
"sync": null,
|
||||
"tags": [],
|
||||
"tax_info": {},
|
||||
"tax_name1": "",
|
||||
"tax_name2": "",
|
||||
"tax_name3": "",
|
||||
"tax_rate1": 0,
|
||||
"tax_rate2": 0,
|
||||
"tax_rate3": 0,
|
||||
"terms": "",
|
||||
"total_taxes": 0,
|
||||
"updated_at": 0,
|
||||
"user_id": "",
|
||||
"uses_inclusive_taxes": false,
|
||||
"vendor_id": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
+123
@@ -0,0 +1,123 @@
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"amount": 0,
|
||||
"archived_at": 0,
|
||||
"assigned_user_id": "",
|
||||
"balance": 0,
|
||||
"client_id": "",
|
||||
"created_at": 0,
|
||||
"custom_surcharge1": 0,
|
||||
"custom_surcharge2": 0,
|
||||
"custom_surcharge3": 0,
|
||||
"custom_surcharge4": 0,
|
||||
"custom_surcharge_tax1": false,
|
||||
"custom_surcharge_tax2": false,
|
||||
"custom_surcharge_tax3": false,
|
||||
"custom_surcharge_tax4": false,
|
||||
"custom_value1": "",
|
||||
"custom_value2": "",
|
||||
"custom_value3": "",
|
||||
"custom_value4": "",
|
||||
"date": "",
|
||||
"design_id": "",
|
||||
"discount": 0,
|
||||
"documents": [],
|
||||
"due_date": "",
|
||||
"e_invoice": {},
|
||||
"entity_type": "",
|
||||
"exchange_rate": 0,
|
||||
"footer": "",
|
||||
"has_expenses": false,
|
||||
"has_tasks": false,
|
||||
"id": "",
|
||||
"invitations": [
|
||||
{
|
||||
"archived_at": 0,
|
||||
"can_sign": false,
|
||||
"client_contact_id": "",
|
||||
"created_at": 0,
|
||||
"email_error": "",
|
||||
"email_status": "",
|
||||
"id": "",
|
||||
"key": "",
|
||||
"link": "",
|
||||
"message_id": "",
|
||||
"opened_date": "",
|
||||
"sent_date": "",
|
||||
"updated_at": 0,
|
||||
"viewed_date": ""
|
||||
}
|
||||
],
|
||||
"invoice_id": "",
|
||||
"is_amount_discount": false,
|
||||
"is_deleted": false,
|
||||
"last_sent_date": "",
|
||||
"line_items": [
|
||||
{
|
||||
"cost": 0,
|
||||
"custom_value1": "",
|
||||
"custom_value2": "",
|
||||
"custom_value3": "",
|
||||
"custom_value4": "",
|
||||
"date": "",
|
||||
"discount": 0,
|
||||
"expense_id": "",
|
||||
"gross_line_total": 0,
|
||||
"income_account_id": "",
|
||||
"is_amount_discount": false,
|
||||
"line_total": 0,
|
||||
"net_cost": 0,
|
||||
"notes": "",
|
||||
"product_cost": 0,
|
||||
"product_key": "",
|
||||
"quantity": 0,
|
||||
"sort_id": "",
|
||||
"tags": "",
|
||||
"task_id": "",
|
||||
"tax_amount": 0,
|
||||
"tax_id": "",
|
||||
"tax_name1": "",
|
||||
"tax_name2": "",
|
||||
"tax_name3": "",
|
||||
"tax_rate1": 0,
|
||||
"tax_rate2": 0,
|
||||
"tax_rate3": 0,
|
||||
"type_id": "",
|
||||
"unit_code": ""
|
||||
}
|
||||
],
|
||||
"location_id": "",
|
||||
"next_send_date": "",
|
||||
"number": "",
|
||||
"paid_to_date": 0,
|
||||
"partial": 0,
|
||||
"partial_due_date": "",
|
||||
"po_number": "",
|
||||
"private_notes": "",
|
||||
"project_id": "",
|
||||
"public_notes": "",
|
||||
"reminder1_sent": "",
|
||||
"reminder2_sent": "",
|
||||
"reminder3_sent": "",
|
||||
"reminder_last_sent": "",
|
||||
"status_id": "",
|
||||
"subscription_id": "",
|
||||
"sync": null,
|
||||
"tags": [],
|
||||
"tax_info": {},
|
||||
"tax_name1": "",
|
||||
"tax_name2": "",
|
||||
"tax_name3": "",
|
||||
"tax_rate1": 0,
|
||||
"tax_rate2": 0,
|
||||
"tax_rate3": 0,
|
||||
"terms": "",
|
||||
"total_taxes": 0,
|
||||
"updated_at": 0,
|
||||
"user_id": "",
|
||||
"uses_inclusive_taxes": false,
|
||||
"vendor_id": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"amount": 0,
|
||||
"archived_at": 0,
|
||||
"assigned_user_id": "",
|
||||
"bank_id": "",
|
||||
"calculate_tax_by_amount": false,
|
||||
"category_id": "",
|
||||
"client_id": "",
|
||||
"created_at": 0,
|
||||
"currency_id": "",
|
||||
"custom_value1": "",
|
||||
"custom_value2": "",
|
||||
"custom_value3": "",
|
||||
"custom_value4": "",
|
||||
"date": "",
|
||||
"documents": [],
|
||||
"entity_type": "",
|
||||
"exchange_rate": 0,
|
||||
"foreign_amount": 0,
|
||||
"frequency_id": "",
|
||||
"id": "",
|
||||
"invoice_currency_id": "",
|
||||
"invoice_documents": false,
|
||||
"invoice_id": "",
|
||||
"is_deleted": false,
|
||||
"last_sent_date": "",
|
||||
"next_send_date": "",
|
||||
"number": "",
|
||||
"payment_date": "",
|
||||
"payment_type_id": "",
|
||||
"private_notes": "",
|
||||
"project_id": "",
|
||||
"public_notes": "",
|
||||
"recurring_dates": [],
|
||||
"recurring_expense_currency_id": "",
|
||||
"remaining_cycles": 0,
|
||||
"should_be_invoiced": false,
|
||||
"status_id": "",
|
||||
"tags": [],
|
||||
"tax_amount1": 0,
|
||||
"tax_amount2": 0,
|
||||
"tax_amount3": 0,
|
||||
"tax_name1": "",
|
||||
"tax_name2": "",
|
||||
"tax_name3": "",
|
||||
"tax_rate1": 0,
|
||||
"tax_rate2": 0,
|
||||
"tax_rate3": 0,
|
||||
"transaction_id": "",
|
||||
"transaction_reference": "",
|
||||
"updated_at": 0,
|
||||
"user_id": "",
|
||||
"uses_inclusive_taxes": false,
|
||||
"vendor_id": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
+90
@@ -0,0 +1,90 @@
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"amount": 0,
|
||||
"archived_at": 0,
|
||||
"assigned_user_id": "",
|
||||
"auto_bill": "",
|
||||
"auto_bill_enabled": false,
|
||||
"balance": 0,
|
||||
"client_id": "",
|
||||
"created_at": 0,
|
||||
"custom_surcharge1": 0,
|
||||
"custom_surcharge2": 0,
|
||||
"custom_surcharge3": 0,
|
||||
"custom_surcharge4": 0,
|
||||
"custom_surcharge_tax1": false,
|
||||
"custom_surcharge_tax2": false,
|
||||
"custom_surcharge_tax3": false,
|
||||
"custom_surcharge_tax4": false,
|
||||
"custom_value1": "",
|
||||
"custom_value2": "",
|
||||
"custom_value3": "",
|
||||
"custom_value4": "",
|
||||
"date": "",
|
||||
"design_id": "",
|
||||
"discount": 0,
|
||||
"documents": [],
|
||||
"due_date": "",
|
||||
"due_date_days": "",
|
||||
"e_invoice": {},
|
||||
"entity_type": "",
|
||||
"exchange_rate": 0,
|
||||
"footer": "",
|
||||
"frequency_id": "",
|
||||
"has_expenses": false,
|
||||
"has_tasks": false,
|
||||
"id": "",
|
||||
"invitations": [
|
||||
{
|
||||
"archived_at": 0,
|
||||
"can_sign": false,
|
||||
"client_contact_id": "",
|
||||
"created_at": 0,
|
||||
"email_error": "",
|
||||
"email_status": "",
|
||||
"id": "",
|
||||
"key": "",
|
||||
"link": "",
|
||||
"message_id": "",
|
||||
"opened_date": "",
|
||||
"sent_date": "",
|
||||
"updated_at": 0,
|
||||
"viewed_date": ""
|
||||
}
|
||||
],
|
||||
"is_amount_discount": false,
|
||||
"is_deleted": false,
|
||||
"last_sent_date": "",
|
||||
"line_items": [],
|
||||
"location_id": "",
|
||||
"next_send_date": "",
|
||||
"next_send_datetime": "",
|
||||
"number": "",
|
||||
"paid_to_date": 0,
|
||||
"partial": 0,
|
||||
"partial_due_date": "",
|
||||
"po_number": "",
|
||||
"private_notes": "",
|
||||
"project_id": "",
|
||||
"public_notes": "",
|
||||
"recurring_dates": [],
|
||||
"remaining_cycles": 0,
|
||||
"status_id": "",
|
||||
"subscription_id": "",
|
||||
"tags": [],
|
||||
"tax_name1": "",
|
||||
"tax_name2": "",
|
||||
"tax_name3": "",
|
||||
"tax_rate1": 0,
|
||||
"tax_rate2": 0,
|
||||
"tax_rate3": 0,
|
||||
"terms": "",
|
||||
"total_taxes": 0,
|
||||
"updated_at": 0,
|
||||
"user_id": "",
|
||||
"uses_inclusive_taxes": false,
|
||||
"vendor_id": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
+185
@@ -0,0 +1,185 @@
|
||||
{
|
||||
"bulk_updates": {
|
||||
"client": [
|
||||
""
|
||||
],
|
||||
"expense": [
|
||||
""
|
||||
],
|
||||
"recurring_invoice": [
|
||||
""
|
||||
],
|
||||
"task": [
|
||||
""
|
||||
]
|
||||
},
|
||||
"countries": [
|
||||
{
|
||||
"capital": "",
|
||||
"citizenship": "",
|
||||
"country_code": "",
|
||||
"currency": "",
|
||||
"currency_code": "",
|
||||
"currency_sub_unit": "",
|
||||
"decimal_separator": "",
|
||||
"eea": false,
|
||||
"full_name": "",
|
||||
"id": "",
|
||||
"iso_3166_2": "",
|
||||
"iso_3166_3": "",
|
||||
"name": "",
|
||||
"region_code": "",
|
||||
"sub_region_code": "",
|
||||
"swap_currency_symbol": false,
|
||||
"swap_postal_code": false,
|
||||
"thousand_separator": ""
|
||||
}
|
||||
],
|
||||
"currencies": [
|
||||
{
|
||||
"code": "",
|
||||
"decimal_separator": "",
|
||||
"exchange_rate": 0,
|
||||
"id": "",
|
||||
"name": "",
|
||||
"precision": 0,
|
||||
"swap_currency_symbol": false,
|
||||
"symbol": "",
|
||||
"thousand_separator": ""
|
||||
}
|
||||
],
|
||||
"date_formats": [
|
||||
{
|
||||
"format": "",
|
||||
"format_dart": "",
|
||||
"format_moment": "",
|
||||
"id": ""
|
||||
}
|
||||
],
|
||||
"datetime_formats": [
|
||||
{
|
||||
"format": "",
|
||||
"format_dart": "",
|
||||
"format_moment": "",
|
||||
"id": ""
|
||||
}
|
||||
],
|
||||
"gateways": [
|
||||
{
|
||||
"created_at": 0,
|
||||
"default_gateway_type_id": "",
|
||||
"fields": "",
|
||||
"id": "",
|
||||
"is_offsite": false,
|
||||
"is_secure": false,
|
||||
"key": "",
|
||||
"name": "",
|
||||
"options": {
|
||||
"1": {
|
||||
"refund": false,
|
||||
"token_billing": false,
|
||||
"webhooks": [
|
||||
""
|
||||
]
|
||||
},
|
||||
"2": {
|
||||
"refund": false,
|
||||
"token_billing": false,
|
||||
"webhooks": [
|
||||
""
|
||||
]
|
||||
}
|
||||
},
|
||||
"provider": "",
|
||||
"site_url": "",
|
||||
"sort_order": 0,
|
||||
"updated_at": 0,
|
||||
"visible": false
|
||||
}
|
||||
],
|
||||
"industries": [
|
||||
{
|
||||
"id": "",
|
||||
"name": ""
|
||||
}
|
||||
],
|
||||
"languages": [
|
||||
{
|
||||
"id": "",
|
||||
"locale": "",
|
||||
"name": ""
|
||||
}
|
||||
],
|
||||
"license_key": false,
|
||||
"payment_types": [
|
||||
{
|
||||
"gateway_type_id": 0,
|
||||
"id": "",
|
||||
"name": ""
|
||||
}
|
||||
],
|
||||
"sizes": [
|
||||
{
|
||||
"id": "",
|
||||
"name": ""
|
||||
}
|
||||
],
|
||||
"templates": {
|
||||
"invoice": {
|
||||
"body": "",
|
||||
"subject": ""
|
||||
},
|
||||
"partial_payment": {
|
||||
"body": "",
|
||||
"subject": ""
|
||||
},
|
||||
"payment": {
|
||||
"body": "",
|
||||
"subject": ""
|
||||
},
|
||||
"payment_failed": {
|
||||
"body": "",
|
||||
"subject": ""
|
||||
},
|
||||
"purchase_order": {
|
||||
"body": "",
|
||||
"subject": ""
|
||||
},
|
||||
"quote": {
|
||||
"body": "",
|
||||
"subject": ""
|
||||
},
|
||||
"quote_reminder1": {
|
||||
"body": "",
|
||||
"subject": ""
|
||||
},
|
||||
"reminder1": {
|
||||
"body": "",
|
||||
"subject": ""
|
||||
},
|
||||
"reminder2": {
|
||||
"body": "",
|
||||
"subject": ""
|
||||
},
|
||||
"reminder3": {
|
||||
"body": "",
|
||||
"subject": ""
|
||||
},
|
||||
"reminder_endless": {
|
||||
"body": "",
|
||||
"subject": ""
|
||||
},
|
||||
"statement": {
|
||||
"body": "",
|
||||
"subject": ""
|
||||
}
|
||||
},
|
||||
"timezones": [
|
||||
{
|
||||
"id": "",
|
||||
"location": "",
|
||||
"name": "",
|
||||
"utc_offset": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"App\\Livewire\\BillingPortal\\Authentication\\Login": {
|
||||
"dependencies": [],
|
||||
"id": ""
|
||||
},
|
||||
"App\\Livewire\\BillingPortal\\Authentication\\Register": {
|
||||
"dependencies": [],
|
||||
"id": ""
|
||||
},
|
||||
"App\\Livewire\\BillingPortal\\Authentication\\RegisterOrLogin": {
|
||||
"dependencies": [],
|
||||
"id": ""
|
||||
},
|
||||
"App\\Livewire\\BillingPortal\\Cart\\Cart": {
|
||||
"dependencies": [],
|
||||
"id": ""
|
||||
}
|
||||
}
|
||||
Vendored
+16
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"category_id": 0,
|
||||
"client_id": "",
|
||||
"company_id": "",
|
||||
"created_at": 0,
|
||||
"event_id": 0,
|
||||
"id": "",
|
||||
"log": "",
|
||||
"type_id": 0,
|
||||
"updated_at": 0,
|
||||
"user_id": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
+14
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"archived_at": 0,
|
||||
"color": null,
|
||||
"created_at": 0,
|
||||
"entity_type": "",
|
||||
"id": "",
|
||||
"is_deleted": false,
|
||||
"name": "",
|
||||
"updated_at": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
+14
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"archived_at": 0,
|
||||
"color": "",
|
||||
"created_at": 0,
|
||||
"id": "",
|
||||
"is_deleted": false,
|
||||
"name": "",
|
||||
"status_order": 0,
|
||||
"updated_at": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
+36
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"archived_at": 0,
|
||||
"assigned_user_id": "",
|
||||
"client_id": "",
|
||||
"created_at": 0,
|
||||
"custom_value1": "",
|
||||
"custom_value2": "",
|
||||
"custom_value3": "",
|
||||
"custom_value4": "",
|
||||
"date": "",
|
||||
"description": "",
|
||||
"documents": [],
|
||||
"due_date": "",
|
||||
"duration": 0,
|
||||
"estimated_duration": null,
|
||||
"id": "",
|
||||
"invoice_id": "",
|
||||
"is_date_based": false,
|
||||
"is_deleted": false,
|
||||
"is_running": false,
|
||||
"meta": {},
|
||||
"number": "",
|
||||
"project_id": "",
|
||||
"rate": 0,
|
||||
"status_id": "",
|
||||
"status_order": null,
|
||||
"status_sort_order": 0,
|
||||
"tags": [],
|
||||
"time_log": "",
|
||||
"updated_at": 0,
|
||||
"user_id": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
+13
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"archived_at": 0,
|
||||
"created_at": 0,
|
||||
"id": "",
|
||||
"is_deleted": false,
|
||||
"name": "",
|
||||
"rate": 0,
|
||||
"updated_at": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"archived_at": 0,
|
||||
"created_at": 0,
|
||||
"id": "",
|
||||
"is_deleted": false,
|
||||
"is_system": false,
|
||||
"name": "",
|
||||
"token": "",
|
||||
"updated_at": 0,
|
||||
"user_id": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
+44
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"archived_at": 0,
|
||||
"created_at": 0,
|
||||
"custom_value1": "",
|
||||
"custom_value2": "",
|
||||
"custom_value3": "",
|
||||
"custom_value4": "",
|
||||
"email": "",
|
||||
"email_verified_at": 0,
|
||||
"first_name": "",
|
||||
"google_2fa_secret": false,
|
||||
"has_password": false,
|
||||
"id": "",
|
||||
"is_deleted": false,
|
||||
"language_id": "",
|
||||
"last_confirmed_email_address": "",
|
||||
"last_login": 0,
|
||||
"last_name": "",
|
||||
"oauth_provider_id": "",
|
||||
"oauth_user_token": "",
|
||||
"passkey_count": 0,
|
||||
"passkey_enabled": false,
|
||||
"phone": "",
|
||||
"referral_code": "",
|
||||
"referral_meta": {
|
||||
"enterprise": 0,
|
||||
"free": 0,
|
||||
"pro": 0
|
||||
},
|
||||
"settings": {
|
||||
"calendar_connection": {
|
||||
"email": "",
|
||||
"status": ""
|
||||
}
|
||||
},
|
||||
"signature": "",
|
||||
"updated_at": 0,
|
||||
"user_logged_in_notification": false,
|
||||
"verified_phone_number": false
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
+63
@@ -0,0 +1,63 @@
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"address1": "",
|
||||
"address2": "",
|
||||
"archived_at": 0,
|
||||
"assigned_user_id": "",
|
||||
"city": "",
|
||||
"classification": "",
|
||||
"contacts": [
|
||||
{
|
||||
"archived_at": 0,
|
||||
"can_sign": false,
|
||||
"cc_only": false,
|
||||
"created_at": 0,
|
||||
"custom_value1": "",
|
||||
"custom_value2": "",
|
||||
"custom_value3": "",
|
||||
"custom_value4": "",
|
||||
"email": "",
|
||||
"first_name": "",
|
||||
"id": "",
|
||||
"is_primary": false,
|
||||
"last_login": 0,
|
||||
"last_name": "",
|
||||
"link": "",
|
||||
"password": "",
|
||||
"phone": "",
|
||||
"send_email": false,
|
||||
"updated_at": 0
|
||||
}
|
||||
],
|
||||
"country_id": "",
|
||||
"created_at": 0,
|
||||
"currency_id": "",
|
||||
"custom_value1": "",
|
||||
"custom_value2": "",
|
||||
"custom_value3": "",
|
||||
"custom_value4": "",
|
||||
"display_name": "",
|
||||
"documents": [],
|
||||
"id": "",
|
||||
"id_number": "",
|
||||
"is_deleted": false,
|
||||
"is_tax_exempt": false,
|
||||
"language_id": "",
|
||||
"last_login": 0,
|
||||
"name": "",
|
||||
"number": "",
|
||||
"phone": "",
|
||||
"postal_code": "",
|
||||
"private_notes": "",
|
||||
"public_notes": "",
|
||||
"routing_id": "",
|
||||
"state": "",
|
||||
"tags": [],
|
||||
"updated_at": 0,
|
||||
"user_id": "",
|
||||
"vat_number": "",
|
||||
"website": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
+18
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"archived_at": 0,
|
||||
"company_id": "",
|
||||
"created_at": 0,
|
||||
"event_id": "",
|
||||
"format": "",
|
||||
"headers": [],
|
||||
"id": "",
|
||||
"is_deleted": false,
|
||||
"rest_method": "",
|
||||
"target_url": "",
|
||||
"updated_at": 0,
|
||||
"user_id": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
+51
-11
@@ -1,10 +1,10 @@
|
||||
use invoiceninjars::models::{
|
||||
use invoiceninja_sdk::models::{
|
||||
BankIntegration, BankTransaction, BankTransactionRule, BulkRequest, Client, ClientGatewayToken,
|
||||
Company, CompanyGateway, CompanyToken, Credit, Design, Document, Expense, ExpenseCategory,
|
||||
GroupSetting, HealthCheck, Invoice, ListParams, ListResponse, Location, PaymentTerm, Project,
|
||||
PurchaseOrder, Quote, RecurringExpense, RecurringInvoice, RecurringQuote, SearchResults,
|
||||
Statics, Subscription, SystemLog, Tag, Task, TaskScheduler, TaskStatus, TaxRate, User, Vendor,
|
||||
Webhook,
|
||||
GroupSetting, HealthCheck, Invoice, ListParams, ListResponse, Location, LoginResponse, Passkey,
|
||||
PaymentTerm, Project, PurchaseOrder, Quote, RecurringExpense, RecurringInvoice, RecurringQuote,
|
||||
SearchResults, Statics, Subscription, SystemLog, Tag, Task, TaskScheduler, TaskStatus, TaxRate,
|
||||
User, Vendor, Webhook,
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
@@ -584,23 +584,24 @@ fn company_keeps_settings_untyped_and_preserves_toggles() {
|
||||
assert_eq!(round_tripped["settings"]["timezone_id"], "15");
|
||||
}
|
||||
|
||||
/// System logs are the one resource whose timestamps are date strings
|
||||
/// rather than integers.
|
||||
/// The spec says system-log timestamps are date strings; a real server sends
|
||||
/// integers, as it does everywhere else.
|
||||
#[test]
|
||||
fn system_log_timestamps_are_strings() {
|
||||
fn system_log_timestamps_are_integers() {
|
||||
let log: SystemLog = serde_json::from_value(json!({
|
||||
"id": "sl1",
|
||||
"event_id": 30,
|
||||
"category_id": 1,
|
||||
"type_id": 300,
|
||||
"log": {"message": "gateway declined"},
|
||||
"created_at": "2026-10-01 12:00:00",
|
||||
"updated_at": "2026-10-01 12:00:00",
|
||||
"created_at": 1792869488,
|
||||
"updated_at": 1792869489,
|
||||
}))
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(log.event_id, 30);
|
||||
assert_eq!(log.created_at, "2026-10-01 12:00:00");
|
||||
assert_eq!(log.created_at, 1792869488);
|
||||
assert_eq!(log.updated_at, 1792869489);
|
||||
assert_eq!(log.log["message"], "gateway declined");
|
||||
}
|
||||
|
||||
@@ -762,3 +763,42 @@ fn statics_ids_accept_both_strings_and_numbers() {
|
||||
assert_eq!(statics.industries[0].id, "3");
|
||||
assert_eq!(statics.sizes[0].id, "", "a null id decodes as empty");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn login_response_types_the_token_and_preserves_the_rest() {
|
||||
let payload = json!({
|
||||
"data": [{
|
||||
"is_owner": true,
|
||||
"is_admin": true,
|
||||
"token": { "id": "t1", "token": "abc" },
|
||||
"company": { "id": "c1", "settings": {} },
|
||||
"user": { "id": "u1", "email": "me@example.com" }
|
||||
}]
|
||||
});
|
||||
|
||||
let login: LoginResponse = serde_json::from_value(payload).unwrap();
|
||||
assert!(login.data[0].is_owner);
|
||||
let token = login.data[0].token.as_ref().unwrap();
|
||||
assert_eq!(token.token, "abc");
|
||||
|
||||
let back = serde_json::to_value(&login).unwrap();
|
||||
assert_eq!(back["data"][0]["company"]["id"], "c1");
|
||||
assert_eq!(back["data"][0]["user"]["email"], "me@example.com");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn passkey_round_trip_preserves_last_used_at() {
|
||||
let used = json!({
|
||||
"id": "p1", "name": "Laptop", "created_at": "2026-01-01T00:00:00.000000Z",
|
||||
"last_used_at": 1700000000
|
||||
});
|
||||
let never_used = json!({
|
||||
"id": "p2", "name": "Phone", "created_at": "2026-02-01T00:00:00.000000Z",
|
||||
"last_used_at": null
|
||||
});
|
||||
|
||||
let used: Passkey = serde_json::from_value(used).unwrap();
|
||||
let never_used: Passkey = serde_json::from_value(never_used).unwrap();
|
||||
assert_eq!(used.last_used_at, Some(1700000000));
|
||||
assert_eq!(never_used.last_used_at, None);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
//! Decodes fixtures captured from a real server.
|
||||
//!
|
||||
//! `tests/fixtures/*.json` are written by `examples/capture_shapes.rs` and
|
||||
//! hold only key names and JSON types (strings are `""`, numbers `0`, bools
|
||||
//! `false`). Mock responses written from the upstream source have been wrong
|
||||
//! about the real thing before, so these pin the shapes a server actually
|
||||
//! sent. To refresh them, seed a disposable instance with
|
||||
//! `examples/seed_sample_data.rs` and re-run the capture.
|
||||
//!
|
||||
//! Three properties are checked: every fixture decodes into its model
|
||||
//! (goal 2), a decode followed by a re-serialise loses no key (goal 1), and no
|
||||
//! fixture contains a real value, so a careless capture cannot leak data.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use invoiceninja_sdk::models::*;
|
||||
use serde::{de::DeserializeOwned, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
fn dir() -> PathBuf {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures")
|
||||
}
|
||||
|
||||
fn load(name: &str) -> Value {
|
||||
let path = dir().join(format!("{name}.json"));
|
||||
let text = fs::read_to_string(&path).unwrap_or_else(|e| panic!("{}: {e}", path.display()));
|
||||
serde_json::from_str(&text).unwrap_or_else(|e| panic!("{}: {e}", path.display()))
|
||||
}
|
||||
|
||||
/// The captured row of a list fixture.
|
||||
fn row(name: &str) -> Value {
|
||||
load(name)["data"][0].clone()
|
||||
}
|
||||
|
||||
/// Key paths present in `raw` but absent from `out`. Arrays are compared
|
||||
/// through their first element, which is all a fixture keeps.
|
||||
fn lost_keys(raw: &Value, out: &Value, prefix: &str, into: &mut Vec<String>) {
|
||||
match (raw, out) {
|
||||
(Value::Object(raw), Value::Object(out)) => {
|
||||
for (key, raw_value) in raw {
|
||||
match out.get(key) {
|
||||
Some(out_value) => {
|
||||
lost_keys(raw_value, out_value, &format!("{prefix}.{key}"), into)
|
||||
}
|
||||
None => into.push(format!("{prefix}.{key}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
(Value::Array(raw), Value::Array(out)) => {
|
||||
if let (Some(raw), Some(out)) = (raw.first(), out.first()) {
|
||||
lost_keys(raw, out, &format!("{prefix}[]"), into);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Decodes `raw` as `T`, re-serialises it, and returns problems.
|
||||
fn check<T: DeserializeOwned + Serialize>(name: &str, raw: &Value) -> Vec<String> {
|
||||
let typed: T = match serde_json::from_value(raw.clone()) {
|
||||
Ok(typed) => typed,
|
||||
Err(e) => return vec![format!("{name}: does not decode: {e}")],
|
||||
};
|
||||
let out = serde_json::to_value(&typed).expect("serialises");
|
||||
let mut lost = Vec::new();
|
||||
lost_keys(raw, &out, name, &mut lost);
|
||||
lost.into_iter()
|
||||
.filter(|path| !OMITTED_WHEN_EMPTY.contains(&path.as_str()))
|
||||
.map(|path| format!("{path}: key lost on a decode/re-serialise"))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Fields that are deliberately left out of a request when empty, because the
|
||||
/// server rejects the empty value (`""` becomes `NULL` on a non-null column,
|
||||
/// or fails an `integer` rule). A fixture holds `""` placeholders, so these
|
||||
/// show up as absent after a re-serialise. Adding to this list is a decision.
|
||||
const OMITTED_WHEN_EMPTY: &[&str] = &[
|
||||
"locations.country_id",
|
||||
"purchase_orders.status_id",
|
||||
"webhooks.format",
|
||||
];
|
||||
|
||||
/// One fixture per `(file, type)`; the macro also records which files it
|
||||
/// covered so a new capture cannot sit untested.
|
||||
macro_rules! list_fixtures {
|
||||
($($file:literal => $ty:ty),+ $(,)?) => {
|
||||
const COVERED_LISTS: &[&str] = &[$($file),+];
|
||||
|
||||
fn check_lists() -> Vec<String> {
|
||||
let mut problems = Vec::new();
|
||||
$( problems.extend(check::<$ty>($file, &row($file))); )+
|
||||
problems
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
list_fixtures! {
|
||||
"activities" => Activity,
|
||||
"clients" => Client,
|
||||
"companies" => Company,
|
||||
"credits" => Credit,
|
||||
"designs" => Design,
|
||||
"documents" => Document,
|
||||
"expense_categories" => ExpenseCategory,
|
||||
"expenses" => Expense,
|
||||
"group_settings" => GroupSetting,
|
||||
"invoices" => Invoice,
|
||||
"locations" => Location,
|
||||
"payment_terms" => PaymentTerm,
|
||||
"payments" => Payment,
|
||||
"products" => Product,
|
||||
"projects" => Project,
|
||||
"purchase_orders" => PurchaseOrder,
|
||||
"quotes" => Quote,
|
||||
"recurring_expenses" => RecurringExpense,
|
||||
"recurring_invoices" => RecurringInvoice,
|
||||
"system_logs" => SystemLog,
|
||||
"tags" => Tag,
|
||||
"task_statuses" => TaskStatus,
|
||||
"tasks" => Task,
|
||||
"tax_rates" => TaxRate,
|
||||
"tokens" => CompanyToken,
|
||||
"users" => User,
|
||||
"vendors" => Vendor,
|
||||
"webhooks" => Webhook,
|
||||
}
|
||||
|
||||
/// Fixtures that are not a one-row list.
|
||||
const COVERED_OTHER: &[&str] = &[
|
||||
"statics",
|
||||
"health_check",
|
||||
"subscription_steps",
|
||||
"activity_feed",
|
||||
];
|
||||
|
||||
#[test]
|
||||
fn every_real_row_decodes_and_loses_no_key() {
|
||||
let problems = check_lists();
|
||||
assert!(
|
||||
problems.is_empty(),
|
||||
"{} problem(s):\n{}",
|
||||
problems.len(),
|
||||
problems.join("\n")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bare_endpoint_fixtures_decode() {
|
||||
let mut problems = Vec::new();
|
||||
problems.extend(check::<Statics>("statics", &load("statics")));
|
||||
problems.extend(check::<HealthCheck>("health_check", &load("health_check")));
|
||||
problems.extend(check::<ActivityFeedEntry>(
|
||||
"activity_feed",
|
||||
&load("activity_feed")["data"][0],
|
||||
));
|
||||
|
||||
// Keyed by class name on a real server; see `subscription_steps`.
|
||||
let steps: Result<BTreeMap<String, SubscriptionStep>, _> =
|
||||
serde_json::from_value(load("subscription_steps"));
|
||||
match steps {
|
||||
Ok(steps) => assert!(!steps.is_empty(), "the captured steps object is empty"),
|
||||
Err(e) => problems.push(format!("subscription_steps: {e}")),
|
||||
}
|
||||
|
||||
assert!(problems.is_empty(), "{}", problems.join("\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_fixture_file_is_covered_by_a_test() {
|
||||
let mut on_disk: Vec<String> = fs::read_dir(dir())
|
||||
.expect("tests/fixtures exists")
|
||||
.filter_map(|entry| {
|
||||
let path = entry.ok()?.path();
|
||||
(path.extension()? == "json").then(|| path.file_stem()?.to_str().map(String::from))?
|
||||
})
|
||||
.collect();
|
||||
on_disk.sort();
|
||||
|
||||
let mut covered: Vec<String> = COVERED_LISTS
|
||||
.iter()
|
||||
.chain(COVERED_OTHER)
|
||||
.map(|s| s.to_string())
|
||||
.collect();
|
||||
covered.sort();
|
||||
|
||||
assert_eq!(
|
||||
on_disk, covered,
|
||||
"a captured fixture has no test (or a test has no fixture)"
|
||||
);
|
||||
}
|
||||
|
||||
/// The capture tool replaces every value; this refuses to pass if any
|
||||
/// survived, so real data cannot be committed by accident.
|
||||
#[test]
|
||||
fn fixtures_hold_shapes_and_never_values() {
|
||||
fn walk(value: &Value, at: &str, bad: &mut Vec<String>) {
|
||||
match value {
|
||||
Value::String(s) if !s.is_empty() => bad.push(format!("{at}: string value")),
|
||||
Value::Number(n) if n.as_f64() != Some(0.0) => bad.push(format!("{at}: number value")),
|
||||
Value::Bool(true) => bad.push(format!("{at}: true")),
|
||||
Value::Array(items) => {
|
||||
assert!(items.len() <= 1, "{at}: a fixture keeps one array element");
|
||||
items.iter().for_each(|v| walk(v, &format!("{at}[]"), bad));
|
||||
}
|
||||
Value::Object(map) => map
|
||||
.iter()
|
||||
.for_each(|(k, v)| walk(v, &format!("{at}.{k}"), bad)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
let mut bad = Vec::new();
|
||||
for entry in fs::read_dir(dir()).expect("tests/fixtures exists") {
|
||||
let path = entry.unwrap().path();
|
||||
if path.extension().is_some_and(|e| e == "json") {
|
||||
let name = path.file_stem().unwrap().to_string_lossy().to_string();
|
||||
walk(&load(&name), &name, &mut bad);
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
bad.is_empty(),
|
||||
"fixtures contain values:\n{}",
|
||||
bad.join("\n")
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user