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]
154pub fn redact_for_log(value: &str) -> String {
155 let chars: Vec<char> = value.chars().collect();
158 if chars.len() <= 12 {
159 return "………".to_string();
160 }
161 let head: String = chars[..4].iter().collect();
162 let tail: String = chars[chars.len() - 4..].iter().collect();
163 format!("{head}…{tail}")
164}
165
166#[must_use]
191pub fn validate_iban(input: &str) -> ValidationOutcome {
192 match check_iban(input) {
193 Ok(()) => ValidationOutcome::Valid,
194 Err(fault) => ValidationOutcome::Invalid {
195 reason: fault.reason(),
196 },
197 }
198}
199
200#[derive(Clone, Copy, Debug, PartialEq, Eq)]
203enum IbanFault {
204 Length(usize),
205 CountryCode,
206 CheckDigits,
207 Bban,
208 Character { position: usize },
209 Checksum { remainder: u64 },
210}
211
212impl IbanFault {
213 fn reason(self) -> String {
215 match self {
216 Self::Length(len) => {
217 format!("IBAN length {len} outside ISO 13616 range 15..=34")
218 }
219 Self::CountryCode => {
220 "IBAN country code must be two ASCII letters".to_string()
221 }
222 Self::CheckDigits => {
223 "IBAN check digits must be two ASCII digits".to_string()
224 }
225 Self::Bban => "IBAN BBAN must be alphanumeric ASCII".to_string(),
226 Self::Character { position } => {
227 format!(
228 "Non-alphanumeric character in IBAN at position {position}"
229 )
230 }
231 Self::Checksum { remainder } => {
232 format!("IBAN MOD-97 checksum failed (remainder={remainder})")
233 }
234 }
235 }
236}
237
238fn check_iban(input: &str) -> Result<(), IbanFault> {
240 let compact: String = input
241 .chars()
242 .filter(|c| !c.is_whitespace())
243 .collect::<String>()
244 .to_ascii_uppercase();
245
246 if compact.len() < 15 || compact.len() > 34 {
247 return Err(IbanFault::Length(compact.len()));
248 }
249
250 let bytes = compact.as_bytes();
252 if !(bytes[0].is_ascii_alphabetic() && bytes[1].is_ascii_alphabetic()) {
253 return Err(IbanFault::CountryCode);
254 }
255 if !(bytes[2].is_ascii_digit() && bytes[3].is_ascii_digit()) {
256 return Err(IbanFault::CheckDigits);
257 }
258 if !bytes[4..].iter().all(u8::is_ascii_alphanumeric) {
260 return Err(IbanFault::Bban);
261 }
262
263 let mut rearranged = String::with_capacity(compact.len());
265 rearranged.push_str(&compact[4..]);
266 rearranged.push_str(&compact[..4]);
267
268 let mut remainder: u64 = 0;
270 for (position, ch) in rearranged.chars().enumerate() {
271 let digits: u64 = if ch.is_ascii_digit() {
272 u64::from(ch as u8 - b'0')
273 } else if ch.is_ascii_alphabetic() {
274 u64::from((ch.to_ascii_uppercase() as u8) - b'A') + 10
275 } else {
276 return Err(IbanFault::Character { position });
283 };
284 if digits >= 10 {
286 remainder = (remainder * 100 + digits) % 97;
287 } else {
288 remainder = (remainder * 10 + digits) % 97;
289 }
290 }
291
292 if remainder == 1 {
293 Ok(())
294 } else {
295 Err(IbanFault::Checksum { remainder })
296 }
297}
298
299#[must_use]
315pub fn validate_bic(input: &str) -> ValidationOutcome {
316 let compact: String =
317 input.chars().filter(|c| !c.is_whitespace()).collect();
318
319 if compact.len() != 8 && compact.len() != 11 {
320 return ValidationOutcome::Invalid {
321 reason: format!(
322 "BIC length {} is not 8 or 11 (ISO 9362)",
323 compact.len()
324 ),
325 };
326 }
327 let upper = compact.to_ascii_uppercase();
328 let bytes = upper.as_bytes();
329
330 if !bytes[..4].iter().all(u8::is_ascii_alphabetic) {
332 return ValidationOutcome::Invalid {
333 reason: "BIC bank code (chars 1-4) must be ASCII letters"
334 .to_string(),
335 };
336 }
337 if !bytes[4..6].iter().all(u8::is_ascii_alphabetic) {
339 return ValidationOutcome::Invalid {
340 reason: "BIC country code (chars 5-6) must be ASCII letters"
341 .to_string(),
342 };
343 }
344 if !bytes[6..8].iter().all(u8::is_ascii_alphanumeric) {
346 return ValidationOutcome::Invalid {
347 reason: "BIC location code (chars 7-8) must be ASCII alphanumerics"
348 .to_string(),
349 };
350 }
351 if compact.len() == 11
353 && !bytes[8..11].iter().all(u8::is_ascii_alphanumeric)
354 {
355 return ValidationOutcome::Invalid {
356 reason: "BIC branch code (chars 9-11) must be ASCII alphanumerics"
357 .to_string(),
358 };
359 }
360
361 ValidationOutcome::Valid
362}
363
364#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
373pub struct MonetaryAmount {
374 pub currency: String,
376 pub amount: f64,
378}
379
380impl MonetaryAmount {
381 #[must_use]
393 pub fn to_jsonld(&self) -> serde_json::Value {
394 serde_json::json!({
395 "@type": "MonetaryAmount",
396 "currency": self.currency,
397 "value": self.amount,
398 })
399 }
400}
401
402#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
408pub struct BankAccount {
409 #[serde(default, skip_serializing_if = "Option::is_none")]
411 pub name: Option<String>,
412 #[serde(default, skip_serializing_if = "Option::is_none")]
414 pub iban: Option<String>,
415 #[serde(default, skip_serializing_if = "Option::is_none")]
417 pub bic: Option<String>,
418}
419
420impl BankAccount {
421 #[must_use]
437 pub fn to_jsonld(&self) -> serde_json::Value {
438 let mut obj = serde_json::json!({
439 "@context": context_with_iso(),
440 "@type": "BankAccount",
441 });
442 if let Some(name) = &self.name {
443 obj["name"] = serde_json::json!(name);
444 }
445 if let Some(iban) = &self.iban {
446 obj["iso20022:iban"] = serde_json::json!(iban);
452 }
453 if let Some(bic) = &self.bic {
454 obj["iso20022:bic"] = serde_json::json!(bic);
455 }
456 obj
457 }
458}
459
460#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
463pub struct PaymentInstrument {
464 #[serde(default, skip_serializing_if = "Option::is_none")]
466 pub name: Option<String>,
467 pub instrument_type: String,
470 #[serde(default, skip_serializing_if = "Option::is_none")]
472 pub brand: Option<String>,
473}
474
475impl PaymentInstrument {
476 #[must_use]
492 pub fn to_jsonld(&self) -> serde_json::Value {
493 let mut obj = serde_json::json!({
494 "@context": context_with_iso(),
495 "@type": "PaymentService",
496 "iso20022:instrumentType": self.instrument_type,
497 });
498 if let Some(name) = &self.name {
499 obj["name"] = serde_json::json!(name);
500 }
501 if let Some(brand) = &self.brand {
502 obj["brand"] = serde_json::json!(brand);
503 }
504 obj
505 }
506}
507
508#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
515pub struct FinancialTransaction {
516 #[serde(default, skip_serializing_if = "Option::is_none")]
518 pub instructed_amount: Option<MonetaryAmount>,
519 #[serde(default, skip_serializing_if = "Option::is_none")]
521 pub debtor_account: Option<BankAccount>,
522 #[serde(default, skip_serializing_if = "Option::is_none")]
524 pub creditor_account: Option<BankAccount>,
525 #[serde(default, skip_serializing_if = "Option::is_none")]
527 pub execution_date: Option<String>,
528 #[serde(default, skip_serializing_if = "Option::is_none")]
530 pub end_to_end_id: Option<String>,
531}
532
533impl FinancialTransaction {
534 #[must_use]
552 pub fn to_jsonld(&self) -> serde_json::Value {
553 let mut obj = serde_json::json!({
554 "@context": context_with_iso(),
555 "@type": "MoneyTransfer",
556 });
557 if let Some(amount) = &self.instructed_amount {
558 obj["amount"] = amount.to_jsonld();
559 }
560 if let Some(debtor) = &self.debtor_account {
561 obj["iso20022:debtorAccount"] = strip_context(debtor.to_jsonld());
562 }
563 if let Some(creditor) = &self.creditor_account {
564 obj["iso20022:creditorAccount"] =
565 strip_context(creditor.to_jsonld());
566 }
567 if let Some(date) = &self.execution_date {
568 obj["iso20022:executionDate"] = serde_json::json!(date);
569 }
570 if let Some(id) = &self.end_to_end_id {
571 obj["iso20022:endToEndId"] = serde_json::json!(id);
572 obj["identifier"] = serde_json::json!(id);
573 }
574 obj
575 }
576}
577
578#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
581pub struct RegulatedFinancialInstitution {
582 pub name: String,
584 #[serde(default, skip_serializing_if = "Option::is_none")]
586 pub lei: Option<String>,
587 #[serde(default, skip_serializing_if = "Option::is_none")]
589 pub licence_id: Option<String>,
590 #[serde(default, skip_serializing_if = "Option::is_none")]
592 pub regulator: Option<String>,
593 #[serde(default, skip_serializing_if = "Option::is_none")]
595 pub url: Option<String>,
596}
597
598impl RegulatedFinancialInstitution {
599 #[must_use]
614 pub fn to_jsonld(&self) -> serde_json::Value {
615 let mut obj = serde_json::json!({
616 "@context": context_with_iso(),
617 "@type": "BankOrCreditUnion",
618 "name": self.name,
619 });
620 if let Some(lei) = &self.lei {
621 obj["iso20022:lei"] = serde_json::json!(lei);
622 obj["identifier"] = serde_json::json!(lei);
623 }
624 if let Some(licence) = &self.licence_id {
625 obj["iso20022:licenceId"] = serde_json::json!(licence);
626 }
627 if let Some(regulator) = &self.regulator {
628 obj["iso20022:regulator"] = serde_json::json!(regulator);
629 }
630 if let Some(url) = &self.url {
631 obj["url"] = serde_json::json!(url);
632 }
633 obj
634 }
635}
636
637#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
640pub struct FinancialProduct {
641 pub name: String,
643 pub product_type: String,
646 #[serde(default, skip_serializing_if = "Option::is_none")]
648 pub issuer: Option<String>,
649 #[serde(default, skip_serializing_if = "Option::is_none")]
651 pub annual_percentage_rate: Option<f64>,
652 #[serde(default, skip_serializing_if = "Option::is_none")]
654 pub isin: Option<String>,
655}
656
657impl FinancialProduct {
658 #[must_use]
674 pub fn to_jsonld(&self) -> serde_json::Value {
675 let mut obj = serde_json::json!({
676 "@context": context_with_iso(),
677 "@type": "FinancialProduct",
678 "name": self.name,
679 "iso20022:productType": self.product_type,
680 });
681 if let Some(issuer) = &self.issuer {
682 obj["provider"] = serde_json::json!({
683 "@type": "Organization",
684 "name": issuer,
685 });
686 }
687 if let Some(apr) = self.annual_percentage_rate {
688 obj["annualPercentageRate"] = serde_json::json!(apr);
689 }
690 if let Some(isin) = &self.isin {
691 obj["iso20022:isin"] = serde_json::json!(isin);
692 obj["identifier"] = serde_json::json!(isin);
693 }
694 obj
695 }
696}
697
698fn context_with_iso() -> serde_json::Value {
701 serde_json::json!({
702 "@vocab": "https://schema.org/",
703 "iso20022": "https://www.iso20022.org/",
704 })
705}
706
707fn strip_context(mut value: serde_json::Value) -> serde_json::Value {
711 if let Some(obj) = value.as_object_mut() {
712 let _ = obj.remove("@context");
713 }
714 value
715}
716
717#[derive(Debug, Clone, PartialEq)]
723pub enum Iso20022Entity {
724 BankAccount(BankAccount),
726 PaymentInstrument(PaymentInstrument),
728 FinancialTransaction(FinancialTransaction),
730 RegulatedFinancialInstitution(RegulatedFinancialInstitution),
732 FinancialProduct(FinancialProduct),
734}
735
736impl Iso20022Entity {
737 #[must_use]
748 pub fn to_jsonld(&self) -> serde_json::Value {
749 match self {
750 Self::BankAccount(b) => b.to_jsonld(),
751 Self::PaymentInstrument(p) => p.to_jsonld(),
752 Self::FinancialTransaction(t) => t.to_jsonld(),
753 Self::RegulatedFinancialInstitution(r) => r.to_jsonld(),
754 Self::FinancialProduct(p) => p.to_jsonld(),
755 }
756 }
757
758 #[must_use]
768 pub const fn type_name(&self) -> &'static str {
769 match self {
770 Self::BankAccount(_) => "BankAccount",
771 Self::PaymentInstrument(_) => "PaymentInstrument",
772 Self::FinancialTransaction(_) => "FinancialTransaction",
773 Self::RegulatedFinancialInstitution(_) => {
774 "RegulatedFinancialInstitution"
775 }
776 Self::FinancialProduct(_) => "FinancialProduct",
777 }
778 }
779}
780
781#[derive(Debug, Clone, PartialEq, Eq)]
785pub enum DispatchError {
786 MissingType,
788 UnknownType(String),
790 Malformed(String),
792}
793
794impl std::fmt::Display for DispatchError {
795 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
796 match self {
797 Self::MissingType => write!(
798 f,
799 "iso20022 frontmatter is missing the required `type` field"
800 ),
801 Self::UnknownType(t) => write!(
802 f,
803 "iso20022 frontmatter type `{t}` is not one of: \
804 BankAccount, PaymentInstrument, FinancialTransaction, \
805 RegulatedFinancialInstitution, FinancialProduct"
806 ),
807 Self::Malformed(reason) => {
808 write!(f, "iso20022 frontmatter payload is malformed: {reason}")
809 }
810 }
811 }
812}
813
814pub fn from_frontmatter(
838 value: &serde_json::Value,
839) -> Result<Iso20022Entity, DispatchError> {
840 let type_str = value
841 .get("type")
842 .and_then(|v| v.as_str())
843 .ok_or(DispatchError::MissingType)?;
844
845 let mut payload = value.clone();
848 if let Some(obj) = payload.as_object_mut() {
849 let _ = obj.remove("type");
850 }
851
852 match type_str {
853 "BankAccount" => serde_json::from_value::<BankAccount>(payload)
854 .map(Iso20022Entity::BankAccount)
855 .map_err(|e| DispatchError::Malformed(e.to_string())),
856 "PaymentInstrument" => {
857 serde_json::from_value::<PaymentInstrument>(payload)
858 .map(Iso20022Entity::PaymentInstrument)
859 .map_err(|e| DispatchError::Malformed(e.to_string()))
860 }
861 "FinancialTransaction" => {
862 serde_json::from_value::<FinancialTransaction>(payload)
863 .map(Iso20022Entity::FinancialTransaction)
864 .map_err(|e| DispatchError::Malformed(e.to_string()))
865 }
866 "RegulatedFinancialInstitution" => {
867 serde_json::from_value::<RegulatedFinancialInstitution>(payload)
868 .map(Iso20022Entity::RegulatedFinancialInstitution)
869 .map_err(|e| DispatchError::Malformed(e.to_string()))
870 }
871 "FinancialProduct" => {
872 serde_json::from_value::<FinancialProduct>(payload)
873 .map(Iso20022Entity::FinancialProduct)
874 .map_err(|e| DispatchError::Malformed(e.to_string()))
875 }
876 other => Err(DispatchError::UnknownType(other.to_string())),
877 }
878}
879
880pub fn warn_invalid_fields(entity: &Iso20022Entity, page_label: &str) -> usize {
898 fn warn_iban(page_label: &str, iban: &str, who: &str) -> usize {
903 let Err(fault) = check_iban(iban) else {
904 return 0;
905 };
906 match fault {
907 IbanFault::Length(_) => log::warn!(
908 "[json-ld/iso20022] {page_label}: invalid IBAN on {who}: \
909 length outside the ISO 13616 range 15..=34"
910 ),
911 IbanFault::CountryCode => log::warn!(
912 "[json-ld/iso20022] {page_label}: invalid IBAN on {who}: \
913 country code is not two ASCII letters"
914 ),
915 IbanFault::CheckDigits => log::warn!(
916 "[json-ld/iso20022] {page_label}: invalid IBAN on {who}: \
917 check digits are not two ASCII digits"
918 ),
919 IbanFault::Bban => log::warn!(
920 "[json-ld/iso20022] {page_label}: invalid IBAN on {who}: \
921 BBAN is not alphanumeric ASCII"
922 ),
923 IbanFault::Character { .. } => log::warn!(
924 "[json-ld/iso20022] {page_label}: invalid IBAN on {who}: \
925 contains a non-alphanumeric character"
926 ),
927 IbanFault::Checksum { .. } => log::warn!(
928 "[json-ld/iso20022] {page_label}: invalid IBAN on {who}: \
929 MOD-97 checksum failed"
930 ),
931 }
932 1
933 }
934 fn warn_bic(page_label: &str, bic: &str, who: &str) -> usize {
935 if let ValidationOutcome::Invalid { reason } = validate_bic(bic) {
936 log::warn!(
937 "[json-ld/iso20022] {page_label}: invalid BIC on {who}: \
938 {} — {reason}",
939 redact_for_log(bic)
940 );
941 1
942 } else {
943 0
944 }
945 }
946
947 let mut warnings = 0_usize;
948 match entity {
949 Iso20022Entity::BankAccount(b) => {
950 if let Some(iban) = &b.iban {
951 warnings += warn_iban(page_label, iban, "bank_account.iban");
952 }
953 if let Some(bic) = &b.bic {
954 warnings += warn_bic(page_label, bic, "bank_account.bic");
955 }
956 }
957 Iso20022Entity::FinancialTransaction(t) => {
958 if let Some(d) = &t.debtor_account {
959 if let Some(iban) = &d.iban {
960 warnings +=
961 warn_iban(page_label, iban, "debtor_account.iban");
962 }
963 if let Some(bic) = &d.bic {
964 warnings += warn_bic(page_label, bic, "debtor_account.bic");
965 }
966 }
967 if let Some(c) = &t.creditor_account {
968 if let Some(iban) = &c.iban {
969 warnings +=
970 warn_iban(page_label, iban, "creditor_account.iban");
971 }
972 if let Some(bic) = &c.bic {
973 warnings +=
974 warn_bic(page_label, bic, "creditor_account.bic");
975 }
976 }
977 }
978 Iso20022Entity::RegulatedFinancialInstitution(_)
979 | Iso20022Entity::PaymentInstrument(_)
980 | Iso20022Entity::FinancialProduct(_) => {}
981 }
982
983 warnings
984}
985
986#[derive(Debug, Clone, PartialEq, Eq)]
992pub struct SchemaOrgError {
993 pub schema_type: String,
995 pub field: String,
997 pub reason: String,
999}
1000
1001#[must_use]
1027#[allow(clippy::collapsible_match)]
1028pub fn validate_schema_org(value: &serde_json::Value) -> Vec<SchemaOrgError> {
1029 let mut errors = Vec::new();
1030
1031 let schema_type = value
1032 .get("@type")
1033 .and_then(|v| v.as_str())
1034 .unwrap_or("Unknown")
1035 .to_string();
1036
1037 let ctx_ok = value.get("@context").is_some_and(|c| {
1039 let s = serde_json::to_string(c).unwrap_or_default();
1040 s.contains("schema.org")
1041 });
1042 if !ctx_ok {
1043 errors.push(SchemaOrgError {
1044 schema_type: schema_type.clone(),
1045 field: "@context".to_string(),
1046 reason: "missing or does not reference schema.org".to_string(),
1047 });
1048 }
1049
1050 {
1051 match schema_type.as_str() {
1052 "BankAccount" => {
1053 let has_id = ["identifier", "iso20022:iban", "iso20022:bic"]
1054 .iter()
1055 .any(|f| value.get(*f).is_some());
1056 if !has_id {
1057 errors.push(SchemaOrgError {
1058 schema_type,
1059 field: "identifier|iso20022:iban|iso20022:bic"
1060 .to_string(),
1061 reason:
1062 "BankAccount must carry at least one identifier"
1063 .to_string(),
1064 });
1065 }
1066 }
1067 "MoneyTransfer" => {
1068 let amount = value.get("amount");
1069 let amount_ok = amount.is_some_and(|a| {
1070 a.get("@type").and_then(|t| t.as_str())
1071 == Some("MonetaryAmount")
1072 && a.get("currency").is_some()
1073 && a.get("value").is_some()
1074 });
1075 if !amount_ok {
1076 errors.push(SchemaOrgError {
1077 schema_type,
1078 field: "amount".to_string(),
1079 reason: "MoneyTransfer.amount must be a \
1080 MonetaryAmount with currency and value"
1081 .to_string(),
1082 });
1083 }
1084 }
1085 "BankOrCreditUnion" | "FinancialProduct" => {
1086 let missing = value
1087 .get("name")
1088 .and_then(|v| v.as_str())
1089 .is_none_or(str::is_empty);
1090 if missing {
1091 errors.push(SchemaOrgError {
1092 schema_type,
1093 field: "name".to_string(),
1094 reason: "required field is missing or empty"
1095 .to_string(),
1096 });
1097 }
1098 }
1099 "PaymentService" => {
1100 let missing = value.get("iso20022:instrumentType").is_none();
1101 if missing {
1102 errors.push(SchemaOrgError {
1103 schema_type,
1104 field: "iso20022:instrumentType".to_string(),
1105 reason: "PaymentService requires the namespaced \
1106 instrumentType field"
1107 .to_string(),
1108 });
1109 }
1110 }
1111 _ => {}
1112 }
1113 }
1114
1115 errors
1116}
1117
1118#[cfg(test)]
1123mod tests {
1124
1125 #[test]
1126 fn warn_invalid_fields_counts_each_iban_fault_once() {
1127 for bad in [
1128 "GB29", "1B29NWBK60161331926819", "GBX9NWBK60161331926819", "GB29NWBK6016133192681_", "GB29NWBK60161331926818", ] {
1134 let e = Iso20022Entity::BankAccount(BankAccount {
1135 iban: Some(bad.into()),
1136 ..BankAccount::default()
1137 });
1138 assert_eq!(warn_invalid_fields(&e, "page.md"), 1, "{bad}");
1139 }
1140 let ok = Iso20022Entity::BankAccount(BankAccount {
1141 iban: Some("GB29NWBK60161331926819".into()),
1142 ..BankAccount::default()
1143 });
1144 assert_eq!(warn_invalid_fields(&ok, "page.md"), 0);
1145 }
1146
1147 #[test]
1169 fn validation_reasons_never_quote_the_rejected_value() {
1170 let cases = [
1171 "GB82!WEST12345698765432",
1172 "GB82 WEST 1234 5698 7654 32£",
1173 "ZZ99QQQQ00000000000000",
1174 "GB82WEST1234569876543Z2",
1175 ];
1176
1177 for (case_idx, raw) in cases.iter().enumerate() {
1178 if let ValidationOutcome::Invalid { reason } = validate_iban(raw) {
1179 for chunk in raw
1182 .split(|c: char| !c.is_ascii_alphanumeric())
1183 .filter(|c| c.len() >= 4)
1184 {
1185 assert!(
1191 !reason.contains(chunk),
1192 "validation reason quotes a run of the rejected \
1193 input (case {case_idx})"
1194 );
1195 }
1196 for ch in raw.chars().filter(|c| {
1198 !c.is_ascii_alphanumeric() && !c.is_whitespace()
1199 }) {
1200 assert!(
1201 !reason.contains(ch),
1202 "validation reason quotes a character of the \
1203 rejected input (case {case_idx})"
1204 );
1205 }
1206 }
1207 }
1208 }
1209
1210 use super::*;
1211
1212 #[test]
1215 fn iban_uk_natwest_valid() {
1216 assert!(validate_iban("GB29NWBK60161331926819").is_valid());
1218 }
1219
1220 #[test]
1221 fn iban_de_deutsche_valid() {
1222 assert!(validate_iban("DE89370400440532013000").is_valid());
1223 }
1224
1225 #[test]
1226 fn iban_accepts_space_print_form() {
1227 assert!(validate_iban("GB29 NWBK 6016 1331 9268 19").is_valid());
1228 }
1229
1230 fn invalid_reason(outcome: ValidationOutcome) -> String {
1233 match outcome {
1234 ValidationOutcome::Invalid { reason } => reason,
1235 ValidationOutcome::Valid => String::new(),
1236 }
1237 }
1238
1239 #[test]
1240 fn invalid_reason_is_empty_for_valid_outcome() {
1241 assert_eq!(invalid_reason(ValidationOutcome::Valid), "");
1242 }
1243
1244 #[test]
1245 fn iban_rejects_bad_checksum() {
1246 let res = validate_iban("GB29NWBK60161331926811");
1248 assert!(!res.is_valid());
1249 assert!(invalid_reason(res).contains("MOD-97"));
1250 }
1251
1252 #[test]
1253 fn iban_rejects_short_input() {
1254 assert!(!validate_iban("GB29").is_valid());
1255 }
1256
1257 #[test]
1258 fn iban_rejects_non_letter_country_code() {
1259 let res = validate_iban("1229NWBK60161331926819");
1260 assert!(!res.is_valid());
1261 }
1262
1263 #[test]
1264 fn bic_8_chars_valid() {
1265 assert!(validate_bic("NWBKGB2L").is_valid());
1266 }
1267
1268 #[test]
1269 fn bic_11_chars_valid() {
1270 assert!(validate_bic("NWBKGB2LXXX").is_valid());
1271 }
1272
1273 #[test]
1274 fn bic_rejects_9_char_length() {
1275 let res = validate_bic("NWBKGB2LX");
1276 assert!(!res.is_valid());
1277 assert!(invalid_reason(res).contains("8 or 11"));
1278 }
1279
1280 #[test]
1281 fn bic_rejects_digit_in_country_code() {
1282 assert!(!validate_bic("NWBK12 2L").is_valid());
1284 }
1285
1286 #[test]
1287 fn iban_rejects_non_alphanumeric_bban() {
1288 let res = validate_iban("GB29NWBK60161331926*1");
1290 assert!(matches!(
1291 &res,
1292 ValidationOutcome::Invalid { reason } if reason.contains("BBAN")
1293 ));
1294 }
1295
1296 #[test]
1297 fn iban_rejects_bad_check_digits() {
1298 let res = validate_iban("GBABNWBK60161331926819");
1300 assert!(!res.is_valid());
1301 }
1302
1303 #[test]
1304 fn bic_rejects_digit_in_bank_code() {
1305 let res = validate_bic("1WBKGB2L");
1307 assert!(matches!(
1308 &res,
1309 ValidationOutcome::Invalid { reason } if reason.contains("bank code")
1310 ));
1311 }
1312
1313 #[test]
1314 fn bic_rejects_letter_in_location_code() {
1315 let res = validate_bic("NWBKGB!!");
1322 assert!(!res.is_valid());
1323 }
1324
1325 #[test]
1326 fn bic_rejects_non_alphanumeric_branch_code() {
1327 let res = validate_bic("NWBKGB2L!!!");
1329 assert!(!res.is_valid());
1330 }
1331
1332 #[test]
1333 fn entity_type_name_covers_all_variants() {
1334 let rfi = Iso20022Entity::RegulatedFinancialInstitution(
1337 RegulatedFinancialInstitution::default(),
1338 );
1339 assert_eq!(rfi.type_name(), "RegulatedFinancialInstitution");
1340
1341 let fp = Iso20022Entity::FinancialProduct(FinancialProduct::default());
1342 assert_eq!(fp.type_name(), "FinancialProduct");
1343 }
1344
1345 #[test]
1348 fn redact_keeps_only_the_ends_of_a_full_iban() {
1349 assert_eq!(redact_for_log("GB29NWBK60161331926819"), "GB29…6819");
1352 assert_eq!(redact_for_log("BE68539007547034"), "BE68…7034");
1353 }
1354
1355 #[test]
1356 fn redact_masks_short_values_entirely() {
1357 assert_eq!(redact_for_log("NWBKGB2L"), "………");
1360 assert_eq!(redact_for_log("NWBKGB2LXXX"), "………");
1361 assert_eq!(redact_for_log(""), "………");
1362 assert_eq!(redact_for_log("GB29"), "………");
1363 }
1364
1365 #[test]
1366 fn redact_never_returns_the_input_unchanged() {
1367 for value in [
1370 "GB29NWBK60161331926819",
1371 "BE68539007547034",
1372 "NWBKGB2L",
1373 "",
1374 "not-an-iban-but-long-enough",
1375 ] {
1376 assert_ne!(
1377 redact_for_log(value),
1378 value,
1379 "redaction returned {value} unchanged"
1380 );
1381 }
1382 }
1383
1384 #[test]
1385 fn redact_handles_multibyte_input_without_panicking() {
1386 let _ = redact_for_log("ééééééééééééééééé");
1389 let _ = redact_for_log("日本語のテキストです長いです");
1390 let _ = redact_for_log("🏦🏦🏦🏦🏦🏦🏦🏦🏦🏦🏦🏦🏦🏦");
1391 }
1392
1393 #[test]
1394 fn bank_account_jsonld_includes_iban_and_bic_namespaced() {
1395 let acc = BankAccount {
1396 name: Some("Treasury".to_string()),
1397 iban: Some("GB29NWBK60161331926819".to_string()),
1398 bic: Some("NWBKGB2L".to_string()),
1399 };
1400 let v = acc.to_jsonld();
1401 assert_eq!(v["@type"], "BankAccount");
1402 assert_eq!(v["iso20022:iban"], "GB29NWBK60161331926819");
1403 assert_eq!(v["iso20022:bic"], "NWBKGB2L");
1404 assert_eq!(v["name"], "Treasury");
1405 assert!(v.get("identifier").is_none());
1411 }
1412
1413 #[test]
1414 fn bank_account_jsonld_omits_optional_fields() {
1415 let v = BankAccount::default().to_jsonld();
1416 assert!(v.get("iso20022:iban").is_none());
1417 assert!(v.get("iso20022:bic").is_none());
1418 assert!(v.get("name").is_none());
1419 }
1420
1421 #[test]
1422 fn payment_instrument_emits_namespaced_type() {
1423 let p = PaymentInstrument {
1424 name: Some("Visa Debit".to_string()),
1425 instrument_type: "card".to_string(),
1426 brand: Some("Visa".to_string()),
1427 };
1428 let v = p.to_jsonld();
1429 assert_eq!(v["@type"], "PaymentService");
1430 assert_eq!(v["iso20022:instrumentType"], "card");
1431 assert_eq!(v["brand"], "Visa");
1432 }
1433
1434 #[test]
1435 fn financial_transaction_jsonld_shape_full() {
1436 let t = FinancialTransaction {
1437 instructed_amount: Some(MonetaryAmount {
1438 currency: "EUR".to_string(),
1439 amount: 1500.00,
1440 }),
1441 debtor_account: Some(BankAccount {
1442 name: None,
1443 iban: Some("GB29NWBK60161331926819".to_string()),
1444 bic: None,
1445 }),
1446 creditor_account: Some(BankAccount {
1447 name: None,
1448 iban: Some("DE89370400440532013000".to_string()),
1449 bic: None,
1450 }),
1451 execution_date: Some("2026-06-25".to_string()),
1452 end_to_end_id: Some("E2E-001".to_string()),
1453 };
1454 let v = t.to_jsonld();
1455 assert_eq!(v["@type"], "MoneyTransfer");
1456 assert_eq!(v["amount"]["currency"], "EUR");
1457 assert_eq!(v["amount"]["value"], 1500.0);
1458 assert_eq!(
1459 v["iso20022:debtorAccount"]["iso20022:iban"],
1460 "GB29NWBK60161331926819"
1461 );
1462 assert!(v["iso20022:debtorAccount"].get("@context").is_none());
1464 assert_eq!(v["iso20022:endToEndId"], "E2E-001");
1465 assert_eq!(v["identifier"], "E2E-001");
1466 }
1467
1468 #[test]
1469 fn regulated_institution_jsonld_includes_lei() {
1470 let r = RegulatedFinancialInstitution {
1471 name: "Acme Bank plc".to_string(),
1472 lei: Some("529900W18LQJJN6SJ336".to_string()),
1473 licence_id: Some("FCA-FRN-123456".to_string()),
1474 regulator: Some("FCA".to_string()),
1475 url: Some("https://acme.example".to_string()),
1476 };
1477 let v = r.to_jsonld();
1478 assert_eq!(v["@type"], "BankOrCreditUnion");
1479 assert_eq!(v["name"], "Acme Bank plc");
1480 assert_eq!(v["iso20022:lei"], "529900W18LQJJN6SJ336");
1481 assert_eq!(v["iso20022:licenceId"], "FCA-FRN-123456");
1482 }
1483
1484 #[test]
1485 fn financial_product_jsonld_includes_isin_and_apr() {
1486 let p = FinancialProduct {
1487 name: "Green Bond 2030".to_string(),
1488 product_type: "deposit".to_string(),
1489 issuer: Some("Acme Bank".to_string()),
1490 annual_percentage_rate: Some(3.5),
1491 isin: Some("US0378331005".to_string()),
1492 };
1493 let v = p.to_jsonld();
1494 assert_eq!(v["@type"], "FinancialProduct");
1495 assert_eq!(v["name"], "Green Bond 2030");
1496 assert_eq!(v["iso20022:productType"], "deposit");
1497 assert_eq!(v["iso20022:isin"], "US0378331005");
1498 assert_eq!(v["annualPercentageRate"], 3.5);
1499 assert_eq!(v["provider"]["@type"], "Organization");
1500 assert_eq!(v["provider"]["name"], "Acme Bank");
1501 }
1502
1503 #[test]
1506 fn dispatch_bank_account_round_trip() {
1507 let fm = serde_json::json!({
1508 "type": "BankAccount",
1509 "iban": "GB29NWBK60161331926819",
1510 });
1511 let entity = from_frontmatter(&fm).unwrap();
1512 assert_eq!(entity.type_name(), "BankAccount");
1513 let jsonld = entity.to_jsonld();
1514 assert_eq!(jsonld["iso20022:iban"], "GB29NWBK60161331926819");
1515 }
1516
1517 #[test]
1518 fn dispatch_financial_transaction_from_yaml_shape() {
1519 let fm = serde_json::json!({
1520 "type": "FinancialTransaction",
1521 "instructed_amount": {"currency": "EUR", "amount": 1500.00},
1522 "debtor_account": {"iban": "GB29NWBK60161331926819"},
1523 "creditor_account": {"iban": "DE89370400440532013000"},
1524 });
1525 let entity = from_frontmatter(&fm).unwrap();
1526 assert_eq!(entity.type_name(), "FinancialTransaction");
1527 let jsonld = entity.to_jsonld();
1528 assert_eq!(jsonld["amount"]["currency"], "EUR");
1529 }
1530
1531 #[test]
1532 fn dispatch_missing_type_errors() {
1533 let fm = serde_json::json!({"iban": "GB29NWBK60161331926819"});
1534 let err = from_frontmatter(&fm).unwrap_err();
1535 assert_eq!(err, DispatchError::MissingType);
1536 }
1537
1538 #[test]
1539 fn dispatch_unknown_type_errors() {
1540 let fm = serde_json::json!({"type": "GalacticCredits"});
1541 let err = from_frontmatter(&fm).unwrap_err();
1542 assert!(
1543 matches!(err, DispatchError::UnknownType(t) if t == "GalacticCredits")
1544 );
1545 }
1546
1547 #[test]
1550 fn warn_invalid_fields_counts_iban_failure() {
1551 let entity = Iso20022Entity::BankAccount(BankAccount {
1552 iban: Some("INVALID-IBAN".to_string()),
1553 ..BankAccount::default()
1554 });
1555 let warnings = warn_invalid_fields(&entity, "page.md");
1556 assert_eq!(warnings, 1);
1557 }
1558
1559 #[test]
1560 fn warn_invalid_fields_counts_zero_for_valid_iban() {
1561 let entity = Iso20022Entity::BankAccount(BankAccount {
1562 iban: Some("GB29NWBK60161331926819".to_string()),
1563 bic: Some("NWBKGB2L".to_string()),
1564 ..BankAccount::default()
1565 });
1566 let warnings = warn_invalid_fields(&entity, "page.md");
1567 assert_eq!(warnings, 0);
1568 }
1569
1570 #[test]
1571 fn warn_invalid_fields_walks_transaction_subfields() {
1572 let entity =
1573 Iso20022Entity::FinancialTransaction(FinancialTransaction {
1574 debtor_account: Some(BankAccount {
1575 iban: Some("BAD".to_string()),
1576 ..BankAccount::default()
1577 }),
1578 creditor_account: Some(BankAccount {
1579 bic: Some("BADBIC".to_string()), ..BankAccount::default()
1581 }),
1582 ..FinancialTransaction::default()
1583 });
1584 let warnings = warn_invalid_fields(&entity, "page.md");
1585 assert_eq!(warnings, 2);
1586 }
1587
1588 #[test]
1591 fn first_use_pointer_fires_exactly_once() {
1592 reset_first_use_for_test();
1593 assert!(!first_use_was_logged());
1594 log_first_use_pointer();
1595 assert!(first_use_was_logged());
1596 log_first_use_pointer();
1598 assert!(first_use_was_logged());
1599 }
1600
1601 #[test]
1604 fn schema_validator_passes_bank_account_with_identifier() {
1605 let v = BankAccount {
1606 iban: Some("GB29NWBK60161331926819".to_string()),
1607 ..BankAccount::default()
1608 }
1609 .to_jsonld();
1610 assert!(
1611 validate_schema_org(&v).is_empty(),
1612 "{:?}",
1613 validate_schema_org(&v)
1614 );
1615 }
1616
1617 #[test]
1618 fn schema_validator_flags_bank_account_without_identifier() {
1619 let v = BankAccount::default().to_jsonld();
1620 let errs = validate_schema_org(&v);
1621 assert!(errs.iter().any(|e| e.field.contains("identifier")));
1622 }
1623
1624 #[test]
1625 fn schema_validator_passes_money_transfer_with_amount() {
1626 let v = FinancialTransaction {
1627 instructed_amount: Some(MonetaryAmount {
1628 currency: "USD".to_string(),
1629 amount: 10.0,
1630 }),
1631 ..FinancialTransaction::default()
1632 }
1633 .to_jsonld();
1634 assert!(
1635 validate_schema_org(&v).is_empty(),
1636 "{:?}",
1637 validate_schema_org(&v)
1638 );
1639 }
1640
1641 #[test]
1642 fn schema_validator_flags_money_transfer_missing_amount() {
1643 let v = FinancialTransaction::default().to_jsonld();
1644 let errs = validate_schema_org(&v);
1645 assert!(errs.iter().any(|e| e.field == "amount"));
1646 }
1647
1648 #[test]
1649 fn schema_validator_flags_empty_institution_name() {
1650 let v = RegulatedFinancialInstitution {
1651 name: String::new(),
1652 ..RegulatedFinancialInstitution::default()
1653 }
1654 .to_jsonld();
1655 let errs = validate_schema_org(&v);
1656 assert!(errs.iter().any(|e| e.field == "name"));
1657 }
1658
1659 #[test]
1660 fn schema_validator_flags_missing_context() {
1661 let v = serde_json::json!({"@type": "BankAccount", "identifier": "x"});
1662 let errs = validate_schema_org(&v);
1663 assert!(errs.iter().any(|e| e.field == "@context"));
1664 }
1665
1666 #[test]
1669 fn payment_instrument_jsonld_omits_optional_fields() {
1670 let p = PaymentInstrument {
1671 name: None,
1672 instrument_type: "transfer".to_string(),
1673 brand: None,
1674 };
1675 let v = p.to_jsonld();
1676 assert!(v.get("name").is_none());
1677 assert!(v.get("brand").is_none());
1678 assert_eq!(v["iso20022:instrumentType"], "transfer");
1679 }
1680
1681 #[test]
1682 fn financial_product_jsonld_omits_optional_fields() {
1683 let p = FinancialProduct {
1684 name: "Plain Loan".to_string(),
1685 product_type: "loan".to_string(),
1686 issuer: None,
1687 annual_percentage_rate: None,
1688 isin: None,
1689 };
1690 let v = p.to_jsonld();
1691 assert!(v.get("provider").is_none());
1692 assert!(v.get("annualPercentageRate").is_none());
1693 assert!(v.get("iso20022:isin").is_none());
1694 assert!(v.get("identifier").is_none());
1695 }
1696
1697 #[test]
1698 fn strip_context_passes_non_object_values_through() {
1699 let v = strip_context(serde_json::json!("scalar"));
1700 assert_eq!(v, serde_json::json!("scalar"));
1701 }
1702
1703 #[test]
1706 fn entity_to_jsonld_covers_remaining_variants() {
1707 let pi = Iso20022Entity::PaymentInstrument(PaymentInstrument {
1708 instrument_type: "card".to_string(),
1709 ..PaymentInstrument::default()
1710 });
1711 assert_eq!(pi.to_jsonld()["@type"], "PaymentService");
1712
1713 let rfi = Iso20022Entity::RegulatedFinancialInstitution(
1714 RegulatedFinancialInstitution {
1715 name: "Acme Bank".to_string(),
1716 ..RegulatedFinancialInstitution::default()
1717 },
1718 );
1719 assert_eq!(rfi.to_jsonld()["@type"], "BankOrCreditUnion");
1720
1721 let fp = Iso20022Entity::FinancialProduct(FinancialProduct {
1722 name: "Bond".to_string(),
1723 product_type: "derivative".to_string(),
1724 ..FinancialProduct::default()
1725 });
1726 assert_eq!(fp.to_jsonld()["@type"], "FinancialProduct");
1727 }
1728
1729 #[test]
1730 fn entity_type_name_payment_instrument() {
1731 let pi =
1732 Iso20022Entity::PaymentInstrument(PaymentInstrument::default());
1733 assert_eq!(pi.type_name(), "PaymentInstrument");
1734 }
1735
1736 #[test]
1739 fn dispatch_error_display_all_variants() {
1740 assert!(DispatchError::MissingType
1741 .to_string()
1742 .contains("missing the required `type` field"));
1743 assert!(DispatchError::UnknownType("Widget".to_string())
1744 .to_string()
1745 .contains("`Widget` is not one of"));
1746 assert!(DispatchError::Malformed("bad shape".to_string())
1747 .to_string()
1748 .contains("malformed: bad shape"));
1749 }
1750
1751 #[test]
1754 fn dispatch_non_string_type_field_is_missing_type() {
1755 let fm = serde_json::json!({"type": 42});
1756 assert_eq!(
1757 from_frontmatter(&fm).unwrap_err(),
1758 DispatchError::MissingType
1759 );
1760 }
1761
1762 #[test]
1763 fn dispatch_payment_instrument_round_trip_and_malformed() {
1764 let ok = serde_json::json!({
1765 "type": "PaymentInstrument",
1766 "instrument_type": "card",
1767 });
1768 let entity = from_frontmatter(&ok).unwrap();
1769 assert_eq!(entity.type_name(), "PaymentInstrument");
1770
1771 let bad = serde_json::json!({"type": "PaymentInstrument"});
1773 assert!(matches!(
1774 from_frontmatter(&bad),
1775 Err(DispatchError::Malformed(_))
1776 ));
1777 }
1778
1779 #[test]
1780 fn dispatch_regulated_institution_round_trip_and_malformed() {
1781 let ok = serde_json::json!({
1782 "type": "RegulatedFinancialInstitution",
1783 "name": "Acme Bank",
1784 });
1785 let entity = from_frontmatter(&ok).unwrap();
1786 assert_eq!(entity.type_name(), "RegulatedFinancialInstitution");
1787
1788 let bad = serde_json::json!({"type": "RegulatedFinancialInstitution"});
1789 assert!(matches!(
1790 from_frontmatter(&bad),
1791 Err(DispatchError::Malformed(_))
1792 ));
1793 }
1794
1795 #[test]
1796 fn dispatch_financial_product_round_trip_and_malformed() {
1797 let ok = serde_json::json!({
1798 "type": "FinancialProduct",
1799 "name": "Green Bond",
1800 "product_type": "deposit",
1801 });
1802 let entity = from_frontmatter(&ok).unwrap();
1803 assert_eq!(entity.type_name(), "FinancialProduct");
1804
1805 let bad = serde_json::json!({"type": "FinancialProduct"});
1806 assert!(matches!(
1807 from_frontmatter(&bad),
1808 Err(DispatchError::Malformed(_))
1809 ));
1810 }
1811
1812 #[test]
1813 fn dispatch_bank_account_malformed_payload() {
1814 let bad = serde_json::json!({"type": "BankAccount", "iban": 123});
1816 assert!(matches!(
1817 from_frontmatter(&bad),
1818 Err(DispatchError::Malformed(_))
1819 ));
1820 }
1821
1822 #[test]
1823 fn dispatch_financial_transaction_malformed_payload() {
1824 let bad = serde_json::json!({
1825 "type": "FinancialTransaction",
1826 "debtor_account": "not an object",
1827 });
1828 assert!(matches!(
1829 from_frontmatter(&bad),
1830 Err(DispatchError::Malformed(_))
1831 ));
1832 }
1833
1834 #[test]
1837 fn warn_walks_bank_account_with_bic_only() {
1838 let e = Iso20022Entity::BankAccount(BankAccount {
1839 iban: None,
1840 bic: Some("BAD".to_string()),
1841 ..BankAccount::default()
1842 });
1843 assert_eq!(warn_invalid_fields(&e, "page.md"), 1);
1844 }
1845
1846 #[test]
1847 fn warn_walks_transaction_with_sparse_accounts() {
1848 let e = Iso20022Entity::FinancialTransaction(FinancialTransaction {
1851 debtor_account: Some(BankAccount {
1852 iban: None,
1853 bic: Some("BAD".to_string()),
1854 ..BankAccount::default()
1855 }),
1856 creditor_account: Some(BankAccount {
1857 iban: Some("INVALID".to_string()),
1858 bic: None,
1859 ..BankAccount::default()
1860 }),
1861 ..FinancialTransaction::default()
1862 });
1863 assert_eq!(warn_invalid_fields(&e, "page.md"), 2);
1864 }
1865
1866 #[test]
1867 fn warn_transaction_without_accounts_emits_nothing() {
1868 let e = Iso20022Entity::FinancialTransaction(
1869 FinancialTransaction::default(),
1870 );
1871 assert_eq!(warn_invalid_fields(&e, "page.md"), 0);
1872 }
1873
1874 #[test]
1875 fn warn_skips_entities_without_account_fields() {
1876 let pi =
1877 Iso20022Entity::PaymentInstrument(PaymentInstrument::default());
1878 assert_eq!(warn_invalid_fields(&pi, "page.md"), 0);
1879 let fp = Iso20022Entity::FinancialProduct(FinancialProduct::default());
1880 assert_eq!(warn_invalid_fields(&fp, "page.md"), 0);
1881 }
1882
1883 #[test]
1886 fn schema_validator_flags_financial_product_missing_name() {
1887 let v = serde_json::json!({
1890 "@context": "https://schema.org",
1891 "@type": "FinancialProduct",
1892 });
1893 let errs = validate_schema_org(&v);
1894 assert!(errs.iter().any(|e| e.field == "name"));
1895 }
1896
1897 #[test]
1898 fn schema_validator_passes_institution_with_name() {
1899 let v = RegulatedFinancialInstitution {
1900 name: "Acme Bank".to_string(),
1901 ..RegulatedFinancialInstitution::default()
1902 }
1903 .to_jsonld();
1904 assert!(
1905 validate_schema_org(&v).is_empty(),
1906 "{:?}",
1907 validate_schema_org(&v)
1908 );
1909 }
1910
1911 #[test]
1912 fn schema_validator_flags_payment_service_missing_instrument_type() {
1913 let v = serde_json::json!({
1914 "@context": "https://schema.org",
1915 "@type": "PaymentService",
1916 });
1917 let errs = validate_schema_org(&v);
1918 assert!(errs.iter().any(|e| e.field == "iso20022:instrumentType"));
1919 }
1920
1921 #[test]
1922 fn schema_validator_passes_payment_service_with_instrument_type() {
1923 let v = PaymentInstrument {
1924 instrument_type: "card".to_string(),
1925 ..PaymentInstrument::default()
1926 }
1927 .to_jsonld();
1928 assert!(
1929 validate_schema_org(&v).is_empty(),
1930 "{:?}",
1931 validate_schema_org(&v)
1932 );
1933 }
1934
1935 #[test]
1936 fn schema_validator_ignores_unknown_types() {
1937 let v = serde_json::json!({
1938 "@context": "https://schema.org",
1939 "@type": "SomethingElse",
1940 });
1941 assert!(
1942 validate_schema_org(&v).is_empty(),
1943 "{:?}",
1944 validate_schema_org(&v)
1945 );
1946 }
1947
1948 #[test]
1949 fn schema_validator_defaults_to_unknown_when_type_field_absent() {
1950 let v = serde_json::json!({"@context": "https://schema.org"});
1954 let errs = validate_schema_org(&v);
1955 assert!(errs.is_empty(), "Unknown type enforces no required fields");
1956 }
1957
1958 #[test]
1967 fn domain_types_partial_eq_covers_equal_and_unequal_tail_fields() {
1968 let a = MonetaryAmount {
1969 currency: "EUR".to_string(),
1970 amount: 1.0,
1971 };
1972 let b = MonetaryAmount {
1973 currency: "EUR".to_string(),
1974 amount: 1.0,
1975 };
1976 let c = MonetaryAmount {
1977 currency: "EUR".to_string(),
1978 amount: 2.0,
1979 };
1980 assert_eq!(a, b);
1981 assert_ne!(a, c);
1982
1983 let ba1 = BankAccount {
1984 name: Some("N".to_string()),
1985 iban: Some("I".to_string()),
1986 bic: Some("B".to_string()),
1987 };
1988 let ba2 = ba1.clone();
1989 let mut ba3 = ba1.clone();
1990 ba3.bic = Some("DIFFERENT".to_string());
1991 assert_eq!(ba1, ba2);
1992 assert_ne!(ba1, ba3);
1993
1994 let pi1 = PaymentInstrument {
1995 name: Some("N".to_string()),
1996 instrument_type: "card".to_string(),
1997 brand: Some("Visa".to_string()),
1998 };
1999 let mut pi2 = pi1.clone();
2000 pi2.brand = Some("Other".to_string());
2001 assert_eq!(pi1, pi1.clone());
2002 assert_ne!(pi1, pi2);
2003
2004 let rfi1 = RegulatedFinancialInstitution {
2005 name: "Acme".to_string(),
2006 lei: Some("L".to_string()),
2007 licence_id: Some("LIC".to_string()),
2008 regulator: Some("FCA".to_string()),
2009 url: Some("https://x".to_string()),
2010 };
2011 let mut rfi2 = rfi1.clone();
2012 rfi2.url = Some("https://y".to_string());
2013 assert_eq!(rfi1, rfi1.clone());
2014 assert_ne!(rfi1, rfi2);
2015
2016 let fp1 = FinancialProduct {
2017 name: "Bond".to_string(),
2018 product_type: "deposit".to_string(),
2019 issuer: Some("Acme".to_string()),
2020 annual_percentage_rate: Some(1.0),
2021 isin: Some("US1".to_string()),
2022 };
2023 let mut fp2 = fp1.clone();
2024 fp2.isin = Some("US2".to_string());
2025 assert_eq!(fp1, fp1.clone());
2026 assert_ne!(fp1, fp2);
2027
2028 let ft1 = FinancialTransaction {
2029 instructed_amount: Some(a.clone()),
2030 debtor_account: Some(ba1.clone()),
2031 creditor_account: Some(ba1.clone()),
2032 execution_date: Some("2026-01-01".to_string()),
2033 end_to_end_id: Some("E1".to_string()),
2034 };
2035 let mut ft2 = ft1.clone();
2036 ft2.end_to_end_id = Some("E2".to_string());
2037 assert_eq!(ft1, ft1.clone());
2038 assert_ne!(ft1, ft2);
2039
2040 let se1 = SchemaOrgError {
2041 schema_type: "BankAccount".to_string(),
2042 field: "identifier".to_string(),
2043 reason: "missing".to_string(),
2044 };
2045 let mut se2 = se1.clone();
2046 se2.reason = "different".to_string();
2047 assert_eq!(se1, se1.clone());
2048 assert_ne!(se1, se2);
2049
2050 let vo1 = ValidationOutcome::Invalid {
2051 reason: "x".to_string(),
2052 };
2053 let vo2 = ValidationOutcome::Invalid {
2054 reason: "y".to_string(),
2055 };
2056 assert_eq!(
2057 vo1,
2058 ValidationOutcome::Invalid {
2059 reason: "x".to_string()
2060 }
2061 );
2062 assert_ne!(vo1, vo2);
2063 assert_ne!(vo1, ValidationOutcome::Valid);
2064
2065 let e1 = Iso20022Entity::FinancialProduct(fp1.clone());
2066 let e2 = Iso20022Entity::FinancialProduct(fp2.clone());
2067 assert_eq!(e1, Iso20022Entity::FinancialProduct(fp1.clone()));
2068 assert_ne!(e1, e2);
2069 assert_ne!(e1, Iso20022Entity::BankAccount(ba1.clone()));
2070 }
2071}