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:
+300
@@ -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(¶ms).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
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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};
|
||||
@@ -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>,
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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>,
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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};
|
||||
@@ -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>,
|
||||
}
|
||||
@@ -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>,
|
||||
}
|
||||
@@ -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>,
|
||||
}
|
||||
@@ -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>,
|
||||
}
|
||||
@@ -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>,
|
||||
}
|
||||
Reference in New Issue
Block a user