Initial commit: async Rust client for the Invoice Ninja v5 API
CI / test (push) Canceled after 0s

Covers Clients, Invoices, Quotes, Credits, Payments, Products, Expenses,
Projects, Tasks, and Vendors, each with list, list_all, get, create,
update, and delete, generated by the resource_methods! macro.

Every model carries a #[serde(flatten)] extra map so fields the API
returns but the crate does not model survive a get/update round-trip
instead of being silently dropped.

Includes a Nix dev shell (Rust via rust-overlay), a GitHub Actions CI
workflow running build/test/fmt/clippy, and round-trip tests for the
extra-preservation invariant.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-10-23 14:30:01 -04:00
co-authored by Claude Opus 5
commit ff9a859d0b
25 changed files with 2544 additions and 0 deletions
+18
View File
@@ -0,0 +1,18 @@
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
[*.rs]
indent_style = space
indent_size = 4
[*.{yml,yaml,toml,nix}]
indent_style = space
indent_size = 2
[*.md]
trim_trailing_whitespace = false
+22
View File
@@ -0,0 +1,22 @@
name: CI
on:
push:
branches: [main]
pull_request:
env:
CARGO_TERM_COLOR: always
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt, clippy
- run: cargo build --all-targets
- run: cargo test
- run: cargo fmt --check
- run: cargo clippy --all-targets -- -D warnings
+4
View File
@@ -0,0 +1,4 @@
/target
.devenv/
.direnv/
*.swp
+72
View File
@@ -0,0 +1,72 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## What this is
An async Rust client for the [Invoice Ninja](https://www.invoiceninja.com/) v5 API.
## Development environment
This project uses Nix for toolchain management (Rust via `rust-overlay`):
```sh
nix develop
```
## Common commands
```sh
cargo build
cargo test
cargo test <test_name> # run a single test
cargo check
cargo clippy
cargo fmt
# Run the example (requires IN_API_TOKEN, optionally IN_BASE_URL)
IN_API_TOKEN=... cargo run --example list_clients
```
Tests live in `tests/` (integration-style, no network access required);
`src/` has no `#[test]` modules.
## Architecture
- `src/client.rs` — `InvoiceNinjaClient` holds a `reqwest::Client` (with the
`X-Api-Token` auth header baked in) and a `base_url`. Low-level helpers
(`get_list`, `get_one`, `post_one`, `put_one`, `delete_one`) handle request
dispatch and decode `ListResponse<T>` / `ItemResponse<T>` envelopes, mapping
non-2xx responses through `api_error` into `Error::Api`.
- Per-resource methods (`list_clients`, `get_invoice`, `create_product`, etc.)
are generated by the `resource_methods!` macro at the bottom of
`client.rs`, which expands to
`list`/`list_all`/`get`/`create`/`update`/`delete` for a given REST path and
model type. **To add a new resource**: add a model under `src/models/`,
re-export it from `src/models/mod.rs`, and add one `resource_methods!`
invocation in `client.rs`. Note the macro takes six method names before the
path and model — adding a resource means passing all six.
- `list_*` returns one page; `list_all_*` goes through `get_all_pages`, which
follows `page`/`per_page` until a page returns fewer rows than requested.
- `src/models/` — one file per resource (`client.rs`, `invoice.rs`,
`payment.rs`, `product.rs`, `expense.rs`, `project.rs`, `task.rs`,
`vendor.rs`), plus `common.rs` for shared envelope/pagination types
(`ListParams`, `ListResponse`, `ItemResponse`, `Meta`, `Pagination`).
Invoice Ninja stores quotes and credits in the same table as invoices (an
`entity_type` field distinguishes them), so `Quote` and `Credit` are type
aliases for `Invoice` (`src/models/invoice.rs`) and share the
`/quotes`, `/credits`, and `/invoices` paths via the macro.
- Every model struct has a `#[serde(flatten)] pub extra: Map<String, Value>`
field. This preserves any API fields not explicitly modeled, so a `get`
followed by an `update` round-trips without silently dropping data. Keep
this pattern when adding fields or new models.
- `src/error.rs` — `Error` enum (`thiserror`) covering transport, JSON
decode, API error responses (with per-field validation `errors` map), and
client construction errors. `ApiErrorBody` mirrors Invoice Ninja's error
JSON shape.
## Coverage
Clients, Invoices, Quotes, Credits, Payments, Products, Expenses, Projects,
Tasks, and Vendors, each with `list`, `list_all`, `get`, `create`, `update`,
and `delete`.
Generated
+1169
View File
File diff suppressed because it is too large Load Diff
+16
View File
@@ -0,0 +1,16 @@
[package]
name = "invoiceninjars"
version = "0.1.0"
edition = "2021"
description = "An async Rust client for the Invoice Ninja v5 API"
license = "MIT"
repository = "https://github.com/bryant/invoiceninjars"
[dependencies]
reqwest = { version = "0.12", default-features = false, features = ["json", "native-tls"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
thiserror = "2"
[dev-dependencies]
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 bryant
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+44
View File
@@ -0,0 +1,44 @@
# invoiceninjars
An async Rust client for the [Invoice Ninja](https://www.invoiceninja.com/) v5 API.
## Development
Enter the dev shell (Rust toolchain via `rust-overlay`):
```sh
nix develop
```
## Usage
```rust
use invoiceninjars::{InvoiceNinjaClient, models::ListParams};
let client = InvoiceNinjaClient::new(
"https://invoicing.co/api/v1", // or your self-hosted base URL
std::env::var("IN_API_TOKEN")?,
)?;
let clients = client.list_clients(&ListParams::new().per_page(50)).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?;
```
Run the example (requires `IN_API_TOKEN`, optionally `IN_BASE_URL`):
```sh
IN_API_TOKEN=... cargo run --example list_clients
```
## Coverage
Clients, Invoices, Quotes, Credits, Payments, Products, Expenses, Projects,
Tasks, and Vendors, each with `list`, `list_all`, `get`, `create`, `update`,
and `delete`. Add more resources by adding a model under `src/models/` and a
`resource_methods!` invocation in `src/client.rs`.
+21
View File
@@ -0,0 +1,21 @@
use invoiceninjars::models::ListParams;
use invoiceninjars::InvoiceNinjaClient;
#[tokio::main]
async fn main() -> invoiceninjars::Result<()> {
let base_url =
std::env::var("IN_BASE_URL").unwrap_or_else(|_| "https://invoicing.co/api/v1".into());
let token = std::env::var("IN_API_TOKEN").expect("set IN_API_TOKEN");
let client = InvoiceNinjaClient::new(base_url, token)?;
let clients = client
.list_clients(&ListParams::new().per_page(10).sort("created_at|desc"))
.await?;
for c in clients {
println!("{:?}\t{}", c.id, c.name);
}
Ok(())
}
Generated
+82
View File
@@ -0,0 +1,82 @@
{
"nodes": {
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1791914995,
"narHash": "sha256-Zf/xfQEAasnwnmEfHqW/4hjDE/s44yn2hD4RuVIZRjA=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "b7a79d3fea1e214f6d668d521cecec0c01e26544",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1792502884,
"narHash": "sha256-IoNisKcGbUjdd45qCqywGMCYmZ5EnkxezvLII0FWwNA=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "02c087f3fe673c9e317f206a627cb6b910336dbe",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs",
"rust-overlay": "rust-overlay"
}
},
"rust-overlay": {
"inputs": {
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1792739799,
"narHash": "sha256-M2rr/yPbTHvgAzCyZ+zHJHflFM0XU7t42Sw2skA3TQM=",
"owner": "oxalica",
"repo": "rust-overlay",
"rev": "b0829a2cc9f7b596583331d78a942c1e5170d8a0",
"type": "github"
},
"original": {
"owner": "oxalica",
"repo": "rust-overlay",
"type": "github"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
}
},
"root": "root",
"version": 7
}
+34
View File
@@ -0,0 +1,34 @@
{
description = "invoiceninjars: a Rust client for the Invoice Ninja v5 API";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
rust-overlay.url = "github:oxalica/rust-overlay";
rust-overlay.inputs.nixpkgs.follows = "nixpkgs";
flake-utils.url = "github:numtide/flake-utils";
};
outputs = { self, nixpkgs, rust-overlay, flake-utils, ... }:
flake-utils.lib.eachDefaultSystem (system:
let
pkgs = import nixpkgs {
inherit system;
overlays = [ rust-overlay.overlays.default ];
};
rustToolchain = pkgs.rust-bin.stable.latest.default.override {
extensions = [ "rust-src" "rust-analyzer" "clippy" "rustfmt" ];
};
in
{
devShells.default = pkgs.mkShell {
packages = [
rustToolchain
pkgs.pkg-config
pkgs.openssl
];
RUST_SRC_PATH = "${rustToolchain}/lib/rustlib/src/rust/library";
};
});
}
+300
View File
@@ -0,0 +1,300 @@
use reqwest::{header, StatusCode};
use serde::{de::DeserializeOwned, Serialize};
use crate::error::{ApiErrorBody, Error, Result};
use crate::models::{
Client as ClientModel, Credit, Expense, Invoice, ItemResponse, ListParams, ListResponse,
Payment, Product, Project, Quote, Task, Vendor,
};
/// An async client for the Invoice Ninja v5 API.
///
/// `base_url` should point at the API root, e.g. `https://invoicing.co/api/v1`
/// for the hosted app, or `https://your-domain.example/api/v1` for a
/// self-hosted install.
#[derive(Debug, Clone)]
pub struct InvoiceNinjaClient {
http: reqwest::Client,
base_url: String,
}
impl InvoiceNinjaClient {
pub fn new(base_url: impl Into<String>, api_token: impl Into<String>) -> Result<Self> {
let base_url = base_url.into();
if base_url.trim().is_empty() {
return Err(Error::InvalidBaseUrl(base_url));
}
let base_url = base_url.trim_end_matches('/').to_string();
let mut headers = header::HeaderMap::new();
let mut token_value = header::HeaderValue::from_str(&api_token.into())
.map_err(|e| Error::InvalidToken(e.to_string()))?;
token_value.set_sensitive(true);
headers.insert("X-Api-Token", token_value);
headers.insert(
header::ACCEPT,
header::HeaderValue::from_static("application/json"),
);
headers.insert(
"X-Requested-With",
header::HeaderValue::from_static("XMLHttpRequest"),
);
let http = reqwest::Client::builder()
.default_headers(headers)
.build()?;
Ok(Self { http, base_url })
}
fn url(&self, path: &str) -> String {
format!("{}/{}", self.base_url, path.trim_start_matches('/'))
}
async fn decode<T: DeserializeOwned>(response: reqwest::Response) -> Result<T> {
let status = response.status();
let bytes = response.bytes().await?;
if status.is_success() {
Ok(serde_json::from_slice(&bytes)?)
} else {
Err(Self::api_error(status, &bytes))
}
}
fn api_error(status: StatusCode, bytes: &[u8]) -> Error {
match serde_json::from_slice::<ApiErrorBody>(bytes) {
Ok(body) => Error::Api {
status,
message: body.message,
errors: body.errors,
},
Err(_) => Error::Api {
status,
message: String::from_utf8_lossy(bytes).into_owned(),
errors: Default::default(),
},
}
}
async fn get_list<T: DeserializeOwned>(
&self,
path: &str,
params: &ListParams,
) -> Result<Vec<T>> {
let response = self.http.get(self.url(path)).query(params).send().await?;
let body: ListResponse<T> = Self::decode(response).await?;
Ok(body.data)
}
/// Pages through a list endpoint, following `page`/`per_page` until a
/// page comes back shorter than requested (or empty).
async fn get_all_pages<T: DeserializeOwned>(
&self,
path: &str,
params: &ListParams,
) -> Result<Vec<T>> {
const DEFAULT_PAGE_SIZE: u64 = 50;
let per_page = params.per_page.unwrap_or(DEFAULT_PAGE_SIZE);
let mut page = params.page.unwrap_or(1);
let mut params = params.clone();
params.per_page = Some(per_page);
let mut all = Vec::new();
loop {
params.page = Some(page);
let response = self.http.get(self.url(path)).query(&params).send().await?;
let body: ListResponse<T> = Self::decode(response).await?;
let got = body.data.len() as u64;
all.extend(body.data);
if got < per_page {
break;
}
page += 1;
}
Ok(all)
}
async fn get_one<T: DeserializeOwned>(&self, path: &str) -> Result<T> {
let response = self.http.get(self.url(path)).send().await?;
let body: ItemResponse<T> = Self::decode(response).await?;
Ok(body.data)
}
async fn post_one<B: Serialize, T: DeserializeOwned>(
&self,
path: &str,
payload: &B,
) -> Result<T> {
let response = self.http.post(self.url(path)).json(payload).send().await?;
let body: ItemResponse<T> = Self::decode(response).await?;
Ok(body.data)
}
async fn put_one<B: Serialize, T: DeserializeOwned>(
&self,
path: &str,
payload: &B,
) -> Result<T> {
let response = self.http.put(self.url(path)).json(payload).send().await?;
let body: ItemResponse<T> = Self::decode(response).await?;
Ok(body.data)
}
async fn delete_one(&self, path: &str) -> Result<()> {
let response = self.http.delete(self.url(path)).send().await?;
let status = response.status();
let bytes = response.bytes().await?;
if status.is_success() {
Ok(())
} else {
Err(Self::api_error(status, &bytes))
}
}
}
/// Generates typed list/list_all/get/create/update/delete methods for a
/// resource backed by a single Invoice Ninja REST endpoint (e.g. `/clients`).
macro_rules! resource_methods {
($list:ident, $list_all:ident, $get:ident, $create:ident, $update:ident, $delete:ident, $path:literal, $model:ty) => {
pub async fn $list(&self, params: &ListParams) -> Result<Vec<$model>> {
self.get_list($path, params).await
}
/// Like the paginated list method, but automatically follows
/// pagination and returns every matching record across all pages.
pub async fn $list_all(&self, params: &ListParams) -> Result<Vec<$model>> {
self.get_all_pages($path, params).await
}
pub async fn $get(&self, id: &str) -> Result<$model> {
self.get_one(&format!("{}/{}", $path, id)).await
}
pub async fn $create(&self, body: &$model) -> Result<$model> {
self.post_one($path, body).await
}
pub async fn $update(&self, id: &str, body: &$model) -> Result<$model> {
self.put_one(&format!("{}/{}", $path, id), body).await
}
pub async fn $delete(&self, id: &str) -> Result<()> {
self.delete_one(&format!("{}/{}", $path, id)).await
}
};
}
impl InvoiceNinjaClient {
resource_methods!(
list_clients,
list_all_clients,
get_client,
create_client,
update_client,
delete_client,
"clients",
ClientModel
);
resource_methods!(
list_invoices,
list_all_invoices,
get_invoice,
create_invoice,
update_invoice,
delete_invoice,
"invoices",
Invoice
);
resource_methods!(
list_quotes,
list_all_quotes,
get_quote,
create_quote,
update_quote,
delete_quote,
"quotes",
Quote
);
resource_methods!(
list_credits,
list_all_credits,
get_credit,
create_credit,
update_credit,
delete_credit,
"credits",
Credit
);
resource_methods!(
list_payments,
list_all_payments,
get_payment,
create_payment,
update_payment,
delete_payment,
"payments",
Payment
);
resource_methods!(
list_products,
list_all_products,
get_product,
create_product,
update_product,
delete_product,
"products",
Product
);
resource_methods!(
list_expenses,
list_all_expenses,
get_expense,
create_expense,
update_expense,
delete_expense,
"expenses",
Expense
);
resource_methods!(
list_projects,
list_all_projects,
get_project,
create_project,
update_project,
delete_project,
"projects",
Project
);
resource_methods!(
list_tasks,
list_all_tasks,
get_task,
create_task,
update_task,
delete_task,
"tasks",
Task
);
resource_methods!(
list_vendors,
list_all_vendors,
get_vendor,
create_vendor,
update_vendor,
delete_vendor,
"vendors",
Vendor
);
}
+35
View File
@@ -0,0 +1,35 @@
use serde::Deserialize;
use std::collections::HashMap;
/// Error shape returned by the Invoice Ninja API for failed requests.
#[derive(Debug, Clone, Deserialize)]
pub struct ApiErrorBody {
#[serde(default)]
pub message: String,
#[serde(default)]
pub errors: HashMap<String, Vec<String>>,
}
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("http transport error: {0}")]
Http(#[from] reqwest::Error),
#[error("failed to decode JSON response: {0}")]
Json(#[from] serde_json::Error),
#[error("api returned {status}: {message}")]
Api {
status: reqwest::StatusCode,
message: String,
errors: HashMap<String, Vec<String>>,
},
#[error("invalid base url: {0}")]
InvalidBaseUrl(String),
#[error("invalid api token: {0}")]
InvalidToken(String),
}
pub type Result<T> = std::result::Result<T, Error>;
+22
View File
@@ -0,0 +1,22 @@
//! An async Rust client for the [Invoice Ninja](https://www.invoiceninja.com/) v5 API.
//!
//! ```no_run
//! use invoiceninjars::{InvoiceNinjaClient, models::ListParams};
//!
//! # async fn run() -> invoiceninjars::Result<()> {
//! let client = InvoiceNinjaClient::new(
//! "https://invoicing.co/api/v1",
//! std::env::var("IN_API_TOKEN").unwrap(),
//! )?;
//!
//! let clients = client.list_clients(&ListParams::new().per_page(50)).await?;
//! # Ok(())
//! # }
//! ```
mod client;
mod error;
pub mod models;
pub use client::InvoiceNinjaClient;
pub use error::{ApiErrorBody, Error, Result};
+72
View File
@@ -0,0 +1,72 @@
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Client {
#[serde(skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
#[serde(default)]
pub name: String,
#[serde(default)]
pub id_number: String,
#[serde(default)]
pub vat_number: String,
#[serde(default)]
pub address1: String,
#[serde(default)]
pub address2: String,
#[serde(default)]
pub city: String,
#[serde(default)]
pub state: String,
#[serde(default)]
pub postal_code: String,
#[serde(default)]
pub country_id: String,
#[serde(default)]
pub phone: String,
#[serde(default)]
pub website: String,
#[serde(default)]
pub private_notes: String,
#[serde(default)]
pub balance: f64,
#[serde(default)]
pub paid_to_date: f64,
#[serde(default)]
pub credit_balance: f64,
#[serde(default)]
pub is_deleted: bool,
#[serde(default)]
pub archived_at: Option<i64>,
#[serde(default)]
pub created_at: Option<i64>,
#[serde(default)]
pub updated_at: Option<i64>,
#[serde(default)]
pub contacts: Vec<ClientContact>,
/// Any fields returned by the API that aren't modeled above are preserved here,
/// so a `get` followed by an `update` round-trips without data loss.
#[serde(flatten)]
pub extra: Map<String, Value>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ClientContact {
#[serde(skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
#[serde(default)]
pub first_name: String,
#[serde(default)]
pub last_name: String,
#[serde(default)]
pub email: String,
#[serde(default)]
pub phone: String,
#[serde(default)]
pub is_primary: bool,
#[serde(flatten)]
pub extra: Map<String, Value>,
}
+83
View File
@@ -0,0 +1,83 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Deserialize)]
pub struct ListResponse<T> {
pub data: Vec<T>,
#[serde(default)]
pub meta: Option<Meta>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct ItemResponse<T> {
pub data: T,
}
#[derive(Debug, Clone, Deserialize)]
pub struct Meta {
#[serde(default)]
pub pagination: Option<Pagination>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct Pagination {
pub total: u64,
pub count: u64,
pub per_page: u64,
pub current_page: u64,
pub total_pages: u64,
}
/// Query parameters accepted by Invoice Ninja's list endpoints.
#[derive(Debug, Clone, Default, Serialize)]
pub struct ListParams {
#[serde(skip_serializing_if = "Option::is_none")]
pub page: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub per_page: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub filter: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub sort: Option<String>,
/// Comma-separated list of relationships to eager-load, e.g. "client,payments".
#[serde(skip_serializing_if = "Option::is_none")]
pub include: Option<String>,
/// Set to 1 to include archived/deleted records.
#[serde(skip_serializing_if = "Option::is_none")]
pub status: Option<String>,
}
impl ListParams {
pub fn new() -> Self {
Self::default()
}
pub fn page(mut self, page: u64) -> Self {
self.page = Some(page);
self
}
pub fn per_page(mut self, per_page: u64) -> Self {
self.per_page = Some(per_page);
self
}
pub fn filter(mut self, filter: impl Into<String>) -> Self {
self.filter = Some(filter.into());
self
}
pub fn sort(mut self, sort: impl Into<String>) -> Self {
self.sort = Some(sort.into());
self
}
pub fn include(mut self, include: impl Into<String>) -> Self {
self.include = Some(include.into());
self
}
pub fn status(mut self, status: impl Into<String>) -> Self {
self.status = Some(status.into());
self
}
}
+51
View File
@@ -0,0 +1,51 @@
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Expense {
#[serde(skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
#[serde(default)]
pub vendor_id: String,
#[serde(default)]
pub client_id: String,
#[serde(default)]
pub invoice_id: String,
#[serde(default)]
pub amount: f64,
#[serde(default)]
pub exchange_rate: f64,
#[serde(default)]
pub public_notes: String,
#[serde(default)]
pub private_notes: String,
#[serde(default)]
pub date: String,
#[serde(default)]
pub payment_date: String,
#[serde(default)]
pub transaction_reference: String,
#[serde(default)]
pub category_id: String,
#[serde(default)]
pub tax_name1: String,
#[serde(default)]
pub tax_rate1: f64,
#[serde(default)]
pub tax_name2: String,
#[serde(default)]
pub tax_rate2: f64,
#[serde(default)]
pub should_be_invoiced: bool,
#[serde(default)]
pub is_deleted: bool,
#[serde(default)]
pub archived_at: Option<i64>,
#[serde(default)]
pub created_at: Option<i64>,
#[serde(default)]
pub updated_at: Option<i64>,
#[serde(flatten)]
pub extra: Map<String, Value>,
}
+85
View File
@@ -0,0 +1,85 @@
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Invoice {
#[serde(skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
#[serde(default)]
pub client_id: String,
#[serde(default)]
pub number: String,
#[serde(default)]
pub date: String,
#[serde(default)]
pub due_date: String,
#[serde(default)]
pub po_number: String,
#[serde(default)]
pub status_id: String,
#[serde(default)]
pub amount: f64,
#[serde(default)]
pub balance: f64,
#[serde(default)]
pub discount: f64,
#[serde(default)]
pub is_amount_discount: bool,
#[serde(default)]
pub public_notes: String,
#[serde(default)]
pub private_notes: String,
#[serde(default)]
pub terms: String,
#[serde(default)]
pub footer: String,
#[serde(default)]
pub is_deleted: bool,
#[serde(default)]
pub archived_at: Option<i64>,
#[serde(default)]
pub created_at: Option<i64>,
#[serde(default)]
pub updated_at: Option<i64>,
#[serde(default)]
pub line_items: Vec<InvoiceItem>,
#[serde(flatten)]
pub extra: Map<String, Value>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct InvoiceItem {
#[serde(default)]
pub product_key: String,
#[serde(default)]
pub notes: String,
#[serde(default)]
pub cost: f64,
#[serde(default)]
pub quantity: f64,
#[serde(default)]
pub discount: f64,
#[serde(default)]
pub tax_name1: String,
#[serde(default)]
pub tax_rate1: f64,
#[serde(default)]
pub tax_name2: String,
#[serde(default)]
pub tax_rate2: f64,
#[serde(default)]
pub line_total: f64,
#[serde(flatten)]
pub extra: Map<String, Value>,
}
/// Invoice Ninja stores quotes in the same table/schema as invoices
/// (an `entity_type` distinguishes them), so the `/quotes` endpoint
/// returns the same JSON shape as `/invoices`.
pub type Quote = Invoice;
/// Likewise, credits share the invoices table/schema, so the `/credits`
/// endpoint returns the same JSON shape as `/invoices`.
pub type Credit = Invoice;
+19
View File
@@ -0,0 +1,19 @@
mod client;
mod common;
mod expense;
mod invoice;
mod payment;
mod product;
mod project;
mod task;
mod vendor;
pub use client::{Client, ClientContact};
pub use common::{ItemResponse, ListParams, ListResponse, Meta, Pagination};
pub use expense::Expense;
pub use invoice::{Credit, Invoice, InvoiceItem, Quote};
pub use payment::{Payment, PaymentInvoice};
pub use product::Product;
pub use project::Project;
pub use task::Task;
pub use vendor::{Vendor, VendorContact};
+46
View File
@@ -0,0 +1,46 @@
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Payment {
#[serde(skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
#[serde(default)]
pub client_id: String,
#[serde(default)]
pub amount: f64,
#[serde(default)]
pub applied: f64,
#[serde(default)]
pub refunded: f64,
#[serde(default)]
pub date: String,
#[serde(default)]
pub transaction_reference: String,
#[serde(default)]
pub type_id: String,
#[serde(default)]
pub private_notes: String,
#[serde(default)]
pub is_deleted: bool,
#[serde(default)]
pub archived_at: Option<i64>,
#[serde(default)]
pub created_at: Option<i64>,
#[serde(default)]
pub updated_at: Option<i64>,
#[serde(default)]
pub invoices: Vec<PaymentInvoice>,
#[serde(flatten)]
pub extra: Map<String, Value>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct PaymentInvoice {
pub invoice_id: String,
pub amount: f64,
#[serde(flatten)]
pub extra: Map<String, Value>,
}
+37
View File
@@ -0,0 +1,37 @@
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Product {
#[serde(skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
#[serde(default)]
pub product_key: String,
#[serde(default)]
pub notes: String,
#[serde(default)]
pub cost: f64,
#[serde(default)]
pub price: f64,
#[serde(default)]
pub quantity: f64,
#[serde(default)]
pub tax_name1: String,
#[serde(default)]
pub tax_rate1: f64,
#[serde(default)]
pub tax_name2: String,
#[serde(default)]
pub tax_rate2: f64,
#[serde(default)]
pub is_deleted: bool,
#[serde(default)]
pub archived_at: Option<i64>,
#[serde(default)]
pub created_at: Option<i64>,
#[serde(default)]
pub updated_at: Option<i64>,
#[serde(flatten)]
pub extra: Map<String, Value>,
}
+37
View File
@@ -0,0 +1,37 @@
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Project {
#[serde(skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
#[serde(default)]
pub client_id: String,
#[serde(default)]
pub name: String,
#[serde(default)]
pub number: String,
#[serde(default)]
pub task_rate: f64,
#[serde(default)]
pub due_date: String,
#[serde(default)]
pub budgeted_hours: f64,
#[serde(default)]
pub color: String,
#[serde(default)]
pub public_notes: String,
#[serde(default)]
pub private_notes: String,
#[serde(default)]
pub is_deleted: bool,
#[serde(default)]
pub archived_at: Option<i64>,
#[serde(default)]
pub created_at: Option<i64>,
#[serde(default)]
pub updated_at: Option<i64>,
#[serde(flatten)]
pub extra: Map<String, Value>,
}
+36
View File
@@ -0,0 +1,36 @@
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Task {
#[serde(skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
#[serde(default)]
pub client_id: String,
#[serde(default)]
pub invoice_id: String,
#[serde(default)]
pub project_id: String,
#[serde(default)]
pub description: String,
#[serde(default)]
pub rate: f64,
#[serde(default)]
pub status_id: String,
#[serde(default)]
pub is_running: bool,
#[serde(default)]
pub is_deleted: bool,
/// JSON-encoded array of `[start_timestamp, end_timestamp]` pairs, as returned by the API.
#[serde(default)]
pub time_log: String,
#[serde(default)]
pub archived_at: Option<i64>,
#[serde(default)]
pub created_at: Option<i64>,
#[serde(default)]
pub updated_at: Option<i64>,
#[serde(flatten)]
pub extra: Map<String, Value>,
}
+66
View File
@@ -0,0 +1,66 @@
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Vendor {
#[serde(skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
#[serde(default)]
pub name: String,
#[serde(default)]
pub id_number: String,
#[serde(default)]
pub vat_number: String,
#[serde(default)]
pub address1: String,
#[serde(default)]
pub address2: String,
#[serde(default)]
pub city: String,
#[serde(default)]
pub state: String,
#[serde(default)]
pub postal_code: String,
#[serde(default)]
pub country_id: String,
#[serde(default)]
pub phone: String,
#[serde(default)]
pub website: String,
#[serde(default)]
pub private_notes: String,
#[serde(default)]
pub is_deleted: bool,
#[serde(default)]
pub archived_at: Option<i64>,
#[serde(default)]
pub created_at: Option<i64>,
#[serde(default)]
pub updated_at: Option<i64>,
#[serde(default)]
pub contacts: Vec<VendorContact>,
/// Any fields returned by the API that aren't modeled above are preserved here,
/// so a `get` followed by an `update` round-trips without data loss.
#[serde(flatten)]
pub extra: Map<String, Value>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct VendorContact {
#[serde(skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
#[serde(default)]
pub first_name: String,
#[serde(default)]
pub last_name: String,
#[serde(default)]
pub email: String,
#[serde(default)]
pub phone: String,
#[serde(default)]
pub is_primary: bool,
#[serde(flatten)]
pub extra: Map<String, Value>,
}
+152
View File
@@ -0,0 +1,152 @@
use invoiceninjars::models::{
Client, Credit, Expense, Invoice, ListParams, Project, Quote, Task, Vendor,
};
use serde_json::json;
#[test]
fn client_round_trip_preserves_unknown_fields() {
let payload = json!({
"id": "abc123",
"name": "Acme Inc",
"custom_value1": "unmodeled field",
});
let client: Client = serde_json::from_value(payload).unwrap();
assert_eq!(client.name, "Acme Inc");
assert_eq!(
client.extra.get("custom_value1").unwrap(),
"unmodeled field"
);
let round_tripped = serde_json::to_value(&client).unwrap();
assert_eq!(round_tripped["custom_value1"], "unmodeled field");
}
#[test]
fn invoice_round_trip_preserves_unknown_fields() {
let payload = json!({
"id": "inv1",
"client_id": "abc123",
"number": "INV-0001",
"custom_surcharge1": 12.5,
});
let invoice: Invoice = serde_json::from_value(payload).unwrap();
assert_eq!(invoice.number, "INV-0001");
assert_eq!(invoice.extra.get("custom_surcharge1").unwrap(), 12.5);
let round_tripped = serde_json::to_value(&invoice).unwrap();
assert_eq!(round_tripped["custom_surcharge1"], 12.5);
}
#[test]
fn list_params_omits_unset_fields() {
let params = ListParams::new().per_page(25).sort("created_at|desc");
assert_eq!(
serde_json::to_value(&params).unwrap(),
json!({"per_page": 25, "sort": "created_at|desc"})
);
}
#[test]
fn vendor_round_trip_preserves_nested_contact_fields() {
let payload = json!({
"id": "ven1",
"name": "Supply Co",
"custom_value2": "unmodeled vendor field",
"contacts": [{
"id": "vc1",
"first_name": "Ada",
"email": "ada@supply.example",
"is_primary": true,
"custom_value1": "unmodeled contact field",
}],
});
let vendor: Vendor = serde_json::from_value(payload).unwrap();
assert_eq!(vendor.name, "Supply Co");
assert_eq!(vendor.contacts.len(), 1);
assert_eq!(vendor.contacts[0].first_name, "Ada");
assert!(vendor.contacts[0].is_primary);
let round_tripped = serde_json::to_value(&vendor).unwrap();
assert_eq!(round_tripped["custom_value2"], "unmodeled vendor field");
assert_eq!(
round_tripped["contacts"][0]["custom_value1"],
"unmodeled contact field"
);
}
#[test]
fn expense_round_trip_preserves_unknown_fields() {
let payload = json!({
"id": "exp1",
"vendor_id": "ven1",
"amount": 42.75,
"should_be_invoiced": true,
"custom_value3": "unmodeled field",
});
let expense: Expense = serde_json::from_value(payload).unwrap();
assert_eq!(expense.vendor_id, "ven1");
assert_eq!(expense.amount, 42.75);
assert!(expense.should_be_invoiced);
let round_tripped = serde_json::to_value(&expense).unwrap();
assert_eq!(round_tripped["custom_value3"], "unmodeled field");
}
#[test]
fn task_round_trip_preserves_unknown_fields() {
let payload = json!({
"id": "task1",
"project_id": "proj1",
"description": "Design review",
"time_log": "[[1700000000,1700003600]]",
"custom_value1": "unmodeled field",
});
let task: Task = serde_json::from_value(payload).unwrap();
assert_eq!(task.project_id, "proj1");
assert_eq!(task.time_log, "[[1700000000,1700003600]]");
let round_tripped = serde_json::to_value(&task).unwrap();
assert_eq!(round_tripped["custom_value1"], "unmodeled field");
}
#[test]
fn project_round_trip_preserves_unknown_fields() {
let payload = json!({
"id": "proj1",
"client_id": "abc123",
"name": "Website rebuild",
"budgeted_hours": 120.0,
"custom_value4": "unmodeled field",
});
let project: Project = serde_json::from_value(payload).unwrap();
assert_eq!(project.name, "Website rebuild");
assert_eq!(project.budgeted_hours, 120.0);
let round_tripped = serde_json::to_value(&project).unwrap();
assert_eq!(round_tripped["custom_value4"], "unmodeled field");
}
/// `Quote` and `Credit` are type aliases for `Invoice`; this pins that the
/// aliased endpoints decode the shared invoice shape.
#[test]
fn quote_and_credit_share_the_invoice_shape() {
let payload = json!({
"id": "q1",
"number": "QUO-0001",
"entity_type": "quote",
});
let quote: Quote = serde_json::from_value(payload.clone()).unwrap();
assert_eq!(quote.number, "QUO-0001");
assert_eq!(quote.extra.get("entity_type").unwrap(), "quote");
let credit: Credit = serde_json::from_value(payload).unwrap();
assert_eq!(credit.number, "QUO-0001");
}