Wrap the e-invoicing and integration routes
CI / test (push) Canceled after 0s
CI / Build on the declared MSRV (push) Canceled after 0s
CI / Latest stable (advisory) (push) Canceled after 0s

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>
This commit is contained in:
2026-10-24 23:24:10 -04:00
co-authored by Claude Opus 5
parent c4f31743f0
commit 446617a728
7 changed files with 982 additions and 32 deletions
+110 -4
View File
@@ -18,7 +18,7 @@ declares 34 `Route::resource` groups and 274 explicit route declarations
> exists or what it returns.
**All 34 `Route::resource` groups are now wrapped**, plus `users` (which is
not a resource group). 338 methods in total.
not a resource group). 371 methods in total.
## Resources with full CRUD + bulk
@@ -200,6 +200,86 @@ 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
@@ -424,9 +504,24 @@ check `routes/api.php`).
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.
- **Integrations** — Stripe, QuickBooks, Apple in-app purchase, PostMark,
einvoice (17 routes), calendar_connection, self-update, templates, hooks,
Yodlee (`/yodlee/*`), Nordigen (`/nordigen/institutions`)
- **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}`,
@@ -444,6 +539,17 @@ 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