OFX statements
Bank 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.
Version 1.0.0 · 4 functions · 0 crates
Functions
What use ofx.qwer gives a program, with the types Quillo sees. To take only some, or under other names: use ofx_parse, ofx_read_file from ofx (Quillo 0.6).
ofx_parse(text: str) → listor an errorThe statements in OFX or QFX text: [{ kind, currency, bank_id, branch_id, account, account_type, from, to, balance, available, transactions }], kind "bank" or "credit card", dates as "2026-10-15", balance { amount, cents, date }, and each transaction { id, type, date, user_date, amount, cents, name, memo, check, ref } (id is the bank's FITID: keep it to skip a transaction imported twice).
ofx_read_file(path: str) → listor an errorofx_parse of an .ofx or .qfx file (UTF-8, or Windows-1252 as older banks send).
ofx_transactions(path: str) → listor an errorEvery transaction of every statement in the file as one list of rows, each with its account and currency: for an import into a table.
ofx_tree(text: str) → anyor an errorThe whole OFX document as nested maps (tags as keys, repeated tags as lists, values as text): for what ofx_parse does not read, such as investment statements or the sign-on's status.
Get it
$ quillo install ofx # ofx.qwer into this folder$ quillo install ofx --example # and its example, ofx.qweOr with curl:
$ curl -O https://quillolang.org/plugins/files/ofx.qwer$ curl -O https://quillolang.org/plugins/files/ofx.qwe$ curl -O https://quillolang.org/plugins/files/ofx_sample.ofxThen, in your program, all of it or only what you call:
use ofx.qweruse ofx_parse, ofx_read_file from ofx // Quillo 0.6: only theseIt uses these crates, which cargo fetches the first time the plugin is built:
Example
// A bank's OFX download imported into the books: balances checked, transactions as rows, no transaction twice.//// quillo run ofx.qwe then open http://localhost:8080/statementsuse "ofx.qwer"route GET /statements = ofx_read_file("ofx_sample.ofx")? | map((s) => { kind: s.kind, account: s.account, from: s.from, to: s.to, balance: s.balance.amount, transactions: len(s.transactions), total_cents: sum(s.transactions | map((t) => t.cents)),})route GET /rows = ofx_transactions("ofx_sample.ofx")? | map((t) => { account: t.account, date: t.date, amount: t.amount, cents: t.cents, name: t.name, id: t.id })// The checking account's transactions, and the bank's own name from the sign-on, read from the tree.route GET /checking { let text = read_file("ofx_sample.ofx") let s = ofx_parse(text)?[0] { bank: ofx_tree(text)?.OFX.SIGNONMSGSRSV1.SONRS.FI.ORG, check: (s.transactions | filter((t) => t.type == "check"))[0], card_payment_in: (ofx_parse(text)?[1].transactions | filter((t) => t.cents > 0) | map((t) => t.name)) }}$ quillo run ofx.qwe$ curl 'localhost:8080/statements'$ curl 'localhost:8080/rows'$ curl 'localhost:8080/checking'Source
//! Bank statements in OFX and QFX (Quicken, Money, most banks' "download transactions"), SGML 1.x and XML 2.x: accounts, balances and every transaction, ready to import.//! version: 1.0.0//!//! [dependencies]use serde_json::{json, Map, Value};const MAX: usize = 64 * 1024 * 1024;struct Node { tag: String, text: Option<String>, kids: Vec<Node>,}impl Node { fn get(&self, tag: &str) -> Option<&Node> { self.kids.iter().find(|k| k.tag == tag) } fn all<'a>(&'a self, tag: &'a str) -> impl Iterator<Item = &'a Node> + 'a { self.kids.iter().filter(move |k| k.tag == tag) } fn text(&self, tag: &str) -> Option<String> { self.get(tag).and_then(|n| n.text.clone()) } fn path(&self, path: &[&str]) -> Option<&Node> { path.iter().try_fold(self, |n, t| n.get(t)) } /// Every node with this tag, at any depth. fn find<'a>(&'a self, tag: &str, out: &mut Vec<&'a Node>) { for k in &self.kids { if k.tag == tag { out.push(k); } else { k.find(tag, out); } } }}fn unescape(s: &str) -> String { s.replace("<", "<").replace(">", ">").replace(""", "\"").replace("'", "'").replace(" ", " ").replace("&", "&")}/// OFX 1.x leaves leaf tags unclosed (`<TRNAMT>-12.50`), 2.x closes them; both read the same way.fn parse(text: &str) -> Result<Node, String> { if text.len() > MAX { return Err("the file is over 64 MB".into()); } let start = text.find("<OFX>").ok_or("not OFX: no <OFX> element")?; let body = &text[start..]; let mut stack = vec![Node { tag: "#root".into(), text: None, kids: vec![] }]; let mut rest = body; while let Some(lt) = rest.find('<') { let gt = rest[lt..].find('>').ok_or("an unclosed `<`")? + lt; let tag = rest[lt + 1..gt].trim(); let after = &rest[gt + 1..]; let next = after.find('<').unwrap_or(after.len()); let value = after[..next].trim(); rest = &after[next..]; if tag.starts_with('?') || tag.starts_with('!') { continue; } if let Some(close) = tag.strip_prefix('/') { let close = close.trim().to_uppercase(); // A closing tag for a leaf that 2.x closes: already a leaf, nothing to pop. if stack.last().and_then(|n| n.kids.last()).is_some_and(|k| k.tag == close && k.text.is_some()) && stack.last().map(|n| n.tag != close).unwrap_or(true) { continue; } if let Some(pos) = stack.iter().rposition(|n| n.tag == close) { while stack.len() > pos { let n = stack.pop().unwrap(); stack.last_mut().ok_or("unbalanced tags")?.kids.push(n); } } continue; } let tag = tag.to_uppercase(); if value.is_empty() { stack.push(Node { tag, text: None, kids: vec![] }); } else { stack.last_mut().unwrap().kids.push(Node { tag, text: Some(unescape(value)), kids: vec![] }); } } while stack.len() > 1 { let n = stack.pop().unwrap(); stack.last_mut().unwrap().kids.push(n); } let root = stack.pop().unwrap(); root.kids.into_iter().find(|k| k.tag == "OFX").ok_or_else(|| "not OFX: no <OFX> element".into())}fn date(s: Option<String>) -> Value { match s { Some(s) if s.len() >= 8 && s[..8].bytes().all(|b| b.is_ascii_digit()) => Value::String(format!("{}-{}-{}", &s[..4], &s[4..6], &s[6..8])), _ => Value::Null, }}/// "-1,234.56", "12,50" and "+3.4" as text with a dot, and in cents.fn amount(s: Option<String>) -> (Value, Value) { let Some(s) = s else { return (Value::Null, Value::Null) }; let mut t: String = s.trim().chars().filter(|c| !c.is_whitespace()).collect(); if t.contains(',') && !t.contains('.') { t = t.replace(',', "."); } else { t = t.replace(',', ""); } let t = t.trim_start_matches('+').to_string(); let neg = t.starts_with('-'); let digits = t.trim_start_matches('-'); let (whole, frac) = digits.split_once('.').unwrap_or((digits, "")); let frac2: String = format!("{:0<2}", frac).chars().take(2).collect(); let cents = format!("{}{}", whole, frac2).parse::<i64>().ok().map(|c| if neg { -c } else { c }); (Value::String(t), cents.map(Value::from).unwrap_or(Value::Null))}fn balance(n: Option<&Node>) -> Value { match n { None => Value::Null, Some(b) => { let (a, c) = amount(b.text("BALAMT")); json!({ "amount": a, "cents": c, "date": date(b.text("DTASOF")) }) } }}fn transaction(t: &Node) -> Value { let (a, c) = amount(t.text("TRNAMT")); let name = t.text("NAME").or_else(|| t.path(&["PAYEE"]).and_then(|p| p.text("NAME"))); json!({ "id": t.text("FITID"), "type": t.text("TRNTYPE").map(|s| s.to_lowercase()), "date": date(t.text("DTPOSTED")), "user_date": date(t.text("DTUSER")), "amount": a, "cents": c, "name": name, "memo": t.text("MEMO"), "check": t.text("CHECKNUM"), "ref": t.text("REFNUM"), })}fn statements(ofx: &Node) -> Vec<Value> { let mut found = vec![]; ofx.find("STMTRS", &mut found); ofx.find("CCSTMTRS", &mut found); found .into_iter() .map(|s| { let card = s.tag == "CCSTMTRS"; let acct = s.get("BANKACCTFROM").or_else(|| s.get("CCACCTFROM")); let list = s.get("BANKTRANLIST"); json!({ "kind": if card { "credit card" } else { "bank" }, "currency": s.text("CURDEF"), "bank_id": acct.and_then(|a| a.text("BANKID")), "branch_id": acct.and_then(|a| a.text("BRANCHID")), "account": acct.and_then(|a| a.text("ACCTID")), "account_type": acct.and_then(|a| a.text("ACCTTYPE")).map(|t| t.to_lowercase()).or(if card { Some("creditline".into()) } else { None }), "from": date(list.and_then(|l| l.text("DTSTART"))), "to": date(list.and_then(|l| l.text("DTEND"))), "balance": balance(s.get("LEDGERBAL")), "available": balance(s.get("AVAILBAL")), "transactions": list.map(|l| l.all("STMTTRN").map(transaction).collect::<Vec<_>>()).unwrap_or_default(), }) }) .collect()}fn read(path: &str) -> Result<String, String> { let bytes = std::fs::read(path).map_err(|e| format!("{}: {}", path, e))?; // OFX 1.x files are often Windows-1252: read bytes that are not UTF-8 as Latin-1. Ok(String::from_utf8(bytes).unwrap_or_else(|e| e.into_bytes().iter().map(|&b| b as char).collect()))}fn tree(n: &Node) -> Value { if let Some(t) = &n.text { return Value::String(t.clone()); } let mut m = Map::new(); for k in &n.kids { let v = tree(k); match m.get_mut(&k.tag) { Some(Value::Array(a)) if n.kids.iter().filter(|x| x.tag == k.tag).count() > 1 => a.push(v), Some(old) => *old = Value::Array(vec![old.take(), v]), None => { m.insert(k.tag.clone(), v); } } } Value::Object(m)}/// The statements in OFX or QFX text: `[{ kind, currency, bank_id, branch_id, account, account_type, from, to, balance,/// available, transactions }]`, `kind` "bank" or "credit card", dates as "2026-10-15", `balance` `{ amount, cents, date }`,/// and each transaction `{ id, type, date, user_date, amount, cents, name, memo, check, ref }` (`id` is the bank's/// FITID: keep it to skip a transaction imported twice).pub fn ofx_parse(text: &str) -> Result<Vec<Value>, String> { Ok(statements(&parse(text)?))}/// `ofx_parse` of an .ofx or .qfx file (UTF-8, or Windows-1252 as older banks send).pub fn ofx_read_file(path: &str) -> Result<Vec<Value>, String> { ofx_parse(&read(path)?)}/// Every transaction of every statement in the file as one list of rows, each with its `account` and `currency`: for an/// import into a table.pub fn ofx_transactions(path: &str) -> Result<Vec<Value>, String> { let mut rows = vec![]; for s in ofx_read_file(path)? { for mut t in s["transactions"].as_array().cloned().unwrap_or_default() { t["account"] = s["account"].clone(); t["currency"] = s["currency"].clone(); rows.push(t); } } Ok(rows)}/// The whole OFX document as nested maps (tags as keys, repeated tags as lists, values as text): for what/// `ofx_parse` does not read, such as investment statements or the sign-on's status.pub fn ofx_tree(text: &str) -> Result<Value, String> { let ofx = parse(text)?; Ok(json!({ "OFX": tree(&ofx) }))}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 downloadsMT940 mt940.qwer · 1.0.0MT940 bank statements: accounts, opening and closing balances, and each transaction with its date, amount and counterparty.8 views · 6 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 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 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 OFX statements: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.