Compare commits

..
2 Commits
Author SHA1 Message Date
awkawbandClaude Opus 5 446617a728 Wrap the e-invoicing and integration routes
CI / test (push) Canceled after 0s
CI / Build on the declared MSRV (push) Canceled after 0s
CI / Latest stable (advisory) (push) Canceled after 0s
Closes the last roadmap item, and with it the roadmap: every route in
`routes/api.php` that an API-token client can reach is now wrapped. 33
methods — 17 e-invoicing, 4 QuickBooks, 4 Stripe, 6 calendar, and one each
for Yodlee and GoCardless.

Most return `serde_json::Value`, which is deliberate and is the crate's
largest untyped surface. These replies are pass-throughs from Storecove,
Stripe's gateway driver, Yodlee or GoCardless, or arrays assembled for
Invoice Ninja's own React client. None has a documented schema, several
differ by provider, and they move on the provider's release schedule rather
than Invoice Ninja's — so the rule that keeps `chart` generic applies here
with more force, not less. `client_peppol_discovery` set the precedent.

Three upstream behaviours needed accommodating, each now pinned by a test:

- `validate_einvoice` answers 422 when validation finds problems, so an
  ordinary "this invoice is not Peppol-ready" result arrives as `Error::Api`
  rather than an `Ok` carrying the failures. The per-entity detail is not
  reachable through the typed error. Documented rather than worked around.
- `einvoice/peppol/setup` has two success shapes: a `corppass_url` when the
  connection needs a further browser step, or a bare 204 when it completed.
  Hence `post_unwrapped_opt` and `Option<Value>`, since decoding the 204
  would otherwise be a spurious error.
- `einvoice/peppol/remove_additional_legal_identifier` is a DELETE carrying
  a body — the only one in the crate — and `einvoice/peppol/update` a PUT
  with one, hence `send_unwrapped`.

The Peppol handlers also return `data_get($response, 'message')` as the
entire error body, which may be a bare string rather than
`{"message": …, "errors": {…}}`. `Error::Api` then falls back to an excerpt
with an empty `errors` map; the docs say so on the impl block.

Two things in this block stay unwrapped because they cannot be wrapped. The
OAuth legs of the QuickBooks and calendar flows sit outside the `api/v1`
group, take no token, and answer with redirects, so connecting an account
remains a browser flow — these methods operate a connection that already
exists. And `POST /quickbooks/settings` is a registered route whose
controller has no `settings` method, so it raises `BadMethodCallException`
and 500s; there is nothing to wrap until upstream adds the handler. Both are
now entries in `COVERAGE.md`'s gap list rather than silent omissions.

`GOALS.md` keeps the roadmap rather than deleting it: several entries record
what an item's original reasoning got wrong, which outlasts the fact that it
shipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-24 23:24:10 -04:00
awkawbandClaude Opus 5 c4f31743f0 Wrap import, export, and the e-document importer
First piece of the last roadmap item. Five routes: `POST /export`,
`POST /preimport`, `POST /import`, `POST /import_json`, and
`PUT /edocument/upload`.

A CSV import is two calls, and only that form is wrapped. `preimport`
uploads one file per entity type as `files[<entity_type>]` — a different
field name from the `documents[]` the `/{id}/upload` routes take — and
answers with a hash plus the column mapping the server suggests. `import`
then starts the ingest from that hash, as plain JSON: with a hash present
`ImportController::import` never looks at uploaded files.

The route does also accept the files and the mapping together in one
multipart request. That form is left out on purpose. A mapping is a nested
structure, and bracket-encoding nested structures into form fields is the
thing this crate avoids by sending JSON everywhere else — the same reason
`COVERAGE.md` gives for diverging from the PHP SDK's `form_params`.

`upload_edocument` is not a document attachment, which is why it is not an
`upload_*` method and why the roadmap had already split it out:
`ExpenseController::edocument` parses each file and creates *expenses*. It
shares the `_method=PUT` spoof with the upload routes, since it is likewise
registered as a `PUT`, but its reply has no `data` envelope — hence the new
`multipart_unwrapped` helper, which dispatches a form built at the call site
rather than imposing one shape on all four import routes.

Two new models, for the two shapes that are stable and that are the point of
the call: `ExportJob` (the collection URL, which expires in one hour, or
five past 10,000 activities) and `ImportPreview` (the hash). Their
`available`/`headers`/`hints` payloads stay `Value` — those come from each
entity's own `importable()` map and are undocumented, so a concrete type
would be a hard decode error where `Value` is lossless.

All five queue work and answer before it happens, so a 200 means accepted.
Failed rows are reported by email, not in the response, and `import_json`
overwrites the company it is pointed at.

`/migration/start` stays unwrapped and is now listed on its own: it is
driven by the v4 migration tool rather than by an SDK caller.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-24 23:17:36 -04:00
10 changed files with 1389 additions and 42 deletions
+27
View File
@@ -9,6 +9,33 @@ 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<Value>`. `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[<entity_type>]` and returns the
column mapping the server suggests, then `import` starts the ingest from
the returned `hash`. The route's one-shot form (files and mapping in a
single multipart request) is deliberately not wrapped: a mapping is nested,
and bracket-encoding nested structures is what this crate avoids by sending
JSON. `upload_edocument` imports expenses from e-documents and is not a
document attachment, which is why it is not an `upload_*` method. Adds
`ExportJob` (the export's collection URL expires in 1–5 hours) and
`ImportPreview`. All of these queue work, so a success means accepted, not
done.
- Invitation-key downloads, eight methods:
`download_invoice_by_invitation_key` and
`download_e_invoice_by_invitation_key`, plus the same pair for quotes,
+30 -7
View File
@@ -272,6 +272,22 @@ changes, doc tweaks) does not need an entry.
refunds, ping/health) are hand-written in the last `impl` block rather than
macro-generated, because their request and response shapes are all
different.
- **The import, e-invoicing and integration blocks return
`serde_json::Value` on purpose.** They are pass-throughs from Storecove,
Stripe, Yodlee or GoCardless, or arrays built for Invoice Ninja's React
client; none has a documented schema and they move on the provider's
release schedule. Don't "finish the job" by typing them — the same
reasoning that keeps `chart` generic applies, and `COVERAGE.md` records it
as the crate's largest deliberate untyped surface. `ExportJob` and
`ImportPreview` are typed because their one useful field (the URL, the
hash) is the whole point of the call.
- Three dispatch helpers exist only for that surface: `send_unwrapped` for a
`PUT` or `DELETE` that carries a body (`peppol_remove_legal_identifier` is
the crate's only `DELETE` with one), `post_unwrapped_opt` for a route that
answers either JSON or a bare `204` (`peppol_setup`), and
`multipart_unwrapped` for the import uploads, whose form field is
`files[<entity_type>]` rather than `documents[]` and whose reply has no
`data` envelope. All three go through `req`/`execute` like everything else.
- `list_*` returns one page; `list_all_*` goes through `get_all_pages`, which
terminates on `meta.pagination.total_pages` and only falls back to the
short-page heuristic when the server sends no metadata. **The heuristic is
@@ -337,20 +353,27 @@ changes, doc tweaks) does not need an entry.
## Coverage
**All 34 of the API's `Route::resource` groups are wrapped**, plus `users`
(which isn't one). 333 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 69 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
+146 -10
View File
@@ -18,7 +18,7 @@ declares 34 `Route::resource` groups and 274 explicit route declarations
> exists or what it returns.
**All 34 `Route::resource` groups are now wrapped**, plus `users` (which is
not a resource group). 333 methods in total.
not a resource group). 371 methods in total.
## Resources with full CRUD + bulk
@@ -168,6 +168,118 @@ controllers return that directly rather than raising
for model` text a missing record produces elsewhere, but the status is the
same 400.
## Import and export
| Route | Method | Returns |
| --- | --- | --- |
| `POST /export` | `export` | `ExportJob` |
| `POST /preimport` | `preimport` | `ImportPreview` |
| `POST /import` | `import` | `MessageResponse` |
| `POST /import_json` | `import_json` | `MessageResponse` |
| `PUT /edocument/upload` | `upload_edocument` | `MessageResponse` |
A CSV import is two calls. `preimport` uploads the files — one per entity
type, as `files[<entity_type>]` — and answers with a `hash` plus the column
mapping the server suggests; `import` then starts the ingest from that hash.
The server holds the files against the hash for 20 minutes.
The route also accepts the files and the mapping together in one multipart
request. That form is deliberately not wrapped: a mapping is a nested
structure, and bracket-encoding nested structures into form fields is exactly
what this crate avoids by sending JSON (see [the PHP SDK
comparison](#compared-with-the-official-php-sdk)). With a `hash` present the
controller never looks at uploaded files, so `import` is plain JSON.
`upload_edocument` is *not* a document attachment, which is why it is not one
of the `upload_*` methods: `ExpenseController::edocument` parses each file and
creates **expenses**. Like the `/{id}/upload` routes it is registered as `PUT`
and so goes out as a `POST` with `_method=PUT`; unlike them the reply has no
`data` envelope.
All five queue background work, so a 200 means accepted, not finished. Rows
that fail to import are reported by email. `import_json` restores a company
from an `export` backup and **overwrites the target company**.
## E-invoicing
Seventeen routes, all wrapped. Mostly pass-throughs to Storecove, Invoice
Ninja's Peppol access point, so they return `serde_json::Value` — see
[Typed response gaps](#typed-response-gaps) for why.
| Route | Method | Returns |
| --- | --- | --- |
| `POST /einvoice/validateEntity` | `validate_einvoice` | `Value` |
| `POST /einvoice/configurations` | `einvoice_configurations` | `Value` |
| `GET /einvoice/quota` | `einvoice_quota` | `Value` |
| `GET /einvoice/health_check` | `einvoice_health_check` | `()` |
| `GET /einvoice/delivery_map` | `einvoice_delivery_map` | `Value` |
| `POST /einvoice/token/update` | `update_einvoice_token` | `()` (204) |
| `POST /einvoice/peppol/discovery` | `peppol_discovery` | `Value` |
| `POST /einvoice/peppol/legal_entity` | `peppol_legal_entity` | `Value` |
| `POST /einvoice/peppol/setup` | `peppol_setup` | `Option<Value>` |
| `POST /einvoice/peppol/disconnect` | `peppol_disconnect` | `Value` |
| `PUT /einvoice/peppol/update` | `peppol_update_legal_entity` | `Value` |
| `POST /einvoice/peppol/add_additional_legal_identifier` | `peppol_add_legal_identifier` | `MessageResponse` |
| `DELETE /einvoice/peppol/remove_additional_legal_identifier` | `peppol_remove_legal_identifier` | `Value` |
| `POST /einvoice/peppol/send` | `peppol_retry_send` | `MessageResponse` |
| `POST /einvoice/peppol/sg/c5/activate` | `peppol_sg_c5_activate` | `MessageResponse` |
| `POST /einvoice/peppol/sg/c5/deactivate` | `peppol_sg_c5_deactivate` | `MessageResponse` |
| `POST /einvoice/peppol/sg/c5/cancel` | `peppol_sg_c5_cancel` | `MessageResponse` |
Three things here behave unlike the rest of the API:
- **A failing check is a non-2xx.** `validate_einvoice` answers `422` when
validation finds problems, so "this invoice is not Peppol-ready" — an
ordinary result — arrives as `Error::Api`, and the per-entity detail in the
body is not reachable through the typed error. An `Ok` means it passed.
- **Error bodies are not the usual shape.** The Peppol handlers return
`data_get($response, 'message')` as the entire body, which can be a bare
string. `Error::Api`'s `message` then falls back to an excerpt and `errors`
is empty.
- **`peppol_setup` has two success shapes.** A `corppass_url` when the
connection needs a further browser step (Singapore's CorpPass), or a bare
`204` when it completed. Hence `Option<Value>` rather than treating the
empty body as a decode failure.
`peppol_remove_legal_identifier` is the only `DELETE` in the crate that
carries a request body.
## Integrations
| Route | Method | Returns |
| --- | --- | --- |
| `POST /quickbooks/sync` | `quickbooks_sync` | `()` (204) |
| `POST /quickbooks/action` | `quickbooks_action` | `()` (204) |
| `POST /quickbooks/disconnect` | `quickbooks_disconnect` | `()` (204) |
| `POST /quickbooks/reconnect_url` | `quickbooks_reconnect_url` | `Value` |
| `POST /stripe/update_payment_methods` | `stripe_update_payment_methods` | `MessageResponse` |
| `POST /stripe/import_customers` | `stripe_import_customers` | `MessageResponse` |
| `POST /stripe/verify` | `stripe_verify` | `Value` |
| `POST /stripe/disconnect/{id}` | `stripe_disconnect` | `Value` |
| `GET /calendar_connection` | `calendar_connection` | `Value` |
| `POST /calendar_connection/{provider}/complete` | `complete_calendar_connection` | `Value` |
| `GET /calendar_connection/calendars` | `calendar_connection_calendars` | `Value` |
| `GET /calendar_connection/events` | `calendar_connection_events` | `Value` |
| `PUT /calendar_connection/calendars` | `update_calendar_connection_calendars` | `Value` |
| `DELETE /calendar_connection` | `delete_calendar_connection` | `()` |
| `POST /yodlee/status/{account_number}` | `yodlee_account_status` | `Value` |
| `GET /nordigen/institutions` | `nordigen_institutions` | `Value` |
All four Stripe routes are **password-protected** — supply
`with_api_password` — and answer `403` for a non-admin.
These operate a connection that already exists. **Establishing one is a
browser flow and is not wrappable**: the OAuth authorize and callback legs
live outside the `api/v1` group, take no API token, and answer with
redirects. See [the gap list](#individual-endpoints-not-wrapped).
`yodlee_account_status` takes Yodlee's own `account_id`, as stored on the
matching `BankIntegration` — not an Invoice Ninja id. Its upstream route
carries a `@todo` questioning its own path, so treat it as liable to move.
`nordigen_institutions` covers GoCardless, which was Nordigen when the route
was named; an instance without GoCardless credentials answers `400` rather
than an empty list.
## Document uploads
`PUT /{resource}/{id}/upload` exists for 16 resources, each getting
@@ -359,10 +471,8 @@ smoke test does not exercise them.
### Nothing is blocked on a dependency
Multipart uploads used to be, and are now wrapped — see [Document
uploads](#document-uploads). One upload route is deliberately left out:
`PUT /edocument/upload` is not a document attachment. It imports an expense
from an e-document (`ExpenseController::edocument`), and is better treated as
an import endpoint alongside the others under *Import/export* below.
uploads](#document-uploads) and [Import and
export](#import-and-export).
### Resource groups not wrapped
@@ -383,8 +493,8 @@ check `routes/api.php`).
`POST /user/{id}/reconfirm`
- **Auth** — `/login`, `/logout`, `/refresh`, `/connected_account`,
passkey routes
- **Import/export** — `/import`, `/preimport`, `/import_json`, `/export`,
`/migration/start`
- **Migration** — `/migration/start`. Moves a company between instances and
is driven by the v4 migration tool, not by an SDK caller.
- **Invoice extras** — `delivery_note`, `payment_schedule`,
`update_reminders`
- **Client-portal invitation downloads** — the `routes/client.php` family:
@@ -394,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}`,
@@ -414,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
+32 -10
View File
@@ -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
+1 -1
View File
@@ -100,7 +100,7 @@ IN_API_TOKEN=... cargo run --example list_clients
## Coverage
Every one of the API's 34 resource groups, plus users — 333 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`:
+532 -14
View File
@@ -3,16 +3,17 @@ 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::{
Activity, ActivityFeedEntry, BankIntegration, BankTransaction, BankTransactionRule,
BulkRequest, Client as ClientModel, ClientGatewayToken, Company, CompanyGateway, CompanyToken,
Credit, Design, Document, Expense, ExpenseCategory, GroupSetting, HealthCheck, Invoice,
ItemResponse, ListParams, ListResponse, Location, MessageResponse, Payment, PaymentTerm,
Product, Project, PurchaseOrder, Quote, RecurringExpense, RecurringInvoice, RecurringQuote,
SearchResults, Statics, Subscription, SubscriptionStep, SystemLog, Tag, Task, TaskScheduler,
TaskStatus, TaxRate, User, Vendor, Webhook,
Credit, Design, Document, Expense, ExpenseCategory, ExportJob, GroupSetting, HealthCheck,
ImportPreview, Invoice, ItemResponse, ListParams, ListResponse, Location, MessageResponse,
Payment, PaymentTerm, Product, Project, PurchaseOrder, Quote, RecurringExpense,
RecurringInvoice, RecurringQuote, SearchResults, Statics, Subscription, SubscriptionStep,
SystemLog, Tag, Task, TaskScheduler, TaskStatus, TaxRate, User, Vendor, Webhook,
};
use crate::retry::{self, RetryPolicy};
use crate::upload::FileUpload;
@@ -629,6 +630,80 @@ 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<B: Serialize, T: DeserializeOwned>(
&self,
method: Method,
path: &str,
payload: &B,
) -> Result<T> {
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<B: Serialize, T: DeserializeOwned>(
&self,
path: &str,
payload: &B,
) -> Result<Option<T>> {
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.
///
/// [`upload_files`](Self::upload_files) covers the 16 `/{id}/upload`
/// routes, which all take `documents[]` and answer with an envelope. The
/// import and e-document routes share none of that: the field name is
/// `files`, keyed by entity type for the CSV importers, and the reply is
/// a bare `{"message": …}`. So the form is built at the call site, where
/// the route's own shape is visible, and only the dispatch is shared.
///
/// Always a `POST` on the wire. A route registered as `PUT` needs a
/// `_method=PUT` text field instead, for the reason spelled out on
/// `upload_files`. Like every multipart request, these are never retried:
/// the body is a stream, so `execute` gives it exactly one attempt.
async fn multipart_unwrapped<T: DeserializeOwned>(
&self,
path: &str,
form: multipart::Form,
) -> Result<T> {
let response = self
.execute(self.req(Method::POST, path).multipart(form))
.await?;
Self::decode(response).await
}
/// Issues a request whose body is discarded, failing on non-2xx.
async fn get_discard(&self, path: &str) -> Result<()> {
self.get_raw(path).await.map(|_| ())
@@ -1242,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
@@ -1694,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<serde_json::Value> {
pub async fn client_show_settings(&self, id: &str) -> Result<Value> {
self.post_unwrapped(
&format!("clients/{}/show_settings", seg(id)?),
&serde_json::json!({}),
@@ -1704,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<serde_json::Value> {
pub async fn client_peppol_discovery(&self, id: &str) -> Result<Value> {
self.post_unwrapped(
&format!("clients/{}/peppol_discovery", seg(id)?),
&serde_json::json!({}),
@@ -1918,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<Vec<serde_json::Value>> {
pub async fn client_email_history(&self, client_id: &str) -> Result<Vec<Value>> {
self.post_unwrapped(
&format!("emails/clientHistory/{}", seg(client_id)?),
&serde_json::json!({}),
@@ -1929,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<Vec<serde_json::Value>> {
pub async fn entity_email_history(&self, entity: &str, entity_id: &str) -> Result<Vec<Value>> {
self.post_unwrapped(
"emails/entityHistory",
&serde_json::json!({ "entity": entity, "entity_id": entity_id }),
@@ -1981,3 +2052,450 @@ impl InvoiceNinjaClient {
self.post_raw("live_preview/purchase_order", body).await
}
}
/// Import, export, and the third-party integrations.
///
/// These are the least SDK-shaped part of the API, and they are typed
/// 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
/// matches `client_peppol_discovery`, which has returned `Value` since it
/// landed. Where a shape *is* stable and is the point of the call, it is
/// typed: [`ExportJob`] and [`ImportPreview`].
///
/// Every write here is a `POST`, `PUT`, or `DELETE`, so none is retried on a
/// 5xx. Several queue background work and answer `{"message": "Processing"}`
/// before anything has happened, so a success here means accepted, not done.
impl InvoiceNinjaClient {
/// Queues a full company export and returns the link to collect it from.
///
/// The work happens in a queued job, so this returns as soon as it is
/// accepted. See [`ExportJob`] for the expiry on the URL.
///
/// The body selects the format and date range (`send_email`, `report_keys`,
/// `start_date`, `end_date`); an empty object exports everything.
pub async fn export<B: Serialize>(&self, body: &B) -> Result<ExportJob> {
self.post_unwrapped("export", body).await
}
/// Uploads CSVs and returns the column mapping the server suggests —
/// the first half of a CSV import.
///
/// Each entry pairs an entity type (`client`, `invoice`, `product`,
/// `payment`, `expense`, `vendor`, …) with that entity's CSV, and goes
/// out as `files[<entity_type>]`. Take the `hash` off the result and pass
/// it to [`import`](Self::import) to start the ingest.
///
/// The server caches the uploaded files against that hash for 20 minutes,
/// so the second call has to follow reasonably promptly.
pub async fn preimport(&self, files: Vec<(String, FileUpload)>) -> Result<ImportPreview> {
if files.is_empty() {
return Err(Error::InvalidUpload("no files to import".into()));
}
let mut form = multipart::Form::new();
for (entity_type, file) in files {
form = form.part(format!("files[{entity_type}]"), file.into_part()?);
}
self.multipart_unwrapped("preimport", form).await
}
/// Starts a CSV import from a [`preimport`](Self::preimport) hash.
///
/// The body carries the `hash` plus the column mapping to use — the
/// shape `preimport` described. With a hash present the server never
/// looks at uploaded files, so this goes out as ordinary JSON.
///
/// Only this two-phase form is wrapped. The route also accepts the files
/// and the mapping together in one multipart request, but a mapping is a
/// nested structure, and bracket-encoding nested structures into form
/// fields is the thing this crate avoids by sending JSON everywhere else.
/// Call `preimport` first instead.
///
/// The ingest is queued, so a success means accepted. Rows that fail to
/// import are reported by email, not here.
pub async fn import<B: Serialize>(&self, body: &B) -> Result<MessageResponse> {
self.post_unwrapped("import", body).await
}
/// Restores a company from a `.zip` backup produced by
/// [`export`](Self::export).
///
/// One file, sent as `files`. The reply's `success` flag and any
/// `metadata` the server attaches arrive in
/// [`MessageResponse::extra`](crate::models::MessageResponse).
///
/// **This overwrites the target company.** The route exists for
/// migrations, not for merging two datasets.
pub async fn import_json(&self, file: FileUpload) -> Result<MessageResponse> {
let form = multipart::Form::new().part("files", file.into_part()?);
self.multipart_unwrapped("import_json", form).await
}
/// Imports expenses from e-documents (an XML invoice, or a PDF with one
/// embedded).
///
/// Not a document attachment, which is why it is not one of the
/// `upload_*` methods: the files are parsed and become *expenses*, and
/// nothing is attached to the record you might expect. The route is
/// registered as `PUT`, so this goes out as a `POST` with `_method=PUT`
/// for the usual multipart reason.
///
/// Parsing is queued per file, so the reply says `Processing....` and a
/// file that fails to parse is not reported here.
pub async fn upload_edocument(&self, files: Vec<FileUpload>) -> Result<MessageResponse> {
if files.is_empty() {
return Err(Error::InvalidUpload("no files to upload".into()));
}
let mut form = multipart::Form::new().text("_method", "PUT");
for file in files {
form = form.part("documents[]", file.into_part()?);
}
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<B: Serialize>(&self, body: &B) -> Result<Value> {
self.post_unwrapped("einvoice/validateEntity", body).await
}
/// Updates the company's e-invoice payment means and other Peppol
/// configuration.
pub async fn einvoice_configurations<B: Serialize>(&self, body: &B) -> Result<Value> {
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<Value> {
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<Value> {
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<B: Serialize>(&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<B: Serialize>(&self, body: &B) -> Result<Value> {
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<B: Serialize>(&self, body: &B) -> Result<Value> {
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<B: Serialize>(&self, body: &B) -> Result<Option<Value>> {
self.post_unwrapped_opt("einvoice/peppol/setup", body).await
}
/// Deregisters the company from Peppol. Answers an empty array.
pub async fn peppol_disconnect<B: Serialize>(&self, body: &B) -> Result<Value> {
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<B: Serialize>(&self, body: &B) -> Result<Value> {
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<B: Serialize>(
&self,
body: &B,
) -> Result<MessageResponse> {
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<B: Serialize>(&self, body: &B) -> Result<Value> {
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<B: Serialize>(&self, body: &B) -> Result<MessageResponse> {
self.post_unwrapped("einvoice/peppol/send", body).await
}
/// Activates Singapore's C5 (InvoiceNow) peer.
pub async fn peppol_sg_c5_activate<B: Serialize>(&self, body: &B) -> Result<MessageResponse> {
self.post_unwrapped("einvoice/peppol/sg/c5/activate", body)
.await
}
/// Deactivates Singapore's C5 peer.
pub async fn peppol_sg_c5_deactivate<B: Serialize>(&self, body: &B) -> Result<MessageResponse> {
self.post_unwrapped("einvoice/peppol/sg/c5/deactivate", body)
.await
}
/// Cancels a pending Singapore C5 registration.
pub async fn peppol_sg_c5_cancel<B: Serialize>(&self, body: &B) -> Result<MessageResponse> {
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<B: Serialize>(&self, body: &B) -> Result<()> {
self.post_discard("quickbooks/sync", body).await
}
/// Runs a single QuickBooks action. Answers `204`.
pub async fn quickbooks_action<B: Serialize>(&self, body: &B) -> Result<()> {
self.post_discard("quickbooks/action", body).await
}
/// Disconnects the QuickBooks integration. Answers `204`.
pub async fn quickbooks_disconnect<B: Serialize>(&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<Value> {
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<MessageResponse> {
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<MessageResponse> {
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<Value> {
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<Value> {
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<Value> {
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<B: Serialize>(
&self,
provider: &str,
body: &B,
) -> Result<Value> {
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<Value> {
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<Value> {
// 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<B: Serialize>(
&self,
body: &B,
) -> Result<Value> {
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<Value> {
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<Value> {
self.get_unwrapped("nordigen/institutions").await
}
}
+51
View File
@@ -0,0 +1,51 @@
//! The company import and export jobs.
//!
//! Both endpoints queue work rather than doing it, so neither reply carries
//! the result — an export answers with the URL to fetch later, and an import
//! answers with nothing but an acknowledgement (see `MessageResponse`).
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
/// The reply to `POST /export`.
///
/// The export itself is queued, so `url` is a promise rather than a ready
/// file: fetching it before the job finishes gets nothing. The link points at
/// the `protected_download` route and expires — in one hour, or five if the
/// company has more than 10,000 activities — so it is worth following
/// promptly rather than storing.
///
/// `url` is the only reason to call this endpoint, which is why it is typed
/// rather than left in `extra`.
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct ExportJob {
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub message: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub url: String,
#[serde(flatten)]
pub extra: Map<String, Value>,
}
/// The reply to `POST /preimport` — the first half of a CSV import.
///
/// `hash` is the handle to the uploaded files, which the server caches for 20
/// minutes. Pass it back in the `import` body to start the ingest; without it
/// the files would have to be uploaded again.
///
/// `mappings` is keyed by entity type (`client`, `invoice`, …), one entry per
/// file uploaded. Each value describes what that file's columns could map to:
/// `available` lists the importable fields, `headers` holds the first two
/// rows, and `hints` is the server's guess at a column mapping. Those three
/// stay `Value` because the upstream shapes come from each entity's own
/// `importable()` map and are not documented; a wrong concrete type here
/// would be a hard decode error, where `Value` is lossless.
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct ImportPreview {
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub hash: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub mappings: Map<String, Value>,
#[serde(flatten)]
pub extra: Map<String, Value>,
}
+2
View File
@@ -14,6 +14,7 @@ mod expense;
mod expense_category;
mod group_setting;
mod health;
mod import_export;
mod invoice;
mod location;
mod payment;
@@ -54,6 +55,7 @@ pub use expense::Expense;
pub use expense_category::ExpenseCategory;
pub use group_setting::GroupSetting;
pub use health::{HealthCheck, PhpVersion};
pub use import_export::{ExportJob, ImportPreview};
pub use invoice::{Credit, Invoice, InvoiceItem, Quote};
pub use location::Location;
pub use payment::{Payment, PaymentInvoice};
+566
View File
@@ -2365,3 +2365,569 @@ async fn an_empty_invitation_key_is_refused_before_the_request() {
assert!(matches!(err, Error::InvalidPathSegment(_)), "got {err:?}");
assert!(server.received_requests().await.unwrap().is_empty());
}
// --- import and export --------------------------------------------------
#[tokio::test]
async fn export_posts_and_returns_the_collection_url() {
let server = route_server(
"POST",
"/export",
json!({ "message": "Processing", "url": "https://x.test/protected_download/abc" }),
)
.await;
let job = client(&server)
.export(&json!({ "send_email": false }))
.await
.expect("export should be accepted");
assert_eq!(job.message, "Processing");
assert_eq!(job.url, "https://x.test/protected_download/abc");
}
#[tokio::test]
async fn preimport_sends_each_csv_under_its_entity_type() {
let server = MockServer::start().await;
Mock::given(path("/preimport"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"hash": "h1",
"mappings": {
"client": { "available": { "name": "Name" }, "headers": [["name"]], "hints": [0] }
}
})))
.mount(&server)
.await;
let preview = client(&server)
.preimport(vec![
("client".to_string(), pdf("clients.csv")),
("invoice".to_string(), pdf("invoices.csv")),
])
.await
.expect("preimport should succeed");
assert_eq!(preview.hash, "h1");
assert!(preview.mappings.contains_key("client"));
let sent = &server.received_requests().await.unwrap()[0];
assert_eq!(sent.method.as_str(), "POST");
let body = body_text(sent);
// Keyed by entity type, not `documents[]` — a different field name from
// the `/{id}/upload` routes.
assert!(
body.contains("name=\"files[client]\"; filename=\"clients.csv\""),
"body: {body}"
);
assert!(
body.contains("name=\"files[invoice]\"; filename=\"invoices.csv\""),
"body: {body}"
);
assert!(!body.contains("_method"), "preimport is a real POST");
}
#[tokio::test]
async fn preimport_with_no_files_is_refused_before_the_request() {
let server = MockServer::start().await;
Mock::given(path("/preimport"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({ "hash": "h1" })))
.mount(&server)
.await;
let err = client(&server)
.preimport(vec![])
.await
.expect_err("an empty import must not be sent");
assert!(matches!(err, Error::InvalidUpload(_)), "got {err:?}");
assert!(server.received_requests().await.unwrap().is_empty());
}
/// The second phase is ordinary JSON, not multipart: the hash stands in for
/// the files, so there is nothing to encode as a form.
#[tokio::test]
async fn import_sends_json_rather_than_a_form() {
let server = route_server("POST", "/import", json!({ "message": "Import started" })).await;
let got = client(&server)
.import(&json!({ "hash": "h1", "column_map": { "client": { "mapping": {} } } }))
.await
.expect("import should be accepted");
assert_eq!(got.message, "Import started");
let sent = &server.received_requests().await.unwrap()[0];
let content_type = sent.headers.get("content-type").unwrap().to_str().unwrap();
assert_eq!(content_type, "application/json");
assert_eq!(
sent.body_json::<serde_json::Value>().unwrap()["hash"],
"h1",
"the hash is what stands in for the upload"
);
}
#[tokio::test]
async fn import_json_sends_one_file_named_files() {
let server = route_server(
"POST",
"/import_json",
json!({ "message": "Processing", "success": true }),
)
.await;
let got = client(&server)
.import_json(pdf("backup.zip"))
.await
.expect("import_json should be accepted");
assert_eq!(got.message, "Processing");
assert_eq!(
got.extra["success"], true,
"the success flag is kept in extra"
);
let body = body_text(&server.received_requests().await.unwrap()[0]);
assert!(
body.contains("name=\"files\"; filename=\"backup.zip\""),
"singular `files`, not `files[]`: body: {body}"
);
}
#[tokio::test]
async fn upload_edocument_is_a_post_that_spoofs_put() {
let server = route_server(
"POST",
"/edocument/upload",
json!({ "message": "Processing...." }),
)
.await;
let got = client(&server)
.upload_edocument(vec![pdf("einvoice.xml")])
.await
.expect("edocument upload should be accepted");
assert_eq!(got.message, "Processing....");
let sent = &server.received_requests().await.unwrap()[0];
assert_eq!(
sent.method.as_str(),
"POST",
"the route is a PUT, but a real PUT drops the files"
);
let body = body_text(sent);
assert!(body.contains("name=\"_method\"\r\n\r\nPUT"), "body: {body}");
assert!(
body.contains("name=\"documents[]\"; filename=\"einvoice.xml\""),
"body: {body}"
);
}
#[tokio::test]
async fn edocument_uploads_are_never_replayed() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(500))
.mount(&server)
.await;
client(&server)
.with_retry_policy(quick_retry())
.upload_edocument(vec![pdf("einvoice.xml")])
.await
.expect_err("500");
assert_eq!(
server.received_requests().await.unwrap().len(),
1,
"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")
);
+2
View File
@@ -66,7 +66,9 @@ fn every_model_absorbs_a_null_or_missing_field() {
Document,
Expense,
ExpenseCategory,
ExportJob,
GroupSetting,
ImportPreview,
Invoice,
InvoiceItem,
Location,