use serde::{Deserialize, Serialize};
use std::sync::atomic::{AtomicBool, Ordering};
pub const DOCS_URL: &str =
"https://docs.rs/ssg/latest/ssg/seo/jsonld/iso20022/index.html";
static FIRST_USE_LOGGED: AtomicBool = AtomicBool::new(false);
pub fn log_first_use_pointer() {
if !FIRST_USE_LOGGED.swap(true, Ordering::Relaxed) {
log::info!(
"[json-ld/iso20022] First use of ISO 20022 frontmatter detected. \
See {DOCS_URL} for the list of available types and required fields."
);
}
}
#[cfg(test)]
pub(crate) fn reset_first_use_for_test() {
FIRST_USE_LOGGED.store(false, Ordering::Relaxed);
}
#[cfg(test)]
pub(crate) fn first_use_was_logged() -> bool {
FIRST_USE_LOGGED.load(Ordering::Relaxed)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ValidationOutcome {
Valid,
Invalid {
reason: String,
},
}
impl ValidationOutcome {
#[must_use]
pub const fn is_valid(&self) -> bool {
matches!(self, Self::Valid)
}
}
#[must_use]
pub fn validate_iban(input: &str) -> ValidationOutcome {
let compact: String = input
.chars()
.filter(|c| !c.is_whitespace())
.collect::<String>()
.to_ascii_uppercase();
if compact.len() < 15 || compact.len() > 34 {
return ValidationOutcome::Invalid {
reason: format!(
"IBAN length {} outside ISO 13616 range 15..=34",
compact.len()
),
};
}
let bytes = compact.as_bytes();
if !(bytes[0].is_ascii_alphabetic() && bytes[1].is_ascii_alphabetic()) {
return ValidationOutcome::Invalid {
reason: "IBAN country code must be two ASCII letters".to_string(),
};
}
if !(bytes[2].is_ascii_digit() && bytes[3].is_ascii_digit()) {
return ValidationOutcome::Invalid {
reason: "IBAN check digits must be two ASCII digits".to_string(),
};
}
if !bytes[4..].iter().all(u8::is_ascii_alphanumeric) {
return ValidationOutcome::Invalid {
reason: "IBAN BBAN must be alphanumeric ASCII".to_string(),
};
}
let mut rearranged = String::with_capacity(compact.len());
rearranged.push_str(&compact[4..]);
rearranged.push_str(&compact[..4]);
let mut remainder: u64 = 0;
for ch in rearranged.chars() {
let digits: u64 = if ch.is_ascii_digit() {
u64::from(ch as u8 - b'0')
} else if ch.is_ascii_alphabetic() {
u64::from((ch.to_ascii_uppercase() as u8) - b'A') + 10
} else {
return ValidationOutcome::Invalid {
reason: format!("Non-alphanumeric character in IBAN: {ch:?}"),
};
};
if digits >= 10 {
remainder = (remainder * 100 + digits) % 97;
} else {
remainder = (remainder * 10 + digits) % 97;
}
}
if remainder == 1 {
ValidationOutcome::Valid
} else {
ValidationOutcome::Invalid {
reason: format!(
"IBAN MOD-97 checksum failed (remainder={remainder})"
),
}
}
}
#[must_use]
pub fn validate_bic(input: &str) -> ValidationOutcome {
let compact: String =
input.chars().filter(|c| !c.is_whitespace()).collect();
if compact.len() != 8 && compact.len() != 11 {
return ValidationOutcome::Invalid {
reason: format!(
"BIC length {} is not 8 or 11 (ISO 9362)",
compact.len()
),
};
}
let upper = compact.to_ascii_uppercase();
let bytes = upper.as_bytes();
if !bytes[..4].iter().all(u8::is_ascii_alphabetic) {
return ValidationOutcome::Invalid {
reason: "BIC bank code (chars 1-4) must be ASCII letters"
.to_string(),
};
}
if !bytes[4..6].iter().all(u8::is_ascii_alphabetic) {
return ValidationOutcome::Invalid {
reason: "BIC country code (chars 5-6) must be ASCII letters"
.to_string(),
};
}
if !bytes[6..8].iter().all(u8::is_ascii_alphanumeric) {
return ValidationOutcome::Invalid {
reason: "BIC location code (chars 7-8) must be ASCII alphanumerics"
.to_string(),
};
}
if compact.len() == 11
&& !bytes[8..11].iter().all(u8::is_ascii_alphanumeric)
{
return ValidationOutcome::Invalid {
reason: "BIC branch code (chars 9-11) must be ASCII alphanumerics"
.to_string(),
};
}
ValidationOutcome::Valid
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct MonetaryAmount {
pub currency: String,
pub amount: f64,
}
impl MonetaryAmount {
#[must_use]
pub fn to_jsonld(&self) -> serde_json::Value {
serde_json::json!({
"@type": "MonetaryAmount",
"currency": self.currency,
"value": self.amount,
})
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
pub struct BankAccount {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub iban: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub bic: Option<String>,
}
impl BankAccount {
#[must_use]
pub fn to_jsonld(&self) -> serde_json::Value {
let mut obj = serde_json::json!({
"@context": context_with_iso(),
"@type": "BankAccount",
});
if let Some(name) = &self.name {
obj["name"] = serde_json::json!(name);
}
if let Some(iban) = &self.iban {
obj["iso20022:iban"] = serde_json::json!(iban);
}
if let Some(bic) = &self.bic {
obj["iso20022:bic"] = serde_json::json!(bic);
}
obj
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
pub struct PaymentInstrument {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
pub instrument_type: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub brand: Option<String>,
}
impl PaymentInstrument {
#[must_use]
pub fn to_jsonld(&self) -> serde_json::Value {
let mut obj = serde_json::json!({
"@context": context_with_iso(),
"@type": "PaymentService",
"iso20022:instrumentType": self.instrument_type,
});
if let Some(name) = &self.name {
obj["name"] = serde_json::json!(name);
}
if let Some(brand) = &self.brand {
obj["brand"] = serde_json::json!(brand);
}
obj
}
}
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
pub struct FinancialTransaction {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub instructed_amount: Option<MonetaryAmount>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub debtor_account: Option<BankAccount>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub creditor_account: Option<BankAccount>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub execution_date: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub end_to_end_id: Option<String>,
}
impl FinancialTransaction {
#[must_use]
pub fn to_jsonld(&self) -> serde_json::Value {
let mut obj = serde_json::json!({
"@context": context_with_iso(),
"@type": "MoneyTransfer",
});
if let Some(amount) = &self.instructed_amount {
obj["amount"] = amount.to_jsonld();
}
if let Some(debtor) = &self.debtor_account {
obj["iso20022:debtorAccount"] = strip_context(debtor.to_jsonld());
}
if let Some(creditor) = &self.creditor_account {
obj["iso20022:creditorAccount"] =
strip_context(creditor.to_jsonld());
}
if let Some(date) = &self.execution_date {
obj["iso20022:executionDate"] = serde_json::json!(date);
}
if let Some(id) = &self.end_to_end_id {
obj["iso20022:endToEndId"] = serde_json::json!(id);
obj["identifier"] = serde_json::json!(id);
}
obj
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
pub struct RegulatedFinancialInstitution {
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub lei: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub licence_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub regulator: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub url: Option<String>,
}
impl RegulatedFinancialInstitution {
#[must_use]
pub fn to_jsonld(&self) -> serde_json::Value {
let mut obj = serde_json::json!({
"@context": context_with_iso(),
"@type": "BankOrCreditUnion",
"name": self.name,
});
if let Some(lei) = &self.lei {
obj["iso20022:lei"] = serde_json::json!(lei);
obj["identifier"] = serde_json::json!(lei);
}
if let Some(licence) = &self.licence_id {
obj["iso20022:licenceId"] = serde_json::json!(licence);
}
if let Some(regulator) = &self.regulator {
obj["iso20022:regulator"] = serde_json::json!(regulator);
}
if let Some(url) = &self.url {
obj["url"] = serde_json::json!(url);
}
obj
}
}
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
pub struct FinancialProduct {
pub name: String,
pub product_type: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub issuer: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub annual_percentage_rate: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub isin: Option<String>,
}
impl FinancialProduct {
#[must_use]
pub fn to_jsonld(&self) -> serde_json::Value {
let mut obj = serde_json::json!({
"@context": context_with_iso(),
"@type": "FinancialProduct",
"name": self.name,
"iso20022:productType": self.product_type,
});
if let Some(issuer) = &self.issuer {
obj["provider"] = serde_json::json!({
"@type": "Organization",
"name": issuer,
});
}
if let Some(apr) = self.annual_percentage_rate {
obj["annualPercentageRate"] = serde_json::json!(apr);
}
if let Some(isin) = &self.isin {
obj["iso20022:isin"] = serde_json::json!(isin);
obj["identifier"] = serde_json::json!(isin);
}
obj
}
}
fn context_with_iso() -> serde_json::Value {
serde_json::json!({
"@vocab": "https://schema.org/",
"iso20022": "https://www.iso20022.org/",
})
}
fn strip_context(mut value: serde_json::Value) -> serde_json::Value {
if let Some(obj) = value.as_object_mut() {
let _ = obj.remove("@context");
}
value
}
#[derive(Debug, Clone, PartialEq)]
pub enum Iso20022Entity {
BankAccount(BankAccount),
PaymentInstrument(PaymentInstrument),
FinancialTransaction(FinancialTransaction),
RegulatedFinancialInstitution(RegulatedFinancialInstitution),
FinancialProduct(FinancialProduct),
}
impl Iso20022Entity {
#[must_use]
pub fn to_jsonld(&self) -> serde_json::Value {
match self {
Self::BankAccount(b) => b.to_jsonld(),
Self::PaymentInstrument(p) => p.to_jsonld(),
Self::FinancialTransaction(t) => t.to_jsonld(),
Self::RegulatedFinancialInstitution(r) => r.to_jsonld(),
Self::FinancialProduct(p) => p.to_jsonld(),
}
}
#[must_use]
pub const fn type_name(&self) -> &'static str {
match self {
Self::BankAccount(_) => "BankAccount",
Self::PaymentInstrument(_) => "PaymentInstrument",
Self::FinancialTransaction(_) => "FinancialTransaction",
Self::RegulatedFinancialInstitution(_) => {
"RegulatedFinancialInstitution"
}
Self::FinancialProduct(_) => "FinancialProduct",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DispatchError {
MissingType,
UnknownType(String),
Malformed(String),
}
impl std::fmt::Display for DispatchError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::MissingType => write!(
f,
"iso20022 frontmatter is missing the required `type` field"
),
Self::UnknownType(t) => write!(
f,
"iso20022 frontmatter type `{t}` is not one of: \
BankAccount, PaymentInstrument, FinancialTransaction, \
RegulatedFinancialInstitution, FinancialProduct"
),
Self::Malformed(reason) => {
write!(f, "iso20022 frontmatter payload is malformed: {reason}")
}
}
}
}
pub fn from_frontmatter(
value: &serde_json::Value,
) -> Result<Iso20022Entity, DispatchError> {
let type_str = value
.get("type")
.and_then(|v| v.as_str())
.ok_or(DispatchError::MissingType)?;
let mut payload = value.clone();
if let Some(obj) = payload.as_object_mut() {
let _ = obj.remove("type");
}
match type_str {
"BankAccount" => serde_json::from_value::<BankAccount>(payload)
.map(Iso20022Entity::BankAccount)
.map_err(|e| DispatchError::Malformed(e.to_string())),
"PaymentInstrument" => {
serde_json::from_value::<PaymentInstrument>(payload)
.map(Iso20022Entity::PaymentInstrument)
.map_err(|e| DispatchError::Malformed(e.to_string()))
}
"FinancialTransaction" => {
serde_json::from_value::<FinancialTransaction>(payload)
.map(Iso20022Entity::FinancialTransaction)
.map_err(|e| DispatchError::Malformed(e.to_string()))
}
"RegulatedFinancialInstitution" => {
serde_json::from_value::<RegulatedFinancialInstitution>(payload)
.map(Iso20022Entity::RegulatedFinancialInstitution)
.map_err(|e| DispatchError::Malformed(e.to_string()))
}
"FinancialProduct" => {
serde_json::from_value::<FinancialProduct>(payload)
.map(Iso20022Entity::FinancialProduct)
.map_err(|e| DispatchError::Malformed(e.to_string()))
}
other => Err(DispatchError::UnknownType(other.to_string())),
}
}
pub fn warn_invalid_fields(entity: &Iso20022Entity, page_label: &str) -> usize {
fn warn_iban(page_label: &str, iban: &str, who: &str) -> usize {
if let ValidationOutcome::Invalid { reason } = validate_iban(iban) {
log::warn!(
"[json-ld/iso20022] {page_label}: invalid IBAN on {who}: \
{iban} — {reason}"
);
1
} else {
0
}
}
fn warn_bic(page_label: &str, bic: &str, who: &str) -> usize {
if let ValidationOutcome::Invalid { reason } = validate_bic(bic) {
log::warn!(
"[json-ld/iso20022] {page_label}: invalid BIC on {who}: \
{bic} — {reason}"
);
1
} else {
0
}
}
let mut warnings = 0_usize;
match entity {
Iso20022Entity::BankAccount(b) => {
if let Some(iban) = &b.iban {
warnings += warn_iban(page_label, iban, "bank_account.iban");
}
if let Some(bic) = &b.bic {
warnings += warn_bic(page_label, bic, "bank_account.bic");
}
}
Iso20022Entity::FinancialTransaction(t) => {
if let Some(d) = &t.debtor_account {
if let Some(iban) = &d.iban {
warnings +=
warn_iban(page_label, iban, "debtor_account.iban");
}
if let Some(bic) = &d.bic {
warnings += warn_bic(page_label, bic, "debtor_account.bic");
}
}
if let Some(c) = &t.creditor_account {
if let Some(iban) = &c.iban {
warnings +=
warn_iban(page_label, iban, "creditor_account.iban");
}
if let Some(bic) = &c.bic {
warnings +=
warn_bic(page_label, bic, "creditor_account.bic");
}
}
}
Iso20022Entity::RegulatedFinancialInstitution(_)
| Iso20022Entity::PaymentInstrument(_)
| Iso20022Entity::FinancialProduct(_) => {}
}
warnings
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SchemaOrgError {
pub schema_type: String,
pub field: String,
pub reason: String,
}
#[must_use]
#[allow(clippy::collapsible_match)]
pub fn validate_schema_org(value: &serde_json::Value) -> Vec<SchemaOrgError> {
let mut errors = Vec::new();
let schema_type = value
.get("@type")
.and_then(|v| v.as_str())
.unwrap_or("Unknown")
.to_string();
let ctx_ok = value.get("@context").is_some_and(|c| {
let s = serde_json::to_string(c).unwrap_or_default();
s.contains("schema.org")
});
if !ctx_ok {
errors.push(SchemaOrgError {
schema_type: schema_type.clone(),
field: "@context".to_string(),
reason: "missing or does not reference schema.org".to_string(),
});
}
{
match schema_type.as_str() {
"BankAccount" => {
let has_id = ["identifier", "iso20022:iban", "iso20022:bic"]
.iter()
.any(|f| value.get(*f).is_some());
if !has_id {
errors.push(SchemaOrgError {
schema_type,
field: "identifier|iso20022:iban|iso20022:bic"
.to_string(),
reason:
"BankAccount must carry at least one identifier"
.to_string(),
});
}
}
"MoneyTransfer" => {
let amount = value.get("amount");
let amount_ok = amount.is_some_and(|a| {
a.get("@type").and_then(|t| t.as_str())
== Some("MonetaryAmount")
&& a.get("currency").is_some()
&& a.get("value").is_some()
});
if !amount_ok {
errors.push(SchemaOrgError {
schema_type,
field: "amount".to_string(),
reason: "MoneyTransfer.amount must be a \
MonetaryAmount with currency and value"
.to_string(),
});
}
}
"BankOrCreditUnion" | "FinancialProduct" => {
let missing = value
.get("name")
.and_then(|v| v.as_str())
.is_none_or(str::is_empty);
if missing {
errors.push(SchemaOrgError {
schema_type,
field: "name".to_string(),
reason: "required field is missing or empty"
.to_string(),
});
}
}
"PaymentService" => {
let missing = value.get("iso20022:instrumentType").is_none();
if missing {
errors.push(SchemaOrgError {
schema_type,
field: "iso20022:instrumentType".to_string(),
reason: "PaymentService requires the namespaced \
instrumentType field"
.to_string(),
});
}
}
_ => {}
}
}
errors
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
#[test]
fn iban_uk_natwest_valid() {
assert!(validate_iban("GB29NWBK60161331926819").is_valid());
}
#[test]
fn iban_de_deutsche_valid() {
assert!(validate_iban("DE89370400440532013000").is_valid());
}
#[test]
fn iban_accepts_space_print_form() {
assert!(validate_iban("GB29 NWBK 6016 1331 9268 19").is_valid());
}
fn invalid_reason(outcome: ValidationOutcome) -> String {
match outcome {
ValidationOutcome::Invalid { reason } => reason,
ValidationOutcome::Valid => String::new(),
}
}
#[test]
fn invalid_reason_is_empty_for_valid_outcome() {
assert_eq!(invalid_reason(ValidationOutcome::Valid), "");
}
#[test]
fn iban_rejects_bad_checksum() {
let res = validate_iban("GB29NWBK60161331926811");
assert!(!res.is_valid());
assert!(invalid_reason(res).contains("MOD-97"));
}
#[test]
fn iban_rejects_short_input() {
assert!(!validate_iban("GB29").is_valid());
}
#[test]
fn iban_rejects_non_letter_country_code() {
let res = validate_iban("1229NWBK60161331926819");
assert!(!res.is_valid());
}
#[test]
fn bic_8_chars_valid() {
assert!(validate_bic("NWBKGB2L").is_valid());
}
#[test]
fn bic_11_chars_valid() {
assert!(validate_bic("NWBKGB2LXXX").is_valid());
}
#[test]
fn bic_rejects_9_char_length() {
let res = validate_bic("NWBKGB2LX");
assert!(!res.is_valid());
assert!(invalid_reason(res).contains("8 or 11"));
}
#[test]
fn bic_rejects_digit_in_country_code() {
assert!(!validate_bic("NWBK12 2L").is_valid());
}
#[test]
fn iban_rejects_non_alphanumeric_bban() {
let res = validate_iban("GB29NWBK60161331926*1");
assert!(matches!(
&res,
ValidationOutcome::Invalid { reason } if reason.contains("BBAN")
));
}
#[test]
fn iban_rejects_bad_check_digits() {
let res = validate_iban("GBABNWBK60161331926819");
assert!(!res.is_valid());
}
#[test]
fn bic_rejects_digit_in_bank_code() {
let res = validate_bic("1WBKGB2L");
assert!(matches!(
&res,
ValidationOutcome::Invalid { reason } if reason.contains("bank code")
));
}
#[test]
fn bic_rejects_letter_in_location_code() {
let res = validate_bic("NWBKGB!!");
assert!(!res.is_valid());
}
#[test]
fn bic_rejects_non_alphanumeric_branch_code() {
let res = validate_bic("NWBKGB2L!!!");
assert!(!res.is_valid());
}
#[test]
fn entity_type_name_covers_all_variants() {
let rfi = Iso20022Entity::RegulatedFinancialInstitution(
RegulatedFinancialInstitution::default(),
);
assert_eq!(rfi.type_name(), "RegulatedFinancialInstitution");
let fp = Iso20022Entity::FinancialProduct(FinancialProduct::default());
assert_eq!(fp.type_name(), "FinancialProduct");
}
#[test]
fn bank_account_jsonld_includes_iban_and_bic_namespaced() {
let acc = BankAccount {
name: Some("Treasury".to_string()),
iban: Some("GB29NWBK60161331926819".to_string()),
bic: Some("NWBKGB2L".to_string()),
};
let v = acc.to_jsonld();
assert_eq!(v["@type"], "BankAccount");
assert_eq!(v["iso20022:iban"], "GB29NWBK60161331926819");
assert_eq!(v["iso20022:bic"], "NWBKGB2L");
assert_eq!(v["name"], "Treasury");
assert!(v.get("identifier").is_none());
}
#[test]
fn bank_account_jsonld_omits_optional_fields() {
let v = BankAccount::default().to_jsonld();
assert!(v.get("iso20022:iban").is_none());
assert!(v.get("iso20022:bic").is_none());
assert!(v.get("name").is_none());
}
#[test]
fn payment_instrument_emits_namespaced_type() {
let p = PaymentInstrument {
name: Some("Visa Debit".to_string()),
instrument_type: "card".to_string(),
brand: Some("Visa".to_string()),
};
let v = p.to_jsonld();
assert_eq!(v["@type"], "PaymentService");
assert_eq!(v["iso20022:instrumentType"], "card");
assert_eq!(v["brand"], "Visa");
}
#[test]
fn financial_transaction_jsonld_shape_full() {
let t = FinancialTransaction {
instructed_amount: Some(MonetaryAmount {
currency: "EUR".to_string(),
amount: 1500.00,
}),
debtor_account: Some(BankAccount {
name: None,
iban: Some("GB29NWBK60161331926819".to_string()),
bic: None,
}),
creditor_account: Some(BankAccount {
name: None,
iban: Some("DE89370400440532013000".to_string()),
bic: None,
}),
execution_date: Some("2026-06-25".to_string()),
end_to_end_id: Some("E2E-001".to_string()),
};
let v = t.to_jsonld();
assert_eq!(v["@type"], "MoneyTransfer");
assert_eq!(v["amount"]["currency"], "EUR");
assert_eq!(v["amount"]["value"], 1500.0);
assert_eq!(
v["iso20022:debtorAccount"]["iso20022:iban"],
"GB29NWBK60161331926819"
);
assert!(v["iso20022:debtorAccount"].get("@context").is_none());
assert_eq!(v["iso20022:endToEndId"], "E2E-001");
assert_eq!(v["identifier"], "E2E-001");
}
#[test]
fn regulated_institution_jsonld_includes_lei() {
let r = RegulatedFinancialInstitution {
name: "Acme Bank plc".to_string(),
lei: Some("529900W18LQJJN6SJ336".to_string()),
licence_id: Some("FCA-FRN-123456".to_string()),
regulator: Some("FCA".to_string()),
url: Some("https://acme.example".to_string()),
};
let v = r.to_jsonld();
assert_eq!(v["@type"], "BankOrCreditUnion");
assert_eq!(v["name"], "Acme Bank plc");
assert_eq!(v["iso20022:lei"], "529900W18LQJJN6SJ336");
assert_eq!(v["iso20022:licenceId"], "FCA-FRN-123456");
}
#[test]
fn financial_product_jsonld_includes_isin_and_apr() {
let p = FinancialProduct {
name: "Green Bond 2030".to_string(),
product_type: "deposit".to_string(),
issuer: Some("Acme Bank".to_string()),
annual_percentage_rate: Some(3.5),
isin: Some("US0378331005".to_string()),
};
let v = p.to_jsonld();
assert_eq!(v["@type"], "FinancialProduct");
assert_eq!(v["name"], "Green Bond 2030");
assert_eq!(v["iso20022:productType"], "deposit");
assert_eq!(v["iso20022:isin"], "US0378331005");
assert_eq!(v["annualPercentageRate"], 3.5);
assert_eq!(v["provider"]["@type"], "Organization");
assert_eq!(v["provider"]["name"], "Acme Bank");
}
#[test]
fn dispatch_bank_account_round_trip() {
let fm = serde_json::json!({
"type": "BankAccount",
"iban": "GB29NWBK60161331926819",
});
let entity = from_frontmatter(&fm).unwrap();
assert_eq!(entity.type_name(), "BankAccount");
let jsonld = entity.to_jsonld();
assert_eq!(jsonld["iso20022:iban"], "GB29NWBK60161331926819");
}
#[test]
fn dispatch_financial_transaction_from_yaml_shape() {
let fm = serde_json::json!({
"type": "FinancialTransaction",
"instructed_amount": {"currency": "EUR", "amount": 1500.00},
"debtor_account": {"iban": "GB29NWBK60161331926819"},
"creditor_account": {"iban": "DE89370400440532013000"},
});
let entity = from_frontmatter(&fm).unwrap();
assert_eq!(entity.type_name(), "FinancialTransaction");
let jsonld = entity.to_jsonld();
assert_eq!(jsonld["amount"]["currency"], "EUR");
}
#[test]
fn dispatch_missing_type_errors() {
let fm = serde_json::json!({"iban": "GB29NWBK60161331926819"});
let err = from_frontmatter(&fm).unwrap_err();
assert_eq!(err, DispatchError::MissingType);
}
#[test]
fn dispatch_unknown_type_errors() {
let fm = serde_json::json!({"type": "GalacticCredits"});
let err = from_frontmatter(&fm).unwrap_err();
assert!(
matches!(err, DispatchError::UnknownType(t) if t == "GalacticCredits")
);
}
#[test]
fn warn_invalid_fields_counts_iban_failure() {
let entity = Iso20022Entity::BankAccount(BankAccount {
iban: Some("INVALID-IBAN".to_string()),
..BankAccount::default()
});
let warnings = warn_invalid_fields(&entity, "page.md");
assert_eq!(warnings, 1);
}
#[test]
fn warn_invalid_fields_counts_zero_for_valid_iban() {
let entity = Iso20022Entity::BankAccount(BankAccount {
iban: Some("GB29NWBK60161331926819".to_string()),
bic: Some("NWBKGB2L".to_string()),
..BankAccount::default()
});
let warnings = warn_invalid_fields(&entity, "page.md");
assert_eq!(warnings, 0);
}
#[test]
fn warn_invalid_fields_walks_transaction_subfields() {
let entity =
Iso20022Entity::FinancialTransaction(FinancialTransaction {
debtor_account: Some(BankAccount {
iban: Some("BAD".to_string()),
..BankAccount::default()
}),
creditor_account: Some(BankAccount {
bic: Some("BADBIC".to_string()), ..BankAccount::default()
}),
..FinancialTransaction::default()
});
let warnings = warn_invalid_fields(&entity, "page.md");
assert_eq!(warnings, 2);
}
#[test]
fn first_use_pointer_fires_exactly_once() {
reset_first_use_for_test();
assert!(!first_use_was_logged());
log_first_use_pointer();
assert!(first_use_was_logged());
log_first_use_pointer();
assert!(first_use_was_logged());
}
#[test]
fn schema_validator_passes_bank_account_with_identifier() {
let v = BankAccount {
iban: Some("GB29NWBK60161331926819".to_string()),
..BankAccount::default()
}
.to_jsonld();
assert!(validate_schema_org(&v).is_empty());
}
#[test]
fn schema_validator_flags_bank_account_without_identifier() {
let v = BankAccount::default().to_jsonld();
let errs = validate_schema_org(&v);
assert!(errs.iter().any(|e| e.field.contains("identifier")));
}
#[test]
fn schema_validator_passes_money_transfer_with_amount() {
let v = FinancialTransaction {
instructed_amount: Some(MonetaryAmount {
currency: "USD".to_string(),
amount: 10.0,
}),
..FinancialTransaction::default()
}
.to_jsonld();
assert!(validate_schema_org(&v).is_empty());
}
#[test]
fn schema_validator_flags_money_transfer_missing_amount() {
let v = FinancialTransaction::default().to_jsonld();
let errs = validate_schema_org(&v);
assert!(errs.iter().any(|e| e.field == "amount"));
}
#[test]
fn schema_validator_flags_empty_institution_name() {
let v = RegulatedFinancialInstitution {
name: String::new(),
..RegulatedFinancialInstitution::default()
}
.to_jsonld();
let errs = validate_schema_org(&v);
assert!(errs.iter().any(|e| e.field == "name"));
}
#[test]
fn schema_validator_flags_missing_context() {
let v = serde_json::json!({"@type": "BankAccount", "identifier": "x"});
let errs = validate_schema_org(&v);
assert!(errs.iter().any(|e| e.field == "@context"));
}
#[test]
fn payment_instrument_jsonld_omits_optional_fields() {
let p = PaymentInstrument {
name: None,
instrument_type: "transfer".to_string(),
brand: None,
};
let v = p.to_jsonld();
assert!(v.get("name").is_none());
assert!(v.get("brand").is_none());
assert_eq!(v["iso20022:instrumentType"], "transfer");
}
#[test]
fn financial_product_jsonld_omits_optional_fields() {
let p = FinancialProduct {
name: "Plain Loan".to_string(),
product_type: "loan".to_string(),
issuer: None,
annual_percentage_rate: None,
isin: None,
};
let v = p.to_jsonld();
assert!(v.get("provider").is_none());
assert!(v.get("annualPercentageRate").is_none());
assert!(v.get("iso20022:isin").is_none());
assert!(v.get("identifier").is_none());
}
#[test]
fn strip_context_passes_non_object_values_through() {
let v = strip_context(serde_json::json!("scalar"));
assert_eq!(v, serde_json::json!("scalar"));
}
#[test]
fn entity_to_jsonld_covers_remaining_variants() {
let pi = Iso20022Entity::PaymentInstrument(PaymentInstrument {
instrument_type: "card".to_string(),
..PaymentInstrument::default()
});
assert_eq!(pi.to_jsonld()["@type"], "PaymentService");
let rfi = Iso20022Entity::RegulatedFinancialInstitution(
RegulatedFinancialInstitution {
name: "Acme Bank".to_string(),
..RegulatedFinancialInstitution::default()
},
);
assert_eq!(rfi.to_jsonld()["@type"], "BankOrCreditUnion");
let fp = Iso20022Entity::FinancialProduct(FinancialProduct {
name: "Bond".to_string(),
product_type: "derivative".to_string(),
..FinancialProduct::default()
});
assert_eq!(fp.to_jsonld()["@type"], "FinancialProduct");
}
#[test]
fn entity_type_name_payment_instrument() {
let pi =
Iso20022Entity::PaymentInstrument(PaymentInstrument::default());
assert_eq!(pi.type_name(), "PaymentInstrument");
}
#[test]
fn dispatch_error_display_all_variants() {
assert!(DispatchError::MissingType
.to_string()
.contains("missing the required `type` field"));
assert!(DispatchError::UnknownType("Widget".to_string())
.to_string()
.contains("`Widget` is not one of"));
assert!(DispatchError::Malformed("bad shape".to_string())
.to_string()
.contains("malformed: bad shape"));
}
#[test]
fn dispatch_non_string_type_field_is_missing_type() {
let fm = serde_json::json!({"type": 42});
assert_eq!(
from_frontmatter(&fm).unwrap_err(),
DispatchError::MissingType
);
}
#[test]
fn dispatch_payment_instrument_round_trip_and_malformed() {
let ok = serde_json::json!({
"type": "PaymentInstrument",
"instrument_type": "card",
});
let entity = from_frontmatter(&ok).unwrap();
assert_eq!(entity.type_name(), "PaymentInstrument");
let bad = serde_json::json!({"type": "PaymentInstrument"});
assert!(matches!(
from_frontmatter(&bad),
Err(DispatchError::Malformed(_))
));
}
#[test]
fn dispatch_regulated_institution_round_trip_and_malformed() {
let ok = serde_json::json!({
"type": "RegulatedFinancialInstitution",
"name": "Acme Bank",
});
let entity = from_frontmatter(&ok).unwrap();
assert_eq!(entity.type_name(), "RegulatedFinancialInstitution");
let bad = serde_json::json!({"type": "RegulatedFinancialInstitution"});
assert!(matches!(
from_frontmatter(&bad),
Err(DispatchError::Malformed(_))
));
}
#[test]
fn dispatch_financial_product_round_trip_and_malformed() {
let ok = serde_json::json!({
"type": "FinancialProduct",
"name": "Green Bond",
"product_type": "deposit",
});
let entity = from_frontmatter(&ok).unwrap();
assert_eq!(entity.type_name(), "FinancialProduct");
let bad = serde_json::json!({"type": "FinancialProduct"});
assert!(matches!(
from_frontmatter(&bad),
Err(DispatchError::Malformed(_))
));
}
#[test]
fn dispatch_bank_account_malformed_payload() {
let bad = serde_json::json!({"type": "BankAccount", "iban": 123});
assert!(matches!(
from_frontmatter(&bad),
Err(DispatchError::Malformed(_))
));
}
#[test]
fn dispatch_financial_transaction_malformed_payload() {
let bad = serde_json::json!({
"type": "FinancialTransaction",
"debtor_account": "not an object",
});
assert!(matches!(
from_frontmatter(&bad),
Err(DispatchError::Malformed(_))
));
}
#[test]
fn warn_walks_bank_account_with_bic_only() {
let e = Iso20022Entity::BankAccount(BankAccount {
iban: None,
bic: Some("BAD".to_string()),
..BankAccount::default()
});
assert_eq!(warn_invalid_fields(&e, "page.md"), 1);
}
#[test]
fn warn_walks_transaction_with_sparse_accounts() {
let e = Iso20022Entity::FinancialTransaction(FinancialTransaction {
debtor_account: Some(BankAccount {
iban: None,
bic: Some("BAD".to_string()),
..BankAccount::default()
}),
creditor_account: Some(BankAccount {
iban: Some("INVALID".to_string()),
bic: None,
..BankAccount::default()
}),
..FinancialTransaction::default()
});
assert_eq!(warn_invalid_fields(&e, "page.md"), 2);
}
#[test]
fn warn_transaction_without_accounts_emits_nothing() {
let e = Iso20022Entity::FinancialTransaction(
FinancialTransaction::default(),
);
assert_eq!(warn_invalid_fields(&e, "page.md"), 0);
}
#[test]
fn warn_skips_entities_without_account_fields() {
let pi =
Iso20022Entity::PaymentInstrument(PaymentInstrument::default());
assert_eq!(warn_invalid_fields(&pi, "page.md"), 0);
let fp = Iso20022Entity::FinancialProduct(FinancialProduct::default());
assert_eq!(warn_invalid_fields(&fp, "page.md"), 0);
}
#[test]
fn schema_validator_flags_financial_product_missing_name() {
let v = serde_json::json!({
"@context": "https://schema.org",
"@type": "FinancialProduct",
});
let errs = validate_schema_org(&v);
assert!(errs.iter().any(|e| e.field == "name"));
}
#[test]
fn schema_validator_passes_institution_with_name() {
let v = RegulatedFinancialInstitution {
name: "Acme Bank".to_string(),
..RegulatedFinancialInstitution::default()
}
.to_jsonld();
assert!(validate_schema_org(&v).is_empty());
}
#[test]
fn schema_validator_flags_payment_service_missing_instrument_type() {
let v = serde_json::json!({
"@context": "https://schema.org",
"@type": "PaymentService",
});
let errs = validate_schema_org(&v);
assert!(errs.iter().any(|e| e.field == "iso20022:instrumentType"));
}
#[test]
fn schema_validator_passes_payment_service_with_instrument_type() {
let v = PaymentInstrument {
instrument_type: "card".to_string(),
..PaymentInstrument::default()
}
.to_jsonld();
assert!(validate_schema_org(&v).is_empty());
}
#[test]
fn schema_validator_ignores_unknown_types() {
let v = serde_json::json!({
"@context": "https://schema.org",
"@type": "SomethingElse",
});
assert!(validate_schema_org(&v).is_empty());
}
#[test]
fn schema_validator_defaults_to_unknown_when_type_field_absent() {
let v = serde_json::json!({"@context": "https://schema.org"});
let errs = validate_schema_org(&v);
assert!(errs.is_empty(), "Unknown type enforces no required fields");
}
#[test]
fn domain_types_partial_eq_covers_equal_and_unequal_tail_fields() {
let a = MonetaryAmount {
currency: "EUR".to_string(),
amount: 1.0,
};
let b = MonetaryAmount {
currency: "EUR".to_string(),
amount: 1.0,
};
let c = MonetaryAmount {
currency: "EUR".to_string(),
amount: 2.0,
};
assert_eq!(a, b);
assert_ne!(a, c);
let ba1 = BankAccount {
name: Some("N".to_string()),
iban: Some("I".to_string()),
bic: Some("B".to_string()),
};
let ba2 = ba1.clone();
let mut ba3 = ba1.clone();
ba3.bic = Some("DIFFERENT".to_string());
assert_eq!(ba1, ba2);
assert_ne!(ba1, ba3);
let pi1 = PaymentInstrument {
name: Some("N".to_string()),
instrument_type: "card".to_string(),
brand: Some("Visa".to_string()),
};
let mut pi2 = pi1.clone();
pi2.brand = Some("Other".to_string());
assert_eq!(pi1, pi1.clone());
assert_ne!(pi1, pi2);
let rfi1 = RegulatedFinancialInstitution {
name: "Acme".to_string(),
lei: Some("L".to_string()),
licence_id: Some("LIC".to_string()),
regulator: Some("FCA".to_string()),
url: Some("https://x".to_string()),
};
let mut rfi2 = rfi1.clone();
rfi2.url = Some("https://y".to_string());
assert_eq!(rfi1, rfi1.clone());
assert_ne!(rfi1, rfi2);
let fp1 = FinancialProduct {
name: "Bond".to_string(),
product_type: "deposit".to_string(),
issuer: Some("Acme".to_string()),
annual_percentage_rate: Some(1.0),
isin: Some("US1".to_string()),
};
let mut fp2 = fp1.clone();
fp2.isin = Some("US2".to_string());
assert_eq!(fp1, fp1.clone());
assert_ne!(fp1, fp2);
let ft1 = FinancialTransaction {
instructed_amount: Some(a.clone()),
debtor_account: Some(ba1.clone()),
creditor_account: Some(ba1.clone()),
execution_date: Some("2026-01-01".to_string()),
end_to_end_id: Some("E1".to_string()),
};
let mut ft2 = ft1.clone();
ft2.end_to_end_id = Some("E2".to_string());
assert_eq!(ft1, ft1.clone());
assert_ne!(ft1, ft2);
let se1 = SchemaOrgError {
schema_type: "BankAccount".to_string(),
field: "identifier".to_string(),
reason: "missing".to_string(),
};
let mut se2 = se1.clone();
se2.reason = "different".to_string();
assert_eq!(se1, se1.clone());
assert_ne!(se1, se2);
let vo1 = ValidationOutcome::Invalid {
reason: "x".to_string(),
};
let vo2 = ValidationOutcome::Invalid {
reason: "y".to_string(),
};
assert_eq!(
vo1,
ValidationOutcome::Invalid {
reason: "x".to_string()
}
);
assert_ne!(vo1, vo2);
assert_ne!(vo1, ValidationOutcome::Valid);
let e1 = Iso20022Entity::FinancialProduct(fp1.clone());
let e2 = Iso20022Entity::FinancialProduct(fp2.clone());
assert_eq!(e1, Iso20022Entity::FinancialProduct(fp1.clone()));
assert_ne!(e1, e2);
assert_ne!(e1, Iso20022Entity::BankAccount(ba1.clone()));
}
}