Compare commits
2
Commits
e6e41fe5c6
...
446617a728
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
446617a728 | ||
|
|
c4f31743f0 |
@@ -9,6 +9,33 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
### Added
|
### 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:
|
- Invitation-key downloads, eight methods:
|
||||||
`download_invoice_by_invitation_key` and
|
`download_invoice_by_invitation_key` and
|
||||||
`download_e_invoice_by_invitation_key`, plus the same pair for quotes,
|
`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
|
refunds, ping/health) are hand-written in the last `impl` block rather than
|
||||||
macro-generated, because their request and response shapes are all
|
macro-generated, because their request and response shapes are all
|
||||||
different.
|
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
|
- `list_*` returns one page; `list_all_*` goes through `get_all_pages`, which
|
||||||
terminates on `meta.pagination.total_pages` and only falls back to the
|
terminates on `meta.pagination.total_pages` and only falls back to the
|
||||||
short-page heuristic when the server sends no metadata. **The heuristic is
|
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
|
## Coverage
|
||||||
|
|
||||||
**All 34 of the API's `Route::resource` groups are wrapped**, plus `users`
|
**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
|
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
|
Plus the non-CRUD surface: documents, entity actions and PDF downloads for
|
||||||
the four action-capable resources, their invitation-key PDF and e-document
|
the four action-capable resources, their invitation-key PDF and e-document
|
||||||
downloads, `report`, `chart`, `search`, `statics`, `send_email`,
|
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
|
**`COVERAGE.md` is the detailed inventory** — every wrapped endpoint, every
|
||||||
gap, and the reason for each gap. The main remaining gaps are the
|
gap, and the reason for each gap.
|
||||||
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.
|
|
||||||
|
|
||||||
Keep `COVERAGE.md` current when adding or removing an endpoint: it names
|
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
|
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.
|
> exists or what it returns.
|
||||||
|
|
||||||
**All 34 `Route::resource` groups are now wrapped**, plus `users` (which is
|
**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
|
## 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
|
for model` text a missing record produces elsewhere, but the status is the
|
||||||
same 400.
|
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
|
## Document uploads
|
||||||
|
|
||||||
`PUT /{resource}/{id}/upload` exists for 16 resources, each getting
|
`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
|
### Nothing is blocked on a dependency
|
||||||
|
|
||||||
Multipart uploads used to be, and are now wrapped — see [Document
|
Multipart uploads used to be, and are now wrapped — see [Document
|
||||||
uploads](#document-uploads). One upload route is deliberately left out:
|
uploads](#document-uploads) and [Import and
|
||||||
`PUT /edocument/upload` is not a document attachment. It imports an expense
|
export](#import-and-export).
|
||||||
from an e-document (`ExpenseController::edocument`), and is better treated as
|
|
||||||
an import endpoint alongside the others under *Import/export* below.
|
|
||||||
|
|
||||||
### Resource groups not wrapped
|
### Resource groups not wrapped
|
||||||
|
|
||||||
@@ -383,8 +493,8 @@ check `routes/api.php`).
|
|||||||
`POST /user/{id}/reconfirm`
|
`POST /user/{id}/reconfirm`
|
||||||
- **Auth** — `/login`, `/logout`, `/refresh`, `/connected_account`,
|
- **Auth** — `/login`, `/logout`, `/refresh`, `/connected_account`,
|
||||||
passkey routes
|
passkey routes
|
||||||
- **Import/export** — `/import`, `/preimport`, `/import_json`, `/export`,
|
- **Migration** — `/migration/start`. Moves a company between instances and
|
||||||
`/migration/start`
|
is driven by the v4 migration tool, not by an SDK caller.
|
||||||
- **Invoice extras** — `delivery_note`, `payment_schedule`,
|
- **Invoice extras** — `delivery_note`, `payment_schedule`,
|
||||||
`update_reminders`
|
`update_reminders`
|
||||||
- **Client-portal invitation downloads** — the `routes/client.php` family:
|
- **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
|
don't fit a `base_url` that ends there. Note these are *not* the
|
||||||
invitation-key downloads documented above, which are authenticated
|
invitation-key downloads documented above, which are authenticated
|
||||||
`/api/v1` routes and are wrapped.
|
`/api/v1` routes and are wrapped.
|
||||||
- **Integrations** — Stripe, QuickBooks, Apple in-app purchase, PostMark,
|
- **Integration routes that cannot take an API token** — the OAuth legs of
|
||||||
einvoice (17 routes), calendar_connection, self-update, templates, hooks,
|
the QuickBooks and calendar flows (`/quickbooks/authorize/{token}`,
|
||||||
Yodlee (`/yodlee/*`), Nordigen (`/nordigen/institutions`)
|
`/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`,
|
- **Misc** — `/company_users`, `/company_ledger`, `/client_statement`,
|
||||||
`/claim_license`, `/check_license`, `/check_subdomain`, `/one_time_token`,
|
`/claim_license`, `/check_license`, `/check_subdomain`, `/one_time_token`,
|
||||||
`/last_error`, `/support/messages/send`, `/verify/*`, `/accounts/{id}`,
|
`/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`,
|
each has a generic counterpart — `statics_as`, `search_as`,
|
||||||
`health_check_as` — for pinning a shape they leave untyped.
|
`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:
|
Two remain generic on purpose:
|
||||||
|
|
||||||
- **`chart`** — the eleven chart endpoints share no common response shape
|
- **`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
|
## Roadmap: closing the coverage gaps
|
||||||
|
|
||||||
All 34 `Route::resource` groups are wrapped. Full coverage is the target, so
|
**This roadmap is now closed** — every item below is done, and every route an
|
||||||
`COVERAGE.md`'s gap list is a to-do list, not a permanent boundary. Roughly
|
API-token client can reach is wrapped. It is kept rather than deleted because
|
||||||
in order of value over effort:
|
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
|
**Done:** multipart document uploads, for the 16 resources that have the
|
||||||
route (`upload_client`, `upload_invoice`, …). This earlier claimed it would
|
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
|
family in `routes/client.php`, which is genuinely token-less, is not under
|
||||||
`/api/v1`, and stays unwrapped for that reason.
|
`/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`,
|
As the item also predicted, it is the least SDK-shaped part of the API, and
|
||||||
`edocument/upload`, Stripe, QuickBooks, einvoice (17 routes), Yodlee,
|
the typing reflects that: most of these return `serde_json::Value`, because
|
||||||
Nordigen, calendar. The largest block and the least SDK-shaped; worth
|
their replies are pass-throughs from Storecove, Stripe, Yodlee or GoCardless,
|
||||||
doing last and possibly in pieces.
|
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
|
Two things in this block are *not* wrapped, and cannot be. The OAuth legs of
|
||||||
stale quietly.
|
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
|
## Open questions
|
||||||
|
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ IN_API_TOKEN=... cargo run --example list_clients
|
|||||||
|
|
||||||
## Coverage
|
## 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
|
resource has `list`, `list_all`, `get`, `create`, `update`, `delete`, and
|
||||||
(where the API offers one) `bulk`:
|
(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 percent_encoding::{utf8_percent_encode, AsciiSet, NON_ALPHANUMERIC};
|
||||||
use reqwest::{header, multipart, Method, StatusCode};
|
use reqwest::{header, multipart, Method, StatusCode};
|
||||||
use serde::{de::DeserializeOwned, Serialize};
|
use serde::{de::DeserializeOwned, Serialize};
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
use crate::error::{ApiErrorBody, Error, Result};
|
use crate::error::{ApiErrorBody, Error, Result};
|
||||||
use crate::models::{
|
use crate::models::{
|
||||||
Activity, ActivityFeedEntry, BankIntegration, BankTransaction, BankTransactionRule,
|
Activity, ActivityFeedEntry, BankIntegration, BankTransaction, BankTransactionRule,
|
||||||
BulkRequest, Client as ClientModel, ClientGatewayToken, Company, CompanyGateway, CompanyToken,
|
BulkRequest, Client as ClientModel, ClientGatewayToken, Company, CompanyGateway, CompanyToken,
|
||||||
Credit, Design, Document, Expense, ExpenseCategory, GroupSetting, HealthCheck, Invoice,
|
Credit, Design, Document, Expense, ExpenseCategory, ExportJob, GroupSetting, HealthCheck,
|
||||||
ItemResponse, ListParams, ListResponse, Location, MessageResponse, Payment, PaymentTerm,
|
ImportPreview, Invoice, ItemResponse, ListParams, ListResponse, Location, MessageResponse,
|
||||||
Product, Project, PurchaseOrder, Quote, RecurringExpense, RecurringInvoice, RecurringQuote,
|
Payment, PaymentTerm, Product, Project, PurchaseOrder, Quote, RecurringExpense,
|
||||||
SearchResults, Statics, Subscription, SubscriptionStep, SystemLog, Tag, Task, TaskScheduler,
|
RecurringInvoice, RecurringQuote, SearchResults, Statics, Subscription, SubscriptionStep,
|
||||||
TaskStatus, TaxRate, User, Vendor, Webhook,
|
SystemLog, Tag, Task, TaskScheduler, TaskStatus, TaxRate, User, Vendor, Webhook,
|
||||||
};
|
};
|
||||||
use crate::retry::{self, RetryPolicy};
|
use crate::retry::{self, RetryPolicy};
|
||||||
use crate::upload::FileUpload;
|
use crate::upload::FileUpload;
|
||||||
@@ -629,6 +630,80 @@ impl InvoiceNinjaClient {
|
|||||||
Ok(body.data)
|
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.
|
/// Issues a request whose body is discarded, failing on non-2xx.
|
||||||
async fn get_discard(&self, path: &str) -> Result<()> {
|
async fn get_discard(&self, path: &str) -> Result<()> {
|
||||||
self.get_raw(path).await.map(|_| ())
|
self.get_raw(path).await.map(|_| ())
|
||||||
@@ -1242,7 +1317,7 @@ impl InvoiceNinjaClient {
|
|||||||
/// eleven charts share no common response shape — `totals_v2` answers
|
/// eleven charts share no common response shape — `totals_v2` answers
|
||||||
/// with per-currency maps, `cashflow_forecast` with a date series,
|
/// with per-currency maps, `cashflow_forecast` with a date series,
|
||||||
/// `calculated_fields` with scalars — so there is nothing to narrow to.
|
/// `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.
|
/// calling.
|
||||||
///
|
///
|
||||||
/// `project_analytics` and `project_burnup` take a project id as a
|
/// `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
|
/// 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.
|
/// 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(
|
self.post_unwrapped(
|
||||||
&format!("clients/{}/show_settings", seg(id)?),
|
&format!("clients/{}/show_settings", seg(id)?),
|
||||||
&serde_json::json!({}),
|
&serde_json::json!({}),
|
||||||
@@ -1704,7 +1779,7 @@ impl InvoiceNinjaClient {
|
|||||||
|
|
||||||
/// Looks the client up on the Peppol network. Left as raw JSON: the
|
/// Looks the client up on the Peppol network. Left as raw JSON: the
|
||||||
/// controller returns whatever the discovery service produced.
|
/// 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(
|
self.post_unwrapped(
|
||||||
&format!("clients/{}/peppol_discovery", seg(id)?),
|
&format!("clients/{}/peppol_discovery", seg(id)?),
|
||||||
&serde_json::json!({}),
|
&serde_json::json!({}),
|
||||||
@@ -1918,7 +1993,7 @@ impl InvoiceNinjaClient {
|
|||||||
/// message that has delivery events. Left as raw JSON, since the objects
|
/// message that has delivery events. Left as raw JSON, since the objects
|
||||||
/// mirror whatever the mail provider reported and no instance available
|
/// mirror whatever the mail provider reported and no instance available
|
||||||
/// here had sent mail to take a shape from.
|
/// 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(
|
self.post_unwrapped(
|
||||||
&format!("emails/clientHistory/{}", seg(client_id)?),
|
&format!("emails/clientHistory/{}", seg(client_id)?),
|
||||||
&serde_json::json!({}),
|
&serde_json::json!({}),
|
||||||
@@ -1929,11 +2004,7 @@ impl InvoiceNinjaClient {
|
|||||||
/// Email delivery history for one record. `entity` is `invoice`, `quote`,
|
/// Email delivery history for one record. `entity` is `invoice`, `quote`,
|
||||||
/// `credit`, `recurring_invoice`, or `purchase_order`. Raw JSON, as for
|
/// `credit`, `recurring_invoice`, or `purchase_order`. Raw JSON, as for
|
||||||
/// [`client_email_history`](Self::client_email_history).
|
/// [`client_email_history`](Self::client_email_history).
|
||||||
pub async fn entity_email_history(
|
pub async fn entity_email_history(&self, entity: &str, entity_id: &str) -> Result<Vec<Value>> {
|
||||||
&self,
|
|
||||||
entity: &str,
|
|
||||||
entity_id: &str,
|
|
||||||
) -> Result<Vec<serde_json::Value>> {
|
|
||||||
self.post_unwrapped(
|
self.post_unwrapped(
|
||||||
"emails/entityHistory",
|
"emails/entityHistory",
|
||||||
&serde_json::json!({ "entity": entity, "entity_id": entity_id }),
|
&serde_json::json!({ "entity": entity, "entity_id": entity_id }),
|
||||||
@@ -1981,3 +2052,450 @@ impl InvoiceNinjaClient {
|
|||||||
self.post_raw("live_preview/purchase_order", body).await
|
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 expense_category;
|
||||||
mod group_setting;
|
mod group_setting;
|
||||||
mod health;
|
mod health;
|
||||||
|
mod import_export;
|
||||||
mod invoice;
|
mod invoice;
|
||||||
mod location;
|
mod location;
|
||||||
mod payment;
|
mod payment;
|
||||||
@@ -54,6 +55,7 @@ pub use expense::Expense;
|
|||||||
pub use expense_category::ExpenseCategory;
|
pub use expense_category::ExpenseCategory;
|
||||||
pub use group_setting::GroupSetting;
|
pub use group_setting::GroupSetting;
|
||||||
pub use health::{HealthCheck, PhpVersion};
|
pub use health::{HealthCheck, PhpVersion};
|
||||||
|
pub use import_export::{ExportJob, ImportPreview};
|
||||||
pub use invoice::{Credit, Invoice, InvoiceItem, Quote};
|
pub use invoice::{Credit, Invoice, InvoiceItem, Quote};
|
||||||
pub use location::Location;
|
pub use location::Location;
|
||||||
pub use payment::{Payment, PaymentInvoice};
|
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!(matches!(err, Error::InvalidPathSegment(_)), "got {err:?}");
|
||||||
assert!(server.received_requests().await.unwrap().is_empty());
|
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,
|
Document,
|
||||||
Expense,
|
Expense,
|
||||||
ExpenseCategory,
|
ExpenseCategory,
|
||||||
|
ExportJob,
|
||||||
GroupSetting,
|
GroupSetting,
|
||||||
|
ImportPreview,
|
||||||
Invoice,
|
Invoice,
|
||||||
InvoiceItem,
|
InvoiceItem,
|
||||||
Location,
|
Location,
|
||||||
|
|||||||
Reference in New Issue
Block a user