Correct the models against what a real server actually sends

Every one of these was wrong in a way the mock tests could not see, because
the mocks were written from the same upstream source the models were.

- `SystemLog.created_at`/`updated_at` are integer timestamps, not the date
  strings the OpenAPI spec declares.
- `frequency_id` arrives as a numeric string on recurring invoices and
  expenses, so both now decode through `int_or_numeric_string`.
- `Location.country_id`, `PurchaseOrder.status_id` and `Webhook.format` are
  omitted when empty. The server runs `ConvertEmptyStringsToNull`, so an
  unset `String` sent as `""` becomes NULL and then fails a non-null column
  or an `integer` rule — which meant a create from a `Default` model could
  not succeed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-10-24 21:55:46 -04:00
co-authored by Claude Opus 5
parent 0002d32197
commit bd2d34cae9
8 changed files with 41 additions and 17 deletions
+8 -1
View File
@@ -18,7 +18,14 @@ pub struct Location {
pub state: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub postal_code: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
/// Omitted when empty: the server validates it as an integer, so an unset
/// `""` is rejected with a 422 and a default `Location` could not be
/// created.
#[serde(
default,
deserialize_with = "super::common::null_to_default",
skip_serializing_if = "String::is_empty"
)]
pub country_id: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub user_id: String,
+2
View File
@@ -38,7 +38,9 @@ pub struct Payment {
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct PaymentInvoice {
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub invoice_id: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub amount: f64,
#[serde(flatten)]
+8 -1
View File
@@ -23,7 +23,14 @@ pub struct PurchaseOrder {
pub po_number: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub quote_number: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
/// Omitted when empty: the server turns `""` into `NULL` and then fails
/// the insert on a non-null column with a 500, so a new purchase order
/// must leave it out and take the server's default.
#[serde(
default,
deserialize_with = "super::common::null_to_default",
skip_serializing_if = "String::is_empty"
)]
pub status_id: String,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub date: String,
+1 -1
View File
@@ -28,7 +28,7 @@ pub struct RecurringExpense {
/// Integer enum matching [`RecurringInvoice::frequency_id`].
///
/// [`RecurringInvoice::frequency_id`]: super::RecurringInvoice::frequency_id
#[serde(default, deserialize_with = "super::common::null_to_default")]
#[serde(default, deserialize_with = "super::common::int_or_numeric_string")]
pub frequency_id: i64,
/// Number of cycles left; `-1` means it recurs indefinitely.
#[serde(default, deserialize_with = "super::common::null_to_default")]
+3 -2
View File
@@ -25,8 +25,9 @@ pub struct RecurringInvoice {
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub status_id: String,
/// How often the invoice recurs. Invoice Ninja returns this as an integer
/// enum (1 = daily, 2 = weekly, … ), not a human-readable string.
#[serde(default, deserialize_with = "super::common::null_to_default")]
/// enum (1 = daily, 2 = weekly, … ), not a human-readable string. A real
/// server sends it as a numeric string; both decode.
#[serde(default, deserialize_with = "super::common::int_or_numeric_string")]
pub frequency_id: i64,
/// Number of cycles left to send; `-1` means it recurs indefinitely.
#[serde(default, deserialize_with = "super::common::null_to_default")]
+5 -4
View File
@@ -3,8 +3,9 @@ use serde_json::{Map, Value};
/// A server-side log entry (gateway responses, mail failures, and so on).
///
/// Note that unlike every other resource, this one's `created_at` and
/// `updated_at` are returned as date **strings**, not integer timestamps.
/// The OpenAPI spec declares `created_at` and `updated_at` as date strings,
/// but a real server (5.13.50) sends integer timestamps like every other
/// resource.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct SystemLog {
#[serde(skip_serializing_if = "Option::is_none")]
@@ -23,9 +24,9 @@ pub struct SystemLog {
#[serde(default)]
pub log: Value,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub created_at: String,
pub created_at: i64,
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub updated_at: String,
pub updated_at: i64,
#[serde(flatten)]
pub extra: Map<String, Value>,
+7 -2
View File
@@ -13,8 +13,13 @@ pub struct Webhook {
/// The URL Invoice Ninja posts to.
#[serde(default, deserialize_with = "super::common::null_to_default")]
pub target_url: String,
/// Payload format: `JSON` or `UBL`.
#[serde(default, deserialize_with = "super::common::null_to_default")]
/// Payload format: `JSON` or `UBL`. Omitted when empty: the server turns
/// `""` into `NULL` and fails the insert on a non-null column (a 500).
#[serde(
default,
deserialize_with = "super::common::null_to_default",
skip_serializing_if = "String::is_empty"
)]
pub format: String,
#[serde(flatten)]