use crate::mt::error::MtError;
use crate::mt::types::{Block4, TagField};
use super::common::{parse_amount, parse_date_mmdd, parse_date_yymmdd, require_field};
#[derive(Debug, Clone, PartialEq)]
pub struct Mt940 {
pub transaction_reference: String,
pub account_id: String,
pub statement_number: String,
pub opening_balance: Balance,
pub closing_balance: Balance,
pub related_reference: Option<String>,
pub statement_lines: Vec<StatementLine>,
pub closing_available: Option<Balance>,
pub forward_available: Vec<Balance>,
pub account_owner_info: Option<String>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct Balance {
pub dc_indicator: char,
pub date: String,
pub currency: String,
pub amount: String,
pub balance_type: char,
}
#[derive(Debug, Clone, PartialEq)]
pub struct StatementLine {
pub value_date: String,
pub entry_date: Option<String>,
pub dc_mark: String,
pub funds_code: Option<char>,
pub amount: String,
pub transaction_type: String,
pub reference: String,
pub institution_reference: Option<String>,
pub supplementary: Option<String>,
pub information: Option<String>,
}
pub fn parse_mt940(block4: &Block4) -> Result<Mt940, MtError> {
let mt = "940";
let transaction_reference = require_field(block4, "20", mt)?.value.clone();
let account_id = block4
.fields
.iter()
.find(|f| f.tag == "25" || f.tag == "25P")
.map(|f| f.value.clone())
.ok_or_else(|| MtError::MissingField {
tag: "25".into(),
message_type: mt.into(),
})?;
let statement_number = require_field(block4, "28C", mt)?.value.clone();
let opening_balance = block4
.fields
.iter()
.find(|f| f.tag == "60F" || f.tag == "60M")
.ok_or_else(|| MtError::MissingField {
tag: "60F/60M".into(),
message_type: mt.into(),
})
.and_then(parse_balance)?;
let closing_balance = block4
.fields
.iter()
.find(|f| f.tag == "62F" || f.tag == "62M")
.ok_or_else(|| MtError::MissingField {
tag: "62F/62M".into(),
message_type: mt.into(),
})
.and_then(parse_balance)?;
let related_reference = block4.get("21").map(|f| f.value.clone());
let statement_lines = parse_statement_lines(block4)?;
let closing_available = block4
.fields
.iter()
.find(|f| f.tag == "64")
.map(parse_balance)
.transpose()?;
let forward_available = block4
.get_all("65")
.into_iter()
.map(parse_balance)
.collect::<Result<Vec<_>, _>>()?;
let account_owner_info = find_standalone_86(block4, &statement_lines);
Ok(Mt940 {
transaction_reference,
account_id,
statement_number,
opening_balance,
closing_balance,
related_reference,
statement_lines,
closing_available,
forward_available,
account_owner_info,
})
}
fn parse_statement_lines(block4: &Block4) -> Result<Vec<StatementLine>, MtError> {
let mut lines: Vec<StatementLine> = Vec::new();
let fields = &block4.fields;
let mut i = 0;
while i < fields.len() {
let field = &fields[i];
if field.tag == "61" {
let mut sl = parse_61(&field.value)?;
if i + 1 < fields.len() && fields[i + 1].tag == "86" {
sl.information = Some(fields[i + 1].value.clone());
i += 1; }
lines.push(sl);
}
i += 1;
}
Ok(lines)
}
fn find_standalone_86(block4: &Block4, consumed_lines: &[StatementLine]) -> Option<String> {
let consumed_count = consumed_lines
.iter()
.filter(|sl| sl.information.is_some())
.count();
let all_86: Vec<&str> = block4
.get_all("86")
.into_iter()
.map(|f| f.value.as_str())
.collect();
if all_86.len() > consumed_count {
Some(all_86[consumed_count..].join("\n"))
} else {
None
}
}
fn parse_balance(field: &TagField) -> Result<Balance, MtError> {
let tag = &field.tag;
let s = field.value.trim();
let balance_type = tag
.chars()
.last()
.filter(|c| *c == 'F' || *c == 'M')
.unwrap_or('F');
if s.len() < 11 {
return Err(MtError::InvalidFieldValue {
tag: tag.clone(),
detail: format!("balance too short: '{s}'"),
});
}
let dc_indicator = s.chars().next().ok_or_else(|| MtError::InvalidFieldValue {
tag: tag.clone(),
detail: "empty balance".into(),
})?;
if dc_indicator != 'D' && dc_indicator != 'C' {
return Err(MtError::InvalidFieldValue {
tag: tag.clone(),
detail: format!("expected D or C, got '{dc_indicator}'"),
});
}
let date = parse_date_yymmdd(&s[1..7]).map_err(|_| MtError::InvalidFieldValue {
tag: tag.clone(),
detail: format!("invalid date in balance '{s}'"),
})?;
let rest = &s[7..];
let amt = parse_amount(rest)?;
Ok(Balance {
dc_indicator,
date,
currency: amt.currency,
amount: amt.value,
balance_type,
})
}
fn parse_61(s: &str) -> Result<StatementLine, MtError> {
let tag = "61";
let mut line_iter = s.lines();
let first_line = line_iter.next().unwrap_or("").trim();
let supplementary_lines: Vec<&str> = line_iter.collect();
let supplementary = if supplementary_lines.is_empty() {
None
} else {
Some(supplementary_lines.join("\n"))
};
if first_line.len() < 7 {
return Err(MtError::InvalidFieldValue {
tag: tag.to_string(),
detail: format!("statement line too short: '{first_line}'"),
});
}
let mut pos = 0usize;
let value_date_str = &first_line[pos..pos + 6];
let value_date = parse_date_yymmdd(value_date_str).map_err(|_| MtError::InvalidFieldValue {
tag: tag.to_string(),
detail: format!("invalid value date '{value_date_str}'"),
})?;
let value_year: u32 = {
let yy: u32 = value_date_str[..2].parse().unwrap_or(0);
if yy >= 80 {
1900 + yy
} else {
2000 + yy
}
};
pos += 6;
let entry_date = if first_line.len() > pos + 4 {
let maybe_mmdd = &first_line[pos..pos + 4];
if maybe_mmdd.chars().all(|c| c.is_ascii_digit())
&& first_line.len() > pos + 4
&& matches!(first_line.chars().nth(pos + 4), Some('D' | 'C' | 'R'))
{
let ed = parse_date_mmdd(value_year, maybe_mmdd).ok();
if ed.is_some() {
pos += 4;
}
ed
} else {
None
}
} else {
None
};
let dc_mark = if first_line[pos..].starts_with("RD") {
pos += 2;
"RD".to_string()
} else if first_line[pos..].starts_with("RC") {
pos += 2;
"RC".to_string()
} else if first_line[pos..].starts_with('D') {
pos += 1;
"D".to_string()
} else if first_line[pos..].starts_with('C') {
pos += 1;
"C".to_string()
} else {
return Err(MtError::InvalidFieldValue {
tag: tag.to_string(),
detail: format!("expected D/C mark at position {pos} in '{first_line}'"),
});
};
let funds_code = if pos < first_line.len() {
let ch = first_line.chars().nth(pos).unwrap();
if ch.is_ascii_alphabetic() {
let next = first_line.chars().nth(pos + 1);
if matches!(next, Some(c) if c.is_ascii_digit()) {
pos += 1;
Some(ch)
} else {
None
}
} else {
None
}
} else {
None
};
let amount_start = pos;
while pos < first_line.len() {
let ch = first_line.chars().nth(pos).unwrap();
if ch.is_ascii_digit() || ch == ',' {
pos += 1;
} else {
break;
}
}
let raw_amount = &first_line[amount_start..pos];
let amount = raw_amount.replace(',', ".");
if pos + 4 > first_line.len() {
return Err(MtError::InvalidFieldValue {
tag: tag.to_string(),
detail: format!("missing transaction type in '{first_line}'"),
});
}
let transaction_type = first_line[pos..pos + 4].to_string();
pos += 4;
let rest = &first_line[pos..];
let (reference, institution_reference) = if let Some(idx) = rest.find("//") {
(rest[..idx].to_string(), Some(rest[idx + 2..].to_string()))
} else {
(rest.to_string(), None)
};
Ok(StatementLine {
value_date,
entry_date,
dc_mark,
funds_code,
amount,
transaction_type,
reference,
institution_reference,
supplementary,
information: None, })
}
#[cfg(test)]
mod tests {
use super::*;
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
:61:2306150615CR2500,00NMSCREF940-001
:86:Incoming transfer
:62F:C230615EUR11499,50
:64:C230615EUR11499,50
-}\
{5:{CHK:GHI12345678}}";
#[test]
fn test_parse_mt940_required_fields() {
let msg = parse(MT940_RAW).unwrap();
let mt = parse_mt940(&msg.block4).unwrap();
assert_eq!(mt.transaction_reference, "STMT-REF-001");
assert_eq!(mt.account_id, "NL91ABNA0417164300");
assert_eq!(mt.statement_number, "1/1");
}
#[test]
fn test_parse_mt940_opening_balance() {
let msg = parse(MT940_RAW).unwrap();
let mt = parse_mt940(&msg.block4).unwrap();
let ob = &mt.opening_balance;
assert_eq!(ob.dc_indicator, 'C');
assert_eq!(ob.date, "2023-06-14");
assert_eq!(ob.currency, "EUR");
assert_eq!(ob.amount, "10000.00");
assert_eq!(ob.balance_type, 'F');
}
#[test]
fn test_parse_mt940_closing_balance() {
let msg = parse(MT940_RAW).unwrap();
let mt = parse_mt940(&msg.block4).unwrap();
let cb = &mt.closing_balance;
assert_eq!(cb.dc_indicator, 'C');
assert_eq!(cb.date, "2023-06-15");
assert_eq!(cb.amount, "11499.50");
}
#[test]
fn test_parse_mt940_statement_lines() {
let msg = parse(MT940_RAW).unwrap();
let mt = parse_mt940(&msg.block4).unwrap();
assert_eq!(mt.statement_lines.len(), 2);
let sl0 = &mt.statement_lines[0];
assert_eq!(sl0.value_date, "2023-06-15");
assert_eq!(sl0.dc_mark, "D");
assert_eq!(sl0.amount, "1000.50");
assert_eq!(sl0.transaction_type, "NTRF");
assert_eq!(sl0.reference, "REF103-001");
assert_eq!(sl0.institution_reference.as_deref(), Some("INSTREF001"));
assert_eq!(sl0.information.as_deref(), Some("Payment to supplier"));
let sl1 = &mt.statement_lines[1];
assert_eq!(sl1.dc_mark, "C");
assert_eq!(sl1.amount, "2500.00");
assert_eq!(sl1.information.as_deref(), Some("Incoming transfer"));
}
#[test]
fn test_parse_mt940_closing_available() {
let msg = parse(MT940_RAW).unwrap();
let mt = parse_mt940(&msg.block4).unwrap();
let ca = mt.closing_available.unwrap();
assert_eq!(ca.dc_indicator, 'C');
assert_eq!(ca.amount, "11499.50");
}
#[test]
fn test_parse_mt940_missing_25_fails() {
let raw = "\
{1:F01BANKBEBBAXXX0000000000}\
{2:O9401200230615BANKBEBBAXXX00000000002306151200N}\
{3:}\
{4:
:20:REF
:28C:1/1
:60F:C230614EUR1000,00
:62F:C230614EUR1000,00
-}";
let msg = parse(raw).unwrap();
let err = parse_mt940(&msg.block4).unwrap_err();
assert!(matches!(err, MtError::MissingField { tag, .. } if tag == "25"));
}
#[test]
fn test_parse_balance_debit() {
let field = crate::mt::types::TagField {
tag: "60F".into(),
value: "D230614EUR500,00".into(),
};
let b = parse_balance(&field).unwrap();
assert_eq!(b.dc_indicator, 'D');
assert_eq!(b.amount, "500.00");
}
#[test]
fn test_parse_61_with_entry_date() {
let sl = parse_61("2306150615CR2500,00NMSCREF940-001").unwrap();
assert_eq!(sl.value_date, "2023-06-15");
assert_eq!(sl.entry_date.as_deref(), Some("2023-06-15"));
assert_eq!(sl.dc_mark, "C");
}
}