diff --git a/CLAUDE.md b/CLAUDE.md index 8e2a65b..be83e02 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -29,37 +29,112 @@ IN_API_TOKEN=... cargo run --example list_clients ``` Tests live in `tests/` (integration-style, no network access required); -`src/` has no `#[test]` modules. +`src/` has no `#[test]` modules. Two files: `model_roundtrip.rs` covers the +serde layer, and `client_requests.rs` covers the request plumbing +(pagination, path escaping, headers, timeouts, error mapping) against a +`wiremock` server on loopback — no credentials or internet needed. ## Architecture - `src/client.rs` — `InvoiceNinjaClient` holds a `reqwest::Client` (with the `X-Api-Token` auth header baked in) and a `base_url`. Low-level helpers - (`get_list`, `get_one`, `post_one`, `put_one`, `delete_one`) handle request - dispatch and decode `ListResponse` / `ItemResponse` envelopes, mapping - non-2xx responses through `api_error` into `Error::Api`. + (`get_list`, `get_one`, `post_one`, `put_one`, `delete_one`, `post_list`) + handle request dispatch and decode `ListResponse` / `ItemResponse` + envelopes, mapping non-2xx responses through `api_error` into `Error::Api`. +- Not every endpoint answers with a JSON envelope, so there are three other + helper families: `get_raw`/`post_raw` return the body as bytes (PDF streams + and CSV report exports), `get_unwrapped`/`post_unwrapped` decode a bare + JSON body with no `data` wrapper (`/statics`, `/charts/*`, `/search`), and + `get_discard` just checks the status (`/ping`). - Per-resource methods (`list_clients`, `get_invoice`, `create_product`, etc.) are generated by the `resource_methods!` macro at the bottom of `client.rs`, which expands to - `list`/`list_all`/`get`/`create`/`update`/`delete` for a given REST path and - model type. **To add a new resource**: add a model under `src/models/`, - re-export it from `src/models/mod.rs`, and add one `resource_methods!` - invocation in `client.rs`. Note the macro takes six method names before the - path and model — adding a resource means passing all six. + `list`/`list_all`/`get`/`create`/`update`/`delete`/`bulk` for a given REST + path and model type. **To add a new resource**: add a model under + `src/models/`, re-export it from `src/models/mod.rs`, and add one + `resource_methods!` invocation in `client.rs`. Note the macro takes seven + method names before the path and model — adding a resource means passing + all seven. +- `resource_methods!` delegates the six CRUD methods to `crud_methods!` and + adds `bulk` on top. **Use `crud_methods!` directly for resources with no + `/bulk` route** — currently `client_gateway_tokens`, `companies`, and + `system_logs`. Check `routes/api.php` before assuming a resource has one; + a generated `bulk_*` against a missing route just 405s. +- Every request is built through the private `req(method, path)` helper, + which is the single place the optional `X-API-PASSWORD` header and the + per-request timeout are attached. **Don't call `self.http` directly in a + new helper** — it would silently skip both. Only `req` itself should touch + `self.http`. +- **Interpolate ids into paths with `seg(id)?`, never raw.** It escapes the + value into a single path segment and rejects ids that are empty or + dot-only. Both halves matter: escaping stops a `/` or `?` in an id from + retargeting the request, but escaping *alone* is not enough, because the + WHATWG rules the `url` crate follows decode `%2E` back to `.` before + resolving dot segments — so an escaped `..` still collapses the path. The + exceptions are `report` and `chart`, which take a multi-segment tail by + design (`chart("project_burnup/", …)`) and must stay unescaped. +- `entity_action_methods!` generates three methods — `{x}_action` and + `download_{x}` for the `GET /{resource}/{id}/{action}` route, plus + `download_{plural}` for the bulk merged-PDF action. **Only invoices, + quotes, credits, and purchase orders have the action route** — don't add it + elsewhere without checking `routes/api.php`. In particular the official PHP + SDK puts bulk `download` on *every* resource, which is wrong; only + PDF-rendering entities support it. +- Non-CRUD endpoints (documents, reports, charts, search, statics, emails, + refunds, ping/health) are hand-written in the last `impl` block rather than + macro-generated, because their request and response shapes are all + different. - `list_*` returns one page; `list_all_*` goes through `get_all_pages`, which - follows `page`/`per_page` until a page returns fewer rows than requested. -- `src/models/` — one file per resource (`client.rs`, `invoice.rs`, - `payment.rs`, `product.rs`, `expense.rs`, `project.rs`, `task.rs`, - `vendor.rs`), plus `common.rs` for shared envelope/pagination types - (`ListParams`, `ListResponse`, `ItemResponse`, `Meta`, `Pagination`). - Invoice Ninja stores quotes and credits in the same table as invoices (an - `entity_type` field distinguishes them), so `Quote` and `Credit` are type - aliases for `Invoice` (`src/models/invoice.rs`) and share the - `/quotes`, `/credits`, and `/invoices` paths via the macro. + terminates on `meta.pagination.total_pages` and only falls back to the + short-page heuristic when the server sends no metadata. **The heuristic is + not sound on its own**: `BaseController` caps `per_page` at + `min(abs(per_page), 5000)`, so a page can be shorter than requested while + records remain — which is why `per_page` is clamped to 5000 before the + walk starts. `per_page(0)` falls back to the default size (it would make + the short-page check `got < 0`, which never terminates), the page counter + advances locally rather than trusting the echoed `current_page`, an empty + page always ends the walk, and a `MAX_PAGES` backstop raises + `Error::PaginationLimit` rather than quietly returning a truncated list. +- `src/models/` — one file per resource, plus `common.rs` for shared + envelope/pagination types (`ListParams`, `ListResponse`, `ItemResponse`, + `Meta`, `Pagination`). +- Several endpoints share a schema, so some models are type aliases rather + than separate structs: Invoice Ninja keeps quotes and credits in the + invoices table (an `entity_type` field distinguishes them), so `Quote` and + `Credit` alias `Invoice` (`src/models/invoice.rs`); likewise + `RecurringQuote` aliases `RecurringInvoice` + (`src/models/recurring_invoice.rs`), whose fields are a strict superset. + Each alias still gets its own path via the macro. - Every model struct has a `#[serde(flatten)] pub extra: Map` field. This preserves any API fields not explicitly modeled, so a `get` followed by an `update` round-trips without silently dropping data. Keep this pattern when adding fields or new models. +- **Every non-`Option` field carries + `#[serde(default, deserialize_with = "super::common::null_to_default")]`, + not a bare `#[serde(default)]`.** `default` alone only covers a *missing* + key; a key present with an explicit `null` fails with `invalid type: null, + expected a string`. Invoice Ninja returns `null` for many + nominally-string fields, so a bare `default` would break decoding of the + whole response. `Option` fields keep the plain `#[serde(default)]` — + they already map `null` to `None`, and that distinction matters (an unset + `archived_at` should stay `None`, not become `Some(0)`). +- Models deliberately cover a curated subset of each schema and let `extra` + carry the rest — `custom_value1..4` and `custom_surcharge1..4` are + intentionally unmodeled. Fields whose JSON type is ambiguous or + inconsistent in the upstream spec (e.g. `Subscription.currency_id`, + `webhook_configuration`, `due_date_days`) are also left to `extra`, since a + wrong concrete type is a hard deserialize error whereas `extra` is lossless. +- Field names and types come from Invoice Ninja's own OpenAPI spec + (`openapi/api-docs.yaml` on the `v5-stable` branch of the invoiceninja + repo). Check it before adding or retyping a field; the same name can differ + in type across resources (`frequency_id` is a number on recurring invoices + and expenses but a string on subscriptions). +- **The published spec is incomplete for routes**: it documents `/{id}` and + action paths only for clients, locations, and tags, and omits them for + invoices and everything else. `routes/api.php` in the same repo is the + authoritative route list, and the controllers are authoritative for what a + given action returns. Use the spec for *schemas*, the routes and + controllers for *endpoints*. - `src/error.rs` — `Error` enum (`thiserror`) covering transport, JSON decode, API error responses (with per-field validation `errors` map), and client construction errors. `ApiErrorBody` mirrors Invoice Ninja's error @@ -67,6 +142,42 @@ Tests live in `tests/` (integration-style, no network access required); ## Coverage -Clients, Invoices, Quotes, Credits, Payments, Products, Expenses, Projects, -Tasks, and Vendors, each with `list`, `list_all`, `get`, `create`, `update`, -and `delete`. +**All 34 of the API's `Route::resource` groups are wrapped**, plus `users` +(which isn't one). 265 methods: 30 resources with CRUD + bulk, 3 with CRUD +only, 4 with entity actions, and 25 hand-written. + +Plus the non-CRUD surface: documents, entity actions and PDF downloads for +the four action-capable resources, `report`, `chart`, `search`, `statics`, +`send_email`, `refund_payment`, `ping`, and `health_check`. + +**`COVERAGE.md` is the detailed inventory** — every wrapped endpoint, every +gap, and the reason for each gap. The main remaining gaps are multipart +uploads (needs a reqwest feature), a handful of per-resource extras +(`clients/{id}/purge`, `company_gateways/{id}/test`, …), and the +integration/auth routes. + +Keep `COVERAGE.md` current when adding or removing an endpoint: it names +exact counts and route groups, so it goes stale quietly. The three coverage +summaries — here, `README.md`, and `COVERAGE.md` — should agree; +`COVERAGE.md` is the one with the specifics, the other two summarize and +link to it. + +## Relationship to the official PHP SDK + +[invoiceninja/sdk-php](https://github.com/invoiceninja/sdk-php) is the +first-party wrapper and a useful cross-check, but it is thinner than this +crate — each endpoint class is just a URI over a shared `BaseEntity`. See the +comparison table in `COVERAGE.md` for what each side covers. + +Two divergences are deliberate and shouldn't be "fixed" to match it: + +- **We send JSON; it sends `form_params`.** Form encoding bracket-encodes + nested structures, which is bad for payloads like an invoice's `line_items`. + The API accepts both. +- **We keep the whole `errors` map on `Error::Api`;** it surfaces only the + first validation message. + +And two of its behaviours are bugs not to copy: it sends `stream: false` in +download payloads (no controller reads a `stream` input), and it exposes +`download` on every resource even though only PDF-rendering entities support +that action. diff --git a/COVERAGE.md b/COVERAGE.md new file mode 100644 index 0000000..f3b6858 --- /dev/null +++ b/COVERAGE.md @@ -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/", &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. diff --git a/Cargo.lock b/Cargo.lock index 9353655..fc8c8dd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,25 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "assert-json-diff" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "atomic-waker" version = "1.1.2" @@ -70,6 +89,24 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "deadpool" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0be2b1d1d6ec8d846f05e137292d0b89133caf95ef33695424c09568bdd39b1b" +dependencies = [ + "deadpool-runtime", + "lazy_static", + "num_cpus", + "tokio", +] + +[[package]] +name = "deadpool-runtime" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b" + [[package]] name = "displaydoc" version = "0.2.7" @@ -81,6 +118,12 @@ dependencies = [ "syn 3.0.9", ] +[[package]] +name = "equivalent" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" + [[package]] name = "errno" version = "0.3.14" @@ -103,6 +146,12 @@ version = "0.1.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48" +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + [[package]] name = "foreign-types" version = "0.3.2" @@ -127,6 +176,21 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "futures" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + [[package]] name = "futures-channel" version = "0.3.34" @@ -134,6 +198,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" dependencies = [ "futures-core", + "futures-sink", ] [[package]] @@ -142,6 +207,40 @@ version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + [[package]] name = "futures-task" version = "0.3.34" @@ -154,8 +253,13 @@ version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" dependencies = [ + "futures-channel", "futures-core", + "futures-io", + "futures-macro", + "futures-sink", "futures-task", + "memchr", "pin-project-lite", "slab", ] @@ -171,6 +275,37 @@ dependencies = [ "r-efi", ] +[[package]] +name = "h2" +version = "0.4.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d29020232d6aa3fb1daca64c1127cf662cf97f254ae16c18c05b8ab635fc118" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "hermit-abi" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" + [[package]] name = "http" version = "1.5.0" @@ -210,6 +345,12 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + [[package]] name = "hyper" version = "1.12.0" @@ -218,10 +359,13 @@ checksum = "2c3e324da4c95177d6291d4c8730197c0d1822f8a9766814a4a44fa5ab797c9c" dependencies = [ "atomic-waker", "bytes", + "futures-channel", "futures-core", + "h2", "http", "http-body", "httparse", + "httpdate", "itoa", "pin-project-lite", "smallvec", @@ -373,15 +517,28 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown", +] + [[package]] name = "invoiceninjars" version = "0.1.0" dependencies = [ + "percent-encoding", "reqwest", "serde", "serde_json", + "serde_urlencoded", "thiserror", "tokio", + "wiremock", ] [[package]] @@ -407,6 +564,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "lazy_static" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb" + [[package]] name = "libc" version = "0.2.191" @@ -465,6 +628,16 @@ dependencies = [ "tempfile", ] +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -565,6 +738,35 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + [[package]] name = "reqwest" version = "0.12.28" @@ -877,6 +1079,20 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-util" +version = "0.7.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e464cf451ba96ebfc6f9b6542f17ee8b8956e33f1e40d9690624e59d7a7f8a4b" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "libc", + "pin-project-lite", + "tokio", +] + [[package]] name = "tower" version = "0.5.3" @@ -1073,6 +1289,29 @@ dependencies = [ "windows-link", ] +[[package]] +name = "wiremock" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08db1edfb05d9b3c1542e521aea074442088292f00b5f28e435c714a98f85031" +dependencies = [ + "assert-json-diff", + "base64 0.22.1", + "deadpool", + "futures", + "http", + "http-body-util", + "hyper", + "hyper-util", + "log", + "once_cell", + "regex", + "serde", + "serde_json", + "tokio", + "url", +] + [[package]] name = "writeable" version = "0.6.4" diff --git a/Cargo.toml b/Cargo.toml index b389343..8f8c808 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,6 +11,14 @@ reqwest = { version = "0.12", default-features = false, features = ["json", "nat serde = { version = "1", features = ["derive"] } serde_json = "1" thiserror = "2" +# Already in the tree via reqwest's `url`; used to escape ids before they are +# interpolated into request paths. +percent-encoding = "2" [dev-dependencies] tokio = { version = "1", features = ["rt-multi-thread", "macros"] } +# Used to assert that ListParams urlencodes the way reqwest will send it. +serde_urlencoded = "0.7" +# Loopback HTTP server for exercising the request plumbing (pagination, path +# escaping, headers, error mapping) without reaching the network. +wiremock = "0.6" diff --git a/README.md b/README.md index 04aedb7..30ffe3b 100644 --- a/README.md +++ b/README.md @@ -15,14 +15,33 @@ nix develop ```rust use invoiceninjars::{InvoiceNinjaClient, models::ListParams}; +// Hosted app: +let client = InvoiceNinjaClient::hosted(std::env::var("IN_API_TOKEN")?)?; + +// Self-hosted — note the base URL must include the `/api/v1` suffix: let client = InvoiceNinjaClient::new( - "https://invoicing.co/api/v1", // or your self-hosted base URL + "https://your-domain.example/api/v1", std::env::var("IN_API_TOKEN")?, )?; let clients = client.list_clients(&ListParams::new().per_page(50)).await?; ``` +`ListParams` has typed setters for the parameters every list endpoint +accepts, plus `param` for the per-resource filters: + +```rust +let unpaid = client + .list_invoices( + &ListParams::new() + .per_page(100) + .sort("date|desc") + .param("client_id", "abc123") + .param("client_status", "unpaid"), + ) + .await?; +``` + List endpoints are paginated. `list_*` returns a single page; `list_all_*` follows pagination and returns every matching record: @@ -30,6 +49,16 @@ follows pagination and returns every matching record: let all_clients = client.list_all_clients(&ListParams::new()).await?; ``` +Every request carries a 60-second deadline by default, since `reqwest` +applies none of its own. Override it for slow report exports or large +`list_all_*` walks: + +```rust +use std::time::Duration; + +let client = InvoiceNinjaClient::hosted(token)?.with_timeout(Duration::from_secs(300)); +``` + Run the example (requires `IN_API_TOKEN`, optionally `IN_BASE_URL`): ```sh @@ -38,7 +67,74 @@ IN_API_TOKEN=... cargo run --example list_clients ## Coverage -Clients, Invoices, Quotes, Credits, Payments, Products, Expenses, Projects, -Tasks, and Vendors, each with `list`, `list_all`, `get`, `create`, `update`, -and `delete`. Add more resources by adding a model under `src/models/` and a +Every one of the API's 34 resource groups, plus users — 265 methods. Each +resource has `list`, `list_all`, `get`, `create`, `update`, `delete`, and +(where the API offers one) `bulk`: + +| Area | Resources | +| --- | --- | +| Billing | Clients, Invoices, Quotes, Credits, Payments, Products, Purchase Orders | +| Recurring | Recurring Invoices, Recurring Quotes, Recurring Expenses, Subscriptions | +| Work | Projects, Tasks, Task Statuses, Task Schedulers | +| Spend | Expenses, Expense Categories, Vendors, Bank Transactions | +| Banking | Bank Integrations, Bank Transaction Rules, Company Gateways, Client Gateway Tokens | +| Admin | Companies, Users, Group Settings, Tokens, System Logs | +| Settings | Tax Rates, Payment Terms, Webhooks, Designs, Locations, Tags | + +Plus documents, entity actions and PDF downloads, reports, charts, search, +statics, email sending, refunds, and health checks. + +**See [COVERAGE.md](COVERAGE.md) for the full endpoint-by-endpoint +inventory**, including everything that is deliberately *not* wrapped and why. + +Models cover a curated subset of each schema; any field the API returns that +isn't modeled explicitly (including `custom_value1..4` and +`custom_surcharge1..4`) is preserved in that model's `extra` map, so a `get` +followed by an `update` round-trips without dropping data. + +### Beyond CRUD + +Bulk actions return the affected records. They're available on every +resource the API gives a `/bulk` route — all but companies, system logs, and +client gateway tokens: + +```rust +let archived = client.bulk_invoices("archive", &["abc123", "def456"]).await?; +``` + +Some endpoints — reading or writing an individual user, `/users/bulk`, +purging a client or company — sit behind the API's `password_protected` +middleware and need the account password on top of a valid token: + +```rust +let client = InvoiceNinjaClient::hosted(std::env::var("IN_API_TOKEN")?)? + .with_api_password(std::env::var("IN_API_PASSWORD")?)?; + +let user = client.get_user("abc123").await?; +``` + +Invoices, quotes, credits, and purchase orders also support per-entity +actions and PDF rendering: + +```rust +let invoice = client.mark_invoice_sent("abc123").await?; +let pdf: Vec = client.download_invoice("abc123").await?; + +// The bulk `download` action streams one merged PDF. Available for quotes, +// credits, and purchase orders too. +let merged: Vec = 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 = 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`. diff --git a/src/client.rs b/src/client.rs index f493c7b..df4e62a 100644 --- a/src/client.rs +++ b/src/client.rs @@ -1,30 +1,101 @@ -use reqwest::{header, StatusCode}; +use std::time::Duration; + +use percent_encoding::{utf8_percent_encode, AsciiSet, NON_ALPHANUMERIC}; +use reqwest::{header, Method, StatusCode}; use serde::{de::DeserializeOwned, Serialize}; use crate::error::{ApiErrorBody, Error, Result}; use crate::models::{ - Client as ClientModel, Credit, Expense, Invoice, ItemResponse, ListParams, ListResponse, - Payment, Product, Project, Quote, Task, Vendor, + BankIntegration, BankTransaction, BankTransactionRule, BulkRequest, Client as ClientModel, + ClientGatewayToken, Company, CompanyGateway, CompanyToken, Credit, Design, Document, Expense, + ExpenseCategory, GroupSetting, Invoice, ItemResponse, ListParams, ListResponse, Location, + Payment, PaymentTerm, Product, Project, PurchaseOrder, Quote, RecurringExpense, + RecurringInvoice, RecurringQuote, Subscription, SystemLog, Tag, Task, TaskScheduler, + TaskStatus, TaxRate, User, Vendor, Webhook, }; +/// API root of the hosted Invoice Ninja app. +pub const HOSTED_BASE_URL: &str = "https://invoicing.co/api/v1"; + +/// Deadline applied to every request unless overridden with +/// [`InvoiceNinjaClient::with_timeout`]. +/// +/// `reqwest` applies no timeout of its own, so without this a stalled +/// connection hangs the caller indefinitely. Generous enough for PDF renders +/// and report exports, which are the slowest responses the API produces. +pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(60); + +/// Separate, shorter deadline for establishing the connection, so an +/// unreachable host fails fast rather than consuming the full request budget. +const CONNECT_TIMEOUT: Duration = Duration::from_secs(10); + +/// Characters escaped when interpolating a value into a request path: every +/// non-alphanumeric except the RFC 3986 unreserved punctuation, none of which +/// can change a URL's structure. +const PATH_SEGMENT: &AsciiSet = &NON_ALPHANUMERIC.remove(b'-').remove(b'_').remove(b'~'); + +/// Escapes one path segment, rejecting values that cannot safely be one. +/// +/// Every id and action reaching a `format!` that builds a request path goes +/// through this. Ids are caller-supplied and often come from search results +/// or user input, so an unescaped `/`, `?`, or `#` would change which +/// endpoint is called rather than failing cleanly. +/// +/// Escaping alone is not sufficient, which is why this returns a `Result`. +/// The `url` crate implements the WHATWG rules, and those decode `%2E` back +/// to `.` *before* removing dot segments — so even a correctly escaped `..` +/// collapses the path (`clients/%2E%2E` resolves to `/`). Values that are +/// empty, or made only of dots, are therefore refused rather than encoded: +/// an empty id would address the collection route instead of a record, and a +/// dot-only id would walk up the path. Invoice Ninja ids are hashids, so +/// nothing legitimate is rejected. +/// +/// This is for single segments only — `report` and `chart` take a +/// multi-segment tail by design and are left unescaped. +fn seg(value: &str) -> Result { + if value.is_empty() || value.chars().all(|c| c == '.') { + return Err(Error::InvalidPathSegment(value.to_string())); + } + Ok(utf8_percent_encode(value, PATH_SEGMENT)) +} + /// An async client for the Invoice Ninja v5 API. /// -/// `base_url` should point at the API root, e.g. `https://invoicing.co/api/v1` -/// for the hosted app, or `https://your-domain.example/api/v1` for a +/// `base_url` should point at the API root — note the `/api/v1` suffix, e.g. +/// `https://invoicing.co/api/v1` for the hosted app (or just use +/// [`hosted`](Self::hosted)), or `https://your-domain.example/api/v1` for a /// self-hosted install. #[derive(Debug, Clone)] pub struct InvoiceNinjaClient { http: reqwest::Client, base_url: String, + /// Marked sensitive, so it renders as `Sensitive` rather than the value + /// in the derived `Debug`. + api_password: Option, + timeout: Duration, } impl InvoiceNinjaClient { pub fn new(base_url: impl Into, api_token: impl Into) -> Result { let base_url = base_url.into(); - if base_url.trim().is_empty() { + let trimmed = base_url.trim(); + if trimmed.is_empty() { return Err(Error::InvalidBaseUrl(base_url)); } - let base_url = base_url.trim_end_matches('/').to_string(); + + // Parse up front rather than letting a malformed base URL surface as a + // confusing per-request failure. A missing scheme is the common typo + // (`invoicing.co/api/v1`), and it fails here instead of on every call. + let parsed = reqwest::Url::parse(trimmed) + .map_err(|e| Error::InvalidBaseUrl(format!("{trimmed}: {e}")))?; + if !matches!(parsed.scheme(), "http" | "https") { + return Err(Error::InvalidBaseUrl(format!( + "{trimmed}: expected an http or https url, got scheme `{}`", + parsed.scheme() + ))); + } + + let base_url = trimmed.trim_end_matches('/').to_string(); let mut headers = header::HeaderMap::new(); let mut token_value = header::HeaderValue::from_str(&api_token.into()) @@ -42,9 +113,73 @@ impl InvoiceNinjaClient { let http = reqwest::Client::builder() .default_headers(headers) + .connect_timeout(CONNECT_TIMEOUT) .build()?; - Ok(Self { http, base_url }) + Ok(Self { + http, + base_url, + api_password: None, + timeout: DEFAULT_TIMEOUT, + }) + } + + /// Overrides the per-request deadline, which defaults to + /// [`DEFAULT_TIMEOUT`]. + /// + /// Worth raising when pulling large report exports or paging through big + /// result sets with `list_all_*`, where a single call can legitimately + /// run long. + pub fn with_timeout(mut self, timeout: Duration) -> Self { + self.timeout = timeout; + self + } + + /// Attaches an `X-API-PASSWORD` header to every subsequent request. + /// + /// A number of endpoints sit behind the API's `password_protected` + /// middleware and reject requests without the account password, even + /// when the token is valid: reading or writing an individual user, + /// `/users/bulk`, purging a client or company, and merging clients. + /// Without this, those calls fail rather than returning data. + /// + /// ```no_run + /// # fn main() -> invoiceninjars::Result<()> { + /// use invoiceninjars::InvoiceNinjaClient; + /// + /// let client = InvoiceNinjaClient::hosted(std::env::var("IN_API_TOKEN").unwrap())? + /// .with_api_password(std::env::var("IN_API_PASSWORD").unwrap())?; + /// # Ok(()) + /// # } + /// ``` + pub fn with_api_password(mut self, password: impl Into) -> Result { + let mut value = header::HeaderValue::from_str(&password.into()) + .map_err(|e| Error::InvalidToken(e.to_string()))?; + value.set_sensitive(true); + self.api_password = Some(value); + Ok(self) + } + + /// Single place every request is built, so the optional password header + /// is attached exactly once. + fn req(&self, method: Method, path: &str) -> reqwest::RequestBuilder { + let builder = self + .http + .request(method, self.url(path)) + .timeout(self.timeout); + match &self.api_password { + Some(password) => builder.header("X-API-PASSWORD", password.clone()), + None => builder, + } + } + + /// Builds a client pointed at the hosted app at `https://invoicing.co`. + /// + /// Equivalent to [`new`](Self::new) with [`HOSTED_BASE_URL`]. Prefer this + /// over passing the URL by hand: the base URL must include the `/api/v1` + /// prefix, and a bare host silently 404s every request. + pub fn hosted(api_token: impl Into) -> Result { + Self::new(HOSTED_BASE_URL, api_token) } fn url(&self, path: &str) -> String { @@ -82,43 +217,105 @@ impl InvoiceNinjaClient { path: &str, params: &ListParams, ) -> Result> { - let response = self.http.get(self.url(path)).query(params).send().await?; + let response = self + .req(Method::GET, path) + .query(params) + .query(¶ms.custom) + .send() + .await?; let body: ListResponse = Self::decode(response).await?; Ok(body.data) } - /// Pages through a list endpoint, following `page`/`per_page` until a - /// page comes back shorter than requested (or empty). + /// Pages through a list endpoint until the paginator reports the last + /// page, falling back to a short-page check when no metadata is sent. + /// + /// Termination prefers `meta.pagination.total_pages`, which is + /// authoritative. The short-page heuristic alone is not: the server caps + /// page size (see [`MAX_PAGE_SIZE`]), so a page can be shorter than + /// requested while more records remain. async fn get_all_pages( &self, path: &str, params: &ListParams, ) -> Result> { + /// Page size used when the caller does not pick one. const DEFAULT_PAGE_SIZE: u64 = 50; - let per_page = params.per_page.unwrap_or(DEFAULT_PAGE_SIZE); - let mut page = params.page.unwrap_or(1); + /// Server-side ceiling on `per_page`: Invoice Ninja's `BaseController` + /// applies `min(abs(per_page), 5000)`. + /// + /// Requesting more than this comes back capped, so the request is + /// clamped to match. Otherwise the short-page fallback below would + /// read the very first (capped, therefore "short") page as the last + /// one and silently drop every record after it. + const MAX_PAGE_SIZE: u64 = 5000; + + /// Backstop for a server that never signals the end. At the default + /// page size this is well past any real result set, so reaching it + /// means the pagination contract is broken rather than the data being + /// large. + const MAX_PAGES: u64 = 10_000; + + // `per_page` of 0 would make the short-page check `got < 0`, which no + // page length can satisfy — the loop would never terminate. + let per_page = match params.per_page { + Some(0) | None => DEFAULT_PAGE_SIZE, + Some(n) => n.min(MAX_PAGE_SIZE), + }; + let mut page = params.page.unwrap_or(1).max(1); let mut params = params.clone(); params.per_page = Some(per_page); let mut all = Vec::new(); + let mut fetched = 0u64; loop { params.page = Some(page); - let response = self.http.get(self.url(path)).query(¶ms).send().await?; + let response = self + .req(Method::GET, path) + .query(¶ms) + .query(¶ms.custom) + .send() + .await?; let body: ListResponse = Self::decode(response).await?; let got = body.data.len() as u64; + let total_pages = body.meta.and_then(|m| m.pagination).map(|p| p.total_pages); all.extend(body.data); + fetched += 1; - if got < per_page { + // An empty page ends the walk whatever the metadata claims, so a + // bogus `total_pages` cannot spin on a resource that has run out. + if got == 0 { break; } + + let more = match total_pages { + Some(total) if total > 0 => page < total, + // No usable metadata: fall back to treating a short page as + // the last. Sound here because `per_page` is clamped to what + // the server will actually honour. + _ => got >= per_page, + }; + if !more { + break; + } + + if fetched >= MAX_PAGES { + return Err(Error::PaginationLimit { + path: path.to_string(), + pages: fetched, + }); + } + + // Advance our own counter rather than trusting `current_page`, so + // a server that echoes a fixed page number cannot loop forever. page += 1; } Ok(all) } async fn get_one(&self, path: &str) -> Result { - let response = self.http.get(self.url(path)).send().await?; + let response = self.req(Method::GET, path).send().await?; let body: ItemResponse = Self::decode(response).await?; Ok(body.data) } @@ -128,7 +325,7 @@ impl InvoiceNinjaClient { path: &str, payload: &B, ) -> Result { - let response = self.http.post(self.url(path)).json(payload).send().await?; + let response = self.req(Method::POST, path).json(payload).send().await?; let body: ItemResponse = Self::decode(response).await?; Ok(body.data) } @@ -138,13 +335,13 @@ impl InvoiceNinjaClient { path: &str, payload: &B, ) -> Result { - let response = self.http.put(self.url(path)).json(payload).send().await?; + let response = self.req(Method::PUT, path).json(payload).send().await?; let body: ItemResponse = Self::decode(response).await?; Ok(body.data) } async fn delete_one(&self, path: &str) -> Result<()> { - let response = self.http.delete(self.url(path)).send().await?; + let response = self.req(Method::DELETE, path).send().await?; let status = response.status(); let bytes = response.bytes().await?; if status.is_success() { @@ -153,11 +350,71 @@ impl InvoiceNinjaClient { Err(Self::api_error(status, &bytes)) } } + + async fn post_list( + &self, + path: &str, + payload: &B, + ) -> Result> { + let response = self.req(Method::POST, path).json(payload).send().await?; + let body: ListResponse = Self::decode(response).await?; + Ok(body.data) + } + + /// Returns the raw response body. Used for endpoints that do not answer + /// with JSON — PDF streams (`application/pdf`) and CSV report exports. + async fn get_raw(&self, path: &str) -> Result> { + let response = self.req(Method::GET, path).send().await?; + let status = response.status(); + let bytes = response.bytes().await?; + if status.is_success() { + Ok(bytes.to_vec()) + } else { + Err(Self::api_error(status, &bytes)) + } + } + + async fn post_raw(&self, path: &str, payload: &B) -> Result> { + let response = self.req(Method::POST, path).json(payload).send().await?; + let status = response.status(); + let bytes = response.bytes().await?; + if status.is_success() { + Ok(bytes.to_vec()) + } else { + Err(Self::api_error(status, &bytes)) + } + } + + /// Decodes a response that has no envelope and no stable schema + /// (`/statics`, `/charts/*`, `/search`). + async fn get_unwrapped(&self, path: &str) -> Result { + let response = self.req(Method::GET, path).send().await?; + Self::decode(response).await + } + + async fn post_unwrapped( + &self, + path: &str, + payload: &B, + ) -> Result { + let response = self.req(Method::POST, path).json(payload).send().await?; + Self::decode(response).await + } + + /// Issues a request whose body is discarded, failing on non-2xx. + async fn get_discard(&self, path: &str) -> Result<()> { + self.get_raw(path).await.map(|_| ()) + } } /// Generates typed list/list_all/get/create/update/delete methods for a /// resource backed by a single Invoice Ninja REST endpoint (e.g. `/clients`). -macro_rules! resource_methods { +/// +/// Most resources should use [`resource_methods!`] instead, which adds +/// `bulk`. This bare form is for the handful of `Route::resource` groups +/// that have no `/bulk` route — `client_gateway_tokens`, `system_logs`, and +/// `companies` — where a `bulk_*` method would just 405. +macro_rules! crud_methods { ($list:ident, $list_all:ident, $get:ident, $create:ident, $update:ident, $delete:ident, $path:literal, $model:ty) => { pub async fn $list(&self, params: &ListParams) -> Result> { self.get_list($path, params).await @@ -170,7 +427,7 @@ macro_rules! resource_methods { } pub async fn $get(&self, id: &str) -> Result<$model> { - self.get_one(&format!("{}/{}", $path, id)).await + self.get_one(&format!("{}/{}", $path, seg(id)?)).await } pub async fn $create(&self, body: &$model) -> Result<$model> { @@ -178,11 +435,74 @@ macro_rules! resource_methods { } pub async fn $update(&self, id: &str, body: &$model) -> Result<$model> { - self.put_one(&format!("{}/{}", $path, id), body).await + self.put_one(&format!("{}/{}", $path, seg(id)?), body).await } pub async fn $delete(&self, id: &str) -> Result<()> { - self.delete_one(&format!("{}/{}", $path, id)).await + self.delete_one(&format!("{}/{}", $path, seg(id)?)).await + } + }; +} + +/// Generates everything [`crud_methods!`] does, plus `bulk`. +macro_rules! resource_methods { + ($list:ident, $list_all:ident, $get:ident, $create:ident, $update:ident, $delete:ident, $bulk:ident, $path:literal, $model:ty) => { + crud_methods!($list, $list_all, $get, $create, $update, $delete, $path, $model); + + /// Applies `action` to many records at once and returns the affected + /// records. `archive`, `restore`, and `delete` are available on every + /// resource that has a `/bulk` route. + /// + /// A few actions answer with something other than a list of entities + /// and so cannot be used here — see + /// [`download_invoices`](Self::download_invoices) for the PDF ones. + /// An `ids` list that matches nothing is not an error; it comes back + /// as an empty `Vec`. + pub async fn $bulk(&self, action: &str, ids: &[impl AsRef]) -> Result> { + self.post_list(&format!("{}/bulk", $path), &BulkRequest::new(action, ids)) + .await + } + }; +} + +/// Generates the `GET /{resource}/{id}/{action}` helpers. Only invoices, +/// quotes, credits, and purchase orders expose this route. +macro_rules! entity_action_methods { + ($action:ident, $download:ident, $download_many:ident, $path:literal, $model:ty) => { + /// Performs a single-entity action and returns the updated record. + /// + /// Actions that return the entity: `auto_bill`, `clone_to_invoice`, + /// `clone_to_quote`, `mark_paid`, `mark_sent`, `archive`, `restore`, + /// `delete`. + /// + /// Three actions on this route return something else and will fail to + /// decode here: `download` (a PDF — use the `download_*` method), + /// `convert_to_purchase_order` (a purchase order — use + /// [`convert_invoice_to_purchase_order`](Self::convert_invoice_to_purchase_order)), + /// and `history` / `delivery_note` / `cancel`, which upstream answers + /// with an empty body. + pub async fn $action(&self, id: &str, action: &str) -> Result<$model> { + self.get_one(&format!("{}/{}/{}", $path, seg(id)?, seg(action)?)) + .await + } + + /// Downloads the rendered PDF bytes for one record. + pub async fn $download(&self, id: &str) -> Result> { + self.get_raw(&format!("{}/{}/download", $path, seg(id)?)) + .await + } + + /// Downloads one PDF covering every listed record. + /// + /// This is the bulk `download` action, which streams a merged PDF + /// rather than returning JSON, so it cannot go through the `bulk_*` + /// method for this resource. + pub async fn $download_many(&self, ids: &[impl AsRef]) -> Result> { + self.post_raw( + &format!("{}/bulk", $path), + &BulkRequest::new("download", ids), + ) + .await } }; } @@ -195,6 +515,7 @@ impl InvoiceNinjaClient { create_client, update_client, delete_client, + bulk_clients, "clients", ClientModel ); @@ -206,6 +527,7 @@ impl InvoiceNinjaClient { create_invoice, update_invoice, delete_invoice, + bulk_invoices, "invoices", Invoice ); @@ -217,6 +539,7 @@ impl InvoiceNinjaClient { create_quote, update_quote, delete_quote, + bulk_quotes, "quotes", Quote ); @@ -228,6 +551,7 @@ impl InvoiceNinjaClient { create_credit, update_credit, delete_credit, + bulk_credits, "credits", Credit ); @@ -239,6 +563,7 @@ impl InvoiceNinjaClient { create_payment, update_payment, delete_payment, + bulk_payments, "payments", Payment ); @@ -250,6 +575,7 @@ impl InvoiceNinjaClient { create_product, update_product, delete_product, + bulk_products, "products", Product ); @@ -261,6 +587,7 @@ impl InvoiceNinjaClient { create_expense, update_expense, delete_expense, + bulk_expenses, "expenses", Expense ); @@ -272,6 +599,7 @@ impl InvoiceNinjaClient { create_project, update_project, delete_project, + bulk_projects, "projects", Project ); @@ -283,6 +611,7 @@ impl InvoiceNinjaClient { create_task, update_task, delete_task, + bulk_tasks, "tasks", Task ); @@ -294,7 +623,511 @@ impl InvoiceNinjaClient { create_vendor, update_vendor, delete_vendor, + bulk_vendors, "vendors", Vendor ); + + resource_methods!( + list_recurring_invoices, + list_all_recurring_invoices, + get_recurring_invoice, + create_recurring_invoice, + update_recurring_invoice, + delete_recurring_invoice, + bulk_recurring_invoices, + "recurring_invoices", + RecurringInvoice + ); + + resource_methods!( + list_recurring_quotes, + list_all_recurring_quotes, + get_recurring_quote, + create_recurring_quote, + update_recurring_quote, + delete_recurring_quote, + bulk_recurring_quotes, + "recurring_quotes", + RecurringQuote + ); + + resource_methods!( + list_recurring_expenses, + list_all_recurring_expenses, + get_recurring_expense, + create_recurring_expense, + update_recurring_expense, + delete_recurring_expense, + bulk_recurring_expenses, + "recurring_expenses", + RecurringExpense + ); + + resource_methods!( + list_purchase_orders, + list_all_purchase_orders, + get_purchase_order, + create_purchase_order, + update_purchase_order, + delete_purchase_order, + bulk_purchase_orders, + "purchase_orders", + PurchaseOrder + ); + + resource_methods!( + list_tax_rates, + list_all_tax_rates, + get_tax_rate, + create_tax_rate, + update_tax_rate, + delete_tax_rate, + bulk_tax_rates, + "tax_rates", + TaxRate + ); + + resource_methods!( + list_expense_categories, + list_all_expense_categories, + get_expense_category, + create_expense_category, + update_expense_category, + delete_expense_category, + bulk_expense_categories, + "expense_categories", + ExpenseCategory + ); + + resource_methods!( + list_task_statuses, + list_all_task_statuses, + get_task_status, + create_task_status, + update_task_status, + delete_task_status, + bulk_task_statuses, + "task_statuses", + TaskStatus + ); + + resource_methods!( + list_payment_terms, + list_all_payment_terms, + get_payment_term, + create_payment_term, + update_payment_term, + delete_payment_term, + bulk_payment_terms, + "payment_terms", + PaymentTerm + ); + + resource_methods!( + list_subscriptions, + list_all_subscriptions, + get_subscription, + create_subscription, + update_subscription, + delete_subscription, + bulk_subscriptions, + "subscriptions", + Subscription + ); + + resource_methods!( + list_bank_transactions, + list_all_bank_transactions, + get_bank_transaction, + create_bank_transaction, + update_bank_transaction, + delete_bank_transaction, + bulk_bank_transactions, + "bank_transactions", + BankTransaction + ); + + resource_methods!( + list_webhooks, + list_all_webhooks, + get_webhook, + create_webhook, + update_webhook, + delete_webhook, + bulk_webhooks, + "webhooks", + Webhook + ); + + entity_action_methods!( + invoice_action, + download_invoice, + download_invoices, + "invoices", + Invoice + ); + + entity_action_methods!( + quote_action, + download_quote, + download_quotes, + "quotes", + Quote + ); + + entity_action_methods!( + credit_action, + download_credit, + download_credits, + "credits", + Credit + ); + + entity_action_methods!( + purchase_order_action, + download_purchase_order, + download_purchase_orders, + "purchase_orders", + PurchaseOrder + ); +} + +/// Documents, reports, and the remaining endpoints that do not follow the +/// per-resource CRUD shape. +impl InvoiceNinjaClient { + // --- documents ------------------------------------------------------ + + pub async fn list_documents(&self, params: &ListParams) -> Result> { + self.get_list("documents", params).await + } + + pub async fn list_all_documents(&self, params: &ListParams) -> Result> { + self.get_all_pages("documents", params).await + } + + pub async fn get_document(&self, id: &str) -> Result { + self.get_one(&format!("documents/{}", seg(id)?)).await + } + + pub async fn update_document(&self, id: &str, body: &Document) -> Result { + self.put_one(&format!("documents/{}", seg(id)?), body).await + } + + pub async fn delete_document(&self, id: &str) -> Result<()> { + self.delete_one(&format!("documents/{}", seg(id)?)).await + } + + /// Downloads a document's raw bytes. + pub async fn download_document(&self, id: &str) -> Result> { + self.get_raw(&format!("documents/{}/download", seg(id)?)) + .await + } + + pub async fn bulk_documents( + &self, + action: &str, + ids: &[impl AsRef], + ) -> Result> { + self.post_list("documents/bulk", &BulkRequest::new(action, ids)) + .await + } + + // --- entity actions with non-entity responses ----------------------- + + /// Converts an invoice into a purchase order. Separate from + /// [`invoice_action`](Self::invoice_action) because the response is a + /// purchase order rather than an invoice. + pub async fn convert_invoice_to_purchase_order(&self, id: &str) -> Result { + self.get_one(&format!("invoices/{}/convert_to_purchase_order", seg(id)?)) + .await + } + + /// Marks an invoice as sent, returning the updated invoice. + pub async fn mark_invoice_sent(&self, id: &str) -> Result { + self.invoice_action(id, "mark_sent").await + } + + /// Marks an invoice as paid, returning the updated invoice. + /// + /// Upstream rejects this with HTTP 400 if the invoice is already paid or + /// deleted, which surfaces as [`Error::Api`](crate::Error::Api). + pub async fn mark_invoice_paid(&self, id: &str) -> Result { + self.invoice_action(id, "mark_paid").await + } + + // --- reports -------------------------------------------------------- + + /// Runs a report and returns the raw export body, which is CSV for most + /// reports. + /// + /// `name` is the report path segment, e.g. `invoices`, `clients`, + /// `payments`, `profitloss`, `ar_detail_report`, `tax_summary_report`. + /// `body` carries the report's filters (commonly `start_date`, + /// `end_date`, `date_range`, `report_keys`). + /// + /// Large exports are queued and emailed instead of returned inline; in + /// that case the body is a short JSON message rather than CSV. + pub async fn report(&self, name: &str, body: &B) -> Result> { + self.post_raw(&format!("reports/{}", name), body).await + } + + // --- charts, search, and lookups ------------------------------------ + + /// Posts to a `/charts/{name}` endpoint, e.g. `totals_v2`, + /// `chart_summary_v2`, `calculated_fields`, `cashflow_forecast`. + /// + /// The chart payloads have no stable published schema, so the response is + /// returned untyped. + pub async fn chart( + &self, + name: &str, + body: &B, + ) -> Result { + self.post_unwrapped(&format!("charts/{}", name), body).await + } + + /// Full-text search across entities. + pub async fn search(&self, body: &B) -> Result { + self.post_unwrapped("search", body).await + } + + /// Fetches the static lookup tables. + /// + /// The response is a single object keyed by lookup name; the official PHP + /// SDK exposes `currencies`, `industries`, `languages`, `countries`, + /// `banks`, `payment_types`, and `templates`, and the API also returns + /// `timezones`, `date_formats`, `datetime_formats`, `gateways`, and + /// `sizes`. + /// + /// Each table has its own field set (currencies carry `symbol` and + /// `precision`, countries carry ISO codes), so there's no shared item + /// type; decode into `serde_json::Value`, or into your own struct + /// covering just the tables you need. + /// + /// This is a large and near-constant payload — cache it rather than + /// fetching per request. + pub async fn statics(&self) -> Result { + self.get_unwrapped("statics").await + } + + /// Queues an email for an entity. The body selects the target, e.g. + /// `{"entity": "invoice", "entity_id": "...", "template": "email_template_invoice"}`. + pub async fn send_email(&self, body: &B) -> Result { + self.post_unwrapped("emails", body).await + } + + /// Refunds a payment. The body takes `id`, optionally `amount`, + /// `invoices`, and `send_email`. + pub async fn refund_payment(&self, body: &B) -> Result { + self.post_one("payments/refund", body).await + } + + // --- health --------------------------------------------------------- + + /// Cheap authenticated round-trip, useful for validating a token. + pub async fn ping(&self) -> Result<()> { + self.get_discard("ping").await + } + + /// Server-side health check (database, queues, and so on). + pub async fn health_check(&self) -> Result { + self.get_unwrapped("health_check").await + } +} + +/// Resource groups added to match (and go past) the official PHP SDK. +impl InvoiceNinjaClient { + resource_methods!( + list_bank_integrations, + list_all_bank_integrations, + get_bank_integration, + create_bank_integration, + update_bank_integration, + delete_bank_integration, + bulk_bank_integrations, + "bank_integrations", + BankIntegration + ); + + resource_methods!( + list_bank_transaction_rules, + list_all_bank_transaction_rules, + get_bank_transaction_rule, + create_bank_transaction_rule, + update_bank_transaction_rule, + delete_bank_transaction_rule, + bulk_bank_transaction_rules, + "bank_transaction_rules", + BankTransactionRule + ); + + resource_methods!( + list_company_gateways, + list_all_company_gateways, + get_company_gateway, + create_company_gateway, + update_company_gateway, + delete_company_gateway, + bulk_company_gateways, + "company_gateways", + CompanyGateway + ); + + resource_methods!( + list_designs, + list_all_designs, + get_design, + create_design, + update_design, + delete_design, + bulk_designs, + "designs", + Design + ); + + resource_methods!( + list_group_settings, + list_all_group_settings, + get_group_setting, + create_group_setting, + update_group_setting, + delete_group_setting, + bulk_group_settings, + "group_settings", + GroupSetting + ); + + resource_methods!( + list_locations, + list_all_locations, + get_location, + create_location, + update_location, + delete_location, + bulk_locations, + "locations", + Location + ); + + resource_methods!( + list_tags, + list_all_tags, + get_tag, + create_tag, + update_tag, + delete_tag, + bulk_tags, + "tags", + Tag + ); + + resource_methods!( + list_task_schedulers, + list_all_task_schedulers, + get_task_scheduler, + create_task_scheduler, + update_task_scheduler, + delete_task_scheduler, + bulk_task_schedulers, + "task_schedulers", + TaskScheduler + ); + + resource_methods!( + list_tokens, + list_all_tokens, + get_token, + create_token, + update_token, + delete_token, + bulk_tokens, + "tokens", + CompanyToken + ); + + // --- no /bulk route --------------------------------------------------- + + crud_methods!( + list_client_gateway_tokens, + list_all_client_gateway_tokens, + get_client_gateway_token, + create_client_gateway_token, + update_client_gateway_token, + delete_client_gateway_token, + "client_gateway_tokens", + ClientGatewayToken + ); + + crud_methods!( + list_companies, + list_all_companies, + get_company, + create_company, + update_company, + delete_company, + "companies", + Company + ); + + crud_methods!( + list_system_logs, + list_all_system_logs, + get_system_log, + create_system_log, + update_system_log, + delete_system_log, + "system_logs", + SystemLog + ); +} + +/// Users. +/// +/// `/users` is not a `Route::resource`, and all of it except the index +/// listing is behind the `password_protected` middleware, so every method +/// here other than [`list_users`](Self::list_users) and +/// [`list_all_users`](Self::list_all_users) needs +/// [`with_api_password`](Self::with_api_password). There is also no plain +/// `DELETE /users/{id}` route — use +/// [`detach_user_from_company`](Self::detach_user_from_company) or the bulk +/// `delete` action. +impl InvoiceNinjaClient { + pub async fn list_users(&self, params: &ListParams) -> Result> { + self.get_list("users", params).await + } + + pub async fn list_all_users(&self, params: &ListParams) -> Result> { + self.get_all_pages("users", params).await + } + + /// Requires an API password. + pub async fn get_user(&self, id: &str) -> Result { + self.get_one(&format!("users/{}", seg(id)?)).await + } + + /// Requires an API password. + pub async fn create_user(&self, body: &User) -> Result { + self.post_one("users", body).await + } + + /// Requires an API password. + pub async fn update_user(&self, id: &str, body: &User) -> Result { + self.put_one(&format!("users/{}", seg(id)?), body).await + } + + /// Removes a user from the current company. Requires an API password. + /// + /// This is the closest thing to deleting a user; the API exposes no + /// `DELETE /users/{id}`. + pub async fn detach_user_from_company(&self, id: &str) -> Result<()> { + self.delete_one(&format!("users/{}/detach_from_company", seg(id)?)) + .await + } + + /// Requires an API password. + pub async fn bulk_users(&self, action: &str, ids: &[impl AsRef]) -> Result> { + self.post_list("users/bulk", &BulkRequest::new(action, ids)) + .await + } } diff --git a/src/error.rs b/src/error.rs index 8eecec6..d7f7089 100644 --- a/src/error.rs +++ b/src/error.rs @@ -30,6 +30,26 @@ pub enum Error { #[error("invalid api token: {0}")] InvalidToken(String), + + /// An id or action could not be used as a single URL path segment. + /// + /// Raised for values that would change which endpoint is addressed rather + /// than identifying a record — an empty id (which would hit the + /// collection route) or one made only of dots (which the URL parser + /// resolves away). Invoice Ninja ids are hashids, so this only rejects + /// input that was never a valid id. + #[error("`{0}` cannot be used as a url path segment")] + InvalidPathSegment(String), + + /// A `list_all_*` call kept receiving full pages past the point where any + /// plausible result set would have ended. + /// + /// This means the server is not paginating the way the client expects — + /// typically because it ignores the `page` parameter and keeps replaying + /// the same page. Raised instead of returning the partial result, so the + /// truncation cannot be mistaken for a complete answer. + #[error("pagination did not terminate for {path} after {pages} pages")] + PaginationLimit { path: String, pages: u64 }, } pub type Result = std::result::Result; diff --git a/src/lib.rs b/src/lib.rs index de6756d..f21c022 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -18,5 +18,5 @@ mod client; mod error; pub mod models; -pub use client::InvoiceNinjaClient; +pub use client::{InvoiceNinjaClient, HOSTED_BASE_URL}; pub use error::{ApiErrorBody, Error, Result}; diff --git a/src/models/bank_integration.rs b/src/models/bank_integration.rs new file mode 100644 index 0000000..7083adb --- /dev/null +++ b/src/models/bank_integration.rs @@ -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, + #[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, + #[serde(default)] + pub created_at: Option, + #[serde(default)] + pub updated_at: Option, + + #[serde(flatten)] + pub extra: Map, +} diff --git a/src/models/bank_transaction.rs b/src/models/bank_transaction.rs new file mode 100644 index 0000000..d05a56a --- /dev/null +++ b/src/models/bank_transaction.rs @@ -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, + /// 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, + #[serde(default)] + pub created_at: Option, + #[serde(default)] + pub updated_at: Option, + + #[serde(flatten)] + pub extra: Map, +} diff --git a/src/models/bank_transaction_rule.rs b/src/models/bank_transaction_rule.rs new file mode 100644 index 0000000..2d4f8e3 --- /dev/null +++ b/src/models/bank_transaction_rule.rs @@ -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, + #[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, + #[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, + #[serde(default)] + pub created_at: Option, + #[serde(default)] + pub updated_at: Option, + + #[serde(flatten)] + pub extra: Map, +} diff --git a/src/models/client.rs b/src/models/client.rs index 2c882d6..ccda34f 100644 --- a/src/models/client.rs +++ b/src/models/client.rs @@ -5,37 +5,37 @@ use serde_json::{Map, Value}; pub struct Client { #[serde(skip_serializing_if = "Option::is_none")] pub id: Option, - #[serde(default)] + #[serde(default, deserialize_with = "super::common::null_to_default")] pub name: String, - #[serde(default)] + #[serde(default, deserialize_with = "super::common::null_to_default")] pub id_number: String, - #[serde(default)] + #[serde(default, deserialize_with = "super::common::null_to_default")] pub vat_number: String, - #[serde(default)] + #[serde(default, deserialize_with = "super::common::null_to_default")] pub address1: String, - #[serde(default)] + #[serde(default, deserialize_with = "super::common::null_to_default")] pub address2: String, - #[serde(default)] + #[serde(default, deserialize_with = "super::common::null_to_default")] pub city: String, - #[serde(default)] + #[serde(default, deserialize_with = "super::common::null_to_default")] pub state: String, - #[serde(default)] + #[serde(default, deserialize_with = "super::common::null_to_default")] pub postal_code: String, - #[serde(default)] + #[serde(default, deserialize_with = "super::common::null_to_default")] pub country_id: String, - #[serde(default)] + #[serde(default, deserialize_with = "super::common::null_to_default")] pub phone: String, - #[serde(default)] + #[serde(default, deserialize_with = "super::common::null_to_default")] pub website: String, - #[serde(default)] + #[serde(default, deserialize_with = "super::common::null_to_default")] pub private_notes: String, - #[serde(default)] + #[serde(default, deserialize_with = "super::common::null_to_default")] pub balance: f64, - #[serde(default)] + #[serde(default, deserialize_with = "super::common::null_to_default")] pub paid_to_date: f64, - #[serde(default)] + #[serde(default, deserialize_with = "super::common::null_to_default")] pub credit_balance: f64, - #[serde(default)] + #[serde(default, deserialize_with = "super::common::null_to_default")] pub is_deleted: bool, #[serde(default)] pub archived_at: Option, @@ -43,7 +43,7 @@ pub struct Client { pub created_at: Option, #[serde(default)] pub updated_at: Option, - #[serde(default)] + #[serde(default, deserialize_with = "super::common::null_to_default")] pub contacts: Vec, /// Any fields returned by the API that aren't modeled above are preserved here, @@ -56,15 +56,15 @@ pub struct Client { pub struct ClientContact { #[serde(skip_serializing_if = "Option::is_none")] pub id: Option, - #[serde(default)] + #[serde(default, deserialize_with = "super::common::null_to_default")] pub first_name: String, - #[serde(default)] + #[serde(default, deserialize_with = "super::common::null_to_default")] pub last_name: String, - #[serde(default)] + #[serde(default, deserialize_with = "super::common::null_to_default")] pub email: String, - #[serde(default)] + #[serde(default, deserialize_with = "super::common::null_to_default")] pub phone: String, - #[serde(default)] + #[serde(default, deserialize_with = "super::common::null_to_default")] pub is_primary: bool, #[serde(flatten)] diff --git a/src/models/client_gateway_token.rs b/src/models/client_gateway_token.rs new file mode 100644 index 0000000..a511b0a --- /dev/null +++ b/src/models/client_gateway_token.rs @@ -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, + #[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, + #[serde(default)] + pub created_at: Option, + #[serde(default)] + pub updated_at: Option, + + #[serde(flatten)] + pub extra: Map, +} diff --git a/src/models/common.rs b/src/models/common.rs index c4e4973..89ca25a 100644 --- a/src/models/common.rs +++ b/src/models/common.rs @@ -1,7 +1,12 @@ -use serde::{Deserialize, Serialize}; +use serde::{Deserialize, Deserializer, Serialize}; #[derive(Debug, Clone, Deserialize)] pub struct ListResponse { + /// 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, #[serde(default)] pub meta: Option, @@ -44,6 +49,19 @@ pub struct ListParams { /// Set to 1 to include archived/deleted records. #[serde(skip_serializing_if = "Option::is_none")] pub status: Option, + + /// Extra query parameters appended verbatim. + /// + /// The typed fields above are only the parameters common to every list + /// endpoint; individual resources accept many more (`client_id`, + /// `vendor_id`, `number`, `created_at`, `is_deleted`, per-resource + /// filters). Use [`param`](Self::param) to set those. + /// + /// Skipped by serde and applied as a separate query pass, because + /// `serde_urlencoded` — which backs `reqwest`'s query serialization — + /// does not handle `#[serde(flatten)]`ed maps. + #[serde(skip)] + pub custom: Vec<(String, String)>, } impl ListParams { @@ -80,4 +98,57 @@ impl ListParams { self.status = Some(status.into()); self } + + /// Adds an arbitrary query parameter, for filters the typed setters do + /// not cover. + /// + /// ``` + /// use invoiceninjars::models::ListParams; + /// + /// let params = ListParams::new() + /// .per_page(100) + /// .param("client_id", "abc123") + /// .param("is_deleted", "false"); + /// ``` + /// + /// Can be called more than once with the same key; both pairs are sent. + pub fn param(mut self, key: impl Into, value: impl Into) -> 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 +where + D: Deserializer<'de>, + T: Deserialize<'de> + Default, +{ + Ok(Option::::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, +} + +impl BulkRequest { + pub fn new(action: impl Into, ids: &[impl AsRef]) -> Self { + Self { + action: action.into(), + ids: ids.iter().map(|id| id.as_ref().to_string()).collect(), + } + } } diff --git a/src/models/company.rs b/src/models/company.rs new file mode 100644 index 0000000..55a4fd8 --- /dev/null +++ b/src/models/company.rs @@ -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, + #[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, + #[serde(default)] + pub created_at: Option, + #[serde(default)] + pub updated_at: Option, + + #[serde(flatten)] + pub extra: Map, +} diff --git a/src/models/company_gateway.rs b/src/models/company_gateway.rs new file mode 100644 index 0000000..8fe9c12 --- /dev/null +++ b/src/models/company_gateway.rs @@ -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, + #[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, + #[serde(default, deserialize_with = "super::common::null_to_default")] + pub is_deleted: bool, + #[serde(default)] + pub archived_at: Option, + #[serde(default)] + pub created_at: Option, + #[serde(default)] + pub updated_at: Option, + + #[serde(flatten)] + pub extra: Map, +} diff --git a/src/models/company_token.rs b/src/models/company_token.rs new file mode 100644 index 0000000..1bb1415 --- /dev/null +++ b/src/models/company_token.rs @@ -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, + #[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, + #[serde(default)] + pub created_at: Option, + #[serde(default)] + pub updated_at: Option, + + #[serde(flatten)] + pub extra: Map, +} diff --git a/src/models/design.rs b/src/models/design.rs new file mode 100644 index 0000000..96d7e56 --- /dev/null +++ b/src/models/design.rs @@ -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, + #[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, + #[serde(default)] + pub created_at: Option, + #[serde(default)] + pub updated_at: Option, + #[serde(default)] + pub deleted_at: Option, + + #[serde(flatten)] + pub extra: Map, +} diff --git a/src/models/document.rs b/src/models/document.rs new file mode 100644 index 0000000..9509aeb --- /dev/null +++ b/src/models/document.rs @@ -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, + #[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, + #[serde(default)] + pub updated_at: Option, + #[serde(default)] + pub deleted_at: Option, + + #[serde(flatten)] + pub extra: Map, +} diff --git a/src/models/expense.rs b/src/models/expense.rs index 5e4e2d8..2cdf107 100644 --- a/src/models/expense.rs +++ b/src/models/expense.rs @@ -5,39 +5,39 @@ use serde_json::{Map, Value}; pub struct Expense { #[serde(skip_serializing_if = "Option::is_none")] pub id: Option, - #[serde(default)] + #[serde(default, deserialize_with = "super::common::null_to_default")] pub vendor_id: String, - #[serde(default)] + #[serde(default, deserialize_with = "super::common::null_to_default")] pub client_id: String, - #[serde(default)] + #[serde(default, deserialize_with = "super::common::null_to_default")] pub invoice_id: String, - #[serde(default)] + #[serde(default, deserialize_with = "super::common::null_to_default")] pub amount: f64, - #[serde(default)] + #[serde(default, deserialize_with = "super::common::null_to_default")] pub exchange_rate: f64, - #[serde(default)] + #[serde(default, deserialize_with = "super::common::null_to_default")] pub public_notes: String, - #[serde(default)] + #[serde(default, deserialize_with = "super::common::null_to_default")] pub private_notes: String, - #[serde(default)] + #[serde(default, deserialize_with = "super::common::null_to_default")] pub date: String, - #[serde(default)] + #[serde(default, deserialize_with = "super::common::null_to_default")] pub payment_date: String, - #[serde(default)] + #[serde(default, deserialize_with = "super::common::null_to_default")] pub transaction_reference: String, - #[serde(default)] + #[serde(default, deserialize_with = "super::common::null_to_default")] pub category_id: String, - #[serde(default)] + #[serde(default, deserialize_with = "super::common::null_to_default")] pub tax_name1: String, - #[serde(default)] + #[serde(default, deserialize_with = "super::common::null_to_default")] pub tax_rate1: f64, - #[serde(default)] + #[serde(default, deserialize_with = "super::common::null_to_default")] pub tax_name2: String, - #[serde(default)] + #[serde(default, deserialize_with = "super::common::null_to_default")] pub tax_rate2: f64, - #[serde(default)] + #[serde(default, deserialize_with = "super::common::null_to_default")] pub should_be_invoiced: bool, - #[serde(default)] + #[serde(default, deserialize_with = "super::common::null_to_default")] pub is_deleted: bool, #[serde(default)] pub archived_at: Option, diff --git a/src/models/expense_category.rs b/src/models/expense_category.rs new file mode 100644 index 0000000..e2af1cb --- /dev/null +++ b/src/models/expense_category.rs @@ -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, + #[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, + #[serde(default)] + pub created_at: Option, + #[serde(default)] + pub updated_at: Option, + + #[serde(flatten)] + pub extra: Map, +} diff --git a/src/models/group_setting.rs b/src/models/group_setting.rs new file mode 100644 index 0000000..ac19b4a --- /dev/null +++ b/src/models/group_setting.rs @@ -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, + #[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, + #[serde(default)] + pub created_at: Option, + #[serde(default)] + pub updated_at: Option, + + #[serde(flatten)] + pub extra: Map, +} diff --git a/src/models/invoice.rs b/src/models/invoice.rs index 63a7ef9..5f03850 100644 --- a/src/models/invoice.rs +++ b/src/models/invoice.rs @@ -5,35 +5,35 @@ use serde_json::{Map, Value}; pub struct Invoice { #[serde(skip_serializing_if = "Option::is_none")] pub id: Option, - #[serde(default)] + #[serde(default, deserialize_with = "super::common::null_to_default")] pub client_id: String, - #[serde(default)] + #[serde(default, deserialize_with = "super::common::null_to_default")] pub number: String, - #[serde(default)] + #[serde(default, deserialize_with = "super::common::null_to_default")] pub date: String, - #[serde(default)] + #[serde(default, deserialize_with = "super::common::null_to_default")] pub due_date: String, - #[serde(default)] + #[serde(default, deserialize_with = "super::common::null_to_default")] pub po_number: String, - #[serde(default)] + #[serde(default, deserialize_with = "super::common::null_to_default")] pub status_id: String, - #[serde(default)] + #[serde(default, deserialize_with = "super::common::null_to_default")] pub amount: f64, - #[serde(default)] + #[serde(default, deserialize_with = "super::common::null_to_default")] pub balance: f64, - #[serde(default)] + #[serde(default, deserialize_with = "super::common::null_to_default")] pub discount: f64, - #[serde(default)] + #[serde(default, deserialize_with = "super::common::null_to_default")] pub is_amount_discount: bool, - #[serde(default)] + #[serde(default, deserialize_with = "super::common::null_to_default")] pub public_notes: String, - #[serde(default)] + #[serde(default, deserialize_with = "super::common::null_to_default")] pub private_notes: String, - #[serde(default)] + #[serde(default, deserialize_with = "super::common::null_to_default")] pub terms: String, - #[serde(default)] + #[serde(default, deserialize_with = "super::common::null_to_default")] pub footer: String, - #[serde(default)] + #[serde(default, deserialize_with = "super::common::null_to_default")] pub is_deleted: bool, #[serde(default)] pub archived_at: Option, @@ -41,7 +41,7 @@ pub struct Invoice { pub created_at: Option, #[serde(default)] pub updated_at: Option, - #[serde(default)] + #[serde(default, deserialize_with = "super::common::null_to_default")] pub line_items: Vec, #[serde(flatten)] @@ -50,25 +50,25 @@ pub struct Invoice { #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct InvoiceItem { - #[serde(default)] + #[serde(default, deserialize_with = "super::common::null_to_default")] pub product_key: String, - #[serde(default)] + #[serde(default, deserialize_with = "super::common::null_to_default")] pub notes: String, - #[serde(default)] + #[serde(default, deserialize_with = "super::common::null_to_default")] pub cost: f64, - #[serde(default)] + #[serde(default, deserialize_with = "super::common::null_to_default")] pub quantity: f64, - #[serde(default)] + #[serde(default, deserialize_with = "super::common::null_to_default")] pub discount: f64, - #[serde(default)] + #[serde(default, deserialize_with = "super::common::null_to_default")] pub tax_name1: String, - #[serde(default)] + #[serde(default, deserialize_with = "super::common::null_to_default")] pub tax_rate1: f64, - #[serde(default)] + #[serde(default, deserialize_with = "super::common::null_to_default")] pub tax_name2: String, - #[serde(default)] + #[serde(default, deserialize_with = "super::common::null_to_default")] pub tax_rate2: f64, - #[serde(default)] + #[serde(default, deserialize_with = "super::common::null_to_default")] pub line_total: f64, #[serde(flatten)] diff --git a/src/models/location.rs b/src/models/location.rs new file mode 100644 index 0000000..f36ddb9 --- /dev/null +++ b/src/models/location.rs @@ -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, + #[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, + #[serde(default)] + pub created_at: Option, + #[serde(default)] + pub updated_at: Option, + + #[serde(flatten)] + pub extra: Map, +} diff --git a/src/models/mod.rs b/src/models/mod.rs index 008502e..291e84b 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -1,19 +1,67 @@ +mod bank_integration; +mod bank_transaction; +mod bank_transaction_rule; mod client; +mod client_gateway_token; mod common; +mod company; +mod company_gateway; +mod company_token; +mod design; +mod document; mod expense; +mod expense_category; +mod group_setting; mod invoice; +mod location; mod payment; +mod payment_term; mod product; mod project; +mod purchase_order; +mod recurring_expense; +mod recurring_invoice; +mod subscription; +mod system_log; +mod tag; mod task; +mod task_scheduler; +mod task_status; +mod tax_rate; +mod user; mod vendor; +mod webhook; +pub use bank_integration::BankIntegration; +pub use bank_transaction::BankTransaction; +pub use bank_transaction_rule::BankTransactionRule; pub use client::{Client, ClientContact}; -pub use common::{ItemResponse, ListParams, ListResponse, Meta, Pagination}; +pub use client_gateway_token::ClientGatewayToken; +pub use common::{BulkRequest, ItemResponse, ListParams, ListResponse, Meta, Pagination}; +pub use company::Company; +pub use company_gateway::CompanyGateway; +pub use company_token::CompanyToken; +pub use design::Design; +pub use document::Document; pub use expense::Expense; +pub use expense_category::ExpenseCategory; +pub use group_setting::GroupSetting; pub use invoice::{Credit, Invoice, InvoiceItem, Quote}; +pub use location::Location; pub use payment::{Payment, PaymentInvoice}; +pub use payment_term::PaymentTerm; pub use product::Product; pub use project::Project; +pub use purchase_order::PurchaseOrder; +pub use recurring_expense::RecurringExpense; +pub use recurring_invoice::{RecurringInvoice, RecurringQuote}; +pub use subscription::Subscription; +pub use system_log::SystemLog; +pub use tag::Tag; pub use task::Task; +pub use task_scheduler::TaskScheduler; +pub use task_status::TaskStatus; +pub use tax_rate::TaxRate; +pub use user::User; pub use vendor::{Vendor, VendorContact}; +pub use webhook::Webhook; diff --git a/src/models/payment.rs b/src/models/payment.rs index 7c46260..f776d2c 100644 --- a/src/models/payment.rs +++ b/src/models/payment.rs @@ -5,23 +5,23 @@ use serde_json::{Map, Value}; pub struct Payment { #[serde(skip_serializing_if = "Option::is_none")] pub id: Option, - #[serde(default)] + #[serde(default, deserialize_with = "super::common::null_to_default")] pub client_id: String, - #[serde(default)] + #[serde(default, deserialize_with = "super::common::null_to_default")] pub amount: f64, - #[serde(default)] + #[serde(default, deserialize_with = "super::common::null_to_default")] pub applied: f64, - #[serde(default)] + #[serde(default, deserialize_with = "super::common::null_to_default")] pub refunded: f64, - #[serde(default)] + #[serde(default, deserialize_with = "super::common::null_to_default")] pub date: String, - #[serde(default)] + #[serde(default, deserialize_with = "super::common::null_to_default")] pub transaction_reference: String, - #[serde(default)] + #[serde(default, deserialize_with = "super::common::null_to_default")] pub type_id: String, - #[serde(default)] + #[serde(default, deserialize_with = "super::common::null_to_default")] pub private_notes: String, - #[serde(default)] + #[serde(default, deserialize_with = "super::common::null_to_default")] pub is_deleted: bool, #[serde(default)] pub archived_at: Option, @@ -29,7 +29,7 @@ pub struct Payment { pub created_at: Option, #[serde(default)] pub updated_at: Option, - #[serde(default)] + #[serde(default, deserialize_with = "super::common::null_to_default")] pub invoices: Vec, #[serde(flatten)] diff --git a/src/models/payment_term.rs b/src/models/payment_term.rs new file mode 100644 index 0000000..bc5a4c0 --- /dev/null +++ b/src/models/payment_term.rs @@ -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, + /// 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, + #[serde(default)] + pub created_at: Option, + #[serde(default)] + pub updated_at: Option, + + #[serde(flatten)] + pub extra: Map, +} diff --git a/src/models/product.rs b/src/models/product.rs index 30956d7..fc8e43f 100644 --- a/src/models/product.rs +++ b/src/models/product.rs @@ -5,25 +5,25 @@ use serde_json::{Map, Value}; pub struct Product { #[serde(skip_serializing_if = "Option::is_none")] pub id: Option, - #[serde(default)] + #[serde(default, deserialize_with = "super::common::null_to_default")] pub product_key: String, - #[serde(default)] + #[serde(default, deserialize_with = "super::common::null_to_default")] pub notes: String, - #[serde(default)] + #[serde(default, deserialize_with = "super::common::null_to_default")] pub cost: f64, - #[serde(default)] + #[serde(default, deserialize_with = "super::common::null_to_default")] pub price: f64, - #[serde(default)] + #[serde(default, deserialize_with = "super::common::null_to_default")] pub quantity: f64, - #[serde(default)] + #[serde(default, deserialize_with = "super::common::null_to_default")] pub tax_name1: String, - #[serde(default)] + #[serde(default, deserialize_with = "super::common::null_to_default")] pub tax_rate1: f64, - #[serde(default)] + #[serde(default, deserialize_with = "super::common::null_to_default")] pub tax_name2: String, - #[serde(default)] + #[serde(default, deserialize_with = "super::common::null_to_default")] pub tax_rate2: f64, - #[serde(default)] + #[serde(default, deserialize_with = "super::common::null_to_default")] pub is_deleted: bool, #[serde(default)] pub archived_at: Option, diff --git a/src/models/project.rs b/src/models/project.rs index 71f378d..92a2c0e 100644 --- a/src/models/project.rs +++ b/src/models/project.rs @@ -5,25 +5,25 @@ use serde_json::{Map, Value}; pub struct Project { #[serde(skip_serializing_if = "Option::is_none")] pub id: Option, - #[serde(default)] + #[serde(default, deserialize_with = "super::common::null_to_default")] pub client_id: String, - #[serde(default)] + #[serde(default, deserialize_with = "super::common::null_to_default")] pub name: String, - #[serde(default)] + #[serde(default, deserialize_with = "super::common::null_to_default")] pub number: String, - #[serde(default)] + #[serde(default, deserialize_with = "super::common::null_to_default")] pub task_rate: f64, - #[serde(default)] + #[serde(default, deserialize_with = "super::common::null_to_default")] pub due_date: String, - #[serde(default)] + #[serde(default, deserialize_with = "super::common::null_to_default")] pub budgeted_hours: f64, - #[serde(default)] + #[serde(default, deserialize_with = "super::common::null_to_default")] pub color: String, - #[serde(default)] + #[serde(default, deserialize_with = "super::common::null_to_default")] pub public_notes: String, - #[serde(default)] + #[serde(default, deserialize_with = "super::common::null_to_default")] pub private_notes: String, - #[serde(default)] + #[serde(default, deserialize_with = "super::common::null_to_default")] pub is_deleted: bool, #[serde(default)] pub archived_at: Option, diff --git a/src/models/purchase_order.rs b/src/models/purchase_order.rs new file mode 100644 index 0000000..29e7e65 --- /dev/null +++ b/src/models/purchase_order.rs @@ -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, + #[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, + #[serde(default)] + pub archived_at: Option, + #[serde(default)] + pub created_at: Option, + #[serde(default)] + pub updated_at: Option, + #[serde(default, deserialize_with = "super::common::null_to_default")] + pub line_items: Vec, + + #[serde(flatten)] + pub extra: Map, +} diff --git a/src/models/recurring_expense.rs b/src/models/recurring_expense.rs new file mode 100644 index 0000000..b35ee22 --- /dev/null +++ b/src/models/recurring_expense.rs @@ -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, + #[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, + #[serde(default)] + pub created_at: Option, + #[serde(default)] + pub updated_at: Option, + + #[serde(flatten)] + pub extra: Map, +} diff --git a/src/models/recurring_invoice.rs b/src/models/recurring_invoice.rs new file mode 100644 index 0000000..ff93719 --- /dev/null +++ b/src/models/recurring_invoice.rs @@ -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, + #[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, + #[serde(default)] + pub archived_at: Option, + #[serde(default)] + pub created_at: Option, + #[serde(default)] + pub updated_at: Option, + #[serde(default, deserialize_with = "super::common::null_to_default")] + pub line_items: Vec, + + #[serde(flatten)] + pub extra: Map, +} + +/// 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; diff --git a/src/models/subscription.rs b/src/models/subscription.rs new file mode 100644 index 0000000..4837bf0 --- /dev/null +++ b/src/models/subscription.rs @@ -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, + #[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, + #[serde(default)] + pub created_at: Option, + #[serde(default)] + pub updated_at: Option, + + #[serde(flatten)] + pub extra: Map, +} diff --git a/src/models/system_log.rs b/src/models/system_log.rs new file mode 100644 index 0000000..922ba95 --- /dev/null +++ b/src/models/system_log.rs @@ -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, + #[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, +} diff --git a/src/models/tag.rs b/src/models/tag.rs new file mode 100644 index 0000000..02d9db0 --- /dev/null +++ b/src/models/tag.rs @@ -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, + #[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, + #[serde(default)] + pub created_at: Option, + #[serde(default)] + pub updated_at: Option, + + #[serde(flatten)] + pub extra: Map, +} diff --git a/src/models/task.rs b/src/models/task.rs index f1b1358..7004583 100644 --- a/src/models/task.rs +++ b/src/models/task.rs @@ -5,24 +5,24 @@ use serde_json::{Map, Value}; pub struct Task { #[serde(skip_serializing_if = "Option::is_none")] pub id: Option, - #[serde(default)] + #[serde(default, deserialize_with = "super::common::null_to_default")] pub client_id: String, - #[serde(default)] + #[serde(default, deserialize_with = "super::common::null_to_default")] pub invoice_id: String, - #[serde(default)] + #[serde(default, deserialize_with = "super::common::null_to_default")] pub project_id: String, - #[serde(default)] + #[serde(default, deserialize_with = "super::common::null_to_default")] pub description: String, - #[serde(default)] + #[serde(default, deserialize_with = "super::common::null_to_default")] pub rate: f64, - #[serde(default)] + #[serde(default, deserialize_with = "super::common::null_to_default")] pub status_id: String, - #[serde(default)] + #[serde(default, deserialize_with = "super::common::null_to_default")] pub is_running: bool, - #[serde(default)] + #[serde(default, deserialize_with = "super::common::null_to_default")] pub is_deleted: bool, /// JSON-encoded array of `[start_timestamp, end_timestamp]` pairs, as returned by the API. - #[serde(default)] + #[serde(default, deserialize_with = "super::common::null_to_default")] pub time_log: String, #[serde(default)] pub archived_at: Option, diff --git a/src/models/task_scheduler.rs b/src/models/task_scheduler.rs new file mode 100644 index 0000000..7794a76 --- /dev/null +++ b/src/models/task_scheduler.rs @@ -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, + #[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, + #[serde(default, deserialize_with = "super::common::null_to_default")] + pub is_deleted: bool, + #[serde(default)] + pub archived_at: Option, + #[serde(default)] + pub created_at: Option, + #[serde(default)] + pub updated_at: Option, + + #[serde(flatten)] + pub extra: Map, +} diff --git a/src/models/task_status.rs b/src/models/task_status.rs new file mode 100644 index 0000000..2516d74 --- /dev/null +++ b/src/models/task_status.rs @@ -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, + #[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, + #[serde(default)] + pub created_at: Option, + #[serde(default)] + pub updated_at: Option, + + #[serde(flatten)] + pub extra: Map, +} diff --git a/src/models/tax_rate.rs b/src/models/tax_rate.rs new file mode 100644 index 0000000..0513c06 --- /dev/null +++ b/src/models/tax_rate.rs @@ -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, + #[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, + #[serde(default)] + pub created_at: Option, + #[serde(default)] + pub updated_at: Option, + + #[serde(flatten)] + pub extra: Map, +} diff --git a/src/models/user.rs b/src/models/user.rs new file mode 100644 index 0000000..88b3b63 --- /dev/null +++ b/src/models/user.rs @@ -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, + #[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, + #[serde(default)] + pub created_at: Option, + #[serde(default)] + pub updated_at: Option, + + #[serde(flatten)] + pub extra: Map, +} diff --git a/src/models/vendor.rs b/src/models/vendor.rs index 159cd99..ece4a3a 100644 --- a/src/models/vendor.rs +++ b/src/models/vendor.rs @@ -5,31 +5,31 @@ use serde_json::{Map, Value}; pub struct Vendor { #[serde(skip_serializing_if = "Option::is_none")] pub id: Option, - #[serde(default)] + #[serde(default, deserialize_with = "super::common::null_to_default")] pub name: String, - #[serde(default)] + #[serde(default, deserialize_with = "super::common::null_to_default")] pub id_number: String, - #[serde(default)] + #[serde(default, deserialize_with = "super::common::null_to_default")] pub vat_number: String, - #[serde(default)] + #[serde(default, deserialize_with = "super::common::null_to_default")] pub address1: String, - #[serde(default)] + #[serde(default, deserialize_with = "super::common::null_to_default")] pub address2: String, - #[serde(default)] + #[serde(default, deserialize_with = "super::common::null_to_default")] pub city: String, - #[serde(default)] + #[serde(default, deserialize_with = "super::common::null_to_default")] pub state: String, - #[serde(default)] + #[serde(default, deserialize_with = "super::common::null_to_default")] pub postal_code: String, - #[serde(default)] + #[serde(default, deserialize_with = "super::common::null_to_default")] pub country_id: String, - #[serde(default)] + #[serde(default, deserialize_with = "super::common::null_to_default")] pub phone: String, - #[serde(default)] + #[serde(default, deserialize_with = "super::common::null_to_default")] pub website: String, - #[serde(default)] + #[serde(default, deserialize_with = "super::common::null_to_default")] pub private_notes: String, - #[serde(default)] + #[serde(default, deserialize_with = "super::common::null_to_default")] pub is_deleted: bool, #[serde(default)] pub archived_at: Option, @@ -37,7 +37,7 @@ pub struct Vendor { pub created_at: Option, #[serde(default)] pub updated_at: Option, - #[serde(default)] + #[serde(default, deserialize_with = "super::common::null_to_default")] pub contacts: Vec, /// Any fields returned by the API that aren't modeled above are preserved here, @@ -50,15 +50,15 @@ pub struct Vendor { pub struct VendorContact { #[serde(skip_serializing_if = "Option::is_none")] pub id: Option, - #[serde(default)] + #[serde(default, deserialize_with = "super::common::null_to_default")] pub first_name: String, - #[serde(default)] + #[serde(default, deserialize_with = "super::common::null_to_default")] pub last_name: String, - #[serde(default)] + #[serde(default, deserialize_with = "super::common::null_to_default")] pub email: String, - #[serde(default)] + #[serde(default, deserialize_with = "super::common::null_to_default")] pub phone: String, - #[serde(default)] + #[serde(default, deserialize_with = "super::common::null_to_default")] pub is_primary: bool, #[serde(flatten)] diff --git a/src/models/webhook.rs b/src/models/webhook.rs new file mode 100644 index 0000000..292cea4 --- /dev/null +++ b/src/models/webhook.rs @@ -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, + /// 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, +} diff --git a/tests/client_requests.rs b/tests/client_requests.rs new file mode 100644 index 0000000..022f2f5 --- /dev/null +++ b/tests/client_requests.rs @@ -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::>() + }))) + .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("bad gateway")) + .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"); +} diff --git a/tests/model_roundtrip.rs b/tests/model_roundtrip.rs index f9dc410..ff3e756 100644 --- a/tests/model_roundtrip.rs +++ b/tests/model_roundtrip.rs @@ -1,5 +1,9 @@ use invoiceninjars::models::{ - Client, Credit, Expense, Invoice, ListParams, Project, Quote, Task, Vendor, + BankIntegration, BankTransaction, BankTransactionRule, BulkRequest, Client, ClientGatewayToken, + Company, CompanyGateway, CompanyToken, Credit, Design, Document, Expense, ExpenseCategory, + GroupSetting, Invoice, ListParams, ListResponse, Location, PaymentTerm, Project, PurchaseOrder, + Quote, RecurringExpense, RecurringInvoice, RecurringQuote, Subscription, SystemLog, Tag, Task, + TaskScheduler, TaskStatus, TaxRate, User, Vendor, Webhook, }; use serde_json::json; @@ -150,3 +154,543 @@ fn quote_and_credit_share_the_invoice_shape() { let credit: Credit = serde_json::from_value(payload).unwrap(); assert_eq!(credit.number, "QUO-0001"); } + +#[test] +fn recurring_invoice_decodes_recurrence_fields() { + // frequency_id / remaining_cycles come back as JSON numbers, and + // remaining_cycles is -1 for an indefinite schedule. + let payload = json!({ + "id": "ri1", + "client_id": "abc123", + "frequency_id": 5, + "remaining_cycles": -1, + "auto_bill": "off", + "next_send_date": "2026-11-01", + "line_items": [{"product_key": "widget", "cost": 9.5, "quantity": 2.0}], + "custom_value1": "unmodeled field", + }); + + let ri: RecurringInvoice = serde_json::from_value(payload).unwrap(); + assert_eq!(ri.frequency_id, 5); + assert_eq!(ri.remaining_cycles, -1); + assert_eq!(ri.next_send_date, "2026-11-01"); + assert_eq!(ri.line_items.len(), 1); + assert_eq!(ri.line_items[0].product_key, "widget"); + + let round_tripped = serde_json::to_value(&ri).unwrap(); + assert_eq!(round_tripped["custom_value1"], "unmodeled field"); +} + +/// `RecurringQuote` aliases `RecurringInvoice`; the API's RecurringQuote +/// fields are a strict subset, so a quote payload must decode without the +/// invoice-only fields present. +#[test] +fn recurring_quote_decodes_without_invoice_only_fields() { + let payload = json!({ + "id": "rq1", + "client_id": "abc123", + "frequency_id": 2, + "remaining_cycles": 12, + }); + + let rq: RecurringQuote = serde_json::from_value(payload).unwrap(); + assert_eq!(rq.frequency_id, 2); + assert_eq!(rq.remaining_cycles, 12); + // Invoice-only fields fall back to their defaults rather than failing. + assert_eq!(rq.auto_bill, ""); + assert!(!rq.auto_bill_enabled); +} + +#[test] +fn purchase_order_round_trip_preserves_unknown_fields() { + let payload = json!({ + "id": "po1", + "vendor_id": "ven1", + "number": "PO-0001", + "amount": 250.0, + "custom_surcharge1": 7.25, + }); + + let po: PurchaseOrder = serde_json::from_value(payload).unwrap(); + assert_eq!(po.vendor_id, "ven1"); + assert_eq!(po.number, "PO-0001"); + assert_eq!(po.amount, 250.0); + + let round_tripped = serde_json::to_value(&po).unwrap(); + assert_eq!(round_tripped["custom_surcharge1"], 7.25); +} + +#[test] +fn recurring_expense_round_trip_preserves_unknown_fields() { + let payload = json!({ + "id": "rex1", + "vendor_id": "ven1", + "frequency_id": 4, + "remaining_cycles": 6, + "amount": 19.99, + "custom_value2": "unmodeled field", + }); + + let rex: RecurringExpense = serde_json::from_value(payload).unwrap(); + assert_eq!(rex.frequency_id, 4); + assert_eq!(rex.remaining_cycles, 6); + assert_eq!(rex.amount, 19.99); + + let round_tripped = serde_json::to_value(&rex).unwrap(); + assert_eq!(round_tripped["custom_value2"], "unmodeled field"); +} + +/// `Subscription.frequency_id` is a string even though the recurring-invoice +/// field of the same name is numeric; this pins that asymmetry. +#[test] +fn subscription_frequency_id_is_a_string() { + let payload = json!({ + "id": "sub1", + "product_id": "prod1", + "frequency_id": "5", + "trial_enabled": true, + "trial_duration": 14, + "promo_discount": 10.0, + }); + + let sub: Subscription = serde_json::from_value(payload).unwrap(); + assert_eq!(sub.frequency_id, "5"); + assert!(sub.trial_enabled); + assert_eq!(sub.trial_duration, 14); + assert_eq!(sub.promo_discount, 10.0); +} + +#[test] +fn small_lookup_resources_decode() { + let tax_rate: TaxRate = serde_json::from_value(json!({ + "id": "tr1", "name": "GST", "rate": 10.0 + })) + .unwrap(); + assert_eq!(tax_rate.name, "GST"); + assert_eq!(tax_rate.rate, 10.0); + + let category: ExpenseCategory = serde_json::from_value(json!({ + "id": "ec1", "name": "Accounting" + })) + .unwrap(); + assert_eq!(category.name, "Accounting"); + + let status: TaskStatus = serde_json::from_value(json!({ + "id": "ts1", "name": "Backlog", "color": "#000000", "task_status_order": 4 + })) + .unwrap(); + assert_eq!(status.task_status_order, 4); + assert_eq!(status.color, "#000000"); + + let term: PaymentTerm = serde_json::from_value(json!({ + "id": "pt1", "name": "NET 30", "num_days": 30 + })) + .unwrap(); + assert_eq!(term.num_days, 30); + + let webhook: Webhook = serde_json::from_value(json!({ + "id": "wh1", "event_id": "1", "target_url": "https://example.com/hook", "format": "JSON" + })) + .unwrap(); + assert_eq!(webhook.target_url, "https://example.com/hook"); + assert_eq!(webhook.format, "JSON"); +} + +/// Bank transactions are the one resource whose ids come back as integers +/// rather than hashed strings. +#[test] +fn bank_transaction_decodes_integer_ids() { + let payload = json!({ + "id": "bt1", + "transaction_id": 9876543210i64, + "bank_account_id": 42, + "category_id": 7, + "amount": 123.45, + "base_type": "CREDIT", + "date": "2026-10-01", + }); + + let bt: BankTransaction = serde_json::from_value(payload).unwrap(); + assert_eq!(bt.transaction_id, 9876543210); + assert_eq!(bt.bank_account_id, 42); + assert_eq!(bt.category_id, 7); + assert_eq!(bt.base_type, "CREDIT"); +} + +/// A newly constructed model must not serialize an `id`, so `create_*` calls +/// do not send `"id": null` to the API. +#[test] +fn default_models_omit_id_on_serialize() { + let body = serde_json::to_value(RecurringInvoice::default()).unwrap(); + assert!(body.get("id").is_none()); + + let body = serde_json::to_value(TaxRate::default()).unwrap(); + assert!(body.get("id").is_none()); +} + +// --- explicit-null tolerance ------------------------------------------- +// +// Invoice Ninja returns `null` for plenty of nominally-string fields. +// `#[serde(default)]` only covers a *missing* key, so these payloads used to +// fail to decode entirely; the models pair `default` with a +// null-to-default deserializer. + +#[test] +fn null_strings_numbers_and_bools_decode_as_defaults() { + let payload = json!({ + "id": "abc123", + "name": null, + "address1": null, + "balance": null, + "is_deleted": null, + "contacts": null, + }); + + let client: Client = serde_json::from_value(payload).unwrap(); + assert_eq!(client.name, ""); + assert_eq!(client.address1, ""); + assert_eq!(client.balance, 0.0); + assert!(!client.is_deleted); + assert!(client.contacts.is_empty()); +} + +#[test] +fn null_line_items_and_nested_nulls_decode() { + let payload = json!({ + "id": "inv1", + "number": null, + "public_notes": null, + "line_items": [{ + "product_key": null, + "notes": null, + "cost": null, + "quantity": 3.0, + }], + }); + + let invoice: Invoice = serde_json::from_value(payload).unwrap(); + assert_eq!(invoice.number, ""); + assert_eq!(invoice.public_notes, ""); + assert_eq!(invoice.line_items.len(), 1); + assert_eq!(invoice.line_items[0].product_key, ""); + assert_eq!(invoice.line_items[0].cost, 0.0); + assert_eq!(invoice.line_items[0].quantity, 3.0); +} + +#[test] +fn null_integer_fields_decode_across_resources() { + let ri: RecurringInvoice = serde_json::from_value(json!({ + "id": "ri1", "frequency_id": null, "remaining_cycles": null, "auto_bill": null + })) + .unwrap(); + assert_eq!(ri.frequency_id, 0); + assert_eq!(ri.remaining_cycles, 0); + assert_eq!(ri.auto_bill, ""); + + let status: TaskStatus = serde_json::from_value(json!({ + "id": "ts1", "name": null, "task_status_order": null + })) + .unwrap(); + assert_eq!(status.task_status_order, 0); + + let bt: BankTransaction = serde_json::from_value(json!({ + "id": "bt1", "transaction_id": null, "bank_account_id": null, "base_type": null + })) + .unwrap(); + assert_eq!(bt.transaction_id, 0); + assert_eq!(bt.bank_account_id, 0); +} + +/// Nullable timestamps stay `None` rather than collapsing to `Some(0)`, so +/// "never archived" is still distinguishable from "archived at epoch". +#[test] +fn null_timestamps_remain_none() { + let client: Client = serde_json::from_value(json!({ + "id": "abc123", "archived_at": null, "created_at": null, "updated_at": 1700000000i64 + })) + .unwrap(); + assert_eq!(client.archived_at, None); + assert_eq!(client.created_at, None); + assert_eq!(client.updated_at, Some(1700000000)); +} + +/// A null value for an *unmodeled* key still round-trips through `extra`. +#[test] +fn null_unknown_fields_round_trip_through_extra() { + let payload = json!({"id": "abc123", "name": "Acme Inc", "custom_value1": null}); + + let client: Client = serde_json::from_value(payload).unwrap(); + assert!(client.extra.get("custom_value1").unwrap().is_null()); + + let round_tripped = serde_json::to_value(&client).unwrap(); + assert!(round_tripped["custom_value1"].is_null()); +} + +// --- non-CRUD endpoint plumbing ---------------------------------------- + +#[test] +fn bulk_request_serializes_action_and_ids() { + let body = BulkRequest::new("archive", &["abc123", "def456"]); + + assert_eq!( + serde_json::to_value(&body).unwrap(), + json!({"action": "archive", "ids": ["abc123", "def456"]}) + ); +} + +/// `BulkRequest::new` takes anything string-like, so ids pulled off models +/// (which are `Option`) don't need converting at the call site. +#[test] +fn bulk_request_accepts_owned_and_borrowed_ids() { + let owned: Vec = 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 = + 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 = serde_json::from_value(json!({ + "data": [{"id": "inv1", "number": "INV-0001"}], + "meta": {"pagination": { + "total": 1, "count": 1, "per_page": 50, "current_page": 1, "total_pages": 1 + }}, + })) + .unwrap(); + + assert_eq!(body.data.len(), 1); + assert_eq!(body.data[0].number, "INV-0001"); + let pagination = body.meta.unwrap().pagination.unwrap(); + assert_eq!(pagination.total, 1); + assert_eq!(pagination.per_page, 50); +} + +#[test] +fn document_round_trip_preserves_unknown_fields() { + // `type` is a Rust keyword, so the field is `r#type` but must still map + // to the plain JSON key. + let payload = json!({ + "id": "doc1", + "name": "contract.pdf", + "type": "pdf", + "hash": "abc", + "is_default": false, + "custom_value1": "unmodeled field", + }); + + let doc: Document = serde_json::from_value(payload).unwrap(); + assert_eq!(doc.name, "contract.pdf"); + assert_eq!(doc.r#type, "pdf"); + + let round_tripped = serde_json::to_value(&doc).unwrap(); + assert_eq!(round_tripped["type"], "pdf"); + assert_eq!(round_tripped["custom_value1"], "unmodeled field"); +} + +// --- list params ------------------------------------------------------ + +/// The typed fields serialize; `custom` is skipped here because it is +/// applied as a separate query pass (serde_urlencoded can't flatten a map). +#[test] +fn list_params_skips_custom_in_struct_serialization() { + let params = ListParams::new().per_page(25).param("client_id", "abc123"); + + assert_eq!( + serde_json::to_value(¶ms).unwrap(), + json!({"per_page": 25}) + ); + assert_eq!( + params.custom, + vec![("client_id".to_string(), "abc123".to_string())] + ); +} + +#[test] +fn list_params_custom_pairs_accumulate_and_allow_repeats() { + let params = ListParams::new() + .param("client_id", "abc123") + .param("status", "active") + .param("status", "paid"); + + assert_eq!(params.custom.len(), 3); + assert_eq!( + params.custom, + vec![ + ("client_id".to_string(), "abc123".to_string()), + ("status".to_string(), "active".to_string()), + ("status".to_string(), "paid".to_string()), + ] + ); +} + +/// Both halves of the query must survive urlencoding, since the client +/// serializes the typed struct and the custom pairs in two passes. +#[test] +fn list_params_both_halves_urlencode() { + let params = ListParams::new().per_page(25).sort("created_at|desc"); + + let typed = serde_urlencoded::to_string(¶ms).unwrap(); + assert!(typed.contains("per_page=25")); + assert!(typed.contains("sort=created_at%7Cdesc")); + + let custom = serde_urlencoded::to_string( + &ListParams::new() + .param("client_id", "abc123") + .param("is_deleted", "false") + .custom, + ) + .unwrap(); + assert_eq!(custom, "client_id=abc123&is_deleted=false"); +} + +// --- newly added resource groups --------------------------------------- + +#[test] +fn company_keeps_settings_untyped_and_preserves_toggles() { + // Company has well over a hundred fields; only the structural ones are + // modeled, and `settings` stays untyped. + let payload = json!({ + "id": "co1", + "company_key": "abc", + "settings": {"currency_id": "1", "timezone_id": "15"}, + "enabled_modules": 32767, + "show_product_cost": true, + "markdown_enabled": true, + }); + + let company: Company = serde_json::from_value(payload).unwrap(); + assert_eq!(company.company_key, "abc"); + assert_eq!(company.enabled_modules, 32767); + assert!(company.markdown_enabled); + assert_eq!(company.settings["currency_id"], "1"); + // An unmodeled feature toggle still round-trips. + let round_tripped = serde_json::to_value(&company).unwrap(); + assert_eq!(round_tripped["show_product_cost"], true); + assert_eq!(round_tripped["settings"]["timezone_id"], "15"); +} + +/// System logs are the one resource whose timestamps are date strings +/// rather than integers. +#[test] +fn system_log_timestamps_are_strings() { + let log: SystemLog = serde_json::from_value(json!({ + "id": "sl1", + "event_id": 30, + "category_id": 1, + "type_id": 300, + "log": {"message": "gateway declined"}, + "created_at": "2026-10-01 12:00:00", + "updated_at": "2026-10-01 12:00:00", + })) + .unwrap(); + + assert_eq!(log.event_id, 30); + assert_eq!(log.created_at, "2026-10-01 12:00:00"); + assert_eq!(log.log["message"], "gateway declined"); +} + +#[test] +fn new_resource_models_decode() { + let integration: BankIntegration = serde_json::from_value(json!({ + "id": "bi1", "bank_account_id": 7788, "balance": 1234.56, "currency": "USD" + })) + .unwrap(); + assert_eq!(integration.bank_account_id, 7788); + assert_eq!(integration.balance, 1234.56); + + let rule: BankTransactionRule = serde_json::from_value(json!({ + "id": "btr1", + "name": "Coffee", + "matches_on_all": true, + "rules": [{"search_key": "description", "operator": "contains", "value": "cafe"}], + })) + .unwrap(); + assert_eq!(rule.rules.len(), 1); + assert_eq!(rule.rules[0]["operator"], "contains"); + + let gateway: CompanyGateway = serde_json::from_value(json!({ + "id": "cg1", "gateway_key": "d14dd26a", "accepted_credit_cards": 32, "config": "encrypted" + })) + .unwrap(); + assert_eq!(gateway.accepted_credit_cards, 32); + assert_eq!(gateway.config, "encrypted"); + + let tag: Tag = serde_json::from_value(json!({ + "id": "t1", "name": "urgent", "color": "#ff0000", "entity_type": "invoice" + })) + .unwrap(); + assert_eq!(tag.name, "urgent"); + + let location: Location = serde_json::from_value(json!({ + "id": "loc1", "name": "Warehouse", "city": "Brisbane", "is_shipping_location": true + })) + .unwrap(); + assert!(location.is_shipping_location); + + let scheduler: TaskScheduler = serde_json::from_value(json!({ + "id": "ts1", "job": "email_report", "repeat_every": "WEEK", + "start_from": 1700000000i64, "report_keys": ["date", "amount"], + })) + .unwrap(); + assert_eq!(scheduler.report_keys, vec!["date", "amount"]); + + let token: CompanyToken = serde_json::from_value(json!({ + "id": "tok1", "name": "CI token", "token": "secret", "is_system": false + })) + .unwrap(); + assert_eq!(token.name, "CI token"); + + let gw_token: ClientGatewayToken = serde_json::from_value(json!({ + "id": "cgt1", "client_id": "abc123", "token": "card_x", "is_default": true + })) + .unwrap(); + assert!(gw_token.is_default); + + let group: GroupSetting = serde_json::from_value(json!({ + "id": "gs1", "name": "Wholesale", "settings": {"currency_id": "2"} + })) + .unwrap(); + assert_eq!(group.settings["currency_id"], "2"); + + let design: Design = serde_json::from_value(json!({ + "id": "d1", "name": "Clean", "is_custom": false, "is_active": true + })) + .unwrap(); + assert!(design.is_active); + + let user: User = serde_json::from_value(json!({ + "id": "u1", "first_name": "Ada", "email": "ada@example.com", "passkey_count": 2 + })) + .unwrap(); + assert_eq!(user.first_name, "Ada"); + assert_eq!(user.passkey_count, 2); +} + +/// `Design.design` is typed as a string upstream but returned as a nested +/// object, so it is left unmodeled and must survive in `extra`. +#[test] +fn design_body_round_trips_through_extra() { + let payload = json!({ + "id": "d1", + "name": "Custom", + "design": {"header": "

", "body": "
", "footer": "

"}, + }); + + let design: Design = serde_json::from_value(payload).unwrap(); + let round_tripped = serde_json::to_value(&design).unwrap(); + assert_eq!(round_tripped["design"]["header"], "

"); +}