From 446617a728d566fc0e541452568b34342f0a9875 Mon Sep 17 00:00:00 2001 From: Andrew Bryant Date: Sat, 24 Oct 2026 23:24:10 -0400 Subject: [PATCH] Wrap the e-invoicing and integration routes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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`, 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 --- CHANGELOG.md | 15 ++ CLAUDE.md | 37 +++- COVERAGE.md | 114 ++++++++++- GOALS.md | 42 +++- README.md | 2 +- src/client.rs | 409 ++++++++++++++++++++++++++++++++++++++- tests/client_requests.rs | 395 +++++++++++++++++++++++++++++++++++++ 7 files changed, 982 insertions(+), 32 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 05b45f5..a32bdfd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- E-invoicing, seventeen methods: `validate_einvoice`, + `einvoice_configurations`, `einvoice_quota`, `einvoice_health_check`, + `einvoice_delivery_map`, `update_einvoice_token`, and eleven `peppol_*`. + Three behave unlike the rest of the API, and the docs on each say so: a + failing `validate_einvoice` is a `422`, so an ordinary "not Peppol-ready" + result arrives as `Error::Api`; the Peppol handlers return the error + message as the whole body rather than `{"message": …}`, so `Error::Api`'s + `errors` map is empty for them; and `peppol_setup` has two success shapes, + hence `Option`. `peppol_remove_legal_identifier` is the crate's + only `DELETE` with a request body. +- Integrations, sixteen methods: four `quickbooks_*`, four `stripe_*` (all + password-protected), six `calendar_connection` methods, plus + `yodlee_account_status` and `nordigen_institutions`. These operate a + connection that already exists — establishing one is an OAuth browser flow + whose routes take no API token and cannot be wrapped. - Import and export, five methods: `export`, `preimport`, `import`, `import_json`, and `upload_edocument`. A CSV import is two calls — `preimport` uploads the files as `files[]` and returns the diff --git a/CLAUDE.md b/CLAUDE.md index 926da61..833f749 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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[]` 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 diff --git a/COVERAGE.md b/COVERAGE.md index 78afae3..f644ed4 100644 --- a/COVERAGE.md +++ b/COVERAGE.md @@ -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` | +| `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` 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 diff --git a/GOALS.md b/GOALS.md index e1e41dc..77f1bc4 100644 --- a/GOALS.md +++ b/GOALS.md @@ -137,9 +137,14 @@ without checking — see the note in `CLAUDE.md`. ## Roadmap: closing the coverage gaps -All 34 `Route::resource` groups are wrapped. Full coverage is the target, so -`COVERAGE.md`'s gap list is a to-do list, not a permanent boundary. Roughly -in order of value over effort: +**This roadmap is now closed** — every item below is done, and every route an +API-token client can reach is wrapped. It is kept rather than deleted because +several entries record what an item's original reasoning got wrong, which is +worth more than the fact that it shipped. What is still unwrapped is in +`COVERAGE.md`'s gap list, with a reason per entry, and none of it is a +sequenced to-do any more. + +The items ran roughly in order of value over effort: **Done:** multipart document uploads, for the 16 resources that have the route (`upload_client`, `upload_invoice`, …). This earlier claimed it would @@ -184,15 +189,32 @@ lines of macro. What the item had actually described is the client-portal family in `routes/client.php`, which is genuinely token-less, is not under `/api/v1`, and stays unwrapped for that reason. -Still to do: +**Done:** import/export and the integrations — `/import`, `/preimport`, +`/import_json`, `/export`, `edocument/upload`, einvoice (17 routes), +QuickBooks, Stripe, calendar, Yodlee and Nordigen (38 methods). This was the +last item, so **the roadmap is closed**: every route in `routes/api.php` that +an API-token client can reach is now wrapped. It did land in pieces, as the +item predicted — import/export first, then the integrations. -6. **Import/export and the integrations** — `/import`, `/export`, - `edocument/upload`, Stripe, QuickBooks, einvoice (17 routes), Yodlee, - Nordigen, calendar. The largest block and the least SDK-shaped; worth - doing last and possibly in pieces. +As the item also predicted, it is the least SDK-shaped part of the API, and +the typing reflects that: most of these return `serde_json::Value`, because +their replies are pass-throughs from Storecove, Stripe, Yodlee or GoCardless, +or arrays assembled for Invoice Ninja's React client. That is a deliberate +application of the rule in goal 2 — a wrong concrete type is a hard decode +error, where `Value` is lossless — and not a shortcut to revisit without a +reason. `COVERAGE.md` records it as the crate's largest untyped surface. -Keep `COVERAGE.md` current as these land — it names exact counts, so it goes -stale quietly. +Two things in this block are *not* wrapped, and cannot be. The OAuth legs of +the QuickBooks and calendar flows take no API token and answer with browser +redirects, so connecting an account stays a browser flow; the methods here +operate a connection that already exists. And `POST /quickbooks/settings` is +a registered route with no controller method behind it, so it 500s — there is +nothing to wrap until upstream adds the handler. + +What remains unwrapped is now only what each entry in `COVERAGE.md`'s gap +list explains: the auth routes (an open question below), the client-portal +family, the provider webhooks, and a few company and user extras. Keep that +file current — it names exact counts, so it goes stale quietly. ## Open questions diff --git a/README.md b/README.md index 89f9780..eb6754b 100644 --- a/README.md +++ b/README.md @@ -100,7 +100,7 @@ IN_API_TOKEN=... cargo run --example list_clients ## Coverage -Every one of the API's 34 resource groups, plus users — 338 methods. Each +Every one of the API's 34 resource groups, plus users — 371 methods. Each resource has `list`, `list_all`, `get`, `create`, `update`, `delete`, and (where the API offers one) `bulk`: diff --git a/src/client.rs b/src/client.rs index 69cf4a7..940c7f3 100644 --- a/src/client.rs +++ b/src/client.rs @@ -3,6 +3,7 @@ use std::time::Duration; use percent_encoding::{utf8_percent_encode, AsciiSet, NON_ALPHANUMERIC}; use reqwest::{header, multipart, Method, StatusCode}; use serde::{de::DeserializeOwned, Serialize}; +use serde_json::Value; use crate::error::{ApiErrorBody, Error, Result}; use crate::models::{ @@ -629,6 +630,55 @@ impl InvoiceNinjaClient { Ok(body.data) } + /// `post_unwrapped` for the other body-carrying verbs. + /// + /// The e-invoice routes use all three: `PUT einvoice/peppol/update` and + /// `DELETE einvoice/peppol/remove_additional_legal_identifier` both take + /// a payload, and a `DELETE` with a body is unusual enough to be worth + /// naming rather than hiding behind a generic request builder. + async fn send_unwrapped( + &self, + method: Method, + path: &str, + payload: &B, + ) -> Result { + let response = self.execute(self.req(method, path).json(payload)).await?; + Self::decode(response).await + } + + /// `post_unwrapped` for a route that answers either a JSON body or a + /// `204 No Content`. + /// + /// `einvoice/peppol/setup` does exactly that: it returns a + /// `corppass_url` when one is needed to finish the connection, and an + /// empty 204 when the setup completed outright. Decoding the 204 into + /// `T` would fail on an empty body, so the absence is returned as `None` + /// instead of being an error — the two outcomes are both successes and + /// the caller has to branch on them anyway. + async fn post_unwrapped_opt( + &self, + path: &str, + payload: &B, + ) -> Result> { + let response = self + .execute(self.req(Method::POST, path).json(payload)) + .await?; + let status = response.status(); + let bytes = response.bytes().await?; + if !status.is_success() { + return Err(Self::api_error(status, &bytes)); + } + if bytes.is_empty() { + return Ok(None); + } + serde_json::from_slice(&bytes) + .map(Some) + .map_err(|source| Error::Json { + source, + excerpt: excerpt(&bytes), + }) + } + /// Sends a caller-built multipart form and decodes a body with no `data` /// wrapper. /// @@ -1267,7 +1317,7 @@ impl InvoiceNinjaClient { /// eleven charts share no common response shape — `totals_v2` answers /// with per-currency maps, `cashflow_forecast` with a date series, /// `calculated_fields` with scalars — so there is nothing to narrow to. - /// Pass `serde_json::Value`, or your own struct for the chart you are + /// Pass `Value`, or your own struct for the chart you are /// calling. /// /// `project_analytics` and `project_burnup` take a project id as a @@ -1719,7 +1769,7 @@ impl InvoiceNinjaClient { /// The client's effective settings, each tagged with the level it came /// from. Left as raw JSON: the map's keys are the settings themselves. - pub async fn client_show_settings(&self, id: &str) -> Result { + pub async fn client_show_settings(&self, id: &str) -> Result { self.post_unwrapped( &format!("clients/{}/show_settings", seg(id)?), &serde_json::json!({}), @@ -1729,7 +1779,7 @@ impl InvoiceNinjaClient { /// Looks the client up on the Peppol network. Left as raw JSON: the /// controller returns whatever the discovery service produced. - pub async fn client_peppol_discovery(&self, id: &str) -> Result { + pub async fn client_peppol_discovery(&self, id: &str) -> Result { self.post_unwrapped( &format!("clients/{}/peppol_discovery", seg(id)?), &serde_json::json!({}), @@ -1943,7 +1993,7 @@ impl InvoiceNinjaClient { /// message that has delivery events. Left as raw JSON, since the objects /// mirror whatever the mail provider reported and no instance available /// here had sent mail to take a shape from. - pub async fn client_email_history(&self, client_id: &str) -> Result> { + pub async fn client_email_history(&self, client_id: &str) -> Result> { self.post_unwrapped( &format!("emails/clientHistory/{}", seg(client_id)?), &serde_json::json!({}), @@ -1954,11 +2004,7 @@ impl InvoiceNinjaClient { /// Email delivery history for one record. `entity` is `invoice`, `quote`, /// `credit`, `recurring_invoice`, or `purchase_order`. Raw JSON, as for /// [`client_email_history`](Self::client_email_history). - pub async fn entity_email_history( - &self, - entity: &str, - entity_id: &str, - ) -> Result> { + pub async fn entity_email_history(&self, entity: &str, entity_id: &str) -> Result> { self.post_unwrapped( "emails/entityHistory", &serde_json::json!({ "entity": entity, "entity_id": entity_id }), @@ -2010,7 +2056,7 @@ impl InvoiceNinjaClient { /// Import, export, and the third-party integrations. /// /// These are the least SDK-shaped part of the API, and they are typed -/// accordingly. Most return `serde_json::Value` rather than a model: their +/// accordingly. Most return `Value` rather than a model: their /// replies are pass-throughs from an external service (Storecove, Stripe, /// Yodlee, GoCardless) or ad-hoc arrays assembled for Invoice Ninja's own /// React client, so they have no stable documented schema. The convention @@ -2110,3 +2156,346 @@ impl InvoiceNinjaClient { self.multipart_unwrapped("edocument/upload", form).await } } + +/// The e-invoicing surface: Peppol, Verifactu, and the token and quota the +/// hosted service meters them with. +/// +/// Almost everything here is a pass-through to Storecove, Invoice Ninja's +/// Peppol access point, so the replies are whatever that service said and +/// are returned as `Value`. Two upstream habits shape these +/// signatures: +/// +/// - **A failing check is a non-2xx.** `validate_einvoice` answers `422` +/// when validation finds problems, and the Peppol routes pass Storecove's +/// status straight through. So an ordinary "this invoice is not +/// Peppol-ready" result arrives as [`Error::Api`](crate::Error::Api), not +/// as an `Ok` carrying failures. +/// - **Error bodies are not the usual shape.** The Peppol handlers return +/// `data_get($response, 'message')` as the whole body, which may be a bare +/// string or an object rather than `{"message": …, "errors": {…}}`. When +/// it is, `Error::Api`'s `message` falls back to an excerpt of the body +/// and `errors` is empty. +impl InvoiceNinjaClient { + /// Checks one entity against the company's e-invoice rules. + /// + /// The body names what to check: `{"entity": "invoices", "entity_id": + /// "…"}`, where `entity` is one of `invoices`, `recurring_invoices`, + /// `credits`, `clients`, or `companies`. + /// + /// **A failed check is a `422`**, so it comes back as `Error::Api` and + /// the per-entity detail in the body is not reachable through the typed + /// error. An `Ok` means the entity passed. A company whose + /// `e_invoice_type` is neither `VERIFACTU` nor `PEPPOL` always passes, + /// with every list empty. + pub async fn validate_einvoice(&self, body: &B) -> Result { + self.post_unwrapped("einvoice/validateEntity", body).await + } + + /// Updates the company's e-invoice payment means and other Peppol + /// configuration. + pub async fn einvoice_configurations(&self, body: &B) -> Result { + self.post_unwrapped("einvoice/configurations", body).await + } + + /// Returns the account's remaining e-invoice quota as `{"quota": n}`. + /// + /// Hosted only in practice: it asks the hosted Invoice Ninja service, + /// authenticated with the account's e-invoicing token, and refreshes the + /// local count from the answer. Without a token the server answers + /// `422`. + pub async fn einvoice_quota(&self) -> Result { + self.get_unwrapped("einvoice/quota").await + } + + /// Checks that the e-invoicing service is reachable and the account's + /// token is good. + /// + /// The body is an empty object on success, so there is nothing to + /// return; a missing token or an unreachable service is a `422` or + /// `400`. + pub async fn einvoice_health_check(&self) -> Result<()> { + self.get_discard("einvoice/health_check").await + } + + /// Returns the static Peppol delivery map: per country, which + /// classifications are routable and which client identifiers each + /// requires. + /// + /// Constant per release, so it is a reasonable thing to cache. + pub async fn einvoice_delivery_map(&self) -> Result { + self.get_unwrapped("einvoice/delivery_map").await + } + + /// Rotates the account's e-invoicing token. + /// + /// Answers `204` on success, so there is nothing to decode. The new + /// token is stored server-side against the account and is never sent + /// back. + pub async fn update_einvoice_token(&self, body: &B) -> Result<()> { + self.post_discard("einvoice/token/update", body).await + } + + /// Looks up whether a Peppol participant is reachable, as + /// `{"discovered": bool}`. + /// + /// This is the company-level lookup, which takes the identifier in the + /// body. [`client_peppol_discovery`](Self::client_peppol_discovery) is + /// the per-client route and takes a client id instead. + pub async fn peppol_discovery(&self, body: &B) -> Result { + self.post_unwrapped("einvoice/peppol/discovery", body).await + } + + /// Fetches the company's registered Peppol legal entity. + /// + /// A `POST` despite being a read — that is how the route is registered. + pub async fn peppol_legal_entity(&self, body: &B) -> Result { + self.post_unwrapped("einvoice/peppol/legal_entity", body) + .await + } + + /// Registers the company as a Peppol participant. + /// + /// Returns `Some({"corppass_url": …})` when the connection needs a + /// further authorisation step the caller has to send the user through + /// (Singapore's CorpPass), and `None` when the setup completed on its + /// own — upstream answers `204` in that case. Both are successes. + pub async fn peppol_setup(&self, body: &B) -> Result> { + self.post_unwrapped_opt("einvoice/peppol/setup", body).await + } + + /// Deregisters the company from Peppol. Answers an empty array. + pub async fn peppol_disconnect(&self, body: &B) -> Result { + self.post_unwrapped("einvoice/peppol/disconnect", body) + .await + } + + /// Updates the registered Peppol legal entity. Answers an empty array. + pub async fn peppol_update_legal_entity(&self, body: &B) -> Result { + self.send_unwrapped(Method::PUT, "einvoice/peppol/update", body) + .await + } + + /// Adds an additional tax identifier to the Peppol registration. + /// + /// An identifier that already exists for the region is a `400` rather + /// than a no-op. + pub async fn peppol_add_legal_identifier( + &self, + body: &B, + ) -> Result { + self.post_unwrapped("einvoice/peppol/add_additional_legal_identifier", body) + .await + } + + /// Removes an additional tax identifier from the Peppol registration. + /// + /// A `DELETE` that carries a body, which is why it does not look like + /// the other `delete_*` methods. Answers an empty array. + pub async fn peppol_remove_legal_identifier(&self, body: &B) -> Result { + self.send_unwrapped( + Method::DELETE, + "einvoice/peppol/remove_additional_legal_identifier", + body, + ) + .await + } + + /// Retries sending a document over Peppol. + /// + /// Queued, so the reply is `{"message": "trying...."}` and says nothing + /// about whether the send eventually succeeded. + pub async fn peppol_retry_send(&self, body: &B) -> Result { + self.post_unwrapped("einvoice/peppol/send", body).await + } + + /// Activates Singapore's C5 (InvoiceNow) peer. + pub async fn peppol_sg_c5_activate(&self, body: &B) -> Result { + self.post_unwrapped("einvoice/peppol/sg/c5/activate", body) + .await + } + + /// Deactivates Singapore's C5 peer. + pub async fn peppol_sg_c5_deactivate(&self, body: &B) -> Result { + self.post_unwrapped("einvoice/peppol/sg/c5/deactivate", body) + .await + } + + /// Cancels a pending Singapore C5 registration. + pub async fn peppol_sg_c5_cancel(&self, body: &B) -> Result { + self.post_unwrapped("einvoice/peppol/sg/c5/cancel", body) + .await + } +} + +/// The third-party integrations: QuickBooks, Stripe Connect, calendar sync, +/// and the two bank-feed providers. +/// +/// Replies are `Value` for the same reason as the e-invoice +/// block — they are pass-throughs from the provider's own SDK, or arrays +/// assembled for Invoice Ninja's React client. Several answer `204`, so +/// their methods return `()`. +/// +/// The OAuth halves of these flows are **not** wrapped and cannot be: the +/// authorize and callback routes live outside the `api/v1` group, take no +/// API token, and answer with browser redirects. Connecting an account is a +/// thing a user does in a browser; these methods operate a connection that +/// already exists. +impl InvoiceNinjaClient { + /// Starts a QuickBooks sync. Answers `204`; the work is queued. + pub async fn quickbooks_sync(&self, body: &B) -> Result<()> { + self.post_discard("quickbooks/sync", body).await + } + + /// Runs a single QuickBooks action. Answers `204`. + pub async fn quickbooks_action(&self, body: &B) -> Result<()> { + self.post_discard("quickbooks/action", body).await + } + + /// Disconnects the QuickBooks integration. Answers `204`. + pub async fn quickbooks_disconnect(&self, body: &B) -> Result<()> { + self.post_discard("quickbooks/disconnect", body).await + } + + /// Mints a one-time URL for re-authorising an expired QuickBooks + /// connection. + /// + /// Answers `reconnect_url`, `requires_reconnect`, and + /// `refresh_token_expires_at`. The URL embeds a token good for 30 + /// minutes and has to be opened in a browser. With no connection + /// configured the server answers `400`. + pub async fn quickbooks_reconnect_url(&self) -> Result { + self.post_unwrapped("quickbooks/reconnect_url", &serde_json::json!({})) + .await + } + + /// Queues a refresh of every stored Stripe payment method. + /// + /// Password-protected: needs + /// [`with_api_password`](Self::with_api_password), and answers `403` for + /// a non-admin. + pub async fn stripe_update_payment_methods(&self) -> Result { + self.post_unwrapped("stripe/update_payment_methods", &serde_json::json!({})) + .await + } + + /// Queues an import of existing Stripe customers into clients. + /// + /// Password-protected. Matching is by email, so running it against an + /// instance that already has those clients can create duplicates. + pub async fn stripe_import_customers(&self) -> Result { + self.post_unwrapped("stripe/import_customers", &serde_json::json!({})) + .await + } + + /// Verifies the Stripe Connect connection. + /// + /// Password-protected. The body is whatever the gateway driver's + /// `verifyConnect` returned, which is not a documented shape. + pub async fn stripe_verify(&self) -> Result { + self.post_unwrapped("stripe/verify", &serde_json::json!({})) + .await + } + + /// Disconnects a Stripe Connect account from one company gateway. + /// + /// Password-protected. The reply comes from the driver's `disconnect`. + pub async fn stripe_disconnect(&self, company_gateway_id: &str) -> Result { + self.post_unwrapped( + &format!("stripe/disconnect/{}", seg(company_gateway_id)?), + &serde_json::json!({}), + ) + .await + } + + /// Returns the company's calendar connection, or a null one if there is + /// none. + pub async fn calendar_connection(&self) -> Result { + self.get_one("calendar_connection").await + } + + /// Finishes a calendar connection the user has authorised in a browser. + /// + /// `provider` is the OAuth provider that was authorised. The redirect + /// and callback legs are not wrappable — see the note on this `impl` + /// block — so this is the first step an API caller can take, using + /// whatever the callback handed back. + pub async fn complete_calendar_connection( + &self, + provider: &str, + body: &B, + ) -> Result { + self.post_one( + &format!("calendar_connection/{}/complete", seg(provider)?), + body, + ) + .await + } + + /// Lists the calendars the connected account exposes, as + /// `{"calendars": …}`. + pub async fn calendar_connection_calendars(&self) -> Result { + self.get_one("calendar_connection/calendars").await + } + + /// Lists events from the selected calendars between two dates, as + /// `{"events": …}`. + /// + /// `from` and `to` are required by the server and are dates, not + /// timestamps. + pub async fn calendar_connection_events(&self, from: &str, to: &str) -> Result { + // Encoded with the path-segment set rather than through `seg`: these + // are query values, not segments, so `seg`'s refusal of empty and + // dot-only input would be checking the wrong thing. The set still + // escapes `&`, `=` and `?`, which is what matters here. + self.get_one(&format!( + "calendar_connection/events?from={}&to={}", + utf8_percent_encode(from, PATH_SEGMENT), + utf8_percent_encode(to, PATH_SEGMENT) + )) + .await + } + + /// Chooses which of the connected account's calendars to sync. + /// + /// The body is `{"calendar_ids": [...]}`. It replaces the selection + /// rather than adding to it, so an empty list syncs nothing. + pub async fn update_calendar_connection_calendars( + &self, + body: &B, + ) -> Result { + self.put_one("calendar_connection/calendars", body).await + } + + /// Disconnects the calendar connection. + /// + /// Answers `{"data": {"calendar_connection": null}}`, which carries no + /// information beyond the success, so it is discarded. + pub async fn delete_calendar_connection(&self) -> Result<()> { + self.delete_one("calendar_connection").await + } + + /// Returns the Yodlee account summary for one bank account number. + /// + /// The number is Yodlee's `account_id`, as stored on the matching + /// [`BankIntegration`](crate::models::BankIntegration) — not an Invoice + /// Ninja id. An account the company does not hold is a `400`. + /// + /// A `POST` despite being a read; the upstream route carries a `@todo` + /// questioning its own path, so treat it as liable to move. + pub async fn yodlee_account_status(&self, account_number: &str) -> Result { + self.post_unwrapped( + &format!("yodlee/status/{}", seg(account_number)?), + &serde_json::json!({}), + ) + .await + } + + /// Lists the GoCardless (formerly Nordigen) institutions available for + /// bank feeds. + /// + /// Needs the instance to be configured with GoCardless credentials; + /// without them the server answers `400` rather than an empty list. + pub async fn nordigen_institutions(&self) -> Result { + self.get_unwrapped("nordigen/institutions").await + } +} diff --git a/tests/client_requests.rs b/tests/client_requests.rs index 1ffaca3..13217b5 100644 --- a/tests/client_requests.rs +++ b/tests/client_requests.rs @@ -2536,3 +2536,398 @@ async fn edocument_uploads_are_never_replayed() { "a multipart body is not cloneable, so it gets one attempt" ); } + +// --- e-invoicing -------------------------------------------------------- + +fn discovered() -> serde_json::Value { + json!({ "discovered": true }) +} + +extra_test!( + validate_einvoice_posts_to_validate_entity, + "POST", + "/einvoice/validateEntity", + json!({ "passes": true, "invoices": [], "clients": [] }), + |c| c.validate_einvoice(&json!({ "entity": "invoices", "entity_id": "i1" })), + |got| assert_eq!(got["passes"], true) +); + +/// A failed check is a 422, so it arrives as an error rather than an `Ok` +/// carrying the failures. Pinned because it is surprising. +#[tokio::test] +async fn a_failing_einvoice_check_is_an_error_not_a_result() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/einvoice/validateEntity")) + .respond_with( + ResponseTemplate::new(422).set_body_json(json!({ "passes": false, "invoices": ["x"] })), + ) + .mount(&server) + .await; + + let err = client(&server) + .validate_einvoice(&json!({ "entity": "invoices" })) + .await + .expect_err("a failed validation is a 422"); + assert!( + matches!(&err, Error::Api { status, .. } if status.as_u16() == 422), + "{err:?}" + ); +} + +extra_test!( + einvoice_configurations_posts_the_settings, + "POST", + "/einvoice/configurations", + json!({}), + |c| c.einvoice_configurations(&json!({ "payment_means": [] })), + |got| assert!(got.is_object()) +); + +extra_test!( + einvoice_quota_reads_the_remaining_count, + "GET", + "/einvoice/quota", + json!({ "quota": 42 }), + |c| c.einvoice_quota(), + |got| assert_eq!(got["quota"], 42) +); + +extra_test!( + einvoice_delivery_map_reads_the_static_map, + "GET", + "/einvoice/delivery_map", + json!({ "DE": { "routable": true } }), + |c| c.einvoice_delivery_map(), + |got| assert_eq!(got["DE"]["routable"], true) +); + +extra_test!( + peppol_discovery_posts_the_identifier, + "POST", + "/einvoice/peppol/discovery", + discovered(), + |c| c.peppol_discovery(&json!({ "identifier": "0088:123" })), + |got| assert_eq!(got["discovered"], true) +); + +extra_test!( + peppol_legal_entity_is_a_post_despite_being_a_read, + "POST", + "/einvoice/peppol/legal_entity", + json!({ "id": 7, "party_name": "Acme" }), + |c| c.peppol_legal_entity(&json!({})), + |got| assert_eq!(got["id"], 7) +); + +extra_test!( + peppol_disconnect_posts_and_tolerates_an_empty_array, + "POST", + "/einvoice/peppol/disconnect", + json!([]), + |c| c.peppol_disconnect(&json!({})), + |got| assert!(got.is_array()) +); + +extra_test!( + peppol_update_legal_entity_is_a_put, + "PUT", + "/einvoice/peppol/update", + json!([]), + |c| c.peppol_update_legal_entity(&json!({ "party_name": "Acme" })), + |got| assert!(got.is_array()) +); + +extra_test!( + peppol_add_legal_identifier_returns_the_ok_message, + "POST", + "/einvoice/peppol/add_additional_legal_identifier", + message(), + |c| c.peppol_add_legal_identifier(&json!({ "identifier": "0088:1" })), + |got| assert_eq!(got.message, "ok") +); + +// A `DELETE` that carries a body, which no other method in the crate does. +extra_test!( + peppol_remove_legal_identifier_is_a_delete_with_a_body, + "DELETE", + "/einvoice/peppol/remove_additional_legal_identifier", + json!([]), + |c| c.peppol_remove_legal_identifier(&json!({ "identifier": "0088:1" })), + |got| assert!(got.is_array()) +); + +extra_test!( + peppol_retry_send_returns_the_queued_message, + "POST", + "/einvoice/peppol/send", + json!({ "message": "trying...." }), + |c| c.peppol_retry_send(&json!({ "entity_id": "i1" })), + |got| assert_eq!(got.message, "trying....") +); + +extra_test!( + peppol_sg_c5_activate_posts_to_the_nested_path, + "POST", + "/einvoice/peppol/sg/c5/activate", + message(), + |c| c.peppol_sg_c5_activate(&json!({})), + |got| assert_eq!(got.message, "ok") +); + +extra_test!( + peppol_sg_c5_deactivate_posts_to_the_nested_path, + "POST", + "/einvoice/peppol/sg/c5/deactivate", + message(), + |c| c.peppol_sg_c5_deactivate(&json!({})), + |got| assert_eq!(got.message, "ok") +); + +extra_test!( + peppol_sg_c5_cancel_posts_to_the_nested_path, + "POST", + "/einvoice/peppol/sg/c5/cancel", + message(), + |c| c.peppol_sg_c5_cancel(&json!({})), + |got| assert_eq!(got.message, "ok") +); + +/// `setup` answers either a URL to send the user to, or a bare 204 when the +/// registration completed outright. Both are successes, so the 204 is `None` +/// rather than a decode error. +#[tokio::test] +async fn peppol_setup_maps_a_204_to_none_and_a_body_to_some() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/einvoice/peppol/setup")) + .respond_with(ResponseTemplate::new(204)) + .up_to_n_times(1) + .mount(&server) + .await; + assert_eq!( + client(&server).peppol_setup(&json!({})).await.unwrap(), + None, + "an empty 204 is a completed setup, not a failure" + ); + + let server = route_server( + "POST", + "/einvoice/peppol/setup", + json!({ "corppass_url": "https://x.test/corppass" }), + ) + .await; + let got = client(&server).peppol_setup(&json!({})).await.unwrap(); + assert_eq!( + got.unwrap()["corppass_url"], + "https://x.test/corppass", + "a body means a further authorisation step" + ); +} + +/// The token rotation answers 204 and keeps the new token server-side. +#[tokio::test] +async fn update_einvoice_token_discards_the_empty_body() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/einvoice/token/update")) + .respond_with(ResponseTemplate::new(204)) + .expect(1) + .mount(&server) + .await; + + client(&server) + .update_einvoice_token(&json!({})) + .await + .expect("a 204 is a success"); +} + +#[tokio::test] +async fn einvoice_health_check_accepts_an_empty_object() { + let server = route_server("GET", "/einvoice/health_check", json!({})).await; + client(&server) + .einvoice_health_check() + .await + .expect("an empty object is a pass"); +} + +// --- quickbooks, stripe, calendar, bank feeds --------------------------- + +/// The three 204 routes. A JSON decode attempt on an empty body would fail, +/// so these must go through the discarding helper. +#[tokio::test] +async fn the_quickbooks_204_routes_discard_their_bodies() { + for (route, call) in ["sync", "action", "disconnect"].iter().zip(0..3) { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path(format!("/quickbooks/{route}"))) + .respond_with(ResponseTemplate::new(204)) + .expect(1) + .mount(&server) + .await; + + let c = client(&server); + let body = json!({}); + let result = match call { + 0 => c.quickbooks_sync(&body).await, + 1 => c.quickbooks_action(&body).await, + _ => c.quickbooks_disconnect(&body).await, + }; + result.unwrap_or_else(|e| panic!("quickbooks/{route} should succeed: {e:?}")); + } +} + +extra_test!( + quickbooks_reconnect_url_returns_the_one_time_link, + "POST", + "/quickbooks/reconnect_url", + json!({ "reconnect_url": "https://x.test/r", "requires_reconnect": true }), + |c| c.quickbooks_reconnect_url(), + |got| assert_eq!(got["reconnect_url"], "https://x.test/r") +); + +extra_test!( + stripe_update_payment_methods_queues_the_refresh, + "POST", + "/stripe/update_payment_methods", + json!({ "message": "Processing" }), + |c| c.stripe_update_payment_methods(), + |got| assert_eq!(got.message, "Processing") +); + +extra_test!( + stripe_import_customers_queues_the_import, + "POST", + "/stripe/import_customers", + json!({ "message": "Processing" }), + |c| c.stripe_import_customers(), + |got| assert_eq!(got.message, "Processing") +); + +extra_test!( + stripe_verify_passes_the_driver_response_through, + "POST", + "/stripe/verify", + json!({ "verified": true }), + |c| c.stripe_verify(), + |got| assert_eq!(got["verified"], true) +); + +extra_test!( + stripe_disconnect_escapes_the_gateway_id, + "POST", + "/stripe/disconnect/cg1", + json!({ "disconnected": true }), + |c| c.stripe_disconnect("cg1"), + |got| assert_eq!(got["disconnected"], true) +); + +/// The stripe routes are password-protected, so the header has to reach them. +#[tokio::test] +async fn the_stripe_routes_send_the_api_password() { + let server = route_server( + "POST", + "/stripe/import_customers", + json!({ "message": "Processing" }), + ) + .await; + client(&server) + .with_api_password("secret") + .unwrap() + .stripe_import_customers() + .await + .unwrap(); + + let headers = &server.received_requests().await.unwrap()[0].headers; + assert_eq!(headers["X-API-PASSWORD"], "secret"); +} + +extra_test!( + calendar_connection_unwraps_the_data_envelope, + "GET", + "/calendar_connection", + json!({ "data": { "provider": "google" } }), + |c| c.calendar_connection(), + |got| assert_eq!(got["provider"], "google") +); + +extra_test!( + complete_calendar_connection_escapes_the_provider, + "POST", + "/calendar_connection/google/complete", + json!({ "data": { "provider": "google" } }), + |c| c.complete_calendar_connection("google", &json!({ "code": "x" })), + |got| assert_eq!(got["provider"], "google") +); + +extra_test!( + calendar_connection_calendars_lists_the_available_ones, + "GET", + "/calendar_connection/calendars", + json!({ "data": { "calendars": [{ "id": "c1" }] } }), + |c| c.calendar_connection_calendars(), + |got| assert_eq!(got["calendars"][0]["id"], "c1") +); + +extra_test!( + update_calendar_connection_calendars_is_a_put, + "PUT", + "/calendar_connection/calendars", + json!({ "data": { "calendar_connection": { "id": "x" } } }), + |c| c.update_calendar_connection_calendars(&json!({ "calendar_ids": ["c1"] })), + |got| assert_eq!(got["calendar_connection"]["id"], "x") +); + +#[tokio::test] +async fn calendar_connection_events_sends_both_dates_as_query_params() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/calendar_connection/events")) + .and(query_param("from", "2026-01-01")) + .and(query_param("to", "2026-01-31")) + .respond_with( + ResponseTemplate::new(200) + .set_body_json(json!({ "data": { "events": [{ "id": "e1" }] } })), + ) + .expect(1) + .mount(&server) + .await; + + let got = client(&server) + .calendar_connection_events("2026-01-01", "2026-01-31") + .await + .expect("events should decode"); + assert_eq!(got["events"][0]["id"], "e1"); +} + +#[tokio::test] +async fn delete_calendar_connection_discards_the_null_payload() { + let server = route_server( + "DELETE", + "/calendar_connection", + json!({ "data": { "calendar_connection": null } }), + ) + .await; + client(&server) + .delete_calendar_connection() + .await + .expect("the disconnect should succeed"); +} + +extra_test!( + yodlee_account_status_escapes_the_account_number, + "POST", + "/yodlee/status/acc%2F1", + json!({ "account_status": "ok" }), + |c| c.yodlee_account_status("acc/1"), + |got| assert_eq!(got["account_status"], "ok") +); + +extra_test!( + nordigen_institutions_returns_the_bare_array, + "GET", + "/nordigen/institutions", + json!([{ "id": "BANK_X" }]), + |c| c.nordigen_institutions(), + |got| assert_eq!(got[0]["id"], "BANK_X") +);