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