Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
446617a728 | ||
|
|
c4f31743f0 |
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>,
|
||||
}
|
||||
@@ -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};
|
||||
|
||||
@@ -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")
|
||||
);
|
||||
|
||||
@@ -66,7 +66,9 @@ fn every_model_absorbs_a_null_or_missing_field() {
|
||||
Document,
|
||||
Expense,
|
||||
ExpenseCategory,
|
||||
ExportJob,
|
||||
GroupSetting,
|
||||
ImportPreview,
|
||||
Invoice,
|
||||
InvoiceItem,
|
||||
Location,
|
||||
|
||||
Reference in New Issue
Block a user