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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-24 23:24:10 -04:00

193 lines
6.6 KiB
Markdown

# invoiceninjars
An async Rust client for the [Invoice Ninja](https://www.invoiceninja.com/) v5 API.
Requires Rust 1.88 or newer.
> **Pre-1.0: no API stability promise.** Pin an exact version and expect
> breaking changes in minor releases; `CHANGELOG.md` records them.
> [`GOALS.md`](GOALS.md) covers what the crate is for and what it is not.
> **Development happens on
> [Gitea](https://gitea.awkawb.cloud/awkawb/invoiceninja-sdk); the
> [GitHub copy](https://github.com/awkawb1/invoiceninja-sdk) is a read-only
> mirror.** Anything pushed to GitHub is overwritten by the next sync, so
> please open issues and pull requests on Gitea.
## Development
Enter the dev shell (Rust toolchain via `rust-overlay`):
```sh
nix develop
```
The dev toolchain is pinned in `flake.nix` rather than tracking latest
stable, so it only moves deliberately. To check the crate still builds on
the declared minimum of 1.88:
```sh
nix develop .#msrv --command cargo test
```
## Usage
```rust
use invoiceninjars::{InvoiceNinjaClient, models::ListParams};
// Hosted app:
let client = InvoiceNinjaClient::hosted(std::env::var("IN_API_TOKEN")?)?;
// Self-hosted — note the base URL must include the `/api/v1` suffix:
let client = InvoiceNinjaClient::new(
"https://your-domain.example/api/v1",
std::env::var("IN_API_TOKEN")?,
)?;
let clients = client.list_clients(&ListParams::new().per_page(50)).await?;
```
`ListParams` has typed setters for the parameters every list endpoint
accepts, plus `param` for the per-resource filters:
```rust
let unpaid = client
.list_invoices(
&ListParams::new()
.per_page(100)
.sort("date|desc")
.param("client_id", "abc123")
.param("client_status", "unpaid"),
)
.await?;
```
List endpoints are paginated. `list_*` returns a single page; `list_all_*`
follows pagination and returns every matching record:
```rust
let all_clients = client.list_all_clients(&ListParams::new()).await?;
```
Every request carries a 60-second deadline by default, since `reqwest`
applies none of its own, and transient failures are retried up to 3 times
with exponential backoff. Both are configurable:
```rust
use std::time::Duration;
use invoiceninjars::RetryPolicy;
let client = InvoiceNinjaClient::hosted(token)?
.with_timeout(Duration::from_secs(300))
.with_retry_policy(RetryPolicy::new(5));
```
Note that the timeout applies to each attempt, not to the call as a whole;
`RetryPolicy::max_elapsed` (120 seconds by default) is what bounds the total.
Retrying is deliberately conservative about `POST`. A `POST` that fails with
a 5xx or times out is **not** replayed, because the server may have applied
it before the response was lost and a repeat would create a second invoice
or payment. A 429 is retried for any method, since the server states it
refused the request without processing it. Pass `RetryPolicy::none()` to
disable retrying entirely.
Run the example (requires `IN_API_TOKEN`, optionally `IN_BASE_URL`):
```sh
IN_API_TOKEN=... cargo run --example list_clients
```
## Coverage
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`:
| Area | Resources |
| --- | --- |
| Billing | Clients, Invoices, Quotes, Credits, Payments, Products, Purchase Orders |
| Recurring | Recurring Invoices, Recurring Quotes, Recurring Expenses, Subscriptions |
| Work | Projects, Tasks, Task Statuses, Task Schedulers |
| Spend | Expenses, Expense Categories, Vendors, Bank Transactions |
| Banking | Bank Integrations, Bank Transaction Rules, Company Gateways, Client Gateway Tokens |
| Admin | Companies, Users, Group Settings, Tokens, System Logs |
| Settings | Tax Rates, Payment Terms, Webhooks, Designs, Locations, Tags |
Plus documents, entity actions and PDF downloads — by record id, or by
invitation key for the PDF and XML a given contact receives — reports,
charts, search, statics, email sending, refunds, and health checks.
**See [COVERAGE.md](COVERAGE.md) for the full endpoint-by-endpoint
inventory**, including everything that is deliberately *not* wrapped and why.
Models cover a curated subset of each schema; any field the API returns that
isn't modeled explicitly (including `custom_value1..4` and
`custom_surcharge1..4`) is preserved in that model's `extra` map, so a `get`
followed by an `update` round-trips without dropping data.
### Beyond CRUD
Bulk actions return the affected records. They're available on every
resource the API gives a `/bulk` route — all but companies, system logs, and
client gateway tokens:
```rust
let archived = client.bulk_invoices("archive", &["abc123", "def456"]).await?;
```
Some endpoints — reading or writing an individual user, `/users/bulk`,
purging a client or company — sit behind the API's `password_protected`
middleware and need the account password on top of a valid token:
```rust
let client = InvoiceNinjaClient::hosted(std::env::var("IN_API_TOKEN")?)?
.with_api_password(std::env::var("IN_API_PASSWORD")?)?;
let user = client.get_user("abc123").await?;
```
Invoices, quotes, credits, and purchase orders also support per-entity
actions and PDF rendering:
```rust
let invoice = client.mark_invoice_sent("abc123").await?;
let pdf: Vec<u8> = client.download_invoice("abc123").await?;
// The bulk `download` action streams one merged PDF. Available for quotes,
// credits, and purchase orders too.
let merged: Vec<u8> = client.download_invoices(&["abc123", "def456"]).await?;
```
Documents are attached through the record they belong to, since that is the
only way the API creates one. Sixteen resources support it, as
`upload_client`, `upload_invoice`, `upload_expense`, and so on:
```rust
use invoiceninjars::FileUpload;
let receipt = FileUpload::new("receipt.pdf", std::fs::read("receipt.pdf")?)
.mime("application/pdf");
// `Some(true)` makes the document visible to the client in the portal.
let expense = client.upload_expense("abc123", vec![receipt], Some(false)).await?;
```
Files are held in memory while uploading, and an upload is never retried,
since a replay after a lost response would attach the file twice.
Reports return their raw export body (CSV for most of them). `statics`,
`search`, and `health_check` return typed results; `chart` has no common
response shape, so it decodes into any type you choose:
```rust
use serde_json::{json, Value};
let csv: Vec<u8> = client.report("invoices", &json!({"date_range": "this_year"})).await?;
let totals: Value = client.chart("totals_v2", &json!({})).await?;
let statics = client.statics().await?;
```
Add more resources by adding a model under `src/models/` and a
`resource_methods!` invocation in `src/client.rs`.