BAI2 bank statements
BAI2 bank statements read into groups, accounts, balances and transactions with their type codes, funds availability and continuation records, every control total and record count checked and a problem named by its line.
Version 1.0.0 · 5 functions · 1 crate
Functions
What use bai2.qwer gives a program, with the types Quillo sees. To take only some, or under other names: use bai2_type, bai2_parse from bai2 (Quillo 0.6).
bai2_type(code: int) → anyWhat a BAI type code is: { code, name, category }, category being status, credit, debit, loan, non-monetary or custom. 475 is "check paid", a debit.
bai2_parse(text: str) → anyor an errorReads a BAI2 file into { sender, receiver, date, groups: [{ as_of_date, accounts: [{ account, currency, opening_ledger, closing_ledger, opening_available, closing_available, summary, credits, debits, transactions }] }] }. Amounts are text with the currency's decimals; minor keeps them in cents. Any problem, a total or count that disagrees among them, is an error naming the line; bai2_check lists them all.
bai2_check(text: str) → anyEvery problem in a BAI2 file: { ok, problems, control_total }, each problem starting line N:.
bai2_read_file(path: str) → anyor an errorReads a BAI2 file from disk as bai2_parse does.
bai2_transactions(text: str) → listor an errorOne row per transaction across all groups and accounts: { account, currency, date, code, name, category, amount, signed, bank_ref, customer_ref, text, line }, what matching to invoices reads. signed is in cents, debits below zero.
Get it
$ quillo install bai2 # bai2.qwer into this folder$ quillo install bai2 --example # and its example, bai2.qweOr with curl:
$ curl -O https://quillolang.org/plugins/files/bai2.qwer$ curl -O https://quillolang.org/plugins/files/bai2.qwe$ curl -O https://quillolang.org/plugins/files/bai2_sample.txtThen, in your program, all of it or only what you call:
use bai2.qweruse bai2_type, bai2_parse from bai2 // Quillo 0.6: only theseIt uses this crate, which cargo fetches the first time the plugin is built:
serde_json = { version = "1", features = ["preserve_order"] }Example
// The morning's bank statement in BAI2: balances per account, transactions as rows for matching// to invoices, what a type code means, and what a total that does not add up looks like.//// quillo run bai2.qwe then open http://localhost:8080/statement, /rows, /type/475 and /tampered//// bai2_sample.txt is synthetic, from no bank. Totals in a BAI2 file are checked on reading: an// account's 49 against its balances and transactions, a group's 98 and the file's 99 against those.use "bai2.qwer"let TEXT = read_file("bai2_sample.txt")route GET /statement { let f = bai2_read_file("bai2_sample.txt")? let g = f.groups[0] { sender: f.sender, as_of: g.as_of_date, control_total: f.control_total, accounts: g.accounts | map((a) => { account: a.account, currency: a.currency, opening: a.opening_ledger, closing: a.closing_ledger, available: a.closing_available, credits: a.credits, debits: a.debits, count: len(a.transactions), }), }}route GET /rows = bai2_transactions(TEXT)? | map((t) => { code: t.code, name: t.name, signed: t.signed, ref: t.customer_ref, text: t.text })route GET /funds { let t = bai2_parse(TEXT)?.groups[0].accounts[0].transactions { value_dated: t[0].funds, distributed: t[1].funds, by_days: t[2].funds, line: t[0].line }}route GET /type/:code(int) = bai2_type(code)// A dollar taken off a transaction, and a file cut short: the reader says where.route GET /tampered { let bad = replace(TEXT, "16,475,100000,", "16,475,99900,") { first: bai2_parse(bad).error, all: bai2_check(bad).problems, cut: bai2_check(slice(TEXT, 0, 300)).problems, continued: bai2_check(TEXT).ok, }}$ quillo run bai2.qwe$ curl 'localhost:8080/statement'$ curl 'localhost:8080/rows'$ curl 'localhost:8080/funds'$ curl 'localhost:8080/type/475'$ curl 'localhost:8080/tampered'Source
//! BAI2 bank statements read: groups, accounts and transactions with their type codes, funds types and continuation records, each control total and record count checked, and problems named by line.//! version: 1.0.0//!//! [dependencies]//! serde_json = { version = "1", features = ["preserve_order"] }use serde_json::{json, Map, Value};const TYPES: &[(i64, &str)] = &[ (10, "opening ledger"), (11, "average opening ledger, month to date"), (12, "average opening ledger, year to date"), (15, "closing ledger"), (20, "average closing ledger, month to date"), (25, "average closing ledger, year to date"), (30, "current ledger"), (37, "ACH net position"), (39, "opening available and total same-day ACH"), (40, "opening available"), (41, "average opening available, month to date"), (45, "closing available"), (50, "average closing available, month to date"), (55, "average closing available, year to date"), (56, "loan balance"), (57, "total investment position"), (59, "current available, CRS suppressed"), (60, "current available"), (72, "one-day float"), (74, "two or more days float"), (75, "total float"), (100, "total credits"), (101, "total credit amount"), (102, "number of credit items"), (106, "funds transfer credits"), (108, "credit, any type"), (110, "total lockbox deposits"), (115, "lockbox deposit"), (140, "total ACH credits"), (142, "ACH credit received"), (143, "item in ACH deposit"), (145, "ACH concentration credit"), (165, "preauthorized ACH credit"), (169, "miscellaneous ACH credit"), (170, "other deposit"), (171, "individual loan deposit"), (172, "deposit corrected"), (174, "other deposit, foreign"), (175, "check deposit package"), (181, "individual check deposit"), (187, "cash letter credit"), (190, "total incoming money transfers"), (191, "incoming internal money transfer"), (195, "incoming money transfer"), (196, "money transfer adjustment"), (198, "compensation"), (201, "individual automatic transfer credit"), (206, "book transfer credit"), (208, "international money transfer credit"), (212, "letter of credit"), (250, "checks sold"), (251, "sold under repurchase"), (256, "ACH reversal"), (258, "ACH return credit"), (275, "ZBA credit"), (277, "ZBA float adjustment"), (295, "broker deposit"), (301, "commercial deposit"), (306, "interest credit"), (308, "interest received"), (354, "interest credit"), (357, "credit adjustment"), (395, "check reversal"), (398, "credit, fee adjustment"), (399, "miscellaneous credit"), (400, "total debits"), (401, "total debit amount"), (402, "number of debit items"), (406, "funds transfer debits"), (408, "debit, any type"), (409, "debit, any type"), (410, "total lockbox debits"), (430, "total payable-through drafts"), (435, "payable-through draft"), (440, "total ACH debits"), (446, "ACH credit reversal"), (450, "total ACH disbursements"), (451, "ACH debit received"), (452, "item in ACH disbursement"), (455, "preauthorized ACH debit"), (462, "account holder initiated ACH debit"), (466, "ACH settlement"), (468, "miscellaneous ACH debit"), (469, "miscellaneous ACH debit"), (470, "total check paid"), (472, "cumulative checks paid"), (474, "certified check debit"), (475, "check paid"), (476, "federal reserve bank letter debit"), (477, "bank originated debit"), (481, "individual loan payment"), (484, "draft"), (490, "total outgoing money transfers"), (491, "individual outgoing internal money transfer"), (495, "outgoing money transfer"), (496, "money transfer adjustment"), (498, "compensation"), (501, "individual automatic transfer debit"), (506, "book transfer debit"), (508, "international money transfer debit"), (555, "deposited item returned"), (557, "deposit adjustment"), (558, "ACH return debit"), (575, "ZBA debit"), (577, "ZBA float adjustment"), (595, "broker debit"), (616, "interest debit"), (624, "miscellaneous fee"), (695, "check fee"), (698, "miscellaneous fee"), (699, "miscellaneous debit"), (890, "non-monetary information"),];fn category(code: i64) -> &'static str { match code { 1..=99 => "status", 100..=399 => "credit", 400..=699 => "debit", 700..=799 => "loan", 890 => "non-monetary", 900..=919 => "custom status", 920..=959 => "custom credit", 960..=999 => "custom debit", _ => "unknown", }}fn named(code: i64) -> &'static str { TYPES.iter().find(|t| t.0 == code).map_or("", |t| t.1)}/// What a BAI type code is: `{ code, name, category }`, category being status, credit, debit, loan, non-monetary or custom. 475 is "check paid", a debit.pub fn bai2_type(code: i64) -> Value { json!({ "code": code, "name": named(code), "category": category(code) })}/// Decimal places of a currency, for reading amounts, which carry no point.fn places(ccy: &str) -> usize { match ccy { "BIF" | "CLP" | "DJF" | "GNF" | "ISK" | "JPY" | "KMF" | "KRW" | "PYG" | "RWF" | "UGX" | "VND" | "VUV" | "XAF" | "XOF" | "XPF" => 0, "BHD" | "IQD" | "JOD" | "KWD" | "LYD" | "OMR" | "TND" => 3, _ => 2, }}fn decimal(minor: i64, ccy: &str) -> String { let p = places(ccy); let a = minor.unsigned_abs(); let sign = if minor < 0 { "-" } else { "" }; if p == 0 { return format!("{}{}", sign, a); } let unit = 10u64.pow(p as u32); format!("{}{}.{:0w$}", sign, a / unit, a % unit, w = p)}fn amount(x: &str) -> Result<Option<i64>, String> { let t = x.trim(); if t.is_empty() { return Ok(None); } let body = t.strip_prefix(['+', '-']).unwrap_or(t); if body.is_empty() || !body.bytes().all(|b| b.is_ascii_digit()) { return Err(format!("amount `{}` is not digits", x)); } let n: i64 = body.parse().map_err(|_| format!("amount `{}` is too large", x))?; Ok(Some(if t.starts_with('-') { -n } else { n }))}struct Line<'a> { n: usize, code: &'a str, body: String, ended: bool,}/// One logical record's fields split by commas, the final `/` taken off.fn body_of(l: &str) -> (String, bool) { let t = l.trim_end(); match t.strip_suffix('/') { Some(b) => (b.to_string(), true), None => (t.to_string(), false), }}/// Walks a funds type from position `i` in `f`; gives `(value, next position)`. Z, 0, 1, 2 stand alone; V takes a date and time; S/// takes three amounts; D a count and pairs of a day and an amount.fn funds(f: &[&str], i: usize) -> Result<(Value, usize), String> { let t = f.get(i).copied().unwrap_or("").trim(); Ok(match t { "" | "Z" => (json!({ "type": "Z", "means": "unknown" }), i + 1), "0" => (json!({ "type": "0", "means": "immediate" }), i + 1), "1" => (json!({ "type": "1", "means": "one day" }), i + 1), "2" => (json!({ "type": "2", "means": "two or more days" }), i + 1), "V" => { if f.len() < i + 3 { return Err("funds type V needs a date and a time".into()); } (json!({ "type": "V", "means": "value dated", "date": f[i + 1], "time": f[i + 2] }), i + 3) } "S" => { if f.len() < i + 4 { return Err("funds type S needs three amounts".into()); } (json!({ "type": "S", "means": "distributed", "immediate": amount(f[i + 1])?, "one_day": amount(f[i + 2])?, "two_or_more_days": amount(f[i + 3])? }), i + 4) } "D" => { let n: usize = f.get(i + 1).and_then(|x| x.trim().parse().ok()).ok_or("funds type D needs a count")?; if f.len() < i + 2 + n * 2 { return Err(format!("funds type D says {} pairs, and fewer follow", n)); } let mut pairs = vec![]; for k in 0..n { pairs.push(json!({ "days": f[i + 2 + k * 2].trim().parse::<i64>().map_err(|_| "a D day is not a number")?, "amount": amount(f[i + 3 + k * 2])? })); } (json!({ "type": "D", "means": "distributed by days", "days": pairs }), i + 2 + n * 2) } o => return Err(format!("funds type `{}` is not Z, 0, 1, 2, V, S or D", o)), })}/// A transaction's fields as far as they go. `done` says the text field has begun, so an 88 adds to the text.struct Tx { code: i64, amount: Option<i64>, funds: Value, bank_ref: String, customer_ref: String, text: String, done: bool,}fn parse_tx(body: &str) -> Result<Tx, String> { let f: Vec<&str> = body.split(',').collect(); let code: i64 = f.get(1).and_then(|x| x.trim().parse().ok()).ok_or(format!("type code `{}` is not a number", f.get(1).unwrap_or(&"")))?; let amt = amount(f.get(2).copied().unwrap_or(""))?; if f.len() < 4 { return Ok(Tx { code, amount: amt, funds: Value::Null, bank_ref: String::new(), customer_ref: String::new(), text: String::new(), done: false }); } let (fv, next) = funds(&f, 3).or_else(|e| if f.len() < 8 { Ok((Value::Null, usize::MAX)) } else { Err(e) })?; if next == usize::MAX || f.len() < next + 3 { let bank = f.get(next).copied().unwrap_or("").trim().to_string(); let cust = f.get(next.wrapping_add(1)).copied().unwrap_or("").trim().to_string(); return Ok(Tx { code, amount: amt, funds: fv, bank_ref: bank, customer_ref: cust, text: String::new(), done: false }); } Ok(Tx { code, amount: amt, funds: fv, bank_ref: f[next].trim().to_string(), customer_ref: f[next + 1].trim().to_string(), text: f[next + 2..].join(",").trim().to_string(), done: true })}fn minor(v: Option<i64>) -> Value { v.map(Value::from).unwrap_or(Value::Null)}fn read(text: &str) -> (Value, Vec<String>) { let mut p: Vec<String> = vec![]; // Records with their continuations joined; a transaction's text may hold commas and slashes. let mut recs: Vec<Line> = vec![]; let raw: Vec<&str> = text.lines().collect(); for (i, l) in raw.iter().enumerate() { let n = i + 1; let t = l.trim_start_matches('\u{feff}').trim_end(); if t.is_empty() { continue; } let code = t.split(',').next().unwrap_or(""); if code == "88" { let Some(prev) = recs.last_mut() else { p.push(format!("line {}: a continuation with nothing before it", n)); continue; }; let (b, ended) = body_of(&t[2..]); let rest = b.strip_prefix(',').unwrap_or(&b); if prev.code == "16" { let open = parse_tx(&prev.body).map(|x| x.done).unwrap_or(false); if open { prev.body.push(' '); } else { prev.body.push(','); } prev.body.push_str(rest); } else { prev.body.push(','); prev.body.push_str(rest); } prev.ended = ended; continue; } if !["01", "02", "03", "16", "49", "98", "99"].contains(&code) { p.push(format!("line {}: record code `{}` is not a BAI2 record", n, code)); continue; } let (body, ended) = body_of(t); recs.push(Line { n, code: match code { "01" => "01", "02" => "02", "03" => "03", "16" => "16", "49" => "49", "98" => "98", _ => "99" }, body, ended }); } let mut file = Map::new(); let mut groups: Vec<Value> = vec![]; let mut group: Option<Map<String, Value>> = None; let mut accounts: Vec<Value> = vec![]; let mut account: Option<Map<String, Value>> = None; let mut txs: Vec<Value> = vec![]; let (mut acc_sum, mut acc_lines, mut grp_sum, mut grp_lines, mut file_sum, mut file_lines) = (0i64, 0i64, 0i64, 0i64, 0i64, 0i64); let mut finished = false; let (mut group_ccy, mut acc_ccy) = (String::from("USD"), String::from("USD")); let mut group_count = 0i64; let mut idx = 0; while idx < recs.len() { let r = &recs[idx]; let next_n = recs.get(idx + 1).map_or(raw.len() + 1, |x| x.n); let width = next_n - r.n; // this record and any blank or 88 lines after it let width = raw[r.n - 1..next_n - 1].iter().filter(|l| !l.trim().is_empty()).count().min(width) as i64; idx += 1; let n = r.n; let f: Vec<&str> = r.body.split(',').collect(); file_lines += width; grp_lines += width; acc_lines += width; if finished { p.push(format!("line {}: a record after the file trailer", n)); continue; } if !r.ended && r.code != "16" { p.push(format!("line {}: the record does not end with `/`", n)); } match r.code { "01" => { if n != 1 && !file.is_empty() { p.push(format!("line {}: a second file header", n)); } if f.len() < 9 { p.push(format!("line {}: a file header has 9 fields, this has {}", n, f.len())); continue; } file_lines = width; if f[8].trim() != "2" { p.push(format!("line {}: version `{}`; this reads version 2", n, f[8].trim())); } file.insert("sender".into(), json!(f[1].trim())); file.insert("receiver".into(), json!(f[2].trim())); file.insert("date".into(), json!(f[3].trim())); file.insert("time".into(), json!(f[4].trim())); file.insert("file_id".into(), json!(f[5].trim())); } "02" => { if file.is_empty() { p.push(format!("line {}: a group before the file header", n)); } if group.is_some() { p.push(format!("line {}: a group header inside a group", n)); } if f.len() < 7 { p.push(format!("line {}: a group header has at least 7 fields, this has {}", n, f.len())); continue; } let status = match f[3].trim() { "1" => "update", "2" => "deletion", "3" => "correction", "4" => "test only", o => { p.push(format!("line {}: group status `{}` is not 1 to 4", n, o)); "" } }; group_ccy = { let c = f[6].trim(); if c.is_empty() { "USD".to_string() } else { c.to_uppercase() } }; let mut g = Map::new(); g.insert("receiver".into(), json!(f[1].trim())); g.insert("originator".into(), json!(f[2].trim())); g.insert("status".into(), json!(status)); g.insert("as_of_date".into(), json!(f[4].trim())); g.insert("as_of_time".into(), json!(f[5].trim())); g.insert("currency".into(), json!(group_ccy)); g.insert("as_of_modifier".into(), json!(f.get(7).map_or("", |x| x.trim()))); group = Some(g); grp_lines = width; grp_sum = 0; accounts = vec![]; } "03" => { if group.is_none() { p.push(format!("line {}: an account outside a group", n)); } if account.is_some() { p.push(format!("line {}: an account inside an account: no 49 before it", n)); } if f.len() < 3 { p.push(format!("line {}: an account record needs its number and currency", n)); continue; } acc_ccy = { let c = f[2].trim(); if c.is_empty() { group_ccy.clone() } else { c.to_uppercase() } }; acc_sum = 0; acc_lines = width; txs = vec![]; let mut summary = vec![]; let mut i = 3; while i < f.len() && !(f[i].trim().is_empty() && i + 1 >= f.len()) { let code_s = f[i].trim(); if code_s.is_empty() { break; } let Ok(code) = code_s.parse::<i64>() else { p.push(format!("line {}: type code `{}` is not a number", n, code_s)); break; }; let amt = match amount(f.get(i + 1).copied().unwrap_or("")) { Ok(a) => a, Err(e) => { p.push(format!("line {}: {}", n, e)); None } }; let count = f.get(i + 2).map_or("", |x| x.trim()); let (fv, next) = match funds(&f, i + 3) { Ok(x) => x, Err(e) => { p.push(format!("line {}: {}", n, e)); (Value::Null, f.len()) } }; acc_sum += amt.unwrap_or(0); summary.push(json!({ "code": code, "name": named(code), "category": category(code), "minor": minor(amt), "amount": amt.map(|a| decimal(a, &acc_ccy)), "items": count.parse::<i64>().ok(), "funds": fv, })); i = next; } let bal = |c: i64| summary.iter().find(|s| s["code"] == c).and_then(|s| s.get("amount").cloned()).unwrap_or(Value::Null); let mut a = Map::new(); a.insert("account".into(), json!(f[1].trim())); a.insert("currency".into(), json!(acc_ccy)); a.insert("opening_ledger".into(), bal(10)); a.insert("closing_ledger".into(), bal(15)); a.insert("opening_available".into(), bal(40)); a.insert("closing_available".into(), bal(45)); a.insert("summary".into(), Value::Array(summary)); account = Some(a); } "16" => { if account.is_none() { p.push(format!("line {}: a transaction outside an account", n)); continue; } match parse_tx(&r.body) { Err(e) => p.push(format!("line {}: {}", n, e)), Ok(t) => { if category(t.code) == "status" { p.push(format!("line {}: type code {} is a balance, and belongs on the account record", n, t.code)); } if t.code >= 100 && t.code < 700 && t.amount.is_some_and(|a| a < 0) { p.push(format!("line {}: a transaction's amount carries no sign; its type says which way it goes", n)); } acc_sum += t.amount.unwrap_or(0); let signed = t.amount.map(|a| if category(t.code).ends_with("debit") { -a } else { a }); txs.push(json!({ "line": n, "code": t.code, "name": named(t.code), "category": category(t.code), "minor": minor(t.amount), "signed": minor(signed), "amount": t.amount.map(|a| decimal(a, &acc_ccy)), "funds": t.funds, "bank_ref": t.bank_ref, "customer_ref": t.customer_ref, "text": t.text, })); } } } "49" => { let Some(mut a) = account.take() else { p.push(format!("line {}: an account trailer with no account open", n)); continue; }; if f.len() < 3 { p.push(format!("line {}: an account trailer has a total and a record count", n)); continue; } match amount(f[1]) { Ok(Some(t)) if t == acc_sum => {} Ok(t) => p.push(format!("line {}: account {}, control total {} should be {}", n, a["account"].as_str().unwrap_or(""), t.unwrap_or(0), acc_sum)), Err(e) => p.push(format!("line {}: {}", n, e)), } if f[2].trim().parse::<i64>().ok() != Some(acc_lines) { p.push(format!("line {}: account {}, record count {} should be {}", n, a["account"].as_str().unwrap_or(""), f[2].trim(), acc_lines)); } let credits: i64 = txs.iter().filter(|t| t["category"].as_str().unwrap_or("").ends_with("credit")).filter_map(|t| t["minor"].as_i64()).sum(); let debits: i64 = txs.iter().filter(|t| t["category"].as_str().unwrap_or("").ends_with("debit")).filter_map(|t| t["minor"].as_i64()).sum(); a.insert("credits".into(), json!(decimal(credits, &acc_ccy))); a.insert("debits".into(), json!(decimal(debits, &acc_ccy))); a.insert("control_total".into(), json!(acc_sum)); a.insert("transactions".into(), Value::Array(std::mem::take(&mut txs))); accounts.push(Value::Object(a)); grp_sum += acc_sum; acc_lines = 0; } "98" => { if account.is_some() { p.push(format!("line {}: the group ends inside an account", n)); account = None; } let Some(mut g) = group.take() else { p.push(format!("line {}: a group trailer with no group open", n)); continue; }; if f.len() < 4 { p.push(format!("line {}: a group trailer has a total, an account count and a record count", n)); continue; } match amount(f[1]) { Ok(Some(t)) if t == grp_sum => {} Ok(t) => p.push(format!("line {}: group control total {} should be {}", n, t.unwrap_or(0), grp_sum)), Err(e) => p.push(format!("line {}: {}", n, e)), } if f[2].trim().parse::<usize>().ok() != Some(accounts.len()) { p.push(format!("line {}: group says {} accounts, and has {}", n, f[2].trim(), accounts.len())); } if f[3].trim().parse::<i64>().ok() != Some(grp_lines) { p.push(format!("line {}: group record count {} should be {}", n, f[3].trim(), grp_lines)); } g.insert("control_total".into(), json!(grp_sum)); g.insert("accounts".into(), Value::Array(std::mem::take(&mut accounts))); groups.push(Value::Object(g)); file_sum += grp_sum; group_count += 1; } _ => { if group.is_some() { p.push(format!("line {}: the file ends inside a group", n)); group = None; } if f.len() < 4 { p.push(format!("line {}: a file trailer has a total, a group count and a record count", n)); continue; } match amount(f[1]) { Ok(Some(t)) if t == file_sum => {} Ok(t) => p.push(format!("line {}: file control total {} should be {}", n, t.unwrap_or(0), file_sum)), Err(e) => p.push(format!("line {}: {}", n, e)), } if f[2].trim().parse::<i64>().ok() != Some(group_count) { p.push(format!("line {}: file says {} groups, and has {}", n, f[2].trim(), group_count)); } if f[3].trim().parse::<i64>().ok() != Some(file_lines) { p.push(format!("line {}: file record count {} should be {}", n, f[3].trim(), file_lines)); } finished = true; } } } if !finished { p.push(format!("line {}: the file has no trailer (99): it may be cut short", raw.len().max(1))); } file.insert("control_total".into(), json!(file_sum)); file.insert("groups".into(), Value::Array(groups)); (Value::Object(file), p)}/// Reads a BAI2 file into `{ sender, receiver, date, groups: [{ as_of_date, accounts: [{ account, currency, opening_ledger, closing_ledger, opening_available, closing_available, summary, credits, debits, transactions }] }] }`. Amounts are text with the currency's decimals; `minor` keeps them in cents. Any problem, a total or count that disagrees among them, is an error naming the line; `bai2_check` lists them all.pub fn bai2_parse(text: &str) -> Result<Value, String> { let (v, p) = read(text); match p.into_iter().next() { Some(first) => Err(first), None => Ok(v), }}/// Every problem in a BAI2 file: `{ ok, problems, control_total }`, each problem starting `line N:`.pub fn bai2_check(text: &str) -> Value { let (v, p) = read(text); json!({ "ok": p.is_empty(), "problems": p, "control_total": v["control_total"] })}/// Reads a BAI2 file from disk as `bai2_parse` does.pub fn bai2_read_file(path: &str) -> Result<Value, String> { bai2_parse(&std::fs::read_to_string(path).map_err(|e| format!("{}: {}", path, e))?)}/// One row per transaction across all groups and accounts: `{ account, currency, date, code, name, category, amount, signed, bank_ref, customer_ref, text, line }`, what matching to invoices reads. `signed` is in cents, debits below zero.pub fn bai2_transactions(text: &str) -> Result<Vec<Value>, String> { let v = bai2_parse(text)?; let mut out = vec![]; for g in v["groups"].as_array().into_iter().flatten() { for a in g["accounts"].as_array().into_iter().flatten() { for t in a["transactions"].as_array().into_iter().flatten() { out.push(json!({ "account": a["account"], "currency": a["currency"], "date": g["as_of_date"], "code": t["code"], "name": t["name"], "category": t["category"], "amount": t["amount"], "signed": t["signed"], "bank_ref": t["bank_ref"], "customer_ref": t["customer_ref"], "text": t["text"], "line": t["line"], })); } } } Ok(out)}Related
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 downloadsISO 20022 bank files 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 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 downloadsNACHA ACH files nacha.qwer · 1.0.0US ACH files: fixed-width batches built with entry hash, control totals, trace numbers and padding to a block of ten, and files read back with routing check digits and totals checked, each problem naming its line, returns and notifications of change included.5 views · 6 downloadsThread 0 posts
On BAI2 bank statements: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.