Compare commits
10
Commits
355cf08324
...
e7c9663621
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 }}
|
||||||
+59
-1
@@ -9,6 +9,40 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
|
- 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
|
- A declared minimum supported Rust version of **1.88**, verified by running
|
||||||
the test suite against it. The floor comes from the dependency graph
|
the test suite against it. The floor comes from the dependency graph
|
||||||
(`reqwest` → `url` → `idna` → `icu_*`), not from this crate's own code.
|
(`reqwest` → `url` → `idna` → `icu_*`), not from this crate's own code.
|
||||||
@@ -17,7 +51,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
|
release. The advisory job fails visibly rather than silently; it is kept
|
||||||
non-blocking through branch protection rather than `continue-on-error`.
|
non-blocking through branch protection rather than `continue-on-error`.
|
||||||
- Wrappers for every one of the API's 34 `Route::resource` groups, plus
|
- 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,
|
- `COVERAGE.md`, a detailed record of what is wrapped, what is not, and why,
|
||||||
checked against `routes/api.php` on the `v5-stable` branch.
|
checked against `routes/api.php` on the `v5-stable` branch.
|
||||||
- Retry with full-jitter exponential backoff, configurable through
|
- Retry with full-jitter exponential backoff, configurable through
|
||||||
@@ -58,6 +92,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.
|
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
|
- API errors with an empty or message-less body now report the status reason
|
||||||
("Not Found") instead of rendering blank.
|
("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
|
### Fixed
|
||||||
|
|
||||||
@@ -69,6 +107,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
|
- `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`
|
no solution. It now falls back to the default page size, and a `MAX_PAGES`
|
||||||
backstop raises `Error::PaginationLimit` rather than quietly truncating.
|
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
|
- Requests could hang indefinitely. `reqwest` applies no timeout of its own
|
||||||
and none was configured.
|
and none was configured.
|
||||||
- Ids were interpolated into request paths unescaped, so a `/`, `?`, or `#`
|
- Ids were interpolated into request paths unescaped, so a `/`, `?`, or `#`
|
||||||
|
|||||||
@@ -84,10 +84,90 @@ IN_API_TOKEN=... cargo run --example list_clients
|
|||||||
```
|
```
|
||||||
|
|
||||||
Tests live in `tests/` (integration-style, no network access required);
|
Tests live in `tests/` (integration-style, no network access required);
|
||||||
`src/` has no `#[test]` modules. Two files: `model_roundtrip.rs` covers the
|
`src/` has no `#[test]` modules. Four files:
|
||||||
serde layer, and `client_requests.rs` covers the request plumbing
|
|
||||||
(pagination, path escaping, headers, timeouts, error mapping) against a
|
- `model_roundtrip.rs` — the serde layer, from hand-written payloads.
|
||||||
`wiremock` server on loopback — no credentials or internet needed.
|
- `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.
|
||||||
|
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
|
## Changelog
|
||||||
|
|
||||||
@@ -161,6 +241,18 @@ changes, doc tweaks) does not need an entry.
|
|||||||
elsewhere without checking `routes/api.php`. In particular the official PHP
|
elsewhere without checking `routes/api.php`. In particular the official PHP
|
||||||
SDK puts bulk `download` on *every* resource, which is wrong; only
|
SDK puts bulk `download` on *every* resource, which is wrong; only
|
||||||
PDF-rendering entities support it.
|
PDF-rendering entities support it.
|
||||||
|
- `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,
|
- Non-CRUD endpoints (documents, reports, charts, search, statics, emails,
|
||||||
refunds, ping/health) are hand-written in the last `impl` block rather than
|
refunds, ping/health) are hand-written in the last `impl` block rather than
|
||||||
macro-generated, because their request and response shapes are all
|
macro-generated, because their request and response shapes are all
|
||||||
@@ -220,22 +312,28 @@ changes, doc tweaks) does not need an entry.
|
|||||||
decode, API error responses (with per-field validation `errors` map), and
|
decode, API error responses (with per-field validation `errors` map), and
|
||||||
client construction errors. `ApiErrorBody` mirrors Invoice Ninja's error
|
client construction errors. `ApiErrorBody` mirrors Invoice Ninja's error
|
||||||
JSON shape.
|
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
|
## Coverage
|
||||||
|
|
||||||
**All 34 of the API's `Route::resource` groups are wrapped**, plus `users`
|
**All 34 of the API's `Route::resource` groups are wrapped**, plus `users`
|
||||||
(which isn't one). 265 methods: 30 resources with CRUD + bulk, 3 with CRUD
|
(which isn't one). 325 methods: 30 resources with CRUD + bulk, 3 with CRUD
|
||||||
only, 4 with entity actions, and 25 hand-written.
|
only, 4 with entity actions, 16 with document uploads, and 69 hand-written.
|
||||||
|
|
||||||
Plus the non-CRUD surface: documents, entity actions and PDF downloads for
|
Plus the non-CRUD surface: documents, entity actions and PDF downloads for
|
||||||
the four action-capable resources, `report`, `chart`, `search`, `statics`,
|
the four action-capable resources, `report`, `chart`, `search`, `statics`,
|
||||||
`send_email`, `refund_payment`, `ping`, and `health_check`.
|
`send_email`, `refund_payment`, `ping`, and `health_check`.
|
||||||
|
|
||||||
**`COVERAGE.md` is the detailed inventory** — every wrapped endpoint, every
|
**`COVERAGE.md` is the detailed inventory** — every wrapped endpoint, every
|
||||||
gap, and the reason for each gap. The main remaining gaps are multipart
|
gap, and the reason for each gap. The main remaining gaps are the
|
||||||
uploads (needs a reqwest feature), a handful of per-resource extras
|
integration and auth routes, import/export, the public invitation-key
|
||||||
(`clients/{id}/purge`, `company_gateways/{id}/test`, …), and the
|
downloads (which take no auth token, so they don't fit the client's
|
||||||
integration/auth routes.
|
request path), and a few company and user extras.
|
||||||
|
|
||||||
Keep `COVERAGE.md` current when adding or removing an endpoint: it names
|
Keep `COVERAGE.md` current when adding or removing an endpoint: it names
|
||||||
exact counts and route groups, so it goes stale quietly. The three coverage
|
exact counts and route groups, so it goes stale quietly. The three coverage
|
||||||
|
|||||||
+156
-36
@@ -18,7 +18,7 @@ declares 34 `Route::resource` groups and 274 explicit route declarations
|
|||||||
> exists or what it returns.
|
> exists or what it returns.
|
||||||
|
|
||||||
**All 34 `Route::resource` groups are now wrapped**, plus `users` (which is
|
**All 34 `Route::resource` groups are now wrapped**, plus `users` (which is
|
||||||
not a resource group). 265 methods in total.
|
not a resource group). 325 methods in total.
|
||||||
|
|
||||||
## Resources with full CRUD + bulk
|
## Resources with full CRUD + bulk
|
||||||
|
|
||||||
@@ -58,6 +58,10 @@ not a resource group). 265 methods in total.
|
|||||||
| Task Schedulers | `/task_schedulers` | `TaskScheduler` |
|
| Task Schedulers | `/task_schedulers` | `TaskScheduler` |
|
||||||
| Tokens | `/tokens` | `CompanyToken` |
|
| 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
|
`bulk_*` accepts any action the endpoint supports; `archive`, `restore`, and
|
||||||
`delete` work on all of them. An `ids` list matching nothing returns an empty
|
`delete` work on all of them. An `ids` list matching nothing returns an empty
|
||||||
`Vec`, not an error.
|
`Vec`, not an error.
|
||||||
@@ -123,6 +127,54 @@ same reason: the response isn't JSON.
|
|||||||
|
|
||||||
Convenience wrappers: `mark_invoice_sent` and `mark_invoice_paid`.
|
Convenience wrappers: `mark_invoice_sent` and `mark_invoice_paid`.
|
||||||
|
|
||||||
|
## 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
|
## Other wrapped endpoints
|
||||||
|
|
||||||
| Endpoint | Method |
|
| Endpoint | Method |
|
||||||
@@ -164,16 +216,112 @@ the whole tail — `chart("project_burnup/<id>", &body)`.
|
|||||||
Large report exports are queued and emailed rather than returned inline; in
|
Large report exports are queued and emailed rather than returned inline; in
|
||||||
that case the body is a short JSON message instead of CSV.
|
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.
|
||||||
|
|
||||||
|
## 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
|
## Not covered
|
||||||
|
|
||||||
### Blocked on a dependency change
|
### Nothing is blocked on a dependency
|
||||||
|
|
||||||
**Multipart uploads** — `PUT /{resource}/{id}/upload` exists for clients,
|
Multipart uploads used to be, and are now wrapped — see [Document
|
||||||
companies, credits, expenses, group_settings, invoices, payments, products,
|
uploads](#document-uploads). One upload route is deliberately left out:
|
||||||
projects, purchase_orders, quotes, recurring_expenses, recurring_invoices,
|
`PUT /edocument/upload` is not a document attachment. It imports an expense
|
||||||
recurring_quotes, tasks, and vendors. These need reqwest's `multipart`
|
from an e-document (`ExpenseController::edocument`), and is better treated as
|
||||||
feature, which isn't currently enabled. This is the main reason `Document`
|
an import endpoint alongside the others under *Import/export* below.
|
||||||
has no `create_document`.
|
|
||||||
|
|
||||||
### Resource groups not wrapped
|
### Resource groups not wrapped
|
||||||
|
|
||||||
@@ -184,42 +332,14 @@ 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 —
|
`src/client.rs` (or `crud_methods!` if the resource has no `/bulk` route —
|
||||||
check `routes/api.php`).
|
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
|
### 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`,
|
- **Company extras** — `GET /companies/{id}/logo`,
|
||||||
`POST /companies/{id}/default`, `updateOriginTaxData`,
|
`POST /companies/{id}/default`, `updateOriginTaxData`,
|
||||||
`POST /companies/current`
|
`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`) —
|
- **User extras** (not password-protected, unlike the rest of `/users`) —
|
||||||
`POST /users/{id}/disconnect_mailer`, `POST /users/{id}/disconnect_oauth`,
|
`POST /users/{id}/disconnect_mailer`, `POST /users/{id}/disconnect_oauth`,
|
||||||
`POST /user/{id}/reconfirm`
|
`POST /user/{id}/reconfirm`
|
||||||
- **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`,
|
- **Auth** — `/login`, `/logout`, `/refresh`, `/connected_account`,
|
||||||
passkey routes
|
passkey routes
|
||||||
- **Import/export** — `/import`, `/preimport`, `/import_json`, `/export`,
|
- **Import/export** — `/import`, `/preimport`, `/import_json`, `/export`,
|
||||||
|
|||||||
Generated
+24
@@ -600,6 +600,22 @@ version = "2.8.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
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]]
|
[[package]]
|
||||||
name = "mio"
|
name = "mio"
|
||||||
version = "1.2.4"
|
version = "1.2.4"
|
||||||
@@ -776,6 +792,7 @@ dependencies = [
|
|||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"bytes",
|
"bytes",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
|
"futures-util",
|
||||||
"http",
|
"http",
|
||||||
"http-body",
|
"http-body",
|
||||||
"http-body-util",
|
"http-body-util",
|
||||||
@@ -784,6 +801,7 @@ dependencies = [
|
|||||||
"hyper-util",
|
"hyper-util",
|
||||||
"js-sys",
|
"js-sys",
|
||||||
"log",
|
"log",
|
||||||
|
"mime_guess",
|
||||||
"native-tls",
|
"native-tls",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
@@ -1163,6 +1181,12 @@ version = "0.2.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicase"
|
||||||
|
version = "2.10.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "357cc3acc6a036009fd6c973ed009037c732d60d0b4f6c673e9041497482a28f"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unicode-ident"
|
name = "unicode-ident"
|
||||||
version = "1.0.27"
|
version = "1.0.27"
|
||||||
|
|||||||
+1
-1
@@ -12,7 +12,7 @@ license = "MIT"
|
|||||||
repository = "https://github.com/awkawb1/invoiceninja-sdk"
|
repository = "https://github.com/awkawb1/invoiceninja-sdk"
|
||||||
|
|
||||||
[dependencies]
|
[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 = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
thiserror = "2"
|
thiserror = "2"
|
||||||
|
|||||||
@@ -141,27 +141,44 @@ 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
|
`COVERAGE.md`'s gap list is a to-do list, not a permanent boundary. Roughly
|
||||||
in order of value over effort:
|
in order of value over effort:
|
||||||
|
|
||||||
1. **Multipart uploads** — 16 `PUT /{resource}/{id}/upload` routes plus
|
**Done:** multipart document uploads, for the 16 resources that have the
|
||||||
`edocument/upload`. Blocked only on enabling reqwest's `multipart`
|
route (`upload_client`, `upload_invoice`, …). This earlier claimed it would
|
||||||
feature. This is also what makes `create_document` possible, so it
|
also make `create_document` possible, which was wrong: upstream
|
||||||
unblocks the one obviously missing piece of an otherwise complete
|
`DocumentController::store` is an empty stub, and an upload is the *only*
|
||||||
resource.
|
way a document is created. There is no `create_document` and there will not
|
||||||
2. **Per-resource extras** — mechanical, and they reuse existing patterns:
|
be one. `edocument/upload` was left out because it imports an expense from
|
||||||
`webhooks/{id}/retry`, `tasks/sort`, `designs/set/default`,
|
an e-document rather than attaching a file, so it belongs with item 6.
|
||||||
`client_gateway_tokens/{id}/setAsDefault`, `subscriptions/steps`, the
|
|
||||||
client extras (`updateTaxData`, `show_settings`, `check_vat`, …), the
|
**Done:** the per-resource extras — webhook retry, task sort, default
|
||||||
bank-integration and company-gateway extras.
|
design, default gateway token, subscription steps, and the client,
|
||||||
3. **Password-protected actions** — `with_api_password` already makes these
|
bank-integration, and company-gateway routes (19 methods). The numbering
|
||||||
reachable; only the methods are missing. Client purge and merge, vendor
|
below keeps its original item numbers. Running the smoke test against a real
|
||||||
merge, company purge, user invite and purge.
|
server overturned one reading of the source here: `/subscriptions/steps`
|
||||||
4. **Preview, activities, and email history** — distinct response shapes, so
|
answers with an object keyed by class name, not the array the controller
|
||||||
each needs its own hand-written method and probably its own model.
|
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). They are verified against the upstream controllers and mocks only;
|
||||||
|
the smoke test has a password tier for the first real-server run
|
||||||
|
(`IN_API_PASSWORD`), which has not happened yet. 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`).
|
||||||
|
|
||||||
|
Still to do:
|
||||||
|
|
||||||
5. **Public invitation-key downloads** — eight routes that take no auth
|
5. **Public invitation-key downloads** — eight routes that take no auth
|
||||||
token, so they do not fit the authenticated request path and need a
|
token, so they do not fit the authenticated request path and need a
|
||||||
different entry point.
|
different entry point.
|
||||||
6. **Import/export and the integrations** — Stripe, QuickBooks, einvoice
|
6. **Import/export and the integrations** — `/import`, `/export`,
|
||||||
(17 routes), Yodlee, Nordigen, calendar. The largest block and the least
|
`edocument/upload`, Stripe, QuickBooks, einvoice (17 routes), Yodlee,
|
||||||
SDK-shaped; worth doing last and possibly in pieces.
|
Nordigen, calendar. The largest block and the least SDK-shaped; worth
|
||||||
|
doing last and possibly in pieces.
|
||||||
|
|
||||||
Keep `COVERAGE.md` current as these land — it names exact counts, so it goes
|
Keep `COVERAGE.md` current as these land — it names exact counts, so it goes
|
||||||
stale quietly.
|
stale quietly.
|
||||||
@@ -178,9 +195,12 @@ These are genuinely undecided. Deciding one is a change to this file.
|
|||||||
eleven chart endpoints share no response shape. Per-chart structs would
|
eleven chart endpoints share no response shape. Per-chart structs would
|
||||||
be eleven small models of unstable payloads; unclear whether that earns
|
be eleven small models of unstable payloads; unclear whether that earns
|
||||||
its keep.
|
its keep.
|
||||||
- **Large responses are buffered entirely into memory.** PDF downloads and
|
- **Large bodies are buffered entirely into memory.** PDF downloads and CSV
|
||||||
CSV report exports go through `Vec<u8>`. Streaming would be better for
|
report exports come back as `Vec<u8>`, and an uploaded `FileUpload` holds
|
||||||
big exports but changes those signatures.
|
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
|
- **`Error::PaginationLimit` has no test**, because reaching it needs
|
||||||
10,001 mock round trips. Making `MAX_PAGES` injectable purely for a test
|
10,001 mock round trips. Making `MAX_PAGES` injectable purely for a test
|
||||||
may be worse than the gap.
|
may be worse than the gap.
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ IN_API_TOKEN=... cargo run --example list_clients
|
|||||||
|
|
||||||
## Coverage
|
## Coverage
|
||||||
|
|
||||||
Every one of the API's 34 resource groups, plus users — 265 methods. Each
|
Every one of the API's 34 resource groups, plus users — 325 methods. Each
|
||||||
resource has `list`, `list_all`, `get`, `create`, `update`, `delete`, and
|
resource has `list`, `list_all`, `get`, `create`, `update`, `delete`, and
|
||||||
(where the API offers one) `bulk`:
|
(where the API offers one) `bulk`:
|
||||||
|
|
||||||
@@ -158,15 +158,33 @@ let pdf: Vec<u8> = client.download_invoice("abc123").await?;
|
|||||||
let merged: Vec<u8> = client.download_invoices(&["abc123", "def456"]).await?;
|
let merged: Vec<u8> = client.download_invoices(&["abc123", "def456"]).await?;
|
||||||
```
|
```
|
||||||
|
|
||||||
Reports return their raw export body (CSV for most of them), and the
|
Documents are attached through the record they belong to, since that is the
|
||||||
schemaless endpoints decode into any type you choose:
|
only way the API creates one. Sixteen resources support it, as
|
||||||
|
`upload_client`, `upload_invoice`, `upload_expense`, and so on:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use invoiceninjars::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
|
```rust
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
let csv: Vec<u8> = client.report("invoices", &json!({"date_range": "this_year"})).await?;
|
let csv: Vec<u8> = client.report("invoices", &json!({"date_range": "this_year"})).await?;
|
||||||
let totals: Value = client.chart("totals_v2", &json!({})).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
|
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");
|
||||||
|
}
|
||||||
@@ -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 invoiceninjars::{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 = invoiceninjars::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,861 @@
|
|||||||
|
//! 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 invoiceninjars::models::{
|
||||||
|
Client, ListParams, Location, PurchaseOrder, RecurringInvoice, Vendor, Webhook,
|
||||||
|
};
|
||||||
|
use invoiceninjars::{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)),
|
||||||
|
}
|
||||||
|
|
||||||
|
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)),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -37,7 +37,10 @@
|
|||||||
msrvToolchain = toolchainFor msrvVersion [ ];
|
msrvToolchain = toolchainFor msrvVersion [ ];
|
||||||
|
|
||||||
shellFor = toolchain: pkgs.mkShell {
|
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";
|
RUST_SRC_PATH = "${toolchain}/lib/rustlib/src/rust/library";
|
||||||
};
|
};
|
||||||
in
|
in
|
||||||
|
|||||||
+607
-17
@@ -1,19 +1,21 @@
|
|||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use percent_encoding::{utf8_percent_encode, AsciiSet, NON_ALPHANUMERIC};
|
use percent_encoding::{utf8_percent_encode, AsciiSet, NON_ALPHANUMERIC};
|
||||||
use reqwest::{header, Method, StatusCode};
|
use reqwest::{header, multipart, Method, StatusCode};
|
||||||
use serde::{de::DeserializeOwned, Serialize};
|
use serde::{de::DeserializeOwned, Serialize};
|
||||||
|
|
||||||
use crate::error::{ApiErrorBody, Error, Result};
|
use crate::error::{ApiErrorBody, Error, Result};
|
||||||
use crate::models::{
|
use crate::models::{
|
||||||
BankIntegration, BankTransaction, BankTransactionRule, BulkRequest, Client as ClientModel,
|
Activity, ActivityFeedEntry, BankIntegration, BankTransaction, BankTransactionRule,
|
||||||
ClientGatewayToken, Company, CompanyGateway, CompanyToken, Credit, Design, Document, Expense,
|
BulkRequest, Client as ClientModel, ClientGatewayToken, Company, CompanyGateway, CompanyToken,
|
||||||
ExpenseCategory, GroupSetting, HealthCheck, Invoice, ItemResponse, ListParams, ListResponse,
|
Credit, Design, Document, Expense, ExpenseCategory, GroupSetting, HealthCheck, Invoice,
|
||||||
Location, Payment, PaymentTerm, Product, Project, PurchaseOrder, Quote, RecurringExpense,
|
ItemResponse, ListParams, ListResponse, Location, MessageResponse, Payment, PaymentTerm,
|
||||||
RecurringInvoice, RecurringQuote, SearchResults, Statics, Subscription, SystemLog, Tag, Task,
|
Product, Project, PurchaseOrder, Quote, RecurringExpense, RecurringInvoice, RecurringQuote,
|
||||||
TaskScheduler, TaskStatus, TaxRate, User, Vendor, Webhook,
|
SearchResults, Statics, Subscription, SubscriptionStep, SystemLog, Tag, Task, TaskScheduler,
|
||||||
|
TaskStatus, TaxRate, User, Vendor, Webhook,
|
||||||
};
|
};
|
||||||
use crate::retry::{self, RetryPolicy};
|
use crate::retry::{self, RetryPolicy};
|
||||||
|
use crate::upload::FileUpload;
|
||||||
|
|
||||||
/// API root of the hosted Invoice Ninja app.
|
/// API root of the hosted Invoice Ninja app.
|
||||||
pub const HOSTED_BASE_URL: &str = "https://invoicing.co/api/v1";
|
pub const HOSTED_BASE_URL: &str = "https://invoicing.co/api/v1";
|
||||||
@@ -60,6 +62,28 @@ fn seg(value: &str) -> Result<impl std::fmt::Display + '_> {
|
|||||||
Ok(utf8_percent_encode(value, PATH_SEGMENT))
|
Ok(utf8_percent_encode(value, PATH_SEGMENT))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Standard base64 with padding. Only used for the one header that needs it,
|
||||||
|
/// which is not worth a dependency.
|
||||||
|
fn base64(bytes: &[u8]) -> String {
|
||||||
|
const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||||
|
|
||||||
|
let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4);
|
||||||
|
for chunk in bytes.chunks(3) {
|
||||||
|
let n = chunk
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.fold(0u32, |n, (i, b)| n | (*b as u32) << (16 - 8 * i));
|
||||||
|
for i in 0..4 {
|
||||||
|
if i <= chunk.len() {
|
||||||
|
out.push(ALPHABET[(n >> (18 - 6 * i) & 63) as usize] as char);
|
||||||
|
} else {
|
||||||
|
out.push('=');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
/// Renders the start of a response body for an error message.
|
/// Renders the start of a response body for an error message.
|
||||||
///
|
///
|
||||||
/// Collapsed to one line and truncated, so a stray HTML error page or a
|
/// Collapsed to one line and truncated, so a stray HTML error page or a
|
||||||
@@ -101,9 +125,9 @@ fn excerpt(bytes: &[u8]) -> String {
|
|||||||
pub struct InvoiceNinjaClient {
|
pub struct InvoiceNinjaClient {
|
||||||
http: reqwest::Client,
|
http: reqwest::Client,
|
||||||
base_url: String,
|
base_url: String,
|
||||||
/// Marked sensitive, so it renders as `Sensitive` rather than the value
|
/// The header to send it under, and its value. Marked sensitive, so it
|
||||||
/// in the derived `Debug`.
|
/// renders as `Sensitive` rather than the value in the derived `Debug`.
|
||||||
api_password: Option<header::HeaderValue>,
|
api_password: Option<(&'static str, header::HeaderValue)>,
|
||||||
timeout: Duration,
|
timeout: Duration,
|
||||||
retry: RetryPolicy,
|
retry: RetryPolicy,
|
||||||
}
|
}
|
||||||
@@ -184,7 +208,14 @@ impl InvoiceNinjaClient {
|
|||||||
/// middleware and reject requests without the account password, even
|
/// middleware and reject requests without the account password, even
|
||||||
/// when the token is valid: reading or writing an individual user,
|
/// when the token is valid: reading or writing an individual user,
|
||||||
/// `/users/bulk`, purging a client or company, and merging clients.
|
/// `/users/bulk`, purging a client or company, and merging clients.
|
||||||
/// Without this, those calls fail rather than returning data.
|
/// Without this, those calls fail rather than returning data. (The
|
||||||
|
/// server also lets a recent password-authenticated session through
|
||||||
|
/// without the header, so an omitted password is not always refused.)
|
||||||
|
///
|
||||||
|
/// A password that fits a header value is sent as `X-API-PASSWORD`. One
|
||||||
|
/// that does not (it contains a control character such as a newline) is
|
||||||
|
/// sent as `X-API-PASSWORD-BASE64`, which the server's middleware decodes.
|
||||||
|
/// It used to be refused locally with a misleading `InvalidToken`.
|
||||||
///
|
///
|
||||||
/// ```no_run
|
/// ```no_run
|
||||||
/// # fn main() -> invoiceninjars::Result<()> {
|
/// # fn main() -> invoiceninjars::Result<()> {
|
||||||
@@ -196,10 +227,17 @@ impl InvoiceNinjaClient {
|
|||||||
/// # }
|
/// # }
|
||||||
/// ```
|
/// ```
|
||||||
pub fn with_api_password(mut self, password: impl Into<String>) -> Result<Self> {
|
pub fn with_api_password(mut self, password: impl Into<String>) -> Result<Self> {
|
||||||
let mut value = header::HeaderValue::from_str(&password.into())
|
let password = password.into();
|
||||||
.map_err(|e| Error::InvalidToken(e.to_string()))?;
|
let (name, mut value) = match header::HeaderValue::from_str(&password) {
|
||||||
|
Ok(value) => ("X-API-PASSWORD", value),
|
||||||
|
Err(_) => (
|
||||||
|
"X-API-PASSWORD-BASE64",
|
||||||
|
header::HeaderValue::from_str(&base64(password.as_bytes()))
|
||||||
|
.expect("base64 output is always a valid header value"),
|
||||||
|
),
|
||||||
|
};
|
||||||
value.set_sensitive(true);
|
value.set_sensitive(true);
|
||||||
self.api_password = Some(value);
|
self.api_password = Some((name, value));
|
||||||
Ok(self)
|
Ok(self)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -211,7 +249,7 @@ impl InvoiceNinjaClient {
|
|||||||
.request(method, self.url(path))
|
.request(method, self.url(path))
|
||||||
.timeout(self.timeout);
|
.timeout(self.timeout);
|
||||||
match &self.api_password {
|
match &self.api_password {
|
||||||
Some(password) => builder.header("X-API-PASSWORD", password.clone()),
|
Some((name, password)) => builder.header(*name, password.clone()),
|
||||||
None => builder,
|
None => builder,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -220,8 +258,16 @@ impl InvoiceNinjaClient {
|
|||||||
/// configured [`RetryPolicy`].
|
/// configured [`RetryPolicy`].
|
||||||
///
|
///
|
||||||
/// Every helper goes through here, so retrying is uniform. The request is
|
/// Every helper goes through here, so retrying is uniform. The request is
|
||||||
/// cloned before each attempt; a body that cannot be cloned (none of ours
|
/// cloned before each attempt; a body that cannot be cloned simply gets
|
||||||
/// today, since all payloads are in-memory JSON) simply gets one attempt.
|
/// one attempt.
|
||||||
|
///
|
||||||
|
/// Multipart uploads rely on that. `reqwest` builds their body as a
|
||||||
|
/// stream, so `try_clone` returns `None` and they are never replayed —
|
||||||
|
/// not even on a 429. That is wanted: an upload *adds* a document, so it
|
||||||
|
/// is not idempotent whatever its HTTP verb, and a replay after a lost
|
||||||
|
/// response would attach the file twice. A test pins this, because
|
||||||
|
/// nothing in the types would stop a future change from making the body
|
||||||
|
/// cloneable and silently breaking it.
|
||||||
///
|
///
|
||||||
/// Note what is *not* retried: a `POST` that fails with a 5xx or times
|
/// Note what is *not* retried: a `POST` that fails with a 5xx or times
|
||||||
/// out is surfaced, because the server may already have applied it and a
|
/// out is surfaced, because the server may already have applied it and a
|
||||||
@@ -545,10 +591,52 @@ impl InvoiceNinjaClient {
|
|||||||
Self::decode(response).await
|
Self::decode(response).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Attaches files to a record through its `/upload` route.
|
||||||
|
///
|
||||||
|
/// The route is registered as `PUT`, but PHP does not parse a multipart
|
||||||
|
/// body on a `PUT`, so the files would never arrive. The working form is
|
||||||
|
/// a `POST` carrying `_method=PUT`, which Laravel's kernel turns back
|
||||||
|
/// into a `PUT` before routing. The published OpenAPI spec documents
|
||||||
|
/// `POST` with `_method=POST`, which would 405 against these routes.
|
||||||
|
///
|
||||||
|
/// `is_public` goes out as `1`/`0`: Laravel's `boolean` rule accepts only
|
||||||
|
/// `true`, `false`, `0`, `1`, `'0'` and `'1'`, so the string `"true"`
|
||||||
|
/// fails validation.
|
||||||
|
async fn upload_files<T: DeserializeOwned>(
|
||||||
|
&self,
|
||||||
|
path: &str,
|
||||||
|
files: Vec<FileUpload>,
|
||||||
|
is_public: Option<bool>,
|
||||||
|
) -> Result<T> {
|
||||||
|
// The server answers an upload with no files with a success that
|
||||||
|
// changed nothing, which is exactly the quiet failure to avoid.
|
||||||
|
if files.is_empty() {
|
||||||
|
return Err(Error::InvalidUpload("no files to upload".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut form = multipart::Form::new().text("_method", "PUT");
|
||||||
|
if let Some(is_public) = is_public {
|
||||||
|
form = form.text("is_public", if is_public { "1" } else { "0" });
|
||||||
|
}
|
||||||
|
for file in files {
|
||||||
|
form = form.part("documents[]", file.into_part()?);
|
||||||
|
}
|
||||||
|
|
||||||
|
let response = self
|
||||||
|
.execute(self.req(Method::POST, path).multipart(form))
|
||||||
|
.await?;
|
||||||
|
let body: ItemResponse<T> = Self::decode(response).await?;
|
||||||
|
Ok(body.data)
|
||||||
|
}
|
||||||
|
|
||||||
/// Issues a request whose body is discarded, failing on non-2xx.
|
/// Issues a request whose body is discarded, failing on non-2xx.
|
||||||
async fn get_discard(&self, path: &str) -> Result<()> {
|
async fn get_discard(&self, path: &str) -> Result<()> {
|
||||||
self.get_raw(path).await.map(|_| ())
|
self.get_raw(path).await.map(|_| ())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn post_discard<B: Serialize>(&self, path: &str, payload: &B) -> Result<()> {
|
||||||
|
self.post_raw(path, payload).await.map(|_| ())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Generates typed list/list_all/get/create/update/delete methods for a
|
/// Generates typed list/list_all/get/create/update/delete methods for a
|
||||||
@@ -609,6 +697,34 @@ macro_rules! resource_methods {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Generates `upload_{x}`, which attaches files through
|
||||||
|
/// `PUT /{resource}/{id}/upload`. Sixteen resources expose this route; check
|
||||||
|
/// `routes/api.php` before adding one.
|
||||||
|
///
|
||||||
|
/// The upload route is how a document is created at all —
|
||||||
|
/// `DocumentController::store` is an empty stub upstream, which is why there
|
||||||
|
/// is no `create_document`.
|
||||||
|
macro_rules! upload_methods {
|
||||||
|
($upload:ident, $path:literal, $model:ty) => {
|
||||||
|
/// Attaches files to the record and returns it as refreshed by the
|
||||||
|
/// server. `is_public` sets whether the documents are visible to the
|
||||||
|
/// client in the portal; `None` leaves the server default.
|
||||||
|
///
|
||||||
|
/// The server enforces file type and size limits, which surface as a
|
||||||
|
/// 422 [`Error::Api`](crate::Error::Api). Uploads are never retried,
|
||||||
|
/// since a replay could attach the file twice.
|
||||||
|
pub async fn $upload(
|
||||||
|
&self,
|
||||||
|
id: &str,
|
||||||
|
files: Vec<FileUpload>,
|
||||||
|
is_public: Option<bool>,
|
||||||
|
) -> Result<$model> {
|
||||||
|
self.upload_files(&format!("{}/{}/upload", $path, seg(id)?), files, is_public)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/// Generates the `GET /{resource}/{id}/{action}` helpers. Only invoices,
|
/// Generates the `GET /{resource}/{id}/{action}` helpers. Only invoices,
|
||||||
/// quotes, credits, and purchase orders expose this route.
|
/// quotes, credits, and purchase orders expose this route.
|
||||||
macro_rules! entity_action_methods {
|
macro_rules! entity_action_methods {
|
||||||
@@ -977,6 +1093,33 @@ impl InvoiceNinjaClient {
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- document uploads -----------------------------------------------
|
||||||
|
|
||||||
|
upload_methods!(upload_client, "clients", ClientModel);
|
||||||
|
upload_methods!(upload_company, "companies", Company);
|
||||||
|
upload_methods!(upload_credit, "credits", Credit);
|
||||||
|
upload_methods!(upload_expense, "expenses", Expense);
|
||||||
|
upload_methods!(upload_group_setting, "group_settings", GroupSetting);
|
||||||
|
upload_methods!(upload_invoice, "invoices", Invoice);
|
||||||
|
upload_methods!(upload_payment, "payments", Payment);
|
||||||
|
upload_methods!(upload_product, "products", Product);
|
||||||
|
upload_methods!(upload_project, "projects", Project);
|
||||||
|
upload_methods!(upload_purchase_order, "purchase_orders", PurchaseOrder);
|
||||||
|
upload_methods!(upload_quote, "quotes", Quote);
|
||||||
|
upload_methods!(
|
||||||
|
upload_recurring_expense,
|
||||||
|
"recurring_expenses",
|
||||||
|
RecurringExpense
|
||||||
|
);
|
||||||
|
upload_methods!(
|
||||||
|
upload_recurring_invoice,
|
||||||
|
"recurring_invoices",
|
||||||
|
RecurringInvoice
|
||||||
|
);
|
||||||
|
upload_methods!(upload_recurring_quote, "recurring_quotes", RecurringQuote);
|
||||||
|
upload_methods!(upload_task, "tasks", Task);
|
||||||
|
upload_methods!(upload_vendor, "vendors", Vendor);
|
||||||
|
|
||||||
// --- entity actions with non-entity responses -----------------------
|
// --- entity actions with non-entity responses -----------------------
|
||||||
|
|
||||||
/// Converts an invoice into a purchase order. Separate from
|
/// Converts an invoice into a purchase order. Separate from
|
||||||
@@ -1317,3 +1460,450 @@ impl InvoiceNinjaClient {
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Per-resource routes that sit beside the CRUD set. Each answers with its
|
||||||
|
/// own shape (checked against the upstream controllers), so none of them is
|
||||||
|
/// macro-generated.
|
||||||
|
///
|
||||||
|
/// All are `POST` unless noted, so none is retried on a 5xx or a timeout.
|
||||||
|
/// Several are no-body triggers; they send `{}` rather than an empty body.
|
||||||
|
impl InvoiceNinjaClient {
|
||||||
|
// --- webhooks, tasks, designs ---------------------------------------
|
||||||
|
|
||||||
|
/// Re-sends a webhook for one entity. `body` takes `entity` (e.g.
|
||||||
|
/// `invoice`) and `entity_id`. Answers with the webhook itself, not the
|
||||||
|
/// delivery result.
|
||||||
|
pub async fn retry_webhook<B: Serialize>(&self, id: &str, body: &B) -> Result<Webhook> {
|
||||||
|
self.post_one(&format!("webhooks/{}/retry", seg(id)?), body)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reorders task-board columns and the tasks in them. `body` takes
|
||||||
|
/// `status_ids` (task status ids, in order) and `task_ids` (a map from
|
||||||
|
/// task status id to the ordered task ids in that column).
|
||||||
|
pub async fn sort_tasks<B: Serialize>(&self, body: &B) -> Result<MessageResponse> {
|
||||||
|
self.post_unwrapped("tasks/sort", body).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Makes a design the default for an entity. `body` takes `design_id`
|
||||||
|
/// and `entity`, and optionally `settings_level` (`company`,
|
||||||
|
/// `group_settings`, or `client`) with the matching `group_settings_id`
|
||||||
|
/// or `client_id`.
|
||||||
|
pub async fn set_default_design<B: Serialize>(&self, body: &B) -> Result<MessageResponse> {
|
||||||
|
self.post_unwrapped("designs/set/default", body).await
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- client gateway tokens ------------------------------------------
|
||||||
|
|
||||||
|
/// Makes a stored payment method the client's default.
|
||||||
|
pub async fn set_default_client_gateway_token(&self, id: &str) -> Result<ClientGatewayToken> {
|
||||||
|
self.post_one(
|
||||||
|
&format!("client_gateway_tokens/{}/setAsDefault", seg(id)?),
|
||||||
|
&serde_json::json!({}),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- subscriptions --------------------------------------------------
|
||||||
|
|
||||||
|
/// The steps of the purchase flow and what each depends on.
|
||||||
|
///
|
||||||
|
/// There is no `data` wrapper. The server sends a JSON object keyed by
|
||||||
|
/// the PHP class behind each step (the controller's `collect()` keeps
|
||||||
|
/// those keys), though the upstream source reads as if it were a list.
|
||||||
|
/// Both shapes decode here; the keys are dropped, since each step's `id`
|
||||||
|
/// says the same thing, and the order of the result is not meaningful.
|
||||||
|
pub async fn subscription_steps(&self) -> Result<Vec<SubscriptionStep>> {
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
#[serde(untagged)]
|
||||||
|
enum Steps {
|
||||||
|
List(Vec<SubscriptionStep>),
|
||||||
|
Keyed(std::collections::BTreeMap<String, SubscriptionStep>),
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(match self.get_unwrapped("subscriptions/steps").await? {
|
||||||
|
Steps::List(steps) => steps,
|
||||||
|
Steps::Keyed(steps) => steps.into_values().collect(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validates a proposed `steps` list against the dependency rules.
|
||||||
|
/// Succeeds on a valid list; an invalid one is a 422
|
||||||
|
/// ([`Error::Api`]) carrying the reason.
|
||||||
|
pub async fn check_subscription_steps<B: Serialize>(&self, body: &B) -> Result<()> {
|
||||||
|
self.post_discard("subscriptions/steps/check", body).await
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- company gateways -----------------------------------------------
|
||||||
|
|
||||||
|
/// Asks the gateway to verify its credentials. The outcome is in the
|
||||||
|
/// returned message; a failed check is still an HTTP 200.
|
||||||
|
pub async fn test_company_gateway(&self, id: &str) -> Result<MessageResponse> {
|
||||||
|
self.post_unwrapped(
|
||||||
|
&format!("company_gateways/{}/test", seg(id)?),
|
||||||
|
&serde_json::json!({}),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Duplicates a company gateway, returning the copy.
|
||||||
|
pub async fn clone_company_gateway(&self, id: &str) -> Result<CompanyGateway> {
|
||||||
|
self.post_one(
|
||||||
|
&format!("company_gateways/{}/clone", seg(id)?),
|
||||||
|
&serde_json::json!({}),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Starts importing the gateway's customers as clients. The import runs
|
||||||
|
/// in the background; the message only confirms it was queued.
|
||||||
|
pub async fn import_company_gateway_customers(&self, id: &str) -> Result<MessageResponse> {
|
||||||
|
self.post_unwrapped(
|
||||||
|
&format!("company_gateways/{}/import_customers", seg(id)?),
|
||||||
|
&serde_json::json!({}),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- bank integrations and transactions -----------------------------
|
||||||
|
|
||||||
|
/// Asks the server to refresh every linked bank account. The response
|
||||||
|
/// body is a serialised query builder with no usable content, so it is
|
||||||
|
/// discarded; list the integrations afterwards to see the result.
|
||||||
|
/// Throttled to 30 calls a minute.
|
||||||
|
pub async fn refresh_bank_accounts(&self) -> Result<()> {
|
||||||
|
self.post_discard("bank_integrations/refresh_accounts", &serde_json::json!({}))
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Disconnects a bank account, returning the updated integration.
|
||||||
|
/// `account_id` is the provider's account id, not the integration's.
|
||||||
|
pub async fn remove_bank_account(&self, account_id: &str) -> Result<BankIntegration> {
|
||||||
|
self.post_one(
|
||||||
|
&format!("bank_integrations/remove_account/{}", seg(account_id)?),
|
||||||
|
&serde_json::json!({}),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Starts fetching transactions for every auto-sync integration. Despite
|
||||||
|
/// the `account_id` in the route, the server ignores it. Throttled to one
|
||||||
|
/// call a minute.
|
||||||
|
pub async fn fetch_bank_transactions(&self, account_id: &str) -> Result<MessageResponse> {
|
||||||
|
self.post_unwrapped(
|
||||||
|
&format!("bank_integrations/get_transactions/{}", seg(account_id)?),
|
||||||
|
&serde_json::json!({}),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Matches bank transactions to invoices, payments, or expenses. `body`
|
||||||
|
/// takes a `transactions` array; the matched transactions come back.
|
||||||
|
pub async fn match_bank_transactions<B: Serialize>(
|
||||||
|
&self,
|
||||||
|
body: &B,
|
||||||
|
) -> Result<Vec<BankTransaction>> {
|
||||||
|
self.post_list("bank_transactions/match", body).await
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- client extras --------------------------------------------------
|
||||||
|
|
||||||
|
/// Re-fetches the client's tax data from the tax provider. Throttled to
|
||||||
|
/// 10 calls a minute.
|
||||||
|
pub async fn update_client_tax_data(&self, id: &str) -> Result<ClientModel> {
|
||||||
|
self.post_one(
|
||||||
|
&format!("clients/{}/updateTaxData", seg(id)?),
|
||||||
|
&serde_json::json!({}),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The client's effective settings, each tagged with the level it came
|
||||||
|
/// from. Left as raw JSON: the map's keys are the settings themselves.
|
||||||
|
pub async fn client_show_settings(&self, id: &str) -> Result<serde_json::Value> {
|
||||||
|
self.post_unwrapped(
|
||||||
|
&format!("clients/{}/show_settings", seg(id)?),
|
||||||
|
&serde_json::json!({}),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Looks the client up on the Peppol network. Left as raw JSON: the
|
||||||
|
/// controller returns whatever the discovery service produced.
|
||||||
|
pub async fn client_peppol_discovery(&self, id: &str) -> Result<serde_json::Value> {
|
||||||
|
self.post_unwrapped(
|
||||||
|
&format!("clients/{}/peppol_discovery", seg(id)?),
|
||||||
|
&serde_json::json!({}),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validates the client's VAT number. Updates the client's VAT status as
|
||||||
|
/// a side effect.
|
||||||
|
pub async fn check_client_vat(&self, id: &str) -> Result<MessageResponse> {
|
||||||
|
self.post_unwrapped(
|
||||||
|
&format!("clients/{}/check_vat", seg(id)?),
|
||||||
|
&serde_json::json!({}),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The client's VAT validation status. A `GET`, and the only read-only
|
||||||
|
/// method in this block.
|
||||||
|
pub async fn client_vat_status(&self, id: &str) -> Result<MessageResponse> {
|
||||||
|
self.get_unwrapped(&format!("clients/{}/vat_status", seg(id)?))
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every document attached to the client or to its invoices, quotes,
|
||||||
|
/// credits, expenses, payments, tasks, recurring invoices, recurring
|
||||||
|
/// expenses, and projects. `body` takes `filter` (a name substring) and
|
||||||
|
/// `sort` (`column|asc` or `column|desc`). Returns one page.
|
||||||
|
pub async fn client_documents<B: Serialize>(
|
||||||
|
&self,
|
||||||
|
id: &str,
|
||||||
|
body: &B,
|
||||||
|
) -> Result<Vec<Document>> {
|
||||||
|
self.post_list(&format!("clients/{}/documents", seg(id)?), body)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Destructive actions behind the API's `password_protected` middleware.
|
||||||
|
///
|
||||||
|
/// Every method here needs
|
||||||
|
/// [`with_api_password`](InvoiceNinjaClient::with_api_password); without it
|
||||||
|
/// the server normally refuses the request. Most are **irreversible**: a purge
|
||||||
|
/// force-deletes rows rather than archiving them, and a merge removes one of
|
||||||
|
/// the two records.
|
||||||
|
///
|
||||||
|
/// All are `POST`s, so none is retried on a 5xx or a timeout. That matters
|
||||||
|
/// most here: replaying a merge after a lost response could act on a record
|
||||||
|
/// that no longer exists.
|
||||||
|
impl InvoiceNinjaClient {
|
||||||
|
/// Permanently deletes a client and its documents. Unlike
|
||||||
|
/// [`delete_client`](Self::delete_client), which soft-deletes, this cannot
|
||||||
|
/// be undone.
|
||||||
|
pub async fn purge_client(&self, id: &str) -> Result<MessageResponse> {
|
||||||
|
self.post_unwrapped(
|
||||||
|
&format!("clients/{}/purge", seg(id)?),
|
||||||
|
&serde_json::json!({}),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Merges `mergeable_id` into `id`, returning the client that is kept.
|
||||||
|
///
|
||||||
|
/// The route is `clients/{id}/{mergeable_id}/merge` and the controller
|
||||||
|
/// calls `merge` on the first, so `id` is the survivor. Merging a client
|
||||||
|
/// into itself, or naming one that does not exist, is a 400.
|
||||||
|
pub async fn merge_clients(&self, id: &str, mergeable_id: &str) -> Result<ClientModel> {
|
||||||
|
self.post_one(
|
||||||
|
&format!("clients/{}/{}/merge", seg(id)?, seg(mergeable_id)?),
|
||||||
|
&serde_json::json!({}),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Merges `mergeable_id` into `id`, returning the vendor that is kept.
|
||||||
|
/// Same semantics as [`merge_clients`](Self::merge_clients).
|
||||||
|
pub async fn merge_vendors(&self, id: &str, mergeable_id: &str) -> Result<Vendor> {
|
||||||
|
self.post_one(
|
||||||
|
&format!("vendors/{}/{}/merge", seg(id)?, seg(mergeable_id)?),
|
||||||
|
&serde_json::json!({}),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deletes a company. The hosted app refuses to purge its default
|
||||||
|
/// company with a 400.
|
||||||
|
pub async fn purge_company(&self, id: &str) -> Result<MessageResponse> {
|
||||||
|
self.post_unwrapped(
|
||||||
|
&format!("companies/purge/{}", seg(id)?),
|
||||||
|
&serde_json::json!({}),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Force-deletes a company's clients, products, projects, tasks, vendors,
|
||||||
|
/// expenses, purchase orders, bank transactions and activity, and resets
|
||||||
|
/// its number counters. The company, its settings, and its invoices
|
||||||
|
/// remain as the server leaves them.
|
||||||
|
pub async fn purge_company_save_settings(&self, id: &str) -> Result<MessageResponse> {
|
||||||
|
self.post_unwrapped(
|
||||||
|
&format!("companies/purge_save_settings/{}", seg(id)?),
|
||||||
|
&serde_json::json!({}),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Re-sends a user's invitation email.
|
||||||
|
pub async fn invite_user(&self, id: &str) -> Result<MessageResponse> {
|
||||||
|
self.post_unwrapped(
|
||||||
|
&format!("users/{}/invite", seg(id)?),
|
||||||
|
&serde_json::json!({}),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Permanently deletes a user. Only the account owner may, and not
|
||||||
|
/// themselves. The server answers 204 with no body.
|
||||||
|
pub async fn purge_user(&self, id: &str) -> Result<()> {
|
||||||
|
self.post_discard(&format!("users/{}/purge", seg(id)?), &serde_json::json!({}))
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A blank user as the server would create it, for filling in and
|
||||||
|
/// passing to [`create_user`](Self::create_user). Nothing is stored.
|
||||||
|
pub async fn new_user_template(&self) -> Result<User> {
|
||||||
|
self.get_one("users/create").await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Activity log, email history, and document previews. Each has a response
|
||||||
|
/// shape of its own (read from the upstream controllers, and for the activity
|
||||||
|
/// routes confirmed against a 5.13.50 server), so none is macro-generated.
|
||||||
|
impl InvoiceNinjaClient {
|
||||||
|
// --- activities -----------------------------------------------------
|
||||||
|
|
||||||
|
/// One page of the activity log, newest first.
|
||||||
|
///
|
||||||
|
/// The server takes its row limit from a `rows` parameter (default 75)
|
||||||
|
/// rather than `per_page`, though it paginates the result as well; use
|
||||||
|
/// [`ListParams::param`] to pass `rows`.
|
||||||
|
pub async fn list_activities(&self, params: &ListParams) -> Result<Vec<Activity>> {
|
||||||
|
self.get_list("activities", params).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_all_activities(&self, params: &ListParams) -> Result<Vec<Activity>> {
|
||||||
|
self.get_all_pages("activities", params).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The activity log as the web app shows it: each entry's records are
|
||||||
|
/// resolved into labelled references, newest first. Non-admins see only
|
||||||
|
/// their own activity. `rows` defaults to 75 on the server.
|
||||||
|
pub async fn activity_feed(&self, rows: Option<u32>) -> Result<Vec<ActivityFeedEntry>> {
|
||||||
|
let mut params = ListParams::new().param("reactv2", "1");
|
||||||
|
if let Some(rows) = rows {
|
||||||
|
params = params.param("rows", rows.to_string());
|
||||||
|
}
|
||||||
|
self.get_list("activities", ¶ms).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The feed for one record. `entity` is the **singular** name (`invoice`,
|
||||||
|
/// `quote`, `credit`, `purchase_order`, `payment`, `client`, `vendor`,
|
||||||
|
/// `expense`, `task`, `project`, `subscription`, `recurring_invoice`).
|
||||||
|
/// Contrast [`add_activity_note`](Self::add_activity_note), which wants
|
||||||
|
/// the plural.
|
||||||
|
pub async fn entity_activity(
|
||||||
|
&self,
|
||||||
|
entity: &str,
|
||||||
|
entity_id: &str,
|
||||||
|
rows: Option<u32>,
|
||||||
|
) -> Result<Vec<ActivityFeedEntry>> {
|
||||||
|
let mut body = serde_json::json!({ "entity": entity, "entity_id": entity_id });
|
||||||
|
// Left out rather than sent as null: the server tests for the key's
|
||||||
|
// presence, so a null `rows` would count as given and remove the cap.
|
||||||
|
if let Some(rows) = rows {
|
||||||
|
body["rows"] = rows.into();
|
||||||
|
}
|
||||||
|
self.post_list("activities/entity", &body).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Adds a note to a record's history. `entity` is the **plural** table
|
||||||
|
/// name (`invoices`, `quotes`, `credits`, `recurring_invoices`,
|
||||||
|
/// `clients`, `vendors`, `payments`, `projects`, `tasks`, `expenses`,
|
||||||
|
/// `recurring_expenses`, `bank_transactions`, `purchase_orders`), unlike
|
||||||
|
/// [`entity_activity`](Self::entity_activity).
|
||||||
|
///
|
||||||
|
/// Not idempotent: each call adds another note, so it is not retried on a
|
||||||
|
/// 5xx.
|
||||||
|
pub async fn add_activity_note(
|
||||||
|
&self,
|
||||||
|
entity: &str,
|
||||||
|
entity_id: &str,
|
||||||
|
notes: &str,
|
||||||
|
) -> Result<Activity> {
|
||||||
|
self.post_one(
|
||||||
|
"activities/notes",
|
||||||
|
&serde_json::json!({ "entity": entity, "entity_id": entity_id, "notes": notes }),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The PDF of an invoice, quote or credit as it was when the activity was
|
||||||
|
/// recorded. A 404 means that activity kept no backup.
|
||||||
|
pub async fn download_activity_entity(&self, activity_id: &str) -> Result<Vec<u8>> {
|
||||||
|
self.get_raw(&format!("activities/download_entity/{}", seg(activity_id)?))
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- email history --------------------------------------------------
|
||||||
|
|
||||||
|
/// The client's email delivery history, newest first: one object per sent
|
||||||
|
/// message that has delivery events. Left as raw JSON, since the objects
|
||||||
|
/// mirror whatever the mail provider reported and no instance available
|
||||||
|
/// here had sent mail to take a shape from.
|
||||||
|
pub async fn client_email_history(&self, client_id: &str) -> Result<Vec<serde_json::Value>> {
|
||||||
|
self.post_unwrapped(
|
||||||
|
&format!("emails/clientHistory/{}", seg(client_id)?),
|
||||||
|
&serde_json::json!({}),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Email delivery history for one record. `entity` is `invoice`, `quote`,
|
||||||
|
/// `credit`, `recurring_invoice`, or `purchase_order`. Raw JSON, as for
|
||||||
|
/// [`client_email_history`](Self::client_email_history).
|
||||||
|
pub async fn entity_email_history(
|
||||||
|
&self,
|
||||||
|
entity: &str,
|
||||||
|
entity_id: &str,
|
||||||
|
) -> Result<Vec<serde_json::Value>> {
|
||||||
|
self.post_unwrapped(
|
||||||
|
"emails/entityHistory",
|
||||||
|
&serde_json::json!({ "entity": entity, "entity_id": entity_id }),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- previews -------------------------------------------------------
|
||||||
|
//
|
||||||
|
// These render a document without saving it and answer with a PDF, so
|
||||||
|
// they return the bytes. `body` is the entity to render plus, for the
|
||||||
|
// design routes, the `design` object.
|
||||||
|
|
||||||
|
/// Renders a preview of an existing record or of an unsaved design.
|
||||||
|
/// Takes `entity`, `entity_id` (`-1` for a mock record) and `design`.
|
||||||
|
pub async fn preview<B: Serialize>(&self, body: &B) -> Result<Vec<u8>> {
|
||||||
|
self.post_raw("preview", body).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`preview`](Self::preview) as the HTML the PDF is made from.
|
||||||
|
pub async fn preview_html<B: Serialize>(&self, body: &B) -> Result<String> {
|
||||||
|
let bytes = self.post_raw("preview?html=true", body).await?;
|
||||||
|
Ok(String::from_utf8_lossy(&bytes).into_owned())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Renders an unsaved invoice, quote, credit or recurring invoice as a
|
||||||
|
/// PDF: the body is the entity's own fields, plus `entity` and
|
||||||
|
/// `entity_id`.
|
||||||
|
pub async fn live_preview<B: Serialize>(&self, body: &B) -> Result<Vec<u8>> {
|
||||||
|
self.post_raw("live_preview", body).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Renders a design against mock data, for editing it.
|
||||||
|
pub async fn live_design<B: Serialize>(&self, body: &B) -> Result<Vec<u8>> {
|
||||||
|
self.post_raw("live_design", body).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`preview`](Self::preview) for a purchase order.
|
||||||
|
pub async fn preview_purchase_order<B: Serialize>(&self, body: &B) -> Result<Vec<u8>> {
|
||||||
|
self.post_raw("preview/purchase_order", body).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`live_preview`](Self::live_preview) for a purchase order.
|
||||||
|
pub async fn live_preview_purchase_order<B: Serialize>(&self, body: &B) -> Result<Vec<u8>> {
|
||||||
|
self.post_raw("live_preview/purchase_order", body).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+42
-1
@@ -15,7 +15,11 @@ pub struct ApiErrorBody {
|
|||||||
#[derive(Debug, thiserror::Error)]
|
#[derive(Debug, thiserror::Error)]
|
||||||
#[non_exhaustive]
|
#[non_exhaustive]
|
||||||
pub enum Error {
|
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),
|
Http(#[from] reqwest::Error),
|
||||||
|
|
||||||
/// The response was not the JSON this endpoint is documented to return.
|
/// The response was not the JSON this endpoint is documented to return.
|
||||||
@@ -31,6 +35,15 @@ pub enum Error {
|
|||||||
excerpt: String,
|
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}")]
|
#[error("api returned {status}: {message}")]
|
||||||
Api {
|
Api {
|
||||||
status: reqwest::StatusCode,
|
status: reqwest::StatusCode,
|
||||||
@@ -54,6 +67,14 @@ pub enum Error {
|
|||||||
#[error("`{0}` cannot be used as a url path segment")]
|
#[error("`{0}` cannot be used as a url path segment")]
|
||||||
InvalidPathSegment(String),
|
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
|
/// A `list_all_*` call kept receiving full pages past the point where any
|
||||||
/// plausible result set would have ended.
|
/// plausible result set would have ended.
|
||||||
///
|
///
|
||||||
@@ -66,3 +87,23 @@ pub enum Error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub type Result<T> = std::result::Result<T, 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
|
||||||
|
}
|
||||||
|
|||||||
@@ -18,7 +18,9 @@ mod client;
|
|||||||
mod error;
|
mod error;
|
||||||
pub mod models;
|
pub mod models;
|
||||||
mod retry;
|
mod retry;
|
||||||
|
mod upload;
|
||||||
|
|
||||||
pub use client::{InvoiceNinjaClient, DEFAULT_TIMEOUT, HOSTED_BASE_URL};
|
pub use client::{InvoiceNinjaClient, DEFAULT_TIMEOUT, HOSTED_BASE_URL};
|
||||||
pub use error::{ApiErrorBody, Error, Result};
|
pub use error::{ApiErrorBody, Error, Result};
|
||||||
pub use retry::RetryPolicy;
|
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>,
|
||||||
|
}
|
||||||
@@ -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`).
|
/// Body for a bulk action (`POST /{resource}/bulk`).
|
||||||
///
|
///
|
||||||
/// The action set varies by resource; `archive`, `restore`, and `delete` work
|
/// 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>,
|
||||||
|
}
|
||||||
|
|||||||
@@ -18,7 +18,14 @@ pub struct Location {
|
|||||||
pub state: String,
|
pub state: String,
|
||||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
pub postal_code: String,
|
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,
|
pub country_id: String,
|
||||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
pub user_id: String,
|
pub user_id: String,
|
||||||
|
|||||||
+6
-2
@@ -1,3 +1,4 @@
|
|||||||
|
mod activity;
|
||||||
mod bank_integration;
|
mod bank_integration;
|
||||||
mod bank_transaction;
|
mod bank_transaction;
|
||||||
mod bank_transaction_rule;
|
mod bank_transaction_rule;
|
||||||
@@ -35,12 +36,15 @@ mod user;
|
|||||||
mod vendor;
|
mod vendor;
|
||||||
mod webhook;
|
mod webhook;
|
||||||
|
|
||||||
|
pub use activity::{Activity, ActivityFeedEntry, ActivityRef};
|
||||||
pub use bank_integration::BankIntegration;
|
pub use bank_integration::BankIntegration;
|
||||||
pub use bank_transaction::BankTransaction;
|
pub use bank_transaction::BankTransaction;
|
||||||
pub use bank_transaction_rule::BankTransactionRule;
|
pub use bank_transaction_rule::BankTransactionRule;
|
||||||
pub use client::{Client, ClientContact};
|
pub use client::{Client, ClientContact};
|
||||||
pub use client_gateway_token::ClientGatewayToken;
|
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::Company;
|
||||||
pub use company_gateway::CompanyGateway;
|
pub use company_gateway::CompanyGateway;
|
||||||
pub use company_token::CompanyToken;
|
pub use company_token::CompanyToken;
|
||||||
@@ -61,7 +65,7 @@ pub use recurring_expense::RecurringExpense;
|
|||||||
pub use recurring_invoice::{RecurringInvoice, RecurringQuote};
|
pub use recurring_invoice::{RecurringInvoice, RecurringQuote};
|
||||||
pub use search::{SearchHit, SearchResults};
|
pub use search::{SearchHit, SearchResults};
|
||||||
pub use statics::{Currency, DateFormat, StaticItem, Statics};
|
pub use statics::{Currency, DateFormat, StaticItem, Statics};
|
||||||
pub use subscription::Subscription;
|
pub use subscription::{Subscription, SubscriptionStep};
|
||||||
pub use system_log::SystemLog;
|
pub use system_log::SystemLog;
|
||||||
pub use tag::Tag;
|
pub use tag::Tag;
|
||||||
pub use task::Task;
|
pub use task::Task;
|
||||||
|
|||||||
@@ -38,7 +38,9 @@ pub struct Payment {
|
|||||||
|
|
||||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
pub struct PaymentInvoice {
|
pub struct PaymentInvoice {
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
pub invoice_id: String,
|
pub invoice_id: String,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
pub amount: f64,
|
pub amount: f64,
|
||||||
|
|
||||||
#[serde(flatten)]
|
#[serde(flatten)]
|
||||||
|
|||||||
@@ -23,7 +23,14 @@ pub struct PurchaseOrder {
|
|||||||
pub po_number: String,
|
pub po_number: String,
|
||||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
pub quote_number: String,
|
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,
|
pub status_id: String,
|
||||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
pub date: String,
|
pub date: String,
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ pub struct RecurringExpense {
|
|||||||
/// Integer enum matching [`RecurringInvoice::frequency_id`].
|
/// Integer enum matching [`RecurringInvoice::frequency_id`].
|
||||||
///
|
///
|
||||||
/// [`RecurringInvoice::frequency_id`]: super::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,
|
pub frequency_id: i64,
|
||||||
/// Number of cycles left; `-1` means it recurs indefinitely.
|
/// Number of cycles left; `-1` means it recurs indefinitely.
|
||||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
#[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")]
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
pub status_id: String,
|
pub status_id: String,
|
||||||
/// How often the invoice recurs. Invoice Ninja returns this as an integer
|
/// How often the invoice recurs. Invoice Ninja returns this as an integer
|
||||||
/// enum (1 = daily, 2 = weekly, … ), not a human-readable string.
|
/// enum (1 = daily, 2 = weekly, … ), not a human-readable string. A real
|
||||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
/// server sends it as a numeric string; both decode.
|
||||||
|
#[serde(default, deserialize_with = "super::common::int_or_numeric_string")]
|
||||||
pub frequency_id: i64,
|
pub frequency_id: i64,
|
||||||
/// Number of cycles left to send; `-1` means it recurs indefinitely.
|
/// Number of cycles left to send; `-1` means it recurs indefinitely.
|
||||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
|||||||
@@ -55,3 +55,16 @@ pub struct Subscription {
|
|||||||
#[serde(flatten)]
|
#[serde(flatten)]
|
||||||
pub extra: Map<String, Value>,
|
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).
|
/// A server-side log entry (gateway responses, mail failures, and so on).
|
||||||
///
|
///
|
||||||
/// Note that unlike every other resource, this one's `created_at` and
|
/// The OpenAPI spec declares `created_at` and `updated_at` as date strings,
|
||||||
/// `updated_at` are returned as date **strings**, not integer timestamps.
|
/// but a real server (5.13.50) sends integer timestamps like every other
|
||||||
|
/// resource.
|
||||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
pub struct SystemLog {
|
pub struct SystemLog {
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
@@ -23,9 +24,9 @@ pub struct SystemLog {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub log: Value,
|
pub log: Value,
|
||||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
#[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")]
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
pub updated_at: String,
|
pub updated_at: i64,
|
||||||
|
|
||||||
#[serde(flatten)]
|
#[serde(flatten)]
|
||||||
pub extra: Map<String, Value>,
|
pub extra: Map<String, Value>,
|
||||||
|
|||||||
@@ -13,8 +13,13 @@ pub struct Webhook {
|
|||||||
/// The URL Invoice Ninja posts to.
|
/// The URL Invoice Ninja posts to.
|
||||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
pub target_url: String,
|
pub target_url: String,
|
||||||
/// Payload format: `JSON` or `UBL`.
|
/// Payload format: `JSON` or `UBL`. Omitted when empty: the server turns
|
||||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
/// `""` 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,
|
pub format: String,
|
||||||
|
|
||||||
#[serde(flatten)]
|
#[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 invoiceninjars::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()
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,127 @@
|
|||||||
|
//! 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 invoiceninjars::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,
|
||||||
|
GroupSetting,
|
||||||
|
Invoice,
|
||||||
|
InvoiceItem,
|
||||||
|
Location,
|
||||||
|
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": ""
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -584,23 +584,24 @@ fn company_keeps_settings_untyped_and_preserves_toggles() {
|
|||||||
assert_eq!(round_tripped["settings"]["timezone_id"], "15");
|
assert_eq!(round_tripped["settings"]["timezone_id"], "15");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// System logs are the one resource whose timestamps are date strings
|
/// The spec says system-log timestamps are date strings; a real server sends
|
||||||
/// rather than integers.
|
/// integers, as it does everywhere else.
|
||||||
#[test]
|
#[test]
|
||||||
fn system_log_timestamps_are_strings() {
|
fn system_log_timestamps_are_integers() {
|
||||||
let log: SystemLog = serde_json::from_value(json!({
|
let log: SystemLog = serde_json::from_value(json!({
|
||||||
"id": "sl1",
|
"id": "sl1",
|
||||||
"event_id": 30,
|
"event_id": 30,
|
||||||
"category_id": 1,
|
"category_id": 1,
|
||||||
"type_id": 300,
|
"type_id": 300,
|
||||||
"log": {"message": "gateway declined"},
|
"log": {"message": "gateway declined"},
|
||||||
"created_at": "2026-10-01 12:00:00",
|
"created_at": 1792869488,
|
||||||
"updated_at": "2026-10-01 12:00:00",
|
"updated_at": 1792869489,
|
||||||
}))
|
}))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
assert_eq!(log.event_id, 30);
|
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");
|
assert_eq!(log.log["message"], "gateway declined");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 invoiceninjars::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