diff --git a/src/models/activity.rs b/src/models/activity.rs new file mode 100644 index 0000000..9170976 --- /dev/null +++ b/src/models/activity.rs @@ -0,0 +1,122 @@ +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; + +/// One row of the activity log, as `GET /activities` returns it. +/// +/// Every reference is a hashed id, or an empty string when the activity does +/// not concern that kind of record. `activity_type_id` arrives as a string +/// here but as a number in the [feed](ActivityFeedEntry). +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct Activity { + #[serde(skip_serializing_if = "Option::is_none")] + pub id: Option, + #[serde(default, deserialize_with = "super::common::null_to_default")] + pub activity_type_id: String, + #[serde(default, deserialize_with = "super::common::null_to_default")] + pub user_id: String, + #[serde(default, deserialize_with = "super::common::null_to_default")] + pub company_id: String, + #[serde(default, deserialize_with = "super::common::null_to_default")] + pub client_id: String, + #[serde(default, deserialize_with = "super::common::null_to_default")] + pub contact_id: String, + #[serde(default, deserialize_with = "super::common::null_to_default")] + pub vendor_id: String, + #[serde(default, deserialize_with = "super::common::null_to_default")] + pub vendor_contact_id: String, + #[serde(default, deserialize_with = "super::common::null_to_default")] + pub invoice_id: String, + #[serde(default, deserialize_with = "super::common::null_to_default")] + pub quote_id: String, + #[serde(default, deserialize_with = "super::common::null_to_default")] + pub credit_id: String, + #[serde(default, deserialize_with = "super::common::null_to_default")] + pub payment_id: String, + #[serde(default, deserialize_with = "super::common::null_to_default")] + pub recurring_invoice_id: String, + #[serde(default, deserialize_with = "super::common::null_to_default")] + pub recurring_expense_id: String, + #[serde(default, deserialize_with = "super::common::null_to_default")] + pub purchase_order_id: String, + #[serde(default, deserialize_with = "super::common::null_to_default")] + pub expense_id: String, + #[serde(default, deserialize_with = "super::common::null_to_default")] + pub task_id: String, + #[serde(default, deserialize_with = "super::common::null_to_default")] + pub token_id: String, + #[serde(default, deserialize_with = "super::common::null_to_default")] + pub is_system: bool, + #[serde(default, deserialize_with = "super::common::null_to_default")] + pub notes: String, + #[serde(default, deserialize_with = "super::common::null_to_default")] + pub ip: String, + #[serde(default)] + pub created_at: Option, + #[serde(default)] + pub updated_at: Option, + + #[serde(flatten)] + pub extra: Map, +} + +/// A named record an activity refers to: the display label and its hashed id. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct ActivityRef { + #[serde(default, deserialize_with = "super::common::null_to_default")] + pub label: String, + #[serde(default, deserialize_with = "super::common::null_to_default")] + pub hashed_id: String, + + #[serde(flatten)] + pub extra: Map, +} + +/// One entry of the activity feed (`activity_feed`, `entity_activity`). +/// +/// A different shape from [`Activity`]: the server resolves the records an +/// activity mentions into labelled references. Which references are present +/// depends on the activity type, so each is optional, and any the server adds +/// that this does not name lands in `extra`. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct ActivityFeedEntry { + #[serde(default, deserialize_with = "super::common::null_to_default")] + pub id: String, + #[serde(default, deserialize_with = "super::common::null_to_default")] + pub hashed_id: String, + #[serde(default, deserialize_with = "super::common::int_or_numeric_string")] + pub activity_type_id: i64, + #[serde(default, deserialize_with = "super::common::null_to_default")] + pub notes: String, + #[serde(default, deserialize_with = "super::common::null_to_default")] + pub ip: String, + #[serde(default, deserialize_with = "super::common::int_or_numeric_string")] + pub created_at: i64, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub user: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub client: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub vendor: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub invoice: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub quote: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub credit: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub payment: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub task: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub expense: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub purchase_order: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub recurring_invoice: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub recurring_expense: Option, + + #[serde(flatten)] + pub extra: Map, +} diff --git a/src/models/common.rs b/src/models/common.rs index 3cfc0d8..4545e12 100644 --- a/src/models/common.rs +++ b/src/models/common.rs @@ -154,6 +154,32 @@ where } } +/// Deserializes an integer that may arrive as a JSON number or as a numeric +/// string. +/// +/// The OpenAPI spec calls `frequency_id` a number on recurring invoices and +/// expenses, but a real 5.13.50 server returns the string `"5"`. A strict +/// `i64` made that a hard decode error *after* the record had already been +/// created. A string that is not a whole number is still an error. +pub(crate) fn int_or_numeric_string<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + match Option::::deserialize(deserializer)? { + None | Some(Value::Null) => Ok(0), + Some(Value::Number(n)) => n + .as_i64() + .ok_or_else(|| serde::de::Error::custom(format!("expected a whole number, got {n}"))), + Some(Value::String(s)) if s.is_empty() => Ok(0), + Some(Value::String(s)) => s.trim().parse().map_err(|_| { + serde::de::Error::custom(format!("expected a whole number, got string `{s}`")) + }), + Some(other) => Err(serde::de::Error::custom(format!( + "expected a whole number or numeric string, got {other}" + ))), + } +} + /// Body for a bulk action (`POST /{resource}/bulk`). /// /// The action set varies by resource; `archive`, `restore`, and `delete` work @@ -174,3 +200,15 @@ impl BulkRequest { } } } + +/// The `{"message": "..."}` body that action endpoints answer with. +/// +/// `message` is defaulted so a reply that carries none still decodes. +/// Anything else the server adds is kept in `extra`. +#[derive(Debug, Clone, Default, Deserialize)] +pub struct MessageResponse { + #[serde(default, deserialize_with = "null_to_default")] + pub message: String, + #[serde(flatten)] + pub extra: serde_json::Map, +} diff --git a/src/models/mod.rs b/src/models/mod.rs index d1961db..488ca46 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -1,3 +1,4 @@ +mod activity; mod bank_integration; mod bank_transaction; mod bank_transaction_rule; @@ -35,12 +36,15 @@ mod user; mod vendor; mod webhook; +pub use activity::{Activity, ActivityFeedEntry, ActivityRef}; pub use bank_integration::BankIntegration; pub use bank_transaction::BankTransaction; pub use bank_transaction_rule::BankTransactionRule; pub use client::{Client, ClientContact}; pub use client_gateway_token::ClientGatewayToken; -pub use common::{BulkRequest, ItemResponse, ListParams, ListResponse, Meta, Pagination}; +pub use common::{ + BulkRequest, ItemResponse, ListParams, ListResponse, MessageResponse, Meta, Pagination, +}; pub use company::Company; pub use company_gateway::CompanyGateway; pub use company_token::CompanyToken; @@ -61,7 +65,7 @@ pub use recurring_expense::RecurringExpense; pub use recurring_invoice::{RecurringInvoice, RecurringQuote}; pub use search::{SearchHit, SearchResults}; pub use statics::{Currency, DateFormat, StaticItem, Statics}; -pub use subscription::Subscription; +pub use subscription::{Subscription, SubscriptionStep}; pub use system_log::SystemLog; pub use tag::Tag; pub use task::Task; diff --git a/src/models/subscription.rs b/src/models/subscription.rs index 4837bf0..76301d0 100644 --- a/src/models/subscription.rs +++ b/src/models/subscription.rs @@ -55,3 +55,16 @@ pub struct Subscription { #[serde(flatten)] pub extra: Map, } + +/// One step of the subscription purchase flow and the steps it depends on, +/// as returned by `GET /subscriptions/steps`. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct SubscriptionStep { + #[serde(default, deserialize_with = "super::common::null_to_default")] + pub id: String, + #[serde(default, deserialize_with = "super::common::null_to_default")] + pub dependencies: Vec, + + #[serde(flatten)] + pub extra: Map, +}