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);
|
||||
`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
|
||||
|
||||
- `src/client.rs` — `InvoiceNinjaClient` holds a `reqwest::Client` (with the
|
||||
`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
|
||||
dispatch and decode `ListResponse<T>` / `ItemResponse<T>` envelopes, mapping
|
||||
non-2xx responses through `api_error` into `Error::Api`.
|
||||
(`get_list`, `get_one`, `post_one`, `put_one`, `delete_one`, `post_list`)
|
||||
handle request dispatch and decode `ListResponse<T>` / `ItemResponse<T>`
|
||||
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.)
|
||||
are generated by the `resource_methods!` macro at the bottom of
|
||||
`client.rs`, which expands to
|
||||
`list`/`list_all`/`get`/`create`/`update`/`delete` for a given REST path and
|
||||
model type. **To add a new resource**: add a model under `src/models/`,
|
||||
re-export it from `src/models/mod.rs`, and add one `resource_methods!`
|
||||
invocation in `client.rs`. Note the macro takes six method names before the
|
||||
path and model — adding a resource means passing all six.
|
||||
`list`/`list_all`/`get`/`create`/`update`/`delete`/`bulk` for a given REST
|
||||
path and model type. **To add a new resource**: add a model under
|
||||
`src/models/`, re-export it from `src/models/mod.rs`, and add one
|
||||
`resource_methods!` invocation in `client.rs`. Note the macro takes seven
|
||||
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
|
||||
follows `page`/`per_page` until a page returns fewer rows than requested.
|
||||
- `src/models/` — one file per resource (`client.rs`, `invoice.rs`,
|
||||
`payment.rs`, `product.rs`, `expense.rs`, `project.rs`, `task.rs`,
|
||||
`vendor.rs`), plus `common.rs` for shared envelope/pagination types
|
||||
(`ListParams`, `ListResponse`, `ItemResponse`, `Meta`, `Pagination`).
|
||||
Invoice Ninja stores quotes and credits in the same table as invoices (an
|
||||
`entity_type` field distinguishes them), so `Quote` and `Credit` are type
|
||||
aliases for `Invoice` (`src/models/invoice.rs`) and share the
|
||||
`/quotes`, `/credits`, and `/invoices` paths via the macro.
|
||||
terminates on `meta.pagination.total_pages` and only falls back to the
|
||||
short-page heuristic when the server sends no metadata. **The heuristic is
|
||||
not sound on its own**: `BaseController` caps `per_page` at
|
||||
`min(abs(per_page), 5000)`, so a page can be shorter than requested while
|
||||
records remain — which is why `per_page` is clamped to 5000 before the
|
||||
walk starts. `per_page(0)` falls back to the default size (it would make
|
||||
the short-page check `got < 0`, which never terminates), the page counter
|
||||
advances locally rather than trusting the echoed `current_page`, an empty
|
||||
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>`
|
||||
field. This preserves any API fields not explicitly modeled, so a `get`
|
||||
followed by an `update` round-trips without silently dropping data. Keep
|
||||
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
|
||||
decode, API error responses (with per-field validation `errors` map), and
|
||||
client construction errors. `ApiErrorBody` mirrors Invoice Ninja's error
|
||||
@@ -67,6 +142,42 @@ Tests live in `tests/` (integration-style, no network access required);
|
||||
|
||||
## Coverage
|
||||
|
||||
Clients, Invoices, Quotes, Credits, Payments, Products, Expenses, Projects,
|
||||
Tasks, and Vendors, each with `list`, `list_all`, `get`, `create`, `update`,
|
||||
and `delete`.
|
||||
**All 34 of the API's `Route::resource` groups are wrapped**, plus `users`
|
||||
(which isn't one). 265 methods: 30 resources with CRUD + bulk, 3 with CRUD
|
||||
only, 4 with entity actions, and 25 hand-written.
|
||||
|
||||
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.
|
||||
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]]
|
||||
name = "atomic-waker"
|
||||
version = "1.1.2"
|
||||
@@ -70,6 +89,24 @@ version = "0.8.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "displaydoc"
|
||||
version = "0.2.7"
|
||||
@@ -81,6 +118,12 @@ dependencies = [
|
||||
"syn 3.0.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826"
|
||||
|
||||
[[package]]
|
||||
name = "errno"
|
||||
version = "0.3.14"
|
||||
@@ -103,6 +146,12 @@ version = "0.1.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48"
|
||||
|
||||
[[package]]
|
||||
name = "fnv"
|
||||
version = "1.0.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
|
||||
|
||||
[[package]]
|
||||
name = "foreign-types"
|
||||
version = "0.3.2"
|
||||
@@ -127,6 +176,21 @@ dependencies = [
|
||||
"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]]
|
||||
name = "futures-channel"
|
||||
version = "0.3.34"
|
||||
@@ -134,6 +198,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -142,6 +207,40 @@ version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "futures-task"
|
||||
version = "0.3.34"
|
||||
@@ -154,8 +253,13 @@ version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
|
||||
dependencies = [
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"futures-io",
|
||||
"futures-macro",
|
||||
"futures-sink",
|
||||
"futures-task",
|
||||
"memchr",
|
||||
"pin-project-lite",
|
||||
"slab",
|
||||
]
|
||||
@@ -171,6 +275,37 @@ dependencies = [
|
||||
"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]]
|
||||
name = "http"
|
||||
version = "1.5.0"
|
||||
@@ -210,6 +345,12 @@ version = "1.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
|
||||
|
||||
[[package]]
|
||||
name = "httpdate"
|
||||
version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
|
||||
|
||||
[[package]]
|
||||
name = "hyper"
|
||||
version = "1.12.0"
|
||||
@@ -218,10 +359,13 @@ checksum = "2c3e324da4c95177d6291d4c8730197c0d1822f8a9766814a4a44fa5ab797c9c"
|
||||
dependencies = [
|
||||
"atomic-waker",
|
||||
"bytes",
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"h2",
|
||||
"http",
|
||||
"http-body",
|
||||
"httparse",
|
||||
"httpdate",
|
||||
"itoa",
|
||||
"pin-project-lite",
|
||||
"smallvec",
|
||||
@@ -373,15 +517,28 @@ dependencies = [
|
||||
"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]]
|
||||
name = "invoiceninjars"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"percent-encoding",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_urlencoded",
|
||||
"thiserror",
|
||||
"tokio",
|
||||
"wiremock",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -407,6 +564,12 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lazy_static"
|
||||
version = "1.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb"
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.191"
|
||||
@@ -465,6 +628,16 @@ dependencies = [
|
||||
"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]]
|
||||
name = "once_cell"
|
||||
version = "1.21.4"
|
||||
@@ -565,6 +738,35 @@ version = "6.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "reqwest"
|
||||
version = "0.12.28"
|
||||
@@ -877,6 +1079,20 @@ dependencies = [
|
||||
"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]]
|
||||
name = "tower"
|
||||
version = "0.5.3"
|
||||
@@ -1073,6 +1289,29 @@ dependencies = [
|
||||
"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]]
|
||||
name = "writeable"
|
||||
version = "0.6.4"
|
||||
|
||||
@@ -11,6 +11,14 @@ reqwest = { version = "0.12", default-features = false, features = ["json", "nat
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
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]
|
||||
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
|
||||
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(
|
||||
"https://invoicing.co/api/v1", // or your self-hosted base URL
|
||||
"https://your-domain.example/api/v1",
|
||||
std::env::var("IN_API_TOKEN")?,
|
||||
)?;
|
||||
|
||||
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_*`
|
||||
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?;
|
||||
```
|
||||
|
||||
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`):
|
||||
|
||||
```sh
|
||||
@@ -38,7 +67,74 @@ IN_API_TOKEN=... cargo run --example list_clients
|
||||
|
||||
## Coverage
|
||||
|
||||
Clients, Invoices, Quotes, Credits, Payments, Products, Expenses, Projects,
|
||||
Tasks, and Vendors, each with `list`, `list_all`, `get`, `create`, `update`,
|
||||
and `delete`. Add more resources by adding a model under `src/models/` and a
|
||||
Every one of the API's 34 resource groups, plus users — 265 methods. Each
|
||||
resource has `list`, `list_all`, `get`, `create`, `update`, `delete`, and
|
||||
(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`.
|
||||
|
||||
+856
-23
File diff suppressed because it is too large
Load Diff
@@ -30,6 +30,26 @@ pub enum Error {
|
||||
|
||||
#[error("invalid api token: {0}")]
|
||||
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>;
|
||||
|
||||
+1
-1
@@ -18,5 +18,5 @@ mod client;
|
||||
mod error;
|
||||
pub mod models;
|
||||
|
||||
pub use client::InvoiceNinjaClient;
|
||||
pub use client::{InvoiceNinjaClient, HOSTED_BASE_URL};
|
||||
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 {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub id: Option<String>,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub id_number: String,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub vat_number: String,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub address1: String,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub address2: String,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub city: String,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub state: String,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub postal_code: String,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub country_id: String,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub phone: String,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub website: String,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub private_notes: String,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub balance: f64,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub paid_to_date: f64,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub credit_balance: f64,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub is_deleted: bool,
|
||||
#[serde(default)]
|
||||
pub archived_at: Option<i64>,
|
||||
@@ -43,7 +43,7 @@ pub struct Client {
|
||||
pub created_at: Option<i64>,
|
||||
#[serde(default)]
|
||||
pub updated_at: Option<i64>,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub contacts: Vec<ClientContact>,
|
||||
|
||||
/// Any fields returned by the API that aren't modeled above are preserved here,
|
||||
@@ -56,15 +56,15 @@ pub struct Client {
|
||||
pub struct ClientContact {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub id: Option<String>,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub first_name: String,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub last_name: String,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub email: String,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub phone: String,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub is_primary: bool,
|
||||
|
||||
#[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)]
|
||||
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>,
|
||||
#[serde(default)]
|
||||
pub meta: Option<Meta>,
|
||||
@@ -44,6 +49,19 @@ pub struct ListParams {
|
||||
/// Set to 1 to include archived/deleted records.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
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 {
|
||||
@@ -80,4 +98,57 @@ impl ListParams {
|
||||
self.status = Some(status.into());
|
||||
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 {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub id: Option<String>,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub vendor_id: String,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub client_id: String,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub invoice_id: String,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub amount: f64,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub exchange_rate: f64,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub public_notes: String,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub private_notes: String,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub date: String,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub payment_date: String,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub transaction_reference: String,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub category_id: String,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub tax_name1: String,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub tax_rate1: f64,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub tax_name2: String,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub tax_rate2: f64,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub should_be_invoiced: bool,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub is_deleted: bool,
|
||||
#[serde(default)]
|
||||
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 {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub id: Option<String>,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub client_id: String,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub number: String,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub date: String,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub due_date: String,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub po_number: String,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub status_id: String,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub amount: f64,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub balance: f64,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub discount: f64,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub is_amount_discount: bool,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub public_notes: String,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub private_notes: String,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub terms: String,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub footer: String,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub is_deleted: bool,
|
||||
#[serde(default)]
|
||||
pub archived_at: Option<i64>,
|
||||
@@ -41,7 +41,7 @@ pub struct Invoice {
|
||||
pub created_at: Option<i64>,
|
||||
#[serde(default)]
|
||||
pub updated_at: Option<i64>,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub line_items: Vec<InvoiceItem>,
|
||||
|
||||
#[serde(flatten)]
|
||||
@@ -50,25 +50,25 @@ pub struct Invoice {
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct InvoiceItem {
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub product_key: String,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub notes: String,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub cost: f64,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub quantity: f64,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub discount: f64,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub tax_name1: String,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub tax_rate1: f64,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub tax_name2: String,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub tax_rate2: f64,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub line_total: f64,
|
||||
|
||||
#[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_gateway_token;
|
||||
mod common;
|
||||
mod company;
|
||||
mod company_gateway;
|
||||
mod company_token;
|
||||
mod design;
|
||||
mod document;
|
||||
mod expense;
|
||||
mod expense_category;
|
||||
mod group_setting;
|
||||
mod invoice;
|
||||
mod location;
|
||||
mod payment;
|
||||
mod payment_term;
|
||||
mod product;
|
||||
mod project;
|
||||
mod purchase_order;
|
||||
mod recurring_expense;
|
||||
mod recurring_invoice;
|
||||
mod subscription;
|
||||
mod system_log;
|
||||
mod tag;
|
||||
mod task;
|
||||
mod task_scheduler;
|
||||
mod task_status;
|
||||
mod tax_rate;
|
||||
mod user;
|
||||
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 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_category::ExpenseCategory;
|
||||
pub use group_setting::GroupSetting;
|
||||
pub use invoice::{Credit, Invoice, InvoiceItem, Quote};
|
||||
pub use location::Location;
|
||||
pub use payment::{Payment, PaymentInvoice};
|
||||
pub use payment_term::PaymentTerm;
|
||||
pub use product::Product;
|
||||
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_scheduler::TaskScheduler;
|
||||
pub use task_status::TaskStatus;
|
||||
pub use tax_rate::TaxRate;
|
||||
pub use user::User;
|
||||
pub use vendor::{Vendor, VendorContact};
|
||||
pub use webhook::Webhook;
|
||||
|
||||
+10
-10
@@ -5,23 +5,23 @@ use serde_json::{Map, Value};
|
||||
pub struct Payment {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub id: Option<String>,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub client_id: String,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub amount: f64,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub applied: f64,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub refunded: f64,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub date: String,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub transaction_reference: String,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub type_id: String,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub private_notes: String,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub is_deleted: bool,
|
||||
#[serde(default)]
|
||||
pub archived_at: Option<i64>,
|
||||
@@ -29,7 +29,7 @@ pub struct Payment {
|
||||
pub created_at: Option<i64>,
|
||||
#[serde(default)]
|
||||
pub updated_at: Option<i64>,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub invoices: Vec<PaymentInvoice>,
|
||||
|
||||
#[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 {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub id: Option<String>,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub product_key: String,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub notes: String,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub cost: f64,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub price: f64,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub quantity: f64,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub tax_name1: String,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub tax_rate1: f64,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub tax_name2: String,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub tax_rate2: f64,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub is_deleted: bool,
|
||||
#[serde(default)]
|
||||
pub archived_at: Option<i64>,
|
||||
|
||||
+10
-10
@@ -5,25 +5,25 @@ use serde_json::{Map, Value};
|
||||
pub struct Project {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub id: Option<String>,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub client_id: String,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub number: String,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub task_rate: f64,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub due_date: String,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub budgeted_hours: f64,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub color: String,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub public_notes: String,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub private_notes: String,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub is_deleted: bool,
|
||||
#[serde(default)]
|
||||
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 {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub id: Option<String>,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub client_id: String,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub invoice_id: String,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub project_id: String,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub description: String,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub rate: f64,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub status_id: String,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub is_running: bool,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub is_deleted: bool,
|
||||
/// 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,
|
||||
#[serde(default)]
|
||||
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 {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub id: Option<String>,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub id_number: String,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub vat_number: String,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub address1: String,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub address2: String,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub city: String,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub state: String,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub postal_code: String,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub country_id: String,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub phone: String,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub website: String,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub private_notes: String,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub is_deleted: bool,
|
||||
#[serde(default)]
|
||||
pub archived_at: Option<i64>,
|
||||
@@ -37,7 +37,7 @@ pub struct Vendor {
|
||||
pub created_at: Option<i64>,
|
||||
#[serde(default)]
|
||||
pub updated_at: Option<i64>,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub contacts: Vec<VendorContact>,
|
||||
|
||||
/// Any fields returned by the API that aren't modeled above are preserved here,
|
||||
@@ -50,15 +50,15 @@ pub struct Vendor {
|
||||
pub struct VendorContact {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub id: Option<String>,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub first_name: String,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub last_name: String,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub email: String,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub phone: String,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "super::common::null_to_default")]
|
||||
pub is_primary: bool,
|
||||
|
||||
#[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::{
|
||||
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;
|
||||
|
||||
@@ -150,3 +154,543 @@ fn quote_and_credit_share_the_invoice_shape() {
|
||||
let credit: Credit = serde_json::from_value(payload).unwrap();
|
||||
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