nordnet-model 0.1.0

Pure data types and crypto for the Nordnet External API v2 (no I/O).
Documentation
//! Types reused across resource groups.
//!
//! Surface: `ErrorResponse`, `Currency`, `Money`, `Amount`,
//! `AmountWithCurrency`, `Timestamp`, plus the `opt_arb_prec` and
//! `date_iso8601` serde adapters used by multiple groups.

use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use time::OffsetDateTime;

/// Standard error description as defined under `#_errorresponse` in the
/// reference HTML.
///
/// `code` is required, `message` is optional (and human-translated).
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
pub struct ErrorResponse {
    pub code: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub message: Option<String>,
}

/// ISO 4217 currency code as it appears in Nordnet payloads (e.g. `"SEK"`,
/// `"EUR"`). The API encodes it as a plain JSON string.
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(transparent)]
pub struct Currency(pub String);

impl std::fmt::Display for Currency {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        std::fmt::Display::fmt(&self.0, f)
    }
}

impl From<&str> for Currency {
    fn from(v: &str) -> Self {
        Self(v.to_owned())
    }
}

/// A monetary amount in a specific currency, using the field name `amount`.
///
/// `amount` is `rust_decimal::Decimal` — never `f64`. This type is provided
/// for endpoints that nest a `{amount, currency}` object literally; for the
/// much more common Nordnet `{value, currency}` shape, see
/// [`AmountWithCurrency`].
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
pub struct Money {
    #[serde(with = "rust_decimal::serde::arbitrary_precision")]
    pub amount: Decimal,
    pub currency: Currency,
}

/// A monetary amount without an attached currency. Some Nordnet response
/// shapes use a bare numeric field where the currency is implied by the
/// surrounding object. Use this rather than `Decimal` directly so the
/// "money is decimal, never float" invariant is visible at the type level.
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
#[serde(transparent)]
pub struct Amount(#[serde(with = "rust_decimal::serde::arbitrary_precision")] pub Decimal);

/// A monetary amount with attached currency, encoded as
/// `{currency: <Currency>, value: <Decimal>}` per the documented
/// Nordnet `Amount` schema.
///
/// Cannot derive [`Eq`] because `value` is a `Decimal` carried under the
/// `arbitrary_precision` adapter (`PartialEq` only).
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
pub struct AmountWithCurrency {
    /// The amount currency.
    pub currency: Currency,
    /// The amount value. `Decimal` (never `f64`).
    #[serde(with = "rust_decimal::serde::arbitrary_precision")]
    pub value: Decimal,
}

/// Common timestamp type for fields that the docs mark as ISO 8601. Use
/// the [`time::serde::iso8601`] adapter at the field level:
///
/// ```ignore
/// #[serde(with = "time::serde::iso8601")]
/// pub created_at: Timestamp,
/// ```
pub type Timestamp = OffsetDateTime;

/// Serde adapter for `Option<Decimal>` that uses arbitrary-precision number
/// encoding (matches the `arbitrary_precision` adapter applied to
/// non-optional `Decimal` fields).
///
/// `rust_decimal` exposes `arbitrary_precision_option` directly; this
/// wrapper exists so the import surface in field attributes stays uniform
/// with the non-optional case (`with = "..."`).
pub mod opt_arb_prec {
    use rust_decimal::Decimal;
    use serde::{Deserialize, Deserializer, Serialize, Serializer};

    /// Serialize an `Option<Decimal>` as a bare JSON number (or `null`).
    /// Invoked indirectly via `#[serde(with = "opt_arb_prec")]`.
    pub fn serialize<S>(value: &Option<Decimal>, serializer: S) -> Result<S::Ok, S::Error>
    where
        S: Serializer,
    {
        #[derive(Serialize)]
        struct Wrapped<'a>(#[serde(with = "rust_decimal::serde::arbitrary_precision")] &'a Decimal);
        value.as_ref().map(Wrapped).serialize(serializer)
    }

    /// Deserialize a bare JSON number (or `null`) into `Option<Decimal>`.
    /// Invoked indirectly via `#[serde(with = "opt_arb_prec")]`.
    pub fn deserialize<'de, D>(deserializer: D) -> Result<Option<Decimal>, D::Error>
    where
        D: Deserializer<'de>,
    {
        #[derive(Deserialize)]
        struct Wrapped(#[serde(with = "rust_decimal::serde::arbitrary_precision")] Decimal);
        Ok(Option::<Wrapped>::deserialize(deserializer)?.map(|w| w.0))
    }
}

/// Serde adapter for `time::Date` that round-trips the documented
/// `string(date)` wire form (`"YYYY-MM-DD"`).
///
/// Use at the field level with `#[serde(with = "crate::models::shared::date_iso8601")]`
/// (or `date_iso8601::option` for `Option<Date>`).
pub mod date_iso8601 {
    use serde::{Deserialize, Deserializer, Serialize, Serializer};
    use time::macros::format_description;
    use time::Date;

    const FORMAT: &[time::format_description::FormatItem<'static>] =
        format_description!("[year]-[month]-[day]");

    /// Serialize a [`Date`] as a `YYYY-MM-DD` string.
    /// Invoked indirectly via `#[serde(with = "date_iso8601")]`.
    pub fn serialize<S>(value: &Date, serializer: S) -> Result<S::Ok, S::Error>
    where
        S: Serializer,
    {
        value
            .format(&FORMAT)
            .map_err(serde::ser::Error::custom)?
            .serialize(serializer)
    }

    /// Deserialize a `YYYY-MM-DD` string into a [`Date`].
    /// Invoked indirectly via `#[serde(with = "date_iso8601")]`.
    pub fn deserialize<'de, D>(deserializer: D) -> Result<Date, D::Error>
    where
        D: Deserializer<'de>,
    {
        let s = String::deserialize(deserializer)?;
        Date::parse(&s, &FORMAT).map_err(serde::de::Error::custom)
    }

    /// `Option<Date>` flavor of the same `YYYY-MM-DD` adapter.
    pub mod option {
        use super::FORMAT;
        use serde::{Deserialize, Deserializer, Serialize, Serializer};
        use time::Date;

        /// Serialize an `Option<Date>` as a `YYYY-MM-DD` string or JSON `null`.
        /// Invoked indirectly via `#[serde(with = "date_iso8601::option")]`.
        pub fn serialize<S>(value: &Option<Date>, serializer: S) -> Result<S::Ok, S::Error>
        where
            S: Serializer,
        {
            match value {
                Some(d) => d
                    .format(&FORMAT)
                    .map_err(serde::ser::Error::custom)?
                    .serialize(serializer),
                None => serializer.serialize_none(),
            }
        }

        /// Deserialize a `YYYY-MM-DD` string or `null` into `Option<Date>`.
        /// Invoked indirectly via `#[serde(with = "date_iso8601::option")]`.
        pub fn deserialize<'de, D>(deserializer: D) -> Result<Option<Date>, D::Error>
        where
            D: Deserializer<'de>,
        {
            let opt = Option::<String>::deserialize(deserializer)?;
            match opt {
                Some(s) => Date::parse(&s, &FORMAT)
                    .map(Some)
                    .map_err(serde::de::Error::custom),
                None => Ok(None),
            }
        }
    }

    /// `Vec<Date>` flavor of the same `YYYY-MM-DD` adapter, used by
    /// schemas that document an array of dates (e.g.
    /// `LeverageFilter.expiration_dates`).
    pub mod vec {
        use super::FORMAT;
        use serde::{ser::SerializeSeq, Deserialize, Deserializer, Serializer};
        use time::Date;

        /// Serialize a `Vec<Date>` as a JSON array of `YYYY-MM-DD` strings.
        /// Invoked indirectly via `#[serde(with = "date_iso8601::vec")]`.
        pub fn serialize<S>(value: &[Date], serializer: S) -> Result<S::Ok, S::Error>
        where
            S: Serializer,
        {
            let mut seq = serializer.serialize_seq(Some(value.len()))?;
            for d in value {
                let s = d.format(&FORMAT).map_err(serde::ser::Error::custom)?;
                seq.serialize_element(&s)?;
            }
            seq.end()
        }

        /// Deserialize a JSON array of `YYYY-MM-DD` strings into `Vec<Date>`.
        /// Invoked indirectly via `#[serde(with = "date_iso8601::vec")]`.
        pub fn deserialize<'de, D>(deserializer: D) -> Result<Vec<Date>, D::Error>
        where
            D: Deserializer<'de>,
        {
            let raws = Vec::<String>::deserialize(deserializer)?;
            raws.into_iter()
                .map(|s| Date::parse(&s, &FORMAT).map_err(serde::de::Error::custom))
                .collect()
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use pretty_assertions::assert_eq;

    #[test]
    fn error_response_round_trip() {
        let raw = r#"{"code":"NEXT_BAD","message":"Nope."}"#;
        let parsed: ErrorResponse = serde_json::from_str(raw).unwrap();
        assert_eq!(parsed.code, "NEXT_BAD");
        assert_eq!(parsed.message.as_deref(), Some("Nope."));
        assert_eq!(serde_json::to_string(&parsed).unwrap(), raw);
    }

    #[test]
    fn error_response_message_optional() {
        let raw = r#"{"code":"NO_MSG"}"#;
        let parsed: ErrorResponse = serde_json::from_str(raw).unwrap();
        assert_eq!(parsed.message, None);
        // Serialization should omit `message` because we set
        // `skip_serializing_if = Option::is_none`.
        assert_eq!(serde_json::to_string(&parsed).unwrap(), raw);
    }

    #[test]
    fn money_uses_decimal() {
        let m = Money {
            amount: Decimal::new(12345, 2),
            currency: Currency("SEK".into()),
        };
        let s = serde_json::to_string(&m).unwrap();
        let back: Money = serde_json::from_str(&s).unwrap();
        assert_eq!(back, m);
    }

    #[test]
    fn amount_with_currency_round_trips() {
        let a = AmountWithCurrency {
            currency: Currency("SEK".into()),
            value: Decimal::new(98765, 2),
        };
        let s = serde_json::to_string(&a).unwrap();
        // Document the wire shape and field order produced by serde for
        // this struct (currency first, value second).
        assert_eq!(s, r#"{"currency":"SEK","value":987.65}"#);
        let back: AmountWithCurrency = serde_json::from_str(&s).unwrap();
        assert_eq!(back, a);
    }

    #[test]
    fn opt_arb_prec_round_trips_some_and_none() {
        #[derive(Serialize, Deserialize, PartialEq, Debug)]
        struct W {
            #[serde(
                default,
                skip_serializing_if = "Option::is_none",
                with = "opt_arb_prec"
            )]
            v: Option<Decimal>,
        }
        let some = W {
            v: Some(Decimal::new(31415, 4)),
        };
        let s = serde_json::to_string(&some).unwrap();
        assert_eq!(s, r#"{"v":3.1415}"#);
        let back: W = serde_json::from_str(&s).unwrap();
        assert_eq!(back, some);

        let none = W { v: None };
        let s = serde_json::to_string(&none).unwrap();
        assert_eq!(s, "{}");
    }

    #[test]
    fn date_iso8601_round_trip() {
        use time::macros::date;
        #[derive(Serialize, Deserialize, PartialEq, Debug)]
        struct W {
            #[serde(with = "date_iso8601")]
            d: time::Date,
        }
        let raw = r#"{"d":"2025-12-19"}"#;
        let parsed: W = serde_json::from_str(raw).unwrap();
        assert_eq!(parsed.d, date!(2025 - 12 - 19));
        assert_eq!(serde_json::to_string(&parsed).unwrap(), raw);
    }

    #[test]
    fn date_iso8601_option_round_trip() {
        use time::macros::date;
        #[derive(Serialize, Deserialize, PartialEq, Debug)]
        struct W {
            #[serde(default, with = "date_iso8601::option")]
            d: Option<time::Date>,
        }
        let raw_some = r#"{"d":"2026-05-02"}"#;
        let parsed: W = serde_json::from_str(raw_some).unwrap();
        assert_eq!(parsed.d, Some(date!(2026 - 05 - 02)));
        assert_eq!(serde_json::to_string(&parsed).unwrap(), raw_some);

        let raw_null = r#"{"d":null}"#;
        let parsed: W = serde_json::from_str(raw_null).unwrap();
        assert_eq!(parsed.d, None);
        // Serializing None goes back as `null` here because we did not set
        // `skip_serializing_if`. Group fields that mark the field optional
        // additionally use `skip_serializing_if = "Option::is_none"`.
        assert_eq!(serde_json::to_string(&parsed).unwrap(), raw_null);
    }

    #[test]
    fn date_iso8601_vec_round_trip() {
        use time::macros::date;
        #[derive(Serialize, Deserialize, PartialEq, Debug)]
        struct W {
            #[serde(with = "date_iso8601::vec")]
            ds: Vec<time::Date>,
        }
        let raw = r#"{"ds":["2025-12-19","2026-01-15"]}"#;
        let parsed: W = serde_json::from_str(raw).unwrap();
        assert_eq!(
            parsed.ds,
            vec![date!(2025 - 12 - 19), date!(2026 - 01 - 15)]
        );
        assert_eq!(serde_json::to_string(&parsed).unwrap(), raw);
    }

    #[test]
    fn date_iso8601_rejects_bad_format() {
        #[derive(Deserialize, Debug)]
        struct W {
            #[allow(dead_code)]
            #[serde(with = "date_iso8601")]
            d: time::Date,
        }
        // Too few components.
        let r: Result<W, _> = serde_json::from_str(r#"{"d":"2025-12"}"#);
        assert!(r.is_err());
        // Wrong separator.
        let r: Result<W, _> = serde_json::from_str(r#"{"d":"2025/12/19"}"#);
        assert!(r.is_err());
    }
}