Wrap all 34 resource groups, then harden the request plumbing
Expands coverage to every `Route::resource` group in the v5 API plus `users` (265 methods), and fixes several defects in the shared request path that the new breadth made more exposed. Coverage: - 25 new models, each keeping the `extra` flatten map and the `null_to_default` convention so unmodeled fields round-trip. - `crud_methods!` split out of `resource_methods!` for the three groups with no `/bulk` route, where a generated `bulk_*` would only 405. - COVERAGE.md added as the detailed inventory, verified against `routes/api.php` on v5-stable. Fixes, each confirmed against upstream source or an observed request: - `list_all_*` silently truncated. `BaseController` caps page size at `min(abs(per_page), 5000)`, so the "short page means last page" rule stopped early and returned a partial list as if complete. Termination now prefers `meta.pagination.total_pages` (decoded but previously discarded), and `per_page` is clamped so the fallback stays sound. - `per_page(0)` paged forever: the check `got < 0` has no solution. It now falls back to the default size, with a MAX_PAGES backstop that raises `Error::PaginationLimit` rather than quietly truncating. - Ids were interpolated into paths raw, so a `/` or `?` in an id retargeted the request. All sites now use `seg(id)?`. Escaping alone is insufficient: the WHATWG rules `url` implements decode `%2E` to `.` before resolving dot segments, so an escaped `..` still collapses the path. Empty and dot-only ids are rejected. - No request timeout. reqwest applies none, so a stalled connection hung the caller indefinitely. Adds a 60s default and `with_timeout`. - Base URL is validated at construction; a missing scheme used to 404 on every call instead of failing once. Adds tests/client_requests.rs: 26 tests over the request plumbing against a wiremock loopback server, including regressions for the truncation and non-termination bugs. No network or credentials needed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -29,37 +29,112 @@ 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.
|
`src/` has no `#[test]` modules. Two files: `model_roundtrip.rs` covers the
|
||||||
|
serde layer, and `client_requests.rs` covers the request plumbing
|
||||||
|
(pagination, path escaping, headers, timeouts, error mapping) against a
|
||||||
|
`wiremock` server on loopback — no credentials or internet needed.
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
- `src/client.rs` — `InvoiceNinjaClient` holds a `reqwest::Client` (with the
|
- `src/client.rs` — `InvoiceNinjaClient` holds a `reqwest::Client` (with the
|
||||||
`X-Api-Token` auth header baked in) and a `base_url`. Low-level helpers
|
`X-Api-Token` auth header baked in) and a `base_url`. Low-level helpers
|
||||||
(`get_list`, `get_one`, `post_one`, `put_one`, `delete_one`) handle request
|
(`get_list`, `get_one`, `post_one`, `put_one`, `delete_one`, `post_list`)
|
||||||
dispatch and decode `ListResponse<T>` / `ItemResponse<T>` envelopes, mapping
|
handle request dispatch and decode `ListResponse<T>` / `ItemResponse<T>`
|
||||||
non-2xx responses through `api_error` into `Error::Api`.
|
envelopes, mapping non-2xx responses through `api_error` into `Error::Api`.
|
||||||
|
- Not every endpoint answers with a JSON envelope, so there are three other
|
||||||
|
helper families: `get_raw`/`post_raw` return the body as bytes (PDF streams
|
||||||
|
and CSV report exports), `get_unwrapped`/`post_unwrapped` decode a bare
|
||||||
|
JSON body with no `data` wrapper (`/statics`, `/charts/*`, `/search`), and
|
||||||
|
`get_discard` just checks the status (`/ping`).
|
||||||
- Per-resource methods (`list_clients`, `get_invoice`, `create_product`, etc.)
|
- Per-resource methods (`list_clients`, `get_invoice`, `create_product`, etc.)
|
||||||
are generated by the `resource_methods!` macro at the bottom of
|
are generated by the `resource_methods!` macro at the bottom of
|
||||||
`client.rs`, which expands to
|
`client.rs`, which expands to
|
||||||
`list`/`list_all`/`get`/`create`/`update`/`delete` for a given REST path and
|
`list`/`list_all`/`get`/`create`/`update`/`delete`/`bulk` for a given REST
|
||||||
model type. **To add a new resource**: add a model under `src/models/`,
|
path and model type. **To add a new resource**: add a model under
|
||||||
re-export it from `src/models/mod.rs`, and add one `resource_methods!`
|
`src/models/`, re-export it from `src/models/mod.rs`, and add one
|
||||||
invocation in `client.rs`. Note the macro takes six method names before the
|
`resource_methods!` invocation in `client.rs`. Note the macro takes seven
|
||||||
path and model — adding a resource means passing all six.
|
method names before the path and model — adding a resource means passing
|
||||||
|
all seven.
|
||||||
|
- `resource_methods!` delegates the six CRUD methods to `crud_methods!` and
|
||||||
|
adds `bulk` on top. **Use `crud_methods!` directly for resources with no
|
||||||
|
`/bulk` route** — currently `client_gateway_tokens`, `companies`, and
|
||||||
|
`system_logs`. Check `routes/api.php` before assuming a resource has one;
|
||||||
|
a generated `bulk_*` against a missing route just 405s.
|
||||||
|
- Every request is built through the private `req(method, path)` helper,
|
||||||
|
which is the single place the optional `X-API-PASSWORD` header and the
|
||||||
|
per-request timeout are attached. **Don't call `self.http` directly in a
|
||||||
|
new helper** — it would silently skip both. Only `req` itself should touch
|
||||||
|
`self.http`.
|
||||||
|
- **Interpolate ids into paths with `seg(id)?`, never raw.** It escapes the
|
||||||
|
value into a single path segment and rejects ids that are empty or
|
||||||
|
dot-only. Both halves matter: escaping stops a `/` or `?` in an id from
|
||||||
|
retargeting the request, but escaping *alone* is not enough, because the
|
||||||
|
WHATWG rules the `url` crate follows decode `%2E` back to `.` before
|
||||||
|
resolving dot segments — so an escaped `..` still collapses the path. The
|
||||||
|
exceptions are `report` and `chart`, which take a multi-segment tail by
|
||||||
|
design (`chart("project_burnup/<id>", …)`) and must stay unescaped.
|
||||||
|
- `entity_action_methods!` generates three methods — `{x}_action` and
|
||||||
|
`download_{x}` for the `GET /{resource}/{id}/{action}` route, plus
|
||||||
|
`download_{plural}` for the bulk merged-PDF action. **Only invoices,
|
||||||
|
quotes, credits, and purchase orders have the action route** — don't add it
|
||||||
|
elsewhere without checking `routes/api.php`. In particular the official PHP
|
||||||
|
SDK puts bulk `download` on *every* resource, which is wrong; only
|
||||||
|
PDF-rendering entities support it.
|
||||||
|
- Non-CRUD endpoints (documents, reports, charts, search, statics, emails,
|
||||||
|
refunds, ping/health) are hand-written in the last `impl` block rather than
|
||||||
|
macro-generated, because their request and response shapes are all
|
||||||
|
different.
|
||||||
- `list_*` returns one page; `list_all_*` goes through `get_all_pages`, which
|
- `list_*` returns one page; `list_all_*` goes through `get_all_pages`, which
|
||||||
follows `page`/`per_page` until a page returns fewer rows than requested.
|
terminates on `meta.pagination.total_pages` and only falls back to the
|
||||||
- `src/models/` — one file per resource (`client.rs`, `invoice.rs`,
|
short-page heuristic when the server sends no metadata. **The heuristic is
|
||||||
`payment.rs`, `product.rs`, `expense.rs`, `project.rs`, `task.rs`,
|
not sound on its own**: `BaseController` caps `per_page` at
|
||||||
`vendor.rs`), plus `common.rs` for shared envelope/pagination types
|
`min(abs(per_page), 5000)`, so a page can be shorter than requested while
|
||||||
(`ListParams`, `ListResponse`, `ItemResponse`, `Meta`, `Pagination`).
|
records remain — which is why `per_page` is clamped to 5000 before the
|
||||||
Invoice Ninja stores quotes and credits in the same table as invoices (an
|
walk starts. `per_page(0)` falls back to the default size (it would make
|
||||||
`entity_type` field distinguishes them), so `Quote` and `Credit` are type
|
the short-page check `got < 0`, which never terminates), the page counter
|
||||||
aliases for `Invoice` (`src/models/invoice.rs`) and share the
|
advances locally rather than trusting the echoed `current_page`, an empty
|
||||||
`/quotes`, `/credits`, and `/invoices` paths via the macro.
|
page always ends the walk, and a `MAX_PAGES` backstop raises
|
||||||
|
`Error::PaginationLimit` rather than quietly returning a truncated list.
|
||||||
|
- `src/models/` — one file per resource, plus `common.rs` for shared
|
||||||
|
envelope/pagination types (`ListParams`, `ListResponse`, `ItemResponse`,
|
||||||
|
`Meta`, `Pagination`).
|
||||||
|
- Several endpoints share a schema, so some models are type aliases rather
|
||||||
|
than separate structs: Invoice Ninja keeps quotes and credits in the
|
||||||
|
invoices table (an `entity_type` field distinguishes them), so `Quote` and
|
||||||
|
`Credit` alias `Invoice` (`src/models/invoice.rs`); likewise
|
||||||
|
`RecurringQuote` aliases `RecurringInvoice`
|
||||||
|
(`src/models/recurring_invoice.rs`), whose fields are a strict superset.
|
||||||
|
Each alias still gets its own path via the macro.
|
||||||
- Every model struct has a `#[serde(flatten)] pub extra: Map<String, Value>`
|
- Every model struct has a `#[serde(flatten)] pub extra: Map<String, Value>`
|
||||||
field. This preserves any API fields not explicitly modeled, so a `get`
|
field. This preserves any API fields not explicitly modeled, so a `get`
|
||||||
followed by an `update` round-trips without silently dropping data. Keep
|
followed by an `update` round-trips without silently dropping data. Keep
|
||||||
this pattern when adding fields or new models.
|
this pattern when adding fields or new models.
|
||||||
|
- **Every non-`Option` field carries
|
||||||
|
`#[serde(default, deserialize_with = "super::common::null_to_default")]`,
|
||||||
|
not a bare `#[serde(default)]`.** `default` alone only covers a *missing*
|
||||||
|
key; a key present with an explicit `null` fails with `invalid type: null,
|
||||||
|
expected a string`. Invoice Ninja returns `null` for many
|
||||||
|
nominally-string fields, so a bare `default` would break decoding of the
|
||||||
|
whole response. `Option<T>` fields keep the plain `#[serde(default)]` —
|
||||||
|
they already map `null` to `None`, and that distinction matters (an unset
|
||||||
|
`archived_at` should stay `None`, not become `Some(0)`).
|
||||||
|
- Models deliberately cover a curated subset of each schema and let `extra`
|
||||||
|
carry the rest — `custom_value1..4` and `custom_surcharge1..4` are
|
||||||
|
intentionally unmodeled. Fields whose JSON type is ambiguous or
|
||||||
|
inconsistent in the upstream spec (e.g. `Subscription.currency_id`,
|
||||||
|
`webhook_configuration`, `due_date_days`) are also left to `extra`, since a
|
||||||
|
wrong concrete type is a hard deserialize error whereas `extra` is lossless.
|
||||||
|
- Field names and types come from Invoice Ninja's own OpenAPI spec
|
||||||
|
(`openapi/api-docs.yaml` on the `v5-stable` branch of the invoiceninja
|
||||||
|
repo). Check it before adding or retyping a field; the same name can differ
|
||||||
|
in type across resources (`frequency_id` is a number on recurring invoices
|
||||||
|
and expenses but a string on subscriptions).
|
||||||
|
- **The published spec is incomplete for routes**: it documents `/{id}` and
|
||||||
|
action paths only for clients, locations, and tags, and omits them for
|
||||||
|
invoices and everything else. `routes/api.php` in the same repo is the
|
||||||
|
authoritative route list, and the controllers are authoritative for what a
|
||||||
|
given action returns. Use the spec for *schemas*, the routes and
|
||||||
|
controllers for *endpoints*.
|
||||||
- `src/error.rs` — `Error` enum (`thiserror`) covering transport, JSON
|
- `src/error.rs` — `Error` enum (`thiserror`) covering transport, JSON
|
||||||
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
|
||||||
@@ -67,6 +142,42 @@ Tests live in `tests/` (integration-style, no network access required);
|
|||||||
|
|
||||||
## Coverage
|
## Coverage
|
||||||
|
|
||||||
Clients, Invoices, Quotes, Credits, Payments, Products, Expenses, Projects,
|
**All 34 of the API's `Route::resource` groups are wrapped**, plus `users`
|
||||||
Tasks, and Vendors, each with `list`, `list_all`, `get`, `create`, `update`,
|
(which isn't one). 265 methods: 30 resources with CRUD + bulk, 3 with CRUD
|
||||||
and `delete`.
|
only, 4 with entity actions, and 25 hand-written.
|
||||||
|
|
||||||
|
Plus the non-CRUD surface: documents, entity actions and PDF downloads for
|
||||||
|
the four action-capable resources, `report`, `chart`, `search`, `statics`,
|
||||||
|
`send_email`, `refund_payment`, `ping`, and `health_check`.
|
||||||
|
|
||||||
|
**`COVERAGE.md` is the detailed inventory** — every wrapped endpoint, every
|
||||||
|
gap, and the reason for each gap. The main remaining gaps are multipart
|
||||||
|
uploads (needs a reqwest feature), a handful of per-resource extras
|
||||||
|
(`clients/{id}/purge`, `company_gateways/{id}/test`, …), and the
|
||||||
|
integration/auth routes.
|
||||||
|
|
||||||
|
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
|
||||||
|
summaries — here, `README.md`, and `COVERAGE.md` — should agree;
|
||||||
|
`COVERAGE.md` is the one with the specifics, the other two summarize and
|
||||||
|
link to it.
|
||||||
|
|
||||||
|
## Relationship to the official PHP SDK
|
||||||
|
|
||||||
|
[invoiceninja/sdk-php](https://github.com/invoiceninja/sdk-php) is the
|
||||||
|
first-party wrapper and a useful cross-check, but it is thinner than this
|
||||||
|
crate — each endpoint class is just a URI over a shared `BaseEntity`. See the
|
||||||
|
comparison table in `COVERAGE.md` for what each side covers.
|
||||||
|
|
||||||
|
Two divergences are deliberate and shouldn't be "fixed" to match it:
|
||||||
|
|
||||||
|
- **We send JSON; it sends `form_params`.** Form encoding bracket-encodes
|
||||||
|
nested structures, which is bad for payloads like an invoice's `line_items`.
|
||||||
|
The API accepts both.
|
||||||
|
- **We keep the whole `errors` map on `Error::Api`;** it surfaces only the
|
||||||
|
first validation message.
|
||||||
|
|
||||||
|
And two of its behaviours are bugs not to copy: it sends `stream: false` in
|
||||||
|
download payloads (no controller reads a `stream` input), and it exposes
|
||||||
|
`download` on every resource even though only PDF-rendering entities support
|
||||||
|
that action.
|
||||||
|
|||||||
+290
@@ -0,0 +1,290 @@
|
|||||||
|
# API coverage
|
||||||
|
|
||||||
|
What this crate wraps of the Invoice Ninja v5 API, and what it doesn't.
|
||||||
|
|
||||||
|
Checked against `routes/api.php` on the `v5-stable` branch of the
|
||||||
|
[invoiceninja repo](https://github.com/invoiceninja/invoiceninja), which
|
||||||
|
declares 34 `Route::resource` groups and 274 explicit route declarations
|
||||||
|
(272 single-verb, plus two `Route::match` pairs registering two verbs each).
|
||||||
|
|
||||||
|
> Note on sources: Invoice Ninja's published `openapi/api-docs.yaml` is
|
||||||
|
> reliable for **schemas** but incomplete for **routes** — it documents
|
||||||
|
> `/{id}` and action paths only for clients, locations, and tags. Use
|
||||||
|
> `routes/api.php` and the controllers when checking whether an endpoint
|
||||||
|
> exists or what it returns.
|
||||||
|
|
||||||
|
**All 34 `Route::resource` groups are now wrapped**, plus `users` (which is
|
||||||
|
not a resource group). 265 methods in total.
|
||||||
|
|
||||||
|
## Resources with full CRUD + bulk
|
||||||
|
|
||||||
|
30 resources, each with `list_*`, `list_all_*`, `get_*`, `create_*`,
|
||||||
|
`update_*`, `delete_*`, and `bulk_*`:
|
||||||
|
|
||||||
|
| Resource | Path | Model |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Clients | `/clients` | `Client` (+ `ClientContact`) |
|
||||||
|
| Invoices | `/invoices` | `Invoice` (+ `InvoiceItem`) |
|
||||||
|
| Quotes | `/quotes` | `Quote` (alias of `Invoice`) |
|
||||||
|
| Credits | `/credits` | `Credit` (alias of `Invoice`) |
|
||||||
|
| Payments | `/payments` | `Payment` (+ `PaymentInvoice`) |
|
||||||
|
| Products | `/products` | `Product` |
|
||||||
|
| Purchase Orders | `/purchase_orders` | `PurchaseOrder` |
|
||||||
|
| Recurring Invoices | `/recurring_invoices` | `RecurringInvoice` |
|
||||||
|
| Recurring Quotes | `/recurring_quotes` | `RecurringQuote` (alias of `RecurringInvoice`) |
|
||||||
|
| Recurring Expenses | `/recurring_expenses` | `RecurringExpense` |
|
||||||
|
| Subscriptions | `/subscriptions` | `Subscription` |
|
||||||
|
| Projects | `/projects` | `Project` |
|
||||||
|
| Tasks | `/tasks` | `Task` |
|
||||||
|
| Task Statuses | `/task_statuses` | `TaskStatus` |
|
||||||
|
| Expenses | `/expenses` | `Expense` |
|
||||||
|
| Expense Categories | `/expense_categories` | `ExpenseCategory` |
|
||||||
|
| Vendors | `/vendors` | `Vendor` (+ `VendorContact`) |
|
||||||
|
| Bank Transactions | `/bank_transactions` | `BankTransaction` |
|
||||||
|
| Tax Rates | `/tax_rates` | `TaxRate` |
|
||||||
|
| Payment Terms | `/payment_terms` | `PaymentTerm` |
|
||||||
|
| Webhooks | `/webhooks` | `Webhook` |
|
||||||
|
| Bank Integrations | `/bank_integrations` | `BankIntegration` |
|
||||||
|
| Bank Transaction Rules | `/bank_transaction_rules` | `BankTransactionRule` |
|
||||||
|
| Company Gateways | `/company_gateways` | `CompanyGateway` |
|
||||||
|
| Designs | `/designs` | `Design` |
|
||||||
|
| Group Settings | `/group_settings` | `GroupSetting` |
|
||||||
|
| Locations | `/locations` | `Location` |
|
||||||
|
| Tags | `/tags` | `Tag` |
|
||||||
|
| Task Schedulers | `/task_schedulers` | `TaskScheduler` |
|
||||||
|
| Tokens | `/tokens` | `CompanyToken` |
|
||||||
|
|
||||||
|
`bulk_*` accepts any action the endpoint supports; `archive`, `restore`, and
|
||||||
|
`delete` work on all of them. An `ids` list matching nothing returns an empty
|
||||||
|
`Vec`, not an error.
|
||||||
|
|
||||||
|
## Resources without a bulk route
|
||||||
|
|
||||||
|
Three `Route::resource` groups have no `/bulk` route, so they get the six
|
||||||
|
CRUD methods but no `bulk_*` (which would just 405). These use
|
||||||
|
`crud_methods!` rather than `resource_methods!`:
|
||||||
|
|
||||||
|
| Resource | Path | Model |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Client Gateway Tokens | `/client_gateway_tokens` | `ClientGatewayToken` |
|
||||||
|
| Companies | `/companies` | `Company` |
|
||||||
|
| System Logs | `/system_logs` | `SystemLog` |
|
||||||
|
|
||||||
|
`Company` and `GroupSetting` keep their `settings` blob as
|
||||||
|
`serde_json::Value`: `CompanySettings` is the largest object in the API, and
|
||||||
|
`Company` itself returns over a hundred fields, so only the structural ones
|
||||||
|
are modeled and `extra` carries the feature toggles.
|
||||||
|
|
||||||
|
## Users
|
||||||
|
|
||||||
|
`/users` is not a `Route::resource` and has no `DELETE /users/{id}`, so it is
|
||||||
|
hand-written:
|
||||||
|
|
||||||
|
| Endpoint | Method | Needs password? |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `GET /users` | `list_users`, `list_all_users` | no |
|
||||||
|
| `GET /users/{id}` | `get_user` | **yes** |
|
||||||
|
| `POST /users` | `create_user` | **yes** |
|
||||||
|
| `PUT /users/{id}` | `update_user` | **yes** |
|
||||||
|
| `DELETE /users/{id}/detach_from_company` | `detach_user_from_company` | **yes** |
|
||||||
|
| `POST /users/bulk` | `bulk_users` | **yes** |
|
||||||
|
|
||||||
|
"Needs password" means the route sits behind the API's `password_protected`
|
||||||
|
middleware and requires an `X-API-PASSWORD` header on top of a valid token.
|
||||||
|
Supply it with `InvoiceNinjaClient::with_api_password`, which also unlocks
|
||||||
|
client and vendor merge, and client/company/user purge. Note that even
|
||||||
|
*reading* a single user is gated this way.
|
||||||
|
|
||||||
|
## Entity actions
|
||||||
|
|
||||||
|
`GET /{resource}/{id}/{action}` exists for **only four** resources — invoices,
|
||||||
|
quotes, credits, and purchase orders. Each gets `{singular}_action(id, action)`
|
||||||
|
and `download_{singular}(id)`.
|
||||||
|
|
||||||
|
Actions returning the entity: `auto_bill`, `clone_to_invoice`,
|
||||||
|
`clone_to_quote`, `mark_paid`, `mark_sent`, `archive`, `restore`, `delete`.
|
||||||
|
|
||||||
|
Actions that do **not** return the entity, and so are not usable through
|
||||||
|
`*_action`:
|
||||||
|
|
||||||
|
| Action | Returns | Use instead |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `download` | `application/pdf` stream | `download_invoice(id)` etc. |
|
||||||
|
| `convert_to_purchase_order` | a purchase order | `convert_invoice_to_purchase_order(id)` |
|
||||||
|
| `cancel`, `history`, `delivery_note` | empty body (upstream quirk) | — not wrapped |
|
||||||
|
|
||||||
|
Each of the four also gets `download_{plural}(&ids)` — the bulk `download`
|
||||||
|
action, which streams one merged PDF. It can't go through `bulk_*` for the
|
||||||
|
same reason: the response isn't JSON.
|
||||||
|
|
||||||
|
Convenience wrappers: `mark_invoice_sent` and `mark_invoice_paid`.
|
||||||
|
|
||||||
|
## Other wrapped endpoints
|
||||||
|
|
||||||
|
| Endpoint | Method |
|
||||||
|
| --- | --- |
|
||||||
|
| `GET /documents`, `/documents/{id}` | `list_documents`, `list_all_documents`, `get_document` |
|
||||||
|
| `PUT/DELETE /documents/{id}` | `update_document`, `delete_document` |
|
||||||
|
| `GET /documents/{id}/download` | `download_document` → bytes |
|
||||||
|
| `POST /documents/bulk` | `bulk_documents` |
|
||||||
|
| `POST /reports/{name}` | `report(name, body)` → raw bytes (CSV for most) |
|
||||||
|
| `POST /charts/{name}` | `chart(name, body)` → caller-chosen `T` |
|
||||||
|
| `POST /search` | `search(body)` |
|
||||||
|
| `GET /statics` | `statics()` |
|
||||||
|
| `POST /emails` | `send_email(body)` |
|
||||||
|
| `POST /payments/refund` | `refund_payment(body)` |
|
||||||
|
| `GET /ping` | `ping()` |
|
||||||
|
| `GET /health_check` | `health_check()` |
|
||||||
|
|
||||||
|
`report` and `chart` take the name as a string, so all 32 report and 11 chart
|
||||||
|
endpoints are reachable without a per-endpoint method:
|
||||||
|
|
||||||
|
- **Reports** — `activities`, `ar_detail_report`, `ar_summary_report`,
|
||||||
|
`client_balance_report`, `client_contacts`, `client_locations`, `clients`,
|
||||||
|
`client_sales_report`, `contacts`, `credits`, `documents`, `expenses`,
|
||||||
|
`invoice_items`, `invoices`, `locations`, `payments`, `preview`, `products`,
|
||||||
|
`product_sales`, `profitloss`, `projects`, `purchase_order_items`,
|
||||||
|
`purchase_orders`, `quote_items`, `quotes`, `recurring_invoice_items`,
|
||||||
|
`recurring_invoices`, `tasks`, `tax_period_report`, `tax_summary_report`,
|
||||||
|
`user_sales_report`, `vendors`
|
||||||
|
- **Charts** — `totals`, `totals_v2`, `chart_summary`, `chart_summary_v2`,
|
||||||
|
`calculated_fields`, `analytics_summary`, `analytics_totals`,
|
||||||
|
`cashflow_forecast`, `client_payment_analytics`, `project_analytics`,
|
||||||
|
`project_burnup`
|
||||||
|
|
||||||
|
Three of these take a further path segment rather than carrying the id in the
|
||||||
|
body: `charts/project_analytics/{project}`, `charts/project_burnup/{project}`,
|
||||||
|
and `reports/preview/{hash}`. The name argument is interpolated as-is, so pass
|
||||||
|
the whole tail — `chart("project_burnup/<id>", &body)`.
|
||||||
|
|
||||||
|
Large report exports are queued and emailed rather than returned inline; in
|
||||||
|
that case the body is a short JSON message instead of CSV.
|
||||||
|
|
||||||
|
## Not covered
|
||||||
|
|
||||||
|
### Blocked on a dependency change
|
||||||
|
|
||||||
|
**Multipart uploads** — `PUT /{resource}/{id}/upload` exists for clients,
|
||||||
|
companies, credits, expenses, group_settings, invoices, payments, products,
|
||||||
|
projects, purchase_orders, quotes, recurring_expenses, recurring_invoices,
|
||||||
|
recurring_quotes, tasks, and vendors. These need reqwest's `multipart`
|
||||||
|
feature, which isn't currently enabled. This is the main reason `Document`
|
||||||
|
has no `create_document`.
|
||||||
|
|
||||||
|
### Resource groups not wrapped
|
||||||
|
|
||||||
|
None — all 34 `Route::resource` groups are covered.
|
||||||
|
|
||||||
|
Adding a new one is mechanical: add a model under `src/models/`, re-export it
|
||||||
|
from `src/models/mod.rs`, and add a `resource_methods!` invocation in
|
||||||
|
`src/client.rs` (or `crud_methods!` if the resource has no `/bulk` route —
|
||||||
|
check `routes/api.php`).
|
||||||
|
|
||||||
|
### Password-protected endpoints still unwrapped
|
||||||
|
|
||||||
|
`with_api_password` makes these reachable, but the methods themselves aren't
|
||||||
|
written yet:
|
||||||
|
|
||||||
|
- `POST /clients/{id}/purge`
|
||||||
|
- `POST /clients/{id}/{other}/merge`
|
||||||
|
- `POST /vendors/{id}/{other}/merge`
|
||||||
|
- `POST /companies/purge/{company}`,
|
||||||
|
`POST /companies/purge_save_settings/{company}`
|
||||||
|
- `POST /users/{id}/invite`, `POST /users/{id}/purge`, `GET /users/create`
|
||||||
|
|
||||||
|
### Individual endpoints not wrapped
|
||||||
|
|
||||||
|
- **Client extras** — `updateTaxData`, `show_settings`, `peppol_discovery`,
|
||||||
|
`check_vat`, `vat_status`, `documents`
|
||||||
|
- **Bank integration extras** — `refresh_accounts`, `remove_account/{id}`,
|
||||||
|
`get_transactions/{id}`, and `POST /bank_transactions/match`
|
||||||
|
- **Company gateway extras** — `test`, `clone`, `import_customers`
|
||||||
|
- **Company extras** — `GET /companies/{id}/logo`,
|
||||||
|
`POST /companies/{id}/default`, `updateOriginTaxData`,
|
||||||
|
`POST /companies/current`
|
||||||
|
- **Design extras** — `POST /designs/set/default`
|
||||||
|
- **Client gateway token extras** — `setAsDefault`
|
||||||
|
- **Task extras** — `POST /tasks/sort`
|
||||||
|
- **Webhook extras** — `POST /webhooks/{id}/retry`
|
||||||
|
- **Subscription extras** — `GET /subscriptions/steps`,
|
||||||
|
`POST /subscriptions/steps/check`
|
||||||
|
- **User extras** (not password-protected, unlike the rest of `/users`) —
|
||||||
|
`POST /users/{id}/disconnect_mailer`, `POST /users/{id}/disconnect_oauth`,
|
||||||
|
`POST /user/{id}/reconfirm`
|
||||||
|
- **Preview** — `POST /preview`, `POST /preview/purchase_order`, live preview
|
||||||
|
- **Activities** — `GET /activities`, `POST /activities/entity`,
|
||||||
|
`POST /activities/notes`, `download_entity/{activity}`
|
||||||
|
- **Email history** — `POST /emails/clientHistory/{client}`,
|
||||||
|
`POST /emails/entityHistory`
|
||||||
|
- **Auth** — `/login`, `/logout`, `/refresh`, `/connected_account`,
|
||||||
|
passkey routes
|
||||||
|
- **Import/export** — `/import`, `/preimport`, `/import_json`, `/export`,
|
||||||
|
`/migration/start`
|
||||||
|
- **Invoice extras** — `delivery_note`, `payment_schedule`,
|
||||||
|
`update_reminders`
|
||||||
|
- **Public invitation-key downloads** — `GET /{entity}/{invitation_key}/download`
|
||||||
|
for `invoice`, `quote`, `credit`, and `purchase_order` (note the singular
|
||||||
|
path segment), plus the matching e-invoice variants `download_e_invoice`,
|
||||||
|
`download_e_quote`, `download_e_credit`,
|
||||||
|
`download_e_purchase_order`. These are invitation-key based and take no
|
||||||
|
auth token, so they don't fit the client's authenticated request path.
|
||||||
|
- **Integrations** — Stripe, QuickBooks, Apple in-app purchase, PostMark,
|
||||||
|
einvoice (17 routes), calendar_connection, self-update, templates, hooks,
|
||||||
|
Yodlee (`/yodlee/*`), Nordigen (`/nordigen/institutions`)
|
||||||
|
- **Misc** — `/company_users`, `/company_ledger`, `/client_statement`,
|
||||||
|
`/claim_license`, `/check_license`, `/check_subdomain`, `/one_time_token`,
|
||||||
|
`/last_error`, `/support/messages/send`, `/verify/*`, `/accounts/{id}`,
|
||||||
|
`/feedback`, `/filters/{entity}`, `/scheduler`, `/mailer/check`,
|
||||||
|
`/smtp/check`, `/password_timeout`, `/refresh_react`, `/webcron`,
|
||||||
|
`/token_hash_router`, `/reactivate_email/{bounce_id}`,
|
||||||
|
`/protected_download/{hash}`, `/confirm_purchase`,
|
||||||
|
`/exports/preview/{hash}`, `/templates/preview/{hash}`
|
||||||
|
|
||||||
|
### Typed response gaps
|
||||||
|
|
||||||
|
`statics`, `chart`, `search`, `send_email`, and `health_check` are generic
|
||||||
|
over `T: DeserializeOwned` rather than returning concrete structs, because
|
||||||
|
none of them has a stable published schema. Pass `serde_json::Value`, or your
|
||||||
|
own struct to pin the parts you rely on.
|
||||||
|
|
||||||
|
## Compared with the official PHP SDK
|
||||||
|
|
||||||
|
[invoiceninja/sdk-php](https://github.com/invoiceninja/sdk-php) is the
|
||||||
|
first-party wrapper. It's worth checking against, but it is deliberately
|
||||||
|
thinner than this crate: every one of its endpoint classes is just a URI on
|
||||||
|
top of a shared `BaseEntity` offering `all`, `get`, `create`, `update`,
|
||||||
|
`bulk`, `archive`, `delete`, `restore`, and `download`. Only `Companies` and
|
||||||
|
`Statics` add anything of their own.
|
||||||
|
|
||||||
|
**It covers nothing we don't.** The five resources it used to have on us —
|
||||||
|
`users`, `companies`, `group_settings`, `bank_integrations`,
|
||||||
|
`client_gateway_tokens` — are all wrapped now.
|
||||||
|
|
||||||
|
**We cover a lot it doesn't:** `payment_terms`, `task_statuses`,
|
||||||
|
`recurring_quotes`, `recurring_expenses`, `bank_transaction_rules`,
|
||||||
|
`company_gateways`, `designs`, `locations`, `tags`, `task_schedulers`,
|
||||||
|
`tokens`, `system_logs`, documents, per-entity actions (`mark_sent`,
|
||||||
|
`mark_paid`, `clone_to_*`, …), per-entity PDF download, reports, charts,
|
||||||
|
search, `send_email`, `refund_payment`, `ping`, `health_check`, automatic
|
||||||
|
pagination (`list_all_*`), and `X-API-PASSWORD` support for the
|
||||||
|
password-protected routes.
|
||||||
|
|
||||||
|
Deliberate differences in behaviour:
|
||||||
|
|
||||||
|
| | PHP SDK | This crate |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Request bodies | `form_params` (urlencoded) | JSON |
|
||||||
|
| Base URL | host only; appends `/api/v1` itself | full API root, or `hosted()` |
|
||||||
|
| Single-entity delete | none — `delete()` is bulk | `delete_*(id)` via HTTP `DELETE`, plus `bulk_*` |
|
||||||
|
| Validation errors | surfaces only the first message | whole per-field `errors` map on `Error::Api` |
|
||||||
|
| List filters | arbitrary array | typed setters plus `ListParams::param` |
|
||||||
|
|
||||||
|
The urlencoded-vs-JSON difference is the one that matters: form encoding
|
||||||
|
bracket-encodes nested structures, so a payload like an invoice's
|
||||||
|
`line_items` array of objects is far cleaner as JSON. Both are accepted by
|
||||||
|
the API.
|
||||||
|
|
||||||
|
Two things in the PHP SDK are *not* worth copying: it sends
|
||||||
|
`stream: false` in download payloads, but no controller reads a `stream`
|
||||||
|
input, and its `BaseEntity::download` is available on every resource even
|
||||||
|
though only PDF-rendering entities support the action.
|
||||||
Generated
+239
@@ -2,6 +2,25 @@
|
|||||||
# It is not intended for manual editing.
|
# It is not intended for manual editing.
|
||||||
version = 4
|
version = 4
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "aho-corasick"
|
||||||
|
version = "1.1.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba"
|
||||||
|
dependencies = [
|
||||||
|
"memchr",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "assert-json-diff"
|
||||||
|
version = "2.0.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "atomic-waker"
|
name = "atomic-waker"
|
||||||
version = "1.1.2"
|
version = "1.1.2"
|
||||||
@@ -70,6 +89,24 @@ version = "0.8.7"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
|
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "deadpool"
|
||||||
|
version = "0.12.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0be2b1d1d6ec8d846f05e137292d0b89133caf95ef33695424c09568bdd39b1b"
|
||||||
|
dependencies = [
|
||||||
|
"deadpool-runtime",
|
||||||
|
"lazy_static",
|
||||||
|
"num_cpus",
|
||||||
|
"tokio",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "deadpool-runtime"
|
||||||
|
version = "0.1.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "displaydoc"
|
name = "displaydoc"
|
||||||
version = "0.2.7"
|
version = "0.2.7"
|
||||||
@@ -81,6 +118,12 @@ dependencies = [
|
|||||||
"syn 3.0.9",
|
"syn 3.0.9",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "equivalent"
|
||||||
|
version = "1.0.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "errno"
|
name = "errno"
|
||||||
version = "0.3.14"
|
version = "0.3.14"
|
||||||
@@ -103,6 +146,12 @@ version = "0.1.15"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48"
|
checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fnv"
|
||||||
|
version = "1.0.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "foreign-types"
|
name = "foreign-types"
|
||||||
version = "0.3.2"
|
version = "0.3.2"
|
||||||
@@ -127,6 +176,21 @@ dependencies = [
|
|||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "futures"
|
||||||
|
version = "0.3.34"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3"
|
||||||
|
dependencies = [
|
||||||
|
"futures-channel",
|
||||||
|
"futures-core",
|
||||||
|
"futures-executor",
|
||||||
|
"futures-io",
|
||||||
|
"futures-sink",
|
||||||
|
"futures-task",
|
||||||
|
"futures-util",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futures-channel"
|
name = "futures-channel"
|
||||||
version = "0.3.34"
|
version = "0.3.34"
|
||||||
@@ -134,6 +198,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4"
|
checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"futures-core",
|
"futures-core",
|
||||||
|
"futures-sink",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -142,6 +207,40 @@ version = "0.3.34"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
|
checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "futures-executor"
|
||||||
|
version = "0.3.34"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432"
|
||||||
|
dependencies = [
|
||||||
|
"futures-core",
|
||||||
|
"futures-task",
|
||||||
|
"futures-util",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "futures-io"
|
||||||
|
version = "0.3.34"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "futures-macro"
|
||||||
|
version = "0.3.34"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 3.0.9",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "futures-sink"
|
||||||
|
version = "0.3.34"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futures-task"
|
name = "futures-task"
|
||||||
version = "0.3.34"
|
version = "0.3.34"
|
||||||
@@ -154,8 +253,13 @@ version = "0.3.34"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
|
checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"futures-channel",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
|
"futures-io",
|
||||||
|
"futures-macro",
|
||||||
|
"futures-sink",
|
||||||
"futures-task",
|
"futures-task",
|
||||||
|
"memchr",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"slab",
|
"slab",
|
||||||
]
|
]
|
||||||
@@ -171,6 +275,37 @@ dependencies = [
|
|||||||
"r-efi",
|
"r-efi",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "h2"
|
||||||
|
version = "0.4.20"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7d29020232d6aa3fb1daca64c1127cf662cf97f254ae16c18c05b8ab635fc118"
|
||||||
|
dependencies = [
|
||||||
|
"atomic-waker",
|
||||||
|
"bytes",
|
||||||
|
"fnv",
|
||||||
|
"futures-core",
|
||||||
|
"futures-sink",
|
||||||
|
"http",
|
||||||
|
"indexmap",
|
||||||
|
"slab",
|
||||||
|
"tokio",
|
||||||
|
"tokio-util",
|
||||||
|
"tracing",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hashbrown"
|
||||||
|
version = "0.17.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hermit-abi"
|
||||||
|
version = "0.5.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "http"
|
name = "http"
|
||||||
version = "1.5.0"
|
version = "1.5.0"
|
||||||
@@ -210,6 +345,12 @@ version = "1.10.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
|
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "httpdate"
|
||||||
|
version = "1.0.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hyper"
|
name = "hyper"
|
||||||
version = "1.12.0"
|
version = "1.12.0"
|
||||||
@@ -218,10 +359,13 @@ checksum = "2c3e324da4c95177d6291d4c8730197c0d1822f8a9766814a4a44fa5ab797c9c"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"atomic-waker",
|
"atomic-waker",
|
||||||
"bytes",
|
"bytes",
|
||||||
|
"futures-channel",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
|
"h2",
|
||||||
"http",
|
"http",
|
||||||
"http-body",
|
"http-body",
|
||||||
"httparse",
|
"httparse",
|
||||||
|
"httpdate",
|
||||||
"itoa",
|
"itoa",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"smallvec",
|
"smallvec",
|
||||||
@@ -373,15 +517,28 @@ dependencies = [
|
|||||||
"icu_properties",
|
"icu_properties",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "indexmap"
|
||||||
|
version = "2.14.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
|
||||||
|
dependencies = [
|
||||||
|
"equivalent",
|
||||||
|
"hashbrown",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "invoiceninjars"
|
name = "invoiceninjars"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"percent-encoding",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"serde_urlencoded",
|
||||||
"thiserror",
|
"thiserror",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"wiremock",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -407,6 +564,12 @@ dependencies = [
|
|||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "lazy_static"
|
||||||
|
version = "1.5.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "libc"
|
name = "libc"
|
||||||
version = "0.2.191"
|
version = "0.2.191"
|
||||||
@@ -465,6 +628,16 @@ dependencies = [
|
|||||||
"tempfile",
|
"tempfile",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "num_cpus"
|
||||||
|
version = "1.17.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b"
|
||||||
|
dependencies = [
|
||||||
|
"hermit-abi",
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "once_cell"
|
name = "once_cell"
|
||||||
version = "1.21.4"
|
version = "1.21.4"
|
||||||
@@ -565,6 +738,35 @@ version = "6.0.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "regex"
|
||||||
|
version = "1.13.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d"
|
||||||
|
dependencies = [
|
||||||
|
"aho-corasick",
|
||||||
|
"memchr",
|
||||||
|
"regex-automata",
|
||||||
|
"regex-syntax",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "regex-automata"
|
||||||
|
version = "0.4.18"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2"
|
||||||
|
dependencies = [
|
||||||
|
"aho-corasick",
|
||||||
|
"memchr",
|
||||||
|
"regex-syntax",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "regex-syntax"
|
||||||
|
version = "0.8.11"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "reqwest"
|
name = "reqwest"
|
||||||
version = "0.12.28"
|
version = "0.12.28"
|
||||||
@@ -877,6 +1079,20 @@ dependencies = [
|
|||||||
"tokio",
|
"tokio",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tokio-util"
|
||||||
|
version = "0.7.20"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e464cf451ba96ebfc6f9b6542f17ee8b8956e33f1e40d9690624e59d7a7f8a4b"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"futures-core",
|
||||||
|
"futures-sink",
|
||||||
|
"libc",
|
||||||
|
"pin-project-lite",
|
||||||
|
"tokio",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tower"
|
name = "tower"
|
||||||
version = "0.5.3"
|
version = "0.5.3"
|
||||||
@@ -1073,6 +1289,29 @@ dependencies = [
|
|||||||
"windows-link",
|
"windows-link",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wiremock"
|
||||||
|
version = "0.6.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "08db1edfb05d9b3c1542e521aea074442088292f00b5f28e435c714a98f85031"
|
||||||
|
dependencies = [
|
||||||
|
"assert-json-diff",
|
||||||
|
"base64 0.22.1",
|
||||||
|
"deadpool",
|
||||||
|
"futures",
|
||||||
|
"http",
|
||||||
|
"http-body-util",
|
||||||
|
"hyper",
|
||||||
|
"hyper-util",
|
||||||
|
"log",
|
||||||
|
"once_cell",
|
||||||
|
"regex",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"tokio",
|
||||||
|
"url",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "writeable"
|
name = "writeable"
|
||||||
version = "0.6.4"
|
version = "0.6.4"
|
||||||
|
|||||||
@@ -11,6 +11,14 @@ reqwest = { version = "0.12", default-features = false, features = ["json", "nat
|
|||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
thiserror = "2"
|
thiserror = "2"
|
||||||
|
# Already in the tree via reqwest's `url`; used to escape ids before they are
|
||||||
|
# interpolated into request paths.
|
||||||
|
percent-encoding = "2"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
|
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
|
||||||
|
# Used to assert that ListParams urlencodes the way reqwest will send it.
|
||||||
|
serde_urlencoded = "0.7"
|
||||||
|
# Loopback HTTP server for exercising the request plumbing (pagination, path
|
||||||
|
# escaping, headers, error mapping) without reaching the network.
|
||||||
|
wiremock = "0.6"
|
||||||
|
|||||||
@@ -15,14 +15,33 @@ nix develop
|
|||||||
```rust
|
```rust
|
||||||
use invoiceninjars::{InvoiceNinjaClient, models::ListParams};
|
use invoiceninjars::{InvoiceNinjaClient, models::ListParams};
|
||||||
|
|
||||||
|
// Hosted app:
|
||||||
|
let client = InvoiceNinjaClient::hosted(std::env::var("IN_API_TOKEN")?)?;
|
||||||
|
|
||||||
|
// Self-hosted — note the base URL must include the `/api/v1` suffix:
|
||||||
let client = InvoiceNinjaClient::new(
|
let client = InvoiceNinjaClient::new(
|
||||||
"https://invoicing.co/api/v1", // or your self-hosted base URL
|
"https://your-domain.example/api/v1",
|
||||||
std::env::var("IN_API_TOKEN")?,
|
std::env::var("IN_API_TOKEN")?,
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
let clients = client.list_clients(&ListParams::new().per_page(50)).await?;
|
let clients = client.list_clients(&ListParams::new().per_page(50)).await?;
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`ListParams` has typed setters for the parameters every list endpoint
|
||||||
|
accepts, plus `param` for the per-resource filters:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let unpaid = client
|
||||||
|
.list_invoices(
|
||||||
|
&ListParams::new()
|
||||||
|
.per_page(100)
|
||||||
|
.sort("date|desc")
|
||||||
|
.param("client_id", "abc123")
|
||||||
|
.param("client_status", "unpaid"),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
```
|
||||||
|
|
||||||
List endpoints are paginated. `list_*` returns a single page; `list_all_*`
|
List endpoints are paginated. `list_*` returns a single page; `list_all_*`
|
||||||
follows pagination and returns every matching record:
|
follows pagination and returns every matching record:
|
||||||
|
|
||||||
@@ -30,6 +49,16 @@ follows pagination and returns every matching record:
|
|||||||
let all_clients = client.list_all_clients(&ListParams::new()).await?;
|
let all_clients = client.list_all_clients(&ListParams::new()).await?;
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Every request carries a 60-second deadline by default, since `reqwest`
|
||||||
|
applies none of its own. Override it for slow report exports or large
|
||||||
|
`list_all_*` walks:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
let client = InvoiceNinjaClient::hosted(token)?.with_timeout(Duration::from_secs(300));
|
||||||
|
```
|
||||||
|
|
||||||
Run the example (requires `IN_API_TOKEN`, optionally `IN_BASE_URL`):
|
Run the example (requires `IN_API_TOKEN`, optionally `IN_BASE_URL`):
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
@@ -38,7 +67,74 @@ IN_API_TOKEN=... cargo run --example list_clients
|
|||||||
|
|
||||||
## Coverage
|
## Coverage
|
||||||
|
|
||||||
Clients, Invoices, Quotes, Credits, Payments, Products, Expenses, Projects,
|
Every one of the API's 34 resource groups, plus users — 265 methods. Each
|
||||||
Tasks, and Vendors, each with `list`, `list_all`, `get`, `create`, `update`,
|
resource has `list`, `list_all`, `get`, `create`, `update`, `delete`, and
|
||||||
and `delete`. Add more resources by adding a model under `src/models/` and a
|
(where the API offers one) `bulk`:
|
||||||
|
|
||||||
|
| Area | Resources |
|
||||||
|
| --- | --- |
|
||||||
|
| Billing | Clients, Invoices, Quotes, Credits, Payments, Products, Purchase Orders |
|
||||||
|
| Recurring | Recurring Invoices, Recurring Quotes, Recurring Expenses, Subscriptions |
|
||||||
|
| Work | Projects, Tasks, Task Statuses, Task Schedulers |
|
||||||
|
| Spend | Expenses, Expense Categories, Vendors, Bank Transactions |
|
||||||
|
| Banking | Bank Integrations, Bank Transaction Rules, Company Gateways, Client Gateway Tokens |
|
||||||
|
| Admin | Companies, Users, Group Settings, Tokens, System Logs |
|
||||||
|
| Settings | Tax Rates, Payment Terms, Webhooks, Designs, Locations, Tags |
|
||||||
|
|
||||||
|
Plus documents, entity actions and PDF downloads, reports, charts, search,
|
||||||
|
statics, email sending, refunds, and health checks.
|
||||||
|
|
||||||
|
**See [COVERAGE.md](COVERAGE.md) for the full endpoint-by-endpoint
|
||||||
|
inventory**, including everything that is deliberately *not* wrapped and why.
|
||||||
|
|
||||||
|
Models cover a curated subset of each schema; any field the API returns that
|
||||||
|
isn't modeled explicitly (including `custom_value1..4` and
|
||||||
|
`custom_surcharge1..4`) is preserved in that model's `extra` map, so a `get`
|
||||||
|
followed by an `update` round-trips without dropping data.
|
||||||
|
|
||||||
|
### Beyond CRUD
|
||||||
|
|
||||||
|
Bulk actions return the affected records. They're available on every
|
||||||
|
resource the API gives a `/bulk` route — all but companies, system logs, and
|
||||||
|
client gateway tokens:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let archived = client.bulk_invoices("archive", &["abc123", "def456"]).await?;
|
||||||
|
```
|
||||||
|
|
||||||
|
Some endpoints — reading or writing an individual user, `/users/bulk`,
|
||||||
|
purging a client or company — sit behind the API's `password_protected`
|
||||||
|
middleware and need the account password on top of a valid token:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let client = InvoiceNinjaClient::hosted(std::env::var("IN_API_TOKEN")?)?
|
||||||
|
.with_api_password(std::env::var("IN_API_PASSWORD")?)?;
|
||||||
|
|
||||||
|
let user = client.get_user("abc123").await?;
|
||||||
|
```
|
||||||
|
|
||||||
|
Invoices, quotes, credits, and purchase orders also support per-entity
|
||||||
|
actions and PDF rendering:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let invoice = client.mark_invoice_sent("abc123").await?;
|
||||||
|
let pdf: Vec<u8> = client.download_invoice("abc123").await?;
|
||||||
|
|
||||||
|
// The bulk `download` action streams one merged PDF. Available for quotes,
|
||||||
|
// credits, and purchase orders too.
|
||||||
|
let merged: Vec<u8> = client.download_invoices(&["abc123", "def456"]).await?;
|
||||||
|
```
|
||||||
|
|
||||||
|
Reports return their raw export body (CSV for most of them), and the
|
||||||
|
schemaless endpoints decode into any type you choose:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
|
let csv: Vec<u8> = client.report("invoices", &json!({"date_range": "this_year"})).await?;
|
||||||
|
let totals: Value = client.chart("totals_v2", &json!({})).await?;
|
||||||
|
let statics: Value = client.statics().await?;
|
||||||
|
```
|
||||||
|
|
||||||
|
Add more resources by adding a model under `src/models/` and a
|
||||||
`resource_methods!` invocation in `src/client.rs`.
|
`resource_methods!` invocation in `src/client.rs`.
|
||||||
|
|||||||
+856
-23
File diff suppressed because it is too large
Load Diff
@@ -30,6 +30,26 @@ pub enum Error {
|
|||||||
|
|
||||||
#[error("invalid api token: {0}")]
|
#[error("invalid api token: {0}")]
|
||||||
InvalidToken(String),
|
InvalidToken(String),
|
||||||
|
|
||||||
|
/// An id or action could not be used as a single URL path segment.
|
||||||
|
///
|
||||||
|
/// Raised for values that would change which endpoint is addressed rather
|
||||||
|
/// than identifying a record — an empty id (which would hit the
|
||||||
|
/// collection route) or one made only of dots (which the URL parser
|
||||||
|
/// resolves away). Invoice Ninja ids are hashids, so this only rejects
|
||||||
|
/// input that was never a valid id.
|
||||||
|
#[error("`{0}` cannot be used as a url path segment")]
|
||||||
|
InvalidPathSegment(String),
|
||||||
|
|
||||||
|
/// A `list_all_*` call kept receiving full pages past the point where any
|
||||||
|
/// plausible result set would have ended.
|
||||||
|
///
|
||||||
|
/// This means the server is not paginating the way the client expects —
|
||||||
|
/// typically because it ignores the `page` parameter and keeps replaying
|
||||||
|
/// the same page. Raised instead of returning the partial result, so the
|
||||||
|
/// truncation cannot be mistaken for a complete answer.
|
||||||
|
#[error("pagination did not terminate for {path} after {pages} pages")]
|
||||||
|
PaginationLimit { path: String, pages: u64 },
|
||||||
}
|
}
|
||||||
|
|
||||||
pub type Result<T> = std::result::Result<T, Error>;
|
pub type Result<T> = std::result::Result<T, Error>;
|
||||||
|
|||||||
+1
-1
@@ -18,5 +18,5 @@ mod client;
|
|||||||
mod error;
|
mod error;
|
||||||
pub mod models;
|
pub mod models;
|
||||||
|
|
||||||
pub use client::InvoiceNinjaClient;
|
pub use client::{InvoiceNinjaClient, HOSTED_BASE_URL};
|
||||||
pub use error::{ApiErrorBody, Error, Result};
|
pub use error::{ApiErrorBody, Error, Result};
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::{Map, Value};
|
||||||
|
|
||||||
|
/// A connected bank account feed.
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
|
pub struct BankIntegration {
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub id: Option<String>,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub user_id: String,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub provider_bank_name: String,
|
||||||
|
/// Returned as an integer rather than a hashed id.
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub bank_account_id: i64,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub bank_account_name: String,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub bank_account_number: String,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub bank_account_status: String,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub bank_account_type: String,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub balance: f64,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub currency: String,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub is_deleted: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub archived_at: Option<i64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub created_at: Option<i64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub updated_at: Option<i64>,
|
||||||
|
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub extra: Map<String, Value>,
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::{Map, Value};
|
||||||
|
|
||||||
|
/// A transaction imported from a connected bank account.
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
|
pub struct BankTransaction {
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub id: Option<String>,
|
||||||
|
/// The upstream transaction identifier, returned as an integer rather
|
||||||
|
/// than a hashed id.
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub transaction_id: i64,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub bank_account_id: i64,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub amount: f64,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub currency_id: String,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub account_type: String,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub description: String,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub category_id: i64,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub category_type: String,
|
||||||
|
/// Either `CREDIT` or `DEBIT`.
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub base_type: String,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub date: String,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub is_deleted: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub archived_at: Option<i64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub created_at: Option<i64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub updated_at: Option<i64>,
|
||||||
|
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub extra: Map<String, Value>,
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::{Map, Value};
|
||||||
|
|
||||||
|
/// A rule for auto-categorising imported bank transactions.
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
|
pub struct BankTransactionRule {
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub id: Option<String>,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub user_id: String,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub name: String,
|
||||||
|
/// Match clauses. Left untyped: the shape is a nested
|
||||||
|
/// `search_key`/`operator`/`value` object the spec does not define.
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub rules: Vec<Value>,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub auto_convert: bool,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub matches_on_all: bool,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub applies_to: String,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub client_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 category_id: String,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub is_deleted: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub archived_at: Option<i64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub created_at: Option<i64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub updated_at: Option<i64>,
|
||||||
|
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub extra: Map<String, Value>,
|
||||||
|
}
|
||||||
+22
-22
@@ -5,37 +5,37 @@ use serde_json::{Map, Value};
|
|||||||
pub struct Client {
|
pub struct Client {
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub id: Option<String>,
|
pub id: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
pub name: String,
|
pub name: String,
|
||||||
#[serde(default)]
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
pub id_number: String,
|
pub id_number: String,
|
||||||
#[serde(default)]
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
pub vat_number: String,
|
pub vat_number: String,
|
||||||
#[serde(default)]
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
pub address1: String,
|
pub address1: String,
|
||||||
#[serde(default)]
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
pub address2: String,
|
pub address2: String,
|
||||||
#[serde(default)]
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
pub city: String,
|
pub city: String,
|
||||||
#[serde(default)]
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
pub state: String,
|
pub state: String,
|
||||||
#[serde(default)]
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
pub postal_code: String,
|
pub postal_code: String,
|
||||||
#[serde(default)]
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
pub country_id: String,
|
pub country_id: String,
|
||||||
#[serde(default)]
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
pub phone: String,
|
pub phone: String,
|
||||||
#[serde(default)]
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
pub website: String,
|
pub website: String,
|
||||||
#[serde(default)]
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
pub private_notes: String,
|
pub private_notes: String,
|
||||||
#[serde(default)]
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
pub balance: f64,
|
pub balance: f64,
|
||||||
#[serde(default)]
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
pub paid_to_date: f64,
|
pub paid_to_date: f64,
|
||||||
#[serde(default)]
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
pub credit_balance: f64,
|
pub credit_balance: f64,
|
||||||
#[serde(default)]
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
pub is_deleted: bool,
|
pub is_deleted: bool,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub archived_at: Option<i64>,
|
pub archived_at: Option<i64>,
|
||||||
@@ -43,7 +43,7 @@ pub struct Client {
|
|||||||
pub created_at: Option<i64>,
|
pub created_at: Option<i64>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub updated_at: Option<i64>,
|
pub updated_at: Option<i64>,
|
||||||
#[serde(default)]
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
pub contacts: Vec<ClientContact>,
|
pub contacts: Vec<ClientContact>,
|
||||||
|
|
||||||
/// Any fields returned by the API that aren't modeled above are preserved here,
|
/// Any fields returned by the API that aren't modeled above are preserved here,
|
||||||
@@ -56,15 +56,15 @@ pub struct Client {
|
|||||||
pub struct ClientContact {
|
pub struct ClientContact {
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub id: Option<String>,
|
pub id: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
pub first_name: String,
|
pub first_name: String,
|
||||||
#[serde(default)]
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
pub last_name: String,
|
pub last_name: String,
|
||||||
#[serde(default)]
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
pub email: String,
|
pub email: String,
|
||||||
#[serde(default)]
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
pub phone: String,
|
pub phone: String,
|
||||||
#[serde(default)]
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
pub is_primary: bool,
|
pub is_primary: bool,
|
||||||
|
|
||||||
#[serde(flatten)]
|
#[serde(flatten)]
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::{Map, Value};
|
||||||
|
|
||||||
|
/// A stored payment method for a client.
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
|
pub struct ClientGatewayToken {
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub id: Option<String>,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub client_id: String,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub token: String,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub routing_number: String,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub company_gateway_id: String,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub is_default: bool,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub is_deleted: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub archived_at: Option<i64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub created_at: Option<i64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub updated_at: Option<i64>,
|
||||||
|
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub extra: Map<String, Value>,
|
||||||
|
}
|
||||||
+72
-1
@@ -1,7 +1,12 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Deserializer, Serialize};
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
pub struct ListResponse<T> {
|
pub struct ListResponse<T> {
|
||||||
|
/// Defaulted because not every list-shaped response carries a `data` key:
|
||||||
|
/// a bulk action whose `ids` match nothing returns HTTP 200 with just
|
||||||
|
/// `{"message": "No Invoices Found"}`, which should read as "no results"
|
||||||
|
/// rather than a decode failure.
|
||||||
|
#[serde(default = "Vec::new")]
|
||||||
pub data: Vec<T>,
|
pub data: Vec<T>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub meta: Option<Meta>,
|
pub meta: Option<Meta>,
|
||||||
@@ -44,6 +49,19 @@ pub struct ListParams {
|
|||||||
/// Set to 1 to include archived/deleted records.
|
/// Set to 1 to include archived/deleted records.
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub status: Option<String>,
|
pub status: Option<String>,
|
||||||
|
|
||||||
|
/// Extra query parameters appended verbatim.
|
||||||
|
///
|
||||||
|
/// The typed fields above are only the parameters common to every list
|
||||||
|
/// endpoint; individual resources accept many more (`client_id`,
|
||||||
|
/// `vendor_id`, `number`, `created_at`, `is_deleted`, per-resource
|
||||||
|
/// filters). Use [`param`](Self::param) to set those.
|
||||||
|
///
|
||||||
|
/// Skipped by serde and applied as a separate query pass, because
|
||||||
|
/// `serde_urlencoded` — which backs `reqwest`'s query serialization —
|
||||||
|
/// does not handle `#[serde(flatten)]`ed maps.
|
||||||
|
#[serde(skip)]
|
||||||
|
pub custom: Vec<(String, String)>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ListParams {
|
impl ListParams {
|
||||||
@@ -80,4 +98,57 @@ impl ListParams {
|
|||||||
self.status = Some(status.into());
|
self.status = Some(status.into());
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Adds an arbitrary query parameter, for filters the typed setters do
|
||||||
|
/// not cover.
|
||||||
|
///
|
||||||
|
/// ```
|
||||||
|
/// use invoiceninjars::models::ListParams;
|
||||||
|
///
|
||||||
|
/// let params = ListParams::new()
|
||||||
|
/// .per_page(100)
|
||||||
|
/// .param("client_id", "abc123")
|
||||||
|
/// .param("is_deleted", "false");
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// Can be called more than once with the same key; both pairs are sent.
|
||||||
|
pub fn param(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
|
||||||
|
self.custom.push((key.into(), value.into()));
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deserializes `null` as `T::default()` instead of failing.
|
||||||
|
///
|
||||||
|
/// `#[serde(default)]` alone only covers a *missing* key — a key that is
|
||||||
|
/// present with an explicit `null` still errors for non-`Option` types.
|
||||||
|
/// Invoice Ninja returns `null` for plenty of nominally-string fields, so the
|
||||||
|
/// models pair `default` with this to tolerate both shapes.
|
||||||
|
pub(crate) fn null_to_default<'de, D, T>(deserializer: D) -> Result<T, D::Error>
|
||||||
|
where
|
||||||
|
D: Deserializer<'de>,
|
||||||
|
T: Deserialize<'de> + Default,
|
||||||
|
{
|
||||||
|
Ok(Option::<T>::deserialize(deserializer)?.unwrap_or_default())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Body for a bulk action (`POST /{resource}/bulk`).
|
||||||
|
///
|
||||||
|
/// The action set varies by resource; `archive`, `restore`, and `delete` work
|
||||||
|
/// everywhere, and are what the generated `bulk_*` methods are typed for.
|
||||||
|
/// See [`crate::InvoiceNinjaClient::bulk_invoices`] for the actions whose
|
||||||
|
/// responses are *not* a list of entities.
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct BulkRequest {
|
||||||
|
pub action: String,
|
||||||
|
pub ids: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BulkRequest {
|
||||||
|
pub fn new(action: impl Into<String>, ids: &[impl AsRef<str>]) -> Self {
|
||||||
|
Self {
|
||||||
|
action: action.into(),
|
||||||
|
ids: ids.iter().map(|id| id.as_ref().to_string()).collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::{Map, Value};
|
||||||
|
|
||||||
|
/// A company (tenant).
|
||||||
|
///
|
||||||
|
/// The API returns well over a hundred fields here; this models the
|
||||||
|
/// identifying and structural ones and leaves the rest — including every
|
||||||
|
/// `enable_*`/`show_*` feature toggle — to [`extra`](Self::extra), which
|
||||||
|
/// still round-trips them on update.
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
|
pub struct Company {
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub id: Option<String>,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub company_key: String,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub size_id: String,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub industry_id: String,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub portal_mode: String,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub subdomain: String,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub portal_domain: String,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub logo: String,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub db: String,
|
||||||
|
/// The company settings blob. Left untyped — `CompanySettings` is the
|
||||||
|
/// largest object in the API.
|
||||||
|
#[serde(default)]
|
||||||
|
pub settings: Value,
|
||||||
|
/// Custom field label definitions.
|
||||||
|
#[serde(default)]
|
||||||
|
pub custom_fields: Value,
|
||||||
|
/// Bitmask of enabled modules.
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub enabled_modules: i64,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub enabled_tax_rates: i64,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub enabled_item_tax_rates: i64,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub enabled_expense_tax_rates: i64,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub first_day_of_week: String,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub first_month_of_year: String,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub slack_webhook_url: String,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub google_analytics_key: String,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub session_timeout: i64,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub markdown_enabled: bool,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub client_can_register: bool,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub is_large: bool,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub is_disabled: bool,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub calculate_taxes: bool,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub is_deleted: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub archived_at: Option<i64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub created_at: Option<i64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub updated_at: Option<i64>,
|
||||||
|
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub extra: Map<String, Value>,
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::{Map, Value};
|
||||||
|
|
||||||
|
/// A configured payment gateway.
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
|
pub struct CompanyGateway {
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub id: Option<String>,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub gateway_key: String,
|
||||||
|
/// Bitmask of accepted card types.
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub accepted_credit_cards: i64,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub require_billing_address: bool,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub require_shipping_address: bool,
|
||||||
|
/// An encrypted JSON *string*, not an object — decrypted server-side, so
|
||||||
|
/// it is opaque here.
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub config: String,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub update_details: bool,
|
||||||
|
/// Per-card fee and limit rules; shape varies by gateway.
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub fees_and_limits: Vec<Value>,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub is_deleted: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub archived_at: Option<i64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub created_at: Option<i64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub updated_at: Option<i64>,
|
||||||
|
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub extra: Map<String, Value>,
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::{Map, Value};
|
||||||
|
|
||||||
|
/// An API token. Served from `/tokens`.
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
|
pub struct CompanyToken {
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub id: Option<String>,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub name: String,
|
||||||
|
/// Only returned in full when the token is first created.
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub token: String,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub is_system: bool,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub is_deleted: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub archived_at: Option<i64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub created_at: Option<i64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub updated_at: Option<i64>,
|
||||||
|
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub extra: Map<String, Value>,
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::{Map, Value};
|
||||||
|
|
||||||
|
/// A PDF template.
|
||||||
|
///
|
||||||
|
/// The `design` field itself (the HTML section bodies) is left to
|
||||||
|
/// [`extra`](Self::extra): the spec types it as a string, but the API
|
||||||
|
/// returns a nested object of `header`/`body`/`footer` sections.
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
|
pub struct Design {
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub id: Option<String>,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub name: String,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub is_custom: bool,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub is_active: bool,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub is_deleted: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub archived_at: Option<i64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub created_at: Option<i64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub updated_at: Option<i64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub deleted_at: Option<i64>,
|
||||||
|
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub extra: Map<String, Value>,
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::{Map, Value};
|
||||||
|
|
||||||
|
/// A document (file) attached to another entity.
|
||||||
|
///
|
||||||
|
/// Documents are created by uploading to the owning entity
|
||||||
|
/// (`PUT /{resource}/{id}/upload`, multipart) rather than by posting to
|
||||||
|
/// `/documents`, so this crate exposes read, update, delete, and download
|
||||||
|
/// but no `create_document`.
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
|
pub struct Document {
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub id: Option<String>,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub user_id: String,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub assigned_user_id: String,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub project_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 name: String,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub url: String,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub preview: String,
|
||||||
|
/// File extension, e.g. `pdf`.
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub r#type: String,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub disk: String,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub hash: String,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub is_deleted: bool,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub is_default: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub created_at: Option<i64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub updated_at: Option<i64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub deleted_at: Option<i64>,
|
||||||
|
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub extra: Map<String, Value>,
|
||||||
|
}
|
||||||
+17
-17
@@ -5,39 +5,39 @@ use serde_json::{Map, Value};
|
|||||||
pub struct Expense {
|
pub struct Expense {
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub id: Option<String>,
|
pub id: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
pub vendor_id: String,
|
pub vendor_id: String,
|
||||||
#[serde(default)]
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
pub client_id: String,
|
pub client_id: String,
|
||||||
#[serde(default)]
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
pub invoice_id: String,
|
pub invoice_id: String,
|
||||||
#[serde(default)]
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
pub amount: f64,
|
pub amount: f64,
|
||||||
#[serde(default)]
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
pub exchange_rate: f64,
|
pub exchange_rate: f64,
|
||||||
#[serde(default)]
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
pub public_notes: String,
|
pub public_notes: String,
|
||||||
#[serde(default)]
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
pub private_notes: String,
|
pub private_notes: String,
|
||||||
#[serde(default)]
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
pub date: String,
|
pub date: String,
|
||||||
#[serde(default)]
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
pub payment_date: String,
|
pub payment_date: String,
|
||||||
#[serde(default)]
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
pub transaction_reference: String,
|
pub transaction_reference: String,
|
||||||
#[serde(default)]
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
pub category_id: String,
|
pub category_id: String,
|
||||||
#[serde(default)]
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
pub tax_name1: String,
|
pub tax_name1: String,
|
||||||
#[serde(default)]
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
pub tax_rate1: f64,
|
pub tax_rate1: f64,
|
||||||
#[serde(default)]
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
pub tax_name2: String,
|
pub tax_name2: String,
|
||||||
#[serde(default)]
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
pub tax_rate2: f64,
|
pub tax_rate2: f64,
|
||||||
#[serde(default)]
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
pub should_be_invoiced: bool,
|
pub should_be_invoiced: bool,
|
||||||
#[serde(default)]
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
pub is_deleted: bool,
|
pub is_deleted: bool,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub archived_at: Option<i64>,
|
pub archived_at: Option<i64>,
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::{Map, Value};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
|
pub struct ExpenseCategory {
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub id: Option<String>,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub name: String,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub user_id: String,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub is_deleted: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub archived_at: Option<i64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub created_at: Option<i64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub updated_at: Option<i64>,
|
||||||
|
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub extra: Map<String, Value>,
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::{Map, Value};
|
||||||
|
|
||||||
|
/// A named settings group that clients can be assigned to.
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
|
pub struct GroupSetting {
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub id: Option<String>,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub user_id: String,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub name: String,
|
||||||
|
/// The settings override blob. Left untyped — Invoice Ninja's
|
||||||
|
/// `CompanySettings` object has well over a hundred fields.
|
||||||
|
#[serde(default)]
|
||||||
|
pub settings: Value,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub is_deleted: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub archived_at: Option<i64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub created_at: Option<i64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub updated_at: Option<i64>,
|
||||||
|
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub extra: Map<String, Value>,
|
||||||
|
}
|
||||||
+26
-26
@@ -5,35 +5,35 @@ use serde_json::{Map, Value};
|
|||||||
pub struct Invoice {
|
pub struct Invoice {
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub id: Option<String>,
|
pub id: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
pub client_id: String,
|
pub client_id: String,
|
||||||
#[serde(default)]
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
pub number: String,
|
pub number: String,
|
||||||
#[serde(default)]
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
pub date: String,
|
pub date: String,
|
||||||
#[serde(default)]
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
pub due_date: String,
|
pub due_date: String,
|
||||||
#[serde(default)]
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
pub po_number: String,
|
pub po_number: String,
|
||||||
#[serde(default)]
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
pub status_id: String,
|
pub status_id: String,
|
||||||
#[serde(default)]
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
pub amount: f64,
|
pub amount: f64,
|
||||||
#[serde(default)]
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
pub balance: f64,
|
pub balance: f64,
|
||||||
#[serde(default)]
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
pub discount: f64,
|
pub discount: f64,
|
||||||
#[serde(default)]
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
pub is_amount_discount: bool,
|
pub is_amount_discount: bool,
|
||||||
#[serde(default)]
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
pub public_notes: String,
|
pub public_notes: String,
|
||||||
#[serde(default)]
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
pub private_notes: String,
|
pub private_notes: String,
|
||||||
#[serde(default)]
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
pub terms: String,
|
pub terms: String,
|
||||||
#[serde(default)]
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
pub footer: String,
|
pub footer: String,
|
||||||
#[serde(default)]
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
pub is_deleted: bool,
|
pub is_deleted: bool,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub archived_at: Option<i64>,
|
pub archived_at: Option<i64>,
|
||||||
@@ -41,7 +41,7 @@ pub struct Invoice {
|
|||||||
pub created_at: Option<i64>,
|
pub created_at: Option<i64>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub updated_at: Option<i64>,
|
pub updated_at: Option<i64>,
|
||||||
#[serde(default)]
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
pub line_items: Vec<InvoiceItem>,
|
pub line_items: Vec<InvoiceItem>,
|
||||||
|
|
||||||
#[serde(flatten)]
|
#[serde(flatten)]
|
||||||
@@ -50,25 +50,25 @@ pub struct Invoice {
|
|||||||
|
|
||||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
pub struct InvoiceItem {
|
pub struct InvoiceItem {
|
||||||
#[serde(default)]
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
pub product_key: String,
|
pub product_key: String,
|
||||||
#[serde(default)]
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
pub notes: String,
|
pub notes: String,
|
||||||
#[serde(default)]
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
pub cost: f64,
|
pub cost: f64,
|
||||||
#[serde(default)]
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
pub quantity: f64,
|
pub quantity: f64,
|
||||||
#[serde(default)]
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
pub discount: f64,
|
pub discount: f64,
|
||||||
#[serde(default)]
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
pub tax_name1: String,
|
pub tax_name1: String,
|
||||||
#[serde(default)]
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
pub tax_rate1: f64,
|
pub tax_rate1: f64,
|
||||||
#[serde(default)]
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
pub tax_name2: String,
|
pub tax_name2: String,
|
||||||
#[serde(default)]
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
pub tax_rate2: f64,
|
pub tax_rate2: f64,
|
||||||
#[serde(default)]
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
pub line_total: f64,
|
pub line_total: f64,
|
||||||
|
|
||||||
#[serde(flatten)]
|
#[serde(flatten)]
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::{Map, Value};
|
||||||
|
|
||||||
|
/// A physical address attached to a client or vendor.
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
|
pub struct Location {
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub id: Option<String>,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub name: String,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub address1: String,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub address2: String,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub city: String,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub state: String,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub postal_code: String,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub country_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 assigned_user_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 vendor_id: String,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub is_shipping_location: bool,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub is_deleted: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub archived_at: Option<i64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub created_at: Option<i64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub updated_at: Option<i64>,
|
||||||
|
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub extra: Map<String, Value>,
|
||||||
|
}
|
||||||
+49
-1
@@ -1,19 +1,67 @@
|
|||||||
|
mod bank_integration;
|
||||||
|
mod bank_transaction;
|
||||||
|
mod bank_transaction_rule;
|
||||||
mod client;
|
mod client;
|
||||||
|
mod client_gateway_token;
|
||||||
mod common;
|
mod common;
|
||||||
|
mod company;
|
||||||
|
mod company_gateway;
|
||||||
|
mod company_token;
|
||||||
|
mod design;
|
||||||
|
mod document;
|
||||||
mod expense;
|
mod expense;
|
||||||
|
mod expense_category;
|
||||||
|
mod group_setting;
|
||||||
mod invoice;
|
mod invoice;
|
||||||
|
mod location;
|
||||||
mod payment;
|
mod payment;
|
||||||
|
mod payment_term;
|
||||||
mod product;
|
mod product;
|
||||||
mod project;
|
mod project;
|
||||||
|
mod purchase_order;
|
||||||
|
mod recurring_expense;
|
||||||
|
mod recurring_invoice;
|
||||||
|
mod subscription;
|
||||||
|
mod system_log;
|
||||||
|
mod tag;
|
||||||
mod task;
|
mod task;
|
||||||
|
mod task_scheduler;
|
||||||
|
mod task_status;
|
||||||
|
mod tax_rate;
|
||||||
|
mod user;
|
||||||
mod vendor;
|
mod vendor;
|
||||||
|
mod webhook;
|
||||||
|
|
||||||
|
pub use bank_integration::BankIntegration;
|
||||||
|
pub use bank_transaction::BankTransaction;
|
||||||
|
pub use bank_transaction_rule::BankTransactionRule;
|
||||||
pub use client::{Client, ClientContact};
|
pub use client::{Client, ClientContact};
|
||||||
pub use common::{ItemResponse, ListParams, ListResponse, Meta, Pagination};
|
pub use client_gateway_token::ClientGatewayToken;
|
||||||
|
pub use common::{BulkRequest, ItemResponse, ListParams, ListResponse, Meta, Pagination};
|
||||||
|
pub use company::Company;
|
||||||
|
pub use company_gateway::CompanyGateway;
|
||||||
|
pub use company_token::CompanyToken;
|
||||||
|
pub use design::Design;
|
||||||
|
pub use document::Document;
|
||||||
pub use expense::Expense;
|
pub use expense::Expense;
|
||||||
|
pub use expense_category::ExpenseCategory;
|
||||||
|
pub use group_setting::GroupSetting;
|
||||||
pub use invoice::{Credit, Invoice, InvoiceItem, Quote};
|
pub use invoice::{Credit, Invoice, InvoiceItem, Quote};
|
||||||
|
pub use location::Location;
|
||||||
pub use payment::{Payment, PaymentInvoice};
|
pub use payment::{Payment, PaymentInvoice};
|
||||||
|
pub use payment_term::PaymentTerm;
|
||||||
pub use product::Product;
|
pub use product::Product;
|
||||||
pub use project::Project;
|
pub use project::Project;
|
||||||
|
pub use purchase_order::PurchaseOrder;
|
||||||
|
pub use recurring_expense::RecurringExpense;
|
||||||
|
pub use recurring_invoice::{RecurringInvoice, RecurringQuote};
|
||||||
|
pub use subscription::Subscription;
|
||||||
|
pub use system_log::SystemLog;
|
||||||
|
pub use tag::Tag;
|
||||||
pub use task::Task;
|
pub use task::Task;
|
||||||
|
pub use task_scheduler::TaskScheduler;
|
||||||
|
pub use task_status::TaskStatus;
|
||||||
|
pub use tax_rate::TaxRate;
|
||||||
|
pub use user::User;
|
||||||
pub use vendor::{Vendor, VendorContact};
|
pub use vendor::{Vendor, VendorContact};
|
||||||
|
pub use webhook::Webhook;
|
||||||
|
|||||||
+10
-10
@@ -5,23 +5,23 @@ use serde_json::{Map, Value};
|
|||||||
pub struct Payment {
|
pub struct Payment {
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub id: Option<String>,
|
pub id: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
pub client_id: String,
|
pub client_id: String,
|
||||||
#[serde(default)]
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
pub amount: f64,
|
pub amount: f64,
|
||||||
#[serde(default)]
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
pub applied: f64,
|
pub applied: f64,
|
||||||
#[serde(default)]
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
pub refunded: f64,
|
pub refunded: f64,
|
||||||
#[serde(default)]
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
pub date: String,
|
pub date: String,
|
||||||
#[serde(default)]
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
pub transaction_reference: String,
|
pub transaction_reference: String,
|
||||||
#[serde(default)]
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
pub type_id: String,
|
pub type_id: String,
|
||||||
#[serde(default)]
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
pub private_notes: String,
|
pub private_notes: String,
|
||||||
#[serde(default)]
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
pub is_deleted: bool,
|
pub is_deleted: bool,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub archived_at: Option<i64>,
|
pub archived_at: Option<i64>,
|
||||||
@@ -29,7 +29,7 @@ pub struct Payment {
|
|||||||
pub created_at: Option<i64>,
|
pub created_at: Option<i64>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub updated_at: Option<i64>,
|
pub updated_at: Option<i64>,
|
||||||
#[serde(default)]
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
pub invoices: Vec<PaymentInvoice>,
|
pub invoices: Vec<PaymentInvoice>,
|
||||||
|
|
||||||
#[serde(flatten)]
|
#[serde(flatten)]
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::{Map, Value};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
|
pub struct PaymentTerm {
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub id: Option<String>,
|
||||||
|
/// Human-readable form of the term, e.g. `NET 30`.
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub name: String,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub num_days: i64,
|
||||||
|
#[serde(default)]
|
||||||
|
pub archived_at: Option<i64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub created_at: Option<i64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub updated_at: Option<i64>,
|
||||||
|
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub extra: Map<String, Value>,
|
||||||
|
}
|
||||||
+10
-10
@@ -5,25 +5,25 @@ use serde_json::{Map, Value};
|
|||||||
pub struct Product {
|
pub struct Product {
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub id: Option<String>,
|
pub id: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
pub product_key: String,
|
pub product_key: String,
|
||||||
#[serde(default)]
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
pub notes: String,
|
pub notes: String,
|
||||||
#[serde(default)]
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
pub cost: f64,
|
pub cost: f64,
|
||||||
#[serde(default)]
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
pub price: f64,
|
pub price: f64,
|
||||||
#[serde(default)]
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
pub quantity: f64,
|
pub quantity: f64,
|
||||||
#[serde(default)]
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
pub tax_name1: String,
|
pub tax_name1: String,
|
||||||
#[serde(default)]
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
pub tax_rate1: f64,
|
pub tax_rate1: f64,
|
||||||
#[serde(default)]
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
pub tax_name2: String,
|
pub tax_name2: String,
|
||||||
#[serde(default)]
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
pub tax_rate2: f64,
|
pub tax_rate2: f64,
|
||||||
#[serde(default)]
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
pub is_deleted: bool,
|
pub is_deleted: bool,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub archived_at: Option<i64>,
|
pub archived_at: Option<i64>,
|
||||||
|
|||||||
+10
-10
@@ -5,25 +5,25 @@ use serde_json::{Map, Value};
|
|||||||
pub struct Project {
|
pub struct Project {
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub id: Option<String>,
|
pub id: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
pub client_id: String,
|
pub client_id: String,
|
||||||
#[serde(default)]
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
pub name: String,
|
pub name: String,
|
||||||
#[serde(default)]
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
pub number: String,
|
pub number: String,
|
||||||
#[serde(default)]
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
pub task_rate: f64,
|
pub task_rate: f64,
|
||||||
#[serde(default)]
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
pub due_date: String,
|
pub due_date: String,
|
||||||
#[serde(default)]
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
pub budgeted_hours: f64,
|
pub budgeted_hours: f64,
|
||||||
#[serde(default)]
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
pub color: String,
|
pub color: String,
|
||||||
#[serde(default)]
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
pub public_notes: String,
|
pub public_notes: String,
|
||||||
#[serde(default)]
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
pub private_notes: String,
|
pub private_notes: String,
|
||||||
#[serde(default)]
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
pub is_deleted: bool,
|
pub is_deleted: bool,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub archived_at: Option<i64>,
|
pub archived_at: Option<i64>,
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::{Map, Value};
|
||||||
|
|
||||||
|
use super::invoice::InvoiceItem;
|
||||||
|
|
||||||
|
/// A purchase order. Structurally close to an invoice, but addressed to a
|
||||||
|
/// vendor rather than a client.
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
|
pub struct PurchaseOrder {
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub id: Option<String>,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub vendor_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 invoice_id: String,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub project_id: String,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub number: String,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub po_number: String,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub quote_number: String,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub status_id: String,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub date: String,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub due_date: String,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub partial_due_date: String,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub last_sent_date: String,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub amount: f64,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub balance: f64,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub paid_to_date: f64,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub discount: f64,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub partial: f64,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub exchange_rate: f64,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub is_amount_discount: bool,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub uses_inclusive_taxes: bool,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub public_notes: String,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub private_notes: String,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub terms: String,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub footer: String,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub currency_id: String,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub design_id: String,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub is_deleted: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub last_viewed: Option<i64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub archived_at: Option<i64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub created_at: Option<i64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub updated_at: Option<i64>,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub line_items: Vec<InvoiceItem>,
|
||||||
|
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub extra: Map<String, Value>,
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::{Map, Value};
|
||||||
|
|
||||||
|
/// A recurring expense template: an expense plus the recurrence fields
|
||||||
|
/// (`frequency_id`, `remaining_cycles`, `next_send_date`).
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
|
pub struct RecurringExpense {
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub id: Option<String>,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub client_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 invoice_id: String,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub project_id: String,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub category_id: String,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub number: String,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub amount: f64,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub foreign_amount: f64,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub exchange_rate: f64,
|
||||||
|
/// Integer enum matching [`RecurringInvoice::frequency_id`].
|
||||||
|
///
|
||||||
|
/// [`RecurringInvoice::frequency_id`]: super::RecurringInvoice::frequency_id
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub frequency_id: i64,
|
||||||
|
/// Number of cycles left; `-1` means it recurs indefinitely.
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub remaining_cycles: i64,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub date: String,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub payment_date: String,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub last_sent_date: String,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub next_send_date: String,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub public_notes: String,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub private_notes: String,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub transaction_reference: String,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub tax_name1: String,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub tax_rate1: f64,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub tax_name2: String,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub tax_rate2: f64,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub tax_name3: String,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub tax_rate3: f64,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub should_be_invoiced: bool,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub uses_inclusive_taxes: bool,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub is_deleted: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub archived_at: Option<i64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub created_at: Option<i64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub updated_at: Option<i64>,
|
||||||
|
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub extra: Map<String, Value>,
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::{Map, Value};
|
||||||
|
|
||||||
|
use super::invoice::InvoiceItem;
|
||||||
|
|
||||||
|
/// A recurring invoice template. Shares the invoices table with [`Invoice`],
|
||||||
|
/// plus the recurrence fields (`frequency_id`, `remaining_cycles`,
|
||||||
|
/// `next_send_date`, auto-billing).
|
||||||
|
///
|
||||||
|
/// [`Invoice`]: super::Invoice
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
|
pub struct RecurringInvoice {
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub id: Option<String>,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub client_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 project_id: String,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub number: String,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub po_number: String,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub status_id: String,
|
||||||
|
/// How often the invoice recurs. Invoice Ninja returns this as an integer
|
||||||
|
/// enum (1 = daily, 2 = weekly, … ), not a human-readable string.
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub frequency_id: i64,
|
||||||
|
/// Number of cycles left to send; `-1` means it recurs indefinitely.
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub remaining_cycles: i64,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub auto_bill: String,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub auto_bill_enabled: bool,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub date: String,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub due_date: String,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub next_send_date: String,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub last_sent_date: String,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub amount: f64,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub balance: f64,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub paid_to_date: f64,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub discount: f64,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub partial: f64,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub exchange_rate: f64,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub is_amount_discount: bool,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub uses_inclusive_taxes: bool,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub public_notes: String,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub private_notes: String,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub terms: String,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub footer: String,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub design_id: String,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub is_deleted: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub last_viewed: Option<i64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub archived_at: Option<i64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub created_at: Option<i64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub updated_at: Option<i64>,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub line_items: Vec<InvoiceItem>,
|
||||||
|
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub extra: Map<String, Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recurring quotes share the recurring-invoice schema (the API's
|
||||||
|
/// `RecurringQuote` fields are a strict subset of `RecurringInvoice`), so the
|
||||||
|
/// `/recurring_quotes` endpoint returns the same JSON shape.
|
||||||
|
pub type RecurringQuote = RecurringInvoice;
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::{Map, Value};
|
||||||
|
|
||||||
|
/// A subscription (billing plan) definition.
|
||||||
|
///
|
||||||
|
/// Note that unlike [`RecurringInvoice::frequency_id`], the API returns this
|
||||||
|
/// resource's `frequency_id` as a string.
|
||||||
|
///
|
||||||
|
/// [`RecurringInvoice::frequency_id`]: super::RecurringInvoice::frequency_id
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
|
pub struct Subscription {
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub id: Option<String>,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub product_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 frequency_id: String,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub auto_bill: String,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub promo_code: String,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub promo_discount: f64,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub is_recurring: bool,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub is_amount_discount: bool,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub allow_cancellation: bool,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub per_seat_enabled: bool,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub max_seats_limit: i64,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub trial_enabled: bool,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub trial_duration: i64,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub allow_query_overrides: bool,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub allow_plan_changes: bool,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub refund_period: i64,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub is_deleted: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub archived_at: Option<i64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub created_at: Option<i64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub updated_at: Option<i64>,
|
||||||
|
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub extra: Map<String, Value>,
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::{Map, Value};
|
||||||
|
|
||||||
|
/// A server-side log entry (gateway responses, mail failures, and so on).
|
||||||
|
///
|
||||||
|
/// Note that unlike every other resource, this one's `created_at` and
|
||||||
|
/// `updated_at` are returned as date **strings**, not integer timestamps.
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
|
pub struct SystemLog {
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub id: Option<String>,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub user_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 event_id: i64,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub category_id: i64,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub type_id: i64,
|
||||||
|
/// The logged payload; shape depends on the event.
|
||||||
|
#[serde(default)]
|
||||||
|
pub log: Value,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub created_at: String,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub updated_at: String,
|
||||||
|
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub extra: Map<String, Value>,
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::{Map, Value};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
|
pub struct Tag {
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub id: Option<String>,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub entity_type: String,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub name: String,
|
||||||
|
/// Hex colour value.
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub color: String,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub is_deleted: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub archived_at: Option<i64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub created_at: Option<i64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub updated_at: Option<i64>,
|
||||||
|
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub extra: Map<String, Value>,
|
||||||
|
}
|
||||||
+9
-9
@@ -5,24 +5,24 @@ use serde_json::{Map, Value};
|
|||||||
pub struct Task {
|
pub struct Task {
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub id: Option<String>,
|
pub id: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
pub client_id: String,
|
pub client_id: String,
|
||||||
#[serde(default)]
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
pub invoice_id: String,
|
pub invoice_id: String,
|
||||||
#[serde(default)]
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
pub project_id: String,
|
pub project_id: String,
|
||||||
#[serde(default)]
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
pub description: String,
|
pub description: String,
|
||||||
#[serde(default)]
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
pub rate: f64,
|
pub rate: f64,
|
||||||
#[serde(default)]
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
pub status_id: String,
|
pub status_id: String,
|
||||||
#[serde(default)]
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
pub is_running: bool,
|
pub is_running: bool,
|
||||||
#[serde(default)]
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
pub is_deleted: bool,
|
pub is_deleted: bool,
|
||||||
/// JSON-encoded array of `[start_timestamp, end_timestamp]` pairs, as returned by the API.
|
/// JSON-encoded array of `[start_timestamp, end_timestamp]` pairs, as returned by the API.
|
||||||
#[serde(default)]
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
pub time_log: String,
|
pub time_log: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub archived_at: Option<i64>,
|
pub archived_at: Option<i64>,
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::{Map, Value};
|
||||||
|
|
||||||
|
/// A scheduled job (recurring report emails, and similar).
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
|
pub struct TaskScheduler {
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub id: Option<String>,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub paused: bool,
|
||||||
|
/// Frequency, e.g. `DAY`, `WEEK`, `MONTH`.
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub repeat_every: String,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub start_from: i64,
|
||||||
|
/// The job to run, e.g. `email_report`.
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub job: String,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub date_range: String,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub date_key: String,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub start_date: String,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub end_date: String,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub report_keys: Vec<String>,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub is_deleted: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub archived_at: Option<i64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub created_at: Option<i64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub updated_at: Option<i64>,
|
||||||
|
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub extra: Map<String, Value>,
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::{Map, Value};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
|
pub struct TaskStatus {
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub id: Option<String>,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub name: String,
|
||||||
|
/// Hex colour value, e.g. `#000000`.
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub color: String,
|
||||||
|
/// Position of this status in the kanban board ordering.
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub task_status_order: i64,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub is_deleted: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub archived_at: Option<i64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub created_at: Option<i64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub updated_at: Option<i64>,
|
||||||
|
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub extra: Map<String, Value>,
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::{Map, Value};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
|
pub struct TaxRate {
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub id: Option<String>,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub name: String,
|
||||||
|
/// The rate as a percentage, e.g. `10.0` for 10%.
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub rate: f64,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub is_deleted: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub archived_at: Option<i64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub created_at: Option<i64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub updated_at: Option<i64>,
|
||||||
|
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub extra: Map<String, Value>,
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::{Map, Value};
|
||||||
|
|
||||||
|
/// A user account.
|
||||||
|
///
|
||||||
|
/// Every `/users` endpoint except the index listing sits behind the API's
|
||||||
|
/// `password_protected` middleware, so reads of a single user and all writes
|
||||||
|
/// need an `X-API-PASSWORD` header — see
|
||||||
|
/// [`InvoiceNinjaClient::with_api_password`](crate::InvoiceNinjaClient::with_api_password).
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
|
pub struct User {
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub id: Option<String>,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub first_name: String,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub last_name: String,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub email: String,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub phone: String,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub signature: String,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub avatar: String,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub language_id: String,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub accepted_terms_version: String,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub oauth_user_id: String,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub oauth_provider_id: String,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub verified_phone_number: bool,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub has_password: bool,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub last_confirmed_email_address: String,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub passkey_enabled: bool,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub passkey_count: i64,
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub is_deleted: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub archived_at: Option<i64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub created_at: Option<i64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub updated_at: Option<i64>,
|
||||||
|
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub extra: Map<String, Value>,
|
||||||
|
}
|
||||||
+19
-19
@@ -5,31 +5,31 @@ use serde_json::{Map, Value};
|
|||||||
pub struct Vendor {
|
pub struct Vendor {
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub id: Option<String>,
|
pub id: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
pub name: String,
|
pub name: String,
|
||||||
#[serde(default)]
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
pub id_number: String,
|
pub id_number: String,
|
||||||
#[serde(default)]
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
pub vat_number: String,
|
pub vat_number: String,
|
||||||
#[serde(default)]
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
pub address1: String,
|
pub address1: String,
|
||||||
#[serde(default)]
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
pub address2: String,
|
pub address2: String,
|
||||||
#[serde(default)]
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
pub city: String,
|
pub city: String,
|
||||||
#[serde(default)]
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
pub state: String,
|
pub state: String,
|
||||||
#[serde(default)]
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
pub postal_code: String,
|
pub postal_code: String,
|
||||||
#[serde(default)]
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
pub country_id: String,
|
pub country_id: String,
|
||||||
#[serde(default)]
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
pub phone: String,
|
pub phone: String,
|
||||||
#[serde(default)]
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
pub website: String,
|
pub website: String,
|
||||||
#[serde(default)]
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
pub private_notes: String,
|
pub private_notes: String,
|
||||||
#[serde(default)]
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
pub is_deleted: bool,
|
pub is_deleted: bool,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub archived_at: Option<i64>,
|
pub archived_at: Option<i64>,
|
||||||
@@ -37,7 +37,7 @@ pub struct Vendor {
|
|||||||
pub created_at: Option<i64>,
|
pub created_at: Option<i64>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub updated_at: Option<i64>,
|
pub updated_at: Option<i64>,
|
||||||
#[serde(default)]
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
pub contacts: Vec<VendorContact>,
|
pub contacts: Vec<VendorContact>,
|
||||||
|
|
||||||
/// Any fields returned by the API that aren't modeled above are preserved here,
|
/// Any fields returned by the API that aren't modeled above are preserved here,
|
||||||
@@ -50,15 +50,15 @@ pub struct Vendor {
|
|||||||
pub struct VendorContact {
|
pub struct VendorContact {
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub id: Option<String>,
|
pub id: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
pub first_name: String,
|
pub first_name: String,
|
||||||
#[serde(default)]
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
pub last_name: String,
|
pub last_name: String,
|
||||||
#[serde(default)]
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
pub email: String,
|
pub email: String,
|
||||||
#[serde(default)]
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
pub phone: String,
|
pub phone: String,
|
||||||
#[serde(default)]
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
pub is_primary: bool,
|
pub is_primary: bool,
|
||||||
|
|
||||||
#[serde(flatten)]
|
#[serde(flatten)]
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::{Map, Value};
|
||||||
|
|
||||||
|
/// An outbound webhook registration (the API calls these "subscriptions"
|
||||||
|
/// internally, but they are served from `/webhooks`).
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
|
pub struct Webhook {
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub id: Option<String>,
|
||||||
|
/// The event this hook fires on, as an integer-enum string.
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub event_id: String,
|
||||||
|
/// The URL Invoice Ninja posts to.
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub target_url: String,
|
||||||
|
/// Payload format: `JSON` or `UBL`.
|
||||||
|
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||||
|
pub format: String,
|
||||||
|
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub extra: Map<String, Value>,
|
||||||
|
}
|
||||||
@@ -0,0 +1,546 @@
|
|||||||
|
//! Request-plumbing tests: pagination, path escaping, headers, timeouts, and
|
||||||
|
//! error mapping.
|
||||||
|
//!
|
||||||
|
//! These run against a `wiremock` server bound to loopback, so they need no
|
||||||
|
//! network access and no credentials. `tests/model_roundtrip.rs` covers the
|
||||||
|
//! serde layer; this file covers everything between a method call and the
|
||||||
|
//! bytes on the wire.
|
||||||
|
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use invoiceninjars::{models::ListParams, Error, InvoiceNinjaClient};
|
||||||
|
use serde_json::json;
|
||||||
|
use wiremock::matchers::{header, method, path, query_param};
|
||||||
|
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||||
|
|
||||||
|
/// Builds a client pointed at the mock server.
|
||||||
|
fn client(server: &MockServer) -> InvoiceNinjaClient {
|
||||||
|
InvoiceNinjaClient::new(server.uri(), "test-token").expect("mock uri is a valid base url")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A `data`-wrapped list page carrying pagination metadata.
|
||||||
|
fn page(rows: usize, current_page: u64, total_pages: u64) -> serde_json::Value {
|
||||||
|
let data: Vec<_> = (0..rows)
|
||||||
|
.map(|i| json!({ "id": format!("id{i}") }))
|
||||||
|
.collect();
|
||||||
|
json!({
|
||||||
|
"data": data,
|
||||||
|
"meta": {
|
||||||
|
"pagination": {
|
||||||
|
"total": rows as u64 * total_pages,
|
||||||
|
"count": rows as u64,
|
||||||
|
"per_page": rows as u64,
|
||||||
|
"current_page": current_page,
|
||||||
|
"total_pages": total_pages,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- auth and headers ---------------------------------------------------
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn token_and_accept_headers_are_sent() {
|
||||||
|
let server = MockServer::start().await;
|
||||||
|
Mock::given(method("GET"))
|
||||||
|
.and(path("/clients"))
|
||||||
|
.and(header("X-Api-Token", "test-token"))
|
||||||
|
.and(header("Accept", "application/json"))
|
||||||
|
.and(header("X-Requested-With", "XMLHttpRequest"))
|
||||||
|
.respond_with(ResponseTemplate::new(200).set_body_json(page(0, 1, 1)))
|
||||||
|
.mount(&server)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
client(&server)
|
||||||
|
.list_clients(&ListParams::new())
|
||||||
|
.await
|
||||||
|
.expect("request should succeed");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn api_password_header_is_attached_when_configured() {
|
||||||
|
let server = MockServer::start().await;
|
||||||
|
Mock::given(method("GET"))
|
||||||
|
.and(path("/users/u1"))
|
||||||
|
.and(header("X-API-PASSWORD", "hunter2"))
|
||||||
|
.respond_with(ResponseTemplate::new(200).set_body_json(json!({ "data": { "id": "u1" } })))
|
||||||
|
.mount(&server)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let client = client(&server)
|
||||||
|
.with_api_password("hunter2")
|
||||||
|
.expect("password is a valid header value");
|
||||||
|
let user = client.get_user("u1").await.expect("request should succeed");
|
||||||
|
assert_eq!(user.id.as_deref(), Some("u1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn api_password_header_is_absent_by_default() {
|
||||||
|
let server = MockServer::start().await;
|
||||||
|
Mock::given(method("GET"))
|
||||||
|
.and(path("/clients"))
|
||||||
|
.respond_with(ResponseTemplate::new(200).set_body_json(page(0, 1, 1)))
|
||||||
|
.mount(&server)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
client(&server)
|
||||||
|
.list_clients(&ListParams::new())
|
||||||
|
.await
|
||||||
|
.expect("request should succeed");
|
||||||
|
|
||||||
|
let sent = &server.received_requests().await.unwrap()[0];
|
||||||
|
assert!(
|
||||||
|
sent.headers.get("X-API-PASSWORD").is_none(),
|
||||||
|
"password header must not be sent unless configured"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- path escaping ------------------------------------------------------
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn ids_are_escaped_into_a_single_path_segment() {
|
||||||
|
let server = MockServer::start().await;
|
||||||
|
Mock::given(method("GET"))
|
||||||
|
.respond_with(ResponseTemplate::new(200).set_body_json(json!({ "data": { "id": "x" } })))
|
||||||
|
.mount(&server)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
// A `/` in the id must not open a new path segment: the whole value
|
||||||
|
// stays under /clients rather than reaching /companies.
|
||||||
|
client(&server)
|
||||||
|
.get_client("../companies/1")
|
||||||
|
.await
|
||||||
|
.expect("escaped id should still reach the clients endpoint");
|
||||||
|
|
||||||
|
let sent = &server.received_requests().await.unwrap()[0];
|
||||||
|
assert_eq!(sent.url.path(), "/clients/%2E%2E%2Fcompanies%2F1");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Escaping by itself does not make a dot-only id safe: the WHATWG rules the
|
||||||
|
/// `url` crate follows decode `%2E` back to `.` before resolving dot
|
||||||
|
/// segments, so `clients/%2E%2E` still collapses to `/`. Such ids have to be
|
||||||
|
/// refused before a request is built.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn dot_only_ids_are_rejected_before_any_request_is_sent() {
|
||||||
|
let server = MockServer::start().await;
|
||||||
|
Mock::given(method("DELETE"))
|
||||||
|
.respond_with(ResponseTemplate::new(200))
|
||||||
|
.mount(&server)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
for id in ["..", ".", "..."] {
|
||||||
|
let err = client(&server)
|
||||||
|
.delete_client(id)
|
||||||
|
.await
|
||||||
|
.expect_err("a dot-only id must not be sent");
|
||||||
|
assert!(
|
||||||
|
matches!(err, Error::InvalidPathSegment(ref v) if v == id),
|
||||||
|
"expected InvalidPathSegment for {id:?}, got {err:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
server.received_requests().await.unwrap().is_empty(),
|
||||||
|
"nothing should have reached the server"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn empty_ids_are_rejected_rather_than_hitting_the_collection() {
|
||||||
|
let server = MockServer::start().await;
|
||||||
|
Mock::given(method("DELETE"))
|
||||||
|
.respond_with(ResponseTemplate::new(200))
|
||||||
|
.mount(&server)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
// `delete_client("")` would otherwise address DELETE /clients.
|
||||||
|
let err = client(&server)
|
||||||
|
.delete_client("")
|
||||||
|
.await
|
||||||
|
.expect_err("an empty id must not be sent");
|
||||||
|
assert!(matches!(err, Error::InvalidPathSegment(_)), "got {err:?}");
|
||||||
|
assert!(server.received_requests().await.unwrap().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn query_injection_via_id_is_escaped() {
|
||||||
|
let server = MockServer::start().await;
|
||||||
|
Mock::given(method("GET"))
|
||||||
|
.and(path("/invoices/abc%3Fper_page%3D1/download"))
|
||||||
|
.respond_with(ResponseTemplate::new(200).set_body_bytes(b"%PDF-1.4".to_vec()))
|
||||||
|
.mount(&server)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let pdf = client(&server)
|
||||||
|
.download_invoice("abc?per_page=1")
|
||||||
|
.await
|
||||||
|
.expect("escaped id should not inject a query string");
|
||||||
|
assert_eq!(pdf, b"%PDF-1.4");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn ordinary_hashids_are_left_readable() {
|
||||||
|
let server = MockServer::start().await;
|
||||||
|
Mock::given(method("GET"))
|
||||||
|
.and(path("/clients/Wpmbk5ezJn"))
|
||||||
|
.respond_with(
|
||||||
|
ResponseTemplate::new(200).set_body_json(json!({ "data": { "id": "Wpmbk5ezJn" } })),
|
||||||
|
)
|
||||||
|
.mount(&server)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let got = client(&server)
|
||||||
|
.get_client("Wpmbk5ezJn")
|
||||||
|
.await
|
||||||
|
.expect("a normal id should pass through unescaped");
|
||||||
|
assert_eq!(got.id.as_deref(), Some("Wpmbk5ezJn"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- pagination ---------------------------------------------------------
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn list_all_follows_total_pages_from_metadata() {
|
||||||
|
let server = MockServer::start().await;
|
||||||
|
for p in 1..=3u64 {
|
||||||
|
Mock::given(method("GET"))
|
||||||
|
.and(path("/clients"))
|
||||||
|
.and(query_param("page", p.to_string()))
|
||||||
|
.respond_with(ResponseTemplate::new(200).set_body_json(page(50, p, 3)))
|
||||||
|
.mount(&server)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
let all = client(&server)
|
||||||
|
.list_all_clients(&ListParams::new())
|
||||||
|
.await
|
||||||
|
.expect("should walk every page");
|
||||||
|
assert_eq!(all.len(), 150, "expected all three pages");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The regression this rewrite exists for. The server caps `per_page` at
|
||||||
|
/// 5000, so asking for more returns a page shorter than requested while more
|
||||||
|
/// records remain. The old short-page-means-last heuristic stopped there and
|
||||||
|
/// silently dropped the rest.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn capped_page_size_does_not_truncate_the_walk() {
|
||||||
|
let server = MockServer::start().await;
|
||||||
|
for p in 1..=2u64 {
|
||||||
|
Mock::given(method("GET"))
|
||||||
|
.and(path("/clients"))
|
||||||
|
.and(query_param("page", p.to_string()))
|
||||||
|
// 10 rows returned against a much larger requested page size.
|
||||||
|
.respond_with(ResponseTemplate::new(200).set_body_json(page(10, p, 2)))
|
||||||
|
.mount(&server)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
let all = client(&server)
|
||||||
|
.list_all_clients(&ListParams::new().per_page(9999))
|
||||||
|
.await
|
||||||
|
.expect("should keep paging past a server-capped page");
|
||||||
|
assert_eq!(
|
||||||
|
all.len(),
|
||||||
|
20,
|
||||||
|
"a short page must not be read as the last page when metadata says otherwise"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn per_page_is_clamped_to_the_server_maximum() {
|
||||||
|
let server = MockServer::start().await;
|
||||||
|
Mock::given(method("GET"))
|
||||||
|
.and(path("/clients"))
|
||||||
|
.and(query_param("per_page", "5000"))
|
||||||
|
.respond_with(ResponseTemplate::new(200).set_body_json(page(0, 1, 1)))
|
||||||
|
.mount(&server)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
client(&server)
|
||||||
|
.list_all_clients(&ListParams::new().per_page(100_000))
|
||||||
|
.await
|
||||||
|
.expect("oversized per_page should be clamped to 5000, not sent verbatim");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `per_page(0)` made the old loop compare `got < 0`, which nothing
|
||||||
|
/// satisfies — it paged forever. It must fall back to the default size.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn zero_per_page_does_not_spin() {
|
||||||
|
let server = MockServer::start().await;
|
||||||
|
Mock::given(method("GET"))
|
||||||
|
.and(path("/clients"))
|
||||||
|
.and(query_param("per_page", "50"))
|
||||||
|
.respond_with(ResponseTemplate::new(200).set_body_json(page(0, 1, 1)))
|
||||||
|
.mount(&server)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let all = tokio::time::timeout(
|
||||||
|
Duration::from_secs(10),
|
||||||
|
client(&server).list_all_clients(&ListParams::new().per_page(0)),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("must terminate rather than page forever")
|
||||||
|
.expect("request should succeed");
|
||||||
|
assert!(all.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn empty_page_terminates_even_if_metadata_claims_more() {
|
||||||
|
let server = MockServer::start().await;
|
||||||
|
// Metadata insists there are 99 pages, but the first comes back empty.
|
||||||
|
Mock::given(method("GET"))
|
||||||
|
.and(path("/clients"))
|
||||||
|
.respond_with(ResponseTemplate::new(200).set_body_json(page(0, 1, 99)))
|
||||||
|
.mount(&server)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let all = tokio::time::timeout(
|
||||||
|
Duration::from_secs(10),
|
||||||
|
client(&server).list_all_clients(&ListParams::new()),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("an empty page must end the walk")
|
||||||
|
.expect("request should succeed");
|
||||||
|
assert!(all.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn list_all_falls_back_to_short_page_without_metadata() {
|
||||||
|
let server = MockServer::start().await;
|
||||||
|
Mock::given(method("GET"))
|
||||||
|
.and(path("/clients"))
|
||||||
|
.and(query_param("page", "1"))
|
||||||
|
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||||
|
"data": (0..50).map(|i| json!({ "id": format!("id{i}") })).collect::<Vec<_>>()
|
||||||
|
})))
|
||||||
|
.mount(&server)
|
||||||
|
.await;
|
||||||
|
Mock::given(method("GET"))
|
||||||
|
.and(path("/clients"))
|
||||||
|
.and(query_param("page", "2"))
|
||||||
|
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||||
|
"data": [{ "id": "last" }]
|
||||||
|
})))
|
||||||
|
.mount(&server)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let all = client(&server)
|
||||||
|
.list_all_clients(&ListParams::new())
|
||||||
|
.await
|
||||||
|
.expect("should page without metadata");
|
||||||
|
assert_eq!(all.len(), 51);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn list_all_honours_a_starting_page() {
|
||||||
|
let server = MockServer::start().await;
|
||||||
|
for p in 2..=3u64 {
|
||||||
|
Mock::given(method("GET"))
|
||||||
|
.and(path("/clients"))
|
||||||
|
.and(query_param("page", p.to_string()))
|
||||||
|
.respond_with(ResponseTemplate::new(200).set_body_json(page(50, p, 3)))
|
||||||
|
.mount(&server)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
let all = client(&server)
|
||||||
|
.list_all_clients(&ListParams::new().page(2))
|
||||||
|
.await
|
||||||
|
.expect("should start at the requested page");
|
||||||
|
assert_eq!(all.len(), 100, "pages 2 and 3 only");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn custom_params_are_sent_on_every_page() {
|
||||||
|
let server = MockServer::start().await;
|
||||||
|
for p in 1..=2u64 {
|
||||||
|
Mock::given(method("GET"))
|
||||||
|
.and(path("/clients"))
|
||||||
|
.and(query_param("page", p.to_string()))
|
||||||
|
.and(query_param("client_id", "abc"))
|
||||||
|
.and(query_param("status", "active"))
|
||||||
|
.respond_with(ResponseTemplate::new(200).set_body_json(page(50, p, 2)))
|
||||||
|
.mount(&server)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
let all = client(&server)
|
||||||
|
.list_all_clients(&ListParams::new().status("active").param("client_id", "abc"))
|
||||||
|
.await
|
||||||
|
.expect("filters must survive pagination");
|
||||||
|
assert_eq!(all.len(), 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- error mapping ------------------------------------------------------
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn validation_errors_keep_the_whole_field_map() {
|
||||||
|
let server = MockServer::start().await;
|
||||||
|
Mock::given(method("POST"))
|
||||||
|
.and(path("/clients"))
|
||||||
|
.respond_with(ResponseTemplate::new(422).set_body_json(json!({
|
||||||
|
"message": "The given data was invalid.",
|
||||||
|
"errors": {
|
||||||
|
"name": ["The name field is required."],
|
||||||
|
"contacts.0.email": ["The email must be valid.", "Already taken."]
|
||||||
|
}
|
||||||
|
})))
|
||||||
|
.mount(&server)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let err = client(&server)
|
||||||
|
.create_client(&Default::default())
|
||||||
|
.await
|
||||||
|
.expect_err("422 should surface as Error::Api");
|
||||||
|
|
||||||
|
match err {
|
||||||
|
Error::Api {
|
||||||
|
status,
|
||||||
|
message,
|
||||||
|
errors,
|
||||||
|
} => {
|
||||||
|
assert_eq!(status, 422);
|
||||||
|
assert_eq!(message, "The given data was invalid.");
|
||||||
|
assert_eq!(errors["name"], vec!["The name field is required."]);
|
||||||
|
assert_eq!(
|
||||||
|
errors["contacts.0.email"],
|
||||||
|
vec!["The email must be valid.", "Already taken."],
|
||||||
|
"every message is kept, not just the first"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
other => panic!("expected Error::Api, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn non_json_error_body_falls_back_to_the_raw_text() {
|
||||||
|
let server = MockServer::start().await;
|
||||||
|
Mock::given(method("GET"))
|
||||||
|
.and(path("/clients"))
|
||||||
|
.respond_with(ResponseTemplate::new(502).set_body_string("<html>bad gateway</html>"))
|
||||||
|
.mount(&server)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let err = client(&server)
|
||||||
|
.list_clients(&ListParams::new())
|
||||||
|
.await
|
||||||
|
.expect_err("502 should surface as Error::Api");
|
||||||
|
|
||||||
|
match err {
|
||||||
|
Error::Api {
|
||||||
|
status, message, ..
|
||||||
|
} => {
|
||||||
|
assert_eq!(status, 502);
|
||||||
|
assert!(
|
||||||
|
message.contains("bad gateway"),
|
||||||
|
"a non-JSON body should be preserved verbatim, got {message:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
other => panic!("expected Error::Api, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn delete_reports_api_errors_rather_than_succeeding() {
|
||||||
|
let server = MockServer::start().await;
|
||||||
|
Mock::given(method("DELETE"))
|
||||||
|
.and(path("/clients/abc"))
|
||||||
|
.respond_with(ResponseTemplate::new(403).set_body_json(json!({ "message": "forbidden" })))
|
||||||
|
.mount(&server)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let err = client(&server)
|
||||||
|
.delete_client("abc")
|
||||||
|
.await
|
||||||
|
.expect_err("403 should not read as a successful delete");
|
||||||
|
assert!(matches!(err, Error::Api { status, .. } if status == 403));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- base url validation ------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn base_url_without_a_scheme_is_rejected_at_construction() {
|
||||||
|
let err = InvoiceNinjaClient::new("invoicing.co/api/v1", "t")
|
||||||
|
.expect_err("a schemeless url should fail fast, not 404 on every call");
|
||||||
|
assert!(matches!(err, Error::InvalidBaseUrl(_)), "got {err:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn non_http_base_url_is_rejected() {
|
||||||
|
let err = InvoiceNinjaClient::new("ftp://example.com/api/v1", "t")
|
||||||
|
.expect_err("only http(s) is usable");
|
||||||
|
assert!(matches!(err, Error::InvalidBaseUrl(_)), "got {err:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_base_url_is_rejected() {
|
||||||
|
let err = InvoiceNinjaClient::new(" ", "t").expect_err("empty base url should fail");
|
||||||
|
assert!(matches!(err, Error::InvalidBaseUrl(_)), "got {err:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn trailing_slashes_are_tolerated() {
|
||||||
|
InvoiceNinjaClient::new("https://example.com/api/v1/", "t")
|
||||||
|
.expect("a trailing slash should not be an error");
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- timeouts -----------------------------------------------------------
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn requests_give_up_once_the_timeout_elapses() {
|
||||||
|
let server = MockServer::start().await;
|
||||||
|
Mock::given(method("GET"))
|
||||||
|
.and(path("/clients"))
|
||||||
|
.respond_with(
|
||||||
|
ResponseTemplate::new(200)
|
||||||
|
.set_body_json(page(0, 1, 1))
|
||||||
|
.set_delay(Duration::from_secs(30)),
|
||||||
|
)
|
||||||
|
.mount(&server)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let client = client(&server).with_timeout(Duration::from_millis(150));
|
||||||
|
let err = tokio::time::timeout(
|
||||||
|
Duration::from_secs(10),
|
||||||
|
client.list_clients(&ListParams::new()),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("the client's own timeout should fire well before this one")
|
||||||
|
.expect_err("a stalled response must not hang the caller");
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
matches!(&err, Error::Http(e) if e.is_timeout()),
|
||||||
|
"expected a timeout error, got {err:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- envelope handling --------------------------------------------------
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn bulk_response_without_a_data_key_reads_as_empty() {
|
||||||
|
let server = MockServer::start().await;
|
||||||
|
Mock::given(method("POST"))
|
||||||
|
.and(path("/invoices/bulk"))
|
||||||
|
.respond_with(
|
||||||
|
ResponseTemplate::new(200).set_body_json(json!({ "message": "No Invoices Found" })),
|
||||||
|
)
|
||||||
|
.mount(&server)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let got = client(&server)
|
||||||
|
.bulk_invoices("archive", &["missing"])
|
||||||
|
.await
|
||||||
|
.expect("a no-match bulk action is not an error");
|
||||||
|
assert!(got.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn ping_accepts_an_empty_success_body() {
|
||||||
|
let server = MockServer::start().await;
|
||||||
|
Mock::given(method("GET"))
|
||||||
|
.and(path("/ping"))
|
||||||
|
.respond_with(ResponseTemplate::new(200))
|
||||||
|
.mount(&server)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
client(&server).ping().await.expect("ping should succeed");
|
||||||
|
}
|
||||||
+545
-1
@@ -1,5 +1,9 @@
|
|||||||
use invoiceninjars::models::{
|
use invoiceninjars::models::{
|
||||||
Client, Credit, Expense, Invoice, ListParams, Project, Quote, Task, Vendor,
|
BankIntegration, BankTransaction, BankTransactionRule, BulkRequest, Client, ClientGatewayToken,
|
||||||
|
Company, CompanyGateway, CompanyToken, Credit, Design, Document, Expense, ExpenseCategory,
|
||||||
|
GroupSetting, Invoice, ListParams, ListResponse, Location, PaymentTerm, Project, PurchaseOrder,
|
||||||
|
Quote, RecurringExpense, RecurringInvoice, RecurringQuote, Subscription, SystemLog, Tag, Task,
|
||||||
|
TaskScheduler, TaskStatus, TaxRate, User, Vendor, Webhook,
|
||||||
};
|
};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
||||||
@@ -150,3 +154,543 @@ fn quote_and_credit_share_the_invoice_shape() {
|
|||||||
let credit: Credit = serde_json::from_value(payload).unwrap();
|
let credit: Credit = serde_json::from_value(payload).unwrap();
|
||||||
assert_eq!(credit.number, "QUO-0001");
|
assert_eq!(credit.number, "QUO-0001");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn recurring_invoice_decodes_recurrence_fields() {
|
||||||
|
// frequency_id / remaining_cycles come back as JSON numbers, and
|
||||||
|
// remaining_cycles is -1 for an indefinite schedule.
|
||||||
|
let payload = json!({
|
||||||
|
"id": "ri1",
|
||||||
|
"client_id": "abc123",
|
||||||
|
"frequency_id": 5,
|
||||||
|
"remaining_cycles": -1,
|
||||||
|
"auto_bill": "off",
|
||||||
|
"next_send_date": "2026-11-01",
|
||||||
|
"line_items": [{"product_key": "widget", "cost": 9.5, "quantity": 2.0}],
|
||||||
|
"custom_value1": "unmodeled field",
|
||||||
|
});
|
||||||
|
|
||||||
|
let ri: RecurringInvoice = serde_json::from_value(payload).unwrap();
|
||||||
|
assert_eq!(ri.frequency_id, 5);
|
||||||
|
assert_eq!(ri.remaining_cycles, -1);
|
||||||
|
assert_eq!(ri.next_send_date, "2026-11-01");
|
||||||
|
assert_eq!(ri.line_items.len(), 1);
|
||||||
|
assert_eq!(ri.line_items[0].product_key, "widget");
|
||||||
|
|
||||||
|
let round_tripped = serde_json::to_value(&ri).unwrap();
|
||||||
|
assert_eq!(round_tripped["custom_value1"], "unmodeled field");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `RecurringQuote` aliases `RecurringInvoice`; the API's RecurringQuote
|
||||||
|
/// fields are a strict subset, so a quote payload must decode without the
|
||||||
|
/// invoice-only fields present.
|
||||||
|
#[test]
|
||||||
|
fn recurring_quote_decodes_without_invoice_only_fields() {
|
||||||
|
let payload = json!({
|
||||||
|
"id": "rq1",
|
||||||
|
"client_id": "abc123",
|
||||||
|
"frequency_id": 2,
|
||||||
|
"remaining_cycles": 12,
|
||||||
|
});
|
||||||
|
|
||||||
|
let rq: RecurringQuote = serde_json::from_value(payload).unwrap();
|
||||||
|
assert_eq!(rq.frequency_id, 2);
|
||||||
|
assert_eq!(rq.remaining_cycles, 12);
|
||||||
|
// Invoice-only fields fall back to their defaults rather than failing.
|
||||||
|
assert_eq!(rq.auto_bill, "");
|
||||||
|
assert!(!rq.auto_bill_enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn purchase_order_round_trip_preserves_unknown_fields() {
|
||||||
|
let payload = json!({
|
||||||
|
"id": "po1",
|
||||||
|
"vendor_id": "ven1",
|
||||||
|
"number": "PO-0001",
|
||||||
|
"amount": 250.0,
|
||||||
|
"custom_surcharge1": 7.25,
|
||||||
|
});
|
||||||
|
|
||||||
|
let po: PurchaseOrder = serde_json::from_value(payload).unwrap();
|
||||||
|
assert_eq!(po.vendor_id, "ven1");
|
||||||
|
assert_eq!(po.number, "PO-0001");
|
||||||
|
assert_eq!(po.amount, 250.0);
|
||||||
|
|
||||||
|
let round_tripped = serde_json::to_value(&po).unwrap();
|
||||||
|
assert_eq!(round_tripped["custom_surcharge1"], 7.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn recurring_expense_round_trip_preserves_unknown_fields() {
|
||||||
|
let payload = json!({
|
||||||
|
"id": "rex1",
|
||||||
|
"vendor_id": "ven1",
|
||||||
|
"frequency_id": 4,
|
||||||
|
"remaining_cycles": 6,
|
||||||
|
"amount": 19.99,
|
||||||
|
"custom_value2": "unmodeled field",
|
||||||
|
});
|
||||||
|
|
||||||
|
let rex: RecurringExpense = serde_json::from_value(payload).unwrap();
|
||||||
|
assert_eq!(rex.frequency_id, 4);
|
||||||
|
assert_eq!(rex.remaining_cycles, 6);
|
||||||
|
assert_eq!(rex.amount, 19.99);
|
||||||
|
|
||||||
|
let round_tripped = serde_json::to_value(&rex).unwrap();
|
||||||
|
assert_eq!(round_tripped["custom_value2"], "unmodeled field");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `Subscription.frequency_id` is a string even though the recurring-invoice
|
||||||
|
/// field of the same name is numeric; this pins that asymmetry.
|
||||||
|
#[test]
|
||||||
|
fn subscription_frequency_id_is_a_string() {
|
||||||
|
let payload = json!({
|
||||||
|
"id": "sub1",
|
||||||
|
"product_id": "prod1",
|
||||||
|
"frequency_id": "5",
|
||||||
|
"trial_enabled": true,
|
||||||
|
"trial_duration": 14,
|
||||||
|
"promo_discount": 10.0,
|
||||||
|
});
|
||||||
|
|
||||||
|
let sub: Subscription = serde_json::from_value(payload).unwrap();
|
||||||
|
assert_eq!(sub.frequency_id, "5");
|
||||||
|
assert!(sub.trial_enabled);
|
||||||
|
assert_eq!(sub.trial_duration, 14);
|
||||||
|
assert_eq!(sub.promo_discount, 10.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn small_lookup_resources_decode() {
|
||||||
|
let tax_rate: TaxRate = serde_json::from_value(json!({
|
||||||
|
"id": "tr1", "name": "GST", "rate": 10.0
|
||||||
|
}))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(tax_rate.name, "GST");
|
||||||
|
assert_eq!(tax_rate.rate, 10.0);
|
||||||
|
|
||||||
|
let category: ExpenseCategory = serde_json::from_value(json!({
|
||||||
|
"id": "ec1", "name": "Accounting"
|
||||||
|
}))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(category.name, "Accounting");
|
||||||
|
|
||||||
|
let status: TaskStatus = serde_json::from_value(json!({
|
||||||
|
"id": "ts1", "name": "Backlog", "color": "#000000", "task_status_order": 4
|
||||||
|
}))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(status.task_status_order, 4);
|
||||||
|
assert_eq!(status.color, "#000000");
|
||||||
|
|
||||||
|
let term: PaymentTerm = serde_json::from_value(json!({
|
||||||
|
"id": "pt1", "name": "NET 30", "num_days": 30
|
||||||
|
}))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(term.num_days, 30);
|
||||||
|
|
||||||
|
let webhook: Webhook = serde_json::from_value(json!({
|
||||||
|
"id": "wh1", "event_id": "1", "target_url": "https://example.com/hook", "format": "JSON"
|
||||||
|
}))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(webhook.target_url, "https://example.com/hook");
|
||||||
|
assert_eq!(webhook.format, "JSON");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bank transactions are the one resource whose ids come back as integers
|
||||||
|
/// rather than hashed strings.
|
||||||
|
#[test]
|
||||||
|
fn bank_transaction_decodes_integer_ids() {
|
||||||
|
let payload = json!({
|
||||||
|
"id": "bt1",
|
||||||
|
"transaction_id": 9876543210i64,
|
||||||
|
"bank_account_id": 42,
|
||||||
|
"category_id": 7,
|
||||||
|
"amount": 123.45,
|
||||||
|
"base_type": "CREDIT",
|
||||||
|
"date": "2026-10-01",
|
||||||
|
});
|
||||||
|
|
||||||
|
let bt: BankTransaction = serde_json::from_value(payload).unwrap();
|
||||||
|
assert_eq!(bt.transaction_id, 9876543210);
|
||||||
|
assert_eq!(bt.bank_account_id, 42);
|
||||||
|
assert_eq!(bt.category_id, 7);
|
||||||
|
assert_eq!(bt.base_type, "CREDIT");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A newly constructed model must not serialize an `id`, so `create_*` calls
|
||||||
|
/// do not send `"id": null` to the API.
|
||||||
|
#[test]
|
||||||
|
fn default_models_omit_id_on_serialize() {
|
||||||
|
let body = serde_json::to_value(RecurringInvoice::default()).unwrap();
|
||||||
|
assert!(body.get("id").is_none());
|
||||||
|
|
||||||
|
let body = serde_json::to_value(TaxRate::default()).unwrap();
|
||||||
|
assert!(body.get("id").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- explicit-null tolerance -------------------------------------------
|
||||||
|
//
|
||||||
|
// Invoice Ninja returns `null` for plenty of nominally-string fields.
|
||||||
|
// `#[serde(default)]` only covers a *missing* key, so these payloads used to
|
||||||
|
// fail to decode entirely; the models pair `default` with a
|
||||||
|
// null-to-default deserializer.
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn null_strings_numbers_and_bools_decode_as_defaults() {
|
||||||
|
let payload = json!({
|
||||||
|
"id": "abc123",
|
||||||
|
"name": null,
|
||||||
|
"address1": null,
|
||||||
|
"balance": null,
|
||||||
|
"is_deleted": null,
|
||||||
|
"contacts": null,
|
||||||
|
});
|
||||||
|
|
||||||
|
let client: Client = serde_json::from_value(payload).unwrap();
|
||||||
|
assert_eq!(client.name, "");
|
||||||
|
assert_eq!(client.address1, "");
|
||||||
|
assert_eq!(client.balance, 0.0);
|
||||||
|
assert!(!client.is_deleted);
|
||||||
|
assert!(client.contacts.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn null_line_items_and_nested_nulls_decode() {
|
||||||
|
let payload = json!({
|
||||||
|
"id": "inv1",
|
||||||
|
"number": null,
|
||||||
|
"public_notes": null,
|
||||||
|
"line_items": [{
|
||||||
|
"product_key": null,
|
||||||
|
"notes": null,
|
||||||
|
"cost": null,
|
||||||
|
"quantity": 3.0,
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
|
||||||
|
let invoice: Invoice = serde_json::from_value(payload).unwrap();
|
||||||
|
assert_eq!(invoice.number, "");
|
||||||
|
assert_eq!(invoice.public_notes, "");
|
||||||
|
assert_eq!(invoice.line_items.len(), 1);
|
||||||
|
assert_eq!(invoice.line_items[0].product_key, "");
|
||||||
|
assert_eq!(invoice.line_items[0].cost, 0.0);
|
||||||
|
assert_eq!(invoice.line_items[0].quantity, 3.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn null_integer_fields_decode_across_resources() {
|
||||||
|
let ri: RecurringInvoice = serde_json::from_value(json!({
|
||||||
|
"id": "ri1", "frequency_id": null, "remaining_cycles": null, "auto_bill": null
|
||||||
|
}))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(ri.frequency_id, 0);
|
||||||
|
assert_eq!(ri.remaining_cycles, 0);
|
||||||
|
assert_eq!(ri.auto_bill, "");
|
||||||
|
|
||||||
|
let status: TaskStatus = serde_json::from_value(json!({
|
||||||
|
"id": "ts1", "name": null, "task_status_order": null
|
||||||
|
}))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(status.task_status_order, 0);
|
||||||
|
|
||||||
|
let bt: BankTransaction = serde_json::from_value(json!({
|
||||||
|
"id": "bt1", "transaction_id": null, "bank_account_id": null, "base_type": null
|
||||||
|
}))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(bt.transaction_id, 0);
|
||||||
|
assert_eq!(bt.bank_account_id, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Nullable timestamps stay `None` rather than collapsing to `Some(0)`, so
|
||||||
|
/// "never archived" is still distinguishable from "archived at epoch".
|
||||||
|
#[test]
|
||||||
|
fn null_timestamps_remain_none() {
|
||||||
|
let client: Client = serde_json::from_value(json!({
|
||||||
|
"id": "abc123", "archived_at": null, "created_at": null, "updated_at": 1700000000i64
|
||||||
|
}))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(client.archived_at, None);
|
||||||
|
assert_eq!(client.created_at, None);
|
||||||
|
assert_eq!(client.updated_at, Some(1700000000));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A null value for an *unmodeled* key still round-trips through `extra`.
|
||||||
|
#[test]
|
||||||
|
fn null_unknown_fields_round_trip_through_extra() {
|
||||||
|
let payload = json!({"id": "abc123", "name": "Acme Inc", "custom_value1": null});
|
||||||
|
|
||||||
|
let client: Client = serde_json::from_value(payload).unwrap();
|
||||||
|
assert!(client.extra.get("custom_value1").unwrap().is_null());
|
||||||
|
|
||||||
|
let round_tripped = serde_json::to_value(&client).unwrap();
|
||||||
|
assert!(round_tripped["custom_value1"].is_null());
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- non-CRUD endpoint plumbing ----------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bulk_request_serializes_action_and_ids() {
|
||||||
|
let body = BulkRequest::new("archive", &["abc123", "def456"]);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_value(&body).unwrap(),
|
||||||
|
json!({"action": "archive", "ids": ["abc123", "def456"]})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `BulkRequest::new` takes anything string-like, so ids pulled off models
|
||||||
|
/// (which are `Option<String>`) don't need converting at the call site.
|
||||||
|
#[test]
|
||||||
|
fn bulk_request_accepts_owned_and_borrowed_ids() {
|
||||||
|
let owned: Vec<String> = vec!["abc123".to_string(), "def456".to_string()];
|
||||||
|
let from_owned = BulkRequest::new("delete", &owned);
|
||||||
|
let from_borrowed = BulkRequest::new("delete", &["abc123", "def456"]);
|
||||||
|
|
||||||
|
assert_eq!(from_owned.ids, from_borrowed.ids);
|
||||||
|
assert_eq!(from_owned.action, "delete");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A bulk action whose `ids` match nothing answers HTTP 200 with
|
||||||
|
/// `{"message": "No Invoices Found"}` — no `data` key at all. That has to
|
||||||
|
/// read as an empty result, not a decode error.
|
||||||
|
#[test]
|
||||||
|
fn list_envelope_without_data_key_decodes_as_empty() {
|
||||||
|
let body: ListResponse<Invoice> =
|
||||||
|
serde_json::from_value(json!({"message": "No Invoices Found"})).unwrap();
|
||||||
|
|
||||||
|
assert!(body.data.is_empty());
|
||||||
|
assert!(body.meta.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn list_envelope_still_decodes_data_and_pagination() {
|
||||||
|
let body: ListResponse<Invoice> = serde_json::from_value(json!({
|
||||||
|
"data": [{"id": "inv1", "number": "INV-0001"}],
|
||||||
|
"meta": {"pagination": {
|
||||||
|
"total": 1, "count": 1, "per_page": 50, "current_page": 1, "total_pages": 1
|
||||||
|
}},
|
||||||
|
}))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(body.data.len(), 1);
|
||||||
|
assert_eq!(body.data[0].number, "INV-0001");
|
||||||
|
let pagination = body.meta.unwrap().pagination.unwrap();
|
||||||
|
assert_eq!(pagination.total, 1);
|
||||||
|
assert_eq!(pagination.per_page, 50);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn document_round_trip_preserves_unknown_fields() {
|
||||||
|
// `type` is a Rust keyword, so the field is `r#type` but must still map
|
||||||
|
// to the plain JSON key.
|
||||||
|
let payload = json!({
|
||||||
|
"id": "doc1",
|
||||||
|
"name": "contract.pdf",
|
||||||
|
"type": "pdf",
|
||||||
|
"hash": "abc",
|
||||||
|
"is_default": false,
|
||||||
|
"custom_value1": "unmodeled field",
|
||||||
|
});
|
||||||
|
|
||||||
|
let doc: Document = serde_json::from_value(payload).unwrap();
|
||||||
|
assert_eq!(doc.name, "contract.pdf");
|
||||||
|
assert_eq!(doc.r#type, "pdf");
|
||||||
|
|
||||||
|
let round_tripped = serde_json::to_value(&doc).unwrap();
|
||||||
|
assert_eq!(round_tripped["type"], "pdf");
|
||||||
|
assert_eq!(round_tripped["custom_value1"], "unmodeled field");
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- list params ------------------------------------------------------
|
||||||
|
|
||||||
|
/// The typed fields serialize; `custom` is skipped here because it is
|
||||||
|
/// applied as a separate query pass (serde_urlencoded can't flatten a map).
|
||||||
|
#[test]
|
||||||
|
fn list_params_skips_custom_in_struct_serialization() {
|
||||||
|
let params = ListParams::new().per_page(25).param("client_id", "abc123");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_value(¶ms).unwrap(),
|
||||||
|
json!({"per_page": 25})
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
params.custom,
|
||||||
|
vec![("client_id".to_string(), "abc123".to_string())]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn list_params_custom_pairs_accumulate_and_allow_repeats() {
|
||||||
|
let params = ListParams::new()
|
||||||
|
.param("client_id", "abc123")
|
||||||
|
.param("status", "active")
|
||||||
|
.param("status", "paid");
|
||||||
|
|
||||||
|
assert_eq!(params.custom.len(), 3);
|
||||||
|
assert_eq!(
|
||||||
|
params.custom,
|
||||||
|
vec![
|
||||||
|
("client_id".to_string(), "abc123".to_string()),
|
||||||
|
("status".to_string(), "active".to_string()),
|
||||||
|
("status".to_string(), "paid".to_string()),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Both halves of the query must survive urlencoding, since the client
|
||||||
|
/// serializes the typed struct and the custom pairs in two passes.
|
||||||
|
#[test]
|
||||||
|
fn list_params_both_halves_urlencode() {
|
||||||
|
let params = ListParams::new().per_page(25).sort("created_at|desc");
|
||||||
|
|
||||||
|
let typed = serde_urlencoded::to_string(¶ms).unwrap();
|
||||||
|
assert!(typed.contains("per_page=25"));
|
||||||
|
assert!(typed.contains("sort=created_at%7Cdesc"));
|
||||||
|
|
||||||
|
let custom = serde_urlencoded::to_string(
|
||||||
|
&ListParams::new()
|
||||||
|
.param("client_id", "abc123")
|
||||||
|
.param("is_deleted", "false")
|
||||||
|
.custom,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(custom, "client_id=abc123&is_deleted=false");
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- newly added resource groups ---------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn company_keeps_settings_untyped_and_preserves_toggles() {
|
||||||
|
// Company has well over a hundred fields; only the structural ones are
|
||||||
|
// modeled, and `settings` stays untyped.
|
||||||
|
let payload = json!({
|
||||||
|
"id": "co1",
|
||||||
|
"company_key": "abc",
|
||||||
|
"settings": {"currency_id": "1", "timezone_id": "15"},
|
||||||
|
"enabled_modules": 32767,
|
||||||
|
"show_product_cost": true,
|
||||||
|
"markdown_enabled": true,
|
||||||
|
});
|
||||||
|
|
||||||
|
let company: Company = serde_json::from_value(payload).unwrap();
|
||||||
|
assert_eq!(company.company_key, "abc");
|
||||||
|
assert_eq!(company.enabled_modules, 32767);
|
||||||
|
assert!(company.markdown_enabled);
|
||||||
|
assert_eq!(company.settings["currency_id"], "1");
|
||||||
|
// An unmodeled feature toggle still round-trips.
|
||||||
|
let round_tripped = serde_json::to_value(&company).unwrap();
|
||||||
|
assert_eq!(round_tripped["show_product_cost"], true);
|
||||||
|
assert_eq!(round_tripped["settings"]["timezone_id"], "15");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// System logs are the one resource whose timestamps are date strings
|
||||||
|
/// rather than integers.
|
||||||
|
#[test]
|
||||||
|
fn system_log_timestamps_are_strings() {
|
||||||
|
let log: SystemLog = serde_json::from_value(json!({
|
||||||
|
"id": "sl1",
|
||||||
|
"event_id": 30,
|
||||||
|
"category_id": 1,
|
||||||
|
"type_id": 300,
|
||||||
|
"log": {"message": "gateway declined"},
|
||||||
|
"created_at": "2026-10-01 12:00:00",
|
||||||
|
"updated_at": "2026-10-01 12:00:00",
|
||||||
|
}))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(log.event_id, 30);
|
||||||
|
assert_eq!(log.created_at, "2026-10-01 12:00:00");
|
||||||
|
assert_eq!(log.log["message"], "gateway declined");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn new_resource_models_decode() {
|
||||||
|
let integration: BankIntegration = serde_json::from_value(json!({
|
||||||
|
"id": "bi1", "bank_account_id": 7788, "balance": 1234.56, "currency": "USD"
|
||||||
|
}))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(integration.bank_account_id, 7788);
|
||||||
|
assert_eq!(integration.balance, 1234.56);
|
||||||
|
|
||||||
|
let rule: BankTransactionRule = serde_json::from_value(json!({
|
||||||
|
"id": "btr1",
|
||||||
|
"name": "Coffee",
|
||||||
|
"matches_on_all": true,
|
||||||
|
"rules": [{"search_key": "description", "operator": "contains", "value": "cafe"}],
|
||||||
|
}))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(rule.rules.len(), 1);
|
||||||
|
assert_eq!(rule.rules[0]["operator"], "contains");
|
||||||
|
|
||||||
|
let gateway: CompanyGateway = serde_json::from_value(json!({
|
||||||
|
"id": "cg1", "gateway_key": "d14dd26a", "accepted_credit_cards": 32, "config": "encrypted"
|
||||||
|
}))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(gateway.accepted_credit_cards, 32);
|
||||||
|
assert_eq!(gateway.config, "encrypted");
|
||||||
|
|
||||||
|
let tag: Tag = serde_json::from_value(json!({
|
||||||
|
"id": "t1", "name": "urgent", "color": "#ff0000", "entity_type": "invoice"
|
||||||
|
}))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(tag.name, "urgent");
|
||||||
|
|
||||||
|
let location: Location = serde_json::from_value(json!({
|
||||||
|
"id": "loc1", "name": "Warehouse", "city": "Brisbane", "is_shipping_location": true
|
||||||
|
}))
|
||||||
|
.unwrap();
|
||||||
|
assert!(location.is_shipping_location);
|
||||||
|
|
||||||
|
let scheduler: TaskScheduler = serde_json::from_value(json!({
|
||||||
|
"id": "ts1", "job": "email_report", "repeat_every": "WEEK",
|
||||||
|
"start_from": 1700000000i64, "report_keys": ["date", "amount"],
|
||||||
|
}))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(scheduler.report_keys, vec!["date", "amount"]);
|
||||||
|
|
||||||
|
let token: CompanyToken = serde_json::from_value(json!({
|
||||||
|
"id": "tok1", "name": "CI token", "token": "secret", "is_system": false
|
||||||
|
}))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(token.name, "CI token");
|
||||||
|
|
||||||
|
let gw_token: ClientGatewayToken = serde_json::from_value(json!({
|
||||||
|
"id": "cgt1", "client_id": "abc123", "token": "card_x", "is_default": true
|
||||||
|
}))
|
||||||
|
.unwrap();
|
||||||
|
assert!(gw_token.is_default);
|
||||||
|
|
||||||
|
let group: GroupSetting = serde_json::from_value(json!({
|
||||||
|
"id": "gs1", "name": "Wholesale", "settings": {"currency_id": "2"}
|
||||||
|
}))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(group.settings["currency_id"], "2");
|
||||||
|
|
||||||
|
let design: Design = serde_json::from_value(json!({
|
||||||
|
"id": "d1", "name": "Clean", "is_custom": false, "is_active": true
|
||||||
|
}))
|
||||||
|
.unwrap();
|
||||||
|
assert!(design.is_active);
|
||||||
|
|
||||||
|
let user: User = serde_json::from_value(json!({
|
||||||
|
"id": "u1", "first_name": "Ada", "email": "ada@example.com", "passkey_count": 2
|
||||||
|
}))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(user.first_name, "Ada");
|
||||||
|
assert_eq!(user.passkey_count, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `Design.design` is typed as a string upstream but returned as a nested
|
||||||
|
/// object, so it is left unmodeled and must survive in `extra`.
|
||||||
|
#[test]
|
||||||
|
fn design_body_round_trips_through_extra() {
|
||||||
|
let payload = json!({
|
||||||
|
"id": "d1",
|
||||||
|
"name": "Custom",
|
||||||
|
"design": {"header": "<h1>", "body": "<div>", "footer": "<p>"},
|
||||||
|
});
|
||||||
|
|
||||||
|
let design: Design = serde_json::from_value(payload).unwrap();
|
||||||
|
let round_tripped = serde_json::to_value(&design).unwrap();
|
||||||
|
assert_eq!(round_tripped["design"]["header"], "<h1>");
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user