Closes the last roadmap item, and with it the roadmap: every route in
`routes/api.php` that an API-token client can reach is now wrapped. 33
methods — 17 e-invoicing, 4 QuickBooks, 4 Stripe, 6 calendar, and one each
for Yodlee and GoCardless.
Most return `serde_json::Value`, which is deliberate and is the crate's
largest untyped surface. These replies are pass-throughs from Storecove,
Stripe's gateway driver, Yodlee or GoCardless, or arrays assembled for
Invoice Ninja's own React client. None has a documented schema, several
differ by provider, and they move on the provider's release schedule rather
than Invoice Ninja's — so the rule that keeps `chart` generic applies here
with more force, not less. `client_peppol_discovery` set the precedent.
Three upstream behaviours needed accommodating, each now pinned by a test:
- `validate_einvoice` answers 422 when validation finds problems, so an
ordinary "this invoice is not Peppol-ready" result arrives as `Error::Api`
rather than an `Ok` carrying the failures. The per-entity detail is not
reachable through the typed error. Documented rather than worked around.
- `einvoice/peppol/setup` has two success shapes: a `corppass_url` when the
connection needs a further browser step, or a bare 204 when it completed.
Hence `post_unwrapped_opt` and `Option<Value>`, since decoding the 204
would otherwise be a spurious error.
- `einvoice/peppol/remove_additional_legal_identifier` is a DELETE carrying
a body — the only one in the crate — and `einvoice/peppol/update` a PUT
with one, hence `send_unwrapped`.
The Peppol handlers also return `data_get($response, 'message')` as the
entire error body, which may be a bare string rather than
`{"message": …, "errors": {…}}`. `Error::Api` then falls back to an excerpt
with an empty `errors` map; the docs say so on the impl block.
Two things in this block stay unwrapped because they cannot be wrapped. The
OAuth legs of the QuickBooks and calendar flows sit outside the `api/v1`
group, take no token, and answer with redirects, so connecting an account
remains a browser flow — these methods operate a connection that already
exists. And `POST /quickbooks/settings` is a registered route whose
controller has no `settings` method, so it raises `BadMethodCallException`
and 500s; there is nothing to wrap until upstream adds the handler. Both are
now entries in `COVERAGE.md`'s gap list rather than silent omissions.
`GOALS.md` keeps the roadmap rather than deleting it: several entries record
what an item's original reasoning got wrong, which outlasts the fact that it
shipped.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
612 lines
31 KiB
Markdown
612 lines
31 KiB
Markdown
# API coverage
|
|
|
|
What this crate wraps of the Invoice Ninja v5 API, and what it doesn't.
|
|
|
|
Full coverage is a stated goal, so the gaps below are a to-do list rather
|
|
than a permanent boundary — [`GOALS.md`](GOALS.md) puts them in rough
|
|
priority order.
|
|
|
|
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). 371 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` |
|
|
|
|
`create_recurring_quote` currently 500s against Invoice Ninja 5.13.50: the
|
|
server's own insert names a `design_id` column its `recurring_quotes` table
|
|
does not have. The request is well-formed; nothing here can work around it.
|
|
|
|
`bulk_*` accepts any action the endpoint supports; `archive`, `restore`, and
|
|
`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`.
|
|
|
|
## Invitation-key downloads
|
|
|
|
The same four resources also answer two downloads keyed by **invitation key**
|
|
rather than record id — the per-contact token that appears in a client-portal
|
|
link. Eight routes, all wrapped:
|
|
|
|
| Route | Method | Returns |
|
|
| --- | --- | --- |
|
|
| `GET /invoice/{key}/download` | `download_invoice_by_invitation_key` | `application/pdf` |
|
|
| `GET /invoice/{key}/download_e_invoice` | `download_e_invoice_by_invitation_key` | `application/xml` |
|
|
| `GET /quote/{key}/download` | `download_quote_by_invitation_key` | `application/pdf` |
|
|
| `GET /quote/{key}/download_e_quote` | `download_e_quote_by_invitation_key` | `application/xml` |
|
|
| `GET /credit/{key}/download` | `download_credit_by_invitation_key` | `application/pdf` |
|
|
| `GET /credit/{key}/download_e_credit` | `download_e_credit_by_invitation_key` | `application/xml` |
|
|
| `GET /purchase_order/{key}/download` | `download_purchase_order_by_invitation_key` | `application/pdf` |
|
|
| `GET /purchase_order/{key}/download_e_purchase_order` | `download_e_purchase_order_by_invitation_key` | `application/xml` |
|
|
|
|
Two things about these are easy to get wrong, and both are pinned by tests.
|
|
The entity segment is **singular** — `invoice/…`, not `invoices/…` — which
|
|
is the only place in the API that departs from the plural CRUD path. And
|
|
despite being the download a *client* follows, they are **authenticated**:
|
|
the routes sit inside `routes/api.php`'s `api/v1` group under its
|
|
`token_auth` middleware, so they need the API token like any other call.
|
|
|
|
This file previously listed them as unwrapped on the grounds that they take
|
|
no auth token. That was wrong — it conflated them with the client-portal
|
|
routes in `routes/client.php` (`/client/invoice/{key}/download_pdf` and
|
|
friends), which are a separate route family, are not under `/api/v1`, and
|
|
remain unwrapped for that reason.
|
|
|
|
The smoke test's read tier exercises the invoice PDF, taking a key from the
|
|
`invitations` array the list route already returns. The `download_e_*` four
|
|
are not exercised: they render from the company's e-invoice settings, which a
|
|
test instance need not have configured.
|
|
|
|
An unknown or deleted key is a `400` carrying `no record found`. The
|
|
controllers return that directly rather than raising
|
|
`ModelNotFoundException`, so the message differs from the `No query results
|
|
for model` text a missing record produces elsewhere, but the status is the
|
|
same 400.
|
|
|
|
## Import and export
|
|
|
|
| Route | Method | Returns |
|
|
| --- | --- | --- |
|
|
| `POST /export` | `export` | `ExportJob` |
|
|
| `POST /preimport` | `preimport` | `ImportPreview` |
|
|
| `POST /import` | `import` | `MessageResponse` |
|
|
| `POST /import_json` | `import_json` | `MessageResponse` |
|
|
| `PUT /edocument/upload` | `upload_edocument` | `MessageResponse` |
|
|
|
|
A CSV import is two calls. `preimport` uploads the files — one per entity
|
|
type, as `files[<entity_type>]` — and answers with a `hash` plus the column
|
|
mapping the server suggests; `import` then starts the ingest from that hash.
|
|
The server holds the files against the hash for 20 minutes.
|
|
|
|
The route also accepts the files and the mapping together in one multipart
|
|
request. That form is deliberately not wrapped: a mapping is a nested
|
|
structure, and bracket-encoding nested structures into form fields is exactly
|
|
what this crate avoids by sending JSON (see [the PHP SDK
|
|
comparison](#compared-with-the-official-php-sdk)). With a `hash` present the
|
|
controller never looks at uploaded files, so `import` is plain JSON.
|
|
|
|
`upload_edocument` is *not* a document attachment, which is why it is not one
|
|
of the `upload_*` methods: `ExpenseController::edocument` parses each file and
|
|
creates **expenses**. Like the `/{id}/upload` routes it is registered as `PUT`
|
|
and so goes out as a `POST` with `_method=PUT`; unlike them the reply has no
|
|
`data` envelope.
|
|
|
|
All five queue background work, so a 200 means accepted, not finished. Rows
|
|
that fail to import are reported by email. `import_json` restores a company
|
|
from an `export` backup and **overwrites the target company**.
|
|
|
|
## E-invoicing
|
|
|
|
Seventeen routes, all wrapped. Mostly pass-throughs to Storecove, Invoice
|
|
Ninja's Peppol access point, so they return `serde_json::Value` — see
|
|
[Typed response gaps](#typed-response-gaps) for why.
|
|
|
|
| Route | Method | Returns |
|
|
| --- | --- | --- |
|
|
| `POST /einvoice/validateEntity` | `validate_einvoice` | `Value` |
|
|
| `POST /einvoice/configurations` | `einvoice_configurations` | `Value` |
|
|
| `GET /einvoice/quota` | `einvoice_quota` | `Value` |
|
|
| `GET /einvoice/health_check` | `einvoice_health_check` | `()` |
|
|
| `GET /einvoice/delivery_map` | `einvoice_delivery_map` | `Value` |
|
|
| `POST /einvoice/token/update` | `update_einvoice_token` | `()` (204) |
|
|
| `POST /einvoice/peppol/discovery` | `peppol_discovery` | `Value` |
|
|
| `POST /einvoice/peppol/legal_entity` | `peppol_legal_entity` | `Value` |
|
|
| `POST /einvoice/peppol/setup` | `peppol_setup` | `Option<Value>` |
|
|
| `POST /einvoice/peppol/disconnect` | `peppol_disconnect` | `Value` |
|
|
| `PUT /einvoice/peppol/update` | `peppol_update_legal_entity` | `Value` |
|
|
| `POST /einvoice/peppol/add_additional_legal_identifier` | `peppol_add_legal_identifier` | `MessageResponse` |
|
|
| `DELETE /einvoice/peppol/remove_additional_legal_identifier` | `peppol_remove_legal_identifier` | `Value` |
|
|
| `POST /einvoice/peppol/send` | `peppol_retry_send` | `MessageResponse` |
|
|
| `POST /einvoice/peppol/sg/c5/activate` | `peppol_sg_c5_activate` | `MessageResponse` |
|
|
| `POST /einvoice/peppol/sg/c5/deactivate` | `peppol_sg_c5_deactivate` | `MessageResponse` |
|
|
| `POST /einvoice/peppol/sg/c5/cancel` | `peppol_sg_c5_cancel` | `MessageResponse` |
|
|
|
|
Three things here behave unlike the rest of the API:
|
|
|
|
- **A failing check is a non-2xx.** `validate_einvoice` answers `422` when
|
|
validation finds problems, so "this invoice is not Peppol-ready" — an
|
|
ordinary result — arrives as `Error::Api`, and the per-entity detail in the
|
|
body is not reachable through the typed error. An `Ok` means it passed.
|
|
- **Error bodies are not the usual shape.** The Peppol handlers return
|
|
`data_get($response, 'message')` as the entire body, which can be a bare
|
|
string. `Error::Api`'s `message` then falls back to an excerpt and `errors`
|
|
is empty.
|
|
- **`peppol_setup` has two success shapes.** A `corppass_url` when the
|
|
connection needs a further browser step (Singapore's CorpPass), or a bare
|
|
`204` when it completed. Hence `Option<Value>` rather than treating the
|
|
empty body as a decode failure.
|
|
|
|
`peppol_remove_legal_identifier` is the only `DELETE` in the crate that
|
|
carries a request body.
|
|
|
|
## Integrations
|
|
|
|
| Route | Method | Returns |
|
|
| --- | --- | --- |
|
|
| `POST /quickbooks/sync` | `quickbooks_sync` | `()` (204) |
|
|
| `POST /quickbooks/action` | `quickbooks_action` | `()` (204) |
|
|
| `POST /quickbooks/disconnect` | `quickbooks_disconnect` | `()` (204) |
|
|
| `POST /quickbooks/reconnect_url` | `quickbooks_reconnect_url` | `Value` |
|
|
| `POST /stripe/update_payment_methods` | `stripe_update_payment_methods` | `MessageResponse` |
|
|
| `POST /stripe/import_customers` | `stripe_import_customers` | `MessageResponse` |
|
|
| `POST /stripe/verify` | `stripe_verify` | `Value` |
|
|
| `POST /stripe/disconnect/{id}` | `stripe_disconnect` | `Value` |
|
|
| `GET /calendar_connection` | `calendar_connection` | `Value` |
|
|
| `POST /calendar_connection/{provider}/complete` | `complete_calendar_connection` | `Value` |
|
|
| `GET /calendar_connection/calendars` | `calendar_connection_calendars` | `Value` |
|
|
| `GET /calendar_connection/events` | `calendar_connection_events` | `Value` |
|
|
| `PUT /calendar_connection/calendars` | `update_calendar_connection_calendars` | `Value` |
|
|
| `DELETE /calendar_connection` | `delete_calendar_connection` | `()` |
|
|
| `POST /yodlee/status/{account_number}` | `yodlee_account_status` | `Value` |
|
|
| `GET /nordigen/institutions` | `nordigen_institutions` | `Value` |
|
|
|
|
All four Stripe routes are **password-protected** — supply
|
|
`with_api_password` — and answer `403` for a non-admin.
|
|
|
|
These operate a connection that already exists. **Establishing one is a
|
|
browser flow and is not wrappable**: the OAuth authorize and callback legs
|
|
live outside the `api/v1` group, take no API token, and answer with
|
|
redirects. See [the gap list](#individual-endpoints-not-wrapped).
|
|
|
|
`yodlee_account_status` takes Yodlee's own `account_id`, as stored on the
|
|
matching `BankIntegration` — not an Invoice Ninja id. Its upstream route
|
|
carries a `@todo` questioning its own path, so treat it as liable to move.
|
|
`nordigen_institutions` covers GoCardless, which was Nordigen when the route
|
|
was named; an instance without GoCardless credentials answers `400` rather
|
|
than an empty list.
|
|
|
|
## Document uploads
|
|
|
|
`PUT /{resource}/{id}/upload` exists for 16 resources, each getting
|
|
`upload_{singular}(id, files, is_public)` that returns the refreshed record:
|
|
|
|
| Resource | Method |
|
|
| --- | --- |
|
|
| Clients | `upload_client` |
|
|
| Companies | `upload_company` |
|
|
| Credits | `upload_credit` |
|
|
| Expenses | `upload_expense` |
|
|
| Group Settings | `upload_group_setting` |
|
|
| Invoices | `upload_invoice` |
|
|
| Payments | `upload_payment` |
|
|
| Products | `upload_product` |
|
|
| Projects | `upload_project` |
|
|
| Purchase Orders | `upload_purchase_order` |
|
|
| Quotes | `upload_quote` |
|
|
| Recurring Expenses | `upload_recurring_expense` |
|
|
| Recurring Invoices | `upload_recurring_invoice` |
|
|
| Recurring Quotes | `upload_recurring_quote` |
|
|
| Tasks | `upload_task` |
|
|
| Vendors | `upload_vendor` |
|
|
|
|
This is the *only* way the API creates a document:
|
|
`DocumentController::store` is an empty stub upstream, so `Document` has
|
|
`list`, `get`, `update`, `delete`, `download`, and `bulk`, but no
|
|
`create_document`, and there will not be one.
|
|
|
|
Three details that are easy to get wrong, all verified against the upstream
|
|
source rather than the published spec:
|
|
|
|
- **The request is a `POST` carrying `_method=PUT`.** The route is
|
|
registered as `PUT`, but PHP does not parse a multipart body on a `PUT`, so
|
|
the files would never arrive. Laravel's kernel turns the spoofed method back
|
|
into a `PUT` before routing. The OpenAPI spec documents `POST` with
|
|
`_method=POST`, which would 405 against these routes.
|
|
- **`is_public` is sent as `1`/`0`.** Laravel's `boolean` rule accepts only
|
|
`true`, `false`, `0`, `1`, `'0'`, and `'1'` under strict comparison, so the
|
|
string `"true"` fails validation.
|
|
- **Uploads are never retried**, even on a 429. An upload adds a document, so
|
|
it is not idempotent whatever its verb, and a replay after a lost response
|
|
would attach the file twice.
|
|
|
|
The server enforces file type and size limits; a refusal comes back as a 422
|
|
`Error::Api` with the per-field `errors` map. Files are held in memory while
|
|
uploading.
|
|
|
|
## Other wrapped endpoints
|
|
|
|
| Endpoint | Method |
|
|
| --- | --- |
|
|
| `GET /documents`, `/documents/{id}` | `list_documents`, `list_all_documents`, `get_document` |
|
|
| `PUT/DELETE /documents/{id}` | `update_document`, `delete_document` |
|
|
| `GET /documents/{id}/download` | `download_document` → bytes |
|
|
| `POST /documents/bulk` | `bulk_documents` |
|
|
| `POST /reports/{name}` | `report(name, body)` → raw bytes (CSV for most) |
|
|
| `POST /charts/{name}` | `chart(name, body)` → caller-chosen `T` |
|
|
| `POST /search` | `search(body)` |
|
|
| `GET /statics` | `statics()` |
|
|
| `POST /emails` | `send_email(body)` |
|
|
| `POST /payments/refund` | `refund_payment(body)` |
|
|
| `GET /ping` | `ping()` |
|
|
| `GET /health_check` | `health_check()` |
|
|
|
|
`report` and `chart` take the name as a string, so all 32 report and 11 chart
|
|
endpoints are reachable without a per-endpoint method:
|
|
|
|
- **Reports** — `activities`, `ar_detail_report`, `ar_summary_report`,
|
|
`client_balance_report`, `client_contacts`, `client_locations`, `clients`,
|
|
`client_sales_report`, `contacts`, `credits`, `documents`, `expenses`,
|
|
`invoice_items`, `invoices`, `locations`, `payments`, `preview`, `products`,
|
|
`product_sales`, `profitloss`, `projects`, `purchase_order_items`,
|
|
`purchase_orders`, `quote_items`, `quotes`, `recurring_invoice_items`,
|
|
`recurring_invoices`, `tasks`, `tax_period_report`, `tax_summary_report`,
|
|
`user_sales_report`, `vendors`
|
|
- **Charts** — `totals`, `totals_v2`, `chart_summary`, `chart_summary_v2`,
|
|
`calculated_fields`, `analytics_summary`, `analytics_totals`,
|
|
`cashflow_forecast`, `client_payment_analytics`, `project_analytics`,
|
|
`project_burnup`
|
|
|
|
Three of these take a further path segment rather than carrying the id in the
|
|
body: `charts/project_analytics/{project}`, `charts/project_burnup/{project}`,
|
|
and `reports/preview/{hash}`. The name argument is interpolated as-is, so pass
|
|
the whole tail — `chart("project_burnup/<id>", &body)`.
|
|
|
|
Large report exports are queued and emailed rather than returned inline; in
|
|
that case the body is a short JSON message instead of CSV.
|
|
|
|
## Activities, email history and previews
|
|
|
|
Each has a response shape of its own. Marked *observed* where confirmed
|
|
against a 5.13.50 server.
|
|
|
|
| Endpoint | Method | Returns |
|
|
| --- | --- | --- |
|
|
| `GET /activities` | `list_activities`, `list_all_activities` | `Vec<Activity>` (*observed*) |
|
|
| `GET /activities?reactv2=1` | `activity_feed(rows)` | `Vec<ActivityFeedEntry>` (*observed*) |
|
|
| `POST /activities/entity` | `entity_activity(entity, id, rows)` | `Vec<ActivityFeedEntry>` (*observed*) |
|
|
| `POST /activities/notes` | `add_activity_note(entity, id, notes)` | `Activity` (*observed*) |
|
|
| `GET /activities/download_entity/{id}` | `download_activity_entity` | PDF bytes |
|
|
| `POST /emails/clientHistory/{client}` | `client_email_history` | `Vec<serde_json::Value>` |
|
|
| `POST /emails/entityHistory` | `entity_email_history` | `Vec<serde_json::Value>` |
|
|
| `POST /preview` | `preview`, `preview_html` | PDF bytes / HTML |
|
|
| `POST /live_preview` | `live_preview` | PDF bytes (*observed*) |
|
|
| `POST /live_design` | `live_design` | PDF bytes |
|
|
| `POST /preview/purchase_order` | `preview_purchase_order` | PDF bytes |
|
|
| `POST /live_preview/purchase_order` | `live_preview_purchase_order` | PDF bytes |
|
|
|
|
Things that are easy to get wrong:
|
|
|
|
- **The entity name is singular in one route and plural in the next.**
|
|
`entity_activity` takes `invoice`; `add_activity_note` takes `invoices`.
|
|
- **`rows`, not `per_page`, caps the activity log** (default 75). Pass it
|
|
through `ListParams::param`. `entity_activity` leaves `rows` out when not
|
|
given rather than sending `null`, because the server tests for the key and
|
|
a null would count as given and remove the cap.
|
|
- **The two activity shapes differ.** `Activity` carries hashed ids and a
|
|
string `activity_type_id`; the feed's `ActivityFeedEntry` carries a numeric
|
|
one and labelled references (`invoice`, `client`, … each `{label,
|
|
hashed_id}`).
|
|
- **Email history is raw JSON.** The objects mirror what the mail provider
|
|
reported, and the instance available had never sent mail, so there was no
|
|
shape to take.
|
|
- **Not yet run against a real server:** `preview`, `preview_html`, and both
|
|
purchase-order previews (they need a design object or a purchase order and a
|
|
PDF backend), and `live_design`, which answered a bare minimal request with
|
|
a 500 from the server on 5.13.50.
|
|
|
|
## Password-protected actions
|
|
|
|
Behind the `password_protected` middleware, so each needs
|
|
`with_api_password` (the server also lets a recent password-authenticated
|
|
session through without the header). Most are **irreversible**, and all are
|
|
`POST`s except one, so none is retried on a 5xx.
|
|
|
|
| Endpoint | Method | Returns |
|
|
| --- | --- | --- |
|
|
| `POST /clients/{id}/purge` | `purge_client` | `MessageResponse` |
|
|
| `POST /clients/{id}/{other}/merge` | `merge_clients(id, other)` | the surviving `Client` |
|
|
| `POST /vendors/{id}/{other}/merge` | `merge_vendors(id, other)` | the surviving `Vendor` |
|
|
| `POST /companies/purge/{id}` | `purge_company` | `MessageResponse` |
|
|
| `POST /companies/purge_save_settings/{id}` | `purge_company_save_settings` | `MessageResponse` |
|
|
| `POST /users/{id}/invite` | `invite_user` | `MessageResponse` |
|
|
| `POST /users/{id}/purge` | `purge_user` | `()` (the server answers 204) |
|
|
| `GET /users/create` | `new_user_template` | a blank `User`, nothing stored |
|
|
|
|
A merge keeps the first id and folds the second into it. The smoke test's
|
|
optional password tier (merge two throwaway clients, purge the survivor) was
|
|
run against a disposable 5.13.50 instance on 2026-10-24 and passes; it also
|
|
established that a purged record reads back as a **400**, not a 404. A
|
|
password containing a control character cannot be a header value and goes out
|
|
as `X-API-PASSWORD-BASE64` instead.
|
|
|
|
## Per-resource extras
|
|
|
|
Routes that sit beside a resource's CRUD set, each with its own response
|
|
shape (read from the upstream controllers; the ones marked *observed* were
|
|
also confirmed against a 5.13.50 server). All are `POST` unless noted, so none
|
|
is retried on a 5xx or a timeout.
|
|
|
|
| Endpoint | Method | Returns |
|
|
| --- | --- | --- |
|
|
| `POST /webhooks/{id}/retry` | `retry_webhook` | `Webhook` |
|
|
| `POST /tasks/sort` | `sort_tasks` | `MessageResponse` |
|
|
| `POST /designs/set/default` | `set_default_design` | `MessageResponse` |
|
|
| `POST /client_gateway_tokens/{id}/setAsDefault` | `set_default_client_gateway_token` | `ClientGatewayToken` |
|
|
| `GET /subscriptions/steps` | `subscription_steps` | `Vec<SubscriptionStep>` (*observed*: an object keyed by class name, not the array the source suggests) |
|
|
| `POST /subscriptions/steps/check` | `check_subscription_steps` | `()` |
|
|
| `POST /company_gateways/{id}/test` | `test_company_gateway` | `MessageResponse` |
|
|
| `POST /company_gateways/{id}/clone` | `clone_company_gateway` | `CompanyGateway` |
|
|
| `POST /company_gateways/{id}/import_customers` | `import_company_gateway_customers` | `MessageResponse` |
|
|
| `POST /bank_integrations/refresh_accounts` | `refresh_bank_accounts` | `()` (the body is a serialised query builder) |
|
|
| `POST /bank_integrations/remove_account/{id}` | `remove_bank_account` | `BankIntegration` |
|
|
| `POST /bank_integrations/get_transactions/{id}` | `fetch_bank_transactions` | `MessageResponse` (the server ignores the id) |
|
|
| `POST /bank_transactions/match` | `match_bank_transactions` | `Vec<BankTransaction>` |
|
|
| `POST /clients/{id}/updateTaxData` | `update_client_tax_data` | `Client` |
|
|
| `POST /clients/{id}/show_settings` | `client_show_settings` | `serde_json::Value` (*observed*) |
|
|
| `POST /clients/{id}/peppol_discovery` | `client_peppol_discovery` | `serde_json::Value` |
|
|
| `POST /clients/{id}/check_vat` | `check_client_vat` | `MessageResponse` |
|
|
| `GET /clients/{id}/vat_status` | `client_vat_status` | `MessageResponse` (*observed*) |
|
|
| `POST /clients/{id}/documents` | `client_documents` | `Vec<Document>` (*observed*; one page) |
|
|
|
|
`updateTaxData` and `check_vat` call out to external tax services, so the
|
|
smoke test does not exercise them.
|
|
|
|
## Not covered
|
|
|
|
### Nothing is blocked on a dependency
|
|
|
|
Multipart uploads used to be, and are now wrapped — see [Document
|
|
uploads](#document-uploads) and [Import and
|
|
export](#import-and-export).
|
|
|
|
### 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`).
|
|
|
|
### Individual endpoints not wrapped
|
|
|
|
- **Company extras** — `GET /companies/{id}/logo`,
|
|
`POST /companies/{id}/default`, `updateOriginTaxData`,
|
|
`POST /companies/current`
|
|
- **User extras** (not password-protected, unlike the rest of `/users`) —
|
|
`POST /users/{id}/disconnect_mailer`, `POST /users/{id}/disconnect_oauth`,
|
|
`POST /user/{id}/reconfirm`
|
|
- **Auth** — `/login`, `/logout`, `/refresh`, `/connected_account`,
|
|
passkey routes
|
|
- **Migration** — `/migration/start`. Moves a company between instances and
|
|
is driven by the v4 migration tool, not by an SDK caller.
|
|
- **Invoice extras** — `delivery_note`, `payment_schedule`,
|
|
`update_reminders`
|
|
- **Client-portal invitation downloads** — the `routes/client.php` family:
|
|
`/client/invoice/{key}/download_pdf`, the `download_e_*` variants for
|
|
quotes and credits, `/client/{entity}/{key}/download`, and
|
|
`/client/documents/{hash}/hashed`. These are not under `/api/v1`, so they
|
|
don't fit a `base_url` that ends there. Note these are *not* the
|
|
invitation-key downloads documented above, which are authenticated
|
|
`/api/v1` routes and are wrapped.
|
|
- **Integration routes that cannot take an API token** — the OAuth legs of
|
|
the QuickBooks and calendar flows (`/quickbooks/authorize/{token}`,
|
|
`/quickbooks/reconnect/{token}`, `/quickbooks/authorized`,
|
|
`/calendar_connection/{provider}/authorize/{hash}`) and the provider
|
|
webhooks (`/yodlee/refresh`, `/yodlee/data_updates`,
|
|
`/yodlee/refresh_updates`, `/yodlee/balance`, `/postmark_webhook`,
|
|
`/sns_webhook`, `/postmark_inbound_webhook`, `/payment_webhook/…`). The
|
|
OAuth routes sit outside the `api/v1` group and answer with browser
|
|
redirects; the webhooks are things a provider calls, not things a client
|
|
calls. Connecting an account is a browser flow — see
|
|
[Integrations](#integrations) for the methods that operate a connection
|
|
once it exists.
|
|
- **`POST /quickbooks/settings`** — the route is registered, but
|
|
`QuickbooksController` has no `settings` method, so it raises
|
|
`BadMethodCallException` and 500s. Nothing to wrap until upstream adds the
|
|
handler.
|
|
- **Other integrations** — Apple in-app purchase, PostMark, self-update,
|
|
templates, hooks (commented out upstream)
|
|
- **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`, `search`, and `health_check` return concrete types (`Statics`,
|
|
`SearchResults`, `HealthCheck`). Each keeps the crate's `extra` convention,
|
|
so fields they don't model stay reachable rather than being dropped, and
|
|
each has a generic counterpart — `statics_as`, `search_as`,
|
|
`health_check_as` — for pinning a shape they leave untyped.
|
|
|
|
The [e-invoicing](#e-invoicing) and [integration](#integrations) methods
|
|
return `serde_json::Value` as a rule, which is the largest untyped surface in
|
|
the crate and is deliberate. Their replies are pass-throughs from an external
|
|
service — Storecove, Stripe's gateway driver, Yodlee, GoCardless — or arrays
|
|
assembled for Invoice Ninja's own React client. None has a documented schema,
|
|
several differ by provider, and they change on the provider's release
|
|
schedule rather than Invoice Ninja's. The precedent is
|
|
`client_peppol_discovery`, which has returned `Value` since it landed. Where
|
|
a shape *is* stable and is the point of the call it is still typed, as with
|
|
`ExportJob` and `ImportPreview`.
|
|
|
|
Two remain generic on purpose:
|
|
|
|
- **`chart`** — the eleven chart endpoints share no common response shape
|
|
(`totals_v2` returns per-currency maps, `cashflow_forecast` a date series,
|
|
`calculated_fields` scalars), so there is nothing to narrow to.
|
|
- **`send_email`** — the endpoint serves invoices, quotes, credits, and
|
|
purchase orders, and answers with whichever entity was emailed. The `data`
|
|
envelope *is* unwrapped, so `T` is the entity itself.
|
|
|
|
Within the typed models, a few fields are deliberately left as
|
|
`serde_json::Value`: `Statics::gateways`, `Statics::templates`,
|
|
`Statics::bulk_updates`, `HealthCheck::extensions`, and
|
|
`HealthCheck::queue_data`. These are nested configuration blobs rather than
|
|
uniform lookup rows, and the same reasoning applies as for
|
|
`Company::settings` — a wrong concrete type is a hard decode error, whereas
|
|
`Value` is lossless.
|
|
|
|
## 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 by id and by
|
|
invitation key, the XML e-documents, 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.
|