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") +);