use mx20022_model::common::ChoiceWrapper;
use mx20022_model::generated::camt::camt_053_001_11 as camt053;
use crate::mappings::error::{TranslationError, TranslationResult, TranslationWarnings};
use crate::mt::fields::mt940::{Balance, Mt940, StatementLine};
pub fn mt940_to_camt053(
mt940: &Mt940,
msg_id: &str,
creation_time: &str,
) -> Result<TranslationResult<camt053::Document>, TranslationError> {
let mut warnings = TranslationWarnings::default();
let grp_hdr = camt053::GroupHeader81::builder()
.msg_id(camt053::Max35Text(msg_id.to_string()))
.cre_dt_tm(camt053::ISODateTime(creation_time.to_string()))
.build()?;
let acct_id = build_account_id(&mt940.account_id);
let acct = camt053::CashAccount41 {
id: Some(acct_id),
tp: None,
ccy: None,
nm: None,
prxy: None,
ownr: None,
svcr: None,
};
let stmt_id = camt053::Max35Text(mt940.transaction_reference.clone());
let elctrnc_seq_nb: Option<camt053::Number> = parse_statement_number(&mt940.statement_number);
let mut bal_vec: Vec<camt053::CashBalance8> = Vec::new();
let opening_type_code = if mt940.opening_balance.balance_type == 'F' {
"OPBD"
} else {
"ITBD"
};
bal_vec.push(build_balance(&mt940.opening_balance, opening_type_code)?);
let closing_type_code = if mt940.closing_balance.balance_type == 'F' {
"CLBD"
} else {
"ITCL"
};
bal_vec.push(build_balance(&mt940.closing_balance, closing_type_code)?);
if let Some(ca) = &mt940.closing_available {
bal_vec.push(build_balance(ca, "CLAV")?);
}
for fa in &mt940.forward_available {
bal_vec.push(build_balance(fa, "FWAV")?);
}
let stmt_currency = mt940.opening_balance.currency.as_str();
let mut ntry_vec: Vec<camt053::ReportEntry13> = Vec::new();
for sl in &mt940.statement_lines {
match build_entry(sl, stmt_currency) {
Ok(entry) => ntry_vec.push(entry),
Err(e) => {
warnings.add(":61:", format!("skipped statement line: {e}"));
}
}
}
let stmt = camt053::AccountStatement12 {
id: stmt_id,
stmt_pgntn: None,
elctrnc_seq_nb,
rptg_seq: None,
lgl_seq_nb: None,
cre_dt_tm: None,
fr_to_dt: None,
cpy_dplct_ind: None,
rptg_src: None,
acct,
rltd_acct: None,
intrst: vec![],
bal: bal_vec,
txs_summry: None,
ntry: ntry_vec,
addtl_stmt_inf: None,
};
let bk_to_cstmr_stmt = camt053::BankToCustomerStatementV11 {
grp_hdr,
stmt: vec![stmt],
splmtry_data: vec![],
};
let doc = camt053::Document { bk_to_cstmr_stmt };
Ok(TranslationResult {
message: doc,
warnings,
})
}
fn build_account_id(account_id: &str) -> ChoiceWrapper<camt053::AccountIdentification4Choice> {
let trimmed = account_id.trim();
if is_iban_like(trimmed) {
ChoiceWrapper::new(camt053::AccountIdentification4Choice::IBAN(
camt053::IBAN2007Identifier(trimmed.to_string()),
))
} else {
ChoiceWrapper::new(camt053::AccountIdentification4Choice::Othr(
camt053::GenericAccountIdentification1 {
id: camt053::Max34Text(trimmed.to_string()),
schme_nm: None,
issr: None,
},
))
}
}
fn is_iban_like(s: &str) -> bool {
let chars: Vec<char> = s.chars().collect();
chars.len() >= 15
&& chars.len() <= 34
&& chars[0].is_ascii_uppercase()
&& chars[1].is_ascii_uppercase()
&& chars[2..].iter().all(char::is_ascii_alphanumeric)
}
fn parse_statement_number(s: &str) -> Option<camt053::Number> {
let first = s.split('/').next().unwrap_or("").trim();
if first.chars().all(|c| c.is_ascii_digit()) && !first.is_empty() {
Some(camt053::Number(first.to_string()))
} else {
None
}
}
fn build_balance(
bal: &Balance,
balance_type_code: &str,
) -> Result<camt053::CashBalance8, TranslationError> {
let cdt_dbt_ind = match bal.dc_indicator {
'C' => camt053::CreditDebitCode::Crdt,
'D' => camt053::CreditDebitCode::Dbit,
other => {
return Err(TranslationError::InvalidFieldValue {
field: "balance.dc_indicator".into(),
detail: format!("expected C or D, got '{other}'"),
});
}
};
let amt = camt053::ActiveOrHistoricCurrencyAndAmount {
value: camt053::ActiveOrHistoricCurrencyAndAmountSimpleType(bal.amount.clone()),
ccy: camt053::ActiveOrHistoricCurrencyCode(bal.currency.clone()),
};
let bal_type = camt053::BalanceType13 {
cd_or_prtry: ChoiceWrapper::new(camt053::BalanceType10Choice::Cd(
camt053::ExternalBalanceType1Code(balance_type_code.to_string()),
)),
sub_tp: None,
};
let dt = ChoiceWrapper::new(camt053::DateAndDateTime2Choice::Dt(camt053::ISODate(
bal.date.clone(),
)));
camt053::CashBalance8::builder()
.tp(bal_type)
.amt(amt)
.cdt_dbt_ind(cdt_dbt_ind)
.dt(dt)
.build()
.map_err(TranslationError::Builder)
}
fn build_entry(
sl: &StatementLine,
stmt_currency: &str,
) -> Result<camt053::ReportEntry13, TranslationError> {
if stmt_currency.is_empty() {
return Err(TranslationError::MissingField {
field: "Bal[OPBD]/Amt/@Ccy".into(),
context: "mt940_to_camt053 entry currency".into(),
});
}
let is_credit = sl.dc_mark == "C" || sl.dc_mark == "RC";
let cdt_dbt_ind = if is_credit {
camt053::CreditDebitCode::Crdt
} else {
camt053::CreditDebitCode::Dbit
};
let amt = camt053::ActiveOrHistoricCurrencyAndAmount {
value: camt053::ActiveOrHistoricCurrencyAndAmountSimpleType(sl.amount.clone()),
ccy: camt053::ActiveOrHistoricCurrencyCode(stmt_currency.to_string()),
};
let val_dt = ChoiceWrapper::new(camt053::DateAndDateTime2Choice::Dt(camt053::ISODate(
sl.value_date.clone(),
)));
let sts = ChoiceWrapper::new(camt053::EntryStatus1Choice::Cd(
camt053::ExternalEntryStatus1Code("BOOK".to_string()),
));
let bk_tx_cd = camt053::BankTransactionCodeStructure4 {
domn: None,
prtry: Some(camt053::ProprietaryBankTransactionCodeStructure1 {
cd: camt053::Max35Text(sl.transaction_type.clone()),
issr: None,
}),
};
let mut builder = camt053::ReportEntry13::builder()
.ntry_ref(camt053::Max35Text(sl.reference.clone()))
.amt(amt)
.cdt_dbt_ind(cdt_dbt_ind)
.sts(sts)
.val_dt(val_dt)
.bk_tx_cd(bk_tx_cd);
if let Some(info) = &sl.information {
builder = builder.addtl_ntry_inf(camt053::Max500Text(info.clone()));
}
builder.build().map_err(TranslationError::Builder)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::mt::fields::mt940::parse_mt940;
use crate::mt::parser::parse;
const MT940_RAW: &str = "\
{1:F01BANKBEBBAXXX0000000000}\
{2:O9401200230615BANKBEBBAXXX00000000002306151200N}\
{3:{108:MT940REF}}\
{4:
:20:STMT-REF-001
:25:NL91ABNA0417164300
:28C:1/1
:60F:C230614EUR10000,00
:61:2306150615DN1000,50NTRFREF103-001//INSTREF001
:86:Payment to supplier
:62F:C230615EUR8999,50
-}{5:{CHK:GHI12345678}}";
#[test]
fn test_mt940_to_camt053_basic() {
let msg = parse(MT940_RAW).unwrap();
let mt940 = parse_mt940(&msg.block4).unwrap();
let result = mt940_to_camt053(&mt940, "MSG001", "2023-06-15T10:00:00").unwrap();
let doc = &result.message;
let stmt = &doc.bk_to_cstmr_stmt.stmt[0];
assert_eq!(stmt.id.0, "STMT-REF-001");
}
#[test]
fn test_mt940_to_camt053_balances() {
let msg = parse(MT940_RAW).unwrap();
let mt940 = parse_mt940(&msg.block4).unwrap();
let result = mt940_to_camt053(&mt940, "MSG001", "2023-06-15T10:00:00").unwrap();
let stmt = &result.message.bk_to_cstmr_stmt.stmt[0];
assert!(stmt.bal.len() >= 2);
let ob = &stmt.bal[0];
assert!(matches!(ob.cdt_dbt_ind, camt053::CreditDebitCode::Crdt));
assert_eq!(ob.amt.value.0, "10000.00");
}
#[test]
fn test_mt940_to_camt053_entries() {
let msg = parse(MT940_RAW).unwrap();
let mt940 = parse_mt940(&msg.block4).unwrap();
let result = mt940_to_camt053(&mt940, "MSG001", "2023-06-15T10:00:00").unwrap();
let stmt = &result.message.bk_to_cstmr_stmt.stmt[0];
assert_eq!(stmt.ntry.len(), 1);
let entry = &stmt.ntry[0];
assert!(matches!(entry.cdt_dbt_ind, camt053::CreditDebitCode::Dbit));
assert_eq!(entry.amt.value.0, "1000.50");
assert_eq!(
entry.addtl_ntry_inf.as_ref().map(|s| s.0.as_str()),
Some("Payment to supplier")
);
}
#[test]
fn test_mt940_to_camt053_iban_account() {
let msg = parse(MT940_RAW).unwrap();
let mt940 = parse_mt940(&msg.block4).unwrap();
let result = mt940_to_camt053(&mt940, "MSG001", "2023-06-15T10:00:00").unwrap();
let stmt = &result.message.bk_to_cstmr_stmt.stmt[0];
if let Some(ChoiceWrapper {
inner: camt053::AccountIdentification4Choice::IBAN(iban),
}) = &stmt.acct.id
{
assert_eq!(iban.0, "NL91ABNA0417164300");
} else {
panic!("expected IBAN account identification");
}
}
#[test]
fn test_mt940_to_camt053_entry_currency_inherited_from_opening_balance() {
let msg = parse(MT940_RAW).unwrap();
let mt940 = parse_mt940(&msg.block4).unwrap();
let result = mt940_to_camt053(&mt940, "MSG001", "2023-06-15T10:00:00").unwrap();
let stmt = &result.message.bk_to_cstmr_stmt.stmt[0];
assert!(!stmt.ntry.is_empty(), "statement must have entries");
for (i, entry) in stmt.ntry.iter().enumerate() {
assert_eq!(
entry.amt.ccy.0, "EUR",
"entry {i} ccy must inherit from :60F: ({}), got {:?}",
mt940.opening_balance.currency, entry.amt.ccy.0
);
}
}
#[test]
fn test_mt940_to_camt053_empty_opening_currency_errors() {
let msg = parse(MT940_RAW).unwrap();
let mut mt940 = parse_mt940(&msg.block4).unwrap();
mt940.opening_balance.currency.clear();
let result = mt940_to_camt053(&mt940, "MSG001", "2023-06-15T10:00:00").unwrap();
let stmt = &result.message.bk_to_cstmr_stmt.stmt[0];
assert!(
stmt.ntry.is_empty(),
"all entries should have been rejected; got {} entries",
stmt.ntry.len()
);
assert!(
result
.warnings
.warnings
.iter()
.any(|w| w.field == ":61:" && w.message.contains("Bal[OPBD]/Amt/@Ccy")),
"expected a :61: warning citing the missing opening-balance currency, got: {:?}",
result.warnings.warnings
);
}
}