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
+30 -7
View File
@@ -272,6 +272,22 @@ changes, doc tweaks) does not need an entry.
refunds, ping/health) are hand-written in the last `impl` block rather than
macro-generated, because their request and response shapes are all
different.
- **The import, e-invoicing and integration blocks return
`serde_json::Value` on purpose.** They are pass-throughs from Storecove,
Stripe, Yodlee or GoCardless, or arrays built for Invoice Ninja's React
client; none has a documented schema and they move on the provider's
release schedule. Don't "finish the job" by typing them — the same
reasoning that keeps `chart` generic applies, and `COVERAGE.md` records it
as the crate's largest deliberate untyped surface. `ExportJob` and
`ImportPreview` are typed because their one useful field (the URL, the
hash) is the whole point of the call.
- Three dispatch helpers exist only for that surface: `send_unwrapped` for a
`PUT` or `DELETE` that carries a body (`peppol_remove_legal_identifier` is
the crate's only `DELETE` with one), `post_unwrapped_opt` for a route that
answers either JSON or a bare `204` (`peppol_setup`), and
`multipart_unwrapped` for the import uploads, whose form field is
`files[<entity_type>]` rather than `documents[]` and whose reply has no
`data` envelope. All three go through `req`/`execute` like everything else.
- `list_*` returns one page; `list_all_*` goes through `get_all_pages`, which
terminates on `meta.pagination.total_pages` and only falls back to the
short-page heuristic when the server sends no metadata. **The heuristic is
@@ -337,20 +353,27 @@ changes, doc tweaks) does not need an entry.
## Coverage
**All 34 of the API's `Route::resource` groups are wrapped**, plus `users`
(which isn't one). 338 methods: 30 resources with CRUD + bulk, 3 with CRUD
(which isn't one). 371 methods: 30 resources with CRUD + bulk, 3 with CRUD
only, 4 with entity actions, 4 with invitation-key downloads, 16 with
document uploads, and 74 hand-written.
document uploads, and 107 hand-written.
Plus the non-CRUD surface: documents, entity actions and PDF downloads for
the four action-capable resources, their invitation-key PDF and e-document
downloads, `report`, `chart`, `search`, `statics`, `send_email`,
`refund_payment`, `ping`, and `health_check`.
`refund_payment`, `ping`, `health_check`, import/export, the 17 e-invoicing
routes, and the QuickBooks, Stripe, calendar, Yodlee and GoCardless
integrations.
**`GOALS.md`'s roadmap is closed** — every route an API-token client can
reach is wrapped. What is left out is left out for a reason, not pending:
the auth routes (a different auth model, and an open question in `GOALS.md`),
the client-portal family and the OAuth and provider-webhook routes (all
outside `/api/v1`, so they don't fit the client's `base_url` or its token),
`POST /quickbooks/settings` (a registered route with no controller method,
so it 500s), and a few company and user extras.
**`COVERAGE.md` is the detailed inventory** — every wrapped endpoint, every
gap, and the reason for each gap. The main remaining gaps are the
integration and auth routes, import/export, the client-portal routes (which
are not under `/api/v1`, so they don't fit the client's `base_url`), and a
few company and user extras.
gap, and the reason for each gap.
Keep `COVERAGE.md` current when adding or removing an endpoint: it names
exact counts and route groups, so it goes stale quietly. The three coverage