Files
invoiceninja-sdk/COVERAGE.md
T
awkawbandClaude Opus 5 446617a728
CI / test (push) Canceled after 0s
CI / Build on the declared MSRV (push) Canceled after 0s
CI / Latest stable (advisory) (push) Canceled after 0s
Wrap the e-invoicing and integration routes
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>
2026-10-24 23:24:10 -04:00

31 KiB

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 puts them in rough priority order.

Checked against routes/api.php on the v5-stable branch of the invoiceninja repo, 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). 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 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.

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 POSTs 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 and 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 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 and integration 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 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.