MT940
MT940 bank statements: accounts, opening and closing balances, and each transaction with its date, amount and counterparty.
Version 1.0.0 · 2 functions · 1 crate
Functions
What use mt940.qwer gives a program, with the types Quillo sees. To take only some, or under other names: use mt940_parse, mt940_read_file from mt940 (Quillo 0.6).
mt940_parse(text: str) → listor an errorThe statements in MT940 text: each { reference, account, number, opening, closing, available, transactions }. Balances are { date, currency, amount, cents }, amount signed text (-23.45, exact; dec(amount) for a decimal) and cents the same as a whole number. Transactions are { date, entry_date, amount, cents, reversal, type, reference, bank_reference, details, info, info_fields }: info is the :86: text, info_fields its parts when the bank structures it (/NAME/.../REMI/... or ?20...). SWIFT headers ({1:...}) and Windows line ends are taken care of.
mt940_read_file(path: str) → listor an errorThe statements of an MT940 file (.sta, .940, .txt), read as mt940_parse reads text; Latin-1 files are read too.
Get it
$ quillo install mt940 # mt940.qwer into this folder$ quillo install mt940 --example # and its example, mt940.qweOr with curl:
$ curl -O https://quillolang.org/plugins/files/mt940.qwer$ curl -O https://quillolang.org/plugins/files/mt940.qwe$ curl -O https://quillolang.org/plugins/files/mt940_sample.staThen, in your program, all of it or only what you call:
use mt940.qweruse mt940_parse, mt940_read_file from mt940 // Quillo 0.6: only theseIt uses this crate, which cargo fetches the first time the plugin is built:
regex = "1"Example
// A bookkeeping import of a bank's MT940 statement: balances, transactions and who each one was with.//// quillo run mt940.qwe then open http://localhost:8080/statementuse "mt940.qwer"route GET /statement { let s = mt940_read_file("mt940_sample.sta")?[0] { account: s.account, opening: s.opening.amount, closing: s.closing.amount, adds_up: s.opening.cents + sum(s.transactions | map((t) => t.cents)) == s.closing.cents, transactions: s.transactions | map((t) => { date: t.date, amount: t.amount, with: t.info_fields.NAME, about: t.info_fields.REMI }), }}route GET /raw = mt940_read_file("mt940_sample.sta")?[0].transactions[2]$ quillo run mt940.qwe$ curl 'localhost:8080/statement'$ curl 'localhost:8080/raw'Source
//! MT940 bank statements (SWIFT, as most European banks export them): accounts, opening and closing balances, and each transaction with its date, amount, counterparty and description, for bookkeeping imports.//! version: 1.0.0//!//! [dependencies]//! regex = "1"use regex::Regex;use serde_json::{json, Map, Value};use std::sync::OnceLock;/// Files up to 50 MB are read.const MAX: usize = 50 * 1024 * 1024;fn date(yymmdd: &str) -> String { format!("20{}-{}-{}", &yymmdd[0..2], &yymmdd[2..4], &yymmdd[4..6])}/// `1234,56` with its mark as signed text and cents.fn money(mark: &str, amount: &str) -> (String, i64) { let (whole, frac) = amount.split_once(',').unwrap_or((amount, "")); let frac2 = format!("{:0<2}", frac).chars().take(2).collect::<String>(); let cents = whole.trim_start_matches('0').parse::<i64>().unwrap_or(0) * 100 + frac2.parse::<i64>().unwrap_or(0); let neg = mark.ends_with('D') && mark != "RD" || mark == "RC"; let w = whole.trim_start_matches('0'); let text = format!("{}{}.{}", if neg { "-" } else { "" }, if w.is_empty() { "0" } else { w }, frac2); (text, if neg { -cents } else { cents })}fn balance(v: &str) -> Value { static B: OnceLock<Regex> = OnceLock::new(); let re = B.get_or_init(|| Regex::new(r"^([CD])([0-9]{6})([A-Z]{3})([0-9,]+)").expect("pattern")); match re.captures(v.trim()) { Some(c) => { let (text, cents) = money(&c[1], &c[4]); json!({ "date": date(&c[2]), "currency": &c[3], "amount": text, "cents": cents }) } None => Value::Null, }}/// The parts of a :86: text: `/KEY/value/KEY/value` (Dutch banks) or `?20...?32...` (German banks).fn info_fields(info: &str) -> Value { let flat = info.replace('\n', ""); let mut m = Map::new(); if flat.starts_with('/') { let parts: Vec<&str> = flat.split('/').collect(); let mut i = 1; while i + 1 < parts.len() { let key = parts[i].trim(); if !key.is_empty() && key.chars().all(|c| c.is_ascii_uppercase()) { m.insert(key.to_string(), Value::String(parts[i + 1].trim().to_string())); i += 2; } else { i += 1; } } } else if flat.contains("?2") || flat.contains("?3") { for piece in flat.split('?').skip(1) { if piece.len() >= 2 && piece[..2].chars().all(|c| c.is_ascii_digit()) { let e = m.entry(piece[..2].to_string()).or_insert_with(|| Value::String(String::new())); if let Value::String(s) = e { s.push_str(&piece[2..]); } } } } Value::Object(m)}fn transaction(line: &str) -> Option<Map<String, Value>> { static T: OnceLock<Regex> = OnceLock::new(); let re = T.get_or_init(|| Regex::new(r"(?s)^([0-9]{6})([0-9]{4})?(RC|RD|C|D)([A-Z])?([0-9,]+)([NFS][A-Z0-9]{3})([^\n]*?)(?://([^\n]*))?(?:\n(.*))?$").expect("pattern")); let c = re.captures(line.trim())?; let value_date = date(&c[1]); let entry_date = c.get(2).map(|m| { let md = m.as_str(); let mut year: i32 = c[1][0..2].parse::<i32>().unwrap_or(0) + 2000; // An entry dated in January for a value date in December is the next year, and the reverse. if &c[1][2..4] == "12" && &md[0..2] == "01" { year += 1; } if &c[1][2..4] == "01" && &md[0..2] == "12" { year -= 1; } format!("{}-{}-{}", year, &md[0..2], &md[2..4]) }); let (text, cents) = money(&c[3], &c[5]); let mut m = Map::new(); m.insert("date".into(), json!(value_date)); m.insert("entry_date".into(), json!(entry_date)); m.insert("amount".into(), json!(text)); m.insert("cents".into(), json!(cents)); m.insert("reversal".into(), json!(c[3].starts_with('R'))); m.insert("type".into(), json!(&c[6])); m.insert("reference".into(), json!(c[7].trim())); m.insert("bank_reference".into(), json!(c.get(8).map(|x| x.as_str().trim()))); m.insert("details".into(), json!(c.get(9).map(|x| x.as_str().trim()).filter(|s| !s.is_empty()))); Some(m)}/// The statements in MT940 text: each `{ reference, account, number, opening, closing, available, transactions }`./// Balances are `{ date, currency, amount, cents }`, `amount` signed text (`-23.45`, exact; `dec(amount)` for a decimal)/// and `cents` the same as a whole number. Transactions are `{ date, entry_date, amount, cents, reversal, type, reference,/// bank_reference, details, info, info_fields }`: `info` is the :86: text, `info_fields` its parts when the bank structures/// it (`/NAME/.../REMI/...` or `?20...`). SWIFT headers (`{1:...}`) and Windows line ends are taken care of.pub fn mt940_parse(text: &str) -> Result<Vec<Value>, String> { if text.len() > MAX { return Err("statements up to 50 MB".into()); } // Each tag starts a line with `:NN:` or `:NNa:`; the lines after it, up to the next tag, belong to it. let mut tags: Vec<(String, String)> = vec![]; for line in text.replace("\r\n", "\n").replace('\r', "\n").lines() { let l = line.trim_end(); if l.starts_with('{') || l == "-}" || l == "-" { if l == "-" || l == "-}" { tags.push(("-".into(), String::new())); } // A header line may end with the start of block 4: `{4:`. continue; } let tag = l.strip_prefix(':').and_then(|r| r.split_once(':')).filter(|(t, _)| t.len() <= 3 && t.chars().next().is_some_and(|c| c.is_ascii_digit())); match tag { Some((t, v)) => tags.push((t.to_string(), v.to_string())), None => { if let Some(last) = tags.last_mut() { last.1.push('\n'); last.1.push_str(l); } } } } let mut out = vec![]; let mut cur: Option<Map<String, Value>> = None; let mut txs: Vec<Value> = vec![]; let finish = |cur: &mut Option<Map<String, Value>>, txs: &mut Vec<Value>, out: &mut Vec<Value>| { if let Some(mut s) = cur.take() { s.insert("transactions".into(), Value::Array(std::mem::take(txs))); out.push(Value::Object(s)); } }; for (t, v) in tags { match t.as_str() { "20" => { finish(&mut cur, &mut txs, &mut out); let mut s = Map::new(); s.insert("reference".into(), json!(v.trim())); for k in ["account", "number", "opening", "closing", "available"] { s.insert(k.into(), Value::Null); } cur = Some(s); } "-" => finish(&mut cur, &mut txs, &mut out), _ => { let Some(s) = cur.as_mut() else { continue }; match t.as_str() { "25" => { s.insert("account".into(), json!(v.trim())); } "28C" | "28" => { s.insert("number".into(), json!(v.trim())); } "60F" | "60M" => { s.insert("opening".into(), balance(&v)); } "62F" | "62M" => { s.insert("closing".into(), balance(&v)); } "64" => { s.insert("available".into(), balance(&v)); } "61" => { if let Some(mut tx) = transaction(&v) { tx.insert("info".into(), Value::Null); tx.insert("info_fields".into(), json!({})); txs.push(Value::Object(tx)); } else { return Err(format!("`:61:{}` is not a transaction line", v.lines().next().unwrap_or(""))); } } "86" => { if let Some(Value::Object(tx)) = txs.last_mut() { tx.insert("info".into(), json!(v.trim())); tx.insert("info_fields".into(), info_fields(v.trim())); } } _ => {} } } } } finish(&mut cur, &mut txs, &mut out); if out.is_empty() { return Err("no statement (no :20: line) in the text".into()); } Ok(out)}/// The statements of an MT940 file (.sta, .940, .txt), read as mt940_parse reads text; Latin-1 files are read too.pub fn mt940_read_file(path: &str) -> Result<Vec<Value>, String> { let bytes = std::fs::read(path).map_err(|e| format!("{}: {}", path, e))?; let text = String::from_utf8(bytes).unwrap_or_else(|e| e.into_bytes().iter().map(|&b| b as char).collect()); mt940_parse(&text)}Related
iso20022.qwer · 1.0.0ISO 20022 bank files: camt.053 statements (and camt.052 and 054) read into balances, entries and each payment's counterparty and reference, and pain.001 SEPA credit transfers built with checked IBANs and exact totals.7 views · 6 downloadsOFX statements ofx.qwer · 1.0.0Bank and credit card statements in OFX and QFX, old SGML or XML: accounts, balances and each transaction with its bank id, ready to import without doubles.6 views · 8 downloadsFixed width fixed_width.qwer · 1.0.0Fixed-width records read and written from a column spec: bank payment files, payroll exports and mainframe formats.8 views · 8 downloadsQuicken QIF qif.qwer · 1.0.0Quicken QIF files read and written: bank, cash, card and investment accounts, AutoSwitch account lists, categories, classes, securities and memorized items. Split transactions checked to the cent, transfers and classes taken out of the category, dates in Quicken's forms such as 1/ 2'26, day or month first told from the file, and amounts with thousands separators of either kind.2 views · 7 downloadsAvro avro.qwer · 1.0.0Apache Avro data files: rows written with a schema, as Kafka, Hadoop and BigQuery use, and read back with the schema they carry.9 views · 6 downloadsBibTeX bibtex.qwer · 1.0.0BibTeX and BibLaTeX bibliographies read, with LaTeX accents and macros resolved; reference lists in APA or IEEE style, CSL-JSON for Zotero and pandoc, and entries written back.7 views · 6 downloadsThread 0 posts
On MT940: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.