Files
invoiceninja-sdk/README.md
T
awkawbandClaude Opus 5 e4f2373b53 Add multipart document uploads for the 16 resources that have the route
First item on the GOALS.md roadmap. `upload_{x}(id, files, is_public)`
attaches files through `/{resource}/{id}/upload` and returns the
refreshed record. Verified against upstream that all 16 controllers
answer with `itemResponse($x->fresh())`.

Three details that look wrong but are not, each checked in the upstream
source rather than taken from the published spec:

- The request is a POST carrying `_method=PUT`. The route is registered
  PUT, but PHP does not parse a multipart body on a real PUT, so the
  files never arrive. Laravel's kernel calls
  `enableHttpMethodParameterOverride()`, turning the spoofed method back
  into a PUT before routing. The spec documents POST with `_method=POST`,
  which would 405 against these routes.
- `is_public` is sent as 1/0. Laravel's `boolean` rule accepts only
  true, false, 0, 1, '0' and '1' under strict comparison, so "true"
  fails validation.
- Uploads are never retried. An upload adds a document, so it is not
  idempotent whatever its verb, and a replay after a lost response would
  attach the file twice. This falls out of reqwest's multipart body being
  un-cloneable, which nothing in the types enforces, so a test pins it
  for 500, 503 and 429.

An upload with no files, a nameless file or an unparseable MIME type is
refused locally (`Error::InvalidUpload`), since the server answers an
empty upload with a success that changed nothing.

Corrects two earlier claims:

- GOALS.md and COVERAGE.md said uploads would make `create_document`
  possible. They do not: `DocumentController::store` is an empty stub,
  and an upload is the only way a document is created. There is no
  `create_document`.
- The documented method count (265) had been stale since the typed
  `statics`/`search`/`health_check` commit, which added three `_as`
  methods without updating it. Recounted from the source: 284 = 210 +
  18 + 12 + 16 uploads + 28 hand-written.

Also fixes a README example, `let statics: Value = client.statics()`,
that stopped compiling when `statics` lost its type parameter.

`edocument/upload` is left out: it imports an expense from an
e-document rather than attaching a file, so it moves to the
import/export roadmap item.

Enabling `multipart` adds mime, mime_guess and unicase; MSRV stays 1.88,
re-verified by running the suite on it. 101 tests, up from 91.

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

6.6 KiB

invoiceninjars

An async Rust client for the Invoice Ninja 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 covers what the crate is for and what it is not.

Development happens on Gitea; the GitHub copy 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):

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:

nix develop .#msrv --command cargo test

Usage

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:

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:

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:

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):

IN_API_TOKEN=... cargo run --example list_clients

Coverage

Every one of the API's 34 resource groups, plus users — 284 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, reports, charts, search, statics, email sending, refunds, and health checks.

See 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:

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:

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:

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:

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:

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.