Add the Activity, SubscriptionStep and MessageResponse models

These are the types the per-resource extras and the activity endpoints
need, split out so they land before the code and tests that use them.

`MessageResponse` covers the `{"message": "..."}` body that the action
endpoints answer with, defaulting `message` so a reply carrying none still
decodes. `int_or_numeric_string` goes in alongside them: the spec calls
`frequency_id` a number, but a real 5.13.50 server sends the string `"5"`,
and a strict `i64` turned that into a hard decode error *after* the record
had already been created.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-10-24 21:55:39 -04:00
co-authored by Claude Opus 5
parent 9e7d81307d
commit 0002d32197
4 changed files with 179 additions and 2 deletions
+122
View File
@@ -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<String>,
#[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<i64>,
#[serde(default)]
pub updated_at: Option<i64>,
#[serde(flatten)]
pub extra: Map<String, Value>,
}
/// 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<String, Value>,
}
/// 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<ActivityRef>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub client: Option<ActivityRef>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub vendor: Option<ActivityRef>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub invoice: Option<ActivityRef>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub quote: Option<ActivityRef>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub credit: Option<ActivityRef>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub payment: Option<ActivityRef>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub task: Option<ActivityRef>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub expense: Option<ActivityRef>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub purchase_order: Option<ActivityRef>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub recurring_invoice: Option<ActivityRef>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub recurring_expense: Option<ActivityRef>,
#[serde(flatten)]
pub extra: Map<String, Value>,
}
+38
View File
@@ -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<i64, D::Error>
where
D: Deserializer<'de>,
{
match Option::<Value>::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<String, Value>,
}
+6 -2
View File
@@ -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;
+13
View File
@@ -55,3 +55,16 @@ pub struct Subscription {
#[serde(flatten)]
pub extra: Map<String, Value>,
}
/// 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<String>,
#[serde(flatten)]
pub extra: Map<String, Value>,
}