mod errors;
pub mod sanitizers;
mod tag_parsers;
mod transaction_types;
mod utils;
use chrono::prelude::*;
use log::debug;
use pest::Parser;
use pest_derive::Parser;
use rust_decimal::Decimal;
use serde_derive::{Deserialize, Serialize};
use std::str::FromStr;
pub use crate::errors::{
DateParseError, ParseError, RequiredTagNotFoundError, UnexpectedTagError, VariantNotFound,
};
use crate::tag_parsers::{
parse_20_tag, parse_21_tag, parse_25_tag, parse_28_tag, parse_60_tag, parse_61_tag,
parse_62_tag, parse_64_tag, parse_65_tag, parse_86_tag,
};
pub use crate::transaction_types::TransactionTypeIdentificationCode;
#[derive(Parser)]
#[grammar = "mt940.pest"]
pub struct MT940Parser;
#[derive(Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct Message {
pub transaction_ref_no: String,
pub ref_to_related_msg: Option<String>,
pub account_id: String,
pub statement_no: String,
pub sequence_no: Option<String>,
pub opening_balance: Balance,
pub statement_lines: Vec<StatementLine>,
pub closing_balance: Balance,
pub closing_available_balance: Option<AvailableBalance>,
pub forward_available_balance: Option<AvailableBalance>,
pub information_to_account_owner: Option<InformationToAccountOwner>,
}
#[derive(Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct StatementLine {
pub value_date: NaiveDate,
pub entry_date: Option<NaiveDate>,
pub ext_debit_credit_indicator: ExtDebitOrCredit,
pub funds_code: Option<String>,
pub amount: Decimal,
pub transaction_type_ident_code: TransactionTypeIdentificationCode,
pub customer_ref: String,
pub bank_ref: Option<String>,
pub supplementary_details: Option<String>,
pub information_to_account_owner: Option<InformationToAccountOwner>,
}
#[derive(Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct Balance {
pub is_intermediate: bool,
pub debit_credit_indicator: DebitOrCredit,
pub date: NaiveDate,
pub iso_currency_code: String,
pub amount: Decimal,
}
#[derive(Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct AvailableBalance {
pub debit_credit_indicator: DebitOrCredit,
pub date: NaiveDate,
pub iso_currency_code: String,
pub amount: Decimal,
}
#[serde_with::skip_serializing_none]
#[derive(Debug, Eq, PartialEq, Serialize, Deserialize, Clone)]
#[serde(untagged)]
#[allow(clippy::large_enum_variant)]
pub enum InformationToAccountOwner {
Plain(String),
Structured {
transaction_code: String,
posting_text: Option<String>,
prima_nota: Option<String>,
unknown11: Option<String>,
purpose: Option<String>,
applicant_bin: Option<String>,
applicant_iban: Option<String>,
applicant_name: Option<String>,
return_debit_notes: Option<String>,
recipient_name: Option<String>,
unknown38: Option<String>,
additional_purpose: Option<String>,
unknown70: Option<String>,
},
}
#[derive(Debug, Eq, PartialEq, Serialize, Deserialize)]
pub enum DebitOrCredit {
Debit,
Credit,
}
impl FromStr for DebitOrCredit {
type Err = VariantNotFound;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let dc = if s == "C" {
DebitOrCredit::Credit
} else if s == "D" {
DebitOrCredit::Debit
} else {
return Err(VariantNotFound(s.into()));
};
Ok(dc)
}
}
#[derive(Debug, Eq, PartialEq, Serialize, Deserialize)]
pub enum ExtDebitOrCredit {
Debit,
Credit,
ReverseDebit,
ReverseCredit,
}
impl FromStr for ExtDebitOrCredit {
type Err = VariantNotFound;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let dc = if s == "C" {
ExtDebitOrCredit::Credit
} else if s == "D" {
ExtDebitOrCredit::Debit
} else if s == "RD" {
ExtDebitOrCredit::ReverseCredit
} else if s == "RC" {
ExtDebitOrCredit::ReverseDebit
} else {
return Err(VariantNotFound(s.into()));
};
Ok(dc)
}
}
impl Message {
pub fn from_fields(fields: Vec<Field>) -> Result<Message, ParseError> {
let mut current_acceptable_tags: &[&str] = &["20"];
let known_tags = [
"20", "21", "25", "28", "28C", "60M", "60F", "61", "86", "62M", "62F", "64", "65",
];
let mut transaction_ref_no = None;
let mut ref_to_related_msg = None;
let mut account_id = None;
let mut statement_no = None;
let mut sequence_no = None;
let mut opening_balance = None;
let mut statement_lines = vec![];
let mut closing_balance = None;
let mut closing_available_balance = None;
let mut forward_available_balance = None;
let mut information_to_account_owner: Option<InformationToAccountOwner> = None;
let mut last_tag = String::default();
for field in fields {
debug!("Now parsing tag: {}", field.tag);
let current_acceptable_tags_owned = current_acceptable_tags
.iter()
.map(|x| x.to_string())
.collect();
if !known_tags.contains(&field.tag.as_str()) {
return Err(ParseError::UnknownTagError(field.tag));
}
if !current_acceptable_tags.contains(&field.tag.as_str()) {
return Err(UnexpectedTagError::new(
&field.tag,
&last_tag,
current_acceptable_tags_owned,
)
.into());
}
match field.tag.as_str() {
"20" => {
transaction_ref_no = Some(parse_20_tag(&field)?);
current_acceptable_tags = &["21", "25"];
}
"21" => {
ref_to_related_msg = Some(parse_21_tag(&field)?);
current_acceptable_tags = &["25"];
}
"25" => {
account_id = Some(parse_25_tag(&field)?);
current_acceptable_tags = &["28", "28C"];
}
"28" | "28C" => {
let res = parse_28_tag(&field)?;
statement_no = Some(res.0);
sequence_no = res.1;
current_acceptable_tags = &["60M", "60F"];
}
"60M" | "60F" => {
opening_balance = Some(parse_60_tag(&field)?);
current_acceptable_tags = &["61", "62M", "62F", "86"];
}
"61" => {
let statement_line = parse_61_tag(&field)?;
statement_lines.push(statement_line);
current_acceptable_tags = &["61", "86", "62M", "62F"];
}
"86" => {
let info_to_account_owner = parse_86_tag(&field)?;
match last_tag.as_str() {
"61" | "86" => {
if let Some(sl) = statement_lines.last_mut() {
match (&mut sl.information_to_account_owner, info_to_account_owner)
{
(None, info) => {
sl.information_to_account_owner = Some(info);
}
(
Some(InformationToAccountOwner::Plain(sl_info)),
InformationToAccountOwner::Plain(info),
) => {
sl_info.push_str(&info);
}
(
Some(InformationToAccountOwner::Plain(_)),
structured @ InformationToAccountOwner::Structured {
..
},
) => {
sl.information_to_account_owner = Some(structured);
}
_ => {}
}
}
}
"62M" | "62F" | "64" | "65" => {
match (&mut information_to_account_owner, info_to_account_owner) {
(None, info) => {
information_to_account_owner = Some(info);
}
(
Some(InformationToAccountOwner::Plain(ref mut current_info)),
InformationToAccountOwner::Plain(info),
) => {
current_info.push_str(&info);
}
(
Some(InformationToAccountOwner::Plain(_)),
structured @ InformationToAccountOwner::Structured { .. },
) => {
information_to_account_owner = Some(structured);
}
_ => {}
}
}
_ => (),
}
current_acceptable_tags = &["61", "62M", "62F", "86"];
}
"62M" | "62F" => {
closing_balance = Some(parse_62_tag(&field)?);
current_acceptable_tags = &["64", "65", "86"];
}
"64" => {
closing_available_balance = Some(parse_64_tag(&field)?);
current_acceptable_tags = &["65", "86"];
}
"65" => {
forward_available_balance = Some(parse_65_tag(&field)?);
current_acceptable_tags = &["65", "86"];
}
_ => (),
}
last_tag = field.tag;
}
let message = Message {
transaction_ref_no: transaction_ref_no
.ok_or_else(|| RequiredTagNotFoundError::new("20"))?,
ref_to_related_msg,
account_id: account_id.ok_or_else(|| RequiredTagNotFoundError::new("25"))?,
statement_no: statement_no.ok_or_else(|| RequiredTagNotFoundError::new("28C"))?,
sequence_no,
opening_balance: opening_balance.ok_or_else(|| RequiredTagNotFoundError::new("60"))?,
statement_lines,
closing_balance: closing_balance.ok_or_else(|| RequiredTagNotFoundError::new("62"))?,
closing_available_balance,
forward_available_balance,
information_to_account_owner,
};
Ok(message)
}
}
#[derive(Debug, Eq, PartialEq)]
pub struct Field {
pub tag: String,
pub value: String,
}
impl Field {
pub fn new(tag: &str, value: &str) -> Field {
Field {
tag: tag.to_string(),
value: value.to_string(),
}
}
}
impl FromStr for Field {
type Err = ParseError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let mut parsed_field = MT940Parser::parse(Rule::field, s)?;
let inner = parsed_field.next().unwrap().into_inner();
let tag = inner.clone().next().unwrap().into_inner().as_str();
let value = inner
.clone()
.nth(1)
.unwrap()
.as_str()
.trim()
.replace("\r\n", "\n");
let field = Field::new(tag, &value);
Ok(field)
}
}
pub fn parse_fields(statement: &str) -> Result<Vec<Field>, Box<pest::error::Error<Rule>>> {
let parsed_fields = MT940Parser::parse(Rule::fields, statement)?;
let mut fields = vec![];
for parsed_field in parsed_fields {
if let Rule::EOI = parsed_field.as_rule() {
break;
}
let inner = parsed_field.into_inner();
let tag = inner.clone().next().unwrap().into_inner().as_str();
let value = inner
.clone()
.nth(1)
.unwrap()
.as_str()
.trim()
.replace("\r\n", "\n");
let field = Field::new(tag, &value);
fields.push(field);
}
Ok(fields)
}
pub fn parse_mt940(statement: &str) -> Result<Vec<Message>, ParseError> {
let fields = parse_fields(statement)?;
if fields.is_empty() {
return Err(RequiredTagNotFoundError::new("20").into());
}
let mut fields_per_message = vec![];
let mut current_20_tag_index = -1i32;
for field in fields {
if field.tag == "20" {
current_20_tag_index += 1;
fields_per_message.push(vec![]);
}
if current_20_tag_index < 0 {
return Err(RequiredTagNotFoundError::new("20").into());
}
fields_per_message[current_20_tag_index as usize].push(field);
}
let mut messages = Vec::with_capacity(fields_per_message.len());
for mf in fields_per_message {
let m = Message::from_fields(mf)?;
messages.push(m);
}
Ok(messages)
}
#[cfg(test)]
mod tests {
use pretty_assertions::assert_eq;
use proptest::{prop_assert_eq, prop_assume, proptest};
use regex::Regex;
use super::*;
#[test]
fn parse_mt940_fields() {
let input = "ignored stuff in front
blah blah
:123:something\r\n\
:456:something else\r\n\
:789:even with\r\n\
new line\r\n\
like this\r\n\
:012:and then more stuff\r\n\
\r\n";
let expected = vec![
Field::new("123", "something"),
Field::new("456", "something else"),
Field::new("789", "even with\nnew line\nlike this"),
Field::new("012", "and then more stuff"),
];
let input_parsed = parse_fields(input).unwrap();
assert_eq!(expected, input_parsed);
}
proptest! {
#[test]
fn dont_crash(tag in "[[:alnum:]]+", value in r"[0-9A-Za-z/\-\?:\(\)\.,‘\+\{\} ]+") {
let re_no_ws_in_front_or_end = Regex::new(r"^[^\s]+(\s+[^\s]+)*$").unwrap();
prop_assume!(re_no_ws_in_front_or_end.is_match(&value), "Can't have a value that has whitespace in front or end");
let parsed = parse_fields(&format!(":{tag}:{value}")).unwrap();
prop_assert_eq!((&parsed[0].tag, &parsed[0].value), (&tag, &value));
}
}
}
#[cfg(doctest)]
doc_comment::doctest!("../README.md", README);