Skip to main content

ssg/plugins/seo/jsonld/
iso20022.rs

1// Copyright © 2023 - 2026 Static Site Generator (SSG). All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! ISO 20022 / banking domain JSON-LD types.
5//!
6//! Opt-in extension to the [`crate::seo::JsonLdPlugin`] that lets
7//! fintech / banking / payments authors emit strongly-typed, Schema.org
8//! compatible JSON-LD blobs for the five most common financial domain
9//! entities. Ports concepts from the ISO 20022 messaging vocabulary into
10//! a Schema.org-friendly shape:
11//!
12//! - [`BankAccount`] — Schema.org `BankAccount` with IBAN/BIC.
13//! - [`PaymentInstrument`] — card, transfer, direct debit.
14//! - [`FinancialTransaction`] — extends Schema.org `MoneyTransfer`.
15//! - [`RegulatedFinancialInstitution`] — extends `Organization`.
16//! - [`FinancialProduct`] — loan, deposit, derivative.
17//!
18//! All ISO-20022-specific fields are emitted under the `iso20022:`
19//! namespace prefix so that Schema.org-only validators still see a
20//! conformant payload.
21//!
22//! # Validation
23//!
24//! Two validators are bundled:
25//!
26//! - [`validate_iban`] — performs MOD-97 checksum verification per
27//!   ISO 13616.
28//! - [`validate_bic`] — checks the 8/11-character ISO 9362 layout.
29//!
30//! Invalid values do NOT fail the build — they emit a `log::warn!`
31//! naming the offending page and the field that didn't validate.
32//!
33//! # Documentation pointer
34//!
35//! On first use within a build, [`log_first_use_pointer`] emits an
36//! info-level log pointing at the canonical docs URL.
37//!
38//! # Schema.org base mapping
39//!
40//! | ISO 20022 type                   | `@type`                        |
41//! |----------------------------------|--------------------------------|
42//! | `BankAccount`                    | `BankAccount`                  |
43//! | `PaymentInstrument`              | `PaymentService`               |
44//! | `FinancialTransaction`           | `MoneyTransfer`                |
45//! | `RegulatedFinancialInstitution`  | `BankOrCreditUnion`            |
46//! | `FinancialProduct`               | `FinancialProduct`             |
47
48use serde::{Deserialize, Serialize};
49use std::sync::atomic::{AtomicBool, Ordering};
50
51/// Canonical docs URL emitted on first use within a build.
52pub const DOCS_URL: &str =
53    "https://docs.rs/ssg/latest/ssg/seo/jsonld/iso20022/index.html";
54
55/// Tracks whether the first-use info pointer has fired during this
56/// process, so we don't spam the log for every page on a large site.
57static FIRST_USE_LOGGED: AtomicBool = AtomicBool::new(false);
58
59/// Emits an info-level log pointing at the iso20022 docs URL,
60/// exactly once per process. Idempotent.
61///
62/// Resolves AC7.
63///
64/// # Examples
65///
66/// ```
67/// use ssg::seo::jsonld::iso20022::log_first_use_pointer;
68/// // Idempotent — second call is a no-op.
69/// log_first_use_pointer();
70/// log_first_use_pointer();
71/// ```
72pub fn log_first_use_pointer() {
73    if !FIRST_USE_LOGGED.swap(true, Ordering::Relaxed) {
74        log::info!(
75            "[json-ld/iso20022] First use of ISO 20022 frontmatter detected. \
76             See {DOCS_URL} for the list of available types and required fields."
77        );
78    }
79}
80
81#[cfg(test)]
82pub(crate) fn reset_first_use_for_test() {
83    FIRST_USE_LOGGED.store(false, Ordering::Relaxed);
84}
85
86#[cfg(test)]
87pub(crate) fn first_use_was_logged() -> bool {
88    FIRST_USE_LOGGED.load(Ordering::Relaxed)
89}
90
91// =====================================================================
92// Validators
93// =====================================================================
94
95/// Result of an ISO 20022 field validation. Errors are warnings, not
96/// hard failures — the build continues regardless.
97#[derive(Debug, Clone, PartialEq, Eq)]
98pub enum ValidationOutcome {
99    /// The value parsed and passed all syntactic + checksum checks.
100    Valid,
101    /// The value failed validation; `reason` is a human-readable note.
102    Invalid {
103        /// Why the value was rejected (length, checksum mismatch, etc.).
104        reason: String,
105    },
106}
107
108impl ValidationOutcome {
109    /// Returns `true` when the outcome is [`ValidationOutcome::Valid`].
110    ///
111    /// # Examples
112    ///
113    /// ```
114    /// use ssg::seo::jsonld::iso20022::ValidationOutcome;
115    /// assert!(ValidationOutcome::Valid.is_valid());
116    /// let bad = ValidationOutcome::Invalid { reason: "x".into() };
117    /// assert!(!bad.is_valid());
118    /// ```
119    #[must_use]
120    pub const fn is_valid(&self) -> bool {
121        matches!(self, Self::Valid)
122    }
123}
124
125/// Masks the middle of a financial identifier for logging.
126///
127/// # Why logging differs from publishing
128///
129/// An IBAN given in front matter is *meant* to be published — it ends up
130/// in the emitted JSON-LD as `iso20022:iban`, because the author is
131/// advertising payment details on purpose. A build log is a different
132/// channel: it is captured by CI, retained in artefacts, and read over
133/// shoulders. Writing a full account number there is gratuitous, and
134/// `CodeQL`'s `rust/cleartext-logging` rule is right to flag it.
135///
136/// Enough is kept to act on the warning — the leading country and bank
137/// prefix, and the trailing digits — while the account-identifying
138/// middle is masked. The `reason` in the same message already says what
139/// is wrong, so the author can find the value in their own front matter
140/// without the log restating it.
141///
142/// Short inputs are masked entirely rather than partially: a 6-character
143/// value split 4-and-2 would reveal most of itself.
144///
145/// # Examples
146///
147/// ```
148/// use ssg::seo::jsonld::iso20022::redact_for_log;
149/// assert_eq!(redact_for_log("GB29NWBK60161331926819"), "GB29…6819");
150/// assert_eq!(redact_for_log("NWBKGB2L"), "………");
151/// assert_eq!(redact_for_log(""), "………");
152/// ```
153#[must_use]
154pub fn redact_for_log(value: &str) -> String {
155    // Count by characters, not bytes: an operator may paste anything
156    // into front matter, and slicing a multi-byte scalar would panic.
157    let chars: Vec<char> = value.chars().collect();
158    if chars.len() <= 12 {
159        return "………".to_string();
160    }
161    let head: String = chars[..4].iter().collect();
162    let tail: String = chars[chars.len() - 4..].iter().collect();
163    format!("{head}…{tail}")
164}
165
166/// Validates an IBAN (ISO 13616) using the MOD-97 checksum.
167///
168/// Accepts the canonical compact form (no spaces) as well as the
169/// space-delimited print form. Length bounds: 15–34 characters
170/// once whitespace is stripped.
171///
172/// # Algorithm
173///
174/// 1. Strip all ASCII whitespace; upper-case.
175/// 2. Move the first 4 characters (country + check digits) to the end.
176/// 3. Map letters A-Z → 10..=35.
177/// 4. The resulting integer must be congruent to 1 mod 97.
178///
179/// Implemented without `num-bigint` by walking the digit string left
180/// to right, taking each modulo step incrementally — this keeps the
181/// crate dependency-free for the ISO validator.
182///
183/// # Examples
184///
185/// ```
186/// use ssg::seo::jsonld::iso20022::validate_iban;
187/// assert!(validate_iban("GB29NWBK60161331926819").is_valid());
188/// assert!(!validate_iban("GB29").is_valid());
189/// ```
190#[must_use]
191pub fn validate_iban(input: &str) -> ValidationOutcome {
192    match check_iban(input) {
193        Ok(()) => ValidationOutcome::Valid,
194        Err(fault) => ValidationOutcome::Invalid {
195            reason: fault.reason(),
196        },
197    }
198}
199
200/// The first failure [`check_iban`] found, with the measurements behind
201/// [`validate_iban`]'s detailed reason.
202#[derive(Clone, Copy, Debug, PartialEq, Eq)]
203enum IbanFault {
204    Length(usize),
205    CountryCode,
206    CheckDigits,
207    Bban,
208    Character { position: usize },
209    Checksum { remainder: u64 },
210}
211
212impl IbanFault {
213    /// The detailed reason for callers of [`validate_iban`]; never logged.
214    fn reason(self) -> String {
215        match self {
216            Self::Length(len) => {
217                format!("IBAN length {len} outside ISO 13616 range 15..=34")
218            }
219            Self::CountryCode => {
220                "IBAN country code must be two ASCII letters".to_string()
221            }
222            Self::CheckDigits => {
223                "IBAN check digits must be two ASCII digits".to_string()
224            }
225            Self::Bban => "IBAN BBAN must be alphanumeric ASCII".to_string(),
226            Self::Character { position } => {
227                format!(
228                    "Non-alphanumeric character in IBAN at position {position}"
229                )
230            }
231            Self::Checksum { remainder } => {
232                format!("IBAN MOD-97 checksum failed (remainder={remainder})")
233            }
234        }
235    }
236}
237
238/// The checks behind [`validate_iban`], returning the first failure.
239fn check_iban(input: &str) -> Result<(), IbanFault> {
240    let compact: String = input
241        .chars()
242        .filter(|c| !c.is_whitespace())
243        .collect::<String>()
244        .to_ascii_uppercase();
245
246    if compact.len() < 15 || compact.len() > 34 {
247        return Err(IbanFault::Length(compact.len()));
248    }
249
250    // First two chars must be ASCII letters (country code), next two ASCII digits (check digits).
251    let bytes = compact.as_bytes();
252    if !(bytes[0].is_ascii_alphabetic() && bytes[1].is_ascii_alphabetic()) {
253        return Err(IbanFault::CountryCode);
254    }
255    if !(bytes[2].is_ascii_digit() && bytes[3].is_ascii_digit()) {
256        return Err(IbanFault::CheckDigits);
257    }
258    // The remaining BBAN must be alphanumeric.
259    if !bytes[4..].iter().all(u8::is_ascii_alphanumeric) {
260        return Err(IbanFault::Bban);
261    }
262
263    // Rearrange: move first 4 chars to end.
264    let mut rearranged = String::with_capacity(compact.len());
265    rearranged.push_str(&compact[4..]);
266    rearranged.push_str(&compact[..4]);
267
268    // Expand letters → numeric (A=10..Z=35) and compute mod 97 streaming.
269    let mut remainder: u64 = 0;
270    for (position, ch) in rearranged.chars().enumerate() {
271        let digits: u64 = if ch.is_ascii_digit() {
272            u64::from(ch as u8 - b'0')
273        } else if ch.is_ascii_alphabetic() {
274            u64::from((ch.to_ascii_uppercase() as u8) - b'A') + 10
275        } else {
276            // The offending character is deliberately not echoed. This
277            // reason reaches `log::warn!` in `warn_invalid_fields`, which
278            // redacts the IBAN itself -- and then quoted one character of
279            // it straight back into the same line, which is what
280            // `rust/cleartext-logging` flagged. A position is enough to
281            // locate the problem and reveals nothing about the account.
282            return Err(IbanFault::Character { position });
283        };
284        // Each letter expands to two digits (10..=35); fold accordingly.
285        if digits >= 10 {
286            remainder = (remainder * 100 + digits) % 97;
287        } else {
288            remainder = (remainder * 10 + digits) % 97;
289        }
290    }
291
292    if remainder == 1 {
293        Ok(())
294    } else {
295        Err(IbanFault::Checksum { remainder })
296    }
297}
298
299/// Validates a BIC (ISO 9362) by length + alphanumeric layout.
300///
301/// Valid BICs are 8 or 11 characters, all ASCII letters and digits.
302/// The first 4 are the bank code (letters), next 2 the country code
303/// (letters), next 2 the location code (alphanumeric); positions 9–11
304/// (when present) are the branch code.
305///
306/// # Examples
307///
308/// ```
309/// use ssg::seo::jsonld::iso20022::validate_bic;
310/// assert!(validate_bic("NWBKGB2L").is_valid());
311/// assert!(validate_bic("NWBKGB2LXXX").is_valid());
312/// assert!(!validate_bic("NWBKGB").is_valid());
313/// ```
314#[must_use]
315pub fn validate_bic(input: &str) -> ValidationOutcome {
316    let compact: String =
317        input.chars().filter(|c| !c.is_whitespace()).collect();
318
319    if compact.len() != 8 && compact.len() != 11 {
320        return ValidationOutcome::Invalid {
321            reason: format!(
322                "BIC length {} is not 8 or 11 (ISO 9362)",
323                compact.len()
324            ),
325        };
326    }
327    let upper = compact.to_ascii_uppercase();
328    let bytes = upper.as_bytes();
329
330    // Bank code: 4 letters
331    if !bytes[..4].iter().all(u8::is_ascii_alphabetic) {
332        return ValidationOutcome::Invalid {
333            reason: "BIC bank code (chars 1-4) must be ASCII letters"
334                .to_string(),
335        };
336    }
337    // Country code: 2 letters
338    if !bytes[4..6].iter().all(u8::is_ascii_alphabetic) {
339        return ValidationOutcome::Invalid {
340            reason: "BIC country code (chars 5-6) must be ASCII letters"
341                .to_string(),
342        };
343    }
344    // Location code: 2 alphanumerics
345    if !bytes[6..8].iter().all(u8::is_ascii_alphanumeric) {
346        return ValidationOutcome::Invalid {
347            reason: "BIC location code (chars 7-8) must be ASCII alphanumerics"
348                .to_string(),
349        };
350    }
351    // Optional branch code: 3 alphanumerics
352    if compact.len() == 11
353        && !bytes[8..11].iter().all(u8::is_ascii_alphanumeric)
354    {
355        return ValidationOutcome::Invalid {
356            reason: "BIC branch code (chars 9-11) must be ASCII alphanumerics"
357                .to_string(),
358        };
359    }
360
361    ValidationOutcome::Valid
362}
363
364// =====================================================================
365// Domain types
366// =====================================================================
367
368/// ISO 4217 monetary amount.
369///
370/// Skips serialisation when both fields are empty/zero — keeps the
371/// JSON-LD compact when authors leave the amount out of frontmatter.
372#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
373pub struct MonetaryAmount {
374    /// ISO 4217 currency code (e.g. `EUR`, `USD`).
375    pub currency: String,
376    /// Numeric amount.
377    pub amount: f64,
378}
379
380impl MonetaryAmount {
381    /// Renders to a Schema.org `MonetaryAmount` JSON value.
382    ///
383    /// # Examples
384    ///
385    /// ```
386    /// use ssg::seo::jsonld::iso20022::MonetaryAmount;
387    /// let m = MonetaryAmount { currency: "EUR".into(), amount: 100.0 };
388    /// let v = m.to_jsonld();
389    /// assert_eq!(v["currency"], "EUR");
390    /// assert_eq!(v["value"], 100.0);
391    /// ```
392    #[must_use]
393    pub fn to_jsonld(&self) -> serde_json::Value {
394        serde_json::json!({
395            "@type": "MonetaryAmount",
396            "currency": self.currency,
397            "value": self.amount,
398        })
399    }
400}
401
402/// A bank account record. Schema.org base type: `BankAccount`.
403///
404/// IBAN/BIC are optional — but if either is supplied, they are validated
405/// and warnings emitted on mismatch. The IBAN ends up under the
406/// `iso20022:iban` namespaced field; BIC similarly under `iso20022:bic`.
407#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
408pub struct BankAccount {
409    /// Account holder display name.
410    #[serde(default, skip_serializing_if = "Option::is_none")]
411    pub name: Option<String>,
412    /// International Bank Account Number (ISO 13616).
413    #[serde(default, skip_serializing_if = "Option::is_none")]
414    pub iban: Option<String>,
415    /// Bank Identifier Code (ISO 9362, 8 or 11 chars).
416    #[serde(default, skip_serializing_if = "Option::is_none")]
417    pub bic: Option<String>,
418}
419
420impl BankAccount {
421    /// Builds the JSON-LD blob for this account.
422    ///
423    /// # Examples
424    ///
425    /// ```
426    /// use ssg::seo::jsonld::iso20022::BankAccount;
427    /// let acc = BankAccount {
428    ///     name: Some("Treasury".into()),
429    ///     iban: Some("GB29NWBK60161331926819".into()),
430    ///     bic: None,
431    /// };
432    /// let v = acc.to_jsonld();
433    /// assert_eq!(v["@type"], "BankAccount");
434    /// assert_eq!(v["iso20022:iban"], "GB29NWBK60161331926819");
435    /// ```
436    #[must_use]
437    pub fn to_jsonld(&self) -> serde_json::Value {
438        let mut obj = serde_json::json!({
439            "@context": context_with_iso(),
440            "@type": "BankAccount",
441        });
442        if let Some(name) = &self.name {
443            obj["name"] = serde_json::json!(name);
444        }
445        if let Some(iban) = &self.iban {
446            // The IBAN is already the typed identifier via the
447            // `iso20022:iban` namespace; duplicating it into the
448            // generic schema.org `identifier` field would publish the
449            // same string twice and trip downstream consumers that
450            // expect distinct values across fields.
451            obj["iso20022:iban"] = serde_json::json!(iban);
452        }
453        if let Some(bic) = &self.bic {
454            obj["iso20022:bic"] = serde_json::json!(bic);
455        }
456        obj
457    }
458}
459
460/// A payment instrument (card, transfer, direct debit). Schema.org
461/// base type: `PaymentService`.
462#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
463pub struct PaymentInstrument {
464    /// Human-readable name (e.g. "Visa Debit").
465    #[serde(default, skip_serializing_if = "Option::is_none")]
466    pub name: Option<String>,
467    /// Instrument family. Accepted values: `card`, `transfer`,
468    /// `direct_debit` — anything else passes through.
469    pub instrument_type: String,
470    /// Optional brand string (e.g. "Visa", "SEPA").
471    #[serde(default, skip_serializing_if = "Option::is_none")]
472    pub brand: Option<String>,
473}
474
475impl PaymentInstrument {
476    /// Builds the JSON-LD blob for this instrument.
477    ///
478    /// # Examples
479    ///
480    /// ```
481    /// use ssg::seo::jsonld::iso20022::PaymentInstrument;
482    /// let p = PaymentInstrument {
483    ///     name: Some("Visa Debit".into()),
484    ///     instrument_type: "card".into(),
485    ///     brand: Some("Visa".into()),
486    /// };
487    /// let v = p.to_jsonld();
488    /// assert_eq!(v["@type"], "PaymentService");
489    /// assert_eq!(v["iso20022:instrumentType"], "card");
490    /// ```
491    #[must_use]
492    pub fn to_jsonld(&self) -> serde_json::Value {
493        let mut obj = serde_json::json!({
494            "@context": context_with_iso(),
495            "@type": "PaymentService",
496            "iso20022:instrumentType": self.instrument_type,
497        });
498        if let Some(name) = &self.name {
499            obj["name"] = serde_json::json!(name);
500        }
501        if let Some(brand) = &self.brand {
502            obj["brand"] = serde_json::json!(brand);
503        }
504        obj
505    }
506}
507
508/// A financial transaction. Schema.org base type: `MoneyTransfer`.
509///
510/// Authors typically supply `instructed_amount` plus debtor/creditor
511/// accounts; the resulting JSON-LD exposes a Schema.org-shaped
512/// `amount` field alongside the namespaced `iso20022:*Account`
513/// references.
514#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
515pub struct FinancialTransaction {
516    /// Amount being transferred.
517    #[serde(default, skip_serializing_if = "Option::is_none")]
518    pub instructed_amount: Option<MonetaryAmount>,
519    /// Account being debited.
520    #[serde(default, skip_serializing_if = "Option::is_none")]
521    pub debtor_account: Option<BankAccount>,
522    /// Account being credited.
523    #[serde(default, skip_serializing_if = "Option::is_none")]
524    pub creditor_account: Option<BankAccount>,
525    /// ISO 8601 timestamp.
526    #[serde(default, skip_serializing_if = "Option::is_none")]
527    pub execution_date: Option<String>,
528    /// Optional unique identifier (`EndToEndId` / `MessageId`).
529    #[serde(default, skip_serializing_if = "Option::is_none")]
530    pub end_to_end_id: Option<String>,
531}
532
533impl FinancialTransaction {
534    /// Builds the JSON-LD blob for this transaction.
535    ///
536    /// # Examples
537    ///
538    /// ```
539    /// use ssg::seo::jsonld::iso20022::{FinancialTransaction, MonetaryAmount};
540    /// let tx = FinancialTransaction {
541    ///     instructed_amount: Some(MonetaryAmount {
542    ///         currency: "EUR".into(),
543    ///         amount: 50.0,
544    ///     }),
545    ///     ..FinancialTransaction::default()
546    /// };
547    /// let v = tx.to_jsonld();
548    /// assert_eq!(v["@type"], "MoneyTransfer");
549    /// assert_eq!(v["amount"]["currency"], "EUR");
550    /// ```
551    #[must_use]
552    pub fn to_jsonld(&self) -> serde_json::Value {
553        let mut obj = serde_json::json!({
554            "@context": context_with_iso(),
555            "@type": "MoneyTransfer",
556        });
557        if let Some(amount) = &self.instructed_amount {
558            obj["amount"] = amount.to_jsonld();
559        }
560        if let Some(debtor) = &self.debtor_account {
561            obj["iso20022:debtorAccount"] = strip_context(debtor.to_jsonld());
562        }
563        if let Some(creditor) = &self.creditor_account {
564            obj["iso20022:creditorAccount"] =
565                strip_context(creditor.to_jsonld());
566        }
567        if let Some(date) = &self.execution_date {
568            obj["iso20022:executionDate"] = serde_json::json!(date);
569        }
570        if let Some(id) = &self.end_to_end_id {
571            obj["iso20022:endToEndId"] = serde_json::json!(id);
572            obj["identifier"] = serde_json::json!(id);
573        }
574        obj
575    }
576}
577
578/// A regulated financial institution. Schema.org base type:
579/// `BankOrCreditUnion`.
580#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
581pub struct RegulatedFinancialInstitution {
582    /// Display name of the institution.
583    pub name: String,
584    /// Optional Legal Entity Identifier (ISO 17442).
585    #[serde(default, skip_serializing_if = "Option::is_none")]
586    pub lei: Option<String>,
587    /// Regulator's licence reference (e.g. UK FCA FRN).
588    #[serde(default, skip_serializing_if = "Option::is_none")]
589    pub licence_id: Option<String>,
590    /// Regulator name (e.g. "FCA", "`BaFin`", "ECB").
591    #[serde(default, skip_serializing_if = "Option::is_none")]
592    pub regulator: Option<String>,
593    /// Optional canonical URL.
594    #[serde(default, skip_serializing_if = "Option::is_none")]
595    pub url: Option<String>,
596}
597
598impl RegulatedFinancialInstitution {
599    /// Builds the JSON-LD blob for this institution.
600    ///
601    /// # Examples
602    ///
603    /// ```
604    /// use ssg::seo::jsonld::iso20022::RegulatedFinancialInstitution;
605    /// let r = RegulatedFinancialInstitution {
606    ///     name: "Acme Bank".into(),
607    ///     ..RegulatedFinancialInstitution::default()
608    /// };
609    /// let v = r.to_jsonld();
610    /// assert_eq!(v["@type"], "BankOrCreditUnion");
611    /// assert_eq!(v["name"], "Acme Bank");
612    /// ```
613    #[must_use]
614    pub fn to_jsonld(&self) -> serde_json::Value {
615        let mut obj = serde_json::json!({
616            "@context": context_with_iso(),
617            "@type": "BankOrCreditUnion",
618            "name": self.name,
619        });
620        if let Some(lei) = &self.lei {
621            obj["iso20022:lei"] = serde_json::json!(lei);
622            obj["identifier"] = serde_json::json!(lei);
623        }
624        if let Some(licence) = &self.licence_id {
625            obj["iso20022:licenceId"] = serde_json::json!(licence);
626        }
627        if let Some(regulator) = &self.regulator {
628            obj["iso20022:regulator"] = serde_json::json!(regulator);
629        }
630        if let Some(url) = &self.url {
631            obj["url"] = serde_json::json!(url);
632        }
633        obj
634    }
635}
636
637/// A financial product (loan, deposit, derivative). Schema.org base
638/// type: `FinancialProduct`.
639#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
640pub struct FinancialProduct {
641    /// Product display name.
642    pub name: String,
643    /// Product category. Accepted values: `loan`, `deposit`,
644    /// `derivative` — anything else passes through.
645    pub product_type: String,
646    /// Optional issuing institution.
647    #[serde(default, skip_serializing_if = "Option::is_none")]
648    pub issuer: Option<String>,
649    /// Optional annual percentage rate.
650    #[serde(default, skip_serializing_if = "Option::is_none")]
651    pub annual_percentage_rate: Option<f64>,
652    /// Optional ISIN (ISO 6166).
653    #[serde(default, skip_serializing_if = "Option::is_none")]
654    pub isin: Option<String>,
655}
656
657impl FinancialProduct {
658    /// Builds the JSON-LD blob for this product.
659    ///
660    /// # Examples
661    ///
662    /// ```
663    /// use ssg::seo::jsonld::iso20022::FinancialProduct;
664    /// let p = FinancialProduct {
665    ///     name: "Green Bond".into(),
666    ///     product_type: "deposit".into(),
667    ///     ..FinancialProduct::default()
668    /// };
669    /// let v = p.to_jsonld();
670    /// assert_eq!(v["@type"], "FinancialProduct");
671    /// assert_eq!(v["iso20022:productType"], "deposit");
672    /// ```
673    #[must_use]
674    pub fn to_jsonld(&self) -> serde_json::Value {
675        let mut obj = serde_json::json!({
676            "@context": context_with_iso(),
677            "@type": "FinancialProduct",
678            "name": self.name,
679            "iso20022:productType": self.product_type,
680        });
681        if let Some(issuer) = &self.issuer {
682            obj["provider"] = serde_json::json!({
683                "@type": "Organization",
684                "name": issuer,
685            });
686        }
687        if let Some(apr) = self.annual_percentage_rate {
688            obj["annualPercentageRate"] = serde_json::json!(apr);
689        }
690        if let Some(isin) = &self.isin {
691            obj["iso20022:isin"] = serde_json::json!(isin);
692            obj["identifier"] = serde_json::json!(isin);
693        }
694        obj
695    }
696}
697
698/// Builds the `@context` object that pulls in the `iso20022:` prefix
699/// alongside the Schema.org base context.
700fn context_with_iso() -> serde_json::Value {
701    serde_json::json!({
702        "@vocab": "https://schema.org/",
703        "iso20022": "https://www.iso20022.org/",
704    })
705}
706
707/// Strips the `@context` field from a nested JSON-LD value — used when
708/// embedding a sub-entity (e.g. an account inside a transaction) so
709/// the context only appears once at the top level.
710fn strip_context(mut value: serde_json::Value) -> serde_json::Value {
711    if let Some(obj) = value.as_object_mut() {
712        let _ = obj.remove("@context");
713    }
714    value
715}
716
717// =====================================================================
718// Frontmatter dispatch
719// =====================================================================
720
721/// Tagged union dispatched from the `iso20022.type` frontmatter key.
722#[derive(Debug, Clone, PartialEq)]
723pub enum Iso20022Entity {
724    /// Bank account variant — see [`BankAccount`].
725    BankAccount(BankAccount),
726    /// Payment instrument variant — see [`PaymentInstrument`].
727    PaymentInstrument(PaymentInstrument),
728    /// Financial transaction variant — see [`FinancialTransaction`].
729    FinancialTransaction(FinancialTransaction),
730    /// Regulated financial institution variant — see [`RegulatedFinancialInstitution`].
731    RegulatedFinancialInstitution(RegulatedFinancialInstitution),
732    /// Financial product variant — see [`FinancialProduct`].
733    FinancialProduct(FinancialProduct),
734}
735
736impl Iso20022Entity {
737    /// Renders this entity to a JSON-LD blob.
738    ///
739    /// # Examples
740    ///
741    /// ```
742    /// use ssg::seo::jsonld::iso20022::{BankAccount, Iso20022Entity};
743    /// let e = Iso20022Entity::BankAccount(BankAccount::default());
744    /// let v = e.to_jsonld();
745    /// assert_eq!(v["@type"], "BankAccount");
746    /// ```
747    #[must_use]
748    pub fn to_jsonld(&self) -> serde_json::Value {
749        match self {
750            Self::BankAccount(b) => b.to_jsonld(),
751            Self::PaymentInstrument(p) => p.to_jsonld(),
752            Self::FinancialTransaction(t) => t.to_jsonld(),
753            Self::RegulatedFinancialInstitution(r) => r.to_jsonld(),
754            Self::FinancialProduct(p) => p.to_jsonld(),
755        }
756    }
757
758    /// Returns the discriminant string (e.g. `"BankAccount"`).
759    ///
760    /// # Examples
761    ///
762    /// ```
763    /// use ssg::seo::jsonld::iso20022::{BankAccount, Iso20022Entity};
764    /// let e = Iso20022Entity::BankAccount(BankAccount::default());
765    /// assert_eq!(e.type_name(), "BankAccount");
766    /// ```
767    #[must_use]
768    pub const fn type_name(&self) -> &'static str {
769        match self {
770            Self::BankAccount(_) => "BankAccount",
771            Self::PaymentInstrument(_) => "PaymentInstrument",
772            Self::FinancialTransaction(_) => "FinancialTransaction",
773            Self::RegulatedFinancialInstitution(_) => {
774                "RegulatedFinancialInstitution"
775            }
776            Self::FinancialProduct(_) => "FinancialProduct",
777        }
778    }
779}
780
781/// Errors that can occur when interpreting an `iso20022:` frontmatter
782/// block. These do NOT abort the build — they cause the block to be
783/// skipped with a warning.
784#[derive(Debug, Clone, PartialEq, Eq)]
785pub enum DispatchError {
786    /// The frontmatter `iso20022.type` field was missing.
787    MissingType,
788    /// The `type` field referenced an unknown discriminant.
789    UnknownType(String),
790    /// The payload failed to deserialise into the requested type.
791    Malformed(String),
792}
793
794impl std::fmt::Display for DispatchError {
795    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
796        match self {
797            Self::MissingType => write!(
798                f,
799                "iso20022 frontmatter is missing the required `type` field"
800            ),
801            Self::UnknownType(t) => write!(
802                f,
803                "iso20022 frontmatter type `{t}` is not one of: \
804                 BankAccount, PaymentInstrument, FinancialTransaction, \
805                 RegulatedFinancialInstitution, FinancialProduct"
806            ),
807            Self::Malformed(reason) => {
808                write!(f, "iso20022 frontmatter payload is malformed: {reason}")
809            }
810        }
811    }
812}
813
814/// Parses the `iso20022:` frontmatter block into a typed entity.
815///
816/// Returns `Err(DispatchError)` if the discriminant is missing or
817/// unknown — caller decides whether to warn or fail.
818///
819/// # Errors
820///
821/// Returns [`DispatchError::MissingType`] when no `type` discriminant
822/// is present, [`DispatchError::UnknownType`] when the discriminant
823/// is unrecognised, or [`DispatchError::Malformed`] when the payload
824/// fails to deserialise.
825///
826/// # Examples
827///
828/// ```
829/// use ssg::seo::jsonld::iso20022::from_frontmatter;
830/// let fm = serde_json::json!({
831///     "type": "BankAccount",
832///     "iban": "GB29NWBK60161331926819",
833/// });
834/// let entity = from_frontmatter(&fm).unwrap();
835/// assert_eq!(entity.type_name(), "BankAccount");
836/// ```
837pub fn from_frontmatter(
838    value: &serde_json::Value,
839) -> Result<Iso20022Entity, DispatchError> {
840    let type_str = value
841        .get("type")
842        .and_then(|v| v.as_str())
843        .ok_or(DispatchError::MissingType)?;
844
845    // Clone-and-strip the `type` field so the inner shape matches the
846    // struct definition cleanly.
847    let mut payload = value.clone();
848    if let Some(obj) = payload.as_object_mut() {
849        let _ = obj.remove("type");
850    }
851
852    match type_str {
853        "BankAccount" => serde_json::from_value::<BankAccount>(payload)
854            .map(Iso20022Entity::BankAccount)
855            .map_err(|e| DispatchError::Malformed(e.to_string())),
856        "PaymentInstrument" => {
857            serde_json::from_value::<PaymentInstrument>(payload)
858                .map(Iso20022Entity::PaymentInstrument)
859                .map_err(|e| DispatchError::Malformed(e.to_string()))
860        }
861        "FinancialTransaction" => {
862            serde_json::from_value::<FinancialTransaction>(payload)
863                .map(Iso20022Entity::FinancialTransaction)
864                .map_err(|e| DispatchError::Malformed(e.to_string()))
865        }
866        "RegulatedFinancialInstitution" => {
867            serde_json::from_value::<RegulatedFinancialInstitution>(payload)
868                .map(Iso20022Entity::RegulatedFinancialInstitution)
869                .map_err(|e| DispatchError::Malformed(e.to_string()))
870        }
871        "FinancialProduct" => {
872            serde_json::from_value::<FinancialProduct>(payload)
873                .map(Iso20022Entity::FinancialProduct)
874                .map_err(|e| DispatchError::Malformed(e.to_string()))
875        }
876        other => Err(DispatchError::UnknownType(other.to_string())),
877    }
878}
879
880/// Walks every IBAN/BIC inside an entity and emits `log::warn!` for
881/// anything that fails validation. The `page_label` is included in the
882/// warning so site authors can locate the offending page.
883///
884/// Returns the count of validation warnings emitted — useful for
885/// asserting in tests.
886///
887/// # Examples
888///
889/// ```
890/// use ssg::seo::jsonld::iso20022::{BankAccount, Iso20022Entity, warn_invalid_fields};
891/// let e = Iso20022Entity::BankAccount(BankAccount {
892///     iban: Some("INVALID".into()),
893///     ..BankAccount::default()
894/// });
895/// assert_eq!(warn_invalid_fields(&e, "page.md"), 1);
896/// ```
897pub fn warn_invalid_fields(entity: &Iso20022Entity, page_label: &str) -> usize {
898    // Each arm logs a literal: the account decides which line prints,
899    // but nothing read or computed from it is interpolated, so there is
900    // no data flow from the IBAN to the log (`rust/cleartext-logging`).
901    // Only the page and the field are named.
902    fn warn_iban(page_label: &str, iban: &str, who: &str) -> usize {
903        let Err(fault) = check_iban(iban) else {
904            return 0;
905        };
906        match fault {
907            IbanFault::Length(_) => log::warn!(
908                "[json-ld/iso20022] {page_label}: invalid IBAN on {who}: \
909                 length outside the ISO 13616 range 15..=34"
910            ),
911            IbanFault::CountryCode => log::warn!(
912                "[json-ld/iso20022] {page_label}: invalid IBAN on {who}: \
913                 country code is not two ASCII letters"
914            ),
915            IbanFault::CheckDigits => log::warn!(
916                "[json-ld/iso20022] {page_label}: invalid IBAN on {who}: \
917                 check digits are not two ASCII digits"
918            ),
919            IbanFault::Bban => log::warn!(
920                "[json-ld/iso20022] {page_label}: invalid IBAN on {who}: \
921                 BBAN is not alphanumeric ASCII"
922            ),
923            IbanFault::Character { .. } => log::warn!(
924                "[json-ld/iso20022] {page_label}: invalid IBAN on {who}: \
925                 contains a non-alphanumeric character"
926            ),
927            IbanFault::Checksum { .. } => log::warn!(
928                "[json-ld/iso20022] {page_label}: invalid IBAN on {who}: \
929                 MOD-97 checksum failed"
930            ),
931        }
932        1
933    }
934    fn warn_bic(page_label: &str, bic: &str, who: &str) -> usize {
935        if let ValidationOutcome::Invalid { reason } = validate_bic(bic) {
936            log::warn!(
937                "[json-ld/iso20022] {page_label}: invalid BIC on {who}: \
938                 {} — {reason}",
939                redact_for_log(bic)
940            );
941            1
942        } else {
943            0
944        }
945    }
946
947    let mut warnings = 0_usize;
948    match entity {
949        Iso20022Entity::BankAccount(b) => {
950            if let Some(iban) = &b.iban {
951                warnings += warn_iban(page_label, iban, "bank_account.iban");
952            }
953            if let Some(bic) = &b.bic {
954                warnings += warn_bic(page_label, bic, "bank_account.bic");
955            }
956        }
957        Iso20022Entity::FinancialTransaction(t) => {
958            if let Some(d) = &t.debtor_account {
959                if let Some(iban) = &d.iban {
960                    warnings +=
961                        warn_iban(page_label, iban, "debtor_account.iban");
962                }
963                if let Some(bic) = &d.bic {
964                    warnings += warn_bic(page_label, bic, "debtor_account.bic");
965                }
966            }
967            if let Some(c) = &t.creditor_account {
968                if let Some(iban) = &c.iban {
969                    warnings +=
970                        warn_iban(page_label, iban, "creditor_account.iban");
971                }
972                if let Some(bic) = &c.bic {
973                    warnings +=
974                        warn_bic(page_label, bic, "creditor_account.bic");
975                }
976            }
977        }
978        Iso20022Entity::RegulatedFinancialInstitution(_)
979        | Iso20022Entity::PaymentInstrument(_)
980        | Iso20022Entity::FinancialProduct(_) => {}
981    }
982
983    warnings
984}
985
986// =====================================================================
987// Minimal Schema.org JSON-LD validator
988// =====================================================================
989
990/// Validation error raised by the bundled Schema.org subset validator.
991#[derive(Debug, Clone, PartialEq, Eq)]
992pub struct SchemaOrgError {
993    /// The `@type` (or "Unknown") whose required field tripped.
994    pub schema_type: String,
995    /// The missing or wrong-shape field.
996    pub field: String,
997    /// Human-readable reason.
998    pub reason: String,
999}
1000
1001/// Validates an ISO 20022 JSON-LD blob against the Schema.org subset.
1002///
1003/// Checks (per `@type`):
1004/// - `@context` is present and references `schema.org`.
1005/// - `BankAccount`: no hard required fields, but at least one
1006///   identifier (`identifier` / `iso20022:iban` / `iso20022:bic`) must
1007///   be set so search engines have something to dedupe against.
1008/// - `MoneyTransfer`: requires `amount` of shape `MonetaryAmount`.
1009/// - `BankOrCreditUnion`: requires `name`.
1010/// - `FinancialProduct`: requires `name`.
1011/// - `PaymentService`: requires the namespaced `iso20022:instrumentType`.
1012///
1013/// This is a deliberately small subset of the Schema.org vocabulary —
1014/// only the fields that affect downstream rich-result indexing.
1015///
1016/// # Examples
1017///
1018/// ```
1019/// use ssg::seo::jsonld::iso20022::{BankAccount, validate_schema_org};
1020/// let v = BankAccount {
1021///     iban: Some("GB29NWBK60161331926819".into()),
1022///     ..BankAccount::default()
1023/// }.to_jsonld();
1024/// assert!(validate_schema_org(&v).is_empty());
1025/// ```
1026#[must_use]
1027#[allow(clippy::collapsible_match)]
1028pub fn validate_schema_org(value: &serde_json::Value) -> Vec<SchemaOrgError> {
1029    let mut errors = Vec::new();
1030
1031    let schema_type = value
1032        .get("@type")
1033        .and_then(|v| v.as_str())
1034        .unwrap_or("Unknown")
1035        .to_string();
1036
1037    // Verify @context references schema.org.
1038    let ctx_ok = value.get("@context").is_some_and(|c| {
1039        let s = serde_json::to_string(c).unwrap_or_default();
1040        s.contains("schema.org")
1041    });
1042    if !ctx_ok {
1043        errors.push(SchemaOrgError {
1044            schema_type: schema_type.clone(),
1045            field: "@context".to_string(),
1046            reason: "missing or does not reference schema.org".to_string(),
1047        });
1048    }
1049
1050    {
1051        match schema_type.as_str() {
1052            "BankAccount" => {
1053                let has_id = ["identifier", "iso20022:iban", "iso20022:bic"]
1054                    .iter()
1055                    .any(|f| value.get(*f).is_some());
1056                if !has_id {
1057                    errors.push(SchemaOrgError {
1058                        schema_type,
1059                        field: "identifier|iso20022:iban|iso20022:bic"
1060                            .to_string(),
1061                        reason:
1062                            "BankAccount must carry at least one identifier"
1063                                .to_string(),
1064                    });
1065                }
1066            }
1067            "MoneyTransfer" => {
1068                let amount = value.get("amount");
1069                let amount_ok = amount.is_some_and(|a| {
1070                    a.get("@type").and_then(|t| t.as_str())
1071                        == Some("MonetaryAmount")
1072                        && a.get("currency").is_some()
1073                        && a.get("value").is_some()
1074                });
1075                if !amount_ok {
1076                    errors.push(SchemaOrgError {
1077                        schema_type,
1078                        field: "amount".to_string(),
1079                        reason: "MoneyTransfer.amount must be a \
1080                                 MonetaryAmount with currency and value"
1081                            .to_string(),
1082                    });
1083                }
1084            }
1085            "BankOrCreditUnion" | "FinancialProduct" => {
1086                let missing = value
1087                    .get("name")
1088                    .and_then(|v| v.as_str())
1089                    .is_none_or(str::is_empty);
1090                if missing {
1091                    errors.push(SchemaOrgError {
1092                        schema_type,
1093                        field: "name".to_string(),
1094                        reason: "required field is missing or empty"
1095                            .to_string(),
1096                    });
1097                }
1098            }
1099            "PaymentService" => {
1100                let missing = value.get("iso20022:instrumentType").is_none();
1101                if missing {
1102                    errors.push(SchemaOrgError {
1103                        schema_type,
1104                        field: "iso20022:instrumentType".to_string(),
1105                        reason: "PaymentService requires the namespaced \
1106                                 instrumentType field"
1107                            .to_string(),
1108                    });
1109                }
1110            }
1111            _ => {}
1112        }
1113    }
1114
1115    errors
1116}
1117
1118// =====================================================================
1119// Tests
1120// =====================================================================
1121
1122#[cfg(test)]
1123mod tests {
1124
1125    #[test]
1126    fn warn_invalid_fields_counts_each_iban_fault_once() {
1127        for bad in [
1128            "GB29",                   // length
1129            "1B29NWBK60161331926819", // country code
1130            "GBX9NWBK60161331926819", // check digits
1131            "GB29NWBK6016133192681_", // BBAN
1132            "GB29NWBK60161331926818", // checksum
1133        ] {
1134            let e = Iso20022Entity::BankAccount(BankAccount {
1135                iban: Some(bad.into()),
1136                ..BankAccount::default()
1137            });
1138            assert_eq!(warn_invalid_fields(&e, "page.md"), 1, "{bad}");
1139        }
1140        let ok = Iso20022Entity::BankAccount(BankAccount {
1141            iban: Some("GB29NWBK60161331926819".into()),
1142            ..BankAccount::default()
1143        });
1144        assert_eq!(warn_invalid_fields(&ok, "page.md"), 0);
1145    }
1146
1147    /// A validation reason must never quote the value it rejected.
1148    ///
1149    /// These reasons are logged. `warn_invalid_fields` redacts the IBAN
1150    /// before logging it and then interpolated the reason into the same
1151    /// line -- and one reason quoted a character of the account number
1152    /// straight back, which is what `rust/cleartext-logging` flagged.
1153    /// Redacting a value in one half of a log line and echoing part of it
1154    /// in the other half is not redaction.
1155    ///
1156    /// Asserted over distinctive inputs so a future reason that starts
1157    /// interpolating the value fails here rather than in a scan weeks
1158    /// later.
1159    ///
1160    /// Scope, stated because it is easy to overclaim: this covers the
1161    /// reasons `validate_iban` can actually produce. The MOD-97 loop's
1162    /// non-alphanumeric branch -- the line `CodeQL` pointed at -- is
1163    /// unreachable, because the BBAN check above it already rejects any
1164    /// non-alphanumeric character and the first four are validated
1165    /// separately. Removing the redaction there does not fail this test,
1166    /// which was verified rather than assumed. It is fixed anyway: the
1167    /// branch is one reordering away from being live.
1168    #[test]
1169    fn validation_reasons_never_quote_the_rejected_value() {
1170        let cases = [
1171            "GB82!WEST12345698765432",
1172            "GB82 WEST 1234 5698 7654 32£",
1173            "ZZ99QQQQ00000000000000",
1174            "GB82WEST1234569876543Z2",
1175        ];
1176
1177        for (case_idx, raw) in cases.iter().enumerate() {
1178            if let ValidationOutcome::Invalid { reason } = validate_iban(raw) {
1179                // Every run of 4+ alphanumerics from the input must be
1180                // absent from the reason.
1181                for chunk in raw
1182                    .split(|c: char| !c.is_ascii_alphanumeric())
1183                    .filter(|c| c.len() >= 4)
1184                {
1185                    // The assertion message deliberately does not echo
1186                    // `reason` or the input: doing so flowed the rejected
1187                    // value into a panic message, which CodeQL correctly
1188                    // reports as cleartext logging — the exact pattern this
1189                    // test exists to forbid. Case index locates a failure.
1190                    assert!(
1191                        !reason.contains(chunk),
1192                        "validation reason quotes a run of the rejected \
1193                         input (case {case_idx})"
1194                    );
1195                }
1196                // And no non-alphanumeric character from the input either.
1197                for ch in raw.chars().filter(|c| {
1198                    !c.is_ascii_alphanumeric() && !c.is_whitespace()
1199                }) {
1200                    assert!(
1201                        !reason.contains(ch),
1202                        "validation reason quotes a character of the \
1203                         rejected input (case {case_idx})"
1204                    );
1205                }
1206            }
1207        }
1208    }
1209
1210    use super::*;
1211
1212    // ── Validators ──────────────────────────────────────────────────
1213
1214    #[test]
1215    fn iban_uk_natwest_valid() {
1216        // Known good IBAN from ISO test vectors.
1217        assert!(validate_iban("GB29NWBK60161331926819").is_valid());
1218    }
1219
1220    #[test]
1221    fn iban_de_deutsche_valid() {
1222        assert!(validate_iban("DE89370400440532013000").is_valid());
1223    }
1224
1225    #[test]
1226    fn iban_accepts_space_print_form() {
1227        assert!(validate_iban("GB29 NWBK 6016 1331 9268 19").is_valid());
1228    }
1229
1230    /// Extracts the rejection reason, or the empty string for a
1231    /// [`ValidationOutcome::Valid`] result.
1232    fn invalid_reason(outcome: ValidationOutcome) -> String {
1233        match outcome {
1234            ValidationOutcome::Invalid { reason } => reason,
1235            ValidationOutcome::Valid => String::new(),
1236        }
1237    }
1238
1239    #[test]
1240    fn invalid_reason_is_empty_for_valid_outcome() {
1241        assert_eq!(invalid_reason(ValidationOutcome::Valid), "");
1242    }
1243
1244    #[test]
1245    fn iban_rejects_bad_checksum() {
1246        // Tweak last digit so MOD-97 fails.
1247        let res = validate_iban("GB29NWBK60161331926811");
1248        assert!(!res.is_valid());
1249        assert!(invalid_reason(res).contains("MOD-97"));
1250    }
1251
1252    #[test]
1253    fn iban_rejects_short_input() {
1254        assert!(!validate_iban("GB29").is_valid());
1255    }
1256
1257    #[test]
1258    fn iban_rejects_non_letter_country_code() {
1259        let res = validate_iban("1229NWBK60161331926819");
1260        assert!(!res.is_valid());
1261    }
1262
1263    #[test]
1264    fn bic_8_chars_valid() {
1265        assert!(validate_bic("NWBKGB2L").is_valid());
1266    }
1267
1268    #[test]
1269    fn bic_11_chars_valid() {
1270        assert!(validate_bic("NWBKGB2LXXX").is_valid());
1271    }
1272
1273    #[test]
1274    fn bic_rejects_9_char_length() {
1275        let res = validate_bic("NWBKGB2LX");
1276        assert!(!res.is_valid());
1277        assert!(invalid_reason(res).contains("8 or 11"));
1278    }
1279
1280    #[test]
1281    fn bic_rejects_digit_in_country_code() {
1282        // Pos 5-6 must be letters.
1283        assert!(!validate_bic("NWBK12 2L").is_valid());
1284    }
1285
1286    #[test]
1287    fn iban_rejects_non_alphanumeric_bban() {
1288        // Covers lines 180-182: BBAN char not alphanumeric.
1289        let res = validate_iban("GB29NWBK60161331926*1");
1290        assert!(matches!(
1291            &res,
1292            ValidationOutcome::Invalid { reason } if reason.contains("BBAN")
1293        ));
1294    }
1295
1296    #[test]
1297    fn iban_rejects_bad_check_digits() {
1298        // Pos 3-4 must be digits — letter there is a 173-176 hit.
1299        let res = validate_iban("GBABNWBK60161331926819");
1300        assert!(!res.is_valid());
1301    }
1302
1303    #[test]
1304    fn bic_rejects_digit_in_bank_code() {
1305        // Covers lines 254-257: chars 1-4 must be letters.
1306        let res = validate_bic("1WBKGB2L");
1307        assert!(matches!(
1308            &res,
1309            ValidationOutcome::Invalid { reason } if reason.contains("bank code")
1310        ));
1311    }
1312
1313    #[test]
1314    fn bic_rejects_letter_in_location_code() {
1315        // Covers lines 267-271: chars 7-8 must be alphanumeric.
1316        // (Using a non-ASCII or punctuation char triggers the
1317        //  alphanumeric rule; we rely on length checks first then
1318        //  alphabet rules.) A space-stripped non-ascii will be
1319        //  filtered out though; so use a punctuation symbol that
1320        //  isn't whitespace.
1321        let res = validate_bic("NWBKGB!!");
1322        assert!(!res.is_valid());
1323    }
1324
1325    #[test]
1326    fn bic_rejects_non_alphanumeric_branch_code() {
1327        // Covers lines 277-280: 11-char BIC with bad branch code.
1328        let res = validate_bic("NWBKGB2L!!!");
1329        assert!(!res.is_valid());
1330    }
1331
1332    #[test]
1333    fn entity_type_name_covers_all_variants() {
1334        // Covers the RegulatedFinancialInstitution + FinancialProduct
1335        // match arms at lines 695-698.
1336        let rfi = Iso20022Entity::RegulatedFinancialInstitution(
1337            RegulatedFinancialInstitution::default(),
1338        );
1339        assert_eq!(rfi.type_name(), "RegulatedFinancialInstitution");
1340
1341        let fp = Iso20022Entity::FinancialProduct(FinancialProduct::default());
1342        assert_eq!(fp.type_name(), "FinancialProduct");
1343    }
1344
1345    // ── Domain → JSON-LD ────────────────────────────────────────────
1346
1347    #[test]
1348    fn redact_keeps_only_the_ends_of_a_full_iban() {
1349        // Enough to locate the value in front matter, not enough to be
1350        // an account number.
1351        assert_eq!(redact_for_log("GB29NWBK60161331926819"), "GB29…6819");
1352        assert_eq!(redact_for_log("BE68539007547034"), "BE68…7034");
1353    }
1354
1355    #[test]
1356    fn redact_masks_short_values_entirely() {
1357        // A BIC is 8 or 11 characters. Splitting 4-and-4 would reveal
1358        // most of it, so anything at or under 12 is masked whole.
1359        assert_eq!(redact_for_log("NWBKGB2L"), "………");
1360        assert_eq!(redact_for_log("NWBKGB2LXXX"), "………");
1361        assert_eq!(redact_for_log(""), "………");
1362        assert_eq!(redact_for_log("GB29"), "………");
1363    }
1364
1365    #[test]
1366    fn redact_never_returns_the_input_unchanged() {
1367        // The property that matters: whatever goes in, the full value
1368        // never comes back out.
1369        for value in [
1370            "GB29NWBK60161331926819",
1371            "BE68539007547034",
1372            "NWBKGB2L",
1373            "",
1374            "not-an-iban-but-long-enough",
1375        ] {
1376            assert_ne!(
1377                redact_for_log(value),
1378                value,
1379                "redaction returned {value} unchanged"
1380            );
1381        }
1382    }
1383
1384    #[test]
1385    fn redact_handles_multibyte_input_without_panicking() {
1386        // Front matter is author-supplied; slicing by byte offset would
1387        // panic on a multi-byte scalar.
1388        let _ = redact_for_log("ééééééééééééééééé");
1389        let _ = redact_for_log("日本語のテキストです長いです");
1390        let _ = redact_for_log("🏦🏦🏦🏦🏦🏦🏦🏦🏦🏦🏦🏦🏦🏦");
1391    }
1392
1393    #[test]
1394    fn bank_account_jsonld_includes_iban_and_bic_namespaced() {
1395        let acc = BankAccount {
1396            name: Some("Treasury".to_string()),
1397            iban: Some("GB29NWBK60161331926819".to_string()),
1398            bic: Some("NWBKGB2L".to_string()),
1399        };
1400        let v = acc.to_jsonld();
1401        assert_eq!(v["@type"], "BankAccount");
1402        assert_eq!(v["iso20022:iban"], "GB29NWBK60161331926819");
1403        assert_eq!(v["iso20022:bic"], "NWBKGB2L");
1404        assert_eq!(v["name"], "Treasury");
1405        // No generic `identifier` field — the IBAN is already the
1406        // typed identifier via the `iso20022:iban` namespace.
1407        // Duplicating the same string under `identifier` was
1408        // confusing for downstream consumers (and lit up in
1409        // examples/iso20022_example.rs output).
1410        assert!(v.get("identifier").is_none());
1411    }
1412
1413    #[test]
1414    fn bank_account_jsonld_omits_optional_fields() {
1415        let v = BankAccount::default().to_jsonld();
1416        assert!(v.get("iso20022:iban").is_none());
1417        assert!(v.get("iso20022:bic").is_none());
1418        assert!(v.get("name").is_none());
1419    }
1420
1421    #[test]
1422    fn payment_instrument_emits_namespaced_type() {
1423        let p = PaymentInstrument {
1424            name: Some("Visa Debit".to_string()),
1425            instrument_type: "card".to_string(),
1426            brand: Some("Visa".to_string()),
1427        };
1428        let v = p.to_jsonld();
1429        assert_eq!(v["@type"], "PaymentService");
1430        assert_eq!(v["iso20022:instrumentType"], "card");
1431        assert_eq!(v["brand"], "Visa");
1432    }
1433
1434    #[test]
1435    fn financial_transaction_jsonld_shape_full() {
1436        let t = FinancialTransaction {
1437            instructed_amount: Some(MonetaryAmount {
1438                currency: "EUR".to_string(),
1439                amount: 1500.00,
1440            }),
1441            debtor_account: Some(BankAccount {
1442                name: None,
1443                iban: Some("GB29NWBK60161331926819".to_string()),
1444                bic: None,
1445            }),
1446            creditor_account: Some(BankAccount {
1447                name: None,
1448                iban: Some("DE89370400440532013000".to_string()),
1449                bic: None,
1450            }),
1451            execution_date: Some("2026-06-25".to_string()),
1452            end_to_end_id: Some("E2E-001".to_string()),
1453        };
1454        let v = t.to_jsonld();
1455        assert_eq!(v["@type"], "MoneyTransfer");
1456        assert_eq!(v["amount"]["currency"], "EUR");
1457        assert_eq!(v["amount"]["value"], 1500.0);
1458        assert_eq!(
1459            v["iso20022:debtorAccount"]["iso20022:iban"],
1460            "GB29NWBK60161331926819"
1461        );
1462        // Sub-objects must not carry a redundant @context.
1463        assert!(v["iso20022:debtorAccount"].get("@context").is_none());
1464        assert_eq!(v["iso20022:endToEndId"], "E2E-001");
1465        assert_eq!(v["identifier"], "E2E-001");
1466    }
1467
1468    #[test]
1469    fn regulated_institution_jsonld_includes_lei() {
1470        let r = RegulatedFinancialInstitution {
1471            name: "Acme Bank plc".to_string(),
1472            lei: Some("529900W18LQJJN6SJ336".to_string()),
1473            licence_id: Some("FCA-FRN-123456".to_string()),
1474            regulator: Some("FCA".to_string()),
1475            url: Some("https://acme.example".to_string()),
1476        };
1477        let v = r.to_jsonld();
1478        assert_eq!(v["@type"], "BankOrCreditUnion");
1479        assert_eq!(v["name"], "Acme Bank plc");
1480        assert_eq!(v["iso20022:lei"], "529900W18LQJJN6SJ336");
1481        assert_eq!(v["iso20022:licenceId"], "FCA-FRN-123456");
1482    }
1483
1484    #[test]
1485    fn financial_product_jsonld_includes_isin_and_apr() {
1486        let p = FinancialProduct {
1487            name: "Green Bond 2030".to_string(),
1488            product_type: "deposit".to_string(),
1489            issuer: Some("Acme Bank".to_string()),
1490            annual_percentage_rate: Some(3.5),
1491            isin: Some("US0378331005".to_string()),
1492        };
1493        let v = p.to_jsonld();
1494        assert_eq!(v["@type"], "FinancialProduct");
1495        assert_eq!(v["name"], "Green Bond 2030");
1496        assert_eq!(v["iso20022:productType"], "deposit");
1497        assert_eq!(v["iso20022:isin"], "US0378331005");
1498        assert_eq!(v["annualPercentageRate"], 3.5);
1499        assert_eq!(v["provider"]["@type"], "Organization");
1500        assert_eq!(v["provider"]["name"], "Acme Bank");
1501    }
1502
1503    // ── Frontmatter dispatch ────────────────────────────────────────
1504
1505    #[test]
1506    fn dispatch_bank_account_round_trip() {
1507        let fm = serde_json::json!({
1508            "type": "BankAccount",
1509            "iban": "GB29NWBK60161331926819",
1510        });
1511        let entity = from_frontmatter(&fm).unwrap();
1512        assert_eq!(entity.type_name(), "BankAccount");
1513        let jsonld = entity.to_jsonld();
1514        assert_eq!(jsonld["iso20022:iban"], "GB29NWBK60161331926819");
1515    }
1516
1517    #[test]
1518    fn dispatch_financial_transaction_from_yaml_shape() {
1519        let fm = serde_json::json!({
1520            "type": "FinancialTransaction",
1521            "instructed_amount": {"currency": "EUR", "amount": 1500.00},
1522            "debtor_account": {"iban": "GB29NWBK60161331926819"},
1523            "creditor_account": {"iban": "DE89370400440532013000"},
1524        });
1525        let entity = from_frontmatter(&fm).unwrap();
1526        assert_eq!(entity.type_name(), "FinancialTransaction");
1527        let jsonld = entity.to_jsonld();
1528        assert_eq!(jsonld["amount"]["currency"], "EUR");
1529    }
1530
1531    #[test]
1532    fn dispatch_missing_type_errors() {
1533        let fm = serde_json::json!({"iban": "GB29NWBK60161331926819"});
1534        let err = from_frontmatter(&fm).unwrap_err();
1535        assert_eq!(err, DispatchError::MissingType);
1536    }
1537
1538    #[test]
1539    fn dispatch_unknown_type_errors() {
1540        let fm = serde_json::json!({"type": "GalacticCredits"});
1541        let err = from_frontmatter(&fm).unwrap_err();
1542        assert!(
1543            matches!(err, DispatchError::UnknownType(t) if t == "GalacticCredits")
1544        );
1545    }
1546
1547    // ── Warning emission ────────────────────────────────────────────
1548
1549    #[test]
1550    fn warn_invalid_fields_counts_iban_failure() {
1551        let entity = Iso20022Entity::BankAccount(BankAccount {
1552            iban: Some("INVALID-IBAN".to_string()),
1553            ..BankAccount::default()
1554        });
1555        let warnings = warn_invalid_fields(&entity, "page.md");
1556        assert_eq!(warnings, 1);
1557    }
1558
1559    #[test]
1560    fn warn_invalid_fields_counts_zero_for_valid_iban() {
1561        let entity = Iso20022Entity::BankAccount(BankAccount {
1562            iban: Some("GB29NWBK60161331926819".to_string()),
1563            bic: Some("NWBKGB2L".to_string()),
1564            ..BankAccount::default()
1565        });
1566        let warnings = warn_invalid_fields(&entity, "page.md");
1567        assert_eq!(warnings, 0);
1568    }
1569
1570    #[test]
1571    fn warn_invalid_fields_walks_transaction_subfields() {
1572        let entity =
1573            Iso20022Entity::FinancialTransaction(FinancialTransaction {
1574                debtor_account: Some(BankAccount {
1575                    iban: Some("BAD".to_string()),
1576                    ..BankAccount::default()
1577                }),
1578                creditor_account: Some(BankAccount {
1579                    bic: Some("BADBIC".to_string()), // length 6, invalid
1580                    ..BankAccount::default()
1581                }),
1582                ..FinancialTransaction::default()
1583            });
1584        let warnings = warn_invalid_fields(&entity, "page.md");
1585        assert_eq!(warnings, 2);
1586    }
1587
1588    // ── First-use info pointer ──────────────────────────────────────
1589
1590    #[test]
1591    fn first_use_pointer_fires_exactly_once() {
1592        reset_first_use_for_test();
1593        assert!(!first_use_was_logged());
1594        log_first_use_pointer();
1595        assert!(first_use_was_logged());
1596        // Calling again is a no-op (the flag stays set).
1597        log_first_use_pointer();
1598        assert!(first_use_was_logged());
1599    }
1600
1601    // ── Schema.org subset validator ─────────────────────────────────
1602
1603    #[test]
1604    fn schema_validator_passes_bank_account_with_identifier() {
1605        let v = BankAccount {
1606            iban: Some("GB29NWBK60161331926819".to_string()),
1607            ..BankAccount::default()
1608        }
1609        .to_jsonld();
1610        assert!(
1611            validate_schema_org(&v).is_empty(),
1612            "{:?}",
1613            validate_schema_org(&v)
1614        );
1615    }
1616
1617    #[test]
1618    fn schema_validator_flags_bank_account_without_identifier() {
1619        let v = BankAccount::default().to_jsonld();
1620        let errs = validate_schema_org(&v);
1621        assert!(errs.iter().any(|e| e.field.contains("identifier")));
1622    }
1623
1624    #[test]
1625    fn schema_validator_passes_money_transfer_with_amount() {
1626        let v = FinancialTransaction {
1627            instructed_amount: Some(MonetaryAmount {
1628                currency: "USD".to_string(),
1629                amount: 10.0,
1630            }),
1631            ..FinancialTransaction::default()
1632        }
1633        .to_jsonld();
1634        assert!(
1635            validate_schema_org(&v).is_empty(),
1636            "{:?}",
1637            validate_schema_org(&v)
1638        );
1639    }
1640
1641    #[test]
1642    fn schema_validator_flags_money_transfer_missing_amount() {
1643        let v = FinancialTransaction::default().to_jsonld();
1644        let errs = validate_schema_org(&v);
1645        assert!(errs.iter().any(|e| e.field == "amount"));
1646    }
1647
1648    #[test]
1649    fn schema_validator_flags_empty_institution_name() {
1650        let v = RegulatedFinancialInstitution {
1651            name: String::new(),
1652            ..RegulatedFinancialInstitution::default()
1653        }
1654        .to_jsonld();
1655        let errs = validate_schema_org(&v);
1656        assert!(errs.iter().any(|e| e.field == "name"));
1657    }
1658
1659    #[test]
1660    fn schema_validator_flags_missing_context() {
1661        let v = serde_json::json!({"@type": "BankAccount", "identifier": "x"});
1662        let errs = validate_schema_org(&v);
1663        assert!(errs.iter().any(|e| e.field == "@context"));
1664    }
1665
1666    // ── Optional-field omission in JSON-LD emitters ─────────────────
1667
1668    #[test]
1669    fn payment_instrument_jsonld_omits_optional_fields() {
1670        let p = PaymentInstrument {
1671            name: None,
1672            instrument_type: "transfer".to_string(),
1673            brand: None,
1674        };
1675        let v = p.to_jsonld();
1676        assert!(v.get("name").is_none());
1677        assert!(v.get("brand").is_none());
1678        assert_eq!(v["iso20022:instrumentType"], "transfer");
1679    }
1680
1681    #[test]
1682    fn financial_product_jsonld_omits_optional_fields() {
1683        let p = FinancialProduct {
1684            name: "Plain Loan".to_string(),
1685            product_type: "loan".to_string(),
1686            issuer: None,
1687            annual_percentage_rate: None,
1688            isin: None,
1689        };
1690        let v = p.to_jsonld();
1691        assert!(v.get("provider").is_none());
1692        assert!(v.get("annualPercentageRate").is_none());
1693        assert!(v.get("iso20022:isin").is_none());
1694        assert!(v.get("identifier").is_none());
1695    }
1696
1697    #[test]
1698    fn strip_context_passes_non_object_values_through() {
1699        let v = strip_context(serde_json::json!("scalar"));
1700        assert_eq!(v, serde_json::json!("scalar"));
1701    }
1702
1703    // ── Iso20022Entity dispatch surfaces ────────────────────────────
1704
1705    #[test]
1706    fn entity_to_jsonld_covers_remaining_variants() {
1707        let pi = Iso20022Entity::PaymentInstrument(PaymentInstrument {
1708            instrument_type: "card".to_string(),
1709            ..PaymentInstrument::default()
1710        });
1711        assert_eq!(pi.to_jsonld()["@type"], "PaymentService");
1712
1713        let rfi = Iso20022Entity::RegulatedFinancialInstitution(
1714            RegulatedFinancialInstitution {
1715                name: "Acme Bank".to_string(),
1716                ..RegulatedFinancialInstitution::default()
1717            },
1718        );
1719        assert_eq!(rfi.to_jsonld()["@type"], "BankOrCreditUnion");
1720
1721        let fp = Iso20022Entity::FinancialProduct(FinancialProduct {
1722            name: "Bond".to_string(),
1723            product_type: "derivative".to_string(),
1724            ..FinancialProduct::default()
1725        });
1726        assert_eq!(fp.to_jsonld()["@type"], "FinancialProduct");
1727    }
1728
1729    #[test]
1730    fn entity_type_name_payment_instrument() {
1731        let pi =
1732            Iso20022Entity::PaymentInstrument(PaymentInstrument::default());
1733        assert_eq!(pi.type_name(), "PaymentInstrument");
1734    }
1735
1736    // ── DispatchError display ───────────────────────────────────────
1737
1738    #[test]
1739    fn dispatch_error_display_all_variants() {
1740        assert!(DispatchError::MissingType
1741            .to_string()
1742            .contains("missing the required `type` field"));
1743        assert!(DispatchError::UnknownType("Widget".to_string())
1744            .to_string()
1745            .contains("`Widget` is not one of"));
1746        assert!(DispatchError::Malformed("bad shape".to_string())
1747            .to_string()
1748            .contains("malformed: bad shape"));
1749    }
1750
1751    // ── from_frontmatter: remaining discriminants + error paths ────
1752
1753    #[test]
1754    fn dispatch_non_string_type_field_is_missing_type() {
1755        let fm = serde_json::json!({"type": 42});
1756        assert_eq!(
1757            from_frontmatter(&fm).unwrap_err(),
1758            DispatchError::MissingType
1759        );
1760    }
1761
1762    #[test]
1763    fn dispatch_payment_instrument_round_trip_and_malformed() {
1764        let ok = serde_json::json!({
1765            "type": "PaymentInstrument",
1766            "instrument_type": "card",
1767        });
1768        let entity = from_frontmatter(&ok).unwrap();
1769        assert_eq!(entity.type_name(), "PaymentInstrument");
1770
1771        // `instrument_type` is required — omitting it fails deserialise.
1772        let bad = serde_json::json!({"type": "PaymentInstrument"});
1773        assert!(matches!(
1774            from_frontmatter(&bad),
1775            Err(DispatchError::Malformed(_))
1776        ));
1777    }
1778
1779    #[test]
1780    fn dispatch_regulated_institution_round_trip_and_malformed() {
1781        let ok = serde_json::json!({
1782            "type": "RegulatedFinancialInstitution",
1783            "name": "Acme Bank",
1784        });
1785        let entity = from_frontmatter(&ok).unwrap();
1786        assert_eq!(entity.type_name(), "RegulatedFinancialInstitution");
1787
1788        let bad = serde_json::json!({"type": "RegulatedFinancialInstitution"});
1789        assert!(matches!(
1790            from_frontmatter(&bad),
1791            Err(DispatchError::Malformed(_))
1792        ));
1793    }
1794
1795    #[test]
1796    fn dispatch_financial_product_round_trip_and_malformed() {
1797        let ok = serde_json::json!({
1798            "type": "FinancialProduct",
1799            "name": "Green Bond",
1800            "product_type": "deposit",
1801        });
1802        let entity = from_frontmatter(&ok).unwrap();
1803        assert_eq!(entity.type_name(), "FinancialProduct");
1804
1805        let bad = serde_json::json!({"type": "FinancialProduct"});
1806        assert!(matches!(
1807            from_frontmatter(&bad),
1808            Err(DispatchError::Malformed(_))
1809        ));
1810    }
1811
1812    #[test]
1813    fn dispatch_bank_account_malformed_payload() {
1814        // `iban` must be a string — a number fails deserialisation.
1815        let bad = serde_json::json!({"type": "BankAccount", "iban": 123});
1816        assert!(matches!(
1817            from_frontmatter(&bad),
1818            Err(DispatchError::Malformed(_))
1819        ));
1820    }
1821
1822    #[test]
1823    fn dispatch_financial_transaction_malformed_payload() {
1824        let bad = serde_json::json!({
1825            "type": "FinancialTransaction",
1826            "debtor_account": "not an object",
1827        });
1828        assert!(matches!(
1829            from_frontmatter(&bad),
1830            Err(DispatchError::Malformed(_))
1831        ));
1832    }
1833
1834    // ── warn_invalid_fields: remaining walk combinations ────────────
1835
1836    #[test]
1837    fn warn_walks_bank_account_with_bic_only() {
1838        let e = Iso20022Entity::BankAccount(BankAccount {
1839            iban: None,
1840            bic: Some("BAD".to_string()),
1841            ..BankAccount::default()
1842        });
1843        assert_eq!(warn_invalid_fields(&e, "page.md"), 1);
1844    }
1845
1846    #[test]
1847    fn warn_walks_transaction_with_sparse_accounts() {
1848        // Debtor carries only a BIC; creditor carries only an IBAN —
1849        // exercises every Some/None combination in the account walk.
1850        let e = Iso20022Entity::FinancialTransaction(FinancialTransaction {
1851            debtor_account: Some(BankAccount {
1852                iban: None,
1853                bic: Some("BAD".to_string()),
1854                ..BankAccount::default()
1855            }),
1856            creditor_account: Some(BankAccount {
1857                iban: Some("INVALID".to_string()),
1858                bic: None,
1859                ..BankAccount::default()
1860            }),
1861            ..FinancialTransaction::default()
1862        });
1863        assert_eq!(warn_invalid_fields(&e, "page.md"), 2);
1864    }
1865
1866    #[test]
1867    fn warn_transaction_without_accounts_emits_nothing() {
1868        let e = Iso20022Entity::FinancialTransaction(
1869            FinancialTransaction::default(),
1870        );
1871        assert_eq!(warn_invalid_fields(&e, "page.md"), 0);
1872    }
1873
1874    #[test]
1875    fn warn_skips_entities_without_account_fields() {
1876        let pi =
1877            Iso20022Entity::PaymentInstrument(PaymentInstrument::default());
1878        assert_eq!(warn_invalid_fields(&pi, "page.md"), 0);
1879        let fp = Iso20022Entity::FinancialProduct(FinancialProduct::default());
1880        assert_eq!(warn_invalid_fields(&fp, "page.md"), 0);
1881    }
1882
1883    // ── Schema.org validator: remaining arms ────────────────────────
1884
1885    #[test]
1886    fn schema_validator_flags_financial_product_missing_name() {
1887        // Hits the second literal of the BankOrCreditUnion |
1888        // FinancialProduct match arm.
1889        let v = serde_json::json!({
1890            "@context": "https://schema.org",
1891            "@type": "FinancialProduct",
1892        });
1893        let errs = validate_schema_org(&v);
1894        assert!(errs.iter().any(|e| e.field == "name"));
1895    }
1896
1897    #[test]
1898    fn schema_validator_passes_institution_with_name() {
1899        let v = RegulatedFinancialInstitution {
1900            name: "Acme Bank".to_string(),
1901            ..RegulatedFinancialInstitution::default()
1902        }
1903        .to_jsonld();
1904        assert!(
1905            validate_schema_org(&v).is_empty(),
1906            "{:?}",
1907            validate_schema_org(&v)
1908        );
1909    }
1910
1911    #[test]
1912    fn schema_validator_flags_payment_service_missing_instrument_type() {
1913        let v = serde_json::json!({
1914            "@context": "https://schema.org",
1915            "@type": "PaymentService",
1916        });
1917        let errs = validate_schema_org(&v);
1918        assert!(errs.iter().any(|e| e.field == "iso20022:instrumentType"));
1919    }
1920
1921    #[test]
1922    fn schema_validator_passes_payment_service_with_instrument_type() {
1923        let v = PaymentInstrument {
1924            instrument_type: "card".to_string(),
1925            ..PaymentInstrument::default()
1926        }
1927        .to_jsonld();
1928        assert!(
1929            validate_schema_org(&v).is_empty(),
1930            "{:?}",
1931            validate_schema_org(&v)
1932        );
1933    }
1934
1935    #[test]
1936    fn schema_validator_ignores_unknown_types() {
1937        let v = serde_json::json!({
1938            "@context": "https://schema.org",
1939            "@type": "SomethingElse",
1940        });
1941        assert!(
1942            validate_schema_org(&v).is_empty(),
1943            "{:?}",
1944            validate_schema_org(&v)
1945        );
1946    }
1947
1948    #[test]
1949    fn schema_validator_defaults_to_unknown_when_type_field_absent() {
1950        // Distinct from `..._ignores_unknown_types` above: here `@type`
1951        // is missing entirely, exercising the `.unwrap_or("Unknown")`
1952        // fallback (as opposed to an unrecognised *value*).
1953        let v = serde_json::json!({"@context": "https://schema.org"});
1954        let errs = validate_schema_org(&v);
1955        assert!(errs.is_empty(), "Unknown type enforces no required fields");
1956    }
1957
1958    // ── Derived PartialEq/Eq surfaces ───────────────────────────────
1959    //
1960    // These domain types are rarely compared via `==` in the tests
1961    // above (assertions mostly poke at the rendered JSON), so the
1962    // derived `eq` impls need their own direct exercise, including a
1963    // difference in a *later* field so short-circuiting `&&` chains
1964    // don't leave the tail comparisons unexecuted.
1965
1966    #[test]
1967    fn domain_types_partial_eq_covers_equal_and_unequal_tail_fields() {
1968        let a = MonetaryAmount {
1969            currency: "EUR".to_string(),
1970            amount: 1.0,
1971        };
1972        let b = MonetaryAmount {
1973            currency: "EUR".to_string(),
1974            amount: 1.0,
1975        };
1976        let c = MonetaryAmount {
1977            currency: "EUR".to_string(),
1978            amount: 2.0,
1979        };
1980        assert_eq!(a, b);
1981        assert_ne!(a, c);
1982
1983        let ba1 = BankAccount {
1984            name: Some("N".to_string()),
1985            iban: Some("I".to_string()),
1986            bic: Some("B".to_string()),
1987        };
1988        let ba2 = ba1.clone();
1989        let mut ba3 = ba1.clone();
1990        ba3.bic = Some("DIFFERENT".to_string());
1991        assert_eq!(ba1, ba2);
1992        assert_ne!(ba1, ba3);
1993
1994        let pi1 = PaymentInstrument {
1995            name: Some("N".to_string()),
1996            instrument_type: "card".to_string(),
1997            brand: Some("Visa".to_string()),
1998        };
1999        let mut pi2 = pi1.clone();
2000        pi2.brand = Some("Other".to_string());
2001        assert_eq!(pi1, pi1.clone());
2002        assert_ne!(pi1, pi2);
2003
2004        let rfi1 = RegulatedFinancialInstitution {
2005            name: "Acme".to_string(),
2006            lei: Some("L".to_string()),
2007            licence_id: Some("LIC".to_string()),
2008            regulator: Some("FCA".to_string()),
2009            url: Some("https://x".to_string()),
2010        };
2011        let mut rfi2 = rfi1.clone();
2012        rfi2.url = Some("https://y".to_string());
2013        assert_eq!(rfi1, rfi1.clone());
2014        assert_ne!(rfi1, rfi2);
2015
2016        let fp1 = FinancialProduct {
2017            name: "Bond".to_string(),
2018            product_type: "deposit".to_string(),
2019            issuer: Some("Acme".to_string()),
2020            annual_percentage_rate: Some(1.0),
2021            isin: Some("US1".to_string()),
2022        };
2023        let mut fp2 = fp1.clone();
2024        fp2.isin = Some("US2".to_string());
2025        assert_eq!(fp1, fp1.clone());
2026        assert_ne!(fp1, fp2);
2027
2028        let ft1 = FinancialTransaction {
2029            instructed_amount: Some(a.clone()),
2030            debtor_account: Some(ba1.clone()),
2031            creditor_account: Some(ba1.clone()),
2032            execution_date: Some("2026-01-01".to_string()),
2033            end_to_end_id: Some("E1".to_string()),
2034        };
2035        let mut ft2 = ft1.clone();
2036        ft2.end_to_end_id = Some("E2".to_string());
2037        assert_eq!(ft1, ft1.clone());
2038        assert_ne!(ft1, ft2);
2039
2040        let se1 = SchemaOrgError {
2041            schema_type: "BankAccount".to_string(),
2042            field: "identifier".to_string(),
2043            reason: "missing".to_string(),
2044        };
2045        let mut se2 = se1.clone();
2046        se2.reason = "different".to_string();
2047        assert_eq!(se1, se1.clone());
2048        assert_ne!(se1, se2);
2049
2050        let vo1 = ValidationOutcome::Invalid {
2051            reason: "x".to_string(),
2052        };
2053        let vo2 = ValidationOutcome::Invalid {
2054            reason: "y".to_string(),
2055        };
2056        assert_eq!(
2057            vo1,
2058            ValidationOutcome::Invalid {
2059                reason: "x".to_string()
2060            }
2061        );
2062        assert_ne!(vo1, vo2);
2063        assert_ne!(vo1, ValidationOutcome::Valid);
2064
2065        let e1 = Iso20022Entity::FinancialProduct(fp1.clone());
2066        let e2 = Iso20022Entity::FinancialProduct(fp2.clone());
2067        assert_eq!(e1, Iso20022Entity::FinancialProduct(fp1.clone()));
2068        assert_ne!(e1, e2);
2069        assert_ne!(e1, Iso20022Entity::BankAccount(ba1.clone()));
2070    }
2071}