The "auth routes" open question in GOALS.md conflated four unrelated things once the upstream routes and controllers were checked: logout and the passkey settings routes sit behind the same token_auth middleware as everything else this crate wraps, login needs a credential exchange since there's no token yet, refresh/refresh_react turned out to be the bulk sync-dump endpoint already parked as a separate open question, and connected_account is Gmail OAuth linking that belongs with Integrations. Adds logout, list_passkeys, delete_passkey (passkey registration is left out — it needs a WebAuthn ceremony a browser has to drive), and InvoiceNinjaClient::login as an associated function returning a lean LoginResponse that types only is_owner/is_admin/token and leaves the server's much larger nested payload in extra. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
669 lines
34 KiB
Markdown
669 lines
34 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). 375 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.
|
|
|
|
## Logout, passkeys, and login
|
|
|
|
`/login`, `/logout`, and the passkey routes used to sit together under one
|
|
"Auth" gap. They don't share a shape: `/logout` and the passkey *settings*
|
|
routes are plain `token_auth` endpoints like everything else this crate
|
|
wraps, while `/login` sits in a different, unauthenticated middleware group
|
|
because the caller has no token yet to prove who they are.
|
|
|
|
| Endpoint | Method | Needs an existing client? |
|
|
| --- | --- | --- |
|
|
| `POST /logout` | `logout` | yes |
|
|
| `GET /settings/passkeys` | `list_passkeys` | yes |
|
|
| `DELETE /settings/passkeys/{id}` | `delete_passkey` | yes |
|
|
| `POST /login` | `InvoiceNinjaClient::login` (associated fn, not a method) | **no** |
|
|
|
|
**`logout` has a bigger blast radius than its name suggests.** The
|
|
controller doesn't revoke the caller's token — it regenerates *every*
|
|
`is_system` token belonging to the whole company, logging out every user
|
|
and device that shares it, including the one making the call.
|
|
|
|
**Passkey registration is not wrapped.** `POST /settings/passkeys/options`
|
|
and `POST /settings/passkeys` exist to drive a WebAuthn ceremony that only a
|
|
browser talking to a platform authenticator can complete — there is no
|
|
attestation object for this crate to produce standalone. Listing and
|
|
deleting an existing credential need no such ceremony, so those are the two
|
|
operations offered.
|
|
|
|
**`login` returns the server's full "first load" payload** — nested
|
|
company, clients, invoices, designs, and more under each `CompanyUser`
|
|
entry — because it reuses the same transformer as a browser's initial page
|
|
load. This crate does not model that nested tree; [`LoginCompanyUser`]
|
|
types only `is_owner`, `is_admin`, and the `token` it exists to produce,
|
|
and leaves the rest in `extra`, losslessly. [`LoginCompanyUser::token`]
|
|
is what a caller actually needs — feed it to
|
|
[`InvoiceNinjaClient::new`] or [`InvoiceNinjaClient::hosted`].
|
|
|
|
`login` optionally takes a one-time password for accounts with two-factor
|
|
auth enabled. It does not support passkey login, SSO (`oauth_login`,
|
|
`oidc/*`), `/login/precheck`, or a self-hosted instance's optional
|
|
`X-API-SECRET` header — each needs a ceremony, a redirect, or a header this
|
|
single email/password call does not send.
|
|
|
|
`/refresh` and `/refresh_react` were in the old "Auth" bucket too, but
|
|
they're not an authentication concept: `BaseController::refreshResponse`
|
|
is the same bulk, `updated_at`-filtered account dump as `/login`'s first
|
|
load. That's the sync-engine shape [`GOALS.md`](GOALS.md) already parks as
|
|
a separate open question, not something to bolt onto auth — not
|
|
implemented, for the same reason.
|
|
|
|
`/connected_account` (linking a Gmail OAuth account) was also filed under
|
|
"Auth". It's an integration-linking endpoint, not login/logout, so it
|
|
belongs with Stripe/QuickBooks under [Integrations](#integrations) above —
|
|
also not implemented.
|
|
|
|
## 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** — see [Logout, passkeys, and login](#logout-passkeys-and-login)
|
|
above for what's covered (`login`, `logout`, passkey list/delete) and
|
|
what isn't: `/login/precheck`, `oauth_login`, `oidc/*`, passkey
|
|
registration and passkey login, `/refresh`, `/refresh_react`, and
|
|
`/connected_account`
|
|
- **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`, `/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.
|