1use serde::{Deserialize, Serialize};
49use std::sync::atomic::{AtomicBool, Ordering};
50
51pub const DOCS_URL: &str =
53 "https://docs.rs/ssg/latest/ssg/seo/jsonld/iso20022/index.html";
54
55static FIRST_USE_LOGGED: AtomicBool = AtomicBool::new(false);
58
59pub 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#[derive(Debug, Clone, PartialEq, Eq)]
98pub enum ValidationOutcome {
99 Valid,
101 Invalid {
103 reason: String,
105 },
106}
107
108impl ValidationOutcome {
109 #[must_use]
120 pub const fn is_valid(&self) -> bool {
121 matches!(self, Self::Valid)
122 }
123}
124
125#[must_use]
169pub fn redact_for_log(value: &str) -> String {
170 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#[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 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 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 let mut rearranged = String::with_capacity(compact.len());
228 rearranged.push_str(&compact[4..]);
229 rearranged.push_str(&compact[..4]);
230
231 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 return ValidationOutcome::Invalid {
246 reason: format!(
247 "Non-alphanumeric character in IBAN at position {position}"
248 ),
249 };
250 };
251 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#[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 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 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 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 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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
344pub struct MonetaryAmount {
345 pub currency: String,
347 pub amount: f64,
349}
350
351impl MonetaryAmount {
352 #[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#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
379pub struct BankAccount {
380 #[serde(default, skip_serializing_if = "Option::is_none")]
382 pub name: Option<String>,
383 #[serde(default, skip_serializing_if = "Option::is_none")]
385 pub iban: Option<String>,
386 #[serde(default, skip_serializing_if = "Option::is_none")]
388 pub bic: Option<String>,
389}
390
391impl BankAccount {
392 #[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 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#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
434pub struct PaymentInstrument {
435 #[serde(default, skip_serializing_if = "Option::is_none")]
437 pub name: Option<String>,
438 pub instrument_type: String,
441 #[serde(default, skip_serializing_if = "Option::is_none")]
443 pub brand: Option<String>,
444}
445
446impl PaymentInstrument {
447 #[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#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
486pub struct FinancialTransaction {
487 #[serde(default, skip_serializing_if = "Option::is_none")]
489 pub instructed_amount: Option<MonetaryAmount>,
490 #[serde(default, skip_serializing_if = "Option::is_none")]
492 pub debtor_account: Option<BankAccount>,
493 #[serde(default, skip_serializing_if = "Option::is_none")]
495 pub creditor_account: Option<BankAccount>,
496 #[serde(default, skip_serializing_if = "Option::is_none")]
498 pub execution_date: Option<String>,
499 #[serde(default, skip_serializing_if = "Option::is_none")]
501 pub end_to_end_id: Option<String>,
502}
503
504impl FinancialTransaction {
505 #[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#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
552pub struct RegulatedFinancialInstitution {
553 pub name: String,
555 #[serde(default, skip_serializing_if = "Option::is_none")]
557 pub lei: Option<String>,
558 #[serde(default, skip_serializing_if = "Option::is_none")]
560 pub licence_id: Option<String>,
561 #[serde(default, skip_serializing_if = "Option::is_none")]
563 pub regulator: Option<String>,
564 #[serde(default, skip_serializing_if = "Option::is_none")]
566 pub url: Option<String>,
567}
568
569impl RegulatedFinancialInstitution {
570 #[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#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
611pub struct FinancialProduct {
612 pub name: String,
614 pub product_type: String,
617 #[serde(default, skip_serializing_if = "Option::is_none")]
619 pub issuer: Option<String>,
620 #[serde(default, skip_serializing_if = "Option::is_none")]
622 pub annual_percentage_rate: Option<f64>,
623 #[serde(default, skip_serializing_if = "Option::is_none")]
625 pub isin: Option<String>,
626}
627
628impl FinancialProduct {
629 #[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
669fn context_with_iso() -> serde_json::Value {
672 serde_json::json!({
673 "@vocab": "https://schema.org/",
674 "iso20022": "https://www.iso20022.org/",
675 })
676}
677
678fn 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#[derive(Debug, Clone, PartialEq)]
694pub enum Iso20022Entity {
695 BankAccount(BankAccount),
697 PaymentInstrument(PaymentInstrument),
699 FinancialTransaction(FinancialTransaction),
701 RegulatedFinancialInstitution(RegulatedFinancialInstitution),
703 FinancialProduct(FinancialProduct),
705}
706
707impl Iso20022Entity {
708 #[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 #[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#[derive(Debug, Clone, PartialEq, Eq)]
756pub enum DispatchError {
757 MissingType,
759 UnknownType(String),
761 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
785pub 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 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
851pub 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#[derive(Debug, Clone, PartialEq, Eq)]
939pub struct SchemaOrgError {
940 pub schema_type: String,
942 pub field: String,
944 pub reason: String,
946}
947
948#[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 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#[cfg(test)]
1070mod tests {
1071 #[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 for chunk in raw
1106 .split(|c: char| !c.is_ascii_alphanumeric())
1107 .filter(|c| c.len() >= 4)
1108 {
1109 assert!(
1115 !reason.contains(chunk),
1116 "validation reason quotes a run of the rejected \
1117 input (case {case_idx})"
1118 );
1119 }
1120 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 #[test]
1139 fn iban_uk_natwest_valid() {
1140 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 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 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 assert!(!validate_bic("NWBK12 2L").is_valid());
1208 }
1209
1210 #[test]
1211 fn iban_rejects_non_alphanumeric_bban() {
1212 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 let res = validate_iban("GBABNWBK60161331926819");
1224 assert!(!res.is_valid());
1225 }
1226
1227 #[test]
1228 fn bic_rejects_digit_in_bank_code() {
1229 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 let res = validate_bic("NWBKGB!!");
1246 assert!(!res.is_valid());
1247 }
1248
1249 #[test]
1250 fn bic_rejects_non_alphanumeric_branch_code() {
1251 let res = validate_bic("NWBKGB2L!!!");
1253 assert!(!res.is_valid());
1254 }
1255
1256 #[test]
1257 fn entity_type_name_covers_all_variants() {
1258 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 #[test]
1272 fn redact_keeps_only_the_ends_of_a_full_iban() {
1273 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 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 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 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 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 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 #[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 #[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()), ..BankAccount::default()
1505 }),
1506 ..FinancialTransaction::default()
1507 });
1508 let warnings = warn_invalid_fields(&entity, "page.md");
1509 assert_eq!(warnings, 2);
1510 }
1511
1512 #[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 log_first_use_pointer();
1522 assert!(first_use_was_logged());
1523 }
1524
1525 #[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 #[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 #[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 #[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 #[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 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 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 #[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 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 #[test]
1802 fn schema_validator_flags_financial_product_missing_name() {
1803 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 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 #[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}