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:
2026-10-23 18:08:38 -04:00
co-authored by Claude Opus 5
parent ff9a859d0b
commit 9976249210
44 changed files with 4003 additions and 175 deletions
+132 -21
View File
@@ -29,37 +29,112 @@ IN_API_TOKEN=... cargo run --example list_clients
``` ```
Tests live in `tests/` (integration-style, no network access required); Tests live in `tests/` (integration-style, no network access required);
`src/` has no `#[test]` modules. `src/` has no `#[test]` modules. Two files: `model_roundtrip.rs` covers the
serde layer, and `client_requests.rs` covers the request plumbing
(pagination, path escaping, headers, timeouts, error mapping) against a
`wiremock` server on loopback — no credentials or internet needed.
## Architecture ## Architecture
- `src/client.rs` — `InvoiceNinjaClient` holds a `reqwest::Client` (with the - `src/client.rs` — `InvoiceNinjaClient` holds a `reqwest::Client` (with the
`X-Api-Token` auth header baked in) and a `base_url`. Low-level helpers `X-Api-Token` auth header baked in) and a `base_url`. Low-level helpers
(`get_list`, `get_one`, `post_one`, `put_one`, `delete_one`) handle request (`get_list`, `get_one`, `post_one`, `put_one`, `delete_one`, `post_list`)
dispatch and decode `ListResponse<T>` / `ItemResponse<T>` envelopes, mapping handle request dispatch and decode `ListResponse<T>` / `ItemResponse<T>`
non-2xx responses through `api_error` into `Error::Api`. envelopes, mapping non-2xx responses through `api_error` into `Error::Api`.
- Not every endpoint answers with a JSON envelope, so there are three other
helper families: `get_raw`/`post_raw` return the body as bytes (PDF streams
and CSV report exports), `get_unwrapped`/`post_unwrapped` decode a bare
JSON body with no `data` wrapper (`/statics`, `/charts/*`, `/search`), and
`get_discard` just checks the status (`/ping`).
- Per-resource methods (`list_clients`, `get_invoice`, `create_product`, etc.) - Per-resource methods (`list_clients`, `get_invoice`, `create_product`, etc.)
are generated by the `resource_methods!` macro at the bottom of are generated by the `resource_methods!` macro at the bottom of
`client.rs`, which expands to `client.rs`, which expands to
`list`/`list_all`/`get`/`create`/`update`/`delete` for a given REST path and `list`/`list_all`/`get`/`create`/`update`/`delete`/`bulk` for a given REST
model type. **To add a new resource**: add a model under `src/models/`, path and model type. **To add a new resource**: add a model under
re-export it from `src/models/mod.rs`, and add one `resource_methods!` `src/models/`, re-export it from `src/models/mod.rs`, and add one
invocation in `client.rs`. Note the macro takes six method names before the `resource_methods!` invocation in `client.rs`. Note the macro takes seven
path and model — adding a resource means passing all six. method names before the path and model — adding a resource means passing
all seven.
- `resource_methods!` delegates the six CRUD methods to `crud_methods!` and
adds `bulk` on top. **Use `crud_methods!` directly for resources with no
`/bulk` route** — currently `client_gateway_tokens`, `companies`, and
`system_logs`. Check `routes/api.php` before assuming a resource has one;
a generated `bulk_*` against a missing route just 405s.
- Every request is built through the private `req(method, path)` helper,
which is the single place the optional `X-API-PASSWORD` header and the
per-request timeout are attached. **Don't call `self.http` directly in a
new helper** — it would silently skip both. Only `req` itself should touch
`self.http`.
- **Interpolate ids into paths with `seg(id)?`, never raw.** It escapes the
value into a single path segment and rejects ids that are empty or
dot-only. Both halves matter: escaping stops a `/` or `?` in an id from
retargeting the request, but escaping *alone* is not enough, because the
WHATWG rules the `url` crate follows decode `%2E` back to `.` before
resolving dot segments — so an escaped `..` still collapses the path. The
exceptions are `report` and `chart`, which take a multi-segment tail by
design (`chart("project_burnup/<id>", …)`) and must stay unescaped.
- `entity_action_methods!` generates three methods — `{x}_action` and
`download_{x}` for the `GET /{resource}/{id}/{action}` route, plus
`download_{plural}` for the bulk merged-PDF action. **Only invoices,
quotes, credits, and purchase orders have the action route** — don't add it
elsewhere without checking `routes/api.php`. In particular the official PHP
SDK puts bulk `download` on *every* resource, which is wrong; only
PDF-rendering entities support it.
- Non-CRUD endpoints (documents, reports, charts, search, statics, emails,
refunds, ping/health) are hand-written in the last `impl` block rather than
macro-generated, because their request and response shapes are all
different.
- `list_*` returns one page; `list_all_*` goes through `get_all_pages`, which - `list_*` returns one page; `list_all_*` goes through `get_all_pages`, which
follows `page`/`per_page` until a page returns fewer rows than requested. terminates on `meta.pagination.total_pages` and only falls back to the
- `src/models/` — one file per resource (`client.rs`, `invoice.rs`, short-page heuristic when the server sends no metadata. **The heuristic is
`payment.rs`, `product.rs`, `expense.rs`, `project.rs`, `task.rs`, not sound on its own**: `BaseController` caps `per_page` at
`vendor.rs`), plus `common.rs` for shared envelope/pagination types `min(abs(per_page), 5000)`, so a page can be shorter than requested while
(`ListParams`, `ListResponse`, `ItemResponse`, `Meta`, `Pagination`). records remain — which is why `per_page` is clamped to 5000 before the
Invoice Ninja stores quotes and credits in the same table as invoices (an walk starts. `per_page(0)` falls back to the default size (it would make
`entity_type` field distinguishes them), so `Quote` and `Credit` are type the short-page check `got < 0`, which never terminates), the page counter
aliases for `Invoice` (`src/models/invoice.rs`) and share the advances locally rather than trusting the echoed `current_page`, an empty
`/quotes`, `/credits`, and `/invoices` paths via the macro. page always ends the walk, and a `MAX_PAGES` backstop raises
`Error::PaginationLimit` rather than quietly returning a truncated list.
- `src/models/` — one file per resource, plus `common.rs` for shared
envelope/pagination types (`ListParams`, `ListResponse`, `ItemResponse`,
`Meta`, `Pagination`).
- Several endpoints share a schema, so some models are type aliases rather
than separate structs: Invoice Ninja keeps quotes and credits in the
invoices table (an `entity_type` field distinguishes them), so `Quote` and
`Credit` alias `Invoice` (`src/models/invoice.rs`); likewise
`RecurringQuote` aliases `RecurringInvoice`
(`src/models/recurring_invoice.rs`), whose fields are a strict superset.
Each alias still gets its own path via the macro.
- Every model struct has a `#[serde(flatten)] pub extra: Map<String, Value>` - Every model struct has a `#[serde(flatten)] pub extra: Map<String, Value>`
field. This preserves any API fields not explicitly modeled, so a `get` field. This preserves any API fields not explicitly modeled, so a `get`
followed by an `update` round-trips without silently dropping data. Keep followed by an `update` round-trips without silently dropping data. Keep
this pattern when adding fields or new models. this pattern when adding fields or new models.
- **Every non-`Option` field carries
`#[serde(default, deserialize_with = "super::common::null_to_default")]`,
not a bare `#[serde(default)]`.** `default` alone only covers a *missing*
key; a key present with an explicit `null` fails with `invalid type: null,
expected a string`. Invoice Ninja returns `null` for many
nominally-string fields, so a bare `default` would break decoding of the
whole response. `Option<T>` fields keep the plain `#[serde(default)]` —
they already map `null` to `None`, and that distinction matters (an unset
`archived_at` should stay `None`, not become `Some(0)`).
- Models deliberately cover a curated subset of each schema and let `extra`
carry the rest — `custom_value1..4` and `custom_surcharge1..4` are
intentionally unmodeled. Fields whose JSON type is ambiguous or
inconsistent in the upstream spec (e.g. `Subscription.currency_id`,
`webhook_configuration`, `due_date_days`) are also left to `extra`, since a
wrong concrete type is a hard deserialize error whereas `extra` is lossless.
- Field names and types come from Invoice Ninja's own OpenAPI spec
(`openapi/api-docs.yaml` on the `v5-stable` branch of the invoiceninja
repo). Check it before adding or retyping a field; the same name can differ
in type across resources (`frequency_id` is a number on recurring invoices
and expenses but a string on subscriptions).
- **The published spec is incomplete for routes**: it documents `/{id}` and
action paths only for clients, locations, and tags, and omits them for
invoices and everything else. `routes/api.php` in the same repo is the
authoritative route list, and the controllers are authoritative for what a
given action returns. Use the spec for *schemas*, the routes and
controllers for *endpoints*.
- `src/error.rs` — `Error` enum (`thiserror`) covering transport, JSON - `src/error.rs` — `Error` enum (`thiserror`) covering transport, JSON
decode, API error responses (with per-field validation `errors` map), and decode, API error responses (with per-field validation `errors` map), and
client construction errors. `ApiErrorBody` mirrors Invoice Ninja's error client construction errors. `ApiErrorBody` mirrors Invoice Ninja's error
@@ -67,6 +142,42 @@ Tests live in `tests/` (integration-style, no network access required);
## Coverage ## Coverage
Clients, Invoices, Quotes, Credits, Payments, Products, Expenses, Projects, **All 34 of the API's `Route::resource` groups are wrapped**, plus `users`
Tasks, and Vendors, each with `list`, `list_all`, `get`, `create`, `update`, (which isn't one). 265 methods: 30 resources with CRUD + bulk, 3 with CRUD
and `delete`. only, 4 with entity actions, and 25 hand-written.
Plus the non-CRUD surface: documents, entity actions and PDF downloads for
the four action-capable resources, `report`, `chart`, `search`, `statics`,
`send_email`, `refund_payment`, `ping`, and `health_check`.
**`COVERAGE.md` is the detailed inventory** — every wrapped endpoint, every
gap, and the reason for each gap. The main remaining gaps are multipart
uploads (needs a reqwest feature), a handful of per-resource extras
(`clients/{id}/purge`, `company_gateways/{id}/test`, …), and the
integration/auth routes.
Keep `COVERAGE.md` current when adding or removing an endpoint: it names
exact counts and route groups, so it goes stale quietly. The three coverage
summaries — here, `README.md`, and `COVERAGE.md` — should agree;
`COVERAGE.md` is the one with the specifics, the other two summarize and
link to it.
## Relationship to the official PHP SDK
[invoiceninja/sdk-php](https://github.com/invoiceninja/sdk-php) is the
first-party wrapper and a useful cross-check, but it is thinner than this
crate — each endpoint class is just a URI over a shared `BaseEntity`. See the
comparison table in `COVERAGE.md` for what each side covers.
Two divergences are deliberate and shouldn't be "fixed" to match it:
- **We send JSON; it sends `form_params`.** Form encoding bracket-encodes
nested structures, which is bad for payloads like an invoice's `line_items`.
The API accepts both.
- **We keep the whole `errors` map on `Error::Api`;** it surfaces only the
first validation message.
And two of its behaviours are bugs not to copy: it sends `stream: false` in
download payloads (no controller reads a `stream` input), and it exposes
`download` on every resource even though only PDF-rendering entities support
that action.
+290
View File
@@ -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
View File
@@ -2,6 +2,25 @@
# It is not intended for manual editing. # It is not intended for manual editing.
version = 4 version = 4
[[package]]
name = "aho-corasick"
version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba"
dependencies = [
"memchr",
]
[[package]]
name = "assert-json-diff"
version = "2.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12"
dependencies = [
"serde",
"serde_json",
]
[[package]] [[package]]
name = "atomic-waker" name = "atomic-waker"
version = "1.1.2" version = "1.1.2"
@@ -70,6 +89,24 @@ version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
[[package]]
name = "deadpool"
version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0be2b1d1d6ec8d846f05e137292d0b89133caf95ef33695424c09568bdd39b1b"
dependencies = [
"deadpool-runtime",
"lazy_static",
"num_cpus",
"tokio",
]
[[package]]
name = "deadpool-runtime"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b"
[[package]] [[package]]
name = "displaydoc" name = "displaydoc"
version = "0.2.7" version = "0.2.7"
@@ -81,6 +118,12 @@ dependencies = [
"syn 3.0.9", "syn 3.0.9",
] ]
[[package]]
name = "equivalent"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826"
[[package]] [[package]]
name = "errno" name = "errno"
version = "0.3.14" version = "0.3.14"
@@ -103,6 +146,12 @@ version = "0.1.15"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48" checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48"
[[package]]
name = "fnv"
version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
[[package]] [[package]]
name = "foreign-types" name = "foreign-types"
version = "0.3.2" version = "0.3.2"
@@ -127,6 +176,21 @@ dependencies = [
"percent-encoding", "percent-encoding",
] ]
[[package]]
name = "futures"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3"
dependencies = [
"futures-channel",
"futures-core",
"futures-executor",
"futures-io",
"futures-sink",
"futures-task",
"futures-util",
]
[[package]] [[package]]
name = "futures-channel" name = "futures-channel"
version = "0.3.34" version = "0.3.34"
@@ -134,6 +198,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4"
dependencies = [ dependencies = [
"futures-core", "futures-core",
"futures-sink",
] ]
[[package]] [[package]]
@@ -142,6 +207,40 @@ version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
[[package]]
name = "futures-executor"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432"
dependencies = [
"futures-core",
"futures-task",
"futures-util",
]
[[package]]
name = "futures-io"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed"
[[package]]
name = "futures-macro"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.9",
]
[[package]]
name = "futures-sink"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d"
[[package]] [[package]]
name = "futures-task" name = "futures-task"
version = "0.3.34" version = "0.3.34"
@@ -154,8 +253,13 @@ version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
dependencies = [ dependencies = [
"futures-channel",
"futures-core", "futures-core",
"futures-io",
"futures-macro",
"futures-sink",
"futures-task", "futures-task",
"memchr",
"pin-project-lite", "pin-project-lite",
"slab", "slab",
] ]
@@ -171,6 +275,37 @@ dependencies = [
"r-efi", "r-efi",
] ]
[[package]]
name = "h2"
version = "0.4.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d29020232d6aa3fb1daca64c1127cf662cf97f254ae16c18c05b8ab635fc118"
dependencies = [
"atomic-waker",
"bytes",
"fnv",
"futures-core",
"futures-sink",
"http",
"indexmap",
"slab",
"tokio",
"tokio-util",
"tracing",
]
[[package]]
name = "hashbrown"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
[[package]]
name = "hermit-abi"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284"
[[package]] [[package]]
name = "http" name = "http"
version = "1.5.0" version = "1.5.0"
@@ -210,6 +345,12 @@ version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
[[package]]
name = "httpdate"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
[[package]] [[package]]
name = "hyper" name = "hyper"
version = "1.12.0" version = "1.12.0"
@@ -218,10 +359,13 @@ checksum = "2c3e324da4c95177d6291d4c8730197c0d1822f8a9766814a4a44fa5ab797c9c"
dependencies = [ dependencies = [
"atomic-waker", "atomic-waker",
"bytes", "bytes",
"futures-channel",
"futures-core", "futures-core",
"h2",
"http", "http",
"http-body", "http-body",
"httparse", "httparse",
"httpdate",
"itoa", "itoa",
"pin-project-lite", "pin-project-lite",
"smallvec", "smallvec",
@@ -373,15 +517,28 @@ dependencies = [
"icu_properties", "icu_properties",
] ]
[[package]]
name = "indexmap"
version = "2.14.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
dependencies = [
"equivalent",
"hashbrown",
]
[[package]] [[package]]
name = "invoiceninjars" name = "invoiceninjars"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"percent-encoding",
"reqwest", "reqwest",
"serde", "serde",
"serde_json", "serde_json",
"serde_urlencoded",
"thiserror", "thiserror",
"tokio", "tokio",
"wiremock",
] ]
[[package]] [[package]]
@@ -407,6 +564,12 @@ dependencies = [
"wasm-bindgen", "wasm-bindgen",
] ]
[[package]]
name = "lazy_static"
version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb"
[[package]] [[package]]
name = "libc" name = "libc"
version = "0.2.191" version = "0.2.191"
@@ -465,6 +628,16 @@ dependencies = [
"tempfile", "tempfile",
] ]
[[package]]
name = "num_cpus"
version = "1.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b"
dependencies = [
"hermit-abi",
"libc",
]
[[package]] [[package]]
name = "once_cell" name = "once_cell"
version = "1.21.4" version = "1.21.4"
@@ -565,6 +738,35 @@ version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "regex"
version = "1.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d"
dependencies = [
"aho-corasick",
"memchr",
"regex-automata",
"regex-syntax",
]
[[package]]
name = "regex-automata"
version = "0.4.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2"
dependencies = [
"aho-corasick",
"memchr",
"regex-syntax",
]
[[package]]
name = "regex-syntax"
version = "0.8.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
[[package]] [[package]]
name = "reqwest" name = "reqwest"
version = "0.12.28" version = "0.12.28"
@@ -877,6 +1079,20 @@ dependencies = [
"tokio", "tokio",
] ]
[[package]]
name = "tokio-util"
version = "0.7.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e464cf451ba96ebfc6f9b6542f17ee8b8956e33f1e40d9690624e59d7a7f8a4b"
dependencies = [
"bytes",
"futures-core",
"futures-sink",
"libc",
"pin-project-lite",
"tokio",
]
[[package]] [[package]]
name = "tower" name = "tower"
version = "0.5.3" version = "0.5.3"
@@ -1073,6 +1289,29 @@ dependencies = [
"windows-link", "windows-link",
] ]
[[package]]
name = "wiremock"
version = "0.6.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08db1edfb05d9b3c1542e521aea074442088292f00b5f28e435c714a98f85031"
dependencies = [
"assert-json-diff",
"base64 0.22.1",
"deadpool",
"futures",
"http",
"http-body-util",
"hyper",
"hyper-util",
"log",
"once_cell",
"regex",
"serde",
"serde_json",
"tokio",
"url",
]
[[package]] [[package]]
name = "writeable" name = "writeable"
version = "0.6.4" version = "0.6.4"
+8
View File
@@ -11,6 +11,14 @@ reqwest = { version = "0.12", default-features = false, features = ["json", "nat
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
thiserror = "2" thiserror = "2"
# Already in the tree via reqwest's `url`; used to escape ids before they are
# interpolated into request paths.
percent-encoding = "2"
[dev-dependencies] [dev-dependencies]
tokio = { version = "1", features = ["rt-multi-thread", "macros"] } tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
# Used to assert that ListParams urlencodes the way reqwest will send it.
serde_urlencoded = "0.7"
# Loopback HTTP server for exercising the request plumbing (pagination, path
# escaping, headers, error mapping) without reaching the network.
wiremock = "0.6"
+100 -4
View File
@@ -15,14 +15,33 @@ nix develop
```rust ```rust
use invoiceninjars::{InvoiceNinjaClient, models::ListParams}; use invoiceninjars::{InvoiceNinjaClient, models::ListParams};
// Hosted app:
let client = InvoiceNinjaClient::hosted(std::env::var("IN_API_TOKEN")?)?;
// Self-hosted — note the base URL must include the `/api/v1` suffix:
let client = InvoiceNinjaClient::new( let client = InvoiceNinjaClient::new(
"https://invoicing.co/api/v1", // or your self-hosted base URL "https://your-domain.example/api/v1",
std::env::var("IN_API_TOKEN")?, std::env::var("IN_API_TOKEN")?,
)?; )?;
let clients = client.list_clients(&ListParams::new().per_page(50)).await?; let clients = client.list_clients(&ListParams::new().per_page(50)).await?;
``` ```
`ListParams` has typed setters for the parameters every list endpoint
accepts, plus `param` for the per-resource filters:
```rust
let unpaid = client
.list_invoices(
&ListParams::new()
.per_page(100)
.sort("date|desc")
.param("client_id", "abc123")
.param("client_status", "unpaid"),
)
.await?;
```
List endpoints are paginated. `list_*` returns a single page; `list_all_*` List endpoints are paginated. `list_*` returns a single page; `list_all_*`
follows pagination and returns every matching record: follows pagination and returns every matching record:
@@ -30,6 +49,16 @@ follows pagination and returns every matching record:
let all_clients = client.list_all_clients(&ListParams::new()).await?; let all_clients = client.list_all_clients(&ListParams::new()).await?;
``` ```
Every request carries a 60-second deadline by default, since `reqwest`
applies none of its own. Override it for slow report exports or large
`list_all_*` walks:
```rust
use std::time::Duration;
let client = InvoiceNinjaClient::hosted(token)?.with_timeout(Duration::from_secs(300));
```
Run the example (requires `IN_API_TOKEN`, optionally `IN_BASE_URL`): Run the example (requires `IN_API_TOKEN`, optionally `IN_BASE_URL`):
```sh ```sh
@@ -38,7 +67,74 @@ IN_API_TOKEN=... cargo run --example list_clients
## Coverage ## Coverage
Clients, Invoices, Quotes, Credits, Payments, Products, Expenses, Projects, Every one of the API's 34 resource groups, plus users — 265 methods. Each
Tasks, and Vendors, each with `list`, `list_all`, `get`, `create`, `update`, resource has `list`, `list_all`, `get`, `create`, `update`, `delete`, and
and `delete`. Add more resources by adding a model under `src/models/` and a (where the API offers one) `bulk`:
| Area | Resources |
| --- | --- |
| Billing | Clients, Invoices, Quotes, Credits, Payments, Products, Purchase Orders |
| Recurring | Recurring Invoices, Recurring Quotes, Recurring Expenses, Subscriptions |
| Work | Projects, Tasks, Task Statuses, Task Schedulers |
| Spend | Expenses, Expense Categories, Vendors, Bank Transactions |
| Banking | Bank Integrations, Bank Transaction Rules, Company Gateways, Client Gateway Tokens |
| Admin | Companies, Users, Group Settings, Tokens, System Logs |
| Settings | Tax Rates, Payment Terms, Webhooks, Designs, Locations, Tags |
Plus documents, entity actions and PDF downloads, reports, charts, search,
statics, email sending, refunds, and health checks.
**See [COVERAGE.md](COVERAGE.md) for the full endpoint-by-endpoint
inventory**, including everything that is deliberately *not* wrapped and why.
Models cover a curated subset of each schema; any field the API returns that
isn't modeled explicitly (including `custom_value1..4` and
`custom_surcharge1..4`) is preserved in that model's `extra` map, so a `get`
followed by an `update` round-trips without dropping data.
### Beyond CRUD
Bulk actions return the affected records. They're available on every
resource the API gives a `/bulk` route — all but companies, system logs, and
client gateway tokens:
```rust
let archived = client.bulk_invoices("archive", &["abc123", "def456"]).await?;
```
Some endpoints — reading or writing an individual user, `/users/bulk`,
purging a client or company — sit behind the API's `password_protected`
middleware and need the account password on top of a valid token:
```rust
let client = InvoiceNinjaClient::hosted(std::env::var("IN_API_TOKEN")?)?
.with_api_password(std::env::var("IN_API_PASSWORD")?)?;
let user = client.get_user("abc123").await?;
```
Invoices, quotes, credits, and purchase orders also support per-entity
actions and PDF rendering:
```rust
let invoice = client.mark_invoice_sent("abc123").await?;
let pdf: Vec<u8> = client.download_invoice("abc123").await?;
// The bulk `download` action streams one merged PDF. Available for quotes,
// credits, and purchase orders too.
let merged: Vec<u8> = client.download_invoices(&["abc123", "def456"]).await?;
```
Reports return their raw export body (CSV for most of them), and the
schemaless endpoints decode into any type you choose:
```rust
use serde_json::{json, Value};
let csv: Vec<u8> = client.report("invoices", &json!({"date_range": "this_year"})).await?;
let totals: Value = client.chart("totals_v2", &json!({})).await?;
let statics: Value = client.statics().await?;
```
Add more resources by adding a model under `src/models/` and a
`resource_methods!` invocation in `src/client.rs`. `resource_methods!` invocation in `src/client.rs`.
+856 -23
View File
File diff suppressed because it is too large Load Diff
+20
View File
@@ -30,6 +30,26 @@ pub enum Error {
#[error("invalid api token: {0}")] #[error("invalid api token: {0}")]
InvalidToken(String), InvalidToken(String),
/// An id or action could not be used as a single URL path segment.
///
/// Raised for values that would change which endpoint is addressed rather
/// than identifying a record — an empty id (which would hit the
/// collection route) or one made only of dots (which the URL parser
/// resolves away). Invoice Ninja ids are hashids, so this only rejects
/// input that was never a valid id.
#[error("`{0}` cannot be used as a url path segment")]
InvalidPathSegment(String),
/// A `list_all_*` call kept receiving full pages past the point where any
/// plausible result set would have ended.
///
/// This means the server is not paginating the way the client expects —
/// typically because it ignores the `page` parameter and keeps replaying
/// the same page. Raised instead of returning the partial result, so the
/// truncation cannot be mistaken for a complete answer.
#[error("pagination did not terminate for {path} after {pages} pages")]
PaginationLimit { path: String, pages: u64 },
} }
pub type Result<T> = std::result::Result<T, Error>; pub type Result<T> = std::result::Result<T, Error>;
+1 -1
View File
@@ -18,5 +18,5 @@ mod client;
mod error; mod error;
pub mod models; pub mod models;
pub use client::InvoiceNinjaClient; pub use client::{InvoiceNinjaClient, HOSTED_BASE_URL};
pub use error::{ApiErrorBody, Error, Result}; pub use error::{ApiErrorBody, Error, Result};
+39
View File
@@ -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>,
}
+43
View File
@@ -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>,
}
+40
View File
@@ -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
View File
@@ -5,37 +5,37 @@ use serde_json::{Map, Value};
pub struct Client { pub struct Client {
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub id: Option<String>, pub id: Option<String>,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub name: String, pub name: String,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub id_number: String, pub id_number: String,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub vat_number: String, pub vat_number: String,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub address1: String, pub address1: String,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub address2: String, pub address2: String,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub city: String, pub city: String,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub state: String, pub state: String,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub postal_code: String, pub postal_code: String,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub country_id: String, pub country_id: String,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub phone: String, pub phone: String,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub website: String, pub website: String,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub private_notes: String, pub private_notes: String,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub balance: f64, pub balance: f64,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub paid_to_date: f64, pub paid_to_date: f64,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub credit_balance: f64, pub credit_balance: f64,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub is_deleted: bool, pub is_deleted: bool,
#[serde(default)] #[serde(default)]
pub archived_at: Option<i64>, pub archived_at: Option<i64>,
@@ -43,7 +43,7 @@ pub struct Client {
pub created_at: Option<i64>, pub created_at: Option<i64>,
#[serde(default)] #[serde(default)]
pub updated_at: Option<i64>, pub updated_at: Option<i64>,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub contacts: Vec<ClientContact>, pub contacts: Vec<ClientContact>,
/// Any fields returned by the API that aren't modeled above are preserved here, /// Any fields returned by the API that aren't modeled above are preserved here,
@@ -56,15 +56,15 @@ pub struct Client {
pub struct ClientContact { pub struct ClientContact {
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub id: Option<String>, pub id: Option<String>,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub first_name: String, pub first_name: String,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub last_name: String, pub last_name: String,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub email: String, pub email: String,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub phone: String, pub phone: String,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub is_primary: bool, pub is_primary: bool,
#[serde(flatten)] #[serde(flatten)]
+30
View File
@@ -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
View File
@@ -1,7 +1,12 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Deserializer, Serialize};
#[derive(Debug, Clone, Deserialize)] #[derive(Debug, Clone, Deserialize)]
pub struct ListResponse<T> { pub struct ListResponse<T> {
/// Defaulted because not every list-shaped response carries a `data` key:
/// a bulk action whose `ids` match nothing returns HTTP 200 with just
/// `{"message": "No Invoices Found"}`, which should read as "no results"
/// rather than a decode failure.
#[serde(default = "Vec::new")]
pub data: Vec<T>, pub data: Vec<T>,
#[serde(default)] #[serde(default)]
pub meta: Option<Meta>, pub meta: Option<Meta>,
@@ -44,6 +49,19 @@ pub struct ListParams {
/// Set to 1 to include archived/deleted records. /// Set to 1 to include archived/deleted records.
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub status: Option<String>, pub status: Option<String>,
/// Extra query parameters appended verbatim.
///
/// The typed fields above are only the parameters common to every list
/// endpoint; individual resources accept many more (`client_id`,
/// `vendor_id`, `number`, `created_at`, `is_deleted`, per-resource
/// filters). Use [`param`](Self::param) to set those.
///
/// Skipped by serde and applied as a separate query pass, because
/// `serde_urlencoded` — which backs `reqwest`'s query serialization —
/// does not handle `#[serde(flatten)]`ed maps.
#[serde(skip)]
pub custom: Vec<(String, String)>,
} }
impl ListParams { impl ListParams {
@@ -80,4 +98,57 @@ impl ListParams {
self.status = Some(status.into()); self.status = Some(status.into());
self self
} }
/// Adds an arbitrary query parameter, for filters the typed setters do
/// not cover.
///
/// ```
/// use invoiceninjars::models::ListParams;
///
/// let params = ListParams::new()
/// .per_page(100)
/// .param("client_id", "abc123")
/// .param("is_deleted", "false");
/// ```
///
/// Can be called more than once with the same key; both pairs are sent.
pub fn param(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.custom.push((key.into(), value.into()));
self
}
}
/// Deserializes `null` as `T::default()` instead of failing.
///
/// `#[serde(default)]` alone only covers a *missing* key — a key that is
/// present with an explicit `null` still errors for non-`Option` types.
/// Invoice Ninja returns `null` for plenty of nominally-string fields, so the
/// models pair `default` with this to tolerate both shapes.
pub(crate) fn null_to_default<'de, D, T>(deserializer: D) -> Result<T, D::Error>
where
D: Deserializer<'de>,
T: Deserialize<'de> + Default,
{
Ok(Option::<T>::deserialize(deserializer)?.unwrap_or_default())
}
/// Body for a bulk action (`POST /{resource}/bulk`).
///
/// The action set varies by resource; `archive`, `restore`, and `delete` work
/// everywhere, and are what the generated `bulk_*` methods are typed for.
/// See [`crate::InvoiceNinjaClient::bulk_invoices`] for the actions whose
/// responses are *not* a list of entities.
#[derive(Debug, Clone, Serialize)]
pub struct BulkRequest {
pub action: String,
pub ids: Vec<String>,
}
impl BulkRequest {
pub fn new(action: impl Into<String>, ids: &[impl AsRef<str>]) -> Self {
Self {
action: action.into(),
ids: ids.iter().map(|id| id.as_ref().to_string()).collect(),
}
}
} }
+77
View File
@@ -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>,
}
+38
View File
@@ -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>,
}
+27
View File
@@ -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>,
}
+32
View File
@@ -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>,
}
+48
View File
@@ -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
View File
@@ -5,39 +5,39 @@ use serde_json::{Map, Value};
pub struct Expense { pub struct Expense {
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub id: Option<String>, pub id: Option<String>,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub vendor_id: String, pub vendor_id: String,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub client_id: String, pub client_id: String,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub invoice_id: String, pub invoice_id: String,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub amount: f64, pub amount: f64,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub exchange_rate: f64, pub exchange_rate: f64,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub public_notes: String, pub public_notes: String,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub private_notes: String, pub private_notes: String,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub date: String, pub date: String,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub payment_date: String, pub payment_date: String,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub transaction_reference: String, pub transaction_reference: String,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub category_id: String, pub category_id: String,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub tax_name1: String, pub tax_name1: String,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub tax_rate1: f64, pub tax_rate1: f64,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub tax_name2: String, pub tax_name2: String,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub tax_rate2: f64, pub tax_rate2: f64,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub should_be_invoiced: bool, pub should_be_invoiced: bool,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub is_deleted: bool, pub is_deleted: bool,
#[serde(default)] #[serde(default)]
pub archived_at: Option<i64>, pub archived_at: Option<i64>,
+23
View File
@@ -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>,
}
+28
View File
@@ -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
View File
@@ -5,35 +5,35 @@ use serde_json::{Map, Value};
pub struct Invoice { pub struct Invoice {
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub id: Option<String>, pub id: Option<String>,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub client_id: String, pub client_id: String,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub number: String, pub number: String,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub date: String, pub date: String,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub due_date: String, pub due_date: String,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub po_number: String, pub po_number: String,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub status_id: String, pub status_id: String,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub amount: f64, pub amount: f64,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub balance: f64, pub balance: f64,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub discount: f64, pub discount: f64,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub is_amount_discount: bool, pub is_amount_discount: bool,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub public_notes: String, pub public_notes: String,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub private_notes: String, pub private_notes: String,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub terms: String, pub terms: String,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub footer: String, pub footer: String,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub is_deleted: bool, pub is_deleted: bool,
#[serde(default)] #[serde(default)]
pub archived_at: Option<i64>, pub archived_at: Option<i64>,
@@ -41,7 +41,7 @@ pub struct Invoice {
pub created_at: Option<i64>, pub created_at: Option<i64>,
#[serde(default)] #[serde(default)]
pub updated_at: Option<i64>, pub updated_at: Option<i64>,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub line_items: Vec<InvoiceItem>, pub line_items: Vec<InvoiceItem>,
#[serde(flatten)] #[serde(flatten)]
@@ -50,25 +50,25 @@ pub struct Invoice {
#[derive(Debug, Clone, Default, Serialize, Deserialize)] #[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct InvoiceItem { pub struct InvoiceItem {
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub product_key: String, pub product_key: String,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub notes: String, pub notes: String,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub cost: f64, pub cost: f64,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub quantity: f64, pub quantity: f64,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub discount: f64, pub discount: f64,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub tax_name1: String, pub tax_name1: String,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub tax_rate1: f64, pub tax_rate1: f64,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub tax_name2: String, pub tax_name2: String,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub tax_rate2: f64, pub tax_rate2: f64,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub line_total: f64, pub line_total: f64,
#[serde(flatten)] #[serde(flatten)]
+44
View File
@@ -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
View File
@@ -1,19 +1,67 @@
mod bank_integration;
mod bank_transaction;
mod bank_transaction_rule;
mod client; mod client;
mod client_gateway_token;
mod common; mod common;
mod company;
mod company_gateway;
mod company_token;
mod design;
mod document;
mod expense; mod expense;
mod expense_category;
mod group_setting;
mod invoice; mod invoice;
mod location;
mod payment; mod payment;
mod payment_term;
mod product; mod product;
mod project; mod project;
mod purchase_order;
mod recurring_expense;
mod recurring_invoice;
mod subscription;
mod system_log;
mod tag;
mod task; mod task;
mod task_scheduler;
mod task_status;
mod tax_rate;
mod user;
mod vendor; mod vendor;
mod webhook;
pub use bank_integration::BankIntegration;
pub use bank_transaction::BankTransaction;
pub use bank_transaction_rule::BankTransactionRule;
pub use client::{Client, ClientContact}; pub use client::{Client, ClientContact};
pub use common::{ItemResponse, ListParams, ListResponse, Meta, Pagination}; pub use client_gateway_token::ClientGatewayToken;
pub use common::{BulkRequest, ItemResponse, ListParams, ListResponse, Meta, Pagination};
pub use company::Company;
pub use company_gateway::CompanyGateway;
pub use company_token::CompanyToken;
pub use design::Design;
pub use document::Document;
pub use expense::Expense; pub use expense::Expense;
pub use expense_category::ExpenseCategory;
pub use group_setting::GroupSetting;
pub use invoice::{Credit, Invoice, InvoiceItem, Quote}; pub use invoice::{Credit, Invoice, InvoiceItem, Quote};
pub use location::Location;
pub use payment::{Payment, PaymentInvoice}; pub use payment::{Payment, PaymentInvoice};
pub use payment_term::PaymentTerm;
pub use product::Product; pub use product::Product;
pub use project::Project; pub use project::Project;
pub use purchase_order::PurchaseOrder;
pub use recurring_expense::RecurringExpense;
pub use recurring_invoice::{RecurringInvoice, RecurringQuote};
pub use subscription::Subscription;
pub use system_log::SystemLog;
pub use tag::Tag;
pub use task::Task; pub use task::Task;
pub use task_scheduler::TaskScheduler;
pub use task_status::TaskStatus;
pub use tax_rate::TaxRate;
pub use user::User;
pub use vendor::{Vendor, VendorContact}; pub use vendor::{Vendor, VendorContact};
pub use webhook::Webhook;
+10 -10
View File
@@ -5,23 +5,23 @@ use serde_json::{Map, Value};
pub struct Payment { pub struct Payment {
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub id: Option<String>, pub id: Option<String>,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub client_id: String, pub client_id: String,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub amount: f64, pub amount: f64,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub applied: f64, pub applied: f64,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub refunded: f64, pub refunded: f64,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub date: String, pub date: String,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub transaction_reference: String, pub transaction_reference: String,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub type_id: String, pub type_id: String,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub private_notes: String, pub private_notes: String,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub is_deleted: bool, pub is_deleted: bool,
#[serde(default)] #[serde(default)]
pub archived_at: Option<i64>, pub archived_at: Option<i64>,
@@ -29,7 +29,7 @@ pub struct Payment {
pub created_at: Option<i64>, pub created_at: Option<i64>,
#[serde(default)] #[serde(default)]
pub updated_at: Option<i64>, pub updated_at: Option<i64>,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub invoices: Vec<PaymentInvoice>, pub invoices: Vec<PaymentInvoice>,
#[serde(flatten)] #[serde(flatten)]
+22
View File
@@ -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
View File
@@ -5,25 +5,25 @@ use serde_json::{Map, Value};
pub struct Product { pub struct Product {
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub id: Option<String>, pub id: Option<String>,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub product_key: String, pub product_key: String,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub notes: String, pub notes: String,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub cost: f64, pub cost: f64,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub price: f64, pub price: f64,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub quantity: f64, pub quantity: f64,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub tax_name1: String, pub tax_name1: String,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub tax_rate1: f64, pub tax_rate1: f64,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub tax_name2: String, pub tax_name2: String,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub tax_rate2: f64, pub tax_rate2: f64,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub is_deleted: bool, pub is_deleted: bool,
#[serde(default)] #[serde(default)]
pub archived_at: Option<i64>, pub archived_at: Option<i64>,
+10 -10
View File
@@ -5,25 +5,25 @@ use serde_json::{Map, Value};
pub struct Project { pub struct Project {
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub id: Option<String>, pub id: Option<String>,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub client_id: String, pub client_id: String,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub name: String, pub name: String,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub number: String, pub number: String,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub task_rate: f64, pub task_rate: f64,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub due_date: String, pub due_date: String,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub budgeted_hours: f64, pub budgeted_hours: f64,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub color: String, pub color: String,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub public_notes: String, pub public_notes: String,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub private_notes: String, pub private_notes: String,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub is_deleted: bool, pub is_deleted: bool,
#[serde(default)] #[serde(default)]
pub archived_at: Option<i64>, pub archived_at: Option<i64>,
+79
View File
@@ -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>,
}
+77
View File
@@ -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>,
}
+92
View File
@@ -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;
+57
View File
@@ -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>,
}
+32
View File
@@ -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>,
}
+26
View File
@@ -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
View File
@@ -5,24 +5,24 @@ use serde_json::{Map, Value};
pub struct Task { pub struct Task {
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub id: Option<String>, pub id: Option<String>,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub client_id: String, pub client_id: String,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub invoice_id: String, pub invoice_id: String,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub project_id: String, pub project_id: String,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub description: String, pub description: String,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub rate: f64, pub rate: f64,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub status_id: String, pub status_id: String,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub is_running: bool, pub is_running: bool,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub is_deleted: bool, pub is_deleted: bool,
/// JSON-encoded array of `[start_timestamp, end_timestamp]` pairs, as returned by the API. /// JSON-encoded array of `[start_timestamp, end_timestamp]` pairs, as returned by the API.
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub time_log: String, pub time_log: String,
#[serde(default)] #[serde(default)]
pub archived_at: Option<i64>, pub archived_at: Option<i64>,
+40
View File
@@ -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>,
}
+27
View File
@@ -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>,
}
+24
View File
@@ -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>,
}
+55
View File
@@ -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
View File
@@ -5,31 +5,31 @@ use serde_json::{Map, Value};
pub struct Vendor { pub struct Vendor {
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub id: Option<String>, pub id: Option<String>,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub name: String, pub name: String,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub id_number: String, pub id_number: String,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub vat_number: String, pub vat_number: String,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub address1: String, pub address1: String,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub address2: String, pub address2: String,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub city: String, pub city: String,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub state: String, pub state: String,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub postal_code: String, pub postal_code: String,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub country_id: String, pub country_id: String,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub phone: String, pub phone: String,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub website: String, pub website: String,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub private_notes: String, pub private_notes: String,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub is_deleted: bool, pub is_deleted: bool,
#[serde(default)] #[serde(default)]
pub archived_at: Option<i64>, pub archived_at: Option<i64>,
@@ -37,7 +37,7 @@ pub struct Vendor {
pub created_at: Option<i64>, pub created_at: Option<i64>,
#[serde(default)] #[serde(default)]
pub updated_at: Option<i64>, pub updated_at: Option<i64>,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub contacts: Vec<VendorContact>, pub contacts: Vec<VendorContact>,
/// Any fields returned by the API that aren't modeled above are preserved here, /// Any fields returned by the API that aren't modeled above are preserved here,
@@ -50,15 +50,15 @@ pub struct Vendor {
pub struct VendorContact { pub struct VendorContact {
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub id: Option<String>, pub id: Option<String>,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub first_name: String, pub first_name: String,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub last_name: String, pub last_name: String,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub email: String, pub email: String,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub phone: String, pub phone: String,
#[serde(default)] #[serde(default, deserialize_with = "super::common::null_to_default")]
pub is_primary: bool, pub is_primary: bool,
#[serde(flatten)] #[serde(flatten)]
+22
View File
@@ -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>,
}
+546
View File
@@ -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
View File
@@ -1,5 +1,9 @@
use invoiceninjars::models::{ use invoiceninjars::models::{
Client, Credit, Expense, Invoice, ListParams, Project, Quote, Task, Vendor, BankIntegration, BankTransaction, BankTransactionRule, BulkRequest, Client, ClientGatewayToken,
Company, CompanyGateway, CompanyToken, Credit, Design, Document, Expense, ExpenseCategory,
GroupSetting, Invoice, ListParams, ListResponse, Location, PaymentTerm, Project, PurchaseOrder,
Quote, RecurringExpense, RecurringInvoice, RecurringQuote, Subscription, SystemLog, Tag, Task,
TaskScheduler, TaskStatus, TaxRate, User, Vendor, Webhook,
}; };
use serde_json::json; use serde_json::json;
@@ -150,3 +154,543 @@ fn quote_and_credit_share_the_invoice_shape() {
let credit: Credit = serde_json::from_value(payload).unwrap(); let credit: Credit = serde_json::from_value(payload).unwrap();
assert_eq!(credit.number, "QUO-0001"); assert_eq!(credit.number, "QUO-0001");
} }
#[test]
fn recurring_invoice_decodes_recurrence_fields() {
// frequency_id / remaining_cycles come back as JSON numbers, and
// remaining_cycles is -1 for an indefinite schedule.
let payload = json!({
"id": "ri1",
"client_id": "abc123",
"frequency_id": 5,
"remaining_cycles": -1,
"auto_bill": "off",
"next_send_date": "2026-11-01",
"line_items": [{"product_key": "widget", "cost": 9.5, "quantity": 2.0}],
"custom_value1": "unmodeled field",
});
let ri: RecurringInvoice = serde_json::from_value(payload).unwrap();
assert_eq!(ri.frequency_id, 5);
assert_eq!(ri.remaining_cycles, -1);
assert_eq!(ri.next_send_date, "2026-11-01");
assert_eq!(ri.line_items.len(), 1);
assert_eq!(ri.line_items[0].product_key, "widget");
let round_tripped = serde_json::to_value(&ri).unwrap();
assert_eq!(round_tripped["custom_value1"], "unmodeled field");
}
/// `RecurringQuote` aliases `RecurringInvoice`; the API's RecurringQuote
/// fields are a strict subset, so a quote payload must decode without the
/// invoice-only fields present.
#[test]
fn recurring_quote_decodes_without_invoice_only_fields() {
let payload = json!({
"id": "rq1",
"client_id": "abc123",
"frequency_id": 2,
"remaining_cycles": 12,
});
let rq: RecurringQuote = serde_json::from_value(payload).unwrap();
assert_eq!(rq.frequency_id, 2);
assert_eq!(rq.remaining_cycles, 12);
// Invoice-only fields fall back to their defaults rather than failing.
assert_eq!(rq.auto_bill, "");
assert!(!rq.auto_bill_enabled);
}
#[test]
fn purchase_order_round_trip_preserves_unknown_fields() {
let payload = json!({
"id": "po1",
"vendor_id": "ven1",
"number": "PO-0001",
"amount": 250.0,
"custom_surcharge1": 7.25,
});
let po: PurchaseOrder = serde_json::from_value(payload).unwrap();
assert_eq!(po.vendor_id, "ven1");
assert_eq!(po.number, "PO-0001");
assert_eq!(po.amount, 250.0);
let round_tripped = serde_json::to_value(&po).unwrap();
assert_eq!(round_tripped["custom_surcharge1"], 7.25);
}
#[test]
fn recurring_expense_round_trip_preserves_unknown_fields() {
let payload = json!({
"id": "rex1",
"vendor_id": "ven1",
"frequency_id": 4,
"remaining_cycles": 6,
"amount": 19.99,
"custom_value2": "unmodeled field",
});
let rex: RecurringExpense = serde_json::from_value(payload).unwrap();
assert_eq!(rex.frequency_id, 4);
assert_eq!(rex.remaining_cycles, 6);
assert_eq!(rex.amount, 19.99);
let round_tripped = serde_json::to_value(&rex).unwrap();
assert_eq!(round_tripped["custom_value2"], "unmodeled field");
}
/// `Subscription.frequency_id` is a string even though the recurring-invoice
/// field of the same name is numeric; this pins that asymmetry.
#[test]
fn subscription_frequency_id_is_a_string() {
let payload = json!({
"id": "sub1",
"product_id": "prod1",
"frequency_id": "5",
"trial_enabled": true,
"trial_duration": 14,
"promo_discount": 10.0,
});
let sub: Subscription = serde_json::from_value(payload).unwrap();
assert_eq!(sub.frequency_id, "5");
assert!(sub.trial_enabled);
assert_eq!(sub.trial_duration, 14);
assert_eq!(sub.promo_discount, 10.0);
}
#[test]
fn small_lookup_resources_decode() {
let tax_rate: TaxRate = serde_json::from_value(json!({
"id": "tr1", "name": "GST", "rate": 10.0
}))
.unwrap();
assert_eq!(tax_rate.name, "GST");
assert_eq!(tax_rate.rate, 10.0);
let category: ExpenseCategory = serde_json::from_value(json!({
"id": "ec1", "name": "Accounting"
}))
.unwrap();
assert_eq!(category.name, "Accounting");
let status: TaskStatus = serde_json::from_value(json!({
"id": "ts1", "name": "Backlog", "color": "#000000", "task_status_order": 4
}))
.unwrap();
assert_eq!(status.task_status_order, 4);
assert_eq!(status.color, "#000000");
let term: PaymentTerm = serde_json::from_value(json!({
"id": "pt1", "name": "NET 30", "num_days": 30
}))
.unwrap();
assert_eq!(term.num_days, 30);
let webhook: Webhook = serde_json::from_value(json!({
"id": "wh1", "event_id": "1", "target_url": "https://example.com/hook", "format": "JSON"
}))
.unwrap();
assert_eq!(webhook.target_url, "https://example.com/hook");
assert_eq!(webhook.format, "JSON");
}
/// Bank transactions are the one resource whose ids come back as integers
/// rather than hashed strings.
#[test]
fn bank_transaction_decodes_integer_ids() {
let payload = json!({
"id": "bt1",
"transaction_id": 9876543210i64,
"bank_account_id": 42,
"category_id": 7,
"amount": 123.45,
"base_type": "CREDIT",
"date": "2026-10-01",
});
let bt: BankTransaction = serde_json::from_value(payload).unwrap();
assert_eq!(bt.transaction_id, 9876543210);
assert_eq!(bt.bank_account_id, 42);
assert_eq!(bt.category_id, 7);
assert_eq!(bt.base_type, "CREDIT");
}
/// A newly constructed model must not serialize an `id`, so `create_*` calls
/// do not send `"id": null` to the API.
#[test]
fn default_models_omit_id_on_serialize() {
let body = serde_json::to_value(RecurringInvoice::default()).unwrap();
assert!(body.get("id").is_none());
let body = serde_json::to_value(TaxRate::default()).unwrap();
assert!(body.get("id").is_none());
}
// --- explicit-null tolerance -------------------------------------------
//
// Invoice Ninja returns `null` for plenty of nominally-string fields.
// `#[serde(default)]` only covers a *missing* key, so these payloads used to
// fail to decode entirely; the models pair `default` with a
// null-to-default deserializer.
#[test]
fn null_strings_numbers_and_bools_decode_as_defaults() {
let payload = json!({
"id": "abc123",
"name": null,
"address1": null,
"balance": null,
"is_deleted": null,
"contacts": null,
});
let client: Client = serde_json::from_value(payload).unwrap();
assert_eq!(client.name, "");
assert_eq!(client.address1, "");
assert_eq!(client.balance, 0.0);
assert!(!client.is_deleted);
assert!(client.contacts.is_empty());
}
#[test]
fn null_line_items_and_nested_nulls_decode() {
let payload = json!({
"id": "inv1",
"number": null,
"public_notes": null,
"line_items": [{
"product_key": null,
"notes": null,
"cost": null,
"quantity": 3.0,
}],
});
let invoice: Invoice = serde_json::from_value(payload).unwrap();
assert_eq!(invoice.number, "");
assert_eq!(invoice.public_notes, "");
assert_eq!(invoice.line_items.len(), 1);
assert_eq!(invoice.line_items[0].product_key, "");
assert_eq!(invoice.line_items[0].cost, 0.0);
assert_eq!(invoice.line_items[0].quantity, 3.0);
}
#[test]
fn null_integer_fields_decode_across_resources() {
let ri: RecurringInvoice = serde_json::from_value(json!({
"id": "ri1", "frequency_id": null, "remaining_cycles": null, "auto_bill": null
}))
.unwrap();
assert_eq!(ri.frequency_id, 0);
assert_eq!(ri.remaining_cycles, 0);
assert_eq!(ri.auto_bill, "");
let status: TaskStatus = serde_json::from_value(json!({
"id": "ts1", "name": null, "task_status_order": null
}))
.unwrap();
assert_eq!(status.task_status_order, 0);
let bt: BankTransaction = serde_json::from_value(json!({
"id": "bt1", "transaction_id": null, "bank_account_id": null, "base_type": null
}))
.unwrap();
assert_eq!(bt.transaction_id, 0);
assert_eq!(bt.bank_account_id, 0);
}
/// Nullable timestamps stay `None` rather than collapsing to `Some(0)`, so
/// "never archived" is still distinguishable from "archived at epoch".
#[test]
fn null_timestamps_remain_none() {
let client: Client = serde_json::from_value(json!({
"id": "abc123", "archived_at": null, "created_at": null, "updated_at": 1700000000i64
}))
.unwrap();
assert_eq!(client.archived_at, None);
assert_eq!(client.created_at, None);
assert_eq!(client.updated_at, Some(1700000000));
}
/// A null value for an *unmodeled* key still round-trips through `extra`.
#[test]
fn null_unknown_fields_round_trip_through_extra() {
let payload = json!({"id": "abc123", "name": "Acme Inc", "custom_value1": null});
let client: Client = serde_json::from_value(payload).unwrap();
assert!(client.extra.get("custom_value1").unwrap().is_null());
let round_tripped = serde_json::to_value(&client).unwrap();
assert!(round_tripped["custom_value1"].is_null());
}
// --- non-CRUD endpoint plumbing ----------------------------------------
#[test]
fn bulk_request_serializes_action_and_ids() {
let body = BulkRequest::new("archive", &["abc123", "def456"]);
assert_eq!(
serde_json::to_value(&body).unwrap(),
json!({"action": "archive", "ids": ["abc123", "def456"]})
);
}
/// `BulkRequest::new` takes anything string-like, so ids pulled off models
/// (which are `Option<String>`) don't need converting at the call site.
#[test]
fn bulk_request_accepts_owned_and_borrowed_ids() {
let owned: Vec<String> = vec!["abc123".to_string(), "def456".to_string()];
let from_owned = BulkRequest::new("delete", &owned);
let from_borrowed = BulkRequest::new("delete", &["abc123", "def456"]);
assert_eq!(from_owned.ids, from_borrowed.ids);
assert_eq!(from_owned.action, "delete");
}
/// A bulk action whose `ids` match nothing answers HTTP 200 with
/// `{"message": "No Invoices Found"}` — no `data` key at all. That has to
/// read as an empty result, not a decode error.
#[test]
fn list_envelope_without_data_key_decodes_as_empty() {
let body: ListResponse<Invoice> =
serde_json::from_value(json!({"message": "No Invoices Found"})).unwrap();
assert!(body.data.is_empty());
assert!(body.meta.is_none());
}
#[test]
fn list_envelope_still_decodes_data_and_pagination() {
let body: ListResponse<Invoice> = serde_json::from_value(json!({
"data": [{"id": "inv1", "number": "INV-0001"}],
"meta": {"pagination": {
"total": 1, "count": 1, "per_page": 50, "current_page": 1, "total_pages": 1
}},
}))
.unwrap();
assert_eq!(body.data.len(), 1);
assert_eq!(body.data[0].number, "INV-0001");
let pagination = body.meta.unwrap().pagination.unwrap();
assert_eq!(pagination.total, 1);
assert_eq!(pagination.per_page, 50);
}
#[test]
fn document_round_trip_preserves_unknown_fields() {
// `type` is a Rust keyword, so the field is `r#type` but must still map
// to the plain JSON key.
let payload = json!({
"id": "doc1",
"name": "contract.pdf",
"type": "pdf",
"hash": "abc",
"is_default": false,
"custom_value1": "unmodeled field",
});
let doc: Document = serde_json::from_value(payload).unwrap();
assert_eq!(doc.name, "contract.pdf");
assert_eq!(doc.r#type, "pdf");
let round_tripped = serde_json::to_value(&doc).unwrap();
assert_eq!(round_tripped["type"], "pdf");
assert_eq!(round_tripped["custom_value1"], "unmodeled field");
}
// --- list params ------------------------------------------------------
/// The typed fields serialize; `custom` is skipped here because it is
/// applied as a separate query pass (serde_urlencoded can't flatten a map).
#[test]
fn list_params_skips_custom_in_struct_serialization() {
let params = ListParams::new().per_page(25).param("client_id", "abc123");
assert_eq!(
serde_json::to_value(&params).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(&params).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>");
}